@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.
- package/LICENSE +201 -0
- package/README.md +26 -0
- package/package.json +39 -0
- package/src/index.d.ts +21 -0
- package/src/index.js +22 -0
- package/src/index.js.map +1 -0
- package/src/lib/apply-publish-schedule.d.ts +34 -0
- package/src/lib/apply-publish-schedule.js +285 -0
- package/src/lib/apply-publish-schedule.js.map +1 -0
- package/src/lib/assign-contact-owner.d.ts +162 -0
- package/src/lib/assign-contact-owner.js +322 -0
- package/src/lib/assign-contact-owner.js.map +1 -0
- package/src/lib/associate-company-by-domain.d.ts +70 -0
- package/src/lib/associate-company-by-domain.js +150 -0
- package/src/lib/associate-company-by-domain.js.map +1 -0
- package/src/lib/author-page-nodes.d.ts +36 -0
- package/src/lib/author-page-nodes.js +296 -0
- package/src/lib/author-page-nodes.js.map +1 -0
- package/src/lib/built-in-page-layout.d.ts +48 -0
- package/src/lib/built-in-page-layout.js +107 -0
- package/src/lib/built-in-page-layout.js.map +1 -0
- package/src/lib/capture-host-contact.d.ts +95 -0
- package/src/lib/capture-host-contact.js +137 -0
- package/src/lib/capture-host-contact.js.map +1 -0
- package/src/lib/collection-fallback-nodes.d.ts +90 -0
- package/src/lib/collection-fallback-nodes.js +602 -0
- package/src/lib/collection-fallback-nodes.js.map +1 -0
- package/src/lib/compose-author-page.d.ts +109 -0
- package/src/lib/compose-author-page.js +225 -0
- package/src/lib/compose-author-page.js.map +1 -0
- package/src/lib/compose-collection-page.d.ts +94 -0
- package/src/lib/compose-collection-page.js +256 -0
- package/src/lib/compose-collection-page.js.map +1 -0
- package/src/lib/compose-screen-nodes.d.ts +156 -0
- package/src/lib/compose-screen-nodes.js +547 -0
- package/src/lib/compose-screen-nodes.js.map +1 -0
- package/src/lib/compose-search-page.d.ts +39 -0
- package/src/lib/compose-search-page.js +57 -0
- package/src/lib/compose-search-page.js.map +1 -0
- package/src/lib/convert-host-lead.d.ts +189 -0
- package/src/lib/convert-host-lead.js +443 -0
- package/src/lib/convert-host-lead.js.map +1 -0
- package/src/lib/defer-lazy-panels.d.ts +58 -0
- package/src/lib/defer-lazy-panels.js +171 -0
- package/src/lib/defer-lazy-panels.js.map +1 -0
- package/src/lib/element-animation-assets.d.ts +62 -0
- package/src/lib/element-animation-assets.js +290 -0
- package/src/lib/element-animation-assets.js.map +1 -0
- package/src/lib/emit-host-event.d.ts +29 -0
- package/src/lib/emit-host-event.js +30 -0
- package/src/lib/emit-host-event.js.map +1 -0
- package/src/lib/enrich-gated-page.d.ts +67 -0
- package/src/lib/enrich-gated-page.js +134 -0
- package/src/lib/enrich-gated-page.js.map +1 -0
- package/src/lib/entry-link-routes.d.ts +61 -0
- package/src/lib/entry-link-routes.js +169 -0
- package/src/lib/entry-link-routes.js.map +1 -0
- package/src/lib/get-author-content.d.ts +119 -0
- package/src/lib/get-author-content.js +288 -0
- package/src/lib/get-author-content.js.map +1 -0
- package/src/lib/get-collection-content.d.ts +290 -0
- package/src/lib/get-collection-content.js +565 -0
- package/src/lib/get-collection-content.js.map +1 -0
- package/src/lib/get-components.d.ts +24 -0
- package/src/lib/get-components.js +102 -0
- package/src/lib/get-components.js.map +1 -0
- package/src/lib/get-datasets.d.ts +45 -0
- package/src/lib/get-datasets.js +169 -0
- package/src/lib/get-datasets.js.map +1 -0
- package/src/lib/get-forms.d.ts +24 -0
- package/src/lib/get-forms.js +116 -0
- package/src/lib/get-forms.js.map +1 -0
- package/src/lib/get-layout-version.d.ts +26 -0
- package/src/lib/get-layout-version.js +99 -0
- package/src/lib/get-layout-version.js.map +1 -0
- package/src/lib/get-media-asset-facts.d.ts +74 -0
- package/src/lib/get-media-asset-facts.js +116 -0
- package/src/lib/get-media-asset-facts.js.map +1 -0
- package/src/lib/get-plugin-installs.d.ts +29 -0
- package/src/lib/get-plugin-installs.js +94 -0
- package/src/lib/get-plugin-installs.js.map +1 -0
- package/src/lib/get-screen-version.d.ts +27 -0
- package/src/lib/get-screen-version.js +66 -0
- package/src/lib/get-screen-version.js.map +1 -0
- package/src/lib/get-screen.d.ts +39 -0
- package/src/lib/get-screen.js +126 -0
- package/src/lib/get-screen.js.map +1 -0
- package/src/lib/get-variables.d.ts +41 -0
- package/src/lib/get-variables.js +128 -0
- package/src/lib/get-variables.js.map +1 -0
- package/src/lib/get-video-asset-facts.d.ts +22 -0
- package/src/lib/get-video-asset-facts.js +22 -0
- package/src/lib/get-video-asset-facts.js.map +1 -0
- package/src/lib/host-event-listeners.d.ts +83 -0
- package/src/lib/host-event-listeners.js +91 -0
- package/src/lib/host-event-listeners.js.map +1 -0
- package/src/lib/org-permissions.d.ts +63 -0
- package/src/lib/org-permissions.js +187 -0
- package/src/lib/org-permissions.js.map +1 -0
- package/src/lib/repeat-record-pages.d.ts +48 -0
- package/src/lib/repeat-record-pages.js +64 -0
- package/src/lib/repeat-record-pages.js.map +1 -0
- package/src/lib/required-site-plugins.d.ts +114 -0
- package/src/lib/required-site-plugins.js +121 -0
- package/src/lib/required-site-plugins.js.map +1 -0
- package/src/lib/resolve-dataset.d.ts +40 -0
- package/src/lib/resolve-dataset.js +64 -0
- package/src/lib/resolve-dataset.js.map +1 -0
- package/src/lib/search-results-nodes.d.ts +48 -0
- package/src/lib/search-results-nodes.js +302 -0
- package/src/lib/search-results-nodes.js.map +1 -0
- package/src/lib/social-image-facts.d.ts +107 -0
- package/src/lib/social-image-facts.js +133 -0
- package/src/lib/social-image-facts.js.map +1 -0
- package/src/lib/stamp-form-dataset-bindings.d.ts +32 -0
- package/src/lib/stamp-form-dataset-bindings.js +59 -0
- package/src/lib/stamp-form-dataset-bindings.js.map +1 -0
- package/src/lib/template-screens.d.ts +227 -0
- package/src/lib/template-screens.js +364 -0
- 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;
|