@realiizlabs/admin 0.13.0 → 0.13.1

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.
@@ -82,14 +82,47 @@ function imagePathFor(kind, slug, originalName = "", opts = {}) {
82
82
  }
83
83
  }
84
84
 
85
+ // src/media/validate.ts
86
+ var IMAGE_LIMITS = { maxEach: 800 * 1024, maxTotal: 3.5 * 1024 * 1024, prefix: "/blog/" };
87
+ var IMAGE_ERRORS = {
88
+ path: "That image path isn\u2019t allowed.",
89
+ size: "That picture is too large to send \u2014 try a smaller one."
90
+ };
91
+ var SAFE_PATH = /^[a-z0-9][a-z0-9/_-]*\.webp$/;
92
+ function decodedSize(b64) {
93
+ const clean = b64.replace(/\s+/g, "");
94
+ const pad = clean.endsWith("==") ? 2 : clean.endsWith("=") ? 1 : 0;
95
+ return Math.floor(clean.length * 3 / 4) - pad;
96
+ }
97
+ function validateImages(images, limits = IMAGE_LIMITS) {
98
+ let total = 0;
99
+ const files = [];
100
+ for (const img of images) {
101
+ if (typeof img?.path !== "string" || typeof img?.base64 !== "string") return { ok: false, error: IMAGE_ERRORS.path };
102
+ if (!img.path.startsWith(limits.prefix) || img.path.includes("..")) return { ok: false, error: IMAGE_ERRORS.path };
103
+ const rel = img.path.slice(1);
104
+ if (!SAFE_PATH.test(rel)) return { ok: false, error: IMAGE_ERRORS.path };
105
+ const size = decodedSize(img.base64);
106
+ if (size === 0 || size > limits.maxEach) return { ok: false, error: IMAGE_ERRORS.size };
107
+ total += size;
108
+ if (total > limits.maxTotal) return { ok: false, error: IMAGE_ERRORS.size };
109
+ files.push({ path: `public/${rel}`, content: img.base64.replace(/\s+/g, ""), encoding: "base64" });
110
+ }
111
+ return { ok: true, files };
112
+ }
113
+
85
114
  exports.ACCEPTED_TYPES = ACCEPTED_TYPES;
115
+ exports.IMAGE_ERRORS = IMAGE_ERRORS;
116
+ exports.IMAGE_LIMITS = IMAGE_LIMITS;
86
117
  exports.MAX_INPUT_BYTES = MAX_INPUT_BYTES;
87
118
  exports.MEDIA_MESSAGES = MESSAGES;
88
119
  exports.MediaError = MediaError;
89
120
  exports.checkInput = checkInput;
121
+ exports.decodedSize = decodedSize;
90
122
  exports.fitWithin = fitWithin;
91
123
  exports.imagePathFor = imagePathFor;
92
124
  exports.resizeImage = resizeImage;
93
125
  exports.slugifyName = slugifyName;
126
+ exports.validateImages = validateImages;
94
127
  //# sourceMappingURL=index.cjs.map
