@aglyn/tenant-runtime 1.0.0-beta.143

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.
Files changed (120) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +26 -0
  3. package/package.json +39 -0
  4. package/src/index.d.ts +21 -0
  5. package/src/index.js +22 -0
  6. package/src/index.js.map +1 -0
  7. package/src/lib/apply-publish-schedule.d.ts +34 -0
  8. package/src/lib/apply-publish-schedule.js +285 -0
  9. package/src/lib/apply-publish-schedule.js.map +1 -0
  10. package/src/lib/assign-contact-owner.d.ts +162 -0
  11. package/src/lib/assign-contact-owner.js +322 -0
  12. package/src/lib/assign-contact-owner.js.map +1 -0
  13. package/src/lib/associate-company-by-domain.d.ts +70 -0
  14. package/src/lib/associate-company-by-domain.js +150 -0
  15. package/src/lib/associate-company-by-domain.js.map +1 -0
  16. package/src/lib/author-page-nodes.d.ts +36 -0
  17. package/src/lib/author-page-nodes.js +296 -0
  18. package/src/lib/author-page-nodes.js.map +1 -0
  19. package/src/lib/built-in-page-layout.d.ts +48 -0
  20. package/src/lib/built-in-page-layout.js +107 -0
  21. package/src/lib/built-in-page-layout.js.map +1 -0
  22. package/src/lib/capture-host-contact.d.ts +95 -0
  23. package/src/lib/capture-host-contact.js +137 -0
  24. package/src/lib/capture-host-contact.js.map +1 -0
  25. package/src/lib/collection-fallback-nodes.d.ts +90 -0
  26. package/src/lib/collection-fallback-nodes.js +602 -0
  27. package/src/lib/collection-fallback-nodes.js.map +1 -0
  28. package/src/lib/compose-author-page.d.ts +109 -0
  29. package/src/lib/compose-author-page.js +225 -0
  30. package/src/lib/compose-author-page.js.map +1 -0
  31. package/src/lib/compose-collection-page.d.ts +94 -0
  32. package/src/lib/compose-collection-page.js +256 -0
  33. package/src/lib/compose-collection-page.js.map +1 -0
  34. package/src/lib/compose-screen-nodes.d.ts +156 -0
  35. package/src/lib/compose-screen-nodes.js +547 -0
  36. package/src/lib/compose-screen-nodes.js.map +1 -0
  37. package/src/lib/compose-search-page.d.ts +39 -0
  38. package/src/lib/compose-search-page.js +57 -0
  39. package/src/lib/compose-search-page.js.map +1 -0
  40. package/src/lib/convert-host-lead.d.ts +189 -0
  41. package/src/lib/convert-host-lead.js +443 -0
  42. package/src/lib/convert-host-lead.js.map +1 -0
  43. package/src/lib/defer-lazy-panels.d.ts +58 -0
  44. package/src/lib/defer-lazy-panels.js +171 -0
  45. package/src/lib/defer-lazy-panels.js.map +1 -0
  46. package/src/lib/element-animation-assets.d.ts +62 -0
  47. package/src/lib/element-animation-assets.js +290 -0
  48. package/src/lib/element-animation-assets.js.map +1 -0
  49. package/src/lib/emit-host-event.d.ts +29 -0
  50. package/src/lib/emit-host-event.js +30 -0
  51. package/src/lib/emit-host-event.js.map +1 -0
  52. package/src/lib/enrich-gated-page.d.ts +67 -0
  53. package/src/lib/enrich-gated-page.js +134 -0
  54. package/src/lib/enrich-gated-page.js.map +1 -0
  55. package/src/lib/entry-link-routes.d.ts +61 -0
  56. package/src/lib/entry-link-routes.js +169 -0
  57. package/src/lib/entry-link-routes.js.map +1 -0
  58. package/src/lib/get-author-content.d.ts +119 -0
  59. package/src/lib/get-author-content.js +288 -0
  60. package/src/lib/get-author-content.js.map +1 -0
  61. package/src/lib/get-collection-content.d.ts +290 -0
  62. package/src/lib/get-collection-content.js +565 -0
  63. package/src/lib/get-collection-content.js.map +1 -0
  64. package/src/lib/get-components.d.ts +24 -0
  65. package/src/lib/get-components.js +102 -0
  66. package/src/lib/get-components.js.map +1 -0
  67. package/src/lib/get-datasets.d.ts +45 -0
  68. package/src/lib/get-datasets.js +169 -0
  69. package/src/lib/get-datasets.js.map +1 -0
  70. package/src/lib/get-forms.d.ts +24 -0
  71. package/src/lib/get-forms.js +116 -0
  72. package/src/lib/get-forms.js.map +1 -0
  73. package/src/lib/get-layout-version.d.ts +26 -0
  74. package/src/lib/get-layout-version.js +99 -0
  75. package/src/lib/get-layout-version.js.map +1 -0
  76. package/src/lib/get-media-asset-facts.d.ts +74 -0
  77. package/src/lib/get-media-asset-facts.js +116 -0
  78. package/src/lib/get-media-asset-facts.js.map +1 -0
  79. package/src/lib/get-plugin-installs.d.ts +29 -0
  80. package/src/lib/get-plugin-installs.js +94 -0
  81. package/src/lib/get-plugin-installs.js.map +1 -0
  82. package/src/lib/get-screen-version.d.ts +27 -0
  83. package/src/lib/get-screen-version.js +66 -0
  84. package/src/lib/get-screen-version.js.map +1 -0
  85. package/src/lib/get-screen.d.ts +39 -0
  86. package/src/lib/get-screen.js +126 -0
  87. package/src/lib/get-screen.js.map +1 -0
  88. package/src/lib/get-variables.d.ts +41 -0
  89. package/src/lib/get-variables.js +128 -0
  90. package/src/lib/get-variables.js.map +1 -0
  91. package/src/lib/get-video-asset-facts.d.ts +22 -0
  92. package/src/lib/get-video-asset-facts.js +22 -0
  93. package/src/lib/get-video-asset-facts.js.map +1 -0
  94. package/src/lib/host-event-listeners.d.ts +83 -0
  95. package/src/lib/host-event-listeners.js +91 -0
  96. package/src/lib/host-event-listeners.js.map +1 -0
  97. package/src/lib/org-permissions.d.ts +63 -0
  98. package/src/lib/org-permissions.js +187 -0
  99. package/src/lib/org-permissions.js.map +1 -0
  100. package/src/lib/repeat-record-pages.d.ts +48 -0
  101. package/src/lib/repeat-record-pages.js +64 -0
  102. package/src/lib/repeat-record-pages.js.map +1 -0
  103. package/src/lib/required-site-plugins.d.ts +114 -0
  104. package/src/lib/required-site-plugins.js +121 -0
  105. package/src/lib/required-site-plugins.js.map +1 -0
  106. package/src/lib/resolve-dataset.d.ts +40 -0
  107. package/src/lib/resolve-dataset.js +64 -0
  108. package/src/lib/resolve-dataset.js.map +1 -0
  109. package/src/lib/search-results-nodes.d.ts +48 -0
  110. package/src/lib/search-results-nodes.js +302 -0
  111. package/src/lib/search-results-nodes.js.map +1 -0
  112. package/src/lib/social-image-facts.d.ts +107 -0
  113. package/src/lib/social-image-facts.js +133 -0
  114. package/src/lib/social-image-facts.js.map +1 -0
  115. package/src/lib/stamp-form-dataset-bindings.d.ts +32 -0
  116. package/src/lib/stamp-form-dataset-bindings.js +59 -0
  117. package/src/lib/stamp-form-dataset-bindings.js.map +1 -0
  118. package/src/lib/template-screens.d.ts +227 -0
  119. package/src/lib/template-screens.js +364 -0
  120. package/src/lib/template-screens.js.map +1 -0
