/** * scaffold-vitrine/generate.ts — PURE spec -> GeneratedFile[] (zero I/O). * * Emits, into web/{appCode}-web/src/: * - vitrine/sections/*.tsx reusable presentational blocks (SDK theme tokens + lucide) * - vitrine/pages/*.tsx composed pages (home override + presentation pages) * - vitrine/locales/{lang}/vitrine.json i18n copy (namespace `vitrine`) * - extensions/vitrine.generated.ts wiring (PublicRouteRegistry + addClientResources + vitrineExtensions) * * The page<->i18n key contract is generated in a SINGLE pass (renderSection emits * both the JSX `t('scope.sN.field')` calls and the matching JSON), so the two can * never drift. Components use the SDK theme.css tokens (--text-secondary, * --bg-card, --border-color, primary/accent color utilities, gradient-text, * hover-card, animated-gradient) — the vitrine renders inside the SDK public shell. */ import type { GeneratedFile, PageSpec, ProjectLayout, ScaffoldVitrineInput, Section, } from './types.js'; // ── lucide-react icon allowlist (all present in ^0.562) ────────────────────── const LUCIDE_ALLOWLIST = new Set([ 'Sparkles', 'Zap', 'Shield', 'ShieldCheck', 'Lock', 'Rocket', 'Users', 'UserPlus', 'Bell', 'Star', 'Heart', 'Check', 'CheckCircle', 'BadgeCheck', 'Globe', 'Layers', 'Settings', 'Cpu', 'Cloud', 'Database', 'Code', 'Gauge', 'LineChart', 'BarChart3', 'PieChart', 'TrendingUp', 'Activity', 'Mail', 'MessageSquare', 'Phone', 'MapPin', 'Map', 'Calendar', 'Clock', 'Award', 'Briefcase', 'Building', 'Building2', 'Factory', 'Store', 'ShoppingCart', 'CreditCard', 'Wallet', 'Coins', 'Receipt', 'Package', 'Truck', 'Wrench', 'Palette', 'Smartphone', 'Monitor', 'Server', 'Wifi', 'Key', 'Eye', 'Search', 'Filter', 'Target', 'Lightbulb', 'Puzzle', 'Workflow', 'Boxes', 'Component', 'Blocks', 'Layout', 'PenTool', 'Image', 'FileText', 'Folder', 'Bookmark', 'Tag', 'Gift', 'Flag', 'Compass', 'Headphones', 'LifeBuoy', 'Megaphone', 'Share2', 'ThumbsUp', ]); function normalizeIcon(name?: string): string { if (name && LUCIDE_ALLOWLIST.has(name)) return name; return 'Sparkles'; } const SECTION_COMPONENT: Record = { hero: 'Hero', features: 'FeatureGrid', split: 'SplitFeature', cta: 'CtaBanner', faq: 'Faq', logos: 'LogoStrip', }; /** JSON string literal for embedding in generated source. */ const s = (v: string): string => JSON.stringify(v); // ============================================================================ // Static section components (reusable, prop-driven, theme-token styled) // ============================================================================ const BANNER = '/* Scaffolded by /site-vitrine — editable. Re-running keeps your edits (use force to overwrite). */\n'; const HERO_TSX = `${BANNER}import type { ReactElement } from 'react'; import { Sparkles, ArrowRight, Check } from 'lucide-react'; export interface HeroCta { label: string; href: string; } export interface HeroProps { badge?: string; headline: string; highlight?: string; subhead?: string; primaryCta?: HeroCta; secondaryCta?: HeroCta; bullets?: string[]; } export function Hero({ badge, headline, highlight, subhead, primaryCta, secondaryCta, bullets }: HeroProps): ReactElement { return (
{badge ? (
{badge}
) : null}

{headline} {highlight ? ( <>
{highlight} ) : null}

