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/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})