@aglyn/aglyn 1.0.0-beta.147 → 1.0.0-beta.149

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 (37) hide show
  1. package/package.json +10 -10
  2. package/src/lib/app-utils/binding-token-catalog.js +9 -2
  3. package/src/lib/app-utils/binding-token-catalog.js.map +1 -1
  4. package/src/lib/app-utils/collection-entries.d.ts +52 -3
  5. package/src/lib/app-utils/collection-entries.js +98 -9
  6. package/src/lib/app-utils/collection-entries.js.map +1 -1
  7. package/src/lib/app-utils/console-routes.d.ts +2 -0
  8. package/src/lib/app-utils/console-routes.js +14 -0
  9. package/src/lib/app-utils/console-routes.js.map +1 -1
  10. package/src/lib/app-utils/docs-help.generated.d.ts +1 -1
  11. package/src/lib/app-utils/docs-help.generated.js +3 -0
  12. package/src/lib/app-utils/docs-help.generated.js.map +1 -1
  13. package/src/lib/app-utils/docs-index.generated.js +1 -1
  14. package/src/lib/app-utils/docs-index.generated.js.map +1 -1
  15. package/src/lib/app-utils/notifications.d.ts +136 -2
  16. package/src/lib/app-utils/notifications.js +139 -3
  17. package/src/lib/app-utils/notifications.js.map +1 -1
  18. package/src/lib/foundation/definitions/host-content-collections.d.ts +42 -40
  19. package/src/lib/foundation/definitions/host-content-collections.js +47 -109
  20. package/src/lib/foundation/definitions/host-content-collections.js.map +1 -1
  21. package/src/lib/plugin-manager/feature-plugins.d.ts +70 -0
  22. package/src/lib/plugin-manager/feature-plugins.js +27 -0
  23. package/src/lib/plugin-manager/feature-plugins.js.map +1 -1
  24. package/src/lib/plugin-manager/first-party-plugins.generated.d.ts +13 -0
  25. package/src/lib/plugin-manager/first-party-plugins.generated.js +257 -0
  26. package/src/lib/plugin-manager/first-party-plugins.generated.js.map +1 -1
  27. package/src/lib/plugin-manager/plugin-contact-capture.d.ts +16 -0
  28. package/src/lib/plugin-manager/plugin-contact-capture.js.map +1 -1
  29. package/src/lib/plugin-manager/plugin-host-collections.d.ts +27 -5
  30. package/src/lib/plugin-manager/plugin-host-collections.js +25 -16
  31. package/src/lib/plugin-manager/plugin-host-collections.js.map +1 -1
  32. package/src/lib/plugin-manager/plugin-org-capacity.d.ts +57 -0
  33. package/src/lib/plugin-manager/plugin-org-capacity.js +68 -0
  34. package/src/lib/plugin-manager/plugin-org-capacity.js.map +1 -0
  35. package/src/lib/plugin-manager/record-captured-contact.d.ts +89 -0
  36. package/src/lib/plugin-manager/record-captured-contact.js +128 -0
  37. package/src/lib/plugin-manager/record-captured-contact.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.147",
3
+ "version": "1.0.0-beta.149",
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.147",
37
- "@aglyn/shared-data-mdi": "1.0.0-beta.147",
38
- "@aglyn/shared-data-types": "1.0.0-beta.147",
39
- "@aglyn/shared-util-email": "1.0.0-beta.147",
40
- "@aglyn/shared-util-http": "1.0.0-beta.147",
41
- "@aglyn/shared-util-logger": "1.0.0-beta.147",
42
- "@aglyn/shared-util-timestamp": "1.0.0-beta.147",
43
- "@aglyn/shared-util-tools": "1.0.0-beta.147",
44
- "@aglyn/shared-util-vendor": "1.0.0-beta.147",
36
+ "@aglyn/shared-data-enums": "1.0.0-beta.149",
37
+ "@aglyn/shared-data-mdi": "1.0.0-beta.149",
38
+ "@aglyn/shared-data-types": "1.0.0-beta.149",
39
+ "@aglyn/shared-util-email": "1.0.0-beta.149",
40
+ "@aglyn/shared-util-http": "1.0.0-beta.149",
41
+ "@aglyn/shared-util-logger": "1.0.0-beta.149",
42
+ "@aglyn/shared-util-timestamp": "1.0.0-beta.149",
43
+ "@aglyn/shared-util-tools": "1.0.0-beta.149",
44
+ "@aglyn/shared-util-vendor": "1.0.0-beta.149",
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",
@@ -177,7 +177,11 @@ import { humanizeDatasetFieldId } from "./datasets.js";
177
177
  {
178
178
  token: '{{pagination.totalPages}}',
179
179
  label: 'Total pages',
180
- description: 'Pages in the listing, after any category filter.'
180
+ // A listing bigger than one read cannot be counted honestly (AGL-3219):
181
+ // the count and the entries were read at two different moments, and the
182
+ // pager built from them disagreed with itself at the page boundary. It
183
+ // still resolves, so a template binding it keeps rendering.
184
+ description: 'Pages in the listing, after any category filter. Empty on a listing too large to count — bind the next link instead.'
181
185
  },
182
186
  {
183
187
  token: '{{pagination.prevUrl}}',
@@ -187,7 +191,10 @@ import { humanizeDatasetFieldId } from "./datasets.js";
187
191
  {
188
192
  token: '{{pagination.nextUrl}}',
189
193
  label: 'Next page link',
190
- description: 'Keeps the category; empty on the last page.'
194
+ // The reliable "is there more" (AGL-3219): it is set from a read that
195
+ // asked for one entry more than the page needed, so empty means there was
196
+ // no such entry rather than that a total said so.
197
+ description: 'Keeps the category; empty when there is nothing older to show.'
191
198
  }
192
199
  ];
