Skip to content

feat: localise the documentation and clear the layer of duxt's own content - #7

Merged
TitusKirch merged 71 commits into
devfrom
feat/localised-content-sources
Sep 8, 2026
Merged

TitusKirch merged 71 commits into
devfrom
feat/localised-content-sources

Conversation

@TitusKirch

Copy link
Copy Markdown
Member

Summary

Three strands, which grew out of one another.

Translations become collections of their own. A source gains a locale
dimension rather than the content path gaining a locale segment, so a page
missing in one language falls back to the nearest one that has it instead of
404ing. The sidebar is built from the default language's tree and wears the
translated titles, so a partly translated site still lists every page. Recorded
as ADR-0007.

The layer stops shipping duxt's own content. A stranger extending
@kirchdev/duxt inherited a site advertising somebody else's project: the
landing headline, six feature cards, a "Resources" dropdown of duxt's tech
stack, the site name and a v0.0.0 badge. All of it moved to
www/app/app.config.ts; the layer keeps only chrome, and a test now enforces
that. The rule was already written in app/utils/duxt-config.ts — this applies
it to what had been missed.

SEO comes from the Nuxt SEO bundle rather than three modules wired by hand,
which also brings the two that were missing: schema.org and the utils that
derive the og and Twitter tags. Recorded as ADR-0009. The version-aware
canonical stays hand-written, because an old version has to point at the current
one.

Along the way: devtools panels rendered into the docs, a pageIcon a section can
lend to pages that carry none, a palette moved into a cascade layer so a
consumer's override cannot lose a race, and documentation for theming and
branding across all five locale trees.

Type of change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation
  • Internal / chore

Checklist

  • The project's checks pass locally (lint / format / tests, as applicable)
  • Tests added or updated — or not applicable
  • Docs updated (README / inline help / comments) — or not applicable
  • Commits follow Conventional Commits

Important

The full pnpm check has not been run on the final commits. lint,
format, typecheck and all 1660 tests pass; build:app, check:previews,
check:a11y and check:seo were not run, because a nuxt dev server held the
www/.data SQLite and two writers corrupt it. The last two commits — the
cascade-layer fix in particular — are therefore verified by unit test and
reasoning, not by a build. CI will run the full chain.

Note

Merge with a merge commit, not a squash. Squashing collapses the 69
individual feat:/fix: commits into this PR's own title, and release-please
would then cut nothing.

Related issues

None.

A source gains a `locales` list, and so does a ref, resolved the way `status`
already is. A string is a folder inside the source's path; an object moves that
language to its own folder, repository or ref — which is what React and Vue do
outside their tooling, with de.react.dev and the vuejs-translations org.

The default locale stays the tree in `path` itself, without a folder, so adding
the key moves no URL a site already serves. The locale is deliberately NOT part
of the content prefix: i18n puts it in front of the path anyway, so original and
translation live under identical content paths in separate collections — which
leaves every path comparison in the theme untouched.

`expandSources` is exported because content.config.ts and the manifest must
agree entry for entry, and a name computed twice is a name that drifts once.
A translated tree is almost never complete — two pages of forty is the normal
state of every project that has tried this — so a page missing from a language
is answered with the nearest language that has it rather than a 404. Starlight
is the only comparable generator that says this out loud, and it is right: the
gap belongs to the writer, and hiding the page punishes the reader for it.

The chain follows the rule the layer already applies twice, in the locale files
and in resolveDuxtText: the locale, its base language, a sibling region,
vue-i18n's own fallbackLocale, then the untranslated original. Reading
fallbackLocale rather than carrying a second list is what keeps the interface
and the pages agreeing on where they fall back to.

The reader is told which language they are being shown, and the page carries
noindex with a canonical pointing at the language that actually holds it —
otherwise seven locales index the same English page.
A partly translated documentation has two obvious sidebars and both are wrong.
Built from the translation, it hides every page that has none — pages the
fallback serves perfectly well, so the reader is left with no way to them at
all. Built from the original, a German reader looks at an English table of
contents. So the structure comes from the original, which is complete by
definition, and each entry takes the translated title where one exists.

Two things this fixes in the process, both older than translations:

The active collection was kept in a module-level ref so the handler could stay
one stable function. On the server that variable is shared by every request in
flight, and the navigation rendered for one request could be read by another —
a hydration mismatch that took the page down with it. A closure is not the
answer either: Nuxt compares handlers by reference, and several components
asking for one key with a handler each is NUXT_E3004, after which the first
handler wins and the rest are ignored. `useState` is per request AND reachable
from a stable handler, which is what both halves need.

