content-query 0.4.0 → 0.5.1

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,76 @@ 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:
70
82
 
71
- ## Migrating to 0.4
83
+ ```ts
84
+ import { authors } from "#content-query/authors";
85
+ import { posts } from "#content-query/posts";
72
86
 
73
- Version 0.4.0 starts fluent queries from the collection and selects no data implicitly:
87
+ const withAuthors = await posts.using(authors).findMany({
88
+ columns: { title: true },
89
+ with: { author: { columns: { name: true } } },
90
+ });
91
+ ```
74
92
 
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")` |
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.
83
94
 
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.
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:
85
96
 
86
- ## Migrating to 0.2
97
+ ```ts
98
+ import type { ContentPost } from "#content-query/posts";
87
99
 
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:
100
+ type PostLink = ContentPost<"id" | "title">; // What select("id", "title") returns.
101
+ ```
102
+
103
+ To describe a field, write a JSDoc comment above it in the schema or above a computed field's name. Editors show the comment when you hover that field on `ContentPost`, on `ContentPost<"id" | "title">`, and on query results:
104
+
105
+ ```ts
106
+ schema: schema.object({
107
+ /** The headline, also used as the page title. */
108
+ title: schema.string(),
109
+ }),
110
+ ```
111
+
112
+ Zod's `.describe()` and `.meta()` store descriptions at runtime only, so they don't show up in types.
89
113
 
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. |
114
+ 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.
95
115
 
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.
116
+ 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).
117
+
118
+ Add `.content-query/` to `.gitignore`. Follow the quick start for a complete walkthrough, or choose a framework example below.
97
119
 
98
120
  ## Documentation
99
121
 
@@ -104,7 +126,7 @@ Built-in payload names start with an underscore: `_body` and `_rendered`. A quer
104
126
 
105
127
  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
128
 
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).
129
+ 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
130
 
109
131
  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
132
 
@@ -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-DhIxnCSM.mjs";
2
+ import { i as CollectionRegistry } from "./types-WOsFmNnN.mjs";
3
+ import { n as QueryStyle } from "./query-style-C2FcT1f9.mjs";
4
+ import "./revision-aqGUf6YV.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. */
@@ -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,92 @@ 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
+ * Both forms keep JSDoc comments written on schema fields and computed field names.
197
+ *
136
198
  * @param directory - Directory that will contain the declaration file.
137
199
  * @param config - Absolute path of content-query.config.
138
- * @returns Declaration source for `server.d.ts`.
200
+ * @param name - The collection's name in the registry.
201
+ * @param names - The collection's generated export and type names.
202
+ * @returns Declaration source for the entry's `server.d.ts`.
139
203
  */
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);
204
+ function collectionDeclarations(directory, config, name, { exportName, typeName, payloadTypeName }) {
205
+ const key = JSON.stringify(name);
206
+ const definition = `Registry[${key}]`;
207
+ const data = `import("content-query").QueryData<${definition}>`;
144
208
  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>;`,
209
+ ...configTypes(directory, config),
210
+ `/** The ${exportName} collection. Start queries here, or pass it to another collection's \`using()\` to follow relationships into it. */`,
211
+ `export declare const ${exportName}: import("content-query/runtime").Collection<Registry, ${key}, ConfiguredQueryStyle>;`,
212
+ `/** 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. */`,
213
+ `export type ${typeName}<Field extends keyof ${data} = keyof ${data}> = keyof ${data} extends Field ? ${data} : import("content-query").SelectedData<${data}, Field>;`,
214
+ `/** The value of one payload that the ${exportName} collection declares. */`,
215
+ `export type ${payloadTypeName}<Payload extends keyof import("content-query").PayloadsOf<${definition}>> = import("content-query").PayloadData<${definition}, Payload>;`,
152
216
  ``
153
217
  ].join("\n");
154
218
  }
155
219
  /**
156
- * Declares project-bound virtual server types and the framework-neutral development bridge.
220
+ * Declares project-bound virtual collection modules and the framework-neutral development bridge.
157
221
  *
158
222
  * @param directory - Directory that will contain the declaration file.
159
223
  * @param config - Absolute path of content-query.config.
160
- * @returns Declaration source for the `content-query:server` and `content-query:client` modules.
224
+ * @param names - Every collection's generated names, keyed by collection name.
225
+ * @returns Declaration source for the `content-query:collections/<export name>` and
226
+ * `content-query:client` modules, plus the type-only `content-query:collections` module, whose
227
+ * `CollectionKey` names every collection for integrations that accept a collection name.
161
228
  */
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`;
229
+ function virtualDeclarations(directory, config, names) {
230
+ 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`);
231
+ 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`;
232
+ return `${collections.join("")}${keys}declare module "content-query:client" {\n export function onContentUpdate(callback: () => void | Promise<void>): () => void;\n}\n`;
164
233
  }
165
234
  //#endregion
166
235
  //#region src/artifacts/write.ts
@@ -213,6 +282,8 @@ async function writeImmutable(filename, source) {
213
282
  }
214
283
  }
215
284
  const immutableModule = /\/(?:entries\/[a-f0-9]{64}|payloads\/[^/]+\/[a-f0-9]{64}|index-[a-f0-9]{64})\.js$/;
285
+ /** Matches each collection's entry, which imports the collection's other modules. */
286
+ const collectionEntry = /^collections\/[^/]+\/server\.js$/;
216
287
  /** Lists one directory's entries, treating a missing directory or a file as empty. */