193
200
  /**
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/binding-token-catalog.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 DATASET_FIELD_TYPE_LABELS,\n type DatasetModel,\n} from './dataset-models'\nimport { humanizeDatasetFieldId } from './datasets'\n\n/**\n * Browsable data-placeholder catalogs for the designer's insert picker\n * (AGL-583). Hand-typing `{{entry.title}}` stays the advanced path; these\n * catalogs give every token a friendly label + description so editors can\n * browse and insert instead of memorizing the grammar.\n *\n * The token STRINGS are owned elsewhere — `collectionEntryTokens`\n * (collection-entries.ts) and the tenant compose pipeline resolve them —\n * this module only names them for pickers. The spec cross-checks the entry\n * catalog against the resolver so the two can never drift.\n */\nexport interface BindingTokenCatalogEntry {\n /** The literal token inserted into the prop, e.g. `{{entry.title}}`. */\n token: string\n /** Friendly picker label, e.g. `Title`. */\n label: string\n /** One-line description shown as the option's secondary text. */\n description?: string\n}\n\n/**\n * `{{entry.*}}` tokens (AGL-105/551/582) — resolve per entry inside a\n * Collection entries block and page-wide on entry-template screens.\n * Mirrors {@link collectionEntryTokens}; the spec enforces the mirror.\n */\nexport const ENTRY_TOKEN_CATALOG: readonly BindingTokenCatalogEntry[] = [\n { token: '{{entry.title}}', label: 'Title', description: 'The entry headline.' },\n {\n token: '{{entry.excerpt}}',\n label: 'Excerpt',\n description: 'Short summary text.',\n },\n {\n token: '{{entry.body}}',\n label: 'Body',\n description: 'The full entry body.',\n },\n {\n token: '{{entry.url}}',\n label: 'Link URL',\n description: 'Auto-route to the entry page.',\n },\n {\n token: '{{entry.date}}',\n label: 'Published date',\n description: 'Formatted publish date.',\n },\n /*\n Beside the readable date on purpose: a designer reaching for \"the date\" in\n a Video element's Publication date field has to see that the readable one\n is the wrong pick there, and why.\n */\n {\n token: '{{entry.publishedAt}}',\n label: 'Publish timestamp',\n description:\n 'The publish date and time in ISO 8601, for a field that needs a ' +\n 'machine-readable date, such as a Video element’s publication date.',\n },\n {\n token: '{{entry.author}}',\n label: 'Author',\n description: 'The byline set on the entry.',\n },\n {\n token: '{{entry.authorBio}}',\n label: 'Author bio',\n description: 'Blurb from the author’s record.',\n },\n {\n token: '{{entry.authorImage}}',\n label: 'Author portrait',\n description: 'Portrait or logo from the author’s record.',\n },\n {\n token: '{{entry.authorUrl}}',\n label: 'Author link',\n description: 'The author’s own page.',\n },\n {\n token: '{{entry.authorPageUrl}}',\n label: 'Author page',\n description:\n 'This author’s page on this site — everything they wrote, across every ' +\n 'collection. Separate from Author link, which is their own site.',\n },\n {\n token: '{{entry.slug}}',\n label: 'Slug',\n description: 'URL-safe entry identifier.',\n },\n /*\n Named \"Its collection…\" rather than \"Collection…\", which is what these\n describe and also exactly what the `{{collection.*}}` entries below are\n called. The picker groups options by heading, so the two sets sit apart —\n but a designer scanning it reads the LABEL, and two options reading\n \"Collection name\" three lines apart is a choice made by guessing. The\n pronoun is doing real work: on the one page where both resolve, they mean\n different collections.\n */\n {\n token: '{{entry.collection}}',\n label: 'Its collection',\n description:\n 'Which section this entry belongs to. Worth binding on a listing that ' +\n 'MIXES collections — an author page — where a card otherwise cannot ' +\n 'tell a release note from an essay.',\n },\n {\n token: '{{entry.collectionSlug}}',\n label: 'Its collection slug',\n description: 'That collection’s URL segment.',\n },\n {\n token: '{{entry.collectionUrl}}',\n label: 'Its collection link',\n description: 'The listing this entry belongs to.',\n },\n {\n token: '{{entry.coverImage}}',\n label: 'Cover image',\n description: 'Cover image URL.',\n },\n {\n token: '{{entry.coverVideo}}',\n label: 'Featured video',\n description:\n 'The featured video’s source — a library film, a video link or a ' +\n 'Wistia link — for a Video element.',\n },\n {\n token: '{{entry.category}}',\n label: 'Category',\n description: 'The entry category.',\n },\n {\n token: '{{entry.tags}}',\n label: 'Tags',\n description: 'Tags, comma separated.',\n },\n {\n token: '{{entry.seoTitle}}',\n label: 'SEO title',\n description: 'Search title; falls back to Title.',\n },\n {\n token: '{{entry.seoDescription}}',\n label: 'SEO description',\n description: 'Meta description; falls back to Excerpt.',\n },\n]\n\n/**\n * `{{collection.*}}` and `{{pagination.*}}` tokens (AGL-551/1321/1386) —\n * resolve on collection list/entry template screens (see the tenant compose\n * pipeline's collection tokens).\n *\n * The category and pagination tokens all resolve to the empty string where\n * they do not apply, so they are safe to bind unconditionally: ONE list\n * screen serves the bare listing, every `/page/{n}` and every\n * `/category/{slug}`, and a template has no runtime conditional to vary\n * itself with.\n */\nexport const COLLECTION_TOKEN_CATALOG: readonly BindingTokenCatalogEntry[] = [\n {\n token: '{{collection.name}}',\n label: 'Collection name',\n description: 'Display name of the routed collection.',\n },\n {\n token: '{{collection.slug}}',\n label: 'Collection slug',\n description: 'URL slug of the routed collection.',\n },\n {\n token: '{{collection.category}}',\n label: 'Filtered category',\n description: 'Category the URL filtered on; empty when unfiltered.',\n },\n {\n token: '{{collection.categorySlug}}',\n label: 'Filtered category slug',\n description: 'That category’s URL segment; empty when unfiltered.',\n },\n {\n token: '{{pagination.page}}',\n label: 'Current page',\n description: 'Page number this URL is showing.',\n },\n {\n token: '{{pagination.totalPages}}',\n label: 'Total pages',\n description: 'Pages in the listing, after any category filter.',\n },\n {\n token: '{{pagination.prevUrl}}',\n label: 'Previous page link',\n description: 'Keeps the category; empty on the first page.',\n },\n {\n token: '{{pagination.nextUrl}}',\n label: 'Next page link',\n description: 'Keeps the category; empty on the last page.',\n },\n]\n\n/**\n * `{{author.*}}` tokens (AGL-2518) — resolve on an author's own page,\n * `/author/{slug}`.\n *\n * Empty everywhere else, like the category tokens above and for the same\n * reason: a template has no runtime conditional, so the tokens have to be the\n * thing that varies. A heading bound to `{{author.name}}` prints a name on an\n * author page and nothing anywhere else.\n *\n * The `{{pagination.*}}` tokens resolve HERE TOO, over this author's own\n * archive — deliberately the same four names a collection listing uses, so a\n * pager built once works on both.\n */\nexport const AUTHOR_TOKEN_CATALOG: readonly BindingTokenCatalogEntry[] = [\n {\n token: '{{author.name}}',\n label: 'Name',\n description: 'The byline this page collects.',\n },\n {\n token: '{{author.bio}}',\n label: 'Bio',\n description: 'Their blurb, from the author record.',\n },\n {\n token: '{{author.image}}',\n label: 'Portrait',\n description: 'Their portrait or logo, from the author record.',\n },\n {\n token: '{{author.jobTitle}}',\n label: 'Role',\n description: 'Their job title; empty for an Organization author.',\n },\n {\n token: '{{author.worksFor}}',\n label: 'Organization',\n description: 'Who they write for; empty for an Organization author.',\n },\n {\n token: '{{author.url}}',\n label: 'Their own site',\n description:\n 'The url on their record — a personal site, not this page. Empty when ' +\n 'they have none.',\n },\n {\n token: '{{author.pageUrl}}',\n label: 'This page',\n description: 'The canonical address of the page you are designing.',\n },\n {\n token: '{{author.entryCount}}',\n label: 'Post count',\n description: 'How many entries they have published, across every collection.',\n },\n {\n token: '{{author.entryCountLabel}}',\n label: 'Post count, worded',\n description:\n '\"1 post\" or \"12 posts\" — pluralized for you, because a template has no ' +\n 'conditional to do it with.',\n },\n]\n\n/**\n * Renders an `{{item.*}}` token for a dataset field, optionally hopping\n * one reference to a field of the referenced record (AGL-180). Always\n * takes the stable model field ID — display names are labels only.\n */\nexport function datasetItemToken(\n fieldId: string,\n targetFieldId?: string,\n): string {\n return targetFieldId\n ? `{{item.${fieldId}.${targetFieldId}}}`\n : `{{item.${fieldId}}}`\n}\n\n/**\n * Dataset-item token entries for a repeatable container's model: one per\n * field in model order, labeled with the CURRENT display name while the\n * token carries the stable reference id (AGL-578 — renaming a label never\n * breaks bindings). Reference fields with a configured display field also\n * get their one-hop token (`{{item.author.name}}`).\n */\nexport function datasetItemTokens(\n model: DatasetModel,\n): BindingTokenCatalogEntry[] {\n const entries: BindingTokenCatalogEntry[] = []\n for (const fieldId of model.order ?? []) {\n const field = model.fields?.[fieldId]\n if (!field) continue\n const label = field.name?.trim() || humanizeDatasetFieldId(fieldId)\n entries.push({\n token: datasetItemToken(fieldId),\n label,\n description: DATASET_FIELD_TYPE_LABELS[field.type] ?? field.type,\n })\n // One reference hop (AGL-180): surface the configured display field of\n // the referenced record — the hop editors reach for most.\n const hopId =\n field.type === 'reference' ? field.reference?.displayFieldId : undefined\n if (hopId) {\n entries.push({\n token: datasetItemToken(fieldId, hopId),\n label: `${label} → ${humanizeDatasetFieldId(hopId)}`,\n description: 'Field from the referenced record.',\n })\n }\n }\n return entries\n}\n"],"names":["DATASET_FIELD_TYPE_LABELS","humanizeDatasetFieldId","ENTRY_TOKEN_CATALOG","token","label","description","COLLECTION_TOKEN_CATALOG","AUTHOR_TOKEN_CATALOG","datasetItemToken","fieldId","targetFieldId","datasetItemTokens","model","entries","order","field","fields","name","trim","push","type","hopId","reference","displayFieldId","undefined"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,yBAAyB,QAEpB,sBAAkB;AACzB,SAASC,sBAAsB,QAAQ,gBAAY;AAsBnD;;;;CAIC,GACD,OAAO,MAAMC,sBAA2D;IACtE;QAAEC,OAAO;QAAmBC,OAAO;QAASC,aAAa;IAAsB;IAC/E;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;;;;EAIA,GACA;QACEF,OAAO;QACPC,OAAO;QACPC,aACE,qEACA;IACJ;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aACE,2EACA;IACJ;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;;;;;;;;EAQA,GACA;QACEF,OAAO;QACPC,OAAO;QACPC,aACE,0EACA,wEACA;IACJ;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aACE,qEACA;IACJ;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;CACD,CAAA;AAED;;;;;;;;;;CAUC,GACD,OAAO,MAAMC,2BAAgE;IAC3E;QACEH,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;CACD,CAAA;AAED;;;;;;;;;;;;CAYC,GACD,OAAO,MAAME,uBAA4D;IACvE;QACEJ,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aACE,0EACA;IACJ;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aACE,4EACA;IACJ;CACD,CAAA;AAED;;;;CAIC,GACD,OAAO,SAASG,iBACdC,OAAe,EACfC,aAAsB;IAEtB,OAAOA,gBACH,CAAC,OAAO,EAAED,QAAQ,CAAC,EAAEC,cAAc,EAAE,CAAC,GACtC,CAAC,OAAO,EAAED,QAAQ,EAAE,CAAC;AAC3B;AAEA;;;;;;CAMC,GACD,OAAO,SAASE,kBACdC,KAAmB;QAGGA;IADtB,MAAMC,UAAsC,EAAE;IAC9C,KAAK,MAAMJ,YAAWG,eAAAA,MAAME,KAAK,YAAXF,eAAe,EAAE,CAAE;YAOxBZ;YANDY,eAEAG,aASiBA;QAX/B,MAAMA,SAAQH,gBAAAA,MAAMI,MAAM,qBAAZJ,aAAc,CAACH,QAAQ;QACrC,IAAI,CAACM,OAAO;QACZ,MAAMX,QAAQW,EAAAA,cAAAA,MAAME,IAAI,qBAAVF,YAAYG,IAAI,OAAMjB,uBAAuBQ;QAC3DI,QAAQM,IAAI,CAAC;YACXhB,OAAOK,iBAAiBC;YACxBL;YACAC,WAAW,GAAEL,wCAAAA,yBAAyB,CAACe,MAAMK,IAAI,CAAC,YAArCpB,wCAAyCe,MAAMK,IAAI;QAClE;QACA,uEAAuE;QACvE,0DAA0D;QAC1D,MAAMC,QACJN,MAAMK,IAAI,KAAK,eAAcL,mBAAAA,MAAMO,SAAS,qBAAfP,iBAAiBQ,cAAc,GAAGC;QACjE,IAAIH,OAAO;YACTR,QAAQM,IAAI,CAAC;gBACXhB,OAAOK,iBAAiBC,SAASY;gBACjCjB,OAAO,GAAGA,MAAM,GAAG,EAAEH,uBAAuBoB,QAAQ;gBACpDhB,aAAa;YACf;QACF;IACF;IACA,OAAOQ;AACT"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/binding-token-catalog.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 DATASET_FIELD_TYPE_LABELS,\n type DatasetModel,\n} from './dataset-models'\nimport { humanizeDatasetFieldId } from './datasets'\n\n/**\n * Browsable data-placeholder catalogs for the designer's insert picker\n * (AGL-583). Hand-typing `{{entry.title}}` stays the advanced path; these\n * catalogs give every token a friendly label + description so editors can\n * browse and insert instead of memorizing the grammar.\n *\n * The token STRINGS are owned elsewhere — `collectionEntryTokens`\n * (collection-entries.ts) and the tenant compose pipeline resolve them —\n * this module only names them for pickers. The spec cross-checks the entry\n * catalog against the resolver so the two can never drift.\n */\nexport interface BindingTokenCatalogEntry {\n /** The literal token inserted into the prop, e.g. `{{entry.title}}`. */\n token: string\n /** Friendly picker label, e.g. `Title`. */\n label: string\n /** One-line description shown as the option's secondary text. */\n description?: string\n}\n\n/**\n * `{{entry.*}}` tokens (AGL-105/551/582) — resolve per entry inside a\n * Collection entries block and page-wide on entry-template screens.\n * Mirrors {@link collectionEntryTokens}; the spec enforces the mirror.\n */\nexport const ENTRY_TOKEN_CATALOG: readonly BindingTokenCatalogEntry[] = [\n { token: '{{entry.title}}', label: 'Title', description: 'The entry headline.' },\n {\n token: '{{entry.excerpt}}',\n label: 'Excerpt',\n description: 'Short summary text.',\n },\n {\n token: '{{entry.body}}',\n label: 'Body',\n description: 'The full entry body.',\n },\n {\n token: '{{entry.url}}',\n label: 'Link URL',\n description: 'Auto-route to the entry page.',\n },\n {\n token: '{{entry.date}}',\n label: 'Published date',\n description: 'Formatted publish date.',\n },\n /*\n Beside the readable date on purpose: a designer reaching for \"the date\" in\n a Video element's Publication date field has to see that the readable one\n is the wrong pick there, and why.\n */\n {\n token: '{{entry.publishedAt}}',\n label: 'Publish timestamp',\n description:\n 'The publish date and time in ISO 8601, for a field that needs a ' +\n 'machine-readable date, such as a Video element’s publication date.',\n },\n {\n token: '{{entry.author}}',\n label: 'Author',\n description: 'The byline set on the entry.',\n },\n {\n token: '{{entry.authorBio}}',\n label: 'Author bio',\n description: 'Blurb from the author’s record.',\n },\n {\n token: '{{entry.authorImage}}',\n label: 'Author portrait',\n description: 'Portrait or logo from the author’s record.',\n },\n {\n token: '{{entry.authorUrl}}',\n label: 'Author link',\n description: 'The author’s own page.',\n },\n {\n token: '{{entry.authorPageUrl}}',\n label: 'Author page',\n description:\n 'This author’s page on this site — everything they wrote, across every ' +\n 'collection. Separate from Author link, which is their own site.',\n },\n {\n token: '{{entry.slug}}',\n label: 'Slug',\n description: 'URL-safe entry identifier.',\n },\n /*\n Named \"Its collection…\" rather than \"Collection…\", which is what these\n describe and also exactly what the `{{collection.*}}` entries below are\n called. The picker groups options by heading, so the two sets sit apart —\n but a designer scanning it reads the LABEL, and two options reading\n \"Collection name\" three lines apart is a choice made by guessing. The\n pronoun is doing real work: on the one page where both resolve, they mean\n different collections.\n */\n {\n token: '{{entry.collection}}',\n label: 'Its collection',\n description:\n 'Which section this entry belongs to. Worth binding on a listing that ' +\n 'MIXES collections — an author page — where a card otherwise cannot ' +\n 'tell a release note from an essay.',\n },\n {\n token: '{{entry.collectionSlug}}',\n label: 'Its collection slug',\n description: 'That collection’s URL segment.',\n },\n {\n token: '{{entry.collectionUrl}}',\n label: 'Its collection link',\n description: 'The listing this entry belongs to.',\n },\n {\n token: '{{entry.coverImage}}',\n label: 'Cover image',\n description: 'Cover image URL.',\n },\n {\n token: '{{entry.coverVideo}}',\n label: 'Featured video',\n description:\n 'The featured video’s source — a library film, a video link or a ' +\n 'Wistia link — for a Video element.',\n },\n {\n token: '{{entry.category}}',\n label: 'Category',\n description: 'The entry category.',\n },\n {\n token: '{{entry.tags}}',\n label: 'Tags',\n description: 'Tags, comma separated.',\n },\n {\n token: '{{entry.seoTitle}}',\n label: 'SEO title',\n description: 'Search title; falls back to Title.',\n },\n {\n token: '{{entry.seoDescription}}',\n label: 'SEO description',\n description: 'Meta description; falls back to Excerpt.',\n },\n]\n\n/**\n * `{{collection.*}}` and `{{pagination.*}}` tokens (AGL-551/1321/1386) —\n * resolve on collection list/entry template screens (see the tenant compose\n * pipeline's collection tokens).\n *\n * The category and pagination tokens all resolve to the empty string where\n * they do not apply, so they are safe to bind unconditionally: ONE list\n * screen serves the bare listing, every `/page/{n}` and every\n * `/category/{slug}`, and a template has no runtime conditional to vary\n * itself with.\n */\nexport const COLLECTION_TOKEN_CATALOG: readonly BindingTokenCatalogEntry[] = [\n {\n token: '{{collection.name}}',\n label: 'Collection name',\n description: 'Display name of the routed collection.',\n },\n {\n token: '{{collection.slug}}',\n label: 'Collection slug',\n description: 'URL slug of the routed collection.',\n },\n {\n token: '{{collection.category}}',\n label: 'Filtered category',\n description: 'Category the URL filtered on; empty when unfiltered.',\n },\n {\n token: '{{collection.categorySlug}}',\n label: 'Filtered category slug',\n description: 'That category’s URL segment; empty when unfiltered.',\n },\n {\n token: '{{pagination.page}}',\n label: 'Current page',\n description: 'Page number this URL is showing.',\n },\n {\n token: '{{pagination.totalPages}}',\n label: 'Total pages',\n // A listing bigger than one read cannot be counted honestly (AGL-3219):\n // the count and the entries were read at two different moments, and the\n // pager built from them disagreed with itself at the page boundary. It\n // still resolves, so a template binding it keeps rendering.\n description:\n 'Pages in the listing, after any category filter. Empty on a listing too large to count — bind the next link instead.',\n },\n {\n token: '{{pagination.prevUrl}}',\n label: 'Previous page link',\n description: 'Keeps the category; empty on the first page.',\n },\n {\n token: '{{pagination.nextUrl}}',\n label: 'Next page link',\n // The reliable \"is there more\" (AGL-3219): it is set from a read that\n // asked for one entry more than the page needed, so empty means there was\n // no such entry rather than that a total said so.\n description:\n 'Keeps the category; empty when there is nothing older to show.',\n },\n]\n\n/**\n * `{{author.*}}` tokens (AGL-2518) — resolve on an author's own page,\n * `/author/{slug}`.\n *\n * Empty everywhere else, like the category tokens above and for the same\n * reason: a template has no runtime conditional, so the tokens have to be the\n * thing that varies. A heading bound to `{{author.name}}` prints a name on an\n * author page and nothing anywhere else.\n *\n * The `{{pagination.*}}` tokens resolve HERE TOO, over this author's own\n * archive — deliberately the same four names a collection listing uses, so a\n * pager built once works on both.\n */\nexport const AUTHOR_TOKEN_CATALOG: readonly BindingTokenCatalogEntry[] = [\n {\n token: '{{author.name}}',\n label: 'Name',\n description: 'The byline this page collects.',\n },\n {\n token: '{{author.bio}}',\n label: 'Bio',\n description: 'Their blurb, from the author record.',\n },\n {\n token: '{{author.image}}',\n label: 'Portrait',\n description: 'Their portrait or logo, from the author record.',\n },\n {\n token: '{{author.jobTitle}}',\n label: 'Role',\n description: 'Their job title; empty for an Organization author.',\n },\n {\n token: '{{author.worksFor}}',\n label: 'Organization',\n description: 'Who they write for; empty for an Organization author.',\n },\n {\n token: '{{author.url}}',\n label: 'Their own site',\n description:\n 'The url on their record — a personal site, not this page. Empty when ' +\n 'they have none.',\n },\n {\n token: '{{author.pageUrl}}',\n label: 'This page',\n description: 'The canonical address of the page you are designing.',\n },\n {\n token: '{{author.entryCount}}',\n label: 'Post count',\n description: 'How many entries they have published, across every collection.',\n },\n {\n token: '{{author.entryCountLabel}}',\n label: 'Post count, worded',\n description:\n '\"1 post\" or \"12 posts\" — pluralized for you, because a template has no ' +\n 'conditional to do it with.',\n },\n]\n\n/**\n * Renders an `{{item.*}}` token for a dataset field, optionally hopping\n * one reference to a field of the referenced record (AGL-180). Always\n * takes the stable model field ID — display names are labels only.\n */\nexport function datasetItemToken(\n fieldId: string,\n targetFieldId?: string,\n): string {\n return targetFieldId\n ? `{{item.${fieldId}.${targetFieldId}}}`\n : `{{item.${fieldId}}}`\n}\n\n/**\n * Dataset-item token entries for a repeatable container's model: one per\n * field in model order, labeled with the CURRENT display name while the\n * token carries the stable reference id (AGL-578 — renaming a label never\n * breaks bindings). Reference fields with a configured display field also\n * get their one-hop token (`{{item.author.name}}`).\n */\nexport function datasetItemTokens(\n model: DatasetModel,\n): BindingTokenCatalogEntry[] {\n const entries: BindingTokenCatalogEntry[] = []\n for (const fieldId of model.order ?? []) {\n const field = model.fields?.[fieldId]\n if (!field) continue\n const label = field.name?.trim() || humanizeDatasetFieldId(fieldId)\n entries.push({\n token: datasetItemToken(fieldId),\n label,\n description: DATASET_FIELD_TYPE_LABELS[field.type] ?? field.type,\n })\n // One reference hop (AGL-180): surface the configured display field of\n // the referenced record — the hop editors reach for most.\n const hopId =\n field.type === 'reference' ? field.reference?.displayFieldId : undefined\n if (hopId) {\n entries.push({\n token: datasetItemToken(fieldId, hopId),\n label: `${label} → ${humanizeDatasetFieldId(hopId)}`,\n description: 'Field from the referenced record.',\n })\n }\n }\n return entries\n}\n"],"names":["DATASET_FIELD_TYPE_LABELS","humanizeDatasetFieldId","ENTRY_TOKEN_CATALOG","token","label","description","COLLECTION_TOKEN_CATALOG","AUTHOR_TOKEN_CATALOG","datasetItemToken","fieldId","targetFieldId","datasetItemTokens","model","entries","order","field","fields","name","trim","push","type","hopId","reference","displayFieldId","undefined"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,yBAAyB,QAEpB,sBAAkB;AACzB,SAASC,sBAAsB,QAAQ,gBAAY;AAsBnD;;;;CAIC,GACD,OAAO,MAAMC,sBAA2D;IACtE;QAAEC,OAAO;QAAmBC,OAAO;QAASC,aAAa;IAAsB;IAC/E;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;;;;EAIA,GACA;QACEF,OAAO;QACPC,OAAO;QACPC,aACE,qEACA;IACJ;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aACE,2EACA;IACJ;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;;;;;;;;EAQA,GACA;QACEF,OAAO;QACPC,OAAO;QACPC,aACE,0EACA,wEACA;IACJ;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aACE,qEACA;IACJ;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;CACD,CAAA;AAED;;;;;;;;;;CAUC,GACD,OAAO,MAAMC,2BAAgE;IAC3E;QACEH,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACP,wEAAwE;QACxE,wEAAwE;QACxE,uEAAuE;QACvE,4DAA4D;QAC5DC,aACE;IACJ;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACP,sEAAsE;QACtE,0EAA0E;QAC1E,kDAAkD;QAClDC,aACE;IACJ;CACD,CAAA;AAED;;;;;;;;;;;;CAYC,GACD,OAAO,MAAME,uBAA4D;IACvE;QACEJ,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aACE,0EACA;IACJ;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aACE,4EACA;IACJ;CACD,CAAA;AAED;;;;CAIC,GACD,OAAO,SAASG,iBACdC,OAAe,EACfC,aAAsB;IAEtB,OAAOA,gBACH,CAAC,OAAO,EAAED,QAAQ,CAAC,EAAEC,cAAc,EAAE,CAAC,GACtC,CAAC,OAAO,EAAED,QAAQ,EAAE,CAAC;AAC3B;AAEA;;;;;;CAMC,GACD,OAAO,SAASE,kBACdC,KAAmB;QAGGA;IADtB,MAAMC,UAAsC,EAAE;IAC9C,KAAK,MAAMJ,YAAWG,eAAAA,MAAME,KAAK,YAAXF,eAAe,EAAE,CAAE;YAOxBZ;YANDY,eAEAG,aASiBA;QAX/B,MAAMA,SAAQH,gBAAAA,MAAMI,MAAM,qBAAZJ,aAAc,CAACH,QAAQ;QACrC,IAAI,CAACM,OAAO;QACZ,MAAMX,QAAQW,EAAAA,cAAAA,MAAME,IAAI,qBAAVF,YAAYG,IAAI,OAAMjB,uBAAuBQ;QAC3DI,QAAQM,IAAI,CAAC;YACXhB,OAAOK,iBAAiBC;YACxBL;YACAC,WAAW,GAAEL,wCAAAA,yBAAyB,CAACe,MAAMK,IAAI,CAAC,YAArCpB,wCAAyCe,MAAMK,IAAI;QAClE;QACA,uEAAuE;QACvE,0DAA0D;QAC1D,MAAMC,QACJN,MAAMK,IAAI,KAAK,eAAcL,mBAAAA,MAAMO,SAAS,qBAAfP,iBAAiBQ,cAAc,GAAGC;QACjE,IAAIH,OAAO;YACTR,QAAQM,IAAI,CAAC;gBACXhB,OAAOK,iBAAiBC,SAASY;gBACjCjB,OAAO,GAAGA,MAAM,GAAG,EAAEH,uBAAuBoB,QAAQ;gBACpDhB,aAAa;YACf;QACF;IACF;IACA,OAAOQ;AACT"}
@@ -81,6 +81,24 @@ export declare const COLLECTION_SEARCH_COMPONENT_ID = "collectionSearch";
81
81
  */
82
82
  export declare const COLLECTION_PAGE_ROUTE_SEGMENT = "page";
83
83
  export declare const COLLECTION_CATEGORY_ROUTE_SEGMENT = "category";
84
+ /**
85
+ * `/{collection}/after/{cursor}` — the older page (AGL-3219).
86
+ *
87
+ * ## Why a PATH segment and not `?after=`
88
+ *
89
+ * The tenant catch-all is statically rendered and ISR-cached with the URL
90
+ * PATH as its key (`revalidate = 3600`, AGL-1152). A query string is not part
91
+ * of that key, so every cursor address would share ONE cache entry and serve
92
+ * whichever of them rendered first to everybody — the same reason a routed
93
+ * category is `/{collection}/category/{slug}` rather than `?category=`. And
94
+ * reading `searchParams` at all opts the whole catch-all out of static
95
+ * rendering, which is the opposite of what that page is for.
96
+ *
97
+ * A path segment is cached per address, crawlable, and costs nothing.
98
+ */
99
+ export declare const COLLECTION_AFTER_ROUTE_SEGMENT = "after";
100
+ /** `/{collection}/before/{cursor}` — the newer page (AGL-3219). */
101
+ export declare const COLLECTION_BEFORE_ROUTE_SEGMENT = "before";
84
102
  /** Namespaces cloned template ids per container/entry (cf. `rep__`). */
85
103
  export declare const COLLECTION_ENTRIES_NODE_ID_PREFIX = "centry__";
86
104
  /** Hard bound on entries a single block renders, before `entriesLimit`. */
@@ -475,16 +493,25 @@ export declare function collectionListUrl(options: {
475
493
  collectionSlug: string;
476
494
  categorySlug?: string | null;
477
495
  page?: number | null;
496
+ /** Continue after this entry's document id — the older direction. */
497
+ after?: string | null;
498
+ /** Continue before this entry's document id — the newer direction. */
499
+ before?: string | null;
478
500
  }): string;
479
501
  /** Where a listing's pager can go from here (AGL-1386). */
480
502
  export interface CollectionPaginationLinks {
481
503
  /** 1-based current page; 1 when the listing is unpaginated. */
482
504
  page: number;
483
- /** Total pages in the CURRENT (possibly category-filtered) set. */
505
+ /**
506
+ * Total pages in the CURRENT set.
507
+ *
508
+ * @deprecated AGL-3219 — 0 when it cannot be known, which is any collection
509
+ * past one read's bound. See `CollectionPagination.totalPages`.
510
+ */
484
511
  totalPages: number;
485
512
  /** The previous page's URL, or `''` on the first page. */
486
513
  prevUrl: string;
487
- /** The next page's URL, or `''` on the last page. */
514
+ /** The next page's URL, or `''` when nothing older follows. */
488
515
  nextUrl: string;
489
516
  }
490
517
  /**
@@ -511,8 +538,26 @@ export declare function collectionPaginationLinks(options: {
511
538
  categorySlug?: string | null;
512
539
  /** 1-based current page; anything unusable reads as page 1. */
513
540
  page?: number | null;
514
- /** Total pages; anything unusable reads as a single page. */
541
+ /**
542
+ * Total pages.
543
+ *
544
+ * @deprecated AGL-3219 — no longer consulted for whether a next link
545
+ * exists; {@link collectionPaginationLinks} asks `nextCursor` instead.
546
+ */
515
547
  totalPages?: number | null;
548
+ /**
549
+ * The document id the older page continues from, or `''` when there is no
550
+ * older page (AGL-3219).
551
+ *
552
+ * This is the ONLY thing that decides whether `nextUrl` is a link. It comes
553
+ * from a `limit(perPage + 1)` probe, so an empty string means the read
554
+ * looked for an eleventh entry and there was not one — which is a fact
555
+ * about the collection, unlike a total that two differently-cached reads
556
+ * could disagree about.
557
+ */
558
+ nextCursor?: string | null;
559
+ /** The document id the newer page continues before, or `''`. */
560
+ prevCursor?: string | null;
516
561
  }): CollectionPaginationLinks;
