Skip to content

Commit a0e5a24

Browse files
fix(ssg): per-page <title>/meta on the auto-shell path (#1756)
buildApp() SSG shipped the identical config title and meta on every generated page — there was no per-page head mechanism in the app-shell model: - @Head … @endhead was extracted by processHeadDirective but the headContent was discarded at the call site; it now accumulates on the context and is merged into the shell's headRaw. A <title> inside it is promoted to the shell title so the shell doesn't emit a competing default (first <title> wins in browsers). - @section('title', …) — the idiom views already use with body-fragment layouts — now feeds the shell title. Precedence: useHead()/@title > @Head <title> > @section('title') > config title. - stripDocumentWrapper() gains an opt-in preserveHead mode that carries page-authored <title>/<meta>/<link> through the stripped fragment as an inert base64 marker comment (in-band, because the fragment is cached between strip and compose); composeShellWithPage() hoists the marker into the shell <head>. The serve-app shell path opts in; default behavior is unchanged for all other callers. Verified end-to-end: a generateStaticSite() build of a view using @section('title') + @Head now emits the per-page title and description in the static HTML. Closes #1756 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2005915 commit a0e5a24

5 files changed

Lines changed: 304 additions & 8 deletions

File tree

packages/stx/src/app-shell.ts

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ import { classifyAllScripts } from './script-classifier'
1717
export { extractLayoutMetadata } from 'stx-router/layout-metadata'
1818
export type { LayoutMetadata } from 'stx-router/layout-metadata'
1919

20+
// Marker comment carrying page-authored head tags (base64) through a
21+
// stripped fragment so composeShellWithPage can hoist them (#1756)
22+
const PAGE_HEAD_MARKER = 'stx-page-head:'
23+
const PAGE_HEAD_MARKER_RE = /<!--stx-page-head:([A-Za-z0-9+/=]+)-->\n?/
24+
2025
// Cache router script at module level (loaded once)
2126
let _cachedRouterScript: string | null = null
2227
function getRouterScriptCached(): string {
@@ -197,7 +202,27 @@ export function composeShellWithPage(
197202
pageContent: string,
198203
pageTitle?: string,
199204
): string {
200-
const title = pageTitle || 'stx App'
205+
// Hoist page-authored head tags carried through the fragment by
206+
// stripDocumentWrapper({ preserveHead: true }) — see #1756. A page <title>
207+
// wins over the default; remaining tags (meta/link) join the shell head.
208+
let hoistedTitle: string | undefined
209+
let hoistedHead = ''
210+
const markerMatch = pageContent.match(PAGE_HEAD_MARKER_RE)
211+
if (markerMatch) {
212+
pageContent = pageContent.replace(markerMatch[0], '')
213+
try {
214+
const tags = Buffer.from(markerMatch[1], 'base64').toString('utf8')
215+
const titleMatch = tags.match(/<title[^>]*>([\s\S]*?)<\/title>/i)
216+
if (titleMatch)
217+
hoistedTitle = titleMatch[1].trim()
218+
hoistedHead = tags.replace(/<title[^>]*>[\s\S]*?<\/title>/i, '').trim()
219+
}
220+
catch {
221+
// malformed marker — ignore and fall through to defaults
222+
}
223+
}
224+
225+
const title = pageTitle || hoistedTitle || 'stx App'
201226

202227
// Router script (cached at module level)
203228
const routerScript = getRouterScriptCached()
@@ -212,7 +237,7 @@ export function composeShellWithPage(
212237
<meta charset="UTF-8">
213238
<meta name="viewport" content="width=device-width, initial-scale=1.0">
214239
<title>${title}</title>
215-
${shell.shellStyles.join('\n ')}
240+
${hoistedHead ? ` ${hoistedHead}\n` : ''} ${shell.shellStyles.join('\n ')}
216241
</head>
217242
<body>
218243
${shell.beforeSlot}
@@ -240,9 +265,11 @@ ${routerScript}
240265
*
241266
* Strips:
242267
* - `<!DOCTYPE>`, `<html>`, `<head>`, `<body>` wrappers
243-
* - `<meta>`, `<title>`, `<link>` tags from `<head>`
268+
* - `<meta>`, `<title>`, `<link>` tags from `<head>` — unless
269+
* `preserveHead` is set, which carries them through the fragment as an
270+
* inert marker comment for `composeShellWithPage` to hoist (#1756)
244271
*/
245-
export function stripDocumentWrapper(html: string): string {
272+
export function stripDocumentWrapper(html: string, options: { preserveHead?: boolean } = {}): string {
246273
const trimmed = html.trim()
247274

248275
// No document wrapper — already a fragment
@@ -262,6 +289,24 @@ export function stripDocumentWrapper(html: string): string {
262289
}
263290
}
264291

292+
// preserveHead (#1756): carry the page's <title>/<meta>/<link> through the
293+
// fragment in-band (the stripped fragment is cached between strip and
294+
// compose, so a side-channel would be lost). Base64 keeps any `-->` inside
295+
// tag content from terminating the comment. Default charset/viewport metas
296+
// are skipped — the shell always emits its own.
297+
if (options.preserveHead && headMatch) {
298+
const headTags: string[] = []
299+
const tagRegex = /<title\b[^>]*>[\s\S]*?<\/title>|<meta\b[^>]*>|<link\b[^>]*>/gi
300+
let m: RegExpExecArray | null
301+
while ((m = tagRegex.exec(headMatch[1])) !== null) {
302+
if (/<meta\b[^>]*(?:\bcharset|name="viewport")/i.test(m[0]))
303+
continue
304+
headTags.push(m[0])
305+
}
306+
if (headTags.length > 0)
307+
headStyles.unshift(`<!--${PAGE_HEAD_MARKER}${Buffer.from(headTags.join('\n'), 'utf8').toString('base64')}-->`)
308+
}
309+
265310
// Use index-based extraction for <body> — more robust than regex with [\s\S]*?
266311
// which can match the wrong </body> if one appears in a string literal
267312
const bodyOpenMatch = trimmed.match(/<body\b[^>]*>/i)

packages/stx/src/dev-server/serve-app.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -332,7 +332,7 @@ export async function serveApp(appDir: string = '.', options: DevServerOptions =
332332
}
333333
if (valid) {
334334
let output = memEntry.output
335-
if (shell) output = stripDocumentWrapper(output)
335+
if (shell) output = stripDocumentWrapper(output, { preserveHead: true })
336336
return { route, content: output }
337337
}
338338
}
@@ -343,7 +343,7 @@ export async function serveApp(appDir: string = '.', options: DevServerOptions =
343343
const cached = await checkCache(route.filePath, merged)
344344
if (cached) {
345345
let output = cached
346-
if (shell) output = stripDocumentWrapper(output)
346+
if (shell) output = stripDocumentWrapper(output, { preserveHead: true })
347347
return { route, content: output }
348348
}
349349
}
@@ -437,7 +437,7 @@ export async function serveApp(appDir: string = '.', options: DevServerOptions =
437437
// inside the shell's own <!DOCTYPE>/html/head/body structure.
438438
// Preserves page scripts and styles as fragment content.
439439
if (shell) {
440-
output = stripDocumentWrapper(output)
440+
output = stripDocumentWrapper(output, { preserveHead: true })
441441
}
442442

443443
if (cacheEnabled && isStaticBuild) {

packages/stx/src/process.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -323,12 +323,33 @@ export async function processDirectives(
323323
// the module-global path is the historical API.
324324
const contextHead = (context.__stx_runtime_head as Record<string, any>) || null
325325
const runtimeHead = contextHead ?? getHeadStatic()
326+
327+
// Per-page head sources beyond useHead() (#1756):
328+
// - @head … @endhead raw HTML, accumulated on the context by the
329+
// processHeadDirective pass (page + layout + includes). A <title>
330+
// inside it is promoted to the shell title so the shell doesn't
331+
// emit a competing default (first <title> wins in browsers).
332+
// - @section('title', …) — the idiom users reach for with
333+
// body-fragment layouts, where no @yield('title') target exists.
334+
let pageHeadRaw = ((context.__stx_head_raw as string) || '').trim()
335+
let pageHeadTitle: string | undefined
336+
const rawTitleMatch = pageHeadRaw.match(/<title[^>]*>([\s\S]*?)<\/title>/i)
337+
if (rawTitleMatch) {
338+
pageHeadTitle = rawTitleMatch[1].trim()
339+
pageHeadRaw = pageHeadRaw.replace(rawTitleMatch[0], '').trim()
340+
}
341+
const sectionTitle = (context.__sections as Record<string, string> | undefined)?.title
342+
343+
// Title precedence: useHead()/@title > @head <title> > @section('title') > config
344+
const pageTitle = runtimeHead.title || pageHeadTitle || sectionTitle
345+
326346
const headConfig = {
327347
...baseHeadConfig,
328-
...(runtimeHead.title && { title: runtimeHead.title }),
348+
...(pageTitle && { title: pageTitle }),
329349
meta: [...(baseHeadConfig.meta || []), ...(runtimeHead.meta || [])],
330350
link: [...(baseHeadConfig.link || []), ...(runtimeHead.link || [])],
331351
script: [...(baseHeadConfig.script || []), ...(runtimeHead.script || [])],
352+
headRaw: [baseHeadConfig.headRaw, pageHeadRaw].filter(Boolean).join('\n'),
332353
...(runtimeHead.htmlAttrs && { htmlAttrs: { ...(baseHeadConfig.htmlAttrs || {}), ...runtimeHead.htmlAttrs } }),
333354
...(runtimeHead.bodyAttrs && { bodyAttrs: { ...(baseHeadConfig.bodyAttrs || {}), ...runtimeHead.bodyAttrs } }),
334355
}
@@ -1126,6 +1147,13 @@ async function processOtherDirectives(
11261147
}
11271148
const headResult = processHeadDirective(output)
11281149
output = headResult.content
1150+
// Accumulate @head … @endhead content on the context so the auto-shell
1151+
// composition (top of processDirectives) can inject it into the generated
1152+
// <head>. Previously the extracted headContent was discarded here, so
1153+
// @head was a silent no-op on the app-shell/SSG path (#1756).
1154+
if (headResult.headContent) {
1155+
context.__stx_head_raw = `${(context.__stx_head_raw as string) || ''}${headResult.headContent}\n`
1156+
}
11291157
output = processTitleDirective(output, context)
11301158
output = processMetaDirective(output, context)
11311159

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
/**
2+
* Per-page <title>/meta on the auto-shell (SSG/app-shell) path — #1756.
3+
*
4+
* Before the fix, every page generated by buildApp() carried the identical
5+
* config title: @section('title', …) had no @yield target in the shell model
6+
* so it was silently dropped, and @head … @endhead content was extracted by
7+
* processHeadDirective but discarded at the call site.
8+
*/
9+
import { afterEach, describe, expect, it } from 'bun:test'
10+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
11+
import { tmpdir } from 'node:os'
12+
import { join } from 'node:path'
13+
import { defaultConfig, processDirectives } from '../../src/index'
14+
15+
const tmpDirs: string[] = []
16+
17+
function tmp(): string {
18+
const dir = mkdtempSync(join(tmpdir(), 'stx-per-page-head-'))
19+
tmpDirs.push(dir)
20+
return dir
21+
}
22+
23+
afterEach(() => {
24+
for (const dir of tmpDirs.splice(0))
25+
rmSync(dir, { recursive: true, force: true })
26+
})
27+
28+
function shellOptions(extra: Record<string, unknown> = {}): Record<string, unknown> {
29+
return {
30+
...defaultConfig,
31+
partialsDir: '/tmp',
32+
componentsDir: '/tmp',
33+
autoShell: true,
34+
app: { head: { title: 'Config Title' } },
35+
...extra,
36+
}
37+
}
38+
39+
describe('per-page head on the auto-shell path (#1756)', () => {
40+
it('uses @section("title", literal) as the shell title', async () => {
41+
const result = await processDirectives(
42+
'@section(\'title\', \'About Us\')\n<h1>About</h1>',
43+
{},
44+
'/test.stx',
45+
shellOptions(),
46+
new Set(),
47+
)
48+
expect(result).toContain('<title>About Us</title>')
49+
expect(result).not.toContain('<title>Config Title</title>')
50+
})
51+
52+
it('uses @section("title", expression) resolved from server context', async () => {
53+
const result = await processDirectives(
54+
'@section(\'title\', pageTitle)\n<h1>Product</h1>',
55+
{ pageTitle: 'Widget · Shop' },
56+
'/test.stx',
57+
shellOptions(),
58+
new Set(),
59+
)
60+
expect(result).toContain('<title>Widget · Shop</title>')
61+
})
62+
63+
it('injects @head … @endhead content into the generated <head>', async () => {
64+
const result = await processDirectives(
65+
'@head\n<meta name="description" content="Per-page description">\n<link rel="canonical" href="https://example.com/about">\n@endhead\n<h1>About</h1>',
66+
{},
67+
'/test.stx',
68+
shellOptions(),
69+
new Set(),
70+
)
71+
const headEnd = result.indexOf('</head>')
72+
const head = result.slice(0, headEnd)
73+
expect(head).toContain('<meta name="description" content="Per-page description">')
74+
expect(head).toContain('<link rel="canonical" href="https://example.com/about">')
75+
// directive markers removed from the body
76+
expect(result).not.toContain('@head')
77+
expect(result).not.toContain('@endhead')
78+
})
79+
80+
it('promotes a <title> inside @head to the shell title (no competing default)', async () => {
81+
const result = await processDirectives(
82+
'@head\n<title>Head Title</title>\n@endhead\n<h1>Hi</h1>',
83+
{},
84+
'/test.stx',
85+
shellOptions(),
86+
new Set(),
87+
)
88+
expect(result).toContain('<title>Head Title</title>')
89+
expect(result).not.toContain('<title>Config Title</title>')
90+
// exactly one <title> in the document
91+
expect(result.match(/<title/g)!.length).toBe(1)
92+
})
93+
94+
it('@title() still wins over @section("title") and config', async () => {
95+
const result = await processDirectives(
96+
'@title(\'Runtime Title\')\n@section(\'title\', \'Section Title\')\n<h1>Hi</h1>',
97+
{},
98+
'/test.stx',
99+
shellOptions(),
100+
new Set(),
101+
)
102+
expect(result).toContain('<title>Runtime Title</title>')
103+
expect(result).not.toContain('Section Title')
104+
})
105+
106+
it('falls back to the config title when no per-page source exists', async () => {
107+
const result = await processDirectives(
108+
'<h1>Plain</h1>',
109+
{},
110+
'/test.stx',
111+
shellOptions(),
112+
new Set(),
113+
)
114+
expect(result).toContain('<title>Config Title</title>')
115+
})
116+
117+
it('keeps config headRaw alongside @head content', async () => {
118+
const result = await processDirectives(
119+
'@head\n<meta name="robots" content="noindex">\n@endhead\n<h1>Hi</h1>',
120+
{},
121+
'/test.stx',
122+
shellOptions({ app: { head: { title: 'Config Title', headRaw: '<meta name="generator" content="stx">' } } }),
123+
new Set(),
124+
)
125+
const head = result.slice(0, result.indexOf('</head>'))
126+
expect(head).toContain('<meta name="generator" content="stx">')
127+
expect(head).toContain('<meta name="robots" content="noindex">')
128+
})
129+
130+
it('honors @section("title") from a view extending a body-fragment layout', async () => {
131+
const dir = tmp()
132+
const layoutsDir = join(dir, 'layouts')
133+
rmSync(layoutsDir, { recursive: true, force: true })
134+
// body-fragment layout: no <html>/<head>, just structure + @yield('content')
135+
const { mkdirSync } = await import('node:fs')
136+
mkdirSync(layoutsDir, { recursive: true })
137+
writeFileSync(join(layoutsDir, 'app.stx'), '<main>@yield(\'content\')</main>\n', 'utf8')
138+
const pagePath = join(dir, 'about.stx')
139+
writeFileSync(pagePath, '@extends(\'app\')\n@section(\'title\', \'About Page\')\n@section(\'content\')\n<h1>About</h1>\n@endsection\n', 'utf8')
140+
141+
const result = await processDirectives(
142+
await Bun.file(pagePath).text(),
143+
{},
144+
pagePath,
145+
shellOptions({ layoutsDir }),
146+
new Set(),
147+
)
148+
expect(result).toContain('<title>About Page</title>')
149+
expect(result).toContain('<h1>About</h1>')
150+
expect(result).not.toContain('<title>Config Title</title>')
151+
})
152+
})

packages/stx/test/shell/app-shell.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,77 @@ describe('App Shell', () => {
266266
})
267267
})
268268

269+
describe('page head preservation through shell composition (#1756)', () => {
270+
const pageDoc = `<!DOCTYPE html>
271+
<html>
272+
<head>
273+
<meta charset="UTF-8">
274+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
275+
<title>Per-Page Title</title>
276+
<meta name="description" content="Page description">
277+
<link rel="canonical" href="https://example.com/page">
278+
</head>
279+
<body><h1>Content</h1></body>
280+
</html>`
281+
282+
it('preserveHead carries title/meta/link as an inert marker', () => {
283+
const fragment = stripDocumentWrapper(pageDoc, { preserveHead: true })
284+
expect(fragment).toContain('<!--stx-page-head:')
285+
expect(fragment).toContain('<h1>Content</h1>')
286+
// tags are encoded, not raw, in the fragment
287+
expect(fragment).not.toContain('<title>')
288+
expect(fragment).not.toContain('<meta name="description"')
289+
// default charset/viewport metas are not carried — the shell emits its own
290+
const encoded = fragment.match(/<!--stx-page-head:([A-Za-z0-9+/=]+)-->/)![1]
291+
const decoded = Buffer.from(encoded, 'base64').toString('utf8')
292+
expect(decoded).toContain('<title>Per-Page Title</title>')
293+
expect(decoded).toContain('<meta name="description" content="Page description">')
294+
expect(decoded).toContain('<link rel="canonical" href="https://example.com/page">')
295+
expect(decoded).not.toContain('charset')
296+
expect(decoded).not.toContain('viewport')
297+
})
298+
299+
it('composeShellWithPage hoists the marker into the shell head', async () => {
300+
const shellPath = path.join(tmpDir, 'shell-hoist.stx')
301+
fs.writeFileSync(shellPath, '<div class="app"><slot /></div>')
302+
const shell = await processShell(shellPath, {})
303+
expect(shell).not.toBeNull()
304+
305+
const fragment = stripDocumentWrapper(pageDoc, { preserveHead: true })
306+
const result = composeShellWithPage(shell!, fragment)
307+
308+
expect(result).toContain('<title>Per-Page Title</title>')
309+
expect(result).not.toContain('<title>stx App</title>')
310+
const head = result.slice(0, result.indexOf('</head>'))
311+
expect(head).toContain('<meta name="description" content="Page description">')
312+
expect(head).toContain('<link rel="canonical" href="https://example.com/page">')
313+
// marker removed from the final document
314+
expect(result).not.toContain('stx-page-head:')
315+
// exactly one title in the document
316+
expect(result.match(/<title/g)!.length).toBe(1)
317+
})
318+
319+
it('explicit pageTitle argument still wins over the hoisted title', async () => {
320+
const shellPath = path.join(tmpDir, 'shell-hoist2.stx')
321+
fs.writeFileSync(shellPath, '<div><slot /></div>')
322+
const shell = await processShell(shellPath, {})
323+
324+
const fragment = stripDocumentWrapper(pageDoc, { preserveHead: true })
325+
const result = composeShellWithPage(shell!, fragment, 'Explicit Title')
326+
expect(result).toContain('<title>Explicit Title</title>')
327+
expect(result).not.toContain('Per-Page Title')
328+
})
329+
330+
it('compose without a marker keeps the default title', async () => {
331+
const shellPath = path.join(tmpDir, 'shell-hoist3.stx')
332+
fs.writeFileSync(shellPath, '<div><slot /></div>')
333+
const shell = await processShell(shellPath, {})
334+
335+
const result = composeShellWithPage(shell!, '<h1>No marker</h1>')
336+
expect(result).toContain('<title>stx App</title>')
337+
})
338+
})
339+
269340
describe('isSpaNavigation', () => {
270341
it('should detect X-STX-Router header', () => {
271342
const request = new Request('http://localhost/test', {

0 commit comments

Comments
 (0)