95
128
  //# sourceMappingURL=index.cjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/media/errors.ts","../../src/media/resize.ts","../../src/media/paths.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["/** A picture problem the person can act on. The message is the sentence shown under the control. */\nexport class MediaError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"MediaError\";\n }\n}\n\nexport const MESSAGES = {\n type: \"That file type isn’t supported — use a JPG, PNG or WebP.\",\n size: \"That picture is too big to send — pick one under 10MB.\",\n encode: \"This browser can’t prepare pictures — try Chrome, Edge or Safari 16+.\",\n} as const;\n","/**\n * resizeImage — shrink a picture in the browser to web size, as WebP.\n *\n * Phone photos are 3–8MB; a Vercel server action body is capped at 4.5MB and the\n * GitHub API wants base64. So the shrink happens here, before anything leaves the\n * browser: decode → fit inside maxWidth → draw to a canvas → encode WebP, lowering\n * quality in steps until the result is under maxBytes. GIFs lose their animation\n * (a still frame is encoded) — acceptable for a blog.\n *\n * No network, no dependencies. Throws MediaError with a plain sentence for the\n * three things that can go wrong: wrong type, too big to even try, or a browser\n * that cannot encode WebP (toBlob returns null or a non-WebP type).\n */\n\nimport { MediaError, MESSAGES } from \"./errors\";\n\nexport const ACCEPTED_TYPES = [\"image/jpeg\", \"image/png\", \"image/webp\", \"image/gif\"] as const;\nexport const MAX_INPUT_BYTES = 10 * 1024 * 1024;\n\nexport interface ResizeOptions {\n /** Longest allowed width in pixels. Default 1600. */\n maxWidth?: number;\n /** Starting WebP quality 0–1. Default 0.82. */\n quality?: number;\n /** Target ceiling for the encoded size. Default 600KB. */\n maxBytes?: number;\n}\n\nexport interface ResizedImage {\n blob: Blob;\n width: number;\n height: number;\n}\n\n/** Pure: fit (width × height) inside maxWidth, never scaling up, whole pixels. */\nexport function fitWithin(width: number, height: number, maxWidth: number): { width: number; height: number } {\n if (width <= maxWidth) return { width, height };\n const ratio = maxWidth / width;\n return { width: Math.round(width * ratio), height: Math.max(1, Math.round(height * ratio)) };\n}\n\n/** The type/size guards, separated so they can run (and be tested) without a canvas. */\nexport function checkInput(file: { type: string; size: number }): void {\n if (!(ACCEPTED_TYPES as readonly string[]).includes(file.type)) throw new MediaError(MESSAGES.type);\n if (file.size > MAX_INPUT_BYTES) throw new MediaError(MESSAGES.size);\n}\n\nexport async function resizeImage(file: File, opts: ResizeOptions = {}): Promise<ResizedImage> {\n checkInput(file);\n const { maxWidth = 1600, quality = 0.82, maxBytes = 600 * 1024 } = opts;\n\n const bitmap = await decode(file);\n const { width, height } = fitWithin(bitmap.width, bitmap.height, maxWidth);\n const canvas = makeCanvas(width, height);\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) throw new MediaError(MESSAGES.encode);\n ctx.drawImage(bitmap, 0, 0, width, height);\n bitmap.close?.();\n\n let q = quality;\n let blob = await encode(canvas, q);\n while (blob.size > maxBytes && q - 0.08 >= 0.5) {\n q -= 0.08;\n blob = await encode(canvas, q);\n }\n return { blob, width, height };\n}\n\nasync function decode(file: File): Promise<ImageBitmap> {\n try {\n return await createImageBitmap(file);\n } catch {\n throw new MediaError(MESSAGES.encode);\n }\n}\n\ntype AnyCanvas = HTMLCanvasElement | OffscreenCanvas;\n\nfunction makeCanvas(width: number, height: number): AnyCanvas {\n if (typeof OffscreenCanvas !== \"undefined\") return new OffscreenCanvas(width, height);\n const c = document.createElement(\"canvas\");\n c.width = width;\n c.height = height;\n return c;\n}\n\nasync function encode(canvas: AnyCanvas, quality: number): Promise<Blob> {\n const blob =\n \"convertToBlob\" in canvas\n ? await canvas.convertToBlob({ type: \"image/webp\", quality }).catch(() => null)\n : await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, \"image/webp\", quality));\n if (!blob || blob.type !== \"image/webp\") throw new MediaError(MESSAGES.encode);\n return blob;\n}\n","/**\n * Where a picture lives, decided from the post's slug so the path is known before\n * anything is committed: hero → /blog/{slug}.webp, social → /blog/{slug}-social.webp,\n * body → /blog/{slug}/{name}.webp. The host commits the file under public/ + path.\n */\n\nexport type ImageKind = \"hero\" | \"social\" | \"body\";\n\nexport interface PathOptions {\n /** URL prefix the pictures are served from. Default \"/blog\". */\n publicDir?: string;\n}\n\n/** Lower-case letters, digits and single hyphens; empty in → \"image\". */\nexport function slugifyName(name: string): string {\n const base = name.replace(/\\.[a-z0-9]+$/i, \"\");\n const s = base\n .normalize(\"NFKD\")\n .replace(/[̀-ͯ]/g, \"\")\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\");\n return s || \"image\";\n}\n\nexport function imagePathFor(kind: ImageKind, slug: string, originalName = \"\", opts: PathOptions = {}): string {\n const dir = (opts.publicDir ?? \"/blog\").replace(/\\/$/, \"\");\n switch (kind) {\n case \"hero\":\n return `${dir}/${slug}.webp`;\n case \"social\":\n return `${dir}/${slug}-social.webp`;\n case \"body\":\n return `${dir}/${slug}/${slugifyName(originalName)}.webp`;\n }\n}\n"]}
