@natilon/cms-server 0.1.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 ADDED
@@ -0,0 +1,105 @@
1
+ # @natilon/cms-server
2
+
3
+ Express-based CMS server with pluggable adapters for content storage,
4
+ media, auth, and build-status. Designed to be mounted from any Node
5
+ process or — via [`@natilon/astro-cms`](../astro-cms) — directly into
6
+ `astro dev`.
7
+
8
+ The server is **glue code**: routes, JSON wiring, error handling. All
9
+ behavior comes from adapter factories you instantiate from your
10
+ `cms.config.mjs`.
11
+
12
+ ## Install
13
+
14
+ ```sh
15
+ npm i @natilon/cms-server
16
+ # Optional, only needed for the dev-mode admin UI middleware:
17
+ npm i -D vite
18
+ ```
19
+
20
+ ## Usage
21
+
22
+ ```js
23
+ // scripts/admin-server.mjs
24
+ import path from "path";
25
+ import { fileURLToPath } from "url";
26
+ import { startCmsServer } from "@natilon/cms-server";
27
+ import cmsConfig, { publicConfig } from "../cms.config.mjs";
28
+
29
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
30
+ const ROOT_DIR = path.join(__dirname, "..");
31
+
32
+ await startCmsServer({
33
+ config: cmsConfig,
34
+ publicConfig,
35
+ rootDir: ROOT_DIR,
36
+ realm: "My Site Admin",
37
+ adminUi: {
38
+ mode: "auto", // vite-dev when NODE_ENV !== "production", static otherwise
39
+ distDir: path.join(ROOT_DIR, "node_modules/@natilon/admin-ui/dist"),
40
+ sourceDir: path.join(ROOT_DIR, "node_modules/@natilon/admin-ui"),
41
+ },
42
+ });
43
+ ```
44
+
45
+ ## API
46
+
47
+ ### `createCmsServer({ config, rootDir, publicConfig?, realm? })`
48
+ Returns `{ app, adapters }`. Routes are already mounted; you can add
49
+ your own middleware before/after and call `app.listen()` yourself.
50
+
51
+ ### `mountAdminUi(app, adminUi)`
52
+ Mounts the admin SPA under `/admin`. Modes:
53
+
54
+ - `{ mode: "static", dir }` — serves a prebuilt SPA bundle.
55
+ - `{ mode: "vite-dev", root, base? }` — dev middleware with HMR (requires `vite`).
56
+ - `{ mode: "auto", distDir, sourceDir }` — `vite-dev` when `NODE_ENV !== "production"`, `static` otherwise.
57
+
58
+ ### `startCmsServer(opts)`
59
+ Convenience: builds the app, mounts the admin UI, listens. Returns
60
+ `{ server, app, adapters }`.
61
+
62
+ ## Subpath exports
63
+
64
+ - `@natilon/cms-server/media-url` — pure `createMediaUrl(mediaConfig)`
65
+ factory for building CDN URLs in your site code. No `express`
66
+ dependency, safe to import from Astro components.
67
+ - `@natilon/cms-server/adapters` — direct access to each adapter
68
+ factory if you want to compose your own server.
69
+
70
+ ## Routes
71
+
72
+ | Method | Path | Purpose |
73
+ | ------- | -------------------------------------- | ------------------------ |
74
+ | GET | `/api/config` | sanitized `publicConfig` (allowlisted before auth) |
75
+ | GET | `/api/collections` | collection summaries |
76
+ | GET | `/api/collections/:c` | list entries |
77
+ | GET | `/api/collections/:c/:file` | read entry |
78
+ | PUT | `/api/collections/:c/:file` | update entry |
79
+ | POST | `/api/collections/:c` | create entry |
80
+ | DELETE | `/api/collections/:c/:file` | delete entry |
81
+ | GET | `/api/assets` | grouped local-assets list (allowlisted before auth) |
82
+ | GET | `/api/assets/:folder` | files in one local folder |
83
+ | GET | `/api/media/folders` | CDN folders (proxy) |
84
+ | GET | `/api/media/folder/:folder` | CDN files (proxy) |
85
+ | POST | `/api/media/upload` | upload to CDN (proxy) |
86
+ | POST | `/api/publish` | git commit + push |
87
+ | GET | `/api/publish/status` | pending changes |
88
+ | GET | `/api/deploy/status` | Netlify deploy status |
89
+
90
+ ## Adapters
91
+
92
+ All under `src/adapters/`. Each is a pure factory; no module-scoped state.
93
+
94
+ | Factory | Implements | Notes |
95
+ | ------------------------ | ----------------- | -------------------------------------- |
96
+ | `createFsJsonContent` | ContentAdapter | JSON files on disk + `git push` |
97
+ | `createLocalAssetsMedia` | MediaAdapter | legacy `src/assets/` file picker |
98
+ | `createCdnProxyMedia` | MediaAdapter | proxies CloudFront/S3 listing + upload |
99
+ | `createBasicAuth` | AuthAdapter | HTTP Basic + HMAC-SHA256 JWT for media |
100
+ | `createNetlifyBuild` | BuildAdapter | Netlify deploy status |
101
+ | `createMediaUrl` | (URL helper) | `cdnBase` + resize-prefix → URLs |
102
+
103
+ JSDoc contracts in `src/adapters/types.mjs`. Swap in your own
104
+ implementation by passing different adapter instances; the server
105
+ glue is agnostic.
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * natilon-cms CLI
4
+ *
5
+ * Usage:
6
+ * natilon-cms [start] [--port 4001] [--config ./cms.config.mjs]
7
+ *
8
+ * Reads cms.config.mjs from the current working directory (or --config path),
9
+ * auto-discovers the admin-ui dist, and starts the CMS server.
10
+ *
11
+ * In your package.json:
12
+ * "scripts": {
13
+ * "admin": "node --env-file=.env node_modules/.bin/natilon-cms"
14
+ * }
15
+ */
16
+
17
+ import { pathToFileURL } from "url";
18
+ import path from "path";
19
+ import { startCmsServer } from "../src/index.mjs";
20
+
21
+ const args = process.argv.slice(2).filter((a) => a !== "start");
22
+
23
+ function flag(name) {
24
+ const i = args.indexOf(name);
25
+ return i !== -1 ? args[i + 1] : null;
26
+ }
27
+
28
+ const cwd = process.cwd();
29
+ const cfgPath = path.resolve(cwd, flag("--config") || "cms.config.mjs");
30
+ const port = parseInt(flag("--port") || process.env.ADMIN_PORT || "4001", 10);
31
+ const realm = flag("--realm") || "CMS Admin";
32
+
33
+ // Load the consumer's config file.
34
+ let config, publicConfig;
35
+ try {
36
+ const mod = await import(pathToFileURL(cfgPath).href);
37
+ config = mod.default;
38
+ publicConfig = mod.publicConfig; // optional — server auto-derives if absent
39
+ if (!config) throw new Error("cms.config.mjs must have a default export");
40
+ } catch (err) {
41
+ if (err.code === "ERR_MODULE_NOT_FOUND" || err.code === "ERR_LOAD_URL") {
42
+ console.error(`[natilon-cms] Cannot find config file: ${cfgPath}`);
43
+ console.error(" Create cms.config.mjs in your project root, or use --config <path>.");
44
+ } else {
45
+ console.error(`[natilon-cms] Failed to load ${cfgPath}:\n ${err.message}`);
46
+ }
47
+ process.exit(1);
48
+ }
49
+
50
+ await startCmsServer({
51
+ config,
52
+ publicConfig, // undefined is fine — server uses defaultPublicConfig(config)
53
+ rootDir: cwd,
54
+ realm,
55
+ port,
56
+ });
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@natilon/cms-server",
3
+ "version": "0.1.0",
4
+ "description": "Express-based CMS server with pluggable adapters for content, media, auth, and build.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/natilon/cms.git",
9
+ "directory": "packages/cms-server"
10
+ },
11
+ "keywords": ["cms", "headless-cms", "git-based", "astro", "express"],
12
+ "type": "module",
13
+ "bin": {
14
+ "natilon-cms": "./bin/natilon-cms.mjs"
15
+ },
16
+ "exports": {
17
+ ".": "./src/index.mjs",
18
+ "./media-url": "./src/adapters/media-url.mjs",
19
+ "./adapters": "./src/adapters/index.mjs",
20
+ "./public-config": "./src/default-public-config.mjs"
21
+ },
22
+ "files": [
23
+ "src",
24
+ "bin"
25
+ ],
26
+ "dependencies": {
27
+ "depd": "^2.0.0",
28
+ "express": "^4.21.0"
29
+ },
30
+ "peerDependencies": {
31
+ "vite": "^5.0.0"
32
+ },
33
+ "peerDependenciesMeta": {
34
+ "vite": {
35
+ "optional": true
36
+ }
37
+ }
38
+ }
@@ -0,0 +1,81 @@
1
+ import crypto from "crypto";
2
+
3
+ /**
4
+ * HTTP Basic auth + HMAC-SHA256 JWT for media tokens.
5
+ *
6
+ * @param {Object} opts
7
+ * @param {string} opts.user
8
+ * @param {string|undefined} opts.pass
9
+ * @param {string|undefined} opts.jwtSecret
10
+ * @param {number} opts.jwtTtl
11
+ * @param {string} [opts.realm="Admin"]
12
+ * @returns {import('./types.mjs').AuthAdapter}
13
+ */
14
+ export function createBasicAuth({
15
+ user,
16
+ pass,
17
+ jwtSecret,
18
+ jwtTtl,
19
+ realm = "Admin",
20
+ }) {
21
+ const configured = Boolean(pass);
22
+
23
+ function issueMediaToken(tenantId) {
24
+ if (!jwtSecret) throw new Error("JWT_SECRET not set");
25
+ const header = Buffer.from(
26
+ JSON.stringify({ alg: "HS256", typ: "JWT" }),
27
+ ).toString("base64url");
28
+ const payload = Buffer.from(
29
+ JSON.stringify({
30
+ sub: user,
31
+ tenant_id: tenantId,
32
+ iat: Math.floor(Date.now() / 1000),
33
+ exp: Math.floor(Date.now() / 1000) + jwtTtl,
34
+ }),
35
+ ).toString("base64url");
36
+ const sig = crypto
37
+ .createHmac("sha256", jwtSecret)
38
+ .update(`${header}.${payload}`)
39
+ .digest("base64url");
40
+ return `${header}.${payload}.${sig}`;
41
+ }
42
+
43
+ function middlewareWith(allowlist) {
44
+ return (req, res, next) => {
45
+ if (allowlist && allowlist(req)) return next();
46
+ const auth = req.headers.authorization;
47
+ if (!auth || !auth.startsWith("Basic ")) {
48
+ res.set("WWW-Authenticate", `Basic realm="${realm}"`);
49
+ return res.status(401).send("Authentication required");
50
+ }
51
+ const [u, p] = Buffer.from(auth.slice(6), "base64")
52
+ .toString()
53
+ .split(":");
54
+ if (u === user && p === pass) {
55
+ req.cmsUser = { login: user, role: "admin" };
56
+ return next();
57
+ }
58
+ res.set("WWW-Authenticate", `Basic realm="${realm}"`);
59
+ res.status(401).send("Invalid credentials");
60
+ };
61
+ }
62
+
63
+ function verify(authHeader) {
64
+ if (!configured) return { login: user, role: "admin" };
65
+ if (!authHeader?.startsWith("Basic ")) return null;
66
+ const decoded = Buffer.from(authHeader.slice(6), "base64").toString();
67
+ const colon = decoded.indexOf(":");
68
+ const u = decoded.slice(0, colon);
69
+ const p = decoded.slice(colon + 1);
70
+ if (u === user && p === pass) return { login: user, role: "admin" };
71
+ return null;
72
+ }
73
+
74
+ return {
75
+ configured,
76
+ middleware: middlewareWith(null),
77
+ middlewareWith,
78
+ issueMediaToken,
79
+ verify,
80
+ };
81
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Netlify deploys API integration.
3
+ *
4
+ * @param {Object} opts
5
+ * @param {string|undefined} opts.token
6
+ * @param {string|undefined} opts.siteId
7
+ * @param {string} opts.defaultBranch
8
+ * @returns {import('./types.mjs').BuildAdapter}
9
+ */
10
+ export function createNetlifyBuild({ token, siteId, defaultBranch }) {
11
+ const configured = Boolean(token && siteId);
12
+
13
+ return {
14
+ configured,
15
+
16
+ async getDeployStatus({ branch, sha } = {}) {
17
+ if (!configured) return { configured: false };
18
+ const targetBranch = branch || defaultBranch;
19
+ const url = `https://api.netlify.com/api/v1/sites/${siteId}/deploys?per_page=20&branch=${encodeURIComponent(
20
+ targetBranch,
21
+ )}`;
22
+ const r = await fetch(url, {
23
+ headers: { Authorization: `Bearer ${token}` },
24
+ });
25
+ if (!r.ok) {
26
+ const err = new Error(`Netlify API ${r.status}`);
27
+ err.upstreamStatus = r.status;
28
+ throw err;
29
+ }
30
+ const deploys = await r.json();
31
+ let deploy = null;
32
+ if (sha) {
33
+ deploy = deploys.find(
34
+ (d) => d.commit_ref && d.commit_ref.startsWith(sha),
35
+ );
36
+ // SHA not found yet — Netlify hasn't picked up the commit, keep waiting
37
+ if (!deploy) return { configured: true, deploy: null };
38
+ }
39
+ if (!deploy) deploy = deploys[0] || null;
40
+ if (!deploy) return { configured: true, deploy: null };
41
+ return {
42
+ configured: true,
43
+ deploy: {
44
+ id: deploy.id,
45
+ state: deploy.state,
46
+ branch: deploy.branch,
47
+ commitRef: deploy.commit_ref,
48
+ deployUrl: deploy.deploy_ssl_url || deploy.deploy_url,
49
+ adminUrl: deploy.admin_url
50
+ ? `${deploy.admin_url}/deploys/${deploy.id}`
51
+ : null,
52
+ createdAt: deploy.created_at,
53
+ updatedAt: deploy.updated_at,
54
+ errorMessage: deploy.error_message || null,
55
+ title: deploy.title || null,
56
+ },
57
+ };
58
+ },
59
+ };
60
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * CDN-backed media adapter that proxies requests to a remote media
3
+ * service expecting Bearer-token auth (e.g. the natilon media-cdn lambda).
4
+ *
5
+ * The admin server calls these methods server-side, so the browser never
6
+ * needs to talk to the CDN directly — eliminating CORS friction and
7
+ * keeping the media JWT off the client.
8
+ *
9
+ * Backend contract (mirrors the lambda):
10
+ * GET / → { folders: string[] }
11
+ * GET /{folder}/ → { items, page, pages, total }
12
+ * POST /_upload → { key, ... }
13
+ *
14
+ * @param {Object} opts
15
+ * @param {string} opts.baseUrl e.g. "https://media.natilon.com"
16
+ * @param {() => string} opts.getToken Returns a fresh JWT each call.
17
+ * @returns {{
18
+ * listFolders: () => Promise<object>,
19
+ * listFolder: (folder: string, q?: {page?: number, perPage?: number, search?: string}) => Promise<object>,
20
+ * upload: (payload: object) => Promise<object>,
21
+ * }}
22
+ */
23
+ export function createCdnProxyMedia({ baseUrl, getToken }) {
24
+ const ROOT = baseUrl.replace(/\/+$/, "");
25
+
26
+ function sanitize(folder) {
27
+ return String(folder).replace(/[^a-zA-Z0-9._-]/g, "");
28
+ }
29
+
30
+ async function call(path, opts = {}) {
31
+ const r = await fetch(ROOT + path, {
32
+ ...opts,
33
+ headers: {
34
+ Authorization: `Bearer ${getToken()}`,
35
+ ...(opts.body ? { "Content-Type": "application/json" } : {}),
36
+ ...(opts.headers || {}),
37
+ },
38
+ });
39
+ const text = await r.text();
40
+ let body;
41
+ try {
42
+ body = text ? JSON.parse(text) : {};
43
+ } catch {
44
+ const err = new Error(`Media backend returned non-JSON (${r.status})`);
45
+ err.upstreamStatus = r.status;
46
+ throw err;
47
+ }
48
+ if (!r.ok) {
49
+ const err = new Error(body?.error || `Media backend ${r.status}`);
50
+ err.upstreamStatus = r.status;
51
+ err.body = body;
52
+ throw err;
53
+ }
54
+ return body;
55
+ }
56
+
57
+ return {
58
+ listFolders() {
59
+ // `/_list/` matches CloudFront's `*/` behavior so it routes to the
60
+ // lambda; bare `/` would hit the S3 default behavior and 403.
61
+ return call("/_list/");
62
+ },
63
+
64
+ listFolder(folder, { page = 1, perPage = 30, search = "" } = {}) {
65
+ const params = new URLSearchParams({
66
+ page: String(page),
67
+ per_page: String(perPage),
68
+ });
69
+ if (search) params.set("search", search);
70
+ return call(`/${sanitize(folder)}/?${params.toString()}`);
71
+ },
72
+
73
+ upload(payload) {
74
+ return call("/_upload", {
75
+ method: "POST",
76
+ body: JSON.stringify(payload),
77
+ });
78
+ },
79
+ };
80
+ }
@@ -0,0 +1,248 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { execSync } from "child_process";
4
+
5
+ const HISTORY_KEEP = 50; // max revisions kept per file
6
+
7
+ /**
8
+ * Filesystem-backed content adapter. Pages live as JSON files under
9
+ * `<rootDir>/<pagesDir>/<collection>/<file>.json`. Publishing means
10
+ * `git add && commit && push` to a configured branch.
11
+ *
12
+ * @param {Object} opts
13
+ * @param {string} opts.rootDir
14
+ * @param {string} opts.pagesDir
15
+ * @param {string} opts.publishBranch
16
+ * @param {string[]} opts.publishPaths
17
+ * @param {(timestamp: string) => string} opts.commitMessage
18
+ * @returns {import('./types.mjs').ContentAdapter}
19
+ */
20
+ export function createFsJsonContent({
21
+ rootDir,
22
+ pagesDir,
23
+ publishBranch,
24
+ publishPaths,
25
+ commitMessage,
26
+ }) {
27
+ const PAGES_DIR = path.join(rootDir, pagesDir);
28
+ const HISTORY_DIR = path.join(rootDir, ".cms-history");
29
+ const PATHS_ARG = publishPaths.join(" ");
30
+
31
+ function historyDir(collection, file) {
32
+ return path.join(HISTORY_DIR, sanitize(collection), sanitize(file));
33
+ }
34
+
35
+ function saveHistory(collection, file) {
36
+ const src = pagePath(collection, file);
37
+ if (!fs.existsSync(src)) return;
38
+ const dir = historyDir(collection, file);
39
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
40
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
41
+ fs.copyFileSync(src, path.join(dir, `${ts}.json`));
42
+ // Prune old revisions beyond HISTORY_KEEP
43
+ const entries = fs.readdirSync(dir).filter((f) => f.endsWith(".json")).sort();
44
+ while (entries.length > HISTORY_KEEP) {
45
+ fs.unlinkSync(path.join(dir, entries.shift()));
46
+ }
47
+ }
48
+
49
+ function git(cmd) {
50
+ return execSync(`git ${cmd}`, { cwd: rootDir, encoding: "utf-8" }).trim();
51
+ }
52
+
53
+ function sanitize(p) {
54
+ return String(p).replace(/[^a-zA-Z0-9._-]/g, "");
55
+ }
56
+
57
+ function pagePath(collection, file) {
58
+ return path.join(PAGES_DIR, sanitize(collection), sanitize(file));
59
+ }
60
+
61
+ return {
62
+ async listCollections() {
63
+ if (!fs.existsSync(PAGES_DIR)) return [];
64
+ const dirs = fs
65
+ .readdirSync(PAGES_DIR)
66
+ .filter((d) => fs.statSync(path.join(PAGES_DIR, d)).isDirectory());
67
+ return dirs.map((dir) => {
68
+ const files = fs
69
+ .readdirSync(path.join(PAGES_DIR, dir))
70
+ .filter((f) => f.endsWith(".json"));
71
+ return { name: dir, count: files.length };
72
+ });
73
+ },
74
+
75
+ async listPages(collection, sortConfig = null) {
76
+ const dir = path.join(PAGES_DIR, sanitize(collection));
77
+ if (!fs.existsSync(dir)) return null;
78
+ const files = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
79
+ const pages = files.map((file) => {
80
+ const data = JSON.parse(fs.readFileSync(path.join(dir, file), "utf-8"));
81
+ return {
82
+ id: data.id,
83
+ slug: data.slug,
84
+ lang: data.lang,
85
+ collection: data.collection,
86
+ title: data.meta?.title || data.meta?.name || file,
87
+ file,
88
+ meta: data.meta || {},
89
+ _sort: sortConfig ? (data.meta?.[sortConfig.field] ?? Infinity) : null,
90
+ };
91
+ });
92
+ if (sortConfig) {
93
+ const dir = sortConfig.direction === "desc" ? -1 : 1;
94
+ pages.sort((a, b) => {
95
+ if (a._sort < b._sort) return -1 * dir;
96
+ if (a._sort > b._sort) return 1 * dir;
97
+ return 0;
98
+ });
99
+ } else {
100
+ pages.sort((a, b) => a.title.localeCompare(b.title));
101
+ }
102
+ pages.forEach((p) => delete p._sort);
103
+ return pages;
104
+ },
105
+
106
+ async readPage(collection, file) {
107
+ const filePath = pagePath(collection, file);
108
+ if (!fs.existsSync(filePath)) return null;
109
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
110
+ },
111
+
112
+ async writePage(collection, file, data) {
113
+ saveHistory(collection, file);
114
+ const filePath = pagePath(collection, file);
115
+ const dir = path.dirname(filePath);
116
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
117
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8");
118
+ },
119
+
120
+ async createPage(collection, data) {
121
+ const slug = data.slug || data.id || `new-${Date.now()}`;
122
+ const fileName = `${sanitize(data.lang || "en")}-${sanitize(slug)}.json`;
123
+ const filePath = pagePath(collection, fileName);
124
+ const dir = path.dirname(filePath);
125
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
126
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8");
127
+ return { file: fileName };
128
+ },
129
+
130
+ async deletePage(collection, file) {
131
+ const filePath = pagePath(collection, file);
132
+ if (!fs.existsSync(filePath)) return false;
133
+ fs.unlinkSync(filePath);
134
+ return true;
135
+ },
136
+
137
+ async duplicatePage(collection, file) {
138
+ const filePath = pagePath(collection, file);
139
+ if (!fs.existsSync(filePath)) return null;
140
+ const src = JSON.parse(fs.readFileSync(filePath, "utf-8"));
141
+
142
+ const lang = src.lang || "en";
143
+ const baseSlug = (src.slug || "page").replace(/-copy(-\d+)?$/, "");
144
+ const newSlug = `${baseSlug}-copy-${Math.floor(Date.now() / 1000)}`;
145
+
146
+ // Regenerate block IDs to avoid duplicates within the document if the
147
+ // same block is later edited in both copies.
148
+ function reid(blocks) {
149
+ if (!Array.isArray(blocks)) return blocks;
150
+ return blocks.map((b) => {
151
+ const next = { ...b, id: "b-" + Math.random().toString(36).slice(2, 9) };
152
+ if (Array.isArray(b.children)) {
153
+ next.children = b.children.map((col) => reid(col));
154
+ }
155
+ return next;
156
+ });
157
+ }
158
+
159
+ const data = {
160
+ ...src,
161
+ id: `${lang}/${newSlug}`,
162
+ slug: newSlug,
163
+ meta: { ...(src.meta || {}), title: `${src.meta?.title || newSlug} (Copy)` },
164
+ blocks: reid(src.blocks || []),
165
+ };
166
+
167
+ const fileName = `${sanitize(lang)}-${sanitize(newSlug)}.json`;
168
+ const dest = pagePath(collection, fileName);
169
+ const dir = path.dirname(dest);
170
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
171
+ fs.writeFileSync(dest, JSON.stringify(data, null, 2), "utf-8");
172
+ return { file: fileName };
173
+ },
174
+
175
+ async pendingChanges() {
176
+ const status = git(`status --porcelain ${PATHS_ARG}`);
177
+ const hasChanges = status.length > 0;
178
+ const changedFiles = hasChanges
179
+ ? status.split("\n").filter(Boolean).length
180
+ : 0;
181
+ return { hasChanges, changedFiles };
182
+ },
183
+
184
+ async publish() {
185
+ const status = git(`status --porcelain ${PATHS_ARG}`);
186
+ if (!status) return { ok: false, message: "No changes to publish" };
187
+
188
+ git(`add ${PATHS_ARG}`);
189
+ const timestamp = new Date().toISOString().replace("T", " ").slice(0, 19);
190
+ const msg = commitMessage(timestamp);
191
+ git(`commit -m ${JSON.stringify(msg)}`);
192
+ git(`push origin HEAD:${publishBranch}`);
193
+
194
+ const shortSha = git("rev-parse --short HEAD");
195
+ const sha = git("rev-parse HEAD");
196
+ return {
197
+ ok: true,
198
+ message: `Published to ${publishBranch} (${shortSha})`,
199
+ sha,
200
+ shortSha,
201
+ branch: publishBranch,
202
+ };
203
+ },
204
+
205
+ async listHistory(collection, file) {
206
+ const dir = historyDir(collection, file);
207
+ if (!fs.existsSync(dir)) return [];
208
+ return fs
209
+ .readdirSync(dir)
210
+ .filter((f) => f.endsWith(".json"))
211
+ .sort()
212
+ .reverse()
213
+ .map((f) => {
214
+ const ts = f.replace(".json", "").replace(/-(\d{2})-(\d{3})$/, ".$1Z").replace(/-/g, (m, o, s) => {
215
+ // Reconstruct ISO: YYYY-MM-DDTHH-MM-SS-mmmZ → YYYY-MM-DDTHH:MM:SS.mmmZ
216
+ return s.slice(0, o).match(/T/) ? ":" : m;
217
+ });
218
+ const stat = fs.statSync(path.join(dir, f));
219
+ return { ts: f.replace(".json", ""), size: stat.size };
220
+ });
221
+ },
222
+
223
+ async restoreHistory(collection, file, ts) {
224
+ const src = path.join(historyDir(collection, file), sanitize(ts) + ".json");
225
+ if (!fs.existsSync(src)) return false;
226
+ const data = JSON.parse(fs.readFileSync(src, "utf-8"));
227
+ // writePage saves history of current version first, then overwrites
228
+ await this.writePage(collection, file, data);
229
+ return true;
230
+ },
231
+
232
+ async listScheduled() {
233
+ const collections = await this.listCollections();
234
+ const now = new Date();
235
+ const due = [];
236
+ for (const { name } of collections) {
237
+ const pages = await this.listPages(name);
238
+ for (const page of pages || []) {
239
+ if (page.meta?.publishAt && new Date(page.meta.publishAt) <= now) {
240
+ const data = await this.readPage(name, page.file);
241
+ if (data?.meta?.publishAt) due.push({ collection: name, file: page.file, data });
242
+ }
243
+ }
244
+ }
245
+ return due;
246
+ },
247
+ };
248
+ }