content-query 0.2.0 → 0.4.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/README.md +28 -3
- package/dist/{build-Dxz7P1ur.mjs → build-CcqIpkes.mjs} +763 -39
- package/dist/build-DL1GzmRy.d.mts +55 -0
- package/dist/build.d.mts +2 -2
- package/dist/build.mjs +1 -1
- package/dist/cli.mjs +14 -8
- package/dist/computed.d.mts +2 -2
- package/dist/{content-N1gYshjg.mjs → content-BXrgP7nL.mjs} +271 -81
- package/dist/{content-G69dPPuo.d.mts → content-CwTKqRs9.d.mts} +49 -24
- package/dist/{types-ChaNhj42.d.mts → descriptor-bcPa6XLk.d.mts} +26 -1
- package/dist/{diagnostics-Cu94mZhl.mjs → diagnostics-DmUZtrJ6.mjs} +3 -1
- package/dist/{errors-C2iuBM0G.mjs → errors-Bfd0R4_P.mjs} +16 -1
- package/dist/file-DSOcdFj0.mjs +81 -0
- package/dist/{glob-Bv0dpvCQ.mjs → glob-BXJuwy_p.mjs} +94 -6
- package/dist/index.d.mts +61 -17
- package/dist/index.mjs +1 -1
- package/dist/internal.d.mts +11 -4
- package/dist/internal.mjs +29 -5
- package/dist/loaders.d.mts +2 -3
- package/dist/loaders.mjs +2 -81
- package/dist/next.d.mts +1 -1
- package/dist/next.mjs +2 -2
- package/dist/{operators-UqWUhKQJ.mjs → operators-Cwy8Kx4o.mjs} +88 -2
- package/dist/query-style-eXENQJ0O.d.mts +22 -0
- package/dist/query.d.mts +25 -2
- package/dist/query.mjs +1 -1
- package/dist/{relational-types-DgK3rLuw.d.mts → relational-types-C8xJtLG5.d.mts} +113 -38
- package/dist/runtime.d.mts +3 -2
- package/dist/runtime.mjs +1 -1
- package/dist/types-DbFfF6JE.d.mts +181 -0
- package/dist/vite.d.mts +15 -3
- package/dist/vite.mjs +25 -2
- package/package.json +2 -2
- package/dist/build-DfKd2bpf.d.mts +0 -25
- package/dist/descriptor-D9Th5hzk.d.mts +0 -18
- package/dist/types-C-kdMblM.d.mts +0 -69
- package/dist/validation-D_XdE1pF.mjs +0 -84
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { i as groupDiagnostics, n as contentError, o as withContext, t as ContentError } from "./errors-
|
|
1
|
+
import { c as normalizeSnapshot, d as builtinLoader, f as builtinPath, l as renderedPayload, n as loadGlob, s as isRecord, u as validate } from "./glob-BXJuwy_p.mjs";
|
|
2
|
+
import { i as groupDiagnostics, n as contentError, o as payloadIdentity, r as describeCause, s as withContext, t as ContentError } from "./errors-Bfd0R4_P.mjs";
|
|
3
3
|
import { i as detach, l as valuePath, o as encode } from "./values-BbTV4QT-.mjs";
|
|
4
4
|
import { t as assertServerOnly } from "./server-only-CIJUlyL-.mjs";
|
|
5
|
-
import {
|
|
5
|
+
import { n as loadFile } from "./file-DSOcdFj0.mjs";
|
|
6
|
+
import { a as isRelationship, i as variantKey, n as createRuntime, o as prepareRelationships, r as isQueryStyle, s as CODEC_VERSION } from "./content-BXrgP7nL.mjs";
|
|
6
7
|
import { t as renderMarkdown } from "./markdown-CHvBBJNa.mjs";
|
|
7
8
|
import { access, link, mkdir, readFile, readdir, realpath, rm, rmdir, stat, writeFile } from "node:fs/promises";
|
|
8
9
|
import path from "node:path";
|
|
@@ -35,7 +36,8 @@ async function revisionModules(revision, { payloadChunkBytes = 0 } = {}) {
|
|
|
35
36
|
const manifest = {
|
|
36
37
|
version: 2,
|
|
37
38
|
codec: CODEC_VERSION,
|
|
38
|
-
collections: revision.registry
|
|
39
|
+
collections: revision.registry,
|
|
40
|
+
...revision.queryStyle ? { queryStyle: revision.queryStyle } : {}
|
|
39
41
|
};
|
|
40
42
|
const imports = [];
|
|
41
43
|
const names = [];
|
|
@@ -82,12 +84,34 @@ async function revisionModules(revision, { payloadChunkBytes = 0 } = {}) {
|
|
|
82
84
|
payloads.push(`[${JSON.stringify(id)}]: () => import(${JSON.stringify(`./entries/${key}.js`)})`);
|
|
83
85
|
}
|
|
84
86
|
flushBatch();
|
|
87
|
+
const custom = [];
|
|
88
|
+
for (const [payload, entries] of collection.payloads ?? []) {
|
|
89
|
+
const payloadDirectory = `payloads/${directoryName(payload)}`;
|
|
90
|
+
const rows = [];
|
|
91
|
+
for (const [id, variants] of entries) {
|
|
92
|
+
const loaders = [];
|
|
93
|
+
for (const [variant, load] of variants) {
|
|
94
|
+
const encoded = load.encoded ?? encode(await load());
|
|
95
|
+
const key = digest([
|
|
96
|
+
name,
|
|
97
|
+
payload,
|
|
98
|
+
id,
|
|
99
|
+
variant,
|
|
100
|
+
encoded
|
|
101
|
+
]);
|
|
102
|
+
publishPayload(`${directory}/${payloadDirectory}/${key}.js`, `export default ${JSON.stringify(encoded)};\n`);
|
|
103
|
+
loaders.push(`${JSON.stringify(variant)}: () => import(${JSON.stringify(`./${payloadDirectory}/${key}.js`)})`);
|
|
104
|
+
}
|
|
105
|
+
rows.push(`${JSON.stringify(id)}: { ${loaders.join(", ")} }`);
|
|
106
|
+
}
|
|
107
|
+
custom.push(`${JSON.stringify(payload)}: {\n${rows.join(",\n")}\n}`);
|
|
108
|
+
}
|
|
85
109
|
const recordSource = JSON.stringify(collection.records.map(encode));
|
|
86
110
|
const compressedRecords = compactJson(recordSource);
|
|
87
111
|
const helpers = [...chunks.length ? ["createPayloadLoaders"] : [], ...compressedRecords ? ["decodeCompressed"] : []];
|
|
88
112
|
const loaders = helpers.length ? `import { ${helpers.join(", ")} } from "content-query/internal";\n` : "";
|
|
89
113
|
const loading = chunks.length ? `createPayloadLoaders([${chunks.join(",\n")}])` : `{ ${payloads.join(",\n")} }`;
|
|
90
|
-
const code = `${loaders}export default { records: ${compressedRecords ? `decodeCompressed(${compressedRecords})` : recordSource}, builtins: ${loading} };\n`;
|
|
114
|
+
const code = `${loaders}export default { records: ${compressedRecords ? `decodeCompressed(${compressedRecords})` : recordSource}, builtins: ${loading}${custom.length ? `, payloads: {\n${custom.join(",\n")}\n}` : ""} };\n`;
|
|
91
115
|
const index = `index-${digest(code)}.js`;
|
|
92
116
|
modules.set(`${directory}/${index}`, code);
|
|
93
117
|
modules.set(`${directory}/index.js`, `export { default } from ${JSON.stringify(`./${index}`)};\n`);
|
|
@@ -102,13 +126,39 @@ async function revisionModules(revision, { payloadChunkBytes = 0 } = {}) {
|
|
|
102
126
|
}
|
|
103
127
|
//#endregion
|
|
104
128
|
//#region src/artifacts/declarations.ts
|
|
105
|
-
/**
|
|
129
|
+
/**
|
|
130
|
+
* Generates type-only consumer bindings without loading schemas or adding runtime imports.
|
|
131
|
+
*
|
|
132
|
+
* The registry and the optional `queryStyle` both come from the config module's types, so a
|
|
133
|
+
* literal `export const queryStyle = "relational"` narrows `content` without a rebuild. A missing
|
|
134
|
+
* export, or one typed as the whole `QueryStyle` union, allows both query styles.
|
|
135
|
+
*
|
|
136
|
+
* @param directory - Directory that will contain the declaration file.
|
|
137
|
+
* @param config - Absolute path of content-query.config.
|
|
138
|
+
* @returns Declaration source for `server.d.ts`.
|
|
139
|
+
*/
|
|
106
140
|
function contentDeclarations(directory, config) {
|
|
107
141
|
let relative = path.relative(directory, config).replace(/\\/g, "/");
|
|
108
142
|
if (!relative.startsWith(".")) relative = `./${relative}`;
|
|
109
|
-
|
|
143
|
+
const source = JSON.stringify(relative);
|
|
144
|
+
return [
|
|
145
|
+
`type Registry = typeof import(${source}).collections;`,
|
|
146
|
+
`type ConfiguredQueryStyle = typeof import(${source}) extends { queryStyle: infer Style extends import("content-query/runtime").QueryStyle } ? Style : import("content-query/runtime").QueryStyle;`,
|
|
147
|
+
`export type CollectionKey = keyof Registry & string;`,
|
|
148
|
+
`export type CollectionData<Name extends CollectionKey> = import("content-query").QueryData<Registry[Name]>;`,
|
|
149
|
+
`export type Collections = { [Name in CollectionKey]: CollectionData<Name> };`,
|
|
150
|
+
`export type CollectionPayload<Name extends CollectionKey, Payload extends keyof import("content-query").PayloadsOf<Registry[Name]>> = import("content-query").PayloadData<Registry[Name], Payload>;`,
|
|
151
|
+
`export declare const content: import("content-query/runtime").Content<Registry, ConfiguredQueryStyle>;`,
|
|
152
|
+
``
|
|
153
|
+
].join("\n");
|
|
110
154
|
}
|
|
111
|
-
/**
|
|
155
|
+
/**
|
|
156
|
+
* Declares project-bound virtual server types and the framework-neutral development bridge.
|
|
157
|
+
*
|
|
158
|
+
* @param directory - Directory that will contain the declaration file.
|
|
159
|
+
* @param config - Absolute path of content-query.config.
|
|
160
|
+
* @returns Declaration source for the `content-query:server` and `content-query:client` modules.
|
|
161
|
+
*/
|
|
112
162
|
function virtualDeclarations(directory, config) {
|
|
113
163
|
return `declare module "content-query:server" {\n${contentDeclarations(directory, config).replace("export declare const", "export const")}\n}\ndeclare module "content-query:client" {\n export function onContentUpdate(callback: () => void | Promise<void>): () => void;\n}\n`;
|
|
114
164
|
}
|
|
@@ -162,7 +212,7 @@ async function writeImmutable(filename, source) {
|
|
|
162
212
|
await rm(temporary, { force: true });
|
|
163
213
|
}
|
|
164
214
|
}
|
|
165
|
-
const immutableModule = /\/(?:entries\/[a-f0-9]{64}|index-[a-f0-9]{64})\.js$/;
|
|
215
|
+
const immutableModule = /\/(?:entries\/[a-f0-9]{64}|payloads\/[^/]+\/[a-f0-9]{64}|index-[a-f0-9]{64})\.js$/;
|
|
166
216
|
/** Lists one directory's entries, treating a missing directory or a file as empty. */
|
|
167
217
|
async function entriesOf(directory) {
|
|
168
218
|
try {
|
|
@@ -196,9 +246,14 @@ async function pruneArtifacts(outDir, modules) {
|
|
|
196
246
|
for (const directory of await entriesOf(collections)) {
|
|
197
247
|
const owned = path.join(collections, directory);
|
|
198
248
|
const entries = path.join(owned, "entries");
|
|
249
|
+
const payloads = path.join(owned, "payloads");
|
|
250
|
+
const payloadDirectories = await entriesOf(payloads);
|
|
199
251
|
const candidates = [...(await entriesOf(owned)).map((name) => `collections/${directory}/${name}`), ...(await entriesOf(entries)).map((name) => `collections/${directory}/entries/${name}`)];
|
|
252
|
+
for (const payload of payloadDirectories) candidates.push(...(await entriesOf(path.join(payloads, payload))).map((name) => `collections/${directory}/payloads/${payload}/${name}`));
|
|
200
253
|
for (const filename of candidates) if ((immutableModule.test(`/${filename}`) || filename.endsWith(`/${directory}/index.js`)) && !modules.has(filename)) await rm(path.join(outDir, filename), { force: true });
|
|
201
254
|
await removeIfEmpty(entries);
|
|
255
|
+
for (const payload of payloadDirectories) await removeIfEmpty(path.join(payloads, payload));
|
|
256
|
+
await removeIfEmpty(payloads);
|
|
202
257
|
await removeIfEmpty(owned);
|
|
203
258
|
}
|
|
204
259
|
}
|
|
@@ -243,21 +298,195 @@ function hasBuiltinPayloads(definition) {
|
|
|
243
298
|
return builtinLoader(definition.loader)?.kind !== "file";
|
|
244
299
|
}
|
|
245
300
|
/**
|
|
301
|
+
* Describes a collection's custom payloads by their sorted variant keys. Underscore names belong
|
|
302
|
+
* to built-ins, so an invalid declaration never shadows them; preparation reports it instead.
|
|
303
|
+
*
|
|
304
|
+
* @param definition - A collection definition.
|
|
305
|
+
* @returns Payload descriptors keyed by custom payload name.
|
|
306
|
+
*/
|
|
307
|
+
function customPayloads(definition) {
|
|
308
|
+
return Object.fromEntries(Object.entries(definition.payloads ?? {}).filter(([name]) => !name.startsWith("_")).map(([name, payload]) => [name, { variants: Object.keys(payload?.variants ?? {}).sort() }]));
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
246
311
|
* Builds the query registry that runtimes validate queries against, without resolvers or schemas.
|
|
247
312
|
*
|
|
248
313
|
* @param registry - The configured collection definitions.
|
|
249
|
-
* @returns Relationship and payload descriptors for each collection.
|
|
314
|
+
* @returns Relationship and payload descriptors for each collection, built-in and custom.
|
|
250
315
|
*/
|
|
251
316
|
function queryRegistry(registry) {
|
|
252
317
|
return Object.fromEntries(Object.entries(registry).map(([name, definition]) => [name, {
|
|
253
318
|
relationships: definition.relationships ?? {},
|
|
254
|
-
payloads:
|
|
319
|
+
payloads: {
|
|
320
|
+
...hasBuiltinPayloads(definition) ? builtinPayloads : {},
|
|
321
|
+
...customPayloads(definition)
|
|
322
|
+
}
|
|
255
323
|
}]));
|
|
256
324
|
}
|
|
257
325
|
//#endregion
|
|
326
|
+
//#region src/shared/paths.ts
|
|
327
|
+
/** Checks containment without confusing sibling prefixes, drive roots, or trailing separators. */
|
|
328
|
+
function withinDirectory(directory, filename) {
|
|
329
|
+
const relative = path.relative(directory, filename);
|
|
330
|
+
return relative === "" || relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
331
|
+
}
|
|
332
|
+
/** Resolves existing symlink ancestors even when the output directory does not exist yet. */
|
|
333
|
+
async function canonicalPath(filename) {
|
|
334
|
+
try {
|
|
335
|
+
return await realpath(filename);
|
|
336
|
+
} catch (cause) {
|
|
337
|
+
if (!cause || typeof cause !== "object" || !("code" in cause) || cause.code !== "ENOENT") throw cause;
|
|
338
|
+
const parent = path.dirname(filename);
|
|
339
|
+
if (parent === filename) throw cause;
|
|
340
|
+
return path.join(await canonicalPath(parent), path.basename(filename));
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
//#endregion
|
|
344
|
+
//#region src/preparation/files.ts
|
|
345
|
+
/**
|
|
346
|
+
* Finds the folder an entry's `files` helpers read from: the loader's `source.directory`, or the
|
|
347
|
+
* folder of its `source.filePath`. Both are relative to the project root.
|
|
348
|
+
*
|
|
349
|
+
* @param root - The project root.
|
|
350
|
+
* @param source - The entry's loader-supplied source metadata.
|
|
351
|
+
* @returns The absolute folder, or undefined when the source names neither.
|
|
352
|
+
*/
|
|
353
|
+
function entryFolder(root, source) {
|
|
354
|
+
if (!source || typeof source !== "object") return;
|
|
355
|
+
const { directory, filePath } = source;
|
|
356
|
+
if (typeof directory === "string" && directory) return path.resolve(root, directory);
|
|
357
|
+
if (typeof filePath === "string" && filePath) return path.resolve(root, path.dirname(filePath));
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Creates tracked file helpers for one resolver call. Reads are scoped to the entry's folder and
|
|
361
|
+
* the collection's shared directories, and each read is recorded as a dependency.
|
|
362
|
+
*
|
|
363
|
+
* @param options - The entry's roots, cancellation, and the dependency list to record into.
|
|
364
|
+
* @returns The `files` object resolvers receive.
|
|
365
|
+
*/
|
|
366
|
+
function createEntryFiles(options) {
|
|
367
|
+
const { root, folder, shared, entry, signal, dependencies } = options;
|
|
368
|
+
const roots = [...folder ? [folder] : [], ...shared];
|
|
369
|
+
/** Resolves a path from the entry's folder and rejects one outside every allowed root. */
|
|
370
|
+
function resolve(filePath) {
|
|
371
|
+
if (typeof filePath !== "string" || !filePath) throw new ContentError({
|
|
372
|
+
code: "source-error",
|
|
373
|
+
message: "files helpers require a nonempty path."
|
|
374
|
+
});
|
|
375
|
+
if (!folder && !path.isAbsolute(filePath)) throw new ContentError({
|
|
376
|
+
code: "source-error",
|
|
377
|
+
message: `Entry "${entry}" has no folder for files helpers. glob() and file() entries use their file's folder, and custom loaders can set source.directory.`
|
|
378
|
+
});
|
|
379
|
+
const resolved = path.resolve(folder ?? root, filePath);
|
|
380
|
+
if (!roots.some((allowed) => withinDirectory(allowed, resolved))) throw new ContentError({
|
|
381
|
+
code: "source-error",
|
|
382
|
+
source: resolved,
|
|
383
|
+
message: `"${filePath}" is outside entry "${entry}"'s folder and the collection's sharedFiles directories.`
|
|
384
|
+
});
|
|
385
|
+
return resolved;
|
|
386
|
+
}
|
|
387
|
+
/** Reads a file and records it, reporting a missing or unreadable file as a source error. */
|
|
388
|
+
async function read(filePath, reader) {
|
|
389
|
+
const resolved = resolve(filePath);
|
|
390
|
+
dependencies.push({
|
|
391
|
+
kind: "file",
|
|
392
|
+
path: resolved
|
|
393
|
+
});
|
|
394
|
+
try {
|
|
395
|
+
return await reader(resolved);
|
|
396
|
+
} catch (cause) {
|
|
397
|
+
signal.throwIfAborted();
|
|
398
|
+
throw contentError(cause, {
|
|
399
|
+
code: "source-error",
|
|
400
|
+
source: resolved,
|
|
401
|
+
message: `Could not read "${filePath}": ${describeCause(cause)}`
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
/** Validates one helper entry's data, reporting failures against that file. */
|
|
406
|
+
async function entryOf(schema, value, filePath) {
|
|
407
|
+
const data = await validate(schema, value.data, { source: value.source });
|
|
408
|
+
return {
|
|
409
|
+
id: value.id,
|
|
410
|
+
data,
|
|
411
|
+
...value.body === void 0 ? {} : { body: value.body },
|
|
412
|
+
filePath
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
return {
|
|
416
|
+
async glob(globOptions) {
|
|
417
|
+
const { schema, base, ...loaderOptions } = globOptions ?? {};
|
|
418
|
+
const patterns = typeof loaderOptions.pattern === "string" ? [loaderOptions.pattern] : loaderOptions.pattern;
|
|
419
|
+
if (!Array.isArray(patterns) || patterns.some((pattern) => typeof pattern !== "string" || path.posix.isAbsolute(pattern.replace(/^!/, "")) || pattern.replace(/^!/, "").split("/").includes(".."))) throw new ContentError({
|
|
420
|
+
code: "source-error",
|
|
421
|
+
message: "files.glob() requires relative patterns that stay inside their base."
|
|
422
|
+
});
|
|
423
|
+
const directory = resolve(base ?? ".");
|
|
424
|
+
const options = {
|
|
425
|
+
...loaderOptions,
|
|
426
|
+
base: directory
|
|
427
|
+
};
|
|
428
|
+
dependencies.push({
|
|
429
|
+
kind: "glob",
|
|
430
|
+
base: directory,
|
|
431
|
+
options
|
|
432
|
+
});
|
|
433
|
+
const entries = await loadGlob({
|
|
434
|
+
kind: "glob",
|
|
435
|
+
options
|
|
436
|
+
}, root, signal, { warnEmpty: false });
|
|
437
|
+
const retained = loaderOptions.retainBody !== false;
|
|
438
|
+
return Promise.all(entries.map((value) => {
|
|
439
|
+
dependencies.push({
|
|
440
|
+
kind: "file",
|
|
441
|
+
path: value.source
|
|
442
|
+
});
|
|
443
|
+
return entryOf(schema, {
|
|
444
|
+
...value,
|
|
445
|
+
body: retained ? value.body : void 0
|
|
446
|
+
}, value.sourceData.filePath);
|
|
447
|
+
}));
|
|
448
|
+
},
|
|
449
|
+
async file(filePath, fileOptions = {}) {
|
|
450
|
+
const { schema, ...loaderOptions } = fileOptions;
|
|
451
|
+
const resolved = resolve(filePath);
|
|
452
|
+
dependencies.push({
|
|
453
|
+
kind: "file",
|
|
454
|
+
path: resolved
|
|
455
|
+
});
|
|
456
|
+
const descriptor = {
|
|
457
|
+
kind: "file",
|
|
458
|
+
filename: resolved,
|
|
459
|
+
options: loaderOptions
|
|
460
|
+
};
|
|
461
|
+
const entries = await loadFile(descriptor, root, signal);
|
|
462
|
+
const relative = path.relative(root, builtinPath(descriptor, root)).split(path.sep).join("/");
|
|
463
|
+
return Promise.all(entries.map((value) => entryOf(schema, value, relative)));
|
|
464
|
+
},
|
|
465
|
+
text(filePath) {
|
|
466
|
+
return read(filePath, (resolved) => readFile(resolved, {
|
|
467
|
+
encoding: "utf8",
|
|
468
|
+
signal
|
|
469
|
+
}));
|
|
470
|
+
},
|
|
471
|
+
bytes(filePath) {
|
|
472
|
+
return read(filePath, async (resolved) => new Uint8Array(await readFile(resolved, { signal })));
|
|
473
|
+
},
|
|
474
|
+
async renderMarkdown(body) {
|
|
475
|
+
const { html, headings } = await renderMarkdown(body);
|
|
476
|
+
return {
|
|
477
|
+
html,
|
|
478
|
+
metadata: { headings }
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
//#endregion
|
|
258
484
|
//#region src/preparation/prepare.ts
|
|
259
|
-
/**
|
|
260
|
-
|
|
485
|
+
/**
|
|
486
|
+
* Prepares one immutable revision with loader-defined source metadata, aggregating failures.
|
|
487
|
+
* `root` is the project root that entry folders and `sharedFiles` resolve against.
|
|
488
|
+
*/
|
|
489
|
+
async function prepareRevision(registry, sources, cache, signal, development, sourceDiagnostics = [], parsedData, { root = process.cwd() } = {}) {
|
|
261
490
|
const diagnostics = [...sourceDiagnostics];
|
|
262
491
|
const collections = Object.create(null);
|
|
263
492
|
for (const [collectionName, definition] of Object.entries(registry)) {
|
|
@@ -279,7 +508,18 @@ async function prepareRevision(registry, sources, cache, signal, development, so
|
|
|
279
508
|
});
|
|
280
509
|
continue;
|
|
281
510
|
}
|
|
511
|
+
const payloadProblem = invalidPayloads(definition.payloads, declarationNames) ?? invalidSharedFiles(definition.sharedFiles);
|
|
512
|
+
if (payloadProblem) {
|
|
513
|
+
diagnostics.push({
|
|
514
|
+
code: "validation-error",
|
|
515
|
+
operation: "build",
|
|
516
|
+
collection: collectionName,
|
|
517
|
+
message: payloadProblem
|
|
518
|
+
});
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
282
521
|
const descriptor = builtinLoader(definition.loader);
|
|
522
|
+
const shared = (definition.sharedFiles ?? []).map((directory) => path.resolve(root, directory));
|
|
283
523
|
for (const entry of sources.get(collectionName)?.values() ?? []) {
|
|
284
524
|
signal.throwIfAborted();
|
|
285
525
|
const context = {
|
|
@@ -308,6 +548,15 @@ async function prepareRevision(registry, sources, cache, signal, development, so
|
|
|
308
548
|
const metadata = detach(validated);
|
|
309
549
|
const values = Object.create(null);
|
|
310
550
|
const before = diagnostics.length;
|
|
551
|
+
const dependencies = [];
|
|
552
|
+
const files = createEntryFiles({
|
|
553
|
+
root,
|
|
554
|
+
folder: entryFolder(root, entry.sourceData),
|
|
555
|
+
shared,
|
|
556
|
+
entry: entry.id,
|
|
557
|
+
signal,
|
|
558
|
+
dependencies
|
|
559
|
+
});
|
|
311
560
|
for (const [field, computedField] of Object.entries(computed)) try {
|
|
312
561
|
const result = await computedField.resolve({
|
|
313
562
|
collection: collectionName,
|
|
@@ -315,6 +564,7 @@ async function prepareRevision(registry, sources, cache, signal, development, so
|
|
|
315
564
|
source: detach(entry.sourceData),
|
|
316
565
|
data: detach(metadata),
|
|
317
566
|
readBody: async () => entry.body,
|
|
567
|
+
files,
|
|
318
568
|
signal
|
|
319
569
|
});
|
|
320
570
|
values[field] = await validate(computedField.schema, result, {
|
|
@@ -322,7 +572,7 @@ async function prepareRevision(registry, sources, cache, signal, development, so
|
|
|
322
572
|
path: [field]
|
|
323
573
|
});
|
|
324
574
|
} catch (cause) {
|
|
325
|
-
diagnostics.push(...
|
|
575
|
+
diagnostics.push(...located(cause, {
|
|
326
576
|
...context,
|
|
327
577
|
path: [field],
|
|
328
578
|
code: "validation-error",
|
|
@@ -359,7 +609,8 @@ async function prepareRevision(registry, sources, cache, signal, development, so
|
|
|
359
609
|
definition,
|
|
360
610
|
signature,
|
|
361
611
|
record,
|
|
362
|
-
payload
|
|
612
|
+
payload,
|
|
613
|
+
dependencies
|
|
363
614
|
});
|
|
364
615
|
records.push(record);
|
|
365
616
|
if (Object.keys(payload).length) builtins.set(entry.id, async () => payload);
|
|
@@ -393,9 +644,207 @@ async function prepareRevision(registry, sources, cache, signal, development, so
|
|
|
393
644
|
};
|
|
394
645
|
const failed = diagnostics.map((diagnostic) => diagnostic.collection);
|
|
395
646
|
if (!failed.includes(void 0)) diagnostics.push(...prepareRelationships(revision, new Set(failed)).flatMap((error) => error.diagnostics));
|
|
647
|
+
const unresolved = new Set(diagnostics.map((diagnostic) => diagnostic.collection));
|
|
648
|
+
if (!unresolved.has(void 0)) {
|
|
649
|
+
for (const [collectionName, definition] of Object.entries(registry)) if (!unresolved.has(collectionName) && Object.keys(definition.payloads ?? {}).length) collections[collectionName].payloads = await preparePayloads(collectionName, definition.payloads, collections[collectionName].records, sources.get(collectionName), cache, signal, diagnostics, {
|
|
650
|
+
root,
|
|
651
|
+
shared: (definition.sharedFiles ?? []).map((directory) => path.resolve(root, directory))
|
|
652
|
+
});
|
|
653
|
+
}
|
|
396
654
|
if (diagnostics.length) throw groupDiagnostics(diagnostics);
|
|
397
655
|
return revision;
|
|
398
656
|
}
|
|
657
|
+
/**
|
|
658
|
+
* Checks custom payload declarations before any entry is prepared.
|
|
659
|
+
*
|
|
660
|
+
* @param payloads - The collection's payload declarations.
|
|
661
|
+
* @param declarationNames - Computed field and relationship names the payloads must not repeat.
|
|
662
|
+
* @returns A diagnostic message, or undefined when every declaration is valid.
|
|
663
|
+
*/
|
|
664
|
+
function invalidPayloads(payloads, declarationNames) {
|
|
665
|
+
if (payloads === void 0) return;
|
|
666
|
+
if (!payloads || typeof payloads !== "object" || Array.isArray(payloads)) return "Payloads must be an object of named payload declarations.";
|
|
667
|
+
for (const [name, declaration] of Object.entries(payloads)) {
|
|
668
|
+
if (!name || name.startsWith("_") || declarationNames.includes(name)) return `Custom payload "${name}" must be named without a leading "_" and must not repeat a computed field or relationship.`;
|
|
669
|
+
const { schema, resolve, variants, combinations, cache } = declaration ?? {};
|
|
670
|
+
if (!schema || typeof schema !== "object" || !("~standard" in schema) || typeof resolve !== "function") return `Payload "${name}" requires a schema and a resolve() function.`;
|
|
671
|
+
if (variants !== void 0 && (!variants || typeof variants !== "object" || Array.isArray(variants) || Object.entries(variants).some(([key, values]) => !key || (typeof values === "string" ? !values : !Array.isArray(values) || !values.every((value) => typeof value === "string" && value))))) return `Payload "${name}" variants must map each key to a data field name or a list of values.`;
|
|
672
|
+
if (combinations !== void 0 && (typeof combinations !== "function" || !Object.keys(variants ?? {}).length)) return `Payload "${name}" combinations() must be a function and requires declared variants.`;
|
|
673
|
+
if (cache !== void 0 && typeof cache !== "boolean") return `Payload "${name}" cache must be a boolean.`;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* Lists an entry's variant combinations: the cross product of each key's values, or the
|
|
678
|
+
* declaration's validated `combinations()` override. A key's values come from a named string-array
|
|
679
|
+
* field of the entry's data, where a missing value means none, or from a fixed list.
|
|
680
|
+
* Combinations have sorted keys.
|
|
681
|
+
*
|
|
682
|
+
* @param name - The payload name, for error messages.
|
|
683
|
+
* @param payload - The payload declaration.
|
|
684
|
+
* @param context - The enumerator context for one entry.
|
|
685
|
+
* @returns Distinct combinations; a payload without variants has exactly one, `{}`.
|
|
686
|
+
*/
|
|
687
|
+
async function variantCombinations(name, payload, context) {
|
|
688
|
+
const keys = Object.keys(payload.variants ?? {}).sort();
|
|
689
|
+
const values = Object.create(null);
|
|
690
|
+
for (const key of keys) {
|
|
691
|
+
const source = payload.variants[key];
|
|
692
|
+
const listed = typeof source === "string" ? context.data[source] ?? [] : source;
|
|
693
|
+
if (!Array.isArray(listed) || !listed.every((value) => typeof value === "string" && value)) {
|
|
694
|
+
const field = typeof source === "string" ? ` in field "${String(source)}"` : "";
|
|
695
|
+
throw new Error(`Variant "${key}" of payload "${name}" must list non-empty strings${field}.`);
|
|
696
|
+
}
|
|
697
|
+
values[key] = [...new Set(listed)];
|
|
698
|
+
}
|
|
699
|
+
let combinations;
|
|
700
|
+
if (payload.combinations) {
|
|
701
|
+
const listed = await payload.combinations({
|
|
702
|
+
...context,
|
|
703
|
+
variants: values
|
|
704
|
+
});
|
|
705
|
+
if (!listed || typeof listed !== "object" || !(Symbol.iterator in listed)) throw new Error(`Payload "${name}" combinations() must return an iterable of variants.`);
|
|
706
|
+
combinations = [...listed].map((combination) => {
|
|
707
|
+
const variant = combination;
|
|
708
|
+
if (!variant || typeof variant !== "object" || Object.keys(variant).length !== keys.length || keys.some((key) => !values[key].includes(variant[key]))) throw new Error(`Each combination of payload "${name}" must give every variant key (${keys.join(", ")}) one enumerated value.`);
|
|
709
|
+
return Object.fromEntries(keys.map((key) => [key, variant[key]]));
|
|
710
|
+
});
|
|
711
|
+
} else combinations = keys.reduce((partial, key) => partial.flatMap((variant) => values[key].map((value) => ({
|
|
712
|
+
...variant,
|
|
713
|
+
[key]: value
|
|
714
|
+
}))), [{}]);
|
|
715
|
+
return [...new Map(combinations.map((variant) => [variantKey(variant), variant])).values()];
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* Adds the location of a resolver failure to every diagnostic that lacks it. A `files` helper error
|
|
719
|
+
* keeps its own source file and field path; the entry, payload, and variant come from `location`.
|
|
720
|
+
*
|
|
721
|
+
* @param cause - The resolver failure.
|
|
722
|
+
* @param location - The entry, and the payload and variant when resolving one.
|
|
723
|
+
* @returns A contextual error whose diagnostics all name the location.
|
|
724
|
+
*/
|
|
725
|
+
function located(cause, location) {
|
|
726
|
+
if (!(cause instanceof ContentError)) return contentError(cause, location);
|
|
727
|
+
return groupDiagnostics(cause.diagnostics.map((diagnostic) => ({
|
|
728
|
+
...location,
|
|
729
|
+
...Object.fromEntries(Object.entries(diagnostic).filter(([, value]) => value !== void 0))
|
|
730
|
+
})));
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* Checks a collection's `sharedFiles` directories.
|
|
734
|
+
*
|
|
735
|
+
* @param sharedFiles - The declared shared directories.
|
|
736
|
+
* @returns A diagnostic message, or undefined when the declaration is valid.
|
|
737
|
+
*/
|
|
738
|
+
function invalidSharedFiles(sharedFiles) {
|
|
739
|
+
if (sharedFiles !== void 0 && (!Array.isArray(sharedFiles) || !sharedFiles.every((directory) => typeof directory === "string" && directory))) return "sharedFiles must list directories as nonempty strings.";
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* Resolves, validates, and encodes a collection's custom payloads for every entry and variant.
|
|
743
|
+
* Each variant is cached on its own with the files it read, so unchanged entries reuse every
|
|
744
|
+
* variant and an invalidated variant re-resolves alone.
|
|
745
|
+
*
|
|
746
|
+
* @returns Lazy loaders by payload name, entry ID, and canonical variant key.
|
|
747
|
+
*/
|
|
748
|
+
async function preparePayloads(collection, payloads, records, sources, cache, signal, diagnostics, roots) {
|
|
749
|
+
const table = new Map(Object.keys(payloads).map((name) => [name, /* @__PURE__ */ new Map()]));
|
|
750
|
+
for (const record of records) {
|
|
751
|
+
const entry = sources?.get(record.id);
|
|
752
|
+
const cached = cache.get(JSON.stringify([collection, record.id]));
|
|
753
|
+
const data = Object.fromEntries(Object.entries(record).filter(([field]) => field !== "id"));
|
|
754
|
+
const folder = entryFolder(roots.root, entry?.sourceData);
|
|
755
|
+
/** Each resolver receives its own copies, so it cannot change another's inputs. */
|
|
756
|
+
const context = () => ({
|
|
757
|
+
collection,
|
|
758
|
+
id: record.id,
|
|
759
|
+
source: detach(entry?.sourceData),
|
|
760
|
+
data: detach(data),
|
|
761
|
+
signal
|
|
762
|
+
});
|
|
763
|
+
for (const [name, payload] of Object.entries(payloads)) {
|
|
764
|
+
signal.throwIfAborted();
|
|
765
|
+
const reuse = payload.cache !== false;
|
|
766
|
+
const previous = reuse ? cached?.payloads?.get(name) : void 0;
|
|
767
|
+
const base = {
|
|
768
|
+
code: "validation-error",
|
|
769
|
+
operation: "build",
|
|
770
|
+
collection,
|
|
771
|
+
id: record.id,
|
|
772
|
+
source: entry?.source,
|
|
773
|
+
payload: name,
|
|
774
|
+
message: ""
|
|
775
|
+
};
|
|
776
|
+
let combinations = previous?.combinations;
|
|
777
|
+
if (!combinations) try {
|
|
778
|
+
combinations = await variantCombinations(name, payload, {
|
|
779
|
+
...context(),
|
|
780
|
+
variants: {}
|
|
781
|
+
});
|
|
782
|
+
} catch (cause) {
|
|
783
|
+
diagnostics.push(...contentError(cause, {
|
|
784
|
+
...base,
|
|
785
|
+
message: cause instanceof Error ? cause.message : `Payload "${name}" variants failed.`
|
|
786
|
+
}).diagnostics);
|
|
787
|
+
continue;
|
|
788
|
+
}
|
|
789
|
+
const nodes = /* @__PURE__ */ new Map();
|
|
790
|
+
let complete = true;
|
|
791
|
+
for (const variant of combinations) {
|
|
792
|
+
signal.throwIfAborted();
|
|
793
|
+
const key = variantKey(variant);
|
|
794
|
+
const reused = previous?.nodes.get(key);
|
|
795
|
+
if (reused) {
|
|
796
|
+
nodes.set(key, reused);
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
799
|
+
const location = {
|
|
800
|
+
...base,
|
|
801
|
+
variant
|
|
802
|
+
};
|
|
803
|
+
const dependencies = [];
|
|
804
|
+
try {
|
|
805
|
+
const result = await payload.resolve({
|
|
806
|
+
...context(),
|
|
807
|
+
variant: { ...variant },
|
|
808
|
+
readBody: async () => entry?.body,
|
|
809
|
+
files: createEntryFiles({
|
|
810
|
+
root: roots.root,
|
|
811
|
+
folder,
|
|
812
|
+
shared: roots.shared,
|
|
813
|
+
entry: record.id,
|
|
814
|
+
signal,
|
|
815
|
+
dependencies
|
|
816
|
+
})
|
|
817
|
+
});
|
|
818
|
+
const value = detach(await validate(payload.schema, result, location));
|
|
819
|
+
nodes.set(key, {
|
|
820
|
+
value,
|
|
821
|
+
encoded: encode(value),
|
|
822
|
+
dependencies
|
|
823
|
+
});
|
|
824
|
+
} catch (cause) {
|
|
825
|
+
complete = false;
|
|
826
|
+
const formatted = cause && typeof cause === "object" && "path" in cause && typeof cause.path === "string" ? cause.path : "";
|
|
827
|
+
diagnostics.push(...located(cause, {
|
|
828
|
+
...location,
|
|
829
|
+
path: formatted ? valuePath(formatted) : void 0,
|
|
830
|
+
message: `Payload ${payloadIdentity(name, variant)} failed: ${cause instanceof Error ? cause.message : String(cause)}${formatted ? ` (at ${formatted})` : ""}`
|
|
831
|
+
}).diagnostics);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
signal.throwIfAborted();
|
|
835
|
+
if (cached && reuse) {
|
|
836
|
+
cached.payloads ??= /* @__PURE__ */ new Map();
|
|
837
|
+
cached.payloads.set(name, {
|
|
838
|
+
combinations,
|
|
839
|
+
nodes
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
if (!complete) continue;
|
|
843
|
+
table.get(name).set(record.id, new Map([...nodes].map(([key, node]) => [key, Object.assign(async () => node.value, { encoded: node.encoded })])));
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
return table;
|
|
847
|
+
}
|
|
399
848
|
//#endregion
|
|
400
849
|
//#region src/preparation/config.ts
|
|
401
850
|
/** Loads build-only configuration without retaining a previous config module value. */
|
|
@@ -418,6 +867,7 @@ async function loadRegistry(config) {
|
|
|
418
867
|
async: true,
|
|
419
868
|
forceTranspile: true
|
|
420
869
|
});
|
|
870
|
+
if (imported.queryStyle !== void 0 && !isQueryStyle(imported.queryStyle)) throw new Error("content-query.config queryStyle must be \"fluent\" or \"relational\".");
|
|
421
871
|
if (imported.loaderConfig !== void 0 && (!imported.loaderConfig || typeof imported.loaderConfig !== "object" || Array.isArray(imported.loaderConfig))) throw new Error("content-query.config loaderConfig must be an object.");
|
|
422
872
|
const registry = imported.collections;
|
|
423
873
|
if (!registry || typeof registry !== "object" || Array.isArray(registry)) throw new Error("content-query.config must export a collections registry.");
|
|
@@ -425,28 +875,11 @@ async function loadRegistry(config) {
|
|
|
425
875
|
return {
|
|
426
876
|
registry,
|
|
427
877
|
dependencies,
|
|
428
|
-
loaderConfig: imported.loaderConfig
|
|
878
|
+
loaderConfig: imported.loaderConfig,
|
|
879
|
+
queryStyle: imported.queryStyle
|
|
429
880
|
};
|
|
430
881
|
}
|
|
431
882
|
//#endregion
|
|
432
|
-
//#region src/shared/paths.ts
|
|
433
|
-
/** Checks containment without confusing sibling prefixes, drive roots, or trailing separators. */
|
|
434
|
-
function withinDirectory(directory, filename) {
|
|
435
|
-
const relative = path.relative(directory, filename);
|
|
436
|
-
return relative === "" || relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
437
|
-
}
|
|
438
|
-
/** Resolves existing symlink ancestors even when the output directory does not exist yet. */
|
|
439
|
-
async function canonicalPath(filename) {
|
|
440
|
-
try {
|
|
441
|
-
return await realpath(filename);
|
|
442
|
-
} catch (cause) {
|
|
443
|
-
if (!cause || typeof cause !== "object" || !("code" in cause) || cause.code !== "ENOENT") throw cause;
|
|
444
|
-
const parent = path.dirname(filename);
|
|
445
|
-
if (parent === filename) throw cause;
|
|
446
|
-
return path.join(await canonicalPath(parent), path.basename(filename));
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
//#endregion
|
|
450
883
|
//#region src/loaders/match.ts
|
|
451
884
|
const matchers = /* @__PURE__ */ new WeakMap();
|
|
452
885
|
/**
|
|
@@ -491,6 +924,7 @@ const awaitWriteFinish = {
|
|
|
491
924
|
stabilityThreshold: 50,
|
|
492
925
|
pollInterval: 10
|
|
493
926
|
};
|
|
927
|
+
const NEW_DIRECTORY_RECHECKS_MS = [100, 1e3];
|
|
494
928
|
/** Finds an existing ancestor so watching also detects a missing base directory's creation. */
|
|
495
929
|
async function watchAncestor(filename) {
|
|
496
930
|
let ancestor = filename;
|
|
@@ -552,6 +986,7 @@ async function watchProject(options, register) {
|
|
|
552
986
|
});
|
|
553
987
|
register(watcher);
|
|
554
988
|
watcher.on("all", (event, filename) => {
|
|
989
|
+
options.dependencyChanged?.(event, filename);
|
|
555
990
|
if (event === "addDir") return;
|
|
556
991
|
if (configDependency(options, filename)) {
|
|
557
992
|
options.configChanged();
|
|
@@ -615,12 +1050,188 @@ async function watchSources(options, register) {
|
|
|
615
1050
|
}
|
|
616
1051
|
if (dedicated) {
|
|
617
1052
|
external.on("all", changed);
|
|
1053
|
+
if (external !== shared) external.on("all", (event, filename) => options.dependencyChanged?.(event, filename));
|
|
618
1054
|
external.on("error", (cause) => options.failed(cause));
|
|
1055
|
+
if (builtin.kind === "glob") recheckNewDirectories(external, shared, (filename) => affectsSnapshot(builtin, target, "add", filename), () => options.invalidated(name), register);
|
|
619
1056
|
await ready(external);
|
|
620
1057
|
}
|
|
621
1058
|
}
|
|
622
1059
|
}
|
|
623
1060
|
}
|
|
1061
|
+
/**
|
|
1062
|
+
* Invalidates a glob collection when a new directory holds matching files that no watcher event
|
|
1063
|
+
* reported. Chokidar subscribes to a new directory only after reading it, so a file created in
|
|
1064
|
+
* that gap is otherwise missed until something else reloads the collection.
|
|
1065
|
+
*
|
|
1066
|
+
* @param directories - The watcher that reports new directories under the glob base.
|
|
1067
|
+
* @param shared - The project watcher, which reports ordinary files when it covers the base.
|
|
1068
|
+
* @param matches - Whether a file belongs to the collection's snapshot.
|
|
1069
|
+
* @param invalidate - Reloads the collection from disk.
|
|
1070
|
+
* @param register - Registers cleanup for pending rechecks and listeners.
|
|
1071
|
+
*/
|
|
1072
|
+
function recheckNewDirectories(directories, shared, matches, invalidate, register) {
|
|
1073
|
+
const pending = /* @__PURE__ */ new Set();
|
|
1074
|
+
const reported = /* @__PURE__ */ new Set();
|
|
1075
|
+
let closed = false;
|
|
1076
|
+
/** Records added or changed files while a recheck could otherwise report them twice. */
|
|
1077
|
+
function track(event, filename) {
|
|
1078
|
+
if (pending.size && (event === "add" || event === "change")) reported.add(path.resolve(filename));
|
|
1079
|
+
}
|
|
1080
|
+
/** Reloads only when the directory holds a matching file that no event reported. */
|
|
1081
|
+
async function recheck(directory) {
|
|
1082
|
+
let files;
|
|
1083
|
+
try {
|
|
1084
|
+
files = (await readdir(directory, {
|
|
1085
|
+
recursive: true,
|
|
1086
|
+
withFileTypes: true
|
|
1087
|
+
})).filter((entry) => entry.isFile()).map((entry) => path.resolve(entry.parentPath, entry.name));
|
|
1088
|
+
} catch {
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
if (!closed && files.some((filename) => matches(filename) && !reported.has(filename))) invalidate();
|
|
1092
|
+
}
|
|
1093
|
+
/** Schedules rechecks for a newly created directory. */
|
|
1094
|
+
function added(directory) {
|
|
1095
|
+
for (const delay of NEW_DIRECTORY_RECHECKS_MS) {
|
|
1096
|
+
const timer = setTimeout(() => {
|
|
1097
|
+
recheck(directory).finally(() => {
|
|
1098
|
+
pending.delete(timer);
|
|
1099
|
+
if (!pending.size) reported.clear();
|
|
1100
|
+
});
|
|
1101
|
+
}, delay);
|
|
1102
|
+
timer.unref();
|
|
1103
|
+
pending.add(timer);
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
directories.on("all", track);
|
|
1107
|
+
directories.on("addDir", added);
|
|
1108
|
+
shared?.on("all", track);
|
|
1109
|
+
register(() => {
|
|
1110
|
+
closed = true;
|
|
1111
|
+
directories.off("all", track);
|
|
1112
|
+
directories.off("addDir", added);
|
|
1113
|
+
shared?.off("all", track);
|
|
1114
|
+
pending.forEach(clearTimeout);
|
|
1115
|
+
pending.clear();
|
|
1116
|
+
reported.clear();
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
/**
|
|
1120
|
+
* Watches files and directories that resolvers read when no other watcher reports them: paths
|
|
1121
|
+
* outside the project root or below a directory the project watcher ignores, such as `dist/`.
|
|
1122
|
+
*
|
|
1123
|
+
* @param options - Watch options with the project root and the dependency callback.
|
|
1124
|
+
* @returns `add()` to watch further dependency paths, and `close()` to stop watching.
|
|
1125
|
+
*/
|
|
1126
|
+
function watchDependencies(options) {
|
|
1127
|
+
const ignoredByProject = projectIgnored(options);
|
|
1128
|
+
const watched = /* @__PURE__ */ new Set();
|
|
1129
|
+
let watcher;
|
|
1130
|
+
return {
|
|
1131
|
+
add(paths) {
|
|
1132
|
+
const uncovered = [...paths].filter((filename) => !watched.has(filename) && (ignoredByProject(filename) || !(withinDirectory(options.root, filename) || withinDirectory(path.dirname(options.config), filename))));
|
|
1133
|
+
if (!uncovered.length) return;
|
|
1134
|
+
uncovered.forEach((filename) => watched.add(filename));
|
|
1135
|
+
if (watcher) {
|
|
1136
|
+
watcher.add(uncovered);
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
1139
|
+
watcher = watch(uncovered, {
|
|
1140
|
+
ignoreInitial: true,
|
|
1141
|
+
awaitWriteFinish
|
|
1142
|
+
});
|
|
1143
|
+
watcher.on("all", (event, filename) => options.dependencyChanged?.(event, filename));
|
|
1144
|
+
watcher.on("error", (cause) => options.failed(cause));
|
|
1145
|
+
},
|
|
1146
|
+
async close() {
|
|
1147
|
+
await watcher?.close();
|
|
1148
|
+
}
|
|
1149
|
+
};
|
|
1150
|
+
}
|
|
1151
|
+
//#endregion
|
|
1152
|
+
//#region src/preparation/dependencies.ts
|
|
1153
|
+
/**
|
|
1154
|
+
* Indexes every file and glob that cached entries and payload variants read.
|
|
1155
|
+
*
|
|
1156
|
+
* @param cache - The preparation cache, whose nodes record their dependencies.
|
|
1157
|
+
* @returns A reverse index from files and globs to nodes.
|
|
1158
|
+
*/
|
|
1159
|
+
function dependencyIndex(cache) {
|
|
1160
|
+
const index = {
|
|
1161
|
+
files: /* @__PURE__ */ new Map(),
|
|
1162
|
+
globs: []
|
|
1163
|
+
};
|
|
1164
|
+
/** Records one node's dependencies. */
|
|
1165
|
+
function add(node, dependencies) {
|
|
1166
|
+
for (const dependency of dependencies) if (dependency.kind === "file") {
|
|
1167
|
+
const nodes = index.files.get(dependency.path);
|
|
1168
|
+
if (nodes) nodes.push(node);
|
|
1169
|
+
else index.files.set(dependency.path, [node]);
|
|
1170
|
+
} else index.globs.push({
|
|
1171
|
+
base: dependency.base,
|
|
1172
|
+
options: dependency.options,
|
|
1173
|
+
node
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
1176
|
+
for (const [key, entry] of cache) {
|
|
1177
|
+
add({ key }, entry.dependencies);
|
|
1178
|
+
for (const [payload, cached] of entry.payloads ?? []) for (const [variant, node] of cached.nodes) add({
|
|
1179
|
+
key,
|
|
1180
|
+
payload,
|
|
1181
|
+
variant
|
|
1182
|
+
}, node.dependencies);
|
|
1183
|
+
}
|
|
1184
|
+
return index;
|
|
1185
|
+
}
|
|
1186
|
+
/**
|
|
1187
|
+
* Finds the nodes a file event affects: nodes that read the file, nodes whose glob gains or loses
|
|
1188
|
+
* a match, nodes whose glob base gains a directory, and, for a removed directory, nodes that read
|
|
1189
|
+
* anything inside it.
|
|
1190
|
+
*
|
|
1191
|
+
* @param index - The dependency index.
|
|
1192
|
+
* @param event - The watcher event, such as `add`, `change`, `unlink`, or `unlinkDir`.
|
|
1193
|
+
* @param filename - The affected path.
|
|
1194
|
+
* @returns Distinct affected nodes.
|
|
1195
|
+
*/
|
|
1196
|
+
function dependents(index, event, filename) {
|
|
1197
|
+
const resolved = path.resolve(filename);
|
|
1198
|
+
const found = /* @__PURE__ */ new Map();
|
|
1199
|
+
/** Adds a node once. */
|
|
1200
|
+
const include = (node) => found.set(JSON.stringify([
|
|
1201
|
+
node.key,
|
|
1202
|
+
node.payload,
|
|
1203
|
+
node.variant
|
|
1204
|
+
]), node);
|
|
1205
|
+
for (const node of index.files.get(resolved) ?? []) include(node);
|
|
1206
|
+
if (event === "unlinkDir") {
|
|
1207
|
+
for (const [file, nodes] of index.files) if (withinDirectory(resolved, file)) nodes.forEach(include);
|
|
1208
|
+
}
|
|
1209
|
+
for (const glob of index.globs) if (event === "addDir" ? resolved !== glob.base && withinDirectory(glob.base, resolved) : affectsSnapshot({
|
|
1210
|
+
kind: "glob",
|
|
1211
|
+
options: glob.options
|
|
1212
|
+
}, glob.base, event, resolved)) include(glob.node);
|
|
1213
|
+
return [...found.values()];
|
|
1214
|
+
}
|
|
1215
|
+
/**
|
|
1216
|
+
* Drops affected nodes from the cache, so the next preparation resolves them again. An entry node
|
|
1217
|
+
* drops the entry's whole cache record; a payload node drops only that variant.
|
|
1218
|
+
*
|
|
1219
|
+
* @param cache - The preparation cache.
|
|
1220
|
+
* @param nodes - The nodes to invalidate.
|
|
1221
|
+
*/
|
|
1222
|
+
function invalidateNodes(cache, nodes) {
|
|
1223
|
+
for (const node of nodes) if (node.payload === void 0) cache.delete(node.key);
|
|
1224
|
+
else cache.get(node.key)?.payloads?.get(node.payload)?.nodes.delete(node.variant);
|
|
1225
|
+
}
|
|
1226
|
+
/**
|
|
1227
|
+
* Lists the paths that dependencies need watched: each read file and each glob base.
|
|
1228
|
+
*
|
|
1229
|
+
* @param index - The dependency index.
|
|
1230
|
+
* @returns Distinct absolute paths.
|
|
1231
|
+
*/
|
|
1232
|
+
function dependencyPaths(index) {
|
|
1233
|
+
return [.../* @__PURE__ */ new Set([...index.files.keys(), ...index.globs.map((glob) => glob.base)])];
|
|
1234
|
+
}
|
|
624
1235
|
//#endregion
|
|
625
1236
|
//#region src/preparation/loaders/context.ts
|
|
626
1237
|
/** Supplies portable filesystem defaults and fails explicitly for unavailable Astro settings. */
|
|
@@ -1011,7 +1622,13 @@ function sessionInternals(session) {
|
|
|
1011
1622
|
if (!internal) throw new Error("Unknown ContentQuery session.");
|
|
1012
1623
|
return internal;
|
|
1013
1624
|
}
|
|
1014
|
-
/**
|
|
1625
|
+
/**
|
|
1626
|
+
* Creates a preparation session with ordered source commands and atomic revision publication.
|
|
1627
|
+
*
|
|
1628
|
+
* @param options - Root, configuration, watch, and publication settings.
|
|
1629
|
+
* @returns A session whose `content` is typed by `Style`; each published revision carries the
|
|
1630
|
+
* configured `queryStyle`, which the runtime enforces.
|
|
1631
|
+
*/
|
|
1015
1632
|
async function createSession(options) {
|
|
1016
1633
|
options = {
|
|
1017
1634
|
...options,
|
|
@@ -1020,9 +1637,47 @@ async function createSession(options) {
|
|
|
1020
1637
|
outDir: path.resolve(options.root, options.outDir)
|
|
1021
1638
|
};
|
|
1022
1639
|
const state = {};
|
|
1640
|
+
const sourceListeners = /* @__PURE__ */ new Set();
|
|
1641
|
+
const sourceSnapshots = /* @__PURE__ */ new WeakMap();
|
|
1642
|
+
const sourceOwner = randomUUID();
|
|
1643
|
+
let sourceVersion = 0;
|
|
1644
|
+
/** Detaches and freezes integration metadata without exposing prepared payloads or schemas. */
|
|
1645
|
+
function sources() {
|
|
1646
|
+
if (state.error) throw state.error;
|
|
1647
|
+
if (!state.revision) throw new ContentError({
|
|
1648
|
+
code: "artifact-error",
|
|
1649
|
+
message: "No prepared source revision is available."
|
|
1650
|
+
});
|
|
1651
|
+
let snapshot = sourceSnapshots.get(state.revision);
|
|
1652
|
+
if (!snapshot) {
|
|
1653
|
+
snapshot = Object.freeze({
|
|
1654
|
+
revision: `${sourceOwner}:${++sourceVersion}`,
|
|
1655
|
+
entries: Object.freeze(Object.entries(state.revision.collections).flatMap(([collection, prepared]) => prepared.records.map(({ id }) => {
|
|
1656
|
+
const filePath = prepared.sources?.get(id);
|
|
1657
|
+
return Object.freeze({
|
|
1658
|
+
collection,
|
|
1659
|
+
id,
|
|
1660
|
+
...filePath === void 0 ? {} : { filePath }
|
|
1661
|
+
});
|
|
1662
|
+
})))
|
|
1663
|
+
});
|
|
1664
|
+
sourceSnapshots.set(state.revision, snapshot);
|
|
1665
|
+
}
|
|
1666
|
+
return snapshot;
|
|
1667
|
+
}
|
|
1668
|
+
/** Keeps a consumer notification failure from corrupting successfully prepared content. */
|
|
1669
|
+
function notifySources(event) {
|
|
1670
|
+
const listeners = [...sourceListeners];
|
|
1671
|
+
for (const listener of listeners) try {
|
|
1672
|
+
listener(event);
|
|
1673
|
+
} catch (cause) {
|
|
1674
|
+
console.error("[content-query] Source subscriber failed", cause);
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1023
1677
|
let registry = {};
|
|
1024
1678
|
let loaders = /* @__PURE__ */ new Map();
|
|
1025
1679
|
let configDependencies;
|
|
1680
|
+
let queryStyle;
|
|
1026
1681
|
let configurationError;
|
|
1027
1682
|
const candidates = /* @__PURE__ */ new Map();
|
|
1028
1683
|
const cache = /* @__PURE__ */ new Map();
|
|
@@ -1049,11 +1704,26 @@ async function createSession(options) {
|
|
|
1049
1704
|
kind: "invalidate",
|
|
1050
1705
|
collection
|
|
1051
1706
|
}),
|
|
1707
|
+
dependencyChanged: (event, filename) => dependencyChanged(event, filename),
|
|
1052
1708
|
failed: (cause) => enqueue({
|
|
1053
1709
|
kind: "error",
|
|
1054
1710
|
cause
|
|
1055
1711
|
})
|
|
1056
1712
|
};
|
|
1713
|
+
let index;
|
|
1714
|
+
let preparing = false;
|
|
1715
|
+
const recentEvents = [];
|
|
1716
|
+
const dependencyWatcher = options.watch ? watchDependencies(watchOptions) : void 0;
|
|
1717
|
+
/** Invalidates only the cached nodes that read a changed file. */
|
|
1718
|
+
function dependencyChanged(event, filename) {
|
|
1719
|
+
if (preparing) recentEvents.push([event, filename]);
|
|
1720
|
+
index ??= dependencyIndex(cache);
|
|
1721
|
+
const nodes = dependents(index, event, filename);
|
|
1722
|
+
if (nodes.length) enqueue({
|
|
1723
|
+
kind: "payloads",
|
|
1724
|
+
nodes
|
|
1725
|
+
});
|
|
1726
|
+
}
|
|
1057
1727
|
/** Queues ordered notifications and makes any in-flight preparation obsolete. */
|
|
1058
1728
|
function enqueue(command) {
|
|
1059
1729
|
if (disposed) return;
|
|
@@ -1084,6 +1754,10 @@ async function createSession(options) {
|
|
|
1084
1754
|
message: cause instanceof Error ? cause.message : "Content preparation failed."
|
|
1085
1755
|
});
|
|
1086
1756
|
options.change?.(state);
|
|
1757
|
+
notifySources({
|
|
1758
|
+
type: "error",
|
|
1759
|
+
error: state.error
|
|
1760
|
+
});
|
|
1087
1761
|
}
|
|
1088
1762
|
/** Aborts an owned subscription group and attempts every cleanup even when one fails. */
|
|
1089
1763
|
async function releaseResources(controller, handlers) {
|
|
@@ -1103,7 +1777,8 @@ async function createSession(options) {
|
|
|
1103
1777
|
const loaded = options.registry ? {
|
|
1104
1778
|
registry: options.registry,
|
|
1105
1779
|
dependencies: /* @__PURE__ */ new Set(),
|
|
1106
|
-
loaderConfig: void 0
|
|
1780
|
+
loaderConfig: void 0,
|
|
1781
|
+
queryStyle: options.queryStyle
|
|
1107
1782
|
} : await loadRegistry(options.config);
|
|
1108
1783
|
const nextSubscriptions = new AbortController();
|
|
1109
1784
|
const nextCleanups = [];
|
|
@@ -1188,9 +1863,11 @@ async function createSession(options) {
|
|
|
1188
1863
|
registry = nextRegistry;
|
|
1189
1864
|
loaders = nextLoaders;
|
|
1190
1865
|
configDependencies = loaded.dependencies;
|
|
1866
|
+
queryStyle = loaded.queryStyle;
|
|
1191
1867
|
state.registry = queryRegistry(registry);
|
|
1192
1868
|
candidates.clear();
|
|
1193
1869
|
cache.clear();
|
|
1870
|
+
index = void 0;
|
|
1194
1871
|
loadFailures.clear();
|
|
1195
1872
|
configurationError = void 0;
|
|
1196
1873
|
installed = true;
|
|
@@ -1198,6 +1875,17 @@ async function createSession(options) {
|
|
|
1198
1875
|
}
|
|
1199
1876
|
/** Acquires invalidated snapshots and publishes only the complete current attempt. */
|
|
1200
1877
|
async function prepare(names) {
|
|
1878
|
+
preparing = true;
|
|
1879
|
+
try {
|
|
1880
|
+
await prepareAttempt(names);
|
|
1881
|
+
} finally {
|
|
1882
|
+
preparing = false;
|
|
1883
|
+
index = void 0;
|
|
1884
|
+
for (const [event, filename] of recentEvents.splice(0)) dependencyChanged(event, filename);
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
/** Acquires snapshots, prepares, and publishes one attempt. */
|
|
1888
|
+
async function prepareAttempt(names) {
|
|
1201
1889
|
const currentGeneration = generation;
|
|
1202
1890
|
attempt = new AbortController();
|
|
1203
1891
|
const signal = attempt.signal;
|
|
@@ -1222,13 +1910,20 @@ async function createSession(options) {
|
|
|
1222
1910
|
});
|
|
1223
1911
|
try {
|
|
1224
1912
|
const failures = [...loadFailures.values()];
|
|
1225
|
-
const
|
|
1913
|
+
const style = queryStyle;
|
|
1914
|
+
const revision = await prepareRevision(registry, candidates, cache, signal, options.watch, failures.flatMap((error) => error.diagnostics), new Map([...loaders].map(([name, loader]) => [name, loader.parsed])), { root: options.root });
|
|
1226
1915
|
if (disposed || currentGeneration !== generation) return;
|
|
1916
|
+
if (style) revision.queryStyle = style;
|
|
1227
1917
|
await options.publish?.(revision, registry, signal);
|
|
1228
1918
|
if (disposed || currentGeneration !== generation) return;
|
|
1229
1919
|
state.revision = revision;
|
|
1230
1920
|
state.error = void 0;
|
|
1921
|
+
dependencyWatcher?.add(dependencyPaths(index = dependencyIndex(cache)));
|
|
1231
1922
|
options.change?.(state);
|
|
1923
|
+
notifySources({
|
|
1924
|
+
type: "ready",
|
|
1925
|
+
snapshot: sources()
|
|
1926
|
+
});
|
|
1232
1927
|
} catch (cause) {
|
|
1233
1928
|
if (!disposed && currentGeneration === generation) fail(cause);
|
|
1234
1929
|
}
|
|
@@ -1261,6 +1956,10 @@ async function createSession(options) {
|
|
|
1261
1956
|
await configure(false);
|
|
1262
1957
|
for (const name of Object.keys(registry)) stale.add(name);
|
|
1263
1958
|
} else if (command.kind === "invalidate") stale.add(command.collection);
|
|
1959
|
+
else if (command.kind === "payloads") {
|
|
1960
|
+
invalidateNodes(cache, command.nodes);
|
|
1961
|
+
index = void 0;
|
|
1962
|
+
}
|
|
1264
1963
|
} catch (cause) {
|
|
1265
1964
|
if (command.kind === "config") configurationError = contentError(cause, {
|
|
1266
1965
|
code: "source-error",
|
|
@@ -1282,9 +1981,14 @@ async function createSession(options) {
|
|
|
1282
1981
|
async function dispose() {
|
|
1283
1982
|
if (disposed) return;
|
|
1284
1983
|
disposed = true;
|
|
1984
|
+
sourceListeners.clear();
|
|
1285
1985
|
generation++;
|
|
1286
1986
|
attempt?.abort();
|
|
1287
|
-
const failed = (await Promise.allSettled([
|
|
1987
|
+
const failed = (await Promise.allSettled([
|
|
1988
|
+
watcher?.close() ?? Promise.resolve(),
|
|
1989
|
+
dependencyWatcher?.close() ?? Promise.resolve(),
|
|
1990
|
+
releaseSubscriptions()
|
|
1991
|
+
])).find((result) => result.status === "rejected");
|
|
1288
1992
|
if (failed?.status === "rejected") throw contentError(failed.reason, {
|
|
1289
1993
|
code: "source-error",
|
|
1290
1994
|
operation: "dispose",
|
|
@@ -1293,6 +1997,14 @@ async function createSession(options) {
|
|
|
1293
1997
|
}
|
|
1294
1998
|
const session = {
|
|
1295
1999
|
content: createRuntime(state),
|
|
2000
|
+
sources,
|
|
2001
|
+
subscribe(listener) {
|
|
2002
|
+
if (disposed) throw new Error("Content session has been disposed.");
|
|
2003
|
+
sourceListeners.add(listener);
|
|
2004
|
+
return () => {
|
|
2005
|
+
sourceListeners.delete(listener);
|
|
2006
|
+
};
|
|
2007
|
+
},
|
|
1296
2008
|
dispose
|
|
1297
2009
|
};
|
|
1298
2010
|
internals.set(session, {
|
|
@@ -1386,11 +2098,23 @@ assertServerOnly();
|
|
|
1386
2098
|
/**
|
|
1387
2099
|
* Builds content independently of a bundler; bind the registry through an erased type import.
|
|
1388
2100
|
* Generated watch sessions prune previous-run artifacts after their first successful publication.
|
|
2101
|
+
*
|
|
2102
|
+
* @param arguments_ - Optional build options.
|
|
2103
|
+
* @returns A session whose `content` exposes the root query APIs allowed by `Style`. Pass
|
|
2104
|
+
* `typeof queryStyle` from the config to type it; the configured `queryStyle` is always enforced
|
|
2105
|
+
* at runtime.
|
|
1389
2106
|
*/
|
|
1390
2107
|
async function buildContent(...arguments_) {
|
|
1391
2108
|
return buildAtRoot(process.cwd(), arguments_[0] ?? {});
|
|
1392
2109
|
}
|
|
1393
|
-
/**
|
|
2110
|
+
/**
|
|
2111
|
+
* Resolves host paths and prunes previous-run artifacts on the first successful publication.
|
|
2112
|
+
*
|
|
2113
|
+
* @param root - Application root that relative config and output paths resolve against.
|
|
2114
|
+
* @param options - Build options.
|
|
2115
|
+
* @param change - Optional state observer for host integrations.
|
|
2116
|
+
* @returns A session typed by `Registry` and `Style`.
|
|
2117
|
+
*/
|
|
1394
2118
|
async function buildAtRoot(root, options, change) {
|
|
1395
2119
|
const { backend = "generated", watch = false } = options;
|
|
1396
2120
|
if (!["generated", "in-memory"].includes(backend) || typeof watch !== "boolean" || options.config !== void 0 && typeof options.config !== "string" || options.outDir !== void 0 && typeof options.outDir !== "string") throw new ContentError({
|