@stelstone/server 0.26.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,275 @@
1
+ # @stelstone/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 [`stelstone`](../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 @stelstone/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 "@stelstone/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
+ // Optional. Left out, the server resolves the admin UI itself — source when
38
+ // it is present, the built bundle otherwise (see `resolveAdminUiOptions`).
39
+ adminUi: {
40
+ mode: "auto", // vite-dev when NODE_ENV !== "production", static otherwise
41
+ distDir: path.join(ROOT_DIR, "node_modules/@stelstone/admin-ui/dist"),
42
+ sourceDir: path.join(ROOT_DIR, "node_modules/@stelstone/admin-ui"),
43
+ },
44
+ });
45
+ ```
46
+
47
+ Most sites need none of this: `npx stelstone` reads `cms.config.mjs`, finds
48
+ the admin UI and serves it. Reach for the API when you are embedding the CMS
49
+ in a server you already have.
50
+
51
+ ## API
52
+
53
+ ### `createCmsServer({ config, rootDir, publicConfig?, realm? })`
54
+ Returns `{ handle, middleware, adapters }` — `handle` is a Fetch handler
55
+ (`Request` → `Response | null`), `middleware` the same thing adapted to Node's
56
+ `(req, res, next)`. Nothing listens; you mount it where you like.
57
+
58
+ ### `resolveAdminUi(options)`
59
+ Turns an admin UI choice into something mountable. Modes:
60
+
61
+ - `{ mode: "static", dir }` — serves a prebuilt SPA bundle. Returns `{ fetchHandler }`.
62
+ - `{ mode: "vite-dev", root, base? }` — dev middleware with HMR (requires `vite`,
63
+ and the admin UI's *source*, which a published tarball does not ship).
64
+ Returns `{ nodeMiddleware }`.
65
+ - `{ mode: "auto", distDir, sourceDir }` — `vite-dev` when `NODE_ENV !== "production"`, `static` otherwise.
66
+
67
+ Either mode answers `/admin` with a 301 to `/admin/`. The SPA links its bundle
68
+ relatively, so without the trailing slash a browser resolves `./assets/…`
69
+ against `/admin`, gets a 404, and shows a blank page with nothing in the
70
+ console to explain it.
71
+
72
+ ### `resolveAdminUiOptions({ dev?, previewThemeCss?, onWarn? })`
73
+ The source-then-dist decision, shared so callers cannot drift: the admin UI's
74
+ source when it is there and `dev` is set, the built bundle otherwise, `null`
75
+ when neither exists. `startCmsServer` and `stelstone` both use it.
76
+
77
+ ### `startCmsServer(opts)`
78
+ Convenience: builds the handler, mounts the admin UI, listens. Returns
79
+ `{ server, handle, adapters, stopScheduler }`.
80
+
81
+ ## Subpath exports
82
+
83
+ - `@stelstone/server/media-url` — pure `createMediaUrl(mediaConfig)`
84
+ factory for building CDN URLs in your site code. No `express`
85
+ dependency, safe to import from Astro components.
86
+ - `@stelstone/server/adapters` — direct access to each adapter
87
+ factory if you want to compose your own server.
88
+
89
+ ## Routes
90
+
91
+ | Method | Path | Purpose |
92
+ | ------- | -------------------------------------- | ------------------------ |
93
+ | GET | `/api/config` | sanitized `publicConfig` (allowlisted before auth) |
94
+ | GET | `/api/collections` | collection summaries |
95
+ | GET | `/api/collections/:c` | list entries |
96
+ | GET | `/api/collections/:c/:file` | read entry |
97
+ | PUT | `/api/collections/:c/:file` | update entry |
98
+ | POST | `/api/collections/:c` | create entry |
99
+ | DELETE | `/api/collections/:c/:file` | delete entry |
100
+ | POST | `/api/collections/:c/:file/publish` | publish ONE record — only its file is committed (501 on backends that publish on save) |
101
+ | GET | `/api/links` | every page's public path, for the `link` field's picker |
102
+ | GET | `/api/assets` | grouped local-assets list (allowlisted before auth) |
103
+ | GET | `/api/assets/:folder` | files in one local folder |
104
+ | GET | `/api/media/folders` | CDN folders (proxy) |
105
+ | GET | `/api/media/folder/:folder` | CDN files (proxy) |
106
+ | POST | `/api/media/upload` | upload to CDN (proxy) |
107
+ | POST | `/api/publish` | git commit + push (everything under publishPaths) |
108
+ | GET | `/api/publish/status` | pending changes, per file, plus `perEntryPublish` capability |
109
+ | GET | `/api/deploy/status` | Netlify deploy status |
110
+
111
+ ## Adapters
112
+
113
+ All under `src/adapters/`. Each is a pure factory; no module-scoped state.
114
+
115
+ | Factory | Implements | Notes |
116
+ | ------------------------ | ----------------- | -------------------------------------- |
117
+ | `createFsJsonContent` | ContentAdapter | JSON files on disk + `git push` |
118
+ | `createLocalAssetsMedia` | MediaAdapter | legacy `src/assets/` file picker |
119
+ | `createCdnProxyMedia` | MediaAdapter | proxies CloudFront/S3 listing + upload |
120
+ | `createBasicAuth` | AuthAdapter | HTTP Basic + HMAC-SHA256 JWT for media |
121
+ | `createNetlifyBuild` | BuildAdapter | Netlify deploy status |
122
+ | `createMediaUrl` | (URL helper) | `cdnBase` + resize-prefix → URLs |
123
+
124
+ JSDoc contracts in `src/adapters/types.mjs`. Swap in your own
125
+ implementation by passing different adapter instances; the server
126
+ glue is agnostic.
127
+
128
+ ## Collection listing & the content index
129
+
130
+ ### The problem
131
+
132
+ 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.
133
+
134
+ ### How `_index.json` works
135
+
136
+ Each collection maintains a manifest at `<pagesDir>/<collection>/_index.json`:
137
+
138
+ ```json
139
+ {
140
+ "entries": [
141
+ {
142
+ "id": "entry-1",
143
+ "slug": "my-first-post",
144
+ "lang": "en",
145
+ "collection": "blog",
146
+ "title": "My First Post",
147
+ "file": "entry-1.json",
148
+ "meta": { /* custom fields from metaFields */ }
149
+ }
150
+ ]
151
+ }
152
+ ```
153
+
154
+ 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.
155
+
156
+ ### Forms — `config.mail` + `config.forms`
157
+
158
+ The replacement for Netlify Forms, served by the same handler on both
159
+ runtimes (`POST /api/forms/:name`, public). Delivery is Resend; the sender
160
+ domain must be verified there (SPF/DKIM) or mail will not arrive.
161
+
162
+ ```js
163
+ mail: { from: "Site <forms@site.com>" }, // keyEnv: "RESEND_API_KEY" is the default
164
+ forms: {
165
+ iletisim: {
166
+ to: "info@site.com",
167
+ subject: (fields) => `Mesaj — ${fields["Ad ve Soyad"]}`, // optional
168
+ replyTo: "E-Posta", // reply-to comes from this field
169
+ redirect: "/tesekkurler/", // plain HTML posts get a 303 here
170
+ honeypot: "bot-field", // default
171
+ turnstile: false, // true → verify cf-turnstile-response (TURNSTILE_SECRET)
172
+ },
173
+ },
174
+ ```
175
+
176
+ ```html
177
+ <form method="POST" action="/api/forms/iletisim">
178
+ <p hidden><input name="bot-field" tabindex="-1" autocomplete="off" /></p>
179
+ <input name="Ad ve Soyad" required />
180
+ <input name="E-Posta" type="email" required />
181
+ <textarea name="Mesaj" required></textarea>
182
+ <button>Gönder</button>
183
+ </form>
184
+ ```
185
+
186
+ Gates, in order: unknown form → 404 · filled honeypot → silent 200, nothing
187
+ delivered (an error would teach the bot which field to skip) · size limits
188
+ (30 fields, 5000 chars/field) → 400 · per-IP rate limit (5/min; in-memory on
189
+ Node, KV-backed on Workers) → 429 · unconfigured mail → 503 · delivery
190
+ failure → 502. JS-driven forms get JSON; plain HTML posts get the redirect.
191
+
192
+ ### Serving the Worker admin from the site's own address
193
+
194
+ A deployed CMS Worker answers at `<name>.workers.dev`, which is neither an
195
+ address to hand a client nor same-origin with the site. Pick by where the
196
+ site is hosted; the first two make `siteadi.com/admin` literal, which also
197
+ ends CORS configuration — auth headers never travel cross-origin:
198
+
199
+ | Site hosting | Setup |
200
+ | --- | --- |
201
+ | Cloudflare | Attach a route to the Worker: `siteadi.com/admin*` (and `/api/*`). True same-origin. |
202
+ | Netlify | Proxy in `_redirects`: `/admin/* https://<name>.workers.dev/admin/:splat 200` (same for `/api/*`). Netlify proxies server-side; the browser sees one origin. |
203
+ | Elsewhere | Give the Worker a custom domain (`admin.siteadi.com`). Not same-origin, but presentable; keep `cors.origin` pinned to the site. |
204
+
205
+ `workers.dev` is a fallback for testing, not an address to ship.
206
+
207
+ ### Configuration: `content.draftBranch` — saving without publishing
208
+
209
+ Without it, this backend commits every save straight to `content.branch`;
210
+ when CI deploys that branch, **saving a live page publishes it instantly** —
211
+ there is no "work on it, publish when ready". Set a draft branch and the
212
+ model becomes the fs backend's: saves land on the draft branch, the site
213
+ keeps serving the published version, and Publish (the whole site or one
214
+ record) moves files across with a single commit.
215
+
216
+ ```js
217
+ content: {
218
+ provider: "github",
219
+ branch: "main", // what CI deploys
220
+ draftBranch: "cms-drafts", // where saves go; created automatically
221
+ // ...
222
+ }
223
+ ```
224
+
225
+ Two things to know: the `_index.json` manifests live on the draft branch and
226
+ are never published (the site build ignores `_`-prefixed files), and content
227
+ edits pushed to `main` outside the CMS will show up in `pendingChanges` as a
228
+ diff — with a draft branch configured, content should change through the CMS.
229
+ Config validation warns when the github backend runs without a draft branch.
230
+
231
+ ### Configuration: the `content.list` block
232
+
233
+ In `cms.config.mjs`, configure listing behavior under `content.list`:
234
+
235
+ ```js
236
+ content: {
237
+ provider: "github",
238
+ // ...
239
+ list: {
240
+ strategy: "index", // default; reads _index.json
241
+ rebuild: "build", // "build" (default) | "lazy"
242
+ indexFile: "_index.json", // manifest filename
243
+ resolve: undefined, // optional: custom listing function
244
+ },
245
+ }
246
+ ```
247
+
248
+ | Option | Values | Description |
249
+ | --------- | ----------------------- | ----------- |
250
+ | `strategy` | `"index"` | Built-in strategy: reads the `_index.json` manifest. Only option currently shipped. |
251
+ | `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). |
252
+ | `indexFile` | string | Override the manifest filename (default: `_index.json`). |
253
+ | `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. |
254
+
255
+ ### Out-of-band rebuild: the `build-index` CLI
256
+
257
+ Regenerate all `_index.json` manifests locally:
258
+
259
+ ```sh
260
+ npx stelstone build-index --config ./cms.config.mjs
261
+ ```
262
+
263
+ Walks the local `pagesDir`, regenerates `_index.json` for every collection, and prints per-collection entry counts. Commit the result.
264
+
265
+ **When to run:**
266
+ - Once when adopting the index on an existing repo.
267
+ - After bulk imports or migrations.
268
+ - After content changed outside the CMS (e.g., direct git edits).
269
+ - **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"`).
270
+
271
+ ### Quick decision guide
272
+
273
+ - **Small site or self-hosted**: `rebuild: "lazy"` keeps setup simple.
274
+ - **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.
275
+ - **Real full-text search, faceting, or huge scale**: Provide a `resolve` hook backed by D1, KV, Algolia, Pagefind, or another external index.
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * stelstone CLI
4
+ *
5
+ * Usage:
6
+ * stelstone [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.
10
+ *
11
+ * Reads cms.config.mjs from the current working directory (or --config path),
12
+ * auto-discovers the admin-ui dist, and starts the CMS server.
13
+ *
14
+ * In your package.json:
15
+ * "scripts": {
16
+ * "admin": "node --env-file=.env node_modules/.bin/stelstone"
17
+ * }
18
+ */
19
+
20
+ import { pathToFileURL } from "url";
21
+ import path from "path";
22
+ import fs from "fs";
23
+ import { startCmsServer } from "../src/index.mjs";
24
+ import { buildListEntry } from "../src/adapters/_shared.mjs";
25
+
26
+ const args = process.argv.slice(2).filter((a) => a !== "start");
27
+
28
+ // Detect subcommand (first non-flag positional arg)
29
+ const subcommand = process.argv.slice(2).find((a) => !a.startsWith("-"));
30
+
31
+ function flag(name) {
32
+ const i = args.indexOf(name);
33
+ return i !== -1 ? args[i + 1] : null;
34
+ }
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(`[stelstone] 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(`[stelstone] Failed to load ${cfgPath}:\n ${err.message}`);
54
+ }
55
+ process.exit(1);
56
+ }
57
+ }
58
+
59
+ const dev = boolFlag("--dev");
60
+
61
+ const cwd = process.cwd();
62
+ const cfgPath = path.resolve(cwd, flag("--config") || "cms.config.mjs");
63
+ const port = parseInt(flag("--port") || process.env.ADMIN_PORT || "4001", 10);
64
+ const realm = flag("--realm") || "CMS Admin";
65
+
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("[stelstone] 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(`[stelstone] 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[stelstone] Indexed ${totalEntries} entries across all collections`);
161
+ process.exit(0);
162
+ } catch (err) {
163
+ console.error(`[stelstone] Failed to build index:\n ${err.message}`);
164
+ process.exit(1);
165
+ }
166
+ }
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
+
174
+ await startCmsServer({
175
+ config,
176
+ publicConfig, // undefined is fine — server uses defaultPublicConfig(config)
177
+ rootDir: cwd,
178
+ realm,
179
+ port,
180
+ dev,
181
+ });
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@stelstone/server",
3
+ "version": "0.26.0",
4
+ "description": "Runtime-agnostic CMS server built on the Web Fetch API, with pluggable adapters for content, media, auth, and build.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/natilon/stelstone.git",
9
+ "directory": "packages/server"
10
+ },
11
+ "keywords": [
12
+ "cms",
13
+ "headless-cms",
14
+ "git-based",
15
+ "astro",
16
+ "cloudflare-workers"
17
+ ],
18
+ "type": "module",
19
+ "bin": {
20
+ "stelstone": "./bin/stelstone.mjs"
21
+ },
22
+ "scripts": {
23
+ "test": "node --test 'test/**/*.test.mjs'"
24
+ },
25
+ "exports": {
26
+ ".": "./src/index.mjs",
27
+ "./media-url": "./src/adapters/media-url.mjs",
28
+ "./adapters": "./src/adapters/index.mjs",
29
+ "./public-config": "./src/default-public-config.mjs",
30
+ "./routes": "./src/routes.mjs",
31
+ "./handler": "./src/core/handler.mjs",
32
+ "./node-adapter": "./src/core/node-adapter.mjs",
33
+ "./static-files": "./src/core/static-files.mjs",
34
+ "./config-schema": "./src/core/config-schema.mjs",
35
+ "./adapter-options": "./src/core/adapter-options.mjs"
36
+ },
37
+ "files": [
38
+ "src",
39
+ "bin"
40
+ ],
41
+ "peerDependencies": {
42
+ "vite": "^5.0.0",
43
+ "@stelstone/admin-ui": ">=0.12.0"
44
+ },
45
+ "peerDependenciesMeta": {
46
+ "vite": {
47
+ "optional": true
48
+ },
49
+ "@stelstone/admin-ui": {
50
+ "optional": true
51
+ }
52
+ }
53
+ }