Skip to content

feat(publisher): resolve tokens in loop cell filters - #546

Open
borskyj-symph wants to merge 3 commits into
CoreBunch:mainfrom
borskyj-symph:feat/loop-filter-tokens
Open

borskyj-symph wants to merge 3 commits into
CoreBunch:mainfrom
borskyj-symph:feat/loop-filter-tokens

Conversation

@borskyj-symph

@borskyj-symph borskyj-symph commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

What changed

A loop's cell filter took a literal value, so a filtered loop was unusable on an entry template. The one value that has to differ per rendered row was typed once into a template every row shares, which meant a course page could not list its own dates.

cellValue now accepts the same {...} tokens as any text binding. resolveFilterTokens in server/publish/loopPrefetch.ts interpolates it against the render's TemplateRenderDataContext before the source fetches, so the loop sources and cellFilterSql stay unaware that tokens exist.

A filter that cannot produce a usable value empties the loop instead of unfiltering it. parseCellFilter reads a blank cellValue as "not configured yet" and lists the whole table, which is right for a loop mid-edit and wrong on a published entry route, where it would spill every other row's data onto the page.

Two separate things make a value unusable, and both empty the loop.

A token that did not resolve is the obvious one, and the check runs per token rather than per string. interpolateTokensWithStatus reports whether any token failed, so a value mixing text with a token aborts as well. course-{currentEntry.missing} interpolates to course-, which is not blank, and with isNot that matches nearly every row in the table.

A value that resolved to nothing is the other. A token whose stored cell holds only whitespace is not "missing", so the per-token status stays clean, and an author-written empty fallback {a.b|} fires and counts as resolved for the same reason. Both leave the filter blank, so both abort. A fallback with actual text counts as resolved and is used. A malformed or unknown-source token still emits verbatim and is not treated as a failure.

The abort respects the operator. isTrue, isFalse, isSet and isEmpty never read cellValue, so an unusable value those operators ignore leaves the loop querying normally. cellFilterUsesValue resolves the raw stored operator exactly as parseCellFilter does, so the two cannot disagree.

The render context is threaded through every path that has one: public pages, branch preview, branch review, the Content workspace row preview, and the editor preview runtime. The public paths build the page and site frames themselves before the prefetch, because publishPage fills those frames too late for a filter to read them, and a {page.title} filter would otherwise empty the loop. What publishPage receives is unchanged, so its permalink route fallback still behaves as before. The hole endpoint builds a context with an empty entry stack, so {page.*} and {route.*} filters resolve inside a hole and a {currentEntry.*} one renders empty.

A {route.query.*} filter makes the loop request-dependent. checkInlineTokens in dynamicDetection.ts walks strings nested inside object and array props, so it sees filters.cellValue and defers the loop to a Layer C hole. The hole endpoint keys on the originating page's full query, so two visitors asking for different values never share a cache slot. Only route.query is request-dependent; {currentEntry.*}, {page.*}, {site.*}, route.path and route.slug all stay in the baked page.

What this does not do

A loop nested inside another loop cannot filter on the outer loop's current item. prefetchLoopData resolves every loop node once, keyed by node id, against one shared context, and the per-iteration entry stack is built later by the renderer. An inner {currentEntry.*} filter therefore reads the page's entry on every iteration of the outer loop.

A filter cannot mix {route.query.*} with {currentEntry.*}. The query token defers the loop to a hole, and a hole is fetched by node id with no entry in scope, so the entry half resolves to nothing and the loop renders empty. "{currentEntry.slug}-{route.query.region}" on a course page is the shape that hits this. Making it work needs the hole endpoint to resolve the entry from the request path, which is a separate change.

Both limitations are in docs/features/loops.md. The query and entry mix is tracked as #548.

Impact

An entry template can now carry a filtered loop, which is what lets one template replace a set of hand-maintained pages. A filter without tokens behaves exactly as before, and there is no schema migration.

Three editing surfaces differ, and the difference is the entry each one has in scope. The canvas sends the filter value to the loop-preview endpoint verbatim, matches nothing, and falls back to synthetic items so the loop body stays visible while it is laid out. Preview posts the canvas context to the runtime preview endpoint, which does resolve, against a real published row when the table has one and against a synthetic sample row when it does not. The Content workspace row preview has the edited row in scope and resolves exactly as the published page does.

