@natilon/cms-server 0.9.0 → 0.10.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
@@ -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.
@@ -19,10 +19,15 @@
19
19
 
20
20
  import { pathToFileURL } from "url";
21
21
  import path from "path";
22
+ import fs from "fs";
22
23
  import { startCmsServer } from "../src/index.mjs";
24
+ import { buildListEntry } from "../src/adapters/_shared.mjs";
23
25
 
24
26
  const args = process.argv.slice(2).filter((a) => a !== "start");
25
27
 
28
+ // Detect subcommand (first non-flag positional arg)
29
+ const subcommand = process.argv.slice(2).find((a) => !a.startsWith("-"));
30
+
26
31
  function flag(name) {
27
32
  const i = args.indexOf(name);
28
33
  return i !== -1 ? args[i + 1] : null;
@@ -32,6 +37,25 @@ function boolFlag(name) {
32
37
  return args.includes(name);
33
38
  }
34
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
+
35
59
  const dev = boolFlag("--dev");
36
60
 
37
61
  const cwd = process.cwd();
@@ -39,23 +63,114 @@ const cfgPath = path.resolve(cwd, flag("--config") || "cms.config.mjs");
39
63
  const port = parseInt(flag("--port") || process.env.ADMIN_PORT || "4001", 10);
40
64
  const realm = flag("--realm") || "CMS Admin";
41
65
 
42
- // Load the consumer's config file.
43
- let config, publicConfig;
44
- try {
45
- const mod = await import(pathToFileURL(cfgPath).href);
46
- config = mod.default;
47
- publicConfig = mod.publicConfig; // optional — server auto-derives if absent
48
- if (!config) throw new Error("cms.config.mjs must have a default export");
49
- } catch (err) {
50
- if (err.code === "ERR_MODULE_NOT_FOUND" || err.code === "ERR_LOAD_URL") {
51
- console.error(`[natilon-cms] Cannot find config file: ${cfgPath}`);
52
- console.error(" Create cms.config.mjs in your project root, or use --config <path>.");
53
- } else {
54
- 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);
55
165
  }
56
- process.exit(1);
57
166
  }
