@jxsuite/site 2.2.2 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/site",
3
- "version": "2.2.2",
3
+ "version": "2.3.1",
4
4
  "description": "What a Jx project's page at a route consists of — routing, layout, context, head and the document that boots it",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -17,6 +17,7 @@
17
17
  "./context": "./src/context.ts",
18
18
  "./head-merger": "./src/head-merger.ts",
19
19
  "./layout": "./src/layout.ts",
20
+ "./pagination": "./src/pagination.ts",
20
21
  "./paths": "./src/paths.ts",
21
22
  "./routes": "./src/routes.ts",
22
23
  "./serve": "./src/serve.ts",
@@ -31,7 +32,7 @@
31
32
  "test:coverage": "bun test --isolate --coverage"
32
33
  },
33
34
  "dependencies": {
34
- "@jxsuite/runtime": "^6.0.1",
35
- "@jxsuite/schema": "^5.0.0"
35
+ "@jxsuite/runtime": "^6.1.0",
36
+ "@jxsuite/schema": "^5.2.0"
36
37
  }
37
38
  }
package/src/context.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  * url $site.state.* — site-wide reactive state $page.url — current page URL path $page.title — page
9
9
  * title $page.params — dynamic route parameters (if any) $page.locale/$page.dir — the route's
10
10
  * language and writing direction (§13.4) $page.alternates — this page in every language it exists
11
- * in (§13.5)
11
+ * in (§13.5) $page.pagination — where a paged route's page sits in its list (§4.3)
12
12
  *
13
13
  * The site build is not the only thing that has to produce this state. A page rendered live — on
14
14
  * the studio's canvas, or by the cloud's preview origin — binds `${$site.name}` and `${$page.url}`
@@ -25,6 +25,7 @@ import { isNonNullObject, isString } from "@jxsuite/schema/guards";
25
25
  import type { JxDocument, JxElement, JxStateObject, ProjectConfig } from "@jxsuite/schema/types";
26
26
  import { localeDirection, localeLabel, localeOfRoute } from "@jxsuite/schema/locale";
27
27
  import type { ResolvedI18n } from "@jxsuite/schema/locale";
28
+ import type { PagePagination } from "./pagination.ts";
28
29
 
29
30
  /**
30
31
  * One route in a translation set: the locale it serves, and where it lives. The shape `<head>` and
@@ -42,6 +43,8 @@ export interface SiteRoute {
42
43
  urlPattern: string;
43
44
  sourcePath?: string;
44
45
  _pathParams?: Record<string, string>;
46
+ /** Set on a page of a paged list (§4.3); becomes `$page.pagination`. */
47
+ pagination?: PagePagination;
45
48
  }
46
49
 
47
50
  /**
@@ -129,6 +132,21 @@ export function injectContext(
129
132
  if (alternates.length > 0) {
130
133
  page.alternates = alternates;
131
134
  }
135
+ if (route.pagination) {
136
+ const { first, itemCount, last, next, number, pageCount, pageSize, pages, prev } =
137
+ route.pagination;
138
+ page.pagination = {
139
+ first,
140
+ itemCount,
141
+ last,
142
+ next,
143
+ number,
144
+ pageCount,
145
+ pageSize,
146
+ pages: pages.map(({ current, number: n, url }) => ({ current, number: n, url })),
147
+ prev,
148
+ };
149
+ }
132
150
  if (pageLang !== undefined) {
133
151
  page.dir = localeDirection(pageLang);
134
152
  page.locale = pageLang;
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Paging a list into numbered routes (site-architecture.md §4.3).
3
+ *
4
+ * A page whose `$paths` is a `paginate` source is built once per `pageSize` items of a list in its
5
+ * own `state`, each page with the list replaced by its slice and with `$page.pagination` beside it.
6
+ * The arithmetic lives here, with no platform imports, so the build and any host that renders a
7
+ * paged route (Studio's canvas) count pages, cut slices and spell pager URLs the same way.
8
+ *
9
+ * @docs framework/site/routing
10
+ */
11
+
12
+ import { isJsonObject, isNumber, isString } from "@jxsuite/schema/guards";
13
+ import type { JsonObject, JsonValue, JxPathsDef } from "@jxsuite/schema/types";
14
+
15
+ /** A `$paths` that pages a list: which state entry, how many items to a page, and over what. */
16
+ export interface PagedSource {
17
+ /** The key of the paged entry in the page's own `state`. */
18
+ key: string;
19
+ pageSize: number;
20
+ /**
21
+ * The `$paths` source of the route's other parameter, when the list is paged once per value of it
22
+ * (a paged archive per tag). Absent when the page number is the route's only parameter.
23
+ */
24
+ each?: JsonObject | JsonValue[];
25
+ }
26
+
27
+ /** One page of a paged list, as a numbered pager renders it. */
28
+ export interface PageLink {
29
+ number: number;
30
+ url: string;
31
+ current: boolean;
32
+ }
33
+
34
+ /** `$page.pagination`: where this page sits in its list. */
35
+ export interface PagePagination {
36
+ /** This page's number, from 1. */
37
+ number: number;
38
+ pageCount: number;
39
+ pageSize: number;
40
+ /** Items in the whole list, every page together. */
41
+ itemCount: number;
42
+ first: string;
43
+ prev: string | null;
44
+ next: string | null;
45
+ last: string;
46
+ pages: PageLink[];
47
+ }
48
+
49
+ const STATE_POINTER = /^#\/state\/([^/]+)$/;
50
+
51
+ /**
52
+ * An RFC 6901 reference token as the key it names: `~1` is `/` and `~0` is `~`, in that order, so
53
+ * `~01` stays `~1`.
54
+ */
55
+ function unescapeToken(token: string): string {
56
+ return token.replaceAll("~1", "/").replaceAll("~0", "~");
57
+ }
58
+
59
+ /**
60
+ * The paged source a `$paths` declares, or null when it declares another shape.
61
+ *
62
+ * @param {JxPathsDef | JsonValue | undefined} paths - A page's `$paths`
63
+ * @returns {PagedSource | null}
64
+ * @throws {TypeError} When `paginate` is present but the source is malformed; the message names the
65
+ * fault
66
+ */
67
+ export function pagedSource(paths: JxPathsDef | JsonValue | undefined): PagedSource | null {
68
+ if (!isJsonObject(paths) || !("paginate" in paths)) {
69
+ return null;
70
+ }
71
+ const { each, pageSize, paginate } = paths;
72
+ const ref = isJsonObject(paginate) ? paginate.$ref : undefined;
73
+ const key = isString(ref) ? STATE_POINTER.exec(ref)?.[1] : undefined;
74
+ if (key === undefined) {
75
+ throw new TypeError('$paths.paginate must be { "$ref": "#/state/<key>" }');
76
+ }
77
+ if (!isNumber(pageSize) || !Number.isInteger(pageSize) || pageSize < 1) {
78
+ throw new TypeError("$paths.pageSize must be a positive integer");
79
+ }
80
+ const source: PagedSource = { key: unescapeToken(key), pageSize };
81
+ if (each !== undefined) {
82
+ if (!isJsonObject(each) && !Array.isArray(each)) {
83
+ throw new TypeError("$paths.each must be a $paths source");
84
+ }
85
+ if (isJsonObject(each) && "paginate" in each) {
86
+ throw new TypeError("$paths.each cannot itself be paged");
87
+ }
88
+ source.each = each;
89
+ }
90
+ return source;
91
+ }
92
+
93
+ /**
94
+ * How many pages a list of `itemCount` items fills: at least one, so an empty list still has its
95
+ * listing page.
96
+ */
97
+ export function pageCount(itemCount: number, pageSize: number): number {
98
+ return Math.max(1, Math.ceil(itemCount / pageSize));
99
+ }
100
+
101
+ /** The items of page `page` (from 1), and nothing past the end of the list. */
102
+ export function pageSlice<T>(items: readonly T[], page: number, pageSize: number): T[] {
103
+ const start = (page - 1) * pageSize;
104
+ return items.slice(start, start + pageSize);
105
+ }
106
+
107
+ /**
108
+ * The URL of page `page` of a route whose page number is its only remaining parameter.
109
+ *
110
+ * A catch-all may be empty, so its first page is the list's own URL (`/blog/*` gives `/blog`, then
111
+ * `/blog/2`); a named parameter is always one segment, so every page is numbered (`/blog/:page`
112
+ * gives `/blog/1`).
113
+ *
114
+ * @param {string} urlPattern - The route's pattern, every parameter but the page one substituted
115
+ * @param {number} page
116
+ * @returns {string}
117
+ */
118
+ export function pageUrl(urlPattern: string, page: number): string {
119
+ if (urlPattern.endsWith("*")) {
120
+ if (page === 1) {
121
+ const root = urlPattern.slice(0, -1).replace(/\/$/, "");
122
+ return root === "" ? "/" : root;
123
+ }
124
+ return `${urlPattern.slice(0, -1)}${page}`;
125
+ }
126
+ return urlPattern.replace(/:[^/]+/, String(page));
127
+ }
128
+
129
+ /**
130
+ * `$page.pagination` for page `page` of a list of `itemCount` items.
131
+ *
132
+ * @param {string} urlPattern - As {@link pageUrl} takes it
133
+ * @param {number} page
134
+ * @param {number} pageSize
135
+ * @param {number} itemCount
136
+ * @returns {PagePagination}
137
+ */
138
+ export function pagination(
139
+ urlPattern: string,
140
+ page: number,
141
+ pageSize: number,
142
+ itemCount: number,
143
+ ): PagePagination {
144
+ const count = pageCount(itemCount, pageSize);
145
+ const pages: PageLink[] = [];
146
+ for (let number = 1; number <= count; number++) {
147
+ pages.push({ current: number === page, number, url: pageUrl(urlPattern, number) });
148
+ }
149
+ return {
150
+ first: pageUrl(urlPattern, 1),
151
+ itemCount,
152
+ last: pageUrl(urlPattern, count),
153
+ next: page < count ? pageUrl(urlPattern, page + 1) : null,
154
+ number: page,
155
+ pageCount: count,
156
+ pageSize,
157
+ pages,
158
+ prev: page > 1 ? pageUrl(urlPattern, page - 1) : null,
159
+ };
160
+ }