diff --git a/.changeset/smaller-markdown-and-vscode-packages.md b/.changeset/smaller-markdown-and-vscode-packages.md new file mode 100644 index 000000000..5e4c108ac --- /dev/null +++ b/.changeset/smaller-markdown-and-vscode-packages.md @@ -0,0 +1,19 @@ +--- +"@hyperbook/types": minor +"@hyperbook/fs": minor +"@hyperbook/markdown": minor +"hyperbook": patch +"hyperbook-studio": minor +--- + +Keep the Markdown npm package and the VS Code extension small by leaving out large element runtimes. + +The Markdown package no longer includes any of the eight large element runtimes; it keeps their integration scripts and styles. With `cdn: true`, Blockflow now loads from `blockflow.openpatch.org` and Excalidraw from its own packages on UNPKG. OpenSCAD, Online IDE and SQL IDE use independent, pinned releases on `cdn.openpatch.org`. Their owning repositories publish immutable runtime files to Cloudflare R2, and Hyperbook verifies the public CDN files before publishing a release. + +PyIDE now uses Pyodide's versioned jsDelivr CDN by default because its full local distribution is very large. Builds and asset prefetching skip downloading this runtime unless `elements.pyide.cdn` is explicitly `false`. Set it to `false` for self-hosted and offline books, and prefetch the runtime before an offline build. The documentation explains this default and how to cache runtime downloads in CI and assets on your hosting service. + +The VS Code extension no longer includes any of the eight large runtimes. Its preview resolves them like a Hyperbook build: elements with a `cdn` setting load from that CDN, and the others use the runtimes in the Hyperbook CLI's asset cache, which the extension now shares. Runtimes that are not downloaded yet load from their default CDN, and the preview offers to download them. The new commands "Download Element Runtimes..." and "Download All Element Runtimes" work like `hyperbook assets fetch` and `hyperbook assets fetch --all`. + +`@hyperbook/fs` now provides `AssetManager` and `assetCacheDir`, which the CLI and the VS Code extension share. `@hyperbook/types` exports `elementRuntimeFiles`, the files that show an element's runtime is available locally. + +The built Markdown ESM package also supports direct Node imports while remaining compatible with the CLI and extension bundles. diff --git a/.github/workflows/changeset-version.yml b/.github/workflows/changeset-version.yml index 61c7a3444..f45aa04ad 100644 --- a/.github/workflows/changeset-version.yml +++ b/.github/workflows/changeset-version.yml @@ -13,7 +13,11 @@ permissions: packages: write jobs: + publish-openscad-cdn: + uses: ./.github/workflows/publish-openscad-cdn.yml + secrets: inherit release: + needs: publish-openscad-cdn name: Release runs-on: ubuntu-latest steps: @@ -43,6 +47,11 @@ jobs: run: | pnpm --filter "hyperbook-studio" vscode:package + - name: Verify default CDN runtimes before publishing + # CDN previews must work before any consumer package can be published. + # Validate all files against the locally built runtime bundles. + run: node scripts/verify-default-cdns.mjs + - name: Publish CLI asset bundles before npm run: node packages/hyperbook/publish-assets.mjs env: diff --git a/.github/workflows/publish-openscad-cdn.yml b/.github/workflows/publish-openscad-cdn.yml new file mode 100644 index 000000000..4fd3eea5a --- /dev/null +++ b/.github/workflows/publish-openscad-cdn.yml @@ -0,0 +1,44 @@ +name: Publish OpenSCAD runtime to R2 +on: + workflow_dispatch: + workflow_call: +permissions: + contents: read +concurrency: + group: cdn-openscad + cancel-in-progress: false +jobs: + publish: + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_R2_CDN }} + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID || secrets.CLOUDFLARE_ACCOUNT_ID }} + WRANGLER_SEND_METRICS: "false" + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v6 + with: + version: 11.3.0 + - uses: actions/setup-node@v6 + with: + node-version: "24" + cache: pnpm + - name: Install runtime downloader dependencies + run: pnpm install --frozen-lockfile --filter @hyperbook/markdown... + - run: pnpm test:cdn + - name: Install Wrangler + run: npm install --global wrangler@4.149.0 + - name: Check Cloudflare configuration + run: | + test -n "$CLOUDFLARE_API_TOKEN" || { echo 'Missing CLOUDFLARE_R2_CDN Actions secret'; exit 1; } + test -n "$CLOUDFLARE_ACCOUNT_ID" || { echo 'Missing CLOUDFLARE_ACCOUNT_ID Actions variable or secret'; exit 1; } + - name: Prepare upstream WASM, fonts and libraries + run: node scripts/prepare-openscad-cdn.mjs + - name: Allow browser access to runtime files + run: wrangler r2 bucket cors set cdn --file scripts/cdn-cors.json --force + - name: Publish and verify immutable runtime files + run: | + version=$(node -p 'JSON.parse(require("fs").readFileSync("packages/markdown/openscad-config.json", "utf8")).wasmBuild.cdnVersion') + node scripts/publish-cdn.mjs openscad "$version" .cache/cdn-openscad/runtime + node scripts/verify-cdn.mjs openscad "$version" .cache/cdn-openscad/runtime diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 6af7fe848..54fb89f80 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -24,3 +24,5 @@ jobs: run: pnpm build - name: Test packages run: pnpm test + - name: Test CDN publishing + run: pnpm test:cdn diff --git a/docs/cdn-releases.md b/docs/cdn-releases.md new file mode 100644 index 000000000..c8654454e --- /dev/null +++ b/docs/cdn-releases.md @@ -0,0 +1,89 @@ +# Runtime releases on cdn.openpatch.org + +Cloudflare R2 bucket `cdn` serves immutable runtime releases through the custom +domain `cdn.openpatch.org`. Each repository owns its release uploads: + +| Repository | Object prefix | Trigger | +| ---------------------- | ---------------------------------- | ------------------------------------------------------------- | +| `openpatch/online-ide` | `onlineide//include/` | Published GitHub release, after attaching `dist-embedded.zip` | +| `openpatch/sql-ide` | `sqlide//include/` | Published GitHub release, after attaching `dist-embedded.zip` | +| `openpatch/hyperbook` | `openscad//` | Hyperbook release workflow, before publishing packages | + +All workflows use Wrangler and the Cloudflare API token in the organization +Actions secret `CLOUDFLARE_R2_CDN`. Set `CLOUDFLARE_ACCOUNT_ID` as an organization +Actions variable or secret. Grant both to the publishing repositories. S3 access +keys are unnecessary. The token needs **Account → Workers R2 Storage → Edit** +(the R2 token permission **Admin Read & Write**) for the account holding `cdn`. +Cloudflare's bucket-scoped **Object Read & Write** permission supports only the +S3 API, so it cannot authenticate these Wrangler operations. See +[Cloudflare's permission documentation](https://developers.cloudflare.com/r2/api/tokens/#permissions). + +The IDE publishers download the ZIP already attached to the release and verify +GitHub's SHA-256 digest. HTML examples and source maps are excluded. OpenSCAD is +prepared directly from the upstream WASM distribution and the pinned font and +library downloads in `packages/markdown/runtime-assets.json`, with SHA-256 +verification. It does not read a published Markdown package. + +Every object gets its correct MIME type and +`Cache-Control: public, max-age=31536000, immutable`. The publisher retains old +versions, refuses conflicting files, resumes interrupted uploads and writes +`manifest.json` last. Every upload is followed by a public-domain check of all +file checksums, MIME types, cache headers and CORS. A same-version rerun is safe. +Repository concurrency groups prevent overlapping uploads of the same release; +keep each prefix owned by its repository. + +The workflows apply `scripts/cdn-cors.json` to allow public GET/HEAD requests +from any origin. This is needed for JavaScript modules, workers, WASM and fonts, +including VS Code webview previews. There is no public upload permission. + +## First upload and retries + +In each IDE repository, open **Actions → Release Embedded Build → Run workflow** +and enter an existing published tag in `cdn_release_tag`. Leaving that input +empty retains the existing manual build/artifact behavior. The equivalent CLI +commands are: + +```sh +gh workflow run release-embedded.yml --repo openpatch/online-ide -f cdn_release_tag=v2.2.1-hyperbook.28 +gh workflow run release-embedded.yml --repo openpatch/sql-ide -f cdn_release_tag=v2.0.0-hyperbook.4 +gh workflow run publish-openscad-cdn.yml --repo openpatch/hyperbook +``` + +Hyperbook pins these default base URLs. You can also configure them explicitly: + +```json +{ + "elements": { + "onlineide": { + "cdn": "https://cdn.openpatch.org/onlineide/v2.2.1-hyperbook.28/" + }, + "sqlide": { "cdn": "https://cdn.openpatch.org/sqlide/v2.0.0-hyperbook.4/" }, + "openscad": { "cdn": "https://cdn.openpatch.org/openscad/2026.10.08-1/" } + } +} +``` + +Keep `include/` below the IDE base URLs. Relative chunks, workers and other +assets resolve there automatically. Pin a release version; changing a moving +`latest` alias could break older books. + +## Updating OpenSCAD + +Update the upstream URL and SHA-256 in `openscad-config.json`, then increment +`wasmBuild.cdnVersion`. Increment it when changing font or library downloads as +well. Publish that version before using it as Hyperbook's default. The build +continues to keep the small Hyperbook integration scripts local. + +Update IDE pins in `packages/markdown/ide-releases.json`; local bundle downloads +and default CDN URLs use the same tags. Hyperbook's release workflow verifies +every pinned CDN runtime against the local bundle payloads before publishing +CLI bundles, npm packages or the VS Code extension. A missing or different CDN +release prevents package publication. + +## Cloudflare caching + +Let the custom domain respect object Cache-Control headers. Do not cache 404 +responses: a version should not remain missing after its first upload. If the +hostname already served cached responses before the CORS policy was applied, +purge those URLs or the hostname once, following the +[Cloudflare CORS guidance](https://developers.cloudflare.com/r2/buckets/cors/#use-cors-with-a-custom-domain). diff --git a/package.json b/package.json index 3b82f2a4a..fdcdad007 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "build": "pnpm -r build", "build:vercel": "pnpm --filter=!hyperbook-studio build", "test": "CI=true pnpm -r test", + "test:cdn": "node --test scripts/publish-cdn.test.mjs", "lint": "pnpm -r lint", "hyperbook:build": "pnpm --filter hyperbook build", "version-packages": "changeset version", diff --git a/packages/fs/package.json b/packages/fs/package.json index aa1d4d7fc..005baa2a3 100644 --- a/packages/fs/package.json +++ b/packages/fs/package.json @@ -45,6 +45,7 @@ "mdast-util-from-markdown": "^2.0.3", "micromark-extension-directive": "^4.0.0", "mime-types": "^3.0.2", + "tar": "7.5.22", "unist-util-visit": "^5.1.0", "yaml": "^2.9.0" }, diff --git a/packages/fs/src/assets.ts b/packages/fs/src/assets.ts new file mode 100644 index 000000000..b30787474 --- /dev/null +++ b/packages/fs/src/assets.ts @@ -0,0 +1,335 @@ +import path from "path"; +import os from "node:os"; +import fs from "fs/promises"; +import { createReadStream, createWriteStream } from "fs"; +import { createHash, randomUUID } from "node:crypto"; +import { Transform, Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import { extract } from "tar"; +import { + elementCoreFiles, + elementRuntimeFiles, + DownloadableElement, +} from "@hyperbook/types"; + +export interface AssetBundle { + url: string; + sha256: string; + bytes: number; + files: Record; +} + +export interface AssetManifest { + format: 1; + version: string; + bundles: Record; +} + +export interface AssetOptions { + /** Use cached and local bundles only; never download. */ + offline?: boolean; + /** Shared cache for downloaded bundles. Defaults to {@link assetCacheDir}. */ + cacheDir?: string; + /** Folder with the `directive-*` assets that ship with the host. */ + assetsPath: string; + /** Asset manifest listing the verified bundle for each element. */ + manifestPath: string; + /** Folder with `/.tar.gz` archives from a workspace build. */ + bundlesPath: string; + /** Elements with downloadable bundles. Defaults to every downloadable element. */ + elements?: readonly string[]; +} + +export function assetCacheDir(): string { + if (process.env.HYPERBOOK_ASSET_CACHE) { + return path.resolve(process.env.HYPERBOOK_ASSET_CACHE); + } + if (process.platform === "win32") { + return path.join( + process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local"), + "hyperbook", + "assets", + ); + } + if (process.platform === "darwin") { + return path.join(os.homedir(), "Library", "Caches", "hyperbook", "assets"); + } + return path.join( + process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache"), + "hyperbook", + "assets", + ); +} + +function safePath(value: string): boolean { + return ( + value.length > 0 && + !value.includes("\\") && + !value.includes("\0") && + !path.posix.isAbsolute(value) && + !/^[a-zA-Z]:/.test(value) && + !value.split("/").some((part) => part === ".." || part === ".") + ); +} + +/** Large assets live outside the installation, so global and npx installs work too. */ +export class AssetManager { + readonly cacheDir: string; + private readonly assetsPath: string; + private readonly manifestPath: string; + private readonly bundlesPath: string; + private readonly offline: boolean; + private readonly remoteDirectives: readonly string[]; + private manifest?: Promise; + private readonly pending = new Map>(); + + constructor(options: AssetOptions) { + this.cacheDir = path.resolve(options.cacheDir || assetCacheDir()); + this.assetsPath = options.assetsPath; + this.manifestPath = options.manifestPath; + this.bundlesPath = options.bundlesPath; + this.offline = options.offline || false; + this.remoteDirectives = options.elements || Object.keys(elementCoreFiles); + } + + async readManifest(): Promise { + if (!this.manifest) { + this.manifest = fs.readFile(this.manifestPath, "utf8").then((text) => { + const manifest: AssetManifest = JSON.parse(text); + if ( + manifest.format !== 1 || + !manifest.bundles || + typeof manifest.version !== "string" || + !safePath(manifest.version) + ) + throw new Error("Invalid Hyperbook asset manifest."); + for (const [name, bundle] of Object.entries(manifest.bundles)) { + if ( + !this.remoteDirectives.includes(name) || + !bundle.url.startsWith("https://") || + !/^[a-f0-9]{64}$/.test(bundle.sha256) || + !Number.isSafeInteger(bundle.bytes) || + bundle.bytes <= 0 || + !bundle.files || + Object.keys(bundle.files).length === 0 || + Object.entries(bundle.files).some( + ([file, size]) => + !safePath(file) || + !file.startsWith(`directive-${name}/`) || + !Number.isSafeInteger(size) || + size < 0, + ) + ) { + throw new Error(`Invalid asset manifest entry: ${name}.`); + } + } + return manifest; + }); + } + return this.manifest; + } + + /** Directives without any assets are valid; missing remote bundles are errors. */ + async ensure(directive: string, cdn = false): Promise { + const bundled = path.join(this.assetsPath, `directive-${directive}`); + try { + if ((await fs.stat(bundled)).isDirectory()) { + await Promise.all( + (cdn + ? [] + : elementRuntimeFiles[directive as DownloadableElement] || [] + ).map((file) => fs.access(path.join(bundled, file))), + ); + return bundled; + } + } catch (error: any) { + if (error.code !== "ENOENT") throw error; + } + if (cdn || !this.remoteDirectives.includes(directive)) return undefined; + const manifest = await this.readManifest(); + const bundle = manifest.bundles[directive]; + if (!bundle) + throw new Error( + `Asset manifest has no bundle for ${directive}. Reinstall Hyperbook.`, + ); + const key = bundle.sha256; + let pending = this.pending.get(key); + if (!pending) { + pending = this.obtain( + directive, + bundle, + path.join(this.bundlesPath, manifest.version, `${directive}.tar.gz`), + ); + this.pending.set(key, pending); + pending.catch(() => this.pending.delete(key)); + } + return pending; + } + + async copyDirective( + directive: string, + assetsOut: string, + cdn = false, + ): Promise { + const source = await this.ensure(directive, cdn); + if (!source) return; + const destination = path.join(assetsOut, `directive-${directive}`); + await fs.mkdir(destination, { recursive: true }); + await fs.cp(source, destination, { + recursive: true, + filter(file) { + if (!cdn) return true; + const relative = path.relative(source, file).split(path.sep).join("/"); + return ( + !relative || + (elementCoreFiles[directive as DownloadableElement]?.includes( + relative, + ) ?? + false) + ); + }, + }); + } + + private async complete( + destination: string, + bundle: AssetBundle, + ): Promise { + try { + if ( + (await fs.readFile(path.join(destination, ".complete"), "utf8")) !== + bundle.sha256 + ) + return false; + for (const [file, size] of Object.entries(bundle.files)) { + const stat = await fs.lstat(path.join(destination, file)); + if (!stat.isFile() || stat.size !== size) return false; + } + return true; + } catch (error: any) { + if (error.code === "ENOENT" || error.code === "ENOTDIR") return false; + throw error; + } + } + + private async obtain( + directive: string, + bundle: AssetBundle, + localArchive: string, + ): Promise { + const destination = path.join(this.cacheDir, bundle.sha256); + // A small pointer selects an immutable generation. Replacing that pointer + // cannot remove files another process is copying, even during cache repair. + try { + const generation = await fs.readFile( + path.join(destination, "current"), + "utf8", + ); + if (/^content-[a-f0-9-]+$/.test(generation)) { + const contents = path.join(destination, generation); + if (await this.complete(contents, bundle)) + return path.join(contents, `directive-${directive}`); + } + } catch (error: any) { + if (error.code !== "ENOENT") throw error; + } + // Workspace builds can use their verified archives before a release exists. + const local = ( + await fs.stat(localArchive).catch((error) => { + if (error.code !== "ENOENT") throw error; + return undefined; + }) + )?.isFile(); + if (this.offline && !local) { + throw new Error( + `Assets for ${directive} are missing from ${this.cacheDir}. Run "hyperbook assets fetch" online before building with --offline.`, + ); + } + + await fs.mkdir(destination, { recursive: true }); + const staging = await fs.mkdtemp(path.join(destination, ".download-")); + const archive = path.join(staging, "bundle.tar.gz"); + const unpacked = path.join(staging, "unpacked"); + try { + let input; + if (local) { + console.log(`[Assets] Using local ${directive} bundle...`); + input = createReadStream(localArchive); + } else { + console.log(`[Assets] Downloading ${directive}...`); + const response = await fetch(bundle.url, { + signal: AbortSignal.timeout(300_000), + }); + if (!response.ok || !response.body) + throw new Error(`HTTP ${response.status} from ${bundle.url}`); + input = Readable.fromWeb(response.body as any); + } + let bytes = 0; + const hash = createHash("sha256"); + const verify = new Transform({ + transform(chunk, _encoding, callback) { + bytes += chunk.length; + if (bytes > bundle.bytes) + return callback( + new Error("Asset download exceeds its expected size."), + ); + hash.update(chunk); + callback(null, chunk); + }, + }); + await pipeline( + input, + verify, + createWriteStream(archive, { flags: "wx" }), + ); + if (bytes !== bundle.bytes || hash.digest("hex") !== bundle.sha256) { + throw new Error("Asset download failed SHA-256 verification."); + } + await fs.mkdir(unpacked); + let invalidEntry: string | undefined; + const seen = new Set(); + await extract({ + file: archive, + cwd: unpacked, + strict: true, + filter(entryPath, entry) { + if (!("type" in entry)) return false; + const normalized = entryPath.replace(/\/$/, ""); + const directory = + entry.type === "Directory" && + (normalized === `directive-${directive}` || + normalized.startsWith(`directive-${directive}/`)); + const file = + entry.type === "File" && + Object.prototype.hasOwnProperty.call(bundle.files, normalized) && + entry.size === bundle.files[normalized] && + !seen.has(normalized); + if (!safePath(normalized) || (!directory && !file)) { + invalidEntry = entryPath; + return false; + } + if (file) seen.add(normalized); + return true; + }, + }); + if (invalidEntry) + throw new Error(`Unexpected asset archive entry: ${invalidEntry}.`); + await fs.writeFile(path.join(unpacked, ".complete"), bundle.sha256); + if (!(await this.complete(unpacked, bundle))) + throw new Error("Asset archive is incomplete."); + const generation = `content-${randomUUID()}`; + const contents = path.join(destination, generation); + await fs.rename(unpacked, contents); + const pointer = path.join(staging, "current"); + await fs.writeFile(pointer, generation); + await fs.rename(pointer, path.join(destination, "current")); + return path.join(contents, `directive-${directive}`); + } catch (error: any) { + throw new Error( + `Could not prepare assets for ${directive}: ${error.message}`, + ); + } finally { + await fs.rm(staging, { recursive: true, force: true }); + } + } +} diff --git a/packages/fs/src/index.ts b/packages/fs/src/index.ts index 695efe2bd..9ffe10331 100644 --- a/packages/fs/src/index.ts +++ b/packages/fs/src/index.ts @@ -28,4 +28,7 @@ export { } from "./passwords"; export type { PasswordRegistry, ResolvedRegistry } from "./passwords"; +export { AssetManager, assetCacheDir } from "./assets"; +export type { AssetBundle, AssetManifest, AssetOptions } from "./assets"; + export { hyperlibrary, hyperbook, hyperproject, vfile, passwords }; diff --git a/packages/hyperbook/README.md b/packages/hyperbook/README.md index b3bac63f9..0d762f3e6 100644 --- a/packages/hyperbook/README.md +++ b/packages/hyperbook/README.md @@ -100,6 +100,9 @@ run `hyperbook assets fetch`, build with `--offline`, and save the cache again. Deleting the cache is safe; the next online build downloads the required bundles again. +The Hyperbook extension for VS Code shares this cache. Its preview uses the +runtimes found there, and its **Download Element Runtimes** commands fill it. + ### Optional CDNs Set `cdn` per element in `hyperbook.json` to load its large runtime from a CDN: @@ -117,8 +120,10 @@ Set `cdn` per element in `hyperbook.json` to load its large runtime from a CDN: ``` Omitting `cdn` or setting it to `false` keeps local assets. `true` selects the -default CDN: jsDelivr for Pyodide and Typst, GeoGebra's server for GeoGebra, and -versioned Hyperbook assets on UNPKG for the other elements. The option supports +default CDN: jsDelivr for Pyodide and Typst, GeoGebra's server for GeoGebra, +`blockflow.openpatch.org` for Blockflow, Excalidraw's own packages on UNPKG for +Excalidraw, and versioned `@hyperbook/markdown` assets on UNPKG for OpenSCAD, +the Online IDE, and the SQL IDE. The option supports `pyide`, `typst`, `geogebra`, `openscad`, `excalidraw`, `onlineide`, `sqlide`, and `blockflow` (both player and editor). @@ -143,10 +148,17 @@ to the CDN. `assets fetch --all` still downloads every bundle. Build the workspace packages before building the CLI. Its postbuild step writes the small npm package to `dist/`, an asset manifest to `dist/asset-manifest.json`, and eight separate archives to `.cache/asset-bundles//`. The Markdown -build and VS Code extension continue to include their complete assets. Markdown's -npm tarball omits the Pyodide distribution to fit npm's upload limit; standalone -users can extract the CLI's `pyide.tar.gz` bundle alongside the other directive -assets. +build keeps its complete assets in `dist/assets`, which the CLI and the VS Code +extension build from. The published packages stay small: + +- Markdown's npm tarball omits the Pyodide, GeoGebra, Typst, Blockflow, and + Excalidraw runtimes, which have upstream CDNs. It keeps OpenSCAD, the Online + IDE, and the SQL IDE, because their default CDN is this package on UNPKG. + `packages/markdown/tests/packageContents.test.ts` enforces this split. +- The VS Code extension omits all eight runtimes through `.vscodeignore`. Its + preview uses runtimes from the CLI's asset cache and loads missing ones from + their default CDN. It ships the CLI's `asset-manifest.json`, so its download + commands fetch the same verified bundles into the same cache. Locally built CLIs use these archives directly, including with `--offline`, so workspace builds work before the corresponding release is published. diff --git a/packages/hyperbook/helpers/assets.ts b/packages/hyperbook/helpers/assets.ts index 465cff2bf..95c17e998 100644 --- a/packages/hyperbook/helpers/assets.ts +++ b/packages/hyperbook/helpers/assets.ts @@ -1,356 +1,40 @@ import path from "path"; -import os from "os"; -import fs from "fs/promises"; -import { createReadStream, createWriteStream } from "fs"; -import { createHash, randomUUID } from "crypto"; -import { Transform, Readable } from "stream"; -import { pipeline } from "stream/promises"; -import { extract } from "tar"; +import { + AssetManager as SharedAssetManager, + AssetOptions as SharedAssetOptions, + assetCacheDir, +} from "@hyperbook/fs"; import remoteDirectives from "../asset-bundles.json"; -import { elementCoreFiles, DownloadableElement } from "@hyperbook/types"; -const runtimeFiles: Record = { - blockflow: ["player.html"], - onlineide: ["include/online-ide-embedded.js"], - sqlide: ["include/sql-ide-embedded.js"], - excalidraw: ["hyperbook-excalidraw.umd.js"], - geogebra: ["GeoGebra/deployggb.js"], - pyide: ["pyodide/pyodide.js", "pyodide/pyodide-lock.json"], - typst: [ - "typst-bundle.js", - "typst-compiler.wasm", - "fonts/LibertinusSerif-Regular.otf", - ], - openscad: [ - "openscad.wasm", - "libraries/BOSL2.zip", - "fonts/Roboto-Regular.ttf", - ], -}; +export { assetCacheDir }; +export type { AssetBundle, AssetManifest } from "@hyperbook/fs"; -export interface AssetBundle { - url: string; - sha256: string; - bytes: number; - files: Record; -} - -export interface AssetManifest { - format: 1; - version: string; - bundles: Record; -} - -export interface AssetOptions { - offline?: boolean; - cacheDir?: string; - assetsPath?: string; - manifestPath?: string; - bundlesPath?: string; -} - -export function assetCacheDir(): string { - if (process.env.HYPERBOOK_ASSET_CACHE) { - return path.resolve(process.env.HYPERBOOK_ASSET_CACHE); - } - if (process.platform === "win32") { - return path.join( - process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local"), - "hyperbook", - "assets", - ); - } - if (process.platform === "darwin") { - return path.join(os.homedir(), "Library", "Caches", "hyperbook", "assets"); - } - return path.join( - process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache"), - "hyperbook", - "assets", - ); -} - -function safePath(value: string): boolean { - return ( - value.length > 0 && - !value.includes("\\") && - !value.includes("\0") && - !path.posix.isAbsolute(value) && - !/^[a-zA-Z]:/.test(value) && - !value.split("/").some((part) => part === ".." || part === ".") - ); -} +export type AssetOptions = Partial>; /** Large assets live outside the installation, so global and npx installs work too. */ -export class AssetManager { - readonly cacheDir: string; - private readonly assetsPath: string; - private readonly manifestPath: string; - private readonly bundlesPath: string; - private readonly offline: boolean; - private manifest?: Promise; - private readonly pending = new Map>(); - +export class AssetManager extends SharedAssetManager { constructor(options: AssetOptions = {}) { - this.cacheDir = path.resolve(options.cacheDir || assetCacheDir()); // helpers/ is flattened into dist/ by ncc. Source runs use the package root. const fromSource = path.basename(__dirname) === "helpers"; const packagePath = fromSource ? path.dirname(__dirname) : __dirname; - this.assetsPath = options.assetsPath || path.join(packagePath, "assets"); - this.manifestPath = - options.manifestPath || - path.join( - packagePath, - ...(fromSource ? ["dist"] : []), - "asset-manifest.json", - ); - this.bundlesPath = - options.bundlesPath || - path.join( - fromSource ? packagePath : path.dirname(packagePath), - ".cache", - "asset-bundles", - ); - this.offline = options.offline || false; - } - - async readManifest(): Promise { - if (!this.manifest) { - this.manifest = fs.readFile(this.manifestPath, "utf8").then((text) => { - const manifest: AssetManifest = JSON.parse(text); - if ( - manifest.format !== 1 || - !manifest.bundles || - typeof manifest.version !== "string" || - !safePath(manifest.version) - ) - throw new Error("Invalid Hyperbook asset manifest."); - for (const [name, bundle] of Object.entries(manifest.bundles)) { - if ( - !remoteDirectives.includes(name) || - !bundle.url.startsWith("https://") || - !/^[a-f0-9]{64}$/.test(bundle.sha256) || - !Number.isSafeInteger(bundle.bytes) || - bundle.bytes <= 0 || - !bundle.files || - Object.keys(bundle.files).length === 0 || - Object.entries(bundle.files).some( - ([file, size]) => - !safePath(file) || - !file.startsWith(`directive-${name}/`) || - !Number.isSafeInteger(size) || - size < 0, - ) - ) { - throw new Error(`Invalid asset manifest entry: ${name}.`); - } - } - return manifest; - }); - } - return this.manifest; - } - - /** Directives without any assets are valid; missing remote bundles are errors. */ - async ensure(directive: string, cdn = false): Promise { - const bundled = path.join(this.assetsPath, `directive-${directive}`); - try { - if ((await fs.stat(bundled)).isDirectory()) { - await Promise.all( - (cdn ? [] : runtimeFiles[directive] || []).map((file) => - fs.access(path.join(bundled, file)), - ), - ); - return bundled; - } - } catch (error: any) { - if (error.code !== "ENOENT") throw error; - } - if (cdn || !remoteDirectives.includes(directive)) return undefined; - const manifest = await this.readManifest(); - const bundle = manifest.bundles[directive]; - if (!bundle) - throw new Error( - `Asset manifest has no bundle for ${directive}. Reinstall Hyperbook.`, - ); - const key = bundle.sha256; - let pending = this.pending.get(key); - if (!pending) { - pending = this.obtain( - directive, - bundle, - path.join(this.bundlesPath, manifest.version, `${directive}.tar.gz`), - ); - this.pending.set(key, pending); - pending.catch(() => this.pending.delete(key)); - } - return pending; - } - - async copyDirective( - directive: string, - assetsOut: string, - cdn = false, - ): Promise { - const source = await this.ensure(directive, cdn); - if (!source) return; - const destination = path.join(assetsOut, `directive-${directive}`); - await fs.mkdir(destination, { recursive: true }); - await fs.cp(source, destination, { - recursive: true, - filter(file) { - if (!cdn) return true; - const relative = path.relative(source, file).split(path.sep).join("/"); - return ( - !relative || - (elementCoreFiles[directive as DownloadableElement]?.includes( - relative, - ) ?? - false) - ); - }, + super({ + ...options, + elements: remoteDirectives, + assetsPath: options.assetsPath || path.join(packagePath, "assets"), + manifestPath: + options.manifestPath || + path.join( + packagePath, + ...(fromSource ? ["dist"] : []), + "asset-manifest.json", + ), + bundlesPath: + options.bundlesPath || + path.join( + fromSource ? packagePath : path.dirname(packagePath), + ".cache", + "asset-bundles", + ), }); } - - private async complete( - destination: string, - bundle: AssetBundle, - ): Promise { - try { - if ( - (await fs.readFile(path.join(destination, ".complete"), "utf8")) !== - bundle.sha256 - ) - return false; - for (const [file, size] of Object.entries(bundle.files)) { - const stat = await fs.lstat(path.join(destination, file)); - if (!stat.isFile() || stat.size !== size) return false; - } - return true; - } catch (error: any) { - if (error.code === "ENOENT" || error.code === "ENOTDIR") return false; - throw error; - } - } - - private async obtain( - directive: string, - bundle: AssetBundle, - localArchive: string, - ): Promise { - const destination = path.join(this.cacheDir, bundle.sha256); - // A small pointer selects an immutable generation. Replacing that pointer - // cannot remove files another process is copying, even during cache repair. - try { - const generation = await fs.readFile( - path.join(destination, "current"), - "utf8", - ); - if (/^content-[a-f0-9-]+$/.test(generation)) { - const contents = path.join(destination, generation); - if (await this.complete(contents, bundle)) - return path.join(contents, `directive-${directive}`); - } - } catch (error: any) { - if (error.code !== "ENOENT") throw error; - } - // Workspace builds can use their verified archives before a release exists. - const local = ( - await fs.stat(localArchive).catch((error) => { - if (error.code !== "ENOENT") throw error; - return undefined; - }) - )?.isFile(); - if (this.offline && !local) { - throw new Error( - `Assets for ${directive} are missing from ${this.cacheDir}. Run "hyperbook assets fetch" online before building with --offline.`, - ); - } - - await fs.mkdir(destination, { recursive: true }); - const staging = await fs.mkdtemp(path.join(destination, ".download-")); - const archive = path.join(staging, "bundle.tar.gz"); - const unpacked = path.join(staging, "unpacked"); - try { - let input; - if (local) { - console.log(`[Assets] Using local ${directive} bundle...`); - input = createReadStream(localArchive); - } else { - console.log(`[Assets] Downloading ${directive}...`); - const response = await fetch(bundle.url, { - signal: AbortSignal.timeout(300_000), - }); - if (!response.ok || !response.body) - throw new Error(`HTTP ${response.status} from ${bundle.url}`); - input = Readable.fromWeb(response.body as any); - } - let bytes = 0; - const hash = createHash("sha256"); - const verify = new Transform({ - transform(chunk, _encoding, callback) { - bytes += chunk.length; - if (bytes > bundle.bytes) - return callback( - new Error("Asset download exceeds its expected size."), - ); - hash.update(chunk); - callback(null, chunk); - }, - }); - await pipeline( - input, - verify, - createWriteStream(archive, { flags: "wx" }), - ); - if (bytes !== bundle.bytes || hash.digest("hex") !== bundle.sha256) { - throw new Error("Asset download failed SHA-256 verification."); - } - await fs.mkdir(unpacked); - let invalidEntry: string | undefined; - const seen = new Set(); - await extract({ - file: archive, - cwd: unpacked, - strict: true, - filter(entryPath, entry) { - if (!("type" in entry)) return false; - const normalized = entryPath.replace(/\/$/, ""); - const directory = - entry.type === "Directory" && - (normalized === `directive-${directive}` || - normalized.startsWith(`directive-${directive}/`)); - const file = - entry.type === "File" && - Object.prototype.hasOwnProperty.call(bundle.files, normalized) && - entry.size === bundle.files[normalized] && - !seen.has(normalized); - if (!safePath(normalized) || (!directory && !file)) { - invalidEntry = entryPath; - return false; - } - if (file) seen.add(normalized); - return true; - }, - }); - if (invalidEntry) - throw new Error(`Unexpected asset archive entry: ${invalidEntry}.`); - await fs.writeFile(path.join(unpacked, ".complete"), bundle.sha256); - if (!(await this.complete(unpacked, bundle))) - throw new Error("Asset archive is incomplete."); - const generation = `content-${randomUUID()}`; - const contents = path.join(destination, generation); - await fs.rename(unpacked, contents); - const pointer = path.join(staging, "current"); - await fs.writeFile(pointer, generation); - await fs.rename(pointer, path.join(destination, "current")); - return path.join(contents, `directive-${directive}`); - } catch (error: any) { - throw new Error( - `Could not prepare assets for ${directive}: ${error.message}`, - ); - } finally { - await fs.rm(staging, { recursive: true, force: true }); - } - } } diff --git a/packages/hyperbook/tests/assets-build.test.ts b/packages/hyperbook/tests/assets-build.test.ts index de85d5df4..56b5c48f9 100644 --- a/packages/hyperbook/tests/assets-build.test.ts +++ b/packages/hyperbook/tests/assets-build.test.ts @@ -131,7 +131,7 @@ it("builds and prefetches CDN elements offline without obtaining their local run ); const out = path.join(root, ".hyperbook", "out"); const html = await fs.readFile(path.join(out, "index.html"), "utf8"); - expect(html).toContain("unpkg.com/@hyperbook/markdown@"); + expect(html).toContain("https://blockflow.openpatch.org/player.html"); expect(html).toContain('data-runtime-url="https://cdn.example.com/python/"'); await expect( fs.stat(path.join(out, "__hyperbook_assets", "directive-blockflow")), @@ -167,6 +167,108 @@ it("honors CDN configuration for a directive first added during incremental deve expect(download).not.toHaveBeenCalled(); }); +it("defaults PyIDE to its CDN during prefetching and offline builds", async () => { + await fs.writeFile(path.join(root, "book", "index.md"), "::pyide"); + await fs.mkdir(path.join(options.assetsPath!, "directive-pyide")); + await fs.writeFile( + path.join(options.assetsPath!, "directive-pyide", "client.js"), + "client", + ); + const manager = new AssetManager({ ...options, offline: true }); + await fetchAssets(project, manager); + await runBuildProject( + project, + project, + undefined, + undefined, + undefined, + manager, + ); + const out = path.join(root, ".hyperbook", "out"); + expect(await fs.readFile(path.join(out, "index.html"), "utf8")).toContain( + 'data-runtime-url="https://cdn.jsdelivr.net/pyodide/v314.0.7/full/"', + ); + expect( + await fs.readFile( + path.join(out, "__hyperbook_assets", "directive-pyide", "client.js"), + "utf8", + ), + ).toBe("client"); + await expect( + fs.stat(path.join(out, "__hyperbook_assets", "directive-pyide", "pyodide")), + ).rejects.toMatchObject({ code: "ENOENT" }); + expect(download).not.toHaveBeenCalled(); + + await fs.writeFile( + path.join(root, "hyperbook.json"), + JSON.stringify({ name: "Assets", elements: { pyide: { cdn: false } } }), + ); + expect(await projectDirectives(project)).toContain("pyide"); + const input = path.join(root, "input"); + await fs.mkdir(path.join(input, "directive-pyide", "pyodide"), { + recursive: true, + }); + await fs.writeFile( + path.join(input, "directive-pyide", "pyodide", "pyodide.js"), + "python", + ); + await fs.writeFile( + path.join(input, "directive-pyide", "pyodide", "pyodide-lock.json"), + "{}", + ); + const archivePath = path.join(root, "python.tar.gz"); + await create({ file: archivePath, cwd: input, gzip: true }, [ + "directive-pyide", + ]); + const archive = await fs.readFile(archivePath); + await fs.writeFile( + options.manifestPath!, + JSON.stringify({ + format: 1, + version: "test", + bundles: { + pyide: { + url: "https://example.com/python.tar.gz", + sha256: createHash("sha256").update(archive).digest("hex"), + bytes: archive.length, + files: { + "directive-pyide/pyodide/pyodide.js": 6, + "directive-pyide/pyodide/pyodide-lock.json": 2, + }, + }, + }, + }), + ); + download.mockImplementation( + async () => new Response(new Uint8Array(archive)), + ); + await fetchAssets(project, new AssetManager(options)); + await runBuildProject( + project, + project, + undefined, + undefined, + undefined, + new AssetManager({ ...options, offline: true }), + ); + expect(await fs.readFile(path.join(out, "index.html"), "utf8")).not.toContain( + "https://cdn.jsdelivr.net/pyodide/", + ); + expect( + await fs.readFile( + path.join( + out, + "__hyperbook_assets", + "directive-pyide", + "pyodide", + "pyodide.js", + ), + "utf8", + ), + ).toBe("python"); + expect(download).toHaveBeenCalledOnce(); +}); + it("downloads a directive first introduced during an incremental build", async () => { const builder = new IncrementalBuilder( root, diff --git a/packages/hyperbook/tests/packageContents.test.ts b/packages/hyperbook/tests/packageContents.test.ts new file mode 100644 index 000000000..77d45a4e2 --- /dev/null +++ b/packages/hyperbook/tests/packageContents.test.ts @@ -0,0 +1,37 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { + DownloadableElement, + elementCoreFiles, + elementRuntimeFiles, +} from "@hyperbook/types"; +import { expect, it } from "vitest"; + +const dist = path.join(__dirname, "..", "dist"); + +it("starts the bundled CLI with its bundled Markdown renderer", () => { + const help = execFileSync( + process.execPath, + [path.join(dist, "index.js"), "--help"], + { encoding: "utf8" }, + ); + expect(help).toContain("Usage:"); + expect(help).toContain("assets"); +}); + +it("ships integration files and a download manifest without large runtimes", async () => { + const manifest = JSON.parse( + await fs.readFile(path.join(dist, "asset-manifest.json"), "utf8"), + ); + for (const element of Object.keys(elementCoreFiles) as DownloadableElement[]) { + expect(manifest.bundles).toHaveProperty(element); + const directory = path.join(dist, "assets", `directive-${element}`); + for (const file of elementCoreFiles[element]) + expect((await fs.stat(path.join(directory, file))).isFile()).toBe(true); + for (const file of elementRuntimeFiles[element]) + await expect(fs.stat(path.join(directory, file))).rejects.toMatchObject({ + code: "ENOENT", + }); + } +}); diff --git a/packages/markdown/README.md b/packages/markdown/README.md index 4e208b5cc..08cbe01a4 100644 --- a/packages/markdown/README.md +++ b/packages/markdown/README.md @@ -42,16 +42,38 @@ console.log(result.data.headings); // Extracted headings ## Browser assets -Browser scripts and styles are included under `dist/assets`. Pyodide's large -distribution is provided separately to keep the package within npm's upload -limit. When self-hosting PyIDE with this package, download the -[Pyodide asset bundle for Hyperbook 0.113.0](https://github.com/openpatch/hyperbook/releases/download/hyperbook-assets-v0.113.0/pyide.tar.gz), -verify its SHA-256 checksum against the -[CLI asset manifest](https://unpkg.com/hyperbook@0.113.0/dist/asset-manifest.json), -and extract it into the served assets directory, alongside the other -`directive-*` folders. The bundle includes `directive-pyide/pyodide/` and its -complete Python package distribution. - -The Hyperbook CLI downloads this bundle automatically. Hyperbook Studio for -VS Code includes the runtime. Setting `elements.pyide.cdn` to `true` uses the -Pyodide CDN instead of locally served runtime files. +Browser scripts and styles are included under `dist/assets`. To keep the +package small, it leaves out the large runtimes of these elements and keeps only +their integration scripts and styles: + +| Element | Default CDN | +| --- | --- | +| `pyide` | Pyodide on jsDelivr | +| `typst` | Typst packages and fonts on jsDelivr | +| `geogebra` | GeoGebra's server | +| `blockflow` | `blockflow.openpatch.org` | +| `excalidraw` | Excalidraw's own packages on UNPKG | +| `onlineide` | Runtime from `@hyperbook/markdown@0.85.0` on UNPKG | +| `sqlide` | Runtime from `@hyperbook/markdown@0.85.0` on UNPKG | +| `openscad` | Runtime from `@hyperbook/markdown@0.85.0` on UNPKG | + +All eight large runtimes are excluded from the npm package. The OpenSCAD, +Online IDE, and SQL IDE default CDNs are pinned to the last published Markdown +release containing them, independently of the current renderer version. +Workspace builds still prepare the full asset tree for publishing CLI bundles. + +To serve a missing runtime, choose one of these options: + +- **Use the CDN.** Set `elements..cdn` to `true` in the configuration + passed to `process`, or to the base URL of your own copy. +- **Download it.** The Hyperbook CLI publishes one verified bundle per element + for each release. For example, the + [PyIDE bundle for Hyperbook 0.113.0](https://github.com/openpatch/hyperbook/releases/download/hyperbook-assets-v0.113.0/pyide.tar.gz) + is listed with its SHA-256 checksum in the + [CLI asset manifest](https://unpkg.com/hyperbook@0.113.0/dist/asset-manifest.json). + Extract a bundle into the served assets directory, alongside the other + `directive-*` folders. + +The Hyperbook CLI downloads these bundles automatically. The Hyperbook extension +for VS Code shares the CLI's asset cache and can download them on request. +`AssetManager` from `@hyperbook/fs` implements both. diff --git a/packages/markdown/ide-releases.json b/packages/markdown/ide-releases.json new file mode 100644 index 000000000..33a337a79 --- /dev/null +++ b/packages/markdown/ide-releases.json @@ -0,0 +1,4 @@ +{ + "onlineide": "v2.2.1-hyperbook.28", + "sqlide": "v2.0.0-hyperbook.4" +} diff --git a/packages/markdown/openscad-config.json b/packages/markdown/openscad-config.json index 55632e28f..a970f5fbf 100644 --- a/packages/markdown/openscad-config.json +++ b/packages/markdown/openscad-config.json @@ -1,5 +1,6 @@ { "wasmBuild": { + "cdnVersion": "2026.10.08-1", "url": "https://files.openscad.org/snapshots/OpenSCAD-2026.10.08-WebAssembly-web.zip", "sha256": "274c0e725733efea09e99b1186ebafe50ce799b8c8353ad59d9e72a6b9c76ab1", "target": "directive-openscad", diff --git a/packages/markdown/package.json b/packages/markdown/package.json index 039e1d50a..42ac935fb 100644 --- a/packages/markdown/package.json +++ b/packages/markdown/package.json @@ -14,7 +14,30 @@ }, "files": [ "dist", - "!dist/assets/directive-pyide/pyodide" + "!dist/assets/directive-blockflow/**", + "!dist/assets/directive-onlineide/**", + "dist/assets/directive-onlineide/style.css", + "!dist/assets/directive-sqlide/**", + "dist/assets/directive-sqlide/client.js", + "dist/assets/directive-sqlide/style.css", + "!dist/assets/directive-excalidraw/**", + "dist/assets/directive-excalidraw/client.js", + "dist/assets/directive-excalidraw/style.css", + "!dist/assets/directive-openscad/**", + "dist/assets/directive-openscad/client.js", + "dist/assets/directive-openscad/worker.js", + "dist/assets/directive-openscad/style.css", + "!dist/assets/directive-geogebra/**", + "dist/assets/directive-geogebra/client.js", + "dist/assets/directive-geogebra/geogebra-web-component.js", + "dist/assets/directive-geogebra/style.css", + "!dist/assets/directive-pyide/**", + "dist/assets/directive-pyide/client.js", + "dist/assets/directive-pyide/python-friendly-error-messages.js", + "dist/assets/directive-pyide/style.css", + "!dist/assets/directive-typst/**", + "dist/assets/directive-typst/client.js", + "dist/assets/directive-typst/style.css" ], "publishConfig": { "access": "public" diff --git a/packages/markdown/postbuild.mjs b/packages/markdown/postbuild.mjs index 23c5e04c0..248ee12ef 100644 --- a/packages/markdown/postbuild.mjs +++ b/packages/markdown/postbuild.mjs @@ -1,5 +1,13 @@ import path from "path"; -import { cp, readFile, writeFile, mkdir, access, rm, readdir } from "fs/promises"; +import { + cp, + readFile, + writeFile, + mkdir, + access, + rm, + readdir, +} from "fs/promises"; import { minify } from "terser"; import https from "https"; import { Extract } from "unzipper"; @@ -111,7 +119,10 @@ async function downloadAndExtractZip(url, destination, sha256) { async function postbuild() { // Read openscad-config.json for WASM asset configuration - const openscadConfig = JSON.parse(await readFile("openscad-config.json", "utf-8")); + const openscadConfig = JSON.parse( + await readFile("openscad-config.json", "utf-8"), + ); + const ideReleases = JSON.parse(await readFile("ide-releases.json", "utf8")); // Download and extract zips const zipFiles = [ @@ -120,11 +131,11 @@ async function postbuild() { dst: path.join("./dist", "assets", "directive-blockflow"), }, { - url: "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/openpatch/sql-ide/releases/download/v2.0.0-hyperbook.4/dist-embedded.zip", + url: `https://github.com/openpatch/sql-ide/releases/download/${ideReleases.sqlide}/dist-embedded.zip`, dst: path.join("./dist", "assets", "directive-sqlide", "include"), }, { - url: "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/openpatch/online-ide/releases/download/v2.2.1-hyperbook.28/dist-embedded.zip", + url: `https://github.com/openpatch/online-ide/releases/download/${ideReleases.onlineide}/dist-embedded.zip`, dst: path.join("./dist", "assets", "directive-onlineide", "include"), }, { @@ -140,8 +151,11 @@ async function postbuild() { } // These files are served locally by the elements. The CLI publishes them in - // optional bundles; Markdown and VS Code keep their complete asset trees. - await installRuntimeAssets(JSON.parse(await readFile("runtime-assets.json", "utf8"))); + // optional bundles. The Markdown npm package and VS Code extension exclude + // the runtimes, retaining only their small integration scripts and styles. + await installRuntimeAssets( + JSON.parse(await readFile("runtime-assets.json", "utf8")), + ); const assets = [ { @@ -413,14 +427,18 @@ async function postbuild() { await cp("locales", "./dist/locales", { recursive: true }); await esbuild({ - entryPoints: ["./node_modules/@raspberrypifoundation/python-friendly-error-messages/dist/index.browser.js"], + entryPoints: [ + "./node_modules/@raspberrypifoundation/python-friendly-error-messages/dist/index.browser.js", + ], bundle: false, format: "iife", globalName: "PythonFriendlyErrorMessages", outfile: "./dist/assets/directive-pyide/python-friendly-error-messages.js", minify: true, }); - console.log("Built python-friendly-error-messages → dist/assets/directive-pyide/python-friendly-error-messages.js"); + console.log( + "Built python-friendly-error-messages → dist/assets/directive-pyide/python-friendly-error-messages.js", + ); // Bundle any directive that ships a src/index.js into dist/assets//client.js. // Each src/ directory is removed from dist after bundling — only the bundle is served. @@ -478,6 +496,8 @@ async function postbuild() { outfile: "./dist/assets/codemirror/codemirror.bundle.js", minify: true, }); - console.log("Built CodeMirror bundle → dist/assets/codemirror/codemirror.bundle.js"); + console.log( + "Built CodeMirror bundle → dist/assets/codemirror/codemirror.bundle.js", + ); } postbuild(); diff --git a/packages/markdown/src/elementAssets.ts b/packages/markdown/src/elementAssets.ts index 0e167c55a..4b5bd2598 100644 --- a/packages/markdown/src/elementAssets.ts +++ b/packages/markdown/src/elementAssets.ts @@ -3,8 +3,13 @@ import { HyperbookContext, elementCdn, } from "@hyperbook/types"; -import packageJson from "../package.json"; +import excalidrawPackageJson from "../../web-component-excalidraw/package.json"; import runtimeAssets from "../runtime-assets.json"; +import ideReleases from "../ide-releases.json"; +import openscadConfig from "../openscad-config.json"; + +/** Hosted Blockflow app; the npm package does not include Blockflow's bundle. */ +const blockflowCdn = "https://blockflow.openpatch.org/"; /** Resolve payload files; Hyperbook's integration scripts stay local. */ export function elementAssetUrl( @@ -29,6 +34,18 @@ export function elementAssetUrl( cdn, ).href; } + if (element === "onlineide" || element === "sqlide") { + return new URL( + file, + `https://cdn.openpatch.org/${element}/${ideReleases[element]}/`, + ).href; + } + if (element === "openscad") { + return new URL( + file, + `https://cdn.openpatch.org/openscad/${openscadConfig.wasmBuild.cdnVersion}/`, + ).href; + } if (element === "pyide") { const version = runtimeAssets .find((asset) => asset.target === "directive-pyide")! @@ -58,5 +75,15 @@ export function elementAssetUrl( .replace(/^5\.0\//, `${version}/`); return `https://www.geogebra.org/apps/${relative}`; } - return `https://unpkg.com/@hyperbook/markdown@${packageJson.version}/dist/assets/directive-${element}/${file}`; + if (element === "blockflow") return new URL(file, blockflowCdn).href; + if (element === "excalidraw") { + // The npm package omits Excalidraw's fonts and bundle; use their own packages. + if (file === "hyperbook-excalidraw.umd.js") + return `https://unpkg.com/${excalidrawPackageJson.name}@${excalidrawPackageJson.version}/dist/index.umd.js`; + const base = `https://unpkg.com/@excalidraw/excalidraw@${excalidrawPackageJson.dependencies["@excalidraw/excalidraw"]}/dist/prod/`; + return file === "excalidraw.css" + ? `${base}index.css` + : new URL(file, base).href; + } + throw new Error(`No default CDN asset for ${element}/${file}`); } diff --git a/packages/markdown/tests/__snapshots__/remarkDirectivePyide.test.ts.snap b/packages/markdown/tests/__snapshots__/remarkDirectivePyide.test.ts.snap index 49e17271d..25a7dcdc6 100644 --- a/packages/markdown/tests/__snapshots__/remarkDirectivePyide.test.ts.snap +++ b/packages/markdown/tests/__snapshots__/remarkDirectivePyide.test.ts.snap @@ -2,7 +2,7 @@ exports[`remarkDirectivePyide > should transform basic pyide 1`] = ` " -
+

@@ -35,7 +35,7 @@ exports[`remarkDirectivePyide > should transform basic pyide 1`] = `
 
 exports[`remarkDirectivePyide > should transform pyide with canvas and packages attributes 1`] = `
 "
-
+
@@ -74,7 +74,7 @@ exports[`remarkDirectivePyide > should transform pyide with canvas and packages exports[`remarkDirectivePyide > should transform pyide with canvas attribute 1`] = ` " -
+
@@ -114,7 +114,7 @@ pygame.init()
exports[`remarkDirectivePyide > should transform pyide with packages attribute 1`] = ` " -
+

diff --git a/packages/markdown/tests/blockflowVscodePreview.test.ts b/packages/markdown/tests/blockflowVscodePreview.test.ts
index be5cbbe1a..78bdd6812 100644
--- a/packages/markdown/tests/blockflowVscodePreview.test.ts
+++ b/packages/markdown/tests/blockflowVscodePreview.test.ts
@@ -1,13 +1,80 @@
-import { readFileSync } from "node:fs";
+import {
+  mkdirSync,
+  mkdtempSync,
+  readFileSync,
+  rmSync,
+  writeFileSync,
+} from "node:fs";
+import { createRequire } from "node:module";
+import { execFileSync } from "node:child_process";
+import { tmpdir } from "node:os";
 import path from "node:path";
 import { runInNewContext } from "node:vm";
+import { fileURLToPath } from "node:url";
 import ts from "typescript";
-import { HyperbookContext } from "@hyperbook/types";
+import {
+  DownloadableElement,
+  HyperbookContext,
+  HyperbookJson,
+  elementCoreFiles,
+  elementCdn,
+  elementRuntimeFiles,
+} from "@hyperbook/types";
 import { expect, it } from "vitest";
 
-it("uses valid VS Code resource URLs for the bundle and local projects", async () => {
+const vscodePlatform = new URL("../../../platforms/vscode/", import.meta.url);
+const elements = Object.keys(elementCoreFiles) as DownloadableElement[];
+
+it("loads the compiled extension in a Node extension host", () => {
+  execFileSync(
+    process.execPath,
+    [
+      "-e",
+      `
+        const fs = require('node:fs');
+        const Module = require('node:module');
+        const filename = process.argv[1];
+        const extension = new Module(filename);
+        extension.filename = filename;
+        extension.paths = Module._nodeModulePaths(require('node:path').dirname(filename));
+        const vscode = { languages: { createDiagnosticCollection: () => ({}) } };
+        extension.require = name => name === 'vscode'
+          ? vscode : Module.prototype.require.call(extension, name);
+        try {
+          extension._compile(fs.readFileSync(filename, 'utf8'), filename);
+          if (typeof extension.exports.activate !== 'function')
+            throw new Error('The compiled extension does not export activate.');
+        } catch (error) {
+          console.error(error.message);
+          process.exitCode = 1;
+        }
+      `,
+      fileURLToPath(new URL("out/extension.js", vscodePlatform)),
+    ],
+    { stdio: "pipe" },
+  );
+});
+
+type RuntimeLocation =
+  | { kind: "bundled" }
+  | { kind: "cached"; directory: string }
+  | { kind: "missing" };
+
+/** Renders the active document with the extension's Preview and a stubbed VS Code. */
+async function renderPreview({
+  locate = () => ({ kind: "bundled" }),
+  config = { name: "Test" },
+  directives = {},
+  renders = 1,
+}: {
+  locate?: (element: DownloadableElement) => RuntimeLocation;
+  config?: HyperbookJson;
+  directives?: Record;
+  renders?: number;
+} = {}): Promise<{ ctx: HyperbookContext; messages: string[] }> {
+  const messages: string[] = [];
   const source = readFileSync(
-    new URL("../../../platforms/vscode/src/Preview.ts", import.meta.url),
+    new URL("src/Preview.ts", vscodePlatform),
     "utf8",
   );
   const compiled = ts.transpileModule(source, {
@@ -25,8 +92,12 @@ it("uses valid VS Code resource URLs for the bundle and local projects", async (
   };
   const vscode = {
     languages: { createDiagnosticCollection: () => ({}) },
+    commands: { executeCommand: () => {} },
     workspace: { getConfiguration: () => ({}) },
     window: {
+      showInformationMessage: async (message: string) => {
+        messages.push(message);
+      },
       activeTextEditor: {
         document: {
           uri: { fsPath: "/workspace/book/lesson/index.md" },
@@ -61,15 +132,20 @@ it("uses valid VS Code resource URLs for the bundle and local projects", async (
   };
   const modules: Record = {
     vscode,
-    path,
+    path: path.posix,
+    "./RuntimeAssets": {
+      runtimeElements: elements,
+      runtimeLabels: Object.fromEntries(elements.map((e) => [e, `<${e}>`])),
+    },
     "@hyperbook/fs": filesystem,
     "@hyperbook/types": {
+      elementCdn,
       isExternalUrl: (p: string) => /^https?:\/\//.test(p),
     },
     "@hyperbook/markdown": {
       process: async (_content: string, context: HyperbookContext) => {
         ctx = context;
-        return "";
+        return { data: { directives } };
       },
     },
   };
@@ -80,6 +156,9 @@ it("uses valid VS Code resource URLs for the bundle and local projects", async (
   });
   const preview = Object.create(exports.default.prototype);
   preview.context = { extensionUri: "/extension" };
+  // Object.create skips the constructor and its field initializers.
+  preview.offeredDownloads = new Set();
+  preview.runtimes = { locate: async (e: DownloadableElement) => locate(e) };
   preview.panel = {
     webview: {
       asWebviewUri: (p: string) => ({
@@ -88,18 +167,157 @@ it("uses valid VS Code resource URLs for the bundle and local projects", async (
     },
   };
   preview.checkDocumentIsHyperbookFile = () => true;
-  preview.getConfig = async () => ({ name: "Test" });
+  preview.getConfig = async () => config;
   preview.publishDiagnostics = () => {};
-  await preview.handleTextDocumentChange();
+  for (let i = 0; i < renders; i++) await preview.handleTextDocumentChange();
+  return { ctx: ctx!, messages };
+}
 
-  const asset = ctx!.makeUrl(["directive-blockflow", "editor.html"], "assets");
+it("uses valid VS Code resource URLs for the bundle and local projects", async () => {
+  const { ctx } = await renderPreview();
+
+  const asset = ctx.makeUrl(["directive-blockflow", "editor.html"], "assets");
   expect(asset).toBe(
     "https://file+.vscode-resource.vscode-cdn.net/extension/assets/hyperbook/directive-blockflow/editor.html",
   );
   expect(
     new URL(asset, "https://preview.vscode-webview.net/index.html").hostname,
   ).toBe("file+.vscode-resource.vscode-cdn.net");
-  expect(ctx!.makeUrl("/project.sb3", "public")).toBe(
+  expect(ctx.makeUrl("/project.sb3", "public")).toBe(
     "https://file+.vscode-resource.vscode-cdn.net/workspace/public/project.sb3",
   );
 });
+
+it("keeps local runtimes when the extension ships them", async () => {
+  const config = { name: "Test", elements: { pyide: { cdn: false } } };
+  const { ctx } = await renderPreview({ config });
+  expect(ctx.config).toEqual(config);
+});
+
+it("uses Pyodide's default CDN even when its local runtime is cached", async () => {
+  const located: DownloadableElement[] = [];
+  const { ctx, messages } = await renderPreview({
+    locate: (element) => {
+      located.push(element);
+      return {
+        kind: "cached",
+        directory: `/cache/hash/content-1/directive-${element}`,
+      };
+    },
+    directives: { pyide: {} },
+  });
+  expect(located).not.toContain("pyide");
+  expect(elementCdn(ctx.config, "pyide")).toBe(true);
+  expect(messages).toEqual([]);
+});
+
+it("uses runtimes from the CLI asset cache", async () => {
+  const { ctx, messages } = await renderPreview({
+    locate: (element) =>
+      element === "pyide" || element === "typst"
+        ? {
+            kind: "cached",
+            directory: `/cache/hash/content-1/directive-${element}`,
+          }
+        : { kind: "bundled" },
+    config: { name: "Test", elements: { pyide: { cdn: false } } },
+    directives: { pyide: {}, typst: {} },
+  });
+  const cache =
+    "https://file+.vscode-resource.vscode-cdn.net/cache/hash/content-1";
+  expect(ctx.config.elements?.pyide).toEqual({
+    cdn: `${cache}/directive-pyide/pyodide/`,
+  });
+  expect(ctx.config.elements?.typst).toEqual({
+    cdn: `${cache}/directive-typst/`,
+  });
+  expect(ctx.config.elements?.geogebra).toBeUndefined();
+  expect(messages).toEqual([]);
+});
+
+it("loads runtimes that are not downloaded from their CDN and offers a download once", async () => {
+  const { ctx, messages } = await renderPreview({
+    locate: () => ({ kind: "missing" }),
+    directives: { pyide: {}, "blockflow-player": {}, alert: {} },
+    renders: 2,
+  });
+  for (const element of elements)
+    expect(elementCdn(ctx.config, element)).toBe(true);
+  expect(messages).toHaveLength(1);
+  expect(messages[0]).toContain("");
+  expect(messages[0]).not.toContain("");
+  expect(messages[0]).not.toContain("");
+});
+
+it("keeps a book's own CDN and element settings, like a Hyperbook build", async () => {
+  const located: DownloadableElement[] = [];
+  const { ctx, messages } = await renderPreview({
+    locate: (element) => {
+      located.push(element);
+      return { kind: "missing" };
+    },
+    config: {
+      name: "Test",
+      elements: {
+        typst: { cdn: "https://assets.example.com/typst/" },
+        geogebra: { cdn: true },
+        onlineide: { height: 400 },
+      },
+    },
+    directives: { typst: {}, geogebra: {} },
+  });
+  expect(ctx.config.elements?.typst?.cdn).toBe(
+    "https://assets.example.com/typst/",
+  );
+  expect(located).not.toContain("typst");
+  expect(located).not.toContain("geogebra");
+  expect(ctx.config.elements?.onlineide).toEqual({ height: 400, cdn: true });
+  // The book chose these CDNs, so there is nothing to download.
+  expect(messages).toEqual([]);
+});
+
+it("leaves large element runtimes out of the VS Code package", async () => {
+  const require = createRequire(new URL("package.json", vscodePlatform));
+  const vsce = require("@vscode/vsce");
+  const fixture = mkdtempSync(path.join(tmpdir(), "hyperbook-vsix-"));
+  try {
+    for (const name of ["package.json", ".vscodeignore"])
+      writeFileSync(
+        path.join(fixture, name),
+        readFileSync(new URL(name, vscodePlatform)),
+      );
+    const write = (file: string) => {
+      mkdirSync(path.dirname(path.join(fixture, file)), { recursive: true });
+      writeFileSync(path.join(fixture, file), "");
+    };
+    write("out/extension.js");
+    write("out/asset-manifest.json");
+    write("assets/hyperbook/directive-alert/client.js");
+    for (const element of elements)
+      for (const file of [
+        ...elementCoreFiles[element],
+        ...elementRuntimeFiles[element],
+      ])
+        write(`assets/hyperbook/directive-${element}/${file}`);
+
+    const files: string[] = await vsce.listFiles({
+      cwd: fixture,
+      packageManager: vsce.PackageManager.None,
+    });
+    expect(files).toContain("out/extension.js");
+    expect(files).toContain("out/asset-manifest.json");
+    expect(files).toContain("assets/hyperbook/directive-alert/client.js");
+    for (const element of elements) {
+      for (const file of elementCoreFiles[element])
+        expect(files).toContain(
+          `assets/hyperbook/directive-${element}/${file}`,
+        );
+      for (const file of elementRuntimeFiles[element])
+        expect(files).not.toContain(
+          `assets/hyperbook/directive-${element}/${file}`,
+        );
+    }
+  } finally {
+    rmSync(fixture, { recursive: true, force: true });
+  }
+}, 60_000);
diff --git a/packages/markdown/tests/elementCdn.test.ts b/packages/markdown/tests/elementCdn.test.ts
index 9a64fc7f5..b9c434342 100644
--- a/packages/markdown/tests/elementCdn.test.ts
+++ b/packages/markdown/tests/elementCdn.test.ts
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
 import { DownloadableElement, elementCdn } from "@hyperbook/types";
 import { process as processMarkdown } from "../src/process";
 import { elementAssetUrl } from "../src/elementAssets";
-import packageJson from "../package.json";
+import excalidrawPackageJson from "../../web-component-excalidraw/package.json";
 import { ctx } from "./mock";
 
 const elements: [DownloadableElement, string][] = [
@@ -19,8 +19,26 @@ const elements: [DownloadableElement, string][] = [
 describe.each(elements)(
   "%s CDN configuration",
   (element: DownloadableElement, markdown: string) => {
-    it("keeps local assets as the default and accepts explicit false", async () => {
-      const local = await processMarkdown(markdown, ctx);
+    it("defaults to CDN for PyIDE and local assets for other elements", async () => {
+      const defaults = await processMarkdown(markdown, ctx);
+      const configured = await processMarkdown(markdown, {
+        ...ctx,
+        config: {
+          ...ctx.config,
+          elements: {
+            ...ctx.config.elements,
+            [element]: {
+              ...ctx.config.elements?.[element],
+              cdn: element === "pyide",
+            },
+          },
+        },
+      });
+      expect(defaults.value).toBe(configured.value);
+      expect(defaults.data.directives).toEqual(configured.data.directives);
+    });
+
+    it("accepts explicit false for local assets", async () => {
       const disabled = await processMarkdown(markdown, {
         ...ctx,
         config: {
@@ -31,8 +49,12 @@ describe.each(elements)(
           },
         },
       });
-      expect(disabled.value).toBe(local.value);
-      expect(disabled.data.directives).toEqual(local.data.directives);
+      const output =
+        String(disabled.value) + JSON.stringify(disabled.data.directives);
+      expect(output).not.toContain('data-runtime-url="https://');
+      expect(output).not.toContain("https://cdn.");
+      expect(output).not.toContain("http://assets.example.com/");
+      expect(disabled.data.directives).toHaveProperty(element);
     });
 
     it("uses the default CDN and a custom HTTP base URL", async () => {
@@ -49,8 +71,14 @@ describe.each(elements)(
                 pyide: "https://cdn.jsdelivr.net/pyodide/v314.0.7/full/",
                 typst: "https://cdn.jsdelivr.net/npm/@myriaddreamin/",
                 geogebra: "https://www.geogebra.org/apps/",
-              }[element as "pyide" | "typst" | "geogebra"] ||
-                `https://unpkg.com/@hyperbook/markdown@${packageJson.version}/dist/assets/directive-${element}/`
+                blockflow: "https://blockflow.openpatch.org/",
+                excalidraw:
+                  "https://unpkg.com/@hyperbook/web-component-excalidraw@",
+                onlineide:
+                  "https://cdn.openpatch.org/onlineide/v2.2.1-hyperbook.28/",
+                sqlide: "https://cdn.openpatch.org/sqlide/v2.0.0-hyperbook.4/",
+                openscad: "https://cdn.openpatch.org/openscad/2026.10.08-1/",
+              }[element]
             : "http://assets.example.com/runtime/",
         );
         if (element === "blockflow")