58
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
+
59
174
  await startCmsServer({
60
175
  config,
61
176
  publicConfig, // undefined is fine — server uses defaultPublicConfig(config)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@natilon/cms-server",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
4
4
  "description": "Express-based CMS server with pluggable adapters for content, media, auth, and build.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -25,6 +25,18 @@ export function safeFileName(name) {
25
25
  return name;
26
26
  }
27
27
 
28
+ /**
29
+ * True when a filename is a content entry (and not a generated manifest such
30
+ * as `_index.json`). Manifest/index files are list projections, not entries,
31
+ * so every directory-scan that enumerates entries must exclude them.
32
+ *
33
+ * @param {string} file
34
+ * @returns {boolean}
35
+ */
36
+ export function isEntryFile(file) {
37
+ return typeof file === "string" && file.endsWith(".json") && !file.startsWith("_");
38
+ }
39
+
28
40
  /**
29
41
  * Sorts a pages array in-place and returns it.
30
42
  *
@@ -130,6 +142,134 @@ export function pageFieldValue(page, key) {
130
142
  return page.meta?.[key] ?? "";
131
143
  }
132
144
 
145
+ /**
146
+ * Meta keys the server and admin UI always need on a list entry regardless of
147
+ * a collection's `listFields`, because core workflow logic reads them:
148
+ * - `publishAt` — scheduled-publish detection (listScheduledDue) + status badge
149
+ * - `draft` — publish/draft/scheduled status badge
150
+ * These must never be dropped from the list index.
151
+ */
152
+ export const SYSTEM_LIST_META_KEYS = ["draft", "publishAt"];
153
+
154
+ /**
155
+ * Computes the set of `meta` keys that a list index must store for a
156
+ * collection. This is the union of the keys the list view actually consumes —
157
+ * `listFields`, `filters`, and sort keys — plus the always-required
158
+ * SYSTEM_LIST_META_KEYS. Top-level keys (id/slug/lang/collection/title/file)
159
+ * are excluded because they are stored at the top level of each entry, not
160
+ * under `meta`.
161
+ *
162
+ * Driving the index off config (instead of dumping the whole `meta` object)
163
+ * keeps manifests small and guarantees that every searchable/filterable/
164
+ * sortable field is present — so the admin list never shows a configured
165
+ * column or filter as silently empty.
166
+ *
167
+ * @param {object} [colConfig] A single collection's config object.
168
+ * @returns {string[]} Meta keys to project into the list entry.
169
+ */
170
+ export function listMetaKeys(colConfig) {
171
+ const keys = new Set(SYSTEM_LIST_META_KEYS);
172
+ const addFields = (arr) => {
173
+ for (const f of arr || []) {
174
+ if (f && f.key) keys.add(f.key);
175
+ }
176
+ };
177
+ addFields(colConfig?.listFields);
178
+ addFields(colConfig?.filters);
179
+ addFields(colConfig?.sort);
180
+ if (colConfig?.defaultSort?.key) keys.add(colConfig.defaultSort.key);
181
+ for (const k of TOP_LEVEL_KEYS) keys.delete(k);
182
+ return [...keys];
183
+ }
184
+
185
+ /**
186
+ * Builds the list-index record for a single page entry. Top-level
187
+ * id/slug/lang/collection/title/file are always stored; `meta` is projected
188
+ * down to only the keys returned by {@link listMetaKeys} for the collection.
189
+ *
190
+ * This is the single source of truth for list-entry shape, shared by every
191
+ * code path that produces one (fs listing, GitHub index read/write/bootstrap,
192
+ * and the `natilon-cms build-index` CLI) so they cannot diverge.
193
+ *
194
+ * Relation fields (a metaField with a `collection` target, e.g. a `combobox`)
195
+ * store a slug in the source data. When a `lookups` table for the target
196
+ * collection is supplied, the stored value is resolved to the referenced
197
+ * entry's display name so the admin list shows names instead of raw slugs.
198
+ * Resolution is best-effort: an unknown slug, or a missing lookup table, keeps
199
+ * the original slug. Producers that cannot cheaply load other collections
200
+ * (e.g. the GitHub edge write-path) simply omit `lookups` and store slugs.
201
+ *
202
+ * @param {object} [colConfig] The collection's config (for listFields etc.).
203
+ * @param {string} collection Collection name (fallback for entry.collection).
204
+ * @param {string} file Source file name.
205
+ * @param {object} data Full page object (has id/slug/lang/meta/...).
206
+ * @param {Object<string, Record<string,string>|Map<string,string>>} [lookups]
207
+ * Per-collection slug→displayName tables, keyed by target collection name.
208
+ * @returns {object} The projected list entry.
209
+ */
210
+ export function buildListEntry(colConfig, collection, file, data, lookups = {}) {
211
+ const fullMeta = data.meta || {};
212
+ const meta = {};
213
+ for (const key of listMetaKeys(colConfig)) {
214
+ if (fullMeta[key] !== undefined) meta[key] = fullMeta[key];
215
+ }
216
+ resolveRelationLabels(meta, colConfig, lookups);
217
+ return {
218
+ id: data.id,
219
+ slug: data.slug,
220
+ lang: data.lang,
221
+ collection: data.collection ?? collection,
222
+ title: fullMeta.title || fullMeta.name || file,
223
+ file,
224
+ meta,
225
+ };
226
+ }
227
+
228
+ /**
229
+ * Maps each relation field key of a collection to its target collection and
230
+ * cardinality. A metaField is treated as a relation when it declares a
231
+ * `collection` target (e.g. `type: "combobox", collection: "authors"`).
232
+ *
233
+ * @param {object} [colConfig]
234
+ * @returns {Record<string, { collection: string, multiple: boolean }>}
235
+ */
236
+ export function relationFields(colConfig) {
237
+ const map = {};
238
+ for (const f of colConfig?.metaFields || []) {
239
+ if (f && f.key && typeof f.collection === "string") {
240
+ map[f.key] = { collection: f.collection, multiple: !!f.multiple };
241
+ }
242
+ }
243
+ return map;
244
+ }
245
+
246
+ /**
247
+ * In-place resolution of relation slugs in a projected `meta` object to the
248
+ * referenced entries' display names, using the supplied `lookups` tables.
249
+ * Handles both single values and arrays (multiple-select). Best-effort: leaves
250
+ * the slug untouched when no resolution is available.
251
+ *
252
+ * @param {object} meta The projected meta object (mutated).
253
+ * @param {object} [colConfig]
254
+ * @param {object} [lookups] Per-collection slug→displayName tables.
255
+ */
256
+ export function resolveRelationLabels(meta, colConfig, lookups = {}) {
257
+ const relations = relationFields(colConfig);
258
+ for (const key of Object.keys(meta)) {
259
+ const rel = relations[key];
260
+ if (!rel) continue;
261
+ const table = lookups[rel.collection];
262
+ if (!table) continue;
263
+ const get = (slug) => (table instanceof Map ? table.get(slug) : table[slug]);
264
+ const resolveOne = (slug) => {
265
+ const name = get(slug);
266
+ return name != null && name !== "" ? name : slug;
267
+ };
268
+ const value = meta[key];
269
+ meta[key] = Array.isArray(value) ? value.map(resolveOne) : resolveOne(value);
270
+ }
271
+ }
272
+
133
273
  /**
134
274
  * Applies pagination, search, and filtering to a pre-sorted pages array.
135
275
  * Does not mutate the input array.
@@ -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, isEntryFile } 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");
@@ -64,7 +65,7 @@ export function createFsJsonContent({
64
65
  return dirs.map((dir) => {
65
66
  const files = fs
66
67
  .readdirSync(path.join(PAGES_DIR, dir))
67
- .filter((f) => f.endsWith(".json"));
68
+ .filter(isEntryFile);
68
69
  return { name: dir, count: files.length };
69
70
  });
70
71
  },
@@ -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;
75
- const files = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
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 (!isEntryFile(f)) 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
+
98
+ const files = fs.readdirSync(dir).filter(isEntryFile);
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
  },
@@ -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
@@ -28,8 +28,18 @@ import { acquireLock, renewLock, releaseLock, getLock } from "./locks.mjs";
28
28
  import { queryPages } from "./adapters/_shared.mjs";
29
29
  import { createRequire } from "module";
30
30
 
31
- const _require = createRequire(import.meta.url);
32
- const SERVER_VERSION = _require("../package.json").version;
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
+ }
33
43
 
34
44
  function ok(json) {
35
45
  return { json };
package/src/server.mjs CHANGED
@@ -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({