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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/extension-base/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ The generator renders `template/` into `prototypes/<name>/`. 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:

Expand Down
38 changes: 18 additions & 20 deletions packages/extension-base/scripts/cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -116,6 +129,7 @@ const createBuildOptions = async ({ root, production }) => {
plugins: [
importAsGlobals(HOST_GLOBALS),
copyPublicDocuments({ root, outdir }),
...(!production ? [createDevelopmentNotifier({ outdir })] : []),
],
};
};
Expand All @@ -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 });
Expand All @@ -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;
Expand All @@ -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 <build|dev> [--port <number>]");
throw new Error("Usage: roam-prototype <build|dev>");
};

const normalizePath = (value) => {
Expand Down
2 changes: 1 addition & 1 deletion packages/extension-base/template/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"description": __PROTOTYPE_DESCRIPTION_JSON__,
"type": "module",
"scripts": {
"start": "roam-prototype dev",
"dev": "roam-prototype dev",
Comment thread
mdroidian marked this conversation as resolved.
Comment thread
mdroidian marked this conversation as resolved.
"build": "roam-prototype build",
"test": "vitest run --passWithNoTests"
},
Expand Down
2 changes: 1 addition & 1 deletion prototypes/loaded-dialog/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
81 changes: 49 additions & 32 deletions scripts/validate-prototypes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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(
Expand All @@ -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;
}
}
3 changes: 2 additions & 1 deletion test/create-prototype.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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:");
Expand Down
24 changes: 23 additions & 1 deletion test/extension-cli.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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-"));
Expand Down Expand Up @@ -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",
]);
});
42 changes: 42 additions & 0 deletions test/validate-prototypes.test.mjs
Original file line number Diff line number Diff line change
@@ -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 });
}
});