Error messages
Spec §14b: "Error messages are documentation: build failures name the page, field, or budget concerned and link to the relevant doc."
These are the rules the errors already in this repo follow. Follow them in new code. Every message below is one this codebase produces, with the interpolated names filled in.
The citations are checked on every run by
packages/core/src/source-citations.test.ts, here and in every other document
and source comment in the repo (#426). Counting a citation as a prose pairing of
a name with a repo source path — the forms x (path), x in path, and
x at/from path, outside fenced blocks — there are 112 below, 99 of them
distinct, naming functions and the types, classes and constants beside them, and
each names a file that declares or re-exports it. The rule is written down
because two readers applying different ones get different totals: the 2026-09-09
sweep (#344) read a, and b (path) as two citations and counted 74, where
the test reads one. Both totals are asserted by that test, so they are
reproducible rather than remembered — which is what a sweep by hand was not:
#344 corrected 4 citations here and 3 in code comments, and this test's first
run found 5 more it had not reached, none of them in this document.
The messages themselves are checked on every run by
packages/core/src/catalogued-messages.test.ts (#444), whose unit is a message
rather than a name. A message is held against the one string or template
literal in shipping source that produces it end to end, with the spans that
literal interpolates left as holes it cannot read. Of the 273 messages fenced
below, 217 are checked that way and 56 are not, and that test lists the 56
one by one with the reason each is out: 54 because the producer assembles the
message from more than one literal, 1 because the fence quotes an excerpt
rather than a whole message, and 1 because Babel wrote it rather than this
repo. beacon.test.ts is the stronger arrangement over four of the 150 — it
calls beaconFaultReport and asserts this document holds what came back, so
those four are pinned whole rather than around their holes.
What a template interpolates is not checked, and that is most of what is below: the 217 checked messages pin 33768 of the 84193 fenced characters, and the rest is values. An enumerated list a message fills a hole with is a value like any other — the two stale field lists #440 corrected were exactly that, and neither test would have found them.
Two holes in those tests matter to a reader of this catalogue.
examples.test.ts checks its
producer against a copy of the message written inside that file rather than
against this one, so it holds that producer still and says nothing about the
copy below. And a citation naming no path, or naming a property rather than a
binding, is skipped rather than checked.
The catalogue is scoped, and the scope is a boundary rather than a backlog.
Quoted below are @pagedeck/core, @pagedeck/content, @pagedeck/islands, @pagedeck/edge
and its adapters (#19), @pagedeck/search, the docs site's refusal of an unlisted
docs/ entry from @pagedeck/docs (#576), the deploy's refusal of an origin that lost
its manifest from @pagedeck/site (#561), its refusal of an --edge target no adapter
names (#19), its refusals of a file of presigned URLs (#652) and the
signing step's refusals (#665), and the @pagedeck/fixtures and @pagedeck/examples scaffolding the
messages are asserted against. No other package is. The premise above is a soundness claim, that every message here is
one this codebase produces; it was never a completeness one, that every message
this codebase produces is here. The rules bind what is not quoted all the same:
a loader's refusal follows rules 1 to 3 whether or not it appears below, so an
absence here is not evidence of a violation.
A runnable example of one of these failures is
packages/examples/src/a-broken-entry.ts; the message it produces is asserted
in packages/examples/src/examples.test.ts.
1. Name the failing thing first
The first words say which collection, config, fixture or snapshot the message is about. A reader scanning a CI log should not have to reach the stack trace to know where to look.
Collection "pages": loader syncAll failed
Config "/site/pagedeck.config.ts": has no default export — export default defineConfig({ collections })
Fixture "/site/content/en/home.json": is not valid JSON
Snapshot pull from "https://cdn.example/store.db" failed
packages/content/src/collection.ts, packages/core/src/config.ts,
packages/fixtures/src/loader.ts, packages/core/src/snapshot.ts.
2. Go down to the entry and the field
A collection name alone is not a place to look. Schema failures name the
locale, the path and the field, dotted for nesting (hero.title):
Collection "articles": 1 entry does not match the collection schema — fix the content, or relax the schema:
/en/no-title: title — Invalid input: expected string, received undefined
That is the message packages/examples/src/a-broken-entry.ts produces, asserted
character for character in packages/examples/src/examples.test.ts. The text
after the field name comes from the validator, so it is whatever zod, valibot
or arktype said; everything before it is validateEntry and schemaFailure in
packages/content/src/collection.ts. Both paths that write a loader's entries
share them — sync, and the fetchOne write-back in getEntryCached — so one
entry failing a cached fetch reads exactly the way one entry failing a sync
reads.
A failure with no field to name says so ((whole entry)) rather than leaving
the reader to guess the scope.
3. State the fix in the same sentence
The message ends with what to do, not just what happened. Before and after,
from listDueEntries:
- Without:
Collection "posts": no publishField - With:
Collection "posts": declares no publishField and no unpublishField, so no entry has a publication schedule — declare one to query due entries
Others in the same shape:
Collection "posts": no cursor to sync since — run a full sync first
Config "/site/pagedeck.config.ts": declares no collections, so sync would do nothing — list at least one
No store to push at "/site/content.db" — run pagedeck sync first
Config "/site/pagedeck.config.ts": no store to read at "/site/content.db", so pagedeck dev has no pages to serve — run pagedeck sync if it has not been created yet
Dev server: no store to read at "/site/content.db" — run pagedeck sync if it has not been created yet
Snapshot target "https://cdn.example/store.db": scheme "s3:" is not supported — use file: or https: (an S3-style target is an https: presigned URL)
Edge target "fastly" is not supported — use one of: cloudfront-function, netlify, nginx, cloudflare-worker
Routing manifest: version 2 is newer than edge adapter "nginx" reads (1) — upgrade the @pagedeck/adapter-* package you compile with, or build with the @pagedeck/core that wrote it
The edge target names the supported list rather than describing it, because the
list is the deploy's adapters' names interpolated (EDGE_ADAPTERS in
packages/site/src/deploy.bin.ts): prose naming four hosts is prose that
drifts from that list the first time a fifth lands.
The two store lines for pagedeck dev share one fix, written once as
UNREADABLE_STORE_FIX (packages/core/src/dev.ts): the first is the refusal at
startup, the second what a request meets for a store file that will not open.
Rule 8 argues when each one runs.
Where the fix needs a reason, give the reason — the redirect failure in
packages/core/src/snapshot.ts explains that redirects are not followed
because they can move the transfer off https:, then says to point the target
at the final location.
The same file refuses a target on a loopback or link-local host, and the reason is the direction that is dangerous rather than the address (#118): a push PUTs the site's whole content store to whatever the target names, so a URL that came out of an edited CI variable or a lost interpolation ships the store instead of failing to fetch one.
Snapshot target "https://169.254.169.254/site.db": host "169.254.169.254" is a link-local host — no snapshot is served from this runner or its link, and a push to one would PUT the site's whole content store to whatever the target names; point the target at the host the snapshot lives on
One message serves both verbs, because parseTarget is what refuses and both
pagedeck store pull and pagedeck store push go through it. So the reason is written as a
standing fact about a push rather than as a report of what this run did — a
reader who typed pull is told why the check exists, not what their push
supposedly sent. packages/core/src/snapshot.test.ts asserts the sentence above
character for character, so this quote cannot drift out of date in silence.
The host is named because the target is the thing the reader edits and the host
is the part of it that is wrong, and it is named through redactTarget like
every other target-derived string this module prints (rule 6). No override is
offered: a switch that turns the refusal off is a switch the edited variable it
guards against can set too.
The same file refuses a pull whose body has no end it can recognise (#313). A snapshot is the site's whole content store, and a transfer that stops half way through one used to be renamed over the store and surface much later as SQLite corruption, so the refusal says what is missing, what the two acceptable framings are, and — where a store already existed — that it is still there:
Snapshot pull from "https://cdn.example/store.db" failed: the host declared neither a content-length nor a chunked transfer-encoding, so the body ends wherever the connection does and a cut transfer ends it as quietly as a complete one — whatever arrived would be renamed over the store; serve the snapshot with a content-length (a presigned S3 GET does) or chunked
Snapshot pull from "https://cdn.example/store.db" failed: the host declared 4112 bytes and the body carried 1000 — the store at "/site/content.db" is left as it was rather than replaced by a snapshot that is not the one the host described; re-run the pull
Snapshot pull from "https://cdn.example/store.db" failed: the host declared a content-length of "twenty", which is not a count of bytes, so there is nothing to check the snapshot against — serve the snapshot with a content-length that counts its bytes
Snapshot pull from "https://cdn.example/store.db" failed: the host sent the body with content-encoding: gzip, and fetch decodes it before it is written, so the declared length counts other bytes than the ones that would reach the store — serve the snapshot unencoded, or chunked, which needs no length
All four are the one sentence pullFailed writes — "Snapshot pull from X
failed" — with a detail after the colon, because a reader grepping a CI log for
a failed pull should meet the same opening whether it failed at the request, at
the framing, or part way through the body (rule 1).
The first two name the numbers the reader can act on. The count says declared and carried rather than "truncated", because the same check catches a body that is longer than was declared, and a message that has already decided which mistake was made is a message that is wrong half the time. The store's path is named in it for one reason: the reader's next question after a failed pull is whether the store survived, and the answer is worth more than the inference.
The last two are refusals of things that work elsewhere, so each ends with the framing that does work rather than with the rule alone — a host serving snapshots gzipped is not doing anything wrong on the open web, it is doing something this transport cannot check a length against.
A refusal of something that works elsewhere needs the reason most of all,
because without it the message reads as the framework being arbitrary. React 19
lets a component declare a stylesheet with a precedence prop; this framework
refuses one, and hoistedReport (packages/core/src/render.tsx) says what it
is protecting rather than merely that it refuses:
Component "Sheeted": declares a stylesheet with React's precedence prop, and entry /en/home renders it — React hoists "/island.css" to the front of the island's own fragment, inside <body>, where it outranks the <head> the build owns as the single writer of the CSS tiers; import the stylesheet from the component's module so the build places it in a tier, or remove the precedence prop
The <head> this build emits is written entirely by the build, and having one
writer for it is what makes the CSS tiers order deterministically at all. A
build-time render prerenders a fragment, so React hoists the sheet into
<body>, where it beats a <head> tier sheet at equal specificity — silently,
in every direction
docs/research/2026-08-29-react-19-stylesheets-under-island-roots.md measured.
So the refusal is stated as that ownership and not as a rule about one React
feature, which is also what makes it read correctly the next time React grows a
resource the build has to place.
A sheet React hoisted outside every island marker has no component to name — a static component's sheet goes to the front of the page's fragment, and nothing in the bytes says which component wrote it — so that one names the entry instead, and is refused all the same:
Entry /en/home: declares a stylesheet with React's precedence prop — React hoists "/page.css" to the front of the page's fragment, inside <body>, where it outranks the <head> the build owns as the single writer of the CSS tiers; import the stylesheet from the component's module so the build places it in a tier, or remove the precedence prop
Rule 5 collects them, in the document order of the emitted bytes, and a line that has no component to name says so rather than leaving a blank:
Entry /en/home: 2 stylesheets are declared with React's precedence prop — React hoists each to the front of the fragment it was rendered in, inside <body>, where it outranks the <head> the build owns as the single writer of the CSS tiers; import each stylesheet from its component's module so the build places it in a tier, or remove the precedence prop:
the entry's own tree — "/page.css"
"Sheeted" — "/island.css"
A component rendering a plain <link rel="stylesheet"> with no precedence is
not a React resource, keeps its place, and still builds. The rule is written
against the data-precedence attribute React stamps rather than against
<link>, which is what gets that right without a second case.
Where the framework does the thing instead of refusing it, the message is
about the one case it still cannot do. A <title> or a <meta> a component
renders is absorbed into the <head> and nothing is reported (#239,
CONTEXT.md). What has no answer is two of them claiming one head singleton
with two values, and absorbedHeadConflicts (packages/core/src/head.ts) says
what the build did with the rest before it says what it could not do with these:
Entry /en/home: 2 claims on <title> disagree — a document holds one <title>, and the build absorbs into the <head> what a render hoisted rather than picking a winner; make the claims agree, or leave one:
"Alpha" — "from Alpha"
"Beta" — "from Beta"
Both claimants and both values (rule 2), because only the reader knows which of them is the mistake. A claimant with no component to name is the site's own declaration or the build's own head, and each says which it is:
Entry /en/home: 2 claims on <meta charset> disagree — a document holds one <meta charset>, and the build absorbs into the <head> what a render hoisted rather than picking a winner; make the claims agree, or leave one:
the build's own <head> — "utf-8"
"Alpha" — "utf-16"
Rule 5 collects them: one paragraph per singleton the page disagreed over, in the order the claims were made. A value that is a URL rather than content is redacted first, by rule 6:
Entry /en/home: 2 claims on <meta property="og:image"> disagree — a document holds one <meta property="og:image">, and the build absorbs into the <head> what a render hoisted rather than picking a winner; make the claims agree, or leave one:
build.head — "https://cdn.example/a.png"
"Alpha" — "https://cdn.example/b.png"
The body has a singleton region too, and landmarkReport in
packages/core/src/build.ts refuses a second claim on it the way the head's
refusal does (#455). documentHtml wraps every page's tree in
<main>, so a component that renders its own nests inside it and axe reports
landmark-main-is-top-level:
Entry /en/: 2 <main> landmarks in one document — the build writes one around the page's whole rendered tree, so a component that renders its own nests inside it and axe reports landmark-main-is-top-level; render <section>, <div> or a fragment in the component instead (CONTEXT.md, "The <main> landmark is the framework's, written once per document")
The entry is named and the component is not, for the reason the page-level
stylesheet message above names an entry: a document records nowhere which
component wrote a tag, and a page composed from CMS blocks has several
candidates. The page is the one name that sends a reader to the right file every
time. The fix offers three elements because the component wanted a wrapper and
only the name it chose is refused, and it cites CONTEXT.md instead of
restating the ownership rule, which is where a site author can read why the
landmark is not theirs to write.
A landmark in a site's chrome is refused by a message of its own, and before
that count runs (#409). BuildSection.chrome renders a header, nav or footer
beside core's landmark rather than inside it, and chromeLandmarkReport in
packages/core/src/build.ts refuses a <main> in either region:
Entry /en/: build.chrome rendered a <main> landmark — the build writes the one <main> around the page tree and places the chrome before and after it, so a landmark in the chrome is a second one and is not taken in place of the build's; render <header>, <nav>, <footer> or a <div> in the chrome instead (CONTEXT.md, "The <main> landmark is the framework's, written once per document")
The count above would refuse the same document as two landmarks and send the
reader to the page's components, which did nothing wrong. This one names the
field instead: the chrome is one callback, so build.chrome is the one place to
look. It also refuses what the count cannot see as a fault, a chrome whose
landmark would be the page's only one if core did not write its own. The fix
offers the elements a chrome is made of, because the chrome wanted one of those
and chose the one core owns.
The same rule reaches the tree at outDir by a second door, and there it is a
ConfigError. An incremental build reuses a page it did not render by reading
the document back off that tree, so those bytes never pass through the composer
above — and a tree written before this refusal existed holds documents it would
have refused. carryDocuments in packages/core/src/build.ts counts them on
the way in, which is what makes "one landmark per document" true of every page
of every build rather than of the pages a run happened to re-render:
Output "/site/dist": 1 reused page cannot be carried from the previous build — an incremental build reads a page it does not render back off this tree rather than composing it again, so a document the previous manifest does not name, or that this tree does not hold as that build wrote it, has no bytes to carry, and one this build would refuse to compose is not carried past that refusal — run pagedeck build to write the whole site again:
"/index.html" — en /'s document, and it holds 2 <main> landmarks — the bytes are the ones the previous build recorded, so that build wrote them before this one refused a second landmark; render <section>, <div> or a fragment where the component renders <main> (CONTEXT.md, "The <main> landmark is the framework's, written once per document")
Rule 5 collects it under the headline the two faults about an intact tree
already share, because a component that renders <main> renders it on every
page that mounts it. The line states that the bytes match the row that recorded
them, which is load-bearing twice over: it rules out the edited-tree fault
reported a line above, and it dates the document to a build that predates the
refusal. Rule 7 is where the two classes are argued.
The same function refuses a second thing about the same body, and there the
fix is usually to load the script on fewer pages. A facade declares where its
placeholder goes by naming an element's id, and mountFacades in
packages/core/src/build.ts refuses a page that renders no such element (#461):
Entry /en/post: 2 facades declare a mount point no element on this page carries — a facade's placeholder is emitted inside the element its mount point names, so this page has nowhere to put one; render the element, or take the script off this page with a pages or pageTypes override set to "off":
"comments" — no element carries id="comments"
"chat" — no element carries id="chat"
The entry is named for landmarkReport's reason and for one more: a facade is
declared once for a whole site, so the script's name says nothing about which
page failed to render its element. Rule 5 collects them because a site that
spelled one id wrong has likely spelled several, and the two lines are one edit
in one config object.
The fix names both ways out, and the second is what the message is for. A
site-wide chat widget whose mount point exists only on post pages is a script
that should have been scoped to post pages — the "Off broadly, on narrowly"
layering in
packages/docs/content/reference/third-party-scripts.md — and the alternative
reading, that every page now owes the site an empty element, is the one a
message naming only the first fix would leave. There is no fallback to the
default placement: a facade emitted somewhere other than where the site put it
is the fault this refusal exists to prevent, one step quieter.
A mount point the site's chrome renders is refused with that reason instead
(#409). The element exists, so "no element on this page carries" it would be
untrue and would send the reader looking for a typo. chromeMountReport in
packages/core/src/build.ts says where the element is and why that place
cannot hold a placeholder, and the two reports join one throw when a page has
both kinds:
Entry /en/: 1 facade declares a mount point only build.chrome renders — a facade's placeholder goes inside the <main> landmark with the page's content, and the chrome is outside it; move the element carrying the id into the page tree:
"comments" — id="comments" is on an element build.chrome renders
The fix is the one move that keeps the placeholder in the landmark. Scoping the script off the page is still available, but it is not what a site that put the element in its footer on purpose wants to hear first.
A fault in the chrome's own nodes names the chrome rather than the entry
(#409). renderPage walks the chrome as the tail of the page tree, so the
component refusals, the island prop reports and a thrown component all read the
chrome's region off the node's place in that tree and say it (nodeSource in
packages/core/src/tree.tsx):
Component "Thrower": threw while rendering build.chrome (before <main>) on entry /en/ — fix the component, or the props build.chrome gives it
Without it the message would say the entry renders a component the entry's content does not hold, and the fix would point at props the entry never gave.
A deploy over an origin that lost its manifest names the build to put back
(#561). deploy.bin.ts reads an origin with no manifest.json as a first
deploy, but only while the origin's deploy history is empty too. Every document
in that history is a build an apply published before it put manifest.json, so
a history with no manifest beside it is an origin that lost one, and a dangling
link there is the same loss. Planned as a first deploy, the prune does not know
which build was live and can delete the files that build serves at once, so the
run is refused before it plans.
The fix names a file, because "the build the origin serves" is a question the
reader would have to go and answer. Every apply writes the build's deploy
instant beside its document, rollbacks included, so the build with the newest
instant is the one the origin served, and the refusal names it and the exact
copy to make. --from that same document is the second way back. When an
instant there cannot be read, it could be the newest, so the refusal names no
build and points at the line the last apply printed, which names that build's
history file. Why the origin is refused rather than planned is left to the
paragraph above, so the message spends its length on the fix:
Deploy: there is no manifest at "/srv/origin/manifest.json", but the origin's deploy history at "/srv/origin/.pagedeck/manifests" holds 2 builds, so the origin is damaged rather than new; build "b2" has the newest deploy instant there, so it is the build the origin last served — copy "/srv/origin/.pagedeck/manifests/b2.json" to "/srv/origin/manifest.json", or pass --from "/srv/origin/.pagedeck/manifests/b2.json"
Deploy: there is no manifest at "/srv/origin/manifest.json", but the origin's deploy history at "/srv/origin/.pagedeck/manifests" holds 2 builds, so the origin is damaged rather than new; the deploy instants there do not say which build the origin serves — copy the history file the last apply's "Filed this build" line names to "/srv/origin/manifest.json", or pass --from that file
The copy is a full restore: a history document is byte-identical to the
manifest.json it was published as. Neither fix needs a new option.
4. Attach the cause, do not flatten it
Wrap with { cause } instead of interpolating the inner message. The outer
message names the collection; the inner one says what actually broke, and the
CLI prints the whole chain (describeError in packages/core/src/exit.ts
joins it with : ).
throw new Error(`Collection "${collection}": loader ${operation} failed`, {
cause,
});Flattening loses the inner error's type and stack for anything that catches it programmatically, and gains nothing in the log.
A report that collects (rule 5) quotes each inner message on its own line
instead, and that is not an exception to this rule. A cause is a claim that
one error caused another, and several independent failures are not that: two
causes cannot both be the cause. So readManifests
(packages/core/src/cli.ts) flattens a pair of manifest failures into one
report rather than carrying either as a cause, and rethrows a lone one whole so
the common case keeps its chain. What this rule forbids is
losing the inner text, and a quoted line does not lose it — it keeps it for
every failure rather than for one. §2's schema failure is the shape: the
validator's text goes on the failing entry's line.
unparsedReport (packages/core/src/island-facts.ts) does the same with the
parser's text, indenting every line of it by the same four spaces so the caret
stays over the column it points at:
Island scan: 2 component modules will not parse, so the scan cannot tell whether a "use client" boundary is declared — fix the syntax error the parser names in each:
"/site/components/Copy.js" —
Parse failed with 1 error:
Expected `}` but found `EOF`
1: export default function Copy() { return "marker-copy-7c02";
1: export default function Copy() { return "marker-copy-7c02";
^
"/site/components/Hero.js" —
Parse failed with 1 error:
Expected `}` but found `EOF`
1: export default function Hero() { return "marker-hero-41bd";
1: export default function Hero() { return "marker-hero-41bd";
^
The source line appears twice because that is what the parser emits; the frame is quoted as it arrived rather than edited into shape.
A site's .tsx or .jsx module that the pagedeck executable compiles itself
(#702) is the other case: one module and one compiler run, so its faults are
wrapped rather than quoted. compileFault (packages/core/src/jsx-loader.ts)
names the module, and the line when there is one fault, and carries the
compiler's text in the cause:
Module "/site/components/Bad.tsx" line 2: does not compile — fix the syntax at the line named
Module "/site/components/Typed.jsx": 2 syntax errors, so it does not compile — fix the syntax at each line named
The run prints the cause after the fix, so a config that imports the first
module fails with Config "/site/pagedeck.config.ts": failed to load: Module "/site/components/Bad.tsx" line 2: does not compile — fix the syntax at the line named: column 16: Unterminated regular expression. The second module's
faults print on that same line, joined by ; , rather than one per line: its
error is a cause of the config's, and describeError prints a line break in a
cause as a replacement character. The cause is an AggregateError holding
rolldown's own errors rather than one of them, because their messages are
coloured, multi-line code frames that would print the same way.
Attach the cause when one failure is being wrapped; quote it per line when many are being reported.
5. Report every failure, not the first
One run should reveal every problem of the same kind that it can see.
validateBuffer collects every failing entry and every issue on each of them
before throwing:
Collection "pages": 2 entries do not match the collection schema — fix the content, or relax the schema:
/en/home: hero.title — Invalid input: expected string, received number
/de/pricing: layout — Invalid input: expected string, received undefined
syncSite in packages/core/src/sync.ts does the same one level up: a failing
collection does not stop the others, and every failure is reported at the end.
The alternative is a build loop that reveals one broken field per run.
listDueEntries in packages/content/src/collection.ts collects the same way
over a collection's publication windows (#283) — an entry whose unpublish
instant is at or before its publish instant is due at no instant at all, so it
emitted no page and said nothing:
Collection "posts": 2 entries have an unpublishField instant at or before their publishField instant — fix the two instants, or declare only one end of the window:
/en/equal: unpublishField "unpublish_at" (2025-01-01T00:00:00Z) is at or before publishField "publish_at" (2025-01-01T00:00:00Z)
/en/transposed: unpublishField "unpublish_at" (2020-01-01T00:00:00Z) is at or before publishField "publish_at" (2030-01-01T00:00:00Z)
The field name repeats on every line rather than sitting once in the headline:
a collection has one publishField for every entry in the report, but the
instant a reader has to check is the entry's, and rule 2 is about that value.
An author who transposed one pair has likely transposed several, which is what
puts this here rather than on the first offender.
layoutContents in packages/core/src/layout.ts reads every entry a
collection renders into a layout (#712) before the first page renders. An entry
without a string title or html, or whose frontmatter.components is not a
list, is a shape fault. A name in frontmatter.components that the registry
does not hold is the other fault, and its line quotes every such name on that
entry. The two have different fixes, so each gets its own paragraph in one
throw:
Collection "pages": 2 entries do not have the shape a layout renders — give each a string title and html, and a list at frontmatter.components if it has one, as the markdown loader writes them, or render the collection through a content callback instead:
/en/broken — html is not a string
/en/listless — frontmatter.components is not a list
Collection "pages": 1 entry names components at frontmatter.components that build.components does not register — name only registered components, which are "counter", "layout":
/en/counter — "./x.js", "@scope/pkg"
The message lists the registered names because its reader is often editing
content, not the config. The build looks a frontmatter name up in the registry
and never resolves it as a module, so it refuses ./x.js above as an unknown
name, the same as a typo. A page that falls back to another locale's entry
renders that entry, and the report names the entry once. Both faults are
content, so they exit 1; rule 7 argues the class.
loadComponents in packages/core/src/tree.tsx collects across a page's
whole tree, so one run names every component a rename left behind:
Entry /en/home: 2 components are not registered — declare each under build.components, or add each to the registry passed to renderPage, or remove the reference from the entry:
Testimonials
Quote
Entry /en/home: 3 components have no default export — export each component as its module's default:
Hero
Heading
Icon
Those are two throws, and they stay two throws because they are two
classes. An unregistered name is the site's wiring — RegistryError, exit 2.
A module missing its default export is a page that will not render —
RenderError, exit 1 (rule 7). One combined report would have to pick one
exit code for both, which means dropping a classification to save a header. A
page with both faults reports the wiring one first: a name the registry does
not hold has no module to have a default export, so its export verdict does not
exist yet, and reporting the render fault first would send a reader to fix
modules while the registry naming them is still wrong.
renderPage is the second place a run splits for that reason.
throwContradictedHydration (packages/core/src/client-reference.tsx) reports
a "use client" module registered hydrate: "none" — the registry's wiring,
RegistryError, exit 2 — and throwProxyFaults reports every other proxy fault
on the same page as a RenderError, exit 1. The order is argued differently
there: a contradicted instance renders nothing, so the render report below it
was collected over a page with that component's subtree missing from it, and the
report that is incomplete is not the one to put first.
Collecting is also what decides where the collection lives. resolveComponent
answers about one name, and it is called one name at a time — by
resolveHydrationMode, and by loadComponents itself — so it keeps the
single-name sentence; loadComponents is what walks a whole tree and holds the
results, so it is the only place that knows what "every failure on this page"
means. Put a collection at the level that can see the whole set, not at the
level that happens to detect the fault.
A config load checks each component declaration's shape with the check
defineComponents runs (componentFaults in
packages/islands/src/registry.ts), so a plain object written without that
helper is refused before any component loads. Since #710 a component has one
declaration, a path or a package specifier. Before #710 a loader,
{ import: () => import("…") }, was a second form, and the check names each
component still declared that way, with or without a path beside it:
Config "/site/pagedeck.config.ts": "build.components" holds 3 components that are not usable — fix each one:
"hero": declares no path — name its module by a path relative to the config file, such as "./components/<module>.tsx", or by a package specifier, such as "<package>/<module>"
"legacy": declares a loader, import, which was removed — name its module by a path relative to the config file, such as "./components/<module>.tsx", or by a package specifier, such as "<package>/<module>"
"lead": declares both path and import, and import was removed — keep path and drop import
build.modules went in the same change. A path names a component's module for
the render and the client build alike, so the map had nothing left to add. A
config that still declares it fails at load, in the same run as the build
section's other faults. The message lists every entry the map holds, each with
the path to declare under build.components:
Config "/site/pagedeck.config.ts": "build.modules" was removed — declare each component once, by the path of its module under "build.components", and delete "build.modules"; it lists 2 components to declare there by these paths:
"counter" — "./components/counter.tsx"
"pricing" — "@acme/design-system/components/pricing"
A map with no entry it can read, empty or not an object, gets the form by example instead:
Config "/site/pagedeck.config.ts": "build.modules" was removed — declare each component once, by the path of its module under "build.components", and delete "build.modules", as components: { counter: "./components/counter.tsx" }
Then deriveComponents (packages/core/src/components.ts) resolves every
declaration, a relative or absolute path as a file against the config's
directory and anything else as a package specifier, through the island scan's
own resolver. It names each one that resolves to nothing, rather than leaving
the first to surface as a Node import error mid-render:
Config "/site/pagedeck.config.ts": 2 components declare a module that does not resolve — point each path at a file, relative to this config file, or install the package each specifier names:
"ghost" — "./components/Ghost.js" resolves to "/site/components/Ghost.js", and no file is there
"phantom" — "@acme/phantom/widget" resolves to no module from "/site/pagedeck.config.ts"
The docs site collects the same way over the repository's docs/ tree (#576).
refuseUnclassified in packages/docs/src/site.ts names every entry directly
under that tree which REPOSITORY_DOCS neither puts on the site nor excludes,
so a new directory and a new file beside it cost one run and not two. pagedeck sync
reports it as the cause of Collection "repository": loader syncAll failed:
Docs site: 2 entries directly under "/repo/docs" are on neither of the docs site's lists, so the site cannot tell whether to show them — add each entry to REPOSITORY_DOCS.published to put it on the site, or to REPOSITORY_DOCS.excluded to keep it off the site:
drafts
notes.md
The boundary is the kind of failure. Content that fails its schema is one of
possibly many, so it is collected. A validator that throws is not — it is a
wiring failure, the rest of the pass would be measured with a broken
instrument, and collection.ts rethrows on the first one
(Collection "…": schema threw validating entry "…", with the thrown error as
cause). Collect the failures a run can meaningfully enumerate; stop on the
ones that invalidate the run itself.
An edge adapter's compile (defineAdapter, packages/edge/src/adapter.ts)
collects across a whole compile and reports once through throwIfAny
(packages/edge/src/faults.ts),
one paragraph per kind of fault so each keeps its own count and its own fix:
Edge target "nginx": 2 values cannot be expressed by this target — remove the character, or compile a target that can express it:
the default tree's redirect from "/price$" — nginx interpolates "$" inside a quoted string, and offers no escape for a literal one
the default tree's header "Content-Security-Policy" under prefix "/en/" — nginx interpolates "$" inside a quoted string, and offers no escape for a literal one
Edge target "cloudflare-worker": 2 redirect targets are not paths on this site — write a tree-relative path like "/pricing"; a target off this site compiles to an open redirect at the edge:
the default tree's redirect target on "/a" — the target holds a scheme
the default tree's redirect target on "/b" — the target begins "//", which is a host
The second is planRouting's own refusal of an off-site target, made again by
the Worker's compiler (#665): a compiler is handed a document, not necessarily
one planRouting wrote. The Vercel, Netlify and Cloudflare Pages compilers make
it again for a source as well as a target, through refuseOffsite
(packages/edge/src/faults.ts), and a source gets a paragraph of its own,
because its fix is planRouting's source fix (#41):
Edge target "netlify": 1 redirect source is not a path on this site — write a tree-relative path like "/pricing"; the edge matches the path alone, so a source spelled as a URL is a rule that can never fire:
the default tree's redirect from "https://evil.example/b/" — the source holds a scheme
Netlify and Cloudflare Pages write a path into a line of _redirects or
_headers, so each refuses a : anywhere in it, not only one that begins a
segment, and any whitespace character, named by code point (#41):
Edge target "netlify": 2 values cannot be expressed by this target — remove the character, or compile a target that can express it:
the default tree's redirect from "/time-12:30/" — Netlify reads ":" in a path pattern as the start of a placeholder, and offers no escape for a literal one
the default tree's header prefix "/p q/" — Netlify reads U+0020, a whitespace character, as the end of a path pattern, and offers no escape for a literal one
Every adapter makes planRouting's header checks again, for the same reason
(#671). defineAdapter runs them before any adapter's own grammar, with
unusableHeaderName and unusableHeaderValue from
packages/core/src/routing.ts, so all six adapters refuse a field alike. Both
fixes are planRouting's own, so each rule has one wording. A value is named
by the code point that broke it and never quoted:
Edge target "cloudflare-worker": 1 header name is not a token — write the name as a header field name, such as "X-Frame-Options"; a name that is not one is emitted verbatim, and each target then either reads that line as a different field than the one written, or refuses it outright after the build has already reported success:
the default tree's header name "X-Frame Options" under prefix "/" — the header name holds " ", and a header name is one RFC 9110 token
Edge target "cloudflare-worker": 1 header value cannot be sent — remove the character; a field value may hold no control character but HTAB (RFC 9110 forbids the C0 ones and DEL, and a C1 one reaches a headers file as two bytes of UTF-8), since a line break can write a second header, and a Worker's Headers refuses any character above U+00FF:
the default tree's header "X-Note" under prefix "/" — the header value holds U+000D
A name that begins # or ! is an RFC 9110 token, so it passes that check,
but a line-based headers file can read it as a comment or a detach. It is
refused in a paragraph of its own, by unwritableHeaderName
(packages/core/src/routing.ts), and the line says which reading (#41):
Edge target "netlify": 1 header name cannot be written to a line-based headers file — drop the leading character from the name:
the default tree's header name "#X-Frame-Options" under prefix "/" — the header name begins "#", which a line-based headers file can read as the start of a comment
A value may hold no control character but HTAB (#41). RFC 9110's field-value
forbids the C0 controls and U+007F. It admits a C1 control, U+0080 to U+009F,
as obs-text, but obs-text is an octet, and a headers file is UTF-8, so a C1
control is written as two bytes, and U+0085 is a Unicode line break. So a C1
control is refused too, and any other character up to U+00FF passes.
planRouting refuses a value by the same rule (#681), so a site's own config
fails at build time and the compiler's check catches a document edited after
planning. A Worker's Headers.set throws on a CR or LF inside the value, on a
NUL anywhere in it, and on a character above U+00FF, which it cannot convert to
a byte. It throws on every request under that prefix; a CR or LF at either end
it trims instead.
A tree is named here by its host key, the host the parser reads out of a
locale's domain (#396), because the routing manifest this compiler reads
carries the key and no declared spelling; planRouting, which has the pages,
quotes each declared spelling beside the key instead.
An unknown target and an unreadable routing version are thrown on the spot instead, on the boundary above: one leaves no compiler to run, the other no document to run it on.
budgetFaultReport (packages/core/src/budgets.ts) has the same shape over a
declared JavaScript budget, and needs it for the same reason: an unparseable
limit, a key that is not a page pattern and two keys no page can choose between
are three different edits, and one headline could carry only one of the three
fixes.
Config "/site/pagedeck.config.ts": "build.budget" declares 1 limit that is not a size — write a number and a unit, one of b, kb or mb, such as "15kb":
"/pricing" — "big"
Config "/site/pagedeck.config.ts": "build.budget" declares 1 key that is not a page pattern — write a path glob starting with "/", optionally prefixed "<locale>:", such as "en:/pricing":
"blog" — the path does not start with "/"
Config "/site/pagedeck.config.ts": "build.budget" holds 1 pair of patterns no page can choose between — make one of the pair more specific, or give both the same limit:
"/a/*" and "/*/b" — equally specific, and both match "/a/b"
A budget that is not an object at all is the one budget fault reported alone. There is nothing to enumerate inside it, so the collection every other paragraph is a collection of does not exist.
islandPropsBudgetFaultReport (packages/core/src/budgets.ts) refuses a
build.islandPropsBudget that is not a size, with the same fix. The field is one
size for the whole site rather than a map, so there is one line under the
headline and no pattern half (#653):
Config "/site/pagedeck.config.ts": "build.islandPropsBudget" is not a size — write a number and a unit, one of b, kb or mb, such as "4kb":
"big"
assertBuildSection (packages/core/src/config.ts) then joins those
paragraphs to its own missing-field report and throws once. Unlike
loadComponents above, this is one throw rather than two: both faults are the
site's wiring, so rule 7 classifies them the same way and there is no exit code
to drop. A build section is written as one object literal, so a field the site
has not declared yet and a budget key it spelled without a leading slash are one
edit session, and revealing them one run at a time is the build loop rule 5
exists to prevent. They still cannot share a headline — a missing field and an
unusable pattern have different fixes — which is why the throw is paragraphs
joined rather than a report merged. The missing-field report names each field
with the shape it takes, and pages takes two:
Config "/site/pagedeck.config.ts": "build" is missing 3 fields pagedeck build needs — declare each in the build section:
"pages" — declare it as a list of page sources, or as a page set from definePages
"components" — declare it as an object
"content" — declare it as a function, or name a layout on every page source
content is required unless every page source names a layout (#712). It
renders exactly the pages whose source names none, so a site can render a
collection into a layout and a paged list through the callback. A layout that
build.components does not register gets a paragraph in the same throw,
naming every such source, whether or not it emits a page yet:
Config "/site/pagedeck.config.ts": "build.pages" names 1 layout that build.components does not register — register each under build.components, or name a registered component, which are "counter", "layout":
sources[0] — layout "Layout"
contentOf in packages/core/src/layout.ts throws this when it meets a page
with neither a layout nor a callback. loadConfig already refuses such a
config, so only a LoadedConfig built some other way reaches it, and it names
the first such page rather than collecting them:
Page /en/notes: names no layout, and the build section declares no content callback to render it — declare build.content, or name a layout on the page's source
Three more paragraphs join the same throw, from the same file and for the same
reason. cssFaultReport collects every build.css entry that is not a
stylesheet path, and every line says why it is not one, because an index and an
empty quote diagnose nothing — least of all the whitespace-only entry, whose
quote is invisible:
Config "/site/pagedeck.config.ts": "build.css" declares 3 entries that are not stylesheet paths — write each as a path to a stylesheet, relative to this config file:
css[1] — not a string
css[2] — "" — the path is empty
css[3] — " " — the path is only whitespace
viteFaultReport collects every build.vite.plugins entry that is not a Vite
plugin. The fix wording is the load-bearing part: the mistake it most often
catches is a plugin factory passed uncalled, which reads correctly and whose
own failure is a TypeError from inside Vite's plugin sort, naming no config
and no field.
Config "/site/pagedeck.config.ts": "build.vite.plugins" declares 1 entry that is not a Vite plugin — pass what a plugin factory returns, not the factory itself — call the factory, as plugins: [somePlugin()]:
plugins[0] — not a plugin object
criticalCssFaultReport (packages/core/src/critical-css.ts) is the third,
and it is budgetFaultReport's shape by construction rather than by
resemblance (#23): both fields are maps keyed by page pattern, so the key half,
the collection and the ambiguous-pair refusal are one implementation in
packages/core/src/page-patterns.ts, and each field supplies only what its own
values mean — that a budget's is a size, that this one's is a flag. A key legal
in one field and refused in the other, or refused with a different sentence, is
the drift that placement prevents.
Config "/site/pagedeck.config.ts": "build.criticalCss" declares 1 value that is not true or false — write true to inline a page's stylesheets into its HTML, or false to leave it linking them:
"/landing" — "yes"
Config "/site/pagedeck.config.ts": "build.criticalCss" holds 1 pair of patterns no page can choose between — make one of the pair more specific, or give both the same flag:
"/a/*" and "/*/b" — equally specific, and both match "/a/b"
The value is quoted with JSON.stringify rather than interpolated, so "yes"
and yes are two different lines: a string that looks like a flag is the
mistake this paragraph is most often read for. The fix names the flag in this
field's own word for rule 3's sake — "the same value" would make a reader
translate the advice back into the field they are looking at, which is why
PatternValueRule.ambiguousFix is supplied per field rather than written once
in the shared module.
foldStrategyFaultReport (packages/core/src/fold.ts) is the fourth, and it
is the one field of the four that is neither a pattern map nor a list — so
nothing in page-patterns.ts applies to it and both of its messages are its
own. It joins the same throw for assertBuildSection's reason: a threshold
somebody typed in quotes and a field the site has not declared yet are one
edit session.
Config "/site/pagedeck.config.ts": "build.foldStrategy" declares a threshold that is not a tree position — write a whole number of nodes, 0 or more, such as { threshold: 8 }:
"8" — not a number
There is exactly one value inside the field, so the collected shape here is not
about counting: it is about where the reason goes. "8", 8.5 and -1 are
three different mistakes — a number in quotes, a pixel measurement written where
a node count belongs, and a negative that would put a whole page below the fold
by arithmetic rather than by intent — and a headline can carry only the fix they
share. The value is quoted with JSON.stringify on criticalCssFaultReport's
argument, and it is load-bearing in a numeric field: an interpolated 8 would
be indistinguishable from the 8 that would have worked. The 8 in both fixes
is deliberately not the default — a fix an author can copy without changing
anything is not a fix (rule 3).
A key the field does not take is a fault of its own, with a fix of its own, so it is a second paragraph in the same throw:
Config "/site/pagedeck.config.ts": "build.foldStrategy" declares 1 field this build does not read — delete the field, or correct it to "threshold", the only field foldStrategy takes:
"treshold"
This is the quietest failure the field has: { treshold: 4 } is an object with
nothing wrong in it that does none of what the site asked for — the build takes
the default and no artifact anywhere records that a threshold was written. It is
the same argument the value check is made on, one level up. The reason is in the
headline rather than on each line, because every key in the list has the same
one.
A css that is not an array and a vite that is not an object are each
reported alone, on the argument the non-object budget above makes: there is no
collection inside them to enumerate. So is a criticalCss that is not an
object, and so is a foldStrategy that is neither a flag nor an object:
Config "/site/pagedeck.config.ts": "build.foldStrategy" must be true, false, or an object with a threshold — write foldStrategy: false to turn fold-driven hydration off, or foldStrategy: { threshold: 8 } to tune it
A head that is not a function is the fifth paragraph, and it is reported
alone for a reason the four above do not have (issue #41). The others are
values — a map, a list, an object — and a value is reported alone when there is
no collection inside it to enumerate. This field is a callback, so there is
nothing inside it to enumerate even in principle: a function is not a container,
and the only thing that can be wrong with one at config load is that it is not a
function. It joins assertBuildSection's throw all the same, on that function's
standing argument — a site wiring up a head and a budget writes one object
literal, and learning about them one run apart is the build loop rule 5 exists
to prevent.
Config "/site/pagedeck.config.ts": "build.head" must be a function returning one page's head fields — head: (page, store) => ({ title: "…" })
The fix carries the signature rather than naming the type, because the mistake
it catches is head: { title: "…" } — a site that wrote the head fields where
the function belongs. That reads perfectly well, type-checks nowhere in a .js
config, and would otherwise surface partway through a build as a call on a
non-function, naming a page and not the field.
A chrome that is not a function is the sixth, and it is reported alone on
the same argument (#409). It is the second optional field that is a callback, and the
mistake it catches is the same one, the regions written where the function
belongs:
Config "/site/pagedeck.config.ts": "build.chrome" must be a function returning one page's chrome — chrome: (page, store) => ({ before: [{ component: "Nav" }], after: [{ component: "Footer" }] })
originFaultReport is the seventh, and it collects (issue #39). The field is one
string, so the collection is not about counting either: a scheme a browser will
not fetch, userinfo, a path, a trailing slash, a query and a fragment are six
different mistakes with six different fixes in the author's head, and the
headline can carry only the one they share. Every line quotes the value, because a URL is
what the author typed and what they will search for.
Config "/site/pagedeck.config.ts": "build.origin" is not a site origin — write the scheme and host the site is served from and nothing else, as origin: "https://example.com":
"https://example.com/shop" — the origin holds the path "/shop", and this build appends each page's own path to it
The quote is redacted, and this is the field rule 6 is written for. An
origin is a URL, and both places a URL hides a credential are cut out of it
before it reaches a log: the query at its delimiter, the way unusableReason
cuts a route, and the userinfo at the @ that ends it. So the two faults that
would otherwise print a token name it instead — one report each, since each is
a different origin:
Config "/site/pagedeck.config.ts": "build.origin" is not a site origin — write the scheme and host the site is served from and nothing else, as origin: "https://example.com":
"https://example.com?…" — the origin holds a query, and an origin is a scheme and a host
Config "/site/pagedeck.config.ts": "build.origin" is not a site origin — write the scheme and host the site is served from and nothing else, as origin: "https://example.com":
"https://…@example.com" — the origin holds userinfo, and an origin is a scheme and a host
The cut is made on the string rather than on a parsed URL, because the value that most needs it is the one that did not parse — and a value that did not parse is reported as that, since every other verdict about it would be measured on a URL nobody has:
Config "/site/pagedeck.config.ts": "build.origin" is not a site origin — write the scheme and host the site is served from and nothing else, as origin: "https://example.com":
"example.com" — not an absolute URL, so it names no scheme and no host
An origin that is not a string is reported alone, on the argument the
non-object budget makes: there is no collection inside it to enumerate.
xDefaultFaultReport is the eighth, and its two paragraphs are the two ways a
locale code can be wrong in a build section. The first is the fault
missingFallbackReport (packages/core/src/locales.ts) reports about a locale
map, mirrored rather than shared: a field pointing at a locale the map does not
hold. It names the declared locales rather than describing them, for the reason
the edge target does — prose listing a site's languages is prose that drifts
from the map it is about.
Config "/site/pagedeck.config.ts": "build.xDefault" names a locale that is not declared — declare the locale, or point xDefault at a declared locale:
"fr" — the declared locales are "en", "de"
The second is a cross-field refusal, and it is the one paragraph in this section
about a field that is unusable rather than malformed. x-default is emitted
as an absolute URL, so without build.origin there is nothing to compose one
from, and a site that declared it would get no tag and no word about why. The
fix offers both directions, because either is a whole answer:
Config "/site/pagedeck.config.ts": "build.xDefault" is declared without "build.origin", and the x-default link it names is an absolute URL — declare origin: "https://example.com", or remove xDefault
Both can be present at once and are joined into one throw (rule 5). An
xDefault that is not a string is reported alone, like a non-string origin.
scriptsFaultReport (packages/core/src/scripts.ts) is the ninth, and it is
the only one with two doors (#46). A site normally writes its script layer
through defineScripts, which refuses at construction for urlTemplate's
reason; the value also lands on build.scripts, which a .js config may build
by hand, so assertBuildSection calls the same function and joins the same
paragraphs. One predicate, two doors, one set of words — ADR-0004's arrangement
over a different field.
The two doors differ in one thing, and it is where and the field name. At
construction the author is looking at the object they wrote, so a sub-field is
"pageTypes"; in a config it is the path they would search for:
Script settings: declares 1 field this build does not read — delete the field, or correct it to one of: scripts, pageTypes, pages, runtime, consentDefaults:
"pagetypes"
Config "/site/pagedeck.config.ts": "build.scripts.pageTypes" declares 1 key that is not a page pattern — write a path glob starting with "/", optionally prefixed "<locale>:", such as "en:/pricing":
"blog" — the path does not start with "/"
The override maps are patternMapFaultReport's by construction, the way
criticalCssFaultReport is: build.budget, build.criticalCss and these two
are read by one author in one object literal, so the key half, the specificity
order and the ambiguous-pair refusal are one implementation. What this field
supplies is what a value means — a map of script name to strategy, or to "off"
to take the script off those pages — and every fault of one key's map is on that
key's line, joined with ; , because they are one edit to one entry:
Script settings: "pages" declares 1 override no script can take — write a map of script name to strategy or "off", such as { "/blog/**": { analytics: "idle" } }:
"/home" — "analitycs" — not a declared script, and the declared scripts are "analytics", "chat"; "chat" — "lazy" — not a loading strategy or "off" — write one of: worker, idle, interaction, facade, off
"off" is legal in an override map and refused in a declaration, so the two fix
lines name different sets on purpose: rule 3 asks a fix to name what is legal at
the door the reader is standing at, and a declaration that loads on no page is
the empty script layer refused a paragraph above.
An unknown script name is checked only when the declarations gave a set to check
against, which is xDefaultFaultReport's silence over a locale set that is not
a Map: a scripts that is not a list has already been reported, and a
membership verdict measured against a set nobody has is a guess printed as a
fact. A set the declarations left empty is that same set — an empty
scripts, or one whose every name was refused above — and it is silent for a
second reason on top: the line it would print ends the declared scripts are
with nothing after it, which is the empty list rule 3 refuses.
The declarations themselves report in defineImages' shape, and the fix is on
each line for its reason — a name, a source, a strategy and a facade are fixed
four different ways, so a headline carrying one would carry the wrong one three
times out of four:
Script settings: declares 4 script fields that cannot be loaded from — declare each as the type its own line names:
scripts[0] — "name" — "" — not a script name — write the name an override addresses this script by, such as "analytics"
scripts[1] — "src" — 7 — not a script source — write the URL or path the script is served from, such as "https://example.com/analytics.js"
scripts[1] — "strategy" — "lazy" — not a loading strategy — write one of: worker, idle, interaction, facade
scripts[2] — "facade.html" — " " — not placeholder HTML — write the markup the page shows until the script loads, such as "<button>Chat</button>"
A list entry that is not a declaration at all is a paragraph of its own rather than a line under that headline: a value with no fields has no field to name.
Script settings: declares 1 entry that is not a script declaration — write each as a name and a source, as { name: "analytics", src: "https://example.com/analytics.js" }:
scripts[1] — "https://example.com/a.js"
A key the declaration does not take is its own paragraph, on
foldStrategyFaultReport's argument reaching one level further down:
{ stratergy: "idle" } is a script with nothing visibly wrong in it that
silently takes the default strategy.
Script settings: declares 1 script field this build does not read — delete the field, or correct it to one of: name, src, strategy, facade, category, attributes, integrity:
scripts[0] — "stratergy"
Two paragraphs are about names rather than types. An override addresses a script by name, so two scripts sharing one leaves an override with nothing to say which it meant — refused rather than resolved, because a pick by declaration order would be a guess the author never made:
Script settings: declares 1 name that more than one script uses, and an override addresses a script by name — give each script its own name:
"analytics" — scripts[0], scripts[2]
And a script that can reach the facade strategy with no facade to render emits
neither a placeholder nor a script, so the widget is simply absent from the page
with nothing anywhere saying why. Both layers can reach it, so each line says
which one did, and a script that already declared facade is reported once
rather than once per override agreeing with it:
Script settings: 2 scripts can resolve to the facade strategy with no facade to render — declare the placeholder the page shows until the script loads, as facade: { html: "<button>Chat</button>" }:
"chat" — declares strategy "facade"
"video" — pageTypes "/support/**" sets "facade"
The last paragraph is about the field that backs the worker strategy. A
runtime this build cannot call is refused rather than ignored, because
ignoring it lands the site on the idle fallback below with a warning saying it
configured no runtime — a message that would be true and unfindable, since the
author is looking at the field it says is not there.
Script settings: declares a script runtime this build cannot call — "partytown" — write the adapter the worker strategy loads through, as runtime: ({ scripts }) => ["<script>…</script>"]
Three paragraphs are about consent, and they arrive by the two doors above with the rest (#47). A category that is not one is a line under the type faults, because it is one more field of a declaration that four different edits can fix:
Script settings: declares 1 script field that cannot be loaded from — declare each as the type its own line names:
scripts[0] — "category" — "analytic" — not a consent category — write one of: analytics, functional, marketing, necessary
consentDefaults is patternMapFaultReport's by construction, the way the two
override maps are, and every fault of one key's map is on that key's line joined
with ; for their reason — one market is one edit to one entry:
Script settings: "consentDefaults" declares 1 default no category can take — write a map of consent category to granted or denied, such as { "de:/**": { analytics: "denied" } }:
"/**" — "analitycs" — not a consent category — write one of: analytics, functional, marketing, necessary; "marketing" — "maybe" — not a consent default — write one of: granted, denied; "necessary" — a necessary script loads without waiting on consent, which is what the category means — delete the key, or give the script a category a visitor can withhold
The third line is the one that is not a type fault, and it is refused rather
than honoured or ignored for the reason foldStrategyFaultReport's unknown key
is. necessary means the script loads without waiting on consent, so
{ necessary: "denied" } is a site contradicting itself: honouring it would
break the promise the category makes to every other reader of the config, and
ignoring it would leave an author looking at a line that does nothing. Its fix
names both edits, because which one is right depends on what the author meant by
the script and no build can know that.
Unlike the override maps, no line here is checked against the declared scripts.
A key is a market and a value is a category, and a category the site has
declared no script in yet is a default waiting for one — a marketing pixel
added next week should not have needed its market default in the same commit.
Four more lines are about the attribute map a vendor is configured through
(#440), and all four sit under the type headline above, because attributes
is one more field of a declaration that its own edit fixes:
Script settings: declares 4 script fields that cannot be loaded from — declare each as the type its own line names:
scripts[0] — "attributes" — "data-domain=example.com" — not an attribute map — write a map of data attribute to value, as attributes: { "data-domain": "example.com" }
scripts[1] — "attributes.data-" — not a data attribute name — write a key of the form data-<name>, such as "data-domain"
scripts[2] — "attributes.data-domain " — a name setAttribute throws on — it holds 1 character outside XML's Name production, " " (U+0020), and the loader sets every declared attribute as it runs, so one key like this stops script loading on every page that carries it; delete it, or write the name the vendor documents, such as "data-domain"
scripts[3] — "attributes.data-domain" — 7 — not an attribute value — write the text the vendor reads, such as "example.com"
The first line names the field and enumerates nothing under it, on facade's
argument: a value with no keys has no key to name. The next two name the key and
quote no value, because there the key is what is wrong and the line already
carries it. Each is reported instead of a value fault rather than beside it: a
key that is not an attribute name has no attribute for a value to be wrong on,
and rule 5 asks for every failure, not for one failure twice. A key that fails
both key rules is reported on the prefix alone, for the same reason.
The second line refuses what the ScriptAttributes type refuses and nothing
further — the prefix, and at least one character under it. data-Domain,
data-foo_bar, data-1 and data-x.y are all accepted: each reaches
setAttribute without a throw and lands on dataset as domain, foo_bar,
1 and x.y, and each is a spelling some vendor documents. A door refusing one
would fail a config tsc had already passed, which is ADR-0004's two doors
disagreeing about one field. The bare data- is the one key this line refuses
that the type does not, because the type cannot: its dataset key is the empty
string, so the attribute configures nothing and reports nothing.
The third line is the one refusal that is not the type read back, and the
distinction it turns on is whose rule is being enforced. A data- key may
still fail XML's Name production — a space in it, a trailing space, an angle
bracket, a tab — and setAttribute throws InvalidCharacterError on every one
of those. The throw happens inside the loader, which sets every declared
attribute as it runs, so the site does not lose one attribute: it loses every
script on every page that carries the declaration, with no message anywhere. A trailing space is an ordinary
config typo, so this is reachable rather than theoretical.
That is the opposite of the stricter expression this door once carried, which
refused names the browser accepts and was rejected for it. This refuses only
names the browser rejects. The door is not adding an opinion about spelling;
it is reporting at config load what would otherwise be a runtime crash with
nothing to read. The character is printed with its code point because the
reachable form of the mistake is invisible without it — "data-domain " quoted
back is a key whose trailing space nobody reading a CI log will see — and every
character of the name that failed is named, in the order it appears, so one edit
fixes the key.
Two more lines are about the integrity metadata a pinned vendor bundle is
checked against (#314), and both sit under the same type headline, because
integrity is one more field of a declaration that its own edit fixes:
Script settings: declares 2 script fields that cannot be loaded from — declare each as the type its own line names:
scripts[0] — "integrity" — "" — not integrity metadata — write the hash the vendor publishes for this exact file, such as "sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
scripts[1] — "integrity" — "oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC" — holds no sha256-, sha384- or sha512- hash, and a browser ignores integrity it cannot parse, so the script would load unchecked — write the hash the vendor publishes for this exact file, such as "sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
The first line is the type read back: a .js config is never typechecked, so a
number or an empty string reaches this door as readily as a hash.
The second line refuses a string the type accepts, and it is the permitted
exception to the two-doors rule the attribute lines argue above. That rule
forbids a refusal of what tsc passed and the browser honours. A value with
no sha256-, sha384- or sha512- token is not honoured: a browser ignores
integrity metadata it cannot parse and runs the script unchecked, with nothing
in the console a site would read. The config passes, the page ships, and the
check the site declared is not made. Refusing it at config load is the only
place the fault can be seen.
The token check is lowercase-only on purpose. Every vendor prints the algorithm in lowercase, and the two ways to be wrong are not the same size. Accepting a spelling some browser ignores is the silent unchecked load again. Refusing a spelling some browser would accept is loud, and it carries its fix in the same line.
Three faults are reported alone, on the non-object budget's argument — settings
that are not an object, a scripts that is not a list, and a scripts that is
an empty one. The last is the right type, so it cannot sit under a headline
about types, and it is refused rather than accepted because it would otherwise
make "the site declared no scripts" and "the site declared none of them"
indistinguishable — the first is the case that must ship zero script-layer
bytes.
Script settings: must be an object declaring the site's third-party scripts — scripts: { scripts: [{ name: "analytics", src: "https://example.com/analytics.js" }] }
Script settings: declares no scripts to load — write scripts as a list of declarations, such as scripts: { scripts: [{ name: "analytics", src: "https://example.com/analytics.js" }] }
Script settings: declares an empty list of scripts, so the script layer would do nothing — list at least one, or declare no scripts at all
Every value is quoted through quote (packages/core/src/quote.ts), the same
function defineImages quotes through, and it redacts for rule 6's reason: a
script source is a URL, and a URL is where a credential hides. The cut never
fires on the values most of these paragraphs quote — a non-string, an empty
string, whitespace — and is the guard for the next check that quotes a value
that did parse. runtime is that next check: it is the first field of either
caller that plausibly holds an object of URLs, so the cut reaches every
string at every depth rather than the top-level one alone, and
{ url: "https://cdn.example/x?key=SECRET" } is quoted
{"url":"https://cdn.example/x?…"}.
rootProvidersFaultReport is the tenth, and it is the first build field with
two halves that have to agree (issue #66). build.rootProviders declares
the provider stack twice over: stack, the values the build-time render wraps
the page in, and module, the specifier the generated island entry imports the
same stack from in the browser. A site that declared one half and not the other
type-checks nowhere in a .js config and builds green — and then every island
on the site is a hydration mismatch at its first node, reported by React in a
browser as a page defect, naming no config field. So a half-declaration is a
refusal at config load, one paragraph per half, joined with everything else the
section got wrong:
Config "/site/pagedeck.config.ts": "build.rootProviders" declares a stack this build cannot apply — write the stack as an array of { component, props }, outermost first, the shape both sides apply:
"stack" — absent, so the build-time render would wrap the page in nothing while every island root wrapped the stack
Config "/site/pagedeck.config.ts": "build.rootProviders" names no module for an island entry to import the stack from — name the module whose default export is that same stack, as module: "./providers.js"
A stack that is present but is not a list at all is the other single-line
paragraph, and it is one line for the reason the absent half is: there are no
indices to walk, so there is nothing under it to enumerate. It names the kind
the field takes rather than the kind the site wrote — { stack: 7 } and
{ stack: { hero: … } } are the same edit — and it never quotes, for the
reason the whole stack half never quotes:
Config "/site/pagedeck.config.ts": "build.rootProviders" declares a stack this build cannot apply — write the stack as an array of { component, props }, outermost first, the shape both sides apply:
"stack" — not an array of providers
Once it is a list, the stack's own faults are collected by index,
cssFaultReport's shape, because a stack is a list and a site reads it as one:
Config "/site/pagedeck.config.ts": "build.rootProviders" declares a stack this build cannot apply — write the stack as an array of { component, props }, outermost first, the shape both sides apply:
stack[0] — not an object
stack[1].component — not a component
stack[2].props — not an object
component is refused only where it is neither a function nor an object, which
is narrower than it looks: memo, forwardRef and lazy all return objects,
and a check for a function would refuse provider components React itself
produced. What is left is the mistake the line is read for — a renamed import
that is now undefined, whose own failure is a createElement(undefined)
thrown from inside a page render, naming a page.
An empty stack is refused rather than accepted, and that is the one refusal here about a field that is usable rather than malformed:
Config "/site/pagedeck.config.ts": "build.rootProviders" declares a stack this build cannot apply — write the stack as an array of { component, props }, outermost first, the shape both sides apply:
"stack" — declares no providers, and a site with no stack says so by leaving "build.rootProviders" out
Issue #66's sixth criterion is that zero configured providers costs zero bytes,
and an absent field is what buys that: every stage downstream reads the absence
and emits no wrapper, no import and no core-group pin. { stack: [], module: … }
asks for the same nothing while still paying for the import the entry writes, so
it is refused with the shorter spelling as the fix rather than quietly rewritten
into it.
The other half has its own paragraph, and it is the only one that quotes.
moduleFault is where a module that is present and unusable is refused — not
a string at all, or a string with nothing in it:
Config "/site/pagedeck.config.ts": "build.rootProviders" declares a module no island entry can import — name the module whose default export is that same stack, as module: "./providers.js":
"module" — not a string
Empty and whitespace-only are said apart, cssFaultReport's reason: they are
two different typos — an interpolation that produced nothing, and a path
somebody half-deleted — and a quote alone shows neither, because a reader
cannot see the difference between "" and " " by looking at them:
Config "/site/pagedeck.config.ts": "build.rootProviders" declares a module no island entry can import — name the module whose default export is that same stack, as module: "./providers.js":
"module" — " " — the specifier is only whitespace
The value is quoted here and nowhere else in this field's report, which is rule
6 read the right way round rather than an exception to it: a specifier is a path
the site wrote and the build is about to hand the bundler, so it is the one
thing here that is not a provider prop. What the stack holds is site config —
which is why stackFaultLines names a kind and never a value, and why the
browser's half of the same declaration hashes every prop it reports.
A rootProviders that is not an object is reported alone, on the argument the
non-object budget makes, and a key the field does not take is its own paragraph
with its own fix, exactly as foldStrategy's is:
Config "/site/pagedeck.config.ts": "build.rootProviders" must be an object holding the stack and the module it is imported from — declare both halves, as rootProviders: { stack: providers, module: "./providers.js" }
Config "/site/pagedeck.config.ts": "build.rootProviders" declares 1 field this build does not read — delete the field, or correct it to "stack" or "module", the only fields rootProviders takes:
"modules"
driftThresholdFaultReport (packages/core/src/drift.ts) is the eleventh, and it
is foldStrategyFaultReport's threshold paragraph over spec §9's other
threshold (#29). One value, so the collected shape is again about where the
reason goes rather than about counting: "5", 5.5 and -1 are a number
somebody typed in quotes, a fraction of a page, and a count below the lowest one
a build can reach. It joins the same throw for that field's reason — two
thresholds a site tunes are two lines of one object literal.
Config "/site/pagedeck.config.ts": "build.driftThreshold" is not a count of drifted pages — write a whole number of pages, 0 or more, such as driftThreshold: 3:
"5" — not a number
The value is quoted with JSON.stringify for the reason foldStrategy's is,
and it is load-bearing in the same way: a .js config is never typechecked, so
a threshold in quotes reaches the comparison and is coerced rather than
refused — 6 > "5" is true and 6 > "50" is false, and nothing anywhere
would say a threshold had been misread. The 3 in the fix is deliberately not
the default (rule 3).
driftSupplementFaultReport (packages/core/src/supplement.ts) is the
twelfth, and it is build.head's paragraph over #29's other field. It is a
callback, so there is nothing inside it to enumerate even in principle — a
function is not a container — and the only thing that can be wrong with one at
config load is that it is not a function. It joins the same throw as the
threshold beside it, because two lines of one object literal are one edit
session.
Config "/site/pagedeck.config.ts": "build.driftSupplement" must be a function returning a stylesheet for the classes it is handed — driftSupplement: (classes) => compile(classes)
The fix carries the signature rather than naming the type, build.head's reason:
the mistake it catches is a site that wrote its toolkit's stylesheet or its
plugin where the function belongs, which reads perfectly well, type-checks
nowhere in a .js config, and would otherwise surface as a call on a non-function
partway through the first build that drifted. The value is not quoted, because
what this line is about is the kind and the site is looking at the field it
wrote.
searchFaultReport (packages/core/src/search.ts) is the thirteenth, and it is
defineImages' second paragraph over #62's field. Two things can be wrong with
a search adapter and no two of them are fixed the same way — a name is what
every later report calls the adapter, an index is the function this build
hands its rendered pages to — so the fix is on each line and the headline
carries only what they share. It joins the same throw as everything above,
because a site wiring up an indexer writes it in the same object literal.
Config "/site/pagedeck.config.ts": "build.search" declares 2 fields this build cannot index through — declare each as the type its own line names:
"name" — undefined — not an adapter name — write the name this adapter is reported by, such as "lunr"
"index" — undefined — not an index function — write the function this build hands its rendered pages to, as index: (documents) => [{ path: "/search-index.json", kind: "asset", contents }]
A key the interface does not name is not a fault here, which is where this
field departs from foldStrategy, rootProviders and the script layer. Those
are settings the build reads, so a key it does not read is a line that silently
does nothing — the argument the unknown-key paragraphs are all made on. This
value is an adapter the site implements, and its options, its state and its
own methods are none of this build's business; refusing them would refuse a
class instance for having a class's fields. A search that is not an object is
reported alone, on the non-object budget's argument:
Config "/site/pagedeck.config.ts": "build.search" must be an object with a name and an index function — search: { name: "lunr", index: (documents) => [{ path: "/search-index.json", kind: "asset", contents }] }
Two more refusals are about what that adapter answers with, and they are the
build's rather than the config's: searchFiles (same file) is on the far side
of the call, holding files a site's own code has just returned. Both name the
adapter, which is what SearchAdapter.name exists for — a message about a
returned file that could not say which library produced it would leave a reader
looking at a path and nothing else. Both are a ConfigError and exit 2 on
compileSupplements' criterion: a declared adapter that returns this file
returns it on every run until somebody edits it.
The first is a file at a deploy key the build already wrote. buildManifest
refuses a duplicated key too (duplicateKeyReport), and this is not a second
opinion about that fault: that report is handed a set of files with no author in
it and names none, and the reader of this one needs to know which of the two
arrived last and through which field.
Search index: the "lunr" adapter returned 1 file at a deploy key this build already wrote — a deploy key holds one file, and the site's own pages, chunks and assets are written before the adapter is called; return each derived file at a path of the adapter's own, such as "/search-index.json":
"/index.html" — the build already emitted an html file there
The second is a file that sets page or name. Those two fields are claims —
EmittedFile.page says a file is one page's own HTML, EmittedFile.name says
which chunk of the client build it is, and the manifest answers a column of the
document from each — so a derived file carrying either answers for a page it is
not. misclaimReason covers two of the four ways to write it and this is why
that is not enough: a page naming a route this build did not emit claims a key
nothing reads, and a name no page entry joins to is never looked up. A refusal
that fires for half of one mistake is worse than none, because the half that
passes is the half nobody notices. Both fields are named on one line, because
they are one edit.
Search index: the "lunr" adapter returned 1 file claiming a page or a chunk of the client build — a "page" says a file is one page's own HTML and a "name" says which chunk of the client build it is, and the manifest answers a column of the document from each; return each derived file with neither field, and link it from the site's own pages:
"/search-index.json" — sets "page" and "name"
An adapter that throws is wrapped with the throw as its cause (rule 4) and
named, because an indexer's own stack says which library failed and nothing in
it says which build field called it. A plain Error and exit 1, the class
compileSupplements gives the same event over the other site-supplied callback:
the wiring is intact, the field is a function and it was called, and what failed
is the site's own code running.
Search index: the "lunr" adapter threw while indexing 2 documents — "build.search" is the site's own indexer and is handed every page this build rendered; fix the adapter, or remove "build.search" until it indexes this site
An adapter's optional patch is held to the same rules, and adds one of its
own (#307). At config load, searchFaultReport refuses a patch that is
present and not a function, on its own line and with two fixes, because an
absent patch is a declaration — an adapter that cannot patch, whose
incremental builds render every page — and so removing the field is as good an
answer as writing the function:
Config "/site/pagedeck.config.ts": "build.search" declares 1 field this build cannot index through — declare each as the type its own line names:
"patch" — true — not a patch function — write the function this build hands the previous index and the pages that moved to, as patch: ({ previous, documents, removed }) => ({ written, pruned }), or remove "patch" so an incremental build renders every page for the index
On an incremental build, searchPatchFiles (same file) holds the files a patch
writes to both refusals above, with the same words — a patch's answer is refused
exactly as index's is. A patch that throws is wrapped the same way, and says
what it was handed:
Search index: the "lunr" adapter threw while patching the index over 1 document and 1 removal — "build.search" is the site's own indexer and is handed the previous build's index and the pages that moved since; fix the adapter's patch, or run pagedeck build to index every page
The refusal of its own is a pruned file the previous index does not hold. A
patch prunes what its own previous index wrote, which it is handed as
previous; a key outside that set is a patch reasoning about some other index,
and one naming a page's document would read as the adapter deleting it. Every
such key, one per line, as fileKey spells it:
Search index: the "lunr" adapter pruned 2 files the previous build's index does not hold — a patch prunes only files its own previous index wrote, which it is handed as "previous"; prune each file by the domain and path it was handed at:
"/index.html"
"//shop.example/search/en.json"
The previous index is read back off the tree, and a tree that lost part of it
is refused before anything renders (carrySearchIndex in
packages/core/src/build.ts). This is carryDocuments' refusal over the
index's files — the rows the previous manifest records under the adapter's name
(ManifestFile.search) — and it refuses rather than composes, where a sitemap
composes, because an index is a function of every page's render and an
incremental build does not hold them:
Output "/site/dist": 1 file of the "lunr" search index cannot be carried from the previous build — an incremental build hands the adapter's patch the previous index as this tree holds it, so a file this tree does not hold as that build wrote it leaves an index no build wrote to patch — run pagedeck build to write the whole index again:
"/search-index.json" — the tree does not hold it
A file that is there and cannot be opened says so rather than reading as
missing — "the tree holds it and it could not be read" — and every read that
threw rides as the refusal's cause (rule 4): the one error itself, or an
AggregateError of all of them, so an EACCES reaches the log instead of
sending a reader to rebuild a tree that is whole. A file whose bytes no longer
hash to the recorded row is the third line, "the bytes there are not the ones
the previous build recorded", and has no cause because nothing threw.
Two refusals belong to an adapter rather than to the seam, and they are the
only ones @pagedeck/search has of its own. The first is localeFaultReport
(packages/search/src/shards.ts). That indexer writes each locale's files under
/search/<locale>/, so a locale that is not a path segment writes them
somewhere the query will not look — or, for a dot segment, out of the search
directory altogether. A ConfigError and exit 2 on rule 7's criterion: the
locale set is the site's own declaration and fails identically on every run. The
class is @pagedeck/core's rather than a local one, because a local class would have
to be named in isWiringFault and that would make core import a search package.
Search index: 3 locales are not path segments, and each locale's shards are written under "/search/<locale>/" — declare each locale the way a path spells one, such as "pt-BR":
"" — the locale is empty
".." — the locale is a dot segment, which resolves out of the search directory
"en/us" — the locale holds "/", which would write the shards into another directory
Every offending locale and not the first (rule 5), because a site that spelled one locale as a path usually spelled its neighbours the same way. Sorted by the locale rather than by the order the documents arrived in, so two builds of one site report identically (spec §11) — a message that reorders between runs is a message a reader cannot diff. Each line carries its own reason rather than sharing one, because the three are three different edits.
The second is refuseUnreadable's (same file, #307): a previous index its
patch cannot read back. patchDocuments rebuilds a directory out of the
previous files, so before it reads any it checks every directory — its format,
moved or not, because a directory left alone at another format would be carried
beside directories written at this one, and that it holds every file its
index.json names. Every such fault is one line (rule 5), each naming its
directory's index.json by fileKey, so a directory in a domain's tree says
which tree (rules 1 and 2), and sorted by that key so two runs report
identically. A ConfigError for the locale refusal's reason, with pagedeck build as
the fix, because a full build is the one thing that writes the index without
reading the old one. Core wraps it as the thrown cause of its patch refusal
above, so the adapter's name comes with it.
Search index: the previous index cannot be patched, for 2 reasons — a patch reads each directory back out of its previous files, so every one has to be this format and whole; run pagedeck build to write the whole index again:
"//de.example/search/de/index.json" is format 0, and this @pagedeck/search writes format 1
"/search/en/index.json" names "terms-0000.json", and the previous index holds no such file
retentionFaultReport (packages/core/src/retention.ts) is the fourteenth, and
it is foldStrategyFaultReport's three paragraphs over spec §11's retained
manifests (#32). The field is a small object the build reads, so all three
apply: a value that is not an object at all is reported alone, the count carries
its reason on its own line, and a key this build does not read is a paragraph of
its own. It joins the same throw as the drift threshold beside it, because two
counts a site tunes are two lines of one object literal.
Config "/site/pagedeck.config.ts": "build.retention" is not a count of manifests to keep — write a whole number of builds, 0 or more, such as retention: { keep: 10 }:
"20" — not a number
Config "/site/pagedeck.config.ts": "build.retention" declares 1 field this build does not read — delete the field, or correct it to "keep", the only field retention takes:
"kepe"
Config "/site/pagedeck.config.ts": "build.retention" must be an object with a keep count — retention: { keep: 10 }
The value is quoted with JSON.stringify for driftThreshold's reason, and it
is load-bearing in the same way: a .js config is never typechecked, so a count
in quotes reaches the prune as a string — slice(0, "2") keeps nothing, and
every retained manifest of a site that asked to keep two is deleted by the next
build with nothing anywhere saying the count was misread. The 10 in both fixes
is deliberately not the default (rule 3).
Three refusals belong to the store rather than to the field, and they are
localeFaultReport's argument over the other value this repo turns into a path
segment. Each retained build is written to <build id>.json, so an id that is
empty, starts with a dot, or holds a / or a \ writes the document out of the
store, into another directory, or over the store's own directory name. A
ConfigError and exit 2 on rule 7's criterion: runBuildVerb mints a uuid, so
an id like this comes from a CI wrapper that mints its own and fails the same
way on every run until that wrapper is edited.
The family is refused rather than the spellings that can be listed, and the
reason is that a list is wrong on a host it was not written for. join is the
platform's: ..\..\victim is on Windows exactly what ../../victim is here,
and "" names the store's directory rather than a document in it. Every leading
dot goes with the two dot segments for the same reason — a name in that family
is a hidden file at best. Nothing this framework mints has one, and neither do
the ids a CI wrapper mints from a branch name, a short sha or a run number,
which retention.test.ts asserts beside the refusals so that a widened guard
cannot start refusing real builds unnoticed. Each reason is its own sentence
(rule 2): an empty id, a separator and a leading dot are three different edits
to whatever minted it.
Retained manifest "..": the build id is a dot segment, which resolves out of the retention store, and each retained build is written to "<build id>.json" — mint the build id as a name a path can hold, such as a uuid
The second is a rollback naming a build the store does not hold. It names what the store does hold rather than describing it, which is the edge target's reason: prose saying "roll back to a retained build" sends a reader to list a directory the message has already listed. An empty store gets a sentence of its own, because the first would otherwise end with nothing after the dash — the empty list rule 3 refuses — and because "nothing has been retained yet" and "the build you asked for was pruned" are two different things to do next.
Retained manifest "b9": is not in the store at "/site/.pagedeck/manifests" — the retained builds are "b1", "b2", so roll back to one of those, or raise build.retention.keep before the build you want is pruned
Retained manifest "b9": is not in the store at "/site/.pagedeck/manifests" — the store holds no retained build, so run pagedeck build to fill it
The third is a document at the named path that will not open, and it arrived
when #387 closed the symlink this door still followed.
readRetainedManifest opens through the same O_RDONLY | O_NOFOLLOW the
store's listing uses, so a link planted at <store>/<id>.json fails the open
instead of being followed into a file whose first bytes V8's parse error would
quote back onto stderr — rule 6, out of a directory a site owner does not think
of as sensitive.
The refusal is keyed on a listed set of errnos, and what is not in the set is
rethrown as it arrived. UNREADABLE_DOCUMENT_CODES
(packages/core/src/retention.ts) holds the codes that are a fact about the
path — the two spellings of a refused link, the two permission codes, a
directory or a non-directory in the way, a name the file system will not take —
because each is what rule 7 asks for: no retry changes any of them. EMFILE,
EIO and EBUSY are deliberately absent. They are open failures a retry
fixes, and exit 2 is a promise that it will not, so classifying them would tell
CI to stop retrying in the one case where retrying is the answer. An unlisted
code therefore leaves the verb at exit 1, which is where every non-ENOENT
failure of this door landed before the flag went on it.
Which member it was is the cause's to say (rule 4), so the sentence enumerates
instead of diagnosing: a reader who took the read bit off a document meets "a
mode that forbids the read" among four faults rather than a claim about a
symlink they did not plant. That is the same rule the declared/carried byte
counts above are written under. The O_NOFOLLOW clause stands behind the
enumeration rather than in front of it, and is phrased as a standing fact about
how the store opens rather than as a finding about this run — a refusal of
something that works everywhere else still needs its reason. The path is in the
sentence rather than left to the cause, because one member does not supply it:
EISDIR reaches a log as illegal operation on a directory, read, naming no
file at all. The listing
door answers all of this by skipping the file and pruning it, because a caller
that named no document has asked for none in particular.
Retained manifest "b1": nothing opened at "/site/.pagedeck/manifests/b1.json" — the open ends there on a link, on a directory, on a mode that forbids the read, or on a name the file system will not take, and the store opens every document read-only and never through a symlink, so a link is refused rather than followed; put a document this pagedeck can open at that path, or roll back to a build id the store already holds
routingFaultReport (packages/core/src/routing.ts) is the fifteenth, and it
is the config door spec §11's routing document went without until #270. Four
paragraphs, joined into the same throw as everything above, because a site
declaring redirects writes them in the same object literal as the rest of its
build section.
What it checks and what it leaves to planRouting is the whole design of it.
Every refusal planRouting already makes — an off-site target, a rule starting
at a path this build serves, a target that is no page, two rules on one path, a
404 page nothing routes, a loop — is a verdict against the route table, which
does not exist when a config is loaded. This door answers only what needs no
route table and what a .js config makes real: whether each field is the type
the pass will read it as. That is isConfiguredCollection's reason with a
measured failure behind it — draft calls rule.set.forEach, so a header rule
written without a set reaches the build as a TypeError naming neither the
config, the field nor the rule, a minute in, after every page has rendered.
Config "/site/pagedeck.config.ts": "build.routing" declares 4 rule fields this build cannot route with — declare each as the type its own line names:
redirects[0] — "from" — 7 — not a string — write the path this rule starts at, as authored, such as "/old"
redirects[0] — "status" — "301" — not a number — write the 3xx a static host answers with, as status: 301, or leave it out for 308
notFound[0] — "path" — undefined — not a string — write the path the route table spells this page with, such as "/404"
headers[0] — "set" — undefined — not a list of header fields — write the headers this prefix carries, as set: [{ name: "X-Frame-Options", value: "DENY" }]
The fix is on each line and not in the headline, defineImages' second
paragraph: a path, a status and a header list are fixed three different ways, so
a headline carrying one would carry the wrong one most of the time. The value is
quoted through quote — rule 6, and it is load-bearing twice over.
Either end of a redirect can be a whole URL with a query string on it, and
status: "301" and status: 301 are one line to an interpolation and two
different mistakes, of which only the first is invisible in a config nobody
typechecks. The status is checked for being a number and not for being one of
four, which looks like half a check and is the boundary drawn on purpose: the
value set is planRouting's and has to be, because RoutingInput.removals
reaches that pass without passing this door at all.
A field that is a string and holds nothing is refused on the same line, and it
says which of the two typos it is — an empty value and a whitespace-only one are
different mistakes, and a quote of two spaces diagnoses neither on its own. Each
names its own field's noun rather than "the value", which is
canonicalizePath's wording one door along and defineImages' at the third:
Config "/site/pagedeck.config.ts": "build.routing" declares 2 rule fields this build cannot route with — declare each as the type its own line names:
redirects[0] — "from" — " " — the path is only whitespace — write the path this rule starts at, as authored, such as "/old"
redirects[0] — "to" — "" — the path is empty — write the path this rule ends at, as authored, such as "/pricing"
An experiment's arms are checked the same way a header rule's set is, and for
that rule's measured reason (#34): draft calls rule.variants.forEach and
walks each name character by character, so a split written without a variants
list, or with a name a .js config left as a number, is the same TypeError a
minute into the build. Which numbers are shares of visitors stays
planRouting's, beside the route table this door does not have.
Config "/site/pagedeck.config.ts": "build.routing" declares 2 rule fields this build cannot route with — declare each as the type its own line names:
experiments[0] — "variants" — undefined — not a list of variants — write the variants of this experiment, as variants: [{ name: "b", weight: 50 }]
experiments[1].variants[0] — "weight" — "50" — not a number — write this variant's share of visitors, such as weight: 50
A member the rule leaves undefined is a member the rule left out, for
domain and status alike, and neither is reported. This repo sets
exactOptionalPropertyTypes nowhere, so { from, to, domain: undefined } is a
RedirectRule a typechecked site can write — a spread of a partial rule
composes one without anybody typing the word — and a door refusing what its own
published types accept is a door no site can get past.
The other three paragraphs are the three other shapes a routing config can be wrong in, and they are separate for rule 3's reason — a value that is not an object, a key this build never reads, a member that is not a list and an entry that is not a rule are four edits with four fixes.
Config "/site/pagedeck.config.ts": "build.routing" declares 1 field this build does not read — delete the field, or correct it to one of: redirects, notFound, headers, experiments:
"redirect"
Config "/site/pagedeck.config.ts": "build.routing" declares 1 member that is not a list of rules — write each as an array, as routing: { redirects: [{ from: "/old", to: "/pricing" }] }:
"notFound" — 7
Config "/site/pagedeck.config.ts": "build.routing" declares 1 entry that is not a rule — write each as an object, as routing: { redirects: [{ from: "/old", to: "/pricing" }] }:
redirects[0] — "/old"
The unknown-key paragraph is the quietest failure this field has, which is
foldStrategyFaultReport's argument one level up: { redirect: [...] } is an
object with nothing visibly wrong in it that puts no rule in the document and
records nowhere that any were written. A routing that is not an object at all
— an array included, since RoutingConfig is the object holding the four lists
and not one of them — is reported alone, on the non-object budget's argument.
Config "/site/pagedeck.config.ts": "build.routing" must be an object of redirects, 404 pages, header rules and experiments — routing: { redirects: [{ from: "/old", to: "/pricing" }] }
planRouting's own reports name the same field on the line rather than in the
headline, and that asymmetry is #270's, not an oversight. Those are thrown
from inside a build, where the only thing the first words can honestly name is
the document — the same pass also reports over removals, which comes off the
previous manifest through planIncremental and no config holds. So the config
path travels on the locator, and rule 1 is met at rule 2's level:
Routing manifest: 1 redirect target is no page of this build — point it at a page this build routes or a file it emits, or drop the rule:
build.routing.redirects[0] — "/nowhere" in the default tree
Since #553 a redirect may also target a file the build emits into the rule's
own tree, such as the sitemap, the feed or robots.txt, and the fix names both
options: a page this build routes or a file it emits. The headline still says
"no page", because it is greppable and pinned. A target is refused on that line
when it is neither, and also when it is a file in another tree, a file at a
reserved deploy key, or a file under the experiment segment. A page's own
document, such as /about/index.html, is refused too, although the build emits
it: it would give the page a second address. The fix clause cannot say that
without growing a third option, so it is said here. Since #607 a file target
is matched before the trailingSlash policy spells it, so under "always" it
keeps the file's own spelling and is not refused.
A known limitation, not a rule: a refused target is quoted after the policy has
spelled it, not as the author typed it. Under "always" a target that names no
file in the rule's tree, such as a file of another tree, is quoted with a slash
the author did not write: "/sitemap-shop.xml/" for to: "/sitemap-shop.xml".
The locator still names the rule, so the author can find it.
A bare redirects[0] is where #270 found the locator: a real site tripping
that refusal was told a position in a list nothing named. The same pass reports
over deletion records, which no config holds, and those are named by a phrase
rather than by a fourth path — a redirect for a page this build deleted. That is
rule 2's shape for a claimant with no authored name, and it is what keeps a
report carrying both kinds honest: the paragraphs are collected, so a
removals[0] printed beside build.routing.redirects[0] would read as a
second config field and send its reader searching for a removals nothing
declares.
Routing manifest: 1 path is redirected by more than one rule — give each path one target and one status:
"/gone" in the default tree — build.routing.redirects[0] (config) to "/pricing" 301, a redirect for a page this build deleted (deleted-page) to "/about" 308
A header field named Location, in any letter case, is refused in a paragraph
of its own (#559). It is a valid token, so unusableHeaderName
(packages/core/src/routing.ts) passes it, and its fix is a different edit:
drop the field. Since #559 a rule's set rides every redirect under its prefix,
and each target writes Location on a redirect itself; on any other response
the field means nothing. So it is refused wherever it is declared.
Routing manifest: 1 header field is named "Location" in some letter case — remove it; every target writes "Location" itself on the redirects it answers and it means nothing on any other response, so it is refused wherever it is declared — to send a path elsewhere, write a redirect rule:
build.routing.headers[0].set[0] — "location"
A header value is refused by the rule every edge target applies at compile
time, unusableHeaderValue (packages/core/src/routing.ts), with the same fix
(#681). Without it, a site declaring a value with NUL or a character above
U+00FF would build, write its routing manifest and fail on deploy. The value is
named by code point and never quoted:
Routing manifest: 1 header value cannot be sent — remove the character; a field value may hold no control character but HTAB (RFC 9110 forbids the C0 ones and DEL, and a C1 one reaches a headers file as two bytes of UTF-8), since a line break can write a second header, and a Worker's Headers refuses any character above U+00FF:
build.routing.headers[0].set[0] — "X-Note" — the header value holds U+0000
packages/core/src/routing.build.test.ts reads every one of these refusals off
a spawned pagedeck build's stderr, which is the difference that issue was about: the
refusals were all unit-tested and none of them could fire.
sitemapFaultReport (packages/core/src/sitemap.ts) is the sixteenth, and its
two paragraphs are xDefaultFaultReport's two shapes over spec §7's sitemaps
(#40). The first is the field's one value read as a type: pattern is a choice
between two spellings, so the line quotes what the site wrote and the fix names
both of them rather than describing them — a message telling an author their
pattern is invalid without saying what a valid one looks like sends them to the
source.
Config "/site/pagedeck.config.ts": "build.sitemap" declares 1 field this build cannot write sitemaps from — declare each as the type its own line names:
"pattern" — "flat" — not a sitemap URL pattern — write "suffix" for /sitemap-en.xml, or "directory" for /en/sitemap.xml
The second is xDefault's cross-field refusal one field along, and for its
reason: every <loc> a sitemap holds is an absolute URL, so a site that
declared this and no origin would get no sitemap and no word about why. The fix
offers both directions, because either is a whole answer.
Config "/site/pagedeck.config.ts": "build.sitemap" is declared without "build.origin", and every <loc> a sitemap holds is an absolute URL — declare origin: "https://example.com", or remove sitemap
Both can be present at once and are joined into one throw (rule 5). A sitemap
that is not an object is reported alone, on the non-object budget's argument.
Config "/site/pagedeck.config.ts": "build.sitemap" must be an object naming the URL pattern its files take — sitemap: { pattern: "suffix" }
One more is the build's rather than the config's, and it is searchFiles'
collision one emitter over: sitemapFiles (same file) refuses a sitemap at a
deploy key the build has already written, which is what a site whose route table
puts a page at /sitemap.xml gets. A ConfigError and exit 2 on the same
criterion — a route table that collides today collides on every run until
somebody edits it — and every colliding key is reported rather than the first
(rule 5), because a pattern that collides in one tree usually collides in all of
them. The fix offers the other pattern beside the edit, since changing where the
sitemaps go is as whole an answer as moving the page.
Sitemaps: 1 sitemap is at a deploy key this build already wrote — a deploy key holds one file, and the site's own pages, chunks and assets are written before the sitemaps are; move the page off that address, or declare the other URL pattern — sitemap: { pattern: "directory" }:
"/sitemap-en.xml" — the build already emitted an html file there
A page's document can also collide with an emitted file along the path, and
that refusal is one check over the whole staged set (#622). A path in an
output tree is a file or a directory, never both. Under trailingSlash: "always" a page at /sitemap.xml/ writes /sitemap.xml/index.html, which
needs /sitemap.xml as a directory where the sitemap index is a file. The keys
differ, so every emitter's check passed and the build failed at the write with
a raw EISDIR or ENOTDIR. pathCollisionReport
(packages/core/src/manifest.ts) runs once the last emitter has staged its
files and before anything is written. It compares each page's document with
every file the build emits into the same tree, in three shapes: the same path,
the file's path as a directory of the document, and the document's path as a
directory of the file. A ConfigError and exit 2, for the sitemap refusal's
reason, and every collision is on its own line (rule 5). Each line names the
page by its (locale, path) identity, then the document and the file by their
deploy keys. The fix offers both directions, because either is a whole answer:
move the page, or move the file when it is a passthrough file the site placed:
Site build: 1 page document collides with a file this build emits into the same tree — a path in an output tree holds a file or a directory, never both, so no page below can be written beside the file its line names; route each page below at another path, or move the file if it is a passthrough file:
en /sitemap.xml/ — its document "/sitemap.xml/index.html" needs "/sitemap.xml" as a directory, where the build emits an asset file
A file whose deploy key a deploy refuses is refused by the build that emits
it (#674). readManifest refuses a manifest whose file row spells a key
deployKeyFault refuses, and the build collected passthrough files with no
name filter. A file named Icon\r in public/, which macOS writes for a folder
with a custom icon, became the key "/Icon\r", and every later read refused
the manifest that build wrote. deployKeyReport
(packages/core/src/manifest.ts) runs beside pathCollisionReport, through
the same deployKeyFault, over every file the build emits and before anything
is written. A ConfigError and exit 2, and every such file is on its own line
(rule 5), named by its key and, for a passthrough file, by the source file to
rename or delete:
Site build: 2 files are at a key a deploy refuses — a deploy key starts with "/" and holds no ".", ".." or empty segment, no backslash and no control character; rename or delete each:
"/Icon\r" — from "/site/public/Icon\r"
"/a\\b.txt" — from "/site/public/a\\b.txt"
A page an incremental build reuses must not name a chunk or stylesheet that
build did not emit (#720). The build reads a reused page back off the output
tree, so the page keeps the script and stylesheet names it was written with.
An incremental build pins the previous build's split, but a chunk's bytes can
still change when an island moves, and then the chunk and every chunk
importing it get new names. movedReferences (packages/core/src/links.ts)
finds each reused page that names a chunk or stylesheet the previous build
emitted and this one did not. stageUntilSettled
(packages/core/src/build.ts) then renders those pages again and bundles
again, until no reused page names a missing file. Each bundle runs the head
callbacks, social cards, renders and bundler again, so an incremental build
runs at most three, and a reused page that still names a missing file after
the third is refused, whatever build.links declares, because a page that
loads a missing script is broken for every visitor. A ConfigError and exit
2, one line per reference (rule 5):
Site build: 1 page this build reuses still names a chunk or stylesheet this build did not emit after 3 bundles — each bundle after the first renders again every reused page the one before found naming a file it did not emit, and an incremental build runs no more bundles than that — run pagedeck build to write the whole site again:
en /signup — "/assets/entry-01ec8b620130b05a-CSeRjcbH.js"
Seven emitters refuse a deploy key this build already wrote, and the line
under the headline is one implementation (#434). collisionLines and
collisionLine (packages/core/src/manifest.ts) compose it for the sitemaps,
the search index, the icon, the robots.txt, the preview app, the passthrough
files and the feed, beside the fileKey those refusals are keyed through.
collisionLines also sorts, so that none of the six reports collecting a set of
keys depends on the order its files were composed in; the feed checks one key
rather than a set and takes the line alone. What stays with each emitter is the
headline and the fix clause, and those clauses are five rather than seven: the
icon, the robots.txt and the feed each have one address and one fix, and write
it in the same words, "move the page off that address". Rule 3 is what keeps
even that one out of the shared line — a fix names what the author writes, and a
sitemap pattern, an adapter's return value, a build.preview.path and a
passthrough directory are not corrected by one sentence. That is
criticalCssFaultReport's placement argument reaching a line rather than a
field — seven copies of the sentence were seven chances for one emitter's
refusal to drift from the one beside it.
The passthrough stage publishes from both build.passthrough.root and
build.passthrough.contentRoot, and naming the right key is not the whole of
its fix, because the two collide for different reasons (COLLISION_FIX,
packages/core/src/passthrough.ts). A file beneath root collides with
whatever sits at its address, so moving the page or taking the file out clears
it. A file beneath contentRoot collides only across output trees: a reference
reached an address one tree holds and another does not, and the file is
published into both. Taking that file out trades this refusal for the
unresolved-reference one below, so its fix is to point the reference at a file
no tree already holds, or to have every tree emit a file at that address.
The passthrough stage refuses two more, and both name the directory a reader
wrote rather than the field it sits in (#439, #474). build.passthrough holds
two directories now — root, published whole, and contentRoot, the content
tree a page's content-relative references resolve into — so the field is
interpolated into passthroughDirectory's pair of refusals
(packages/core/src/build.ts) rather than written out: "build.passthrough"
alone would send the reader of a two-key setting to check the wrong one. Both
are ConfigError and exit 2, and both fire before a page is rendered, so a run
does not pay for the renders first.
Removing the broken key is only a fix while the other key is declared (#491).
Without it, what is left is passthrough: {}, which the refusal further down
rejects. So its fix is to remove the key when the other one is declared, and to
remove passthrough when it is not. The first line below is a site that
declares only root, and the second is a site that declares both:
Config "/site/pagedeck.config.ts": "build.passthrough.root" names a directory that does not exist — "/site/nowhere" — point root at the directory of files the site publishes, or remove passthrough
Config "/site/pagedeck.config.ts": "build.passthrough.contentRoot" names a path that is not a directory — "/site/src/logo.png" — point contentRoot at the content tree a page's content-relative references resolve into, or remove contentRoot
A setting that names neither directory is refused at config load (#491).
Both keys are optional, so passthrough: {} is well formed and publishes
nothing, and a setting that does nothing is refused, as defineScripts refuses
an empty list. The fix names both keys, because either one alone is a setting
this build can act on (passthroughFaultReport,
packages/core/src/passthrough.ts):
Config "/site/pagedeck.config.ts": "build.passthrough" declares neither root nor contentRoot, so it publishes nothing — declare root as the directory of files the site publishes whole, such as root: "./public", declare contentRoot as the content tree its pages' relative references resolve into, such as contentRoot: "./src", or remove passthrough
The last one is the gate #439 could not land on its own (#474). A post
writing ../../assets/images/ferry/logo.png reached the emitted HTML untouched
and nothing in the build looked at it, so the page shipped a dead <img> on a
green build; the refusal is what closes that, and it ships with the emission
that gives such a reference somewhere to resolve to, because a gate with no fix
available behind it turns a passing build into a failing one. Collected over
every reference rather than thrown at the first (rule 5) — a post that lost its
images lost all of them at once — and each line carries the four things a reader
acts on: the page the dead image shows up on, what it wrote, the address this
build went looking at, and the content entry the reference is written in.
That last one is rule 2, and it is the one a reader edits. A route is a fact
about where the page landed and a reader who has only that has to map it back to
a markdown file themselves; the entry is refKey's spelling of the file the
reference sits in, which is the column driftWarnings' report spells "own
entry" further down this document. A
page no stored entry backs — a source composing its instances from a literal
list, a JSON file or a build-time fetch (#87) — ends its line
no content entry: no stored entry backs this page, so the reference is in whatever composed it
instead, which is §2's (whole entry) read at a page rather than at a field:
Passthrough: 2 references a page makes to a file beside its content resolve to nothing this build can publish — put the file at that path beneath the directory "build.passthrough.contentRoot" names, or point the reference at a file that is already there:
en /posts/ferry — "../../assets/images/ferry/rules.png" → "/assets/images/ferry/rules.png" — content entry "posts en posts/ferry"
en /posts/json-bonsai — "../../assets/images/json-bonsai/query.png" → "/assets/images/json-bonsai/query.png" — content entry "posts en posts/json-bonsai"
speculationFaultReport (packages/core/src/speculation.ts) is the seventeenth,
and it is searchFaultReport's shape over spec §13's Speculation Rules (#42).
Two fields, each read as the type its own line names, both of them collected
into one paragraph (rule 5) with the count interpolated rather than written out
— the drift sitemapFaultReport is written against, one field along.
Config "/site/pagedeck.config.ts": "build.speculation" declares 2 fields this build cannot emit speculation rules from — declare each as the type its own line names:
"action" — "preload" — not a speculation action — write "prefetch" to fetch the next page's bytes, or "prerender" to render it
"max" — 0 — not a whole number of pages above zero — write the most pages one document may list, such as max: 5
Each fix names the supported values rather than describing them, which is rule
3's edge-target lesson at a two-valued field: an author told their action is
invalid, without being told what a valid one is, goes to the source. max: 0 is
refused rather than read as "emit nothing", because a site that declared the
feature and got no element would have set a field and watched it do nothing.
A speculation that is not an object is reported alone, on
searchFaultReport's argument about a non-object adapter — the same one
sitemapFaultReport reports on: there is nothing inside it to enumerate, so the
collection the paragraph above is a collection of does not exist.
Config "/site/pagedeck.config.ts": "build.speculation" must be an object naming the action its rules take and how many pages one may list — speculation: { action: "prefetch", max: 5 }
viewTransitionsFaultReport (packages/core/src/view-transitions.ts) is the
eighteenth, and it is the same issue's other door — one line and no
collection, because the field is a boolean and there is nothing inside it to
enumerate, which is build.head's argument about a function. It is checked at
all because viewTransitions: "auto" is the spelling the CSS at-rule itself
uses, and therefore the mistake a reader of the at-rule makes first; unchecked,
it is truthy and the site would never learn it wrote something this framework
does not define.
Config "/site/pagedeck.config.ts": "build.viewTransitions" must be true or false — viewTransitions: true
safelistFaultReport (packages/core/src/drift.ts) is the nineteenth, and it
is the threshold's field one line along — spec §9's other declaration, every
class a site's code states (#260, #261). It collects for budgetFaultReport's
reason and counts in the headline for the same one: a key written as a string, a
key holding a whole className unsplit and a key holding a number are three
different edits, and an author fixing one at a time is the build loop rule 5
exists to close. The count names the keys that are wrong rather than the
safelist they are in, because a safelist with one bad entry and nine good ones
is a usable declaration with an edit to make.
Key rather than field, because #261 gave the declaration a second key shape.
A key is a CMS styling field, "hero.theme", or a component whose own source
states the class, "hero" — spec §9's two clauses, one each — so a report
calling either a field would send half its readers looking for a CMS field that
does not exist. The fix carries both spellings for the same reason.
Config "/site/pagedeck.config.ts": "build.safelist" declares 3 keys that enumerate no class names — declare the classes each CMS styling field can render and each component states for itself, as safelist: { "hero.theme": ["bg-white", "text-slate-900"], "hero": ["hero"] }:
"hero.theme" — is a string, not an array of class names
"button.variant" — holds "bg-indigo-600 text-white", which is several classes in one string — declare each class on its own
"hero" — holds a number, and a class name is a string
The second line is the fault this field is checked for at all, and it is a
failure of silence rather than of the build. "bg-indigo-600 text-white" is the
shape a design system's styling table holds a value in — one className per
CMS option — so writing the table's values straight into the safelist reads
correctly and declares nothing: no rendered class ever equals that string, so
the site gets exactly the build of a site that declared no safelist, and drifts
on every option it believed it had sanctioned. Nothing downstream could tell the
two apart, which is why the refusal is here and not later.
The class is quoted with JSON.stringify and the key with its own quotes, on
criticalCssFaultReport's argument: the whole diagnosis is that this value is
one string where two class names belong, and an interpolated one would look like
the two that would have worked. A safelist that is not an object is reported
alone, on the argument the non-object budget makes — there is no collection
inside it to enumerate:
Config "/site/pagedeck.config.ts": "build.safelist" must be an object keyed by where each class came from — the CMS styling field, or the component whose source states it — declare the classes each CMS styling field can render and each component states for itself, as safelist: { "hero.theme": ["bg-white", "text-slate-900"], "hero": ["hero"] }
beaconFaultReport (packages/core/src/beacon.ts) is the twentieth, and it is
the same rule over a field with one door (#48). There is no defineBeacon
to refuse it at construction, because there is nothing here for a site to
construct — build.beacon is one string in an object literal, the shape
build.origin has — so the config load is the only place these faults can be
found, and every one of them is found in one pass:
Config "/site/pagedeck.config.ts": "build.beacon" must be an object saying where real-user metrics are sent — beacon: { endpoint: "https://example.com/rum" }
Config "/site/pagedeck.config.ts": "build.beacon" cannot be used as declared — beacon: { endpoint: "https://example.com/rum" }:
"endPoint" is not a field this build reads — delete it, or correct it to: endpoint
endpoint "wss://collector.example/rum?…" has the scheme "wss:", and a browser reports over http: or https: — write https://example.com/rum
The first is reported alone on the non-object settings' argument one field up: there is no field inside it for the other lines to be about.
The refusal worth reading twice is userinfo, and it is the one place in this
document where rule 6's cut is not enough on its own. This endpoint is written
into the HTML of every page of the site, so a credential in it has already been
published by the time anything can complain; redacting it in the message would
have shipped it anyway. So it is refused — and every line of this report quotes
the endpoint through quoteAddress (packages/core/src/quote.ts), because the
line that names a password must not be the line that prints it:
Config "/site/pagedeck.config.ts": "build.beacon" cannot be used as declared — beacon: { endpoint: "https://example.com/rum" }:
endpoint "https://…@collector.example/rum" carries userinfo, and this URL is written into every page of the site — take the credential out, and authenticate the collector another way
That cut is structural rather than a pattern, and the difference is a bug this
document briefly promised was impossible. The first version matched the
userinfo with a regular expression, which has to decide where a credential
ends — and nothing in the string says: a password holding a / was printed
whole and one holding a @ was printed in part. Parsing does not settle it
either, since user:p/w@collector.example/rum parses as the scheme user: with
the credential in its path. So the end of the credential is the value's last
@, found with lastIndexOf, and the scheme is what is kept in front of the
marker — which is what lets the scheme still be named in the line above. The cut
belongs to redactSource (packages/core/src/quote.ts) rather than to this
field, and quoteAddress is a name for the field it is reached through: rule 6
below states the rule once, with the spans it takes for a credential, the one it
knowingly does not, and the property to check any edit of it against.
Config "/site/pagedeck.config.ts": "build.beacon" cannot be used as declared — beacon: { endpoint: "https://example.com/rum" }:
endpoint "user:…@collector.example/rum" has the scheme "user:", and a browser reports over http: or https: — write https://example.com/rum
Two more belong to the deploy rather than to the store, and they are the
same issue's serialization half read at the verb. racedDeployReport
(packages/core/src/diff.ts) refuses a pagedeck diff whose target build was not
based on the build it is deploying over — spec §11's last-write-wins detection,
which is a comparison of BuildStamp.parent against the other document's
build.id, made only of a plan that has files to upload or prune. Both name the two builds, because only the reader knows which of the
two deploys is the one that should not have happened, and both end in the flag
that proceeds anyway. A ConfigError and exit 2 on pruneWindow's criterion
one function along: the manifests are intact, and the same command line fails
the same way until someone edits it.
Manifest diff: build "b3" was built on "b9" and is being deployed over build "b2", so another deploy wrote this site after this build read it — re-run pagedeck build so it is based on what is live, or pass --force to overwrite that deploy
A build that records no parent at all gets its own sentence, on the argument the empty store above gets one: it is a different fact with a different thing to do about it. The build recorded no base, so nothing in it says it followed the build being deployed over — which is not evidence of a race, it is the absence of the evidence that would rule one out. It is refused all the same, because the feature is deploy safety and failing toward asking is the direction that keeps a site's bytes.
Manifest diff: build "b1" records no parent, so nothing in it says it was built on build "b2" — a build records the newest manifest its retention store held when it started, and one that ran before the store existed records none; re-run pagedeck build so it records this base, or pass --force to deploy it anyway
One build on both sides is exempt from both, and that is the rule not
applying rather than a hole in it. A build cannot race itself: the document is
empty by construction, the deploy writes nothing, and there is no ordering for
the refusal to protect. A false positive costs more here than the case it
catches is worth — an author who meets --force on a no-op deploy learns that
--force is what you pass to make pagedeck diff work, and passes it next on the
deploy that is racing. A refusal is worth having only while it fires rarely and
means something every time it does.
pagedeck rollback refuses neither, and that is the design rather than an
omission: a rollback deploys a build that came before what is live, so it is out
of order by definition. That the refusal is at the verb and not inside
diffManifests is what buys it — one document shape, one ordering, one code
path, and no direction flag (packages/core/src/diff.ts).
What that verb collects instead is rule 5 over the two halves of one command
line. A grace period it cannot read and a build id the store does not hold are
one edit, and reporting them a run apart is the loop this rule exists to close;
each line keeps the fix it arrived with, defineImages' second paragraph, and
the headline carries only what they share. A lone failure is rethrown whole so
the common case keeps its cause (rule 4) and the sentences above.
Rollback to build "b9": 2 things stopped this run — each line names its own fix:
pagedeck rollback --grace-seconds takes a whole number of seconds, and got "a while".
Retained manifest "b9": is not in the store at "/site/.pagedeck/manifests" — the retained builds are "b1", "b2", so roll back to one of those, or raise build.retention.keep before the build you want is pruned
A site with no build section is thrown on the spot rather than collected, on the
boundary argument the deploy's unknown --edge target is: outDir defaults to
./site only inside a build section, so without one there is no outDir and
no manifest to have failed to read, and the collection would have nothing to
collect.
Config "/site/pagedeck.config.ts": declares no build section, so pagedeck rollback has no outDir to find the current manifest in — add a build section to pagedeck.config.ts — build: { pages, components, content }
Eight belong to planRouting (packages/core/src/routing.ts) and none of
them joins that throw either, because the routing config is not a build field:
it is compiled into its own document, so its faults arrive under that document's
name rather than under a config path (rule 1). They are spec §12's build-time
experiments (#34), and all eight are a ConfigError and exit 2 on rule 7's
criterion — a split an author declared fails identically on every run until the
config or a route is edited.
Five of them are the shape of a declaration, collected in one pass and reported
as five paragraphs for defineImages' reason: an empty split, a name a URL
cannot hold, a name written twice, a weight nobody can be assigned by and a
cookie key a browser will not carry are five different edits, and a headline can
carry only one of the five fixes.
Routing manifest: 1 experiment declares no variants — list at least one variant, or drop the experiment; an experiment with no variants emits nothing and assigns nobody:
experiments[0] — en /pricing
Routing manifest: 2 variant names are not path segments, and each variant is written under "/_v/<name>/" — name each variant the way a path spells one segment, such as "b":
experiments[1].variants[2] — ".." — the variant name is a dot segment, which resolves out of the variant tree
experiments[1].variants[3] — "b/c" — the variant name holds "/", and a variant name is one path segment of a URL
Routing manifest: 1 experiment declares one variant name more than once, and a name is what the experiment assigns a visitor to — give each variant of one page its own name:
experiments[1] — "b"
Routing manifest: 1 variant weight is not a share of visitors — write a positive number, such as 50; the shares are relative, so they need not total anything in particular:
experiments[1].variants[0] — 0
Routing manifest: 2 experiments declare cookie keys no browser will carry — write the key as a cookie name, such as "fw_pricing":
experiments[0] — the cookie key is empty
experiments[1] — the cookie key holds " ", which ends a cookie name
The variant name and the cookie key each name the offending character
rather than describing the value, localeFaultReport's argument over the other
two values this repo turns into path segments: a reader looking at fw pricing
cannot see which of its characters the message is about. The weight is quoted
with JSON.stringify for driftThreshold's reason and it is load-bearing in
the same way — a .js config is never typechecked, so "50" reaches the edge
and divides into a share as NaN, and an interpolated 50 in the report would
be indistinguishable from the one that would have worked.
The variant name is quoted whole and the cookie key is not, and that split is
rule 6 applied rather than an inconsistency in one pass. The rule redacts what
could be a credential, so the question is what each value is. A cookie key is
a name a browser carries — it goes out in a Set-Cookie and comes back on every
request, so it is the kind of value a site spells out of a token, and
fw_${sessionId} is a plausible line of config. A variant name is an authored
label written into a public URL as a path segment (/_v/<name>/), served and
crawled: a name holding a secret would have leaked it to every visitor long
before any build printed this report, so quoting it discloses nothing the site
is not already publishing. Each message then gives up what that costs it — the
cookie line can only name a character, which is why the check walks the key
character by character, while the variant line can show the whole name, which is
what lets an author with three near-identical names see which one it is about.
The page identity is unquoted for neither reason: en /pricing is spelled the
way the route table spells it, so the line reads as a lookup the author can make
rather than as a value being reported back at them.
The three below are the same declaration checked against the route table, which
is NotFoundRule's precedent one shape along. A page identity is answerable —
collectPages enumerates every one of them — so a misspelled one is a build
failure that names it rather than a split that silently never runs, and the only
other evidence of that would be a flat line in an analytics tool weeks later.
Routing manifest: 2 experiments name no page of their trees — name a page by the locale and path the route table spells it with, or drop the experiment:
experiments[0] — the route table holds no en /plans
experiments[1] — de-ch /about renders into the "shop.example" tree
Routing manifest: 1 page carries more than one experiment, and a visitor is assigned to one variant of one page — give each page one experiment:
en /pricing — experiments[0], experiments[1]
The last is the cost of the variant URL scheme, refused at the config door so
that it is never paid at emission. Variant outputs are written under /_v/, so
a real page there is a page a variant would overwrite:
Routing manifest: 1 page is written under "/_v/", the segment this build reserves for variant outputs — move the page out of "/_v/", or drop the experiments in its tree:
the default tree — en /_v/b/en/pricing is written to "/_v/b/en/pricing"
It fires only in a tree that declares a split, and that is the segment being
reserved where it is claimed rather than everywhere. A site that declares no
experiment writes nothing under /_v/, so refusing its page there would be this
build failing a site over a feature it does not use — issue #34's fourth
acceptance criterion, "zero output difference for builds with no declared
variants", read at the refusal rather than at the document.
The reserved deploy keys are refused in every tree, because the edge denies
them in every tree whether or not a site uses anything (#556). They are not
every deploy key, only the deploy's own: a deploy publishes the build's
manifest at /manifest.json and files its history under /.pagedeck/manifests/, and
every target answers both with the site's 404. A page or a redirect there
would build green and never be served:
Routing manifest: 2 pages or redirects are at paths this build keeps off the edge for the deploy's own manifest and history — move each off "/manifest.json" and out of "/.pagedeck/"; every target answers 404 there, so nothing routed at one of them is ever served:
"/.pagedeck/changelog" in the default tree — the page en /.pagedeck/changelog
"/manifest.json" in the default tree — build.routing.redirects[0]
A redirect is read by its source with the trailing slash stripped, so
/manifest.json/ is refused as well: the edge answers both spellings of an
address, and a row on the slashed one would put a second row on the key.
The image contract is checked at construction rather than at config load, so
none of its refusals joins that throw (#43). Spec §10's image settings are
read by the site's own component and by nothing in the pipeline — the framework
never wraps what a component rendered, so there is nothing to consume them — and
a build field nothing reads would have been a check bought with a drift
nobody can see: a site could declare one setting on the config and hand
imageAttributes a different object. defineImages and urlTemplate refuse
where the site writes them instead. Both are a ConfigError and exit 2 (rule
7): they are the site's wiring, and no retry improves them.
defineImages (packages/core/src/images.ts) is budgetFaultReport's shape
over a value that is neither a pattern map nor a list, and reports in four
paragraphs:
Image settings: declares 1 field this build does not read — delete the field, or correct it to one of: adapter, widths, quality, format, sizes:
"formats"
Image settings: declares 2 fields images cannot be built from — declare each as the type its own line names:
"adapter" — not a function — pass a function of (src, width, quality, format) returning a URL, or urlTemplate("https://cdn.example{src}?w={width}")
"quality" — "70" — not a number — write the number your CDN's quality scale takes, such as 70
Image settings: declares no widths, so there is no srcset entry to build — list at least one, such as widths: [640, 1280]
Image settings: declares 1 width that is not a pixel width — write each as a whole number of pixels above zero, such as widths: [640, 1280]:
widths[2] — 0 — not a whole number of pixels above zero
The second paragraph is the one place in this document where the fix is on each line rather than in the headline, and it is not a softening of rule 3. Four different fields can be wrong at once and no two of them are fixed the same way — an adapter is a function, a quality is a number on the CDN's own scale, a format is that CDN's own token — so a headline carrying one fix would be carrying the wrong one three times out of four. What the headline states is the only thing the four share: which field, and that each line names the type it needed. The alternative, a paragraph per field, is four headlines for what is one edit to one object literal.
The third is separate for the opposite reason. widths: [] is the right type,
so it cannot sit under that headline, and it is reported alone on the non-object
budget's argument: a list with nothing in it has no entry to enumerate a fault
against. Settings that are not an object at all are reported alone for the same
reason:
Image settings: must be an object declaring an adapter, widths, quality and format — images: { adapter: urlTemplate("https://cdn.example{src}?w={width}&q={quality}&fm={format}"), widths: [640, 1280], quality: 70, format: "auto" }
The unknown-key paragraph is the quietest of the four and half the reason the
function exists: { wdiths: [640] } is an object with nothing visibly wrong in
it that does none of what the site asked for.
Every value on those lines is cut at its ? or # before it is quoted, and
that is rule 6 reaching further than it looks. The line most often read is
quality, a numeric field, and the mistake that produces it is a paste into the
wrong place — a signed CDN template typed where a number belongs is exactly the
string that carries an account token, and it would otherwise be echoed whole
into a CI log by a message about a number. Cutting costs a well-formed value
nothing, because none of these fields holds either character.
The cut is one implementation — packages/core/src/quote.ts, which this
module and the script layer both quote through — and not a copy in each file
(#46). That is criticalCssFaultReport's placement argument reaching a rule
rather than a field: rule 6 is not about images or about scripts, neither caller
supplies anything of its own to the quoting, and two copies under one sentence
is how one of them comes to cut a different depth from the other while both
still look like the same rule.
urlTemplate refuses a template with no {width}, one with no source
placeholder, and one holding a placeholder it cannot fill:
Image URL template: holds no "{src}" or "{srcParam}" placeholder, so every image would get the same URL — write the source where the CDN takes it, {src} in a path or {srcParam} in a query parameter, as "https://cdn.example{src}?w={width}"
Image URL template: holds no "{width}" placeholder, so every srcset entry would be the same URL — write the CDN's width parameter as {width}, as "https://cdn.example{src}?w={width}"
Image URL template: holds 1 placeholder this adapter cannot fill — correct each to one of: {src}, {srcParam}, {width}, {quality}, {format}:
"{quailty}"
No message here quotes the template, and that is rule 6 rather than concision. An image CDN's URL is exactly the kind that carries a signing key, and these refusals are read in CI logs where nothing masks a string the site composed. So a fault is stated by placeholder name — what the author typed, and not a secret — and the fix carries an example template of the framework's own instead of the site's. No message this module can produce ever quotes a built URL either: every refusal happens before the adapter is called.
imageAttributes is the last, and it is the CLS guard #43's third criterion
asks for: an <img> with no width and height reserves no space, so
everything under it jumps as the bytes arrive. It is a RenderError and exit
1 under rule 7 — the site's wiring is intact and what is missing is a field of
one asset — and it is squarely "will only render wrong", since the page builds,
ships, and reflows.
Entry /en/home: image "/uploads/hero.jpg?…" declares 2 intrinsic dimensions that are not pixel sizes, so the browser reserves no space for it and the page shifts as it loads — pass the asset's own pixel width and height:
width — undefined — not a number
height — 0 — not a whole number of pixels above zero
The source is cut the same way, unusableReason's cut and for its reason: an
image source is as often a path as a URL, and a signed one carries its
credential in the query. An unusable source is a second paragraph of its own and
comes first, on loadComponents' argument — an image with no usable source has
no image to have dimensions, so its dimension verdict is about nothing:
Entry /en/home: image source is not usable, so no URL can be built for it — pass the asset's own path or URL, such as "/hero.jpg":
" " — the source is only whitespace
That report collects every fault of one image and no more, and the level is
the argument rather than a shortcut. A page's images are computed one call at a
time by the site's own code — there is no pass that holds them all — which is
the position resolveComponent is in when it keeps the single-name sentence.
renderPage's byte scan joins its two reports the same way (issue #22). The
scan reads one page's emitted HTML once and finds three things in it, two of
which are faults: a hydration marker the parser will defeat, and a stylesheet
React hoisted. The third is document metadata React hoisted, which the same walk
takes out of the page for the <head> to hold and reports nothing about (#239)
— so the join below is still over two reports. Both
are a RenderError and exit 1 (rule 7), so unlike loadComponents there is no
classification a split would preserve — and a page holding both would otherwise
cost two builds. They cannot share a headline either: a defeated marker is where
the content put a component, a hoisted sheet is what a component's own source
declared, and the two fixes are two files. So the marker paragraphs come first,
the hoisted-sheet paragraph second, in a fixed order so two builds of one page
report identically (spec §11).
checkBudgets (packages/core/src/budgets.ts) collects over pages instead of
over fields. A component added to a shared tier breaches every page that uses
it at once, so a report naming one of them would send the author back for the
same edit as many times as the site has sections:
JavaScript budget: 1 page transfers more JavaScript for first render than its budget allows — ship fewer or smaller islands to each page, hydrate one on "visible" or "idle" instead of "load", or raise its limit in pagedeck.config.ts's build.budget:
en /pricing — "/pricing" allows 15360 B, the page transfers 70813 B over 3 chunks:
/assets/fw-core-DO-Blg1p.js — 52397 B
/assets/Chart-9f31.js — 18004 B
/assets/en_pricing-1a2b.js — 412 B
fold strategy promoted "Chart" at tree position 0 from "visible" to "load" — position 0 is above the fold threshold of 4
Every breaching page and every cause, but only each page's three largest
chunks, and the cutoff is not a softening of this rule. Rule 5 is about the
failures a run can enumerate, and each page is one of those; a chunk is not a
failure but evidence about one, and a page over budget by a hundred chunks of
2 kB is diagnosed by three lines exactly as well as by a hundred. The page's own
line carries the total count and says the 3 largest whenever it is quoting
only some of them, and budget-report.json — written immediately before this
throw, so it is on disk by the time the message is read — holds every chunk.
Inlined JavaScript is evidence of that second kind, not a fourth (#346),
and it is cut at nothing because there is only ever one line of it. A budget
counts what the page runs, and two emitters write an executable <script>
straight into the document rather than into a chunk — the script layer's consent
loader and the RUM beacon. Those bytes are in the headline figure, so a message
printing only chunk lines would name a smaller number than the one it failed on
and send the reader hunting for a chunk that does not exist. The line carries a
phrase where a chunk's carries a path, because there is no file to open — and
the page's own line splits the sum rather than attributing all of it to a chunk
list that may be empty:
JavaScript budget: 1 page transfers more JavaScript for first render than its budget allows — ship fewer or smaller islands to each page, hydrate one on "visible" or "idle" instead of "load", or raise its limit in pagedeck.config.ts's build.budget:
en /about — "/about" allows 0 B, the page transfers 300 B, all of it inlined into its document:
inlined into the document — 300 B
That page has no entry chunk at all — a 0b budget and a consent loader in its
document — which is the shape #346 was filed on. A page carrying both halves
names both — the page transfers 2100 B, 1500 B of it over 3 chunks and 600 B inlined into its document — and a page that inlines nothing keeps the sentence
above and gets no such line, reading exactly as it did before.
A cause is the third kind and is cut at nothing. Fold-driven hydration (#24) can change what a page transfers with nothing in the page's own declarations changed — a copy edit that pushes a block above the threshold — which is spec §9's "promoted by fold score after content edit", and a reason for a failure is what rule 5 is about rather than evidence about one: there is no such thing as the three most important reasons. There are never many either, since a page has as many as it has islands the fold rule moved. They sit below the chunks because the chunks are what the limit was exceeded by and a cause says why one of them is there.
checkBudgets also reports every island whose serialized props are over
build.islandPropsBudget (#653). The props are the JSON in the island's
marker, so a server component that hands an island a whole CMS entry ships the
whole entry in the page, and no JavaScript budget sees it:
Island props budget: 1 island carries more props in its marker than the limit allows — pass the island only the fields it renders, or raise the limit in pagedeck.config.ts's build.islandPropsBudget:
en /products/shirt — "variant_picker" at prefix "i3f2a0b1c2d3e" carries 69109 B of props against a limit of 3072 B, over 4 top-level props, the 3 largest:
"entry" — 66804 B
"variants" — 2250 B
"price" — 9 B
The island is named twice, and each name does a different job. The registry name
says which component to open. The prefix says which instance, because a page
can render one island twice with different props, and finds it
in the HTML. The props are cut at three on the chunks' argument: a prop is
evidence about the breach, the largest is almost always the one to drop, and
budget-report.json holds every one. Every island over the limit on every page
is reported, by rule 5.
A page over both limits gets both paragraphs in one throw, the JavaScript one
first. Both are ConfigError and exit 2, so a split would keep no
classification, and they cannot share a headline because the fixes are two
different edits.
inlineStyleElements (packages/core/src/critical-css.ts) collects over every
flagged page and throws once, as two paragraphs, on assertBuildSection's
argument for joining and loadComponents' argument for not merging: both are
ConfigError and exit 2, so a split would preserve no classification, and the
two fixes are two files — a spelling drift in this repo, and a byte in the
site's own stylesheet.
The first is coverage this feature would otherwise lose. checkSiteLinks reads
<link href>, and a sheet that is inlined has no link for it to see, so an
inlined href nothing emitted would ship a page that is silently unstyled rather
than one that 404s. It is refused in checkSiteLinks' own words, because it is
the same drift reaching the same reader by a different door:
Critical CSS: 1 inlined stylesheet was never emitted, so the page it is on would render unstyled — the inlined URL and the file name must be one spelling: see chunkPath in client-build.ts:
"/assets/gone.css" — inlined into en /landing, emitted by nothing
The second refuses a stylesheet holding </style, which an HTML parser reads
as the end of the element — putting the rest of the sheet into the page as
markup. Escaping it is not on the table: this build promises never to edit a
stylesheet's bytes.
Critical CSS: 1 stylesheet cannot be inlined because it holds "</style", which ends the element early and puts the rest of the sheet into the page as markup — remove the "</style" sequence from the stylesheet, or drop the page from build.criticalCss so the sheet is linked instead:
"/assets/quote.css" — inlined into en /landing, "</style" at line 2, column 20
The position is what makes that line diagnostic on its own, and it is a position rather than a quote because both halves of this document apply at once. Rule 5 asks every line of a collected report to say why it is there, and "this sheet holds it" leaves a reader searching a bundler's minified output by hand; rule 6 governs what may be quoted, and a snippet of the sheet around the match would put a site's own bytes in a CI log to say what two numbers say exactly. Line and column, because a bundled sheet is one very long line where the column is the whole answer, and a hand-written one is the other way round. That is rule 6's "name the position instead of the value" used where rule 5 asks for a reason — the two rules agree here rather than trading off.
Both messages are asserted character for character in
packages/core/src/critical-css.test.ts, which is what inlineStyleElements
taking two plain maps instead of a ClientBuild is for: a refusal reachable
only through a whole fixture site is a refusal nobody pins.
buildClient (packages/core/src/client-build.ts) reports the two specifier
sets LoadedBuildSection.componentModules feeds it, and they stay two reports on
loadComponents' argument read at the headline rather than at the class. Both
are a ConfigError and exit 2 (rule 7), because a path or specifier the
site's config declares resolves the same way on every run until someone edits
it. Both end with the same fix sentence, UNRESOLVED_FIX, because both are one
edit to one field of one file. What differs is what each names.
The first is the tier plan's grouped specifiers, read before the emitted graph is looked at so that no later fault is measured with a broken instrument:
Client build: 2 grouped specifiers did not resolve — install the package, or fix the component's path or specifier in build.components:
"@ds/absent" — resolved against "/site/pagedeck.config.ts"
"@ds/also-absent" — resolved against "/site/pagedeck.config.ts"
The second is the specifiers a generated entry imports a component by (#235). A
tail component is in no tier group, so the grouped headline is not true of it,
and it arrives by a different door: the entry importing it is an unresolvable
import, so the bundler refuses the invocation before the report above is
reached. That refusal is a plain Error at exit 1 whose advice is to add the
specifier to build.rolldownOptions.external — the one thing spec §11 and
docs/adr/0007-islands-are-built-in-one-module-graph.md refuse, since an
externalized island module is loaded by the browser on its own and the singleton
guarantee stops at the invocation boundary. So it is caught, the component and
the entry that imports it are named, and the bundler's own error is kept as the
cause rather than dropped:
Client build: 1 component specifier did not resolve — install the package, or fix the component's path or specifier in build.components:
"Ghost" — "@ds/absent-component" — imported by the entry for en /, resolved against "/site/pagedeck.config.ts"
A collected report keeping a cause is not the exception to rule 4 it looks like. What that rule carves out is several failures competing to be the cause, and this invocation stopped once: there is one thrown object, so there is no choice to make about which error caused this one, and the lines are read out of the build's resolver record rather than out of errors. The bundler's error is an aggregate, but it stops at the first import it cannot place — two unresolvable component specifiers still arrive as one "Build failed with 1 error" naming one of them. So there is no second message to quote on the second line, and the one there is carries what the record cannot: the module the invocation gave up on.
Which of the two it is, is read off the build's own resolver record and never
off the bundler's message. The failure arrives as a plain Error holding more
plain Errors, with no code, no kind and no field naming the specifier, so the
only thing in it that says "unresolved import" is prose a bundler release is
free to reword. resolveModuleIds answers in buildStart, before a module is
loaded, with the same resolver and the same origin the invocation itself uses,
so its record is complete whatever the invocation went on to fail at — and a
failure it does not account for is rethrown exactly as it arrived, which is the
line island-facts.ts draws in the same shape: a ConfigError around every
bundler rejection would call a crash in Rolldown the site's wiring.
scanIslandFacts resolves the same map earlier in a full pagedeck build and refuses
an uninstalled package there first, in unresolvedReport's own words. What
reaches this one end to end is a specifier that resolves for the scan's SSR
invocation and not for the browser build — a package exported under the node
condition alone. #235 measured that case on a spawned pagedeck build;
client-build.test.ts pins the report directly rather than spawning one.
defineLocales (packages/core/src/locales.ts) collects at the door a locale
map is written at, and both halves of this rule land in the one function. A
domain that is not a bare host (#319) and a fallback naming a locale the map
does not declare are two paragraphs of one throw, on assertBuildSection's
argument: a locale map is one object literal, both faults are the site's own
wiring at exit 2, and neither report is measured on what the other reads — so
they join, and they keep separate headlines because a host and a locale code are
fixed differently. One domain can carry several faults, so evil.com/# gets a
line each for the path and the fragment, and every line repeats the value for
listDueEntries' reason. Every value is quoted through quoteAddress for rule
6's: a domain holding userinfo is a credential written into a config field.
Locale set: 1 locale declares a domain that is not a bare host — write the host a locale's pages are served from and nothing else, as domain: "example.de":
"de": "https://example.de" — the domain opens with the scheme "https:", and the origin supplies the scheme
Locale set: 1 locale falls back to a locale that is not declared — declare the target, or point the fallback at a declared locale:
"de" falls back to "de-AT"
A declared domain the scan finds nothing else wrong with must still have a tree
key that is a host name (#677): dot-separated labels of 1 to 63 ASCII letters,
digits and hyphens, none starting or ending with a hyphen, 253 characters at
most, with one trailing dot allowed. The rule reads the tree key, the host the
URL parser reads out of the declared domain, so münchen.de passes as
xn--mnchen-3ya.de, and a bracketed IPv6 literal the parser accepts passes as
it is. The parser turns .., %2e%2e and 。。 all into .., whose labels
are empty, and a tree keyed .. would be written beside the output directory
rather than inside it. Every locale the rule refuses gets a line of its own:
Locale set: 2 locales declare domains that are not bare hosts — write the host a locale's pages are served from and nothing else, as domain: "example.de":
"de": ".." — the domain parses to a tree key that is not a host name, so it cannot key a tree under the output directory; a host name is dot-separated labels of 1 to 63 ASCII letters, digits and hyphens, none starting or ending with a hyphen, at most 253 characters in all and optionally ending in one dot
"de-AT": "-a.com" — the domain parses to a tree key that is not a host name, so it cannot key a tree under the output directory; a host name is dot-separated labels of 1 to 63 ASCII letters, digits and hyphens, none starting or ending with a hyphen, at most 253 characters in all and optionally ending in one dot
The cycle report stays a throw of its own, and that is the boundary above rather than an exception to it: it walks the graph the fallbacks describe, and a fallback naming nothing is a hole in that graph, so a cycle report collected beside the paragraphs here would be a partial answer printed as a whole one.
readManifest (packages/core/src/manifest.ts) collects over a manifest of
the current version whose fields are not what pagedeck build writes (#529). The
version check comes first. After it, the document is compared against every
field Manifest declares, at every depth: the top-level objects, each files
and pages row, the routing document and the tier plan. Each field that is
absent or of the wrong type gets a fault, and the refusal names all of them:
Manifest "/site/dist/manifest.json": 2 fields do not hold what pagedeck build writes there (pages[0].dependencies: expected a list, found nothing; tiers.groups: expected a list, found nothing), which pagedeck build never writes — run pagedeck build, and read the manifest it writes in place of this one
Six readers take fields off a parsed manifest: the incremental planner,
pagedeck rollback, pagedeck diff, pagedeck deploy, the retention store and parity. Before
this check, a hand-edited or truncated document at the current version reached
them unchecked, and each one threw a TypeError far from the file. The check is
in the one reader they share, so a seventh reader gets it too. It checks types
only, and each fault names the field and the type it found. It quotes no value,
so no byte of the document reaches the log.
The faults are on one line, not one line each. Two callers put this message
into their own report as one line and pass it through printable: the full
build's warning that it could not prune, and the retention store's warning.
printable would change each line break into a replacement character.
The fix is the same for every reader. A document with these faults cannot be
repaired to the document a build wrote, so the fix is a new one: pagedeck build
writes a whole manifest, and the reader then reads that one. This holds for
pagedeck build --incremental, which reads the new manifest in the output directory.
It also holds for pagedeck diff, pagedeck rollback, deploy and parity, which read the
document the operator points them at.
What a value means is not checked here: a kind outside the four, a path that
goes outside the output tree, two rows at one key. The readers that act on
those values check them where the value decides a deletion (untrustedRows in
packages/core/src/build.ts, indexFiles in packages/core/src/diff.ts).
6. Redact anything that could be a credential
Presigned snapshot URLs carry their credential in the query string, and CI
secret masking will not catch a URL that CI composed itself. Every snapshot
message and success line goes through redactTarget
(packages/core/src/snapshot.ts), which keeps scheme, host and path and drops
the rest. Apply the same rule to any new message that could hold a token.
Redaction covers messages, and that is all it can cover. A target passed as
pagedeck store pull <url> is an argument of the process before it is ever a string
in a message, and the two places it lands there are outside this document's
reach: /proc/<pid>/cmdline is world-readable on stock Linux, so any other user
on a shared CI runner can read it out of a running pagedeck, and most CI providers
echo the run: line they are about to execute, arguments and all. A careful
in-message redaction defeated one layer up is not a defence, and calling it one
would be worse than not having it.
So pagedeck store reads its target from PAGEDECK_SNAPSHOT_URL when the command line
names none (#117). What does the work is that the target is never named on the
command line: the process reads the variable rather than being handed its value,
so nothing lands in cmdline and nothing is echoed. A workflow written as
pagedeck store pull "$PAGEDECK_SNAPSHOT_URL" gets none of that — the shell expands the
variable before pagedeck starts — and it is the argument-less form that is worth
documenting. Even that is a smaller exposure rather than none: a workflow that
echoes the variable puts the credential back in the log, and no framework code
can stop it. What the framework can do is stop being the reason it is there.
Both sources given at once is refused rather than ranked, and the refusal quotes
both through redactTarget like every other message — a refusal that printed the
URL whole would be this rule's own failure.
A deploy through presigned URLs quotes keys, never URLs (#652).
deploy.bin.ts reaches a presigned origin through a file of presigned URLs named by
PAGEDECK_DEPLOY_URLS, and every URL in it carries a credential. No message
redacts one, because no message prints one: each refusal names the method and
the deploy key the URL is listed under, which is also what the operator has to
fix. A key or a field is quoted through redactTarget, because a map written
backwards puts a URL where the key belongs. A URL with a user name or password
is refused, because fetch refuses one with a message that quotes it whole. A
request that fails keeps its cause, copied with the URL and its query string
cut wherever the chain repeats them. The file's own path is quoted, because a
path is not a credential. A file that is not JSON is refused without the
parser's message, because Node's JSON.parse quotes the text around the fault,
and the text is URLs:
Deploy URLs "/ci/signed.json": is not valid JSON — write it as {"get": {key: URL}, "put": {key: URL}, "delete": {key: URL}}; the parser's own message is not printed, because it quotes the file and the file holds credentials
Deploy URLs "/ci/signed.json": could not be opened (ENOENT) — point PAGEDECK_DEPLOY_URLS at the file the signing step wrote
Deploy URLs "/ci/signed.json": 5 entries cannot be used, and nothing was sent — fix each in the signing step that wrote the file:
get "/manifest.json": is a http: URL — presign it over https, because a presigned URL carries its credential and a deploy sends bytes only over https
put "/index.html": names a loopback host — point it at the origin, not at this runner
put "/app.js": carries a user name or password before its host — presign it without one; a presigned URL carries its credential in the signature
put "https://bucket.example/site/about.html": is not a deploy key — a deploy key starts with "/" and holds no ".", ".." or empty segment, no backslash and no control character
put "/en/index.html": names a different host, or a different path before the key, than the other URLs do, so it could write another key — sign every URL for one origin
Deploy: PAGEDECK_DEPLOY_URLS holds no PUT URL for 2 keys this apply writes, so nothing was sent — re-run the dry run with --requests, sign every key it lists, and apply with the file that signing writes:
"/assets/app-a1b2c3.js"
"/.pagedeck/manifests/b2.json"
Deploy: PAGEDECK_DEPLOY_URLS holds no DELETE URL for 1 key this prune deletes, so nothing was sent — re-run the dry run with --prune and --requests, sign every key it lists, and apply with the file that signing writes:
"/assets/app-000000.js"
Deploy: the prune would delete 1 key the deploy writes for itself, so nothing was sent — no build emits such a key, so the document in the origin's deploy history that names it was not filed by an apply; replace that document with the manifest.json its build wrote, and the prune plans no DELETE for the key:
"/manifest.json", named by "/.pagedeck/manifests/b0.json"
Deploy history index "/.pagedeck/deploy-history.json": is not valid JSON — rewrite it as {"builds": ["<build id>", ...]}, naming each build whose document is under "/.pagedeck/manifests/", or delete it: the next apply writes a new one, and a prune then never finds the files only the builds it lost had named
Deploy history index "/.pagedeck/deploy-history.json": is larger than 24600014 bytes, the most an index of 100000 builds of 243-byte ids takes, so the rest was not read — rewrite it as {"builds": ["<build id>", ...]}, naming each build whose document is under "/.pagedeck/manifests/", or delete it: the next apply writes a new one, and a prune then never finds the files only the builds it lost had named
Deploy history index "/.pagedeck/deploy-history.json": names 100001 builds, more than the 100000 an index holds — rewrite it as {"builds": ["<build id>", ...]}, naming each build whose document is under "/.pagedeck/manifests/", or delete it: the next apply writes a new one, and a prune then never finds the files only the builds it lost had named
Deploy history index "/.pagedeck/deploy-history.json": entry 2 is a build id of 244 bytes, longer than the 243 a "<build id>.deployed-at" file name can hold — rewrite it as {"builds": ["<build id>", ...]}, naming each build whose document is under "/.pagedeck/manifests/", or delete it: the next apply writes a new one, and a prune then never finds the files only the builds it lost had named
Deploy of "/old.txt": PAGEDECK_DEPLOY_URLS holds no DELETE URL for this key — re-run the dry run with --prune and --requests, and sign every DELETE it lists
Deploy of "/../sentinel.txt": is not a deploy key — a deploy key starts with "/" and holds no ".", ".." or empty segment, no backslash and no control character, so it could name a file outside the origin at "/srv/origin"
Deploy: the origin holds no "/manifest.json", but its history index "/.pagedeck/deploy-history.json" names 2 builds, so the origin is damaged rather than new — put the document of the build it last served, "/.pagedeck/manifests/<build id>.json", back at "/manifest.json", or pass --from a copy of it; that build is the one whose "/.pagedeck/manifests/<build id>.deployed-at" holds the newest instant
Deploy read of "/manifest.json": PAGEDECK_DEPLOY_URLS holds no GET URL for this key, which the run reads before it plans — sign a GET for it; a dry run with --requests lists every key to sign
Deploy read of "/manifest.json": the host answered 403 to GET — re-presign the URL, and check that the credential it was signed with may read this key. The URL is not printed: it carries the credential in its query string.
Deploy read of "/manifest.json": the request failed — check that the origin is reachable from this runner. The URL is not printed: it carries the credential in its query string.
Deploy of "/index.html": the request failed — check that the origin is reachable from this runner. The URL is not printed: it carries the credential in its query string.
The last three exit 1, and every other one exits 2. The command line has five refusals of its own, and a rollback to a presigned origin has four more. Each of the first five is followed by the usage text:
Option "--origin" and PAGEDECK_DEPLOY_URLS are both given, as "../../.origin" and "/ci/signed.json" — two sources for one origin are refused rather than ranked; pass one.
No --origin given, and PAGEDECK_DEPLOY_URLS is not set.
Options "--requests" and "--apply" are given together — --requests lists what a dry run would ask the signing step for, and an apply signs nothing; drop one.
Option "--requests" names "/ci/signed.json", which is the file PAGEDECK_DEPLOY_URLS names — the dry run would write the requests over the presigned URLs; write the requests to another file.
Option "--requests" is given without PAGEDECK_DEPLOY_URLS — it lists the requests a presigned origin needs signed, and a directory origin needs none; set PAGEDECK_DEPLOY_URLS, or drop "--requests".
Rollback to build "b1": there is no manifest at "/site/dist/manifest.json", so there is no tree to restore from — pass --out the tree build "b1" wrote
Rollback to build "b1": the tree at "/site/dist" is build "b2", and a rollback uploads the restored build's own bytes from that tree — pass --out the tree build "b1" wrote.
Rollback to build "b1": the origin holds no "/manifest.json", so no build is live to roll back from — deploy the build without --rollback
Rollback to build "b1": the origin's deploy history holds no "/.pagedeck/manifests/b1.json", so the origin never served that build — name a build the history holds
The signing step quotes variable names, never values (#665).
presign.bin.ts is the one process that holds the credential, so its refusals
name the variable a value belongs in and print none of the five. The endpoint
is withheld as well: it carries the account id. A requests file that is not
JSON is refused without the parser's message, like the deploy's own file:
Signing takes two files — pass the requests file deploy.bin.js --requests wrote, then the file to write the URLs to.
Signing: "requests.json" is both the requests and the file to write — write the URLs to another file.
Signing: 4 variables are not set — set each from the repository's secrets, in the step that signs and in no other: PAGEDECK_S3_ENDPOINT, PAGEDECK_S3_REGION, PAGEDECK_S3_BUCKET, PAGEDECK_S3_ACCESS_KEY_ID
Signing: PAGEDECK_S3_ENDPOINT is not an https: origin with no path, user or query — set it to the bucket host's S3 endpoint, such as https://<account id>.r2.cloudflarestorage.com; its value is not printed
Signing: "/ci/requests.json" could not be opened (ENOENT) — pass the file deploy.bin.js --requests wrote
Signing: "/ci/requests.json" is not valid JSON — pass the file deploy.bin.js --requests wrote; the parser's own message is not printed, because it quotes the file
Signing: "/ci/requests.json" is not an object — pass the file deploy.bin.js --requests wrote
Signing: "/ci/requests.json" has 3 entries that cannot be signed, and nothing was signed — pass the file deploy.bin.js --requests wrote:
"post": is not a field the deploy reads — the fields are "get", "put" and "delete"
get "manifest.json": is not a deploy key — a deploy key starts with "/" and holds no ".", ".." or empty segment, no backslash and no control character
"put": is not an object of key to request — write it as {key: {}}
The first is followed by the usage text. Every one exits 2.
A redactor is written for an input set, and a new door widens it. Reading a
target from a variable rather than an argument changed what redactTarget has to
survive, and the four shapes it gained are worth naming because the next
credential-bearing input will arrive the same way. A URL with no authority —
data:, mailto: — keeps its whole payload in pathname, so "scheme, host and
path" was the entire string; one of those is now cut to its scheme, file:
excepted, whose path is its address. A fragment is cut like a query, at the
delimiter and keeping it, which is unusableReason's spelling above rather than
a second one. And a value holding a newline could forge a line into a report, so
control characters are replaced with U+FFFD — the same thing an unprintable
code unit already reaches a log as. The fourth arrived later through the same
door (#320): an interpolation that lost its scheme — //AKIAX:SECRET@bucket… —
is not a URL at all, so it never reaches the parsed branch. Nothing on that
branch strips userinfo either. It composes its quote out of URL.protocol,
URL.host and URL.pathname, and userinfo is absent from it because host
excludes userinfo, not because a rule removes one. The fallback has no parse to
compose from, so it carries the rule instead, and that rule runs in the opposite
direction to the delimiter's: a query or a fragment means drop what follows,
userinfo means drop what came before, so the span before the @ is replaced
where it stands and what is left of the string names the target.
The fallback's rule is broad on purpose, and the breadth is its residual
risk. It replaces everything before the last @ standing ahead of the
target's query, whether or not the URL grammar would call that span userinfo, so
an identifying prefix is spent whenever an @ appears anywhere before the
query: /reports?notify=someone@example.com is quoted …@example.com, the mail
host kept and the path that said which report was meant gone. That is the
deliberate direction. A narrower rule bounded to the authority — where RFC 3986
ends userinfo — was tried first and returned
https//AKIA:SECRET@bucket.example/store.db whole, one dropped colon being
enough to put the credential past the bound; a value reaches the fallback by
failing to parse, so reading it with the grammar is reading a string already
shown not to obey one. redactSource below judges the span in front of an @
instead of redacting in front of every one, and this rule does not: a target is
one string out of a CI secret store, and no message here has to name a file
inside it. None of that makes the function a sanitizer: a path is still printed
whole, and the CLI's real answer to a presigned target is to keep it out of the
process table.
One rule, at every door that quotes a value a site wrote. Three functions
made this cut and only one of them made it whole, which is the shape of #378
and #383. redactOrigin bounded its span to the authority and a slash in front
of the @ — a dropped colon, a stray /, a password holding one — walked past
the bound; redactSource (packages/core/src/quote.ts, and its copy in
colors.ts) dropped no userinfo at all, so an image source or a script source
behind basic auth printed its credential whole, and printed it whole even when
the query cut fired, because that cut takes what stands behind a delimiter and
userinfo stands in front of one. Both are redactSource now: everything between
a leading scheme and the last @ goes, and the query or fragment is cut after
it. The fallback above is the third function and keeps its own rule, for the
reason stated there.
Which @s it fires on is narrower than "every one of them", and the narrowing
is what keeps these messages legible. A rule that redacted in front of every
last @ quoted three different failing CDN sources as "https://…@2x.jpg", two
of the three lines identical byte for byte, inside a report whose own reasoning
is that the source is the thing a reader acts on; a link report quoting
https://…@pedro spends the host it exists to name. So the span in front of the
last @ is judged, on the value with any scheme:// taken off it, and it is
taken for a credential when it holds no /, or holds a :, or holds a second
@. https://user:s3cret@cdn.example.com/hero.jpg,
https://user:s3c/ret@example.com, https//user:s3cret@example.com,
user:s3cret@example.com and mailto:tok@example.com all still lose every byte
in front of the @. Every other @ is one a path wrote, and the value is kept
whole with its host and its file name: /hero@2x.png,
https://cdn.example.com/photos/hero@2x.jpg, https://mastodon.social/@pedro,
./components/@ui/Button.tsx. What breadth is left is paid by a span with no
path in front of it, which is where a scoped specifier sits — @pagedeck/islands is
quoted …@pagedeck/islands — and that is the same trade every paragraph above makes.
The rule's residual is written down rather than hidden. A credential holding
a / and carrying no :, standing alone in front of a single @, is not
redacted: https://b64/tok==@host/x is printed whole, which is a
standard-base64 key written as bare userinfo. It is accepted rather than missed.
Basic auth — the shape that puts a credential in front of a host in the first
place — always carries the : the second clause reads, and the token forms
written bare hold no /: ghp_, glpat-, a JWT and a hex digest are all
slash-free. A span that does not say what it is cannot give a narrower residual
than that, and the wider rule is the one this replaced. quote.test.ts pins
both sides of it, the shapes now kept whole beside this one.
A route is cut the same way and for the same reason. unusableReason in
packages/core/src/pages.ts quotes a route only as far as its ? or #,
keeping the delimiter so the reader still sees which of the two it was:
Route table: 1 route is not a usable path — return a path like "/pricing", or undefined to emit no page:
sources[0] "pages" /en/home — the route holds a query or fragment: "/search?…"
A route holding a malformed percent-escape is cut the same way, at the % that
failed:
Route table: 1 route is not a usable path — return a path like "/pricing", or undefined to emit no page:
sources[0] "pages" /en/home — the route holds a malformed percent-escape: "/a%…"
A route holding a lone surrogate is cut at the surrogate, the same way:
Route table: 1 route is not a usable path — return a path like "/pricing", or undefined to emit no page:
sources[0] "pages" /en/home — the route holds a lone surrogate: "/a\ud800…"
The kept character there is the offending code unit itself. A lone surrogate
has no UTF-8 spelling, which is the fault being reported, so quoteIdentifier
writes it as a \ud800 escape (#730). The words are what name it; the quote is
only the prefix that says which route failed.
canonicalizePath makes every one of these cuts on a path handed to it
directly, and tests the delimiter first for this rule's sake:
/x?token=SECRET%2 cut at its % would quote the token, cut at its ? quotes
nothing past the delimiter. The order is a fixed precedence, not a search for
the leftmost fault: a path is cut at the match of the first test it fails,
wherever that lands, so /a<U+D800>%2 is cut at its %, to the right of the
surrogate. What that buys is the rule above — append a test at the end and it
cannot change the cut for any path an earlier test already matched, so the
delimiter's cut stays first however many refusals there are.
The cut is written there rather than reusing redactTarget, which parses a
URL and would leave a fragment whole on a string that is not one.
Where there is no prefix worth keeping, name the position instead of the value.
A route returned as segments is refused when one of them is ., .., empty, or
holds a lone surrogate — the first three survive percent-encoding and then
resolve away path structure, the last is the one value encodeURIComponent
cannot spell — and each offending segment is named by index, never quoted:
Route table: 1 route is not a usable path — return a path like "/pricing", or undefined to emit no page:
sources[0] "pages" /en/home — the route holds a segment that is not a path segment: segments[1] is a dot segment
A header value is cut the same way and named rather than quoted. A header set
is authored config, and a value is exactly the field that can hold a token, so
packages/adapter-nginx/src/nginx.ts locates one by its field name and the prefix it
sits under and names the character that broke, never the value. A path or a
prefix in the same report is quoted: it has already been through
canonicalizePath, so it holds no query and no fragment, and it is what the
author edits.
The segment is the param value, so quoting it would put a slug in the log for
nothing: the index is what the author edits. Rule 5 applies inside the line as
well as across the report, so every offending segment of one route is listed.
unusableSegmentReason in packages/core/src/pages.ts.
A build id is quoted through a door of its own, and the marks are the
door's. racedDeployReport (packages/core/src/diff.ts), the retention
store's three refusals and its misfiled-document warning
(packages/core/src/retention.ts) and pagedeck rollback's collected report
(packages/core/src/cli.ts) all name build ids, and an id is not the
framework's: runBuildVerb mints a uuid, but readManifest checks only that
an id is a string, and unusableId answers only whether an id can be a
path — a newline, a " and an erase-line escape are none of the shapes it
refuses. So an id read back off disk, or composed by a CI wrapper out of a
branch name, is arbitrary bytes arriving in a report that bin.ts marks per
line. Every one of those messages calls quoteIdentifier
(packages/core/src/quote.ts), and the quotation marks around each id come
from JSON.stringify inside it rather than from the sentence around it — a
value that supplied its own " would otherwise close a hand-written quotation
and write the rest of the line.
One of them now quotes a path through the same door, and that is a second
kind of value at it rather than an oversight. The store's third refusal names
the file it could not open, and a retained document's file name is the build
id with .json after it — so the bytes that make an id dangerous here are in
the path too, and quoting the path by hand while quoting the id through the
door would leave the newline in the half that was not escaped. misfiledWarning
had already brought a readdir name through it for the same reason. What the
path must not meet is the other rule's cut, and both of its halves reach a
path. The delimiter half fires on a # or a ?, which unusableId permits:
/site/.pagedeck/manifests/run#7.json is quoted "/site/.pagedeck/manifests/run#…", the
file name gone. The userinfo half fires whenever the span in front of the id's
last @ also carries a : or a second @ — credentialEnd's second and
third clauses, which a store path cannot escape through the first, that one
asking whether the span holds no /. It is the worse cut, because it replaces
everything in front of the @: /site/.pagedeck/manifests/main:v1@sha.json is
quoted "…@sha.json" and /site/.pagedeck/manifests/a@b@c.json is quoted
"…@c.json", the store's own directory gone from a sentence whose job is to
say which file to open. An id carrying a single @ and no : in front of it
is kept whole — main@sha, and @pagedeck and hero@2x alike.
Either half alone makes quote the wrong door here.
It is not quote, and the difference is this rule having two halves rather
than one. The cut argued for above is aimed at a value that addresses
something, because that is the shape a credential gets pasted into. unusableId
permits @, # and ?, so <branch>@<sha> is an id a CI wrapper mints
without being asked to, and quote renders main@9e1f4a02 as …@9e1f4a02.
That is the raced-deploy line naming the racing build with the half that
identifies it gone, and the store's refusal listing, as the builds to roll back
to, ids that are not the ids on disk — rule 2 lost in one message and rule 3 in
the other. So the identifier door escapes and cuts nowhere, and what that costs
is written down at the function rather than left implicit: an id a wrapper
minted out of a credentialed URL prints whole.
What the operator typed goes through the same door (#398). Five parsers in
packages/core/src/cli.ts echo command-line input into their refusals:
parseSyncArgs, parseBuildArgs, parseDevArgs, parseDiffArgs and
parseRollbackArgs, for an unknown option and for a flag's value. runCli
does the same for an unrecognised verb, runStoreVerb for an unrecognised
pagedeck store subcommand, resolveSnapshotTarget for a target given twice, and
pagedeck diff for a manifest path it cannot read. The two bins in packages/site
do the same (#519): deploy.bin.ts for an unknown option, an option given
twice, --rollback beside --from, the rollback's build id and the manifest
paths built from --out and --from, and parity.bin.ts for an unknown verb,
an unknown option, an option given twice and the --baseline path. The
modules those bins call quote the same paths: readBuiltSite and
readBaseline in parity-read.ts for the --build and --baseline paths,
applyPlan in deploy-target.ts for the --out tree a planned file is
missing from, and readManifest for the manifest paths deploy.bin.ts reads
and passes its refusals through (#532). pagedeck diff hands readManifest the path
the operator typed too (loadAttempt in cli.ts), so the same quoting closes a
forged line there that predates #532. Each of these calls quoteIdentifier. An
option name the bin spells itself, such as "--from", is not typed input and
keeps its literal quotes. The operator who typed the value is not always its
only reader: these verbs run in CI, and other people read the stderr that CI
captures. A snapshot target goes through redactTarget first and
quoteIdentifier after, so redaction still sees the raw value. A plain value
reads as it did before, so and got "a while". in the rollback report above is
unchanged.
A cause walks past every one of these doors, so the chain is neutralised
where it is flattened. The store's missing-target refusal attaches its ENOENT
as a cause under rule 4, and Node's own text re-prints the id inside
open '<path>' with nothing done to it — so a message whose own line was
closed still reached stderr as two, the second wearing pagedeck: and then erasing
it. What runCli writes is describeError (packages/core/src/exit.ts), and
that is where every message but the first goes through printable. The first
is the framework's own and carries rule 5's layout; everything joined after it
with ": " is being pasted into the middle of a line and never had any.
Both doors cover the C1 controls as well as C0 and DEL (#520).
JSON.stringify escapes C0, " and \, but it leaves U+007F–U+009F, U+2028
and U+2029 raw, so quoteIdentifier writes each of those out as a \uXXXX
escape after it. The output is still a JSON string literal that reads back to
the value. printable replaces U+0000–U+001F, U+007F and U+0080–U+009F with
U+FFFD. U+009B is the single-byte CSI, which a terminal that honours 8-bit
controls reads as ESC [: without this, \u009B2K erases a line there as
\u001B[2K does everywhere. U+2028 and U+2029 are not terminal controls, so
printable leaves them alone.
quote (packages/core/src/quote.ts) and its copy for the color probe's
answer, quoteValue (packages/content/src/colors.ts), now do the same
(#742). Each passes its JSON.stringify output through
escapeUnescapedByJson, which is the step quoteIdentifier takes after
JSON.stringify. Each passes a value JSON.stringify cannot write, such as a
symbol, through printable instead. Those two quote the hrefs and URLs that
the link check and a content-relative reference report, an image source, and
the probe's answer, and a CMS can supply any of them. The undeclared-locale
refusal of href quotes its code through quoteIdentifier, because page code
can pass an entry's locale there.
An ordinary id reads exactly as it did before any of this, which is why the two lines below are byte for byte the ones §5 above already quotes:
Manifest diff: build "b3" was built on "b9" and is being deployed over build "b2", so another deploy wrote this site after this build read it — re-run pagedeck build so it is based on what is live, or pass --force to overwrite that deploy
Retained manifest "b9": is not in the store at "/site/.pagedeck/manifests" — the retained builds are "b1", "b2", so roll back to one of those, or raise build.retention.keep before the build you want is pruned
An entry id is the loader's, so every collection message that names one
neutralises it first (#730). A loader reports the id, and a CMS loader takes
it from the CMS. refusedFieldReasons (packages/content/src/collection.ts)
refuses a NUL, a backslash, a leading / and a .., and lets ESC, CR, a
newline and U+009B through, because those are not path faults. Each of these
messages is a first message, which describeError prints as it is. So a sync
used to print an id holding ESC [2K as a line that erased itself and wrote
pagedeck: sync complete in its place. Both doors now live in
packages/content/src/escape.ts, exported as @pagedeck/content/escape, and
@pagedeck/core re-exports them, so there is still one printable and one
quoteIdentifier. The subpath imports nothing, so a browser bundle that takes
core's quoting does not take node:sqlite with it.
Where rule 2's layout puts the id at the head of a line, as /locale/path:,
the id goes through printable. There are no marks around it for a " to
close, and an ordinary id prints as it always did. These are the unusable-id
refusal, validateEntry's schema-failure lines and invertedWindowReport's
lines. A schema-failure line passes whole, because the field name is a key
from the entry's data. An inverted-window line also passes the two instants,
which are entry content. A build reaches the same ids later, so the route
table's reports and a layout's entry reports pass the id through printable
too. Here is the refusal for an ordinary id, and then for
an id holding ESC, CR, a newline, U+009B and a NUL:
Collection "pages": 1 entry does not have a usable entry id — an entry id names an entry rather than a file path, so pass an id with no "..", leading "/", backslash or NUL, spelled literally or percent-encoded, and no tab, newline or leading space a URL parser would drop:
/en/../secret: its path holds a ".."
Collection "pages": 1 entry does not have a usable entry id — an entry id names an entry rather than a file path, so pass an id with no "..", leading "/", backslash or NUL, spelled literally or percent-encoded, and no tab, newline or leading space a URL parser would drop:
/en/x�[2K�pagedeck: sync complete��2K�/../y: its path holds a NUL and a ".."
Where a message puts the id inside quotation marks, the id goes through
quoteIdentifier. These are getEntryCached's refusal of an unusable id, a
validator that threw, a write that failed and an entry absent right after its
write. A template name that templateOf read out of an entry's data takes
the same door. An ordinary id reads as before. A backslash now prints as
\\, which is what JSON.stringify writes:
Collection "pages": entry id "/en/x\u001b[2K\rpagedeck: sync complete\n\u009b2K\u0000" is not a usable identifier — its path holds a NUL — an entry id names an entry rather than a file path, so pass an id with no "..", leading "/", backslash or NUL, spelled literally or percent-encoded, and no tab, newline or leading space a URL parser would drop
colorFailureWarning takes both doors. An image source is the entry's, so it
is quoted through quoteIdentifier after redactSource cuts it. The probe's
own text goes through printable, because a probe that throws often names the
source it failed on.
Validator text is the one exception, and it is deliberate. §2's schema
failure interpolates issue.message from zod, valibot or arktype verbatim
apart from its control characters, which printable replaces (#730), and
several validators quote the value they received — zod's enum mismatch renders
… received '<value>'. So a CMS field value can reach a CI log. That is the
trade §2 makes on purpose: a schema failure that named the field but not what
was in it sends the reader back to the CMS to find out what the message could
have told them. The boundary is what the value is. A content value is
content, and the log it lands in is the log the site's own content is built
from. A credential is not content, and nothing that could hold one — a URL, a
target, a header — is exempt: those go through redactTarget, whatever the
message is about.
A message that quotes values of both kinds decides per value, not per
message. quotedValue (packages/core/src/head.ts) is the case: a head
claim's value is a title or a description on one page and an og:image URL on
the next, and the same line prints either. So an absolute URL goes through
redactTarget; a path-shaped value — one beginning / or . — is cut at its
first ? or # with the delimiter kept, unusableReason's cut rather than
redactTarget's, for the reason that function is not reused above; and anything
else is quoted whole, because it is content and the report's job is to let a
reader tell two values apart. A title ending in a question mark is a likelier
input than a credential written with no scheme and no leading slash, and cutting
every value at its ? would redact the first to protect against the second.
7. Classify by throwing the right class
The exit code comes from the class, not from the prose, so CI can branch
without parsing text (packages/core/src/exit.ts).
Throw ConfigError — exit 2 — when the fault is in how the site is wired and
will fail identically on every retry until a human edits something: a missing
or malformed config, an unsupported snapshot URL scheme or a snapshot target on
a loopback or link-local host (packages/core/src/snapshot.ts), a command line
the CLI does not understand
(usageError in packages/core/src/cli.ts).
A package that cannot import ConfigError declares its own wiring-fault
class. ConfigError lives in @pagedeck/core, the orchestrator that consumes
every other package, so a package @pagedeck/core depends on cannot import it
without inverting the dependency. The rule is not to move the class and not to
downgrade the fault to exit 1: declare a class local to the package, name it
for the thing that is miswired, and add it to isWiringFault in
packages/core/src/cli.ts. That function is the single place the split is
resolved, so however many classes there are, CI sees one exit code.
Four exist today:
ConfigError(packages/core/src/exit.ts) — the config and the command line, as above. Also aroutecallback that returns something no page can be built from — a query, a fragment, a malformed percent-escape, or a lone surrogate — and an entry in a locale the site never declared (packages/core/src/pages.ts). A path handed straight tocanonicalizePathcarrying any of the four is the same fault reaching the same class by a different door.The lone surrogate is there because
encodeURIthrowsURIErroron one, andURIErroris a class this rule does not name: the run exited by an unclassified path with the message "URI malformed", which names no collection, no entry and no fix. Refusing it in the wiring pass is what gives it a class, and it is refused only because the encoder cannot take it — not as a well-formedness policy over Unicode. A route returned as segments is encoded byencodeURIComponentinstead, before the wiring pass reads anything, so that form is refused a step earlier byunusableSegmentReason— same predicate, same class, same report.RegistryError(packages/islands/src/registry.ts) — a component registry that is malformed, a reference to a component nobody registered, a"use client"directive contradicted byhydrate: "none".StoreError(packages/islands/src/store-stamp.ts) — a provider in the root provider stack delivering a store the framework did not mint (#252). Named for the store rather than for the provider that carried it, because what is wrong is the instance: a site that callscreateStore()of its own gets a page in which every component works and no state crosses an island. It reaches a build throughrenderPage, which checks the stack before it renders a page through it, and it fails there identically on every run until the module that minted the store is edited — which is what puts it here and not on exit1. A preview entry and a dev server's page entry throw it too, where there is no exit code to pick and the class is only what says which fault it is; a shipped page reports instead (rule 8). It is declared instore-stamp.tsrather than instore.tsso thatcli.tscan name it without pulling a state library onto the CLI's graph for oneinstanceof.CollectionError(packages/content/src/collection.ts) — a template an entry names that the collection'sbyTemplatedeclares no component usage for, and a collection that declares noschemaat all (the field is required: a Standard Schema, orfalseto store what the loader hands over unchecked). Also a loader that reports an authoritative sync fromsyncSince, where only a full sync can have observed the whole source — the loader is code the site wired in, and it makes that claim on every run until somebody edits it. And a collection that declares neitherpublishFieldnorunpublishField, queried for the entries inside a publication window (listDueEntries, #36): the window a collection declares is part of its declaration, so a collection that declares none is the same fault as one that declares no schema, one field along. A collection that declares both ends, on entries whoseunpublishFieldinstant is at or before theirpublishFieldinstant, is the same argument one field further still (listDueEntries, #283) —listDueANDspublishField <= nowwithunpublishField > now, so such an entry is due at no instant at all and emitted no page while saying nothing. Collected across the collection under rule 5 rather than thrown on the first offender, because an author who transposed one pair has likely transposed several. Named for the collection because the collection's own declaration is what is at fault, and because aContentErrorwould read as the opposite of what it means: content that fails its schema is not wiring, and exits1.
None of the four comes right on a retry.
A class in a package @pagedeck/core does not depend on cannot join them, and
does not need to. That is the paragraph above pointing the other way:
isWiringFault reaches a class by instanceof, and core depends on no loader,
so naming a loader's own error class would invert the dependency rather than
respect it. The exit code arrives anyway, by a different door: a loader such as
defineExampleCmsLoader (packages/cms-example/src/loader.ts) validates its
options when it is called, and it is called while pagedeck.config.ts is
being evaluated, so loadConfig catches the throw out of its dynamic import
and rewraps it as a ConfigError — exit 2,
with the loader's own sentence on the cause chain. A fault only a request can
discover stays exit 1, correctly: that is the network and not the wiring.
@pagedeck/search reaches the same place by the other route, throwing
ConfigError itself, which rule 5 argues where localeFaultReport is quoted.
isWiringFault reads the class of the error it is handed, and never its
cause chain. That is the part worth getting right, because rule 4 wraps
failures and it is tempting to walk down to the class underneath.
A cause is whatever the failing code happened to be holding, and one of the
things it holds is an error a content-authored component threw: a RenderError
for "a component threw while rendering" attaches the component's own throw as
its cause. A walk would let a component that threw a RegistryError pick the
framework's exit code. That is content controlling the build, which is the same
fault issue #114 ruled on when it dropped content-minted island ids rather than
let them throw over the framework.
So a wrapper that needs to preserve a classification carries the class itself.
runSync in packages/content/src/collection.ts wraps a replay failure to
name the entry it happened on, and rewraps a CollectionError as a
CollectionError — everything else it wraps is a store or loader failure and
stays a plain Error. Wrap this way whenever a wiring fault can reach the
wrapper: the wrapper adds a place to look, and it must not erase whose fault it
is.
The sync verb reads the same predicate over the failures rule 5 collected,
since a run that reports its failures instead of throwing them still has to be
classified.
Throw RenderError — exit 1 — when a page will not render, or will only
render wrong: a component threw, its module has no default export, it suspended
on data the framework did not resolve, an island's props will not go into its
marker — because they do not serialize to JSON, because they use a prop name the
framework refuses such as dangerouslySetInnerHTML or __proto__, or because
they are not plain JSON data — the document composed around the render holds a
second main landmark (landmarkReport in packages/core/src/build.ts) or a
main landmark in the site's chrome (chromeLandmarkReport in
packages/core/src/build.ts), or
the render was handed a locale that is not the one the page's entry names
(refuseMismatchedLocale in packages/core/src/tree.tsx). That last one is
thrown where both paths pass rather than in renderPage: a preview builds its
tree with buildPageTree and never reaches renderPage, so a guard sited there
refused the wrong pairing on the build and let it through in front of the person
watching the page (#206). A
node that is not an island has no marker to go into, and a refused name on one
is the same class of fault for the same reason: the build spreads those props
into React, so the name is an instruction whatever else the node does or does
not do.
"Will only render wrong" is part of the criterion, not a stretch of it. A
page built under another language's locale does render: it produces bytes, and
the build stays green while the reader gets a right-to-left English page. That
is a render fault as squarely as an exception is, because what the class means
is that the output of this render cannot be shipped. A run that produces the
wrong page has failed to produce the page, and it fails the same way on every
retry until the call site is fixed — but not by editing pagedeck.config.ts, which
is what ConfigError would send the reader to do. The locale set is intact;
the pairing of locale and page at the call is what is wrong.
The landmark is the one entry in that list thrown as a ConfigError too, and
what the class follows is the subject rather than the count. A document
composed with two main landmarks is a page that renders and ships wrong, so
it exits 1 on the argument the paragraph above makes. carryDocuments
(packages/core/src/build.ts) counts the same landmarks in a document an
incremental build reads back off the tree at outDir instead of composing
(#455), and nothing rendered there at all: what is at fault is an input to the
run, and the run fails that way on every retry until the full build its report
names is run. One rule, two subjects, and the subject is what rule 7 reads.
layoutContents (packages/core/src/layout.ts) throws a RenderError too,
for an entry a layout cannot render and for a name in frontmatter.components
that the registry does not hold (#712). Both are entry content, and the entry
may come from a CMS. No ContentError exists; content that fails its schema is
a plain Error from @pagedeck/content, which exits 1 for the same reason.
A RegistryError or ConfigError here would let an author's frontmatter pick
exit 2 for a site whose config is intact, which is content controlling the
build's exit code, the fault #114 ruled on. A layout name is different: the
config writes it, so an unregistered layout is a ConfigError at load.
Exit 1
rather than 2 because the registry and the config are intact — they resolved
the component, which is what the wiring is for. What failed is the component's
own code or the content the entry gave it, the same class of fault as a loader
throwing. So isWiringFault in packages/core/src/cli.ts does not name it —
and because that function does not walk causes, the wiring-fault class a
component threw and a RenderError carries as its cause cannot promote the
run to exit 2.
A bundler hook is the one place this classification used to be lost, and it
is no longer. Rolldown flattens anything thrown out of a build hook into a
plain Error — the class gone, the cause dropped — so a ConfigError raised
there arrived at isWiringFault as something it could not name, and a site
wiring fault exited 1 (#94). Every bundler invocation a verb runs now goes
through runBundle (packages/core/src/bundler.ts), which guards each of the
framework's own hooks, records what one raised, and rethrows it once build()
has settled — unchanged, so the class this rule branches on and the cause rule 4
attaches both reach the CLI.
packages/core/src/bundler-invocations.test.ts is what keeps that the only
door: it refuses a second non-suite module importing build from vite, so a
new plugin cannot reach an unguarded invocation without a suite being written to
hand it one. A *.test.ts file may still call build() directly and ten do,
because a suite building its own fixture is measuring a plugin rather than
reporting to a reader; that test enumerates the exclusions, and they scope this
paragraph too.
The wrapper reports the first fault it recorded and only that one, which is this rule winning a direct conflict with rule 5 — several errors of different classes cannot be reported as one without flattening them, which is the fault being fixed. A plugin that wants rule 5's collection collects in its own closure and reports after the build, the way the island scan reports every unparseable module in a run.
Everything else exits 1, which means "the run did not finish" — a loader
threw, content failed its schema, a snapshot host refused, or the framework
itself has a bug. It is deliberately not a promise that a retry will help; it
only says the fault is not the site's wiring.
8. Write it through the run's channel, and mark it on the way out
A message follows rules 1 to 7 and still fails its reader if the reader cannot
tell it apart from the output of everything else in the process. A spawned
pagedeck build loads Babel and the bundler into itself, and they write to the same
stderr:
[BABEL] Note: The code generator has deoptimised the styling of .../react-dom/cjs/react-dom-client.production.js as it exceeds the max of 500KB.
So "did this build cleanly?" cannot be answered by asking whether stderr is empty — a fully successful build's stderr is not empty, and the set of tools loaded is not something this repo controls. Issue #184 ruled that the promise to keep is "nothing of mine", and rejected suppressing other people's advisories: enumerating their prefixes is a treadmill, and they are worth reading in a CI log.
Two halves follow, and new code has to hold both.
One channel. Every diagnostic the framework produces in a build process
goes to the run's io.err (CliIo in packages/core/src/cli.ts). Nothing else
in these packages writes to console.warn, console.error or
process.stderr — packages/core/src/bin.ts is the single place the channel
becomes a stream. A message written past either is a message that keeps its
text and loses its provenance.
Two console writes exist in the workspace, and they are two different things.
reportBrowserFault (packages/islands/src/browser-report.ts) is a channel in
its own right: the browser has no io.err to write to, and this rule's closing
section argues why that is this rule rather than an exception to it. It has three
callers and one write — reportRootProviderFault delegates to it, the shared
store module calls it directly (#252), and @pagedeck/search's island reports a query
it could not answer through it (#62) — which is the shape this half asks for,
and it is on its own rather than inside any of them because a site with no
provider stack must not carry the stack digest's machinery for the sake of one
line (#66 criterion 6), and a package outside @pagedeck/islands must be able to
reach it without importing a hydration runtime. The other write is a fallback — mergeComponents in
packages/islands/src/registry.ts ends options.warn ?? installedWarn ?? console.warn, and the third arm is reached only by a merge under no run and no
installed sink at all. Neither is a build diagnostic taking the short way out,
which is the thing this half forbids; count a third of either kind as the hole
#184 closed reopening.
The one call that cannot take the channel as an argument is that collision
warning in mergeComponents: it is written in the site's pagedeck.config.ts,
which the run imports and passes nothing to. installRegistryWarnings
(packages/islands/src/registry.ts) is how it reaches io.err anyway, and
runCli is its only caller — so under a run the console.warn arm above is
never taken, and what is left on it is a design system merging registries in a
process the CLI never started. Reach for the same shape only when there is
genuinely no argument to thread — a library warning from inside code the site
wrote — and restore it in a finally.
A marker per line. bin.ts prefixes every line it writes with
DIAGNOSTIC_MARKER — pagedeck:, exported from @pagedeck/core. Per line and not per
call, because rule 5's collected report is a headline plus one indented line
per failure written through one io.err: marking the headline alone would make
a four-fault report read as one, with the rest looking like somebody else's
output.
A refusal has to reach the person who can act on it, and for pagedeck dev that
decides when it runs (#433). A dev server that bound its port with no store
file announced itself as serving and then answered every request with a 500, so
the fix reached a browser, possibly on another device, and never the terminal of
the person who typed the command. So startDevServer checks that the store file
exists before the island scan and the bind, and refuses there. The check is
existence and nothing more. A file that exists and will not open still starts
the server, and each request names the store with the same fix, because
pageHandler reads the store per request so that a store pagedeck sync --watch is
rewriting is picked up without a restart.
The startup refusal is a ConfigError, exit 2, as the maintainer ruled on
2026-09-24, and that sits beside pagedeck push's "No store to push", which throws a
plain Error and exits 1 for what reads as the same absence. The asymmetry is
kept on rule 7's own test: a dev server with no store cannot answer any request
until a person runs a different verb, and retrying pagedeck dev fails identically
every time. This change leaves the push refusal classed as it was; whether it
should move to exit 2 as well is a question for its own issue.
A warning follows both halves, and says why it is a warning. The build
writes twenty-one, and each says what it cannot promise; a twenty-second is written
by pagedeck sync rather than by a build and is the last one described below; three
more are the browser's and are argued in this rule's closing section, where they
sit among the browser's refusals rather than after them.
All twenty-one reach io.err, and four of them only through one verb, and
this is the rule that has to say so, because it is the rule about the channel.
The twenty-second is reachable too, by the same shape through the other verb: syncSite
returns it on SyncReport.warnings and runSyncVerb writes it. It is described
after the twenty-one below, and kept out of their count because pagedeck sync is what
writes it and no build ever does. Seventeen — resourcePlacerWarning,
uncompiledGlobalCssWarning, workerFallbackWarning, workerConsentWarning,
unloadedScriptWarning, undeclaredHeadersWarning,
undeclaredContentRootWarning, unprunedTreeWarning, checkSiteLinks' two,
probeExternalLinks' three, retainManifest's two and compileIslands' two —
are collected into SiteBuild.warnings by buildSite and written by
runBuildVerb. All seventeen are reachable; one of them was not until #270, and
its own section below records what closed it — a site's redirects now reach
planRouting through build.routing. The last two travel one stage further to
get there, and #242 is that stage: a bundler plugin has no io.err to write
to, so compileIslands records them and buildClient hands them back on
ClientBuild.warnings, which is what buildSite collects.
The four #29 adds, driftWarnings' two and compileSupplements' two, are
strings shaped for that same field, and #281 gave them the caller they were
waiting on: buildSite (packages/core/src/build.ts) runs checkDrift and
compileSupplements on a run given incremental: true, and puts both sets of
warnings into the same warnings list the seventeen above go into. So all
twenty-one are reachable, and the four reach a reader through
pagedeck build --incremental and through nothing else — a full build cannot drift by
construction, since the class manifest it would check against is the one it is
writing, so it makes no plan, takes no check and produces none of these four.
That is the protocol rather than a gap.
drift.build.test.ts is where the text below is read off a real build's stderr,
which is what the top of this document promises of every message in it;
drift.test.ts, supplement.test.ts and supplement.build.test.ts still
execute each one where it is written.
The first is the weaker half of the refusal in
rule 3:
resourcePlacerWarning (packages/core/src/island-facts.ts) reports a module
in a "use client" closure that imports preinit or preinitModule from
react-dom. preinit places a stylesheet from code, so a call made from an
effect leaves nothing in the emitted HTML for the byte scan to refuse, and the
import specifier is the only anchor a build has — one a re-export or an alias
defeats. The message states that limit rather than reading like a rule the build
can hold, because a reader who trusted it as one would be trusting a promise the
build cannot keep:
Island scan: 1 module in a "use client" closure imports preinit or preinitModule from react-dom — a preinit call places a stylesheet past the <head> tiers the build owns, which the build refuses when the rendered HTML shows it; this is a warning and not a refusal because a call made from an effect leaves nothing in the HTML to see, and an import reached through a re-export or an alias leaves nothing here to see either:
"/site/components/Copy.js" — preinit
It reaches io.err the same way every refusal does, and by the same route as
every other answer the island scan gives: scanIslandFacts returns it as data
on IslandFacts.warnings, buildSite passes it out on SiteBuild.warnings,
and runBuildVerb writes it — before the success line, so a reader meets the
caveat above the summary. buildSite takes no CliIo on purpose, so the verb
is where a diagnostic becomes a line.
The second is uncompiledGlobalCssWarning (packages/core/src/build.ts), on
the same channel and by the same route. A site that declares build.css and
hydrates no island on any page compiles none of it: a declared stylesheet
reaches the bundler through a generated page entry, and a site with no island
has no entry for the bundler to run at all.
Global CSS: 1 declared stylesheet is not in this build, so no page links it — a declared stylesheet reaches the bundler through a page's generated entry module, and this site hydrates no island on any page, so there is no entry module to import it from; this is a warning and not a refusal because every page this build emitted is otherwise correct, and the sheets compile as soon as one page mounts one interactive component — island a component anywhere on the site, or drop the declaration until the site has one:
"./styles/global.css"
A warning for the opposite reason to the one above: not because the build cannot see the fault, but because it can see it exactly and the output is still correct HTML. Refusing would fail a build nobody can fault. What it must not do is stay silent — the reference page promises the sheet on every page, and this is the one site shape where it does not arrive.
The third is workerFallbackWarning (packages/core/src/scripts.ts), on the
same channel and by the same route, and it is the second warning's argument over
a different field (#46). Core defines the worker strategy and deliberately
ships no mechanism to move a script off the main thread — the mechanism is
build.scripts.runtime, an adapter the site supplies — so a site that declared
worker and configured none loads those scripts on idle instead, which is the
fallback spec §12 states.
Script runtime: 2 scripts can resolve to the worker strategy and this site configures no script runtime, so each of them loads on idle instead — worker moves a script off the main thread, and core ships no mechanism to do that with because a framework that picked one would carry a vendor's runtime into every site that never asked for it; this is a warning and not a refusal because idle is the fallback spec §12 states for this case, and a site that did not want off-main-thread loading is served correctly by it — supply build.scripts.runtime, or declare strategy: "idle" to say the fallback is what you meant:
"analytics" — declares no strategy, so it takes the worker default
"pixel" — pageTypes "/blog/**" sets "worker"
Not a refusal, because the config is well formed and the fallback may be exactly
what the site wanted — worker is the default a site gets for saying nothing,
so refusing would fail every build that never thought about the field. Not
silence either, which the ruling on #46 named specifically: a site that
configured a strategy and quietly did not get it has no way to find out.
Once per build, and the scope is the reason it can be. runtime is one
field on one config, so the question the message answers is asked once; a line
per script per page would be the same sentence repeated down a route table.
That costs a precision the wording is careful about — it says a script can
resolve to worker, which is decided from the settings alone, rather than that
one did on some page, which would need the route table and still produce one
message. Each line says which door the script reaches worker through, and a
script reachable through more than one is named once. No src is quoted, which
is the field rule 6 would have to reach.
The fourth is workerConsentWarning (packages/core/src/scripts.ts), the same
argument one issue later and over a second reason the same fallback applies
(#47). A script that declares a consent category is gated by the loader this
build writes, and a worker script is not loaded by that loader at all — it is
loaded by the elements build.scripts.runtime returned, which core owns nothing
about by construction. Loading it there would be loading it with no gate, so the
build does not: the script takes idle, and this says so.
Script consent: 2 scripts declare a consent category and can resolve to the worker strategy, so each of them loads on idle instead — the consent gate this build writes lives in the loader that backs the main-thread strategies, and a worker script is loaded by the elements build.scripts.runtime returned, which core never reads, so leaving it there would load a categorized script with no gate on it at all; this is a warning and not a refusal because idle is the fallback spec §12 states for a worker script this build cannot deliver, and it is the strategy the gate does reach — declare strategy: "idle" to say the downgrade is what you meant, or drop the category and gate the script inside the adapter, which is the only place a worker script can be gated:
"analytics" — category "marketing" — declares no strategy, so it takes the worker default
"pixel" — category "analytics" — pageTypes "/blog/**" sets "worker"
Not a refusal, because the config is well formed and idle is a documented
fallback that serves the site correctly — refusing would fail a build this
framework knows how to make right, which is workerFallbackWarning's position.
Not silence either: the site declared a strategy and did not get it, which is
the one thing rule 8 will not let a build keep to itself. Both fixes are in the
sentence, because which is right depends on something no build can know — a site
whose own adapter gates for it drops the category and keeps worker, and a site
that wanted core's gate writes the strategy it now has.
It is mutually exclusive with the warning above, and that is the point rather
than a coincidence. A site with no runtime has every worker script fall
back to idle already, and the warning above has named these same scripts for
that reason — saying it twice about one script would be two paragraphs for one
edit. Only a site that configured a runtime hears this one. The scope, the
ordering of the doors and the name-once rule are workerReachingScripts' and
are shared with it in code, so the two cannot come to describe the same config
differently.
The fifth is unloadedScriptWarning (packages/core/src/scripts.ts), the
third of the script layer's and the one of the three that is not decided from
the config alone (#345). An override map can set a script to "off", which
takes it off the pages its key covers; a key that covers every page the site
builds leaves a declaration nothing acts on.
Script reach: 2 scripts resolve to "off" on every page this site builds, so no page loads any of them — this site builds 4 pages, and an override takes a script off every page its key covers, so a key that covers them all leaves a declaration nothing acts on while it still reads in the config like a script that loads; this is a warning and not a refusal because every field of the declaration is well formed and a site mid-migration may have taken a script off every page on purpose — drop the declaration from build.scripts.scripts, or narrow the override that takes it off so at least one page keeps it:
"analytics" — pageTypes "/**" sets "off"
"pixel" — pageTypes "/**" sets "off"
Not a refusal, on the two warnings above's argument: every field is well formed, this build knows exactly what to do with the config, and a site mid-migration may have taken a script off every page on purpose for one release. Not silence either, and here the silence would be total — the declaration reads like a script that loads, the override reads like a narrowing, and what the emitted documents show is an absence, so a mistyped script name in an override and a key that covers more pages than its author thought both look exactly like a script that works.
It reads the route table, which is what separates it from the two above.
Those name the scripts that can resolve to worker, from the settings alone.
This one cannot be written that way: whether a key covers every page is a fact
about the pages, so a site whose only "off" key is "/blog/**" has dead config
exactly when every page it built is under /blog/, and no reading of the map
alone tells those two sites apart. It is still one message per build rather than
one per page, for the reason the two above give. A site that builds no pages
names no script at all — every declaration would resolve to "off" vacuously,
and the edit that fixes an empty route table is in neither scripts nor the
override maps.
The count is of the route table and the sentence says so, which is what
keeps it true on pagedeck build --incremental: that build re-renders a subset and is
handed the whole table anyway, because "does this script reach any page" is a
whole-site question, so a message counting the pages this run rendered would be
false on exactly the build that renders fewest.
pagedeck dev reports the two above and not this one, and that is the protocol rather
than a gap: it renders one page per request, so the widest question it could ask
is whether this page loads the script — which is not this question, and a page
carrying no scripts is the ordinary outcome the feature exists to produce.
Rule 5 lists every script and puts the overrides on the line, because the name
alone sends a reader back through two layers by hand; a script taken off page by
page names every key that did it, since dropping one of several is half an edit.
The lines are sorted and so are the keys on each of them, so two builds of one
site write one stderr (spec §11) — checkSiteLinks' reason above, over an order
that would otherwise come from the config and from the route table. No src is
quoted, which is the field rule 6 would have to reach.
The sixth is undeclaredHeadersWarning (packages/core/src/routing.ts), the
last of the ones decided from the config alone and the only one whose subject is
an absence in the emitted output rather than a downgrade inside it (#318).
build.routing.headers is wholly author-written and core supplies no default,
so a site that declares no rule gets no _headers file, no nginx add_header
block and no CloudFront viewer-response function at all — and a generated site
has no server of its own to add one later, which makes that field the whole
surface.
Security headers: this site declares no header set, so no response its output serves carries one — build.routing.headers is the only place a generated site can put a response header, and with the field absent no _headers file, no nginx add_header block and no CloudFront viewer-response function is emitted at all; this is a warning and not a refusal because every page this build emitted is correct and a host that already sets these headers would be handed duplicates by a default nobody wrote — spread SECURITY_HEADERS into the set of a rule over "/", which is these three, or declare a set of your own to say the host is doing it:
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Not a refusal, on uncompiledGlobalCssWarning's argument: the pages are
correct, and a site whose CDN or reverse proxy already sets these headers has
nothing at all to fix. Not a default either, and that is the half this one has
to state: emitting the headers anyway is the fix that suggests itself, and it
would hand that same site each header twice, from two places, one of which its
config does not show. So the framework takes a posture and the site opts into
it — SECURITY_HEADERS is data next to HeaderRule, inert until a set
spreads it — which is what makes the fix one line without making it automatic.
Not silence either, and the silence here would be total for rule 8's own reason:
the absence produces no artifact, so there is nothing a reader can open that
distinguishes "never asked" from "answered one layer out".
Any rule at all ends it, whatever that rule carries. A site whose only
header is a Cache-Control on /assets/ gets silence. Grading a declared set
against a list of names the framework would have preferred is the silent default
arriving by another door — it would make the build the judge of a posture the
site already took — and no reading of a header table says whether the missing
half is set by the host. The question asked is the one that can be answered:
did anybody take the question.
The fix names the constant rather than describing it, rule 3, because the constant is the edit. The three indented lines are the offer and not a collected report, which is the one place a message in this document departs from rule 5's shape: there is exactly one fault and the headline states it, and what the lines carry is what the author would be spreading, so the decision can be made without opening the source. Rule 6 reaches none of it — three fixed names and three fixed values, none of them the site's.
Two headers are deliberately not in that constant, and the reference page at
packages/docs/content/reference/routing.md carries both arguments in full.
Strict-Transport-Security is left out because a browser told once to refuse
plain HTTP keeps refusing, so a site not yet fully on HTTPS — or one sharing a
domain — can lock itself and its siblings out, and none of that is knowable from
anything a build reads; a value spread unread may not be able to do that.
Content-Security-Policy is left out because a page whose script layer this
build composes has no policy correct by default. #315 settled the other half of
that story without a header: each page's manifest row records the inline script
loader's CSP hash, and the site writes the policy that uses it.
pagedeck dev does not report it, and unlike the fifth's case that is not a question
it could ask badly — it is a question with no subject there at all. That server
answers requests itself and emits none of #33's three artifacts, so a site with
no header set is missing nothing yet.
The seventh and eighth are driftWarnings' (packages/core/src/drift.ts), and
they are the two paragraphs of one report — spec §9's drift protocol, #29. An
incremental build extracts the classes of the pages it re-rendered and checks
them against the class manifest the last full build recorded and against the
safelist the site declared; a class neither set holds is a class no stylesheet
covers, which spec §9 says "is always a safelist gap or content-hygiene bug".
The declaration is why the report names two causes and not one. build.safelist
carries every place spec §9 says a class may come from — a CMS-exposed styling
option, and a class a component states in its own source (#260, #261) — so a
class outside both it and the manifest is either a place the site forgot to
declare or the invariant broken outright. Naming both is rule 3's: the two have
different edits.
Class drift: 2 re-rendered pages use classes the last full build's class manifest does not hold — spec §9's invariant is that classes derive from code and never from content, so a class in rendered HTML that no recorded class covers is either a class this site's code states and the site's declared "build.safelist" does not — a CMS-exposed styling option, or a class a component writes for itself — or a component writing a class name out of a content value; check "build.safelist" for the option the source entry below sets and for the components that entry renders, then read those components. This is a warning and not a refusal because the supplement spec §9 inlines into each page below leaves that page correctly styled and the site's stylesheets byte-identical:
en /pricing — "bg-lime-300", "text-lime-900" — own entry "pages en pricing"
de /pricing — "bg-lime-300" — dependency "globals en nav"
Every drifted page and every missing class on each (rule 5), because a safelist
gap reaches every page rendering the component it belongs to, and a report
naming one of them is as many build loops as the site has sections. Rule 2's
field is the source entry: each line names what put that page in the render
set, read off AffectedPage.reasons, and a page in it for several reasons names
all of them joined with ; rather than having one picked for it. A page
re-rendered for a structural reason has no entry to name and says so, which is
§2's (whole entry) one document along:
en /pricing — "bg-lime-300" — no source entry: the route table holds this page and the previous manifest does not
A class name is quoted whole, and it is rule 6 read at what the value is. The case this report exists for is a component writing a class out of a content value, so the class is content by construction — §2's boundary, where a content value goes into the log the site's own content is built from — and it is the whole diagnosis besides: naming a position instead would send a reader to search a page for a class the message declined to spell.
Not a refusal, on uncompiledGlobalCssWarning's argument: spec §9 inlines a
supplement into each drifted page, so the emitted pages are correct and
core.css is byte-identical, and refusing would fail a build whose output
nobody can fault. Not silence either — drift is a bug every time, and the report
is the only thing that says a safelist has stopped covering the site.
The eighth is the threshold breach, a second paragraph rather than a line under
the first, on loadComponents' argument: the fix above is a safelist or a
component, and the fix here is a build to run.
Class drift: 6 pages drifted and "build.driftThreshold" allows 5, so this build's manifest records a full rebuild request — an incremental build carries the previous build's class manifest forward rather than re-extracting it, so every class above drifts again on every incremental build until a full build records it, and each of those builds inlines the same supplement into the same pages. This is a warning and not a refusal because every page this build emitted is correct: the request is the manifest's "fullRebuild" field, and spec §9 leaves scheduling the rebuild to the site's CI — run a full pagedeck build, and fix the safelist gap or the component the lines above name.
Once per build, and the scope is workerFallbackWarning's reason: the
threshold is one field on one config compared against one count, so the question
is asked once however many pages drifted. The message names the field, both
numbers and the manifest column, because the thing it is reporting is a record
this build wrote — Manifest.fullRebuild, which carries why a rebuild is needed
and not merely that one is, so a deploy stack holding the manifest alone can act
on it.
The ninth and tenth are compileSupplements' (packages/core/src/supplement.ts),
and they are the other half of the same protocol — spec §9's step 1, the
supplement inlined into each drifted page. The framework compiles nothing: no
source of it names a CSS toolkit (CONTEXT.md), so build.driftSupplement is
the site's compiler, handed one page's missing classes and returning a
stylesheet covering them. Each warning is one way that seam can produce no
supplement, and each is loud because the page it is about is the one thing this
protocol cannot make right on its own.
The ninth is the seam left undeclared. It is the case the drift warning above cannot cover: that one calls drift a warning because a supplement leaves the page correctly styled, which is a promise nothing keeps when there is no compiler to make one with.
Class drift: 2 drifted pages have no supplement, because this site declares no supplement compiler — spec §9 inlines a stylesheet holding only the missing rules into each page below, and this framework names no CSS toolkit to compile one with, so the compiler is the site's to declare; without it each page below ships the classes it drifted on with no rules behind them, which is a page that renders unstyled where those classes are used. This is a warning and not a refusal because a site whose CSS is hand-written has no compiler to declare and the fault is the drift itself — fix the safelist gap or the component the drift report names, or declare build.driftSupplement, as driftSupplement: (classes) => compile(classes):
en /pricing — "badge-rogue"
de /pricing — "badge-rogue"
Not a refusal, on uncompiledGlobalCssWarning's argument plus one this field has
of its own: a site whose CSS is hand-written has no compiler to declare and never
will, so refusing would turn every content-hygiene bug on such a site into a
failed build with a fix its author cannot take — and the pages the build emitted
are the pages it would have emitted anyway. Not silence either, because an
unstyled element is the one defect a reader cannot see from a green build. Both
fixes are in the sentence for workerConsentWarning's reason: which is right
depends on something no build can know — a site with a toolkit wires the seam,
and a site without one fixes the class.
The tenth is the seam declared and answering with nothing. A second paragraph
rather than a line under the first, on loadComponents' argument: this site
has wired the seam, so "declare a compiler" is not its fix, and what an empty
answer means is that the compiler does not generate these classes.
Class drift: 1 supplement compiled to no rules, so each page below ships the classes it drifted on with no rules behind them — "build.driftSupplement" was handed those classes and returned a stylesheet holding nothing, which is what a compiler answers about a class it does not generate: a name somebody typed by hand, or a safelist the toolkit was never told about. This is a warning and not a refusal because the page renders and only the rules for these classes are missing, and an empty answer is the compiler's rather than a fault in this build — fix the safelist gap or the component the drift report names, or generate rules for these classes:
en /pricing — "badge-rogue"
A stylesheet of whitespace is the same answer as an empty one and is reported on
the same line: it covers no class, and an element holding it would be bytes on
the page for the compiler having had nothing to say. Every class is quoted whole
on both, driftWarnings' reason — the class is content by construction here, and
it is the whole diagnosis.
Three refusals sit under the same field, and none is a warning. A compiler
that throws stops the pass on the first one, with the throw attached as the
cause (rule 4) rather than collected — rule 5's own boundary, the one
collection.ts draws over a validator that throws: the remaining pages would be
measured with a broken instrument. A plain Error and exit 1, the class a
loader's throw takes (rule 7): the wiring is intact, the field is a function and
it was called, and what failed is the site's own code running.
Class drift: the supplement compiler threw on en /pricing — "build.driftSupplement" is called with the classes one page used that the last full build's class manifest does not hold, "badge-rogue" here; fix the compiler, or fix the safelist gap or the component the drift report names so the page does not drift
The second is a compiler that answers with something that is not a stylesheet,
refused on the spot because a .js config is never typechecked and the value is
checked where it is used. The kind is named and the value never quoted (rule 6):
a compiler's output is the site's own CSS, and the kind is the whole diagnosis —
the mistake it catches is a compiler that forgot to return, whose own failure is
a TypeError on undefined naming no page and no field. A ConfigError and
exit 2, and not the exit 1 the throw above takes: rule 7's criterion is
whether the fault comes right on a retry, and a declared function that returns a
number returns one on every run until somebody edits it. The throw is the other
side of that line — a compiler that failed once failed while running, which is
what a loader's throw is — so the two sit apart by the criterion rather than by
which line of this function noticed them.
Class drift: the supplement compiler answered with undefined on en /pricing, and a supplement is a stylesheet — return the CSS covering the classes it is handed, as driftSupplement: (classes) => compile(classes)
The third is inlineStyleElements' </style refusal over the other kind of
inlined sheet, collected over every page and thrown once, with the position
named rather than the bytes quoted for that message's reasons exactly. A
ConfigError and exit 2, the class the refusal above takes and for the same
reason: the same compiler answers the same way on every run until somebody edits
it.
Class drift: 1 supplement cannot be inlined because it holds "</style", which ends the element early and puts the rest of the sheet into the page as markup — return a stylesheet with no "</style" sequence in it, or fix the safelist gap or the component the drift report names so the page does not drift:
en /pricing — "</style" at line 1, column 23
The eleventh and twelfth are checkSiteLinks' (packages/core/src/links.ts), and
they are the two halves of issue #31's reference check: a document names a route
or a file, and this build knows which of them it emitted.
The eleventh is the broken half, and it is a warning only where the site asked
for one. build.links defaults to broken: "error", which is the refusal the
build already made over a <script src> naming a file nothing emitted (#56), so
the paragraph below is what a site declaring broken: "warn" gets instead of
that throw — same sentence, same lines, with the reason it is not a refusal
appended. The setting is named in the text, because a reader meeting a warning
about a page that points at nothing should be able to see why the build went on:
Site build: 2 references name nothing this build emitted — check each against the page or the file it should name: an asset URL and its file name are minted at two stages (see chunkPath in client-build.ts), and a route is served only where a page renders one. This is a warning and not a refusal because "build.links" declares broken: "warn":
en / — "/apple"
en / — "/zebra"
One paragraph over both kinds of reference, and the fix carries both edits: an href that resolves to no page and a URL that resolves to no file are one question to a reader — "what does this point at" — and the document does not say which of the two an author meant. The page comes first on each line and the lines are sorted, so one page's references sit together and two builds of one site write one stderr (spec §11).
The twelfth is the redirected half, and it is a warning at either setting, which is issue #31's second acceptance criterion in as many words: a link that resolves through a redirect works, so there is nothing for a refusal to be right about. What it costs is the hop, so the line carries the direct target — the whole of what a reader does about it — beside the page and the href:
Site build: 1 reference resolves through a redirect — point it at the target on the line below, so a visitor's first request is the page rather than a hop. This is a warning and not a refusal because the redirect works and the page it lands on is one this build emitted:
en / — "/old" → "/about"
No build produced that paragraph until #270, and the reason was wiring rather
than absence. buildSite called planRouting with no config and no field
of BuildSection carried one, so the routing document a real build handed the
check held no redirects at all — the warning was implemented, unit-tested and
unable to fire on any site in the world. build.routing is the field that
closed it, and the day it landed this paragraph started appearing with no change
to the wording above. It is asserted in two places for two reasons:
packages/core/src/links.test.ts composes the report, which is what keeps the
text above text this repo produces, and
packages/core/src/routing.build.test.ts builds a site that declares a redirect
and links its from, and reads this line off a spawned pagedeck build's stderr —
beside the same corpus with the rule removed, which is refused as a broken
reference instead. Both paragraphs are skipped whole, along with the walk that
would produce them, by a site declaring links: { broken: false }.
The thirteenth, fourteenth and fifteenth are probeExternalLinks'
(packages/core/src/links.ts), and they are the opt-in half of the same check:
the absolute http:/https: URLs a site's pages link, asked about one at a
time through a probe the site declared (build.links.external, spec §13).
None of the three is ever a refusal, and that is this door's decision rather
than a gap. What they measure is a host nobody in the build controls, and
rule 7's question — does the fault come right on a retry — answers yes for
every one of them: a 503, a timeout, a runner behind a firewall. A build that
failed on those would fail on a Sunday for a reason no reader can act on and
whose fix is to run it again. The broken setting governs references into this
site and does not reach here.
The thirteenth is a URL that answered. Under 400 is silence; 400 and over is a line, with the status the probe returned and every page that links it:
Site build: 1 external reference answered with a status a reader will not see the page at — check the link, or the host behind it; this build asks each URL once and takes the status its probe answers with. This is a warning and never a refusal, because a host this site does not control is not this site's wiring and the same URL usually answers on the next run:
"https://example.com/moved" — 410 — linked from en /
The fourteenth is a URL the probe threw on, and it is a separate paragraph because it has a separate fix: a timeout, a DNS failure and a proxy refusing the request are how a network says nothing, and none of them says the link is broken. The thrown value is quoted on the line rather than attached as a cause, rule 4's own boundary for a report that collects — several hosts can fail in one run, and two causes cannot both be the cause.
Site build: 1 external reference could not be checked, because the external link probe this site declared threw — the fault is the network, the host or the probe's own client rather than the page, so nothing here says the link is broken. This is a warning and never a refusal, because a build whose success depends on another host's uptime fails on a Sunday for a reason no reader can act on:
"https://example.com/slow" — Error: ETIMEDOUT — linked from en /
The fifteenth is what the build did not ask about. Requests are sequential,
paced by build.links.external.intervalMs, and bounded in number by
build.links.external.limit, so a site with more URLs than the limit gets an
answer about some of them — and silence about the rest would read exactly like a
pass:
Site build: 1 external reference was not checked, because this build reached the 2 requests "build.links.external.limit" allows — raise the limit, or read this as the check having stopped rather than as a link that answered:
"https://example.com/c" — linked from en /
Every URL on these three lines is quoted through quote
(packages/core/src/quote.ts) and so is cut at its ? or # — rule 6, and
this is a field where it bites: an outbound link in a CMS field is exactly the
string that carries somebody's tracking token or a signed asset URL. The one
string this build does not compose is cut too: a probe's thrown message is the
site's client talking, and fetch embeds the request in its own message, so
every http(s) run inside it is cut the same way before the line is written
(describe, packages/core/src/links.ts).
One refusal sits under this field and it is the site's own function, on the
bargain CONTEXT.md records for build.driftSupplement. A probe answering with
something that is not an HTTP status is a declared function returning the wrong
kind on every run until somebody edits it, so it is a ConfigError and exit 2 —
while a probe that throws is the warning above rather than a stopped pass,
because a throw here is the network and not the code.
It is collected and thrown after the pass rather than from inside it (rule 5). A probe that answers wrongly once usually answers wrongly for every URL, so stopping at the first would reveal them one build at a time and leave the rest of the site's URLs unasked:
Site build: the external link probe answered with something that is not an HTTP status 2 times — return the status code the request came back with, as probe: async (url) => (await fetch(url, { method: "HEAD" })).status:
"https://example.com/a" — answered "200"
"https://example.com/c" — answered NaN
What a refusal costs, here as everywhere in this build, is the warnings the same
run collected: buildSite returns them and a throw returns nothing. That is the
shape of a refusal rather than a fault in this one.
The sixteenth is retainManifest's (packages/core/src/retention.ts, #32),
and it is about a directory rather than about the site, as the twenty-first
below is too: a document in the retention store that this pagedeck cannot read, removed rather than kept.
listRetainedManifests skips such a file instead of throwing over it, because
its two callers want the newest id and the deletion list and neither needs to
understand an old document — throwing would make the first build after a
MANIFEST_VERSION bump refuse, so a pagedeck upgrade would brick every build of
the site and the only fix would be deleting a directory the site was never told
exists. readRetainedManifest keeps the refusal, because rolling back to a
document this build cannot read has to fail.
Retention store: 1 retained manifest could not be read and has been pruned — a build reads the store to record its own parent and to prune by the site's keep count, and a document this pagedeck cannot read answers neither; ignore this once after a pagedeck upgrade, or pin one pagedeck version across CI and local if it returns on every build:
Manifest "/site/.pagedeck/manifests/9e1f4a02.json": is version 10, and this build reads version 11 — upgrade pagedeck, or read a manifest this version wrote
The case that makes it a diagnostic rather than silence is not the upgrade.
An upgrade clears the store once, and a reader told nothing loses little. Two
pagedeck versions building one site — a CI image that moved while a developer's did
not, one workflow pinning a version and another taking the release — clears it
on every run for ever, and the symptom is a store that is always one build deep
and a rollback that never reaches past yesterday. Nothing else in the run says
so: the build writes a correct site and exits 0.
Rule 5 lists every file rather than a count of them, because a store carried
across an upgrade holds one per retained build. Each line is the read's own
failure message, which is readManifests' shape in cli.ts and is what tells
the two failures apart — a document an older pagedeck wrote and a file that is not
JSON at all send a reader to different places. It is appended after everything
stageSite collected, last in the list: the store is written after the tree, so
the fault is not known until every warning about the site itself already is.
The seventeenth is misfiledWarning's (packages/core/src/retention.ts, #312),
and it is the other thing a run can find in that same directory: a document
whose file name and whose build.id disagree. Only retainManifest writes the
store and it writes each document to fileOf(root, id), so the two agree in
every file a pagedeck build produced — a pair that disagrees was written by hand.
It is counted as its own warning on this rule's convention rather than as a
second paragraph of the sixteenth, which is the same convention driftWarnings'
two and compileSupplements' two are counted on: what is numbered here is a
message a reader can meet — its own subject, its own fix, its own reason for
being a warning — and not the value it travels in. retainManifest returns the
two joined by a blank line, so they reach SiteBuild.warnings as one entry and
runBuildVerb writes them in one call. That is why the collected count above
says retainManifest's two.
Retention store: 1 retained manifest is filed under a name that is not its build id — pagedeck build writes each document to "<build id>.json" and reads it back by that id, so a document filed elsewhere is unreachable by rollback and was written by hand; rename the file to the name its own build id spells, or delete it — and if that id cannot itself be a file name, mint the build id as a name a path can hold, such as a uuid, and file the document under that:
"/site/.pagedeck/manifests/planted.json" — the build id is "../../../victim", and the file is still in the store
Retention store: 2 retained manifests are filed under names that are not their build ids — pagedeck build writes each document to "<build id>.json" and reads it back by that id, so a document filed elsewhere is unreachable by rollback and was written by hand; rename each file to the name its own build id spells, or delete it — and for any id that cannot itself be a file name, mint the build id as a name a path can hold, such as a uuid, and file the document under that:
"/site/.pagedeck/manifests/backup.json" — the build id is "9e1f4a02", and the file is still in the store
"/site/.pagedeck/manifests/planted.json" — the build id is "../../../victim", and the file has been pruned by this build
It is a warning rather than the refusal issue #312 asked for, and the reason
is what the fix did to the fault. The prune used to compose a path from the id
inside each document, so a planted "../../../victim" deleted a file outside
the store — and deleted it again on every build, because the planted document
was never the file that prune unlinked. The prune now unlinks the name readdir
gave it, so no id read off disk reaches a path and there is no traversal left to
refuse. A refusal would fail the build before the prune, which leaves the
planted document in place to fail the next build the same way: the
self-perpetuating half of the fault kept, with a bricked build where a deleted
file used to be. So the build finishes, the count removes the document when its
turn in the order comes, and this line says what was found.
Every misfiled document and not only the ones a run pruned (rule 5). One inside
the keep window is the same fault with a longer life, and it has a consequence
of its own: runBuildVerb stamps the newest document's id as the next build's
parent, so a misfiled newest makes every later build record a parent no
rollback can reach. Both the file and the id are named (rule 2), because the
whole fault is that they disagree and neither of them alone says so.
Each line says whether the file is still there, which the sixteenth settles in its headline instead — it prunes every document it reports, so "and has been pruned" is true of all of them. Here it is true of some: a misfiled document past the keep count went with this build's prune and one inside the window is on disk, both are reported, and a single headline could only be right about one half. Rule 3 is why it matters rather than tidiness: the fix below starts with an edit to a file, and sending a reader to rename or delete one this build has already unlinked is a fix that cannot be carried out.
The fix is a rename and not a new id, which is where this message parts from
the refusal in rule 7 that shares its store. That one answers "this id cannot be
a path", so a new id is the whole of it. Here the id is usually fine — a
document called backup.json whose build.id is 9e1f4a02 needs the file
renamed to 9e1f4a02.json and nothing minted — so the rename leads, and minting
is kept for the subset where the id genuinely cannot be a file name, which is
the only case where renaming the file to it would not work. The clause is
written in a singular and a plural form (MISFILED_FIX.one and .many, on
MISSING_ID_FIX's shape in tiers.ts), because a subject counting several
documents followed by "rename the file" tells a reader to fix one of them.
Both interpolated values pass through printable (packages/core/src/exit.ts),
and so does every line of the sixteenth. Neither value is the site's: the file
name is what readdir answered with and the id is bytes out of a document
readManifest checks for types and not for content — the same document this
message exists to say was written by hand. bin.ts marks per line, so a
build.id holding a newline would forge a line carrying pagedeck: and a carriage
return with an erase-line escape would take that marker off the line already
written; a planted document could otherwise write whatever it liked into a build
log as this framework's own diagnostic. Every control character becomes U+FFFD
instead. That is not rule 6, which is about a value that should not be printed
at all; it is about a value that must not be able to stop being a value.
The eighteenth and nineteenth are bailoutWarning's and crashWarning's
(packages/core/src/react-compiler.ts, #106), and they are the two things React
Compiler can say about a module the client build compiled: it refused a
component over a rule, or it fell over on one. Both leave the same output — the
component the author wrote, rendering correctly and memoizing nothing — so a
reader who is not told cannot tell either event from a component there was
nothing to do for, and the two need opposite responses.
The eighteenth is the bailout. The compiler's own reason names the rule and its
description states the fix, so both are quoted rather than paraphrased, and
the closing sentence is what makes the loss visible at all. The line below is
packages/core/src/react-compiler.test.ts', which pins it character for
character off a build of a component that writes to its props:
Module "/site/components/Title.tsx" line 3: React Compiler skipped component "Title" — This value cannot be modified: Modifying component props or hook arguments is not allowed. Consider using a local variable instead. It renders as written, without automatic memoization.
The nineteenth is the crash, and the wording is the whole of what separates it
from the line above: one is a rule to accept or rewrite around, the other is a
compiler bug to report. Only the first line of the thrown error is quoted, rule
6's reason — the rest is the compiler's own stack under absolute paths from
whatever machine ran the build. The line below is
packages/core/src/react-compiler.harness.ts', which provokes a real crash by
exhausting the stack and therefore runs under
pnpm test:compiler-crash-harness rather than under pnpm test — AGENTS.md
gives the placement and the trigger to re-measure on.
Module "/site/components/Chart.tsx" line 2: React Compiler crashed on component "Chart" — RangeError: Maximum call stack size exceeded. That is a fault in the compiler rather than a rule the component broke, so report it as a React Compiler bug; "use no memo" does not silence it, because the crash is logged whether or not the directive is present. It renders as written, without automatic memoization.
Neither is a refusal, on uncompiledGlobalCssWarning's argument: the compiler's
panicThreshold is left at its default, which does not throw, and failing a
site's build over a memoization it can do without would fail a build whose
output nobody can fault. Not silence either, which is the whole reason these
exist — a bailout is invisible from a green build and from the emitted bytes
alike. A bailout inside a dependency is silent, and that is the one narrowing:
the author cannot edit the component, so the line would be noise. A crash is not
narrowed, because reporting a compiler bug needs the module named rather than
editable (isAuthored).
One entry per event, in module id order with each module's own events in the
order the compiler raised them, so a reader can diff one build's stderr against
the next; compileIslands sorts, because Rolldown transforms modules
concurrently and recording order is the scheduler's.
Until #242 these two were the exception to this whole rule, written through
Rolldown's this.warn and so reaching stderr unmarked. The move costs two
things. A reader hears of a bailout after the build rather than while its
module compiles, which every warning on this route already pays. The other
belongs here, because it is about this channel: a
build that fails after a bailout now says nothing about it, since
SiteBuild.warnings is written by a run that finished and this.warn printed
as it went. That is rule 5's boundary rather than a gap — a stopped run's later
readings were taken with a broken instrument — and the bailout is still there on
the next green build. packages/core/src/bin.test.ts is where the marked line
is read off a spawned pagedeck build, over a site whose one component the compiler
refuses for a different rule than the one quoted above: the line is marked, the
build still exits 0, and no unmarked copy of it reached the descriptor.
The twentieth is undeclaredContentRootWarning (packages/core/src/build.ts),
and it is the silent half #474 left behind (#485). A site that declares
build.passthrough.contentRoot has every content-relative reference resolved,
and refused where it names nothing. A site that declares no content tree ships
the same reference as written, with nothing published at the address it
reaches, and before #485 nothing said so. The build now collects those
references with the same call the refusal reads, and reports them:
Passthrough: 2 content-relative references resolve to nothing this build publishes, because this site declares no build.passthrough.contentRoot — a content-relative reference resolves against the address its page is served at, and the file it names is published from the content tree that key declares, so without it each page below points at an address nothing in this build emits; this is a warning and not a refusal because the host may serve these files from somewhere this build never reads — declare build.passthrough.contentRoot as the directory these files sit beneath, and the build publishes each one and refuses any that is missing:
en /posts/ferry — "../../assets/images/ferry/logo.png" → "/assets/images/ferry/logo.png" — content entry "posts en posts/ferry"
en /posts/json-bonsai — "../../assets/images/json-bonsai/query.png" → "/assets/images/json-bonsai/query.png" — content entry "posts en posts/json-bonsai"
Not a refusal, on workerFallbackWarning's argument: the config is well formed,
and this build cannot know that nothing serves these addresses, because a host
can hold files the build never reads. Refusing would fail a site for a key it
never declared. Not silence either, because a dead <img> on a green build is
exactly the defect #474 was filed about. Each line is the refusal's own line
(contentReferenceLine, packages/core/src/build.ts): the page, what it wrote,
the address it reaches and the content entry it is written in, one line per
address, in address order. So two builds of one site write one stderr, and a
reader who declares the key finds the refusal's lines where the warning's were.
Once per build. The subject is one field the site left empty, so a line per
page would be one sentence repeated down a route table. Only an asset reference
is reported: a relative <a href> names a page and not a file, so declaring
contentRoot would not fix it, and contentRelativeReferences leaves it out.
A site whose host really does serve these files cannot clear it yet. The
one fix the message names is contentRoot, and declaring it is a claim the
build checks: each reference must name a file in the tree, so a host-served one
refuses the build. undeclaredHeadersWarning gives its host case a way out,
because any header rule ends it and a rule is a field the site already has.
This warning has no such field, and a way out would be a new setting. None is
added: no site has this case yet, and a setting that silences the warning puts
#474's silent default one line away, so its shape waits for a site that needs
it. Until one exists, such a site builds, its
output is correct, and every build writes this warning, one line per address.
The twenty-first is unprunedTreeWarning (packages/core/src/build.ts), and
it is the one case a full build cannot prune the tree it builds into (#515). A
full build reads the manifest.json already in outDir and, after writing its
own, deletes each file the old one named and the new one does not, so a post
retracted since the last build leaves the directory a sync-based deploy
publishes. When that document is there and this build cannot use it, the
build deletes nothing and says so:
Output "/site/dist": this build removed no file an earlier build wrote there, because the manifest.json it left is not one this build can prune against — a full build deletes each file the previous build's manifest names and its own does not, and without that document it cannot tell a file an earlier build wrote from one placed there by hand, so a page that build published and this one did not, such as a post set to draft since, may still be in the directory; this is a warning and not a refusal because every file this build wrote is correct and the manifest it wrote is the one the next build prunes against — before a deploy that syncs this directory, delete the pages that build published and this one did not, or point build.outDir at a new, empty directory and build again; a deploy that reads the manifest needs neither:
Manifest "/site/dist/manifest.json": is version 10, and this build reads version 11 — upgrade pagedeck, or read a manifest this version wrote
It opens on the output directory, as the two incremental refusals about this
tree above do, and the opening does not say why the document was unusable,
because the reasons differ. The lines below it say, each in the
Manifest "<path>": shape: readManifest's own refusal, as above, which
since #529 includes a document of this version with no files list or any other
field pagedeck build writes (rule 5); a file that could not be opened, with its
code; or a row no build could have written. The rows are the prune's
input, so these are containment checks and not tidiness: writeSite deletes
the path each row names, and one row that escapes would have the default verb
delete a file outside the tree. A row whose deploy key holds a ., .. or
empty segment, a backslash or a control character never gets here: readManifest refuses
the document (#659), and that refusal is the line. Of the rest, a row is
refused when its path or domain resolves outside outDir as text, when the
directory it sits in resolves outside outDir through a symbolic link, when it
names a directory or a link rather than a regular file (rm is not recursive,
and would throw after the tree was written), and when it names the root
manifest.json this build has just written. A row naming nothing on disk
passes: there is nothing to delete. Every refused row is listed (rule 5), each
value through quoteIdentifier:
Output "/site/dist": this build removed no file an earlier build wrote there, because the manifest.json it left is not one this build can prune against — a full build deletes each file the previous build's manifest names and its own does not, and without that document it cannot tell a file an earlier build wrote from one placed there by hand, so a page that build published and this one did not, such as a post set to draft since, may still be in the directory; this is a warning and not a refusal because every file this build wrote is correct and the manifest it wrote is the one the next build prunes against — before a deploy that syncs this directory, delete the pages that build published and this one did not, or point build.outDir at a new, empty directory and build again; a deploy that reads the manifest needs neither:
Manifest "/site/dist/manifest.json": files row 0 names "/manifest.json", which is the manifest this build writes — pagedeck build never lists its own manifest as a file, so this row was not written by a build
Not a refusal, on retainManifest's argument one directory over: the case a
reader meets is the first build after an upgrade that moved MANIFEST_VERSION,
and refusing it would fail every site that upgraded. pagedeck build --incremental
refuses over the same document, and that is not a disagreement: an incremental
run cannot plan without it, and a full run needs it only to prune. Not silence
either, because what is left behind is #515's own leak, a retracted page in a
tree a directory-sync host copies whole. It lasts one build: the manifest this
build wrote is one the next build reads.
The fix never says to empty the directory. Nothing refuses an outDir of
. or an ancestor of the site, so "empty it" could mean deleting the site's
source. Both edits it names reach build output only: the stale pages
themselves, or a new directory the build fills from nothing.
A manifest.json that is absent is not this warning. That is a first build or
an emptied directory, and it prunes nothing and says nothing, as a full build
always did. Each line passes through printable for the sixteenth's reason:
it can carry a parser's echo of bytes somebody wrote.
The incremental refusal runs the same row check, untrustedRows
(packages/core/src/build.ts), from previousBuild (#524). It runs after
readManifest accepts the document and before the store position is read, so
a refused run has written and deleted nothing. Its lines are the ones above,
under a headline that names the document, as readManifest's own refusals do:
Manifest "/site/dist/manifest.json": pagedeck build --incremental cannot trust this document to decide which files to delete, so it wrote and deleted nothing — an incremental build deletes each file the previous build's manifest names and its own does not, and each fault below shows this document was not written by a build, so none of its rows can be trusted to choose a deletion — run pagedeck build, which builds the whole site, deletes nothing this document names, warns that it did not, and writes a manifest the next incremental build can use:
Manifest "/site/dist/manifest.json": files row 12 names "/link/victim", which resolves outside the output directory through a symbolic link — pagedeck build writes only paths inside it, so this row was not written by a build
The fix is a full build, and not an edit to the document: that build warns as above, prunes nothing, and leaves a manifest of its own, which is the one the next incremental run plans against.
The twenty-second is not a build's at all, and it is colorFailureWarning
(packages/content/src/colors.ts, #44): the image sources a sync could not
fetch a dominant color for. It is written by pagedeck sync and reaches io.err
through runSyncVerb, which writes SyncReport.warnings before the failures —
a caveat about a collection that did sync belongs above the collections that did
not.
Collection "pages": 2 image sources could not be probed for a dominant color, so they render with no placeholder — this is a warning and never a refusal, because a color is decoration and the host that serves the image is not this site's wiring; the next sync asks again:
"/uploads/a.jpg" — timed out
"/uploads/b.jpg?" — 502 Bad Gateway
A warning for probeExternalLinks' reason, and it is that entry's argument
about a host this site does not control, applied to a fetch whose whole product
is a background color: a sync that failed on an image host's uptime would fail
on a Sunday for a reason no reader can act on, and what a reader loses by it
finishing is decoration. Not silence either — the sources named here are the
ones that will be asked about again on every sync until somebody looks, which is
the visible cost of the ruling CONTEXT.md records against caching the failure.
Rule 5 lists every source and rule 6 redacts each one, which the second line
above shows: an image source is as often a signed CDN URL as a path, its
credential is in the query string when the URL is signed and in front of the
host when the CDN is behind basic auth, and both go. The cut is quote's
(packages/core/src/quote.ts) and is made again in colors.ts rather than
imported, because @pagedeck/core consumes @pagedeck/content and the import would invert
the dependency — the constraint that put CollectionError in that package in
the first place. The copy is checked rather than promised: quote.test.ts reads
both files off disk and refuses a difference, because the claim that the two are
one rule was true for as long as nobody edited either (#383).
One refusal sits under the same seam and it is the site's own function,
which is build.links.external.probe's bargain one door along. A color probe
answering with something that is not a string is a declared function returning
the wrong kind on every run until somebody edits it, so it is a
CollectionError and exit 2 — while a probe that throws is the warning above,
because a throw is the host and not the code. It is collected and thrown after
the pass, before anything is cached (rule 5): a probe that answers wrongly for
one source answers wrongly for all of them.
Collection "pages": the image color probe answered 1 source with something that is not a CSS color — return a CSS color such as "#2f3a28", or undefined for an image that has none:
"/uploads/a.jpg" — 17 — not a string
The value is quoted through quoteValue, which is quote's replacer and not a
bare JSON.stringify — a probe answering with the CDN's response object carries
the signed URL it just asked for, so the cut has to reach a nested string
(rule 6). It is the same cut quoteSource above makes, deliberately at the same
depth, because two copies cutting at different depths is a drift no reader
of either message can see (#46).
A declared imageColors.concurrency that is not a whole number of probes above
zero is refused beside it, before any request is made, rather than repaired —
budgetFaultReport's treatment of every other site-declared number:
Collection "pages": "imageColors.concurrency" is not a number of probes — write a whole number of probes above zero, such as { concurrency: 4 }:
0 — below one, and a sync that may run no probe at all would never cache a color
The channel is threaded rather than declared, which is this rule's first
half taken seriously at a seam a site fills. The color probe is site-supplied,
so the obvious place for a sink is beside it in the collection — and that is
exactly the console.warn in a pagedeck.config.ts that this rule forbids. So
ImageColorsSetting carries no sink, CollectionSyncOptions.onWarning carries
the run's, and the site declares only the thing that has to be its:
installRegistryWarnings' shape, at a door where there is an argument to
thread.
A message a browser produces goes through the console, and that is this rule
rather than an exception to it. Issue #66's last two criteria are both about
the root provider stack, and issue #252's two are both about the shared store;
all four are answered where the thing they are about is used — in a page, at
hydration — so there is no CliIo to write them to and no argument to thread
one through: the page was loaded by a reader, not run by a caller. So the
channel is console.error, and reportBrowserFault
(packages/islands/src/browser-report.ts) is the single place these packages
write a diagnostic of their own to one. The only other console reference in
packages/islands or packages/preview is mergeComponents' last-resort
console.warn arm, which is a build diagnostic falling back to a stream when
no run installed a sink and is argued at the top of this rule. A browser
diagnostic added later goes through reportBrowserFault, for the same reason
every build diagnostic goes through io.err. One has: @pagedeck/search's island
reports a query the runtime could not answer, and it reaches the channel through
@pagedeck/islands/browser-report rather than writing a console.error of its own —
which is what keeps the count at two console writes rather than three.
console.error for all of them, the warning included, because a browser's
default console filter hides warn in some setups and a warning nobody sees is
not a warning. Which of the two it is is stated in the prose, which is a claim text
can make and a log level cannot. No marker: bin.ts writes pagedeck: because a
build's stderr is shared with Babel and the bundler, and a console line the
framework wrote is already attributed by the stack frame beside it.
The divergence report is the first (criterion 5). build.rootProviders is two
halves of one declaration — the stack the build renders with and the module
the entry imports — and a site whose halves stopped agreeing builds green and
ships a page whose every island root wraps in providers the markup was not
rendered with. The build writes its half into the entry as a digest and the
browser recomputes its own:
Root providers: the stack this page was built with and the stack the browser imported are not one declaration, so every island root wraps in providers the markup it hydrates was not rendered with — write both halves of "build.rootProviders" from one import in pagedeck.config.ts, and compute no provider prop from a clock, an environment or a random value:
built with 1 provider:
stack[0] store=object#7ee4f3ca
imported 1 provider:
stack[0] now=number#a2de48c1, store=object#7ee4f3ca
Every prop value in it is hashed and none is quoted, which is rule 6 applied where it bites hardest: a provider prop is site config, site config is where a token lives, and the digest is written into the entry module of every page. A provider is named by index rather than by component name for a different reason — the browser's half is computed from a minified bundle where every function has been renamed, so a digest that read a name would report a divergence on every correctly wired site. What the reader is left with is the index, the prop name, the kind of the value and the fact that it moved, which is rule 2's field.
A prop that is a plain object, a null-prototype object or an array carries the
hash of its shape one level down (store=object#7ee4f3ca), so a config read
from the environment on one side and not the other names itself rather than
reading as object on both (#255). Any other non-primitive keeps the bare
word, and the missing suffix is the report saying the value was not read into —
a class instance, a function, a Map, a Set, a Date, a URL, a typed
array, a boxed primitive, an Array subclass. A divergence inside one of those
is therefore silent, which is the price of the walk stopping where it does: the
test is the prototype and never a constructor name, because the browser's half
of this digest is computed from a minified bundle where every class has been
renamed, and a digest that read a name would report every correctly wired site.
Which store shapes are safe, plainly, and why each is. A store holding its
state in a class is not read into at all — its prototype is not
Object.prototype, so it keeps the bare word. A store holding its state in a
closure is read into: it is a plain object of methods, and the
store=object#7ee4f3ca above is one. It digests alike on both sides because
every field it has is a function and a function is tagged by the bare word
function, so two instances — one per environment, which is what the design
asks for — hash the same text. A store that is a plain object carrying a
primitive on it is read into too, and there the two instances part: a counter
that moved while the build rendered the page will report a divergence the site
does not have. That is a real cost and not an argued-away one; the 2026-09-06
ruling on #255 took it because the alternative is that no object prop is
compared at all (CONTEXT.md, "A provider prop is digested one level down, and
no prop opts out").
A getter that throws when read, and a revoked proxy, keep the bare word rather than stopping the build when they are one level down, inside a prop. A prop that is itself a throwing getter still fails the build, exactly as it did before #255: the digest reads each prop unguarded, and the render that ran before the digest was taken read the same prop first and threw on it there.
The key names one level down are hashed with the values, because a key inside a
value is not a field the site typed into props and a map keyed by a token has
its secret in its keys. Only own enumerable string keys are seen at all, so a
symbol-keyed or non-enumerable field at that depth is invisible to the digest.
The second is a warning, and it is the ninth warning in this document
(criterion 4). A provider that creates its own state inside its component body
hands every island
root a different object, so two islands appear to share state and do not — the
failure that otherwise surfaces only in production. It cannot be decided
statically, so it is decided by watching: rootProviderProbe
(packages/islands/src/root-provider-probe.ts) reads each root's context value
for the same configured provider and reports when two roots disagree.
Root provider "OwnStoreProvider": delivers a different value to each island root, so two islands that look like they share state do not — the provider creates its own state instead of receiving one, so move that state to a module-level instance and pass it in, as { component: OwnStoreProvider, props: { store } }; this is a warning and not a refusal because the page renders and only its behaviour is wrong, and because this probe sees only a provider whose component is a plain function returning a context element
A warning for both of the reasons the two above are warnings at once. The output
is correct HTML, as uncompiledGlobalCssWarning's is — the page renders, and
only its behaviour is wrong, so a dev server that refused would take away the
page an author needs in order to see the fault. And the instrument cannot be
complete, as resourcePlacerWarning's cannot: a class, a memo and a provider
that renders a wrapper of its own are all invisible to it, so it under-reports
by construction and the message says so rather than reading like a rule the
build can hold. This one is named here rather than by a component's own name for
the opposite reason to the digest's: it runs only under pagedeck dev, where nothing
is minified, so the name in the message is the name in the source.
A third message shares the channel, and it is the boundary underneath the
other two. rootProvidersFaultReport validates the specifier in
build.rootProviders.module and never what the module evaluates to — no config
load ever sees that value — so the browser is where a default export that is not
a provider stack arrives. It is reported here rather than thrown, because this
runs at the top of the generated entry, above hydrateIslands: a throw would
stop every island on the page from hydrating over one malformed export, which is
a worse page than the one the check exists to describe. The digest comparison is
skipped rather than attempted, on loadComponents' argument — a stack nobody
has cannot have a prop that moved — so one fault produces one report:
Root providers: the module "build.rootProviders.module" default-exports is not a stack this page can apply, so nothing here can check it against the stack this page was built with — default-export the same array of { component, props } that "build.rootProviders.stack" holds, outermost first:
the default export — a function, not an array of providers
The lines are collected by index and worded exactly as stackFaultLines in
config.ts words them, because they are the two halves of one declaration
checked where each half arrives:
Root providers: the module "build.rootProviders.module" default-exports is not a stack this page can apply, so nothing here can check it against the stack this page was built with — default-export the same array of { component, props } that "build.rootProviders.stack" holds, outermost first:
stack[0] — not an object
stack[1].component — not a component
stack[2].props — not an object
A value is named by its kind and never quoted (rule 6): the thing being described is a provider stack, a provider's props are site config, and a module that exported a config object where the stack belongs would otherwise put what that object holds into a browser console. The kind is the diagnosis anyway — a reader told the default export is a function knows they exported the provider instead of the stack.
Three more share the channel, and they are the shared store's (#252).
packages/islands/src/store-stamp.ts and store.ts describe a fault nothing
else would ever say a word about: Jotai's own duplicate-instance detector lives
inside getDefaultStore(), which a framework built on createStore() plus
<Provider store> never calls, so a page holding two stores renders perfectly
and simply stops sharing state.
The first is the duplicate instance, and it is the one message in this
document whose class depends on which caller reached it. The stack is checked
where a store is handed in — renderPage for the build's half,
the generated island entry above hydrateIslands, and the generated preview
entry above mountPreview — and each caller states its own verdict rather than
sniffing one. A build and a preview app refuse: nothing has shipped, and in a
build the refusal is a StoreError and exit 2 (rule 7). A page entry passes
import.meta.env.PROD, which Vite folds to false on the dev server and true
in the shipped bundle, so the one place this reports rather than refuses is a
visitor's browser. That is the 2026-08-24 design review's ruling: #64's
chunk-graph assertion is the gate, and a canary must not take a visitor's page
away. The sentence says which it is, because a reader meeting it in a console
needs to know it did not stop the page:
Shared store: 2 providers deliver a store this framework did not mint, so two island roots resolve the same atoms on two stores and neither sees the other's writes — pass the one instance "@pagedeck/islands/store" exports, and call createStore() nowhere in site code; islands are separate React roots, so one store object reached through one module is the only thing that carries state between them, and a build, a dev server and a preview app refuse this where a shipped page only reports it, because the build's chunk-graph assertion is the gate and a canary must not take a visitor's page away:
stack[0].props.store
stack[2].props.cart
Every offending prop and never the first (rule 5), located the way the divergence report above locates a provider: by index into the stack and then by prop name, which is rule 2's field and is what the site edits. No value is quoted — a store is closures over whatever state the site put in it, and there is no reading of one a message could promise holds no credential (rule 6). The prop path is the whole diagnosis anyway.
The second and third are the hydration seam's, and both are reports in every environment rather than refusals in any. They are the tenth and eleventh warnings in this document, and each says why it is one.
Jotai's SSR documentation states that atoms hydrate once per store, and
useHydrateAtoms enforces it by dropping the second value in silence. Under the
visible and idle strategies island hydration order is the reader's scroll
and the browser's idle callback, so two islands hydrating one atom is a race
whose loser disappears. Refusing would cost every island that had not hydrated
yet — the page taken away from the author who needs it to see what happened — so
the first value is kept, which is what Jotai would have done, and the report is
the whole of what changes. Rule 5's shape, one line per atom:
Shared store: 2 atoms are hydrated a second time, and an atom hydrates once per store, so the value this call brought was dropped and the first one kept — hydrate the shared store once, from the page and before any root mounts, and give an island its own atom rather than a second value for a shared one; island hydration order is not controlled under the "visible" and "idle" strategies, so which value survives is not the site's to choose. This is reported and not refused because the page renders and only its state is wrong, and refusing here would cost every island that has not hydrated yet:
atom[0] "cart"
atom[2] — no debugLabel
The third is the other half of "hydrated once, before any root mounts", and
it is reported alone with nothing enumerated under it. That is the non-object
budget's argument rather than a shortcut: every atom in the call has the same
fault, the same cause and the same fix, and the fault is not about an atom at
all — it is about when the call was made. markRootsMounting, written into the
generated entry immediately above hydrateIslands, is what gives this message a
fact to report; without it a first hydration arriving after two roots had
rendered was accepted in silence, because every atom in it was new.
Shared store: hydrateStore was called after island roots began mounting, so every root that had already rendered did it with the value the store held before this call — a visible flash where the value is rendered, and a wrong answer for anything that read it in an effect or an event before the write landed — call hydrateStore from the page, above the module that hydrates the islands, so the store is complete before the first root mounts. This is reported and not refused because the page renders and only its state is wrong, and refusing here would take away the roots that mounted correctly.
No hydrated value reaches either report, and this is the message rule 6 is
written for. A hydration payload is whatever the site put on the page — a CMS
field, a session, a signed URL — so a line that echoed one would put arbitrary
site data into a console anybody who opens the page can read, and unlike §2's
validator text there is no schema failure here that the value would explain. An
atom is named by its debugLabel where it has one and by its position in the
call otherwise, and a debug label is what the author wrote and what they edit.
An atom with no label says so rather than leaving the position bare, the way §2
writes (whole entry).
Three more are @pagedeck/search's query runtime's (packages/search/src/query.ts,
#62), and they are the browser's for the plain reason: this code runs when a
reader types into a search box, and there is no run to report to. All three are
thrown rather than written, because a query that cannot be answered has no
answer to fall back on; the island catches each one, shows no results, and puts
the message on the channel through reportBrowserFault.
The first is a file the deployment does not have. An index is several files that have to travel together, so the one that is missing is named with the status that said so:
Search index "/search/en/terms-0001.json": the request failed with status 404, so this query cannot be answered — check that the build wrote a search index for locale "en" and that it was deployed with the pages
The second and third are the format check, and they are two messages because
they are two fixes — @pagedeck/edge's checkVersion split over a routing manifest,
and the same shape here. Both halves of a search index come from one package, so
the version says which half the deployment is carrying from another build: an
index ahead of the page means the page is stale, and an index behind it means the
index is. A single mismatch check would report every disagreement as "newer" and
send half its readers to rebuild the half that was already current.
Search index "/search/en/index.json": format 2 is newer than this query runtime reads (1) — the index was written by a newer @pagedeck/search than the page querying it, so the page is the stale half; rebuild and redeploy the site so the page ships the @pagedeck/search that wrote this index
Search index "/search/en/index.json": format 0 is older than this query runtime reads (1) — the index was written by an older @pagedeck/search than the page querying it, so the index is the stale half; rebuild the site so the index is written by the @pagedeck/search this page ships
A fourth message the same module carries is not a browser's at all, and is named
here because it is written beside them: createSearchClient falls back to
globalThis.fetch where a caller supplied none, and every browser this framework
targets has one. So the reader of this message is running the query runtime
somewhere else — a test, or an old runtime — and the fix is the option rather
than the environment.
Search query runtime: this environment has no global fetch, so no index file can be requested — pass a fetch to createSearchClient
Nothing else changes. A third-party advisory passes through unmarked and
unedited, and an in-process caller of runCli supplies its own err and
receives the message exactly as the CLI composed it — which is why every
assertion in this document quotes unmarked text.
A caller asserts about a spawned run by selecting the marked lines in, never by subtracting prefixes out:
const mine = stderr.split("\n").filter((line) => line.startsWith(DIAGNOSTIC_MARKER));
expect(mine).toEqual([]);pagedeck: is the command's own name rather than a [SHOUT] label, so it does not
read as one more tool's prefix. It is not collision-proof: this repo names its
bundler plugins pagedeck:compile-islands and the like, so a bundler line opening
with a plugin name would be selected too. That fails towards over-reporting
rather than towards a missed diagnostic; a caller that wants the narrow
question matches pagedeck: , with the space markDiagnostic always writes.
Not yet: links to docs
§14b also asks that errors link to the relevant doc. None do, and that still holds after #61.
The docs site now exists — packages/docs builds this file, the ADRs and the
deploy recipe into pages (#576) — but it is not deployed anywhere, so there
is no URL to put in a message. A path is not enough: a build failure is read in
a CI log, where /error-messages resolves to nothing.
When the site has an origin, the messages above are where the links belong: the schema failure to the schema guide, the snapshot scheme failure to the snapshot how-to. Until then, do not add a link to a page nobody can open; a dead URL in a build failure is worse than no URL.
One line names a page instead, by the title and heading the docs site shows.
pagedeck build prints it after its summary when the site declares
build.preview (#713):
preview: /_preview — this app authenticates nothing and renders any draft posted to it; put the deployment behind whatever the drafts need (Pagedeck documentation: Preview app, Security)
It used to name a README path in this repository, which an installed site does not have and which belongs to a private package. A page title survives the move to an origin. When the site has one, the URL goes beside the title.