ReviewOS

also looking at this

stacks/stx

fix(layouts): keep a page's @push content when its layout extends another layout

#2105
Open mkantautas wants to merge fix/nested-layout-push into main
2 files +106 -3
packages/stx/src/process.tsmodified+18-3
Changes to packages/stx/src/process.ts
@@ -962,6 +962,13 @@ async function processDirectivesInternal(
962962
963963 let output = template
964964
965 // What the page and the layouts so far pushed, when this call renders the
966 // layout that a layout extends (see the nested-layout branch). Taken off the
967 // context before anything else runs, so view composers, middleware,
968 // components and includes never see it.
969 const inheritedStacks = context.__stx_layout_stacks as { stacks: Record<string, string[]>, htmlComments: string[] } | undefined
970 delete context.__stx_layout_stacks
971
965972 await runViewComposersForTemplate(filePath, context)
966973 prepareDirectiveContext(context, opts)
967974
@@ -1052,8 +1059,16 @@ async function processDirectivesInternal(
10521059 // and: <link href="./file.css" stx-inline />
10531060 output = await processInlineAssets(output, filePath, dependencies)
10541061
1055 // Store stacks for @push/@stack directives
1062 // Store stacks for @push/@stack directives, starting with the inherited
1063 // ones. Their HTML comments are masked against the caller's table, so each
1064 // one moves into this call's table under a new index.
10561065 const stacks: Record<string, string[]> = {}
1066 for (const [name, entries] of Object.entries(inheritedStacks?.stacks ?? {})) {
1067 stacks[name] = entries.map(entry => entry.replace(/\x00STX_HTML_COMMENT_(\d+)\x00/g, (_, i) => {
1068 htmlComments.push(inheritedStacks!.htmlComments[Number(i)] ?? '')
1069 return `\x00STX_HTML_COMMENT_${htmlComments.length - 1}\x00`
1070 }))
1071 }
10571072
10581073 // Process @push and @prepend directives first
10591074 output = processStackPushDirectives(output, stacks)
@@ -1461,8 +1476,8 @@ async function processDirectivesInternal(
14611476 }
14621477 }
14631478
1464 // Create a new context with merged sections for the nested layout
1465 const nestedContext = { ...context, __sections: sections }
1479 // Create a new context with merged sections and the pushes so far for the nested layout
1480 const nestedContext = { ...context, __sections: sections, __stx_layout_stacks: { stacks, htmlComments } }
14661481
14671482 // Process the nested layout by recursively calling processDirectives
14681483 // with the modified template that includes the @layout directive
packages/stx/test/directives/nested-layout-push.test.tsadded+88-0
Changes to packages/stx/test/directives/nested-layout-push.test.ts
@@ -0,0 +1,88 @@
1/**
2 * `@push` from a page whose layout extends another layout.
3 *
4 * The page's pushes are collected before its layout is processed. A layout
5 * that extends another one is processed by a recursive call, which started
6 * with an empty set of stacks: everything the page pushed was dropped, and only
7 * the pushes of the layouts themselves reached `@stack`.
8 *
9 * Rendered via the src `processDirectives` (not Bun.build) so it exercises the
10 * fix, not a stale dist.
11 */
12import fs from 'node:fs'
13import path from 'node:path'
14import { afterAll, beforeAll, describe, expect, it } from 'bun:test'
15import { processDirectives } from '../../src/process'
16import { createTempDir } from '../../src/temp-dir'
17import type { StxOptions } from '../../src/types'
18import { clearComposers, composer } from '../../src/view-composers'
19
20const tmp = createTempDir('nested-layout-push')
21const opts: StxOptions = { debug: false, componentsDir: 'components', layoutsDir: tmp }
22
23const layouts: Record<string, string> = {
24 'base.stx': `<aside>@stack('styles')</aside><main>@yield('content')</main><footer>@stack('scripts')</footer>`,
25 'mid.stx': `@extends('base')`,
26 'mid-push.stx': `@extends('base')`,
27 'mid-prepend.stx': `@extends('base')@prepend('scripts')<i>mid</i>@endprepend`,
28 'top.stx': `@extends('mid-push')`,
29 'mid-comment.stx': `<!-- mid-comment -->@extends('base')`,
30 'base-nav.stx': `<footer>@stack('scripts')</footer>`,
31 'mid-nav.stx': `@extends('base-nav')`,
32}
33
34beforeAll(async () => {
35 await fs.promises.mkdir(tmp, { recursive: true })
36 for (const [name, content] of Object.entries(layouts))
37 await fs.promises.writeFile(path.join(tmp, name), content)
38})
39afterAll(async () => { await fs.promises.rm(tmp, { recursive: true, force: true }) })
40
41async function render(view: string): Promise<{ styles: string, scripts: string }> {
42 const out = await processDirectives(`${view}@section('content')C@endsection`, {}, path.join(tmp, 'page.stx'), opts, new Set())
43 return {
44 styles: out.match(/<aside>([\s\S]*?)<\/aside>/)?.[1] ?? out,
45 scripts: (out.match(/<footer>([\s\S]*?)<\/footer>/)?.[1] ?? out).replace(/\n/g, ''),
46 }
47}
48
49const page = ``
50
51describe('@push through a layout that extends another layout', () => {
52 it('keeps the page\'s pushes', async () => {
53 expect(await render(`@extends('mid')${page}`)).toEqual({ styles: '<em>css</em>', scripts: '<b>page</b><u>base</u>' })
54 })
55
56 it('keeps the page\'s pushes next to the intermediate layout\'s, in order', async () => {
57 expect((await render(`@extends('mid-push')${page}`)).scripts).toBe('<b>page</b><i>mid</i><u>base</u>')
58 expect((await render(`@extends('mid-prepend')${page}`)).scripts).toBe('<i>mid</i><b>page</b><u>base</u>')
59 })
60
61 it('carries through three levels', async () => {
62 expect((await render(`@extends('top')${page}`)).scripts).toBe('<b>page</b><s>top</s><i>mid</i><u>base</u>')
63 })
64
65 it('keeps HTML comments in the page\'s pushes', async () => {
66 const view = ``
67 expect((await render(`@extends('mid')${view}`)).scripts).toBe('<!-- page-comment --><b>page</b><u>base</u>')
68 expect((await render(`@extends('mid-comment')${view}`)).scripts).toBe('<!-- page-comment --><b>page</b><u>base</u>')
69 })
70
71 it('keeps them out of what a view composer renders', async () => {
72 composer('mid-nav', async (ctx) => {
73 ctx.nav = await processDirectives(`<header>@stack('scripts')</header>`, { ...ctx }, path.join(tmp, 'nav.stx'), opts, new Set())
74 })
75 try {
76 const out = await processDirectives(`@extends('mid-nav')${page}@section('content')C@endsection`, {}, path.join(tmp, 'page.stx'), opts, new Set())
77 expect(out.match(/<header>([\s\S]*?)<\/header>/)?.[1]).toBe('N')
78 expect(out.match(/<footer>([\s\S]*?)<\/footer>/)?.[1]?.replace(/\n/g, '')).toBe('<b>page</b><u>base</u>')
79 }
80 finally {
81 clearComposers()
82 }
83 })
84
85 it('renders the same order as a page that extends the base layout directly', async () => {
86 expect(await render(`@extends('base')${page}`)).toEqual({ styles: '<em>css</em>', scripts: '<b>page</b><u>base</u>' })
87 })
88})