@natilon/cms-server 0.8.0 → 0.10.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
@@ -103,3 +103,77 @@ All under `src/adapters/`. Each is a pure factory; no module-scoped state.
103
103
  JSDoc contracts in `src/adapters/types.mjs`. Swap in your own
104
104
  implementation by passing different adapter instances; the server
105
105
  glue is agnostic.
106
+
107
+ ## Collection listing & the content index
108
+
109
+ ### The problem
110
+
111
+ Listing a collection traditionally fetches every entry's full JSON via GraphQL or a fallback REST approach (one request per file). On Cloudflare Workers this exceeds the 50-subrequest limit. On GitHub's GraphQL, large collections return 502 errors. The solution: **a lightweight per-collection `_index.json` manifest** containing only entry metadata (id, slug, lang, collection, title, and brief meta). The server reads one manifest per collection instead of fetching every entry.
112
+
113
+ ### How `_index.json` works
114
+
115
+ Each collection maintains a manifest at `<pagesDir>/<collection>/_index.json`:
116
+
117
+ ```json
118
+ {
119
+ "entries": [
120
+ {
121
+ "id": "entry-1",
122
+ "slug": "my-first-post",
123
+ "lang": "en",
124
+ "collection": "blog",
125
+ "title": "My First Post",
126
+ "file": "entry-1.json",
127
+ "meta": { /* custom fields from metaFields */ }
128
+ }
129
+ ]
130
+ }
131
+ ```
132
+
133
+ The index is maintained **incrementally**: `createPage`, `writePage`, and `deletePage` upsert or remove entries. On batch operations, `writeBatch` regenerates the `_index.json` in the same commit. Normal CMS edits keep the index in sync automatically — no rebuild needed.
134
+
135
+ ### Configuration: the `content.list` block
136
+
137
+ In `cms.config.mjs`, configure listing behavior under `content.list`:
138
+
139
+ ```js
140
+ content: {
141
+ provider: "github",
142
+ // ...
143
+ list: {
144
+ strategy: "index", // default; reads _index.json
145
+ rebuild: "build", // "build" (default) | "lazy"
146
+ indexFile: "_index.json", // manifest filename
147
+ resolve: undefined, // optional: custom listing function
148
+ },
149
+ }
150
+ ```
151
+
152
+ | Option | Values | Description |
153
+ | --------- | ----------------------- | ----------- |
154
+ | `strategy` | `"index"` | Built-in strategy: reads the `_index.json` manifest. Only option currently shipped. |
155
+ | `rebuild` | `"build"` (default), `"lazy"` | **`"build"`**: server never cold-rebuilds an index at request time. If missing, returns empty and logs a warning to run the CLI. Safest for serverless (avoids subrequest blowups). **`"lazy"`**: if an index is missing, the server bootstraps it with one GraphQL request and persists it. Convenient for small/self-hosted setups, but risky on very large collections (GraphQL may fail). |
156
+ | `indexFile` | string | Override the manifest filename (default: `_index.json`). |
157
+ | `resolve` | `async (collection, { sortConfig }) => entries[] \| null` | Optional: bring your own listing logic. Completely replaces the built-in strategy. Return `null` for unknown collections. Plug in D1, KV, Algolia, Pagefind, or any external index here — this is the scale/search extension point. |
158
+
159
+ ### Out-of-band rebuild: the `build-index` CLI
160
+
161
+ Regenerate all `_index.json` manifests locally:
162
+
163
+ ```sh
164
+ npx natilon-cms build-index --config ./cms.config.mjs
165
+ ```
166
+
167
+ Walks the local `pagesDir`, regenerates `_index.json` for every collection, and prints per-collection entry counts. Commit the result.
168
+
169
+ **When to run:**
170
+ - Once when adopting the index on an existing repo.
171
+ - After bulk imports or migrations.
172
+ - After content changed outside the CMS (e.g., direct git edits).
173
+ - **Recommended in CI/deployment for serverless**: build and commit the index locally or in your CI pipeline, so the Worker only ever reads it (pairs with `rebuild: "build"`).
174
+
175
+ ### Quick decision guide
176
+
177
+ - **Small site or self-hosted**: `rebuild: "lazy"` keeps setup simple.
178
+ - **Serverless (Cloudflare Workers) or large collections (default, recommended)**: Use `rebuild: "build"` and run `build-index` in your build/deploy pipeline. Commit the index and the Worker reads it once per request — zero surprise subrequests.
179
+ - **Real full-text search, faceting, or huge scale**: Provide a `resolve` hook backed by D1, KV, Algolia, Pagefind, or another external index.
@@ -3,7 +3,10 @@
3
3
  * natilon-cms CLI
4
4
  *
5
5
  * Usage:
6
- * natilon-cms [start] [--port 4001] [--config ./cms.config.mjs]
6
+ * natilon-cms [start] [--port 4001] [--config ./cms.config.mjs] [--dev]
7
+ *
8
+ * --dev serves the admin UI via Vite dev middleware (HMR, no rebuild step).
9
+ * Requires a linked/monorepo admin-ui source; falls back to static dist.
7
10
  *
8
11
  * Reads cms.config.mjs from the current working directory (or --config path),
9
12
  * auto-discovers the admin-ui dist, and starts the CMS server.
@@ -16,41 +19,163 @@
16
19
 
17
20
  import { pathToFileURL } from "url";
18
21
  import path from "path";
22
+ import fs from "fs";
19
23
  import { startCmsServer } from "../src/index.mjs";
24
+ import { buildListEntry } from "../src/adapters/_shared.mjs";
20
25
 
21
26
  const args = process.argv.slice(2).filter((a) => a !== "start");
22
27
 
28
+ // Detect subcommand (first non-flag positional arg)
29
+ const subcommand = process.argv.slice(2).find((a) => !a.startsWith("-"));
30
+
23
31
  function flag(name) {
24
32
  const i = args.indexOf(name);
25
33
  return i !== -1 ? args[i + 1] : null;
26
34
  }
27
35
 