@@ -111,3 +139,49 @@ it("uses pinned upstream Typst fonts and GeoGebra codebases", () => {
     elementAssetUrl(configured, "geogebra", "GeoGebra/HTML5/5.0/web3d/"),
   ).toBe("https://www.geogebra.org/apps/5.4.931.2/web3d/");
 });
+
+it("loads Blockflow and Excalidraw from their upstream CDNs", () => {
+  const configured = {
+    ...ctx,
+    config: {
+      ...ctx.config,
+      elements: { blockflow: { cdn: true }, excalidraw: { cdn: true } },
+    },
+  };
+  expect(elementAssetUrl(configured, "blockflow", "editor.html")).toBe(
+    "https://blockflow.openpatch.org/editor.html",
+  );
+  expect(
+    elementAssetUrl(configured, "excalidraw", "hyperbook-excalidraw.umd.js"),
+  ).toBe(
+    `https://unpkg.com/@hyperbook/web-component-excalidraw@${excalidrawPackageJson.version}/dist/index.umd.js`,
+  );
+  const excalidraw = `https://unpkg.com/@excalidraw/excalidraw@${excalidrawPackageJson.dependencies["@excalidraw/excalidraw"]}/dist/prod/`;
+  expect(elementAssetUrl(configured, "excalidraw", "excalidraw.css")).toBe(
+    `${excalidraw}index.css`,
+  );
+  // Excalidraw resolves fonts/ beside its asset base.
+  expect(elementAssetUrl(configured, "excalidraw", "")).toBe(excalidraw);
+  // An unpkg URL needs an exact version to stay stable.
+  expect(excalidraw).toMatch(/@\d+\.\d+\.\d+\/dist/);
+});
+
+it.each([
+  ["onlineide", "v2.2.1-hyperbook.28", "include/online-ide-embedded.js"],
+  ["sqlide", "v2.0.0-hyperbook.4", "include/sql-ide-embedded.js"],
+  ["openscad", "2026.10.08-1", "openscad.wasm"],
+] as const)(
+  "pins %s's default CDN independently of the Markdown package",
+  (element: DownloadableElement, version: string, file: string) => {
+    const configured = {
+      ...ctx,
+      config: { ...ctx.config, elements: { [element]: { cdn: true } } },
+    };
+    expect(elementAssetUrl(configured, element, "")).toBe(
+      `https://cdn.openpatch.org/${element}/${version}/`,
+    );
+    expect(elementAssetUrl(configured, element, file)).toBe(
+      `https://cdn.openpatch.org/${element}/${version}/${file}`,
+    );
+  },
+);
diff --git a/packages/markdown/tests/packageContents.test.ts b/packages/markdown/tests/packageContents.test.ts
new file mode 100644
index 000000000..a42a28dfe
--- /dev/null
+++ b/packages/markdown/tests/packageContents.test.ts
@@ -0,0 +1,87 @@
+import { execFileSync } from "node:child_process";
+import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import {
+  DownloadableElement,
+  elementCoreFiles,
+  elementRuntimeFiles,
+} from "@hyperbook/types";
+import { expect, it } from "vitest";
+import packageJson from "../package.json";
+
+const elements = Object.keys(elementCoreFiles) as DownloadableElement[];
+
+/** What `npm publish` would upload, given a dist tree with every element. */
+function packedFiles(): string[] {
+  const fixture = mkdtempSync(path.join(tmpdir(), "hyperbook-markdown-pack-"));
+  try {
+    const { name, version, files } = packageJson;
+    writeFileSync(
+      path.join(fixture, "package.json"),
+      JSON.stringify({ name, version, files }),
+    );
+    const write = (file: string) => {
+      mkdirSync(path.dirname(path.join(fixture, file)), { recursive: true });
+      writeFileSync(path.join(fixture, file), "");
+    };
+    write("dist/index.js");
+    write("dist/assets/directive-blockflow-player/client.js");
+    for (const element of elements)
+      for (const file of [
+        ...elementCoreFiles[element],
+        ...elementRuntimeFiles[element],
+      ])
+        write(`dist/assets/directive-${element}/${file}`);
+    const [pack] = JSON.parse(
+      execFileSync("npm", ["pack", "--dry-run", "--json", "--ignore-scripts"], {
+        cwd: fixture,
+        encoding: "utf8",
+        stdio: ["ignore", "pipe", "ignore"],
+      }),
+    );
+    return pack.files.map((file: { path: string }) => file.path);
+  } finally {
+    rmSync(fixture, { recursive: true, force: true });
+  }
+}
+
+it("publishes integration files without any large element runtimes", () => {
+  const files = packedFiles();
+  expect(files).toContain("dist/index.js");
+  expect(files).toContain("dist/assets/directive-blockflow-player/client.js");
+  for (const element of elements) {
+    for (const file of elementCoreFiles[element])
+      expect(files).toContain(`dist/assets/directive-${element}/${file}`);
+    for (const file of elementRuntimeFiles[element]) {
+      const packed = files.includes(`dist/assets/directive-${element}/${file}`);
+      expect(packed, `${element}: ${file}`).toBe(false);
+    }
+  }
+}, 60_000);
+
+it("renders with the built ESM package in plain Node", () => {
+  execFileSync(
+    process.execPath,
+    [
+      "--input-type=module",
+      "-e",
+      `
+        const { process: render } = await import(process.argv[1]);
+        const result = await render('# Standalone renderer', {
+          root: process.cwd(),
+          config: { name: 'Standalone' },
+          makeUrl: parts => Array.isArray(parts) ? parts.join('/') : parts,
+          navigation: {
+            current: { name: 'Standalone' }, next: null, previous: null,
+            pages: [], sections: [], glossary: []
+          }
+        });
+        if (!String(result.value).includes('Standalone renderer'))
+          throw new Error('The built package did not render the Markdown.');
+      `,
+      new URL("../dist/index.js", import.meta.url).href,
+    ],
+    { stdio: "pipe" },
+  );
+});
diff --git a/packages/markdown/tests/vscodeRuntimeAssets.test.ts b/packages/markdown/tests/vscodeRuntimeAssets.test.ts
new file mode 100644
index 000000000..582607f01
--- /dev/null
+++ b/packages/markdown/tests/vscodeRuntimeAssets.test.ts
@@ -0,0 +1,178 @@
+import { createHash } from "node:crypto";
+import {
+  mkdirSync,
+  mkdtempSync,
+  readFileSync,
+  rmSync,
+  statSync,
+  writeFileSync,
+} from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { runInNewContext } from "node:vm";
+import ts from "typescript";
+import { create } from "tar";
+import * as hyperbookFs from "@hyperbook/fs";
+import * as types from "@hyperbook/types";
+import { afterEach, expect, it, vi } from "vitest";
+
+const source = readFileSync(
+  new URL("../../../platforms/vscode/src/RuntimeAssets.ts", import.meta.url),
+  "utf8",
+);
+
+let root: string | undefined;
+afterEach(() => {
+  vi.unstubAllEnvs();
+  if (root) rmSync(root, { recursive: true, force: true });
+  root = undefined;
+});
+
+/** The extension's RuntimeAssets with a stubbed VS Code API. */
+function loadRuntimeAssets(messages: string[]) {
+  const vscode = {
+    EventEmitter: class {
+      listeners: (() => void)[] = [];
+      event = (listener: () => void) => this.listeners.push(listener);
+      fire() {
+        this.listeners.forEach((listener) => listener());
+      }
+    },
+    ProgressLocation: { Notification: 15 },
+    window: {
+      withProgress: (_options: unknown, task: any) =>
+        task({ report: () => {} }),
+      showInformationMessage: (message: string) => messages.push(message),
+      showErrorMessage: (message: string) => messages.push(message),
+    },
+  };
+  const modules: Record = {
+    vscode,
+    path,
+    "@hyperbook/fs": hyperbookFs,
+    "@hyperbook/types": types,
+  };
+  const exports: any = {};
+  runInNewContext(
+    ts.transpileModule(source, {
+      compilerOptions: {
+        module: ts.ModuleKind.CommonJS,
+        esModuleInterop: true,
+        target: ts.ScriptTarget.ES2022,
+      },
+    }).outputText,
+    { exports, require: (name: string) => modules[name] },
+  );
+  return exports.default;
+}
+
+it("shares verified runtimes with the CLI's asset cache", async () => {
+  root = mkdtempSync(path.join(tmpdir(), "hyperbook-vscode-runtimes-"));
+  const cacheDir = path.join(root, "cache");
+  vi.stubEnv("HYPERBOOK_ASSET_CACHE", cacheDir);
+
+  // An installed extension: integration files only, plus the CLI manifest.
+  const extension = path.join(root, "extension");
+  const core = path.join(extension, "assets", "hyperbook", "directive-pyide");
+  mkdirSync(core, { recursive: true });
+  writeFileSync(path.join(core, "client.js"), "client");
+
+  // The runtime bundle a CLI release publishes.
+  const content = path.join(root, "content");
+  const runtime = path.join(content, "directive-pyide", "pyodide");
+  mkdirSync(runtime, { recursive: true });
+  writeFileSync(path.join(runtime, "pyodide.js"), "pyodide");
+  writeFileSync(path.join(runtime, "pyodide-lock.json"), "{}");
+  const files = {
+    "directive-pyide/pyodide/pyodide-lock.json": 2,
+    "directive-pyide/pyodide/pyodide.js": 7,
+  };
+  const archive = path.join(root, "pyide.tar.gz");
+  await create(
+    { file: archive, cwd: content, gzip: true, portable: true, noMtime: true },
+    Object.keys(files),
+  );
+  const manifest = {
+    format: 1,
+    version: "1.0.0",
+    bundles: {
+      pyide: {
+        url: "https://example.invalid/pyide.tar.gz",
+        sha256: createHash("sha256")
+          .update(readFileSync(archive))
+          .digest("hex"),
+        bytes: statSync(archive).size,
+        files,
+      },
+    },
+  };
+  mkdirSync(path.join(extension, "out"));
+  writeFileSync(
+    path.join(extension, "out", "asset-manifest.json"),
+    JSON.stringify(manifest),
+  );
+
+  const messages: string[] = [];
+  const RuntimeAssets = loadRuntimeAssets(messages);
+  const runtimes = new RuntimeAssets(extension);
+  expect(runtimes.cacheDir).toBe(cacheDir);
+  expect(await runtimes.locate("pyide")).toEqual({ kind: "missing" });
+  expect(await runtimes.locate("typst")).toEqual({ kind: "missing" });
+
+  // Serve the download from the verified archive instead of the network.
+  const fetch = vi.fn(async () => new Response(readFileSync(archive)));
+  vi.stubGlobal("fetch", fetch);
+  let changed = 0;
+  runtimes.onDidChange(() => changed++);
+  expect(await runtimes.download(["pyide"])).toBe(true);
+  vi.unstubAllGlobals();
+  expect(fetch).toHaveBeenCalledWith(
+    manifest.bundles.pyide.url,
+    expect.anything(),
+  );
+  expect(changed).toBe(1);
+
+  const location = await runtimes.locate("pyide");
+  expect(location.kind).toBe("cached");
+  expect(location.directory.startsWith(cacheDir)).toBe(true);
+  expect(
+    readFileSync(
+      path.join(location.directory, "pyodide", "pyodide.js"),
+      "utf8",
+    ),
+  ).toBe("pyodide");
+
+  // The CLI finds the same bundle offline, so neither downloads it twice.
+  const cli = new hyperbookFs.AssetManager({
+    offline: true,
+    cacheDir,
+    assetsPath: path.join(root, "cli-assets"),
+    manifestPath: path.join(extension, "out", "asset-manifest.json"),
+    bundlesPath: path.join(root, "cli-bundles"),
+  });
+  expect(await cli.ensure("pyide")).toBe(location.directory);
+
+  // A runtime the extension ships counts as bundled.
+  const bundled = path.join(
+    extension,
+    "assets",
+    "hyperbook",
+    "directive-typst",
+  );
+  mkdirSync(path.join(bundled, "fonts"), { recursive: true });
+  for (const file of types.elementRuntimeFiles.typst)
+    writeFileSync(path.join(bundled, file), "");
+  expect(await runtimes.locate("typst")).toEqual({ kind: "bundled" });
+});
+
+it("reports runtimes it cannot download", async () => {
+  root = mkdtempSync(path.join(tmpdir(), "hyperbook-vscode-runtimes-"));
+  vi.stubEnv("HYPERBOOK_ASSET_CACHE", path.join(root, "cache"));
+  const messages: string[] = [];
+  const RuntimeAssets = loadRuntimeAssets(messages);
+  // No manifest: for example a development build without the CLI.
+  const runtimes = new RuntimeAssets(path.join(root, "extension"));
+  expect(await runtimes.download(["geogebra"])).toBe(false);
+  expect(messages.at(-1)).toContain("GeoGebra");
+  expect(await runtimes.locate("geogebra")).toEqual({ kind: "missing" });
+});
diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts
index f5a0abb9b..ee5f82a59 100644
--- a/packages/types/src/index.ts
+++ b/packages/types/src/index.ts
@@ -3,7 +3,7 @@ type ElementConfig = {
 };
 
 export type ElementAssetConfig = ElementConfig & {
-  /** Use the default CDN (true), a custom HTTP(S) asset base URL, or local assets (false/default). */
+  /** Use the default CDN (true), a custom HTTP(S) asset base URL, or local assets (false). Defaults to true for PyIDE and false for other elements. */
   cdn?: boolean | string;
 };
 
