@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 +275 -0
- package/bin/stelstone.mjs +181 -0
- package/package.json +53 -0
- package/src/adapters/_shared.mjs +401 -0
- package/src/adapters/basic-auth.mjs +102 -0
- package/src/adapters/build-netlify.mjs +60 -0
- package/src/adapters/cdn-proxy-media.mjs +79 -0
- package/src/adapters/cloudflare-access.mjs +144 -0
- package/src/adapters/fs-json-content.mjs +302 -0
- package/src/adapters/fs-templates.mjs +57 -0
- package/src/adapters/github-api.mjs +100 -0
- package/src/adapters/github-content.mjs +577 -0
- package/src/adapters/github-oauth.mjs +153 -0
- package/src/adapters/github-templates.mjs +100 -0
- package/src/adapters/index.mjs +12 -0
- package/src/adapters/local-assets-media.mjs +68 -0
- package/src/adapters/media-url.mjs +133 -0
- package/src/adapters/resend-mail.mjs +41 -0
- package/src/adapters/types.mjs +104 -0
- package/src/admin-ui-path.mjs +77 -0
- package/src/core/adapter-options.mjs +167 -0
- package/src/core/config-schema.mjs +408 -0
- package/src/core/forms.mjs +99 -0
- package/src/core/handler.mjs +209 -0
- package/src/core/node-adapter.mjs +99 -0
- package/src/core/static-files.mjs +115 -0
- package/src/default-public-config.mjs +39 -0
- package/src/index.mjs +22 -0
- package/src/routes.mjs +737 -0
- package/src/server.mjs +325 -0
- package/src/version.mjs +8 -0
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
|
+
}
|