`prefix` could resolve to undefined while the manifest was absent, which matched
no source and emptied the query chain — a 404 on a page that exists.
content.config.ts is loaded by c12 in Content's own pass and has no access to
the Nuxt config, so it resolves the default locale from
sourceOptions.defaultLocale alone. If that disagrees with i18n.defaultLocale the
collections are named for one language while the theme queries another, and the
result is not an error but an empty page.

The value is checked rather than injected: injecting it would fix the manifest
and leave content.config.ts computing the other answer, which is the same bug
one layer deeper. A translation the site does not serve is a warning, not an
error — it builds and is never read.
Both pages said translated content was out of reach, which was true when they
were written and is the opposite of what the layer now does. They describe the
locales key, the folder-per-language layout and the fallback chain, and they
name what a translation costs to build — roughly two seconds and 0.6 MB of
database per language times version times repository, measured linear to at
least 200 collections, because a consumer decides that matrix.

ADR 0007 records what was read before the shape was chosen: four comparable
generators put translations in a folder per language and only one has a
documented fallback, while the largest projects leave their tooling entirely and
run a translation repository of their own.
Four language folders rather than six: docs/pt serves both Portuguese locales
and en-US reads the original through fallbackLocale, which is the rule the
locale files already follow — the language carries the text, the region only its
deviations.

The development site serves them, deliberately incomplete: thirteen of a hundred
and eleven pages, so both halves of the feature are exercised on the site the
layer is built against — the translated page, and the fallback banner on every
page that has none.
Eight guides in four languages. The checklists translate too — a checklist a
reader cannot read is a checklist nobody ticks.
The sources reference gained the locales section it was missing — the feature
shipped without the page that documents its fields.
The MDC components, the components, the composables and the endpoints, plus the credits, the
decision records and the shared partials, in de, es, fr and pt. Carries the sections the English
pages gained since the first batch — `refs` and the landing keys — and closes the German quotation
marks that had stayed straight.
SQLite's default journal mode locks the whole file while a writer holds it, so a reader gets
SQLITE_BUSY at once — `readOnly` does not help, it is the writer that excludes us. Content writes
its parse cache from a `Promise.all` over the collections and the build's own hooks read it; with a
handful of files the write is over before anyone reads, and at six hundred it is not. A busy
timeout turns that race into a wait.
Every panel is now a renderer over data it is handed, with the queries, the file reads and the
route rules left in the half beside it. Five things ride on that seam:

- the filter box sits in a bar of its own between the heading and its table, and says how many
  rows it kept;
- a structured config value unfolds instead of being cut off at 96 characters, so a translated
  label can be read rather than merely counted;
- the version matrix holds one axis fixed at a time — versions of a language, languages of a
  version — and names its columns, which a locale-only site had left as bare asterisks;
- every shipped redirect is listed, with the ones `redirectFrom` generated marked as the layer's
  own and the rest as another module's, from the list the module now records beside them;
- the config panel no longer throws on a key that is a string in the defaults and an object in the
  site, which is every site that translates its headline.
A page can now embed a panel with `::devtools-panel{tab="pages"}`, and what it embeds is the
panel's own renderer run over a fixture site — `pnpm previews` writes one HTML document per tab
into `www/public/devtools/`, and `pnpm check:previews` fails the gate when they no longer match
what the panels draw. The two obvious alternatives both rot: a screenshot goes stale the day a
column is added, and the real route exists in no build, so a published page would frame a 404.

The frame is an iframe, because a panel carries a stylesheet written against no documentation
theme; the reader's theme goes in and the document's height comes back, both best-effort.
One page per tab — the question it answers, the panel itself embedded as it renders, its columns,
and the traps it exists to catch — under Reference, in all five languages. The endpoint page keeps
the route and hands the reader over, rather than summarising ten panels in a sentence.
`pnpm previews` wrote `www/public/devtools/*.html`, while the pages that embed
them — `docs/4.reference/8.devtools/` — ship inside the package. On this repo's
own site that resolved; on any other site rendering those pages it was ten
broken frames.

