diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc1377d7a..e8b0069a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/README.md b/README.md index ecdb00281..ceec38644 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/package.json b/package.json index caf32fc8c..2e1ac96f5 100644 --- a/package.json +++ b/package.json @@ -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" @@ -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", diff --git a/scripts/build.ts b/scripts/build.ts index 11f2792e6..116ef01fd 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -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 @@ -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'; @@ -227,7 +230,7 @@ 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) { @@ -235,11 +238,4 @@ if (watchMode) { } 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); - } - } } diff --git a/scripts/release/full-zip.ts b/scripts/release/full-zip.ts index a59310484..6ad889070 100644 --- a/scripts/release/full-zip.ts +++ b/scripts/release/full-zip.ts @@ -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 @@ -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 { @@ -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/` 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) }, @@ -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. diff --git a/scripts/release/lockstep-packages.ts b/scripts/release/lockstep-packages.ts index 06058ebcd..7f0c8c8aa 100644 --- a/scripts/release/lockstep-packages.ts +++ b/scripts/release/lockstep-packages.ts @@ -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. */ diff --git a/site/src/content/guide/shipping/deployment.mdx b/site/src/content/guide/shipping/deployment.mdx index 29e4d38dd..ce0e23e6e 100644 --- a/site/src/content/guide/shipping/deployment.mdx +++ b/site/src/content/guide/shipping/deployment.mdx @@ -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 ` + +``` + 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 `