@jxsuite/schema 5.0.0 → 5.2.0
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/defs/field-schema.schema.ts +37 -4
- package/package.json +2 -1
- package/schema.json +38 -0
- package/schemas/document.paths.schema.json +30 -0
- package/src/content-query.ts +348 -0
- package/src/format-registry.ts +6 -0
- package/types.ts +17 -6
|
@@ -62,10 +62,12 @@ export const relationshipRefSchema = {
|
|
|
62
62
|
* ones every per-project entry document unions extension shapes on top of.
|
|
63
63
|
*
|
|
64
64
|
* These mirror `resolvePathEntries` in the compiler's pages-discovery exactly (specs/
|
|
65
|
-
* site-architecture.md §4.3): an explicit value list, a JSON data-file reference,
|
|
66
|
-
* bare array of parameter objects. Anything else is dispatched by discriminator key to
|
|
67
|
-
* extension's `resolvePaths` capability, so extension shapes reach the union through the entry
|
|
68
|
-
* document rather than from here.
|
|
65
|
+
* site-architecture.md §4.3): an explicit value list, a JSON data-file reference, a paged list, and
|
|
66
|
+
* the legacy bare array of parameter objects. Anything else is dispatched by discriminator key to
|
|
67
|
+
* an extension's `resolvePaths` capability, so extension shapes reach the union through the entry
|
|
68
|
+
* document rather than from here. The paged list is expanded by `expandDynamicRoutes` itself rather
|
|
69
|
+
* than `resolvePathEntries`, because it reads the page's own state; its `each` holds another source
|
|
70
|
+
* of this union (or an extension's), checked when the route expands.
|
|
69
71
|
*
|
|
70
72
|
* Each shape is CLOSED (`additionalProperties: false`). `$paths` is a build-time instruction with a
|
|
71
73
|
* fully specified grammar and no forward-compatible extension points of its own — a stray or
|
|
@@ -103,6 +105,37 @@ export const documentPathsCoreMembers = [
|
|
|
103
105
|
required: ["$ref"],
|
|
104
106
|
type: "object",
|
|
105
107
|
},
|
|
108
|
+
{
|
|
109
|
+
additionalProperties: false,
|
|
110
|
+
description:
|
|
111
|
+
"Paged list: one route per `pageSize` items of an entry of this page's own state, which must " +
|
|
112
|
+
"resolve to an array at build time. The page number fills the route's one parameter that " +
|
|
113
|
+
"`each` does not.",
|
|
114
|
+
properties: {
|
|
115
|
+
each: {
|
|
116
|
+
description:
|
|
117
|
+
"Another `$paths` source for the route's other parameter: the list is paged once per " +
|
|
118
|
+
"value of it, as a paged archive per tag is.",
|
|
119
|
+
type: ["object", "array"],
|
|
120
|
+
},
|
|
121
|
+
pageSize: { description: "Items on each page.", minimum: 1, type: "integer" },
|
|
122
|
+
paginate: {
|
|
123
|
+
additionalProperties: false,
|
|
124
|
+
description: "The state entry to page.",
|
|
125
|
+
properties: {
|
|
126
|
+
$ref: {
|
|
127
|
+
description: "A pointer to an entry of this page's state: `#/state/<key>`.",
|
|
128
|
+
pattern: "^#/state/[^/]+$",
|
|
129
|
+
type: "string",
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
required: ["$ref"],
|
|
133
|
+
type: "object",
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
required: ["paginate", "pageSize"],
|
|
137
|
+
type: "object",
|
|
138
|
+
},
|
|
106
139
|
{
|
|
107
140
|
description:
|
|
108
141
|
"Legacy explicit form: an array of ready-made parameter objects, used as-is with no " +
|
package/package.json
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
".": "./src/schema.ts",
|
|
12
12
|
"./asset-paths": "./src/asset-paths.ts",
|
|
13
13
|
"./component-meta": "./src/component-meta.ts",
|
|
14
|
+
"./content-query": "./src/content-query.ts",
|
|
14
15
|
"./extension-registry": "./src/extension-registry.ts",
|
|
15
16
|
"./format-registry": "./src/format-registry.ts",
|
|
16
17
|
"./project-schemas": "./src/project-schemas.ts",
|
|
@@ -61,7 +62,7 @@
|
|
|
61
62
|
"upgrade": "bun update --latest && bun install"
|
|
62
63
|
},
|
|
63
64
|
"type": "module",
|
|
64
|
-
"version": "5.
|
|
65
|
+
"version": "5.2.0",
|
|
65
66
|
"devDependencies": {
|
|
66
67
|
"json-schema-to-ts": "^3.1.1"
|
|
67
68
|
}
|
package/schema.json
CHANGED
|
@@ -28351,6 +28351,44 @@
|
|
|
28351
28351
|
],
|
|
28352
28352
|
"type": "object"
|
|
28353
28353
|
},
|
|
28354
|
+
{
|
|
28355
|
+
"additionalProperties": false,
|
|
28356
|
+
"description": "Paged list: one route per `pageSize` items of an entry of this page's own state, which must resolve to an array at build time. The page number fills the route's one parameter that `each` does not.",
|
|
28357
|
+
"properties": {
|
|
28358
|
+
"each": {
|
|
28359
|
+
"description": "Another `$paths` source for the route's other parameter: the list is paged once per value of it, as a paged archive per tag is.",
|
|
28360
|
+
"type": [
|
|
28361
|
+
"object",
|
|
28362
|
+
"array"
|
|
28363
|
+
]
|
|
28364
|
+
},
|
|
28365
|
+
"pageSize": {
|
|
28366
|
+
"description": "Items on each page.",
|
|
28367
|
+
"minimum": 1,
|
|
28368
|
+
"type": "integer"
|
|
28369
|
+
},
|
|
28370
|
+
"paginate": {
|
|
28371
|
+
"additionalProperties": false,
|
|
28372
|
+
"description": "The state entry to page.",
|
|
28373
|
+
"properties": {
|
|
28374
|
+
"$ref": {
|
|
28375
|
+
"description": "A pointer to an entry of this page's state: `#/state/<key>`.",
|
|
28376
|
+
"pattern": "^#/state/[^/]+$",
|
|
28377
|
+
"type": "string"
|
|
28378
|
+
}
|
|
28379
|
+
},
|
|
28380
|
+
"required": [
|
|
28381
|
+
"$ref"
|
|
28382
|
+
],
|
|
28383
|
+
"type": "object"
|
|
28384
|
+
}
|
|
28385
|
+
},
|
|
28386
|
+
"required": [
|
|
28387
|
+
"paginate",
|
|
28388
|
+
"pageSize"
|
|
28389
|
+
],
|
|
28390
|
+
"type": "object"
|
|
28391
|
+
},
|
|
28354
28392
|
{
|
|
28355
28393
|
"description": "Legacy explicit form: an array of ready-made parameter objects, used as-is with no expansion. Prefer one of the source shapes above.",
|
|
28356
28394
|
"items": {
|
|
@@ -38,6 +38,36 @@
|
|
|
38
38
|
"required": ["$ref"],
|
|
39
39
|
"type": "object"
|
|
40
40
|
},
|
|
41
|
+
{
|
|
42
|
+
"additionalProperties": false,
|
|
43
|
+
"description": "Paged list: one route per `pageSize` items of an entry of this page's own state, which must resolve to an array at build time. The page number fills the route's one parameter that `each` does not.",
|
|
44
|
+
"properties": {
|
|
45
|
+
"each": {
|
|
46
|
+
"description": "Another `$paths` source for the route's other parameter: the list is paged once per value of it, as a paged archive per tag is.",
|
|
47
|
+
"type": ["object", "array"]
|
|
48
|
+
},
|
|
49
|
+
"pageSize": {
|
|
50
|
+
"description": "Items on each page.",
|
|
51
|
+
"minimum": 1,
|
|
52
|
+
"type": "integer"
|
|
53
|
+
},
|
|
54
|
+
"paginate": {
|
|
55
|
+
"additionalProperties": false,
|
|
56
|
+
"description": "The state entry to page.",
|
|
57
|
+
"properties": {
|
|
58
|
+
"$ref": {
|
|
59
|
+
"description": "A pointer to an entry of this page's state: `#/state/<key>`.",
|
|
60
|
+
"pattern": "^#/state/[^/]+$",
|
|
61
|
+
"type": "string"
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
"required": ["$ref"],
|
|
65
|
+
"type": "object"
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
"required": ["paginate", "pageSize"],
|
|
69
|
+
"type": "object"
|
|
70
|
+
},
|
|
41
71
|
{
|
|
42
72
|
"description": "Legacy explicit form: an array of ready-made parameter objects, used as-is with no expansion. Prefer one of the source shapes above.",
|
|
43
73
|
"items": {
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content queries — the frontmatter filters a content type can carry: `where` (what exists) and
|
|
3
|
+
* `hold` (what authors can see and production cannot, site-architecture.md §7.6).
|
|
4
|
+
*
|
|
5
|
+
* Both are **data, never code**. A `project.json` is read by Studio, by the dev server and by CI,
|
|
6
|
+
* so a filter that needed `eval` or a function would make every one of those a place that executes
|
|
7
|
+
* the project's text. The grammar is a small JSON query and it is closed: an unknown operator is a
|
|
8
|
+
* build error naming the key, never a silent no-match.
|
|
9
|
+
*
|
|
10
|
+
* It lives here, and not in the parser that loads entries, because Studio answers the same rules
|
|
11
|
+
* for its badges and its lists, and a core package may not import an extension (specs/extensions.md
|
|
12
|
+
* §3). Pure logic with no `node:` imports.
|
|
13
|
+
*
|
|
14
|
+
* @module @jxsuite/schema/content-query
|
|
15
|
+
* @license MIT
|
|
16
|
+
* @docs framework/site/content-collections
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { isBoolean, isNumber, isString } from "./guards.ts";
|
|
20
|
+
import type { ContentFieldValue, ContentLoaderEntry, JsonValue } from "../types.ts";
|
|
21
|
+
|
|
22
|
+
/** An entry's frontmatter as a filter reads it: field name → value, absent when it is not set. */
|
|
23
|
+
type FieldData = ContentLoaderEntry["data"];
|
|
24
|
+
|
|
25
|
+
/** A field value, or an operand, in the form a filter compares; `undefined` is a missing field. */
|
|
26
|
+
type FieldValue = ContentFieldValue | undefined;
|
|
27
|
+
|
|
28
|
+
/** Whether an entry's frontmatter passes a compiled `where`. */
|
|
29
|
+
export interface WherePredicate {
|
|
30
|
+
(data: FieldData): boolean;
|
|
31
|
+
/**
|
|
32
|
+
* The first field whose condition `data` fails, for a message that says why an entry was left
|
|
33
|
+
* out.
|
|
34
|
+
*/
|
|
35
|
+
why: (data: FieldData) => string | undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** One rule of a content type's `hold`: what it is called, and the frontmatter that matches it. */
|
|
39
|
+
export interface HoldRule {
|
|
40
|
+
/** The words every surface shows for an entry this rule holds. */
|
|
41
|
+
label: string;
|
|
42
|
+
/** A query with the grammar of `where`. */
|
|
43
|
+
when: Record<string, JsonValue>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The label of the first `hold` rule an entry's frontmatter matches, or undefined for none. */
|
|
47
|
+
export type HoldPredicate = (data: FieldData) => string | undefined;
|
|
48
|
+
|
|
49
|
+
/** What a caller may pin. */
|
|
50
|
+
export interface QueryOptions {
|
|
51
|
+
/** What `{ "$now": true }` reads, asked each time so a long-lived host stays current. */
|
|
52
|
+
now?: (() => Date) | undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** What compiling a query carries down to each condition. */
|
|
56
|
+
interface QueryContext {
|
|
57
|
+
/** The query's name in a message: `where`, or `hold[1].when`. */
|
|
58
|
+
name: string;
|
|
59
|
+
now: () => Date;
|
|
60
|
+
/** What a value that is not a date answers: whichever way keeps the entry from publishing. */
|
|
61
|
+
onBadDate: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The query operators. Closed on purpose: see the module comment. */
|
|
65
|
+
const OPERATORS = new Set(["$eq", "$ne", "$in", "$nin", "$exists", "$gt", "$gte", "$lt", "$lte"]);
|
|
66
|
+
|
|
67
|
+
function isPlainObject(value: FieldValue | FieldData | JsonValue): value is FieldData {
|
|
68
|
+
return (
|
|
69
|
+
typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Date)
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A frontmatter value in the form a filter compares: YAML dates become RFC 3339 text. */
|
|
74
|
+
function comparable(value: FieldValue): FieldValue {
|
|
75
|
+
if (value instanceof Date) {
|
|
76
|
+
if (Number.isNaN(value.getTime())) {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
const iso = value.toISOString();
|
|
80
|
+
return iso.endsWith("T00:00:00.000Z") ? iso.slice(0, 10) : iso.replace(/\.\d{3}Z$/, "Z");
|
|
81
|
+
}
|
|
82
|
+
return value;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function deepEqual(a: FieldValue, b: FieldValue): boolean {
|
|
86
|
+
if (a === b) {
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
90
|
+
return (
|
|
91
|
+
a.length === b.length && a.every((item, i) => deepEqual(comparable(item), comparable(b[i])))
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
if (isPlainObject(a) && isPlainObject(b)) {
|
|
95
|
+
const keys = Object.keys(a);
|
|
96
|
+
return (
|
|
97
|
+
keys.length === Object.keys(b).length &&
|
|
98
|
+
keys.every((key) => key in b && deepEqual(comparable(a[key]), comparable(b[key])))
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Equality, with one convenience that earns its keep: a scalar matches an array field that CONTAINS
|
|
106
|
+
* it, so `{"tags": "frappe"}` selects every entry tagged frappe without a second operator. An array
|
|
107
|
+
* or object operand compares as a whole value.
|
|
108
|
+
*/
|
|
109
|
+
function sameValue(actual: FieldValue, expected: JsonValue | undefined): boolean {
|
|
110
|
+
const a = comparable(actual);
|
|
111
|
+
if (Array.isArray(expected) || isPlainObject(expected)) {
|
|
112
|
+
return deepEqual(a, expected);
|
|
113
|
+
}
|
|
114
|
+
if (Array.isArray(a)) {
|
|
115
|
+
return a.some((item) => comparable(item) === expected);
|
|
116
|
+
}
|
|
117
|
+
return a === expected;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Read a frontmatter field: a literal key first, then a dotted path into nested data. */
|
|
121
|
+
export function readField(data: FieldData, key: string): FieldValue {
|
|
122
|
+
// Own properties only: `constructor` and `toString` are not frontmatter, whatever `in` says.
|
|
123
|
+
if (Object.hasOwn(data, key)) {
|
|
124
|
+
return data[key];
|
|
125
|
+
}
|
|
126
|
+
if (!key.includes(".")) {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
return readPath(data, key.split("."));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Follow a dotted path's parts through nested objects, own properties only like {@link readField}. */
|
|
133
|
+
function readPath(holder: FieldValue | FieldData, [part, ...rest]: readonly string[]): FieldValue {
|
|
134
|
+
if (part === undefined || !isPlainObject(holder) || !Object.hasOwn(holder, part)) {
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
const value = holder[part];
|
|
138
|
+
return rest.length === 0 ? value : readPath(value, rest);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Order two comparable values; undefined when they are not the same kind of thing. */
|
|
142
|
+
function order(actual: FieldValue, operand: string | number): number | undefined {
|
|
143
|
+
const a = comparable(actual);
|
|
144
|
+
if (isNumber(a) && isNumber(operand)) {
|
|
145
|
+
return a - operand;
|
|
146
|
+
}
|
|
147
|
+
if (isString(a) && isString(operand)) {
|
|
148
|
+
return a < operand ? -1 : a > operand ? 1 : 0;
|
|
149
|
+
}
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
type FieldTest = (actual: FieldValue) => boolean;
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The operand that reads the clock: `{ "$now": true }`. An object, so no string can be mistaken for
|
|
157
|
+
* it.
|
|
158
|
+
*/
|
|
159
|
+
function isNowOperand(operand: JsonValue | undefined): boolean {
|
|
160
|
+
return isPlainObject(operand) && operand.$now === true && Object.keys(operand).length === 1;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* A frontmatter value as a moment, or undefined when it is not one. `Date.parse` is what decides,
|
|
165
|
+
* so ECMAScript answers the time zone: a date alone is UTC, a date-time with no offset is local.
|
|
166
|
+
*/
|
|
167
|
+
function timeOf(value: ContentFieldValue): number | undefined {
|
|
168
|
+
const time =
|
|
169
|
+
value instanceof Date ? value.getTime() : isString(value) ? Date.parse(value) : Number.NaN;
|
|
170
|
+
return Number.isNaN(time) ? undefined : time;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function operatorTest(
|
|
174
|
+
where: string,
|
|
175
|
+
op: string,
|
|
176
|
+
operand: JsonValue | undefined,
|
|
177
|
+
ctx: QueryContext,
|
|
178
|
+
): FieldTest {
|
|
179
|
+
switch (op) {
|
|
180
|
+
case "$eq": {
|
|
181
|
+
return (actual) => sameValue(actual, operand);
|
|
182
|
+
}
|
|
183
|
+
case "$ne": {
|
|
184
|
+
return (actual) => !sameValue(actual, operand);
|
|
185
|
+
}
|
|
186
|
+
case "$in":
|
|
187
|
+
case "$nin": {
|
|
188
|
+
if (!Array.isArray(operand)) {
|
|
189
|
+
throw new TypeError(`${where}.${op}: expected an array, got ${JSON.stringify(operand)}`);
|
|
190
|
+
}
|
|
191
|
+
const hit: FieldTest = (actual) => operand.some((candidate) => sameValue(actual, candidate));
|
|
192
|
+
return op === "$in" ? hit : (actual) => !hit(actual);
|
|
193
|
+
}
|
|
194
|
+
case "$exists": {
|
|
195
|
+
if (!isBoolean(operand)) {
|
|
196
|
+
throw new TypeError(
|
|
197
|
+
`${where}.$exists: expected true or false, got ${JSON.stringify(operand)}`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
// An empty YAML key is null, and "set to nothing" is not a value an author means by "exists".
|
|
201
|
+
return (actual) => (actual !== undefined && actual !== null) === operand;
|
|
202
|
+
}
|
|
203
|
+
default: {
|
|
204
|
+
const test = (diff: number) =>
|
|
205
|
+
op === "$gt" ? diff > 0 : op === "$gte" ? diff >= 0 : op === "$lt" ? diff < 0 : diff <= 0;
|
|
206
|
+
if (isNowOperand(operand)) {
|
|
207
|
+
return (actual) => {
|
|
208
|
+
if (actual === undefined || actual === null) {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
const time = timeOf(actual);
|
|
212
|
+
return time === undefined ? ctx.onBadDate : test(time - ctx.now().getTime());
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
if (!isString(operand) && !isNumber(operand)) {
|
|
216
|
+
throw new TypeError(
|
|
217
|
+
`${where}.${op}: expected a string or number, got ${JSON.stringify(operand)}`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
return (actual) => {
|
|
221
|
+
const diff = order(actual, operand);
|
|
222
|
+
return diff !== undefined && test(diff);
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** A condition object is an operator set when every key is `$`-prefixed. */
|
|
229
|
+
function operatorKeys(condition: FieldData): string[] | null {
|
|
230
|
+
const keys = Object.keys(condition);
|
|
231
|
+
const dollars = keys.filter((key) => key.startsWith("$"));
|
|
232
|
+
if (dollars.length === 0) {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
return dollars.length === keys.length ? keys : [];
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function compileCondition(key: string, condition: JsonValue, ctx: QueryContext): FieldTest {
|
|
239
|
+
const where = `${ctx.name}.${key}`;
|
|
240
|
+
if (!isPlainObject(condition)) {
|
|
241
|
+
return (actual) => sameValue(actual, condition);
|
|
242
|
+
}
|
|
243
|
+
const ops = operatorKeys(condition);
|
|
244
|
+
if (ops === null) {
|
|
245
|
+
// A plain object with no operators is a literal value to compare whole.
|
|
246
|
+
return (actual) => deepEqual(comparable(actual), condition);
|
|
247
|
+
}
|
|
248
|
+
if (ops.length === 0) {
|
|
249
|
+
throw new TypeError(`${where}: cannot mix $operators with plain keys in one condition`);
|
|
250
|
+
}
|
|
251
|
+
const tests = ops.map((op) => {
|
|
252
|
+
if (!OPERATORS.has(op)) {
|
|
253
|
+
throw new TypeError(`${where}: unknown operator "${op}" (use ${[...OPERATORS].join(", ")})`);
|
|
254
|
+
}
|
|
255
|
+
return operatorTest(where, op, condition[op], ctx);
|
|
256
|
+
});
|
|
257
|
+
return (actual) => tests.every((test) => test(actual));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Compile a query's fields, each with its condition, against `ctx`. */
|
|
261
|
+
function compileFields(query: FieldData, ctx: QueryContext) {
|
|
262
|
+
return Object.entries(query).map(([key, condition]) => {
|
|
263
|
+
if (key.startsWith("$")) {
|
|
264
|
+
throw new TypeError(
|
|
265
|
+
`${ctx.name}.${key}: top-level operators are not supported, conditions on different fields already combine with AND`,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
/* SAFETY: a query is authored JSON read from `project.json`, so each condition is a JSON value;
|
|
269
|
+
a frontmatter `Date` can only be on the data side of a comparison. */
|
|
270
|
+
return { key, test: compileCondition(key, condition as JsonValue, ctx) };
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** The answer of a rule that matched nothing. */
|
|
275
|
+
const nothing = (): undefined => {};
|
|
276
|
+
|
|
277
|
+
/** When a query reads `{ "$now": true }`, unless a caller pins it. */
|
|
278
|
+
const realClock = (): Date => new Date();
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Compile a `where` filter into a predicate over an entry's frontmatter.
|
|
282
|
+
*
|
|
283
|
+
* Every key is a frontmatter field (a dotted path reaches into nested data), keys combine with AND,
|
|
284
|
+
* and a value is either a literal to equal or an operator object (`$eq`, `$ne`, `$in`, `$nin`,
|
|
285
|
+
* `$exists`, `$gt`, `$gte`, `$lt`, `$lte`; several in one object combine with AND too). There is no
|
|
286
|
+
* OR and no evaluation of text: a project that needs "either" splits it into two content types, or
|
|
287
|
+
* into two rules of a `hold`.
|
|
288
|
+
*
|
|
289
|
+
* @param {JsonValue} [where]
|
|
290
|
+
* @param {QueryOptions} [options]
|
|
291
|
+
* @returns {WherePredicate}
|
|
292
|
+
*/
|
|
293
|
+
export function compileWhere(where?: JsonValue, options: QueryOptions = {}): WherePredicate {
|
|
294
|
+
if (where === undefined) {
|
|
295
|
+
return Object.assign(() => true, { why: nothing });
|
|
296
|
+
}
|
|
297
|
+
if (!isPlainObject(where)) {
|
|
298
|
+
throw new TypeError(`"where" must be an object mapping frontmatter fields to conditions`);
|
|
299
|
+
}
|
|
300
|
+
// A date that cannot be read leaves the entry out: `where` keeps what passes.
|
|
301
|
+
const ctx = { name: "where", now: options.now ?? realClock, onBadDate: false };
|
|
302
|
+
const tests = compileFields(where, ctx);
|
|
303
|
+
return Object.assign(
|
|
304
|
+
(data: FieldData) => tests.every(({ key, test }) => test(readField(data, key))),
|
|
305
|
+
{
|
|
306
|
+
why: (data: FieldData) => tests.find(({ key, test }) => !test(readField(data, key)))?.key,
|
|
307
|
+
},
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Compile a content type's `hold` rules into what names why an entry is held (site-architecture.md
|
|
313
|
+
* §7.6). Any rule matching holds the entry, and the first match is the reason, so a list is the
|
|
314
|
+
* "or" `where` refuses and needs no operator of its own.
|
|
315
|
+
*
|
|
316
|
+
* The polarity is the reason this is not a `filter`: ECMAScript's `Array.prototype.filter` keeps
|
|
317
|
+
* what its predicate answers true for, and a rule here withholds it. It is also why a missing field
|
|
318
|
+
* behaves, in one list: `{ "$gt": { "$now": true } }` does not match a missing date, and `$nin`
|
|
319
|
+
* does match a missing review.
|
|
320
|
+
*
|
|
321
|
+
* @param {JsonValue} [hold] - The type's `hold` value
|
|
322
|
+
* @param {QueryOptions} [options]
|
|
323
|
+
* @returns {HoldPredicate} The label of the first rule that matches, or undefined
|
|
324
|
+
*/
|
|
325
|
+
export function compileHold(hold?: JsonValue, options: QueryOptions = {}): HoldPredicate {
|
|
326
|
+
if (hold === undefined) {
|
|
327
|
+
return nothing;
|
|
328
|
+
}
|
|
329
|
+
if (!Array.isArray(hold)) {
|
|
330
|
+
throw new TypeError(`"hold" must be an array of { "label", "when" } rules`);
|
|
331
|
+
}
|
|
332
|
+
const now = options.now ?? realClock;
|
|
333
|
+
const rules = hold.map((rule, index) => {
|
|
334
|
+
const at = `hold[${index}]`;
|
|
335
|
+
if (!isPlainObject(rule) || !isString(rule.label) || rule.label.trim() === "") {
|
|
336
|
+
throw new TypeError(`${at}: expected a non-empty "label"`);
|
|
337
|
+
}
|
|
338
|
+
if (!isPlainObject(rule.when) || Object.keys(rule.when).length === 0) {
|
|
339
|
+
// An empty `when` matches every entry, which would quietly empty the type from production.
|
|
340
|
+
throw new TypeError(`${at}: expected "when" to name at least one frontmatter field`);
|
|
341
|
+
}
|
|
342
|
+
// A date that cannot be read holds the entry: a typo must not publish it early.
|
|
343
|
+
const ctx = { name: `${at}.when`, now, onBadDate: true };
|
|
344
|
+
return { label: rule.label, tests: compileFields(rule.when, ctx) };
|
|
345
|
+
});
|
|
346
|
+
return (data) =>
|
|
347
|
+
rules.find(({ tests }) => tests.every(({ key, test }) => test(readField(data, key))))?.label;
|
|
348
|
+
}
|
package/src/format-registry.ts
CHANGED
|
@@ -268,6 +268,12 @@ export interface ProjectDataContext {
|
|
|
268
268
|
* Absent means everything now, which is what a build needs (specs/extensions.md §8).
|
|
269
269
|
*/
|
|
270
270
|
lazy?: boolean;
|
|
271
|
+
/**
|
|
272
|
+
* Keep entries a content type's `hold` rules match, labelled, rather than leaving them out
|
|
273
|
+
* (site-architecture.md §7.6). A host that shows authors their work asks for this. Absent means a
|
|
274
|
+
* production build: leaving an entry out is the default, so a host that forgets cannot leak one.
|
|
275
|
+
*/
|
|
276
|
+
includeHeld?: boolean;
|
|
271
277
|
}
|
|
272
278
|
|
|
273
279
|
/**
|
package/types.ts
CHANGED
|
@@ -347,7 +347,7 @@ export interface JxElement {
|
|
|
347
347
|
* `isMappedArray`). The bare `| JxMappedArray` form (whole children slot is one repeater) is
|
|
348
348
|
* retained for backward compatibility with legacy docs.
|
|
349
349
|
*/
|
|
350
|
-
children?: (JxElement | string)[] | JxMappedArray;
|
|
350
|
+
children?: (JxElement | string)[] | JxMappedArray | ComputedChildren;
|
|
351
351
|
style?: JxStyle;
|
|
352
352
|
attributes?: Record<string, JxAttributeValue>;
|
|
353
353
|
className?: string;
|
|
@@ -793,14 +793,16 @@ export interface ProjectConfig {
|
|
|
793
793
|
export type JxAttributeValue = Bindable<string | number | boolean>;
|
|
794
794
|
|
|
795
795
|
/**
|
|
796
|
-
* A dynamic-route path source (spec §4.3): explicit values, data-file ref, a
|
|
797
|
-
*
|
|
798
|
-
*
|
|
799
|
-
*
|
|
796
|
+
* A dynamic-route path source (spec §4.3): explicit values, data-file ref, a paged list of an entry
|
|
797
|
+
* of the page's own state (with `each` naming the source of a second parameter), a legacy array of
|
|
798
|
+
* param objects, or an extension-discriminated source (an object carrying a `resolvePaths`
|
|
799
|
+
* discriminator key registered by an enabled extension, e.g. the parser's `contentType` — the
|
|
800
|
+
* extension owns the narrow shape).
|
|
800
801
|
*/
|
|
801
802
|
export type JxPathsDef =
|
|
802
803
|
| { values: JsonValue[]; param?: string }
|
|
803
804
|
| { $ref: string; param?: string; field?: string }
|
|
805
|
+
| { paginate: { $ref: string }; pageSize: number; each?: JsonValue }
|
|
804
806
|
| { param?: string; field?: string; [discriminator: string]: JsonValue | undefined }
|
|
805
807
|
| Record<string, JsonValue>[];
|
|
806
808
|
|
|
@@ -819,7 +821,7 @@ export interface JxMutableNode {
|
|
|
819
821
|
* here. The bare `| JxMappedArray` form (whole children slot is one repeater) is retained for
|
|
820
822
|
* backward compatibility.
|
|
821
823
|
*/
|
|
822
|
-
children?: (JxMutableNode | string)[] | JxMappedArray;
|
|
824
|
+
children?: (JxMutableNode | string)[] | JxMappedArray | ComputedChildren;
|
|
823
825
|
style?: JxStyle;
|
|
824
826
|
attributes?: Record<string, JxAttributeValue>;
|
|
825
827
|
className?: string;
|
|
@@ -907,6 +909,13 @@ export type ContentFieldValue =
|
|
|
907
909
|
| ContentFieldValue[]
|
|
908
910
|
| { [key: string]: ContentFieldValue };
|
|
909
911
|
|
|
912
|
+
/**
|
|
913
|
+
* A `${…}` template holding an element's whole child list (spec.md §8.4), as `"${item.$children ??
|
|
914
|
+
* []}"`. A plain string is not a `children` value, so the type asks for the opening of a template
|
|
915
|
+
* rather than any string.
|
|
916
|
+
*/
|
|
917
|
+
export type ComputedChildren = `${string}\${${string}`;
|
|
918
|
+
|
|
910
919
|
/** One loaded content entry, as produced by a format class's `load` capability. */
|
|
911
920
|
export interface ContentLoaderEntry {
|
|
912
921
|
id: string;
|
|
@@ -918,6 +927,8 @@ export interface ContentLoaderEntry {
|
|
|
918
927
|
toc?: TocEntry[];
|
|
919
928
|
readingTime?: number;
|
|
920
929
|
wordCount?: number;
|
|
930
|
+
/** The label of the `hold` rule that holds this entry, on a host that keeps held entries. */
|
|
931
|
+
hold?: string;
|
|
921
932
|
/**
|
|
922
933
|
* The source file's modification time, RFC 3339. The fallback a feed uses when an entry carries
|
|
923
934
|
* no authored date, and what lets the sitemap give a generated page its own `<lastmod>` rather
|