@aglyn/tenant-runtime 1.0.0-beta.145 → 1.0.0-beta.147

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/tenant-runtime",
3
- "version": "1.0.0-beta.145",
3
+ "version": "1.0.0-beta.147",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,8 +25,8 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.0.0-beta.145",
29
- "@aglyn/tenant-data-admin": "1.0.0-beta.145",
28
+ "@aglyn/aglyn": "1.0.0-beta.147",
29
+ "@aglyn/tenant-data-admin": "1.0.0-beta.147",
30
30
  "@swc/helpers": "0.5.23"
31
31
  },
32
32
  "peerDependencies": {
@@ -85,7 +85,7 @@ import { collectSocialImageFacts } from "./social-image-facts.js";
85
85
  * falls through to the designed built-in fallback.
86
86
  */ export async function composeCollectionTemplatePage(options) {
87
87
  var _templateRes_screen_seo;
88
- var _templateRes_screen_seo1, _options_host_seo, _options_host, _content_pagination;
88
+ var _templateRes_screen_seo1, _options_host_seo, _options_host, _content_pagination, _content_pagination1;
89
89
  const { hostId, content } = options;
90
90
  const collection = content.collection;
91
91
  if (!collection) return null;
@@ -143,6 +143,8 @@ import { collectSocialImageFacts } from "./social-image-facts.js";
143
143
  categorySlug: content.category.slug
144
144
  } : {}, content.entriesReachedBound ? {
145
145
  entriesReachedBound: true
146
+ } : {}, ((_content_pagination1 = content.pagination) == null ? void 0 : _content_pagination1.windowStart) ? {
147
+ windowStart: content.pagination.windowStart
146
148
  } : {})
147
149
  });
148
150
  if (!nodes) return null;
@@ -188,7 +190,7 @@ import { collectSocialImageFacts } from "./social-image-facts.js";
188
190
  const collection = content.collection;
189
191
  if (!collection) return null;
190
192
  try {
191
- var _content_entry, _host_seo, _content_pagination;
193
+ var _content_entry, _host_seo, _content_pagination, _content_pagination1;
192
194
  // The layout for a page the platform composed rather than the author
193
195
  // (AGL-2513). Still the home screen's layout by default — the rule this
194
196
  // branch has always followed — but the host can now name a different one,
@@ -241,6 +243,8 @@ import { collectSocialImageFacts } from "./social-image-facts.js";
241
243
  categorySlug: content.category.slug
242
244
  } : {}, content.entriesReachedBound ? {
243
245
  entriesReachedBound: true
246
+ } : {}, ((_content_pagination1 = content.pagination) == null ? void 0 : _content_pagination1.windowStart) ? {
247
+ windowStart: content.pagination.windowStart
244
248
  } : {})
245
249
  });