@@ -0,0 +1,288 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ import { AUTHORS_MAX_PER_HOST, collectionTotalPages, contentAuthorMatchesSlug, contentAuthorSlug, contentAuthorSlugCandidates, hostCollectionKind, normalizeContentAuthor, urlSlugSegment } from "@aglyn/aglyn/server";
18
+ import { firebaseAdmin } from "@aglyn/tenant-data-admin";
19
+ import { PUBLISHED_SITE_DATA_TTL_SECONDS, tenantDataTag, withRenderCache } from "@aglyn/tenant-data-admin/render-cache";
20
+ import { getPublishedCollectionSource } from "./get-collection-content.js";
21
+ /**
22
+ * How many content collections one author page walks.
23
+ *
24
+ * The same window site search reads, and for the same reason: a host's
25
+ * `collections` subcollection holds commerce's catalogs too, so this is a
26
+ * bound on DOCUMENTS SCANNED rather than on content collections found. Sites
27
+ * with more than twenty collections of both kinds are not a shape the product
28
+ * has yet, and the alternative — an unbounded scan on a public, uncached
29
+ * first render — is the shape of an outage.
30
+ */ const AUTHOR_PAGE_COLLECTION_SCAN = 20;
31
+ /** How long the host's author roster stays warm. */ const AUTHORS_TTL_SECONDS = PUBLISHED_SITE_DATA_TTL_SECONDS;
32
+ /**
33
+ * Every author a host has defined, normalized (AGL-2518).
34
+ *
35
+ * ONE cached query, shared by the author page, the sitemap and anything else
36
+ * that needs to turn a slug into a person. Bounded by
37
+ * {@link AUTHORS_MAX_PER_HOST}, which is the platform cap, so the bound can
38
+ * never hide an author that exists.
39
+ *
40
+ * Reading the roster rather than resolving the author out of their own posts
41
+ * is a deliberate reversal of what AGL-2517 did. That version took the record
42
+ * off the first matching entry to avoid a second Firestore read — which meant
43
+ * an author with no published posts had no record, so their page had no name,
44
+ * no bio and no links, and rendered as an empty archive of nobody. A person
45
+ * who has not published yet still has a page; and this read is cached across
46
+ * the whole site, so it costs one query per TTL rather than one per render.
47
+ *
48
+ * Fail-open to an empty roster: the page then falls back to whatever the
49
+ * entries themselves carry, which is the old behavior rather than a 500.
50
+ */ export async function getContentAuthors(options) {
51
+ try {
52
+ return await withRenderCache({
53
+ key: [
54
+ 'tenant-content-authors',
55
+ options.hostId
56
+ ],
57
+ revalidate: AUTHORS_TTL_SECONDS,
58
+ tags: [
59
+ tenantDataTag(options.hostId)
60
+ ],
61
+ read: ()=>readContentAuthors(options.hostId)
62
+ });
63
+ } catch (error) {
64
+ console.error(error);
65
+ return readContentAuthors(options.hostId);
66
+ }
67
+ }
68
+ async function readContentAuthors(hostId) {
69
+ try {
70
+ const snapshot = await firebaseAdmin.app().firestore().collection('hosts').doc(hostId).collection('authors').limit(AUTHORS_MAX_PER_HOST).get();
71
+ return snapshot.docs.map((doc)=>normalizeContentAuthor(doc.data(), doc.id)).filter((author)=>Boolean(author));
72
+ } catch (error) {
73
+ console.error(error);
74
+ return [];
75
+ }
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();
79
+ const collections = [];
80
+ for (const doc of snapshot.docs){
81
+ var _doc_get, _ref, _ref1, _doc_get1;
82
+ // Commerce's catalogs share this path (AGL-954) and own no entries.
83
+ if (hostCollectionKind(doc.data()) !== 'content') continue;
84
+ const slug = String((_doc_get = doc.get('slug')) != null ? _doc_get : '').trim();
85
+ if (!slug) continue;
86
+ collections.push({
87
+ slug,
88
+ name: String((_ref = (_ref1 = (_doc_get1 = doc.get('displayName')) != null ? _doc_get1 : doc.get('name')) != null ? _ref1 : doc.get('title')) != null ? _ref : '').trim() || slug
89
+ });
90
+ }
91
+ return collections;
92
+ }
93
+ /**
94
+ * Everything one author's page shows (AGL-2518) — the person, and what they
95
+ * wrote across the WHOLE site.
96
+ *
97
+ * ## Where the entries come from
98
+ *
99
+ * Every content collection the host owns, through
100
+ * {@link getPublishedCollectionSource} — the same cached per-collection read
101
+ * `/blog` and every "Latest posts" rail already use. So on a warm site this
102
+ * page adds no Firestore reads at all: it is a filter over data the cache is
103
+ * holding anyway. That is the whole reason it walks collections rather than
104
+ * running a collection-group query on `authorId`, which would be one query
105
+ * but would also need its own composite index, would miss every entry written
106
+ * under the legacy free-typed byline (AGL-686), and would share nothing with
107
+ * the rest of the site.
108
+ *
109
+ * ## Why each entry is stamped with its collection
110
+ *
111
+ * One page, several collections, so the routed slug cannot build `entry.url`
112
+ * any more — a changelog note listed under a `/blog` route would link to a
113
+ * page that does not exist. Each entry carries `collectionSlug` and
114
+ * `collectionName` out of the read that found it, and the token map prefers
115
+ * them (`collectionEntryTokens`). Single-collection listings set neither and
116
+ * are unchanged.
117
+ *
118
+ * ## Ordering
119
+ *
120
+ * Newest first by `publishedAt`, with undated entries last rather than first:
121
+ * a draft-turned-live with no timestamp should not lead a person's archive.
122
+ * Sorted ACROSS collections, because the point of the page is a single
123
+ * chronological body of work rather than three lists stacked.
124
+ */ export async function getAuthorContent(options) {
125
+ var _options_authorSlug;
126
+ const { hostId } = options;
127
+ const slug = String((_options_authorSlug = options.authorSlug) != null ? _options_authorSlug : '').trim();
128
+ // The segment as a URL actually spells it. The route parser already
129
+ // slugifies, but this function is called directly by tests and by the
130
+ // sitemap, so it normalizes its own input rather than trusting a caller.
131
+ const slugified = urlSlugSegment(slug);
132
+ const page = Math.max(1, Math.floor(Number(options.page) || 1));
133
+ const perPage = Math.max(1, Math.floor(Number(options.perPage) || 10));
134
+ const empty = {
135
+ slug: slugified,
136
+ author: null,
137
+ name: slugified,
138
+ known: false,
139
+ entries: [],
140
+ categories: [],
141
+ page,
142
+ perPage,
143
+ totalEntries: 0,
144
+ totalPages: 1
145
+ };
146
+ if (!slugified) return empty;
147
+ try {
148
+ var _authors_find;
149
+ var _entries_find;
150
+ const [authors, collections] = await Promise.all([
151
+ getContentAuthors({
152
+ hostId
153
+ }),
154
+ listContentCollections(hostId)
155
+ ]);
156
+ const record = (_authors_find = authors.find((author)=>contentAuthorMatchesSlug({
157
+ author
158
+ }, slug))) != null ? _authors_find : null;
159
+ /*
160
+ Every segment that means this person, resolved ONCE from the record and
161
+ then matched against each entry — rather than asking each entry whether
162
+ it matches the routed segment.
163
+
164
+ The difference is not cosmetic. An entry stores `authorId`; the URL
165
+ carries the author's stored SLUG. Asking the entry alone, its only
166
+ candidate is the id, which does not equal the slug, so the archive comes
167
+ back empty for exactly the authors who set an address — the field whose
168
+ whole purpose is to give them a stable one.
169
+
170
+ It also closes a fail-open hole. `attachEntryAuthors` resolves
171
+ `entry.author` and is deliberately allowed to fail (a byline is not
172
+ worth a 500). When it does, the entry keeps only its `authorId`, and a
173
+ per-entry match against a name-derived segment would silently drop it —
174
+ an archive quietly missing posts, which looks exactly like an author who
175
+ wrote fewer of them.
176
+ */ const accepted = new Set([
177
+ slugified
178
+ ]);
179
+ if (record) {
180
+ for (const candidate of contentAuthorSlugCandidates({
181
+ author: record
182
+ })){
183
+ accepted.add(candidate);
184
+ }
185
+ }
186
+ const matchesAuthor = (entry)=>contentAuthorSlugCandidates(_extends({}, entry.author ? {
187
+ author: entry.author
188
+ } : {}, entry.authorId ? {
189
+ authorId: entry.authorId
190
+ } : {}, entry.authorName ? {
191
+ authorName: entry.authorName
192
+ } : {})).some((candidate)=>accepted.has(candidate));
193
+ const sources = await Promise.all(collections.map(async (collection)=>({
194
+ collection,
195
+ source: await getPublishedCollectionSource({
196
+ hostId,
197
+ collectionSlug: collection.slug
198
+ })
199
+ })));
200
+ const entries = [];
201
+ const categories = [];
202
+ for (const { collection, source } of sources){
203
+ categories.push(...source.categories);
204
+ for (const entry of source.entries){
205
+ if (!matchesAuthor(entry)) continue;
206
+ // Stamped rather than mutated in place: `source.entries` is the
207
+ // CACHED array, shared with every other page rendering this
208
+ // collection, and writing a collection slug onto it would leak this
209
+ // page's context into theirs.
210
+ entries.push(_extends({}, entry, {
211
+ collectionSlug: collection.slug,
212
+ collectionName: collection.name
213
+ }));
214
+ }
215
+ }
216
+ entries.sort((a, b)=>{
217
+ var _ref, _ref1;
218
+ var _b_publishedAt, _a_publishedAt;
219
+ return ((_ref = (_b_publishedAt = b.publishedAt) == null ? void 0 : _b_publishedAt.seconds) != null ? _ref : 0) - ((_ref1 = (_a_publishedAt = a.publishedAt) == null ? void 0 : _a_publishedAt.seconds) != null ? _ref1 : 0);
220
+ });
221
+ // The record wins for the display name; failing that, the byline of a
222
+ // post they actually wrote; failing that, the raw segment.
223
+ const name = (record == null ? void 0 : record.name) || ((_entries_find = entries.find((entry)=>{
224
+ var _entry_authorName;
225
+ return ((_entry_authorName = entry.authorName) != null ? _entry_authorName : '').trim();
226
+ })) == null ? void 0 : _entries_find.authorName) || slugified;
227
+ const totalEntries = entries.length;
228
+ return {
229
+ slug: slugified,
230
+ author: record,
231
+ name,
232
+ known: Boolean(record) || totalEntries > 0,
233
+ /*
234
+ The WHOLE narrowed set, not this page's slice.
235
+
236
+ Narrowing happens before the count, so `totalPages` describes this
237
+ author's work rather than the site's — the category route's rule, one
238
+ axis over. The WINDOW, though, belongs to the Collection entries
239
+ block: it receives `page` and `perPage` and slices for itself
240
+ (`expandCollectionEntries`), exactly as it does on a routed collection
241
+ listing, where `getCollectionContent` also hands over the full
242
+ filtered set.
243
+
244
+ Slicing here as well double-windows and empties every page after the
245
+ first: the block would take `slice(10, 20)` of a ten-element array and
246
+ render nothing. A page-2 archive with a working pager and no cards on
247
+ it — which reads as "this author wrote exactly ten things".
248
+ */ entries,
249
+ categories,
250
+ page,
251
+ perPage,
252
+ totalEntries,
253
+ totalPages: collectionTotalPages(totalEntries, perPage)
254
+ };
255
+ } catch (error) {
256
+ // Fail-open, like every read on this path: a person's page that 500s is
257
+ // worse than one that renders their name and nothing else.
258
+ console.error('author content read failed', error);
259
+ return empty;
260
+ }
261
+ }
262
+ /**
263
+ * Every author page this site can serve, for the sitemap (AGL-2518).
264
+ *
265
+ * Roster order, and only authors that address something: an author whose
266
+ * record has neither a slug nor a name has no URL, and listing one would put
267
+ * `/author/` in the sitemap.
268
+ */ export async function listAuthorPageSlugs(options) {
269
+ const authors = await getContentAuthors(options);
270
+ const seen = new Set();
271
+ const rows = [];
272
+ for (const author of authors){
273
+ var _author_name;
274
+ const slug = contentAuthorSlug({
275
+ author
276
+ });
277
+ if (!slug || seen.has(slug)) continue;
278
+ seen.add(slug);
279
+ rows.push({
280
+ slug,
281
+ name: (_author_name = author.name) != null ? _author_name : slug
282
+ });
283
+ }
284
+ return rows;
285
+ }
286
+ export default getAuthorContent;
287
+
288
+ //# sourceMappingURL=get-author-content.js.map
@@ -0,0 +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":"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"}
@@ -0,0 +1,290 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import { type CollectionCategory, type ContentAuthorRecord } from '@aglyn/aglyn/server';
18
+ export interface CollectionEntrySummary {
19
+ $id: string;
20
+ title: string;
21
+ slug: string;
22
+ excerpt?: string;
23
+ /**
24
+ * The byline TEXT (AGL-686). Either the entry's own legacy free-typed
25
+ * string or — since AGL-2486 — the name of the author record `authorId`
26
+ * points at, resolved here so every downstream reader (the Entry Meta
27
+ * block, `{{entry.author}}`, the RSS feed) keeps asking one field.
28
+ */
29
+ authorName?: string;
30
+ /** Reference into `hosts/{hostId}/authors` (AGL-2486). */
31
+ authorId?: string;
32
+ /**
33
+ * The resolved author RECORD (AGL-2486) — what `Article.author` is built
34
+ * from. Null when the entry names no author, in which case the page falls
35
+ * back to the site's publisher entity exactly as it always has.
36
+ */
37
+ author?: ContentAuthorRecord | null;
38
+ body?: string;
39
+ coverImage?: string;
40
+ /** `og:image:alt` for the cover (AGL-2417); travels WITH `coverImage`. */
41
+ coverImageAlt?: string;
42
+ /**
43
+ * The featured video (AGL-2956): a media reference or a URL, a Wistia link
44
+ * included, in the shape `coverImage` is. See
45
+ * `CollectionEntryRecord.coverVideo`.
46
+ */
47
+ coverVideo?: string;
48
+ /** Search-result title override (AGL-582); falls back to `title`. */
49
+ seoTitle?: string;
50
+ /** Meta description override (AGL-582); falls back to `excerpt`. */
51
+ seoDescription?: string;
52
+ /**
53
+ * Stable reference into the collection's `categories` taxonomy
54
+ * (AGL-582); resolved to a display name at render.
55
+ */
56
+ categoryId?: string;
57
+ /** Legacy free-typed bucket (AGL-582); read-only fallback. */
58
+ category?: string;
59
+ /** Free-form labels (AGL-582). */
60
+ tags?: string[];
61
+ publishedAt?: {
62
+ seconds: number;
63
+ } | null;
64
+ /**
65
+ * Last edited, which is what `Article.dateModified` publishes (AGL-2534).
66
+ *
67
+ * Distinct from {@link publishedAt} on purpose: re-dating a post is not
68
+ * editing it, so the console writes `publishedAt` alone when an author
69
+ * backdates and this stays put. Google reads `dateModified` for freshness.
70
+ */
71
+ updatedAt?: {
72
+ seconds: number;
73
+ } | null;
74
+ /**
75
+ * The collection this entry came out of (AGL-2518), stamped only by a
76
+ * reader that MIXES collections — the author page. Unset on every routed
77
+ * listing, where the route already answers the question. See
78
+ * `CollectionEntryRecord.collectionSlug`.
79
+ */
80
+ collectionSlug?: string;
81
+ /** The display name of {@link collectionSlug}. */
82
+ collectionName?: string;
83
+ }
84
+ /** A scheduled entry whose time has come — before any plan question. */
85
+ export declare function isDueScheduled(value: FirebaseFirestore.DocumentData): boolean;
86
+ /**
87
+ * A scheduled entry still waiting on its time: not due yet, and not refused.
88
+ * Nothing but a render publishes a content entry, so a cache that stored a
89
+ * read holding one would withhold the render that notices it come due — see
90
+ * {@link LiveEntriesRead.pendingSchedule}.
91
+ */
92
+ export declare function isPendingScheduled(value: FirebaseFirestore.DocumentData): boolean;
93
+ /**
94
+ * Is this host's plan allowed to publish on a schedule? (AGL-471 shape.)
95
+ *
96
+ * `React.cache`-deduped per request via `getOrgForHost`, and — this is the
97
+ * part that keeps it off the hot path — every caller below only asks once it
98
+ * has already found a due scheduled entry. A collection with nothing due pays
99
+ * nothing, which is almost every render.
100
+ *
101
+ * THREE answers, not two, and the third is the point. `refused` means we read
102
+ * the plan and it does not carry the entitlement. `unresolved` means we could
103
+ * not find out. Both withhold the entry, but only `refused` may write the
104
+ * terminal marker — burning a schedule permanently on the strength of a
105
+ * hostIndex miss or a transient rejection would destroy a customer's post for
106
+ * a reason that may not be true a second later.
107
+ *
108
+ * Withholding on `unresolved` rather than publishing is what every other
109
+ * entitlement caller on the tenant runtime already does: `apply-publish-schedule`
110
+ * here, and the automation engine's two runners, all pass a possibly
111
+ * undefined org straight into `checkEntitlement`, which resolves a missing
112
+ * plan as free and denies (AGL-247). Opening here instead would make this the
113
+ * one gate in the lib that admits when it cannot see — the exact shape of the
114
+ * free-tier leak `no-plan-gated-entitlement` exists to forbid.
115
+ *
116
+ * The blast radius of withholding is deliberately small: `isLive` answers true
117
+ * for `status: 'published'` before it ever consults this, so an unresolved
118
+ * read hides only the due-scheduled entry, never the published ones, and the
119
+ * next render retries.
120
+ */
121
+ export type SchedulePermission = 'allowed' | 'refused' | 'unresolved';
122
+ export declare function scheduledPublishingPermission(hostId: string): Promise<SchedulePermission>;
123
+ /**
124
+ * Scheduled entries (AGL-123) go live lazily like AGL-61: a due
125
+ * `publishAt` counts as published for this render, and the doc is flipped
126
+ * to `published` fail-open so the state becomes durable.
127
+ *
128
+ * PLAN GATE (AGL-471). `scheduledPublishing` is a Business entitlement, and
129
+ * until now nothing on the entry path checked it: the console let any plan
130
+ * write `status: 'scheduled'`, and this render path published it. Scheduling
131
+ * worked end to end on Free. The screens path has gated this since AGL-471
132
+ * and records its refusal since AGL-1185 — entries were simply never wired
133
+ * to either, which is why the leak was invisible from the screens side.
134
+ *
135
+ * The permission is threaded in rather than resolved here so the org read
136
+ * happens once per call site instead of once per entry.
137
+ *
138
+ * Exported for the one other reader that decides whether an entry is on the
139
+ * site — whether a LINK to it resolves (AGL-3118) — so a link and the page it
140
+ * points at can never disagree about which entries exist.
141
+ */
142
+ export declare function isLive(value: FirebaseFirestore.DocumentData, permission: SchedulePermission): boolean;
143
+ export interface CollectionContent {
144
+ collection: {
145
+ $id: string;
146
+ displayName: string;
147
+ slug: string;
148
+ /**
149
+ * Legacy entry-template screen (AGL-105); superseded by
150
+ * `entryScreenId` but still honored when only it is set.
151
+ */
152
+ templateScreenId?: string;
153
+ /** List-template screen (AGL-551); `/{collection}` renders through it. */
154
+ listScreenId?: string;
155
+ /**
156
+ * Entry-template screen (AGL-551); `/{collection}/{entry}` renders
157
+ * through it with `{{entry.*}}` tokens.
158
+ */
159
+ entryScreenId?: string;
160
+ /**
161
+ * What KIND of article this collection publishes (AGL-2536) — the
162
+ * `schema.org` type its entries serialise as. Unset publishes `Article`,
163
+ * which is what every collection published before the setting existed.
164
+ */
165
+ schemaType?: string;
166
+ /**
167
+ * Category taxonomy (AGL-582): entries reference these by stable
168
+ * `id`; `name` is the renameable display label.
169
+ */
170
+ categories?: CollectionCategory[];
171
+ } | null;
172
+ entries: CollectionEntrySummary[];
173
+ entry: CollectionEntrySummary | null;
174
+ /**
175
+ * Whether the read that produced `entries` stopped at
176
+ * {@link COLLECTION_SOURCE_MAX} (AGL-1516). Set on LIST routes only —
177
+ * an entry route reads one document by slug and bounds nothing.
178
+ */
179
+ entriesReachedBound?: boolean;
180
+ /** List pagination (AGL-620); null for entry pages or unpaginated lists. */
181
+ pagination?: CollectionPagination | null;
182
+ /**
183
+ * The category this listing is filtered to (AGL-1321); null on the
184
+ * canonical unfiltered list and on entry pages.
185
+ */
186
+ category?: CollectionRouteCategory | null;
187
+ error: unknown;
188
+ }
189
+ /** The category a `/{collection}/category/{slug}` route addresses (AGL-1321). */
190
+ export interface CollectionRouteCategory {
191
+ /** The URL segment, normalized — what the canonical link must say. */
192
+ slug: string;
193
+ /** Taxonomy id; absent when the segment matched no known category. */
194
+ id?: string;
195
+ /** Display label; falls back to the raw segment for an unknown category. */
196
+ name: string;
197
+ /**
198
+ * The taxonomy's {@link CollectionCategory.description}, carried onto the
199
+ * route so the head can describe the FILTERED listing rather than inherit
200
+ * the whole collection's description. Absent for an unknown segment, which
201
+ * names no category and therefore has nothing to describe.
202
+ */
203
+ description?: string;
204
+ /**
205
+ * Whether the segment resolved against the collection's taxonomy. An
206
+ * unknown category still renders — an empty listing, not a crash — but the
207
+ * page must not invite indexing of a URL that names nothing.
208
+ */
209
+ known: boolean;
210
+ }
211
+ export interface CollectionPagination {
212
+ /** 1-based current page. */
213
+ page: number;
214
+ perPage: number;
215
+ totalPages: number;
216
+ totalEntries: number;
217
+ }
218
+ /** Compose-time view of a collection: its live entries and its taxonomy. */
219
+ export interface PublishedCollectionSource {
220
+ /**
221
+ * The collection DOCUMENT this source was read from — its display name and
222
+ * its template screen ids — or null when the slug names no content
223
+ * collection.
224
+ *
225
+ * Carried so a routed listing can be served ENTIRELY from this cached
226
+ * source. `getCollectionContent` used to resolve the same document itself
227
+ * and then read the same entries again uncached, which meant `/blog`,
228
+ * every `/blog/page/{n}`, every `/blog/category/{slug}` and the RSS feed
229
+ * each paid a full collection read per regeneration while the identical
230
+ * data already sat in this cache for every OTHER page on the site.
231
+ */
232
+ collection: CollectionContent['collection'];
233
+ entries: CollectionEntrySummary[];
234
+ categories: CollectionCategory[];
235
+ /**
236
+ * Whether the read saw a schedule it is still waiting on — see
237
+ * {@link LiveEntriesRead.pendingSchedule}. Never stored, only consulted by
238
+ * the cache above, so no consumer has to know about it.
239
+ */
240
+ pendingSchedule?: boolean;
241
+ /**
242
+ * Whether the entries read stopped at {@link COLLECTION_SOURCE_MAX}
243
+ * (AGL-1516) — carried out of the loader because `entries.length` cannot
244
+ * answer it once the liveness filter has run. Fail-open paths report
245
+ * `false`: an empty result is not a bounded read, and describing it as one
246
+ * would tell a reader their search covered less than it did.
247
+ */
248
+ reachedBound: boolean;
249
+ }
250
+ /**
251
+ * Published entries + category taxonomy for a collection resolved by slug —
252
+ * the data source of the Collection entries block on arbitrary screens
253
+ * (AGL-551/582). Fail-open: errors and unknown slugs resolve to an empty
254
+ * list so a renamed collection never takes a published screen down.
255
+ */
256
+ export declare function getPublishedCollectionSource(options: {
257
+ hostId: string;
258
+ collectionSlug: string;
259
+ }): Promise<PublishedCollectionSource>;
260
+ /** Entries-only view of {@link getPublishedCollectionSource} (AGL-551). */
261
+ export declare function getPublishedCollectionEntries(options: {
262
+ hostId: string;
263
+ collectionSlug: string;
264
+ }): Promise<CollectionEntrySummary[]>;
265
+ /**
266
+ * Resolves a non-screen path against the host's content collections
267
+ * (Content Collections & Blog): `/{collectionSlug}` returns the published
268
+ * entry list, `/{collectionSlug}/{entrySlug}` one entry. Fail-open — errors
269
+ * resolve to `collection: null` and the caller 404s.
270
+ *
271
+ * A listing resolves only for a collection with a live entry (AGL-3101); one
272
+ * with nothing live answers `collection: null` as well, so its listing, feed
273
+ * and markdown twin are not public until its first entry is.
274
+ */
275
+ export declare function getCollectionContent(options: {
276
+ hostId: string;
277
+ collectionSlug: string;
278
+ entrySlug?: string;
279
+ /** 1-based list page (AGL-620); with `perPage`, drives pagination metadata. */
280
+ page?: number;
281
+ /** Entries per page (AGL-620); when set the list is paginated. */
282
+ perPage?: number;
283
+ /**
284
+ * Category segment of `/{collection}/category/{slug}` (AGL-1321). Filters
285
+ * the listing before pagination is computed, so page counts and the page
286
+ * windows describe the FILTERED set rather than the whole collection.
287
+ */
288
+ categorySlug?: string;
289
+ }): Promise<CollectionContent>;
290
+ export default getCollectionContent;