hired-wall-build.mjs
Rama main · réplica de lectura del repositorio original.
#!/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 <image href> 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 = /^<!-- hire n=(\d+) level=(handle|role|count)(?: handle=([^\s]+))? role="([^"]*)"(?: sector="([^"]*)")?(?: geo="([^"]*)")?(?: weeks=(\d+))? link="([^"]*)"(?: withdrawn)? -->$/;
/** 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 = [`<!-- hire n=${e.n} level=${e.level}`];
if (e.handle) parts.push(`handle=${e.handle}`);
parts.push(`role="${e.role}"`);
if (e.sector) parts.push(`sector="${e.sector}"`);
if (e.geo) parts.push(`geo="${e.geo}"`);
if (e.weeks) parts.push(`weeks=${e.weeks}`);
parts.push(`link="${e.link}"`);
if (e.withdrawn) parts.push('withdrawn');
return parts.join(' ') + ' -->';
}
const esc = (s) => String(s).replace(/&/g, '&').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'
? `<a href="https://github.com/${e.handle}"><img src="https://github.com/${e.handle}.png?size=64" width="28" height="28" align="top" alt="@${e.handle}"> **@${e.handle}**</a>`
: `**${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('<!-- ENTRIES -->');
const end = text.indexOf('<!-- /ENTRIES -->');
if (start === -1 || end === -1 || end < start) throw new Error('HIRED.md is missing its <!-- ENTRIES --> 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)}<!-- ENTRIES -->\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 += `<g><rect x="${x}" y="14" width="${CW}" height="150" rx="10" fill="none" stroke="#30363d" stroke-dasharray="5 5"/>
<text x="${x + CW / 2}" y="86" text-anchor="middle" fill="#8b949e" font-size="13" font-style="italic">The next card is yours.</text>
<text x="${x + CW / 2}" y="106" text-anchor="middle" fill="#DD7627" font-size="12" font-weight="700">Share your hire →</text></g>`;
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 += `<g><rect x="${x}" y="14" width="${CW}" height="150" rx="10" fill="#161b22" stroke="#30363d"/>
<text x="${x + 16}" y="44" fill="#DD7627" font-size="22" font-weight="800">“</text>
${quote.map((l, k) => `<text x="${x + 34}" y="${44 + k * 19}" fill="#e6edf3" font-size="13" font-style="italic">${esc(l)}</text>`).join('\n')}
${avatar
? `<clipPath id="av${i}"><circle cx="${x + 29}" cy="${H - 52}" r="13"/></clipPath><image href="${avatar}" x="${x + 16}" y="${H - 65}" width="26" height="26" clip-path="url(#av${i})"/>`
: `<clipPath id="an${i}"><circle cx="${x + 29}" cy="${H - 52}" r="13"/></clipPath><circle cx="${x + 29}" cy="${H - 52}" r="13" fill="#30363d"/><g clip-path="url(#an${i})" fill="#8b949e"><circle cx="${x + 29}" cy="${H - 56}" r="4.5"/><path d="M ${x + 21} ${H - 39} a 8 8 0 0 1 16 0 z"/></g>`}
<text x="${x + 50}" y="${H - 56}" fill="#c9d1d9" font-size="12.5" font-weight="700">${esc(who)}</text>
<text x="${x + 50}" y="${H - 41}" fill="#8b949e" font-size="11">${esc(sub)}</text>
<text x="${x + 16}" y="${H - 20}" fill="#DD7627" font-size="10.5" font-weight="800" letter-spacing="0.6">HIRE #${e.n}</text>
${e.weeks ? `<text x="${x + CW - 16}" y="${H - 20}" text-anchor="end" fill="#3fb950" font-size="10.5" font-weight="700">${e.weeks} weeks</text>` : ''}</g>`;
}
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 += `<text x="${W - 12}" y="${H / 2 + 1}" text-anchor="middle" fill="#8b949e" font-size="22" font-weight="600">›</text>
<text x="${W - 12}" y="${H / 2 + 17}" text-anchor="middle" fill="#8b949e" font-size="${fs}" font-weight="700">+${rest}</text>`;
}
return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" font-family="-apple-system,'Segoe UI',Helvetica,Arial,sans-serif">
<rect width="${W}" height="${H}" fill="none"/>
${cards}
</svg>\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('<!-- ENTRIES -->') + '<!-- ENTRIES -->'.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; });
}