ReviewOS

also looking at this

stacks/stx

feat: config app.head on already-shelled/SSG pages + dev-server proxy (Nuxt parity)

#1765
Merged glennmichael123 wants to merge feat/nuxt-config-parity into main
4 files +173 -1
packages/bun-plugin/src/serve.tsmodified+37-0
Changes to packages/bun-plugin/src/serve.ts
@@ -1718,6 +1718,43 @@ export async function serve(options: ServeOptions): Promise<void> {
17181718 const url = new URL(req.url)
17191719 let path = url.pathname
17201720
1721 // Dev proxy — forward matching paths to a backend before any routing.
1722 // Configured (Vite-style) in stx.config.ts:
1723 // server: { proxy: { '/api': 'http://localhost:8000' } }
1724 // or per-rule: { '/api': { target, changeOrigin?, rewrite? } }.
1725 // Keys are matched by path prefix; the full path+query is forwarded
1726 // (or `rewrite(path)` when given). Lets an stx app talk to a separate
1727 // API server in dev without CORS — the parity gap vs Nuxt's
1728 // `vite.server.proxy` / `nitro.devProxy`.
1729 const proxyRules = (stxConfig as any).server?.proxy as
1730 | Record<string, string | { target: string, changeOrigin?: boolean, rewrite?: (p: string) => string }>
1731 | undefined
1732 if (proxyRules) {
1733 for (const [prefix, rule] of Object.entries(proxyRules)) {
1734 if (path !== prefix && !path.startsWith(prefix))
1735 continue
1736 const ruleCfg = typeof rule === 'string' ? { target: rule } : rule
1737 const outPath = ruleCfg.rewrite ? ruleCfg.rewrite(path) : path
1738 const target = new URL(ruleCfg.target)
1739 const outUrl = new URL(outPath + url.search, target)
1740 const headers = new Headers(req.headers)
1741 if (ruleCfg.changeOrigin !== false)
1742 headers.set('host', target.host)
1743 try {
1744 return await fetch(outUrl, {
1745 method: req.method,
1746 headers,
1747 body: req.body,
1748 redirect: 'manual',
1749 ...(req.body ? { duplex: 'half' } : {}),
1750 } as RequestInit)
1751 }
1752 catch (err) {
1753 return new Response(`[stx dev proxy] ${prefix} → ${ruleCfg.target} failed: ${err instanceof Error ? err.message : String(err)}`, { status: 502 })
1754 }
1755 }
1756 }
1757
17211758 // i18n: detect + strip locale prefix BEFORE any other routing
17221759 // decision so downstream sees the unprefixed path. Records the
17231760 // resolved locale so the post-render translation pass below uses
packages/stx/src/document-shell.tsmodified+70-0
Changes to packages/stx/src/document-shell.ts
@@ -162,10 +162,80 @@ export function hasDocumentShell(html: string): boolean {
162162 return /<!DOCTYPE\b/i.test(html) || /<html[\s>]/i.test(html)
163163}
164164
165/**
166 * Inject the config `app.head` tags — script / meta / link / headRaw, but NOT
167 * `title` — into an EXISTING `<head>`.
168 *
169 * `generateDocumentShell` populates config head only when IT builds the shell.
170 * A page whose layout already renders `<html><head>` (the common case, and what
171 * SSG/static builds emit) skips that path, so global config head never lands —
172 * most visibly a site-wide analytics `<script>` (Fathom/Contentsquare/…) that
173 * silently never injects on layout-based or statically-built pages.
174 *
175 * Idempotent: skips a `<script src>`, `<link href>`, meta name/property/charset/
176 * http-equiv, or inline `<script>` content already present in the head, so it's
177 * safe to run even when the layout or `useHead` already emitted the same tag.
178 * `title` is intentionally excluded — the page/layout owns its own title.
179 */
180export function injectConfigHeadTags(html: string, headConfig: AppHeadConfig = {}): string {
181 const headCloseIdx = html.lastIndexOf('</head>')
182 if (headCloseIdx === -1)
183 return html
184
185 const { meta = [], link = [], script = [], headRaw = '' } = headConfig
186 const head = html.slice(0, headCloseIdx)
187 const parts: string[] = []
188
189 for (const m of meta as Record<string, string>[]) {
190 const dedup = m.name
191 ? `name="${m.name}"`
192 : m.property
193 ? `property="${m.property}"`
194 : m.charset != null
195 ? 'charset='
196 : m['http-equiv']
197 ? `http-equiv="${m['http-equiv']}"`
198 : ''
199 if (dedup && head.includes(dedup))
200 continue
201 parts.push(` <meta ${Object.entries(m).map(([k, v]) => `${k}="${v}"`).join(' ')}>`)
202 }
203
204 for (const l of link as Record<string, string>[]) {
205 if (l.href && head.includes(`href="${l.href}"`))
206 continue
207 parts.push(` <link ${Object.entries(l).map(([k, v]) => `${k}="${v}"`).join(' ')}>`)
208 }
209
210 for (const s of script as Record<string, any>[]) {
211 if (s.src) {
212 if (head.includes(`src="${s.src}"`))
213 continue
214 parts.push(` <script ${Object.entries(s).filter(([k]) => k !== 'content').map(([k, v]) => `${k}="${v}"`).join(' ')}></script>`)
215 }
216 else if (s.content) {
217 if (head.includes(s.content))
218 continue
219 parts.push(` <script>${s.content}</script>`)
220 }
221 }
222
223 if (headRaw && !head.includes(headRaw))
224 parts.push(` ${headRaw}`)
225
226 if (parts.length === 0)
227 return html
228 return `${html.slice(0, headCloseIdx)}${parts.join('\n')}\n${html.slice(headCloseIdx)}`
229}
230
165231/**
166232 * Wrap HTML in a document shell if it doesn't already have one.
167233 * This is the main entry point — called at the top level after all
168234 * directive processing is complete.
235 *
236 * When the html ALREADY has a shell we don't re-wrap (and don't touch its
237 * head here — the caller injects config-only head via `injectConfigHeadTags`,
238 * so runtime `useHead` tags aren't double-emitted).
169239 */
170240export function ensureDocumentShell(
171241 html: string,
packages/stx/src/process.tsmodified+10-1
Changes to packages/stx/src/process.ts
@@ -51,7 +51,7 @@ import { processServerBindings } from './server-bindings'
5151import { processVueTemplate } from './vue-template'
5252import { processDynamicComponents } from './dynamic-components'
5353import { processScopedStyles } from './style-scoping'
54import { ensureDocumentShell, injectCloakStyle } from './document-shell'
54import { ensureDocumentShell, hasDocumentShell, injectCloakStyle, injectConfigHeadTags } from './document-shell'
5555
5656// Extracted modules
5757import { hasSignalsSyntax, convertSignalDirectivesToAttributes, convertSignalLoopsToAttributes, preEvalLiteralReactiveIfs, processSignals } from './signal-processing'
@@ -353,7 +353,16 @@ export async function processDirectives(
353353 ...(runtimeHead.htmlAttrs && { htmlAttrs: { ...(baseHeadConfig.htmlAttrs || {}), ...runtimeHead.htmlAttrs } }),
354354 ...(runtimeHead.bodyAttrs && { bodyAttrs: { ...(baseHeadConfig.bodyAttrs || {}), ...runtimeHead.bodyAttrs } }),
355355 }
356 // A page/layout that already rendered its own <html><head> (the common
357 // case, and what SSG/static builds emit) makes ensureDocumentShell a
358 // no-op, so config `app.head` (script/meta/link/headRaw) never got into
359 // the head — most visibly a site-wide analytics <script>. Inject the
360 // CONFIG head (not the merged runtime head — renderHead below handles
361 // useHead) into the existing head, idempotently. See stacksjs/stx#1765.
362 const alreadyShelled = hasDocumentShell(result)
356363 result = ensureDocumentShell(result, headConfig)
364 if (alreadyShelled)
365 result = injectConfigHeadTags(result, baseHeadConfig)
357366
358367 // Inject layout meta tag for SPA layout-change detection
359368 const layoutComment = result.match(/<!-- stx-layout: ([^ ]+) -->/)
packages/stx/test/shell/config-head-existing-shell.test.tsadded+56-0
Changes to packages/stx/test/shell/config-head-existing-shell.test.ts
@@ -0,0 +1,56 @@
1/**
2 * Regression: config `app.head` must land on pages that ALREADY render their
3 * own <html><head> (layout-based / SSG static builds) — not just when stx
4 * builds the shell. Otherwise a site-wide analytics <script> (Fathom /
5 * Contentsquare / ts-analytics) silently never injects on static builds.
6 * See stacksjs/stx#1765.
7 */
8import { describe, expect, it } from 'bun:test'
9import { injectConfigHeadTags } from '../../src/document-shell'
10
11const HEAD = (inner = '') => `<!doctype html><html><head><title>Page</title>${inner}</head><body><h1>Hi</h1></body></html>`
12const count = (s: string, needle: string): number => s.split(needle).length - 1
13
14describe('injectConfigHeadTags (config app.head into an existing shell)', () => {
15 it('injects config app.head.script into an existing <head>', () => {
16 const out = injectConfigHeadTags(HEAD(), { script: [{ src: 'https://cs.example.com/uxa.js', defer: true }] } as any)
17 expect(out).toContain('src="https://cs.example.com/uxa.js"')
18 expect(out).toContain('defer="true"')
19 // before </head>
20 expect(out.indexOf('cs.example.com')).toBeLessThan(out.indexOf('</head>'))
21 })
22
23 it('injects config meta and link', () => {
24 const out = injectConfigHeadTags(HEAD(), { meta: [{ name: 'description', content: 'D' }], link: [{ rel: 'icon', href: '/favicon.ico' }] } as any)
25 expect(out).toContain('name="description"')
26 expect(out).toContain('href="/favicon.ico"')
27 })
28
29 it('is idempotent — never duplicates a script src / link href / meta already present', () => {
30 const already = HEAD('<script src="https://cs.example.com/uxa.js" defer></script><meta charset="utf-8">')
31 const out = injectConfigHeadTags(already, {
32 script: [{ src: 'https://cs.example.com/uxa.js', defer: true }],
33 meta: [{ charset: 'utf-8' }, { name: 'description', content: 'D' }],
34 } as any)
35 expect(count(out, 'cs.example.com/uxa.js')).toBe(1)
36 expect(count(out, 'charset=')).toBe(1)
37 expect(out).toContain('name="description"') // the new one IS added
38 })
39
40 it('never injects a config title (page/layout owns its title)', () => {
41 const out = injectConfigHeadTags(HEAD(), { title: 'Config Title', script: [{ src: '/a.js' }] } as any)
42 expect(out).not.toContain('Config Title')
43 expect(count(out, '<title>')).toBe(1)
44 })
45
46 it('injects inline <script> content, deduped', () => {
47 const out = injectConfigHeadTags(HEAD(), { script: [{ content: 'window.X=1' }] } as any)
48 expect(out).toContain('<script>window.X=1</script>')
49 expect(count(injectConfigHeadTags(out, { script: [{ content: 'window.X=1' }] } as any), 'window.X=1')).toBe(1)
50 })
51
52 it('no-ops when there is no </head> (SPA fragment)', () => {
53 const frag = '<main>hi</main>'
54 expect(injectConfigHeadTags(frag, { script: [{ src: '/a.js' }] } as any)).toBe(frag)
55 })
56})