diff --git a/.changeset/add-typst-directive.md b/.changeset/add-typst-directive.md new file mode 100644 index 00000000..bdae5f41 --- /dev/null +++ b/.changeset/add-typst-directive.md @@ -0,0 +1,5 @@ +--- +"@hyperbook/markdown": minor +--- + +Add Typst directive with interactive editor and improved error handling. The new Typst directive enables users to write and preview Typst documents directly in Hyperbook with support for multiple files, binary assets, and PDF export. Errors display as dismissible overlays preserving the last successful render, with clean error messages parsed from the Rust SourceDiagnostic format. diff --git a/packages/markdown/assets/directive-typst/client.js b/packages/markdown/assets/directive-typst/client.js new file mode 100644 index 00000000..9279f384 --- /dev/null +++ b/packages/markdown/assets/directive-typst/client.js @@ -0,0 +1,631 @@ +hyperbook.typst = (function () { + // Register code-input template for typst syntax highlighting + window.codeInput?.registerTemplate( + "typst-highlighted", + codeInput.templates.prism(window.Prism, [ + new codeInput.plugins.AutoCloseBrackets(), + new codeInput.plugins.Indent(true, 2), + ]), + ); + + const elems = document.getElementsByClassName("directive-typst"); + + // Typst WASM module URLs + const TYPST_COMPILER_URL = "https://cdn.jsdelivr.net/npm/@myriaddreamin/typst-ts-web-compiler/pkg/typst_ts_web_compiler_bg.wasm"; + const TYPST_RENDERER_URL = "https://cdn.jsdelivr.net/npm/@myriaddreamin/typst-ts-renderer/pkg/typst_ts_renderer_bg.wasm"; + + // Load typst all-in-one bundle + let typstLoaded = false; + let typstLoadPromise = null; + + // Rendering queue to ensure only one render at a time + let renderQueue = Promise.resolve(); + + const queueRender = (renderFn) => { + renderQueue = renderQueue.then(renderFn).catch((error) => { + console.error("Queued render error:", error); + }); + return renderQueue; + }; + + const loadTypst = () => { + if (typstLoaded) { + return Promise.resolve(); + } + if (typstLoadPromise) { + return typstLoadPromise; + } + + typstLoadPromise = new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.src = "https://cdn.jsdelivr.net/npm/@myriaddreamin/typst.ts/dist/esm/contrib/all-in-one-lite.bundle.js"; + script.type = "module"; + script.id = "typst-loader"; + script.onload = () => { + // Wait a bit for the module to initialize + const checkTypst = () => { + if (typeof $typst !== "undefined") { + // Initialize the Typst compiler and renderer + $typst.setCompilerInitOptions({ + getModule: () => TYPST_COMPILER_URL, + }); + $typst.setRendererInitOptions({ + getModule: () => TYPST_RENDERER_URL, + }); + typstLoaded = true; + resolve(); + } else { + setTimeout(checkTypst, 50); + } + }; + checkTypst(); + }; + script.onerror = reject; + document.head.appendChild(script); + }); + + return typstLoadPromise; + }; + + // Parse error message from SourceDiagnostic format + const parseTypstError = (errorMessage) => { + try { + // Try to extract message from SourceDiagnostic format + const match = errorMessage.match(/message:\s*"([^"]+)"/); + if (match) { + return match[1]; + } + } catch (e) { + // Fallback to original message + } + return errorMessage; + }; + + // Render typst code to SVG + const renderTypst = async (code, container, loadingIndicator, sourceFiles, binaryFiles, id, previewContainer) => { + // Queue this render to ensure only one compilation runs at a time + return queueRender(async () => { + // Show loading indicator + if (loadingIndicator) { + loadingIndicator.style.display = "flex"; + } + + await loadTypst(); + + try { + // Reset shadow files for this render + $typst.resetShadow(); + + // Add source files + for (const { filename, content } of sourceFiles) { + const path = filename.startsWith('/') ? filename.substring(1) : filename; + await $typst.addSource(`/${path}`, content); + } + + // Add binary files + for (const { dest, url } of binaryFiles) { + try { + let arrayBuffer; + + // Check if URL is a data URL (user-uploaded file) + if (url.startsWith('data:')) { + const response = await fetch(url); + arrayBuffer = await response.arrayBuffer(); + } else { + // External URL + const response = await fetch(url); + if (!response.ok) { + console.warn(`Failed to load binary file: ${url}`); + continue; + } + arrayBuffer = await response.arrayBuffer(); + } + + const path = dest.startsWith('/') ? dest.substring(1) : dest; + $typst.mapShadow(`/${path}`, new Uint8Array(arrayBuffer)); + } catch (error) { + console.warn(`Error loading binary file ${url}:`, error); + } + } + + const svg = await $typst.svg({ mainContent: code }); + + // Remove any existing error overlay from preview-container + if (previewContainer) { + const existingError = previewContainer.querySelector('.typst-error-overlay'); + if (existingError) { + existingError.remove(); + } + } + + container.innerHTML = svg; + + // Scale SVG to fit container + const svgElem = container.firstElementChild; + if (svgElem) { + const width = Number.parseFloat(svgElem.getAttribute("width")); + const height = Number.parseFloat(svgElem.getAttribute("height")); + const containerWidth = container.clientWidth - 20; + if (width > 0 && containerWidth > 0) { + svgElem.setAttribute("width", containerWidth); + svgElem.setAttribute("height", (height * containerWidth) / width); + } + } + } catch (error) { + const errorText = parseTypstError(error || "Error rendering Typst"); + + // Check if we have existing content (previous successful render) + const hasExistingContent = container.querySelector('svg') !== null; + + // Always use error overlay in preview-container if available + if (previewContainer) { + // Remove any existing error overlay + const existingError = previewContainer.querySelector('.typst-error-overlay'); + if (existingError) { + existingError.remove(); + } + + // Clear preview if no existing content + if (!hasExistingContent) { + container.innerHTML = ''; + } + + // Create floating error overlay in preview-container + const errorOverlay = document.createElement('div'); + errorOverlay.className = 'typst-error-overlay'; + errorOverlay.innerHTML = ` +
+
+ ⚠️ Typst Error + +
+
${errorText}
+
+ `; + + // Add close button functionality + const closeBtn = errorOverlay.querySelector('.typst-error-close'); + closeBtn.addEventListener('click', () => { + errorOverlay.remove(); + }); + + previewContainer.appendChild(errorOverlay); + } else { + // Fallback: show error in preview container directly + container.innerHTML = `
${errorText}
`; + } + } finally { + // Hide loading indicator + if (loadingIndicator) { + loadingIndicator.style.display = "none"; + } + } + }); + }; + + // Export to PDF + const exportPdf = async (code, id, sourceFiles, binaryFiles) => { + // Queue this export to ensure only one compilation runs at a time + return queueRender(async () => { + await loadTypst(); + + try { + // Reset shadow files for this export + $typst.resetShadow(); + + // Add source files + for (const { filename, content } of sourceFiles) { + const path = filename.startsWith('/') ? filename.substring(1) : filename; + await $typst.addSource(`/${path}`, content); + } + + // Add binary files + for (const { dest, url } of binaryFiles) { + try { + let arrayBuffer; + + // Check if URL is a data URL (user-uploaded file) + if (url.startsWith('data:')) { + const response = await fetch(url); + arrayBuffer = await response.arrayBuffer(); + } else { + // External URL + const response = await fetch(url); + if (!response.ok) { + continue; + } + arrayBuffer = await response.arrayBuffer(); + } + + const path = dest.startsWith('/') ? dest.substring(1) : dest; + $typst.mapShadow(`/${path}`, new Uint8Array(arrayBuffer)); + } catch (error) { + console.warn(`Error loading binary file ${url}:`, error); + } + } + + const pdfData = await $typst.pdf({ mainContent: code }); + const pdfFile = new Blob([pdfData], { type: "application/pdf" }); + const link = document.createElement("a"); + link.href = URL.createObjectURL(pdfFile); + link.download = `typst-${id}.pdf`; + link.click(); + URL.revokeObjectURL(link.href); + } catch (error) { + console.error("PDF export error:", error); + alert(i18n.get("typst-pdf-error") || "Error exporting PDF"); + } + }); + }; + + for (let elem of elems) { + const id = elem.getAttribute("data-id"); + const previewContainer = elem.querySelector(".preview-container"); + const preview = elem.querySelector(".typst-preview"); + const loadingIndicator = elem.querySelector(".typst-loading"); + const editor = elem.querySelector(".editor.typst"); + const downloadBtn = elem.querySelector(".download-pdf"); + const downloadProjectBtn = elem.querySelector(".download-project"); + const resetBtn = elem.querySelector(".reset"); + const sourceTextarea = elem.querySelector(".typst-source"); + const tabsList = elem.querySelector(".tabs-list"); + const binaryFilesList = elem.querySelector(".binary-files-list"); + const addSourceFileBtn = elem.querySelector(".add-source-file"); + const addBinaryFileBtn = elem.querySelector(".add-binary-file"); + + // Parse source files and binary files from data attributes + const sourceFilesData = elem.getAttribute("data-source-files"); + const binaryFilesData = elem.getAttribute("data-binary-files"); + + let sourceFiles = sourceFilesData + ? JSON.parse(atob(sourceFilesData)) + : []; + let binaryFiles = binaryFilesData + ? JSON.parse(atob(binaryFilesData)) + : []; + + // Track current active file + let currentFile = sourceFiles.find(f => f.filename === "main.typ" || f.filename === "main.typst") || sourceFiles[0]; + + // Store file contents in memory + const fileContents = new Map(); + sourceFiles.forEach(f => fileContents.set(f.filename, f.content)); + + // Function to update tabs UI + const updateTabs = () => { + if (!tabsList) return; + + tabsList.innerHTML = ""; + + // Add source file tabs + sourceFiles.forEach(file => { + const tab = document.createElement("div"); + tab.className = "file-tab"; + if (file.filename === currentFile.filename) { + tab.classList.add("active"); + } + + const tabName = document.createElement("span"); + tabName.className = "tab-name"; + tabName.textContent = file.filename; + tab.appendChild(tabName); + + // Add delete button (except for main file) + if (file.filename !== "main.typ" && file.filename !== "main.typst") { + const deleteBtn = document.createElement("button"); + deleteBtn.className = "tab-delete"; + deleteBtn.textContent = "×"; + deleteBtn.title = i18n.get("typst-delete-file") || "Delete file"; + deleteBtn.addEventListener("click", (e) => { + e.stopPropagation(); + if (confirm(`${i18n.get("typst-delete-confirm") || "Delete"} ${file.filename}?`)) { + sourceFiles = sourceFiles.filter(f => f.filename !== file.filename); + fileContents.delete(file.filename); + + // Switch to main file if we deleted the current file + if (currentFile.filename === file.filename) { + currentFile = sourceFiles[0]; + if (editor) { + editor.value = fileContents.get(currentFile.filename) || ""; + } + } + + updateTabs(); + saveState(); + rerenderTypst(); + } + }); + tab.appendChild(deleteBtn); + } + + tab.addEventListener("click", () => { + if (currentFile.filename !== file.filename) { + // Save current file content + if (editor) { + fileContents.set(currentFile.filename, editor.value); + } + + // Switch to new file + currentFile = file; + if (editor) { + editor.value = fileContents.get(currentFile.filename) || ""; + } + + updateTabs(); + saveState(); + } + }); + + tabsList.appendChild(tab); + }); + }; + + // Function to update binary files list + const updateBinaryFilesList = () => { + if (!binaryFilesList) return; + + binaryFilesList.innerHTML = ""; + + if (binaryFiles.length === 0) { + const emptyMsg = document.createElement("div"); + emptyMsg.className = "binary-files-empty"; + emptyMsg.textContent = i18n.get("typst-no-binary-files") || "No binary files"; + binaryFilesList.appendChild(emptyMsg); + return; + } + + binaryFiles.forEach(file => { + const item = document.createElement("div"); + item.className = "binary-file-item"; + + const icon = document.createElement("span"); + icon.className = "binary-file-icon"; + icon.textContent = "📎"; + item.appendChild(icon); + + const name = document.createElement("span"); + name.className = "binary-file-name"; + name.textContent = file.dest; + item.appendChild(name); + + const deleteBtn = document.createElement("button"); + deleteBtn.className = "binary-file-delete"; + deleteBtn.textContent = "×"; + deleteBtn.title = i18n.get("typst-delete-file") || "Delete file"; + deleteBtn.addEventListener("click", () => { + if (confirm(`${i18n.get("typst-delete-confirm") || "Delete"} ${file.dest}?`)) { + binaryFiles = binaryFiles.filter(f => f.dest !== file.dest); + updateBinaryFilesList(); + saveState(); + rerenderTypst(); + } + }); + item.appendChild(deleteBtn); + + binaryFilesList.appendChild(item); + }); + }; + + // Function to save state to store + const saveState = async () => { + if (!editor) return; + + // Update current file content + fileContents.set(currentFile.filename, editor.value); + + // Update sourceFiles array with latest content + sourceFiles = sourceFiles.map(f => ({ + filename: f.filename, + content: fileContents.get(f.filename) || f.content + })); + + await store.typst?.put({ + id, + code: editor.value, + sourceFiles, + binaryFiles, + currentFile: currentFile.filename + }); + }; + + // Function to rerender typst + const rerenderTypst = () => { + if (editor) { + // Update sourceFiles with current editor content + fileContents.set(currentFile.filename, editor.value); + sourceFiles = sourceFiles.map(f => ({ + filename: f.filename, + content: fileContents.get(f.filename) || f.content + })); + + const mainFile = sourceFiles.find(f => f.filename === "main.typ" || f.filename === "main.typst"); + const mainCode = mainFile ? mainFile.content : ""; + renderTypst(mainCode, preview, loadingIndicator, sourceFiles, binaryFiles, id, previewContainer); + } + }; + + // Add source file button + addSourceFileBtn?.addEventListener("click", () => { + const filename = prompt(i18n.get("typst-filename-prompt") || "Enter filename (e.g., helper.typ):"); + if (filename) { + // Validate filename + if (!filename.endsWith(".typ") && !filename.endsWith(".typst")) { + alert(i18n.get("typst-filename-error") || "Filename must end with .typ or .typst"); + return; + } + + if (sourceFiles.some(f => f.filename === filename)) { + alert(i18n.get("typst-filename-exists") || "File already exists"); + return; + } + + // Add new file + const newFile = { filename, content: `// ${filename}\n` }; + sourceFiles.push(newFile); + fileContents.set(filename, newFile.content); + + // Switch to new file + if (editor) { + fileContents.set(currentFile.filename, editor.value); + } + currentFile = newFile; + if (editor) { + editor.value = newFile.content; + } + + updateTabs(); + saveState(); + rerenderTypst(); + } + }); + + // Add binary file button + addBinaryFileBtn?.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + + const input = document.createElement("input"); + input.type = "file"; + input.accept = "image/*,.pdf"; + input.addEventListener("change", async (e) => { + const file = e.target.files[0]; + if (file) { + const dest = `/${file.name}`; + + // Check if file already exists + if (binaryFiles.some(f => f.dest === dest)) { + if (!confirm(i18n.get("typst-file-replace") || `Replace existing ${dest}?`)) { + return; + } + binaryFiles = binaryFiles.filter(f => f.dest !== dest); + } + + // Read file as data URL + const reader = new FileReader(); + reader.onload = async (e) => { + const url = e.target.result; + binaryFiles.push({ dest, url }); + updateBinaryFilesList(); + saveState(); + rerenderTypst(); + }; + reader.readAsDataURL(file); + } + }); + input.click(); + }); + + // Get initial code + let initialCode = ""; + if (editor) { + // Edit mode - code is in the editor + // Wait for code-input to load + editor.addEventListener("code-input_load", async () => { + // Check for stored code + const result = await store.typst?.get(id); + if (result) { + editor.value = result.code; + + // Restore sourceFiles and binaryFiles if available + if (result.sourceFiles) { + sourceFiles = result.sourceFiles; + sourceFiles.forEach(f => fileContents.set(f.filename, f.content)); + } + if (result.binaryFiles) { + binaryFiles = result.binaryFiles; + } + if (result.currentFile) { + currentFile = sourceFiles.find(f => f.filename === result.currentFile) || sourceFiles[0]; + editor.value = fileContents.get(currentFile.filename) || ""; + } + } + initialCode = editor.value; + + updateTabs(); + updateBinaryFilesList(); + rerenderTypst(); + + // Listen for input changes + editor.addEventListener("input", () => { + saveState(); + rerenderTypst(); + }); + }); + } else if (sourceTextarea) { + // Preview mode - code is in hidden textarea + initialCode = sourceTextarea.value; + loadTypst().then(() => { + renderTypst(initialCode, preview, loadingIndicator, sourceFiles, binaryFiles, id, previewContainer); + }); + } + + // Download PDF button + downloadBtn?.addEventListener("click", async () => { + // Get the main file content + const mainFile = sourceFiles.find(f => f.filename === "main.typ" || f.filename === "main.typst"); + const code = mainFile ? mainFile.content : (editor ? editor.value : initialCode); + await exportPdf(code, id, sourceFiles, binaryFiles); + }); + + // Download Project button (ZIP with all files) + downloadProjectBtn?.addEventListener("click", async () => { + // Get the main file content + const mainFile = sourceFiles.find(f => f.filename === "main.typ" || f.filename === "main.typst"); + const code = mainFile ? mainFile.content : (editor ? editor.value : initialCode); + const encoder = new TextEncoder(); + const zipFiles = {}; + + // Add all source files + for (const { filename, content } of sourceFiles) { + const path = filename.startsWith('/') ? filename.substring(1) : filename; + zipFiles[path] = encoder.encode(content); + } + + // Add binary files + for (const { dest, url } of binaryFiles) { + try { + let arrayBuffer; + + // Check if URL is a data URL (user-uploaded file) + if (url.startsWith('data:')) { + const response = await fetch(url); + arrayBuffer = await response.arrayBuffer(); + } else { + // External URL + const response = await fetch(url); + if (response.ok) { + arrayBuffer = await response.arrayBuffer(); + } else { + console.warn(`Failed to load binary file: ${url}`); + continue; + } + } + + const path = dest.startsWith('/') ? dest.substring(1) : dest; + zipFiles[path] = new Uint8Array(arrayBuffer); + } catch (error) { + console.warn(`Error loading binary file ${url}:`, error); + } + } + + // Create ZIP using UZIP + const zipData = UZIP.encode(zipFiles); + const zipBlob = new Blob([zipData], { type: "application/zip" }); + const link = document.createElement("a"); + link.href = URL.createObjectURL(zipBlob); + link.download = `typst-project-${id}.zip`; + link.click(); + URL.revokeObjectURL(link.href); + }); + + // Reset button (edit mode only) + resetBtn?.addEventListener("click", async () => { + if (window.confirm(i18n.get("typst-reset-prompt") || "Are you sure you want to reset the code?")) { + store.typst?.delete(id); + window.location.reload(); + } + }); + } + + return {}; +})(); diff --git a/packages/markdown/assets/directive-typst/style.css b/packages/markdown/assets/directive-typst/style.css new file mode 100644 index 00000000..a1b337c1 --- /dev/null +++ b/packages/markdown/assets/directive-typst/style.css @@ -0,0 +1,466 @@ +.directive-typst { + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + margin-bottom: 16px; + overflow: hidden; + gap: 8px; + height: calc(100dvh - 128px); +} + +code-input { + margin: 0; +} + +.directive-typst .preview-container { + width: 100%; + border: 1px solid var(--color-spacer); + border-radius: 8px; + overflow: auto; + background-color: white; + position: relative; +} + +.directive-typst .typst-preview { + padding: 16px; + min-height: 100px; + display: flex; + justify-content: center; + align-items: flex-start; +} + +.directive-typst.preview-only { + gap: 0px; + align-items: normal; + height: auto; + + .download-pdf { + border-right: none; + } + + .preview-container { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } +} + +.directive-typst .typst-preview svg { + max-width: 100%; + height: auto; +} + +.directive-typst .typst-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 32px; + gap: 12px; + color: var(--color-text); +} + +.directive-typst .typst-spinner { + width: 32px; + height: 32px; + border: 3px solid var(--color-spacer); + border-top-color: var(--color-brand, #3b82f6); + border-radius: 50%; + animation: typst-spin 1s linear infinite; +} + +@keyframes typst-spin { + to { + transform: rotate(360deg); + } +} + +.directive-typst .typst-error { + color: #dc2626; + padding: 16px; + background-color: #fef2f2; + border-radius: 4px; + font-family: monospace; + white-space: pre-wrap; + word-break: break-word; +} + +.directive-typst .typst-error-overlay { + position: absolute; + bottom: 16px; + left: 16px; + right: 16px; + z-index: 100; + animation: typst-error-slide-in 0.3s ease-out; +} + +@keyframes typst-error-slide-in { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.directive-typst .typst-error-content { + background-color: #fef2f2; + border: 2px solid #dc2626; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + overflow: hidden; +} + +.directive-typst .typst-error-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background-color: #dc2626; + color: white; +} + +.directive-typst .typst-error-title { + font-weight: 600; + font-size: 14px; + display: flex; + align-items: center; + gap: 8px; +} + +.directive-typst .typst-error-close { + background: none; + border: none; + color: white; + cursor: pointer; + font-size: 24px; + line-height: 1; + padding: 0; + margin: 0; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: background-color 0.2s; + flex: none; +} + +.directive-typst .typst-error-close:hover { + background-color: rgba(255, 255, 255, 0.2); +} + +.directive-typst .typst-error-message { + padding: 16px; + color: #dc2626; + font-family: monospace; + font-size: 13px; + white-space: pre-wrap; + word-break: break-word; + line-height: 1.5; +} + +.directive-typst .editor-container { + width: 100%; + display: flex; + flex-direction: column; + height: 400px; +} + +.directive-typst .file-tabs { + display: flex; + align-items: center; + gap: 4px; + background-color: var(--color--background); + border: 1px solid var(--color-spacer); + border-bottom: none; + border-radius: 8px 8px 0 0; + padding: 4px; + overflow-x: auto; +} + +.directive-typst .tabs-list { + display: flex; + gap: 4px; + flex: 1; + overflow-x: auto; + padding: 2px; +} + +.directive-typst .file-tab { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background-color: var(--color--background); + border: 1px solid var(--color-spacer); + border-radius: 4px; + cursor: pointer; + white-space: nowrap; + font-size: 14px; + transition: all 0.2s; +} + +.directive-typst .file-tab:hover { + background-color: var(--color-spacer); +} + +.directive-typst .file-tab.active { + background-color: var(--color-brand, #3b82f6); + color: var(--color-brand-text); + border-color: var(--color-brand, #3b82f6); +} + +.directive-typst .tab-name { + flex: 1; +} + +.directive-typst .tab-delete { + background: none; + border: none; + color: inherit; + cursor: pointer; + font-size: 18px; + line-height: 1; + padding: 0; + margin: 0; + width: 18px; + height: 18px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 2px; + flex-shrink: 0; +} + +.directive-typst .tab-delete:hover { + background-color: rgba(0, 0, 0, 0.1); +} + +.directive-typst .file-tab.active .tab-delete:hover { + background-color: rgba(255, 255, 255, 0.2); +} + +.directive-typst .add-source-file { + padding: 6px 12px; + font-size: 16px; + flex: none; + font-weight: bold; + white-space: nowrap; + border: 1px solid var(--color-spacer); + border-radius: 4px; + background-color: var(--color--background); + color: var(--color-text); + cursor: pointer; + min-width: 32px; +} + +.directive-typst .add-source-file:hover { + background-color: var(--color-spacer); +} + +.directive-typst .binary-files-section { + border: 1px solid var(--color-spacer); + border-top: none; + border-bottom: none; + background-color: var(--color--background); + margin-bottom: 0; +} + +.directive-typst .binary-files-section summary { + padding: 8px 12px; + cursor: pointer; + font-weight: 500; + display: flex; + align-items: center; + user-select: none; + background-color: var(--color--background); + list-style: none; +} + +.directive-typst .binary-files-section summary::-webkit-details-marker { + display: none; +} + +.directive-typst .binary-files-section summary .summary-text { + display: flex; + align-items: center; + gap: 8px; +} + +.directive-typst .binary-files-section summary .summary-indicator { + transition: transform 0.2s; + display: inline-block; + font-size: 12px; +} + +.directive-typst .binary-files-section[open] summary .summary-indicator { + transform: rotate(90deg); +} + +.directive-typst .binary-files-section summary:hover { + background-color: var(--color-spacer); +} + +.directive-typst .add-binary-file { + padding: 6px 12px; + font-size: 14px; + border: 1px solid var(--color-spacer); + border-radius: 4px; + background-color: var(--color--background); + color: var(--color-text); + cursor: pointer; + width: 100%; +} + +.directive-typst .add-binary-file:hover { + background-color: var(--color-spacer); +} + +.directive-typst .binary-files-actions { + padding: 8px; +} + +.directive-typst .binary-files-list { + padding: 8px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.directive-typst .binary-files-empty { + padding: 12px; + text-align: center; + color: var(--color-text); + opacity: 0.6; + font-style: italic; + font-size: 14px; +} + +.directive-typst .binary-file-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + font-size: 14px; +} + +.directive-typst .binary-file-icon { + font-size: 16px; + flex-shrink: 0; +} + +.directive-typst .binary-file-name { + flex: 1; + word-break: break-all; +} + +.directive-typst .binary-file-delete { + background: none; + border: none; + color: var(--color-text); + cursor: pointer; + font-size: 18px; + line-height: 1; + padding: 0; + margin: 0; + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 2px; + flex: none; +} + +.directive-typst .binary-file-delete:hover { + background-color: rgba(220, 38, 38, 0.1); + color: #dc2626; +} + +.directive-typst .editor { + width: 100%; + border: 1px solid var(--color-spacer); + flex: 1; +} + +.directive-typst .editor:not(.active) { + display: none; +} + +.directive-typst .buttons { + display: flex; + border: 1px solid var(--color-spacer); + border-radius: 8px; + border-bottom: none; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.directive-typst .buttons.bottom { + border: 1px solid var(--color-spacer); + border-radius: 8px; + border-top: none; + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +.directive-typst .buttons-container { + display: flex; + justify-content: center; + width: 100%; +} + +.directive-typst button { + flex: 1; + padding: 8px 16px; + border: none; + border-right: 1px solid var(--color-spacer); + background-color: var(--color--background); + color: var(--color-text); + cursor: pointer; +} + +.directive-typst button:not(.active) { + opacity: 0.6; +} + +.directive-typst .buttons:last-child { + border-right: none; +} + +.directive-typst button:hover { + background-color: var(--color-spacer); +} + +.directive-typst .buttons-container button { + flex: none; + border: 1px solid var(--color-spacer); + border-radius: 8px; + padding: 8px 24px; +} + +.directive-typst .hidden { + display: none !important; +} + +@media screen and (min-width: 1024px) { + .directive-typst:not(.preview-only) { + flex-direction: row; + height: calc(100dvh - 128px); + + .preview-container { + flex: 1; + height: 100% !important; + } + + .editor-container { + flex: 1; + height: 100%; + overflow: hidden; + } + } +} diff --git a/packages/markdown/assets/prism/prism-typst.js b/packages/markdown/assets/prism/prism-typst.js new file mode 100644 index 00000000..aef645ae --- /dev/null +++ b/packages/markdown/assets/prism/prism-typst.js @@ -0,0 +1,182 @@ + +const comment = [ + { + pattern: /\/\*[\s\S]*?\*\//, + greedy: true, + }, + { + pattern: /\/\/.*/, + greedy: true, + }, +]; + +const raw = [ + { + pattern: /^```[\s\S]*?^```$/m, + greedy: true, + }, + { + pattern: /`[\s\S]*?`/, + greedy: true, + } +]; + +const label = { + pattern: /<[\w\-.:]*>/, // starting with ([^\\]) not necessary anymore when matching "escaped" before +}; + +const general_escapes = [ + /\\u{[\da-fA-F]+?\}/, + /\\\S/, + /\\\s/, +] + +Prism.languages["typst-math"] = { + comment: comment, + raw: raw, + escaped: general_escapes, + operator: [ + /<=>|<==>|<-->|\[\||\|\]|\|\||:=|::=|\.\.\.|=:|!=|>>>|>=|<<<|<==|<=|\|->|=>|\|=>|==>|-->|~~>|~>|>->|->>|<--|<~~|<~|<-<|<<-|<->|->|<-|<<|>>/, + /[_\\\^\+\-\*\/&']/, + ], + string: [ + /"(?:\\.|[^\\"])*"/, + /\$/ + ], + function: /\b[a-zA-Z][\w-]*(?=\[|\()/, + symbol: [ + /[a-zA-Z][\w]+/, + /#[a-zA-Z][\w]*/, + ] +} + +const math = [ + { + pattern: /\$(?:\\.|[^\\$])*?\$/, + inside: Prism.languages["typst-math"], + // greedy: true, + }, +] + + +Prism.languages["typst-code"] = { + typst: { + // pattern: /\[[\s\S]*\]/, + pattern: /\[(?:[^\]\[]|\[(?:[^\]\[]|\[(?:[^\]\[]|\[[^\]\[]*\])*\])*\])*\]/, + inside: Prism.languages["typst"], + greedy: true + }, + comment: comment, + math: math, + function: [ + { + pattern: /#?[a-zA-Z][\w\-]*?(?=\[|\()/, + greedy: true + }, + /(?<=show [\w.]*)[a-zA-Z][\w-]*\s*:/, + /(?<=show\s*:\s*)[a-zA-Z][\w-]*/, + ], + keyword: /(?:#|\b)(?:none|auto|let|return|if|else|set|show|context|for|while|not|in|continue|break|include|import|as)\b/, + boolean: /(?:#|\b)(?:true|false)\b/, + string: { + pattern: /"(?:\\.|[^\\"])*"/, + greedy: true, + }, + label: label, + number: [ + /0b[01]+/, + /0o[0-7]+/, + /0x[\da-fA-F]+/, + /(?|<=|\.\.|<|>/, + punctuation: /[\{\}\(\):\,\;\.]/ +}; + + +Prism.languages.typst = { + comment: comment, + raw: raw, + math: math, + "code-mode": [ + { + // enter code mode via #my-func() or #() + // pattern: /(?<=#[a-zA-Z][\w-.]*\()(?:[^)(]|\((?:[^)(]|\((?:[^)(]|\([^)(]*\))*\))*\))*\)/, + // pattern: /(?<=#([a-zA-Z][\w-.]*)?\()(?:[^)(]|\((?:[^)(]|\((?:[^)(]|\((?:[^)(]|\((?:[^)(]|\([^)(]*\))*\))*\))*\))*\))*\)/, + // between # and ( either + // - nothing: #( + // - Declaration: #let ... ( + // - Function call: #my-func2( + // # + pattern: /(?> 8) & 255; + }, + readUint: function (buff, p) { + return ( + buff[p + 3] * (256 * 256 * 256) + + ((buff[p + 2] << 16) | (buff[p + 1] << 8) | buff[p]) + ); + }, + writeUint: function (buff, p, n) { + buff[p] = n & 255; + buff[p + 1] = (n >> 8) & 255; + buff[p + 2] = (n >> 16) & 255; + buff[p + 3] = (n >> 24) & 255; + }, + readASCII: function (buff, p, l) { + var s = ""; + for (var i = 0; i < l; i++) s += String.fromCharCode(buff[p + i]); + return s; + }, + writeASCII: function (data, p, s) { + for (var i = 0; i < s.length; i++) data[p + i] = s.charCodeAt(i); + }, + pad: function (n) { + return n.length < 2 ? "0" + n : n; + }, + readIBM: function (buff, p, l) { + var codes = [ + 0xc7, 0xfc, 0xe9, 0xe2, 0xe4, 0xe0, 0xe5, 0xe7, 0xea, 0xeb, 0xe8, 0xef, + 0xee, 0xec, 0xc4, 0xc5, 0xc9, 0xe6, 0xc6, 0xf4, 0xf6, 0xf2, 0xfb, 0xf9, + 0xff, 0xd6, 0xdc, 0xa2, 0xa3, 0xa5, 0xa7, 0x192, 0xe1, 0xed, 0xf3, 0xfa, + 0xf1, 0xd1, 0xaa, 0xba, 0xbf, 0x2310, 0xac, 0xbd, 0xbc, 0xa1, 0xab, + 0xbb, + ]; + var out = ""; + for (var i = 0; i < l; i++) { + var cc = buff[p + i]; + if (cc < 0x80) cc = cc; + else if (cc < 0xb0) cc = codes[cc - 0x80]; + else return null; + out += String.fromCharCode(cc); + } + return out; + }, + readUTF8: function (buff, p, l) { + var s = "", + ns; + for (var i = 0; i < l; i++) s += "%" + B.pad(buff[p + i].toString(16)); + try { + ns = decodeURIComponent(s); + } catch (e) { + return B.readASCII(buff, p, l); + } + return ns; + }, + writeUTF8: function (buff, p, str) { + var strl = str.length, + i = 0; + for (var ci = 0; ci < strl; ci++) { + var code = str.charCodeAt(ci); + if ((code & (0xffffffff - (1 << 7) + 1)) == 0) { + buff[p + i] = code; + i++; + } else if ((code & (0xffffffff - (1 << 11) + 1)) == 0) { + buff[p + i] = 192 | (code >> 6); + buff[p + i + 1] = 128 | ((code >> 0) & 63); + i += 2; + } else if ((code & (0xffffffff - (1 << 16) + 1)) == 0) { + buff[p + i] = 224 | (code >> 12); + buff[p + i + 1] = 128 | ((code >> 6) & 63); + buff[p + i + 2] = 128 | ((code >> 0) & 63); + i += 3; + } else if ((code & (0xffffffff - (1 << 21) + 1)) == 0) { + buff[p + i] = 240 | (code >> 18); + buff[p + i + 1] = 128 | ((code >> 12) & 63); + buff[p + i + 2] = 128 | ((code >> 6) & 63); + buff[p + i + 3] = 128 | ((code >> 0) & 63); + i += 4; + } else throw "e"; + } + return i; + }, + sizeUTF8: function (str) { + var strl = str.length, + i = 0; + for (var ci = 0; ci < strl; ci++) { + var code = str.charCodeAt(ci); + if ((code & (0xffffffff - (1 << 7) + 1)) == 0) { + i++; + } else if ((code & (0xffffffff - (1 << 11) + 1)) == 0) { + i += 2; + } else if ((code & (0xffffffff - (1 << 16) + 1)) == 0) { + i += 3; + } else if ((code & (0xffffffff - (1 << 21) + 1)) == 0) { + i += 4; + } else throw "e"; + } + return i; + }, + }; + + var crc = { + table: (function () { + var tab = new Uint32Array(256); + for (var n = 0; n < 256; n++) { + var c = n; + for (var k = 0; k < 8; k++) { + if (c & 1) c = 0xedb88320 ^ (c >>> 1); + else c = c >>> 1; + } + tab[n] = c; + } + return tab; + })(), + update: function (c, buf, off, len) { + for (var i = 0; i < len; i++) + c = crc.table[(c ^ buf[off + i]) & 0xff] ^ (c >>> 8); + return c; + }, + crc: function (b, o, l) { + return crc.update(0xffffffff, b, o, l) ^ 0xffffffff; + }, + }; + + function adler(data, o, len) { + var a = 1, + b = 0; + var off = o, + end = o + len; + while (off < end) { + var eend = Math.min(off + 5552, end); + while (off < eend) { + a += data[off++]; + b += a; + } + a = a % 65521; + b = b % 65521; + } + return (b << 16) | a; + } + + function parseTar(data) { + var off = 0, + out = {}; + while (off + 1024 < data.length) { + var no = off; + while (data[no] != 0) no++; + var nam = B.readASCII(data, off, no - off); + off += 100; + off += 24; + var sz = parseInt(B.readASCII(data, off, 12), 8); + off += 12; + var tm = parseInt(B.readASCII(data, off, 12), 8); + off += 12; + // console.log(nam, sz, tm); + off += 8 + 1 + 100; + off += 6 + 2 + 32 + 32 + 8 + 8 + 155 + 12; + + out[nam] = data.slice(off, off + sz); + off += sz; + + var ex = off & 0x1ff; + if (ex != 0) off += 512 - ex; + } + return out; + } + /* + var parse7z = function() { + var rUs = B.readUshort, rUi = B.readUint; + var data, off; + + function NUMBER() { + var v = data[off++]; + if ((v&128)==0) return v; + else if((v& 64)==0) return ((((((v&63)<<8)+data[off++]) ) ) ); + else if((v& 32)==0) return ((((((v&31)<<8)+data[off++])<<8)+data[off++]) ); + else if((v& 16)==0) return ((((((v&31)<<8)+data[off++])<<8)+data[off++])<<8)+data[off++]; + else throw v.toString(2); + } + + function readProp() { + var ID = data[off++]; console.log(ID.toString(16)); + + if (ID==0x17) readProp(off); // EncodedHeader + else if(ID==0x06) { // PackInfo + var pos = NUMBER(); + var cnt = NUMBER(); if(cnt!=1) throw "e"; + + console.log(pos, cnt); + + console.log(data.slice(off)); + + var siz = readProp(); console.log(siz); + + console.log(data.slice(off)); + + var crc = readProp(); console.log(crc); + + + console.log(data.slice(off)); + } + else if(ID==0x09) { // Size + return NUMBER(); + } + else throw ID; + + } + + function pars(_d) { + data = _d; off=8; + + var sign = [55, 122, 188, 175, 39, 28, 0, 4]; + for(var i=0; i<8; i++) if(data[i]!=sign[i]) throw "e"; + + var crc = rUi(data,off); off+=4; + + var nho = rUi(data,off); off+=8; + var nhs = rUi(data,off); off+=8; + var nhc = rUi(data,off); off+=4; + console.log(nho,nhs,nhc); + + //var hdr=data[off++]; + + //console.log(hdr); + + off = 32+nho; + readProp(); + + + + + + console.log(data.slice(off)); throw "e"; + } + + return pars; + }(); + */ + + function parse(buf, onlyNames) { + // ArrayBuffer + var rUs = B.readUshort, + rUi = B.readUint, + o = 0, + out = {}; + var data = new Uint8Array(buf); + if (data.length > 257 + 6 && B.readASCII(data, 257, 6) == "ustar ") + return parseTar(data); + //if(B.readASCII(data,0,2)=="7z") return parse7z(data); + + var eocd = data.length - 4; + + while (rUi(data, eocd) != 0x06054b50) eocd--; + + var o = eocd; + o += 4; // sign = 0x06054b50 + o += 4; // disks = 0; + var cnu = rUs(data, o); + o += 2; + var cnt = rUs(data, o); + o += 2; + + var csize = rUi(data, o); + o += 4; + var coffs = rUi(data, o); + o += 4; + + o = coffs; + for (var i = 0; i < cnu; i++) { + var sign = rUi(data, o); + o += 4; + o += 4; // versions; + o += 4; // flag + compr + var time = _readTime(data, o); + o += 4; // time + + var crc32 = rUi(data, o); + o += 4; + var csize = rUi(data, o); + o += 4; + var usize = rUi(data, o); + o += 4; + + var nl = rUs(data, o), + el = rUs(data, o + 2), + cl = rUs(data, o + 4); + o += 6; // name, extra, comment + o += 8; // disk, attribs + var roff = rUi(data, o); + o += 4; + + o += nl; + + var lo = 0; + while (lo < el) { + var id = rUs(data, o + lo); + lo += 2; + var sz = rUs(data, o + lo); + lo += 2; + if (id == 1) { + // Zip64 + if (usize == 0xffffffff) { + usize = rUi(data, o + lo); + lo += 8; + } + if (csize == 0xffffffff) { + csize = rUi(data, o + lo); + lo += 8; + } + if (roff == 0xffffffff) { + roff = rUi(data, o + lo); + lo += 8; + } + } else lo += sz; + } + + o += el + cl; + + _readLocal(data, roff, out, csize, usize, onlyNames); + } + //console.log(out); + return out; + } + + function _readTime(data, o) { + var time = B.readUshort(data, o), + date = B.readUshort(data, o + 2); + var year = 1980 + (date >>> 9); + var mont = (date >>> 5) & 15; + var day = date & 31; + //console.log(year,mont,day); + + var hour = time >>> 11; + var minu = (time >>> 5) & 63; + var seco = 2 * (time & 31); + + var stamp = new Date(year, mont, day, hour, minu, seco).getTime(); + + //console.log(date,time); + return stamp; + } + function _writeTime(data, o, stamp) { + var dt = new Date(stamp); + var date = + ((dt.getFullYear() - 1980) << 9) | + ((dt.getMonth() + 1) << 5) | + dt.getDate(); + var time = + (dt.getHours() << 11) | (dt.getMinutes() << 5) | (dt.getSeconds() >>> 1); + B.writeUshort(data, o, time); + B.writeUshort(data, o + 2, date); + } + + function _readLocal(data, o, out, csize, usize, onlyNames) { + var rUs = B.readUshort, + rUi = B.readUint; + var sign = rUi(data, o); + o += 4; + var ver = rUs(data, o); + o += 2; + var gpflg = rUs(data, o); + o += 2; + //if((gpflg&8)!=0) throw "unknown sizes"; + var cmpr = rUs(data, o); + o += 2; + + var time = _readTime(data, o); + o += 4; + + var crc32 = rUi(data, o); + o += 4; + //var csize = rUi(data, o); o+=4; + //var usize = rUi(data, o); o+=4; + o += 8; + + var nlen = rUs(data, o); + o += 2; + var elen = rUs(data, o); + o += 2; + + var name = + (gpflg & 2048) == 0 + ? B.readIBM(data, o, nlen) + : B.readUTF8(data, o, nlen); + if (name == null) name = B.readUTF8(data, o, nlen); + o += nlen; //console.log(name); + o += elen; + + //console.log(sign.toString(16), ver, gpflg, cmpr, crc32.toString(16), "csize, usize", csize, usize, nlen, elen, name, o); + if (onlyNames) { + out[name] = { size: usize, csize: csize }; + return; + } + var file = new Uint8Array(data.buffer, o); + if (gpflg & 1) { + out[name] = new Uint8Array(0); + alert("ZIPs with a password are not supported.", 3000); + } else if (cmpr == 0) + out[name] = new Uint8Array(file.buffer.slice(o, o + csize)); + else if (cmpr == 8) { + var buf = new Uint8Array(usize); + inflateRaw(file, buf); + /*var nbuf = pako["inflateRaw"](file); + if(usize>8514000) { + //console.log(PUtils.readASCII(buf , 8514500, 500)); + //console.log(PUtils.readASCII(nbuf, 8514500, 500)); + } + for(var i=0; i>> 4; + //console.log(CM, CINFO,CMF,FLG); + return inflateRaw( + new Uint8Array(file.buffer, file.byteOffset + 2, file.length - 6), + buf, + ); + } + function deflate(data, opts /*, buf, off*/) { + if (opts == null) opts = { level: 6 }; + var off = 0, + buf = new Uint8Array(50 + Math.floor(data.length * 1.1)); + buf[off] = 120; + buf[off + 1] = 156; + off += 2; + off = UZIP["F"]["deflateRaw"](data, buf, off, opts["level"]); + var crc = adler(data, 0, data.length); + buf[off + 0] = (crc >>> 24) & 255; + buf[off + 1] = (crc >>> 16) & 255; + buf[off + 2] = (crc >>> 8) & 255; + buf[off + 3] = (crc >>> 0) & 255; + return new Uint8Array(buf.buffer, 0, off + 4); + } + function deflateRaw(data, opts) { + if (opts == null) opts = { level: 6 }; + var buf = new Uint8Array(50 + Math.floor(data.length * 1.1)); + var off = UZIP["F"]["deflateRaw"](data, buf, off, opts["level"]); + return new Uint8Array(buf.buffer, 0, off); + } + + function encode(obj, noCmpr) { + if (noCmpr == null) noCmpr = false; + var tot = 0, + wUi = B.writeUint, + wUs = B.writeUshort; + var zpd = {}; + for (var p in obj) { + var cpr = !_noNeed(p) && !noCmpr, + buf = obj[p], + cr = crc.crc(buf, 0, buf.length); + zpd[p] = { + cpr: cpr, + usize: buf.length, + crc: cr, + file: cpr ? deflateRaw(buf) : buf, + }; + } + + for (var p in zpd) tot += zpd[p].file.length + 30 + 46 + 2 * B.sizeUTF8(p); + tot += 22; + + var data = new Uint8Array(tot), + o = 0; + var fof = []; + + for (var p in zpd) { + var file = zpd[p]; + fof.push(o); + o = _writeHeader(data, o, p, file, 0); + } + var i = 0, + ioff = o; + for (var p in zpd) { + var file = zpd[p]; + fof.push(o); + o = _writeHeader(data, o, p, file, 1, fof[i++]); + } + var csize = o - ioff; + + wUi(data, o, 0x06054b50); + o += 4; + o += 4; // disks + wUs(data, o, i); + o += 2; + wUs(data, o, i); + o += 2; // number of c d records + wUi(data, o, csize); + o += 4; + wUi(data, o, ioff); + o += 4; + o += 2; + return data.buffer; + } + // no need to compress .PNG, .ZIP, .JPEG .... + function _noNeed(fn) { + var ext = fn.split(".").pop().toLowerCase(); + return "png,jpg,jpeg,zip".indexOf(ext) != -1; + } + + function _writeHeader(data, o, p, obj, t, roff) { + // it is a task of a user to provide valid file names + //var bad = "#%&{}\<>*?$'\":@+`|="; + //for(var i=0; i> 1; + var cl = tree[i + 1], + val = (lit << 4) | cl; // : (0x8000 | (U.of0[lit-257]<<7) | (U.exb[lit-257]<<4) | cl); + var rest = MAX_BITS - cl, + i0 = tree[i] << rest, + i1 = i0 + (1 << rest); + //tree[i]=r15[i0]>>>(15-MAX_BITS); + while (i0 != i1) { + var p0 = r15[i0] >>> (15 - MAX_BITS); + map[p0] = val; + i0++; + } + } + } + function revCodes(tree, MAX_BITS) { + var r15 = U.rev15, + imb = 15 - MAX_BITS; + for (var i = 0; i < tree.length; i += 2) { + var i0 = tree[i] << (MAX_BITS - tree[i + 1]); + tree[i] = r15[i0] >>> imb; + } + } + + // used only in deflate + function _putsE(dt, pos, val) { + val = val << (pos & 7); + var o = pos >>> 3; + dt[o] |= val; + dt[o + 1] |= val >>> 8; + } + function _putsF(dt, pos, val) { + val = val << (pos & 7); + var o = pos >>> 3; + dt[o] |= val; + dt[o + 1] |= val >>> 8; + dt[o + 2] |= val >>> 16; + } + + function _bitsE(dt, pos, length) { + return ( + ((dt[pos >>> 3] | (dt[(pos >>> 3) + 1] << 8)) >>> (pos & 7)) & + ((1 << length) - 1) + ); + } + function _bitsF(dt, pos, length) { + return ( + ((dt[pos >>> 3] | + (dt[(pos >>> 3) + 1] << 8) | + (dt[(pos >>> 3) + 2] << 16)) >>> + (pos & 7)) & + ((1 << length) - 1) + ); + } + + function _get17(dt, pos) { + // return at least 17 meaningful bytes + return ( + (dt[pos >>> 3] | + (dt[(pos >>> 3) + 1] << 8) | + (dt[(pos >>> 3) + 2] << 16)) >>> + (pos & 7) + ); + } + function _get25(dt, pos) { + // return at least 17 meaningful bytes + return ( + (dt[pos >>> 3] | + (dt[(pos >>> 3) + 1] << 8) | + (dt[(pos >>> 3) + 2] << 16) | + (dt[(pos >>> 3) + 3] << 24)) >>> + (pos & 7) + ); + } + + (function () { + var len = 1 << 15; + for (var i = 0; i < len; i++) { + var x = i; + x = ((x & 0xaaaaaaaa) >>> 1) | ((x & 0x55555555) << 1); + x = ((x & 0xcccccccc) >>> 2) | ((x & 0x33333333) << 2); + x = ((x & 0xf0f0f0f0) >>> 4) | ((x & 0x0f0f0f0f) << 4); + x = ((x & 0xff00ff00) >>> 8) | ((x & 0x00ff00ff) << 8); + U.rev15[i] = ((x >>> 16) | (x << 16)) >>> 17; + } + + function pushV(tgt, n, sv) { + while (n-- != 0) tgt.push(0, sv); + } + + for (var i = 0; i < 32; i++) { + U.ldef[i] = (U.of0[i] << 3) | U.exb[i]; + U.ddef[i] = (U.df0[i] << 4) | U.dxb[i]; + } + + pushV(U.fltree, 144, 8); + pushV(U.fltree, 255 - 143, 9); + pushV(U.fltree, 279 - 255, 7); + pushV(U.fltree, 287 - 279, 8); + /* + var i = 0; + for(; i<=143; i++) U.fltree.push(0,8); + for(; i<=255; i++) U.fltree.push(0,9); + for(; i<=279; i++) U.fltree.push(0,7); + for(; i<=287; i++) U.fltree.push(0,8); + */ + makeCodes(U.fltree, 9); + codes2map(U.fltree, 9, U.flmap); + revCodes(U.fltree, 9); + + pushV(U.fdtree, 32, 5); + //for(i=0;i<32; i++) U.fdtree.push(0,5); + makeCodes(U.fdtree, 5); + codes2map(U.fdtree, 5, U.fdmap); + revCodes(U.fdtree, 5); + + pushV(U.itree, 19, 0); + pushV(U.ltree, 286, 0); + pushV(U.dtree, 30, 0); + pushV(U.ttree, 320, 0); + /* + for(var i=0; i< 19; i++) U.itree.push(0,0); + for(var i=0; i<286; i++) U.ltree.push(0,0); + for(var i=0; i< 30; i++) U.dtree.push(0,0); + for(var i=0; i<320; i++) U.ttree.push(0,0); + */ + })(); + + function deflateRaw(data, out, opos, lvl) { + var opts = [ + /* + ush good_length; /* reduce lazy search above this match length + ush max_lazy; /* do not perform lazy search above this match length + ush nice_length; /* quit search above this match length + */ + /* good lazy nice chain */ + /* 0 */ [0, 0, 0, 0, 0] /* store only */, + /* 1 */ [4, 4, 8, 4, 0] /* max speed, no lazy matches */, + /* 2 */ [4, 5, 16, 8, 0], + /* 3 */ [4, 6, 16, 16, 0], + + /* 4 */ [4, 10, 16, 32, 0] /* lazy matches */, + /* 5 */ [8, 16, 32, 32, 0], + /* 6 */ [8, 16, 128, 128, 0], + /* 7 */ [8, 32, 128, 256, 0], + /* 8 */ [32, 128, 258, 1024, 1], + /* 9 */ [32, 258, 258, 4096, 1], + ]; /* max compression */ + + var opt = opts[lvl]; + + //var U = UZIP.F.U, goodIndex = UZIP.F._goodIndex, hash = UZIP.F._hash, putsE = UZIP.F._putsE; + var i = 0, + pos = opos << 3, + cvrd = 0, + dlen = data.length; + + if (lvl == 0) { + while (i < dlen) { + var len = Math.min(0xffff, dlen - i); + _putsE(out, pos, i + len == dlen ? 1 : 0); + pos = _copyExact(data, i, len, out, pos + 8); + i += len; + } + return pos >>> 3; + } + + var lits = U.lits, + strt = U.strt, + prev = U.prev, + li = 0, + lc = 0, + bs = 0, + ebits = 0, + c = 0, + nc = 0; // last_item, literal_count, block_start + if (dlen > 2) { + nc = _hash(data, 0); + strt[nc] = 0; + } + var nmch = 0, + nmci = 0; + + for (i = 0; i < dlen; i++) { + c = nc; + //* + if (i + 1 < dlen - 2) { + nc = _hash(data, i + 1); + var ii = (i + 1) & 0x7fff; + prev[ii] = strt[nc]; + strt[nc] = ii; + } //*/ + if (cvrd <= i) { + if ((li > 14000 || lc > 26697) && dlen - i > 100) { + if (cvrd < i) { + lits[li] = i - cvrd; + li += 2; + cvrd = i; + } + pos = _writeBlock( + i == dlen - 1 || cvrd == dlen ? 1 : 0, + lits, + li, + ebits, + data, + bs, + i - bs, + out, + pos, + ); + li = lc = ebits = 0; + bs = i; + } + + var mch = 0; + //if(nmci==i) mch= nmch; else + if (i < dlen - 2) + mch = _bestMatch( + data, + i, + prev, + c, + Math.min(opt[2], dlen - i), + opt[3], + ); + /* + if(mch!=0 && opt[4]==1 && (mch>>>16)>>16)>(mch>>>16)) mch=0; + }//*/ + var len = mch >>> 16, + dst = mch & 0xffff; //if(i-dst<0) throw "e"; + if (mch != 0) { + var len = mch >>> 16, + dst = mch & 0xffff; //if(i-dst<0) throw "e"; + var lgi = _goodIndex(len, U.of0); + U.lhst[257 + lgi]++; + var dgi = _goodIndex(dst, U.df0); + U.dhst[dgi]++; + ebits += U.exb[lgi] + U.dxb[dgi]; + lits[li] = (len << 23) | (i - cvrd); + lits[li + 1] = (dst << 16) | (lgi << 8) | dgi; + li += 2; + cvrd = i + len; + } else { + U.lhst[data[i]]++; + } + lc++; + } + } + if (bs != i || data.length == 0) { + if (cvrd < i) { + lits[li] = i - cvrd; + li += 2; + cvrd = i; + } + pos = _writeBlock(1, lits, li, ebits, data, bs, i - bs, out, pos); + li = 0; + lc = 0; + li = lc = ebits = 0; + bs = i; + } + while ((pos & 7) != 0) pos++; + return pos >>> 3; + } + function _bestMatch(data, i, prev, c, nice, chain) { + var ci = i & 0x7fff, + pi = prev[ci]; + //console.log("----", i); + var dif = (ci - pi + (1 << 15)) & 0x7fff; + if (pi == ci || c != _hash(data, i - dif)) return 0; + var tl = 0, + td = 0; // top length, top distance + var dlim = Math.min(0x7fff, i); + while ( + dif <= dlim && + --chain != 0 && + pi != ci /*&& c==UZIP.F._hash(data,i-dif)*/ + ) { + if (tl == 0 || data[i + tl] == data[i + tl - dif]) { + var cl = _howLong(data, i, dif); + if (cl > tl) { + tl = cl; + td = dif; + if (tl >= nice) break; //* + if (dif + 2 < cl) cl = dif + 2; + var maxd = 0; // pi does not point to the start of the word + for (var j = 0; j < cl - 2; j++) { + var ei = (i - dif + j + (1 << 15)) & 0x7fff; + var li = prev[ei]; + var curd = (ei - li + (1 << 15)) & 0x7fff; + if (curd > maxd) { + maxd = curd; + pi = ei; + } + } //*/ + } + } + + ci = pi; + pi = prev[ci]; + dif += (ci - pi + (1 << 15)) & 0x7fff; + } + return (tl << 16) | td; + } + function _howLong(data, i, dif) { + if ( + data[i] != data[i - dif] || + data[i + 1] != data[i + 1 - dif] || + data[i + 2] != data[i + 2 - dif] + ) + return 0; + var oi = i, + l = Math.min(data.length, i + 258); + i += 3; + //while(i+4>> 23, + end = off + (qb & ((1 << 23) - 1)); + while (off < end) pos = _writeLit(data[off++], ltree, out, pos); + + if (len != 0) { + var qc = lits[si + 1], + dst = qc >> 16, + lgi = (qc >> 8) & 255, + dgi = qc & 255; + pos = _writeLit(257 + lgi, ltree, out, pos); + _putsE(out, pos, len - U.of0[lgi]); + pos += U.exb[lgi]; + + pos = _writeLit(dgi, dtree, out, pos); + _putsF(out, pos, dst - U.df0[dgi]); + pos += U.dxb[dgi]; + off += len; + } + } + pos = _writeLit(256, ltree, out, pos); + } + //console.log(pos-opos, fxdSize, dynSize, cstSize); + return pos; + } + function _copyExact(data, off, len, out, pos) { + var p8 = pos >>> 3; + out[p8] = len; + out[p8 + 1] = len >>> 8; + out[p8 + 2] = 255 - out[p8]; + out[p8 + 3] = 255 - out[p8 + 1]; + p8 += 4; + out.set(new Uint8Array(data.buffer, off, len), p8); + //for(var i=0; i 4 && U.itree[(U.ordr[numh - 1] << 1) + 1] == 0) numh--; + return [ML, MD, MH, numl, numd, numh, lset, dset]; + } + function getSecond(a) { + var b = []; + for (var i = 0; i < a.length; i += 2) b.push(a[i + 1]); + return b; + } + function nonZero(a) { + var b = ""; + for (var i = 0; i < a.length; i += 2) + if (a[i + 1] != 0) b += (i >> 1) + ","; + return b; + } + function contSize(tree, hst) { + var s = 0; + for (var i = 0; i < hst.length; i++) s += hst[i] * tree[(i << 1) + 1]; + return s; + } + function _codeTiny(set, tree, out, pos) { + for (var i = 0; i < set.length; i += 2) { + var l = set[i], + rst = set[i + 1]; //console.log(l, pos, tree[(l<<1)+1]); + pos = _writeLit(l, tree, out, pos); + var rsl = l == 16 ? 2 : l == 17 ? 3 : 7; + if (l > 15) { + _putsE(out, pos, rst, rsl); + pos += rsl; + } + } + return pos; + } + function _lenCodes(tree, set) { + var len = tree.length; + while (len != 2 && tree[len - 1] == 0) len -= 2; // when no distances, keep one code with length 0 + for (var i = 0; i < len; i += 2) { + var l = tree[i + 1], + nxt = i + 3 < len ? tree[i + 3] : -1, + nnxt = i + 5 < len ? tree[i + 5] : -1, + prv = i == 0 ? -1 : tree[i - 1]; + if (l == 0 && nxt == l && nnxt == l) { + var lz = i + 5; + while (lz + 2 < len && tree[lz + 2] == l) lz += 2; + var zc = Math.min((lz + 1 - i) >>> 1, 138); + if (zc < 11) set.push(17, zc - 3); + else set.push(18, zc - 11); + i += zc * 2 - 2; + } else if (l == prv && nxt == l && nnxt == l) { + var lz = i + 5; + while (lz + 2 < len && tree[lz + 2] == l) lz += 2; + var zc = Math.min((lz + 1 - i) >>> 1, 6); + set.push(16, zc - 3); + i += zc * 2 - 2; + } else set.push(l, 0); + } + return len >>> 1; + } + function _hufTree(hst, tree, MAXL) { + var list = [], + hl = hst.length, + tl = tree.length, + i = 0; + for (i = 0; i < tl; i += 2) { + tree[i] = 0; + tree[i + 1] = 0; + } + for (i = 0; i < hl; i++) if (hst[i] != 0) list.push({ lit: i, f: hst[i] }); + var end = list.length, + l2 = list.slice(0); + if (end == 0) return 0; // empty histogram (usually for dist) + if (end == 1) { + var lit = list[0].lit, + l2 = lit == 0 ? 1 : 0; + tree[(lit << 1) + 1] = 1; + tree[(l2 << 1) + 1] = 1; + return 1; + } + list.sort(function (a, b) { + return a.f - b.f; + }); + var a = list[0], + b = list[1], + i0 = 0, + i1 = 1, + i2 = 2; + list[0] = { lit: -1, f: a.f + b.f, l: a, r: b, d: 0 }; + while (i1 != end - 1) { + if (i0 != i1 && (i2 == end || list[i0].f < list[i2].f)) { + a = list[i0++]; + } else { + a = list[i2++]; + } + if (i0 != i1 && (i2 == end || list[i0].f < list[i2].f)) { + b = list[i0++]; + } else { + b = list[i2++]; + } + list[i1++] = { lit: -1, f: a.f + b.f, l: a, r: b }; + } + var maxl = setDepth(list[i1 - 1], 0); + if (maxl > MAXL) { + restrictDepth(l2, MAXL, maxl); + maxl = MAXL; + } + for (i = 0; i < end; i++) tree[(l2[i].lit << 1) + 1] = l2[i].d; + return maxl; + } + + function setDepth(t, d) { + if (t.lit != -1) { + t.d = d; + return d; + } + return Math.max(setDepth(t.l, d + 1), setDepth(t.r, d + 1)); + } + + function restrictDepth(dps, MD, maxl) { + var i = 0, + bCost = 1 << (maxl - MD), + dbt = 0; + dps.sort(function (a, b) { + return b.d == a.d ? a.f - b.f : b.d - a.d; + }); + + for (i = 0; i < dps.length; i++) + if (dps[i].d > MD) { + var od = dps[i].d; + dps[i].d = MD; + dbt += bCost - (1 << (maxl - od)); + } else break; + dbt = dbt >>> (maxl - MD); + while (dbt > 0) { + var od = dps[i].d; + if (od < MD) { + dps[i].d++; + dbt -= 1 << (MD - od - 1); + } else i++; + } + for (; i >= 0; i--) + if (dps[i].d == MD && dbt < 0) { + dps[i].d--; + dbt++; + } + if (dbt != 0) console.log("debt left"); + } + + function _goodIndex(v, arr) { + var i = 0; + if (arr[i | 16] <= v) i |= 16; + if (arr[i | 8] <= v) i |= 8; + if (arr[i | 4] <= v) i |= 4; + if (arr[i | 2] <= v) i |= 2; + if (arr[i | 1] <= v) i |= 1; + return i; + } + function _writeLit(ch, ltree, out, pos) { + _putsF(out, pos, ltree[ch << 1]); + return pos + ltree[(ch << 1) + 1]; + } + + function inflate(data, buf) { + var u8 = Uint8Array; + if (data[0] == 3 && data[1] == 0) return buf ? buf : new u8(0); + //var F=UZIP.F, bitsF = F._bitsF, bitsE = F._bitsE, decodeTiny = F._decodeTiny, makeCodes = F.makeCodes, codes2map=F.codes2map, get17 = F._get17; + + var noBuf = buf == null; + if (noBuf) buf = new u8((data.length >>> 2) << 3); + + var BFINAL = 0, + BTYPE = 0, + HLIT = 0, + HDIST = 0, + HCLEN = 0, + ML = 0, + MD = 0; + var off = 0, + pos = 0; + var lmap, dmap; + + while (BFINAL == 0) { + BFINAL = _bitsF(data, pos, 1); + BTYPE = _bitsF(data, pos + 1, 2); + pos += 3; + //console.log(BFINAL, BTYPE); + + if (BTYPE == 0) { + if ((pos & 7) != 0) pos += 8 - (pos & 7); + var p8 = (pos >>> 3) + 4, + len = data[p8 - 4] | (data[p8 - 3] << 8); //console.log(len);//bitsF(data, pos, 16), + if (noBuf) buf = _check(buf, off + len); + buf.set(new u8(data.buffer, data.byteOffset + p8, len), off); + //for(var i=0; i tl) tl = l; + } + pos += 3 * HCLEN; //console.log(itree); + makeCodes(U.itree, tl); + codes2map(U.itree, tl, U.imap); + + lmap = U.lmap; + dmap = U.dmap; + + pos = _decodeTiny( + U.imap, + (1 << tl) - 1, + HLIT + HDIST, + data, + pos, + U.ttree, + ); + var mx0 = _copyOut(U.ttree, 0, HLIT, U.ltree); + ML = (1 << mx0) - 1; + var mx1 = _copyOut(U.ttree, HLIT, HDIST, U.dtree); + MD = (1 << mx1) - 1; + + //var ml = _decodeTiny(U.imap, (1<>>24))-1; pos+=(ml&0xffffff); + makeCodes(U.ltree, mx0); + codes2map(U.ltree, mx0, lmap); + + //var md = _decodeTiny(U.imap, (1<>>24))-1; pos+=(md&0xffffff); + makeCodes(U.dtree, mx1); + codes2map(U.dtree, mx1, dmap); + } + //var ooff=off, opos=pos; + while (true) { + var code = lmap[_get17(data, pos) & ML]; + pos += code & 15; + var lit = code >>> 4; //U.lhst[lit]++; + if (lit >>> 8 == 0) { + buf[off++] = lit; + } else if (lit == 256) { + break; + } else { + var end = off + lit - 254; + if (lit > 264) { + var ebs = U.ldef[lit - 257]; + end = off + (ebs >>> 3) + _bitsE(data, pos, ebs & 7); + pos += ebs & 7; + } + //UZIP.F.dst[end-off]++; + + var dcode = dmap[_get17(data, pos) & MD]; + pos += dcode & 15; + var dlit = dcode >>> 4; + var dbs = U.ddef[dlit], + dst = (dbs >>> 4) + _bitsF(data, pos, dbs & 15); + pos += dbs & 15; + + //var o0 = off-dst, stp = Math.min(end-off, dst); + //if(stp>20) while(off>>3); + } + //console.log(UZIP.F.dst); + //console.log(tlen, dlen, off-tlen+tcnt); + return buf.length == off ? buf : buf.slice(0, off); + } + function _check(buf, len) { + var bl = buf.length; + if (len <= bl) return buf; + var nbuf = new Uint8Array(Math.max(bl << 1, len)); + nbuf.set(buf, 0); + //for(var i=0; i>> 4; + if (lit <= 15) { + tree[i] = lit; + i++; + } else { + var ll = 0, + n = 0; + if (lit == 16) { + n = 3 + _bitsE(data, pos, 2); + pos += 2; + ll = tree[i - 1]; + } else if (lit == 17) { + n = 3 + _bitsE(data, pos, 3); + pos += 3; + } else if (lit == 18) { + n = 11 + _bitsE(data, pos, 7); + pos += 7; + } + var ni = i + n; + while (i < ni) { + tree[i] = ll; + i++; + } + } + } + return pos; + } + function _copyOut(src, off, len, tree) { + var mx = 0, + i = 0, + tl = tree.length >>> 1; + while (i < len) { + var v = src[i + off]; + tree[i << 1] = 0; + tree[(i << 1) + 1] = v; + if (v > mx) mx = v; + i++; + } + while (i < tl) { + tree[i << 1] = 0; + tree[(i << 1) + 1] = 0; + i++; + } + return mx; + } + + UZIP["F"] = { inflate: inflate, deflateRaw: deflateRaw }; +})(); diff --git a/packages/markdown/locales/de.json b/packages/markdown/locales/de.json index e32e85d1..b71698b5 100644 --- a/packages/markdown/locales/de.json +++ b/packages/markdown/locales/de.json @@ -53,5 +53,25 @@ "webide-reset": "Zurücksetzen", "webide-reset-prompt": "Sind Sie sicher, dass Sie den Code zurücksetzen möchten?", "webide-copy": "Kopieren", - "webide-download": "Herunterladen" + "webide-download": "Herunterladen", + "typst-code": "Typst", + "typst-reset": "Zurücksetzen", + "typst-reset-prompt": "Sind Sie sicher, dass Sie den Code zurücksetzen möchten?", + "typst-download-pdf": "PDF herunterladen", + "typst-pdf-error": "Fehler beim PDF-Export", + "typst-download-project": "Projekt herunterladen", + "typst-loading": "Typst wird geladen...", + "typst-add-source-file": "Quelldatei hinzufügen", + "typst-add-binary-file": "Binärdatei hinzufügen", + "typst-source": "Quelle", + "typst-binary": "Binär", + "typst-add": "Hinzufügen", + "typst-delete-file": "Datei löschen", + "typst-delete-confirm": "Löschen", + "typst-filename-prompt": "Dateiname eingeben (z.B. helper.typ):", + "typst-filename-error": "Dateiname muss auf .typ oder .typst enden", + "typst-filename-exists": "Datei existiert bereits", + "typst-file-replace": "Existierende Datei ersetzen?", + "typst-binary-files": "Binärdateien", + "typst-no-binary-files": "Keine Binärdateien" } diff --git a/packages/markdown/locales/en.json b/packages/markdown/locales/en.json index c9361eb3..d2de1e8a 100644 --- a/packages/markdown/locales/en.json +++ b/packages/markdown/locales/en.json @@ -53,5 +53,25 @@ "webide-reset": "Reset", "webide-reset-prompt": "Are you sure you want to reset the code?", "webide-copy": "Copy", - "webide-download": "Download" + "webide-download": "Download", + "typst-code": "Typst", + "typst-reset": "Reset", + "typst-reset-prompt": "Are you sure you want to reset the code?", + "typst-download-pdf": "Download PDF", + "typst-pdf-error": "Error exporting PDF", + "typst-download-project": "Download Project", + "typst-loading": "Loading Typst...", + "typst-add-source-file": "Add source file", + "typst-add-binary-file": "Add binary file", + "typst-source": "Source", + "typst-binary": "Binary", + "typst-add": "Add", + "typst-delete-file": "Delete file", + "typst-delete-confirm": "Delete", + "typst-filename-prompt": "Enter filename (e.g., helper.typ):", + "typst-filename-error": "Filename must end with .typ or .typst", + "typst-filename-exists": "File already exists", + "typst-file-replace": "Replace existing file?", + "typst-binary-files": "Binary Files", + "typst-no-binary-files": "No binary files" } diff --git a/packages/markdown/src/process.ts b/packages/markdown/src/process.ts index 2c0a012a..f4a38722 100644 --- a/packages/markdown/src/process.ts +++ b/packages/markdown/src/process.ts @@ -62,6 +62,7 @@ import remarkSubSup from "./remarkSubSup"; import remarkImageAttrs from "./remarkImageAttrs"; import remarkDirectiveLearningmap from "./remarkDirectiveLearningmap"; import remarkDirectiveTextinput from "./remarkDirectiveTextinput"; +import remarkDirectiveTypst from "./remarkDirectiveTypst"; export const remark = (ctx: HyperbookContext) => { i18n.init(ctx.config.language || "en"); @@ -106,6 +107,7 @@ export const remark = (ctx: HyperbookContext) => { remarkDirectiveMultievent(ctx), remarkDirectiveLearningmap(ctx), remarkDirectiveTextinput(ctx), + remarkDirectiveTypst(ctx), remarkCode(ctx), remarkMath, /* needs to be last directive */ diff --git a/packages/markdown/src/rehypeHtmlStructure.ts b/packages/markdown/src/rehypeHtmlStructure.ts index de5725db..0ee896f3 100644 --- a/packages/markdown/src/rehypeHtmlStructure.ts +++ b/packages/markdown/src/rehypeHtmlStructure.ts @@ -435,6 +435,15 @@ window.Prism.manual = true;`, }, children: [], }, + { + type: "element", + tagName: "script", + properties: { + src: makeUrl(["prism", "prism-typst.js"], "assets"), + defer: true, + }, + children: [], + }, { type: "element", tagName: "link", diff --git a/packages/markdown/src/remarkDirectiveTypst.ts b/packages/markdown/src/remarkDirectiveTypst.ts new file mode 100644 index 00000000..05e47c8f --- /dev/null +++ b/packages/markdown/src/remarkDirectiveTypst.ts @@ -0,0 +1,423 @@ +// Register directive nodes in mdast: +/// +// +import { HyperbookContext } from "@hyperbook/types"; +import { Code, Root, Text } from "mdast"; +import { visit } from "unist-util-visit"; +import { VFile } from "vfile"; +import { + expectContainerDirective, + isDirective, + registerDirective, + requestCSS, + requestJS, +} from "./remarkHelper"; +import hash from "./objectHash"; +import { i18n } from "./i18n"; +import { Element, ElementContent } from "hast"; +import { readFile } from "./helper"; + +function htmlEntities(str: string) { + return String(str) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +export default (ctx: HyperbookContext) => () => { + const name = "typst"; + + return (tree: Root, file: VFile) => { + visit(tree, function (node) { + if (isDirective(node) && node.name === name) { + const { + height = "auto", + id = hash(node), + mode = "preview", + } = node.attributes || {}; + const data = node.data || (node.data = {}); + + expectContainerDirective(node, file, name); + registerDirective(file, name, ["client.js"], ["style.css"], []); + requestJS(file, ["code-input", "code-input.min.js"]); + requestCSS(file, ["code-input", "code-input.min.css"]); + requestJS(file, ["code-input", "auto-close-brackets.min.js"]); + requestJS(file, ["code-input", "indent.min.js"]); + requestJS(file, ["uzip", "uzip.js"]); + + const sourceFiles: { filename: string; content: string }[] = []; + const binaryFiles: { dest: string; url: string }[] = []; + + // Parse @file and @source directives from text nodes (including nested in paragraphs) + for (const child of node.children) { + if (child.type === "text") { + const text = (child as Text).value; + + // Parse @file directives for binary files + const fileMatches = text.matchAll( + /@file\s+dest="([^"]+)"\s+src="([^"]+)"/g, + ); + for (const match of fileMatches) { + const dest = match[1]; + const src = match[2]; + const url = ctx.makeUrl( + src, + "public", + ctx.navigation.current || undefined, + ); + binaryFiles.push({ dest, url }); + } + + // Parse @source directives for typst source files + const sourceMatches = text.matchAll( + /@source\s+dest="([^"]+)"\s+src="([^"]+)"/g, + ); + for (const match of sourceMatches) { + const dest = match[1]; + const src = match[2]; + const content = readFile(src, ctx) || ""; + sourceFiles.push({ filename: dest, content }); + } + } else if (child.type === "paragraph") { + // Check text nodes inside paragraphs + for (const textNode of child.children) { + if (textNode.type === "text") { + const text = (textNode as Text).value; + + // Parse @file directives for binary files + const fileMatches = text.matchAll( + /@file\s+dest="([^"]+)"\s+src="([^"]+)"/g, + ); + for (const match of fileMatches) { + const dest = match[1]; + const src = match[2]; + const url = ctx.makeUrl( + src, + "public", + ctx.navigation.current || undefined, + ); + binaryFiles.push({ dest, url }); + } + + // Parse @source directives for typst source files + const sourceMatches = text.matchAll( + /@source\s+dest="([^"]+)"\s+src="([^"]+)"/g, + ); + for (const match of sourceMatches) { + const dest = match[1]; + const src = match[2]; + const content = readFile(src, ctx) || ""; + sourceFiles.push({ filename: dest, content }); + } + } + } + } + } + + // Find all typ/typst code blocks inside the directive + const codeBlocks = node.children.filter( + (n) => n.type === "code" && (n.lang === "typ" || n.lang === "typst"), + ) as Code[]; + + if (codeBlocks.length > 0) { + // If multiple code blocks exist, use meta for filenames + for (const codeBlock of codeBlocks) { + const filename = codeBlock.meta?.trim() || "main.typ"; + // Named file - add to sourceFiles + sourceFiles.push({ + filename: filename, + content: codeBlock.value, + }); + } + } + let mainSource = sourceFiles.find( + (f) => f.filename == "main.typ" || f.filename == "main.typst", + ); + if (!mainSource && sourceFiles.length > 0) { + mainSource = sourceFiles[0]; + } else if (!mainSource) { + mainSource = { filename: "main.typ", content: "" }; + } + + const isEditMode = mode === "edit"; + const isPreviewMode = mode === "preview" || !isEditMode; + + data.hName = "div"; + data.hProperties = { + class: ["directive-typst", isPreviewMode ? "preview-only" : ""] + .join(" ") + .trim(), + "data-id": id, + "data-source-files": Buffer.from( + JSON.stringify(sourceFiles), + ).toString("base64"), + "data-binary-files": Buffer.from( + JSON.stringify(binaryFiles), + ).toString("base64"), + }; + + const previewContainer: Element = { + type: "element", + tagName: "div", + properties: { + class: "preview-container", + style: `height: ${height};`, + }, + children: [ + { + type: "element", + tagName: "div", + properties: { + class: "typst-loading", + }, + children: [ + { + type: "element", + tagName: "div", + properties: { + class: "typst-spinner", + }, + children: [], + }, + { + type: "element", + tagName: "span", + properties: {}, + children: [ + { + type: "text", + value: i18n.get("typst-loading"), + }, + ], + }, + ], + }, + { + type: "element", + tagName: "div", + properties: { + class: "typst-preview", + }, + children: [], + }, + ], + }; + + const downloadButton: Element = { + type: "element", + tagName: "button", + properties: { + class: "download-pdf", + }, + children: [ + { + type: "text", + value: i18n.get("typst-download-pdf"), + }, + ], + }; + + const downloadProjectButton: Element = { + type: "element", + tagName: "button", + properties: { + class: "download-project", + }, + children: [ + { + type: "text", + value: i18n.get("typst-download-project"), + }, + ], + }; + + if (isEditMode) { + // Edit mode: show editor and preview side by side + data.hChildren = [ + previewContainer, + { + type: "element", + tagName: "div", + properties: { + class: "editor-container", + }, + children: [ + { + type: "element", + tagName: "div", + properties: { + class: "file-tabs", + }, + children: [ + { + type: "element", + tagName: "div", + properties: { + class: "tabs-list", + }, + children: [], + }, + { + type: "element", + tagName: "button", + properties: { + class: "add-source-file", + title: i18n.get("typst-add-source-file"), + }, + children: [ + { + type: "text", + value: "+", + }, + ], + }, + ], + }, + { + type: "element", + tagName: "details", + properties: { + class: "binary-files-section", + }, + children: [ + { + type: "element", + tagName: "summary", + properties: {}, + children: [ + { + type: "element", + tagName: "span", + properties: { + class: "summary-text", + }, + children: [ + { + type: "element", + tagName: "span", + properties: { + class: "summary-indicator", + }, + children: [ + { + type: "text", + value: "▶", + }, + ], + }, + { + type: "text", + value: i18n.get("typst-binary-files"), + }, + ], + }, + ], + }, + { + type: "element", + tagName: "div", + properties: { + class: "binary-files-list", + }, + children: [], + }, + { + type: "element", + tagName: "div", + properties: { + class: "binary-files-actions", + }, + children: [ + { + type: "element", + tagName: "button", + properties: { + class: "add-binary-file", + title: i18n.get("typst-add-binary-file"), + }, + children: [ + { + type: "text", + value: "+ " + i18n.get("typst-add"), + }, + ], + }, + ], + }, + ], + }, + { + type: "element", + tagName: "code-input", + properties: { + class: "editor typst active line-numbers", + language: "typst", + template: "typst-highlighted", + }, + children: [ + { + type: "raw", + value: htmlEntities(mainSource.content), + }, + ], + }, + { + type: "element", + tagName: "div", + properties: { + class: "buttons bottom", + }, + children: [ + { + type: "element", + tagName: "button", + properties: { + class: "reset", + }, + children: [ + { + type: "text", + value: i18n.get("typst-reset"), + }, + ], + }, + downloadProjectButton, + downloadButton, + ], + }, + ], + }, + ]; + } else { + // Preview mode: show only preview with download button + data.hChildren = [ + previewContainer, + { + type: "element", + tagName: "div", + properties: { + class: "buttons bottom", + }, + children: [downloadProjectButton, downloadButton], + }, + { + type: "element", + tagName: "textarea", + properties: { + class: "typst-source hidden", + style: "display: none;", + }, + children: [ + { + type: "text", + value: mainSource.content, + }, + ], + }, + ]; + } + } + }); + }; +}; diff --git a/packages/markdown/tests/__snapshots__/process.test.ts.snap b/packages/markdown/tests/__snapshots__/process.test.ts.snap index a61cb8e7..046935e6 100644 --- a/packages/markdown/tests/__snapshots__/process.test.ts.snap +++ b/packages/markdown/tests/__snapshots__/process.test.ts.snap @@ -43,7 +43,7 @@ body { // Remove no-js class as soon as JavaScript is available document.documentElement.classList.remove('no-js');" +
✎ GitHub© Copyright 2026 by OpenPatch
" `; exports[`process > should hide copy button for block code 1`] = ` @@ -116,7 +116,7 @@ body { // Remove no-js class as soon as JavaScript is available document.documentElement.classList.remove('no-js');
My Hyperbook
@@ -132,7 +132,7 @@ HYPERBOOK_ASSETS = "/assets/"
hide this block code
-
© Copyright 2025
" +
© Copyright 2026
" `; exports[`process > should include copy button for block code 1`] = ` @@ -178,7 +178,7 @@ body { // Remove no-js class as soon as JavaScript is available document.documentElement.classList.remove('no-js');
My Hyperbook
@@ -194,7 +194,7 @@ HYPERBOOK_ASSETS = "/assets/"
test this block code
-
© Copyright 2025
" +
© Copyright 2026
" `; exports[`process > should include copy button for inline code 1`] = ` @@ -240,7 +240,7 @@ body { // Remove no-js class as soon as JavaScript is available document.documentElement.classList.remove('no-js');
My Hyperbook
@@ -254,7 +254,7 @@ HYPERBOOK_ASSETS = "/assets/"

test this inline code

-
© Copyright 2025
" +
© Copyright 2026
" `; exports[`process > should not include copy button for inline code 1`] = ` @@ -300,7 +300,7 @@ body { // Remove no-js class as soon as JavaScript is available document.documentElement.classList.remove('no-js');
My Hyperbook
@@ -314,7 +314,7 @@ HYPERBOOK_ASSETS = "/assets/"

test this inline code

-
© Copyright 2025
" +
© Copyright 2026
" `; exports[`process > should result in a heading with a colon 1`] = ` @@ -360,7 +360,7 @@ body { // Remove no-js class as soon as JavaScript is available document.documentElement.classList.remove('no-js');
My Hyperbook
@@ -376,7 +376,7 @@ HYPERBOOK_ASSETS = "/assets/"

Heading 1:1

-
© Copyright 2025
" +
© Copyright 2026
" `; exports[`process > should result in two link 1`] = ` @@ -422,7 +422,7 @@ body { // Remove no-js class as soon as JavaScript is available document.documentElement.classList.remove('no-js');
My Hyperbook
@@ -443,7 +443,7 @@ HYPERBOOK_ASSETS = "/assets/" in Python Teil des Programms .

-
© Copyright 2025
" +
© Copyright 2026
" `; exports[`process > should transform 1`] = ` @@ -489,7 +489,7 @@ body { // Remove no-js class as soon as JavaScript is available document.documentElement.classList.remove('no-js');
My Hyperbook
@@ -541,7 +541,7 @@ HYPERBOOK_ASSETS = "/assets/"
-
© Copyright 2025
" +
© Copyright 2026
" `; exports[`process > should transfrom complex context 1`] = ` @@ -587,7 +587,7 @@ body { // Remove no-js class as soon as JavaScript is available document.documentElement.classList.remove('no-js');" +
✎ GitHub© Copyright 2026 by OpenPatch
" `; diff --git a/website/de/book/elements/typst-doc.typ b/website/de/book/elements/typst-doc.typ new file mode 100644 index 00000000..72f82dcb --- /dev/null +++ b/website/de/book/elements/typst-doc.typ @@ -0,0 +1,3 @@ += This is a Test + +The content of this document is located in the file `typst-doc.typ`. diff --git a/website/de/book/elements/typst.md b/website/de/book/elements/typst.md new file mode 100644 index 00000000..548cce8b --- /dev/null +++ b/website/de/book/elements/typst.md @@ -0,0 +1,307 @@ +--- +name: Typst +permaid: typst +--- + +# Typst + +Die Typst-Direktive ermöglicht es dir, [Typst](https://typst.app/)-Dokumente direkt in deinem Hyperbook zu rendern. Typst ist ein modernes Markup-basiertes Satzsystem, das einfach zu erlernen ist und schöne Dokumente erzeugt. + +## Optionen + +| Option | Beschreibung | Standard | +|--------|--------------|----------| +| `id` | Eindeutiger Bezeichner für den Typst-Block | Automatisch generiert | +| `mode` | Anzeigemodus: `preview` (nur Ansicht) oder `edit` (mit Editor) | `preview` | +| `height` | Höhe des Vorschau-Containers | `auto` | + +## Wichtige Hinweise + +- **Mehrere Typst-Blöcke**: Wenn mehrere Typst-Blöcke auf derselben Seite vorhanden sind, werden sie nacheinander gerendert (einer nach dem anderen), um die Dateiisolierung zu gewährleisten. Jeder Block behält sein eigenes unabhängiges Dateisystem während des Renderns bei. +- **Dateiisolierung**: Dateien, die in einem Typst-Block geladen werden (über `@source` oder `@file`), sind vollständig von anderen Blöcken auf derselben Seite isoliert. Das bedeutet, dass du denselben Dateinamen (z.B. `other.typ`) in verschiedenen Blöcken ohne Konflikte verwenden kannst. + + +## Verwendung + +Um die Typst-Direktive zu verwenden, umschließe deinen Typst-Code in einem `:::typst`-Block mit einem Code-Block, der die Sprache `typ` oder `typst` verwendet. + +### Vorschau-Modus + +Im Vorschau-Modus wird nur die gerenderte Ausgabe mit einem Download-Button zum Exportieren als PDF angezeigt. + +````md +:::typst{mode="preview"} + +```typ += Hallo Welt! + +Dies ist ein einfaches Typst-Dokument. + +- Erster Eintrag +- Zweiter Eintrag +- Dritter Eintrag +``` + +::: +```` + +:::typst{mode="preview"} + +```typ += Hallo Welt! + +Dies ist ein einfaches Typst-Dokument. + +- Erster Eintrag +- Zweiter Eintrag +- Dritter Eintrag +``` + +::: + +### Bearbeitungsmodus + +Im Bearbeitungsmodus wird ein Editor neben der Vorschau angezeigt, sodass Benutzer den Typst-Code ändern und Live-Updates sehen können. + +````md +:::typst{mode="edit"} + +```typ += Interaktives Dokument + +Du kannst diesen Text bearbeiten und die Änderungen live sehen! + +$ sum_(i=1)^n i = (n(n+1))/2 $ +``` + +::: +```` + +:::typst{mode="edit"} + +```typ += Interaktives Dokument + +Du kannst diesen Text bearbeiten und die Änderungen live sehen! + +$ sum_(i=1)^n i = (n(n+1))/2 $ +``` + +::: + +### Laden aus externen Dateien + +Du kannst Typst-Quelldateien und Binärdateien (wie Bilder) aus externen Quellen laden, indem du spezielle Direktiven verwendest. + +#### Laden von Quelldateien + +Verwende die `@source`-Direktive, um Typst-Quelldateien zu laden, die in dein Hauptdokument eingebunden werden können: + +````md +:::typst{mode="preview"} + +@source dest="other.typ" src="typst-doc.typ" + +```typ += Hauptdokument + +#include "/other.typ" +``` +::: +```` + +:::typst{mode="preview"} + +@source dest="other.typ" src="typst-doc.typ" + +```typ += Hauptdokument + +#include "/other.typ" +``` +::: + +#### Laden von Binärdateien + +Verwende die `@file`-Direktive, um Binärdateien wie Bilder zu laden: + +````md +:::typst{mode="preview"} + +@file dest="/image.jpg" src="/my-image.jpg" + +```typ += Dokument mit Bild + +#figure( + image("/image.jpg", width: 80%), + caption: "Mein Bild" +) +``` +::: +```` + +#### Suchpfade für Dateien + +Dateien, die in `src`-Attributen referenziert werden, werden in den folgenden Speicherorten gesucht (in dieser Reihenfolge): +1. `public/`-Verzeichnis +2. `book/`-Verzeichnis +3. Verzeichnis der aktuellen Seite + +#### Mehrere Quelldateien + +Du kannst mehrere Quelldateien definieren, indem du benannte Code-Blöcke verwendest: + +````md +:::typst{mode="preview"} + +```typ main.typ += Hauptdokument + +#include "/helper.typ" +``` + +```typ helper.typ += Hilfsinhalt + +Dieser Inhalt befindet sich in einer separaten Datei. +``` +::: +```` + +## Beispiele + +### Mathematische Formeln + +````md +:::typst{mode="preview" height="300px"} + +```typ += Mathematische Formeln + +Typst unterstützt schönen mathematischen Formelsatz: + +$ integral_0^infinity e^(-x^2) dif x = sqrt(pi)/2 $ + +Die quadratische Formel: + +$ x = (-b plus.minus sqrt(b^2 - 4a c)) / (2a) $ +``` +::: +```` + +:::typst{mode="preview" height="300px"} + +```typ += Mathematische Formeln + +Typst unterstützt schönen mathematischen Formelsatz: + +$ integral_0^infinity e^(-x^2) dif x = sqrt(pi)/2 $ + +Die quadratische Formel: + +$ x = (-b plus.minus sqrt(b^2 - 4a c)) / (2a) $ +``` +::: + +### Tabellen + +````md +:::typst{mode="preview" height="250px"} +```typ += Datentabelle + +#table( + columns: (auto, auto, auto), + [*Name*], [*Alter*], [*Stadt*], + [Alice], [25], [Berlin], + [Bob], [30], [München], + [Carol], [28], [Hamburg], +) +``` +::: +```` + +:::typst{mode="preview" height="250px"} +```typ += Datentabelle + +#table( + columns: (auto, auto, auto), + [*Name*], [*Alter*], [*Stadt*], + [Alice], [25], [Berlin], + [Bob], [30], [München], + [Carol], [28], [Hamburg], +) +``` +::: + +### Komplexes Beispiel mit mehreren Dateien + +Dieses Beispiel demonstriert das Laden von sowohl Quell- als auch Binärdateien mit mehreren benannten Typst-Dateien: + +````md +:::typst{mode="preview" height="250px"} + +@file dest="/hello.jpg" src="/test.jpg" + +```typ main.typ += Code-Beispiel + +Hier ist etwas Inline-`Code` und ein Code-Block: + +#raw(block: true, lang: "python", +"def hello(): + print('Hallo Welt!')") + +#figure( + image("/hello.jpg", width: 80%), + caption: "Eine komplexe Abbildung mit einem Bild." +) + +#include "/other.typ" +``` + +```typ other.typ += Zusätzlicher Inhalt + +#figure( + image("hello.jpg", width: 80%), + caption: "Eine weitere Ansicht des Bildes." +) +``` +::: +```` + +:::typst{mode="preview" height="250px"} + +@file dest="/hello.jpg" src="/test.jpg" + +```typ main.typ += Code-Beispiel + +Hier ist etwas Inline-`Code` und ein Code-Block: + +#raw(block: true, lang: "python", +"def hello(): + print('Hallo Welt!')") + +#figure( + image("/hello.jpg", width: 80%), + caption: "Eine komplexe Abbildung mit einem Bild und einer Tabellenbeschriftung." +) + +#include "/other.typ" + +``` + +```typ other.typ += Ein weiterer Code-Block + +#figure( + image("hello.jpg", width: 80%), + caption: "Eine komplexe Abbildung mit einem Bild und einer Tabellenbeschriftung." +) +``` +::: diff --git a/website/en/book/changelog.md b/website/en/book/changelog.md index 271a73b8..6241233c 100644 --- a/website/en/book/changelog.md +++ b/website/en/book/changelog.md @@ -38,6 +38,35 @@ If you need a new feature, open an [issue](https://github.com/openpatch/hyperboo :::: --> +## v0.73.0 + +::::tabs + +:::tab{title="New :rocket:" id="new"} + +**Typst Directive** + +Write and preview Typst documents directly in your Hyperbook with a powerful interactive editor. + +**Features** +- Interactive editor with syntax highlighting +- Live preview with automatic rendering +- Multi-file project support with tab interface +- Binary file upload for images, fonts, and other assets +- Export to PDF +- Download entire project as ZIP +- State persistence across page reloads + +**Smart Error Handling** +- Error messages display as dismissible overlays in the preview +- Last successful render is preserved when errors occur +- Clean, readable error messages extracted from Typst compiler output +- Smooth animations with visual feedback + +::: + +:::: + ## v0.72.2 ::::tabs diff --git a/website/en/book/elements/typst-doc.typ b/website/en/book/elements/typst-doc.typ new file mode 100644 index 00000000..72f82dcb --- /dev/null +++ b/website/en/book/elements/typst-doc.typ @@ -0,0 +1,3 @@ += This is a Test + +The content of this document is located in the file `typst-doc.typ`. diff --git a/website/en/book/elements/typst.md b/website/en/book/elements/typst.md new file mode 100644 index 00000000..2b5a1651 --- /dev/null +++ b/website/en/book/elements/typst.md @@ -0,0 +1,307 @@ +--- +name: Typst +permaid: typst +--- + +# Typst + +The Typst directive allows you to render [Typst](https://typst.app/) documents directly in your hyperbook. Typst is a modern markup-based typesetting system that is easy to learn and produces beautiful documents. + +## Options + +| Option | Description | Default | +|--------|-------------|---------| +| `id` | Unique identifier for the Typst block | Auto-generated | +| `mode` | Display mode: `preview` (view only) or `edit` (with editor) | `preview` | +| `height` | Height of the preview container | `auto` | + +## Important Notes + +- **Multiple Typst Blocks**: When multiple Typst blocks are present on the same page, they render sequentially (one at a time) to ensure file isolation. Each block maintains its own independent file system during rendering. +- **File Isolation**: Files loaded in one Typst block (via `@source` or `@file`) are completely isolated from other blocks on the same page. This means you can use the same filename (e.g., `other.typ`) in different blocks without conflicts. + + +## Usage + +To use the Typst directive, wrap your Typst code in a `:::typst` block with a code block using the `typ` or `typst` language. + +### Preview Mode + +In preview mode, only the rendered output is shown with a download button for exporting to PDF. + +````md +:::typst{mode="preview"} + +```typ += Hello World! + +This is a simple Typst document. + +- First item +- Second item +- Third item +``` + +::: +```` + +:::typst{mode="preview"} + +```typ += Hello World! + +This is a simple Typst document. + +- First item +- Second item +- Third item +``` + +::: + +### Edit Mode + +In edit mode, an editor is shown alongside the preview, allowing users to modify the Typst code and see live updates. + +````md +:::typst{mode="edit"} + +```typ += Interactive Document + +You can edit this text and see the changes live! + +$ sum_(i=1)^n i = (n(n+1))/2 $ +``` + +::: +```` + +:::typst{mode="edit"} + +```typ += Interactive Document + +You can edit this text and see the changes live! + +$ sum_(i=1)^n i = (n(n+1))/2 $ +``` + +::: + +### Loading from External Files + +You can load Typst source files and binary files (like images) from external sources using special directives. + +#### Loading Source Files + +Use the `@source` directive to load Typst source files that can be included in your main document: + +````md +:::typst{mode="preview"} + +@source dest="other.typ" src="typst-doc.typ" + +```typ += Main Document + +#include "/other.typ" +``` +::: +```` + +:::typst{mode="preview"} + +@source dest="other.typ" src="typst-doc.typ" + +```typ += Main Document + +#include "/other.typ" +``` +::: + +#### Loading Binary Files + +Use the `@file` directive to load binary files like images: + +````md +:::typst{mode="preview"} + +@file dest="/image.jpg" src="/my-image.jpg" + +```typ += Document with Image + +#figure( + image("/image.jpg", width: 80%), + caption: "My image" +) +``` +::: +```` + +#### File Search Locations + +Files referenced in `src` attributes are searched in the following locations (in order): +1. `public/` directory +2. `book/` directory +3. Current page's directory + +#### Multiple Source Files + +You can define multiple source files by using named code blocks: + +````md +:::typst{mode="preview"} + +```typ main.typ += Main Document + +#include "/helper.typ" +``` + +```typ helper.typ += Helper Content + +This content is in a separate file. +``` +::: +```` + +## Examples + +### Mathematical Formulas + +````md +:::typst{mode="preview" height="300px"} + +```typ += Mathematical Formulas + +Typst supports beautiful mathematical typesetting: + +$ integral_0^infinity e^(-x^2) dif x = sqrt(pi)/2 $ + +The quadratic formula: + +$ x = (-b plus.minus sqrt(b^2 - 4a c)) / (2a) $ +``` +::: +```` + +:::typst{mode="preview" height="300px"} + +```typ += Mathematical Formulas + +Typst supports beautiful mathematical typesetting: + +$ integral_0^infinity e^(-x^2) dif x = sqrt(pi)/2 $ + +The quadratic formula: + +$ x = (-b plus.minus sqrt(b^2 - 4a c)) / (2a) $ +``` +::: + +### Tables + +````md +:::typst{mode="preview" height="250px"} +```typ += Data Table + +#table( + columns: (auto, auto, auto), + [*Name*], [*Age*], [*City*], + [Alice], [25], [Berlin], + [Bob], [30], [Munich], + [Carol], [28], [Hamburg], +) +``` +::: +```` + +:::typst{mode="preview" height="250px"} +```typ += Data Table + +#table( + columns: (auto, auto, auto), + [*Name*], [*Age*], [*City*], + [Alice], [25], [Berlin], + [Bob], [30], [Munich], + [Carol], [28], [Hamburg], +) +``` +::: + +### Complex Example with Multiple Files + +This example demonstrates loading both source and binary files, with multiple named Typst files: + +````md +:::typst{mode="preview" height="250px"} + +@file dest="/hello.jpg" src="/test.jpg" + +```typ main.typ += Code Example + +Here is some inline `code` and a code block: + +#raw(block: true, lang: "python", +"def hello(): + print('Hello, World!')") + +#figure( + image("/hello.jpg", width: 80%), + caption: "A complex figure with an image." +) + +#include "/other.typ" +``` + +```typ other.typ += Additional Content + +#figure( + image("hello.jpg", width: 80%), + caption: "Another view of the image." +) +``` +::: +```` + +:::typst{mode="preview" height="250px"} + +@file dest="/hello.jpg" src="/test.jpg" + +```typ main.typ += Code Example + +Here is some inline `code` and a code block: + +#raw(block: true, lang: "python", +"def hello(): + print('Hello, World!')") + +#figure( + image("/hello.jpg", width: 80%), + caption: "A complex figure with an image and a table caption." +) + +#include "/other.typ" + +``` + +```typ other.typ += Another Code Block + +#figure( + image("hello.jpg", width: 80%), + caption: "A complex figure with an image and a table caption." +) +``` +:::