content-query 0.3.0 → 0.5.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 (39) hide show
  1. package/README.md +46 -15
  2. package/dist/{build-CI4s0oFP.mjs → build-DWpB69_c.mjs} +826 -69
  3. package/dist/{build-jKnjDjQE.d.mts → build-DfoncKL8.d.mts} +17 -5
  4. package/dist/build.d.mts +1 -1
  5. package/dist/build.mjs +1 -1
  6. package/dist/cli.mjs +14 -8
  7. package/dist/{content-N1gYshjg.mjs → collection-Cb5eWGnv.mjs} +998 -344
  8. package/dist/collection-types-CsQtGw7_.d.mts +119 -0
  9. package/dist/computed.d.mts +2 -2
  10. package/dist/{types-FmUJ3cH1.d.mts → descriptor-bcPa6XLk.d.mts} +17 -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 +76 -18
  16. package/dist/index.mjs +1 -1
  17. package/dist/internal.d.mts +54 -7
  18. package/dist/internal.mjs +424 -19
  19. package/dist/loaders.d.mts +1 -2
  20. package/dist/loaders.mjs +2 -81
  21. package/dist/next.d.mts +3 -2
  22. package/dist/next.mjs +67 -14
  23. package/dist/operators-D-w1-gDg.mjs +391 -0
  24. package/dist/query-style-C2FcT1f9.d.mts +23 -0
  25. package/dist/query.d.mts +25 -2
  26. package/dist/query.mjs +1 -1
  27. package/dist/{relational-types-B45zM34W.d.mts → relational-types-CTcalUNW.d.mts} +241 -50
  28. package/dist/revision-DBwU-aC9.d.mts +56 -0
  29. package/dist/runtime.d.mts +3 -2
  30. package/dist/runtime.mjs +1 -1
  31. package/dist/types-Dz1vTXa2.d.mts +214 -0
  32. package/dist/vite.d.mts +1 -1
  33. package/dist/vite.mjs +77 -35
  34. package/package.json +1 -1
  35. package/dist/content-lwPxE9Yu.d.mts +0 -109
  36. package/dist/descriptor-BUPT2J7X.d.mts +0 -18
  37. package/dist/operators-UqWUhKQJ.mjs +0 -173
  38. package/dist/types-BC8gVJ6u.d.mts +0 -69
  39. package/dist/validation-D_XdE1pF.mjs +0 -84
@@ -1,8 +1,10 @@
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 { I as collectionNames } from "./operators-D-w1-gDg.mjs";
7
+ import { a as variantKey, c as CODEC_VERSION, i as isQueryStyle, n as createCollections, o as isRelationship, s as prepareRelationships } from "./collection-Cb5eWGnv.mjs";
6
8
  import { t as renderMarkdown } from "./markdown-CHvBBJNa.mjs";
7
9
  import { access, link, mkdir, readFile, readdir, realpath, rm, rmdir, stat, writeFile } from "node:fs/promises";
8
10
  import path from "node:path";
@@ -18,7 +20,13 @@ import picomatch from "picomatch";
18
20
  function digest(value) {
19
21
  return createHash("sha256").update(JSON.stringify(value)).digest("hex");
20
22
  }
21
- /** Maps collection names to distinct safe directory names without changing public identities. */
23
+ /**
24
+ * Maps collection and payload names to distinct safe directory names without changing public
25
+ * identities. Names that are not portable directory names are base64url-encoded behind `~`.
26
+ *
27
+ * @param name - The collection or payload name.
28
+ * @returns The directory name under `collections/` or a collection's `payloads/`.
29
+ */
22
30
  function directoryName(name) {
23
31
  return /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(name) && !/^(?:con|prn|aux|nul|com[0-9]|lpt[0-9])$/i.test(name) ? name : `~${Buffer.from(name).toString("base64url")}`;
24
32
  }
@@ -29,19 +37,43 @@ function compactJson(source) {
29
37
  const compressed = JSON.stringify(gzipSync(source, { level: 1 }).toString("base64"));
30
38
  return compressed.length < bytes * .9 ? compressed : void 0;
31
39
  }