Verification

  • bun test src/__tests__/server/loopPrefetch.test.ts: 18 pass. The token cases cover a resolved filter, partial resolution with isNot, a whitespace-only resolve, an empty |fallback, a fired |fallback, a valueless operator with an unusable value, an unresolved filter rendering empty, a plain value left untouched, and {site.name} resolving on the public path. The two blank-value tests were confirmed to fail against the previous commit.
  • bun test across dynamic detection, cell filter, loop SQL safety, public rendering, hole routing and the CMS runtime handlers: 135, 120 and 99 pass respectively. One run of apiSecurityBoundary timed out at 5000ms under parallel load and passes when that file runs alone. It touches nothing in this branch.
  • bunx tsc -b
  • bunx eslint on the changed files

🤖 Generated with Claude Code

A loop's cell filter took a literal value, so a filtered loop was
unusable on an entry template: the one value that has to differ per
rendered row was typed once into a template every row shares. A course
page could not list its own dates.

`cellValue` now accepts the same `{...}` tokens as any text binding.
`resolveFilterTokens` interpolates it against the render's
`TemplateRenderDataContext` in `prefetchLoopData`, before the source
fetches, so the sources and `cellFilterSql` stay unaware of tokens.

A token that resolves to nothing empties the loop rather than
unfiltering it. `parseCellFilter` reads a blank value as "not configured
yet" and lists the whole table, which is right for a loop mid-edit and
wrong on a published entry route, where it would spill every other row
onto the page.

The context is threaded through every render path that has one: public
pages, branch preview, branch review, the row preview, and the editor
preview runtime. The hole endpoint builds one with an empty entry stack,
so `{page.*}` and `{route.*}` filters resolve there and `{currentEntry.*}`
renders empty.

Verified with `bun test src/__tests__/server/loopPrefetch.test.ts`,
`bunx tsc -b`, and eslint on the changed files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
borskyj and others added 2 commits September 19, 2026 21:42
Review follow-ups on the tokenized cell filter.

Resolution status is now tracked per token instead of per string.
`interpolateTokensWithStatus` reports whether any token resolved to
nothing without a fallback, so `course-{currentEntry.missing}` no longer
passes the blankness test as `course-` and, with `isNot`, list nearly
every row in the table.

The abort respects the operator. `isTrue`, `isFalse`, `isSet` and
`isEmpty` never read `cellValue`, so an unresolvable token in a value
they ignore no longer empties the loop. `cellFilterUsesValue` resolves
the raw operator exactly as `parseCellFilter` does.

The public render paths build the page and site frames before the loop
prefetch. They were filled later, inside `publishPage`, so a
`{page.title}` or `{site.name}` filter resolved to nothing and rendered
the loop empty. The context handed to `publishPage` is unchanged, so its
permalink route fallback still behaves as before.

Dynamic detection walks strings nested inside object and array props, so
a `{route.query.*}` token in `filters.cellValue` makes the loop a Layer
C hole exactly as the same token in a plain string prop does. The hole
endpoint keys on the full query, so two visitors asking for different
values never share a cache slot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follow-up to ff530bf, found by a second review pass.

Tracking resolution per token dropped the blank-output check, and two
cases slip through it. A token whose cell holds only whitespace is not
"missing", so the token reports a clean resolve. An author-written empty
fallback, `{a.b|}`, fires and counts as resolved for the same reason.
Both leave `cellValue` blank, and `parseCellFilter` reads a blank value
as "not configured yet" and drops the condition, listing the whole
table. That is the spill the guard exists to stop, reintroduced through
a narrower door.

Both conditions now empty the loop, and both stay gated on whether the
operator reads the value at all.

Documented a limitation the nested dynamic detection exposes: a filter
mixing `{route.query.*}` with `{currentEntry.*}` defers the loop to a
hole, and a hole has no entry in scope, so the entry half resolves to
nothing and the loop renders empty. Resolving the entry from the request
path inside the hole endpoint is a separate change.

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

This branch has not been deployed

No deployments
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.

2 participants