The generated documents move into the layer's own `public/`, which Nuxt serves
for every layer, and `public` joins the `files` allowlist so they reach the
tarball.
Original and translation live under IDENTICAL content paths in different
collections — the locale sits in front of the URL, not in the content tree — so
`sourceForPath`, which matches on the prefix alone, cannot separate them and
answered with whichever collection sorted first. The devtools path debugger
stripped a `/de/` segment, announced that it had, and then reported `docs`.

`sourcesForRoute` decides prefix and language together, walking the same
`localeChain` the theme walks, and both `useDuxtCollection` and the panel now
call it — so the debugger cannot disagree with the site it debugs. The panel
also prints the fallback chain and the language a candidate carries, neither of
which anything showed before.
`definePartials` read `_partials/**/*.md` with `cwd` at the source's own folder,
so it saw `docs/_partials/` and nothing else. A German page including
`:partial{name="install"}` therefore rendered the English block in the middle of
German prose, with nothing on the page saying it had happened, and the
translated files under `docs/de/_partials/` were read by nothing at all.

Partials now carry the language dimension the pages carry: one collection per
language, named the way the page collections are — `duxt_partials` for the
original, `duxt_partials_de` beside it — and the component walks the same
fallback chain the page around it walked. A site that declares no `locales` gets
the one collection it always got, under the name it always had.
The link checker held one map from path to page, and every language of a source
serves identical content paths — so the map kept whichever language was parsed
last. Anchors are derived from heading TEXT, so a German heading has a German
anchor, and the checker would have reported every correct anchor on a translated
site as broken.

A link is now resolved in the linking page's own collection first and in the
untranslated original second, which is where the site itself falls back when a
language does not carry the page.
The layer knew, per page, which locales carry it and when each file last
changed — `git-meta` already reads that — and said neither out loud. OpenCode is
the cautionary case: seventeen languages, an agent that kept them in sync, the
workflow switched off, and nothing anywhere saying the translations had stopped
moving.

One line per language in every build, plus the files behind the figure. A note
rather than a warning, because an untranslated page is a state of the site and
not a defect in it, and reporting it as a warning would train everyone to read
past the warnings. The staleness half needs `history: true` on the source and is
skipped rather than guessed without it. The devtools Checks panel shows the same
report.
On a document served from the site's own origin, `allow-scripts` together with
`allow-same-origin` hands the frame back everything the sandbox attribute was
meant to withhold. Nothing in the framed page needs it: `postMessage` crosses
origins by design, and the theme and height handshake is all the page does.

What is framed is generated HTML nobody reviews per release, so the sandbox is
worth keeping teeth in.
`tag(value, kind)` interpolated the kind into the class list, so the commonest
call — `tag('default')`, with no kind — produced `class="tag "`. Appended
instead of interpolated.
A `nuxt build` the kernel or a Ctrl-C left behind keeps `contents.sqlite` open,
and every following build then fails with `database is locked` — a message
naming neither the process nor the file. The busy timeout in `content-cache.ts`
covers the layer's own reader; Content's cache writer has no such patience, and
a build cannot ask it for any.

Write-ahead logging lets a reader and one writer coexist, which is the actual
shape of a build. Measured on this repo's site while translating: 237 lock
errors went to zero. The mode lives in the file header rather than in a
connection, so setting it from the earliest module holds for Content too.
…lite lock

Three changes reach the reader: partials now carry a language dimension, the
build prints a coverage report per language, and a link is checked against its
own language's anchors. The deployment guide gains the `database is locked`
note, now that WAL removes the everyday case but not an orphaned process.

ADR 0007 recorded the untranslated partials collection as deliberate. It was,
while nothing translated a partial; the consequence is rewritten to say what
replaced it and why.

Mirrored into de, es, fr and pt.
The row takes the reader to duxt's repository, its tracker and its community,
and its fourth entry linked `/getting-started` on the site the reader was
already on. Absolute, so `useDuxtLink` passes it through instead of prefixing a
locale onto it. The layer's own aside still ships empty.
`duxt.logo` takes a wordmark and its dark-mode twin; unset, the header and
footer keep the icon-and-title pair they had. The layer therefore stays
unbranded by default, which is the only correct behaviour for a theme: a site
extending duxt must show its own name, never this one.

The choice sits in DuxtBrand rather than in the three templates that draw it —
the header twice, the footer once — because a branch repeated three times is a
branch that drifts. The light/dark swap is CSS, not `useColorMode()`: the
composable resolves on the client, so a scripted choice renders the light mark
into the server HTML and flips it after hydration.
One SVG does the whole job — sharp at every size, with its own
`prefers-color-scheme` rule inside so the mark lightens against a dark tab
strip. The PNG exists only because iOS ignores SVG icons.

