ReviewOS

also looking at this

stacks/stx

fix(layouts): fill @parent in a layout that extends another layout

#2103
Open mkantautas wants to merge fix/nested-layout-parent into main
2 files +88 -1

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

packages/stx/src/process.tsmodified+17-1
Changes to packages/stx/src/process.ts
@@ -99,6 +99,22 @@ export { validateClientScript } from './script-validation'
9999 */
100100const PARENT_PLACEHOLDER = '<!--PARENT_CONTENT_PLACEHOLDER-->'
101101
102/**
103 * Mark the `@parent` in a section of a layout that extends another layout, as
104 * the page's own sections are marked, so the layout it extends splices its
105 * section in there. Layout source arrives raw, so `@@parent`, a longer word
106 * (`a@parents.com`) and a `@parent` inside a comment stay text.
107 */
108function markLayoutParent(content: string): string {
109 const visible = maskAtElementPosition(
110 content.replace(/\{\{--[\s\S]*?--\}\}/g, comment => ' '.repeat(comment.length)),
111 matchHtmlComment,
112 comment => ' '.repeat(comment.length),
113 ).output
114 const at = visible.search(/(?<![\w@])@parent(?!\w)/)
115 return at === -1 ? content : content.slice(0, at) + PARENT_PLACEHOLDER + content.slice(at + '@parent'.length)
116}
117
102118function escapeHtmlAttribute(value: string): string {
103119 return value
104120 .replace(/&/g, '&amp;')
@@ -1445,7 +1461,7 @@ async function processDirectivesInternal(
14451461 // Extract layout sections for parent content
14461462 const layoutSections: Record<string, string> = {}
14471463 const processedLayout = layoutContent.replace(/@section\(\s*['"]([^'"]+)['"]\s*\)([\s\S]*?)(?:@show|@endsection)/g, (_, name, content) => {
1448 layoutSections[name] = content.trim()
1464 layoutSections[name] = markLayoutParent(content.trim())
14491465 return '' // Remove the section from the template
14501466 })
14511467
packages/stx/test/directives/nested-layout-parent.test.tsadded+71-0
Changes to packages/stx/test/directives/nested-layout-parent.test.ts
@@ -0,0 +1,71 @@
1/**
2 * `@parent` in a layout that extends another layout.
3 *
4 * A page's `@parent` is marked when its sections are extracted, so the layout
5 * splices its own section in there. The sections of an intermediate layout
6 * were stored with `@parent` left as text: the next layout never filled it,
7 * its own section content was lost, and the page rendered the literal
8 * `@parent`.
9 *
10 * Rendered via the src `processDirectives` (not Bun.build) so it exercises the
11 * fix, not a stale dist.
12 */
13import fs from 'node:fs'
14import path from 'node:path'
15import { afterAll, beforeAll, describe, expect, it } from 'bun:test'
16import { processDirectives } from '../../src/process'
17import { createTempDir } from '../../src/temp-dir'
18import type { StxOptions } from '../../src/types'
19
20const tmp = createTempDir('nested-layout-parent')
21const opts: StxOptions = { debug: false, componentsDir: 'components', layoutsDir: tmp }
22
23const layouts: Record<string, string> = {
24 'base.stx': `<nav>@section('nav')BASE@show</nav><main>@yield('content')</main>`,
25 'mid.stx': `@extends('base')@section('nav')MID @parent@endsection`,
26 'top.stx': `@extends('mid')@section('nav')TOP @parent@endsection`,
27 'bare.stx': `<nav>@yield('nav')</nav><main>@yield('content')</main>`,
28 'mid-bare.stx': `@extends('bare')@section('nav')MID @parent@endsection`,
29 'mid-text.stx': `@extends('base')@section('nav')MID @@parent a@parents.com <!-- @parent -->@endsection`,
30 'mid-commented.stx': `@extends('base')@section('nav')<!-- @parent -->MID @parent@endsection`,
31}
32
33beforeAll(async () => {
34 await fs.promises.mkdir(tmp, { recursive: true })
35 for (const [name, content] of Object.entries(layouts))
36 await fs.promises.writeFile(path.join(tmp, name), content)
37})
38afterAll(async () => { await fs.promises.rm(tmp, { recursive: true, force: true }) })
39
40async function nav(view: string): Promise<string> {
41 const out = await processDirectives(`${view}@section('content')C@endsection`, {}, path.join(tmp, 'page.stx'), opts, new Set())
42 return out.match(/<nav>([\s\S]*?)<\/nav>/)?.[1] ?? out
43}
44
45describe('@parent in an intermediate layout', () => {
46 it('splices the parent layout\'s section into the intermediate layout\'s', async () => {
47 expect(await nav(`@extends('mid')@section('nav')PAGE @parent@endsection`)).toBe('PAGE MID BASE')
48 expect(await nav(`@extends('mid')`)).toBe('MID BASE')
49 })
50
51 it('carries through three levels', async () => {
52 expect(await nav(`@extends('top')@section('nav')PAGE @parent@endsection`)).toBe('PAGE TOP MID BASE')
53 expect(await nav(`@extends('top')`)).toBe('TOP MID BASE')
54 })
55
56 it('renders nothing for @parent when the parent layout has no such section', async () => {
57 expect(await nav(`@extends('mid-bare')`)).toBe('MID ')
58 })
59
60 it('still lets the page replace the section without @parent', async () => {
61 expect(await nav(`@extends('mid')@section('nav')PAGE@endsection`)).toBe('PAGE')
62 })
63
64 it('leaves @@parent, a word starting with @parent and a commented @parent as text', async () => {
65 expect(await nav(`@extends('mid-text')`)).toBe('MID @@parent a@parents.com <!-- @parent -->')
66 })
67
68 it('fills the @parent that follows a commented one', async () => {
69 expect(await nav(`@extends('mid-commented')`)).toBe('<!-- @parent -->MID BASE')
70 })
71})