32
- /** Creates portable ESM; build integrations can batch payload sources before a strict bundler runs. */
33
- async function revisionModules(revision, { payloadChunkBytes = 0 } = {}) {
40
+ /** Picks a module-local binding that cannot shadow the entry's export name. */
41
+ function binding(base, exportName) {
42
+ return base === exportName ? `${base}_` : base;
43
+ }
44
+ /**
45
+ * Creates one collection's entry, which exports the collection's query handle under its export
46
+ * name and imports only that collection's modules.
47
+ */
48
+ function collectionEntry$1(manifest, name, exportName, index, renderer) {
49
+ const runtime = binding("runtime", exportName);
50
+ const data = binding("data", exportName);
51
+ const render = renderer ? `, body => import("content-query/internal/markdown").then(module => module.renderMarkdown(body))` : "";
52
+ return `import * as ${runtime} from "content-query/internal";\nimport ${data} from ${JSON.stringify(`./${index}`)};\nexport const ${exportName} = ${runtime}.createArtifactCollection(JSON.parse(${JSON.stringify(JSON.stringify(manifest))}), ${JSON.stringify(name)}, ${data}${render});\n`;
53
+ }
54
+ /**
55
+ * Creates portable ESM; build integrations can batch payload sources before a strict bundler runs.
56
+ *
57
+ * Each collection gets `collections/<export name>/server.js`, which exports its query handle and
58
+ * imports only that collection's modules, so a bundle that imports it never references the
59
+ * modules of another collection.
60
+ *
61
+ * @param revision - The prepared revision to publish.
62
+ * @param options - `payloadChunkBytes` batches payload modules for bundlers. `preparedEntry`
63
+ * also emits `prepared-server.js`, which exports the whole revision for development facades.
64
+ * @returns Module sources keyed by path relative to the output directory.
65
+ */
66
+ async function revisionModules(revision, { payloadChunkBytes = 0, preparedEntry = false } = {}) {
34
67
  const modules = /* @__PURE__ */ new Map();
35
- const manifest = {
36
- version: 2,
37
- codec: CODEC_VERSION,
38
- collections: revision.registry
39
- };
40
- const imports = [];
41
- const names = [];
42
- let needsRenderer = false;
68
+ const names = collectionNames(revision.registry);
69
+ /** Each collection's index module, for the optional prepared entry. */
70
+ const indexes = [];
71
+ let anyMarkdown = false;
43
72
  for (const [name, collection] of Object.entries(revision.collections)) {
44
- const directory = `collections/${directoryName(name)}`;
73
+ const { exportName } = names.get(name);
74
+ const directory = `collections/${exportName}`;
75
+ /** Whether the collection captured Markdown, which the packaged renderer renders on demand. */
76
+ let markdown = false;
45
77
  const payloads = [];
46
78
  const chunks = [];
47
79
  let batch = [];
@@ -65,7 +97,7 @@ async function revisionModules(revision, { payloadChunkBytes = 0 } = {}) {
65
97
  for (const [id, load] of collection.builtins) {
66
98
  const payload = await load();
67
99
  const encoded = encode(payload);
68
- needsRenderer ||= payload.markdown !== void 0;
100
+ markdown ||= payload.markdown !== void 0;
69
101
  if (payloadChunkBytes) {
70
102
  const bytes = Buffer.byteLength(`[${JSON.stringify(id)}]: ${JSON.stringify(encoded)},\n`);
71
103
  if (batchBytes && batchBytes + bytes > payloadChunkBytes) flushBatch();
@@ -82,35 +114,121 @@ async function revisionModules(revision, { payloadChunkBytes = 0 } = {}) {
82
114
  payloads.push(`[${JSON.stringify(id)}]: () => import(${JSON.stringify(`./entries/${key}.js`)})`);
83
115
  }
84
116
  flushBatch();
117
+ const custom = [];
118
+ for (const [payload, entries] of collection.payloads ?? []) {
119
+ const payloadDirectory = `payloads/${directoryName(payload)}`;
120
+ const rows = [];
121
+ for (const [id, variants] of entries) {
122
+ const loaders = [];
123
+ for (const [variant, load] of variants) {
124
+ const encoded = load.encoded ?? encode(await load());
125
+ const key = digest([
126
+ name,
127
+ payload,
128
+ id,
129
+ variant,
130
+ encoded
131
+ ]);
132
+ publishPayload(`${directory}/${payloadDirectory}/${key}.js`, `export default ${JSON.stringify(encoded)};\n`);
133
+ loaders.push(`${JSON.stringify(variant)}: () => import(${JSON.stringify(`./${payloadDirectory}/${key}.js`)})`);
134
+ }
135
+ rows.push(`${JSON.stringify(id)}: { ${loaders.join(", ")} }`);
136
+ }
137
+ custom.push(`${JSON.stringify(payload)}: {\n${rows.join(",\n")}\n}`);
138
+ }
85
139
  const recordSource = JSON.stringify(collection.records.map(encode));
86
140
  const compressedRecords = compactJson(recordSource);
87
141
  const helpers = [...chunks.length ? ["createPayloadLoaders"] : [], ...compressedRecords ? ["decodeCompressed"] : []];
88
142
  const loaders = helpers.length ? `import { ${helpers.join(", ")} } from "content-query/internal";\n` : "";
89
143
  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`;
144
+ const code = `${loaders}export default { records: ${compressedRecords ? `decodeCompressed(${compressedRecords})` : recordSource}, builtins: ${loading}${custom.length ? `, payloads: {\n${custom.join(",\n")}\n}` : ""} };\n`;
91
145
  const index = `index-${digest(code)}.js`;
92
146
  modules.set(`${directory}/${index}`, code);
93
- modules.set(`${directory}/index.js`, `export { default } from ${JSON.stringify(`./${index}`)};\n`);
94
- const binding = `collection${imports.length}`;
95
- imports.push(`import ${binding} from ${JSON.stringify(`./${directory}/${index}`)};`);
96
- names.push(`[${JSON.stringify(name)}]: ${binding}`);
147
+ const descriptor = revision.registry[name];
148
+ const manifest = {
149
+ version: 3,
150
+ codec: CODEC_VERSION,
151
+ collections: { [name]: {
152
+ relationships: descriptor.relationships,
153
+ ...descriptor.payloads ? { payloads: descriptor.payloads } : {}
154
+ } },
155
+ ...revision.queryStyle ? { queryStyle: revision.queryStyle } : {}
156
+ };
157
+ modules.set(`${directory}/server.js`, collectionEntry$1(manifest, name, exportName, index, markdown));
158
+ indexes.push([name, `./${directory}/${index}`]);
159
+ anyMarkdown ||= markdown;
160
+ }
161
+ if (preparedEntry) {
162
+ const manifest = {
163
+ version: 3,
164
+ codec: CODEC_VERSION,
165
+ collections: Object.fromEntries(Object.entries(revision.registry).map(([name, { relationships, payloads }]) => [name, {
166
+ relationships,
167
+ ...payloads ? { payloads } : {}
168
+ }])),
169
+ ...revision.queryStyle ? { queryStyle: revision.queryStyle } : {}
170
+ };
171
+ const render = anyMarkdown ? `, body => import("content-query/internal/markdown").then(module => module.renderMarkdown(body))` : "";
172
+ modules.set("prepared-server.js", `import { createArtifactRevision } from "content-query/internal";\n${indexes.map(([, specifier], index) => `import collection${index} from ${JSON.stringify(specifier)};`).join("\n")}\nexport const revision = createArtifactRevision(JSON.parse(${JSON.stringify(JSON.stringify(manifest))}), { ${indexes.map(([name], index) => `[${JSON.stringify(name)}]: collection${index}`).join(", ")} }${render});\nexport const registry = revision.registry;\nexport const error = undefined;\n`);
97
173
  }
98
- modules.set("manifest.js", `export default JSON.parse(${JSON.stringify(JSON.stringify(manifest))});\n`);
99
- const renderer = needsRenderer ? `, body => import("content-query/internal/markdown").then(module => module.renderMarkdown(body))` : "";
100
- modules.set("server.js", `import { createArtifactRuntime } from "content-query/internal";\n${imports.join("\n")}\nexport const content = createArtifactRuntime(JSON.parse(${JSON.stringify(JSON.stringify(manifest))}), { ${names.join(", ")} }${renderer});\n`);
101
174
  return modules;
102
175
  }
103
176
  //#endregion
104
177
  //#region src/artifacts/declarations.ts
105
- /** Generates type-only consumer bindings without loading schemas or adding runtime imports. */
106
- function contentDeclarations(directory, config) {
178
+ /** Declares the config-bound `Registry` and `ConfiguredQueryStyle` aliases for one directory. */
179
+ function configTypes(directory, config) {
107
180
  let relative = path.relative(directory, config).replace(/\\/g, "/");
108
181
  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`;
182
+ const source = JSON.stringify(relative);
183
+ return [`type Registry = typeof import(${source}).collections;`, `type ConfiguredQueryStyle = typeof import(${source}) extends { queryStyle: infer Style extends import("content-query/runtime").QueryStyle } ? Style : import("content-query/runtime").QueryStyle;`];
110
184
  }
111
- /** Declares project-bound virtual server types and the framework-neutral development bridge. */
112
- function virtualDeclarations(directory, config) {
113
- 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`;
185
+ /**
186
+ * Generates type-only bindings for one collection entry without loading schemas or adding runtime
187
+ * imports.
188
+ *
189
+ * The registry and the optional `queryStyle` both come from the config module's types, so a
190
+ * literal `export const queryStyle = "relational"` narrows the export without a rebuild. A missing
191
+ * export, or one typed as the whole `QueryStyle` union, allows both query styles.
192
+ *
193
+ * The entry type, such as `ContentJob`, is the full entry. `ContentJob<Field>` is the shape a
194
+ * query returns when it selects `Field`: every selected key is present, and optional fields read
195
+ * as `undefined` when missing, so query results and the type are assignable in both directions.
196
+ *
197
+ * @param directory - Directory that will contain the declaration file.
198
+ * @param config - Absolute path of content-query.config.
199
+ * @param name - The collection's name in the registry.
200
+ * @param names - The collection's generated export and type names.
201
+ * @returns Declaration source for the entry's `server.d.ts`.
202
+ */
203
+ function collectionDeclarations(directory, config, name, { exportName, typeName, payloadTypeName }) {
204
+ const key = JSON.stringify(name);
205
+ const definition = `Registry[${key}]`;
206
+ const data = `import("content-query").QueryData<${definition}>`;
207
+ return [
208
+ ...configTypes(directory, config),
209
+ `/** The ${exportName} collection. Start queries here, or pass it to another collection's \`using()\` to follow relationships into it. */`,
210
+ `export declare const ${exportName}: import("content-query/runtime").Collection<Registry, ${key}, ConfiguredQueryStyle>;`,
211
+ `/** A ${exportName} entry: its ID, validated metadata, and computed fields. Pass field names, such as \`${typeName}<"id">\`, for the shape a query returns when it selects them. */`,
212
+ `export type ${typeName}<Field extends keyof ${data} = keyof ${data}> = keyof ${data} extends Field ? ${data} : { [Key in Field]: ${data}[Key] };`,
213
+ `/** The value of one payload that the ${exportName} collection declares. */`,
214
+ `export type ${payloadTypeName}<Payload extends keyof import("content-query").PayloadsOf<${definition}>> = import("content-query").PayloadData<${definition}, Payload>;`,
215
+ ``
216
+ ].join("\n");
217
+ }
218
+ /**
219
+ * Declares project-bound virtual collection modules and the framework-neutral development bridge.
220
+ *
221
+ * @param directory - Directory that will contain the declaration file.
222
+ * @param config - Absolute path of content-query.config.
223
+ * @param names - Every collection's generated names, keyed by collection name.
224
+ * @returns Declaration source for the `content-query:collections/<export name>` and
225
+ * `content-query:client` modules, plus the type-only `content-query:collections` module, whose
226
+ * `CollectionKey` names every collection for integrations that accept a collection name.
227
+ */
228
+ function virtualDeclarations(directory, config, names) {
229
+ const collections = [...names].map(([name, generated]) => `declare module "content-query:collections/${generated.exportName}" {\n${collectionDeclarations(directory, config, name, generated).replace("export declare const", "export const")}}\n`);
230
+ const keys = `declare module "content-query:collections" {\n${configTypes(directory, config)[0]}\n/** Names of the configured collections, for integrations that accept a collection name. Types only: import collections from their own modules. */\nexport type CollectionKey = keyof Registry & string;\n}\n`;
231
+ return `${collections.join("")}${keys}declare module "content-query:client" {\n export function onContentUpdate(callback: () => void | Promise<void>): () => void;\n}\n`;
114
232
  }
115
233
  //#endregion
116
234
  //#region src/artifacts/write.ts
@@ -162,7 +280,9 @@ async function writeImmutable(filename, source) {
162
280
  await rm(temporary, { force: true });
163
281
  }
164
282
  }
165
- const immutableModule = /\/(?:entries\/[a-f0-9]{64}|index-[a-f0-9]{64})\.js$/;
283
+ const immutableModule = /\/(?:entries\/[a-f0-9]{64}|payloads\/[^/]+\/[a-f0-9]{64}|index-[a-f0-9]{64})\.js$/;
284
+ /** Matches each collection's entry, which imports the collection's other modules. */
285
+ const collectionEntry = /^collections\/[^/]+\/server\.js$/;
166
286
  /** Lists one directory's entries, treating a missing directory or a file as empty. */
167
287
  async function entriesOf(directory) {
168
288
  try {
@@ -196,19 +316,39 @@ async function pruneArtifacts(outDir, modules) {
196
316
  for (const directory of await entriesOf(collections)) {
197
317
  const owned = path.join(collections, directory);
198
318
  const entries = path.join(owned, "entries");
319
+ const payloads = path.join(owned, "payloads");
320
+ const payloadDirectories = await entriesOf(payloads);
199
321
  const candidates = [...(await entriesOf(owned)).map((name) => `collections/${directory}/${name}`), ...(await entriesOf(entries)).map((name) => `collections/${directory}/entries/${name}`)];
200
- 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 });
322
+ for (const payload of payloadDirectories) candidates.push(...(await entriesOf(path.join(payloads, payload))).map((name) => `collections/${directory}/payloads/${payload}/${name}`));
323
+ for (const filename of candidates) {
324
+ const generated = immutableModule.test(`/${filename}`) || filename.endsWith(`/${directory}/server.js`);
325
+ const declarations = filename.endsWith(`/${directory}/server.d.ts`) && !modules.has(filename.replace(/\.d\.ts$/, ".js"));
326
+ if (generated && !modules.has(filename) || declarations) await rm(path.join(outDir, filename), { force: true });
327
+ }
201
328
  await removeIfEmpty(entries);
329
+ for (const payload of payloadDirectories) await removeIfEmpty(path.join(payloads, payload));
330
+ await removeIfEmpty(payloads);
202
331
  await removeIfEmpty(owned);
203
332
  }
204
333
  }
205
- /** Stages modules with bounded I/O before atomically switching the public server entry. */
334
+ /**
335
+ * Stages modules with bounded I/O before switching each collection's public entry.
336
+ *
337
+ * @param revision - The prepared revision to publish.
338
+ * @param outDir - The generated output directory.
339
+ * @param config - Absolute path of content-query.config, which declarations import types from.
340
+ * @param options - `signal` cancels the write; `prune` removes superseded modules.
341
+ */
206
342
  async function writeArtifacts(revision, outDir, config, { signal, prune = false } = {}) {
207
343
  const modules = await revisionModules(revision);
344
+ const names = collectionNames(revision.registry);
208
345
  await writeChanged(path.join(outDir, "package.json"), "{\"type\":\"module\",\"private\":true}\n");
209
- await writeChanged(path.join(outDir, "server.d.ts"), contentDeclarations(outDir, config));
210
- await writeChanged(path.join(outDir, "types.d.ts"), virtualDeclarations(outDir, config));
211
- const staged = [...modules].filter(([filename]) => filename !== "server.js");
346
+ await writeChanged(path.join(outDir, "types.d.ts"), virtualDeclarations(outDir, config, names));
347
+ for (const [name, generated] of names) {
348
+ const directory = path.join(outDir, "collections", generated.exportName);
349
+ await writeChanged(path.join(directory, "server.d.ts"), collectionDeclarations(directory, config, name, generated));
350
+ }
351
+ const staged = [...modules].filter(([filename]) => !collectionEntry.test(filename));
212
352
  const directories = new Set(staged.map(([filename]) => path.dirname(path.join(outDir, filename))));
213
353
  await Promise.all([...directories].map(async (directory) => mkdir(directory, { recursive: true })));
214
354
  const pending = staged.values();
@@ -220,7 +360,7 @@ async function writeArtifacts(revision, outDir, config, { signal, prune = false
220
360
  }
221
361
  }))).find((outcome) => outcome.status === "rejected");
222
362
  if (failure?.status === "rejected") throw failure.reason;
223
- await writeChanged(path.join(outDir, "server.js"), modules.get("server.js"), signal);
363
+ for (const [filename, code] of modules) if (collectionEntry.test(filename)) await writeChanged(path.join(outDir, filename), code, signal);
224
364
  if (prune) await pruneArtifacts(outDir, modules);
225
365
  }
226
366
  //#endregion
@@ -243,21 +383,196 @@ function hasBuiltinPayloads(definition) {
243
383
  return builtinLoader(definition.loader)?.kind !== "file";
244
384
  }
245
385
  /**
386
+ * Describes a collection's custom payloads by their sorted variant keys. Underscore names belong
387
+ * to built-ins, so an invalid declaration never shadows them; preparation reports it instead.
388
+ *
389
+ * @param definition - A collection definition.
390
+ * @returns Payload descriptors keyed by custom payload name.
391
+ */
392
+ function customPayloads(definition) {
393
+ return Object.fromEntries(Object.entries(definition.payloads ?? {}).filter(([name]) => !name.startsWith("_")).map(([name, payload]) => [name, { variants: Object.keys(payload?.variants ?? {}).sort() }]));
394
+ }
395
+ /**
246
396
  * Builds the query registry that runtimes validate queries against, without resolvers or schemas.
247
397
  *
248
398
  * @param registry - The configured collection definitions.
249
- * @returns Relationship and payload descriptors for each collection.
399
+ * @returns Relationship and payload descriptors for each collection, built-in and custom.
250
400
  */
251
401
  function queryRegistry(registry) {
252
402
  return Object.fromEntries(Object.entries(registry).map(([name, definition]) => [name, {
253
403
  relationships: definition.relationships ?? {},
254
- payloads: hasBuiltinPayloads(definition) ? { ...builtinPayloads } : {}
404
+ payloads: {
405
+ ...hasBuiltinPayloads(definition) ? builtinPayloads : {},
406
+ ...customPayloads(definition)
407
+ },
408
+ ...definition.typeName === void 0 ? {} : { typeName: definition.typeName }
255
409
  }]));
256
410
  }
257
411
  //#endregion
412
+ //#region src/shared/paths.ts
413
+ /** Checks containment without confusing sibling prefixes, drive roots, or trailing separators. */
414
+ function withinDirectory(directory, filename) {
415
+ const relative = path.relative(directory, filename);
416
+ return relative === "" || relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
417
+ }
418
+ /** Resolves existing symlink ancestors even when the output directory does not exist yet. */
419
+ async function canonicalPath(filename) {
420
+ try {
421
+ return await realpath(filename);
422
+ } catch (cause) {
423
+ if (!cause || typeof cause !== "object" || !("code" in cause) || cause.code !== "ENOENT") throw cause;
424
+ const parent = path.dirname(filename);
425
+ if (parent === filename) throw cause;
426
+ return path.join(await canonicalPath(parent), path.basename(filename));
427
+ }
428
+ }
429
+ //#endregion
430
+ //#region src/preparation/files.ts
431
+ /**
432
+ * Finds the folder an entry's `files` helpers read from: the loader's `source.directory`, or the
433
+ * folder of its `source.filePath`. Both are relative to the project root.
434
+ *
435
+ * @param root - The project root.
436
+ * @param source - The entry's loader-supplied source metadata.
437
+ * @returns The absolute folder, or undefined when the source names neither.
438
+ */
439
+ function entryFolder(root, source) {
440
+ if (!source || typeof source !== "object") return;
441
+ const { directory, filePath } = source;
442
+ if (typeof directory === "string" && directory) return path.resolve(root, directory);
443
+ if (typeof filePath === "string" && filePath) return path.resolve(root, path.dirname(filePath));
444
+ }
445
+ /**
446
+ * Creates tracked file helpers for one resolver call. Reads are scoped to the entry's folder and
447
+ * the collection's shared directories, and each read is recorded as a dependency.
448
+ *
449
+ * @param options - The entry's roots, cancellation, and the dependency list to record into.
450
+ * @returns The `files` object resolvers receive.
451
+ */
452
+ function createEntryFiles(options) {
453
+ const { root, folder, shared, entry, signal, dependencies } = options;
454
+ const roots = [...folder ? [folder] : [], ...shared];
455
+ /** Resolves a path from the entry's folder and rejects one outside every allowed root. */
456
+ function resolve(filePath) {
457
+ if (typeof filePath !== "string" || !filePath) throw new ContentError({
458
+ code: "source-error",
459
+ message: "files helpers require a nonempty path."
460
+ });
461
+ if (!folder && !path.isAbsolute(filePath)) throw new ContentError({
462
+ code: "source-error",
463
+ 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.`
464
+ });
465
+ const resolved = path.resolve(folder ?? root, filePath);
466
+ if (!roots.some((allowed) => withinDirectory(allowed, resolved))) throw new ContentError({
467
+ code: "source-error",
468
+ source: resolved,
469
+ message: `"${filePath}" is outside entry "${entry}"'s folder and the collection's sharedFiles directories.`
470
+ });
471
+ return resolved;
472
+ }
473
+ /** Reads a file and records it, reporting a missing or unreadable file as a source error. */
474
+ async function read(filePath, reader) {
475
+ const resolved = resolve(filePath);
476
+ dependencies.push({
477
+ kind: "file",
478
+ path: resolved
479
+ });
480
+ try {
481
+ return await reader(resolved);
482
+ } catch (cause) {
483
+ signal.throwIfAborted();
484
+ throw contentError(cause, {
485
+ code: "source-error",
486
+ source: resolved,
487
+ message: `Could not read "${filePath}": ${describeCause(cause)}`
488
+ });
489
+ }
490
+ }
491
+ /** Validates one helper entry's data, reporting failures against that file. */
492
+ async function entryOf(schema, value, filePath) {
493
+ const data = await validate(schema, value.data, { source: value.source });
494
+ return {
495
+ id: value.id,
496
+ data,
497
+ ...value.body === void 0 ? {} : { body: value.body },
498
+ filePath
499
+ };
500
+ }
501
+ return {
502
+ async glob(globOptions) {
503
+ const { schema, base, ...loaderOptions } = globOptions ?? {};
504
+ const patterns = typeof loaderOptions.pattern === "string" ? [loaderOptions.pattern] : loaderOptions.pattern;
505
+ if (!Array.isArray(patterns) || patterns.some((pattern) => typeof pattern !== "string" || path.posix.isAbsolute(pattern.replace(/^!/, "")) || pattern.replace(/^!/, "").split("/").includes(".."))) throw new ContentError({
506
+ code: "source-error",
507
+ message: "files.glob() requires relative patterns that stay inside their base."
508
+ });
509
+ const directory = resolve(base ?? ".");
510
+ const options = {
511
+ ...loaderOptions,
512
+ base: directory
513
+ };
514
+ dependencies.push({
515
+ kind: "glob",
516
+ base: directory,
517
+ options
518
+ });
519
+ const entries = await loadGlob({
520
+ kind: "glob",
521
+ options
522
+ }, root, signal, { warnEmpty: false });
523
+ const retained = loaderOptions.retainBody !== false;
524
+ return Promise.all(entries.map((value) => {
525
+ dependencies.push({
526
+ kind: "file",
527
+ path: value.source
528
+ });
529
+ return entryOf(schema, {
530
+ ...value,
531
+ body: retained ? value.body : void 0
532
+ }, value.sourceData.filePath);
533
+ }));
534
+ },
535
+ async file(filePath, fileOptions = {}) {
536
+ const { schema, ...loaderOptions } = fileOptions;
537
+ const resolved = resolve(filePath);
538
+ dependencies.push({
539
+ kind: "file",
540
+ path: resolved
541
+ });
542
+ const descriptor = {
543
+ kind: "file",
544
+ filename: resolved,
545
+ options: loaderOptions
546
+ };
547
+ const entries = await loadFile(descriptor, root, signal);
548
+ const relative = path.relative(root, builtinPath(descriptor, root)).split(path.sep).join("/");
549
+ return Promise.all(entries.map((value) => entryOf(schema, value, relative)));
550
+ },
551
+ text(filePath) {
552
+ return read(filePath, (resolved) => readFile(resolved, {
553
+ encoding: "utf8",
554
+ signal
555
+ }));
556
+ },
557
+ bytes(filePath) {
558
+ return read(filePath, async (resolved) => new Uint8Array(await readFile(resolved, { signal })));
559
+ },
560
+ async renderMarkdown(body) {
561
+ const { html, headings } = await renderMarkdown(body);
562
+ return {
563
+ html,
564
+ metadata: { headings }
565
+ };
566
+ }
567
+ };
568
+ }
569
+ //#endregion
258
570
  //#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) {
571
+ /**
572
+ * Prepares one immutable revision with loader-defined source metadata, aggregating failures.
573
+ * `root` is the project root that entry folders and `sharedFiles` resolve against.
574
+ */
575
+ async function prepareRevision(registry, sources, cache, signal, development, sourceDiagnostics = [], parsedData, { root = process.cwd() } = {}) {
261
576
  const diagnostics = [...sourceDiagnostics];
262
577
  const collections = Object.create(null);
263
578
  for (const [collectionName, definition] of Object.entries(registry)) {
@@ -279,7 +594,18 @@ async function prepareRevision(registry, sources, cache, signal, development, so
279
594
  });
280
595
  continue;
281
596
  }
597
+ const payloadProblem = invalidPayloads(definition.payloads, declarationNames) ?? invalidSharedFiles(definition.sharedFiles);
598
+ if (payloadProblem) {
599
+ diagnostics.push({
600
+ code: "validation-error",
601
+ operation: "build",
602
+ collection: collectionName,
603
+ message: payloadProblem
604
+ });
605
+ continue;
606
+ }
282
607
  const descriptor = builtinLoader(definition.loader);
608
+ const shared = (definition.sharedFiles ?? []).map((directory) => path.resolve(root, directory));
283
609
  for (const entry of sources.get(collectionName)?.values() ?? []) {
284
610
  signal.throwIfAborted();
285
611
  const context = {
@@ -308,6 +634,15 @@ async function prepareRevision(registry, sources, cache, signal, development, so
308
634
  const metadata = detach(validated);
309
635
  const values = Object.create(null);
310
636
  const before = diagnostics.length;
637
+ const dependencies = [];
638
+ const files = createEntryFiles({
639
+ root,
640
+ folder: entryFolder(root, entry.sourceData),
641
+ shared,
642
+ entry: entry.id,
643
+ signal,
644
+ dependencies
645
+ });
311
646
  for (const [field, computedField] of Object.entries(computed)) try {
312
647
  const result = await computedField.resolve({
313
648
  collection: collectionName,
@@ -315,6 +650,7 @@ async function prepareRevision(registry, sources, cache, signal, development, so
315
650
  source: detach(entry.sourceData),
316
651
  data: detach(metadata),
317
652
  readBody: async () => entry.body,
653
+ files,
318
654
  signal
319
655
  });
320
656
  values[field] = await validate(computedField.schema, result, {
@@ -322,7 +658,7 @@ async function prepareRevision(registry, sources, cache, signal, development, so
322
658
  path: [field]
323
659
  });
324
660
  } catch (cause) {
325
- diagnostics.push(...contentError(cause, {
661
+ diagnostics.push(...located(cause, {
326
662
  ...context,
327
663
  path: [field],
328
664
  code: "validation-error",
@@ -359,7 +695,8 @@ async function prepareRevision(registry, sources, cache, signal, development, so
359
695
  definition,
360
696
  signature,
361
697
  record,
362
- payload
698
+ payload,
699
+ dependencies
363
700
  });
364
701
  records.push(record);
365
702
  if (Object.keys(payload).length) builtins.set(entry.id, async () => payload);
@@ -393,9 +730,207 @@ async function prepareRevision(registry, sources, cache, signal, development, so
393
730
  };
394
731
  const failed = diagnostics.map((diagnostic) => diagnostic.collection);
395
732
  if (!failed.includes(void 0)) diagnostics.push(...prepareRelationships(revision, new Set(failed)).flatMap((error) => error.diagnostics));
733
+ const unresolved = new Set(diagnostics.map((diagnostic) => diagnostic.collection));
734
+ if (!unresolved.has(void 0)) {
735
+ 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, {
736
+ root,
737
+ shared: (definition.sharedFiles ?? []).map((directory) => path.resolve(root, directory))
738
+ });
739
+ }
396
740
  if (diagnostics.length) throw groupDiagnostics(diagnostics);
397
741
  return revision;
398
742
  }
743
+ /**
744
+ * Checks custom payload declarations before any entry is prepared.
745
+ *
746
+ * @param payloads - The collection's payload declarations.
747
+ * @param declarationNames - Computed field and relationship names the payloads must not repeat.
748
+ * @returns A diagnostic message, or undefined when every declaration is valid.
749
+ */
750
+ function invalidPayloads(payloads, declarationNames) {
751
+ if (payloads === void 0) return;
752
+ if (!payloads || typeof payloads !== "object" || Array.isArray(payloads)) return "Payloads must be an object of named payload declarations.";
753
+ for (const [name, declaration] of Object.entries(payloads)) {
754
+ 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.`;
755
+ const { schema, resolve, variants, combinations, cache } = declaration ?? {};
756
+ if (!schema || typeof schema !== "object" || !("~standard" in schema) || typeof resolve !== "function") return `Payload "${name}" requires a schema and a resolve() function.`;
757
+ 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.`;
758
+ if (combinations !== void 0 && (typeof combinations !== "function" || !Object.keys(variants ?? {}).length)) return `Payload "${name}" combinations() must be a function and requires declared variants.`;
759
+ if (cache !== void 0 && typeof cache !== "boolean") return `Payload "${name}" cache must be a boolean.`;
760
+ }
761
+ }
762
+ /**
763
+ * Lists an entry's variant combinations: the cross product of each key's values, or the
764
+ * declaration's validated `combinations()` override. A key's values come from a named string-array
765
+ * field of the entry's data, where a missing value means none, or from a fixed list.
766
+ * Combinations have sorted keys.
767
+ *
768
+ * @param name - The payload name, for error messages.
769
+ * @param payload - The payload declaration.
770
+ * @param context - The enumerator context for one entry.
771
+ * @returns Distinct combinations; a payload without variants has exactly one, `{}`.
772
+ */
773
+ async function variantCombinations(name, payload, context) {
774
+ const keys = Object.keys(payload.variants ?? {}).sort();
775
+ const values = Object.create(null);
776
+ for (const key of keys) {
777
+ const source = payload.variants[key];
778
+ const listed = typeof source === "string" ? context.data[source] ?? [] : source;
779
+ if (!Array.isArray(listed) || !listed.every((value) => typeof value === "string" && value)) {
780
+ const field = typeof source === "string" ? ` in field "${String(source)}"` : "";
781
+ throw new Error(`Variant "${key}" of payload "${name}" must list non-empty strings${field}.`);
782
+ }
783
+ values[key] = [...new Set(listed)];
784
+ }
785
+ let combinations;
786
+ if (payload.combinations) {
787
+ const listed = await payload.combinations({
788
+ ...context,
789
+ variants: values
790
+ });
791
+ if (!listed || typeof listed !== "object" || !(Symbol.iterator in listed)) throw new Error(`Payload "${name}" combinations() must return an iterable of variants.`);
792
+ combinations = [...listed].map((combination) => {
793
+ const variant = combination;
794
+ 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.`);
795
+ return Object.fromEntries(keys.map((key) => [key, variant[key]]));
796
+ });
797
+ } else combinations = keys.reduce((partial, key) => partial.flatMap((variant) => values[key].map((value) => ({
798
+ ...variant,
799
+ [key]: value
800
+ }))), [{}]);
801
+ return [...new Map(combinations.map((variant) => [variantKey(variant), variant])).values()];
802
+ }
803
+ /**
804
+ * Adds the location of a resolver failure to every diagnostic that lacks it. A `files` helper error
805
+ * keeps its own source file and field path; the entry, payload, and variant come from `location`.
806
+ *
807
+ * @param cause - The resolver failure.
808
+ * @param location - The entry, and the payload and variant when resolving one.
809
+ * @returns A contextual error whose diagnostics all name the location.
810
+ */
811
+ function located(cause, location) {
812
+ if (!(cause instanceof ContentError)) return contentError(cause, location);
813
+ return groupDiagnostics(cause.diagnostics.map((diagnostic) => ({
814
+ ...location,
815
+ ...Object.fromEntries(Object.entries(diagnostic).filter(([, value]) => value !== void 0))
816
+ })));
817
+ }
818
+ /**
819
+ * Checks a collection's `sharedFiles` directories.
820
+ *
821
+ * @param sharedFiles - The declared shared directories.
822
+ * @returns A diagnostic message, or undefined when the declaration is valid.
823
+ */
824
+ function invalidSharedFiles(sharedFiles) {
825
+ if (sharedFiles !== void 0 && (!Array.isArray(sharedFiles) || !sharedFiles.every((directory) => typeof directory === "string" && directory))) return "sharedFiles must list directories as nonempty strings.";
826
+ }
827
+ /**
828
+ * Resolves, validates, and encodes a collection's custom payloads for every entry and variant.
829
+ * Each variant is cached on its own with the files it read, so unchanged entries reuse every
830
+ * variant and an invalidated variant re-resolves alone.
831
+ *
832
+ * @returns Lazy loaders by payload name, entry ID, and canonical variant key.
833
+ */
834
+ async function preparePayloads(collection, payloads, records, sources, cache, signal, diagnostics, roots) {
835
+ const table = new Map(Object.keys(payloads).map((name) => [name, /* @__PURE__ */ new Map()]));
836
+ for (const record of records) {
837
+ const entry = sources?.get(record.id);
838
+ const cached = cache.get(JSON.stringify([collection, record.id]));
839
+ const data = Object.fromEntries(Object.entries(record).filter(([field]) => field !== "id"));
840
+ const folder = entryFolder(roots.root, entry?.sourceData);
841
+ /** Each resolver receives its own copies, so it cannot change another's inputs. */
842
+ const context = () => ({
843
+ collection,
844
+ id: record.id,
845
+ source: detach(entry?.sourceData),
846
+ data: detach(data),
847
+ signal
848
+ });
849
+ for (const [name, payload] of Object.entries(payloads)) {
850
+ signal.throwIfAborted();
851
+ const reuse = payload.cache !== false;
852
+ const previous = reuse ? cached?.payloads?.get(name) : void 0;
853
+ const base = {
854
+ code: "validation-error",
855
+ operation: "build",
856
+ collection,
857
+ id: record.id,
858
+ source: entry?.source,
859
+ payload: name,
860
+ message: ""
861
+ };
862
+ let combinations = previous?.combinations;
863
+ if (!combinations) try {
864
+ combinations = await variantCombinations(name, payload, {
865
+ ...context(),
866
+ variants: {}
867
+ });
868
+ } catch (cause) {
869
+ diagnostics.push(...contentError(cause, {
870
+ ...base,
871
+ message: cause instanceof Error ? cause.message : `Payload "${name}" variants failed.`
872
+ }).diagnostics);
873
+ continue;
874
+ }
875
+ const nodes = /* @__PURE__ */ new Map();
876
+ let complete = true;
877
+ for (const variant of combinations) {
878
+ signal.throwIfAborted();
879
+ const key = variantKey(variant);
880
+ const reused = previous?.nodes.get(key);
881
+ if (reused) {
882
+ nodes.set(key, reused);
883
+ continue;
884
+ }
885
+ const location = {
886
+ ...base,
887
+ variant
888
+ };
889
+ const dependencies = [];
890
+ try {
891
+ const result = await payload.resolve({
892
+ ...context(),
893
+ variant: { ...variant },
894
+ readBody: async () => entry?.body,
895
+ files: createEntryFiles({
896
+ root: roots.root,
897
+ folder,
898
+ shared: roots.shared,
899
+ entry: record.id,
900
+ signal,
901
+ dependencies
902
+ })
903
+ });
904
+ const value = detach(await validate(payload.schema, result, location));
905
+ nodes.set(key, {
906
+ value,
907
+ encoded: encode(value),
908
+ dependencies
909
+ });
910
+ } catch (cause) {
911
+ complete = false;
912
+ const formatted = cause && typeof cause === "object" && "path" in cause && typeof cause.path === "string" ? cause.path : "";
913
+ diagnostics.push(...located(cause, {
914
+ ...location,
915
+ path: formatted ? valuePath(formatted) : void 0,
916
+ message: `Payload ${payloadIdentity(name, variant)} failed: ${cause instanceof Error ? cause.message : String(cause)}${formatted ? ` (at ${formatted})` : ""}`
917
+ }).diagnostics);
918
+ }
919
+ }
920
+ signal.throwIfAborted();
921
+ if (cached && reuse) {
922
+ cached.payloads ??= /* @__PURE__ */ new Map();
923
+ cached.payloads.set(name, {
924
+ combinations,
925
+ nodes
926
+ });
927
+ }
928
+ if (!complete) continue;
929
+ table.get(name).set(record.id, new Map([...nodes].map(([key, node]) => [key, Object.assign(async () => node.value, { encoded: node.encoded })])));
930
+ }
931
+ }
932
+ return table;
933
+ }
399
934
  //#endregion
400
935
  //#region src/preparation/config.ts
401
936
  /** Loads build-only configuration without retaining a previous config module value. */
@@ -418,35 +953,20 @@ async function loadRegistry(config) {
418
953
  async: true,
419
954
  forceTranspile: true
420
955
  });
956
+ if (imported.queryStyle !== void 0 && !isQueryStyle(imported.queryStyle)) throw new Error("content-query.config queryStyle must be \"fluent\" or \"relational\".");
421
957
  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
958
  const registry = imported.collections;
423
959
  if (!registry || typeof registry !== "object" || Array.isArray(registry)) throw new Error("content-query.config must export a collections registry.");
424
960
  for (const [name, definition] of Object.entries(registry)) if (!name || !definition || typeof definition !== "object" || !("loader" in definition) || !definition.loader || typeof definition.loader !== "object" || !("name" in definition.loader) || typeof definition.loader.name !== "string" || !definition.loader.name || !("load" in definition.loader) || typeof definition.loader.load !== "function") throw new Error(`Collection "${name}" requires a named loader object with load(context).`);
961
+ collectionNames(registry);
425
962
  return {
426
963
  registry,
427
964
  dependencies,
428
- loaderConfig: imported.loaderConfig
965
+ loaderConfig: imported.loaderConfig,
966
+ queryStyle: imported.queryStyle
429
967
  };
430
968
  }
431
969
  //#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
970
  //#region src/loaders/match.ts
451
971
  const matchers = /* @__PURE__ */ new WeakMap();
452
972
  /**
@@ -491,6 +1011,7 @@ const awaitWriteFinish = {
491
1011
  stabilityThreshold: 50,
492
1012
  pollInterval: 10
493
1013
  };
1014
+ const NEW_DIRECTORY_RECHECKS_MS = [100, 1e3];
494
1015
  /** Finds an existing ancestor so watching also detects a missing base directory's creation. */
495
1016
  async function watchAncestor(filename) {
496
1017
  let ancestor = filename;
@@ -552,6 +1073,7 @@ async function watchProject(options, register) {
552
1073
  });
553
1074
  register(watcher);
554
1075
  watcher.on("all", (event, filename) => {
1076
+ options.dependencyChanged?.(event, filename);
555
1077
  if (event === "addDir") return;
556
1078
  if (configDependency(options, filename)) {
557
1079
  options.configChanged();
@@ -615,12 +1137,188 @@ async function watchSources(options, register) {
615
1137
  }
616
1138
  if (dedicated) {
617
1139
  external.on("all", changed);
1140
+ if (external !== shared) external.on("all", (event, filename) => options.dependencyChanged?.(event, filename));
618
1141
  external.on("error", (cause) => options.failed(cause));
1142
+ if (builtin.kind === "glob") recheckNewDirectories(external, shared, (filename) => affectsSnapshot(builtin, target, "add", filename), () => options.invalidated(name), register);
619
1143
  await ready(external);
620
1144
  }
621
1145
  }
622
1146
  }
623
1147
  }
1148
+ /**
1149
+ * Invalidates a glob collection when a new directory holds matching files that no watcher event
1150
+ * reported. Chokidar subscribes to a new directory only after reading it, so a file created in
1151
+ * that gap is otherwise missed until something else reloads the collection.
1152
+ *
1153
+ * @param directories - The watcher that reports new directories under the glob base.
1154
+ * @param shared - The project watcher, which reports ordinary files when it covers the base.
1155
+ * @param matches - Whether a file belongs to the collection's snapshot.
1156
+ * @param invalidate - Reloads the collection from disk.
1157
+ * @param register - Registers cleanup for pending rechecks and listeners.
1158
+ */
1159
+ function recheckNewDirectories(directories, shared, matches, invalidate, register) {
1160
+ const pending = /* @__PURE__ */ new Set();
1161
+ const reported = /* @__PURE__ */ new Set();
1162
+ let closed = false;
1163
+ /** Records added or changed files while a recheck could otherwise report them twice. */
1164
+ function track(event, filename) {
1165
+ if (pending.size && (event === "add" || event === "change")) reported.add(path.resolve(filename));
1166
+ }
1167
+ /** Reloads only when the directory holds a matching file that no event reported. */
1168
+ async function recheck(directory) {
1169
+ let files;
1170
+ try {
1171
+ files = (await readdir(directory, {
1172
+ recursive: true,
1173
+ withFileTypes: true
1174
+ })).filter((entry) => entry.isFile()).map((entry) => path.resolve(entry.parentPath, entry.name));
1175
+ } catch {
1176
+ return;
1177
+ }
1178
+ if (!closed && files.some((filename) => matches(filename) && !reported.has(filename))) invalidate();
1179
+ }
1180
+ /** Schedules rechecks for a newly created directory. */
1181
+ function added(directory) {
1182
+ for (const delay of NEW_DIRECTORY_RECHECKS_MS) {
1183
+ const timer = setTimeout(() => {
1184
+ recheck(directory).finally(() => {
1185
+ pending.delete(timer);
1186
+ if (!pending.size) reported.clear();
1187
+ });
1188
+ }, delay);
1189
+ timer.unref();
1190
+ pending.add(timer);
1191
+ }
1192
+ }
1193
+ directories.on("all", track);
1194
+ directories.on("addDir", added);
1195
+ shared?.on("all", track);
1196
+ register(() => {
1197
+ closed = true;
1198
+ directories.off("all", track);
1199
+ directories.off("addDir", added);
1200
+ shared?.off("all", track);
1201
+ pending.forEach(clearTimeout);
1202
+ pending.clear();
1203
+ reported.clear();
1204
+ });
1205
+ }
1206
+ /**
1207
+ * Watches files and directories that resolvers read when no other watcher reports them: paths
1208
+ * outside the project root or below a directory the project watcher ignores, such as `dist/`.
1209
+ *
1210
+ * @param options - Watch options with the project root and the dependency callback.
1211
+ * @returns `add()` to watch further dependency paths, and `close()` to stop watching.
1212
+ */
1213
+ function watchDependencies(options) {
1214
+ const ignoredByProject = projectIgnored(options);
1215
+ const watched = /* @__PURE__ */ new Set();
1216
+ let watcher;
1217
+ return {
1218
+ add(paths) {
1219
+ const uncovered = [...paths].filter((filename) => !watched.has(filename) && (ignoredByProject(filename) || !(withinDirectory(options.root, filename) || withinDirectory(path.dirname(options.config), filename))));
1220
+ if (!uncovered.length) return;
1221
+ uncovered.forEach((filename) => watched.add(filename));
1222
+ if (watcher) {
1223
+ watcher.add(uncovered);
1224
+ return;
1225
+ }
1226
+ watcher = watch(uncovered, {
1227
+ ignoreInitial: true,
1228
+ awaitWriteFinish
1229
+ });
1230
+ watcher.on("all", (event, filename) => options.dependencyChanged?.(event, filename));
1231
+ watcher.on("error", (cause) => options.failed(cause));
1232
+ },
1233
+ async close() {
1234
+ await watcher?.close();
1235
+ }
1236
+ };
1237
+ }
1238
+ //#endregion
1239
+ //#region src/preparation/dependencies.ts
1240
+ /**
1241
+ * Indexes every file and glob that cached entries and payload variants read.
1242
+ *
1243
+ * @param cache - The preparation cache, whose nodes record their dependencies.
1244
+ * @returns A reverse index from files and globs to nodes.
1245
+ */
1246
+ function dependencyIndex(cache) {
1247
+ const index = {
1248
+ files: /* @__PURE__ */ new Map(),
1249
+ globs: []
1250
+ };
1251
+ /** Records one node's dependencies. */
1252
+ function add(node, dependencies) {
1253
+ for (const dependency of dependencies) if (dependency.kind === "file") {
1254
+ const nodes = index.files.get(dependency.path);
1255
+ if (nodes) nodes.push(node);
1256
+ else index.files.set(dependency.path, [node]);
1257
+ } else index.globs.push({
1258
+ base: dependency.base,
1259
+ options: dependency.options,
1260
+ node
1261
+ });
1262
+ }
1263
+ for (const [key, entry] of cache) {
1264
+ add({ key }, entry.dependencies);
1265
+ for (const [payload, cached] of entry.payloads ?? []) for (const [variant, node] of cached.nodes) add({
1266
+ key,
1267
+ payload,
1268
+ variant
1269
+ }, node.dependencies);
1270
+ }
1271
+ return index;
1272
+ }
1273
+ /**
1274
+ * Finds the nodes a file event affects: nodes that read the file, nodes whose glob gains or loses
1275
+ * a match, nodes whose glob base gains a directory, and, for a removed directory, nodes that read
1276
+ * anything inside it.
1277
+ *
1278
+ * @param index - The dependency index.
1279
+ * @param event - The watcher event, such as `add`, `change`, `unlink`, or `unlinkDir`.
1280
+ * @param filename - The affected path.
1281
+ * @returns Distinct affected nodes.
1282
+ */
1283
+ function dependents(index, event, filename) {
1284
+ const resolved = path.resolve(filename);
1285
+ const found = /* @__PURE__ */ new Map();
1286
+ /** Adds a node once. */
1287
+ const include = (node) => found.set(JSON.stringify([
1288
+ node.key,
1289
+ node.payload,
1290
+ node.variant
1291
+ ]), node);
1292
+ for (const node of index.files.get(resolved) ?? []) include(node);
1293
+ if (event === "unlinkDir") {
1294
+ for (const [file, nodes] of index.files) if (withinDirectory(resolved, file)) nodes.forEach(include);
1295
+ }
1296
+ for (const glob of index.globs) if (event === "addDir" ? resolved !== glob.base && withinDirectory(glob.base, resolved) : affectsSnapshot({
1297
+ kind: "glob",
1298
+ options: glob.options
1299
+ }, glob.base, event, resolved)) include(glob.node);
1300
+ return [...found.values()];
1301
+ }
1302
+ /**
1303
+ * Drops affected nodes from the cache, so the next preparation resolves them again. An entry node
1304
+ * drops the entry's whole cache record; a payload node drops only that variant.
1305
+ *
1306
+ * @param cache - The preparation cache.
1307
+ * @param nodes - The nodes to invalidate.
1308
+ */
1309
+ function invalidateNodes(cache, nodes) {
1310
+ for (const node of nodes) if (node.payload === void 0) cache.delete(node.key);
1311
+ else cache.get(node.key)?.payloads?.get(node.payload)?.nodes.delete(node.variant);
1312
+ }
1313
+ /**
1314
+ * Lists the paths that dependencies need watched: each read file and each glob base.
1315
+ *
1316
+ * @param index - The dependency index.
1317
+ * @returns Distinct absolute paths.
1318
+ */
1319
+ function dependencyPaths(index) {
1320
+ return [.../* @__PURE__ */ new Set([...index.files.keys(), ...index.globs.map((glob) => glob.base)])];
1321
+ }
624
1322
  //#endregion
625
1323
  //#region src/preparation/loaders/context.ts
626
1324
  /** Supplies portable filesystem defaults and fails explicitly for unavailable Astro settings. */
@@ -1011,7 +1709,13 @@ function sessionInternals(session) {
1011
1709
  if (!internal) throw new Error("Unknown ContentQuery session.");
1012
1710
  return internal;
1013
1711
  }
1014
- /** Creates a preparation session with ordered source commands and atomic revision publication. */
1712
+ /**
1713
+ * Creates a preparation session with ordered source commands and atomic revision publication.
1714
+ *
1715
+ * @param options - Root, configuration, watch, and publication settings.
1716
+ * @returns A session whose `collections` are typed by `Style`; each published revision carries
1717
+ * the configured `queryStyle`, which the runtime enforces.
1718
+ */
1015
1719
  async function createSession(options) {
1016
1720
  options = {
1017
1721
  ...options,
@@ -1060,6 +1764,7 @@ async function createSession(options) {
1060
1764
  let registry = {};
1061
1765
  let loaders = /* @__PURE__ */ new Map();
1062
1766
  let configDependencies;
1767
+ let queryStyle;
1063
1768
  let configurationError;
1064
1769
  const candidates = /* @__PURE__ */ new Map();
1065
1770
  const cache = /* @__PURE__ */ new Map();
@@ -1086,11 +1791,26 @@ async function createSession(options) {
1086
1791
  kind: "invalidate",
1087
1792
  collection
1088
1793
  }),
1794
+ dependencyChanged: (event, filename) => dependencyChanged(event, filename),
1089
1795
  failed: (cause) => enqueue({
1090
1796
  kind: "error",
1091
1797
  cause
1092
1798
  })
1093
1799
  };
1800
+ let index;
1801
+ let preparing = false;
1802
+ const recentEvents = [];
1803
+ const dependencyWatcher = options.watch ? watchDependencies(watchOptions) : void 0;
1804
+ /** Invalidates only the cached nodes that read a changed file. */
1805
+ function dependencyChanged(event, filename) {
1806
+ if (preparing) recentEvents.push([event, filename]);
1807
+ index ??= dependencyIndex(cache);
1808
+ const nodes = dependents(index, event, filename);
1809
+ if (nodes.length) enqueue({
1810
+ kind: "payloads",
1811
+ nodes
1812
+ });
1813
+ }
1094
1814
  /** Queues ordered notifications and makes any in-flight preparation obsolete. */
1095
1815
  function enqueue(command) {
1096
1816
  if (disposed) return;
@@ -1144,7 +1864,8 @@ async function createSession(options) {
1144
1864
  const loaded = options.registry ? {
1145
1865
  registry: options.registry,
1146
1866
  dependencies: /* @__PURE__ */ new Set(),
1147
- loaderConfig: void 0
1867
+ loaderConfig: void 0,
1868
+ queryStyle: options.queryStyle
1148
1869
  } : await loadRegistry(options.config);
1149
1870
  const nextSubscriptions = new AbortController();
1150
1871
  const nextCleanups = [];
@@ -1229,9 +1950,11 @@ async function createSession(options) {
1229
1950
  registry = nextRegistry;
1230
1951
  loaders = nextLoaders;
1231
1952
  configDependencies = loaded.dependencies;
1953
+ queryStyle = loaded.queryStyle;
1232
1954
  state.registry = queryRegistry(registry);
1233
1955
  candidates.clear();
1234
1956
  cache.clear();
1957
+ index = void 0;
1235
1958
  loadFailures.clear();
1236
1959
  configurationError = void 0;
1237
1960
  installed = true;
@@ -1239,6 +1962,17 @@ async function createSession(options) {
1239
1962
  }
1240
1963
  /** Acquires invalidated snapshots and publishes only the complete current attempt. */
1241
1964
  async function prepare(names) {
1965
+ preparing = true;
1966
+ try {
1967
+ await prepareAttempt(names);
1968
+ } finally {
1969
+ preparing = false;
1970
+ index = void 0;
1971
+ for (const [event, filename] of recentEvents.splice(0)) dependencyChanged(event, filename);
1972
+ }
1973
+ }
1974
+ /** Acquires snapshots, prepares, and publishes one attempt. */
1975
+ async function prepareAttempt(names) {
1242
1976
  const currentGeneration = generation;
1243
1977
  attempt = new AbortController();
1244
1978
  const signal = attempt.signal;
@@ -1263,12 +1997,15 @@ async function createSession(options) {
1263
1997
  });
1264
1998
  try {
1265
1999
  const failures = [...loadFailures.values()];
1266
- const revision = await prepareRevision(registry, candidates, cache, signal, options.watch, failures.flatMap((error) => error.diagnostics), new Map([...loaders].map(([name, loader]) => [name, loader.parsed])));
2000
+ const style = queryStyle;
2001
+ 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 });
1267
2002
  if (disposed || currentGeneration !== generation) return;
2003
+ if (style) revision.queryStyle = style;
1268
2004
  await options.publish?.(revision, registry, signal);
1269
2005
  if (disposed || currentGeneration !== generation) return;
1270
2006
  state.revision = revision;
1271
2007
  state.error = void 0;
2008
+ dependencyWatcher?.add(dependencyPaths(index = dependencyIndex(cache)));
1272
2009
  options.change?.(state);
1273
2010
  notifySources({
1274
2011
  type: "ready",
@@ -1306,6 +2043,10 @@ async function createSession(options) {
1306
2043
  await configure(false);
1307
2044
  for (const name of Object.keys(registry)) stale.add(name);
1308
2045
  } else if (command.kind === "invalidate") stale.add(command.collection);
2046
+ else if (command.kind === "payloads") {
2047
+ invalidateNodes(cache, command.nodes);
2048
+ index = void 0;
2049
+ }
1309
2050
  } catch (cause) {
1310
2051
  if (command.kind === "config") configurationError = contentError(cause, {
1311
2052
  code: "source-error",
@@ -1330,7 +2071,11 @@ async function createSession(options) {
1330
2071
  sourceListeners.clear();
1331
2072
  generation++;
1332
2073
  attempt?.abort();
1333
- const failed = (await Promise.allSettled([watcher?.close() ?? Promise.resolve(), releaseSubscriptions()])).find((result) => result.status === "rejected");
2074
+ const failed = (await Promise.allSettled([
2075
+ watcher?.close() ?? Promise.resolve(),
2076
+ dependencyWatcher?.close() ?? Promise.resolve(),
2077
+ releaseSubscriptions()
2078
+ ])).find((result) => result.status === "rejected");
1334
2079
  if (failed?.status === "rejected") throw contentError(failed.reason, {
1335
2080
  code: "source-error",
1336
2081
  operation: "dispose",
@@ -1338,7 +2083,7 @@ async function createSession(options) {
1338
2083
  });
1339
2084
  }
1340
2085
  const session = {
1341
- content: createRuntime(state),
2086
+ collections: createCollections(state),
1342
2087
  sources,
1343
2088
  subscribe(listener) {
1344
2089
  if (disposed) throw new Error("Content session has been disposed.");
@@ -1440,11 +2185,23 @@ assertServerOnly();
1440
2185
  /**
1441
2186
  * Builds content independently of a bundler; bind the registry through an erased type import.
1442
2187
  * Generated watch sessions prune previous-run artifacts after their first successful publication.
2188
+ *
2189
+ * @param arguments_ - Optional build options.
2190
+ * @returns A session whose `content` exposes the root query APIs allowed by `Style`. Pass
2191
+ * `typeof queryStyle` from the config to type it; the configured `queryStyle` is always enforced
2192
+ * at runtime.
1443
2193
  */
1444
2194
  async function buildContent(...arguments_) {
1445
2195
  return buildAtRoot(process.cwd(), arguments_[0] ?? {});
1446
2196
  }
1447
- /** Resolves host paths and prunes previous-run artifacts on the first successful publication. */
2197
+ /**
2198
+ * Resolves host paths and prunes previous-run artifacts on the first successful publication.
2199
+ *
2200
+ * @param root - Application root that relative config and output paths resolve against.
2201
+ * @param options - Build options.
2202
+ * @param change - Optional state observer for host integrations.
2203
+ * @returns A session typed by `Registry` and `Style`.
2204
+ */
1448
2205
  async function buildAtRoot(root, options, change) {
1449
2206
  const { backend = "generated", watch = false } = options;
1450
2207
  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({
@@ -1490,4 +2247,4 @@ async function buildAtRoot(root, options, change) {
1490
2247
  });
1491
2248
  }
1492
2249
  //#endregion
1493
- export { sessionInternals as a, contentDeclarations as c, resolveConfigPath as i, virtualDeclarations as l, buildContent as n, withinDirectory as o, configCandidates as r, writeChanged as s, buildAtRoot as t, revisionModules as u };
2250
+ export { sessionInternals as a, collectionDeclarations as c, resolveConfigPath as i, virtualDeclarations as l, buildContent as n, withinDirectory as o, configCandidates as r, writeChanged as s, buildAtRoot as t, revisionModules as u };