#!/usr/bin/env node /** * emit-chunks.mjs * * Parse a finished design.html (from build-design.mjs) and emit paste-ready * chunks under
docs blocks contain // literal references to these markers (e.g. `grep `) which // would false-positive a naive whole-file regex. Anchor every match to a //
opener — that's where the paste-ready blocks live.
const PRE_OPEN = `]*class=["']ds-code["'][^>]*>\\s*`;
// ─── 1. tokens.css ────────────────────────────────────────────────
const rootMatch = html.match(
new RegExp(`${PRE_OPEN}([\\s\\S]*?)`),
);
if (!rootMatch) {
console.error(
'✗ emit-chunks: missing ... block in design.html',
);
process.exit(1);
}
const tokensCss = htmlDecode(rootMatch[1]).trim();
fs.writeFileSync(path.join(chunksDir, "tokens.css"), tokensCss + "\n");
// ─── 2. easings.js ────────────────────────────────────────────────
const motionMatch = html.match(
new RegExp(`${PRE_OPEN}([\\s\\S]*?)`),
);
if (!motionMatch) {
console.error(
'✗ emit-chunks: missing ... block in design.html',
);
process.exit(1);
}
const easingsJs = htmlDecode(motionMatch[1]).trim();
fs.writeFileSync(path.join(chunksDir, "easings.js"), easingsJs + "\n");
// ─── 3. voice.md ──────────────────────────────────────────────────
// §5 ships a paste-ready register for Phase 4b workers writing on-screen copy
// (headline / chip / button text). Narrator scripts are TTS-bound and stay in
// Phase 2 — voice.md never enters that path.
const voiceMatch = html.match(
new RegExp(`${PRE_OPEN}([\\s\\S]*?)`),
);
if (!voiceMatch) {
console.error(
'✗ emit-chunks: missing ... block in design.html',
);
process.exit(1);
}
const voiceMd = htmlDecode(voiceMatch[1]).trim();
fs.writeFileSync(path.join(chunksDir, "voice.md"), voiceMd + "\n");
// ─── 3.5 composition-hints.md ─────────────────────────────────────
// §H ships scene-composition rules (surface contract, material avoidance, 60/30/10
// color placement). The plan agent reads this when picking
// components for a scene — without it, peoples-style hard rules (single
// triple-stamp per plate, cream-frame only on dark surfaces, …) wouldn't reach
// anyone. Presets without a §H emit a stub block so the file always exists and
// plan agent's must-read path is uniform across presets.
const hintsMatch = html.match(
new RegExp(`${PRE_OPEN}([\\s\\S]*?)`),
);
let hintsFile = null;
if (hintsMatch) {
const hintsMd = htmlDecode(hintsMatch[1]).trim();
fs.writeFileSync(path.join(chunksDir, "composition-hints.md"), hintsMd + "\n");
hintsFile = "chunks/composition-hints.md";
}
// ─── 3.6 type-roles.md ────────────────────────────────────────────
// §T type-role atlas (optional). Phase 4b scene worker reads on demand when
// text outside §6 components is needed — paste-ready markdown with per-role
// metadata, the §I CSS rule, and a sample snippet. Presets without §T → no
// file written, index.json's type_roles_file = null.
const typeRolesMatch = html.match(
new RegExp(`${PRE_OPEN}([\\s\\S]*?)`),
);
let typeRolesFile = null;
let typeRolesBytes = 0;
if (typeRolesMatch) {
const typeRolesMd = htmlDecode(typeRolesMatch[1]).trim();
fs.writeFileSync(path.join(chunksDir, "type-roles.md"), typeRolesMd + "\n");
typeRolesFile = "chunks/type-roles.md";
typeRolesBytes = Buffer.byteLength(typeRolesMd);
}
// ─── 4. components ────────────────────────────────────────────────
// Component blocks live inside ...
with HTML-entity-
// escaped markers (so design.html renders the markers as visible text for human
// readers). Match only when anchored to a ds-code opener to avoid the
// docs paragraph that explains the marker convention with a literal placeholder.
const compRe = new RegExp(
`${PRE_OPEN}<!--\\s*COMPONENT:\\s*([a-z0-9-]+)\\s*-->([\\s\\S]*?)<!--\\s*\\/COMPONENT\\s*-->`,
"g",
);
// designhtml-class presets (peoples-platform, …) emit a COMPONENT-META line right
// after the COMPONENT marker, carrying frontmatter fields (surface / composes /
// role / avoids_same_scene / slots, …). Pluck it out before the body is treated
// as raw HTML, then spread the parsed JSON into the index.json entry so the plan
// agent can filter components by surface/role without opening each .html file.
// Legacy presets without frontmatter skip emission → meta stays null → entry
// stays {id, file} as before.
const componentMetaRe = /^\s*\s*\r?\n?/;
const components = [];
let cm;
while ((cm = compRe.exec(html)) !== null) {
const id = cm[1];
let body = htmlDecode(cm[2]);
let meta = null;
const metaMatch = body.match(componentMetaRe);
if (metaMatch) {
try {
meta = JSON.parse(metaMatch[1]);
} catch (e) {
console.error(
`! emit-chunks: component '${id}' has COMPONENT-META but JSON is invalid (${e.message}); ignoring`,
);
}
body = body.slice(metaMatch[0].length);
}
body = stripCodeFence(body).trim();
fs.writeFileSync(path.join(componentsDir, `${id}.html`), body + "\n");
components.push({
id,
file: `chunks/components/${id}.html`,
size: Buffer.byteLength(body),
meta,
});
}
if (components.length === 0) {
console.error("✗ emit-chunks: no COMPONENT blocks found — design.html may be malformed or empty");
process.exit(1);
}
// ─── 5. index.json (manifest) ─────────────────────────────────────
// Parse the AGENT NOTE comment for preset / source URL so downstream phases
// can route on preset without re-parsing the HTML themselves.
let preset = null;
let source_url = null;
const agentNote = html.match(//);
if (agentNote) {
const note = agentNote[0];
const ps = note.match(/Style preset:\s*([^\n(]+?)\s*(?:\([^)]+\))?\s*$/m);
const su = note.match(/Brand DNA from:\s*(\S+)/);
if (ps) preset = ps[1].trim();
if (su) source_url = su[1].trim();
}
// ─── 4.6 caption-skin.html (optional preset-local caption skin) ───
// A preset MAY ship its own pre-baked, brand-tokenized caption skin at
// style-presets//caption-skin.html. When present, copy it into chunks/ so
// build-captions-html.mjs can use it as the project's caption SOURCE (a second source
// alongside the registry caption-* skins). Absent → registry skins, exactly as before.
let captionSkinFile = null;
if (preset) {
const skinSrc = path.resolve(__dirname, "..", "style-presets", preset, "caption-skin.html");
if (fs.existsSync(skinSrc)) {
fs.copyFileSync(skinSrc, path.join(chunksDir, "caption-skin.html"));
captionSkinFile = "chunks/caption-skin.html";
}
}
const index = {
generated_at: new Date().toISOString(),
source_url,
preset,
tokens_file: "chunks/tokens.css",
easings_file: "chunks/easings.js",
voice_file: "chunks/voice.md",
// hints_file is null when the preset doesn't declare §H. Plan agent treats null
// as "no preset-level composition contract — pick by component id only".
hints_file: hintsFile,
// type_roles_file is null when preset declares no §T. Worker reads on demand
// (paths flow through prep.mjs → dispatch).
type_roles_file: typeRolesFile,
// caption_skin_file is null unless the preset ships style-presets//caption-skin.html.
// When set, build-captions-html.mjs uses it as the caption source (preferred over registry
// caption-* skins); null → registry skin scoring, as before.
caption_skin_file: captionSkinFile,
components: components.map(({ id, file, meta }) =>
// Spread frontmatter (surface / composes / role / avoids_same_scene / slots)
// alongside id+file. Plan agent reads these without opening component .html.
meta ? { id, file, ...meta } : { id, file },
),
};
fs.writeFileSync(path.join(chunksDir, "index.json"), JSON.stringify(index, null, 2) + "\n");
// ─── 6. report ────────────────────────────────────────────────────
const fmt = (b) => (b / 1024).toFixed(1);
const tokenBytes = Buffer.byteLength(tokensCss);
const easingBytes = Buffer.byteLength(easingsJs);
const voiceBytes = Buffer.byteLength(voiceMd);
const hintsBytes = hintsFile ? Buffer.byteLength(htmlDecode(hintsMatch[1]).trim()) : 0;
const compBytes = components.reduce((sum, c) => sum + c.size, 0);
const designBytes = Buffer.byteLength(html);
const chunksBytes = tokenBytes + easingBytes + voiceBytes + hintsBytes + typeRolesBytes + compBytes;
console.log(`✓ ${path.relative(process.cwd(), chunksDir)}/`);
console.log(` tokens.css ${fmt(tokenBytes)} KB`);
console.log(` easings.js ${fmt(easingBytes)} KB`);
console.log(` voice.md ${fmt(voiceBytes)} KB`);
if (hintsFile) console.log(` composition-hints.md ${fmt(hintsBytes)} KB`);
if (typeRolesFile) console.log(` type-roles.md ${fmt(typeRolesBytes)} KB`);
if (captionSkinFile) console.log(` caption-skin.html (preset-local caption source)`);
console.log(` components/ ${components.length} files`);
for (const c of components) {
console.log(` ${c.id}.html (${fmt(c.size)} KB)`);
}
console.log(` index.json lists ${components.length} components (preset=${preset || "?"})`);
console.log(
` totals chunks ${fmt(chunksBytes)} KB vs design.html ${fmt(designBytes)} KB (~${Math.round((chunksBytes / designBytes) * 100)}% of source)`,
);