feat(publisher): resolve tokens in loop cell filters - #546
Open
borskyj-symph wants to merge 3 commits into
Open
borskyj-symph wants to merge 3 commits into
borskyj-symph wants to merge 3 commits into
Conversation
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>
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
cellValuenow accepts the same{...}tokens as any text binding.resolveFilterTokensinserver/publish/loopPrefetch.tsinterpolates it against the render'sTemplateRenderDataContextbefore the source fetches, so the loop sources andcellFilterSqlstay unaware that tokens exist.A filter that cannot produce a usable value empties the loop instead of unfiltering it.
parseCellFilterreads a blankcellValueas "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.
interpolateTokensWithStatusreports whether any token failed, so a value mixing text with a token aborts as well.course-{currentEntry.missing}interpolates tocourse-, which is not blank, and withisNotthat 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,isSetandisEmptynever readcellValue, so an unusable value those operators ignore leaves the loop querying normally.cellFilterUsesValueresolves the raw stored operator exactly asparseCellFilterdoes, 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
publishPagefills those frames too late for a filter to read them, and a{page.title}filter would otherwise empty the loop. WhatpublishPagereceives 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.checkInlineTokensindynamicDetection.tswalks strings nested inside object and array props, so it seesfilters.cellValueand 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. Onlyroute.queryis request-dependent;{currentEntry.*},{page.*},{site.*},route.pathandroute.slugall 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.
prefetchLoopDataresolves 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 withisNot, 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 testacross dynamic detection, cell filter, loop SQL safety, public rendering, hole routing and the CMS runtime handlers: 135, 120 and 99 pass respectively. One run ofapiSecurityBoundarytimed out at 5000ms under parallel load and passes when that file runs alone. It touches nothing in this branch.bunx tsc -bbunx eslinton the changed files🤖 Generated with Claude Code