Docs/Skills and code/skill deploy-docs

.claude/skills/deploy-docs/scripts/deploy-docs.js

Code170 lines10.0 KBSkill: deploy-docs

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(/&amp;/g, '&').replace(/&quot;/g, '"').replace(/&lt;/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.