diff --git a/.changeset/emitter-fill-order.md b/.changeset/emitter-fill-order.md new file mode 100644 index 00000000..5443b1d2 --- /dev/null +++ b/.changeset/emitter-fill-order.md @@ -0,0 +1,5 @@ +--- +"@contentrain/emitter-astro": patch +--- + +Fix silent body drop: the generated layout filled marks before splitting at `CHROME_BODY_SLOT`, so the `@@body@@` inside the marker comment was consumed by the `@@…@@` pattern (leaving ``) and page content was never spliced in (measured: 49.8 vs 97.8). Generated projects now compose via `composeBody` — split at the marker first, then fill each side. The contract constant is unchanged; this was an implementation-order bug. diff --git a/packages/emitter-astro/src/emit.test.ts b/packages/emitter-astro/src/emit.test.ts index b2a8e07b..8ce3a371 100644 --- a/packages/emitter-astro/src/emit.test.ts +++ b/packages/emitter-astro/src/emit.test.ts @@ -4,6 +4,11 @@ import { MIGRATION_CONTRACT_VERSION } from '@contentrain/types' import type { EmitInput } from './index' import { emitAstroProject, wrapLegacyCss, patternToPagePath, pascalCase } from './index' +// Tiny replicas of the emitted fill helpers — used by the ordering-regression test. +const REPLICA_SLOT = '' +const replicaFill = (h: string, m: Record) => + h.replace(/@@([a-z0-9_]+)@@/gi, (_a, k: string) => m[k] ?? '') + const ir: ProjectIR = { version: MIGRATION_CONTRACT_VERSION, site: { url: 'https://example.com', locales: ['en'] }, @@ -138,7 +143,7 @@ describe('emitAstroProject', () => { expect(layout).toContain('set:html') expect(layout).not.toContain('
') expect(layout).not.toContain('') - expect(layout).toContain('split(BODY_SLOT)') + expect(layout).toContain('composeBody(') expect(layout).toContain(`Astro.slots.render('default')`) const chrome = JSON.parse(result.files['src/data/chrome/f-article.json']!) // legacy before/after pair composes into a single body with the slot between @@ -154,6 +159,24 @@ describe('emitAstroProject', () => { expect(chrome.body.startsWith('
')).toBe(true) }) + it('the layout composes via composeBody — never fill-then-split', () => { + const layout = result.files['src/layouts/FArticle.astro']! + expect(layout).toContain('composeBody(chrome.body, marks, content)') + expect(layout).not.toContain('fillMarks(chrome.body') + const fill = result.files['src/lib/fill.ts']! + // composeBody must split at the marker before any filling + expect(fill).toMatch(/composeBody[\s\S]*?\.split\(BODY_SLOT\)[\s\S]*?fillMarks\(part, marks\)/) + }) + + it('regression: filling before splitting eats the marker and drops the body', () => { + const chromeBody = `

@@title@@

${REPLICA_SLOT}
` + const naive = replicaFill(chromeBody, { title: 'T' }).split(REPLICA_SLOT).join('BODY') + expect(naive).not.toContain('BODY') // the bug: marker consumed → content silently dropped + expect(naive).toContain('') + const correct = chromeBody.split(REPLICA_SLOT).map((p) => replicaFill(p, { title: 'T' })).join('BODY') + expect(correct).toContain('
BODY
') + }) + it('a body chunk without the marker gets it appended, with a warning', () => { const bad = emitAstroProject({ ir: { diff --git a/packages/emitter-astro/src/layouts.ts b/packages/emitter-astro/src/layouts.ts index ca4c83c1..3e8408b8 100644 --- a/packages/emitter-astro/src/layouts.ts +++ b/packages/emitter-astro/src/layouts.ts @@ -49,7 +49,7 @@ export function familyFiles(family: LayoutFamily, lang: string): FamilyGenResult files[`src/layouts/${name}.astro`] = `--- // Family: ${family.id}${family.name ? ` (${family.name})` : ''} — emitted by @contentrain/emitter-astro import chrome from '../data/chrome/${family.id}.json' -import { fillMarks, BODY_SLOT } from '../lib/fill' +import { fillMarks, composeBody } from '../lib/fill' interface Props { title?: string @@ -60,8 +60,9 @@ interface Props { const { title = '', marks = {}, body } = Astro.props const head = fillMarks(chrome.head, marks) const content = body ?? (Astro.slots.has('default') ? await Astro.slots.render('default') : '') -// Marks fill the CHROME only; content splices in untouched, then ONE injection. -const html = fillMarks(chrome.body, marks).split(BODY_SLOT).join(content) +// Split at the marker FIRST, then fill marks per side — filling first would +// eat the @@body@@ inside the marker and silently drop the content. +const html = composeBody(chrome.body, marks, content) --- diff --git a/packages/emitter-astro/src/scaffold.ts b/packages/emitter-astro/src/scaffold.ts index d55e2888..e88d0e3b 100644 --- a/packages/emitter-astro/src/scaffold.ts +++ b/packages/emitter-astro/src/scaffold.ts @@ -81,6 +81,19 @@ export function fillMarks(html: string, marks: Record): string return html.replace(/@@([a-z0-9_]+)@@/gi, (_all, key: string) => esc(marks[key] ?? '')) } +/** + * Compose the body chrome with the page content. The split happens BEFORE any + * mark filling: fillMarks' @@…@@ pattern would otherwise consume the @@body@@ + * inside the marker comment, leaving behind and silently dropping the + * content (measured cost on a real page: 49.8 vs 97.8). + */ +export function composeBody(chromeBody: string, marks: Record, content: string): string { + return chromeBody + .split(BODY_SLOT) + .map((part) => fillMarks(part, marks)) + .join(content) +} + export interface MarkablePost { slug: string title: string