chore: import upstream snapshot with attribution
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:23:53 +08:00
commit b3a7f98e5a
3020 changed files with 935484 additions and 0 deletions
+261
View File
@@ -0,0 +1,261 @@
/**
* Environment-probe helpers for `emdash-plugin init`.
*
* The goal: when the user runs init, pre-fill prompts with whatever the
* surrounding environment already knows. None of these probes are
* authoritative — they're just sensible defaults the user can override.
*
* Sources, in priority order per field:
*
* - git config (user.name, user.email): the canonical "who am I" on
* any developer machine.
* - `git remote get-url origin`: the most reliable source for the
* plugin's repo URL.
* - package.json#description / #license / #repository: catches the
* "scaffolding into an existing repo skeleton" case.
*
* Every probe swallows errors. The CLI uses these as soft defaults; a
* missing git binary, a non-git target dir, an unreadable package.json
* are all expected and silently fall through to "no default".
*/
import { execFile } from "node:child_process";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
/**
* Hard cap on the bytes we'll read from package.json. Pre-fills are a
* convenience; we don't want to OOM on a deranged 1GB package.json that
* happens to live in the target directory.
*/
const PACKAGE_JSON_MAX_BYTES = 64 * 1024;
/**
* Timeout for the git child-process calls. Local git config / remote
* lookup should complete in milliseconds; anything that hangs is
* almost certainly a misconfigured remote or a network mount. We'd
* rather skip the pre-fill than make `init` feel slow.
*/
const GIT_TIMEOUT_MS = 2_000;
/**
* Snapshot of pre-fill values discovered from the surrounding
* environment. Every field is optional — undefined means "nothing
* found", and the caller should fall back to its own default.
*/
export interface EnvironmentDefaults {
authorName: string | undefined;
authorEmail: string | undefined;
license: string | undefined;
description: string | undefined;
repo: string | undefined;
}
/**
* Probe the environment for pre-fillable values. Inspects:
*
* 1. git config (global and per-dir) for user.name + user.email.
* 2. `git remote get-url origin` (with normalization) for the repo URL.
* 3. `<targetDir>/package.json` for description / license / repository.
*
* Errors in any one probe don't abort the others. The function returns
* whatever it could determine, with each missing field as undefined.
*/
export async function probeEnvironment(targetDir: string): Promise<EnvironmentDefaults> {
// Run independent probes in parallel — they don't depend on each
// other and each one is the slow path of a single fs/exec call.
const [authorName, authorEmail, repoFromGit, fromPackageJson] = await Promise.all([
gitConfig("user.name", targetDir),
gitConfig("user.email", targetDir),
gitRemoteUrl(targetDir),
readPackageJson(targetDir),
]);
return {
authorName,
authorEmail,
// package.json#repository overrides the git remote only if it
// looks like a deliberate, complete URL (the git remote is the
// stronger signal of "where this code actually lives", but if a
// pre-existing package.json points elsewhere, respect it).
repo: fromPackageJson.repo ?? repoFromGit,
license: fromPackageJson.license,
description: fromPackageJson.description,
};
}
// ──────────────────────────────────────────────────────────────────────────
// Git config
// ──────────────────────────────────────────────────────────────────────────
/**
* Read a git config value, falling back from the target dir to the
* global config. Returns undefined if git isn't installed, the dir
* isn't a repo, or the key is unset.
*
* We use `git config --get` rather than `git config --show-origin` etc.
* because `--get` is the simplest "give me the effective value" form
* and it walks the repo→global→system fallback chain itself.
*/
async function gitConfig(key: string, cwd: string): Promise<string | undefined> {
try {
const { stdout } = await execFileAsync("git", ["config", "--get", key], {
cwd,
timeout: GIT_TIMEOUT_MS,
// Limit output size to defend against a deliberately-bizarre
// git config value. 4 KiB is generous for "name" / "email".
maxBuffer: 4096,
});
const value = stdout.trim();
return value.length === 0 ? undefined : value;
} catch {
return undefined;
}
}
// ──────────────────────────────────────────────────────────────────────────
// Git remote
// ──────────────────────────────────────────────────────────────────────────
const GIT_SSH_RE = /^git@([^:]+):(.+?)(?:\.git)?$/;
const HTTPS_TRAILING_GIT_RE = /\.git$/;
const GIT_URL_PREFIX_RE = /^git\+/;
/**
* Read `origin` remote URL and normalize to https. `git@github.com:foo/bar.git`
* becomes `https://github.com/foo/bar`. Returns undefined if there's no
* origin remote, no git, or the URL doesn't normalize to a recognisable
* shape.
*/
async function gitRemoteUrl(cwd: string): Promise<string | undefined> {
let raw: string;
try {
const { stdout } = await execFileAsync("git", ["remote", "get-url", "origin"], {
cwd,
timeout: GIT_TIMEOUT_MS,
// 1 KiB is plenty for a URL; protects against weird remote
// names that include the entire output of a hostile hook.
maxBuffer: 1024,
});
raw = stdout.trim();
} catch {
return undefined;
}
if (raw.length === 0) return undefined;
return normalizeRepoUrl(raw);
}
/**
* Normalize a git remote URL to the https form the manifest's `repo`
* field expects. Handles:
*
* git@github.com:foo/bar.git → https://github.com/foo/bar
* git@github.com:foo/bar → https://github.com/foo/bar
* https://github.com/foo/bar.git → https://github.com/foo/bar
* https://github.com/foo/bar → https://github.com/foo/bar
* ssh://git@... → undefined (not auto-rewritten; user can paste)
*
* Returns undefined for shapes we don't recognise; the manifest schema
* requires `https://...` and we'd rather omit the pre-fill than write
* a value the schema will reject.
*/
function normalizeRepoUrl(raw: string): string | undefined {
const sshMatch = GIT_SSH_RE.exec(raw);
if (sshMatch) {
const [, host, path] = sshMatch;
return `https://${host}/${path}`;
}
if (raw.startsWith("https://")) {
return raw.replace(HTTPS_TRAILING_GIT_RE, "");
}
return undefined;
}
// ──────────────────────────────────────────────────────────────────────────
// package.json
// ──────────────────────────────────────────────────────────────────────────
interface PackageJsonDefaults {
license: string | undefined;
description: string | undefined;
repo: string | undefined;
}
const EMPTY_PACKAGE_JSON: PackageJsonDefaults = {
license: undefined,
description: undefined,
repo: undefined,
};
/**
* Read `<targetDir>/package.json` and pull license, description, and
* a normalized repo URL out of it. Returns the empty defaults shape on
* any failure (missing file, parse error, oversized file). The
* scaffolder never trusts this output directly — values flow through
* `EnvironmentDefaults` which the caller may or may not use.
*/
async function readPackageJson(targetDir: string): Promise<PackageJsonDefaults> {
const path = join(targetDir, "package.json");
let raw: string;
try {
raw = await readFile(path, "utf8");
} catch {
return EMPTY_PACKAGE_JSON;
}
if (raw.length > PACKAGE_JSON_MAX_BYTES) {
return EMPTY_PACKAGE_JSON;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return EMPTY_PACKAGE_JSON;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return EMPTY_PACKAGE_JSON;
}
// JSON.parse returns `unknown`; the runtime check above narrows to
// "non-array object", and the cast just makes the property accesses
// below readable. The properties are still typed as `unknown` and
// each one gets its own runtime check.
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- narrowed by runtime check above
const pkg = parsed as Record<string, unknown>;
const license = typeof pkg.license === "string" ? pkg.license.trim() : undefined;
const description = typeof pkg.description === "string" ? pkg.description.trim() : undefined;
const repo = repoFromPackageJsonRepository(pkg.repository);
return {
license: license && license.length > 0 ? license : undefined,
description: description && description.length > 0 ? description : undefined,
repo,
};
}
/**
* Pull a repo URL out of package.json#repository. Handles both forms:
*
* "repository": "https://github.com/foo/bar"
* "repository": { "type": "git", "url": "git+https://github.com/foo/bar.git" }
*
* Normalizes through `normalizeRepoUrl`. Returns undefined if the
* value isn't a recognisable string or object-with-url.
*/
function repoFromPackageJsonRepository(value: unknown): string | undefined {
let raw: string | undefined;
if (typeof value === "string") {
raw = value;
} else if (value && typeof value === "object" && !Array.isArray(value)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- narrowed by runtime check above
const url = (value as Record<string, unknown>).url;
if (typeof url === "string") raw = url;
}
if (raw === undefined || raw.length === 0) return undefined;
// npm accepts a leading `git+` on the URL (e.g. `git+https://...`).
// Strip it before normalisation so the https-check passes.
const stripped = raw.replace(GIT_URL_PREFIX_RE, "");
return normalizeRepoUrl(stripped);
}
+156
View File
@@ -0,0 +1,156 @@
/**
* Filesystem half of `emdash-plugin init`. Takes the scaffold inputs +
* the target directory and writes the file tree. Pure templates live in
* `./templates.ts` so this module is just policy: which files exist,
* where they go, what happens when something's already there.
*
* Overwrite policy: refuses by default if any target file exists. Pass
* `--force` to allow overwriting (file-by-file, not directory-wide).
* This avoids the common "I ran init in the wrong dir and clobbered my
* package.json" surprise.
*/
import { access, mkdir, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import {
renderGitignore,
renderManifest,
renderPackageJson,
renderPluginEntry,
renderReadme,
renderTest,
renderTsconfig,
type ScaffoldInputs,
} from "./templates.js";
export type InitErrorCode = "TARGET_FILE_EXISTS" | "INVALID_SLUG" | "INVALID_PUBLISHER";
export class InitError extends Error {
override readonly name = "InitError";
readonly code: InitErrorCode;
/** When set, the list of paths that already exist and would be overwritten. */
readonly conflicts: string[];
constructor(code: InitErrorCode, message: string, conflicts: string[] = []) {
super(message);
this.code = code;
this.conflicts = conflicts;
}
}
export interface ScaffoldOptions {
/** Absolute path to the target directory. Created if it doesn't exist. */
targetDir: string;
/** Validated scaffold inputs. */
inputs: ScaffoldInputs;
/**
* When true, overwrite existing files. When false, refuse with
* `TARGET_FILE_EXISTS` listing the conflicting paths.
*/
force: boolean;
/** Optional callback per file written, for CLI progress output. */
onFileWritten?: (relativePath: string) => void;
}
export interface ScaffoldResult {
/** Absolute paths of every file the scaffolder wrote. */
written: string[];
}
/**
* The file tree the scaffolder produces. Order matters: parents must
* appear before children (the writer creates intermediate dirs from
* the file path, so order is informational rather than mandatory, but
* a consistent order keeps the per-file progress output predictable).
*/
const FILES = [
"emdash-plugin.jsonc",
"package.json",
"tsconfig.json",
".gitignore",
"README.md",
"src/plugin.ts",
"tests/plugin.test.ts",
] as const;
type ScaffoldFile = (typeof FILES)[number];
/**
* Scaffold a plugin into `targetDir`. The target dir is created if it
* doesn't exist; missing intermediate directories under it are created
* per-file as needed.
*
* If any target file already exists and `force` is false, the function
* throws BEFORE writing anything. Partial writes don't happen — either
* every file gets written or none do.
*/
export async function scaffold(options: ScaffoldOptions): Promise<ScaffoldResult> {
const { targetDir, inputs, force, onFileWritten } = options;
const absDir = resolve(targetDir);
// Pre-flight: check for conflicts. We do this before any write so a
// partial scaffold can't leave the target dir in a half-broken state.
if (!force) {
const conflicts: string[] = [];
for (const file of FILES) {
const absPath = join(absDir, file);
if (await exists(absPath)) {
conflicts.push(file);
}
}
if (conflicts.length > 0) {
throw new InitError(
"TARGET_FILE_EXISTS",
`Cannot scaffold into ${absDir}: the following files already exist. Pass --force to overwrite them.\n ${conflicts.join("\n ")}`,
conflicts,
);
}
}
await mkdir(absDir, { recursive: true });
const written: string[] = [];
for (const file of FILES) {
const absPath = join(absDir, file);
await mkdir(dirname(absPath), { recursive: true });
await writeFile(absPath, renderFile(file, inputs), "utf8");
written.push(absPath);
onFileWritten?.(file);
}
return { written };
}
/**
* Dispatch each scaffold-file path to its renderer. Centralised here so
* adding a new file (icon, screenshot stub, docs page) is one place to
* update — append to FILES, add a case.
*/
function renderFile(file: ScaffoldFile, inputs: ScaffoldInputs): string {
switch (file) {
case "emdash-plugin.jsonc":
return renderManifest(inputs);
case "package.json":
return renderPackageJson(inputs);
case "tsconfig.json":
return renderTsconfig();
case ".gitignore":
return renderGitignore();
case "README.md":
return renderReadme(inputs);
case "src/plugin.ts":
return renderPluginEntry();
case "tests/plugin.test.ts":
return renderTest();
}
}
async function exists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch {
return false;
}
}
+396
View File
@@ -0,0 +1,396 @@
/**
* Pure file-content producers for `emdash-plugin init`.
*
* No filesystem access here — each function takes the inputs and returns
* the bytes that should land at the target path. Keeping these as pure
* functions makes the scaffolder testable without touching disk and
* keeps every template inspectable in one place.
*
* The shape produced is the authoring contract:
*
* emdash-plugin.jsonc — identity + trust contract + profile
* src/plugin.ts — `{ routes?, hooks? } satisfies SandboxedPlugin`
* package.json — type:module, devDep on @emdash-cms/plugin-cli
* tsconfig.json — strict, standalone
* .gitignore
* README.md
* tests/plugin.test.ts
*
* No `src/index.ts`, no `dist/` in source control. `emdash-plugin build`
* generates `dist/` artefacts (plugin.mjs, manifest.json, index.mjs).
*/
import type { ManifestAuthor, ManifestSecurityContact } from "../manifest/schema.js";
/**
* Inputs to the scaffolder.
*
* Every field except `slug` is optional. Missing fields produce a
* placeholder in the generated manifest — either a `TODO:` line
* comment marking a value the author must fill in before the plugin
* works, or an outright omission of an optional field that the
* schema doesn't require.
*
* The contract: a scaffold produced from `{ slug }` alone is a valid
* starting point that the author can `cd` into, fix the TODOs in,
* and ship. There are no "init failed because you didn't pass enough
* flags" surprises.
*/
export interface ScaffoldInputs {
/** Plugin slug. Used as the directory name and the `slug` field. */
slug: string;
/**
* Pre-filled publisher DID (resolved from a handle if the user
* typed one). When undefined, the manifest carries a TODO comment
* and an empty string; the author must set this before the plugin
* will load.
*
* The runtime only ever compares DIDs, so we write a DID — even if
* the user typed a handle. The handle, when known, is emitted as a
* `// <handle>` line comment next to the pinned DID via
* `publisherHandle` below.
*/
publisher: string | undefined;
/**
* Optional handle that resolved to the `publisher` DID. Rendered as
* a `// <handle>` line comment next to the pinned DID so a `git
* diff` reviewer sees a human-readable name for the publisher. The
* CLI ignores the comment on subsequent reads — only the DID is
* authoritative.
*/
publisherHandle: string | undefined;
/** SPDX license expression. Defaults to "MIT" when undefined. */
license: string | undefined;
/**
* Author block. When undefined, the manifest carries a TODO
* comment and a placeholder name; author.url and author.email
* are omitted from the output entirely (the schema makes them
* optional).
*/
author: ManifestAuthor | undefined;
/**
* Security contact. When undefined, the manifest carries a TODO
* comment and a placeholder email; the author replaces it with
* a real contact before publishing.
*/
security: ManifestSecurityContact | undefined;
/** Optional short description. Omitted from the manifest when undefined. */
description: string | undefined;
/** Optional repo URL. Omitted from the manifest when undefined. */
repo: string | undefined;
}
/**
* `emdash-plugin.jsonc` — the manifest. Includes a `$schema` pointer
* for editor completion. JSONC: tab-indented, no trailing comma on the
* final field. Fields omitted when the input is empty so the generated
* file is the smallest valid manifest, not a sea of `""`.
*/
export function renderManifest(input: ScaffoldInputs): string {
const lines: string[] = [];
lines.push("{");
lines.push(
'\t"$schema": "./node_modules/@emdash-cms/plugin-cli/schemas/emdash-plugin.schema.json",',
);
lines.push("");
lines.push(`\t"slug": ${jsonString(input.slug)},`);
// `version` deliberately omitted — the build reads it from
// `package.json` so there's a single source of truth. Registry-only
// plugins (no package.json) would set it here, but the scaffold
// always emits one.
if (!input.publisher) {
lines.push(
'\t// TODO: set your atproto handle (e.g. "example.com") or DID before running `emdash-plugin bundle` or any local-dev integration. The plugin cannot load without it.',
);
lines.push('\t"publisher": "",');
} else {
// When we know the handle that resolved to this DID, append it
// as a line comment for `git diff` readability. The handle is
// purely informational — the CLI never reads it back.
const trailer = input.publisherHandle ? ` // ${input.publisherHandle}` : "";
lines.push(`\t"publisher": ${jsonString(input.publisher)},${trailer}`);
}
lines.push("");
lines.push(`\t"license": ${jsonString(input.license ?? "MIT")},`);
if (input.author) {
lines.push(`\t"author": ${renderAuthor(input.author)},`);
} else {
lines.push(
"\t// TODO: replace the placeholder with your real name and (optionally) url/email before publishing.",
);
lines.push(
`\t"author": { "name": ${jsonString(`TODO: replace with your name (${input.slug} author)`)} },`,
);
}
if (input.security) {
lines.push(`\t"security": ${renderSecurityContact(input.security)},`);
} else {
lines.push(
"\t// TODO: replace the placeholder with a real security contact email or url before publishing. The lexicon mandates at least one.",
);
lines.push('\t"security": { "email": "TODO@example.com" },');
}
if (input.description) {
lines.push(`\t"description": ${jsonString(input.description)},`);
}
if (input.repo) {
lines.push(`\t"repo": ${jsonString(input.repo)},`);
}
lines.push("");
lines.push("\t// Trust contract — what runtime APIs the plugin asks for.");
lines.push("\t// Empty arrays mean no extra privileges beyond logging,");
lines.push("\t// KV, and route/hook registration. Changing these between");
lines.push("\t// releases requires a version bump because installed");
lines.push("\t// users have consented to the old contract.");
lines.push('\t"capabilities": [],');
lines.push('\t"allowedHosts": [],');
lines.push('\t"storage": {}');
lines.push("}");
lines.push("");
return lines.join("\n");
}
/**
* Render a single author object as a JSONC inline value. Always
* single-line so the generated manifest stays compact.
*/
function renderAuthor(author: ManifestAuthor): string {
const parts: string[] = [`"name": ${jsonString(author.name)}`];
if (author.url) parts.push(`"url": ${jsonString(author.url)}`);
if (author.email) parts.push(`"email": ${jsonString(author.email)}`);
return `{ ${parts.join(", ")} }`;
}
/**
* Render a single security contact as a JSONC inline value.
*/
function renderSecurityContact(contact: ManifestSecurityContact): string {
const parts: string[] = [];
if (contact.email) parts.push(`"email": ${jsonString(contact.email)}`);
if (contact.url) parts.push(`"url": ${jsonString(contact.url)}`);
return `{ ${parts.join(", ")} }`;
}
/**
* `src/plugin.ts` — runtime code. One route, no hooks. Demonstrates the
* two primitives a sandboxed plugin author needs: the strict
* `SandboxedPlugin` type (which infers handler signatures per hook /
* route name) and a default-exported `{ hooks?, routes? }` object.
*/
export function renderPluginEntry(): string {
return `import type { SandboxedPlugin } from "emdash/plugin";
/**
* Sandboxed plugin entry. The default export is a bare object; the
* \`satisfies SandboxedPlugin\` annotation gives TypeScript per-hook /
* per-route inference (\`ctx\` is \`PluginContext\` automatically; hook
* \`event\` parameters are typed by hook name).
*/
export default {
\troutes: {
\t\thello: {
\t\t\thandler: async (_routeCtx, ctx) => {
\t\t\t\tctx.log.info("hello route called", { pluginId: ctx.plugin.id });
\t\t\t\treturn { greeting: "hello", pluginId: ctx.plugin.id };
\t\t\t},
\t\t},
\t},
} satisfies SandboxedPlugin;
`;
}
/**
* `package.json` — npm-shape so the plugin is `pnpm add`-able. The
* scaffold sets `private: true` defensively; flip it off when you're
* ready to publish to npm. `version` here is the single source of
* truth — the build reads it and writes it into the bundled manifest.
*
* `./sandbox` export points at the built runtime bytes that both
* in-process and isolate loaders consume. `main` / `import` point at
* the auto-generated descriptor module the integration imports for
* default in `astro.config.mjs`.
*/
export function renderPackageJson(input: ScaffoldInputs): string {
const pkg = {
name: input.slug,
version: "0.1.0",
private: true,
type: "module",
main: "dist/index.mjs",
exports: {
".": {
import: "./dist/index.mjs",
types: "./dist/index.d.mts",
},
"./sandbox": "./dist/plugin.mjs",
},
files: ["dist", "emdash-plugin.jsonc"],
scripts: {
build: "emdash-plugin build",
dev: "emdash-plugin dev",
typecheck: "tsc --noEmit",
test: "vitest run",
},
peerDependencies: {
emdash: ">=0.12.0",
},
devDependencies: {
"@emdash-cms/plugin-cli": ">=0.1.0",
emdash: ">=0.12.0",
typescript: "^5.9.0",
vitest: "^4.1.0",
},
};
return `${JSON.stringify(pkg, null, "\t")}\n`;
}
/**
* `tsconfig.json` — strict, ES2022, bundler resolution. Mirrors the
* `node22 + bundler` style the rest of the EmDash workspace uses, but
* doesn't extend anything from the workspace so the scaffold is
* self-contained.
*/
export function renderTsconfig(): string {
const config = {
compilerOptions: {
target: "ES2022",
module: "preserve",
moduleResolution: "bundler",
strict: true,
esModuleInterop: true,
verbatimModuleSyntax: true,
skipLibCheck: true,
types: [],
},
include: ["src/**/*", "tests/**/*"],
exclude: ["node_modules"],
};
return `${JSON.stringify(config, null, "\t")}\n`;
}
/**
* `.gitignore` — node_modules + dist (build output should not be
* committed; rebuild on every install).
*/
export function renderGitignore(): string {
return "node_modules/\ndist/\n";
}
/**
* `README.md` — three sections: develop, publish, version-bump rules.
* Nothing else. The author can extend; the scaffold doesn't pre-write
* marketing copy.
*/
export function renderReadme(input: ScaffoldInputs): string {
// The slug is the package title in headings + the import specifier,
// but it can contain hyphens (e.g. `my-plugin`) which aren't legal
// JS identifiers. Derive a camelCase binding name for the import +
// integration call.
const title = input.slug;
const importBinding = toCamelCase(input.slug);
return `# ${title}
A sandboxed plugin for [EmDash CMS](https://emdashcms.com).
## Develop
\`\`\`sh
pnpm install
pnpm typecheck
pnpm test
\`\`\`
To test against a running EmDash site, run \`pnpm dev\` in this
directory (rebuilds on save) and \`pnpm add file:../path/to/this\`
in the site. Then \`import ${importBinding} from "${input.slug}"\` and pass
it into \`emdash({ sandboxed: [${importBinding}] })\`.
## Publish
\`\`\`sh
emdash-plugin login # if you're not already logged in
emdash-plugin bundle # produces dist/${title}-<version>.tar.gz
# upload that tarball to a public URL, then:
emdash-plugin publish --url https://your-host/...
\`\`\`
## Version bumps
Bump \`version\` in \`package.json\` when you ship a release. The
scaffold's \`emdash-plugin.jsonc\` deliberately omits \`version\`
the build pipeline reads it from \`package.json\` so there's a single
source of truth. **Bump major** for breaking changes, **bump minor**
for new routes or hooks, **bump patch** for fixes.
You MUST bump version whenever you change \`capabilities\`, \`allowedHosts\`,
or \`storage\` in the manifest. Installed users have consented to the
old trust contract; a change without a version bump would let new
behaviour slip past consent.
`;
}
/**
* `tests/plugin.test.ts` — one passing test that exercises the
* hello route. Uses a minimal stubbed PluginContext rather than
* pulling in the runtime: the test asserts the handler returns the
* expected shape, not that the runtime wires it up correctly.
*/
export function renderTest(): string {
return `import { describe, expect, it } from "vitest";
import plugin from "../src/plugin.js";
describe("hello route", () => {
\tit("returns a greeting", async () => {
\t\tconst handler = plugin.routes?.hello;
\t\tif (!handler || typeof handler !== "object" || !("handler" in handler)) {
\t\t\tthrow new Error("hello route handler not found");
\t\t}
\t\tconst result = await handler.handler({} as never, makeTestContext());
\t\texpect(result).toEqual({ greeting: "hello", pluginId: "test-plugin" });
\t});
});
function makeTestContext() {
\t// Minimal stub PluginContext: the hello route only reads
\t// \`ctx.log.info\` and \`ctx.plugin.id\`. Real PluginContext has many
\t// more methods; add them as your plugin grows.
\treturn {
\t\tplugin: { id: "test-plugin", version: "0.1.0" },
\t\tlog: {
\t\t\tinfo: () => {},
\t\t\twarn: () => {},
\t\t\terror: () => {},
\t\t\tdebug: () => {},
\t\t},
\t} as unknown as import("emdash").PluginContext;
}
`;
}
/**
* JSON-stringify a string with double quotes and proper escaping.
* Trivially `JSON.stringify` does the job, but wrapping it gives us
* a single place to switch quote styles or escape behaviour later.
*/
function jsonString(value: string): string {
return JSON.stringify(value);
}
const SLUG_SEPARATOR_RE = /[-_]([a-z0-9])/g;
/**
* Convert a plugin slug (`my-plugin`, `my_plugin`) into a JS identifier
* for use as an import binding. Slugs are validated to start with a
* letter (see `PLUGIN_SLUG_RE`), so the result is always a legal
* identifier.
*/
function toCamelCase(slug: string): string {
return slug.replace(SLUG_SEPARATOR_RE, (_, ch: string) => ch.toUpperCase());
}