@hyperbook/markdown 0.45.0 → 0.46.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,631 @@
1
+ hyperbook.typst = (function () {
2
+ // Register code-input template for typst syntax highlighting
3
+ window.codeInput?.registerTemplate(
4
+ "typst-highlighted",
5
+ codeInput.templates.prism(window.Prism, [
6
+ new codeInput.plugins.AutoCloseBrackets(),
7
+ new codeInput.plugins.Indent(true, 2),
8
+ ]),
9
+ );
10
+
11
+ const elems = document.getElementsByClassName("directive-typst");
12
+
13
+ // Typst WASM module URLs
14
+ const TYPST_COMPILER_URL = "https://cdn.jsdelivr.net/npm/@myriaddreamin/typst-ts-web-compiler/pkg/typst_ts_web_compiler_bg.wasm";
15
+ const TYPST_RENDERER_URL = "https://cdn.jsdelivr.net/npm/@myriaddreamin/typst-ts-renderer/pkg/typst_ts_renderer_bg.wasm";
16
+
17
+ // Load typst all-in-one bundle
18
+ let typstLoaded = false;
19
+ let typstLoadPromise = null;
20
+
21
+ // Rendering queue to ensure only one render at a time
22
+ let renderQueue = Promise.resolve();
23
+
24
+ const queueRender = (renderFn) => {
25
+ renderQueue = renderQueue.then(renderFn).catch((error) => {
26
+ console.error("Queued render error:", error);
27
+ });
28
+ return renderQueue;
29
+ };
30
+
31
+ const loadTypst = () => {
32
+ if (typstLoaded) {
33
+ return Promise.resolve();
34
+ }
35
+ if (typstLoadPromise) {
36
+ return typstLoadPromise;
37
+ }
38
+
39
+ typstLoadPromise = new Promise((resolve, reject) => {
40
+ const script = document.createElement("script");
41
+ script.src = "https://cdn.jsdelivr.net/npm/@myriaddreamin/typst.ts/dist/esm/contrib/all-in-one-lite.bundle.js";
42
+ script.type = "module";
43
+ script.id = "typst-loader";
44
+ script.onload = () => {
45
+ // Wait a bit for the module to initialize
46
+ const checkTypst = () => {
47
+ if (typeof $typst !== "undefined") {
48
+ // Initialize the Typst compiler and renderer
49
+ $typst.setCompilerInitOptions({
50
+ getModule: () => TYPST_COMPILER_URL,
51
+ });
52
+ $typst.setRendererInitOptions({
53
+ getModule: () => TYPST_RENDERER_URL,
54
+ });
55
+ typstLoaded = true;
56
+ resolve();
57
+ } else {
58
+ setTimeout(checkTypst, 50);
59
+ }
60
+ };
61
+ checkTypst();
62
+ };
63
+ script.onerror = reject;
64
+ document.head.appendChild(script);
65
+ });
66
+
67
+ return typstLoadPromise;
68
+ };
69
+
70
+ // Parse error message from SourceDiagnostic format
71
+ const parseTypstError = (errorMessage) => {
72
+ try {
73
+ // Try to extract message from SourceDiagnostic format
74
+ const match = errorMessage.match(/message:\s*"([^"]+)"/);
75
+ if (match) {
76
+ return match[1];
77
+ }
78
+ } catch (e) {
79
+ // Fallback to original message
80
+ }
81
+ return errorMessage;
82
+ };
83
+
84
+ // Render typst code to SVG
85
+ const renderTypst = async (code, container, loadingIndicator, sourceFiles, binaryFiles, id, previewContainer) => {
86
+ // Queue this render to ensure only one compilation runs at a time
87
+ return queueRender(async () => {
88
+ // Show loading indicator
89
+ if (loadingIndicator) {
90
+ loadingIndicator.style.display = "flex";
91
+ }
92
+
93
+ await loadTypst();
94
+
95
+ try {
96
+ // Reset shadow files for this render
97
+ $typst.resetShadow();
98
+
99
+ // Add source files
100
+ for (const { filename, content } of sourceFiles) {
101
+ const path = filename.startsWith('/') ? filename.substring(1) : filename;
102
+ await $typst.addSource(`/${path}`, content);
103
+ }
104
+
105
+ // Add binary files
106
+ for (const { dest, url } of binaryFiles) {
107
+ try {
108
+ let arrayBuffer;
109
+
110
+ // Check if URL is a data URL (user-uploaded file)
111
+ if (url.startsWith('data:')) {
112
+ const response = await fetch(url);
113
+ arrayBuffer = await response.arrayBuffer();
114
+ } else {
115
+ // External URL
116
+ const response = await fetch(url);
117
+ if (!response.ok) {
118
+ console.warn(`Failed to load binary file: ${url}`);
119
+ continue;
120
+ }
121
+ arrayBuffer = await response.arrayBuffer();
122
+ }
123
+
124
+ const path = dest.startsWith('/') ? dest.substring(1) : dest;
125
+ $typst.mapShadow(`/${path}`, new Uint8Array(arrayBuffer));
126
+ } catch (error) {
127
+ console.warn(`Error loading binary file ${url}:`, error);
128
+ }
129
+ }
130
+
131
+ const svg = await $typst.svg({ mainContent: code });
132
+
133
+ // Remove any existing error overlay from preview-container
134
+ if (previewContainer) {
135
+ const existingError = previewContainer.querySelector('.typst-error-overlay');
136
+ if (existingError) {
137
+ existingError.remove();
138
+ }
139
+ }
140
+
141
+ container.innerHTML = svg;
142
+
143
+ // Scale SVG to fit container
144
+ const svgElem = container.firstElementChild;
145
+ if (svgElem) {
146
+ const width = Number.parseFloat(svgElem.getAttribute("width"));
147
+ const height = Number.parseFloat(svgElem.getAttribute("height"));
148
+ const containerWidth = container.clientWidth - 20;
149
+ if (width > 0 && containerWidth > 0) {
150
+ svgElem.setAttribute("width", containerWidth);
151
+ svgElem.setAttribute("height", (height * containerWidth) / width);
152
+ }
153
+ }
154
+ } catch (error) {
155
+ const errorText = parseTypstError(error || "Error rendering Typst");
156
+
157
+ // Check if we have existing content (previous successful render)
158
+ const hasExistingContent = container.querySelector('svg') !== null;
159
+
160
+ // Always use error overlay in preview-container if available
161
+ if (previewContainer) {
162
+ // Remove any existing error overlay
163
+ const existingError = previewContainer.querySelector('.typst-error-overlay');
164
+ if (existingError) {
165
+ existingError.remove();
166
+ }
167
+
168
+ // Clear preview if no existing content
169
+ if (!hasExistingContent) {
170
+ container.innerHTML = '';
171
+ }
172
+
173
+ // Create floating error overlay in preview-container
174
+ const errorOverlay = document.createElement('div');
175
+ errorOverlay.className = 'typst-error-overlay';
176
+ errorOverlay.innerHTML = `
177
+ <div class="typst-error-content">
178
+ <div class="typst-error-header">
179
+ <span class="typst-error-title">⚠️ Typst Error</span>
180
+ <button class="typst-error-close" title="Dismiss error">×</button>
181
+ </div>
182
+ <div class="typst-error-message">${errorText}</div>
183
+ </div>
184
+ `;
185
+
186
+ // Add close button functionality
187
+ const closeBtn = errorOverlay.querySelector('.typst-error-close');
188
+ closeBtn.addEventListener('click', () => {
189
+ errorOverlay.remove();
190
+ });
191
+
192
+ previewContainer.appendChild(errorOverlay);
193
+ } else {
194
+ // Fallback: show error in preview container directly
195
+ container.innerHTML = `<div class="typst-error">${errorText}</div>`;
196
+ }
197
+ } finally {
198
+ // Hide loading indicator
199
+ if (loadingIndicator) {
200
+ loadingIndicator.style.display = "none";
201
+ }
202
+ }
203
+ });
204
+ };
205
+
206
+ // Export to PDF
207
+ const exportPdf = async (code, id, sourceFiles, binaryFiles) => {
208
+ // Queue this export to ensure only one compilation runs at a time
209
+ return queueRender(async () => {
210
+ await loadTypst();
211
+
212
+ try {
213
+ // Reset shadow files for this export
214
+ $typst.resetShadow();
215
+
216
+ // Add source files
217
+ for (const { filename, content } of sourceFiles) {
218
+ const path = filename.startsWith('/') ? filename.substring(1) : filename;
219
+ await $typst.addSource(`/${path}`, content);
220
+ }
221
+
222
+ // Add binary files
223
+ for (const { dest, url } of binaryFiles) {
224
+ try {
225
+ let arrayBuffer;
226
+
227
+ // Check if URL is a data URL (user-uploaded file)
228
+ if (url.startsWith('data:')) {
229
+ const response = await fetch(url);
230
+ arrayBuffer = await response.arrayBuffer();
231
+ } else {
232
+ // External URL
233
+ const response = await fetch(url);
234
+ if (!response.ok) {
235
+ continue;
236
+ }
237
+ arrayBuffer = await response.arrayBuffer();
238
+ }
239
+
240
+ const path = dest.startsWith('/') ? dest.substring(1) : dest;
241
+ $typst.mapShadow(`/${path}`, new Uint8Array(arrayBuffer));
242
+ } catch (error) {
243
+ console.warn(`Error loading binary file ${url}:`, error);
244
+ }
245
+ }
246
+
247
+ const pdfData = await $typst.pdf({ mainContent: code });
248
+ const pdfFile = new Blob([pdfData], { type: "application/pdf" });
249
+ const link = document.createElement("a");
250
+ link.href = URL.createObjectURL(pdfFile);
251
+ link.download = `typst-${id}.pdf`;
252
+ link.click();
253
+ URL.revokeObjectURL(link.href);
254
+ } catch (error) {
255
+ console.error("PDF export error:", error);
256
+ alert(i18n.get("typst-pdf-error") || "Error exporting PDF");
257
+ }
258
+ });
259
+ };
260
+
261
+ for (let elem of elems) {
262
+ const id = elem.getAttribute("data-id");
263
+ const previewContainer = elem.querySelector(".preview-container");
264
+ const preview = elem.querySelector(".typst-preview");
265
+ const loadingIndicator = elem.querySelector(".typst-loading");
266
+ const editor = elem.querySelector(".editor.typst");
267
+ const downloadBtn = elem.querySelector(".download-pdf");
268
+ const downloadProjectBtn = elem.querySelector(".download-project");
269
+ const resetBtn = elem.querySelector(".reset");
270
+ const sourceTextarea = elem.querySelector(".typst-source");
271
+ const tabsList = elem.querySelector(".tabs-list");
272
+ const binaryFilesList = elem.querySelector(".binary-files-list");
273
+ const addSourceFileBtn = elem.querySelector(".add-source-file");
274
+ const addBinaryFileBtn = elem.querySelector(".add-binary-file");
275
+
276
+ // Parse source files and binary files from data attributes
277
+ const sourceFilesData = elem.getAttribute("data-source-files");
278
+ const binaryFilesData = elem.getAttribute("data-binary-files");
279
+
280
+ let sourceFiles = sourceFilesData
281
+ ? JSON.parse(atob(sourceFilesData))
282
+ : [];
283
+ let binaryFiles = binaryFilesData
284
+ ? JSON.parse(atob(binaryFilesData))
285
+ : [];
286
+
287
+ // Track current active file
288
+ let currentFile = sourceFiles.find(f => f.filename === "main.typ" || f.filename === "main.typst") || sourceFiles[0];
289
+
290
+ // Store file contents in memory
291
+ const fileContents = new Map();
292
+ sourceFiles.forEach(f => fileContents.set(f.filename, f.content));
293
+
294
+ // Function to update tabs UI
295
+ const updateTabs = () => {
296
+ if (!tabsList) return;
297
+
298
+ tabsList.innerHTML = "";
299
+
300
+ // Add source file tabs
301
+ sourceFiles.forEach(file => {
302
+ const tab = document.createElement("div");
303
+ tab.className = "file-tab";
304
+ if (file.filename === currentFile.filename) {
305
+ tab.classList.add("active");
306
+ }
307
+
308
+ const tabName = document.createElement("span");
309
+ tabName.className = "tab-name";
310
+ tabName.textContent = file.filename;
311
+ tab.appendChild(tabName);
312
+
313
+ // Add delete button (except for main file)
314
+ if (file.filename !== "main.typ" && file.filename !== "main.typst") {
315
+ const deleteBtn = document.createElement("button");
316
+ deleteBtn.className = "tab-delete";
317
+ deleteBtn.textContent = "×";
318
+ deleteBtn.title = i18n.get("typst-delete-file") || "Delete file";
319
+ deleteBtn.addEventListener("click", (e) => {
320
+ e.stopPropagation();
321
+ if (confirm(`${i18n.get("typst-delete-confirm") || "Delete"} ${file.filename}?`)) {
322
+ sourceFiles = sourceFiles.filter(f => f.filename !== file.filename);
323
+ fileContents.delete(file.filename);
324
+
325
+ // Switch to main file if we deleted the current file
326
+ if (currentFile.filename === file.filename) {
327
+ currentFile = sourceFiles[0];
328
+ if (editor) {
329
+ editor.value = fileContents.get(currentFile.filename) || "";
330
+ }
331
+ }
332
+
333
+ updateTabs();
334
+ saveState();
335
+ rerenderTypst();
336
+ }
337
+ });
338
+ tab.appendChild(deleteBtn);
339
+ }
340
+
341
+ tab.addEventListener("click", () => {
342
+ if (currentFile.filename !== file.filename) {
343
+ // Save current file content
344
+ if (editor) {
345
+ fileContents.set(currentFile.filename, editor.value);
346
+ }
347
+
348
+ // Switch to new file
349
+ currentFile = file;
350
+ if (editor) {
351
+ editor.value = fileContents.get(currentFile.filename) || "";
352
+ }
353
+
354
+ updateTabs();
355
+ saveState();
356
+ }
357
+ });
358
+
359
+ tabsList.appendChild(tab);
360
+ });
361
+ };
362
+
363
+ // Function to update binary files list
364
+ const updateBinaryFilesList = () => {
365
+ if (!binaryFilesList) return;
366
+
367
+ binaryFilesList.innerHTML = "";
368
+
369
+ if (binaryFiles.length === 0) {
370
+ const emptyMsg = document.createElement("div");
371
+ emptyMsg.className = "binary-files-empty";
372
+ emptyMsg.textContent = i18n.get("typst-no-binary-files") || "No binary files";
373
+ binaryFilesList.appendChild(emptyMsg);
374
+ return;
375
+ }
376
+
377
+ binaryFiles.forEach(file => {
378
+ const item = document.createElement("div");
379
+ item.className = "binary-file-item";
380
+
381
+ const icon = document.createElement("span");
382
+ icon.className = "binary-file-icon";
383
+ icon.textContent = "📎";
384
+ item.appendChild(icon);
385
+
386
+ const name = document.createElement("span");
387
+ name.className = "binary-file-name";
388
+ name.textContent = file.dest;
389
+ item.appendChild(name);
390
+
391
+ const deleteBtn = document.createElement("button");
392
+ deleteBtn.className = "binary-file-delete";
393
+ deleteBtn.textContent = "×";
394
+ deleteBtn.title = i18n.get("typst-delete-file") || "Delete file";
395
+ deleteBtn.addEventListener("click", () => {
396
+ if (confirm(`${i18n.get("typst-delete-confirm") || "Delete"} ${file.dest}?`)) {
397
+ binaryFiles = binaryFiles.filter(f => f.dest !== file.dest);
398
+ updateBinaryFilesList();
399
+ saveState();
400
+ rerenderTypst();
401
+ }
402
+ });
403
+ item.appendChild(deleteBtn);
404
+
405
+ binaryFilesList.appendChild(item);
406
+ });
407
+ };
408
+
409
+ // Function to save state to store
410
+ const saveState = async () => {
411
+ if (!editor) return;
412
+
413
+ // Update current file content
414
+ fileContents.set(currentFile.filename, editor.value);
415
+
416
+ // Update sourceFiles array with latest content
417
+ sourceFiles = sourceFiles.map(f => ({
418
+ filename: f.filename,
419
+ content: fileContents.get(f.filename) || f.content
420
+ }));
421
+
422
+ await store.typst?.put({
423
+ id,
424
+ code: editor.value,
425
+ sourceFiles,
426
+ binaryFiles,
427
+ currentFile: currentFile.filename
428
+ });
429
+ };
430
+
431
+ // Function to rerender typst
432
+ const rerenderTypst = () => {
433
+ if (editor) {
434
+ // Update sourceFiles with current editor content
435
+ fileContents.set(currentFile.filename, editor.value);
436
+ sourceFiles = sourceFiles.map(f => ({
437
+ filename: f.filename,
438
+ content: fileContents.get(f.filename) || f.content
439
+ }));
440
+
441
+ const mainFile = sourceFiles.find(f => f.filename === "main.typ" || f.filename === "main.typst");
442
+ const mainCode = mainFile ? mainFile.content : "";
443
+ renderTypst(mainCode, preview, loadingIndicator, sourceFiles, binaryFiles, id, previewContainer);
444
+ }
445
+ };
446
+
447
+ // Add source file button
448
+ addSourceFileBtn?.addEventListener("click", () => {
449
+ const filename = prompt(i18n.get("typst-filename-prompt") || "Enter filename (e.g., helper.typ):");
450
+ if (filename) {
451
+ // Validate filename
452
+ if (!filename.endsWith(".typ") && !filename.endsWith(".typst")) {
453
+ alert(i18n.get("typst-filename-error") || "Filename must end with .typ or .typst");
454
+ return;
455
+ }
456
+
457
+ if (sourceFiles.some(f => f.filename === filename)) {
458
+ alert(i18n.get("typst-filename-exists") || "File already exists");
459
+ return;
460
+ }
461
+
462
+ // Add new file
463
+ const newFile = { filename, content: `// ${filename}\n` };
464
+ sourceFiles.push(newFile);
465
+ fileContents.set(filename, newFile.content);
466
+
467
+ // Switch to new file
468
+ if (editor) {
469
+ fileContents.set(currentFile.filename, editor.value);
470
+ }
471
+ currentFile = newFile;
472
+ if (editor) {
473
+ editor.value = newFile.content;
474
+ }
475
+
476
+ updateTabs();
477
+ saveState();
478
+ rerenderTypst();
479
+ }
480
+ });
481
+
482
+ // Add binary file button
483
+ addBinaryFileBtn?.addEventListener("click", (e) => {
484
+ e.preventDefault();
485
+ e.stopPropagation();
486
+
487
+ const input = document.createElement("input");
488
+ input.type = "file";
489
+ input.accept = "image/*,.pdf";
490
+ input.addEventListener("change", async (e) => {
491
+ const file = e.target.files[0];
492
+ if (file) {
493
+ const dest = `/${file.name}`;
494
+
495
+ // Check if file already exists
496
+ if (binaryFiles.some(f => f.dest === dest)) {
497
+ if (!confirm(i18n.get("typst-file-replace") || `Replace existing ${dest}?`)) {
498
+ return;
499
+ }
500
+ binaryFiles = binaryFiles.filter(f => f.dest !== dest);
501
+ }
502
+
503
+ // Read file as data URL
504
+ const reader = new FileReader();
505
+ reader.onload = async (e) => {
506
+ const url = e.target.result;
507
+ binaryFiles.push({ dest, url });
508
+ updateBinaryFilesList();
509
+ saveState();
510
+ rerenderTypst();
511
+ };
512
+ reader.readAsDataURL(file);
513
+ }
514
+ });
515
+ input.click();
516
+ });
517
+
518
+ // Get initial code
519
+ let initialCode = "";
520
+ if (editor) {
521
+ // Edit mode - code is in the editor
522
+ // Wait for code-input to load
523
+ editor.addEventListener("code-input_load", async () => {
524
+ // Check for stored code
525
+ const result = await store.typst?.get(id);
526
+ if (result) {
527
+ editor.value = result.code;
528
+
529
+ // Restore sourceFiles and binaryFiles if available
530
+ if (result.sourceFiles) {
531
+ sourceFiles = result.sourceFiles;
532
+ sourceFiles.forEach(f => fileContents.set(f.filename, f.content));
533
+ }
534
+ if (result.binaryFiles) {
535
+ binaryFiles = result.binaryFiles;
536
+ }
537
+ if (result.currentFile) {
538
+ currentFile = sourceFiles.find(f => f.filename === result.currentFile) || sourceFiles[0];
539
+ editor.value = fileContents.get(currentFile.filename) || "";
540
+ }
541
+ }
542
+ initialCode = editor.value;
543
+
544
+ updateTabs();
545
+ updateBinaryFilesList();
546
+ rerenderTypst();
547
+
548
+ // Listen for input changes
549
+ editor.addEventListener("input", () => {
550
+ saveState();
551
+ rerenderTypst();
552
+ });
553
+ });
554
+ } else if (sourceTextarea) {
555
+ // Preview mode - code is in hidden textarea
556
+ initialCode = sourceTextarea.value;
557
+ loadTypst().then(() => {
558
+ renderTypst(initialCode, preview, loadingIndicator, sourceFiles, binaryFiles, id, previewContainer);
559
+ });
560
+ }
561
+
562
+ // Download PDF button
563
+ downloadBtn?.addEventListener("click", async () => {
564
+ // Get the main file content
565
+ const mainFile = sourceFiles.find(f => f.filename === "main.typ" || f.filename === "main.typst");
566
+ const code = mainFile ? mainFile.content : (editor ? editor.value : initialCode);
567
+ await exportPdf(code, id, sourceFiles, binaryFiles);
568
+ });
569
+
570
+ // Download Project button (ZIP with all files)
571
+ downloadProjectBtn?.addEventListener("click", async () => {
572
+ // Get the main file content
573
+ const mainFile = sourceFiles.find(f => f.filename === "main.typ" || f.filename === "main.typst");
574
+ const code = mainFile ? mainFile.content : (editor ? editor.value : initialCode);
575
+ const encoder = new TextEncoder();
576
+ const zipFiles = {};
577
+
578
+ // Add all source files
579
+ for (const { filename, content } of sourceFiles) {
580
+ const path = filename.startsWith('/') ? filename.substring(1) : filename;
581
+ zipFiles[path] = encoder.encode(content);
582
+ }
583
+
584
+ // Add binary files
585
+ for (const { dest, url } of binaryFiles) {
586
+ try {
587
+ let arrayBuffer;
588
+
589
+ // Check if URL is a data URL (user-uploaded file)
590
+ if (url.startsWith('data:')) {
591
+ const response = await fetch(url);
592
+ arrayBuffer = await response.arrayBuffer();
593
+ } else {
594
+ // External URL
595
+ const response = await fetch(url);
596
+ if (response.ok) {
597
+ arrayBuffer = await response.arrayBuffer();
598
+ } else {
599
+ console.warn(`Failed to load binary file: ${url}`);
600
+ continue;
601
+ }
602
+ }
603
+
604
+ const path = dest.startsWith('/') ? dest.substring(1) : dest;
605
+ zipFiles[path] = new Uint8Array(arrayBuffer);
606
+ } catch (error) {
607
+ console.warn(`Error loading binary file ${url}:`, error);
608
+ }
609
+ }
610
+
611
+ // Create ZIP using UZIP
612
+ const zipData = UZIP.encode(zipFiles);
613
+ const zipBlob = new Blob([zipData], { type: "application/zip" });
614
+ const link = document.createElement("a");
615
+ link.href = URL.createObjectURL(zipBlob);
616
+ link.download = `typst-project-${id}.zip`;
617
+ link.click();
618
+ URL.revokeObjectURL(link.href);
619
+ });
620
+
621
+ // Reset button (edit mode only)
622
+ resetBtn?.addEventListener("click", async () => {
623
+ if (window.confirm(i18n.get("typst-reset-prompt") || "Are you sure you want to reset the code?")) {
624
+ store.typst?.delete(id);
625
+ window.location.reload();
626
+ }
627
+ });
628
+ }
629
+
630
+ return {};
631
+ })();