@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 +4 -3
- package/src/context.ts +19 -1
- package/src/pagination.ts +160 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/site",
|
|
3
|
-
"version": "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
|
|
35
|
-
"@jxsuite/schema": "^5.
|
|
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
|
+
}
|