Docs/Skills and code/skill deploy-docs
.claude/skills/deploy-docs/scripts/build-source.js
What the file says about itself
Builds the "skills and code" part of the docs site from the repository itself. node build-source.js write docs/source/** and docs/15-skills-and-code.html, update the side navigation of every docs page, link tool and file names in the docs…
Mentioned on: page 12
#!/usr/bin/env node
// Builds the "skills and code" part of the docs site from the repository itself.
// node build-source.js write docs/source/** and docs/15-skills-and-code.html, update the side navigation of every docs page,
// link tool and file names in the docs pages to their source pages, then check every link it made
// node build-source.js --report same, and print the full list of files, links and exclusions
// What it publishes (an allowlist, never a blind copy): .claude/skills/** (not examples/ except README.md), .claude/agents/*.md, src/**, scripts/**, tests/**,
// the top-level js/mjs/md files and package.json, docs/SCRIPT-PIPELINE.md, CLAUDE.md, and three global skills the pipeline uses
// (groq-transcribe, script-audio-align, google-ai-mode) read from ~/.claude/skills.
// What it never publishes: research/, data/, reports/, transcripts/, seeds/, node_modules/, lock files, .env files, anything over 400 KB, any file
// with a key-like string in it (reported, not published). The home directory and the owner's email are replaced by <home> and <email> in the pages.
// Each file is shown as it is in the repository: markdown rendered (with the raw text under a toggle), code as text. If a page and its file differ, the file is right.
const fs = require('fs');
const os = require('os');
const path = require('path');
const ROOT = path.resolve(__dirname, '..', '..', '..', '..');
const DOCS = path.join(ROOT, 'docs');
const OUT = path.join(DOCS, 'source');
const HOME = os.homedir();
const MAXB = 400 * 1024;
const WRITTEN = '2026-11-03';
const REPORT = process.argv.includes('--report');
const PRIORITY = 'video-script-production';
const CATALOGUE = '15-skills-and-code.html';
const TEXT = /\.(js|mjs|cjs|json|md|py|css|txt)$/i;
const SKIP_DIR = new Set(['node_modules', '.git', 'data', 'reports', 'transcripts', 'research', 'ecosystem-ratings', 'seeds', 'series', '.wrangler', '__pycache__', 'venv', '.venv']);
const GLOBAL_SKILLS = ['groq-transcribe', 'script-audio-align', 'google-ai-mode'];
const SECRET = [/AIza[0-9A-Za-z_-]{20,}/, /sk-[A-Za-z0-9]{20,}/, /gsk_[A-Za-z0-9]{20,}/, /CLOUDFLARE_API_TOKEN\s*=\s*\S+/, /(?:api[_-]?key|secret|token|password)\s*[:=]\s*['"][A-Za-z0-9_\-]{20,}['"]/i, /-----BEGIN [A-Z ]*PRIVATE KEY-----/];
const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
// The user name and the git e-mail are read at run time, never written in this file, because this file is published too.
const reEsc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const USER = os.userInfo().username;
const MAIL = (require('child_process').spawnSync('git', ['config', 'user.email'], { cwd: ROOT, encoding: 'utf8' }).stdout || '').trim();
const RE_HOME = new RegExp('(?:[A-Za-z]:)?[\\\\/]+(?:c[\\\\/]+)?Users[\\\\/]+' + reEsc(USER), 'gi');
const RE_PROJ = new RegExp('C--Users-' + reEsc(USER) + '-', 'g');
const RE_MAIL = MAIL ? new RegExp(reEsc(MAIL), 'gi') : null;
const redact = (t) => { t = t.replace(RE_HOME, '<home>').replace(RE_PROJ, 'C--Users-<user>-'); return RE_MAIL ? t.replace(RE_MAIL, '<email>') : t; };
const cut = (s, max) => {
s = s.replace(/\s+/g, ' ').trim();
if (s.length <= max) return s;
const head = s.slice(0, max);
const k = Math.max(head.lastIndexOf('. '), head.lastIndexOf('; '));
return (k > max * 0.5 ? head.slice(0, k + 1) : head.replace(/\s+\S*$/, '')) + (k > max * 0.5 ? '' : '…');
};
// ---------------------------------------------------------------- collect
const skipped = [];
const entries = [];
function addFile(abs, rel, meta) {
if (!TEXT.test(rel)) return;
if (/package-lock\.json$|project-dump|(^|\/)\.env/.test(rel)) return;
const st = fs.statSync(abs);
if (st.size > MAXB) { skipped.push({ rel, why: `over ${MAXB / 1024} KB` }); return; }
const raw = fs.readFileSync(abs, 'utf8');
for (const re of SECRET) if (re.test(raw)) { skipped.push({ rel, why: 'key-like string or private address in the file' }); return; }
entries.push({ rel, abs, size: st.size, lines: raw.split(/\r?\n/).length, text: redact(raw), ...meta });
}
function walk(absDir, relDir, meta, filter) {
if (!fs.existsSync(absDir)) return;
for (const e of fs.readdirSync(absDir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
if (SKIP_DIR.has(e.name)) continue;
const abs = path.join(absDir, e.name), rel = (relDir ? relDir + '/' : '') + e.name;
if (e.isDirectory()) walk(abs, rel, meta, filter);
else if (!filter || filter(rel)) addFile(abs, rel, meta);
}
}
const skillDirs = fs.readdirSync(path.join(ROOT, '.claude', 'skills'), { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
for (const s of skillDirs) {
walk(path.join(ROOT, '.claude', 'skills', s), '.claude/skills/' + s, { group: 'skills', skill: s },
(rel) => !/\/examples\//.test(rel) || path.posix.basename(rel) === 'README.md');
}
walk(path.join(ROOT, '.claude', 'agents'), '.claude/agents', { group: 'agents', skill: null });
for (const d of ['src', 'scripts', 'tests']) walk(path.join(ROOT, d), d, { group: 'code', skill: null });
for (const f of fs.readdirSync(ROOT)) {
const abs = path.join(ROOT, f);
if (!fs.statSync(abs).isFile()) continue;
if (/\.(js|mjs|md)$/.test(f) || f === 'package.json') addFile(abs, f, { group: /\.md$/.test(f) ? 'docs' : 'code', skill: null });
}
addFile(path.join(DOCS, 'SCRIPT-PIPELINE.md'), 'docs/SCRIPT-PIPELINE.md', { group: 'docs', skill: null });
for (const s of GLOBAL_SKILLS) walk(path.join(HOME, '.claude', 'skills', s), 'global-skills/' + s, { group: 'global', skill: s });
for (const e of entries) {
e.out = 'source/' + e.rel.split('/').map((x) => x.replace(/^\./, '')).join('/') + '.html';
e.base = path.posix.basename(e.rel);
e.kind = /\.md$/i.test(e.rel) ? 'md' : /\.json$/i.test(e.rel) ? 'json' : 'code';
e.display = e.group === 'global' ? e.rel.replace(/^global-skills\//, '~/.claude/skills/') : e.rel;
if (e.skill) e.base_dir = (e.group === 'global' ? 'global-skills/' : '.claude/skills/') + e.skill + '/';
}
// ---------------------------------------------------------------- resolve a file name to a page
const byRel = new Map(entries.map((e) => [e.rel, e]));
const suffix = new Map();
for (const e of entries) {
const segs = e.rel.split('/');
for (let k = 1; k <= Math.min(segs.length, 5); k++) { const key = segs.slice(-k).join('/'); (suffix.get(key) || suffix.set(key, []).get(key)).push(e); }
}
const skillPage = new Map(entries.filter((e) => e.base === 'SKILL.md' && e.skill).map((e) => [e.skill, e]));
function resolve(tok, ctx) {
const t = String(tok).trim().replace(/^\.\//, '').replace(/[.,;:)]+$/, '');
if (!t || /\s/.test(t)) return null;
if (skillPage.has(t)) return skillPage.get(t);
const m = t.match(/^\.claude\/skills\/([\w-]+)\/?$/);
if (m && skillPage.has(m[1])) return skillPage.get(m[1]);
if (byRel.has(t)) return byRel.get(t);
if (!/[\/.]/.test(t)) return null;
const c = suffix.get(t);
if (!c) return null;
if (c.length === 1) return c[0];
if (ctx && ctx.skill) { const s = c.filter((e) => e.skill === ctx.skill); if (s.length === 1) return s[0]; }
const p = c.filter((e) => e.skill === PRIORITY);
return p.length === 1 ? p[0] : null;
}
const href = (fromOut, e) => path.posix.relative(path.posix.dirname(fromOut), e.out);
const mentions = new Map(); // entry.rel -> Set of docs page names that link to it
const byOut = new Map(entries.map((e) => [e.out, e]));
// ---------------------------------------------------------------- markdown
function frontmatter(text) {
if (!text.startsWith('---')) return null;
const end = text.indexOf('\n---', 3);
if (end < 0) return null;
const fm = {};
let key = null;
for (const l of text.slice(3, end).split(/\r?\n/)) {
const m = l.match(/^([A-Za-z][\w-]*):\s*(.*)$/);
if (m) { key = m[1]; fm[key] = m[2]; } else if (key && l.trim()) fm[key] += ' ' + l.trim();
}
return { fm, body: text.slice(end + 4).replace(/^\r?\n/, '') };
}
const slug = (s) => s.toLowerCase().replace(/<[^>]+>/g, '').replace(/[`*_]/g, '').replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '-');
function renderMd(text, ctx) {
const L = text.split(/\r?\n/);
const slugs = new Set(L.filter((l) => /^#{1,6}\s+/.test(l)).map((l) => slug(l.replace(/^#+\s+/, ''))));
const used = new Map();
const code = (c) => {
const e = resolve(c.replace(/^node\s+/, '').split(/\s+/)[0], ctx);
if (e && e !== ctx) return `<a class="srclink" href="${href(ctx.out, e)}"><code>${esc(c)}</code></a>`;
return `<code>${esc(c)}</code>`;
};
const inline = (s) => {
const codes = [];
s = s.replace(/`([^`]+)`/g, (m, c) => { codes.push(c); return '\u0000' + (codes.length - 1) + '\u0000'; });
s = esc(s);
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>').replace(/(^|[\s(])\*([^*\s][^*]*?)\*(?=[\s).,;:]|$)/g, '$1<em>$2</em>');
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (m, t, u) => {
u = u.replace(/&/g, '&');
if (/^https?:/.test(u)) return `<a href="${esc(u)}">${t}</a>`;
if (u.startsWith('#')) return slugs.has(u.slice(1)) ? `<a href="${esc(u)}">${t}</a>` : t;
const target = path.posix.normalize(path.posix.dirname(ctx.rel) + '/' + u.split('#')[0]);
const e = byRel.get(target);
return e && e !== ctx ? `<a class="srclink" href="${href(ctx.out, e)}">${t}</a>` : t;
});
return s.replace(/\u0000(\d+)\u0000/g, (m, i) => code(codes[i]));
};
const isBlockStart = (l) => /^(#{1,6}\s|```|>|\s*\|)/.test(l) || /^\s*([-*+]|\d+[.)])\s+/.test(l) || /^-{3,}\s*$/.test(l);
let html = '', i = 0;
while (i < L.length) {
const l = L[i];
if (!l.trim()) { i++; continue; }
if (/^\s*<!--/.test(l)) { while (i < L.length && !/-->/.test(L[i])) i++; i++; continue; }
if (/^```/.test(l)) {
const buf = []; i++;
while (i < L.length && !/^```/.test(L[i])) buf.push(L[i++]);
i++; html += `<pre><code>${esc(buf.join('\n'))}</code></pre>\n`; continue;
}
let m = l.match(/^(#{1,6})\s+(.*)$/);
if (m) {
const lvl = Math.min(6, m[1].length + 1);
let id = slug(m[2]); const n = used.get(id) || 0; used.set(id, n + 1); if (n) id += '-' + n;
html += `<h${lvl} id="${id}">${inline(m[2])}</h${lvl}>\n`; i++; continue;
}
if (/^-{3,}\s*$/.test(l)) { html += '<hr>\n'; i++; continue; }
if (/^\s*\|/.test(l) && i + 1 < L.length && /^\s*\|?\s*:?-{2,}/.test(L[i + 1])) {
const cells = (r) => r.trim().replace(/\\\|/g, '\u0001').replace(/^\||\|$/g, '').split('|').map((c) => inline(c.replace(/\u0001/g, '|').trim()));
const head = cells(l); i += 2; const rows = [];
while (i < L.length && /^\s*\|/.test(L[i])) rows.push(cells(L[i++]));
html += `<div class="tw"><table><thead><tr>${head.map((c) => `<th>${c}</th>`).join('')}</tr></thead><tbody>${rows.map((r) => `<tr>${r.map((c) => `<td>${c}</td>`).join('')}</tr>`).join('')}</tbody></table></div>\n`; continue;
}
if (/^>/.test(l)) {
const buf = [];
while (i < L.length && /^>/.test(L[i])) buf.push(L[i++].replace(/^>\s?/, ''));
html += `<blockquote><p>${inline(buf.join(' '))}</p></blockquote>\n`; continue;
}
if (/^\s*([-*+]|\d+[.)])\s+/.test(l)) {
const items = [];
while (i < L.length) {
const mm = L[i].match(/^(\s*)([-*+]|\d+[.)])\s+(.*)$/);
if (mm) { items.push({ ind: mm[1].length, ord: /\d/.test(mm[2]), t: mm[3] }); i++; continue; }
if (L[i].trim() === '') { let j = i; while (j < L.length && !L[j].trim()) j++; if (j < L.length && /^\s*([-*+]|\d+[.)])\s+/.test(L[j]) && j - i < 2) { i = j; continue; } break; }
if (/^\s+\S/.test(L[i]) && items.length) { items[items.length - 1].t += ' ' + L[i].trim(); i++; continue; }
break;
}
const stack = []; let h = '';
for (const it of items) {
while (stack.length && it.ind < stack[stack.length - 1].ind) h += '</li></' + stack.pop().tag + '>';
if (stack.length && it.ind === stack[stack.length - 1].ind) h += '</li>';
else { const tag = it.ord ? 'ol' : 'ul'; h += `<${tag}>`; stack.push({ ind: it.ind, tag }); }
h += '<li>' + inline(it.t);
}
while (stack.length) h += '</li></' + stack.pop().tag + '>';
html += h + '\n'; continue;
}
const buf = [l]; i++;
while (i < L.length && L[i].trim() && !isBlockStart(L[i])) buf.push(L[i++]);
html += `<p>${inline(buf.join(' '))}</p>\n`;
}
return html;
}
// ---------------------------------------------------------------- summaries
function summaryOf(e) {
if (e.kind === 'md') {
const f = frontmatter(e.text);
if (f && f.fm.description) return cut(f.fm.description, 240);
const body = f ? f.body : e.text;
const h = (body.match(/^#\s+(.*)$/m) || [])[1];
const p = body.split(/\r?\n\r?\n/).map((x) => x.trim()).find((x) => x && !/^#|^\||^```|^[-*]\s|^>|^\*\(|^---/.test(x));
return cut([h, p].filter(Boolean).join(': ').replace(/[`*]/g, ''), 240);
}
if (e.kind === 'json') {
try { const j = JSON.parse(e.text); const k = Array.isArray(j) ? [] : Object.keys(j).filter((x) => !x.startsWith('_')); return Array.isArray(j) ? `JSON list of ${j.length}` : `JSON: ${k.slice(0, 7).join(', ')}${k.length > 7 ? ', …' : ''}`; } catch { return 'JSON'; }
}
const L = e.text.split(/\r?\n/);
let i = L[0] && L[0].startsWith('#!') ? 1 : 0;
const buf = [];
if (/^\s*\/\*/.test(L[i] || '')) { while (i < L.length && buf.length < 12) { buf.push(L[i].replace(/^\s*\/?\*+\/?\s?/, '').replace(/\*\/\s*$/, '')); if (/\*\//.test(L[i])) break; i++; } }
else if (/^\s*#(?!!)/.test(L[i] || '') && /\.py$/.test(e.rel)) { while (i < L.length && /^\s*#/.test(L[i])) buf.push(L[i++].replace(/^\s*#\s?/, '')); }
else { while (i < L.length && /^\s*\/\//.test(L[i])) buf.push(L[i++].replace(/^\s*\/\/\s?/, '')); }
const s = buf.filter((x) => x.trim()).join(' ');
return cut(s, 240);
}
for (const e of entries) e.summary = summaryOf(e);
// ---------------------------------------------------------------- sidebar tree for a page under source/
function folderKey(e) { return e.skill ? 'skill:' + e.skill : e.group === 'agents' ? 'agents' : e.group === 'code' ? (e.rel.includes('/') ? e.rel.split('/')[0] : 'root') : 'docs'; }
function folderTitle(k) { return k.startsWith('skill:') ? 'Skill: ' + k.slice(6) : ({ agents: 'Agents', src: 'src/', scripts: 'scripts/', tests: 'tests/', root: 'Top-level code', docs: 'Project documents' })[k] || k; }
for (const e of entries) e.fk = folderKey(e);
const folders = new Map();
for (const e of entries) (folders.get(e.fk) || folders.set(e.fk, []).get(e.fk)).push(e);
function treeBase(e) { return e.skill ? e.base_dir : e.fk === 'src' ? 'src/' : e.fk === 'scripts' ? 'scripts/' : e.fk === 'tests' ? 'tests/' : e.fk === 'agents' ? '.claude/agents/' : ''; }
function sideNav(cur) {
const list = folders.get(cur.fk).slice().sort((a, b) => (a.base === 'SKILL.md' ? -1 : b.base === 'SKILL.md' ? 1 : a.rel.localeCompare(b.rel)));
const base = treeBase(cur);
const groups = new Map();
for (const e of list) { const sub = path.posix.dirname(e.rel.slice(base.length)); (groups.get(sub) || groups.set(sub, []).get(sub)).push(e); }
let h = `<nav class="side" aria-label="Files">\n<p class="brand"><a href="${path.posix.relative(path.posix.dirname(cur.out), 'index.html')}">Ecosystem project docs</a></p>\n<p class="sub">Skills and code, published from the repository</p>\n<details open><summary>${esc(folderTitle(cur.fk))}</summary>\n`;
h += `<ul><li><a href="${path.posix.relative(path.posix.dirname(cur.out), CATALOGUE)}">Skills and code catalogue</a></li><li><a href="${path.posix.relative(path.posix.dirname(cur.out), '12-tools-and-files.html')}">Tools and files (page 12)</a></li></ul>\n`;
for (const [sub, es] of groups) {
if (sub !== '.') h += `<p class="grp sub">${esc(sub)}/</p>\n`;
h += '<ul>' + es.map((e) => `<li><a href="${href(cur.out, e)}"${e === cur ? ' aria-current="page" class="cur"' : ''}>${esc(e.base)}</a></li>`).join('') + '</ul>\n';
}
return h + '</details>\n</nav>';
}
// ---------------------------------------------------------------- the docs pages: nav, stylesheet, links to source pages
const docPages = fs.readdirSync(DOCS).filter((f) => f.endsWith('.html')).sort();
function updateNav(h) {
if (!h.includes(CATALOGUE)) {
h = h.replace(/(<li><a href="14-video-production\.html"[^>]*>[^<]*<\/a><\/li>\s*<\/ul>)/, `$1\n<p class="grp">Machinery</p>\n<ul>\n<li><a href="${CATALOGUE}">Skills and code<span class="tag">core</span></a></li>\n</ul>`);
}
return h.replace('Pages 01 to 13 written 2026-10-27; page 14 on 2026-10-31</p>', `Pages 01 to 13 written 2026-10-27; page 14 on 2026-10-31; page 15 on ${WRITTEN}</p>`);
}
function linkifyDoc(h, name) {
const parts = h.split(/(<[^>]+>)/);
let a = 0, pre = 0, nav = 0, head = 0, skipTag = 0, n = 0;
for (let i = 0; i < parts.length; i++) {
const t = parts[i];
if (!t.startsWith('<')) continue;
if (/^<a[\s>]/i.test(t)) a++; else if (/^<\/a>/i.test(t)) a--;
else if (/^<pre[\s>]/i.test(t)) pre++; else if (/^<\/pre>/i.test(t)) pre--;
else if (/^<nav[\s>]/i.test(t)) nav++; else if (/^<\/nav>/i.test(t)) nav--;
else if (/^<head[\s>]/i.test(t)) head++; else if (/^<\/head>/i.test(t)) head--;
else if (/^<(script|style|title|h1)[\s>]/i.test(t)) skipTag++; else if (/^<\/(script|style|title|h1)>/i.test(t)) skipTag--;
else if (t === '<code>' && !a && !pre && !nav && !head && !skipTag && parts[i + 2] === '</code>' && parts[i + 1] && !parts[i + 1].startsWith('<')) {
const text = parts[i + 1].replace(/&/g, '&');
const tok = (text.match(/^(?:node\s+)?([A-Za-z0-9_.\/-]+\.(?:js|mjs|cjs|json|md|py))(?:\s|$)/) || text.match(/^([A-Za-z0-9_.\/-]+)$/) || [])[1];
const e = tok && resolve(tok, null);
if (e) {
parts[i] = `<a class="srclink" href="${path.posix.relative('.', e.out)}"><code>`; parts[i + 2] = '</code></a>';
(mentions.get(e.rel) || mentions.set(e.rel, new Set()).get(e.rel)).add(name); n++;
}
}
}
return { html: parts.join(''), n };
}
let linkedTotal = 0;
for (const f of docPages) {
if (f === CATALOGUE) continue;
let h = fs.readFileSync(path.join(DOCS, f), 'utf8');
h = updateNav(h);
if (!h.includes('assets/source.css')) h = h.replace('<link rel="stylesheet" href="assets/docs.css">', '<link rel="stylesheet" href="assets/docs.css">\n<link rel="stylesheet" href="assets/source.css">');
const r = linkifyDoc(h, f);
linkedTotal += r.n;
fs.writeFileSync(path.join(DOCS, f), r.html);
// links made in an earlier build count too, so a re-run keeps every "Mentioned on" list
for (const m of r.html.matchAll(/href="(source\/[^"#]+)"/g)) { const e = byOut.get(m[1]); if (e) (mentions.get(e.rel) || mentions.set(e.rel, new Set()).get(e.rel)).add(f); }
}
// ---------------------------------------------------------------- source pages
fs.rmSync(OUT, { recursive: true, force: true });
const pageHead = (title, desc, out) => `<!doctype html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width, initial-scale=1">\n<title>${esc(title)} | Ecosystem project docs</title>\n<meta name="description" content="${esc(cut(desc || title, 200))}">\n<link rel="stylesheet" href="${path.posix.relative(path.posix.dirname(out), 'assets/docs.css')}">\n<link rel="stylesheet" href="${path.posix.relative(path.posix.dirname(out), 'assets/source.css')}">\n</head>\n<body>\n<div class="shell">\n`;
for (const e of entries) {
const rel = (p) => path.posix.relative(path.posix.dirname(e.out), p);
const fm = e.kind === 'md' ? frontmatter(e.text) : null;
let body = '';
if (e.kind === 'md') {
if (fm) body += `<div class="callout"><span class="t">Skill header (frontmatter)</span><p>${Object.entries(fm.fm).map(([k, v]) => `<strong>${esc(k)}:</strong> ${esc(cut(v, 600))}`).join('<br>')}</p></div>\n`;
body += renderMd(fm ? fm.body : e.text, e);
body += `<details class="raw"><summary>Show the raw text</summary><pre class="src"><code>${esc(e.text)}</code></pre></details>\n`;
} else {
body += `<pre class="src"><code>${esc(e.text)}</code></pre>\n`;
}
const crumbs = [`<a href="${rel('index.html')}">Docs</a>`, `<a href="${rel(CATALOGUE)}">Skills and code</a>`];
if (e.skill) crumbs.push(`<a href="${href(e.out, skillPage.get(e.skill))}">${esc(e.group === 'global' ? 'global skill ' + e.skill : 'skill ' + e.skill)}</a>`);
else crumbs.push(esc(folderTitle(e.fk)));
const ment = [...(mentions.get(e.rel) || [])].sort();
const stages = e.rel.startsWith('.claude/skills/video-script-production/') ? '' : '';
const html = pageHead(e.display, e.summary, e.out) + sideNav(e) + `\n<main>\n<p class="crumbs">${crumbs.join('<span class="sep">/</span>')}</p>\n<h1><code>${esc(e.display)}</code></h1>\n`
+ `<p class="srcmeta"><span><b>${e.kind === 'md' ? 'Markdown' : e.kind === 'json' ? 'JSON' : 'Code'}</b></span><span>${e.lines} lines</span><span>${(e.size / 1024).toFixed(1)} KB</span><span>${esc(folderTitle(e.fk))}</span></p>\n`
+ (e.summary ? `<div class="callout"><span class="t">What the file says about itself</span><p>${esc(e.summary)}</p></div>\n` : '')
+ (ment.length ? `<p class="small">Mentioned on: ${ment.map((m) => `<a href="${rel(m)}">page ${m.slice(0, 2)}</a>`).join(', ')}</p>\n` : '')
+ body
+ `<p class="foot">Shown as it is in the repository at build time (${WRITTEN}); the home directory appears as <home>. If this page and the file differ, the file is right. ${stages}<a href="${rel(CATALOGUE)}">Back to the catalogue</a>.</p>\n</main>\n</div>\n</body>\n</html>\n`;
fs.mkdirSync(path.join(DOCS, path.posix.dirname(e.out)), { recursive: true });
fs.writeFileSync(path.join(DOCS, e.out), html);
}
// ---------------------------------------------------------------- the catalogue page (15)
const A = (tok, label) => { const e = resolve(tok, null); return e ? `<a class="srclink" href="${path.posix.relative('.', e.out)}"><code>${esc(label || tok)}</code></a>` : `<code>${esc(label || tok)}</code>`; };
const fmOf = (e) => (e && e.kind === 'md' ? (frontmatter(e.text) || { fm: {} }).fm : {});
const listFiles = (es, from) => '<ul class="filelist">' + es.map((e) => `<li><a class="srclink" href="${path.posix.relative(path.posix.dirname(from), e.out)}"><code>${esc(e.base)}</code></a>${e.summary ? `<span class="d">${esc(cut(e.summary, 150))}</span>` : ''}</li>`).join('') + '</ul>';
const STAGES = [
['S0', 'Set up', 'Create the script folder and its files; the gate runner reads which stage the files show.', ['scripts/scaffold.js', 'scripts/check.js', 'references/runbook.md']],
['S1, S1b', 'Rival-video brief and harvest of what already exists', 'Search results, rival videos, articles and filings become saved sources.', ['references/s1-rival-brief.md', 'references/research-protocol.md', 'scripts/fetch-articles.mjs', 'google-ai-mode']],
['S2', 'Teardown of winners', 'What winning videos do, with quotes.', ['winning-video-teardown', 'references/s2-teardown.md']],
['S3', 'Fact base and ledger', 'Every figure checked in a second source and given a status; the ledger check refuses a spoken figure that is not in it.', ['scripts/ledger-check.js', 'references/research-protocol.md']],
['S4', 'Thesis and packaging', 'The question, the promise, the title tension, the opening.', ['references/opening-protocol.md', 'references/quality-bar.md']],
['S5', 'Chain and texture plan', 'The declared structure: loops, people, pulls, figures, beats.', ['references/chain-schema.md', 'references/beat-contract.md', 'references/texture.md', 'references/implicit-pull.md']],
['S6, S7', 'Draft the voice-over, then two passes', 'The voice, the lessons, the load budget.', ['references/voice.md', 'references/principles.md', 'references/stakes-and-load.md']],
['S8', 'The gate loop', 'Lint (70 rules), damage test, ledger, timecodes, the bar numbers.', ['scripts/check.js', 'scripts/lint.js', 'scripts/rules.json', 'scripts/voice-patterns.json', 'scripts/selftest.js', 'scripts/retime.js', 'references/lint-fixes.md']],
['S9', 'Read-aloud pass', 'A person reads it aloud against a checklist.', ['references/read-aloud-checklist.md']],
['S10', 'Reader pass', 'Blind readers (only when the user asks for subagents).', ['references/reader-prompts.md', 'scripts/extract-vo.js', 'script-cohort-analysis']],
['S11', 'Hand-off and close', 'Manual gates, what is unproven, the research log, the learnings.', ['references/handoff-template.md', 'scripts/session-log.js', 'references/learnings.md', 'references/tools.md', 'references/history.md']],
['After', 'Hinglish version', 'Translate paragraph by paragraph; lint and damage test run with the Hinglish overlay; parity with the English.', ['references/hinglish-adaptation.md', 'scripts/lang-hi.json', 'scripts/hinglish-parity.js']],
['After', 'Voice', 'Prompts, recording with a lead-in, transcription, alignment, trim; the quota fallback through AI Studio.', ['scripts/tts-gemini.js', 'scripts/tts-gemini-record.js', 'scripts/tts-trim-lead-in.js', 'groq-transcribe', 'script-audio-align']],
['After', 'Video', 'Beat sheets, scenes, sound, stitching; Haiku sub-agents for the long mechanical stages.', ['video-visual-production', 'video-beats-orchestrate', 'video-beat-prepare', 'video-beat-storyboard', 'video-beat-build', 'video-beat-review']],
['Record', 'This site', 'The docs are built and deployed from the repository.', ['deploy-docs', 'scripts/build-source.js', 'scripts/deploy-docs.js']],
];
const SKILL_GROUPS = [
['Writing the script', ['video-script-production']],
['Building the video', ['video-visual-production', 'video-beats-orchestrate', 'video-beat-prepare', 'video-beat-storyboard', 'video-beat-build', 'video-beat-review']],
['Studying the niche, the winners and the competition', ['ecosystem-rater', 'script-cohort-analysis', 'winning-video-teardown', 'within-channel-study', 'meme-fetcher']],
['Publishing and housekeeping', ['deploy-docs']],
];
const listed = new Set(SKILL_GROUPS.flatMap((g) => g[1]));
const otherSkills = skillDirs.filter((s) => !listed.has(s));
if (otherSkills.length) SKILL_GROUPS.push(['Other skills', otherSkills]);
const skillBlock = (name, open) => {
const files = entries.filter((e) => e.skill === name);
if (!files.length) return '';
const main = skillPage.get(name);
const refs = files.filter((e) => /\/references\//.test(e.rel)), scr = files.filter((e) => /\/scripts\//.test(e.rel)), oth = files.filter((e) => !/\/references\/|\/scripts\//.test(e.rel) && e !== main);
const d = fmOf(main).description || (main ? main.summary : '');
return `<details class="folder"${open ? ' open' : ''} id="skill-${name}"><summary>${esc(name)} <span class="small">(${files.length} files: ${refs.length} references, ${scr.length} scripts)</span></summary>\n<p>${main ? `<a class="srclink" href="${path.posix.relative('.', main.out)}"><code>SKILL.md</code></a> ` : ''}${esc(cut(d, 420))}</p>\n`
+ (refs.length ? `<p class="small">References</p>${listFiles(refs, CATALOGUE)}` : '') + (scr.length ? `<p class="small">Scripts and data</p>${listFiles(scr, CATALOGUE)}` : '') + (oth.length ? `<p class="small">Other files</p>${listFiles(oth, CATALOGUE)}` : '') + '</details>\n';
};
const codeFolders = [...folders.keys()].filter((k) => !k.startsWith('skill:') && k !== 'agents' && k !== 'docs');
const nSkills = new Set(entries.filter((e) => e.skill && e.group === 'skills').map((e) => e.skill)).size;
const nav14 = (fs.readFileSync(path.join(DOCS, '14-video-production.html'), 'utf8').match(/<nav class="side"[\s\S]*?<\/nav>/) || [''])[0]
.replace(/ aria-current="page"/g, '').replace(`<li><a href="${CATALOGUE}">`, `<li><a href="${CATALOGUE}" aria-current="page">`);
const cat = `<!doctype html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width, initial-scale=1">\n<title>Skills and code: the machinery | Ecosystem project docs</title>\n<meta name="description" content="Every skill, agent, script and project code file, published as a page and mapped to the stage of the plan it carries out.">\n<link rel="stylesheet" href="assets/docs.css">\n<link rel="stylesheet" href="assets/source.css">\n</head>\n<body>\n<div class="shell">\n${nav14}\n<main>\n`
+ `<h1>Skills and code: the machinery</h1>\n<p class="meta">How the plan in pages 01 to 14 is carried out: every skill, agent, script and code file, published as a page and linked from the stage it serves.</p>\n`
+ `<div class="tldr">\n<span class="label">TL;DR</span>\n<p>The plan says <em>what</em> we do; this page shows <em>how</em>. Each skill is a folder of instructions (<code>SKILL.md</code>), references and scripts; the scripts are plain Node programs with no dependencies that check, build and record. <strong>${entries.length} files</strong> from the repository are published here, each as its own page, shown as it is.</p>\n<ul>\n<li><strong>The spine:</strong> ${A('video-script-production')} takes facts to a finished script by stages S0 to S11; ${A('scripts/check.js')} runs every gate and says what to do next.</li>\n<li><strong>The rules are code:</strong> ${A('scripts/lint.js')} reads ${A('scripts/rules.json')}; a damage test (${A('scripts/selftest.js')}) breaks each property on purpose and checks the matching rule fires.</li>\n<li><strong>What is excluded:</strong> research files, data, anything over 400 KB, and any file with a key-like string. The list is at the bottom.</li>\n</ul>\n</div>\n`
+ `<h2 id="map">1. The plan, stage by stage, and the files that do it</h2>\n<div class="tw"><table><thead><tr><th>Stage</th><th>What happens</th><th>Files and skills</th></tr></thead><tbody>\n${STAGES.map((s) => `<tr><td>${esc(s[0])}</td><td><strong>${esc(s[1])}</strong><br><span class="small">${esc(s[2])}</span></td><td>${s[3].map((t) => A(t)).join(', ')}</td></tr>`).join('\n')}\n</tbody></table></div>\n`
+ `<h2 id="skills">2. Skills</h2>\n<p>A skill is a folder under <code>.claude/skills/</code>. Open one to see its instructions, its references (the rules and the reasons) and its scripts (the tools).</p>\n${SKILL_GROUPS.map((g, gi) => `<h3>${esc(g[0])}</h3>\n${g[1].map((s, si) => skillBlock(s, gi === 0 && si === 0)).join('')}`).join('')}\n`
+ `<h2 id="agents">3. Agents</h2>\n<p>Agent definitions fix the model and the tools for the sub-agents that run the long mechanical stages (Haiku, by the user's choice, to save tokens).</p>\n${listFiles(entries.filter((e) => e.group === 'agents'), CATALOGUE)}\n`
+ `<h2 id="global">4. Global skills the pipeline uses</h2>\n<p>These live in <code>~/.claude/skills/</code>, not in the repository, so they are copied here at build time. They transcribe audio, align a script to it, and drive Google AI Mode for research.</p>\n${GLOBAL_SKILLS.map((s) => skillBlock(s, false)).join('')}\n`
+ `<h2 id="code">5. Project code</h2>\n<p>The series and channel study code (discovery, qualification, transcripts, analysis, viewers) that feeds the research stages.</p>\n${codeFolders.map((k) => `<details class="folder"><summary>${esc(folderTitle(k))} <span class="small">(${folders.get(k).length} files)</span></summary>\n${listFiles(folders.get(k), CATALOGUE)}</details>`).join('\n')}\n`
+ `<h2 id="docs">6. Project documents</h2>\n<p>The instructions to a fresh session and the front-door documents.</p>\n${listFiles(entries.filter((e) => e.group === 'docs'), CATALOGUE)}\n`
+ `<h2 id="how">7. How these pages are made</h2>\n<ul>\n<li><code>build-source.js</code> (in ${A('deploy-docs')}) reads the repository, writes every page under <code>docs/source/</code>, adds this page to the navigation, and links file names in the other pages to their source pages. ${A('scripts/deploy-docs.js')} runs it, checks every link, and publishes only the allowlisted files.</li>\n<li>Each file page shows markdown rendered (with the raw text under a toggle) and code as text. The home directory is replaced by <code><home></code>.</li>\n<li><strong>${nSkills} skills in the repository, ${GLOBAL_SKILLS.length} global skills, ${entries.length} files in all.</strong></li>\n<li>Excluded on purpose: <code>research/</code> (the scripts and sources for each video), <code>data/</code>, <code>reports/</code>, <code>transcripts/</code>, <code>seeds/</code>, lock files and <code>.env</code> files, skill <code>examples/</code> (except their README), and files over 400 KB${skipped.length ? `. Not published because of what they contain or their size: ${skipped.map((s) => `<code>${esc(redact(s.rel))}</code> (${esc(s.why)})`).join(', ')}` : ''}.</li>\n</ul>\n`
+ `<div class="pager">\n<a href="14-video-production.html"><small>Previous</small>Video production plan</a>\n<span></span>\n</div>\n<p class="foot">Written ${WRITTEN} by <code>build-source.js</code> from the files in the repository. If a page and a file disagree, the file is right: rebuild.</p>\n</main>\n</div>\n</body>\n</html>\n`;
fs.writeFileSync(path.join(DOCS, CATALOGUE), cat);
// index card for the new page (once)
{
const f = path.join(DOCS, 'index.html');
let h = fs.readFileSync(f, 'utf8');
if (!h.includes(CATALOGUE + '"><')) {
h = h.replace(/(<div class="card"><h3><a href="14-video-production\.html">[\s\S]*?<\/div>\n)/, `$1<div class="card"><h3><a href="${CATALOGUE}">15 Skills and code</a></h3><p class="when">Read to see how the plan is carried out.</p><p>Every skill, tool and script as its own page, with each stage mapped to the files that do it.</p></div>\n`);
h = h.replace('</tbody></table></div>\n\n<h2 id="resume">', `<tr><td>See how the plan is implemented, or find the code behind a gate</td><td><a href="${CATALOGUE}">15</a>, then the file page it links to.</td></tr>\n</tbody></table></div>\n\n<h2 id="resume">`);
fs.writeFileSync(f, h);
}
}
// ---------------------------------------------------------------- check every link the build made
const problems = [];
const checkFile = (abs) => {
const h = fs.readFileSync(abs, 'utf8');
for (const m of h.matchAll(/href="([^"]+)"/g)) {
const u = m[1];
if (/^(https?:|mailto:|#)/.test(u)) continue;
const [file, frag] = u.split('#');
if (!file) continue;
const t = path.resolve(path.dirname(abs), file);
if (!fs.existsSync(t)) { problems.push(`${path.relative(DOCS, abs)}: broken link ${u}`); continue; }
if (frag && /\.html$/.test(t) && !new RegExp(`\\sid="${frag}"`).test(fs.readFileSync(t, 'utf8'))) problems.push(`${path.relative(DOCS, abs)}: missing anchor ${u}`);
}
};
for (const f of fs.readdirSync(DOCS).filter((x) => x.endsWith('.html'))) checkFile(path.join(DOCS, f));
for (const e of entries) checkFile(path.join(DOCS, e.out));
const nLinked = new Set([...mentions.keys()]).size;
console.log(`Built ${entries.length} source pages + ${CATALOGUE}; ${linkedTotal} file names in the docs pages now link to a source page (${nLinked} distinct files); ${skipped.length} file(s) not published.`);
if (REPORT) {
const by = {}; for (const e of entries) by[e.fk] = (by[e.fk] || 0) + 1;
console.log('By folder:', JSON.stringify(by));
skipped.forEach((s) => console.log(' not published:', s.rel, '-', s.why));
}
if (problems.length) { console.error(problems.slice(0, 30).join('\n')); console.error(`${problems.length} link problem(s)`); process.exit(1); }
console.log('Links OK');
Shown as it is in the repository at build time (2026-11-03); the home directory appears as <home>. If this page and the file differ, the file is right. Back to the catalogue.