@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 +74 -0
- package/bin/natilon-cms.mjs +140 -15
- package/package.json +1 -1
- package/src/adapters/_shared.mjs +212 -2
- package/src/adapters/fs-json-content.mjs +25 -10
- package/src/adapters/github-content.mjs +184 -106
- package/src/admin-ui-path.mjs +13 -0
- package/src/default-public-config.mjs +8 -1
- package/src/routes.mjs +49 -4
- package/src/server.mjs +9 -1
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.
|
package/bin/natilon-cms.mjs
CHANGED
|
@@ -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
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
console.error(`[natilon-cms]
|
|
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
package/src/adapters/_shared.mjs
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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
|
-
|
|
186
|
+
// Non-404 error reading index — fall through to rebuild logic.
|
|
137
187
|
}
|
|
138
188
|
|
|
139
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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.
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
373
|
+
// 7. Update ref
|
|
299
374
|
await apiPatch(`/git/refs/heads/${branch}`, { sha: newCommit.sha });
|
|
300
375
|
|
|
301
|
-
//
|
|
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
|
},
|
package/src/admin-ui-path.mjs
CHANGED
|
@@ -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:
|
|
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
|
|
71
|
-
|
|
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
|
-
|
|
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.");
|