commit a4f43d526fb08ce91aa8d3489dee77016960aa4d Author: Paul B. Hartzog Date: Wed Sep 9 13:09:19 2026 -0400 init diff --git a/app.html b/app.html new file mode 100644 index 0000000..74fc20f --- /dev/null +++ b/app.html @@ -0,0 +1,343 @@ + + + + + +__TITLE__ + + + + + + + +
+ + + + diff --git a/cloudflare/gitignore b/cloudflare/gitignore new file mode 100644 index 0000000..e8a7406 --- /dev/null +++ b/cloudflare/gitignore @@ -0,0 +1,3 @@ +_manifest.js +node_modules/ +.wrangler/ diff --git a/cloudflare/package.json b/cloudflare/package.json new file mode 100644 index 0000000..52d9a15 --- /dev/null +++ b/cloudflare/package.json @@ -0,0 +1,11 @@ +{ + "name": "{{NAME}}", + "private": true, + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy" + }, + "devDependencies": { + "wrangler": "^4" + } +} diff --git a/cloudflare/worker.js b/cloudflare/worker.js new file mode 100644 index 0000000..b1adf1e --- /dev/null +++ b/cloudflare/worker.js @@ -0,0 +1,6 @@ +import { CARDS, TAGS } from "./_manifest.js"; +import { handler } from "../engine/serve.js"; + +export default { + fetch: (request, env) => handler(CARDS, TAGS, env.TITLE) +}; diff --git a/cloudflare/wrangler.toml b/cloudflare/wrangler.toml new file mode 100644 index 0000000..641cdca --- /dev/null +++ b/cloudflare/wrangler.toml @@ -0,0 +1,14 @@ +name = "{{NAME}}" +main = "worker.js" +compatibility_date = "2025-06-01" + +[vars] +TITLE = "{{TITLE}}" + +[build] +command = "node ../engine/gen.mjs --src content --target _manifest.js" +watch_dir = "content" + +[[rules]] +type = "Text" +globs = ["**/*.html", "**/*.md"] diff --git a/emit.mjs b/emit.mjs new file mode 100644 index 0000000..552d3d8 --- /dev/null +++ b/emit.mjs @@ -0,0 +1,53 @@ +// emit.mjs — project the engine's deploy adapter into a target dir. +// +// node engine/emit.mjs +// e.g. node engine/emit.mjs cloudflare site-instruments +// +// Rule (uniform for every generated file): READ the target for its per-target +// values, then REBUILD all adapter files from the plugin templates carrying +// those values. Structural template changes propagate to every target on the +// next run; hand-added values (name/TITLE) survive because they're read back +// out of the target. content/ is AUTHORED — never generated, never touched. + +import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { execFileSync } from "node:child_process"; + +const ENGINE = dirname(fileURLToPath(import.meta.url)); // .../engine +const [target, dir] = process.argv.slice(2); +if (!target || !dir) { + console.error("usage: node engine/emit.mjs "); + process.exit(1); +} + +const plugin = join(ENGINE, target); +if (!existsSync(plugin)) { + console.error(`no deploy plugin '${target}' (expected ${plugin}/)`); + process.exit(1); +} + +// --- read per-target values out of the existing target (preserve across rebuild) --- +const tomlPath = join(dir, "wrangler.toml"); +const prior = existsSync(tomlPath) ? readFileSync(tomlPath, "utf8") : ""; +const NAME = (prior.match(/^name\s*=\s*"([^"]*)"/m) || [])[1] || "{{NAME}}"; +const TITLE = (prior.match(/TITLE\s*=\s*"([^"]*)"/m) || [])[1] || "{{TITLE}}"; + +// --- regenerate every adapter file from templates, injecting preserved values --- +mkdirSync(join(dir, "content"), { recursive: true }); // ensure authored dir exists (untouched if present) +const fill = s => s.replaceAll("{{NAME}}", NAME).replaceAll("{{TITLE}}", TITLE); +for (const f of readdirSync(plugin)) { + const out = f === "gitignore" ? ".gitignore" : f; // stored dotless in the plugin; dotted in the target + writeFileSync(join(dir, out), fill(readFileSync(join(plugin, f), "utf8"))); +} + +// --- manifest from authored content (gen owns _manifest.js; content is read-only to us) --- +execFileSync("node", [join(ENGINE, "gen.mjs"), "--src", "content", "--target", "_manifest.js"], + { cwd: dir, stdio: "inherit" }); + +// --- first-scaffold notice: values with no prior source stay as placeholders --- +const todo = []; +if (NAME === "{{NAME}}") todo.push("name"); +if (TITLE === "{{TITLE}}") todo.push("TITLE"); +console.log(`emit: ${target} → ${dir}/ (adapter files regenerated)`); +if (todo.length) console.log(` fill in ${todo.join(" and ")} in ${dir}/wrangler.toml before deploy`); diff --git a/gen.mjs b/gen.mjs new file mode 100644 index 0000000..b37895f --- /dev/null +++ b/gen.mjs @@ -0,0 +1,77 @@ +import { readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { relative, dirname, join, sep } from "node:path"; +import { randomBytes } from "node:crypto"; + +const arg = (k, d) => { const i = process.argv.indexOf(k); return i > -1 ? process.argv[i + 1] : d; }; +const SRC = arg("--src", "content"); +const TARGET = arg("--target", "_manifest.js"); + +// --- uuid v7: 48-bit ms timestamp (time-ordered) + version/variant + random --- +function uuidv7(){ + const ts = Date.now(); + const b = randomBytes(16); + b[0]=(ts/2**40)&0xff; b[1]=(ts/2**32)&0xff; b[2]=(ts/2**24)&0xff; + b[3]=(ts/2**16)&0xff; b[4]=(ts/2**8)&0xff; b[5]=ts&0xff; + b[6]=(b[6]&0x0f)|0x70; // version 7 + b[8]=(b[8]&0x3f)|0x80; // variant 10 + const h = b.toString("hex"); + return `${h.slice(0,8)}-${h.slice(8,12)}-${h.slice(12,16)}-${h.slice(16,20)}-${h.slice(20)}`; +} +const ID_RE = //i; +const idLine = id => ``; + +const files = readdirSync(SRC).filter(f => f.endsWith(".md")).sort(); + +// --- discovery: every card gets a stable v7 id. mint if absent (incl. user-removed), +// remint on collision (bootstrap audit). writes the id into the card at EOF. --- +const seen = new Set(); +const idOf = {}; +let minted = 0, reminted = 0; +for (const f of files) { + const p = join(SRC, f); + let md = readFileSync(p, "utf8"); + const m = md.match(ID_RE); + let id = m ? m[1].toLowerCase() : null; + if (!id) { // absent → mint, append at bottom + id = uuidv7(); + md = md.replace(/\s*$/, "") + `\n\n${idLine(id)}\n`; + writeFileSync(p, md); minted++; + } else if (seen.has(id)) { // collision → remint in place + id = uuidv7(); + md = md.replace(ID_RE, idLine(id)); + writeFileSync(p, md); reminted++; + } + seen.add(id); + idOf[f] = id; +} + +// --- manifest --- +const parseTags = md => ((md.match(/^---\n([\s\S]*?)\n---/)?.[1] + .match(/tags:\s*\[([^\]]*)\]/)?.[1]) || "") + .split(",").map(s => s.trim()).filter(Boolean); + +const allTags = new Set(); +for (const f of files) parseTags(readFileSync(join(SRC, f), "utf8")).forEach(t => allTags.add(t)); + +const spec = f => { + let r = relative(dirname(TARGET) || ".", join(SRC, f)).split(sep).join("/"); + return r.startsWith(".") ? r : "./" + r; +}; + +const imports = files.map((f, i) => `import c${i} from ${JSON.stringify(spec(f))};`).join("\n"); +const items = files.map((f, i) => + ` { slug: ${JSON.stringify(f.replace(/\.md$/,""))}, id: ${JSON.stringify(idOf[f])}, md: c${i} }`).join(",\n"); +const tags = [...allTags].sort(); + +writeFileSync(TARGET, +`// AUTO-GENERATED by gen.mjs — do not edit +${imports} + +export const CARDS = [ +${items} +]; + +// tag vocabulary, derived at build (not boot) +export const TAGS = ${JSON.stringify(tags)}; +`); +console.log(`gen: ${files.length} card(s), ${tags.length} tag(s), +${minted} id(s), ${reminted} reminted -> ${TARGET}`); diff --git a/serve.js b/serve.js new file mode 100644 index 0000000..b6a9421 --- /dev/null +++ b/serve.js @@ -0,0 +1,11 @@ +import PAGE from "./app.html"; + +export function handler(CARDS, TAGS, TITLE) { + const inject = "window.__CARDS__ = " + JSON.stringify(CARDS) + ";\n" + + "window.__TAGS__ = " + JSON.stringify(TAGS) + ";"; + const html = PAGE + .replace("__TITLE__", () => TITLE) // fn-form: no $-pattern interpretation + .replace("/*__DATA__*/", () => inject); + return new Response(html, + { headers: { "content-type": "text/html; charset=utf-8" } }); +}