Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,23 @@ class, no dark palette. A sub-app's own theme bootstrap is removed twice:
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.

### The knowledge base's CSS is inlined; a sub-app's is layered

Inside a web fragment the `<head>` belongs to reframed, which has lost, moved
and duplicated head `<link>`/`<style>` nodes across ClientRouter swaps
(web-fragments #297). So the knowledge base stylesheet is never linked from
`<head>`: `src/layouts/Base.astro` inlines it into every `<body>`, where it is
replaced together with the page. And every sub-app stylesheet — CSS files in
`scripts/build-vite.js`, inline `<style>` blocks in `src/utils/transform.js` —
is wrapped in the `kb-app` cascade layer, below the knowledge base's own rules,
with the masthead and the catalog (`.kb-shell`) fenced by `all: revert` in the
`kb-reset` layer. A sub-app sheet that outlives its page can then not restyle
them. `src/utils/css-layers.js` owns the order and the wrapper; keep all three
emitters of the order statement (layout head, rewritten sub-app CSS,
`knowledge-base.css`) in agreement, and do not reintroduce a `<link>` to the
knowledge base CSS — `tests/build-integrity.spec.js` and
`tests/css-isolation.spec.js` fail if you do.

### Sub-app HTML is untrusted input

Artifacts come from other repositories' releases. Treat their HTML, CSS and
Expand All @@ -163,8 +180,9 @@ pages silently in production.
The check covers `<script>` elements. Inline `on*` handlers are a known gap
(#67): they are equally blocked by the policy but nothing strips or reports them.

Inline `<style>` is still allowed (`style-src` keeps `'unsafe-inline'`); tightening
that is a separate piece of work.
Inline `<style>` is allowed (`style-src` keeps `'unsafe-inline'`) and now relied
on: the knowledge base stylesheet itself is an inline block in every body (see
above). Tightening `style-src` would mean hashes or nonces for it, not dropping it.

### Portability

Expand Down
50 changes: 36 additions & 14 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ Orchestrator: `scripts/build-vite.js`. Flags: `--local`, `--headless`.

## Build Config

**astro.config.mjs** is the only build config — base `/knowledge-base`, used by `astro build`/`astro dev`. There is no non-Astro build path. It sets `vite.build.assetsInlineLimit: 0`: Astro would otherwise inline a small component `<script>` into the page, and the deployment serves `script-src 'self'`.
**astro.config.mjs** is the only build config — base `/knowledge-base`, used by `astro build`/`astro dev`. There is no non-Astro build path. It sets `vite.build.assetsInlineLimit: 0`: Astro would otherwise inline a small component `<script>` into the page, and the deployment serves `script-src 'self'`. It also sets `build.inlineStylesheets: 'always'`: no page links a stylesheet of its own from `<head>` (see *CSS isolation* below).

`src/utils/config.js` holds what both the config and the pages need: `PATH_PREFIX`/`BASE_PATH` and `isHeadlessBuild()`. Import them; do not re-spell either one inline.

Expand All @@ -72,11 +72,12 @@ Orchestrator: `scripts/build-vite.js`. Flags: `--local`, `--headless`.
- `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, document splitting (head/body/title/body-class), headless transforms
- `src/layouts/Base.astro` — The one document shell: head, knowledge base CSS (which carries the self-hosted Inter faces), `<ClientRouter />`, shadow-DOM compat styles
- `src/layouts/Base.astro` — The one document shell: head (opening with the cascade layer order), `<ClientRouter />`, and a body that opens with the knowledge base CSS inlined (`?inline` import; carries the self-hosted Inter faces) followed by the shadow-DOM compat styles
- `src/utils/css-layers.js` — The cascade-layer contract: `LAYER_ORDER` and `layerSubAppCss()`, which wraps a sub-app stylesheet in the `kb-app` layer. Used by the layout, the build and `transform.js`
- `src/components/Masthead.astro` — Persistent Knowledge base header + Library/current-app sub-nav (all pages, both modes)
- `src/components/AppCard.astro`, `src/components/AppIcon.astro` — Catalog card and its icon
- `src/templates/shadow-compat.js` — Shadow-DOM design-token styles, injected into the body by the layout
- `src/scripts/embedded-transitions.js` — Loaded by the layout; inert standalone. Inside a web fragment it runs Astro's view transition on the host document (the iframe's is never painted) and replaces Astro's swap with one that targets reframed's `wf-html`/`wf-head`/`wf-body`, because the default swap nests a new `wf-html` per navigation and leaks every stylesheet
- `src/scripts/embedded-transitions.js` — Loaded by the layout; inert standalone. Inside a web fragment it runs Astro's view transition on the host document (the iframe's is never painted) and replaces Astro's swap with one that targets reframed's `wf-html`/`wf-head`/`wf-body`, because the default swap nests a new `wf-html` per navigation and leaks every stylesheet. Its head diff never moves a reused node: on a pierced page a `<link>` already moved once by reframed's portal falls out of the applied stylesheets when moved again
- `src/utils/config.js` — `PATH_PREFIX`/`BASE_PATH`, `isHeadlessBuild()` and `REGISTRY_FILE` — the build-wide constants
- `src/utils/registry.js` — Registry validation, manifest reading/validation, expansion map. Shared by the build and by Astro so both resolve the same registry
- `scripts/build-vite.js` — Build orchestrator (4 steps: prepare, hoist, copy assets, astro build)
Expand Down Expand Up @@ -106,6 +107,15 @@ Both modes render the same document: the masthead (`Masthead.astro`) — brandin

Resolution order: a per-app `"headless"` in `apps.json` wins; otherwise `isHeadlessBuild()`.

### CSS Isolation

Inside a web fragment the `<head>` is reframed's, and reframed has lost, relocated and duplicated head `<link>`/`<style>` nodes across ClientRouter swaps (web-fragments #297). The symptom was a catalog rendered under a docs theme after Library → app → Library, and a masthead wearing two themes on the next app. Two defences, both in `src/utils/css-layers.js`'s terms:

- **The knowledge base stylesheet is inlined into every `<body>`**, never linked from `<head>`: it is present exactly when its page is. `scripts/build-vite.js` publishes the same bytes as `dist/style.css` for anything outside the repo that still fetches that URL.
- **Every sub-app stylesheet is wrapped in the `kb-app` cascade layer** — CSS files by `copyAssets()` in the build, inline `<style>` blocks by `transform.js` — and the knowledge base's own regions (`.kb-shell`: the masthead and the catalog) sit behind a fence in the `kb-reset` layer (`all: revert` plus the Preflight defaults they rely on). Layer order is `theme, base, kb-app, kb-reset, components, utilities`: a leaked docs-theme rule cannot beat a knowledge base rule or a Tailwind utility, cannot fill a gap the knowledge base left unstyled, and Tailwind's Preflight stays below the app's own CSS so headings keep their theme sizes. The order statement is emitted by the layout's head, at the top of every rewritten sub-app stylesheet and in `knowledge-base.css`, so it holds whichever the browser parses first.

Known limit: a `!important` declaration in a sub-app stylesheet outranks the fence (importance inverts layer order).

### Light Only

The knowledge base has no dark mode: no theme toggle, no persisted theme, no `dark` class, no dark palette. A sub-app's own theme bootstrap is removed twice over — `hoist-inline-scripts.js` deletes it while it is still inline, and `transformSubAppHtml()` strips any that reaches Astro, along with a `dark` body class — so an embedding host's theme cannot bleed into the fragment.
Expand Down Expand Up @@ -143,15 +153,27 @@ Self-contained Playwright E2E — `npm test` auto-starts everything (no external
2. **:4201 host** — `tests/host/server.mjs`, a minimal Express "wrapping web-fragment
application" (`FragmentGateway` + `getNodeMiddleware`) that proxies/embeds the :3000
fragment on a single origin via `<web-fragment fragment-id="knowledge-base">`.

Tests drive the host origin (`http://localhost:4201`). Suites (`tests/`), all four
commands listed in `AGENTS.md`:
3. **:4202 pierced host** — the same server with `KB_PIERCING=true`: server-side piercing
on, as a production Angular SSR gateway runs. The first page then arrives as SSR markup
that reframed adopts and portals — a different starting tree for the ClientRouter, and
the only place the sub-app CSS ever got lost on navigation (a reused head `<link>` moved a
second time drops out of the applied stylesheets; see `embedded-transitions.js`).

Every embedded test runs twice, as Playwright projects `chromium` (:4201) and
`chromium-pierced` (:4202). Suites (`tests/`), all four commands listed in `AGENTS.md`:
- `build-integrity.spec.js` — `dist/` output: both apps enumerated, absolute URL rewriting,
headless markup and the per-app `"headless"` override, the content-hashed knowledge base
stylesheet plus its stable `dist/style.css` alias, no inline script anywhere, and
single-page bundle expansion (`tests/fixtures/single-page-bundle/` → two apps).
- `transform.spec.js` — unit tests for `transformSubAppHtml()`: the malformed and
hostile documents no fixture app happens to ship.
headless markup and the per-app `"headless"` override, the knowledge base stylesheet
inlined into every body (no page links one from its head) plus its stable `dist/style.css`
alias, the layer order opening every head and every sub-app stylesheet wrapped in `kb-app`,
no inline script anywhere, and single-page bundle expansion
(`tests/fixtures/single-page-bundle/` → two apps).
- `transform.spec.js` — unit tests for `transformSubAppHtml()` and `layerSubAppCss()`: the
malformed and hostile documents no fixture app happens to ship.
- `css-isolation.spec.js` — Library → app → Library → app in both embeddings leaves the
catalog and masthead computed styles identical to first load; a leak injected on purpose
(the fixture stylesheets plus a hostile layered one appended to the fragment head) applies
outside the `.kb-shell` fence and changes nothing inside it; a docs page keeps its heading
sizes (Preflight below the app CSS).
- `web-fragment.spec.js` — shadow-DOM isolation (reframed `wf-html`/`wf-body`; host chrome must
not leak in), routing + smooth no-reload SPA transitions, cross-app navigation, asset
loading (no host-origin 404s), and the documented history limitation (fragment routing is
Expand Down Expand Up @@ -182,9 +204,9 @@ commands listed in `AGENTS.md`:

Two build-pipeline pieces support this: `apps.json` entries may carry a `prebuilt` path
(tarball or unpacked directory) consumed by `scripts/build-vite.js` (`stageEntry`) for hermetic
offline builds; and the build copies the knowledge base stylesheet — identified as the local
stylesheet the landing page loads — to a stable `dist/style.css` alias. Pages themselves
reference the content-hashed bundle Astro injects, so nothing depends on that filename.
offline builds; and the build writes the knowledge base stylesheet — taken from the landing
page's inline `<style data-kb-stylesheet>` block — to a stable `dist/style.css` alias. Pages
themselves carry the CSS inline, so nothing depends on that filename.

An entry may also carry `"optional": true`: the build then skips it with a warning when its
`prebuilt`/`localPath` artifact is missing, instead of failing. That is how the sibling
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ const gateway = new FragmentGateway();
gateway.registerFragment({
fragmentId: 'knowledge-base',
endpoint: 'http://localhost:3000', // the fragment server
piercing: false,
piercing: false, // or true (SSR piercing) — both are tested
routePatterns: [
'/knowledge-base/:_*', // landing + sub-app pages + assets
'/__wf/knowledge-base/:_*', // fragment asset prefix
Expand Down Expand Up @@ -310,6 +310,14 @@ import { initializeWebFragments } from 'web-fragments';
initializeWebFragments();
```

`initializeWebFragments()` must run before anything renders a `<web-fragment>`.
With piercing on, a `<web-fragment>` created earlier finds the server-rendered
`<web-fragment-host>` before that element is defined and fails with
`portalHost is not a function`; the fragment then falls back to client
rendering. (`tests/host/server.mjs` loads its router stand-in with `defer` for
exactly this reason.) `piercing: true` — how an Angular SSR gateway usually
embeds — is supported and is what the `chromium-pierced` test project runs.

Per fragment page this then happens: the ClientRouter pushes `/knowledge-base/…`,
Angular sees the `popstate` and navigates there, matches the same `**` route
config and reuses the component (the default `RouteReuseStrategy`), so the
Expand Down
27 changes: 18 additions & 9 deletions astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ export default defineConfig({
base: BASE_PATH,
output: 'static',

build: {
// No page links a stylesheet from its <head>. The knowledge base stylesheet
// is inlined into every <body> by Base.astro (a `?inline` import), and the
// one sheet Astro still collects on its own — the ClientRouter's
// route-announcer rule — is inlined here rather than emitted as a <link>.
// Inside a web fragment a head <link> is exactly the node reframed may lose
// or duplicate across a ClientRouter swap (web-fragments #297), so the
// deployment keeps none. See src/utils/css-layers.js for the whole picture.
inlineStylesheets: 'always',
},

vite: {
plugins: [
tailwindcss(),
Expand All @@ -31,17 +42,15 @@ export default defineConfig({
// script included. Zero disables the inlining.
assetsInlineLimit: 0,
},
// No assetFileNames override: CSS is content-hashed like every other asset.
// No assetFileNames override: assets are content-hashed like everything else.
//
// This used to force the name "style.css" onto every CSS asset so that
// /{PREFIX}/style.css was a fixed path. Nothing needs a fixed path — the
// <link> on every page is injected by Astro from Base.astro's CSS import, so
// it always carries whatever name the bundle was given. Forcing a constant
// name only made Rollup disambiguate collisions as style.css / style2.css,
// which the build then had to guess between, and it defeated cache-busting
// for the one stylesheet every page loads (#50). scripts/build-vite.js still
// publishes dist/style.css as an alias of this bundle for anything outside
// this repository that refers to it by that path.
// /{PREFIX}/style.css was a fixed path. Nothing needs a fixed path: forcing
// a constant name only made Rollup disambiguate collisions as style.css /
// style2.css, which the build then had to guess between (#50). Today no
// page links a stylesheet at all (see `build.inlineStylesheets` above);
// scripts/build-vite.js publishes dist/style.css from the inline block for
// anything outside this repository that still refers to it by that path.
},
});

19 changes: 19 additions & 0 deletions playwright.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,31 @@ export default defineConfig({
stdout: 'pipe',
stderr: 'pipe',
},
{
// The same host with server-side piercing on — how a production Angular
// SSR gateway embeds the fragment. The first page then arrives as SSR
// markup that reframed adopts and portals, a different starting point
// for the ClientRouter than client rendering; the docs pages losing
// their CSS on the first navigation only ever happened here.
command: 'node tests/host/server.mjs',
env: { HOST_PORT: '4202', KB_PIERCING: 'true' },
port: 4202,
reuseExistingServer: !process.env.CI,
timeout: 30_000,
stdout: 'pipe',
stderr: 'pipe',
},
],

// Every embedded test runs against both hosts.
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'chromium-pierced',
use: { ...devices['Desktop Chrome'], baseURL: 'http://localhost:4202' },
},
],
});
Loading