@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.
@@ -0,0 +1,272 @@
1
+ /**
2
+ * GitHub Contents API adapter — serverless content backend.
3
+ *
4
+ * Every writePage/createPage/deletePage is an immediate commit to GitHub.
5
+ * No git binary required. Safe to run on Vercel, Cloudflare Workers, etc.
6
+ *
7
+ * Config (cms.config.mjs):
8
+ * content: {
9
+ * provider: "github",
10
+ * githubTokenEnv: "GITHUB_TOKEN", // PAT with repo scope
11
+ * owner: "your-org",
12
+ * repo: "your-site",
13
+ * branch: "main",
14
+ * pagesDir: "src/pages-data",
15
+ * commitMessage: (ts) => `Content updated ${ts}`,
16
+ * }
17
+ *
18
+ * History is backed by GitHub's commit history for each file.
19
+ * restoreHistory treats the `ts` field as a commit SHA.
20
+ *
21
+ * @param {Object} opts
22
+ * @param {string} opts.token
23
+ * @param {string} opts.owner
24
+ * @param {string} opts.repo
25
+ * @param {string} opts.branch
26
+ * @param {string} opts.pagesDir
27
+ * @param {(ts: string) => string} opts.commitMessage
28
+ * @returns {import('./types.mjs').ContentAdapter}
29
+ */
30
+ export function createGitHubContent({ token, owner, repo, branch, pagesDir, commitMessage }) {
31
+ const BASE = `https://api.github.com/repos/${owner}/${repo}`;
32
+ // SHA cache: avoid extra GET before every PUT. Invalidated on write.
33
+ const shaCache = new Map();
34
+
35
+ const headers = () => ({
36
+ Authorization: `Bearer ${token}`,
37
+ Accept: "application/vnd.github+json",
38
+ "X-GitHub-Api-Version": "2022-11-28",
39
+ "User-Agent": "natilon-cms",
40
+ "Content-Type": "application/json",
41
+ });
42
+
43
+ async function apiGet(path) {
44
+ const r = await fetch(`${BASE}${path}`, { headers: headers() });
45
+ if (r.status === 404) return null;
46
+ if (!r.ok) throw new Error(`GitHub API ${r.status} GET ${path}`);
47
+ return r.json();
48
+ }
49
+
50
+ async function apiPut(path, body) {
51
+ const r = await fetch(`${BASE}${path}`, {
52
+ method: "PUT",
53
+ headers: headers(),
54
+ body: JSON.stringify(body),
55
+ });
56
+ if (!r.ok) {
57
+ const err = await r.json().catch(() => ({}));
58
+ throw new Error(`GitHub API ${r.status} PUT ${path}: ${err.message || ""}`);
59
+ }
60
+ return r.json();
61
+ }
62
+
63
+ async function apiDelete(path, body) {
64
+ const r = await fetch(`${BASE}${path}`, {
65
+ method: "DELETE",
66
+ headers: headers(),
67
+ body: JSON.stringify(body),
68
+ });
69
+ if (!r.ok) throw new Error(`GitHub API ${r.status} DELETE ${path}`);
70
+ return r.json();
71
+ }
72
+
73
+ function contentPath(collection, file) {
74
+ return `${pagesDir}/${collection}/${file}`;
75
+ }
76
+
77
+ async function getFileSha(path) {
78
+ if (shaCache.has(path)) return shaCache.get(path);
79
+ const data = await apiGet(`/contents/${path}?ref=${branch}`);
80
+ if (!data || Array.isArray(data)) return null;
81
+ shaCache.set(path, data.sha);
82
+ return data.sha;
83
+ }
84
+
85
+ function decodeContent(data) {
86
+ const raw = Buffer.from(data.content, "base64").toString("utf-8");
87
+ return JSON.parse(raw);
88
+ }
89
+
90
+ function encodeContent(obj) {
91
+ return Buffer.from(JSON.stringify(obj, null, 2), "utf-8").toString("base64");
92
+ }
93
+
94
+ function commitMsg() {
95
+ const ts = new Date().toISOString().replace("T", " ").slice(0, 19);
96
+ return commitMessage ? commitMessage(ts) : `Content updated ${ts}`;
97
+ }
98
+
99
+ function sanitize(p) {
100
+ return String(p).replace(/[^a-zA-Z0-9._-]/g, "");
101
+ }
102
+
103
+ return {
104
+ async listCollections() {
105
+ const items = await apiGet(`/contents/${pagesDir}?ref=${branch}`);
106
+ if (!items || !Array.isArray(items)) return [];
107
+ const dirs = items.filter((i) => i.type === "dir");
108
+ return Promise.all(
109
+ dirs.map(async (d) => {
110
+ const files = await apiGet(`/contents/${pagesDir}/${d.name}?ref=${branch}`);
111
+ const count = Array.isArray(files) ? files.filter((f) => f.name.endsWith(".json")).length : 0;
112
+ return { name: d.name, count };
113
+ }),
114
+ );
115
+ },
116
+
117
+ async listPages(collection, sortConfig = null) {
118
+ const items = await apiGet(`/contents/${pagesDir}/${collection}?ref=${branch}`);
119
+ if (!Array.isArray(items)) return null;
120
+ const jsonFiles = items.filter((i) => i.type === "file" && i.name.endsWith(".json"));
121
+
122
+ const pages = await Promise.all(
123
+ jsonFiles.map(async (item) => {
124
+ const fileData = await apiGet(`/contents/${item.path}?ref=${branch}`);
125
+ if (!fileData) return null;
126
+ shaCache.set(item.path, fileData.sha);
127
+ const data = decodeContent(fileData);
128
+ return {
129
+ id: data.id,
130
+ slug: data.slug,
131
+ lang: data.lang,
132
+ collection: data.collection,
133
+ title: data.meta?.title || data.meta?.name || item.name,
134
+ file: item.name,
135
+ meta: data.meta || {},
136
+ };
137
+ }),
138
+ );
139
+
140
+ const valid = pages.filter(Boolean);
141
+ if (sortConfig) {
142
+ const dir = sortConfig.direction === "desc" ? -1 : 1;
143
+ valid.sort((a, b) => {
144
+ const av = a.meta?.[sortConfig.field] ?? "";
145
+ const bv = b.meta?.[sortConfig.field] ?? "";
146
+ if (av < bv) return -1 * dir;
147
+ if (av > bv) return 1 * dir;
148
+ return 0;
149
+ });
150
+ } else {
151
+ valid.sort((a, b) => a.title.localeCompare(b.title));
152
+ }
153
+ return valid;
154
+ },
155
+
156
+ async readPage(collection, file) {
157
+ const path = contentPath(collection, sanitize(file));
158
+ const data = await apiGet(`/contents/${path}?ref=${branch}`);
159
+ if (!data || Array.isArray(data)) return null;
160
+ shaCache.set(path, data.sha);
161
+ return decodeContent(data);
162
+ },
163
+
164
+ async writePage(collection, file, data) {
165
+ const path = contentPath(collection, sanitize(file));
166
+ const sha = await getFileSha(path);
167
+ const result = await apiPut(`/contents/${path}`, {
168
+ message: commitMsg(),
169
+ content: encodeContent(data),
170
+ branch,
171
+ ...(sha ? { sha } : {}),
172
+ });
173
+ shaCache.set(path, result.content?.sha);
174
+ },
175
+
176
+ async createPage(collection, data) {
177
+ const slug = data.slug || data.id || `new-${Date.now()}`;
178
+ const fileName = `${sanitize(data.lang || "en")}-${sanitize(slug)}.json`;
179
+ const path = contentPath(collection, fileName);
180
+ const result = await apiPut(`/contents/${path}`, {
181
+ message: commitMsg(),
182
+ content: encodeContent(data),
183
+ branch,
184
+ });
185
+ shaCache.set(path, result.content?.sha);
186
+ return { file: fileName };
187
+ },
188
+
189
+ async deletePage(collection, file) {
190
+ const path = contentPath(collection, sanitize(file));
191
+ const sha = await getFileSha(path);
192
+ if (!sha) return false;
193
+ await apiDelete(`/contents/${path}`, { message: commitMsg(), sha, branch });
194
+ shaCache.delete(path);
195
+ return true;
196
+ },
197
+
198
+ async duplicatePage(collection, file) {
199
+ const src = await this.readPage(collection, file);
200
+ if (!src) return null;
201
+ const lang = src.lang || "en";
202
+ const baseSlug = (src.slug || "page").replace(/-copy(-\d+)?$/, "");
203
+ const newSlug = `${baseSlug}-copy-${Math.floor(Date.now() / 1000)}`;
204
+
205
+ function reid(blocks) {
206
+ if (!Array.isArray(blocks)) return blocks;
207
+ return blocks.map((b) => {
208
+ const next = { ...b, id: "b-" + Math.random().toString(36).slice(2, 9) };
209
+ if (Array.isArray(b.children)) next.children = b.children.map((col) => reid(col));
210
+ return next;
211
+ });
212
+ }
213
+
214
+ const data = {
215
+ ...src,
216
+ id: `${lang}/${newSlug}`,
217
+ slug: newSlug,
218
+ meta: { ...(src.meta || {}), title: `${src.meta?.title || newSlug} (Copy)` },
219
+ blocks: reid(src.blocks || []),
220
+ };
221
+ return this.createPage(collection, data);
222
+ },
223
+
224
+ async pendingChanges() {
225
+ // Every write is already committed — nothing is ever "pending"
226
+ return { hasChanges: false, changedFiles: 0 };
227
+ },
228
+
229
+ async publish() {
230
+ // Writes are committed instantly; trigger is external (Netlify webhook on push)
231
+ return { ok: true, message: "All changes are already committed to GitHub" };
232
+ },
233
+
234
+ async listHistory(collection, file) {
235
+ const path = contentPath(collection, sanitize(file));
236
+ const commits = await apiGet(`/commits?path=${encodeURIComponent(path)}&per_page=20&sha=${branch}`);
237
+ if (!Array.isArray(commits)) return [];
238
+ return commits.map((c) => ({
239
+ ts: c.sha,
240
+ label: c.commit.message,
241
+ date: c.commit.author?.date,
242
+ author: c.commit.author?.name || c.commit.author?.email || "",
243
+ size: null,
244
+ }));
245
+ },
246
+
247
+ async restoreHistory(collection, file, commitSha) {
248
+ const path = contentPath(collection, sanitize(file));
249
+ const fileData = await apiGet(`/contents/${path}?ref=${commitSha}`);
250
+ if (!fileData || Array.isArray(fileData)) return false;
251
+ const data = decodeContent(fileData);
252
+ await this.writePage(collection, file, data);
253
+ return true;
254
+ },
255
+
256
+ async listScheduled() {
257
+ const collections = await this.listCollections();
258
+ const now = new Date();
259
+ const due = [];
260
+ for (const { name } of collections) {
261
+ const pages = await this.listPages(name);
262
+ for (const page of pages || []) {
263
+ if (page.meta?.publishAt && new Date(page.meta.publishAt) <= now) {
264
+ const data = await this.readPage(name, page.file);
265
+ if (data?.meta?.publishAt) due.push({ collection: name, file: page.file, data });
266
+ }
267
+ }
268
+ }
269
+ return due;
270
+ },
271
+ };
272
+ }
@@ -0,0 +1,174 @@
1
+ import crypto from "crypto";
2
+
3
+ const GITHUB_AUTH_URL = "https://github.com/login/oauth/authorize";
4
+ const GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token";
5
+ const GITHUB_USER_URL = "https://api.github.com/user";
6
+
7
+ /**
8
+ * GitHub OAuth adapter — drop-in replacement for createBasicAuth.
9
+ *
10
+ * Config (cms.config.mjs):
11
+ * auth: {
12
+ * provider: "github-oauth",
13
+ * githubClientIdEnv: "GITHUB_CLIENT_ID",
14
+ * githubClientSecretEnv: "GITHUB_CLIENT_SECRET",
15
+ * allowedLogins: ["your-github-username"], // whitelist
16
+ * jwtSecretEnv: "JWT_SECRET",
17
+ * jwtTtl: 8 * 60 * 60,
18
+ * }
19
+ *
20
+ * Add to your .env:
21
+ * GITHUB_CLIENT_ID=Ov23li...
22
+ * GITHUB_CLIENT_SECRET=...
23
+ *
24
+ * Register callback URL in your GitHub OAuth App:
25
+ * http://localhost:4001/admin/oauth/callback (dev)
26
+ * https://admin.yoursite.com/admin/oauth/callback (prod)
27
+ *
28
+ * @param {Object} opts
29
+ * @param {string} opts.clientId
30
+ * @param {string} opts.clientSecret
31
+ * @param {string[]} opts.allowedLogins GitHub usernames allowed in
32
+ * @param {string} opts.jwtSecret
33
+ * @param {number} opts.jwtTtl Session lifetime in seconds
34
+ * @param {string} [opts.realm]
35
+ * @returns {import('./types.mjs').AuthAdapter & { oauthRoutes: { login, callback } }}
36
+ */
37
+ export function createGitHubOAuth({
38
+ clientId,
39
+ clientSecret,
40
+ allowedLogins = [],
41
+ roles = {},
42
+ jwtSecret,
43
+ jwtTtl = 8 * 60 * 60,
44
+ realm = "Admin",
45
+ }) {
46
+ function getRole(login) {
47
+ if (roles[login]) return roles[login];
48
+ if (roles["*"]) return roles["*"];
49
+ return "admin";
50
+ }
51
+ const configured = Boolean(clientId && clientSecret);
52
+
53
+ function sign(header, payload) {
54
+ return crypto
55
+ .createHmac("sha256", jwtSecret)
56
+ .update(`${header}.${payload}`)
57
+ .digest("base64url");
58
+ }
59
+
60
+ function issueToken(claims) {
61
+ if (!jwtSecret) throw new Error("JWT_SECRET not set");
62
+ const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url");
63
+ const payload = Buffer.from(JSON.stringify({
64
+ iat: Math.floor(Date.now() / 1000),
65
+ exp: Math.floor(Date.now() / 1000) + jwtTtl,
66
+ ...claims,
67
+ })).toString("base64url");
68
+ return `${header}.${payload}.${sign(header, payload)}`;
69
+ }
70
+
71
+ function verifyToken(tokenStr) {
72
+ if (!jwtSecret) return null;
73
+ const parts = (tokenStr || "").split(".");
74
+ if (parts.length !== 3) return null;
75
+ const [header, payload, sig] = parts;
76
+ if (sign(header, payload) !== sig) return null;
77
+ try {
78
+ const data = JSON.parse(Buffer.from(payload, "base64url").toString());
79
+ if (data.exp < Math.floor(Date.now() / 1000)) return null;
80
+ return data;
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
85
+
86
+ function middlewareWith(allowlist) {
87
+ return (req, res, next) => {
88
+ if (allowlist && allowlist(req)) return next();
89
+ const authHeader = req.headers.authorization || "";
90
+ if (authHeader.startsWith("Bearer ")) {
91
+ const claims = verifyToken(authHeader.slice(7));
92
+ if (claims?.type === "session") {
93
+ req.cmsUser = { login: claims.sub, role: getRole(claims.sub), name: claims.name };
94
+ return next();
95
+ }
96
+ }
97
+ res.status(401).json({ error: "Authentication required", loginUrl: "/admin/oauth/login" });
98
+ };
99
+ }
100
+
101
+ // OAuth route handlers — mounted by server.mjs
102
+ const oauthRoutes = {
103
+ login(req, res) {
104
+ const state = crypto.randomBytes(16).toString("hex");
105
+ const callbackUrl = `${req.protocol}://${req.get("host")}/admin/oauth/callback`;
106
+ const params = new URLSearchParams({
107
+ client_id: clientId,
108
+ redirect_uri: callbackUrl,
109
+ scope: "read:user",
110
+ state,
111
+ });
112
+ // state is stateless here — for production add a cookie/session store
113
+ res.redirect(`${GITHUB_AUTH_URL}?${params.toString()}`);
114
+ },
115
+
116
+ async callback(req, res) {
117
+ const { code } = req.query;
118
+ if (!code) return res.status(400).send("Missing code");
119
+
120
+ try {
121
+ const callbackUrl = `${req.protocol}://${req.get("host")}/admin/oauth/callback`;
122
+
123
+ // Exchange code for GitHub access token
124
+ const tokenRes = await fetch(GITHUB_TOKEN_URL, {
125
+ method: "POST",
126
+ headers: { Accept: "application/json", "Content-Type": "application/json" },
127
+ body: JSON.stringify({ client_id: clientId, client_secret: clientSecret, code, redirect_uri: callbackUrl }),
128
+ });
129
+ const tokenData = await tokenRes.json();
130
+ if (!tokenData.access_token) {
131
+ return res.status(401).send("GitHub OAuth failed: " + (tokenData.error_description || tokenData.error || "unknown"));
132
+ }
133
+
134
+ // Get GitHub user identity
135
+ const userRes = await fetch(GITHUB_USER_URL, {
136
+ headers: { Authorization: `Bearer ${tokenData.access_token}`, "User-Agent": "natilon-cms" },
137
+ });
138
+ const user = await userRes.json();
139
+
140
+ if (allowedLogins.length > 0 && !allowedLogins.includes(user.login)) {
141
+ return res.status(403).send(`GitHub user "${user.login}" is not authorised for this CMS.`);
142
+ }
143
+
144
+ const sessionToken = issueToken({ sub: user.login, type: "session", name: user.name || user.login });
145
+ res.redirect(`/admin/?token=${encodeURIComponent(sessionToken)}`);
146
+ } catch (err) {
147
+ res.status(500).send("OAuth error: " + err.message);
148
+ }
149
+ },
150
+ };
151
+
152
+ function verify(authHeader) {
153
+ if (!authHeader?.startsWith("Bearer ")) return null;
154
+ const claims = verifyToken(authHeader.slice(7));
155
+ if (!claims || claims.type !== "session") return null;
156
+ return { login: claims.sub, role: getRole(claims.sub), name: claims.name };
157
+ }
158
+
159
+ function issueSessionToken(login, name) {
160
+ return issueToken({ sub: login, type: "session", name });
161
+ }
162
+
163
+ return {
164
+ configured,
165
+ middleware: middlewareWith(null),
166
+ middlewareWith,
167
+ issueMediaToken(tenantId) {
168
+ return issueToken({ sub: "media", tenant_id: tenantId, type: "media" });
169
+ },
170
+ verify,
171
+ issueSessionToken,
172
+ oauthRoutes,
173
+ };
174
+ }
@@ -0,0 +1,8 @@
1
+ export { createFsJsonContent } from "./fs-json-content.mjs";
2
+ export { createGitHubContent } from "./github-content.mjs";
3
+ export { createLocalAssetsMedia } from "./local-assets-media.mjs";
4
+ export { createCdnProxyMedia } from "./cdn-proxy-media.mjs";
5
+ export { createBasicAuth } from "./basic-auth.mjs";
6
+ export { createGitHubOAuth } from "./github-oauth.mjs";
7
+ export { createNetlifyBuild } from "./build-netlify.mjs";
8
+ export { createMediaUrl } from "./media-url.mjs";
@@ -0,0 +1,69 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+
4
+ const IMAGE_RE = /\.(png|jpg|jpeg|svg|webp|gif)$/i;
5
+
6
+ /**
7
+ * Local-filesystem media adapter for the legacy file picker.
8
+ * Lists images grouped by top-level folder under `<rootDir>/<assetsDir>`.
9
+ *
10
+ * @param {Object} opts
11
+ * @param {string} opts.rootDir
12
+ * @param {string} opts.assetsDir Relative to rootDir, e.g. "src/assets".
13
+ * @returns {import('./types.mjs').MediaAdapter & {urlPrefix: string}}
14
+ */
15
+ export function createLocalAssetsMedia({ rootDir, assetsDir }) {
16
+ const ROOT = path.join(rootDir, assetsDir);
17
+ const URL_PREFIX = `/${assetsDir.replace(/^\/+/, "")}`;
18
+
19
+ function sanitize(p) {
20
+ return String(p).replace(/[^a-zA-Z0-9._-]/g, "");
21
+ }
22
+
23
+ function listImages(dir, prefix) {
24
+ const out = [];
25
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
26
+ for (const entry of entries) {
27
+ const full = path.join(dir, entry.name);
28
+ if (entry.isDirectory()) {
29
+ out.push(...listImages(full, `${prefix}/${entry.name}`));
30
+ } else if (IMAGE_RE.test(entry.name)) {
31
+ out.push(`${prefix}/${entry.name}`);
32
+ }
33
+ }
34
+ return out;
35
+ }
36
+
37
+ return {
38
+ async listGrouped() {
39
+ if (!fs.existsSync(ROOT)) return {};
40
+ const folders = fs
41
+ .readdirSync(ROOT)
42
+ .filter((d) => fs.statSync(path.join(ROOT, d)).isDirectory());
43
+ const out = {};
44
+ for (const folder of folders) {
45
+ const files = listImages(
46
+ path.join(ROOT, folder),
47
+ `${URL_PREFIX}/${folder}`,
48
+ );
49
+ if (files.length > 0) out[folder] = files;
50
+ }
51
+ return out;
52
+ },
53
+
54
+ async listFolder(folder) {
55
+ const dir = path.join(ROOT, sanitize(folder));
56
+ if (!fs.existsSync(dir)) return [];
57
+ return listImages(dir, `${URL_PREFIX}/${sanitize(folder)}`);
58
+ },
59
+
60
+ resolveLocalPath(relPath) {
61
+ const sub = String(relPath).replace(/^\/+/, "");
62
+ const full = path.join(ROOT, sub);
63
+ if (!full.startsWith(ROOT) || !fs.existsSync(full)) return null;
64
+ return full;
65
+ },
66
+
67
+ urlPrefix: URL_PREFIX,
68
+ };
69
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Build CDN URLs and responsive <img> props for media stored behind a
3
+ * CloudFront/S3 (or compatible) origin with a path-based resize behavior.
4
+ *
5
+ * Pure factory — no environment, no fs. Configure once with the consumer's
6
+ * `cms.config.media` settings; everything else is derived.
7
+ *
8
+ * @typedef {Object} MediaUrlConfig
9
+ * @property {string} cdnBase e.g. "https://media.natilon.com"
10
+ * @property {string} [resizePrefix="/_r/"] path prefix for the resize behavior
11
+ * @property {number[]} [defaultWidths] fallback srcset widths
12
+ * @property {string} [tenantId] multi-tenant prefix, e.g. "natilon"
13
+ *
14
+ * @typedef {Object} ResizeParams
15
+ * @property {number} [w]
16
+ * @property {number} [h]
17
+ * @property {number} [q]
18
+ * @property {"cover"|"contain"|"fill"|"inside"|"outside"} [fit]
19
+ * @property {"webp"|"avif"|"jpg"|"png"} [f]
20
+ */
21
+
22
+ /**
23
+ * @param {MediaUrlConfig} config
24
+ */
25
+ export function createMediaUrl(config) {
26
+ const cdnBase = String(config.cdnBase || "").replace(/\/+$/, "");
27
+ const resizePrefix = (config.resizePrefix || "/_r/").replace(/\/+$/, "/");
28
+ const defaultWidths = config.defaultWidths || [400, 800, 1200, 1600, 2400];
29
+ const tenantId = config.tenantId ? `/p/${config.tenantId}` : "";
30
+ let cdnHost = "";
31
+ try {
32
+ cdnHost = new URL(cdnBase).hostname;
33
+ } catch {
34
+ /* empty cdnBase or invalid URL — url() will still work for absolute inputs */
35
+ }
36
+
37
+ function buildQuery(params) {
38
+ const qs = new URLSearchParams();
39
+ if (params?.w) qs.set("w", String(params.w));
40
+ if (params?.h) qs.set("h", String(params.h));
41
+ if (params?.q) qs.set("q", String(params.q));
42
+ if (params?.fit) qs.set("fit", params.fit);
43
+ if (params?.f) qs.set("f", params.f);
44
+ return qs.toString();
45
+ }
46
+
47
+ /** Build a CDN URL from a key or a full URL, optionally with resize params. */
48
+ function url(keyOrUrl, params) {
49
+ if (!keyOrUrl) return "";
50
+ const query = buildQuery(params);
51
+
52
+ if (keyOrUrl.startsWith("http://") || keyOrUrl.startsWith("https://")) {
53
+ if (!query) return keyOrUrl;
54
+ try {
55
+ const u = new URL(keyOrUrl);
56
+ if (cdnHost && u.hostname === cdnHost) {
57
+ const r = resizePrefix.replace(/\/$/, "");
58
+ if (tenantId && u.pathname.startsWith(tenantId + "/")) {
59
+ const rest = u.pathname.slice(tenantId.length);
60
+ if (!rest.startsWith(resizePrefix)) {
61
+ u.pathname = tenantId + r + rest;
62
+ }
63
+ } else if (!u.pathname.startsWith(resizePrefix)) {
64
+ u.pathname = r + u.pathname;
65
+ }
66
+ }
67
+ const existing = u.search.replace(/^\?/, "");
68
+ u.search = existing ? `${existing}&${query}` : query;
69
+ return u.toString();
70
+ } catch {
71
+ return `${keyOrUrl}${keyOrUrl.includes("?") ? "&" : "?"}${query}`;
72
+ }
73
+ }
74
+
75
+ const key = keyOrUrl.replace(/^\/+/, "");
76
+ if (!query) return `${cdnBase}${tenantId}/${key}`;
77
+ return `${cdnBase}${tenantId}${resizePrefix}${key}?${query}`;
78
+ }
79
+
80
+ /** Resolve a stored value (key, full URL, or bare name + folder hint) to a CDN URL. */
81
+ function resolve(value, folder, params) {
82
+ if (!value) return "";
83
+ if (value.startsWith("http://") || value.startsWith("https://")) {
84
+ return params ? url(value, params) : value;
85
+ }
86
+ let key = value.replace(/^\/+/, "").replace(/^src\/assets\//, "");
87
+ if (folder && !key.includes("/")) {
88
+ key = `${folder}/${key}`;
89
+ }
90
+ return url(key, params);
91
+ }
92
+
93
+ function pickWidths(targetW, widths) {
94
+ if (widths && widths.length) return [...widths].sort((a, b) => a - b);
95
+ if (!targetW) return defaultWidths;
96
+ const set = new Set();
97
+ for (const m of [0.5, 1, 1.5, 2]) {
98
+ const w = Math.round(targetW * m);
99
+ if (w >= 200 && w <= 2400) set.add(w);
100
+ }
101
+ return [...set].sort((a, b) => a - b);
102
+ }
103
+
104
+ /** Build a srcset string sized at the given widths. */
105
+ function srcset(value, widths, folder, params) {
106
+ if (!value) return "";
107
+ return widths.map((w) => `${resolve(value, folder, { ...params, w })} ${w}w`).join(", ");
108
+ }
109
+
110
+ /** Build the {src, srcset, sizes, width, height} props for a responsive <img>. */
111
+ function imageProps(value, opts = {}) {
112
+ if (!value) return { src: "" };
113
+ const { folder, width, height, widths, sizes = "100vw", f = "webp", q = 80, fit } = opts;
114
+ const widthList = pickWidths(width, widths);
115
+ const fallbackW =
116
+ width ?? widthList[Math.min(widthList.length - 1, Math.floor(widthList.length / 2))];
117
+ const baseParams = { f, q, ...(fit ? { fit } : {}) };
118
+ const src = resolve(value, folder, {
119
+ ...baseParams,
120
+ w: fallbackW,
121
+ ...(height ? { h: height } : {}),
122
+ });
123
+ return {
124
+ src,
125
+ srcset: srcset(value, widthList, folder, baseParams),
126
+ sizes,
127
+ width,
128
+ height,
129
+ };
130
+ }
131
+
132
+ return { url, resolve, srcset, imageProps, defaultWidths };
133
+ }