first-commit
ci / Validate workspace (push) Has been cancelled
landing-page-ci / Validate landing page (push) Has been cancelled
landing-page-deploy / Deploy landing page (push) Has been cancelled
github-metrics / Generate repository metrics SVG (push) Has been cancelled
refresh-contributors-wall / Refresh contributors wall cache bust (push) Waiting to run
ci / Validate workspace (push) Has been cancelled
landing-page-ci / Validate landing page (push) Has been cancelled
landing-page-deploy / Deploy landing page (push) Has been cancelled
github-metrics / Generate repository metrics SVG (push) Has been cancelled
refresh-contributors-wall / Refresh contributors wall cache bust (push) Waiting to run
This commit is contained in:
@@ -0,0 +1,821 @@
|
||||
#!/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 <inputs.json> <output.html>
|
||||
*
|
||||
* 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 `<span class='dot'>${seg.text}</span>`;
|
||||
if (seg.em) return `<em>${seg.text}</em>`;
|
||||
return seg.text;
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
/** Newline → `<br/>` for multi-line headings/labels. */
|
||||
function br(s: string): string {
|
||||
return s.replace(/\n/g, '<br/>');
|
||||
}
|
||||
|
||||
/** 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 = `<svg viewBox='0 0 24 24'><path d='M5 19L19 5M19 5H8M19 5v11'/></svg>`;
|
||||
const ARROW_PLUS = `<svg viewBox='0 0 24 24'><circle cx='12' cy='12' r='9'/><path d='M9 12h6M12 9v6'/></svg>`;
|
||||
|
||||
/** 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 `<head>
|
||||
<meta charset='utf-8' />
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1' />
|
||||
<title>${i.brand.name} — ${i.brand.tagline}</title>
|
||||
<meta name='description' content='${i.brand.description}' />
|
||||
<link rel='preconnect' href='https://fonts.googleapis.com' />
|
||||
<link rel='preconnect' href='https://fonts.gstatic.com' crossorigin />
|
||||
<link href='https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;500;600;700;800;900&family=Inter:wght@300;400;500;600&family=Playfair+Display:ital,wght@0,500;0,600;1,400;1,500;1,600;1,700&family=JetBrains+Mono:wght@400;500&display=swap' rel='stylesheet' />
|
||||
<style>${css}${CODE_INLINE_CSS}</style>
|
||||
</head>`;
|
||||
}
|
||||
|
||||
function renderRails(i: EditorialCollageInputs): string {
|
||||
return `
|
||||
<div class='side-rail right' data-od-id='rail-right'>
|
||||
<span class='rail-text'>${i.brand.rails.right}</span>
|
||||
</div>
|
||||
<div class='side-rail left' data-od-id='rail-left'>
|
||||
<span class='rail-text'>${i.brand.rails.left}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderTopbar(i: EditorialCollageInputs): string {
|
||||
const langs = i.brand.languages
|
||||
.map((l, idx) => (idx === 0 ? `<b>${l}</b>` : l))
|
||||
.join(' · ');
|
||||
return `
|
||||
<div class='topbar' data-od-id='topbar'>
|
||||
<div class='container topbar-inner'>
|
||||
<span><b>OD / ${i.brand.year}</b> · ${i.brand.edition}</span>
|
||||
<span class='mid'>
|
||||
<span>Filed under <b class='coral'>${i.brand.filed_under}</b></span>
|
||||
<span>${i.brand.license} · Made on Earth</span>
|
||||
</span>
|
||||
<span class='right'>
|
||||
<a class='topbar-link' href='${i.brand.primary_url}/releases'${ext(i.brand.primary_url)}><span class='pulse'></span>${i.brand.status}</a>
|
||||
<span>${langs}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderNav(i: EditorialCollageInputs): string {
|
||||
const links = i.nav
|
||||
.map(
|
||||
(link) =>
|
||||
`<li><a href='${link.href}'${ext(link.href)}>${link.label}${
|
||||
link.count ? `<span class='num'>${link.count}</span>` : ''
|
||||
}</a></li>`,
|
||||
)
|
||||
.join('\n ');
|
||||
return `
|
||||
<header class='nav' data-od-id='nav'>
|
||||
<div class='container nav-inner'>
|
||||
<a href='#top' class='brand'>
|
||||
<span class='brand-mark'>${i.brand.mark}</span>
|
||||
<span>${i.brand.name}</span>
|
||||
<span class='brand-meta'><b>${i.brand.meta.title}</b>${i.brand.meta.subtitle}</span>
|
||||
</a>
|
||||
<nav>
|
||||
<ul class='nav-links'>
|
||||
${links}
|
||||
</ul>
|
||||
</nav>
|
||||
<div class='nav-side'>
|
||||
${
|
||||
i.brand.download_url
|
||||
? `<a class='nav-cta ghost' href='${i.brand.download_url}'${ext(i.brand.download_url)}>${i.brand.download_url_label ?? 'Download'}</a>
|
||||
`
|
||||
: ''
|
||||
}<a class='nav-cta' href='${i.brand.primary_url}'${ext(i.brand.primary_url)}>${i.brand.primary_url_label}</a>
|
||||
<span class='status-dot' aria-hidden='true'></span>
|
||||
</div>
|
||||
</div>
|
||||
</header>`;
|
||||
}
|
||||
|
||||
function renderSecRule(r: SectionRule): string {
|
||||
return `
|
||||
<div class='sec-rule'>
|
||||
<span class='roman'>${r.roman}</span>
|
||||
<span class='meta-grp'>
|
||||
<span>${r.meta[0]}</span>
|
||||
<span class='dot-mark'>${r.meta[1]}</span>
|
||||
<span>${r.meta[2]}</span>
|
||||
</span>
|
||||
<span>${r.pagination}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderHeroStat(s: HeroStat): string {
|
||||
const variant = s.variant ?? 'dashed';
|
||||
const ringClass = variant === 'solid' ? 'ring solid' : variant === 'coral' ? 'ring coral' : 'ring';
|
||||
return `<div class='stat'>
|
||||
<span class='${ringClass}'>${s.value}</span>
|
||||
<span class='stat-label'><b>${s.label}</b>${s.sub}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderHeroIndex(item: HeroIndexItem): string {
|
||||
return `<span${item.active ? ` class='on'` : ''}><span class='n'>${item.num}</span>${item.label}</span>`;
|
||||
}
|
||||
|
||||
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 `
|
||||
<section class='hero' id='top' data-od-id='hero'>
|
||||
<div class='container'>
|
||||
<div class='sec-rule'>
|
||||
<span class='roman'>I.</span>
|
||||
<span class='meta-grp'>
|
||||
<span>Hero / Cover Plate</span>
|
||||
<span class='dot-mark'>•</span>
|
||||
<span>${i.brand.name} / Volume 01</span>
|
||||
</span>
|
||||
<span>001 / 008</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class='container hero-grid'>
|
||||
<div class='hero-copy'>
|
||||
<span class='label' data-reveal>${i.hero.label} <span class='ix'>${i.hero.ix}</span></span>
|
||||
<h1 class='display' data-reveal>${mixed(i.hero.headline)}</h1>
|
||||
<p class='lead' data-reveal>${i.hero.lead}</p>
|
||||
<div class='hero-actions' data-reveal>
|
||||
<a class='btn btn-primary' href='${i.hero.primary.href}'${ext(i.hero.primary.href)}>
|
||||
${i.hero.primary.label}
|
||||
<span class='arrow'>${ARROW_OUT}</span>
|
||||
</a>
|
||||
<a class='btn btn-ghost' href='${i.hero.secondary.href}'${ext(i.hero.secondary.href)}>
|
||||
${i.hero.secondary.label}
|
||||
<span class='arrow'>${ARROW_PLUS}</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class='hero-stats' data-reveal>
|
||||
${stats}
|
||||
</div>
|
||||
<div class='hero-foot' data-reveal>
|
||||
<span class='meta'>${i.hero.meta}</span>
|
||||
<span class='coord'>${i.brand.coordinates}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class='hero-art' data-reveal='scale'>
|
||||
<span class='corner tl'></span>
|
||||
<span class='corner tr'></span>
|
||||
<span class='corner bl'></span>
|
||||
<span class='corner br'></span>
|
||||
<span class='annot annot-tl coord'>${i.hero.annotations.tl}</span>
|
||||
<span class='annot annot-tr'>${i.hero.annotations.tr}</span>
|
||||
<span class='annot annot-bl coord'>${i.hero.annotations.bl}</span>
|
||||
<span class='annot annot-br'>${i.hero.annotations.br}</span>
|
||||
<img src='${assets}hero.png' alt='' />
|
||||
<div class='index'>
|
||||
${index}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function renderAbout(i: EditorialCollageInputs): string {
|
||||
const r = i.rules.about;
|
||||
const assets = i.imagery.assets_path.replace(/\/?$/, '/');
|
||||
return `
|
||||
<section class='about' data-od-id='about'>
|
||||
<div class='container'>
|
||||
${renderSecRule(r).trim()}
|
||||
<div class='about-grid'>
|
||||
<div class='about-copy' data-reveal>
|
||||
<span class='label'>${i.about.label} <span class='ix'>${i.about.ix}</span></span>
|
||||
<h2 class='display'>${mixed(i.about.headline)}</h2>
|
||||
<p class='lead'>${i.about.lead}</p>
|
||||
<a class='btn btn-ghost' href='${i.about.cta_href}'${ext(i.about.cta_href)}>
|
||||
${i.about.cta_label}
|
||||
<span class='arrow'>${ARROW_OUT}</span>
|
||||
</a>
|
||||
<div class='footer-row'>
|
||||
<span class='mark'>${i.brand.mark}</span>
|
||||
<span>${i.about.footer_text}</span>
|
||||
<span class='stamp'>
|
||||
<span>${i.about.stamp_top}</span>
|
||||
<span style='color: var(--ink);'>${i.about.stamp_bottom}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class='about-art' data-reveal='right'>
|
||||
<img src='${assets}about.png' alt='' />
|
||||
<div class='about-side-note'>
|
||||
<b></b>
|
||||
${i.about.side_note}
|
||||
</div>
|
||||
<div class='about-caption'>
|
||||
<b>${i.about.caption.bold}</b>
|
||||
${i.about.caption.rest}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function renderCapabilityCard(c: CapabilityCard): string {
|
||||
return `<div class='card' data-reveal>
|
||||
<div class='num'>${c.num}<span class='tag'>${c.tag}</span></div>
|
||||
<svg class='icon' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='1.5'>
|
||||
${c.icon_svg}
|
||||
</svg>
|
||||
<h3>${br(c.title)}</h3>
|
||||
<p>${c.body}</p>
|
||||
<a class='arrow-mark' href='${c.href}'${ext(c.href)} aria-label='Learn more about ${c.tag}'>
|
||||
${ARROW_OUT}
|
||||
</a>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderCapabilities(i: EditorialCollageInputs): string {
|
||||
const cards = i.capabilities.cards.map(renderCapabilityCard).join('\n ');
|
||||
const assets = i.imagery.assets_path.replace(/\/?$/, '/');
|
||||
return `
|
||||
<section class='capabilities' id='agents' data-od-id='capabilities'>
|
||||
<div class='container'>
|
||||
${renderSecRule(i.rules.capabilities).trim()}
|
||||
<div class='capabilities-grid'>
|
||||
<div class='capabilities-art' data-reveal='left'>
|
||||
<span class='corner tl'></span>
|
||||
<span class='corner br'></span>
|
||||
<img src='${assets}capabilities.png' alt='' />
|
||||
<div class='ribbon'>${i.capabilities.ribbon}</div>
|
||||
</div>
|
||||
<div class='capabilities-copy' data-reveal>
|
||||
<span class='label'>${i.capabilities.label} <span class='ix'>${i.capabilities.ix}</span></span>
|
||||
<h2 class='display'>${mixed(i.capabilities.headline)}</h2>
|
||||
<p class='lead'>${i.capabilities.lead}</p>
|
||||
<div class='cards'>
|
||||
${cards}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function renderLabPill(p: LabPill): string {
|
||||
return `<button class='pill${p.active ? ' active' : ''}'>${p.label}<span class='count'>${p.count}</span></button>`;
|
||||
}
|
||||
|
||||
function renderLabCard(c: LabCard, n: number, assets: string): string {
|
||||
return `<div class='lab' data-reveal>
|
||||
<div class='lab-img'><span class='badge'>${c.badge}</span><img src='${assets}lab-${n}.png' alt='' /></div>
|
||||
<div class='num-row'><span>${c.num}</span><span>${c.year}</span></div>
|
||||
<h4>${c.title}</h4>
|
||||
<p>${c.body}</p>
|
||||
<a class='arrow-mark' href='${c.href}'${ext(c.href)} aria-label='Open ${c.title}'>${ARROW_OUT}</a>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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 ? `<span class='on'></span>` : `<span></span>`,
|
||||
).join('');
|
||||
return `
|
||||
<section class='labs' id='labs' data-od-id='labs'>
|
||||
<div class='container'>
|
||||
${renderSecRule(i.rules.labs).trim()}
|
||||
<div class='labs-head'>
|
||||
<div data-reveal>
|
||||
<span class='label'>${i.labs.label} <span class='ix'>${i.labs.ix}</span></span>
|
||||
<h2 class='display' style='margin-top:30px;'>${mixed(i.labs.headline)}</h2>
|
||||
</div>
|
||||
<div class='pills' data-reveal='right'>
|
||||
${pills}
|
||||
</div>
|
||||
</div>
|
||||
<div class='labs-meta'>
|
||||
<span class='ring'>${i.labs.meta.ring}</span>
|
||||
<div class='meta-text'>
|
||||
<b>${i.labs.meta.bold}</b>
|
||||
${i.labs.meta.sub}
|
||||
</div>
|
||||
</div>
|
||||
<div class='labs-grid'>
|
||||
${cards}
|
||||
</div>
|
||||
<div class='labs-foot'>
|
||||
<div class='progress'>
|
||||
${progress}
|
||||
</div>
|
||||
<span class='meta'>${i.labs.foot}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function renderMethodStep(s: MethodStep, last: boolean, n: number, assets: string): string {
|
||||
return `<div class='method-step' data-reveal>
|
||||
<div class='num'>${s.num}</div>
|
||||
<h4>${s.title}${last ? '' : ` <span class='arrow-r'>→</span>`}</h4>
|
||||
<p>${s.body}</p>
|
||||
<div class='img'><img src='${assets}method-${n}.png' alt='' /></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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 `
|
||||
<section class='method' data-od-id='method'>
|
||||
<div class='container'>
|
||||
${renderSecRule(i.rules.method).trim()}
|
||||
<div class='method-head'>
|
||||
<div data-reveal>
|
||||
<span class='label'>${i.method.label} <span class='ix'>${i.method.ix}</span></span>
|
||||
<h2 class='display' style='margin-top:30px;'>${mixed(i.method.headline)}</h2>
|
||||
</div>
|
||||
<div class='right' data-reveal='right'>
|
||||
<span class='plus'>+</span>
|
||||
<p>${i.method.right}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class='method-grid'>
|
||||
${steps}
|
||||
</div>
|
||||
<div class='method-foot'>
|
||||
<div class='left'>
|
||||
<span class='ring'></span>
|
||||
<span>${i.method.foot_left}</span>
|
||||
</div>
|
||||
<div class='right'><a class='method-repo-link' href='https://${i.method.foot_right_bold}'${ext('https://x')}><b>${i.method.foot_right_bold}</b></a> · ${i.method.foot_right_rest}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function renderWorkCard(c: WorkCard, idx: number, assets: string, href: string): string {
|
||||
return `<a class='work-card${idx === 1 ? ' alt' : ''}' data-reveal href='${href}'${ext(href)}>
|
||||
<div class='label-row'>
|
||||
<span class='small-label'>${c.small_label}</span>
|
||||
<span class='index'>${c.index}</span>
|
||||
</div>
|
||||
<h3>${c.title}</h3>
|
||||
<p>${c.body}</p>
|
||||
<div class='img'><img src='${assets}work-${idx + 1}.png' alt='' /></div>
|
||||
<div class='meta-row'>
|
||||
<span class='year'>${c.year}</span>
|
||||
<span>${c.tag}</span>
|
||||
</div>
|
||||
</a>`;
|
||||
}
|
||||
|
||||
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 `
|
||||
<section class='tight' data-od-id='work'>
|
||||
<div class='work'>
|
||||
<div class='work-rule'>
|
||||
<span class='roman'>${r.roman}</span>
|
||||
<span style='display:inline-flex;gap:24px;'>
|
||||
<span>${r.meta[0]}</span>
|
||||
<span style='color:var(--coral);'>${r.meta[1]}</span>
|
||||
<span>${r.meta[2]}</span>
|
||||
</span>
|
||||
<span>${r.pagination}</span>
|
||||
</div>
|
||||
<div class='work-grid'>
|
||||
<div class='work-copy' data-reveal>
|
||||
<span class='label'>${i.work.label}</span>
|
||||
<h2>${mixed(i.work.headline)}</h2>
|
||||
<a class='work-link' href='${i.work.link_href}'${ext(i.work.link_href)}>${i.work.link_label}</a>
|
||||
</div>
|
||||
${cards}
|
||||
</div>
|
||||
<div class='work-arrows'>
|
||||
<button class='nav-btn'><svg width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='1.6'><path d='M14 6l-6 6 6 6'/></svg></button>
|
||||
<button class='nav-btn active'><svg width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='1.6'><path d='M10 6l6 6-6 6'/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function renderPartner(p: Partner, href: string): string {
|
||||
return `<a class='partner' data-reveal href='${href}'${ext(href)}>
|
||||
<div class='glyph'>
|
||||
<svg viewBox='0 0 80 30' fill='none' stroke='currentColor' stroke-width='2'>
|
||||
${p.glyph_svg}
|
||||
</svg>
|
||||
</div>
|
||||
<span>${p.name}</span>
|
||||
<small>${p.role}</small>
|
||||
</a>`;
|
||||
}
|
||||
|
||||
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 `
|
||||
<section class='testimonial' data-od-id='testimonial'>
|
||||
<div class='container'>
|
||||
${renderSecRule(i.rules.testimonial).trim()}
|
||||
<div class='testimonial-grid'>
|
||||
<div class='testimonial-copy' data-reveal>
|
||||
<span class='label'>${i.testimonial.label} <span class='ix'>${i.testimonial.ix}</span></span>
|
||||
<h2 style='margin-top:30px;'>“${mixed(i.testimonial.quote)}”</h2>
|
||||
<div class='author'>
|
||||
<span class='avatar'>${i.testimonial.author.initial}</span>
|
||||
<p>${i.testimonial.author.name}<br/><span>${i.testimonial.author.title}</span></p>
|
||||
</div>
|
||||
<div class='divider'></div>
|
||||
<p class='partners-text'>${i.testimonial.partners_text}</p>
|
||||
<div class='partners'>
|
||||
${partners}
|
||||
</div>
|
||||
<a class='read-more' href='${i.testimonial.read_more_href}'${ext(i.testimonial.read_more_href)}>${i.testimonial.read_more_label}</a>
|
||||
</div>
|
||||
<div class='testimonial-art' data-reveal='right'>
|
||||
<img src='${assets}testimonial.png' alt='' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function renderCTA(i: EditorialCollageInputs): string {
|
||||
const assets = i.imagery.assets_path.replace(/\/?$/, '/');
|
||||
return `
|
||||
<section class='cta' id='contact' data-od-id='cta'>
|
||||
<div class='container'>
|
||||
${renderSecRule(i.rules.cta).trim()}
|
||||
<div class='cta-grid'>
|
||||
<div data-reveal>
|
||||
<span class='label'>${i.cta.label} <span class='ix'>${i.cta.ix}</span></span>
|
||||
<h2 class='display'>${mixed(i.cta.headline)}</h2>
|
||||
<p class='lead'>${i.cta.lead}</p>
|
||||
<div class='cta-actions'>
|
||||
<a class='btn btn-primary' href='${i.cta.primary.href}'${ext(i.cta.primary.href)}>
|
||||
${i.cta.primary.label}
|
||||
<span class='arrow'>${ARROW_OUT}</span>
|
||||
</a>
|
||||
<a class='email-pill' href='${i.brand.contact_email}'${ext(i.brand.contact_email)}>
|
||||
${/^mailto:/.test(i.brand.contact_email) ? i.brand.contact_email.replace(/^mailto:/, '') : 'Open an issue'}
|
||||
<span class='arrow-circle'>→</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class='cta-foot'>
|
||||
<span class='stamp'>● Live</span>
|
||||
<span>${i.brand.version} / ${i.brand.license}</span>
|
||||
<span style='margin-left:auto;'>${i.brand.coordinates}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class='cta-art' data-reveal='right'>
|
||||
<img src='${assets}cta.png' alt='' />
|
||||
<div class='index'>Nº 08</div>
|
||||
<div class='ribbon'>${i.cta.ribbon}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function renderFooterColumn(c: FooterColumn): string {
|
||||
const links = c.links
|
||||
.map((l) => `<li><a href='${l.href}'${ext(l.href)}>${l.label}</a></li>`)
|
||||
.join('\n ');
|
||||
return `<div class='foot-col'>
|
||||
<h5>${c.title}</h5>
|
||||
<ul>
|
||||
${links}
|
||||
</ul>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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
|
||||
? `
|
||||
<a class='foot-cta' href='${brandCta.href}'${ext(brandCta.href)}>${brandCta.label}${
|
||||
brandCta.meta ? `<span class='meta'>${brandCta.meta}</span>` : ''
|
||||
}</a>`
|
||||
: '';
|
||||
return `
|
||||
<footer data-od-id='footer'>
|
||||
<div class='container'>
|
||||
<div class='foot-grid'>
|
||||
<div class='foot-brand'>
|
||||
<a href='#top' class='brand'>
|
||||
<span class='brand-mark'>${i.brand.mark}</span>
|
||||
<span>${i.brand.name}</span>
|
||||
</a>
|
||||
<p style='margin-top:18px;'>${i.footer.brand_description}</p>${brandCtaHtml}
|
||||
</div>
|
||||
${cols}
|
||||
</div>
|
||||
<div class='foot-bottom'>
|
||||
<span><span class='pulse'></span>● <b style='color:var(--ink);'>${i.brand.name}</b> · ${i.brand.license} · ${i.brand.year} / ${i.brand.edition}</span>
|
||||
<span class='right'>
|
||||
<span>${i.brand.location}</span>
|
||||
<span>${i.brand.coordinates}</span>
|
||||
<span style='color:var(--coral);'>♥ ${i.brand.year_roman}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class='foot-mega'>
|
||||
<div class='word' data-reveal='rise-lg'>${mixed(i.footer.mega)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>`;
|
||||
}
|
||||
|
||||
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) =>
|
||||
`<span class='wire-item'><span class='wire-dot'>·</span><span class='wire-coord'>${c.coord}</span><span class='wire-name'>${c.name}</span></span>`,
|
||||
)
|
||||
.join('\n ');
|
||||
const contribRow = [...w.contributors, ...w.contributors]
|
||||
.map(
|
||||
(c) =>
|
||||
`<a class='wire-item is-link' href='${c.href}'${ext(c.href)} aria-label='Open ${c.handle} on GitHub'><span class='wire-dot'>·</span><span class='wire-handle'>@${c.handle}</span><span class='wire-role'>${c.role}</span></a>`,
|
||||
)
|
||||
.join('\n ');
|
||||
const subtitle =
|
||||
w.subtitle ??
|
||||
`Open · ${w.cities.length} cities · ${Math.max(w.contributors.length - 1, 0)} contributors`;
|
||||
return `
|
||||
<section class='wire' data-od-id='wire' aria-label='Global wire — cities and contributors'>
|
||||
<div class='container wire-inner'>
|
||||
<div class='wire-left'>
|
||||
<span class='wire-mark' aria-hidden='true'><span class='wire-pulse'></span></span>
|
||||
<span class='wire-title'>
|
||||
<b>${w.title}</b>
|
||||
<span>${subtitle}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class='wire-rows'>
|
||||
<div class='wire-row'>
|
||||
<div class='marquee-track' aria-hidden='true'>
|
||||
${cityRow}
|
||||
</div>
|
||||
</div>
|
||||
<div class='wire-row reverse'>
|
||||
<div class='marquee-track'>
|
||||
${contribRow}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* inline scripts (mirror apps/landing-page/app/_components/*)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
const REVEAL_AND_NAV_SCRIPT = `
|
||||
<script>
|
||||
/*
|
||||
* Scroll-reveal observer — mirrors apps/landing-page/app/_components/reveal-root.tsx.
|
||||
* Watches every [data-reveal] element and flips data-revealed='true'
|
||||
* when it first enters the viewport, triggering the CSS transition.
|
||||
*/
|
||||
(function () {
|
||||
var elements = document.querySelectorAll('[data-reveal]:not([data-revealed])');
|
||||
if (!elements.length) return;
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
for (var i = 0; i < elements.length; i++) elements[i].dataset.revealed = 'true';
|
||||
return;
|
||||
}
|
||||
var observer = new IntersectionObserver(function (entries) {
|
||||
for (var i = 0; i < entries.length; i++) {
|
||||
if (!entries[i].isIntersecting) continue;
|
||||
entries[i].target.dataset.revealed = 'true';
|
||||
observer.unobserve(entries[i].target);
|
||||
}
|
||||
}, { threshold: 0.12, rootMargin: '0px 0px -8% 0px' });
|
||||
for (var j = 0; j < elements.length; j++) observer.observe(elements[j]);
|
||||
})();
|
||||
|
||||
/*
|
||||
* Headroom-style sticky header — mirrors apps/landing-page/app/_components/header.tsx.
|
||||
* Hides the nav on downward scroll, re-pins it on upward scroll, and
|
||||
* always keeps it visible near the top of the page.
|
||||
*/
|
||||
(function () {
|
||||
var nav = document.querySelector('header.nav');
|
||||
if (!nav) return;
|
||||
var SHOW_TOP = 100;
|
||||
var DELTA = 6;
|
||||
var lastY = window.scrollY || 0;
|
||||
function onScroll() {
|
||||
var y = window.scrollY || 0;
|
||||
var d = y - lastY;
|
||||
if (y <= SHOW_TOP) {
|
||||
nav.classList.remove('is-hidden');
|
||||
} else if (d > DELTA) {
|
||||
nav.classList.add('is-hidden');
|
||||
} else if (d < -DELTA) {
|
||||
nav.classList.remove('is-hidden');
|
||||
}
|
||||
lastY = y;
|
||||
}
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
})();
|
||||
</script>`;
|
||||
|
||||
const STAR_SCRIPT_TEMPLATE = (repo: string) => `
|
||||
<script>
|
||||
/*
|
||||
* GitHub star count — pulls live count and replaces the placeholder
|
||||
* text in the nav CTA. Failures fall back silently.
|
||||
*/
|
||||
(function () {
|
||||
var cta = document.querySelector('a.nav-cta:not(.ghost)');
|
||||
if (!cta) return;
|
||||
function format(n) {
|
||||
if (!isFinite(n) || n <= 0) return '0';
|
||||
if (n < 1000) return String(n);
|
||||
var k = (n / 1000).toFixed(1).replace(/\\.0$/, '');
|
||||
return k + 'K';
|
||||
}
|
||||
fetch('https://api.github.com/repos/${repo}', {
|
||||
headers: { Accept: 'application/vnd.github+json' }
|
||||
})
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (data) {
|
||||
if (!data || typeof data.stargazers_count !== 'number') return;
|
||||
cta.textContent = 'Star · ' + format(data.stargazers_count);
|
||||
cta.setAttribute('aria-label', 'Star on GitHub — ' + format(data.stargazers_count) + ' stars');
|
||||
})
|
||||
.catch(function () { /* leave placeholder on failure */ });
|
||||
})();
|
||||
</script>`;
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 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 [
|
||||
`<!DOCTYPE html>`,
|
||||
`<html lang='${inputs.brand.locale ?? 'en'}'>`,
|
||||
renderHead(inputs, css),
|
||||
`<body>`,
|
||||
renderRails(inputs),
|
||||
`<div class='shell'>`,
|
||||
renderTopbar(inputs),
|
||||
renderNav(inputs),
|
||||
renderHero(inputs),
|
||||
renderWire(inputs),
|
||||
renderAbout(inputs),
|
||||
renderCapabilities(inputs),
|
||||
renderLabs(inputs),
|
||||
renderMethod(inputs),
|
||||
renderWork(inputs),
|
||||
renderTestimonial(inputs),
|
||||
renderCTA(inputs),
|
||||
renderFooter(inputs),
|
||||
`</div>`,
|
||||
REVEAL_AND_NAV_SCRIPT,
|
||||
starScript,
|
||||
`</body>`,
|
||||
`</html>`,
|
||||
``,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const [, , inputsArg, outputArg] = process.argv;
|
||||
if (!inputsArg || !outputArg) {
|
||||
console.error('Usage: npx tsx scripts/compose.ts <inputs.json> <output.html>');
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
#!/usr/bin/env -S npx -y tsx
|
||||
/**
|
||||
* open-design-landing — gpt-image-2 generator (fal.ai backend).
|
||||
*
|
||||
* Generates the 16 collage assets defined in `assets/image-manifest.json`
|
||||
* by composing per-slot prompts (style anchor + brand variables +
|
||||
* per-slot composition) and calling fal.ai's `openai/gpt-image-2`
|
||||
* synchronous endpoint. Downloads each result to the `--out` directory.
|
||||
*
|
||||
* Requires `FAL_KEY` in the environment. If it is missing, the script
|
||||
* prints the prompts it would have sent so an operator can route them
|
||||
* through the `/gpt-image-fal` skill manually, or set the key and re-run.
|
||||
*
|
||||
* Usage:
|
||||
* FAL_KEY=... npx tsx scripts/imagegen.ts <inputs.json> [--out=assets/] [--only=hero,cta]
|
||||
*
|
||||
* Cost note: 16 images × ~$0.025 each ≈ $0.40 per full run at high
|
||||
* quality. Re-running is idempotent — slots whose target file already
|
||||
* exists are skipped unless `--force` is passed.
|
||||
*/
|
||||
|
||||
import { readFile, writeFile, mkdir, stat } from 'node:fs/promises';
|
||||
import { resolve, dirname, isAbsolute } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { EditorialCollageInputs } from '../schema';
|
||||
|
||||
const SKILL_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
interface ManifestSlot {
|
||||
id: string;
|
||||
file: string;
|
||||
width: number;
|
||||
height: number;
|
||||
ratio: string;
|
||||
prompt_section: string;
|
||||
required: boolean;
|
||||
rekey_on_brand_change: boolean;
|
||||
}
|
||||
interface Manifest { slots: ManifestSlot[] }
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* prompt constants (mirror assets/imagegen-prompts.md verbatim)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
const STYLE_ANCHOR = `Use case: ads-marketing
|
||||
|
||||
Asset type: editorial website hero / creative studio landing page visual
|
||||
|
||||
Primary request: Generate a refined editorial web page composition in the
|
||||
same visual language as a high-end creative AI research studio.
|
||||
|
||||
Style/medium: sophisticated digital collage, modern Swiss editorial layout,
|
||||
Bauhaus geometric composition, classical plaster sculpture fragments,
|
||||
brutalist/minimal architecture, art-direction website mockup, premium
|
||||
agency aesthetic.
|
||||
|
||||
Scene/backdrop: warm off-white handmade paper background with subtle
|
||||
grain, faint vertical folds, scanned paper fibers, lightly aged print
|
||||
texture, thin drafting lines and registration marks.
|
||||
|
||||
Subject: a surreal collage combining a cropped classical plaster head or
|
||||
face fragment, abstract architectural blocks, archways or stairs, sky
|
||||
cutouts, one small human figure, a delicate tree or botanical element,
|
||||
and geometric color planes.
|
||||
|
||||
Composition/framing: wide 16:9 web page layout, strong asymmetrical
|
||||
grid, generous negative space, large typography area on the left or
|
||||
top-left, collage focal object on the right or center-right, precise
|
||||
alignment, thin divider lines, small UI navigation details.
|
||||
|
||||
Lighting/mood: soft diffused daylight, museum-like calm, intelligent,
|
||||
restrained, tactile, poetic, premium, research-driven.
|
||||
|
||||
Color palette: warm ivory, stone beige, soft concrete gray, deep black
|
||||
text, muted charcoal, washed coral-red accent, occasional mustard-yellow
|
||||
accent, pale sky blue only inside small sky/image cutouts.
|
||||
|
||||
Materials/textures: matte plaster, limestone, travertine, concrete, rough
|
||||
torn paper edges, halftone print grain, translucent vellum-like overlays,
|
||||
fine grid paper, dotted matrix patterns.
|
||||
|
||||
Typography: large clean grotesk sans-serif for main headline, elegant
|
||||
high-contrast italic serif for emphasized words, tiny uppercase coral
|
||||
labels, compact UI microcopy. Text must be crisp, readable, and spelled
|
||||
exactly as provided.
|
||||
|
||||
Graphic details: thin hairline circles, partial arcs, crosshair marks,
|
||||
small black dots, dotted grids, fine coordinate lines, numbered
|
||||
annotations, small arrow buttons, simple pill buttons, minimal logo mark.
|
||||
|
||||
Constraints: preserve a high-end editorial web design feel; keep spacing
|
||||
elegant and uncluttered; no cartoon style; no neon colors; no glossy 3D;
|
||||
no busy gradients; no generic stock-photo look.
|
||||
|
||||
Avoid: distorted typography, misspelled text, extra random words, heavy
|
||||
shadows, childish illustration, cyberpunk, saturated purple/blue palette,
|
||||
plastic materials, overly decorative UI cards, cluttered composition,
|
||||
low-resolution textures, watermarks.`;
|
||||
|
||||
const PER_SLOT: Record<string, string> = {
|
||||
hero: `Composition/framing: left half is intentionally empty/quiet to allow real
|
||||
HTML headline overlay; right half holds a tall surreal collage of a
|
||||
cropped classical plaster head with the top sliced open, sky/architecture
|
||||
cutouts visible inside the head, a delicate young tree growing through
|
||||
the composition, a coral sun disk behind, a mustard accent ring at the
|
||||
base, hairline coordinate marks and dotted matrices around it, a small
|
||||
human figure standing for scale in the lower-left of the image. Page
|
||||
type: hero landing.`,
|
||||
about: `Composition: a surreal museum-vitrine arrangement of a partial plaster
|
||||
profile head facing right, with an open archway carved through the
|
||||
torso, sky cutout inside the arch, a tree seedling growing out of the
|
||||
shoulder, and a coral half-circle behind the head. Tiny dotted hairlines
|
||||
trace contours. Strong negative space top-left for a side-note overlay.
|
||||
Page type: about / manifesto plate.`,
|
||||
capabilities: `Composition: a Bauhaus-grid stack of architectural fragments — a coral
|
||||
arch on the left, a beige concrete column center, a mustard small disc
|
||||
upper-right, a delicate tree mid-frame, a small classical hand fragment
|
||||
holding a pencil bottom-center. Crosshair and circular hairlines
|
||||
overlay. Page type: capabilities matrix.`,
|
||||
'method-1': `Composition: a magnifying glass over a small architectural map. Coral
|
||||
accent disc behind. One numbered annotation tag '01 · Detect'.
|
||||
Page type: method tile.`,
|
||||
'method-2': `Composition: a clipboard with a tiny questionnaire and a coral pen,
|
||||
on the warm paper ground. Mustard sticker corner. Annotation '02 ·
|
||||
Discover'. Page type: method tile.`,
|
||||
'method-3': `Composition: a compass + ruler + color swatch fan arranged like an
|
||||
architect's drafting kit. Coral accent on the swatch. Annotation
|
||||
'03 · Direct'. Page type: method tile.`,
|
||||
'method-4': `Composition: a printer's tray with stacked paper sheets exiting,
|
||||
mustard ribbon tag. Annotation '04 · Deliver'. Page type: method tile.`,
|
||||
'lab-1': `Portrait composition: a stack of folded magazine spreads, slight
|
||||
perspective, coral spine, mustard tab. Page type: lab card.`,
|
||||
'lab-2': `Portrait composition: a film strip + a synthetic eye + a soundwave
|
||||
hairline. Coral arc behind. Page type: lab card.`,
|
||||
'lab-3': `Portrait composition: a typewriter with prompt cards in the carriage,
|
||||
coral platen knob. Page type: lab card.`,
|
||||
'lab-4': `Portrait composition: five small dotted gauges arranged in a circle
|
||||
(5-dim critique), one filled coral. Page type: lab card.`,
|
||||
'lab-5': `Portrait composition: a glass dome / cloche over a tiny sandbox
|
||||
cityscape, mustard sun behind. Page type: lab card.`,
|
||||
'work-1': `Portrait composition: an oversized open magazine spread on a desk,
|
||||
coral spine, mustard tab. Slight perspective. Page type: work card.`,
|
||||
'work-2': `Portrait composition: a concrete dashboard slab, a coral graph bar
|
||||
rising, a small classical bust beside it for scale. Page type: work card.`,
|
||||
testimonial: `Composition: a classical plaster bust facing 3/4 left, slightly cropped,
|
||||
with a small sky cutout where the eye would be, a thin coral arc around
|
||||
the back of the head, mustard dot at the chin. Quiet background, lots of
|
||||
negative space upper right. Page type: testimonial portrait.`,
|
||||
cta: `Composition: a closing-plate collage — a mustard sun behind a single
|
||||
coral arch on the right, a delicate tree growing through the arch, a
|
||||
small human figure in the lower-left foreground reading a folded
|
||||
broadsheet, hairline coordinate ladder up the left edge, and a small
|
||||
"FIN." dotted seal in the upper-right. Page type: closing CTA plate.`,
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* prompt builder
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
function brandVarsBlock(inputs: EditorialCollageInputs): string {
|
||||
// Pull the brand-shaped strings the model should bias toward.
|
||||
const navText = inputs.nav.slice(0, 5).map((n) => `"${n.label}"`).join(', ');
|
||||
const eyebrow = `${inputs.hero.label} ${inputs.hero.ix}`;
|
||||
const headline = inputs.hero.headline.map((s) => s.text).join('');
|
||||
const italic = inputs.hero.headline.filter((s) => s.em).map((s) => `"${s.text}"`).join(', ');
|
||||
const body = inputs.hero.lead.replace(/<[^>]+>/g, '').replace(/&[^;]+;/g, '');
|
||||
return `Brand/logo text: "${inputs.brand.name}"
|
||||
Navigation text: ${navText}
|
||||
Eyebrow label: "${eyebrow}"
|
||||
Main headline: "${headline}"
|
||||
Italic emphasis words: ${italic}
|
||||
Body copy: "${body}"
|
||||
Primary button: "${inputs.hero.primary.label}"
|
||||
Secondary button: "${inputs.hero.secondary.label}"
|
||||
Footer/micro labels: "${inputs.brand.location}", "${inputs.brand.coordinates}"`;
|
||||
}
|
||||
|
||||
export function promptForSlot(slot: ManifestSlot, inputs: EditorialCollageInputs): string {
|
||||
const override = inputs.imagery.prompts?.[slot.id];
|
||||
const composition = override ?? PER_SLOT[slot.id] ?? `Page type: ${slot.id} plate.`;
|
||||
return [STYLE_ANCHOR, brandVarsBlock(inputs), composition].join('\n\n');
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* fal.ai client (raw fetch — no npm dependency)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
interface FalImageResult {
|
||||
images: Array<{ url: string; width?: number; height?: number; content_type?: string }>;
|
||||
}
|
||||
|
||||
async function callFalGptImage(
|
||||
prompt: string,
|
||||
width: number,
|
||||
height: number,
|
||||
apiKey: string,
|
||||
): Promise<Uint8Array> {
|
||||
// fal.ai exposes both queue (async) and run (sync) endpoints. Use sync
|
||||
// for simpler scripting; per-image latency is ~25-45s.
|
||||
const endpoint = 'https://fal.run/openai/gpt-image-2';
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Key ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
prompt,
|
||||
image_size: { width, height },
|
||||
num_images: 1,
|
||||
quality: 'high',
|
||||
output_format: 'png',
|
||||
background: 'opaque',
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '<unreadable>');
|
||||
throw new Error(`fal.run/openai/gpt-image-2 ${res.status}: ${text.slice(0, 400)}`);
|
||||
}
|
||||
const json = (await res.json()) as FalImageResult;
|
||||
const url = json.images?.[0]?.url;
|
||||
if (!url) throw new Error('fal.ai response missing images[0].url');
|
||||
const dl = await fetch(url);
|
||||
if (!dl.ok) throw new Error(`download ${url} failed: ${dl.status}`);
|
||||
const buf = await dl.arrayBuffer();
|
||||
return new Uint8Array(buf);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* top-level
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
interface CliArgs {
|
||||
inputsPath: string;
|
||||
outDir: string;
|
||||
only?: Set<string>;
|
||||
force: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliArgs {
|
||||
const inputsPath = argv[2];
|
||||
if (!inputsPath || inputsPath.startsWith('--')) {
|
||||
throw new Error('Usage: imagegen.ts <inputs.json> [--out=assets/] [--only=hero,cta] [--force]');
|
||||
}
|
||||
let outDir = './assets/';
|
||||
let only: Set<string> | undefined;
|
||||
let force = false;
|
||||
for (const arg of argv.slice(3)) {
|
||||
if (arg.startsWith('--out=')) outDir = arg.slice('--out='.length);
|
||||
else if (arg.startsWith('--only=')) only = new Set(arg.slice('--only='.length).split(','));
|
||||
else if (arg === '--force') force = true;
|
||||
else throw new Error(`unknown arg: ${arg}`);
|
||||
}
|
||||
return {
|
||||
inputsPath: isAbsolute(inputsPath) ? inputsPath : resolve(process.cwd(), inputsPath),
|
||||
outDir: isAbsolute(outDir) ? outDir : resolve(process.cwd(), outDir),
|
||||
only,
|
||||
force,
|
||||
};
|
||||
}
|
||||
|
||||
async function fileExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
const s = await stat(path);
|
||||
return s.isFile() && s.size > 256;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const { inputsPath, outDir, only, force } = parseArgs(process.argv);
|
||||
const apiKey = process.env.FAL_KEY ?? '';
|
||||
const dryRun = !apiKey;
|
||||
|
||||
const inputs = JSON.parse(await readFile(inputsPath, 'utf8')) as EditorialCollageInputs;
|
||||
const manifestPath = resolve(SKILL_ROOT, 'assets', 'image-manifest.json');
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as Manifest;
|
||||
await mkdir(outDir, { recursive: true });
|
||||
|
||||
const targets = manifest.slots.filter((s) => !only || only.has(s.id));
|
||||
if (dryRun) {
|
||||
console.log(`FAL_KEY not set — dry run. Printing prompts for ${targets.length} slot(s).\n`);
|
||||
} else {
|
||||
console.log(`Generating ${targets.length} slot(s) → ${outDir}`);
|
||||
}
|
||||
|
||||
for (const slot of targets) {
|
||||
const target = resolve(outDir, slot.file);
|
||||
if (!force && (await fileExists(target))) {
|
||||
console.log(`· ${slot.id} — skip (exists)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const prompt = promptForSlot(slot, inputs);
|
||||
if (dryRun) {
|
||||
console.log(`\n=== ${slot.id} (${slot.width}×${slot.height}) → ${slot.file} ===`);
|
||||
console.log(prompt);
|
||||
console.log(`=== end ${slot.id} ===\n`);
|
||||
continue;
|
||||
}
|
||||
|
||||
process.stdout.write(`· ${slot.id} (${slot.width}×${slot.height}) … `);
|
||||
try {
|
||||
const png = await callFalGptImage(prompt, slot.width, slot.height, apiKey);
|
||||
await writeFile(target, png);
|
||||
console.log(`ok (${(png.byteLength / 1024).toFixed(0)} KB)`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.log(`fail — ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`\nNext: set FAL_KEY in env and re-run to generate, or paste each prompt block into /gpt-image-fal manually.`);
|
||||
}
|
||||
}
|
||||
|
||||
const isMain = import.meta.url === `file://${process.argv[1]}`;
|
||||
if (isMain) {
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env -S npx -y tsx
|
||||
/**
|
||||
* open-design-landing — SVG framework placeholder generator.
|
||||
*
|
||||
* When `imagery.strategy === 'placeholder'`, this script writes one
|
||||
* paper-textured SVG file per slot in `assets/image-manifest.json`.
|
||||
* The generated files live alongside the schema-named PNGs that the
|
||||
* composer references (`hero.png`, `about.png`, `lab-1.png`, …) so
|
||||
* the layout renders fully without any image budget.
|
||||
*
|
||||
* Each placeholder shows: slot id · ratio · pixel dimensions · the
|
||||
* `prompt_section` hint copied from the manifest. Drop the real PNG
|
||||
* with the same filename to swap in production imagery; no markup
|
||||
* change required.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/placeholder.ts <out-dir>
|
||||
*
|
||||
* Default out-dir is `./assets/`.
|
||||
*/
|
||||
|
||||
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||
import { resolve, dirname, isAbsolute, basename } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const SKILL_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
interface ManifestSlot {
|
||||
id: string;
|
||||
file: string;
|
||||
width: number;
|
||||
height: number;
|
||||
ratio: string;
|
||||
prompt_section: string;
|
||||
required: boolean;
|
||||
rekey_on_brand_change: boolean;
|
||||
}
|
||||
|
||||
interface Manifest {
|
||||
skill: string;
|
||||
design_system: string;
|
||||
slots: ManifestSlot[];
|
||||
}
|
||||
|
||||
const PAPER = '#efe7d2';
|
||||
const INK_FAINT = '#8b8676';
|
||||
const CORAL = '#ed6f5c';
|
||||
const LINE = 'rgba(21, 20, 15, 0.16)';
|
||||
|
||||
/** Compose a single paper-textured SVG for one slot. */
|
||||
export function placeholderSvg(slot: ManifestSlot): string {
|
||||
const w = slot.width;
|
||||
const h = slot.height;
|
||||
const cx = w / 2;
|
||||
const cy = h / 2;
|
||||
const isPortrait = h > w;
|
||||
const titleSize = Math.round(Math.min(w, h) * (isPortrait ? 0.075 : 0.07));
|
||||
const metaSize = Math.round(Math.min(w, h) * 0.028);
|
||||
const dimsSize = Math.round(Math.min(w, h) * 0.024);
|
||||
|
||||
// Inner frame inset.
|
||||
const inset = Math.round(Math.min(w, h) * 0.04);
|
||||
const frame = {
|
||||
x: inset,
|
||||
y: inset,
|
||||
w: w - inset * 2,
|
||||
h: h - inset * 2,
|
||||
};
|
||||
|
||||
// Diagonal strokes for the classic "image goes here" cross.
|
||||
const cross = `
|
||||
<line x1='${frame.x}' y1='${frame.y}' x2='${frame.x + frame.w}' y2='${frame.y + frame.h}' stroke='${INK_FAINT}' stroke-opacity='0.22' stroke-width='1' />
|
||||
<line x1='${frame.x + frame.w}' y1='${frame.y}' x2='${frame.x}' y2='${frame.y + frame.h}' stroke='${INK_FAINT}' stroke-opacity='0.22' stroke-width='1' />
|
||||
`;
|
||||
|
||||
const cornerLen = Math.round(Math.min(w, h) * 0.05);
|
||||
const corners = `
|
||||
<path d='M${frame.x} ${frame.y + cornerLen} L${frame.x} ${frame.y} L${frame.x + cornerLen} ${frame.y}' stroke='${INK_FAINT}' fill='none' stroke-width='1.5' />
|
||||
<path d='M${frame.x + frame.w - cornerLen} ${frame.y} L${frame.x + frame.w} ${frame.y} L${frame.x + frame.w} ${frame.y + cornerLen}' stroke='${INK_FAINT}' fill='none' stroke-width='1.5' />
|
||||
<path d='M${frame.x} ${frame.y + frame.h - cornerLen} L${frame.x} ${frame.y + frame.h} L${frame.x + cornerLen} ${frame.y + frame.h}' stroke='${INK_FAINT}' fill='none' stroke-width='1.5' />
|
||||
<path d='M${frame.x + frame.w - cornerLen} ${frame.y + frame.h} L${frame.x + frame.w} ${frame.y + frame.h} L${frame.x + frame.w} ${frame.y + frame.h - cornerLen}' stroke='${INK_FAINT}' fill='none' stroke-width='1.5' />
|
||||
`;
|
||||
|
||||
return `<?xml version='1.0' encoding='UTF-8'?>
|
||||
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 ${w} ${h}' width='${w}' height='${h}'>
|
||||
<defs>
|
||||
<filter id='paper'>
|
||||
<feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/>
|
||||
<feColorMatrix values='0 0 0 0 0.18 0 0 0 0 0.16 0 0 0 0 0.12 0 0 0 0.07 0'/>
|
||||
</filter>
|
||||
</defs>
|
||||
<!-- paper base -->
|
||||
<rect width='${w}' height='${h}' fill='${PAPER}' />
|
||||
<rect width='${w}' height='${h}' filter='url(#paper)' />
|
||||
<!-- frame -->
|
||||
<rect x='${frame.x}' y='${frame.y}' width='${frame.w}' height='${frame.h}' fill='none' stroke='${LINE}' stroke-dasharray='6 6' />
|
||||
${cross}
|
||||
${corners}
|
||||
<!-- coral plate index, top-left -->
|
||||
<text x='${inset + 14}' y='${inset + 26}' font-family='Inter Tight, system-ui, sans-serif' font-size='${dimsSize}' font-weight='600' letter-spacing='2' fill='${CORAL}'>PLATE · ${slot.id.toUpperCase()}</text>
|
||||
<!-- coordinates, top-right -->
|
||||
<text x='${w - inset - 14}' y='${inset + 26}' text-anchor='end' font-family='JetBrains Mono, monospace' font-size='${dimsSize}' fill='${INK_FAINT}'>${w} × ${h} · ${slot.ratio}</text>
|
||||
<!-- centered title block -->
|
||||
<text x='${cx}' y='${cy - titleSize * 0.2}' text-anchor='middle' font-family='Playfair Display, serif' font-style='italic' font-weight='500' font-size='${titleSize}' fill='#15140f'>${escapeXml(slot.id)}</text>
|
||||
<text x='${cx}' y='${cy + metaSize * 1.6}' text-anchor='middle' font-family='Inter Tight, system-ui, sans-serif' font-size='${metaSize}' letter-spacing='3' fill='${INK_FAINT}'>${escapeXml(slot.prompt_section.toUpperCase())}</text>
|
||||
<!-- bottom slug -->
|
||||
<text x='${inset + 14}' y='${h - inset - 14}' font-family='Inter Tight, system-ui, sans-serif' font-size='${dimsSize}' letter-spacing='2' fill='${INK_FAINT}'>${slot.required ? 'REQUIRED' : 'OPTIONAL'} · ${slot.rekey_on_brand_change ? 'REKEY ON BRAND' : 'STABLE'}</text>
|
||||
<text x='${w - inset - 14}' y='${h - inset - 14}' text-anchor='end' font-family='Inter Tight, system-ui, sans-serif' font-size='${dimsSize}' letter-spacing='2' fill='${INK_FAINT}'>OPEN DESIGN · ATELIER ZERO</text>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function escapeXml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
async function loadManifest(): Promise<Manifest> {
|
||||
const path = resolve(SKILL_ROOT, 'assets', 'image-manifest.json');
|
||||
return JSON.parse(await readFile(path, 'utf8')) as Manifest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write `<out>/<slot.file>` for every slot. The composer references
|
||||
* slots by .png filename; we honor that by writing `<basename>.svg`
|
||||
* AND a `<basename>.png.svg` symlink-style fallback. Most static
|
||||
* hosts serve SVG to <img> just fine, so the practical convention
|
||||
* is: if you want placeholders, point your `imagery.assets_path` at
|
||||
* a directory of `.svg` files OR rename the SVGs to `.png` (some
|
||||
* browsers honor extensionless content-sniffing).
|
||||
*
|
||||
* For the most reliable result, write BOTH:
|
||||
* - `<id>.svg` — clean, editable
|
||||
* - `<file>` — same SVG content under the .png filename so the
|
||||
* composer's `<img src='./assets/<id>.png'>` works
|
||||
* without changing markup.
|
||||
*/
|
||||
export async function writePlaceholders(outDir: string): Promise<string[]> {
|
||||
const manifest = await loadManifest();
|
||||
await mkdir(outDir, { recursive: true });
|
||||
const written: string[] = [];
|
||||
for (const slot of manifest.slots) {
|
||||
const svg = placeholderSvg(slot);
|
||||
const svgPath = resolve(outDir, `${slot.id}.svg`);
|
||||
const pngPath = resolve(outDir, slot.file);
|
||||
await writeFile(svgPath, svg, 'utf8');
|
||||
await writeFile(pngPath, svg, 'utf8');
|
||||
written.push(svgPath, pngPath);
|
||||
}
|
||||
return written;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const [, , outArg] = process.argv;
|
||||
const out = isAbsolute(outArg ?? '')
|
||||
? outArg!
|
||||
: resolve(process.cwd(), outArg ?? './assets/');
|
||||
const written = await writePlaceholders(out);
|
||||
const pngs = written.filter((p) => p.endsWith('.png')).length;
|
||||
const svgs = written.filter((p) => p.endsWith('.svg')).length;
|
||||
console.log(`✓ wrote ${pngs} png-named placeholders + ${svgs} svg files into ${out}`);
|
||||
console.log(` (${written.map((p) => basename(p)).join(', ')})`);
|
||||
}
|
||||
|
||||
const isMain = import.meta.url === `file://${process.argv[1]}`;
|
||||
if (isMain) {
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user