content-query 0.4.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.
package/README.md CHANGED
@@ -9,6 +9,7 @@ Use it with your existing framework. Your application owns its routes, layouts,
9
9
  - **Typed schemas.** Validate metadata with bundled schema builders or any Standard Schema V1 provider, with inferred TypeScript types.
10
10
  - **Flexible sources.** Load local files, use Astro-compatible content loaders, or write your own loader.
11
11
  - **Queries and relationships.** Select fields, filter, sort, paginate, and load related entries with validated references.
12
+ - **Per-collection entries.** Import each collection from its own generated module with named types such as `ContentPost`, so a route bundles and traces only the collections it uses.
12
13
  - **Markdown rendering.** Request HTML, headings, and syntax highlighting as query payloads, loaded only when a query asks for them.
13
14
  - **Custom payloads.** Declare schema-validated per-entry data, optionally by variant such as locale, resolved during the build from tracked related files, and loaded only when a query names it. Development edits re-resolve only the variants whose files changed.
14
15
  - **Optional MDX companion.** [`@content-query/mdx`](https://github.com/yangshun/content-query/tree/main/packages/mdx) supplies React islands and ordinary React document modules with Vite SSR. Core has no MDX or React dependency.
@@ -45,55 +46,65 @@ Add Markdown files with `title` frontmatter under `content/posts/`, then build:
45
46
  npx content-query build
46
47
  ```
47
48
 
48
- Query the generated module from server code:
49
+ Each collection gets its own generated entry, which exports the collection under its name. Query it from server code:
49
50
 
50
51
  ```ts
51
- import { content } from "./.content-query/server.js";
52
+ import { posts } from "./.content-query/collections/posts/server.js";
52
53
  import { eq } from "content-query/query";
53
54
 
54
- const posts = await content
55
- .from("posts")
55
+ const summaries = await posts
56
56
  .select("id", "title")
57
57
  .where((fields) => eq(fields.draft, false))
58
58
  .orderBy("title");
59
59
 
60
- const post = await content.query.posts.findFirst({
61
- where: { id: posts[0]!.id },
60
+ const post = await posts.findFirst({
61
+ where: { id: summaries[0]!.id },
62
62
  payloads: { _rendered: true },
63
63
  });
64
64
  const { html, headings } = post!._payloads._rendered;
65
65
  ```
66
66
 
67
+ Built-in payloads are `_body` and `_rendered`. JSON, YAML, and TOML sources and `file()` collections have none, and a custom `BodyLoader` offers only `_body`. A query for a missing entry returns `undefined`.
68
+
69
+ An entry imports only its own collection's data, so a route that imports `posts` never bundles or traces another collection. With Vite, import from `content-query:collections/posts`. Elsewhere, such as in Next.js, map the entries in `package.json` to import `{ posts }` from `#content-query/posts` at any directory depth:
70
+
71
+ ```json
72
+ {
73
+ "imports": {
74
+ "#content-query/*": "./.content-query/collections/*/server.js"
75
+ }
76
+ }
77
+ ```
78
+
67
79
  Queries return only the data they select, not even `id` implicitly. A query must select fields, include a relationship, request a payload, or select adjacent entries; otherwise it fails to type-check and throws an `invalid-query` error. Counts need no selection.
68
80
 
69
- Add `.content-query/` to `.gitignore`. Follow the quick start for a complete walkthrough, or choose a framework example below.
81
+ To follow a relationship, pass its target collection to `using()`. Nothing related loads implicitly:
82
+
83
+ ```ts
84
+ import { authors } from "#content-query/authors";
85
+ import { posts } from "#content-query/posts";
70
86
 
71
- ## Migrating to 0.4
87
+ const withAuthors = await posts.using(authors).findMany({
88
+ columns: { title: true },
89
+ with: { author: { columns: { name: true } } },
90
+ });
91
+ ```
72
92
 
73
- Version 0.4.0 starts fluent queries from the collection and selects no data implicitly:
93
+ A relationship whose target was not supplied is a type error and throws `invalid-query`. Nested relationships need every collection they reach, such as `posts.using(authors, publishers)`. Self-relationships need nothing.
74
94
 
75
- | 0.3 | 0.4 |
76
- | --------------------------------------------- | ---------------------------------------------------------------------------------- |
77
- | `content.select("id", "title").from("posts")` | `content.from("posts").select("id", "title")` |
78
- | `content.select().from("posts")` | `content.from("posts").select(...)` listing the fields used |
79
- | `findMany()` or `findMany({ where })` | Add `columns: { field: true }` for each field used |
80
- | `columns: { draft: false }` | List the included fields with `true` |
81
- | `with: { author: true }` | `with: { author: { columns: { name: true } } }` |
82
- | `adjacent: true` or `.selectAdjacent()` | `adjacent: { scope: "results", columns: { id: true } }` or `.selectAdjacent("id")` |
95
+ Each entry also exports the collection's types. `ContentPost` is a full entry; pass the selected fields to type query results, such as props for a list component:
83
96
 
84
- Results include `id` only when it is selected. Queries that request only payloads, such as `findFirst({ where: { id }, payloads: { _rendered: true } })`, need no columns.
97
+ ```ts
98
+ import type { ContentPost } from "#content-query/posts";
85
99
 
86
- ## Migrating to 0.2
100
+ type PostLink = ContentPost<"id" | "title">; // What select("id", "title") returns.
101
+ ```
87
102
 
88
- Version 0.2.0 removes `content.readBody()` and `content.render()`. Request bodies and rendered output as payloads in the query that loads the entry:
103
+ Type names are `Content` plus the export name, capitalized and made singular by dropping one trailing `s` (`jobs` gives `ContentJob`, `coding` gives `ContentCoding`). Set `typeName: "Category"` in `defineCollection()` for irregular plurals. Declared payloads are typed as `ContentPostPayload<"detail">`. Collection names that are not identifiers are converted for the export and import path, such as `blog-posts` to `blogPosts`; two collections that would share an export or type name fail the build.
89
104
 
90
- | 0.1 | 0.2 |
91
- | ------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
92
- | `await content.readBody("posts", id)` | `(await content.query.posts.findFirst({ where: { id }, payloads: { _body: true } }))?._payloads._body` |
93
- | `await content.render("posts", id)` | `(await content.query.posts.findFirst({ where: { id }, payloads: { _rendered: true } }))?._payloads._rendered` |
94
- | Fluent queries | Add `.withPayload("_rendered")` or `.withPayload("_body")` before the terminal. |
105
+ Paginate root queries in one call. `paginate({ page: 2, size: 10 })` returns `items`, `total`, `page`, `pageCount`, `hasNextPage`, and `hasPreviousPage` for numbered archive pages. `paginate({ size: 10, after: previous.endCursor })` returns cursor pages with `startCursor` and `endCursor` for "load more" lists. Both work in fluent queries and in `posts.paginate()`; see [pagination](https://github.com/yangshun/content-query/blob/main/docs/API.md#pagination).
95
106
 
96
- Built-in payload names start with an underscore: `_body` and `_rendered`. A query for a missing entry returns `undefined` instead of rejecting with `entry-not-found`, which no longer exists. JSON, YAML, and TOML sources and `file()` collections offer no payloads, and custom loaders typed as `BodyLoader` offer only `_body`. Rebuild generated content after upgrading; 0.1 artifacts are rejected.
107
+ Add `.content-query/` to `.gitignore`. Follow the quick start for a complete walkthrough, or choose a framework example below.
97
108
 
98
109
  ## Documentation
99
110
 
@@ -104,7 +115,7 @@ Built-in payload names start with an underscore: `_body` and `_rendered`. A quer
104
115
 
105
116
  The Vite integration supports Vite 8, and the Next.js integration supports Next.js 15 and 16. Both integrations are optional; standalone builds and queries require neither framework.
106
117
 
107
- To allow only one query API, export `queryStyle` from the config: `export const queryStyle = "relational";` keeps `content.query` and `content.count()`, and `"fluent"` keeps `content.from()`. Generated types report the disabled API in the editor, and the runtime throws an `invalid-query` error for it. See [query style](https://github.com/yangshun/content-query/blob/main/docs/API.md#query-style).
118
+ To allow only one query API, export `queryStyle` from the config: `export const queryStyle = "relational";` keeps `posts.findMany()`, `findFirst()`, `paginate()`, and `count(options)`, and `"fluent"` keeps `posts.select()` and the other fluent starters. `count()` without options works in both. Generated types report the disabled API in the editor, and the runtime throws an `invalid-query` error for it. See [query style](https://github.com/yangshun/content-query/blob/main/docs/API.md#query-style).
108
119
 
109
120
  Pass `contentQuery()` directly to Vite or Vite+'s `plugins` array. No `Plugin` type cast is needed; workspace consumers share the pinned toolchain versions in the dependency catalog.
110
121
 
@@ -3,7 +3,8 @@ import { i as groupDiagnostics, n as contentError, o as payloadIdentity, r as de
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
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
+ 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";
7
8
  import { t as renderMarkdown } from "./markdown-CHvBBJNa.mjs";
8
9
  import { access, link, mkdir, readFile, readdir, realpath, rm, rmdir, stat, writeFile } from "node:fs/promises";
9
10
  import path from "node:path";
@@ -19,7 +20,13 @@ import picomatch from "picomatch";
19
20
  function digest(value) {
20
21
  return createHash("sha256").update(JSON.stringify(value)).digest("hex");
21
22
  }
22
- /** 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
+ */
23
30
  function directoryName(name) {
24
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")}`;
25
32
  }
@@ -30,20 +37,43 @@ function compactJson(source) {
30
37
  const compressed = JSON.stringify(gzipSync(source, { level: 1 }).toString("base64"));
31
38
  return compressed.length < bytes * .9 ? compressed : void 0;
32
39
  }
33
- /** Creates portable ESM; build integrations can batch payload sources before a strict bundler runs. */
34
- 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 } = {}) {
35
67
  const modules = /* @__PURE__ */ new Map();
36
- const manifest = {
37
- version: 2,
38
- codec: CODEC_VERSION,
39
- collections: revision.registry,
40
- ...revision.queryStyle ? { queryStyle: revision.queryStyle } : {}
41
- };
42
- const imports = [];
43
- const names = [];
44
- 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;
45
72
  for (const [name, collection] of Object.entries(revision.collections)) {
46
- 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;
47
77
  const payloads = [];
48
78
  const chunks = [];
49
79
  let batch = [];
@@ -67,7 +97,7 @@ async function revisionModules(revision, { payloadChunkBytes = 0 } = {}) {
67
97
  for (const [id, load] of collection.builtins) {
68
98
  const payload = await load();
69
99
  const encoded = encode(payload);
70
- needsRenderer ||= payload.markdown !== void 0;
100
+ markdown ||= payload.markdown !== void 0;
71
101
  if (payloadChunkBytes) {
72
102
  const bytes = Buffer.byteLength(`[${JSON.stringify(id)}]: ${JSON.stringify(encoded)},\n`);
73
103
  if (batchBytes && batchBytes + bytes > payloadChunkBytes) flushBatch();
@@ -114,53 +144,91 @@ async function revisionModules(revision, { payloadChunkBytes = 0 } = {}) {
114
144
  const code = `${loaders}export default { records: ${compressedRecords ? `decodeCompressed(${compressedRecords})` : recordSource}, builtins: ${loading}${custom.length ? `, payloads: {\n${custom.join(",\n")}\n}` : ""} };\n`;
115
145
  const index = `index-${digest(code)}.js`;
116
146
  modules.set(`${directory}/${index}`, code);
117
- modules.set(`${directory}/index.js`, `export { default } from ${JSON.stringify(`./${index}`)};\n`);
118
- const binding = `collection${imports.length}`;
119
- imports.push(`import ${binding} from ${JSON.stringify(`./${directory}/${index}`)};`);
120
- 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`);
121
173
  }
122
- modules.set("manifest.js", `export default JSON.parse(${JSON.stringify(JSON.stringify(manifest))});\n`);
123
- const renderer = needsRenderer ? `, body => import("content-query/internal/markdown").then(module => module.renderMarkdown(body))` : "";
124
- 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`);
125
174
  return modules;
126
175
  }
127
176
  //#endregion
128
177
  //#region src/artifacts/declarations.ts
178
+ /** Declares the config-bound `Registry` and `ConfiguredQueryStyle` aliases for one directory. */
179
+ function configTypes(directory, config) {
180
+ let relative = path.relative(directory, config).replace(/\\/g, "/");
181
+ if (!relative.startsWith(".")) relative = `./${relative}`;
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;`];
184
+ }
129
185
  /**
130
- * Generates type-only consumer bindings without loading schemas or adding runtime imports.
186
+ * Generates type-only bindings for one collection entry without loading schemas or adding runtime
187
+ * imports.
131
188
  *
132
189
  * 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
190
+ * literal `export const queryStyle = "relational"` narrows the export without a rebuild. A missing
134
191
  * export, or one typed as the whole `QueryStyle` union, allows both query styles.
135
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
+ *
136
197
  * @param directory - Directory that will contain the declaration file.
137
198
  * @param config - Absolute path of content-query.config.
138
- * @returns Declaration source for `server.d.ts`.
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`.
139
202
  */
140
- function contentDeclarations(directory, config) {
141
- let relative = path.relative(directory, config).replace(/\\/g, "/");
142
- if (!relative.startsWith(".")) relative = `./${relative}`;
143
- const source = JSON.stringify(relative);
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}>`;
144
207
  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>;`,
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>;`,
152
215
  ``
153
216
  ].join("\n");
154
217
  }
155
218
  /**
156
- * Declares project-bound virtual server types and the framework-neutral development bridge.
219
+ * Declares project-bound virtual collection modules and the framework-neutral development bridge.
157
220
  *
158
221
  * @param directory - Directory that will contain the declaration file.
159
222
  * @param config - Absolute path of content-query.config.
160
- * @returns Declaration source for the `content-query:server` and `content-query:client` modules.
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.
161
227
  */
162
- function virtualDeclarations(directory, config) {
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`;
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`;
164
232
  }
165
233
  //#endregion
166
234
  //#region src/artifacts/write.ts
@@ -213,6 +281,8 @@ async function writeImmutable(filename, source) {
213
281
  }
214
282
  }
215
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$/;
216
286
  /** Lists one directory's entries, treating a missing directory or a file as empty. */
217
287
  async function entriesOf(directory) {
218
288
  try {
@@ -250,20 +320,35 @@ async function pruneArtifacts(outDir, modules) {
250
320
  const payloadDirectories = await entriesOf(payloads);
251
321
  const candidates = [...(await entriesOf(owned)).map((name) => `collections/${directory}/${name}`), ...(await entriesOf(entries)).map((name) => `collections/${directory}/entries/${name}`)];
252
322
  for (const payload of payloadDirectories) candidates.push(...(await entriesOf(path.join(payloads, payload))).map((name) => `collections/${directory}/payloads/${payload}/${name}`));
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 });
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
+ }
254
328
  await removeIfEmpty(entries);
255
329
  for (const payload of payloadDirectories) await removeIfEmpty(path.join(payloads, payload));
256
330
  await removeIfEmpty(payloads);
257
331
  await removeIfEmpty(owned);
258
332
  }
259
333
  }
260
- /** 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
+ */
261
342
  async function writeArtifacts(revision, outDir, config, { signal, prune = false } = {}) {
262
343
  const modules = await revisionModules(revision);
344
+ const names = collectionNames(revision.registry);
263
345
  await writeChanged(path.join(outDir, "package.json"), "{\"type\":\"module\",\"private\":true}\n");
264
- await writeChanged(path.join(outDir, "server.d.ts"), contentDeclarations(outDir, config));
265
- await writeChanged(path.join(outDir, "types.d.ts"), virtualDeclarations(outDir, config));
266
- 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));
267
352
  const directories = new Set(staged.map(([filename]) => path.dirname(path.join(outDir, filename))));
268
353
  await Promise.all([...directories].map(async (directory) => mkdir(directory, { recursive: true })));
269
354
  const pending = staged.values();
@@ -275,7 +360,7 @@ async function writeArtifacts(revision, outDir, config, { signal, prune = false
275
360
  }
276
361
  }))).find((outcome) => outcome.status === "rejected");
277
362
  if (failure?.status === "rejected") throw failure.reason;
278
- 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);
279
364
  if (prune) await pruneArtifacts(outDir, modules);
280
365
  }
281
366
  //#endregion
@@ -319,7 +404,8 @@ function queryRegistry(registry) {
319
404
  payloads: {
320
405
  ...hasBuiltinPayloads(definition) ? builtinPayloads : {},
321
406
  ...customPayloads(definition)
322
- }
407
+ },
408
+ ...definition.typeName === void 0 ? {} : { typeName: definition.typeName }
323
409
  }]));
324
410
  }
325
411
  //#endregion
@@ -872,6 +958,7 @@ async function loadRegistry(config) {
872
958
  const registry = imported.collections;
873
959
  if (!registry || typeof registry !== "object" || Array.isArray(registry)) throw new Error("content-query.config must export a collections registry.");
874
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);
875
962
  return {
876
963
  registry,
877
964
  dependencies,
@@ -1626,8 +1713,8 @@ function sessionInternals(session) {
1626
1713
  * Creates a preparation session with ordered source commands and atomic revision publication.
1627
1714
  *
1628
1715
  * @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.
1716
+ * @returns A session whose `collections` are typed by `Style`; each published revision carries
1717
+ * the configured `queryStyle`, which the runtime enforces.
1631
1718
  */
1632
1719
  async function createSession(options) {
1633
1720
  options = {
@@ -1996,7 +2083,7 @@ async function createSession(options) {
1996
2083
  });
1997
2084
  }
1998
2085
  const session = {
1999
- content: createRuntime(state),
2086
+ collections: createCollections(state),
2000
2087
  sources,
2001
2088
  subscribe(listener) {
2002
2089
  if (disposed) throw new Error("Content session has been disposed.");
@@ -2160,4 +2247,4 @@ async function buildAtRoot(root, options, change) {
2160
2247
  });
2161
2248
  }
2162
2249
  //#endregion
2163
- 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 };
@@ -1,6 +1,7 @@
1
- import { i as InvalidRelationships, o as ContentError, t as Content } from "./content-CwTKqRs9.mjs";
2
- import { i as CollectionRegistry } from "./types-DbFfF6JE.mjs";
3
- import { n as QueryStyle } from "./query-style-eXENQJ0O.mjs";
1
+ import { a as InvalidRelationships, r as CollectionHandles, s as ContentError } from "./collection-types-CsQtGw7_.mjs";
2
+ import { i as CollectionRegistry } from "./types-Dz1vTXa2.mjs";
3
+ import { n as QueryStyle } from "./query-style-C2FcT1f9.mjs";
4
+ import "./revision-DBwU-aC9.mjs";
4
5
  //#region src/preparation/session.d.ts
5
6
  /** A local source captured by a successfully prepared content revision. */
6
7
  type ContentSourceEntry = Readonly<{
@@ -22,8 +23,12 @@ type ContentSourceEvent = {
22
23
  error: ContentError;
23
24
  };
24
25
  type ContentSession<Registry extends CollectionRegistry = {}, Style extends QueryStyle = QueryStyle> = {
25
- /** Root query APIs are typed by `Style`; the configured `queryStyle` is enforced at runtime. */
26
- content: Content<Registry, Style>;
26
+ /**
27
+ * One query handle per collection, keyed by collection name, like the generated entries export.
28
+ * Each loads only its own collection; pass others to `using()` to follow relationships. Members
29
+ * are typed by `Style`; the configured `queryStyle` is enforced at runtime.
30
+ */
31
+ collections: CollectionHandles<Registry, Style>;
27
32
  /** Reads the latest successful source snapshot; current preparation errors are rethrown. */
28
33
  sources(): ContentSourceSnapshot;
29
34
  /** Observes subsequent publications and failures; returns an idempotent unsubscribe function. */
package/dist/build.d.mts CHANGED
@@ -1,2 +1,2 @@
1
- import { a as ContentSourceEvent, i as ContentSourceEntry, n as buildContent, o as ContentSourceSnapshot, r as ContentSession, t as BuildContentOptions } from "./build-DL1GzmRy.mjs";
1
+ import { a as ContentSourceEvent, i as ContentSourceEntry, n as buildContent, o as ContentSourceSnapshot, r as ContentSession, t as BuildContentOptions } from "./build-DfoncKL8.mjs";
2
2
  export { type BuildContentOptions, type ContentSession, type ContentSourceEntry, type ContentSourceEvent, type ContentSourceSnapshot, buildContent };
package/dist/build.mjs CHANGED
@@ -1 +1 @@
1
- export { n as buildContent } from "./build-CcqIpkes.mjs";
1
+ export { n as buildContent } from "./build-DWpB69_c.mjs";
package/dist/cli.mjs CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { o as payloadIdentity, t as ContentError } from "./errors-Bfd0R4_P.mjs";
3
- import { t as buildAtRoot } from "./build-CcqIpkes.mjs";
3
+ import { t as buildAtRoot } from "./build-DWpB69_c.mjs";
4
4
  import { cac } from "cac";
5
5
  //#region package.json
6
- var version = "0.4.0";
6
+ var version = "0.5.0";
7
7
  //#endregion
8
8
  //#region src/integrations/cli/arguments.ts
9
9
  /** Validates one path option after CAC's automatic numeric conversion. */