@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,30 @@
|
|
|
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
|
+
*/ import { runHostEventListeners } from "./host-event-listeners.js";
|
|
17
|
+
/**
|
|
18
|
+
* One emit point for host events (AGL-148): hands the event to every
|
|
19
|
+
* registered host-event listener (see `host-event-listeners.ts`) and returns
|
|
20
|
+
* the site alerts they produced, so request/response emitters (form submit,
|
|
21
|
+
* booking) can surface them; fire-and-forget emitters ignore the result. No
|
|
22
|
+
* listener throws into the emitting request.
|
|
23
|
+
*/ export async function emitHostEvent(hostId, event, payload = {}) {
|
|
24
|
+
return {
|
|
25
|
+
alerts: await runHostEventListeners(hostId, event, payload)
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export default emitHostEvent;
|
|
29
|
+
|
|
30
|
+
//# sourceMappingURL=emit-host-event.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/tenant/runtime/src/lib/emit-host-event.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 type { HostActionAlert, HostEventType } from '@aglyn/aglyn/server'\nimport {\n type HostEventPayload,\n runHostEventListeners,\n} from './host-event-listeners'\n\n/**\n * One emit point for host events (AGL-148): hands the event to every\n * registered host-event listener (see `host-event-listeners.ts`) and returns\n * the site alerts they produced, so request/response emitters (form submit,\n * booking) can surface them; fire-and-forget emitters ignore the result. No\n * listener throws into the emitting request.\n */\nexport async function emitHostEvent(\n hostId: string,\n event: HostEventType,\n payload: HostEventPayload = {},\n): Promise<{ alerts: HostActionAlert[] }> {\n return { alerts: await runHostEventListeners(hostId, event, payload) }\n}\n\nexport default emitHostEvent\n"],"names":["runHostEventListeners","emitHostEvent","hostId","event","payload","alerts"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAGD,SAEEA,qBAAqB,QAChB,4BAAwB;AAE/B;;;;;;CAMC,GACD,OAAO,eAAeC,cACpBC,MAAc,EACdC,KAAoB,EACpBC,UAA4B,CAAC,CAAC;IAE9B,OAAO;QAAEC,QAAQ,MAAML,sBAAsBE,QAAQC,OAAOC;IAAS;AACvE;AAEA,eAAeH,cAAa"}
|
|
@@ -0,0 +1,67 @@
|
|
|
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
|
+
/**
|
|
18
|
+
* The enricher slice for a screen whose nodes are withheld from the page and
|
|
19
|
+
* fetched after a gate opens (AGL-2510) — a password-protected screen
|
|
20
|
+
* (AGL-87) and a members-only one (AGL-109).
|
|
21
|
+
*
|
|
22
|
+
* ## Why these two need their own call
|
|
23
|
+
*
|
|
24
|
+
* Every other rendered page gets its enricher props from the loader, but
|
|
25
|
+
* these two deliberately ship `nodes: null`: embedding a gated tree in static
|
|
26
|
+
* HTML would publish the very content the gate exists to withhold. So the
|
|
27
|
+
* page arrives with the built-in prompt and no site chrome at all, and the
|
|
28
|
+
* composed tree — the site's shared layout, nav and footer included — is
|
|
29
|
+
* swapped in by the client once the visitor gets past the gate. A nav menu
|
|
30
|
+
* built from primitives opens through `clientAutomations`, which is enricher
|
|
31
|
+
* output, so without this the unlocked page renders the site's nav and none
|
|
32
|
+
* of its behavior.
|
|
33
|
+
*
|
|
34
|
+
* Delivering it WITH the nodes is what keeps the gate honest. The slice is
|
|
35
|
+
* derived from the withheld tree and describes its structure; shipping it in
|
|
36
|
+
* the public HTML alongside `nodes: null` would leak an outline of the page
|
|
37
|
+
* to anyone who never answers the prompt.
|
|
38
|
+
*
|
|
39
|
+
* ## The path
|
|
40
|
+
*
|
|
41
|
+
* Taken from the host's own routing map rather than from the caller, because
|
|
42
|
+
* both callers are POST endpoints a visitor controls: a supplied path would
|
|
43
|
+
* let anyone pick which overlays and path-scoped automations a gated page
|
|
44
|
+
* runs. A screen missing from the map (never routed) gets the site root,
|
|
45
|
+
* which is what an unrouted screen's own address would be.
|
|
46
|
+
*
|
|
47
|
+
* ## The entry links
|
|
48
|
+
*
|
|
49
|
+
* `entryRoutes` rides along when the tree links to a live content entry
|
|
50
|
+
* (AGL-3118), and the client adds those keys to the page's routing map — see
|
|
51
|
+
* {@link gatedEntryLinkRoutes}.
|
|
52
|
+
*
|
|
53
|
+
* Fail-open to `{}` — an enricher slice is behavior on top of a page, and no
|
|
54
|
+
* failure here may cost a visitor the content they just unlocked.
|
|
55
|
+
*/
|
|
56
|
+
export declare function enrichGatedScreenPage(options: {
|
|
57
|
+
hostId: string;
|
|
58
|
+
screenId: string;
|
|
59
|
+
screen: any;
|
|
60
|
+
nodes: any;
|
|
61
|
+
/**
|
|
62
|
+
* The site document, when the caller already read it to compose `nodes`
|
|
63
|
+
* (AGL-2883). Read here when absent.
|
|
64
|
+
*/
|
|
65
|
+
host?: Record<string, unknown> | null;
|
|
66
|
+
}): Promise<Record<string, unknown>>;
|
|
67
|
+
export default enrichGatedScreenPage;
|
|
@@ -0,0 +1,134 @@
|
|
|
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 * as Aglyn from "@aglyn/aglyn/server";
|
|
18
|
+
import { getHostDocAdmin, getOrgForHost } from "@aglyn/tenant-data-admin";
|
|
19
|
+
/**
|
|
20
|
+
* Where the entries a gated tree links to are served (AGL-3118), or
|
|
21
|
+
* `undefined` when it names none that are live.
|
|
22
|
+
*
|
|
23
|
+
* The page's routing map was built before the gate opened, from a tree it
|
|
24
|
+
* did not have, so it holds no entry a gated page links to; these travel
|
|
25
|
+
* with the nodes for the reason the enricher slice does. A tree that names
|
|
26
|
+
* no entry costs no read.
|
|
27
|
+
*
|
|
28
|
+
* Never rejects, so the caller can start it early and abandon it on a
|
|
29
|
+
* failure of its own.
|
|
30
|
+
*
|
|
31
|
+
* The two readers it needs are loaded HERE rather than imported at the top of
|
|
32
|
+
* this file. Both reach `withRenderCache` and through it `next/cache`, which
|
|
33
|
+
* drags Next's server runtime into the module graph of everything that
|
|
34
|
+
* imports this enricher — including the commerce plugin, whose specs then
|
|
35
|
+
* fail to load at all. Loading them inside the one branch that reads also
|
|
36
|
+
* means a gated tree naming no entry pays for neither.
|
|
37
|
+
*/ async function gatedEntryLinkRoutes(hostId, nodes) {
|
|
38
|
+
try {
|
|
39
|
+
const refs = Aglyn.collectEntryLinkRefs({
|
|
40
|
+
nodes: [
|
|
41
|
+
nodes
|
|
42
|
+
]
|
|
43
|
+
});
|
|
44
|
+
if (!refs.length) return undefined;
|
|
45
|
+
const [{ getTemplateScreenRouting }, { resolveEntryLinkRoutes }] = await Promise.all([
|
|
46
|
+
import("./template-screens.js"),
|
|
47
|
+
import("./entry-link-routes.js")
|
|
48
|
+
]);
|
|
49
|
+
const routing = await getTemplateScreenRouting({
|
|
50
|
+
hostId
|
|
51
|
+
});
|
|
52
|
+
const routes = await resolveEntryLinkRoutes({
|
|
53
|
+
hostId,
|
|
54
|
+
refs,
|
|
55
|
+
collectionSlugs: routing.collectionListings
|
|
56
|
+
});
|
|
57
|
+
return Object.keys(routes).length ? routes : undefined;
|
|
58
|
+
} catch (error) {
|
|
59
|
+
console.error('gated page entry links failed', error);
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The enricher slice for a screen whose nodes are withheld from the page and
|
|
65
|
+
* fetched after a gate opens (AGL-2510) — a password-protected screen
|
|
66
|
+
* (AGL-87) and a members-only one (AGL-109).
|
|
67
|
+
*
|
|
68
|
+
* ## Why these two need their own call
|
|
69
|
+
*
|
|
70
|
+
* Every other rendered page gets its enricher props from the loader, but
|
|
71
|
+
* these two deliberately ship `nodes: null`: embedding a gated tree in static
|
|
72
|
+
* HTML would publish the very content the gate exists to withhold. So the
|
|
73
|
+
* page arrives with the built-in prompt and no site chrome at all, and the
|
|
74
|
+
* composed tree — the site's shared layout, nav and footer included — is
|
|
75
|
+
* swapped in by the client once the visitor gets past the gate. A nav menu
|
|
76
|
+
* built from primitives opens through `clientAutomations`, which is enricher
|
|
77
|
+
* output, so without this the unlocked page renders the site's nav and none
|
|
78
|
+
* of its behavior.
|
|
79
|
+
*
|
|
80
|
+
* Delivering it WITH the nodes is what keeps the gate honest. The slice is
|
|
81
|
+
* derived from the withheld tree and describes its structure; shipping it in
|
|
82
|
+
* the public HTML alongside `nodes: null` would leak an outline of the page
|
|
83
|
+
* to anyone who never answers the prompt.
|
|
84
|
+
*
|
|
85
|
+
* ## The path
|
|
86
|
+
*
|
|
87
|
+
* Taken from the host's own routing map rather than from the caller, because
|
|
88
|
+
* both callers are POST endpoints a visitor controls: a supplied path would
|
|
89
|
+
* let anyone pick which overlays and path-scoped automations a gated page
|
|
90
|
+
* runs. A screen missing from the map (never routed) gets the site root,
|
|
91
|
+
* which is what an unrouted screen's own address would be.
|
|
92
|
+
*
|
|
93
|
+
* ## The entry links
|
|
94
|
+
*
|
|
95
|
+
* `entryRoutes` rides along when the tree links to a live content entry
|
|
96
|
+
* (AGL-3118), and the client adds those keys to the page's routing map — see
|
|
97
|
+
* {@link gatedEntryLinkRoutes}.
|
|
98
|
+
*
|
|
99
|
+
* Fail-open to `{}` — an enricher slice is behavior on top of a page, and no
|
|
100
|
+
* failure here may cost a visitor the content they just unlocked.
|
|
101
|
+
*/ export async function enrichGatedScreenPage(options) {
|
|
102
|
+
const { hostId, screenId, screen, nodes } = options;
|
|
103
|
+
try {
|
|
104
|
+
var _ref;
|
|
105
|
+
// Started first: its reads share nothing with the enrichers'.
|
|
106
|
+
const entryRoutesPromise = gatedEntryLinkRoutes(hostId, nodes);
|
|
107
|
+
const [host, orgRes] = await Promise.all([
|
|
108
|
+
options.host !== undefined ? options.host : getHostDocAdmin(hostId),
|
|
109
|
+
getOrgForHost(hostId)
|
|
110
|
+
]);
|
|
111
|
+
const routing = (_ref = host == null ? void 0 : host.screens) != null ? _ref : {};
|
|
112
|
+
const path = routing[screenId] || Aglyn.SCREEN_ROOT_PATH;
|
|
113
|
+
const enriched = await Aglyn.runSitePageEnrichers({
|
|
114
|
+
hostId,
|
|
115
|
+
host,
|
|
116
|
+
org: orgRes == null ? void 0 : orgRes.org,
|
|
117
|
+
path,
|
|
118
|
+
slugSegments: path.split('/').filter(Boolean),
|
|
119
|
+
screenId,
|
|
120
|
+
screen,
|
|
121
|
+
nodes
|
|
122
|
+
});
|
|
123
|
+
const entryRoutes = await entryRoutesPromise;
|
|
124
|
+
return entryRoutes ? _extends({}, enriched.props, {
|
|
125
|
+
entryRoutes
|
|
126
|
+
}) : enriched.props;
|
|
127
|
+
} catch (error) {
|
|
128
|
+
console.error('gated page enrichment failed', error);
|
|
129
|
+
return {};
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
export default enrichGatedScreenPage;
|
|
133
|
+
|
|
134
|
+
//# sourceMappingURL=enrich-gated-page.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/tenant/runtime/src/lib/enrich-gated-page.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport * as Aglyn from '@aglyn/aglyn/server'\nimport { getHostDocAdmin, getOrgForHost } from '@aglyn/tenant-data-admin'\n\n/**\n * Where the entries a gated tree links to are served (AGL-3118), or\n * `undefined` when it names none that are live.\n *\n * The page's routing map was built before the gate opened, from a tree it\n * did not have, so it holds no entry a gated page links to; these travel\n * with the nodes for the reason the enricher slice does. A tree that names\n * no entry costs no read.\n *\n * Never rejects, so the caller can start it early and abandon it on a\n * failure of its own.\n *\n * The two readers it needs are loaded HERE rather than imported at the top of\n * this file. Both reach `withRenderCache` and through it `next/cache`, which\n * drags Next's server runtime into the module graph of everything that\n * imports this enricher — including the commerce plugin, whose specs then\n * fail to load at all. Loading them inside the one branch that reads also\n * means a gated tree naming no entry pays for neither.\n */\nasync function gatedEntryLinkRoutes(\n hostId: string,\n nodes: unknown,\n): Promise<Record<string, string> | undefined> {\n try {\n const refs = Aglyn.collectEntryLinkRefs({\n nodes: [nodes as Record<string, unknown> | null],\n })\n if (!refs.length) return undefined\n const [{ getTemplateScreenRouting }, { resolveEntryLinkRoutes }] =\n await Promise.all([\n import('./template-screens'),\n import('./entry-link-routes'),\n ])\n const routing = await getTemplateScreenRouting({ hostId })\n const routes = await resolveEntryLinkRoutes({\n hostId,\n refs,\n collectionSlugs: routing.collectionListings,\n })\n return Object.keys(routes).length ? routes : undefined\n } catch (error) {\n console.error('gated page entry links failed', error)\n return undefined\n }\n}\n\n/**\n * The enricher slice for a screen whose nodes are withheld from the page and\n * fetched after a gate opens (AGL-2510) — a password-protected screen\n * (AGL-87) and a members-only one (AGL-109).\n *\n * ## Why these two need their own call\n *\n * Every other rendered page gets its enricher props from the loader, but\n * these two deliberately ship `nodes: null`: embedding a gated tree in static\n * HTML would publish the very content the gate exists to withhold. So the\n * page arrives with the built-in prompt and no site chrome at all, and the\n * composed tree — the site's shared layout, nav and footer included — is\n * swapped in by the client once the visitor gets past the gate. A nav menu\n * built from primitives opens through `clientAutomations`, which is enricher\n * output, so without this the unlocked page renders the site's nav and none\n * of its behavior.\n *\n * Delivering it WITH the nodes is what keeps the gate honest. The slice is\n * derived from the withheld tree and describes its structure; shipping it in\n * the public HTML alongside `nodes: null` would leak an outline of the page\n * to anyone who never answers the prompt.\n *\n * ## The path\n *\n * Taken from the host's own routing map rather than from the caller, because\n * both callers are POST endpoints a visitor controls: a supplied path would\n * let anyone pick which overlays and path-scoped automations a gated page\n * runs. A screen missing from the map (never routed) gets the site root,\n * which is what an unrouted screen's own address would be.\n *\n * ## The entry links\n *\n * `entryRoutes` rides along when the tree links to a live content entry\n * (AGL-3118), and the client adds those keys to the page's routing map — see\n * {@link gatedEntryLinkRoutes}.\n *\n * Fail-open to `{}` — an enricher slice is behavior on top of a page, and no\n * failure here may cost a visitor the content they just unlocked.\n */\nexport async function enrichGatedScreenPage(options: {\n hostId: string\n screenId: string\n screen: any\n nodes: any\n /**\n * The site document, when the caller already read it to compose `nodes`\n * (AGL-2883). Read here when absent.\n */\n host?: Record<string, unknown> | null\n}): Promise<Record<string, unknown>> {\n const { hostId, screenId, screen, nodes } = options\n try {\n // Started first: its reads share nothing with the enrichers'.\n const entryRoutesPromise = gatedEntryLinkRoutes(hostId, nodes)\n const [host, orgRes] = await Promise.all([\n options.host !== undefined ? options.host : getHostDocAdmin(hostId),\n getOrgForHost(hostId),\n ])\n const routing = ((host as { screens?: Record<string, string> })?.screens ??\n {}) as Record<string, string>\n const path = routing[screenId] || Aglyn.SCREEN_ROOT_PATH\n const enriched = await Aglyn.runSitePageEnrichers({\n hostId,\n host,\n org: orgRes?.org,\n path,\n slugSegments: path.split('/').filter(Boolean),\n screenId,\n screen,\n nodes,\n })\n const entryRoutes = await entryRoutesPromise\n return entryRoutes ? { ...enriched.props, entryRoutes } : enriched.props\n } catch (error) {\n console.error('gated page enrichment failed', error)\n return {}\n }\n}\n\nexport default enrichGatedScreenPage\n"],"names":["Aglyn","getHostDocAdmin","getOrgForHost","gatedEntryLinkRoutes","hostId","nodes","refs","collectEntryLinkRefs","length","undefined","getTemplateScreenRouting","resolveEntryLinkRoutes","Promise","all","routing","routes","collectionSlugs","collectionListings","Object","keys","error","console","enrichGatedScreenPage","options","screenId","screen","entryRoutesPromise","host","orgRes","screens","path","SCREEN_ROOT_PATH","enriched","runSitePageEnrichers","org","slugSegments","split","filter","Boolean","entryRoutes","props"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,YAAYA,WAAW,sBAAqB;AAC5C,SAASC,eAAe,EAAEC,aAAa,QAAQ,2BAA0B;AAEzE;;;;;;;;;;;;;;;;;;CAkBC,GACD,eAAeC,qBACbC,MAAc,EACdC,KAAc;IAEd,IAAI;QACF,MAAMC,OAAON,MAAMO,oBAAoB,CAAC;YACtCF,OAAO;gBAACA;aAAwC;QAClD;QACA,IAAI,CAACC,KAAKE,MAAM,EAAE,OAAOC;QACzB,MAAM,CAAC,EAAEC,wBAAwB,EAAE,EAAE,EAAEC,sBAAsB,EAAE,CAAC,GAC9D,MAAMC,QAAQC,GAAG,CAAC;YAChB,MAAM,CAAC;YACP,MAAM,CAAC;SACR;QACH,MAAMC,UAAU,MAAMJ,yBAAyB;YAAEN;QAAO;QACxD,MAAMW,SAAS,MAAMJ,uBAAuB;YAC1CP;YACAE;YACAU,iBAAiBF,QAAQG,kBAAkB;QAC7C;QACA,OAAOC,OAAOC,IAAI,CAACJ,QAAQP,MAAM,GAAGO,SAASN;IAC/C,EAAE,OAAOW,OAAO;QACdC,QAAQD,KAAK,CAAC,iCAAiCA;QAC/C,OAAOX;IACT;AACF;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAsCC,GACD,OAAO,eAAea,sBAAsBC,OAU3C;IACC,MAAM,EAAEnB,MAAM,EAAEoB,QAAQ,EAAEC,MAAM,EAAEpB,KAAK,EAAE,GAAGkB;IAC5C,IAAI;;QACF,8DAA8D;QAC9D,MAAMG,qBAAqBvB,qBAAqBC,QAAQC;QACxD,MAAM,CAACsB,MAAMC,OAAO,GAAG,MAAMhB,QAAQC,GAAG,CAAC;YACvCU,QAAQI,IAAI,KAAKlB,YAAYc,QAAQI,IAAI,GAAG1B,gBAAgBG;YAC5DF,cAAcE;SACf;QACD,MAAMU,kBAAYa,wBAAD,AAACA,KAA+CE,OAAO,mBACtE,CAAC;QACH,MAAMC,OAAOhB,OAAO,CAACU,SAAS,IAAIxB,MAAM+B,gBAAgB;QACxD,MAAMC,WAAW,MAAMhC,MAAMiC,oBAAoB,CAAC;YAChD7B;YACAuB;YACAO,GAAG,EAAEN,0BAAAA,OAAQM,GAAG;YAChBJ;YACAK,cAAcL,KAAKM,KAAK,CAAC,KAAKC,MAAM,CAACC;YACrCd;YACAC;YACApB;QACF;QACA,MAAMkC,cAAc,MAAMb;QAC1B,OAAOa,cAAc,aAAKP,SAASQ,KAAK;YAAED;aAAgBP,SAASQ,KAAK;IAC1E,EAAE,OAAOpB,OAAO;QACdC,QAAQD,KAAK,CAAC,gCAAgCA;QAC9C,OAAO,CAAC;IACV;AACF;AAEA,eAAeE,sBAAqB"}
|
|
@@ -0,0 +1,61 @@
|
|
|
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
|
+
/**
|
|
18
|
+
* The most entries one page's links are resolved for (AGL-3118).
|
|
19
|
+
*
|
|
20
|
+
* A real page names a handful — a "read next" row, the links in a post — so
|
|
21
|
+
* this is not a budget anything should approach. It is the bound on what one
|
|
22
|
+
* pathological page can cost: a single batched read of at most this many
|
|
23
|
+
* documents per regeneration. Past it, the keys that sort last are not read,
|
|
24
|
+
* and their links render as plain text.
|
|
25
|
+
*/
|
|
26
|
+
export declare const ENTRY_LINK_ROUTES_MAX = 100;
|
|
27
|
+
/**
|
|
28
|
+
* Where each entry a page's links name is served now (AGL-3118): entry key
|
|
29
|
+
* → `{collectionSlug}/{entrySlug}`, the map format `linkableScreenRoutes`
|
|
30
|
+
* takes as `entryRoutes`.
|
|
31
|
+
*
|
|
32
|
+
* Only LIVE entries answer — published, or scheduled and due on a plan that
|
|
33
|
+
* allows it, judged by the same `isLive` as the entry's own route — so a
|
|
34
|
+
* link can never offer a page that route would 404. A draft, an unpublished
|
|
35
|
+
* or a deleted entry is simply absent, and its links render inert.
|
|
36
|
+
*
|
|
37
|
+
* The reads are the minimum the question allows:
|
|
38
|
+
*
|
|
39
|
+
* - NONE for a page that names no entry, the common case — nor for
|
|
40
|
+
* references whose collection has no listing, which no read could route;
|
|
41
|
+
* - otherwise ONE batched read of exactly the named documents (at most
|
|
42
|
+
* {@link ENTRY_LINK_ROUTES_MAX}), cached like the rest of the site's
|
|
43
|
+
* published data: keyed by the host and the sorted key set, busted by the
|
|
44
|
+
* `tenant-data:{hostId}` tag an entry save announces, and held for the
|
|
45
|
+
* same window as every other published read;
|
|
46
|
+
* - and none for the collections' slugs, which the caller hands over from
|
|
47
|
+
* the routing read the render already made. Only the ENTRY facts are
|
|
48
|
+
* cached, and the path is assembled per call, so a renamed collection
|
|
49
|
+
* moves its entries' links as soon as the routing read has the new slug.
|
|
50
|
+
*
|
|
51
|
+
* Fails open to `{}` — a failed read renders inert links, never a 500 — and
|
|
52
|
+
* the failure is not cached, so the next render asks again.
|
|
53
|
+
*/
|
|
54
|
+
export declare function resolveEntryLinkRoutes(options: {
|
|
55
|
+
hostId: string;
|
|
56
|
+
/** Entry keys, as `collectEntryLinkRefs` answers them. */
|
|
57
|
+
refs: readonly string[];
|
|
58
|
+
/** Content collection id → slug: the routing read's `collectionListings`. */
|
|
59
|
+
collectionSlugs: Record<string, string> | null | undefined;
|
|
60
|
+
}): Promise<Record<string, string>>;
|
|
61
|
+
export default resolveEntryLinkRoutes;
|
|
@@ -0,0 +1,169 @@
|
|
|
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 { collectionEntryRoutePath, formatEntryLinkValue, parseEntryLinkValue } 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 { isDueScheduled, isLive, isPendingScheduled, scheduledPublishingPermission } from "./get-collection-content.js";
|
|
21
|
+
/**
|
|
22
|
+
* The most entries one page's links are resolved for (AGL-3118).
|
|
23
|
+
*
|
|
24
|
+
* A real page names a handful — a "read next" row, the links in a post — so
|
|
25
|
+
* this is not a budget anything should approach. It is the bound on what one
|
|
26
|
+
* pathological page can cost: a single batched read of at most this many
|
|
27
|
+
* documents per regeneration. Past it, the keys that sort last are not read,
|
|
28
|
+
* and their links render as plain text.
|
|
29
|
+
*/ export const ENTRY_LINK_ROUTES_MAX = 100;
|
|
30
|
+
/**
|
|
31
|
+
* The fields an entry link is decided from: whether the entry is live (the
|
|
32
|
+
* three `isLive` reads) and where it is served (its slug). Projected, because
|
|
33
|
+
* an entry document carries its whole body and a link needs none of it.
|
|
34
|
+
*/ const ENTRY_LINK_FIELDS = [
|
|
35
|
+
'status',
|
|
36
|
+
'publishAt',
|
|
37
|
+
'scheduleStatus',
|
|
38
|
+
'slug'
|
|
39
|
+
];
|
|
40
|
+
/**
|
|
41
|
+
* A document id Firestore will address. A reference is authored data, and
|
|
42
|
+
* one id `getAll` refuses fails the whole batch, which would take every
|
|
43
|
+
* other entry link on the page down with it.
|
|
44
|
+
*/ function isAddressableId(id) {
|
|
45
|
+
return id.length <= 500 && id !== '.' && id !== '..' && !/^__.*__$/.test(id);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The live slugs of exactly these entries: one batched `getAll` of the
|
|
49
|
+
* documents, projected to {@link ENTRY_LINK_FIELDS}. Throws on a failed
|
|
50
|
+
* read, so the failure is never stored.
|
|
51
|
+
*/ async function readEntryLinkFacts(hostId, targets) {
|
|
52
|
+
const firestore = firebaseAdmin.app().firestore();
|
|
53
|
+
const collections = firestore.collection('hosts').doc(hostId).collection('collections');
|
|
54
|
+
const refs = targets.map(({ collectionId, entryId })=>collections.doc(collectionId).collection('entries').doc(entryId));
|
|
55
|
+
// Snapshots come back in the order the references were asked in.
|
|
56
|
+
const snapshots = await firestore.getAll(...refs, {
|
|
57
|
+
fieldMask: ENTRY_LINK_FIELDS
|
|
58
|
+
});
|
|
59
|
+
const values = snapshots.map((snapshot)=>{
|
|
60
|
+
var _snapshot_data;
|
|
61
|
+
return snapshot.exists ? (_snapshot_data = snapshot.data()) != null ? _snapshot_data : null : null;
|
|
62
|
+
});
|
|
63
|
+
// The plan is read only when one of these entries is due, as on the entry's
|
|
64
|
+
// own route — which is almost never.
|
|
65
|
+
const due = values.some((value)=>value !== null && isDueScheduled(value));
|
|
66
|
+
const permission = due ? await scheduledPublishingPermission(hostId) : 'allowed';
|
|
67
|
+
const slugs = {};
|
|
68
|
+
values.forEach((value, index)=>{
|
|
69
|
+
var _targets_index;
|
|
70
|
+
const key = (_targets_index = targets[index]) == null ? void 0 : _targets_index.key;
|
|
71
|
+
if (!key || !value || !isLive(value, permission)) return;
|
|
72
|
+
const slug = typeof value['slug'] === 'string' ? value['slug'].trim() : '';
|
|
73
|
+
// The entry route matches one path segment against `slug`, so an entry
|
|
74
|
+
// with none — or with a slash in it — has no address to link to.
|
|
75
|
+
if (slug && !slug.includes('/')) slugs[key] = slug;
|
|
76
|
+
});
|
|
77
|
+
const unsettled = values.some((value)=>value !== null && isPendingScheduled(value)) || due && permission === 'unresolved';
|
|
78
|
+
return unsettled ? {
|
|
79
|
+
slugs,
|
|
80
|
+
unsettled: true
|
|
81
|
+
} : {
|
|
82
|
+
slugs
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Where each entry a page's links name is served now (AGL-3118): entry key
|
|
87
|
+
* → `{collectionSlug}/{entrySlug}`, the map format `linkableScreenRoutes`
|
|
88
|
+
* takes as `entryRoutes`.
|
|
89
|
+
*
|
|
90
|
+
* Only LIVE entries answer — published, or scheduled and due on a plan that
|
|
91
|
+
* allows it, judged by the same `isLive` as the entry's own route — so a
|
|
92
|
+
* link can never offer a page that route would 404. A draft, an unpublished
|
|
93
|
+
* or a deleted entry is simply absent, and its links render inert.
|
|
94
|
+
*
|
|
95
|
+
* The reads are the minimum the question allows:
|
|
96
|
+
*
|
|
97
|
+
* - NONE for a page that names no entry, the common case — nor for
|
|
98
|
+
* references whose collection has no listing, which no read could route;
|
|
99
|
+
* - otherwise ONE batched read of exactly the named documents (at most
|
|
100
|
+
* {@link ENTRY_LINK_ROUTES_MAX}), cached like the rest of the site's
|
|
101
|
+
* published data: keyed by the host and the sorted key set, busted by the
|
|
102
|
+
* `tenant-data:{hostId}` tag an entry save announces, and held for the
|
|
103
|
+
* same window as every other published read;
|
|
104
|
+
* - and none for the collections' slugs, which the caller hands over from
|
|
105
|
+
* the routing read the render already made. Only the ENTRY facts are
|
|
106
|
+
* cached, and the path is assembled per call, so a renamed collection
|
|
107
|
+
* moves its entries' links as soon as the routing read has the new slug.
|
|
108
|
+
*
|
|
109
|
+
* Fails open to `{}` — a failed read renders inert links, never a 500 — and
|
|
110
|
+
* the failure is not cached, so the next render asks again.
|
|
111
|
+
*/ export async function resolveEntryLinkRoutes(options) {
|
|
112
|
+
const { hostId, collectionSlugs } = options;
|
|
113
|
+
if (!hostId || !collectionSlugs) return {};
|
|
114
|
+
const slugOf = (collectionId)=>{
|
|
115
|
+
const slug = collectionSlugs[collectionId];
|
|
116
|
+
return typeof slug === 'string' ? slug.trim() : '';
|
|
117
|
+
};
|
|
118
|
+
const targets = new Map();
|
|
119
|
+
for (const ref of options.refs){
|
|
120
|
+
const entry = parseEntryLinkValue(ref);
|
|
121
|
+
if (!entry || !slugOf(entry.collectionId)) continue;
|
|
122
|
+
if (!isAddressableId(entry.collectionId) || !isAddressableId(entry.entryId)) {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const key = formatEntryLinkValue(entry.collectionId, entry.entryId);
|
|
126
|
+
targets.set(key, _extends({
|
|
127
|
+
key
|
|
128
|
+
}, entry));
|
|
129
|
+
}
|
|
130
|
+
if (!targets.size) return {};
|
|
131
|
+
const wanted = [
|
|
132
|
+
...targets.keys()
|
|
133
|
+
].sort().slice(0, ENTRY_LINK_ROUTES_MAX);
|
|
134
|
+
try {
|
|
135
|
+
var _ref;
|
|
136
|
+
const facts = await withRenderCache({
|
|
137
|
+
// One key part for the whole set, so no two sets can join to the same
|
|
138
|
+
// string.
|
|
139
|
+
key: [
|
|
140
|
+
'tenant-entry-link-routes',
|
|
141
|
+
hostId,
|
|
142
|
+
JSON.stringify(wanted)
|
|
143
|
+
],
|
|
144
|
+
revalidate: PUBLISHED_SITE_DATA_TTL_SECONDS,
|
|
145
|
+
tags: [
|
|
146
|
+
tenantDataTag(hostId)
|
|
147
|
+
],
|
|
148
|
+
read: ()=>readEntryLinkFacts(hostId, wanted.flatMap((key)=>{
|
|
149
|
+
var _targets_get;
|
|
150
|
+
return (_targets_get = targets.get(key)) != null ? _targets_get : [];
|
|
151
|
+
})),
|
|
152
|
+
store: (value)=>!value.unsettled
|
|
153
|
+
});
|
|
154
|
+
const routes = {};
|
|
155
|
+
for (const [key, entrySlug] of Object.entries((_ref = facts == null ? void 0 : facts.slugs) != null ? _ref : {})){
|
|
156
|
+
const entry = parseEntryLinkValue(key);
|
|
157
|
+
const collectionSlug = entry ? slugOf(entry.collectionId) : '';
|
|
158
|
+
if (!collectionSlug) continue;
|
|
159
|
+
routes[key] = collectionEntryRoutePath(collectionSlug, entrySlug);
|
|
160
|
+
}
|
|
161
|
+
return routes;
|
|
162
|
+
} catch (error) {
|
|
163
|
+
console.error('entry link lookup failed:', error);
|
|
164
|
+
return {};
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
export default resolveEntryLinkRoutes;
|
|
168
|
+
|
|
169
|
+
//# sourceMappingURL=entry-link-routes.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/tenant/runtime/src/lib/entry-link-routes.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 collectionEntryRoutePath,\n formatEntryLinkValue,\n parseEntryLinkValue,\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 isDueScheduled,\n isLive,\n isPendingScheduled,\n type SchedulePermission,\n scheduledPublishingPermission,\n} from './get-collection-content'\n\n/**\n * The most entries one page's links are resolved for (AGL-3118).\n *\n * A real page names a handful — a \"read next\" row, the links in a post — so\n * this is not a budget anything should approach. It is the bound on what one\n * pathological page can cost: a single batched read of at most this many\n * documents per regeneration. Past it, the keys that sort last are not read,\n * and their links render as plain text.\n */\nexport const ENTRY_LINK_ROUTES_MAX = 100\n\n/**\n * The fields an entry link is decided from: whether the entry is live (the\n * three `isLive` reads) and where it is served (its slug). Projected, because\n * an entry document carries its whole body and a link needs none of it.\n */\nconst ENTRY_LINK_FIELDS = ['status', 'publishAt', 'scheduleStatus', 'slug']\n\n/**\n * A document id Firestore will address. A reference is authored data, and\n * one id `getAll` refuses fails the whole batch, which would take every\n * other entry link on the page down with it.\n */\nfunction isAddressableId(id: string): boolean {\n return (\n id.length <= 500 && id !== '.' && id !== '..' && !/^__.*__$/.test(id)\n )\n}\n\n/** One referenced entry, by its canonical key and the ids it names. */\ninterface EntryLinkTarget {\n key: string\n collectionId: string\n entryId: string\n}\n\n/** What the cached read answers — plain data, so a hit equals a miss. */\ninterface EntryLinkFacts {\n /** Entry key → the entry's slug, for each referenced entry that is live. */\n slugs: Record<string, string>\n /**\n * The read saw an entry whose liveness will change with no write — a\n * schedule still waiting on its time, or a due one whose plan could not be\n * read — so the answer is served but not stored.\n */\n unsettled?: boolean\n}\n\n/**\n * The live slugs of exactly these entries: one batched `getAll` of the\n * documents, projected to {@link ENTRY_LINK_FIELDS}. Throws on a failed\n * read, so the failure is never stored.\n */\nasync function readEntryLinkFacts(\n hostId: string,\n targets: readonly EntryLinkTarget[],\n): Promise<EntryLinkFacts> {\n const firestore = firebaseAdmin.app().firestore()\n const collections = firestore\n .collection('hosts')\n .doc(hostId)\n .collection('collections')\n const refs = targets.map(({ collectionId, entryId }) =>\n collections.doc(collectionId).collection('entries').doc(entryId),\n )\n // Snapshots come back in the order the references were asked in.\n const snapshots = await firestore.getAll(...refs, {\n fieldMask: ENTRY_LINK_FIELDS,\n })\n const values = snapshots.map((snapshot) =>\n snapshot.exists ? (snapshot.data() ?? null) : null,\n )\n // The plan is read only when one of these entries is due, as on the entry's\n // own route — which is almost never.\n const due = values.some((value) => value !== null && isDueScheduled(value))\n const permission: SchedulePermission = due\n ? await scheduledPublishingPermission(hostId)\n : 'allowed'\n const slugs: Record<string, string> = {}\n values.forEach((value, index) => {\n const key = targets[index]?.key\n if (!key || !value || !isLive(value, permission)) return\n const slug = typeof value['slug'] === 'string' ? value['slug'].trim() : ''\n // The entry route matches one path segment against `slug`, so an entry\n // with none — or with a slash in it — has no address to link to.\n if (slug && !slug.includes('/')) slugs[key] = slug\n })\n const unsettled =\n values.some((value) => value !== null && isPendingScheduled(value)) ||\n (due && permission === 'unresolved')\n return unsettled ? { slugs, unsettled: true } : { slugs }\n}\n\n/**\n * Where each entry a page's links name is served now (AGL-3118): entry key\n * → `{collectionSlug}/{entrySlug}`, the map format `linkableScreenRoutes`\n * takes as `entryRoutes`.\n *\n * Only LIVE entries answer — published, or scheduled and due on a plan that\n * allows it, judged by the same `isLive` as the entry's own route — so a\n * link can never offer a page that route would 404. A draft, an unpublished\n * or a deleted entry is simply absent, and its links render inert.\n *\n * The reads are the minimum the question allows:\n *\n * - NONE for a page that names no entry, the common case — nor for\n * references whose collection has no listing, which no read could route;\n * - otherwise ONE batched read of exactly the named documents (at most\n * {@link ENTRY_LINK_ROUTES_MAX}), cached like the rest of the site's\n * published data: keyed by the host and the sorted key set, busted by the\n * `tenant-data:{hostId}` tag an entry save announces, and held for the\n * same window as every other published read;\n * - and none for the collections' slugs, which the caller hands over from\n * the routing read the render already made. Only the ENTRY facts are\n * cached, and the path is assembled per call, so a renamed collection\n * moves its entries' links as soon as the routing read has the new slug.\n *\n * Fails open to `{}` — a failed read renders inert links, never a 500 — and\n * the failure is not cached, so the next render asks again.\n */\nexport async function resolveEntryLinkRoutes(options: {\n hostId: string\n /** Entry keys, as `collectEntryLinkRefs` answers them. */\n refs: readonly string[]\n /** Content collection id → slug: the routing read's `collectionListings`. */\n collectionSlugs: Record<string, string> | null | undefined\n}): Promise<Record<string, string>> {\n const { hostId, collectionSlugs } = options\n if (!hostId || !collectionSlugs) return {}\n const slugOf = (collectionId: string): string => {\n const slug = collectionSlugs[collectionId]\n return typeof slug === 'string' ? slug.trim() : ''\n }\n const targets = new Map<string, EntryLinkTarget>()\n for (const ref of options.refs) {\n const entry = parseEntryLinkValue(ref)\n if (!entry || !slugOf(entry.collectionId)) continue\n if (!isAddressableId(entry.collectionId) || !isAddressableId(entry.entryId)) {\n continue\n }\n const key = formatEntryLinkValue(entry.collectionId, entry.entryId)\n targets.set(key, { key, ...entry })\n }\n if (!targets.size) return {}\n const wanted = [...targets.keys()].sort().slice(0, ENTRY_LINK_ROUTES_MAX)\n try {\n const facts = await withRenderCache<EntryLinkFacts>({\n // One key part for the whole set, so no two sets can join to the same\n // string.\n key: ['tenant-entry-link-routes', hostId, JSON.stringify(wanted)],\n revalidate: PUBLISHED_SITE_DATA_TTL_SECONDS,\n tags: [tenantDataTag(hostId)],\n read: () =>\n readEntryLinkFacts(\n hostId,\n wanted.flatMap((key) => targets.get(key) ?? []),\n ),\n store: (value) => !value.unsettled,\n })\n const routes: Record<string, string> = {}\n for (const [key, entrySlug] of Object.entries(facts?.slugs ?? {})) {\n const entry = parseEntryLinkValue(key)\n const collectionSlug = entry ? slugOf(entry.collectionId) : ''\n if (!collectionSlug) continue\n routes[key] = collectionEntryRoutePath(collectionSlug, entrySlug)\n }\n return routes\n } catch (error) {\n console.error('entry link lookup failed:', error)\n return {}\n }\n}\n\nexport default resolveEntryLinkRoutes\n"],"names":["collectionEntryRoutePath","formatEntryLinkValue","parseEntryLinkValue","firebaseAdmin","PUBLISHED_SITE_DATA_TTL_SECONDS","tenantDataTag","withRenderCache","isDueScheduled","isLive","isPendingScheduled","scheduledPublishingPermission","ENTRY_LINK_ROUTES_MAX","ENTRY_LINK_FIELDS","isAddressableId","id","length","test","readEntryLinkFacts","hostId","targets","firestore","app","collections","collection","doc","refs","map","collectionId","entryId","snapshots","getAll","fieldMask","values","snapshot","exists","data","due","some","value","permission","slugs","forEach","index","key","slug","trim","includes","unsettled","resolveEntryLinkRoutes","options","collectionSlugs","slugOf","Map","ref","entry","set","size","wanted","keys","sort","slice","facts","JSON","stringify","revalidate","tags","read","flatMap","get","store","routes","entrySlug","Object","entries","collectionSlug","error","console"],"mappings":"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"}
|
|
@@ -0,0 +1,119 @@
|
|
|
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
|
+
import { type CollectionEntrySummary } from './get-collection-content';
|
|
19
|
+
/**
|
|
20
|
+
* Every author a host has defined, normalized (AGL-2518).
|
|
21
|
+
*
|
|
22
|
+
* ONE cached query, shared by the author page, the sitemap and anything else
|
|
23
|
+
* that needs to turn a slug into a person. Bounded by
|
|
24
|
+
* {@link AUTHORS_MAX_PER_HOST}, which is the platform cap, so the bound can
|
|
25
|
+
* never hide an author that exists.
|
|
26
|
+
*
|
|
27
|
+
* Reading the roster rather than resolving the author out of their own posts
|
|
28
|
+
* is a deliberate reversal of what AGL-2517 did. That version took the record
|
|
29
|
+
* off the first matching entry to avoid a second Firestore read — which meant
|
|
30
|
+
* an author with no published posts had no record, so their page had no name,
|
|
31
|
+
* no bio and no links, and rendered as an empty archive of nobody. A person
|
|
32
|
+
* who has not published yet still has a page; and this read is cached across
|
|
33
|
+
* the whole site, so it costs one query per TTL rather than one per render.
|
|
34
|
+
*
|
|
35
|
+
* Fail-open to an empty roster: the page then falls back to whatever the
|
|
36
|
+
* entries themselves carry, which is the old behavior rather than a 500.
|
|
37
|
+
*/
|
|
38
|
+
export declare function getContentAuthors(options: {
|
|
39
|
+
hostId: string;
|
|
40
|
+
}): Promise<ContentAuthorRecord[]>;
|
|
41
|
+
/** What a `/author/{slug}` route resolved to. */
|
|
42
|
+
export interface AuthorContent {
|
|
43
|
+
/** The addressed segment, normalized — what a canonical link must say. */
|
|
44
|
+
slug: string;
|
|
45
|
+
/** The author's record, when the slug names one. */
|
|
46
|
+
author: ContentAuthorRecord | null;
|
|
47
|
+
/**
|
|
48
|
+
* The byline to print. Falls back to the raw segment so an unknown author
|
|
49
|
+
* still gets a page with a heading rather than a blank one.
|
|
50
|
+
*/
|
|
51
|
+
name: string;
|
|
52
|
+
/**
|
|
53
|
+
* Did the slug resolve to a real author — a roster record, or an entry
|
|
54
|
+
* published under that byline? An unknown slug renders an empty page rather
|
|
55
|
+
* than crashing, which is the category route's rule, but the page must not
|
|
56
|
+
* invite indexing of an address that names nobody.
|
|
57
|
+
*/
|
|
58
|
+
known: boolean;
|
|
59
|
+
/** This author's published entries, newest first, across every collection. */
|
|
60
|
+
entries: CollectionEntrySummary[];
|
|
61
|
+
/** The merged taxonomy of every collection walked, for name resolution. */
|
|
62
|
+
categories: CollectionCategory[];
|
|
63
|
+
page: number;
|
|
64
|
+
perPage: number;
|
|
65
|
+
totalEntries: number;
|
|
66
|
+
totalPages: number;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Everything one author's page shows (AGL-2518) — the person, and what they
|
|
70
|
+
* wrote across the WHOLE site.
|
|
71
|
+
*
|
|
72
|
+
* ## Where the entries come from
|
|
73
|
+
*
|
|
74
|
+
* Every content collection the host owns, through
|
|
75
|
+
* {@link getPublishedCollectionSource} — the same cached per-collection read
|
|
76
|
+
* `/blog` and every "Latest posts" rail already use. So on a warm site this
|
|
77
|
+
* page adds no Firestore reads at all: it is a filter over data the cache is
|
|
78
|
+
* holding anyway. That is the whole reason it walks collections rather than
|
|
79
|
+
* running a collection-group query on `authorId`, which would be one query
|
|
80
|
+
* but would also need its own composite index, would miss every entry written
|
|
81
|
+
* under the legacy free-typed byline (AGL-686), and would share nothing with
|
|
82
|
+
* the rest of the site.
|
|
83
|
+
*
|
|
84
|
+
* ## Why each entry is stamped with its collection
|
|
85
|
+
*
|
|
86
|
+
* One page, several collections, so the routed slug cannot build `entry.url`
|
|
87
|
+
* any more — a changelog note listed under a `/blog` route would link to a
|
|
88
|
+
* page that does not exist. Each entry carries `collectionSlug` and
|
|
89
|
+
* `collectionName` out of the read that found it, and the token map prefers
|
|
90
|
+
* them (`collectionEntryTokens`). Single-collection listings set neither and
|
|
91
|
+
* are unchanged.
|
|
92
|
+
*
|
|
93
|
+
* ## Ordering
|
|
94
|
+
*
|
|
95
|
+
* Newest first by `publishedAt`, with undated entries last rather than first:
|
|
96
|
+
* a draft-turned-live with no timestamp should not lead a person's archive.
|
|
97
|
+
* Sorted ACROSS collections, because the point of the page is a single
|
|
98
|
+
* chronological body of work rather than three lists stacked.
|
|
99
|
+
*/
|
|
100
|
+
export declare function getAuthorContent(options: {
|
|
101
|
+
hostId: string;
|
|
102
|
+
authorSlug: string;
|
|
103
|
+
page?: number;
|
|
104
|
+
perPage?: number;
|
|
105
|
+
}): Promise<AuthorContent>;
|
|
106
|
+
/**
|
|
107
|
+
* Every author page this site can serve, for the sitemap (AGL-2518).
|
|
108
|
+
*
|
|
109
|
+
* Roster order, and only authors that address something: an author whose
|
|
110
|
+
* record has neither a slug nor a name has no URL, and listing one would put
|
|
111
|
+
* `/author/` in the sitemap.
|
|
112
|
+
*/
|
|
113
|
+
export declare function listAuthorPageSlugs(options: {
|
|
114
|
+
hostId: string;
|
|
115
|
+
}): Promise<{
|
|
116
|
+
slug: string;
|
|
117
|
+
name: string;
|
|
118
|
+
}[]>;
|
|
119
|
+
export default getAuthorContent;
|