Skip to content

fix(import): report loop references the importer cannot resolve - #321

Merged
DavidBabinec merged 1 commit into
mainfrom
fix/import-reference-validation
Jul 31, 2026
Merged

fix(import): report loop references the importer cannot resolve#321
DavidBabinec merged 1 commit into
mainfrom
fix/import-reference-validation

Conversation

@DavidBabinec

Copy link
Copy Markdown
Contributor

An invalid loop imported cleanly and published nothing

<instatic-loop data-source-id="posts" data-limit="12">

posts is a table slug; the source id is data.rows and the table goes in data-table-id. The importer copied the string verbatim into props, site_insert_html reported success, verify-docs found the document intact, and at publish loopPrefetch.ts:288-296 turned the unregistered source into a well-formed { items: [], totalItems: 0 } — indistinguishable from "this table has no rows". renderLoop then rendered ''.

The route still returned 200 with a unique title and one <h1>, so every downstream check passed. Two template packs shipped with an empty journal index before anyone looked at the page.

loopSourceRegistry has exposed has() and getOrThrow() the whole time, and nothing under src/core/htmlImport/ ever called either — so the shape site_list_loop_sources documents and the shape the importer accepts were validated against nothing in common. The one place that does look the id up, dynamicDetection.ts:143-152, explicitly treats a miss as "static".

The fix

The walk now checks each loop against that same registry and reports three cases: an unregistered source, no source at all, and a data.rows loop with no tableId — the second silent path, where filters: {} makes dataRows.ts:418 return empty just as quietly.

Live, through the real MCP surface:

BAD  loop  → Loop data-source-id "posts" is not a registered loop source, so it will render
             nothing. Valid source ids: "data.rows", "site.pages", "site.media", "entry.field".
             A table slug is not a source id — use data-source-id="data.rows" with
             data-table-id="<table>".
NO-TABLE   → Loop source "data.rows" needs data-table-id and will render nothing without it.
GOOD loop  → (silent)

All three still insert. Warnings, not rejections: a paste of thirty elements should not be thrown away over one mistyped attribute, and the importer also serves arbitrary user-pasted HTML. site_insert_html and site_replace_node_html return them alongside the ids they already report, so the caller is told at author time.

Warnings live on a WalkResult rather than on ImportFragment — that is the structural shape callers hand back to insertImportedNodes, and a diagnostic has no business in it.

Two descriptions that promised what the composer does not do

<instatic-outlet> is spliced away on page routes — templateCompose.ts:83 deletes the node and the page's content takes its position — so data-tag only takes effect on entry routes, where the outlet stays and wraps the entry body. The module's own doc comment describes this polymorphism correctly; the tool description and system prompt claimed omitting data-tag makes the outlet "own the page's main landmark", which is never true on a page route. That is how a layout ends up with no <main> on any public route.

Both now say what actually happens and tell authors to wrap the outlet themselves. I did not change the rendering: making the outlet a wrapper needs canHaveChildren and a render change against a deliberately polymorphic module, which is a redesign rather than a fix, and it would add a wrapper element to every templated page route.

Verification

Unit — a table slug, a missing id, and a missing table each warn with the fix in the message; a well-formed loop is silent; the node still imports; several bad loops in one payload all report.

Live — inserted all three shapes through MCP against a real site (above), then restored the page and re-audited: 19/19 routes, 0 problems.

Suite: 6505 pass / 0 fail. Lint clean. tsc -b clean.

Worth knowing

bunx tsc --noEmit typechecks nothing in this repo — tsconfig.json is solution-style with only references and no files/include. I confirmed it by planting a syntax error in a server/ file and getting exit 0. tsc -b (what bun run build and CI use) catches it. Anyone verifying locally with --noEmit is running a no-op.

Related

Follows #318 and #320 from the same investigation.

🤖 Generated with Claude Code

`<instatic-loop data-source-id="posts">` — a table slug where a source id
belongs — imported cleanly. The id was copied verbatim into props,
`site_insert_html` reported success, `verify-docs` found the document intact,
and at publish `loopPrefetch` turned the unregistered source into a
well-formed `{ items: [], totalItems: 0 }`, indistinguishable from "this table
has no rows". The section rendered as nothing and every gate passed, because
the route still returned 200 with a unique title and one h1.

`loopSourceRegistry` has had `has()`/`getOrThrow()` the whole time and nothing
under `htmlImport` ever called either, so the shape `site_list_loop_sources`
documents and the shape the importer accepts were checked against nothing in
common. They now share the registry: the walk reports an unregistered source,
a missing source, and a `data.rows` loop with no table — the second silent
path, where `filters: {}` makes `dataRows` return empty just as quietly.

These are warnings, not rejections. A paste of thirty elements should not be
thrown away over one mistyped attribute, and the importer also serves
arbitrary user-pasted HTML. What matters is that the caller is told at author
time rather than finding an empty section in a screenshot later, so
`site_insert_html` and `site_replace_node_html` return them alongside the ids
they already report. Warnings live on the walk result rather than on
`ImportFragment`, which is the structural shape callers hand back to the store.

Also corrected two descriptions that promised behaviour the composer does not
implement. `<instatic-outlet>` is spliced away on page routes — the page's
content replaces it — so `data-tag` only takes effect on entry routes, and a
template that wants pages to have a `<main>` has to wrap the outlet itself.
The module's own doc comment describes this polymorphism correctly; the tool
description and system prompt did not, which is how a layout ends up with no
main landmark on any public route.

Test: a table slug, a missing id, and a missing table each warn with the fix
in the message; a well-formed loop is silent; the node still imports; several
bad loops in one payload all report.
@DavidBabinec
DavidBabinec merged commit 202bbe5 into main Jul 31, 2026
9 checks passed
DavidBabinec added a commit that referenced this pull request Aug 1, 2026
…322)

* fix(import): warn when a loop has no rows to repeat

A loop authored inside a table is destroyed by HTML parsing before the
importer ever sees it. `<tbody>` may only contain `<tr>`, so the parser
foster-parents `<instatic-loop>` out to before the `<table>` AND strips its
children, which stay behind in the tbody.

Both halves survive as perfectly valid nodes: an empty loop that can never
render, and an orphan `<tr>` whose `{currentEntry.*}` bindings resolve to
nothing. So `site_insert_html` reports success, the document persists, every
existing check passes — the source id and table id on the relocated loop are
still correct — and the route publishes a table with exactly one blank row.

Found building template pack 023, where six data tables published as six blank
rows and cleared every gate.

The fix is the general form of the diagnosis: a loop with no children has no
row template and can never render anything, whatever emptied it. That is
checked after recursion, when the child count is known, and reported through
the same `site_insert_html` warnings channel as #321. The message names the
table cause, because there is essentially only one way this happens.

Also documents the constraint in the `site_insert_html` description and the
site system prompt, so the shape that works is stated where a caller reads it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(import): warn when data-order-by is not a sort key

`data-order-by` accepts any string. `fetchPublishedDataRowItems` narrows it
against a four-value whitelist — publishedAt, createdAt, updatedAt, slug — and
falls back to the source default on a miss.

So `data-order-by="sortHour"`, naming a real and populated field on the table,
imports cleanly, publishes, and produces an order that is not the one asked
for. It reads as a mistake in the content rather than a loop that never
sorted.

The allowed values were already declared on the source as `orderByOptions` and
already advertised through `site_list_loop_sources`; nothing checked an
authored loop against them. Now the importer does, and names them in the
message.

Found in pack 023, where a night published as 22:00, 00:30, 23:00.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant