@aglyn/tenant-runtime 1.0.0-beta.203 → 1.0.0-beta.205

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/README.md CHANGED
@@ -64,12 +64,22 @@ Listening to host events from a plugin's server code:
64
64
  import { registerHostEventListener } from '@aglyn/tenant-runtime'
65
65
 
66
66
  registerHostEventListener('my-plugin', {
67
- async onEvent(hostId, event, payload) {
68
- // react to the event; optionally return site alerts
67
+ async onEvent(hostId, event, payload, context) {
68
+ // react to the event; optionally return site alerts.
69
+ // `context.actor` is who caused it, when the emitting door knew:
70
+ // { kind: 'member' | 'visitor' | 'apiKey' | 'platform', uid?, email?, apiKeyName? }
69
71
  },
70
72
  })
71
73
  ```
72
74
 
75
+ Raising one, with who caused it:
76
+
77
+ ```ts
78
+ await emitHostEvent(hostId, 'contactStageChanged', payload, {
79
+ actor: { kind: 'member', uid, email },
80
+ })
81
+ ```
82
+
73
83
  Loading a screen on the server:
74
84
 
75
85
  ```ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/tenant-runtime",
3
- "version": "1.0.0-beta.203",
3
+ "version": "1.0.0-beta.205",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,8 +25,8 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.0.0-beta.203",
29
- "@aglyn/tenant-data-admin": "1.0.0-beta.203",
28
+ "@aglyn/aglyn": "1.0.0-beta.205",
29
+ "@aglyn/tenant-data-admin": "1.0.0-beta.205",
30
30
  "@swc/helpers": "0.5.23"
31
31
  },
32
32
  "peerDependencies": {
@@ -0,0 +1,25 @@
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 { HostEventActor } from './host-event-listeners';
18
+ /**
19
+ * Who a capture's `contactCreated` names as its cause (AGL-3376): the door's
20
+ * own word when it gave one; otherwise the visitor themselves on a door where
21
+ * the person captured is the one acting — a form, a sign-up, a purchase, a
22
+ * booking. A door a member or a key drives (`manual`, `import`, `api`) passes
23
+ * its actor, and one that does not is left unrecorded rather than guessed.
24
+ */
25
+ export declare function contactCaptureActor(source: string | null | undefined, email: string | null | undefined, explicit?: HostEventActor): HostEventActor | undefined;
@@ -0,0 +1,40 @@
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 { _ as _extends } from "@swc/helpers/_/_extends";
17
+ /** The capture sources in which the person captured is the one acting. */ const SELF_CAPTURE_SOURCES = new Set([
18
+ 'form',
19
+ 'member',
20
+ 'order',
21
+ 'booking',
22
+ 'newsletter'
23
+ ]);
24
+ /**
25
+ * Who a capture's `contactCreated` names as its cause (AGL-3376): the door's
26
+ * own word when it gave one; otherwise the visitor themselves on a door where
27
+ * the person captured is the one acting — a form, a sign-up, a purchase, a
28
+ * booking. A door a member or a key drives (`manual`, `import`, `api`) passes
29
+ * its actor, and one that does not is left unrecorded rather than guessed.
30
+ */ export function contactCaptureActor(source, email, explicit) {
31
+ if (explicit == null ? void 0 : explicit.kind) return explicit;
32
+ if (!SELF_CAPTURE_SOURCES.has(String(source != null ? source : ''))) return undefined;
33
+ return _extends({
34
+ kind: 'visitor'
35
+ }, email ? {
36
+ email
37
+ } : {});
38
+ }
39
+
40
+ //# sourceMappingURL=capture-actor.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../libs/tenant/runtime/src/lib/capture-actor.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 { HostEventActor } from './host-event-listeners'\n\n/** The capture sources in which the person captured is the one acting. */\nconst SELF_CAPTURE_SOURCES = new Set(['form', 'member', 'order', 'booking', 'newsletter'])\n\n/**\n * Who a capture's `contactCreated` names as its cause (AGL-3376): the door's\n * own word when it gave one; otherwise the visitor themselves on a door where\n * the person captured is the one acting — a form, a sign-up, a purchase, a\n * booking. A door a member or a key drives (`manual`, `import`, `api`) passes\n * its actor, and one that does not is left unrecorded rather than guessed.\n */\nexport function contactCaptureActor(\n source: string | null | undefined,\n email: string | null | undefined,\n explicit?: HostEventActor,\n): HostEventActor | undefined {\n if (explicit?.kind) return explicit\n if (!SELF_CAPTURE_SOURCES.has(String(source ?? ''))) return undefined\n return { kind: 'visitor', ...(email ? { email } : {}) }\n}\n"],"names":["SELF_CAPTURE_SOURCES","Set","contactCaptureActor","source","email","explicit","kind","has","String","undefined"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC;AAID,wEAAwE,GACxE,MAAMA,uBAAuB,IAAIC,IAAI;IAAC;IAAQ;IAAU;IAAS;IAAW;CAAa;AAEzF;;;;;;CAMC,GACD,OAAO,SAASC,oBACdC,MAAiC,EACjCC,KAAgC,EAChCC,QAAyB;IAEzB,IAAIA,4BAAAA,SAAUC,IAAI,EAAE,OAAOD;IAC3B,IAAI,CAACL,qBAAqBO,GAAG,CAACC,OAAOL,iBAAAA,SAAU,MAAM,OAAOM;IAC5D,OAAO;QAAEH,MAAM;OAAeF,QAAQ;QAAEA;IAAM,IAAI,CAAC;AACrD"}
@@ -15,7 +15,7 @@
15
15
  * limitations under the License.
16
16
  */
17
17
  import { type HostContactCreated, type UpsertHostContactOptions, type UpsertHostContactVerdict } from '@aglyn/tenant-data-admin';
18
- import type { HostEventPayload } from './host-event-listeners';
18
+ import type { HostEventActor, HostEventPayload } from './host-event-listeners';
19
19
  /**
20
20
  * THE contact capture door for a server path (AGL-2605).
21
21
  *
@@ -76,7 +76,10 @@ import type { HostEventPayload } from './host-event-listeners';
76
76
  * The order doors call this directly and close nothing — a buyer with an
77
77
  * open lead is a lead the rep converts, as in Salesforce.
78
78
  */
79
- export declare function captureHostContact(options: Omit<UpsertHostContactOptions, 'onCreated'>): Promise<UpsertHostContactVerdict>;
79
+ export declare function captureHostContact(captureOptions: Omit<UpsertHostContactOptions, 'onCreated'> & {
80
+ /** Who caused the capture, when the door knows (AGL-3376). */
81
+ actor?: HostEventActor;
82
+ }): Promise<UpsertHostContactVerdict>;
80
83
  /**
81
84
  * The `contactCreated` payload, as the scalars an event may carry.
82
85
  *
@@ -1,4 +1,5 @@
1
1
  import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ import { _ as _object_without_properties_loose } from "@swc/helpers/_/_object_without_properties_loose";
2
3
  /**
3
4
  * @license
4
5
  * Copyright 2026 Aglyn LLC
@@ -18,6 +19,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
18
19
  import { assignOwnerForCapture } from "./assign-contact-owner.js";
19
20
  import { associateCompanyByDomain } from "./associate-company-by-domain.js";
20
21
  import { emitHostEvent } from "./emit-host-event.js";
22
+ import { contactCaptureActor } from "./capture-actor.js";
21
23
  /**
22
24
  * THE contact capture door for a server path (AGL-2605).
23
25
  *
@@ -77,7 +79,10 @@ import { emitHostEvent } from "./emit-host-event.js";
77
79
  * its capture writer for the doors that report through the capture seam.
78
80
  * The order doors call this directly and close nothing — a buyer with an
79
81
  * open lead is a lead the rep converts, as in Salesforce.
80
- */ export async function captureHostContact(options) {
82
+ */ export async function captureHostContact(captureOptions) {
83
+ const { actor } = captureOptions, options = _object_without_properties_loose(captureOptions, [
84
+ "actor"
85
+ ]);
81
86
  return upsertHostContact(_extends({}, options, {
82
87
  onCreated: async (created)=>{
83
88
  var _options_facet, _options_facet1;
@@ -99,7 +104,10 @@ import { emitHostEvent } from "./emit-host-event.js";
99
104
  console.error('captureHostContact owner assignment failed', error);
100
105
  });
101
106
  }
102
- await emitHostEvent(created.hostId, 'contactCreated', contactCreatedPayload(created, options.interaction.formId));
107
+ const cause = contactCaptureActor(created.source, created.email, actor);
108
+ await emitHostEvent(created.hostId, 'contactCreated', contactCreatedPayload(created, options.interaction.formId), cause ? {
109
+ actor: cause
110
+ } : {});
103
111
  }
104
112
  }));
105
113
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/tenant/runtime/src/lib/capture-host-contact.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 type HostContactCreated,\n upsertHostContact,\n type UpsertHostContactOptions,\n type UpsertHostContactVerdict,\n} from '@aglyn/tenant-data-admin'\nimport { assignOwnerForCapture } from './assign-contact-owner'\nimport { associateCompanyByDomain } from './associate-company-by-domain'\nimport { emitHostEvent } from './emit-host-event'\nimport type { HostEventPayload } from './host-event-listeners'\n\n/**\n * THE contact capture door for a server path (AGL-2605).\n *\n * `upsertHostContact` writes the contact and reports, through its\n * `onCreated` hook, when the write made a NEW person. This wrapper is the\n * one place that hook is bound to `contactCreated`, and it is the function\n * every server door calls — the forms route, the membership and newsletter\n * handlers, the order and booking webhooks. A door that calls\n * `upsertHostContact` directly still captures the contact and fires nothing,\n * which is a contact no automation can welcome; `capture-host-contact.spec`\n * scans for exactly that call.\n *\n * ## Why the binding lives here and not in the data library\n *\n * What listens for the event — the automation engine among them — imports\n * the data library for its Firestore handle, its org helpers and its\n * senders. The data library emitting an event would import the listeners\n * back, which is a cycle, and the module boundaries (`scope:data` depends on\n * data and util only) refuse it in any case. So the lower layer reports a\n * fact and this layer, which already knows how to announce one, announces\n * it. The alternative — a sink the runtime registers into when its module\n * happens to be imported — would fire only in a process that had loaded the\n * runtime, and a Stripe webhook that created a contact in a process that had\n * not would announce nothing with no error anywhere. The listeners themselves\n * are registered by a call made at boot (`host-event-listeners.ts`), so every\n * process has them before its first request.\n *\n * Fire-and-forget in the same sense the capture itself is: the hook's\n * failure is caught inside `upsertHostContact`, so a runner that throws\n * costs the door nothing, and this function never rejects.\n *\n * ## The company, before the announcement (AGL-2613)\n *\n * A new person with a work email address is linked to the company at that\n * domain here, in the same hook, BEFORE `contactCreated` goes out — so an\n * automation that reads the new contact finds them already filed, rather\n * than racing a link that lands a moment later. Only when the door did not\n * name a company itself: the console's drawer and an import row that\n * carried one have written it into the facet, and the capture must not\n * second-guess a person's choice with a domain match. The association never\n * rejects, and its own catch keeps a failed lookup from costing the event.\n *\n * ## The owner, on the same terms (AGL-2618)\n *\n * Then the org's assignment rules and the site's default owner decide who\n * follows the new person up — again before `contactCreated`, so an\n * automation's \"create a task for the owner\" finds one — and again only\n * when the door named none: a drawer, an import column or a conversion\n * that picked an owner has said whose the record is. The pass reads the\n * form and the tags off the same options the data library was handed,\n * because the created-report carries the identity and not the routing.\n * It never rejects either; a record it could not assign is one somebody\n * assigns by hand, which is what every record was before this existed.\n *\n * What this does NOT do is close a lead (AGL-3232): a lead is the CRM's\n * record, and which relationship closes one is the CRM's rule, answered in\n * its capture writer for the doors that report through the capture seam.\n * The order doors call this directly and close nothing — a buyer with an\n * open lead is a lead the rep converts, as in Salesforce.\n */\nexport async function captureHostContact(\n options: Omit<UpsertHostContactOptions, 'onCreated'>,\n): Promise<UpsertHostContactVerdict> {\n return upsertHostContact({\n ...options,\n onCreated: async (created) => {\n if (!options.facet?.companyId) {\n await associateCompanyByDomain(created).catch((error: unknown) => {\n console.error('captureHostContact company association failed', error)\n })\n }\n if (!options.facet?.ownerUid) {\n await assignOwnerForCapture({\n hostId: created.hostId,\n contactId: created.contactId,\n email: created.email,\n source: created.source,\n formId: options.interaction.formId ?? null,\n tags: options.tags,\n }).catch((error: unknown) => {\n console.error('captureHostContact owner assignment failed', error)\n })\n }\n await emitHostEvent(\n created.hostId,\n 'contactCreated',\n contactCreatedPayload(created, options.interaction.formId),\n )\n },\n })\n}\n\n/**\n * The `contactCreated` payload, as the scalars an event may carry.\n *\n * `HostEventPayload` holds strings, numbers and booleans and nothing\n * nested, because the payload seeds an expression scope and a condition\n * editor whose operators compare strings. So `campaignIds` rides as one\n * comma-joined string — `contains` still finds a campaign in it — and is\n * present only when the capture had campaigns, so `notEmpty` on it reads\n * as \"came in through a campaign form\". `name` and `lifecycleStage` are\n * always present, empty when the door had none, so a condition on either\n * never sees a missing key: `lifecycleStage == \"lead\"` is the filter that\n * picks the form captures out of the sign-ups (AGL-2612).\n *\n * `formId` rides the same way `campaignIds` does — present only when the\n * capture came through a form — because the created-report carries the\n * person's identity and not the routing, and the form is routing: it is\n * read off the interaction the door was handed. Present, it is what lets a\n * condition say `formId` equals this form and no other (AGL-2626); absent,\n * `notEmpty` on it reads as \"came in through a form\".\n */\nexport function contactCreatedPayload(\n created: HostContactCreated,\n formId?: string | null,\n): HostEventPayload {\n const form = String(formId ?? '').trim()\n return {\n contactId: created.contactId,\n email: created.email,\n name: created.name ?? '',\n source: created.source,\n hostId: created.hostId,\n lifecycleStage: created.lifecycleStage ?? '',\n ...(created.campaignIds.length\n ? { campaignIds: created.campaignIds.join(',') }\n : {}),\n ...(form ? { formId: form } : {}),\n }\n}\n\nexport default captureHostContact\n"],"names":["upsertHostContact","assignOwnerForCapture","associateCompanyByDomain","emitHostEvent","captureHostContact","options","onCreated","created","facet","companyId","catch","error","console","ownerUid","hostId","contactId","email","source","formId","interaction","tags","contactCreatedPayload","form","String","trim","name","lifecycleStage","campaignIds","length","join"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAEEA,iBAAiB,QAGZ,2BAA0B;AACjC,SAASC,qBAAqB,QAAQ,4BAAwB;AAC9D,SAASC,wBAAwB,QAAQ,mCAA+B;AACxE,SAASC,aAAa,QAAQ,uBAAmB;AAGjD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2DC,GACD,OAAO,eAAeC,mBACpBC,OAAoD;IAEpD,OAAOL,kBAAkB,aACpBK;QACHC,WAAW,OAAOC;gBACXF,gBAKAA;YALL,IAAI,GAACA,iBAAAA,QAAQG,KAAK,qBAAbH,eAAeI,SAAS,GAAE;gBAC7B,MAAMP,yBAAyBK,SAASG,KAAK,CAAC,CAACC;oBAC7CC,QAAQD,KAAK,CAAC,iDAAiDA;gBACjE;YACF;YACA,IAAI,GAACN,kBAAAA,QAAQG,KAAK,qBAAbH,gBAAeQ,QAAQ,GAAE;oBAMlBR;gBALV,MAAMJ,sBAAsB;oBAC1Ba,QAAQP,QAAQO,MAAM;oBACtBC,WAAWR,QAAQQ,SAAS;oBAC5BC,OAAOT,QAAQS,KAAK;oBACpBC,QAAQV,QAAQU,MAAM;oBACtBC,MAAM,GAAEb,8BAAAA,QAAQc,WAAW,CAACD,MAAM,YAA1Bb,8BAA8B;oBACtCe,MAAMf,QAAQe,IAAI;gBACpB,GAAGV,KAAK,CAAC,CAACC;oBACRC,QAAQD,KAAK,CAAC,8CAA8CA;gBAC9D;YACF;YACA,MAAMR,cACJI,QAAQO,MAAM,EACd,kBACAO,sBAAsBd,SAASF,QAAQc,WAAW,CAACD,MAAM;QAE7D;;AAEJ;AAEA;;;;;;;;;;;;;;;;;;;CAmBC,GACD,OAAO,SAASG,sBACdd,OAA2B,EAC3BW,MAAsB;QAMdX,eAGUA;IAPlB,MAAMe,OAAOC,OAAOL,iBAAAA,SAAU,IAAIM,IAAI;IACtC,OAAO;QACLT,WAAWR,QAAQQ,SAAS;QAC5BC,OAAOT,QAAQS,KAAK;QACpBS,IAAI,GAAElB,gBAAAA,QAAQkB,IAAI,YAAZlB,gBAAgB;QACtBU,QAAQV,QAAQU,MAAM;QACtBH,QAAQP,QAAQO,MAAM;QACtBY,cAAc,GAAEnB,0BAAAA,QAAQmB,cAAc,YAAtBnB,0BAA0B;OACtCA,QAAQoB,WAAW,CAACC,MAAM,GAC1B;QAAED,aAAapB,QAAQoB,WAAW,CAACE,IAAI,CAAC;IAAK,IAC7C,CAAC,GACDP,OAAO;QAAEJ,QAAQI;IAAK,IAAI,CAAC;AAEnC;AAEA,eAAelB,mBAAkB"}
1
+ {"version":3,"sources":["../../../../../../libs/tenant/runtime/src/lib/capture-host-contact.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 type HostContactCreated,\n upsertHostContact,\n type UpsertHostContactOptions,\n type UpsertHostContactVerdict,\n} from '@aglyn/tenant-data-admin'\nimport { assignOwnerForCapture } from './assign-contact-owner'\nimport { associateCompanyByDomain } from './associate-company-by-domain'\nimport { emitHostEvent } from './emit-host-event'\nimport { contactCaptureActor } from './capture-actor'\nimport type { HostEventActor, HostEventPayload } from './host-event-listeners'\n\n\n/**\n * THE contact capture door for a server path (AGL-2605).\n *\n * `upsertHostContact` writes the contact and reports, through its\n * `onCreated` hook, when the write made a NEW person. This wrapper is the\n * one place that hook is bound to `contactCreated`, and it is the function\n * every server door calls — the forms route, the membership and newsletter\n * handlers, the order and booking webhooks. A door that calls\n * `upsertHostContact` directly still captures the contact and fires nothing,\n * which is a contact no automation can welcome; `capture-host-contact.spec`\n * scans for exactly that call.\n *\n * ## Why the binding lives here and not in the data library\n *\n * What listens for the event — the automation engine among them — imports\n * the data library for its Firestore handle, its org helpers and its\n * senders. The data library emitting an event would import the listeners\n * back, which is a cycle, and the module boundaries (`scope:data` depends on\n * data and util only) refuse it in any case. So the lower layer reports a\n * fact and this layer, which already knows how to announce one, announces\n * it. The alternative — a sink the runtime registers into when its module\n * happens to be imported — would fire only in a process that had loaded the\n * runtime, and a Stripe webhook that created a contact in a process that had\n * not would announce nothing with no error anywhere. The listeners themselves\n * are registered by a call made at boot (`host-event-listeners.ts`), so every\n * process has them before its first request.\n *\n * Fire-and-forget in the same sense the capture itself is: the hook's\n * failure is caught inside `upsertHostContact`, so a runner that throws\n * costs the door nothing, and this function never rejects.\n *\n * ## The company, before the announcement (AGL-2613)\n *\n * A new person with a work email address is linked to the company at that\n * domain here, in the same hook, BEFORE `contactCreated` goes out — so an\n * automation that reads the new contact finds them already filed, rather\n * than racing a link that lands a moment later. Only when the door did not\n * name a company itself: the console's drawer and an import row that\n * carried one have written it into the facet, and the capture must not\n * second-guess a person's choice with a domain match. The association never\n * rejects, and its own catch keeps a failed lookup from costing the event.\n *\n * ## The owner, on the same terms (AGL-2618)\n *\n * Then the org's assignment rules and the site's default owner decide who\n * follows the new person up — again before `contactCreated`, so an\n * automation's \"create a task for the owner\" finds one — and again only\n * when the door named none: a drawer, an import column or a conversion\n * that picked an owner has said whose the record is. The pass reads the\n * form and the tags off the same options the data library was handed,\n * because the created-report carries the identity and not the routing.\n * It never rejects either; a record it could not assign is one somebody\n * assigns by hand, which is what every record was before this existed.\n *\n * What this does NOT do is close a lead (AGL-3232): a lead is the CRM's\n * record, and which relationship closes one is the CRM's rule, answered in\n * its capture writer for the doors that report through the capture seam.\n * The order doors call this directly and close nothing — a buyer with an\n * open lead is a lead the rep converts, as in Salesforce.\n */\nexport async function captureHostContact(\n captureOptions: Omit<UpsertHostContactOptions, 'onCreated'> & {\n /** Who caused the capture, when the door knows (AGL-3376). */\n actor?: HostEventActor\n },\n): Promise<UpsertHostContactVerdict> {\n const { actor, ...options } = captureOptions\n return upsertHostContact({\n ...options,\n onCreated: async (created) => {\n if (!options.facet?.companyId) {\n await associateCompanyByDomain(created).catch((error: unknown) => {\n console.error('captureHostContact company association failed', error)\n })\n }\n if (!options.facet?.ownerUid) {\n await assignOwnerForCapture({\n hostId: created.hostId,\n contactId: created.contactId,\n email: created.email,\n source: created.source,\n formId: options.interaction.formId ?? null,\n tags: options.tags,\n }).catch((error: unknown) => {\n console.error('captureHostContact owner assignment failed', error)\n })\n }\n const cause = contactCaptureActor(created.source, created.email, actor)\n await emitHostEvent(\n created.hostId,\n 'contactCreated',\n contactCreatedPayload(created, options.interaction.formId),\n cause ? { actor: cause } : {},\n )\n },\n })\n}\n\n/**\n * The `contactCreated` payload, as the scalars an event may carry.\n *\n * `HostEventPayload` holds strings, numbers and booleans and nothing\n * nested, because the payload seeds an expression scope and a condition\n * editor whose operators compare strings. So `campaignIds` rides as one\n * comma-joined string — `contains` still finds a campaign in it — and is\n * present only when the capture had campaigns, so `notEmpty` on it reads\n * as \"came in through a campaign form\". `name` and `lifecycleStage` are\n * always present, empty when the door had none, so a condition on either\n * never sees a missing key: `lifecycleStage == \"lead\"` is the filter that\n * picks the form captures out of the sign-ups (AGL-2612).\n *\n * `formId` rides the same way `campaignIds` does — present only when the\n * capture came through a form — because the created-report carries the\n * person's identity and not the routing, and the form is routing: it is\n * read off the interaction the door was handed. Present, it is what lets a\n * condition say `formId` equals this form and no other (AGL-2626); absent,\n * `notEmpty` on it reads as \"came in through a form\".\n */\nexport function contactCreatedPayload(\n created: HostContactCreated,\n formId?: string | null,\n): HostEventPayload {\n const form = String(formId ?? '').trim()\n return {\n contactId: created.contactId,\n email: created.email,\n name: created.name ?? '',\n source: created.source,\n hostId: created.hostId,\n lifecycleStage: created.lifecycleStage ?? '',\n ...(created.campaignIds.length\n ? { campaignIds: created.campaignIds.join(',') }\n : {}),\n ...(form ? { formId: form } : {}),\n }\n}\n\nexport default captureHostContact\n"],"names":["upsertHostContact","assignOwnerForCapture","associateCompanyByDomain","emitHostEvent","contactCaptureActor","captureHostContact","captureOptions","actor","options","onCreated","created","facet","companyId","catch","error","console","ownerUid","hostId","contactId","email","source","formId","interaction","tags","cause","contactCreatedPayload","form","String","trim","name","lifecycleStage","campaignIds","length","join"],"mappings":";;AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAEEA,iBAAiB,QAGZ,2BAA0B;AACjC,SAASC,qBAAqB,QAAQ,4BAAwB;AAC9D,SAASC,wBAAwB,QAAQ,mCAA+B;AACxE,SAASC,aAAa,QAAQ,uBAAmB;AACjD,SAASC,mBAAmB,QAAQ,qBAAiB;AAIrD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2DC,GACD,OAAO,eAAeC,mBACpBC,cAGC;IAED,MAAM,EAAEC,KAAK,EAAc,GAAGD,gBAAZE,2CAAYF;;;IAC9B,OAAON,kBAAkB,aACpBQ;QACHC,WAAW,OAAOC;gBACXF,gBAKAA;YALL,IAAI,GAACA,iBAAAA,QAAQG,KAAK,qBAAbH,eAAeI,SAAS,GAAE;gBAC7B,MAAMV,yBAAyBQ,SAASG,KAAK,CAAC,CAACC;oBAC7CC,QAAQD,KAAK,CAAC,iDAAiDA;gBACjE;YACF;YACA,IAAI,GAACN,kBAAAA,QAAQG,KAAK,qBAAbH,gBAAeQ,QAAQ,GAAE;oBAMlBR;gBALV,MAAMP,sBAAsB;oBAC1BgB,QAAQP,QAAQO,MAAM;oBACtBC,WAAWR,QAAQQ,SAAS;oBAC5BC,OAAOT,QAAQS,KAAK;oBACpBC,QAAQV,QAAQU,MAAM;oBACtBC,MAAM,GAAEb,8BAAAA,QAAQc,WAAW,CAACD,MAAM,YAA1Bb,8BAA8B;oBACtCe,MAAMf,QAAQe,IAAI;gBACpB,GAAGV,KAAK,CAAC,CAACC;oBACRC,QAAQD,KAAK,CAAC,8CAA8CA;gBAC9D;YACF;YACA,MAAMU,QAAQpB,oBAAoBM,QAAQU,MAAM,EAAEV,QAAQS,KAAK,EAAEZ;YACjE,MAAMJ,cACJO,QAAQO,MAAM,EACd,kBACAQ,sBAAsBf,SAASF,QAAQc,WAAW,CAACD,MAAM,GACzDG,QAAQ;gBAAEjB,OAAOiB;YAAM,IAAI,CAAC;QAEhC;;AAEJ;AAEA;;;;;;;;;;;;;;;;;;;CAmBC,GACD,OAAO,SAASC,sBACdf,OAA2B,EAC3BW,MAAsB;QAMdX,eAGUA;IAPlB,MAAMgB,OAAOC,OAAON,iBAAAA,SAAU,IAAIO,IAAI;IACtC,OAAO;QACLV,WAAWR,QAAQQ,SAAS;QAC5BC,OAAOT,QAAQS,KAAK;QACpBU,IAAI,GAAEnB,gBAAAA,QAAQmB,IAAI,YAAZnB,gBAAgB;QACtBU,QAAQV,QAAQU,MAAM;QACtBH,QAAQP,QAAQO,MAAM;QACtBa,cAAc,GAAEpB,0BAAAA,QAAQoB,cAAc,YAAtBpB,0BAA0B;OACtCA,QAAQqB,WAAW,CAACC,MAAM,GAC1B;QAAED,aAAarB,QAAQqB,WAAW,CAACE,IAAI,CAAC;IAAK,IAC7C,CAAC,GACDP,OAAO;QAAEL,QAAQK;IAAK,IAAI,CAAC;AAEnC;AAEA,eAAerB,mBAAkB"}
@@ -150,7 +150,18 @@ import { collectSocialImageFacts } from "./social-image-facts.js";
150
150
  socialImages: card.socialImages,
