This commit is contained in:
Paul B. Hartzog
2026-09-09 13:09:19 -04:00
commit a4f43d526f
8 changed files with 518 additions and 0 deletions
+343
View File
@@ -0,0 +1,343 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>__TITLE__</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600&family=Inter:wght@400;500&display=swap" rel="stylesheet">
<style>
:root{
/* ===== THE DETERMINER =====
--font-floor is the comfortable-readable body atom. Everything derives UP from it.
floor 15px = comfortable-readable minimum (physiological)
ceiling 21px = anti-absurd on huge screens (convention)
viewport term = growth rate (convention) — ~16-17px around a 1440px viewport
--measure 62ch = optimal line length (physiological ~60ch) */
--font-floor: clamp(15px, 0.9rem + 0.35vw, 21px);
--measure: 62ch;
/* derived UP from the determiner (ratios, not magic numbers) */
--f-body: var(--font-floor);
--f-head: calc(var(--font-floor) * 1.7);
--f-eye: calc(var(--font-floor) * 0.72);
--card-min: 22rem; /* ~ a readable measure at floor size; derived target */
--bg:#ffffff; --ink:#2b2b3a; --muted:#6b6b7b; --line:#e3e3ec;
--bar-h:52px; --radius:20px; --gap:14px; --fade:.28s;
}
*{ box-sizing:border-box; }
html,body{ margin:0; height:100%; background:var(--bg); color:var(--ink);
font-family:"Inter",system-ui,sans-serif; font-size:var(--f-body);
-webkit-font-smoothing:antialiased; }
#bar{ position:sticky; top:0; height:var(--bar-h); display:flex; align-items:center; gap:8px;
padding:0 16px; background:rgba(255,255,255,.85); backdrop-filter:blur(8px);
border-bottom:1px solid var(--line); z-index:100; }
.navtag{ font:500 15px/1 "Inter",sans-serif; color:var(--muted); background:none;
border:1px solid var(--line); border-radius:999px; padding:8px 16px; cursor:pointer;
transition:color .2s, border-color .2s, background .2s; }
.navtag:hover{ color:var(--ink); border-color:var(--ink); }
.navtag.active{ color:var(--bg); background:var(--ink); border-color:var(--ink); }
/* ===== n×n GRID: wraps, mobile-safe, no media query =====
minmax(min(--card-min,100%),1fr): as many columns as fit at >=card-min,
but never wider than the container (so phones become one full-width column). */
#grid{ display:grid; gap:var(--gap); padding:var(--gap);
grid-template-columns:repeat(auto-fill, minmax(min(var(--card-min), 100%), 1fr));
align-content:start; }
.card{ position:relative; border:1px solid var(--line); border-radius:var(--radius);
padding:26px 24px; min-height:24vh; background:var(--bg); cursor:pointer; overflow:hidden;
display:flex; flex-direction:column; justify-content:flex-start;
box-shadow:0 1px 2px rgba(43,43,58,.04);
transition:box-shadow .2s ease, transform .12s ease; } /* opacity owned by WAAPI, not CSS */
.card:hover{ box-shadow:0 6px 22px rgba(43,43,58,.08); }
.card:active{ transform:scale(.995); }
.card{ --accent:var(--ink); }
.card::before{ content:""; position:absolute; left:0; top:0; bottom:0; width:4px; background:var(--accent); }
.card h2{ margin:0 0 6px; font-family:"Fraunces",Georgia,serif; font-weight:600;
font-size:var(--f-head); line-height:1.05; letter-spacing:-.01em; color:var(--ink); overflow-wrap:anywhere; }
.card .k{ display:block; font-size:var(--f-eye); letter-spacing:.14em; text-transform:uppercase;
color:var(--accent); font-weight:500; }
.card p{ margin:10px 0 0; color:var(--muted); font-size:var(--f-body); line-height:1.5; max-width:var(--measure); }
.card pre{ margin:12px 0 0; padding:12px 14px; background:#f5f5f9; border:1px solid var(--line);
border-radius:10px; overflow:auto; font:0.85em/1.5 ui-monospace,"SF Mono",Menlo,monospace; color:var(--ink); }
.card blockquote{ margin:12px 0 0; padding:2px 0 2px 14px; border-left:3px solid var(--accent);
color:var(--ink); font-style:italic; font-size:var(--f-body); }
.card .invite{ margin:14px 0 0; align-self:flex-start; padding:7px 14px; border-radius:999px;
border:1px solid var(--accent); color:var(--accent); font-size:0.9em; font-weight:500; }
.card .tags{ margin-top:auto; padding-top:16px; display:flex; flex-wrap:wrap; gap:6px; }
.card .tag{ font:500 0.72rem/1 "Inter",sans-serif; color:var(--muted); cursor:pointer;
border:1px solid var(--line); border-radius:999px; padding:4px 10px; transition:color .2s, border-color .2s; }
.card .tag:hover{ color:var(--ink); border-color:var(--ink); }
/* tags view = ONE virtual card holding a weighted cloud (normalized band sizing).
tag look (Fraunces + accent) carried from the old tag render for consistency. */
.card.cloud{ cursor:default; display:block; }
.card.cloud .cloudtag{ display:inline-block; margin:0 .55em .35em 0; line-height:1.1;
font-family:"Fraunces",Georgia,serif; color:var(--accent); cursor:pointer;
transition:opacity .2s; }
.card.cloud .cloudtag:hover{ opacity:.55; }
.placeholder{ visibility:hidden; }
/* the ?card render: one card, inset by the same --gap gutter as the grid,
fills the remaining width, content-height, scrolls past the viewport.
ONLY functional overrides here — corners/border/shadow/padding inherit from .card. */
.card.solo{ position:fixed; z-index:50;
top:calc(var(--bar-h) + var(--gap)); left:var(--gap); right:var(--gap); width:auto;
height:auto; min-height:0; max-height:calc(100vh - var(--bar-h) - 2*var(--gap));
overflow:auto; }
@media (prefers-reduced-motion:reduce){ .navtag{ transition:none; } }
</style>
</head>
<body>
<nav id="bar"></nav>
<main id="grid"></main>
<script>
/*__DATA__*/ /* worker injects window.__CARDS__ [{slug,md}] + window.__TAGS__ [str] */
/* ---------- parser ---------- */
function parse(md){
const idm = md.match(/<!--\s*id:\s*([0-9a-f-]{36})\s*-->/i); // minted by gen.mjs
const id = idm ? idm[1].toLowerCase() : "";
md = md.replace(/<!--\s*id:\s*[0-9a-f-]{36}\s*-->/i, ""); // strip so it never renders
const fm = md.match(/^---\n([\s\S]*?)\n---\n?/);
const front = fm ? fm[1] : "";
const body = fm ? md.slice(fm[0].length) : md;
const tags = (front.match(/tags:\s*\[([^\]]*)\]/)?.[1] || "")
.split(",").map(s=>s.trim()).filter(Boolean);
const L = body.split("\n"); const blocks = [];
for(let i=0;i<L.length;i++){
const l=L[i]; if(!l.trim()) continue;
if(/^\^\s+/.test(l)){ blocks.push({type:"eyebrow",text:l.replace(/^\^\s+/,"").trim()}); continue; }
if(/^#\s+/.test(l)) { blocks.push({type:"heading",text:l.replace(/^#\s+/,"").trim()}); continue; }
if(/^```/.test(l)) { const b=[]; i++; while(i<L.length&&!/^```/.test(L[i])){b.push(L[i]);i++;}
blocks.push({type:"code",text:b.join("\n")}); continue; }
if(/^>\s?/.test(l)) { const b=[l.replace(/^>\s?/,"")];
while(i+1<L.length&&/^>\s?/.test(L[i+1])){i++;b.push(L[i].replace(/^>\s?/,""));}
blocks.push({type:"quote",text:b.join(" ")}); continue; }
if(/^=>\s+/.test(l)){ blocks.push({type:"invite",text:l.replace(/^=>\s+/,"").trim()}); continue; }
const b=[l.trim()];
while(i+1<L.length&&L[i+1].trim()&&!/^(\^|#|```|>|=>)/.test(L[i+1])){i++;b.push(L[i].trim());}
blocks.push({type:"prose",text:b.join(" ")});
}
const pick=t=>blocks.find(b=>b.type===t)?.text||"";
return { id, tags, eyebrow:pick("eyebrow"), heading:pick("heading"), blocks };
}
const hue = t => { let h=0; for(const c of t) h=(h*31+c.charCodeAt(0))>>>0; return h%360; };
const accentOf = tag => `hsl(${hue(tag)} 52% 42%)`;
function renderBlock(b){
let el;
if(b.type==="prose"){ el=document.createElement("p"); }
else if(b.type==="code"){ el=document.createElement("pre"); const c=document.createElement("code");
c.textContent=b.text; el.append(c); return el; }
else if(b.type==="quote"){ el=document.createElement("blockquote"); }
else if(b.type==="invite"){ el=document.createElement("div"); el.className="invite"; }
else { el=document.createElement("div"); }
el.textContent=b.text;
return el;
}
function renderCard(c){
const primary = c.tags[0] || "";
const el = document.createElement("article");
el.className = "card"; el.dataset.k = primary; el.dataset.slug = c.slug || ""; el.dataset.id = c.id || "";
el.style.setProperty("--accent", accentOf(primary));
const eb=document.createElement("span"); eb.className="k"; eb.textContent=c.eyebrow;
const h =document.createElement("h2"); h.textContent=c.heading;
el.append(eb,h);
for(const b of c.blocks){
if(b.type==="eyebrow"||b.type==="heading") continue;
el.append(renderBlock(b));
}
if(c.tags.length){
const row=document.createElement("div"); row.className="tags";
for(const t of c.tags){
const s=document.createElement("span"); s.className="tag"; s.textContent=t;
s.addEventListener("click",e=>{ e.stopPropagation(); setState({view:'surface', tag:t, card:null}); });
row.append(s);
}
el.append(row);
}
return el;
}
/* the tags view: one virtual card holding a weighted cloud.
weight = card-count per tag (free from ALL). normalized band → size, derived on --font-floor. */
function renderTagCloud(){
const count = new Map();
for(const e of ALL) for(const t of e.tags) count.set(t, (count.get(t)||0)+1);
const counts = TAGS.map(t => count.get(t)||0);
const lo = Math.min(...counts), hi = Math.max(...counts);
const MIN = 1.0, MAX = 2.2; // font-floor multiples: least→most frequent
const mul = c => hi===lo ? (MIN+MAX)/2 : MIN + (MAX-MIN)*(c-lo)/(hi-lo);
const el = document.createElement("article");
el.className = "card cloud"; el.dataset.slug = "cloud:tags"; // non-navigable (see click guard)
for(const t of TAGS){
const s = document.createElement("span");
s.className = "cloudtag"; s.textContent = t;
s.style.fontSize = `calc(var(--font-floor) * ${mul(count.get(t)||0).toFixed(3)})`;
s.style.setProperty("--accent", accentOf(t));
s.addEventListener("click", e => { e.stopPropagation(); setState({view:'surface', tag:t, card:null}); });
el.append(s);
}
return el;
}
/* ---------- source boundary: bundled CARDS + TAGS (injected) ---------- */
const RAW = (window.__CARDS__ || []);
const TAGS = (window.__TAGS__ || []);
const RAWMAP = new Map(RAW.map(c=>[c.slug, c.md]));
const BYID = new Map(RAW.map(c=>[c.id, c])); // id → raw card (identity lookup for ?card=)
const CACHE = new Map();
function cardFor(slug){
if(CACHE.has(slug)) return CACHE.get(slug);
const c = parse(RAWMAP.get(slug)); c.slug = slug; CACHE.set(slug, c); return c;
}
const ALL = RAW.map(c=>({ slug:c.slug, id:c.id, tags:parse(c.md).tags })); // index, file-order (name-sorted)
const grid = document.getElementById("grid");
const bar = document.getElementById("bar");
/* ---------- toolbar: Cards + Tags (intrinsic views) ---------- */
const NAV = [
{ label:"Cards", act:()=> setState({view:'cards', tag:null, card:null}) },
{ label:"Tags", act:()=> setState({view:'tags', tag:null, card:null}) },
];
const navEls = NAV.map(n=>{ const b=document.createElement("button"); b.className="navtag";
b.textContent=n.label; b.addEventListener("click", n.act); bar.append(b); return b; });
/* ================= THE AXIOM =================
ONE state. setState → 'state' event → render(S). Activators only setState.
URL is a peer expression of state (push only) and a peer activator (popstate). */
let S = { view:'cards', tag:null, card:null }; // one query, always. no 'open'. 'card' holds a v7 id (identity); name gives order
/* state <-> URL (push only; NEVER replaceState — browser history is the user's) */
/* URL is query-shaped: every state IS a query, flat, no hierarchy.
all cards → ?cards ( bare / normalizes here on boot — educational: home is a query too )
tags → ?tags
surface → ?tag=NAME
card → ?card=ID
No 'open': a single card is just ?card=ID. Back button walks the query path. */
function stateToURL(S){
if(S.view==='tags') return '?tags';
if(S.view==='surface' && S.tag) return '?tag=' + encodeURIComponent(S.tag);
if(S.view==='card' && S.card) return '?card=' + encodeURIComponent(S.card);
return '?cards'; // canonical all-cards ( / normalizes here on boot )
}
function urlToState(){
const p = new URLSearchParams(location.search);
if(p.has('tags')) return { view:'tags', tag:null, card:null };
if(p.has('tag')) return { view:'surface', tag:p.get('tag'), card:null };
if(p.has('card')) return { view:'card', tag:null, card:p.get('card') };
return { view:'cards', tag:null, card:null }; // ?cards or bare / → all cards
}
const bus = new EventTarget();
let queue = Promise.resolve();
function setState(patch){
S = { ...S, ...patch };
history.pushState(S, '', stateToURL(S)); // PUSH only — appends, never replaces
queue = queue.then(()=> render(S));
}
/* ===== TRANSITIONS: Web Animations API. Completion = Animation.finished
(promise-based, event-driven, guaranteed to SETTLE — resolves on real
completion, rejects on cancel). No transitionend (conditional-firing),
no timers (trust assumptions). A transition is a named render; swap the
keyframes and nothing else changes. t=0 / reduced-motion = instant. ===== */
const DUR = 750; // one duration source (ms) — app-wide floor
const reduce = matchMedia('(prefers-reduced-motion:reduce)').matches;
// run an animation, resolve when it FINISHES; a cancelled anim (element
// pulled mid-flight) rejects → we swallow it and treat as done. Never hangs.
const play = (el, frames, opts={}) => {
if(reduce || !el.animate) return Promise.resolve(); // instant path
const a = el.animate(frames, { duration: DUR, easing:'ease', fill:'both', ...opts });
return a.finished.then(()=>a.cancel(), ()=>{}); // commit then clear; ignore cancel-reject
};
const fadeOut = els => Promise.all(els.map(el =>
play(el, [{opacity:1},{opacity:0}]))); // each card fades; all .finished
const fadeIn = els => Promise.all(els.map(el =>
play(el, [{opacity:0},{opacity:1}])));
/* the group for a state = a query result */
function groupFor(S){
if(S.view==='cards') return ALL.map(e=>e.slug).map(cardFor).map(renderCard);
if(S.view==='tags') return [renderTagCloud()];
if(S.view==='card') { const c = BYID.get(S.card); return c ? [cardFor(c.slug)].map(renderCard) : []; }
// surface: cards carrying the tag
return ALL.filter(e=>e.tags.includes(S.tag)).map(e=>e.slug).map(cardFor).map(renderCard);
}
/* grow: animate the BOX geometry from slot rect (first) to full rect (last).
geometry-space, not transform — content reflows to fill at normal size, never scales.
first keyframe = slot with fill:'both', so the full-size state never paints (no flash).
.solo rests at full w/h, so the cancel-on-finished holds the end with no jump. */
function grow(el, first){
el.classList.add('solo');
const last = el.getBoundingClientRect(); // full rect (from .solo)
const box = r => ({ top:r.top+'px', left:r.left+'px', width:r.width+'px', height:r.height+'px' });
return play(el, [ box(first), box(last) ]);
}
async function swap(S){
const cur = [...grid.querySelectorAll('.card')];
const target = (S.view==='card') ? cur.find(c => c.dataset.id === S.card) : null;
if(target){ // a visible card was clicked → grow sequence
const first = target.getBoundingClientRect(); // slot rect, up front
const others = cur.filter(c => c !== target);
await fadeOut(others); // PHASE 1: the way clears — .finished = broadcast "cleared"
others.forEach(c => c.remove());
navEls.forEach(n => n.classList.remove('active'));
await grow(target, first); // PHASE 2: target heard "cleared", grows — .finished = broadcast "filled"
return;
}
// generic transition: old fades out → new group → new fades in
await fadeOut(cur);
const els = groupFor(S);
grid.replaceChildren(...els);
navEls[0].classList.toggle('active', S.view==='cards');
navEls[1].classList.toggle('active', S.view==='tags');
if(S.view==='card') els.forEach(el => el.classList.add('solo')); // direct ?card= (no source card): appear solo, no grow
await fadeIn(els);
}
/* ---------- the ONE response: DOM := f(state) ---------- */
function key(S){ return S.view+'|'+(S.tag||'')+'|'+(S.card||''); } // what defines "which group"
async function render(S){
if(key(S) !== grid.dataset.key){
grid.dataset.key = key(S);
await swap(S);
}
}
/* ---------- activators ---------- */
grid.addEventListener('click', e => {
const card = e.target.closest('.card'); if(!card || card.classList.contains('cloud')) return;
setState({ view:'card', card: card.dataset.id, tag:null }); // click a card = navigate to ?card=<id>
});
window.addEventListener('popstate', ()=>{ // back/forward = peer activator (no push)
S = urlToState(); queue = queue.then(()=> render(S));
});
function boot(){
S = urlToState();
history.replaceState(S, '', stateToURL(S)); // seed initial entry only (the one allowed replace: first load has no prior)
queue = queue.then(()=> render(S));
}
boot();
</script>
</body>
</html>
+3
View File
@@ -0,0 +1,3 @@
_manifest.js
node_modules/
.wrangler/
+11
View File
@@ -0,0 +1,11 @@
{
"name": "{{NAME}}",
"private": true,
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy"
},
"devDependencies": {
"wrangler": "^4"
}
}
+6
View File
@@ -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)
};
+14
View File
@@ -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"]
+53
View File
@@ -0,0 +1,53 @@
// emit.mjs — project the engine's deploy adapter into a target dir.
//
// node engine/emit.mjs <target> <dir>
// 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 <target> <dir>");
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`);
+77
View File
@@ -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 = /<!--\s*id:\s*([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\s*-->/i;
const idLine = id => `<!-- id: ${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}`);
+11
View File
@@ -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" } });
}