@caelo-cms/shared 0.11.3 → 0.11.4

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 (68) hide show
  1. package/dist/ai-tools.d.ts +8 -0
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/build-page.d.ts +16 -0
  4. package/dist/build-page.d.ts.map +1 -1
  5. package/dist/content.d.ts +24 -0
  6. package/dist/content.d.ts.map +1 -1
  7. package/dist/content.js +40 -0
  8. package/dist/content.js.map +1 -1
  9. package/dist/lists/index.d.ts +2 -0
  10. package/dist/lists/index.d.ts.map +1 -1
  11. package/dist/lists/index.js +2 -0
  12. package/dist/lists/index.js.map +1 -1
  13. package/dist/lists/list-type.d.ts +4 -1
  14. package/dist/lists/list-type.d.ts.map +1 -1
  15. package/dist/lists/list-type.js +2 -0
  16. package/dist/lists/list-type.js.map +1 -1
  17. package/dist/lists/paginated-render.d.ts +38 -0
  18. package/dist/lists/paginated-render.d.ts.map +1 -0
  19. package/dist/lists/paginated-render.js +45 -0
  20. package/dist/lists/paginated-render.js.map +1 -0
  21. package/dist/lists/pagination.d.ts +95 -0
  22. package/dist/lists/pagination.d.ts.map +1 -0
  23. package/dist/lists/pagination.js +136 -0
  24. package/dist/lists/pagination.js.map +1 -0
  25. package/dist/lists/resolve.d.ts +65 -0
  26. package/dist/lists/resolve.d.ts.map +1 -1
  27. package/dist/lists/resolve.js +166 -20
  28. package/dist/lists/resolve.js.map +1 -1
  29. package/dist/live-events.d.ts.map +1 -1
  30. package/dist/live-events.js +12 -1
  31. package/dist/live-events.js.map +1 -1
  32. package/dist/markup-structure.d.ts +25 -0
  33. package/dist/markup-structure.d.ts.map +1 -1
  34. package/dist/markup-structure.js +88 -1
  35. package/dist/markup-structure.js.map +1 -1
  36. package/dist/media.d.ts +13 -0
  37. package/dist/media.d.ts.map +1 -1
  38. package/dist/media.js +40 -0
  39. package/dist/media.js.map +1 -1
  40. package/dist/nested-module-render.d.ts +13 -0
  41. package/dist/nested-module-render.d.ts.map +1 -1
  42. package/dist/nested-module-render.js +22 -1
  43. package/dist/nested-module-render.js.map +1 -1
  44. package/dist/template-engine.d.ts +4 -1
  45. package/dist/template-engine.d.ts.map +1 -1
  46. package/dist/template-engine.js +6 -4
  47. package/dist/template-engine.js.map +1 -1
  48. package/dist/template-sections.d.ts.map +1 -1
  49. package/dist/template-sections.js +1 -0
  50. package/dist/template-sections.js.map +1 -1
  51. package/dist/version.d.ts +2 -2
  52. package/dist/version.js +1 -1
  53. package/package.json +1 -1
  54. package/src/content.ts +43 -0
  55. package/src/lists/index.ts +2 -0
  56. package/src/lists/list-type.ts +2 -0
  57. package/src/lists/paginated-render.ts +77 -0
  58. package/src/lists/pagination.test.ts +422 -0
  59. package/src/lists/pagination.ts +160 -0
  60. package/src/lists/resolve.ts +254 -22
  61. package/src/live-events.ts +12 -1
  62. package/src/markup-structure.test.ts +27 -0
  63. package/src/markup-structure.ts +96 -1
  64. package/src/media.ts +43 -0
  65. package/src/nested-module-render.ts +25 -1
  66. package/src/template-engine.ts +9 -4
  67. package/src/template-sections.ts +1 -0
  68. package/src/version.ts +1 -1
