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.
Files changed (37) hide show
  1. package/README.md +28 -3
  2. package/dist/{build-Dxz7P1ur.mjs → build-CcqIpkes.mjs} +763 -39
  3. package/dist/build-DL1GzmRy.d.mts +55 -0
  4. package/dist/build.d.mts +2 -2
  5. package/dist/build.mjs +1 -1
  6. package/dist/cli.mjs +14 -8
  7. package/dist/computed.d.mts +2 -2
  8. package/dist/{content-N1gYshjg.mjs → content-BXrgP7nL.mjs} +271 -81
  9. package/dist/{content-G69dPPuo.d.mts → content-CwTKqRs9.d.mts} +49 -24
  10. package/dist/{types-ChaNhj42.d.mts → descriptor-bcPa6XLk.d.mts} +26 -1
  11. package/dist/{diagnostics-Cu94mZhl.mjs → diagnostics-DmUZtrJ6.mjs} +3 -1
  12. package/dist/{errors-C2iuBM0G.mjs → errors-Bfd0R4_P.mjs} +16 -1
  13. package/dist/file-DSOcdFj0.mjs +81 -0
  14. package/dist/{glob-Bv0dpvCQ.mjs → glob-BXJuwy_p.mjs} +94 -6
  15. package/dist/index.d.mts +61 -17
  16. package/dist/index.mjs +1 -1
  17. package/dist/internal.d.mts +11 -4
  18. package/dist/internal.mjs +29 -5
  19. package/dist/loaders.d.mts +2 -3
  20. package/dist/loaders.mjs +2 -81
  21. package/dist/next.d.mts +1 -1
  22. package/dist/next.mjs +2 -2
  23. package/dist/{operators-UqWUhKQJ.mjs → operators-Cwy8Kx4o.mjs} +88 -2
  24. package/dist/query-style-eXENQJ0O.d.mts +22 -0
  25. package/dist/query.d.mts +25 -2
  26. package/dist/query.mjs +1 -1
  27. package/dist/{relational-types-DgK3rLuw.d.mts → relational-types-C8xJtLG5.d.mts} +113 -38
  28. package/dist/runtime.d.mts +3 -2
  29. package/dist/runtime.mjs +1 -1
  30. package/dist/types-DbFfF6JE.d.mts +181 -0
  31. package/dist/vite.d.mts +15 -3
  32. package/dist/vite.mjs +25 -2
  33. package/package.json +2 -2
  34. package/dist/build-DfKd2bpf.d.mts +0 -25
  35. package/dist/descriptor-D9Th5hzk.d.mts +0 -18
  36. package/dist/types-C-kdMblM.d.mts +0 -69
  37. package/dist/validation-D_XdE1pF.mjs +0 -84
@@ -1,8 +1,9 @@
1
- import { a as builtinLoader, i as validate, n as normalizeSnapshot, o as builtinPath, r as renderedPayload, t as isRecord } from "./validation-D_XdE1pF.mjs";
2
- import { i as groupDiagnostics, n as contentError, o as withContext, t as ContentError } from "./errors-C2iuBM0G.mjs";
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 { a as CODEC_VERSION, i as prepareRelationships, n as createRuntime, r as isRelationship } from "./content-N1gYshjg.mjs";
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
- /** Generates type-only consumer bindings without loading schemas or adding runtime imports. */
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
- return `type Registry = typeof import(${JSON.stringify(relative)}).collections;\nexport type CollectionKey = keyof Registry & string;\nexport type CollectionData<Name extends CollectionKey> = import("content-query").QueryData<Registry[Name]>;\nexport type Collections = { [Name in CollectionKey]: CollectionData<Name> };\nexport declare const content: import("content-query/runtime").Content<Registry>;\n`;
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
- /** Declares project-bound virtual server types and the framework-neutral development bridge. */
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: hasBuiltinPayloads(definition) ? { ...builtinPayloads } : {}
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
- /** Prepares one immutable revision with loader-defined source metadata, aggregating failures. */
260
- async function prepareRevision(registry, sources, cache, signal, development, sourceDiagnostics = [], parsedData) {
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(...contentError(cause, {
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
- /** Creates a preparation session with ordered source commands and atomic revision publication. */
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 revision = await prepareRevision(registry, candidates, cache, signal, options.watch, failures.flatMap((error) => error.diagnostics), new Map([...loaders].map(([name, loader]) => [name, loader.parsed])));
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([watcher?.close() ?? Promise.resolve(), releaseSubscriptions()])).find((result) => result.status === "rejected");
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
- /** Resolves host paths and prunes previous-run artifacts on the first successful publication. */
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({