1
+ {"version":3,"sources":["../../src/media/errors.ts","../../src/media/resize.ts","../../src/media/paths.ts","../../src/media/validate.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["/** A picture problem the person can act on. The message is the sentence shown under the control. */\nexport class MediaError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"MediaError\";\n }\n}\n\nexport const MESSAGES = {\n type: \"That file type isn’t supported — use a JPG, PNG or WebP.\",\n size: \"That picture is too big to send — pick one under 10MB.\",\n encode: \"This browser can’t prepare pictures — try Chrome, Edge or Safari 16+.\",\n} as const;\n","/**\n * resizeImage — shrink a picture in the browser to web size, as WebP.\n *\n * Phone photos are 3–8MB; a Vercel server action body is capped at 4.5MB and the\n * GitHub API wants base64. So the shrink happens here, before anything leaves the\n * browser: decode → fit inside maxWidth → draw to a canvas → encode WebP, lowering\n * quality in steps until the result is under maxBytes. GIFs lose their animation\n * (a still frame is encoded) — acceptable for a blog.\n *\n * No network, no dependencies. Throws MediaError with a plain sentence for the\n * three things that can go wrong: wrong type, too big to even try, or a browser\n * that cannot encode WebP (toBlob returns null or a non-WebP type).\n */\n\nimport { MediaError, MESSAGES } from \"./errors\";\n\nexport const ACCEPTED_TYPES = [\"image/jpeg\", \"image/png\", \"image/webp\", \"image/gif\"] as const;\nexport const MAX_INPUT_BYTES = 10 * 1024 * 1024;\n\nexport interface ResizeOptions {\n /** Longest allowed width in pixels. Default 1600. */\n maxWidth?: number;\n /** Starting WebP quality 0–1. Default 0.82. */\n quality?: number;\n /** Target ceiling for the encoded size. Default 600KB. */\n maxBytes?: number;\n}\n\nexport interface ResizedImage {\n blob: Blob;\n width: number;\n height: number;\n}\n\n/** Pure: fit (width × height) inside maxWidth, never scaling up, whole pixels. */\nexport function fitWithin(width: number, height: number, maxWidth: number): { width: number; height: number } {\n if (width <= maxWidth) return { width, height };\n const ratio = maxWidth / width;\n return { width: Math.round(width * ratio), height: Math.max(1, Math.round(height * ratio)) };\n}\n\n/** The type/size guards, separated so they can run (and be tested) without a canvas. */\nexport function checkInput(file: { type: string; size: number }): void {\n if (!(ACCEPTED_TYPES as readonly string[]).includes(file.type)) throw new MediaError(MESSAGES.type);\n if (file.size > MAX_INPUT_BYTES) throw new MediaError(MESSAGES.size);\n}\n\nexport async function resizeImage(file: File, opts: ResizeOptions = {}): Promise<ResizedImage> {\n checkInput(file);\n const { maxWidth = 1600, quality = 0.82, maxBytes = 600 * 1024 } = opts;\n\n const bitmap = await decode(file);\n const { width, height } = fitWithin(bitmap.width, bitmap.height, maxWidth);\n const canvas = makeCanvas(width, height);\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) throw new MediaError(MESSAGES.encode);\n ctx.drawImage(bitmap, 0, 0, width, height);\n bitmap.close?.();\n\n let q = quality;\n let blob = await encode(canvas, q);\n while (blob.size > maxBytes && q - 0.08 >= 0.5) {\n q -= 0.08;\n blob = await encode(canvas, q);\n }\n return { blob, width, height };\n}\n\nasync function decode(file: File): Promise<ImageBitmap> {\n try {\n return await createImageBitmap(file);\n } catch {\n throw new MediaError(MESSAGES.encode);\n }\n}\n\ntype AnyCanvas = HTMLCanvasElement | OffscreenCanvas;\n\nfunction makeCanvas(width: number, height: number): AnyCanvas {\n if (typeof OffscreenCanvas !== \"undefined\") return new OffscreenCanvas(width, height);\n const c = document.createElement(\"canvas\");\n c.width = width;\n c.height = height;\n return c;\n}\n\nasync function encode(canvas: AnyCanvas, quality: number): Promise<Blob> {\n const blob =\n \"convertToBlob\" in canvas\n ? await canvas.convertToBlob({ type: \"image/webp\", quality }).catch(() => null)\n : await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, \"image/webp\", quality));\n if (!blob || blob.type !== \"image/webp\") throw new MediaError(MESSAGES.encode);\n return blob;\n}\n","/**\n * Where a picture lives, decided from the post's slug so the path is known before\n * anything is committed: hero → /blog/{slug}.webp, social → /blog/{slug}-social.webp,\n * body → /blog/{slug}/{name}.webp. The host commits the file under public/ + path.\n */\n\nexport type ImageKind = \"hero\" | \"social\" | \"body\";\n\nexport interface PathOptions {\n /** URL prefix the pictures are served from. Default \"/blog\". */\n publicDir?: string;\n}\n\n/** Lower-case letters, digits and single hyphens; empty in → \"image\". */\nexport function slugifyName(name: string): string {\n const base = name.replace(/\\.[a-z0-9]+$/i, \"\");\n const s = base\n .normalize(\"NFKD\")\n .replace(/[̀-ͯ]/g, \"\")\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\");\n return s || \"image\";\n}\n\nexport function imagePathFor(kind: ImageKind, slug: string, originalName = \"\", opts: PathOptions = {}): string {\n const dir = (opts.publicDir ?? \"/blog\").replace(/\\/$/, \"\");\n switch (kind) {\n case \"hero\":\n return `${dir}/${slug}.webp`;\n case \"social\":\n return `${dir}/${slug}-social.webp`;\n case \"body\":\n return `${dir}/${slug}/${slugifyName(originalName)}.webp`;\n }\n}\n","/**\n * Server-side checks on the pictures a save carries. Pure.\n *\n * The browser already shrank them, but anyone can POST to a server action, so\n * the path, type and size are re-checked before anything is committed: only\n * WebP under the type's public folder, ≤ 800 KB each, ≤ 3.5 MB per save.\n * Frontmatter paths are URL paths (/blog/x.webp); the repo file lives under public/.\n */\n\nexport interface ImagePayload {\n /** URL path as written in the frontmatter or body, e.g. /blog/my-post.webp. */\n path: string;\n /** Base64 of the WebP bytes. */\n base64: string;\n}\n\nexport interface ImageLimits {\n maxEach: number;\n maxTotal: number;\n /** URL prefix pictures must live under. */\n prefix: string;\n}\n\nexport const IMAGE_LIMITS: ImageLimits = { maxEach: 800 * 1024, maxTotal: 3.5 * 1024 * 1024, prefix: \"/blog/\" };\n\nexport const IMAGE_ERRORS = {\n path: \"That image path isn’t allowed.\",\n size: \"That picture is too large to send — try a smaller one.\",\n} as const;\n\nexport type ValidatedImages =\n | { ok: true; files: { path: string; content: string; encoding: \"base64\" }[] }\n | { ok: false; error: string };\n\nconst SAFE_PATH = /^[a-z0-9][a-z0-9/_-]*\\.webp$/;\n\n/** Bytes a base64 string decodes to, without decoding it. */\nexport function decodedSize(b64: string): number {\n const clean = b64.replace(/\\s+/g, \"\");\n const pad = clean.endsWith(\"==\") ? 2 : clean.endsWith(\"=\") ? 1 : 0;\n return Math.floor((clean.length * 3) / 4) - pad;\n}\n\nexport function validateImages(images: ImagePayload[], limits: ImageLimits = IMAGE_LIMITS): ValidatedImages {\n let total = 0;\n const files: { path: string; content: string; encoding: \"base64\" }[] = [];\n for (const img of images) {\n if (typeof img?.path !== \"string\" || typeof img?.base64 !== \"string\") return { ok: false, error: IMAGE_ERRORS.path };\n if (!img.path.startsWith(limits.prefix) || img.path.includes(\"..\")) return { ok: false, error: IMAGE_ERRORS.path };\n const rel = img.path.slice(1);\n if (!SAFE_PATH.test(rel)) return { ok: false, error: IMAGE_ERRORS.path };\n const size = decodedSize(img.base64);\n if (size === 0 || size > limits.maxEach) return { ok: false, error: IMAGE_ERRORS.size };\n total += size;\n if (total > limits.maxTotal) return { ok: false, error: IMAGE_ERRORS.size };\n files.push({ path: `public/${rel}`, content: img.base64.replace(/\\s+/g, \"\"), encoding: \"base64\" });\n }\n return { ok: true, files };\n}\n"]}
@@ -1,3 +1,5 @@
1
+ export { I as IMAGE_ERRORS, a as IMAGE_LIMITS, b as ImageLimits, c as ImagePayload, V as ValidatedImages, d as decodedSize, v as validateImages } from '../validate-C84Ll1cD.cjs';
2
+
1
3
  /**
2
4
  * resizeImage — shrink a picture in the browser to web size, as WebP.
3
5
  *
@@ -1,3 +1,5 @@
1
+ export { I as IMAGE_ERRORS, a as IMAGE_LIMITS, b as ImageLimits, c as ImagePayload, V as ValidatedImages, d as decodedSize, v as validateImages } from '../validate-C84Ll1cD.js';
2
+
1
3
  /**
2
4
  * resizeImage — shrink a picture in the browser to web size, as WebP.
3
5
  *
@@ -80,6 +80,35 @@ function imagePathFor(kind, slug, originalName = "", opts = {}) {
80
80
  }
81
81
  }
82
82
 
83
- export { ACCEPTED_TYPES, MAX_INPUT_BYTES, MESSAGES as MEDIA_MESSAGES, MediaError, checkInput, fitWithin, imagePathFor, resizeImage, slugifyName };
83
+ // src/media/validate.ts
84
+ var IMAGE_LIMITS = { maxEach: 800 * 1024, maxTotal: 3.5 * 1024 * 1024, prefix: "/blog/" };
85
+ var IMAGE_ERRORS = {
86
+ path: "That image path isn\u2019t allowed.",
87
+ size: "That picture is too large to send \u2014 try a smaller one."
88
+ };
89
+ var SAFE_PATH = /^[a-z0-9][a-z0-9/_-]*\.webp$/;
90
+ function decodedSize(b64) {
91
+ const clean = b64.replace(/\s+/g, "");
92
+ const pad = clean.endsWith("==") ? 2 : clean.endsWith("=") ? 1 : 0;
93
+ return Math.floor(clean.length * 3 / 4) - pad;
94
+ }
95
+ function validateImages(images, limits = IMAGE_LIMITS) {
96
+ let total = 0;
97
+ const files = [];
98
+ for (const img of images) {
99
+ if (typeof img?.path !== "string" || typeof img?.base64 !== "string") return { ok: false, error: IMAGE_ERRORS.path };
100
+ if (!img.path.startsWith(limits.prefix) || img.path.includes("..")) return { ok: false, error: IMAGE_ERRORS.path };
101
+ const rel = img.path.slice(1);
102
+ if (!SAFE_PATH.test(rel)) return { ok: false, error: IMAGE_ERRORS.path };
103
+ const size = decodedSize(img.base64);
104
+ if (size === 0 || size > limits.maxEach) return { ok: false, error: IMAGE_ERRORS.size };
105
+ total += size;
106
+ if (total > limits.maxTotal) return { ok: false, error: IMAGE_ERRORS.size };
107
+ files.push({ path: `public/${rel}`, content: img.base64.replace(/\s+/g, ""), encoding: "base64" });
108
+ }
109
+ return { ok: true, files };
110
+ }
111
+
112
+ export { ACCEPTED_TYPES, IMAGE_ERRORS, IMAGE_LIMITS, MAX_INPUT_BYTES, MESSAGES as MEDIA_MESSAGES, MediaError, checkInput, decodedSize, fitWithin, imagePathFor, resizeImage, slugifyName, validateImages };
84
113
  //# sourceMappingURL=index.js.map
85
114
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/media/errors.ts","../../src/media/resize.ts","../../src/media/paths.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/** A picture problem the person can act on. The message is the sentence shown under the control. */\nexport class MediaError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"MediaError\";\n }\n}\n\nexport const MESSAGES = {\n type: \"That file type isn’t supported — use a JPG, PNG or WebP.\",\n size: \"That picture is too big to send — pick one under 10MB.\",\n encode: \"This browser can’t prepare pictures — try Chrome, Edge or Safari 16+.\",\n} as const;\n","/**\n * resizeImage — shrink a picture in the browser to web size, as WebP.\n *\n * Phone photos are 3–8MB; a Vercel server action body is capped at 4.5MB and the\n * GitHub API wants base64. So the shrink happens here, before anything leaves the\n * browser: decode → fit inside maxWidth → draw to a canvas → encode WebP, lowering\n * quality in steps until the result is under maxBytes. GIFs lose their animation\n * (a still frame is encoded) — acceptable for a blog.\n *\n * No network, no dependencies. Throws MediaError with a plain sentence for the\n * three things that can go wrong: wrong type, too big to even try, or a browser\n * that cannot encode WebP (toBlob returns null or a non-WebP type).\n */\n\nimport { MediaError, MESSAGES } from \"./errors\";\n\nexport const ACCEPTED_TYPES = [\"image/jpeg\", \"image/png\", \"image/webp\", \"image/gif\"] as const;\nexport const MAX_INPUT_BYTES = 10 * 1024 * 1024;\n\nexport interface ResizeOptions {\n /** Longest allowed width in pixels. Default 1600. */\n maxWidth?: number;\n /** Starting WebP quality 0–1. Default 0.82. */\n quality?: number;\n /** Target ceiling for the encoded size. Default 600KB. */\n maxBytes?: number;\n}\n\nexport interface ResizedImage {\n blob: Blob;\n width: number;\n height: number;\n}\n\n/** Pure: fit (width × height) inside maxWidth, never scaling up, whole pixels. */\nexport function fitWithin(width: number, height: number, maxWidth: number): { width: number; height: number } {\n if (width <= maxWidth) return { width, height };\n const ratio = maxWidth / width;\n return { width: Math.round(width * ratio), height: Math.max(1, Math.round(height * ratio)) };\n}\n\n/** The type/size guards, separated so they can run (and be tested) without a canvas. */\nexport function checkInput(file: { type: string; size: number }): void {\n if (!(ACCEPTED_TYPES as readonly string[]).includes(file.type)) throw new MediaError(MESSAGES.type);\n if (file.size > MAX_INPUT_BYTES) throw new MediaError(MESSAGES.size);\n}\n\nexport async function resizeImage(file: File, opts: ResizeOptions = {}): Promise<ResizedImage> {\n checkInput(file);\n const { maxWidth = 1600, quality = 0.82, maxBytes = 600 * 1024 } = opts;\n\n const bitmap = await decode(file);\n const { width, height } = fitWithin(bitmap.width, bitmap.height, maxWidth);\n const canvas = makeCanvas(width, height);\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) throw new MediaError(MESSAGES.encode);\n ctx.drawImage(bitmap, 0, 0, width, height);\n bitmap.close?.();\n\n let q = quality;\n let blob = await encode(canvas, q);\n while (blob.size > maxBytes && q - 0.08 >= 0.5) {\n q -= 0.08;\n blob = await encode(canvas, q);\n }\n return { blob, width, height };\n}\n\nasync function decode(file: File): Promise<ImageBitmap> {\n try {\n return await createImageBitmap(file);\n } catch {\n throw new MediaError(MESSAGES.encode);\n }\n}\n\ntype AnyCanvas = HTMLCanvasElement | OffscreenCanvas;\n\nfunction makeCanvas(width: number, height: number): AnyCanvas {\n if (typeof OffscreenCanvas !== \"undefined\") return new OffscreenCanvas(width, height);\n const c = document.createElement(\"canvas\");\n c.width = width;\n c.height = height;\n return c;\n}\n\nasync function encode(canvas: AnyCanvas, quality: number): Promise<Blob> {\n const blob =\n \"convertToBlob\" in canvas\n ? await canvas.convertToBlob({ type: \"image/webp\", quality }).catch(() => null)\n : await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, \"image/webp\", quality));\n if (!blob || blob.type !== \"image/webp\") throw new MediaError(MESSAGES.encode);\n return blob;\n}\n","/**\n * Where a picture lives, decided from the post's slug so the path is known before\n * anything is committed: hero → /blog/{slug}.webp, social → /blog/{slug}-social.webp,\n * body → /blog/{slug}/{name}.webp. The host commits the file under public/ + path.\n */\n\nexport type ImageKind = \"hero\" | \"social\" | \"body\";\n\nexport interface PathOptions {\n /** URL prefix the pictures are served from. Default \"/blog\". */\n publicDir?: string;\n}\n\n/** Lower-case letters, digits and single hyphens; empty in → \"image\". */\nexport function slugifyName(name: string): string {\n const base = name.replace(/\\.[a-z0-9]+$/i, \"\");\n const s = base\n .normalize(\"NFKD\")\n .replace(/[̀-ͯ]/g, \"\")\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\");\n return s || \"image\";\n}\n\nexport function imagePathFor(kind: ImageKind, slug: string, originalName = \"\", opts: PathOptions = {}): string {\n const dir = (opts.publicDir ?? \"/blog\").replace(/\\/$/, \"\");\n switch (kind) {\n case \"hero\":\n return `${dir}/${slug}.webp`;\n case \"social\":\n return `${dir}/${slug}-social.webp`;\n case \"body\":\n return `${dir}/${slug}/${slugifyName(originalName)}.webp`;\n }\n}\n"]}
1
+ {"version":3,"sources":["../../src/media/errors.ts","../../src/media/resize.ts","../../src/media/paths.ts","../../src/media/validate.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/** A picture problem the person can act on. The message is the sentence shown under the control. */\nexport class MediaError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"MediaError\";\n }\n}\n\nexport const MESSAGES = {\n type: \"That file type isn’t supported — use a JPG, PNG or WebP.\",\n size: \"That picture is too big to send — pick one under 10MB.\",\n encode: \"This browser can’t prepare pictures — try Chrome, Edge or Safari 16+.\",\n} as const;\n","/**\n * resizeImage — shrink a picture in the browser to web size, as WebP.\n *\n * Phone photos are 3–8MB; a Vercel server action body is capped at 4.5MB and the\n * GitHub API wants base64. So the shrink happens here, before anything leaves the\n * browser: decode → fit inside maxWidth → draw to a canvas → encode WebP, lowering\n * quality in steps until the result is under maxBytes. GIFs lose their animation\n * (a still frame is encoded) — acceptable for a blog.\n *\n * No network, no dependencies. Throws MediaError with a plain sentence for the\n * three things that can go wrong: wrong type, too big to even try, or a browser\n * that cannot encode WebP (toBlob returns null or a non-WebP type).\n */\n\nimport { MediaError, MESSAGES } from \"./errors\";\n\nexport const ACCEPTED_TYPES = [\"image/jpeg\", \"image/png\", \"image/webp\", \"image/gif\"] as const;\nexport const MAX_INPUT_BYTES = 10 * 1024 * 1024;\n\nexport interface ResizeOptions {\n /** Longest allowed width in pixels. Default 1600. */\n maxWidth?: number;\n /** Starting WebP quality 0–1. Default 0.82. */\n quality?: number;\n /** Target ceiling for the encoded size. Default 600KB. */\n maxBytes?: number;\n}\n\nexport interface ResizedImage {\n blob: Blob;\n width: number;\n height: number;\n}\n\n/** Pure: fit (width × height) inside maxWidth, never scaling up, whole pixels. */\nexport function fitWithin(width: number, height: number, maxWidth: number): { width: number; height: number } {\n if (width <= maxWidth) return { width, height };\n const ratio = maxWidth / width;\n return { width: Math.round(width * ratio), height: Math.max(1, Math.round(height * ratio)) };\n}\n\n/** The type/size guards, separated so they can run (and be tested) without a canvas. */\nexport function checkInput(file: { type: string; size: number }): void {\n if (!(ACCEPTED_TYPES as readonly string[]).includes(file.type)) throw new MediaError(MESSAGES.type);\n if (file.size > MAX_INPUT_BYTES) throw new MediaError(MESSAGES.size);\n}\n\nexport async function resizeImage(file: File, opts: ResizeOptions = {}): Promise<ResizedImage> {\n checkInput(file);\n const { maxWidth = 1600, quality = 0.82, maxBytes = 600 * 1024 } = opts;\n\n const bitmap = await decode(file);\n const { width, height } = fitWithin(bitmap.width, bitmap.height, maxWidth);\n const canvas = makeCanvas(width, height);\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) throw new MediaError(MESSAGES.encode);\n ctx.drawImage(bitmap, 0, 0, width, height);\n bitmap.close?.();\n\n let q = quality;\n let blob = await encode(canvas, q);\n while (blob.size > maxBytes && q - 0.08 >= 0.5) {\n q -= 0.08;\n blob = await encode(canvas, q);\n }\n return { blob, width, height };\n}\n\nasync function decode(file: File): Promise<ImageBitmap> {\n try {\n return await createImageBitmap(file);\n } catch {\n throw new MediaError(MESSAGES.encode);\n }\n}\n\ntype AnyCanvas = HTMLCanvasElement | OffscreenCanvas;\n\nfunction makeCanvas(width: number, height: number): AnyCanvas {\n if (typeof OffscreenCanvas !== \"undefined\") return new OffscreenCanvas(width, height);\n const c = document.createElement(\"canvas\");\n c.width = width;\n c.height = height;\n return c;\n}\n\nasync function encode(canvas: AnyCanvas, quality: number): Promise<Blob> {\n const blob =\n \"convertToBlob\" in canvas\n ? await canvas.convertToBlob({ type: \"image/webp\", quality }).catch(() => null)\n : await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, \"image/webp\", quality));\n if (!blob || blob.type !== \"image/webp\") throw new MediaError(MESSAGES.encode);\n return blob;\n}\n","/**\n * Where a picture lives, decided from the post's slug so the path is known before\n * anything is committed: hero → /blog/{slug}.webp, social → /blog/{slug}-social.webp,\n * body → /blog/{slug}/{name}.webp. The host commits the file under public/ + path.\n */\n\nexport type ImageKind = \"hero\" | \"social\" | \"body\";\n\nexport interface PathOptions {\n /** URL prefix the pictures are served from. Default \"/blog\". */\n publicDir?: string;\n}\n\n/** Lower-case letters, digits and single hyphens; empty in → \"image\". */\nexport function slugifyName(name: string): string {\n const base = name.replace(/\\.[a-z0-9]+$/i, \"\");\n const s = base\n .normalize(\"NFKD\")\n .replace(/[̀-ͯ]/g, \"\")\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\");\n return s || \"image\";\n}\n\nexport function imagePathFor(kind: ImageKind, slug: string, originalName = \"\", opts: PathOptions = {}): string {\n const dir = (opts.publicDir ?? \"/blog\").replace(/\\/$/, \"\");\n switch (kind) {\n case \"hero\":\n return `${dir}/${slug}.webp`;\n case \"social\":\n return `${dir}/${slug}-social.webp`;\n case \"body\":\n return `${dir}/${slug}/${slugifyName(originalName)}.webp`;\n }\n}\n","/**\n * Server-side checks on the pictures a save carries. Pure.\n *\n * The browser already shrank them, but anyone can POST to a server action, so\n * the path, type and size are re-checked before anything is committed: only\n * WebP under the type's public folder, ≤ 800 KB each, ≤ 3.5 MB per save.\n * Frontmatter paths are URL paths (/blog/x.webp); the repo file lives under public/.\n */\n\nexport interface ImagePayload {\n /** URL path as written in the frontmatter or body, e.g. /blog/my-post.webp. */\n path: string;\n /** Base64 of the WebP bytes. */\n base64: string;\n}\n\nexport interface ImageLimits {\n maxEach: number;\n maxTotal: number;\n /** URL prefix pictures must live under. */\n prefix: string;\n}\n\nexport const IMAGE_LIMITS: ImageLimits = { maxEach: 800 * 1024, maxTotal: 3.5 * 1024 * 1024, prefix: \"/blog/\" };\n\nexport const IMAGE_ERRORS = {\n path: \"That image path isn’t allowed.\",\n size: \"That picture is too large to send — try a smaller one.\",\n} as const;\n\nexport type ValidatedImages =\n | { ok: true; files: { path: string; content: string; encoding: \"base64\" }[] }\n | { ok: false; error: string };\n\nconst SAFE_PATH = /^[a-z0-9][a-z0-9/_-]*\\.webp$/;\n\n/** Bytes a base64 string decodes to, without decoding it. */\nexport function decodedSize(b64: string): number {\n const clean = b64.replace(/\\s+/g, \"\");\n const pad = clean.endsWith(\"==\") ? 2 : clean.endsWith(\"=\") ? 1 : 0;\n return Math.floor((clean.length * 3) / 4) - pad;\n}\n\nexport function validateImages(images: ImagePayload[], limits: ImageLimits = IMAGE_LIMITS): ValidatedImages {\n let total = 0;\n const files: { path: string; content: string; encoding: \"base64\" }[] = [];\n for (const img of images) {\n if (typeof img?.path !== \"string\" || typeof img?.base64 !== \"string\") return { ok: false, error: IMAGE_ERRORS.path };\n if (!img.path.startsWith(limits.prefix) || img.path.includes(\"..\")) return { ok: false, error: IMAGE_ERRORS.path };\n const rel = img.path.slice(1);\n if (!SAFE_PATH.test(rel)) return { ok: false, error: IMAGE_ERRORS.path };\n const size = decodedSize(img.base64);\n if (size === 0 || size > limits.maxEach) return { ok: false, error: IMAGE_ERRORS.size };\n total += size;\n if (total > limits.maxTotal) return { ok: false, error: IMAGE_ERRORS.size };\n files.push({ path: `public/${rel}`, content: img.base64.replace(/\\s+/g, \"\"), encoding: \"base64\" });\n }\n return { ok: true, files };\n}\n"]}
@@ -556,7 +556,7 @@ async function writeFiles(ctx, branch, files, message, opts = {}) {
556
556
  return { branch, commitSha: commit.sha, treeSha: newTree.sha, fileCount: files.length + remove.length };
557
557
  }
558
558
 
559
- // src/studio/images.ts
559
+ // src/media/validate.ts
560
560
  var IMAGE_LIMITS = { maxEach: 800 * 1024, maxTotal: 3.5 * 1024 * 1024, prefix: "/blog/" };
561
561
  var IMAGE_ERRORS = {
562
562
  path: "That image path isn\u2019t allowed.",