36
+ function boolFlag(name) {
37
+ return args.includes(name);
38
+ }
39
+
40
+ // Load config helper
41
+ async function loadConfig(cfgPath) {
42
+ try {
43
+ const mod = await import(pathToFileURL(cfgPath).href);
44
+ const config = mod.default;
45
+ const publicConfig = mod.publicConfig;
46
+ if (!config) throw new Error("cms.config.mjs must have a default export");
47
+ return { config, publicConfig };
48
+ } catch (err) {
49
+ if (err.code === "ERR_MODULE_NOT_FOUND" || err.code === "ERR_LOAD_URL") {
50
+ console.error(`[natilon-cms] Cannot find config file: ${cfgPath}`);
51
+ console.error(" Create cms.config.mjs in your project root, or use --config <path>.");
52
+ } else {
53
+ console.error(`[natilon-cms] Failed to load ${cfgPath}:\n ${err.message}`);
54
+ }
55
+ process.exit(1);
56
+ }
57
+ }
58
+
59
+ const dev = boolFlag("--dev");
60
+
28
61
  const cwd = process.cwd();
29
62
  const cfgPath = path.resolve(cwd, flag("--config") || "cms.config.mjs");
30
63
  const port = parseInt(flag("--port") || process.env.ADMIN_PORT || "4001", 10);
31
64
  const realm = flag("--realm") || "CMS Admin";
32
65
 
