#!/usr/bin/env -S npx -y tsx /** * open-design-landing — HTML composer. * * Reads `inputs.json` (matching `../schema.ts`) and writes a single * self-contained HTML file with the Atelier Zero stylesheet inlined, * the 16 collage images referenced by relative URL, and the * scroll-reveal + headroom-nav scripts embedded. * * Usage: * npx tsx scripts/compose.ts * * Re-generate the canonical example: * npx tsx scripts/compose.ts inputs.example.json example.html */ import { readFile, writeFile, mkdir } from 'node:fs/promises'; import { dirname, resolve, isAbsolute } from 'node:path'; import { fileURLToPath } from 'node:url'; import type { EditorialCollageInputs, MixedText, HeroIndexItem, HeroStat, CapabilityCard, LabPill, LabCard, MethodStep, WorkCard, Partner, FooterColumn, SectionRule, } from '../schema'; const SKILL_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); /* ------------------------------------------------------------------ * * helpers * ------------------------------------------------------------------ */ /** Render a `MixedText` into HTML (sans/em/dot segments). */ function mixed(text: MixedText): string { return text .map((seg) => { if (seg.dot) return `${seg.text}`; if (seg.em) return `${seg.text}`; return seg.text; }) .join(''); } /** Newline → `
` for multi-line headings/labels. */ function br(s: string): string { return s.replace(/\n/g, '
'); } /** External-link attribute pair. */ function ext(href: string): string { if (/^(https?:|mailto:|\/\/)/i.test(href)) { return ` target='_blank' rel='noreferrer noopener'`; } return ''; } const ARROW_OUT = ``; const ARROW_PLUS = ``; /** A small CSS class we reference from inputs as `code-inline` / `code-inline sm`. */ const CODE_INLINE_CSS = ` .code-inline { font-family: var(--mono); font-size: 14px; background: var(--bone); padding: 1px 6px; border-radius: 4px; } .code-inline.sm { font-size: 12px; padding: 0 4px; } `; /* ------------------------------------------------------------------ * * section renderers * ------------------------------------------------------------------ */ function renderHead(i: EditorialCollageInputs, css: string): string { return ` ${i.brand.name} — ${i.brand.tagline} `; } function renderRails(i: EditorialCollageInputs): string { return `
${i.brand.rails.right}
${i.brand.rails.left}
`; } function renderTopbar(i: EditorialCollageInputs): string { const langs = i.brand.languages .map((l, idx) => (idx === 0 ? `${l}` : l)) .join(' · '); return `
OD / ${i.brand.year}  ·  ${i.brand.edition} Filed under ${i.brand.filed_under} ${i.brand.license} · Made on Earth ${i.brand.status} ${langs}
`; } function renderNav(i: EditorialCollageInputs): string { const links = i.nav .map( (link) => `
  • ${link.label}${ link.count ? `${link.count}` : '' }
  • `, ) .join('\n '); return ` `; } function renderSecRule(r: SectionRule): string { return `
    ${r.roman} ${r.meta[0]} ${r.meta[1]} ${r.meta[2]} ${r.pagination}
    `; } function renderHeroStat(s: HeroStat): string { const variant = s.variant ?? 'dashed'; const ringClass = variant === 'solid' ? 'ring solid' : variant === 'coral' ? 'ring coral' : 'ring'; return `
    ${s.value} ${s.label}${s.sub}
    `; } function renderHeroIndex(item: HeroIndexItem): string { return `${item.num}${item.label}`; } function renderHero(i: EditorialCollageInputs): string { const stats = i.hero.stats.map(renderHeroStat).join('\n '); const index = i.hero.index.map(renderHeroIndex).join('\n '); const assets = i.imagery.assets_path.replace(/\/?$/, '/'); return `
    I. Hero / Cover Plate ${i.brand.name} / Volume 01 001 / 008
    ${i.hero.label} ${i.hero.ix}

    ${mixed(i.hero.headline)}

    ${i.hero.lead}

    ${stats}
    ${i.hero.meta} ${i.brand.coordinates}
    ${i.hero.annotations.tl} ${i.hero.annotations.tr} ${i.hero.annotations.bl} ${i.hero.annotations.br}
    ${index}
    `; } function renderAbout(i: EditorialCollageInputs): string { const r = i.rules.about; const assets = i.imagery.assets_path.replace(/\/?$/, '/'); return `
    ${renderSecRule(r).trim()}
    ${i.about.label} ${i.about.ix}

    ${mixed(i.about.headline)}

    ${i.about.lead}

    ${i.about.cta_label} ${ARROW_OUT}
    ${i.about.side_note}
    ${i.about.caption.bold} ${i.about.caption.rest}
    `; } function renderCapabilityCard(c: CapabilityCard): string { return `
    ${c.num}${c.tag}
    ${c.icon_svg}

    ${br(c.title)}

    ${c.body}

    ${ARROW_OUT}
    `; } function renderCapabilities(i: EditorialCollageInputs): string { const cards = i.capabilities.cards.map(renderCapabilityCard).join('\n '); const assets = i.imagery.assets_path.replace(/\/?$/, '/'); return `
    ${renderSecRule(i.rules.capabilities).trim()}
    ${i.capabilities.ribbon}
    ${i.capabilities.label} ${i.capabilities.ix}

    ${mixed(i.capabilities.headline)}

    ${i.capabilities.lead}

    ${cards}
    `; } function renderLabPill(p: LabPill): string { return ``; } function renderLabCard(c: LabCard, n: number, assets: string): string { return `
    ${c.badge}
    ${c.num}${c.year}

    ${c.title}

    ${c.body}

    ${ARROW_OUT}
    `; } function renderLabs(i: EditorialCollageInputs): string { const pills = i.labs.pills.map(renderLabPill).join('\n '); const assets = i.imagery.assets_path.replace(/\/?$/, '/'); const cards = i.labs.cards .map((c, idx) => renderLabCard(c, idx + 1, assets)) .join('\n '); const progress = Array.from({ length: i.labs.progress.total }, (_, k) => k < i.labs.progress.filled ? `` : ``, ).join(''); return `
    ${renderSecRule(i.rules.labs).trim()}
    ${i.labs.label} ${i.labs.ix}

    ${mixed(i.labs.headline)}

    ${pills}
    ${i.labs.meta.ring}
    ${i.labs.meta.bold} ${i.labs.meta.sub}
    ${cards}
    ${progress}
    ${i.labs.foot}
    `; } function renderMethodStep(s: MethodStep, last: boolean, n: number, assets: string): string { return `
    ${s.num}

    ${s.title}${last ? '' : ` `}

    ${s.body}

    `; } function renderMethod(i: EditorialCollageInputs): string { const assets = i.imagery.assets_path.replace(/\/?$/, '/'); const steps = i.method.steps .map((s, idx, arr) => renderMethodStep(s, idx === arr.length - 1, idx + 1, assets)) .join('\n '); return `
    ${renderSecRule(i.rules.method).trim()}
    ${i.method.label} ${i.method.ix}

    ${mixed(i.method.headline)}

    +

    ${i.method.right}

    ${steps}
    ${i.method.foot_left}
    ${i.method.foot_right_bold}  ·  ${i.method.foot_right_rest}
    `; } function renderWorkCard(c: WorkCard, idx: number, assets: string, href: string): string { return `
    ${c.small_label} ${c.index}

    ${c.title}

    ${c.body}

    ${c.year} ${c.tag}
    `; } function renderWork(i: EditorialCollageInputs): string { const r = i.rules.work; const assets = i.imagery.assets_path.replace(/\/?$/, '/'); // Use the first nav link as the work-card href fallback (we don't model per-card hrefs in WorkCard). const fallbackHref = i.nav.find((l) => /skills/i.test(l.label))?.href ?? '#'; const cards = i.work.cards .map((c, idx) => renderWorkCard(c, idx, assets, fallbackHref)) .join('\n '); return `
    ${r.roman} ${r.meta[0]} ${r.meta[1]} ${r.meta[2]} ${r.pagination}
    ${i.work.label}

    ${mixed(i.work.headline)}

    ${i.work.link_label}
    ${cards}
    `; } function renderPartner(p: Partner, href: string): string { return `
    ${p.glyph_svg}
    ${p.name} ${p.role}
    `; } function renderTestimonial(i: EditorialCollageInputs): string { const assets = i.imagery.assets_path.replace(/\/?$/, '/'); // Each Partner can carry its own href. We fall back to the testimonial // read-more URL (then '#') so older brand inputs without per-partner // links still render valid anchors. const fallback = i.testimonial.read_more_href ?? '#'; const partners = i.testimonial.partners .map((p) => renderPartner(p, p.href ?? fallback)) .join('\n '); return `
    ${renderSecRule(i.rules.testimonial).trim()}
    ${i.testimonial.label} ${i.testimonial.ix}

    “${mixed(i.testimonial.quote)}”

    ${i.testimonial.author.initial}

    ${i.testimonial.author.name}
    ${i.testimonial.author.title}

    ${i.testimonial.partners_text}

    ${partners}
    ${i.testimonial.read_more_label}
    `; } function renderCTA(i: EditorialCollageInputs): string { const assets = i.imagery.assets_path.replace(/\/?$/, '/'); return `
    ${renderSecRule(i.rules.cta).trim()}
    ${i.cta.label} ${i.cta.ix}

    ${mixed(i.cta.headline)}

    ${i.cta.lead}

    ● Live ${i.brand.version} / ${i.brand.license} ${i.brand.coordinates}
    Nº 08
    ${i.cta.ribbon}
    `; } function renderFooterColumn(c: FooterColumn): string { const links = c.links .map((l) => `
  • ${l.label}
  • `) .join('\n '); return `
    ${c.title}
      ${links}
    `; } function renderFooter(i: EditorialCollageInputs): string { const cols = i.footer.columns.map(renderFooterColumn).join('\n '); // Resolve the footer brand CTA — explicit `footer.brand_cta` wins, // otherwise inherit `brand.download_url` so a single field lights up // both the nav and the footer download entry. const brandCta = i.footer.brand_cta ?? (i.brand.download_url ? { label: i.brand.download_url_label ?? 'Download desktop', href: i.brand.download_url, meta: i.brand.version, } : null); const brandCtaHtml = brandCta ? ` ${brandCta.label}${ brandCta.meta ? `${brandCta.meta}` : '' }` : ''; return `
    ${i.brand.mark} ${i.brand.name}

    ${i.footer.brand_description}

    ${brandCtaHtml}
    ${cols}
    ${i.brand.name} · ${i.brand.license} · ${i.brand.year} / ${i.brand.edition} ${i.brand.location} ${i.brand.coordinates} ♥ ${i.brand.year_roman}
    ${mixed(i.footer.mega)}
    `; } function renderWire(i: EditorialCollageInputs): string { const w = i.wire; if (!w || (w.cities.length === 0 && w.contributors.length === 0)) return ''; // Duplicate each list so the marquee CSS animation translates -50% // and lands seamlessly at the start of the second copy. const cityRow = [...w.cities, ...w.cities] .map( (c) => `·${c.coord}${c.name}`, ) .join('\n '); const contribRow = [...w.contributors, ...w.contributors] .map( (c) => `·@${c.handle}${c.role}`, ) .join('\n '); const subtitle = w.subtitle ?? `Open · ${w.cities.length} cities · ${Math.max(w.contributors.length - 1, 0)} contributors`; return `
    ${w.title} ${subtitle}
    ${contribRow}
    `; } /* ------------------------------------------------------------------ * * inline scripts (mirror apps/landing-page/app/_components/*) * ------------------------------------------------------------------ */ const REVEAL_AND_NAV_SCRIPT = ` `; const STAR_SCRIPT_TEMPLATE = (repo: string) => ` `; /* ------------------------------------------------------------------ * * top-level * ------------------------------------------------------------------ */ function repoFromUrl(url: string): string | null { const m = url.match(/github\.com\/([^/]+)\/([^/?#]+)/i); return m ? `${m[1]}/${m[2]}` : null; } export function renderPage(inputs: EditorialCollageInputs, css: string): string { const repo = repoFromUrl(inputs.brand.primary_url); const starScript = repo ? STAR_SCRIPT_TEMPLATE(repo) : ''; return [ ``, ``, renderHead(inputs, css), ``, renderRails(inputs), `
    `, renderTopbar(inputs), renderNav(inputs), renderHero(inputs), renderWire(inputs), renderAbout(inputs), renderCapabilities(inputs), renderLabs(inputs), renderMethod(inputs), renderWork(inputs), renderTestimonial(inputs), renderCTA(inputs), renderFooter(inputs), `
    `, REVEAL_AND_NAV_SCRIPT, starScript, ``, ``, ``, ].join('\n'); } async function main(): Promise { const [, , inputsArg, outputArg] = process.argv; if (!inputsArg || !outputArg) { console.error('Usage: npx tsx scripts/compose.ts '); process.exit(1); } const inputsPath = isAbsolute(inputsArg) ? inputsArg : resolve(process.cwd(), inputsArg); const outputPath = isAbsolute(outputArg) ? outputArg : resolve(process.cwd(), outputArg); const stylesPath = resolve(SKILL_ROOT, 'styles.css'); const [inputsRaw, css] = await Promise.all([ readFile(inputsPath, 'utf8'), readFile(stylesPath, 'utf8'), ]); const inputs = JSON.parse(inputsRaw) as EditorialCollageInputs; const html = renderPage(inputs, css); await mkdir(dirname(outputPath), { recursive: true }); await writeFile(outputPath, html, 'utf8'); console.log(`✓ wrote ${outputPath} (${(html.length / 1024).toFixed(1)} KB)`); } const isMain = import.meta.url === `file://${process.argv[1]}`; if (isMain) { main().catch((err) => { console.error(err); process.exit(1); }); }