@aglyn/aglyn 1.0.0-beta.162 → 1.0.0-beta.164

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/package.json +10 -10
  2. package/src/lib/app-utils/campaign-membership.d.ts +12 -2
  3. package/src/lib/app-utils/campaign-membership.js +13 -2
  4. package/src/lib/app-utils/campaign-membership.js.map +1 -1
  5. package/src/lib/app-utils/collection-entry-date.d.ts +25 -17
  6. package/src/lib/app-utils/collection-entry-date.js +55 -20
  7. package/src/lib/app-utils/collection-entry-date.js.map +1 -1
  8. package/src/lib/app-utils/console-routes.d.ts +2 -0
  9. package/src/lib/app-utils/console-routes.js +22 -0
  10. package/src/lib/app-utils/console-routes.js.map +1 -1
  11. package/src/lib/app-utils/crm.d.ts +8 -0
  12. package/src/lib/app-utils/crm.js.map +1 -1
  13. package/src/lib/app-utils/docs-index.generated.js +6 -6
  14. package/src/lib/app-utils/docs-index.generated.js.map +1 -1
  15. package/src/lib/app-utils/notifications.d.ts +32 -1
  16. package/src/lib/app-utils/notifications.js +69 -17
  17. package/src/lib/app-utils/notifications.js.map +1 -1
  18. package/src/lib/app-utils/platform-brand.d.ts +21 -0
  19. package/src/lib/app-utils/platform-brand.js +22 -1
  20. package/src/lib/app-utils/platform-brand.js.map +1 -1
  21. package/src/lib/canvas-manager/canvas-manager.d.ts +52 -0
  22. package/src/lib/canvas-manager/canvas-manager.js +67 -2
  23. package/src/lib/canvas-manager/canvas-manager.js.map +1 -1
  24. package/src/lib/foundation/definitions/platform.types.d.ts +16 -0
  25. package/src/lib/foundation/definitions/platform.types.js +1 -0
  26. package/src/lib/foundation/definitions/platform.types.js.map +1 -1
  27. package/src/lib/plugin-manager/plugin-membership-detach.d.ts +55 -0
  28. package/src/lib/plugin-manager/plugin-membership-detach.js +102 -0
  29. package/src/lib/plugin-manager/plugin-membership-detach.js.map +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/aglyn",
3
- "version": "1.0.0-beta.162",
3
+ "version": "1.0.0-beta.164",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -33,15 +33,15 @@
33
33
  "./package.json": "./package.json"
34
34
  },
