@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,565 @@
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, collectionCategorySlug, checkEntitlement, COLLECTION_SOURCE_MAX, collectionTotalPages, entryMatchesCategoryRoute, hostCollectionKind, normalizeContentAuthor, normalizeContentSchemaType, resolveCollectionCategoryBySlug, resolveEntryAuthor } from "@aglyn/aglyn/server";
18
+ import { firebaseAdmin, getOrgForHost } from "@aglyn/tenant-data-admin";
19
+ import { PUBLISHED_SITE_DATA_TTL_SECONDS, tenantDataTag, withRenderCache } from "@aglyn/tenant-data-admin/render-cache";
20
+ /**
21
+ * ONE cached read per collection, shared by every surface that lists it
22
+ * (AGL-1302).
23
+ *
24
+ * It began as the compose-time source only: a Collection entries block in a
25
+ * shared layout re-read up to ~100 entry docs on EVERY page of the site. The
26
+ * routed listing was left out on the argument that the page's own ISR entry
27
+ * amortized it — which is true of ONE address and false of a collection,
28
+ * because a collection is not one address. `/blog`, `/blog/page/2…10`, a
29
+ * `/blog/category/{slug}` per category and `/blog/rss.xml` are all the same
30
+ * data, each paying its own `1 + entries + authors` per window, beside a
31
+ * cache already holding exactly that.
32
+ *
33
+ * The other half of that argument was real and is answered rather than
34
+ * dropped: `flipDueEntry` is a write, and nothing else publishes a content
35
+ * entry, so a cache that stored a collection with a schedule still pending
36
+ * would suppress the render that publishes it. `getPublishedCollectionSource`
37
+ * therefore declines to STORE exactly those collections — see its `store`
38
+ * predicate — which leaves scheduled publishing on the render window it has
39
+ * always been on, and puts everything else on this TTL.
40
+ */ const COLLECTION_SOURCE_TTL_SECONDS = PUBLISHED_SITE_DATA_TTL_SECONDS;
41
+ /**
42
+ * Resolve a public content-collection slug (AGL-954). Commerce's product
43
+ * collections share `hosts/{hostId}/collections`, and a slug is only unique
44
+ * within a kind — a bare `limit(1)` handed the URL to whichever doc Firestore
45
+ * returned first, so a catalog collection could shadow a blog. Reads a small
46
+ * window instead and takes the first content-kind match.
47
+ */ async function findContentCollection(hostId, collectionSlug) {
48
+ const matches = await firebaseAdmin.app().firestore().collection('hosts').doc(hostId).collection('collections').where('slug', '==', collectionSlug).limit(5).get();
49
+ return matches.docs.find((docSnapshot)=>hostCollectionKind(docSnapshot.data()) === 'content');
50
+ }
51
+ /** Entry-doc fields shared by the list and single-entry mappers (AGL-582). */ function mapEntryFields(value) {
52
+ var _value_excerpt, _value_authorName, _value_authorId, _value_coverImage, _value_coverImageAlt, _value_coverVideo, _value_seoTitle, _value_seoDescription, _value_categoryId, _value_category;
53
+ var _value_updatedAt;
54
+ return {
55
+ excerpt: (_value_excerpt = value['excerpt']) != null ? _value_excerpt : '',
56
+ // The byline was DECLARED on `CollectionEntrySummary` (AGL-686) and
57
+ // mapped by nobody, so `entry.authorName` was `undefined` on every entry
58
+ // this loader returned — which is every routed entry page and every
59
+ // Collection entries block. The console collected the field, the rules
60
+ // stored it, the JSON-LD builder read it and the Entry Meta block printed
61
+ // it, and all three saw nothing, because the one hop between Firestore
62
+ // and them dropped it (AGL-2486). Written but never read, in the
63
+ // direction that leaves no error behind.
64
+ authorName: (_value_authorName = value['authorName']) != null ? _value_authorName : '',
65
+ authorId: (_value_authorId = value['authorId']) != null ? _value_authorId : '',
66
+ coverImage: (_value_coverImage = value['coverImage']) != null ? _value_coverImage : '',
67
+ coverImageAlt: (_value_coverImageAlt = value['coverImageAlt']) != null ? _value_coverImageAlt : '',
68
+ // Here, where both read paths pick it up, so a list card and the routed
69
+ // entry page can each bind the featured video (AGL-2956).
70
+ coverVideo: (_value_coverVideo = value['coverVideo']) != null ? _value_coverVideo : '',
71
+ seoTitle: (_value_seoTitle = value['seoTitle']) != null ? _value_seoTitle : '',
72
+ seoDescription: (_value_seoDescription = value['seoDescription']) != null ? _value_seoDescription : '',
73
+ categoryId: (_value_categoryId = value['categoryId']) != null ? _value_categoryId : '',
74
+ category: (_value_category = value['category']) != null ? _value_category : '',
75
+ tags: Array.isArray(value['tags']) ? value['tags'].filter((tag)=>typeof tag === 'string') : [],
76
+ /*
77
+ `dateModified`'s source (AGL-2534), and it was missing for the same
78
+ reason `authorName` was — the reason this function's own comment above
79
+ describes.
80
+
81
+ The console has written `updatedAt` on every save, `page.tsx` reads
82
+ `entry.updatedAt.seconds` to publish `Article.dateModified`, a spec
83
+ asserts the conversion, and two console comments explain why it must not
84
+ track `publishedAt`. Nothing mapped it, so `entry.updatedAt` was
85
+ `undefined` on every entry the loader has ever returned and no published
86
+ article has ever carried a `dateModified` — the freshness signal Google
87
+ reads. The spec passed throughout because it builds its entry object by
88
+ hand and never crosses this boundary.
89
+
90
+ Mapped HERE rather than beside `publishedAt` at the two call sites: this
91
+ is an ordinary field with no `publishAt` fallback and nothing sorts on
92
+ it, so one place is enough — and one place is what stops the next read
93
+ path from forgetting it.
94
+ */ updatedAt: ((_value_updatedAt = value['updatedAt']) == null ? void 0 : _value_updatedAt.seconds) ? {
95
+ seconds: value['updatedAt'].seconds
96
+ } : null
97
+ };
98
+ }
99
+ /**
100
+ * The collection doc's category taxonomy (AGL-582), sanitized: only
101
+ * `{ id, name }` pairs with non-empty strings survive, order preserved.
102
+ *
103
+ * `description` rides along when the author wrote one and is dropped when it
104
+ * is blank or not a string, so the head can tell "described" from "not
105
+ * described" by truthiness alone — an empty string reaching the metadata
106
+ * would suppress the template screen's description and leave the listing with
107
+ * none at all.
108
+ */ function mapCollectionCategories(value) {
109
+ if (!Array.isArray(value)) return [];
110
+ return value.filter((item)=>typeof (item == null ? void 0 : item.id) === 'string' && item.id.trim() !== '' && typeof (item == null ? void 0 : item.name) === 'string' && item.name.trim() !== '').map((item)=>{
111
+ const description = typeof item.description === 'string' ? item.description.trim() : '';
112
+ return _extends({
113
+ id: item.id,
114
+ name: item.name
115
+ }, description ? {
116
+ description
117
+ } : {});
118
+ });
119
+ }
120
+ /**
121
+ * The routed view of a collection DOCUMENT (AGL-551): its name and the
122
+ * template screens its list and entry routes render through.
123
+ *
124
+ * `slug` is the slug that was ASKED FOR rather than the one stored, which is
125
+ * what every caller of this file has always returned — a listing has to build
126
+ * its own URLs out of the segment the reader is standing on.
127
+ */ function mapCollectionDoc(collectionDoc, collectionSlug) {
128
+ var _collectionDoc_get, _collectionDoc_get1, _collectionDoc_get2, _collectionDoc_get3;
129
+ return {
130
+ $id: collectionDoc.id,
131
+ displayName: (_collectionDoc_get = collectionDoc.get('displayName')) != null ? _collectionDoc_get : collectionSlug,
132
+ slug: collectionSlug,
133
+ templateScreenId: (_collectionDoc_get1 = collectionDoc.get('templateScreenId')) != null ? _collectionDoc_get1 : undefined,
134
+ listScreenId: (_collectionDoc_get2 = collectionDoc.get('listScreenId')) != null ? _collectionDoc_get2 : undefined,
135
+ entryScreenId: (_collectionDoc_get3 = collectionDoc.get('entryScreenId')) != null ? _collectionDoc_get3 : undefined,
136
+ // Normalized HERE rather than at the head (AGL-2536), so an unrecognised
137
+ // stored value can never reach the JSON-LD: an `@type` the vocabulary
138
+ // does not define makes a consumer discard the whole node, costing the
139
+ // page every property it publishes rather than just this one.
140
+ schemaType: normalizeContentSchemaType(collectionDoc.get('schemaType')),
141
+ categories: mapCollectionCategories(collectionDoc.get('categories'))
142
+ };
143
+ }
144
+ /**
145
+ * Resolve the author RECORDS a set of entries reference (AGL-2486).
146
+ *
147
+ * Costs ZERO reads when no entry names an `authorId`, which is every site
148
+ * that has not adopted custom authors and every entry written before them —
149
+ * the check is on the ids already in hand, not a probe of the collection. When
150
+ * ids are present it is one `getAll` of the DISTINCT ones, bounded by
151
+ * {@link AUTHORS_MAX_PER_HOST} and by the ≤100-entry page above it, rather
152
+ * than a read per entry.
153
+ *
154
+ * Fail-open, like every other read in this file: an authors read that throws
155
+ * leaves the entries with their legacy `authorName` (or the site entity) and
156
+ * the page renders. A byline is not worth a 500.
157
+ */ async function attachEntryAuthors(hostId, entries) {
158
+ const ids = [
159
+ ...new Set(entries.map((entry)=>{
160
+ var _entry_authorId;
161
+ return ((_entry_authorId = entry.authorId) != null ? _entry_authorId : '').trim();
162
+ }).filter(Boolean))
163
+ ].slice(0, AUTHORS_MAX_PER_HOST);
164
+ let authors = [];
165
+ if (ids.length) {
166
+ try {
167
+ const authorsRef = firebaseAdmin.app().firestore().collection('hosts').doc(hostId).collection('authors');
168
+ const snapshots = await firebaseAdmin.app().firestore().getAll(...ids.map((id)=>authorsRef.doc(id)));
169
+ authors = snapshots.map((snapshot)=>snapshot.exists ? normalizeContentAuthor(snapshot.data(), snapshot.id) : null).filter((author)=>Boolean(author));
170
+ } catch (error) {
171
+ console.error(error);
172
+ }
173
+ }
174
+ for (const entry of entries){
175
+ const author = resolveEntryAuthor(entry, authors);
176
+ entry.author = author;
177
+ // The byline TEXT is denormalized onto the field everything downstream
178
+ // already reads, so a record-backed author needs no change in the Entry
179
+ // Meta block, the token map, the fallback nodes or the RSS feed. A record
180
+ // WINS over the legacy string on the same entry: picking an author is the
181
+ // more recent statement of who wrote it.
182
+ if (author == null ? void 0 : author.name) entry.authorName = author.name;
183
+ }
184
+ }
185
+ /**
186
+ * Terminal refusal marker for an entry schedule, the flat-status twin of
187
+ * `publishSchedule.status: 'skipped-unentitled'` on screens (AGL-1185).
188
+ *
189
+ * A field rather than a new `status` value on purpose. `status` is queried
190
+ * (`where('status', 'in', [...])`), rendered by the console, and sorted on by
191
+ * `bundle-timestamps.ts`; adding a member would have meant auditing every one
192
+ * of those readers. A sibling field is invisible to all of them and is read
193
+ * only here.
194
+ */ const ENTRY_SCHEDULE_SKIPPED = 'skipped-unentitled';
195
+ /** A schedule this entry already had declined, and will not have reconsidered. */ function scheduleAlreadyRefused(value) {
196
+ return value['scheduleStatus'] === ENTRY_SCHEDULE_SKIPPED;
197
+ }
198
+ /** A scheduled entry whose time has come — before any plan question. */ export function isDueScheduled(value) {
199
+ var _ref;
200
+ var _value_publishAt;
201
+ return value['status'] === 'scheduled' && !scheduleAlreadyRefused(value) && ((_ref = (_value_publishAt = value['publishAt']) == null ? void 0 : _value_publishAt.seconds) != null ? _ref : Number.POSITIVE_INFINITY) * 1000 <= Date.now();
202
+ }
203
+ /**
204
+ * A scheduled entry still waiting on its time: not due yet, and not refused.
205
+ * Nothing but a render publishes a content entry, so a cache that stored a
206
+ * read holding one would withhold the render that notices it come due — see
207
+ * {@link LiveEntriesRead.pendingSchedule}.
208
+ */ export function isPendingScheduled(value) {
209
+ return value['status'] === 'scheduled' && !scheduleAlreadyRefused(value) && !isDueScheduled(value);
210
+ }
211
+ export async function scheduledPublishingPermission(hostId) {
212
+ try {
213
+ var _this;
214
+ const org = (_this = await getOrgForHost(hostId)) == null ? void 0 : _this.org;
215
+ if (!org) return 'unresolved';
216
+ return checkEntitlement(org, 'scheduledPublishing') ? 'allowed' : 'refused';
217
+ } catch (error) {
218
+ // Caught rather than thrown: the only caller sits inside
219
+ // `getCollectionContent`'s try/catch, which returns an EMPTY collection —
220
+ // so an unhandled rejection here would blank every published entry on the
221
+ // page, not just the scheduled one.
222
+ console.error(error);
223
+ return 'unresolved';
224
+ }
225
+ }
226
+ /**
227
+ * Scheduled entries (AGL-123) go live lazily like AGL-61: a due
228
+ * `publishAt` counts as published for this render, and the doc is flipped
229
+ * to `published` fail-open so the state becomes durable.
230
+ *
231
+ * PLAN GATE (AGL-471). `scheduledPublishing` is a Business entitlement, and
232
+ * until now nothing on the entry path checked it: the console let any plan
233
+ * write `status: 'scheduled'`, and this render path published it. Scheduling
234
+ * worked end to end on Free. The screens path has gated this since AGL-471
235
+ * and records its refusal since AGL-1185 — entries were simply never wired
236
+ * to either, which is why the leak was invisible from the screens side.
237
+ *
238
+ * The permission is threaded in rather than resolved here so the org read
239
+ * happens once per call site instead of once per entry.
240
+ *
241
+ * Exported for the one other reader that decides whether an entry is on the
242
+ * site — whether a LINK to it resolves (AGL-3118) — so a link and the page it
243
+ * points at can never disagree about which entries exist.
244
+ */ export function isLive(value, permission) {
245
+ if (value['status'] === 'published') return true;
246
+ return permission === 'allowed' && isDueScheduled(value);
247
+ }
248
+ /**
249
+ * Make the due state durable — or record that it was refused.
250
+ *
251
+ * The refusal is TERMINAL, for the AGL-1185 reason: left as a bare pending
252
+ * `scheduled`, the entry stays permanently due, so the day the org upgrades
253
+ * to Business the next render publishes it. Content scheduled on a plan that
254
+ * could not honour it, and forgotten, surfacing during an upgrade — exactly
255
+ * when nobody is looking for it. Recording the refusal is what makes it stop
256
+ * being due, and it also stops this path re-reading the org on every
257
+ * subsequent render.
258
+ *
259
+ * Both writes fail open: an error leaves today's state, and the next render
260
+ * retries.
261
+ */ function flipDueEntry(docRef, value, permission) {
262
+ if (value['status'] !== 'scheduled') return;
263
+ if (scheduleAlreadyRefused(value)) return;
264
+ // `unresolved` writes NOTHING. It withholds this render and leaves the
265
+ // schedule exactly as it found it, so a later render can still publish it.
266
+ if (permission === 'unresolved') return;
267
+ if (permission === 'refused') {
268
+ if (!isDueScheduled(value)) return;
269
+ docRef.update({
270
+ scheduleStatus: ENTRY_SCHEDULE_SKIPPED
271
+ }).catch((error)=>console.error(error));
272
+ return;
273
+ }
274
+ docRef.update({
275
+ status: 'published',
276
+ publishedAt: value['publishAt']
277
+ }).catch((error)=>console.error(error));
278
+ }
279
+ /**
280
+ * Fetches a collection's live entries (newest first), shared by the route
281
+ * loader and the compose-time Collection entries block (AGL-551).
282
+ */ async function listLiveEntries(entriesRef, hostId) {
283
+ // No orderBy: entries missing publishedAt would be dropped by Firestore;
284
+ // sort client-side like the version lists.
285
+ const entriesQuery = await entriesRef.where('status', 'in', [
286
+ 'published',
287
+ 'scheduled'
288
+ ])// Named rather than literal (AGL-1516): a search index has to be able to
289
+ // say "this read reached its bound", and it can only do that against a
290
+ // bound it shares with the query. `collectionSourceReachedBound` reads
291
+ // the same constant.
292
+ .limit(COLLECTION_SOURCE_MAX).get();
293
+ // Ask the plan question ONLY if something is actually due (AGL-471). A
294
+ // collection with no due schedule — almost every render — never reads the
295
+ // org at all.
296
+ const due = entriesQuery.docs.filter((entryDoc)=>isDueScheduled(entryDoc.data()));
297
+ const permission = due.length ? await scheduledPublishingPermission(hostId) : 'allowed';
298
+ // Record the terminal refusal on its own pass, because a refused entry is
299
+ // NOT live and so never reaches the `flipDueEntry` inside the map below.
300
+ // Without this the entry stays due forever: excluded from every render, and
301
+ // re-reading the org on each one.
302
+ if (permission !== 'allowed') {
303
+ for (const entryDoc of due){
304
+ flipDueEntry(entryDoc.ref, entryDoc.data(), permission);
305
+ }
306
+ }
307
+ // Measured on the RAW docs, before the liveness filter (AGL-1516). This is
308
+ // the only place that can still see how many documents the query returned;
309
+ // one line down that number is gone for good.
310
+ const reachedBound = entriesQuery.docs.length >= COLLECTION_SOURCE_MAX;
311
+ // Also measured on the RAW docs, and for the same reason: a not-yet-due
312
+ // entry is filtered out one line down, so this is the last place that can
313
+ // see one at all.
314
+ const pendingSchedule = entriesQuery.docs.some((entryDoc)=>isPendingScheduled(entryDoc.data()));
315
+ const entries = entriesQuery.docs.filter((entryDoc)=>isLive(entryDoc.data(), permission)).map((entryDoc)=>{
316
+ var _value_title, _value_slug, _value_publishedAt, _value_publishedAt1;
317
+ const value = entryDoc.data();
318
+ flipDueEntry(entryDoc.ref, value, permission);
319
+ return _extends({
320
+ $id: entryDoc.id,
321
+ title: (_value_title = value['title']) != null ? _value_title : entryDoc.id,
322
+ slug: (_value_slug = value['slug']) != null ? _value_slug : entryDoc.id
323
+ }, mapEntryFields(value), {
324
+ publishedAt: ((_value_publishedAt = value['publishedAt']) != null ? _value_publishedAt : value['publishAt']) ? {
325
+ seconds: ((_value_publishedAt1 = value['publishedAt']) != null ? _value_publishedAt1 : value['publishAt']).seconds
326
+ } : null
327
+ });
328
+ }).sort((a, b)=>{
329
+ var _ref, _ref1;
330
+ var _b_publishedAt, _a_publishedAt;
331
+ 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);
332
+ });
333
+ return {
334
+ entries,
335
+ reachedBound,
336
+ pendingSchedule
337
+ };
338
+ }
339
+ /**
340
+ * Published entries + category taxonomy for a collection resolved by slug —
341
+ * the data source of the Collection entries block on arbitrary screens
342
+ * (AGL-551/582). Fail-open: errors and unknown slugs resolve to an empty
343
+ * list so a renamed collection never takes a published screen down.
344
+ */ export async function getPublishedCollectionSource(options) {
345
+ try {
346
+ return await withRenderCache({
347
+ key: [
348
+ 'tenant-collection-source',
349
+ options.hostId,
350
+ options.collectionSlug
351
+ ],
352
+ revalidate: COLLECTION_SOURCE_TTL_SECONDS,
353
+ tags: [
354
+ tenantDataTag(options.hostId)
355
+ ],
356
+ read: ()=>readPublishedCollectionSource(options),
357
+ // A collection with a schedule still pending is SERVED but not STORED.
358
+ //
359
+ // No beat publishes a content entry — `flipDueEntry` during a render is
360
+ // the whole mechanism — so storing this source would suppress the very
361
+ // renders that would have noticed the entry coming due, and the post
362
+ // would wait out the TTL rather than land at its time. Declining to
363
+ // store leaves those collections exactly as uncached as they were
364
+ // before this cache existed, which is the only cost this cache is not
365
+ // allowed to reduce.
366
+ //
367
+ // Also refuses a collection that resolved to nothing, for the reason
368
+ // `withRenderCache` states about negatives generally: a slug that
369
+ // misses once must not miss for an hour.
370
+ store: (value)=>Boolean(value.collection) && !value.pendingSchedule
371
+ });
372
+ } catch (error) {
373
+ console.error(error);
374
+ return readPublishedCollectionSource(options);
375
+ }
376
+ }
377
+ async function readPublishedCollectionSource(options) {
378
+ try {
379
+ const collectionDoc = await findContentCollection(options.hostId, options.collectionSlug);
380
+ if (!collectionDoc) {
381
+ return {
382
+ collection: null,
383
+ entries: [],
384
+ categories: [],
385
+ reachedBound: false
386
+ };
387
+ }
388
+ const { entries, reachedBound, pendingSchedule } = await listLiveEntries(collectionDoc.ref.collection('entries'), options.hostId);
389
+ // The compose-time source feeds the Collection entries block, whose byline
390
+ // reads `authorName` — so a record-backed author has to be resolved here
391
+ // too, or the block prints nothing for the entries a list page shows
392
+ // (AGL-2486). This result is the cached one, which is what keeps the extra
393
+ // read amortized across every page of the site that carries the block.
394
+ await attachEntryAuthors(options.hostId, entries);
395
+ return _extends({
396
+ collection: mapCollectionDoc(collectionDoc, options.collectionSlug),
397
+ entries,
398
+ categories: mapCollectionCategories(collectionDoc.get('categories')),
399
+ reachedBound
400
+ }, pendingSchedule ? {
401
+ pendingSchedule: true
402
+ } : {});
403
+ } catch (error) {
404
+ console.error(error);
405
+ return {
406
+ collection: null,
407
+ entries: [],
408
+ categories: [],
409
+ reachedBound: false
410
+ };
411
+ }
412
+ }
413
+ /** Entries-only view of {@link getPublishedCollectionSource} (AGL-551). */ export async function getPublishedCollectionEntries(options) {
414
+ return (await getPublishedCollectionSource(options)).entries;
415
+ }
416
+ /**
417
+ * Narrows a listing to its routed category and stamps its pagination
418
+ * (AGL-1321 / AGL-620).
419
+ *
420
+ * Category first, ALWAYS: the two have to describe the same set. Counting
421
+ * pages over the whole collection and then filtering would advertise pages
422
+ * that render empty and hide entries that exist.
423
+ *
424
+ * `entriesReachedBound` is read by the caller BEFORE this runs, because a
425
+ * category route hands its already-narrowed entries to compose and the
426
+ * entries block's "measure the raw set, not the filtered one" rule then has
427
+ * nothing raw left to measure (AGL-1516).
428
+ */ function applyCategoryAndPagination(data, options) {
429
+ var _options_categorySlug;
430
+ const { page = 1, perPage } = options;
431
+ const routedCategory = ((_options_categorySlug = options.categorySlug) != null ? _options_categorySlug : '').trim();
432
+ if (routedCategory) {
433
+ var _ref;
434
+ var _data_collection;
435
+ const match = resolveCollectionCategoryBySlug((_data_collection = data.collection) == null ? void 0 : _data_collection.categories, routedCategory);
436
+ data.category = _extends({
437
+ slug: collectionCategorySlug(routedCategory)
438
+ }, match ? {
439
+ id: match.id
440
+ } : {}, {
441
+ name: (_ref = match == null ? void 0 : match.name) != null ? _ref : routedCategory
442
+ }, (match == null ? void 0 : match.description) ? {
443
+ description: match.description
444
+ } : {}, {
445
+ known: Boolean(match)
446
+ });
447
+ data.entries = data.entries.filter((entry)=>{
448
+ var _data_collection;
449
+ return entryMatchesCategoryRoute(entry, _extends({
450
+ slug: routedCategory
451
+ }, match ? {
452
+ category: match
453
+ } : {}), (_data_collection = data.collection) == null ? void 0 : _data_collection.categories);
454
+ });
455
+ }
456
+ if (perPage && perPage > 0) {
457
+ const totalEntries = data.entries.length;
458
+ data.pagination = {
459
+ page,
460
+ perPage,
461
+ totalEntries,
462
+ totalPages: collectionTotalPages(totalEntries, perPage)
463
+ };
464
+ }
465
+ }
466
+ /**
467
+ * Resolves a non-screen path against the host's content collections
468
+ * (Content Collections & Blog): `/{collectionSlug}` returns the published
469
+ * entry list, `/{collectionSlug}/{entrySlug}` one entry. Fail-open — errors
470
+ * resolve to `collection: null` and the caller 404s.
471
+ *
472
+ * A listing resolves only for a collection with a live entry (AGL-3101); one
473
+ * with nothing live answers `collection: null` as well, so its listing, feed
474
+ * and markdown twin are not public until its first entry is.
475
+ */ export async function getCollectionContent(options) {
476
+ const { hostId, collectionSlug, entrySlug } = options;
477
+ const data = {
478
+ collection: null,
479
+ entries: [],
480
+ entry: null,
481
+ pagination: null,
482
+ category: null,
483
+ error: null
484
+ };
485
+ try {
486
+ // A LIST route is served entirely from the CACHED source, which every
487
+ // other page on the site already shares. It used to resolve the
488
+ // collection and re-read its entries here, uncached, on every
489
+ // regeneration of every listing address — `/blog`, nine `/blog/page/{n}`,
490
+ // one `/blog/category/{slug}` per category, and the RSS feed — so a
491
+ // collection of N live entries cost `1 + N + authors` reads per address
492
+ // per window, for data byte-identical to what the cache was already
493
+ // holding for the home page's "Latest posts" rail.
494
+ //
495
+ // An ENTRY route stays where it was: it reads ONE document by slug and
496
+ // has nothing to share.
497
+ if (!entrySlug) {
498
+ const source = await getPublishedCollectionSource({
499
+ hostId,
500
+ collectionSlug
501
+ });
502
+ if (!source.collection) return data;
503
+ // A collection is not a PAGE until something in it is live (AGL-3101).
504
+ // Without this, creating one publishes `/{slug}` at once — an empty
505
+ // listing with a feed and a markdown twin, before a word of it is
506
+ // written. Answering it as no collection makes every listing address
507
+ // 404 the way an unknown slug does, until the first entry is published
508
+ // or its schedule comes due.
509
+ //
510
+ // Read off the UNFILTERED live set, before the category narrows it, so
511
+ // an empty category of a live collection still renders. A read that
512
+ // stopped at its bound cannot prove the collection empty — the live
513
+ // entries may be past it — so that one keeps its listing. The rule
514
+ // lives here and not in the source above: the source also feeds the
515
+ // Collection entries block and the author page, and to them an empty
516
+ // collection is an empty list, not a missing one.
517
+ if (!source.entries.length && !source.reachedBound) return data;
518
+ data.collection = source.collection;
519
+ data.entries = source.entries;
520
+ data.entriesReachedBound = source.reachedBound;
521
+ applyCategoryAndPagination(data, options);
522
+ return data;
523
+ }
524
+ const collectionDoc = await findContentCollection(hostId, collectionSlug);
525
+ if (!collectionDoc) return data;
526
+ data.collection = mapCollectionDoc(collectionDoc, collectionSlug);
527
+ const entryQuery = await collectionDoc.ref.collection('entries').where('slug', '==', entrySlug).limit(5).get();
528
+ // Same two-step as `listLiveEntries`: only pay for the org read when
529
+ // something is due, and record the refusal on its own pass because a
530
+ // refused entry never becomes the `entryDoc` below (AGL-471).
531
+ const dueHere = entryQuery.docs.filter((docSnapshot)=>isDueScheduled(docSnapshot.data()));
532
+ const permission = dueHere.length ? await scheduledPublishingPermission(hostId) : 'allowed';
533
+ if (permission !== 'allowed') {
534
+ for (const docSnapshot of dueHere){
535
+ flipDueEntry(docSnapshot.ref, docSnapshot.data(), permission);
536
+ }
537
+ }
538
+ const entryDoc = entryQuery.docs.find((docSnapshot)=>isLive(docSnapshot.data(), permission));
539
+ if (entryDoc) {
540
+ var _value_title, _value_body, _value_publishedAt, _value_publishedAt1;
541
+ const value = entryDoc.data();
542
+ flipDueEntry(entryDoc.ref, value, permission);
543
+ data.entry = _extends({
544
+ $id: entryDoc.id,
545
+ title: (_value_title = value['title']) != null ? _value_title : entrySlug,
546
+ slug: entrySlug,
547
+ body: (_value_body = value['body']) != null ? _value_body : ''
548
+ }, mapEntryFields(value), {
549
+ publishedAt: ((_value_publishedAt = value['publishedAt']) != null ? _value_publishedAt : value['publishAt']) ? {
550
+ seconds: ((_value_publishedAt1 = value['publishedAt']) != null ? _value_publishedAt1 : value['publishAt']).seconds
551
+ } : null
552
+ });
553
+ await attachEntryAuthors(hostId, [
554
+ data.entry
555
+ ]);
556
+ }
557
+ } catch (error) {
558
+ console.error(error);
559
+ data.error = error;
560
+ }
561
+ return data;
562
+ }
563
+ export default getCollectionContent;
564
+
565
+ //# sourceMappingURL=get-collection-content.js.map