151
151
  // The designated screen's host variables, and its layout's, fill in
152
152
  // from this site, as the built-in author page's already do (AGL-2883).
153
- host: host
153
+ host: host,
154
+ // Named as the author template it is if the page review holds it
155
+ // (AGL-3374); without it the site's built-in author page serves.
156
+ page: {
157
+ template: {
158
+ role: 'entry',
159
+ route: '/author/:slug',
160
+ collectionName: 'Authors',
161
+ entryPath: `/author/${content.slug}`,
162
+ fallback: 'built-in-design'
163
+ }
164
+ }
154
165
  }));
155
166
  if (!nodes) return null;
156
167
  /*
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/tenant/runtime/src/lib/compose-author-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 buildAuthorPageNodes from './author-page-nodes'\nimport { resolveBuiltInPageLayoutId } from './built-in-page-layout'\nimport composeScreenNodes, {\n composeNodesWithChrome,\n} from './compose-screen-nodes'\nimport type { AuthorContent } from './get-author-content'\nimport getScreen from './get-screen'\nimport { collectSocialImageFacts } from './social-image-facts'\n\n/**\n * The host field naming the screen every author page renders through\n * (AGL-2518).\n *\n * ## Why the HOST and not the author\n *\n * A collection names its own list and entry templates because a site\n * legitimately designs its changelog differently from its blog. Author pages\n * are not like that: they are one page shape repeated per person, and letting\n * each author record name its own template would mean a masthead where the\n * design changes as the reader clicks between colleagues — plus a template\n * picker on a form whose whole subject is a byline.\n *\n * ## Why a SCREEN and not a layout\n *\n * The opposite call from `builtInPageLayoutId` (AGL-2513), and for the reason\n * that comment gives in reverse. Search results have no design worth\n * authoring — the platform composes the body and the site only chooses the\n * chrome around it. An author page has: a portrait, a bio, a role, links, and\n * a listing whose cards a designer wants to match the blog's. That is a page,\n * so the author designs it as one, with `{{author.*}}` tokens and the same\n * Collection entries block every list template uses.\n *\n * Unset is the ordinary case rather than a misconfiguration, and it renders\n * the built-in body — bylines link to this address, so it must always answer.\n */\nexport const AUTHOR_PAGE_SCREEN_FIELD = 'authorScreenId' as const\n\n/**\n * The pseudo-collection an author page's entries block resolves against.\n *\n * The compose pipeline keys entry sources by collection slug, and this page\n * has no single collection — it mixes them. Its entries are handed over\n * already in hand (`ComposeCollectionContext.entries`), which is a path that\n * never touches Firestore, so the key only has to be a name that no real\n * collection can take: a collection slug is a URL segment, and this is not a\n * legal one.\n *\n * It never reaches a URL. Every entry on this page carries its OWN\n * `collectionSlug` out of the read that found it, and `collectionEntryTokens`\n * prefers that — which is the whole reason a cross-collection listing can\n * build correct `entry.url`s at all.\n */\nexport const AUTHOR_ENTRIES_SOURCE_SLUG = '__author__'\n\n/** What the author route renders. */\nexport interface ComposedAuthorPage {\n /** The template screen doc, when one is designated; null for the built-in. */\n screen: Record<string, any> | null\n nodes: Record<string, any>\n /**\n * The current pair of each picture the head's card may name (AGL-2850): the\n * author's share card, then their portrait. Read in the composition's\n * batch, and absent when it answered for neither.\n */\n socialImageFacts?: Aglyn.SocialImageAssetFacts\n}\n\n/** The tokens and entry context both compose paths share. */\nfunction authorComposeContext(content: AuthorContent) {\n const address = content.author\n ? { author: content.author }\n : { authorName: content.slug }\n const pager = Aglyn.contentAuthorPaginationLinks({\n ...address,\n page: content.page,\n totalPages: content.totalPages,\n })\n return {\n /**\n * The pictures the head builds the card from, in its order: the share\n * card, then the portrait (AGL-2850). The author head reads the record\n * alone and never the site default, so the host's image is not one.\n */\n cardImages: [content.author?.seoImage, content.author?.image],\n tokens: {\n ...Aglyn.contentAuthorTokens(content.author, {\n entryCount: content.totalEntries,\n }),\n // The byline falls back to the raw segment for an unknown author, so a\n // heading bound to `{{author.name}}` prints something either way.\n 'author.name': content.name,\n // Named `pagination.*` rather than `author.page*`, so a designer who has\n // already built a pager on a collection list template rebuilds nothing\n // here — and so the two pagers cannot drift into two spellings of the\n // same four values. The edges are the empty string, which is what makes\n // an unconditional binding correct on page 1 of 1.\n 'pagination.page': String(pager.page),\n 'pagination.totalPages': String(pager.totalPages),\n 'pagination.prevUrl': pager.prevUrl,\n 'pagination.nextUrl': pager.nextUrl,\n } as Record<string, string>,\n collection: {\n slug: AUTHOR_ENTRIES_SOURCE_SLUG,\n // The slug above is a key, not an address (AGL-2524) — so a block that\n // would build a URL out of it resolves nothing instead.\n routeless: true,\n // Already narrowed to this author AND to this page's window by the\n // loader, for the reason the category route states: a page count\n // computed over the whole set advertises pages that render empty.\n entries: content.entries,\n categories: content.categories,\n page: content.page,\n },\n }\n}\n\n/**\n * An author page composed through the screen the host designated (AGL-2518).\n *\n * The `composeCollectionTemplatePage` shape: the screen goes through the\n * NORMAL published pipeline — theme, shared layout, reusable components,\n * Collection entries blocks — with `{{author.*}}` and `{{pagination.*}}`\n * substituted and Author Profile blocks filled from the record.\n *\n * Returns null when no screen is designated, or when the designated one has\n * been deleted, so the caller falls through to the built-in body rather than\n * serving a 404 for a page a byline links to.\n */\nexport async function composeAuthorTemplatePage(options: {\n hostId: string\n /**\n * The zone this site's dates read in (AGL-3237); UTC when a site has not\n * named one. Resolved once by the caller that holds the org and the host.\n */\n timeZone?: string\n host: any\n content: AuthorContent\n}): Promise<ComposedAuthorPage | null> {\n const { hostId, host, content } = options\n const screenId = String(host?.[AUTHOR_PAGE_SCREEN_FIELD] ?? '').trim()\n if (!screenId) return null\n try {\n // `allowTemplate`, exactly as a collection template is read: the screen is\n // deliberately not servable at an address of its own, and this is the\n // composition it exists for.\n const templateRes = await getScreen({\n hostId,\n screenId,\n allowTemplate: true,\n })\n if (!templateRes.screen) return null\n const { cardImages, tokens, collection } = authorComposeContext(content)\n const card = collectSocialImageFacts(cardImages)\n const nodes = await composeScreenNodes({\n // The site's zone (AGL-3237). This is the TEMPLATE path, which\n // composes through `composeScreenNodes` rather than\n // `composeNodesWithChrome` — and that difference is why the first two\n // passes at this fix reached every page except the one the bug was\n // reported on.\n ...(options.timeZone ? { timeZone: options.timeZone } : {}),\n hostId,\n screenId,\n screen: templateRes.screen,\n tokens,\n collection,\n socialImages: card.socialImages,\n // The designated screen's host variables, and its layout's, fill in\n // from this site, as the built-in author page's already do (AGL-2883).\n host: host as Aglyn.HostTokenSource,\n })\n if (!nodes) return null\n /*\n The screen document travels UNCHANGED, and deliberately carries no\n composed SEO.\n\n A collection template composes the entry's title and cover into\n `screen.seo` because `page.tsx` reads that on its way to the head. An\n author page does not take that path: its head branch has the author\n RECORD in hand and builds the title, description and portrait from it\n directly, before the screen branch is ever reached. Writing a second,\n unread copy here would be a value that looks authoritative, is dead, and\n goes stale the first time the head's rule changes — which is how a\n template's stored title came to name every post in a collection\n (AGL-1345).\n */\n return {\n screen: templateRes.screen as Record<string, any>,\n nodes: Aglyn.expandContentAuthorProfile(nodes, content.author),\n ...card.collected(),\n }\n } catch (error) {\n console.error('author template composition failed', error)\n return null\n }\n}\n\n/**\n * The built-in author page (AGL-2518) — the body this file's own nodes build,\n * grafted into the site's shared layout so it carries the same header, nav and\n * footer as the posts it links to.\n *\n * Which layout is the host's to choose (`resolveBuiltInPageLayoutId`), shared\n * with site search so the two platform-built pages of a site cannot end up in\n * different chrome.\n *\n * Fail-open to `null`, and the route then renders the body with no chrome at\n * all. A person's page must not be the page that 500s because a layout was\n * deleted.\n */\nexport async function composeAuthorFallbackPage(options: {\n hostId: string\n /**\n * The zone this site's dates read in (AGL-3237); UTC when a site has not\n * named one. Resolved once by the caller that holds the org and the host.\n */\n timeZone?: string\n host: any\n content: AuthorContent\n}): Promise<ComposedAuthorPage | null> {\n const { hostId, host, content } = options\n try {\n const layoutId = await resolveBuiltInPageLayoutId({ hostId, host })\n const { cardImages, tokens, collection } = authorComposeContext(content)\n const card = collectSocialImageFacts(cardImages)\n const nodes = await composeNodesWithChrome({\n ...(options.timeZone ? { timeZone: options.timeZone } : {}),\n hostId,\n layoutId,\n socialImages: card.socialImages,\n screenNodes: buildAuthorPageNodes({\n slug: content.slug,\n name: content.name,\n author: content.author,\n hasEntries: content.entries.length > 0,\n page: content.page,\n perPage: content.perPage,\n totalPages: content.totalPages,\n }),\n tokens,\n collection,\n host: host as Aglyn.HostTokenSource,\n })\n return nodes ? { screen: null, nodes, ...card.collected() } : null\n } catch (error) {\n console.error('author page composition failed', error)\n return null\n }\n}\n\nexport default composeAuthorTemplatePage\n"],"names":["Aglyn","buildAuthorPageNodes","resolveBuiltInPageLayoutId","composeScreenNodes","composeNodesWithChrome","getScreen","collectSocialImageFacts","AUTHOR_PAGE_SCREEN_FIELD","AUTHOR_ENTRIES_SOURCE_SLUG","authorComposeContext","content","address","author","authorName","slug","pager","contentAuthorPaginationLinks","page","totalPages","cardImages","seoImage","image","tokens","contentAuthorTokens","entryCount","totalEntries","name","String","prevUrl","nextUrl","collection","routeless","entries","categories","composeAuthorTemplatePage","options","hostId","host","screenId","trim","templateRes","allowTemplate","screen","card","nodes","timeZone","socialImages","expandContentAuthorProfile","collected","error","console","composeAuthorFallbackPage","layoutId","screenNodes","hasEntries","length","perPage"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/tenant/runtime/src/lib/compose-author-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 buildAuthorPageNodes from './author-page-nodes'\nimport { resolveBuiltInPageLayoutId } from './built-in-page-layout'\nimport composeScreenNodes, {\n composeNodesWithChrome,\n} from './compose-screen-nodes'\nimport type { AuthorContent } from './get-author-content'\nimport getScreen from './get-screen'\nimport { collectSocialImageFacts } from './social-image-facts'\n\n/**\n * The host field naming the screen every author page renders through\n * (AGL-2518).\n *\n * ## Why the HOST and not the author\n *\n * A collection names its own list and entry templates because a site\n * legitimately designs its changelog differently from its blog. Author pages\n * are not like that: they are one page shape repeated per person, and letting\n * each author record name its own template would mean a masthead where the\n * design changes as the reader clicks between colleagues — plus a template\n * picker on a form whose whole subject is a byline.\n *\n * ## Why a SCREEN and not a layout\n *\n * The opposite call from `builtInPageLayoutId` (AGL-2513), and for the reason\n * that comment gives in reverse. Search results have no design worth\n * authoring — the platform composes the body and the site only chooses the\n * chrome around it. An author page has: a portrait, a bio, a role, links, and\n * a listing whose cards a designer wants to match the blog's. That is a page,\n * so the author designs it as one, with `{{author.*}}` tokens and the same\n * Collection entries block every list template uses.\n *\n * Unset is the ordinary case rather than a misconfiguration, and it renders\n * the built-in body — bylines link to this address, so it must always answer.\n */\nexport const AUTHOR_PAGE_SCREEN_FIELD = 'authorScreenId' as const\n\n/**\n * The pseudo-collection an author page's entries block resolves against.\n *\n * The compose pipeline keys entry sources by collection slug, and this page\n * has no single collection — it mixes them. Its entries are handed over\n * already in hand (`ComposeCollectionContext.entries`), which is a path that\n * never touches Firestore, so the key only has to be a name that no real\n * collection can take: a collection slug is a URL segment, and this is not a\n * legal one.\n *\n * It never reaches a URL. Every entry on this page carries its OWN\n * `collectionSlug` out of the read that found it, and `collectionEntryTokens`\n * prefers that — which is the whole reason a cross-collection listing can\n * build correct `entry.url`s at all.\n */\nexport const AUTHOR_ENTRIES_SOURCE_SLUG = '__author__'\n\n/** What the author route renders. */\nexport interface ComposedAuthorPage {\n /** The template screen doc, when one is designated; null for the built-in. */\n screen: Record<string, any> | null\n nodes: Record<string, any>\n /**\n * The current pair of each picture the head's card may name (AGL-2850): the\n * author's share card, then their portrait. Read in the composition's\n * batch, and absent when it answered for neither.\n */\n socialImageFacts?: Aglyn.SocialImageAssetFacts\n}\n\n/** The tokens and entry context both compose paths share. */\nfunction authorComposeContext(content: AuthorContent) {\n const address = content.author\n ? { author: content.author }\n : { authorName: content.slug }\n const pager = Aglyn.contentAuthorPaginationLinks({\n ...address,\n page: content.page,\n totalPages: content.totalPages,\n })\n return {\n /**\n * The pictures the head builds the card from, in its order: the share\n * card, then the portrait (AGL-2850). The author head reads the record\n * alone and never the site default, so the host's image is not one.\n */\n cardImages: [content.author?.seoImage, content.author?.image],\n tokens: {\n ...Aglyn.contentAuthorTokens(content.author, {\n entryCount: content.totalEntries,\n }),\n // The byline falls back to the raw segment for an unknown author, so a\n // heading bound to `{{author.name}}` prints something either way.\n 'author.name': content.name,\n // Named `pagination.*` rather than `author.page*`, so a designer who has\n // already built a pager on a collection list template rebuilds nothing\n // here — and so the two pagers cannot drift into two spellings of the\n // same four values. The edges are the empty string, which is what makes\n // an unconditional binding correct on page 1 of 1.\n 'pagination.page': String(pager.page),\n 'pagination.totalPages': String(pager.totalPages),\n 'pagination.prevUrl': pager.prevUrl,\n 'pagination.nextUrl': pager.nextUrl,\n } as Record<string, string>,\n collection: {\n slug: AUTHOR_ENTRIES_SOURCE_SLUG,\n // The slug above is a key, not an address (AGL-2524) — so a block that\n // would build a URL out of it resolves nothing instead.\n routeless: true,\n // Already narrowed to this author AND to this page's window by the\n // loader, for the reason the category route states: a page count\n // computed over the whole set advertises pages that render empty.\n entries: content.entries,\n categories: content.categories,\n page: content.page,\n },\n }\n}\n\n/**\n * An author page composed through the screen the host designated (AGL-2518).\n *\n * The `composeCollectionTemplatePage` shape: the screen goes through the\n * NORMAL published pipeline — theme, shared layout, reusable components,\n * Collection entries blocks — with `{{author.*}}` and `{{pagination.*}}`\n * substituted and Author Profile blocks filled from the record.\n *\n * Returns null when no screen is designated, or when the designated one has\n * been deleted, so the caller falls through to the built-in body rather than\n * serving a 404 for a page a byline links to.\n */\nexport async function composeAuthorTemplatePage(options: {\n hostId: string\n /**\n * The zone this site's dates read in (AGL-3237); UTC when a site has not\n * named one. Resolved once by the caller that holds the org and the host.\n */\n timeZone?: string\n host: any\n content: AuthorContent\n}): Promise<ComposedAuthorPage | null> {\n const { hostId, host, content } = options\n const screenId = String(host?.[AUTHOR_PAGE_SCREEN_FIELD] ?? '').trim()\n if (!screenId) return null\n try {\n // `allowTemplate`, exactly as a collection template is read: the screen is\n // deliberately not servable at an address of its own, and this is the\n // composition it exists for.\n const templateRes = await getScreen({\n hostId,\n screenId,\n allowTemplate: true,\n })\n if (!templateRes.screen) return null\n const { cardImages, tokens, collection } = authorComposeContext(content)\n const card = collectSocialImageFacts(cardImages)\n const nodes = await composeScreenNodes({\n // The site's zone (AGL-3237). This is the TEMPLATE path, which\n // composes through `composeScreenNodes` rather than\n // `composeNodesWithChrome` — and that difference is why the first two\n // passes at this fix reached every page except the one the bug was\n // reported on.\n ...(options.timeZone ? { timeZone: options.timeZone } : {}),\n hostId,\n screenId,\n screen: templateRes.screen,\n tokens,\n collection,\n socialImages: card.socialImages,\n // The designated screen's host variables, and its layout's, fill in\n // from this site, as the built-in author page's already do (AGL-2883).\n host: host as Aglyn.HostTokenSource,\n // Named as the author template it is if the page review holds it\n // (AGL-3374); without it the site's built-in author page serves.\n page: {\n template: {\n role: 'entry',\n route: '/author/:slug',\n collectionName: 'Authors',\n entryPath: `/author/${content.slug}`,\n fallback: 'built-in-design',\n },\n },\n })\n if (!nodes) return null\n /*\n The screen document travels UNCHANGED, and deliberately carries no\n composed SEO.\n\n A collection template composes the entry's title and cover into\n `screen.seo` because `page.tsx` reads that on its way to the head. An\n author page does not take that path: its head branch has the author\n RECORD in hand and builds the title, description and portrait from it\n directly, before the screen branch is ever reached. Writing a second,\n unread copy here would be a value that looks authoritative, is dead, and\n goes stale the first time the head's rule changes — which is how a\n template's stored title came to name every post in a collection\n (AGL-1345).\n */\n return {\n screen: templateRes.screen as Record<string, any>,\n nodes: Aglyn.expandContentAuthorProfile(nodes, content.author),\n ...card.collected(),\n }\n } catch (error) {\n console.error('author template composition failed', error)\n return null\n }\n}\n\n/**\n * The built-in author page (AGL-2518) — the body this file's own nodes build,\n * grafted into the site's shared layout so it carries the same header, nav and\n * footer as the posts it links to.\n *\n * Which layout is the host's to choose (`resolveBuiltInPageLayoutId`), shared\n * with site search so the two platform-built pages of a site cannot end up in\n * different chrome.\n *\n * Fail-open to `null`, and the route then renders the body with no chrome at\n * all. A person's page must not be the page that 500s because a layout was\n * deleted.\n */\nexport async function composeAuthorFallbackPage(options: {\n hostId: string\n /**\n * The zone this site's dates read in (AGL-3237); UTC when a site has not\n * named one. Resolved once by the caller that holds the org and the host.\n */\n timeZone?: string\n host: any\n content: AuthorContent\n}): Promise<ComposedAuthorPage | null> {\n const { hostId, host, content } = options\n try {\n const layoutId = await resolveBuiltInPageLayoutId({ hostId, host })\n const { cardImages, tokens, collection } = authorComposeContext(content)\n const card = collectSocialImageFacts(cardImages)\n const nodes = await composeNodesWithChrome({\n ...(options.timeZone ? { timeZone: options.timeZone } : {}),\n hostId,\n layoutId,\n socialImages: card.socialImages,\n screenNodes: buildAuthorPageNodes({\n slug: content.slug,\n name: content.name,\n author: content.author,\n hasEntries: content.entries.length > 0,\n page: content.page,\n perPage: content.perPage,\n totalPages: content.totalPages,\n }),\n tokens,\n collection,\n host: host as Aglyn.HostTokenSource,\n })\n return nodes ? { screen: null, nodes, ...card.collected() } : null\n } catch (error) {\n console.error('author page composition failed', error)\n return null\n }\n}\n\nexport default composeAuthorTemplatePage\n"],"names":["Aglyn","buildAuthorPageNodes","resolveBuiltInPageLayoutId","composeScreenNodes","composeNodesWithChrome","getScreen","collectSocialImageFacts","AUTHOR_PAGE_SCREEN_FIELD","AUTHOR_ENTRIES_SOURCE_SLUG","authorComposeContext","content","address","author","authorName","slug","pager","contentAuthorPaginationLinks","page","totalPages","cardImages","seoImage","image","tokens","contentAuthorTokens","entryCount","totalEntries","name","String","prevUrl","nextUrl","collection","routeless","entries","categories","composeAuthorTemplatePage","options","hostId","host","screenId","trim","templateRes","allowTemplate","screen","card","nodes","timeZone","socialImages","template","role","route","collectionName","entryPath","fallback","expandContentAuthorProfile","collected","error","console","composeAuthorFallbackPage","layoutId","screenNodes","hasEntries","length","perPage"],"mappings":"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"}
@@ -126,6 +126,18 @@ import { collectSocialImageFacts } from "./social-image-facts.js";
126
126
  // layout's — fill in as they do on every other page (AGL-2883).
127
127
  host: options.host,
128
128
  tokens,
129
+ // What this template is, for naming it if the page review holds it
130
+ // (AGL-3374): its route pattern, its collection and the entry shown.
131
+ // Without it the route falls back to the built-in design.
132
+ page: {
133
+ template: {
134
+ role: kind,
135
+ route: kind === 'entry' ? `/${collection.slug}/:slug` : `/${collection.slug}`,
136
+ collectionName: collection.displayName || collection.slug,
137
+ entryPath: entry ? `/${collection.slug}/${entry.slug}` : null,
138
+ fallback: 'built-in-design'
139
+ }
140
+ },
129
141
  // List pages hand their already-fetched entries to the Collection
130
142
  // entries block; entry pages carry the routed entry (AGL-582, Related
131
143
  // posts) and let blocks fetch entry lists on demand (e.g. a "More
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/tenant/runtime/src/lib/compose-collection-page.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport * as Aglyn from '@aglyn/aglyn/server'\nimport buildCollectionFallbackNodes from './collection-fallback-nodes'\nimport composeScreenNodes, {\n composeNodesWithChrome,\n} from './compose-screen-nodes'\nimport { resolveBuiltInPageLayoutId } from './built-in-page-layout'\nimport type { CollectionContent } from './get-collection-content'\nimport getScreen from './get-screen'\nimport { collectSocialImageFacts } from './social-image-facts'\n\ntype CollectionDoc = NonNullable<CollectionContent['collection']>\n\n/**\n * Which template screen a collection route renders through (AGL-551):\n * `/{collection}` uses `listScreenId`, `/{collection}/{entry}` uses\n * `entryScreenId` — falling back to the legacy AGL-105 `templateScreenId`\n * so existing blogs keep rendering. `undefined` means no template is set\n * and the designed built-in fallback applies.\n */\nexport function resolveCollectionTemplateScreenId(\n collection: Pick<\n CollectionDoc,\n 'listScreenId' | 'entryScreenId' | 'templateScreenId'\n >,\n kind: 'list' | 'entry',\n): string | undefined {\n if (kind === 'list') return collection.listScreenId || undefined\n return collection.entryScreenId || collection.templateScreenId || undefined\n}\n\n/**\n * Page-level `{{collection.*}}` tokens for template screens (AGL-551), plus\n * the routed category (AGL-1321) so a list template can name what it is\n * showing — \"Guides\" rather than a heading that says \"Blog\" on every filtered\n * URL. Both category tokens resolve to the empty string on the unfiltered\n * listing, which is what makes them safe to bind unconditionally.\n *\n * `{{pagination.*}}` follows the same design (AGL-1386). One static list\n * screen serves the bare listing, every `/page/{n}` and every\n * `/category/{slug}`, so a hand-built pager on it renders identically on all\n * of them: \"Older →\" on a category that has one page, pointing at a URL that\n * dropped the filter. The URLs come from `collectionPaginationLinks`, which\n * builds them through the shared listing-URL builder (so they carry the\n * category) and resolves the EDGES TO THE EMPTY STRING — no previous page,\n * no `prevUrl`. There is no runtime conditional to hide a link with (the\n * `condition` field on nodes is editor-side field visibility, not a render\n * gate), so the empty string is what makes an unconditional binding correct\n * on every route: a link whose href does not resolve renders as an inert\n * placeholder of the same element (AGL-1268/1357).\n *\n * This SURFACES what the platform already computes — the built-in fallback\n * pager reads the same function, so the two cannot drift.\n */\nexport function collectionTokens(\n collection: Pick<CollectionDoc, 'displayName' | 'slug'>,\n category?: CollectionContent['category'],\n pagination?: CollectionContent['pagination'],\n): Record<string, string> {\n // An unpaginated listing is page 1 of 1 — both URLs empty, which reads as\n // the honest \"nowhere to page to\" rather than a broken link.\n const pager = Aglyn.collectionPaginationLinks({\n collectionSlug: collection.slug,\n ...(category?.slug ? { categorySlug: category.slug } : {}),\n page: pagination?.page,\n ...(pagination?.totalPages === undefined\n ? {}\n : { totalPages: pagination.totalPages }),\n // Only a LISTING has cursors, so passing them is also what tells the\n // builder this is a listing and not an inert entry route (AGL-3219).\n ...(pagination\n ? {\n nextCursor: pagination.nextCursor ?? '',\n prevCursor: pagination.prevCursor ?? '',\n }\n : {}),\n })\n return {\n 'collection.name': collection.displayName,\n 'collection.slug': collection.slug,\n 'collection.category': category?.name ?? '',\n 'collection.categorySlug': category?.slug ?? '',\n 'pagination.page': String(pager.page),\n 'pagination.totalPages': String(pager.totalPages),\n 'pagination.prevUrl': pager.prevUrl,\n 'pagination.nextUrl': pager.nextUrl,\n }\n}\n\nexport interface ComposedCollectionPage {\n /** Template screen doc with the entry/collection SEO merged in. */\n screen: Record<string, any>\n nodes: Record<string, any>\n /**\n * The current pair of each asset the head's card may name (AGL-2850): the\n * entry's cover, the template's image, the site default. Read in the\n * composition's batch, and absent when it answered for none of them.\n */\n socialImageFacts?: Aglyn.SocialImageAssetFacts\n}\n\n/**\n * Renders a collection route through its designated template screen\n * (AGL-551), the same mechanism as commerce PDP/collection templates: the\n * screen composes through the NORMAL published pipeline — theme, shared\n * layout, reusable components — with `{{entry.*}}`/`{{collection.*}}`\n * tokens substituted and Collection entries blocks expanded. Returns null\n * when no template is designated (or it fails to compose) so the caller\n * falls through to the designed built-in fallback.\n */\nexport async function composeCollectionTemplatePage(options: {\n hostId: string\n /**\n * The zone this site's dates read in (AGL-3237); UTC when a site has not\n * named one. Resolved once by the caller that holds the org and the host.\n */\n timeZone?: string\n content: CollectionContent\n /**\n * The site, whose default card the head falls back to after the entry's\n * cover and the template's image (AGL-2850). Its document is read in this\n * page's batch with theirs.\n */\n host?: Aglyn.AglynHost | null\n}): Promise<ComposedCollectionPage | null> {\n const { hostId, content } = options\n const collection = content.collection\n if (!collection) return null\n const kind = content.entry ? 'entry' : 'list'\n const screenId = resolveCollectionTemplateScreenId(collection, kind)\n if (!screenId) return null\n\n // The one read that WANTS a template document (AGL-1400): this is the\n // composition it exists for, with `{{entry.*}}` substituted against the\n // routed entry. Every path-resolving caller leaves the flag off and gets the\n // 404 a template deserves at an address of its own.\n const templateRes = await getScreen({ hostId, screenId, allowTemplate: true })\n if (!templateRes.screen) return null\n\n const entry = content.entry\n const tokens = entry\n ? {\n ...collectionTokens(collection),\n // Category names resolve against the collection's taxonomy\n // (AGL-582): `categoryId` lookup first, legacy string fallback.\n ...Aglyn.collectionEntryTokens(\n entry,\n collection.slug,\n collection.categories,\n options.timeZone,\n ),\n }\n : collectionTokens(collection, content.category, content.pagination)\n // The head's card on this page, in its order: the entry's cover, the\n // template's own image, then the site default (AGL-2850).\n const card = collectSocialImageFacts([\n entry?.coverImage,\n (templateRes.screen as any).seo?.image,\n options.host?.seo?.image,\n ])\n const nodes = await composeScreenNodes({\n // The site's zone (AGL-3237). This is the TEMPLATE path, which\n // composes through `composeScreenNodes` rather than\n // `composeNodesWithChrome` — and that difference is why the first two\n // passes at this fix reached every page except the one the bug was\n // reported on.\n ...(options.timeZone ? { timeZone: options.timeZone } : {}),\n hostId,\n screenId,\n screen: templateRes.screen,\n socialImages: card.socialImages,\n // The site the template renders for, so its host variables — and its\n // layout's — fill in as they do on every other page (AGL-2883).\n host: options.host,\n tokens,\n // List pages hand their already-fetched entries to the Collection\n // entries block; entry pages carry the routed entry (AGL-582, Related\n // posts) and let blocks fetch entry lists on demand (e.g. a \"More\n // posts\" section on the article template). The category taxonomy\n // rides along so `{{entry.category}}` resolves inside the blocks.\n collection: entry\n ? { slug: collection.slug, entry, categories: collection.categories }\n : {\n slug: collection.slug,\n // Already filtered to the routed category (AGL-1321) — the block\n // repeats what the ROUTE resolved, so a designer-pinned\n // `filterCategory` on the block narrows it further rather than\n // fighting it.\n entries: content.entries,\n categories: collection.categories,\n ...(content.pagination?.page\n ? { page: content.pagination.page }\n : {}),\n ...(content.category ? { categorySlug: content.category.slug } : {}),\n // The read's own bound (AGL-1516), which has to travel WITH the\n // filtered entries above: once `content.entries` has been narrowed\n // to a category, nothing downstream can tell a complete read from a\n // truncated one by counting it.\n ...(content.entriesReachedBound\n ? { entriesReachedBound: true }\n : {}),\n // Where those entries start (AGL-3213): a listing page past the\n // cached read's bound holds its own window, not the collection\n // from the top, and the block has to window from the same origin.\n ...(content.pagination?.windowStart\n ? { windowStart: content.pagination.windowStart }\n : {}),\n },\n })\n if (!nodes) return null\n\n const screenSeo = (templateRes.screen as any).seo ?? {}\n const seo = entry\n ? // Entry metadata drives the head (AGL-117 merge; AGL-582 overrides).\n {\n ...screenSeo,\n title: entry.seoTitle || entry.title,\n description: entry.seoDescription || entry.excerpt || undefined,\n // The image and its companions move as ONE group (AGL-2417). The\n // spread above carries the SCREEN's `imageWidth`/`imageHeight`/\n // `imageAlt`, so an entry that supplies its own cover was describing\n // it with the screen default's size and — once alts existed — with\n // the screen default's DESCRIPTION: a sentence about a picture this\n // card does not show, delivered to the reader least able to check.\n // When the entry wins, its own companions win with it.\n ...(entry.coverImage\n ? {\n image: entry.coverImage,\n imageWidth: undefined,\n imageHeight: undefined,\n imageAlt: entry.coverImageAlt || undefined,\n }\n : { image: screenSeo.image || undefined }),\n }\n : // A LIST passes its screen's own SEO through UNTOUCHED (AGL-1345).\n //\n // This used to default `title` to `collection.displayName`, which reads\n // like a harmless fallback and is not: it made an authored title\n // indistinguishable from a generated one by the time the head was built.\n // The title rule (AGL-1341) turns on exactly that distinction — an\n // authored title renders VERBATIM, a name joins the site title — so a\n // consumer reading this could only choose between dropping the site\n // title off every untitled list (\"Changelog\") or ignoring the author's\n // title on every titled one (\"Changelog – Acme\" over the sentence they\n // wrote). The collection name is still the fallback; it just belongs to\n // the title resolver, as the page's `name`, alongside every other\n // surface's fallback rather than baked into stored SEO here.\n screenSeo\n return {\n screen: { ...(templateRes.screen as any), seo },\n nodes,\n ...card.collected(),\n }\n}\n\n/**\n * The designed built-in rendering (AGL-551): when a collection has no\n * template screen, its routes still compose through the site's theme and\n * the host's default shared layout (the home screen's layout) instead of\n * the old unthemed article. Fail-open — any error returns null and the\n * caller keeps the legacy plain rendering.\n */\nexport async function composeCollectionFallbackPage(options: {\n hostId: string\n /**\n * The zone this site's dates read in (AGL-3237); UTC when a site has not\n * named one. Resolved once by the caller that holds the org and the host.\n */\n timeZone?: string\n host: Aglyn.AglynHost\n content: CollectionContent\n}): Promise<Omit<ComposedCollectionPage, 'screen'> | null> {\n const { hostId, host, content } = options\n const collection = content.collection\n if (!collection) return null\n try {\n // The layout for a page the platform composed rather than the author\n // (AGL-2513). Still the home screen's layout by default — the rule this\n // branch has always followed — but the host can now name a different one,\n // and site search reads the same setting so the two built-in pages of a\n // site cannot end up in different chrome.\n const layoutId = await resolveBuiltInPageLayoutId({ hostId, host })\n const screenNodes = buildCollectionFallbackNodes({\n ...(options.timeZone ? { timeZone: options.timeZone } : {}),\n collection,\n entries: content.entries,\n entry: content.entry,\n pagination: content.pagination,\n category: content.category,\n // The cover resolves through `resolveMediaSrc` (AGL-1407), and an\n // org-scoped reference has to name the site asking or a site-restricted\n // asset will not serve.\n hostId,\n })\n // The head's card on a page with no template: the entry's cover, then the\n // site default (AGL-2850).\n const card = collectSocialImageFacts([\n content.entry?.coverImage,\n host?.seo?.image,\n ])\n const nodes = await composeNodesWithChrome({\n ...(options.timeZone ? { timeZone: options.timeZone } : {}),\n hostId,\n layoutId,\n screenNodes,\n socialImages: card.socialImages,\n // The layout's host variables fill in from this site (AGL-2883).\n host,\n // Entry routes resolve with an EMPTY entries list (the loader only\n // fetched the one entry), so hand the routed entry over and let the\n // Related posts block fetch the list on demand (AGL-582); list\n // routes keep their already-fetched entries. Categories ride along\n // for `categoryId` → name resolution.\n collection: content.entry\n ? {\n slug: collection.slug,\n entry: content.entry,\n categories: collection.categories,\n }\n : {\n slug: collection.slug,\n entries: content.entries,\n categories: collection.categories,\n ...(content.pagination?.page\n ? { page: content.pagination.page }\n : {}),\n ...(content.category\n ? { categorySlug: content.category.slug }\n : {}),\n // Same fact, same reason as the template path above (AGL-1516).\n ...(content.entriesReachedBound\n ? { entriesReachedBound: true }\n : {}),\n // Same window, same reason as the template path above (AGL-3213).\n ...(content.pagination?.windowStart\n ? { windowStart: content.pagination.windowStart }\n : {}),\n },\n })\n return nodes ? { nodes, ...card.collected() } : null\n } catch (error) {\n console.error(error)\n return null\n }\n}\n\nexport default composeCollectionTemplatePage\n"],"names":["Aglyn","buildCollectionFallbackNodes","composeScreenNodes","composeNodesWithChrome","resolveBuiltInPageLayoutId","getScreen","collectSocialImageFacts","resolveCollectionTemplateScreenId","collection","kind","listScreenId","undefined","entryScreenId","templateScreenId","collectionTokens","category","pagination","pager","collectionPaginationLinks","collectionSlug","slug","categorySlug","page","totalPages","nextCursor","prevCursor","displayName","name","String","prevUrl","nextUrl","composeCollectionTemplatePage","options","content","hostId","entry","screenId","templateRes","allowTemplate","screen","tokens","collectionEntryTokens","categories","timeZone","card","coverImage","seo","image","host","nodes","socialImages","entries","entriesReachedBound","windowStart","screenSeo","title","seoTitle","description","seoDescription","excerpt","imageWidth","imageHeight","imageAlt","coverImageAlt","collected","composeCollectionFallbackPage","layoutId","screenNodes","error","console"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,YAAYA,WAAW,sBAAqB;AAC5C,OAAOC,kCAAkC,iCAA6B;AACtE,OAAOC,sBACLC,sBAAsB,QACjB,4BAAwB;AAC/B,SAASC,0BAA0B,QAAQ,4BAAwB;AAEnE,OAAOC,eAAe,kBAAc;AACpC,SAASC,uBAAuB,QAAQ,0BAAsB;AAI9D;;;;;;CAMC,GACD,OAAO,SAASC,kCACdC,UAGC,EACDC,IAAsB;IAEtB,IAAIA,SAAS,QAAQ,OAAOD,WAAWE,YAAY,IAAIC;IACvD,OAAOH,WAAWI,aAAa,IAAIJ,WAAWK,gBAAgB,IAAIF;AACpE;AAEA;;;;;;;;;;;;;;;;;;;;;;CAsBC,GACD,OAAO,SAASG,iBACdN,UAAuD,EACvDO,QAAwC,EACxCC,UAA4C;QAexBA,wBACAA;IAdpB,0EAA0E;IAC1E,6DAA6D;IAC7D,MAAMC,QAAQjB,MAAMkB,yBAAyB,CAAC;QAC5CC,gBAAgBX,WAAWY,IAAI;OAC3BL,CAAAA,4BAAAA,SAAUK,IAAI,IAAG;QAAEC,cAAcN,SAASK,IAAI;IAAC,IAAI,CAAC;QACxDE,IAAI,EAAEN,8BAAAA,WAAYM,IAAI;OAClBN,CAAAA,8BAAAA,WAAYO,UAAU,MAAKZ,YAC3B,CAAC,IACD;QAAEY,YAAYP,WAAWO,UAAU;IAAC,GAGpCP,aACA;QACEQ,UAAU,GAAER,yBAAAA,WAAWQ,UAAU,YAArBR,yBAAyB;QACrCS,UAAU,GAAET,yBAAAA,WAAWS,UAAU,YAArBT,yBAAyB;IACvC,IACA,CAAC;IAEP,OAAO;QACL,mBAAmBR,WAAWkB,WAAW;QACzC,mBAAmBlB,WAAWY,IAAI;QAClC,qBAAqB,UAAEL,4BAAAA,SAAUY,IAAI,mBAAI;QACzC,yBAAyB,WAAEZ,4BAAAA,SAAUK,IAAI,oBAAI;QAC7C,mBAAmBQ,OAAOX,MAAMK,IAAI;QACpC,yBAAyBM,OAAOX,MAAMM,UAAU;QAChD,sBAAsBN,MAAMY,OAAO;QACnC,sBAAsBZ,MAAMa,OAAO;IACrC;AACF;AAcA;;;;;;;;CAQC,GACD,OAAO,eAAeC,8BAA8BC,OAcnD;QAuFmB;QAtDhB,0BACAA,mBAAAA,eAgCUC,qBAcAA;IA/EZ,MAAM,EAAEC,MAAM,EAAED,OAAO,EAAE,GAAGD;IAC5B,MAAMxB,aAAayB,QAAQzB,UAAU;IACrC,IAAI,CAACA,YAAY,OAAO;IACxB,MAAMC,OAAOwB,QAAQE,KAAK,GAAG,UAAU;IACvC,MAAMC,WAAW7B,kCAAkCC,YAAYC;IAC/D,IAAI,CAAC2B,UAAU,OAAO;IAEtB,sEAAsE;IACtE,wEAAwE;IACxE,6EAA6E;IAC7E,oDAAoD;IACpD,MAAMC,cAAc,MAAMhC,UAAU;QAAE6B;QAAQE;QAAUE,eAAe;IAAK;IAC5E,IAAI,CAACD,YAAYE,MAAM,EAAE,OAAO;IAEhC,MAAMJ,QAAQF,QAAQE,KAAK;IAC3B,MAAMK,SAASL,QACX,aACKrB,iBAAiBN,aAGjBR,MAAMyC,qBAAqB,CAC5BN,OACA3B,WAAWY,IAAI,EACfZ,WAAWkC,UAAU,EACrBV,QAAQW,QAAQ,KAGpB7B,iBAAiBN,YAAYyB,QAAQlB,QAAQ,EAAEkB,QAAQjB,UAAU;IACrE,qEAAqE;IACrE,0DAA0D;IAC1D,MAAM4B,OAAOtC,wBAAwB;QACnC6B,yBAAAA,MAAOU,UAAU;SACjB,2BAAA,AAACR,YAAYE,MAAM,CAASO,GAAG,qBAA/B,yBAAiCC,KAAK;SACtCf,gBAAAA,QAAQgB,IAAI,sBAAZhB,oBAAAA,cAAcc,GAAG,qBAAjBd,kBAAmBe,KAAK;KACzB;IACD,MAAME,QAAQ,MAAM/C,mBAAmB,aAMjC8B,QAAQW,QAAQ,GAAG;QAAEA,UAAUX,QAAQW,QAAQ;IAAC,IAAI,CAAC;QACzDT;QACAE;QACAG,QAAQF,YAAYE,MAAM;QAC1BW,cAAcN,KAAKM,YAAY;QAC/B,qEAAqE;QACrE,gEAAgE;QAChEF,MAAMhB,QAAQgB,IAAI;QAClBR;QACA,kEAAkE;QAClE,sEAAsE;QACtE,kEAAkE;QAClE,iEAAiE;QACjE,kEAAkE;QAClEhC,YAAY2B,QACR;YAAEf,MAAMZ,WAAWY,IAAI;YAAEe;YAAOO,YAAYlC,WAAWkC,UAAU;QAAC,IAClE;YACEtB,MAAMZ,WAAWY,IAAI;YACrB,iEAAiE;YACjE,wDAAwD;YACxD,+DAA+D;YAC/D,eAAe;YACf+B,SAASlB,QAAQkB,OAAO;YACxBT,YAAYlC,WAAWkC,UAAU;WAC7BT,EAAAA,sBAAAA,QAAQjB,UAAU,qBAAlBiB,oBAAoBX,IAAI,IACxB;YAAEA,MAAMW,QAAQjB,UAAU,CAACM,IAAI;QAAC,IAChC,CAAC,GACDW,QAAQlB,QAAQ,GAAG;YAAEM,cAAcY,QAAQlB,QAAQ,CAACK,IAAI;QAAC,IAAI,CAAC,GAK9Da,QAAQmB,mBAAmB,GAC3B;YAAEA,qBAAqB;QAAK,IAC5B,CAAC,GAIDnB,EAAAA,uBAAAA,QAAQjB,UAAU,qBAAlBiB,qBAAoBoB,WAAW,IAC/B;YAAEA,aAAapB,QAAQjB,UAAU,CAACqC,WAAW;QAAC,IAC9C,CAAC;;IAGb,IAAI,CAACJ,OAAO,OAAO;IAEnB,MAAMK,aAAY,0BAAA,AAACjB,YAAYE,MAAM,CAASO,GAAG,YAA/B,0BAAmC,CAAC;IACtD,MAAMA,MAAMX,QAER,aACKmB;QACHC,OAAOpB,MAAMqB,QAAQ,IAAIrB,MAAMoB,KAAK;QACpCE,aAAatB,MAAMuB,cAAc,IAAIvB,MAAMwB,OAAO,IAAIhD;OAQlDwB,MAAMU,UAAU,GAChB;QACEE,OAAOZ,MAAMU,UAAU;QACvBe,YAAYjD;QACZkD,aAAalD;QACbmD,UAAU3B,MAAM4B,aAAa,IAAIpD;IACnC,IACA;QAAEoC,OAAOO,UAAUP,KAAK,IAAIpC;IAAU,KAG5C,EAAE;IACF,wEAAwE;IACxE,iEAAiE;IACjE,yEAAyE;IACzE,mEAAmE;IACnE,sEAAsE;IACtE,oEAAoE;IACpE,uEAAuE;IACvE,uEAAuE;IACvE,wEAAwE;IACxE,kEAAkE;IAClE,6DAA6D;IAC7D2C;IACJ,OAAO;QACLf,QAAQ,aAAMF,YAAYE,MAAM;YAAUO;;QAC1CG;OACGL,KAAKoB,SAAS;AAErB;AAEA;;;;;;CAMC,GACD,OAAO,eAAeC,8BAA8BjC,OASnD;IACC,MAAM,EAAEE,MAAM,EAAEc,IAAI,EAAEf,OAAO,EAAE,GAAGD;IAClC,MAAMxB,aAAayB,QAAQzB,UAAU;IACrC,IAAI,CAACA,YAAY,OAAO;IACxB,IAAI;YAsBAyB,gBACAe,WAyBUf,qBAWAA;QA1DZ,qEAAqE;QACrE,wEAAwE;QACxE,0EAA0E;QAC1E,wEAAwE;QACxE,0CAA0C;QAC1C,MAAMiC,WAAW,MAAM9D,2BAA2B;YAAE8B;YAAQc;QAAK;QACjE,MAAMmB,cAAclE,6BAA6B,aAC3C+B,QAAQW,QAAQ,GAAG;YAAEA,UAAUX,QAAQW,QAAQ;QAAC,IAAI,CAAC;YACzDnC;YACA2C,SAASlB,QAAQkB,OAAO;YACxBhB,OAAOF,QAAQE,KAAK;YACpBnB,YAAYiB,QAAQjB,UAAU;YAC9BD,UAAUkB,QAAQlB,QAAQ;YAC1B,kEAAkE;YAClE,wEAAwE;YACxE,wBAAwB;YACxBmB;;QAEF,0EAA0E;QAC1E,2BAA2B;QAC3B,MAAMU,OAAOtC,wBAAwB;aACnC2B,iBAAAA,QAAQE,KAAK,qBAAbF,eAAeY,UAAU;YACzBG,yBAAAA,YAAAA,KAAMF,GAAG,qBAATE,UAAWD,KAAK;SACjB;QACD,MAAME,QAAQ,MAAM9C,uBAAuB,aACrC6B,QAAQW,QAAQ,GAAG;YAAEA,UAAUX,QAAQW,QAAQ;QAAC,IAAI,CAAC;YACzDT;YACAgC;YACAC;YACAjB,cAAcN,KAAKM,YAAY;YAC/B,iEAAiE;YACjEF;YACA,mEAAmE;YACnE,oEAAoE;YACpE,+DAA+D;YAC/D,mEAAmE;YACnE,sCAAsC;YACtCxC,YAAYyB,QAAQE,KAAK,GACrB;gBACEf,MAAMZ,WAAWY,IAAI;gBACrBe,OAAOF,QAAQE,KAAK;gBACpBO,YAAYlC,WAAWkC,UAAU;YACnC,IACA;gBACEtB,MAAMZ,WAAWY,IAAI;gBACrB+B,SAASlB,QAAQkB,OAAO;gBACxBT,YAAYlC,WAAWkC,UAAU;eAC7BT,EAAAA,sBAAAA,QAAQjB,UAAU,qBAAlBiB,oBAAoBX,IAAI,IACxB;gBAAEA,MAAMW,QAAQjB,UAAU,CAACM,IAAI;YAAC,IAChC,CAAC,GACDW,QAAQlB,QAAQ,GAChB;gBAAEM,cAAcY,QAAQlB,QAAQ,CAACK,IAAI;YAAC,IACtC,CAAC,GAEDa,QAAQmB,mBAAmB,GAC3B;gBAAEA,qBAAqB;YAAK,IAC5B,CAAC,GAEDnB,EAAAA,uBAAAA,QAAQjB,UAAU,qBAAlBiB,qBAAoBoB,WAAW,IAC/B;gBAAEA,aAAapB,QAAQjB,UAAU,CAACqC,WAAW;YAAC,IAC9C,CAAC;;QAGb,OAAOJ,QAAQ;YAAEA;WAAUL,KAAKoB,SAAS,MAAO;IAClD,EAAE,OAAOI,OAAO;QACdC,QAAQD,KAAK,CAACA;QACd,OAAO;IACT;AACF;AAEA,eAAerC,8BAA6B"}
1
+ {"version":3,"sources":["../../../../../../libs/tenant/runtime/src/lib/compose-collection-page.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport * as Aglyn from '@aglyn/aglyn/server'\nimport buildCollectionFallbackNodes from './collection-fallback-nodes'\nimport composeScreenNodes, {\n composeNodesWithChrome,\n} from './compose-screen-nodes'\nimport { resolveBuiltInPageLayoutId } from './built-in-page-layout'\nimport type { CollectionContent } from './get-collection-content'\nimport getScreen from './get-screen'\nimport { collectSocialImageFacts } from './social-image-facts'\n\ntype CollectionDoc = NonNullable<CollectionContent['collection']>\n\n/**\n * Which template screen a collection route renders through (AGL-551):\n * `/{collection}` uses `listScreenId`, `/{collection}/{entry}` uses\n * `entryScreenId` — falling back to the legacy AGL-105 `templateScreenId`\n * so existing blogs keep rendering. `undefined` means no template is set\n * and the designed built-in fallback applies.\n */\nexport function resolveCollectionTemplateScreenId(\n collection: Pick<\n CollectionDoc,\n 'listScreenId' | 'entryScreenId' | 'templateScreenId'\n >,\n kind: 'list' | 'entry',\n): string | undefined {\n if (kind === 'list') return collection.listScreenId || undefined\n return collection.entryScreenId || collection.templateScreenId || undefined\n}\n\n/**\n * Page-level `{{collection.*}}` tokens for template screens (AGL-551), plus\n * the routed category (AGL-1321) so a list template can name what it is\n * showing — \"Guides\" rather than a heading that says \"Blog\" on every filtered\n * URL. Both category tokens resolve to the empty string on the unfiltered\n * listing, which is what makes them safe to bind unconditionally.\n *\n * `{{pagination.*}}` follows the same design (AGL-1386). One static list\n * screen serves the bare listing, every `/page/{n}` and every\n * `/category/{slug}`, so a hand-built pager on it renders identically on all\n * of them: \"Older →\" on a category that has one page, pointing at a URL that\n * dropped the filter. The URLs come from `collectionPaginationLinks`, which\n * builds them through the shared listing-URL builder (so they carry the\n * category) and resolves the EDGES TO THE EMPTY STRING — no previous page,\n * no `prevUrl`. There is no runtime conditional to hide a link with (the\n * `condition` field on nodes is editor-side field visibility, not a render\n * gate), so the empty string is what makes an unconditional binding correct\n * on every route: a link whose href does not resolve renders as an inert\n * placeholder of the same element (AGL-1268/1357).\n *\n * This SURFACES what the platform already computes — the built-in fallback\n * pager reads the same function, so the two cannot drift.\n */\nexport function collectionTokens(\n collection: Pick<CollectionDoc, 'displayName' | 'slug'>,\n category?: CollectionContent['category'],\n pagination?: CollectionContent['pagination'],\n): Record<string, string> {\n // An unpaginated listing is page 1 of 1 — both URLs empty, which reads as\n // the honest \"nowhere to page to\" rather than a broken link.\n const pager = Aglyn.collectionPaginationLinks({\n collectionSlug: collection.slug,\n ...(category?.slug ? { categorySlug: category.slug } : {}),\n page: pagination?.page,\n ...(pagination?.totalPages === undefined\n ? {}\n : { totalPages: pagination.totalPages }),\n // Only a LISTING has cursors, so passing them is also what tells the\n // builder this is a listing and not an inert entry route (AGL-3219).\n ...(pagination\n ? {\n nextCursor: pagination.nextCursor ?? '',\n prevCursor: pagination.prevCursor ?? '',\n }\n : {}),\n })\n return {\n 'collection.name': collection.displayName,\n 'collection.slug': collection.slug,\n 'collection.category': category?.name ?? '',\n 'collection.categorySlug': category?.slug ?? '',\n 'pagination.page': String(pager.page),\n 'pagination.totalPages': String(pager.totalPages),\n 'pagination.prevUrl': pager.prevUrl,\n 'pagination.nextUrl': pager.nextUrl,\n }\n}\n\nexport interface ComposedCollectionPage {\n /** Template screen doc with the entry/collection SEO merged in. */\n screen: Record<string, any>\n nodes: Record<string, any>\n /**\n * The current pair of each asset the head's card may name (AGL-2850): the\n * entry's cover, the template's image, the site default. Read in the\n * composition's batch, and absent when it answered for none of them.\n */\n socialImageFacts?: Aglyn.SocialImageAssetFacts\n}\n\n/**\n * Renders a collection route through its designated template screen\n * (AGL-551), the same mechanism as commerce PDP/collection templates: the\n * screen composes through the NORMAL published pipeline — theme, shared\n * layout, reusable components — with `{{entry.*}}`/`{{collection.*}}`\n * tokens substituted and Collection entries blocks expanded. Returns null\n * when no template is designated (or it fails to compose) so the caller\n * falls through to the designed built-in fallback.\n */\nexport async function composeCollectionTemplatePage(options: {\n hostId: string\n /**\n * The zone this site's dates read in (AGL-3237); UTC when a site has not\n * named one. Resolved once by the caller that holds the org and the host.\n */\n timeZone?: string\n content: CollectionContent\n /**\n * The site, whose default card the head falls back to after the entry's\n * cover and the template's image (AGL-2850). Its document is read in this\n * page's batch with theirs.\n */\n host?: Aglyn.AglynHost | null\n}): Promise<ComposedCollectionPage | null> {\n const { hostId, content } = options\n const collection = content.collection\n if (!collection) return null\n const kind = content.entry ? 'entry' : 'list'\n const screenId = resolveCollectionTemplateScreenId(collection, kind)\n if (!screenId) return null\n\n // The one read that WANTS a template document (AGL-1400): this is the\n // composition it exists for, with `{{entry.*}}` substituted against the\n // routed entry. Every path-resolving caller leaves the flag off and gets the\n // 404 a template deserves at an address of its own.\n const templateRes = await getScreen({ hostId, screenId, allowTemplate: true })\n if (!templateRes.screen) return null\n\n const entry = content.entry\n const tokens = entry\n ? {\n ...collectionTokens(collection),\n // Category names resolve against the collection's taxonomy\n // (AGL-582): `categoryId` lookup first, legacy string fallback.\n ...Aglyn.collectionEntryTokens(\n entry,\n collection.slug,\n collection.categories,\n options.timeZone,\n ),\n }\n : collectionTokens(collection, content.category, content.pagination)\n // The head's card on this page, in its order: the entry's cover, the\n // template's own image, then the site default (AGL-2850).\n const card = collectSocialImageFacts([\n entry?.coverImage,\n (templateRes.screen as any).seo?.image,\n options.host?.seo?.image,\n ])\n const nodes = await composeScreenNodes({\n // The site's zone (AGL-3237). This is the TEMPLATE path, which\n // composes through `composeScreenNodes` rather than\n // `composeNodesWithChrome` — and that difference is why the first two\n // passes at this fix reached every page except the one the bug was\n // reported on.\n ...(options.timeZone ? { timeZone: options.timeZone } : {}),\n hostId,\n screenId,\n screen: templateRes.screen,\n socialImages: card.socialImages,\n // The site the template renders for, so its host variables — and its\n // layout's — fill in as they do on every other page (AGL-2883).\n host: options.host,\n tokens,\n // What this template is, for naming it if the page review holds it\n // (AGL-3374): its route pattern, its collection and the entry shown.\n // Without it the route falls back to the built-in design.\n page: {\n template: {\n role: kind,\n route: kind === 'entry' ? `/${collection.slug}/:slug` : `/${collection.slug}`,\n collectionName: collection.displayName || collection.slug,\n entryPath: entry ? `/${collection.slug}/${entry.slug}` : null,\n fallback: 'built-in-design',\n },\n },\n // List pages hand their already-fetched entries to the Collection\n // entries block; entry pages carry the routed entry (AGL-582, Related\n // posts) and let blocks fetch entry lists on demand (e.g. a \"More\n // posts\" section on the article template). The category taxonomy\n // rides along so `{{entry.category}}` resolves inside the blocks.\n collection: entry\n ? { slug: collection.slug, entry, categories: collection.categories }\n : {\n slug: collection.slug,\n // Already filtered to the routed category (AGL-1321) — the block\n // repeats what the ROUTE resolved, so a designer-pinned\n // `filterCategory` on the block narrows it further rather than\n // fighting it.\n entries: content.entries,\n categories: collection.categories,\n ...(content.pagination?.page\n ? { page: content.pagination.page }\n : {}),\n ...(content.category ? { categorySlug: content.category.slug } : {}),\n // The read's own bound (AGL-1516), which has to travel WITH the\n // filtered entries above: once `content.entries` has been narrowed\n // to a category, nothing downstream can tell a complete read from a\n // truncated one by counting it.\n ...(content.entriesReachedBound\n ? { entriesReachedBound: true }\n : {}),\n // Where those entries start (AGL-3213): a listing page past the\n // cached read's bound holds its own window, not the collection\n // from the top, and the block has to window from the same origin.\n ...(content.pagination?.windowStart\n ? { windowStart: content.pagination.windowStart }\n : {}),\n },\n })\n if (!nodes) return null\n\n const screenSeo = (templateRes.screen as any).seo ?? {}\n const seo = entry\n ? // Entry metadata drives the head (AGL-117 merge; AGL-582 overrides).\n {\n ...screenSeo,\n title: entry.seoTitle || entry.title,\n description: entry.seoDescription || entry.excerpt || undefined,\n // The image and its companions move as ONE group (AGL-2417). The\n // spread above carries the SCREEN's `imageWidth`/`imageHeight`/\n // `imageAlt`, so an entry that supplies its own cover was describing\n // it with the screen default's size and — once alts existed — with\n // the screen default's DESCRIPTION: a sentence about a picture this\n // card does not show, delivered to the reader least able to check.\n // When the entry wins, its own companions win with it.\n ...(entry.coverImage\n ? {\n image: entry.coverImage,\n imageWidth: undefined,\n imageHeight: undefined,\n imageAlt: entry.coverImageAlt || undefined,\n }\n : { image: screenSeo.image || undefined }),\n }\n : // A LIST passes its screen's own SEO through UNTOUCHED (AGL-1345).\n //\n // This used to default `title` to `collection.displayName`, which reads\n // like a harmless fallback and is not: it made an authored title\n // indistinguishable from a generated one by the time the head was built.\n // The title rule (AGL-1341) turns on exactly that distinction — an\n // authored title renders VERBATIM, a name joins the site title — so a\n // consumer reading this could only choose between dropping the site\n // title off every untitled list (\"Changelog\") or ignoring the author's\n // title on every titled one (\"Changelog – Acme\" over the sentence they\n // wrote). The collection name is still the fallback; it just belongs to\n // the title resolver, as the page's `name`, alongside every other\n // surface's fallback rather than baked into stored SEO here.\n screenSeo\n return {\n screen: { ...(templateRes.screen as any), seo },\n nodes,\n ...card.collected(),\n }\n}\n\n/**\n * The designed built-in rendering (AGL-551): when a collection has no\n * template screen, its routes still compose through the site's theme and\n * the host's default shared layout (the home screen's layout) instead of\n * the old unthemed article. Fail-open — any error returns null and the\n * caller keeps the legacy plain rendering.\n */\nexport async function composeCollectionFallbackPage(options: {\n hostId: string\n /**\n * The zone this site's dates read in (AGL-3237); UTC when a site has not\n * named one. Resolved once by the caller that holds the org and the host.\n */\n timeZone?: string\n host: Aglyn.AglynHost\n content: CollectionContent\n}): Promise<Omit<ComposedCollectionPage, 'screen'> | null> {\n const { hostId, host, content } = options\n const collection = content.collection\n if (!collection) return null\n try {\n // The layout for a page the platform composed rather than the author\n // (AGL-2513). Still the home screen's layout by default — the rule this\n // branch has always followed — but the host can now name a different one,\n // and site search reads the same setting so the two built-in pages of a\n // site cannot end up in different chrome.\n const layoutId = await resolveBuiltInPageLayoutId({ hostId, host })\n const screenNodes = buildCollectionFallbackNodes({\n ...(options.timeZone ? { timeZone: options.timeZone } : {}),\n collection,\n entries: content.entries,\n entry: content.entry,\n pagination: content.pagination,\n category: content.category,\n // The cover resolves through `resolveMediaSrc` (AGL-1407), and an\n // org-scoped reference has to name the site asking or a site-restricted\n // asset will not serve.\n hostId,\n })\n // The head's card on a page with no template: the entry's cover, then the\n // site default (AGL-2850).\n const card = collectSocialImageFacts([\n content.entry?.coverImage,\n host?.seo?.image,\n ])\n const nodes = await composeNodesWithChrome({\n ...(options.timeZone ? { timeZone: options.timeZone } : {}),\n hostId,\n layoutId,\n screenNodes,\n socialImages: card.socialImages,\n // The layout's host variables fill in from this site (AGL-2883).\n host,\n // Entry routes resolve with an EMPTY entries list (the loader only\n // fetched the one entry), so hand the routed entry over and let the\n // Related posts block fetch the list on demand (AGL-582); list\n // routes keep their already-fetched entries. Categories ride along\n // for `categoryId` → name resolution.\n collection: content.entry\n ? {\n slug: collection.slug,\n entry: content.entry,\n categories: collection.categories,\n }\n : {\n slug: collection.slug,\n entries: content.entries,\n categories: collection.categories,\n ...(content.pagination?.page\n ? { page: content.pagination.page }\n : {}),\n ...(content.category\n ? { categorySlug: content.category.slug }\n : {}),\n // Same fact, same reason as the template path above (AGL-1516).\n ...(content.entriesReachedBound\n ? { entriesReachedBound: true }\n : {}),\n // Same window, same reason as the template path above (AGL-3213).\n ...(content.pagination?.windowStart\n ? { windowStart: content.pagination.windowStart }\n : {}),\n },\n })\n return nodes ? { nodes, ...card.collected() } : null\n } catch (error) {\n console.error(error)\n return null\n }\n}\n\nexport default composeCollectionTemplatePage\n"],"names":["Aglyn","buildCollectionFallbackNodes","composeScreenNodes","composeNodesWithChrome","resolveBuiltInPageLayoutId","getScreen","collectSocialImageFacts","resolveCollectionTemplateScreenId","collection","kind","listScreenId","undefined","entryScreenId","templateScreenId","collectionTokens","category","pagination","pager","collectionPaginationLinks","collectionSlug","slug","categorySlug","page","totalPages","nextCursor","prevCursor","displayName","name","String","prevUrl","nextUrl","composeCollectionTemplatePage","options","content","hostId","entry","screenId","templateRes","allowTemplate","screen","tokens","collectionEntryTokens","categories","timeZone","card","coverImage","seo","image","host","nodes","socialImages","template","role","route","collectionName","entryPath","fallback","entries","entriesReachedBound","windowStart","screenSeo","title","seoTitle","description","seoDescription","excerpt","imageWidth","imageHeight","imageAlt","coverImageAlt","collected","composeCollectionFallbackPage","layoutId","screenNodes","error","console"],"mappings":"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"}
@@ -155,6 +155,33 @@ export declare function composeNodesWithChrome(options: {
155
155
  */
156
156
  socialImages?: ComposeSocialImages;
157
157
  }): Promise<Record<string, any>>;
158
+ /**
159
+ * What the caller knows about the page it is composing, for the page review
160
+ * to NAME it when it holds or flags it (AGL-3374). Never read to decide
161
+ * whether the page serves.
162
+ */
163
+ export interface ComposePageContext {
164
+ /**
165
+ * A template composed against a routed listing or entry: the route pattern
166
+ * it serves (`/videos`, `/videos/:slug`), the collection it renders, the
167
+ * address being shown, and what a visitor gets when it has no clean
168
+ * version (`built-in-design` for a content collection, whose routes fall
169
+ * back to it; `not-found` for a store template, which has none).
170
+ */
171
+ template?: {
172
+ role: 'list' | 'entry';
173
+ route: string;
174
+ collectionName?: string | null;
175
+ entryPath?: string | null;
176
+ fallback?: 'built-in-design' | 'not-found';
177
+ } | null;
178
+ /** An experiment variant's version of the page. */
179
+ variant?: {
180
+ experimentId: string;
181
+ variantId: string;
182
+ name?: string | null;
183
+ } | null;
184
+ }
158
185
  /**
159
186
  * Full published-render composition for one screen (extracted for AGL-87 so
160
187
  * the SSG path and the password-unlock API build identical trees): applies
@@ -194,5 +221,7 @@ export declare function composeScreenNodes(options: {
194
221
  * than falling back again.
195
222
  */
196
223
  pageReviewFallback?: boolean;
224
+ /** What this page is, for naming it if the page review holds it (AGL-3374). */
225
+ page?: ComposePageContext;
197
226
  }): Promise<Record<string, any> | null>;
198
227
  export default composeScreenNodes;
@@ -469,13 +469,72 @@ import { stampFormDatasetBindings } from "./stamp-form-dataset-bindings.js";
469
469
  }
470
470
  return applyMediaAssetFacts(denormalized, facts);
471
471
  }
