@siglum/engine 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/storage.js ADDED
@@ -0,0 +1,642 @@
1
+ /**
2
+ * @module @siglum/engine/storage
3
+ * Storage utilities for caching bundles, manifests, compiled PDFs, and CTAN packages.
4
+ * Uses @siglum/filesystem for persistent file operations.
5
+ */
6
+
7
+ import { fileSystem } from '@siglum/filesystem';
8
+
9
+ function getFileSystem() {
10
+ return fileSystem;
11
+ }
12
+
13
+ let wasmCacheMounted = false;
14
+ async function ensureWasmCacheMounted() {
15
+ if (wasmCacheMounted) return true;
16
+ const fs = await getFileSystem();
17
+ if (!fs) return false;
18
+ try {
19
+ await fs.mountAuto('/wasm-cache');
20
+ wasmCacheMounted = true;
21
+ return true;
22
+ } catch (e) {
23
+ console.warn('Failed to mount wasm-cache filesystem:', e);
24
+ return false;
25
+ }
26
+ }
27
+
28
+ const IDB_NAME = 'siglum-ctan-cache';
29
+ const IDB_STORE = 'packages';
30
+ const CTAN_CACHE_VERSION = 9; // Bumped to force refetch from TexLive 2025 (enumitem v3.11 fix)
31
+ const BUNDLE_CACHE_VERSION = 4;
32
+ const MANIFEST_CACHE_VERSION = 5; // Bumped: consolidated metadata into bundles.json
33
+
34
+ let idbCache = null;
35
+
36
+ // IndexedDB operations
37
+ async function openIDBCache() {
38
+ if (idbCache) return idbCache;
39
+ return new Promise((resolve, reject) => {
40
+ const request = indexedDB.open(IDB_NAME, 1);
41
+ request.onerror = () => reject(request.error);
42
+ request.onsuccess = () => {
43
+ idbCache = request.result;
44
+ resolve(idbCache);
45
+ };
46
+ request.onupgradeneeded = (event) => {
47
+ const db = event.target.result;
48
+ if (!db.objectStoreNames.contains(IDB_STORE)) {
49
+ db.createObjectStore(IDB_STORE, { keyPath: 'name' });
50
+ }
51
+ };
52
+ });
53
+ }
54
+
55
+ /**
56
+ * Get metadata for a cached CTAN package.
57
+ * @param {string} packageName - Package name
58
+ * @returns {Promise<Object|null>} Package metadata or null
59
+ */
60
+ export async function getPackageMeta(packageName) {
61
+ try {
62
+ const db = await openIDBCache();
63
+ return new Promise((resolve) => {
64
+ const tx = db.transaction(IDB_STORE, 'readonly');
65
+ const store = tx.objectStore(IDB_STORE);
66
+ const request = store.get(packageName);
67
+ request.onerror = () => resolve(null);
68
+ request.onsuccess = () => resolve(request.result);
69
+ });
70
+ } catch (e) {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Save metadata for a CTAN package.
77
+ * @param {string} packageName - Package name
78
+ * @param {Object} meta - Package metadata
79
+ * @returns {Promise<boolean>} True if saved successfully
80
+ */
81
+ export async function savePackageMeta(packageName, meta) {
82
+ try {
83
+ const db = await openIDBCache();
84
+ return new Promise((resolve) => {
85
+ const tx = db.transaction(IDB_STORE, 'readwrite');
86
+ const store = tx.objectStore(IDB_STORE);
87
+ const request = store.put({ name: packageName, ...meta, timestamp: Date.now() });
88
+ request.onerror = () => resolve(false);
89
+ request.onsuccess = () => resolve(true);
90
+ });
91
+ } catch (e) {
92
+ return false;
93
+ }
94
+ }
95
+
96
+ /**
97
+ * List all cached CTAN packages and their metadata.
98
+ * @returns {Promise<Object[]>} Array of package metadata objects
99
+ */
100
+ export async function listAllCachedPackages() {
101
+ try {
102
+ const db = await openIDBCache();
103
+ return new Promise((resolve) => {
104
+ const tx = db.transaction(IDB_STORE, 'readonly');
105
+ const store = tx.objectStore(IDB_STORE);
106
+ const request = store.getAll();
107
+ request.onerror = () => resolve([]);
108
+ request.onsuccess = () => resolve(request.result || []);
109
+ });
110
+ } catch (e) {
111
+ return [];
112
+ }
113
+ }
114
+
115
+ // Mount for manifests
116
+ let manifestsMounted = false;
117
+ async function ensureManifestsMounted() {
118
+ if (manifestsMounted) return true;
119
+ const fs = await getFileSystem();
120
+ if (!fs) return false;
121
+ try {
122
+ await fs.mountAuto('/manifests');
123
+ manifestsMounted = true;
124
+ return true;
125
+ } catch (e) {
126
+ console.warn('Failed to mount manifests filesystem:', e);
127
+ return false;
128
+ }
129
+ }
130
+
131
+ // Mount for format cache
132
+ let fmtCacheMounted = false;
133
+ async function ensureFmtCacheMounted() {
134
+ if (fmtCacheMounted) return true;
135
+ const fs = await getFileSystem();
136
+ if (!fs) return false;
137
+ try {
138
+ await fs.mountAuto('/fmt-cache');
139
+ fmtCacheMounted = true;
140
+ return true;
141
+ } catch (e) {
142
+ console.warn('Failed to mount fmt-cache filesystem:', e);
143
+ return false;
144
+ }
145
+ }
146
+
147
+ // Mount for texlive/CTAN cache
148
+ let texliveMounted = false;
149
+
150
+ /**
151
+ * Ensure the /texlive filesystem is mounted for CTAN package storage.
152
+ * @returns {Promise<boolean>} True if mounted successfully
153
+ */
154
+ export async function ensureTexliveMounted() {
155
+ if (texliveMounted) return true;
156
+ const fs = await getFileSystem();
157
+ if (!fs) return false;
158
+ try {
159
+ await fs.mountAuto('/texlive');
160
+ texliveMounted = true;
161
+ return true;
162
+ } catch (e) {
163
+ console.warn('Failed to mount texlive filesystem:', e);
164
+ return false;
165
+ }
166
+ }
167
+
168
+ // Bundle cache operations
169
+ let bundleCacheMounted = false;
170
+
171
+ async function ensureBundleCacheMounted() {
172
+ if (bundleCacheMounted) return true;
173
+ const fs = await getFileSystem();
174
+ if (!fs) return false;
175
+ try {
176
+ await fs.mountAuto('/bundle-cache');
177
+ bundleCacheMounted = true;
178
+
179
+ // Check version and clear if outdated
180
+ try {
181
+ const versionStr = await fs.readFile('/bundle-cache/version');
182
+ const version = parseInt(versionStr) || 0;
183
+ if (version < BUNDLE_CACHE_VERSION) {
184
+ if (version > 0) {
185
+ console.log(`Bundle cache version upgrade (${version} → ${BUNDLE_CACHE_VERSION}), clearing...`);
186
+ }
187
+ await fs.rmdir('/bundle-cache', { recursive: true });
188
+ await fs.mountAuto('/bundle-cache');
189
+ }
190
+ } catch (e) {
191
+ // Version file doesn't exist, will be created on first write
192
+ }
193
+
194
+ // Write current version
195
+ await fs.writeFile('/bundle-cache/version', String(BUNDLE_CACHE_VERSION));
196
+ return true;
197
+ } catch (e) {
198
+ console.warn('Failed to mount bundle-cache filesystem:', e);
199
+ return false;
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Get a bundle from the cache.
205
+ * @param {string} bundleName - Bundle name
206
+ * @returns {Promise<ArrayBuffer|null>} Bundle data or null if not cached
207
+ */
208
+ export async function getBundleFromCache(bundleName) {
209
+ try {
210
+ const fs = await getFileSystem();
211
+ if (!fs || !await ensureBundleCacheMounted()) return null;
212
+
213
+ const data = await fs.readBinary(`/bundle-cache/bundles/${bundleName}.data`);
214
+ return data?.buffer || null;
215
+ } catch (e) {
216
+ return null;
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Save a bundle to the cache.
222
+ * @param {string} bundleName - Bundle name
223
+ * @param {ArrayBuffer|SharedArrayBuffer} data - Bundle data
224
+ * @returns {Promise<boolean>} True if saved successfully
225
+ */
226
+ export async function saveBundleToCache(bundleName, data) {
227
+ try {
228
+ const fs = await getFileSystem();
229
+ if (!fs || !await ensureBundleCacheMounted()) return false;
230
+
231
+ await fs.mkdir('/bundle-cache/bundles');
232
+ // Convert SharedArrayBuffer to regular ArrayBuffer for IndexedDB compatibility
233
+ // (SharedArrayBuffer can't be serialized for storage)
234
+ let buffer = data;
235
+ if (typeof SharedArrayBuffer !== 'undefined' && data instanceof SharedArrayBuffer) {
236
+ buffer = new ArrayBuffer(data.byteLength);
237
+ new Uint8Array(buffer).set(new Uint8Array(data));
238
+ }
239
+ await fs.writeBinary(`/bundle-cache/bundles/${bundleName}.data`, new Uint8Array(buffer));
240
+ return true;
241
+ } catch (e) {
242
+ console.warn(`Failed to save bundle ${bundleName}:`, e);
243
+ return false;
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Get a manifest from the cache.
249
+ * @param {string} name - Manifest name (without .json extension)
250
+ * @returns {Promise<Object|null>} Parsed manifest or null
251
+ */
252
+ export async function getManifestFromCache(name) {
253
+ try {
254
+ const fs = await getFileSystem();
255
+ if (!fs || !await ensureManifestsMounted()) return null;
256
+
257
+ const content = await fs.readFile(`/manifests/${name}.json`);
258
+ return JSON.parse(content);
259
+ } catch (e) {
260
+ return null;
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Save a manifest to the cache.
266
+ * @param {string} name - Manifest name (without .json extension)
267
+ * @param {Object} data - Manifest data
268
+ * @returns {Promise<boolean>} True if saved successfully
269
+ */
270
+ export async function saveManifestToCache(name, data) {
271
+ try {
272
+ const fs = await getFileSystem();
273
+ if (!fs || !await ensureManifestsMounted()) return false;
274
+
275
+ await fs.writeFile(`/manifests/${name}.json`, JSON.stringify(data), { createParents: true });
276
+ return true;
277
+ } catch (e) {
278
+ return false;
279
+ }
280
+ }
281
+
282
+ /**
283
+ * Get the cached manifest version number.
284
+ * @returns {Promise<number>} Version number (0 if not set)
285
+ */
286
+ export async function getManifestVersion() {
287
+ try {
288
+ const fs = await getFileSystem();
289
+ if (!fs || !await ensureManifestsMounted()) return 0;
290
+
291
+ const content = await fs.readFile('/manifests/version');
292
+ return parseInt(content) || 0;
293
+ } catch (e) {
294
+ return 0;
295
+ }
296
+ }
297
+
298
+ /**
299
+ * Save the manifest version number.
300
+ * @param {number} version - Version number
301
+ * @returns {Promise<boolean>} True if saved successfully
302
+ */
303
+ export async function saveManifestVersion(version) {
304
+ try {
305
+ const fs = await getFileSystem();
306
+ if (!fs || !await ensureManifestsMounted()) return false;
307
+
308
+ await fs.writeFile('/manifests/version', String(version), { createParents: true });
309
+ return true;
310
+ } catch (e) {
311
+ return false;
312
+ }
313
+ }
314
+
315
+ // Aux file cache
316
+ const AUX_STORE = 'aux-cache';
317
+ let auxCacheDb = null;
318
+ const auxMemoryCache = new Map();
319
+
320
+ async function openAuxCacheDb() {
321
+ if (auxCacheDb) return auxCacheDb;
322
+ return new Promise((resolve, reject) => {
323
+ const request = indexedDB.open('siglum-aux-cache', 1);
324
+ request.onerror = () => reject(request.error);
325
+ request.onsuccess = () => {
326
+ auxCacheDb = request.result;
327
+ resolve(auxCacheDb);
328
+ };
329
+ request.onupgradeneeded = (event) => {
330
+ const db = event.target.result;
331
+ if (!db.objectStoreNames.contains(AUX_STORE)) {
332
+ db.createObjectStore(AUX_STORE, { keyPath: 'hash' });
333
+ }
334
+ };
335
+ });
336
+ }
337
+
338
+ /**
339
+ * Get cached aux files for a preamble hash.
340
+ * @param {string} preambleHash - Hash of the document preamble
341
+ * @returns {Promise<{hash: string, files: Object, timestamp: number}|null>} Cached entry or null
342
+ */
343
+ export async function getAuxCache(preambleHash) {
344
+ if (auxMemoryCache.has(preambleHash)) {
345
+ return auxMemoryCache.get(preambleHash);
346
+ }
347
+ try {
348
+ const db = await openAuxCacheDb();
349
+ return new Promise((resolve) => {
350
+ const tx = db.transaction(AUX_STORE, 'readonly');
351
+ const store = tx.objectStore(AUX_STORE);
352
+ const request = store.get(preambleHash);
353
+ request.onerror = () => resolve(null);
354
+ request.onsuccess = () => {
355
+ const result = request.result;
356
+ if (result) auxMemoryCache.set(preambleHash, result);
357
+ resolve(result);
358
+ };
359
+ });
360
+ } catch (e) {
361
+ return null;
362
+ }
363
+ }
364
+
365
+ /**
366
+ * Save aux files for a preamble hash.
367
+ * @param {string} preambleHash - Hash of the document preamble
368
+ * @param {Object} auxFiles - Aux files to cache
369
+ * @returns {Promise<void>}
370
+ */
371
+ export async function saveAuxCache(preambleHash, auxFiles) {
372
+ const entry = { hash: preambleHash, files: auxFiles, timestamp: Date.now() };
373
+ auxMemoryCache.set(preambleHash, entry);
374
+ try {
375
+ const db = await openAuxCacheDb();
376
+ const tx = db.transaction(AUX_STORE, 'readwrite');
377
+ const store = tx.objectStore(AUX_STORE);
378
+ store.put(entry);
379
+ } catch (e) {}
380
+ }
381
+
382
+ // Document cache for compiled PDFs
383
+ const DOC_STORE = 'doc-cache';
384
+ let docCacheDb = null;
385
+ const docMemoryCache = new Map();
386
+ const MAX_DOC_CACHE_SIZE = 10;
387
+
388
+ async function openDocCacheDb() {
389
+ if (docCacheDb) return docCacheDb;
390
+ return new Promise((resolve, reject) => {
391
+ const request = indexedDB.open('siglum-doc-cache', 1);
392
+ request.onerror = () => reject(request.error);
393
+ request.onsuccess = () => {
394
+ docCacheDb = request.result;
395
+ resolve(docCacheDb);
396
+ };
397
+ request.onupgradeneeded = (event) => {
398
+ const db = event.target.result;
399
+ if (!db.objectStoreNames.contains(DOC_STORE)) {
400
+ db.createObjectStore(DOC_STORE, { keyPath: 'key' });
401
+ }
402
+ };
403
+ });
404
+ }
405
+
406
+ // Re-export hashDocument from centralized hash module (BLAKE3-WASM)
407
+ export { hashDocument } from './hash.js';
408
+
409
+ /**
410
+ * Get a cached compiled PDF.
411
+ * @param {string} docHash - Document content hash
412
+ * @param {string} engine - Engine used ('pdflatex', 'xelatex', 'lualatex')
413
+ * @returns {Promise<Uint8Array|null>} PDF data or null if not cached
414
+ */
415
+ export async function getCachedPdf(docHash, engine) {
416
+ const key = docHash + '_' + engine;
417
+ if (docMemoryCache.has(key)) {
418
+ return docMemoryCache.get(key);
419
+ }
420
+ try {
421
+ const db = await openDocCacheDb();
422
+ return new Promise((resolve) => {
423
+ const tx = db.transaction(DOC_STORE, 'readonly');
424
+ const store = tx.objectStore(DOC_STORE);
425
+ const request = store.get(key);
426
+ request.onerror = () => resolve(null);
427
+ request.onsuccess = () => {
428
+ const result = request.result;
429
+ if (result) {
430
+ docMemoryCache.set(key, result.pdfData);
431
+ }
432
+ resolve(result?.pdfData || null);
433
+ };
434
+ });
435
+ } catch (e) {
436
+ return null;
437
+ }
438
+ }
439
+
440
+ /**
441
+ * Save a compiled PDF to the cache.
442
+ * @param {string} docHash - Document content hash
443
+ * @param {string} engine - Engine used ('pdflatex', 'xelatex', 'lualatex')
444
+ * @param {Uint8Array} pdfData - PDF data
445
+ * @returns {Promise<void>}
446
+ */
447
+ export async function saveCachedPdf(docHash, engine, pdfData) {
448
+ const key = docHash + '_' + engine;
449
+ docMemoryCache.set(key, pdfData);
450
+
451
+ // Limit memory cache size
452
+ if (docMemoryCache.size > MAX_DOC_CACHE_SIZE) {
453
+ const firstKey = docMemoryCache.keys().next().value;
454
+ docMemoryCache.delete(firstKey);
455
+ }
456
+
457
+ try {
458
+ const db = await openDocCacheDb();
459
+ const tx = db.transaction(DOC_STORE, 'readwrite');
460
+ const store = tx.objectStore(DOC_STORE);
461
+ store.put({ key, pdfData, timestamp: Date.now() });
462
+ } catch (e) {}
463
+ }
464
+
465
+ /**
466
+ * Get the path for a format file.
467
+ * @param {string} fmtKey - Format key
468
+ * @returns {string} Path to format file
469
+ */
470
+ export function getFmtPath(fmtKey) {
471
+ return `fmt-cache/${fmtKey}.fmt`;
472
+ }
473
+
474
+ /**
475
+ * Clear all cached CTAN package metadata.
476
+ * @returns {Promise<boolean>} True if cleared successfully
477
+ */
478
+ export async function clearCTANCache() {
479
+ try {
480
+ const db = await openIDBCache();
481
+ const tx = db.transaction(IDB_STORE, 'readwrite');
482
+ const store = tx.objectStore(IDB_STORE);
483
+ store.clear();
484
+ await new Promise(r => tx.oncomplete = r);
485
+ return true;
486
+ } catch (e) {
487
+ return false;
488
+ }
489
+ }
490
+
491
+ // WASM cache - stores COMPILED WebAssembly.Module in IndexedDB for instant instantiation
492
+ const WASM_CACHE_VERSION = 2; // Bump to invalidate old byte caches
493
+ const WASM_DB_NAME = 'siglum-wasm-cache';
494
+ const WASM_STORE = 'modules';
495
+
496
+ let wasmCacheDb = null;
497
+
498
+ async function openWasmCacheDb() {
499
+ if (wasmCacheDb) return wasmCacheDb;
500
+ return new Promise((resolve, reject) => {
501
+ const request = indexedDB.open(WASM_DB_NAME, WASM_CACHE_VERSION);
502
+ request.onerror = () => reject(request.error);
503
+ request.onsuccess = () => {
504
+ wasmCacheDb = request.result;
505
+ resolve(wasmCacheDb);
506
+ };
507
+ request.onupgradeneeded = (event) => {
508
+ const db = event.target.result;
509
+ // Clear old stores on version upgrade
510
+ for (const name of db.objectStoreNames) {
511
+ db.deleteObjectStore(name);
512
+ }
513
+ db.createObjectStore(WASM_STORE, { keyPath: 'key' });
514
+ };
515
+ });
516
+ }
517
+
518
+ /**
519
+ * Get cached WASM bytes and compile to WebAssembly.Module.
520
+ * @returns {Promise<WebAssembly.Module|null>} Compiled module or null
521
+ */
522
+ export async function getCompiledWasmModule() {
523
+ try {
524
+ const db = await openWasmCacheDb();
525
+ return new Promise((resolve) => {
526
+ const tx = db.transaction(WASM_STORE, 'readonly');
527
+ const store = tx.objectStore(WASM_STORE);
528
+ const request = store.get('busytex');
529
+ request.onerror = () => resolve(null);
530
+ request.onsuccess = async () => {
531
+ const result = request.result;
532
+ if (result?.bytes instanceof Uint8Array) {
533
+ try {
534
+ const module = await WebAssembly.compile(result.bytes);
535
+ resolve(module);
536
+ } catch {
537
+ resolve(null);
538
+ }
539
+ } else {
540
+ resolve(null);
541
+ }
542
+ };
543
+ });
544
+ } catch (e) {
545
+ console.warn('Failed to get cached WASM:', e);
546
+ return null;
547
+ }
548
+ }
549
+
550
+ /**
551
+ * Save WASM bytes to IndexedDB for future compilation.
552
+ * @param {Uint8Array} bytes - WASM bytes
553
+ * @returns {Promise<boolean>} True if saved successfully
554
+ */
555
+ export async function saveWasmBytes(bytes) {
556
+ try {
557
+ const db = await openWasmCacheDb();
558
+ return new Promise((resolve) => {
559
+ const tx = db.transaction(WASM_STORE, 'readwrite');
560
+ const store = tx.objectStore(WASM_STORE);
561
+ const request = store.put({ key: 'busytex', bytes, timestamp: Date.now() });
562
+ request.onerror = () => resolve(false);
563
+ request.onsuccess = () => resolve(true);
564
+ });
565
+ } catch {
566
+ return false;
567
+ }
568
+ }
569
+
570
+ // WASM Memory Snapshot Cache - stores initialized WASM heap for instant restore
571
+ // This caches the WASM linear memory after first successful initialization
572
+ // Restoring from snapshot skips the ~3-5s initialization overhead
573
+ const MEMORY_SNAPSHOT_VERSION = 1;
574
+ const MEMORY_SNAPSHOT_PATH = '/wasm-cache/memory-snapshot.bin';
575
+ const MEMORY_SNAPSHOT_META_PATH = '/wasm-cache/memory-snapshot-meta.json';
576
+
577
+ // Prevent concurrent save operations (race condition protection)
578
+ let snapshotSaveInProgress = false;
579
+
580
+ /**
581
+ * Save WASM memory snapshot for instant restore on next load.
582
+ * @param {WebAssembly.Memory|Uint8Array} memoryOrSnapshot - Memory object or snapshot bytes
583
+ * @param {Object} [metadata] - Optional metadata to save with snapshot
584
+ * @returns {Promise<boolean>} True if saved successfully
585
+ */
586
+ export async function saveWasmMemorySnapshot(memoryOrSnapshot, metadata = {}) {
587
+ // Prevent concurrent saves - only one save operation at a time
588
+ if (snapshotSaveInProgress) {
589
+ console.log('Memory snapshot save already in progress, skipping');
590
+ return false;
591
+ }
592
+ snapshotSaveInProgress = true;
593
+
594
+ try {
595
+ if (!await ensureWasmCacheMounted()) {
596
+ console.warn('Cannot save memory snapshot - filesystem not available');
597
+ return false;
598
+ }
599
+
600
+ // Accept either a memory object (with .buffer) or a Uint8Array directly
601
+ // This avoids unnecessary copies when we already have a Uint8Array
602
+ const snapshot = memoryOrSnapshot instanceof Uint8Array
603
+ ? memoryOrSnapshot
604
+ : new Uint8Array(memoryOrSnapshot.buffer);
605
+
606
+ const byteLength = snapshot.byteLength;
607
+
608
+ // Write snapshot binary - fileSystem handles any necessary copying internally
609
+ const fs = await getFileSystem();
610
+ if (!fs) return false;
611
+ await fs.writeBinary(MEMORY_SNAPSHOT_PATH, snapshot, { createParents: true, silent: true });
612
+
613
+ // Write metadata as JSON (small, no optimization needed)
614
+ const metaData = {
615
+ byteLength,
616
+ metadata,
617
+ timestamp: Date.now(),
618
+ version: MEMORY_SNAPSHOT_VERSION,
619
+ };
620
+ await fs.writeFile(MEMORY_SNAPSHOT_META_PATH, JSON.stringify(metaData), { silent: true });
621
+
622
+ console.log(`Saved WASM memory snapshot (${(byteLength / 1024 / 1024).toFixed(1)}MB)`);
623
+ return true;
624
+ } catch (e) {
625
+ console.warn('Failed to save memory snapshot:', e);
626
+ return false;
627
+ } finally {
628
+ snapshotSaveInProgress = false;
629
+ }
630
+ }
631
+
632
+ /**
633
+ * Current CTAN cache version. Bump to invalidate cached packages.
634
+ * @type {number}
635
+ */
636
+ export { CTAN_CACHE_VERSION };
637
+
638
+ /**
639
+ * Current manifest cache version. Bump to invalidate cached manifests.
640
+ * @type {number}
641
+ */
642
+ export { MANIFEST_CACHE_VERSION };
package/src/utils.js ADDED
@@ -0,0 +1,33 @@
1
+ // Utility functions for siglum-engine
2
+
3
+ /**
4
+ * Create a batched logger that collects messages and flushes them once per animation frame.
5
+ * This prevents DOM thrashing when the compiler emits many log messages rapidly.
6
+ *
7
+ * @param {function(string[]): void} onFlush - Called with array of messages to display
8
+ * @returns {function(string): void} - Logger function to pass to SiglumCompiler's onLog option
9
+ *
10
+ * @example
11
+ * const compiler = new SiglumCompiler({
12
+ * onLog: createBatchedLogger((messages) => {
13
+ * statusDiv.textContent += messages.join('\n') + '\n';
14
+ * statusDiv.scrollTop = statusDiv.scrollHeight;
15
+ * }),
16
+ * });
17
+ */
18
+ export function createBatchedLogger(onFlush) {
19
+ let buffer = [];
20
+ let flushScheduled = false;
21
+
22
+ return function log(msg) {
23
+ buffer.push(msg);
24
+ if (!flushScheduled) {
25
+ flushScheduled = true;
26
+ requestAnimationFrame(() => {
27
+ onFlush(buffer);
28
+ buffer = [];
29
+ flushScheduled = false;
30
+ });
31
+ }
32
+ };
33
+ }