@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
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CMS request handler — the single implementation of request policy.
|
|
3
|
+
*
|
|
4
|
+
* Everything that decides *whether* and *how* a request runs lives here:
|
|
5
|
+
* route matching, CORS, authentication, role checks, body parsing, and the
|
|
6
|
+
* ResponseSpec → Response translation. Runtimes (Node, Cloudflare Workers)
|
|
7
|
+
* only translate transport; they never make these decisions.
|
|
8
|
+
*
|
|
9
|
+
* This exists because the previous design had one policy implementation per
|
|
10
|
+
* runtime. They drifted, and both of the security defects found in the
|
|
11
|
+
* 2026-08 audit lived in that drift: a `verify()` that was not awaited in one
|
|
12
|
+
* shim, and an admin-role check that passed unauthenticated requests in the
|
|
13
|
+
* other. With a single chain, that class of bug cannot recur.
|
|
14
|
+
*
|
|
15
|
+
* The handler speaks the Web Fetch API:
|
|
16
|
+
*
|
|
17
|
+
* handle(request, env) -> Response | null
|
|
18
|
+
*
|
|
19
|
+
* `null` means "no CMS route matched" so the caller can fall through to its
|
|
20
|
+
* own handling (static files in Node, the assets binding in a Worker).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { allRoutes } from "../routes.mjs";
|
|
24
|
+
|
|
25
|
+
/** Turn "/api/collections/:collection/:file" into a matcher. */
|
|
26
|
+
function compileRoute(route) {
|
|
27
|
+
const paramNames = [];
|
|
28
|
+
const pattern = route.path.replace(/:([A-Za-z_][A-Za-z0-9_]*)/g, (_, name) => {
|
|
29
|
+
paramNames.push(name);
|
|
30
|
+
return "([^/]+)";
|
|
31
|
+
});
|
|
32
|
+
return { ...route, regex: new RegExp(`^${pattern}$`), paramNames };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function decodeParam(value) {
|
|
36
|
+
try {
|
|
37
|
+
return decodeURIComponent(value);
|
|
38
|
+
} catch {
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Query string → plain object (last value wins, matching the previous shims). */
|
|
44
|
+
function queryOf(url) {
|
|
45
|
+
const query = {};
|
|
46
|
+
for (const [key, value] of url.searchParams) query[key] = value;
|
|
47
|
+
return query;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** ResponseSpec ({ status?, json?, text?, redirect?, headers? }) → Response. */
|
|
51
|
+
function toResponse(spec, baseHeaders) {
|
|
52
|
+
if (!spec) return new Response(null, { status: 204, headers: baseHeaders });
|
|
53
|
+
|
|
54
|
+
const headers = new Headers(baseHeaders);
|
|
55
|
+
if (spec.headers) {
|
|
56
|
+
for (const [key, value] of Object.entries(spec.headers)) headers.set(key, value);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (spec.redirect) {
|
|
60
|
+
headers.set("Location", spec.redirect);
|
|
61
|
+
return new Response(null, { status: spec.status || 302, headers });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const status = spec.status || 200;
|
|
65
|
+
if (spec.json !== undefined) {
|
|
66
|
+
headers.set("Content-Type", "application/json");
|
|
67
|
+
return new Response(JSON.stringify(spec.json), { status, headers });
|
|
68
|
+
}
|
|
69
|
+
if (spec.text !== undefined) {
|
|
70
|
+
headers.set("Content-Type", "text/plain; charset=utf-8");
|
|
71
|
+
return new Response(spec.text, { status, headers });
|
|
72
|
+
}
|
|
73
|
+
return new Response(null, { status, headers });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @param {Object} opts
|
|
78
|
+
* @param {Object} opts.config The site's cms.config object.
|
|
79
|
+
* @param {Object|Function} opts.adapters Adapter bag, or (env) => bag
|
|
80
|
+
* when adapters are per-request
|
|
81
|
+
* (Cloudflare Workers).
|
|
82
|
+
* @param {"node"|"worker"} [opts.runtime="node"] Declared by the caller —
|
|
83
|
+
* never sniffed. With `nodejs_compat` a Worker also exposes
|
|
84
|
+
* `process.versions.node`, so feature detection gets this wrong.
|
|
85
|
+
* @param {string|null} [opts.adminUiVersion] Version of the admin UI this
|
|
86
|
+
* deployment serves, when the caller resolved one. Reported by
|
|
87
|
+
* `/api/about`; null when the SPA is served by something the CMS
|
|
88
|
+
* cannot inspect (a Worker's assets binding).
|
|
89
|
+
* @returns {(request: Request, env?: object) => Promise<Response|null>}
|
|
90
|
+
*/
|
|
91
|
+
export function createRequestHandler({ config, adapters, runtime = "node", adminUiVersion = null }) {
|
|
92
|
+
const routes = allRoutes(config).map(compileRoute);
|
|
93
|
+
const resolveAdapters = typeof adapters === "function" ? adapters : () => adapters;
|
|
94
|
+
|
|
95
|
+
// Reporting an open CORS policy is config validation's job, not the
|
|
96
|
+
// handler's — see core/config-schema.mjs.
|
|
97
|
+
const corsOrigin = config.cors?.origin ?? "*";
|
|
98
|
+
const corsAllowList = Array.isArray(corsOrigin) ? corsOrigin : [corsOrigin];
|
|
99
|
+
|
|
100
|
+
function corsHeaders(request) {
|
|
101
|
+
const headers = new Headers({
|
|
102
|
+
"Access-Control-Allow-Methods": "GET,PUT,POST,DELETE,OPTIONS",
|
|
103
|
+
"Access-Control-Allow-Headers": "Content-Type,Authorization",
|
|
104
|
+
});
|
|
105
|
+
const origin = request.headers.get("origin");
|
|
106
|
+
if (corsAllowList.includes("*")) {
|
|
107
|
+
headers.set("Access-Control-Allow-Origin", "*");
|
|
108
|
+
} else if (origin && corsAllowList.includes(origin)) {
|
|
109
|
+
headers.set("Access-Control-Allow-Origin", origin);
|
|
110
|
+
headers.set("Vary", "Origin");
|
|
111
|
+
}
|
|
112
|
+
return headers;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Does any route own this path, regardless of method? (for 405 vs fall-through) */
|
|
116
|
+
function pathIsRouted(pathname) {
|
|
117
|
+
return routes.some((route) => route.regex.test(pathname));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return async function handle(request, env) {
|
|
121
|
+
const url = new URL(request.url);
|
|
122
|
+
const pathname = url.pathname;
|
|
123
|
+
const cors = corsHeaders(request);
|
|
124
|
+
|
|
125
|
+
// Preflight for any path this handler owns.
|
|
126
|
+
if (request.method === "OPTIONS" && pathIsRouted(pathname)) {
|
|
127
|
+
return new Response(null, { status: 204, headers: cors });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const match = routes
|
|
131
|
+
.map((route) => ({ route, m: route.method === request.method && route.regex.exec(pathname) }))
|
|
132
|
+
.find((candidate) => candidate.m);
|
|
133
|
+
|
|
134
|
+
// Not ours — let the caller serve static files / assets / next middleware.
|
|
135
|
+
if (!match) return null;
|
|
136
|
+
|
|
137
|
+
const { route, m } = match;
|
|
138
|
+
const adapterBag = resolveAdapters(env);
|
|
139
|
+
|
|
140
|
+
// ── Authentication ───────────────────────────────────────────────────
|
|
141
|
+
// One code path, always awaited. `verify` receives the Request so that
|
|
142
|
+
// adapters which authenticate from something other than the Authorization
|
|
143
|
+
// header (e.g. Cloudflare Access, which reads its own header or cookie)
|
|
144
|
+
// are first-class rather than special cases.
|
|
145
|
+
let user = null;
|
|
146
|
+
if (route.auth !== "public") {
|
|
147
|
+
if (!adapterBag.auth.configured) {
|
|
148
|
+
// No credentials configured at all — the local dev mode. Every adapter
|
|
149
|
+
// behaves the same here because the decision is made once, here,
|
|
150
|
+
// rather than inside each adapter's verify(). The server warns loudly
|
|
151
|
+
// about this at startup.
|
|
152
|
+
user = { login: "admin", name: "admin", role: "admin" };
|
|
153
|
+
} else {
|
|
154
|
+
user = await adapterBag.auth.verify(request);
|
|
155
|
+
if (!user) {
|
|
156
|
+
const body =
|
|
157
|
+
config.auth?.provider === "github-oauth"
|
|
158
|
+
? { error: "Authentication required", loginUrl: "/admin/oauth/login" }
|
|
159
|
+
: { error: "Authentication required" };
|
|
160
|
+
return toResponse({ status: 401, json: body }, cors);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── Role check ───────────────────────────────────────────────────────
|
|
166
|
+
// Reached only with a resolved user: unauthenticated requests to a
|
|
167
|
+
// non-public route were already rejected above.
|
|
168
|
+
if (route.auth === "admin" && user.role !== "admin") {
|
|
169
|
+
return toResponse({ status: 403, json: { error: "Admin role required" } }, cors);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── Body ─────────────────────────────────────────────────────────────
|
|
173
|
+
// A route with `rawBody` reads the stream itself (the forms endpoint
|
|
174
|
+
// accepts form-encoded bodies, which a failed .json() would consume).
|
|
175
|
+
let body = null;
|
|
176
|
+
if (!route.rawBody && (request.method === "POST" || request.method === "PUT" || request.method === "PATCH")) {
|
|
177
|
+
try {
|
|
178
|
+
body = await request.json();
|
|
179
|
+
} catch {
|
|
180
|
+
body = null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const params = {};
|
|
185
|
+
route.paramNames.forEach((name, i) => {
|
|
186
|
+
params[name] = decodeParam(m[i + 1]);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const ctx = {
|
|
190
|
+
params,
|
|
191
|
+
query: queryOf(url),
|
|
192
|
+
body,
|
|
193
|
+
user,
|
|
194
|
+
request,
|
|
195
|
+
runtime,
|
|
196
|
+
adminUiVersion,
|
|
197
|
+
header: (name) => request.headers.get(name),
|
|
198
|
+
env: (name) => (env && name in env ? env[name] : globalThis.process?.env?.[name]),
|
|
199
|
+
adapters: adapterBag,
|
|
200
|
+
config,
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
return toResponse(await route.handler(ctx), cors);
|
|
205
|
+
} catch (err) {
|
|
206
|
+
return toResponse({ status: 500, json: { error: err.message } }, cors);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node ↔ Fetch transport bridge.
|
|
3
|
+
*
|
|
4
|
+
* Translates Node's IncomingMessage/ServerResponse to the Web Fetch
|
|
5
|
+
* Request/Response the CMS handler speaks, and back. This layer makes no
|
|
6
|
+
* decisions — no auth, no roles, no routing. It only moves bytes, which is
|
|
7
|
+
* what a transport adapter should do.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Readable } from "node:stream";
|
|
11
|
+
|
|
12
|
+
const DEFAULT_BODY_LIMIT = 10 * 1024 * 1024; // 10 MB, matching the old express.json limit
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Buffer a request body. Bodies here are JSON documents (pages, templates,
|
|
16
|
+
* base64 media uploads), so buffering keeps the bridge simple and avoids
|
|
17
|
+
* half-duplex streaming caveats.
|
|
18
|
+
*/
|
|
19
|
+
async function readBody(req, limit) {
|
|
20
|
+
const chunks = [];
|
|
21
|
+
let size = 0;
|
|
22
|
+
for await (const chunk of req) {
|
|
23
|
+
size += chunk.length;
|
|
24
|
+
if (size > limit) {
|
|
25
|
+
const err = new Error("Request body too large");
|
|
26
|
+
err.status = 413;
|
|
27
|
+
throw err;
|
|
28
|
+
}
|
|
29
|
+
chunks.push(chunk);
|
|
30
|
+
}
|
|
31
|
+
return Buffer.concat(chunks);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** IncomingMessage → Request. */
|
|
35
|
+
export async function nodeToRequest(req, { bodyLimit = DEFAULT_BODY_LIMIT } = {}) {
|
|
36
|
+
const proto = req.headers["x-forwarded-proto"] || "http";
|
|
37
|
+
const host = req.headers.host || "localhost";
|
|
38
|
+
const url = new URL(req.url || "/", `${proto}://${host}`);
|
|
39
|
+
|
|
40
|
+
const headers = new Headers();
|
|
41
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
42
|
+
if (value === undefined) continue;
|
|
43
|
+
if (Array.isArray(value)) value.forEach((v) => headers.append(key, v));
|
|
44
|
+
else headers.set(key, value);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const method = req.method || "GET";
|
|
48
|
+
let body;
|
|
49
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
50
|
+
// A connect stack upstream may have parsed the body already.
|
|
51
|
+
if (req.body !== undefined) {
|
|
52
|
+
body = typeof req.body === "string" ? req.body : JSON.stringify(req.body);
|
|
53
|
+
} else {
|
|
54
|
+
const buffered = await readBody(req, bodyLimit);
|
|
55
|
+
body = buffered.length ? buffered : undefined;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return new Request(url, { method, headers, body });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Response → ServerResponse. */
|
|
63
|
+
export async function writeNodeResponse(res, response) {
|
|
64
|
+
const headers = {};
|
|
65
|
+
for (const [key, value] of response.headers) headers[key] = value;
|
|
66
|
+
res.writeHead(response.status, headers);
|
|
67
|
+
|
|
68
|
+
if (!response.body) {
|
|
69
|
+
res.end();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
Readable.fromWeb(response.body).pipe(res);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Wrap a Fetch-style handler as connect/Vite-compatible middleware.
|
|
77
|
+
* When the handler returns null the request is passed to `next()`, so the
|
|
78
|
+
* CMS can be mounted alongside a site's own middleware stack.
|
|
79
|
+
*
|
|
80
|
+
* @param {(request: Request) => Promise<Response|null>} handle
|
|
81
|
+
*/
|
|
82
|
+
export function toNodeMiddleware(handle) {
|
|
83
|
+
return async function cmsMiddleware(req, res, next) {
|
|
84
|
+
try {
|
|
85
|
+
const request = await nodeToRequest(req);
|
|
86
|
+
const response = await handle(request);
|
|
87
|
+
if (!response) {
|
|
88
|
+
if (next) return next();
|
|
89
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
90
|
+
return res.end("Not found");
|
|
91
|
+
}
|
|
92
|
+
await writeNodeResponse(res, response);
|
|
93
|
+
} catch (err) {
|
|
94
|
+
const status = err.status || 500;
|
|
95
|
+
if (!res.headersSent) res.writeHead(status, { "Content-Type": "application/json" });
|
|
96
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal static file serving for the Node runtime.
|
|
3
|
+
*
|
|
4
|
+
* Replaces express.static / res.sendFile so the server has no web-framework
|
|
5
|
+
* dependency. Like the Node bridge, this makes no policy decisions.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { Readable } from "node:stream";
|
|
11
|
+
|
|
12
|
+
const TYPES = {
|
|
13
|
+
".html": "text/html; charset=utf-8",
|
|
14
|
+
".js": "text/javascript; charset=utf-8",
|
|
15
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
16
|
+
".css": "text/css; charset=utf-8",
|
|
17
|
+
".json": "application/json; charset=utf-8",
|
|
18
|
+
".map": "application/json; charset=utf-8",
|
|
19
|
+
".svg": "image/svg+xml",
|
|
20
|
+
".png": "image/png",
|
|
21
|
+
".jpg": "image/jpeg",
|
|
22
|
+
".jpeg": "image/jpeg",
|
|
23
|
+
".gif": "image/gif",
|
|
24
|
+
".webp": "image/webp",
|
|
25
|
+
".avif": "image/avif",
|
|
26
|
+
".ico": "image/x-icon",
|
|
27
|
+
".woff": "font/woff",
|
|
28
|
+
".woff2": "font/woff2",
|
|
29
|
+
".ttf": "font/ttf",
|
|
30
|
+
".txt": "text/plain; charset=utf-8",
|
|
31
|
+
".xml": "application/xml; charset=utf-8",
|
|
32
|
+
".pdf": "application/pdf",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function contentTypeFor(filePath) {
|
|
36
|
+
return TYPES[path.extname(filePath).toLowerCase()] || "application/octet-stream";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resolve a URL sub-path inside `root`, refusing anything that escapes it.
|
|
41
|
+
* The trailing-separator comparison matters: a bare startsWith(root) would
|
|
42
|
+
* also accept a sibling directory such as "<root>-other".
|
|
43
|
+
*/
|
|
44
|
+
export function resolveWithin(root, subPath) {
|
|
45
|
+
const rel = decodeURIComponent(String(subPath)).replace(/^\/+/, "");
|
|
46
|
+
const full = path.resolve(root, rel);
|
|
47
|
+
if (full !== root && !full.startsWith(root + path.sep)) return null;
|
|
48
|
+
return full;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Send one file as a Response, or null when it is missing/not a file. */
|
|
52
|
+
export function fileResponse(filePath, { status = 200 } = {}) {
|
|
53
|
+
let stat;
|
|
54
|
+
try {
|
|
55
|
+
stat = fs.statSync(filePath);
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
if (!stat.isFile()) return null;
|
|
60
|
+
|
|
61
|
+
return new Response(Readable.toWeb(fs.createReadStream(filePath)), {
|
|
62
|
+
status,
|
|
63
|
+
headers: {
|
|
64
|
+
"Content-Type": contentTypeFor(filePath),
|
|
65
|
+
"Content-Length": String(stat.size),
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Serve `root` under the URL prefix `mount`.
|
|
72
|
+
*
|
|
73
|
+
* @param {Object} opts
|
|
74
|
+
* @param {string} opts.root Absolute directory to serve from.
|
|
75
|
+
* @param {string} opts.mount URL prefix, e.g. "/admin" or "/src/assets".
|
|
76
|
+
* @param {boolean} [opts.spaFallback] Serve index.html for unmatched paths.
|
|
77
|
+
* @returns {(request: Request) => Response|null}
|
|
78
|
+
*/
|
|
79
|
+
export function createStaticHandler({ root, mount, spaFallback = false }) {
|
|
80
|
+
const prefix = mount.replace(/\/+$/, "");
|
|
81
|
+
|
|
82
|
+
return function serveStatic(request) {
|
|
83
|
+
if (request.method !== "GET" && request.method !== "HEAD") return null;
|
|
84
|
+
|
|
85
|
+
const url = new URL(request.url);
|
|
86
|
+
const { pathname } = url;
|
|
87
|
+
if (pathname !== prefix && !pathname.startsWith(`${prefix}/`)) return null;
|
|
88
|
+
|
|
89
|
+
// `/admin` → `/admin/`. The SPA's index.html references its bundle
|
|
90
|
+
// relatively (`./assets/…`), and a browser resolves that against `/admin`
|
|
91
|
+
// as `/assets/…` — which 404s, leaving a blank page with no error to go on.
|
|
92
|
+
// Serving index.html at the bare mount is what makes it look like the
|
|
93
|
+
// admin UI is broken; the slash is what makes the relative URLs resolve.
|
|
94
|
+
if (prefix && pathname === prefix) {
|
|
95
|
+
return new Response(null, {
|
|
96
|
+
status: 301,
|
|
97
|
+
headers: { Location: `${prefix}/${url.search}` },
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const sub = pathname.slice(prefix.length) || "/";
|
|
102
|
+
const target = resolveWithin(root, sub === "/" ? "index.html" : sub);
|
|
103
|
+
if (!target) return null;
|
|
104
|
+
|
|
105
|
+
const direct = fileResponse(target);
|
|
106
|
+
if (direct) return direct;
|
|
107
|
+
|
|
108
|
+
// Directory → its index.html
|
|
109
|
+
const asIndex = fileResponse(path.join(target, "index.html"));
|
|
110
|
+
if (asIndex) return asIndex;
|
|
111
|
+
|
|
112
|
+
if (spaFallback) return fileResponse(path.join(root, "index.html"));
|
|
113
|
+
return null;
|
|
114
|
+
};
|
|
115
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derive a safe browser-facing config from the full cms.config object.
|
|
3
|
+
*
|
|
4
|
+
* Never exposes: auth credentials env var names, build tokens, git config,
|
|
5
|
+
* GitHub OAuth secrets, content adapter internals.
|
|
6
|
+
*
|
|
7
|
+
* Used automatically by createCmsServer when the consumer does not supply
|
|
8
|
+
* their own publicConfig function.
|
|
9
|
+
*
|
|
10
|
+
* @param {Object} config Full cms.config object
|
|
11
|
+
* @param {Object} [overrides={}] Extra fields merged in at the end (shallow)
|
|
12
|
+
* @returns {Object} Safe subset for the browser
|
|
13
|
+
*/
|
|
14
|
+
export function defaultPublicConfig(config, overrides = {}) {
|
|
15
|
+
const cfg = {
|
|
16
|
+
mountPath: config.mountPath,
|
|
17
|
+
locales: config.locales,
|
|
18
|
+
defaultLocale: config.defaultLocale,
|
|
19
|
+
previewUrlPattern: config.previewUrlPattern,
|
|
20
|
+
media: config.media,
|
|
21
|
+
collections: config.collections,
|
|
22
|
+
blocks: config.blocks,
|
|
23
|
+
content: {
|
|
24
|
+
provider: config.content?.provider || "fs",
|
|
25
|
+
// Whether saving and publishing are separate steps. False only on the
|
|
26
|
+
// github backend without a draft branch — the admin hides Publish there,
|
|
27
|
+
// because saving already published.
|
|
28
|
+
deferredPublish:
|
|
29
|
+
(config.content?.provider || "fs") !== "github" || !!config.content?.draftBranch,
|
|
30
|
+
list: {
|
|
31
|
+
strategy: config.content?.list?.strategy || "index",
|
|
32
|
+
rebuild: config.content?.list?.rebuild || "build",
|
|
33
|
+
indexFile: config.content?.list?.indexFile || "_index.json",
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
auth: { provider: config.auth?.provider || "basic" },
|
|
37
|
+
};
|
|
38
|
+
return Object.assign(cfg, overrides);
|
|
39
|
+
}
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export {
|
|
2
|
+
createCmsServer,
|
|
3
|
+
resolveAdminUi,
|
|
4
|
+
resolveAdminUiOptions,
|
|
5
|
+
startCmsServer,
|
|
6
|
+
startScheduler,
|
|
7
|
+
} from "./server.mjs";
|
|
8
|
+
export { createRequestHandler } from "./core/handler.mjs";
|
|
9
|
+
export { toNodeMiddleware, nodeToRequest, writeNodeResponse } from "./core/node-adapter.mjs";
|
|
10
|
+
export { defaultPublicConfig } from "./default-public-config.mjs";
|
|
11
|
+
export {
|
|
12
|
+
createFsJsonContent,
|
|
13
|
+
createFsTemplates,
|
|
14
|
+
createGitHubContent,
|
|
15
|
+
createGitHubTemplates,
|
|
16
|
+
createLocalAssetsMedia,
|
|
17
|
+
createCdnProxyMedia,
|
|
18
|
+
createBasicAuth,
|
|
19
|
+
createGitHubOAuth,
|
|
20
|
+
createNetlifyBuild,
|
|
21
|
+
createMediaUrl,
|
|
22
|
+
} from "./adapters/index.mjs";
|