472
+ /**
473
+ * The page's own nodes and those of every layout and reusable component
474
+ * composed into it, for the page review to say WHICH one carries flagged
475
+ * content (AGL-3374). Read only when a review row is filed: the layout
476
+ * versions and the component definitions are the cached reads the compose
477
+ * already made.
478
+ */ async function composedPageParts(options) {
479
+ const parts = [
480
+ {
481
+ type: 'screen',
482
+ id: options.screenId,
483
+ nodes: options.screenNodes
484
+ }
485
+ ];
486
+ const seen = new Set();
487
+ let layoutId = options.layoutId ? String(options.layoutId) : undefined;
488
+ while(layoutId && !seen.has(layoutId) && seen.size < Aglyn.MAX_LAYOUT_CHAIN_DEPTH){
489
+ var _ref;
490
+ var _layoutRes_version;
491
+ seen.add(layoutId);
492
+ const layoutRes = await getPublishedLayoutVersion({
493
+ hostId: options.hostId,
494
+ layoutId
495
+ });
496
+ const layout = layoutRes == null ? void 0 : layoutRes.layout;
497
+ parts.push({
498
+ type: 'layout',
499
+ id: layoutId,
500
+ name: typeof (layout == null ? void 0 : layout.displayName) === 'string' ? layout.displayName : null,
501
+ nodes: (_ref = layoutRes == null ? void 0 : (_layoutRes_version = layoutRes.version) == null ? void 0 : _layoutRes_version.nodes) != null ? _ref : {}
502
+ });
503
+ layoutId = (layout == null ? void 0 : layout.layoutId) ? String(layout.layoutId) : undefined;
504
+ }
505
+ // Only the components this page actually renders, followed through
506
+ // components that nest others, so an unused one is never blamed.
507
+ const { definitions } = await getComponents({
508
+ hostId: options.hostId
509
+ });
510
+ const used = new Set();
511
+ let frontier = parts.map((part)=>part.nodes);
512
+ while(frontier.length){
513
+ const next = [];
514
+ for (const [id, definition] of Object.entries(definitions)){
515
+ if (used.has(id)) continue;
516
+ if (frontier.some((nodes)=>Aglyn.nodesReferenceComponent(nodes, id))) {
517
+ used.add(id);
518
+ next.push(definition.nodes);
519
+ parts.push({
520
+ type: 'component',
521
+ id,
522
+ nodes: definition.nodes
523
+ });
524
+ }
525
+ }
526
+ frontier = next;
527
+ }
528
+ return parts;
529
+ }
472
530
  /**
473
531
  * Full published-render composition for one screen (extracted for AGL-87 so
474
532
  * the SSG path and the password-unlock API build identical trees): applies
475
533
  * a due publish schedule, loads the version, composes the shared layout
476
534
  * chrome, grafts reusable components, and denormalizes.
477
535
  */ export async function composeScreenNodes(options) {
478
- var _ref, _options_versionId;
536
+ var _ref, _options_versionId, _ref1, _ref2;
537
+ var _options_page, _options_page1;
479
538
  const { hostId, screenId, screen } = options;
480
539
  const effectiveVersionId = options.versionId ? null : await applyDuePublishSchedule({
481
540
  hostId,
@@ -566,11 +625,31 @@ import { stampFormDatasetBindings } from "./stamp-form-dataset-bindings.js";
566
625
  * A held version serves the last version this page served clean, screened
567
626
  * again, or nothing.
568
627
  */ const nodes = await composed;
628
+ const version = versionRes.version;
569
629
  const review = await reviewHostedPage({
570
630
  hostId,
571
631
  screenId,
572
632
  versionId,
573
- nodes
633
+ nodes,
634
+ // Named by what it is, not only its id (AGL-3374).
635
+ page: {
636
+ screen: {
637
+ displayName: screen.displayName,
638
+ name: screen.name,
639
+ kind: screen.kind
640
+ },
641
+ template: (_ref1 = (_options_page = options.page) == null ? void 0 : _options_page.template) != null ? _ref1 : null,
642
+ variant: (_ref2 = (_options_page1 = options.page) == null ? void 0 : _options_page1.variant) != null ? _ref2 : null,
643
+ parts: ()=>{
644
+ var _ref;
645
+ return composedPageParts({
646
+ hostId,
647
+ screenId,
648
+ screenNodes: (_ref = version == null ? void 0 : version.nodes) != null ? _ref : {},
649
+ layoutId: version && 'layoutId' in version ? version.layoutId : screen.layoutId
650
+ });
651
+ }
652
+ }
574
653
  });
575
654
  if (review.outcome === 'serve') {
576
655
  // Only the PUBLISHED version is the page's last clean one; a variant or