@natilon/cms-server 0.8.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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@natilon/cms-server",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Express-based CMS server with pluggable adapters for content, media, auth, and build.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -39,8 +39,8 @@ export function sortPages(pages, sortConfig) {
39
39
  if (sortConfig) {
40
40
  const dir = sortConfig.direction === "desc" ? -1 : 1;
41
41
  pages.sort((a, b) => {
42
- const av = a.meta?.[sortConfig.field];
43
- const bv = b.meta?.[sortConfig.field];
42
+ const av = a[sortConfig.field] ?? a.meta?.[sortConfig.field];
43
+ const bv = b[sortConfig.field] ?? b.meta?.[sortConfig.field];
44
44
  const aMissing = av === undefined || av === null;
45
45
  const bMissing = bv === undefined || bv === null;
46
46
  if (aMissing && bMissing) return 0;
@@ -113,6 +113,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
  *
@@ -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 sortConfig = config.collections?.[params.collection]?.sort ?? null;
71
- const pages = await adapters.content.listPages(params.collection, sortConfig);
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
- return ok(pages);
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
 
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.");