ampless 0.2.0-alpha.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/LICENSE +21 -0
- package/README.md +46 -0
- package/dist/index.d.ts +648 -0
- package/dist/index.js +463 -0
- package/dist/media/index.d.ts +48 -0
- package/dist/media/index.js +174 -0
- package/package.json +47 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
// src/config.ts
|
|
2
|
+
function defineConfig(config) {
|
|
3
|
+
return config;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
// src/schema.ts
|
|
7
|
+
function defineSchema(schema) {
|
|
8
|
+
return schema;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// src/dummy.ts
|
|
12
|
+
var DUMMY_POSTS = [
|
|
13
|
+
{
|
|
14
|
+
postId: "post-001",
|
|
15
|
+
siteId: "default",
|
|
16
|
+
slug: "hello-world",
|
|
17
|
+
title: "Hello, ampless",
|
|
18
|
+
excerpt: "Welcome to your new ampless-powered blog. This is the first post.",
|
|
19
|
+
format: "markdown",
|
|
20
|
+
body: "# Hello, ampless\n\nWelcome to your new blog powered by **ampless** \u2014 a serverless CMS for AWS Amplify.\n\nThis post is served from the built-in dummy content. Once you run `npx ampx sandbox` and create posts from the admin panel, they will replace this placeholder.\n\n## What to try next\n\n- Edit `cms.config.ts` to customize your site\n- Run `npx ampx sandbox` to deploy the backend\n- Visit `/admin` to manage content (coming in Phase 4)\n",
|
|
21
|
+
status: "published",
|
|
22
|
+
publishedAt: "2026-04-01T00:00:00Z",
|
|
23
|
+
tags: ["welcome"]
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
postId: "post-002",
|
|
27
|
+
siteId: "default",
|
|
28
|
+
slug: "about-ampless",
|
|
29
|
+
title: "About ampless",
|
|
30
|
+
excerpt: "Why ampless exists, and what makes it different from other CMS options.",
|
|
31
|
+
format: "markdown",
|
|
32
|
+
body: '# About ampless\n\nampless is an open-source CMS built natively for AWS Amplify \u2014 the "EmDash for AWS" position.\n\n## Key differences\n\n- **AWS-native**: Uses Amplify Gen 2, DynamoDB, S3, Cognito, Lambda\n- **Plugin-first**: Core stays small, features come from plugins\n- **AI-first**: MCP Server included from v0.1\n- **MIT licensed**: No commercial barriers\n',
|
|
33
|
+
status: "published",
|
|
34
|
+
publishedAt: "2026-04-02T00:00:00Z",
|
|
35
|
+
tags: ["meta"]
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
postId: "post-003",
|
|
39
|
+
siteId: "default",
|
|
40
|
+
slug: "getting-started",
|
|
41
|
+
title: "Getting started",
|
|
42
|
+
excerpt: "How to set up your ampless site and deploy it to AWS.",
|
|
43
|
+
format: "markdown",
|
|
44
|
+
body: "# Getting started\n\n## Local development\n\n```bash\nnpm install\nnpm run dev\n```\n\n## Deploy to AWS\n\n```bash\nnpx ampx sandbox # personal sandbox\n# or push to git and connect to Amplify Hosting\n```\n",
|
|
45
|
+
status: "published",
|
|
46
|
+
publishedAt: "2026-04-03T00:00:00Z",
|
|
47
|
+
tags: ["docs"]
|
|
48
|
+
}
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
// src/core.ts
|
|
52
|
+
var provider = null;
|
|
53
|
+
function setPostsProvider(p) {
|
|
54
|
+
provider = p;
|
|
55
|
+
}
|
|
56
|
+
function hasPostsProvider() {
|
|
57
|
+
return provider !== null;
|
|
58
|
+
}
|
|
59
|
+
function dummyList(opts = {}) {
|
|
60
|
+
const { siteId = "default", limit, status = "published" } = opts;
|
|
61
|
+
let posts = DUMMY_POSTS.filter((p) => p.siteId === siteId);
|
|
62
|
+
if (status !== "all") posts = posts.filter((p) => p.status === status);
|
|
63
|
+
return limit ? posts.slice(0, limit) : posts;
|
|
64
|
+
}
|
|
65
|
+
async function listPosts(opts = {}) {
|
|
66
|
+
if (provider) return provider.list(opts);
|
|
67
|
+
return dummyList(opts);
|
|
68
|
+
}
|
|
69
|
+
async function getPost(slug, opts = {}) {
|
|
70
|
+
if (provider) return provider.get(slug, opts);
|
|
71
|
+
const { siteId = "default" } = opts;
|
|
72
|
+
return DUMMY_POSTS.find((p) => p.siteId === siteId && p.slug === slug) ?? null;
|
|
73
|
+
}
|
|
74
|
+
async function getPostById(postId, opts = {}) {
|
|
75
|
+
if (provider) return provider.getById(postId, opts);
|
|
76
|
+
const { siteId = "default" } = opts;
|
|
77
|
+
return DUMMY_POSTS.find((p) => p.siteId === siteId && p.postId === postId) ?? null;
|
|
78
|
+
}
|
|
79
|
+
async function createPost(data) {
|
|
80
|
+
if (!provider) throw new Error("No posts provider configured. Call setPostsProvider() first.");
|
|
81
|
+
return provider.create(data);
|
|
82
|
+
}
|
|
83
|
+
async function updatePost(postId, data, opts = {}) {
|
|
84
|
+
if (!provider) throw new Error("No posts provider configured. Call setPostsProvider() first.");
|
|
85
|
+
return provider.update(postId, data, opts);
|
|
86
|
+
}
|
|
87
|
+
async function deletePost(postId, opts = {}) {
|
|
88
|
+
if (!provider) throw new Error("No posts provider configured. Call setPostsProvider() first.");
|
|
89
|
+
return provider.remove(postId, opts);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// src/sites.ts
|
|
93
|
+
var DEFAULT_SITE_ID = "default";
|
|
94
|
+
function resolveSiteId(host, config) {
|
|
95
|
+
const sites = config.sites;
|
|
96
|
+
if (!sites || Object.keys(sites).length === 0) {
|
|
97
|
+
return DEFAULT_SITE_ID;
|
|
98
|
+
}
|
|
99
|
+
const lower = host.toLowerCase();
|
|
100
|
+
for (const [id, site] of Object.entries(sites)) {
|
|
101
|
+
if (site.domains.some((d) => d.toLowerCase() === lower)) return id;
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
function isMultiSite(config) {
|
|
106
|
+
return !!config.sites && Object.keys(config.sites).length >= 2;
|
|
107
|
+
}
|
|
108
|
+
function siteFor(siteId, config) {
|
|
109
|
+
const override = config.sites?.[siteId];
|
|
110
|
+
return {
|
|
111
|
+
name: override?.name ?? config.site.name,
|
|
112
|
+
url: override?.url ?? config.site.url,
|
|
113
|
+
description: override?.description ?? config.site.description
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
function composeSiteIdStatus(siteId, status) {
|
|
117
|
+
return `${siteId}#${status}`;
|
|
118
|
+
}
|
|
119
|
+
function composeSiteIdSlug(siteId, slug) {
|
|
120
|
+
return `${siteId}#${slug}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// src/kv.ts
|
|
124
|
+
var store = null;
|
|
125
|
+
function setKvStore(s) {
|
|
126
|
+
store = s;
|
|
127
|
+
}
|
|
128
|
+
function hasKvStore() {
|
|
129
|
+
return store !== null;
|
|
130
|
+
}
|
|
131
|
+
function requireStore() {
|
|
132
|
+
if (!store) {
|
|
133
|
+
throw new Error("No KvStore configured. Call setKvStore() during initialization.");
|
|
134
|
+
}
|
|
135
|
+
return store;
|
|
136
|
+
}
|
|
137
|
+
var SITE_CONFIG_PK = (siteId) => `siteconfig:${siteId}`;
|
|
138
|
+
async function getSiteSetting(siteId, key) {
|
|
139
|
+
return requireStore().get(SITE_CONFIG_PK(siteId), key);
|
|
140
|
+
}
|
|
141
|
+
async function setSiteSetting(siteId, key, value) {
|
|
142
|
+
return requireStore().put(SITE_CONFIG_PK(siteId), key, value);
|
|
143
|
+
}
|
|
144
|
+
async function deleteSiteSetting(siteId, key) {
|
|
145
|
+
return requireStore().remove(SITE_CONFIG_PK(siteId), key);
|
|
146
|
+
}
|
|
147
|
+
async function listSiteSettings(siteId = DEFAULT_SITE_ID) {
|
|
148
|
+
const items = await requireStore().query(SITE_CONFIG_PK(siteId));
|
|
149
|
+
const out = {};
|
|
150
|
+
for (const item of items) {
|
|
151
|
+
out[item.sk] = item.value;
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
function flattenSettings(obj, prefix = "") {
|
|
156
|
+
const out = {};
|
|
157
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
158
|
+
const path = prefix ? `${prefix}.${k}` : k;
|
|
159
|
+
if (v !== null && typeof v === "object" && !Array.isArray(v)) {
|
|
160
|
+
Object.assign(out, flattenSettings(v, path));
|
|
161
|
+
} else {
|
|
162
|
+
out[path] = v;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
function unflattenSettings(flat) {
|
|
168
|
+
const out = {};
|
|
169
|
+
for (const [path, value] of Object.entries(flat)) {
|
|
170
|
+
const parts = path.split(".");
|
|
171
|
+
let cursor = out;
|
|
172
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
173
|
+
const part = parts[i];
|
|
174
|
+
const existing = cursor[part];
|
|
175
|
+
if (existing === void 0 || typeof existing !== "object" || existing === null) {
|
|
176
|
+
cursor[part] = {};
|
|
177
|
+
}
|
|
178
|
+
cursor = cursor[part];
|
|
179
|
+
}
|
|
180
|
+
cursor[parts[parts.length - 1]] = value;
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// src/format.ts
|
|
186
|
+
function formatDate(input, format = "iso", timezone = "UTC") {
|
|
187
|
+
const d = input instanceof Date ? input : new Date(input);
|
|
188
|
+
if (Number.isNaN(d.getTime())) return "";
|
|
189
|
+
switch (format) {
|
|
190
|
+
case "iso":
|
|
191
|
+
return isoInTimezone(d, timezone);
|
|
192
|
+
case "long":
|
|
193
|
+
return new Intl.DateTimeFormat("en-US", {
|
|
194
|
+
year: "numeric",
|
|
195
|
+
month: "long",
|
|
196
|
+
day: "numeric",
|
|
197
|
+
timeZone: timezone
|
|
198
|
+
}).format(d);
|
|
199
|
+
case "locale":
|
|
200
|
+
return new Intl.DateTimeFormat(void 0, { timeZone: timezone }).format(d);
|
|
201
|
+
default:
|
|
202
|
+
return d.toISOString();
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
function isoInTimezone(d, timezone) {
|
|
206
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
207
|
+
year: "numeric",
|
|
208
|
+
month: "2-digit",
|
|
209
|
+
day: "2-digit",
|
|
210
|
+
timeZone: timezone
|
|
211
|
+
}).formatToParts(d);
|
|
212
|
+
const year = parts.find((p) => p.type === "year")?.value ?? "0000";
|
|
213
|
+
const month = parts.find((p) => p.type === "month")?.value ?? "00";
|
|
214
|
+
const day = parts.find((p) => p.type === "day")?.value ?? "00";
|
|
215
|
+
return `${year}-${month}-${day}`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// src/xml.ts
|
|
219
|
+
var XML_ESCAPES = {
|
|
220
|
+
"&": "&",
|
|
221
|
+
"<": "<",
|
|
222
|
+
">": ">",
|
|
223
|
+
'"': """,
|
|
224
|
+
// Use numeric reference for the apostrophe — ' is XML 1.0 only and
|
|
225
|
+
// a few legacy RSS validators reject it. ' is universal.
|
|
226
|
+
"'": "'"
|
|
227
|
+
};
|
|
228
|
+
function escapeXml(s) {
|
|
229
|
+
return s.replace(/[&<>"']/g, (c) => XML_ESCAPES[c]);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// src/storage.ts
|
|
233
|
+
function formatPublicAssetUrl(bucket, region, key) {
|
|
234
|
+
return `https://${bucket}.s3.${region}.amazonaws.com/${key}`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// src/plugin.ts
|
|
238
|
+
function definePlugin(p) {
|
|
239
|
+
return p;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// src/post-images.ts
|
|
243
|
+
function extractFirstImageUrl(post) {
|
|
244
|
+
switch (post.format) {
|
|
245
|
+
case "tiptap":
|
|
246
|
+
return findTiptapImage(post.body);
|
|
247
|
+
case "markdown":
|
|
248
|
+
return findMarkdownImage(post.body);
|
|
249
|
+
case "html":
|
|
250
|
+
return findHtmlImage(post.body);
|
|
251
|
+
default:
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
function findTiptapImage(body) {
|
|
256
|
+
if (!body || typeof body !== "object") return null;
|
|
257
|
+
const node = body;
|
|
258
|
+
if (node.type === "image") {
|
|
259
|
+
const src = node.attrs?.src;
|
|
260
|
+
if (typeof src === "string" && src.length > 0) return src;
|
|
261
|
+
}
|
|
262
|
+
if (Array.isArray(node.content)) {
|
|
263
|
+
for (const child of node.content) {
|
|
264
|
+
const hit = findTiptapImage(child);
|
|
265
|
+
if (hit) return hit;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
function findMarkdownImage(body) {
|
|
271
|
+
if (typeof body !== "string") return null;
|
|
272
|
+
const m = body.match(/!\[[^\]]*\]\(([^)]+)\)/);
|
|
273
|
+
return m ? m[1] : null;
|
|
274
|
+
}
|
|
275
|
+
function findHtmlImage(body) {
|
|
276
|
+
if (typeof body !== "string") return null;
|
|
277
|
+
const m = body.match(/<img[^>]+src=["']([^"']+)["']/i);
|
|
278
|
+
return m ? m[1] : null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// src/events.ts
|
|
282
|
+
function detectContentEvents(input) {
|
|
283
|
+
switch (input.eventName) {
|
|
284
|
+
case "INSERT": {
|
|
285
|
+
const out = ["content.created"];
|
|
286
|
+
if (input.newStatus === "published") out.push("content.published");
|
|
287
|
+
return out;
|
|
288
|
+
}
|
|
289
|
+
case "MODIFY": {
|
|
290
|
+
const wasPublished = input.oldStatus === "published";
|
|
291
|
+
const isPublished = input.newStatus === "published";
|
|
292
|
+
const out = ["content.updated"];
|
|
293
|
+
if (!wasPublished && isPublished) out.push("content.published");
|
|
294
|
+
else if (wasPublished && !isPublished) out.push("content.unpublished");
|
|
295
|
+
return out;
|
|
296
|
+
}
|
|
297
|
+
case "REMOVE": {
|
|
298
|
+
const out = [];
|
|
299
|
+
if (input.oldStatus === "published") out.push("content.unpublished");
|
|
300
|
+
out.push("content.deleted");
|
|
301
|
+
return out;
|
|
302
|
+
}
|
|
303
|
+
default:
|
|
304
|
+
return [];
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// src/theme.ts
|
|
309
|
+
function defineTheme(m) {
|
|
310
|
+
return m;
|
|
311
|
+
}
|
|
312
|
+
function resolveLocalized(value, locale, fallback = "en") {
|
|
313
|
+
if (value === void 0) return "";
|
|
314
|
+
if (typeof value === "string") return value;
|
|
315
|
+
return value[locale] ?? value[fallback] ?? Object.values(value)[0] ?? "";
|
|
316
|
+
}
|
|
317
|
+
function defineThemeModule(m) {
|
|
318
|
+
return m;
|
|
319
|
+
}
|
|
320
|
+
function themeSettingKey(fieldKey) {
|
|
321
|
+
return `theme.${fieldKey}`;
|
|
322
|
+
}
|
|
323
|
+
var COLOR_RE = /^(#[0-9a-fA-F]{3,8}|rgb\([^)]+\)|rgba\([^)]+\)|hsl\([^)]+\)|hsla\([^)]+\)|oklch\([^)]+\)|oklab\([^)]+\))$/;
|
|
324
|
+
var LENGTH_RE = /^[\d.]+(px|rem|em|%|vh|vw)$/;
|
|
325
|
+
var DANGEROUS_IMAGE_URL_RE = /^\s*(javascript|vbscript):/i;
|
|
326
|
+
function validateThemeValue(field, raw) {
|
|
327
|
+
if (field.type === "linkList") return validateLinkListValue(field, raw);
|
|
328
|
+
if (typeof raw !== "string") return null;
|
|
329
|
+
const v = raw.trim();
|
|
330
|
+
if (!v) return null;
|
|
331
|
+
switch (field.type) {
|
|
332
|
+
case "color":
|
|
333
|
+
return COLOR_RE.test(v) ? v : null;
|
|
334
|
+
case "length":
|
|
335
|
+
return LENGTH_RE.test(v) ? v : null;
|
|
336
|
+
case "image":
|
|
337
|
+
return DANGEROUS_IMAGE_URL_RE.test(v) ? null : v;
|
|
338
|
+
case "select":
|
|
339
|
+
case "fontFamily":
|
|
340
|
+
return field.options.some((o) => o.value === v) ? v : null;
|
|
341
|
+
case "text": {
|
|
342
|
+
const max = field.maxLength ?? 200;
|
|
343
|
+
const sanitized = v.replace(/[\x00-\x1f<>]/g, "");
|
|
344
|
+
return sanitized.length <= max ? sanitized : sanitized.slice(0, max);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
var DANGEROUS_URL_RE = /^\s*(javascript|data|vbscript):/i;
|
|
349
|
+
function validateLinkListValue(field, raw) {
|
|
350
|
+
let parsed;
|
|
351
|
+
if (Array.isArray(raw)) {
|
|
352
|
+
parsed = raw;
|
|
353
|
+
} else if (typeof raw === "string") {
|
|
354
|
+
const trimmed = raw.trim();
|
|
355
|
+
if (!trimmed) return JSON.stringify([]);
|
|
356
|
+
try {
|
|
357
|
+
parsed = JSON.parse(trimmed);
|
|
358
|
+
} catch {
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
} else if (raw === null || raw === void 0) {
|
|
362
|
+
return JSON.stringify([]);
|
|
363
|
+
} else {
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
if (!Array.isArray(parsed)) return null;
|
|
367
|
+
const max = field.maxItems ?? 50;
|
|
368
|
+
if (parsed.length > max) return null;
|
|
369
|
+
const cleaned = [];
|
|
370
|
+
for (const entry of parsed) {
|
|
371
|
+
if (!entry || typeof entry !== "object") continue;
|
|
372
|
+
const e = entry;
|
|
373
|
+
const rawLabel = typeof e.label === "string" ? e.label : "";
|
|
374
|
+
const rawUrl = typeof e.url === "string" ? e.url : "";
|
|
375
|
+
const label = rawLabel.replace(/[\x00-\x1f<>]/g, "").trim().slice(0, 120);
|
|
376
|
+
const url = rawUrl.replace(/[\x00-\x1f]/g, "").trim().slice(0, 500);
|
|
377
|
+
if (!label && !url) continue;
|
|
378
|
+
if (url && DANGEROUS_URL_RE.test(url)) continue;
|
|
379
|
+
cleaned.push({ label, url });
|
|
380
|
+
}
|
|
381
|
+
return JSON.stringify(cleaned);
|
|
382
|
+
}
|
|
383
|
+
function parseLinkList(raw) {
|
|
384
|
+
if (!raw) return [];
|
|
385
|
+
try {
|
|
386
|
+
const parsed = JSON.parse(raw);
|
|
387
|
+
if (!Array.isArray(parsed)) return [];
|
|
388
|
+
return parsed.filter(
|
|
389
|
+
(e) => !!e && typeof e === "object" && typeof e.label === "string" && typeof e.url === "string"
|
|
390
|
+
).map((e) => ({ label: e.label, url: e.url }));
|
|
391
|
+
} catch {
|
|
392
|
+
return [];
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
function stringifyLinkList(items) {
|
|
396
|
+
return JSON.stringify(items.map((i) => ({ label: i.label, url: i.url })));
|
|
397
|
+
}
|
|
398
|
+
function isTagListUrl(url) {
|
|
399
|
+
const m = /^tag:(.+)$/.exec(url.trim());
|
|
400
|
+
if (!m) return null;
|
|
401
|
+
const tag = m[1].trim();
|
|
402
|
+
return tag ? { tag } : null;
|
|
403
|
+
}
|
|
404
|
+
function resolveThemeValues(manifest, stored) {
|
|
405
|
+
const out = {};
|
|
406
|
+
for (const field of manifest.fields) {
|
|
407
|
+
const storeKey = themeSettingKey(field.key);
|
|
408
|
+
const raw = stored[storeKey];
|
|
409
|
+
const validated = raw !== void 0 ? validateThemeValue(field, raw) : null;
|
|
410
|
+
out[field.key] = validated ?? defaultValueAsString(field);
|
|
411
|
+
}
|
|
412
|
+
return out;
|
|
413
|
+
}
|
|
414
|
+
function defaultValueAsString(field) {
|
|
415
|
+
if (field.type === "linkList") return stringifyLinkList(field.default);
|
|
416
|
+
return field.default;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// src/index.ts
|
|
420
|
+
var VERSION = "0.0.1";
|
|
421
|
+
export {
|
|
422
|
+
DEFAULT_SITE_ID,
|
|
423
|
+
SITE_CONFIG_PK,
|
|
424
|
+
VERSION,
|
|
425
|
+
composeSiteIdSlug,
|
|
426
|
+
composeSiteIdStatus,
|
|
427
|
+
createPost,
|
|
428
|
+
defineConfig,
|
|
429
|
+
definePlugin,
|
|
430
|
+
defineSchema,
|
|
431
|
+
defineTheme,
|
|
432
|
+
defineThemeModule,
|
|
433
|
+
deletePost,
|
|
434
|
+
deleteSiteSetting,
|
|
435
|
+
detectContentEvents,
|
|
436
|
+
escapeXml,
|
|
437
|
+
extractFirstImageUrl,
|
|
438
|
+
flattenSettings,
|
|
439
|
+
formatDate,
|
|
440
|
+
formatPublicAssetUrl,
|
|
441
|
+
getPost,
|
|
442
|
+
getPostById,
|
|
443
|
+
getSiteSetting,
|
|
444
|
+
hasKvStore,
|
|
445
|
+
hasPostsProvider,
|
|
446
|
+
isMultiSite,
|
|
447
|
+
isTagListUrl,
|
|
448
|
+
listPosts,
|
|
449
|
+
listSiteSettings,
|
|
450
|
+
parseLinkList,
|
|
451
|
+
resolveLocalized,
|
|
452
|
+
resolveSiteId,
|
|
453
|
+
resolveThemeValues,
|
|
454
|
+
setKvStore,
|
|
455
|
+
setPostsProvider,
|
|
456
|
+
setSiteSetting,
|
|
457
|
+
siteFor,
|
|
458
|
+
stringifyLinkList,
|
|
459
|
+
themeSettingKey,
|
|
460
|
+
unflattenSettings,
|
|
461
|
+
updatePost,
|
|
462
|
+
validateThemeValue
|
|
463
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
type OutputFormat = 'webp' | 'jpeg' | 'original';
|
|
2
|
+
interface CropArea {
|
|
3
|
+
/** Source-pixel coordinates of the crop rectangle. */
|
|
4
|
+
x: number;
|
|
5
|
+
y: number;
|
|
6
|
+
width: number;
|
|
7
|
+
height: number;
|
|
8
|
+
}
|
|
9
|
+
interface ProcessOptions {
|
|
10
|
+
/** Skip all processing and return the input file as-is. */
|
|
11
|
+
original?: boolean;
|
|
12
|
+
/** Crop rectangle in source pixels. Omit for no crop. */
|
|
13
|
+
crop?: CropArea;
|
|
14
|
+
/** Clamp the longer edge to this many pixels. Never upscales. */
|
|
15
|
+
maxDimension?: number;
|
|
16
|
+
/** Output container. `'original'` keeps the input mime. */
|
|
17
|
+
format?: OutputFormat;
|
|
18
|
+
/** Lossy quality 0..1. Ignored for lossless WebP and PNG. Defaults to 0.85. */
|
|
19
|
+
quality?: number;
|
|
20
|
+
/** Force lossless WebP encoding. Only meaningful when format === 'webp'. */
|
|
21
|
+
lossless?: boolean;
|
|
22
|
+
}
|
|
23
|
+
interface ProcessedImage {
|
|
24
|
+
blob: Blob;
|
|
25
|
+
mime: string;
|
|
26
|
+
/** Decoded width in pixels. 0 means the dimensions could not be determined (e.g. SVG, undecodable passthrough). */
|
|
27
|
+
width: number;
|
|
28
|
+
/** Decoded height in pixels. 0 means the dimensions could not be determined (e.g. SVG, undecodable passthrough). */
|
|
29
|
+
height: number;
|
|
30
|
+
/** Filename derived from the input, with the extension matching `mime`. */
|
|
31
|
+
suggestedName: string;
|
|
32
|
+
}
|
|
33
|
+
declare function shouldSkipProcessing(mime: string): boolean;
|
|
34
|
+
declare function extensionFor(mime: string): string;
|
|
35
|
+
declare function replaceExtension(name: string, mime: string): string;
|
|
36
|
+
declare function resolveOutputMime(inputMime: string, format: OutputFormat | undefined): string;
|
|
37
|
+
interface TargetDimensions {
|
|
38
|
+
width: number;
|
|
39
|
+
height: number;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Resolve target dimensions, honouring caller's maxDimension AND the hard
|
|
43
|
+
* canvas-budget ceiling. Never upscales the source.
|
|
44
|
+
*/
|
|
45
|
+
declare function computeTargetDimensions(sourceWidth: number, sourceHeight: number, maxDimension: number | undefined): TargetDimensions;
|
|
46
|
+
declare function processImage(file: File, options?: ProcessOptions): Promise<ProcessedImage>;
|
|
47
|
+
|
|
48
|
+
export { type CropArea, type OutputFormat, type ProcessOptions, type ProcessedImage, type TargetDimensions, computeTargetDimensions, extensionFor, processImage, replaceExtension, resolveOutputMime, shouldSkipProcessing };
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// src/media/processing.ts
|
|
2
|
+
var HARD_MAX_DIMENSION = 8e3;
|
|
3
|
+
var PASSTHROUGH_MIMES = /* @__PURE__ */ new Set([
|
|
4
|
+
"image/gif",
|
|
5
|
+
// animated frames would be lost via Canvas
|
|
6
|
+
"image/svg+xml",
|
|
7
|
+
// raster encoding destroys vectors
|
|
8
|
+
"image/avif",
|
|
9
|
+
// re-encoding cost > benefit for now
|
|
10
|
+
"image/heic",
|
|
11
|
+
// iPhone default; most browsers cannot decode at all
|
|
12
|
+
"image/heif",
|
|
13
|
+
"image/bmp",
|
|
14
|
+
// Canvas decode is patchy
|
|
15
|
+
"image/tiff"
|
|
16
|
+
]);
|
|
17
|
+
function shouldSkipProcessing(mime) {
|
|
18
|
+
if (!mime || !mime.startsWith("image/")) return true;
|
|
19
|
+
return PASSTHROUGH_MIMES.has(mime);
|
|
20
|
+
}
|
|
21
|
+
var MIME_TO_EXT = {
|
|
22
|
+
"image/webp": "webp",
|
|
23
|
+
"image/jpeg": "jpg",
|
|
24
|
+
"image/png": "png",
|
|
25
|
+
"image/gif": "gif",
|
|
26
|
+
"image/svg+xml": "svg",
|
|
27
|
+
"image/avif": "avif",
|
|
28
|
+
"image/heic": "heic",
|
|
29
|
+
"image/heif": "heif",
|
|
30
|
+
"image/bmp": "bmp",
|
|
31
|
+
"image/tiff": "tif"
|
|
32
|
+
};
|
|
33
|
+
function extensionFor(mime) {
|
|
34
|
+
return MIME_TO_EXT[mime] ?? "bin";
|
|
35
|
+
}
|
|
36
|
+
function replaceExtension(name, mime) {
|
|
37
|
+
const ext = extensionFor(mime);
|
|
38
|
+
const i = name.lastIndexOf(".");
|
|
39
|
+
const base = i > 0 ? name.slice(0, i) : name;
|
|
40
|
+
return `${base}.${ext}`;
|
|
41
|
+
}
|
|
42
|
+
function resolveOutputMime(inputMime, format) {
|
|
43
|
+
if (format === "webp") return "image/webp";
|
|
44
|
+
if (format === "jpeg") return "image/jpeg";
|
|
45
|
+
return inputMime || "application/octet-stream";
|
|
46
|
+
}
|
|
47
|
+
function computeTargetDimensions(sourceWidth, sourceHeight, maxDimension) {
|
|
48
|
+
const longer = Math.max(sourceWidth, sourceHeight);
|
|
49
|
+
const requested = maxDimension && maxDimension > 0 ? maxDimension : longer;
|
|
50
|
+
const effective = Math.min(requested, HARD_MAX_DIMENSION);
|
|
51
|
+
if (longer <= effective) {
|
|
52
|
+
return { width: sourceWidth, height: sourceHeight };
|
|
53
|
+
}
|
|
54
|
+
const scale = effective / longer;
|
|
55
|
+
return {
|
|
56
|
+
width: Math.max(1, Math.round(sourceWidth * scale)),
|
|
57
|
+
height: Math.max(1, Math.round(sourceHeight * scale))
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
async function processImage(file, options = {}) {
|
|
61
|
+
if (options.original || shouldSkipProcessing(file.type)) {
|
|
62
|
+
return passthrough(file);
|
|
63
|
+
}
|
|
64
|
+
let bitmap;
|
|
65
|
+
try {
|
|
66
|
+
bitmap = await createImageBitmap(file);
|
|
67
|
+
} catch {
|
|
68
|
+
return passthrough(file);
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const source = options.crop ?? {
|
|
72
|
+
x: 0,
|
|
73
|
+
y: 0,
|
|
74
|
+
width: bitmap.width,
|
|
75
|
+
height: bitmap.height
|
|
76
|
+
};
|
|
77
|
+
const target = computeTargetDimensions(source.width, source.height, options.maxDimension);
|
|
78
|
+
const canvas = createCanvas(target.width, target.height);
|
|
79
|
+
const ctx = canvas.getContext("2d");
|
|
80
|
+
if (!ctx) throw new Error("Failed to obtain 2D rendering context");
|
|
81
|
+
ctx.drawImage(
|
|
82
|
+
bitmap,
|
|
83
|
+
source.x,
|
|
84
|
+
source.y,
|
|
85
|
+
source.width,
|
|
86
|
+
source.height,
|
|
87
|
+
0,
|
|
88
|
+
0,
|
|
89
|
+
target.width,
|
|
90
|
+
target.height
|
|
91
|
+
);
|
|
92
|
+
const outMime = resolveOutputMime(file.type, options.format);
|
|
93
|
+
const quality = options.quality ?? 0.85;
|
|
94
|
+
const wantsLossless = outMime === "image/webp" && options.lossless === true;
|
|
95
|
+
let blob;
|
|
96
|
+
if (wantsLossless) {
|
|
97
|
+
try {
|
|
98
|
+
blob = await encodeWebpLossless(ctx, target.width, target.height);
|
|
99
|
+
} catch {
|
|
100
|
+
blob = await canvasToBlob(canvas, "image/webp", 0.95);
|
|
101
|
+
}
|
|
102
|
+
} else {
|
|
103
|
+
blob = await canvasToBlob(canvas, outMime, quality);
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
blob,
|
|
107
|
+
mime: blob.type || outMime,
|
|
108
|
+
width: target.width,
|
|
109
|
+
height: target.height,
|
|
110
|
+
suggestedName: replaceExtension(file.name, blob.type || outMime)
|
|
111
|
+
};
|
|
112
|
+
} finally {
|
|
113
|
+
bitmap.close?.();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
async function passthrough(file) {
|
|
117
|
+
let width = 0;
|
|
118
|
+
let height = 0;
|
|
119
|
+
if (typeof createImageBitmap !== "undefined" && file.type !== "image/svg+xml") {
|
|
120
|
+
try {
|
|
121
|
+
const bm = await createImageBitmap(file);
|
|
122
|
+
width = bm.width;
|
|
123
|
+
height = bm.height;
|
|
124
|
+
bm.close?.();
|
|
125
|
+
} catch {
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
blob: file,
|
|
130
|
+
mime: file.type || "application/octet-stream",
|
|
131
|
+
width,
|
|
132
|
+
height,
|
|
133
|
+
suggestedName: file.name
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
function createCanvas(width, height) {
|
|
137
|
+
const hasOffscreenEncode = typeof OffscreenCanvas !== "undefined" && typeof OffscreenCanvas.prototype !== "undefined" && "convertToBlob" in OffscreenCanvas.prototype;
|
|
138
|
+
if (hasOffscreenEncode) {
|
|
139
|
+
return new OffscreenCanvas(width, height);
|
|
140
|
+
}
|
|
141
|
+
if (typeof document !== "undefined") {
|
|
142
|
+
const c = document.createElement("canvas");
|
|
143
|
+
c.width = width;
|
|
144
|
+
c.height = height;
|
|
145
|
+
return c;
|
|
146
|
+
}
|
|
147
|
+
throw new Error("No canvas implementation available in this environment");
|
|
148
|
+
}
|
|
149
|
+
async function canvasToBlob(canvas, type, quality) {
|
|
150
|
+
if ("convertToBlob" in canvas) {
|
|
151
|
+
return canvas.convertToBlob({ type, quality });
|
|
152
|
+
}
|
|
153
|
+
return new Promise((resolve, reject) => {
|
|
154
|
+
canvas.toBlob(
|
|
155
|
+
(blob) => blob ? resolve(blob) : reject(new Error(`canvas.toBlob returned null for ${type}`)),
|
|
156
|
+
type,
|
|
157
|
+
quality
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
async function encodeWebpLossless(ctx, width, height) {
|
|
162
|
+
const imageData = ctx.getImageData(0, 0, width, height);
|
|
163
|
+
const mod = await import("@jsquash/webp");
|
|
164
|
+
const buffer = await mod.encode(imageData, { lossless: 1 });
|
|
165
|
+
return new Blob([buffer], { type: "image/webp" });
|
|
166
|
+
}
|
|
167
|
+
export {
|
|
168
|
+
computeTargetDimensions,
|
|
169
|
+
extensionFor,
|
|
170
|
+
processImage,
|
|
171
|
+
replaceExtension,
|
|
172
|
+
resolveOutputMime,
|
|
173
|
+
shouldSkipProcessing
|
|
174
|
+
};
|