The favicon is the bare `d` rather than the bracketed mark. At 16 px the
brackets squeeze the bowl shut and the letter turns into a smudge; the full
`[d]` only holds from roughly 32 px up, which is where the apple-touch icon
keeps it.
Set through `duxt.logo` exactly the way a downstream site would set theirs,
which is also what keeps the option honest: the development site takes the same
path a stranger takes, so a change that breaks it breaks here first.
The mark is the package name with its first letter bracketed, because that is
how the package is written where it is used: an array with one entry. It is
typeset in IBM Plex Mono SemiBold and converted to outlines, so no asset depends
on the font being installed anywhere.

The section records the licence position rather than leaving it to be
rediscovered: OFL clause 5 exempts documents created with the font, no font file
is redistributed, and the reserved name appears in none of the assets. It also
records the artwork as an AI-assisted placeholder with no replacement date, the
way glimpse does for its own.
Nothing else can see this rule. `build:app` and `check:a11y` both run over
`www/`, which overrides every leaking key — the one site in this repo that could
render the layer's defaults is the one that never does. So it is checked over
the assets themselves, the way `contrast.test.ts` checks the palette.

The regex halves catch URLs and product names. The inventory is what enforces
the rule: "The framework underneath" and `v0.0.0` name no product and hold no
URL, yet both were leaks, so every shipped string is listed with a line saying
why the layer draws it. Adding one costs that sentence.
The configuration reference documented `title`, `version`, the landing copy and
the six feature cards as defaults, in five languages. Two comments named
strings that no longer exist.

The handoff that carried this work goes with it.
Nuxt serves every layer's `public/`, so all ten devtools preview documents are
served from every downstream domain — titled "duxt — Sources" and indexable.
They are fixtures for one reference page, not pages of anybody's site.

defu concatenates the list across layers, so a consumer's own `disallow` is
added to this rather than replacing it.
`detectBrowserLanguage.fallbackLocale` is nested, and defu merges nested objects
key by key — so a consumer setting `i18n: { defaultLocale: 'de-DE' }` does not
displace the layer's `en-GB` here. Narrow `duxt.locales` to exclude it and the
site stops serving that language while still redirecting root visitors to it.
Nothing throws, and the page is empty: the same silent failure the check beside
this one already covers for `defaultLocale`.
Three of the five routes the accessibility gate checked carried a `/duxt/`
prefix and a `/workflows/v0.7.0` from a configuration `www` no longer has, so
they 404'd — and a 404 renders the error view, which passes. The gate reported
five pages while checking the same error page three times.

Pointed at pages the site serves, it immediately found what it had been unable
to see: `DuxtNavigation` rendered a `<nav aria-label="Documentation">` on every
instance, and the mobile sheet renders a second copy of the tree while nested
folders recurse into the component. Several landmarks shared one name — an axe
`landmark-unique` failure and a screen-reader landmark list with the same entry
repeated. The landmark is now opt-in through `label`, which only the sidebar
sets.

The route list also gains a page with an iframe, so `frame-title` has something
to judge.
`render: duxt` cannot be detected — a docs tree pulled into another repo's site
carries no dependency to read — so without it write-docs assumes the portable
default and writes relative .md links and body H1s into a tree that uses neither.

`locales` names the four translated trees beside the English root, so they are
maintained and swept rather than left to drift.
Three gaps, one theme.

The override guide says how to change the theme; nothing said how it is built —
that components are copied rather than imported (and what that costs), why the
palette departs from shadcn's default, and that dark mode switches on a class,
which is what catches anyone overriding a colour. That is the new concepts page.

Nothing at all covered identity. `title`, `logo` and the favicon are what makes
a site read as its own rather than as the layer's, and `logo` was missing from
the configuration reference despite existing in the type and being used by www.

duxt's own mark moves out of the README into conventions, where the colour
values, the licence reasoning and why the icon's brackets are redrawn have room —
the README keeps the shape of the answer and links to it, so there is one copy.
The row stuck at `top-14`, one pixel short of the header's h-14 plus its own
border-b, so it parked over that border and the two traded places by a pixel
as the browser rounded the scroll offset.

Both bars painted at `bg-background/80` over 4px of blur, which is not enough
over the dark palette: the scrolling text stayed legible straight through them.

