also looking at this
fix(layouts): keep a page's @push content when its layout extends another layout
#2105
2 files
+106
-3
| @@ -962,6 +962,13 @@ async function processDirectivesInternal( | ||
| 962 | 962 | |
| 963 | 963 | let output = template |
| 964 | 964 | |
| 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 | ||
| 965 | 972 | await runViewComposersForTemplate(filePath, context) |
| 966 | 973 | prepareDirectiveContext(context, opts) |
| 967 | 974 | |
| @@ -1052,8 +1059,16 @@ async function processDirectivesInternal( | ||
| 1052 | 1059 | // and: <link href="./file.css" stx-inline /> |
| 1053 | 1060 | output = await processInlineAssets(output, filePath, dependencies) |
| 1054 | 1061 | |
| 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. | |
| 1056 | 1065 | 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 | } | |
| 1057 | 1072 | |
| 1058 | 1073 | // Process @push and @prepend directives first |
| 1059 | 1074 | output = processStackPushDirectives(output, stacks) |
| @@ -1461,8 +1476,8 @@ async function processDirectivesInternal( | ||
| 1461 | 1476 | } |
| 1462 | 1477 | } |
| 1463 | 1478 | |
| 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 } } | |
| 1466 | 1481 | |
| 1467 | 1482 | // Process the nested layout by recursively calling processDirectives |
| 1468 | 1483 | // with the modified template that includes the @layout directive |
| @@ -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 | */ | |
| 12 | import fs from 'node:fs' | |
| 13 | import path from 'node:path' | |
| 14 | import { afterAll, beforeAll, describe, expect, it } from 'bun:test' | |
| 15 | import { processDirectives } from '../../src/process' | |
| 16 | import { createTempDir } from '../../src/temp-dir' | |
| 17 | import type { StxOptions } from '../../src/types' | |
| 18 | import { clearComposers, composer } from '../../src/view-composers' | |
| 19 | ||
| 20 | const tmp = createTempDir('nested-layout-push') | |
| 21 | const opts: StxOptions = { debug: false, componentsDir: 'components', layoutsDir: tmp } | |
| 22 | ||
| 23 | const 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 | ||
| 34 | beforeAll(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 | }) | |
| 39 | afterAll(async () => { await fs.promises.rm(tmp, { recursive: true, force: true }) }) | |
| 40 | ||
| 41 | async 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 | ||
| 49 | const page = `` | |
| 50 | ||
| 51 | describe('@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 | }) | |