@contentrain/emitter-astro 0.9.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":[],"sources":["../src/types.ts","../src/util.ts","../src/scaffold.ts","../src/balance.ts","../src/layouts.ts","../src/pages.ts","../src/embed.ts","../src/components.ts","../src/chrome.ts","../src/css.ts","../src/index.ts"],"sourcesContent":["import type { EntrySourceRef, ProjectIR, RuntimeBinding } from '@contentrain/types'\n\n// ─── Emit input ───\n\n/**\n * One piece of content ready to render: body already HTML, assets already\n * rewritten to local paths, dates already formatted in the site's observed\n * formats. The emitter renders; it does not fetch, localize, or format —\n * those are producer concerns, and keeping them out is what makes this\n * package portable.\n */\n/** A term as a list card prints it: its name and the archive it links to. */\nexport interface EmitTermRef {\n name: string\n link?: string\n}\n\nexport interface EmitPost {\n slug: string\n title: string\n /** Rendered, asset-rewritten HTML body. */\n body: string\n /**\n * Route parameters for this post beyond `slug` — the date parts of a\n * `/%year%/%monthnum%/%day%/%postname%/` permalink, a post id, anything the\n * route pattern names. Without them every post but the template one would be\n * generated at the wrong address.\n */\n params?: Record<string, string>\n /**\n * Stylesheets only this page loads (page-builder sites emit CSS per page).\n * Family-level stylesheets stay on `LayoutFamily.css.files`.\n */\n css?: string[]\n /** The site's date-format catalog applied to this post's date (index-aligned with `date{n}` marks). */\n dates?: string[]\n author?: string\n author_first?: string\n author_last?: string\n /**\n * Term names, or `{ name, link }` objects for themes whose term lists link\n * each term — a repeat block then reads `item_name` / `item_link`. The\n * `term{n}` and `terms` marks print the name either way.\n */\n terms?: Array<string | EmitTermRef>\n /** Local featured-image file names (largest first). */\n featured?: string[]\n excerpt?: string\n /** Excerpt as source HTML, for themes whose cards keep links and formatting. */\n excerpt_html?: string\n /** Every author, for repeat blocks — `author` stays the first one. */\n authors?: string[]\n /** Producer-supplied extra marks, merged last. */\n marks?: Record<string, unknown>\n /** Locale of this entry, on a multilingual site — overrides the route's. */\n locale?: string\n /**\n * Content-store address of this post (model, entry id, locale) — what a\n * mounted `comments` component addresses its thread by. Only the tool that\n * wrote the content store knows it; without it the comments region mounts\n * as a placeholder.\n */\n entry?: EntrySourceRef\n}\n\n/**\n * One block of a list: a template, optional wrapper, and how many items it takes.\n *\n * Themes commonly render the newest post as a big card and the rest as a grid —\n * two different templates in two different containers. One template per list\n * cannot express that: either everything becomes a big card, or the big card is\n * baked into the chrome and page 2 shows the wrong post.\n */\nexport interface ListSection {\n /** Item markup with `@@marks@@`. */\n template: string\n /** Markup around this section's items; `<!--@@items@@-->` marks where they go. */\n wrapper?: string\n /** Items this section takes, in order. Omitted = the remaining items. */\n count?: number\n}\n\n/** One static path of a list route: its params and the items its list renders. */\nexport interface QueryPage {\n params: Record<string, string>\n items: EmitPost[]\n /**\n * Page-level marks for the list page's chrome — a term's display NAME where\n * the route parameter only carries its slug, its description, its count.\n * Without these every category page would print the template category's name.\n */\n marks?: Record<string, unknown>\n /** Stylesheets only this list page loads. */\n css?: string[]\n /**\n * Item markup with `@@mark@@` placeholders (title, date{n}, author, excerpt,\n * feat, slug), extracted from the source list. When present, list pages\n * render each item by filling the template; when absent, a plain fallback\n * list is emitted and a warning recorded — a silent fallback would read as\n * fidelity when it is not.\n *\n * Shorthand for a single-section list; `sections` takes precedence.\n */\n item_template?: string\n /** Lists whose items are not all rendered alike — a big card then a grid. */\n sections?: ListSection[]\n /** Document title for this page (`<title>`), e.g. \"Category: News – Site\". */\n title?: string\n}\n\n/** Default collection name when a `single` route does not name one. */\nexport const DEFAULT_COLLECTION = 'posts'\n\nexport interface EmitContent {\n /** The default (`posts`) collection — shorthand for `collections.posts`. */\n posts?: EmitPost[]\n /**\n * Content per collection, for sites with more than one kind of single page\n * (posts, pages, custom post types). A `single` route reads the collection\n * named by `RouteModel.collection`.\n */\n collections?: Record<string, EmitPost[]>\n /** QueryBinding id → the static paths (and items) that query produces. */\n queries?: Record<string, QueryPage[]>\n}\n\n/** A legacy stylesheet provided by the producer, referenced from `LayoutFamily.css.files`. */\nexport interface EmitCssFile {\n path: string\n content: string\n}\n\nexport interface EmitOptions {\n /** Generated project's package name. Default: \"migrated-site\". */\n projectName?: string\n /** Include Tailwind 4 as the evolution layer (default true). */\n tailwind?: boolean\n}\n\nexport interface EmitInput {\n ir: ProjectIR\n content?: EmitContent\n css?: EmitCssFile[]\n options?: EmitOptions\n /**\n * Where runtime components (comments, forms) talk to. Without it they are\n * emitted as placeholders and a warning names each one — a migration is\n * complete without any runtime offer being accepted.\n */\n runtime?: RuntimeBinding\n}\n\n// ─── Emit output ───\n\n/**\n * Pure result: every generated file as path → content. Writing to disk is a\n * separate, trivial step (`writeEmit`) so the core stays testable and usable\n * from any host — CLI, service, or another emitter wrapping this one.\n */\nexport interface EmitResult {\n files: Record<string, string>\n warnings: string[]\n}\n\nexport type { ProjectIR, RuntimeBinding, EntrySourceRef }\n","/** \"f-archive\" → \"FArchive\", \"posts\" → \"Posts\" — Astro component file names. */\nexport function pascalCase(id: string): string {\n return id\n .split(/[^a-zA-Z0-9]+/)\n .filter(Boolean)\n .map((part) => part.charAt(0).toUpperCase() + part.slice(1))\n .join('')\n}\n\n/**\n * Route pattern → Astro pages path.\n * `/` → `index.astro`; `/category/:term/page/:page` → `category/[term]/page/[page].astro`;\n * a trailing `*` on a parameter makes it a rest parameter — `/category/:term*`\n * → `category/[...term].astro`, which is what keeps a nested taxonomy address\n * (`/category/about-cc/events/`) from collapsing to its last segment.\n * Returns null for patterns Astro's file routing cannot express (recorded as a warning upstream).\n */\nexport function patternToPagePath(pattern: string): string | null {\n if (pattern === '/' || pattern === '') return 'index.astro'\n const segments = pattern.replace(/^\\/+|\\/+$/g, '').split('/')\n // Literal Unicode segments preserve the source permalink. Validate literals\n // separately from parameters so widening the alphabet cannot admit traversal\n // or turn a source filename containing brackets into an Astro parameter.\n if (segments.some((seg) => seg.startsWith(':')\n ? !/^:[a-zA-Z0-9_]+\\*?$/.test(seg)\n : seg === '.' || seg === '..' || !/^[\\p{L}\\p{M}\\p{N}._-]+$/u.test(seg))) return null\n const mapped = segments.map((seg) => {\n if (!seg.startsWith(':')) return seg\n const rest = seg.endsWith('*')\n const name = seg.slice(1, rest ? -1 : undefined)\n return rest ? `[...${name}]` : `[${name}]`\n })\n const last = mapped.pop()\n return [...mapped, `${last}.astro`].join('/')\n}\n\n/** Index of the rest parameter segment in a page path, or -1. */\nexport function restParamIndex(pagePath: string): number {\n return pagePath.split('/').findIndex((seg) => seg.startsWith('[...'))\n}\n\nconst sortDeep = (v: unknown): unknown => {\n if (Array.isArray(v)) return v.map(sortDeep)\n if (v && typeof v === 'object') {\n return Object.fromEntries(\n Object.entries(v as Record<string, unknown>)\n .toSorted(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))\n .map(([k, val]) => [k, sortDeep(val)]),\n )\n }\n return v\n}\n\n/** JSON with stable key order and trailing newline — deterministic output, stable diffs. */\nexport function stableJson(value: unknown): string {\n return `${JSON.stringify(sortDeep(value), null, 2)}\\n`\n}\n","// Project scaffolding: manifest, config, styles, and the shared fill helper\n// the generated pages use to pour content into `@@mark@@` placeholders.\n\nimport type { DesignTokens, ProjectIR } from '@contentrain/types'\nimport type { EmitOptions } from './types.js'\nimport { stableJson } from './util.js'\n\nexport function scaffoldFiles(ir: ProjectIR, options: EmitOptions): Record<string, string> {\n const tailwind = options.tailwind !== false\n const split = ir.viewport_strategy === 'split'\n const files: Record<string, string> = {}\n\n const pkg: Record<string, unknown> = {\n name: options.projectName ?? 'migrated-site',\n private: true,\n type: 'module',\n scripts: {\n dev: 'astro dev',\n // `astro check` first: type errors in generated pages should fail the\n // build, not surface later in a browser.\n build: 'astro check && astro build',\n preview: 'astro preview',\n // Split viewport production (skeleton): one build per device class, served by device.\n ...(split ? { 'build:desktop': 'VIEWPORT=desktop astro build --outDir dist/desktop', 'build:mobile': 'VIEWPORT=mobile astro build --outDir dist/mobile' } : {}),\n },\n dependencies: {\n astro: '^5.0.0',\n ...(tailwind ? { tailwindcss: '^4.0.0', '@tailwindcss/vite': '^4.0.0' } : {}),\n },\n devDependencies: {\n '@astrojs/check': '^0.9.0',\n typescript: '^5.7.0',\n },\n }\n files['package.json'] = stableJson(pkg)\n\n files['astro.config.mjs'] = [\n `import { defineConfig } from 'astro/config'`,\n ...(tailwind ? [`import tailwindcss from '@tailwindcss/vite'`] : []),\n ``,\n `export default defineConfig({`,\n // Canonical URLs and sitemaps hang off \\`site\\` — for a migration, SEO\n // continuity is the point, so the source site's URL always lands here.\n ` site: ${JSON.stringify(ir.site.url)},`,\n ` build: { format: 'directory' },`,\n ...(tailwind ? [` vite: { plugins: [tailwindcss()] },`] : []),\n `})`,\n ``,\n ].join('\\n')\n\n // Astro wants a tsconfig in every project — editors and the compiler read\n // it even in JS-only projects, and the emitted src/lib/fill.ts is TS.\n files['tsconfig.json'] = `${JSON.stringify({ extends: 'astro/tsconfigs/base', include: ['.astro/types.d.ts', '**/*'], exclude: ['dist'] }, null, 2)}\\n`\n\n if (tailwind) files['src/styles/modern.css'] = modernCss(ir.tokens)\n\n files['src/lib/fill.ts'] = FILL_TS\n return files\n}\n\n/** Tailwind 4 evolution layer: CSS-first config with the site's extracted tokens. */\nfunction modernCss(tokens: DesignTokens | undefined): string {\n const lines: string[] = [`@import 'tailwindcss';`, ``]\n const theme: string[] = []\n const push = (prefix: string, map: Record<string, string> | undefined) => {\n for (const [key, value] of Object.entries(map ?? {})) theme.push(` --${prefix}-${key}: ${value};`)\n }\n push('color', tokens?.colors)\n push('font', tokens?.font_families)\n push('text', tokens?.font_sizes)\n push('spacing', tokens?.spacing)\n push('breakpoint', tokens?.breakpoints)\n if (theme.length) lines.push(`@theme {`, ...theme, `}`)\n return `${lines.join('\\n')}\\n`\n}\n\nconst FILL_TS = `// Emitted by @contentrain/emitter-astro — the template runtime.\n//\n// Chrome, item templates and attribute values carry three marker forms:\n// @@mark@@ — a value, escaped\n// @@mark_html@@ — a value, raw HTML\n// <!--@@repeat:list@@-->…<!--@@/repeat@@--> — once per list item\n// <!--@@if:name@@-->…<!--@@/if@@--> — only when name has a value\n// Rendering order is repeats → conditionals → marks, so a repeat body may hold\n// conditionals and marks that only make sense per item.\n\n/** Where page content splices into the body chrome — must match @contentrain/types CHROME_BODY_SLOT. */\nexport const BODY_SLOT = '<!--@@body@@-->'\n\nconst REPEAT_RE = /<!--@@repeat:([a-z0-9_]+)(?:\\\\|([\\\\s\\\\S]*?))?@@-->([\\\\s\\\\S]*?)<!--@@\\\\/repeat@@-->/gi\nconst IF_RE = /<!--@@if:(!?)([a-z0-9_]+)@@-->([\\\\s\\\\S]*?)<!--@@\\\\/if@@-->/gi\nconst MARK_RE = /@@([a-z0-9_]+)@@/gi\n\nexport type Values = Record<string, unknown>\n\nexport const esc = (value: unknown): string =>\n String(value ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')\n\nconst isFilled = (value: unknown): boolean =>\n Array.isArray(value) ? value.length > 0 : value !== undefined && value !== null && value !== ''\n\n/**\n * Replace @@marks@@. Values are escaped unless the mark name ends in _html —\n * escaping is the default so content-derived text can never break the page,\n * and the _html opt-in exists for themes that print a post's own markup\n * (full content in a list card, a link-bearing excerpt).\n */\nexport function fillMarks(html: string, values: Values): string {\n return html.replace(MARK_RE, (_all, key: string) => {\n const value = values[key]\n if (Array.isArray(value)) return value.map((v) => esc(v && typeof v === 'object' && 'name' in v ? (v as { name: unknown }).name : v)).join(', ')\n return key.toLowerCase().endsWith('_html') ? String(value ?? '') : esc(value ?? '')\n })\n}\n\n/** Expand repeat blocks; each item renders the inner fragment with its own values. */\nexport function expandRepeats(html: string, values: Values): string {\n return html.replace(REPEAT_RE, (_all, name: string, sep: string | undefined, inner: string) => {\n const list = values[name]\n if (!Array.isArray(list) || list.length === 0) return ''\n return list\n .map((item, index) => {\n // An object item prints its name at @@item@@ and exposes item_<key> for\n // the rest, so a template written for string terms keeps working when\n // the producer starts sending { name, link }.\n const record = item && typeof item === 'object' ? (item as Record<string, unknown>) : undefined\n const itemValues: Values = { ...values, item: record && 'name' in record ? record.name : item, item_index: String(index) }\n if (record) {\n for (const [k, v] of Object.entries(record)) itemValues['item_' + k] = v\n }\n return renderTemplate(inner, itemValues)\n })\n .join(sep ?? '')\n })\n}\n\n/** Drop conditional blocks whose value is empty (or present, when negated). */\nexport function applyConditions(html: string, values: Values): string {\n return html.replace(IF_RE, (_all, negate: string, name: string, inner: string) =>\n isFilled(values[name]) !== (negate === '!') ? inner : '',\n )\n}\n\n/** Full render: repeats → conditionals → marks. */\nexport function renderTemplate(html: string, values: Values): string {\n return fillMarks(applyConditions(expandRepeats(html, values), values), values)\n}\n\n/** Fill marks inside attribute values (per-page classes like postid-123). */\nexport function fillAttrs(attrs: Record<string, string> | undefined, values: Values): Record<string, string> {\n const out: Record<string, string> = {}\n for (const [name, value] of Object.entries(attrs ?? {})) out[name] = fillMarks(value, values)\n return out\n}\n\n/**\n * Compose the body chrome with the page content. The split happens BEFORE any\n * rendering: the @@…@@ pattern would otherwise consume the @@body@@ inside the\n * marker comment, leaving <!----> behind and silently dropping the content\n * (measured cost on a real page: 49.8 vs 97.8).\n */\nexport function composeBody(chromeBody: string, values: Values, content: string): string {\n return chromeBody\n .split(BODY_SLOT)\n .map((part) => renderTemplate(part, values))\n .join(content)\n}\n\n/** Component mount marker — must match @contentrain/types CHROME_COMPONENT_OPEN / CHROME_COMPONENT_CLOSE. */\nexport const COMPONENT_OPEN = '<!--@@component:'\nexport const COMPONENT_CLOSE = '@@-->'\nconst COMPONENT_RE = /<!--@@component:([^@\\\\s]+)@@-->/g\n\nexport interface BodyPart {\n html: string\n /** A mount point: the component id the marker named. */\n component?: string\n}\n\n/**\n * Split rendered chrome at component markers into html parts interleaved with\n * mount points, so the layout can render a real component where the theme's\n * comments form (or contact form) stood. The parts are output in order, so the\n * page's HTML is exactly the chrome with the component's markup at the marker —\n * no parser sees the pieces separately.\n */\nexport function splitComponents(html: string): BodyPart[] {\n const parts: BodyPart[] = []\n let last = 0\n for (const match of html.matchAll(COMPONENT_RE)) {\n parts.push({ html: html.slice(last, match.index) })\n parts.push({ html: '', component: match[1] ?? '' })\n last = match.index + match[0].length\n }\n parts.push({ html: html.slice(last) })\n return parts\n}\n\n/** Where a list section's wrapper takes its items — must match @contentrain/types LIST_ITEMS_SLOT. */\nexport const ITEMS_SLOT = '<!--@@items@@-->'\n\nexport interface ListSection {\n template: string\n wrapper?: string\n count?: number\n}\n\n/**\n * Render a list in sections. Themes often render the newest post as a big card\n * and the rest as a grid — two templates in two containers — so a list is a\n * sequence of sections, each taking a count of items (or the remainder).\n */\nexport function renderSections<T>(\n sections: ListSection[],\n items: T[],\n values: (item: T) => Values,\n): string {\n let index = 0\n const out: string[] = []\n for (const section of sections) {\n const take = section.count ?? items.length - index\n const slice = items.slice(index, index + take)\n index += slice.length\n if (slice.length === 0) continue\n const rendered = slice.map((item) => renderTemplate(section.template, values(item))).join('')\n out.push(section.wrapper ? section.wrapper.split(ITEMS_SLOT).join(rendered) : rendered)\n }\n return out.join('')\n}\n\n/** Emitted stylesheets live under /styles/legacy/ — pages reference them by file name. */\nexport const cssHref = (file: string): string => '/styles/legacy/' + (file.split('/').pop() ?? file)\n\n/** A term as a list card prints it: name, and the archive it links to. */\nexport interface TermRef {\n name: string\n link?: string\n}\n\n/** The display name of a term given as a string or a TermRef. */\nexport const termName = (term: string | TermRef): string => (typeof term === 'string' ? term : term.name)\n\nexport interface MarkablePost {\n slug: string\n title: string\n body?: string\n dates?: string[]\n author?: string\n author_first?: string\n author_last?: string\n authors?: string[]\n /**\n * Strings, or objects for themes whose term lists link each term\n * (\\`<!--@@repeat:terms@@-->\\` with \\`item_name\\` / \\`item_link\\`). The\n * \\`term{n}\\` and \\`terms\\` marks print the name either way.\n */\n terms?: Array<string | TermRef>\n featured?: string[]\n excerpt?: string\n excerpt_html?: string\n marks?: Record<string, unknown>\n}\n\n/**\n * The mark vocabulary: title, author (+ first/last), date{n}, term{n}, feat{n},\n * excerpt, slug — plus \\`terms\\`/\\`authors\\` as LISTS for repeat blocks, the\n * _html variants for raw insertion, and any producer-supplied extras.\n */\nexport function postMarks(post: MarkablePost): Values {\n const values: Values = {\n title: post.title,\n author: post.author ?? '',\n author_first: post.author_first ?? '',\n author_last: post.author_last ?? '',\n excerpt: post.excerpt ?? '',\n excerpt_html: post.excerpt_html ?? post.excerpt ?? '',\n body_html: post.body ?? '',\n slug: post.slug,\n feat: post.featured?.[0] ?? '',\n // Objects stay objects for repeat blocks (item_name / item_link); the joined\n // \\`terms\\` mark prints names, so fillMarks never sees [object Object].\n terms: (post.terms ?? []).map((t) => (typeof t === 'string' ? { name: t, link: '' } : { name: t.name, link: t.link ?? '' })),\n terms_names: (post.terms ?? []).map(termName),\n authors: post.authors ?? (post.author ? [post.author] : []),\n }\n for (const [i, d] of (post.dates ?? []).entries()) values['date' + i] = d\n for (const [i, t] of (post.terms ?? []).entries()) values['term' + i] = termName(t)\n for (const [i, f] of (post.featured ?? []).entries()) values['feat' + i] = f\n return { ...values, ...(post.marks ?? {}) }\n}\n\n/**\n * The shapes of the emitted data files. Pages import JSON and assert these\n * rather than letting TypeScript infer the shape from the file's contents:\n * inference reads only the fields the data HAPPENS to carry, so a site whose\n * posts need no extra route parameters produced a type without \\`params\\` and\n * \\`astro check\\` — which the build runs first — failed on the page that reads\n * it. The contract is what the emitter may write, not what one site wrote.\n */\n/** Content-store address of an entry — what a mounted comments component keys its thread by. */\nexport interface EntryRef {\n model_id: string\n entry_id: string\n locale?: string\n}\n\nexport interface EmittedPost extends MarkablePost {\n body: string\n /** Route parameters beyond slug — the date parts of a dated permalink, a post id. */\n params?: Record<string, string>\n /** Stylesheets only this page loads. */\n css?: string[]\n locale?: string\n /** Present when the producer bound this post to the content store. */\n entry?: EntryRef\n}\n\n/** One static path of a list route, as it appears in the emitted query data. */\nexport interface EmittedQueryPage {\n params: Record<string, string>\n items: EmittedPost[]\n marks?: Record<string, unknown>\n css?: string[]\n item_template?: string\n sections?: ListSection[]\n title?: string\n}\n`\n","// Fragment balance check.\n//\n// Chrome is injected as raw HTML, so an unbalanced fragment does not fail the\n// build — the browser silently repairs it and the page loses its layout. That\n// is the failure that cost a page 36 against 100 when body chrome was split\n// into before/after halves. Lifting a header or footer out of the body blob is\n// only safe when each fragment closes what it opens, so the emitter checks and\n// says which tags are dangling rather than trusting the producer's word.\n//\n// This is deliberately not an HTML parser: it skips comments, doctypes and raw\n// text elements, tolerates the tags HTML lets you leave open, and reports what\n// it finds. It answers \"was this split mid-element?\", not \"is this valid HTML?\".\n\nconst VOID_TAGS = new Set([\n 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',\n 'link', 'meta', 'param', 'source', 'track', 'wbr',\n])\n\n/** Tags a browser closes for you. Theme markup is full of them, so counting\n * them would report every real page as broken. */\nconst OPTIONAL_END_TAGS = new Set([\n 'p', 'li', 'dt', 'dd', 'option', 'optgroup', 'thead', 'tbody',\n 'tfoot', 'tr', 'td', 'th', 'rt', 'rp',\n])\n\n/** Elements whose content is text, not markup — `<script>if (a < b)</script>`. */\nconst RAW_TEXT_TAGS = new Set(['script', 'style', 'textarea', 'title'])\n\ninterface Tag {\n name: string\n closing: boolean\n selfClosing: boolean\n}\n\nfunction scanTags(html: string): Tag[] {\n const tags: Tag[] = []\n let i = 0\n while (i < html.length) {\n const lt = html.indexOf('<', i)\n if (lt === -1) break\n if (html.startsWith('<!--', lt)) {\n const end = html.indexOf('-->', lt + 4)\n i = end === -1 ? html.length : end + 3\n continue\n }\n if (html.startsWith('<!', lt) || html.startsWith('<?', lt)) {\n const end = html.indexOf('>', lt)\n i = end === -1 ? html.length : end + 1\n continue\n }\n const head = /^<(\\/?)([a-zA-Z][a-zA-Z0-9-]*)/.exec(html.slice(lt, lt + 64))\n const [matched = '', slash = '', rawName = ''] = head ?? []\n if (!rawName) {\n i = lt + 1\n continue\n }\n // Walk to the tag's `>`, ignoring one inside a quoted attribute value.\n let j = lt + matched.length\n let quote = ''\n while (j < html.length) {\n const ch = html[j]\n if (quote) {\n if (ch === quote) quote = ''\n } else if (ch === '\"' || ch === \"'\") {\n quote = ch\n } else if (ch === '>') {\n break\n }\n j++\n }\n const name = rawName.toLowerCase()\n const closing = slash === '/'\n tags.push({ name, closing, selfClosing: html.slice(lt, j + 1).endsWith('/>') })\n i = j + 1\n if (!closing && RAW_TEXT_TAGS.has(name)) {\n const close = html.toLowerCase().indexOf(`</${name}`, i)\n i = close === -1 ? html.length : close\n }\n }\n return tags\n}\n\nexport interface BalanceReport {\n /** Elements opened and never closed — the fragment ends mid-element. */\n unclosed: string[]\n /** Elements closed but never opened — the fragment starts mid-element. */\n unopened: string[]\n}\n\nexport function checkBalance(html: string): BalanceReport {\n const stack: string[] = []\n const unopened: string[] = []\n for (const tag of scanTags(html)) {\n if (VOID_TAGS.has(tag.name) || tag.selfClosing || OPTIONAL_END_TAGS.has(tag.name)) continue\n if (!tag.closing) {\n stack.push(tag.name)\n continue\n }\n const at = stack.lastIndexOf(tag.name)\n if (at === -1) unopened.push(tag.name)\n else stack.length = at\n }\n return { unclosed: stack.toReversed(), unopened }\n}\n\n/** Human-readable balance complaint, or null when the fragment is sound. */\nexport function balanceWarning(html: string): string | null {\n const { unclosed, unopened } = checkBalance(html)\n const parts: string[] = []\n if (unclosed.length) parts.push(`never closed: ${unclosed.join(', ')}`)\n if (unopened.length) parts.push(`closed but never opened: ${unopened.join(', ')}`)\n return parts.length ? parts.join('; ') : null\n}\n","// Layout families → Astro layouts.\n//\n// Chrome travels as DATA, not as Astro template source: the compiler must\n// never parse browser-tolerated theme markup. And the body chrome is injected\n// as ONE fragment: real themes nest the content container deep inside the\n// chrome, so splitting it into before/after halves produces unbalanced\n// fragments that the parser silently \"repairs\" (measured cost: 36 vs 100).\n// Content splices in at CHROME_BODY_SLOT before the single injection.\n//\n// Root attributes travel with the chrome for the same reason: themes key their\n// container rules off `<body class=\"wp-singular single …\">` and `<html class=\"js\n// wf-…\">`, so a page with perfect content and empty root attributes loses its\n// whole layout (measured: 36.4 vs 100).\n\nimport type { ComponentDef, LayoutFamily } from '@contentrain/types'\nimport { CHROME_BODY_SLOT, CHROME_COMPONENT_CLOSE, CHROME_COMPONENT_OPEN } from '@contentrain/types'\nimport type { ChromeComponentRef } from './chrome.js'\nimport { balanceWarning } from './balance.js'\nimport { pascalCase, stableJson } from './util.js'\n\nexport interface FamilyGenResult {\n files: Record<string, string>\n warnings: string[]\n}\n\n/** A component the family's chrome mounts at a `<!--@@component:ID@@-->` marker. */\nexport interface MountRef {\n id: string\n name: string\n variant: string\n}\n\nconst escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\nconst MARKER_RE = new RegExp(`${escapeRe(CHROME_COMPONENT_OPEN)}([^@\\\\s]+)${escapeRe(CHROME_COMPONENT_CLOSE)}`, 'g')\n\n/** Component ids the chrome names, in order of first appearance. */\nexport function componentMarkers(html: string): string[] {\n return [...new Set([...html.matchAll(MARKER_RE)].map((m) => m[1] ?? ''))]\n}\n\nexport function familyFiles(\n family: LayoutFamily,\n lang: string,\n components: ChromeComponentRef[] = [],\n definitions: Map<string, ComponentDef> = new Map(),\n /** Component ids found in the CONTENT bodies rendered through this family (a form inside a page's post_content). */\n bodyMarkers: Iterable<string> = [],\n): FamilyGenResult {\n const name = pascalCase(family.id)\n // Header/footer regions are emitted as shared components (see chrome.ts) and\n // rendered as siblings of the body fragment; the layout injects the rest.\n const chunks = (family.chrome ?? []).filter((c) => c.position !== 'header' && c.position !== 'footer')\n const warnings: string[] = []\n for (const chunk of family.chrome ?? []) {\n if ((chunk.position === 'header' || chunk.position === 'footer') && componentMarkers(chunk.html).length) {\n warnings.push(`family ${family.id}: ${chunk.position} chrome carries a component marker — components mount in body chrome only; the marker renders as a comment`)\n }\n }\n const joined = (position: string) =>\n chunks.filter((c) => c.position === position).map((c) => c.html).join('\\n')\n\n const bodyChunks = chunks.filter((c) => c.position === 'body')\n let body: string\n if (bodyChunks.length) {\n body = bodyChunks.map((c) => c.html).join('\\n')\n if (!body.includes(CHROME_BODY_SLOT)) {\n warnings.push(`family ${family.id}: body chrome has no ${CHROME_BODY_SLOT} marker — content appended at the end`)\n body += CHROME_BODY_SLOT\n }\n } else {\n // Legacy pair: compose into one string with the slot between the halves.\n body = `${joined('before_body')}${CHROME_BODY_SLOT}${joined('after_body')}`\n }\n const unbalanced = balanceWarning(body)\n if (unbalanced) {\n warnings.push(`family ${family.id}: body chrome is not balanced (${unbalanced}) — the browser will repair it and the page loses its layout`)\n }\n\n // Component mount points. A marker names a ComponentDef; the layout imports\n // that component and renders it there. A marker nobody defined is dropped\n // with a warning rather than left as a mystery comment in every page.\n // Markers live in the chrome (the theme's comments region) or in the content\n // body itself (a contact form inside a page's post_content); the layout splits\n // the COMPOSED html, so both mount the same way. The mount table just has to\n // know every id that can appear.\n const placements = new Map((family.components ?? []).map((p) => [p.component, p]))\n const mounts: MountRef[] = []\n const mount = (id: string, def: ComponentDef) => {\n if (mounts.some((m) => m.id === id)) return\n const variant = placements.get(id)?.variant ?? def.variants?.[0]?.key ?? 'default'\n mounts.push({ id, name: pascalCase(id), variant })\n }\n for (const id of componentMarkers(body)) {\n const def = definitions.get(id)\n if (!def) {\n warnings.push(`family ${family.id}: component marker \"${id}\" has no component definition — marker dropped`)\n body = body.split(`${CHROME_COMPONENT_OPEN}${id}${CHROME_COMPONENT_CLOSE}`).join('')\n continue\n }\n mount(id, def)\n }\n for (const id of new Set(bodyMarkers)) {\n const def = definitions.get(id)\n if (!def) {\n warnings.push(`family ${family.id}: content bodies carry component marker \"${id}\" with no component definition — rendered as a comment`)\n continue\n }\n mount(id, def)\n }\n for (const placement of family.components ?? []) {\n if (!mounts.some((m) => m.id === placement.component)) {\n warnings.push(`family ${family.id}: component placement \"${placement.component}\" has no marker in the body chrome or in any content body — not mounted`)\n }\n }\n\n const files: Record<string, string> = {}\n files[`src/data/chrome/${family.id}.json`] = stableJson({\n head: joined('head'),\n body,\n html_attrs: family.root_attrs?.html ?? {},\n body_attrs: family.root_attrs?.body ?? {},\n })\n\n const cssLinks = (family.css.files ?? [])\n .map((f) => `<link rel=\"stylesheet\" href=\"/styles/legacy/${f.split('/').pop()}\" />`)\n .join('\\n')\n\n const imported = [...new Set([...components.map((c) => c.name), ...mounts.map((m) => m.name)])]\n const componentImports = imported\n .map((n) => `import ${n} from '../components/${n}.astro'`)\n .join('\\n')\n const mountType = [...new Set(mounts.map((m) => `typeof ${m.name}`))].join(' | ')\n const mountTable = mounts.length\n ? `// Components the chrome mounts at <!--@@component:ID@@--> markers; the page's\n// entry address travels to each so a comments thread knows which entry it is.\nconst mounts: Record<string, { Mount: ${mountType}; variant: string } | undefined> = {\n${mounts.map((m) => ` ${JSON.stringify(m.id)}: { Mount: ${m.name}, variant: ${JSON.stringify(m.variant)} },`).join('\\n')}\n}\nconst parts = splitComponents(html).map((part) => ({\n html: part.html,\n Mount: part.component ? mounts[part.component]?.Mount : undefined,\n variant: part.component ? (mounts[part.component]?.variant ?? 'default') : 'default',\n}))\n`\n : ''\n const bodyRender = mounts.length\n ? ` {parts.map(({ html: part, Mount, variant }) => (Mount ? <Mount entry={entry} variant={variant} /> : <Fragment set:html={part} />))}`\n : ` <Fragment set:html={html} />`\n const renderRefs = (position: 'header' | 'footer') =>\n components\n .filter((c) => c.position === position)\n .map((c) => ` <${c.name} marks={marks} />`)\n .join('\\n')\n\n files[`src/layouts/${name}.astro`] = `---\n// Family: ${family.id}${family.name ? ` (${family.name})` : ''} — emitted by @contentrain/emitter-astro\nimport chrome from '../data/chrome/${family.id}.json'\nimport { cssHref, fillAttrs, renderTemplate, composeBody${mounts.length ? ', splitComponents' : ''} } from '../lib/fill'\n${componentImports ? `${componentImports}\\n` : ''}\ninterface Props {\n title?: string\n marks?: Record<string, unknown>\n /** Page content as an HTML string; slot children are used when absent. */\n body?: string\n /** Stylesheets only this page loads (page-builder sites emit CSS per page). */\n css?: string[]\n /** Document language — a multilingual site's routes each pass their own. */\n lang?: string\n /** Content-store address of the page's entry — mounted components (comments) key on it. */\n entry?: { model_id: string; entry_id: string; locale?: string }\n}\nconst { title = '', marks = {}, body, css = [], lang = ${JSON.stringify(lang)}${mounts.length ? ', entry' : ''} } = Astro.props\nconst head = renderTemplate(chrome.head, marks)\n// Root attributes carry the theme's layout hooks; values may hold @@marks@@.\n// An explicit lang from the source wins over the project default.\nconst htmlAttrs = { lang, ...fillAttrs(chrome.html_attrs, marks) }\nconst bodyAttrs = fillAttrs(chrome.body_attrs, marks)\nconst content = body ?? (Astro.slots.has('default') ? await Astro.slots.render('default') : '')\n// Split at the marker FIRST, then fill marks per side — filling first would\n// eat the @@body@@ inside the marker and silently drop the content.\nconst html = composeBody(chrome.body, marks, content)\n${mountTable}---\n<!doctype html>\n<html {...htmlAttrs}>\n <head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\" />\n${cssLinks\n .split('\\n')\n .filter(Boolean)\n .map((l) => ` ${l}`)\n .join('\\n')}\n {css.map((file) => (\n <link rel=\"stylesheet\" href={cssHref(file)} />\n ))}\n <Fragment set:html={head} />\n <title>{title}</title>\n </head>\n <body {...bodyAttrs}>\n${renderRefs('header') ? `${renderRefs('header')}\\n` : ''}${bodyRender}\n${renderRefs('footer') ? `${renderRefs('footer')}\\n` : ''} </body>\n</html>\n`\n return { files, warnings }\n}\n","// Routes → Astro pages.\n//\n// Three shapes, matching what a migrated site actually needs:\n// - `single`: one page per post, body injected with `set:html` inside the family layout.\n// - list kinds (`archive`/`term`/`author`/`date`/`front` with a query): static\n// paths come from the producer's query data; items render through the\n// extracted item template when one exists.\n// - static kinds (no query): the family chrome IS the page.\n//\n// Pagination is a route parameter (`/news/page/:page`), never a separate\n// family — the generated paths simply include the page number in params.\n\nimport type { RouteModel, LayoutFamily } from '@contentrain/types'\nimport type { EmitContent, EmitPost, QueryPage } from './types.js'\nimport { DEFAULT_COLLECTION } from './types.js'\nimport { pascalCase, patternToPagePath, restParamIndex, stableJson } from './util.js'\n\nexport interface PageGenResult {\n files: Record<string, string>\n warnings: string[]\n}\n\nexport function routeFiles(\n route: RouteModel,\n family: LayoutFamily | undefined,\n content: EmitContent,\n siteLocale: string,\n): PageGenResult {\n const warnings: string[] = []\n const files: Record<string, string> = {}\n const pagePath = patternToPagePath(route.pattern)\n if (!pagePath) {\n return { files, warnings: [`route ${route.id}: pattern \"${route.pattern}\" cannot be expressed as an Astro page — skipped`] }\n }\n if (!family) {\n return { files, warnings: [`route ${route.id}: family \"${route.family}\" not found — skipped`] }\n }\n const layout = pascalCase(family.id)\n const depth = pagePath.split('/').length - 1\n const up = '../'.repeat(depth + 1) || '../'\n const hasParams = pagePath.includes('[')\n\n // Astro matches a rest parameter greedily, so anything after it is ambiguous.\n const restAt = restParamIndex(pagePath)\n if (restAt !== -1 && restAt !== pagePath.split('/').length - 1) {\n warnings.push(\n `route ${route.id}: rest parameter is not the final segment of \"${route.pattern}\" — Astro matches rest parameters greedily; verify this route resolves`,\n )\n }\n\n // Collection-driven: `single` always, and any route that names a collection —\n // a site's pages and custom post types are per-entry routes too, they just are\n // not called \"single\".\n if (route.kind === 'single' || route.collection !== undefined) {\n const collection = route.collection ?? DEFAULT_COLLECTION\n const posts = collectionItems(content, collection)\n if (!posts.length) {\n warnings.push(`route ${route.id}: collection \"${collection}\" has no items — page emitted, data file empty`)\n }\n files[`src/data/${collection}.json`] = stableJson(posts)\n files[`src/pages/${pagePath}`] = singlePage(route, layout, up, collection, siteLocale)\n return { files, warnings }\n }\n\n const queryPages: QueryPage[] = route.query ? (content.queries?.[route.query] ?? []) : []\n if (route.query) {\n if (!queryPages.length) warnings.push(`route ${route.id}: query \"${route.query}\" has no data — page emitted, data file empty`)\n if (queryPages.some((p) => !p.item_template && !p.sections?.length)) {\n warnings.push(`route ${route.id}: item template missing on some pages — plain fallback list rendered (not fidelity)`)\n }\n files[`src/data/queries/${route.query}.json`] = stableJson(queryPages)\n files[`src/pages/${pagePath}`] = listPage(route, layout, up, hasParams, siteLocale)\n return { files, warnings }\n }\n\n files[`src/pages/${pagePath}`] = staticPage(route, layout, up, hasParams, warnings, siteLocale)\n return { files, warnings }\n}\n\n/** Items of a named collection; `posts` also accepts the `content.posts` shorthand. */\nexport function collectionItems(content: EmitContent, collection: string): EmitPost[] {\n const named = content.collections?.[collection]\n if (named) return named\n return collection === DEFAULT_COLLECTION ? (content.posts ?? []) : []\n}\n\nfunction singlePage(route: RouteModel, layout: string, up: string, collection: string, siteLocale: string): string {\n const locale = JSON.stringify(route.locale ?? siteLocale)\n return `---\n// Route: ${route.id} (${route.pattern}) — collection: ${collection} — emitted by @contentrain/emitter-astro\nimport Layout from '${up}layouts/${layout}.astro'\nimport data from '${up}data/${collection}.json'\nimport { postMarks, type EmittedPost } from '${up}lib/fill'\n\nexport function getStaticPaths() {\n // The JSON is data; its contract is EmittedPost. Inferring the type from the\n // file would type only the fields this site happens to carry, and \"astro\n // check\" (which the build runs first) would then reject post.params on a site\n // whose permalinks need none. The cast lives INSIDE getStaticPaths because\n // Astro hoists this function: it sees imports, not the component scope.\n const posts = data as EmittedPost[]\n // A post carries its own route parameters (date parts of a dated permalink,\n // a post id) — using the template post's would send every other post to the\n // wrong address.\n return posts.map((post) => ({ params: { ...(post.params ?? {}), slug: post.slug }, props: { post } }))\n}\ntype Props = { post: EmittedPost }\nconst { post } = Astro.props as Props\n---\n<Layout\n title={post.title}\n marks={postMarks(post)}\n body={post.body}\n css={post.css ?? []}\n lang={post.locale ?? ${locale}}\n entry={post.entry}\n/>\n`\n}\n\nfunction listPage(route: RouteModel, layout: string, up: string, hasParams: boolean, siteLocale: string): string {\n const locale = JSON.stringify(route.locale ?? siteLocale)\n // Chain the fallbacks HERE, not in the emitted source: a route without a title\n // used to emit `page.title ?? \"\" ?? ''`, which \"astro check\" rejects as never\n // nullish — and the build runs check first, so the whole site failed to build.\n const routeTitle = route.title === undefined ? `''` : JSON.stringify(route.title)\n // The cast lives inside getStaticPaths: Astro hoists it above the component\n // scope, so it can read imports and nothing else.\n const paths = hasParams\n ? `export function getStaticPaths() {\n const pages = data as EmittedQueryPage[]\n return pages.map((page) => ({ params: page.params, props: { page } }))\n}\ntype Props = { page: EmittedQueryPage }\nconst { page } = Astro.props as Props`\n : `const page = (data as EmittedQueryPage[])[0] ?? { params: {}, items: [], title: undefined }`\n return `---\n// Route: ${route.id} (${route.pattern}) — emitted by @contentrain/emitter-astro\nimport Layout from '${up}layouts/${layout}.astro'\nimport data from '${up}data/queries/${route.query}.json'\nimport { esc, postMarks, renderSections, type EmittedQueryPage } from '${up}lib/fill'\n\n${paths}\n// A list renders in sections (a big card then a grid) — one template is the\n// single-section case. Without a template at all we fall back to a plain list\n// and the emitter has already warned: silence would read as fidelity.\nconst sections = page.sections ?? (page.item_template ? [{ template: page.item_template }] : [])\nconst content = sections.length\n ? renderSections(sections, page.items, postMarks)\n : '<ul class=\"cr-post-list\">' +\n page.items.map((item) => '<li><a href=\"/' + item.slug + '/\">' + esc(item.title) + '</a></li>').join('') +\n '</ul>'\n// Route params carry slugs; page marks carry what the chrome needs to SHOW\n// (a term's display name, description, count).\nconst marks = { ...page.params, ...(page.marks ?? {}) }\nconst title = page.title ?? ${routeTitle}\n---\n<Layout\n title={title || String(marks.term_name ?? page.params.term ?? '')}\n marks={marks}\n body={content}\n css={page.css ?? []}\n lang={${locale}}\n/>\n`\n}\n\nfunction staticPage(route: RouteModel, layout: string, up: string, hasParams: boolean, warnings: string[], siteLocale: string): string {\n if (hasParams) warnings.push(`route ${route.id}: parameterized route without a query — emitted with empty paths`)\n const paths = hasParams\n ? `export function getStaticPaths() {\n return []\n}\n`\n : ''\n return `---\n// Route: ${route.id} (${route.pattern}) — emitted by @contentrain/emitter-astro\nimport Layout from '${up}layouts/${layout}.astro'\n${paths}---\n<Layout title=${JSON.stringify(route.title ?? '')} marks={{}} lang={${JSON.stringify(route.locale ?? siteLocale)}} />\n`\n}\n","// The runtime-component client, emitted as `src/lib/embed.ts`.\n//\n// Kept as source text (like the fill runtime in scaffold.ts) so the generated\n// site depends on nothing but Astro. The text is type-checked and executed by\n// this package's tests — `embed.test.ts` writes it to disk and imports it, and\n// `typecheck.test.ts` runs tsc over it with Astro's strict settings — so the\n// code that ships is the code that was tested, escaping included.\n\nexport const EMBED_TS = `// Emitted by @contentrain/emitter-astro — the runtime-component client.\n//\n// Comments and forms are the two regions of a migrated site that need a live\n// service. This module is the browser side of the provider's PUBLIC API\n// (Studio: /api/forms/v1 and /api/comments/v1) — the same contract that\n// @contentrain/query/cdn implements, inlined so the generated site depends on\n// nothing but Astro. No credential travels with a request: the endpoints are\n// unauthenticated by design, and a page cannot keep a secret.\n//\n// Rendering helpers are pure (strings in, HTML out, escaped by default) so the\n// mount functions at the bottom are the only code that touches the DOM.\n\nexport interface Runtime {\n base_url: string\n project_id: string\n}\n\nexport interface EntryRef {\n model_id: string\n entry_id: string\n locale?: string\n}\n\nexport class EmbedError extends Error {\n status: number\n constructor(status: number, message: string) {\n super(message)\n this.name = 'EmbedError'\n this.status = status\n }\n}\n\n/** Visitor-facing strings — override from the site (\\`strings.send = 'Gönder'\\`). */\nexport const strings = {\n loading: 'Loading…',\n send: 'Send',\n sending: 'Sending…',\n commentsTitle: 'Comments',\n noComments: 'No comments yet.',\n closed: 'Comments are closed.',\n postComment: 'Post comment',\n reply: 'Reply',\n replyingTo: 'Replying to',\n cancel: 'Cancel',\n loadMore: 'Load more comments',\n moderator: 'Moderator',\n pending: 'Thank you — your comment is awaiting moderation.',\n posted: 'Your comment has been posted.',\n name: 'Name',\n email: 'Email',\n emailNote: '(never shown)',\n website: 'Website',\n comment: 'Comment',\n failed: 'Something went wrong. Please try again.',\n honeypot: 'Leave this field empty',\n}\n\nexport const esc = (value: unknown): string =>\n String(value ?? '')\n .replace(/&/g, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n\n// ─── Transport ───\n\n/** \\`base\\` without a trailing slash, segments URL-encoded, empty query values dropped. */\nexport function publicUrl(base: string, segments: string[], query?: Record<string, string | number | undefined>): string {\n const path = segments.map((s) => encodeURIComponent(s)).join('/')\n const params = new URLSearchParams()\n for (const [key, value] of Object.entries(query ?? {})) {\n if (value !== undefined && value !== '') params.set(key, String(value))\n }\n const qs = params.toString()\n return base.replace(/\\\\/+$/, '') + '/' + path + (qs ? '?' + qs : '')\n}\n\nasync function failure(res: Response): Promise<EmbedError> {\n const text = await res.text().catch(() => '')\n let message = text || 'Request failed'\n try {\n const parsed = JSON.parse(text) as { message?: unknown; statusMessage?: unknown }\n const m = parsed.message ?? parsed.statusMessage\n if (typeof m === 'string' && m) message = m\n } catch {\n // not JSON — the raw text is the message\n }\n return new EmbedError(res.status, message)\n}\n\nexport async function getJson<T>(url: string): Promise<T> {\n const res = await fetch(url)\n if (!res.ok) throw await failure(res)\n return (await res.json()) as T\n}\n\nexport async function postJson<T>(url: string, body: unknown): Promise<T> {\n const res = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(body),\n })\n if (!res.ok) throw await failure(res)\n return (await res.json()) as T\n}\n\nexport const formsRoot = (rt: Runtime): string => rt.base_url.replace(/\\\\/+$/, '') + '/api/forms/v1'\nexport const commentsRoot = (rt: Runtime): string => rt.base_url.replace(/\\\\/+$/, '') + '/api/comments/v1'\n\n/** The hidden input Cloudflare Turnstile adds to the enclosing form. */\nexport const CAPTCHA_FIELD = 'cf-turnstile-response'\n\n// ─── Forms ───\n\nexport interface FieldDef {\n type: string\n required?: boolean\n label?: string\n options?: string[]\n min?: number\n max?: number\n pattern?: string\n default?: unknown\n}\n\nexport interface FormConfig {\n modelId: string\n locale: string\n /** Exposed fields only, keyed by field id. */\n fields: Record<string, FieldDef>\n captcha: 'turnstile' | null\n captchaSiteKey: string | null\n successMessage?: string\n honeypotField: string | null\n}\n\nexport interface FieldError {\n field: string\n message: string\n}\n\nexport interface FormSubmitResult {\n success: boolean\n message?: string\n errors?: FieldError[]\n}\n\n/** The documented request body: values under \\`data\\`, control fields beside it. */\nexport interface FormPayload {\n data: Record<string, unknown>\n captchaToken?: string\n _hp?: string\n}\n\nexport function fetchFormConfig(rt: Runtime, model: string): Promise<FormConfig> {\n return getJson<FormConfig>(publicUrl(formsRoot(rt), [rt.project_id, model, 'config']))\n}\n\nexport function submitForm(rt: Runtime, model: string, payload: FormPayload): Promise<FormSubmitResult> {\n return postJson<FormSubmitResult>(publicUrl(formsRoot(rt), [rt.project_id, model, 'submit']), payload)\n}\n\nfunction coerce(def: FieldDef, value: string): unknown {\n switch (def.type) {\n case 'number':\n case 'integer':\n case 'decimal':\n case 'percent':\n case 'rating':\n return value === '' ? '' : Number(value)\n case 'boolean':\n return value === 'on' || value === 'true'\n default:\n return value\n }\n}\n\n/**\n * Form entries (\\`new FormData(form)\\`) → request body. Only exposed fields\n * reach \\`data\\`; the captcha token and the honeypot value travel beside it, so\n * a control field can never collide with a model field. Files are skipped —\n * uploads are not part of the public contract.\n */\nexport function formPayload(\n entries: Iterable<[string, unknown]>,\n config: { fields: Record<string, FieldDef>; honeypotField: string | null },\n): FormPayload {\n const payload: FormPayload = { data: {} }\n for (const [name, raw] of entries) {\n if (typeof raw !== 'string') continue\n if (name === CAPTCHA_FIELD) {\n if (raw) payload.captchaToken = raw\n continue\n }\n if (config.honeypotField && name === config.honeypotField) {\n payload._hp = raw\n continue\n }\n const def = config.fields[name]\n if (!def) continue\n payload.data[name] = coerce(def, raw)\n }\n return payload\n}\n\nexport function labelFor(id: string, def: FieldDef): string {\n if (def.label) return def.label\n const words = id.replace(/[_-]+/g, ' ').trim()\n return words.charAt(0).toUpperCase() + words.slice(1)\n}\n\n/** One input for one exposed field, chosen by field type. */\nexport function fieldControl(id: string, def: FieldDef, prefix = 'cr-field'): string {\n const common =\n ' id=\"' + esc(prefix + '-' + id) + '\" name=\"' + esc(id) + '\"' +\n (def.required ? ' required' : '') +\n (def.pattern ? ' pattern=\"' + esc(def.pattern) + '\"' : '')\n const range =\n (def.min !== undefined ? ' min=\"' + esc(def.min) + '\"' : '') +\n (def.max !== undefined ? ' max=\"' + esc(def.max) + '\"' : '')\n const length = def.max !== undefined ? ' maxlength=\"' + esc(def.max) + '\"' : ''\n switch (def.type) {\n case 'text':\n case 'markdown':\n case 'richtext':\n case 'code':\n return '<textarea' + common + length + ' rows=\"5\"></textarea>'\n case 'select': {\n const blank = def.required ? '' : '<option value=\"\"></option>'\n const options = (def.options ?? []).map((o) => '<option value=\"' + esc(o) + '\">' + esc(o) + '</option>').join('')\n return '<select' + common + '>' + blank + options + '</select>'\n }\n case 'boolean':\n return '<input type=\"checkbox\"' + common + ' />'\n case 'number':\n case 'integer':\n case 'rating':\n return '<input type=\"number\"' + common + range + (def.type === 'integer' ? ' step=\"1\"' : '') + ' />'\n case 'decimal':\n case 'percent':\n return '<input type=\"number\" step=\"any\"' + common + range + ' />'\n case 'email':\n return '<input type=\"email\"' + common + length + ' />'\n case 'url':\n return '<input type=\"url\"' + common + length + ' />'\n case 'phone':\n return '<input type=\"tel\"' + common + length + ' />'\n case 'date':\n return '<input type=\"date\"' + common + ' />'\n case 'datetime':\n return '<input type=\"datetime-local\"' + common + ' />'\n case 'color':\n return '<input type=\"color\"' + common + ' />'\n default:\n return '<input type=\"text\"' + common + length + ' />'\n }\n}\n\nexport function honeypotHtml(field: string | null): string {\n if (!field) return ''\n return (\n '<p class=\"cr-hp\" aria-hidden=\"true\" style=\"position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden\">' +\n '<label>' + esc(strings.honeypot) + ' <input type=\"text\" name=\"' + esc(field) + '\" tabindex=\"-1\" autocomplete=\"off\" /></label></p>'\n )\n}\n\nexport function captchaHtml(captcha: 'turnstile' | null, siteKey: string | null): string {\n if (captcha !== 'turnstile' || !siteKey) return ''\n return '<div class=\"cf-turnstile\" data-sitekey=\"' + esc(siteKey) + '\"></div>'\n}\n\nexport function errorsHtml(errors: FieldError[]): string {\n return (\n '<ul class=\"cr-errors\" role=\"alert\">' +\n errors.map((e) => '<li data-field=\"' + esc(e.field) + '\">' + esc(e.field) + ': ' + esc(e.message) + '</li>').join('') +\n '</ul>'\n )\n}\n\n/** The whole form for a public form config: one control per exposed field, honeypot, captcha, submit. */\nexport function formHtml(config: FormConfig, prefix = 'cr-field'): string {\n const fields = Object.entries(config.fields)\n .map(([id, def]) => {\n const control = fieldControl(id, def, prefix)\n const label = '<label for=\"' + esc(prefix + '-' + id) + '\">' + esc(labelFor(id, def)) + (def.required ? ' <span aria-hidden=\"true\">*</span>' : '') + '</label>'\n return '<p class=\"cr-field cr-field--' + esc(def.type) + '\">' + (def.type === 'boolean' ? control + ' ' + label : label + control) + '</p>'\n })\n .join('')\n return (\n '<form class=\"cr-form\" method=\"post\" data-model=\"' + esc(config.modelId) + '\">' +\n fields +\n honeypotHtml(config.honeypotField) +\n captchaHtml(config.captcha, config.captchaSiteKey) +\n '<p class=\"cr-actions\"><button type=\"submit\">' + esc(strings.send) + '</button></p>' +\n '<div class=\"cr-status\" aria-live=\"polite\"></div>' +\n '</form>'\n )\n}\n\n// ─── Comments ───\n\nexport interface CommentAuthor {\n name: string\n url: string | null\n isModerator: boolean\n}\n\nexport interface PublicComment {\n id: string\n parentId: string | null\n depth: number\n author: CommentAuthor\n /** Plain text. */\n body: string\n type: 'comment' | 'pingback' | 'trackback'\n createdAt: string\n replies: PublicComment[]\n}\n\nexport interface ThreadConfig {\n closed: boolean\n requireApproval: boolean\n requireEmail: boolean\n maxDepth: number\n maxBodyLength: number\n captcha: 'turnstile' | null\n captchaSiteKey: string | null\n honeypotField: string | null\n}\n\nexport interface CommentThread {\n entry: { modelId: string; entryId: string; locale: string }\n config: ThreadConfig\n comments: PublicComment[]\n total: number\n page: number\n limit: number\n}\n\nexport interface CommentSubmitBody {\n author: { name: string; email?: string; url?: string }\n body: string\n /** null for a root comment — sent explicitly, as the provider's own fixture does. */\n parentId: string | null\n captchaToken?: string\n _hp?: string\n}\n\nexport interface CommentSubmitResult {\n success: boolean\n status?: 'pending' | 'approved'\n comment?: PublicComment\n errors?: FieldError[]\n}\n\nexport interface ThreadQuery {\n page?: number\n limit?: number\n sort?: 'oldest' | 'newest'\n}\n\nexport function fetchThread(rt: Runtime, entry: EntryRef, query?: ThreadQuery): Promise<CommentThread> {\n const url = publicUrl(commentsRoot(rt), [rt.project_id, entry.model_id, entry.entry_id], {\n locale: entry.locale,\n page: query?.page,\n limit: query?.limit,\n sort: query?.sort,\n })\n return getJson<CommentThread>(url)\n}\n\nexport function submitComment(rt: Runtime, entry: EntryRef, body: CommentSubmitBody): Promise<CommentSubmitResult> {\n const url = publicUrl(commentsRoot(rt), [rt.project_id, entry.model_id, entry.entry_id], { locale: entry.locale })\n return postJson<CommentSubmitResult>(url, body)\n}\n\n/** Comment-form entries → the documented request body (inputs: author_name, author_email, author_url, body, parent_id). */\nexport function commentPayload(entries: Iterable<[string, unknown]>, honeypotField: string | null): CommentSubmitBody {\n const values: Record<string, string> = {}\n const payload: CommentSubmitBody = { author: { name: '' }, body: '', parentId: null }\n for (const [name, raw] of entries) {\n if (typeof raw !== 'string') continue\n if (name === CAPTCHA_FIELD) {\n if (raw) payload.captchaToken = raw\n continue\n }\n if (honeypotField && name === honeypotField) {\n payload._hp = raw\n continue\n }\n values[name] = raw\n }\n payload.author.name = (values.author_name ?? '').trim()\n if (values.author_email?.trim()) payload.author.email = values.author_email.trim()\n if (values.author_url?.trim()) payload.author.url = values.author_url.trim()\n payload.body = (values.body ?? '').trim()\n payload.parentId = values.parent_id?.trim() || null\n return payload\n}\n\n/** Plain text → paragraphs; blank lines separate paragraphs, single newlines break lines. */\nexport function bodyHtml(text: string): string {\n return text\n .split(/\\\\n{2,}/)\n .map((p) => p.trim())\n .filter(Boolean)\n .map((p) => '<p>' + esc(p).replace(/\\\\n/g, '<br />') + '</p>')\n .join('')\n}\n\nexport function hasMore(thread: CommentThread): boolean {\n return thread.page * thread.limit < thread.total\n}\n\n/** One comment with its replies. A reply button appears only while the thread is open and depth allows it. */\nexport function commentHtml(c: PublicComment, config: { closed: boolean; maxDepth: number }): string {\n const author = c.author.url\n ? '<a href=\"' + esc(c.author.url) + '\" rel=\"nofollow ugc noopener\" target=\"_blank\">' + esc(c.author.name) + '</a>'\n : esc(c.author.name)\n const badge = c.author.isModerator ? ' <span class=\"cr-moderator\">' + esc(strings.moderator) + '</span>' : ''\n const date = '<time datetime=\"' + esc(c.createdAt) + '\">' + esc(c.createdAt.slice(0, 10)) + '</time>'\n const canReply = !config.closed && c.depth < config.maxDepth\n const reply = canReply\n ? '<button type=\"button\" class=\"cr-reply\" data-parent=\"' + esc(c.id) + '\" data-author=\"' + esc(c.author.name) + '\">' + esc(strings.reply) + '</button>'\n : ''\n const replies = c.replies.length\n ? '<ol class=\"cr-replies\">' + c.replies.map((r) => commentHtml(r, config)).join('') + '</ol>'\n : ''\n return (\n '<li class=\"cr-comment cr-comment--' + esc(c.type) + '\" id=\"cr-comment-' + esc(c.id) + '\" data-depth=\"' + esc(c.depth) + '\">' +\n '<div class=\"cr-comment-meta\"><span class=\"cr-comment-author\">' + author + '</span>' + badge + ' ' + date + '</div>' +\n '<div class=\"cr-comment-body\">' + bodyHtml(c.body) + '</div>' +\n (reply ? '<div class=\"cr-comment-actions\">' + reply + '</div>' : '') +\n replies +\n '</li>'\n )\n}\n\nexport function threadHtml(thread: CommentThread): string {\n if (!thread.comments.length) return '<p class=\"cr-empty\">' + esc(strings.noComments) + '</p>'\n return '<ol class=\"cr-comment-list\">' + thread.comments.map((c) => commentHtml(c, thread.config)).join('') + '</ol>'\n}\n\n/** The comment form; a reply carries its parent in a hidden input. */\nexport function commentFormHtml(config: ThreadConfig, prefix = 'cr-c'): string {\n const emailLabel = esc(strings.email) + (config.requireEmail ? ' <span aria-hidden=\"true\">*</span>' : '') + ' <small>' + esc(strings.emailNote) + '</small>'\n return (\n '<form class=\"cr-comment-form\" method=\"post\">' +\n '<input type=\"hidden\" name=\"parent_id\" value=\"\" />' +\n '<p class=\"cr-replying\" hidden><span class=\"cr-replying-to\"></span> <button type=\"button\" class=\"cr-cancel-reply\">' + esc(strings.cancel) + '</button></p>' +\n '<p class=\"cr-field\"><label for=\"' + prefix + '-body\">' + esc(strings.comment) + ' <span aria-hidden=\"true\">*</span></label>' +\n '<textarea id=\"' + prefix + '-body\" name=\"body\" required maxlength=\"' + esc(config.maxBodyLength) + '\" rows=\"5\"></textarea></p>' +\n '<p class=\"cr-field\"><label for=\"' + prefix + '-name\">' + esc(strings.name) + ' <span aria-hidden=\"true\">*</span></label>' +\n '<input type=\"text\" id=\"' + prefix + '-name\" name=\"author_name\" required maxlength=\"120\" autocomplete=\"name\" /></p>' +\n '<p class=\"cr-field\"><label for=\"' + prefix + '-email\">' + emailLabel + '</label>' +\n '<input type=\"email\" id=\"' + prefix + '-email\" name=\"author_email\"' + (config.requireEmail ? ' required' : '') + ' maxlength=\"254\" autocomplete=\"email\" /></p>' +\n '<p class=\"cr-field\"><label for=\"' + prefix + '-url\">' + esc(strings.website) + '</label>' +\n '<input type=\"url\" id=\"' + prefix + '-url\" name=\"author_url\" maxlength=\"2048\" autocomplete=\"url\" /></p>' +\n honeypotHtml(config.honeypotField) +\n captchaHtml(config.captcha, config.captchaSiteKey) +\n '<p class=\"cr-actions\"><button type=\"submit\">' + esc(strings.postComment) + '</button></p>' +\n '<div class=\"cr-status\" aria-live=\"polite\"></div>' +\n '</form>'\n )\n}\n\n// ─── Turnstile (explicit render, so widgets added after load still render) ───\n\ninterface TurnstileApi {\n render: (el: Element, options: { sitekey: string }) => string\n reset: (id?: string) => void\n}\n\nconst TURNSTILE_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&onload=__crTurnstileReady'\nconst widgets = new WeakMap<Element, string>()\nconst waiting: Element[] = []\n\nfunction turnstileApi(): TurnstileApi | undefined {\n return (globalThis as { turnstile?: TurnstileApi }).turnstile\n}\n\nfunction renderWidget(api: TurnstileApi, el: Element): void {\n if (widgets.has(el)) return\n widgets.set(el, api.render(el, { sitekey: el.getAttribute('data-sitekey') ?? '' }))\n}\n\n/** Render every \\`.cf-turnstile\\` under \\`root\\`, loading the Turnstile script once on first use. */\nexport function renderCaptcha(root: ParentNode): void {\n const els = Array.from(root.querySelectorAll('.cf-turnstile'))\n if (!els.length) return\n const api = turnstileApi()\n if (api) {\n for (const el of els) renderWidget(api, el)\n return\n }\n waiting.push(...els)\n if (document.querySelector('script[data-cr-turnstile]')) return\n ;(globalThis as Record<string, unknown>).__crTurnstileReady = () => {\n const ready = turnstileApi()\n if (!ready) return\n for (const el of waiting.splice(0)) renderWidget(ready, el)\n }\n const script = document.createElement('script')\n script.src = TURNSTILE_SRC\n script.async = true\n script.defer = true\n script.setAttribute('data-cr-turnstile', '1')\n document.head.appendChild(script)\n}\n\nexport function resetCaptcha(root: ParentNode): void {\n const api = turnstileApi()\n if (!api) return\n for (const el of Array.from(root.querySelectorAll('.cf-turnstile'))) api.reset(widgets.get(el))\n}\n\n// ─── Mounting (the only DOM code) ───\n\nexport function runtimeOf(host: HTMLElement): Runtime {\n return { base_url: host.dataset.baseUrl ?? '', project_id: host.dataset.project ?? '' }\n}\n\nfunction entryOf(host: HTMLElement): EntryRef {\n return { model_id: host.dataset.model ?? '', entry_id: host.dataset.entry ?? '', locale: host.dataset.locale || undefined }\n}\n\nfunction setStatus(form: HTMLFormElement, html: string): void {\n const status = form.querySelector('.cr-status')\n if (status) status.innerHTML = html\n}\n\nfunction busy(form: HTMLFormElement, on: boolean): void {\n const button = form.querySelector<HTMLButtonElement>('button[type=\"submit\"]')\n if (!button) return\n button.disabled = on\n if (on) {\n button.dataset.label = button.textContent ?? ''\n button.textContent = strings.sending\n } else {\n button.textContent = button.dataset.label ?? button.textContent\n }\n}\n\nfunction entriesOf(form: HTMLFormElement): Array<[string, unknown]> {\n const out: Array<[string, unknown]> = []\n new FormData(form).forEach((value, key) => out.push([key, value]))\n return out\n}\n\n/** \\`<cr-form data-base-url data-project data-model>\\` → fetch the config, render, submit. */\nexport async function mountForm(host: HTMLElement): Promise<void> {\n const rt = runtimeOf(host)\n const model = host.dataset.model ?? ''\n host.innerHTML = '<p class=\"cr-loading\">' + esc(strings.loading) + '</p>'\n let config: FormConfig\n try {\n config = await fetchFormConfig(rt, model)\n } catch (error) {\n host.innerHTML = '<p class=\"cr-error\">' + esc(error instanceof Error ? error.message : strings.failed) + '</p>'\n return\n }\n host.innerHTML = formHtml(config)\n renderCaptcha(host)\n const form = host.querySelector('form')\n if (!form) return\n form.addEventListener('submit', async (event) => {\n event.preventDefault()\n busy(form, true)\n setStatus(form, '')\n try {\n const result = await submitForm(rt, model, formPayload(entriesOf(form), config))\n if (result.success) {\n host.innerHTML = '<p class=\"cr-success\" role=\"status\">' + esc(result.message ?? config.successMessage ?? '') + '</p>'\n return\n }\n setStatus(form, errorsHtml(result.errors ?? []))\n resetCaptcha(form)\n } catch (error) {\n setStatus(form, '<p class=\"cr-error\" role=\"alert\">' + esc(error instanceof Error ? error.message : strings.failed) + '</p>')\n resetCaptcha(form)\n } finally {\n busy(form, false)\n }\n })\n}\n\n/** \\`<cr-comments data-base-url data-project data-model data-entry data-locale>\\` → thread + form. */\nexport async function mountComments(host: HTMLElement): Promise<void> {\n const rt = runtimeOf(host)\n const entry = entryOf(host)\n host.innerHTML = '<p class=\"cr-loading\">' + esc(strings.loading) + '</p>'\n let thread: CommentThread\n try {\n thread = await fetchThread(rt, entry)\n } catch (error) {\n host.innerHTML = '<p class=\"cr-error\">' + esc(error instanceof Error ? error.message : strings.failed) + '</p>'\n return\n }\n\n host.innerHTML =\n '<section class=\"cr-comments-section\">' +\n '<h2 class=\"cr-comments-title\">' + esc(strings.commentsTitle) + ' <span class=\"cr-count\">(' + esc(thread.total) + ')</span></h2>' +\n '<div class=\"cr-thread\">' + threadHtml(thread) + '</div>' +\n (hasMore(thread) ? '<p class=\"cr-more\"><button type=\"button\" class=\"cr-load-more\">' + esc(strings.loadMore) + '</button></p>' : '') +\n (thread.config.closed ? '<p class=\"cr-closed\">' + esc(strings.closed) + '</p>' : commentFormHtml(thread.config)) +\n '</section>'\n renderCaptcha(host)\n\n const threadEl = host.querySelector('.cr-thread')\n const form = host.querySelector<HTMLFormElement>('form.cr-comment-form')\n let page = thread.page\n\n host.addEventListener('click', async (event) => {\n const target = event.target as HTMLElement | null\n if (!target) return\n const replyButton = target.closest<HTMLElement>('.cr-reply')\n if (replyButton && form) {\n const parent = form.querySelector<HTMLInputElement>('input[name=\"parent_id\"]')\n if (parent) parent.value = replyButton.dataset.parent ?? ''\n const replying = form.querySelector<HTMLElement>('.cr-replying')\n const to = form.querySelector('.cr-replying-to')\n if (to) to.textContent = strings.replyingTo + ' ' + (replyButton.dataset.author ?? '')\n if (replying) replying.hidden = false\n replyButton.closest('.cr-comment')?.appendChild(form)\n form.querySelector<HTMLTextAreaElement>('textarea[name=\"body\"]')?.focus()\n return\n }\n if (target.closest('.cr-cancel-reply') && form) {\n const parent = form.querySelector<HTMLInputElement>('input[name=\"parent_id\"]')\n if (parent) parent.value = ''\n const replying = form.querySelector<HTMLElement>('.cr-replying')\n if (replying) replying.hidden = true\n host.querySelector('.cr-comments-section')?.appendChild(form)\n return\n }\n const more = target.closest<HTMLButtonElement>('.cr-load-more')\n if (more && threadEl) {\n more.disabled = true\n try {\n const next = await fetchThread(rt, entry, { page: page + 1 })\n page = next.page\n const list = threadEl.querySelector('.cr-comment-list')\n if (list) list.insertAdjacentHTML('beforeend', next.comments.map((c) => commentHtml(c, next.config)).join(''))\n if (!hasMore(next)) more.closest('.cr-more')?.remove()\n } finally {\n more.disabled = false\n }\n }\n })\n\n form?.addEventListener('submit', async (event) => {\n event.preventDefault()\n busy(form, true)\n setStatus(form, '')\n try {\n const result = await submitComment(rt, entry, commentPayload(entriesOf(form), thread.config.honeypotField))\n if (!result.success) {\n setStatus(form, errorsHtml(result.errors ?? []))\n resetCaptcha(form)\n return\n }\n const parentId = result.comment?.parentId ?? null\n if (result.status === 'approved' && result.comment && threadEl) {\n const html = commentHtml(result.comment, thread.config)\n const parent = parentId ? threadEl.querySelector('#cr-comment-' + parentId) : null\n if (parent) {\n let replies = parent.querySelector(':scope > .cr-replies')\n if (!replies) {\n parent.insertAdjacentHTML('beforeend', '<ol class=\"cr-replies\"></ol>')\n replies = parent.querySelector(':scope > .cr-replies')\n }\n replies?.insertAdjacentHTML('beforeend', html)\n } else {\n const list = threadEl.querySelector('.cr-comment-list')\n if (list) list.insertAdjacentHTML('beforeend', html)\n else threadEl.innerHTML = '<ol class=\"cr-comment-list\">' + html + '</ol>'\n }\n }\n form.reset()\n host.querySelector('.cr-comments-section')?.appendChild(form)\n const replying = form.querySelector<HTMLElement>('.cr-replying')\n if (replying) replying.hidden = true\n resetCaptcha(form)\n setStatus(form, '<p class=\"cr-success\" role=\"status\">' + esc(result.status === 'approved' ? strings.posted : strings.pending) + '</p>')\n } catch (error) {\n setStatus(form, '<p class=\"cr-error\" role=\"alert\">' + esc(error instanceof Error ? error.message : strings.failed) + '</p>')\n resetCaptcha(form)\n } finally {\n busy(form, false)\n }\n })\n}\n`\n","// Components.\n//\n// `runtime`-sourced comments and forms get REAL implementations: an Astro\n// component that mounts a custom element carrying the runtime binding and the\n// page's entry address, and a client script that fetches, renders and submits\n// against the provider's public API (src/lib/embed.ts). Everything else — and\n// every runtime component when no binding was given — is a `<cr-component>`\n// placeholder that marks the spot: the emitter cannot invent an ad slot, and a\n// comments form without a provider is a promise the page cannot keep.\n//\n// A component is mounted where the chrome carries its marker\n// (`<!--@@component:ID@@-->`, see layouts.ts); emitting the file alone would\n// leave it unused, which is not integration.\n\nimport type { ComponentDef, RuntimeBinding } from '@contentrain/types'\nimport { EMBED_TS } from './embed.js'\nimport { pascalCase, stableJson } from './util.js'\n\nexport interface ComponentGenResult {\n files: Record<string, string>\n warnings: string[]\n}\n\n/** Component types this emitter implements against a runtime binding. */\nexport const RUNTIME_IMPLEMENTED = new Set<ComponentDef['type']>(['comments', 'form'])\n\nexport function isRuntimeImplemented(c: ComponentDef, runtime: RuntimeBinding | undefined): boolean {\n if (!runtime || c.source !== 'runtime' || !RUNTIME_IMPLEMENTED.has(c.type)) return false\n return c.type !== 'form' || Boolean(c.model)\n}\n\nexport function componentFiles(components: ComponentDef[], runtime: RuntimeBinding | undefined): ComponentGenResult {\n const files: Record<string, string> = {}\n const warnings: string[] = []\n let needsRuntime = false\n\n for (const c of components) {\n const name = pascalCase(c.id)\n if (isRuntimeImplemented(c, runtime)) {\n needsRuntime = true\n files[`src/components/${name}.astro`] = c.type === 'comments' ? commentsSource(c) : formSource(c)\n continue\n }\n if (c.source === 'runtime' && RUNTIME_IMPLEMENTED.has(c.type)) {\n warnings.push(\n !runtime\n ? `component ${c.id} (${c.type}): no runtime binding given — emitted as a placeholder; pass input.runtime to mount it`\n : `component ${c.id} (form): no model named — a form must say which collection it submits to; emitted as a placeholder`,\n )\n }\n files[`src/components/${name}.astro`] = placeholderSource(c)\n }\n\n if (needsRuntime && runtime) {\n files['src/data/runtime.json'] = stableJson({ base_url: runtime.base_url, project_id: runtime.project_id })\n files['src/lib/embed.ts'] = EMBED_TS\n }\n return { files, warnings }\n}\n\nfunction header(c: ComponentDef, extra = ''): string {\n return `/**\n * cr-component: type=${c.type} source=${c.source}${c.name ? ` — ${c.name}` : ''}\n * Emitted by @contentrain/emitter-astro.${extra}\n */`\n}\n\nfunction placeholderSource(c: ComponentDef): string {\n const variants = (c.variants ?? []).map((v) => v.key)\n return `---\n${header(\n c,\n `${c.source === 'runtime' ? '\\n * Placeholder — needs a live provider; see the migration handoff offers.' : ' Placeholder.'}${\n variants.length ? `\\n * Variants: ${variants.join(', ')}` : ''\n }`,\n)}\ninterface Props {\n variant?: string\n /** Content-store address of the page's entry; unused by a placeholder. */\n entry?: { model_id: string; entry_id: string; locale?: string }\n}\nconst { variant = ${JSON.stringify(variants[0] ?? 'default')} } = Astro.props\n---\n<cr-component data-type=${JSON.stringify(c.type)} data-variant={variant}></cr-component>\n`\n}\n\nfunction commentsSource(c: ComponentDef): string {\n return `---\n${header(c, `\n * Mounted comments thread: reads the entry's approved comments from the runtime\n * provider's public API and posts new ones. Pending comments are never shown —\n * moderation happens on the provider. Renders nothing on a page without an\n * entry address (a list, a static page).`)}\nimport runtime from '../data/runtime.json'\n\ninterface Props {\n /** Content-store address of the page's entry — the thread's key. */\n entry?: { model_id: string; entry_id: string; locale?: string }\n variant?: string\n}\nconst { entry, variant = 'default' } = Astro.props\n---\n{entry && (\n <cr-comments\n class={\\`cr-comments cr-comments--\\${variant}\\`}\n data-base-url={runtime.base_url}\n data-project={runtime.project_id}\n data-model={entry.model_id}\n data-entry={entry.entry_id}\n data-locale={entry.locale}\n data-variant={variant}\n >\n <noscript><p class=\"cr-noscript\">Comments need JavaScript.</p></noscript>\n </cr-comments>\n)}\n\n<script>\n import { mountComments } from '../lib/embed'\n\n class CrComments extends HTMLElement {\n connectedCallback() {\n void mountComments(this)\n }\n }\n if (!customElements.get('cr-comments')) customElements.define('cr-comments', CrComments)\n</script>\n`\n}\n\nfunction formSource(c: ComponentDef): string {\n return `---\n${header(c, `\n * Mounted public form for the ${JSON.stringify(c.model)} model: fetches the\n * exposed fields from the runtime provider, renders one control per field\n * (honeypot and captcha included when the model asks for them) and submits.\n * Submissions land on the provider for moderation; an approved one becomes a\n * content entry there.`)}\nimport runtime from '../data/runtime.json'\n\ninterface Props {\n variant?: string\n /** Accepted for uniform mounting; a form is addressed by its model, not by the page's entry. */\n entry?: { model_id: string; entry_id: string; locale?: string }\n}\nconst { variant = 'default' } = Astro.props\n---\n<cr-form\n class={\\`cr-form-mount cr-form-mount--\\${variant}\\`}\n data-base-url={runtime.base_url}\n data-project={runtime.project_id}\n data-model=${JSON.stringify(c.model)}\n data-variant={variant}\n>\n <noscript><p class=\"cr-noscript\">This form needs JavaScript.</p></noscript>\n</cr-form>\n\n<script>\n import { mountForm } from '../lib/embed'\n\n class CrForm extends HTMLElement {\n connectedCallback() {\n void mountForm(this)\n }\n }\n if (!customElements.get('cr-form')) customElements.define('cr-form', CrForm)\n</script>\n`\n}\n","// Header and footer chrome → shared Astro components.\n//\n// The body chrome stays one fragment because the content container is nested\n// deep inside it. The masthead and the colophon are the exception: they sit\n// outside the content path and repeat across families, so lifting them out\n// gives the project one address for the nav (where a jQuery-free menu replaces\n// the theme's) and one copy of the markup instead of N.\n//\n// Identity is by CONTENT, not by declared name: two families whose headers are\n// byte-identical share a component even if the producer named them differently,\n// and two different headers that claim the same name get separate files plus a\n// warning — never a silent swap, which would put one family's nav on another's\n// pages.\n\nimport type { LayoutFamily } from '@contentrain/types'\nimport { CHROME_BODY_SLOT } from '@contentrain/types'\nimport { balanceWarning } from './balance.js'\nimport { pascalCase, stableJson } from './util.js'\n\nexport type ChromePosition = 'header' | 'footer'\n\nexport interface ChromeComponentRef {\n name: string\n position: ChromePosition\n}\n\nexport interface ChromeRegistry {\n files: Record<string, string>\n /** Family id → the components its layout renders, in chunk order. */\n byFamily: Map<string, ChromeComponentRef[]>\n warnings: string[]\n}\n\nconst DEFAULT_NAME: Record<ChromePosition, string> = {\n header: 'SiteHeader',\n footer: 'SiteFooter',\n}\n\n/** A component name Astro can import: PascalCase and starting with a letter. */\nfunction componentName(raw: string): string {\n const pascal = pascalCase(raw)\n return /^[A-Za-z]/.test(pascal) ? pascal : `Chrome${pascal}`\n}\n\nfunction componentSource(name: string): string {\n return `---\n/**\n * Chrome component: ${name} — emitted by @contentrain/emitter-astro.\n * Markup travels as data so the Astro compiler never parses theme markup, and\n * fills the same \\`@@mark@@\\` placeholders the rest of the chrome does.\n */\nimport chunk from '../data/components/${name}.json'\nimport { renderTemplate } from '../lib/fill'\n\ninterface Props {\n /** Page marks — the region's own placeholders (site name, current-page class). */\n marks?: Record<string, unknown>\n}\nconst { marks = {} } = Astro.props\n---\n<Fragment set:html={renderTemplate(chunk.html, marks)} />\n`\n}\n\nexport function chromeComponents(families: LayoutFamily[]): ChromeRegistry {\n const files: Record<string, string> = {}\n const byFamily = new Map<string, ChromeComponentRef[]>()\n const warnings: string[] = []\n /** html → emitted component name, so identical regions collapse into one. */\n const byHtml = new Map<string, string>()\n /** emitted name → its html, so a name clash can be detected and renamed. */\n const byName = new Map<string, string>()\n\n for (const family of families) {\n const refs: ChromeComponentRef[] = []\n for (const chunk of family.chrome ?? []) {\n if (chunk.position !== 'header' && chunk.position !== 'footer') continue\n const position: ChromePosition = chunk.position\n const where = `family ${family.id}: ${position} chrome`\n\n if (chunk.html.includes(CHROME_BODY_SLOT)) {\n warnings.push(\n `${where} carries ${CHROME_BODY_SLOT} — content goes in the body chunk; the marker is rendered as a comment`,\n )\n }\n const unbalanced = balanceWarning(chunk.html)\n if (unbalanced) {\n warnings.push(\n `${where} is not balanced (${unbalanced}) — a lifted region must close what it opens; keep it in the body chunk`,\n )\n }\n\n const wanted = componentName(chunk.component ?? DEFAULT_NAME[position])\n let name = byHtml.get(chunk.html)\n if (name === undefined) {\n name = wanted\n for (let n = 2; byName.has(name) && byName.get(name) !== chunk.html; n++) {\n name = `${wanted}${n}`\n }\n if (name !== wanted) {\n warnings.push(\n `${where}: component name \"${wanted}\" is already used by different markup — emitted as \"${name}\"`,\n )\n }\n byHtml.set(chunk.html, name)\n byName.set(name, chunk.html)\n files[`src/data/components/${name}.json`] = stableJson({ html: chunk.html })\n files[`src/components/${name}.astro`] = componentSource(name)\n }\n refs.push({ name, position })\n }\n if (refs.length) byFamily.set(family.id, refs)\n }\n\n return { files, byFamily, warnings }\n}\n","// Legacy CSS quarantine (the fidelity layer).\n//\n// Migrated pages must render pixel-identically, and a utility framework's\n// preflight/reset would fight the theme's CSS. The truce is a cascade layer:\n// every legacy stylesheet is wrapped in `@layer legacy { … }`, and modern CSS\n// lives in its own layer — the two never meet in specificity.\n//\n// One CSS rule constrains the wrapping: `@import` (and `@charset`) must\n// precede all other statements, so a file cannot simply be wrapped whole.\n// Leading imports are hoisted above the wrapper and given `layer(legacy)`;\n// an import *after* other rules is already invalid CSS that browsers ignore,\n// so the file is left unwrapped and a warning recorded instead of silently\n// producing different invalidity.\n\nimport { LEGACY_CSS_LAYER } from '@contentrain/types'\n\nexport interface WrapCssResult {\n content: string\n warning?: string\n}\n\nconst LEADING = /^(\\s*(?:@charset[^;]+;|@import[^;]+;|\\/\\*[\\s\\S]*?\\*\\/)\\s*)+/\n\nexport function wrapLegacyCss(path: string, css: string): WrapCssResult {\n const rest = css.replace(LEADING, '')\n if (/@import\\s/.test(rest)) {\n return {\n content: css,\n warning: `${path}: @import after other rules — left unwrapped (fix the source stylesheet; mid-file imports are ignored by browsers)`,\n }\n }\n const leading = css.slice(0, css.length - rest.length)\n const hoisted = leading.replace(/@import\\s+([^;]+);/g, (full, spec: string) =>\n /\\blayer\\s*\\(/.test(spec) ? full : `@import ${spec.trim()} layer(${LEGACY_CSS_LAYER});`,\n )\n return { content: `${hoisted}@layer ${LEGACY_CSS_LAYER} {\\n${rest}\\n}\\n` }\n}\n","// @contentrain/emitter-astro — the open half of the migration pipeline.\n//\n// A ProjectIR (route model, layout families, component variants, query\n// bindings, design tokens) plus prepared content goes in; a complete Astro\n// project comes out as a pure file map. The analysis that *produces* a good\n// ProjectIR is hard and lives elsewhere; rendering one is deliberately\n// boring — which is exactly why it can be open, portable, and replaceable\n// by community emitters for other frameworks.\n\nimport type { EmitInput, EmitPost, EmitResult } from './types.js'\nimport { DEFAULT_COLLECTION } from './types.js'\nimport { scaffoldFiles } from './scaffold.js'\nimport { componentMarkers, familyFiles } from './layouts.js'\nimport { collectionItems, routeFiles } from './pages.js'\nimport { componentFiles, isRuntimeImplemented } from './components.js'\nimport { chromeComponents } from './chrome.js'\nimport { wrapLegacyCss } from './css.js'\n\nexport function emitAstroProject(input: EmitInput): EmitResult {\n const { ir } = input\n const warnings: string[] = []\n const files: Record<string, string> = {}\n const add = (batch: Record<string, string>) => {\n for (const [path, content] of Object.entries(batch)) {\n if (files[path] !== undefined && files[path] !== content) {\n warnings.push(`duplicate file with different content: ${path} — keeping the first`)\n continue\n }\n files[path] = content\n }\n }\n\n add(scaffoldFiles(ir, input.options ?? {}))\n\n const familiesById = new Map(ir.families.map((f) => [f.id, f]))\n const lang = ir.site.locales?.[0] ?? 'en'\n // Header/footer regions first: families that share one share the component.\n const chrome = chromeComponents(ir.families)\n add(chrome.files)\n warnings.push(...chrome.warnings)\n const definitions = new Map((ir.components ?? []).map((c) => [c.id, c]))\n // A component marker can sit inside a post's own body (a form in a page's\n // content). The layout that renders that body must mount it, so collect the\n // ids per family from the collections its routes render.\n const bodyMarkersByFamily = new Map<string, Set<string>>()\n for (const route of ir.routes) {\n if (route.kind !== 'single' && route.collection === undefined) continue\n const posts = collectionItems(input.content ?? {}, route.collection ?? DEFAULT_COLLECTION)\n const ids = bodyMarkersByFamily.get(route.family) ?? new Set<string>()\n for (const post of posts) for (const id of componentMarkers(post.body)) ids.add(id)\n if (ids.size) bodyMarkersByFamily.set(route.family, ids)\n }\n for (const family of ir.families) {\n const fam = familyFiles(family, lang, chrome.byFamily.get(family.id), definitions, bodyMarkersByFamily.get(family.id) ?? [])\n add(fam.files)\n warnings.push(...fam.warnings)\n }\n\n for (const css of input.css ?? []) {\n const wrapped = wrapLegacyCss(css.path, css.content)\n if (wrapped.warning) warnings.push(wrapped.warning)\n const base = css.path.split('/').pop() ?? css.path\n add({ [`public/styles/legacy/${base}`]: wrapped.content })\n }\n const providedCss = new Set((input.css ?? []).map((c) => c.path.split('/').pop() ?? c.path))\n const missingCss = (refs: string[] | undefined, owner: string) => {\n for (const ref of refs ?? []) {\n if (!providedCss.has(ref.split('/').pop() ?? ref)) {\n warnings.push(`${owner}: css file \"${ref}\" not provided in input.css`)\n }\n }\n }\n for (const family of ir.families) missingCss(family.css.files, `family ${family.id}`)\n const allPosts = [\n ...(input.content?.posts ?? []),\n ...Object.values(input.content?.collections ?? {}).flat(),\n ]\n for (const post of allPosts) missingCss(post.css, `post ${post.slug}`)\n for (const [queryId, queryPages] of Object.entries(input.content?.queries ?? {})) {\n for (const qp of queryPages) missingCss(qp.css, `query ${queryId}`)\n }\n\n for (const route of ir.routes) {\n const result = routeFiles(route, familiesById.get(route.family), input.content ?? {}, lang)\n add(result.files)\n warnings.push(...result.warnings)\n }\n\n const components = componentFiles(ir.components ?? [], input.runtime)\n add(components.files)\n warnings.push(...components.warnings)\n\n // A mounted comments thread is keyed by the post's entry address; a post\n // without one renders no thread. Say so once per collection, not per page.\n const commentsMounted = (ir.components ?? []).some((c) => isRuntimeImplemented(c, input.runtime) && c.type === 'comments')\n if (commentsMounted) {\n const collections: Record<string, EmitPost[]> = { ...input.content?.collections }\n if (input.content?.posts && !collections[DEFAULT_COLLECTION]) collections[DEFAULT_COLLECTION] = input.content.posts\n for (const [name, posts] of Object.entries(collections)) {\n const unbound = posts.filter((p) => !p.entry).length\n if (unbound) warnings.push(`collection ${name}: ${unbound} of ${posts.length} posts carry no entry address — the comments component renders nothing on those pages`)\n }\n }\n\n return { files, warnings }\n}\n\n/** Write an EmitResult to disk. Separate from emit so the core stays pure. */\nexport async function writeEmit(result: EmitResult, dir: string): Promise<void> {\n const { mkdir, writeFile } = await import('node:fs/promises')\n const { dirname, join } = await import('node:path')\n const dirs = new Set(Object.keys(result.files).map((p) => dirname(join(dir, p))))\n await Promise.all([...dirs].map((d) => mkdir(d, { recursive: true })))\n await Promise.all(\n Object.entries(result.files).map(([path, content]) => writeFile(join(dir, path), content, 'utf8')),\n )\n}\n\nexport type {\n EmitInput,\n EmitResult,\n EmitContent,\n EmitPost,\n QueryPage,\n EmitCssFile,\n EmitOptions,\n EmitTermRef,\n RuntimeBinding,\n EntrySourceRef,\n} from './types.js'\nexport { wrapLegacyCss } from './css.js'\nexport { pascalCase, patternToPagePath, stableJson } from './util.js'\nexport { componentMarkers } from './layouts.js'\nexport type { MountRef } from './layouts.js'\nexport { isRuntimeImplemented, RUNTIME_IMPLEMENTED } from './components.js'\nexport { EMBED_TS } from './embed.js'\nexport { checkBalance, balanceWarning } from './balance.js'\nexport type { BalanceReport } from './balance.js'\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../src/types.ts","../src/util.ts","../src/scaffold.ts","../src/balance.ts","../src/seo.ts","../src/layouts.ts","../src/pages.ts","../src/embed.ts","../src/components.ts","../src/chrome.ts","../src/css.ts","../src/index.ts"],"sourcesContent":["import type { EntrySourceRef, ProjectIR, RuntimeBinding } from '@contentrain/types'\n\n// ─── Emit input ───\n\n/**\n * One piece of content ready to render: body already HTML, assets already\n * rewritten to local paths, dates already formatted in the site's observed\n * formats. The emitter renders; it does not fetch, localize, or format —\n * those are producer concerns, and keeping them out is what makes this\n * package portable.\n */\n/** A term as a list card prints it: its name and the archive it links to. */\nexport interface EmitTermRef {\n name: string\n link?: string\n}\n\nexport interface EmitPost {\n slug: string\n title: string\n /** Rendered, asset-rewritten HTML body. */\n body: string\n /**\n * Route parameters for this post beyond `slug` — the date parts of a\n * `/%year%/%monthnum%/%day%/%postname%/` permalink, a post id, anything the\n * route pattern names. Without them every post but the template one would be\n * generated at the wrong address.\n */\n params?: Record<string, string>\n /**\n * Stylesheets only this page loads (page-builder sites emit CSS per page).\n * Family-level stylesheets stay on `LayoutFamily.css.files`.\n */\n css?: string[]\n /** The site's date-format catalog applied to this post's date (index-aligned with `date{n}` marks). */\n dates?: string[]\n author?: string\n author_first?: string\n author_last?: string\n /**\n * Term names, or `{ name, link }` objects for themes whose term lists link\n * each term — a repeat block then reads `item_name` / `item_link`. The\n * `term{n}` and `terms` marks print the name either way.\n */\n terms?: Array<string | EmitTermRef>\n /** Local featured-image file names (largest first). */\n featured?: string[]\n excerpt?: string\n /** Excerpt as source HTML, for themes whose cards keep links and formatting. */\n excerpt_html?: string\n /**\n * Meta description. Falls back to `excerpt` with markup stripped — a card\n * and a description tag want the same sentence, so most producers set neither.\n */\n description?: string\n /**\n * Social image, absolute or site-root-relative. `featured` holds bare file\n * names whose serving path only the producer knows, so a crawler-usable\n * `og:image` needs this; a `featured` entry that is already a path is used.\n */\n image?: string\n /** Canonical override — for a page that should point somewhere else. Default: this page's own address. */\n canonical?: string\n /** ISO 8601, for Article structured data. `dates` holds display strings, which schema.org cannot read. */\n published_at?: string\n modified_at?: string\n /** Every author, for repeat blocks — `author` stays the first one. */\n authors?: string[]\n /** Producer-supplied extra marks, merged last. */\n marks?: Record<string, unknown>\n /** Locale of this entry, on a multilingual site — overrides the route's. */\n locale?: string\n /**\n * Content-store address of this post (model, entry id, locale) — what a\n * mounted `comments` component addresses its thread by. Only the tool that\n * wrote the content store knows it; without it the comments region mounts\n * as a placeholder.\n */\n entry?: EntrySourceRef\n}\n\n/**\n * One block of a list: a template, optional wrapper, and how many items it takes.\n *\n * Themes commonly render the newest post as a big card and the rest as a grid —\n * two different templates in two different containers. One template per list\n * cannot express that: either everything becomes a big card, or the big card is\n * baked into the chrome and page 2 shows the wrong post.\n */\nexport interface ListSection {\n /** Item markup with `@@marks@@`. */\n template: string\n /** Markup around this section's items; `<!--@@items@@-->` marks where they go. */\n wrapper?: string\n /** Items this section takes, in order. Omitted = the remaining items. */\n count?: number\n}\n\n/** One static path of a list route: its params and the items its list renders. */\nexport interface QueryPage {\n params: Record<string, string>\n items: EmitPost[]\n /**\n * Page-level marks for the list page's chrome — a term's display NAME where\n * the route parameter only carries its slug, its description, its count.\n * Without these every category page would print the template category's name.\n */\n marks?: Record<string, unknown>\n /** Stylesheets only this list page loads. */\n css?: string[]\n /**\n * Item markup with `@@mark@@` placeholders (title, date{n}, author, excerpt,\n * feat, slug), extracted from the source list. When present, list pages\n * render each item by filling the template; when absent, a plain fallback\n * list is emitted and a warning recorded — a silent fallback would read as\n * fidelity when it is not.\n *\n * Shorthand for a single-section list; `sections` takes precedence.\n */\n item_template?: string\n /** Lists whose items are not all rendered alike — a big card then a grid. */\n sections?: ListSection[]\n /** Document title for this page (`<title>`), e.g. \"Category: News – Site\". */\n title?: string\n /** Meta description for this list page. */\n description?: string\n /** Social image, absolute or site-root-relative. */\n image?: string\n /** Canonical override; default is this page's own address. */\n canonical?: string\n}\n\n/** Default collection name when a `single` route does not name one. */\nexport const DEFAULT_COLLECTION = 'posts'\n\nexport interface EmitContent {\n /** The default (`posts`) collection — shorthand for `collections.posts`. */\n posts?: EmitPost[]\n /**\n * Content per collection, for sites with more than one kind of single page\n * (posts, pages, custom post types). A `single` route reads the collection\n * named by `RouteModel.collection`.\n */\n collections?: Record<string, EmitPost[]>\n /** QueryBinding id → the static paths (and items) that query produces. */\n queries?: Record<string, QueryPage[]>\n}\n\n/** A legacy stylesheet provided by the producer, referenced from `LayoutFamily.css.files`. */\nexport interface EmitCssFile {\n path: string\n content: string\n}\n\nexport interface EmitOptions {\n /** Generated project's package name. Default: \"migrated-site\". */\n projectName?: string\n /** Include Tailwind 4 as the evolution layer (default true). */\n tailwind?: boolean\n /**\n * Emit per-page SEO — title, description, canonical, Open Graph, Twitter card\n * and Article structured data — and remove the template page's copies of those\n * tags from the head chrome. Default true: inheriting one page's canonical and\n * og:title on every page is worse than having neither. Set false to keep the\n * source head verbatim and own these tags in the producer.\n */\n seo?: boolean\n}\n\nexport interface EmitInput {\n ir: ProjectIR\n content?: EmitContent\n css?: EmitCssFile[]\n options?: EmitOptions\n /**\n * Where runtime components (comments, forms) talk to. Without it they are\n * emitted as placeholders and a warning names each one — a migration is\n * complete without any runtime offer being accepted.\n */\n runtime?: RuntimeBinding\n}\n\n// ─── Emit output ───\n\n/**\n * Pure result: every generated file as path → content. Writing to disk is a\n * separate, trivial step (`writeEmit`) so the core stays testable and usable\n * from any host — CLI, service, or another emitter wrapping this one.\n */\nexport interface EmitResult {\n files: Record<string, string>\n warnings: string[]\n}\n\nexport type { ProjectIR, RuntimeBinding, EntrySourceRef }\n","/** \"f-archive\" → \"FArchive\", \"posts\" → \"Posts\" — Astro component file names. */\nexport function pascalCase(id: string): string {\n return id\n .split(/[^a-zA-Z0-9]+/)\n .filter(Boolean)\n .map((part) => part.charAt(0).toUpperCase() + part.slice(1))\n .join('')\n}\n\n/**\n * Route pattern → Astro pages path.\n * `/` → `index.astro`; `/category/:term/page/:page` → `category/[term]/page/[page].astro`;\n * a trailing `*` on a parameter makes it a rest parameter — `/category/:term*`\n * → `category/[...term].astro`, which is what keeps a nested taxonomy address\n * (`/category/about-cc/events/`) from collapsing to its last segment.\n * Returns null for patterns Astro's file routing cannot express (recorded as a warning upstream).\n */\nexport function patternToPagePath(pattern: string): string | null {\n if (pattern === '/' || pattern === '') return 'index.astro'\n const segments = pattern.replace(/^\\/+|\\/+$/g, '').split('/')\n // Literal Unicode segments preserve the source permalink. Validate literals\n // separately from parameters so widening the alphabet cannot admit traversal\n // or turn a source filename containing brackets into an Astro parameter.\n if (segments.some((seg) => seg.startsWith(':')\n ? !/^:[a-zA-Z0-9_]+\\*?$/.test(seg)\n : seg === '.' || seg === '..' || !/^[\\p{L}\\p{M}\\p{N}._-]+$/u.test(seg))) return null\n const mapped = segments.map((seg) => {\n if (!seg.startsWith(':')) return seg\n const rest = seg.endsWith('*')\n const name = seg.slice(1, rest ? -1 : undefined)\n return rest ? `[...${name}]` : `[${name}]`\n })\n const last = mapped.pop()\n return [...mapped, `${last}.astro`].join('/')\n}\n\n/** Index of the rest parameter segment in a page path, or -1. */\nexport function restParamIndex(pagePath: string): number {\n return pagePath.split('/').findIndex((seg) => seg.startsWith('[...'))\n}\n\nconst sortDeep = (v: unknown): unknown => {\n if (Array.isArray(v)) return v.map(sortDeep)\n if (v && typeof v === 'object') {\n return Object.fromEntries(\n Object.entries(v as Record<string, unknown>)\n .toSorted(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))\n .map(([k, val]) => [k, sortDeep(val)]),\n )\n }\n return v\n}\n\n/** JSON with stable key order and trailing newline — deterministic output, stable diffs. */\nexport function stableJson(value: unknown): string {\n return `${JSON.stringify(sortDeep(value), null, 2)}\\n`\n}\n","// Project scaffolding: manifest, config, styles, and the shared fill helper\n// the generated pages use to pour content into `@@mark@@` placeholders.\n\nimport type { DesignTokens, ProjectIR } from '@contentrain/types'\nimport type { EmitOptions } from './types.js'\nimport { stableJson } from './util.js'\n\nexport function scaffoldFiles(ir: ProjectIR, options: EmitOptions): Record<string, string> {\n const tailwind = options.tailwind !== false\n const split = ir.viewport_strategy === 'split'\n const files: Record<string, string> = {}\n\n const pkg: Record<string, unknown> = {\n name: options.projectName ?? 'migrated-site',\n private: true,\n type: 'module',\n scripts: {\n dev: 'astro dev',\n // `astro check` first: type errors in generated pages should fail the\n // build, not surface later in a browser.\n build: 'astro check && astro build',\n preview: 'astro preview',\n // Split viewport production (skeleton): one build per device class, served by device.\n ...(split ? { 'build:desktop': 'VIEWPORT=desktop astro build --outDir dist/desktop', 'build:mobile': 'VIEWPORT=mobile astro build --outDir dist/mobile' } : {}),\n },\n dependencies: {\n astro: '^5.0.0',\n ...(tailwind ? { tailwindcss: '^4.0.0', '@tailwindcss/vite': '^4.0.0' } : {}),\n },\n devDependencies: {\n '@astrojs/check': '^0.9.0',\n typescript: '^5.7.0',\n },\n }\n files['package.json'] = stableJson(pkg)\n\n files['astro.config.mjs'] = [\n `import { defineConfig } from 'astro/config'`,\n ...(tailwind ? [`import tailwindcss from '@tailwindcss/vite'`] : []),\n ``,\n `export default defineConfig({`,\n // Canonical URLs and sitemaps hang off \\`site\\` — for a migration, SEO\n // continuity is the point, so the source site's URL always lands here.\n ` site: ${JSON.stringify(ir.site.url)},`,\n ` build: { format: 'directory' },`,\n ...(tailwind ? [` vite: { plugins: [tailwindcss()] },`] : []),\n `})`,\n ``,\n ].join('\\n')\n\n // Astro wants a tsconfig in every project — editors and the compiler read\n // it even in JS-only projects, and the emitted src/lib/fill.ts is TS.\n files['tsconfig.json'] = `${JSON.stringify({ extends: 'astro/tsconfigs/base', include: ['.astro/types.d.ts', '**/*'], exclude: ['dist', 'public', 'node_modules'] }, null, 2)}\\n`\n\n if (tailwind) files['src/styles/modern.css'] = modernCss(ir.tokens)\n\n files['src/lib/fill.ts'] = FILL_TS\n return files\n}\n\n/** Tailwind 4 evolution layer: CSS-first config with the site's extracted tokens. */\nfunction modernCss(tokens: DesignTokens | undefined): string {\n const lines: string[] = [`@import 'tailwindcss';`, ``]\n const theme: string[] = []\n const push = (prefix: string, map: Record<string, string> | undefined) => {\n for (const [key, value] of Object.entries(map ?? {})) theme.push(` --${prefix}-${key}: ${value};`)\n }\n push('color', tokens?.colors)\n push('font', tokens?.font_families)\n push('text', tokens?.font_sizes)\n push('spacing', tokens?.spacing)\n push('breakpoint', tokens?.breakpoints)\n if (theme.length) lines.push(`@theme {`, ...theme, `}`)\n return `${lines.join('\\n')}\\n`\n}\n\nconst FILL_TS = `// Emitted by @contentrain/emitter-astro — the template runtime.\n//\n// Chrome, item templates and attribute values carry three marker forms:\n// @@mark@@ — a value, escaped\n// @@mark_html@@ — a value, raw HTML\n// <!--@@repeat:list@@-->…<!--@@/repeat@@--> — once per list item\n// <!--@@if:name@@-->…<!--@@/if@@--> — only when name has a value\n// Rendering order is repeats → conditionals → marks, so a repeat body may hold\n// conditionals and marks that only make sense per item.\n\n/** Where page content splices into the body chrome — must match @contentrain/types CHROME_BODY_SLOT. */\nexport const BODY_SLOT = '<!--@@body@@-->'\n\nconst REPEAT_RE = /<!--@@repeat:([a-z0-9_]+)(?:\\\\|([\\\\s\\\\S]*?))?@@-->([\\\\s\\\\S]*?)<!--@@\\\\/repeat@@-->/gi\nconst IF_RE = /<!--@@if:(!?)([a-z0-9_]+)@@-->([\\\\s\\\\S]*?)<!--@@\\\\/if@@-->/gi\nconst MARK_RE = /@@([a-z0-9_]+)@@/gi\n\nexport type Values = Record<string, unknown>\n\nexport const esc = (value: unknown): string =>\n String(value ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')\n\nconst isFilled = (value: unknown): boolean =>\n Array.isArray(value) ? value.length > 0 : value !== undefined && value !== null && value !== ''\n\n/**\n * Replace @@marks@@. Values are escaped unless the mark name ends in _html —\n * escaping is the default so content-derived text can never break the page,\n * and the _html opt-in exists for themes that print a post's own markup\n * (full content in a list card, a link-bearing excerpt).\n */\nexport function fillMarks(html: string, values: Values): string {\n return html.replace(MARK_RE, (_all, key: string) => {\n const value = values[key]\n if (Array.isArray(value)) return value.map((v) => esc(v && typeof v === 'object' && 'name' in v ? (v as { name: unknown }).name : v)).join(', ')\n return key.toLowerCase().endsWith('_html') ? String(value ?? '') : esc(value ?? '')\n })\n}\n\n/** Expand repeat blocks; each item renders the inner fragment with its own values. */\nexport function expandRepeats(html: string, values: Values): string {\n return html.replace(REPEAT_RE, (_all, name: string, sep: string | undefined, inner: string) => {\n const list = values[name]\n if (!Array.isArray(list) || list.length === 0) return ''\n return list\n .map((item, index) => {\n // An object item prints its name at @@item@@ and exposes item_<key> for\n // the rest, so a template written for string terms keeps working when\n // the producer starts sending { name, link }.\n const record = item && typeof item === 'object' ? (item as Record<string, unknown>) : undefined\n const itemValues: Values = { ...values, item: record && 'name' in record ? record.name : item, item_index: String(index) }\n if (record) {\n for (const [k, v] of Object.entries(record)) itemValues['item_' + k] = v\n }\n return renderTemplate(inner, itemValues)\n })\n .join(sep ?? '')\n })\n}\n\n/** Drop conditional blocks whose value is empty (or present, when negated). */\nexport function applyConditions(html: string, values: Values): string {\n return html.replace(IF_RE, (_all, negate: string, name: string, inner: string) =>\n isFilled(values[name]) !== (negate === '!') ? inner : '',\n )\n}\n\n/** Full render: repeats → conditionals → marks. */\nexport function renderTemplate(html: string, values: Values): string {\n return fillMarks(applyConditions(expandRepeats(html, values), values), values)\n}\n\n/** Fill marks inside attribute values (per-page classes like postid-123). */\nexport function fillAttrs(attrs: Record<string, string> | undefined, values: Values): Record<string, string> {\n const out: Record<string, string> = {}\n for (const [name, value] of Object.entries(attrs ?? {})) out[name] = fillMarks(value, values)\n return out\n}\n\n/**\n * Compose the body chrome with the page content. The split happens BEFORE any\n * rendering: the @@…@@ pattern would otherwise consume the @@body@@ inside the\n * marker comment, leaving <!----> behind and silently dropping the content\n * (measured cost on a real page: 49.8 vs 97.8).\n */\nexport function composeBody(chromeBody: string, values: Values, content: string): string {\n return chromeBody\n .split(BODY_SLOT)\n .map((part) => renderTemplate(part, values))\n .join(content)\n}\n\n/** Component mount marker — must match @contentrain/types CHROME_COMPONENT_OPEN / CHROME_COMPONENT_CLOSE. */\nexport const COMPONENT_OPEN = '<!--@@component:'\nexport const COMPONENT_CLOSE = '@@-->'\nconst COMPONENT_RE = /<!--@@component:([^@\\\\s]+)@@-->/g\n\nexport interface BodyPart {\n html: string\n /** A mount point: the component id the marker named. */\n component?: string\n}\n\n/**\n * Split rendered chrome at component markers into html parts interleaved with\n * mount points, so the layout can render a real component where the theme's\n * comments form (or contact form) stood. The parts are output in order, so the\n * page's HTML is exactly the chrome with the component's markup at the marker —\n * no parser sees the pieces separately.\n */\nexport function splitComponents(html: string): BodyPart[] {\n const parts: BodyPart[] = []\n let last = 0\n for (const match of html.matchAll(COMPONENT_RE)) {\n parts.push({ html: html.slice(last, match.index) })\n parts.push({ html: '', component: match[1] ?? '' })\n last = match.index + match[0].length\n }\n parts.push({ html: html.slice(last) })\n return parts\n}\n\n/** Where a list section's wrapper takes its items — must match @contentrain/types LIST_ITEMS_SLOT. */\nexport const ITEMS_SLOT = '<!--@@items@@-->'\n\nexport interface ListSection {\n template: string\n wrapper?: string\n count?: number\n}\n\n/**\n * Render a list in sections. Themes often render the newest post as a big card\n * and the rest as a grid — two templates in two containers — so a list is a\n * sequence of sections, each taking a count of items (or the remainder).\n */\nexport function renderSections<T>(\n sections: ListSection[],\n items: T[],\n values: (item: T) => Values,\n): string {\n let index = 0\n const out: string[] = []\n for (const section of sections) {\n const take = section.count ?? items.length - index\n const slice = items.slice(index, index + take)\n index += slice.length\n if (slice.length === 0) continue\n const rendered = slice.map((item) => renderTemplate(section.template, values(item))).join('')\n out.push(section.wrapper ? section.wrapper.split(ITEMS_SLOT).join(rendered) : rendered)\n }\n return out.join('')\n}\n\n// ─── SEO helpers ───\n\n/**\n * A meta description from free text: markup stripped, whitespace collapsed,\n * cut at a word boundary. The cap is a rendering limit, not an editorial\n * choice — an excerpt is a paragraph and a description tag is a line.\n */\nexport function seoDescription(text: string | undefined, max = 300): string | undefined {\n if (!text) return undefined\n const flat = text.replace(/<[^>]*>/g, ' ').replace(/\\\\s+/g, ' ').trim()\n if (!flat) return undefined\n if (flat.length <= max) return flat\n const cut = flat.slice(0, max)\n const space = cut.lastIndexOf(' ')\n // Concatenation, not a template literal: this file is emitted from one, and\n // an unescaped interpolation would be evaluated at emit time.\n return (space > max * 0.6 ? cut.slice(0, space) : cut).trimEnd() + '…'\n}\n\n/**\n * Absolute URL for a social tag or a canonical link. Crawlers do not resolve\n * relative og:image or og:url, so a value that cannot be made absolute is\n * dropped rather than emitted half-formed. Needs \\`site\\` in astro.config.mjs.\n */\nexport function absoluteUrl(value: string | undefined, site: URL | undefined): string | undefined {\n if (!value) return undefined\n if (/^https?:\\\\/\\\\//i.test(value)) return value\n if (!site) return undefined\n try {\n return new URL(value, site).toString()\n } catch {\n return undefined\n }\n}\n\n/**\n * JSON-LD payload for a \\`<script type=\"application/ld+json\">\\`. \\`<\\` is escaped\n * so a value containing \\`</script>\\` cannot close the block and inject markup.\n */\nexport function jsonLd(value: unknown): string {\n return JSON.stringify(value).replace(/</g, '\\\\\\\\u003c')\n}\n\n/** Emitted stylesheets live under /styles/legacy/ — pages reference them by file name. */\nexport const cssHref = (file: string): string => '/styles/legacy/' + (file.split('/').pop() ?? file)\n\n/** A term as a list card prints it: name, and the archive it links to. */\nexport interface TermRef {\n name: string\n link?: string\n}\n\n/** The display name of a term given as a string or a TermRef. */\nexport const termName = (term: string | TermRef): string => (typeof term === 'string' ? term : term.name)\n\nexport interface MarkablePost {\n slug: string\n title: string\n body?: string\n dates?: string[]\n author?: string\n author_first?: string\n author_last?: string\n authors?: string[]\n /**\n * Strings, or objects for themes whose term lists link each term\n * (\\`<!--@@repeat:terms@@-->\\` with \\`item_name\\` / \\`item_link\\`). The\n * \\`term{n}\\` and \\`terms\\` marks print the name either way.\n */\n terms?: Array<string | TermRef>\n featured?: string[]\n excerpt?: string\n excerpt_html?: string\n marks?: Record<string, unknown>\n}\n\n/**\n * The mark vocabulary: title, author (+ first/last), date{n}, term{n}, feat{n},\n * excerpt, slug — plus \\`terms\\`/\\`authors\\` as LISTS for repeat blocks, the\n * _html variants for raw insertion, and any producer-supplied extras.\n */\nexport function postMarks(post: MarkablePost): Values {\n const values: Values = {\n title: post.title,\n author: post.author ?? '',\n author_first: post.author_first ?? '',\n author_last: post.author_last ?? '',\n excerpt: post.excerpt ?? '',\n excerpt_html: post.excerpt_html ?? post.excerpt ?? '',\n body_html: post.body ?? '',\n slug: post.slug,\n feat: post.featured?.[0] ?? '',\n // Objects stay objects for repeat blocks (item_name / item_link); the joined\n // \\`terms\\` mark prints names, so fillMarks never sees [object Object].\n terms: (post.terms ?? []).map((t) => (typeof t === 'string' ? { name: t, link: '' } : { name: t.name, link: t.link ?? '' })),\n terms_names: (post.terms ?? []).map(termName),\n authors: post.authors ?? (post.author ? [post.author] : []),\n }\n for (const [i, d] of (post.dates ?? []).entries()) values['date' + i] = d\n for (const [i, t] of (post.terms ?? []).entries()) values['term' + i] = termName(t)\n for (const [i, f] of (post.featured ?? []).entries()) values['feat' + i] = f\n return { ...values, ...(post.marks ?? {}) }\n}\n\n/**\n * The shapes of the emitted data files. Pages import JSON and assert these\n * rather than letting TypeScript infer the shape from the file's contents:\n * inference reads only the fields the data HAPPENS to carry, so a site whose\n * posts need no extra route parameters produced a type without \\`params\\` and\n * \\`astro check\\` — which the build runs first — failed on the page that reads\n * it. The contract is what the emitter may write, not what one site wrote.\n */\n/** Content-store address of an entry — what a mounted comments component keys its thread by. */\nexport interface EntryRef {\n model_id: string\n entry_id: string\n locale?: string\n}\n\nexport interface SeoInput {\n title?: string\n description?: string\n canonical?: string\n image?: string\n type?: 'article' | 'website'\n publishedAt?: string\n modifiedAt?: string\n author?: string\n}\n\n/**\n * The SEO of one entry page. \\`featured\\` holds bare file names — only the\n * producer knows where media is served from — so an image is taken from it\n * only when it already reads as a path; otherwise \\`image\\` supplies it and\n * the tag is omitted rather than pointing at a URL that does not resolve.\n */\nexport function postSeo(post: EmittedPost): SeoInput {\n const featured = (post.featured ?? []).find((f) => f.startsWith('/') || /^https?:/i.test(f))\n return {\n title: post.title,\n description: post.description ?? post.excerpt,\n canonical: post.canonical,\n image: post.image ?? featured,\n type: 'article',\n publishedAt: post.published_at,\n modifiedAt: post.modified_at,\n author: post.author,\n }\n}\n\nexport interface EmittedPost extends MarkablePost {\n body: string\n /** Meta description; falls back to the excerpt. */\n description?: string\n /** Social image, absolute or site-root-relative. */\n image?: string\n /** Canonical override; default is the page's own address. */\n canonical?: string\n /** ISO 8601, for Article structured data. */\n published_at?: string\n modified_at?: string\n /** Route parameters beyond slug — the date parts of a dated permalink, a post id. */\n params?: Record<string, string>\n /** Stylesheets only this page loads. */\n css?: string[]\n locale?: string\n /** Present when the producer bound this post to the content store. */\n entry?: EntryRef\n}\n\n/** One static path of a list route, as it appears in the emitted query data. */\nexport interface EmittedQueryPage {\n params: Record<string, string>\n items: EmittedPost[]\n marks?: Record<string, unknown>\n css?: string[]\n item_template?: string\n sections?: ListSection[]\n title?: string\n description?: string\n image?: string\n canonical?: string\n}\n`\n","// Fragment balance check.\n//\n// Chrome is injected as raw HTML, so an unbalanced fragment does not fail the\n// build — the browser silently repairs it and the page loses its layout. That\n// is the failure that cost a page 36 against 100 when body chrome was split\n// into before/after halves. Lifting a header or footer out of the body blob is\n// only safe when each fragment closes what it opens, so the emitter checks and\n// says which tags are dangling rather than trusting the producer's word.\n//\n// This is deliberately not an HTML parser: it skips comments, doctypes and raw\n// text elements, tolerates the tags HTML lets you leave open, and reports what\n// it finds. It answers \"was this split mid-element?\", not \"is this valid HTML?\".\n\nconst VOID_TAGS = new Set([\n 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',\n 'link', 'meta', 'param', 'source', 'track', 'wbr',\n])\n\n/** Tags a browser closes for you. Theme markup is full of them, so counting\n * them would report every real page as broken. */\nconst OPTIONAL_END_TAGS = new Set([\n 'p', 'li', 'dt', 'dd', 'option', 'optgroup', 'thead', 'tbody',\n 'tfoot', 'tr', 'td', 'th', 'rt', 'rp',\n])\n\n/** Elements whose content is text, not markup — `<script>if (a < b)</script>`. */\nconst RAW_TEXT_TAGS = new Set(['script', 'style', 'textarea', 'title'])\n\ninterface Tag {\n name: string\n closing: boolean\n selfClosing: boolean\n}\n\nfunction scanTags(html: string): Tag[] {\n const tags: Tag[] = []\n let i = 0\n while (i < html.length) {\n const lt = html.indexOf('<', i)\n if (lt === -1) break\n if (html.startsWith('<!--', lt)) {\n const end = html.indexOf('-->', lt + 4)\n i = end === -1 ? html.length : end + 3\n continue\n }\n if (html.startsWith('<!', lt) || html.startsWith('<?', lt)) {\n const end = html.indexOf('>', lt)\n i = end === -1 ? html.length : end + 1\n continue\n }\n const head = /^<(\\/?)([a-zA-Z][a-zA-Z0-9-]*)/.exec(html.slice(lt, lt + 64))\n const [matched = '', slash = '', rawName = ''] = head ?? []\n if (!rawName) {\n i = lt + 1\n continue\n }\n // Walk to the tag's `>`, ignoring one inside a quoted attribute value.\n let j = lt + matched.length\n let quote = ''\n while (j < html.length) {\n const ch = html[j]\n if (quote) {\n if (ch === quote) quote = ''\n } else if (ch === '\"' || ch === \"'\") {\n quote = ch\n } else if (ch === '>') {\n break\n }\n j++\n }\n const name = rawName.toLowerCase()\n const closing = slash === '/'\n tags.push({ name, closing, selfClosing: html.slice(lt, j + 1).endsWith('/>') })\n i = j + 1\n if (!closing && RAW_TEXT_TAGS.has(name)) {\n const close = html.toLowerCase().indexOf(`</${name}`, i)\n i = close === -1 ? html.length : close\n }\n }\n return tags\n}\n\nexport interface BalanceReport {\n /** Elements opened and never closed — the fragment ends mid-element. */\n unclosed: string[]\n /** Elements closed but never opened — the fragment starts mid-element. */\n unopened: string[]\n}\n\nexport function checkBalance(html: string): BalanceReport {\n const stack: string[] = []\n const unopened: string[] = []\n for (const tag of scanTags(html)) {\n if (VOID_TAGS.has(tag.name) || tag.selfClosing || OPTIONAL_END_TAGS.has(tag.name)) continue\n if (!tag.closing) {\n stack.push(tag.name)\n continue\n }\n const at = stack.lastIndexOf(tag.name)\n if (at === -1) unopened.push(tag.name)\n else stack.length = at\n }\n return { unclosed: stack.toReversed(), unopened }\n}\n\n/** Human-readable balance complaint, or null when the fragment is sound. */\nexport function balanceWarning(html: string): string | null {\n const { unclosed, unopened } = checkBalance(html)\n const parts: string[] = []\n if (unclosed.length) parts.push(`never closed: ${unclosed.join(', ')}`)\n if (unopened.length) parts.push(`closed but never opened: ${unopened.join(', ')}`)\n return parts.length ? parts.join('; ') : null\n}\n","// Per-page SEO: the one head region the emitter owns.\n//\n// A migrated page used to inherit the TEMPLATE page's head verbatim, so every\n// post carried the template's `<link rel=\"canonical\">`, its `og:title` and its\n// Article JSON-LD. That is worse than having none: a whole site canonicalised\n// onto one URL de-indexes itself, and every share card shows the wrong story.\n// SEO continuity is the reason a migration keeps the source addresses at all,\n// so the emitter derives these tags per page from the entry and removes the\n// template's copies rather than letting two of each fight.\n//\n// What stays: everything else the theme put in `<head>` — charset, preloads,\n// feeds, icons, verification tokens, and any JSON-LD that is not per-page\n// (Organization, WebSite, BreadcrumbList).\n\n/** Tags the emitter owns; a source copy of any of these is replaced, not duplicated. */\nconst TITLE_RE = /<title\\b[^>]*>[\\s\\S]*?<\\/title>\\s*/gi\nconst META_RE = /<meta\\b[^>]*\\b(?:name|property)\\s*=\\s*[\"'](?:description|og:[^\"']*|twitter:[^\"']*)[\"'][^>]*>\\s*/gi\nconst CANONICAL_RE = /<link\\b[^>]*\\brel\\s*=\\s*[\"'][^\"']*\\bcanonical\\b[^\"']*[\"'][^>]*>\\s*/gi\nconst JSONLD_RE = /<script\\b[^>]*\\btype\\s*=\\s*[\"']application\\/ld\\+json[\"'][^>]*>([\\s\\S]*?)<\\/script>\\s*/gi\n\n/**\n * Structured-data types that describe THIS page. An Organization or WebSite\n * block is site-wide and true on every page; an Article block names one post\n * and is a lie on all the others.\n */\nconst PAGE_LD_TYPES = new Set(['article', 'blogposting', 'newsarticle', 'webpage', 'blogposting', 'techarticle'])\n\nfunction isPageScopedLd(json: string): boolean {\n let parsed: unknown\n try {\n parsed = JSON.parse(json)\n } catch {\n // Unparseable JSON-LD is left alone: removing markup we cannot read would\n // be guessing, and a broken block is the theme's problem, not ours.\n return false\n }\n const graph = (parsed as { '@graph'?: unknown })?.['@graph']\n const nodes = Array.isArray(parsed) ? parsed : Array.isArray(graph) ? graph : [parsed]\n return nodes.some((node) => {\n const type = (node as { '@type'?: unknown })?.['@type']\n const types = Array.isArray(type) ? type : [type]\n return types.some((t) => typeof t === 'string' && PAGE_LD_TYPES.has(t.toLowerCase()))\n })\n}\n\nexport interface StripResult {\n html: string\n /** What was taken out, for the emit warning — never a silent removal. */\n removed: string[]\n}\n\n/** Remove the source head's per-page SEO tags so the emitter's own are the only ones. */\nexport function stripSeoTags(html: string): StripResult {\n const removed: string[] = []\n const note = (label: string) => {\n if (!removed.includes(label)) removed.push(label)\n }\n let out = html.replace(TITLE_RE, () => {\n note('<title>')\n return ''\n })\n out = out.replace(META_RE, (tag) => {\n const name = /\\b(?:name|property)\\s*=\\s*[\"']([^\"']+)[\"']/i.exec(tag)?.[1] ?? 'meta'\n note(name.toLowerCase().startsWith('og:') ? 'og:*' : name.toLowerCase().startsWith('twitter:') ? 'twitter:*' : name)\n return ''\n })\n out = out.replace(CANONICAL_RE, () => {\n note('canonical')\n return ''\n })\n out = out.replace(JSONLD_RE, (tag, json: string) => {\n if (!isPageScopedLd(json)) return tag\n note('page-scoped JSON-LD')\n return ''\n })\n return { html: out, removed }\n}\n\n/**\n * Head-only tags found in BODY chrome. A browser that closed `<head>` early\n * leaves the template's canonical and og tags in the body, and a faithful clone\n * carries them there — so the page ends up with the emitter's correct tags AND\n * the template's stale ones, which no fidelity score can see.\n *\n * They are reported, not removed: the body is page content, and `<title>` is\n * legal inside `<svg>`, so cutting tags out of it would break real markup to\n * fix an invisible one. Only unambiguous head tags are looked for.\n */\nexport function bodySeoLeaks(html: string): string[] {\n const found: string[] = []\n if (/<link\\b[^>]*\\brel\\s*=\\s*[\"'][^\"']*\\bcanonical\\b/i.test(html)) found.push('canonical')\n if (/<meta\\b[^>]*\\bproperty\\s*=\\s*[\"']og:/i.test(html)) found.push('og:*')\n if (/<meta\\b[^>]*\\bname\\s*=\\s*[\"']description[\"']/i.test(html)) found.push('description')\n return found\n}\n\n/**\n * `src/components/Seo.astro`. The canonical address comes from `Astro.url` and\n * `Astro.site` rather than from data: it is then, by construction, the address\n * Astro actually generated, and no producer has to recompute a permalink.\n */\nexport const SEO_COMPONENT = `---\n/**\n * Per-page SEO — emitted by @contentrain/emitter-astro.\n *\n * Title, description, canonical, Open Graph, Twitter card and (on entry pages)\n * Article structured data, derived from the entry. The source theme's copies of\n * these tags are removed from the head chrome at emit time, so each appears once.\n *\n * Canonical and absolute URLs need \\`site\\` in astro.config.mjs; without it the\n * canonical link and og:url are omitted rather than pointing at a build host.\n */\nimport { absoluteUrl, jsonLd, seoDescription } from '../lib/fill'\n\ninterface Props {\n title?: string\n /** Meta description; falls back to the entry's excerpt, tags stripped. */\n description?: string\n /** Overrides the generated address — for a page that canonicalises elsewhere. */\n canonical?: string\n /** Social image: absolute, or site-root-relative. */\n image?: string\n /** \\`article\\` on entry pages, \\`website\\` on lists and static pages. */\n type?: 'article' | 'website'\n /** ISO 8601 — structured data only; the displayed date stays a mark. */\n publishedAt?: string\n modifiedAt?: string\n author?: string\n siteName?: string\n locale?: string\n}\n\nconst {\n title = '',\n description,\n canonical,\n image,\n type = 'website',\n publishedAt,\n modifiedAt,\n author,\n siteName,\n locale,\n} = Astro.props\n\nconst site = Astro.site\nconst url = canonical ? absoluteUrl(canonical, site) : absoluteUrl(Astro.url.pathname, site)\nconst imageUrl = absoluteUrl(image, site)\nconst desc = seoDescription(description)\nconst article = type === 'article'\nconst structured = article\n ? {\n '@context': 'https://schema.org',\n '@type': 'Article',\n headline: title,\n ...(desc ? { description: desc } : {}),\n ...(imageUrl ? { image: [imageUrl] } : {}),\n ...(publishedAt ? { datePublished: publishedAt } : {}),\n ...(modifiedAt ? { dateModified: modifiedAt } : {}),\n ...(author ? { author: { '@type': 'Person', name: author } } : {}),\n ...(url ? { mainEntityOfPage: { '@type': 'WebPage', '@id': url } } : {}),\n ...(siteName ? { publisher: { '@type': 'Organization', name: siteName } } : {}),\n }\n : undefined\n---\n<title>{title}</title>\n{desc && <meta name=\"description\" content={desc} />}\n{url && <link rel=\"canonical\" href={url} />}\n<meta property=\"og:type\" content={type} />\n{title && <meta property=\"og:title\" content={title} />}\n{desc && <meta property=\"og:description\" content={desc} />}\n{url && <meta property=\"og:url\" content={url} />}\n{imageUrl && <meta property=\"og:image\" content={imageUrl} />}\n{siteName && <meta property=\"og:site_name\" content={siteName} />}\n{locale && <meta property=\"og:locale\" content={locale} />}\n{article && publishedAt && <meta property=\"article:published_time\" content={publishedAt} />}\n{article && modifiedAt && <meta property=\"article:modified_time\" content={modifiedAt} />}\n{article && author && <meta property=\"article:author\" content={author} />}\n<meta name=\"twitter:card\" content={imageUrl ? 'summary_large_image' : 'summary'} />\n{title && <meta name=\"twitter:title\" content={title} />}\n{desc && <meta name=\"twitter:description\" content={desc} />}\n{imageUrl && <meta name=\"twitter:image\" content={imageUrl} />}\n{structured && <script is:inline type=\"application/ld+json\" set:html={jsonLd(structured)} />}\n`\n","// Layout families → Astro layouts.\n//\n// Chrome travels as DATA, not as Astro template source: the compiler must\n// never parse browser-tolerated theme markup. And the body chrome is injected\n// as ONE fragment: real themes nest the content container deep inside the\n// chrome, so splitting it into before/after halves produces unbalanced\n// fragments that the parser silently \"repairs\" (measured cost: 36 vs 100).\n// Content splices in at CHROME_BODY_SLOT before the single injection.\n//\n// Root attributes travel with the chrome for the same reason: themes key their\n// container rules off `<body class=\"wp-singular single …\">` and `<html class=\"js\n// wf-…\">`, so a page with perfect content and empty root attributes loses its\n// whole layout (measured: 36.4 vs 100).\n\nimport type { ComponentDef, LayoutFamily } from '@contentrain/types'\nimport { CHROME_BODY_SLOT, CHROME_COMPONENT_CLOSE, CHROME_COMPONENT_OPEN } from '@contentrain/types'\nimport type { ChromeComponentRef } from './chrome.js'\nimport { balanceWarning } from './balance.js'\nimport { bodySeoLeaks, stripSeoTags } from './seo.js'\nimport { pascalCase, stableJson } from './util.js'\n\nexport interface FamilyGenResult {\n files: Record<string, string>\n warnings: string[]\n}\n\n/** A component the family's chrome mounts at a `<!--@@component:ID@@-->` marker. */\nexport interface MountRef {\n id: string\n name: string\n variant: string\n}\n\nconst escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\nconst MARKER_RE = new RegExp(`${escapeRe(CHROME_COMPONENT_OPEN)}([^@\\\\s]+)${escapeRe(CHROME_COMPONENT_CLOSE)}`, 'g')\n\n/** Component ids the chrome names, in order of first appearance. */\nexport function componentMarkers(html: string): string[] {\n return [...new Set([...html.matchAll(MARKER_RE)].map((m) => m[1] ?? ''))]\n}\n\nexport interface FamilyOptions {\n /** Emit `<Seo>` and take the template page's SEO tags out of the head chrome. */\n seo?: boolean\n /** `og:site_name`, from `ProjectIR.site.title`. */\n siteName?: string\n}\n\nexport function familyFiles(\n family: LayoutFamily,\n lang: string,\n components: ChromeComponentRef[] = [],\n definitions: Map<string, ComponentDef> = new Map(),\n /** Component ids found in the CONTENT bodies rendered through this family (a form inside a page's post_content). */\n bodyMarkers: Iterable<string> = [],\n options: FamilyOptions = {},\n): FamilyGenResult {\n const name = pascalCase(family.id)\n // Header/footer regions are emitted as shared components (see chrome.ts) and\n // rendered as siblings of the body fragment; the layout injects the rest.\n const chunks = (family.chrome ?? []).filter((c) => c.position !== 'header' && c.position !== 'footer')\n const warnings: string[] = []\n // The template page's title, description, canonical, og/twitter tags and\n // per-page JSON-LD are the emitter's to render per entry; two of each is\n // worse than none.\n const seoOn = options.seo !== false\n for (const chunk of family.chrome ?? []) {\n if ((chunk.position === 'header' || chunk.position === 'footer') && componentMarkers(chunk.html).length) {\n warnings.push(`family ${family.id}: ${chunk.position} chrome carries a component marker — components mount in body chrome only; the marker renders as a comment`)\n }\n }\n const joined = (position: string) =>\n chunks.filter((c) => c.position === position).map((c) => c.html).join('\\n')\n\n const bodyChunks = chunks.filter((c) => c.position === 'body')\n let body: string\n if (bodyChunks.length) {\n body = bodyChunks.map((c) => c.html).join('\\n')\n if (!body.includes(CHROME_BODY_SLOT)) {\n warnings.push(`family ${family.id}: body chrome has no ${CHROME_BODY_SLOT} marker — content appended at the end`)\n body += CHROME_BODY_SLOT\n }\n } else {\n // Legacy pair: compose into one string with the slot between the halves.\n body = `${joined('before_body')}${CHROME_BODY_SLOT}${joined('after_body')}`\n }\n if (seoOn) {\n const leaked = bodySeoLeaks(body)\n if (leaked.length) {\n warnings.push(`family ${family.id}: body chrome carries head-only ${leaked.join(', ')} — a page then serves the template's copy alongside the emitter's; lift them into the head chunk or drop them at capture`)\n }\n }\n\n const unbalanced = balanceWarning(body)\n if (unbalanced) {\n warnings.push(`family ${family.id}: body chrome is not balanced (${unbalanced}) — the browser will repair it and the page loses its layout`)\n }\n\n // Component mount points. A marker names a ComponentDef; the layout imports\n // that component and renders it there. A marker nobody defined is dropped\n // with a warning rather than left as a mystery comment in every page.\n // Markers live in the chrome (the theme's comments region) or in the content\n // body itself (a contact form inside a page's post_content); the layout splits\n // the COMPOSED html, so both mount the same way. The mount table just has to\n // know every id that can appear.\n const placements = new Map((family.components ?? []).map((p) => [p.component, p]))\n const mounts: MountRef[] = []\n const mount = (id: string, def: ComponentDef) => {\n if (mounts.some((m) => m.id === id)) return\n const variant = placements.get(id)?.variant ?? def.variants?.[0]?.key ?? 'default'\n mounts.push({ id, name: pascalCase(id), variant })\n }\n for (const id of componentMarkers(body)) {\n const def = definitions.get(id)\n if (!def) {\n warnings.push(`family ${family.id}: component marker \"${id}\" has no component definition — marker dropped`)\n body = body.split(`${CHROME_COMPONENT_OPEN}${id}${CHROME_COMPONENT_CLOSE}`).join('')\n continue\n }\n mount(id, def)\n }\n for (const id of new Set(bodyMarkers)) {\n const def = definitions.get(id)\n if (!def) {\n warnings.push(`family ${family.id}: content bodies carry component marker \"${id}\" with no component definition — rendered as a comment`)\n continue\n }\n mount(id, def)\n }\n for (const placement of family.components ?? []) {\n if (!mounts.some((m) => m.id === placement.component)) {\n warnings.push(`family ${family.id}: component placement \"${placement.component}\" has no marker in the body chrome or in any content body — not mounted`)\n }\n }\n\n let head = joined('head')\n if (seoOn) {\n const stripped = stripSeoTags(head)\n head = stripped.html\n if (stripped.removed.length) {\n warnings.push(`family ${family.id}: removed the template page's ${stripped.removed.join(', ')} from head chrome — the emitter renders these per page`)\n }\n }\n\n const files: Record<string, string> = {}\n files[`src/data/chrome/${family.id}.json`] = stableJson({\n head,\n body,\n html_attrs: family.root_attrs?.html ?? {},\n body_attrs: family.root_attrs?.body ?? {},\n })\n\n const cssLinks = (family.css.files ?? [])\n .map((f) => `<link rel=\"stylesheet\" href=\"/styles/legacy/${f.split('/').pop()}\" />`)\n .join('\\n')\n\n const siteNameProp = options.siteName ? ` siteName={${JSON.stringify(options.siteName)}}` : ''\n const imported = [...new Set([...components.map((c) => c.name), ...mounts.map((m) => m.name)])]\n const componentImports = [\n ...(seoOn ? [`import Seo from '../components/Seo.astro'`] : []),\n ...imported.map((n) => `import ${n} from '../components/${n}.astro'`),\n ].join('\\n')\n const mountType = [...new Set(mounts.map((m) => `typeof ${m.name}`))].join(' | ')\n const mountTable = mounts.length\n ? `// Components the chrome mounts at <!--@@component:ID@@--> markers; the page's\n// entry address travels to each so a comments thread knows which entry it is.\nconst mounts: Record<string, { Mount: ${mountType}; variant: string } | undefined> = {\n${mounts.map((m) => ` ${JSON.stringify(m.id)}: { Mount: ${m.name}, variant: ${JSON.stringify(m.variant)} },`).join('\\n')}\n}\nconst parts = splitComponents(html).map((part) => ({\n html: part.html,\n Mount: part.component ? mounts[part.component]?.Mount : undefined,\n variant: part.component ? (mounts[part.component]?.variant ?? 'default') : 'default',\n}))\n`\n : ''\n const bodyRender = mounts.length\n ? ` {parts.map(({ html: part, Mount, variant }) => (Mount ? <Mount entry={entry} variant={variant} /> : <Fragment set:html={part} />))}`\n : ` <Fragment set:html={html} />`\n const renderRefs = (position: 'header' | 'footer') =>\n components\n .filter((c) => c.position === position)\n .map((c) => ` <${c.name} marks={marks} />`)\n .join('\\n')\n\n files[`src/layouts/${name}.astro`] = `---\n// Family: ${family.id}${family.name ? ` (${family.name})` : ''} — emitted by @contentrain/emitter-astro\nimport chrome from '../data/chrome/${family.id}.json'\nimport { cssHref, fillAttrs, renderTemplate, composeBody${mounts.length ? ', splitComponents' : ''}${seoOn ? ', type SeoInput' : ''} } from '../lib/fill'\n${componentImports ? `${componentImports}\\n` : ''}\ninterface Props {\n title?: string\n marks?: Record<string, unknown>\n /** Page content as an HTML string; slot children are used when absent. */\n body?: string\n /** Stylesheets only this page loads (page-builder sites emit CSS per page). */\n css?: string[]\n /** Document language — a multilingual site's routes each pass their own. */\n lang?: string\n /** Content-store address of the page's entry — mounted components (comments) key on it. */\n entry?: { model_id: string; entry_id: string; locale?: string }${seoOn ? `\n /** Per-page SEO — see src/components/Seo.astro. */\n seo?: SeoInput` : ''}\n}\nconst { title = '', marks = {}, body, css = [], lang = ${JSON.stringify(lang)}${mounts.length ? ', entry' : ''}${seoOn ? ', seo' : ''} } = Astro.props\nconst head = renderTemplate(chrome.head, marks)\n// Root attributes carry the theme's layout hooks; values may hold @@marks@@.\n// An explicit lang from the source wins over the project default.\nconst htmlAttrs = { lang, ...fillAttrs(chrome.html_attrs, marks) }\nconst bodyAttrs = fillAttrs(chrome.body_attrs, marks)\nconst content = body ?? (Astro.slots.has('default') ? await Astro.slots.render('default') : '')\n// Split at the marker FIRST, then fill marks per side — filling first would\n// eat the @@body@@ inside the marker and silently drop the content.\nconst html = composeBody(chrome.body, marks, content)\n${mountTable}---\n<!doctype html>\n<html {...htmlAttrs}>\n <head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\" />\n${cssLinks\n .split('\\n')\n .filter(Boolean)\n .map((l) => ` ${l}`)\n .join('\\n')}\n {css.map((file) => (\n <link rel=\"stylesheet\" href={cssHref(file)} />\n ))}\n <Fragment set:html={head} />\n${seoOn\n ? ` <Seo title={title} locale={lang}${siteNameProp} {...(seo ?? {})} />`\n : ` <title>{title}</title>`}\n </head>\n <body {...bodyAttrs}>\n${renderRefs('header') ? `${renderRefs('header')}\\n` : ''}${bodyRender}\n${renderRefs('footer') ? `${renderRefs('footer')}\\n` : ''} </body>\n</html>\n`\n return { files, warnings }\n}\n","// Routes → Astro pages.\n//\n// Three shapes, matching what a migrated site actually needs:\n// - `single`: one page per post, body injected with `set:html` inside the family layout.\n// - list kinds (`archive`/`term`/`author`/`date`/`front` with a query): static\n// paths come from the producer's query data; items render through the\n// extracted item template when one exists.\n// - static kinds (no query): the family chrome IS the page.\n//\n// Pagination is a route parameter (`/news/page/:page`), never a separate\n// family — the generated paths simply include the page number in params.\n\nimport type { RouteModel, LayoutFamily } from '@contentrain/types'\nimport type { EmitContent, EmitPost, QueryPage } from './types.js'\nimport { DEFAULT_COLLECTION } from './types.js'\nimport { pascalCase, patternToPagePath, restParamIndex, stableJson } from './util.js'\n\nexport interface PageGenResult {\n files: Record<string, string>\n warnings: string[]\n}\n\nexport function routeFiles(\n route: RouteModel,\n family: LayoutFamily | undefined,\n content: EmitContent,\n siteLocale: string,\n seo = true,\n): PageGenResult {\n const warnings: string[] = []\n const files: Record<string, string> = {}\n const pagePath = patternToPagePath(route.pattern)\n if (!pagePath) {\n return { files, warnings: [`route ${route.id}: pattern \"${route.pattern}\" cannot be expressed as an Astro page — skipped`] }\n }\n if (!family) {\n return { files, warnings: [`route ${route.id}: family \"${route.family}\" not found — skipped`] }\n }\n const layout = pascalCase(family.id)\n const depth = pagePath.split('/').length - 1\n const up = '../'.repeat(depth + 1) || '../'\n const hasParams = pagePath.includes('[')\n\n // Astro matches a rest parameter greedily, so anything after it is ambiguous.\n const restAt = restParamIndex(pagePath)\n if (restAt !== -1 && restAt !== pagePath.split('/').length - 1) {\n warnings.push(\n `route ${route.id}: rest parameter is not the final segment of \"${route.pattern}\" — Astro matches rest parameters greedily; verify this route resolves`,\n )\n }\n\n // Collection-driven: `single` always, and any route that names a collection —\n // a site's pages and custom post types are per-entry routes too, they just are\n // not called \"single\".\n if (route.kind === 'single' || route.collection !== undefined) {\n const collection = route.collection ?? DEFAULT_COLLECTION\n const posts = collectionItems(content, collection)\n if (!posts.length) {\n warnings.push(`route ${route.id}: collection \"${collection}\" has no items — page emitted, data file empty`)\n }\n files[`src/data/${collection}.json`] = stableJson(posts)\n files[`src/pages/${pagePath}`] = singlePage(route, layout, up, collection, siteLocale, hasParams, seo)\n return { files, warnings }\n }\n\n const queryPages: QueryPage[] = route.query ? (content.queries?.[route.query] ?? []) : []\n if (route.query) {\n if (!queryPages.length) warnings.push(`route ${route.id}: query \"${route.query}\" has no data — page emitted, data file empty`)\n if (queryPages.some((p) => !p.item_template && !p.sections?.length)) {\n warnings.push(`route ${route.id}: item template missing on some pages — plain fallback list rendered (not fidelity)`)\n }\n files[`src/data/queries/${route.query}.json`] = stableJson(queryPages)\n files[`src/pages/${pagePath}`] = listPage(route, layout, up, hasParams, siteLocale, seo)\n return { files, warnings }\n }\n\n files[`src/pages/${pagePath}`] = staticPage(route, layout, up, hasParams, warnings, siteLocale, seo)\n return { files, warnings }\n}\n\n/** Items of a named collection; `posts` also accepts the `content.posts` shorthand. */\nexport function collectionItems(content: EmitContent, collection: string): EmitPost[] {\n const named = content.collections?.[collection]\n if (named) return named\n return collection === DEFAULT_COLLECTION ? (content.posts ?? []) : []\n}\n\nfunction singlePage(route: RouteModel, layout: string, up: string, collection: string, siteLocale: string, hasParams: boolean, seo: boolean): string {\n const locale = JSON.stringify(route.locale ?? siteLocale)\n return `---\n// Route: ${route.id} (${route.pattern}) — collection: ${collection} — emitted by @contentrain/emitter-astro\nimport Layout from '${up}layouts/${layout}.astro'\nimport data from '${up}data/${collection}.json'\nimport { postMarks${seo ? ', postSeo' : ''}, type EmittedPost } from '${up}lib/fill'\n\n${hasParams ? `export function getStaticPaths() {\n // The JSON is data; its contract is EmittedPost. Inferring the type from the\n // file would type only the fields this site happens to carry, and \"astro\n // check\" (which the build runs first) would then reject post.params on a site\n // whose permalinks need none. The cast lives INSIDE getStaticPaths because\n // Astro hoists this function: it sees imports, not the component scope.\n const posts = data as EmittedPost[]\n // A post carries its own route parameters (date parts of a dated permalink,\n // a post id) — using the template post's would send every other post to the\n // wrong address.\n return posts.map((post) => ({ params: { ...(post.params ?? {}), slug: post.slug }, props: { post } }))\n}\ntype Props = { post: EmittedPost }\nconst { post } = Astro.props as Props` : `const posts = data as EmittedPost[]\nif (posts.length !== 1) throw new Error(${JSON.stringify(`Static content route requires exactly one entry: ${route.id}`)})\nconst post = posts[0]!`}\n---\n<Layout\n title={post.title}\n marks={postMarks(post)}\n body={post.body}\n css={post.css ?? []}\n lang={post.locale ?? ${locale}}\n entry={post.entry}${seo ? `\n seo={postSeo(post)}` : ''}\n/>\n`\n}\n\nfunction listPage(route: RouteModel, layout: string, up: string, hasParams: boolean, siteLocale: string, seo: boolean): string {\n const locale = JSON.stringify(route.locale ?? siteLocale)\n // Chain the fallbacks HERE, not in the emitted source: a route without a title\n // used to emit `page.title ?? \"\" ?? ''`, which \"astro check\" rejects as never\n // nullish — and the build runs check first, so the whole site failed to build.\n const routeTitle = route.title === undefined ? `''` : JSON.stringify(route.title)\n // The cast lives inside getStaticPaths: Astro hoists it above the component\n // scope, so it can read imports and nothing else.\n const paths = hasParams\n ? `export function getStaticPaths() {\n const pages = data as EmittedQueryPage[]\n return pages.map((page) => ({ params: page.params, props: { page } }))\n}\ntype Props = { page: EmittedQueryPage }\nconst { page } = Astro.props as Props`\n : `const page = (data as EmittedQueryPage[])[0] ?? { params: {}, items: [], title: undefined }`\n return `---\n// Route: ${route.id} (${route.pattern}) — emitted by @contentrain/emitter-astro\nimport Layout from '${up}layouts/${layout}.astro'\nimport data from '${up}data/queries/${route.query}.json'\nimport { esc, postMarks, renderSections, type EmittedQueryPage } from '${up}lib/fill'\n\n${paths}\n// A list renders in sections (a big card then a grid) — one template is the\n// single-section case. Without a template at all we fall back to a plain list\n// and the emitter has already warned: silence would read as fidelity.\nconst sections = page.sections ?? (page.item_template ? [{ template: page.item_template }] : [])\nconst content = sections.length\n ? renderSections(sections, page.items, postMarks)\n : '<ul class=\"cr-post-list\">' +\n page.items.map((item) => '<li><a href=\"/' + item.slug + '/\">' + esc(item.title) + '</a></li>').join('') +\n '</ul>'\n// Route params carry slugs; page marks carry what the chrome needs to SHOW\n// (a term's display name, description, count).\nconst marks = { ...page.params, ...(page.marks ?? {}) }\nconst title = page.title ?? ${routeTitle}\n---\n<Layout\n title={title || String(marks.term_name ?? page.params.term ?? '')}\n marks={marks}\n body={content}\n css={page.css ?? []}\n lang={${locale}}${seo ? `\n seo={{ description: page.description, image: page.image, canonical: page.canonical, type: 'website' }}` : ''}\n/>\n`\n}\n\nfunction staticPage(route: RouteModel, layout: string, up: string, hasParams: boolean, warnings: string[], siteLocale: string, seo: boolean): string {\n if (hasParams) warnings.push(`route ${route.id}: parameterized route without a query — emitted with empty paths`)\n const paths = hasParams\n ? `export function getStaticPaths() {\n return []\n}\n`\n : ''\n return `---\n// Route: ${route.id} (${route.pattern}) — emitted by @contentrain/emitter-astro\nimport Layout from '${up}layouts/${layout}.astro'\n${paths}---\n<Layout title=${JSON.stringify(route.title ?? '')} marks={{}} lang={${JSON.stringify(route.locale ?? siteLocale)}}${seo ? ` seo={{ type: 'website' }}` : ''} />\n`\n}\n","// The runtime-component client, emitted as `src/lib/embed.ts`.\n//\n// Kept as source text (like the fill runtime in scaffold.ts) so the generated\n// site depends on nothing but Astro. The text is type-checked and executed by\n// this package's tests — `embed.test.ts` writes it to disk and imports it, and\n// `typecheck.test.ts` runs tsc over it with Astro's strict settings — so the\n// code that ships is the code that was tested, escaping included.\n\nexport const EMBED_TS = `// Emitted by @contentrain/emitter-astro — the runtime-component client.\n//\n// Comments and forms are the two regions of a migrated site that need a live\n// service. This module is the browser side of the provider's PUBLIC API\n// (Studio: /api/forms/v1 and /api/comments/v1) — the same contract that\n// @contentrain/query/cdn implements, inlined so the generated site depends on\n// nothing but Astro. No credential travels with a request: the endpoints are\n// unauthenticated by design, and a page cannot keep a secret.\n//\n// Rendering helpers are pure (strings in, HTML out, escaped by default) so the\n// mount functions at the bottom are the only code that touches the DOM.\n\nexport interface Runtime {\n base_url: string\n project_id: string\n}\n\nexport interface EntryRef {\n model_id: string\n entry_id: string\n locale?: string\n}\n\nexport class EmbedError extends Error {\n status: number\n constructor(status: number, message: string) {\n super(message)\n this.name = 'EmbedError'\n this.status = status\n }\n}\n\n/** Visitor-facing strings — override from the site (\\`strings.send = 'Gönder'\\`). */\nexport const strings = {\n loading: 'Loading…',\n send: 'Send',\n sending: 'Sending…',\n commentsTitle: 'Comments',\n noComments: 'No comments yet.',\n closed: 'Comments are closed.',\n postComment: 'Post comment',\n reply: 'Reply',\n replyingTo: 'Replying to',\n cancel: 'Cancel',\n loadMore: 'Load more comments',\n moderator: 'Moderator',\n pending: 'Thank you — your comment is awaiting moderation.',\n posted: 'Your comment has been posted.',\n name: 'Name',\n email: 'Email',\n emailNote: '(never shown)',\n website: 'Website',\n comment: 'Comment',\n failed: 'Something went wrong. Please try again.',\n honeypot: 'Leave this field empty',\n}\n\nexport const esc = (value: unknown): string =>\n String(value ?? '')\n .replace(/&/g, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n\n// ─── Transport ───\n\n/** \\`base\\` without a trailing slash, segments URL-encoded, empty query values dropped. */\nexport function publicUrl(base: string, segments: string[], query?: Record<string, string | number | undefined>): string {\n const path = segments.map((s) => encodeURIComponent(s)).join('/')\n const params = new URLSearchParams()\n for (const [key, value] of Object.entries(query ?? {})) {\n if (value !== undefined && value !== '') params.set(key, String(value))\n }\n const qs = params.toString()\n return base.replace(/\\\\/+$/, '') + '/' + path + (qs ? '?' + qs : '')\n}\n\nasync function failure(res: Response): Promise<EmbedError> {\n const text = await res.text().catch(() => '')\n let message = text || 'Request failed'\n try {\n const parsed = JSON.parse(text) as { message?: unknown; statusMessage?: unknown }\n const m = parsed.message ?? parsed.statusMessage\n if (typeof m === 'string' && m) message = m\n } catch {\n // not JSON — the raw text is the message\n }\n return new EmbedError(res.status, message)\n}\n\nexport async function getJson<T>(url: string): Promise<T> {\n const res = await fetch(url)\n if (!res.ok) throw await failure(res)\n return (await res.json()) as T\n}\n\nexport async function postJson<T>(url: string, body: unknown): Promise<T> {\n const res = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(body),\n })\n if (!res.ok) throw await failure(res)\n return (await res.json()) as T\n}\n\nexport const formsRoot = (rt: Runtime): string => rt.base_url.replace(/\\\\/+$/, '') + '/api/forms/v1'\nexport const commentsRoot = (rt: Runtime): string => rt.base_url.replace(/\\\\/+$/, '') + '/api/comments/v1'\n\n/** The hidden input Cloudflare Turnstile adds to the enclosing form. */\nexport const CAPTCHA_FIELD = 'cf-turnstile-response'\n\n// ─── Forms ───\n\nexport interface FieldDef {\n type: string\n required?: boolean\n label?: string\n options?: string[]\n min?: number\n max?: number\n pattern?: string\n default?: unknown\n}\n\nexport interface FormConfig {\n modelId: string\n locale: string\n /** Exposed fields only, keyed by field id. */\n fields: Record<string, FieldDef>\n captcha: 'turnstile' | null\n captchaSiteKey: string | null\n successMessage?: string\n honeypotField: string | null\n}\n\nexport interface FieldError {\n field: string\n message: string\n}\n\nexport interface FormSubmitResult {\n success: boolean\n message?: string\n errors?: FieldError[]\n}\n\n/** The documented request body: values under \\`data\\`, control fields beside it. */\nexport interface FormPayload {\n data: Record<string, unknown>\n captchaToken?: string\n _hp?: string\n}\n\nexport function fetchFormConfig(rt: Runtime, model: string): Promise<FormConfig> {\n return getJson<FormConfig>(publicUrl(formsRoot(rt), [rt.project_id, model, 'config']))\n}\n\nexport function submitForm(rt: Runtime, model: string, payload: FormPayload): Promise<FormSubmitResult> {\n return postJson<FormSubmitResult>(publicUrl(formsRoot(rt), [rt.project_id, model, 'submit']), payload)\n}\n\nfunction coerce(def: FieldDef, value: string): unknown {\n switch (def.type) {\n case 'number':\n case 'integer':\n case 'decimal':\n case 'percent':\n case 'rating':\n return value === '' ? '' : Number(value)\n case 'boolean':\n return value === 'on' || value === 'true'\n default:\n return value\n }\n}\n\n/**\n * Form entries (\\`new FormData(form)\\`) → request body. Only exposed fields\n * reach \\`data\\`; the captcha token and the honeypot value travel beside it, so\n * a control field can never collide with a model field. Files are skipped —\n * uploads are not part of the public contract.\n */\nexport function formPayload(\n entries: Iterable<[string, unknown]>,\n config: { fields: Record<string, FieldDef>; honeypotField: string | null },\n): FormPayload {\n const payload: FormPayload = { data: {} }\n for (const [name, raw] of entries) {\n if (typeof raw !== 'string') continue\n if (name === CAPTCHA_FIELD) {\n if (raw) payload.captchaToken = raw\n continue\n }\n if (config.honeypotField && name === config.honeypotField) {\n payload._hp = raw\n continue\n }\n const def = config.fields[name]\n if (!def) continue\n payload.data[name] = coerce(def, raw)\n }\n return payload\n}\n\nexport function labelFor(id: string, def: FieldDef): string {\n if (def.label) return def.label\n const words = id.replace(/[_-]+/g, ' ').trim()\n return words.charAt(0).toUpperCase() + words.slice(1)\n}\n\n/** One input for one exposed field, chosen by field type. */\nexport function fieldControl(id: string, def: FieldDef, prefix = 'cr-field'): string {\n const common =\n ' id=\"' + esc(prefix + '-' + id) + '\" name=\"' + esc(id) + '\"' +\n (def.required ? ' required' : '') +\n (def.pattern ? ' pattern=\"' + esc(def.pattern) + '\"' : '')\n const range =\n (def.min !== undefined ? ' min=\"' + esc(def.min) + '\"' : '') +\n (def.max !== undefined ? ' max=\"' + esc(def.max) + '\"' : '')\n const length = def.max !== undefined ? ' maxlength=\"' + esc(def.max) + '\"' : ''\n switch (def.type) {\n case 'text':\n case 'markdown':\n case 'richtext':\n case 'code':\n return '<textarea' + common + length + ' rows=\"5\"></textarea>'\n case 'select': {\n const blank = def.required ? '' : '<option value=\"\"></option>'\n const options = (def.options ?? []).map((o) => '<option value=\"' + esc(o) + '\">' + esc(o) + '</option>').join('')\n return '<select' + common + '>' + blank + options + '</select>'\n }\n case 'boolean':\n return '<input type=\"checkbox\"' + common + ' />'\n case 'number':\n case 'integer':\n case 'rating':\n return '<input type=\"number\"' + common + range + (def.type === 'integer' ? ' step=\"1\"' : '') + ' />'\n case 'decimal':\n case 'percent':\n return '<input type=\"number\" step=\"any\"' + common + range + ' />'\n case 'email':\n return '<input type=\"email\"' + common + length + ' />'\n case 'url':\n return '<input type=\"url\"' + common + length + ' />'\n case 'phone':\n return '<input type=\"tel\"' + common + length + ' />'\n case 'date':\n return '<input type=\"date\"' + common + ' />'\n case 'datetime':\n return '<input type=\"datetime-local\"' + common + ' />'\n case 'color':\n return '<input type=\"color\"' + common + ' />'\n default:\n return '<input type=\"text\"' + common + length + ' />'\n }\n}\n\nexport function honeypotHtml(field: string | null): string {\n if (!field) return ''\n return (\n '<p class=\"cr-hp\" aria-hidden=\"true\" style=\"position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden\">' +\n '<label>' + esc(strings.honeypot) + ' <input type=\"text\" name=\"' + esc(field) + '\" tabindex=\"-1\" autocomplete=\"off\" /></label></p>'\n )\n}\n\nexport function captchaHtml(captcha: 'turnstile' | null, siteKey: string | null): string {\n if (captcha !== 'turnstile' || !siteKey) return ''\n return '<div class=\"cf-turnstile\" data-sitekey=\"' + esc(siteKey) + '\"></div>'\n}\n\nexport function errorsHtml(errors: FieldError[]): string {\n return (\n '<ul class=\"cr-errors\" role=\"alert\">' +\n errors.map((e) => '<li data-field=\"' + esc(e.field) + '\">' + esc(e.field) + ': ' + esc(e.message) + '</li>').join('') +\n '</ul>'\n )\n}\n\n/** The whole form for a public form config: one control per exposed field, honeypot, captcha, submit. */\nexport function formHtml(config: FormConfig, prefix = 'cr-field'): string {\n const fields = Object.entries(config.fields)\n .map(([id, def]) => {\n const control = fieldControl(id, def, prefix)\n const label = '<label for=\"' + esc(prefix + '-' + id) + '\">' + esc(labelFor(id, def)) + (def.required ? ' <span aria-hidden=\"true\">*</span>' : '') + '</label>'\n return '<p class=\"cr-field cr-field--' + esc(def.type) + '\">' + (def.type === 'boolean' ? control + ' ' + label : label + control) + '</p>'\n })\n .join('')\n return (\n '<form class=\"cr-form\" method=\"post\" data-model=\"' + esc(config.modelId) + '\">' +\n fields +\n honeypotHtml(config.honeypotField) +\n captchaHtml(config.captcha, config.captchaSiteKey) +\n '<p class=\"cr-actions\"><button type=\"submit\">' + esc(strings.send) + '</button></p>' +\n '<div class=\"cr-status\" aria-live=\"polite\"></div>' +\n '</form>'\n )\n}\n\n// ─── Comments ───\n\nexport interface CommentAuthor {\n name: string\n url: string | null\n isModerator: boolean\n}\n\nexport interface PublicComment {\n id: string\n parentId: string | null\n depth: number\n author: CommentAuthor\n /** Plain text. */\n body: string\n type: 'comment' | 'pingback' | 'trackback'\n createdAt: string\n replies: PublicComment[]\n}\n\nexport interface ThreadConfig {\n closed: boolean\n requireApproval: boolean\n requireEmail: boolean\n maxDepth: number\n maxBodyLength: number\n captcha: 'turnstile' | null\n captchaSiteKey: string | null\n honeypotField: string | null\n}\n\nexport interface CommentThread {\n entry: { modelId: string; entryId: string; locale: string }\n config: ThreadConfig\n comments: PublicComment[]\n total: number\n page: number\n limit: number\n}\n\nexport interface CommentSubmitBody {\n author: { name: string; email?: string; url?: string }\n body: string\n /** null for a root comment — sent explicitly, as the provider's own fixture does. */\n parentId: string | null\n captchaToken?: string\n _hp?: string\n}\n\nexport interface CommentSubmitResult {\n success: boolean\n status?: 'pending' | 'approved'\n comment?: PublicComment\n errors?: FieldError[]\n}\n\nexport interface ThreadQuery {\n page?: number\n limit?: number\n sort?: 'oldest' | 'newest'\n}\n\nexport function fetchThread(rt: Runtime, entry: EntryRef, query?: ThreadQuery): Promise<CommentThread> {\n const url = publicUrl(commentsRoot(rt), [rt.project_id, entry.model_id, entry.entry_id], {\n locale: entry.locale,\n page: query?.page,\n limit: query?.limit,\n sort: query?.sort,\n })\n return getJson<CommentThread>(url)\n}\n\nexport function submitComment(rt: Runtime, entry: EntryRef, body: CommentSubmitBody): Promise<CommentSubmitResult> {\n const url = publicUrl(commentsRoot(rt), [rt.project_id, entry.model_id, entry.entry_id], { locale: entry.locale })\n return postJson<CommentSubmitResult>(url, body)\n}\n\n/** Comment-form entries → the documented request body (inputs: author_name, author_email, author_url, body, parent_id). */\nexport function commentPayload(entries: Iterable<[string, unknown]>, honeypotField: string | null): CommentSubmitBody {\n const values: Record<string, string> = {}\n const payload: CommentSubmitBody = { author: { name: '' }, body: '', parentId: null }\n for (const [name, raw] of entries) {\n if (typeof raw !== 'string') continue\n if (name === CAPTCHA_FIELD) {\n if (raw) payload.captchaToken = raw\n continue\n }\n if (honeypotField && name === honeypotField) {\n payload._hp = raw\n continue\n }\n values[name] = raw\n }\n payload.author.name = (values.author_name ?? '').trim()\n if (values.author_email?.trim()) payload.author.email = values.author_email.trim()\n if (values.author_url?.trim()) payload.author.url = values.author_url.trim()\n payload.body = (values.body ?? '').trim()\n payload.parentId = values.parent_id?.trim() || null\n return payload\n}\n\n/** Plain text → paragraphs; blank lines separate paragraphs, single newlines break lines. */\nexport function bodyHtml(text: string): string {\n return text\n .split(/\\\\n{2,}/)\n .map((p) => p.trim())\n .filter(Boolean)\n .map((p) => '<p>' + esc(p).replace(/\\\\n/g, '<br />') + '</p>')\n .join('')\n}\n\nexport function hasMore(thread: CommentThread): boolean {\n return thread.page * thread.limit < thread.total\n}\n\n/** One comment with its replies. A reply button appears only while the thread is open and depth allows it. */\nexport function commentHtml(c: PublicComment, config: { closed: boolean; maxDepth: number }): string {\n const author = c.author.url\n ? '<a href=\"' + esc(c.author.url) + '\" rel=\"nofollow ugc noopener\" target=\"_blank\">' + esc(c.author.name) + '</a>'\n : esc(c.author.name)\n const badge = c.author.isModerator ? ' <span class=\"cr-moderator\">' + esc(strings.moderator) + '</span>' : ''\n const date = '<time datetime=\"' + esc(c.createdAt) + '\">' + esc(c.createdAt.slice(0, 10)) + '</time>'\n const canReply = !config.closed && c.depth < config.maxDepth\n const reply = canReply\n ? '<button type=\"button\" class=\"cr-reply\" data-parent=\"' + esc(c.id) + '\" data-author=\"' + esc(c.author.name) + '\">' + esc(strings.reply) + '</button>'\n : ''\n const replies = c.replies.length\n ? '<ol class=\"cr-replies\">' + c.replies.map((r) => commentHtml(r, config)).join('') + '</ol>'\n : ''\n return (\n '<li class=\"cr-comment cr-comment--' + esc(c.type) + '\" id=\"cr-comment-' + esc(c.id) + '\" data-depth=\"' + esc(c.depth) + '\">' +\n '<div class=\"cr-comment-meta\"><span class=\"cr-comment-author\">' + author + '</span>' + badge + ' ' + date + '</div>' +\n '<div class=\"cr-comment-body\">' + bodyHtml(c.body) + '</div>' +\n (reply ? '<div class=\"cr-comment-actions\">' + reply + '</div>' : '') +\n replies +\n '</li>'\n )\n}\n\nexport function threadHtml(thread: CommentThread): string {\n if (!thread.comments.length) return '<p class=\"cr-empty\">' + esc(strings.noComments) + '</p>'\n return '<ol class=\"cr-comment-list\">' + thread.comments.map((c) => commentHtml(c, thread.config)).join('') + '</ol>'\n}\n\n/** The comment form; a reply carries its parent in a hidden input. */\nexport function commentFormHtml(config: ThreadConfig, prefix = 'cr-c'): string {\n const emailLabel = esc(strings.email) + (config.requireEmail ? ' <span aria-hidden=\"true\">*</span>' : '') + ' <small>' + esc(strings.emailNote) + '</small>'\n return (\n '<form class=\"cr-comment-form\" method=\"post\">' +\n '<input type=\"hidden\" name=\"parent_id\" value=\"\" />' +\n '<p class=\"cr-replying\" hidden><span class=\"cr-replying-to\"></span> <button type=\"button\" class=\"cr-cancel-reply\">' + esc(strings.cancel) + '</button></p>' +\n '<p class=\"cr-field\"><label for=\"' + prefix + '-body\">' + esc(strings.comment) + ' <span aria-hidden=\"true\">*</span></label>' +\n '<textarea id=\"' + prefix + '-body\" name=\"body\" required maxlength=\"' + esc(config.maxBodyLength) + '\" rows=\"5\"></textarea></p>' +\n '<p class=\"cr-field\"><label for=\"' + prefix + '-name\">' + esc(strings.name) + ' <span aria-hidden=\"true\">*</span></label>' +\n '<input type=\"text\" id=\"' + prefix + '-name\" name=\"author_name\" required maxlength=\"120\" autocomplete=\"name\" /></p>' +\n '<p class=\"cr-field\"><label for=\"' + prefix + '-email\">' + emailLabel + '</label>' +\n '<input type=\"email\" id=\"' + prefix + '-email\" name=\"author_email\"' + (config.requireEmail ? ' required' : '') + ' maxlength=\"254\" autocomplete=\"email\" /></p>' +\n '<p class=\"cr-field\"><label for=\"' + prefix + '-url\">' + esc(strings.website) + '</label>' +\n '<input type=\"url\" id=\"' + prefix + '-url\" name=\"author_url\" maxlength=\"2048\" autocomplete=\"url\" /></p>' +\n honeypotHtml(config.honeypotField) +\n captchaHtml(config.captcha, config.captchaSiteKey) +\n '<p class=\"cr-actions\"><button type=\"submit\">' + esc(strings.postComment) + '</button></p>' +\n '<div class=\"cr-status\" aria-live=\"polite\"></div>' +\n '</form>'\n )\n}\n\n// ─── Turnstile (explicit render, so widgets added after load still render) ───\n\ninterface TurnstileApi {\n render: (el: Element, options: { sitekey: string }) => string\n reset: (id?: string) => void\n}\n\nconst TURNSTILE_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&onload=__crTurnstileReady'\nconst widgets = new WeakMap<Element, string>()\nconst waiting: Element[] = []\n\nfunction turnstileApi(): TurnstileApi | undefined {\n return (globalThis as { turnstile?: TurnstileApi }).turnstile\n}\n\nfunction renderWidget(api: TurnstileApi, el: Element): void {\n if (widgets.has(el)) return\n widgets.set(el, api.render(el, { sitekey: el.getAttribute('data-sitekey') ?? '' }))\n}\n\n/** Render every \\`.cf-turnstile\\` under \\`root\\`, loading the Turnstile script once on first use. */\nexport function renderCaptcha(root: ParentNode): void {\n const els = Array.from(root.querySelectorAll('.cf-turnstile'))\n if (!els.length) return\n const api = turnstileApi()\n if (api) {\n for (const el of els) renderWidget(api, el)\n return\n }\n waiting.push(...els)\n if (document.querySelector('script[data-cr-turnstile]')) return\n ;(globalThis as Record<string, unknown>).__crTurnstileReady = () => {\n const ready = turnstileApi()\n if (!ready) return\n for (const el of waiting.splice(0)) renderWidget(ready, el)\n }\n const script = document.createElement('script')\n script.src = TURNSTILE_SRC\n script.async = true\n script.defer = true\n script.setAttribute('data-cr-turnstile', '1')\n document.head.appendChild(script)\n}\n\nexport function resetCaptcha(root: ParentNode): void {\n const api = turnstileApi()\n if (!api) return\n for (const el of Array.from(root.querySelectorAll('.cf-turnstile'))) api.reset(widgets.get(el))\n}\n\n// ─── Mounting (the only DOM code) ───\n\nexport function runtimeOf(host: HTMLElement): Runtime {\n return { base_url: host.dataset.baseUrl ?? '', project_id: host.dataset.project ?? '' }\n}\n\nfunction entryOf(host: HTMLElement): EntryRef {\n return { model_id: host.dataset.model ?? '', entry_id: host.dataset.entry ?? '', locale: host.dataset.locale || undefined }\n}\n\nfunction setStatus(form: HTMLFormElement, html: string): void {\n const status = form.querySelector('.cr-status')\n if (status) status.innerHTML = html\n}\n\nfunction busy(form: HTMLFormElement, on: boolean): void {\n const button = form.querySelector<HTMLButtonElement>('button[type=\"submit\"]')\n if (!button) return\n button.disabled = on\n if (on) {\n button.dataset.label = button.textContent ?? ''\n button.textContent = strings.sending\n } else {\n button.textContent = button.dataset.label ?? button.textContent\n }\n}\n\nfunction entriesOf(form: HTMLFormElement): Array<[string, unknown]> {\n const out: Array<[string, unknown]> = []\n new FormData(form).forEach((value, key) => out.push([key, value]))\n return out\n}\n\n/** \\`<cr-form data-base-url data-project data-model>\\` → fetch the config, render, submit. */\nexport async function mountForm(host: HTMLElement): Promise<void> {\n const rt = runtimeOf(host)\n const model = host.dataset.model ?? ''\n host.innerHTML = '<p class=\"cr-loading\">' + esc(strings.loading) + '</p>'\n let config: FormConfig\n try {\n config = await fetchFormConfig(rt, model)\n } catch (error) {\n host.innerHTML = '<p class=\"cr-error\">' + esc(error instanceof Error ? error.message : strings.failed) + '</p>'\n return\n }\n host.innerHTML = formHtml(config)\n renderCaptcha(host)\n const form = host.querySelector('form')\n if (!form) return\n form.addEventListener('submit', async (event) => {\n event.preventDefault()\n busy(form, true)\n setStatus(form, '')\n try {\n const result = await submitForm(rt, model, formPayload(entriesOf(form), config))\n if (result.success) {\n host.innerHTML = '<p class=\"cr-success\" role=\"status\">' + esc(result.message ?? config.successMessage ?? '') + '</p>'\n return\n }\n setStatus(form, errorsHtml(result.errors ?? []))\n resetCaptcha(form)\n } catch (error) {\n setStatus(form, '<p class=\"cr-error\" role=\"alert\">' + esc(error instanceof Error ? error.message : strings.failed) + '</p>')\n resetCaptcha(form)\n } finally {\n busy(form, false)\n }\n })\n}\n\n/** \\`<cr-comments data-base-url data-project data-model data-entry data-locale>\\` → thread + form. */\nexport async function mountComments(host: HTMLElement): Promise<void> {\n const rt = runtimeOf(host)\n const entry = entryOf(host)\n host.innerHTML = '<p class=\"cr-loading\">' + esc(strings.loading) + '</p>'\n let thread: CommentThread\n try {\n thread = await fetchThread(rt, entry)\n } catch (error) {\n host.innerHTML = '<p class=\"cr-error\">' + esc(error instanceof Error ? error.message : strings.failed) + '</p>'\n return\n }\n\n host.innerHTML =\n '<section class=\"cr-comments-section\">' +\n '<h2 class=\"cr-comments-title\">' + esc(strings.commentsTitle) + ' <span class=\"cr-count\">(' + esc(thread.total) + ')</span></h2>' +\n '<div class=\"cr-thread\">' + threadHtml(thread) + '</div>' +\n (hasMore(thread) ? '<p class=\"cr-more\"><button type=\"button\" class=\"cr-load-more\">' + esc(strings.loadMore) + '</button></p>' : '') +\n (thread.config.closed ? '<p class=\"cr-closed\">' + esc(strings.closed) + '</p>' : commentFormHtml(thread.config)) +\n '</section>'\n renderCaptcha(host)\n\n const threadEl = host.querySelector('.cr-thread')\n const form = host.querySelector<HTMLFormElement>('form.cr-comment-form')\n let page = thread.page\n\n host.addEventListener('click', async (event) => {\n const target = event.target as HTMLElement | null\n if (!target) return\n const replyButton = target.closest<HTMLElement>('.cr-reply')\n if (replyButton && form) {\n const parent = form.querySelector<HTMLInputElement>('input[name=\"parent_id\"]')\n if (parent) parent.value = replyButton.dataset.parent ?? ''\n const replying = form.querySelector<HTMLElement>('.cr-replying')\n const to = form.querySelector('.cr-replying-to')\n if (to) to.textContent = strings.replyingTo + ' ' + (replyButton.dataset.author ?? '')\n if (replying) replying.hidden = false\n replyButton.closest('.cr-comment')?.appendChild(form)\n form.querySelector<HTMLTextAreaElement>('textarea[name=\"body\"]')?.focus()\n return\n }\n if (target.closest('.cr-cancel-reply') && form) {\n const parent = form.querySelector<HTMLInputElement>('input[name=\"parent_id\"]')\n if (parent) parent.value = ''\n const replying = form.querySelector<HTMLElement>('.cr-replying')\n if (replying) replying.hidden = true\n host.querySelector('.cr-comments-section')?.appendChild(form)\n return\n }\n const more = target.closest<HTMLButtonElement>('.cr-load-more')\n if (more && threadEl) {\n more.disabled = true\n try {\n const next = await fetchThread(rt, entry, { page: page + 1 })\n page = next.page\n const list = threadEl.querySelector('.cr-comment-list')\n if (list) list.insertAdjacentHTML('beforeend', next.comments.map((c) => commentHtml(c, next.config)).join(''))\n if (!hasMore(next)) more.closest('.cr-more')?.remove()\n } finally {\n more.disabled = false\n }\n }\n })\n\n form?.addEventListener('submit', async (event) => {\n event.preventDefault()\n busy(form, true)\n setStatus(form, '')\n try {\n const result = await submitComment(rt, entry, commentPayload(entriesOf(form), thread.config.honeypotField))\n if (!result.success) {\n setStatus(form, errorsHtml(result.errors ?? []))\n resetCaptcha(form)\n return\n }\n const parentId = result.comment?.parentId ?? null\n if (result.status === 'approved' && result.comment && threadEl) {\n const html = commentHtml(result.comment, thread.config)\n const parent = parentId ? threadEl.querySelector('#cr-comment-' + parentId) : null\n if (parent) {\n let replies = parent.querySelector(':scope > .cr-replies')\n if (!replies) {\n parent.insertAdjacentHTML('beforeend', '<ol class=\"cr-replies\"></ol>')\n replies = parent.querySelector(':scope > .cr-replies')\n }\n replies?.insertAdjacentHTML('beforeend', html)\n } else {\n const list = threadEl.querySelector('.cr-comment-list')\n if (list) list.insertAdjacentHTML('beforeend', html)\n else threadEl.innerHTML = '<ol class=\"cr-comment-list\">' + html + '</ol>'\n }\n }\n form.reset()\n host.querySelector('.cr-comments-section')?.appendChild(form)\n const replying = form.querySelector<HTMLElement>('.cr-replying')\n if (replying) replying.hidden = true\n resetCaptcha(form)\n setStatus(form, '<p class=\"cr-success\" role=\"status\">' + esc(result.status === 'approved' ? strings.posted : strings.pending) + '</p>')\n } catch (error) {\n setStatus(form, '<p class=\"cr-error\" role=\"alert\">' + esc(error instanceof Error ? error.message : strings.failed) + '</p>')\n resetCaptcha(form)\n } finally {\n busy(form, false)\n }\n })\n}\n`\n","// Components.\n//\n// `runtime`-sourced comments and forms get REAL implementations: an Astro\n// component that mounts a custom element carrying the runtime binding and the\n// page's entry address, and a client script that fetches, renders and submits\n// against the provider's public API (src/lib/embed.ts). Everything else — and\n// every runtime component when no binding was given — is a `<cr-component>`\n// placeholder that marks the spot: the emitter cannot invent an ad slot, and a\n// comments form without a provider is a promise the page cannot keep.\n//\n// A component is mounted where the chrome carries its marker\n// (`<!--@@component:ID@@-->`, see layouts.ts); emitting the file alone would\n// leave it unused, which is not integration.\n\nimport type { ComponentDef, RuntimeBinding } from '@contentrain/types'\nimport { EMBED_TS } from './embed.js'\nimport { pascalCase, stableJson } from './util.js'\n\nexport interface ComponentGenResult {\n files: Record<string, string>\n warnings: string[]\n}\n\n/** Component types this emitter implements against a runtime binding. */\nexport const RUNTIME_IMPLEMENTED = new Set<ComponentDef['type']>(['comments', 'form'])\n\nexport function isRuntimeImplemented(c: ComponentDef, runtime: RuntimeBinding | undefined): boolean {\n if (!runtime || c.source !== 'runtime' || !RUNTIME_IMPLEMENTED.has(c.type)) return false\n return c.type !== 'form' || Boolean(c.model)\n}\n\nexport function componentFiles(components: ComponentDef[], runtime: RuntimeBinding | undefined): ComponentGenResult {\n const files: Record<string, string> = {}\n const warnings: string[] = []\n let needsRuntime = false\n\n for (const c of components) {\n const name = pascalCase(c.id)\n if (isRuntimeImplemented(c, runtime)) {\n needsRuntime = true\n files[`src/components/${name}.astro`] = c.type === 'comments' ? commentsSource(c) : formSource(c)\n continue\n }\n if (c.source === 'runtime' && RUNTIME_IMPLEMENTED.has(c.type)) {\n warnings.push(\n !runtime\n ? `component ${c.id} (${c.type}): no runtime binding given — emitted as a placeholder; pass input.runtime to mount it`\n : `component ${c.id} (form): no model named — a form must say which collection it submits to; emitted as a placeholder`,\n )\n }\n files[`src/components/${name}.astro`] = placeholderSource(c)\n }\n\n if (needsRuntime && runtime) {\n files['src/data/runtime.json'] = stableJson({ base_url: runtime.base_url, project_id: runtime.project_id })\n files['src/lib/embed.ts'] = EMBED_TS\n }\n return { files, warnings }\n}\n\nfunction header(c: ComponentDef, extra = ''): string {\n return `/**\n * cr-component: type=${c.type} source=${c.source}${c.name ? ` — ${c.name}` : ''}\n * Emitted by @contentrain/emitter-astro.${extra}\n */`\n}\n\nfunction placeholderSource(c: ComponentDef): string {\n const variants = (c.variants ?? []).map((v) => v.key)\n return `---\n${header(\n c,\n `${c.source === 'runtime' ? '\\n * Placeholder — needs a live provider; see the migration handoff offers.' : ' Placeholder.'}${\n variants.length ? `\\n * Variants: ${variants.join(', ')}` : ''\n }`,\n)}\ninterface Props {\n variant?: string\n /** Content-store address of the page's entry; unused by a placeholder. */\n entry?: { model_id: string; entry_id: string; locale?: string }\n}\nconst { variant = ${JSON.stringify(variants[0] ?? 'default')} } = Astro.props\n---\n<cr-component data-type=${JSON.stringify(c.type)} data-variant={variant}></cr-component>\n`\n}\n\nfunction commentsSource(c: ComponentDef): string {\n return `---\n${header(c, `\n * Mounted comments thread: reads the entry's approved comments from the runtime\n * provider's public API and posts new ones. Pending comments are never shown —\n * moderation happens on the provider. Renders nothing on a page without an\n * entry address (a list, a static page).`)}\nimport runtime from '../data/runtime.json'\n\ninterface Props {\n /** Content-store address of the page's entry — the thread's key. */\n entry?: { model_id: string; entry_id: string; locale?: string }\n variant?: string\n}\nconst { entry, variant = 'default' } = Astro.props\n---\n{entry && (\n <cr-comments\n class={\\`cr-comments cr-comments--\\${variant}\\`}\n data-base-url={runtime.base_url}\n data-project={runtime.project_id}\n data-model={entry.model_id}\n data-entry={entry.entry_id}\n data-locale={entry.locale}\n data-variant={variant}\n >\n <noscript><p class=\"cr-noscript\">Comments need JavaScript.</p></noscript>\n </cr-comments>\n)}\n\n<script>\n import { mountComments } from '../lib/embed'\n\n class CrComments extends HTMLElement {\n connectedCallback() {\n void mountComments(this)\n }\n }\n if (!customElements.get('cr-comments')) customElements.define('cr-comments', CrComments)\n</script>\n`\n}\n\nfunction formSource(c: ComponentDef): string {\n return `---\n${header(c, `\n * Mounted public form for the ${JSON.stringify(c.model)} model: fetches the\n * exposed fields from the runtime provider, renders one control per field\n * (honeypot and captcha included when the model asks for them) and submits.\n * Submissions land on the provider for moderation; an approved one becomes a\n * content entry there.`)}\nimport runtime from '../data/runtime.json'\n\ninterface Props {\n variant?: string\n /** Accepted for uniform mounting; a form is addressed by its model, not by the page's entry. */\n entry?: { model_id: string; entry_id: string; locale?: string }\n}\nconst { variant = 'default' } = Astro.props\n---\n<cr-form\n class={\\`cr-form-mount cr-form-mount--\\${variant}\\`}\n data-base-url={runtime.base_url}\n data-project={runtime.project_id}\n data-model=${JSON.stringify(c.model)}\n data-variant={variant}\n>\n <noscript><p class=\"cr-noscript\">This form needs JavaScript.</p></noscript>\n</cr-form>\n\n<script>\n import { mountForm } from '../lib/embed'\n\n class CrForm extends HTMLElement {\n connectedCallback() {\n void mountForm(this)\n }\n }\n if (!customElements.get('cr-form')) customElements.define('cr-form', CrForm)\n</script>\n`\n}\n","// Header and footer chrome → shared Astro components.\n//\n// The body chrome stays one fragment because the content container is nested\n// deep inside it. The masthead and the colophon are the exception: they sit\n// outside the content path and repeat across families, so lifting them out\n// gives the project one address for the nav (where a jQuery-free menu replaces\n// the theme's) and one copy of the markup instead of N.\n//\n// Identity is by CONTENT, not by declared name: two families whose headers are\n// byte-identical share a component even if the producer named them differently,\n// and two different headers that claim the same name get separate files plus a\n// warning — never a silent swap, which would put one family's nav on another's\n// pages.\n\nimport type { LayoutFamily } from '@contentrain/types'\nimport { CHROME_BODY_SLOT } from '@contentrain/types'\nimport { balanceWarning } from './balance.js'\nimport { pascalCase, stableJson } from './util.js'\n\nexport type ChromePosition = 'header' | 'footer'\n\nexport interface ChromeComponentRef {\n name: string\n position: ChromePosition\n}\n\nexport interface ChromeRegistry {\n files: Record<string, string>\n /** Family id → the components its layout renders, in chunk order. */\n byFamily: Map<string, ChromeComponentRef[]>\n warnings: string[]\n}\n\nconst DEFAULT_NAME: Record<ChromePosition, string> = {\n header: 'SiteHeader',\n footer: 'SiteFooter',\n}\n\n/** A component name Astro can import: PascalCase and starting with a letter. */\nfunction componentName(raw: string): string {\n const pascal = pascalCase(raw)\n return /^[A-Za-z]/.test(pascal) ? pascal : `Chrome${pascal}`\n}\n\nfunction componentSource(name: string): string {\n return `---\n/**\n * Chrome component: ${name} — emitted by @contentrain/emitter-astro.\n * Markup travels as data so the Astro compiler never parses theme markup, and\n * fills the same \\`@@mark@@\\` placeholders the rest of the chrome does.\n */\nimport chunk from '../data/components/${name}.json'\nimport { renderTemplate } from '../lib/fill'\n\ninterface Props {\n /** Page marks — the region's own placeholders (site name, current-page class). */\n marks?: Record<string, unknown>\n}\nconst { marks = {} } = Astro.props\n---\n<Fragment set:html={renderTemplate(chunk.html, marks)} />\n`\n}\n\nexport function chromeComponents(families: LayoutFamily[]): ChromeRegistry {\n const files: Record<string, string> = {}\n const byFamily = new Map<string, ChromeComponentRef[]>()\n const warnings: string[] = []\n /** html → emitted component name, so identical regions collapse into one. */\n const byHtml = new Map<string, string>()\n /** emitted name → its html, so a name clash can be detected and renamed. */\n const byName = new Map<string, string>()\n\n for (const family of families) {\n const refs: ChromeComponentRef[] = []\n for (const chunk of family.chrome ?? []) {\n if (chunk.position !== 'header' && chunk.position !== 'footer') continue\n const position: ChromePosition = chunk.position\n const where = `family ${family.id}: ${position} chrome`\n\n if (chunk.html.includes(CHROME_BODY_SLOT)) {\n warnings.push(\n `${where} carries ${CHROME_BODY_SLOT} — content goes in the body chunk; the marker is rendered as a comment`,\n )\n }\n const unbalanced = balanceWarning(chunk.html)\n if (unbalanced) {\n warnings.push(\n `${where} is not balanced (${unbalanced}) — a lifted region must close what it opens; keep it in the body chunk`,\n )\n }\n\n const wanted = componentName(chunk.component ?? DEFAULT_NAME[position])\n let name = byHtml.get(chunk.html)\n if (name === undefined) {\n name = wanted\n for (let n = 2; byName.has(name) && byName.get(name) !== chunk.html; n++) {\n name = `${wanted}${n}`\n }\n if (name !== wanted) {\n warnings.push(\n `${where}: component name \"${wanted}\" is already used by different markup — emitted as \"${name}\"`,\n )\n }\n byHtml.set(chunk.html, name)\n byName.set(name, chunk.html)\n files[`src/data/components/${name}.json`] = stableJson({ html: chunk.html })\n files[`src/components/${name}.astro`] = componentSource(name)\n }\n refs.push({ name, position })\n }\n if (refs.length) byFamily.set(family.id, refs)\n }\n\n return { files, byFamily, warnings }\n}\n","// Legacy CSS quarantine (the fidelity layer).\n//\n// Migrated pages must render pixel-identically, and a utility framework's\n// preflight/reset would fight the theme's CSS. The truce is a cascade layer:\n// every legacy stylesheet is wrapped in `@layer legacy { … }`, and modern CSS\n// lives in its own layer — the two never meet in specificity.\n//\n// One CSS rule constrains the wrapping: `@import` (and `@charset`) must\n// precede all other statements, so a file cannot simply be wrapped whole.\n// Leading imports are hoisted above the wrapper and given `layer(legacy)`;\n// an import *after* other rules is already invalid CSS that browsers ignore,\n// so the file is left unwrapped and a warning recorded instead of silently\n// producing different invalidity.\n\nimport { LEGACY_CSS_LAYER } from '@contentrain/types'\n\nexport interface WrapCssResult {\n content: string\n warning?: string\n}\n\nconst LEADING = /^(\\s*(?:@charset[^;]+;|@import[^;]+;|\\/\\*[\\s\\S]*?\\*\\/)\\s*)+/\n\nexport function wrapLegacyCss(path: string, css: string): WrapCssResult {\n const rest = css.replace(LEADING, '')\n if (/@import\\s/.test(rest)) {\n return {\n content: css,\n warning: `${path}: @import after other rules — left unwrapped (fix the source stylesheet; mid-file imports are ignored by browsers)`,\n }\n }\n const leading = css.slice(0, css.length - rest.length)\n const hoisted = leading.replace(/@import\\s+([^;]+);/g, (full, spec: string) =>\n /\\blayer\\s*\\(/.test(spec) ? full : `@import ${spec.trim()} layer(${LEGACY_CSS_LAYER});`,\n )\n return { content: `${hoisted}@layer ${LEGACY_CSS_LAYER} {\\n${rest}\\n}\\n` }\n}\n","// @contentrain/emitter-astro — the open half of the migration pipeline.\n//\n// A ProjectIR (route model, layout families, component variants, query\n// bindings, design tokens) plus prepared content goes in; a complete Astro\n// project comes out as a pure file map. The analysis that *produces* a good\n// ProjectIR is hard and lives elsewhere; rendering one is deliberately\n// boring — which is exactly why it can be open, portable, and replaceable\n// by community emitters for other frameworks.\n\nimport type { EmitInput, EmitPost, EmitResult } from './types.js'\nimport { DEFAULT_COLLECTION } from './types.js'\nimport { scaffoldFiles } from './scaffold.js'\nimport { componentMarkers, familyFiles } from './layouts.js'\nimport { collectionItems, routeFiles } from './pages.js'\nimport { componentFiles, isRuntimeImplemented } from './components.js'\nimport { chromeComponents } from './chrome.js'\nimport { SEO_COMPONENT } from './seo.js'\nimport { wrapLegacyCss } from './css.js'\n\nexport function emitAstroProject(input: EmitInput): EmitResult {\n const { ir } = input\n const warnings: string[] = []\n const files: Record<string, string> = {}\n const add = (batch: Record<string, string>) => {\n for (const [path, content] of Object.entries(batch)) {\n if (files[path] !== undefined && files[path] !== content) {\n warnings.push(`duplicate file with different content: ${path} — keeping the first`)\n continue\n }\n files[path] = content\n }\n }\n\n add(scaffoldFiles(ir, input.options ?? {}))\n\n // Per-page SEO is on unless the producer owns those tags itself.\n const seo = input.options?.seo !== false\n if (seo) {\n add({ 'src/components/Seo.astro': SEO_COMPONENT })\n if (!ir.site.url) {\n warnings.push('site.url is empty — canonical links and absolute og:url/og:image are omitted; set it so search engines and share cards resolve')\n }\n }\n\n const familiesById = new Map(ir.families.map((f) => [f.id, f]))\n const lang = ir.site.locales?.[0] ?? 'en'\n // Header/footer regions first: families that share one share the component.\n const chrome = chromeComponents(ir.families)\n add(chrome.files)\n warnings.push(...chrome.warnings)\n const definitions = new Map((ir.components ?? []).map((c) => [c.id, c]))\n // A component marker can sit inside a post's own body (a form in a page's\n // content). The layout that renders that body must mount it, so collect the\n // ids per family from the collections its routes render.\n const bodyMarkersByFamily = new Map<string, Set<string>>()\n for (const route of ir.routes) {\n if (route.kind !== 'single' && route.collection === undefined) continue\n const posts = collectionItems(input.content ?? {}, route.collection ?? DEFAULT_COLLECTION)\n const ids = bodyMarkersByFamily.get(route.family) ?? new Set<string>()\n for (const post of posts) for (const id of componentMarkers(post.body)) ids.add(id)\n if (ids.size) bodyMarkersByFamily.set(route.family, ids)\n }\n for (const family of ir.families) {\n const fam = familyFiles(family, lang, chrome.byFamily.get(family.id), definitions, bodyMarkersByFamily.get(family.id) ?? [], { seo, siteName: ir.site.title })\n add(fam.files)\n warnings.push(...fam.warnings)\n }\n\n for (const css of input.css ?? []) {\n const wrapped = wrapLegacyCss(css.path, css.content)\n if (wrapped.warning) warnings.push(wrapped.warning)\n const base = css.path.split('/').pop() ?? css.path\n add({ [`public/styles/legacy/${base}`]: wrapped.content })\n }\n const providedCss = new Set((input.css ?? []).map((c) => c.path.split('/').pop() ?? c.path))\n const missingCss = (refs: string[] | undefined, owner: string) => {\n for (const ref of refs ?? []) {\n if (!providedCss.has(ref.split('/').pop() ?? ref)) {\n warnings.push(`${owner}: css file \"${ref}\" not provided in input.css`)\n }\n }\n }\n for (const family of ir.families) missingCss(family.css.files, `family ${family.id}`)\n const allPosts = [\n ...(input.content?.posts ?? []),\n ...Object.values(input.content?.collections ?? {}).flat(),\n ]\n for (const post of allPosts) missingCss(post.css, `post ${post.slug}`)\n for (const [queryId, queryPages] of Object.entries(input.content?.queries ?? {})) {\n for (const qp of queryPages) missingCss(qp.css, `query ${queryId}`)\n }\n\n for (const route of ir.routes) {\n const result = routeFiles(route, familiesById.get(route.family), input.content ?? {}, lang, seo)\n add(result.files)\n warnings.push(...result.warnings)\n }\n\n const components = componentFiles(ir.components ?? [], input.runtime)\n add(components.files)\n warnings.push(...components.warnings)\n\n // A mounted comments thread is keyed by the post's entry address; a post\n // without one renders no thread. Say so once per collection, not per page.\n const commentsMounted = (ir.components ?? []).some((c) => isRuntimeImplemented(c, input.runtime) && c.type === 'comments')\n if (commentsMounted) {\n const collections: Record<string, EmitPost[]> = { ...input.content?.collections }\n if (input.content?.posts && !collections[DEFAULT_COLLECTION]) collections[DEFAULT_COLLECTION] = input.content.posts\n for (const [name, posts] of Object.entries(collections)) {\n const unbound = posts.filter((p) => !p.entry).length\n if (unbound) warnings.push(`collection ${name}: ${unbound} of ${posts.length} posts carry no entry address — the comments component renders nothing on those pages`)\n }\n }\n\n return { files, warnings }\n}\n\n/** Write an EmitResult to disk. Separate from emit so the core stays pure. */\nexport async function writeEmit(result: EmitResult, dir: string): Promise<void> {\n const { mkdir, writeFile } = await import('node:fs/promises')\n const { dirname, join } = await import('node:path')\n const dirs = new Set(Object.keys(result.files).map((p) => dirname(join(dir, p))))\n await Promise.all([...dirs].map((d) => mkdir(d, { recursive: true })))\n await Promise.all(\n Object.entries(result.files).map(([path, content]) => writeFile(join(dir, path), content, 'utf8')),\n )\n}\n\nexport type {\n EmitInput,\n EmitResult,\n EmitContent,\n EmitPost,\n QueryPage,\n EmitCssFile,\n EmitOptions,\n EmitTermRef,\n RuntimeBinding,\n EntrySourceRef,\n} from './types.js'\nexport { wrapLegacyCss } from './css.js'\nexport { pascalCase, patternToPagePath, stableJson } from './util.js'\nexport { componentMarkers } from './layouts.js'\nexport type { MountRef } from './layouts.js'\nexport { isRuntimeImplemented, RUNTIME_IMPLEMENTED } from './components.js'\nexport { bodySeoLeaks, stripSeoTags, SEO_COMPONENT } from './seo.js'\nexport type { StripResult } from './seo.js'\nexport { EMBED_TS } from './embed.js'\nexport { checkBalance, balanceWarning } from './balance.js'\nexport type { BalanceReport } from './balance.js'\n"],"mappings":"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"}