diff --git a/packages/extension-base/README.md b/packages/extension-base/README.md index b195c92..aad7e77 100644 --- a/packages/extension-base/README.md +++ b/packages/extension-base/README.md @@ -16,7 +16,7 @@ The generator renders `template/` into `prototypes//`. Change the template The shared CLI is modeled on `apps/roam` in the Discourse Graphs monorepo. It bundles TypeScript and imported CSS with esbuild, maps Roam-provided browser globals, injects non-secret build metadata used by `runExtension`, and writes public files to `dist/`. It does not publish artifacts. -`roam-prototype dev` watches the source and serves `dist/` from a local URL. `roam-prototype build` creates a minified production bundle without source maps. README and CHANGELOG files are copied into `dist/`; imported CSS is emitted as `extension.css`. +Run `pnpm dev` from a generated prototype to invoke `roam-prototype dev`, which watches the source and rebuilds `dist/` on changes. The terminal confirms the initial build and every successful update. Reloading the developer extension in Roam remains controlled by Roam's reload command or hotkey. `roam-prototype build` creates a minified production bundle without source maps. README and CHANGELOG files are copied into `dist/`; imported CSS is emitted as `extension.css`. The starter imports the lifecycle wrapper as a named export: diff --git a/packages/extension-base/scripts/cli.mjs b/packages/extension-base/scripts/cli.mjs index d675220..d895ae2 100644 --- a/packages/extension-base/scripts/cli.mjs +++ b/packages/extension-base/scripts/cli.mjs @@ -74,6 +74,19 @@ const copyPublicDocuments = ({ root, outdir }) => ({ }, }); +export const createDevelopmentNotifier = ({ outdir, log = console.log }) => ({ + name: "development-build-notifier", + setup(build) { + let hasBuilt = false; + build.onEnd(({ errors }) => { + if (errors.length) return; + const output = path.relative(process.cwd(), outdir) || "dist"; + log(hasBuilt ? `Updated ${output}` : `Built ${output}; watching for changes`); + hasBuilt = true; + }); + }, +}); + const readManifest = async (root) => { const source = await readFile(path.join(root, "package.json"), "utf8"); const manifest = JSON.parse(source); @@ -116,6 +129,7 @@ const createBuildOptions = async ({ root, production }) => { plugins: [ importAsGlobals(HOST_GLOBALS), copyPublicDocuments({ root, outdir }), + ...(!production ? [createDevelopmentNotifier({ outdir })] : []), ], }; }; @@ -129,17 +143,7 @@ export const buildPrototype = async ({ root = process.cwd() } = {}) => { console.log(`Built ${path.relative(process.cwd(), outdir) || "dist"}`); }; -const parsePort = (args) => { - const index = args.indexOf("--port"); - if (index === -1) return 3000; - const port = Number(args[index + 1]); - if (!Number.isInteger(port) || port < 1 || port > 65535) { - throw new Error("--port must be an integer from 1 to 65535"); - } - return port; -}; - -export const developPrototype = async ({ root = process.cwd(), port = 3000 } = {}) => { +export const developPrototype = async ({ root = process.cwd() } = {}) => { const resolvedRoot = path.resolve(root); const outdir = path.join(resolvedRoot, "dist"); await rm(outdir, { recursive: true, force: true }); @@ -149,12 +153,6 @@ export const developPrototype = async ({ root = process.cwd(), port = 3000 } = { await createBuildOptions({ root: resolvedRoot, production: false }), ); await context.watch(); - const server = await context.serve({ - host: "127.0.0.1", - port, - servedir: outdir, - }); - console.log(`Developer-extension URL: http://${server.host}:${server.port}/`); await new Promise((resolve) => { let stopping = false; @@ -170,16 +168,16 @@ export const developPrototype = async ({ root = process.cwd(), port = 3000 } = { }; const run = async () => { - const [command, ...args] = process.argv.slice(2); + const [command] = process.argv.slice(2); if (command === "build") { await buildPrototype(); return; } if (command === "dev") { - await developPrototype({ port: parsePort(args) }); + await developPrototype(); return; } - throw new Error("Usage: roam-prototype [--port ]"); + throw new Error("Usage: roam-prototype "); }; const normalizePath = (value) => { diff --git a/packages/extension-base/template/package.json b/packages/extension-base/template/package.json index 750bc41..52bb5de 100644 --- a/packages/extension-base/template/package.json +++ b/packages/extension-base/template/package.json @@ -5,7 +5,7 @@ "description": __PROTOTYPE_DESCRIPTION_JSON__, "type": "module", "scripts": { - "start": "roam-prototype dev", + "dev": "roam-prototype dev", "build": "roam-prototype build", "test": "vitest run --passWithNoTests" }, diff --git a/prototypes/loaded-dialog/package.json b/prototypes/loaded-dialog/package.json index 1aef416..41627a2 100644 --- a/prototypes/loaded-dialog/package.json +++ b/prototypes/loaded-dialog/package.json @@ -5,7 +5,7 @@ "description": "Shows a dialog confirming that the extension has loaded.", "type": "module", "scripts": { - "start": "roam-prototype dev", + "dev": "roam-prototype dev", "build": "roam-prototype build", "test": "vitest run --passWithNoTests" }, diff --git a/scripts/validate-prototypes.mjs b/scripts/validate-prototypes.mjs index 28c20a0..7bbad0a 100644 --- a/scripts/validate-prototypes.mjs +++ b/scripts/validate-prototypes.mjs @@ -8,7 +8,7 @@ import { } from "./artifact-utils.mjs"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -const prototypesRoot = path.join(root, "prototypes"); +const defaultPrototypesRoot = path.join(root, "prototypes"); const sourceExtension = /\.[cm]?[jt]sx?$/; const isFile = async (target) => { @@ -19,12 +19,12 @@ const isFile = async (target) => { } }; -const validateSourceDirectory = async (directory, prototype) => { +const validateSourceDirectory = async (directory, prototype, prototypesRoot) => { const entries = await readdir(directory, { withFileTypes: true }); for (const entry of entries) { const target = path.join(directory, entry.name); if (entry.isDirectory()) { - await validateSourceDirectory(target, prototype); + await validateSourceDirectory(target, prototype, prototypesRoot); } else if (entry.isFile() && sourceExtension.test(entry.name)) { const source = await readFile(target, "utf8"); assertAllowedEnvironmentReferences( @@ -37,38 +37,55 @@ const validateSourceDirectory = async (directory, prototype) => { } }; -const entries = await readDirectoryIfExists(prototypesRoot, { withFileTypes: true }); -let packageCount = 0; +export const validatePrototypes = async ({ + prototypesRoot = defaultPrototypesRoot, +} = {}) => { + const entries = await readDirectoryIfExists(prototypesRoot, { + withFileTypes: true, + }); + let packageCount = 0; -for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { - if (!entry.isDirectory()) continue; - const prototype = assertPrototypeName(entry.name); - const directory = path.join(prototypesRoot, prototype); - const packageFile = path.join(directory, "package.json"); - if (!(await isFile(packageFile))) { - console.log(`Skipping ${prototype}: placeholder has no package.json`); - continue; - } + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + if (!entry.isDirectory()) continue; + const prototype = assertPrototypeName(entry.name); + const directory = path.join(prototypesRoot, prototype); + const packageFile = path.join(directory, "package.json"); + if (!(await isFile(packageFile))) { + console.log(`Skipping ${prototype}: placeholder has no package.json`); + continue; + } - const manifest = JSON.parse(await readFile(packageFile, "utf8")); - if (manifest.name !== prototype) { - throw new Error(`${prototype}/package.json name must equal ${prototype}`); - } - for (const script of ["start", "build", "test"]) { - if (typeof manifest.scripts?.[script] !== "string") { - throw new Error(`${prototype}/package.json is missing the ${script} script`); + const manifest = JSON.parse(await readFile(packageFile, "utf8")); + if (manifest.name !== prototype) { + throw new Error(`${prototype}/package.json name must equal ${prototype}`); } - } - if (!(await isFile(path.join(directory, "README.md")))) { - throw new Error(`${prototype} is missing README.md`); - } - const sourceDirectory = path.join(directory, "src"); - if (!(await isFile(path.join(sourceDirectory, "index.ts")))) { - throw new Error(`${prototype} is missing src/index.ts`); + for (const script of ["dev", "build", "test"]) { + if (typeof manifest.scripts?.[script] !== "string") { + throw new Error(`${prototype}/package.json is missing the ${script} script`); + } + } + if (!(await isFile(path.join(directory, "README.md")))) { + throw new Error(`${prototype} is missing README.md`); + } + const sourceDirectory = path.join(directory, "src"); + if (!(await isFile(path.join(sourceDirectory, "index.ts")))) { + throw new Error(`${prototype} is missing src/index.ts`); + } + + await validateSourceDirectory(sourceDirectory, prototype, prototypesRoot); + packageCount += 1; } - await validateSourceDirectory(sourceDirectory, prototype); - packageCount += 1; -} + return packageCount; +}; -console.log(`Validated ${packageCount} prototype package(s).`); +const isCli = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (isCli) { + try { + const packageCount = await validatePrototypes(); + console.log(`Validated ${packageCount} prototype package(s).`); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + } +} diff --git a/test/create-prototype.test.mjs b/test/create-prototype.test.mjs index d8a7ad7..551f2ab 100644 --- a/test/create-prototype.test.mjs +++ b/test/create-prototype.test.mjs @@ -48,7 +48,8 @@ test("creates a complete prototype with catalog dependencies", async () => { "workspace:*", ); assert.equal(manifest.scripts.build, "roam-prototype build"); - assert.equal(manifest.scripts.start, "roam-prototype dev"); + assert.equal(manifest.scripts.dev, "roam-prototype dev"); + assert.equal(manifest.scripts.start, undefined); assert.equal(manifest.devDependencies["@samepage/scripts"], undefined); assert.equal(manifest.dependencies["roamjs-components"], "catalog:"); assert.equal(manifest.dependencies["use-sync-external-store"], "catalog:"); diff --git a/test/extension-cli.test.mjs b/test/extension-cli.test.mjs index 84237f1..1a7ef6a 100644 --- a/test/extension-cli.test.mjs +++ b/test/extension-cli.test.mjs @@ -3,7 +3,10 @@ import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; -import { pathsReferToSameFile } from "../packages/extension-base/scripts/cli.mjs"; +import { + createDevelopmentNotifier, + pathsReferToSameFile, +} from "../packages/extension-base/scripts/cli.mjs"; test("recognizes a CLI invoked through a workspace directory link", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "roam-extension-cli-")); @@ -31,3 +34,22 @@ test("recognizes a CLI invoked through a workspace directory link", async () => await rm(root, { recursive: true, force: true }); } }); + +test("reports the initial development build and subsequent updates", () => { + const messages = []; + const callbacks = []; + const notifier = createDevelopmentNotifier({ + outdir: path.join(process.cwd(), "dist"), + log: (message) => messages.push(message), + }); + notifier.setup({ onEnd: (callback) => callbacks.push(callback) }); + + callbacks[0]({ errors: [] }); + callbacks[0]({ errors: [] }); + callbacks[0]({ errors: [{}] }); + + assert.deepEqual(messages, [ + "Built dist; watching for changes", + "Updated dist", + ]); +}); diff --git a/test/validate-prototypes.test.mjs b/test/validate-prototypes.test.mjs new file mode 100644 index 0000000..95e77bf --- /dev/null +++ b/test/validate-prototypes.test.mjs @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { createPrototype } from "../scripts/create-prototype.mjs"; +import { validatePrototypes } from "../scripts/validate-prototypes.mjs"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +test("validates the generated dev script convention", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "roam-validator-test-")); + const prototypesRoot = path.join(root, "prototypes"); + await mkdir(prototypesRoot); + + try { + const result = await createPrototype({ + name: "sample-prototype", + title: "Sample Prototype", + description: "Tests the validator's script convention.", + repoRoot, + prototypesRoot, + skipInstall: true, + }); + + assert.equal(await validatePrototypes({ prototypesRoot }), 1); + + const packageFile = path.join(result.destination, "package.json"); + const manifest = JSON.parse(await readFile(packageFile, "utf8")); + manifest.scripts.start = manifest.scripts.dev; + delete manifest.scripts.dev; + await writeFile(packageFile, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); + + await assert.rejects( + validatePrototypes({ prototypesRoot }), + /sample-prototype\/package\.json is missing the dev script/, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +});