@mdzip/core-js 1.2.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,1781 @@
1
+ import JSZip from '@progress/jszip-esm';
2
+ const PRODUCER_SPEC_VERSION = '1.1.0';
3
+ const CORE_LIBRARY_VERSION = '1.2.0';
4
+ const CORE_LIBRARY_URL = 'https://github.com/mdzip-project/mdzip-core-js';
5
+ /**
6
+ * Extension-to-MIME map for common image assets in MDZip archives.
7
+ */
8
+ export const MDZ_IMAGE_MIME_TYPES = {
9
+ png: 'image/png',
10
+ jpg: 'image/jpeg',
11
+ jpeg: 'image/jpeg',
12
+ gif: 'image/gif',
13
+ webp: 'image/webp',
14
+ svg: 'image/svg+xml',
15
+ avif: 'image/avif',
16
+ ico: 'image/x-icon'
17
+ };
18
+ /**
19
+ * Core archive reader/validator/mutator for `.mdz` files.
20
+ */
21
+ export class MdzArchiveCore {
22
+ zip;
23
+ /**
24
+ * Public image MIME map for consumers.
25
+ */
26
+ static IMAGE_MIME_TYPES = MDZ_IMAGE_MIME_TYPES;
27
+ static SUPPORTED_MDZ_MAJOR = 1;
28
+ static SUPPORTED_MODES = ['document', 'project'];
29
+ static SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
30
+ static MARKDOWN_IMAGE_REF_RE = /!\[[^\]]*\]\(([^)\n]+)\)/g;
31
+ static entriesCache = new WeakMap();
32
+ static manifestCache = new WeakMap();
33
+ /**
34
+ * Creates an archive wrapper around a previously loaded ZIP object.
35
+ *
36
+ * @param zip - Loaded zip-like structure containing archive entries.
37
+ */
38
+ constructor(zip) {
39
+ this.zip = zip;
40
+ }
41
+ /**
42
+ * Opens archive binary data and returns a core archive instance.
43
+ *
44
+ * @param input - Raw archive bytes.
45
+ * @param zipFactory - Optional custom zip loader.
46
+ */
47
+ static async open(input, zipFactory) {
48
+ const factory = zipFactory ?? MdzArchiveCore.getDefaultZipFactory();
49
+ const zip = await factory.loadAsync(input);
50
+ return new MdzArchiveCore(zip);
51
+ }
52
+ /**
53
+ * Convenience helper to open and validate an archive in one call.
54
+ *
55
+ * @param input - Raw archive bytes.
56
+ * @param zipFactory - Optional custom zip loader.
57
+ */
58
+ static async validate(input, zipFactory) {
59
+ const archive = await MdzArchiveCore.open(input, zipFactory);
60
+ return archive.validate();
61
+ }
62
+ /**
63
+ * Adds or replaces an archive entry and returns a newly generated archive blob.
64
+ *
65
+ * Also validates entry-point integrity and refreshes manifest metadata when present.
66
+ *
67
+ * @param input - Existing archive bytes.
68
+ * @param archiveEntryPath - Archive-relative destination path.
69
+ * @param content - New entry content.
70
+ */
71
+ static async addFile(input, archiveEntryPath, content) {
72
+ const targetPath = MdzPackagerCore.normalizePath(archiveEntryPath);
73
+ const pathError = MdzArchiveCore.validateArchivePath(targetPath);
74
+ if (pathError) {
75
+ throw new Error(`ERR_PATH_INVALID: ${pathError}`);
76
+ }
77
+ const zip = await MdzArchiveCore.loadZip(input);
78
+ const targetLower = targetPath.toLowerCase();
79
+ const archivePaths = MdzArchiveCore.getArchivePaths(zip);
80
+ const nextArchivePaths = archivePaths.filter((p) => p.toLowerCase() !== targetLower);
81
+ nextArchivePaths.push(targetPath);
82
+ if (targetLower === 'manifest.json') {
83
+ const replacement = await MdzArchiveCore.parseManifestObjectContent(content, true);
84
+ const preparedManifest = MdzArchiveCore.stampManifestObject(replacement, true);
85
+ MdzArchiveCore.validateManifest(preparedManifest);
86
+ MdzArchiveCore.ensureCreatableEntryPoint(nextArchivePaths, preparedManifest);
87
+ MdzArchiveCore.removeEntryIgnoreCase(zip, targetPath);
88
+ zip.file('manifest.json', MdzArchiveCore.normaliseLf(JSON.stringify(preparedManifest, null, 2)));
89
+ return MdzArchiveCore.finalizeMutation(zip);
90
+ }
91
+ const existingManifestObject = await MdzArchiveCore.readExistingManifestObject(zip, true);
92
+ if (existingManifestObject) {
93
+ MdzArchiveCore.validateManifest(existingManifestObject);
94
+ }
95
+ MdzArchiveCore.ensureCreatableEntryPoint(nextArchivePaths, existingManifestObject);
96
+ MdzArchiveCore.removeEntryIgnoreCase(zip, targetPath);
97
+ await MdzArchiveCore.writeZipEntry(zip, targetPath, content);
98
+ if (existingManifestObject) {
99
+ const refreshed = MdzArchiveCore.stampManifestObject(existingManifestObject, false);
100
+ MdzArchiveCore.removeEntryIgnoreCase(zip, 'manifest.json');
101
+ zip.file('manifest.json', MdzArchiveCore.normaliseLf(JSON.stringify(refreshed, null, 2)));
102
+ }
103
+ return MdzArchiveCore.finalizeMutation(zip);
104
+ }
105
+ /**
106
+ * Removes an archive entry and returns a newly generated archive blob.
107
+ *
108
+ * Also validates entry-point integrity and refreshes manifest metadata when present.
109
+ *
110
+ * @param input - Existing archive bytes.
111
+ * @param archiveEntryPath - Archive-relative path to remove.
112
+ */
113
+ static async removeFile(input, archiveEntryPath) {
114
+ const targetPath = MdzPackagerCore.normalizePath(archiveEntryPath);
115
+ const pathError = MdzArchiveCore.validateArchivePath(targetPath);
116
+ if (pathError) {
117
+ throw new Error(`ERR_PATH_INVALID: ${pathError}`);
118
+ }
119
+ const zip = await MdzArchiveCore.loadZip(input);
120
+ const targetLower = targetPath.toLowerCase();
121
+ const archivePaths = MdzArchiveCore.getArchivePaths(zip);
122
+ const exists = archivePaths.some((p) => p.toLowerCase() === targetLower);
123
+ if (!exists) {
124
+ throw new Error(`ERR_NOT_FOUND: Entry "${targetPath}" was not found in archive.`);
125
+ }
126
+ const nextArchivePaths = archivePaths.filter((p) => p.toLowerCase() !== targetLower);
127
+ const existingManifestObject = targetLower === 'manifest.json' ? null : await MdzArchiveCore.readExistingManifestObject(zip, true);
128
+ if (existingManifestObject) {
129
+ MdzArchiveCore.validateManifest(existingManifestObject);
130
+ }
131
+ MdzArchiveCore.ensureCreatableEntryPoint(nextArchivePaths, existingManifestObject);
132
+ MdzArchiveCore.removeEntryIgnoreCase(zip, targetPath);
133
+ if (existingManifestObject) {
134
+ const refreshed = MdzArchiveCore.stampManifestObject(existingManifestObject, false);
135
+ MdzArchiveCore.removeEntryIgnoreCase(zip, 'manifest.json');
136
+ zip.file('manifest.json', MdzArchiveCore.normaliseLf(JSON.stringify(refreshed, null, 2)));
137
+ }
138
+ return MdzArchiveCore.finalizeMutation(zip);
139
+ }
140
+ /**
141
+ * Removes multiple archive entries in one mutation operation.
142
+ *
143
+ * @param input - Existing archive bytes.
144
+ * @param archiveEntryPaths - Archive-relative paths to remove.
145
+ */
146
+ static async removeFiles(input, archiveEntryPaths) {
147
+ const targets = archiveEntryPaths.map((p) => {
148
+ const normalized = MdzPackagerCore.normalizePath(p);
149
+ const pathError = MdzArchiveCore.validateArchivePath(normalized);
150
+ if (pathError) {
151
+ throw new Error(`ERR_PATH_INVALID: ${pathError}`);
152
+ }
153
+ return normalized;
154
+ });
155
+ if (targets.length === 0) {
156
+ const zip = await MdzArchiveCore.loadZip(input);
157
+ return MdzArchiveCore.finalizeMutation(zip);
158
+ }
159
+ const zip = await MdzArchiveCore.loadZip(input);
160
+ const targetSet = new Set(targets.map((p) => p.toLowerCase()));
161
+ const archivePaths = MdzArchiveCore.getArchivePaths(zip);
162
+ for (const target of targetSet) {
163
+ if (!archivePaths.some((p) => p.toLowerCase() === target)) {
164
+ throw new Error(`ERR_NOT_FOUND: Entry "${target}" was not found in archive.`);
165
+ }
166
+ }
167
+ const nextArchivePaths = archivePaths.filter((p) => !targetSet.has(p.toLowerCase()));
168
+ const removesManifest = targetSet.has('manifest.json');
169
+ const existingManifestObject = removesManifest ? null : await MdzArchiveCore.readExistingManifestObject(zip, true);
170
+ if (existingManifestObject) {
171
+ MdzArchiveCore.validateManifest(existingManifestObject);
172
+ }
173
+ MdzArchiveCore.ensureCreatableEntryPoint(nextArchivePaths, existingManifestObject);
174
+ for (const target of targetSet) {
175
+ MdzArchiveCore.removeEntryIgnoreCase(zip, target);
176
+ }
177
+ if (existingManifestObject) {
178
+ const refreshed = MdzArchiveCore.stampManifestObject(existingManifestObject, false);
179
+ MdzArchiveCore.removeEntryIgnoreCase(zip, 'manifest.json');
180
+ zip.file('manifest.json', MdzArchiveCore.normaliseLf(JSON.stringify(refreshed, null, 2)));
181
+ }
182
+ return MdzArchiveCore.finalizeMutation(zip);
183
+ }
184
+ /**
185
+ * Finds orphaned image assets in an archive.
186
+ *
187
+ * @param input - Existing archive bytes.
188
+ * @param options - Scan options.
189
+ */
190
+ static async findOrphanedAssets(input, options) {
191
+ const archive = await MdzArchiveCore.open(input);
192
+ return archive.findOrphanedAssets(options);
193
+ }
194
+ /**
195
+ * Opens archive binary data and returns an app-friendly normalized workspace model.
196
+ *
197
+ * @param input - Raw archive bytes.
198
+ * @param options - Workspace open controls.
199
+ * @param zipFactory - Optional custom zip loader.
200
+ */
201
+ static async openWorkspace(input, options, zipFactory) {
202
+ const archive = await MdzArchiveCore.open(input, zipFactory);
203
+ return archive.openWorkspace(options);
204
+ }
205
+ static getDefaultZipFactory() {
206
+ return {
207
+ async loadAsync(data) {
208
+ const zip = await new JSZip().loadAsync(data);
209
+ return zip;
210
+ }
211
+ };
212
+ }
213
+ static async loadZip(input) {
214
+ const zip = await new JSZip().loadAsync(input);
215
+ return zip;
216
+ }
217
+ /**
218
+ * Normalizes path separators and strips leading slashes.
219
+ *
220
+ * @param path - Any input path.
221
+ */
222
+ static normalizePath(path) {
223
+ return String(path || '').replace(/\\/g, '/').replace(/^\/+/, '');
224
+ }
225
+ /**
226
+ * Returns true if the provided path looks like a Markdown document path.
227
+ *
228
+ * @param path - Archive-relative path.
229
+ */
230
+ static isMarkdownFile(path) {
231
+ return /\.(md|markdown)$/i.test(path);
232
+ }
233
+ /**
234
+ * Infers a MIME type from an archive path.
235
+ *
236
+ * @param path - Archive-relative path.
237
+ * @param fallbackMime - MIME type used when extension is unknown.
238
+ */
239
+ static inferMimeType(path, fallbackMime = 'application/octet-stream') {
240
+ const ext = MdzArchiveCore.getPathExtension(path);
241
+ const known = {
242
+ ...MDZ_IMAGE_MIME_TYPES,
243
+ mp3: 'audio/mpeg',
244
+ wav: 'audio/wav',
245
+ ogg: 'audio/ogg',
246
+ m4a: 'audio/mp4',
247
+ mp4: 'video/mp4',
248
+ webm: 'video/webm',
249
+ mov: 'video/quicktime',
250
+ woff: 'font/woff',
251
+ woff2: 'font/woff2',
252
+ ttf: 'font/ttf',
253
+ otf: 'font/otf',
254
+ json: 'application/json',
255
+ csv: 'text/csv',
256
+ txt: 'text/plain',
257
+ css: 'text/css',
258
+ html: 'text/html',
259
+ htm: 'text/html',
260
+ xml: 'application/xml',
261
+ pdf: 'application/pdf'
262
+ };
263
+ return known[ext] ?? fallbackMime;
264
+ }
265
+ /**
266
+ * Classifies an asset using path extension and MIME type.
267
+ *
268
+ * @param path - Archive-relative path.
269
+ * @param mimeType - Optional known MIME type.
270
+ */
271
+ static classifyAssetKind(path, mimeType = MdzArchiveCore.inferMimeType(path)) {
272
+ if (mimeType.startsWith('image/'))
273
+ return 'image';
274
+ if (mimeType.startsWith('audio/'))
275
+ return 'audio';
276
+ if (mimeType.startsWith('video/'))
277
+ return 'video';
278
+ if (mimeType.startsWith('font/'))
279
+ return 'font';
280
+ if (/^(application\/json|text\/csv|text\/plain|text\/css|text\/html|application\/xml|text\/xml)$/.test(mimeType))
281
+ return 'data';
282
+ return 'other';
283
+ }
284
+ /**
285
+ * Returns true when common browser surfaces can preview this asset.
286
+ *
287
+ * @param path - Archive-relative path.
288
+ * @param mimeType - Optional known MIME type.
289
+ */
290
+ static isPreviewableAsset(path, mimeType = MdzArchiveCore.inferMimeType(path)) {
291
+ return mimeType.startsWith('image/') || /^(application\/json|text\/csv|text\/plain|text\/css|text\/html|application\/xml|text\/xml)$/.test(mimeType);
292
+ }
293
+ /**
294
+ * Converts validation details into a compact status.
295
+ *
296
+ * @param result - Validation result.
297
+ */
298
+ static getValidationStatus(result) {
299
+ if (result.errors.length > 0 || !result.isValid)
300
+ return 'error';
301
+ if (result.warnings.length > 0)
302
+ return 'warning';
303
+ return 'valid';
304
+ }
305
+ /**
306
+ * Returns a case-insensitive, normalized archive path sort.
307
+ *
308
+ * @param paths - Archive-relative paths.
309
+ */
310
+ static sortArchivePaths(paths) {
311
+ return paths
312
+ .map(MdzArchiveCore.normalizePath)
313
+ .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
314
+ }
315
+ /**
316
+ * Returns the archive-relative directory name for a path.
317
+ *
318
+ * @param path - Archive-relative path.
319
+ */
320
+ static dirname(path) {
321
+ const normalized = MdzArchiveCore.normalizePath(path).replace(/\/+$/, '');
322
+ const slash = normalized.lastIndexOf('/');
323
+ return slash >= 0 ? normalized.slice(0, slash) : '';
324
+ }
325
+ /**
326
+ * Returns the final path segment for an archive-relative path.
327
+ *
328
+ * @param path - Archive-relative path.
329
+ */
330
+ static basename(path) {
331
+ const normalized = MdzArchiveCore.normalizePath(path).replace(/\/+$/, '');
332
+ const slash = normalized.lastIndexOf('/');
333
+ return slash >= 0 ? normalized.slice(slash + 1) : normalized;
334
+ }
335
+ /**
336
+ * Builds a generic inferred folder tree from archive-relative paths.
337
+ *
338
+ * @param paths - Archive-relative paths.
339
+ */
340
+ static buildPathTree(paths) {
341
+ const roots = [];
342
+ const ensureNode = (siblings, name, path, isDirectory) => {
343
+ let node = siblings.find((item) => item.name.toLowerCase() === name.toLowerCase() && item.isDirectory === isDirectory);
344
+ if (!node) {
345
+ node = { name, path, isDirectory, children: [] };
346
+ siblings.push(node);
347
+ }
348
+ return node;
349
+ };
350
+ for (const archivePath of MdzArchiveCore.sortArchivePaths(paths)) {
351
+ const parts = archivePath.split('/').filter(Boolean);
352
+ let siblings = roots;
353
+ let currentPath = '';
354
+ for (let i = 0; i < parts.length; i += 1) {
355
+ const part = parts[i] ?? '';
356
+ currentPath = currentPath ? `${currentPath}/${part}` : part;
357
+ const isDirectory = i < parts.length - 1;
358
+ const node = ensureNode(siblings, part, currentPath, isDirectory);
359
+ siblings = node.children;
360
+ }
361
+ }
362
+ const sortNodes = (nodes) => {
363
+ nodes.sort((a, b) => {
364
+ if (a.isDirectory !== b.isDirectory)
365
+ return a.isDirectory ? -1 : 1;
366
+ return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
367
+ });
368
+ for (const node of nodes)
369
+ sortNodes(node.children);
370
+ };
371
+ sortNodes(roots);
372
+ return roots;
373
+ }
374
+ /**
375
+ * Validates archive path constraints.
376
+ *
377
+ * @param path - Archive-relative path.
378
+ * @returns `null` when valid, otherwise a short reason string.
379
+ */
380
+ static validateArchivePath(path) {
381
+ const raw = String(path || '');
382
+ if (!raw)
383
+ return 'empty path';
384
+ if (raw.startsWith('/'))
385
+ return 'leading slash';
386
+ if (raw.includes('\\'))
387
+ return 'reserved character';
388
+ const normalized = raw;
389
+ if (normalized.split('/').includes('..'))
390
+ return 'path traversal';
391
+ for (const c of normalized) {
392
+ const code = c.charCodeAt(0);
393
+ if (c === '\0' || (code >= 1 && code <= 31) || code === 127)
394
+ return 'control char';
395
+ }
396
+ if (/[\\:*?"<>|]/.test(normalized))
397
+ return 'reserved character';
398
+ return null;
399
+ }
400
+ static dirOf(filePath) {
401
+ const i = filePath.lastIndexOf('/');
402
+ return i >= 0 ? filePath.slice(0, i + 1) : '';
403
+ }
404
+ /**
405
+ * Resolves a relative Markdown link target against an archive base path.
406
+ *
407
+ * Query/hash fragments are stripped and traversal beyond archive root is rejected.
408
+ *
409
+ * @param base - Referencing file path.
410
+ * @param relative - Relative target path from markdown/link source.
411
+ */
412
+ static resolvePath(base, relative) {
413
+ let target = String(relative || '').trim();
414
+ if (target.startsWith('<') && target.endsWith('>')) {
415
+ target = target.slice(1, -1).trim();
416
+ }
417
+ const q = target.indexOf('?');
418
+ if (q >= 0)
419
+ target = target.slice(0, q);
420
+ const h = target.indexOf('#');
421
+ if (h >= 0)
422
+ target = target.slice(0, h);
423
+ try {
424
+ target = decodeURI(target);
425
+ }
426
+ catch {
427
+ // keep original
428
+ }
429
+ target = target.replace(/\\/g, '/');
430
+ if (target.startsWith('/'))
431
+ throw new Error('Path must be relative');
432
+ const parts = (MdzArchiveCore.dirOf(base) + target).split('/');
433
+ const out = [];
434
+ for (const part of parts) {
435
+ if (part === '..') {
436
+ if (out.length === 0)
437
+ throw new Error('Path escapes archive root');
438
+ out.pop();
439
+ continue;
440
+ }
441
+ if (part === '.')
442
+ continue;
443
+ out.push(part);
444
+ }
445
+ return out.join('/');
446
+ }
447
+ /**
448
+ * Finds an archive entry by path (case-insensitive fallback).
449
+ *
450
+ * @param path - Archive-relative path.
451
+ */
452
+ findEntry(path) {
453
+ const normalized = MdzArchiveCore.normalizePath(path);
454
+ if (this.zip.files[normalized])
455
+ return this.zip.files[normalized];
456
+ if (!MdzArchiveCore.entriesCache.has(this.zip)) {
457
+ MdzArchiveCore.entriesCache.set(this.zip, Object.fromEntries(Object.entries(this.zip.files).map(([k, v]) => [MdzArchiveCore.normalizePath(k).toLowerCase(), v])));
458
+ }
459
+ return MdzArchiveCore.entriesCache.get(this.zip)?.[normalized.toLowerCase()] ?? null;
460
+ }
461
+ /**
462
+ * Lists archive paths.
463
+ *
464
+ * @param options - Listing controls.
465
+ */
466
+ listPaths(options) {
467
+ return MdzArchiveCore.getArchivePaths(this.zip, options);
468
+ }
469
+ /**
470
+ * Lists archive entries with basic type metadata.
471
+ *
472
+ * @param options - Listing controls.
473
+ */
474
+ listEntries(options) {
475
+ const entries = MdzArchiveCore.getArchiveEntries(this.zip, options);
476
+ return entries.map((entry) => ({
477
+ path: entry.path,
478
+ isMarkdown: !entry.isDirectory && MdzArchiveCore.isMarkdownFile(entry.path),
479
+ isImage: !entry.isDirectory && MdzArchiveCore.isImagePath(entry.path),
480
+ isDirectory: entry.isDirectory
481
+ }));
482
+ }
483
+ /**
484
+ * Returns true if an entry path exists (file or directory).
485
+ *
486
+ * @param path - Archive-relative path.
487
+ */
488
+ hasEntry(path) {
489
+ return this.findEntryWithDirectoryFallback(path) != null;
490
+ }
491
+ /**
492
+ * Reads UTF-8 text content from an entry.
493
+ *
494
+ * @param path - Archive-relative file path.
495
+ */
496
+ async readText(path) {
497
+ const entry = this.getFileEntryOrThrow(path);
498
+ return String(await entry.async('text'));
499
+ }
500
+ /**
501
+ * Reads raw bytes from an entry.
502
+ *
503
+ * @param path - Archive-relative file path.
504
+ */
505
+ async readBytes(path) {
506
+ const entry = this.getFileEntryOrThrow(path);
507
+ const out = await entry.async('arraybuffer');
508
+ return new Uint8Array(out);
509
+ }
510
+ /**
511
+ * Reads entry content as raw base64 (no data URI prefix).
512
+ *
513
+ * @param path - Archive-relative file path.
514
+ */
515
+ async readBase64(path) {
516
+ const entry = this.getFileEntryOrThrow(path);
517
+ return String(await entry.async('base64'));
518
+ }
519
+ /**
520
+ * Reads entry content and returns a data URI string.
521
+ *
522
+ * @param path - Archive-relative file path.
523
+ * @param fallbackMime - Optional fallback MIME when extension is unknown.
524
+ */
525
+ async readDataUri(path, fallbackMime) {
526
+ const normalizedPath = MdzArchiveCore.normalizePath(path);
527
+ const base64 = await this.readBase64(normalizedPath);
528
+ const ext = MdzArchiveCore.getPathExtension(normalizedPath);
529
+ const mime = MDZ_IMAGE_MIME_TYPES[ext] ?? (fallbackMime?.trim() || 'application/octet-stream');
530
+ return `data:${mime};base64,${base64}`;
531
+ }
532
+ /**
533
+ * Finds orphaned image assets from markdown references.
534
+ *
535
+ * @param options - Scan options.
536
+ */
537
+ async findOrphanedAssets(options) {
538
+ const allEntries = this.listEntries();
539
+ const assetPaths = allEntries
540
+ .filter((entry) => entry.isImage)
541
+ .map((entry) => entry.path)
542
+ .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
543
+ const assetPathSet = new Set(assetPaths.map((p) => p.toLowerCase()));
544
+ const scanMode = options?.scanMode ?? 'entrypoint';
545
+ const scannedMarkdownPaths = scanMode === 'all-markdown'
546
+ ? allEntries.filter((entry) => entry.isMarkdown).map((entry) => entry.path)
547
+ : [options?.entryPoint ? MdzArchiveCore.normalizePath(options.entryPoint) : await this.resolveEntryPoint()];
548
+ const referencedAssets = new Set();
549
+ const unresolvedReferences = [];
550
+ for (const markdownPath of scannedMarkdownPaths) {
551
+ const markdown = await this.readText(markdownPath);
552
+ const refs = MdzArchiveCore.extractMarkdownImageReferences(markdown);
553
+ for (const ref of refs) {
554
+ if (MdzArchiveCore.hasUriScheme(ref)) {
555
+ unresolvedReferences.push({ sourcePath: markdownPath, reference: ref, reason: 'unsupported-scheme' });
556
+ continue;
557
+ }
558
+ let resolved;
559
+ try {
560
+ resolved = MdzArchiveCore.normalizePath(MdzArchiveCore.resolvePath(markdownPath, ref));
561
+ }
562
+ catch {
563
+ unresolvedReferences.push({ sourcePath: markdownPath, reference: ref, reason: 'invalid-path' });
564
+ continue;
565
+ }
566
+ const entry = this.findEntryWithDirectoryFallback(resolved);
567
+ if (!entry || entry.dir) {
568
+ unresolvedReferences.push({ sourcePath: markdownPath, reference: ref, reason: 'not-found' });
569
+ continue;
570
+ }
571
+ if (!MdzArchiveCore.isImagePath(resolved) || !assetPathSet.has(resolved.toLowerCase())) {
572
+ unresolvedReferences.push({ sourcePath: markdownPath, reference: ref, reason: 'not-asset' });
573
+ continue;
574
+ }
575
+ referencedAssets.add(MdzArchiveCore.resolveAssetPathCase(assetPaths, resolved));
576
+ }
577
+ }
578
+ const manifest = await this.readManifest();
579
+ if (manifest?.cover) {
580
+ const cover = MdzArchiveCore.normalizePath(manifest.cover);
581
+ if (assetPathSet.has(cover.toLowerCase())) {
582
+ referencedAssets.add(MdzArchiveCore.resolveAssetPathCase(assetPaths, cover));
583
+ }
584
+ }
585
+ const referencedAssetPaths = Array.from(referencedAssets).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
586
+ const referencedSet = new Set(referencedAssetPaths.map((p) => p.toLowerCase()));
587
+ const orphanedAssetPaths = assetPaths.filter((p) => !referencedSet.has(p.toLowerCase()));
588
+ return {
589
+ scannedMarkdownPaths,
590
+ assetPaths,
591
+ referencedAssetPaths,
592
+ orphanedAssetPaths,
593
+ unresolvedReferences
594
+ };
595
+ }
596
+ /**
597
+ * Returns a normalized app/editor workspace model for this archive.
598
+ *
599
+ * @param options - Workspace open controls.
600
+ */
601
+ async openWorkspace(options) {
602
+ const validation = await this.validate();
603
+ const manifest = await this.readManifest().catch(() => null);
604
+ const mode = await this.resolveMode().catch(() => 'document');
605
+ const entryPoint = await this.resolveEntryPoint().catch(() => null);
606
+ const entries = this.listEntries();
607
+ const includeLazyReaders = options?.includeLazyAssetReaders !== false;
608
+ const documents = [];
609
+ for (const entry of entries.filter((item) => item.isMarkdown)) {
610
+ documents.push({
611
+ path: entry.path,
612
+ title: MdzArchiveCore.getDocumentTitle(entry.path, manifest),
613
+ text: await this.readText(entry.path),
614
+ isEntryPoint: !!entryPoint && entry.path.toLowerCase() === entryPoint.toLowerCase()
615
+ });
616
+ }
617
+ const assets = [];
618
+ for (const entry of entries.filter((item) => !item.isMarkdown && !item.isDirectory && item.path.toLowerCase() !== 'manifest.json')) {
619
+ const bytes = await this.readBytes(entry.path);
620
+ const mimeType = MdzArchiveCore.inferMimeType(entry.path);
621
+ const asset = {
622
+ path: entry.path,
623
+ fileName: MdzArchiveCore.basename(entry.path),
624
+ byteSize: bytes.byteLength,
625
+ mimeType,
626
+ kind: MdzArchiveCore.classifyAssetKind(entry.path, mimeType),
627
+ isPreviewable: MdzArchiveCore.isPreviewableAsset(entry.path, mimeType)
628
+ };
629
+ if (includeLazyReaders) {
630
+ asset.readBytes = () => this.readBytes(entry.path);
631
+ asset.readDataUri = () => this.readDataUri(entry.path, mimeType);
632
+ }
633
+ assets.push(asset);
634
+ }
635
+ const workspace = {
636
+ title: manifest?.title ?? null,
637
+ mode,
638
+ manifest,
639
+ entryPoint,
640
+ documents,
641
+ assets,
642
+ validation
643
+ };
644
+ if (options?.includeOrphanedAssetAnalysis) {
645
+ workspace.orphanedAssets = await this.findOrphanedAssets({ scanMode: options.orphanedAssetScanMode });
646
+ }
647
+ return workspace;
648
+ }
649
+ static validateManifest(manifest) {
650
+ if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
651
+ throw new Error('ERR_MANIFEST_INVALID: manifest.json must be a JSON object.');
652
+ }
653
+ const candidate = manifest;
654
+ const validateMetadataNode = (value, path) => {
655
+ if (value == null)
656
+ return;
657
+ if (typeof value !== 'object' || Array.isArray(value)) {
658
+ throw new Error(`ERR_MANIFEST_INVALID: manifest.json "${path}" must be an object when provided.`);
659
+ }
660
+ const node = value;
661
+ for (const key of ['name', 'version', 'url']) {
662
+ const v = node[key];
663
+ if (v != null && typeof v !== 'string') {
664
+ throw new Error(`ERR_MANIFEST_INVALID: manifest.json "${path}.${key}" must be a string when provided.`);
665
+ }
666
+ }
667
+ };
668
+ const validateByNode = (value, path) => {
669
+ if (value == null)
670
+ return;
671
+ if (typeof value !== 'object' || Array.isArray(value)) {
672
+ throw new Error(`ERR_MANIFEST_INVALID: manifest.json "${path}" must be an object when provided.`);
673
+ }
674
+ const node = value;
675
+ for (const key of ['name', 'email', 'url']) {
676
+ const v = node[key];
677
+ if (v != null && typeof v !== 'string') {
678
+ throw new Error(`ERR_MANIFEST_INVALID: manifest.json "${path}.${key}" must be a string when provided.`);
679
+ }
680
+ }
681
+ };
682
+ const validateTimestamp = (value, path) => {
683
+ if (value == null)
684
+ return;
685
+ if (typeof value === 'string')
686
+ return;
687
+ if (typeof value !== 'object' || Array.isArray(value)) {
688
+ throw new Error(`ERR_MANIFEST_INVALID: manifest.json "${path}" must be a string or object when provided.`);
689
+ }
690
+ const node = value;
691
+ if (typeof node.when !== 'string' || node.when.trim() === '') {
692
+ throw new Error(`ERR_MANIFEST_INVALID: manifest.json "${path}.when" must be a non-empty string when "${path}" is an object.`);
693
+ }
694
+ validateByNode(node.by, `${path}.by`);
695
+ };
696
+ if (candidate.title != null && (typeof candidate.title !== 'string' || candidate.title.trim() === '')) {
697
+ throw new Error('ERR_MANIFEST_INVALID: manifest.json "title" must be a non-empty string when provided.');
698
+ }
699
+ if (candidate.mode != null) {
700
+ if (typeof candidate.mode !== 'string') {
701
+ throw new Error('ERR_MANIFEST_INVALID: manifest.json "mode" must be a string when provided.');
702
+ }
703
+ if (!MdzArchiveCore.SUPPORTED_MODES.includes(candidate.mode)) {
704
+ throw new Error(`ERR_MODE_UNSUPPORTED: manifest.json mode "${candidate.mode}" is not supported.`);
705
+ }
706
+ }
707
+ if (candidate.description != null && typeof candidate.description !== 'string') {
708
+ throw new Error('ERR_MANIFEST_INVALID: manifest.json "description" must be a string when provided.');
709
+ }
710
+ if (candidate.version != null && typeof candidate.version !== 'string') {
711
+ throw new Error('ERR_MANIFEST_INVALID: manifest.json "version" must be a string when provided.');
712
+ }
713
+ if (candidate.language != null && typeof candidate.language !== 'string') {
714
+ throw new Error('ERR_MANIFEST_INVALID: manifest.json "language" must be a string when provided.');
715
+ }
716
+ if (candidate.license != null && typeof candidate.license !== 'string') {
717
+ throw new Error('ERR_MANIFEST_INVALID: manifest.json "license" must be a string when provided.');
718
+ }
719
+ if (candidate.keywords != null) {
720
+ if (!Array.isArray(candidate.keywords) || candidate.keywords.some((k) => typeof k !== 'string')) {
721
+ throw new Error('ERR_MANIFEST_INVALID: manifest.json "keywords" must be an array of strings when provided.');
722
+ }
723
+ }
724
+ if (candidate.cover != null && typeof candidate.cover !== 'string') {
725
+ throw new Error('ERR_MANIFEST_INVALID: manifest.json "cover" must be a string when provided.');
726
+ }
727
+ if (candidate.entryPoint != null && typeof candidate.entryPoint !== 'string') {
728
+ throw new Error('ERR_MANIFEST_INVALID: manifest.json "entryPoint" must be a string when provided.');
729
+ }
730
+ if (candidate.entryPoint != null && MdzArchiveCore.validateArchivePath(candidate.entryPoint) != null) {
731
+ throw new Error('ERR_MANIFEST_INVALID: manifest.json "entryPoint" must be a valid archive-relative path.');
732
+ }
733
+ if (candidate.spec != null) {
734
+ if (typeof candidate.spec !== 'object' || Array.isArray(candidate.spec)) {
735
+ throw new Error('ERR_MANIFEST_INVALID: manifest.json "spec" must be an object when provided.');
736
+ }
737
+ const spec = candidate.spec;
738
+ if (spec.name != null && typeof spec.name !== 'string') {
739
+ throw new Error('ERR_MANIFEST_INVALID: manifest.json "spec.name" must be a string when provided.');
740
+ }
741
+ if (spec.version != null) {
742
+ if (typeof spec.version !== 'string' || !MdzArchiveCore.SEMVER_RE.test(spec.version)) {
743
+ throw new Error('ERR_MANIFEST_INVALID: manifest.json "spec.version" must be a valid semver string when provided.');
744
+ }
745
+ const specMajor = Number.parseInt(spec.version.split('.')[0] ?? '0', 10);
746
+ if (specMajor > MdzArchiveCore.SUPPORTED_MDZ_MAJOR) {
747
+ throw new Error(`ERR_VERSION_UNSUPPORTED: manifest.json spec.version ${spec.version} is not supported; this viewer supports major ${MdzArchiveCore.SUPPORTED_MDZ_MAJOR}.x only.`);
748
+ }
749
+ }
750
+ }
751
+ if (candidate.mdz != null) {
752
+ if (typeof candidate.mdz !== 'string' || !MdzArchiveCore.SEMVER_RE.test(candidate.mdz)) {
753
+ throw new Error('ERR_MANIFEST_INVALID: manifest.json "mdz" must be a valid semver string when provided.');
754
+ }
755
+ const major = Number.parseInt(candidate.mdz.split('.')[0] ?? '0', 10);
756
+ if (major > MdzArchiveCore.SUPPORTED_MDZ_MAJOR) {
757
+ throw new Error(`ERR_VERSION_UNSUPPORTED: manifest.json targets mdz ${candidate.mdz}, but this viewer supports major ${MdzArchiveCore.SUPPORTED_MDZ_MAJOR}.x only.`);
758
+ }
759
+ }
760
+ if (candidate.producer != null) {
761
+ if (typeof candidate.producer !== 'object' || Array.isArray(candidate.producer)) {
762
+ throw new Error('ERR_MANIFEST_INVALID: manifest.json "producer" must be an object when provided.');
763
+ }
764
+ const producer = candidate.producer;
765
+ validateMetadataNode(producer.application, 'producer.application');
766
+ validateMetadataNode(producer.core, 'producer.core');
767
+ }
768
+ if (candidate.author != null) {
769
+ validateByNode(candidate.author, 'author');
770
+ }
771
+ validateTimestamp(candidate.created, 'created');
772
+ validateTimestamp(candidate.modified, 'modified');
773
+ }
774
+ /**
775
+ * Parses and validates `manifest.json` if present.
776
+ *
777
+ * Missing or invalid cover references are normalized away in returned data.
778
+ */
779
+ async readManifest() {
780
+ if (MdzArchiveCore.manifestCache.has(this.zip)) {
781
+ return MdzArchiveCore.manifestCache.get(this.zip) ?? null;
782
+ }
783
+ const entry = this.zip.files['manifest.json'];
784
+ if (!entry) {
785
+ MdzArchiveCore.manifestCache.set(this.zip, null);
786
+ return null;
787
+ }
788
+ const raw = await entry.async('text');
789
+ let manifest;
790
+ try {
791
+ manifest = JSON.parse(String(raw));
792
+ }
793
+ catch {
794
+ throw new Error('ERR_MANIFEST_INVALID: manifest.json is not valid JSON');
795
+ }
796
+ MdzArchiveCore.validateManifest(manifest);
797
+ const normalized = { ...manifest };
798
+ if (normalized.cover) {
799
+ const coverPath = MdzArchiveCore.normalizePath(normalized.cover);
800
+ const coverPathError = MdzArchiveCore.validateArchivePath(coverPath);
801
+ const coverEntry = Object.entries(this.zip.files).find(([p]) => MdzArchiveCore.normalizePath(p).toLowerCase() === coverPath.toLowerCase())?.[1];
802
+ const coverExistsAsFile = !!coverEntry && !coverEntry.dir;
803
+ if (coverPathError || !coverExistsAsFile) {
804
+ delete normalized.cover;
805
+ }
806
+ }
807
+ MdzArchiveCore.manifestCache.set(this.zip, normalized);
808
+ return normalized;
809
+ }
810
+ /**
811
+ * Resolves archive interpretation mode using manifest data or the spec default.
812
+ */
813
+ async resolveMode() {
814
+ const manifest = await this.readManifest();
815
+ return manifest?.mode ?? 'document';
816
+ }
817
+ /**
818
+ * Validates archive conformance and returns errors/warnings.
819
+ */
820
+ async validate() {
821
+ const errors = [];
822
+ const warnings = [];
823
+ const archivePaths = MdzArchiveCore.getArchivePaths(this.zip);
824
+ for (const path of archivePaths) {
825
+ const pathError = MdzArchiveCore.validateArchivePath(path);
826
+ if (pathError)
827
+ errors.push(`ERR_PATH_INVALID: ${pathError}`);
828
+ }
829
+ let manifest = null;
830
+ let manifestReadFailed = false;
831
+ const manifestEntry = this.findEntry('manifest.json');
832
+ if (manifestEntry) {
833
+ let rawManifest = null;
834
+ try {
835
+ const rawText = await manifestEntry.async('text');
836
+ const parsed = JSON.parse(String(rawText));
837
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
838
+ rawManifest = parsed;
839
+ }
840
+ }
841
+ catch {
842
+ // readManifest() will report JSON parse errors as ERR_MANIFEST_INVALID
843
+ }
844
+ try {
845
+ manifest = await this.readManifest();
846
+ }
847
+ catch (error) {
848
+ manifestReadFailed = true;
849
+ errors.push(error instanceof Error ? error.message : String(error));
850
+ }
851
+ if (manifest) {
852
+ const specVersion = manifest.spec?.version;
853
+ if (!specVersion || specVersion.trim() === '') {
854
+ warnings.push("manifest 'spec.version' is missing; version metadata is unavailable.");
855
+ }
856
+ else if (MdzArchiveCore.SEMVER_RE.test(specVersion)) {
857
+ const major = Number.parseInt(specVersion.split('.')[0] ?? '0', 10);
858
+ if (major < MdzArchiveCore.SUPPORTED_MDZ_MAJOR) {
859
+ warnings.push(`manifest 'spec.version' major version ${major} is older than supported major ${MdzArchiveCore.SUPPORTED_MDZ_MAJOR}.`);
860
+ }
861
+ }
862
+ if (manifest.entryPoint && !archivePaths.some((p) => p.toLowerCase() === manifest.entryPoint.toLowerCase())) {
863
+ errors.push(`ERR_ENTRYPOINT_MISSING: manifest 'entryPoint' references '${manifest.entryPoint}' which does not exist in the archive.`);
864
+ }
865
+ const coverCandidate = typeof rawManifest?.cover === 'string' ? rawManifest.cover : manifest.cover;
866
+ if (coverCandidate) {
867
+ const cover = MdzArchiveCore.normalizePath(coverCandidate);
868
+ const coverEntry = this.findEntry(cover);
869
+ if (!coverEntry || coverEntry.dir) {
870
+ warnings.push(`manifest 'cover' references '${coverCandidate}' which does not exist in the archive.`);
871
+ }
872
+ }
873
+ }
874
+ }
875
+ else {
876
+ warnings.push('No manifest.json present. Version metadata is unavailable.');
877
+ }
878
+ if (!manifestReadFailed) {
879
+ try {
880
+ await this.resolveEntryPoint();
881
+ }
882
+ catch (error) {
883
+ errors.push(error instanceof Error ? error.message : String(error));
884
+ }
885
+ }
886
+ return {
887
+ isValid: errors.length === 0,
888
+ errors,
889
+ warnings
890
+ };
891
+ }
892
+ /**
893
+ * Resolves the primary markdown entry point for rendering.
894
+ *
895
+ * @throws When no unambiguous entry point can be determined.
896
+ */
897
+ async resolveEntryPoint() {
898
+ const manifest = await this.readManifest();
899
+ const archivePaths = MdzArchiveCore.getArchivePaths(this.zip);
900
+ if (manifest?.entryPoint && !archivePaths.some((p) => p.toLowerCase() === manifest.entryPoint.toLowerCase())) {
901
+ throw new Error(`ERR_ENTRYPOINT_MISSING: manifest.json references "${manifest.entryPoint}" which is not in the archive`);
902
+ }
903
+ const resolved = MdzPackagerCore.resolveEntryPoint(archivePaths, manifest);
904
+ if (resolved)
905
+ return resolved;
906
+ const rootMd = archivePaths.filter((p) => !p.includes('/') && MdzArchiveCore.isMarkdownFile(p));
907
+ if (rootMd.length > 1) {
908
+ throw new Error('ERR_ENTRYPOINT_UNRESOLVED: Multiple Markdown files at the archive root and no manifest.json entryPoint. Add an index.md or manifest.json entryPoint.');
909
+ }
910
+ throw new Error('ERR_ENTRYPOINT_UNRESOLVED: No Markdown file found at the archive root.');
911
+ }
912
+ static getArchivePaths(zip, options) {
913
+ return MdzArchiveCore.getArchiveEntries(zip, options).map((entry) => entry.path);
914
+ }
915
+ static getArchiveEntries(zip, options) {
916
+ const includeDirectories = options?.includeDirectories === true;
917
+ const normalize = options?.normalize !== false;
918
+ const sort = options?.sort !== false;
919
+ const entries = Object.entries(zip.files)
920
+ .filter(([, entry]) => includeDirectories || !entry?.dir)
921
+ .map(([path, entry]) => ({
922
+ path: normalize ? MdzArchiveCore.normalizePath(path) : path,
923
+ isDirectory: !!entry?.dir
924
+ }));
925
+ if (sort) {
926
+ entries.sort((a, b) => a.path.localeCompare(b.path, undefined, { sensitivity: 'base' }));
927
+ }
928
+ return entries;
929
+ }
930
+ findEntryWithDirectoryFallback(path) {
931
+ const normalized = MdzArchiveCore.normalizePath(path);
932
+ const direct = this.findEntry(normalized);
933
+ if (direct)
934
+ return direct;
935
+ if (normalized.endsWith('/')) {
936
+ return this.findEntry(normalized.slice(0, -1));
937
+ }
938
+ return this.findEntry(`${normalized}/`);
939
+ }
940
+ getFileEntryOrThrow(path) {
941
+ const normalized = MdzArchiveCore.normalizePath(path);
942
+ const entry = this.findEntryWithDirectoryFallback(normalized);
943
+ if (!entry) {
944
+ throw new Error(`ERR_NOT_FOUND: Entry "${normalized}" was not found in archive.`);
945
+ }
946
+ if (entry.dir) {
947
+ throw new Error(`ERR_IS_DIRECTORY: Entry "${normalized}" is a directory.`);
948
+ }
949
+ return entry;
950
+ }
951
+ static getPathExtension(path) {
952
+ const slash = path.lastIndexOf('/');
953
+ const dot = path.lastIndexOf('.');
954
+ if (dot <= slash)
955
+ return '';
956
+ return path.slice(dot + 1).toLowerCase();
957
+ }
958
+ static isImagePath(path) {
959
+ return MdzArchiveCore.getPathExtension(path) in MDZ_IMAGE_MIME_TYPES;
960
+ }
961
+ static getDocumentTitle(path, manifest) {
962
+ const mapped = manifest?.files?.find((file) => file.path.toLowerCase() === path.toLowerCase());
963
+ if (mapped?.title?.trim())
964
+ return mapped.title.trim();
965
+ const fileName = MdzArchiveCore.basename(path).replace(/\.[^/.]+$/, '');
966
+ const title = fileName.replace(/[_-]+/g, ' ').trim();
967
+ return title || path;
968
+ }
969
+ static extractMarkdownImageReferences(markdown) {
970
+ const refs = [];
971
+ const re = new RegExp(MdzArchiveCore.MARKDOWN_IMAGE_REF_RE.source, 'g');
972
+ let match;
973
+ while ((match = re.exec(markdown)) != null) {
974
+ const raw = match[1]?.trim();
975
+ if (!raw)
976
+ continue;
977
+ refs.push(MdzArchiveCore.cleanMarkdownLinkTarget(raw));
978
+ }
979
+ return refs;
980
+ }
981
+ static cleanMarkdownLinkTarget(raw) {
982
+ let target = raw.trim();
983
+ if (target.startsWith('<') && target.endsWith('>')) {
984
+ target = target.slice(1, -1).trim();
985
+ }
986
+ const quotedTitleIndex = target.search(/\s+"/);
987
+ if (quotedTitleIndex > 0) {
988
+ target = target.slice(0, quotedTitleIndex).trim();
989
+ }
990
+ else {
991
+ const singleQuotedTitleIndex = target.search(/\s+'/);
992
+ if (singleQuotedTitleIndex > 0) {
993
+ target = target.slice(0, singleQuotedTitleIndex).trim();
994
+ }
995
+ }
996
+ return target;
997
+ }
998
+ static hasUriScheme(value) {
999
+ return /^[A-Za-z][A-Za-z\d+\-.]*:/.test(value);
1000
+ }
1001
+ static resolveAssetPathCase(assetPaths, resolvedPath) {
1002
+ const lower = resolvedPath.toLowerCase();
1003
+ const exact = assetPaths.find((p) => p.toLowerCase() === lower);
1004
+ return exact ?? resolvedPath;
1005
+ }
1006
+ static removeEntryIgnoreCase(zip, targetPath) {
1007
+ const targetLower = targetPath.toLowerCase();
1008
+ for (const path of Object.keys(zip.files)) {
1009
+ if (MdzArchiveCore.normalizePath(path).toLowerCase() === targetLower) {
1010
+ zip.remove(path);
1011
+ }
1012
+ }
1013
+ }
1014
+ static isTextFile(path) {
1015
+ return /\.(md|markdown|json|txt|css|html|htm|xml|svg|yaml|yml|toml)$/i.test(path);
1016
+ }
1017
+ static normaliseLf(content) {
1018
+ return content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
1019
+ }
1020
+ static async readExistingManifestObject(zip, requireValid) {
1021
+ const manifestEntry = Object.entries(zip.files).find(([p, entry]) => MdzArchiveCore.normalizePath(p).toLowerCase() === 'manifest.json' && !entry.dir)?.[1];
1022
+ if (!manifestEntry)
1023
+ return null;
1024
+ const raw = await manifestEntry.async('text');
1025
+ try {
1026
+ const parsed = JSON.parse(String(raw));
1027
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
1028
+ if (requireValid)
1029
+ throw new Error('ERR_MANIFEST_INVALID: Existing manifest.json is invalid: expected a JSON object.');
1030
+ return null;
1031
+ }
1032
+ return parsed;
1033
+ }
1034
+ catch (error) {
1035
+ if (requireValid) {
1036
+ if (error instanceof Error && /expected a JSON object/.test(error.message))
1037
+ throw error;
1038
+ throw new Error('ERR_MANIFEST_INVALID: Existing manifest.json is invalid JSON.');
1039
+ }
1040
+ return null;
1041
+ }
1042
+ }
1043
+ static async parseManifestObjectContent(content, requireValid) {
1044
+ const raw = await MdzArchiveCore.readMutationInputAsText(content);
1045
+ try {
1046
+ const parsed = JSON.parse(raw);
1047
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
1048
+ if (requireValid)
1049
+ throw new Error('ERR_MANIFEST_INVALID: Replacement manifest.json is invalid: expected a JSON object.');
1050
+ return {};
1051
+ }
1052
+ return parsed;
1053
+ }
1054
+ catch (error) {
1055
+ if (requireValid) {
1056
+ if (error instanceof Error && /expected a JSON object/.test(error.message))
1057
+ throw error;
1058
+ throw new Error('ERR_MANIFEST_INVALID: Replacement manifest.json is invalid JSON.');
1059
+ }
1060
+ return {};
1061
+ }
1062
+ }
1063
+ static ensureManifestSpecObject(manifest) {
1064
+ let spec = manifest.spec;
1065
+ if (!spec || typeof spec !== 'object' || Array.isArray(spec)) {
1066
+ spec = {};
1067
+ manifest.spec = spec;
1068
+ }
1069
+ const specObj = spec;
1070
+ if (typeof specObj.name !== 'string' || !specObj.name.trim()) {
1071
+ specObj.name = 'mdzip-spec';
1072
+ }
1073
+ if (typeof specObj.version !== 'string' || !specObj.version.trim()) {
1074
+ specObj.version = PRODUCER_SPEC_VERSION;
1075
+ }
1076
+ }
1077
+ static stampManifestObject(manifest, setCreatedIfMissing) {
1078
+ const now = new Date().toISOString();
1079
+ const next = { ...manifest };
1080
+ MdzArchiveCore.ensureManifestSpecObject(next);
1081
+ if (setCreatedIfMissing && next.created == null) {
1082
+ next.created = now;
1083
+ }
1084
+ const modified = next.modified;
1085
+ if (modified && typeof modified === 'object' && !Array.isArray(modified)) {
1086
+ next.modified = { ...modified, when: now };
1087
+ }
1088
+ else {
1089
+ next.modified = now;
1090
+ }
1091
+ return next;
1092
+ }
1093
+ static ensureCreatableEntryPoint(archivePaths, manifest) {
1094
+ if (manifest?.entryPoint && !archivePaths.some((p) => p.toLowerCase() === manifest.entryPoint.toLowerCase())) {
1095
+ throw new Error(`ERR_ENTRYPOINT_MISSING: manifest 'entryPoint' references '${manifest.entryPoint}' which does not exist in the archive.`);
1096
+ }
1097
+ const resolved = MdzPackagerCore.resolveEntryPoint(archivePaths, manifest);
1098
+ if (!resolved) {
1099
+ throw new Error('ERR_ENTRYPOINT_UNRESOLVED: No unambiguous entry point could be determined. Add index.md at the archive root, keep exactly one root Markdown file, or set manifest.entryPoint.');
1100
+ }
1101
+ }
1102
+ static async readMutationInputAsText(content) {
1103
+ if (typeof content === 'string')
1104
+ return content;
1105
+ if (content instanceof Blob)
1106
+ return content.text();
1107
+ return new TextDecoder().decode(content);
1108
+ }
1109
+ static async readMutationInputAsBinary(content) {
1110
+ if (typeof content === 'string')
1111
+ return new TextEncoder().encode(content);
1112
+ if (content instanceof Blob)
1113
+ return content.arrayBuffer();
1114
+ if (content instanceof Uint8Array)
1115
+ return content;
1116
+ return content;
1117
+ }
1118
+ static async writeZipEntry(zip, path, content) {
1119
+ if (MdzArchiveCore.isTextFile(path)) {
1120
+ const text = await MdzArchiveCore.readMutationInputAsText(content);
1121
+ zip.file(path, MdzArchiveCore.normaliseLf(text));
1122
+ return;
1123
+ }
1124
+ const binary = await MdzArchiveCore.readMutationInputAsBinary(content);
1125
+ zip.file(path, binary);
1126
+ }
1127
+ static async finalizeMutation(zip) {
1128
+ const bytes = await zip.generateAsync({
1129
+ type: 'uint8array',
1130
+ compression: 'DEFLATE',
1131
+ compressionOptions: { level: 6 }
1132
+ });
1133
+ const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
1134
+ const blob = new Blob([buffer], { type: 'application/zip' });
1135
+ const archive = await MdzArchiveCore.open(buffer);
1136
+ const manifest = await archive.readManifest();
1137
+ const resolvedEntryPoint = await archive.resolveEntryPoint().catch(() => null);
1138
+ return {
1139
+ blob,
1140
+ manifest,
1141
+ resolvedEntryPoint,
1142
+ archivePaths: MdzArchiveCore.getArchivePaths(zip)
1143
+ };
1144
+ }
1145
+ }
1146
+ /**
1147
+ * Packaging helpers for creating `.mdz` archives from source files.
1148
+ */
1149
+ export class MdzPackagerCore {
1150
+ /**
1151
+ * Default include globs for markdown and common image assets.
1152
+ */
1153
+ static DEFAULT_FILTERS = [
1154
+ '**/*.md',
1155
+ '**/*.markdown',
1156
+ '**/*.png',
1157
+ '**/*.jpg',
1158
+ '**/*.jpeg',
1159
+ '**/*.gif',
1160
+ '**/*.webp',
1161
+ '**/*.svg',
1162
+ '**/*.avif'
1163
+ ];
1164
+ /**
1165
+ * Normalizes input file paths for archive packaging.
1166
+ *
1167
+ * @param path - Source file path.
1168
+ */
1169
+ static normalizePath(path) {
1170
+ return MdzArchiveCore.normalizePath(path).replace(/^\.\//, '');
1171
+ }
1172
+ /**
1173
+ * Validates archive path constraints.
1174
+ *
1175
+ * @param path - Archive-relative path.
1176
+ */
1177
+ static validateArchivePath(path) {
1178
+ return MdzArchiveCore.validateArchivePath(path);
1179
+ }
1180
+ /**
1181
+ * Sanitizes one archive path segment by replacing forbidden characters.
1182
+ *
1183
+ * @param segment - Single path segment.
1184
+ */
1185
+ static sanitisePathSegment(segment) {
1186
+ let out = '';
1187
+ for (const c of segment) {
1188
+ const code = c.charCodeAt(0);
1189
+ if (/[\\:*?"<>|]/.test(c) || c === '\0' || (code >= 1 && code <= 31) || code === 127)
1190
+ out += '_';
1191
+ else
1192
+ out += c;
1193
+ }
1194
+ out = out.trim();
1195
+ if (!out)
1196
+ return '_';
1197
+ if (out === '.' || out === '..')
1198
+ return out.replace(/\./g, '_');
1199
+ return out;
1200
+ }
1201
+ /**
1202
+ * Sanitizes a full archive path by normalizing each path segment.
1203
+ *
1204
+ * @param path - Candidate archive path.
1205
+ */
1206
+ static sanitiseArchivePath(path) {
1207
+ return MdzPackagerCore.normalizePath(path)
1208
+ .split('/')
1209
+ .filter(Boolean)
1210
+ .map(MdzPackagerCore.sanitisePathSegment)
1211
+ .join('/');
1212
+ }
1213
+ /**
1214
+ * Ensures archive path uniqueness by appending `-2`, `-3`, etc when needed.
1215
+ *
1216
+ * @param candidate - Desired archive path.
1217
+ * @param usedPaths - Case-insensitive set of already-used paths.
1218
+ */
1219
+ static makeUniqueArchivePath(candidate, usedPaths) {
1220
+ if (!usedPaths.has(candidate.toLowerCase())) {
1221
+ usedPaths.add(candidate.toLowerCase());
1222
+ return candidate;
1223
+ }
1224
+ const slash = candidate.lastIndexOf('/');
1225
+ const dir = slash >= 0 ? candidate.slice(0, slash + 1) : '';
1226
+ const name = slash >= 0 ? candidate.slice(slash + 1) : candidate;
1227
+ const dot = name.lastIndexOf('.');
1228
+ const base = dot >= 0 ? name.slice(0, dot) : name;
1229
+ const ext = dot >= 0 ? name.slice(dot) : '';
1230
+ let n = 2;
1231
+ while (true) {
1232
+ const next = `${dir}${base}-${n}${ext}`;
1233
+ if (!usedPaths.has(next.toLowerCase())) {
1234
+ usedPaths.add(next.toLowerCase());
1235
+ return next;
1236
+ }
1237
+ n += 1;
1238
+ }
1239
+ }
1240
+ /**
1241
+ * Tests whether a path matches a glob pattern supporting `*`, `?`, and `**`.
1242
+ *
1243
+ * @param path - Archive-relative path.
1244
+ * @param pattern - Glob pattern.
1245
+ */
1246
+ static globMatch(path, pattern) {
1247
+ const pathParts = path.split('/').filter(Boolean);
1248
+ const patternParts = pattern.replace(/\\/g, '/').split('/').filter(Boolean);
1249
+ const segmentMatch = (segment, pat) => {
1250
+ let si = 0;
1251
+ let pi = 0;
1252
+ let star = -1;
1253
+ let match = 0;
1254
+ while (si < segment.length) {
1255
+ const patChar = pat.charAt(pi);
1256
+ const segChar = segment.charAt(si);
1257
+ if (pi < pat.length && (patChar === '?' || patChar.toLowerCase() === segChar.toLowerCase())) {
1258
+ si += 1;
1259
+ pi += 1;
1260
+ }
1261
+ else if (pi < pat.length && patChar === '*') {
1262
+ star = pi;
1263
+ pi += 1;
1264
+ match = si;
1265
+ }
1266
+ else if (star !== -1) {
1267
+ pi = star + 1;
1268
+ match += 1;
1269
+ si = match;
1270
+ }
1271
+ else {
1272
+ return false;
1273
+ }
1274
+ }
1275
+ while (pi < pat.length && pat[pi] === '*')
1276
+ pi += 1;
1277
+ return pi === pat.length;
1278
+ };
1279
+ const matchParts = (pi, gi) => {
1280
+ if (gi === patternParts.length)
1281
+ return pi === pathParts.length;
1282
+ const part = patternParts[gi];
1283
+ if (part == null)
1284
+ return false;
1285
+ if (part === '**') {
1286
+ if (gi === patternParts.length - 1)
1287
+ return true;
1288
+ for (let skip = pi; skip <= pathParts.length; skip += 1) {
1289
+ if (matchParts(skip, gi + 1))
1290
+ return true;
1291
+ }
1292
+ return false;
1293
+ }
1294
+ if (pi >= pathParts.length)
1295
+ return false;
1296
+ const pathPart = pathParts[pi];
1297
+ if (pathPart == null)
1298
+ return false;
1299
+ return segmentMatch(pathPart, part) && matchParts(pi + 1, gi + 1);
1300
+ };
1301
+ return matchParts(0, 0);
1302
+ }
1303
+ /**
1304
+ * Returns true if a path matches at least one filter pattern.
1305
+ *
1306
+ * @param path - Archive-relative path.
1307
+ * @param filters - Glob filter list.
1308
+ */
1309
+ static matchesAnyFilter(path, filters) {
1310
+ return filters.some((pattern) => MdzPackagerCore.globMatch(path, pattern));
1311
+ }
1312
+ /**
1313
+ * Builds a generated manifest from packaging options, or `null` when none is needed.
1314
+ *
1315
+ * @param rootName - Root/source label for fallback title.
1316
+ * @param options - Packaging options.
1317
+ */
1318
+ static buildManifestFromOptions(rootName, options) {
1319
+ const hasManifestOption = options.mapFiles
1320
+ || !!options.title
1321
+ || !!options.mode
1322
+ || !!options.entryPoint
1323
+ || !!options.language
1324
+ || !!options.author
1325
+ || !!options.description
1326
+ || !!options.docVersion;
1327
+ if (!hasManifestOption)
1328
+ return null;
1329
+ const now = new Date().toISOString();
1330
+ return {
1331
+ spec: {
1332
+ name: 'mdzip-spec',
1333
+ version: PRODUCER_SPEC_VERSION
1334
+ },
1335
+ producer: {
1336
+ core: {
1337
+ name: 'mdzip-core-js',
1338
+ version: CORE_LIBRARY_VERSION,
1339
+ url: CORE_LIBRARY_URL
1340
+ }
1341
+ },
1342
+ title: options.title || rootName,
1343
+ mode: options.mode || undefined,
1344
+ entryPoint: options.entryPoint || null,
1345
+ language: options.language || 'en',
1346
+ author: options.author ? { name: options.author } : undefined,
1347
+ authors: options.author ? [{ name: options.author }] : null,
1348
+ description: options.description || null,
1349
+ version: options.docVersion || null,
1350
+ created: now,
1351
+ modified: now
1352
+ };
1353
+ }
1354
+ /**
1355
+ * Resolves entry point using manifest override, `index.md`, or single-root-markdown fallback.
1356
+ *
1357
+ * @param archivePaths - Archive file paths.
1358
+ * @param manifest - Optional manifest with `entryPoint`.
1359
+ */
1360
+ static resolveEntryPoint(archivePaths, manifest) {
1361
+ if (manifest?.entryPoint && archivePaths.some((p) => p.toLowerCase() === manifest.entryPoint.toLowerCase())) {
1362
+ return manifest.entryPoint;
1363
+ }
1364
+ if (archivePaths.some((p) => p.toLowerCase() === 'index.md'))
1365
+ return 'index.md';
1366
+ const rootMarkdown = archivePaths.filter((p) => !p.includes('/') && MdzArchiveCore.isMarkdownFile(p));
1367
+ return rootMarkdown.length === 1 ? (rootMarkdown[0] ?? null) : null;
1368
+ }
1369
+ /**
1370
+ * Generates a simple index markdown page for archives without a clear entry point.
1371
+ *
1372
+ * @param markdownPaths - Markdown files to list.
1373
+ * @param title - Optional heading title.
1374
+ */
1375
+ static buildGeneratedIndex(markdownPaths, title) {
1376
+ const pageTitle = title && title.trim() ? title.trim() : 'Index';
1377
+ const lines = [`# ${pageTitle}`, ''];
1378
+ if (!markdownPaths.length) {
1379
+ lines.push('No Markdown files were found.');
1380
+ return lines.join('\n');
1381
+ }
1382
+ const sorted = markdownPaths.slice().sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
1383
+ for (const p of sorted) {
1384
+ const fileName = p.split('/').pop() || p;
1385
+ const encoded = p.split('/').map(encodeURIComponent).join('/');
1386
+ lines.push(`- [${fileName}](<${encoded}>)`);
1387
+ }
1388
+ lines.push('', '---', '', 'Generated by `mdz-core`', '', 'More info: [markdownzip.org](https://markdownzip.org)');
1389
+ return lines.join('\n');
1390
+ }
1391
+ /**
1392
+ * Creates a canonical MDZip manifest from app-safe metadata.
1393
+ *
1394
+ * @param metadata - Editable manifest fields.
1395
+ */
1396
+ static createManifest(metadata) {
1397
+ const now = new Date().toISOString();
1398
+ const author = typeof metadata?.author === 'string' ? { name: metadata.author } : (metadata?.author ?? undefined);
1399
+ return {
1400
+ spec: {
1401
+ name: 'mdzip-spec',
1402
+ version: PRODUCER_SPEC_VERSION
1403
+ },
1404
+ producer: {
1405
+ core: {
1406
+ name: 'mdzip-core-js',
1407
+ version: CORE_LIBRARY_VERSION,
1408
+ url: CORE_LIBRARY_URL
1409
+ }
1410
+ },
1411
+ title: metadata?.title ?? undefined,
1412
+ mode: metadata?.mode ?? undefined,
1413
+ entryPoint: metadata?.entryPoint ?? null,
1414
+ language: metadata?.language ?? 'en',
1415
+ author,
1416
+ authors: author ? [author] : null,
1417
+ description: metadata?.description ?? null,
1418
+ version: metadata?.version ?? null,
1419
+ license: metadata?.license ?? undefined,
1420
+ keywords: metadata?.keywords ?? undefined,
1421
+ cover: metadata?.cover ?? undefined,
1422
+ created: now,
1423
+ modified: now
1424
+ };
1425
+ }
1426
+ /**
1427
+ * Updates a manifest while preserving spec-managed fields unless explicitly changed elsewhere.
1428
+ *
1429
+ * @param manifest - Existing manifest, or `null` to create one.
1430
+ * @param updates - Editable metadata updates.
1431
+ * @param options - Timestamp controls.
1432
+ */
1433
+ static updateManifest(manifest, updates, options) {
1434
+ const next = manifest ? { ...manifest } : MdzPackagerCore.createManifest(updates);
1435
+ MdzPackagerCore.ensureCanonicalManifest(next, options);
1436
+ if (updates) {
1437
+ if ('title' in updates)
1438
+ next.title = updates.title ?? undefined;
1439
+ if ('author' in updates) {
1440
+ const author = typeof updates.author === 'string' ? { name: updates.author } : (updates.author ?? undefined);
1441
+ next.author = author;
1442
+ next.authors = author ? [author] : null;
1443
+ }
1444
+ if ('description' in updates)
1445
+ next.description = updates.description ?? null;
1446
+ if ('keywords' in updates)
1447
+ next.keywords = updates.keywords ?? undefined;
1448
+ if ('language' in updates)
1449
+ next.language = updates.language ?? null;
1450
+ if ('license' in updates)
1451
+ next.license = updates.license ?? undefined;
1452
+ if ('version' in updates)
1453
+ next.version = updates.version ?? null;
1454
+ if ('cover' in updates)
1455
+ next.cover = updates.cover ?? undefined;
1456
+ if ('mode' in updates)
1457
+ next.mode = updates.mode ?? undefined;
1458
+ if ('entryPoint' in updates)
1459
+ next.entryPoint = updates.entryPoint ?? null;
1460
+ }
1461
+ MdzPackagerCore.ensureCanonicalManifest(next, options);
1462
+ return next;
1463
+ }
1464
+ /**
1465
+ * Splits a manifest into spec-managed fields and ordinary editable metadata.
1466
+ *
1467
+ * @param manifest - Manifest to split.
1468
+ */
1469
+ static splitManifestMetadata(manifest) {
1470
+ return {
1471
+ reserved: {
1472
+ spec: manifest.spec,
1473
+ producer: manifest.producer,
1474
+ created: manifest.created,
1475
+ modified: manifest.modified,
1476
+ entryPoint: manifest.entryPoint,
1477
+ mode: manifest.mode,
1478
+ files: manifest.files
1479
+ },
1480
+ editable: {
1481
+ title: manifest.title ?? null,
1482
+ author: manifest.author ?? null,
1483
+ description: manifest.description ?? null,
1484
+ keywords: manifest.keywords ?? null,
1485
+ language: manifest.language ?? null,
1486
+ license: manifest.license ?? null,
1487
+ version: manifest.version ?? null,
1488
+ cover: manifest.cover ?? null
1489
+ }
1490
+ };
1491
+ }
1492
+ /**
1493
+ * Creates a workspace asset from a browser `File`/`Blob` or raw bytes.
1494
+ *
1495
+ * @param source - Asset source.
1496
+ * @param targetPath - Optional archive path override.
1497
+ */
1498
+ static async createWorkspaceAssetFromFile(source, targetPath) {
1499
+ const isFile = typeof File !== 'undefined' && source instanceof File;
1500
+ const isBlob = typeof Blob !== 'undefined' && source instanceof Blob;
1501
+ const path = MdzPackagerCore.normalizePath(targetPath || (isFile ? source.name : 'asset.bin'));
1502
+ const pathError = MdzPackagerCore.validateArchivePath(path);
1503
+ if (pathError)
1504
+ throw new Error(`ERR_PATH_INVALID: ${pathError}`);
1505
+ const bytes = await MdzPackagerCore.readBinarySource(source);
1506
+ const mimeType = isBlob && source.type ? source.type : MdzArchiveCore.inferMimeType(path);
1507
+ return {
1508
+ path,
1509
+ fileName: MdzArchiveCore.basename(path),
1510
+ byteSize: bytes.byteLength,
1511
+ mimeType,
1512
+ kind: MdzArchiveCore.classifyAssetKind(path, mimeType),
1513
+ isPreviewable: MdzArchiveCore.isPreviewableAsset(path, mimeType),
1514
+ bytes
1515
+ };
1516
+ }
1517
+ /**
1518
+ * Exports a workspace asset as a browser-safe `Blob`.
1519
+ *
1520
+ * @param asset - Workspace asset.
1521
+ */
1522
+ static async exportWorkspaceAsset(asset) {
1523
+ const bytes = await MdzPackagerCore.readWorkspaceAssetBytes(asset);
1524
+ const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
1525
+ return new Blob([buffer], { type: asset.mimeType || MdzArchiveCore.inferMimeType(asset.path) });
1526
+ }
1527
+ /**
1528
+ * Builds an `.mdz` archive from a normalized workspace.
1529
+ *
1530
+ * @param workspace - Workspace model.
1531
+ * @param options - Build controls and metadata overrides.
1532
+ * @param zipWriterFactory - Optional custom zip writer.
1533
+ */
1534
+ static async buildWorkspace(workspace, options, zipWriterFactory) {
1535
+ const entryPoint = options?.entryPoint ?? workspace.entryPoint ?? workspace.documents.find((doc) => doc.isEntryPoint)?.path ?? workspace.documents[0]?.path ?? null;
1536
+ const mode = options?.mode ?? workspace.mode;
1537
+ const title = options?.title ?? workspace.title ?? workspace.manifest?.title ?? null;
1538
+ const manifest = MdzPackagerCore.updateManifest(workspace.manifest, {
1539
+ ...(options?.metadata ?? {}),
1540
+ title,
1541
+ mode,
1542
+ entryPoint
1543
+ });
1544
+ const files = [
1545
+ ...workspace.documents.map((document) => ({
1546
+ path: document.path,
1547
+ text: document.text
1548
+ })),
1549
+ ...(await Promise.all(workspace.assets.map(async (asset) => ({
1550
+ path: asset.path,
1551
+ data: await MdzPackagerCore.readWorkspaceAssetBytes(asset)
1552
+ })))),
1553
+ {
1554
+ path: 'manifest.json',
1555
+ text: JSON.stringify(manifest, null, 2)
1556
+ }
1557
+ ];
1558
+ return MdzPackagerCore.buildArchive(files, options?.rootName || title || 'MDZip Workspace', {
1559
+ createIndex: false,
1560
+ mapFiles: false,
1561
+ filters: ['**/*', '*']
1562
+ }, zipWriterFactory);
1563
+ }
1564
+ static normalizeLf(content) {
1565
+ return content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
1566
+ }
1567
+ static isTextFile(path) {
1568
+ return /\.(md|markdown|json|txt|css|html|htm|xml|svg|yaml|yml|toml)$/i.test(path);
1569
+ }
1570
+ static async readProvidedManifest(selected) {
1571
+ const manifestItem = selected.find((item) => item.archivePath.toLowerCase() === 'manifest.json');
1572
+ if (!manifestItem)
1573
+ return null;
1574
+ const raw = manifestItem.file ? await manifestItem.file.text() : (manifestItem.text ?? '');
1575
+ let manifest;
1576
+ try {
1577
+ manifest = JSON.parse(raw);
1578
+ }
1579
+ catch {
1580
+ throw new Error('ERR_MANIFEST_INVALID: manifest.json is not valid JSON');
1581
+ }
1582
+ MdzArchiveCore.validateManifest(manifest);
1583
+ return manifest;
1584
+ }
1585
+ static ensureCanonicalManifest(manifest, options) {
1586
+ const now = new Date().toISOString();
1587
+ manifest.spec = {
1588
+ ...(manifest.spec ?? {}),
1589
+ name: manifest.spec?.name || 'mdzip-spec',
1590
+ version: manifest.spec?.version || PRODUCER_SPEC_VERSION
1591
+ };
1592
+ manifest.producer = {
1593
+ ...(manifest.producer ?? {}),
1594
+ core: {
1595
+ ...(manifest.producer?.core ?? {}),
1596
+ name: manifest.producer?.core?.name || 'mdzip-core-js',
1597
+ version: manifest.producer?.core?.version || CORE_LIBRARY_VERSION,
1598
+ url: manifest.producer?.core?.url || CORE_LIBRARY_URL
1599
+ }
1600
+ };
1601
+ if (options?.setCreatedIfMissing !== false && manifest.created == null) {
1602
+ manifest.created = now;
1603
+ }
1604
+ if (options?.refreshModified !== false) {
1605
+ const modified = manifest.modified;
1606
+ if (modified && typeof modified === 'object' && !Array.isArray(modified)) {
1607
+ manifest.modified = { ...modified, when: now };
1608
+ }
1609
+ else {
1610
+ manifest.modified = now;
1611
+ }
1612
+ }
1613
+ }
1614
+ static async readBinarySource(source) {
1615
+ if (source instanceof Uint8Array)
1616
+ return source;
1617
+ if (source instanceof ArrayBuffer)
1618
+ return new Uint8Array(source);
1619
+ if (typeof source.arrayBuffer === 'function')
1620
+ return new Uint8Array(await source.arrayBuffer());
1621
+ throw new Error('ERR_ASSET_BYTES_INVALID: Asset source is not readable as bytes.');
1622
+ }
1623
+ static async readWorkspaceAssetBytes(asset) {
1624
+ if (asset.bytes)
1625
+ return MdzPackagerCore.readBinarySource(asset.bytes);
1626
+ if (asset.readBytes)
1627
+ return asset.readBytes();
1628
+ throw new Error(`ERR_ASSET_BYTES_MISSING: Asset "${asset.path}" has no bytes or readBytes() source.`);
1629
+ }
1630
+ /**
1631
+ * Builds an `.mdz` archive from input files and packaging options.
1632
+ *
1633
+ * @param files - Source file candidates.
1634
+ * @param rootName - Root/source label for generated metadata.
1635
+ * @param options - Packaging options.
1636
+ * @param zipWriterFactory - Optional custom zip writer.
1637
+ */
1638
+ static async buildArchive(files, rootName, options, zipWriterFactory) {
1639
+ const cleanInput = files
1640
+ .map((f) => ({ path: MdzPackagerCore.normalizePath(f.path), file: f.file, data: f.data, text: f.text }))
1641
+ .filter((f) => f.path && !f.path.endsWith('/'));
1642
+ if (!cleanInput.length) {
1643
+ throw new Error('ERR_PACK_NO_INPUT: No files found to package.');
1644
+ }
1645
+ let manifest = MdzPackagerCore.buildManifestFromOptions(rootName, options);
1646
+ const skipMap = new Map();
1647
+ const usedPaths = new Set();
1648
+ const selected = [];
1649
+ const manifestFiles = [];
1650
+ let invalidPathCount = 0;
1651
+ let sanitizedPathCount = 0;
1652
+ const addSkip = (reason) => {
1653
+ skipMap.set(reason, (skipMap.get(reason) || 0) + 1);
1654
+ };
1655
+ for (const item of cleanInput) {
1656
+ const originalPath = item.path;
1657
+ let archivePath = originalPath;
1658
+ if (!MdzPackagerCore.matchesAnyFilter(originalPath, options.filters)) {
1659
+ addSkip('excluded by filter');
1660
+ continue;
1661
+ }
1662
+ if (manifest && archivePath.toLowerCase() === 'manifest.json') {
1663
+ addSkip('manifest.json replaced by generated manifest');
1664
+ continue;
1665
+ }
1666
+ const pathError = MdzPackagerCore.validateArchivePath(archivePath);
1667
+ if (pathError) {
1668
+ invalidPathCount += 1;
1669
+ if (!options.mapFiles) {
1670
+ addSkip('invalid path for MDZ rules');
1671
+ continue;
1672
+ }
1673
+ archivePath = MdzPackagerCore.makeUniqueArchivePath(MdzPackagerCore.sanitiseArchivePath(archivePath), usedPaths);
1674
+ sanitizedPathCount += 1;
1675
+ }
1676
+ else {
1677
+ archivePath = MdzPackagerCore.makeUniqueArchivePath(archivePath, usedPaths);
1678
+ }
1679
+ const selectedItem = { archivePath, originalPath };
1680
+ if (item.file)
1681
+ selectedItem.file = item.file;
1682
+ if (item.data)
1683
+ selectedItem.data = item.data;
1684
+ if (item.text != null)
1685
+ selectedItem.text = item.text;
1686
+ selected.push(selectedItem);
1687
+ if (options.mapFiles && manifest && MdzArchiveCore.isMarkdownFile(archivePath)) {
1688
+ const base = originalPath.split('/').pop()?.replace(/\.[^/.]+$/, '') || originalPath;
1689
+ manifestFiles.push({ path: archivePath, originalPath, title: base.replace(/[_-]+/g, ' ').trim() || originalPath });
1690
+ }
1691
+ }
1692
+ if (!manifest) {
1693
+ manifest = await MdzPackagerCore.readProvidedManifest(selected);
1694
+ }
1695
+ let archivePaths = selected.map((f) => f.archivePath);
1696
+ let resolvedEntryPoint = MdzPackagerCore.resolveEntryPoint(archivePaths, manifest);
1697
+ const warningMessages = [];
1698
+ const markdownPaths = archivePaths.filter(MdzArchiveCore.isMarkdownFile);
1699
+ if (!manifest?.mode && markdownPaths.length > 1) {
1700
+ warningMessages.push('Archive contains multiple Markdown files and no explicit manifest.mode; consumers will default to document mode. If these files are intended as separate documents, set mode: "project".');
1701
+ }
1702
+ if (options.createIndex && !resolvedEntryPoint) {
1703
+ if (manifest?.entryPoint) {
1704
+ throw new Error(`ERR_PACK_ENTRYPOINT_MISSING: Manifest entry-point "${manifest.entryPoint}" does not exist.`);
1705
+ }
1706
+ const sortedMarkdownPaths = markdownPaths.slice().sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
1707
+ const generated = MdzPackagerCore.buildGeneratedIndex(sortedMarkdownPaths, options.title || rootName);
1708
+ selected.push({ archivePath: 'index.md', originalPath: '[generated]', text: generated });
1709
+ archivePaths = selected.map((f) => f.archivePath);
1710
+ resolvedEntryPoint = 'index.md';
1711
+ if (manifest)
1712
+ manifest.entryPoint = 'index.md';
1713
+ }
1714
+ if (manifest?.entryPoint && !archivePaths.some((p) => p.toLowerCase() === manifest.entryPoint.toLowerCase())) {
1715
+ throw new Error(`ERR_PACK_ENTRYPOINT_MISSING: Manifest entry-point "${manifest.entryPoint}" does not exist in archive.`);
1716
+ }
1717
+ if (options.mapFiles && manifest) {
1718
+ manifest.files = manifestFiles;
1719
+ }
1720
+ const factory = zipWriterFactory ?? MdzPackagerCore.getDefaultZipWriterFactory();
1721
+ const zip = factory.create();
1722
+ for (const item of selected) {
1723
+ if (item.file) {
1724
+ if (MdzPackagerCore.isTextFile(item.archivePath)) {
1725
+ const text = await item.file.text();
1726
+ zip.file(item.archivePath, MdzPackagerCore.normalizeLf(text));
1727
+ }
1728
+ else {
1729
+ const buffer = await item.file.arrayBuffer();
1730
+ zip.file(item.archivePath, buffer);
1731
+ }
1732
+ }
1733
+ else if (item.data) {
1734
+ if (MdzPackagerCore.isTextFile(item.archivePath)) {
1735
+ const bytes = await MdzPackagerCore.readBinarySource(item.data);
1736
+ const text = new TextDecoder().decode(bytes);
1737
+ zip.file(item.archivePath, MdzPackagerCore.normalizeLf(text));
1738
+ }
1739
+ else {
1740
+ const bytes = await MdzPackagerCore.readBinarySource(item.data);
1741
+ zip.file(item.archivePath, bytes);
1742
+ }
1743
+ }
1744
+ else {
1745
+ const content = item.text || '';
1746
+ if (MdzPackagerCore.isTextFile(item.archivePath)) {
1747
+ zip.file(item.archivePath, MdzPackagerCore.normalizeLf(content));
1748
+ }
1749
+ else {
1750
+ zip.file(item.archivePath, content);
1751
+ }
1752
+ }
1753
+ }
1754
+ if (manifest) {
1755
+ zip.file('manifest.json', JSON.stringify(manifest, null, 2));
1756
+ }
1757
+ const blob = await zip.generateAsync({ type: 'blob', compression: 'DEFLATE', compressionOptions: { level: 6 } });
1758
+ return {
1759
+ blob,
1760
+ manifest,
1761
+ resolvedEntryPoint,
1762
+ archivePaths,
1763
+ selected,
1764
+ warnings: {
1765
+ invalidPathCount,
1766
+ sanitizedPathCount,
1767
+ skippedByReason: Object.fromEntries(skipMap.entries()),
1768
+ messages: warningMessages,
1769
+ unresolvedEntry: !resolvedEntryPoint
1770
+ }
1771
+ };
1772
+ }
1773
+ static getDefaultZipWriterFactory() {
1774
+ return {
1775
+ create() {
1776
+ return new JSZip();
1777
+ }
1778
+ };
1779
+ }
1780
+ }
1781
+ //# sourceMappingURL=mdz-core.js.map