From 5492a7a71dae225c618f7bdb773d7d0495cb3ec9 Mon Sep 17 00:00:00 2001 From: Oto Macenauer Date: Wed, 29 Jul 2026 12:08:28 +0200 Subject: [PATCH] feat: persistent Knowledge base masthead on every page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #24. The branded header only existed on the landing catalog. Opening a documentation app dropped it, leaving no in-page way back to the catalog and no indication of which app was being viewed. Render every page through the layout ------------------------------------ Packaged sub-app pages previously bypassed Base.astro entirely: the catchall route emitted the pre-built document verbatim, so chrome, fonts, marketplace CSS, theme handling and the SPA router all had to be string-injected by transformSubAppHtml(). That forced a parallel implementation of everything the layout already did. transformSubAppHtml() now rewrites URLs and splits the document into its parts (head contents, body attributes, body contents, title), and Base.astro re-hosts them. The layout becomes the single owner of the document shell for landing, packaged and iframe pages alike. Superseded and removed: chromeHtml() and marketplaceRouterScript() — Astro's already handled navigation on these pages, so the bespoke fetch-and-swap router was redundant. Add the masthead ---------------- Masthead.astro renders the DOCS eyebrow, title and strapline plus a sub-navigation row: "Library" linking to the catalog, and a dynamic second entry naming the app currently being viewed. The entry matching the current location is inert text with aria-current="page" rather than a link to itself. Drop the chrome bar and dark mode --------------------------------- With the masthead carrying the navigation, the 56px fixed chrome bar was redundant, so it is gone along with its app switcher and content offset. A fixed bar was also the piece that escaped the shadow boundary when reframed pierced the DOM. The marketplace is now light-only: no theme toggle, no persisted preference, no dark palette. transformSubAppHtml() strips a sub-app's theme bootstrap and `dark` body class unconditionally, so an embedding host's theme cannot bleed into the fragment. Notes ----- - Fixes a latent bug: Chrome.astro's theme button called toggleTheme(), which only the string-injected script defined — it was dead on every Astro-rendered page. - Iframe entries flex-fill the viewport instead of hard-coding 100vh, which would have overflowed below the masthead. - The #297 CSS-accumulation test was counting mid-swap: ClientRouter updates the URL on popstate before swapping the DOM, so neither waitForURL nor networkidle proved the swap had finished. It now waits for landing-only content. The guard itself is unchanged. - The app contract required a dark variable set and a 56px sidebar offset; both are updated. HEADLESS_RULES.md also pointed at a non-existent src/input.css, now src/styles/marketplace.css. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015MkyFmv3JPCkTaFhPVUFtg --- CLAUDE.md | 30 ++- README.md | 8 +- contract/HEADLESS_RULES.md | 5 +- contract/STYLE_GUIDE.md | 30 +-- src/components/Chrome.astro | 97 --------- src/components/Masthead.astro | 81 ++++++++ src/layouts/Base.astro | 32 ++- src/pages/[...path].astro | 59 +++--- src/pages/index.astro | 26 +-- src/styles/marketplace.css | 185 ++++++++--------- src/templates/chrome.js | 364 --------------------------------- src/templates/shadow-compat.js | 22 ++ src/utils/transform.js | 162 +++++++-------- tests/build-integrity.spec.js | 77 ++++++- tests/standalone.spec.js | 31 ++- tests/web-fragment.spec.js | 9 +- 16 files changed, 459 insertions(+), 759 deletions(-) delete mode 100644 src/components/Chrome.astro create mode 100644 src/components/Masthead.astro delete mode 100644 src/templates/chrome.js create mode 100644 src/templates/shadow-compat.js diff --git a/CLAUDE.md b/CLAUDE.md index 91c17fe..3b3b074 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What This Is -Build-time aggregator that downloads pre-built static doc sites from GitHub Release artifacts, injects a persistent branded chrome (56px top nav), and produces a single static deployment. Served by nginx in Docker. Can also run as a web-fragment inside an Angular SSR gateway. +Build-time aggregator that downloads pre-built static doc sites from GitHub Release artifacts, wraps them in a persistent branded masthead, and produces a single static deployment. Served by nginx in Docker. Can also run as a web-fragment inside an Angular SSR gateway. ## Commands @@ -44,7 +44,8 @@ apps.json (registry) → [1] Fetch Release artifacts OR build local repos → apps/{slug}/ → [2] Copy non-HTML assets → public/{slug}/ (Astro serves as static) → [3] astro build: [...path].astro enumerates all HTML via getStaticPaths - → transformSubAppHtml() rewrites URLs + injects chrome + → transformSubAppHtml() rewrites URLs + splits the document, + which Base.astro then re-hosts (masthead + head/body) → dist/ ``` @@ -59,34 +60,43 @@ Orchestrator: `scripts/build-vite.js`. Flags: `--local`, `--headless`, `--path-p ### Core Data Flow -`apps.json` → `scripts/fetch-apps.js` downloads `dist.tar.gz` per app → `apps/{slug}/` → Astro's `src/pages/[...path].astro` catchall uses `getStaticPaths()` from `src/utils/apps.js` to enumerate every HTML file → `src/utils/transform.js` rewrites URLs and injects chrome → static output in `dist/`. +`apps.json` → `scripts/fetch-apps.js` downloads `dist.tar.gz` per app → `apps/{slug}/` → Astro's `src/pages/[...path].astro` catchall uses `getStaticPaths()` from `src/utils/apps.js` to enumerate every HTML file → `src/utils/transform.js` rewrites URLs and splits the document → `Base.astro` re-hosts the parts → static output in `dist/`. ### Key Source Files -- `src/pages/[...path].astro` — Catchall route, renders every sub-app page. Injects `` for Astro client-side transitions. +- `src/pages/[...path].astro` — Catchall route, renders every sub-app page through `Base.astro` - `src/pages/index.astro` — Landing catalog page - `src/utils/apps.js` — `getAppPages()` enumerates sub-app HTML (manifest-driven or filesystem crawl) -- `src/utils/transform.js` — `transformSubAppHtml()`: URL rewriting, chrome injection, headless transforms -- `src/templates/chrome.js` — Chrome HTML template + client-side SPA router script +- `src/utils/transform.js` — `transformSubAppHtml()`: URL rewriting, document splitting (head/body/title/body-class), headless transforms +- `src/layouts/Base.astro` — The one document shell: head, fonts, marketplace CSS, theme script, `` +- `src/components/Chrome.astro` — 56px fixed top bar (standalone mode only) +- `src/components/Masthead.astro` — Persistent Knowledge base header + Library/current-app sub-nav (all pages, both modes) +- `src/templates/chrome.js` — Inline theme script + shadow-DOM compat styles injected by the layout - `scripts/build-vite.js` — Build orchestrator (3-step pipeline) - `scripts/fetch-apps.js` — GitHub Release artifact downloader ### Two Modes -**Non-headless** (standalone): Injects chrome bar, SPA router, theme toggle. Client-side router intercepts navigation, fetches HTML, swaps DOM content. +Both modes render the same document: the masthead (`Masthead.astro`) — branding plus the Library / current-app sub-navigation — on every page, and nothing else chrome-like. There is no fixed top bar and no app switcher; the masthead is the navigation. -**Headless** (web-fragment): Strips dark mode class, marks `data-mp-headless="true"`, injects shadow DOM compat styles. No chrome bar. Designed for embedding in a web-fragments gateway. +**Non-headless** (standalone): Plain marketplace pages. Navigation is Astro's `` (view transitions). + +**Headless** (web-fragment): Marks `data-mp-headless="true"` and injects shadow DOM compat styles. Designed for embedding in a web-fragments gateway. + +### Light Only + +The marketplace has no dark mode: no theme toggle, no persisted theme, no `dark` class, no dark palette. `transformSubAppHtml()` strips any sub-app theme bootstrap and `dark` body class so an embedding host's theme cannot bleed into the fragment. ### URL Rewriting -`transform.js` rewrites all relative and root-relative URLs to absolute `/{prefix}/{slug}/...` paths. Runs before chrome injection to prevent double-rewriting. Removes `` tags. +`transform.js` rewrites all relative and root-relative URLs to absolute `/{prefix}/{slug}/...` paths. Runs before the document is split so nothing is double-rewritten. Removes `` tags. ## Contract for Doc Apps Apps registered in `apps.json` must comply with: - `contract/schema.json` — JSON Schema for `marketplace.json` manifest - `contract/HEADLESS_RULES.md` — Structural requirements (headless HTML, relative paths, `data-mp-headless` attribute) -- `contract/STYLE_GUIDE.md` — Design tokens, typography, dark mode support +- `contract/STYLE_GUIDE.md` — Design tokens and typography (light only — the marketplace has no dark mode) ## Testing diff --git a/README.md b/README.md index db9ac16..4b7eb8a 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # knowledge-base > A unified documentation portal — wraps independently-maintained doc apps into a -> single deployable with a persistent branded chrome, and can be embedded as a +> single deployable with a persistent branded masthead, and can be embedded as a > web fragment inside a host app. --- @@ -279,12 +279,12 @@ knowledge-base/ │ │ ├── index.astro ← Landing catalog │ │ └── [...path].astro ← Catch-all: renders every sub-app page │ ├── layouts/Base.astro -│ ├── components/ ← AppCard, AppIcon, Chrome -│ ├── templates/chrome.js ← Chrome HTML + client SPA router +│ ├── components/ ← AppCard, AppIcon, Masthead +│ ├── templates/shadow-compat.js ← Shadow-DOM design-token styles │ ├── styles/marketplace.css ← Design tokens + Tailwind │ └── utils/ │ ├── apps.js ← getAppPages() page enumeration -│ └── transform.js ← URL rewriting + chrome/headless injection +│ └── transform.js ← URL rewriting + sub-app document splitting ├── scripts/ │ ├── build-vite.js ← Build orchestrator │ ├── fetch-apps.js ← GitHub Release download + extract diff --git a/contract/HEADLESS_RULES.md b/contract/HEADLESS_RULES.md index 201469f..ddd4831 100644 --- a/contract/HEADLESS_RULES.md +++ b/contract/HEADLESS_RULES.md @@ -128,8 +128,9 @@ At minimum the following CSS custom properties must be present: --text-heading, --text-body, --text-muted ``` -Copy the `@theme` block and `:root` / `.dark` variable declarations from -[`src/input.css`](../src/input.css) in this repository. +Copy the `@theme` block and `:root` variable declarations from +[`src/styles/marketplace.css`](../src/styles/marketplace.css) in this repository. +There is deliberately no `.dark` set — the marketplace is light-only. --- diff --git a/contract/STYLE_GUIDE.md b/contract/STYLE_GUIDE.md index 5745e62..3fc1d18 100644 --- a/contract/STYLE_GUIDE.md +++ b/contract/STYLE_GUIDE.md @@ -52,23 +52,16 @@ Ensure Inter is loaded from Google Fonts or bundled: ## Dark mode -Dark mode is **class-based**: add `class="dark"` to `` (default in standalone builds). -The marketplace propagates the user's theme preference to all embedded apps. +**There is none.** The marketplace is light-only: no theme toggle, no persisted +preference, no dark palette. -Required dark-mode variable overrides (copy from `src/input.css`): +Do not ship a dark-mode bootstrap. When an app is integrated, the build strips any +`localStorage`-based theme script and removes a `dark` class from ``, so a +dark theme would be dropped at integration time anyway — and inside a web fragment +it would clash with the host application's own theme. -```css -.dark { - --bg-page: #0a0d14; - --bg-card: #111827; - --bg-strong: #1f2937; - --bg-subtle: #1a2236; - --border: #1f2937; - --text-heading: #ffffff; - --text-body: #d1d5db; - --text-muted: #9ca3af; -} -``` +An app may still define `.dark` styles for its **standalone** deployment; they will +simply never activate inside the marketplace. --- @@ -77,9 +70,8 @@ Required dark-mode variable overrides (copy from `src/input.css`): - Fixed left sidebar, **64px wide** (`w-64`) - Background: `var(--bg-card)` - Border right: `1px solid var(--border)` -- `id="sidebar"` required for marketplace chrome injection -- In **headless** mode: `top: 0` (marketplace chrome handles the offset) -- In **standalone** mode: `top: 56px` (offset by site header) +- `id="sidebar"` required so the marketplace can position it +- `top: 0` — the marketplace renders no fixed top bar, so nothing needs offsetting --- @@ -151,7 +143,7 @@ padding: 0.75rem 1rem; - [ ] Only brand color tokens used (no raw hex brand colors) - [ ] Inter font loaded -- [ ] Both light and dark CSS variable sets defined +- [ ] Light CSS variable set defined (no dark set — the marketplace is light-only) - [ ] `id="sidebar"` present on the left navigation - [ ] `id="content"` or `
` wraps the prose area - [ ] No inline styles that override design tokens with hardcoded values diff --git a/src/components/Chrome.astro b/src/components/Chrome.astro deleted file mode 100644 index 9ee81ce..0000000 --- a/src/components/Chrome.astro +++ /dev/null @@ -1,97 +0,0 @@ ---- -// src/components/Chrome.astro -import AppIcon from './AppIcon.astro'; - -interface App { - slug: string; - name: string; - icon?: string; -} - -interface Props { - apps: App[]; - activeSlug?: string; - base?: string; -} - -const { apps, activeSlug = '', base = '/knowledge-base' } = Astro.props; -const activeApp = apps.find(a => a.slug === activeSlug); - -const link = (path: string) => `${base}/${path}`; ---- - - diff --git a/src/components/Masthead.astro b/src/components/Masthead.astro new file mode 100644 index 0000000..5c2af1f --- /dev/null +++ b/src/components/Masthead.astro @@ -0,0 +1,81 @@ +--- +// src/components/Masthead.astro +// +// Persistent Knowledge base masthead. Rendered by every page through Base.astro +// (landing catalog, packaged sub-app pages, iframe entries) in both headless and +// standalone builds, so the branding and the way back to the catalog never +// disappear once a user opens a documentation app. +// +// Sub-navigation: +// • "Library" → the catalog of all documentation sites +// • a dynamic second entry naming the app currently being viewed +// The entry matching the current location is inert text with aria-current="page" +// rather than a link to itself. +import AppIcon from './AppIcon.astro'; + +interface App { + slug: string; + name: string; + icon?: string; +} + +interface Props { + apps: App[]; + activeSlug?: string; + base?: string; + /** True when the current page is the active app's own index (its crumb is then inert). */ + appRoot?: boolean; +} + +const { apps, activeSlug = '', base = '/knowledge-base', appRoot = false } = Astro.props; + +const activeApp = apps.find(a => a.slug === activeSlug); +const onLibrary = !activeApp; +--- + +
+
+
+ + + + Docs +
+ + {/* On the catalog the masthead IS the page heading; on a documentation page the + sub-app supplies its own

, so the masthead title stays non-heading text. */} + {onLibrary + ?

Knowledge base

+ :

Knowledge base

+ } + +

+ Browse and access all documentation sites. Each app is independently + maintained and deployed — one consistent experience. +

+ + +
+
diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index fb1af71..756077d 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -1,23 +1,40 @@ --- // src/layouts/Base.astro +// +// The single document shell for every marketplace page — landing catalog, +// packaged sub-app pages and iframe entries alike. It owns , the fonts, +// the marketplace stylesheet, and the shadow-DOM compat styles, +// so none of that has to be string-injected into sub-app documents any more. +// +// The marketplace is light-only. There is no theme toggle, no persisted theme and +// no `dark` class anywhere, so an embedding host's theme can never bleed in. import '../styles/marketplace.css'; import { ClientRouter } from 'astro:transitions'; -import { shadowCompatStyle } from '../templates/chrome.js'; +import { shadowCompatStyle } from '../templates/shadow-compat.js'; interface Props { title?: string; headless?: boolean; + /** Extra classes for — packaged sub-app pages pass their own body class. */ bodyClass?: string; + /** Full-height flex column body. Off for sub-app content, which brings its own layout. */ + flexBody?: boolean; } const { title = 'Knowledge base', headless = false, bodyClass = '', + flexBody = true, } = Astro.props; const HEADLESS = process.env.MP_HEADLESS !== 'false'; const effectiveHeadless = headless || HEADLESS; + +const bodyClasses = [ + flexBody ? 'min-h-screen flex flex-col' : '', + bodyClass, +].filter(Boolean).join(' ') || undefined; --- @@ -27,14 +44,19 @@ const effectiveHeadless = headless || HEADLESS; {title} + {/* Sub-app head contents (their stylesheets, meta and inline scripts) are slotted + in first so the marketplace stylesheet — injected by Astro from the CSS import + above — still wins on equal specificity, exactly as it did when the old string + transform appended it to the sub-app document. */} + - + {/* data-astro-transition-persist keeps the font stylesheet stable across + ClientRouter navigation so it isn't churned on every page swap — avoids + web-fragments #297 accumulation. */} - + diff --git a/src/pages/[...path].astro b/src/pages/[...path].astro index 9925e92..54254db 100644 --- a/src/pages/[...path].astro +++ b/src/pages/[...path].astro @@ -4,13 +4,17 @@ // Catchall route that serves every sub-app page as a static Astro page. // getStaticPaths enumerates all HTML files under apps/{slug}/ for every packaged // entry in apps.json, plus a single route per iframe-type entry (issue #10). +// +// Packaged pages are not emitted verbatim: transformSubAppHtml() rewrites their +// URLs and splits the document, and Base.astro re-hosts the parts. That keeps the +// masthead, fonts, marketplace CSS and ClientRouter in one place for all page +// types (issue #24). import { readFileSync } from 'node:fs'; -import { ClientRouter } from 'astro:transitions'; import { getAppPages } from '../utils/apps.js'; import { transformSubAppHtml } from '../utils/transform.js'; import Base from '../layouts/Base.astro'; -import Chrome from '../components/Chrome.astro'; +import Masthead from '../components/Masthead.astro'; const PREFIX = 'knowledge-base'; @@ -25,36 +29,39 @@ export function getStaticPaths() { const props = Astro.props; const { slug, appHeadless, apps, title } = props; -// Packaged apps: read the sub-app HTML file and transform it. Split at -// so is injected for Astro's client-side transitions. -let beforeHeadEnd = ''; -let afterHeadEnd = ''; -if (!props.iframe) { - const raw = readFileSync(props.file, 'utf-8'); - const html = transformSubAppHtml(raw, apps, slug, props.fileRelDir, PREFIX, appHeadless); - const headEnd = html.indexOf(''); - beforeHeadEnd = headEnd >= 0 ? html.slice(0, headEnd) : html; - afterHeadEnd = headEnd >= 0 ? html.slice(headEnd) : ''; -} +const parts = props.iframe + ? null + : transformSubAppHtml(readFileSync(props.file, 'utf-8'), slug, props.fileRelDir, PREFIX); + +const app = apps.find((a: any) => a.slug === slug); +const pageTitle = title ?? parts?.title ?? app?.name ?? 'Knowledge base'; -// iFrame entries: fill the viewport (below the 56px chrome when standalone; full -// height when headless/embedded, where the host supplies its own chrome). -const iframeHeight = appHeadless ? '100vh' : 'calc(100vh - var(--mp-chrome-h, 56px))'; +// The app's own crumb in the masthead is inert on the app index and a link deeper in. +const atAppRoot = !props.fileRelDir; --- -{props.iframe ? ( - - {!appHeadless && } -
+ + + {parts && } + + + + {props.iframe ? ( +
- -) : ( - -)} + ) : ( + + )} + diff --git a/src/pages/index.astro b/src/pages/index.astro index effa02e..b5c1120 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import Base from '../layouts/Base.astro'; -import Chrome from '../components/Chrome.astro'; +import Masthead from '../components/Masthead.astro'; import AppCard from '../components/AppCard.astro'; const HEADLESS = process.env.MP_HEADLESS !== 'false'; @@ -16,31 +16,9 @@ const buildDate = new Date().toISOString().slice(0, 10); --- - {!HEADLESS && } +
- -
-
-
-
- -
- Docs -
-

- Knowledge base -

-

- Browse and access all documentation sites. Each app is independently - maintained and deployed — one consistent experience. -

-
-
-
diff --git a/src/styles/marketplace.css b/src/styles/marketplace.css index fb91551..d6da730 100644 --- a/src/styles/marketplace.css +++ b/src/styles/marketplace.css @@ -4,9 +4,6 @@ @source "../src/templates/**/*.js"; @source "../scripts/**/*.js"; -/* ── Class-based dark mode — also covers wf-html.dark used by web-fragments ── */ -@custom-variant dark (&:where(.dark, .dark *)); - /* ── Design tokens ──────────────────────────────────────────────────────── */ @theme { /* Brand palette */ @@ -23,12 +20,13 @@ --font-mono: 'SF Mono', 'Fira Code', 'Fira Mono', ui-monospace, monospace; } -/* ── Semantic CSS variables (light & dark) ─────────────────────────────── */ -/* Note: @tailwindcss/vite compiles :root to :root,:host automatically, so +/* ── Semantic CSS variables ─────────────────────────────────────────────── */ +/* The marketplace is light-only — there is deliberately no dark palette here, + so an embedding host's dark theme cannot bleed into the fragment. + Note: @tailwindcss/vite compiles :root to :root,:host automatically, so custom properties are available inside shadow DOMs via :host inheritance. - Shadow DOM element-level declarations (wf-html, wf-document) and the - :host(.dark) dark-mode override are injected as an inline `.trim(); - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -function escHtml(str) { - return String(str ?? '') - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); -} - -export function appIconSvg(icon, size = 18) { - const s = size; - const icons = { - 'book-open': ``, - 'cube': ``, - 'chip': ``, - 'chart-bar': ``, - 'shield': ``, - 'cog': ``, - 'terminal': ``, - 'globe': ``, - 'layers': ``, - 'lightning-bolt':``, - 'document': ``, - 'collection': ``, - 'puzzle': ``, - 'database': ``, - }; - return icons[icon] ?? icons['book-open']; -} diff --git a/src/templates/shadow-compat.js b/src/templates/shadow-compat.js new file mode 100644 index 0000000..b5c5c07 --- /dev/null +++ b/src/templates/shadow-compat.js @@ -0,0 +1,22 @@ +// src/templates/shadow-compat.js +// +// Inline `.trim(); diff --git a/src/utils/transform.js b/src/utils/transform.js index 1da34db..95db714 100644 --- a/src/utils/transform.js +++ b/src/utils/transform.js @@ -1,8 +1,12 @@ // src/utils/transform.js // // Pure sub-app HTML transformation utilities for the Astro catchall page. - -import { chromeHtml, chromeScript, marketplaceRouterScript, shadowCompatStyle } from '../templates/chrome.js'; +// +// Packaged sub-app pages arrive as complete pre-built documents. Rather than +// emitting them verbatim, the catchall route renders them through Base.astro so +// the layout owns the masthead, fonts, marketplace CSS and for +// every page. This module rewrites the sub-app URLs and splits the document into +// the parts the layout needs. // ── URL rewriting ───────────────────────────────────────────────────────────── @@ -30,8 +34,6 @@ function resolveUrl(url, base, prefix, slug) { /** * Rewrites all href/src/action URLs in the HTML to absolute paths. - * Runs BEFORE any marketplace assets are injected so injected absolute - * URLs (e.g. /__wf/knowledge-base/style.css) are never double-rewritten. * Removes any pre-existing tag. * * @param {string} html @@ -68,37 +70,15 @@ function rewriteUrlsToAbsolute(html, prefix, slug, fileRelDir) { return html; } -// ── Sub-app HTML transformation ─────────────────────────────────────────────── - -const FONT_PRECONNECT = ` - `; - /** - * Transforms raw sub-app HTML for embedding in the marketplace. - * - * Steps (all applied in order): - * 1. Rewrite all relative/root-relative URLs to absolute /knowledge-base/slug/… paths - * 2. Add data-astro-transition-persist to all stylesheet links (prevents FOUC) - * 3. Inject marketplace CSS link (via /__wf/ gateway route) - * 4. Inject Google Fonts preconnect if Inter is not already loaded - * 5a. Headless: strip dark-mode, mark document, inject shadow DOM compat styles - * 5b. Non-headless: inject chrome bar and SPA router - * - * @param {string} html - * @param {Array} apps - full apps.json array (for chrome nav) - * @param {string} slug - this app's slug - * @param {string} fileRelDir - path of this HTML file relative to the app root - * @param {string} prefix - URL prefix, e.g. 'knowledge-base' - * @param {boolean} headless - whether to render in headless (fragment) mode + * Adds data-astro-transition-persist to every stylesheet so Astro's + * ClientRouter keeps them in place across page transitions instead of removing + * and re-adding them. Inside web-fragments, reframed relocates head nodes out of + * wf-head, so Astro's cleanup can no longer find them and they accumulate + * unbounded — see web-fragments issue #297. */ -export function transformSubAppHtml(html, apps, slug, fileRelDir, prefix, headless) { - // 1. Rewrite all URLs to absolute /knowledge-base/slug/... (must be first) - html = rewriteUrlsToAbsolute(html, prefix, slug, fileRelDir); - - // 2. Add data-astro-transition-persist to every stylesheet so that - // Astro's ClientRouter keeps them in place across page transitions (no FOUC). - // Strip any trailing self-closing slash from attrs before re-serialising. - html = html.replace( +function persistStylesheets(html) { + return html.replace( /]*rel\s*=\s*["']stylesheet["'][^>]*)>/gi, (match, attrs) => { if (attrs.includes('data-astro-transition-persist')) return match; @@ -109,65 +89,65 @@ export function transformSubAppHtml(html, apps, slug, fileRelDir, prefix, headle return ``; }, ); +} - // 3. Inject marketplace CSS via /__wf/ gateway route - const stylePath = `/__wf/${prefix}/style.css`; - if (!html.includes(`href="${stylePath}"`)) { - html = html.replace(/<\/head>/i, ` \n`); - } - - // 4. Inject font preconnect if Inter isn't loaded - if (!html.includes('fonts.googleapis.com')) { - const fontsPersist = FONT_PRECONNECT.replace( - /rel="stylesheet"/, - 'rel="stylesheet" data-astro-transition-persist="css-google-fonts-inter"', - ); - html = html.replace(/<\/head>/i, ` ${fontsPersist}\n`); - } - - if (headless) { - // H-a. Force light mode: remove `dark` class and localStorage theme script - html = html.replace( - /(]*)\bclass=(["'])([^"']*)\2([^>]*>)/i, - (_, pre, q, cls, post) => { - const cleaned = cls.replace(/\bdark\b/g, '').replace(/\s+/g, ' ').trim(); - const classAttr = cleaned ? ` class=${q}${cleaned}${q}` : ''; - return `${pre.trimEnd()}${classAttr}${post}`; - }, - ); - html = html.replace(/