217
288
  async function entriesOf(directory) {
218
289
  try {
@@ -250,20 +321,35 @@ async function pruneArtifacts(outDir, modules) {
250
321
  const payloadDirectories = await entriesOf(payloads);
251
322
  const candidates = [...(await entriesOf(owned)).map((name) => `collections/${directory}/${name}`), ...(await entriesOf(entries)).map((name) => `collections/${directory}/entries/${name}`)];
252
323
  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 });
324
+ for (const filename of candidates) {
325
+ const generated = immutableModule.test(`/${filename}`) || filename.endsWith(`/${directory}/server.js`);
326
+ const declarations = filename.endsWith(`/${directory}/server.d.ts`) && !modules.has(filename.replace(/\.d\.ts$/, ".js"));
327
+ if (generated && !modules.has(filename) || declarations) await rm(path.join(outDir, filename), { force: true });
328
+ }
254
329
  await removeIfEmpty(entries);
255
330
  for (const payload of payloadDirectories) await removeIfEmpty(path.join(payloads, payload));
256
331
  await removeIfEmpty(payloads);
257
332
  await removeIfEmpty(owned);
258
333
  }
259
334
  }
260
- /** Stages modules with bounded I/O before atomically switching the public server entry. */
335
+ /**
336
+ * Stages modules with bounded I/O before switching each collection's public entry.
337
+ *
338
+ * @param revision - The prepared revision to publish.
339
+ * @param outDir - The generated output directory.
340
+ * @param config - Absolute path of content-query.config, which declarations import types from.
341
+ * @param options - `signal` cancels the write; `prune` removes superseded modules.
342
+ */
261
343
  async function writeArtifacts(revision, outDir, config, { signal, prune = false } = {}) {
262
344
  const modules = await revisionModules(revision);
345
+ const names = collectionNames(revision.registry);
263
346
  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");
347
+ await writeChanged(path.join(outDir, "types.d.ts"), virtualDeclarations(outDir, config, names));
348
+ for (const [name, generated] of names) {
349
+ const directory = path.join(outDir, "collections", generated.exportName);
350
+ await writeChanged(path.join(directory, "server.d.ts"), collectionDeclarations(directory, config, name, generated));
351
+ }
352
+ const staged = [...modules].filter(([filename]) => !collectionEntry.test(filename));
267
353
  const directories = new Set(staged.map(([filename]) => path.dirname(path.join(outDir, filename))));
268
354
  await Promise.all([...directories].map(async (directory) => mkdir(directory, { recursive: true })));
269
355
  const pending = staged.values();
@@ -275,7 +361,7 @@ async function writeArtifacts(revision, outDir, config, { signal, prune = false
275
361
  }
276
362
  }))).find((outcome) => outcome.status === "rejected");
277
363
  if (failure?.status === "rejected") throw failure.reason;
278
- await writeChanged(path.join(outDir, "server.js"), modules.get("server.js"), signal);
364
+ for (const [filename, code] of modules) if (collectionEntry.test(filename)) await writeChanged(path.join(outDir, filename), code, signal);
279
365
  if (prune) await pruneArtifacts(outDir, modules);
280
366
  }
281
367
  //#endregion
@@ -319,7 +405,8 @@ function queryRegistry(registry) {
319
405
  payloads: {
320
406
  ...hasBuiltinPayloads(definition) ? builtinPayloads : {},
321
407
  ...customPayloads(definition)
322
- }
408
+ },
409
+ ...definition.typeName === void 0 ? {} : { typeName: definition.typeName }
323
410
  }]));
324
411
  }
325
412
  //#endregion
@@ -872,6 +959,7 @@ async function loadRegistry(config) {
872
959
  const registry = imported.collections;
873
960
  if (!registry || typeof registry !== "object" || Array.isArray(registry)) throw new Error("content-query.config must export a collections registry.");
874
961
  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).`);
962
+ collectionNames(registry);
875
963
  return {
876
964
  registry,
877
965
  dependencies,
@@ -1626,8 +1714,8 @@ function sessionInternals(session) {
1626
1714
  * Creates a preparation session with ordered source commands and atomic revision publication.
1627
1715
  *
1628
1716
  * @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.
1717
+ * @returns A session whose `collections` are typed by `Style`; each published revision carries
1718
+ * the configured `queryStyle`, which the runtime enforces.
1631
1719
  */
1632
1720
  async function createSession(options) {
1633
1721
  options = {
@@ -1996,7 +2084,7 @@ async function createSession(options) {
1996
2084
  });
1997
2085
  }
1998
2086
  const session = {
1999
- content: createRuntime(state),
2087
+ collections: createCollections(state),
2000
2088
  sources,
2001
2089
  subscribe(listener) {
2002
2090
  if (disposed) throw new Error("Content session has been disposed.");
@@ -2160,4 +2248,4 @@ async function buildAtRoot(root, options, change) {
2160
2248
  });
2161
2249
  }
2162
2250
  //#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 };
2251
+ 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 };
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-Aoi7Unm0.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-CIw4yWs9.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-CIw4yWs9.mjs";
4
4
  import { cac } from "cac";
5
5
  //#region package.json
6
- var version = "0.4.0";
6
+ var version = "0.5.1";
7
7
  //#endregion
8
8
  //#region src/integrations/cli/arguments.ts
9
9
  /** Validates one path option after CAC's automatic numeric conversion. */