@@ -0,0 +1,422 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * #1257 — paginated page lists (`docs/lists.md` §6 "Pagination"): the URL
5
+ * shape of follow-up pages, the settings, the resolver's slice and pager,
6
+ * the render passes, and that a cut list is never silent.
7
+ */
8
+
9
+ import { describe, expect, it } from "bun:test";
10
+ import { moduleUpdateSchema } from "../content.js";
11
+ import { createSiteLinkIndex } from "../link-refs.js";
12
+ import type { PageUrlStyle } from "../seo.js";
13
+ import { renderTemplate, type TemplateField } from "../template-engine.js";
14
+ import { DEFAULT_LIST_TYPES } from "./default-types.js";
15
+ import { mergeListRenderDeps, renderEveryPage, renderFirstPage } from "./paginated-render.js";
16
+ import {
17
+ collidesWithFollowUps,
18
+ effectivePagination,
19
+ pageCountOf,
20
+ pagedPath,
21
+ pagedUrl,
22
+ parsePagedPath,
23
+ splitPaginationValue,
24
+ } from "./pagination.js";
25
+ import type { ListPageRecord, ListRenderData } from "./render-data.js";
26
+ import {
27
+ createListRenderContext,
28
+ type ListFieldSpec,
29
+ type ListPageContext,
30
+ paginationMarkers,
31
+ } from "./resolve.js";
32
+ import type { ScopeEntry } from "./scope.js";
33
+
34
+ const id = (n: number): string => `00000000-0000-4000-8000-${String(n).padStart(12, "0")}`;
35
+ const HUB = id(1);
36
+ const TAG = `caelo:term/${id(900)}/${id(901)}`;
37
+
38
+ /** A guides hub at /guides and `n` published guides titled "Guide 001"…. */
39
+ function site(n: number): { data: ListRenderData; paths: Map<string, string> } {
40
+ const record = (pageId: string, title: string, extra: Partial<ListPageRecord> = {}) =>
41
+ ({
42
+ id: pageId,
43
+ title,
44
+ shortTitle: title,
45
+ parentId: null,
46
+ pageTypeSlug: "page",
47
+ properties: {},
48
+ terms: [],
49
+ firstPublishedAt: "2026-10-01T00:00:00.000Z",
50
+ updatedAt: "2026-10-01T00:00:00.000Z",
51
+ summary: null,
52
+ image: null,
53
+ inBuild: true,
54
+ ...extra,
55
+ }) satisfies ListPageRecord;
56
+ const pages: ListPageRecord[] = [record(HUB, "Guides")];
57
+ const paths = new Map<string, string>([[HUB, "/guides"]]);
58
+ for (let i = 1; i <= n; i += 1) {
59
+ const pid = id(100 + i);
60
+ const title = `Guide ${String(i).padStart(3, "0")}`;
61
+ pages.push(record(pid, title, { pageTypeSlug: "guide", terms: [TAG] }));
62
+ paths.set(pid, `/guides/g${i}`);
63
+ }
64
+ return {
65
+ paths,
66
+ data: {
67
+ listTypes: new Map(DEFAULT_LIST_TYPES.map((t, i) => [t.slug, { id: id(600 + i), def: t }])),
68
+ lists: new Map(),
69
+ pageTypes: new Map(),
70
+ pages: new Map(pages.map((p) => [p.id, p])),
71
+ media: new Map(),
72
+ siteLanguage: "en",
73
+ homePageId: null,
74
+ },
75
+ };
76
+ }
77
+
78
+ function ctxFor(
79
+ n: number,
80
+ opts: { page?: number; pageCount?: number; style?: PageUrlStyle } = {},
81
+ ): ListPageContext {
82
+ const { data, paths } = site(n);
83
+ const style = opts.style ?? "directory";
84
+ const index = createSiteLinkIndex({
85
+ pages: [...paths].map(([pid, path]) => ({ id: pid, path, status: "published" as const })),
86
+ media: [],
87
+ env: "preview",
88
+ pageUrlStyle: style,
89
+ });
90
+ return createListRenderContext(data).forPage(HUB, {
91
+ linkResolver: index.forPage(HUB),
92
+ pageUrlStyle: style,
93
+ ...(opts.page !== undefined
94
+ ? {
95
+ pagination: { page: opts.page, ...(opts.pageCount ? { pageCount: opts.pageCount } : {}) },
96
+ }
97
+ : {}),
98
+ });
99
+ }
100
+
101
+ const GUIDES: ListFieldSpec = {
102
+ name: "guides",
103
+ listType: "links",
104
+ bind: "source",
105
+ source: { kind: "query", editable: [], defaults: { page_type: "guide", order: "title_asc" } },
106
+ paginate: {},
107
+ };
108
+ const PAGER: ListFieldSpec = {
109
+ name: "pager",
110
+ listType: "links",
111
+ bind: "source",
112
+ source: { kind: "system", name: "pagination" },
113
+ };
114
+
115
+ function resolve(ctx: ListPageContext, field: ListFieldSpec, value?: unknown): ScopeEntry[] {
116
+ const r = ctx.resolveField(field, value, { contentInstanceId: null });
117
+ if (!r.ok) throw new Error(r.marker);
118
+ return [...r.entries];
119
+ }
120
+ const text = (e: ScopeEntry, key: string): string => {
121
+ const v = e.get(key);
122
+ if (v?.kind === "text") return v.text;
123
+ if (v?.kind === "record") return v.text ?? "";
124
+ if (v?.kind === "bool") return String(v.value);
125
+ throw new Error(`${key}: ${v?.kind}`);
126
+ };
127
+
128
+ describe("follow-up URLs", () => {
129
+ it("puts page n under the overview's path, never page 1", () => {
130
+ expect(pagedPath("/guides", 2)).toBe("/guides/page/2");
131
+ expect(pagedPath("/", 3)).toBe("/page/3");
132
+ expect(() => pagedPath("/guides", 1)).toThrow();
133
+ });
134
+
135
+ it("follows the target's URL style and drops query and fragment", () => {
136
+ expect(pagedUrl("/guides/", 2, "directory")).toBe("/guides/page/2/");
137
+ expect(pagedUrl("/guides", 2, "no-extension")).toBe("/guides/page/2");
138
+ expect(pagedUrl("https://example.com/", 4, "directory")).toBe("https://example.com/page/4/");
139
+ expect(pagedUrl("/guides/?ref=nav#top", 2, "directory")).toBe("/guides/page/2/");
140
+ });
141
+
142
+ it("parses a requested follow-up path; /page/1 is no follow-up page", () => {
143
+ expect(parsePagedPath("/guides/page/3/")).toEqual({ basePath: "/guides", page: 3 });
144
+ expect(parsePagedPath("/page/2")).toEqual({ basePath: "/", page: 2 });
145
+ expect(parsePagedPath("/guides/page/1")).toBeNull();
146
+ expect(parsePagedPath("/guides/pages/2")).toBeNull();
147
+ expect(parsePagedPath("/guides")).toBeNull();
148
+ });
149
+
150
+ it("detects a page sitting where follow-up pages go", () => {
151
+ expect(collidesWithFollowUps("/guides", "/guides/page")).toBe(true);
152
+ expect(collidesWithFollowUps("/guides", "/guides/page/2")).toBe(true);
153
+ expect(collidesWithFollowUps("/", "/page")).toBe(true);
154
+ expect(collidesWithFollowUps("/guides", "/guides/pager")).toBe(false);
155
+ expect(collidesWithFollowUps("/guides", "/guides/g1")).toBe(false);
156
+ });
157
+ });
158
+
159
+ describe("settings", () => {
160
+ it("defaults to 50 per page, indexable; a placement overrides the field", () => {
161
+ expect(effectivePagination({}, undefined)).toEqual({ perPage: 50, followUps: "index" });
162
+ expect(effectivePagination({ per_page: 20 }, { follow_ups: "noindex" })).toEqual({
163
+ perPage: 20,
164
+ followUps: "noindex",
165
+ });
166
+ expect(effectivePagination({}, { per_page: 51 })).toBeNull();
167
+ });
168
+
169
+ it("splits the placement's paginate from the source's knobs", () => {
170
+ expect(splitPaginationValue({ terms: ["x"], paginate: { per_page: 5 } })).toEqual({
171
+ paginate: { per_page: 5 },
172
+ rest: { terms: ["x"] },
173
+ });
174
+ expect(splitPaginationValue(undefined)).toEqual({ paginate: undefined, rest: undefined });
175
+ });
176
+
177
+ it("page count is at least one", () => {
178
+ expect(pageCountOf(0, 50)).toBe(1);
179
+ expect(pageCountOf(50, 50)).toBe(1);
180
+ expect(pageCountOf(51, 50)).toBe(2);
181
+ });
182
+ });
183
+
184
+ describe("module field schema", () => {
185
+ const parseFields = (fields: unknown[]) =>
186
+ moduleUpdateSchema.safeParse({ moduleId: id(9), fields });
187
+ const field = (extra: Record<string, unknown>) => ({
188
+ name: "guides",
189
+ label: "Guides",
190
+ kind: "list",
191
+ listType: "links",
192
+ bind: "source",
193
+ source: { kind: "query", editable: [], defaults: { page_type: "guide", order: "title_asc" } },
194
+ ...extra,
195
+ });
196
+
197
+ it("accepts a paginated query and term_pages", () => {
198
+ expect(parseFields([field({ paginate: { per_page: 20 } })]).success).toBe(true);
199
+ expect(
200
+ parseFields([field({ source: { kind: "system", name: "term_pages" }, paginate: {} })])
201
+ .success,
202
+ ).toBe(true);
203
+ });
204
+
205
+ it("refuses paginate on a query with a limit, a stored list, or twice per module", () => {
206
+ const withLimit = parseFields([
207
+ field({
208
+ source: {
209
+ kind: "query",
210
+ editable: [],
211
+ defaults: { page_type: "guide", order: "title_asc", limit: 10 },
212
+ },
213
+ paginate: {},
214
+ }),
215
+ ]);
216
+ expect(withLimit.success).toBe(false);
217
+ expect(JSON.stringify(withLimit.error?.issues)).toContain("paginate.per_page");
218
+ const local = parseFields([
219
+ { name: "items", label: "Items", kind: "list", listType: "links", paginate: {} },
220
+ ]);
221
+ expect(local.success).toBe(false);
222
+ const twice = parseFields([field({ paginate: {} }), field({ name: "more", paginate: {} })]);
223
+ expect(JSON.stringify(twice.error?.issues)).toContain("only one paginated list field");
224
+ expect(parseFields([field({ paginate: { per_page: 51 } })]).success).toBe(false);
225
+ });
226
+ });
227
+
228
+ describe("resolver", () => {
229
+ it("shows 50 per page by default and records the sequence", () => {
230
+ const ctx = ctxFor(63);
231
+ const page1 = resolve(ctx, GUIDES);
232
+ expect(page1).toHaveLength(50);
233
+ expect(text(page1[0] as ScopeEntry, "title")).toBe("Guide 001");
234
+ expect(ctx.pagination()).toMatchObject({
235
+ field: "guides",
236
+ perPage: 50,
237
+ total: 63,
238
+ pageCount: 2,
239
+ page: 1,
240
+ followUps: "index",
241
+ });
242
+ expect(ctx.truncatedLists()).toEqual([]);
243
+ });
244
+
245
+ it("shows the requested page's slice with a custom page size", () => {
246
+ const ctx = ctxFor(63, { page: 3, pageCount: 4 });
247
+ const got = resolve(ctx, { ...GUIDES, paginate: { per_page: 20 } });
248
+ expect(got.map((e) => text(e, "title"))).toEqual(
249
+ Array.from({ length: 20 }, (_, i) => `Guide ${String(41 + i).padStart(3, "0")}`),
250
+ );
251
+ expect(ctx.pagination()?.pageCount).toBe(4);
252
+ });
253
+
254
+ it("takes the placement's paginate over the field's", () => {
255
+ const ctx = ctxFor(63);
256
+ resolve(ctx, GUIDES, { paginate: { per_page: 10, follow_ups: "noindex" } });
257
+ expect(ctx.pagination()).toMatchObject({ perPage: 10, pageCount: 7, followUps: "noindex" });
258
+ });
259
+
260
+ it("paginates term_pages", () => {
261
+ const ctx = ctxFor(55);
262
+ const field: ListFieldSpec = {
263
+ ...GUIDES,
264
+ source: { kind: "system", name: "term_pages" },
265
+ paginate: { per_page: 25 },
266
+ };
267
+ expect(resolve(ctx, field, { terms: [TAG] })).toHaveLength(25);
268
+ expect(ctx.pagination()).toMatchObject({ total: 55, pageCount: 3 });
269
+ });
270
+
271
+ it("refuses a second, different paginated list on the page", () => {
272
+ const ctx = ctxFor(10);
273
+ resolve(ctx, GUIDES);
274
+ const other = ctx.resolveField(
275
+ { ...GUIDES, name: "more", paginate: { per_page: 3 } },
276
+ undefined,
277
+ { contentInstanceId: null },
278
+ );
279
+ expect(other).toEqual({ ok: false, marker: "list-pagination-conflict:more" });
280
+ // The same list again (a second section over it) is fine.
281
+ expect(resolve(ctx, GUIDES)).toHaveLength(10);
282
+ });
283
+
284
+ it("the pager links every page in the target's style and marks prev / current / next", () => {
285
+ const ctx = ctxFor(63, { page: 2, pageCount: 3, style: "no-extension" });
286
+ resolve(ctx, { ...GUIDES, paginate: { per_page: 25 } });
287
+ const pager = resolve(ctx, PAGER);
288
+ expect(pager.map((e) => text(e, "number"))).toEqual(["1", "2", "3"]);
289
+ expect(pager.map((e) => text(e, "link"))).toEqual([
290
+ "/guides",
291
+ "/guides/page/2",
292
+ "/guides/page/3",
293
+ ]);
294
+ expect(pager.map((e) => text(e, "is_prev"))).toEqual(["true", "false", "false"]);
295
+ expect(pager.map((e) => text(e, "is_current"))).toEqual(["false", "true", "false"]);
296
+ expect(pager.map((e) => text(e, "is_next"))).toEqual(["false", "false", "true"]);
297
+ expect(ctx.pagination()?.pagerShown).toBe(true);
298
+ });
299
+
300
+ it("the pager is empty while everything fits on one page", () => {
301
+ const ctx = ctxFor(5);
302
+ resolve(ctx, GUIDES);
303
+ expect(resolve(ctx, PAGER)).toEqual([]);
304
+ expect(paginationMarkers(ctx.pagination())).toEqual([]);
305
+ });
306
+
307
+ it("a pager before the list asks for a second pass", () => {
308
+ const ctx = ctxFor(63);
309
+ expect(resolve(ctx, PAGER)).toEqual([]);
310
+ resolve(ctx, GUIDES);
311
+ expect(ctx.pagination()).toMatchObject({ pagerEarly: true, pageCount: 2 });
312
+ });
313
+
314
+ it("a paginated page without a pager is flagged", () => {
315
+ const ctx = ctxFor(63);
316
+ resolve(ctx, GUIDES);
317
+ expect(paginationMarkers(ctx.pagination())).toEqual(["list-pager-missing:guides"]);
318
+ });
319
+
320
+ it("a pager without the target's URL style is loud", () => {
321
+ const { data, paths } = site(63);
322
+ const index = createSiteLinkIndex({
323
+ pages: [...paths].map(([pid, path]) => ({ id: pid, path, status: "published" as const })),
324
+ media: [],
325
+ env: "preview",
326
+ pageUrlStyle: "directory",
327
+ });
328
+ const ctx = createListRenderContext(data).forPage(HUB, { linkResolver: index.forPage(HUB) });
329
+ resolve(ctx, GUIDES);
330
+ expect(ctx.resolveField(PAGER, undefined, { contentInstanceId: null })).toEqual({
331
+ ok: false,
332
+ marker: "list-context-unavailable:pagination",
333
+ });
334
+ });
335
+ });
336
+
337
+ describe("cut lists are reported, never silent", () => {
338
+ it("records a query cut at its limit", () => {
339
+ const ctx = ctxFor(63);
340
+ const q: ListFieldSpec = {
341
+ ...GUIDES,
342
+ paginate: undefined,
343
+ source: {
344
+ kind: "query",
345
+ editable: [],
346
+ defaults: { page_type: "guide", order: "title_asc", limit: 50 },
347
+ },
348
+ };
349
+ expect(resolve(ctx, q)).toHaveLength(50);
350
+ expect(ctx.truncatedLists()).toEqual([{ field: "guides", shown: 50, total: 63, limit: 50 }]);
351
+ expect(ctx.pagination()).toBeNull();
352
+ });
353
+
354
+ it("records term_pages cut at 50", () => {
355
+ const ctx = ctxFor(60);
356
+ const field: ListFieldSpec = {
357
+ name: "tagged",
358
+ listType: "links",
359
+ bind: "source",
360
+ source: { kind: "system", name: "term_pages" },
361
+ };
362
+ expect(resolve(ctx, field, { terms: [TAG] })).toHaveLength(50);
363
+ expect(ctx.truncatedLists()).toEqual([{ field: "tagged", shown: 50, total: 60, limit: 50 }]);
364
+ });
365
+
366
+ it("records nothing when everything fits", () => {
367
+ const ctx = ctxFor(3);
368
+ resolve(ctx, {
369
+ ...GUIDES,
370
+ paginate: undefined,
371
+ source: {
372
+ kind: "query",
373
+ editable: [],
374
+ defaults: { page_type: "guide", order: "title_asc", limit: 5 },
375
+ },
376
+ });
377
+ expect(ctx.truncatedLists()).toEqual([]);
378
+ });
379
+ });
380
+
381
+ describe("render passes (engine)", () => {
382
+ const fields: TemplateField[] = [
383
+ { name: "pager", kind: "list", listType: "links", bind: "source", source: PAGER.source },
384
+ {
385
+ name: "guides",
386
+ kind: "list",
387
+ listType: "links",
388
+ bind: "source",
389
+ source: GUIDES.source,
390
+ paginate: { per_page: 30 },
391
+ },
392
+ ];
393
+ // The pager ABOVE the list: page 1 needs the second pass.
394
+ const html =
395
+ '<nav>{{#pager}}{{#is_current}}<b>{{number}}</b>{{/is_current}}{{^is_current}}<a href="{{link}}">{{number}}</a>{{/is_current}}{{/pager}}</nav>' +
396
+ "<ul>{{#guides}}<li>{{title}}</li>{{/guides}}</ul>";
397
+ const render = (pagination?: { page: number; pageCount: number }) => {
398
+ const ctx = ctxFor(63, pagination ?? {});
399
+ const out = renderTemplate({ html, fields, lists: ctx });
400
+ return { lists: ctx, html: out.html, missing: out.missingSlots };
401
+ };
402
+
403
+ it("renders page 1 twice when the pager comes first, with a complete pager", () => {
404
+ const first = renderFirstPage(render);
405
+ expect(first.missing).toEqual([]);
406
+ expect(first.html).toContain(
407
+ '<b>1</b><a href="/guides/page/2/">2</a><a href="/guides/page/3/">3</a>',
408
+ );
409
+ expect((first.html.match(/<li>/g) ?? []).length).toBe(30);
410
+ });
411
+
412
+ it("renders every page of the sequence with its slice", () => {
413
+ const pages = renderEveryPage(render);
414
+ expect(pages).toHaveLength(3);
415
+ expect(pages[2]?.html).toContain('<a href="/guides/">1</a>');
416
+ expect(pages[2]?.html).toContain("<b>3</b>");
417
+ expect((pages[2]?.html.match(/<li>/g) ?? []).length).toBe(3);
418
+ expect(pages[2]?.html).toContain("<li>Guide 063</li>");
419
+ const deps = mergeListRenderDeps(pages.map((p) => p.lists.deps()));
420
+ expect(deps.queries).toEqual([{ pageType: "guide", terms: [], parent: null }]);
421
+ });
422
+ });
@@ -0,0 +1,160 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Paginated page lists (#1257, `docs/lists.md` §6 "Pagination"). An
5
+ * overview page (a guides hub, a category landing page) lists every page
6
+ * a query matches: its paginated `list` field shows `per_page` entries,
7
+ * page 1 at the page's own URL and every further page at
8
+ * `<page path>/page/<n>/`. The static generator and the editor preview
9
+ * emit those follow-up pages; the module draws its own pager from the
10
+ * `pagination` system source.
11
+ *
12
+ * Decisions (maintainer, #1257):
13
+ *
14
+ * - Follow-up URLs are path segments, not `?page=n`: a static host serves
15
+ * files, and a query string would serve page 1 for every page.
16
+ * `/page/1/` is never emitted — page 1 has exactly one URL.
17
+ * - Follow-up pages are indexable and self-canonical by default (Google:
18
+ * do not canonicalise page 2… to page 1); `follow_ups: "noindex"` keeps
19
+ * them out of search (robots noindex, no sitemap entry) per overview.
20
+ * - Page size defaults to the maximum ({@link LIST_QUERY_MAX_LIMIT}):
21
+ * static HTML costs nothing per entry, and fewer follow-up pages mean
22
+ * fewer thin pages.
23
+ * - One paginated list per page: two would need two independent page
24
+ * numbers in one URL.
25
+ */
26
+
27
+ import { z } from "zod";
28
+ import type { PageUrlStyle } from "../seo.js";
29
+ import { trimSlashes } from "../url.js";
30
+ import { LIST_QUERY_MAX_LIMIT } from "./list-type.js";
31
+
32
+ /** The URL segment in front of the page number: `/guides/page/2/`. */
33
+ export const PAGINATION_SEGMENT = "page";
34
+
35
+ /** Who may see follow-up pages in search. */
36
+ export const PAGINATION_FOLLOW_UPS = ["index", "noindex"] as const;
37
+ export type PaginationFollowUps = (typeof PAGINATION_FOLLOW_UPS)[number];
38
+
39
+ /**
40
+ * Pagination settings — on a module `list` field (`paginate`, opts the
41
+ * field in and sets the defaults) and in a placement's value
42
+ * (`paginate`, overrides them for that overview).
43
+ */
44
+ export const listPaginationSchema = z
45
+ .object({
46
+ /** Entries per page, 1..50; default 50. */
47
+ per_page: z.number().int().min(1).max(LIST_QUERY_MAX_LIMIT).optional(),
48
+ /** Follow-up pages indexable (default) or `noindex` and out of the sitemap. */
49
+ follow_ups: z.enum(PAGINATION_FOLLOW_UPS).optional(),
50
+ })
51
+ .strict();
52
+ export type ListPaginationSettings = z.infer<typeof listPaginationSchema>;
53
+
54
+ /** The settings an overview renders with. */
55
+ export interface EffectivePagination {
56
+ readonly perPage: number;
57
+ readonly followUps: PaginationFollowUps;
58
+ }
59
+
60
+ /**
61
+ * The placement value of a paginated field split into its pagination
62
+ * overrides and the rest (query knobs / `terms`). The rest is what the
63
+ * source reads; `paginate` never reaches the query parser.
64
+ */
65
+ export function splitPaginationValue(value: unknown): {
66
+ readonly paginate: unknown;
67
+ readonly rest: unknown;
68
+ } {
69
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
70
+ return { paginate: undefined, rest: value };
71
+ }
72
+ const { paginate, ...rest } = value as Record<string, unknown>;
73
+ return { paginate, rest: Object.keys(rest).length > 0 ? rest : undefined };
74
+ }
75
+
76
+ /**
77
+ * Field settings with the placement's overrides applied; null when the
78
+ * overrides are malformed (the render reports `list-malformed`).
79
+ */
80
+ export function effectivePagination(
81
+ field: ListPaginationSettings,
82
+ overrides: unknown,
83
+ ): EffectivePagination | null {
84
+ const parsed = listPaginationSchema.safeParse(overrides ?? {});
85
+ if (!parsed.success) return null;
86
+ return {
87
+ perPage: parsed.data.per_page ?? field.per_page ?? LIST_QUERY_MAX_LIMIT,
88
+ followUps: parsed.data.follow_ups ?? field.follow_ups ?? "index",
89
+ };
90
+ }
91
+
92
+ /** How many pages `total` entries need at `perPage` (at least one). */
93
+ export function pageCountOf(total: number, perPage: number): number {
94
+ return Math.max(1, Math.ceil(total / perPage));
95
+ }
96
+
97
+ /**
98
+ * The public path of page `n` (≥ 2) of the overview at `path`:
99
+ * `/guides` → `/guides/page/2`, `/` → `/page/2`. No trailing slash: the
100
+ * target's URL style adds it (`pageHrefForStyle`, `pageOutputPath`).
101
+ */
102
+ export function pagedPath(path: string, n: number): string {
103
+ assertFollowUp(n);
104
+ const trimmed = trimSlashes(path);
105
+ return `${trimmed.length > 0 ? `/${trimmed}` : ""}/${PAGINATION_SEGMENT}/${n}`;
106
+ }
107
+
108
+ /**
109
+ * The URL of page `n` (≥ 2) from the overview's own URL (absolute or a
110
+ * path, as the link resolver or the public URL builder made it) in the
111
+ * target's URL style. Query and fragment are dropped: they belong to
112
+ * page 1's link, not to the page sequence.
113
+ */
114
+ export function pagedUrl(url: string, n: number, style: PageUrlStyle): string {
115
+ assertFollowUp(n);
116
+ // Trailing slashes cut by index, not a regex: no backtracking on input.
117
+ let base = url.split(/[?#]/, 1)[0] ?? url;
118
+ let end = base.length;
119
+ while (end > 0 && base.charCodeAt(end - 1) === 47) end -= 1;
120
+ base = base.slice(0, end);
121
+ return `${base}/${PAGINATION_SEGMENT}/${n}${style === "directory" ? "/" : ""}`;
122
+ }
123
+
124
+ /**
125
+ * Split a requested path into the overview's path and a follow-up page
126
+ * number: `/guides/page/3/` → `{ basePath: "/guides", page: 3 }`. Null
127
+ * when the path names no follow-up page (`/page/1` included — page 1 is
128
+ * only served at the overview's own path).
129
+ */
130
+ export function parsePagedPath(path: string): { basePath: string; page: number } | null {
131
+ const m = /^(.*?)\/page\/([1-9][0-9]{0,5})\/?$/.exec(path);
132
+ if (!m) return null;
133
+ const page = Number(m[2]);
134
+ if (page < 2) return null;
135
+ const base = trimSlashes(m[1] ?? "");
136
+ return { basePath: base.length === 0 ? "/" : `/${base}`, page };
137
+ }
138
+
139
+ /**
140
+ * The page path a follow-up page of `overviewPath` would collide with:
141
+ * `pagePath` is `<overview>/page` or below it. A page there would either
142
+ * shadow a follow-up page or, in the no-extension URL style, need a file
143
+ * and a directory of the same name.
144
+ */
145
+ export function collidesWithFollowUps(overviewPath: string, pagePath: string): boolean {
146
+ const prefix = pagedPrefix(overviewPath);
147
+ const p = `/${trimSlashes(pagePath)}`;
148
+ return p === prefix || p.startsWith(`${prefix}/`);
149
+ }
150
+
151
+ function pagedPrefix(path: string): string {
152
+ const trimmed = trimSlashes(path);
153
+ return `${trimmed.length > 0 ? `/${trimmed}` : ""}/${PAGINATION_SEGMENT}`;
154
+ }
155
+
156
+ function assertFollowUp(n: number): void {
157
+ if (!Number.isInteger(n) || n < 2) {
158
+ throw new Error(`pagination: follow-up page numbers start at 2 (got ${n}).`);
159
+ }
160
+ }