@@ -21,6 +21,26 @@ export const elementCoreFiles = {
 
 export type DownloadableElement = keyof typeof elementCoreFiles;
 
+/** Files whose presence shows that an element's full runtime is available locally. */
+export const elementRuntimeFiles: Record = {
+  blockflow: ["player.html"],
+  onlineide: ["include/online-ide-embedded.js"],
+  sqlide: ["include/sql-ide-embedded.js"],
+  excalidraw: ["hyperbook-excalidraw.umd.js"],
+  geogebra: ["GeoGebra/deployggb.js"],
+  pyide: ["pyodide/pyodide.js", "pyodide/pyodide-lock.json"],
+  typst: [
+    "typst-bundle.js",
+    "typst-compiler.wasm",
+    "fonts/LibertinusSerif-Regular.otf",
+  ],
+  openscad: [
+    "openscad.wasm",
+    "libraries/BOSL2.zip",
+    "fonts/Roboto-Regular.ttf",
+  ],
+};
+
 export function elementCdn(
   config: HyperbookJson,
   element: string,
@@ -28,7 +48,8 @@ export function elementCdn(
   if (!Object.prototype.hasOwnProperty.call(elementCoreFiles, element))
     return false;
   const value = config.elements?.[element as DownloadableElement]?.cdn;
-  if (value === undefined || value === false) return false;
+  if (value === undefined) return element === "pyide";
+  if (value === false) return false;
   if (value === true) return true;
   if (typeof value === "string") {
     try {
diff --git a/platforms/vscode/.vscodeignore b/platforms/vscode/.vscodeignore
index aece64b11..421653100 100644
--- a/platforms/vscode/.vscodeignore
+++ b/platforms/vscode/.vscodeignore
@@ -11,3 +11,30 @@ vsc-extension-quickstart.md
 **/*.ts
 .prettierrc
 screenshots/**
+
+# Large element runtimes load from their CDNs instead (see src/Preview.ts).
+# Keep only the integration files listed in elementCoreFiles (@hyperbook/types).
+assets/hyperbook/directive-blockflow/**
+assets/hyperbook/directive-onlineide/**
+!assets/hyperbook/directive-onlineide/style.css
+assets/hyperbook/directive-sqlide/**
+!assets/hyperbook/directive-sqlide/client.js
+!assets/hyperbook/directive-sqlide/style.css
+assets/hyperbook/directive-excalidraw/**
+!assets/hyperbook/directive-excalidraw/client.js
+!assets/hyperbook/directive-excalidraw/style.css
+assets/hyperbook/directive-openscad/**
+!assets/hyperbook/directive-openscad/client.js
+!assets/hyperbook/directive-openscad/worker.js
+!assets/hyperbook/directive-openscad/style.css
+assets/hyperbook/directive-geogebra/**
+!assets/hyperbook/directive-geogebra/client.js
+!assets/hyperbook/directive-geogebra/geogebra-web-component.js
+!assets/hyperbook/directive-geogebra/style.css
+assets/hyperbook/directive-pyide/**
+!assets/hyperbook/directive-pyide/client.js
+!assets/hyperbook/directive-pyide/python-friendly-error-messages.js
+!assets/hyperbook/directive-pyide/style.css
+assets/hyperbook/directive-typst/**
+!assets/hyperbook/directive-typst/client.js
+!assets/hyperbook/directive-typst/style.css
diff --git a/platforms/vscode/README.md b/platforms/vscode/README.md
index 2fe8c1e46..07417babc 100644
--- a/platforms/vscode/README.md
+++ b/platforms/vscode/README.md
@@ -19,6 +19,28 @@ You can preview your Hyperbook pages by clicking the preview icon in the top rig
 
 ![Preview](https://github.com/openpatch/hyperbook/raw/main/platforms/vscode/screenshots/preview.gif)
 
+### Element runtimes
+
+To keep the extension small, it does not include the large runtimes of Pyodide,
+Typst, GeoGebra, OpenSCAD, Excalidraw, Blockflow, the Online IDE, and the SQL
+IDE. The preview resolves them like a Hyperbook build:
+
+- If your `hyperbook.json` sets `cdn` for an element, the preview loads it from
+  that CDN.
+- Otherwise the preview uses the runtime from the Hyperbook CLI's asset cache.
+  The extension and the CLI share this folder, so a runtime downloaded by
+  either one works for both.
+- A runtime that is not downloaded yet loads from the element's default CDN.
+  The preview offers to download it.
+
+To download runtimes for offline use, run **Hyperbook: Download Element
+Runtimes...** or **Hyperbook: Download All Element Runtimes**. They work like
+`hyperbook assets fetch` and `hyperbook assets fetch --all`. Downloads are
+verified with SHA-256 and stored in the CLI's cache folder: `~/.cache/hyperbook/assets`
+on Linux, `~/Library/Caches/hyperbook/assets` on macOS, and
+`%LOCALAPPDATA%\hyperbook\assets` on Windows. `HYPERBOOK_ASSET_CACHE` and
+`XDG_CACHE_HOME` change it, as for the CLI.
+
 ### Hyperbook Config
 
 The `hyperbook.json` is validated against a schema, which presents you
diff --git a/platforms/vscode/package.json b/platforms/vscode/package.json
index 12d928b17..2500230d3 100644
--- a/platforms/vscode/package.json
+++ b/platforms/vscode/package.json
@@ -76,6 +76,16 @@
         "command": "hyperbook.openDocumentation",
         "title": "Open Hyperbook Documentation",
         "category": "Hyperbook"
+      },
+      {
+        "command": "hyperbook.downloadRuntimes",
+        "title": "Download Element Runtimes...",
+        "category": "Hyperbook"
+      },
+      {
+        "command": "hyperbook.downloadAllRuntimes",
+        "title": "Download All Element Runtimes",
+        "category": "Hyperbook"
       }
     ],
     "menus": {
@@ -229,6 +239,7 @@
     "eslint": "10.8.1",
     "file-loader": "6.2.0",
     "glob": "13.0.6",
+    "hyperbook": "workspace:*",
     "node-polyfill-webpack-plugin": "^4.1.0",
     "npm-run-all": "4.1.5",
     "ovsx": "1.1.1",
diff --git a/platforms/vscode/schemas/hyperbook.schema.json b/platforms/vscode/schemas/hyperbook.schema.json
index b951a0f9a..ed152e1f8 100644
--- a/platforms/vscode/schemas/hyperbook.schema.json
+++ b/platforms/vscode/schemas/hyperbook.schema.json
@@ -14,7 +14,7 @@
                 {
                     "properties": {
                         "cdn": {
-                            "description": "Use the default CDN (true), a custom HTTP(S) asset base URL, or local assets (false/default).",
+                            "description": "Use the default CDN (true), a custom HTTP(S) asset base URL, or local assets (false). Defaults to true for PyIDE and false for other elements.",
                             "type": [
                                 "string",
                                 "boolean"
@@ -267,7 +267,7 @@
                         {
                             "properties": {
                                 "cdn": {
-                                    "description": "Use the default CDN (true), a custom HTTP(S) asset base URL, or local assets (false/default).",
+                                    "description": "Use the default CDN (true), a custom HTTP(S) asset base URL, or local assets (false). Defaults to true for PyIDE and false for other elements.",
                                     "type": [
                                         "string",
                                         "boolean"
@@ -334,7 +334,7 @@
                         {
                             "properties": {
                                 "cdn": {
-                                    "description": "Use the default CDN (true), a custom HTTP(S) asset base URL, or local assets (false/default).",
+                                    "description": "Use the default CDN (true), a custom HTTP(S) asset base URL, or local assets (false). Defaults to true for PyIDE and false for other elements.",
                                     "type": [
                                         "string",
                                         "boolean"
@@ -378,7 +378,7 @@
                         {
                             "properties": {
                                 "cdn": {
-                                    "description": "Use the default CDN (true), a custom HTTP(S) asset base URL, or local assets (false/default).",
+                                    "description": "Use the default CDN (true), a custom HTTP(S) asset base URL, or local assets (false). Defaults to true for PyIDE and false for other elements.",
                                     "type": [
                                         "string",
                                         "boolean"
diff --git a/platforms/vscode/src/Preview.ts b/platforms/vscode/src/Preview.ts
index 8604cd0fc..edf794021 100644
--- a/platforms/vscode/src/Preview.ts
+++ b/platforms/vscode/src/Preview.ts
@@ -12,12 +12,15 @@ import { process } from "@hyperbook/markdown";
 import { disposeAll } from "./utils/dispose";
 import path, { posix } from "path";
 import {
+  DownloadableElement,
   HyperbookContext,
   HyperbookJson,
   HyperbookPage,
   Navigation,
+  elementCdn,
   isExternalUrl,
 } from "@hyperbook/types";
+import RuntimeAssets, { runtimeElements, runtimeLabels } from "./RuntimeAssets";
 
 // Helper function to resolve relative paths
 const resolveRelativePath = (path: string, page: HyperbookPage): string => {
@@ -48,6 +51,13 @@ export default class Preview {
   private _resource: vscode.Uri | undefined;
   private _vfile: VFileBook | VFileGlossary | undefined;
 
+  runtimes: RuntimeAssets;
+  /** Elements that load from their CDN only because their runtime is missing. */
+  private cdnFallbacks = new Set();
+  /** Elements the user was already offered a download for. */
+  private readonly offeredDownloads = new Set();
+
+
   private readonly disposables: vscode.Disposable[] = [];
   private _disposed: boolean = false;
   private readonly _onDisposeEmitter = new vscode.EventEmitter();
@@ -61,8 +71,11 @@ export default class Preview {
   private static readonly diagnostics =
     vscode.languages.createDiagnosticCollection("hyperbook");
 
-  constructor(context: vscode.ExtensionContext) {
+  constructor(context: vscode.ExtensionContext, runtimes: RuntimeAssets) {
     this.context = context;
+    this.runtimes = runtimes;
+    runtimes.onDidChange(() => this.handleTextDocumentChange());
+
 
     vscode.workspace.onDidChangeTextDocument(async (e) => {
       // Only refresh when hyperbook config files change
@@ -124,6 +137,68 @@ export default class Preview {
     }
   }
 
+  /**
+   * Resolves element runtimes like a Hyperbook build. An element with a CDN in
+   * the book's config loads from it. Every other element uses its local
+   * runtime: shipped with a development build of the extension, or downloaded
+   * into the Hyperbook CLI's asset cache. A runtime that is not downloaded yet
+   * loads from the element's default CDN, so the preview still works.
+   */
+  async withRuntimes(config: HyperbookJson): Promise {
+    const elements: Record = { ...config.elements };
+    const fallbacks = new Set();
+    for (const element of runtimeElements) {
+      if (elementCdn(config, element)) {
+        continue;
+      }
+      const location = await this.runtimes.locate(element);
+      if (location.kind === "bundled") {
+        continue;
+      }
+      let cdn: string | true = true;
+      if (location.kind === "cached" && this.panel) {
+        // A custom CDN URL points at the contents of directive-/.
+        // Pyodide's URL points at its distribution folder instead.
+        cdn =
+          this.panel.webview
+            .asWebviewUri(vscode.Uri.file(location.directory))
+            .toString()
+            .replace(/\/?$/, "/") + (element === "pyide" ? "pyodide/" : "");
+      } else {
+        fallbacks.add(element);
+      }
+      elements[element] = { ...elements[element], cdn };
+    }
+    this.cdnFallbacks = fallbacks;
+    return { ...config, elements };
+  }
+
+  /** Offers to download the missing runtimes a page uses, once per element. */
+  private offerDownloads(used: DownloadableElement[]) {
+    const missing = used.filter(
+      (element) =>
+        this.cdnFallbacks.has(element) && !this.offeredDownloads.has(element),
+    );
+    if (missing.length === 0) {
+      return;
+    }
+    missing.forEach((element) => this.offeredDownloads.add(element));
+    const names = missing.map((element) => runtimeLabels[element]).join(", ");
+    vscode.window
+      .showInformationMessage(
+        `Hyperbook: The preview loads ${names} from the internet. Download the runtime to preview it offline, like a Hyperbook build.`,
+        "Download",
+      )
+      .then((choice) => {
+        if (choice === "Download") {
+          vscode.commands.executeCommand("hyperbook.downloadRuntimes", missing);
+        }
+      });
+  }
+
+  /** Elements used by the current page, for preselecting downloads. */
+  usedElements: DownloadableElement[] = [];
+
   async handleTextDocumentChange() {
     this.hyperbookViewerConfig = vscode.workspace.getConfiguration("hyperbook");
     if (
@@ -195,7 +270,7 @@ export default class Preview {
         ...publicBookFiles,
         ...publicGlossaryFiles,
       ];
-      const config = await this.getConfig();
+      const config = await this.withRuntimes(await this.getConfig());
 
       // Without this a `:::protect{use="..."}` block fails to render in the
       // preview, even though it builds fine.
@@ -351,6 +426,15 @@ export default class Preview {
       const result = await process(this._vfile.markdown.content, ctx);
 
       this.publishDiagnostics(result);
+      // Blockflow registers as blockflow-player and blockflow-editor.
+      const directives = Object.keys((result.data as any)?.directives || {});
+      this.usedElements = runtimeElements.filter((element) =>
+        directives.some(
+          (name) => name === element || name.startsWith(`${element}-`),
+        ),
+      );
+      this.offerDownloads(this.usedElements);
+
 
       this.panel.webview.html = String(result);
     }
@@ -457,6 +541,7 @@ export default class Preview {
           enableCommandUris: true,
           localResourceRoots: [
             vscode.Uri.joinPath(this.context.extensionUri, "assets"),
+            vscode.Uri.file(this.runtimes.cacheDir),
             vscode.Uri.file(vscode.workspace?.rootPath || ""),
           ],
         },
diff --git a/platforms/vscode/src/RuntimeAssets.ts b/platforms/vscode/src/RuntimeAssets.ts
new file mode 100644
index 000000000..2c7be5582
--- /dev/null
+++ b/platforms/vscode/src/RuntimeAssets.ts
@@ -0,0 +1,129 @@
+import * as vscode from "vscode";
+import path from "path";
+import { AssetManager, AssetOptions, assetCacheDir } from "@hyperbook/fs";
+import { DownloadableElement, elementCoreFiles } from "@hyperbook/types";
+
+export const runtimeElements = Object.keys(
+  elementCoreFiles,
+) as DownloadableElement[];
+
+export const runtimeLabels: Record = {
+  blockflow: "Blockflow",
+  excalidraw: "Excalidraw",
+  geogebra: "GeoGebra",
+  onlineide: "Online IDE",
+  openscad: "OpenSCAD",
+  pyide: "PyIDE (Pyodide)",
+  sqlide: "SQL IDE",
+  typst: "Typst",
+};
+
+/** Where an element's runtime is available to the preview. */
+export type RuntimeLocation =
+  | { kind: "bundled" }
+  | { kind: "cached"; directory: string }
+  | { kind: "missing" };
+
+/**
+ * Element runtimes in the Hyperbook CLI's asset cache.
+ *
+ * The extension does not ship large runtimes. It shares the CLI's cache
+ * folder and its verified, content-addressed bundles, so a runtime fetched by
+ * `hyperbook assets fetch` is used here too, and the other way round.
+ */
+export default class RuntimeAssets {
+  readonly cacheDir = assetCacheDir();
+  private readonly options: AssetOptions;
+  private readonly bundled: string;
+  private readonly found = new Map();
+  private readonly changed = new vscode.EventEmitter();
+  /** Fires after a download added runtimes to the cache. */
+  readonly onDidChange = this.changed.event;
+
+  constructor(extensionPath: string) {
+    this.bundled = path.join(extensionPath, "assets", "hyperbook");
+    this.options = {
+      cacheDir: this.cacheDir,
+      assetsPath: this.bundled,
+      // Copied from the CLI build, which publishes the matching bundles.
+      manifestPath: path.join(extensionPath, "out", "asset-manifest.json"),
+      bundlesPath: path.join(extensionPath, "asset-bundles"),
+    };
+  }
+
+  /** Looks the runtime up without downloading it. */
+  async locate(element: DownloadableElement): Promise {
+    const known = this.found.get(element);
+    if (known) {
+      return known;
+    }
+    let location: RuntimeLocation;
+    try {
+      const directory = await new AssetManager({
+        ...this.options,
+        offline: true,
+      }).ensure(element);
+      location =
+        directory === path.join(this.bundled, `directive-${element}`)
+          ? { kind: "bundled" }
+          : directory
+            ? { kind: "cached", directory }
+            : { kind: "missing" };
+    } catch {
+      location = { kind: "missing" };
+    }
+    // A missing runtime is looked up again, since the CLI may fetch it.
+    if (location.kind !== "missing") {
+      this.found.set(element, location);
+    }
+    return location;
+  }
+
+  /** Downloads and verifies runtimes into the shared cache. */
+  async download(elements: readonly DownloadableElement[]): Promise {
+    const pending: DownloadableElement[] = [];
+    for (const element of elements) {
+      if ((await this.locate(element)).kind === "missing") {
+        pending.push(element);
+      }
+    }
+    if (pending.length === 0) {
+      vscode.window.showInformationMessage(
+        "Hyperbook: The selected runtimes are already available.",
+      );
+      return true;
+    }
+    const failed: string[] = [];
+    const manager = new AssetManager(this.options);
+    await vscode.window.withProgress(
+      {
+        location: vscode.ProgressLocation.Notification,
+        title: "Hyperbook: Downloading runtimes",
+      },
+      async (progress) => {
+        for (const [index, element] of pending.entries()) {
+          progress.report({
+            message: `${runtimeLabels[element]} (${index + 1}/${pending.length})`,
+            increment: index === 0 ? 0 : 100 / pending.length,
+          });
+          try {
+            await manager.ensure(element);
+          } catch (error: any) {
+            failed.push(`${runtimeLabels[element]}: ${error.message}`);
+          }
+        }
+      },
+    );
+    this.changed.fire();
+    if (failed.length > 0) {
+      vscode.window.showErrorMessage(
+        `Hyperbook: Some runtimes could not be downloaded. The preview keeps loading them from their CDN. ${failed.join(" ")}`,
+      );
+      return false;
+    }
+    vscode.window.showInformationMessage(
+      `Hyperbook: Downloaded ${pending.map((element) => runtimeLabels[element]).join(", ")} to ${this.cacheDir}.`,
+    );
+    return true;
+  }
+}
diff --git a/platforms/vscode/src/StatusBarItem.ts b/platforms/vscode/src/StatusBarItem.ts
index 634c87530..3c2281da3 100644
--- a/platforms/vscode/src/StatusBarItem.ts
+++ b/platforms/vscode/src/StatusBarItem.ts
@@ -6,14 +6,14 @@ export default class StatusBarItem {
   statusBarItem: vscode.StatusBarItem;
   previewUtil: Preview;
 
-  constructor(context: vscode.ExtensionContext, utilities?: Preview) {
+  constructor(context: vscode.ExtensionContext, utilities: Preview) {
     this.statusBarItem = vscode.window.createStatusBarItem(
       vscode.StatusBarAlignment.Left
     );
     this.statusBarItem.command = "hyperbook.sidePreview";
     this.statusBarItem.tooltip =
       Constants.ExtensionConstants.STATUS_BAR_HTML_TOOLTIP;
-    this.previewUtil = (utilities && utilities) || new Preview(context);
+    this.previewUtil = utilities;
   }
 
   updateStatusbar() {
diff --git a/platforms/vscode/src/extension.ts b/platforms/vscode/src/extension.ts
index 08a62811f..f25b5fb70 100644
--- a/platforms/vscode/src/extension.ts
+++ b/platforms/vscode/src/extension.ts
@@ -5,9 +5,12 @@ import StatusBarItem from "./StatusBarItem";
 import { hyperbook } from "@hyperbook/fs";
 import { createNewHyperbook } from "./NewCommand";
 import { HyperbookFoldingProvider } from "./FoldingProvider";
+import RuntimeAssets, { runtimeElements, runtimeLabels } from "./RuntimeAssets";
+import { DownloadableElement } from "@hyperbook/types";
 
 export function activate(context: vscode.ExtensionContext) {
-  let preview = new Preview(context);
+  const runtimes = new RuntimeAssets(context.extensionPath);
+  let preview = new Preview(context, runtimes);
   let statusBarItem = new StatusBarItem(context, preview);
   statusBarItem.updateStatusbar();
 
@@ -169,6 +172,48 @@ Start writing your content here...
     },
   );
 
+  // Like `hyperbook assets fetch`: download element runtimes into the CLI's
+  // shared asset cache, so the preview works offline.
+  let disposableDownloadRuntimes = vscode.commands.registerCommand(
+    "hyperbook.downloadRuntimes",
+    async (elements?: DownloadableElement[]) => {
+      if (!elements) {
+        const items = await Promise.all(
+          runtimeElements.map(async (element) => {
+            const location = await runtimes.locate(element);
+            return {
+              label: runtimeLabels[element],
+              element,
+              description:
+                location.kind === "missing" ? undefined : "available offline",
+              picked:
+                location.kind === "missing" &&
+                preview.usedElements.includes(element),
+            };
+          }),
+        );
+        const picked = await vscode.window.showQuickPick(items, {
+          canPickMany: true,
+          title: "Download Hyperbook element runtimes",
+          placeHolder: `Runtimes are stored in ${runtimes.cacheDir}`,
+        });
+        if (!picked || picked.length === 0) {
+          return;
+        }
+        elements = picked.map((item) => item.element);
+      }
+      await runtimes.download(elements);
+    },
+  );
+
+  // Like `hyperbook assets fetch --all`.
+  let disposableDownloadAllRuntimes = vscode.commands.registerCommand(
+    "hyperbook.downloadAllRuntimes",
+    async () => {
+      await runtimes.download(runtimeElements);
+    },
+  );
+
   let disposableNew = vscode.commands.registerCommand(
     "hyperbook.new",
     async () => {
@@ -184,6 +229,8 @@ Start writing your content here...
   context.subscriptions.push(disposableOpenIndexMd);
   context.subscriptions.push(disposableCreateNewPage);
   context.subscriptions.push(disposableOpenDocumentation);
+  context.subscriptions.push(disposableDownloadRuntimes);
+  context.subscriptions.push(disposableDownloadAllRuntimes);
   context.subscriptions.push(disposableStatusBar);
 
   // Completions
diff --git a/platforms/vscode/webpack.extension.config.js b/platforms/vscode/webpack.extension.config.js
index fdb8a2b7d..fab689ecd 100644
--- a/platforms/vscode/webpack.extension.config.js
+++ b/platforms/vscode/webpack.extension.config.js
@@ -1,65 +1,94 @@
 const path = require("path");
+const fs = require("fs");
 const CopyPlugin = require("copy-webpack-plugin");
 
-module.exports = {
-  target: "node",
-  entry: path.join(__dirname, "src", "extension.ts"),
-  resolve: {
-    extensions: [".ts", ".js"],
-  },
-  output: {
-    filename: "extension.js",
-    libraryTarget: "commonjs2",
-    path: path.resolve(__dirname, "out"),
-    devtoolModuleFilenameTemplate: "../[resource-path]",
-  },
-  devtool: "source-map",
-  externals: {
-    vscode: "commonjs vscode", // the vscode-module is created on-the-fly and must be excluded. Add other modules that cannot be webpack'ed, 📖 -> https://webpack.js.org/configuration/externals/
-  },
-  plugins: [
-    new CopyPlugin({
-      patterns: [
+module.exports = (_env, argv) => {
+  // Remove previous build output, which may still contain bundled runtimes.
+  fs.rmSync(path.resolve(__dirname, "assets", "hyperbook"), {
+    recursive: true,
+    force: true,
+  });
+  return {
+    target: "node",
+    entry: path.join(__dirname, "src", "extension.ts"),
+    resolve: {
+      extensions: [".ts", ".js"],
+    },
+    output: {
+      filename: "extension.js",
+      libraryTarget: "commonjs2",
+      path: path.resolve(__dirname, "out"),
+      devtoolModuleFilenameTemplate: "../[resource-path]",
+    },
+    devtool: "source-map",
+    externals: {
+      vscode: "commonjs vscode", // the vscode-module is created on-the-fly and must be excluded. Add other modules that cannot be webpack'ed, 📖 -> https://webpack.js.org/configuration/externals/
+    },
+    plugins: [
+      new CopyPlugin({
+        patterns: [
+          {
+            from: path.resolve(
+              __dirname,
+              "node_modules",
+              // The CLI build retains exactly the small integration files.
+              "hyperbook",
+              "dist",
+              "assets",
+            ),
+            to: path.resolve(__dirname, "assets", "hyperbook"),
+          },
+          {
+            // Lists the verified runtime bundles of the matching CLI release.
+            // The preview downloads them into the CLI's shared asset cache.
+            from: path.resolve(
+              __dirname,
+              "node_modules",
+              "hyperbook",
+              "dist",
+              "asset-manifest.json",
+            ),
+            to: path.resolve(__dirname, "out", "asset-manifest.json"),
+            // Releases must ship it; development builds may skip the CLI build.
+            noErrorOnMissing: argv.mode !== "production",
+          },
+          {
+            from: path.resolve(
+              __dirname,
+              "node_modules",
+              "create-hyperbook",
+              "dist",
+              "templates",
+            ),
+            to: path.resolve(__dirname, "out", "templates"),
+          },
+        ],
+      }),
+    ],
+    module: {
+      rules: [
         {
-          from: path.resolve(
-            __dirname,
-            "node_modules",
-            "@hyperbook",
-            "markdown",
-            "dist",
-            "assets",
-          ),
-          to: path.resolve(__dirname, "assets", "hyperbook"),
+          test: /[\\/]markdown[\\/]dist[\\/]index\.js$/,
+          // esbuild already bundled the CommonJS dependencies. Its remaining
+          // dynamic requires use createRequire for Node built-ins; webpack's
+          // CommonJS parser would replace them with an empty module context.
+          parser: { commonjs: false, createRequire: false },
         },
         {
-          from: path.resolve(
-            __dirname,
-            "node_modules",
-            "create-hyperbook",
-            "dist",
-            "templates",
-          ),
-          to: path.resolve(__dirname, "out", "templates"),
-        },
-      ],
-    }),
-  ],
-  module: {
-    rules: [
-      {
-        test: /\.ts$/,
-        exclude: /node_modules/,
-        use: [
-          {
-            loader: "ts-loader",
-            options: {
-              compilerOptions: {
-                module: "es6", // override `tsconfig.json` so that TypeScript emits native JavaScript modules.
+          test: /\.ts$/,
+          exclude: /node_modules/,
+          use: [
+            {
+              loader: "ts-loader",
+              options: {
+                compilerOptions: {
+                  module: "es6", // override `tsconfig.json` so that TypeScript emits native JavaScript modules.
+                },
               },
             },
-          },
-        ],
-      },
-    ],
-  },
+          ],
+        },
+      ],
+    },
+  };
 };
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index aba5255ee..e4cf6f6cf 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -114,6 +114,9 @@ importers:
       mime-types:
         specifier: ^3.0.2
         version: 3.0.2
+      tar:
+        specifier: 7.5.22
+        version: 7.5.22
       unist-util-visit:
         specifier: ^5.1.0
         version: 5.1.0
@@ -656,6 +659,9 @@ importers:
       glob:
         specifier: 13.0.6
         version: 13.0.6
+      hyperbook:
+        specifier: workspace:*
+        version: link:../../packages/hyperbook
       node-polyfill-webpack-plugin:
         specifier: ^4.1.0
         version: 4.1.0(webpack@5.109.2)
diff --git a/scripts/buildPackage.mjs b/scripts/buildPackage.mjs
index 722f323a8..59b60c0fa 100644
--- a/scripts/buildPackage.mjs
+++ b/scripts/buildPackage.mjs
@@ -2,6 +2,7 @@ import chalk from "chalk";
 import { build } from "esbuild";
 import { existsSync, readFileSync } from "fs";
 import { join } from "path";
+import { builtinModules } from "module";
 
 const ignorePackages = [];
 
@@ -48,6 +49,8 @@ export const buildPackage = async (path) => {
   }
   external.push("path");
   external.push("fs");
+  // Node built-ins stay imports, for packages that only run in Node.
+  external.push(...builtinModules, "node:*");
 
   const platform = JSON.parse(packageJSON)?.platform || "browser";
 
@@ -60,6 +63,16 @@ export const buildPackage = async (path) => {
     bundle: true,
     platform,
     external,
+    // ESM hosts do not provide `require` for bundled CommonJS dependencies.
+    ...(platform === "node"
+      ? {
+          // Downstream CommonJS bundles provide __filename. Using it avoids
+          // their import.meta.url shim calling a shadowed require during setup.
+          banner: {
+            js: 'import { createRequire as __hyperbookCreateRequire } from "node:module"; const require = __hyperbookCreateRequire(typeof __filename === "string" ? __filename : import.meta.url);',
+          },
+        }
+      : {}),
   };
 
   // await build({
diff --git a/scripts/cdn-cors.json b/scripts/cdn-cors.json
new file mode 100644
index 000000000..1f31ff5c2
--- /dev/null
+++ b/scripts/cdn-cors.json
@@ -0,0 +1,13 @@
+{
+  "rules": [
+    {
+      "allowed": {
+        "origins": ["*"],
+        "methods": ["GET", "HEAD"],
+        "headers": ["*"]
+      },
+      "exposeHeaders": ["ETag", "Content-Length"],
+      "maxAgeSeconds": 86400
+    }
+  ]
+}
diff --git a/scripts/prepare-openscad-cdn.mjs b/scripts/prepare-openscad-cdn.mjs
new file mode 100644
index 000000000..d5dcd4856
--- /dev/null
+++ b/scripts/prepare-openscad-cdn.mjs
@@ -0,0 +1,59 @@
+import fs from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { installRuntimeAssets } from "../packages/markdown/runtime-assets.mjs";
+import { releasePrefix } from "./publish-cdn.mjs";
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+
+// Download the upstream WASM distribution, fonts and libraries directly. No
+// published Markdown package or compiled Hyperbook integration is needed.
+export async function prepareOpenscad(
+  directory = path.join(root, ".cache/cdn-openscad/runtime"),
+) {
+  const { wasmBuild } = JSON.parse(
+    await fs.readFile(
+      path.join(root, "packages/markdown/openscad-config.json"),
+      "utf8",
+    ),
+  );
+  releasePrefix("openscad", wasmBuild.cdnVersion);
+  const assets = JSON.parse(
+    await fs.readFile(
+      path.join(root, "packages/markdown/runtime-assets.json"),
+      "utf8",
+    ),
+  );
+  const extra = assets
+    .filter((asset) => asset.target.startsWith(`${wasmBuild.target}/`))
+    .map((asset) => ({
+      ...asset,
+      target: asset.target.slice(wasmBuild.target.length + 1),
+    }));
+  await fs.rm(directory, { recursive: true, force: true });
+  await installRuntimeAssets(
+    [
+      {
+        url: wasmBuild.url,
+        sha256: wasmBuild.sha256,
+        format: "zip",
+        target: ".",
+      },
+      ...extra,
+    ],
+    directory,
+    path.join(root, "packages/markdown/.cache/runtime-assets"),
+  );
+  for (const file of wasmBuild.files)
+    await fs.access(path.join(directory, file));
+  console.log(
+    `Prepared OpenSCAD ${wasmBuild.cdnVersion} from checksum-verified upstream assets`,
+  );
+  return wasmBuild.cdnVersion;
+}
+
+if (
+  process.argv[1] &&
+  path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
+)
+  await prepareOpenscad();
diff --git a/scripts/publish-cdn.mjs b/scripts/publish-cdn.mjs
new file mode 100644
index 000000000..7b15df322
--- /dev/null
+++ b/scripts/publish-cdn.mjs
@@ -0,0 +1,192 @@
+import { createHash } from "node:crypto";
+import { execFile } from "node:child_process";
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { promisify } from "node:util";
+
+// Kept in each publishing repository so releases do not depend on another repo.
+const run = promisify(execFile);
+const hash = (bytes) => createHash("sha256").update(bytes).digest("hex");
+const cacheControl = "public, max-age=31536000, immutable";
+const contentTypes = {
+  ".js": "text/javascript",
+  ".mjs": "text/javascript",
+  ".css": "text/css",
+  ".wasm": "application/wasm",
+  ".json": "application/json",
+  ".zip": "application/zip",
+  ".svg": "image/svg+xml",
+  ".png": "image/png",
+  ".jpg": "image/jpeg",
+  ".jpeg": "image/jpeg",
+  ".gif": "image/gif",
+  ".webp": "image/webp",
+  ".ico": "image/x-icon",
+  ".woff": "font/woff",
+  ".woff2": "font/woff2",
+  ".ttf": "font/ttf",
+  ".otf": "font/otf",
+  ".mp3": "audio/mpeg",
+  ".ogg": "audio/ogg",
+  ".wav": "audio/wav",
+  ".txt": "text/plain",
+};
+
+export function releasePrefix(element, version) {
+  if (
+    !["onlineide", "sqlide", "openscad"].includes(element) ||
+    !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(version) ||
+    version === "latest"
+  ) {
+    throw new Error(
+      "Expected a supported element and an immutable release version",
+    );
+  }
+  return `${element}/${version}`;
+}
+
+export async function runtimeManifest(directory) {
+  const files = {};
+  async function visit(folder, prefix = "") {
+    const entries = await fs.readdir(folder, { withFileTypes: true });
+    entries.sort((a, b) => a.name.localeCompare(b.name, "en"));
+    for (const entry of entries) {
+      const relative = `${prefix}${entry.name}`;
+      if (entry.isSymbolicLink())
+        throw new Error(`Runtime contains a symlink: ${relative}`);
+      if (entry.isDirectory())
+        await visit(path.join(folder, entry.name), `${relative}/`);
+      else if (entry.isFile() && !/\.(map|html)$/i.test(relative)) {
+        if (relative === "manifest.json")
+          throw new Error("manifest.json is reserved for publication metadata");
+        const bytes = await fs.readFile(path.join(folder, entry.name));
+        files[relative] = {
+          sha256: hash(bytes),
+          bytes: bytes.length,
+          contentType:
+            contentTypes[path.extname(relative).toLowerCase()] ||
+            "application/octet-stream",
+        };
+      }
+    }
+  }
+  await visit(directory);
+  if (!Object.keys(files).length)
+    throw new Error("No runtime files to publish");
+  return { schema: 1, files };
+}
+
+export function wranglerStore(bucket = "cdn", runImpl = run) {
+  return {
+    async get(key) {
+      const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "cdn-read-"));
+      const file = path.join(temporary, "object");
+      try {
+        await runImpl("wrangler", [
+          "r2",
+          "object",
+          "get",
+          `${bucket}/${key}`,
+          "--remote",
+          "--file",
+          file,
+        ]);
+        return await fs.readFile(file);
+      } catch (error) {
+        if (
+          /The specified key does not exist/.test(
+            `${error.stdout}\n${error.stderr}`,
+          )
+        )
+          return null;
+        throw error;
+      } finally {
+        await fs.rm(temporary, { recursive: true, force: true });
+      }
+    },
+    async put(key, file, contentType) {
+      await runImpl("wrangler", [
+        "r2",
+        "object",
+        "put",
+        `${bucket}/${key}`,
+        "--remote",
+        "--file",
+        file,
+        "--content-type",
+        contentType,
+        "--cache-control",
+        cacheControl,
+      ]);
+    },
+  };
+}
+
+export async function publishCdn({
+  directory,
+  element,
+  version,
+  store = wranglerStore(),
+}) {
+  const prefix = releasePrefix(element, version);
+  const manifest = await runtimeManifest(directory);
+  const bytes = Buffer.from(JSON.stringify(manifest, null, 2) + "\n");
+  const manifestKey = `${prefix}/manifest.json`;
+  const previous = await store.get(manifestKey);
+  if (previous) {
+    if (!bytes.equals(previous))
+      throw new Error(`Published runtime is immutable: ${prefix}`);
+    console.log(`${prefix} is already published and matches the release`);
+    return manifest;
+  }
+  // A failed upload can be resumed. Existing objects must match before reuse.
+  // The manifest is written last, after all payload objects have been uploaded.
+  const files = Object.entries(manifest.files);
+  let next = 0;
+  const uploads = await Promise.allSettled(
+    Array.from({ length: Math.min(4, files.length) }, async () => {
+      while (next < files.length) {
+        const [relative, metadata] = files[next++];
+        const key = `${prefix}/${relative}`;
+        const previous = await store.get(key);
+        if (previous) {
+          if (hash(previous) !== metadata.sha256)
+            throw new Error(`Existing runtime object differs: ${key}`);
+        } else {
+          const file = path.join(directory, relative);
+          if (hash(await fs.readFile(file)) !== metadata.sha256)
+            throw new Error(`Runtime changed during upload: ${relative}`);
+          await store.put(key, file, metadata.contentType);
+        }
+      }
+    }),
+  );
+  const failed = uploads.find((result) => result.status === "rejected");
+  if (failed) throw failed.reason;
+  const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "cdn-manifest-"));
+  try {
+    const file = path.join(temporary, "manifest.json");
+    await fs.writeFile(file, bytes);
+    await store.put(manifestKey, file, "application/json");
+  } finally {
+    await fs.rm(temporary, { recursive: true, force: true });
+  }
+  console.log(
+    `Published ${files.length} files at https://cdn.openpatch.org/${prefix}/`,
+  );
+  return manifest;
+}
+
+if (
+  process.argv[1] &&
+  path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
+) {
+  const [element, version, directory] = process.argv.slice(2);
+  if (!directory)
+    throw new Error(
+      "Usage: node scripts/publish-cdn.mjs   ",
+    );
+  await publishCdn({ directory, element, version });
+}
diff --git a/scripts/publish-cdn.test.mjs b/scripts/publish-cdn.test.mjs
new file mode 100644
index 000000000..0f74a0bec
--- /dev/null
+++ b/scripts/publish-cdn.test.mjs
@@ -0,0 +1,220 @@
+import assert from "node:assert/strict";
+import { createHash } from "node:crypto";
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { test } from "node:test";
+import {
+  publishCdn,
+  releasePrefix,
+  runtimeManifest,
+  wranglerStore,
+} from "./publish-cdn.mjs";
+import { verifyCdn } from "./verify-cdn.mjs";
+
+async function fixture(t) {
+  const directory = await fs.mkdtemp(path.join(os.tmpdir(), "cdn-test-"));
+  t.after(() => fs.rm(directory, { recursive: true, force: true }));
+  await fs.mkdir(path.join(directory, "include", "assets"), {
+    recursive: true,
+  });
+  await fs.writeFile(
+    path.join(directory, "include", "online-ide-embedded.js"),
+    "export const version = 1;",
+  );
+  await fs.writeFile(
+    path.join(directory, "include", "assets", "runtime.wasm"),
+    Buffer.from([0, 97, 115, 109]),
+  );
+  await fs.writeFile(
+    path.join(directory, "include", "example.html"),
+    "example",
+  );
+  await fs.writeFile(
+    path.join(directory, "include", "bundle.js.map"),
+    "source map",
+  );
+  const objects = new Map([
+    ["sqlide/v1/unchanged.js", Buffer.from("existing SQL release")],
+  ]);
+  const writes = [];
+  const store = {
+    get: async (key) => objects.get(key) || null,
+    async put(key, file, type) {
+      writes.push({ key, type });
+      objects.set(key, await fs.readFile(file));
+    },
+  };
+  return {
+    directory,
+    objects,
+    writes,
+    store,
+    element: "onlineide",
+    version: "v1",
+  };
+}
+
+test("publishes workers/WASM with correct MIME types, skips maps and HTML, and marks completion last", async (t) => {
+  const f = await fixture(t);
+  const manifest = await publishCdn(f);
+  assert.equal(f.writes.at(-1).key, "onlineide/v1/manifest.json");
+  assert.equal(
+    f.writes.find((write) => write.key.endsWith(".js")).type,
+    "text/javascript",
+  );
+  assert.equal(
+    f.writes.find((write) => write.key.endsWith(".wasm")).type,
+    "application/wasm",
+  );
+  assert.equal(Object.keys(manifest.files).length, 2);
+  assert.equal(
+    manifest.files["include/assets/runtime.wasm"].sha256,
+    createHash("sha256")
+      .update(Buffer.from([0, 97, 115, 109]))
+      .digest("hex"),
+  );
+  assert.equal(
+    f.objects.get("sqlide/v1/unchanged.js").toString(),
+    "existing SQL release",
+  );
+});
+
+test("retrying a completed release performs no writes; changed releases cannot replace it", async (t) => {
+  const f = await fixture(t);
+  await publishCdn(f);
+  const writes = f.writes.length;
+  await publishCdn(f);
+  assert.equal(f.writes.length, writes);
+  await fs.writeFile(
+    path.join(f.directory, "include", "online-ide-embedded.js"),
+    "changed",
+  );
+  await assert.rejects(publishCdn(f), /immutable/);
+  assert.equal(f.writes.length, writes);
+});
+
+test("failed publication leaves no completion marker and can resume existing payload files", async (t) => {
+  const f = await fixture(t);
+  const put = f.store.put;
+  f.store.put = async (key, ...args) => {
+    if (key.endsWith(".wasm")) throw new Error("Upload failed");
+    await put(key, ...args);
+  };
+  await assert.rejects(publishCdn(f), /Upload failed/);
+  assert.equal(f.objects.has("onlineide/v1/manifest.json"), false);
+  f.store.put = put;
+  await publishCdn(f);
+  assert.equal(f.objects.has("onlineide/v1/manifest.json"), true);
+});
+
+test("a conflicting object in a partial release is never overwritten", async (t) => {
+  const f = await fixture(t);
+  f.objects.set(
+    "onlineide/v1/include/assets/runtime.wasm",
+    Buffer.from("different"),
+  );
+  await assert.rejects(publishCdn(f), /Existing runtime object differs/);
+  assert.equal(
+    f.objects.get("onlineide/v1/include/assets/runtime.wasm").toString(),
+    "different",
+  );
+  assert.equal(f.objects.has("onlineide/v1/manifest.json"), false);
+});
+
+test("new versions retain all objects from prior versions", async (t) => {
+  const f = await fixture(t);
+  await publishCdn(f);
+  const previous = new Map(f.objects);
+  await publishCdn({ ...f, version: "v2" });
+  for (const [key, value] of previous)
+    assert.deepEqual(f.objects.get(key), value);
+});
+
+test("unsafe namespaces, empty runtimes, reserved metadata and symlinks are rejected", async (t) => {
+  for (const version of ["../x", "/x", "..", "latest", "v1?x", "v1/x"]) {
+    assert.throws(() => releasePrefix("onlineide", version));
+  }
+  assert.throws(() => releasePrefix("unknown", "v1"));
+  const f = await fixture(t);
+  const empty = path.join(f.directory, "empty");
+  await fs.mkdir(empty);
+  await assert.rejects(runtimeManifest(empty), /No runtime files/);
+  await fs.writeFile(path.join(f.directory, "manifest.json"), "reserved");
+  await assert.rejects(runtimeManifest(f.directory), /reserved/);
+  await fs.rm(path.join(f.directory, "manifest.json"));
+  await fs.symlink(
+    "include/assets/runtime.wasm",
+    path.join(f.directory, "link"),
+  );
+  await assert.rejects(runtimeManifest(f.directory), /symlink/);
+});
+
+test("Wrangler uploads to remote R2 with immutable caching and treats only missing keys as absent", async () => {
+  const calls = [];
+  const store = wranglerStore("cdn", async (command, args) => {
+    calls.push({ command, args });
+  });
+  await store.put(
+    "onlineide/v1/include/worker.js",
+    "/tmp/worker.js",
+    "text/javascript",
+  );
+  assert.equal(calls[0].command, "wrangler");
+  assert.deepEqual(calls[0].args, [
+    "r2",
+    "object",
+    "put",
+    "cdn/onlineide/v1/include/worker.js",
+    "--remote",
+    "--file",
+    "/tmp/worker.js",
+    "--content-type",
+    "text/javascript",
+    "--cache-control",
+    "public, max-age=31536000, immutable",
+  ]);
+  const missing = wranglerStore("cdn", async () => {
+    throw { stderr: "The specified key does not exist." };
+  });
+  assert.equal(await missing.get("absent"), null);
+  const denied = wranglerStore("cdn", async () => {
+    throw new Error("Authentication failed");
+  });
+  await assert.rejects(denied.get("absent"), /Authentication failed/);
+});
+
+test("public verification checks all payloads and fails for bad CORS, cache, MIME types and checksums", async (t) => {
+  const f = await fixture(t);
+  const manifest = await publishCdn(f);
+  let fault;
+  const fetchImpl = async (url, options) => {
+    assert.equal(options.headers.Origin, "https://hyperbook.openpatch.org");
+    const key = new URL(url).pathname.slice(1);
+    const relative = key.slice("onlineide/v1/".length);
+    const headers = {
+      "Access-Control-Allow-Origin": "*",
+      "Cache-Control": "public, max-age=31536000, immutable",
+      "Content-Type":
+        manifest.files[relative]?.contentType || "application/json",
+    };
+    if (fault === "CORS") delete headers["Access-Control-Allow-Origin"];
+    if (fault === "Cache-Control") delete headers["Cache-Control"];
+    if (fault === "Content-Type" && relative !== "manifest.json")
+      headers["Content-Type"] = "text/plain";
+    return new Response(
+      fault === "checksum" && relative !== "manifest.json"
+        ? "corrupt"
+        : f.objects.get(key),
+      { headers },
+    );
+  };
+  await verifyCdn({ ...f, fetchImpl });
+  await verifyCdn({ ...f, directory: undefined, manifest, fetchImpl });
+  for (fault of ["CORS", "Cache-Control", "Content-Type", "checksum"]) {
+    await assert.rejects(
+      verifyCdn({ ...f, fetchImpl }),
+      /CORS|Cache-Control|Content-Type|checksum/,
+    );
+  }
+});
diff --git a/scripts/verify-cdn.mjs b/scripts/verify-cdn.mjs
new file mode 100644
index 000000000..693ca7e74
--- /dev/null
+++ b/scripts/verify-cdn.mjs
@@ -0,0 +1,69 @@
+import { createHash } from "node:crypto";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { releasePrefix, runtimeManifest } from "./publish-cdn.mjs";
+
+// Read through the public hostname, exercising the path browsers actually use.
+export async function verifyCdn({
+  element,
+  version,
+  directory,
+  manifest: expectedManifest,
+  fetchImpl = fetch,
+}) {
+  const base = `https://cdn.openpatch.org/${releasePrefix(element, version)}/`;
+  const expected = expectedManifest || (await runtimeManifest(directory));
+  async function get(relative) {
+    const response = await fetchImpl(new URL(relative, base), {
+      headers: { Origin: "https://hyperbook.openpatch.org" },
+      signal: AbortSignal.timeout(120_000),
+    });
+    if (!response.ok) throw new Error(`${relative}: HTTP ${response.status}`);
+    if (response.headers.get("access-control-allow-origin") !== "*") {
+      throw new Error(`${relative}: missing public CORS headers`);
+    }
+    if (!response.headers.get("cache-control")?.includes("immutable")) {
+      throw new Error(`${relative}: missing immutable Cache-Control`);
+    }
+    return response;
+  }
+  const manifest = await (await get("manifest.json")).json();
+  if (JSON.stringify(manifest) !== JSON.stringify(expected))
+    throw new Error("Public CDN manifest differs from the release");
+  const files = Object.entries(expected.files);
+  let next = 0;
+  const results = await Promise.allSettled(
+    Array.from({ length: Math.min(4, files.length) }, async () => {
+      while (next < files.length) {
+        const [relative, metadata] = files[next++];
+        const response = await get(relative);
+        if (
+          response.headers.get("content-type")?.split(";")[0] !==
+          metadata.contentType
+        ) {
+          throw new Error(`${relative}: incorrect Content-Type`);
+        }
+        const bytes = Buffer.from(await response.arrayBuffer());
+        if (
+          bytes.length !== metadata.bytes ||
+          createHash("sha256").update(bytes).digest("hex") !== metadata.sha256
+        ) {
+          throw new Error(`${relative}: public CDN checksum mismatch`);
+        }
+      }
+    }),
+  );
+  const failed = results.find((result) => result.status === "rejected");
+  if (failed) throw failed.reason;
+  console.log(
+    `Verified ${files.length} public CDN files, CORS, MIME types and immutable caching at ${base}`,
+  );
+}
+
+if (
+  process.argv[1] &&
+  path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
+) {
+  const [element, version, directory] = process.argv.slice(2);
+  await verifyCdn({ element, version, directory });
+}
diff --git a/scripts/verify-default-cdns.mjs b/scripts/verify-default-cdns.mjs
new file mode 100644
index 000000000..cd6ee215d
--- /dev/null
+++ b/scripts/verify-default-cdns.mjs
@@ -0,0 +1,48 @@
+import fs from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { runtimeManifest } from "./publish-cdn.mjs";
+import { verifyCdn } from "./verify-cdn.mjs";
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+const markdown = path.join(root, "packages/markdown");
+const readJson = async (file) =>
+  JSON.parse(await fs.readFile(path.join(markdown, file), "utf8"));
+const ideReleases = await readJson("ide-releases.json");
+const { wasmBuild } = await readJson("openscad-config.json");
+const assets = await readJson("runtime-assets.json");
+const openscadFiles = new Set([
+  ...wasmBuild.files,
+  ...assets
+    .filter((asset) => asset.target.startsWith("directive-openscad/"))
+    .map((asset) => asset.target.slice("directive-openscad/".length)),
+]);
+
+// Gate package publication on the exact runtimes built into the CLI bundles.
+// The small Hyperbook integration scripts remain local, outside CDN manifests.
+const results = await Promise.allSettled(
+  Object.entries({ ...ideReleases, openscad: wasmBuild.cdnVersion }).map(
+    async ([element, version]) => {
+      const directory = path.join(
+        markdown,
+        "dist/assets",
+        `directive-${element}`,
+      );
+      const manifest = await runtimeManifest(directory);
+      manifest.files = Object.fromEntries(
+        Object.entries(manifest.files).filter(([file]) =>
+          element === "openscad"
+            ? openscadFiles.has(file)
+            : file.startsWith("include/"),
+        ),
+      );
+      await verifyCdn({ element, version, manifest });
+    },
+  ),
+);
+const failed = results.filter((result) => result.status === "rejected");
+if (failed.length)
+  throw new AggregateError(
+    failed.map((result) => result.reason),
+    "Default CDN runtimes must be published before Hyperbook packages",
+  );
diff --git a/website/de/book/configuration/book.md b/website/de/book/configuration/book.md
index c74716c70..674fe834d 100644
--- a/website/de/book/configuration/book.md
+++ b/website/de/book/configuration/book.md
@@ -109,15 +109,16 @@ Hier ist eine Beispielkonfiguration:
 
 ## Lokale Dateien und CDNs
 
-Hyperbook lädt große Laufzeitumgebungen beim ersten Bedarf herunter und kopiert
-sie in den Build. Mit `cdn` in der Elementkonfiguration kannst du sie stattdessen
-von einem CDN laden:
+PyIDE verwendet standardmäßig das Pyodide-CDN, weil die vollständige Distribution
+sehr groß ist. Andere Elemente laden ihre großen Laufzeitumgebungen beim ersten
+Bedarf herunter und kopieren sie in den Build. Mit `cdn` kannst du dieses
+Verhalten ausdrücklich festlegen:
 
 ```json
 {
   "name": "Mein Hyperbook",
   "elements": {
-    "pyide": { "cdn": true },
+    "pyide": { "cdn": false },
     "typst": { "cdn": "https://assets.example.com/directive-typst/" },
     "geogebra": { "cdn": true },
     "openscad": { "cdn": false }
@@ -127,7 +128,8 @@ von einem CDN laden:
 
 | Wert | Verhalten |
 | --- | --- |
-| Nicht gesetzt oder `false` | Die Laufzeitumgebung herunterladen und in den Build kopieren. Dies ist der Standard. |
+| Nicht gesetzt | Bei `pyide` das Standard-CDN verwenden; bei anderen Elementen die Laufzeitumgebung herunterladen und in den Build kopieren. |
+| `false` | Die Laufzeitumgebung herunterladen und in den Build kopieren, auch bei `pyide`. |
 | `true` | Die Laufzeitumgebung vom Standard-CDN laden. |
 | Eine HTTP(S)-Basis-URL | Die Laufzeitumgebung vom eigenen Server oder CDN laden. |
 
@@ -136,9 +138,22 @@ Diese Option gibt es für `pyide`, `typst`, `geogebra`, `openscad`, `excalidraw`
 Player und den Editor. Kiri:Moto verwendet weiterhin seinen externen Dienst.
 
 Die Standard-CDNs sind jsDelivr für Pyodide und Typst, der GeoGebra-Server für
-GeoGebra und die versionierten Hyperbook-Dateien auf UNPKG für die anderen
-Elemente. Die kleinen Integrationsskripte und Styles von Hyperbook bleiben im
-Build.
+GeoGebra, die gehostete Blockflow-App unter `blockflow.openpatch.org` für
+Blockflow, die eigenen Pakete von Excalidraw auf UNPKG für Excalidraw und
+`cdn.openpatch.org` für OpenSCAD, die Online-IDE und die SQL-IDE. Diese
+Openpatch-Laufzeitumgebungen verwenden festgelegte Releases, die direkt aus
+ihrem jeweiligen Repository veröffentlicht werden. Die kleinen
+Integrationsskripte und Styles von Hyperbook bleiben im Build.
+
+Die Hyperbook-Erweiterung für VS Code enthält diese Laufzeitumgebungen nicht.
+Ihre Vorschau verhält sich wie ein Build: Elemente mit `cdn` laden von diesem
+CDN, die anderen verwenden die Laufzeitumgebungen aus dem Asset-Cache der CLI,
+den die Erweiterung mitbenutzt. Eine noch nicht heruntergeladene
+Laufzeitumgebung lädt vom Standard-CDN. Mit **Hyperbook: Download Element
+Runtimes...** oder **Hyperbook: Download All Element Runtimes** lädst du
+Laufzeitumgebungen in VS Code für die Offline-Vorschau herunter.
+Setze bei PyIDE zusätzlich `elements.pyide.cdn` auf `false`, um die
+heruntergeladene Laufzeitumgebung zu verwenden.
 
 Eine eigene URL muss auf den Inhalt des Verzeichnisses
 `__hyperbook_assets/directive-/` aus einem lokalen Build zeigen.
@@ -158,3 +173,6 @@ Für `hyperbook build --offline` müssen diese nicht im Cache liegen. Zum Lesen
 des Buchs ist weiterhin Zugriff auf das konfigurierte CDN nötig.
 `hyperbook assets fetch --all` lädt unabhängig von der Elementkonfiguration
 alle Laufzeitumgebungen herunter.
+
+Siehe [Hosting und Caching](/hosting/caching), um Downloads zwischen CI-Builds
+aufzubewahren und Cache-Header für das exportierte Buch festzulegen.
diff --git a/website/de/book/elements/blockflow.md b/website/de/book/elements/blockflow.md
index ae2ae99aa..61f2abf4c 100644
--- a/website/de/book/elements/blockflow.md
+++ b/website/de/book/elements/blockflow.md
@@ -7,9 +7,11 @@ lang: de
 
 [Blockflow](https://blockflow.openpatch.org) ist ein Fork von Scratch zum Erstellen von geführten Tutorials. Hyperbook bietet zwei Direktiven zum Einbetten von Blockflow: einen **Player** zum Abspielen und einen **Editor** zum Erstellen von Tutorials.
 
-Hyperbook und die VS Code-Erweiterung enthalten ein Blockflow-Browserpaket mit fester Version.
-Bücher mit einer dieser Direktiven verwenden eine gemeinsame lokale Kopie und sind für Editor
-und Player nicht auf blockflow.openpatch.org angewiesen. Lokale Projekte und Tutorial-Medien
+Hyperbook enthält ein Blockflow-Browserpaket mit fester Version. Die CLI lädt es herunter, wenn ein
+Build Blockflow zum ersten Mal verwendet. Bücher mit einer dieser Direktiven verwenden eine
+gemeinsame lokale Kopie und sind für Editor und Player nicht auf blockflow.openpatch.org
+angewiesen. Die VS Code-Erweiterung enthält das Paket nicht. Ihre Vorschau verwendet die von der CLI
+heruntergeladene Kopie oder lädt Blockflow bis zum Download von blockflow.openpatch.org. Lokale Projekte und Tutorial-Medien
 funktionieren offline, wenn das Buch lokal bereitgestellt wird. Externe Projekte, Inhalte aus der
 Scratch-Bibliothek und Netzwerk-Erweiterungen benötigen weiterhin Internetzugang.
 Lizenz und Quellcode-Link von Blockflow werden mit den Dateien ausgeliefert.
diff --git a/website/de/book/elements/pyide.md b/website/de/book/elements/pyide.md
index af5b49c83..66184694c 100644
--- a/website/de/book/elements/pyide.md
+++ b/website/de/book/elements/pyide.md
@@ -4,7 +4,22 @@ permaid: pyide
 lang: de
 ---
 
-Die Pyodide-Laufzeitumgebung und ihre mitgelieferten Pakete sind lokal im exportierten Hyperbook enthalten. Die CLI lädt sie beim ersten Build mit diesem Element herunter und speichert sie im Cache. Mitgelieferte Pakete können ohne CDN-Verbindung importiert werden. Pakete, die über das Attribut `packages` von PyPI installiert werden, benötigen weiterhin eine Netzwerkverbindung.
+PyIDE lädt Pyodide standardmäßig vom versionierten jsDelivr-CDN. Die vollständige Laufzeitumgebung mit ihren mitgelieferten Paketen ist sehr groß und bleibt deshalb außerhalb des exportierten Hyperbooks. Leser benötigen eine Internetverbindung. Ihr Browser lädt die Laufzeitumgebung und angeforderte Pakete und kann diese Dateien für spätere Besuche zwischenspeichern.
+
+Für selbst gehostete oder offline nutzbare Bücher kannst du Pyodide lokal einbinden. Setze dafür `elements.pyide.cdn` in `hyperbook.json` auf `false`:
+
+```json
+{
+  "name": "Mein Offline-Hyperbook",
+  "elements": {
+    "pyide": { "cdn": false }
+  }
+}
+```
+
+Die CLI lädt dann beim ersten Build mit PyIDE die vollständige Distribution herunter und speichert sie im Cache. Führe für einen Offline-Build zunächst mit Internetverbindung `npx hyperbook assets fetch` und anschließend `npx hyperbook build --offline` aus. Führe in VS Code zusätzlich **Hyperbook: Download Element Runtimes...** aus und wähle PyIDE. Mitgelieferte Pakete funktionieren dann ohne CDN-Verbindung. Pakete, die über das Attribut `packages` von PyPI installiert werden, benötigen weiterhin eine Netzwerkverbindung.
+
+Siehe [CDN-Konfiguration](/configuration/book#lokale-dateien-und-cdns) für eigene Server und [Hosting und Caching](/hosting/caching) für Browser- und Build-Caches.
 
 Das `pyide`-Element repräsentiert eine Python-Integrated-Development-Environment (IDE)-Komponente.
 Es wird verwendet, um eine Python-Coding-Umgebung in die Hyperbook-Website einzubetten.
diff --git a/website/de/book/hosting/caching.md b/website/de/book/hosting/caching.md
new file mode 100644
index 000000000..5c4754eac
--- /dev/null
+++ b/website/de/book/hosting/caching.md
@@ -0,0 +1,88 @@
+---
+name: Caching
+lang: de
+---
+
+# Caching
+
+Der Build-Cache von Hyperbook speichert heruntergeladene Laufzeitumgebungen auf
+dem Rechner der CLI. HTTP-Caches speichern Dateien, die Leser von deinem Host
+oder einem CDN abrufen.
+
+## Downloads zwischen Builds aufbewahren
+
+CLI und VS Code teilen sich einen Asset-Cache. Builds verwenden bereits
+heruntergeladene Laufzeitumgebungen erneut. Mit `HYPERBOOK_ASSET_CACHE` kannst
+du das Verzeichnis festlegen. Standardmäßig wird dieser Pfad verwendet:
+
+| System | Verzeichnis |
+| --- | --- |
+| Linux | `$XDG_CACHE_HOME/hyperbook/assets` oder `~/.cache/hyperbook/assets` |
+| macOS | `~/Library/Caches/hyperbook/assets` |
+| Windows | `%LOCALAPPDATA%\hyperbook\assets` |
+
+CI-Runner starten normalerweise mit einem leeren Dateisystem. Stelle den
+Asset-Cache vor dem Build wieder her und speichere ihn danach. Füge zum
+Beispiel diese Schritte nach Checkout und Node.js-Einrichtung in deinen
+GitHub-Pages-Workflow ein:
+
+```yaml
+- name: Cache Hyperbook runtimes
+  uses: actions/cache@v4
+  with:
+    path: .cache/hyperbook-assets
+    key: ${{ runner.os }}-hyperbook-assets-${{ hashFiles('package-lock.json', 'pnpm-lock.yaml', 'hyperbook.json') }}-${{ github.sha }}
+    restore-keys: |
+      ${{ runner.os }}-hyperbook-assets-
+
+- name: Build
+  env:
+    HYPERBOOK_ASSET_CACHE: .cache/hyperbook-assets
+  run: npx hyperbook build
+```
+
+Der Cache-Schlüssel speichert neu benötigte Laufzeitumgebungen, wenn sich das
+Buch ändert. Mit `restore-keys` verwenden spätere Builds frühere Downloads
+erneut. Hyperbook prüft die Dateien anhand seines Laufzeitmanifests und lädt
+fehlende oder geänderte Pakete herunter. Verwende den normalen Online-Build,
+damit ein fehlender Cache automatisch ergänzt wird. Siehe die
+[GitHub-Dokumentation zum Caching](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching).
+
+Bei GitLab kannst du `.cache/hyperbook-assets` zwischenspeichern und dieselbe
+Variable `HYPERBOOK_ASSET_CACHE` im Pages-Job setzen. Veröffentliche dieses
+Verzeichnis nicht, sondern nur `.hyperbook/out`.
+
+Elemente mit aktiviertem CDN benötigen keinen lokalen Download. PyIDE verwendet
+standardmäßig ein CDN. Setze für ein Offline-Buch `elements.pyide.cdn` auf
+`false`, führe mit Internetverbindung `npx hyperbook assets fetch` aus und baue
+anschließend mit `npx hyperbook build --offline`. `--offline` steuert die
+Downloads beim Build. Ein Buch mit CDN-URLs wird dadurch nicht offline nutzbar.
+
+## Dateien im Browser zwischenspeichern
+
+Wenn dein Host eigene Antwort-Header erlaubt, kannst du für öffentliche Bücher
+mit diesen Einstellungen beginnen:
+
+| Dateien | `Cache-Control` | Wirkung |
+| --- | --- | --- |
+| HTML und Dateien, die unter derselben URL ersetzt werden | `no-cache` | Vor der Wiederverwendung auf Aktualisierungen prüfen. |
+| Dateien mit Inhalts-Hash oder unveränderlicher Release-Version in der URL | `public, max-age=31536000, immutable` | Ein Jahr wiederverwenden; Änderungen unter einer neuen URL veröffentlichen. |
+
+Aktiviere auf deinem Server `ETag` oder `Last-Modified`, damit unveränderte
+Dateien ohne erneuten Download ihrer Inhalte geprüft werden können. Für stabile
+URLs ist auch eine kurze Dauer wie `public, max-age=3600` möglich, wenn bis zu
+eine Stunde Verzögerung nach Änderungen akzeptabel ist. Diese Einstellungen
+folgen dem [HTTP-Caching-Standard](https://httpwg.org/specs/rfc9111.html#field.cache-control).
+
+Die lokal exportierten Laufzeitumgebungen verwenden stabile Pfade unter
+`__hyperbook_assets/directive-*/`. Gib nicht dem gesamten Verzeichnis eine
+unveränderliche Cache-Dauer: Ein Hyperbook-Update kann Dateien unter derselben
+URL ersetzen. Das gilt auch für Dateien aus deinem `public`-Verzeichnis. Setze
+die Header beim Hosting-Dienst oder Webserver. Ein Eintrag in `hyperbook.json`
+konfiguriert kein HTTP-Caching.
+
+Die versionierten Laufzeit-Releases auf `cdn.openpatch.org` senden bereits
+`Cache-Control: public, max-age=31536000, immutable`. Auch die versionierte
+Pyodide-Distribution auf jsDelivr wird im Browser zwischengespeichert; siehe die
+[Pyodide-Dokumentation](https://pyodide.org/en/stable/usage/downloading-and-deploying.html).
+Die Hosting-Einstellungen deines Buchs ändern die Header dieser CDNs nicht.
diff --git a/website/de/book/hosting/custom.md b/website/de/book/hosting/custom.md
index 7e3ce819b..4c408a9d2 100644
--- a/website/de/book/hosting/custom.md
+++ b/website/de/book/hosting/custom.md
@@ -5,6 +5,9 @@ lang: de
 
 # Auf eigenen Server bereitstellen
 
+Siehe [Caching](/hosting/caching#dateien-im-browser-zwischenspeichern) für
+empfohlene HTTP-Header bei der Konfiguration deines Servers.
+
 Du muss nur den Ausgabeordner auf deinen Server kopieren, nachdem du den build Befehl ausgeführt hast.
 
 ```
diff --git a/website/de/book/hosting/ghpages.md b/website/de/book/hosting/ghpages.md
index 94861ba23..0ee5a73c5 100644
--- a/website/de/book/hosting/ghpages.md
+++ b/website/de/book/hosting/ghpages.md
@@ -5,6 +5,9 @@ lang: de
 
 # Auf GitHub Pages bereitstellen
 
+Siehe [Caching](/hosting/caching#downloads-zwischen-builds-aufbewahren), um
+heruntergeladene Laufzeitumgebungen zwischen Workflow-Läufen wiederzuverwenden.
+
 :::alert{warn}
 Denke daran den basePath in deiner [Konfiguration](/configuration/book) zu
 setzen, when du dein Hyperbook auf GitHub bereitstellst.
diff --git a/website/de/book/hosting/glpages.md b/website/de/book/hosting/glpages.md
index fba15f972..106e07db7 100644
--- a/website/de/book/hosting/glpages.md
+++ b/website/de/book/hosting/glpages.md
@@ -5,6 +5,9 @@ lang: de
 
 # Deploy on GitLab Pages
 
+Siehe [Caching](/hosting/caching#downloads-zwischen-builds-aufbewahren), um
+heruntergeladene Laufzeitumgebungen zwischen Pages-Builds wiederzuverwenden.
+
 :::alert{warn}
 Denke daran den basePath in deiner [Konfiguration](/configuration/book) zu
 setzen, when du dein Hyperbook auf GitLab bereitstellst.
diff --git a/website/de/book/hosting/index.md b/website/de/book/hosting/index.md
index cec07a566..caa427b6a 100644
--- a/website/de/book/hosting/index.md
+++ b/website/de/book/hosting/index.md
@@ -1,3 +1,18 @@
 ---
 name: Hosting
 ---
+
+# Hosting
+
+Baue dein Hyperbook mit `npx hyperbook build` und veröffentliche `.hyperbook/out`
+auf einem statischen Hosting-Dienst. Wähle [GitHub Pages](/hosting/ghpages),
+[GitLab Pages](/hosting/glpages), [Vercel](/hosting/vercel) oder
+[einen eigenen Server](/hosting/custom).
+
+PyIDE verwendet standardmäßig das Pyodide-CDN, damit das exportierte Buch klein
+bleibt. Leser benötigen Zugriff auf dieses CDN. Setze `elements.pyide.cdn` auf
+`false`, um die vollständige Laufzeitumgebung selbst zu hosten; siehe
+[PyIDE](/elements/pyide).
+
+Unter [Caching](/hosting/caching) erfährst du, wie CI-Builds schneller werden und
+Leser große Dateien wiederverwenden, während sie aktualisierte Seiten erhalten.
diff --git a/website/de/book/hosting/vercel.md b/website/de/book/hosting/vercel.md
index db82b99fc..eceec44c2 100644
--- a/website/de/book/hosting/vercel.md
+++ b/website/de/book/hosting/vercel.md
@@ -5,6 +5,9 @@ lang: de
 
 # Deploy on Vercel
 
+Siehe [Caching](/hosting/caching#dateien-im-browser-zwischenspeichern) für
+Cache-Header bei der Konfiguration deines Deployments.
+
 Zuerst musst du eine `vercel.json`-Datei erstellen mit dem folgenden Inhalt:
 
 ```json
diff --git a/website/en/book/configuration/book.md b/website/en/book/configuration/book.md
index c71443ba9..d2d947cfc 100644
--- a/website/en/book/configuration/book.md
+++ b/website/en/book/configuration/book.md
@@ -111,15 +111,15 @@ Here is an example configuration:
 
 ## Local assets and CDNs
 
-Hyperbook downloads large element runtimes when they are first needed and copies
-them into the build. To load a runtime from a CDN instead, set `cdn` in its
-element configuration:
+PyIDE uses Pyodide's CDN by default because its full distribution is very large.
+Other elements download their large runtimes when first needed and copy them
+into the build. Use `cdn` in an element's configuration to choose explicitly:
 
 ```json
 {
   "name": "My Hyperbook",
   "elements": {
-    "pyide": { "cdn": true },
+    "pyide": { "cdn": false },
     "typst": { "cdn": "https://assets.example.com/directive-typst/" },
     "geogebra": { "cdn": true },
     "openscad": { "cdn": false }
@@ -129,7 +129,8 @@ element configuration:
 
 | Value | Behavior |
 | --- | --- |
-| Omitted or `false` | Download and include the runtime in the build. This is the default. |
+| Omitted | Use the default CDN for `pyide`; download and include the runtime for other elements. |
+| `false` | Download and include the runtime in the build, including for `pyide`. |
 | `true` | Load the runtime from its default CDN. |
 | An HTTP(S) base URL | Load the runtime from your own server or CDN. |
 
@@ -139,8 +140,19 @@ applies to both the player and editor. Kiri:Moto continues to use its external
 service.
 
 The default CDNs are jsDelivr for Pyodide and Typst, GeoGebra's server for
-GeoGebra, and UNPKG's versioned Hyperbook assets for the other elements.
-Hyperbook's small integration scripts and styles stay in the build.
+GeoGebra, the hosted Blockflow app at `blockflow.openpatch.org` for Blockflow,
+Excalidraw's own packages on UNPKG for Excalidraw, and `cdn.openpatch.org` for
+OpenSCAD, the Online IDE, and the SQL IDE. Each Openpatch runtime uses a pinned
+release, published directly from its owning repository. Hyperbook's
+small integration scripts and styles stay in the build.
+
+The Hyperbook extension for VS Code does not include these runtimes. Its preview
+resolves them like a build: elements with `cdn` load from that CDN, and the
+others use the runtimes in the CLI's asset cache, which the extension shares.
+A runtime that is not downloaded yet loads from its default CDN. Run
+**Hyperbook: Download Element Runtimes...** or **Hyperbook: Download All
+Element Runtimes** in VS Code to download runtimes for offline previews.
+For PyIDE, also set `elements.pyide.cdn` to `false` to use the downloaded runtime.
 
 A custom URL must point to the contents of the corresponding
 `__hyperbook_assets/directive-/` directory from a local build. Preserve
@@ -158,3 +170,6 @@ CDN-enabled runtimes are skipped by `hyperbook assets fetch` and do not need to
 be cached for `hyperbook build --offline`. Readers still need access to the
 configured CDN. `hyperbook assets fetch --all` downloads every runtime,
 regardless of the element configuration.
+
+See [hosting and caching](/hosting/caching) for keeping downloads between CI
+builds and choosing cache headers for your exported book.
diff --git a/website/en/book/elements/blockflow.md b/website/en/book/elements/blockflow.md
index 099de2a46..3ff9fb48d 100644
--- a/website/en/book/elements/blockflow.md
+++ b/website/en/book/elements/blockflow.md
@@ -7,9 +7,11 @@ permaid: blockflow
 
 [Blockflow](https://blockflow.openpatch.org) is a fork of Scratch for building guided tutorials. Hyperbook provides two directives for embedding Blockflow: a **player** for playing tutorials and an **editor** for creating them.
 
-Hyperbook and its VS Code extension include a pinned Blockflow browser bundle. Books that use
-either directive serve one shared local copy, so the editor and player do not depend on
-blockflow.openpatch.org. Local project files and tutorial media work offline when the book is
+Hyperbook includes a pinned Blockflow browser bundle. The CLI downloads it the first time a build
+uses Blockflow. Books that use either directive serve one shared local copy, so the editor and
+player do not depend on blockflow.openpatch.org. The VS Code extension does not include the bundle.
+Its preview uses the CLI's downloaded copy, or loads Blockflow from blockflow.openpatch.org until it
+is downloaded. Local project files and tutorial media work offline when the book is
 served locally; remote projects, Scratch library assets, and network extensions still need internet access.
 The bundled Blockflow license and source link are included with its assets.
 
diff --git a/website/en/book/elements/pyide.md b/website/en/book/elements/pyide.md
index 907465a29..21c6b8d30 100644
--- a/website/en/book/elements/pyide.md
+++ b/website/en/book/elements/pyide.md
@@ -5,7 +5,22 @@ permaid: pyide
 
 # PyIDE
 
-The Pyodide runtime and its bundled packages are included locally in the exported book. The CLI downloads and caches them the first time a build uses this element. Imports of bundled packages work without a CDN connection. Packages installed from PyPI through the `packages` attribute still need a network connection.
+PyIDE loads Pyodide from its versioned jsDelivr CDN by default. The full runtime distribution and its bundled packages are very large, so Hyperbook keeps them out of the exported book. Readers need an internet connection; their browsers download the runtime and requested packages and can cache these files for later visits.
+
+To include Pyodide locally for self-hosted or offline books, set `elements.pyide.cdn` to `false` in `hyperbook.json`:
+
+```json
+{
+  "name": "My offline Hyperbook",
+  "elements": {
+    "pyide": { "cdn": false }
+  }
+}
+```
+
+The CLI then downloads and caches the full distribution the first time a build uses PyIDE. To prepare an offline build, run `npx hyperbook assets fetch` while online, then `npx hyperbook build --offline`. In VS Code, also run **Hyperbook: Download Element Runtimes...** and select PyIDE. Bundled packages then work without a CDN connection. Packages installed from PyPI through the `packages` attribute still need a network connection.
+
+See [CDN configuration](/configuration/book#local-assets-and-cdns) for custom asset servers and [hosting and caching](/hosting/caching) for browser and build caches.
 
 The `pyide` element represents a Python Integrated Development Environment (IDE) component.
 It is used to embed a Python coding environment within the hyperbook website.
diff --git a/website/en/book/hosting/caching.md b/website/en/book/hosting/caching.md
new file mode 100644
index 000000000..cb58dc525
--- /dev/null
+++ b/website/en/book/hosting/caching.md
@@ -0,0 +1,84 @@
+---
+name: Caching
+---
+
+# Caching
+
+Hyperbook's build cache stores downloaded runtimes on the machine running the
+CLI. HTTP caches store the files readers request from your host or a CDN.
+
+## Cache runtime downloads between builds
+
+The CLI and VS Code share an asset cache. Builds reuse downloaded runtimes
+instead of fetching them again. You can set `HYPERBOOK_ASSET_CACHE` to choose
+its directory; the default is:
+
+| System | Directory |
+| --- | --- |
+| Linux | `$XDG_CACHE_HOME/hyperbook/assets`, or `~/.cache/hyperbook/assets` |
+| macOS | `~/Library/Caches/hyperbook/assets` |
+| Windows | `%LOCALAPPDATA%\hyperbook\assets` |
+
+CI runners usually start with an empty filesystem. Restore the asset cache
+before building and save it afterwards. For example, add these steps after
+checkout and Node.js setup in your GitHub Pages workflow:
+
+```yaml
+- name: Cache Hyperbook runtimes
+  uses: actions/cache@v4
+  with:
+    path: .cache/hyperbook-assets
+    key: ${{ runner.os }}-hyperbook-assets-${{ hashFiles('package-lock.json', 'pnpm-lock.yaml', 'hyperbook.json') }}-${{ github.sha }}
+    restore-keys: |
+      ${{ runner.os }}-hyperbook-assets-
+
+- name: Build
+  env:
+    HYPERBOOK_ASSET_CACHE: .cache/hyperbook-assets
+  run: npx hyperbook build
+```
+
+The cache key saves newly needed runtimes when the book changes, and
+`restore-keys` allows later builds to reuse earlier downloads. Hyperbook checks
+cached files against its runtime manifest and downloads missing or changed
+bundles. Keep the normal online build so a cache miss can recover. See
+[GitHub's caching documentation](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching).
+
+On GitLab, cache `.cache/hyperbook-assets` and set the same
+`HYPERBOOK_ASSET_CACHE` variable in your Pages job. Do not publish this directory;
+publish only `.hyperbook/out`.
+
+CDN-enabled elements need no local runtime download. PyIDE uses a CDN by
+default. For an offline book, set `elements.pyide.cdn` to `false`, run
+`npx hyperbook assets fetch` while online, and then build with
+`npx hyperbook build --offline`. The `--offline` flag controls the build's
+downloads; it does not make a book with CDN URLs usable offline.
+
+## Cache files for readers
+
+If your host lets you configure response headers, use the following policies
+as a starting point for public books:
+
+| Files | `Cache-Control` | Effect |
+| --- | --- | --- |
+| HTML and files replaced at the same URL | `no-cache` | Check for updates before reusing the cached response. |
+| Files with a content hash or immutable release version in their URL | `public, max-age=31536000, immutable` | Reuse the file for a year; publish changes at a new URL. |
+
+Enable `ETag` or `Last-Modified` on your server so unchanged files can be
+revalidated without downloading their contents. For files at stable URLs, a
+short lifetime such as `public, max-age=3600` is also possible if a delay of up
+to one hour after updates is acceptable. These settings follow the
+[HTTP caching standard](https://httpwg.org/specs/rfc9111.html#field.cache-control).
+
+Hyperbook's locally exported runtimes use stable paths under
+`__hyperbook_assets/directive-*/`. Do not give that entire directory an
+immutable lifetime: upgrading Hyperbook can replace files at the same URL.
+Apply the same rule to files copied from your `public` directory. Set headers
+on your hosting service or web server; adding them to `hyperbook.json` does not
+configure HTTP caching.
+
+Openpatch's versioned runtime releases on `cdn.openpatch.org` already send
+`Cache-Control: public, max-age=31536000, immutable`. Pyodide's versioned
+jsDelivr distribution is also cached by browsers; see the
+[Pyodide deployment documentation](https://pyodide.org/en/stable/usage/downloading-and-deploying.html).
+Your book's hosting settings do not change headers sent by these CDNs.
diff --git a/website/en/book/hosting/custom.md b/website/en/book/hosting/custom.md
index 2259a9d23..0f816539d 100644
--- a/website/en/book/hosting/custom.md
+++ b/website/en/book/hosting/custom.md
@@ -4,6 +4,9 @@ name: Custom
 
 # Deploy on Your Server
 
+See [Caching](/hosting/caching#cache-files-for-readers) for recommended HTTP
+headers when configuring your server.
+
 You just need to copy the output folder after running build command.
 
 ```
diff --git a/website/en/book/hosting/ghpages.md b/website/en/book/hosting/ghpages.md
index f6f64c431..e6f9d536c 100644
--- a/website/en/book/hosting/ghpages.md
+++ b/website/en/book/hosting/ghpages.md
@@ -4,6 +4,9 @@ name: GitHub Pages
 
 # Deploy on GitHub Pages
 
+See [Caching](/hosting/caching#cache-runtime-downloads-between-builds) to reuse
+downloaded runtimes between workflow runs.
+
 :::alert{warn}
 Remember to add a basePath to your [configuration](/configuration/book), when deploying to GitHub pages.
 :::
diff --git a/website/en/book/hosting/glpages.md b/website/en/book/hosting/glpages.md
index 05795c035..2d5c95db3 100644
--- a/website/en/book/hosting/glpages.md
+++ b/website/en/book/hosting/glpages.md
@@ -4,6 +4,9 @@ name: GitLab Pages
 
 # Deploy on GitLab Pages
 
+See [Caching](/hosting/caching#cache-runtime-downloads-between-builds) to reuse
+downloaded runtimes between Pages builds.
+
 :::alert{warn}
 Remember to add a basePath to your [configuration](/configuration/book), when deploying to GitLab pages.
 :::
diff --git a/website/en/book/hosting/index.md b/website/en/book/hosting/index.md
index 854114741..87ec52a72 100644
--- a/website/en/book/hosting/index.md
+++ b/website/en/book/hosting/index.md
@@ -2,3 +2,17 @@
 name: Hosting
 index: 5
 ---
+
+# Hosting
+
+Build your Hyperbook with `npx hyperbook build` and publish `.hyperbook/out` on
+a static hosting service. Choose [GitHub Pages](/hosting/ghpages),
+[GitLab Pages](/hosting/glpages), [Vercel](/hosting/vercel), or
+[your own server](/hosting/custom).
+
+PyIDE uses Pyodide's CDN by default to keep the exported book small. Readers
+need access to that CDN. Set `elements.pyide.cdn` to `false` to host the full
+runtime yourself; see [PyIDE](/elements/pyide).
+
+See [Caching](/hosting/caching) for faster CI builds and cache headers that let
+readers reuse large runtime files while receiving updated pages.
diff --git a/website/en/book/hosting/vercel.md b/website/en/book/hosting/vercel.md
index 7c1fad80e..dc9a8b96d 100644
--- a/website/en/book/hosting/vercel.md
+++ b/website/en/book/hosting/vercel.md
@@ -4,6 +4,9 @@ name: Vercel
 
 # Deploy on Vercel
 
+See [Caching](/hosting/caching#cache-files-for-readers) for cache headers when
+configuring your deployment.
+
 First you need to create a `vercel.json` file at the root of your
 project with the following content.