Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/emitter-fill-order.md
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 24 additions & 1 deletion packages/emitter-astro/src/emit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<!--@@body@@-->'
const replicaFill = (h: string, m: Record<string, string>) =>
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'] },
Expand Down Expand Up @@ -138,7 +143,7 @@ describe('emitAstroProject', () => {
expect(layout).toContain('set:html')
expect(layout).not.toContain('<header>')
expect(layout).not.toContain('<slot />')
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
Expand All @@ -154,6 +159,24 @@ describe('emitAstroProject', () => {
expect(chrome.body.startsWith('<div class="site">')).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 = `<article><h1>@@title@@</h1><div class="entry-content">${REPLICA_SLOT}</div></article>`
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('<div class="entry-content">BODY</div>')
})

it('a body chunk without the marker gets it appended, with a warning', () => {
const bad = emitAstroProject({
ir: {
Expand Down
7 changes: 4 additions & 3 deletions packages/emitter-astro/src/layouts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
---
<!doctype html>
<html lang=${JSON.stringify(lang)}>
Expand Down
13 changes: 13 additions & 0 deletions packages/emitter-astro/src/scaffold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,19 @@ export function fillMarks(html: string, marks: Record<string, unknown>): 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<string, unknown>, content: string): string {
return chromeBody
.split(BODY_SLOT)
.map((part) => fillMarks(part, marks))
.join(content)
}

export interface MarkablePost {
slug: string
title: string
Expand Down
Loading