From 47d9fa00c598ff9ba1d1096fe6563a98fa2963a3 Mon Sep 17 00:00:00 2001 From: Oto Macenauer Date: Fri, 14 Aug 2026 16:05:15 +0200 Subject: [PATCH 1/2] fix: make the prefix and headless flags mean what they say MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related bits of build configuration that were decorative. `--path-prefix=` / `MP_PREFIX` were parsed and exported and then read by nothing in the Astro build, so `--path-prefix=docs` produced a dist/ that still said knowledge-base in the Astro base, in every rewritten sub-app URL and in every masthead link — only the closing summary agreed with the flag. Making it real means templating nginx.conf, the /__wf/ rewrite and the gateway's route patterns as well, so the flag is gone and the prefix is one constant the config and both pages import. `vite.config.js` imported ./plugins/marketplace.js, which does not exist, and took index.html at the repo root as its input, which does not exist either. No script invoked it. It was also the only reader of MP_PREFIX, which is what made that flag look wired up. Deleted, and the "Two Build Configs" section of CLAUDE.md with it. Base.astro ORed the per-app headless prop with the global flag, so an app declaring "headless": false in a headless build was still rendered headless — the override only worked in one direction. It now trusts the prop and falls back to the build default only when unset. That default also moves into one helper: Base.astro read `MP_HEADLESS !== 'false'` (unset means headless) while the orchestrator computed the opposite, and nothing caught it because the orchestrator never leaves the variable unset. Unset now means standalone everywhere, documented. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QqFK6yffibtCBTF8xZ4hXW --- CLAUDE.md | 14 ++++---- README.md | 11 +++++-- apps.json | 3 +- astro.config.mjs | 9 +++-- scripts/build-vite.js | 15 ++++----- scripts/setup-test-apps.mjs | 6 ++++ src/layouts/Base.astro | 10 ++++-- src/pages/[...path].astro | 10 +++--- src/pages/index.astro | 9 +++-- src/utils/config.js | 42 ++++++++++++++++++++++++ tests/build-integrity.spec.js | 9 +++++ vite.config.js | 62 ----------------------------------- 12 files changed, 100 insertions(+), 100 deletions(-) create mode 100644 src/utils/config.js delete mode 100644 vite.config.js diff --git a/CLAUDE.md b/CLAUDE.md index 1b9aa8c..f9aa0c1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,12 +51,13 @@ apps.json (registry) → dist/ ``` -Orchestrator: `scripts/build-vite.js`. Flags: `--local`, `--headless`, `--path-prefix=`. +Orchestrator: `scripts/build-vite.js`. Flags: `--local`, `--headless`. -## Two Build Configs +## One Build Config -- **astro.config.mjs** — Astro SSG config, base `/knowledge-base`, used by `astro build`/`astro dev` -- **vite.config.js** — standalone Vite config with custom `marketplacePlugin` from `plugins/marketplace.js`, base `/`, used for the non-Astro build path +**astro.config.mjs** — Astro SSG config, base `/knowledge-base`, used by `astro build`/`astro dev`. There is no second, non-Astro build path; a `vite.config.js` claiming to be one was dead code that imported a `plugins/` directory the repo does not have (#47). + +`src/utils/config.js` holds the two build-wide constants both the config and the pages read: `PATH_PREFIX`/`BASE_PATH` and `isHeadlessBuild()`. ## Architecture @@ -153,6 +154,7 @@ in the committed `apps.json` without breaking CI, which only has this repo. ## Environment Variables - `GITHUB_TOKEN` — GitHub API auth for fetching Release artifacts -- `MP_HEADLESS` — `true`/`false`, controls headless mode -- `MP_PREFIX` — URL prefix (default: `knowledge-base`) +- `MP_HEADLESS` — `true` produces web-fragment output; **anything else, including unset, means standalone**. `scripts/build-vite.js` always exports an explicit value, so the default only applies when `astro build`/`astro dev` runs directly. Read it through `isHeadlessBuild()` in `src/utils/config.js`, never inline — the two inline copies used to disagree about the default (#52). A per-app `"headless"` in `apps.json` overrides it in either direction. - `AWS_REGION`, `ECR_REPOSITORY`, `ECS_CLUSTER`, `ECS_SERVICE` — deployment config + +The URL prefix is deliberately **not** an environment variable. `MP_PREFIX` and `--path-prefix=` were parsed and then ignored by the Astro build (#46); the prefix is now the `PATH_PREFIX` constant in `src/utils/config.js`, and `nginx.conf`, `tests/fragment-server.mjs` and the gateway's route patterns bake in the same string. Changing it means changing all of them together. diff --git a/README.md b/README.md index f7af070..ede3dd3 100644 --- a/README.md +++ b/README.md @@ -68,10 +68,15 @@ npm test | `npm run build:local:headless` | Local + headless | `--headless` (or `MP_HEADLESS=true`) produces fragment-ready output: no chrome -bar, `data-mp-headless="true"` on ``, shadow-DOM compat styles. +bar, `data-mp-headless="true"` on ``, shadow-DOM compat styles. Anything +else — including an unset `MP_HEADLESS` — means standalone. An individual app can +pin either mode with `"headless": true|false` in its `apps.json` entry. -Orchestrator: `scripts/build-vite.js` (flags: `--local`, `--headless`, -`--path-prefix=`). +Orchestrator: `scripts/build-vite.js` (flags: `--local`, `--headless`). + +The URL prefix (`/knowledge-base`) is not configurable: it is the `PATH_PREFIX` +constant in `src/utils/config.js`, and `nginx.conf` and the fragment gateway's +route patterns hard-code the same string. --- diff --git a/apps.json b/apps.json index 62ae9dc..e63f35c 100644 --- a/apps.json +++ b/apps.json @@ -31,7 +31,8 @@ ], "type": "iframe", "url": "https://example.com/docs", - "temporary": true + "temporary": true, + "headless": false }, { "type": "single-page", diff --git a/astro.config.mjs b/astro.config.mjs index 1525eb1..0035b61 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -1,10 +1,9 @@ import { defineConfig } from 'astro/config'; import tailwindcss from '@tailwindcss/vite'; - -const PREFIX = 'knowledge-base'; +import { BASE_PATH, PATH_PREFIX } from './src/utils/config.js'; export default defineConfig({ - base: '/knowledge-base', + base: BASE_PATH, output: 'static', vite: { @@ -16,8 +15,8 @@ export default defineConfig({ name: 'wf-fragment-alias', configurePreviewServer(server) { server.middlewares.use((req, _res, next) => { - if (req.url?.startsWith(`/__wf/${PREFIX}`)) { - req.url = req.url.replace(`/__wf/${PREFIX}`, `/${PREFIX}`); + if (req.url?.startsWith(`/__wf/${PATH_PREFIX}`)) { + req.url = req.url.replace(`/__wf/${PATH_PREFIX}`, BASE_PATH); } next(); }); diff --git a/scripts/build-vite.js b/scripts/build-vite.js index 5bc0e89..2932193 100644 --- a/scripts/build-vite.js +++ b/scripts/build-vite.js @@ -23,6 +23,7 @@ import { copyDir, stageArtifact } from './artifacts.js'; import { fetchApps } from './fetch-apps.js'; import { HOIST_DIR, hoistAppInlineScripts } from './hoist-inline-scripts.js'; import { collectHtmlFiles } from '../src/utils/apps.js'; +import { PATH_PREFIX } from '../src/utils/config.js'; import { BUNDLE_MANIFEST, bundleDirName, bundleKey, expandBundle, findBundleRoot, isSinglePage, readBundleManifest, resolveRegistry, toRegistryEntry, writeExpansionMap, @@ -32,10 +33,8 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = join(__dirname, '..'); const APPS_DIR = join(ROOT, 'apps'); -const LOCAL_MODE = process.argv.includes('--local'); -const HEADLESS = process.argv.includes('--headless') || process.env.MP_HEADLESS === 'true'; -const prefixArg = process.argv.find(a => a.startsWith('--path-prefix=')); -const PATH_PREFIX = prefixArg ? prefixArg.split('=').slice(1).join('=') : 'knowledge-base'; +const LOCAL_MODE = process.argv.includes('--local'); +const HEADLESS = process.argv.includes('--headless') || process.env.MP_HEADLESS === 'true'; const log = (msg) => console.log('\x1b[36m→\x1b[0m ' + msg); const ok = (msg) => console.log('\x1b[32m✓\x1b[0m ' + msg); @@ -309,11 +308,9 @@ async function build() { // 3. Run Astro build step('3/4 Running astro build'); - const env = { - ...process.env, - MP_PREFIX: PATH_PREFIX, - MP_HEADLESS: HEADLESS ? 'true' : 'false', - }; + // Always explicit: src/utils/config.js treats an unset MP_HEADLESS as + // standalone, and a build that says "--headless" must not depend on that. + const env = { ...process.env, MP_HEADLESS: HEADLESS ? 'true' : 'false' }; execSync('npx astro build', { cwd: ROOT, stdio: 'inherit', env }); ok('Astro build complete'); diff --git a/scripts/setup-test-apps.mjs b/scripts/setup-test-apps.mjs index 7ab008f..8b91580 100644 --- a/scripts/setup-test-apps.mjs +++ b/scripts/setup-test-apps.mjs @@ -221,6 +221,12 @@ const apps = [ type: 'iframe', url: 'https://example.com/docs', temporary: true, + // Pinned standalone so the suite covers the per-app override in the + // direction that used to be impossible: the harness builds headless, and + // this app must still come out without data-mp-headless (#52). This entry + // is the one with no headless assertions of its own, so it can carry the + // pin without weakening another test. + headless: false, }, { // single-page onboarding mode (issue #35): one bundle, no per-doc metadata diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index 00a97b9..1cb12e6 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -11,9 +11,11 @@ import '../styles/marketplace.css'; import { ClientRouter } from 'astro:transitions'; import { shadowCompatStyle } from '../templates/shadow-compat.js'; +import { isHeadlessBuild } from '../utils/config.js'; interface Props { title?: string; + /** Per-app override. Undefined means "whatever this build is" — see config.js. */ headless?: boolean; /** Extra classes for — packaged sub-app pages pass their own body class. */ bodyClass?: string; @@ -23,13 +25,15 @@ interface Props { const { title = 'Knowledge base', - headless = false, + headless, bodyClass = '', flexBody = true, } = Astro.props; -const HEADLESS = process.env.MP_HEADLESS !== 'false'; -const effectiveHeadless = headless || HEADLESS; +// `??`, not `||`: an app pinned "headless": false in apps.json must be able to +// opt out of a headless build. With `||` the prop could only ever turn headless +// on, so the override was write-only in one direction (#52). +const effectiveHeadless = headless ?? isHeadlessBuild(); const bodyClasses = [ flexBody ? 'min-h-screen flex flex-col' : '', diff --git a/src/pages/[...path].astro b/src/pages/[...path].astro index f17b872..408eebb 100644 --- a/src/pages/[...path].astro +++ b/src/pages/[...path].astro @@ -13,15 +13,13 @@ import { readFileSync } from 'node:fs'; import { getAppPages } from '../utils/apps.js'; +import { BASE_PATH, PATH_PREFIX, isHeadlessBuild } from '../utils/config.js'; import { transformSubAppHtml } from '../utils/transform.js'; import Base from '../layouts/Base.astro'; import Masthead from '../components/Masthead.astro'; -const PREFIX = 'knowledge-base'; - export function getStaticPaths() { - const headless = process.env.MP_HEADLESS !== 'false'; - return getAppPages(process.cwd(), headless).map(({ routePath, ...props }) => ({ + return getAppPages(process.cwd(), isHeadlessBuild()).map(({ routePath, ...props }) => ({ params: { path: routePath }, props, })); @@ -32,7 +30,7 @@ const { slug, appHeadless, apps, title } = props; const parts = props.iframe ? null - : transformSubAppHtml(readFileSync(props.file, 'utf-8'), slug, props.fileRelDir, PREFIX); + : transformSubAppHtml(readFileSync(props.file, 'utf-8'), slug, props.fileRelDir, PATH_PREFIX); const app = apps.find((a: any) => a.slug === slug); const pageTitle = title ?? parts?.title ?? app?.name ?? 'Knowledge base'; @@ -49,7 +47,7 @@ const atAppRoot = !props.fileRelDir; > {parts && } - + {props.iframe ? (
diff --git a/src/pages/index.astro b/src/pages/index.astro index 1e3e08f..781eaa6 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -4,8 +4,7 @@ import Base from '../layouts/Base.astro'; import Masthead from '../components/Masthead.astro'; import AppCard from '../components/AppCard.astro'; import { loadRegistry } from '../utils/apps.js'; - -const HEADLESS = process.env.MP_HEADLESS !== 'false'; +import { BASE_PATH } from '../utils/config.js'; // The effective registry: apps.json, with every single-page bundle replaced by // the docs it expanded into, so each one gets its own catalog card. @@ -14,8 +13,8 @@ const apps = loadRegistry(process.cwd()); const buildDate = new Date().toISOString().slice(0, 10); --- - - + +
@@ -27,7 +26,7 @@ const buildDate = new Date().toISOString().slice(0, 10);
{apps.map((app: any, i: number) => ( - + ))}
diff --git a/src/utils/config.js b/src/utils/config.js new file mode 100644 index 0000000..a2369ba --- /dev/null +++ b/src/utils/config.js @@ -0,0 +1,42 @@ +// src/utils/config.js +// +// The two build-wide decisions — where the site is mounted, and whether this +// build is a web fragment — in one place, because both used to be spelled out +// independently in three or four files and the copies disagreed. + +/** + * The URL prefix everything is served under. + * + * **Not configurable, deliberately.** The build used to advertise a + * `--path-prefix=` flag and an `MP_PREFIX` variable that nothing in the Astro + * build ever read, so `--path-prefix=docs` produced a `dist/` that still said + * `knowledge-base` everywhere and only the closing build summary agreed with the + * flag (#46). Making it real means templating `nginx.conf`, the `/__wf/` rewrite + * and the fragment gateway's route patterns too — all of which bake this string + * in — so the honest fix was to delete the flag and keep one constant. + * + * Changing the prefix is a deployment change, not a build option: this constant, + * `nginx.conf`, `tests/fragment-server.mjs` and the gateway config must move + * together. + */ +export const PATH_PREFIX = 'knowledge-base'; + +/** The same thing as an absolute path — Astro's `base`, and every link root. */ +export const BASE_PATH = `/${PATH_PREFIX}`; + +/** + * Whether this build produces web-fragment (headless) output. + * + * **Unset means standalone.** `scripts/build-vite.js` always exports an explicit + * `'true'`/`'false'`, so the default only applies when `astro build`/`astro dev` + * is run directly — and there, "I did not ask for a fragment" is the answer that + * matches the flag's name. Base.astro used to read `!== 'false'` (unset ⇒ + * headless) while the orchestrator computed the opposite; nothing caught it + * because the orchestrator never leaves the variable unset (#52). + * + * A per-app `"headless"` in apps.json overrides this in either direction; the + * override is resolved in src/utils/apps.js and passed down as a prop. + */ +export function isHeadlessBuild(env = process.env) { + return env.MP_HEADLESS === 'true'; +} diff --git a/tests/build-integrity.spec.js b/tests/build-integrity.spec.js index 480359c..d9aae88 100644 --- a/tests/build-integrity.spec.js +++ b/tests/build-integrity.spec.js @@ -123,6 +123,15 @@ test.describe('iframe onboarding', () => { expect(existsSync(join(DIST, 'external-docs/docs')), 'unexpected packaged pages for iframe entry').toBe(false); }); + test('a per-app "headless": false wins over a headless build', () => { + // The harness builds with --headless, and external-docs is pinned standalone + // in apps.json. Base.astro used to OR the prop with the global flag, so the + // override could only ever turn headless on (#52). + expect(read('external-docs/index.html')).not.toContain('data-mp-headless'); + // …while its neighbours in the same build are still headless. + expect(read('user-guide/index.html')).toContain('data-mp-headless="true"'); + }); + test('landing shows the iframe app card with an External badge', () => { const html = read('index.html'); expect(html).toContain('External Docs'); diff --git a/vite.config.js b/vite.config.js deleted file mode 100644 index 816ce6b..0000000 --- a/vite.config.js +++ /dev/null @@ -1,62 +0,0 @@ -/** - * vite.config.js — knowledge-base Vite configuration - * - * Architecture: - * - index.html is the Vite entry (marketplace landing page template) - * - src/style.css is the TailwindCSS entry (processed by @tailwindcss/vite) - * - The marketplace Vite plugin handles sub-app HTML processing: - * • Dev: intercepts /knowledge-base/{slug}/ requests, transforms HTML on-the-fly - * • Build: post-processes apps/**\/*.html → dist/{slug}/ after Vite finishes - */ - -import { defineConfig } from 'vite'; -import tailwindcss from '@tailwindcss/vite'; -import { marketplacePlugin } from './plugins/marketplace.js'; - -const PREFIX = process.env.MP_PREFIX || 'knowledge-base'; -const HEADLESS = process.env.MP_HEADLESS !== 'false'; // headless by default - -export default defineConfig({ - // Never let Vite inject a — the plugin handles all URL prefixing explicitly. - base: '/', - - plugins: [ - tailwindcss(), - marketplacePlugin({ prefix: PREFIX, headless: HEADLESS, appsDir: 'apps' }), - ], - - css: { - // Disable CSS modules — we use plain CSS - modules: false, - }, - - build: { - outDir: 'dist', - emptyOutDir: true, - rollupOptions: { - input: { - // Only the marketplace landing page is a Vite entry. - // Sub-app HTML files are processed by the marketplace plugin. - index: 'index.html', - }, - output: { - // Emit style.css at the root (not assets/style-[hash].css) - // so sub-apps can reference /{PREFIX}/style.css reliably. - assetFileNames: (info) => - info.name?.endsWith('.css') ? '[name][extname]' : 'assets/[name]-[hash][extname]', - entryFileNames: 'assets/[name]-[hash].js', - }, - }, - }, - - server: { - port: 3000, - strictPort: true, - fs: { strict: false }, - }, - - preview: { - port: 3000, - strictPort: true, - }, -}); From 7be7d4240d9b46238ec5b14c5c8899a28ed09130 Mon Sep 17 00:00:00 2001 From: Oto Macenauer Date: Fri, 14 Aug 2026 16:39:26 +0200 Subject: [PATCH 2/2] docs: correct CLAUDE.md, README.md and AGENTS.md against the tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An accuracy pass, not a rewrite. What was wrong: - CLAUDE.md listed src/components/Chrome.astro and src/templates/chrome.js as key files. Neither exists; the chrome bar and its theme script were removed when the masthead became the whole navigation. It also credited Base.astro with a "theme script" in a light-only marketplace. - README's summary said standalone mode injects a top chrome with an app switcher. There is no chrome bar and no app switcher in either mode. - CLAUDE.md said the test harness registers ../knowledge-base-docs-example. It registers the vendored tests/fixtures/docs-example.dist.tar.gz — the sibling repo is the optional single-page example, a different entry. - Headless mode was described as injecting the shadow-DOM compat styles. Base.astro emits those unconditionally; the attribute is the only difference between the two modes. - The suite list covered three of eight spec files and neither of the two extra Playwright configs; README's test table omitted the container layer entirely and said "both layers run in CI". - STYLE_GUIDE was described as covering dark mode. - AGENTS.md pointed at scripts/build-vite.js for stageArtifact, which lives in scripts/artifacts.js. - build-vite.js printed "1/3" for the first of four steps, and its header comment omitted the hoist step and named the wrong asset destination. Also drops the archaeology the previous commit left behind: the deleted vite.config.js and the removed MP_PREFIX are simply absent now rather than described as things that used to be wrong. Adds what was missing: the per-app headless override, the CSS url() rewrite in copyAssets, the KB_* test variables, the CRLF checkout caveat, and the container suite's reuseExistingServer trap. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QqFK6yffibtCBTF8xZ4hXW --- AGENTS.md | 25 +++++++++++++--- CLAUDE.md | 66 ++++++++++++++++++++++++++++--------------- README.md | 30 ++++++++++++-------- scripts/build-vite.js | 14 +++++---- 4 files changed, 91 insertions(+), 44 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7dc2264..110739b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,6 +103,11 @@ is a second implementation of the same contract and it has drifted twice with this suite.** CI runs it inside the `image` job, which already builds the image. +Locally it sets `reuseExistingServer`, so a container left running from an +earlier run answers on `:8099` and the suite passes against the **old** `dist/`. +Before trusting a green run: `docker ps --filter "publish=8099"`, remove +anything there, rebuild, then run it. + `npm audit --omit=dev --audit-level=high` must also stay clean; it gates CI. ## Repository conventions @@ -123,8 +128,11 @@ strong reason. ### Light only The marketplace has no dark mode: no theme toggle, no persisted theme, no `dark` -class, no dark palette. `src/utils/transform.js` actively strips a sub-app's -theme bootstrap and `dark` body class. Do not reintroduce any of it. +class, no dark palette. A sub-app's own theme bootstrap is removed twice: +`scripts/hoist-inline-scripts.js` deletes it while it is still inline, and +`src/utils/transform.js` strips any that survives, along with a `dark` body +class. Do not reintroduce any of it, and keep both halves — hoisting a bootstrap +instead of deleting it puts it beyond the reach of the transform. ### Sub-app HTML is untrusted input @@ -141,6 +149,9 @@ inline script found in a sub-app artifact into one. A change that introduces an inline script fails `tests/build-integrity.spec.js` before it can start breaking pages silently in production. +The check covers `