@aglyn/aglyn 1.0.0-beta.146 → 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 +95 -3
  5. package/src/lib/app-utils/collection-entries.js +146 -10
  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 +2 -2
  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.146",
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.146",
37
- "@aglyn/shared-data-mdi": "1.0.0-beta.146",
38
- "@aglyn/shared-data-types": "1.0.0-beta.146",
39
- "@aglyn/shared-util-email": "1.0.0-beta.146",
40
- "@aglyn/shared-util-http": "1.0.0-beta.146",
41
- "@aglyn/shared-util-logger": "1.0.0-beta.146",
42
- "@aglyn/shared-util-timestamp": "1.0.0-beta.146",
43
- "@aglyn/shared-util-tools": "1.0.0-beta.146",
44
- "@aglyn/shared-util-vendor": "1.0.0-beta.146",
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`. */
@@ -140,6 +158,38 @@ export declare const COLLECTION_LIST_PAGE_SIZE = 10;
140
158
  * empty collection still has a page 1 (AGL-620).
141
159
  */
142
160
  export declare function collectionTotalPages(total: number, perPage: number): number;
161
+ /**
162
+ * The slice of a listing's entries that the page named by `pagination`
163
+ * actually shows (AGL-3213).
164
+ *
165
+ * The loader hands the WHOLE narrowed set down to compose, because
166
+ * {@link expandCollectionEntries} does its own windowing off the block's
167
+ * props — a Collection entries block may carry its own `perPage`, its own
168
+ * pinned `page`, a `firstPageOnly` switch and its own filters, none of which
169
+ * this function can see. Slicing before compose would double-window and empty
170
+ * every page after the first.
171
+ *
172
+ * Everything the loader serializes into page PROPS is downstream of compose,
173
+ * and there the full set is not merely wasted bytes — it is a false
174
+ * statement. The `ItemList` JSON-LD numbers its items
175
+ * `(page - 1) * perPage + index + 1` on the premise that `entries` is the
176
+ * window; handed all one hundred, `/changelog/page/7` published
177
+ * `numberOfItems: 100` and positions 61 through 160, most of them past the
178
+ * end of the collection. The legacy fallback renderer read it the same way
179
+ * and rendered the whole list on every page.
180
+ *
181
+ * So: the loader keeps the full set until compose has had it, and narrows
182
+ * HERE on the way into props, where `pagination` is the route's own answer to
183
+ * which page this is.
184
+ *
185
+ * An unpaginated listing (an entry route, the RSS feed) passes no pagination
186
+ * and gets its entries back untouched.
187
+ */
188
+ export declare function collectionEntriesPageWindow<T>(entries: readonly T[], pagination: {
189
+ page?: number;
190
+ perPage?: number;
191
+ windowStart?: number;
192
+ } | null | undefined): T[];
143
193
  /** Default/most related posts a Related posts block renders (AGL-582). */
144
194
  export declare const COLLECTION_RELATED_DEFAULT_LIMIT = 3;
145
195
  export declare const COLLECTION_RELATED_MAX = 12;
@@ -334,6 +384,17 @@ export interface CollectionEntriesSource {
334
384
  * wins: that is a deliberately pinned window, not a paginated list.
335
385
  */
336
386
  page?: number;
387
+ /**
388
+ * The absolute index `entries[0]` holds in the collection's own order
389
+ * (AGL-3213). Zero everywhere except a listing page past the cached read's
390
+ * bound, which is served by a window read for that page alone.
391
+ *
392
+ * Without it a windowed source is silently empty: every consumer here
393
+ * slices `[(page - 1) * perPage, …)` on the premise that `entries` starts
394
+ * at the beginning of the collection, so page 11 of a windowed ten-entry
395
+ * source slices from 100 and renders nothing.
396
+ */
397
+ windowStart?: number;
337
398
  }
338
399
  /**
339
400
  * Display name of an entry's category (AGL-582): the `categoryId` lookup
@@ -432,16 +493,25 @@ export declare function collectionListUrl(options: {
432
493
  collectionSlug: string;
433
494
  categorySlug?: string | null;
434
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;
435
500
  }): string;
436
501
  /** Where a listing's pager can go from here (AGL-1386). */
437
502
  export interface CollectionPaginationLinks {
438
503
  /** 1-based current page; 1 when the listing is unpaginated. */
439
504
  page: number;
440
- /** 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
+ */
441
511
  totalPages: number;
442
512
  /** The previous page's URL, or `''` on the first page. */
443
513
  prevUrl: string;
444
- /** The next page's URL, or `''` on the last page. */
514
+ /** The next page's URL, or `''` when nothing older follows. */
445
515
  nextUrl: string;
446
516
  }
447
517
  /**
@@ -468,8 +538,26 @@ export declare function collectionPaginationLinks(options: {
468
538
  categorySlug?: string | null;
469
539
  /** 1-based current page; anything unusable reads as page 1. */
470
540
  page?: number | null;
471
- /** 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
+ */
472
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;
473
561
  }): CollectionPaginationLinks;
474
562
  /** A content-collection path resolved to what it addresses (AGL-1321). */
475
563
  export interface CollectionRoute {
@@ -480,6 +568,10 @@ export interface CollectionRoute {
480
568
  categorySlug?: string;
481
569
  /** 1-based list page; always 1 for entry routes. */
482
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;
483
575
  }
484
576
  /**
485
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
  /**
@@ -133,6 +149,48 @@ export const COLLECTION_CATEGORY_ROUTE_SEGMENT = 'category';
133
149
  if (perPage <= 0) return 1;
134
150
  return Math.max(1, Math.ceil(total / perPage));
135
151
  }
152
+ /**
153
+ * The slice of a listing's entries that the page named by `pagination`
154
+ * actually shows (AGL-3213).
155
+ *
156
+ * The loader hands the WHOLE narrowed set down to compose, because
157
+ * {@link expandCollectionEntries} does its own windowing off the block's
158
+ * props — a Collection entries block may carry its own `perPage`, its own
159
+ * pinned `page`, a `firstPageOnly` switch and its own filters, none of which
160
+ * this function can see. Slicing before compose would double-window and empty
161
+ * every page after the first.
162
+ *
163
+ * Everything the loader serializes into page PROPS is downstream of compose,
164
+ * and there the full set is not merely wasted bytes — it is a false
165
+ * statement. The `ItemList` JSON-LD numbers its items
166
+ * `(page - 1) * perPage + index + 1` on the premise that `entries` is the
167
+ * window; handed all one hundred, `/changelog/page/7` published
168
+ * `numberOfItems: 100` and positions 61 through 160, most of them past the
169
+ * end of the collection. The legacy fallback renderer read it the same way
170
+ * and rendered the whole list on every page.
171
+ *
172
+ * So: the loader keeps the full set until compose has had it, and narrows
173
+ * HERE on the way into props, where `pagination` is the route's own answer to
174
+ * which page this is.
175
+ *
176
+ * An unpaginated listing (an entry route, the RSS feed) passes no pagination
177
+ * and gets its entries back untouched.
178
+ */ export function collectionEntriesPageWindow(entries, pagination) {
179
+ const perPage = Number(pagination == null ? void 0 : pagination.perPage);
180
+ if (!Number.isFinite(perPage) || perPage <= 0) return [
181
+ ...entries
182
+ ];
183
+ const pageRaw = Number(pagination == null ? void 0 : pagination.page);
184
+ const page = Number.isFinite(pageRaw) && pageRaw >= 1 ? Math.floor(pageRaw) : 1;
185
+ // `windowStart` is where `entries` begins in the collection's own order
186
+ // (AGL-3213). A listing past the cached read's bound is served by a window
187
+ // read for that page alone, and slicing it from the page's absolute offset
188
+ // would return nothing.
189
+ const startRaw = Number(pagination == null ? void 0 : pagination.windowStart);
190
+ const windowStart = Number.isFinite(startRaw) && startRaw > 0 ? Math.floor(startRaw) : 0;
191
+ const start = Math.max(0, (page - 1) * Math.floor(perPage) - windowStart);
192
+ return entries.slice(start, start + Math.floor(perPage));
193
+ }
136
194
  /** Default/most related posts a Related posts block renders (AGL-582). */ export const COLLECTION_RELATED_DEFAULT_LIMIT = 3;
137
195
  export const COLLECTION_RELATED_MAX = 12;
138
196
  /** Most categories a collection's taxonomy holds (AGL-582). */ export const COLLECTION_CATEGORIES_MAX = 50;
@@ -404,7 +462,23 @@ categories) {
404
462
  * a second address for the page that already exists.
405
463
  */ export function collectionListUrl(options) {
406
464
  const scoped = options.categorySlug ? `/${options.collectionSlug}/${COLLECTION_CATEGORY_ROUTE_SEGMENT}/` + collectionCategorySlug(options.categorySlug) : `/${options.collectionSlug}`;
407
- 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);
408
482
  return Number.isFinite(page) && page > 1 ? `${scoped}/${COLLECTION_PAGE_ROUTE_SEGMENT}/${Math.floor(page)}` : scoped;
409
483
  }
410
484
  /**
@@ -426,24 +500,59 @@ categories) {
426
500
  * template binds. A second derivation is how a fallback and a template start
427
501
  * disagreeing about which page they are on.
428
502
  */ export function collectionPaginationLinks(options) {
503
+ var _options_nextCursor, _options_prevCursor;
429
504
  const positive = (value)=>{
430
505
  const parsed = Math.floor(Number(value));
431
506
  return Number.isFinite(parsed) && parsed > 0 ? parsed : 1;
432
507
  };
433
508
  const page = positive(options.page);
434
- const totalPages = positive(options.totalPages);
435
- const href = (n)=>collectionListUrl(_extends({
436
- collectionSlug: options.collectionSlug
437
- }, options.categorySlug ? {
438
- categorySlug: options.categorySlug
439
- } : {}, {
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, {
440
541
  page: n
441
542
  }));
442
543
  return {
443
544
  page,
444
545
  totalPages,
445
- prevUrl: page > 1 ? href(page - 1) : '',
446
- 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
+ })) : ''
447
556
  };
448
557
  }
449
558
  const POSITIVE_INTEGER = /^[1-9]\d*$/;
@@ -483,6 +592,28 @@ const POSITIVE_INTEGER = /^[1-9]\d*$/;
483
592
  page: Number(rest[1])
484
593
  } : null;
485
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
+ }
486
617
  if (rest.length === 2 && rest[0] === COLLECTION_CATEGORY_ROUTE_SEGMENT) {
487
618
  const categorySlug = collectionCategorySlug(rest[1]);
488
619
  return categorySlug ? {
@@ -779,7 +910,12 @@ const POSITIVE_INTEGER = /^[1-9]\d*$/;
779
910
  // counting it, because the count cannot see a read that came back full
780
911
  // and was then thinned by the liveness gate.
781
912
  const sourceCapped = collectionSourceIsBounded(source);
782
- const windowed = suppressedBeyondFirstPage ? [] : perPage ? filtered.slice((page - 1) * perPage, (page - 1) * perPage + perPage) : filtered.slice(0, limit);
913
+ // Where this source's first entry sits in the collection (AGL-3213):
914
+ // zero for a whole read, and the page's own offset for a windowed one.
915
+ const windowStartRaw = Number(source.windowStart);
916
+ const windowStart = Number.isFinite(windowStartRaw) && windowStartRaw > 0 ? Math.floor(windowStartRaw) : 0;
917
+ const pageStart = perPage ? Math.max(0, (page - 1) * perPage - windowStart) : 0;
918
+ const windowed = suppressedBeyondFirstPage ? [] : perPage ? filtered.slice(pageStart, pageStart + perPage) : filtered.slice(0, limit);
783
919
  const childIds = [];
784
920
  windowed.forEach((entry, index)=>{
785
921
  const prefix = `${COLLECTION_ENTRIES_NODE_ID_PREFIX}${containerId}__${index}__`;