Headings cleared 80px, the header alone. An anchor jump therefore landed the
heading behind the section row; 112px clears the whole sticky stack.
The hook's script task listed only js/ts, so every SFC — nearly everything this
repo changes — was staged unchecked. oxlint and oxfmt both read an SFC, so the
extension joins the existing task rather than getting one of its own.
Why the layer owns its components rather than importing them from a library or a
ready-made documentation theme — a choice between real alternatives that binds
every part of the interface, with reasoning that leaves no trace in what shipped.

ADR-0007 was missing from the decision log; it goes in with this one. The log is
the index, not the record, so filling a gap in it is not an edit to an accepted
decision.
Content names a directory node after the directory, so `99.adr/` arrived as
"Adr" while the `index.md` it leads to says "Architecture decisions". The
sidebar grouped eight records under a slug and the breadcrumb read "Adr › …".

It only shows where the two differ, which is why it went unnoticed —
`4.reference/` is "Reference" either way. An abbreviated or hyphenated folder is
where it bites, and that is any consumer's tree, not just this one.

The index is identified by carrying the folder's own path, which nothing else in
the tree can hold. Applied before the translation overlay and again after, so a
locale that translates the index title carries it up too.
`sections` is the consumer's reading order, and a tree holds folders it does not
list — `99.adr/` is the standing example, an appendix by construction. The
fallback handed back the entire tree, so the sidebar on such a page listed every
section and every page under all of them at once, while the page beside it showed
one branch. That reads as the sidebar breaking rather than as a page sitting
outside the reading order, and it gets worse the larger the tree is.

It now narrows to the branch the route is in, longest match first, and only
reaches for the whole tree when the route matches no top-level node at all — a
site with no sections configured, where the whole tree is the branch.
The ADRs sit in their own folder outside the reading order, so nothing led to
them but the landing page. Conventions is where a reader asking why a rule exists
already is.
It cannot sit under Conventions: Content builds the tree from file paths, and
`99.adr/` is a top-level folder by contract — flat, fixed prefix. Nesting it
would mean moving the records, which the append-only log does not allow.

Last in the row, so the reading order still ends at the appendix.
The layer's stylesheet is linked TWICE: Vite resolves its absolute path along
two routes — `/_nuxt/@fs/<abs>` and `/_nuxt/<abs>` — and deduplicates neither,
so a 313 kB copy lands after this file. At equal specificity the layer's neutral
`--primary` therefore won on source order, and which value painted first came
down to which response arrived first: buttons and badges flashed grey before
turning branded, and differently on every reload.

`:root:root` outranks `:root`, so the order stops mattering. The doubling itself
is the layer's to fix; until it is, this is what keeps a consumer's own colour
from being a race.
A page states its icon in frontmatter and most do — the sidebar then shows ten
pages with ten symbols that actually distinguish them. Some pages cannot: an
ADR's frontmatter is fixed at `title`, `description`, `status` and `date`, so a
decision log rendered eight bare rows beside sections that all have a column of
icons.

`pageIcon` on a section stands in for its pages, `duxt.pageIcon` for the whole
tree, and a page's own frontmatter still wins. Unset at every level means no
icon, which stays the right default — one symbol repeated down a whole sidebar
distinguishes nothing.

The precedence is a pure function so it can be tested; a nested section wins over
the one above it, and the resolution is not tangled into the components.
The layer's reference gains the key at both levels; www sets it on the ADR
section, which is the case that produced it.
The gavel heads the section and says what the log is. Repeating it on every row
inside said nothing the heading had not; a record is one written decision, so it
reads as a document.
Three modules were wired by hand — robots, sitemap and og-image — and the two
that would have completed the picture were missing: schema.org, and the utils
that derive og and Twitter tags from a page's title and description.

`@nuxtjs/seo` is an ALIAS, not a wrapper: its own documentation says it contains
no logic of its own. What it buys is the loading ORDER, which mattered here —
sitemap wires itself into Content's collections and says so out loud when it is
loaded second, and it was. It also completes the shared devtools panel, which
every one of these modules feeds and which lists the ones that are missing.

The three settings turned off are the ones that would fight the layer: a
lowercased canonical, an invented fallback title, and a merge with site config
that would overwrite what the duxt module already resolved.
What the modules derive is gone from the pages: the og and Twitter pairs that
nuxt-seo-utils reads off title and description, and the JSON-LD that was written
by hand before nuxt-schema-org kept one graph per document.

