diff --git a/ecs/CLAUDE.md b/ecs/CLAUDE.md index 90c63fc3..8541bc71 100644 --- a/ecs/CLAUDE.md +++ b/ecs/CLAUDE.md @@ -40,6 +40,14 @@ A feature's or package's root folder holds only its public exports, one file per tests. Its implementation lives in folders below. Nothing outside a feature imports below its root, and within a package only its entry file re-exports. +## The guide + +Every library has a section of the guide. Its chapters are in its own `src/guide`, listed by a `README.md` there, each +its prose, `NN-name.md`, a program, `NN-name.ts`, and what it prints, `NN-name.out`; its package.json's `guide` field +names, in `files`, what the guide's in-browser compiler reads of it; it's a dependency of `ecs-guide`; and its +`src/guide/guide.test.ts` passes its chapters to `ecs-guide-checks`, with what each teaches and the changes that take +each point away, which its output must catch. A chapter's output is the same with a GPU or without one. + ## Never use top-level await Do not use `await` at module top level in any package's sources. Defer async work to a lazy-init pattern: store the diff --git a/ecs/README.md b/ecs/README.md index 30c60ec5..c11cce28 100644 --- a/ecs/README.md +++ b/ecs/README.md @@ -19,6 +19,7 @@ A high-performance, multithreaded Entity Component System for web-based applicat - **[`ecs-guide`](packages/ecs-guide)**: the guide, each example edited in place, checked, compiled and run in the browser as it's typed. - **[`ecs-sample-browser`](packages/ecs-sample-browser)**: the sample browser, a tab for each sample. +- **[`ecs-guide-checks`](packages/ecs-guide-checks)**: the checks every package's guide chapters pass. ## Online diff --git a/ecs/packages/ecs-gpu/package.json b/ecs/packages/ecs-gpu/package.json index 8b7493d3..a0c9089e 100644 --- a/ecs/packages/ecs-gpu/package.json +++ b/ecs/packages/ecs-gpu/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/ecs-gpu", - "version": "0.0.1", + "version": "0.0.2", "description": "WebGPU devices, surfaces, render targets, views and frame passes for @adobe/ecs", "license": "MIT", "type": "module", @@ -31,6 +31,7 @@ "@types/node": "^25.6.0", "@vitest/browser-playwright": "^5.0.5", "@webgpu/types": "^0.1.61", + "ecs-guide-checks": "workspace:*", "playwright": "^1.64.1", "typescript": "^5.8.3", "vite": "^8.3.4", @@ -41,5 +42,11 @@ "import": "./dist/index.js", "types": "./dist/types/index.d.ts" } + }, + "guide": { + "files": [ + "src/**/*.ts", + "dist/**/*.js" + ] } } diff --git a/ecs/packages/ecs-gpu/src/guide/01-targets.md b/ecs/packages/ecs-gpu/src/guide/01-targets.md new file mode 100644 index 00000000..1e98f947 --- /dev/null +++ b/ecs/packages/ecs-gpu/src/guide/01-targets.md @@ -0,0 +1,13 @@ +# Render targets and views + +A *render target* is an entity with [`Target.options`](01-targets.ts#L8), what its textures are, and `Target.size`, in +pixels. The options are its color format, its samples per pixel, its depth format and the color it's cleared to each +frame; `Target.Options.default` has one sample and a depth buffer. With a device, `Target.states` holds its textures. + +A *view* is an entity drawing into part of a target: `View.target` names the target, `View.viewport` its rectangle in +it, as fractions of its size, and `View.projection` the matrix taking its scene to clip space. +[Two views](01-targets.ts#L9-L10) split the screen in halves. + +- **Owned:** `View.target` is an owner reference, so [despawning the target](01-targets.ts#L27) takes its views with it. +- **Shaders:** `View.wgsl(1)` gives the WGSL declaring a view's uniform at bind group 1, as `view.projection` and + `view.size`, which `View.layout(device)` describes. diff --git a/ecs/packages/ecs-gpu/src/guide/01-targets.out b/ecs/packages/ecs-gpu/src/guide/01-targets.out new file mode 100644 index 00000000..35240931 --- /dev/null +++ b/ecs/packages/ecs-gpu/src/guide/01-targets.out @@ -0,0 +1,5 @@ +target [ 800, 600 ] samples 4 +view of screen at [ 0, 0, 0.5, 1 ] +view of screen at [ 0.5, 0, 0.5, 1 ] +@group(1) @binding(0) var view: View; +views left after the screen went: 0 diff --git a/ecs/packages/ecs-gpu/src/guide/01-targets.ts b/ecs/packages/ecs-gpu/src/guide/01-targets.ts new file mode 100644 index 00000000..f09ae7fa --- /dev/null +++ b/ecs/packages/ecs-gpu/src/guide/01-targets.ts @@ -0,0 +1,40 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Entity, Mat4x4, System, World } from "@adobe/ecs"; +import { Target, View } from "../index.js"; + +let screen = Entity.none; + +const spawnScreen = System.create(({ commands }) => { + screen = commands.spawn([Target.options, Target.size])({ [Target.options]: { ...Target.Options.default, sampleCount: 4 }, [Target.size]: [800, 600] }); + commands.spawn([View.target, View.viewport, View.projection])({ [View.target]: screen, [View.viewport]: [0, 0, 0.5, 1], [View.projection]: Mat4x4.identity }); + commands.spawn([View.target, View.viewport, View.projection])({ [View.target]: screen, [View.viewport]: [0.5, 0, 0.5, 1], [View.projection]: Mat4x4.identity }); +}); + +const listTargets = System.create({ readonly: true, components: { with: [Target.options, Target.size] } }, row => { + console.log("target", row[Target.size], "samples", row[Target.options].sampleCount); +}); + +const listViews = System.create({ readonly: true, components: { with: [View.target, View.viewport] } }, row => { + console.log("view of", row[View.target] === screen ? "screen" : "another", "at", row[View.viewport]); +}); + +let views = 0; +const countViews = System.create({ readonly: true, components: { with: [View.target] } }, () => { + views += 1; +}); + +const removeScreen = System.create(({ commands }) => { + commands.despawn(screen); +}); + +export const main = async (): Promise => { + await using world = World.create(); + await world.run(spawnScreen); + await world.run(listTargets); + await world.run(listViews); + console.log(View.wgsl(1).split("\n")[1]); + // Its views go with it: each view's target owns it. + await world.run(removeScreen); + await world.run(countViews); + console.log("views left after the screen went:", views); +}; diff --git a/ecs/packages/ecs-gpu/src/guide/02-frames.md b/ecs/packages/ecs-gpu/src/guide/02-frames.md new file mode 100644 index 00000000..e1bdc6a9 --- /dev/null +++ b/ecs/packages/ecs-gpu/src/guide/02-frames.md @@ -0,0 +1,15 @@ +# Devices, surfaces and frames + +`Device.request()` gives a WebGPU device, or null where there's none, and a world drawing with it takes it as +[`gpuDevice`](02-frames.ts#L20-L21). `Device.keep(world)` sets it and asks again each time it's lost. + +- **Surfaces:** `Surface.attach` makes a render target presenting to a canvas, an `OffscreenCanvas`, so the world may + run on a worker. [Here](02-frames.ts#L26-L27) it takes the page's canvas; `Surface.observe` keeps the target the + canvas's size on the page, in device pixels. +- **Frames:** `Frame.systems` brings targets, surfaces and views up to date with the device and clears each target, then + presents each surface. A frame runs them with its draws between, which declare + [`after: [Frame.update], before: [Frame.present]`](02-frames.ts#L6-L8). +- **Passes:** `View.begin(encoder, view)` begins a pass drawing into a view, limited to its rectangle. + +Without a device, as in node, the world runs the same systems and draws nothing: a frame still updates +`View.states`, to no views. diff --git a/ecs/packages/ecs-gpu/src/guide/02-frames.out b/ecs/packages/ecs-gpu/src/guide/02-frames.out new file mode 100644 index 00000000..7e76fc35 --- /dev/null +++ b/ecs/packages/ecs-gpu/src/guide/02-frames.out @@ -0,0 +1,2 @@ +views updated before a frame false +views updated after a frame true diff --git a/ecs/packages/ecs-gpu/src/guide/02-frames.ts b/ecs/packages/ecs-gpu/src/guide/02-frames.ts new file mode 100644 index 00000000..80519d11 --- /dev/null +++ b/ecs/packages/ecs-gpu/src/guide/02-frames.ts @@ -0,0 +1,33 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { gpuDevice, Mat4x4, System, World } from "@adobe/ecs"; +import { Device, Frame, Surface, Target, View } from "../index.js"; + +// Draws each view: a pass loading what its target holds, limited to the view's rectangle. +const draw = System.render({ shader: { accessors: {} }, after: [Frame.update], before: [Frame.present] }, ({ encoder }, { resources }) => { + for (const view of resources[View.states]?.values() ?? []) View.begin(encoder, view).end(); +}); + +const frame = System.create({ systems: [Frame.systems, draw] }); + +const spawnView = System.create({ components: { with: [Target.size] } }, (row, { commands }) => { + commands.spawn([View.target, View.viewport, View.projection])({ [View.target]: row.entity, [View.viewport]: [0, 0, 1, 1], [View.projection]: Mat4x4.identity }); +}); + +const updated = System.create({ readonly: true }, ({ resources }) => resources[View.states] !== null); + +export const main = async (): Promise => { + // Null where there's no WebGPU, as in node: the world then draws nothing, and runs the same systems. + const device = await Device.request(); + const world = World.create({ [gpuDevice]: device }); + // The guide's Output tab has an element #output; elsewhere, as in node, there's none. + const output = globalThis.document?.querySelector("#output") ?? null; + const canvas = output?.appendChild(document.createElement("canvas")) ?? null; + if (canvas !== null) { + const surface = await world.run(Surface.attach, { canvas: canvas.transferControlToOffscreen(), width: canvas.clientWidth, height: canvas.clientHeight, target: { ...Target.Options.default, clear: [0.1, 0.3, 0.6, 1] } }); + Surface.observe(canvas, world, surface); + await world.run(spawnView); + } + console.log("views updated before a frame", await world.run(updated)); + for (let frames = 0; frames < 3; frames++) await world.run(frame); + console.log("views updated after a frame", await world.run(updated)); +}; diff --git a/ecs/packages/ecs-gpu/src/guide/README.md b/ecs/packages/ecs-gpu/src/guide/README.md new file mode 100644 index 00000000..73f118ce --- /dev/null +++ b/ecs/packages/ecs-gpu/src/guide/README.md @@ -0,0 +1,7 @@ +# Guide + +How to draw with `@adobe/ecs-gpu`, in chapters, each its prose, `NN-name.md`, and a program, `NN-name.ts`, whose output +is `NN-name.out`. The [interactive guide](../../../ecs-guide) shows them after the ECS's own chapters. + +1. [Render targets and views](01-targets.md) +2. [Devices, surfaces and frames](02-frames.md) diff --git a/ecs/packages/ecs-gpu/src/guide/guide.test.ts b/ecs/packages/ecs-gpu/src/guide/guide.test.ts new file mode 100644 index 00000000..0f73c129 --- /dev/null +++ b/ecs/packages/ecs-gpu/src/guide/guide.test.ts @@ -0,0 +1,19 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { checkChapters } from "ecs-guide-checks"; + +/** What each chapter teaches, which its prose names as code and its program does. */ +const teaches: { readonly [chapter: number]: readonly string[] } = { + 1: ["Target.options", "Target.size", "Target.Options.default", "View.target", "View.viewport", "View.projection", "View.wgsl(1)"], + 2: ["Device.request()", "gpuDevice", "Surface.attach", "Surface.observe", "Frame.systems", "after: [Frame.update], before: [Frame.present]", "View.begin(encoder, view)", "View.states"], +}; + +/** + * Changes to each chapter's program, each taking away a point it teaches, which its output catches: each its code as + * written, once in the program, what takes its place, and the failure it then prints, where it's one. + */ +const mutations: { readonly [chapter: number]: readonly (readonly [string, string, string?])[] } = { + 1: [["[View.viewport]: [0.5, 0, 0.5, 1]", "[View.viewport]: [0, 0, 0.5, 1]"], ["sampleCount: 4 ", "sampleCount: 1 "], ["commands.despawn(screen);", ""], ["View.wgsl(1)", "View.wgsl(2)"]], + 2: [["for (let frames = 0; frames < 3; frames++) await world.run(frame);", ""]], +}; + +checkChapters({ folder: new URL(".", import.meta.url), teaches, mutations }); diff --git a/ecs/packages/ecs-gpu/vite.config.ts b/ecs/packages/ecs-gpu/vite.config.ts index 3fd0ed70..15f22692 100644 --- a/ecs/packages/ecs-gpu/vite.config.ts +++ b/ecs/packages/ecs-gpu/vite.config.ts @@ -15,6 +15,8 @@ export default defineConfig({ minify: false, sourcemap: true, lib: { entry: fileURLToPath(new URL("src/index.ts", import.meta.url)), formats: ["es"], fileName: "index" }, - rolldownOptions: { external: ["@adobe/ecs", "@adobe/ecs/compiler"] }, + // Its keys stay top-level `const` declarations, which an application's build requires of a key it reads, such as + // a trait's in its own declarations: library mode would make them `var`. + rolldownOptions: { external: ["@adobe/ecs", "@adobe/ecs/compiler"], output: { topLevelVar: false } }, }, }); diff --git a/ecs/packages/ecs-gpu/vitest.config.ts b/ecs/packages/ecs-gpu/vitest.config.ts index d92fb62f..b4cd0da5 100644 --- a/ecs/packages/ecs-gpu/vitest.config.ts +++ b/ecs/packages/ecs-gpu/vitest.config.ts @@ -17,20 +17,34 @@ const isolation: Plugin = { */ const gpu = process.env["ECS_GPU"] === "1" || process.env["CI"] === undefined; -// Every test runs in Chromium. A test's timeout only guards against a hang, which no test asserts on. +/** A test's timeout only guards against a hang, which no test asserts on. */ +const timeouts = { testTimeout: 30000, hookTimeout: 30000 }; + +// The guide's chapters run in node, as the guide checks every package's; every other test runs in Chromium. export default defineConfig({ - plugins: [Compiler.plugin(), isolation], test: { - include: ["src/**/*.test.ts"], - testTimeout: 30000, - hookTimeout: 30000, - fileParallelism: false, - provide: { gpu }, - browser: { - enabled: true, - provider: playwright({ launchOptions: { channel: "chromium", args: gpu ? ["--enable-unsafe-webgpu"] : ["--disable-gpu"] } }), - headless: true, - instances: [{ browser: "chromium" }], - }, + projects: [ + { + plugins: [Compiler.plugin()], + test: { ...timeouts, name: "guide", include: ["src/guide/**/*.test.ts"], environment: "node" }, + }, + { + plugins: [Compiler.plugin(), isolation], + test: { + ...timeouts, + name: "browser", + include: ["src/**/*.test.ts"], + exclude: ["src/guide/**", "**/node_modules/**"], + fileParallelism: false, + provide: { gpu }, + browser: { + enabled: true, + provider: playwright({ launchOptions: { channel: "chromium", args: gpu ? ["--enable-unsafe-webgpu"] : ["--disable-gpu"] } }), + headless: true, + instances: [{ browser: "chromium" }], + }, + }, + }, + ], }, }); diff --git a/ecs/packages/ecs-guide-checks/README.md b/ecs/packages/ecs-guide-checks/README.md new file mode 100644 index 00000000..b68d771d --- /dev/null +++ b/ecs/packages/ecs-guide-checks/README.md @@ -0,0 +1,15 @@ +# ecs-guide-checks + +The checks every package's guide chapters pass, for its `src/guide/guide.test.ts`: + +```ts +checkChapters({ folder: new URL(".", import.meta.url), teaches, mutations }); +``` + +- **Files:** each program has its prose and its `.out`, and the guide's index lists every chapter, numbered as its + files are and titled as its prose is. +- **Output:** each program prints what its `.out` says, its package's version left out of ids. +- **Teaching:** each chapter's prose shows, as code, what `teaches` says it teaches, and its program uses it. +- **Mutations:** each of `mutations` takes a point away from a program, and its output must change, to the failure + given where there is one. +- **Links:** the prose links only to chapters there are, and to whole lines of code of its own program. diff --git a/ecs/packages/ecs-guide-checks/package.json b/ecs/packages/ecs-guide-checks/package.json new file mode 100644 index 00000000..9c49a2f5 --- /dev/null +++ b/ecs/packages/ecs-guide-checks/package.json @@ -0,0 +1,21 @@ +{ + "name": "ecs-guide-checks", + "version": "0.0.0", + "description": "The checks every package's guide chapters pass: their programs print what they say, and each point they teach shows in that output", + "license": "MIT", + "type": "module", + "private": true, + "exports": { + ".": "./src/check-chapters.ts" + }, + "scripts": { + "typecheck": "tsc" + }, + "dependencies": { + "typescript": "^5.8.3", + "vitest": "^5.0.5" + }, + "devDependencies": { + "@types/node": "^25.6.0" + } +} diff --git a/ecs/packages/ecs-guide-checks/src/check-chapters.ts b/ecs/packages/ecs-guide-checks/src/check-chapters.ts new file mode 100644 index 00000000..16b3c111 --- /dev/null +++ b/ecs/packages/ecs-guide-checks/src/check-chapters.ts @@ -0,0 +1,165 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { formatWithOptions } from "node:util"; +import ts from "typescript"; +import { afterAll, expect, it, vi } from "vitest"; + +/** + * A package's guide chapters, in `folder`: its index, `README.md`, listing them; each chapter's prose, `NN-name.md`, + * and a program, which its `main` runs and which prints what the `.out` file beside it says. + */ +export type Chapters = { + /** The folder holding the chapters, which is the package's `src/guide`. */ + readonly folder: URL; + /** What each chapter teaches, by its number, 0 for an unnumbered one: names its prose shows as code and its program uses. */ + readonly teaches: { readonly [chapter: number]: readonly string[] }; + /** + * Changes to each chapter's program, each taking away a point it teaches, which its output catches: each its code + * as written, once in the program, what takes its place, and the failure it then prints, where it's one. + */ + readonly mutations: { readonly [chapter: number]: readonly (readonly [string, string, string?])[] }; +}; + +const levels = ["log", "info", "warn", "error", "debug"] as const; +/** Ids name a package's version, which is left out of comparisons, so a new version changes no chapter's output. */ +const unversioned = (text: string): string => text.replace(/(@[\w-]+\/[\w-]+)@[^/\s]+\//g, "$1/"); +const chapterOf = (file: string): number => Number(/^(\d+)-/.exec(file)?.[1]); + +/** What `main` prints, a line per console call, as node writes its values, on one line each, and how it failed, if it did. */ +const printed = async (main: () => Promise): Promise => { + const lines: string[] = []; + const spies = levels.map(level => vi.spyOn(console, level).mockImplementation((...values: unknown[]) => { + lines.push(formatWithOptions({ breakLength: Infinity, compact: true }, ...values)); + })); + try { + await main(); + } catch (error) { + lines.push(`main failed: ${String(error)}`); + } finally { + for (const spy of spies) spy.mockRestore(); + } + return lines.map(line => `${line}\n`).join(""); +}; + +/** What a program prints, run from its file, or how it failed, as a declaration the world rejects fails its import. */ +const run = (file: URL): Promise => printed(async () => { + const program: unknown = await import(/* @vite-ignore */ file.href); + const main: unknown = Reflect.get(Object(program), "main"); + if (typeof main !== "function") throw new Error(`${file.pathname} exports no main`); + await Reflect.apply(main, undefined, []); +}); + +/** Code without its comments, as TypeScript prints it. */ +const withoutComments = (source: string): string => + ts.createPrinter({ removeComments: true }).printFile(ts.createSourceFile("program.ts", source, ts.ScriptTarget.ESNext, false)); + +/** What prose shows as code: its inline code and its blocks. */ +const shownIn = (prose: string): readonly string[] => [...prose.matchAll(/```\w*\n([\s\S]*?)```|`([^`\n]+)`/g)].map(([, block, inline]) => block ?? inline ?? ""); + +/** The messages of TypeScript's syntax errors in code. */ +const syntaxErrors = (code: string): readonly string[] => { + const { diagnostics = [] } = ts.transpileModule(code, { reportDiagnostics: true, compilerOptions: { target: ts.ScriptTarget.ESNext, module: ts.ModuleKind.ESNext } }); + return diagnostics.map(diagnostic => ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n")); +}; + +/** + * Defines the tests a package's chapters pass, in the test file calling it. The folder is read as the tests run, not + * through `import.meta.glob`, which vitest's cache of transformed modules would keep as of their first run, missing a + * chapter added since. Each mutant is written to `.mutants` in the package, beside `src`, and run from there. + */ +export const checkChapters = ({ folder, teaches, mutations }: Chapters): void => { + const files = readdirSync(folder); + const read = (file: string): string => readFileSync(new URL(file, folder), "utf8"); + const names = files.filter(file => /^\d+-[\w-]+\.ts$/.test(file) && !file.endsWith(".type-test.ts")).map(file => file.slice(0, -".ts".length)).sort(); + const proseFiles = files.filter(file => /^\d+-[\w-]+\.md$/.test(file)).sort(); + const mutants = new URL("../../.mutants/", folder); + afterAll(() => rmSync(mutants, { recursive: true, force: true })); + + /** Each chapter's code: its files', without comments. */ + const codeOf = (chapter: number): string => + files.filter(file => file.endsWith(".ts") && !file.endsWith(".test.ts") && chapterOf(file) === chapter).map(file => withoutComments(read(file))).join("\n"); + /** Each link of the prose to lines of a program, which the interactive guide shows in its editor: `NN-name.ts#L4-L9`. */ + const editorLinks = proseFiles.flatMap(prose => [...read(prose).matchAll(/\]\(([^)#\s]*)#L(\d+)(?:-L(\d+))?\)/g)].map(([, file = "", from = "", to]) => + ({ prose, file, from: Number(from), to: Number(to ?? from) }))); + /** The chapters the index lists, in order: each its number, title and prose. */ + const listed = [...read("README.md").matchAll(/^(?:- |(\d+)\. )\[([^\]]+)\]\(([^)]+)\)$/gm)].map(([, number = "0", title = "", file = ""]) => ({ number: Number(number), title, file })); + + it("has prose and an expected output beside each program, and no prose but the chapters' and the unnumbered ones its index lists", () => { + expect(files.filter(file => file.endsWith(".out")).map(file => file.slice(0, -".out".length)).sort()).toEqual(names); + // An unnumbered chapter may be prose alone, as a package's setup is. + const proseAlone = listed.map(chapter => chapter.file).filter(file => !names.includes(file.slice(0, -".md".length))); + expect(proseFiles).toEqual([...proseAlone, ...names.map(name => `${name}.md`)].sort()); + }); + + it("lists every chapter in its index, the unnumbered first, numbered as its files are and by the title its prose gives", () => { + expect(listed.map(chapter => chapter.file).sort()).toEqual(proseFiles); + expect(listed.map(chapter => chapter.number)).toEqual(listed.map(chapter => chapter.number).sort((a, b) => a - b)); + for (const { number, title, file } of listed) { + expect(number).toBe(chapterOf(file)); + const [heading, ...rest] = read(file).split("\n"); + expect(heading).toBe(`# ${title}`); + expect(rest.join("\n").trim()).not.toBe(""); + } + }); + + it.each(names)("%s prints what its .out says", async name => { + const expected = read(`${name}.out`); + expect(expected.trim()).not.toBe(""); + expect(unversioned(await run(new URL(`${name}.ts`, folder)))).toBe(unversioned(expected)); + }); + + it.each(Object.entries(teaches))("chapter %s's prose names what it teaches, as code, and its program does it", (chapter, taught) => { + const prose = `${names.find(name => chapterOf(name) === Number(chapter)) ?? ""}.md`; + const shown = shownIn(read(prose)).join("\n"); + const code = codeOf(Number(chapter)); + expect(taught.filter(name => !shown.includes(name))).toEqual([]); + expect(taught.filter(name => !code.includes(name))).toEqual([]); + }); + + it("teaches something in every chapter", () => { + expect(Object.keys(teaches).map(Number)).toEqual(names.map(chapterOf)); + }); + + it("keeps code in the prose to a line or two, the program in the editor showing the rest", () => { + const long = proseFiles.flatMap(file => [...read(file).matchAll(/```\w*\n([\s\S]*?)```/g)].filter(([, block = ""]) => block.split("\n").length > 3).map(() => file)); + expect(long).toEqual([]); + }); + + it("links chapters only to chapters there are", () => { + const chapterLinks = proseFiles.flatMap(file => [...read(file).matchAll(/\]\(([^)#\s]+\.md)\)/g)].map(([, target = ""]) => target)); + expect(chapterLinks.filter(target => !proseFiles.includes(target))).toEqual([]); + }); + + it.each(editorLinks.map(link => [`${link.prose} to ${link.file}#L${link.from}-L${link.to}`, link] as const))("links %s, lines of its own program that are whole code", (_place, { prose, file, from, to }) => { + expect(file.startsWith(prose.replace(/\.md$/, "."))).toBe(true); + const lines = read(file).split("\n"); + expect(from).toBeGreaterThanOrEqual(1); + expect(to).toBeGreaterThanOrEqual(from); + expect(to).toBeLessThanOrEqual(lines.length); + const code = lines.slice(from - 1, to).join("\n"); + expect(withoutComments(code).trim()).not.toBe(""); + expect(lines[from - 1]?.trim()).not.toBe(""); + expect(lines[to - 1]?.trim()).not.toBe(""); + // Lines cut short leave a brace or a call open, which parses neither as statements nor as a declaration's members. + expect(syntaxErrors(code).length === 0 || syntaxErrors(`interface Members {\n${code}\n}`).length === 0 ? [] : syntaxErrors(code)).toEqual([]); + }); + + it.each(Object.entries(mutations).flatMap(([chapter, changes]) => changes.map(([from, to, failure], index) => [Number(chapter), from, to, failure, index] as const)))( + "catches chapter %s's point taken away, %j, in its output", async (chapter, from, to, failure, index) => { + const name = names.find(item => chapterOf(item) === chapter) ?? ""; + const source = read(`${name}.ts`); + expect(source.split(from).length).toBe(2); + // A file of the package's own, compiled as the program is, outside its sources, which other tests read. + const mutant = `${name}-${index}.ts`; + mkdirSync(mutants, { recursive: true }); + const file = new URL(mutant, mutants); + writeFileSync(file, source.replace(from, to).replace(` from "../index.js";`, ` from "../src/index.js";`)); + try { + const output = (await run(file)).replaceAll(`.mutants/${mutant}`, `src/guide/${name}.ts`); + expect(unversioned(output)).not.toBe(unversioned(read(`${name}.out`))); + if (failure !== undefined) expect(output).toContain(failure); + } finally { + rmSync(file, { force: true }); + } + }); +}; diff --git a/ecs/packages/ecs-guide-checks/tsconfig.json b/ecs/packages/ecs-guide-checks/tsconfig.json new file mode 100644 index 00000000..dc99866b --- /dev/null +++ b/ecs/packages/ecs-guide-checks/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2024", "ESNext.Disposable"], + "types": ["node"], + "skipLibCheck": true, + "isolatedModules": true, + "strict": true, + "noFallthroughCasesInSwitch": true, + "allowImportingTsExtensions": true, + "noEmit": true + }, + "include": ["src"] +} diff --git a/ecs/packages/ecs-guide/guide-packages.ts b/ecs/packages/ecs-guide/guide-packages.ts new file mode 100644 index 00000000..42a1ed5d --- /dev/null +++ b/ecs/packages/ecs-guide/guide-packages.ts @@ -0,0 +1,128 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs"; +import { dirname, join } from "node:path"; +import type { Plugin } from "vite"; +import { filesOf, shippedBy } from "./src/compile/shipped.ts"; + +/** + * A package with a guide section: one of this guide's dependencies whose package.json has a `guide` field. Its + * chapters are in its `src/guide`, listed by its `README.md` there; `guide.files` are the patterns, as in `files`, of + * what the guide's compiler and checker read of it, of what it ships. + */ +type Found = { + readonly name: string; readonly folder: string; readonly manifest: string; readonly patterns: readonly string[]; + readonly dependencies: readonly string[]; + /** The module its root export imports, as every module importing the package by name resolves it, so a bundle holds one copy. */ + readonly entry: string; +}; + +/** What a package.json's `exports` give its root, under the `import` condition, as a path in the package. */ +const entryOf = (manifest: unknown): string => { + const exports: unknown = typeof manifest === "object" && manifest !== null ? Reflect.get(manifest, "exports") : undefined; + const root: unknown = typeof exports === "string" ? exports : typeof exports === "object" && exports !== null ? Reflect.get(exports, ".") : undefined; + const target: unknown = typeof root === "object" && root !== null ? Reflect.get(root, "import") ?? Reflect.get(root, "default") : root; + return typeof target === "string" ? target : "./index.js"; +}; + +const manifestOf = (text: string): { readonly name?: unknown; readonly guide?: unknown; readonly dependencies?: unknown } => { + const json: unknown = JSON.parse(text); + return typeof json === "object" && json !== null ? json : {}; +}; + +const namesOf = (value: unknown): readonly string[] => typeof value === "object" && value !== null ? Object.keys(value) : []; + +/** The guide's packages, each after those it depends on, and by name where neither depends on the other. */ +const packagesOf = (guide: string): readonly Found[] => { + const own = manifestOf(readFileSync(join(guide, "package.json"), "utf8")); + const found: Found[] = []; + for (const name of namesOf(own.dependencies)) { + const installed = join(guide, "node_modules", name, "package.json"); + if (!existsSync(installed)) continue; + const manifestPath = realpathSync(installed); + const manifest = readFileSync(manifestPath, "utf8"); + const declared = manifestOf(manifest).guide; + if (typeof declared !== "object" || declared === null) continue; + const patterns: unknown = Reflect.get(declared, "files"); + found.push({ + name, folder: dirname(manifestPath), manifest, entry: join(dirname(manifestPath), entryOf(JSON.parse(manifest))), + patterns: Array.isArray(patterns) ? patterns.filter(pattern => typeof pattern === "string") : [], + dependencies: namesOf(manifestOf(manifest).dependencies), + }); + } + const ordered: Found[] = []; + const remaining = [...found].sort((a, b) => a.name.localeCompare(b.name)); + while (remaining.length > 0) { + const next = remaining.findIndex(item => item.dependencies.every(dependency => !remaining.some(other => other.name === dependency))); + ordered.push(...remaining.splice(next < 0 ? 0 : next, 1)); + } + return ordered; +}; + +/** + * Every file below `folder`, by its path there, but those in `node_modules` and in folders whose names start with a dot, + * which a package ships none of and a test may fill and empty while this reads. + */ +const filesBelow = (folder: string, below = ""): readonly string[] => readdirSync(join(folder, below), { withFileTypes: true }).flatMap(entry => { + const path = below === "" ? entry.name : `${below}/${entry.name}`; + if (entry.isDirectory()) return entry.name === "node_modules" || entry.name.startsWith(".") ? [] : filesBelow(folder, path); + return entry.isFile() ? [path] : []; +}); + +const named = (folder: string, names: readonly string[]) => names.map(name => ({ name, source: readFileSync(join(folder, name), "utf8") })); + +/** What `virtual:guide-packages` gives for a package: its chapters' files and the files its compiler and checker read. */ +const contentOf = (found: Found, watch: (file: string) => void) => { + const chapters = join(found.folder, "src", "guide"); + if (!existsSync(join(chapters, "README.md"))) throw new Error(`${found.name} declares a guide in its package.json, so its chapters are in src/guide, listed by src/guide/README.md.`); + const inGuide = readdirSync(chapters).filter(name => statSync(join(chapters, name)).isFile()).sort(); + const shipped = shippedBy(filesOf(found.manifest)); + const read = shippedBy(found.patterns); + const below = filesBelow(found.folder).filter(shipped).sort(); + const files = below.filter(read); + const declarations = below.filter(path => path.startsWith("dist/") && path.endsWith(".d.ts")); + for (const name of inGuide) watch(join(chapters, name)); + return { + name: found.name, + index: readFileSync(join(chapters, "README.md"), "utf8"), + prose: named(chapters, inGuide.filter(name => /^\d+-[\w-]+\.md$/.test(name))), + sources: named(chapters, inGuide.filter(name => /^\d+-[\w-]+\.(?:type-test\.)?ts$/.test(name))), + outputs: named(chapters, inGuide.filter(name => /^\d+-[\w-]+\.out$/.test(name))), + files: [["package.json", found.manifest], ...files.map(path => [path, readFileSync(join(found.folder, path), "utf8")])], + declarations: [["package.json", found.manifest], ...declarations.map(path => [path, readFileSync(join(found.folder, path), "utf8")])], + }; +}; + +const content = "virtual:guide-packages"; +const runtime = "virtual:guide-runtime"; + +/** + * The guide's packages as two modules: `virtual:guide-packages`, each package's name, chapters, the files its compiler + * reads and the declarations its checker reads, in the guide's order; and `virtual:guide-runtime`, each package's exports by its name, as + * a run's frame imports them. A build of one of them, or a change to its sources, reloads no page: its modules are + * only invalidated, so the next load reads them anew, since a build writes many files and each would reload the page + * otherwise, losing what the reader typed. + */ +export const guidePackages = (guide: string): Plugin => { + const found = packagesOf(guide); + const folders = found.flatMap(item => ["dist", "src"].map(folder => `${join(item.folder, folder).replace(/\\/g, "/")}/`)); + return { + name: "guide-packages", + resolveId: id => id === content || id === runtime ? `\0${id}` : null, + load(id) { + if (id === `\0${content}`) return `export const packages = ${JSON.stringify(found.map(item => contentOf(item, file => this.addWatchFile(file))))};`; + if (id === `\0${runtime}`) { + const imports = found.map((item, at) => `import * as package${at} from ${JSON.stringify(item.entry.replace(/\\/g, "/"))};`); + return `${imports.join("\n")}\nexport const modules = { ${found.map((item, at) => `${JSON.stringify(item.name)}: package${at}`).join(", ")} };\n`; + } + return null; + }, + hotUpdate({ file, modules }) { + const path = file.replace(/\\/g, "/"); + if (!folders.some(folder => path.startsWith(folder))) return; + for (const module of modules) this.environment.moduleGraph.invalidateModule(module); + const loaded = this.environment.moduleGraph.getModuleById(`\0${content}`); + if (loaded !== undefined) this.environment.moduleGraph.invalidateModule(loaded); + return []; + }, + }; +}; diff --git a/ecs/packages/ecs-guide/package-updates.ts b/ecs/packages/ecs-guide/package-updates.ts deleted file mode 100644 index f685dc04..00000000 --- a/ecs/packages/ecs-guide/package-updates.ts +++ /dev/null @@ -1,17 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Plugin } from "vite"; - -/** - * A build of a package the pages read, or a change to its sources, reloads no page: its modules are only invalidated, - * so the next load reads them anew. A build writes many files, and each would reload the page otherwise, losing what - * the reader typed. `folders` are the package's, as absolute paths ending in `/`. - */ -export const packageUpdates = (folders: readonly string[]): Plugin => ({ - name: "package-updates-without-reloads", - hotUpdate({ file, modules }) { - const path = file.replace(/\\/g, "/"); - if (!folders.some(folder => path.startsWith(folder))) return; - for (const module of modules) this.environment.moduleGraph.invalidateModule(module); - return []; - }, -}); diff --git a/ecs/packages/ecs-guide/package.json b/ecs/packages/ecs-guide/package.json index 70d0653c..302a01bc 100644 --- a/ecs/packages/ecs-guide/package.json +++ b/ecs/packages/ecs-guide/package.json @@ -13,6 +13,8 @@ }, "dependencies": { "@adobe/ecs": "workspace:*", + "@adobe/ecs-gpu": "workspace:*", + "@adobe/ecs-persistence": "workspace:*", "@codemirror/commands": "^6.11.1", "@codemirror/lang-javascript": "^6.2.5", "@codemirror/language": "^6.13.1", diff --git a/ecs/packages/ecs-guide/src/README.md b/ecs/packages/ecs-guide/src/README.md index 78ea8081..cd7aefb7 100644 --- a/ecs/packages/ecs-guide/src/README.md +++ b/ecs/packages/ecs-guide/src/README.md @@ -6,9 +6,14 @@ The ECS's guide, every example edited in place: as it's typed, TypeScript checks `pnpm dev` in this package serves it alone; `pnpm dev` in `ecs-sample-browser` serves it in the sample browser, as the Guide tab. Each chapter has an address of its own, its numbered title's anchor, as GitHub makes it: `#1-resources`. -The chapters are read as the guide builds from `../ecs/src/guide`, where they stay: their order and parts from its -index, `README.md`, the unnumbered chapters first, and each chapter's prose, `NN-name.md`, its programs, `NN-name.ts`, and what they print, -`NN-name.out`. A link in the prose to a program's lines, `[text](01-resources.ts#L14-L23)`, which on GitHub opens the +Each package with a guide section has its chapters in its own `src/guide`, where they stay: their order and parts from +its index, `README.md`, the unnumbered chapters first, and each chapter's prose, `NN-name.md`, its programs, +`NN-name.ts`, and what they print, `NN-name.out`. A package has a section when it's one of this guide's dependencies and +its package.json has a `guide` field, whose `files` are the patterns of what the guide's compiler reads of it; the +guide reads them as it builds (`guide-packages.ts`), so it names no package. Sections come in the order the packages +depend on each other, the ECS's first: its chapters in the parts its index gives, each other package's in one part +titled by its name, numbered on from the chapters before. Every package's chapters pass the same checks +(`ecs-guide-checks`): each program prints what its `.out` says, and taking away each point it teaches changes that. A link in the prose to a program's lines, `[text](01-resources.ts#L14-L23)`, which on GitHub opens the file at them, shows them in the editor, selected; a link to a chapter's prose, `[text](15-reductions.md)`, opens that chapter. @@ -79,28 +84,28 @@ tries again. A worker that fails to load, or fails as it runs, is started again short wait, the status saying it's retrying; only then is its failure a problem, saying what failed to load. Each worker's entry (`workers/serve.ts`) loads its modules and says why when they fail. Both dev servers that serve the guide optimize the workers' dependencies before the first page asks, so a first run never reloads the page, and -neither reloads it as the package is built again (`package-updates.ts`): its modules are invalidated, so the next load, +neither reloads it as a package is built again (`guide-packages.ts`): its modules are invalidated, so the next load, the workers' included, reads the new build, and the reader's edits stay. ## How an example runs - **Checking**: TypeScript's language service, in a worker (`workers/typescript.ts`, `check/language-service.ts`), - over the chapter's files with the package's declarations from `../ecs/dist`, the standard library's and WebGPU's. + over the chapter's files with each package's declarations from its `dist`, the standard library's and WebGPU's. The same service says what's at a place for a hover (`getQuickInfoAtPosition`), over the files as the editor has them, the text it checks being the text it shows, so their positions are the same: one asked at a time, and only the newest answered (`latestAnswer` in `compile/latest.ts`). - **Compiling**: the ECS's compiler, in a worker (`workers/compiler.ts`, `compile/compile-run.ts`), with oxc's WASM - parser and transformer, from the package's files held in memory (`compile/package-files.ts`). The examples compile at - their place in the package's sources, `src/guide`, as the package's own tests do, so their ids name the package. + parser and transformer, from the packages' files held in memory (`compile/package-files.ts`). A chapter's examples + compile at their place in its package's sources, `src/guide`, as the package's own tests do, so their ids name it. Each kernel's module is inlined as a `data:` url, which loads in the world's workers wherever they're served from. - **Running**: each run is a fresh frame, so it has a registry of its own, and disposing the frame disposes its worlds and workers. The compiler's worker compiles one run at a time, the newest asked for, and puts the run's files in a cache, in a folder of the page's own, which the service worker drops once its page is gone (`compile/run-files.ts`): - its page, whose import map sends `@adobe/ecs` and the hooks compiled code imports to one exports module, and the - compiled modules; the page's `#output` fills its frame, for a program to render into, and the frame says when + its page, whose import map sends each package to its exports module and the hooks compiled code imports to the + runtime's, and the compiled modules; the page's `#output` fills its frame, for a program to render into, and the frame says when something is first put there. The service worker (`service-worker.ts`) serves them, and gives every response in its scope the headers that isolate a page across origins, so shared memory and the world's workers work on a host that can't send them. The - frame loads the runtime (`run/runtime.ts`): the package, the hooks, and `start` (`run/program.ts`), which loads the + frame loads the runtime (`run/runtime.ts`): the packages, the hooks, and `start` (`run/program.ts`), which loads the run's modules and awaits each one's `main`, telling the page what the console takes, each turn's lines together, written as node writes values (`run/inspect.ts`), and how the run ended (`run/protocol.ts`). @@ -118,14 +123,14 @@ the browser, which no export offers. What it depends on of the package's insides `prepared-lookup.js`, from `../ecs/dist/implementation/world`: the same files `@adobe/ecs` resolves to, so they share its registry. `compile/run-files.ts` names the same hooks' paths in the package's build, which the compiler writes in compiled code, for each run's import map. -- `compile/package-files.ts` reads the package's sources' root and interface, `src/index.ts` and `src/interface`, and - the same in its build, `dist`: what the build reads types from and kernels inline from, for anything an example can - import. `check/language-service.ts` reads the build's declarations. Both keep only what the package ships, by its - package.json's `files`. -- `compile/run-files.ts` compiles each example at its place in the package's sources, `src/guide`, as the package's +- `compile/package-files.ts` reads what each package's `guide.files` name, the ECS's sources' root and interface, + `src/index.ts` and `src/interface`, and the same in its build, `dist`: what the build reads types from and kernels + inline from, for anything an example can import. `check/language-service.ts` reads each build's declarations. Both + keep only what a package ships, by its package.json's `files`. +- `compile/run-files.ts` compiles each example at its place in its package's sources, `src/guide`, as the package's own tests are, so its keys' and systems' ids name the package, as `18-identity.test.ts` checks. -The editor shows each example importing the package as `@adobe/ecs`, as a consumer does, where its file imports the +The editor shows each example importing its package by name, as a consumer does, where its file imports the package's sources' root as `../index.js` (`content/guide.ts`); both resolve to the package where the guide compiles it, so the file is otherwise the same, line for line. diff --git a/ecs/packages/ecs-guide/src/app/app.page.test.ts b/ecs/packages/ecs-guide/src/app/app.page.test.ts index f560cc4b..84f65785 100644 --- a/ecs/packages/ecs-guide/src/app/app.page.test.ts +++ b/ecs/packages/ecs-guide/src/app/app.page.test.ts @@ -18,14 +18,17 @@ const chapter = (number: number) => { return found; }; -it.each(chapters.filter(chapter => chapter.files.length > 0).map(item => [item.slug, item.files.length, item.expected, item.part === "UI"]))("shows %s, and runs its program in a frame that's cross-origin isolated, printing what the guide shows", async (slug, files, expected, animates) => { +/** Whether a chapter's program draws into its run's `#output`, which the guide then shows. */ +const draws = (chapter: (typeof chapters)[number]): boolean => chapter.files.some(file => file.source.includes(`"#output"`)); + +it.each(chapters.filter(chapter => chapter.files.length > 0).map(item => [item.slug, item.files.length, item.expected, item.part === "UI", draws(item)]))("shows %s, and runs its program in a frame that's cross-origin isolated, printing what the guide shows", async (slug, files, expected, animates, drawn) => { const { page, errors } = await open(browser, `${inject("guide")}#${slug}`); try { await page.waitForFunction(lines => document.querySelector("guide-console")?.logged.entries.length === lines, expected.split("\n").length - 1); // An animation runs until its frame goes. expect(await statusOf(page, /Finished|Running|Failed/)).toBe(animates ? "Running…" : "Finished"); expect(unversioned(await consoleOf(page))).toBe(unversioned(expected)); - expect(await page.evaluate(() => document.querySelector("guide-tabs")?.selected)).toBe(animates ? "output" : "console"); + expect(await page.evaluate(() => document.querySelector("guide-tabs")?.selected)).toBe(drawn ? "output" : "console"); expect(await page.locator("guide-problems li").count()).toBe(0); expect(await page.locator("guide-nav sp-sidenav-item[selected]").getAttribute("value")).toBe(slug); expect(await page.locator("guide-files sp-tab").count()).toBe(files); diff --git a/ecs/packages/ecs-guide/src/check/checker.ts b/ecs/packages/ecs-guide/src/check/checker.ts index 8012a9b6..07192369 100644 --- a/ecs/packages/ecs-guide/src/check/checker.ts +++ b/ecs/packages/ecs-guide/src/check/checker.ts @@ -11,12 +11,12 @@ import { isQuickInfo, type CheckReply, type CheckRequest, type InfoReply, type I * check tries again. */ export type Checker = { - readonly check: (files: readonly Source[]) => Promise<{ readonly diagnostics: readonly Diagnostic[]; readonly failure: string | null }>; + readonly check: (owner: string, files: readonly Source[]) => Promise<{ readonly diagnostics: readonly Diagnostic[]; readonly failure: string | null }>; /** - * What's at `position` in the file named, of `files` as they are now, or null: one asked at a time, and only the - * newest answered, a reply to one asked before another dropped. + * What's at `position` in the file named, of `files` as they are now, examples of `owner`, or null: one asked at a + * time, and only the newest answered, a reply to one asked before another dropped. */ - readonly info: (files: readonly Source[], file: string, position: number) => Promise; + readonly info: (owner: string, files: readonly Source[], file: string, position: number) => Promise; }; const infoReplyOf = (data: unknown): InfoReply | null => { @@ -66,22 +66,22 @@ export const createChecker = (): Checker => { asking.clear(); }, }); - const info = latestAnswer<{ readonly files: readonly Source[]; readonly file: string; readonly position: number }, QuickInfo | null>(({ files, file, position }) => { - const request: InfoRequest = { kind: "info", serial: ++serial, files, file, position }; + const info = latestAnswer<{ readonly owner: string; readonly files: readonly Source[]; readonly file: string; readonly position: number }, QuickInfo | null>(({ owner, files, file, position }) => { + const request: InfoRequest = { kind: "info", serial: ++serial, owner, files, file, position }; return new Promise(resolve => { asking.set(request.serial, { request, resolve }); worker.post(request); }); }, null); return { - check: async files => { - const request: CheckRequest = { kind: "check", version: next++, files }; + check: async (owner, files) => { + const request: CheckRequest = { kind: "check", version: next++, owner, files }; const reply = await new Promise(resolve => { waiting.set(request.version, { request, resolve }); worker.post(request); }); return { diagnostics: reply.diagnostics, failure: reply.failure }; }, - info: (files, file, position) => info({ files, file, position }), + info: (owner, files, file, position) => info({ owner, files, file, position }), }; }; diff --git a/ecs/packages/ecs-guide/src/check/language-service.test.ts b/ecs/packages/ecs-guide/src/check/language-service.test.ts index c061cc97..105840c7 100644 --- a/ecs/packages/ecs-guide/src/check/language-service.test.ts +++ b/ecs/packages/ecs-guide/src/check/language-service.test.ts @@ -4,10 +4,12 @@ import { chapters, numbered } from "../content/guide.js"; import { createCheck } from "./language-service.js"; const check = createCheck(); +/** The package the guide's numbered chapters before any other package's are of. */ +const core = numbered(1)?.package ?? ""; -it("finds no error in any chapter's files, as the package's own build checks them", () => { +it("finds no error in any chapter's files, as its package's own build checks them", () => { for (const chapter of chapters.filter(chapter => chapter.files.length > 0)) { - const diagnostics = check(chapter.files.map(file => ({ name: file.name, code: file.source }))); + const diagnostics = check(chapter.package, chapter.files.map(file => ({ name: file.name, code: file.source }))); expect([chapter.slug, diagnostics]).toEqual([chapter.slug, []]); } }); @@ -16,11 +18,11 @@ it("reports a type error at its place in the file, and none once it's fixed", () const resources = numbered(1)?.files[0]; if (resources === undefined) throw new Error("no 01-resources.ts"); const edited = resources.source.replace("await using moon = World.create({ [gravity]: -1.6 });", "await using moon = World.create({ [gravity]: \"low\" });"); - const diagnostics = check([{ name: resources.name, code: edited }]); + const diagnostics = check(core, [{ name: resources.name, code: edited }]); const line = edited.split("\n").findIndex(text => text.includes("\"low\"")) + 1; expect(diagnostics).toEqual([expect.objectContaining({ kind: "typescript", severity: "error", file: resources.name, line })]); // Checked again after the error is fixed, the file has none. - expect(check([{ name: resources.name, code: resources.source }])).toEqual([]); + expect(check(core, [{ name: resources.name, code: resources.source }])).toEqual([]); }); it("reports the error the type-test chapter expects, once its @ts-expect-error is gone, saying what the chapter says", () => { @@ -28,7 +30,7 @@ it("reports the error the type-test chapter expects, once its @ts-expect-error i if (traits === undefined) throw new Error("no 11-traits.type-test.ts"); const edited = traits.source.replace(/\/\/ @ts-expect-error[^\n]*\n\/\/[^\n]*\n/, ""); expect(edited).not.toBe(traits.source); - const [diagnostic] = check([{ name: traits.name, code: edited }]); + const [diagnostic] = check(core, [{ name: traits.name, code: edited }]); expect(diagnostic?.line).toBe(edited.split("\n").indexOf("instance(name);") + 1); expect(diagnostic?.message).toBe("Argument of type 'typeof name' is not assignable to parameter of type 'typeof transform'."); expect(traits.source).toContain(`\n// ${diagnostic?.message}\n`); @@ -39,7 +41,7 @@ it("reports TypeScript's errors at each use of a key the keys chapter no longer if (keys === undefined) throw new Error("no 00-keys.ts"); const edited = keys.source.replace(" [score]: { type: number };\n", ""); expect(edited).not.toBe(keys.source); - const lines = check([{ name: keys.name, code: edited }]).map(diagnostic => edited.split("\n")[(diagnostic.line ?? 0) - 1]?.trim()); + const lines = check(core, [{ name: keys.name, code: edited }]).map(diagnostic => edited.split("\n")[(diagnostic.line ?? 0) - 1]?.trim()); expect(lines).toContain("row[score] += resources[bonus];"); expect(lines).toContain("console.log(\"score\", row[score]);"); }); diff --git a/ecs/packages/ecs-guide/src/check/language-service.ts b/ecs/packages/ecs-guide/src/check/language-service.ts index cf66b7c8..c40f7cdf 100644 --- a/ecs/packages/ecs-guide/src/check/language-service.ts +++ b/ecs/packages/ecs-guide/src/check/language-service.ts @@ -3,34 +3,29 @@ import ts from "typescript"; import type { Diagnostic } from "../compile/diagnostic.js"; import type { Source } from "../compile/messages.js"; import type { Part, QuickInfo } from "./messages.js"; -import { filesOf, shippedBy } from "../compile/shipped.js"; +import { packages } from "virtual:guide-packages"; /** - * TypeScript's checks of examples: a language service over a chapter's files, with the package's declarations from its + * TypeScript's checks of examples: a language service over a chapter's files, with each package's declarations from its * build, the standard library's and WebGPU's, all read as the guide builds. * Each check updates only the files that changed, so the program is built again incrementally. */ const libraries: { readonly [path: string]: string } = import.meta.glob(["../../node_modules/typescript/lib/lib.es*.d.ts", "../../node_modules/typescript/lib/lib.dom*.d.ts", "../../node_modules/typescript/lib/lib.decorators*.d.ts"], { query: "?raw", import: "default", eager: true }); -/** The package's declarations, of the files it ships, as its `files` say. */ -const declarations: { readonly [path: string]: string } = import.meta.glob("../../../ecs/dist/**/*.d.ts", { query: "?raw", import: "default", eager: true }); -const manifest: { readonly [path: string]: string } = import.meta.glob("../../../ecs/package.json", { query: "?raw", import: "default", eager: true }); -const shipped = shippedBy(filesOf(Object.values(manifest)[0] ?? "{}")); const webgpu: { readonly [path: string]: string } = import.meta.glob("../../node_modules/@webgpu/types/dist/index.d.ts", { query: "?raw", import: "default", eager: true }); -/** Where the examples are: their package re-exports the package from its root, as `../index.js` imports it. */ -const examplesFolder = "/guide/src/guide"; +/** Where a package's examples are: their own package re-exports the package from its root, as `../index.js` imports it. */ +const examplesFolderOf = (name: string): string => `/guides/${name}/src/guide`; const files = new Map([ ...Object.entries(libraries).map(([path, text]): [string, string] => [`/node_modules/typescript/lib/${path.slice(path.lastIndexOf("/") + 1)}`, text]), - ...Object.entries({ ...declarations, ...manifest }) - .map(([path, text]): [string, string] => [path.slice("../../../ecs/".length), text]) - .filter(([path]) => path === "package.json" || shipped(path)) - .map(([path, text]): [string, string] => [`/node_modules/@adobe/ecs/${path}`, text]), + ...packages.flatMap(({ name, declarations }) => [ + ...declarations.map(([path, text]): [string, string] => [`/node_modules/${name}/${path}`, text]), + [`/guides/${name}/package.json`, JSON.stringify({ name: "guide", type: "module" })] as const, + [`/guides/${name}/src/index.ts`, `export * from ${JSON.stringify(name)};\n`] as const, + ]), ...Object.values(webgpu).map((text): [string, string] => ["/node_modules/@webgpu/types/dist/index.d.ts", text]), ["/node_modules/@webgpu/types/package.json", JSON.stringify({ name: "@webgpu/types", types: "dist/index.d.ts" })], - ["/guide/package.json", JSON.stringify({ name: "guide", type: "module" })], - ["/guide/src/index.ts", `export * from "@adobe/ecs";\n`], ]); /** The options the samples check with, resolving as a bundler does. */ @@ -52,12 +47,12 @@ const diagnosticOf = (name: string, source: ts.SourceFile | undefined, diagnosti const partsOf = (parts: readonly ts.SymbolDisplayPart[] | undefined): readonly Part[] => (parts ?? []).map(part => ({ text: part.text, kind: part.kind })); /** - * TypeScript's language service over examples, by their names, kept between requests: `check` gives each file's - * diagnostics, and `info` what's at a place in one, each of the files as given. + * TypeScript's language service over examples of a package, `owner`, by their names, kept between requests: `check` + * gives each file's diagnostics, and `info` what's at a place in one, each of the files as given. */ export type Service = { - readonly check: (sources: readonly Source[]) => readonly Diagnostic[]; - readonly info: (sources: readonly Source[], name: string, position: number) => QuickInfo | null; + readonly check: (owner: string, sources: readonly Source[]) => readonly Diagnostic[]; + readonly info: (owner: string, sources: readonly Source[], name: string, position: number) => QuickInfo | null; }; /** Checks examples, by their names, giving TypeScript's diagnostics for each, in one language service kept between checks. */ @@ -74,7 +69,7 @@ export const createService = (): Service => { const text = files.get(file); return text === undefined ? undefined : ts.ScriptSnapshot.fromString(text); }, - getCurrentDirectory: () => "/guide", + getCurrentDirectory: () => "/guides", getDefaultLibFileName: () => "/node_modules/typescript/lib/lib.d.ts", fileExists: file => files.has(file), readFile: file => files.get(file), @@ -82,28 +77,29 @@ export const createService = (): Service => { getDirectories: () => [], }, ts.createDocumentRegistry()); /** Makes the files the service reads those given, each changed one a new version. */ - const take = (sources: readonly Source[]): void => { - roots = sources.map(source => `${examplesFolder}/${source.name}`); + const take = (owner: string, sources: readonly Source[]): void => { + const folder = examplesFolderOf(owner); + roots = sources.map(source => `${folder}/${source.name}`); for (const { name, code } of sources) { - const file = `${examplesFolder}/${name}`; + const file = `${folder}/${name}`; if (files.get(file) === code) continue; files.set(file, code); versions.set(file, (versions.get(file) ?? 0) + 1); } }; - const info: Service["info"] = (sources, name, position) => { - take(sources); - const found = service.getQuickInfoAtPosition(`${examplesFolder}/${name}`, position); + const info: Service["info"] = (owner, sources, name, position) => { + take(owner, sources); + const found = service.getQuickInfoAtPosition(`${examplesFolderOf(owner)}/${name}`, position); if (found === undefined) return null; return { start: found.textSpan.start, length: found.textSpan.length, display: partsOf(found.displayParts), documentation: partsOf(found.documentation), tags: (found.tags ?? []).map(tag => ({ name: tag.name, text: partsOf(tag.text) })), }; }; - const check: Service["check"] = sources => { - take(sources); + const check: Service["check"] = (owner, sources) => { + take(owner, sources); return sources.flatMap(({ name }) => { - const file = `${examplesFolder}/${name}`; + const file = `${examplesFolderOf(owner)}/${name}`; const source = service.getProgram()?.getSourceFile(file); return [...service.getSyntacticDiagnostics(file), ...service.getSemanticDiagnostics(file)].map(diagnostic => diagnosticOf(name, source, diagnostic)); }); diff --git a/ecs/packages/ecs-guide/src/check/messages.ts b/ecs/packages/ecs-guide/src/check/messages.ts index f120d9df..01eb6541 100644 --- a/ecs/packages/ecs-guide/src/check/messages.ts +++ b/ecs/packages/ecs-guide/src/check/messages.ts @@ -2,8 +2,8 @@ import type { Diagnostic } from "../compile/diagnostic.js"; import type { Source } from "../compile/messages.js"; -/** Checks a chapter's files, by their names: TypeScript's diagnostics for each, as of `version`. */ -export type CheckRequest = { readonly kind: "check"; readonly version: number; readonly files: readonly Source[] }; +/** Checks a chapter's files, examples of the package `owner`, by their names: TypeScript's diagnostics for each, as of `version`. */ +export type CheckRequest = { readonly kind: "check"; readonly version: number; readonly owner: string; readonly files: readonly Source[] }; /** A check's diagnostics; `failure` says why TypeScript itself failed, null when it didn't. */ export type CheckReply = { readonly kind: "checked"; readonly version: number; readonly diagnostics: readonly Diagnostic[]; readonly failure: string | null }; @@ -20,8 +20,8 @@ export type QuickInfo = { readonly tags: readonly { readonly name: string; readonly text: readonly Part[] }[]; }; -/** Asks what's at `position` in the file named, of a chapter's files as they are now; `serial` tells replies apart. */ -export type InfoRequest = { readonly kind: "info"; readonly serial: number; readonly files: readonly Source[]; readonly file: string; readonly position: number }; +/** Asks what's at `position` in the file named, of a chapter's files as they are now, examples of `owner`; `serial` tells replies apart. */ +export type InfoRequest = { readonly kind: "info"; readonly serial: number; readonly owner: string; readonly files: readonly Source[]; readonly file: string; readonly position: number }; /** What's there, null for nothing; `failure` says why TypeScript itself failed. */ export type InfoReply = { readonly kind: "info"; readonly serial: number; readonly info: QuickInfo | null; readonly failure: string | null }; @@ -43,15 +43,18 @@ const isSources = (value: unknown): value is readonly Source[] => Array.isArray( export const infoRequestOf = (data: unknown): InfoRequest | null => { if (typeof data !== "object" || data === null || Reflect.get(data, "kind") !== "info") return null; const serial: unknown = Reflect.get(data, "serial"); + const owner: unknown = Reflect.get(data, "owner"); const files: unknown = Reflect.get(data, "files"); const file: unknown = Reflect.get(data, "file"); const position: unknown = Reflect.get(data, "position"); - return typeof serial === "number" && isSources(files) && typeof file === "string" && typeof position === "number" ? { kind: "info", serial, files, file, position } : null; + return typeof serial === "number" && typeof owner === "string" && isSources(files) && typeof file === "string" && typeof position === "number" + ? { kind: "info", serial, owner, files, file, position } : null; }; export const checkRequestOf = (data: unknown): CheckRequest | null => { if (typeof data !== "object" || data === null || Reflect.get(data, "kind") !== "check") return null; const version: unknown = Reflect.get(data, "version"); + const owner: unknown = Reflect.get(data, "owner"); const files: unknown = Reflect.get(data, "files"); - return typeof version === "number" && isSources(files) ? { kind: "check", version, files } : null; + return typeof version === "number" && typeof owner === "string" && isSources(files) ? { kind: "check", version, owner, files } : null; }; diff --git a/ecs/packages/ecs-guide/src/compile/compile-run.test.ts b/ecs/packages/ecs-guide/src/compile/compile-run.test.ts index ce959ee6..fdb362fb 100644 --- a/ecs/packages/ecs-guide/src/compile/compile-run.test.ts +++ b/ecs/packages/ecs-guide/src/compile/compile-run.test.ts @@ -26,25 +26,27 @@ beforeAll(async () => { guard = code => protect(code, source => parse(source, { lang: "js" })); }); -const request = (files: CompileRequest["files"]): CompileRequest => ({ kind: "compile", run: 1, files, runs: "https://guide/run/", runtime: "https://guide/runtime.js", names: ["System"] }); +const core = numbered(1)?.package ?? ""; +const request = (files: CompileRequest["files"], owner = core): CompileRequest => ({ kind: "compile", run: 1, package: owner, files, runs: "https://guide/run/", runtime: "https://guide/runtime.js", packages: [{ name: owner, names: ["System"] }] }); it("compiles every chapter's files without a diagnostic, their kernels inlined, their ids naming the package", async () => { let kernels = 0; for (const chapter of chapters.filter(chapter => chapter.files.length > 0)) { - const { diagnostics, files } = await compileRun(compiler, request(chapter.files.map(file => ({ name: file.name, code: file.source }))), guard); + const { diagnostics, files } = await compileRun(compiler, request(chapter.files.map(file => ({ name: file.name, code: file.source })), chapter.package), guard); expect([chapter.slug, diagnostics]).toEqual([chapter.slug, []]); - const modules = files?.slice(2) ?? []; - expect(modules.map(file => file.path)).toEqual(chapter.files.map(file => `node_modules/@adobe/ecs/src/guide/${file.name}`)); + // Past its page, the runtime's exports module and its package's. + const modules = files?.slice(3) ?? []; + expect(modules.map(file => file.path)).toEqual(chapter.files.map(file => `node_modules/${chapter.package}/src/guide/${file.name}`)); for (const module of modules) { expect(module.text).not.toContain("@ecs-kernel-inline/"); kernels += [...module.text.matchAll(/"data:text\/javascript;base64,/g)].length; // Each module's own code is protected from loops that never end, its kernels never. expect(module.text.startsWith(`import { loopGuard as __guideLoop, callGuard as __guideCall } from "@ecs-guide/guard";`)).toBe(true); for (const [, kernel = ""] of module.text.matchAll(/"data:text\/javascript;base64,([^"]+)"/g)) expect(atob(kernel)).not.toContain("__guide"); - if (module.text.includes("identify$(")) expect(module.text).toMatch(/"@adobe\/ecs@[^/]+\/src\/guide\/[^#"]+#\w+"/); + if (module.text.includes("identify$(")) expect(module.text).toContain(`"${chapter.package}@`); } } - expect(kernels).toBe(24); + expect(kernels).toBe(25); }); it("reports what the build rejects as a diagnostic located in the file, and gives no run", async () => { @@ -61,7 +63,7 @@ it("describes each system of every chapter, at its call in the file as given, wi const counts = { compiled: 0, eligible: 0, registration: 0, none: 0 }; for (const chapter of chapters.filter(chapter => chapter.files.length > 0)) { const sources = chapter.files.map(file => ({ name: file.name, code: file.source })); - const { systems } = await compileRun(compiler, request(sources), guard); + const { systems } = await compileRun(compiler, request(sources, chapter.package), guard); expect(systems.map(entry => entry.file)).toEqual(sources.map(source => source.name)); for (const { file, systems: described } of systems) { const code = sources.find(source => source.name === file)?.code ?? ""; @@ -93,7 +95,7 @@ it("compiles a kernel whose body loops, its kernel holding no loop protection", expect(edited).not.toBe(kernels.source); const { diagnostics, files } = await compileRun(compiler, request([{ name: kernels.name, code: edited }]), guard); expect(diagnostics).toEqual([]); - const module = files?.[2]?.text ?? ""; + const module = files?.[3]?.text ?? ""; const inlined = [...module.matchAll(/"data:text\/javascript;base64,([^"]+)"/g)].map(([, kernel = ""]) => atob(kernel)); expect(inlined.length).toBe(2); for (const kernel of inlined) expect(kernel).not.toContain("__guide"); diff --git a/ecs/packages/ecs-guide/src/compile/compile-run.ts b/ecs/packages/ecs-guide/src/compile/compile-run.ts index 8330bb71..1c6964ef 100644 --- a/ecs/packages/ecs-guide/src/compile/compile-run.ts +++ b/ecs/packages/ecs-guide/src/compile/compile-run.ts @@ -5,7 +5,7 @@ import type { Diagnostic } from "./diagnostic.js"; import type { CompileRequest } from "./messages.js"; // The plugin's own inlining of kernels for node, which the guide does in the browser too (README.md). import { inlineKernels, inlinePlaceholder } from "../../../ecs/src/implementation/compiler/inline-kernels.js"; -import { examplesFolder, runFiles, type RunFile } from "./run-files.js"; +import { examplesFolderOf, runFiles, type RunFile } from "./run-files.js"; /** * A run compiled: the build's diagnostics, its files, null when a module failed, and what each system of each module @@ -23,7 +23,7 @@ export const compileRun = async (compiler: MemoryCompiler, request: CompileReque const compiled: { readonly path: string; readonly code: string }[] = []; const systems: FileSystems[] = []; for (const { name, code } of request.files) { - const path = `${examplesFolder}/${name}`; + const path = `${examplesFolderOf(request.package)}/${name}`; const result = await compiler.compile(path, code, index => JSON.stringify(inlinePlaceholder(index, "")), { describe: true }); if ("failure" in result) { diagnostics.push({ kind: "build", severity: "error", file: name, message: result.failure, line: result.location?.line ?? null, column: result.location?.column ?? null, length: null }); @@ -33,5 +33,5 @@ export const compileRun = async (compiler: MemoryCompiler, request: CompileReque compiled.push({ path, code: inlineKernels(guard(result.code), result.kernels, []) }); systems.push({ file: name, systems: result.systems ?? [] }); } - return { diagnostics, files: compiled.length < request.files.length ? null : runFiles(request.run, request.runtime, request.names, compiled), systems }; + return { diagnostics, files: compiled.length < request.files.length ? null : runFiles(request.run, request.runtime, request.packages, compiled), systems }; }; diff --git a/ecs/packages/ecs-guide/src/compile/messages.ts b/ecs/packages/ecs-guide/src/compile/messages.ts index 208bd5c6..7e46f127 100644 --- a/ecs/packages/ecs-guide/src/compile/messages.ts +++ b/ecs/packages/ecs-guide/src/compile/messages.ts @@ -5,13 +5,17 @@ import type { Diagnostic } from "./diagnostic.js"; /** An example's source, by its file's name beside the guide. */ export type Source = { readonly name: string; readonly code: string }; +/** A package a run's frame imports: its name, and the names it exports, which its exports module re-exports. */ +export type Exported = { readonly name: string; readonly names: readonly string[] }; + /** - * Compiles a run's modules: on success puts its files in the cache under `runs`, the service worker's folder of runs, - * its exports module loading `runtime` and re-exporting `names`; with `runs` null, only compiles them. + * Compiles a run's modules, examples of `package`: on success puts its files in the cache under `runs`, the service + * worker's folder of runs, its exports modules loading `runtime` and re-exporting each of `packages`' names from it; + * with `runs` null, only compiles them. */ export type CompileRequest = { - readonly kind: "compile"; readonly run: number; readonly files: readonly Source[]; - readonly runs: string | null; readonly runtime: string; readonly names: readonly string[]; + readonly kind: "compile"; readonly run: number; readonly package: string; readonly files: readonly Source[]; + readonly runs: string | null; readonly runtime: string; readonly packages: readonly Exported[]; }; /** @@ -49,13 +53,17 @@ const isStrings = (value: unknown): value is readonly string[] => Array.isArray( const isSources = (value: unknown): value is readonly Source[] => Array.isArray(value) && value.every(item => typeof item === "object" && item !== null && typeof Reflect.get(item, "name") === "string" && typeof Reflect.get(item, "code") === "string"); +const isExported = (value: unknown): value is readonly Exported[] => Array.isArray(value) + && value.every(item => typeof item === "object" && item !== null && typeof Reflect.get(item, "name") === "string" && isStrings(Reflect.get(item, "names"))); + export const compileRequestOf = (data: unknown): CompileRequest | null => { if (typeof data !== "object" || data === null || Reflect.get(data, "kind") !== "compile") return null; const run: unknown = Reflect.get(data, "run"); + const owner: unknown = Reflect.get(data, "package"); const files: unknown = Reflect.get(data, "files"); const runs: unknown = Reflect.get(data, "runs"); const runtime: unknown = Reflect.get(data, "runtime"); - const names: unknown = Reflect.get(data, "names"); - return typeof run === "number" && isSources(files) && (runs === null || typeof runs === "string") && typeof runtime === "string" && isStrings(names) - ? { kind: "compile", run, files, runs, runtime, names } : null; + const packages: unknown = Reflect.get(data, "packages"); + return typeof run === "number" && typeof owner === "string" && isSources(files) && (runs === null || typeof runs === "string") && typeof runtime === "string" && isExported(packages) + ? { kind: "compile", run, package: owner, files, runs, runtime, packages } : null; }; diff --git a/ecs/packages/ecs-guide/src/compile/package-files.ts b/ecs/packages/ecs-guide/src/compile/package-files.ts index 64fbb1d7..6b840644 100644 --- a/ecs/packages/ecs-guide/src/compile/package-files.ts +++ b/ecs/packages/ecs-guide/src/compile/package-files.ts @@ -1,21 +1,10 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { packageRoot } from "./run-files.js"; -import { filesOf, shippedBy } from "./shipped.js"; +import { packages } from "virtual:guide-packages"; /** - * The package as the compiler reads it, installed at `packageRoot`: its manifest, and of the files it ships, as its - * `files` say, its sources' root and interface, which the build reads types and the examples' imports from, and its - * build's, which kernels inline from. Read from `../ecs` as the guide builds. + * The guide's packages as the compiler reads them, each installed at `/node_modules/` and its name: its manifest, and + * of the files it ships, those its `guide.files` name, which the build reads types, the examples' imports and the + * kernels it inlines from. */ -const manifest: { readonly [path: string]: string } = import.meta.glob("../../../ecs/package.json", { query: "?raw", import: "default", eager: true }); -const sources: { readonly [path: string]: string } = import.meta.glob( - ["../../../ecs/src/index.ts", "../../../ecs/src/interface/**/*.ts", "../../../ecs/dist/index.js", "../../../ecs/dist/interface/**/*.js"], - { query: "?raw", import: "default", eager: true }, -); - -const shipped = shippedBy(filesOf(Object.values(manifest)[0] ?? "{}")); - -export const packageFiles: ReadonlyMap = new Map(Object.entries({ ...manifest, ...sources }) - .map(([path, text]): [string, string] => [path.slice("../../../ecs/".length), text]) - .filter(([path]) => path === "package.json" || shipped(path)) - .map(([path, text]) => [`${packageRoot}/${path}`, text])); +export const packageFiles: ReadonlyMap = new Map(packages.flatMap(({ name, files }) => + files.map(([path, text]): [string, string] => [`/node_modules/${name}/${path}`, text]))); diff --git a/ecs/packages/ecs-guide/src/compile/run-files.test.ts b/ecs/packages/ecs-guide/src/compile/run-files.test.ts index 7908de76..a51aeb5b 100644 --- a/ecs/packages/ecs-guide/src/compile/run-files.test.ts +++ b/ecs/packages/ecs-guide/src/compile/run-files.test.ts @@ -2,13 +2,21 @@ import { expect, it } from "vitest"; import { hookModules, runFiles } from "./run-files.js"; -it("gives a run's page an import map sending every specifier compiled code imports from the package to its exports", () => { - const files = runFiles(4, "https://guide/assets/runtime.js", ["System", "start"], [{ path: "/node_modules/@adobe/ecs/src/guide/01-resources.ts", code: "export {};" }]); - expect(files.map(file => file.path)).toEqual(["index.html", "exports.js", "node_modules/@adobe/ecs/src/guide/01-resources.ts"]); +it("gives a run's page an import map sending each package, as compiled code imports it, to its exports, and the runtime's own to the runtime's", () => { + const packages = [{ name: "@adobe/ecs", names: ["System"] }, { name: "@adobe/ecs-gpu", names: ["Frame", "View"] }]; + const files = runFiles(4, "https://guide/assets/runtime.js", packages, [{ path: "/node_modules/@adobe/ecs-gpu/src/guide/01-devices.ts", code: "export {};" }]); + expect(files.map(file => file.path)).toEqual(["index.html", "exports.js", "package-0.js", "package-1.js", "node_modules/@adobe/ecs-gpu/src/guide/01-devices.ts"]); const page = files[0]?.text ?? ""; const map: unknown = JSON.parse(/