Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/rfc45-step3a-renderer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@cloudhal/astro-renderer": minor
"@cloudhal/core-ts": patch
---

RFC-0045 rollout step 3a: the renderer resolves reference and repeater fields. Both types stay refused on every write path until rollout step 7, so nothing changes for existing sites.

- **`@cloudhal/astro-renderer`.**
- `entryFields` resolves a reference to cards (`RenderedField.references`: `ReferencedEntry` with group id, id, collection, locale, slug, title, summary, the graph `path` to link with (the locale middleware localizes it), the canonical `url`, cover image and `fromFallbackLocale`), in stored order, by `pickMember`'s locale rule among the members the source's view shows, gates included. `depth: 2` adds each card's own fields, with their references as cards without fields. Resolution is breadth-first over a visited set of groups, one `groupMembers` call per level, and spends the request's 200 targets (`PlaceholderBudget.references`; `placeholderBudget()` includes it); past the budget the rest resolve to nothing, with one `console.warn` per request.
- A repeater renders its items' sub-fields (`RenderedField.items`), like fields; empty items are left out.
- An unresolved target leaves nothing behind: no id, no title, no empty item. `value` keeps only the resolved group ids.
- `GraphSource.groupMembers?` and `GraphSource.referencedBy?` on `SnapshotSource` (in memory: gate-independent indexes built once per snapshot, filtered per view) and `LiveSource` (one `entry_refs` read expanded by the `shared` rule, each candidate resolved like the snapshot's index). `referencedBy` lists entries whose resolved values name the group, exactly as each page resolves them (a translation reads its original only while the view shows it), newest first with ties by id, at most 100.
- `GraphSource.conversionEntries?` builds the entry maps of a text ↔ reference conversion while one runs (rollout step 4 adds the conversions).
- `EntryFields.astro` draws references as links (`lang` on a fallback-locale card, a `ul.ch-references` for several) and repeaters as an `ol.ch-items` of nested `dl`s. Old theme copies get both with no change of their own.
- The site Worker's `GET /__cloudhal/features` answers `{ "gates": 2, "fields": 2 }`.
- **`@cloudhal/core-ts`.** `getGroupMembers` and `referrerCandidates` (the live renderer's reads, by `site_id`); `conversionEntries`, `conversionTargets`, `conversionCollections` and `foldText` in `./fields`; `resolveFields` takes an optional fourth argument with the conversion maps (`ConvertContext.entries`).
Original file line number Diff line number Diff line change
Expand Up @@ -1048,6 +1048,7 @@ The riskiest SQL and interplay. Every phase PR's review checks the items its pha
- `RELEASE_EXECUTOR_COST` is updated for the re-plan.
3. **Marker interplay** (phases 2 to 4).
- `rename_field`'s `root_key` and `fromKey` lag in the fragment (over-blocks only) and in the rows `INSERT` (`fromKey` match).
- Step 4: the live `referencedBy` reads candidates from `entry_refs`, and a text value has no row until phase B rewrites its entry. So during a text → reference conversion it lists a converting referrer only after phase B, or step 4 makes it read candidates by collection while such a marker runs. The snapshot's index already resolves through the conversion maps (3a).
- Text → reference conversion: readers convert on the fly, `field_operation_running` refuses target deletes, moves and group changes, and any marker on `from` or `to` purges everything.
- `set_field_localized`'s phase-A `UPDATE` of `shared`.
- `entryRefsRebuild` reading through `fromKey`.
Expand Down Expand Up @@ -1204,3 +1205,8 @@ Rollout step 2 shipped in two PRs: 2a, store and integrity, and 2b, the consumer
- a reference sub-field added over a key that items already hold is `field_key_in_use`;
- the `shared` subquery matches only reference and repeater definitions, as an aggregate, not a correlated `ORDER BY`, which workerd's SQLite does not resolve;
- the list-values fingerprint format is 2, so listed collections rebuild once after the `readsOriginal` tightening.

### Amended in 3a (PR #211, 2026-11-12)

- **`ReferencedEntry.path`** (§4): the member's graph path (`entryPath`, no locale prefix). Themes link with it, so the locale middleware localizes it once, like every other entry link. `url` stays the canonical URL, already localized, and absolute only on a locale's own domain.
- **`referencedBy` follows each page** (§4): a translation resolves with its same-site original while the view shows that original, and without it otherwise, as its page reads it through the gated `entryById`. Ties on date order by id, so both sources agree on order and on what `limit` keeps.
14 changes: 14 additions & 0 deletions packages/astro-renderer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,20 @@ Guide: [`docs/guides/structured-data.md`](../../docs/guides/structured-data.md).

Derived gates: `unlist` keeps an entry's page (200), comments and menu links but leaves `entries()`, `allEntries()`, `entriesByFilter`/`entriesByTerm` (archives, related), feeds, the sitemap and search; `hide` is a 404 like an expired `availability`. Both sources apply them, the gate index caps the edge cache at every derived instant and at every value instant of date/datetime list fields (for `now` conditions), and previews ignore them with an "Ended on …" notice. The site Worker's `GET /__cloudhal/features` answers `{ "gates": 2 }`; writers refuse `expiry` until it does.

### References and repeaters (RFC-0045, rollout step 3)
Additive, and dormant until rollout step 7 lets sites define the two types. `entryFields` (and `source.entryFields`) resolve a `reference` to cards of the targets the page's *view* shows (gates applied at its instant; draft, archived, embargoed and hidden targets resolve to nothing and leave no id, title or empty item) and a `repeater` to its items. `EntryFields.astro` draws both, so old theme copies show them with no change of their own (`theme-v0.8.0` in the compat test).

| Export | Signature |
|---|---|
| `RenderedField.references?` | `ReferencedEntry[]`: `{ groupId, id, collection, locale, slug, title, summary?, path, url, image?, fromFallbackLocale, fields? }` in stored order. Link with `path` (the graph path, which the locale middleware localizes like every entry link); `url` is the canonical URL, already localized; the member in the page's locale, else the default locale's under `localization.fallback: default-with-notice` (flagged: set `lang`) |
| `RenderedField.items?` | `RenderedFields[]`: each repeater item's sub-fields, rendered like fields; empty items left out |
| `EntryFieldsOptions.depth?` | `1` (default): cards; `2`: each card's own `fields`, with *their* references as cards without fields |
| `GraphSource.groupMembers?` | `(groupIds) => Promise<Map<string, ContentEntry[]>>`: the members this view shows, by group (snapshot: in memory; live: one store read per call) |
| `GraphSource.referencedBy?` | `(groupId, { collection?, field?, locale?, limit? }) => Promise<ContentEntry[]>`: entries this view lists whose resolved values name the group (a translation inheriting its original's value included), newest first, at most 100 |
| `PlaceholderBudget.references?` | the request's resolved-target allowance (200, in `placeholderBudget()`); past it targets resolve to nothing, with one `console.warn`. The starter passes one `placeholderBudget()` per request in `media.budget`; a theme that calls `entryFields` itself must do the same, once per request (without one, every call gets its own 200) |

Resolution is breadth-first over a visited set of groups, one `groupMembers` call per level, so cycles cost nothing. A "used by" list drawn from `referencedBy` is purged with the target's page when a referrer appears, goes or changes (the purge planner reads snapshot bodies, rollout step 3b); drawing *those* entries' fields is outside that guarantee. The site Worker's `GET /__cloudhal/features` answers `{ "gates": 2, "fields": 2 }`.

## Site analytics (RFC-0006)

The renderer carries the site side of Cloudhal's cookie-less analytics (`@cloudhal/analytics`):
Expand Down
9 changes: 7 additions & 2 deletions packages/astro-renderer/src/cloudflare-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import {
} from "@cloudhal/astro-renderer/runtime";
import { SqlCommentStore } from "@cloudhal/comments/sql";
import { GATES_FEATURE_VERSION } from "@cloudhal/core-ts/availability";
import { FIELDS_FEATURE_VERSION } from "@cloudhal/core-ts/fields";
import { D1Driver } from "@cloudhal/storage/d1";
import {
ANALYTICS_BEACON_PATH,
Expand Down Expand Up @@ -277,7 +278,8 @@ function fromAdmin(request: Request, env: Env): boolean {

/**
* What this site Worker enforces (RFC-0008 §7.6–§7.7): the admin Worker asks before it lets anyone
* write time gates. Authenticated like the purge (it is not a public endpoint), never cached.
* write time gates, and (RFC-0045 §1, `fields: 2`) reference and repeater fields, which this
* renderer resolves. Authenticated like the purge (it is not a public endpoint), never cached.
*/
function features(request: Request, env: Env): Response {
const headers = { "cache-control": "no-store", "cloudflare-cdn-cache-control": "no-store" };
Expand All @@ -288,7 +290,10 @@ function features(request: Request, env: Env): Response {
});
if (!fromAdmin(request, env))
return Response.json({ error: "unauthorized" }, { status: 401, headers });
return Response.json({ gates: GATES_FEATURE_VERSION }, { headers });
return Response.json(
{ gates: GATES_FEATURE_VERSION, fields: FIELDS_FEATURE_VERSION },
{ headers },
);
}

/**
Expand Down
25 changes: 20 additions & 5 deletions packages/astro-renderer/src/components/EntryFields.astro
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,15 @@
* `source.entryFields(entry, { locale, media })` (or `entryFields(source, entry, …)`) returns.
*
* Values are escaped text, except rich text (the body's markdown pipeline: raw HTML escaped, links
* through `safeHref`) and images (`imageAttributes`, the asset's alt in the page's language). No
* styles of its own: themes style `.ch-fields`, `.ch-field[data-type]` and `[data-field="<key>"]`.
* through `safeHref`) and images (`imageAttributes`, the asset's alt in the page's language).
* RFC-0045: a reference is a link to each target the page's view shows, by its graph path (the
* locale middleware localizes it, like every entry link; `lang` on a card from the fallback
* locale; several in a list), a repeater a nested `dl` per item with something to show.
* Unresolved targets leave nothing: no id, no attribute, no empty link. No styles of its own:
* themes style `.ch-fields`, `.ch-field[data-type]`, `[data-field="<key>"]`, `.ch-references` and
* `.ch-items`.
*/
import type { RenderedFields } from "../fields.ts";
import type { RenderedField, RenderedFields } from "../fields.ts";

interface Props {
fields: RenderedFields;
Expand All @@ -19,14 +24,24 @@ interface Props {
}
const { fields, label, class: className } = Astro.props;
const shown = Object.values(fields).filter((f) => !f.empty);
const multiline = (f: (typeof shown)[number]) => f.def.type === "text" && f.def.multiline === true;
const multiline = (f: RenderedField) => f.def.type === "text" && f.def.multiline === true;
const langOf = (c: { fromFallbackLocale: boolean; locale: string }) =>
c.fromFallbackLocale ? c.locale : undefined;
---
{shown.length > 0 && (
<dl class:list={["ch-fields", className]} aria-label={label}>
{shown.map((f) => (
<div class="ch-field" data-field={f.key} data-type={f.def.type} data-multiline={multiline(f) ? "" : undefined} data-from-original={f.fromOriginal ? "" : undefined}>
<dt>{f.label}</dt>
{f.html !== undefined ? (
{f.references !== undefined ? (
f.references.length === 1 && !Array.isArray(f.value) ? (
<dd><a href={f.references[0]?.path} lang={f.references[0] && langOf(f.references[0])}>{f.references[0]?.title}</a></dd>
) : (
<dd><ul class="ch-references">{f.references.map((c) => <li><a href={c.path} lang={langOf(c)}>{c.title}</a></li>)}</ul></dd>
)
) : f.items !== undefined ? (
<dd><ol class="ch-items">{f.items.map((item) => <li><Astro.self fields={item} /></li>)}</ol></dd>
) : f.html !== undefined ? (
<dd set:html={f.html} />
) : f.href !== undefined ? (
<dd><a href={f.href}>{f.text}</a></dd>
Expand Down
Loading
Loading