@jxsuite/schema 5.1.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/package.json +2 -1
- package/src/content-query.ts +348 -0
- package/src/format-registry.ts +6 -0
- package/types.ts +11 -2
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
|
}
|
|
@@ -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;
|
|
@@ -821,7 +821,7 @@ export interface JxMutableNode {
|
|
|
821
821
|
* here. The bare `| JxMappedArray` form (whole children slot is one repeater) is retained for
|
|
822
822
|
* backward compatibility.
|
|
823
823
|
*/
|
|
824
|
-
children?: (JxMutableNode | string)[] | JxMappedArray;
|
|
824
|
+
children?: (JxMutableNode | string)[] | JxMappedArray | ComputedChildren;
|
|
825
825
|
style?: JxStyle;
|
|
826
826
|
attributes?: Record<string, JxAttributeValue>;
|
|
827
827
|
className?: string;
|
|
@@ -909,6 +909,13 @@ export type ContentFieldValue =
|
|
|
909
909
|
| ContentFieldValue[]
|
|
910
910
|
| { [key: string]: ContentFieldValue };
|
|
911
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
|
+
|
|
912
919
|
/** One loaded content entry, as produced by a format class's `load` capability. */
|
|
913
920
|
export interface ContentLoaderEntry {
|
|
914
921
|
id: string;
|
|
@@ -920,6 +927,8 @@ export interface ContentLoaderEntry {
|
|
|
920
927
|
toc?: TocEntry[];
|
|
921
928
|
readingTime?: number;
|
|
922
929
|
wordCount?: number;
|
|
930
|
+
/** The label of the `hold` rule that holds this entry, on a host that keeps held entries. */
|
|
931
|
+
hold?: string;
|
|
923
932
|
/**
|
|
924
933
|
* The source file's modification time, RFC 3339. The fallback a feed uses when an entry carries
|
|
925
934
|
* no authored date, and what lets the sitemap give a generated page its own `<lastmod>` rather
|