35
35
  "dependencies": {
36
- "@aglyn/shared-data-enums": "1.0.0-beta.162",
37
- "@aglyn/shared-data-mdi": "1.0.0-beta.162",
38
- "@aglyn/shared-data-types": "1.0.0-beta.162",
39
- "@aglyn/shared-util-email": "1.0.0-beta.162",
40
- "@aglyn/shared-util-http": "1.0.0-beta.162",
41
- "@aglyn/shared-util-logger": "1.0.0-beta.162",
42
- "@aglyn/shared-util-timestamp": "1.0.0-beta.162",
43
- "@aglyn/shared-util-tools": "1.0.0-beta.162",
44
- "@aglyn/shared-util-vendor": "1.0.0-beta.162",
36
+ "@aglyn/shared-data-enums": "1.0.0-beta.164",
37
+ "@aglyn/shared-data-mdi": "1.0.0-beta.164",
38
+ "@aglyn/shared-data-types": "1.0.0-beta.164",
39
+ "@aglyn/shared-util-email": "1.0.0-beta.164",
40
+ "@aglyn/shared-util-http": "1.0.0-beta.164",
41
+ "@aglyn/shared-util-logger": "1.0.0-beta.164",
42
+ "@aglyn/shared-util-timestamp": "1.0.0-beta.164",
43
+ "@aglyn/shared-util-tools": "1.0.0-beta.164",
44
+ "@aglyn/shared-util-vendor": "1.0.0-beta.164",
45
45
  "@data-driven-forms/react-form-renderer": "^4.2.0",
46
46
  "@msgpack/msgpack": "^3.1.3",
47
47
  "@types/unist": "^3.0.3",
@@ -100,10 +100,20 @@ export declare function campaignMembershipUnchanged(stored: readonly string[], s
100
100
  * detach fails the build rather than shipping a campaign whose removal leaves
101
101
  * that collection pointing at nothing.
102
102
  *
103
+ * A lead (`hosts/{hostId}/leads/{personKey}`, AGL-3254) carries the field at
104
+ * the top of its document like a form does: a lead is one site's own record,
105
+ * captured there and worked there, so there is no second holder to keep the
106
+ * edge away from. It joins a campaign by hand — the New lead drawer, the CSV
107
+ * import, the list's bulk bar — or by being enrolled in a sequence that is
108
+ * in one, and it hands the membership to the contact it converts into.
109
+ *
103
110
  * Contacts are deliberately NOT here. They live on the org
104
111
  * (`orgs/{orgId}/contacts`), not the host, and carry the field inside a
105
112
  * per-holder facet — so they are detached by their own pass, against a field
106
- * path that names the group.
113
+ * path that names the group. Nor are a plugin's own members — a sequence and
114
+ * its enrollments live under the org, in collections the core does not name
115
+ * — so a plugin registers a detacher for those
116
+ * (`plugin-manager/plugin-membership-detach.ts`) and the deletion runs it.
107
117
  */
108
- export declare const CAMPAIGN_MEMBER_HOST_COLLECTIONS: readonly ["forms", "screens"];
118
+ export declare const CAMPAIGN_MEMBER_HOST_COLLECTIONS: readonly ["forms", "screens", "leads"];
109
119
  export type CampaignMemberHostCollection = (typeof CAMPAIGN_MEMBER_HOST_COLLECTIONS)[number];
@@ -164,13 +164,24 @@
164
164
  * detach fails the build rather than shipping a campaign whose removal leaves
165
165
  * that collection pointing at nothing.
166
166
  *
167
+ * A lead (`hosts/{hostId}/leads/{personKey}`, AGL-3254) carries the field at
168
+ * the top of its document like a form does: a lead is one site's own record,
169
+ * captured there and worked there, so there is no second holder to keep the
170
+ * edge away from. It joins a campaign by hand — the New lead drawer, the CSV
171
+ * import, the list's bulk bar — or by being enrolled in a sequence that is
172
+ * in one, and it hands the membership to the contact it converts into.
173
+ *
167
174
  * Contacts are deliberately NOT here. They live on the org
168
175
  * (`orgs/{orgId}/contacts`), not the host, and carry the field inside a
169
176
  * per-holder facet — so they are detached by their own pass, against a field
170
- * path that names the group.
177
+ * path that names the group. Nor are a plugin's own members — a sequence and
178
+ * its enrollments live under the org, in collections the core does not name
179
+ * — so a plugin registers a detacher for those
180
+ * (`plugin-manager/plugin-membership-detach.ts`) and the deletion runs it.
171
181
  */ export const CAMPAIGN_MEMBER_HOST_COLLECTIONS = [
172
182
  'forms',
173
- 'screens'
183
+ 'screens',
184
+ 'leads'
174
185
  ];
175
186
 
176
187
  //# sourceMappingURL=campaign-membership.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/campaign-membership.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\n/**\n * WHAT ELSE IS IN A CAMPAIGN: the edge between a campaign and the forms,\n * screens and people a push is coordinated across.\n *\n * ## The edge is a field on the RESOURCE, not a list on the campaign\n *\n * A campaign container lives at `hosts/{hostId}/emailCampaigns/{campaignId}`\n * and names nothing outside itself. A send joins one by carrying\n * `emailCampaignId`; every other member joins the same way, through\n * {@link CAMPAIGN_MEMBERSHIP_FIELD} on its own document.\n *\n * Three properties decide it, and all three point the same way:\n *\n * - **Reading is free where it is asked.** A form's own page reads the form\n * document to draw anything at all, so the campaign comes with it. A\n * membership collection under the campaign would make \"which campaign is\n * this in\" a query issued on mount by every resource page, for a fact one\n * field already carries.\n * - **The reduction is already gated this way.** `campaign-manage.ts` clears\n * `emailCampaignId` from every send before it removes a container, so no\n * send is left naming one that is gone. Extending that pass to the\n * membership field is the SAME mechanism; a membership collection would be\n * a second, different deletion story beside it.\n * - **Deleting a MEMBER can leave nothing behind.** The campaign holds no\n * list, so a deleted form takes its own edge with it. This matters most on\n * a contact: an erasure removes the document, and a membership row stored\n * anywhere else would survive it holding the id of a person who asked to\n * be forgotten.\n *\n * ## Plural, because a landing page outlives one campaign\n *\n * The field is an ARRAY. A signup form built for the spring push is the same\n * form the summer push places, and a single-valued field would make the\n * second assignment silently erase the first — a campaign losing a member\n * with nothing on screen to say so. The automation step that has always\n * written this edge (`assignCampaign`) already used `arrayUnion`, so the\n * product has treated it as many-to-many from the start.\n *\n * ## Ids, never names\n *\n * A campaign's name is editable, and every reader here resolves the name from\n * the campaign document at read time. Renaming a campaign therefore moves no\n * membership: the stored value is the container's document id, which nothing\n * rewrites.\n */\n\nimport { contactFacetPath, readContactFacet } from './contacts'\n\n/**\n * The field naming the campaigns a resource belongs to.\n *\n * Not `emailCampaignId`, which is the SEND's field: on a send document the\n * word `campaignId` already means the send's own id — it is what `cid`\n * carries into every unsubscribe footer — so that collection needed a\n * different word. A form, a screen and a contact have no second meaning for\n * it, and the plural says the resource may be in more than one.\n */\nexport const CAMPAIGN_MEMBERSHIP_FIELD = 'campaignIds'\n\n/**\n * How many campaigns one resource may name.\n *\n * A ceiling on the FIELD, not on the product: an array Firestore has to index\n * on every write is not the place to discover that a script has been adding\n * an id a day for a year. Twenty is far past what a person assigns by hand\n * and far short of anything that costs a write.\n */\nexport const CAMPAIGN_MEMBERSHIP_CAP = 20\n\n/**\n * A stored membership array as a clean list of ids.\n *\n * Deduped, trimmed, non-strings dropped, capped. Every reader goes through\n * this because the field is written by three consoles and one automation\n * step, and a surface that trusted the raw value would render `undefined` as\n * a chip the first time one of them wrote a blank.\n */\nexport function normalizeCampaignIds(raw: unknown): string[] {\n if (!Array.isArray(raw)) return []\n const seen: string[] = []\n for (const entry of raw) {\n if (typeof entry !== 'string') continue\n const id = entry.trim()\n if (!id || seen.includes(id)) continue\n seen.push(id)\n if (seen.length >= CAMPAIGN_MEMBERSHIP_CAP) break\n }\n return seen\n}\n\n/**\n * The campaigns a HOST resource — a form, a screen — belongs to.\n *\n * Host resources carry the field at the top of the document: they belong to\n * one site, which is the same site the campaign belongs to, so there is no\n * second holder to keep the edge away from.\n */\nexport function readCampaignIds(\n resource: Record<string, unknown> | null | undefined,\n): string[] {\n return normalizeCampaignIds((resource ?? {})[CAMPAIGN_MEMBERSHIP_FIELD])\n}\n\n/**\n * The dotted path to a contact's membership, inside ONE holder's facet.\n *\n * A contact document is shared by every site in the org — one human who\n * touched two sites is one row — and almost nothing on it is legitimately\n * shared. Which campaigns a merchant has filed a person under is that\n * merchant's business record on exactly the footing their notes and tags are,\n * so it lives at `facets.{groupId}.campaignIds` and not at the top of the\n * document, where every other site in an agency's account could read it.\n */\nexport function contactCampaignFieldPath(groupId: string): string {\n return contactFacetPath(groupId, CAMPAIGN_MEMBERSHIP_FIELD)\n}\n\n/** The campaigns ONE holder has filed this contact under. */\nexport function readContactCampaignIds(\n contact: Record<string, unknown> | null | undefined,\n groupId: string,\n): string[] {\n return normalizeCampaignIds(readContactFacet(contact, groupId).campaignIds)\n}\n\n/**\n * The membership a save should write, given what is on screen.\n *\n * Pure, and shared by the three consoles that offer the picker, because the\n * decision each of them makes is identical and is not obvious: an EMPTY\n * selection has to be stored as an empty array rather than as a removed\n * field.\n *\n * Removing the field would read the same to every surface here — the readers\n * above answer `[]` either way — and would be wrong for the one reader that\n * is not a surface. `campaign-manage.ts` finds a campaign's members with\n * `array-contains`, which matches on the automatic single-field index; that\n * index has an entry only while the array does, so the two shapes are the\n * same to a reader and different to a writer only in cost. Storing `[]` keeps\n * one shape for \"belongs to no campaign\" across every writer, which is the\n * property the send collection's own detach comment argues for one field\n * over.\n *\n * @returns the value to store under {@link CAMPAIGN_MEMBERSHIP_FIELD}.\n */\nexport function campaignMembershipValue(selected: readonly string[]): string[] {\n return normalizeCampaignIds(selected)\n}\n\n/**\n * Whether a document's stored membership already says what a save would.\n *\n * Order-insensitive: a picker hands back its options' order and the stored\n * array is in the order it was written, so comparing them literally would\n * report a change on every open and leave Save permanently enabled.\n */\nexport function campaignMembershipUnchanged(\n stored: readonly string[],\n selected: readonly string[],\n): boolean {\n if (stored.length !== selected.length) return false\n const sortedStored = [...stored].sort()\n const sortedSelected = [...selected].sort()\n return sortedStored.every((id, index) => id === sortedSelected[index])\n}\n\n/**\n * The host subcollections whose documents may name a campaign.\n *\n * The list a campaign's deletion walks, and the list this feature's coverage\n * spec reads — so a collection that grows the picker without growing the\n * detach fails the build rather than shipping a campaign whose removal leaves\n * that collection pointing at nothing.\n *\n * Contacts are deliberately NOT here. They live on the org\n * (`orgs/{orgId}/contacts`), not the host, and carry the field inside a\n * per-holder facet — so they are detached by their own pass, against a field\n * path that names the group.\n */\nexport const CAMPAIGN_MEMBER_HOST_COLLECTIONS = ['forms', 'screens'] as const\n\nexport type CampaignMemberHostCollection =\n (typeof CAMPAIGN_MEMBER_HOST_COLLECTIONS)[number]\n"],"names":["contactFacetPath","readContactFacet","CAMPAIGN_MEMBERSHIP_FIELD","CAMPAIGN_MEMBERSHIP_CAP","normalizeCampaignIds","raw","Array","isArray","seen","entry","id","trim","includes","push","length","readCampaignIds","resource","contactCampaignFieldPath","groupId","readContactCampaignIds","contact","campaignIds","campaignMembershipValue","selected","campaignMembershipUnchanged","stored","sortedStored","sort","sortedSelected","every","index","CAMPAIGN_MEMBER_HOST_COLLECTIONS"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4CC,GAED,SAASA,gBAAgB,EAAEC,gBAAgB,QAAQ,gBAAY;AAE/D;;;;;;;;CAQC,GACD,OAAO,MAAMC,4BAA4B,cAAa;AAEtD;;;;;;;CAOC,GACD,OAAO,MAAMC,0BAA0B,GAAE;AAEzC;;;;;;;CAOC,GACD,OAAO,SAASC,qBAAqBC,GAAY;IAC/C,IAAI,CAACC,MAAMC,OAAO,CAACF,MAAM,OAAO,EAAE;IAClC,MAAMG,OAAiB,EAAE;IACzB,KAAK,MAAMC,SAASJ,IAAK;QACvB,IAAI,OAAOI,UAAU,UAAU;QAC/B,MAAMC,KAAKD,MAAME,IAAI;QACrB,IAAI,CAACD,MAAMF,KAAKI,QAAQ,CAACF,KAAK;QAC9BF,KAAKK,IAAI,CAACH;QACV,IAAIF,KAAKM,MAAM,IAAIX,yBAAyB;IAC9C;IACA,OAAOK;AACT;AAEA;;;;;;CAMC,GACD,OAAO,SAASO,gBACdC,QAAoD;IAEpD,OAAOZ,qBAAqB,CAACY,mBAAAA,WAAY,CAAC,EAAE,CAACd,0BAA0B;AACzE;AAEA;;;;;;;;;CASC,GACD,OAAO,SAASe,yBAAyBC,OAAe;IACtD,OAAOlB,iBAAiBkB,SAAShB;AACnC;AAEA,2DAA2D,GAC3D,OAAO,SAASiB,uBACdC,OAAmD,EACnDF,OAAe;IAEf,OAAOd,qBAAqBH,iBAAiBmB,SAASF,SAASG,WAAW;AAC5E;AAEA;;;;;;;;;;;;;;;;;;;CAmBC,GACD,OAAO,SAASC,wBAAwBC,QAA2B;IACjE,OAAOnB,qBAAqBmB;AAC9B;AAEA;;;;;;CAMC,GACD,OAAO,SAASC,4BACdC,MAAyB,EACzBF,QAA2B;IAE3B,IAAIE,OAAOX,MAAM,KAAKS,SAAST,MAAM,EAAE,OAAO;IAC9C,MAAMY,eAAe;WAAID;KAAO,CAACE,IAAI;IACrC,MAAMC,iBAAiB;WAAIL;KAAS,CAACI,IAAI;IACzC,OAAOD,aAAaG,KAAK,CAAC,CAACnB,IAAIoB,QAAUpB,OAAOkB,cAAc,CAACE,MAAM;AACvE;AAEA;;;;;;;;;;;;CAYC,GACD,OAAO,MAAMC,mCAAmC;IAAC;IAAS;CAAU,CAAS"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/campaign-membership.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\n/**\n * WHAT ELSE IS IN A CAMPAIGN: the edge between a campaign and the forms,\n * screens and people a push is coordinated across.\n *\n * ## The edge is a field on the RESOURCE, not a list on the campaign\n *\n * A campaign container lives at `hosts/{hostId}/emailCampaigns/{campaignId}`\n * and names nothing outside itself. A send joins one by carrying\n * `emailCampaignId`; every other member joins the same way, through\n * {@link CAMPAIGN_MEMBERSHIP_FIELD} on its own document.\n *\n * Three properties decide it, and all three point the same way:\n *\n * - **Reading is free where it is asked.** A form's own page reads the form\n * document to draw anything at all, so the campaign comes with it. A\n * membership collection under the campaign would make \"which campaign is\n * this in\" a query issued on mount by every resource page, for a fact one\n * field already carries.\n * - **The reduction is already gated this way.** `campaign-manage.ts` clears\n * `emailCampaignId` from every send before it removes a container, so no\n * send is left naming one that is gone. Extending that pass to the\n * membership field is the SAME mechanism; a membership collection would be\n * a second, different deletion story beside it.\n * - **Deleting a MEMBER can leave nothing behind.** The campaign holds no\n * list, so a deleted form takes its own edge with it. This matters most on\n * a contact: an erasure removes the document, and a membership row stored\n * anywhere else would survive it holding the id of a person who asked to\n * be forgotten.\n *\n * ## Plural, because a landing page outlives one campaign\n *\n * The field is an ARRAY. A signup form built for the spring push is the same\n * form the summer push places, and a single-valued field would make the\n * second assignment silently erase the first — a campaign losing a member\n * with nothing on screen to say so. The automation step that has always\n * written this edge (`assignCampaign`) already used `arrayUnion`, so the\n * product has treated it as many-to-many from the start.\n *\n * ## Ids, never names\n *\n * A campaign's name is editable, and every reader here resolves the name from\n * the campaign document at read time. Renaming a campaign therefore moves no\n * membership: the stored value is the container's document id, which nothing\n * rewrites.\n */\n\nimport { contactFacetPath, readContactFacet } from './contacts'\n\n/**\n * The field naming the campaigns a resource belongs to.\n *\n * Not `emailCampaignId`, which is the SEND's field: on a send document the\n * word `campaignId` already means the send's own id — it is what `cid`\n * carries into every unsubscribe footer — so that collection needed a\n * different word. A form, a screen and a contact have no second meaning for\n * it, and the plural says the resource may be in more than one.\n */\nexport const CAMPAIGN_MEMBERSHIP_FIELD = 'campaignIds'\n\n/**\n * How many campaigns one resource may name.\n *\n * A ceiling on the FIELD, not on the product: an array Firestore has to index\n * on every write is not the place to discover that a script has been adding\n * an id a day for a year. Twenty is far past what a person assigns by hand\n * and far short of anything that costs a write.\n */\nexport const CAMPAIGN_MEMBERSHIP_CAP = 20\n\n/**\n * A stored membership array as a clean list of ids.\n *\n * Deduped, trimmed, non-strings dropped, capped. Every reader goes through\n * this because the field is written by three consoles and one automation\n * step, and a surface that trusted the raw value would render `undefined` as\n * a chip the first time one of them wrote a blank.\n */\nexport function normalizeCampaignIds(raw: unknown): string[] {\n if (!Array.isArray(raw)) return []\n const seen: string[] = []\n for (const entry of raw) {\n if (typeof entry !== 'string') continue\n const id = entry.trim()\n if (!id || seen.includes(id)) continue\n seen.push(id)\n if (seen.length >= CAMPAIGN_MEMBERSHIP_CAP) break\n }\n return seen\n}\n\n/**\n * The campaigns a HOST resource — a form, a screen — belongs to.\n *\n * Host resources carry the field at the top of the document: they belong to\n * one site, which is the same site the campaign belongs to, so there is no\n * second holder to keep the edge away from.\n */\nexport function readCampaignIds(\n resource: Record<string, unknown> | null | undefined,\n): string[] {\n return normalizeCampaignIds((resource ?? {})[CAMPAIGN_MEMBERSHIP_FIELD])\n}\n\n/**\n * The dotted path to a contact's membership, inside ONE holder's facet.\n *\n * A contact document is shared by every site in the org — one human who\n * touched two sites is one row — and almost nothing on it is legitimately\n * shared. Which campaigns a merchant has filed a person under is that\n * merchant's business record on exactly the footing their notes and tags are,\n * so it lives at `facets.{groupId}.campaignIds` and not at the top of the\n * document, where every other site in an agency's account could read it.\n */\nexport function contactCampaignFieldPath(groupId: string): string {\n return contactFacetPath(groupId, CAMPAIGN_MEMBERSHIP_FIELD)\n}\n\n/** The campaigns ONE holder has filed this contact under. */\nexport function readContactCampaignIds(\n contact: Record<string, unknown> | null | undefined,\n groupId: string,\n): string[] {\n return normalizeCampaignIds(readContactFacet(contact, groupId).campaignIds)\n}\n\n/**\n * The membership a save should write, given what is on screen.\n *\n * Pure, and shared by the three consoles that offer the picker, because the\n * decision each of them makes is identical and is not obvious: an EMPTY\n * selection has to be stored as an empty array rather than as a removed\n * field.\n *\n * Removing the field would read the same to every surface here — the readers\n * above answer `[]` either way — and would be wrong for the one reader that\n * is not a surface. `campaign-manage.ts` finds a campaign's members with\n * `array-contains`, which matches on the automatic single-field index; that\n * index has an entry only while the array does, so the two shapes are the\n * same to a reader and different to a writer only in cost. Storing `[]` keeps\n * one shape for \"belongs to no campaign\" across every writer, which is the\n * property the send collection's own detach comment argues for one field\n * over.\n *\n * @returns the value to store under {@link CAMPAIGN_MEMBERSHIP_FIELD}.\n */\nexport function campaignMembershipValue(selected: readonly string[]): string[] {\n return normalizeCampaignIds(selected)\n}\n\n/**\n * Whether a document's stored membership already says what a save would.\n *\n * Order-insensitive: a picker hands back its options' order and the stored\n * array is in the order it was written, so comparing them literally would\n * report a change on every open and leave Save permanently enabled.\n */\nexport function campaignMembershipUnchanged(\n stored: readonly string[],\n selected: readonly string[],\n): boolean {\n if (stored.length !== selected.length) return false\n const sortedStored = [...stored].sort()\n const sortedSelected = [...selected].sort()\n return sortedStored.every((id, index) => id === sortedSelected[index])\n}\n\n/**\n * The host subcollections whose documents may name a campaign.\n *\n * The list a campaign's deletion walks, and the list this feature's coverage\n * spec reads — so a collection that grows the picker without growing the\n * detach fails the build rather than shipping a campaign whose removal leaves\n * that collection pointing at nothing.\n *\n * A lead (`hosts/{hostId}/leads/{personKey}`, AGL-3254) carries the field at\n * the top of its document like a form does: a lead is one site's own record,\n * captured there and worked there, so there is no second holder to keep the\n * edge away from. It joins a campaign by hand — the New lead drawer, the CSV\n * import, the list's bulk bar — or by being enrolled in a sequence that is\n * in one, and it hands the membership to the contact it converts into.\n *\n * Contacts are deliberately NOT here. They live on the org\n * (`orgs/{orgId}/contacts`), not the host, and carry the field inside a\n * per-holder facet — so they are detached by their own pass, against a field\n * path that names the group. Nor are a plugin's own members — a sequence and\n * its enrollments live under the org, in collections the core does not name\n * — so a plugin registers a detacher for those\n * (`plugin-manager/plugin-membership-detach.ts`) and the deletion runs it.\n */\nexport const CAMPAIGN_MEMBER_HOST_COLLECTIONS = ['forms', 'screens', 'leads'] as const\n\nexport type CampaignMemberHostCollection =\n (typeof CAMPAIGN_MEMBER_HOST_COLLECTIONS)[number]\n"],"names":["contactFacetPath","readContactFacet","CAMPAIGN_MEMBERSHIP_FIELD","CAMPAIGN_MEMBERSHIP_CAP","normalizeCampaignIds","raw","Array","isArray","seen","entry","id","trim","includes","push","length","readCampaignIds","resource","contactCampaignFieldPath","groupId","readContactCampaignIds","contact","campaignIds","campaignMembershipValue","selected","campaignMembershipUnchanged","stored","sortedStored","sort","sortedSelected","every","index","CAMPAIGN_MEMBER_HOST_COLLECTIONS"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4CC,GAED,SAASA,gBAAgB,EAAEC,gBAAgB,QAAQ,gBAAY;AAE/D;;;;;;;;CAQC,GACD,OAAO,MAAMC,4BAA4B,cAAa;AAEtD;;;;;;;CAOC,GACD,OAAO,MAAMC,0BAA0B,GAAE;AAEzC;;;;;;;CAOC,GACD,OAAO,SAASC,qBAAqBC,GAAY;IAC/C,IAAI,CAACC,MAAMC,OAAO,CAACF,MAAM,OAAO,EAAE;IAClC,MAAMG,OAAiB,EAAE;IACzB,KAAK,MAAMC,SAASJ,IAAK;QACvB,IAAI,OAAOI,UAAU,UAAU;QAC/B,MAAMC,KAAKD,MAAME,IAAI;QACrB,IAAI,CAACD,MAAMF,KAAKI,QAAQ,CAACF,KAAK;QAC9BF,KAAKK,IAAI,CAACH;QACV,IAAIF,KAAKM,MAAM,IAAIX,yBAAyB;IAC9C;IACA,OAAOK;AACT;AAEA;;;;;;CAMC,GACD,OAAO,SAASO,gBACdC,QAAoD;IAEpD,OAAOZ,qBAAqB,CAACY,mBAAAA,WAAY,CAAC,EAAE,CAACd,0BAA0B;AACzE;AAEA;;;;;;;;;CASC,GACD,OAAO,SAASe,yBAAyBC,OAAe;IACtD,OAAOlB,iBAAiBkB,SAAShB;AACnC;AAEA,2DAA2D,GAC3D,OAAO,SAASiB,uBACdC,OAAmD,EACnDF,OAAe;IAEf,OAAOd,qBAAqBH,iBAAiBmB,SAASF,SAASG,WAAW;AAC5E;AAEA;;;;;;;;;;;;;;;;;;;CAmBC,GACD,OAAO,SAASC,wBAAwBC,QAA2B;IACjE,OAAOnB,qBAAqBmB;AAC9B;AAEA;;;;;;CAMC,GACD,OAAO,SAASC,4BACdC,MAAyB,EACzBF,QAA2B;IAE3B,IAAIE,OAAOX,MAAM,KAAKS,SAAST,MAAM,EAAE,OAAO;IAC9C,MAAMY,eAAe;WAAID;KAAO,CAACE,IAAI;IACrC,MAAMC,iBAAiB;WAAIL;KAAS,CAACI,IAAI;IACzC,OAAOD,aAAaG,KAAK,CAAC,CAACnB,IAAIoB,QAAUpB,OAAOkB,cAAc,CAACE,MAAM;AACvE;AAEA;;;;;;;;;;;;;;;;;;;;;;CAsBC,GACD,OAAO,MAAMC,mCAAmC;IAAC;IAAS;IAAW;CAAQ,CAAS"}
@@ -121,27 +121,35 @@ export declare const COLLECTION_ENTRY_DATE_TIME_ZONE = "UTC";
121
121
  * know, which would take down a composing page rather than mis-date one post.
122
122
  */
123
123
  export declare function isSupportedTimeZone(value: unknown): boolean;
124
+ export declare function supportedTimeZones(): readonly string[];
124
125
  /**
125
- * The zone a site's dates are read in (AGL-3237).
126
- *
127
- * The ORGANIZATION names it and every site it owns inherits it: a customer
128
- * sets it once for the workspace rather than per site. Unset is UTC — the
129
- * value every existing site already renders, so nothing moves by a day when
130
- * this ships.
131
- *
132
- * Validated on the way out rather than trusted: a stored zone can predate an
133
- * IANA rename, or be whatever a hand-edited document holds, and a bad one has
134
- * to fall back rather than throw inside a page render.
135
- *
136
- * ⚑ Org only, deliberately. A per-SITE override is a reasonable thing to want
137
- * — an agency running sites in several regions — but it would make `timeZone`
138
- * a field of the host document, and the host write-deny guard (AGL-1361)
139
- * would then require it classified in the host rules too. That is a second
140
- * rules change, and rules ship by hand rather than with a promotion, so the
141
- * override is worth its own issue rather than a free ride on this one.
126
+ * The zone a site's dates are read in (AGL-3237, AGL-3252).
127
+ *
128
+ * THE SITE WINS, then the workspace, then UTC. The organization names the
129
+ * zone once and every site it owns inherits it — which is the answer a
130
+ * customer with one site wants and never has to think about — and a site that
131
+ * names its own overrides it, for the agency running a Chicago site and a
132
+ * Berlin site out of one workspace. Neither set is UTC: the value every
133
+ * existing site already renders, so nothing moves by a day when this ships.
134
+ *
135
+ * Each candidate is validated in turn rather than trusted, and an unusable
136
+ * one FALLS THROUGH rather than terminating the chain: a stored zone can
137
+ * predate an IANA rename, or be whatever a hand-edited document holds, and a
138
+ * site whose own zone has gone stale should read in its workspace's rather
139
+ * than jump to UTC.
140
+ *
141
+ * ⚑ AGL-3237 deferred the site half, on the reading that a `timeZone` on the
142
+ * host document would need a Firestore rules change to go with it. It did
143
+ * not. The host block's client branch is a `hasAny([…])` DENY list, so a
144
+ * field it does not name is already writable by an editor; what the override
145
+ * needed was a classification in `HOST_CLIENT_WRITABLE_FIELDS`, which is a
146
+ * code change on the same commit rather than a rules deploy. The deferral was
147
+ * a misreading of the guard, not a cost.
142
148
  */
143
149
  export declare function resolveSiteTimeZone(org?: {
144
150
  timeZone?: string;
151
+ } | null, host?: {
152
+ timeZone?: string;
145
153
  } | null): string;
146
154
  export declare function formatCollectionEntryDate(publishedAt: {
147
155
  seconds: number;
@@ -146,26 +146,61 @@
146
146
  }
147
147
  }
148
148
  /**
149
- * The zone a site's dates are read in (AGL-3237).
150
- *
151
- * The ORGANIZATION names it and every site it owns inherits it: a customer
152
- * sets it once for the workspace rather than per site. Unset is UTC — the
153
- * value every existing site already renders, so nothing moves by a day when
154
- * this ships.
155
- *
156
- * Validated on the way out rather than trusted: a stored zone can predate an
157
- * IANA rename, or be whatever a hand-edited document holds, and a bad one has
158
- * to fall back rather than throw inside a page render.
159
- *
160
- * ⚑ Org only, deliberately. A per-SITE override is a reasonable thing to want
161
- * — an agency running sites in several regions — but it would make `timeZone`
162
- * a field of the host document, and the host write-deny guard (AGL-1361)
163
- * would then require it classified in the host rules too. That is a second
164
- * rules change, and rules ship by hand rather than with a promotion, so the
165
- * override is worth its own issue rather than a free ride on this one.
166
- */ export function resolveSiteTimeZone(org) {
167
- var _ref;
168
- const orgZone = String((_ref = org == null ? void 0 : org.timeZone) != null ? _ref : '').trim();
149
+ * Every zone this runtime can offer as a choice (AGL-3252).
150
+ *
151
+ * Read off `Intl` rather than checked in, for the reason
152
+ * {@link isSupportedTimeZone} asks `Intl` rather than an allowlist: the IANA
153
+ * database renames zones, and a stored list would offer names the runtime has
154
+ * stopped accepting while missing ones it has learned. A picker built from
155
+ * this and a validator built from that cannot disagree about what is
156
+ * choosable.
157
+ *
158
+ * A memoized FUNCTION rather than a module constant: this module is imported
159
+ * by the tenant page composer, which never draws a picker, and enumerating
160
+ * four hundred zone names on import is work nothing there asks for.
161
+ *
162
+ * An old runtime without `supportedValuesOf` gets an EMPTY list rather than a
163
+ * broken control — the callers offer their inherited-default option first, so
164
+ * a picker with no zones in it still says what the site is doing.
165
+ */ let supportedTimeZonesCache = null;
166
+ export function supportedTimeZones() {
167
+ if (supportedTimeZonesCache) return supportedTimeZonesCache;
168
+ try {
169
+ var _ref;
170
+ supportedTimeZonesCache = (_ref = Intl.supportedValuesOf == null ? void 0 : Intl.supportedValuesOf.call(Intl, 'timeZone')) != null ? _ref : [];
171
+ } catch (unused) {
172
+ supportedTimeZonesCache = [];
173
+ }
174
+ return supportedTimeZonesCache;
175
+ }
176
+ /**
177
+ * The zone a site's dates are read in (AGL-3237, AGL-3252).
178
+ *
179
+ * THE SITE WINS, then the workspace, then UTC. The organization names the
180
+ * zone once and every site it owns inherits it — which is the answer a
181
+ * customer with one site wants and never has to think about — and a site that
182
+ * names its own overrides it, for the agency running a Chicago site and a
183
+ * Berlin site out of one workspace. Neither set is UTC: the value every
184
+ * existing site already renders, so nothing moves by a day when this ships.
185
+ *
186
+ * Each candidate is validated in turn rather than trusted, and an unusable
187
+ * one FALLS THROUGH rather than terminating the chain: a stored zone can
188
+ * predate an IANA rename, or be whatever a hand-edited document holds, and a
189
+ * site whose own zone has gone stale should read in its workspace's rather
190
+ * than jump to UTC.
191
+ *
192
+ * ⚑ AGL-3237 deferred the site half, on the reading that a `timeZone` on the
193
+ * host document would need a Firestore rules change to go with it. It did
194
+ * not. The host block's client branch is a `hasAny([…])` DENY list, so a
195
+ * field it does not name is already writable by an editor; what the override
196
+ * needed was a classification in `HOST_CLIENT_WRITABLE_FIELDS`, which is a
197
+ * code change on the same commit rather than a rules deploy. The deferral was
198
+ * a misreading of the guard, not a cost.
199
+ */ export function resolveSiteTimeZone(org, host) {
200
+ var _ref, _ref1;
201
+ const siteZone = String((_ref = host == null ? void 0 : host.timeZone) != null ? _ref : '').trim();
202
+ if (isSupportedTimeZone(siteZone)) return siteZone;
203
+ const orgZone = String((_ref1 = org == null ? void 0 : org.timeZone) != null ? _ref1 : '').trim();
169
204
  return isSupportedTimeZone(orgZone) ? orgZone : COLLECTION_ENTRY_DATE_TIME_ZONE;
170
205
  }
171
206
  export function formatCollectionEntryDate(publishedAt, format, locale = COLLECTION_ENTRY_DATE_LOCALE, /**
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/collection-entry-date.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\n/**\n * How an entry's published date is spelled, and the one function that spells\n * it.\n *\n * Its own module because a published page's client bundle reads\n * `formatCollectionEntryDate` and nothing else here: `collection-entries.ts`\n * carries the whole collection runtime — routing, pagination, category links,\n * the search index and the four block expanders — and a bundler cannot drop\n * the rest of it around a single named import.\n */\n\n/**\n * How an entry's published date reads (AGL-1459).\n *\n * Named shapes rather than a format string, for the same reason the styles\n * panel offers a unit picker rather than a CSS box: an editor choosing a\n * byline is not choosing a grammar, and a free-typed pattern is a field where\n * every typo renders as itself on a published page.\n */\nexport type CollectionEntryDateFormat =\n 'default' | 'monthYear' | 'mediumDate' | 'longDate' | 'iso'\n\n/**\n * \"However the site already renders it\" — a REAL value, never absence\n * (AGL-1459).\n *\n * `''` is the shape AGL-1451/AGL-1453 closed repo-wide: an emptied field\n * cannot survive a save, so an author who tried a format and wanted the\n * original back would have no option to pick. So the do-nothing choice is a\n * word, exactly like `COLLECTION_ALL_PILL_NONE` in `collection-entries.ts`.\n */\nexport const COLLECTION_ENTRY_DATE_FORMAT_DEFAULT: CollectionEntryDateFormat =\n 'default'\n\n/**\n * The offered formats, with the labels an author reads (AGL-1459). Lives here\n * rather than in the block's schema so the list and the formatter cannot\n * drift into offering a shape nothing knows how to produce.\n *\n * Example dates in the labels are written the way the default runtime renders\n * them, which is what makes the choice legible without opening a preview.\n */\nexport const COLLECTION_ENTRY_DATE_FORMAT_OPTIONS: readonly {\n value: CollectionEntryDateFormat\n label: string\n}[] = [\n { value: 'default', label: 'Site default — 8/9/2026' },\n { value: 'monthYear', label: 'Month and year — Aug 2026' },\n { value: 'mediumDate', label: 'Short date — Aug 9, 2026' },\n { value: 'longDate', label: 'Long date — August 9, 2026' },\n { value: 'iso', label: 'ISO — 2026-08-09' },\n]\n\n/** Any stored value read back as a format this module knows (AGL-1459). */\nexport function normalizeCollectionEntryDateFormat(\n value: unknown,\n): CollectionEntryDateFormat {\n const wanted = String(value ?? '').trim()\n const match = COLLECTION_ENTRY_DATE_FORMAT_OPTIONS.find(\n (option) => option.value === wanted,\n )\n return match?.value ?? COLLECTION_ENTRY_DATE_FORMAT_DEFAULT\n}\n\n/**\n * One entry's published date, in the shape the author asked for (AGL-1459).\n *\n * **The formatting lives HERE, beside the timestamp, and deliberately not in\n * the block.** By the time a date reaches the component it is already a\n * formatted string, and re-parsing one is ambiguous by construction: the same\n * `8/9/2026` reads as 9 August under an `en-US` runtime and 8 September under\n * `en-GB`. A byline that silently moves an article by three weeks is far worse\n * than one that is the wrong shape.\n *\n * `default` returns exactly `toLocaleDateString()` — the string this function\n * replaced, character for character — because the block is live on published\n * entries and opening a dropdown must not restyle them.\n *\n * ## Why the runtime gets no say (AGL-1926)\n *\n * Every branch pins BOTH the locale and the time zone, and the answer is a\n * pure function of the timestamp. It used to pass `locale` straight through\n * (normally `undefined`) with no `timeZone` at all, so the output was a\n * function of the RUNTIME: `en-US` + UTC on a Vercel server, the visitor's\n * own locale and zone in their browser. That is fine while only the server\n * ever calls it — the string is stamped into node props at compose time and\n * both sides then agree on it — but it makes the function a hydration\n * mismatch waiting for its first client-side caller, and `catch-all-client`\n * was exactly that caller. A post published at 02:30 UTC is dated the 10th by\n * the server and the 9th by every visitor west of Greenwich; React reports\n * the difference as a text mismatch (the live React #418 on tenant-web,\n * AGL-1926) and then reconciles against a DOM it no longer describes, which\n * is where the `removeChild`/`insertBefore` pair comes from.\n *\n * The pinned values are the ones production already emits, so no published\n * page changes: Vercel runs UTC with an `en-US` ICU default, which is why\n * every entry date served from aglyn.com today reads `8/9/2026`. Pinning\n * makes that byte-for-byte guaranteed instead of a property of the host.\n *\n * `locale` still exists for a caller that must render in a different one; it\n * now defaults to the value the server was picking implicitly rather than to\n * \"whatever this runtime happens to be\".\n */\nexport const COLLECTION_ENTRY_DATE_LOCALE = 'en-US'\n\n/**\n * The zone a calendar day is read in when a site has not named one.\n *\n * Entry timestamps are absolute instants; the day they are ATTRIBUTED to is\n * an editorial fact about the PUBLISHER, and `timeZone` below is how a site\n * states it. UTC remains the default because it is the answer every existing\n * site is already rendering — a site that never sets a zone must not have its\n * archive move by a day on deploy.\n *\n * ⚑ What AGL-1926 needed was not UTC, it was a zone BOTH SIDES AGREE ON. A\n * per-site zone is still that: it is resolved once, server-side, and travels\n * in the node props, so the client re-render reads the same string. What\n * would reopen the React #418 crash is reading the VISITOR's zone, because\n * only one side of the render knows it.\n */\nexport const COLLECTION_ENTRY_DATE_TIME_ZONE = 'UTC'\n\n/**\n * Is this a zone the runtime can actually format in (AGL-3237)?\n *\n * `Intl` is the only authority worth asking — an allowlist of IANA names goes\n * stale, and `toLocaleDateString` throws a RangeError on a name it does not\n * know, which would take down a composing page rather than mis-date one post.\n */\nexport function isSupportedTimeZone(value: unknown): boolean {\n const name = String(value ?? '').trim()\n if (!name) return false\n try {\n new Intl.DateTimeFormat('en-US', { timeZone: name })\n return true\n } catch {\n return false\n }\n}\n\n/**\n * The zone a site's dates are read in (AGL-3237).\n *\n * The ORGANIZATION names it and every site it owns inherits it: a customer\n * sets it once for the workspace rather than per site. Unset is UTC — the\n * value every existing site already renders, so nothing moves by a day when\n * this ships.\n *\n * Validated on the way out rather than trusted: a stored zone can predate an\n * IANA rename, or be whatever a hand-edited document holds, and a bad one has\n * to fall back rather than throw inside a page render.\n *\n * ⚑ Org only, deliberately. A per-SITE override is a reasonable thing to want\n * — an agency running sites in several regions — but it would make `timeZone`\n * a field of the host document, and the host write-deny guard (AGL-1361)\n * would then require it classified in the host rules too. That is a second\n * rules change, and rules ship by hand rather than with a promotion, so the\n * override is worth its own issue rather than a free ride on this one.\n */\nexport function resolveSiteTimeZone(\n org?: { timeZone?: string } | null,\n): string {\n const orgZone = String(org?.timeZone ?? '').trim()\n return isSupportedTimeZone(orgZone) ? orgZone : COLLECTION_ENTRY_DATE_TIME_ZONE\n}\n\nexport function formatCollectionEntryDate(\n publishedAt: { seconds: number } | null | undefined,\n format?: CollectionEntryDateFormat,\n locale: string = COLLECTION_ENTRY_DATE_LOCALE,\n /**\n * The site's zone (AGL-3237) — resolved once per render and passed down, so\n * server and client format the same instant into the same string.\n */\n timeZoneName: string = COLLECTION_ENTRY_DATE_TIME_ZONE,\n): string {\n const seconds = publishedAt?.seconds\n if (!seconds) return ''\n const date = new Date(seconds * 1000)\n // An unknown zone formats in UTC rather than throwing: this runs while a\n // page composes, and a RangeError here is a site that does not render.\n const timeZone = isSupportedTimeZone(timeZoneName)\n ? timeZoneName\n : COLLECTION_ENTRY_DATE_TIME_ZONE\n switch (normalizeCollectionEntryDateFormat(format)) {\n case 'monthYear':\n return date.toLocaleDateString(locale, {\n month: 'short',\n year: 'numeric',\n timeZone,\n })\n case 'mediumDate':\n return date.toLocaleDateString(locale, {\n month: 'short',\n day: 'numeric',\n year: 'numeric',\n timeZone,\n })\n case 'longDate':\n return date.toLocaleDateString(locale, {\n month: 'long',\n day: 'numeric',\n year: 'numeric',\n timeZone,\n })\n case 'iso': {\n // The calendar day IN THE SITE'S ZONE, not `toISOString()`'s slice by\n // luck and not the RUNTIME's local day: `getFullYear`/`getMonth`/\n // `getDate` read the host's zone, so this branch moved an entry by a\n // day depending on who rendered it. Read through the same formatter the\n // branches above use, so all four shapes name one day.\n const parts = new Intl.DateTimeFormat('en-US', {\n timeZone,\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n }).formatToParts(date)\n const part = (type: string) =>\n parts.find((entry) => entry.type === type)?.value ?? ''\n return `${part('year')}-${part('month')}-${part('day')}`\n }\n default:\n return date.toLocaleDateString(locale, { timeZone })\n }\n}\n\n/**\n * One entry's publish instant for a machine to read: an ISO 8601 date-time in\n * UTC, or `''` for an entry with no date (AGL-2956).\n *\n * {@link formatCollectionEntryDate} writes the date a READER sees, and every\n * one of its shapes is ambiguous or incomplete to a parser: `8/9/2026` is a\n * different day in `en-GB`, and even its `iso` shape is a calendar day with no\n * time or zone. A field that has to be parsed — a Video element's publication\n * date, which the page publishes as `VideoObject.uploadDate` — needs the\n * instant itself, spelled by `toISOString()` as `Article.datePublished` is.\n *\n * It reads the same `publishedAt` and treats a missing or zero `seconds` the\n * same way, so an entry is dated here exactly when it is dated there.\n *\n * A `seconds` no `Date` can hold answers `''` rather than throwing:\n * `toISOString` raises a RangeError on an invalid date, and this runs while a\n * page composes.\n */\nexport function collectionEntryPublishedAtIso(\n publishedAt: { seconds: number } | null | undefined,\n): string {\n const seconds = publishedAt?.seconds\n if (!seconds) return ''\n const date = new Date(seconds * 1000)\n return Number.isNaN(date.getTime()) ? '' : date.toISOString()\n}\n"],"names":["COLLECTION_ENTRY_DATE_FORMAT_DEFAULT","COLLECTION_ENTRY_DATE_FORMAT_OPTIONS","value","label","normalizeCollectionEntryDateFormat","wanted","String","trim","match","find","option","COLLECTION_ENTRY_DATE_LOCALE","COLLECTION_ENTRY_DATE_TIME_ZONE","isSupportedTimeZone","name","Intl","DateTimeFormat","timeZone","resolveSiteTimeZone","org","orgZone","formatCollectionEntryDate","publishedAt","format","locale","timeZoneName","seconds","date","Date","toLocaleDateString","month","year","day","parts","formatToParts","part","type","entry","collectionEntryPublishedAtIso","Number","isNaN","getTime","toISOString"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/collection-entry-date.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\n/**\n * How an entry's published date is spelled, and the one function that spells\n * it.\n *\n * Its own module because a published page's client bundle reads\n * `formatCollectionEntryDate` and nothing else here: `collection-entries.ts`\n * carries the whole collection runtime — routing, pagination, category links,\n * the search index and the four block expanders — and a bundler cannot drop\n * the rest of it around a single named import.\n */\n\n/**\n * How an entry's published date reads (AGL-1459).\n *\n * Named shapes rather than a format string, for the same reason the styles\n * panel offers a unit picker rather than a CSS box: an editor choosing a\n * byline is not choosing a grammar, and a free-typed pattern is a field where\n * every typo renders as itself on a published page.\n */\nexport type CollectionEntryDateFormat =\n 'default' | 'monthYear' | 'mediumDate' | 'longDate' | 'iso'\n\n/**\n * \"However the site already renders it\" — a REAL value, never absence\n * (AGL-1459).\n *\n * `''` is the shape AGL-1451/AGL-1453 closed repo-wide: an emptied field\n * cannot survive a save, so an author who tried a format and wanted the\n * original back would have no option to pick. So the do-nothing choice is a\n * word, exactly like `COLLECTION_ALL_PILL_NONE` in `collection-entries.ts`.\n */\nexport const COLLECTION_ENTRY_DATE_FORMAT_DEFAULT: CollectionEntryDateFormat =\n 'default'\n\n/**\n * The offered formats, with the labels an author reads (AGL-1459). Lives here\n * rather than in the block's schema so the list and the formatter cannot\n * drift into offering a shape nothing knows how to produce.\n *\n * Example dates in the labels are written the way the default runtime renders\n * them, which is what makes the choice legible without opening a preview.\n */\nexport const COLLECTION_ENTRY_DATE_FORMAT_OPTIONS: readonly {\n value: CollectionEntryDateFormat\n label: string\n}[] = [\n { value: 'default', label: 'Site default — 8/9/2026' },\n { value: 'monthYear', label: 'Month and year — Aug 2026' },\n { value: 'mediumDate', label: 'Short date — Aug 9, 2026' },\n { value: 'longDate', label: 'Long date — August 9, 2026' },\n { value: 'iso', label: 'ISO — 2026-08-09' },\n]\n\n/** Any stored value read back as a format this module knows (AGL-1459). */\nexport function normalizeCollectionEntryDateFormat(\n value: unknown,\n): CollectionEntryDateFormat {\n const wanted = String(value ?? '').trim()\n const match = COLLECTION_ENTRY_DATE_FORMAT_OPTIONS.find(\n (option) => option.value === wanted,\n )\n return match?.value ?? COLLECTION_ENTRY_DATE_FORMAT_DEFAULT\n}\n\n/**\n * One entry's published date, in the shape the author asked for (AGL-1459).\n *\n * **The formatting lives HERE, beside the timestamp, and deliberately not in\n * the block.** By the time a date reaches the component it is already a\n * formatted string, and re-parsing one is ambiguous by construction: the same\n * `8/9/2026` reads as 9 August under an `en-US` runtime and 8 September under\n * `en-GB`. A byline that silently moves an article by three weeks is far worse\n * than one that is the wrong shape.\n *\n * `default` returns exactly `toLocaleDateString()` — the string this function\n * replaced, character for character — because the block is live on published\n * entries and opening a dropdown must not restyle them.\n *\n * ## Why the runtime gets no say (AGL-1926)\n *\n * Every branch pins BOTH the locale and the time zone, and the answer is a\n * pure function of the timestamp. It used to pass `locale` straight through\n * (normally `undefined`) with no `timeZone` at all, so the output was a\n * function of the RUNTIME: `en-US` + UTC on a Vercel server, the visitor's\n * own locale and zone in their browser. That is fine while only the server\n * ever calls it — the string is stamped into node props at compose time and\n * both sides then agree on it — but it makes the function a hydration\n * mismatch waiting for its first client-side caller, and `catch-all-client`\n * was exactly that caller. A post published at 02:30 UTC is dated the 10th by\n * the server and the 9th by every visitor west of Greenwich; React reports\n * the difference as a text mismatch (the live React #418 on tenant-web,\n * AGL-1926) and then reconciles against a DOM it no longer describes, which\n * is where the `removeChild`/`insertBefore` pair comes from.\n *\n * The pinned values are the ones production already emits, so no published\n * page changes: Vercel runs UTC with an `en-US` ICU default, which is why\n * every entry date served from aglyn.com today reads `8/9/2026`. Pinning\n * makes that byte-for-byte guaranteed instead of a property of the host.\n *\n * `locale` still exists for a caller that must render in a different one; it\n * now defaults to the value the server was picking implicitly rather than to\n * \"whatever this runtime happens to be\".\n */\nexport const COLLECTION_ENTRY_DATE_LOCALE = 'en-US'\n\n/**\n * The zone a calendar day is read in when a site has not named one.\n *\n * Entry timestamps are absolute instants; the day they are ATTRIBUTED to is\n * an editorial fact about the PUBLISHER, and `timeZone` below is how a site\n * states it. UTC remains the default because it is the answer every existing\n * site is already rendering — a site that never sets a zone must not have its\n * archive move by a day on deploy.\n *\n * ⚑ What AGL-1926 needed was not UTC, it was a zone BOTH SIDES AGREE ON. A\n * per-site zone is still that: it is resolved once, server-side, and travels\n * in the node props, so the client re-render reads the same string. What\n * would reopen the React #418 crash is reading the VISITOR's zone, because\n * only one side of the render knows it.\n */\nexport const COLLECTION_ENTRY_DATE_TIME_ZONE = 'UTC'\n\n/**\n * Is this a zone the runtime can actually format in (AGL-3237)?\n *\n * `Intl` is the only authority worth asking — an allowlist of IANA names goes\n * stale, and `toLocaleDateString` throws a RangeError on a name it does not\n * know, which would take down a composing page rather than mis-date one post.\n */\nexport function isSupportedTimeZone(value: unknown): boolean {\n const name = String(value ?? '').trim()\n if (!name) return false\n try {\n new Intl.DateTimeFormat('en-US', { timeZone: name })\n return true\n } catch {\n return false\n }\n}\n\n/**\n * Every zone this runtime can offer as a choice (AGL-3252).\n *\n * Read off `Intl` rather than checked in, for the reason\n * {@link isSupportedTimeZone} asks `Intl` rather than an allowlist: the IANA\n * database renames zones, and a stored list would offer names the runtime has\n * stopped accepting while missing ones it has learned. A picker built from\n * this and a validator built from that cannot disagree about what is\n * choosable.\n *\n * A memoized FUNCTION rather than a module constant: this module is imported\n * by the tenant page composer, which never draws a picker, and enumerating\n * four hundred zone names on import is work nothing there asks for.\n *\n * An old runtime without `supportedValuesOf` gets an EMPTY list rather than a\n * broken control — the callers offer their inherited-default option first, so\n * a picker with no zones in it still says what the site is doing.\n */\nlet supportedTimeZonesCache: readonly string[] | null = null\nexport function supportedTimeZones(): readonly string[] {\n if (supportedTimeZonesCache) return supportedTimeZonesCache\n try {\n supportedTimeZonesCache =\n (Intl as { supportedValuesOf?: (key: string) => string[] })\n .supportedValuesOf?.('timeZone') ?? []\n } catch {\n supportedTimeZonesCache = []\n }\n return supportedTimeZonesCache\n}\n\n/**\n * The zone a site's dates are read in (AGL-3237, AGL-3252).\n *\n * THE SITE WINS, then the workspace, then UTC. The organization names the\n * zone once and every site it owns inherits it — which is the answer a\n * customer with one site wants and never has to think about — and a site that\n * names its own overrides it, for the agency running a Chicago site and a\n * Berlin site out of one workspace. Neither set is UTC: the value every\n * existing site already renders, so nothing moves by a day when this ships.\n *\n * Each candidate is validated in turn rather than trusted, and an unusable\n * one FALLS THROUGH rather than terminating the chain: a stored zone can\n * predate an IANA rename, or be whatever a hand-edited document holds, and a\n * site whose own zone has gone stale should read in its workspace's rather\n * than jump to UTC.\n *\n * ⚑ AGL-3237 deferred the site half, on the reading that a `timeZone` on the\n * host document would need a Firestore rules change to go with it. It did\n * not. The host block's client branch is a `hasAny([…])` DENY list, so a\n * field it does not name is already writable by an editor; what the override\n * needed was a classification in `HOST_CLIENT_WRITABLE_FIELDS`, which is a\n * code change on the same commit rather than a rules deploy. The deferral was\n * a misreading of the guard, not a cost.\n */\nexport function resolveSiteTimeZone(\n org?: { timeZone?: string } | null,\n host?: { timeZone?: string } | null,\n): string {\n const siteZone = String(host?.timeZone ?? '').trim()\n if (isSupportedTimeZone(siteZone)) return siteZone\n const orgZone = String(org?.timeZone ?? '').trim()\n return isSupportedTimeZone(orgZone) ? orgZone : COLLECTION_ENTRY_DATE_TIME_ZONE\n}\n\nexport function formatCollectionEntryDate(\n publishedAt: { seconds: number } | null | undefined,\n format?: CollectionEntryDateFormat,\n locale: string = COLLECTION_ENTRY_DATE_LOCALE,\n /**\n * The site's zone (AGL-3237) — resolved once per render and passed down, so\n * server and client format the same instant into the same string.\n */\n timeZoneName: string = COLLECTION_ENTRY_DATE_TIME_ZONE,\n): string {\n const seconds = publishedAt?.seconds\n if (!seconds) return ''\n const date = new Date(seconds * 1000)\n // An unknown zone formats in UTC rather than throwing: this runs while a\n // page composes, and a RangeError here is a site that does not render.\n const timeZone = isSupportedTimeZone(timeZoneName)\n ? timeZoneName\n : COLLECTION_ENTRY_DATE_TIME_ZONE\n switch (normalizeCollectionEntryDateFormat(format)) {\n case 'monthYear':\n return date.toLocaleDateString(locale, {\n month: 'short',\n year: 'numeric',\n timeZone,\n })\n case 'mediumDate':\n return date.toLocaleDateString(locale, {\n month: 'short',\n day: 'numeric',\n year: 'numeric',\n timeZone,\n })\n case 'longDate':\n return date.toLocaleDateString(locale, {\n month: 'long',\n day: 'numeric',\n year: 'numeric',\n timeZone,\n })\n case 'iso': {\n // The calendar day IN THE SITE'S ZONE, not `toISOString()`'s slice by\n // luck and not the RUNTIME's local day: `getFullYear`/`getMonth`/\n // `getDate` read the host's zone, so this branch moved an entry by a\n // day depending on who rendered it. Read through the same formatter the\n // branches above use, so all four shapes name one day.\n const parts = new Intl.DateTimeFormat('en-US', {\n timeZone,\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n }).formatToParts(date)\n const part = (type: string) =>\n parts.find((entry) => entry.type === type)?.value ?? ''\n return `${part('year')}-${part('month')}-${part('day')}`\n }\n default:\n return date.toLocaleDateString(locale, { timeZone })\n }\n}\n\n/**\n * One entry's publish instant for a machine to read: an ISO 8601 date-time in\n * UTC, or `''` for an entry with no date (AGL-2956).\n *\n * {@link formatCollectionEntryDate} writes the date a READER sees, and every\n * one of its shapes is ambiguous or incomplete to a parser: `8/9/2026` is a\n * different day in `en-GB`, and even its `iso` shape is a calendar day with no\n * time or zone. A field that has to be parsed — a Video element's publication\n * date, which the page publishes as `VideoObject.uploadDate` — needs the\n * instant itself, spelled by `toISOString()` as `Article.datePublished` is.\n *\n * It reads the same `publishedAt` and treats a missing or zero `seconds` the\n * same way, so an entry is dated here exactly when it is dated there.\n *\n * A `seconds` no `Date` can hold answers `''` rather than throwing:\n * `toISOString` raises a RangeError on an invalid date, and this runs while a\n * page composes.\n */\nexport function collectionEntryPublishedAtIso(\n publishedAt: { seconds: number } | null | undefined,\n): string {\n const seconds = publishedAt?.seconds\n if (!seconds) return ''\n const date = new Date(seconds * 1000)\n return Number.isNaN(date.getTime()) ? '' : date.toISOString()\n}\n"],"names":["COLLECTION_ENTRY_DATE_FORMAT_DEFAULT","COLLECTION_ENTRY_DATE_FORMAT_OPTIONS","value","label","normalizeCollectionEntryDateFormat","wanted","String","trim","match","find","option","COLLECTION_ENTRY_DATE_LOCALE","COLLECTION_ENTRY_DATE_TIME_ZONE","isSupportedTimeZone","name","Intl","DateTimeFormat","timeZone","supportedTimeZonesCache","supportedTimeZones","supportedValuesOf","resolveSiteTimeZone","org","host","siteZone","orgZone","formatCollectionEntryDate","publishedAt","format","locale","timeZoneName","seconds","date","Date","toLocaleDateString","month","year","day","parts","formatToParts","part","type","entry","collectionEntryPublishedAtIso","Number","isNaN","getTime","toISOString"],"mappings":"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"}
@@ -105,6 +105,7 @@ export declare enum Route {
105
105
  MANAGE_SUPPORT = "/[orgSlug]/support",
106
106
  MANAGE_SUPPORT_TICKETS = "/[orgSlug]/support/tickets",
107
107
  MANAGE_SUPPORT_FORUM = "/[orgSlug]/support/forum",
108
+ SUPPORT_ENTRY = "/support",
108
109
  HOST_DASHBOARD = "/[orgSlug]/hosts/[host]",
109
110
  HOST_PLUGIN = "/[orgSlug]/hosts/[host]/[pluginSlug]",
110
111
  HOST_INBOX = "/[orgSlug]/hosts/[host]/inbox",
@@ -385,6 +386,7 @@ export interface RoutePayload {
385
386
  orgSlug: string;
386
387
  };
387
388
  [Route.BILLING_ENTRY]: undefined;
389
+ [Route.SUPPORT_ENTRY]: undefined;
388
390
  [Route.HOST_INBOX]: {
389
391
  orgSlug: string;
390
392
  host: string;
@@ -342,6 +342,28 @@ export var Route = /*#__PURE__*/ function(Route) {
342
342
  Route["MANAGE_SUPPORT"] = "/[orgSlug]/support";
343
343
  Route["MANAGE_SUPPORT_TICKETS"] = "/[orgSlug]/support/tickets";
344
344
  Route["MANAGE_SUPPORT_FORUM"] = "/[orgSlug]/support/forum";
345
+ /*
346
+ * The ONE support URL that carries no org (AGL-3265), and the second member
347
+ * of the apex category `BILLING_ENTRY` opened.
348
+ *
349
+ * Everything a person is handed when they need help — a receipt, a system
350
+ * email footer, a Stripe customer portal, a self-host runbook — stores ONE
351
+ * link for every customer, with no workspace to interpolate. `/[orgSlug]`
352
+ * cannot be templated into any of them, so the only support address those
353
+ * surfaces could carry was one outside the console entirely.
354
+ *
355
+ * This resolves the workspace from the SESSION instead: one workspace goes
356
+ * straight through, several offer a choice, none says so. It forwards to
357
+ * `MANAGE_SUPPORT`, which is itself an umbrella — so the tier decision about
358
+ * tickets versus the forum stays in the one place that already makes it.
359
+ *
360
+ * `support` is a reserved subdomain (`RESERVED_SUBDOMAINS` in
361
+ * `host-naming.ts`), so no org can hold this slug and the literal segment
362
+ * can never shadow a real `/[orgSlug]`. That is the same guarantee
363
+ * `BILLING_ENTRY` rests on, and it is a precondition rather than a
364
+ * coincidence: an apex route whose segment an org could claim would take a
365
+ * workspace's own address away from it.
366
+ */ Route["SUPPORT_ENTRY"] = "/support";
345
367
  Route["HOST_DASHBOARD"] = "/[orgSlug]/hosts/[host]";
346
368
  // The catch-all console page a plugin's own nav items resolve against
347
369
  // (`app/(app)/[orgSlug]/hosts/[host]/[pluginSlug]/page.tsx`). Plugin slugs