also looking at this
feat: config app.head on already-shelled/SSG pages + dev-server proxy (Nuxt parity)
#1765
4 files
+173
-1
| @@ -1718,6 +1718,43 @@ export async function serve(options: ServeOptions): Promise<void> { | ||
| 1718 | 1718 | const url = new URL(req.url) |
| 1719 | 1719 | let path = url.pathname |
| 1720 | 1720 | |
| 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 | ||
| 1721 | 1758 | // i18n: detect + strip locale prefix BEFORE any other routing |
| 1722 | 1759 | // decision so downstream sees the unprefixed path. Records the |
| 1723 | 1760 | // resolved locale so the post-render translation pass below uses |
| @@ -162,10 +162,80 @@ export function hasDocumentShell(html: string): boolean { | ||
| 162 | 162 | return /<!DOCTYPE\b/i.test(html) || /<html[\s>]/i.test(html) |
| 163 | 163 | } |
| 164 | 164 | |
| 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 | */ | |
| 180 | export 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 | ||
| 165 | 231 | /** |
| 166 | 232 | * Wrap HTML in a document shell if it doesn't already have one. |
| 167 | 233 | * This is the main entry point — called at the top level after all |
| 168 | 234 | * 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). | |
| 169 | 239 | */ |
| 170 | 240 | export function ensureDocumentShell( |
| 171 | 241 | html: string, |
| @@ -51,7 +51,7 @@ import { processServerBindings } from './server-bindings' | ||
| 51 | 51 | import { processVueTemplate } from './vue-template' |
| 52 | 52 | import { processDynamicComponents } from './dynamic-components' |
| 53 | 53 | import { processScopedStyles } from './style-scoping' |
| 54 | import { ensureDocumentShell, injectCloakStyle } from './document-shell' | |
| 54 | import { ensureDocumentShell, hasDocumentShell, injectCloakStyle, injectConfigHeadTags } from './document-shell' | |
| 55 | 55 | |
| 56 | 56 | // Extracted modules |
| 57 | 57 | import { hasSignalsSyntax, convertSignalDirectivesToAttributes, convertSignalLoopsToAttributes, preEvalLiteralReactiveIfs, processSignals } from './signal-processing' |
| @@ -353,7 +353,16 @@ export async function processDirectives( | ||
| 353 | 353 | ...(runtimeHead.htmlAttrs && { htmlAttrs: { ...(baseHeadConfig.htmlAttrs || {}), ...runtimeHead.htmlAttrs } }), |
| 354 | 354 | ...(runtimeHead.bodyAttrs && { bodyAttrs: { ...(baseHeadConfig.bodyAttrs || {}), ...runtimeHead.bodyAttrs } }), |
| 355 | 355 | } |
| 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) | |
| 356 | 363 | result = ensureDocumentShell(result, headConfig) |
| 364 | if (alreadyShelled) | |
| 365 | result = injectConfigHeadTags(result, baseHeadConfig) | |
| 357 | 366 | |
| 358 | 367 | // Inject layout meta tag for SPA layout-change detection |
| 359 | 368 | const layoutComment = result.match(/<!-- stx-layout: ([^ ]+) -->/) |
| @@ -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 | */ | |
| 8 | import { describe, expect, it } from 'bun:test' | |
| 9 | import { injectConfigHeadTags } from '../../src/document-shell' | |
| 10 | ||
| 11 | const HEAD = (inner = '') => `<!doctype html><html><head><title>Page</title>${inner}</head><body><h1>Hi</h1></body></html>` | |
| 12 | const count = (s: string, needle: string): number => s.split(needle).length - 1 | |
| 13 | ||
| 14 | describe('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 | }) | |