246
250
  return nodes ? _extends({
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/tenant/runtime/src/lib/compose-collection-page.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport * as Aglyn from '@aglyn/aglyn/server'\nimport buildCollectionFallbackNodes from './collection-fallback-nodes'\nimport composeScreenNodes, {\n composeNodesWithChrome,\n} from './compose-screen-nodes'\nimport { resolveBuiltInPageLayoutId } from './built-in-page-layout'\nimport type { CollectionContent } from './get-collection-content'\nimport getScreen from './get-screen'\nimport { collectSocialImageFacts } from './social-image-facts'\n\ntype CollectionDoc = NonNullable<CollectionContent['collection']>\n\n/**\n * Which template screen a collection route renders through (AGL-551):\n * `/{collection}` uses `listScreenId`, `/{collection}/{entry}` uses\n * `entryScreenId` — falling back to the legacy AGL-105 `templateScreenId`\n * so existing blogs keep rendering. `undefined` means no template is set\n * and the designed built-in fallback applies.\n */\nexport function resolveCollectionTemplateScreenId(\n collection: Pick<\n CollectionDoc,\n 'listScreenId' | 'entryScreenId' | 'templateScreenId'\n >,\n kind: 'list' | 'entry',\n): string | undefined {\n if (kind === 'list') return collection.listScreenId || undefined\n return collection.entryScreenId || collection.templateScreenId || undefined\n}\n\n/**\n * Page-level `{{collection.*}}` tokens for template screens (AGL-551), plus\n * the routed category (AGL-1321) so a list template can name what it is\n * showing — \"Guides\" rather than a heading that says \"Blog\" on every filtered\n * URL. Both category tokens resolve to the empty string on the unfiltered\n * listing, which is what makes them safe to bind unconditionally.\n *\n * `{{pagination.*}}` follows the same design (AGL-1386). One static list\n * screen serves the bare listing, every `/page/{n}` and every\n * `/category/{slug}`, so a hand-built pager on it renders identically on all\n * of them: \"Older →\" on a category that has one page, pointing at a URL that\n * dropped the filter. The URLs come from `collectionPaginationLinks`, which\n * builds them through the shared listing-URL builder (so they carry the\n * category) and resolves the EDGES TO THE EMPTY STRING — no previous page,\n * no `prevUrl`. There is no runtime conditional to hide a link with (the\n * `condition` field on nodes is editor-side field visibility, not a render\n * gate), so the empty string is what makes an unconditional binding correct\n * on every route: a link whose href does not resolve renders as an inert\n * placeholder of the same element (AGL-1268/1357).\n *\n * This SURFACES what the platform already computes — the built-in fallback\n * pager reads the same function, so the two cannot drift.\n */\nexport function collectionTokens(\n collection: Pick<CollectionDoc, 'displayName' | 'slug'>,\n category?: CollectionContent['category'],\n pagination?: CollectionContent['pagination'],\n): Record<string, string> {\n // An unpaginated listing is page 1 of 1 — both URLs empty, which reads as\n // the honest \"nowhere to page to\" rather than a broken link.\n const pager = Aglyn.collectionPaginationLinks({\n collectionSlug: collection.slug,\n ...(category?.slug ? { categorySlug: category.slug } : {}),\n page: pagination?.page,\n totalPages: pagination?.totalPages,\n })\n return {\n 'collection.name': collection.displayName,\n 'collection.slug': collection.slug,\n 'collection.category': category?.name ?? '',\n 'collection.categorySlug': category?.slug ?? '',\n 'pagination.page': String(pager.page),\n 'pagination.totalPages': String(pager.totalPages),\n 'pagination.prevUrl': pager.prevUrl,\n 'pagination.nextUrl': pager.nextUrl,\n }\n}\n\nexport interface ComposedCollectionPage {\n /** Template screen doc with the entry/collection SEO merged in. */\n screen: Record<string, any>\n nodes: Record<string, any>\n /**\n * The current pair of each asset the head's card may name (AGL-2850): the\n * entry's cover, the template's image, the site default. Read in the\n * composition's batch, and absent when it answered for none of them.\n */\n socialImageFacts?: Aglyn.SocialImageAssetFacts\n}\n\n/**\n * Renders a collection route through its designated template screen\n * (AGL-551), the same mechanism as commerce PDP/collection templates: the\n * screen composes through the NORMAL published pipeline — theme, shared\n * layout, reusable components — with `{{entry.*}}`/`{{collection.*}}`\n * tokens substituted and Collection entries blocks expanded. Returns null\n * when no template is designated (or it fails to compose) so the caller\n * falls through to the designed built-in fallback.\n */\nexport async function composeCollectionTemplatePage(options: {\n hostId: string\n content: CollectionContent\n /**\n * The site, whose default card the head falls back to after the entry's\n * cover and the template's image (AGL-2850). Its document is read in this\n * page's batch with theirs.\n */\n host?: Aglyn.AglynHost | null\n}): Promise<ComposedCollectionPage | null> {\n const { hostId, content } = options\n const collection = content.collection\n if (!collection) return null\n const kind = content.entry ? 'entry' : 'list'\n const screenId = resolveCollectionTemplateScreenId(collection, kind)\n if (!screenId) return null\n\n // The one read that WANTS a template document (AGL-1400): this is the\n // composition it exists for, with `{{entry.*}}` substituted against the\n // routed entry. Every path-resolving caller leaves the flag off and gets the\n // 404 a template deserves at an address of its own.\n const templateRes = await getScreen({ hostId, screenId, allowTemplate: true })\n if (!templateRes.screen) return null\n\n const entry = content.entry\n const tokens = entry\n ? {\n ...collectionTokens(collection),\n // Category names resolve against the collection's taxonomy\n // (AGL-582): `categoryId` lookup first, legacy string fallback.\n ...Aglyn.collectionEntryTokens(\n entry,\n collection.slug,\n collection.categories,\n ),\n }\n : collectionTokens(collection, content.category, content.pagination)\n // The head's card on this page, in its order: the entry's cover, the\n // template's own image, then the site default (AGL-2850).\n const card = collectSocialImageFacts([\n entry?.coverImage,\n (templateRes.screen as any).seo?.image,\n options.host?.seo?.image,\n ])\n const nodes = await composeScreenNodes({\n hostId,\n screenId,\n screen: templateRes.screen,\n socialImages: card.socialImages,\n // The site the template renders for, so its host variables — and its\n // layout's — fill in as they do on every other page (AGL-2883).\n host: options.host,\n tokens,\n // List pages hand their already-fetched entries to the Collection\n // entries block; entry pages carry the routed entry (AGL-582, Related\n // posts) and let blocks fetch entry lists on demand (e.g. a \"More\n // posts\" section on the article template). The category taxonomy\n // rides along so `{{entry.category}}` resolves inside the blocks.\n collection: entry\n ? { slug: collection.slug, entry, categories: collection.categories }\n : {\n slug: collection.slug,\n // Already filtered to the routed category (AGL-1321) — the block\n // repeats what the ROUTE resolved, so a designer-pinned\n // `filterCategory` on the block narrows it further rather than\n // fighting it.\n entries: content.entries,\n categories: collection.categories,\n ...(content.pagination?.page\n ? { page: content.pagination.page }\n : {}),\n ...(content.category ? { categorySlug: content.category.slug } : {}),\n // The read's own bound (AGL-1516), which has to travel WITH the\n // filtered entries above: once `content.entries` has been narrowed\n // to a category, nothing downstream can tell a complete read from a\n // truncated one by counting it.\n ...(content.entriesReachedBound\n ? { entriesReachedBound: true }\n : {}),\n },\n })\n if (!nodes) return null\n\n const screenSeo = (templateRes.screen as any).seo ?? {}\n const seo = entry\n ? // Entry metadata drives the head (AGL-117 merge; AGL-582 overrides).\n {\n ...screenSeo,\n title: entry.seoTitle || entry.title,\n description: entry.seoDescription || entry.excerpt || undefined,\n // The image and its companions move as ONE group (AGL-2417). The\n // spread above carries the SCREEN's `imageWidth`/`imageHeight`/\n // `imageAlt`, so an entry that supplies its own cover was describing\n // it with the screen default's size and — once alts existed — with\n // the screen default's DESCRIPTION: a sentence about a picture this\n // card does not show, delivered to the reader least able to check.\n // When the entry wins, its own companions win with it.\n ...(entry.coverImage\n ? {\n image: entry.coverImage,\n imageWidth: undefined,\n imageHeight: undefined,\n imageAlt: entry.coverImageAlt || undefined,\n }\n : { image: screenSeo.image || undefined }),\n }\n : // A LIST passes its screen's own SEO through UNTOUCHED (AGL-1345).\n //\n // This used to default `title` to `collection.displayName`, which reads\n // like a harmless fallback and is not: it made an authored title\n // indistinguishable from a generated one by the time the head was built.\n // The title rule (AGL-1341) turns on exactly that distinction — an\n // authored title renders VERBATIM, a name joins the site title — so a\n // consumer reading this could only choose between dropping the site\n // title off every untitled list (\"Changelog\") or ignoring the author's\n // title on every titled one (\"Changelog – Acme\" over the sentence they\n // wrote). The collection name is still the fallback; it just belongs to\n // the title resolver, as the page's `name`, alongside every other\n // surface's fallback rather than baked into stored SEO here.\n screenSeo\n return {\n screen: { ...(templateRes.screen as any), seo },\n nodes,\n ...card.collected(),\n }\n}\n\n/**\n * The designed built-in rendering (AGL-551): when a collection has no\n * template screen, its routes still compose through the site's theme and\n * the host's default shared layout (the home screen's layout) instead of\n * the old unthemed article. Fail-open — any error returns null and the\n * caller keeps the legacy plain rendering.\n */\nexport async function composeCollectionFallbackPage(options: {\n hostId: string\n host: Aglyn.AglynHost\n content: CollectionContent\n}): Promise<Omit<ComposedCollectionPage, 'screen'> | null> {\n const { hostId, host, content } = options\n const collection = content.collection\n if (!collection) return null\n try {\n // The layout for a page the platform composed rather than the author\n // (AGL-2513). Still the home screen's layout by default — the rule this\n // branch has always followed — but the host can now name a different one,\n // and site search reads the same setting so the two built-in pages of a\n // site cannot end up in different chrome.\n const layoutId = await resolveBuiltInPageLayoutId({ hostId, host })\n const screenNodes = buildCollectionFallbackNodes({\n collection,\n entries: content.entries,\n entry: content.entry,\n pagination: content.pagination,\n category: content.category,\n // The cover resolves through `resolveMediaSrc` (AGL-1407), and an\n // org-scoped reference has to name the site asking or a site-restricted\n // asset will not serve.\n hostId,\n })\n // The head's card on a page with no template: the entry's cover, then the\n // site default (AGL-2850).\n const card = collectSocialImageFacts([\n content.entry?.coverImage,\n host?.seo?.image,\n ])\n const nodes = await composeNodesWithChrome({\n hostId,\n layoutId,\n screenNodes,\n socialImages: card.socialImages,\n // The layout's host variables fill in from this site (AGL-2883).\n host,\n // Entry routes resolve with an EMPTY entries list (the loader only\n // fetched the one entry), so hand the routed entry over and let the\n // Related posts block fetch the list on demand (AGL-582); list\n // routes keep their already-fetched entries. Categories ride along\n // for `categoryId` → name resolution.\n collection: content.entry\n ? {\n slug: collection.slug,\n entry: content.entry,\n categories: collection.categories,\n }\n : {\n slug: collection.slug,\n entries: content.entries,\n categories: collection.categories,\n ...(content.pagination?.page\n ? { page: content.pagination.page }\n : {}),\n ...(content.category\n ? { categorySlug: content.category.slug }\n : {}),\n // Same fact, same reason as the template path above (AGL-1516).\n ...(content.entriesReachedBound\n ? { entriesReachedBound: true }\n : {}),\n },\n })\n return nodes ? { nodes, ...card.collected() } : null\n } catch (error) {\n console.error(error)\n return null\n }\n}\n\nexport default composeCollectionTemplatePage\n"],"names":["Aglyn","buildCollectionFallbackNodes","composeScreenNodes","composeNodesWithChrome","resolveBuiltInPageLayoutId","getScreen","collectSocialImageFacts","resolveCollectionTemplateScreenId","collection","kind","listScreenId","undefined","entryScreenId","templateScreenId","collectionTokens","category","pagination","pager","collectionPaginationLinks","collectionSlug","slug","categorySlug","page","totalPages","displayName","name","String","prevUrl","nextUrl","composeCollectionTemplatePage","options","content","hostId","entry","screenId","templateRes","allowTemplate","screen","tokens","collectionEntryTokens","categories","card","coverImage","seo","image","host","nodes","socialImages","entries","entriesReachedBound","screenSeo","title","seoTitle","description","seoDescription","excerpt","imageWidth","imageHeight","imageAlt","coverImageAlt","collected","composeCollectionFallbackPage","layoutId","screenNodes","error","console"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/tenant/runtime/src/lib/compose-collection-page.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport * as Aglyn from '@aglyn/aglyn/server'\nimport buildCollectionFallbackNodes from './collection-fallback-nodes'\nimport composeScreenNodes, {\n composeNodesWithChrome,\n} from './compose-screen-nodes'\nimport { resolveBuiltInPageLayoutId } from './built-in-page-layout'\nimport type { CollectionContent } from './get-collection-content'\nimport getScreen from './get-screen'\nimport { collectSocialImageFacts } from './social-image-facts'\n\ntype CollectionDoc = NonNullable<CollectionContent['collection']>\n\n/**\n * Which template screen a collection route renders through (AGL-551):\n * `/{collection}` uses `listScreenId`, `/{collection}/{entry}` uses\n * `entryScreenId` — falling back to the legacy AGL-105 `templateScreenId`\n * so existing blogs keep rendering. `undefined` means no template is set\n * and the designed built-in fallback applies.\n */\nexport function resolveCollectionTemplateScreenId(\n collection: Pick<\n CollectionDoc,\n 'listScreenId' | 'entryScreenId' | 'templateScreenId'\n >,\n kind: 'list' | 'entry',\n): string | undefined {\n if (kind === 'list') return collection.listScreenId || undefined\n return collection.entryScreenId || collection.templateScreenId || undefined\n}\n\n/**\n * Page-level `{{collection.*}}` tokens for template screens (AGL-551), plus\n * the routed category (AGL-1321) so a list template can name what it is\n * showing — \"Guides\" rather than a heading that says \"Blog\" on every filtered\n * URL. Both category tokens resolve to the empty string on the unfiltered\n * listing, which is what makes them safe to bind unconditionally.\n *\n * `{{pagination.*}}` follows the same design (AGL-1386). One static list\n * screen serves the bare listing, every `/page/{n}` and every\n * `/category/{slug}`, so a hand-built pager on it renders identically on all\n * of them: \"Older →\" on a category that has one page, pointing at a URL that\n * dropped the filter. The URLs come from `collectionPaginationLinks`, which\n * builds them through the shared listing-URL builder (so they carry the\n * category) and resolves the EDGES TO THE EMPTY STRING — no previous page,\n * no `prevUrl`. There is no runtime conditional to hide a link with (the\n * `condition` field on nodes is editor-side field visibility, not a render\n * gate), so the empty string is what makes an unconditional binding correct\n * on every route: a link whose href does not resolve renders as an inert\n * placeholder of the same element (AGL-1268/1357).\n *\n * This SURFACES what the platform already computes — the built-in fallback\n * pager reads the same function, so the two cannot drift.\n */\nexport function collectionTokens(\n collection: Pick<CollectionDoc, 'displayName' | 'slug'>,\n category?: CollectionContent['category'],\n pagination?: CollectionContent['pagination'],\n): Record<string, string> {\n // An unpaginated listing is page 1 of 1 — both URLs empty, which reads as\n // the honest \"nowhere to page to\" rather than a broken link.\n const pager = Aglyn.collectionPaginationLinks({\n collectionSlug: collection.slug,\n ...(category?.slug ? { categorySlug: category.slug } : {}),\n page: pagination?.page,\n totalPages: pagination?.totalPages,\n })\n return {\n 'collection.name': collection.displayName,\n 'collection.slug': collection.slug,\n 'collection.category': category?.name ?? '',\n 'collection.categorySlug': category?.slug ?? '',\n 'pagination.page': String(pager.page),\n 'pagination.totalPages': String(pager.totalPages),\n 'pagination.prevUrl': pager.prevUrl,\n 'pagination.nextUrl': pager.nextUrl,\n }\n}\n\nexport interface ComposedCollectionPage {\n /** Template screen doc with the entry/collection SEO merged in. */\n screen: Record<string, any>\n nodes: Record<string, any>\n /**\n * The current pair of each asset the head's card may name (AGL-2850): the\n * entry's cover, the template's image, the site default. Read in the\n * composition's batch, and absent when it answered for none of them.\n */\n socialImageFacts?: Aglyn.SocialImageAssetFacts\n}\n\n/**\n * Renders a collection route through its designated template screen\n * (AGL-551), the same mechanism as commerce PDP/collection templates: the\n * screen composes through the NORMAL published pipeline — theme, shared\n * layout, reusable components — with `{{entry.*}}`/`{{collection.*}}`\n * tokens substituted and Collection entries blocks expanded. Returns null\n * when no template is designated (or it fails to compose) so the caller\n * falls through to the designed built-in fallback.\n */\nexport async function composeCollectionTemplatePage(options: {\n hostId: string\n content: CollectionContent\n /**\n * The site, whose default card the head falls back to after the entry's\n * cover and the template's image (AGL-2850). Its document is read in this\n * page's batch with theirs.\n */\n host?: Aglyn.AglynHost | null\n}): Promise<ComposedCollectionPage | null> {\n const { hostId, content } = options\n const collection = content.collection\n if (!collection) return null\n const kind = content.entry ? 'entry' : 'list'\n const screenId = resolveCollectionTemplateScreenId(collection, kind)\n if (!screenId) return null\n\n // The one read that WANTS a template document (AGL-1400): this is the\n // composition it exists for, with `{{entry.*}}` substituted against the\n // routed entry. Every path-resolving caller leaves the flag off and gets the\n // 404 a template deserves at an address of its own.\n const templateRes = await getScreen({ hostId, screenId, allowTemplate: true })\n if (!templateRes.screen) return null\n\n const entry = content.entry\n const tokens = entry\n ? {\n ...collectionTokens(collection),\n // Category names resolve against the collection's taxonomy\n // (AGL-582): `categoryId` lookup first, legacy string fallback.\n ...Aglyn.collectionEntryTokens(\n entry,\n collection.slug,\n collection.categories,\n ),\n }\n : collectionTokens(collection, content.category, content.pagination)\n // The head's card on this page, in its order: the entry's cover, the\n // template's own image, then the site default (AGL-2850).\n const card = collectSocialImageFacts([\n entry?.coverImage,\n (templateRes.screen as any).seo?.image,\n options.host?.seo?.image,\n ])\n const nodes = await composeScreenNodes({\n hostId,\n screenId,\n screen: templateRes.screen,\n socialImages: card.socialImages,\n // The site the template renders for, so its host variables — and its\n // layout's — fill in as they do on every other page (AGL-2883).\n host: options.host,\n tokens,\n // List pages hand their already-fetched entries to the Collection\n // entries block; entry pages carry the routed entry (AGL-582, Related\n // posts) and let blocks fetch entry lists on demand (e.g. a \"More\n // posts\" section on the article template). The category taxonomy\n // rides along so `{{entry.category}}` resolves inside the blocks.\n collection: entry\n ? { slug: collection.slug, entry, categories: collection.categories }\n : {\n slug: collection.slug,\n // Already filtered to the routed category (AGL-1321) — the block\n // repeats what the ROUTE resolved, so a designer-pinned\n // `filterCategory` on the block narrows it further rather than\n // fighting it.\n entries: content.entries,\n categories: collection.categories,\n ...(content.pagination?.page\n ? { page: content.pagination.page }\n : {}),\n ...(content.category ? { categorySlug: content.category.slug } : {}),\n // The read's own bound (AGL-1516), which has to travel WITH the\n // filtered entries above: once `content.entries` has been narrowed\n // to a category, nothing downstream can tell a complete read from a\n // truncated one by counting it.\n ...(content.entriesReachedBound\n ? { entriesReachedBound: true }\n : {}),\n // Where those entries start (AGL-3213): a listing page past the\n // cached read's bound holds its own window, not the collection\n // from the top, and the block has to window from the same origin.\n ...(content.pagination?.windowStart\n ? { windowStart: content.pagination.windowStart }\n : {}),\n },\n })\n if (!nodes) return null\n\n const screenSeo = (templateRes.screen as any).seo ?? {}\n const seo = entry\n ? // Entry metadata drives the head (AGL-117 merge; AGL-582 overrides).\n {\n ...screenSeo,\n title: entry.seoTitle || entry.title,\n description: entry.seoDescription || entry.excerpt || undefined,\n // The image and its companions move as ONE group (AGL-2417). The\n // spread above carries the SCREEN's `imageWidth`/`imageHeight`/\n // `imageAlt`, so an entry that supplies its own cover was describing\n // it with the screen default's size and — once alts existed — with\n // the screen default's DESCRIPTION: a sentence about a picture this\n // card does not show, delivered to the reader least able to check.\n // When the entry wins, its own companions win with it.\n ...(entry.coverImage\n ? {\n image: entry.coverImage,\n imageWidth: undefined,\n imageHeight: undefined,\n imageAlt: entry.coverImageAlt || undefined,\n }\n : { image: screenSeo.image || undefined }),\n }\n : // A LIST passes its screen's own SEO through UNTOUCHED (AGL-1345).\n //\n // This used to default `title` to `collection.displayName`, which reads\n // like a harmless fallback and is not: it made an authored title\n // indistinguishable from a generated one by the time the head was built.\n // The title rule (AGL-1341) turns on exactly that distinction — an\n // authored title renders VERBATIM, a name joins the site title — so a\n // consumer reading this could only choose between dropping the site\n // title off every untitled list (\"Changelog\") or ignoring the author's\n // title on every titled one (\"Changelog – Acme\" over the sentence they\n // wrote). The collection name is still the fallback; it just belongs to\n // the title resolver, as the page's `name`, alongside every other\n // surface's fallback rather than baked into stored SEO here.\n screenSeo\n return {\n screen: { ...(templateRes.screen as any), seo },\n nodes,\n ...card.collected(),\n }\n}\n\n/**\n * The designed built-in rendering (AGL-551): when a collection has no\n * template screen, its routes still compose through the site's theme and\n * the host's default shared layout (the home screen's layout) instead of\n * the old unthemed article. Fail-open — any error returns null and the\n * caller keeps the legacy plain rendering.\n */\nexport async function composeCollectionFallbackPage(options: {\n hostId: string\n host: Aglyn.AglynHost\n content: CollectionContent\n}): Promise<Omit<ComposedCollectionPage, 'screen'> | null> {\n const { hostId, host, content } = options\n const collection = content.collection\n if (!collection) return null\n try {\n // The layout for a page the platform composed rather than the author\n // (AGL-2513). Still the home screen's layout by default — the rule this\n // branch has always followed — but the host can now name a different one,\n // and site search reads the same setting so the two built-in pages of a\n // site cannot end up in different chrome.\n const layoutId = await resolveBuiltInPageLayoutId({ hostId, host })\n const screenNodes = buildCollectionFallbackNodes({\n collection,\n entries: content.entries,\n entry: content.entry,\n pagination: content.pagination,\n category: content.category,\n // The cover resolves through `resolveMediaSrc` (AGL-1407), and an\n // org-scoped reference has to name the site asking or a site-restricted\n // asset will not serve.\n hostId,\n })\n // The head's card on a page with no template: the entry's cover, then the\n // site default (AGL-2850).\n const card = collectSocialImageFacts([\n content.entry?.coverImage,\n host?.seo?.image,\n ])\n const nodes = await composeNodesWithChrome({\n hostId,\n layoutId,\n screenNodes,\n socialImages: card.socialImages,\n // The layout's host variables fill in from this site (AGL-2883).\n host,\n // Entry routes resolve with an EMPTY entries list (the loader only\n // fetched the one entry), so hand the routed entry over and let the\n // Related posts block fetch the list on demand (AGL-582); list\n // routes keep their already-fetched entries. Categories ride along\n // for `categoryId` → name resolution.\n collection: content.entry\n ? {\n slug: collection.slug,\n entry: content.entry,\n categories: collection.categories,\n }\n : {\n slug: collection.slug,\n entries: content.entries,\n categories: collection.categories,\n ...(content.pagination?.page\n ? { page: content.pagination.page }\n : {}),\n ...(content.category\n ? { categorySlug: content.category.slug }\n : {}),\n // Same fact, same reason as the template path above (AGL-1516).\n ...(content.entriesReachedBound\n ? { entriesReachedBound: true }\n : {}),\n // Same window, same reason as the template path above (AGL-3213).\n ...(content.pagination?.windowStart\n ? { windowStart: content.pagination.windowStart }\n : {}),\n },\n })\n return nodes ? { nodes, ...card.collected() } : null\n } catch (error) {\n console.error(error)\n return null\n }\n}\n\nexport default composeCollectionTemplatePage\n"],"names":["Aglyn","buildCollectionFallbackNodes","composeScreenNodes","composeNodesWithChrome","resolveBuiltInPageLayoutId","getScreen","collectSocialImageFacts","resolveCollectionTemplateScreenId","collection","kind","listScreenId","undefined","entryScreenId","templateScreenId","collectionTokens","category","pagination","pager","collectionPaginationLinks","collectionSlug","slug","categorySlug","page","totalPages","displayName","name","String","prevUrl","nextUrl","composeCollectionTemplatePage","options","content","hostId","entry","screenId","templateRes","allowTemplate","screen","tokens","collectionEntryTokens","categories","card","coverImage","seo","image","host","nodes","socialImages","entries","entriesReachedBound","windowStart","screenSeo","title","seoTitle","description","seoDescription","excerpt","imageWidth","imageHeight","imageAlt","coverImageAlt","collected","composeCollectionFallbackPage","layoutId","screenNodes","error","console"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,YAAYA,WAAW,sBAAqB;AAC5C,OAAOC,kCAAkC,iCAA6B;AACtE,OAAOC,sBACLC,sBAAsB,QACjB,4BAAwB;AAC/B,SAASC,0BAA0B,QAAQ,4BAAwB;AAEnE,OAAOC,eAAe,kBAAc;AACpC,SAASC,uBAAuB,QAAQ,0BAAsB;AAI9D;;;;;;CAMC,GACD,OAAO,SAASC,kCACdC,UAGC,EACDC,IAAsB;IAEtB,IAAIA,SAAS,QAAQ,OAAOD,WAAWE,YAAY,IAAIC;IACvD,OAAOH,WAAWI,aAAa,IAAIJ,WAAWK,gBAAgB,IAAIF;AACpE;AAEA;;;;;;;;;;;;;;;;;;;;;;CAsBC,GACD,OAAO,SAASG,iBACdN,UAAuD,EACvDO,QAAwC,EACxCC,UAA4C;;IAE5C,0EAA0E;IAC1E,6DAA6D;IAC7D,MAAMC,QAAQjB,MAAMkB,yBAAyB,CAAC;QAC5CC,gBAAgBX,WAAWY,IAAI;OAC3BL,CAAAA,4BAAAA,SAAUK,IAAI,IAAG;QAAEC,cAAcN,SAASK,IAAI;IAAC,IAAI,CAAC;QACxDE,IAAI,EAAEN,8BAAAA,WAAYM,IAAI;QACtBC,UAAU,EAAEP,8BAAAA,WAAYO,UAAU;;IAEpC,OAAO;QACL,mBAAmBf,WAAWgB,WAAW;QACzC,mBAAmBhB,WAAWY,IAAI;QAClC,qBAAqB,UAAEL,4BAAAA,SAAUU,IAAI,mBAAI;QACzC,yBAAyB,WAAEV,4BAAAA,SAAUK,IAAI,oBAAI;QAC7C,mBAAmBM,OAAOT,MAAMK,IAAI;QACpC,yBAAyBI,OAAOT,MAAMM,UAAU;QAChD,sBAAsBN,MAAMU,OAAO;QACnC,sBAAsBV,MAAMW,OAAO;IACrC;AACF;AAcA;;;;;;;;CAQC,GACD,OAAO,eAAeC,8BAA8BC,OASnD;QAgFmB;QAhDhB,0BACAA,mBAAAA,eA0BUC,qBAcAA;IAxEZ,MAAM,EAAEC,MAAM,EAAED,OAAO,EAAE,GAAGD;IAC5B,MAAMtB,aAAauB,QAAQvB,UAAU;IACrC,IAAI,CAACA,YAAY,OAAO;IACxB,MAAMC,OAAOsB,QAAQE,KAAK,GAAG,UAAU;IACvC,MAAMC,WAAW3B,kCAAkCC,YAAYC;IAC/D,IAAI,CAACyB,UAAU,OAAO;IAEtB,sEAAsE;IACtE,wEAAwE;IACxE,6EAA6E;IAC7E,oDAAoD;IACpD,MAAMC,cAAc,MAAM9B,UAAU;QAAE2B;QAAQE;QAAUE,eAAe;IAAK;IAC5E,IAAI,CAACD,YAAYE,MAAM,EAAE,OAAO;IAEhC,MAAMJ,QAAQF,QAAQE,KAAK;IAC3B,MAAMK,SAASL,QACX,aACKnB,iBAAiBN,aAGjBR,MAAMuC,qBAAqB,CAC5BN,OACAzB,WAAWY,IAAI,EACfZ,WAAWgC,UAAU,KAGzB1B,iBAAiBN,YAAYuB,QAAQhB,QAAQ,EAAEgB,QAAQf,UAAU;IACrE,qEAAqE;IACrE,0DAA0D;IAC1D,MAAMyB,OAAOnC,wBAAwB;QACnC2B,yBAAAA,MAAOS,UAAU;SACjB,2BAAA,AAACP,YAAYE,MAAM,CAASM,GAAG,qBAA/B,yBAAiCC,KAAK;SACtCd,gBAAAA,QAAQe,IAAI,sBAAZf,oBAAAA,cAAca,GAAG,qBAAjBb,kBAAmBc,KAAK;KACzB;IACD,MAAME,QAAQ,MAAM5C,mBAAmB;QACrC8B;QACAE;QACAG,QAAQF,YAAYE,MAAM;QAC1BU,cAAcN,KAAKM,YAAY;QAC/B,qEAAqE;QACrE,gEAAgE;QAChEF,MAAMf,QAAQe,IAAI;QAClBP;QACA,kEAAkE;QAClE,sEAAsE;QACtE,kEAAkE;QAClE,iEAAiE;QACjE,kEAAkE;QAClE9B,YAAYyB,QACR;YAAEb,MAAMZ,WAAWY,IAAI;YAAEa;YAAOO,YAAYhC,WAAWgC,UAAU;QAAC,IAClE;YACEpB,MAAMZ,WAAWY,IAAI;YACrB,iEAAiE;YACjE,wDAAwD;YACxD,+DAA+D;YAC/D,eAAe;YACf4B,SAASjB,QAAQiB,OAAO;YACxBR,YAAYhC,WAAWgC,UAAU;WAC7BT,EAAAA,sBAAAA,QAAQf,UAAU,qBAAlBe,oBAAoBT,IAAI,IACxB;YAAEA,MAAMS,QAAQf,UAAU,CAACM,IAAI;QAAC,IAChC,CAAC,GACDS,QAAQhB,QAAQ,GAAG;YAAEM,cAAcU,QAAQhB,QAAQ,CAACK,IAAI;QAAC,IAAI,CAAC,GAK9DW,QAAQkB,mBAAmB,GAC3B;YAAEA,qBAAqB;QAAK,IAC5B,CAAC,GAIDlB,EAAAA,uBAAAA,QAAQf,UAAU,qBAAlBe,qBAAoBmB,WAAW,IAC/B;YAAEA,aAAanB,QAAQf,UAAU,CAACkC,WAAW;QAAC,IAC9C,CAAC;IAEb;IACA,IAAI,CAACJ,OAAO,OAAO;IAEnB,MAAMK,aAAY,0BAAA,AAAChB,YAAYE,MAAM,CAASM,GAAG,YAA/B,0BAAmC,CAAC;IACtD,MAAMA,MAAMV,QAER,aACKkB;QACHC,OAAOnB,MAAMoB,QAAQ,IAAIpB,MAAMmB,KAAK;QACpCE,aAAarB,MAAMsB,cAAc,IAAItB,MAAMuB,OAAO,IAAI7C;OAQlDsB,MAAMS,UAAU,GAChB;QACEE,OAAOX,MAAMS,UAAU;QACvBe,YAAY9C;QACZ+C,aAAa/C;QACbgD,UAAU1B,MAAM2B,aAAa,IAAIjD;IACnC,IACA;QAAEiC,OAAOO,UAAUP,KAAK,IAAIjC;IAAU,KAG5C,EAAE;IACF,wEAAwE;IACxE,iEAAiE;IACjE,yEAAyE;IACzE,mEAAmE;IACnE,sEAAsE;IACtE,oEAAoE;IACpE,uEAAuE;IACvE,uEAAuE;IACvE,wEAAwE;IACxE,kEAAkE;IAClE,6DAA6D;IAC7DwC;IACJ,OAAO;QACLd,QAAQ,aAAMF,YAAYE,MAAM;YAAUM;;QAC1CG;OACGL,KAAKoB,SAAS;AAErB;AAEA;;;;;;CAMC,GACD,OAAO,eAAeC,8BAA8BhC,OAInD;IACC,MAAM,EAAEE,MAAM,EAAEa,IAAI,EAAEd,OAAO,EAAE,GAAGD;IAClC,MAAMtB,aAAauB,QAAQvB,UAAU;IACrC,IAAI,CAACA,YAAY,OAAO;IACxB,IAAI;YAqBAuB,gBACAc,WAwBUd,qBAWAA;QAxDZ,qEAAqE;QACrE,wEAAwE;QACxE,0EAA0E;QAC1E,wEAAwE;QACxE,0CAA0C;QAC1C,MAAMgC,WAAW,MAAM3D,2BAA2B;YAAE4B;YAAQa;QAAK;QACjE,MAAMmB,cAAc/D,6BAA6B;YAC/CO;YACAwC,SAASjB,QAAQiB,OAAO;YACxBf,OAAOF,QAAQE,KAAK;YACpBjB,YAAYe,QAAQf,UAAU;YAC9BD,UAAUgB,QAAQhB,QAAQ;YAC1B,kEAAkE;YAClE,wEAAwE;YACxE,wBAAwB;YACxBiB;QACF;QACA,0EAA0E;QAC1E,2BAA2B;QAC3B,MAAMS,OAAOnC,wBAAwB;aACnCyB,iBAAAA,QAAQE,KAAK,qBAAbF,eAAeW,UAAU;YACzBG,yBAAAA,YAAAA,KAAMF,GAAG,qBAATE,UAAWD,KAAK;SACjB;QACD,MAAME,QAAQ,MAAM3C,uBAAuB;YACzC6B;YACA+B;YACAC;YACAjB,cAAcN,KAAKM,YAAY;YAC/B,iEAAiE;YACjEF;YACA,mEAAmE;YACnE,oEAAoE;YACpE,+DAA+D;YAC/D,mEAAmE;YACnE,sCAAsC;YACtCrC,YAAYuB,QAAQE,KAAK,GACrB;gBACEb,MAAMZ,WAAWY,IAAI;gBACrBa,OAAOF,QAAQE,KAAK;gBACpBO,YAAYhC,WAAWgC,UAAU;YACnC,IACA;gBACEpB,MAAMZ,WAAWY,IAAI;gBACrB4B,SAASjB,QAAQiB,OAAO;gBACxBR,YAAYhC,WAAWgC,UAAU;eAC7BT,EAAAA,sBAAAA,QAAQf,UAAU,qBAAlBe,oBAAoBT,IAAI,IACxB;gBAAEA,MAAMS,QAAQf,UAAU,CAACM,IAAI;YAAC,IAChC,CAAC,GACDS,QAAQhB,QAAQ,GAChB;gBAAEM,cAAcU,QAAQhB,QAAQ,CAACK,IAAI;YAAC,IACtC,CAAC,GAEDW,QAAQkB,mBAAmB,GAC3B;gBAAEA,qBAAqB;YAAK,IAC5B,CAAC,GAEDlB,EAAAA,uBAAAA,QAAQf,UAAU,qBAAlBe,qBAAoBmB,WAAW,IAC/B;gBAAEA,aAAanB,QAAQf,UAAU,CAACkC,WAAW;YAAC,IAC9C,CAAC;QAEb;QACA,OAAOJ,QAAQ;YAAEA;WAAUL,KAAKoB,SAAS,MAAO;IAClD,EAAE,OAAOI,OAAO;QACdC,QAAQD,KAAK,CAACA;QACd,OAAO;IACT;AACF;AAEA,eAAepC,8BAA6B"}
@@ -53,6 +53,15 @@ export interface ComposeCollectionContext {
53
53
  * category filter both in between — `entries.length` no longer tells them.
54
54
  */
55
55
  entriesReachedBound?: boolean;
56
+ /**
57
+ * Where `entries` begins in the collection's own order (AGL-3213).
58
+ *
59
+ * Zero for every listing served from the cached read. A page past that
60
+ * read's bound carries only its own ten entries, and the block windows
61
+ * `[(page - 1) * perPage, …)` — so without this it would slice from an
62
+ * offset its own array never reaches and render an empty listing.
63
+ */
64
+ windowStart?: number;
56
65
  /**
57
66
  * Whether {@link slug} is a cache KEY rather than an address (AGL-2524).
58
67
  *
@@ -150,6 +150,8 @@ import { stampFormDatasetBindings } from "./stamp-form-dataset-bindings.js";
150
150
  page: collection.page
151
151
  } : {}, collection.entriesReachedBound ? {
152
152
  reachedBound: true
153
+ } : {}, collection.windowStart ? {
154
+ windowStart: collection.windowStart
153
155
  } : {});
154
156
  return;
155
157
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/tenant/runtime/src/lib/compose-screen-nodes.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport * as Aglyn from '@aglyn/aglyn/server'\n// By path: the overlay is server-only, and every `@aglyn/aglyn` barrel\n// re-exports `app-utils/server` into published pages.\nimport {\n applyMediaAssetFacts,\n mediaAssetRefs,\n} from '@aglyn/aglyn/app-utils/media-asset-facts'\nimport applyDuePublishSchedule from './apply-publish-schedule'\nimport getComponents from './get-components'\nimport getDatasets from './get-datasets'\nimport getForms from './get-forms'\nimport getMediaAssetFacts from './get-media-asset-facts'\nimport {\n getPublishedCollectionSource,\n type PublishedCollectionSource,\n} from './get-collection-content'\nimport getPluginInstalls from './get-plugin-installs'\nimport getVariables, { getFunctions, getWorkflows } from './get-variables'\nimport getPublishedLayoutVersion from './get-layout-version'\nimport getScreenVersion from './get-screen-version'\nimport {\n type ComposeSocialImages,\n socialImageAssetFacts,\n socialImageRefs,\n} from './social-image-facts'\nimport { stampFormDatasetBindings } from './stamp-form-dataset-bindings'\n\n/**\n * Content-collection context for a compose (AGL-551): the collection the\n * route resolved (list/entry template screens). `entries` rides along when\n * the route already fetched them (list pages); blocks bound to other\n * collections — or to this one when `entries` is absent — fetch on demand.\n */\nexport interface ComposeCollectionContext {\n slug: string\n entries?: Aglyn.CollectionEntryRecord[]\n /**\n * The entry being rendered (AGL-582, entry-template screens / entry\n * fallback) — the Related posts block resolves against it.\n */\n entry?: Aglyn.CollectionEntryRecord | null\n /**\n * The routed collection's category taxonomy (AGL-582): entry\n * `categoryId`s resolve to display names against it during expansion.\n */\n categories?: Aglyn.CollectionCategory[]\n /**\n * The list page the URL asked for (AGL-1321). Fills in an entries block\n * that declares `perPage` but no `page` — design time cannot know which\n * page a visitor is on.\n */\n page?: number\n /**\n * The category segment the URL filtered on (AGL-1321); marks the current\n * pill in a Category Pills block. `entries` arrives already filtered.\n */\n categorySlug?: string\n /**\n * Whether the read behind `entries` stopped at its `.limit()` (AGL-1516).\n * Travels with the entries because the search boxes downstream have to say\n * what they actually searched, and — with the liveness gate and the route's\n * category filter both in between — `entries.length` no longer tells them.\n */\n entriesReachedBound?: boolean\n /**\n * Whether {@link slug} is a cache KEY rather than an address (AGL-2524).\n *\n * The author page mixes collections, and the compose pipeline keys entry\n * sources by collection slug — so it hands over a synthetic one\n * (`AUTHOR_ENTRIES_SOURCE_SLUG`) with its entries already in hand. That is\n * harmless for the entries block, whose every row carries its OWN\n * `collectionSlug` and builds `entry.url` from it, and for the search box,\n * whose index is built the same way.\n *\n * It is NOT harmless for anything that builds a URL from the source's slug\n * itself. Set this and such a block resolves nothing rather than pointing\n * readers at `/{synthetic}/…`.\n */\n routeless?: boolean\n}\n\ninterface CollectionBlockScan {\n slugs: Set<string>\n hasRelated: boolean\n hasCategories: boolean\n hasSearch: boolean\n}\n\n/**\n * Which collections does this tree ask for (AGL-1152)?\n *\n * Extracted so the PREFETCH below and the expansion that consumes it read the\n * tree through one function rather than two copies of the same predicate. A\n * divergence between them is not a type error — it is a slug prefetched and\n * never awaited, or (worse) a slug the prefetch missed that then pays the full\n * serial read anyway — so there is deliberately no second implementation to\n * drift.\n */\nfunction scanCollectionBlocks(\n nodes: Record<string, any>,\n collection?: ComposeCollectionContext,\n): CollectionBlockScan {\n const slugs = new Set<string>()\n let hasRelated = false\n let hasCategories = false\n let hasSearch = false\n for (const node of Object.values(nodes)) {\n if (node?.componentId === Aglyn.COLLECTION_ENTRIES_COMPONENT_ID) {\n const slug =\n String(node?.props?.collectionSlug ?? '').trim() || collection?.slug\n if (slug) slugs.add(slug)\n }\n // Category pills (AGL-1321) need only the taxonomy, but it rides on the\n // same source, so they count as a reason to resolve one.\n if (node?.componentId === Aglyn.COLLECTION_CATEGORIES_COMPONENT_ID) {\n const slug =\n String(node?.props?.collectionSlug ?? '').trim() || collection?.slug\n if (slug) {\n hasCategories = true\n slugs.add(slug)\n }\n }\n // The standalone toolbar search box (AGL-1516, Figma 494:1220) needs the\n // collection's entries to index, and rides the same source the listing\n // beside it was built from — one read, one answer.\n if (node?.componentId === Aglyn.COLLECTION_SEARCH_COMPONENT_ID) {\n const slug =\n String(node?.props?.collectionSlug ?? '').trim() || collection?.slug\n if (slug) {\n hasSearch = true\n slugs.add(slug)\n }\n }\n // Related posts (AGL-582) always resolve against the ROUTED collection\n // — they only mean something with a current entry in context.\n if (\n node?.componentId === Aglyn.COLLECTION_RELATED_COMPONENT_ID &&\n collection?.slug &&\n collection.entry\n ) {\n hasRelated = true\n slugs.add(collection.slug)\n }\n }\n return { slugs, hasRelated, hasCategories, hasSearch }\n}\n\n/**\n * Issue the collection reads AS SOON AS THE SCREEN NODES EXIST (AGL-1152).\n *\n * `getPublishedCollectionSource` is three SEQUENTIAL round trips —\n * `findContentCollection`, then `listLiveEntries`, then `attachEntryAuthors`\n * — and until this existed it ran after the chrome bundle had already been\n * awaited, so a page carrying a Collection entries block paid the whole thing\n * as a serial tail on the compose phase. Measured on the tenant: a page with\n * no collection block composes in ~20 ms, one with a block in ~572 ms, and the\n * tree work itself accounts for under 2 ms of that at 50 entries. The gap is\n * this read, waiting for reads it shares nothing with.\n *\n * Same shape as `screenDatasetsPromise` directly below, and the same caveat\n * applies: the SCREEN's own nodes are a fast path, NOT the correctness gate. A\n * collection block can arrive from a layout or a grafted reusable component,\n * neither of which exists yet at this point, so the real scan still runs\n * against the composed tree and still fetches anything this missed.\n *\n * The ROUTED collection is deliberately excluded when its entries are already\n * in hand: the expansion below answers that slug from `collection.entries`\n * without reading at all, so prefetching it would buy a read nobody awaits.\n */\nfunction prefetchCollectionSources(\n hostId: string,\n screenNodes: Record<string, any>,\n collection?: ComposeCollectionContext,\n): Record<string, Promise<PublishedCollectionSource>> {\n const prefetched: Record<\n string,\n Promise<PublishedCollectionSource>\n > = {}\n for (const slug of scanCollectionBlocks(screenNodes, collection).slugs) {\n if (slug === collection?.slug && collection.entries) continue\n const pending = getPublishedCollectionSource({\n hostId,\n collectionSlug: slug,\n })\n // Marked handled the moment it exists, for the reason `composed` is in\n // `composeScreenNodes`: a tree that turns out not to need this slug never\n // awaits it, and an unawaited rejection takes the process down rather than\n // failing this one read. The real await below still sees the rejection.\n void pending.catch(() => undefined)\n prefetched[slug] = pending\n }\n return prefetched\n}\n\n/**\n * Expands Collection entries blocks (AGL-551) against their collections'\n * published entries, and Related posts blocks (AGL-582) against the routed\n * entry. Fetches lazily — screens without the blocks cost nothing — and\n * fails open on lookup errors like every other compose stage.\n */\nasync function expandCollectionEntryBlocks(\n hostId: string,\n nodes: Record<string, any>,\n collection?: ComposeCollectionContext,\n prefetched?: Record<string, Promise<PublishedCollectionSource>>,\n): Promise<Record<string, any>> {\n const { slugs, hasRelated, hasCategories, hasSearch } = scanCollectionBlocks(\n nodes,\n collection,\n )\n if (!slugs.size) return nodes\n const sources: Record<string, Aglyn.CollectionEntriesSource> = {}\n await Promise.all(\n [...slugs].map(async (slug) => {\n // The routed collection rides its already-fetched entries +\n // categories (AGL-582); other collections fetch both on demand.\n if (slug === collection?.slug && collection.entries) {\n sources[slug] = {\n slug,\n entries: collection.entries,\n categories: collection.categories,\n // Only the ROUTED collection carries the URL's page (AGL-1321) — a\n // block bound to another collection must not inherit it.\n ...(collection.page ? { page: collection.page } : {}),\n // ...nor its bound (AGL-1516): this is a fact about the read the\n // route already performed, and it describes only that collection.\n ...(collection.entriesReachedBound ? { reachedBound: true } : {}),\n }\n return\n }\n // The prefetch when this slug was visible on the screen's own nodes;\n // a live read when it only appeared after layout/component grafting.\n const fetched = await (prefetched?.[slug] ??\n getPublishedCollectionSource({ hostId, collectionSlug: slug }))\n sources[slug] = {\n slug,\n entries: fetched.entries,\n categories:\n slug === collection?.slug && collection.categories\n ? collection.categories\n : fetched.categories,\n ...(fetched.reachedBound ? { reachedBound: true } : {}),\n }\n }),\n )\n const expanded = Aglyn.expandCollectionEntries(\n nodes,\n sources,\n collection?.slug,\n )\n const withCategories = hasCategories\n ? Aglyn.expandCollectionCategories(\n expanded,\n sources,\n /*\n No DEFAULT collection for the pills on a routeless page (AGL-2524).\n\n Category pills are the one block that builds its links from the\n SOURCE's slug — `/{slug}/category/{x}` — because a category is a\n filter on one collection's own listing. On the author page that slug\n is synthetic, so an unbound pills block stamped links to a route that\n does not exist.\n\n Rendering nothing is the honest answer rather than a fallback. The\n page spans every collection, and there is no\n `/author/{slug}/category/{x}` for a pill to lead to; the only real\n destination would be some collection's listing, which drops the\n author the reader is looking at. A pills block that NAMES a\n collection is unaffected and still resolves that collection's own\n taxonomy — \"browse the blog by category\", which is a sensible thing\n to put beside an archive.\n */\n collection?.routeless ? undefined : collection?.slug,\n collection?.categorySlug,\n )\n : expanded\n const withSearch = hasSearch\n ? Aglyn.expandCollectionSearch(withCategories, sources, collection?.slug)\n : withCategories\n if (!hasRelated || !collection?.entry) return withSearch\n return Aglyn.expandCollectionRelated(\n withSearch,\n sources[collection.slug],\n collection.entry,\n )\n}\n\n/**\n * Shared post-version composition (AGL-551, extracted from\n * `composeScreenNodes`): layout chrome, reusable components, repeatables,\n * collection entries, bindings, function definitions, plugin installs,\n * named tokens, denormalize, and last the current facts of each placed image\n * and film, and of the social card the page is shared as. The screen path and\n * the collection-fallback\n * path (which has no screen doc) build identical trees through this one\n * pipeline.\n */\nexport async function composeNodesWithChrome(options: {\n hostId: string\n /**\n * The layout binding, or a PROMISE of it.\n *\n * The unresolved form exists for the same reason `screenNodes` accepts one\n * (AGL-1428): the binding may live on the version document (key-present\n * wins over the screen's), and awaiting the version before this call would\n * put every host-scoped read back behind it. Only the layout-chain walk\n * consumes the binding, so it alone waits; the rest of the chrome bundle\n * still starts immediately.\n */\n layoutId?:\n | string\n | null\n | Promise<string | null | undefined>\n /**\n * The screen's values for the properties of the layouts it renders inside\n * (AGL-2893), keyed by layout id — or a PROMISE of them, for the reason\n * `layoutId` accepts one: they live on the version document beside the\n * binding. Absent, every layout renders its properties' defaults.\n */\n layoutPropValues?:\n | Aglyn.AglynScreenVersion['layoutPropValues']\n | null\n | Promise<Aglyn.AglynScreenVersion['layoutPropValues'] | null | undefined>\n /**\n * The screen's own nodes, or a PROMISE of them (AGL-1428).\n *\n * Accepting the unresolved form is what lets `composeScreenNodes` hand the\n * version read over before it has finished, so the host-scoped reads below\n * — none of which look at these nodes — overlap it instead of queueing\n * behind it. Passing a resolved value behaves exactly as before, which is\n * what every other caller does.\n */\n screenNodes: Record<string, any> | Promise<Record<string, any>>\n /** Entry-template tokens (AGL-105) substituted before denormalize. */\n tokens?: Record<string, string>\n /** Routed content collection (AGL-551) for Collection entries blocks. */\n collection?: ComposeCollectionContext\n /**\n * The host document, for `host.*` tokens (AGL-1022).\n *\n * Passed in rather than read here: every caller already holds it, and\n * `composeScreenNodes` was the largest single render phase before AGL-1225\n * cut it to one round trip — adding a read back would spend that win on\n * data we already have in hand.\n */\n host?: Aglyn.HostTokenSource | null\n /**\n * The social card the head shares this page as (AGL-2850). The documents of\n * the assets it names are read in the same batch as the images and films\n * the tree places.\n */\n socialImages?: ComposeSocialImages\n}): Promise<Record<string, any>> {\n const { hostId, layoutId } = options\n\n /**\n * The layout chain, innermost first (AGL-703).\n *\n * A layout may itself render inside another layout, so this walks the\n * `layoutId` pointers rather than reading one. Fetching is sequential\n * because each step's parent is only known once the previous layout\n * document is in hand — but the walk is short by construction\n * (MAX_LAYOUT_CHAIN_DEPTH) and every layout is already a cached read.\n *\n * `seen` stops a cycle from looping forever. Stored data can hold one\n * even though both the console and `canNestLayout` refuse to create it:\n * the API, a script, or a restored backup can all write a layout\n * document directly, and a render must degrade rather than hang.\n */\n const walkLayoutChain = async () => {\n const chain: Aglyn.LayoutChainEntry[] = []\n const seen = new Set<string>()\n const boundLayoutId = await layoutId\n let currentLayoutId = boundLayoutId ? String(boundLayoutId) : undefined\n while (\n currentLayoutId &&\n !seen.has(currentLayoutId) &&\n chain.length < Aglyn.MAX_LAYOUT_CHAIN_DEPTH\n ) {\n seen.add(currentLayoutId)\n const layoutRes = await getPublishedLayoutVersion({\n hostId,\n layoutId: currentLayoutId,\n })\n // The version's declared properties travel with its nodes (AGL-2893):\n // they are applied to this layout alone, with the screen's values for\n // this layout, before the screen is grafted into its slot.\n chain.push({\n layoutId: currentLayoutId,\n nodes: layoutRes?.version?.nodes as any,\n props: (layoutRes?.version as Aglyn.AglynLayoutVersion | undefined)\n ?.props,\n })\n const parentId = (layoutRes?.layout as any)?.layoutId\n currentLayoutId = parentId ? String(parentId) : undefined\n }\n return chain\n }\n\n /**\n * ONE round-trip stage instead of three (AGL-1225).\n *\n * This used to be `await layout walk` → `await getComponents` → `await\n * Promise.all([five reads])`: three sequential waits, where only the first\n * has any reason to be sequential. Every one of the other six reads takes\n * `hostId` and nothing else — none of them consumes the layout chain — so\n * they were waiting on a walk whose result they never look at.\n *\n * The walk stays internally sequential because it genuinely is: each step's\n * parent id is only known once the previous layout document is in hand. It\n * just no longer gates anything else. The critical path becomes the walk\n * alone rather than walk + components + bulk.\n *\n * Measured budget that motivated this (production, `/product/besigner`):\n * `composeScreenNodes` was the largest single phase at 1577 ms cold and\n * consistently ~1.4-1.6 s warm too, so this is not cold-start cost. The\n * existing `AGL-1152:render` timing line reports `composeScreenNodes` as a\n * phase, so the effect of this shows up there directly — no new\n * instrumentation, and a regression would be visible in the same place.\n */\n /**\n * Does the SCREEN itself repeat over a dataset (AGL-1440)?\n *\n * Asked as soon as the nodes are in hand — which since AGL-1428 is after\n * the fan-out has been ISSUED rather than before — so the overwhelmingly\n * common case still keeps the AGL-1225 shape: a page that repeats almost\n * always says so on its own document, and its datasets read goes out\n * alongside the remaining chrome reads instead of after them.\n *\n * It is deliberately NOT the correctness gate — a repeatable can arrive from\n * a layout or a grafted reusable component, neither of which exists yet. The\n * gate is re-asked against the composed tree after grafting, which is the\n * exact input `expandRepeatables` reads.\n */\n /*\n * ISSUE THE HOST-SCOPED READS FIRST, THEN AWAIT THE NODES (AGL-1428).\n *\n * Every read in this bundle is keyed by `hostId` (and `layoutId`) alone —\n * none of them reads `screenNodes` — so starting them before the nodes are\n * in hand lets `composeScreenNodes`' version read overlap them rather than\n * run ahead of them. `Promise.all` is created BEFORE the `await` below on\n * purpose: that is the line that makes the two independent, and moving the\n * await above it silently gives the whole saving back.\n *\n * `getDatasets` is the one read that genuinely depends on the nodes, so it\n * cannot join the bundle. It does not have to wait for the bundle either —\n * it is issued the moment the nodes resolve and awaited after, so it still\n * overlaps whatever is left of the chrome reads instead of costing the\n * extra serial round trip that dropping it from the batch would imply.\n */\n const chromeBundle = Promise.all([\n walkLayoutChain(),\n getComponents({ hostId }),\n // Host variable + function bindings (AGL-91/93): {{name}} and\n // {{fn:name(args)}} in string props resolve to values; unknown tokens\n // and failed runs stay literal.\n Promise.all([\n getVariables({ hostId }),\n getFunctions({ hostId }),\n getWorkflows({ hostId }),\n getPluginInstalls({ hostId }),\n ]),\n ])\n const screenNodes = await options.screenNodes\n const screenDatasetKeys = Aglyn.repeatDatasetKeys(screenNodes)\n const screenDatasetsPromise = screenDatasetKeys.length\n ? getDatasets({ hostId, keys: screenDatasetKeys })\n : undefined\n // Does the SCREEN itself place a form entity? Gated and re-asked exactly\n // like the datasets read beside it (AGL-1440): most pages carry no form, and\n // the ones that do usually say so on their own document, so the read goes\n // out here alongside the chrome reads instead of as a serial tail. It is not\n // the correctness gate — a placed form can arrive from a layout or a grafted\n // component — so the composed tree is asked again below.\n const screenFormsPromise = Aglyn.placesFormEntity(screenNodes)\n ? getForms({ hostId })\n : undefined\n // Issued HERE, beside the datasets read and before the chrome bundle is\n // awaited, so the collection read overlaps it instead of trailing it.\n const prefetchedSources = prefetchCollectionSources(\n hostId,\n screenNodes,\n options.collection,\n )\n const [layoutChain, componentsRes, bulk] = await chromeBundle\n const [rawVariables, functions, workflows, pluginInstalls] = bulk\n const screenDatasets = await screenDatasetsPromise\n // Settled by now: it is read off the same version document the layout\n // binding the walk above waited on came from.\n const layoutPropValues = await options.layoutPropValues\n\n const composedNodes = Aglyn.composeLayoutChainWithProps(\n layoutChain as any,\n screenNodes as any,\n layoutPropValues,\n )\n const graftedComponents = Aglyn.composeReusableComponentNodes(\n composedNodes as any,\n componentsRes.definitions as any,\n )\n /*\n * PLACED FORMS RESOLVE AGAINST THEIR ENTITY (`docs/specs/reusable-forms.md`).\n *\n * A form node bound to `hosts/{hostId}/forms/{formId}` renders that entity's\n * published design, so a form is edited once and every page placing it\n * follows. Without this the entity's tree was written on every publish and\n * read by nothing: the fields had to be redrawn per page, and the two copies\n * diverged the moment either was touched.\n *\n * The gate is the COMPONENT-grafted tree, not the screen's own nodes, for\n * the reason the repeatables gate below states: a form placed inside a\n * layout or a shared component does not exist in `screenNodes`, and a page\n * that renders one would silently keep its stale inline copy.\n *\n * The second graft re-runs the component expansion deliberately. Instances\n * already expanded are skipped by their own prefix, so the repeat costs a\n * scan, and passing BOTH placement kinds is what expands a reusable\n * component nested inside a form's design — which the first pass could not\n * have seen, because that subtree was not in the tree yet.\n */\n const forms =\n (await screenFormsPromise)?.forms ??\n (Aglyn.placesFormEntity(graftedComponents as any)\n ? (await getForms({ hostId })).forms\n : undefined)\n const grafted = forms\n ? Aglyn.composeReusableComponentNodes(\n graftedComponents as any,\n componentsRes.definitions as any,\n [Aglyn.placedFormPlacement(forms as any)],\n )\n : graftedComponents\n // Computed variables (AGL-129): workflow-backed values resolve once per\n // compose; failures keep each variable's stored fallback.\n const variables = Aglyn.resolveComputedVariables(\n rawVariables,\n functions,\n workflows,\n )\n // Repeatables (AGL-103) expand after grafting (so they work inside\n // reusable components) and before bindings (so {{name}} tokens inside\n // cloned items still resolve).\n //\n // Only the datasets this tree repeats over are read (AGL-1440), and the tree\n // asked is the composed one — after grafting — because that is the map the\n // expansion reads: a repeatable living in a layout or a reusable component\n // is invisible in `screenNodes`, and reading only the screen's keys would\n // silently render one template row where the author put a list. The\n // screen's own keys were issued beside the chrome reads above; a key only a\n // layout or a component adds is read here, for that key alone.\n const datasetKeys = Aglyn.repeatDatasetKeys(grafted as any)\n const unreadDatasetKeys = datasetKeys.filter(\n (key) => !screenDatasetKeys.includes(key),\n )\n const datasets = unreadDatasetKeys.length\n ? {\n ...screenDatasets,\n ...(await getDatasets({ hostId, keys: unreadDatasetKeys })),\n }\n : screenDatasets\n const repeated = Aglyn.expandRepeatables(grafted as any, datasets)\n // Collection entries blocks (AGL-551) expand alongside repeatables:\n // per-entry {{entry.*}} tokens substitute inside the clones here, while\n // page-level tokens wait for resolveNamedTokens below.\n const withEntries = await expandCollectionEntryBlocks(\n hostId,\n repeated,\n options.collection,\n prefetchedSources,\n )\n // Entry Meta blocks (AGL-1385): fill in the routed entry's date/category/\n // tags. Needs no source fetch — the routed entry and its taxonomy are\n // already in hand — so it sits outside `expandCollectionEntryBlocks`, which\n // returns early when no block asks for a collection read. AFTER it, so the\n // per-entry clones it just produced already carry their own resolved values\n // and are skipped.\n const withEntryMeta = Aglyn.expandCollectionEntryMeta(\n withEntries as any,\n options.collection?.entry,\n options.collection?.categories,\n )\n // Entry Author cards (AGL-2486): the same fill, one block over. Its values\n // come off the author RECORD the routed entry resolved to, which the\n // collection read has already attached, so this costs nothing either.\n const withEntryAuthor = Aglyn.expandCollectionEntryAuthor(\n withEntryMeta as any,\n options.collection?.entry,\n )\n const bound = Aglyn.resolveNodesBindings(\n withEntryAuthor as any,\n variables,\n functions,\n )\n // Host variables (AGL-1022): `{{host.*}}` resolves from the INSTALLING\n // site, late — at render, never at install — so a rebrand propagates to\n // every artifact that names the host instead of hard-coding it. Same\n // registry the email path uses, so a token means one thing in both.\n const withHostTokens = Aglyn.resolveNodesHostTokens(\n bound as any,\n options.host,\n )\n // Function widgets run client-side: embed their definitions (AGL-93), and\n // beside each the site variables that function reads and no others\n // (AGL-3202).\n const withFunctions = Aglyn.attachFunctionDefinitions(\n withHostTokens,\n functions,\n variables,\n )\n // Marketplace plugins (AGL-45): stamp each marketplacePlugin node with its\n // pinned install (version/sha256/capabilities) + kill-switch state.\n const nodes = Aglyn.attachPluginInstalls(withFunctions, pluginInstalls)\n // Entry-template tokens (AGL-105): {{entry.*}} from the rendered entry.\n const finalNodes = Aglyn.resolveNamedTokens(nodes as any, options.tokens)\n // The document's one `main` landmark (AGL-2486). LAST, so it reads the tree\n // the page actually ships — a slot grafted from a layout chain, an element\n // an author chose — rather than the screen as stored.\n const withLandmark = Aglyn.stampDocumentLandmark(finalNodes as any)\n // Each form's dataset binding, signed so the submit route can trust it\n // (AGL-2773). Read off THIS tree, the one the page ships, so a form grafted\n // from a layout, a component or a form entity is signed as it renders; and\n // after every stage that rewrites props, so nothing changes what the\n // signature covers.\n const withFormBindings = stampFormDatasetBindings(withLandmark, hostId)\n const denormalized = Aglyn.canvas.processNodesToDenormalized(\n withFormBindings as any,\n )\n /*\n * WHAT EACH PLACED IMAGE AND FILM IS NOW (AGL-2807, AGL-2833).\n *\n * An image's pixel pair and a film's length, shape and poster come from the\n * asset's DAM document as it is now, not as it was when it was picked. LAST,\n * on the tree the page ships, because an asset can arrive from any stage\n * above: a layout, a component, a form, a repeated row, a collection entry,\n * a binding.\n *\n * ONE read for all of them, issued once the tree is final rather than beside\n * the chrome reads. A read issued there could only cover the screen's own\n * placements, and nearly every layout places an image the screen does not\n * (a logo, a footer mark), so it would buy a second read on almost every\n * page. The late read costs its own round trip after the chrome reads, paid\n * each time a page is composed, which for a published page is its ISR\n * regeneration. A tree with no library asset, on a page whose social card\n * names none, issues none.\n *\n * THE SOCIAL CARD'S ASSETS JOIN THE SAME READ (AGL-2850), ahead of the\n * placements. A placement the cap leaves out keeps its pick-time pair, which\n * for an image is a reservation the decoded picture corrects. Nothing\n * corrects a card's pair, because a crawler lays the card out from it before\n * fetching the image, and a card names at most three documents.\n */\n const socialImages = options.socialImages\n const cardRefs = socialImages ? socialImageRefs(socialImages.images) : []\n const refs = mediaAssetRefs(denormalized)\n if (!refs.length && !cardRefs.length) return denormalized\n const facts = await getMediaAssetFacts({\n hostId,\n refs: [...cardRefs, ...refs],\n })\n if (socialImages) {\n const cardFacts = socialImageAssetFacts(socialImages.images, facts)\n if (cardFacts) socialImages.onFacts(cardFacts)\n }\n return applyMediaAssetFacts(denormalized, facts)\n}\n\n/**\n * Full published-render composition for one screen (extracted for AGL-87 so\n * the SSG path and the password-unlock API build identical trees): applies\n * a due publish schedule, loads the version, composes the shared layout\n * chrome, grafts reusable components, and denormalizes.\n */\nexport async function composeScreenNodes(options: {\n hostId: string\n screenId: string\n screen: Aglyn.AglynScreen\n /** Entry-template tokens (AGL-105) substituted before denormalize. */\n tokens?: Record<string, string>\n /** Routed content collection (AGL-551) for Collection entries blocks. */\n collection?: ComposeCollectionContext\n /**\n * Compose a specific version instead of the published one (AGL-253):\n * experiment variants point at versions; schedules don't apply.\n */\n versionId?: string\n /** The host document, for `host.*` tokens (AGL-1022). */\n host?: Aglyn.HostTokenSource | null\n /** The social card the head shares this page as (AGL-2850). */\n socialImages?: ComposeSocialImages\n}): Promise<Record<string, any> | null> {\n const { hostId, screenId, screen } = options\n\n const effectiveVersionId = options.versionId\n ? null\n : await applyDuePublishSchedule({\n hostId,\n collectionName: 'screens',\n docId: screenId,\n parent: screen,\n })\n const versionId = (options.versionId ??\n effectiveVersionId ??\n screen.versionId) as string\n\n /*\n * OVERLAP THE VERSION READ WITH THE CHROME BUNDLE (AGL-1428).\n *\n * `composeNodesWithChrome`'s reads are keyed by `hostId`/`layoutId`, both\n * of which are in hand here, so the version read no longer has to finish\n * before they start. Handing the promise over instead of the resolved\n * value is the whole change: it is `composeNodesWithChrome` that decides\n * when it actually needs the nodes.\n *\n * The `versionId`-less screen still exits BEFORE anything is issued, so a\n * screen that has never been published costs no reads. What remains is the\n * narrow case of a `versionId` that points at a missing or unreadable\n * version document — a data-integrity fault rather than a routing outcome\n * — and that one now pays for a chrome bundle it discards. That is the\n * deliberate trade: one wasted bundle on a broken screen, against the\n * version read hiding under the chrome reads on every render that works.\n */\n if (!versionId) return null\n\n const versionPromise = getScreenVersion({ hostId, screenId, versionId })\n\n /*\n * NEITHER DERIVED PROMISE MAY REJECT ON ITS OWN.\n *\n * `composed` is discarded on every failure path below, and a rejected\n * promise nobody awaited is an unhandled rejection — which in Node takes\n * the whole render process down instead of letting this 404. So the nodes\n * handed to the compose absorb the failure (an empty tree it will never be\n * asked for), and `composed` gets a rejection handler attached the moment\n * it exists rather than at the point we decide to drop it. The real error\n * still propagates, from the `await versionPromise` below, exactly where it\n * did when this function awaited the version directly.\n */\n const composed = composeNodesWithChrome({\n hostId,\n // Version-first (key-present wins, null = explicitly no layout), screen\n // fallback — resolved as a promise so only the layout-chain walk waits on\n // the version read; the rest of the chrome bundle keeps the AGL-1428\n // overlap. A failed version read falls back to the screen binding; the\n // whole compose is discarded on that path anyway.\n layoutId: versionPromise.then(\n (res) =>\n res.version && 'layoutId' in res.version\n ? ((res.version as Aglyn.AglynScreenVersion).layoutId as\n | string\n | null)\n : (screen.layoutId as string | undefined),\n () => screen.layoutId as string | undefined,\n ),\n // The screen's values for its layouts' properties (AGL-2893), beside the\n // binding on the same document; a failed read renders the defaults.\n layoutPropValues: versionPromise.then(\n (res) =>\n (res.version as Aglyn.AglynScreenVersion | undefined)?.layoutPropValues,\n () => undefined,\n ),\n screenNodes: versionPromise.then(\n (res) => (res.version?.nodes ?? {}) as any,\n () => ({}) as any,\n ),\n tokens: options.tokens,\n collection: options.collection,\n host: options.host,\n socialImages: options.socialImages,\n })\n void composed.catch(() => undefined)\n\n const versionRes = await versionPromise\n if (versionRes.error || !versionRes.version) return null\n\n return composed\n}\n\nexport default composeScreenNodes\n"],"names":["Aglyn","applyMediaAssetFacts","mediaAssetRefs","applyDuePublishSchedule","getComponents","getDatasets","getForms","getMediaAssetFacts","getPublishedCollectionSource","getPluginInstalls","getVariables","getFunctions","getWorkflows","getPublishedLayoutVersion","getScreenVersion","socialImageAssetFacts","socialImageRefs","stampFormDatasetBindings","scanCollectionBlocks","nodes","collection","slugs","Set","hasRelated","hasCategories","hasSearch","node","Object","values","componentId","COLLECTION_ENTRIES_COMPONENT_ID","slug","String","props","collectionSlug","trim","add","COLLECTION_CATEGORIES_COMPONENT_ID","COLLECTION_SEARCH_COMPONENT_ID","COLLECTION_RELATED_COMPONENT_ID","entry","prefetchCollectionSources","hostId","screenNodes","prefetched","entries","pending","catch","undefined","expandCollectionEntryBlocks","size","sources","Promise","all","map","categories","page","entriesReachedBound","reachedBound","fetched","expanded","expandCollectionEntries","withCategories","expandCollectionCategories","routeless","categorySlug","withSearch","expandCollectionSearch","expandCollectionRelated","composeNodesWithChrome","options","layoutId","walkLayoutChain","chain","seen","boundLayoutId","currentLayoutId","has","length","MAX_LAYOUT_CHAIN_DEPTH","layoutRes","push","version","parentId","layout","chromeBundle","screenDatasetKeys","repeatDatasetKeys","screenDatasetsPromise","keys","screenFormsPromise","placesFormEntity","prefetchedSources","layoutChain","componentsRes","bulk","rawVariables","functions","workflows","pluginInstalls","screenDatasets","layoutPropValues","composedNodes","composeLayoutChainWithProps","graftedComponents","composeReusableComponentNodes","definitions","forms","grafted","placedFormPlacement","variables","resolveComputedVariables","datasetKeys","unreadDatasetKeys","filter","key","includes","datasets","repeated","expandRepeatables","withEntries","withEntryMeta","expandCollectionEntryMeta","withEntryAuthor","expandCollectionEntryAuthor","bound","resolveNodesBindings","withHostTokens","resolveNodesHostTokens","host","withFunctions","attachFunctionDefinitions","attachPluginInstalls","finalNodes","resolveNamedTokens","tokens","withLandmark","stampDocumentLandmark","withFormBindings","denormalized","canvas","processNodesToDenormalized","socialImages","cardRefs","images","refs","facts","cardFacts","onFacts","composeScreenNodes","screenId","screen","effectiveVersionId","versionId","collectionName","docId","parent","versionPromise","composed","then","res","versionRes","error"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/tenant/runtime/src/lib/compose-screen-nodes.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport * as Aglyn from '@aglyn/aglyn/server'\n// By path: the overlay is server-only, and every `@aglyn/aglyn` barrel\n// re-exports `app-utils/server` into published pages.\nimport {\n applyMediaAssetFacts,\n mediaAssetRefs,\n} from '@aglyn/aglyn/app-utils/media-asset-facts'\nimport applyDuePublishSchedule from './apply-publish-schedule'\nimport getComponents from './get-components'\nimport getDatasets from './get-datasets'\nimport getForms from './get-forms'\nimport getMediaAssetFacts from './get-media-asset-facts'\nimport {\n getPublishedCollectionSource,\n type PublishedCollectionSource,\n} from './get-collection-content'\nimport getPluginInstalls from './get-plugin-installs'\nimport getVariables, { getFunctions, getWorkflows } from './get-variables'\nimport getPublishedLayoutVersion from './get-layout-version'\nimport getScreenVersion from './get-screen-version'\nimport {\n type ComposeSocialImages,\n socialImageAssetFacts,\n socialImageRefs,\n} from './social-image-facts'\nimport { stampFormDatasetBindings } from './stamp-form-dataset-bindings'\n\n/**\n * Content-collection context for a compose (AGL-551): the collection the\n * route resolved (list/entry template screens). `entries` rides along when\n * the route already fetched them (list pages); blocks bound to other\n * collections — or to this one when `entries` is absent — fetch on demand.\n */\nexport interface ComposeCollectionContext {\n slug: string\n entries?: Aglyn.CollectionEntryRecord[]\n /**\n * The entry being rendered (AGL-582, entry-template screens / entry\n * fallback) — the Related posts block resolves against it.\n */\n entry?: Aglyn.CollectionEntryRecord | null\n /**\n * The routed collection's category taxonomy (AGL-582): entry\n * `categoryId`s resolve to display names against it during expansion.\n */\n categories?: Aglyn.CollectionCategory[]\n /**\n * The list page the URL asked for (AGL-1321). Fills in an entries block\n * that declares `perPage` but no `page` — design time cannot know which\n * page a visitor is on.\n */\n page?: number\n /**\n * The category segment the URL filtered on (AGL-1321); marks the current\n * pill in a Category Pills block. `entries` arrives already filtered.\n */\n categorySlug?: string\n /**\n * Whether the read behind `entries` stopped at its `.limit()` (AGL-1516).\n * Travels with the entries because the search boxes downstream have to say\n * what they actually searched, and — with the liveness gate and the route's\n * category filter both in between — `entries.length` no longer tells them.\n */\n entriesReachedBound?: boolean\n /**\n * Where `entries` begins in the collection's own order (AGL-3213).\n *\n * Zero for every listing served from the cached read. A page past that\n * read's bound carries only its own ten entries, and the block windows\n * `[(page - 1) * perPage, …)` — so without this it would slice from an\n * offset its own array never reaches and render an empty listing.\n */\n windowStart?: number\n /**\n * Whether {@link slug} is a cache KEY rather than an address (AGL-2524).\n *\n * The author page mixes collections, and the compose pipeline keys entry\n * sources by collection slug — so it hands over a synthetic one\n * (`AUTHOR_ENTRIES_SOURCE_SLUG`) with its entries already in hand. That is\n * harmless for the entries block, whose every row carries its OWN\n * `collectionSlug` and builds `entry.url` from it, and for the search box,\n * whose index is built the same way.\n *\n * It is NOT harmless for anything that builds a URL from the source's slug\n * itself. Set this and such a block resolves nothing rather than pointing\n * readers at `/{synthetic}/…`.\n */\n routeless?: boolean\n}\n\ninterface CollectionBlockScan {\n slugs: Set<string>\n hasRelated: boolean\n hasCategories: boolean\n hasSearch: boolean\n}\n\n/**\n * Which collections does this tree ask for (AGL-1152)?\n *\n * Extracted so the PREFETCH below and the expansion that consumes it read the\n * tree through one function rather than two copies of the same predicate. A\n * divergence between them is not a type error — it is a slug prefetched and\n * never awaited, or (worse) a slug the prefetch missed that then pays the full\n * serial read anyway — so there is deliberately no second implementation to\n * drift.\n */\nfunction scanCollectionBlocks(\n nodes: Record<string, any>,\n collection?: ComposeCollectionContext,\n): CollectionBlockScan {\n const slugs = new Set<string>()\n let hasRelated = false\n let hasCategories = false\n let hasSearch = false\n for (const node of Object.values(nodes)) {\n if (node?.componentId === Aglyn.COLLECTION_ENTRIES_COMPONENT_ID) {\n const slug =\n String(node?.props?.collectionSlug ?? '').trim() || collection?.slug\n if (slug) slugs.add(slug)\n }\n // Category pills (AGL-1321) need only the taxonomy, but it rides on the\n // same source, so they count as a reason to resolve one.\n if (node?.componentId === Aglyn.COLLECTION_CATEGORIES_COMPONENT_ID) {\n const slug =\n String(node?.props?.collectionSlug ?? '').trim() || collection?.slug\n if (slug) {\n hasCategories = true\n slugs.add(slug)\n }\n }\n // The standalone toolbar search box (AGL-1516, Figma 494:1220) needs the\n // collection's entries to index, and rides the same source the listing\n // beside it was built from — one read, one answer.\n if (node?.componentId === Aglyn.COLLECTION_SEARCH_COMPONENT_ID) {\n const slug =\n String(node?.props?.collectionSlug ?? '').trim() || collection?.slug\n if (slug) {\n hasSearch = true\n slugs.add(slug)\n }\n }\n // Related posts (AGL-582) always resolve against the ROUTED collection\n // — they only mean something with a current entry in context.\n if (\n node?.componentId === Aglyn.COLLECTION_RELATED_COMPONENT_ID &&\n collection?.slug &&\n collection.entry\n ) {\n hasRelated = true\n slugs.add(collection.slug)\n }\n }\n return { slugs, hasRelated, hasCategories, hasSearch }\n}\n\n/**\n * Issue the collection reads AS SOON AS THE SCREEN NODES EXIST (AGL-1152).\n *\n * `getPublishedCollectionSource` is three SEQUENTIAL round trips —\n * `findContentCollection`, then `listLiveEntries`, then `attachEntryAuthors`\n * — and until this existed it ran after the chrome bundle had already been\n * awaited, so a page carrying a Collection entries block paid the whole thing\n * as a serial tail on the compose phase. Measured on the tenant: a page with\n * no collection block composes in ~20 ms, one with a block in ~572 ms, and the\n * tree work itself accounts for under 2 ms of that at 50 entries. The gap is\n * this read, waiting for reads it shares nothing with.\n *\n * Same shape as `screenDatasetsPromise` directly below, and the same caveat\n * applies: the SCREEN's own nodes are a fast path, NOT the correctness gate. A\n * collection block can arrive from a layout or a grafted reusable component,\n * neither of which exists yet at this point, so the real scan still runs\n * against the composed tree and still fetches anything this missed.\n *\n * The ROUTED collection is deliberately excluded when its entries are already\n * in hand: the expansion below answers that slug from `collection.entries`\n * without reading at all, so prefetching it would buy a read nobody awaits.\n */\nfunction prefetchCollectionSources(\n hostId: string,\n screenNodes: Record<string, any>,\n collection?: ComposeCollectionContext,\n): Record<string, Promise<PublishedCollectionSource>> {\n const prefetched: Record<\n string,\n Promise<PublishedCollectionSource>\n > = {}\n for (const slug of scanCollectionBlocks(screenNodes, collection).slugs) {\n if (slug === collection?.slug && collection.entries) continue\n const pending = getPublishedCollectionSource({\n hostId,\n collectionSlug: slug,\n })\n // Marked handled the moment it exists, for the reason `composed` is in\n // `composeScreenNodes`: a tree that turns out not to need this slug never\n // awaits it, and an unawaited rejection takes the process down rather than\n // failing this one read. The real await below still sees the rejection.\n void pending.catch(() => undefined)\n prefetched[slug] = pending\n }\n return prefetched\n}\n\n/**\n * Expands Collection entries blocks (AGL-551) against their collections'\n * published entries, and Related posts blocks (AGL-582) against the routed\n * entry. Fetches lazily — screens without the blocks cost nothing — and\n * fails open on lookup errors like every other compose stage.\n */\nasync function expandCollectionEntryBlocks(\n hostId: string,\n nodes: Record<string, any>,\n collection?: ComposeCollectionContext,\n prefetched?: Record<string, Promise<PublishedCollectionSource>>,\n): Promise<Record<string, any>> {\n const { slugs, hasRelated, hasCategories, hasSearch } = scanCollectionBlocks(\n nodes,\n collection,\n )\n if (!slugs.size) return nodes\n const sources: Record<string, Aglyn.CollectionEntriesSource> = {}\n await Promise.all(\n [...slugs].map(async (slug) => {\n // The routed collection rides its already-fetched entries +\n // categories (AGL-582); other collections fetch both on demand.\n if (slug === collection?.slug && collection.entries) {\n sources[slug] = {\n slug,\n entries: collection.entries,\n categories: collection.categories,\n // Only the ROUTED collection carries the URL's page (AGL-1321) — a\n // block bound to another collection must not inherit it.\n ...(collection.page ? { page: collection.page } : {}),\n // ...nor its bound (AGL-1516): this is a fact about the read the\n // route already performed, and it describes only that collection.\n ...(collection.entriesReachedBound ? { reachedBound: true } : {}),\n // ...nor where its window starts (AGL-3213), for the same reason:\n // it is a fact about the read this route performed.\n ...(collection.windowStart\n ? { windowStart: collection.windowStart }\n : {}),\n }\n return\n }\n // The prefetch when this slug was visible on the screen's own nodes;\n // a live read when it only appeared after layout/component grafting.\n const fetched = await (prefetched?.[slug] ??\n getPublishedCollectionSource({ hostId, collectionSlug: slug }))\n sources[slug] = {\n slug,\n entries: fetched.entries,\n categories:\n slug === collection?.slug && collection.categories\n ? collection.categories\n : fetched.categories,\n ...(fetched.reachedBound ? { reachedBound: true } : {}),\n }\n }),\n )\n const expanded = Aglyn.expandCollectionEntries(\n nodes,\n sources,\n collection?.slug,\n )\n const withCategories = hasCategories\n ? Aglyn.expandCollectionCategories(\n expanded,\n sources,\n /*\n No DEFAULT collection for the pills on a routeless page (AGL-2524).\n\n Category pills are the one block that builds its links from the\n SOURCE's slug — `/{slug}/category/{x}` — because a category is a\n filter on one collection's own listing. On the author page that slug\n is synthetic, so an unbound pills block stamped links to a route that\n does not exist.\n\n Rendering nothing is the honest answer rather than a fallback. The\n page spans every collection, and there is no\n `/author/{slug}/category/{x}` for a pill to lead to; the only real\n destination would be some collection's listing, which drops the\n author the reader is looking at. A pills block that NAMES a\n collection is unaffected and still resolves that collection's own\n taxonomy — \"browse the blog by category\", which is a sensible thing\n to put beside an archive.\n */\n collection?.routeless ? undefined : collection?.slug,\n collection?.categorySlug,\n )\n : expanded\n const withSearch = hasSearch\n ? Aglyn.expandCollectionSearch(withCategories, sources, collection?.slug)\n : withCategories\n if (!hasRelated || !collection?.entry) return withSearch\n return Aglyn.expandCollectionRelated(\n withSearch,\n sources[collection.slug],\n collection.entry,\n )\n}\n\n/**\n * Shared post-version composition (AGL-551, extracted from\n * `composeScreenNodes`): layout chrome, reusable components, repeatables,\n * collection entries, bindings, function definitions, plugin installs,\n * named tokens, denormalize, and last the current facts of each placed image\n * and film, and of the social card the page is shared as. The screen path and\n * the collection-fallback\n * path (which has no screen doc) build identical trees through this one\n * pipeline.\n */\nexport async function composeNodesWithChrome(options: {\n hostId: string\n /**\n * The layout binding, or a PROMISE of it.\n *\n * The unresolved form exists for the same reason `screenNodes` accepts one\n * (AGL-1428): the binding may live on the version document (key-present\n * wins over the screen's), and awaiting the version before this call would\n * put every host-scoped read back behind it. Only the layout-chain walk\n * consumes the binding, so it alone waits; the rest of the chrome bundle\n * still starts immediately.\n */\n layoutId?:\n | string\n | null\n | Promise<string | null | undefined>\n /**\n * The screen's values for the properties of the layouts it renders inside\n * (AGL-2893), keyed by layout id — or a PROMISE of them, for the reason\n * `layoutId` accepts one: they live on the version document beside the\n * binding. Absent, every layout renders its properties' defaults.\n */\n layoutPropValues?:\n | Aglyn.AglynScreenVersion['layoutPropValues']\n | null\n | Promise<Aglyn.AglynScreenVersion['layoutPropValues'] | null | undefined>\n /**\n * The screen's own nodes, or a PROMISE of them (AGL-1428).\n *\n * Accepting the unresolved form is what lets `composeScreenNodes` hand the\n * version read over before it has finished, so the host-scoped reads below\n * — none of which look at these nodes — overlap it instead of queueing\n * behind it. Passing a resolved value behaves exactly as before, which is\n * what every other caller does.\n */\n screenNodes: Record<string, any> | Promise<Record<string, any>>\n /** Entry-template tokens (AGL-105) substituted before denormalize. */\n tokens?: Record<string, string>\n /** Routed content collection (AGL-551) for Collection entries blocks. */\n collection?: ComposeCollectionContext\n /**\n * The host document, for `host.*` tokens (AGL-1022).\n *\n * Passed in rather than read here: every caller already holds it, and\n * `composeScreenNodes` was the largest single render phase before AGL-1225\n * cut it to one round trip — adding a read back would spend that win on\n * data we already have in hand.\n */\n host?: Aglyn.HostTokenSource | null\n /**\n * The social card the head shares this page as (AGL-2850). The documents of\n * the assets it names are read in the same batch as the images and films\n * the tree places.\n */\n socialImages?: ComposeSocialImages\n}): Promise<Record<string, any>> {\n const { hostId, layoutId } = options\n\n /**\n * The layout chain, innermost first (AGL-703).\n *\n * A layout may itself render inside another layout, so this walks the\n * `layoutId` pointers rather than reading one. Fetching is sequential\n * because each step's parent is only known once the previous layout\n * document is in hand — but the walk is short by construction\n * (MAX_LAYOUT_CHAIN_DEPTH) and every layout is already a cached read.\n *\n * `seen` stops a cycle from looping forever. Stored data can hold one\n * even though both the console and `canNestLayout` refuse to create it:\n * the API, a script, or a restored backup can all write a layout\n * document directly, and a render must degrade rather than hang.\n */\n const walkLayoutChain = async () => {\n const chain: Aglyn.LayoutChainEntry[] = []\n const seen = new Set<string>()\n const boundLayoutId = await layoutId\n let currentLayoutId = boundLayoutId ? String(boundLayoutId) : undefined\n while (\n currentLayoutId &&\n !seen.has(currentLayoutId) &&\n chain.length < Aglyn.MAX_LAYOUT_CHAIN_DEPTH\n ) {\n seen.add(currentLayoutId)\n const layoutRes = await getPublishedLayoutVersion({\n hostId,\n layoutId: currentLayoutId,\n })\n // The version's declared properties travel with its nodes (AGL-2893):\n // they are applied to this layout alone, with the screen's values for\n // this layout, before the screen is grafted into its slot.\n chain.push({\n layoutId: currentLayoutId,\n nodes: layoutRes?.version?.nodes as any,\n props: (layoutRes?.version as Aglyn.AglynLayoutVersion | undefined)\n ?.props,\n })\n const parentId = (layoutRes?.layout as any)?.layoutId\n currentLayoutId = parentId ? String(parentId) : undefined\n }\n return chain\n }\n\n /**\n * ONE round-trip stage instead of three (AGL-1225).\n *\n * This used to be `await layout walk` → `await getComponents` → `await\n * Promise.all([five reads])`: three sequential waits, where only the first\n * has any reason to be sequential. Every one of the other six reads takes\n * `hostId` and nothing else — none of them consumes the layout chain — so\n * they were waiting on a walk whose result they never look at.\n *\n * The walk stays internally sequential because it genuinely is: each step's\n * parent id is only known once the previous layout document is in hand. It\n * just no longer gates anything else. The critical path becomes the walk\n * alone rather than walk + components + bulk.\n *\n * Measured budget that motivated this (production, `/product/besigner`):\n * `composeScreenNodes` was the largest single phase at 1577 ms cold and\n * consistently ~1.4-1.6 s warm too, so this is not cold-start cost. The\n * existing `AGL-1152:render` timing line reports `composeScreenNodes` as a\n * phase, so the effect of this shows up there directly — no new\n * instrumentation, and a regression would be visible in the same place.\n */\n /**\n * Does the SCREEN itself repeat over a dataset (AGL-1440)?\n *\n * Asked as soon as the nodes are in hand — which since AGL-1428 is after\n * the fan-out has been ISSUED rather than before — so the overwhelmingly\n * common case still keeps the AGL-1225 shape: a page that repeats almost\n * always says so on its own document, and its datasets read goes out\n * alongside the remaining chrome reads instead of after them.\n *\n * It is deliberately NOT the correctness gate — a repeatable can arrive from\n * a layout or a grafted reusable component, neither of which exists yet. The\n * gate is re-asked against the composed tree after grafting, which is the\n * exact input `expandRepeatables` reads.\n */\n /*\n * ISSUE THE HOST-SCOPED READS FIRST, THEN AWAIT THE NODES (AGL-1428).\n *\n * Every read in this bundle is keyed by `hostId` (and `layoutId`) alone —\n * none of them reads `screenNodes` — so starting them before the nodes are\n * in hand lets `composeScreenNodes`' version read overlap them rather than\n * run ahead of them. `Promise.all` is created BEFORE the `await` below on\n * purpose: that is the line that makes the two independent, and moving the\n * await above it silently gives the whole saving back.\n *\n * `getDatasets` is the one read that genuinely depends on the nodes, so it\n * cannot join the bundle. It does not have to wait for the bundle either —\n * it is issued the moment the nodes resolve and awaited after, so it still\n * overlaps whatever is left of the chrome reads instead of costing the\n * extra serial round trip that dropping it from the batch would imply.\n */\n const chromeBundle = Promise.all([\n walkLayoutChain(),\n getComponents({ hostId }),\n // Host variable + function bindings (AGL-91/93): {{name}} and\n // {{fn:name(args)}} in string props resolve to values; unknown tokens\n // and failed runs stay literal.\n Promise.all([\n getVariables({ hostId }),\n getFunctions({ hostId }),\n getWorkflows({ hostId }),\n getPluginInstalls({ hostId }),\n ]),\n ])\n const screenNodes = await options.screenNodes\n const screenDatasetKeys = Aglyn.repeatDatasetKeys(screenNodes)\n const screenDatasetsPromise = screenDatasetKeys.length\n ? getDatasets({ hostId, keys: screenDatasetKeys })\n : undefined\n // Does the SCREEN itself place a form entity? Gated and re-asked exactly\n // like the datasets read beside it (AGL-1440): most pages carry no form, and\n // the ones that do usually say so on their own document, so the read goes\n // out here alongside the chrome reads instead of as a serial tail. It is not\n // the correctness gate — a placed form can arrive from a layout or a grafted\n // component — so the composed tree is asked again below.\n const screenFormsPromise = Aglyn.placesFormEntity(screenNodes)\n ? getForms({ hostId })\n : undefined\n // Issued HERE, beside the datasets read and before the chrome bundle is\n // awaited, so the collection read overlaps it instead of trailing it.\n const prefetchedSources = prefetchCollectionSources(\n hostId,\n screenNodes,\n options.collection,\n )\n const [layoutChain, componentsRes, bulk] = await chromeBundle\n const [rawVariables, functions, workflows, pluginInstalls] = bulk\n const screenDatasets = await screenDatasetsPromise\n // Settled by now: it is read off the same version document the layout\n // binding the walk above waited on came from.\n const layoutPropValues = await options.layoutPropValues\n\n const composedNodes = Aglyn.composeLayoutChainWithProps(\n layoutChain as any,\n screenNodes as any,\n layoutPropValues,\n )\n const graftedComponents = Aglyn.composeReusableComponentNodes(\n composedNodes as any,\n componentsRes.definitions as any,\n )\n /*\n * PLACED FORMS RESOLVE AGAINST THEIR ENTITY (`docs/specs/reusable-forms.md`).\n *\n * A form node bound to `hosts/{hostId}/forms/{formId}` renders that entity's\n * published design, so a form is edited once and every page placing it\n * follows. Without this the entity's tree was written on every publish and\n * read by nothing: the fields had to be redrawn per page, and the two copies\n * diverged the moment either was touched.\n *\n * The gate is the COMPONENT-grafted tree, not the screen's own nodes, for\n * the reason the repeatables gate below states: a form placed inside a\n * layout or a shared component does not exist in `screenNodes`, and a page\n * that renders one would silently keep its stale inline copy.\n *\n * The second graft re-runs the component expansion deliberately. Instances\n * already expanded are skipped by their own prefix, so the repeat costs a\n * scan, and passing BOTH placement kinds is what expands a reusable\n * component nested inside a form's design — which the first pass could not\n * have seen, because that subtree was not in the tree yet.\n */\n const forms =\n (await screenFormsPromise)?.forms ??\n (Aglyn.placesFormEntity(graftedComponents as any)\n ? (await getForms({ hostId })).forms\n : undefined)\n const grafted = forms\n ? Aglyn.composeReusableComponentNodes(\n graftedComponents as any,\n componentsRes.definitions as any,\n [Aglyn.placedFormPlacement(forms as any)],\n )\n : graftedComponents\n // Computed variables (AGL-129): workflow-backed values resolve once per\n // compose; failures keep each variable's stored fallback.\n const variables = Aglyn.resolveComputedVariables(\n rawVariables,\n functions,\n workflows,\n )\n // Repeatables (AGL-103) expand after grafting (so they work inside\n // reusable components) and before bindings (so {{name}} tokens inside\n // cloned items still resolve).\n //\n // Only the datasets this tree repeats over are read (AGL-1440), and the tree\n // asked is the composed one — after grafting — because that is the map the\n // expansion reads: a repeatable living in a layout or a reusable component\n // is invisible in `screenNodes`, and reading only the screen's keys would\n // silently render one template row where the author put a list. The\n // screen's own keys were issued beside the chrome reads above; a key only a\n // layout or a component adds is read here, for that key alone.\n const datasetKeys = Aglyn.repeatDatasetKeys(grafted as any)\n const unreadDatasetKeys = datasetKeys.filter(\n (key) => !screenDatasetKeys.includes(key),\n )\n const datasets = unreadDatasetKeys.length\n ? {\n ...screenDatasets,\n ...(await getDatasets({ hostId, keys: unreadDatasetKeys })),\n }\n : screenDatasets\n const repeated = Aglyn.expandRepeatables(grafted as any, datasets)\n // Collection entries blocks (AGL-551) expand alongside repeatables:\n // per-entry {{entry.*}} tokens substitute inside the clones here, while\n // page-level tokens wait for resolveNamedTokens below.\n const withEntries = await expandCollectionEntryBlocks(\n hostId,\n repeated,\n options.collection,\n prefetchedSources,\n )\n // Entry Meta blocks (AGL-1385): fill in the routed entry's date/category/\n // tags. Needs no source fetch — the routed entry and its taxonomy are\n // already in hand — so it sits outside `expandCollectionEntryBlocks`, which\n // returns early when no block asks for a collection read. AFTER it, so the\n // per-entry clones it just produced already carry their own resolved values\n // and are skipped.\n const withEntryMeta = Aglyn.expandCollectionEntryMeta(\n withEntries as any,\n options.collection?.entry,\n options.collection?.categories,\n )\n // Entry Author cards (AGL-2486): the same fill, one block over. Its values\n // come off the author RECORD the routed entry resolved to, which the\n // collection read has already attached, so this costs nothing either.\n const withEntryAuthor = Aglyn.expandCollectionEntryAuthor(\n withEntryMeta as any,\n options.collection?.entry,\n )\n const bound = Aglyn.resolveNodesBindings(\n withEntryAuthor as any,\n variables,\n functions,\n )\n // Host variables (AGL-1022): `{{host.*}}` resolves from the INSTALLING\n // site, late — at render, never at install — so a rebrand propagates to\n // every artifact that names the host instead of hard-coding it. Same\n // registry the email path uses, so a token means one thing in both.\n const withHostTokens = Aglyn.resolveNodesHostTokens(\n bound as any,\n options.host,\n )\n // Function widgets run client-side: embed their definitions (AGL-93), and\n // beside each the site variables that function reads and no others\n // (AGL-3202).\n const withFunctions = Aglyn.attachFunctionDefinitions(\n withHostTokens,\n functions,\n variables,\n )\n // Marketplace plugins (AGL-45): stamp each marketplacePlugin node with its\n // pinned install (version/sha256/capabilities) + kill-switch state.\n const nodes = Aglyn.attachPluginInstalls(withFunctions, pluginInstalls)\n // Entry-template tokens (AGL-105): {{entry.*}} from the rendered entry.\n const finalNodes = Aglyn.resolveNamedTokens(nodes as any, options.tokens)\n // The document's one `main` landmark (AGL-2486). LAST, so it reads the tree\n // the page actually ships — a slot grafted from a layout chain, an element\n // an author chose — rather than the screen as stored.\n const withLandmark = Aglyn.stampDocumentLandmark(finalNodes as any)\n // Each form's dataset binding, signed so the submit route can trust it\n // (AGL-2773). Read off THIS tree, the one the page ships, so a form grafted\n // from a layout, a component or a form entity is signed as it renders; and\n // after every stage that rewrites props, so nothing changes what the\n // signature covers.\n const withFormBindings = stampFormDatasetBindings(withLandmark, hostId)\n const denormalized = Aglyn.canvas.processNodesToDenormalized(\n withFormBindings as any,\n )\n /*\n * WHAT EACH PLACED IMAGE AND FILM IS NOW (AGL-2807, AGL-2833).\n *\n * An image's pixel pair and a film's length, shape and poster come from the\n * asset's DAM document as it is now, not as it was when it was picked. LAST,\n * on the tree the page ships, because an asset can arrive from any stage\n * above: a layout, a component, a form, a repeated row, a collection entry,\n * a binding.\n *\n * ONE read for all of them, issued once the tree is final rather than beside\n * the chrome reads. A read issued there could only cover the screen's own\n * placements, and nearly every layout places an image the screen does not\n * (a logo, a footer mark), so it would buy a second read on almost every\n * page. The late read costs its own round trip after the chrome reads, paid\n * each time a page is composed, which for a published page is its ISR\n * regeneration. A tree with no library asset, on a page whose social card\n * names none, issues none.\n *\n * THE SOCIAL CARD'S ASSETS JOIN THE SAME READ (AGL-2850), ahead of the\n * placements. A placement the cap leaves out keeps its pick-time pair, which\n * for an image is a reservation the decoded picture corrects. Nothing\n * corrects a card's pair, because a crawler lays the card out from it before\n * fetching the image, and a card names at most three documents.\n */\n const socialImages = options.socialImages\n const cardRefs = socialImages ? socialImageRefs(socialImages.images) : []\n const refs = mediaAssetRefs(denormalized)\n if (!refs.length && !cardRefs.length) return denormalized\n const facts = await getMediaAssetFacts({\n hostId,\n refs: [...cardRefs, ...refs],\n })\n if (socialImages) {\n const cardFacts = socialImageAssetFacts(socialImages.images, facts)\n if (cardFacts) socialImages.onFacts(cardFacts)\n }\n return applyMediaAssetFacts(denormalized, facts)\n}\n\n/**\n * Full published-render composition for one screen (extracted for AGL-87 so\n * the SSG path and the password-unlock API build identical trees): applies\n * a due publish schedule, loads the version, composes the shared layout\n * chrome, grafts reusable components, and denormalizes.\n */\nexport async function composeScreenNodes(options: {\n hostId: string\n screenId: string\n screen: Aglyn.AglynScreen\n /** Entry-template tokens (AGL-105) substituted before denormalize. */\n tokens?: Record<string, string>\n /** Routed content collection (AGL-551) for Collection entries blocks. */\n collection?: ComposeCollectionContext\n /**\n * Compose a specific version instead of the published one (AGL-253):\n * experiment variants point at versions; schedules don't apply.\n */\n versionId?: string\n /** The host document, for `host.*` tokens (AGL-1022). */\n host?: Aglyn.HostTokenSource | null\n /** The social card the head shares this page as (AGL-2850). */\n socialImages?: ComposeSocialImages\n}): Promise<Record<string, any> | null> {\n const { hostId, screenId, screen } = options\n\n const effectiveVersionId = options.versionId\n ? null\n : await applyDuePublishSchedule({\n hostId,\n collectionName: 'screens',\n docId: screenId,\n parent: screen,\n })\n const versionId = (options.versionId ??\n effectiveVersionId ??\n screen.versionId) as string\n\n /*\n * OVERLAP THE VERSION READ WITH THE CHROME BUNDLE (AGL-1428).\n *\n * `composeNodesWithChrome`'s reads are keyed by `hostId`/`layoutId`, both\n * of which are in hand here, so the version read no longer has to finish\n * before they start. Handing the promise over instead of the resolved\n * value is the whole change: it is `composeNodesWithChrome` that decides\n * when it actually needs the nodes.\n *\n * The `versionId`-less screen still exits BEFORE anything is issued, so a\n * screen that has never been published costs no reads. What remains is the\n * narrow case of a `versionId` that points at a missing or unreadable\n * version document — a data-integrity fault rather than a routing outcome\n * — and that one now pays for a chrome bundle it discards. That is the\n * deliberate trade: one wasted bundle on a broken screen, against the\n * version read hiding under the chrome reads on every render that works.\n */\n if (!versionId) return null\n\n const versionPromise = getScreenVersion({ hostId, screenId, versionId })\n\n /*\n * NEITHER DERIVED PROMISE MAY REJECT ON ITS OWN.\n *\n * `composed` is discarded on every failure path below, and a rejected\n * promise nobody awaited is an unhandled rejection — which in Node takes\n * the whole render process down instead of letting this 404. So the nodes\n * handed to the compose absorb the failure (an empty tree it will never be\n * asked for), and `composed` gets a rejection handler attached the moment\n * it exists rather than at the point we decide to drop it. The real error\n * still propagates, from the `await versionPromise` below, exactly where it\n * did when this function awaited the version directly.\n */\n const composed = composeNodesWithChrome({\n hostId,\n // Version-first (key-present wins, null = explicitly no layout), screen\n // fallback — resolved as a promise so only the layout-chain walk waits on\n // the version read; the rest of the chrome bundle keeps the AGL-1428\n // overlap. A failed version read falls back to the screen binding; the\n // whole compose is discarded on that path anyway.\n layoutId: versionPromise.then(\n (res) =>\n res.version && 'layoutId' in res.version\n ? ((res.version as Aglyn.AglynScreenVersion).layoutId as\n | string\n | null)\n : (screen.layoutId as string | undefined),\n () => screen.layoutId as string | undefined,\n ),\n // The screen's values for its layouts' properties (AGL-2893), beside the\n // binding on the same document; a failed read renders the defaults.\n layoutPropValues: versionPromise.then(\n (res) =>\n (res.version as Aglyn.AglynScreenVersion | undefined)?.layoutPropValues,\n () => undefined,\n ),\n screenNodes: versionPromise.then(\n (res) => (res.version?.nodes ?? {}) as any,\n () => ({}) as any,\n ),\n tokens: options.tokens,\n collection: options.collection,\n host: options.host,\n socialImages: options.socialImages,\n })\n void composed.catch(() => undefined)\n\n const versionRes = await versionPromise\n if (versionRes.error || !versionRes.version) return null\n\n return composed\n}\n\nexport default composeScreenNodes\n"],"names":["Aglyn","applyMediaAssetFacts","mediaAssetRefs","applyDuePublishSchedule","getComponents","getDatasets","getForms","getMediaAssetFacts","getPublishedCollectionSource","getPluginInstalls","getVariables","getFunctions","getWorkflows","getPublishedLayoutVersion","getScreenVersion","socialImageAssetFacts","socialImageRefs","stampFormDatasetBindings","scanCollectionBlocks","nodes","collection","slugs","Set","hasRelated","hasCategories","hasSearch","node","Object","values","componentId","COLLECTION_ENTRIES_COMPONENT_ID","slug","String","props","collectionSlug","trim","add","COLLECTION_CATEGORIES_COMPONENT_ID","COLLECTION_SEARCH_COMPONENT_ID","COLLECTION_RELATED_COMPONENT_ID","entry","prefetchCollectionSources","hostId","screenNodes","prefetched","entries","pending","catch","undefined","expandCollectionEntryBlocks","size","sources","Promise","all","map","categories","page","entriesReachedBound","reachedBound","windowStart","fetched","expanded","expandCollectionEntries","withCategories","expandCollectionCategories","routeless","categorySlug","withSearch","expandCollectionSearch","expandCollectionRelated","composeNodesWithChrome","options","layoutId","walkLayoutChain","chain","seen","boundLayoutId","currentLayoutId","has","length","MAX_LAYOUT_CHAIN_DEPTH","layoutRes","push","version","parentId","layout","chromeBundle","screenDatasetKeys","repeatDatasetKeys","screenDatasetsPromise","keys","screenFormsPromise","placesFormEntity","prefetchedSources","layoutChain","componentsRes","bulk","rawVariables","functions","workflows","pluginInstalls","screenDatasets","layoutPropValues","composedNodes","composeLayoutChainWithProps","graftedComponents","composeReusableComponentNodes","definitions","forms","grafted","placedFormPlacement","variables","resolveComputedVariables","datasetKeys","unreadDatasetKeys","filter","key","includes","datasets","repeated","expandRepeatables","withEntries","withEntryMeta","expandCollectionEntryMeta","withEntryAuthor","expandCollectionEntryAuthor","bound","resolveNodesBindings","withHostTokens","resolveNodesHostTokens","host","withFunctions","attachFunctionDefinitions","attachPluginInstalls","finalNodes","resolveNamedTokens","tokens","withLandmark","stampDocumentLandmark","withFormBindings","denormalized","canvas","processNodesToDenormalized","socialImages","cardRefs","images","refs","facts","cardFacts","onFacts","composeScreenNodes","screenId","screen","effectiveVersionId","versionId","collectionName","docId","parent","versionPromise","composed","then","res","versionRes","error"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,YAAYA,WAAW,sBAAqB;AAC5C,uEAAuE;AACvE,sDAAsD;AACtD,SACEC,oBAAoB,EACpBC,cAAc,QACT,2CAA0C;AACjD,OAAOC,6BAA6B,8BAA0B;AAC9D,OAAOC,mBAAmB,sBAAkB;AAC5C,OAAOC,iBAAiB,oBAAgB;AACxC,OAAOC,cAAc,iBAAa;AAClC,OAAOC,wBAAwB,6BAAyB;AACxD,SACEC,4BAA4B,QAEvB,8BAA0B;AACjC,OAAOC,uBAAuB,2BAAuB;AACrD,OAAOC,gBAAgBC,YAAY,EAAEC,YAAY,QAAQ,qBAAiB;AAC1E,OAAOC,+BAA+B,0BAAsB;AAC5D,OAAOC,sBAAsB,0BAAsB;AACnD,SAEEC,qBAAqB,EACrBC,eAAe,QACV,0BAAsB;AAC7B,SAASC,wBAAwB,QAAQ,mCAA+B;AAwExE;;;;;;;;;CASC,GACD,SAASC,qBACPC,KAA0B,EAC1BC,UAAqC;IAErC,MAAMC,QAAQ,IAAIC;IAClB,IAAIC,aAAa;IACjB,IAAIC,gBAAgB;IACpB,IAAIC,YAAY;IAChB,KAAK,MAAMC,QAAQC,OAAOC,MAAM,CAACT,OAAQ;QACvC,IAAIO,CAAAA,wBAAAA,KAAMG,WAAW,MAAK7B,MAAM8B,+BAA+B,EAAE;;gBAEtDJ;YADT,MAAMK,OACJC,eAAON,yBAAAA,cAAAA,KAAMO,KAAK,qBAAXP,YAAaQ,cAAc,mBAAI,IAAIC,IAAI,OAAMf,8BAAAA,WAAYW,IAAI;YACtE,IAAIA,MAAMV,MAAMe,GAAG,CAACL;QACtB;QACA,wEAAwE;QACxE,yDAAyD;QACzD,IAAIL,CAAAA,wBAAAA,KAAMG,WAAW,MAAK7B,MAAMqC,kCAAkC,EAAE;;gBAEzDX;YADT,MAAMK,OACJC,gBAAON,yBAAAA,eAAAA,KAAMO,KAAK,qBAAXP,aAAaQ,cAAc,oBAAI,IAAIC,IAAI,OAAMf,8BAAAA,WAAYW,IAAI;YACtE,IAAIA,MAAM;gBACRP,gBAAgB;gBAChBH,MAAMe,GAAG,CAACL;YACZ;QACF;QACA,yEAAyE;QACzE,uEAAuE;QACvE,mDAAmD;QACnD,IAAIL,CAAAA,wBAAAA,KAAMG,WAAW,MAAK7B,MAAMsC,8BAA8B,EAAE;;gBAErDZ;YADT,MAAMK,OACJC,gBAAON,yBAAAA,eAAAA,KAAMO,KAAK,qBAAXP,aAAaQ,cAAc,oBAAI,IAAIC,IAAI,OAAMf,8BAAAA,WAAYW,IAAI;YACtE,IAAIA,MAAM;gBACRN,YAAY;gBACZJ,MAAMe,GAAG,CAACL;YACZ;QACF;QACA,uEAAuE;QACvE,8DAA8D;QAC9D,IACEL,CAAAA,wBAAAA,KAAMG,WAAW,MAAK7B,MAAMuC,+BAA+B,KAC3DnB,8BAAAA,WAAYW,IAAI,KAChBX,WAAWoB,KAAK,EAChB;YACAjB,aAAa;YACbF,MAAMe,GAAG,CAAChB,WAAWW,IAAI;QAC3B;IACF;IACA,OAAO;QAAEV;QAAOE;QAAYC;QAAeC;IAAU;AACvD;AAEA;;;;;;;;;;;;;;;;;;;;;CAqBC,GACD,SAASgB,0BACPC,MAAc,EACdC,WAAgC,EAChCvB,UAAqC;IAErC,MAAMwB,aAGF,CAAC;IACL,KAAK,MAAMb,QAAQb,qBAAqByB,aAAavB,YAAYC,KAAK,CAAE;QACtE,IAAIU,UAASX,8BAAAA,WAAYW,IAAI,KAAIX,WAAWyB,OAAO,EAAE;QACrD,MAAMC,UAAUtC,6BAA6B;YAC3CkC;YACAR,gBAAgBH;QAClB;QACA,uEAAuE;QACvE,0EAA0E;QAC1E,2EAA2E;QAC3E,wEAAwE;QACxE,KAAKe,QAAQC,KAAK,CAAC,IAAMC;QACzBJ,UAAU,CAACb,KAAK,GAAGe;IACrB;IACA,OAAOF;AACT;AAEA;;;;;CAKC,GACD,eAAeK,4BACbP,MAAc,EACdvB,KAA0B,EAC1BC,UAAqC,EACrCwB,UAA+D;IAE/D,MAAM,EAAEvB,KAAK,EAAEE,UAAU,EAAEC,aAAa,EAAEC,SAAS,EAAE,GAAGP,qBACtDC,OACAC;IAEF,IAAI,CAACC,MAAM6B,IAAI,EAAE,OAAO/B;IACxB,MAAMgC,UAAyD,CAAC;IAChE,MAAMC,QAAQC,GAAG,CACf;WAAIhC;KAAM,CAACiC,GAAG,CAAC,OAAOvB;;QACpB,4DAA4D;QAC5D,gEAAgE;QAChE,IAAIA,UAASX,8BAAAA,WAAYW,IAAI,KAAIX,WAAWyB,OAAO,EAAE;YACnDM,OAAO,CAACpB,KAAK,GAAG;gBACdA;gBACAc,SAASzB,WAAWyB,OAAO;gBAC3BU,YAAYnC,WAAWmC,UAAU;eAG7BnC,WAAWoC,IAAI,GAAG;gBAAEA,MAAMpC,WAAWoC,IAAI;YAAC,IAAI,CAAC,GAG/CpC,WAAWqC,mBAAmB,GAAG;gBAAEC,cAAc;YAAK,IAAI,CAAC,GAG3DtC,WAAWuC,WAAW,GACtB;gBAAEA,aAAavC,WAAWuC,WAAW;YAAC,IACtC,CAAC;YAEP;QACF;QACA,qEAAqE;QACrE,qEAAqE;QACrE,MAAMC,UAAU,eAAOhB,8BAAAA,UAAY,CAACb,KAAK,mBACvCvB,6BAA6B;YAAEkC;YAAQR,gBAAgBH;QAAK;QAC9DoB,OAAO,CAACpB,KAAK,GAAG;YACdA;YACAc,SAASe,QAAQf,OAAO;YACxBU,YACExB,UAASX,8BAAAA,WAAYW,IAAI,KAAIX,WAAWmC,UAAU,GAC9CnC,WAAWmC,UAAU,GACrBK,QAAQL,UAAU;WACpBK,QAAQF,YAAY,GAAG;YAAEA,cAAc;QAAK,IAAI,CAAC;IAEzD;IAEF,MAAMG,WAAW7D,MAAM8D,uBAAuB,CAC5C3C,OACAgC,SACA/B,8BAAAA,WAAYW,IAAI;IAElB,MAAMgC,iBAAiBvC,gBACnBxB,MAAMgE,0BAA0B,CAC9BH,UACAV,SACA;;;;;;;;;;;;;;;;;QAiBA,GACA/B,CAAAA,8BAAAA,WAAY6C,SAAS,IAAGjB,YAAY5B,8BAAAA,WAAYW,IAAI,EACpDX,8BAAAA,WAAY8C,YAAY,IAE1BL;IACJ,MAAMM,aAAa1C,YACfzB,MAAMoE,sBAAsB,CAACL,gBAAgBZ,SAAS/B,8BAAAA,WAAYW,IAAI,IACtEgC;IACJ,IAAI,CAACxC,cAAc,EAACH,8BAAAA,WAAYoB,KAAK,GAAE,OAAO2B;IAC9C,OAAOnE,MAAMqE,uBAAuB,CAClCF,YACAhB,OAAO,CAAC/B,WAAWW,IAAI,CAAC,EACxBX,WAAWoB,KAAK;AAEpB;AAEA;;;;;;;;;CASC,GACD,OAAO,eAAe8B,uBAAuBC,OAuD5C;;QAyKI,OAyDDA,qBACAA,sBAOAA;IAzOF,MAAM,EAAE7B,MAAM,EAAE8B,QAAQ,EAAE,GAAGD;IAE7B;;;;;;;;;;;;;GAaC,GACD,MAAME,kBAAkB;QACtB,MAAMC,QAAkC,EAAE;QAC1C,MAAMC,OAAO,IAAIrD;QACjB,MAAMsD,gBAAgB,MAAMJ;QAC5B,IAAIK,kBAAkBD,gBAAgB5C,OAAO4C,iBAAiB5B;QAC9D,MACE6B,mBACA,CAACF,KAAKG,GAAG,CAACD,oBACVH,MAAMK,MAAM,GAAG/E,MAAMgF,sBAAsB,CAC3C;gBAWSC,oBACCA,qBAGQA;YAdlBN,KAAKvC,GAAG,CAACyC;YACT,MAAMI,YAAY,MAAMpE,0BAA0B;gBAChD6B;gBACA8B,UAAUK;YACZ;YACA,sEAAsE;YACtE,sEAAsE;YACtE,2DAA2D;YAC3DH,MAAMQ,IAAI,CAAC;gBACTV,UAAUK;gBACV1D,KAAK,EAAE8D,8BAAAA,qBAAAA,UAAWE,OAAO,qBAAlBF,mBAAoB9D,KAAK;gBAChCc,KAAK,EAAGgD,8BAAAA,sBAAAA,UAAWE,OAAO,qBAAnB,AAACF,oBACJhD,KAAK;YACX;YACA,MAAMmD,WAAYH,8BAAAA,oBAAAA,UAAWI,MAAM,qBAAlB,AAACJ,kBAA2BT,QAAQ;YACrDK,kBAAkBO,WAAWpD,OAAOoD,YAAYpC;QAClD;QACA,OAAO0B;IACT;IAEA;;;;;;;;;;;;;;;;;;;;GAoBC,GACD;;;;;;;;;;;;;GAaC,GACD;;;;;;;;;;;;;;;GAeC,GACD,MAAMY,eAAelC,QAAQC,GAAG,CAAC;QAC/BoB;QACArE,cAAc;YAAEsC;QAAO;QACvB,8DAA8D;QAC9D,sEAAsE;QACtE,gCAAgC;QAChCU,QAAQC,GAAG,CAAC;YACV3C,aAAa;gBAAEgC;YAAO;YACtB/B,aAAa;gBAAE+B;YAAO;YACtB9B,aAAa;gBAAE8B;YAAO;YACtBjC,kBAAkB;gBAAEiC;YAAO;SAC5B;KACF;IACD,MAAMC,cAAc,MAAM4B,QAAQ5B,WAAW;IAC7C,MAAM4C,oBAAoBvF,MAAMwF,iBAAiB,CAAC7C;IAClD,MAAM8C,wBAAwBF,kBAAkBR,MAAM,GAClD1E,YAAY;QAAEqC;QAAQgD,MAAMH;IAAkB,KAC9CvC;IACJ,yEAAyE;IACzE,6EAA6E;IAC7E,0EAA0E;IAC1E,6EAA6E;IAC7E,6EAA6E;IAC7E,yDAAyD;IACzD,MAAM2C,qBAAqB3F,MAAM4F,gBAAgB,CAACjD,eAC9CrC,SAAS;QAAEoC;IAAO,KAClBM;IACJ,wEAAwE;IACxE,sEAAsE;IACtE,MAAM6C,oBAAoBpD,0BACxBC,QACAC,aACA4B,QAAQnD,UAAU;IAEpB,MAAM,CAAC0E,aAAaC,eAAeC,KAAK,GAAG,MAAMV;IACjD,MAAM,CAACW,cAAcC,WAAWC,WAAWC,eAAe,GAAGJ;IAC7D,MAAMK,iBAAiB,MAAMZ;IAC7B,sEAAsE;IACtE,8CAA8C;IAC9C,MAAMa,mBAAmB,MAAM/B,QAAQ+B,gBAAgB;IAEvD,MAAMC,gBAAgBvG,MAAMwG,2BAA2B,CACrDV,aACAnD,aACA2D;IAEF,MAAMG,oBAAoBzG,MAAM0G,6BAA6B,CAC3DH,eACAR,cAAcY,WAAW;IAE3B;;;;;;;;;;;;;;;;;;;GAmBC,GACD,MAAMC,iBACH,QAAA,MAAMjB,uCAAP,AAAC,MAA2BiB,KAAK,mBAChC5G,MAAM4F,gBAAgB,CAACa,qBACpB,AAAC,CAAA,MAAMnG,SAAS;QAAEoC;IAAO,EAAC,EAAGkE,KAAK,GAClC5D;IACN,MAAM6D,UAAUD,QACZ5G,MAAM0G,6BAA6B,CACjCD,mBACAV,cAAcY,WAAW,EACzB;QAAC3G,MAAM8G,mBAAmB,CAACF;KAAc,IAE3CH;IACJ,wEAAwE;IACxE,0DAA0D;IAC1D,MAAMM,YAAY/G,MAAMgH,wBAAwB,CAC9Cf,cACAC,WACAC;IAEF,mEAAmE;IACnE,sEAAsE;IACtE,+BAA+B;IAC/B,EAAE;IACF,6EAA6E;IAC7E,2EAA2E;IAC3E,2EAA2E;IAC3E,0EAA0E;IAC1E,oEAAoE;IACpE,4EAA4E;IAC5E,+DAA+D;IAC/D,MAAMc,cAAcjH,MAAMwF,iBAAiB,CAACqB;IAC5C,MAAMK,oBAAoBD,YAAYE,MAAM,CAC1C,CAACC,MAAQ,CAAC7B,kBAAkB8B,QAAQ,CAACD;IAEvC,MAAME,WAAWJ,kBAAkBnC,MAAM,GACrC,aACKsB,gBACC,MAAMhG,YAAY;QAAEqC;QAAQgD,MAAMwB;IAAkB,MAE1Db;IACJ,MAAMkB,WAAWvH,MAAMwH,iBAAiB,CAACX,SAAgBS;IACzD,oEAAoE;IACpE,wEAAwE;IACxE,uDAAuD;IACvD,MAAMG,cAAc,MAAMxE,4BACxBP,QACA6E,UACAhD,QAAQnD,UAAU,EAClByE;IAEF,0EAA0E;IAC1E,sEAAsE;IACtE,4EAA4E;IAC5E,2EAA2E;IAC3E,4EAA4E;IAC5E,mBAAmB;IACnB,MAAM6B,gBAAgB1H,MAAM2H,yBAAyB,CACnDF,cACAlD,sBAAAA,QAAQnD,UAAU,qBAAlBmD,oBAAoB/B,KAAK,GACzB+B,uBAAAA,QAAQnD,UAAU,qBAAlBmD,qBAAoBhB,UAAU;IAEhC,2EAA2E;IAC3E,qEAAqE;IACrE,sEAAsE;IACtE,MAAMqE,kBAAkB5H,MAAM6H,2BAA2B,CACvDH,gBACAnD,uBAAAA,QAAQnD,UAAU,qBAAlBmD,qBAAoB/B,KAAK;IAE3B,MAAMsF,QAAQ9H,MAAM+H,oBAAoB,CACtCH,iBACAb,WACAb;IAEF,uEAAuE;IACvE,wEAAwE;IACxE,qEAAqE;IACrE,oEAAoE;IACpE,MAAM8B,iBAAiBhI,MAAMiI,sBAAsB,CACjDH,OACAvD,QAAQ2D,IAAI;IAEd,0EAA0E;IAC1E,mEAAmE;IACnE,cAAc;IACd,MAAMC,gBAAgBnI,MAAMoI,yBAAyB,CACnDJ,gBACA9B,WACAa;IAEF,2EAA2E;IAC3E,oEAAoE;IACpE,MAAM5F,QAAQnB,MAAMqI,oBAAoB,CAACF,eAAe/B;IACxD,wEAAwE;IACxE,MAAMkC,aAAatI,MAAMuI,kBAAkB,CAACpH,OAAcoD,QAAQiE,MAAM;IACxE,4EAA4E;IAC5E,2EAA2E;IAC3E,sDAAsD;IACtD,MAAMC,eAAezI,MAAM0I,qBAAqB,CAACJ;IACjD,uEAAuE;IACvE,4EAA4E;IAC5E,2EAA2E;IAC3E,qEAAqE;IACrE,oBAAoB;IACpB,MAAMK,mBAAmB1H,yBAAyBwH,cAAc/F;IAChE,MAAMkG,eAAe5I,MAAM6I,MAAM,CAACC,0BAA0B,CAC1DH;IAEF;;;;;;;;;;;;;;;;;;;;;;;GAuBC,GACD,MAAMI,eAAexE,QAAQwE,YAAY;IACzC,MAAMC,WAAWD,eAAe/H,gBAAgB+H,aAAaE,MAAM,IAAI,EAAE;IACzE,MAAMC,OAAOhJ,eAAe0I;IAC5B,IAAI,CAACM,KAAKnE,MAAM,IAAI,CAACiE,SAASjE,MAAM,EAAE,OAAO6D;IAC7C,MAAMO,QAAQ,MAAM5I,mBAAmB;QACrCmC;QACAwG,MAAM;eAAIF;eAAaE;SAAK;IAC9B;IACA,IAAIH,cAAc;QAChB,MAAMK,YAAYrI,sBAAsBgI,aAAaE,MAAM,EAAEE;QAC7D,IAAIC,WAAWL,aAAaM,OAAO,CAACD;IACtC;IACA,OAAOnJ,qBAAqB2I,cAAcO;AAC5C;AAEA;;;;;CAKC,GACD,OAAO,eAAeG,mBAAmB/E,OAiBxC;QAWoBA,MAAAA;IAVnB,MAAM,EAAE7B,MAAM,EAAE6G,QAAQ,EAAEC,MAAM,EAAE,GAAGjF;IAErC,MAAMkF,qBAAqBlF,QAAQmF,SAAS,GACxC,OACA,MAAMvJ,wBAAwB;QAC5BuC;QACAiH,gBAAgB;QAChBC,OAAOL;QACPM,QAAQL;IACV;IACJ,MAAME,aAAanF,QAAAA,qBAAAA,QAAQmF,SAAS,YAAjBnF,qBACjBkF,8BADiBlF,OAEjBiF,OAAOE,SAAS;IAElB;;;;;;;;;;;;;;;;GAgBC,GACD,IAAI,CAACA,WAAW,OAAO;IAEvB,MAAMI,iBAAiBhJ,iBAAiB;QAAE4B;QAAQ6G;QAAUG;IAAU;IAEtE;;;;;;;;;;;GAWC,GACD,MAAMK,WAAWzF,uBAAuB;QACtC5B;QACA,wEAAwE;QACxE,0EAA0E;QAC1E,qEAAqE;QACrE,uEAAuE;QACvE,kDAAkD;QAClD8B,UAAUsF,eAAeE,IAAI,CAC3B,CAACC,MACCA,IAAI9E,OAAO,IAAI,cAAc8E,IAAI9E,OAAO,GACnC,AAAC8E,IAAI9E,OAAO,CAA8BX,QAAQ,GAGlDgF,OAAOhF,QAAQ,EACtB,IAAMgF,OAAOhF,QAAQ;QAEvB,yEAAyE;QACzE,oEAAoE;QACpE8B,kBAAkBwD,eAAeE,IAAI,CACnC,CAACC;gBACEA;oBAAAA,eAAAA,IAAI9E,OAAO,qBAAZ,AAAC8E,aAAsD3D,gBAAgB;WACzE,IAAMtD;QAERL,aAAamH,eAAeE,IAAI,CAC9B,CAACC;;gBAASA;4BAAAA,eAAAA,IAAI9E,OAAO,qBAAX8E,aAAa9I,KAAK,mBAAI,CAAC;WACjC,IAAO,CAAA,CAAC,CAAA;QAEVqH,QAAQjE,QAAQiE,MAAM;QACtBpH,YAAYmD,QAAQnD,UAAU;QAC9B8G,MAAM3D,QAAQ2D,IAAI;QAClBa,cAAcxE,QAAQwE,YAAY;IACpC;IACA,KAAKgB,SAAShH,KAAK,CAAC,IAAMC;IAE1B,MAAMkH,aAAa,MAAMJ;IACzB,IAAII,WAAWC,KAAK,IAAI,CAACD,WAAW/E,OAAO,EAAE,OAAO;IAEpD,OAAO4E;AACT;AAEA,eAAeT,mBAAkB"}
@@ -74,8 +74,69 @@ async function readContentAuthors(hostId) {
74
74
  return [];
75
75
  }
76
76
  }
77
- /** The public slugs of every content collection this host owns. */ async function listContentCollections(hostId) {
78
- const snapshot = await firebaseAdmin.app().firestore().collection('hosts').doc(hostId).collection('collections').limit(AUTHOR_PAGE_COLLECTION_SCAN).get();
77
+ /**
78
+ * The only fields the author page asks a collection document for.
79
+ *
80
+ * A field mask rather than the whole document (AGL-3213). A collection doc
81
+ * carries its `categories` taxonomy — up to fifty `{ id, name, description }`
82
+ * entries, with prose in every one — and the three facts this read needs are
83
+ * a slug, a display name and a kind. The taxonomy IS read on this path, but
84
+ * out of the per-collection source the entries already come from, where it is
85
+ * cached alongside them; fetching it a second time here bought nothing.
86
+ *
87
+ * The three name candidates ride along together because the fallback chain
88
+ * below reads all three, and a mask that dropped one would silently rename
89
+ * every collection that stores its name under the older key.
90
+ */ const AUTHOR_PAGE_COLLECTION_FIELDS = [
91
+ 'slug',
92
+ 'displayName',
93
+ 'name',
94
+ 'title',
95
+ 'kind'
96
+ ];
97
+ /**
98
+ * The public slugs of every content collection this host owns.
99
+ *
100
+ * ONE cached query per host (AGL-3213). This was the last uncached read on
101
+ * the author path, and the author path is the one that multiplies it: a
102
+ * person's archive is `/author/{slug}` plus a `/page/{n}` per ten entries,
103
+ * each its own ISR address regenerating on its own window, and each paid a
104
+ * fresh {@link AUTHOR_PAGE_COLLECTION_SCAN}-document scan for a table that
105
+ * changes when someone creates a collection. Everything else the page reads —
106
+ * the roster, and every collection's entries — has been shared through
107
+ * `withRenderCache` since AGL-2518/AGL-1302; this one simply never was.
108
+ *
109
+ * Tagged with the host's data tag like its neighbours, so creating or
110
+ * renaming a collection reaches the archive the moment the publish path busts
111
+ * the tag rather than at the TTL.
112
+ *
113
+ * Fail-open to no collections, which is the behaviour the caller's own
114
+ * try/catch already produced: an archive with no posts in it, never a 500.
115
+ */ async function listContentCollections(hostId) {
116
+ try {
117
+ return await withRenderCache({
118
+ key: [
119
+ 'tenant-author-collections',
120
+ hostId
121
+ ],
122
+ revalidate: PUBLISHED_SITE_DATA_TTL_SECONDS,
123
+ tags: [
124
+ tenantDataTag(hostId)
125
+ ],
126
+ read: ()=>readContentCollections(hostId),
127
+ // A host whose scan came back empty is not cached, for the reason
128
+ // `withRenderCache` gives about negatives: a collections read that
129
+ // misses once must not make every author page on the site an empty
130
+ // archive for the hour.
131
+ store: (value)=>value.length > 0
132
+ });
133
+ } catch (error) {
134
+ console.error(error);
135
+ return readContentCollections(hostId);
136
+ }
137
+ }
138
+ async function readContentCollections(hostId) {
139
+ const snapshot = await firebaseAdmin.app().firestore().collection('hosts').doc(hostId).collection('collections').select(...AUTHOR_PAGE_COLLECTION_FIELDS).limit(AUTHOR_PAGE_COLLECTION_SCAN).get();
79
140
  const collections = [];
80
141
  for (const doc of snapshot.docs){
81
142
  var _doc_get, _ref, _ref1, _doc_get1;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/tenant/runtime/src/lib/get-author-content.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n AUTHORS_MAX_PER_HOST,\n type CollectionCategory,\n collectionTotalPages,\n type ContentAuthorRecord,\n contentAuthorMatchesSlug,\n contentAuthorSlug,\n contentAuthorSlugCandidates,\n hostCollectionKind,\n normalizeContentAuthor,\n urlSlugSegment,\n} from '@aglyn/aglyn/server'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport {\n PUBLISHED_SITE_DATA_TTL_SECONDS,\n tenantDataTag,\n withRenderCache,\n} from '@aglyn/tenant-data-admin/render-cache'\nimport {\n type CollectionEntrySummary,\n getPublishedCollectionSource,\n} from './get-collection-content'\n\n/**\n * How many content collections one author page walks.\n *\n * The same window site search reads, and for the same reason: a host's\n * `collections` subcollection holds commerce's catalogs too, so this is a\n * bound on DOCUMENTS SCANNED rather than on content collections found. Sites\n * with more than twenty collections of both kinds are not a shape the product\n * has yet, and the alternative — an unbounded scan on a public, uncached\n * first render — is the shape of an outage.\n */\nconst AUTHOR_PAGE_COLLECTION_SCAN = 20\n\n/** How long the host's author roster stays warm. */\nconst AUTHORS_TTL_SECONDS = PUBLISHED_SITE_DATA_TTL_SECONDS\n\n/**\n * Every author a host has defined, normalized (AGL-2518).\n *\n * ONE cached query, shared by the author page, the sitemap and anything else\n * that needs to turn a slug into a person. Bounded by\n * {@link AUTHORS_MAX_PER_HOST}, which is the platform cap, so the bound can\n * never hide an author that exists.\n *\n * Reading the roster rather than resolving the author out of their own posts\n * is a deliberate reversal of what AGL-2517 did. That version took the record\n * off the first matching entry to avoid a second Firestore read — which meant\n * an author with no published posts had no record, so their page had no name,\n * no bio and no links, and rendered as an empty archive of nobody. A person\n * who has not published yet still has a page; and this read is cached across\n * the whole site, so it costs one query per TTL rather than one per render.\n *\n * Fail-open to an empty roster: the page then falls back to whatever the\n * entries themselves carry, which is the old behavior rather than a 500.\n */\nexport async function getContentAuthors(options: {\n hostId: string\n}): Promise<ContentAuthorRecord[]> {\n try {\n return await withRenderCache({\n key: ['tenant-content-authors', options.hostId],\n revalidate: AUTHORS_TTL_SECONDS,\n tags: [tenantDataTag(options.hostId)],\n read: () => readContentAuthors(options.hostId),\n })\n } catch (error) {\n console.error(error)\n return readContentAuthors(options.hostId)\n }\n}\n\nasync function readContentAuthors(\n hostId: string,\n): Promise<ContentAuthorRecord[]> {\n try {\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(hostId)\n .collection('authors')\n .limit(AUTHORS_MAX_PER_HOST)\n .get()\n return snapshot.docs\n .map((doc) => normalizeContentAuthor(doc.data(), doc.id))\n .filter((author): author is ContentAuthorRecord => Boolean(author))\n } catch (error) {\n console.error(error)\n return []\n }\n}\n\n/** The public slugs of every content collection this host owns. */\nasync function listContentCollections(hostId: string): Promise<\n { slug: string; name: string }[]\n> {\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(hostId)\n .collection('collections')\n .limit(AUTHOR_PAGE_COLLECTION_SCAN)\n .get()\n const collections: { slug: string; name: string }[] = []\n for (const doc of snapshot.docs) {\n // Commerce's catalogs share this path (AGL-954) and own no entries.\n if (hostCollectionKind(doc.data()) !== 'content') continue\n const slug = String(doc.get('slug') ?? '').trim()\n if (!slug) continue\n collections.push({\n slug,\n name:\n String(\n doc.get('displayName') ?? doc.get('name') ?? doc.get('title') ?? '',\n ).trim() || slug,\n })\n }\n return collections\n}\n\n/** What a `/author/{slug}` route resolved to. */\nexport interface AuthorContent {\n /** The addressed segment, normalized — what a canonical link must say. */\n slug: string\n /** The author's record, when the slug names one. */\n author: ContentAuthorRecord | null\n /**\n * The byline to print. Falls back to the raw segment so an unknown author\n * still gets a page with a heading rather than a blank one.\n */\n name: string\n /**\n * Did the slug resolve to a real author — a roster record, or an entry\n * published under that byline? An unknown slug renders an empty page rather\n * than crashing, which is the category route's rule, but the page must not\n * invite indexing of an address that names nobody.\n */\n known: boolean\n /** This author's published entries, newest first, across every collection. */\n entries: CollectionEntrySummary[]\n /** The merged taxonomy of every collection walked, for name resolution. */\n categories: CollectionCategory[]\n page: number\n perPage: number\n totalEntries: number\n totalPages: number\n}\n\n/**\n * Everything one author's page shows (AGL-2518) — the person, and what they\n * wrote across the WHOLE site.\n *\n * ## Where the entries come from\n *\n * Every content collection the host owns, through\n * {@link getPublishedCollectionSource} — the same cached per-collection read\n * `/blog` and every \"Latest posts\" rail already use. So on a warm site this\n * page adds no Firestore reads at all: it is a filter over data the cache is\n * holding anyway. That is the whole reason it walks collections rather than\n * running a collection-group query on `authorId`, which would be one query\n * but would also need its own composite index, would miss every entry written\n * under the legacy free-typed byline (AGL-686), and would share nothing with\n * the rest of the site.\n *\n * ## Why each entry is stamped with its collection\n *\n * One page, several collections, so the routed slug cannot build `entry.url`\n * any more — a changelog note listed under a `/blog` route would link to a\n * page that does not exist. Each entry carries `collectionSlug` and\n * `collectionName` out of the read that found it, and the token map prefers\n * them (`collectionEntryTokens`). Single-collection listings set neither and\n * are unchanged.\n *\n * ## Ordering\n *\n * Newest first by `publishedAt`, with undated entries last rather than first:\n * a draft-turned-live with no timestamp should not lead a person's archive.\n * Sorted ACROSS collections, because the point of the page is a single\n * chronological body of work rather than three lists stacked.\n */\nexport async function getAuthorContent(options: {\n hostId: string\n authorSlug: string\n page?: number\n perPage?: number\n}): Promise<AuthorContent> {\n const { hostId } = options\n const slug = String(options.authorSlug ?? '').trim()\n // The segment as a URL actually spells it. The route parser already\n // slugifies, but this function is called directly by tests and by the\n // sitemap, so it normalizes its own input rather than trusting a caller.\n const slugified = urlSlugSegment(slug)\n const page = Math.max(1, Math.floor(Number(options.page) || 1))\n const perPage = Math.max(1, Math.floor(Number(options.perPage) || 10))\n const empty: AuthorContent = {\n slug: slugified,\n author: null,\n name: slugified,\n known: false,\n entries: [],\n categories: [],\n page,\n perPage,\n totalEntries: 0,\n totalPages: 1,\n }\n if (!slugified) return empty\n try {\n const [authors, collections] = await Promise.all([\n getContentAuthors({ hostId }),\n listContentCollections(hostId),\n ])\n const record =\n authors.find((author) => contentAuthorMatchesSlug({ author }, slug)) ??\n null\n\n /*\n Every segment that means this person, resolved ONCE from the record and\n then matched against each entry — rather than asking each entry whether\n it matches the routed segment.\n\n The difference is not cosmetic. An entry stores `authorId`; the URL\n carries the author's stored SLUG. Asking the entry alone, its only\n candidate is the id, which does not equal the slug, so the archive comes\n back empty for exactly the authors who set an address — the field whose\n whole purpose is to give them a stable one.\n\n It also closes a fail-open hole. `attachEntryAuthors` resolves\n `entry.author` and is deliberately allowed to fail (a byline is not\n worth a 500). When it does, the entry keeps only its `authorId`, and a\n per-entry match against a name-derived segment would silently drop it —\n an archive quietly missing posts, which looks exactly like an author who\n wrote fewer of them.\n */\n const accepted = new Set<string>([slugified])\n if (record) {\n for (const candidate of contentAuthorSlugCandidates({ author: record })) {\n accepted.add(candidate)\n }\n }\n const matchesAuthor = (entry: CollectionEntrySummary): boolean =>\n contentAuthorSlugCandidates({\n ...(entry.author ? { author: entry.author } : {}),\n ...(entry.authorId ? { authorId: entry.authorId } : {}),\n ...(entry.authorName ? { authorName: entry.authorName } : {}),\n }).some((candidate) => accepted.has(candidate))\n\n const sources = await Promise.all(\n collections.map(async (collection) => ({\n collection,\n source: await getPublishedCollectionSource({\n hostId,\n collectionSlug: collection.slug,\n }),\n })),\n )\n\n const entries: CollectionEntrySummary[] = []\n const categories: CollectionCategory[] = []\n for (const { collection, source } of sources) {\n categories.push(...source.categories)\n for (const entry of source.entries) {\n if (!matchesAuthor(entry)) continue\n // Stamped rather than mutated in place: `source.entries` is the\n // CACHED array, shared with every other page rendering this\n // collection, and writing a collection slug onto it would leak this\n // page's context into theirs.\n entries.push({\n ...entry,\n collectionSlug: collection.slug,\n collectionName: collection.name,\n })\n }\n }\n entries.sort(\n (a, b) => (b.publishedAt?.seconds ?? 0) - (a.publishedAt?.seconds ?? 0),\n )\n\n // The record wins for the display name; failing that, the byline of a\n // post they actually wrote; failing that, the raw segment.\n const name =\n record?.name ||\n entries.find((entry) => (entry.authorName ?? '').trim())?.authorName ||\n slugified\n const totalEntries = entries.length\n return {\n slug: slugified,\n author: record,\n name,\n known: Boolean(record) || totalEntries > 0,\n /*\n The WHOLE narrowed set, not this page's slice.\n\n Narrowing happens before the count, so `totalPages` describes this\n author's work rather than the site's — the category route's rule, one\n axis over. The WINDOW, though, belongs to the Collection entries\n block: it receives `page` and `perPage` and slices for itself\n (`expandCollectionEntries`), exactly as it does on a routed collection\n listing, where `getCollectionContent` also hands over the full\n filtered set.\n\n Slicing here as well double-windows and empties every page after the\n first: the block would take `slice(10, 20)` of a ten-element array and\n render nothing. A page-2 archive with a working pager and no cards on\n it — which reads as \"this author wrote exactly ten things\".\n */\n entries,\n categories,\n page,\n perPage,\n totalEntries,\n totalPages: collectionTotalPages(totalEntries, perPage),\n }\n } catch (error) {\n // Fail-open, like every read on this path: a person's page that 500s is\n // worse than one that renders their name and nothing else.\n console.error('author content read failed', error)\n return empty\n }\n}\n\n/**\n * Every author page this site can serve, for the sitemap (AGL-2518).\n *\n * Roster order, and only authors that address something: an author whose\n * record has neither a slug nor a name has no URL, and listing one would put\n * `/author/` in the sitemap.\n */\nexport async function listAuthorPageSlugs(options: {\n hostId: string\n}): Promise<{ slug: string; name: string }[]> {\n const authors = await getContentAuthors(options)\n const seen = new Set<string>()\n const rows: { slug: string; name: string }[] = []\n for (const author of authors) {\n const slug = contentAuthorSlug({ author })\n if (!slug || seen.has(slug)) continue\n seen.add(slug)\n rows.push({ slug, name: author.name ?? slug })\n }\n return rows\n}\n\nexport default getAuthorContent\n"],"names":["AUTHORS_MAX_PER_HOST","collectionTotalPages","contentAuthorMatchesSlug","contentAuthorSlug","contentAuthorSlugCandidates","hostCollectionKind","normalizeContentAuthor","urlSlugSegment","firebaseAdmin","PUBLISHED_SITE_DATA_TTL_SECONDS","tenantDataTag","withRenderCache","getPublishedCollectionSource","AUTHOR_PAGE_COLLECTION_SCAN","AUTHORS_TTL_SECONDS","getContentAuthors","options","key","hostId","revalidate","tags","read","readContentAuthors","error","console","snapshot","app","firestore","collection","doc","limit","get","docs","map","data","id","filter","author","Boolean","listContentCollections","collections","slug","String","trim","push","name","getAuthorContent","authorSlug","slugified","page","Math","max","floor","Number","perPage","empty","known","entries","categories","totalEntries","totalPages","authors","Promise","all","record","find","accepted","Set","candidate","add","matchesAuthor","entry","authorId","authorName","some","has","sources","source","collectionSlug","collectionName","sort","a","b","publishedAt","seconds","length","listAuthorPageSlugs","seen","rows"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,oBAAoB,EAEpBC,oBAAoB,EAEpBC,wBAAwB,EACxBC,iBAAiB,EACjBC,2BAA2B,EAC3BC,kBAAkB,EAClBC,sBAAsB,EACtBC,cAAc,QACT,sBAAqB;AAC5B,SAASC,aAAa,QAAQ,2BAA0B;AACxD,SACEC,+BAA+B,EAC/BC,aAAa,EACbC,eAAe,QACV,wCAAuC;AAC9C,SAEEC,4BAA4B,QACvB,8BAA0B;AAEjC;;;;;;;;;CASC,GACD,MAAMC,8BAA8B;AAEpC,kDAAkD,GAClD,MAAMC,sBAAsBL;AAE5B;;;;;;;;;;;;;;;;;;CAkBC,GACD,OAAO,eAAeM,kBAAkBC,OAEvC;IACC,IAAI;QACF,OAAO,MAAML,gBAAgB;YAC3BM,KAAK;gBAAC;gBAA0BD,QAAQE,MAAM;aAAC;YAC/CC,YAAYL;YACZM,MAAM;gBAACV,cAAcM,QAAQE,MAAM;aAAE;YACrCG,MAAM,IAAMC,mBAAmBN,QAAQE,MAAM;QAC/C;IACF,EAAE,OAAOK,OAAO;QACdC,QAAQD,KAAK,CAACA;QACd,OAAOD,mBAAmBN,QAAQE,MAAM;IAC1C;AACF;AAEA,eAAeI,mBACbJ,MAAc;IAEd,IAAI;QACF,MAAMO,WAAW,MAAMjB,cACpBkB,GAAG,GACHC,SAAS,GACTC,UAAU,CAAC,SACXC,GAAG,CAACX,QACJU,UAAU,CAAC,WACXE,KAAK,CAAC9B,sBACN+B,GAAG;QACN,OAAON,SAASO,IAAI,CACjBC,GAAG,CAAC,CAACJ,MAAQvB,uBAAuBuB,IAAIK,IAAI,IAAIL,IAAIM,EAAE,GACtDC,MAAM,CAAC,CAACC,SAA0CC,QAAQD;IAC/D,EAAE,OAAOd,OAAO;QACdC,QAAQD,KAAK,CAACA;QACd,OAAO,EAAE;IACX;AACF;AAEA,iEAAiE,GACjE,eAAegB,uBAAuBrB,MAAc;IAGlD,MAAMO,WAAW,MAAMjB,cACpBkB,GAAG,GACHC,SAAS,GACTC,UAAU,CAAC,SACXC,GAAG,CAACX,QACJU,UAAU,CAAC,eACXE,KAAK,CAACjB,6BACNkB,GAAG;IACN,MAAMS,cAAgD,EAAE;IACxD,KAAK,MAAMX,OAAOJ,SAASO,IAAI,CAAE;YAGXH,UAMdA,MAAAA,OAAAA;QARN,oEAAoE;QACpE,IAAIxB,mBAAmBwB,IAAIK,IAAI,QAAQ,WAAW;QAClD,MAAMO,OAAOC,QAAOb,WAAAA,IAAIE,GAAG,CAAC,mBAARF,WAAmB,IAAIc,IAAI;QAC/C,IAAI,CAACF,MAAM;QACXD,YAAYI,IAAI,CAAC;YACfH;YACAI,MACEH,QACEb,QAAAA,SAAAA,YAAAA,IAAIE,GAAG,CAAC,0BAARF,YAA0BA,IAAIE,GAAG,CAAC,mBAAlCF,QAA6CA,IAAIE,GAAG,CAAC,oBAArDF,OAAiE,IACjEc,IAAI,MAAMF;QAChB;IACF;IACA,OAAOD;AACT;AA8BA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+BC,GACD,OAAO,eAAeM,iBAAiB9B,OAKtC;QAEqBA;IADpB,MAAM,EAAEE,MAAM,EAAE,GAAGF;IACnB,MAAMyB,OAAOC,QAAO1B,sBAAAA,QAAQ+B,UAAU,YAAlB/B,sBAAsB,IAAI2B,IAAI;IAClD,oEAAoE;IACpE,sEAAsE;IACtE,yEAAyE;IACzE,MAAMK,YAAYzC,eAAekC;IACjC,MAAMQ,OAAOC,KAAKC,GAAG,CAAC,GAAGD,KAAKE,KAAK,CAACC,OAAOrC,QAAQiC,IAAI,KAAK;IAC5D,MAAMK,UAAUJ,KAAKC,GAAG,CAAC,GAAGD,KAAKE,KAAK,CAACC,OAAOrC,QAAQsC,OAAO,KAAK;IAClE,MAAMC,QAAuB;QAC3Bd,MAAMO;QACNX,QAAQ;QACRQ,MAAMG;QACNQ,OAAO;QACPC,SAAS,EAAE;QACXC,YAAY,EAAE;QACdT;QACAK;QACAK,cAAc;QACdC,YAAY;IACd;IACA,IAAI,CAACZ,WAAW,OAAOO;IACvB,IAAI;YAMAM;YAqEAJ;QA1EF,MAAM,CAACI,SAASrB,YAAY,GAAG,MAAMsB,QAAQC,GAAG,CAAC;YAC/ChD,kBAAkB;gBAAEG;YAAO;YAC3BqB,uBAAuBrB;SACxB;QACD,MAAM8C,UACJH,gBAAAA,QAAQI,IAAI,CAAC,CAAC5B,SAAWnC,yBAAyB;gBAAEmC;YAAO,GAAGI,kBAA9DoB,gBACA;QAEF;;;;;;;;;;;;;;;;;IAiBA,GACA,MAAMK,WAAW,IAAIC,IAAY;YAACnB;SAAU;QAC5C,IAAIgB,QAAQ;YACV,KAAK,MAAMI,aAAahE,4BAA4B;gBAAEiC,QAAQ2B;YAAO,GAAI;gBACvEE,SAASG,GAAG,CAACD;YACf;QACF;QACA,MAAME,gBAAgB,CAACC,QACrBnE,4BAA4B,aACtBmE,MAAMlC,MAAM,GAAG;gBAAEA,QAAQkC,MAAMlC,MAAM;YAAC,IAAI,CAAC,GAC3CkC,MAAMC,QAAQ,GAAG;gBAAEA,UAAUD,MAAMC,QAAQ;YAAC,IAAI,CAAC,GACjDD,MAAME,UAAU,GAAG;gBAAEA,YAAYF,MAAME,UAAU;YAAC,IAAI,CAAC,IAC1DC,IAAI,CAAC,CAACN,YAAcF,SAASS,GAAG,CAACP;QAEtC,MAAMQ,UAAU,MAAMd,QAAQC,GAAG,CAC/BvB,YAAYP,GAAG,CAAC,OAAOL,aAAgB,CAAA;gBACrCA;gBACAiD,QAAQ,MAAMjE,6BAA6B;oBACzCM;oBACA4D,gBAAgBlD,WAAWa,IAAI;gBACjC;YACF,CAAA;QAGF,MAAMgB,UAAoC,EAAE;QAC5C,MAAMC,aAAmC,EAAE;QAC3C,KAAK,MAAM,EAAE9B,UAAU,EAAEiD,MAAM,EAAE,IAAID,QAAS;YAC5ClB,WAAWd,IAAI,IAAIiC,OAAOnB,UAAU;YACpC,KAAK,MAAMa,SAASM,OAAOpB,OAAO,CAAE;gBAClC,IAAI,CAACa,cAAcC,QAAQ;gBAC3B,gEAAgE;gBAChE,4DAA4D;gBAC5D,oEAAoE;gBACpE,8BAA8B;gBAC9Bd,QAAQb,IAAI,CAAC,aACR2B;oBACHO,gBAAgBlD,WAAWa,IAAI;oBAC/BsC,gBAAgBnD,WAAWiB,IAAI;;YAEnC;QACF;QACAY,QAAQuB,IAAI,CACV,CAACC,GAAGC;;gBAAOA,gBAAgCD;mBAAjC,UAACC,iBAAAA,EAAEC,WAAW,qBAAbD,eAAeE,OAAO,mBAAI,gBAAMH,iBAAAA,EAAEE,WAAW,qBAAbF,eAAeG,OAAO,oBAAI;;QAGvE,sEAAsE;QACtE,2DAA2D;QAC3D,MAAMvC,OACJmB,CAAAA,0BAAAA,OAAQnB,IAAI,OACZY,gBAAAA,QAAQQ,IAAI,CAAC,CAACM;gBAAWA;mBAAD,EAACA,oBAAAA,MAAME,UAAU,YAAhBF,oBAAoB,IAAI5B,IAAI;+BAArDc,cAA0DgB,UAAU,KACpEzB;QACF,MAAMW,eAAeF,QAAQ4B,MAAM;QACnC,OAAO;YACL5C,MAAMO;YACNX,QAAQ2B;YACRnB;YACAW,OAAOlB,QAAQ0B,WAAWL,eAAe;YACzC;;;;;;;;;;;;;;;MAeA,GACAF;YACAC;YACAT;YACAK;YACAK;YACAC,YAAY3D,qBAAqB0D,cAAcL;QACjD;IACF,EAAE,OAAO/B,OAAO;QACd,wEAAwE;QACxE,2DAA2D;QAC3DC,QAAQD,KAAK,CAAC,8BAA8BA;QAC5C,OAAOgC;IACT;AACF;AAEA;;;;;;CAMC,GACD,OAAO,eAAe+B,oBAAoBtE,OAEzC;IACC,MAAM6C,UAAU,MAAM9C,kBAAkBC;IACxC,MAAMuE,OAAO,IAAIpB;IACjB,MAAMqB,OAAyC,EAAE;IACjD,KAAK,MAAMnD,UAAUwB,QAAS;YAIJxB;QAHxB,MAAMI,OAAOtC,kBAAkB;YAAEkC;QAAO;QACxC,IAAI,CAACI,QAAQ8C,KAAKZ,GAAG,CAAClC,OAAO;QAC7B8C,KAAKlB,GAAG,CAAC5B;QACT+C,KAAK5C,IAAI,CAAC;YAAEH;YAAMI,IAAI,GAAER,eAAAA,OAAOQ,IAAI,YAAXR,eAAeI;QAAK;IAC9C;IACA,OAAO+C;AACT;AAEA,eAAe1C,iBAAgB"}
1
+ {"version":3,"sources":["../../../../../../libs/tenant/runtime/src/lib/get-author-content.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n AUTHORS_MAX_PER_HOST,\n type CollectionCategory,\n collectionTotalPages,\n type ContentAuthorRecord,\n contentAuthorMatchesSlug,\n contentAuthorSlug,\n contentAuthorSlugCandidates,\n hostCollectionKind,\n normalizeContentAuthor,\n urlSlugSegment,\n} from '@aglyn/aglyn/server'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport {\n PUBLISHED_SITE_DATA_TTL_SECONDS,\n tenantDataTag,\n withRenderCache,\n} from '@aglyn/tenant-data-admin/render-cache'\nimport {\n type CollectionEntrySummary,\n getPublishedCollectionSource,\n} from './get-collection-content'\n\n/**\n * How many content collections one author page walks.\n *\n * The same window site search reads, and for the same reason: a host's\n * `collections` subcollection holds commerce's catalogs too, so this is a\n * bound on DOCUMENTS SCANNED rather than on content collections found. Sites\n * with more than twenty collections of both kinds are not a shape the product\n * has yet, and the alternative — an unbounded scan on a public, uncached\n * first render — is the shape of an outage.\n */\nconst AUTHOR_PAGE_COLLECTION_SCAN = 20\n\n/** How long the host's author roster stays warm. */\nconst AUTHORS_TTL_SECONDS = PUBLISHED_SITE_DATA_TTL_SECONDS\n\n/**\n * Every author a host has defined, normalized (AGL-2518).\n *\n * ONE cached query, shared by the author page, the sitemap and anything else\n * that needs to turn a slug into a person. Bounded by\n * {@link AUTHORS_MAX_PER_HOST}, which is the platform cap, so the bound can\n * never hide an author that exists.\n *\n * Reading the roster rather than resolving the author out of their own posts\n * is a deliberate reversal of what AGL-2517 did. That version took the record\n * off the first matching entry to avoid a second Firestore read — which meant\n * an author with no published posts had no record, so their page had no name,\n * no bio and no links, and rendered as an empty archive of nobody. A person\n * who has not published yet still has a page; and this read is cached across\n * the whole site, so it costs one query per TTL rather than one per render.\n *\n * Fail-open to an empty roster: the page then falls back to whatever the\n * entries themselves carry, which is the old behavior rather than a 500.\n */\nexport async function getContentAuthors(options: {\n hostId: string\n}): Promise<ContentAuthorRecord[]> {\n try {\n return await withRenderCache({\n key: ['tenant-content-authors', options.hostId],\n revalidate: AUTHORS_TTL_SECONDS,\n tags: [tenantDataTag(options.hostId)],\n read: () => readContentAuthors(options.hostId),\n })\n } catch (error) {\n console.error(error)\n return readContentAuthors(options.hostId)\n }\n}\n\nasync function readContentAuthors(\n hostId: string,\n): Promise<ContentAuthorRecord[]> {\n try {\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(hostId)\n .collection('authors')\n .limit(AUTHORS_MAX_PER_HOST)\n .get()\n return snapshot.docs\n .map((doc) => normalizeContentAuthor(doc.data(), doc.id))\n .filter((author): author is ContentAuthorRecord => Boolean(author))\n } catch (error) {\n console.error(error)\n return []\n }\n}\n\n/**\n * The only fields the author page asks a collection document for.\n *\n * A field mask rather than the whole document (AGL-3213). A collection doc\n * carries its `categories` taxonomy — up to fifty `{ id, name, description }`\n * entries, with prose in every one — and the three facts this read needs are\n * a slug, a display name and a kind. The taxonomy IS read on this path, but\n * out of the per-collection source the entries already come from, where it is\n * cached alongside them; fetching it a second time here bought nothing.\n *\n * The three name candidates ride along together because the fallback chain\n * below reads all three, and a mask that dropped one would silently rename\n * every collection that stores its name under the older key.\n */\nconst AUTHOR_PAGE_COLLECTION_FIELDS = [\n 'slug',\n 'displayName',\n 'name',\n 'title',\n 'kind',\n] as const\n\n/**\n * The public slugs of every content collection this host owns.\n *\n * ONE cached query per host (AGL-3213). This was the last uncached read on\n * the author path, and the author path is the one that multiplies it: a\n * person's archive is `/author/{slug}` plus a `/page/{n}` per ten entries,\n * each its own ISR address regenerating on its own window, and each paid a\n * fresh {@link AUTHOR_PAGE_COLLECTION_SCAN}-document scan for a table that\n * changes when someone creates a collection. Everything else the page reads —\n * the roster, and every collection's entries — has been shared through\n * `withRenderCache` since AGL-2518/AGL-1302; this one simply never was.\n *\n * Tagged with the host's data tag like its neighbours, so creating or\n * renaming a collection reaches the archive the moment the publish path busts\n * the tag rather than at the TTL.\n *\n * Fail-open to no collections, which is the behaviour the caller's own\n * try/catch already produced: an archive with no posts in it, never a 500.\n */\nasync function listContentCollections(\n hostId: string,\n): Promise<{ slug: string; name: string }[]> {\n try {\n return await withRenderCache({\n key: ['tenant-author-collections', hostId],\n revalidate: PUBLISHED_SITE_DATA_TTL_SECONDS,\n tags: [tenantDataTag(hostId)],\n read: () => readContentCollections(hostId),\n // A host whose scan came back empty is not cached, for the reason\n // `withRenderCache` gives about negatives: a collections read that\n // misses once must not make every author page on the site an empty\n // archive for the hour.\n store: (value) => value.length > 0,\n })\n } catch (error) {\n console.error(error)\n return readContentCollections(hostId)\n }\n}\n\nasync function readContentCollections(\n hostId: string,\n): Promise<{ slug: string; name: string }[]> {\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(hostId)\n .collection('collections')\n .select(...AUTHOR_PAGE_COLLECTION_FIELDS)\n .limit(AUTHOR_PAGE_COLLECTION_SCAN)\n .get()\n const collections: { slug: string; name: string }[] = []\n for (const doc of snapshot.docs) {\n // Commerce's catalogs share this path (AGL-954) and own no entries.\n if (hostCollectionKind(doc.data()) !== 'content') continue\n const slug = String(doc.get('slug') ?? '').trim()\n if (!slug) continue\n collections.push({\n slug,\n name:\n String(\n doc.get('displayName') ?? doc.get('name') ?? doc.get('title') ?? '',\n ).trim() || slug,\n })\n }\n return collections\n}\n\n/** What a `/author/{slug}` route resolved to. */\nexport interface AuthorContent {\n /** The addressed segment, normalized — what a canonical link must say. */\n slug: string\n /** The author's record, when the slug names one. */\n author: ContentAuthorRecord | null\n /**\n * The byline to print. Falls back to the raw segment so an unknown author\n * still gets a page with a heading rather than a blank one.\n */\n name: string\n /**\n * Did the slug resolve to a real author — a roster record, or an entry\n * published under that byline? An unknown slug renders an empty page rather\n * than crashing, which is the category route's rule, but the page must not\n * invite indexing of an address that names nobody.\n */\n known: boolean\n /** This author's published entries, newest first, across every collection. */\n entries: CollectionEntrySummary[]\n /** The merged taxonomy of every collection walked, for name resolution. */\n categories: CollectionCategory[]\n page: number\n perPage: number\n totalEntries: number\n totalPages: number\n}\n\n/**\n * Everything one author's page shows (AGL-2518) — the person, and what they\n * wrote across the WHOLE site.\n *\n * ## Where the entries come from\n *\n * Every content collection the host owns, through\n * {@link getPublishedCollectionSource} — the same cached per-collection read\n * `/blog` and every \"Latest posts\" rail already use. So on a warm site this\n * page adds no Firestore reads at all: it is a filter over data the cache is\n * holding anyway. That is the whole reason it walks collections rather than\n * running a collection-group query on `authorId`, which would be one query\n * but would also need its own composite index, would miss every entry written\n * under the legacy free-typed byline (AGL-686), and would share nothing with\n * the rest of the site.\n *\n * ## Why each entry is stamped with its collection\n *\n * One page, several collections, so the routed slug cannot build `entry.url`\n * any more — a changelog note listed under a `/blog` route would link to a\n * page that does not exist. Each entry carries `collectionSlug` and\n * `collectionName` out of the read that found it, and the token map prefers\n * them (`collectionEntryTokens`). Single-collection listings set neither and\n * are unchanged.\n *\n * ## Ordering\n *\n * Newest first by `publishedAt`, with undated entries last rather than first:\n * a draft-turned-live with no timestamp should not lead a person's archive.\n * Sorted ACROSS collections, because the point of the page is a single\n * chronological body of work rather than three lists stacked.\n */\nexport async function getAuthorContent(options: {\n hostId: string\n authorSlug: string\n page?: number\n perPage?: number\n}): Promise<AuthorContent> {\n const { hostId } = options\n const slug = String(options.authorSlug ?? '').trim()\n // The segment as a URL actually spells it. The route parser already\n // slugifies, but this function is called directly by tests and by the\n // sitemap, so it normalizes its own input rather than trusting a caller.\n const slugified = urlSlugSegment(slug)\n const page = Math.max(1, Math.floor(Number(options.page) || 1))\n const perPage = Math.max(1, Math.floor(Number(options.perPage) || 10))\n const empty: AuthorContent = {\n slug: slugified,\n author: null,\n name: slugified,\n known: false,\n entries: [],\n categories: [],\n page,\n perPage,\n totalEntries: 0,\n totalPages: 1,\n }\n if (!slugified) return empty\n try {\n const [authors, collections] = await Promise.all([\n getContentAuthors({ hostId }),\n listContentCollections(hostId),\n ])\n const record =\n authors.find((author) => contentAuthorMatchesSlug({ author }, slug)) ??\n null\n\n /*\n Every segment that means this person, resolved ONCE from the record and\n then matched against each entry — rather than asking each entry whether\n it matches the routed segment.\n\n The difference is not cosmetic. An entry stores `authorId`; the URL\n carries the author's stored SLUG. Asking the entry alone, its only\n candidate is the id, which does not equal the slug, so the archive comes\n back empty for exactly the authors who set an address — the field whose\n whole purpose is to give them a stable one.\n\n It also closes a fail-open hole. `attachEntryAuthors` resolves\n `entry.author` and is deliberately allowed to fail (a byline is not\n worth a 500). When it does, the entry keeps only its `authorId`, and a\n per-entry match against a name-derived segment would silently drop it —\n an archive quietly missing posts, which looks exactly like an author who\n wrote fewer of them.\n */\n const accepted = new Set<string>([slugified])\n if (record) {\n for (const candidate of contentAuthorSlugCandidates({ author: record })) {\n accepted.add(candidate)\n }\n }\n const matchesAuthor = (entry: CollectionEntrySummary): boolean =>\n contentAuthorSlugCandidates({\n ...(entry.author ? { author: entry.author } : {}),\n ...(entry.authorId ? { authorId: entry.authorId } : {}),\n ...(entry.authorName ? { authorName: entry.authorName } : {}),\n }).some((candidate) => accepted.has(candidate))\n\n const sources = await Promise.all(\n collections.map(async (collection) => ({\n collection,\n source: await getPublishedCollectionSource({\n hostId,\n collectionSlug: collection.slug,\n }),\n })),\n )\n\n const entries: CollectionEntrySummary[] = []\n const categories: CollectionCategory[] = []\n for (const { collection, source } of sources) {\n categories.push(...source.categories)\n for (const entry of source.entries) {\n if (!matchesAuthor(entry)) continue\n // Stamped rather than mutated in place: `source.entries` is the\n // CACHED array, shared with every other page rendering this\n // collection, and writing a collection slug onto it would leak this\n // page's context into theirs.\n entries.push({\n ...entry,\n collectionSlug: collection.slug,\n collectionName: collection.name,\n })\n }\n }\n entries.sort(\n (a, b) => (b.publishedAt?.seconds ?? 0) - (a.publishedAt?.seconds ?? 0),\n )\n\n // The record wins for the display name; failing that, the byline of a\n // post they actually wrote; failing that, the raw segment.\n const name =\n record?.name ||\n entries.find((entry) => (entry.authorName ?? '').trim())?.authorName ||\n slugified\n const totalEntries = entries.length\n return {\n slug: slugified,\n author: record,\n name,\n known: Boolean(record) || totalEntries > 0,\n /*\n The WHOLE narrowed set, not this page's slice.\n\n Narrowing happens before the count, so `totalPages` describes this\n author's work rather than the site's — the category route's rule, one\n axis over. The WINDOW, though, belongs to the Collection entries\n block: it receives `page` and `perPage` and slices for itself\n (`expandCollectionEntries`), exactly as it does on a routed collection\n listing, where `getCollectionContent` also hands over the full\n filtered set.\n\n Slicing here as well double-windows and empties every page after the\n first: the block would take `slice(10, 20)` of a ten-element array and\n render nothing. A page-2 archive with a working pager and no cards on\n it — which reads as \"this author wrote exactly ten things\".\n */\n entries,\n categories,\n page,\n perPage,\n totalEntries,\n totalPages: collectionTotalPages(totalEntries, perPage),\n }\n } catch (error) {\n // Fail-open, like every read on this path: a person's page that 500s is\n // worse than one that renders their name and nothing else.\n console.error('author content read failed', error)\n return empty\n }\n}\n\n/**\n * Every author page this site can serve, for the sitemap (AGL-2518).\n *\n * Roster order, and only authors that address something: an author whose\n * record has neither a slug nor a name has no URL, and listing one would put\n * `/author/` in the sitemap.\n */\nexport async function listAuthorPageSlugs(options: {\n hostId: string\n}): Promise<{ slug: string; name: string }[]> {\n const authors = await getContentAuthors(options)\n const seen = new Set<string>()\n const rows: { slug: string; name: string }[] = []\n for (const author of authors) {\n const slug = contentAuthorSlug({ author })\n if (!slug || seen.has(slug)) continue\n seen.add(slug)\n rows.push({ slug, name: author.name ?? slug })\n }\n return rows\n}\n\nexport default getAuthorContent\n"],"names":["AUTHORS_MAX_PER_HOST","collectionTotalPages","contentAuthorMatchesSlug","contentAuthorSlug","contentAuthorSlugCandidates","hostCollectionKind","normalizeContentAuthor","urlSlugSegment","firebaseAdmin","PUBLISHED_SITE_DATA_TTL_SECONDS","tenantDataTag","withRenderCache","getPublishedCollectionSource","AUTHOR_PAGE_COLLECTION_SCAN","AUTHORS_TTL_SECONDS","getContentAuthors","options","key","hostId","revalidate","tags","read","readContentAuthors","error","console","snapshot","app","firestore","collection","doc","limit","get","docs","map","data","id","filter","author","Boolean","AUTHOR_PAGE_COLLECTION_FIELDS","listContentCollections","readContentCollections","store","value","length","select","collections","slug","String","trim","push","name","getAuthorContent","authorSlug","slugified","page","Math","max","floor","Number","perPage","empty","known","entries","categories","totalEntries","totalPages","authors","Promise","all","record","find","accepted","Set","candidate","add","matchesAuthor","entry","authorId","authorName","some","has","sources","source","collectionSlug","collectionName","sort","a","b","publishedAt","seconds","listAuthorPageSlugs","seen","rows"],"mappings":"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"}