517
562
  /** A content-collection path resolved to what it addresses (AGL-1321). */
518
563
  export interface CollectionRoute {
@@ -523,6 +568,10 @@ export interface CollectionRoute {
523
568
  categorySlug?: string;
524
569
  /** 1-based list page; always 1 for entry routes. */
525
570
  page: number;
571
+ /** Set only for `/{collection}/after/{cursor}` (AGL-3219). */
572
+ after?: string;
573
+ /** Set only for `/{collection}/before/{cursor}` (AGL-3219). */
574
+ before?: string;
526
575
  }
527
576
  /**
528
577
  * Parses a non-screen path against the content-collection route shapes
@@ -72,6 +72,22 @@ export * from "./collection-entry-date.js";
72
72
  * slugged `page` or `category` keeps serving at `/{collection}/{entry}`.
73
73
  */ export const COLLECTION_PAGE_ROUTE_SEGMENT = 'page';
74
74
  export const COLLECTION_CATEGORY_ROUTE_SEGMENT = 'category';
75
+ /**
76
+ * `/{collection}/after/{cursor}` — the older page (AGL-3219).
77
+ *
78
+ * ## Why a PATH segment and not `?after=`
79
+ *
80
+ * The tenant catch-all is statically rendered and ISR-cached with the URL
81
+ * PATH as its key (`revalidate = 3600`, AGL-1152). A query string is not part
82
+ * of that key, so every cursor address would share ONE cache entry and serve
83
+ * whichever of them rendered first to everybody — the same reason a routed
84
+ * category is `/{collection}/category/{slug}` rather than `?category=`. And
85
+ * reading `searchParams` at all opts the whole catch-all out of static
86
+ * rendering, which is the opposite of what that page is for.
87
+ *
88
+ * A path segment is cached per address, crawlable, and costs nothing.
89
+ */ export const COLLECTION_AFTER_ROUTE_SEGMENT = 'after';
90
+ /** `/{collection}/before/{cursor}` — the newer page (AGL-3219). */ export const COLLECTION_BEFORE_ROUTE_SEGMENT = 'before';
75
91
  /** Namespaces cloned template ids per container/entry (cf. `rep__`). */ export const COLLECTION_ENTRIES_NODE_ID_PREFIX = 'centry__';
76
92
  /** Hard bound on entries a single block renders, before `entriesLimit`. */ export const COLLECTION_ENTRIES_MAX = 100;
77
93
  /**
@@ -446,7 +462,23 @@ categories) {
446
462
  * a second address for the page that already exists.
447
463
  */ export function collectionListUrl(options) {
448
464
  const scoped = options.categorySlug ? `/${options.collectionSlug}/${COLLECTION_CATEGORY_ROUTE_SEGMENT}/` + collectionCategorySlug(options.categorySlug) : `/${options.collectionSlug}`;
449
- const page = Number(options.page);
465
+ /*
466
+ * A cursor address (AGL-3219) wins over a page number when both are given,
467
+ * because it is the one that survives a publish at the head of the list.
468
+ */ const cursor = (options.before || options.after || '').trim();
469
+ if (cursor) {
470
+ const segment = options.before ? COLLECTION_BEFORE_ROUTE_SEGMENT : COLLECTION_AFTER_ROUTE_SEGMENT;
471
+ return `${scoped}/${segment}/${encodeURIComponent(cursor)}`;
472
+ }
473
+ /*
474
+ * `/{collection}/page/{n}` is still MINTED for a collection that fits
475
+ * inside one read, where it is exactly as stable as it ever was: every one
476
+ * of those pages is sliced out of a single cached snapshot, so no publish
477
+ * can move one page's entries relative to another's. It is past the bound —
478
+ * where the head and the tail were two different reads of a moving list —
479
+ * that a number stopped being an address, and there the pager mints
480
+ * cursors instead.
481
+ */ const page = Number(options.page);
450
482
  return Number.isFinite(page) && page > 1 ? `${scoped}/${COLLECTION_PAGE_ROUTE_SEGMENT}/${Math.floor(page)}` : scoped;
451
483
  }
452
484
  /**
@@ -468,24 +500,59 @@ categories) {
468
500
  * template binds. A second derivation is how a fallback and a template start
469
501
  * disagreeing about which page they are on.
470
502
  */ export function collectionPaginationLinks(options) {
503
+ var _options_nextCursor, _options_prevCursor;
471
504
  const positive = (value)=>{
472
505
  const parsed = Math.floor(Number(value));
473
506
  return Number.isFinite(parsed) && parsed > 0 ? parsed : 1;
474
507
  };
475
508
  const page = positive(options.page);
476
- const totalPages = positive(options.totalPages);
477
- const href = (n)=>collectionListUrl(_extends({
478
- collectionSlug: options.collectionSlug
479
- }, options.categorySlug ? {
480
- categorySlug: options.categorySlug
481
- } : {}, {
509
+ const totalPagesRaw = Math.floor(Number(options.totalPages));
510
+ /*
511
+ * Zero means UNKNOWN, and only a caller that deals in cursors can mean it
512
+ * (AGL-3219).
513
+ *
514
+ * A listing past the bound passes cursors and no total, and 0 is how it
515
+ * says the number cannot be had. A caller that passes neither — an entry
516
+ * route, which has no listing at all — is the inert case the tokens have
517
+ * always answered with "page 1 of 1", and it keeps that answer rather than
518
+ * telling an entry template it is on page 1 of 0.
519
+ */ const knowsCursors = options.nextCursor !== undefined || options.prevCursor !== undefined;
520
+ const totalPages = Number.isFinite(totalPagesRaw) && totalPagesRaw > 0 ? totalPagesRaw : knowsCursors ? 0 : 1;
521
+ const scope = _extends({
522
+ collectionSlug: options.collectionSlug
523
+ }, options.categorySlug ? {
524
+ categorySlug: options.categorySlug
525
+ } : {});
526
+ const next = ((_options_nextCursor = options.nextCursor) != null ? _options_nextCursor : '').trim();
527
+ const prev = ((_options_prevCursor = options.prevCursor) != null ? _options_prevCursor : '').trim();
528
+ /*
529
+ * Numbered while the whole set fits inside ONE read, cursors once it does
530
+ * not (AGL-3219).
531
+ *
532
+ * A total is only ever stated for a collection the shared read holds
533
+ * entirely, and there every page is sliced out of that one cached snapshot:
534
+ * no publish can move one page's entries relative to another's, so a number
535
+ * is a perfectly good address and stays the one readers and crawlers
536
+ * already have. Past the bound the head and the tail were two reads of a
537
+ * moving list, a number stopped naming the same ten entries from one render
538
+ * to the next, and the pager mints the cursor its own page proves instead.
539
+ */ const numbered = totalPages > 0;
540
+ const numberedHref = (n)=>collectionListUrl(_extends({}, scope, {
482
541
  page: n
483
542
  }));
484
543
  return {
485
544
  page,
486
545
  totalPages,
487
- prevUrl: page > 1 ? href(page - 1) : '',
488
- nextUrl: page < totalPages ? href(page + 1) : ''
546
+ /*
547
+ * Page 2 going back lands on the bare listing either way: it is the head
548
+ * of the collection, and `/{collection}` is its address — there is no
549
+ * cursor for "the beginning".
550
+ */ prevUrl: page === 2 ? collectionListUrl(scope) : page > 2 ? numbered ? numberedHref(page - 1) : prev ? collectionListUrl(_extends({}, scope, {
551
+ before: prev
552
+ })) : '' : '',
553
+ nextUrl: numbered ? page < totalPages ? numberedHref(page + 1) : '' : next ? collectionListUrl(_extends({}, scope, {
554
+ after: next
555
+ })) : ''
489
556
  };
490
557
  }
491
558
  const POSITIVE_INTEGER = /^[1-9]\d*$/;
@@ -525,6 +592,28 @@ const POSITIVE_INTEGER = /^[1-9]\d*$/;
525
592
  page: Number(rest[1])
526
593
  } : null;
527
594
  }
595
+ /*
596
+ * `/{collection}/after/{cursor}` and its `before` twin (AGL-3219).
597
+ *
598
+ * Checked BEFORE the category shape and after the one-segment entry shape,
599
+ * so an entry whose slug happens to be `after` still answers at
600
+ * `/{collection}/after` — that is one segment, and this is two.
601
+ *
602
+ * `page` stays 1: nothing counts a cursor address, and it is the display
603
+ * label rather than a position anything reads back.
604
+ */ if (rest.length === 2 && (rest[0] === COLLECTION_AFTER_ROUTE_SEGMENT || rest[0] === COLLECTION_BEFORE_ROUTE_SEGMENT) && rest[1]) {
605
+ const cursor = decodeURIComponent(rest[1]).trim();
606
+ if (!cursor) return null;
607
+ return rest[0] === COLLECTION_AFTER_ROUTE_SEGMENT ? {
608
+ collectionSlug,
609
+ after: cursor,
610
+ page: 1
611
+ } : {
612
+ collectionSlug,
613
+ before: cursor,
614
+ page: 1
615
+ };
616
+ }
528
617
  if (rest.length === 2 && rest[0] === COLLECTION_CATEGORY_ROUTE_SEGMENT) {
529
618
  const categorySlug = collectionCategorySlug(rest[1]);
530
619
  return categorySlug ? {