33
- // Load the consumer's config file.
34
- let config, publicConfig;
35
- try {
36
- const mod = await import(pathToFileURL(cfgPath).href);
37
- config = mod.default;
38
- publicConfig = mod.publicConfig; // optional — server auto-derives if absent
39
- if (!config) throw new Error("cms.config.mjs must have a default export");
40
- } catch (err) {
41
- if (err.code === "ERR_MODULE_NOT_FOUND" || err.code === "ERR_LOAD_URL") {
42
- console.error(`[natilon-cms] Cannot find config file: ${cfgPath}`);
43
- console.error(" Create cms.config.mjs in your project root, or use --config <path>.");
44
- } else {
45
- console.error(`[natilon-cms] Failed to load ${cfgPath}:\n ${err.message}`);
66
+ // Handle build-index subcommand
67
+ if (subcommand === "build-index") {
68
+ const { config } = await loadConfig(cfgPath);
69
+
70
+ const pagesDir = config.content?.pagesDir;
71
+ if (!pagesDir) {
72
+ console.error("[natilon-cms] config.content.pagesDir is not set");
73
+ process.exit(1);
74
+ }
75
+
76
+ const pagesDirAbs = path.resolve(cwd, pagesDir);
77
+ if (!fs.existsSync(pagesDirAbs)) {
78
+ console.error(`[natilon-cms] Pages directory does not exist: ${pagesDirAbs}`);
79
+ process.exit(1);
80
+ }
81
+
82
+ const indexFileName = config.content?.list?.indexFile || "_index.json";
83
+
84
+ try {
85
+ const collections = fs.readdirSync(pagesDirAbs);
86
+ let totalEntries = 0;
87
+
88
+ // Pre-pass: build slug→displayName lookup tables for every collection so
89
+ // relation fields (e.g. a blog's `author` combobox) can be resolved to the
90
+ // referenced entry's name instead of storing a raw slug.
91
+ const lookups = {};
92
+ for (const collectionName of collections) {
93
+ if (collectionName.startsWith(".")) continue;
94
+ const collectionPath = path.join(pagesDirAbs, collectionName);
95
+ let stat;
96
+ try {
97
+ stat = fs.statSync(collectionPath);
98
+ } catch {
99
+ continue;
100
+ }
101
+ if (!stat.isDirectory()) continue;
102
+
103
+ const table = {};
104
+ for (const fileName of fs.readdirSync(collectionPath)) {
105
+ if (fileName === indexFileName || fileName.startsWith(".")) continue;
106
+ if (!fileName.endsWith(".json")) continue;
107
+ try {
108
+ const data = JSON.parse(fs.readFileSync(path.join(collectionPath, fileName), "utf-8"));
109
+ if (data.slug) table[data.slug] = data.meta?.title || data.meta?.name || data.slug;
110
+ } catch {
111
+ // Skip files that fail to parse
112
+ }
113
+ }
114
+ lookups[collectionName] = table;
115
+ }
116
+
117
+ for (const collectionName of collections) {
118
+ // Skip hidden names and non-directories
119
+ if (collectionName.startsWith(".")) continue;
120
+
121
+ const collectionPath = path.join(pagesDirAbs, collectionName);
122
+ let stat;
123
+ try {
124
+ stat = fs.statSync(collectionPath);
125
+ } catch {
126
+ continue;
127
+ }
128
+ if (!stat.isDirectory()) continue;
129
+
130
+ // Read JSON files in the collection
131
+ const entries = [];
132
+ const files = fs.readdirSync(collectionPath);
133
+
134
+ for (const fileName of files) {
135
+ // Skip _index.json and hidden files
136
+ if (fileName === indexFileName || fileName.startsWith(".")) continue;
137
+ if (!fileName.endsWith(".json")) continue;
138
+
139
+ const filePath = path.join(collectionPath, fileName);
140
+ let data;
141
+ try {
142
+ const content = fs.readFileSync(filePath, "utf-8");
143
+ data = JSON.parse(content);
144
+ } catch {
145
+ // Skip files that fail to parse
146
+ continue;
147
+ }
148
+
149
+ entries.push(buildListEntry(config.collections?.[collectionName], collectionName, fileName, data, lookups));
150
+ }
151
+
152
+ // Write the index file
153
+ const indexPath = path.join(collectionPath, indexFileName);
154
+ fs.writeFileSync(indexPath, JSON.stringify({ entries }, null, 2));
155
+
156
+ console.log(`${collectionName}: ${entries.length} entries`);
157
+ totalEntries += entries.length;
158
+ }
159
+
160
+ console.log(`\n[natilon-cms] Indexed ${totalEntries} entries across all collections`);
161
+ process.exit(0);
162
+ } catch (err) {
163
+ console.error(`[natilon-cms] Failed to build index:\n ${err.message}`);
164
+ process.exit(1);
46
165
  }
47
- process.exit(1);
48
166
  }
49
167
 
168
+ // Load config for server start
169
+ let config, publicConfig;
170
+ const { config: loadedConfig, publicConfig: loadedPublicConfig } = await loadConfig(cfgPath);
171
+ config = loadedConfig;
172
+ publicConfig = loadedPublicConfig;
173
+
50
174
  await startCmsServer({
51
175
  config,
52
176
  publicConfig, // undefined is fine — server uses defaultPublicConfig(config)
53
177
  rootDir: cwd,
54
178
  realm,
55
179
  port,
180
+ dev,
56
181
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@natilon/cms-server",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "Express-based CMS server with pluggable adapters for content, media, auth, and build.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -39,8 +39,8 @@ export function sortPages(pages, sortConfig) {
39
39
  if (sortConfig) {
40
40
  const dir = sortConfig.direction === "desc" ? -1 : 1;
41
41
  pages.sort((a, b) => {
42
- const av = a.meta?.[sortConfig.field];
43
- const bv = b.meta?.[sortConfig.field];
42
+ const av = a[sortConfig.field] ?? a.meta?.[sortConfig.field];
43
+ const bv = b[sortConfig.field] ?? b.meta?.[sortConfig.field];
44
44
  const aMissing = av === undefined || av === null;
45
45
  const bMissing = bv === undefined || bv === null;
46
46
  if (aMissing && bMissing) return 0;
@@ -113,6 +113,216 @@ export async function listScheduledDue(adapter) {
113
113
  return due;
114
114
  }
115
115
 
116
+ /** Top-level keys read directly from the page object; all others come from page.meta. */
117
+ const TOP_LEVEL_KEYS = new Set(["id", "slug", "lang", "collection", "title", "file"]);
118
+
119
+ /**
120
+ * Resolves a field value from a page object using the admin-UI field resolution rule.
121
+ * Top-level keys (id, slug, lang, collection, title, file) are read from the page directly;
122
+ * every other key is read from page.meta.
123
+ *
124
+ * @param {object} page
125
+ * @param {string} key
126
+ * @returns {string}
127
+ */
128
+ export function pageFieldValue(page, key) {
129
+ if (TOP_LEVEL_KEYS.has(key)) return page[key] ?? "";
130
+ return page.meta?.[key] ?? "";
131
+ }
132
+
133
+ /**
134
+ * Meta keys the server and admin UI always need on a list entry regardless of
135
+ * a collection's `listFields`, because core workflow logic reads them:
136
+ * - `publishAt` — scheduled-publish detection (listScheduledDue) + status badge
137
+ * - `draft` — publish/draft/scheduled status badge
138
+ * These must never be dropped from the list index.
139
+ */
140
+ export const SYSTEM_LIST_META_KEYS = ["draft", "publishAt"];
141
+
142
+ /**
143
+ * Computes the set of `meta` keys that a list index must store for a
144
+ * collection. This is the union of the keys the list view actually consumes —
145
+ * `listFields`, `filters`, and sort keys — plus the always-required
146
+ * SYSTEM_LIST_META_KEYS. Top-level keys (id/slug/lang/collection/title/file)
147
+ * are excluded because they are stored at the top level of each entry, not
148
+ * under `meta`.
149
+ *
150
+ * Driving the index off config (instead of dumping the whole `meta` object)
151
+ * keeps manifests small and guarantees that every searchable/filterable/
152
+ * sortable field is present — so the admin list never shows a configured
153
+ * column or filter as silently empty.
154
+ *
155
+ * @param {object} [colConfig] A single collection's config object.
156
+ * @returns {string[]} Meta keys to project into the list entry.
157
+ */
158
+ export function listMetaKeys(colConfig) {
159
+ const keys = new Set(SYSTEM_LIST_META_KEYS);
160
+ const addFields = (arr) => {
161
+ for (const f of arr || []) {
162
+ if (f && f.key) keys.add(f.key);
163
+ }
164
+ };
165
+ addFields(colConfig?.listFields);
166
+ addFields(colConfig?.filters);
167
+ addFields(colConfig?.sort);
168
+ if (colConfig?.defaultSort?.key) keys.add(colConfig.defaultSort.key);
169
+ for (const k of TOP_LEVEL_KEYS) keys.delete(k);
170
+ return [...keys];
171
+ }
172
+
173
+ /**
174
+ * Builds the list-index record for a single page entry. Top-level
175
+ * id/slug/lang/collection/title/file are always stored; `meta` is projected
176
+ * down to only the keys returned by {@link listMetaKeys} for the collection.
177
+ *
178
+ * This is the single source of truth for list-entry shape, shared by every
179
+ * code path that produces one (fs listing, GitHub index read/write/bootstrap,
180
+ * and the `natilon-cms build-index` CLI) so they cannot diverge.
181
+ *
182
+ * Relation fields (a metaField with a `collection` target, e.g. a `combobox`)
183
+ * store a slug in the source data. When a `lookups` table for the target
184
+ * collection is supplied, the stored value is resolved to the referenced
185
+ * entry's display name so the admin list shows names instead of raw slugs.
186
+ * Resolution is best-effort: an unknown slug, or a missing lookup table, keeps
187
+ * the original slug. Producers that cannot cheaply load other collections
188
+ * (e.g. the GitHub edge write-path) simply omit `lookups` and store slugs.
189
+ *
190
+ * @param {object} [colConfig] The collection's config (for listFields etc.).
191
+ * @param {string} collection Collection name (fallback for entry.collection).
192
+ * @param {string} file Source file name.
193
+ * @param {object} data Full page object (has id/slug/lang/meta/...).
194
+ * @param {Object<string, Record<string,string>|Map<string,string>>} [lookups]
195
+ * Per-collection slug→displayName tables, keyed by target collection name.
196
+ * @returns {object} The projected list entry.
197
+ */
198
+ export function buildListEntry(colConfig, collection, file, data, lookups = {}) {
199
+ const fullMeta = data.meta || {};
200
+ const meta = {};
201
+ for (const key of listMetaKeys(colConfig)) {
202
+ if (fullMeta[key] !== undefined) meta[key] = fullMeta[key];
203
+ }
204
+ resolveRelationLabels(meta, colConfig, lookups);
205
+ return {
206
+ id: data.id,
207
+ slug: data.slug,
208
+ lang: data.lang,
209
+ collection: data.collection ?? collection,
210
+ title: fullMeta.title || fullMeta.name || file,
211
+ file,
212
+ meta,
213
+ };
214
+ }
215
+
216
+ /**
217
+ * Maps each relation field key of a collection to its target collection and
218
+ * cardinality. A metaField is treated as a relation when it declares a
219
+ * `collection` target (e.g. `type: "combobox", collection: "authors"`).
220
+ *
221
+ * @param {object} [colConfig]
222
+ * @returns {Record<string, { collection: string, multiple: boolean }>}
223
+ */
224
+ export function relationFields(colConfig) {
225
+ const map = {};
226
+ for (const f of colConfig?.metaFields || []) {
227
+ if (f && f.key && typeof f.collection === "string") {
228
+ map[f.key] = { collection: f.collection, multiple: !!f.multiple };
229
+ }
230
+ }
231
+ return map;
232
+ }
233
+
234
+ /**
235
+ * In-place resolution of relation slugs in a projected `meta` object to the
236
+ * referenced entries' display names, using the supplied `lookups` tables.
237
+ * Handles both single values and arrays (multiple-select). Best-effort: leaves
238
+ * the slug untouched when no resolution is available.
239
+ *
240
+ * @param {object} meta The projected meta object (mutated).
241
+ * @param {object} [colConfig]
242
+ * @param {object} [lookups] Per-collection slug→displayName tables.
243
+ */
244
+ export function resolveRelationLabels(meta, colConfig, lookups = {}) {
245
+ const relations = relationFields(colConfig);
246
+ for (const key of Object.keys(meta)) {
247
+ const rel = relations[key];
248
+ if (!rel) continue;
249
+ const table = lookups[rel.collection];
250
+ if (!table) continue;
251
+ const get = (slug) => (table instanceof Map ? table.get(slug) : table[slug]);
252
+ const resolveOne = (slug) => {
253
+ const name = get(slug);
254
+ return name != null && name !== "" ? name : slug;
255
+ };
256
+ const value = meta[key];
257
+ meta[key] = Array.isArray(value) ? value.map(resolveOne) : resolveOne(value);
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Applies pagination, search, and filtering to a pre-sorted pages array.
263
+ * Does not mutate the input array.
264
+ *
265
+ * @param {object[]} pages Pre-sorted array of page objects.
266
+ * @param {object} opts
267
+ * @param {number} [opts.page=1] Current page (1-based).
268
+ * @param {number|"all"} [opts.perPage=20] Items per page, or "all" / 0 for no pagination.
269
+ * @param {string} [opts.search=""] Substring search term.
270
+ * @param {string[]} [opts.searchFields=[]] Keys to search across.
271
+ * @param {string[]} [opts.filterKeys=[]] Keys for which to compute facet values.
272
+ * @param {object} [opts.filters={}] Active filters: { key: exactValue }.
273
+ * @returns {{ items: object[], total: number, facets: Record<string, string[]> }}
274
+ */
275
+ export function queryPages(pages, {
276
+ page = 1,
277
+ perPage = 20,
278
+ search = "",
279
+ searchFields = [],
280
+ filterKeys = [],
281
+ filters = {},
282
+ } = {}) {
283
+ // 1. Facets — computed over the full pages array
284
+ /** @type {Record<string, string[]>} */
285
+ const facets = {};
286
+ for (const key of filterKeys) {
287
+ const seen = new Set();
288
+ for (const p of pages) {
289
+ const v = String(pageFieldValue(p, key));
290
+ if (v !== "") seen.add(v);
291
+ }
292
+ facets[key] = [...seen].sort();
293
+ }
294
+
295
+ // 2. Filter
296
+ const activeFilters = Object.entries(filters).filter(([, v]) => v);
297
+ const filtered = activeFilters.length === 0
298
+ ? pages
299
+ : pages.filter((p) =>
300
+ activeFilters.every(([k, v]) => String(pageFieldValue(p, k)) === v)
301
+ );
302
+
303
+ // 3. Search
304
+ const term = search ? search.toLowerCase() : "";
305
+ const searched = term
306
+ ? filtered.filter((p) =>
307
+ searchFields.some((k) =>
308
+ String(pageFieldValue(p, k)).toLowerCase().includes(term)
309
+ )
310
+ )
311
+ : filtered;
312
+
313
+ // 4. Total
314
+ const total = searched.length;
315
+
316
+ // 5. Paginate
317
+ const perPageNum = perPage === "all" ? 0 : Number(perPage);
318
+ const items =
319
+ perPage === "all" || perPageNum === 0
320
+ ? searched.slice()
321
+ : searched.slice((page - 1) * perPageNum, page * perPageNum);
322
+
323
+ return { items, total, facets };
324
+ }
325
+
116
326
  /**
117
327
  * Produces a commit message string.
118
328
  *
@@ -1,7 +1,7 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
3
  import { execSync } from "child_process";
4
- import { sanitize, safeFileName, sortPages, buildDuplicateData, listScheduledDue } from "./_shared.mjs";
4
+ import { sanitize, safeFileName, sortPages, buildDuplicateData, listScheduledDue, buildListEntry, relationFields } from "./_shared.mjs";
5
5
 
6
6
  const HISTORY_KEEP = 50; // max revisions kept per file
7
7
 
@@ -24,6 +24,7 @@ export function createFsJsonContent({
24
24
  publishBranch,
25
25
  publishPaths,
26
26
  commitMessage,
27
+ collections = {},
27
28
  }) {
28
29
  const PAGES_DIR = path.join(rootDir, pagesDir);
29
30
  const HISTORY_DIR = path.join(rootDir, ".cms-history");
@@ -72,18 +73,32 @@ export function createFsJsonContent({
72
73
  async listPages(collection, sortConfig = null) {
73
74
  const dir = path.join(PAGES_DIR, sanitize(collection));
74
75
  if (!fs.existsSync(dir)) return null;
76
+
77
+ // Build slug→displayName lookups for collections referenced by this
78
+ // collection's relation fields, so list columns/filters show names
79
+ // instead of raw slugs.
80
+ const lookups = {};
81
+ for (const { collection: target } of Object.values(relationFields(collections[collection]))) {
82
+ if (lookups[target]) continue;
83
+ const targetDir = path.join(PAGES_DIR, sanitize(target));
84
+ if (!fs.existsSync(targetDir)) continue;
85
+ const table = {};
86
+ for (const f of fs.readdirSync(targetDir)) {
87
+ if (!f.endsWith(".json") || f === "_index.json") continue;
88
+ try {
89
+ const d = JSON.parse(fs.readFileSync(path.join(targetDir, f), "utf-8"));
90
+ if (d.slug) table[d.slug] = d.meta?.title || d.meta?.name || d.slug;
91
+ } catch {
92
+ // Skip unreadable entries
93
+ }
94
+ }
95
+ lookups[target] = table;
96
+ }
97
+
75
98
  const files = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
76
99
  const pages = files.map((file) => {
77
100
  const data = JSON.parse(fs.readFileSync(path.join(dir, file), "utf-8"));
78
- return {
79
- id: data.id,
80
- slug: data.slug,
81
- lang: data.lang,
82
- collection: data.collection,
83
- title: data.meta?.title || data.meta?.name || file,
84
- file,
85
- meta: data.meta || {},
86
- };
101
+ return buildListEntry(collections[collection], collection, file, data, lookups);
87
102
  });
88
103
  return sortPages(pages, sortConfig);
89
104
  },
@@ -1,5 +1,5 @@
1
1
  import { createGitHubApi } from "./github-api.mjs";
2
- import { sanitize, safeFileName, sortPages, buildDuplicateData, listScheduledDue, commitMsg } from "./_shared.mjs";
2
+ import { sanitize, safeFileName, sortPages, buildDuplicateData, listScheduledDue, commitMsg, buildListEntry } from "./_shared.mjs";
3
3
 
4
4
  /**
5
5
  * GitHub Contents API adapter — serverless content backend.
@@ -7,6 +7,12 @@ import { sanitize, safeFileName, sortPages, buildDuplicateData, listScheduledDue
7
7
  * Every writePage/createPage/deletePage is an immediate commit to GitHub.
8
8
  * No git binary required. Safe to run on Vercel, Cloudflare Workers, etc.
9
9
  *
10
+ * Listing strategy: each collection keeps a `_index.json` manifest that
11
+ * contains only the listing metadata (id, slug, lang, title, file, meta).
12
+ * listPages fetches this single file — 1 subrequest regardless of size.
13
+ * On first call (no index yet) it bootstraps via GraphQL and persists the
14
+ * index. All write operations keep the index in sync.
15
+ *
10
16
  * Config (cms.config.mjs):
11
17
  * content: {
12
18
  * provider: "github",
@@ -16,11 +22,14 @@ import { sanitize, safeFileName, sortPages, buildDuplicateData, listScheduledDue
16
22
  * branch: "main",
17
23
  * pagesDir: "src/pages-data",
18
24
  * commitMessage: (ts) => `Content updated ${ts}`,
25
+ * list: {
26
+ * strategy: "index", // "index" (default) — only built-in strategy
27
+ * rebuild: "build", // "build" (default) | "lazy"
28
+ * indexFile: "_index.json", // manifest filename
29
+ * resolve: undefined, // optional async (collection, { sortConfig }) => entries[]|null
30
+ * },
19
31
  * }
20
32
  *
21
- * History is backed by GitHub's commit history for each file.
22
- * restoreHistory treats the `ts` field as a commit SHA.
23
- *
24
33
  * @param {Object} opts
25
34
  * @param {string} opts.token
26
35
  * @param {string} opts.owner
@@ -28,9 +37,17 @@ import { sanitize, safeFileName, sortPages, buildDuplicateData, listScheduledDue
28
37
  * @param {string} opts.branch
29
38
  * @param {string} opts.pagesDir
30
39
  * @param {(ts: string) => string} opts.commitMessage
40
+ * @param {Object} [opts.list] Listing strategy config (see above)
31
41
  * @returns {import('./types.mjs').ContentAdapter}
32
42
  */
33
- export function createGitHubContent({ token, owner, repo, branch, pagesDir, commitMessage }) {
43
+ export function createGitHubContent({ token, owner, repo, branch, pagesDir, commitMessage, list = {}, collections = {} }) {
44
+ const listConfig = {
45
+ strategy: "index",
46
+ rebuild: "build",
47
+ indexFile: "_index.json",
48
+ resolve: undefined,
49
+ ...list,
50
+ };
34
51
  const { apiGet, apiPut, apiDelete, apiPost, apiPatch, graphql } = createGitHubApi({ token, owner, repo });
35
52
  // SHA cache: avoid extra GET before every PUT. Invalidated on write.
36
53
  const shaCache = new Map();
@@ -39,6 +56,10 @@ export function createGitHubContent({ token, owner, repo, branch, pagesDir, comm
39
56
  return `${pagesDir}/${collection}/${file}`;
40
57
  }
41
58
 
59
+ function indexFilePath(collection) {
60
+ return `${pagesDir}/${collection}/${listConfig.indexFile}`;
61
+ }
62
+
42
63
  async function getFileSha(path) {
43
64
  if (shaCache.has(path)) return shaCache.get(path);
44
65
  const data = await apiGet(`/contents/${path}?ref=${branch}`);
@@ -56,6 +77,76 @@ export function createGitHubContent({ token, owner, repo, branch, pagesDir, comm
56
77
  return Buffer.from(JSON.stringify(obj, null, 2), "utf-8").toString("base64");
57
78
  }
58
79
 
80
+ /** Build the listing record stored in _index.json for a single entry. */
81
+ function buildIndexEntry(collection, file, data) {
82
+ return buildListEntry(collections[collection], collection, file, data);
83
+ }
84
+
85
+ /** Read _index.json for a collection; returns { entries: [] } if absent. */
86
+ async function readIndex(collection) {
87
+ try {
88
+ const path = indexFilePath(collection);
89
+ const data = await apiGet(`/contents/${path}?ref=${branch}`);
90
+ if (!data || Array.isArray(data)) return { entries: [] };
91
+ shaCache.set(path, data.sha);
92
+ return decodeContent(data);
93
+ } catch {
94
+ return { entries: [] };
95
+ }
96
+ }
97
+
98
+ /** Persist an updated index object to _index.json (single commit). */
99
+ async function writeIndex(collection, index) {
100
+ const path = indexFilePath(collection);
101
+ const sha = shaCache.get(path) ?? await getFileSha(path);
102
+ const result = await apiPut(`/contents/${path}`, {
103
+ message: commitMsg(commitMessage),
104
+ content: encodeContent(index),
105
+ branch,
106
+ ...(sha ? { sha } : {}),
107
+ });
108
+ shaCache.set(path, result.content?.sha);
109
+ }
110
+
111
+ /** Add or replace one entry in _index.json, then persist. */
112
+ async function upsertIndexEntry(collection, file, data) {
113
+ const index = await readIndex(collection);
114
+ const entry = buildIndexEntry(collection, file, data);
115
+ const pos = index.entries.findIndex((e) => e.file === file);
116
+ if (pos >= 0) index.entries[pos] = entry;
117
+ else index.entries.push(entry);
118
+ await writeIndex(collection, index);
119
+ }
120
+
121
+ /** Remove one entry from _index.json, then persist. */
122
+ async function removeIndexEntry(collection, file) {
123
+ const index = await readIndex(collection);
124
+ index.entries = index.entries.filter((e) => e.file !== file);
125
+ await writeIndex(collection, index);
126
+ }
127
+
128
+ // GraphQL query for lazy bootstrap: fetch all blob text in one request.
129
+ const BOOTSTRAP_QUERY = `
130
+ query($owner: String!, $repo: String!, $expr: String!) {
131
+ repository(owner: $owner, name: $repo) {
132
+ object(expression: $expr) {
133
+ ... on Tree {
134
+ entries {
135
+ name
136
+ oid
137
+ type
138
+ object {
139
+ ... on Blob {
140
+ text
141
+ }
142
+ }
143
+ }
144
+ }
145
+ }
146
+ }
147
+ }
148
+ `;
149
+
59
150
  return {
60
151
  async listCollections() {
61
152
  const items = await apiGet(`/contents/${pagesDir}?ref=${branch}`);
@@ -64,114 +155,77 @@ export function createGitHubContent({ token, owner, repo, branch, pagesDir, comm
64
155
  return Promise.all(
65
156
  dirs.map(async (d) => {
66
157
  const files = await apiGet(`/contents/${pagesDir}/${d.name}?ref=${branch}`);
67
- const count = Array.isArray(files) ? files.filter((f) => f.name.endsWith(".json")).length : 0;
158
+ const count = Array.isArray(files)
159
+ ? files.filter((f) => f.name.endsWith(".json") && f.name !== "_index.json").length
160
+ : 0;
68
161
  return { name: d.name, count };
69
162
  }),
70
163
  );
71
164
  },
72
165
 
73
- async _listPagesRest(collection, sortConfig) {
74
- const items = await apiGet(`/contents/${pagesDir}/${collection}?ref=${branch}`);
75
- if (!Array.isArray(items)) return null;
76
- const jsonFiles = items.filter((i) => i.type === "file" && i.name.endsWith(".json"));
77
-
78
- const pages = await Promise.all(
79
- jsonFiles.map(async (item) => {
80
- shaCache.set(item.path, item.sha);
81
- const r = await fetch(item.download_url, {
82
- headers: {
83
- Authorization: `Bearer ${token}`,
84
- "User-Agent": "natilon-cms",
85
- },
86
- });
87
- if (!r.ok) return null;
88
- const data = await r.json();
89
- return {
90
- id: data.id,
91
- slug: data.slug,
92
- lang: data.lang,
93
- collection: data.collection,
94
- title: data.meta?.title || data.meta?.name || item.name,
95
- file: item.name,
96
- meta: data.meta || {},
97
- };
98
- }),
99
- );
100
-
101
- const valid = pages.filter(Boolean);
102
- return sortPages(valid, sortConfig);
103
- },
104
-
105
166
  async listPages(collection, sortConfig = null) {
106
- // Primary path: single GraphQL request fetches all blob contents at once.
107
- // Falls back to the REST N+1 path if GraphQL throws.
108
167
  const safeCollection = sanitize(collection);
109
- const expression = `${branch}:${pagesDir}/${safeCollection}`;
110
- const QUERY = `
111
- query($owner: String!, $repo: String!, $expr: String!) {
112
- repository(owner: $owner, name: $repo) {
113
- object(expression: $expr) {
114
- ... on Tree {
115
- entries {
116
- name
117
- oid
118
- type
119
- object {
120
- ... on Blob {
121
- text
122
- }
123
- }
124
- }
125
- }
126
- }
127
- }
128
- }
129
- `;
130
168
 
131
- let useRest = false;
132
- let gqlData = null;
169
+ // 1. Custom resolver takes full control — nothing else runs.
170
+ if (typeof listConfig.resolve === "function") {
171
+ return await listConfig.resolve(collection, { sortConfig });
172
+ }
173
+
174
+ // 2. strategy "index": try the pre-built manifest first (1 subrequest).
175
+ const idxPath = indexFilePath(safeCollection);
133
176
  try {
134
- gqlData = await graphql(QUERY, { owner, repo, expr: expression });
177
+ const data = await apiGet(`/contents/${idxPath}?ref=${branch}`);
178
+ if (data && !Array.isArray(data)) {
179
+ shaCache.set(idxPath, data.sha);
180
+ const index = decodeContent(data);
181
+ if (Array.isArray(index.entries)) {
182
+ return sortPages(index.entries, sortConfig);
183
+ }
184
+ }
135
185
  } catch {
136
- useRest = true;
186
+ // Non-404 error reading index — fall through to rebuild logic.
137
187
  }
138
188
 
139
- if (!useRest) {
189
+ // Index absent: behaviour depends on rebuild strategy.
190
+ if (listConfig.rebuild === "lazy") {
191
+ // Bootstrap: one GraphQL call fetching all blob text, then persist.
192
+ // On GraphQL failure, throw — never fall back to REST N+1.
193
+ const expression = `${branch}:${pagesDir}/${safeCollection}`;
194
+ const gqlData = await graphql(BOOTSTRAP_QUERY, { owner, repo, expr: expression });
195
+
140
196
  const treeObj = gqlData?.repository?.object;
141
- // collection directory doesn't exist
142
- if (treeObj === null || treeObj === undefined) {
143
- // If GraphQL returned data but object is null, collection is missing
144
- if (gqlData?.repository !== undefined) return null;
145
- // Otherwise fall back
146
- useRest = true;
147
- } else {
148
- const entries = treeObj.entries || [];
149
- const pages = [];
150
- for (const entry of entries) {
151
- if (entry.type !== "blob" || !entry.name.endsWith(".json")) continue;
152
- const path = `${pagesDir}/${safeCollection}/${entry.name}`;
153
- shaCache.set(path, entry.oid);
154
- let data;
155
- try {
156
- data = JSON.parse(entry.object.text);
157
- } catch {
158
- continue;
159
- }
160
- pages.push({
161
- id: data.id,
162
- slug: data.slug,
163
- lang: data.lang,
164
- collection: data.collection,
165
- title: data.meta?.title || data.meta?.name || entry.name,
166
- file: entry.name,
167
- meta: data.meta || {},
168
- });
169
- }
170
- return sortPages(pages, sortConfig);
197
+ if (!treeObj) return null;
198
+
199
+ const pages = [];
200
+ for (const entry of treeObj.entries || []) {
201
+ if (entry.type !== "blob" || !entry.name.endsWith(".json") || entry.name === listConfig.indexFile) continue;
202
+ shaCache.set(`${pagesDir}/${safeCollection}/${entry.name}`, entry.oid);
203
+ let data;
204
+ try { data = JSON.parse(entry.object.text); } catch { continue; }
205
+ pages.push(buildIndexEntry(collection, entry.name, data));
206
+ }
207
+
208
+ // Persist for all future calls.
209
+ try {
210
+ await writeIndex(safeCollection, { entries: pages });
211
+ } catch (err) {
212
+ console.warn(`[listPages] Could not write ${listConfig.indexFile}:`, err?.message);
171
213
  }
214
+
215
+ return sortPages(pages, sortConfig);
172
216
  }
173
217
 
174
- return this._listPagesRest(collection, sortConfig);
218
+ // rebuild === "build" (default): never fetch all bodies at runtime.
219
+ // Check whether the collection directory actually exists.
220
+ const items = await apiGet(`/contents/${pagesDir}/${safeCollection}?ref=${branch}`);
221
+ if (!Array.isArray(items)) return null; // 404 or non-dir → unknown collection
222
+
223
+ // Collection exists but index hasn't been built yet.
224
+ console.warn(
225
+ `[listPages] No ${listConfig.indexFile} found for collection "${safeCollection}". ` +
226
+ `Run \`natilon-cms build-index\` to generate it.`,
227
+ );
228
+ return [];
175
229
  },
176
230
 
177
231
  async readPage(collection, file) {
@@ -193,6 +247,7 @@ export function createGitHubContent({ token, owner, repo, branch, pagesDir, comm
193
247
  ...(sha ? { sha } : {}),
194
248
  });
195
249
  shaCache.set(path, result.content?.sha);
250
+ await upsertIndexEntry(collection, file, data);
196
251
  },
197
252
 
198
253
  async createPage(collection, data) {
@@ -205,6 +260,7 @@ export function createGitHubContent({ token, owner, repo, branch, pagesDir, comm
205
260
  branch,
206
261
  });
207
262
  shaCache.set(path, result.content?.sha);
263
+ await upsertIndexEntry(collection, fileName, data);
208
264
  return { file: fileName };
209
265
  },
210
266
 
@@ -215,6 +271,7 @@ export function createGitHubContent({ token, owner, repo, branch, pagesDir, comm
215
271
  if (!sha) return false;
216
272
  await apiDelete(`/contents/${path}`, { message: commitMsg(commitMessage), sha, branch });
217
273
  shaCache.delete(path);
274
+ await removeIndexEntry(collection, file);
218
275
  return true;
219
276
  },
220
277
 
@@ -271,7 +328,7 @@ export function createGitHubContent({ token, owner, repo, branch, pagesDir, comm
271
328
  const baseCommit = await apiGet(`/git/commits/${baseCommitSha}`);
272
329
  const baseTreeSha = baseCommit.tree.sha;
273
330
 
274
- // 3. Create one blob per file
331
+ // 3. Create one blob per content file
275
332
  const treeItems = await Promise.all(
276
333
  items.map(async ({ collection, file, data }) => {
277
334
  const filePath = contentPath(sanitize(collection), sanitize(file));
@@ -281,13 +338,31 @@ export function createGitHubContent({ token, owner, repo, branch, pagesDir, comm
281
338
  }),
282
339
  );
283
340
 
284
- // 4. Create tree
285
- const newTree = await apiPost(`/git/trees`, {
286
- base_tree: baseTreeSha,
287
- tree: treeItems,
288
- });
341
+ // 4. Compute updated _index.json for each affected collection and add
342
+ // them to the same tree so the index stays in sync atomically.
343
+ const byCollection = {};
344
+ for (const item of items) {
345
+ const col = sanitize(item.collection ?? item.data?.collection ?? "");
346
+ if (!col) continue;
347
+ (byCollection[col] ??= []).push(item);
348
+ }
349
+ for (const [col, colItems] of Object.entries(byCollection)) {
350
+ const index = await readIndex(col);
351
+ for (const { file, data } of colItems) {
352
+ const entry = buildIndexEntry(col, file, data);
353
+ const pos = index.entries.findIndex((e) => e.file === file);
354
+ if (pos >= 0) index.entries[pos] = entry;
355
+ else index.entries.push(entry);
356
+ }
357
+ const idxContent = Buffer.from(JSON.stringify(index, null, 2), "utf-8").toString("base64");
358
+ const idxBlob = await apiPost(`/git/blobs`, { content: idxContent, encoding: "base64" });
359
+ treeItems.push({ path: indexFilePath(col), mode: "100644", type: "blob", sha: idxBlob.sha });
360
+ }
361
+
362
+ // 5. Create tree
363
+ const newTree = await apiPost(`/git/trees`, { base_tree: baseTreeSha, tree: treeItems });
289
364
 
290
- // 5. Create commit
365
+ // 6. Create commit
291
366
  const msg = message || commitMsg(commitMessage, "Batch content update");
292
367
  const newCommit = await apiPost(`/git/commits`, {
293
368
  message: msg,
@@ -295,13 +370,16 @@ export function createGitHubContent({ token, owner, repo, branch, pagesDir, comm
295
370
  parents: [baseCommitSha],
296
371
  });
297
372
 
298
- // 6. Update ref
373
+ // 7. Update ref
299
374
  await apiPatch(`/git/refs/heads/${branch}`, { sha: newCommit.sha });
300
375
 
301
- // 7. Invalidate shaCache for all written paths
376
+ // 8. Invalidate shaCache for all written paths
302
377
  for (const { collection, file } of items) {
303
378
  shaCache.delete(contentPath(sanitize(collection), sanitize(file)));
304
379
  }
380
+ for (const col of Object.keys(byCollection)) {
381
+ shaCache.delete(indexFilePath(col));
382
+ }
305
383
 
306
384
  return { ok: true, sha: newCommit.sha, commitCount: 1 };
307
385
  },
@@ -19,3 +19,16 @@ export function resolveAdminUiDir() {
19
19
  const candidate = path.resolve(__dirname, "../../admin-ui/dist");
20
20
  return fs.existsSync(path.join(candidate, "index.html")) ? candidate : null;
21
21
  }
22
+
23
+ /**
24
+ * Resolve the admin-ui source root (the Vite project root) for dev/HMR mode.
25
+ * Only available when the admin-ui source is present alongside cms-server
26
+ * (monorepo or linked checkout). Returns null when only a published dist exists.
27
+ */
28
+ export function resolveAdminUiSourceDir() {
29
+ const candidate = path.resolve(__dirname, "../../admin-ui");
30
+ return fs.existsSync(path.join(candidate, "vite.config.js")) &&
31
+ fs.existsSync(path.join(candidate, "index.html"))
32
+ ? candidate
33
+ : null;
34
+ }
@@ -20,7 +20,14 @@ export function defaultPublicConfig(config, overrides = {}) {
20
20
  media: config.media,
21
21
  collections: config.collections,
22
22
  blocks: config.blocks,
23
- content: { provider: config.content?.provider || "fs" },
23
+ content: {
24
+ provider: config.content?.provider || "fs",
25
+ list: {
26
+ strategy: config.content?.list?.strategy || "index",
27
+ rebuild: config.content?.list?.rebuild || "build",
28
+ indexFile: config.content?.list?.indexFile || "_index.json",
29
+ },
30
+ },
24
31
  auth: { provider: config.auth?.provider || "basic" },
25
32
  };
26
33
  return Object.assign(cfg, overrides);
package/src/routes.mjs CHANGED
@@ -25,6 +25,21 @@
25
25
 
26
26
 
27
27
  import { acquireLock, renewLock, releaseLock, getLock } from "./locks.mjs";
28
+ import { queryPages } from "./adapters/_shared.mjs";
29
+ import { createRequire } from "module";
30
+
31
+ // Resolve the package version in a way that works in BOTH runtimes:
32
+ // - Node ESM: createRequire(import.meta.url) reads package.json.
33
+ // - Cloudflare Workers (bundled): import.meta.url is undefined, so this
34
+ // throws; we swallow it and fall back rather than crash the Worker.
35
+ // (A static `import ... with { type: "json" }` works in Node but breaks
36
+ // wrangler's esbuild; a bare JSON import breaks Node — hence this approach.)
37
+ let SERVER_VERSION = "unknown";
38
+ try {
39
+ SERVER_VERSION = createRequire(import.meta.url)("../package.json").version;
40
+ } catch {
41
+ // bundled/edge runtime — version stays "unknown"
42
+ }
28
43
 
29
44
  function ok(json) {
30
45
  return { json };
@@ -39,6 +54,14 @@ function badRequest(error) {
39
54
  }
40
55
 
41
56
  export const apiRoutes = [
57
+ // ── About ──────────────────────────────────────────────────────────────
58
+ {
59
+ method: "GET",
60
+ path: "/api/about",
61
+ auth: "public",
62
+ handler: () => ok({ serverVersion: SERVER_VERSION }),
63
+ },
64
+
42
65
  // ── Config ─────────────────────────────────────────────────────────────
43
66
  {
44
67
  method: "GET",
@@ -66,11 +89,33 @@ export const apiRoutes = [
66
89
  method: "GET",
67
90
  path: "/api/collections/:collection",
68
91
  auth: "any",
69
- handler: async ({ adapters, config, params }) => {
70
- const sortConfig = config.collections?.[params.collection]?.sort ?? null;
71
- const pages = await adapters.content.listPages(params.collection, sortConfig);
92
+ handler: async ({ adapters, config, params, query }) => {
93
+ const col = params.collection;
94
+ // ?sortField and ?sortDir override the collection's static sort config
95
+ const sortConfig = query.sortField
96
+ ? { field: query.sortField, direction: query.sortDir === "desc" ? "desc" : "asc" }
97
+ : (config.collections?.[col]?.sort ?? null);
98
+ const pages = await adapters.content.listPages(col, sortConfig);
72
99
  if (pages === null) return notFound();
73
- return ok(pages);
100
+
101
+ // Parse pagination params
102
+ const rawPerPage = query.perPage ?? query.per_page;
103
+ const perPage = rawPerPage === "all" ? "all" : Math.max(1, parseInt(rawPerPage, 10) || 20);
104
+ const page = Math.max(1, parseInt(query.page, 10) || 1);
105
+ const search = query.search ?? "";
106
+
107
+ // Build active filters from f_* query keys
108
+ const filters = {};
109
+ for (const [k, v] of Object.entries(query)) {
110
+ if (k.startsWith("f_") && v) filters[k.slice(2)] = v;
111
+ }
112
+
113
+ const colConfig = config.collections?.[col];
114
+ const searchFields = (colConfig?.listFields ?? [{ key: "title" }, { key: "lang" }, { key: "slug" }]).map((f) => f.key);
115
+ const filterKeys = (colConfig?.filters ?? []).map((f) => f.key);
116
+
117
+ const { items, total, facets } = queryPages(pages, { page, perPage, search, searchFields, filterKeys, filters });
118
+ return ok({ items, total, page, perPage, facets });
74
119
  },
75
120
  },
76
121
 
package/src/server.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import express from "express";
2
2
  import fs from "fs";
3
3
  import path from "path";
4
- import { resolveAdminUiDir } from "./admin-ui-path.mjs";
4
+ import { resolveAdminUiDir, resolveAdminUiSourceDir } from "./admin-ui-path.mjs";
5
5
  import { defaultPublicConfig } from "./default-public-config.mjs";
6
6
  import { allRoutes } from "./routes.mjs";
7
7
  import { mountRoutes, buildPublicAllowlist } from "./express-shim.mjs";
@@ -37,6 +37,8 @@ export function createCmsServer({ config, rootDir, publicConfig: publicConfigFn,
37
37
  branch: process.env.STAGING_BRANCH || config.content.branch || "main",
38
38
  pagesDir: config.content.pagesDir,
39
39
  commitMessage: config.content.commitMessage,
40
+ list: config.content.list,
41
+ collections: config.collections,
40
42
  })
41
43
  : createFsJsonContent({
42
44
  rootDir,
@@ -44,6 +46,7 @@ export function createCmsServer({ config, rootDir, publicConfig: publicConfigFn,
44
46
  publishBranch: process.env.STAGING_BRANCH || config.content.publishBranch,
45
47
  publishPaths: config.content.publishPaths,
46
48
  commitMessage: config.content.commitMessage,
49
+ collections: config.collections,
47
50
  });
48
51
 
49
52
  const localMedia = createLocalAssetsMedia({
@@ -266,6 +269,11 @@ export async function startCmsServer(opts) {
266
269
  : null;
267
270
 
268
271
  const adminUi = opts.adminUi ?? (() => {
272
+ if (opts.dev) {
273
+ const root = resolveAdminUiSourceDir();
274
+ if (root) return { mode: "vite-dev", root, base: "/admin/", previewThemeCss };
275
+ console.warn("admin-ui source not found — dev mode requires a linked/monorepo admin-ui. Falling back to static dist.");
276
+ }
269
277
  const dir = resolveAdminUiDir();
270
278
  if (!dir) {
271
279
  console.warn("admin-ui dist not found — run `npm run build:admin-ui` first, or pass adminUi option explicitly.");