#!/usr/bin/env node /** * hired-wall-build.mjs — the Hired Wall's single builder. * * HIRED.md is the ledger. Each entry is one machine-readable comment line * (the source of truth) followed by its rendered card; this script APPENDS * new entries, regenerates every rendered card from the comments, bumps * docs/hired-count.json (the README badge, docs/hired-badge.svg, is redrawn from * it by .github/scripts/readme-badges.mjs in the hired-wall workflow) and regenerates * docs/hired-wall.svg (the three most recent card-level stories shown under * the hero). One writer for all four surfaces so they cannot drift apart. * * node hired-wall-build.mjs --rebuild * node hired-wall-build.mjs --add --handle santifer --level handle \ * --role "Head of Applied AI" --geo "Spain" --weeks 8 \ * --story "..." --link "https://github.com/.../issues/N" * * Invariants the design promises publicly: * - count == number of ledger lines in HIRED.md == the badge. Auditable. * - numbers are permanent: a withdrawn story keeps its number as * "counted, story withdrawn"; the count never shrinks silently. * - `count`-level entries hold a number and no card (privacy level 3). * * Avatars: GitHub sanitizes SVGs in READMEs, so a remote would * never load. The workflow passes --fetch-avatars and the avatar bytes are * inlined as base64 data URIs at build time (~3KB per face, shields.io * pattern). Tests pass --avatar-fixture to stay offline. */ import { readFileSync, writeFileSync, existsSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import { flagValue, hasFlag, validateFlags } from './lib/cli-flags.mjs'; import { isMainModule } from './lib/is-main-module.mjs'; const HERE = dirname(fileURLToPath(import.meta.url)); const LEVELS = ['handle', 'role', 'count']; const KNOWN_FLAGS = ['--add', '--rebuild', '--root', '--handle', '--level', '--role', '--sector', '--geo', '--weeks', '--story', '--link', '--fetch-avatars', '--avatar-fixture', '--help', '-h']; const VALUE_FLAGS = ['--root', '--handle', '--level', '--role', '--sector', '--geo', '--weeks', '--story', '--link', '--avatar-fixture']; const USAGE = `Usage: node hired-wall-build.mjs --rebuild [--fetch-avatars | --avatar-fixture f.png] node hired-wall-build.mjs --add --level handle|role|count [--handle X] --role "..." [--geo "..."] [--weeks N] [--story "..."] --link URL Appends an entry to HIRED.md and/or regenerates the wall's derived surfaces (cards, docs/hired-count.json, docs/hired-wall.svg) from the ledger comments.`; const LEDGER_RE = /^$/; /** Parse every ledger comment of HIRED.md, in file order. */ export function parseLedger(text) { const out = []; for (const line of text.split('\n')) { const m = line.match(LEDGER_RE); if (!m) continue; out.push({ n: Number(m[1]), level: m[2], handle: m[3] || '', role: m[4], sector: m[5] || '', geo: m[6] || '', weeks: m[7] ? Number(m[7]) : null, link: m[8], withdrawn: / withdrawn -->$/.test(line), raw: line, }); } return out; } export function ledgerLine(e) { const parts = [`'; } const esc = (s) => String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); /** The rendered markdown card that sits under each ledger line. */ export function renderCard(e) { if (e.withdrawn) return `**Hire #${e.n}** — counted, story withdrawn at the author's request.`; if (e.level === 'count') return `**Hire #${e.n}** — counted, story kept private by choice.`; const who = e.level === 'handle' ? `@${e.handle} **@${e.handle}**` : `**${esc(e.role)}**`; const meta = [e.level === 'handle' ? esc(e.role) : (e.sector ? esc(e.sector) : null), e.geo ? esc(e.geo) : null, e.weeks ? `${e.weeks} weeks` : null] .filter(Boolean).join(' · '); return [ `### Hire #${e.n}`, '', `> ${e.story ? esc(e.story) : ''}`, '', `${who} · ${meta} · [story →](${e.link})`, ].join('\n'); } /** * Rebuild HIRED.md's entries section from its own ledger lines. Everything * between the ENTRIES markers is generated; prose outside them is * hand-written and never touched. Stories live IN the ledger section as a * quoted line right under each comment (kept verbatim on rebuild). */ export function rebuildWall(text) { const start = text.indexOf(''); const end = text.indexOf(''); if (start === -1 || end === -1 || end < start) throw new Error('HIRED.md is missing its markers'); const section = text.slice(start, end); const entries = []; const lines = section.split('\n'); for (let i = 0; i < lines.length; i++) { const m = lines[i].match(LEDGER_RE); if (!m) continue; const e = parseLedger(lines[i])[0]; // The story is the first quoted line following the ledger comment. for (let j = i + 1; j < lines.length && !lines[j].match(LEDGER_RE); j++) { const q = lines[j].match(/^> (.+)$/); if (q) { e.story = q[1]; break; } } entries.push(e); } entries.sort((a, b) => b.n - a.n); const body = entries.map((e) => `${ledgerLine(e)}\n${renderCard(e)}`).join('\n\n'); const rebuilt = `${text.slice(0, start)}\n\n${body}\n\n${text.slice(end)}`; return { text: rebuilt, entries }; } // ── The README strip (SVG, three most recent card-level stories) ──────────── async function avatarDataUri(handle, { fetchAvatars, fixture }) { if (fixture) return `data:image/png;base64,${readFileSync(fixture).toString('base64')}`; if (!fetchAvatars) return null; const res = await fetch(`https://github.com/${handle}.png?size=64`, { redirect: 'follow' }); if (!res.ok) return null; const buf = Buffer.from(await res.arrayBuffer()); return `data:image/png;base64,${buf.toString('base64')}`; } /** Word-wrap a quote to fit the card; clamps to 4 lines with an ellipsis. */ function wrapQuote(s, max = 34) { const words = String(s).split(/\s+/); const lines = ['']; for (const w of words) { if ((lines[lines.length - 1] + ' ' + w).trim().length > max) lines.push(w); else lines[lines.length - 1] = (lines[lines.length - 1] + ' ' + w).trim(); if (lines.length > 4) { lines.length = 4; lines[3] = lines[3].replace(/.{0,3}$/, '') + '…'; break; } } return lines; } export async function buildSvg(entries, opts = {}) { const showable = entries.filter((e) => !e.withdrawn && e.level !== 'count').slice(0, 3); const total = entries.length; // "Hay más": deliberadamente mudo — un chevron y el resto exacto en el gris // de la tira, a la derecha. Nada de tarjetas cortadas ni desvanecidos: el // fondo del README es del lector (claro u oscuro) y cualquier fade lava. const peek = total > 3; const W = 900, H = 178, GAP = 14; const CW = peek ? 280 : 288; const X0 = peek ? 4 : (W - (CW * 3 + GAP * 2)) / 2; let cards = ''; for (let i = 0; i < 3; i++) { const x = X0 + i * (CW + GAP); const e = showable[i]; if (!e && i < 3) { cards += ` The next card is yours. Share your hire →`; continue; } if (!e) continue; const quote = wrapQuote(e.story ?? '', peek ? 33 : 34); const avatar = e.level === 'handle' ? await avatarDataUri(e.handle, opts) : null; // Single-line rows have no wrapping: anything wider than the card was // silently CLIPPED by the viewBox (hire #5's four-word role was). Ellipsize // at ~the character count that fits the ~222px text run at each font size. const oneLine = (s, max) => { const t = String(s ?? ''); return t.length > max ? `${t.slice(0, max - 1).trimEnd()}…` : t; }; const who = oneLine(e.level === 'handle' ? `@${e.handle}` : e.role, 33); const sub = oneLine([e.level === 'handle' ? e.role : (e.sector || null), e.geo || null].filter(Boolean).join(' · '), 40); cards += ` “ ${quote.map((l, k) => `${esc(l)}`).join('\n')} ${avatar ? `` : ``} ${esc(who)} ${esc(sub)} HIRE #${e.n} ${e.weeks ? `${e.weeks} weeks` : ''}`; } if (peek) { // El resto es EXACTO siempre (la honestidad es la marca del muro), así que // a 2-3 dígitos la fuente encoge en vez de truncar: "+9"/"+42" a 9.5, // "+997" a 8 — cabe centrado en los 28px de aire hasta el borde. const rest = total - 3; const fs = rest >= 100 ? 8 : rest >= 10 ? 9 : 9.5; cards += `› +${rest}`; } return ` ${cards} \n`; } async function main() { const args = process.argv.slice(2); if (args.includes('--help') || args.includes('-h')) { console.log(USAGE); return; } const bad = validateFlags(args, KNOWN_FLAGS, USAGE, { valueFlags: VALUE_FLAGS, requireOperand: true }); if (bad) { process.exitCode = 1; return; } // Writing is opt-in: a bare run (or one with only modifiers such as // --fetch-avatars) would otherwise fall through to the rebuild below and // rewrite three tracked files, bumping updatedAt to today (#4896). if (!hasFlag(args, '--rebuild') && !hasFlag(args, '--add')) { console.error(`hired-wall-build: pass --rebuild or --add; nothing was written.\n\n${USAGE}`); process.exitCode = 1; return; } const root = flagValue(args, '--root') || HERE; const wallPath = join(root, 'HIRED.md'); let text = readFileSync(wallPath, 'utf8'); if (hasFlag(args, '--add')) { const level = flagValue(args, '--level'); const link = flagValue(args, '--link'); if (!LEVELS.includes(level) || !link) { console.error(`--add needs --level (${LEVELS.join('|')}) and --link.\n\n${USAGE}`); process.exitCode = 1; return; } const { entries } = rebuildWall(text); // Every field arrives from an untrusted issue body: strip anything that // could break the ledger comment (quotes, comment terminators, newlines) // or smuggle markup, and clamp lengths. Display escaping happens later in // renderCard/buildSvg; this is the STORAGE boundary. const clean = (s, max) => String(s ?? '').replace(/["<>]|--/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max); const e = { n: entries.length ? Math.max(...entries.map((x) => x.n)) + 1 : 1, level, handle: clean(flagValue(args, '--handle'), 40).replace(/[^A-Za-z0-9-]/g, ''), role: clean(flagValue(args, '--role'), 80), sector: clean(flagValue(args, '--sector'), 40), geo: clean(flagValue(args, '--geo'), 40), weeks: Number(flagValue(args, '--weeks')) || null, story: clean(flagValue(args, '--story'), 200), link: /^https:\/\/github\.com\/[\w./-]+$/.test(link) || /^https:\/\/santifer\.io\/[\w./-]*$/.test(link) ? link : '', withdrawn: false, }; if (!e.link) { console.error('--link must be a github.com or santifer.io https URL.'); process.exitCode = 1; return; } if (level === 'handle' && !e.handle) { console.error('--level handle needs --handle.'); process.exitCode = 1; return; } const at = text.indexOf('') + ''.length; text = `${text.slice(0, at)}\n\n${ledgerLine(e)}\n> ${e.story}\n${text.slice(at)}`; console.log(`hire #${e.n} appended (${level}${e.handle ? ` @${e.handle}` : ''}).`); } const { text: rebuilt, entries } = rebuildWall(text); writeFileSync(wallPath, rebuilt); const count = entries.length; writeFileSync(join(root, 'docs', 'hired-count.json'), JSON.stringify({ count, updatedAt: new Date().toISOString().slice(0, 10) }, null, 2) + '\n'); const svg = await buildSvg(entries, { fetchAvatars: hasFlag(args, '--fetch-avatars'), fixture: flagValue(args, '--avatar-fixture'), }); writeFileSync(join(root, 'docs', 'hired-wall.svg'), svg); console.log(`wall rebuilt: ${count} hire(s) · docs/hired-count.json · docs/hired-wall.svg`); } if (isMainModule(import.meta.url)) { main().catch((e) => { console.error(e.message); process.exitCode = 1; }); }