@murumets-ee/content 0.78.0 → 0.80.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  import { Entity } from "@murumets-ee/entity";
2
2
  import { ToolkitApp } from "@murumets-ee/core";
3
- import { CollectionResult, CollectionSourceSpec, FindManyClientLike, TemplateNamespace } from "@murumets-ee/blocks/server";
3
+ import { CollectionFieldLookup, CollectionResult, CollectionSourceSpec, FindManyClientLike, TemplateNamespace } from "@murumets-ee/blocks/server";
4
4
 
5
5
  //#region src/block-resolver.d.ts
6
6
  /** Structural mirror of `content-api`'s `MediaProjection`. */
@@ -97,7 +97,8 @@ interface ResolvedBlockTree {
97
97
  */
98
98
  interface BlockCollectionDeps {
99
99
  readonly sources: readonly CollectionSourceSpec[];
100
- readonly sourceFieldOf: (blockType: string) => string | undefined;
100
+ /** Name AND field restriction (kulli-editor F019) — `serverCollectionFieldLookup()`. */
101
+ readonly sourceFieldOf: CollectionFieldLookup;
101
102
  readonly getEntity: (name: string) => Entity | undefined;
102
103
  /** A PUBLISHED-only reader bound to the ambient request context — the caller. */
103
104
  readonly clientFor: (entity: Entity) => FindManyClientLike;
@@ -1 +1 @@
1
- {"version":3,"file":"block-resolver.d.mts","names":[],"sources":["../src/block-resolver.ts"],"mappings":"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"}
1
+ {"version":3,"file":"block-resolver.d.mts","names":[],"sources":["../src/block-resolver.ts"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"block-resolver.mjs","names":[],"sources":["../src/block-resolver.ts"],"sourcesContent":["/**\n * The block-tree resolver for `@murumets-ee/content-api`'s `resolveBlocks` seam\n * (`frontend-delivery` D019).\n *\n * This is the half `content-api` structurally cannot do: it walks a stored\n * tree, re-resolves the media refs inside it through the public-only media\n * path, and turns the `LinkValue`s inside it into hrefs against the published\n * routing index. `content-api` is a leaf — `blocks`, `media` and this package\n * all depend back on it — so it takes this as an injected provider, exactly as\n * it takes chrome, menus and routing resolution.\n *\n * ## What it emits, and why that shape\n *\n * A `ResolvedBlockTree` envelope: `{ resolved: 1, blocks, links, unresolved? }`.\n * The reasoning is D019's and is restated where it binds the code below, but\n * the two-sentence version is: links go into a `linkValueKey`-keyed sidecar\n * because that is exactly what every renderer already consumes, and media is\n * replaced IN PLACE because a media node is not an opaque pointer — it already\n * carries a url that every render reads, and that url has to be overwritten\n * regardless.\n *\n * ## 🔴 The security property this exists for\n *\n * A `field.media()` value inside a block tree is stored as a whole `MediaRef`\n * including a `url`, denormalised when the editor picked it. That url comes\n * from `StorageClient.getUrl`, which returns a **signed** url for any file that\n * is not public — and `StorageClient.upload` defaults to private. So the stored\n * tree can carry a bearer capability, and serving it unchanged puts that\n * capability on a `public, max-age=60` response any shared CDN will hand to\n * every later anonymous visitor. That is precisely the leak `getPublicUrls` and\n * `plan/api` D021 made structurally impossible for TOP-LEVEL media fields; the\n * block-tree path routed around it because the url stopped being a url and\n * became content.\n *\n * So the stored url is **discarded, never used as a fallback**. An id that does\n * not resolve becomes `null`, because \"did not resolve\" and \"is not public\" are\n * the same branch in the shipped resolver — a fallback would preserve the leak\n * exactly in the case that matters.\n *\n * ## 🔴 The fan-out property\n *\n * `resolveLinks`' own docblock hands the COUNT bound to whoever wires it: it\n * caps peak concurrency and deliberately does not cap how many links it\n * resolves. A naive wiring would give it a seam that hits the database per\n * distinct link — one publish check plus one path read each — on an anonymous\n * request path, which is verbatim what CLAUDE.md's no-unbounded-fan-out section\n * forbids. So this module resolves everything FIRST, batched by entity type\n * (two index reads per type, regardless of link count), and hands `resolveLinks`\n * a pure map lookup. What it costs is bounded by the number of distinct entity\n * TYPES a document links to, not by the number of links.\n *\n * ## Collections — rows, through the caller's own gate (editor PR53)\n *\n * A COLLECTION block's rows are resolved here too, into the envelope's additive\n * `collections` sidecar, through `@murumets-ee/blocks/server`'s\n * `resolveTreeCollections` — the ONE resolver the canvas calls as well. Three\n * properties make that safe on an anonymous path:\n *\n * - The reader is the CALLER, not an elevated identity: rows come through a\n * `QueryClient` bound to the ambient request context, and the provider may\n * only list an entity content-api's own whitelist ∧ `view` gate admits\n * (`canReadEntity`, handed in by the handler). Without that predicate, no\n * collection resolves at all — deny-by-default, never a guess.\n * - Only the card's mapped slots cross, never the row.\n * - It is bounded: at most `MAX_COLLECTIONS_PER_DOCUMENT` per tree, serial,\n * each claiming one slot of the host's per-RESPONSE collection allowance\n * (`claimCollection` — a list response resolves many trees) and one unit of\n * the response's resolution budget. No allowance, no collections.\n *\n * ## What it deliberately does not do\n *\n * Plain `query()` fields (the fixed-entity `articleList` / `relatedPosts`\n * shells) stay unresolved: nothing renders their rows, and the collection block\n * is the dynamic listing D001 asked for.\n */\n\nimport { collectLinkValues, type LinkValue, linkValueKey } from '@murumets-ee/blocks/links'\nimport {\n type CollectionCardImage,\n type CollectionResult,\n type CollectionSourceSpec,\n collectBoundColumnsInTree,\n createCollectionReader,\n effectiveCollectionSources,\n type FindManyClientLike,\n materializeServerTemplate,\n readSpotCrop,\n resolveLinks,\n resolveTreeCollections,\n type SpotCrop,\n sameSpotCrop,\n serverBlockStructureLookup,\n serverBoundColumnsInTree,\n serverCollectionFieldLookup,\n serverTemplateMaterializer,\n type TemplateNamespace,\n templateNamespace,\n} from '@murumets-ee/blocks/server'\nimport type { ToolkitApp } from '@murumets-ee/core'\nimport type { Entity } from '@murumets-ee/entity'\n\nimport { getCanonicalPaths } from './paths/enumerate.js'\nimport { buildEnumerateDeps } from './paths/resolve-context.js'\n\n// ---------------------------------------------------------------------------\n// Structural mirrors of `content-api`'s wire types\n// ---------------------------------------------------------------------------\n//\n// Mirrored rather than imported, for the reason every other provider in this\n// package mirrors its counterpart: `content-api` is the leaf, and the app that\n// wires the two is where TypeScript checks they agree.\n\n/** Structural mirror of `content-api`'s `MediaProjection`. */\nexport interface BlockMediaProjection {\n id: string\n url: string\n mimeType: string | null\n width: number | null\n height: number | null\n alt: string | null\n title: string | null\n variants: Record<string, string>\n sources: Record<string, Array<{ url: string; width: number; height: number }>>\n avifSources?: Record<string, Array<{ url: string; width: number; height: number }>>\n focalPoint: { x: number; y: number }\n blurDataUrl: string | null\n /**\n * The files cut for each spot crop the photograph has files for (images\n * PR16) — present only when the resolver was ASKED (`{ spots: true }`) and\n * the photograph records some. Never embedded in a tree: {@link writeSlot}\n * picks the one list a placement's own `crop` names and drops the rest.\n */\n spotSources?: BlockSpotSources[]\n}\n\n/** One spot crop's files, as the media resolver publishes them. */\nexport interface BlockSpotSources {\n crop: { x: number; y: number; w: number; h: number }\n sources: Array<{ url: string; width: number; height: number }>\n avifSources?: Array<{ url: string; width: number; height: number }>\n}\n\n/**\n * Structural mirror of `content-api`'s `ResolveMedia`.\n *\n * The SAME function an app passes as `ContentApiConfig.resolveMedia` — pass one\n * value to both, so a media ref inside a block cannot resolve through a\n * different path (or a different publish gate) than a top-level media field.\n */\nexport type BlockMediaResolver = (\n ids: readonly string[],\n opts?: {\n locale?: string | undefined\n /**\n * Also publish the files cut for spot crops (images PR16). Asked by the\n * BLOCK walk only — a top-level media field has no placement crop, so its\n * projection stays exactly what it was.\n */\n spots?: boolean | undefined\n },\n) => Promise<Map<string, BlockMediaProjection>>\n\n/** Structural mirror of `content-api`'s `UnresolvedBlockRef`. */\nexport interface UnresolvedBlockRef {\n readonly kind: 'media' | 'link'\n readonly ref: string\n readonly reason: 'unresolvable' | 'budget'\n}\n\n/** Structural mirror of `content-api`'s `BlockTreeRef`. */\nexport interface BlockTreeRef {\n readonly entity: string\n readonly id: string\n}\n\n/** Structural mirror of `content-api`'s `ResolvedBlockTree`. */\nexport interface ResolvedBlockTree {\n readonly resolved: 1\n readonly blocks: readonly unknown[]\n readonly links: Readonly<Record<string, string | null>>\n readonly unresolved?: readonly UnresolvedBlockRef[]\n readonly refs?: readonly BlockTreeRef[]\n readonly collections?: Readonly<Record<string, CollectionResult>>\n}\n\n/**\n * What resolving COLLECTION blocks needs from the app — built once per app by\n * {@link createBlockResolver}, absent when the app declares no sources.\n */\nexport interface BlockCollectionDeps {\n readonly sources: readonly CollectionSourceSpec[]\n readonly sourceFieldOf: (blockType: string) => string | undefined\n readonly getEntity: (name: string) => Entity | undefined\n /** A PUBLISHED-only reader bound to the ambient request context — the caller. */\n readonly clientFor: (entity: Entity) => FindManyClientLike\n}\n\n/**\n * The slice of `content-api`'s `ResolutionBudget` this provider uses —\n * narrowed to `claim`, for the reason `ChromeResolutionBudget` states: a\n * provider asks for work and is told how much it may do; it has no business\n * reading the remaining count or the drop summary.\n */\nexport interface BlockResolutionBudget {\n claim: (kind: 'block', n: number) => number\n}\n\nexport interface BlockResolverLogger {\n debug: (obj: object, msg: string) => void\n warn: (obj: object, msg: string) => void\n}\n\n/** Everything the pure resolver needs, injected so it is testable without an app. */\nexport interface BlockResolverDeps {\n /** Public-only media resolution. Omit and every media ref resolves to `null`. */\n resolveMedia?: BlockMediaResolver | undefined\n /**\n * PUBLISHED-only canonical paths for many ids of one entity type, in one\n * locale, as `entityId → path`.\n *\n * **The publish gate is this function's responsibility and the whole reason\n * it is batched rather than per-id.** An unpublished target must yield no\n * entry, or a block link leaks an unpublished document's URL — the obligation\n * `ResolveBlocks`' own docblock puts on the wiring.\n */\n resolvePublishedPaths: (\n entityType: string,\n ids: readonly string[],\n locale: string,\n ) => Promise<Map<string, string>>\n logger?: BlockResolverLogger | undefined\n /** Collection resolution. Omit and collection blocks resolve no rows. */\n collections?: BlockCollectionDeps | undefined\n /**\n * Rebuild a CODED TEMPLATE and name the columns its blocks bind (editor\n * D017, frontend-delivery F109). Defaults to the server block registry's\n * `materializeServerTemplate`; injectable so a test need not seed a registry.\n *\n * `namespace` is the document the ids belong to (editor F124) — see\n * `resolveBlockTree`, which derives it from the entity and the row's id.\n */\n materializeTemplate?:\n | ((\n template: string,\n namespace: TemplateNamespace,\n ) => {\n readonly blocks: unknown[]\n readonly boundColumns: readonly string[]\n })\n | undefined\n /**\n * The columns the blocks in an ORDINARY stored tree bind. Defaults to the\n * server registry's `serverBoundColumnsInTree`; injectable for the same reason\n * as {@link materializeTemplate}.\n */\n boundColumnsOf?: ((tree: readonly unknown[]) => readonly string[]) | undefined\n}\n\n// ---------------------------------------------------------------------------\n// Media refs inside a tree\n// ---------------------------------------------------------------------------\n\n/**\n * How deep the media walk descends, in JSON levels.\n *\n * A stored tree is authenticated-editor JSON, not anonymous input — but it is\n * still data crossing a process boundary, and an unbounded recursion over it is\n * a stack overflow waiting for one pathological document, which under on-demand\n * rendering is a 500 on a live page for every visitor.\n *\n * 🔴 It counts JSON levels, not BLOCK levels, and the two are not the same: one\n * block of nesting costs about three (`block → children → block`, plus one for\n * `props`). This was 64, matching `collectLinkValues`, which sounded principled\n * and was wrong — `op-commit` permitted 64 BLOCK levels then, so a media ref\n * below roughly the 21st block was never visited, its stored url crossed\n * unchanged, and nothing said so. A bound that silently stops enforcing a\n * security control is worse than no bound, because it reads as coverage.\n *\n * 256 covered those 64 block levels with room to spare, and now covers\n * `MAX_BLOCK_DEPTH` (32, the one reconciled persist-and-render cap) with twice\n * that. Left at 256 rather than retuned: the headroom costs nothing, and a\n * bound that only just fits is the shape this comment exists to warn about.\n * Exceeding it is REPORTED rather than returned in silence. `collectLinkValues`\n * keeps its own 64 — that one bounds a display concern (an unresolved link\n * renders as plain text), not a value that must not reach the wire.\n */\nconst MAX_WALK_DEPTH = 256\n\n/**\n * How deep LINK collection descends — `collectLinkValues`' own default, stated\n * here so the two bounds are visibly independent rather than accidentally\n * shared. An unresolved link renders as plain text; an unvisited media ref\n * keeps a url that may be a capability. Different stakes, different bounds.\n *\n * ⚠️ Unlike the media walk, this one truncates SILENTLY: `collectLinkValues`\n * returns what it found and reports nothing, so a link below the bound is\n * absent from the sidecar, absent from `unresolved`, and renders as plain text\n * with no signal anywhere. That is tolerable only because the failure is\n * cosmetic — and it is stated rather than left to be discovered, because the\n * media walk one constant up made exactly the opposite call for exactly the\n * opposite reason.\n */\nconst LINK_WALK_DEPTH = 64\n\n/**\n * Entity ids are UUIDs (user ids are not, but no user id is ever a media ref).\n * Requiring the shape here is what makes structural detection safe: a prop\n * object carrying BOTH a uuid `id` AND a non-empty `url` string, with every\n * optional `MediaRef` field well-typed, is a media ref by any usable\n * definition.\n */\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\n/**\n * `Array.isArray(x)` on an `unknown` narrows to `any[]`, which would put an\n * `any` into every element of a walk over untyped JSON — in a package holding a\n * type-coverage floor. Narrowing through an explicit predicate keeps elements\n * `unknown`.\n */\nfunction isUnknownArray(value: unknown): value is unknown[] {\n return Array.isArray(value)\n}\n\n/**\n * Every key a `MediaRef` or a `MediaProjection` may carry — and NOTHING ELSE\n * may be present for a value to count as one.\n *\n * 🔴 This is a WHITELIST, and the difference is not stylistic. Checking only\n * that the known keys are well-typed made the detector match any object with a\n * uuid `id` and a `url` — a project's `{ id, url, provider: 'youtube' }` prop,\n * say. That object would then be REPLACED by `null` (no media row for its id),\n * the consumer-side validator would drop the whole prop, and the block would\n * render empty with an `unresolved` entry calling it media. Silent data loss,\n * misdescribed in the one channel meant to report it.\n *\n * An unrecognised key means \"this is not a media ref\", so the failure direction\n * is leaving a value alone rather than deleting it.\n */\nconst MEDIA_REF_KEY_MAP: Record<keyof BlockMediaProjection | 'crop', true> = {\n id: true,\n url: true,\n mimeType: true,\n width: true,\n height: true,\n alt: true,\n title: true,\n variants: true,\n // A PROJECTION key, like `variants` and `focalPoint` — listed so a projection\n // is still recognised on a second pass. The STORED `MediaRef` never carries it\n // (images D007), so no stored ref changes shape.\n sources: true,\n // Projection-only too (images PR12), and absent unless AVIF is on.\n avifSources: true,\n focalPoint: true,\n // Projection-only as well (images PR11) — the stored ref never carries it.\n blurDataUrl: true,\n // A STORED key (images PR16, D002): the one crop a placement carries — author\n // data about the spot, validated at every write. Listed in the same commit\n // that let the ref carry it: an unrecognised key makes the detector walk\n // past the ref, and its stored url would cross unresolved (CLAUDE.md,\n // Images fact 2). Kept on the embedded value, so a second pass still sees it.\n crop: true,\n // Projection-only, and never embedded — `writeSlot` consumes it. Listed\n // because the map is tied to the projection's keys by type.\n spotSources: true,\n}\n\n/**\n * Derived from the map above rather than written out beside it, so ADDING a\n * field to the projection is a COMPILE error until this list learns about it.\n *\n * That tie is the whole reason the map exists. A bare literal set would have\n * made the whitelist fail-OPEN and silent: give `MediaRef` a `caption` (which\n * the image block already renders, so it is a plausible next change) and every\n * captioned ref quietly stops being recognised — not collected, not\n * re-resolved, not nulled, and not reported — so its stored url crosses\n * verbatim. That is the same \"a bound that silently stops enforcing a security\n * control reads as coverage\" this file says about {@link MAX_WALK_DEPTH}, and\n * it would have been true one field later.\n */\nconst MEDIA_REF_KEYS = new Set(Object.keys(MEDIA_REF_KEY_MAP))\n\n/**\n * The media id in `value`, or `null` when it is not a media ref.\n *\n * Structural, deliberately — the same technique `asLinkValue` uses for links,\n * and for the same reason: a resolver that needed the block REGISTRY to know\n * which props are media would need every project to register its vocabulary at\n * the content-api mount, which is a second place to forget. Detection that\n * reads the value cannot go stale.\n *\n * Optional fields accept `null` as well as `undefined` so the check is\n * idempotent: a `MediaProjection` (whose optionals are `T | null`) is still\n * recognised, rather than being silently skipped on a second pass.\n */\nfunction mediaRefId(value: unknown, onNearMiss?: (keys: readonly string[]) => void): string | null {\n if (!isRecord(value)) return null\n const id = value.id\n if (typeof id !== 'string' || !UUID_RE.test(id)) return null\n if (typeof value.url !== 'string' || value.url === '') return null\n // The two cheap discriminators are checked FIRST, so the key scan runs only\n // for the handful of values that already look like a ref rather than for\n // every object in the tree.\n const unknownKeys = Object.keys(value).filter((key) => !MEDIA_REF_KEYS.has(key))\n if (unknownKeys.length > 0) {\n // 🔴 REPORTED, not silently declined. A value with a uuid `id` and a `url`\n // that this detector walks past is either a project prop we correctly left\n // alone or a media ref we failed to recognise — and the second is a stored\n // url reaching the wire with nothing anywhere saying so. The caller cannot\n // tell the two apart, and neither can this function; what it can do is\n // refuse to be quiet about the ambiguity.\n onNearMiss?.(unknownKeys)\n return null\n }\n if (!isOptional(value.alt, 'string')) return null\n if (!isOptional(value.title, 'string')) return null\n if (!isOptional(value.mimeType, 'string')) return null\n if (!isOptional(value.width, 'number')) return null\n if (!isOptional(value.height, 'number')) return null\n // Projection-only, like `focalPoint`: a string when present, so a project\n // prop holding something else there is left alone rather than claimed.\n if (!isOptional(value.blurDataUrl, 'string')) return null\n // `variants` is the one whitelisted key the type checks above do not cover,\n // and skipping it left an over-match: `{ id: <uuid>, url: '…', variants: 'x' }`\n // passed the whitelist and was replaced by a projection or by `null`. That is\n // precisely the direction the whitelist exists to prevent.\n if (value.variants !== undefined && value.variants !== null && !isRecord(value.variants)) {\n return null\n }\n // Same reasoning for `focalPoint`, which only a PROJECTION carries (a stored\n // ref never does — images D007): recognised on a second pass, never an\n // over-match on a project prop that happens to hold a non-object there.\n if (value.focalPoint !== undefined && value.focalPoint !== null && !isRecord(value.focalPoint)) {\n return null\n }\n // And `sources`, for the same reason — projection-only, and when present an\n // object of ARRAYS (shape name → files). Checked one level deep, because a\n // bare \"is an object\" let `{ id: <uuid>, url, sources: { natural: 'x' } }`,\n // a project's own prop, be claimed as media and replaced (CodeRabbit).\n if (value.sources !== undefined && value.sources !== null) {\n if (!isRecord(value.sources)) return null\n if (!Object.values(value.sources).every((list) => Array.isArray(list))) return null\n }\n // `avifSources` has `sources`' shape and the same over-match risk.\n if (value.avifSources !== undefined && value.avifSources !== null) {\n if (!isRecord(value.avifSources)) return null\n if (!Object.values(value.avifSources).every((list) => Array.isArray(list))) return null\n }\n // A spot crop is an object. A project prop holding something else under\n // `crop` is left alone rather than claimed. An object that is not a VALID\n // crop is still a media ref — the write gate refuses those, and a stored one\n // is drawn around the focal point (`readSpotCrop` is lenient) rather than\n // being left unresolved with its stored url.\n if (value.crop !== undefined && value.crop !== null && !isRecord(value.crop)) return null\n if (value.spotSources !== undefined && value.spotSources !== null) {\n if (!Array.isArray(value.spotSources)) return null\n }\n return id\n}\n\nfunction isOptional(value: unknown, type: 'string' | 'number'): boolean {\n return value === undefined || value === null || typeof value === type\n}\n\n/** One place in the tree holding a media ref, so it can be overwritten after resolution. */\ninterface MediaSlot {\n readonly container: Record<string, unknown> | unknown[]\n readonly key: string | number\n readonly id: string\n /** The placement's own crop (images PR16), read when the slot was found. */\n readonly crop?: SpotCrop | undefined\n}\n\n/**\n * Collect every media ref in the tree, recording WHERE it sits.\n *\n * Slots rather than a returned copy because the replacement is in place, which\n * is the same shape `projectMedia` uses on entity rows for the same reason: the\n * tree came from a per-request read and copying it would double the cost of the\n * largest thing on the page.\n */\n/** What the walk noticed but could not act on. Both are ambiguity, not failure. */\ninterface WalkReport {\n /** A subtree below {@link MAX_WALK_DEPTH} was never visited. */\n truncated: boolean\n /** Keys that made a ref-shaped value fall outside the whitelist, deduped. */\n nearMissKeys: Set<string>\n}\n\nfunction collectMediaSlots(\n node: unknown,\n out: MediaSlot[],\n depth: number,\n report: WalkReport,\n): void {\n // Flagged, never silent — and flagged rather than logged, so a pathological\n // tree cannot turn the operator log into a hot loop. Below the bound a media\n // ref is not visited, so its stored url would cross unchanged.\n if (depth > MAX_WALK_DEPTH) {\n report.truncated = true\n return\n }\n const noteNearMiss = (keys: readonly string[]): void => {\n for (const key of keys) report.nearMissKeys.add(key)\n }\n if (isUnknownArray(node)) {\n for (let index = 0; index < node.length; index += 1) {\n const id = mediaRefId(node[index], noteNearMiss)\n if (id !== null)\n out.push({ container: node, key: index, id, crop: readSpotCrop(node[index]) })\n else collectMediaSlots(node[index], out, depth + 1, report)\n }\n return\n }\n if (!isRecord(node)) return\n for (const [key, value] of Object.entries(node)) {\n const id = mediaRefId(value, noteNearMiss)\n if (id !== null) out.push({ container: node, key, id, crop: readSpotCrop(value) })\n else collectMediaSlots(value, out, depth + 1, report)\n }\n}\n\n/**\n * The projection as it goes INTO a block tree: null-valued optionals OMITTED.\n *\n * 🔴 Found by rendering a real page in a browser, not by a unit test — the\n * tests all used a projection whose `width`/`height`/`alt` happened to be\n * populated. `MediaProjection` declares its optionals `T | null` because a\n * top-level media field publishes a fixed key set. `MediaRef` — the type every\n * block render is typed against, and the one the consumer-side validator\n * narrows to — declares them `T | undefined`, so a JSON `null` there is not a\n * value any render can hold. The front-end validator did exactly what it should\n * with one: dropped it, and logged \"block props were dropped as unusable\" for\n * `image.width`, `image.height` and `image.alt` on EVERY render of every page\n * carrying an image. Correct behaviour, useless signal — the log that exists to\n * report real data loss would have been drowned by it from day one.\n *\n * Absent and null mean the same thing for every one of these fields, so\n * omitting is lossless. What it buys is a value that is assignable to the type\n * the renders declare, which is the whole reason `MediaRef` carries a url at\n * all.\n */\nfunction toEmbeddedProjection(projection: BlockMediaProjection): Record<string, unknown> {\n const out: Record<string, unknown> = {\n id: projection.id,\n url: projection.url,\n variants: projection.variants,\n // Always embedded, like `variants`: the render reads it to crop a shaped\n // slot, and a projection always carries one (`{0.5, 0.5}` when the row has\n // none), so there is no null to omit.\n focalPoint: projection.focalPoint,\n // Always embedded too: an empty map is \"nothing generated yet\", which a\n // render reads as \"no srcset\" — there is no null to omit.\n sources: projection.sources,\n }\n // Only when there are some (images PR12): the tree is serialised into the\n // page, so an empty key would change every page of a site with AVIF off.\n if (projection.avifSources !== undefined && Object.keys(projection.avifSources).length > 0) {\n out.avifSources = projection.avifSources\n }\n if (projection.mimeType !== null) out.mimeType = projection.mimeType\n if (projection.width !== null) out.width = projection.width\n if (projection.height !== null) out.height = projection.height\n if (projection.alt !== null) out.alt = projection.alt\n if (projection.title !== null) out.title = projection.title\n // Omitted when there is none, like the other optionals: most photographs on\n // a site that predates images PR11 have none until the catch-up reaches them.\n if (projection.blurDataUrl !== null) out.blurDataUrl = projection.blurDataUrl\n return out\n}\n\n/**\n * The files cut for THIS placement's crop, or `undefined` when the worker has\n * not cut them yet (or the placement has no crop). Matched on the four-decimal\n * grid (`sameSpotCrop`).\n */\nfunction spotSourcesFor(\n projection: BlockMediaProjection,\n crop: SpotCrop | undefined,\n): BlockSpotSources | undefined {\n if (crop === undefined || projection.spotSources === undefined) return undefined\n return projection.spotSources.find(\n (spot) => sameSpotCrop(spot.crop, crop) && spot.sources.length > 0,\n )\n}\n\n/**\n * The embedded value for a placement with a spot crop (images PR16, D002).\n *\n * - **Its files exist** — the placement's value becomes THOSE files: `sources`\n * holds the crop's list under `natural` (the crop IS this placement's\n * picture, at the crop's own ratio), `url`/`width`/`height` are the largest\n * file's, and the focal point is the centre (the file is already the crop).\n * So a render selects from the crop's files alone — the original is never\n * appended above them, because it is no longer larger than the value's own\n * width — and CSS has nothing left to crop. `avifSources` follows only when\n * the crop's AVIF files are exactly its WebP list, re-encoded.\n * - **They do not exist yet** — the projection as any placement gets it: the\n * focal-point files, never the original (D002). The crop is kept on the\n * value either way, so a second pass sees the same placement.\n *\n * A placement with NO crop never reaches this function, so its embedded value\n * — and the markup a render makes of it — is byte-identical to before.\n */\nfunction embedCropped(projection: BlockMediaProjection, crop: SpotCrop): Record<string, unknown> {\n const out = toEmbeddedProjection(projection)\n const spot = spotSourcesFor(projection, crop)\n const largest = spot?.sources[spot.sources.length - 1]\n if (spot !== undefined && largest !== undefined) {\n out.url = largest.url\n out.width = largest.width\n out.height = largest.height\n out.focalPoint = { x: 0.5, y: 0.5 }\n out.sources = { natural: spot.sources }\n const avif = spot.avifSources\n const avifMatches =\n avif !== undefined &&\n avif.length === spot.sources.length &&\n avif.every(\n (file, i) =>\n file.width === spot.sources[i]?.width && file.height === spot.sources[i]?.height,\n )\n if (avifMatches) out.avifSources = { natural: avif }\n else delete out.avifSources\n }\n out.crop = { x: crop.x, y: crop.y, w: crop.w, h: crop.h }\n return out\n}\n\nfunction writeSlot(slot: MediaSlot, value: BlockMediaProjection | null): void {\n const written =\n value === null\n ? null\n : slot.crop === undefined\n ? toEmbeddedProjection(value)\n : embedCropped(value, slot.crop)\n if (Array.isArray(slot.container)) {\n if (typeof slot.key === 'number') slot.container[slot.key] = written\n return\n }\n if (typeof slot.key === 'string') slot.container[slot.key] = written\n}\n\n// ---------------------------------------------------------------------------\n// The resolver\n// ---------------------------------------------------------------------------\n\n/**\n * Resolve one stored block tree into a {@link ResolvedBlockTree}.\n *\n * **Throws when `blocks` is not an array**, which is how a corrupt stored value\n * is handled rather than papered over — a resolver that returned `{ resolved: 1,\n * blocks: [] }` for it would report success while deleting a page's content,\n * and the caller could not tell the two apart.\n *\n * ⚠️ **What the caller does with that throw CHANGED** (editor D010, closing\n * F056). It used to serve the stored tree unchanged, so a malformed row cost\n * its own hrefs and nothing else. It no longer does: it re-calls this function\n * with an exhausted budget and, if that fails too, serves an empty envelope. A\n * non-array `blocks` fails this guard DETERMINISTICALLY, so a corrupt column is\n * now precisely the case that reaches that third state and is blanked. That is\n * the intended outcome rather than a regression — a non-array stored value\n * cannot be rendered by any consumer, and it may still be an object carrying\n * media refs whose urls were signed at author time.\n */\nexport async function resolveBlockTree(\n blocks: unknown,\n opts: {\n locale: string\n budget: BlockResolutionBudget\n entity: string\n field: string\n /** content-api's whitelist ∧ `view` gate for THIS caller. Absent → no collection rows. */\n canReadEntity?: ((entityName: string) => boolean) | undefined\n /** One slot of the response's collection allowance. Absent → no collection rows. */\n claimCollection?: (() => boolean) | undefined\n /** A CODED TEMPLATE's root block name — `blocks` is then ignored and the template rebuilt. */\n template?: string | undefined\n /** The row a templated document's bound blocks read. Read, never written. */\n boundValues?: Readonly<Record<string, unknown>> | undefined\n },\n deps: BlockResolverDeps,\n): Promise<ResolvedBlockTree> {\n // A CODED TEMPLATE has no stored tree (editor D017): rebuild it from the\n // registry, and remember which COLUMNS its blocks bind — a bound rich-text\n // column's link marks live in the row, and they are resolved below in the\n // same batch as the tree's own (F109). Only the columns THIS template binds\n // are read, so a same-named column bound by some other block is never walked.\n //\n // An ORDINARY tree can carry bound blocks too — `documentBody` dropped into a\n // composable page renders that page's column — so when the caller hands over\n // the row, its bound columns are walked the same way. Without that the canvas\n // (which composes bound values into any tree) resolved links the site then\n // rendered as plain text.\n let tree: unknown = blocks\n let boundColumns: readonly string[] = []\n if (opts.template !== undefined) {\n // 🔴 The ids belong to THIS document (editor F124): namespaced by the entity\n // and the row's own id, exactly as the editor and the bound-field route\n // namespace them, so the delivered tree carries the ids the canvas edits.\n //\n // The id is read off the row because the row is what the caller hands over\n // — content-api always projects `id` (`query-params.ts`, \"Always include\n // id\"), so a templated field never arrives without one. If it somehow did,\n // the template is served EMPTY rather than with ids that name no document:\n // an empty body is a visible fault, a shared id space is the defect F124\n // closed.\n const documentId = opts.boundValues?.id\n if (typeof documentId === 'string' && documentId !== '') {\n const built = (deps.materializeTemplate ?? materializeServerTemplate)(\n opts.template,\n templateNamespace(opts.entity, documentId),\n )\n tree = built.blocks\n boundColumns = built.boundColumns\n } else {\n tree = []\n deps.logger?.warn(\n { entity: opts.entity, field: opts.field },\n 'content: a templated document arrived without its row id, so its template is served ' +\n 'EMPTY — its block ids could not be namespaced to it (editor F124)',\n )\n }\n } else if (opts.boundValues !== undefined && isUnknownArray(blocks)) {\n // Degrades to \"no bound columns\" rather than failing the tree: bound-column\n // links are an enhancement, and a throw here would otherwise blank the whole\n // document (the zero-budget retry would throw identically).\n try {\n boundColumns = (deps.boundColumnsOf ?? serverBoundColumnsInTree)(blocks)\n } catch (err) {\n deps.logger?.warn(\n { err, entity: opts.entity, field: opts.field },\n 'content: could not enumerate the bound columns of a block tree, so its bound-field links render plain',\n )\n }\n }\n const row = opts.boundValues ?? {}\n const boundSources = boundColumns\n .filter((column) => Object.hasOwn(row, column))\n .map((column) => row[column])\n\n if (!isUnknownArray(tree)) {\n deps.logger?.warn(\n { entity: opts.entity, field: opts.field },\n 'content: stored block tree is not an array — nothing to resolve, so this field is ' +\n 'served EMPTY rather than as stored (the caller retries this throw with an exhausted ' +\n 'budget, and a non-array fails that identically)',\n )\n throw new TypeError(\n `content: block tree \\`${opts.entity}.${opts.field}\\` is not an array; nothing to resolve.`,\n )\n }\n\n const slots: MediaSlot[] = []\n const report: WalkReport = { truncated: false, nearMissKeys: new Set() }\n collectMediaSlots(tree, slots, 0, report)\n // ONE line per call at most, whatever the tree looks like — a per-node log on\n // an anonymous request path is the amplification this file bounds everywhere\n // else. Both are hedged (\"any\", \"may\") because the walk cannot know whether\n // the thing it declined to visit was a media ref at all; what it knows is\n // that it did not look.\n if (report.truncated) {\n deps.logger?.warn(\n { entity: opts.entity, field: opts.field, cap: MAX_WALK_DEPTH },\n 'content: block tree is nested deeper than the media walk descends — any media ref below ' +\n 'the bound keeps the url recorded when an editor picked it, which may be signed',\n )\n }\n if (report.nearMissKeys.size > 0) {\n deps.logger?.debug(\n { entity: opts.entity, field: opts.field, keys: [...report.nearMissKeys].sort() },\n 'content: a value looked like a media ref but carried keys outside the projection, so it ' +\n 'was left alone. If these are media refs, they keep their stored urls',\n )\n }\n // Its OWN bound, not this module's. Link resolution is a display concern — an\n // unresolved link renders as plain text — while the media walk is a security\n // control, which is why only one of the two was raised. Passing\n // `MAX_WALK_DEPTH` here would have silently quadrupled link collection depth\n // as a side effect of a change that had nothing to do with links.\n //\n // The bound columns' links join the SAME list, so they share every step below\n // — one media batch, one published-path lookup per linked entity type, one\n // pair of claims against the response's budget. That is F109's fan-out bound:\n // links are resolved per DOCUMENT, never one request per link and never one\n // batch per column.\n const links = [\n ...collectLinkValues(tree, LINK_WALK_DEPTH),\n ...collectLinkValues(boundSources, LINK_WALK_DEPTH),\n ]\n\n // Media LINKS resolve to a media url, so they join the same batch — one\n // lookup covers a `{kind:'media'}` link and an image of the same asset.\n //\n // 🔴 FILTERED to the uuid shape, and the filter is load-bearing rather than\n // defensive. `mediaRefId` already requires a uuid, but `asLinkValue` accepts\n // ANY non-empty string as a `{kind:'media'}` id — so one legacy or\n // hand-authored link with `id: 'x'` reached `inArray(idColumn, ids)` and\n // Postgres raised `22P02 invalid input syntax for type uuid`. The batch's\n // catch then returned an EMPTY map, and every slot on the page was written\n // `null`: one malformed link deleted every image in the tree. Rejects are\n // reported as `unresolvable` rather than dropped, because a link that can\n // never resolve is exactly what that channel is for.\n const wellFormedMediaLinkIds: string[] = []\n const malformedRefs: UnresolvedBlockRef[] = []\n for (const link of links) {\n if (link.kind !== 'media') continue\n if (UUID_RE.test(link.id)) wellFormedMediaLinkIds.push(link.id)\n else malformedRefs.push({ kind: 'link', ref: linkValueKey(link), reason: 'unresolvable' })\n }\n const mediaIds = distinct([...slots.map((slot) => slot.id), ...wellFormedMediaLinkIds])\n\n const entityTargets: string[] = []\n for (const link of links) {\n if (link.kind !== 'entity') continue\n // Same reason: `filterPublished` issues one `inArray` per entity type, so a\n // single non-uuid id throws and takes every link of that type with it.\n if (UUID_RE.test(link.id)) entityTargets.push(targetKey(link.entity, link.id))\n else malformedRefs.push({ kind: 'link', ref: linkValueKey(link), reason: 'unresolvable' })\n }\n\n const unresolved: UnresolvedBlockRef[] = [...malformedRefs]\n\n // TWO claims against the ONE shared counter, media first.\n //\n // Two rather than one so each pass learns its own grant without splitting a\n // combined number, and media first because a vanished image is the more\n // visible failure — when a page is over budget, the deterministic outcome is\n // \"images render, some links go plain\" rather than whichever the scheduler\n // reached first. Both record their shortfall under the same `block` kind, so\n // the operator log is unchanged.\n //\n // Nothing is claimed for media when no resolver is wired: the budget reports\n // work the server DECLINED, and there is no work to decline. Claiming anyway\n // burned the whole ceiling on a pass that did no I/O and then reported every\n // LINK as `budget` — a drop log naming the wrong cause.\n if (!deps.resolveMedia && mediaIds.length > 0) {\n // The signal for \"this deployment cannot resolve media at all\", emitted\n // HERE because this is now the only place that can tell. `resolveMediaBatch`\n // used to carry it, and the no-claim rule above made its branch\n // unreachable — the ids never reach it. It is the likeliest cause of \"every\n // image on the site is gone\", and the `unresolvable` the envelope reports\n // deliberately does not distinguish causes, so the operator log is the only\n // place it can be said.\n deps.logger?.debug(\n { entity: opts.entity, field: opts.field, count: mediaIds.length },\n 'content: no media resolver wired for block trees — every media ref resolves to null',\n )\n }\n\n const mediaGranted = deps.resolveMedia ? opts.budget.claim('block', mediaIds.length) : 0\n const coveredMedia = mediaIds.slice(0, mediaGranted)\n // 🔴 EMPTY when no resolver is wired, and that is not the same set as\n // \"everything\". `budgetDroppedMedia` means \"a ceiling refused this\", and it\n // is read again below by `unresolvedLinkReason` — so filling it on the\n // no-resolver path reported every media LINK as `reason: 'budget'` and sent\n // an operator to raise a limit that was never involved. The reason was fixed\n // for the media entries and missed for the links; one set, read twice, is\n // what keeps them consistent.\n const budgetDroppedMedia = deps.resolveMedia\n ? new Set(mediaIds.slice(mediaGranted))\n : new Set<string>()\n for (const id of mediaIds.slice(mediaGranted)) {\n unresolved.push({\n kind: 'media',\n ref: id,\n reason: budgetDroppedMedia.has(id) ? 'budget' : 'unresolvable',\n })\n }\n\n const distinctTargets = distinct(entityTargets)\n const linkGranted = opts.budget.claim('block', distinctTargets.length)\n const coveredTargets = distinctTargets.slice(0, linkGranted)\n const budgetDroppedTargets = new Set(distinctTargets.slice(linkGranted))\n\n const media = await resolveMediaBatch(coveredMedia, opts.locale, deps, opts.entity)\n\n // Every slot is written, including the ones that did not resolve. Leaving a\n // stored ref in place for an id the resolver refused is the leak this module\n // exists to close (see the file header) — \"not public\" and \"not found\" are\n // the same branch downstream, so the ONE case where a fallback would matter\n // is exactly the case where it must not happen.\n for (const slot of slots) {\n const projection = media.get(slot.id) ?? null\n writeSlot(slot, projection)\n }\n for (const id of coveredMedia) {\n if (!media.has(id)) unresolved.push({ kind: 'media', ref: id, reason: 'unresolvable' })\n }\n\n const paths = await resolveEntityPaths(coveredTargets, opts.locale, deps, opts.entity)\n\n // `resolveLinks` still owns dedup, the `sanitizeHref` protocol gate on\n // resolved hrefs, anchor composition and the inline url/email cases. What it\n // is handed here are PURE MAP LOOKUPS, so its `runBounded` fan-out does no\n // I/O at all — the batching above already happened.\n const resolvedLinks = await resolveLinks(links, {\n renderContext: 'request',\n locale: opts.locale,\n resolveEntityHref: ({ entity, id }) => paths.get(targetKey(entity, id)) ?? null,\n resolveMediaHref: (id) => media.get(id)?.url ?? null,\n })\n\n const linkRecord: Record<string, string | null> = {}\n for (const [key, resolved] of resolvedLinks) linkRecord[key] = resolved.href\n // Only `entity`/`media` links are REPORTED as unresolved. A `url` or `email`\n // link with a null href was refused by `sanitizeHref` — an editor typed\n // `javascript:` — which is a content problem the security gate handled, not a\n // resolution this response failed to perform, and listing it would invite a\n // consumer to retry it.\n for (const link of links) {\n if (link.kind !== 'entity' && link.kind !== 'media') continue\n const key = linkValueKey(link)\n if (linkRecord[key] !== null && linkRecord[key] !== undefined) continue\n unresolved.push({\n kind: 'link',\n ref: key,\n reason: unresolvedLinkReason(link, budgetDroppedTargets, budgetDroppedMedia),\n })\n }\n\n // What this tree DEPENDS ON, for whatever cache is holding the response.\n //\n // 🔒 Built from what actually RESOLVED, not from what was asked for. `media`\n // holds only ids the public resolver returned, and `paths` only targets the\n // published-path lookup answered — so an unpublished link target contributes\n // no ref, and its id is not disclosed. That is the same gate the hrefs above\n // pass through, applied to identity; the refusal is already reported through\n // `unresolved`, which carries a `linkValueKey` the consumer already holds.\n //\n // Media BEFORE links, and both deduped by the Map: an image embedded as a\n // `field.media()` and linked as `{kind:'media'}` is one row and one tag.\n const { results: collections, cardMedia } = await resolveCollectionsIn(tree, opts, deps)\n\n const refByKey = new Map<string, BlockTreeRef>()\n for (const id of media.keys()) refByKey.set(targetKey('media', id), { entity: 'media', id })\n for (const id of cardMedia) refByKey.set(targetKey('media', id), { entity: 'media', id })\n // A listed row is a row this tree depends on, and it passed the caller's gate\n // to be listed — the same rule the links below follow.\n for (const result of collections.values()) {\n if (result.entity === null) continue\n for (const row of result.rows) {\n refByKey.set(targetKey(result.entity, row.id), { entity: result.entity, id: row.id })\n }\n }\n for (const key of paths.keys()) {\n if (refByKey.has(key)) continue\n const split = key.indexOf(' ')\n if (split <= 0) continue\n const entity = key.slice(0, split)\n const id = key.slice(split + 1)\n if (id === '') continue\n refByKey.set(key, { entity, id })\n }\n\n return {\n resolved: 1,\n blocks: tree,\n links: linkRecord,\n // OMITTED when empty, never `[]`. Its presence is itself information —\n // Contentful's rule for `errors[]`, and the property that stops \"nothing\n // failed\" from looking like \"nothing was attempted\".\n ...(unresolved.length > 0 && { unresolved: dedupeRefs(unresolved) }),\n // Same rule, same reason: a tree that references nothing says so by\n // omitting the field rather than by shipping `[]`.\n ...(refByKey.size > 0 && { refs: [...refByKey.values()] }),\n // And again: a tree with no collection block carries no sidecar.\n ...(collections.size > 0 && { collections: Object.fromEntries(collections) }),\n }\n}\n\n/**\n * Resolve every collection block in `blocks` through the shared resolver.\n *\n * DENY-BY-DEFAULT at three levels: an app with no declared sources has no\n * `deps.collections`, and a call without `canReadEntity` — a host that did not\n * say who may read what — or without `claimCollection` — a host that did not\n * say how many — resolves nothing. Either way the blocks render empty on the\n * site, which is the safe direction.\n *\n * Images go through the SAME public media resolver the tree's own media refs\n * do, and hrefs through the same published-path lookup its links do, so a card\n * cannot show an asset or a URL the rest of the page could not.\n */\nasync function resolveCollectionsIn(\n blocks: readonly unknown[],\n opts: {\n locale: string\n budget: BlockResolutionBudget\n canReadEntity?: ((entityName: string) => boolean) | undefined\n claimCollection?: (() => boolean) | undefined\n },\n deps: BlockResolverDeps,\n): Promise<{\n readonly results: ReadonlyMap<string, CollectionResult>\n readonly cardMedia: ReadonlySet<string>\n}> {\n const collections = deps.collections\n const canRead = opts.canReadEntity\n const claimCollection = opts.claimCollection\n if (collections === undefined || canRead === undefined || claimCollection === undefined) {\n return { results: new Map(), cardMedia: new Set() }\n }\n const resolveMedia = deps.resolveMedia\n // Every media id a card will draw, so the tree names it in `refs` like its\n // own media: a replaced image or an edited alt must evict the cached page\n // (CodeRabbit review, PR53).\n const cardMedia = new Set<string>()\n const results = await resolveTreeCollections(blocks, collections.sourceFieldOf, {\n sources: collections.sources,\n getEntity: collections.getEntity,\n clientFor: collections.clientFor,\n canRead,\n resolveImages:\n resolveMedia === undefined\n ? undefined\n : async (ids): Promise<ReadonlyMap<string, CollectionCardImage>> => {\n const found = await resolveMedia(ids, { locale: opts.locale })\n // A card image must BE an image. A media field's `kind: 'image'`\n // is only a picker filter (editor F116), so a video can sit in\n // one — and the card would draw it as a broken `<img>`.\n const shown = [...found].filter(\n ([, projection]) => projection.mimeType?.startsWith('image/') === true,\n )\n for (const [id] of shown) cardMedia.add(id)\n // All seven keys a card needs — beyond the four: where to crop\n // around and which generated files to offer (images PR07, F021),\n // and the preview it shows while it loads (PR11).\n // `resolveCollectionSource` normalises what this returns\n // (`cardImageOf`: a centre point, an empty `sources` and a missing\n // preview are left out), the SAME step the canvas's injected resolver goes through,\n // so a card carries the same keys on both surfaces.\n return new Map(\n shown.map(([id, projection]) => [\n id,\n {\n url: projection.url,\n alt: projection.alt,\n width: projection.width,\n height: projection.height,\n focalPoint: projection.focalPoint,\n sources: projection.sources,\n // Non-empty only, the rule the embedded projection follows\n // (`cardImageOf` would drop `{}` too; one rule, both paths).\n ...(projection.avifSources !== undefined &&\n Object.keys(projection.avifSources).length > 0 && {\n avifSources: projection.avifSources,\n }),\n blurDataUrl: projection.blurDataUrl,\n },\n ]),\n )\n },\n resolveHrefs: (entityType, ids) => deps.resolvePublishedPaths(entityType, ids, opts.locale),\n locale: opts.locale,\n renderContext: 'request',\n // A slot of the response's collection allowance, THEN one unit of its ONE\n // budget — so a page whose references and media spent the ceiling lists\n // nothing rather than exceeding it, and a list response stops at the\n // allowance however many rows it carries. Allowance first: once it is\n // spent, a refused collection must not also burn budget the rest of the\n // response's media and links need.\n claim: () => claimCollection() && opts.budget.claim('block', 1) >= 1,\n logger: deps.logger,\n })\n return { results, cardMedia }\n}\n\nfunction unresolvedLinkReason(\n link: LinkValue,\n budgetDroppedTargets: ReadonlySet<string>,\n budgetDroppedMedia: ReadonlySet<string>,\n): 'unresolvable' | 'budget' {\n if (link.kind === 'entity' && budgetDroppedTargets.has(targetKey(link.entity, link.id))) {\n return 'budget'\n }\n if (link.kind === 'media' && budgetDroppedMedia.has(link.id)) return 'budget'\n return 'unresolvable'\n}\n\nfunction distinct(values: readonly string[]): string[] {\n return [...new Set(values)]\n}\n\n/**\n * `(entityType, id)` as one map key. A space is unambiguous as a separator\n * because entity names are identifiers — `defineEntity` rejects anything else —\n * so no name can contain one.\n */\nfunction targetKey(entityType: string, id: string): string {\n return [entityType, id].join(' ')\n}\n\n/**\n * One entry per `(kind, ref)`.\n *\n * What this actually collapses is the SAME LinkValue appearing several times —\n * `collectLinkValues` does not dedupe, so a page linking twice to one target\n * would otherwise report it twice. It deliberately does NOT collapse an image\n * and a media LINK to the same asset: those are `media <id>` and\n * `link media:<id>`, two different failures a consumer may want to act on\n * differently, and the suite asserts exactly that.\n */\nfunction dedupeRefs(refs: readonly UnresolvedBlockRef[]): UnresolvedBlockRef[] {\n const seen = new Set<string>()\n const out: UnresolvedBlockRef[] = []\n for (const ref of refs) {\n const key = `${ref.kind} ${ref.ref}`\n if (seen.has(key)) continue\n seen.add(key)\n out.push(ref)\n }\n return out\n}\n\nasync function resolveMediaBatch(\n ids: readonly string[],\n locale: string,\n deps: BlockResolverDeps,\n entity: string,\n): Promise<Map<string, BlockMediaProjection>> {\n // No log for the unwired case HERE: the caller does not claim, so `ids` is\n // always empty on that path and a branch guarding on it could never fire.\n // The signal lives at the call site, which is the only place that still knows.\n if (ids.length === 0 || !deps.resolveMedia) return new Map()\n try {\n return await deps.resolveMedia(ids, { locale, spots: true })\n } catch (err) {\n deps.logger?.warn(\n { entity, err },\n 'content: media resolver threw while resolving a block tree — its media refs resolve to ' +\n 'null (if this is a ForbiddenError, the requesting role is missing `media:view`)',\n )\n return new Map()\n }\n}\n\n/**\n * {@link targetKey} → path, for the covered targets only.\n *\n * ONE call to {@link BlockResolverDeps.resolvePublishedPaths} per entity TYPE,\n * never per link. A document linking 200 times to 3 entity types costs 3 calls.\n */\nasync function resolveEntityPaths(\n targets: readonly string[],\n locale: string,\n deps: BlockResolverDeps,\n entity: string,\n): Promise<Map<string, string>> {\n const out = new Map<string, string>()\n if (targets.length === 0) return out\n\n const byType = new Map<string, string[]>()\n for (const target of targets) {\n const separator = target.indexOf(' ')\n const entityType = target.slice(0, separator)\n const id = target.slice(separator + 1)\n const bucket = byType.get(entityType)\n if (bucket) bucket.push(id)\n else byType.set(entityType, [id])\n }\n\n // SERIAL over types, matching `resolveBlockTrees`' serial-over-rows rule: the\n // set is tiny (a document links to a handful of types at most) and serial\n // keeps the read pattern predictable under load rather than bursting one\n // query per type at once.\n for (const [entityType, ids] of byType) {\n try {\n const paths = await deps.resolvePublishedPaths(entityType, ids, locale)\n for (const [id, path] of paths) out.set(targetKey(entityType, id), path)\n } catch (err) {\n // One broken entity type costs its own links, not the page's whole tree.\n deps.logger?.warn(\n { entity, entityType, err },\n 'content: path resolution threw for a block-tree link target — its links render as ' +\n 'plain text',\n )\n }\n }\n return out\n}\n\n// ---------------------------------------------------------------------------\n// App wiring\n// ---------------------------------------------------------------------------\n\n/**\n * Wire {@link resolveBlockTree} to the running app — the one line an app puts\n * into `createContentApiHandler({ resolveBlocks })`.\n *\n * Pass the SAME `resolveMedia` value the handler gets, so a media ref inside a\n * block and a top-level `media()` field resolve through one path and one gate.\n *\n * **The publish gate comes from `buildEnumerateDeps`' batched `filterPublished`,\n * which reads through an elevated REACH context** (`createSystemReachResolver`,\n * which grants `view` and withholds `view_unpublished`), so unpublished and\n * trashed targets are dropped. That is deliberately the SAME gate `GET /_resolve`\n * applies and no stricter one: the alternative — additionally gating on the\n * caller's per-entity `view` — would make the same URL a working link on one\n * endpoint and plain text on another, while the document behind the href stays\n * whitelist- and permission-gated where it is actually fetched.\n */\nexport function createBlockResolver(\n getApp: () => Promise<ToolkitApp> | ToolkitApp,\n options: {\n /**\n * The locale to resolve in when a request carried none. Pass the SAME value\n * given to `createContentApiHandler({ defaultLocale })` — the handler\n * already defaults every entity read's locale before it reaches a resolver,\n * so this only binds on a path that somehow did not, and disagreeing with\n * the handler would resolve a link in a locale the page is not rendered in.\n */\n defaultLocale: string\n resolveMedia?: BlockMediaResolver | undefined\n },\n): (\n blocks: unknown,\n opts: {\n locale?: string | undefined\n budget: BlockResolutionBudget\n entity: string\n field: string\n canReadEntity?: ((entityName: string) => boolean) | undefined\n claimCollection?: (() => boolean) | undefined\n template?: string | undefined\n boundValues?: Readonly<Record<string, unknown>> | undefined\n },\n) => Promise<ResolvedBlockTree> {\n // Built ONCE per app, not once per field per row. `buildEnumerateDeps` makes\n // a table client, and its `filterPublished` makes a fresh `QueryClient` and a\n // child logger per call — cheap individually, and paid once per `blockTree`\n // field on every row of every list page if it lives inside the closure.\n //\n // Cached against the app IDENTITY rather than assumed singleton: `getApp` is\n // a callback precisely because a host may hand back a different app (tests\n // do), and a cache that ignored that would silently read another app's\n // database.\n let cached: { app: ToolkitApp; deps: BlockResolverDeps } | null = null\n\n const depsFor = (app: ToolkitApp): BlockResolverDeps => {\n if (cached?.app === app) return cached.deps\n // `pathsClient` here is the BATCHED (`{ in }`-capable) reader, which is what\n // `getCanonicalPaths` needs — the point-lookup `buildCanonicalPathDeps`\n // reader cannot express an id batch, and reaching for it is how this ends\n // up one query per link.\n const enumerateDeps = buildEnumerateDeps(app)\n // editor PR53 — only when the app declares collection sources. The reader\n // is `createQueryClient`, which is published-only and bound to the AMBIENT\n // request context: the same caller-scoped read content-api's own list\n // endpoint performs, so a collection sees exactly the rows that caller\n // could fetch directly — nothing wider, no elevated identity.\n const { sources } = effectiveCollectionSources(app)\n const collections: BlockCollectionDeps | undefined =\n sources.length === 0\n ? undefined\n : {\n sources,\n sourceFieldOf: serverCollectionFieldLookup(),\n getEntity: (name) => app.entities.get(name),\n clientFor: (entity) => createCollectionReader(entity, app),\n }\n // A template is REGISTRY code, fixed after boot, so the registry lookups it\n // is rebuilt from are built ONCE per app rather than per row: a list\n // response of a hundred templated rows would otherwise rebuild the\n // block-definition map two hundred times on the anonymous path. The TREE is\n // still rebuilt per row, because its ids belong to that row's document\n // (editor F124) — the part that was cheap. Each call answers a fresh,\n // deep-cloned tree (see `serverTemplateMaterializer`), so the media slots\n // this resolver writes in place never reach another response or the\n // registry.\n const templates = serverTemplateMaterializer()\n // The structure lookup for ORDINARY trees' bound columns, built once per app\n // for the same reason: `serverBoundColumnsInTree` would otherwise rebuild the\n // block-definition map per row per field on the anonymous path.\n const structureLookup = serverBlockStructureLookup()\n const deps: BlockResolverDeps = {\n ...(collections !== undefined && { collections }),\n boundColumnsOf: (tree) => collectBoundColumnsInTree(tree, structureLookup),\n materializeTemplate: (template, namespace) => templates(template, namespace),\n resolveMedia: options.resolveMedia,\n resolvePublishedPaths: async (entityType, ids, locale) => {\n const published = await enumerateDeps.filterPublished(entityType, ids, locale)\n if (published.size === 0) return new Map()\n return getCanonicalPaths(entityType, [...published], locale, enumerateDeps)\n },\n logger: app.logger,\n }\n cached = { app, deps }\n return deps\n }\n\n return async (blocks, opts) => {\n const app = await getApp()\n return resolveBlockTree(\n blocks,\n { ...opts, locale: opts.locale ?? options.defaultLocale },\n depsFor(app),\n )\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"block-resolver.mjs","names":[],"sources":["../src/block-resolver.ts"],"sourcesContent":["/**\n * The block-tree resolver for `@murumets-ee/content-api`'s `resolveBlocks` seam\n * (`frontend-delivery` D019).\n *\n * This is the half `content-api` structurally cannot do: it walks a stored\n * tree, re-resolves the media refs inside it through the public-only media\n * path, and turns the `LinkValue`s inside it into hrefs against the published\n * routing index. `content-api` is a leaf — `blocks`, `media` and this package\n * all depend back on it — so it takes this as an injected provider, exactly as\n * it takes chrome, menus and routing resolution.\n *\n * ## What it emits, and why that shape\n *\n * A `ResolvedBlockTree` envelope: `{ resolved: 1, blocks, links, unresolved? }`.\n * The reasoning is D019's and is restated where it binds the code below, but\n * the two-sentence version is: links go into a `linkValueKey`-keyed sidecar\n * because that is exactly what every renderer already consumes, and media is\n * replaced IN PLACE because a media node is not an opaque pointer — it already\n * carries a url that every render reads, and that url has to be overwritten\n * regardless.\n *\n * ## 🔴 The security property this exists for\n *\n * A `field.media()` value inside a block tree is stored as a whole `MediaRef`\n * including a `url`, denormalised when the editor picked it. That url comes\n * from `StorageClient.getUrl`, which returns a **signed** url for any file that\n * is not public — and `StorageClient.upload` defaults to private. So the stored\n * tree can carry a bearer capability, and serving it unchanged puts that\n * capability on a `public, max-age=60` response any shared CDN will hand to\n * every later anonymous visitor. That is precisely the leak `getPublicUrls` and\n * `plan/api` D021 made structurally impossible for TOP-LEVEL media fields; the\n * block-tree path routed around it because the url stopped being a url and\n * became content.\n *\n * So the stored url is **discarded, never used as a fallback**. An id that does\n * not resolve becomes `null`, because \"did not resolve\" and \"is not public\" are\n * the same branch in the shipped resolver — a fallback would preserve the leak\n * exactly in the case that matters.\n *\n * ## 🔴 The fan-out property\n *\n * `resolveLinks`' own docblock hands the COUNT bound to whoever wires it: it\n * caps peak concurrency and deliberately does not cap how many links it\n * resolves. A naive wiring would give it a seam that hits the database per\n * distinct link — one publish check plus one path read each — on an anonymous\n * request path, which is verbatim what CLAUDE.md's no-unbounded-fan-out section\n * forbids. So this module resolves everything FIRST, batched by entity type\n * (two index reads per type, regardless of link count), and hands `resolveLinks`\n * a pure map lookup. What it costs is bounded by the number of distinct entity\n * TYPES a document links to, not by the number of links.\n *\n * ## Collections — rows, through the caller's own gate (editor PR53)\n *\n * A COLLECTION block's rows are resolved here too, into the envelope's additive\n * `collections` sidecar, through `@murumets-ee/blocks/server`'s\n * `resolveTreeCollections` — the ONE resolver the canvas calls as well. Three\n * properties make that safe on an anonymous path:\n *\n * - The reader is the CALLER, not an elevated identity: rows come through a\n * `QueryClient` bound to the ambient request context, and the provider may\n * only list an entity content-api's own whitelist ∧ `view` gate admits\n * (`canReadEntity`, handed in by the handler). Without that predicate, no\n * collection resolves at all — deny-by-default, never a guess.\n * - Only the card's mapped slots cross, never the row.\n * - It is bounded: at most `MAX_COLLECTIONS_PER_DOCUMENT` per tree, serial,\n * each claiming one slot of the host's per-RESPONSE collection allowance\n * (`claimCollection` — a list response resolves many trees) and one unit of\n * the response's resolution budget. No allowance, no collections.\n *\n * ## What it deliberately does not do\n *\n * Plain `query()` fields (the fixed-entity `articleList` / `relatedPosts`\n * shells) stay unresolved: nothing renders their rows, and the collection block\n * is the dynamic listing D001 asked for.\n */\n\nimport { collectLinkValues, type LinkValue, linkValueKey } from '@murumets-ee/blocks/links'\nimport {\n type CollectionCardImage,\n type CollectionFieldLookup,\n type CollectionResult,\n type CollectionSourceSpec,\n collectBoundColumnsInTree,\n createCollectionReader,\n effectiveCollectionSources,\n type FindManyClientLike,\n materializeServerTemplate,\n readSpotCrop,\n resolveLinks,\n resolveTreeCollections,\n type SpotCrop,\n sameSpotCrop,\n serverBlockStructureLookup,\n serverBoundColumnsInTree,\n serverCollectionFieldLookup,\n serverTemplateMaterializer,\n type TemplateNamespace,\n templateNamespace,\n} from '@murumets-ee/blocks/server'\nimport type { ToolkitApp } from '@murumets-ee/core'\nimport type { Entity } from '@murumets-ee/entity'\n\nimport { getCanonicalPaths } from './paths/enumerate.js'\nimport { buildEnumerateDeps } from './paths/resolve-context.js'\n\n// ---------------------------------------------------------------------------\n// Structural mirrors of `content-api`'s wire types\n// ---------------------------------------------------------------------------\n//\n// Mirrored rather than imported, for the reason every other provider in this\n// package mirrors its counterpart: `content-api` is the leaf, and the app that\n// wires the two is where TypeScript checks they agree.\n\n/** Structural mirror of `content-api`'s `MediaProjection`. */\nexport interface BlockMediaProjection {\n id: string\n url: string\n mimeType: string | null\n width: number | null\n height: number | null\n alt: string | null\n title: string | null\n variants: Record<string, string>\n sources: Record<string, Array<{ url: string; width: number; height: number }>>\n avifSources?: Record<string, Array<{ url: string; width: number; height: number }>>\n focalPoint: { x: number; y: number }\n blurDataUrl: string | null\n /**\n * The files cut for each spot crop the photograph has files for (images\n * PR16) — present only when the resolver was ASKED (`{ spots: true }`) and\n * the photograph records some. Never embedded in a tree: {@link writeSlot}\n * picks the one list a placement's own `crop` names and drops the rest.\n */\n spotSources?: BlockSpotSources[]\n}\n\n/** One spot crop's files, as the media resolver publishes them. */\nexport interface BlockSpotSources {\n crop: { x: number; y: number; w: number; h: number }\n sources: Array<{ url: string; width: number; height: number }>\n avifSources?: Array<{ url: string; width: number; height: number }>\n}\n\n/**\n * Structural mirror of `content-api`'s `ResolveMedia`.\n *\n * The SAME function an app passes as `ContentApiConfig.resolveMedia` — pass one\n * value to both, so a media ref inside a block cannot resolve through a\n * different path (or a different publish gate) than a top-level media field.\n */\nexport type BlockMediaResolver = (\n ids: readonly string[],\n opts?: {\n locale?: string | undefined\n /**\n * Also publish the files cut for spot crops (images PR16). Asked by the\n * BLOCK walk only — a top-level media field has no placement crop, so its\n * projection stays exactly what it was.\n */\n spots?: boolean | undefined\n },\n) => Promise<Map<string, BlockMediaProjection>>\n\n/** Structural mirror of `content-api`'s `UnresolvedBlockRef`. */\nexport interface UnresolvedBlockRef {\n readonly kind: 'media' | 'link'\n readonly ref: string\n readonly reason: 'unresolvable' | 'budget'\n}\n\n/** Structural mirror of `content-api`'s `BlockTreeRef`. */\nexport interface BlockTreeRef {\n readonly entity: string\n readonly id: string\n}\n\n/** Structural mirror of `content-api`'s `ResolvedBlockTree`. */\nexport interface ResolvedBlockTree {\n readonly resolved: 1\n readonly blocks: readonly unknown[]\n readonly links: Readonly<Record<string, string | null>>\n readonly unresolved?: readonly UnresolvedBlockRef[]\n readonly refs?: readonly BlockTreeRef[]\n readonly collections?: Readonly<Record<string, CollectionResult>>\n}\n\n/**\n * What resolving COLLECTION blocks needs from the app — built once per app by\n * {@link createBlockResolver}, absent when the app declares no sources.\n */\nexport interface BlockCollectionDeps {\n readonly sources: readonly CollectionSourceSpec[]\n /** Name AND field restriction (kulli-editor F019) — `serverCollectionFieldLookup()`. */\n readonly sourceFieldOf: CollectionFieldLookup\n readonly getEntity: (name: string) => Entity | undefined\n /** A PUBLISHED-only reader bound to the ambient request context — the caller. */\n readonly clientFor: (entity: Entity) => FindManyClientLike\n}\n\n/**\n * The slice of `content-api`'s `ResolutionBudget` this provider uses —\n * narrowed to `claim`, for the reason `ChromeResolutionBudget` states: a\n * provider asks for work and is told how much it may do; it has no business\n * reading the remaining count or the drop summary.\n */\nexport interface BlockResolutionBudget {\n claim: (kind: 'block', n: number) => number\n}\n\nexport interface BlockResolverLogger {\n debug: (obj: object, msg: string) => void\n warn: (obj: object, msg: string) => void\n}\n\n/** Everything the pure resolver needs, injected so it is testable without an app. */\nexport interface BlockResolverDeps {\n /** Public-only media resolution. Omit and every media ref resolves to `null`. */\n resolveMedia?: BlockMediaResolver | undefined\n /**\n * PUBLISHED-only canonical paths for many ids of one entity type, in one\n * locale, as `entityId → path`.\n *\n * **The publish gate is this function's responsibility and the whole reason\n * it is batched rather than per-id.** An unpublished target must yield no\n * entry, or a block link leaks an unpublished document's URL — the obligation\n * `ResolveBlocks`' own docblock puts on the wiring.\n */\n resolvePublishedPaths: (\n entityType: string,\n ids: readonly string[],\n locale: string,\n ) => Promise<Map<string, string>>\n logger?: BlockResolverLogger | undefined\n /** Collection resolution. Omit and collection blocks resolve no rows. */\n collections?: BlockCollectionDeps | undefined\n /**\n * Rebuild a CODED TEMPLATE and name the columns its blocks bind (editor\n * D017, frontend-delivery F109). Defaults to the server block registry's\n * `materializeServerTemplate`; injectable so a test need not seed a registry.\n *\n * `namespace` is the document the ids belong to (editor F124) — see\n * `resolveBlockTree`, which derives it from the entity and the row's id.\n */\n materializeTemplate?:\n | ((\n template: string,\n namespace: TemplateNamespace,\n ) => {\n readonly blocks: unknown[]\n readonly boundColumns: readonly string[]\n })\n | undefined\n /**\n * The columns the blocks in an ORDINARY stored tree bind. Defaults to the\n * server registry's `serverBoundColumnsInTree`; injectable for the same reason\n * as {@link materializeTemplate}.\n */\n boundColumnsOf?: ((tree: readonly unknown[]) => readonly string[]) | undefined\n}\n\n// ---------------------------------------------------------------------------\n// Media refs inside a tree\n// ---------------------------------------------------------------------------\n\n/**\n * How deep the media walk descends, in JSON levels.\n *\n * A stored tree is authenticated-editor JSON, not anonymous input — but it is\n * still data crossing a process boundary, and an unbounded recursion over it is\n * a stack overflow waiting for one pathological document, which under on-demand\n * rendering is a 500 on a live page for every visitor.\n *\n * 🔴 It counts JSON levels, not BLOCK levels, and the two are not the same: one\n * block of nesting costs about three (`block → children → block`, plus one for\n * `props`). This was 64, matching `collectLinkValues`, which sounded principled\n * and was wrong — `op-commit` permitted 64 BLOCK levels then, so a media ref\n * below roughly the 21st block was never visited, its stored url crossed\n * unchanged, and nothing said so. A bound that silently stops enforcing a\n * security control is worse than no bound, because it reads as coverage.\n *\n * 256 covered those 64 block levels with room to spare, and now covers\n * `MAX_BLOCK_DEPTH` (32, the one reconciled persist-and-render cap) with twice\n * that. Left at 256 rather than retuned: the headroom costs nothing, and a\n * bound that only just fits is the shape this comment exists to warn about.\n * Exceeding it is REPORTED rather than returned in silence. `collectLinkValues`\n * keeps its own 64 — that one bounds a display concern (an unresolved link\n * renders as plain text), not a value that must not reach the wire.\n */\nconst MAX_WALK_DEPTH = 256\n\n/**\n * How deep LINK collection descends — `collectLinkValues`' own default, stated\n * here so the two bounds are visibly independent rather than accidentally\n * shared. An unresolved link renders as plain text; an unvisited media ref\n * keeps a url that may be a capability. Different stakes, different bounds.\n *\n * ⚠️ Unlike the media walk, this one truncates SILENTLY: `collectLinkValues`\n * returns what it found and reports nothing, so a link below the bound is\n * absent from the sidecar, absent from `unresolved`, and renders as plain text\n * with no signal anywhere. That is tolerable only because the failure is\n * cosmetic — and it is stated rather than left to be discovered, because the\n * media walk one constant up made exactly the opposite call for exactly the\n * opposite reason.\n */\nconst LINK_WALK_DEPTH = 64\n\n/**\n * Entity ids are UUIDs (user ids are not, but no user id is ever a media ref).\n * Requiring the shape here is what makes structural detection safe: a prop\n * object carrying BOTH a uuid `id` AND a non-empty `url` string, with every\n * optional `MediaRef` field well-typed, is a media ref by any usable\n * definition.\n */\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\n/**\n * `Array.isArray(x)` on an `unknown` narrows to `any[]`, which would put an\n * `any` into every element of a walk over untyped JSON — in a package holding a\n * type-coverage floor. Narrowing through an explicit predicate keeps elements\n * `unknown`.\n */\nfunction isUnknownArray(value: unknown): value is unknown[] {\n return Array.isArray(value)\n}\n\n/**\n * Every key a `MediaRef` or a `MediaProjection` may carry — and NOTHING ELSE\n * may be present for a value to count as one.\n *\n * 🔴 This is a WHITELIST, and the difference is not stylistic. Checking only\n * that the known keys are well-typed made the detector match any object with a\n * uuid `id` and a `url` — a project's `{ id, url, provider: 'youtube' }` prop,\n * say. That object would then be REPLACED by `null` (no media row for its id),\n * the consumer-side validator would drop the whole prop, and the block would\n * render empty with an `unresolved` entry calling it media. Silent data loss,\n * misdescribed in the one channel meant to report it.\n *\n * An unrecognised key means \"this is not a media ref\", so the failure direction\n * is leaving a value alone rather than deleting it.\n */\nconst MEDIA_REF_KEY_MAP: Record<keyof BlockMediaProjection | 'crop', true> = {\n id: true,\n url: true,\n mimeType: true,\n width: true,\n height: true,\n alt: true,\n title: true,\n variants: true,\n // A PROJECTION key, like `variants` and `focalPoint` — listed so a projection\n // is still recognised on a second pass. The STORED `MediaRef` never carries it\n // (images D007), so no stored ref changes shape.\n sources: true,\n // Projection-only too (images PR12), and absent unless AVIF is on.\n avifSources: true,\n focalPoint: true,\n // Projection-only as well (images PR11) — the stored ref never carries it.\n blurDataUrl: true,\n // A STORED key (images PR16, D002): the one crop a placement carries — author\n // data about the spot, validated at every write. Listed in the same commit\n // that let the ref carry it: an unrecognised key makes the detector walk\n // past the ref, and its stored url would cross unresolved (CLAUDE.md,\n // Images fact 2). Kept on the embedded value, so a second pass still sees it.\n crop: true,\n // Projection-only, and never embedded — `writeSlot` consumes it. Listed\n // because the map is tied to the projection's keys by type.\n spotSources: true,\n}\n\n/**\n * Derived from the map above rather than written out beside it, so ADDING a\n * field to the projection is a COMPILE error until this list learns about it.\n *\n * That tie is the whole reason the map exists. A bare literal set would have\n * made the whitelist fail-OPEN and silent: give `MediaRef` a `caption` (which\n * the image block already renders, so it is a plausible next change) and every\n * captioned ref quietly stops being recognised — not collected, not\n * re-resolved, not nulled, and not reported — so its stored url crosses\n * verbatim. That is the same \"a bound that silently stops enforcing a security\n * control reads as coverage\" this file says about {@link MAX_WALK_DEPTH}, and\n * it would have been true one field later.\n */\nconst MEDIA_REF_KEYS = new Set(Object.keys(MEDIA_REF_KEY_MAP))\n\n/**\n * The media id in `value`, or `null` when it is not a media ref.\n *\n * Structural, deliberately — the same technique `asLinkValue` uses for links,\n * and for the same reason: a resolver that needed the block REGISTRY to know\n * which props are media would need every project to register its vocabulary at\n * the content-api mount, which is a second place to forget. Detection that\n * reads the value cannot go stale.\n *\n * Optional fields accept `null` as well as `undefined` so the check is\n * idempotent: a `MediaProjection` (whose optionals are `T | null`) is still\n * recognised, rather than being silently skipped on a second pass.\n */\nfunction mediaRefId(value: unknown, onNearMiss?: (keys: readonly string[]) => void): string | null {\n if (!isRecord(value)) return null\n const id = value.id\n if (typeof id !== 'string' || !UUID_RE.test(id)) return null\n if (typeof value.url !== 'string' || value.url === '') return null\n // The two cheap discriminators are checked FIRST, so the key scan runs only\n // for the handful of values that already look like a ref rather than for\n // every object in the tree.\n const unknownKeys = Object.keys(value).filter((key) => !MEDIA_REF_KEYS.has(key))\n if (unknownKeys.length > 0) {\n // 🔴 REPORTED, not silently declined. A value with a uuid `id` and a `url`\n // that this detector walks past is either a project prop we correctly left\n // alone or a media ref we failed to recognise — and the second is a stored\n // url reaching the wire with nothing anywhere saying so. The caller cannot\n // tell the two apart, and neither can this function; what it can do is\n // refuse to be quiet about the ambiguity.\n onNearMiss?.(unknownKeys)\n return null\n }\n if (!isOptional(value.alt, 'string')) return null\n if (!isOptional(value.title, 'string')) return null\n if (!isOptional(value.mimeType, 'string')) return null\n if (!isOptional(value.width, 'number')) return null\n if (!isOptional(value.height, 'number')) return null\n // Projection-only, like `focalPoint`: a string when present, so a project\n // prop holding something else there is left alone rather than claimed.\n if (!isOptional(value.blurDataUrl, 'string')) return null\n // `variants` is the one whitelisted key the type checks above do not cover,\n // and skipping it left an over-match: `{ id: <uuid>, url: '…', variants: 'x' }`\n // passed the whitelist and was replaced by a projection or by `null`. That is\n // precisely the direction the whitelist exists to prevent.\n if (value.variants !== undefined && value.variants !== null && !isRecord(value.variants)) {\n return null\n }\n // Same reasoning for `focalPoint`, which only a PROJECTION carries (a stored\n // ref never does — images D007): recognised on a second pass, never an\n // over-match on a project prop that happens to hold a non-object there.\n if (value.focalPoint !== undefined && value.focalPoint !== null && !isRecord(value.focalPoint)) {\n return null\n }\n // And `sources`, for the same reason — projection-only, and when present an\n // object of ARRAYS (shape name → files). Checked one level deep, because a\n // bare \"is an object\" let `{ id: <uuid>, url, sources: { natural: 'x' } }`,\n // a project's own prop, be claimed as media and replaced (CodeRabbit).\n if (value.sources !== undefined && value.sources !== null) {\n if (!isRecord(value.sources)) return null\n if (!Object.values(value.sources).every((list) => Array.isArray(list))) return null\n }\n // `avifSources` has `sources`' shape and the same over-match risk.\n if (value.avifSources !== undefined && value.avifSources !== null) {\n if (!isRecord(value.avifSources)) return null\n if (!Object.values(value.avifSources).every((list) => Array.isArray(list))) return null\n }\n // A spot crop is an object. A project prop holding something else under\n // `crop` is left alone rather than claimed. An object that is not a VALID\n // crop is still a media ref — the write gate refuses those, and a stored one\n // is drawn around the focal point (`readSpotCrop` is lenient) rather than\n // being left unresolved with its stored url.\n if (value.crop !== undefined && value.crop !== null && !isRecord(value.crop)) return null\n if (value.spotSources !== undefined && value.spotSources !== null) {\n if (!Array.isArray(value.spotSources)) return null\n }\n return id\n}\n\nfunction isOptional(value: unknown, type: 'string' | 'number'): boolean {\n return value === undefined || value === null || typeof value === type\n}\n\n/** One place in the tree holding a media ref, so it can be overwritten after resolution. */\ninterface MediaSlot {\n readonly container: Record<string, unknown> | unknown[]\n readonly key: string | number\n readonly id: string\n /** The placement's own crop (images PR16), read when the slot was found. */\n readonly crop?: SpotCrop | undefined\n}\n\n/**\n * Collect every media ref in the tree, recording WHERE it sits.\n *\n * Slots rather than a returned copy because the replacement is in place, which\n * is the same shape `projectMedia` uses on entity rows for the same reason: the\n * tree came from a per-request read and copying it would double the cost of the\n * largest thing on the page.\n */\n/** What the walk noticed but could not act on. Both are ambiguity, not failure. */\ninterface WalkReport {\n /** A subtree below {@link MAX_WALK_DEPTH} was never visited. */\n truncated: boolean\n /** Keys that made a ref-shaped value fall outside the whitelist, deduped. */\n nearMissKeys: Set<string>\n}\n\nfunction collectMediaSlots(\n node: unknown,\n out: MediaSlot[],\n depth: number,\n report: WalkReport,\n): void {\n // Flagged, never silent — and flagged rather than logged, so a pathological\n // tree cannot turn the operator log into a hot loop. Below the bound a media\n // ref is not visited, so its stored url would cross unchanged.\n if (depth > MAX_WALK_DEPTH) {\n report.truncated = true\n return\n }\n const noteNearMiss = (keys: readonly string[]): void => {\n for (const key of keys) report.nearMissKeys.add(key)\n }\n if (isUnknownArray(node)) {\n for (let index = 0; index < node.length; index += 1) {\n const id = mediaRefId(node[index], noteNearMiss)\n if (id !== null)\n out.push({ container: node, key: index, id, crop: readSpotCrop(node[index]) })\n else collectMediaSlots(node[index], out, depth + 1, report)\n }\n return\n }\n if (!isRecord(node)) return\n for (const [key, value] of Object.entries(node)) {\n const id = mediaRefId(value, noteNearMiss)\n if (id !== null) out.push({ container: node, key, id, crop: readSpotCrop(value) })\n else collectMediaSlots(value, out, depth + 1, report)\n }\n}\n\n/**\n * The projection as it goes INTO a block tree: null-valued optionals OMITTED.\n *\n * 🔴 Found by rendering a real page in a browser, not by a unit test — the\n * tests all used a projection whose `width`/`height`/`alt` happened to be\n * populated. `MediaProjection` declares its optionals `T | null` because a\n * top-level media field publishes a fixed key set. `MediaRef` — the type every\n * block render is typed against, and the one the consumer-side validator\n * narrows to — declares them `T | undefined`, so a JSON `null` there is not a\n * value any render can hold. The front-end validator did exactly what it should\n * with one: dropped it, and logged \"block props were dropped as unusable\" for\n * `image.width`, `image.height` and `image.alt` on EVERY render of every page\n * carrying an image. Correct behaviour, useless signal — the log that exists to\n * report real data loss would have been drowned by it from day one.\n *\n * Absent and null mean the same thing for every one of these fields, so\n * omitting is lossless. What it buys is a value that is assignable to the type\n * the renders declare, which is the whole reason `MediaRef` carries a url at\n * all.\n */\nfunction toEmbeddedProjection(projection: BlockMediaProjection): Record<string, unknown> {\n const out: Record<string, unknown> = {\n id: projection.id,\n url: projection.url,\n variants: projection.variants,\n // Always embedded, like `variants`: the render reads it to crop a shaped\n // slot, and a projection always carries one (`{0.5, 0.5}` when the row has\n // none), so there is no null to omit.\n focalPoint: projection.focalPoint,\n // Always embedded too: an empty map is \"nothing generated yet\", which a\n // render reads as \"no srcset\" — there is no null to omit.\n sources: projection.sources,\n }\n // Only when there are some (images PR12): the tree is serialised into the\n // page, so an empty key would change every page of a site with AVIF off.\n if (projection.avifSources !== undefined && Object.keys(projection.avifSources).length > 0) {\n out.avifSources = projection.avifSources\n }\n if (projection.mimeType !== null) out.mimeType = projection.mimeType\n if (projection.width !== null) out.width = projection.width\n if (projection.height !== null) out.height = projection.height\n if (projection.alt !== null) out.alt = projection.alt\n if (projection.title !== null) out.title = projection.title\n // Omitted when there is none, like the other optionals: most photographs on\n // a site that predates images PR11 have none until the catch-up reaches them.\n if (projection.blurDataUrl !== null) out.blurDataUrl = projection.blurDataUrl\n return out\n}\n\n/**\n * The files cut for THIS placement's crop, or `undefined` when the worker has\n * not cut them yet (or the placement has no crop). Matched on the four-decimal\n * grid (`sameSpotCrop`).\n */\nfunction spotSourcesFor(\n projection: BlockMediaProjection,\n crop: SpotCrop | undefined,\n): BlockSpotSources | undefined {\n if (crop === undefined || projection.spotSources === undefined) return undefined\n return projection.spotSources.find(\n (spot) => sameSpotCrop(spot.crop, crop) && spot.sources.length > 0,\n )\n}\n\n/**\n * The embedded value for a placement with a spot crop (images PR16, D002).\n *\n * - **Its files exist** — the placement's value becomes THOSE files: `sources`\n * holds the crop's list under `natural` (the crop IS this placement's\n * picture, at the crop's own ratio), `url`/`width`/`height` are the largest\n * file's, and the focal point is the centre (the file is already the crop).\n * So a render selects from the crop's files alone — the original is never\n * appended above them, because it is no longer larger than the value's own\n * width — and CSS has nothing left to crop. `avifSources` follows only when\n * the crop's AVIF files are exactly its WebP list, re-encoded.\n * - **They do not exist yet** — the projection as any placement gets it: the\n * focal-point files, never the original (D002). The crop is kept on the\n * value either way, so a second pass sees the same placement.\n *\n * A placement with NO crop never reaches this function, so its embedded value\n * — and the markup a render makes of it — is byte-identical to before.\n */\nfunction embedCropped(projection: BlockMediaProjection, crop: SpotCrop): Record<string, unknown> {\n const out = toEmbeddedProjection(projection)\n const spot = spotSourcesFor(projection, crop)\n const largest = spot?.sources[spot.sources.length - 1]\n if (spot !== undefined && largest !== undefined) {\n out.url = largest.url\n out.width = largest.width\n out.height = largest.height\n out.focalPoint = { x: 0.5, y: 0.5 }\n out.sources = { natural: spot.sources }\n const avif = spot.avifSources\n const avifMatches =\n avif !== undefined &&\n avif.length === spot.sources.length &&\n avif.every(\n (file, i) =>\n file.width === spot.sources[i]?.width && file.height === spot.sources[i]?.height,\n )\n if (avifMatches) out.avifSources = { natural: avif }\n else delete out.avifSources\n }\n out.crop = { x: crop.x, y: crop.y, w: crop.w, h: crop.h }\n return out\n}\n\nfunction writeSlot(slot: MediaSlot, value: BlockMediaProjection | null): void {\n const written =\n value === null\n ? null\n : slot.crop === undefined\n ? toEmbeddedProjection(value)\n : embedCropped(value, slot.crop)\n if (Array.isArray(slot.container)) {\n if (typeof slot.key === 'number') slot.container[slot.key] = written\n return\n }\n if (typeof slot.key === 'string') slot.container[slot.key] = written\n}\n\n// ---------------------------------------------------------------------------\n// The resolver\n// ---------------------------------------------------------------------------\n\n/**\n * Resolve one stored block tree into a {@link ResolvedBlockTree}.\n *\n * **Throws when `blocks` is not an array**, which is how a corrupt stored value\n * is handled rather than papered over — a resolver that returned `{ resolved: 1,\n * blocks: [] }` for it would report success while deleting a page's content,\n * and the caller could not tell the two apart.\n *\n * ⚠️ **What the caller does with that throw CHANGED** (editor D010, closing\n * F056). It used to serve the stored tree unchanged, so a malformed row cost\n * its own hrefs and nothing else. It no longer does: it re-calls this function\n * with an exhausted budget and, if that fails too, serves an empty envelope. A\n * non-array `blocks` fails this guard DETERMINISTICALLY, so a corrupt column is\n * now precisely the case that reaches that third state and is blanked. That is\n * the intended outcome rather than a regression — a non-array stored value\n * cannot be rendered by any consumer, and it may still be an object carrying\n * media refs whose urls were signed at author time.\n */\nexport async function resolveBlockTree(\n blocks: unknown,\n opts: {\n locale: string\n budget: BlockResolutionBudget\n entity: string\n field: string\n /** content-api's whitelist ∧ `view` gate for THIS caller. Absent → no collection rows. */\n canReadEntity?: ((entityName: string) => boolean) | undefined\n /** One slot of the response's collection allowance. Absent → no collection rows. */\n claimCollection?: (() => boolean) | undefined\n /** A CODED TEMPLATE's root block name — `blocks` is then ignored and the template rebuilt. */\n template?: string | undefined\n /** The row a templated document's bound blocks read. Read, never written. */\n boundValues?: Readonly<Record<string, unknown>> | undefined\n },\n deps: BlockResolverDeps,\n): Promise<ResolvedBlockTree> {\n // A CODED TEMPLATE has no stored tree (editor D017): rebuild it from the\n // registry, and remember which COLUMNS its blocks bind — a bound rich-text\n // column's link marks live in the row, and they are resolved below in the\n // same batch as the tree's own (F109). Only the columns THIS template binds\n // are read, so a same-named column bound by some other block is never walked.\n //\n // An ORDINARY tree can carry bound blocks too — `documentBody` dropped into a\n // composable page renders that page's column — so when the caller hands over\n // the row, its bound columns are walked the same way. Without that the canvas\n // (which composes bound values into any tree) resolved links the site then\n // rendered as plain text.\n let tree: unknown = blocks\n let boundColumns: readonly string[] = []\n if (opts.template !== undefined) {\n // 🔴 The ids belong to THIS document (editor F124): namespaced by the entity\n // and the row's own id, exactly as the editor and the bound-field route\n // namespace them, so the delivered tree carries the ids the canvas edits.\n //\n // The id is read off the row because the row is what the caller hands over\n // — content-api always projects `id` (`query-params.ts`, \"Always include\n // id\"), so a templated field never arrives without one. If it somehow did,\n // the template is served EMPTY rather than with ids that name no document:\n // an empty body is a visible fault, a shared id space is the defect F124\n // closed.\n const documentId = opts.boundValues?.id\n if (typeof documentId === 'string' && documentId !== '') {\n const built = (deps.materializeTemplate ?? materializeServerTemplate)(\n opts.template,\n templateNamespace(opts.entity, documentId),\n )\n tree = built.blocks\n boundColumns = built.boundColumns\n } else {\n tree = []\n deps.logger?.warn(\n { entity: opts.entity, field: opts.field },\n 'content: a templated document arrived without its row id, so its template is served ' +\n 'EMPTY — its block ids could not be namespaced to it (editor F124)',\n )\n }\n } else if (opts.boundValues !== undefined && isUnknownArray(blocks)) {\n // Degrades to \"no bound columns\" rather than failing the tree: bound-column\n // links are an enhancement, and a throw here would otherwise blank the whole\n // document (the zero-budget retry would throw identically).\n try {\n boundColumns = (deps.boundColumnsOf ?? serverBoundColumnsInTree)(blocks)\n } catch (err) {\n deps.logger?.warn(\n { err, entity: opts.entity, field: opts.field },\n 'content: could not enumerate the bound columns of a block tree, so its bound-field links render plain',\n )\n }\n }\n const row = opts.boundValues ?? {}\n const boundSources = boundColumns\n .filter((column) => Object.hasOwn(row, column))\n .map((column) => row[column])\n\n if (!isUnknownArray(tree)) {\n deps.logger?.warn(\n { entity: opts.entity, field: opts.field },\n 'content: stored block tree is not an array — nothing to resolve, so this field is ' +\n 'served EMPTY rather than as stored (the caller retries this throw with an exhausted ' +\n 'budget, and a non-array fails that identically)',\n )\n throw new TypeError(\n `content: block tree \\`${opts.entity}.${opts.field}\\` is not an array; nothing to resolve.`,\n )\n }\n\n const slots: MediaSlot[] = []\n const report: WalkReport = { truncated: false, nearMissKeys: new Set() }\n collectMediaSlots(tree, slots, 0, report)\n // ONE line per call at most, whatever the tree looks like — a per-node log on\n // an anonymous request path is the amplification this file bounds everywhere\n // else. Both are hedged (\"any\", \"may\") because the walk cannot know whether\n // the thing it declined to visit was a media ref at all; what it knows is\n // that it did not look.\n if (report.truncated) {\n deps.logger?.warn(\n { entity: opts.entity, field: opts.field, cap: MAX_WALK_DEPTH },\n 'content: block tree is nested deeper than the media walk descends — any media ref below ' +\n 'the bound keeps the url recorded when an editor picked it, which may be signed',\n )\n }\n if (report.nearMissKeys.size > 0) {\n deps.logger?.debug(\n { entity: opts.entity, field: opts.field, keys: [...report.nearMissKeys].sort() },\n 'content: a value looked like a media ref but carried keys outside the projection, so it ' +\n 'was left alone. If these are media refs, they keep their stored urls',\n )\n }\n // Its OWN bound, not this module's. Link resolution is a display concern — an\n // unresolved link renders as plain text — while the media walk is a security\n // control, which is why only one of the two was raised. Passing\n // `MAX_WALK_DEPTH` here would have silently quadrupled link collection depth\n // as a side effect of a change that had nothing to do with links.\n //\n // The bound columns' links join the SAME list, so they share every step below\n // — one media batch, one published-path lookup per linked entity type, one\n // pair of claims against the response's budget. That is F109's fan-out bound:\n // links are resolved per DOCUMENT, never one request per link and never one\n // batch per column.\n const links = [\n ...collectLinkValues(tree, LINK_WALK_DEPTH),\n ...collectLinkValues(boundSources, LINK_WALK_DEPTH),\n ]\n\n // Media LINKS resolve to a media url, so they join the same batch — one\n // lookup covers a `{kind:'media'}` link and an image of the same asset.\n //\n // 🔴 FILTERED to the uuid shape, and the filter is load-bearing rather than\n // defensive. `mediaRefId` already requires a uuid, but `asLinkValue` accepts\n // ANY non-empty string as a `{kind:'media'}` id — so one legacy or\n // hand-authored link with `id: 'x'` reached `inArray(idColumn, ids)` and\n // Postgres raised `22P02 invalid input syntax for type uuid`. The batch's\n // catch then returned an EMPTY map, and every slot on the page was written\n // `null`: one malformed link deleted every image in the tree. Rejects are\n // reported as `unresolvable` rather than dropped, because a link that can\n // never resolve is exactly what that channel is for.\n const wellFormedMediaLinkIds: string[] = []\n const malformedRefs: UnresolvedBlockRef[] = []\n for (const link of links) {\n if (link.kind !== 'media') continue\n if (UUID_RE.test(link.id)) wellFormedMediaLinkIds.push(link.id)\n else malformedRefs.push({ kind: 'link', ref: linkValueKey(link), reason: 'unresolvable' })\n }\n const mediaIds = distinct([...slots.map((slot) => slot.id), ...wellFormedMediaLinkIds])\n\n const entityTargets: string[] = []\n for (const link of links) {\n if (link.kind !== 'entity') continue\n // Same reason: `filterPublished` issues one `inArray` per entity type, so a\n // single non-uuid id throws and takes every link of that type with it.\n if (UUID_RE.test(link.id)) entityTargets.push(targetKey(link.entity, link.id))\n else malformedRefs.push({ kind: 'link', ref: linkValueKey(link), reason: 'unresolvable' })\n }\n\n const unresolved: UnresolvedBlockRef[] = [...malformedRefs]\n\n // TWO claims against the ONE shared counter, media first.\n //\n // Two rather than one so each pass learns its own grant without splitting a\n // combined number, and media first because a vanished image is the more\n // visible failure — when a page is over budget, the deterministic outcome is\n // \"images render, some links go plain\" rather than whichever the scheduler\n // reached first. Both record their shortfall under the same `block` kind, so\n // the operator log is unchanged.\n //\n // Nothing is claimed for media when no resolver is wired: the budget reports\n // work the server DECLINED, and there is no work to decline. Claiming anyway\n // burned the whole ceiling on a pass that did no I/O and then reported every\n // LINK as `budget` — a drop log naming the wrong cause.\n if (!deps.resolveMedia && mediaIds.length > 0) {\n // The signal for \"this deployment cannot resolve media at all\", emitted\n // HERE because this is now the only place that can tell. `resolveMediaBatch`\n // used to carry it, and the no-claim rule above made its branch\n // unreachable — the ids never reach it. It is the likeliest cause of \"every\n // image on the site is gone\", and the `unresolvable` the envelope reports\n // deliberately does not distinguish causes, so the operator log is the only\n // place it can be said.\n deps.logger?.debug(\n { entity: opts.entity, field: opts.field, count: mediaIds.length },\n 'content: no media resolver wired for block trees — every media ref resolves to null',\n )\n }\n\n const mediaGranted = deps.resolveMedia ? opts.budget.claim('block', mediaIds.length) : 0\n const coveredMedia = mediaIds.slice(0, mediaGranted)\n // 🔴 EMPTY when no resolver is wired, and that is not the same set as\n // \"everything\". `budgetDroppedMedia` means \"a ceiling refused this\", and it\n // is read again below by `unresolvedLinkReason` — so filling it on the\n // no-resolver path reported every media LINK as `reason: 'budget'` and sent\n // an operator to raise a limit that was never involved. The reason was fixed\n // for the media entries and missed for the links; one set, read twice, is\n // what keeps them consistent.\n const budgetDroppedMedia = deps.resolveMedia\n ? new Set(mediaIds.slice(mediaGranted))\n : new Set<string>()\n for (const id of mediaIds.slice(mediaGranted)) {\n unresolved.push({\n kind: 'media',\n ref: id,\n reason: budgetDroppedMedia.has(id) ? 'budget' : 'unresolvable',\n })\n }\n\n const distinctTargets = distinct(entityTargets)\n const linkGranted = opts.budget.claim('block', distinctTargets.length)\n const coveredTargets = distinctTargets.slice(0, linkGranted)\n const budgetDroppedTargets = new Set(distinctTargets.slice(linkGranted))\n\n const media = await resolveMediaBatch(coveredMedia, opts.locale, deps, opts.entity)\n\n // Every slot is written, including the ones that did not resolve. Leaving a\n // stored ref in place for an id the resolver refused is the leak this module\n // exists to close (see the file header) — \"not public\" and \"not found\" are\n // the same branch downstream, so the ONE case where a fallback would matter\n // is exactly the case where it must not happen.\n for (const slot of slots) {\n const projection = media.get(slot.id) ?? null\n writeSlot(slot, projection)\n }\n for (const id of coveredMedia) {\n if (!media.has(id)) unresolved.push({ kind: 'media', ref: id, reason: 'unresolvable' })\n }\n\n const paths = await resolveEntityPaths(coveredTargets, opts.locale, deps, opts.entity)\n\n // `resolveLinks` still owns dedup, the `sanitizeHref` protocol gate on\n // resolved hrefs, anchor composition and the inline url/email cases. What it\n // is handed here are PURE MAP LOOKUPS, so its `runBounded` fan-out does no\n // I/O at all — the batching above already happened.\n const resolvedLinks = await resolveLinks(links, {\n renderContext: 'request',\n locale: opts.locale,\n resolveEntityHref: ({ entity, id }) => paths.get(targetKey(entity, id)) ?? null,\n resolveMediaHref: (id) => media.get(id)?.url ?? null,\n })\n\n const linkRecord: Record<string, string | null> = {}\n for (const [key, resolved] of resolvedLinks) linkRecord[key] = resolved.href\n // Only `entity`/`media` links are REPORTED as unresolved. A `url` or `email`\n // link with a null href was refused by `sanitizeHref` — an editor typed\n // `javascript:` — which is a content problem the security gate handled, not a\n // resolution this response failed to perform, and listing it would invite a\n // consumer to retry it.\n for (const link of links) {\n if (link.kind !== 'entity' && link.kind !== 'media') continue\n const key = linkValueKey(link)\n if (linkRecord[key] !== null && linkRecord[key] !== undefined) continue\n unresolved.push({\n kind: 'link',\n ref: key,\n reason: unresolvedLinkReason(link, budgetDroppedTargets, budgetDroppedMedia),\n })\n }\n\n // What this tree DEPENDS ON, for whatever cache is holding the response.\n //\n // 🔒 Built from what actually RESOLVED, not from what was asked for. `media`\n // holds only ids the public resolver returned, and `paths` only targets the\n // published-path lookup answered — so an unpublished link target contributes\n // no ref, and its id is not disclosed. That is the same gate the hrefs above\n // pass through, applied to identity; the refusal is already reported through\n // `unresolved`, which carries a `linkValueKey` the consumer already holds.\n //\n // Media BEFORE links, and both deduped by the Map: an image embedded as a\n // `field.media()` and linked as `{kind:'media'}` is one row and one tag.\n const { results: collections, cardMedia } = await resolveCollectionsIn(tree, opts, deps)\n\n const refByKey = new Map<string, BlockTreeRef>()\n for (const id of media.keys()) refByKey.set(targetKey('media', id), { entity: 'media', id })\n for (const id of cardMedia) refByKey.set(targetKey('media', id), { entity: 'media', id })\n // A listed row is a row this tree depends on, and it passed the caller's gate\n // to be listed — the same rule the links below follow.\n for (const result of collections.values()) {\n if (result.entity === null) continue\n for (const row of result.rows) {\n refByKey.set(targetKey(result.entity, row.id), { entity: result.entity, id: row.id })\n }\n }\n for (const key of paths.keys()) {\n if (refByKey.has(key)) continue\n const split = key.indexOf(' ')\n if (split <= 0) continue\n const entity = key.slice(0, split)\n const id = key.slice(split + 1)\n if (id === '') continue\n refByKey.set(key, { entity, id })\n }\n\n return {\n resolved: 1,\n blocks: tree,\n links: linkRecord,\n // OMITTED when empty, never `[]`. Its presence is itself information —\n // Contentful's rule for `errors[]`, and the property that stops \"nothing\n // failed\" from looking like \"nothing was attempted\".\n ...(unresolved.length > 0 && { unresolved: dedupeRefs(unresolved) }),\n // Same rule, same reason: a tree that references nothing says so by\n // omitting the field rather than by shipping `[]`.\n ...(refByKey.size > 0 && { refs: [...refByKey.values()] }),\n // And again: a tree with no collection block carries no sidecar.\n ...(collections.size > 0 && { collections: Object.fromEntries(collections) }),\n }\n}\n\n/**\n * Resolve every collection block in `blocks` through the shared resolver.\n *\n * DENY-BY-DEFAULT at three levels: an app with no declared sources has no\n * `deps.collections`, and a call without `canReadEntity` — a host that did not\n * say who may read what — or without `claimCollection` — a host that did not\n * say how many — resolves nothing. Either way the blocks render empty on the\n * site, which is the safe direction.\n *\n * Images go through the SAME public media resolver the tree's own media refs\n * do, and hrefs through the same published-path lookup its links do, so a card\n * cannot show an asset or a URL the rest of the page could not.\n */\nasync function resolveCollectionsIn(\n blocks: readonly unknown[],\n opts: {\n locale: string\n budget: BlockResolutionBudget\n canReadEntity?: ((entityName: string) => boolean) | undefined\n claimCollection?: (() => boolean) | undefined\n },\n deps: BlockResolverDeps,\n): Promise<{\n readonly results: ReadonlyMap<string, CollectionResult>\n readonly cardMedia: ReadonlySet<string>\n}> {\n const collections = deps.collections\n const canRead = opts.canReadEntity\n const claimCollection = opts.claimCollection\n if (collections === undefined || canRead === undefined || claimCollection === undefined) {\n return { results: new Map(), cardMedia: new Set() }\n }\n const resolveMedia = deps.resolveMedia\n // Every media id a card will draw, so the tree names it in `refs` like its\n // own media: a replaced image or an edited alt must evict the cached page\n // (CodeRabbit review, PR53).\n const cardMedia = new Set<string>()\n const results = await resolveTreeCollections(blocks, collections.sourceFieldOf, {\n sources: collections.sources,\n getEntity: collections.getEntity,\n clientFor: collections.clientFor,\n canRead,\n resolveImages:\n resolveMedia === undefined\n ? undefined\n : async (ids): Promise<ReadonlyMap<string, CollectionCardImage>> => {\n const found = await resolveMedia(ids, { locale: opts.locale })\n // A card image must BE an image. A media field's `kind: 'image'`\n // is only a picker filter (editor F116), so a video can sit in\n // one — and the card would draw it as a broken `<img>`.\n const shown = [...found].filter(\n ([, projection]) => projection.mimeType?.startsWith('image/') === true,\n )\n for (const [id] of shown) cardMedia.add(id)\n // All seven keys a card needs — beyond the four: where to crop\n // around and which generated files to offer (images PR07, F021),\n // and the preview it shows while it loads (PR11).\n // `resolveCollectionSource` normalises what this returns\n // (`cardImageOf`: a centre point, an empty `sources` and a missing\n // preview are left out), the SAME step the canvas's injected resolver goes through,\n // so a card carries the same keys on both surfaces.\n return new Map(\n shown.map(([id, projection]) => [\n id,\n {\n url: projection.url,\n alt: projection.alt,\n width: projection.width,\n height: projection.height,\n focalPoint: projection.focalPoint,\n sources: projection.sources,\n // Non-empty only, the rule the embedded projection follows\n // (`cardImageOf` would drop `{}` too; one rule, both paths).\n ...(projection.avifSources !== undefined &&\n Object.keys(projection.avifSources).length > 0 && {\n avifSources: projection.avifSources,\n }),\n blurDataUrl: projection.blurDataUrl,\n },\n ]),\n )\n },\n resolveHrefs: (entityType, ids) => deps.resolvePublishedPaths(entityType, ids, opts.locale),\n locale: opts.locale,\n renderContext: 'request',\n // A slot of the response's collection allowance, THEN one unit of its ONE\n // budget — so a page whose references and media spent the ceiling lists\n // nothing rather than exceeding it, and a list response stops at the\n // allowance however many rows it carries. Allowance first: once it is\n // spent, a refused collection must not also burn budget the rest of the\n // response's media and links need.\n claim: () => claimCollection() && opts.budget.claim('block', 1) >= 1,\n logger: deps.logger,\n })\n return { results, cardMedia }\n}\n\nfunction unresolvedLinkReason(\n link: LinkValue,\n budgetDroppedTargets: ReadonlySet<string>,\n budgetDroppedMedia: ReadonlySet<string>,\n): 'unresolvable' | 'budget' {\n if (link.kind === 'entity' && budgetDroppedTargets.has(targetKey(link.entity, link.id))) {\n return 'budget'\n }\n if (link.kind === 'media' && budgetDroppedMedia.has(link.id)) return 'budget'\n return 'unresolvable'\n}\n\nfunction distinct(values: readonly string[]): string[] {\n return [...new Set(values)]\n}\n\n/**\n * `(entityType, id)` as one map key. A space is unambiguous as a separator\n * because entity names are identifiers — `defineEntity` rejects anything else —\n * so no name can contain one.\n */\nfunction targetKey(entityType: string, id: string): string {\n return [entityType, id].join(' ')\n}\n\n/**\n * One entry per `(kind, ref)`.\n *\n * What this actually collapses is the SAME LinkValue appearing several times —\n * `collectLinkValues` does not dedupe, so a page linking twice to one target\n * would otherwise report it twice. It deliberately does NOT collapse an image\n * and a media LINK to the same asset: those are `media <id>` and\n * `link media:<id>`, two different failures a consumer may want to act on\n * differently, and the suite asserts exactly that.\n */\nfunction dedupeRefs(refs: readonly UnresolvedBlockRef[]): UnresolvedBlockRef[] {\n const seen = new Set<string>()\n const out: UnresolvedBlockRef[] = []\n for (const ref of refs) {\n const key = `${ref.kind} ${ref.ref}`\n if (seen.has(key)) continue\n seen.add(key)\n out.push(ref)\n }\n return out\n}\n\nasync function resolveMediaBatch(\n ids: readonly string[],\n locale: string,\n deps: BlockResolverDeps,\n entity: string,\n): Promise<Map<string, BlockMediaProjection>> {\n // No log for the unwired case HERE: the caller does not claim, so `ids` is\n // always empty on that path and a branch guarding on it could never fire.\n // The signal lives at the call site, which is the only place that still knows.\n if (ids.length === 0 || !deps.resolveMedia) return new Map()\n try {\n return await deps.resolveMedia(ids, { locale, spots: true })\n } catch (err) {\n deps.logger?.warn(\n { entity, err },\n 'content: media resolver threw while resolving a block tree — its media refs resolve to ' +\n 'null (if this is a ForbiddenError, the requesting role is missing `media:view`)',\n )\n return new Map()\n }\n}\n\n/**\n * {@link targetKey} → path, for the covered targets only.\n *\n * ONE call to {@link BlockResolverDeps.resolvePublishedPaths} per entity TYPE,\n * never per link. A document linking 200 times to 3 entity types costs 3 calls.\n */\nasync function resolveEntityPaths(\n targets: readonly string[],\n locale: string,\n deps: BlockResolverDeps,\n entity: string,\n): Promise<Map<string, string>> {\n const out = new Map<string, string>()\n if (targets.length === 0) return out\n\n const byType = new Map<string, string[]>()\n for (const target of targets) {\n const separator = target.indexOf(' ')\n const entityType = target.slice(0, separator)\n const id = target.slice(separator + 1)\n const bucket = byType.get(entityType)\n if (bucket) bucket.push(id)\n else byType.set(entityType, [id])\n }\n\n // SERIAL over types, matching `resolveBlockTrees`' serial-over-rows rule: the\n // set is tiny (a document links to a handful of types at most) and serial\n // keeps the read pattern predictable under load rather than bursting one\n // query per type at once.\n for (const [entityType, ids] of byType) {\n try {\n const paths = await deps.resolvePublishedPaths(entityType, ids, locale)\n for (const [id, path] of paths) out.set(targetKey(entityType, id), path)\n } catch (err) {\n // One broken entity type costs its own links, not the page's whole tree.\n deps.logger?.warn(\n { entity, entityType, err },\n 'content: path resolution threw for a block-tree link target — its links render as ' +\n 'plain text',\n )\n }\n }\n return out\n}\n\n// ---------------------------------------------------------------------------\n// App wiring\n// ---------------------------------------------------------------------------\n\n/**\n * Wire {@link resolveBlockTree} to the running app — the one line an app puts\n * into `createContentApiHandler({ resolveBlocks })`.\n *\n * Pass the SAME `resolveMedia` value the handler gets, so a media ref inside a\n * block and a top-level `media()` field resolve through one path and one gate.\n *\n * **The publish gate comes from `buildEnumerateDeps`' batched `filterPublished`,\n * which reads through an elevated REACH context** (`createSystemReachResolver`,\n * which grants `view` and withholds `view_unpublished`), so unpublished and\n * trashed targets are dropped. That is deliberately the SAME gate `GET /_resolve`\n * applies and no stricter one: the alternative — additionally gating on the\n * caller's per-entity `view` — would make the same URL a working link on one\n * endpoint and plain text on another, while the document behind the href stays\n * whitelist- and permission-gated where it is actually fetched.\n */\nexport function createBlockResolver(\n getApp: () => Promise<ToolkitApp> | ToolkitApp,\n options: {\n /**\n * The locale to resolve in when a request carried none. Pass the SAME value\n * given to `createContentApiHandler({ defaultLocale })` — the handler\n * already defaults every entity read's locale before it reaches a resolver,\n * so this only binds on a path that somehow did not, and disagreeing with\n * the handler would resolve a link in a locale the page is not rendered in.\n */\n defaultLocale: string\n resolveMedia?: BlockMediaResolver | undefined\n },\n): (\n blocks: unknown,\n opts: {\n locale?: string | undefined\n budget: BlockResolutionBudget\n entity: string\n field: string\n canReadEntity?: ((entityName: string) => boolean) | undefined\n claimCollection?: (() => boolean) | undefined\n template?: string | undefined\n boundValues?: Readonly<Record<string, unknown>> | undefined\n },\n) => Promise<ResolvedBlockTree> {\n // Built ONCE per app, not once per field per row. `buildEnumerateDeps` makes\n // a table client, and its `filterPublished` makes a fresh `QueryClient` and a\n // child logger per call — cheap individually, and paid once per `blockTree`\n // field on every row of every list page if it lives inside the closure.\n //\n // Cached against the app IDENTITY rather than assumed singleton: `getApp` is\n // a callback precisely because a host may hand back a different app (tests\n // do), and a cache that ignored that would silently read another app's\n // database.\n let cached: { app: ToolkitApp; deps: BlockResolverDeps } | null = null\n\n const depsFor = (app: ToolkitApp): BlockResolverDeps => {\n if (cached?.app === app) return cached.deps\n // `pathsClient` here is the BATCHED (`{ in }`-capable) reader, which is what\n // `getCanonicalPaths` needs — the point-lookup `buildCanonicalPathDeps`\n // reader cannot express an id batch, and reaching for it is how this ends\n // up one query per link.\n const enumerateDeps = buildEnumerateDeps(app)\n // editor PR53 — only when the app declares collection sources. The reader\n // is `createQueryClient`, which is published-only and bound to the AMBIENT\n // request context: the same caller-scoped read content-api's own list\n // endpoint performs, so a collection sees exactly the rows that caller\n // could fetch directly — nothing wider, no elevated identity.\n const { sources } = effectiveCollectionSources(app)\n const collections: BlockCollectionDeps | undefined =\n sources.length === 0\n ? undefined\n : {\n sources,\n sourceFieldOf: serverCollectionFieldLookup(),\n getEntity: (name) => app.entities.get(name),\n clientFor: (entity) => createCollectionReader(entity, app),\n }\n // A template is REGISTRY code, fixed after boot, so the registry lookups it\n // is rebuilt from are built ONCE per app rather than per row: a list\n // response of a hundred templated rows would otherwise rebuild the\n // block-definition map two hundred times on the anonymous path. The TREE is\n // still rebuilt per row, because its ids belong to that row's document\n // (editor F124) — the part that was cheap. Each call answers a fresh,\n // deep-cloned tree (see `serverTemplateMaterializer`), so the media slots\n // this resolver writes in place never reach another response or the\n // registry.\n const templates = serverTemplateMaterializer()\n // The structure lookup for ORDINARY trees' bound columns, built once per app\n // for the same reason: `serverBoundColumnsInTree` would otherwise rebuild the\n // block-definition map per row per field on the anonymous path.\n const structureLookup = serverBlockStructureLookup()\n const deps: BlockResolverDeps = {\n ...(collections !== undefined && { collections }),\n boundColumnsOf: (tree) => collectBoundColumnsInTree(tree, structureLookup),\n materializeTemplate: (template, namespace) => templates(template, namespace),\n resolveMedia: options.resolveMedia,\n resolvePublishedPaths: async (entityType, ids, locale) => {\n const published = await enumerateDeps.filterPublished(entityType, ids, locale)\n if (published.size === 0) return new Map()\n return getCanonicalPaths(entityType, [...published], locale, enumerateDeps)\n },\n logger: app.logger,\n }\n cached = { app, deps }\n return deps\n }\n\n return async (blocks, opts) => {\n const app = await getApp()\n return resolveBlockTree(\n blocks,\n { ...opts, locale: opts.locale ?? options.defaultLocale },\n depsFor(app),\n )\n }\n}\n"],"mappings":"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"}
@@ -1,4 +1,4 @@
1
- import { r as ChromePlacement } from "./site-chrome-CpnNlaw5.mjs";
1
+ import { r as ChromePlacement } from "./site-chrome-CUE16iIz.mjs";
2
2
  import { ToolkitApp } from "@murumets-ee/core";
3
3
 
4
4
  //#region src/chrome-provider.d.ts
@@ -1,2 +1,2 @@
1
- import{t as e}from"./chrome-entity-names-zYJzP-_U.mjs";import{t}from"./site-chrome-BhlkbRuq.mjs";import{getBlockTreeFieldName as n}from"@murumets-ee/entity";import{composeChromeRegion as r,stripScopeTags as i}from"@murumets-ee/blocks/render";const a=t;async function o(e,t){let n=e.document&&(!t.claim||t.claim())?await t.readOverrides(e.document.entityType,e.document.id,e.locale):null,o={header:null,footer:null};for(let s of a){if(t.claim&&!t.claim())continue;let a=await t.readChromeDocument(s,e.locale);if(!a)continue;let c={id:a.id,blocks:a.blocks};try{let e=r(c,n);if(!e)continue;o[s]={blocks:i(e.blocks),overridden:e.overridden}}catch(n){t.logger?.warn({region:s,documentId:c.id,locale:e.locale,err:n},`content: chrome region failed to compose (malformed stored blocks, or strip depth exceeded) — serving no chrome for this region`)}}return a.some(e=>o[e]!==null)?o:null}function s(t){return{entity:e,getChrome:async r=>{let i=await t(),{createQueryClient:a}=await import(`@murumets-ee/core/clients`),{schemaRegistry:s}=await import(`@murumets-ee/db`),{eq:c,getTableColumns:l}=await import(`drizzle-orm`),u=e=>{let t=s.get(e);if(t)return l(t)},d=async(e,t,n,r)=>{let o=i.entities.get(e);return o?(await a(o).findMany({limit:1,locale:t,...n!==void 0&&{where:n},...r!==void 0&&{exposeInternalFields:r}}))[0]??null:null},f=i.entities.get(e),p=f?n(f):void 0,m=u(e)?.placement;return!p||!m?null:o(r,{claim:()=>r.budget.claim(`block`,1)>0,readChromeDocument:async(t,n)=>{let r=await d(e,n,c(m,t));if(!r)return null;let i=r[p];return{id:typeof r.id==`string`?r.id:e,blocks:Array.isArray(i)?i:[]}},readOverrides:async(e,t,n)=>{let r=u(e)?.id;if(!r)return null;let i=await d(e,n,c(r,t),[`_overrides`]);if(!i)return null;let a=i._overrides;return a&&typeof a==`object`&&!Array.isArray(a)?a:null},logger:i.logger})}}}export{o as composeChromeRegions,s as createChromeProvider};
1
+ import{t as e}from"./chrome-entity-names-zYJzP-_U.mjs";import{t}from"./site-chrome-BUEOhmyf.mjs";import{getBlockTreeFieldName as n}from"@murumets-ee/entity";import{composeChromeRegion as r,stripScopeTags as i}from"@murumets-ee/blocks/render";const a=t;async function o(e,t){let n=e.document&&(!t.claim||t.claim())?await t.readOverrides(e.document.entityType,e.document.id,e.locale):null,o={header:null,footer:null};for(let s of a){if(t.claim&&!t.claim())continue;let a=await t.readChromeDocument(s,e.locale);if(!a)continue;let c={id:a.id,blocks:a.blocks};try{let e=r(c,n);if(!e)continue;o[s]={blocks:i(e.blocks),overridden:e.overridden}}catch(n){t.logger?.warn({region:s,documentId:c.id,locale:e.locale,err:n},`content: chrome region failed to compose (malformed stored blocks, or strip depth exceeded) — serving no chrome for this region`)}}return a.some(e=>o[e]!==null)?o:null}function s(t){return{entity:e,getChrome:async r=>{let i=await t(),{createQueryClient:a}=await import(`@murumets-ee/core/clients`),{schemaRegistry:s}=await import(`@murumets-ee/db`),{eq:c,getTableColumns:l}=await import(`drizzle-orm`),u=e=>{let t=s.get(e);if(t)return l(t)},d=async(e,t,n,r)=>{let o=i.entities.get(e);return o?(await a(o).findMany({limit:1,locale:t,...n!==void 0&&{where:n},...r!==void 0&&{exposeInternalFields:r}}))[0]??null:null},f=i.entities.get(e),p=f?n(f):void 0,m=u(e)?.placement;return!p||!m?null:o(r,{claim:()=>r.budget.claim(`block`,1)>0,readChromeDocument:async(t,n)=>{let r=await d(e,n,c(m,t));if(!r)return null;let i=r[p];return{id:typeof r.id==`string`?r.id:e,blocks:Array.isArray(i)?i:[]}},readOverrides:async(e,t,n)=>{let r=u(e)?.id;if(!r)return null;let i=await d(e,n,c(r,t),[`_overrides`]);if(!i)return null;let a=i._overrides;return a&&typeof a==`object`&&!Array.isArray(a)?a:null},logger:i.logger})}}}export{o as composeChromeRegions,s as createChromeProvider};
2
2
  //# sourceMappingURL=chrome-provider.mjs.map
@@ -1,2 +1,2 @@
1
- import { a as siteChromeEntity, i as SiteChromeEntityOptions, n as CHROME_PLACEMENT_LABELS, r as ChromePlacement, t as CHROME_PLACEMENTS } from "../site-chrome-CpnNlaw5.mjs";
1
+ import { a as siteChromeEntity, i as SiteChromeEntityOptions, n as CHROME_PLACEMENT_LABELS, r as ChromePlacement, t as CHROME_PLACEMENTS } from "../site-chrome-CUE16iIz.mjs";
2
2
  export { CHROME_PLACEMENTS, CHROME_PLACEMENT_LABELS, type ChromePlacement, type SiteChromeEntityOptions, siteChromeEntity };
@@ -1 +1 @@
1
- import{n as e,r as t,t as n}from"../site-chrome-BhlkbRuq.mjs";export{n as CHROME_PLACEMENTS,e as CHROME_PLACEMENT_LABELS,t as siteChromeEntity};
1
+ import{n as e,r as t,t as n}from"../site-chrome-BUEOhmyf.mjs";export{n as CHROME_PLACEMENTS,e as CHROME_PLACEMENT_LABELS,t as siteChromeEntity};
package/dist/seed.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import{t as e}from"./chrome-entity-names-zYJzP-_U.mjs";import{i as t,n}from"./schema-D8sBQozw.mjs";import{n as r,t as i}from"./site-chrome-BhlkbRuq.mjs";import{getApp as a}from"@murumets-ee/core";import{eq as o}from"drizzle-orm";import{createAdminClient as s}from"@murumets-ee/core/clients";import{randomUUID as c}from"node:crypto";const l={header:{handle:`main`},footer:{handle:`footer`}};function u(e){return[{id:c(),type:`menu`,props:{handle:l[e].handle,orientation:`horizontal`},traits:{}}]}async function d(){let n=a().entities.get(e);if(!n)return{created:0};let c=s(n),l=0,d=c.getTable().placement;if(!d)throw Error(`${e} has no "placement" column — the chrome entity registered by this app is not the one @murumets-ee/content ships.`);for(let e of i)if(!((await c.findMany({where:o(d,e),limit:1})).length>0))try{await c.create({placement:e,label:r[e],layout:u(e)}),l++}catch(e){if(!t(e))throw e}return{created:l}}async function f(e){let t=n.makeClient(a().db.readWrite),r={};for(let n of e)r[n]=(await t.deleteMany({entityType:n})).length;return r}export{f as forgetEntityPaths,d as seedSiteChrome};
1
+ import{t as e}from"./chrome-entity-names-zYJzP-_U.mjs";import{i as t,n}from"./schema-D8sBQozw.mjs";import{n as r,t as i}from"./site-chrome-BUEOhmyf.mjs";import{getApp as a}from"@murumets-ee/core";import{eq as o}from"drizzle-orm";import{createAdminClient as s}from"@murumets-ee/core/clients";import{randomUUID as c}from"node:crypto";const l={header:{handle:`main`},footer:{handle:`footer`}};function u(e){return[{id:c(),type:`menu`,props:{handle:l[e].handle,orientation:`horizontal`},traits:{}}]}async function d(){let n=a().entities.get(e);if(!n)return{created:0};let c=s(n),l=0,d=c.getTable().placement;if(!d)throw Error(`${e} has no "placement" column — the chrome entity registered by this app is not the one @murumets-ee/content ships.`);for(let e of i)if(!((await c.findMany({where:o(d,e),limit:1})).length>0))try{await c.create({placement:e,label:r[e],layout:u(e)}),l++}catch(e){if(!t(e))throw e}return{created:l}}async function f(e){let t=n.makeClient(a().db.readWrite),r={};for(let n of e)r[n]=(await t.deleteMany({entityType:n})).length;return r}export{f as forgetEntityPaths,d as seedSiteChrome};
2
2
  //# sourceMappingURL=seed.mjs.map
@@ -0,0 +1,2 @@
1
+ import{n as e}from"./versionable-Br_G5VGR.mjs";import{auditable as t,defineEntity as n,field as r,publishable as i}from"@murumets-ee/entity";const a=[`header`,`footer`],o={header:`Site header`,footer:`Site footer`},s={label:{label:`Name`,description:`Shown in the admin list and as the editor title. Visitors never see it.`}};function c(e){let t={...s};for(let[n,r]of Object.entries(e??{}))t[n]={...t[n],...r};return t}function l(o){let s=o?.viewRole??`editor`,l=o?.updateRole??`editor`,u=o?.adminRole??`admin`,d=o?.maxVersions??30;return n({name:`site_chrome`,kind:`collection`,fields:{placement:r.select({options:a,required:!0,unique:!0}),label:r.text({required:!0}),layout:r.blockTree({translatable:!0,...o?.environment!==void 0&&{environment:o.environment}})},behaviors:[t(),i(),e({maxPerDoc:d}),...o?.behaviors??[]],scope:`global`,access:{view:s,create:u,update:l,delete:u},admin:{icon:`panel-top`,description:`Blocks rendered around every page that does not override them.`,rootFields:[`label`],fieldOverrides:c(o?.admin?.fieldOverrides),titleField:`label`,group:`Site`,disableCreate:!0}})}l().name;export{o as n,l as r,a as t};
2
+ //# sourceMappingURL=site-chrome-BUEOhmyf.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"site-chrome-BUEOhmyf.mjs","names":[],"sources":["../src/entities/site-chrome.ts"],"sourcesContent":["/**\n * `site_chrome` — the block documents that hold the site chrome, one row per\n * PLACEMENT, so a header is edited on the same canvas as the page it sits\n * above instead of being typed into a JSON textarea.\n *\n * ## Placement, not type\n *\n * There is ONE entity. A chrome document says where it appears by carrying a\n * `placement` value, and `'header'` / `'footer'` are exactly the region keys\n * `GET /v1/_chrome` emits — so the provider maps placement to region by\n * identity rather than through a lookup table. That is the whole of the model.\n *\n * It replaces three entities (`site_header`, `site_footer`, and a single-row\n * site layout) and six hardcoded name lists, the most consequential of which\n * was not a list of names at all: `apps/admin/lib/chrome-documents.ts` carried\n * a module constant pairing each chrome entity with a\n * `position: 'before' | 'after'`. That was the placement flag, written in code,\n * in the app, where no operator could reach it and no plugin could extend it.\n *\n * ## What survives, and why it had to\n *\n * **A separate ROW per placement.** `publishable()` and `versionable()` are\n * per-row, so a header and a footer sharing one row could not be published or\n * rolled back independently. That constraint is real and this model honours\n * it — it is only the entity TYPE that collapses, never the row.\n *\n * **Peer-document editing.** The editor composes header, page and footer as\n * peers with per-document undo (`plan/site-chrome` D001 / D003). It never\n * keyed on the entity NAME to do that: `DocumentSlice.entity` is the only\n * discriminator and `pageDocumentId` is explicit state, precisely so that no\n * entity-name allowlist is needed. One name in place of two costs it nothing.\n *\n * ## Cardinality — why `kind: 'collection'` and a unique column\n *\n * `kind: 'singleton'` is enforced now (`plan/site-chrome` D011), and\n * `assertSingletonSlotFree` counts every live row of an entity — so an entity\n * holding a header row AND a footer row trips the gate on the second insert.\n * The kind and the collapse are mutually exclusive, which is why phase 09 lists\n * \"the singleton kind\" among the things that go.\n *\n * `placement`'s `unique: true` is what takes over the half that mattered: the\n * rule becomes one row per placement, enforced at the DATABASE for every\n * writer rather than in one client. On a `trashable()` entity — which this is,\n * by auto-enable, because it is publishable — the schema builder re-emits that\n * as a PARTIAL unique index (`WHERE \"deletedAt\" IS NULL`), so trashing a\n * header frees its placement for a replacement instead of wedging it.\n *\n * What is NOT carried over is the singleton DELETE rule (\"never leave zero\").\n * Deleting the only header is now possible, and the site falls back to the\n * delivery template's own markup — the same thing it does before the first\n * header is ever created. `trashable()` makes it recoverable.\n *\n * ## `admin.disableCreate` is correctness, not a workaround\n *\n * The block-editor create branch auto-POSTs `{ title: 'Untitled' }` and\n * nothing else, so any entity on that branch with a second required field\n * would reject on `/new`. `placement` is exactly such a field.\n *\n * Suppressing the affordance is also the honest answer: a third chrome row\n * would render NOWHERE, because the delivery template has exactly two chrome\n * slots. A new placement is a code change on both sides — a `placement` option\n * here and a slot in `BaseLayout.astro` — and that coupling is what keeps the\n * enum from accumulating values nothing renders.\n *\n * The two rows a site needs are created by the `content:site-chrome` seeder,\n * which `lumi migrate` runs automatically as a `bootstrap` seeder. Nobody has\n * to press anything.\n *\n * ## Wiring\n *\n * @example\n * ```ts\n * // apps/<app>/entities/site-chrome.ts\n * import { withOpLog } from '@murumets-ee/blocks/server'\n * import { siteChromeEntity } from '@murumets-ee/content/entities'\n *\n * export const SiteChrome = siteChromeEntity({ environment: 'page' })\n * .with(withOpLog()) // see below — the app layers this on\n * ```\n *\n * `withOpLog()` is deliberately NOT in the factory: it ships from\n * `@murumets-ee/blocks/server`, and `@murumets-ee/content` must not depend on\n * `@murumets-ee/blocks` (blocks depends back on content). The app layers it at\n * config time. **Without it the block editor 404s** — `editor-state` refuses a\n * document whose entity has no op-log behavior — so an app that registers this\n * and skips it gets a chrome document it cannot open.\n *\n * Behaviors the factory does apply:\n *\n * - `auditable()` — created/updated stamps\n * - `publishable()` — a header is drafted and published like any document,\n * which is the point of it being one\n * - `versionable({ maxPerDoc })` — per-locale history\n */\n\nimport type { Behavior, EntityAdminConfig } from '@murumets-ee/entity'\nimport { auditable, defineEntity, field, publishable } from '@murumets-ee/entity'\nimport { versionable } from '../behaviors/versionable.js'\n\n/**\n * Where a chrome document appears on every page that does not override it.\n *\n * These strings ARE the `/v1/_chrome` region keys and the delivery template's\n * slot names. Adding one means adding a slot to `BaseLayout.astro` in the same\n * change, or the value names a region nothing renders.\n */\nexport const CHROME_PLACEMENTS = ['header', 'footer'] as const\n\n/** One placement. Also a region name on the wire — same string, by design. */\nexport type ChromePlacement = (typeof CHROME_PLACEMENTS)[number]\n\n/** The default label a bootstrap row gets, per placement. */\nexport const CHROME_PLACEMENT_LABELS: Readonly<Record<ChromePlacement, string>> = {\n header: 'Site header',\n footer: 'Site footer',\n}\n\n/** Optional overrides apps can apply when calling the factory. */\nexport interface SiteChromeEntityOptions {\n /**\n * Roles allowed to read the document. Default `'editor'`.\n *\n * ADVISORY entity-level metadata that does NOT govern the public content API\n * — that path resolves a role and asks `buildPermissionChecker`.\n * `site_chrome:view` is seeded onto `public` by\n * `buildInitialRoleDefinitions()` so a fresh install serves its chrome;\n * changing this option does not change that.\n */\n viewRole?: string\n /** Roles allowed to edit a document's blocks. Default `'editor'`. */\n updateRole?: string\n /** Roles allowed to create/delete a chrome row. Default `'admin'`. */\n adminRole?: string\n /**\n * The block environment the editor offers in this document's canvas. Pass\n * the same value the site's page entity uses so a header can be built from\n * the same vocabulary. Omitted means \"no environment restriction\".\n */\n environment?: string\n /** Override the version-retention cap. Default 30 versions per locale. */\n maxVersions?: number\n /**\n * Extra behaviors layered after the factory's own.\n *\n * This is how an app supplies `withOpLog()`, which is REQUIRED for the block\n * editor to open the document and which cannot live in this package —\n * `withOpLog` ships from `@murumets-ee/blocks/server`, and\n * `@murumets-ee/content` must not depend on `@murumets-ee/blocks` because\n * blocks depends back on content.\n *\n * Typed as field-contributing-nothing on purpose: the entity's inferred field\n * map is part of its public type, and a behavior that added fields here would\n * widen it silently. `withOpLog()` adds none, which its own docblock states.\n */\n behaviors?: readonly Behavior<Record<never, never>, string>[]\n /**\n * How the Page-settings tab and forms NAME this entity's fields. Merged per\n * field over the factory's English defaults, so a project adds only what it\n * translates — typically `labelKey` / `descriptionKey` into its OWN catalogue\n * (e.g. `{ label: { labelKey: 'SiteChrome.fields.label', descriptionKey:\n * 'SiteChrome.fields.labelHelp' } }`). Resolution is key → English label →\n * humanized name, never the raw field name (editor-curation D003).\n */\n admin?: {\n fieldOverrides?: EntityAdminConfig['fieldOverrides']\n }\n}\n\n/**\n * The English defaults every project gets. `label` is the only root field the\n * Page-settings tab offers (`rootFields` below), so it is the one an operator\n * reads; without this it humanized to \"Label\", untranslatable.\n */\nconst DEFAULT_FIELD_OVERRIDES: NonNullable<EntityAdminConfig['fieldOverrides']> = {\n label: {\n label: 'Name',\n description: 'Shown in the admin list and as the editor title. Visitors never see it.',\n },\n}\n\n/** Per-field shallow merge — a project's key never drops the default English fallback. */\nfunction mergeFieldOverrides(\n project: EntityAdminConfig['fieldOverrides'],\n): NonNullable<EntityAdminConfig['fieldOverrides']> {\n const merged: NonNullable<EntityAdminConfig['fieldOverrides']> = { ...DEFAULT_FIELD_OVERRIDES }\n for (const [name, override] of Object.entries(project ?? {})) {\n merged[name] = { ...merged[name], ...override }\n }\n return merged\n}\n\n/**\n * Build the `site_chrome` entity — the block documents rendered around every\n * page that does not override them.\n *\n * The return type is intentionally INFERRED (no annotation) so `'site_chrome'`\n * stays a string literal in the caller's `Entity<…, 'site_chrome', …>` view;\n * annotating it as bare `Entity` widens the name to `string`, which propagates\n * into the resolved-permissions union as a `${string}:view` template. The\n * compile-time guard at the foot of this file is what keeps that true.\n */\nexport function siteChromeEntity(options?: SiteChromeEntityOptions) {\n const viewRole = options?.viewRole ?? 'editor'\n const updateRole = options?.updateRole ?? 'editor'\n const adminRole = options?.adminRole ?? 'admin'\n const maxVersions = options?.maxVersions ?? 30\n\n return defineEntity({\n name: 'site_chrome',\n // NOT `'singleton'` — see the cardinality note in this file's header. One\n // row per placement, enforced by `placement`'s own unique constraint.\n kind: 'collection',\n fields: {\n // THE placement flag. Deliberately not translatable: where a document\n // appears is a property of the document, not of a translation of it.\n //\n // `unique: true` is load-bearing rather than decorative — it is the\n // whole cardinality rule. `indexed` is NOT set alongside it: the schema\n // builder skips indexing unique fields because the constraint already\n // emits its own btree.\n placement: field.select({\n options: CHROME_PLACEMENTS,\n required: true,\n unique: true,\n }),\n // The human name of the row, shown in the list and as the editor's\n // title. `titleField` below points at it explicitly so nothing has to\n // fall back to a heuristic — or to a `title` column this entity has\n // never had.\n label: field.text({ required: true }),\n // The ONE blockTree field, which is what makes this a canvas.\n //\n // `translatable: true`, load-bearing: without it the column lives on the\n // base row, so writing the English header overwrites the Estonian one\n // and every locale then serves English. The write succeeds; only a\n // per-locale READ reveals it.\n //\n // `translation: 'shared'` is not an option — the schema builder throws on\n // it for blockTree — so each locale's chrome is an independent tree. That\n // matches the editor, whose session id is `${id}:${locale}`.\n layout: field.blockTree({\n translatable: true,\n ...(options?.environment !== undefined && { environment: options.environment }),\n }),\n },\n behaviors: [\n auditable(),\n publishable(),\n versionable({ maxPerDoc: maxVersions }),\n ...(options?.behaviors ?? []),\n ],\n scope: 'global',\n access: {\n view: viewRole,\n // Restricts WHO may create; `placement`'s unique constraint restricts\n // HOW MANY. Both apply, and neither substitutes for the other.\n create: adminRole,\n update: updateRole,\n delete: adminRole,\n },\n admin: {\n icon: 'panel-top',\n description: 'Blocks rendered around every page that does not override them.',\n // `layout` is a blockTree and is excluded from forms by type, so this is\n // only about what the block editor's settings sheet offers. `placement`\n // is deliberately absent: moving a header to the footer slot by editing\n // a dropdown is not an operation anyone wants, and it would collide with\n // the row already holding that placement.\n rootFields: ['label'],\n fieldOverrides: mergeFieldOverrides(options?.admin?.fieldOverrides),\n titleField: 'label',\n group: 'Site',\n // See this file's header — the rows are bootstrapped, not hand-created.\n disableCreate: true,\n },\n })\n}\n\n/**\n * COMPILE-TIME guard for the inferred-return-type note above.\n *\n * It lives in `src/`, NOT in `tests/`, and that placement is the point: this\n * package's tsconfig includes `src` only, so a type-level assertion under\n * `tests/` is compiled by no gate at all — vitest transpiles without checking\n * types. A guard nobody compiles is worse than no guard, because it reads as\n * one.\n *\n * Negative-tested: annotating `siteChromeEntity`'s return as bare `Entity`\n * makes this line fail `tsc --noEmit` with TS2322.\n */\nconst _chromeNameStaysLiteral: 'site_chrome' = siteChromeEntity().name\nvoid _chromeNameStaysLiteral\n"],"mappings":"6IA0GA,MAAa,EAAoB,CAAC,SAAU,QAAQ,EAMvC,EAAqE,CAChF,OAAQ,cACR,OAAQ,aACV,EA0DM,EAA4E,CAChF,MAAO,CACL,MAAO,OACP,YAAa,yEACf,CACF,EAGA,SAAS,EACP,EACkD,CAClD,IAAM,EAA2D,CAAE,GAAG,CAAwB,EAC9F,IAAK,GAAM,CAAC,EAAM,KAAa,OAAO,QAAQ,GAAW,CAAC,CAAC,EACzD,EAAO,GAAQ,CAAE,GAAG,EAAO,GAAO,GAAG,CAAS,EAEhD,OAAO,CACT,CAYA,SAAgB,EAAiB,EAAmC,CAClE,IAAM,EAAW,GAAS,UAAY,SAChC,EAAa,GAAS,YAAc,SACpC,EAAY,GAAS,WAAa,QAClC,EAAc,GAAS,aAAe,GAE5C,OAAO,EAAa,CAClB,KAAM,cAGN,KAAM,aACN,OAAQ,CAQN,UAAW,EAAM,OAAO,CACtB,QAAS,EACT,SAAU,GACV,OAAQ,EACV,CAAC,EAKD,MAAO,EAAM,KAAK,CAAE,SAAU,EAAK,CAAC,EAWpC,OAAQ,EAAM,UAAU,CACtB,aAAc,GACd,GAAI,GAAS,cAAgB,IAAA,IAAa,CAAE,YAAa,EAAQ,WAAY,CAC/E,CAAC,CACH,EACA,UAAW,CACT,EAAU,EACV,EAAY,EACZ,EAAY,CAAE,UAAW,CAAY,CAAC,EACtC,GAAI,GAAS,WAAa,CAAC,CAC7B,EACA,MAAO,SACP,OAAQ,CACN,KAAM,EAGN,OAAQ,EACR,OAAQ,EACR,OAAQ,CACV,EACA,MAAO,CACL,KAAM,YACN,YAAa,iEAMb,WAAY,CAAC,OAAO,EACpB,eAAgB,EAAoB,GAAS,OAAO,cAAc,EAClE,WAAY,QACZ,MAAO,OAEP,cAAe,EACjB,CACF,CAAC,CACH,CAc+C,EAAiB,CAAC,CAAC"}
@@ -1,5 +1,5 @@
1
1
  import { t as VersionableFields } from "./versionable-ByZgfpp0.mjs";
2
- import { Behavior } from "@murumets-ee/entity";
2
+ import { Behavior, EntityAdminConfig } from "@murumets-ee/entity";
3
3
 
4
4
  //#region src/entities/site-chrome.d.ts
5
5
  /**
@@ -52,6 +52,17 @@ interface SiteChromeEntityOptions {
52
52
  * widen it silently. `withOpLog()` adds none, which its own docblock states.
53
53
  */
54
54
  behaviors?: readonly Behavior<Record<never, never>, string>[];
55
+ /**
56
+ * How the Page-settings tab and forms NAME this entity's fields. Merged per
57
+ * field over the factory's English defaults, so a project adds only what it
58
+ * translates — typically `labelKey` / `descriptionKey` into its OWN catalogue
59
+ * (e.g. `{ label: { labelKey: 'SiteChrome.fields.label', descriptionKey:
60
+ * 'SiteChrome.fields.labelHelp' } }`). Resolution is key → English label →
61
+ * humanized name, never the raw field name (editor-curation D003).
62
+ */
63
+ admin?: {
64
+ fieldOverrides?: EntityAdminConfig['fieldOverrides'];
65
+ };
55
66
  }
56
67
  /**
57
68
  * Build the `site_chrome` entity — the block documents rendered around every
@@ -83,4 +94,4 @@ declare function siteChromeEntity(options?: SiteChromeEntityOptions): import("@m
83
94
  }, "site_chrome", [Behavior<import("@murumets-ee/entity").AuditableFields, string>, Behavior<import("@murumets-ee/entity").PublishableFields, "publishable">, Behavior<VersionableFields, string>, ...Behavior<Record<never, never>, string>[]]>;
84
95
  //#endregion
85
96
  export { siteChromeEntity as a, SiteChromeEntityOptions as i, CHROME_PLACEMENT_LABELS as n, ChromePlacement as r, CHROME_PLACEMENTS as t };
86
- //# sourceMappingURL=site-chrome-CpnNlaw5.d.mts.map
97
+ //# sourceMappingURL=site-chrome-CUE16iIz.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"site-chrome-CpnNlaw5.d.mts","names":[],"sources":["../src/entities/site-chrome.ts"],"mappings":";;;;;;;;;;;cA0Ga,iBAAA;;KAGD,eAAA,WAA0B,iBAAiB;;cAG1C,uBAAA,EAAyB,QAAA,CAAS,MAAA,CAAO,eAAA;;UAMrC,uBAAA;;;;;;;;;;EAUf,QAAA;;EAEA,UAAA;;EAEA,SAAA;;;;;;EAMA,WAAA;;EAEA,WAAA;;;;;;;;;;;;;;EAcA,SAAA,YAAqB,QAAQ,CAAC,MAAA;AAAA;;;;;;;;;;;iBAahB,gBAAA,CAAiB,OAAA,GAAU,uBAAA,iCAAuB,MAAA;;sGAAA,iBAAA"}
1
+ {"version":3,"file":"site-chrome-CUE16iIz.d.mts","names":[],"sources":["../src/entities/site-chrome.ts"],"mappings":";;;;;;;;;;;cA0Ga,iBAAA;;KAGD,eAAA,WAA0B,iBAAiB;;cAG1C,uBAAA,EAAyB,QAAA,CAAS,MAAA,CAAO,eAAA;;UAMrC,uBAAA;;;;;;;;;;EAUf,QAAA;;EAEA,UAAA;;EAEA,SAAA;;;;;;EAMA,WAAA;;EAEA,WAAA;;;;;;;;;;;;;;EAcA,SAAA,YAAqB,QAAA,CAAS,MAAA;;;;;;;;;EAS9B,KAAA;IACE,cAAA,GAAiB,iBAAA;EAAA;AAAA;;;;;;;;;;;iBAqCL,gBAAA,CAAiB,OAAA,GAAU,uBAAA,iCAAuB,MAAA;;sGAAA,iBAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@murumets-ee/content",
3
- "version": "0.78.0",
3
+ "version": "0.80.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "exports": {
@@ -56,14 +56,14 @@
56
56
  "drizzle-orm": "^0.45.2",
57
57
  "server-only": "^0.0.1",
58
58
  "zod": "^3.24.1",
59
- "@murumets-ee/blocks": "0.78.0",
60
- "@murumets-ee/core": "0.78.0",
61
- "@murumets-ee/db": "0.78.0",
62
- "@murumets-ee/entity": "0.78.0",
63
- "@murumets-ee/i18n": "0.78.0",
64
- "@murumets-ee/logging": "0.78.0",
65
- "@murumets-ee/queue": "0.78.0",
66
- "@murumets-ee/settings": "0.78.0"
59
+ "@murumets-ee/blocks": "0.80.0",
60
+ "@murumets-ee/core": "0.80.0",
61
+ "@murumets-ee/db": "0.80.0",
62
+ "@murumets-ee/entity": "0.80.0",
63
+ "@murumets-ee/i18n": "0.80.0",
64
+ "@murumets-ee/logging": "0.80.0",
65
+ "@murumets-ee/queue": "0.80.0",
66
+ "@murumets-ee/settings": "0.80.0"
67
67
  },
68
68
  "devDependencies": {
69
69
  "@types/node": "^20.19.42",
@@ -1,2 +0,0 @@
1
- import{n as e}from"./versionable-Br_G5VGR.mjs";import{auditable as t,defineEntity as n,field as r,publishable as i}from"@murumets-ee/entity";const a=[`header`,`footer`],o={header:`Site header`,footer:`Site footer`};function s(o){let s=o?.viewRole??`editor`,c=o?.updateRole??`editor`,l=o?.adminRole??`admin`,u=o?.maxVersions??30;return n({name:`site_chrome`,kind:`collection`,fields:{placement:r.select({options:a,required:!0,unique:!0}),label:r.text({required:!0}),layout:r.blockTree({translatable:!0,...o?.environment!==void 0&&{environment:o.environment}})},behaviors:[t(),i(),e({maxPerDoc:u}),...o?.behaviors??[]],scope:`global`,access:{view:s,create:l,update:c,delete:l},admin:{icon:`panel-top`,description:`Blocks rendered around every page that does not override them.`,rootFields:[`label`],titleField:`label`,group:`Site`,disableCreate:!0}})}s().name;export{o as n,s as r,a as t};
2
- //# sourceMappingURL=site-chrome-BhlkbRuq.mjs.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"site-chrome-BhlkbRuq.mjs","names":[],"sources":["../src/entities/site-chrome.ts"],"sourcesContent":["/**\n * `site_chrome` — the block documents that hold the site chrome, one row per\n * PLACEMENT, so a header is edited on the same canvas as the page it sits\n * above instead of being typed into a JSON textarea.\n *\n * ## Placement, not type\n *\n * There is ONE entity. A chrome document says where it appears by carrying a\n * `placement` value, and `'header'` / `'footer'` are exactly the region keys\n * `GET /v1/_chrome` emits — so the provider maps placement to region by\n * identity rather than through a lookup table. That is the whole of the model.\n *\n * It replaces three entities (`site_header`, `site_footer`, and a single-row\n * site layout) and six hardcoded name lists, the most consequential of which\n * was not a list of names at all: `apps/admin/lib/chrome-documents.ts` carried\n * a module constant pairing each chrome entity with a\n * `position: 'before' | 'after'`. That was the placement flag, written in code,\n * in the app, where no operator could reach it and no plugin could extend it.\n *\n * ## What survives, and why it had to\n *\n * **A separate ROW per placement.** `publishable()` and `versionable()` are\n * per-row, so a header and a footer sharing one row could not be published or\n * rolled back independently. That constraint is real and this model honours\n * it — it is only the entity TYPE that collapses, never the row.\n *\n * **Peer-document editing.** The editor composes header, page and footer as\n * peers with per-document undo (`plan/site-chrome` D001 / D003). It never\n * keyed on the entity NAME to do that: `DocumentSlice.entity` is the only\n * discriminator and `pageDocumentId` is explicit state, precisely so that no\n * entity-name allowlist is needed. One name in place of two costs it nothing.\n *\n * ## Cardinality — why `kind: 'collection'` and a unique column\n *\n * `kind: 'singleton'` is enforced now (`plan/site-chrome` D011), and\n * `assertSingletonSlotFree` counts every live row of an entity — so an entity\n * holding a header row AND a footer row trips the gate on the second insert.\n * The kind and the collapse are mutually exclusive, which is why phase 09 lists\n * \"the singleton kind\" among the things that go.\n *\n * `placement`'s `unique: true` is what takes over the half that mattered: the\n * rule becomes one row per placement, enforced at the DATABASE for every\n * writer rather than in one client. On a `trashable()` entity — which this is,\n * by auto-enable, because it is publishable — the schema builder re-emits that\n * as a PARTIAL unique index (`WHERE \"deletedAt\" IS NULL`), so trashing a\n * header frees its placement for a replacement instead of wedging it.\n *\n * What is NOT carried over is the singleton DELETE rule (\"never leave zero\").\n * Deleting the only header is now possible, and the site falls back to the\n * delivery template's own markup — the same thing it does before the first\n * header is ever created. `trashable()` makes it recoverable.\n *\n * ## `admin.disableCreate` is correctness, not a workaround\n *\n * The block-editor create branch auto-POSTs `{ title: 'Untitled' }` and\n * nothing else, so any entity on that branch with a second required field\n * would reject on `/new`. `placement` is exactly such a field.\n *\n * Suppressing the affordance is also the honest answer: a third chrome row\n * would render NOWHERE, because the delivery template has exactly two chrome\n * slots. A new placement is a code change on both sides — a `placement` option\n * here and a slot in `BaseLayout.astro` — and that coupling is what keeps the\n * enum from accumulating values nothing renders.\n *\n * The two rows a site needs are created by the `content:site-chrome` seeder,\n * which `lumi migrate` runs automatically as a `bootstrap` seeder. Nobody has\n * to press anything.\n *\n * ## Wiring\n *\n * @example\n * ```ts\n * // apps/<app>/entities/site-chrome.ts\n * import { withOpLog } from '@murumets-ee/blocks/server'\n * import { siteChromeEntity } from '@murumets-ee/content/entities'\n *\n * export const SiteChrome = siteChromeEntity({ environment: 'page' })\n * .with(withOpLog()) // see below — the app layers this on\n * ```\n *\n * `withOpLog()` is deliberately NOT in the factory: it ships from\n * `@murumets-ee/blocks/server`, and `@murumets-ee/content` must not depend on\n * `@murumets-ee/blocks` (blocks depends back on content). The app layers it at\n * config time. **Without it the block editor 404s** — `editor-state` refuses a\n * document whose entity has no op-log behavior — so an app that registers this\n * and skips it gets a chrome document it cannot open.\n *\n * Behaviors the factory does apply:\n *\n * - `auditable()` — created/updated stamps\n * - `publishable()` — a header is drafted and published like any document,\n * which is the point of it being one\n * - `versionable({ maxPerDoc })` — per-locale history\n */\n\nimport type { Behavior } from '@murumets-ee/entity'\nimport { auditable, defineEntity, field, publishable } from '@murumets-ee/entity'\nimport { versionable } from '../behaviors/versionable.js'\n\n/**\n * Where a chrome document appears on every page that does not override it.\n *\n * These strings ARE the `/v1/_chrome` region keys and the delivery template's\n * slot names. Adding one means adding a slot to `BaseLayout.astro` in the same\n * change, or the value names a region nothing renders.\n */\nexport const CHROME_PLACEMENTS = ['header', 'footer'] as const\n\n/** One placement. Also a region name on the wire — same string, by design. */\nexport type ChromePlacement = (typeof CHROME_PLACEMENTS)[number]\n\n/** The default label a bootstrap row gets, per placement. */\nexport const CHROME_PLACEMENT_LABELS: Readonly<Record<ChromePlacement, string>> = {\n header: 'Site header',\n footer: 'Site footer',\n}\n\n/** Optional overrides apps can apply when calling the factory. */\nexport interface SiteChromeEntityOptions {\n /**\n * Roles allowed to read the document. Default `'editor'`.\n *\n * ADVISORY entity-level metadata that does NOT govern the public content API\n * — that path resolves a role and asks `buildPermissionChecker`.\n * `site_chrome:view` is seeded onto `public` by\n * `buildInitialRoleDefinitions()` so a fresh install serves its chrome;\n * changing this option does not change that.\n */\n viewRole?: string\n /** Roles allowed to edit a document's blocks. Default `'editor'`. */\n updateRole?: string\n /** Roles allowed to create/delete a chrome row. Default `'admin'`. */\n adminRole?: string\n /**\n * The block environment the editor offers in this document's canvas. Pass\n * the same value the site's page entity uses so a header can be built from\n * the same vocabulary. Omitted means \"no environment restriction\".\n */\n environment?: string\n /** Override the version-retention cap. Default 30 versions per locale. */\n maxVersions?: number\n /**\n * Extra behaviors layered after the factory's own.\n *\n * This is how an app supplies `withOpLog()`, which is REQUIRED for the block\n * editor to open the document and which cannot live in this package —\n * `withOpLog` ships from `@murumets-ee/blocks/server`, and\n * `@murumets-ee/content` must not depend on `@murumets-ee/blocks` because\n * blocks depends back on content.\n *\n * Typed as field-contributing-nothing on purpose: the entity's inferred field\n * map is part of its public type, and a behavior that added fields here would\n * widen it silently. `withOpLog()` adds none, which its own docblock states.\n */\n behaviors?: readonly Behavior<Record<never, never>, string>[]\n}\n\n/**\n * Build the `site_chrome` entity — the block documents rendered around every\n * page that does not override them.\n *\n * The return type is intentionally INFERRED (no annotation) so `'site_chrome'`\n * stays a string literal in the caller's `Entity<…, 'site_chrome', …>` view;\n * annotating it as bare `Entity` widens the name to `string`, which propagates\n * into the resolved-permissions union as a `${string}:view` template. The\n * compile-time guard at the foot of this file is what keeps that true.\n */\nexport function siteChromeEntity(options?: SiteChromeEntityOptions) {\n const viewRole = options?.viewRole ?? 'editor'\n const updateRole = options?.updateRole ?? 'editor'\n const adminRole = options?.adminRole ?? 'admin'\n const maxVersions = options?.maxVersions ?? 30\n\n return defineEntity({\n name: 'site_chrome',\n // NOT `'singleton'` — see the cardinality note in this file's header. One\n // row per placement, enforced by `placement`'s own unique constraint.\n kind: 'collection',\n fields: {\n // THE placement flag. Deliberately not translatable: where a document\n // appears is a property of the document, not of a translation of it.\n //\n // `unique: true` is load-bearing rather than decorative — it is the\n // whole cardinality rule. `indexed` is NOT set alongside it: the schema\n // builder skips indexing unique fields because the constraint already\n // emits its own btree.\n placement: field.select({\n options: CHROME_PLACEMENTS,\n required: true,\n unique: true,\n }),\n // The human name of the row, shown in the list and as the editor's\n // title. `titleField` below points at it explicitly so nothing has to\n // fall back to a heuristic — or to a `title` column this entity has\n // never had.\n label: field.text({ required: true }),\n // The ONE blockTree field, which is what makes this a canvas.\n //\n // `translatable: true`, load-bearing: without it the column lives on the\n // base row, so writing the English header overwrites the Estonian one\n // and every locale then serves English. The write succeeds; only a\n // per-locale READ reveals it.\n //\n // `translation: 'shared'` is not an option — the schema builder throws on\n // it for blockTree — so each locale's chrome is an independent tree. That\n // matches the editor, whose session id is `${id}:${locale}`.\n layout: field.blockTree({\n translatable: true,\n ...(options?.environment !== undefined && { environment: options.environment }),\n }),\n },\n behaviors: [\n auditable(),\n publishable(),\n versionable({ maxPerDoc: maxVersions }),\n ...(options?.behaviors ?? []),\n ],\n scope: 'global',\n access: {\n view: viewRole,\n // Restricts WHO may create; `placement`'s unique constraint restricts\n // HOW MANY. Both apply, and neither substitutes for the other.\n create: adminRole,\n update: updateRole,\n delete: adminRole,\n },\n admin: {\n icon: 'panel-top',\n description: 'Blocks rendered around every page that does not override them.',\n // `layout` is a blockTree and is excluded from forms by type, so this is\n // only about what the block editor's settings sheet offers. `placement`\n // is deliberately absent: moving a header to the footer slot by editing\n // a dropdown is not an operation anyone wants, and it would collide with\n // the row already holding that placement.\n rootFields: ['label'],\n titleField: 'label',\n group: 'Site',\n // See this file's header — the rows are bootstrapped, not hand-created.\n disableCreate: true,\n },\n })\n}\n\n/**\n * COMPILE-TIME guard for the inferred-return-type note above.\n *\n * It lives in `src/`, NOT in `tests/`, and that placement is the point: this\n * package's tsconfig includes `src` only, so a type-level assertion under\n * `tests/` is compiled by no gate at all — vitest transpiles without checking\n * types. A guard nobody compiles is worse than no guard, because it reads as\n * one.\n *\n * Negative-tested: annotating `siteChromeEntity`'s return as bare `Entity`\n * makes this line fail `tsc --noEmit` with TS2322.\n */\nconst _chromeNameStaysLiteral: 'site_chrome' = siteChromeEntity().name\nvoid _chromeNameStaysLiteral\n"],"mappings":"6IA0GA,MAAa,EAAoB,CAAC,SAAU,QAAQ,EAMvC,EAAqE,CAChF,OAAQ,cACR,OAAQ,aACV,EAoDA,SAAgB,EAAiB,EAAmC,CAClE,IAAM,EAAW,GAAS,UAAY,SAChC,EAAa,GAAS,YAAc,SACpC,EAAY,GAAS,WAAa,QAClC,EAAc,GAAS,aAAe,GAE5C,OAAO,EAAa,CAClB,KAAM,cAGN,KAAM,aACN,OAAQ,CAQN,UAAW,EAAM,OAAO,CACtB,QAAS,EACT,SAAU,GACV,OAAQ,EACV,CAAC,EAKD,MAAO,EAAM,KAAK,CAAE,SAAU,EAAK,CAAC,EAWpC,OAAQ,EAAM,UAAU,CACtB,aAAc,GACd,GAAI,GAAS,cAAgB,IAAA,IAAa,CAAE,YAAa,EAAQ,WAAY,CAC/E,CAAC,CACH,EACA,UAAW,CACT,EAAU,EACV,EAAY,EACZ,EAAY,CAAE,UAAW,CAAY,CAAC,EACtC,GAAI,GAAS,WAAa,CAAC,CAC7B,EACA,MAAO,SACP,OAAQ,CACN,KAAM,EAGN,OAAQ,EACR,OAAQ,EACR,OAAQ,CACV,EACA,MAAO,CACL,KAAM,YACN,YAAa,iEAMb,WAAY,CAAC,OAAO,EACpB,WAAY,QACZ,MAAO,OAEP,cAAe,EACjB,CACF,CAAC,CACH,CAc+C,EAAiB,CAAC,CAAC"}