@smallpen/local-package 0.1.0-alpha.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.
@@ -0,0 +1,486 @@
1
+ import { dirname, join, resolve } from "node:path";
2
+
3
+ import { SmallPenError } from "@smallpen/core";
4
+
5
+ import { LocalPackageBackend } from "./local-package-backend.mjs";
6
+ import { resolveWorkspace } from "./workspace.mjs";
7
+
8
+ function collectStableIds(value, result) {
9
+ if (Array.isArray(value)) {
10
+ for (const child of value) collectStableIds(child, result);
11
+ return;
12
+ }
13
+ if (!value || typeof value !== "object") return;
14
+ if (typeof value.id === "string") result.add(value.id);
15
+ for (const child of Object.values(value)) collectStableIds(child, result);
16
+ }
17
+
18
+ function screenById(snapshot, screenId) {
19
+ const entry = snapshot.manifest.entries.screens.find(
20
+ (candidate) => snapshot.entries[candidate].id === screenId,
21
+ );
22
+ return entry ? snapshot.entries[entry] : undefined;
23
+ }
24
+
25
+ function defaultTokenLibrary() {
26
+ return {
27
+ activeSetIds: [],
28
+ activeThemeIds: ["theme_default"],
29
+ id: "tlib_default",
30
+ sets: [
31
+ {
32
+ description: "",
33
+ id: "tset_theme_default",
34
+ name: "Theme/Default",
35
+ tokens: [],
36
+ },
37
+ ],
38
+ themes: [
39
+ {
40
+ description: "",
41
+ externalId: "",
42
+ group: "Theme",
43
+ id: "theme_default",
44
+ isSource: false,
45
+ name: "Default",
46
+ setIds: ["tset_theme_default"],
47
+ },
48
+ ],
49
+ };
50
+ }
51
+
52
+ async function seedDefaultTheme(backend, snapshot) {
53
+ if (snapshot.manifest.entries.tokens.length > 0) return snapshot;
54
+ await backend.commit({
55
+ baseRevision: snapshot.revision,
56
+ batchId: `seed-default-theme-${snapshot.revision.slice(0, 12)}`,
57
+ operations: [
58
+ {
59
+ entry: "tokens/tokens.json",
60
+ library: defaultTokenLibrary(),
61
+ type: "replace-token-library",
62
+ },
63
+ ],
64
+ });
65
+ return backend.snapshot;
66
+ }
67
+
68
+ export function reconcilePackageViewState(snapshot, value = {}) {
69
+ const firstScreenEntry = snapshot.manifest.entries.screens[0];
70
+ const defaultScreen =
71
+ screenById(snapshot, snapshot.manifest.defaultScreenId) ??
72
+ (firstScreenEntry ? snapshot.entries[firstScreenEntry] : undefined);
73
+ const selectedScreen = screenById(snapshot, value.screenId) ?? defaultScreen;
74
+ const presentation =
75
+ selectedScreen?.presentations.find(
76
+ ({ id }) => id === value.presentationId,
77
+ ) ??
78
+ selectedScreen?.presentations.find(
79
+ ({ id }) => id === selectedScreen.basePresentationId,
80
+ );
81
+ const stableIds = new Set();
82
+ collectStableIds(snapshot.manifest, stableIds);
83
+ collectStableIds(snapshot.entries, stableIds);
84
+ return {
85
+ presentationId: presentation?.id,
86
+ screenId: selectedScreen?.id,
87
+ selectedIds: (value.selectedIds ?? []).filter((id) => stableIds.has(id)),
88
+ viewport: {
89
+ x: value.viewport?.x ?? 0,
90
+ y: value.viewport?.y ?? 0,
91
+ zoom: value.viewport?.zoom ?? 1,
92
+ },
93
+ };
94
+ }
95
+
96
+ export class LocalWorkspaceSession {
97
+ #libraryCacheRoot;
98
+ #listeners = new Set();
99
+ #packages = new Map();
100
+
101
+ activeLocator;
102
+
103
+ constructor({ libraryCacheRoot } = {}) {
104
+ this.#libraryCacheRoot = libraryCacheRoot;
105
+ }
106
+
107
+ get packages() {
108
+ return [...this.#packages.values()].map((session) => ({
109
+ active: session.locator === this.activeLocator,
110
+ changes: structuredClone(session.backend.changes),
111
+ draft: session.backend.snapshot.manifest.draft !== undefined,
112
+ locator: session.locator,
113
+ fileId: session.backend.snapshot.runtime.file,
114
+ name: session.backend.snapshot.manifest.name,
115
+ packageId: session.backend.snapshot.manifest.packageId,
116
+ revision: session.backend.snapshot.revision,
117
+ role: session.backend.snapshot.manifest.role,
118
+ status: structuredClone(session.workspaceStatus),
119
+ viewState: structuredClone(session.viewState),
120
+ }));
121
+ }
122
+
123
+ async open(locator, viewState = {}) {
124
+ const requested = resolve(locator);
125
+ const existing = this.#find(requested);
126
+ if (existing) {
127
+ this.activeLocator = existing.locator;
128
+ return this.describe(existing.locator);
129
+ }
130
+ const backend = new LocalPackageBackend();
131
+ let snapshot = await backend.open(requested);
132
+ const duplicate = [...this.#packages.values()].find(
133
+ (candidate) =>
134
+ candidate.backend.snapshot.runtime.file === snapshot.runtime.file,
135
+ );
136
+ if (duplicate) {
137
+ await backend.close();
138
+ throw new SmallPenError(
139
+ "duplicate_file_id",
140
+ "Another open Package has the same persistent file identity",
141
+ {
142
+ fileId: snapshot.runtime.file,
143
+ locator: snapshot.locator,
144
+ openLocator: duplicate.locator,
145
+ packageId: snapshot.manifest.packageId,
146
+ },
147
+ );
148
+ }
149
+ snapshot = await seedDefaultTheme(backend, snapshot);
150
+ const session = {
151
+ backend,
152
+ locator: snapshot.locator,
153
+ unsubscribe: undefined,
154
+ viewState: reconcilePackageViewState(snapshot, viewState),
155
+ workspaceStatus: structuredClone(backend.status),
156
+ };
157
+ session.unsubscribe = backend.subscribe((event) => {
158
+ if (event.snapshot) {
159
+ session.viewState = reconcilePackageViewState(
160
+ event.snapshot,
161
+ session.viewState,
162
+ );
163
+ }
164
+ void this.#updateWorkspaceStatus(session);
165
+ this.#emit({ ...event, locator: session.locator });
166
+ if (
167
+ event.type === "external-revision" ||
168
+ event.type === "invalid-external-state" ||
169
+ event.type === "external-recovered"
170
+ ) {
171
+ this.#emitDependencyChanges(session, event);
172
+ }
173
+ });
174
+ this.#packages.set(session.locator, session);
175
+ this.activeLocator ??= session.locator;
176
+ if (snapshot.manifest.role === "product") {
177
+ const dependency = snapshot.manifest.dependencies[0];
178
+ try {
179
+ await this.open(join(dirname(snapshot.locator), dependency.path));
180
+ } catch {
181
+ // Composite status below retains Product and exposes dependency Repair.
182
+ }
183
+ }
184
+ for (const library of snapshot.manifest.libraries ?? []) {
185
+ if (library.source.type !== "local") continue;
186
+ try {
187
+ await this.open(
188
+ resolve(dirname(snapshot.locator), library.source.path),
189
+ );
190
+ } catch {
191
+ // Composite status below retains the owner and exposes Library Repair.
192
+ }
193
+ }
194
+ await this.#updateWorkspaceStatus(session);
195
+ this.#emit({ locator: session.locator, snapshot, type: "package-opened" });
196
+ return this.describe(session.locator);
197
+ }
198
+
199
+ describe(locator) {
200
+ const session = this.#required(locator);
201
+ return {
202
+ backend: session.backend,
203
+ locator: session.locator,
204
+ snapshot: session.backend.snapshot,
205
+ status: structuredClone(session.workspaceStatus),
206
+ viewState: structuredClone(session.viewState),
207
+ workspace: structuredClone(
208
+ session.lastValidWorkspace ?? session.repairWorkspace,
209
+ ),
210
+ };
211
+ }
212
+
213
+ setActive(locator) {
214
+ const session = this.#required(locator);
215
+ this.activeLocator = session.locator;
216
+ this.#emit({ locator: session.locator, type: "active-package-changed" });
217
+ return this.describe(session.locator);
218
+ }
219
+
220
+ async refresh(locator) {
221
+ const session = this.#required(locator);
222
+ await this.#updateWorkspaceStatus(session);
223
+ return this.describe(session.locator);
224
+ }
225
+
226
+ updateViewState(locator, changes) {
227
+ const session = this.#required(locator);
228
+ session.viewState = reconcilePackageViewState(session.backend.snapshot, {
229
+ ...session.viewState,
230
+ ...structuredClone(changes),
231
+ viewport: {
232
+ ...session.viewState.viewport,
233
+ ...(changes.viewport ?? {}),
234
+ },
235
+ });
236
+ this.#emit({
237
+ locator: session.locator,
238
+ type: "package-view-state-changed",
239
+ viewState: structuredClone(session.viewState),
240
+ });
241
+ return structuredClone(session.viewState);
242
+ }
243
+
244
+ async commit(locator, batch) {
245
+ const session = this.#required(locator);
246
+ this.#assertWorkspaceWritable(session);
247
+ const result = await session.backend.commit(batch);
248
+ await this.#updateWorkspaceStatus(session);
249
+ session.viewState = reconcilePackageViewState(
250
+ session.backend.snapshot,
251
+ session.viewState,
252
+ );
253
+ this.#emit({
254
+ change: session.backend.changes.at(-1),
255
+ locator: session.locator,
256
+ result,
257
+ type: "package-committed",
258
+ });
259
+ return result;
260
+ }
261
+
262
+ async undo(locator) {
263
+ const session = this.#required(locator);
264
+ this.#assertWorkspaceWritable(session);
265
+ const result = await session.backend.undo();
266
+ await this.#updateWorkspaceStatus(session);
267
+ session.viewState = reconcilePackageViewState(
268
+ session.backend.snapshot,
269
+ session.viewState,
270
+ );
271
+ this.#emit({ locator: session.locator, result, type: "package-undone" });
272
+ return result;
273
+ }
274
+
275
+ async redo(locator) {
276
+ const session = this.#required(locator);
277
+ this.#assertWorkspaceWritable(session);
278
+ const result = await session.backend.redo();
279
+ await this.#updateWorkspaceStatus(session);
280
+ session.viewState = reconcilePackageViewState(
281
+ session.backend.snapshot,
282
+ session.viewState,
283
+ );
284
+ this.#emit({ locator: session.locator, result, type: "package-redone" });
285
+ return result;
286
+ }
287
+
288
+ async save(locator) {
289
+ const session = this.#required(locator);
290
+ this.#assertWorkspaceWritable(session);
291
+ const result = await session.backend.save();
292
+ await this.#updateWorkspaceStatus(session);
293
+ return result;
294
+ }
295
+
296
+ importMedia(locator, media) {
297
+ return this.#write(locator, "package-media-imported", (backend) =>
298
+ backend.importMedia(media),
299
+ );
300
+ }
301
+
302
+ importFontVariant(locator, font) {
303
+ return this.#write(locator, "package-font-variant-imported", (backend) =>
304
+ backend.importFontVariant(font),
305
+ );
306
+ }
307
+
308
+ updateFontFamily(locator, fontId, family) {
309
+ return this.#write(locator, "package-font-updated", (backend) =>
310
+ backend.updateFontFamily(fontId, family),
311
+ );
312
+ }
313
+
314
+ deleteFontFamily(locator, fontId) {
315
+ return this.#write(locator, "package-font-deleted", (backend) =>
316
+ backend.deleteFontFamily(fontId),
317
+ );
318
+ }
319
+
320
+ deleteFontVariant(locator, fontVariantId) {
321
+ return this.#write(locator, "package-font-variant-deleted", (backend) =>
322
+ backend.deleteFontVariant(fontVariantId),
323
+ );
324
+ }
325
+
326
+ subscribe(listener) {
327
+ this.#listeners.add(listener);
328
+ return () => this.#listeners.delete(listener);
329
+ }
330
+
331
+ async close(locator) {
332
+ const session = this.#required(locator);
333
+ session.unsubscribe?.();
334
+ await session.backend.close();
335
+ this.#packages.delete(session.locator);
336
+ if (this.activeLocator === session.locator) {
337
+ this.activeLocator = this.#packages.keys().next().value;
338
+ }
339
+ this.#emit({ locator: session.locator, type: "package-closed" });
340
+ }
341
+
342
+ async closeAll() {
343
+ for (const locator of [...this.#packages.keys()]) await this.close(locator);
344
+ }
345
+
346
+ #find(locator) {
347
+ return (
348
+ this.#packages.get(locator) ??
349
+ [...this.#packages.values()].find(
350
+ (session) => resolve(session.locator) === resolve(locator),
351
+ )
352
+ );
353
+ }
354
+
355
+ #required(locator) {
356
+ const session = this.#find(locator);
357
+ if (!session) {
358
+ throw new SmallPenError(
359
+ "package_not_open",
360
+ `Package is not open: ${locator}`,
361
+ );
362
+ }
363
+ return session;
364
+ }
365
+
366
+ #emitDependencyChanges(changed, event) {
367
+ const packageId = changed.backend.snapshot?.manifest.packageId;
368
+ if (!packageId) return;
369
+ for (const session of this.#packages.values()) {
370
+ if (session === changed) continue;
371
+ if (
372
+ session.backend.snapshot.manifest.dependencies?.some(
373
+ (dependency) => dependency.packageId === packageId,
374
+ ) ||
375
+ session.backend.snapshot.manifest.libraries?.some(
376
+ (library) => library.packageId === packageId,
377
+ )
378
+ ) {
379
+ void this.#updateWorkspaceStatus(session).then(() => {
380
+ this.#emit({
381
+ dependencyEvent: event.type,
382
+ dependencyLocator: changed.locator,
383
+ locator: session.locator,
384
+ status: structuredClone(session.workspaceStatus),
385
+ type: "package-dependency-changed",
386
+ });
387
+ });
388
+ }
389
+ }
390
+ }
391
+
392
+ async #updateWorkspaceStatus(session) {
393
+ try {
394
+ const resolution = await resolveWorkspace(session.locator, {
395
+ libraryCacheRoot: this.#libraryCacheRoot,
396
+ });
397
+ if (resolution.status === "ready") {
398
+ session.lastValidWorkspace = resolution.workspace;
399
+ session.repairWorkspace = undefined;
400
+ session.workspaceStatus = {
401
+ foundationRevision: resolution.workspace.foundation?.revision,
402
+ lastValidRevision: resolution.workspace.product.revision,
403
+ readOnly: false,
404
+ state: "ready",
405
+ warnings: resolution.warnings,
406
+ };
407
+ } else {
408
+ // On a first open there is no last-valid composite Workspace yet.
409
+ // Keep the resolved dependencies available for diagnostics without
410
+ // promoting the invalid Product to last-valid state. This lets Web
411
+ // identify the actual broken reference instead of failing on the
412
+ // first otherwise-valid Foundation instance it encounters.
413
+ session.repairWorkspace = {
414
+ ...(resolution.foundation
415
+ ? { foundation: resolution.foundation }
416
+ : {}),
417
+ libraries: resolution.libraries ?? [],
418
+ product: resolution.product,
419
+ };
420
+ session.workspaceStatus = {
421
+ conflicts: resolution.conflicts,
422
+ foundationRevision:
423
+ session.lastValidWorkspace?.foundation?.revision ??
424
+ resolution.foundation?.revision,
425
+ lastValidRevision:
426
+ session.lastValidWorkspace?.product?.revision ??
427
+ session.backend.snapshot.revision,
428
+ readOnly: true,
429
+ state: "repair",
430
+ };
431
+ }
432
+ } catch (error) {
433
+ session.workspaceStatus = {
434
+ error: {
435
+ code: error?.code ?? "invalid_workspace_state",
436
+ details: error?.details ?? {},
437
+ message: error instanceof Error ? error.message : String(error),
438
+ },
439
+ foundationRevision: session.lastValidWorkspace?.foundation?.revision,
440
+ lastValidRevision:
441
+ session.lastValidWorkspace?.product?.revision ??
442
+ session.backend.snapshot.revision,
443
+ readOnly: true,
444
+ state: "repair",
445
+ };
446
+ }
447
+ this.#emit({
448
+ locator: session.locator,
449
+ status: structuredClone(session.workspaceStatus),
450
+ type: "package-workspace-status",
451
+ });
452
+ return session.workspaceStatus;
453
+ }
454
+
455
+ async #write(locator, type, operation) {
456
+ const session = this.#required(locator);
457
+ this.#assertWorkspaceWritable(session);
458
+ const value = await operation(session.backend);
459
+ await this.#updateWorkspaceStatus(session);
460
+ session.viewState = reconcilePackageViewState(
461
+ session.backend.snapshot,
462
+ session.viewState,
463
+ );
464
+ this.#emit({
465
+ change: session.backend.changes.at(-1),
466
+ locator: session.locator,
467
+ type,
468
+ value,
469
+ });
470
+ return value;
471
+ }
472
+
473
+ #assertWorkspaceWritable(session) {
474
+ if (session.workspaceStatus?.readOnly) {
475
+ throw new SmallPenError(
476
+ "workspace_repair_read_only",
477
+ "Product Workspace is read-only until Package or Foundation Repair succeeds",
478
+ { status: session.workspaceStatus },
479
+ );
480
+ }
481
+ }
482
+
483
+ #emit(event) {
484
+ for (const listener of this.#listeners) listener(event);
485
+ }
486
+ }
@@ -0,0 +1,184 @@
1
+ import { SmallPenError } from "@smallpen/core";
2
+
3
+ const SIGNATURES = [
4
+ { bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], mimeType: "image/png" },
5
+ { bytes: [0xff, 0xd8, 0xff], mimeType: "image/jpeg" },
6
+ { ascii: "GIF87a", mimeType: "image/gif" },
7
+ { ascii: "GIF89a", mimeType: "image/gif" },
8
+ { ascii: "RIFF", mimeType: "image/webp" },
9
+ ];
10
+
11
+ function failMedia(message, details) {
12
+ throw new SmallPenError("invalid_media_file", message, details);
13
+ }
14
+
15
+ function ascii(bytes, start, length) {
16
+ return new TextDecoder("ascii").decode(bytes.slice(start, start + length));
17
+ }
18
+
19
+ // Detects an image media type from its leading signature bytes.
20
+ export function detectMediaType(bytes) {
21
+ for (const signature of SIGNATURES) {
22
+ if (signature.bytes) {
23
+ if (signature.bytes.every((value, index) => bytes[index] === value)) {
24
+ // RIFF alone is not WebP; the container must declare WEBP at offset 8.
25
+ if (signature.mimeType === "image/webp") {
26
+ if (ascii(bytes, 8, 4) !== "WEBP") continue;
27
+ }
28
+ return signature.mimeType;
29
+ }
30
+ } else if (ascii(bytes, 0, signature.ascii.length) === signature.ascii) {
31
+ return signature.mimeType;
32
+ }
33
+ }
34
+ if (bytes.subarray(0, 4096).some((byte) => byte === 0x3c)) {
35
+ // A leading '<' suggests text; SVG roots are detected by inspectMedia.
36
+ return "image/svg+xml";
37
+ }
38
+ return undefined;
39
+ }
40
+
41
+ function inspectPngDimensions(bytes) {
42
+ if (
43
+ bytes.byteLength < 24 ||
44
+ ascii(bytes, 12, 4) !== "IHDR"
45
+ ) {
46
+ failMedia("PNG has an invalid signature or IHDR");
47
+ }
48
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
49
+ return { height: view.getUint32(20), width: view.getUint32(16) };
50
+ }
51
+
52
+ function inspectJpegDimensions(bytes) {
53
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
54
+ let offset = 2;
55
+ while (offset + 9 <= bytes.byteLength) {
56
+ if (bytes[offset] !== 0xff) {
57
+ failMedia("JPEG has a corrupt marker structure");
58
+ }
59
+ const marker = bytes[offset + 1];
60
+ if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
61
+ offset += 2;
62
+ continue;
63
+ }
64
+ const length = view.getUint16(offset + 2);
65
+ if (
66
+ marker >= 0xc0 &&
67
+ marker <= 0xcf &&
68
+ marker !== 0xc4 &&
69
+ marker !== 0xc8 &&
70
+ marker !== 0xcc
71
+ ) {
72
+ const height = view.getUint16(offset + 5);
73
+ const width = view.getUint16(offset + 7);
74
+ if (marker === 0xc2) {
75
+ failMedia("Progressive JPEG media is not supported for import");
76
+ }
77
+ if (width <= 0 || height <= 0) failMedia("JPEG dimensions are invalid");
78
+ return { height, width };
79
+ }
80
+ offset += 2 + length;
81
+ }
82
+ failMedia("JPEG has no frame header");
83
+ }
84
+
85
+ function inspectGifDimensions(bytes) {
86
+ if (bytes.byteLength < 10) failMedia("GIF header is truncated");
87
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
88
+ const width = view.getUint16(6, true);
89
+ const height = view.getUint16(8, true);
90
+ if (width <= 0 || height <= 0) failMedia("GIF dimensions are invalid");
91
+ return { height, width };
92
+ }
93
+
94
+ function inspectWebpDimensions(bytes) {
95
+ if (bytes.byteLength < 30) failMedia("WebP header is truncated");
96
+ const format = ascii(bytes, 12, 4);
97
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
98
+ if (format === "VP8 ") {
99
+ if (bytes[23] !== 0x9d || bytes[24] !== 0x01 || bytes[25] !== 0x2a) {
100
+ failMedia("WebP VP8 start code is invalid");
101
+ }
102
+ const width = view.getUint16(26, true) & 0x3fff;
103
+ const height = view.getUint16(28, true) & 0x3fff;
104
+ return { height, width };
105
+ }
106
+ if (format === "VP8L") {
107
+ const bits = view.getUint32(21, true);
108
+ const width = (bits & 0x3fff) + 1;
109
+ const height = ((bits >> 14) & 0x3fff) + 1;
110
+ return { height, width };
111
+ }
112
+ if (format === "VP8X") {
113
+ const width = 1 + (bytes[24] | (bytes[25] << 8) | (bytes[26] << 16));
114
+ const height = 1 + (bytes[27] | (bytes[28] << 8) | (bytes[29] << 16));
115
+ return { height, width };
116
+ }
117
+ failMedia(`WebP chunk ${format} is not supported`);
118
+ }
119
+
120
+ // SVG text: reuse the same root attribute sniffing as flat draft imports.
121
+ function inspectSvgDimensions(bytes) {
122
+ let source;
123
+ try {
124
+ source = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
125
+ } catch {
126
+ failMedia("SVG is not valid UTF-8 text");
127
+ }
128
+ const root = /<svg\b[^>]*>/i.exec(source)?.[0];
129
+ if (!root) failMedia("SVG has no svg root element");
130
+ const attribute = (name) =>
131
+ new RegExp(`\\b${name}\\s*=\\s*["']([^"']+)["']`, "i").exec(root)?.[1];
132
+ const length = (value) => {
133
+ const match = /^\s*([0-9]+(?:\.[0-9]+)?)(?:px)?\s*$/i.exec(value ?? "");
134
+ return match ? Math.round(Number(match[1])) : undefined;
135
+ };
136
+ let width = length(attribute("width"));
137
+ let height = length(attribute("height"));
138
+ const viewBox = attribute("viewBox")?.trim().split(/[\s,]+/).map(Number);
139
+ if (
140
+ (!width || !height) &&
141
+ viewBox?.length === 4 &&
142
+ viewBox.every(Number.isFinite)
143
+ ) {
144
+ width ??= Math.round(viewBox[2]);
145
+ height ??= Math.round(viewBox[3]);
146
+ }
147
+ if (
148
+ !Number.isSafeInteger(width) ||
149
+ width <= 0 ||
150
+ !Number.isSafeInteger(height) ||
151
+ height <= 0
152
+ ) {
153
+ failMedia("SVG dimensions are invalid");
154
+ }
155
+ return { height, width };
156
+ }
157
+
158
+ // Returns { mimeType, height, width } for supported image media bytes.
159
+ export function inspectMedia(bytes, mimeType) {
160
+ const detected = detectMediaType(bytes);
161
+ const resolved = mimeType ?? detected;
162
+ if (resolved !== "image/svg+xml" && detected === undefined) {
163
+ failMedia("Media bytes do not match a supported image signature", {
164
+ mimeType: resolved,
165
+ });
166
+ }
167
+ if (resolved === undefined) {
168
+ failMedia("Media type could not be detected");
169
+ }
170
+ if (resolved === "image/svg+xml") {
171
+ return { ...inspectSvgDimensions(bytes), mimeType: resolved };
172
+ }
173
+ const dimensions =
174
+ resolved === "image/png"
175
+ ? inspectPngDimensions(bytes)
176
+ : resolved === "image/jpeg"
177
+ ? inspectJpegDimensions(bytes)
178
+ : resolved === "image/gif"
179
+ ? inspectGifDimensions(bytes)
180
+ : resolved === "image/webp"
181
+ ? inspectWebpDimensions(bytes)
182
+ : failMedia(`${resolved} media is not supported`);
183
+ return { ...dimensions, mimeType: resolved };
184
+ }