diff --git a/.changeset/rfc45-step3a-renderer.md b/.changeset/rfc45-step3a-renderer.md new file mode 100644 index 00000000..8813d4cd --- /dev/null +++ b/.changeset/rfc45-step3a-renderer.md @@ -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`). diff --git a/docs/rfcs/0045-collections-stage-4-references-and-repeaters.md b/docs/rfcs/0045-collections-stage-4-references-and-repeaters.md index b941ed5b..b2bb0163 100644 --- a/docs/rfcs/0045-collections-stage-4-references-and-repeaters.md +++ b/docs/rfcs/0045-collections-stage-4-references-and-repeaters.md @@ -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`. @@ -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. diff --git a/packages/astro-renderer/README.md b/packages/astro-renderer/README.md index bc22f5c7..bad3d1a0 100644 --- a/packages/astro-renderer/README.md +++ b/packages/astro-renderer/README.md @@ -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>`: the members this view shows, by group (snapshot: in memory; live: one store read per call) | +| `GraphSource.referencedBy?` | `(groupId, { collection?, field?, locale?, limit? }) => Promise`: 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`): diff --git a/packages/astro-renderer/src/cloudflare-entry.ts b/packages/astro-renderer/src/cloudflare-entry.ts index fb7a66bc..741c53fb 100644 --- a/packages/astro-renderer/src/cloudflare-entry.ts +++ b/packages/astro-renderer/src/cloudflare-entry.ts @@ -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, @@ -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" }; @@ -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 }, + ); } /** diff --git a/packages/astro-renderer/src/components/EntryFields.astro b/packages/astro-renderer/src/components/EntryFields.astro index fce66ffd..bb71679a 100644 --- a/packages/astro-renderer/src/components/EntryFields.astro +++ b/packages/astro-renderer/src/components/EntryFields.astro @@ -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=""]`. + * 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=""]`, `.ch-references` and + * `.ch-items`. */ -import type { RenderedFields } from "../fields.ts"; +import type { RenderedField, RenderedFields } from "../fields.ts"; interface Props { fields: RenderedFields; @@ -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 && (
{shown.map((f) => (
{f.label}
- {f.html !== undefined ? ( + {f.references !== undefined ? ( + f.references.length === 1 && !Array.isArray(f.value) ? ( +
{f.references[0]?.title}
+ ) : ( +
+ ) + ) : f.items !== undefined ? ( +
    {f.items.map((item) =>
  1. )}
+ ) : f.html !== undefined ? (
) : f.href !== undefined ? (
{f.text}
diff --git a/packages/astro-renderer/src/fields.ts b/packages/astro-renderer/src/fields.ts index 41d44dbb..477fdab9 100644 --- a/packages/astro-renderer/src/fields.ts +++ b/packages/astro-renderer/src/fields.ts @@ -8,12 +8,16 @@ * `asset:` references resolved), never stored HTML; * - URLs: through `safeHref`, dropped when unsafe; * - images: `` attributes from `imageAttributes`, with the asset's alt in the page's language; - * - everything else: `formatFieldValue` text, which themes print escaped. + * - everything else: `formatFieldValue` text, which themes print escaped; + * - RFC-0045: references as cards of the targets this view shows (`references`, depth 1 by + * default, 2 at most, within a per-request budget), repeaters as their items' sub-fields + * (`items`). An unresolved target leaves nothing behind: no id, no title, no empty item. * * Worker-safe; the same function serves live and snapshot sources. */ -import type { Asset, ContentEntry, Site } from "@cloudhal/core-contracts"; +import type { Asset, ContentEntry, FieldDefinition, Site } from "@cloudhal/core-contracts"; import { + type ConversionEntries, type FormatFieldOptions, fieldHelp, fieldLabel, @@ -21,10 +25,19 @@ import { hasFieldValue, type ResolvedField, type ResolvedFields, + referenceUses, resolveFields, } from "@cloudhal/core-ts/fields"; import { renderMarkdown } from "./markdown.ts"; -import { type ImageAttributes, imageAttributes, type MediaContext } from "./media.ts"; +import { + type ImageAttributes, + imageAttributes, + type MediaContext, + type PlaceholderBudget, + REFERENCE_BUDGET, +} from "./media.ts"; +import { conversionsFor } from "./reference-conversions.ts"; +import { type ReferencedEntry, referenceCards } from "./references.ts"; import { safeHref } from "./safe-urls.ts"; export { @@ -37,6 +50,7 @@ export { type ResolvedFields, resolveFields, } from "@cloudhal/core-ts/fields"; +export type { ReferencedEntry } from "./references.ts"; /** An image field's picture: `` attributes plus the asset it shows. */ export interface FieldImage extends ImageAttributes { @@ -49,7 +63,7 @@ export interface RenderedField extends ResolvedField { /** The label in the page's language (then the site's default, then any, then the key). */ label: string; help?: string; - /** Nothing to show: no value, an unreadable one, an unsafe URL or a missing image. */ + /** Nothing to show: no value, an unreadable one, an unsafe URL, a missing image or target. */ empty: boolean; /** The value as plain text in the page's language ("" when empty). Print it escaped. */ text: string; @@ -61,6 +75,10 @@ export interface RenderedField extends ResolvedField { image?: FieldImage; /** `date` (`YYYY-MM-DD`) and `datetime` (the UTC instant): for `