@natilon/cms-server 0.6.0 → 0.9.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/bin/natilon-cms.mjs +11 -1
- package/package.json +1 -1
- package/src/adapters/_shared.mjs +84 -2
- package/src/admin-ui-path.mjs +13 -0
- package/src/routes.mjs +68 -4
- package/src/server.mjs +6 -1
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.
|
|
@@ -25,6 +28,12 @@ function flag(name) {
|
|
|
25
28
|
return i !== -1 ? args[i + 1] : null;
|
|
26
29
|
}
|
|
27
30
|
|
|
31
|
+
function boolFlag(name) {
|
|
32
|
+
return args.includes(name);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const dev = boolFlag("--dev");
|
|
36
|
+
|
|
28
37
|
const cwd = process.cwd();
|
|
29
38
|
const cfgPath = path.resolve(cwd, flag("--config") || "cms.config.mjs");
|
|
30
39
|
const port = parseInt(flag("--port") || process.env.ADMIN_PORT || "4001", 10);
|
|
@@ -53,4 +62,5 @@ await startCmsServer({
|
|
|
53
62
|
rootDir: cwd,
|
|
54
63
|
realm,
|
|
55
64
|
port,
|
|
65
|
+
dev,
|
|
56
66
|
});
|
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,88 @@ 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
|
+
* Applies pagination, search, and filtering to a pre-sorted pages array.
|
|
135
|
+
* Does not mutate the input array.
|
|
136
|
+
*
|
|
137
|
+
* @param {object[]} pages Pre-sorted array of page objects.
|
|
138
|
+
* @param {object} opts
|
|
139
|
+
* @param {number} [opts.page=1] Current page (1-based).
|
|
140
|
+
* @param {number|"all"} [opts.perPage=20] Items per page, or "all" / 0 for no pagination.
|
|
141
|
+
* @param {string} [opts.search=""] Substring search term.
|
|
142
|
+
* @param {string[]} [opts.searchFields=[]] Keys to search across.
|
|
143
|
+
* @param {string[]} [opts.filterKeys=[]] Keys for which to compute facet values.
|
|
144
|
+
* @param {object} [opts.filters={}] Active filters: { key: exactValue }.
|
|
145
|
+
* @returns {{ items: object[], total: number, facets: Record<string, string[]> }}
|
|
146
|
+
*/
|
|
147
|
+
export function queryPages(pages, {
|
|
148
|
+
page = 1,
|
|
149
|
+
perPage = 20,
|
|
150
|
+
search = "",
|
|
151
|
+
searchFields = [],
|
|
152
|
+
filterKeys = [],
|
|
153
|
+
filters = {},
|
|
154
|
+
} = {}) {
|
|
155
|
+
// 1. Facets — computed over the full pages array
|
|
156
|
+
/** @type {Record<string, string[]>} */
|
|
157
|
+
const facets = {};
|
|
158
|
+
for (const key of filterKeys) {
|
|
159
|
+
const seen = new Set();
|
|
160
|
+
for (const p of pages) {
|
|
161
|
+
const v = String(pageFieldValue(p, key));
|
|
162
|
+
if (v !== "") seen.add(v);
|
|
163
|
+
}
|
|
164
|
+
facets[key] = [...seen].sort();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 2. Filter
|
|
168
|
+
const activeFilters = Object.entries(filters).filter(([, v]) => v);
|
|
169
|
+
const filtered = activeFilters.length === 0
|
|
170
|
+
? pages
|
|
171
|
+
: pages.filter((p) =>
|
|
172
|
+
activeFilters.every(([k, v]) => String(pageFieldValue(p, k)) === v)
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
// 3. Search
|
|
176
|
+
const term = search ? search.toLowerCase() : "";
|
|
177
|
+
const searched = term
|
|
178
|
+
? filtered.filter((p) =>
|
|
179
|
+
searchFields.some((k) =>
|
|
180
|
+
String(pageFieldValue(p, k)).toLowerCase().includes(term)
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
: filtered;
|
|
184
|
+
|
|
185
|
+
// 4. Total
|
|
186
|
+
const total = searched.length;
|
|
187
|
+
|
|
188
|
+
// 5. Paginate
|
|
189
|
+
const perPageNum = perPage === "all" ? 0 : Number(perPage);
|
|
190
|
+
const items =
|
|
191
|
+
perPage === "all" || perPageNum === 0
|
|
192
|
+
? searched.slice()
|
|
193
|
+
: searched.slice((page - 1) * perPageNum, page * perPageNum);
|
|
194
|
+
|
|
195
|
+
return { items, total, facets };
|
|
196
|
+
}
|
|
197
|
+
|
|
116
198
|
/**
|
|
117
199
|
* Produces a commit message string.
|
|
118
200
|
*
|
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
|
+
}
|
package/src/routes.mjs
CHANGED
|
@@ -25,6 +25,11 @@
|
|
|
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
|
+
const _require = createRequire(import.meta.url);
|
|
32
|
+
const SERVER_VERSION = _require("../package.json").version;
|
|
28
33
|
|
|
29
34
|
function ok(json) {
|
|
30
35
|
return { json };
|
|
@@ -39,6 +44,14 @@ function badRequest(error) {
|
|
|
39
44
|
}
|
|
40
45
|
|
|
41
46
|
export const apiRoutes = [
|
|
47
|
+
// ── About ──────────────────────────────────────────────────────────────
|
|
48
|
+
{
|
|
49
|
+
method: "GET",
|
|
50
|
+
path: "/api/about",
|
|
51
|
+
auth: "public",
|
|
52
|
+
handler: () => ok({ serverVersion: SERVER_VERSION }),
|
|
53
|
+
},
|
|
54
|
+
|
|
42
55
|
// ── Config ─────────────────────────────────────────────────────────────
|
|
43
56
|
{
|
|
44
57
|
method: "GET",
|
|
@@ -66,11 +79,33 @@ export const apiRoutes = [
|
|
|
66
79
|
method: "GET",
|
|
67
80
|
path: "/api/collections/:collection",
|
|
68
81
|
auth: "any",
|
|
69
|
-
handler: async ({ adapters, config, params }) => {
|
|
70
|
-
const
|
|
71
|
-
|
|
82
|
+
handler: async ({ adapters, config, params, query }) => {
|
|
83
|
+
const col = params.collection;
|
|
84
|
+
// ?sortField and ?sortDir override the collection's static sort config
|
|
85
|
+
const sortConfig = query.sortField
|
|
86
|
+
? { field: query.sortField, direction: query.sortDir === "desc" ? "desc" : "asc" }
|
|
87
|
+
: (config.collections?.[col]?.sort ?? null);
|
|
88
|
+
const pages = await adapters.content.listPages(col, sortConfig);
|
|
72
89
|
if (pages === null) return notFound();
|
|
73
|
-
|
|
90
|
+
|
|
91
|
+
// Parse pagination params
|
|
92
|
+
const rawPerPage = query.perPage ?? query.per_page;
|
|
93
|
+
const perPage = rawPerPage === "all" ? "all" : Math.max(1, parseInt(rawPerPage, 10) || 20);
|
|
94
|
+
const page = Math.max(1, parseInt(query.page, 10) || 1);
|
|
95
|
+
const search = query.search ?? "";
|
|
96
|
+
|
|
97
|
+
// Build active filters from f_* query keys
|
|
98
|
+
const filters = {};
|
|
99
|
+
for (const [k, v] of Object.entries(query)) {
|
|
100
|
+
if (k.startsWith("f_") && v) filters[k.slice(2)] = v;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const colConfig = config.collections?.[col];
|
|
104
|
+
const searchFields = (colConfig?.listFields ?? [{ key: "title" }, { key: "lang" }, { key: "slug" }]).map((f) => f.key);
|
|
105
|
+
const filterKeys = (colConfig?.filters ?? []).map((f) => f.key);
|
|
106
|
+
|
|
107
|
+
const { items, total, facets } = queryPages(pages, { page, perPage, search, searchFields, filterKeys, filters });
|
|
108
|
+
return ok({ items, total, page, perPage, facets });
|
|
74
109
|
},
|
|
75
110
|
},
|
|
76
111
|
|
|
@@ -170,6 +205,35 @@ export const apiRoutes = [
|
|
|
170
205
|
},
|
|
171
206
|
},
|
|
172
207
|
|
|
208
|
+
// ── Preview URL ────────────────────────────────────────────────────────
|
|
209
|
+
// Resolves the preview URL for an entry. Supports both `config.previewUrl`
|
|
210
|
+
// (a function, called server-side with the page data) and the simpler
|
|
211
|
+
// `config.previewUrlPattern` (template string with {collection}/{slug}/{lang}).
|
|
212
|
+
{
|
|
213
|
+
method: "GET",
|
|
214
|
+
path: "/api/preview-url/:collection/:file",
|
|
215
|
+
auth: "any",
|
|
216
|
+
handler: async ({ adapters, params, config }) => {
|
|
217
|
+
const data = await adapters.content.readPage(params.collection, params.file);
|
|
218
|
+
if (!data) return notFound();
|
|
219
|
+
|
|
220
|
+
let url = null;
|
|
221
|
+
if (typeof config.previewUrl === "function") {
|
|
222
|
+
try {
|
|
223
|
+
url = config.previewUrl({ collection: params.collection, data });
|
|
224
|
+
} catch (err) {
|
|
225
|
+
return { status: 500, json: { error: `previewUrl(): ${err.message}` } };
|
|
226
|
+
}
|
|
227
|
+
} else if (config.previewUrlPattern) {
|
|
228
|
+
url = config.previewUrlPattern
|
|
229
|
+
.replace("{collection}", data.collection || params.collection)
|
|
230
|
+
.replace("{slug}", data.slug || "")
|
|
231
|
+
.replace("{lang}", data.lang || "");
|
|
232
|
+
}
|
|
233
|
+
return ok({ url });
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
|
|
173
237
|
// ── History ────────────────────────────────────────────────────────────
|
|
174
238
|
{
|
|
175
239
|
method: "GET",
|
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";
|
|
@@ -266,6 +266,11 @@ export async function startCmsServer(opts) {
|
|
|
266
266
|
: null;
|
|
267
267
|
|
|
268
268
|
const adminUi = opts.adminUi ?? (() => {
|
|
269
|
+
if (opts.dev) {
|
|
270
|
+
const root = resolveAdminUiSourceDir();
|
|
271
|
+
if (root) return { mode: "vite-dev", root, base: "/admin/", previewThemeCss };
|
|
272
|
+
console.warn("admin-ui source not found — dev mode requires a linked/monorepo admin-ui. Falling back to static dist.");
|
|
273
|
+
}
|
|
269
274
|
const dir = resolveAdminUiDir();
|
|
270
275
|
if (!dir) {
|
|
271
276
|
console.warn("admin-ui dist not found — run `npm run build:admin-ui` first, or pass adminUi option explicitly.");
|