Docs/Skills and code/skill deploy-docs
.claude/skills/deploy-docs/scripts/deploy-docs.js
What the file says about itself
Deploys ONLY the static docs site (docs/*.html, docs/source/**/*.html and docs/assets/**) to Cloudflare Pages, then checks every page on the live URL.
Mentioned on: page 12
#!/usr/bin/env node
// Deploys ONLY the static docs site (docs/*.html, docs/source/**/*.html and docs/assets/**) to Cloudflare Pages, then checks every page on the live URL.
// usage: node deploy-docs.js [--dry] [--no-build] [--project ecosystem-docs] [--branch main] [--url https://ecosystem-docs-c9q.pages.dev]
// Step 0 runs build-source.js (the skills-and-code pages, built from the repository) unless --no-build is given.
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const arg = (n, d) => { const i = process.argv.indexOf('--' + n); return i > -1 && process.argv[i + 1] ? process.argv[i + 1] : d; };
const DRY = process.argv.includes('--dry');
const PROJECT = arg('project', 'ecosystem-docs');
const BRANCH = arg('branch', 'main');
const PROD = arg('url', 'https://ecosystem-docs-c9q.pages.dev').replace(/\/$/, '');
const ROOT = path.resolve(__dirname, '..', '..', '..', '..');
const DOCS = path.join(ROOT, 'docs');
const ASSET_EXT = /\.(css|js|png|jpg|jpeg|svg|webp|woff2?|ico)$/i;
const MAX_BYTES = 20 * 1024 * 1024;
const fail = (m) => { console.error('FAIL: ' + m); process.exit(1); };
const say = (m) => console.log(m);
// 0. Build the skills-and-code pages from the repository so what is published is what is in the repo.
if (!process.argv.includes('--no-build')) {
const b = spawnSync(process.execPath, [path.join(__dirname, 'build-source.js')], { encoding: 'utf8' });
process.stdout.write(b.stdout || '');
if (b.status !== 0) { process.stderr.write(b.stderr || ''); fail('build-source.js failed; nothing deployed'); }
}
// 1. Allowlist copy into a clean folder: never run wrangler from the repo (it would upload node_modules, data, the DBs).
const work = path.join(os.tmpdir(), 'ecosystem-docs-deploy');
const site = path.join(work, 'site');
fs.rmSync(work, { recursive: true, force: true });
fs.mkdirSync(site, { recursive: true });
const pages = fs.readdirSync(DOCS).filter((f) => f.endsWith('.html')).sort();
if (!pages.includes('index.html')) fail('docs/index.html missing');
for (const f of pages) fs.copyFileSync(path.join(DOCS, f), path.join(site, f));
// the generated source pages (html only, nothing else under docs/source is staged)
const srcPages = [];
(function copySource(d, rel) {
if (!fs.existsSync(d)) return;
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
if (e.isDirectory()) copySource(path.join(d, e.name), rel + e.name + '/');
else if (e.name.endsWith('.html')) {
fs.mkdirSync(path.join(site, rel), { recursive: true });
fs.copyFileSync(path.join(d, e.name), path.join(site, rel, e.name));
srcPages.push(rel + e.name);
}
}
})(path.join(DOCS, 'source'), 'source/');
const assetsDir = path.join(DOCS, 'assets');
if (fs.existsSync(assetsDir)) {
(function walk(d, rel) {
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
if (e.isDirectory()) walk(path.join(d, e.name), rel + e.name + '/');
else if (ASSET_EXT.test(e.name)) {
fs.mkdirSync(path.join(site, 'assets', rel), { recursive: true });
fs.copyFileSync(path.join(d, e.name), path.join(site, 'assets', rel, e.name));
}
}
})(assetsDir, '');
}
const files = [];
(function list(d) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { const p = path.join(d, e.name); e.isDirectory() ? list(p) : files.push(p); } })(site);
for (const p of files) if (fs.statSync(p).size > MAX_BYTES) fail('file over 20 MB: ' + p);
say(`Staged ${files.length} files (${pages.length} pages) in ${site}`);
// 2. Structure checks: ids, links, anchors, TL;DR first, nav identical, no local paths or secrets.
const html = Object.fromEntries(pages.map((f) => [f, fs.readFileSync(path.join(site, f), 'utf8')]));
const problems = [];
const warnings = [];
for (const f of pages) {
const h = html[f];
const ids = [...h.matchAll(/\sid="([^"]+)"/g)].map((m) => m[1]);
const dup = ids.filter((x, i) => ids.indexOf(x) !== i);
if (dup.length) problems.push(`${f}: duplicate ids ${[...new Set(dup)].join(', ')}`);
const main = h.indexOf('<main>');
if (main < 0 || !/<div class="tldr">/.test(h.slice(main, main + 700))) problems.push(`${f}: TL;DR not first in <main>`);
for (const m of h.matchAll(/href="([^"]+)"/g)) {
const href = m[1];
if (/^(https?:|mailto:)/.test(href)) continue;
const [file, frag] = href.split('#');
const target = file === '' ? f : file;
if (target.startsWith('assets/')) { if (!fs.existsSync(path.join(site, target))) problems.push(`${f}: missing asset ${target}`); continue; }
if (target.startsWith('source/')) { if (!fs.existsSync(path.join(site, target))) problems.push(`${f}: broken link ${href}`); continue; }
if (!html[target]) { problems.push(`${f}: broken link ${href}`); continue; }
if (frag && !new RegExp(`\\sid="${frag}"`).test(html[target])) problems.push(`${f}: missing anchor ${href}`);
}
if (/[A-Z]:\\Users\\|C:\/Users\//.test(h)) warnings.push(`${f}: names a local user path (already public; fine unless unwanted)`);
if (/AIza[0-9A-Za-z_-]{20,}|sk-[A-Za-z0-9]{20,}|CLOUDFLARE_API_TOKEN\s*=/.test(h)) problems.push(`${f}: looks like it contains a key`);
}
// the generated source pages: a title, no keys, every internal link and stylesheet resolves to a staged file
for (const p of srcPages) {
const h = fs.readFileSync(path.join(site, p), 'utf8');
if (!/<title>[^<]+<\/title>/.test(h)) problems.push(`${p}: no title`);
if (/AIza[0-9A-Za-z_-]{20,}|sk-[A-Za-z0-9]{20,}|gsk_[A-Za-z0-9]{20,}|CLOUDFLARE_API_TOKEN\s*=/.test(h)) problems.push(`${p}: looks like it contains a key`);
for (const m of h.matchAll(/(?:href|src)="([^"]+)"/g)) {
const u = m[1];
if (/^(https?:|mailto:|#)/.test(u)) continue;
const t = path.resolve(path.dirname(path.join(site, p)), u.split('#')[0]);
if (!fs.existsSync(t)) problems.push(`${p}: broken link ${u}`);
}
}
const navs = new Set(pages.map((f) => (html[f].match(/<nav class="side"[\s\S]*?<\/nav>/) || [''])[0].replace(/ aria-current="page"/g, '')));
if (navs.size !== 1) problems.push('side navigation differs between pages (a page was added without updating every page)');
if (problems.length) { console.error(problems.join('\n')); fail(`${problems.length} structure problem(s); nothing deployed`); }
warnings.forEach((w) => say('warn: ' + w));
say('Structure OK');
if (DRY) { say('Dry run: stopping before deploy.'); process.exit(0); }
// 3. Wrangler. Version 3 runs Pages natively; version 4 routes `pages` to the Workers flow and fails.
const wr = (args, opt = {}) => spawnSync('npx', ['--yes', 'wrangler@3', ...args], { cwd: work, encoding: 'utf8', shell: true, timeout: 300000, ...opt });
const who = wr(['whoami']);
const whoText = (who.stdout || '') + (who.stderr || '');
if (who.status !== 0 || /not authenticated|CLOUDFLARE_API_TOKEN|You are not logged in/i.test(whoText)) {
fail('wrangler is not authenticated in this shell. Ask the user to run, in their own terminal: npx wrangler@3 login (never ask for a token in chat). Then re-run.');
}
say('Wrangler authenticated');
const dep = wr(['pages', 'deploy', 'site', '--project-name', PROJECT, '--branch', BRANCH, '--commit-dirty=true']);
const out = (dep.stdout || '') + (dep.stderr || '');
if (dep.status !== 0) { console.error(out.slice(-1500)); fail('wrangler deploy failed'); }
const deployUrl = (out.match(/https:\/\/[a-z0-9]+\.[a-z0-9-]+\.pages\.dev/) || [''])[0];
say('Deployed. Deployment URL: ' + (deployUrl || '(not parsed)'));
// 4. Verify the live site: every page 200 on its clean URL with the right <title>, and the stylesheet.
const title = (h) => (h.match(/<title>([^<]*)<\/title>/) || [])[1];
const unesc = (s) => (s || '').replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
(async () => {
const bad = [];
const targets = pages.map((f) => ({ f, url: f === 'index.html' ? PROD + '/' : PROD + '/' + f.replace(/\.html$/, '') }));
for (const t of targets) {
let ok = false, note = '';
for (let i = 0; i < 6 && !ok; i++) {
try {
const r = await fetch(t.url + '?v=' + Date.now(), { redirect: 'follow' });
const body = await r.text();
note = r.status + ' ' + (unesc(title(body)) === unesc(title(html[t.f])) ? 'title ok' : 'title differs');
ok = r.status === 200 && unesc(title(body)) === unesc(title(html[t.f]));
} catch (e) { note = e.message; }
if (!ok) await new Promise((r) => setTimeout(r, 4000));
}
say(`${ok ? 'ok ' : 'BAD '} ${t.url} ${note}`);
if (!ok) bad.push(t.url);
}
// a sample of the source pages: the first, the last, and every skill's SKILL.md
const sample = [...new Set([srcPages[0], srcPages[srcPages.length - 1], ...srcPages.filter((p) => /\/SKILL\.md\.html$/.test(p))].filter(Boolean))];
for (const p of sample) {
const url = PROD + '/' + p.replace(/\.html$/, '');
const want = unesc(title(fs.readFileSync(path.join(site, p), 'utf8')));
let ok = false, note = '';
for (let i = 0; i < 6 && !ok; i++) {
try { const r = await fetch(url + '?v=' + Date.now(), { redirect: 'follow' }); const body = await r.text(); ok = r.status === 200 && unesc(title(body)) === want; note = r.status + (ok ? ' title ok' : ' title differs'); } catch (e) { note = e.message; }
if (!ok) await new Promise((r) => setTimeout(r, 4000));
}
if (!ok) { say(`BAD ${url} ${note}`); bad.push(url); }
}
say(`${bad.length ? 'BAD ' : 'ok '} ${sample.length} source pages sampled`);
const css = await fetch(PROD + '/assets/docs.css');
say(`${css.status === 200 ? 'ok ' : 'BAD '} ${PROD}/assets/docs.css ${css.status}`);
if (css.status !== 200) bad.push('docs.css');
// nothing outside the allowlist may be reachable
for (const probe of ['/CLAUDE.md', '/research/video-production/README.md', '/.env']) {
const r = await fetch(PROD + probe, { redirect: 'manual' });
const t = r.status === 200 ? await r.text() : '';
if (r.status === 200 && !/<html/i.test(t)) { say('BAD non-doc file reachable: ' + probe); bad.push(probe); }
}
if (bad.length) fail(`${bad.length} live check(s) failed`);
say(`\nDONE: ${targets.length} pages live at ${PROD}`);
})();
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.