{subhead ? (

{subhead}

) : null} {primaryCta || secondaryCta ? (
{primaryCta ? ( {primaryCta.label} ) : null} {secondaryCta ? ( {secondaryCta.label} ) : null}
) : null} {bullets && bullets.length > 0 ? (
{bullets.map((b) => (
{b}
))}
) : null}
); } `; const FEATUREGRID_TSX = `${BANNER}import type { ComponentType, ReactElement } from 'react'; export interface FeatureItem { icon: ComponentType<{ className?: string }>; title: string; description: string; } export interface FeatureGridProps { title?: string; subtitle?: string; items: FeatureItem[]; } export function FeatureGrid({ title, subtitle, items }: FeatureGridProps): ReactElement { return (
{title || subtitle ? (
{title ?

{title}

: null} {subtitle ?

{subtitle}

: null}
) : null}
{items.map((item) => { const Icon = item.icon; return (

{item.title}

{item.description}

); })}
); } `; const SPLITFEATURE_TSX = `${BANNER}import type { ReactElement } from 'react'; import { Check } from 'lucide-react'; export interface SplitCta { label: string; href: string; } export interface SplitFeatureProps { title: string; highlight?: string; body?: string; bullets?: string[]; side?: 'left' | 'right'; cta?: SplitCta; } export function SplitFeature({ title, highlight, body, bullets, side = 'left', cta }: SplitFeatureProps): ReactElement { const visual = (
); const content = (

{title} {highlight ? ( <>
{highlight} ) : null}

{body ?

{body}

: null} {bullets && bullets.length > 0 ? (
    {bullets.map((b) => (
  • {b}
  • ))}
) : null} {cta ? ( {cta.label} ) : null}
); return (
{side === 'right' ? ( <> {content} {visual} ) : ( <> {visual} {content} )}
); } `; const CTABANNER_TSX = `${BANNER}import type { ReactElement } from 'react'; import { ArrowRight } from 'lucide-react'; export interface BannerCta { label: string; href: string; } export interface CtaBannerProps { headline: string; subhead?: string; primaryCta: BannerCta; secondaryCta?: BannerCta; } export function CtaBanner({ headline, subhead, primaryCta, secondaryCta }: CtaBannerProps): ReactElement { return (

{headline}

{subhead ?

{subhead}

: null}
); } `; const FAQ_TSX = `${BANNER}import type { ReactElement } from 'react'; export interface FaqItem { question: string; answer: string; } export interface FaqProps { title?: string; items: FaqItem[]; } export function Faq({ title, items }: FaqProps): ReactElement { return (
{title ?

{title}

: null}
{items.map((item) => (
{item.question} +

{item.answer}

))}
); } `; const LOGOSTRIP_TSX = `${BANNER}import type { ReactElement } from 'react'; export interface LogoStripProps { label?: string; items: string[]; } export function LogoStrip({ label, items }: LogoStripProps): ReactElement { return (
{label ? {label} : null} {items.map((it) => ( {it} ))}
); } `; const MARKETING_HEADER_TSX = `${BANNER}import type { ReactElement } from 'react'; export interface MarketingNavLink { label: string; href: string; } export interface MarketingHeaderProps { brand: string; logoUrl?: string; links?: MarketingNavLink[]; ctaLabel?: string; ctaHref?: string; } export function MarketingHeader({ brand, logoUrl, links = [], ctaLabel, ctaHref }: MarketingHeaderProps): ReactElement { return (
{logoUrl ? {brand} : null} {brand}
); } `; const MARKETING_FOOTER_TSX = `${BANNER}import type { ReactElement } from 'react'; export interface MarketingFooterLink { label: string; href: string; } export interface MarketingFooterProps { brand: string; tagline?: string; links?: MarketingFooterLink[]; } export function MarketingFooter({ brand, tagline, links = [] }: MarketingFooterProps): ReactElement { return (
{brand}
{tagline ?

{tagline}

: null}
© {new Date().getFullYear()} {brand}
); } `; const SECTION_SOURCES: Record = { hero: { file: 'Hero.tsx', src: HERO_TSX }, features: { file: 'FeatureGrid.tsx', src: FEATUREGRID_TSX }, split: { file: 'SplitFeature.tsx', src: SPLITFEATURE_TSX }, cta: { file: 'CtaBanner.tsx', src: CTABANNER_TSX }, faq: { file: 'Faq.tsx', src: FAQ_TSX }, logos: { file: 'LogoStrip.tsx', src: LOGOSTRIP_TSX }, }; // ============================================================================ // Per-section render: emits matching JSX + i18n in one pass (no drift possible) // ============================================================================ interface RenderedSection { jsx: string; i18n: Record; icons: string[]; } const key = (scope: string, i: number, field: string): string => `${scope}.s${i}.${field}`; function indexed(values: string[]): Record { return Object.fromEntries(values.map((v, j) => [String(j), v])); } function tag(name: string, props: string[]): string { return ` <${name}\n ${props.join('\n ')}\n />`; } function renderSection(scope: string, i: number, sec: Section): RenderedSection { switch (sec.type) { case 'hero': { const i18n: Record = { headline: sec.headline }; const props = [`headline={t(${s(key(scope, i, 'headline'))})}`]; if (sec.badge) { i18n.badge = sec.badge; props.push(`badge={t(${s(key(scope, i, 'badge'))})}`); } if (sec.highlight) { i18n.highlight = sec.highlight; props.push(`highlight={t(${s(key(scope, i, 'highlight'))})}`); } if (sec.subhead) { i18n.subhead = sec.subhead; props.push(`subhead={t(${s(key(scope, i, 'subhead'))})}`); } if (sec.primaryCta) { i18n.primaryCta = sec.primaryCta.label; props.push(`primaryCta={{ label: t(${s(key(scope, i, 'primaryCta'))}), href: ${s(sec.primaryCta.href)} }}`); } if (sec.secondaryCta) { i18n.secondaryCta = sec.secondaryCta.label; props.push(`secondaryCta={{ label: t(${s(key(scope, i, 'secondaryCta'))}), href: ${s(sec.secondaryCta.href)} }}`); } if (sec.bullets && sec.bullets.length > 0) { i18n.bullets = indexed(sec.bullets); props.push(`bullets={[${sec.bullets.map((_, j) => `t(${s(key(scope, i, `bullets.${j}`))})`).join(', ')}]}`); } return { jsx: tag('Hero', props), i18n, icons: [] }; } case 'features': { const itemsI18n: Record = {}; const icons: string[] = []; const lits = sec.items.map((it, j) => { itemsI18n[String(j)] = { title: it.title, description: it.description }; const icon = normalizeIcon(it.icon); icons.push(icon); return ` { icon: ${icon}, title: t(${s(key(scope, i, `items.${j}.title`))}), description: t(${s(key(scope, i, `items.${j}.description`))}) }`; }); const i18n: Record = { items: itemsI18n }; const props: string[] = []; if (sec.title) { i18n.title = sec.title; props.push(`title={t(${s(key(scope, i, 'title'))})}`); } if (sec.subtitle) { i18n.subtitle = sec.subtitle; props.push(`subtitle={t(${s(key(scope, i, 'subtitle'))})}`); } props.push(`items={[\n${lits.join(',\n')}\n ]}`); return { jsx: tag('FeatureGrid', props), i18n, icons }; } case 'split': { const i18n: Record = { title: sec.title }; const props = [`title={t(${s(key(scope, i, 'title'))})}`]; if (sec.highlight) { i18n.highlight = sec.highlight; props.push(`highlight={t(${s(key(scope, i, 'highlight'))})}`); } if (sec.body) { i18n.body = sec.body; props.push(`body={t(${s(key(scope, i, 'body'))})}`); } if (sec.bullets && sec.bullets.length > 0) { i18n.bullets = indexed(sec.bullets); props.push(`bullets={[${sec.bullets.map((_, j) => `t(${s(key(scope, i, `bullets.${j}`))})`).join(', ')}]}`); } props.push(`side=${s(sec.side)}`); if (sec.cta) { i18n.cta = sec.cta.label; props.push(`cta={{ label: t(${s(key(scope, i, 'cta'))}), href: ${s(sec.cta.href)} }}`); } return { jsx: tag('SplitFeature', props), i18n, icons: [] }; } case 'cta': { const i18n: Record = { headline: sec.headline, primaryCta: sec.primaryCta.label }; const props = [ `headline={t(${s(key(scope, i, 'headline'))})}`, `primaryCta={{ label: t(${s(key(scope, i, 'primaryCta'))}), href: ${s(sec.primaryCta.href)} }}`, ]; if (sec.subhead) { i18n.subhead = sec.subhead; props.push(`subhead={t(${s(key(scope, i, 'subhead'))})}`); } if (sec.secondaryCta) { i18n.secondaryCta = sec.secondaryCta.label; props.push(`secondaryCta={{ label: t(${s(key(scope, i, 'secondaryCta'))}), href: ${s(sec.secondaryCta.href)} }}`); } return { jsx: tag('CtaBanner', props), i18n, icons: [] }; } case 'faq': { const itemsI18n: Record = {}; const lits = sec.items.map((it, j) => { itemsI18n[String(j)] = { question: it.question, answer: it.answer }; return ` { question: t(${s(key(scope, i, `items.${j}.question`))}), answer: t(${s(key(scope, i, `items.${j}.answer`))}) }`; }); const i18n: Record = { items: itemsI18n }; const props: string[] = []; if (sec.title) { i18n.title = sec.title; props.push(`title={t(${s(key(scope, i, 'title'))})}`); } props.push(`items={[\n${lits.join(',\n')}\n ]}`); return { jsx: tag('Faq', props), i18n, icons: [] }; } case 'logos': { const i18n: Record = { items: indexed(sec.items) }; const props: string[] = []; if (sec.label) { i18n.label = sec.label; props.push(`label={t(${s(key(scope, i, 'label'))})}`); } props.push(`items={[${sec.items.map((_, j) => `t(${s(key(scope, i, `items.${j}`))})`).join(', ')}]}`); return { jsx: tag('LogoStrip', props), i18n, icons: [] }; } } } // ============================================================================ // Page composer // ============================================================================ export interface NavLink { label: string; href: string; } interface BuildPageOptions { whiteLabel: boolean; brand?: string; tagline?: string; logoUrl?: string; navLinks?: NavLink[]; } interface BuiltPage { content: string; i18nScope: Record; } function buildPage( scope: string, compName: string, sections: Section[], opts: BuildPageOptions, ): BuiltPage { const rendered = sections.map((sec, i) => renderSection(scope, i, sec)); const i18nScope: Record = {}; rendered.forEach((r, i) => { i18nScope[`s${i}`] = r.i18n; }); const icons = Array.from(new Set(rendered.flatMap((r) => r.icons))).sort(); const sectionComps = Array.from(new Set(sections.map((sec) => SECTION_COMPONENT[sec.type]))); const imports = [ `import type { ReactElement } from 'react';`, `import { useTranslation } from 'react-i18next';`, `import type { PageProps } from '@atlashub/smartstack';`, ]; if (icons.length > 0) imports.push(`import { ${icons.join(', ')} } from 'lucide-react';`); for (const comp of sectionComps) imports.push(`import { ${comp} } from '../sections/${comp}';`); if (opts.whiteLabel) { imports.push(`import { MarketingHeader } from '../sections/MarketingHeader';`); imports.push(`import { MarketingFooter } from '../sections/MarketingFooter';`); } const body = rendered.map((r) => r.jsx).join('\n'); let inner: string; if (opts.whiteLabel) { const navLit = `[${(opts.navLinks ?? []) .map((l) => `{ label: ${s(l.label)}, href: ${s(l.href)} }`) .join(', ')}]`; const headerProps = [`brand=${s(opts.brand ?? '')}`]; if (opts.logoUrl) headerProps.push(`logoUrl=${s(opts.logoUrl)}`); headerProps.push(`links={${navLit}}`); const footerProps = [`brand=${s(opts.brand ?? '')}`]; if (opts.tagline) footerProps.push(`tagline=${s(opts.tagline)}`); footerProps.push(`links={${navLit}}`); inner = `
${body}
`; } else { inner = `
${body}
`; } const content = `${imports.join('\n')} export function ${compName}(_props?: PageProps): ReactElement { const { t } = useTranslation('vitrine'); return ( ${inner} ); } `; return { content, i18nScope }; } // ============================================================================ // Wiring file (regenerated each run) // ============================================================================ function buildWiringFile(spec: ScaffoldVitrineInput): string { const hasHome = !!spec.home?.enabled; const lines: string[] = [ '/* AUTO-GENERATED by /site-vitrine (scaffold-vitrine) — regenerated on each run. Do not edit. */', `import { PublicRouteRegistry, lazyWithRetry, addClientResources, PAGE_KEYS } from '@atlashub/smartstack';`, `import type { ExtensionConfig } from '@atlashub/smartstack';`, ]; for (const lang of spec.languages) { lines.push(`import ${lang}Vitrine from '../vitrine/locales/${lang}/vitrine.json';`); } if (hasHome) lines.push(`import { HomePage } from '../vitrine/pages/HomePage';`); lines.push(''); for (const lang of spec.languages) { lines.push(`addClientResources('${lang}', { vitrine: ${lang}Vitrine });`); } lines.push(''); for (const page of spec.pages ?? []) { const comp = `${page.name}Page`; lines.push( `const ${comp} = lazyWithRetry(() =>\n` + ` import('../vitrine/pages/${comp}').then((m) => {\n` + ` const resolved = m.${comp} ?? m.default;\n` + ` if (!resolved) throw new Error('${comp}: missing export');\n` + ` return { default: resolved };\n` + ` }),\n` + `);`, ); lines.push( `PublicRouteRegistry.register({ path: ${s(page.path)}, component: ${comp}, layout: ${s(page.layout)} });`, ); } lines.push(''); const pagesMap = hasHome ? '{ [PAGE_KEYS.HOME]: HomePage }' : '{}'; lines.push(`export const vitrineExtensions: ExtensionConfig = { pages: ${pagesMap} };`); lines.push(''); return lines.join('\n'); } // ============================================================================ // main.tsx wiring (idempotent string transform — model: login-config) // ============================================================================ export const VITRINE_IMPORT_LINE = `import { vitrineExtensions } from './extensions/vitrine.generated';`; const COMPONENT_REGISTRY_IMPORT = `import './extensions/componentRegistry.generated';`; export interface InjectResult { content: string; status: 'applied' | 'unchanged' | 'not-found' | 'skipped-customised'; } /** * Ensure main.tsx (a) imports the wiring module (runs the i18n + route * registrations as side-effects, and brings in `vitrineExtensions`) and (b) * spreads `vitrineExtensions` into the provider's `extensions: {}`. * * Idempotent. Atomic: if the empty `extensions: {}` can't be found (main.tsx * customized), nothing is written and 'not-found' is returned so the caller can * print a paste-in snippet. */ export function injectVitrineExtensions(source: string): InjectResult { if (/\/\/\s*@customised/.test(source)) { return { content: source, status: 'skipped-customised' }; } const hasImport = source.includes(VITRINE_IMPORT_LINE); const hasSpread = source.includes('...vitrineExtensions'); if (hasImport && hasSpread) return { content: source, status: 'unchanged' }; let content = source; if (!hasImport) { if (content.includes(COMPONENT_REGISTRY_IMPORT)) { content = content.replace( COMPONENT_REGISTRY_IMPORT, `${COMPONENT_REGISTRY_IMPORT}\n${VITRINE_IMPORT_LINE}`, ); } else { content = `${VITRINE_IMPORT_LINE}\n${content}`; } } if (!hasSpread) { if (!/extensions:\s*\{\s*\}/.test(content)) { // Don't leave a dangling import without the spread — bail atomically. return { content: source, status: 'not-found' }; } content = content.replace(/extensions:\s*\{\s*\}/, 'extensions: { ...vitrineExtensions }'); } return { content, status: 'applied' }; } export function snippetForManualWiring(): string { return ( 'Wire the vitrine manually in web/-web/src/main.tsx: add ' + `\`${VITRINE_IMPORT_LINE}\` near the other extension imports, and change ` + '`extensions: {}` to `extensions: { ...vitrineExtensions }` in the SmartStackProvider config.' ); } // ============================================================================ // Top-level generate // ============================================================================ export function buildNavLinks(spec: ScaffoldVitrineInput): NavLink[] { const links: NavLink[] = [{ label: 'Home', href: '/' }]; for (const page of spec.pages ?? []) { links.push({ label: page.title ?? page.name, href: page.path }); } return links; } export function countSections(spec: ScaffoldVitrineInput): number { const home = spec.home?.enabled ? spec.home.sections.length : 0; const pages = (spec.pages ?? []).reduce((n, p) => n + p.sections.length, 0); return home + pages; } export function generate(spec: ScaffoldVitrineInput, layout: ProjectLayout): GeneratedFile[] { const files: GeneratedFile[] = []; const srcPrefix = `${layout.webDir}/src`; const usesWhiteLabel = (spec.pages ?? []).some((p) => p.layout === 'none'); // 1) Section components (only those used) + marketing chrome when white-label. const usedTypes = new Set(); if (spec.home?.enabled) spec.home.sections.forEach((sec) => usedTypes.add(sec.type)); (spec.pages ?? []).forEach((p) => p.sections.forEach((sec) => usedTypes.add(sec.type))); for (const t of usedTypes) { const def = SECTION_SOURCES[t]; files.push({ path: `${srcPrefix}/vitrine/sections/${def.file}`, content: def.src, strategy: 'skip-if-exists' }); } if (usesWhiteLabel) { files.push({ path: `${srcPrefix}/vitrine/sections/MarketingHeader.tsx`, content: MARKETING_HEADER_TSX, strategy: 'skip-if-exists' }); files.push({ path: `${srcPrefix}/vitrine/sections/MarketingFooter.tsx`, content: MARKETING_FOOTER_TSX, strategy: 'skip-if-exists' }); } // 2) Pages + i18n accumulation. const i18nRoot: Record = {}; const navLinks = buildNavLinks(spec); if (spec.home?.enabled) { const built = buildPage('home', 'HomePage', spec.home.sections, { whiteLabel: false }); files.push({ path: `${srcPrefix}/vitrine/pages/HomePage.tsx`, content: built.content, strategy: 'skip-if-exists' }); i18nRoot.home = built.i18nScope; } for (const page of spec.pages ?? []) { const scope = page.name.toLowerCase(); const built = buildPage(scope, `${page.name}Page`, page.sections, { whiteLabel: page.layout === 'none', brand: spec.branding?.appName ?? spec.appCode, tagline: spec.branding?.tagline, logoUrl: spec.branding?.logoUrl, navLinks, }); files.push({ path: `${srcPrefix}/vitrine/pages/${page.name}Page.tsx`, content: built.content, strategy: 'skip-if-exists' }); i18nRoot[scope] = built.i18nScope; } // 3) i18n locales — same copy per language (translate non-primary later). Deep-merge. for (const lang of spec.languages) { files.push({ path: `${srcPrefix}/vitrine/locales/${lang}/vitrine.json`, content: `${JSON.stringify(i18nRoot, null, 2)}\n`, strategy: 'deep-merge-json', }); } // 4) Wiring (overwrite). files.push({ path: `${srcPrefix}/extensions/vitrine.generated.ts`, content: buildWiringFile(spec), strategy: 'overwrite', }); return files; }