Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,6 @@ jobs:
- name: Build core
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
EXOJS_FULL_BUNDLE: '1'
run: pnpm build

- name: Build extension packages
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ npm run dev
npm install @codexo/exojs
```

ExoJS ships as ESM — use `import` syntax with a modern bundler or runtime. A prebuilt IIFE bundle (`dist/exo.iife.js`, global `Exo`) is included for CDN and script-tag usage.
ExoJS ships as ESM — use `import` syntax with a modern bundler or runtime. Prebuilt IIFE bundles are included for CDN and script-tag usage: `dist/exo.iife.js` (the core) and `dist/exo.full.iife.js` (core plus every extension except React), both on the global `Exo`.
Optional packages install independently — add only what your project needs:

```bash
Expand Down
9 changes: 9 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@
"dist/exo.iife.js.map",
"dist/exo.iife.min.js",
"dist/exo.iife.min.js.map",
"dist/exo.full.iife.js",
"dist/exo.full.iife.js.map",
"dist/exo.full.iife.min.js",
"dist/exo.full.iife.min.js.map",
"README.md",
"CHANGELOG.md",
"LICENSE"
Expand Down Expand Up @@ -208,6 +212,11 @@
"limit": "340 KB",
"gzip": true
},
{
"path": "dist/exo.full.iife.min.js",
"limit": "550 KB",
"gzip": true
},
{
"path": "dist/exo.debug.esm.js",
"limit": "5 KB",
Expand Down
24 changes: 10 additions & 14 deletions scripts/build.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Builds the core package: `exo.esm.js`, `exo.debug.esm.js`, the preserveModules
* `dist/esm` tree (with declarations), `exo.iife.js`, and (production only)
* `exo.iife.min.js`.
* `exo.iife.min.js`, `exo.full.iife.js` and `exo.full.iife.min.js`.
*
* Bundling runs on Rolldown; declarations are a separate `tsc
* --emitDeclarationOnly` pass over `dist/esm`, since Rolldown has no
Expand All @@ -11,11 +11,14 @@
* inner dev loop this mode serves, and `pnpm typecheck` already covers type
* correctness.
*
* `EXOJS_FULL_BUNDLE=1` additionally builds the opt-in all-in-one IIFE bundle
* (core + every extension package), transpiling TypeScript source across
* multiple rootDirs (src/ and each extension package's src/) - Rolldown's
* built-in transpiler has no single-Program rootDir constraint, so this needs
* no separate esbuild-based path the way the previous Rollup pipeline did.
* The production build also emits the all-in-one IIFE bundle (`exo.full.iife.js`
* and its minified twin: core + every extension package except react, whose
* peer cannot ship in a script-tag bundle), transpiling TypeScript source
* across multiple rootDirs (src/ and each extension package's src/) -
* Rolldown's built-in transpiler has no single-Program rootDir constraint, so
* this needs no separate esbuild-based path the way the previous Rollup
* pipeline did. The dev build and watch mode skip it: it is the slowest job of
* the set and nothing in the inner loop reads it.
*/
import { spawnSync } from 'node:child_process';
import { dirname, relative as relativePath, resolve as resolvePath } from 'node:path';
Expand Down Expand Up @@ -227,19 +230,12 @@ if (watchMode) {
} else {
const jobs =
buildMode === 'production'
? [bundled(true), debugBundled(true), modules(), iife(false), iife(true)]
? [bundled(true), debugBundled(true), modules(), iife(false), iife(true), fullBundle(false), fullBundle(true)]
: [bundled(true), debugBundled(true), modules(), iife(false)];

for (const job of jobs) {
await runJob(job);
}
await emitDeclarations();
writeSourceStamp(resolvePath(rootDir, 'src'), resolvePath(rootDir, 'dist'));

if (process.env.EXOJS_FULL_BUNDLE === '1') {
const fullBundleJobs = buildMode === 'production' ? [fullBundle(false), fullBundle(true)] : [fullBundle(false)];
for (const job of fullBundleJobs) {
await runJob(job);
}
}
}
56 changes: 42 additions & 14 deletions scripts/release/full-zip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
*
* A self-contained, offline-servable snapshot of a coordinated release:
*
* npm/ the four official tarballs (Core, Particles, Tilemap, Tiled)
* vendor/ each package's ESM tree (exojs, exojs-particles, exojs-tilemap, exojs-tiled)
* npm/ every lockstep tarball (Core + extensions)
* vendor/ every lockstep package's ESM tree, plus Core's single-file
* bundles (exo.esm.js, the IIFE pair, the full IIFE pair)
* examples/ src/** (TS), js/** (transpiled), assets/**, examples.json
* site/ the built static site (itself servable; references ./vendor + ./examples)
* README.md CHANGELOG.md LICENSE release-manifest.json checksums.sha256
Expand All @@ -18,6 +19,7 @@ import { basename, dirname, join, relative, resolve } from 'node:path';
import { ModuleKind, ScriptTarget, transpileModule } from 'typescript';

import type { CommandRunner } from './command-runner.ts';
import { LOCKSTEP_PACKAGES } from './lockstep-packages.ts';
import { type ReleaseManifest, renderChecksums, serializeManifest, sha256File } from './manifest.ts';

export interface AssembleOptions {
Expand All @@ -32,15 +34,30 @@ export interface AssembleOptions {
manifest: ReleaseManifest;
}

const VENDOR_PACKAGES = [
{ name: 'exojs', vendorDir: 'exojs' },
{ name: 'exojs-particles', vendorDir: 'exojs-particles' },
{ name: 'exojs-tilemap', vendorDir: 'exojs-tilemap' },
{ name: 'exojs-tiled', vendorDir: 'exojs-tiled' },
{ name: 'exojs-physics', vendorDir: 'exojs-physics' },
{ name: 'exojs-audio-fx', vendorDir: 'exojs-audio-fx' },
/**
* Core's single-file bundles, shipped under `vendor/exojs/` beside the ESM
* tree so the archive serves a script tag as well as an import map. The list
* mirrors the bundle entries of the root `package.json#files`; the test suite
* pins the two together.
*/
export const CORE_BUNDLE_FILES = [
'exo.esm.js',
'exo.esm.js.map',
'exo.debug.esm.js',
'exo.debug.esm.js.map',
'exo.iife.js',
'exo.iife.js.map',
'exo.iife.min.js',
'exo.iife.min.js.map',
'exo.full.iife.js',
'exo.full.iife.js.map',
'exo.full.iife.min.js',
'exo.full.iife.min.js.map',
] as const;

/** `vendor/<dir>` for a lockstep package: the npm name without its scope. */
export const vendorDirFor = (packageName: string): string => packageName.replace(/^@codexo\//, '');

const FORBIDDEN_PATTERNS: { label: string; test: (text: string) => boolean }[] = [
{ label: 'workspace: specifier', test: t => t.includes('workspace:') },
{ label: '@assets alias', test: t => /['"`]@assets['"`/]/.test(t) },
Expand Down Expand Up @@ -204,13 +221,24 @@ export const assembleFullReleaseTree = (options: AssembleOptions): AssembleResul
copyFile(resolve(options.stagingDir, record.file), npmOut);
}

// vendor/ - each package's ESM tree, taken from the built site's vendor dir.
for (const { name, vendorDir } of VENDOR_PACKAGES) {
const from = resolve(options.siteDistDir, 'vendor', vendorDir, 'esm');
// vendor/ - every lockstep package's ESM tree, read from the package's own
// dist so the set cannot drift from the release matrix, plus Core's bundles.
for (const pkg of LOCKSTEP_PACKAGES) {
const distDir = resolve(options.rootDir, pkg.dir, 'dist');
const from = join(distDir, 'esm');
if (!existsSync(from)) {
throw new Error(`[full-zip] Missing vendored ESM for ${name} at ${from}. Run "pnpm site:build" first.`);
throw new Error(`[full-zip] Missing built ESM for ${pkg.name} at ${from}. Build every lockstep package first.`);
}
const vendorDir = join(treeDir, 'vendor', vendorDirFor(pkg.name));
cpSync(from, join(vendorDir, 'esm'), { recursive: true });
if (pkg.isExtension) continue;
for (const file of CORE_BUNDLE_FILES) {
const bundle = join(distDir, file);
if (!existsSync(bundle)) {
throw new Error(`[full-zip] Missing Core bundle ${file} at ${bundle}. Run a production "pnpm build" first.`);
}
copyFile(bundle, vendorDir);
}
cpSync(from, join(treeDir, 'vendor', vendorDir, 'esm'), { recursive: true });
}

// examples/ - src/** (TS), js/** (transpiled), assets/**, examples.json.
Expand Down
4 changes: 2 additions & 2 deletions scripts/release/lockstep-packages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
* directory instead and need no edit.
* - `scripts/ci/select-lanes.ts` RUNTIME_PACKAGES (dependency-free ESM that
* runs before any install, so it cannot import this TS module).
* - `site/scripts/sync-exo-vendor.ts` / `full-zip.ts` vendor tree - a smaller,
* site-owned set (the offline examples site only embeds packages it uses).
* - `site/scripts/sync-exo-vendor.ts` vendor tree - a smaller, site-owned
* set (the offline examples site only embeds packages it uses).
*/

/** Order is canonical PUBLISH_ORDER: Core first (peer of every extension), then extensions. */
Expand Down
9 changes: 9 additions & 0 deletions site/src/content/guide/shipping/deployment.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,15 @@ If you don't have any module-aware setup — no bundler, no import maps, just a

There is no `import` statement anywhere in this path — `Exo` is already in scope by the time the second `<script>` block runs, as long as it comes after the bundle's `<script>` tag. A minified build for production is available at `dist/exo.iife.min.js`; swap the `src` to use it.

The IIFE above is the core alone. Extensions are not available as separate script tags; the zero-build path to them is the full bundle, `dist/exo.full.iife.js` (minified: `dist/exo.full.iife.min.js`), which carries the core and every extension except React on the same `Exo` global. It is a single, considerably larger file, so use it when you actually reach for an extension, and the core bundle otherwise:

```html
<script src="https://cdn.jsdelivr.net/npm/@codexo/exojs/dist/exo.full.iife.min.js"></script>
<script>
const { Application, ParticleSystem, PointLight } = Exo;
</script>
```

Prefer the ES module bundle when your host supports import maps and you want the browser to load only what you use. Prefer the IIFE bundle when you need a true zero-build setup — for example a single static HTML file, a CMS or forum post that only allows pasting a `<script>` tag, or any environment without import-map support.

## MIME types
Expand Down
22 changes: 20 additions & 2 deletions test/release/full-zip.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { join, resolve } from 'node:path';

import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import { scanForbiddenContent, writeSiteServer } from '../../scripts/release/full-zip';
import { CORE_BUNDLE_FILES, scanForbiddenContent, vendorDirFor, writeSiteServer } from '../../scripts/release/full-zip';
import { LOCKSTEP_PACKAGES } from '../../scripts/release/lockstep-packages';

let tree: string;

Expand All @@ -20,6 +21,23 @@ beforeEach(() => {

afterEach(() => rmSync(tree, { recursive: true, force: true }));

describe('vendor tree', () => {
it('ships exactly the single-file bundles the npm tarball ships', () => {
const files = (JSON.parse(readFileSync(resolve(import.meta.dirname!, '..', '..', 'package.json'), 'utf8')) as { files: string[] }).files;
const bundles = files.filter(f => f.startsWith('dist/') && !f.endsWith('/')).map(f => f.slice('dist/'.length));

expect([...CORE_BUNDLE_FILES].sort()).toEqual([...bundles].sort());
});

it('gives every lockstep package a distinct vendor directory', () => {
const dirs = LOCKSTEP_PACKAGES.map(p => vendorDirFor(p.name));

expect(new Set(dirs).size).toBe(LOCKSTEP_PACKAGES.length);
expect(dirs).toContain('exojs');
expect(dirs.every(d => !d.includes('/') && !d.startsWith('@'))).toBe(true);
});
});

describe('scanForbiddenContent', () => {
it('passes a clean tree (compiled ESM + TS example sources, no forbidden patterns)', () => {
write('vendor/exojs/esm/index.js', "export * from './core/index.js';\n");
Expand Down
Loading