What they cannot know is stated instead. `og:locale` is not derived from i18n,
and Open Graph spells it with an underscore; the alternates are the other
locales the site serves. The `WebSite` node says what the site is, once, for
every page under it. A 404 asks not to be indexed — an indexed error page
competes with the page the reader was looking for — while still allowing follow,
because the suggestions on it are real. The landing page declares itself a
website rather than an article, and finally renders an OG card: it is the page
most likely to be shared and was the only one coming back as a bare URL.

The canonical stays ours. The module points it at the page being rendered, which
is right everywhere except here: an old version has to point at the current one,
or a search engine keeps serving v0.7.0 to someone who wanted today's docs.
Every rule here breaks silently. A canonical pointing at the wrong version, a
missing hreflang, an error page that forgot to say noindex, an OG card with no
image — none of them throws, none fails a build, and all of them are only
visible to a crawler weeks later.

So they are asserted over the BUILT HTML rather than over the source: the
canonical in particular is written twice, once by nuxt-seo-utils and once by the
page, and only the rendered output says which one survived unhead's
deduplication.

Joins the check chain, so CI runs it without a workflow change.
The heading is "Navigation and links", so `#navigation` resolved to nothing —
the build's own link check reported it. The sentence reads the same without it;
the section is two screens up.
A link inside a menu is a link nobody opens the menu for, and this one is a page of
the site while the five links beside it lead away from it. As a navbar entry it also
renders server-side, which the dropdown's contents do not.
…race

Vite resolves this stylesheet's absolute path along two routes at once —
`/_nuxt/@fs/<abs>` and `/_nuxt/<abs>` — and deduplicates neither, so a second
313 kB copy of it lands AFTER the consumer's own `css` entry. At equal
specificity the layer's neutral tokens then won on source order, and which value
painted first came down to which response arrived first: a consumer's buttons
and badges flashed neutral before turning branded, differently on every reload.

Unlayered CSS beats layered CSS whatever the source order, so the palette moves
into a cascade layer and the order stops deciding. The layer is ANONYMOUS: a
named one can be reordered by a consumer writing `@layer` itself, which would
hand the race straight back.

Only the tokens go in. Everything further down styles the layer's own
components, where an override by name is meant to need the specificity it takes.

`www` drops the `:root:root` it needed while the race was live — it is the proof
the fix works, not a leftover.
The accessible name read "the {tab} panel of the devtools tab", which is both
redundant and inaccurate: the tab it points at is registered as `duxt`, so the
sentence describes a thing by a name it does not carry. Naming the panel is
enough — that is what the iframe holds.
Comment thread public/devtools/cache.html Fixed
Comment thread public/devtools/checks.html Fixed
Comment thread public/devtools/config.html Fixed
Comment thread public/devtools/i18n.html Fixed
Comment thread public/devtools/pages.html Fixed
Comment thread public/devtools/paths.html Fixed
Comment thread public/devtools/redirects.html Fixed
Comment thread public/devtools/search.html Fixed
Comment thread public/devtools/sources.html Fixed
Comment thread public/devtools/versions.html Fixed
`new DatabaseSync` on a missing path WRITES an empty 4 kB database. Content then
found a file where it expected none, never created its schema, and every query
afterwards died with `no such table: _development_cache`.

Invisible on any machine that has built once, because the file is there by then.
It only bites a fresh checkout — which is to say CI, where it did, and every new
contributor. Skipping costs the first build its WAL and nothing else: that build
has no cache to read anyway, and the second one finds the file and switches it.

The test asserts on the filesystem rather than the return value, because the bug
was never about what this reports — only about what it left behind.
The devtools previews listen for a `postMessage` and act on it without asking
who sent it. Reported ten times by CodeQL, once per generated page — but there
is one source: `server/devtools/shell.ts` writes the script into all of them,
which is why the fix belongs there and not in the suggested diffs. Editing the
generated files would have been undone by the next `pnpm previews`.

Origin AND source, because either alone leaves a hole: the origin still admits a
sibling frame on the same site, and `event.source` alone admits a parent on
another origin. These pages are served from `public/`, so the page framing one
is always same-origin.

The height message goes back addressed rather than broadcast for the same
reason — `'*'` hands it to whatever ends up framing the page.
@TitusKirch
TitusKirch merged commit ba493c4 into dev Sep 8, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants