diff --git a/.changeset/add-prosemark-typst.md b/.changeset/add-prosemark-typst.md new file mode 100644 index 00000000..3d6c26a5 --- /dev/null +++ b/.changeset/add-prosemark-typst.md @@ -0,0 +1,5 @@ +--- +'@prosemark/typst': minor +--- + +Add **`@prosemark/typst`**: CodeMirror widgets that render dollar-delimited **`Math`** nodes with [typst.ts](https://github.com/Myriad-Dreamin/typst.ts) (WASM compiler/renderer), plus delimiter/formula syntax theme and an LRU SVG cache. Default WASM URLs load from **jsDelivr** (pinned typst.ts version). Re-export math markdown syntax as **`typstMath*`** for consumers who only install this package. diff --git a/.changeset/latex-inline-math-starlight-css.md b/.changeset/latex-inline-math-starlight-css.md new file mode 100644 index 00000000..93f2352e --- /dev/null +++ b/.changeset/latex-inline-math-starlight-css.md @@ -0,0 +1,5 @@ +--- +'@prosemark/latex': patch +--- + +Fix inline MathJax SVG layout when doc themes (e.g. Starlight) set `.sl-markdown-content svg { display: block; height: auto }` by overriding display and height on inline math widgets. diff --git a/.changeset/typst-inline-math-baseline.md b/.changeset/typst-inline-math-baseline.md new file mode 100644 index 00000000..b3c6fa07 --- /dev/null +++ b/.changeset/typst-inline-math-baseline.md @@ -0,0 +1,5 @@ +--- +'@prosemark/typst': patch +--- + +Align inline Typst math with surrounding text by removing extra typst y-inset, using `vertical-align: baseline` on widgets, and applying a per-SVG em offset derived from typst glyph transforms (with ink-bbox fallback for fractions). Expand typst viewBoxes to ink bounds so subscripts and fractions do not clip or overlap the next line. Scale typst SVG height from viewBox pt using a 12pt-per-em ratio (typst body size). Scale display math the same way. diff --git a/.changeset/typst-inline-math-layout.md b/.changeset/typst-inline-math-layout.md new file mode 100644 index 00000000..0ba29ca9 --- /dev/null +++ b/.changeset/typst-inline-math-layout.md @@ -0,0 +1,5 @@ +--- +'@prosemark/typst': patch +--- + +Fix inline and block math layout by setting `display: inline` on typst SVG output (including multiple sibling fragments) via inline styles and widget CSS, so fragments do not stack as block-level boxes. Rewrite typst `#000` fills to `currentColor` so math inherits editor text color. diff --git a/.changeset/typst-jsdelivr-wasm-default.md b/.changeset/typst-jsdelivr-wasm-default.md new file mode 100644 index 00000000..f06867dd --- /dev/null +++ b/.changeset/typst-jsdelivr-wasm-default.md @@ -0,0 +1,5 @@ +--- +'@prosemark/typst': patch +--- + +Default Typst WASM URLs to jsDelivr (pinned version) instead of bundler `?url` imports, so static hosts (e.g. Cloudflare Pages) are not required to ship the ~28 MB compiler WASM. Export `jsdelivrTypstWasmUrls` and `TYPST_TS_VERSION`; override with `compilerWasmUrl` / `rendererWasmUrl` to self-host or bundle. diff --git a/.changeset/typst-math-error-reporting.md b/.changeset/typst-math-error-reporting.md new file mode 100644 index 00000000..ab2bbdd5 --- /dev/null +++ b/.changeset/typst-math-error-reporting.md @@ -0,0 +1,5 @@ +--- +'@prosemark/typst': patch +--- + +Show typst math render errors inline as readable messages (matching the LaTeX extension), not the raw formula source. diff --git a/.changeset/typst-math-ink-color.md b/.changeset/typst-math-ink-color.md new file mode 100644 index 00000000..e65ecac8 --- /dev/null +++ b/.changeset/typst-math-ink-color.md @@ -0,0 +1,5 @@ +--- +'@prosemark/typst': patch +--- + +Inject the editor foreground color into typst math documents (`#show math` / `#show math.equation: set text(fill: …)`), resolve color at widget render time (not the default black field value), and fall back to rewriting black SVG fill/stroke to `currentColor`. Expose `data-typst-ink-fill` and `data-typst-widget-version` on widget roots for deploy/cache verification. diff --git a/apps/debug-vsc-extn-in-web/package.json b/apps/debug-vsc-extn-in-web/package.json index 44c4290f..cd332425 100644 --- a/apps/debug-vsc-extn-in-web/package.json +++ b/apps/debug-vsc-extn-in-web/package.json @@ -22,7 +22,7 @@ "@codemirror/view": "^6.39.4", "@lezer/markdown": "^1.6.1", "@prosemark/core": "workspace:*", - "@prosemark/latex": "workspace:*", + "@prosemark/typst": "workspace:*", "@prosemark/paste-rich-text": "workspace:*", "@prosemark/render-html": "workspace:*", "@prosemark/spellcheck-frontend": "workspace:*", diff --git a/apps/debug-vsc-extn-in-web/src/initDoc.md b/apps/debug-vsc-extn-in-web/src/initDoc.md index bc577cfb..4335e60a 100644 --- a/apps/debug-vsc-extn-in-web/src/initDoc.md +++ b/apps/debug-vsc-extn-in-web/src/initDoc.md @@ -24,10 +24,10 @@ function recieveMessage() { } ``` -## LaTeX math (layout check) +## Typst math (layout check) -Inline $a^2 + b^2 = c^2$ and a display block: +Inline $e^(i pi) + 1 = 0$ and a display block: $$ -\sum_{n=1}^{\infty} \frac{1}{n^2} = \frac{\pi^2}{6} +sum_(n=1)^oo 1/n^2 = pi^2/6 $$ diff --git a/apps/debug-vsc-extn-in-web/src/main.ts b/apps/debug-vsc-extn-in-web/src/main.ts index e17358f2..265779d4 100644 --- a/apps/debug-vsc-extn-in-web/src/main.ts +++ b/apps/debug-vsc-extn-in-web/src/main.ts @@ -13,9 +13,9 @@ import { renderHtmlMarkdownSyntaxExtensions, } from '@prosemark/render-html'; import { - latexMarkdownEditorExtensions, - latexMarkdownSyntaxTheme, -} from '@prosemark/latex'; + typstMarkdownEditorExtensions, + typstMarkdownSyntaxTheme, +} from '@prosemark/typst'; import { GFM } from '@lezer/markdown'; import { pastePlainTextExtension, @@ -41,7 +41,10 @@ if (!(logOutput instanceof HTMLPreElement)) { const log = (message: string) => { const stamp = new Date().toISOString().slice(11, 23); const line = `[${stamp}] ${message}`; - logOutput.textContent = `${line}\n${logOutput.textContent ?? ''}`.slice(0, 12000); + logOutput.textContent = `${line}\n${logOutput.textContent ?? ''}`.slice( + 0, + 12000, + ); console.log(line); }; @@ -64,8 +67,8 @@ const editor = new EditorView({ }), prosemarkBasicSetup(), prosemarkLightThemeSetup(), - ...latexMarkdownSyntaxTheme, - ...latexMarkdownEditorExtensions(), + ...typstMarkdownSyntaxTheme, + ...typstMarkdownEditorExtensions(), htmlBlockExtension, pasteRichTextExtension(), pastePlainTextExtension(), @@ -224,7 +227,9 @@ requireButton('clear-log').addEventListener('click', () => { window.addEventListener('error', (event) => { log( `window.error: ${event.message || 'Unknown error'}${ - event.error instanceof Error ? ` :: ${event.error.stack ?? event.error.message}` : '' + event.error instanceof Error + ? ` :: ${event.error.stack ?? event.error.message}` + : '' }`, ); }); @@ -232,7 +237,9 @@ window.addEventListener('error', (event) => { window.addEventListener('unhandledrejection', (event) => { log( `unhandledrejection: ${ - event.reason instanceof Error ? event.reason.stack ?? event.reason.message : String(event.reason) + event.reason instanceof Error + ? (event.reason.stack ?? event.reason.message) + : String(event.reason) }`, ); }); diff --git a/apps/demo/package.json b/apps/demo/package.json index fa6a096f..bf172f01 100644 --- a/apps/demo/package.json +++ b/apps/demo/package.json @@ -23,7 +23,7 @@ "@lezer-unofficial/printer": "^1.0.1", "@lezer/markdown": "^1.5.1", "@prosemark/core": "workspace:*", - "@prosemark/latex": "workspace:*", + "@prosemark/typst": "workspace:*", "@prosemark/paste-rich-text": "workspace:*", "@prosemark/render-html": "workspace:*", "@prosemark/spellcheck-frontend": "workspace:*", diff --git a/apps/demo/src/initDoc.md b/apps/demo/src/initDoc.md index 9721c5a3..e10a653f 100644 --- a/apps/demo/src/initDoc.md +++ b/apps/demo/src/initDoc.md @@ -43,14 +43,14 @@ export default function MyComponent() { Some centered text -### LaTeX math +### Typst math -Inline: Euler's identity $e^{i\pi} + 1 = 0$. +Inline: Euler's identity $e^(i pi) + 1 = 0$ (Typst math syntax inside `$…$`). Display: $$ -\int_{-\infty}^{\infty} e^{-x^2}\,dx = \sqrt{\pi} +integral_(-infinity)^infinity e^(-x^2) dif x = sqrt(pi) $$ ## Credits diff --git a/apps/demo/src/main.ts b/apps/demo/src/main.ts index 44e13d0c..768e1a15 100644 --- a/apps/demo/src/main.ts +++ b/apps/demo/src/main.ts @@ -14,9 +14,9 @@ import { renderHtmlMarkdownSyntaxExtensions, } from '@prosemark/render-html'; import { - latexMarkdownEditorExtensions, - latexMarkdownSyntaxTheme, -} from '@prosemark/latex'; + typstMarkdownEditorExtensions, + typstMarkdownSyntaxTheme, +} from '@prosemark/typst'; import { indentWithTab } from '@codemirror/commands'; import { GFM } from '@lezer/markdown'; import { indentUnit, syntaxTree } from '@codemirror/language'; @@ -47,8 +47,8 @@ const editor = new EditorView({ }), prosemarkBasicSetup(), prosemarkLightThemeSetup(), - ...latexMarkdownSyntaxTheme, - ...latexMarkdownEditorExtensions(), + ...typstMarkdownSyntaxTheme, + ...typstMarkdownEditorExtensions(), htmlBlockExtension, pasteRichTextExtension(), pastePlainTextExtension(), diff --git a/apps/docs/astro.config.ts b/apps/docs/astro.config.ts index 1792cfbc..2f6d2c70 100644 --- a/apps/docs/astro.config.ts +++ b/apps/docs/astro.config.ts @@ -1,5 +1,6 @@ import { defineConfig } from 'astro/config'; import starlight from '@astrojs/starlight'; +import { fileURLToPath } from 'node:url'; import { createStarlightTypeDocPlugin } from 'starlight-typedoc'; const buildTypeDocEntry = ( @@ -47,6 +48,7 @@ const spellcheckFrontend = buildTypeDocEntry( '../../packages/spellcheck-frontend', ); const latex = buildTypeDocEntry('@prosemark/latex', '../../packages/latex'); +const typst = buildTypeDocEntry('@prosemark/typst', '../../packages/typst'); // https://astro.build/config export default defineConfig({ @@ -57,6 +59,9 @@ export default defineConfig({ }, resolve: { alias: { + '@components': fileURLToPath( + new URL('./src/components', import.meta.url), + ), buffer: 'buffer', }, }, @@ -88,6 +93,7 @@ export default defineConfig({ pasteRichText.sidebarGroup, spellcheckFrontend.sidebarGroup, latex.sidebarGroup, + typst.sidebarGroup, ], }, ], @@ -97,6 +103,7 @@ export default defineConfig({ pasteRichText.plugin, spellcheckFrontend.plugin, latex.plugin, + typst.plugin, ], editLink: { diff --git a/apps/docs/package.json b/apps/docs/package.json index 2fccc90d..52225b70 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -17,6 +17,7 @@ "@lezer/markdown": "^1.5.1", "@prosemark/core": "workspace:*", "@prosemark/latex": "workspace:*", + "@prosemark/typst": "workspace:*", "@prosemark/paste-rich-text": "workspace:*", "@prosemark/render-html": "workspace:*", "@prosemark/spellcheck-frontend": "workspace:*", diff --git a/apps/docs/src/components/ProseMarkDemo.astro b/apps/docs/src/components/ProseMarkDemo.astro index c1e1593f..83f1ef60 100644 --- a/apps/docs/src/components/ProseMarkDemo.astro +++ b/apps/docs/src/components/ProseMarkDemo.astro @@ -1,5 +1,5 @@ --- - +import ProseMarkDemoSharedStyles from './ProseMarkDemoSharedStyles.astro'; ---
@@ -71,104 +71,4 @@ }); - + diff --git a/apps/docs/src/components/ProseMarkDemoSharedStyles.astro b/apps/docs/src/components/ProseMarkDemoSharedStyles.astro new file mode 100644 index 00000000..d1ddbe19 --- /dev/null +++ b/apps/docs/src/components/ProseMarkDemoSharedStyles.astro @@ -0,0 +1,111 @@ + diff --git a/apps/docs/src/components/ProseMarkTypstDemo.astro b/apps/docs/src/components/ProseMarkTypstDemo.astro new file mode 100644 index 00000000..4878dc3b --- /dev/null +++ b/apps/docs/src/components/ProseMarkTypstDemo.astro @@ -0,0 +1,74 @@ +--- +import ProseMarkDemoSharedStyles from './ProseMarkDemoSharedStyles.astro'; +--- + +
+
+
+ + + + diff --git a/apps/docs/src/components/initDoc.md b/apps/docs/src/components/initDoc.md index dab8d956..31c8bce3 100644 --- a/apps/docs/src/components/initDoc.md +++ b/apps/docs/src/components/initDoc.md @@ -51,6 +51,8 @@ $$ \int_{-\infty}^{\infty} e^{-x^2}\,dx = \sqrt{\pi} $$ +For Typst math instead of MathJax, try the [Typst math demo](/demo/typst). + ## Credits Made by Simon Richard (https://jsimonrichard.com) diff --git a/apps/docs/src/components/typstInitDoc.md b/apps/docs/src/components/typstInitDoc.md new file mode 100644 index 00000000..3d2cec91 --- /dev/null +++ b/apps/docs/src/components/typstInitDoc.md @@ -0,0 +1,19 @@ +# Typst math + +Live editor using `@prosemark/typst` (Typst WASM). For the main demo with MathJax / LaTeX math, see [Demo](/demo). + +Delimiters are the same as elsewhere (`$…$` / `$$…$$`), but the **math syntax is Typst**, not LaTeX. + +## Inline + +Euler's identity $e^(i pi) + 1 = 0$, a sum $sum_(k=1)^n k = (n(n+1))/2$, and Greek letters $alpha^2 + beta^2 = gamma^2$. + +## Display + +$$ +integral_(-infinity)^infinity e^(-x^2) dif x = sqrt(pi) +$$ + +$$ +mat(1, 2, 3; 4, 5, 6; 7, 8, 9) +$$ diff --git a/apps/docs/src/content/docs/demo.mdx b/apps/docs/src/content/docs/demo.mdx index 3da31876..7705bf47 100644 --- a/apps/docs/src/content/docs/demo.mdx +++ b/apps/docs/src/content/docs/demo.mdx @@ -3,6 +3,6 @@ title: Demo template: splash --- -import ProseMarkDemo from '../../components/ProseMarkDemo.astro'; +import ProseMarkDemo from '@components/ProseMarkDemo.astro'; diff --git a/apps/docs/src/content/docs/demo/typst.mdx b/apps/docs/src/content/docs/demo/typst.mdx new file mode 100644 index 00000000..597b6c43 --- /dev/null +++ b/apps/docs/src/content/docs/demo/typst.mdx @@ -0,0 +1,8 @@ +--- +title: Typst math demo +template: splash +--- + +import ProseMarkTypstDemo from '@components/ProseMarkTypstDemo.astro'; + + diff --git a/apps/docs/src/content/docs/index.mdx b/apps/docs/src/content/docs/index.mdx index 3cf29c20..ae9f3877 100644 --- a/apps/docs/src/content/docs/index.mdx +++ b/apps/docs/src/content/docs/index.mdx @@ -21,6 +21,6 @@ hero: icon: external --- -import LogoAttrib from '../../components/LogoAttrib.astro'; +import LogoAttrib from '@components/LogoAttrib.astro'; diff --git a/apps/docs/tsconfig.json b/apps/docs/tsconfig.json index 8bf91d3b..0db2ff93 100644 --- a/apps/docs/tsconfig.json +++ b/apps/docs/tsconfig.json @@ -1,5 +1,11 @@ { "extends": "astro/tsconfigs/strict", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@components/*": ["src/components/*"] + } + }, "include": [".astro/types.d.ts", "**/*"], "exclude": ["dist"] } diff --git a/bun.lock b/bun.lock index f6bec886..68ec6333 100644 --- a/bun.lock +++ b/bun.lock @@ -30,10 +30,10 @@ "@codemirror/view": "^6.39.4", "@lezer/markdown": "^1.6.1", "@prosemark/core": "workspace:*", - "@prosemark/latex": "workspace:*", "@prosemark/paste-rich-text": "workspace:*", "@prosemark/render-html": "workspace:*", "@prosemark/spellcheck-frontend": "workspace:*", + "@prosemark/typst": "workspace:*", "dictionary-en": "^3.2.0", "nspell": "^2.1.5", }, @@ -54,10 +54,10 @@ "@lezer-unofficial/printer": "^1.0.1", "@lezer/markdown": "^1.5.1", "@prosemark/core": "workspace:*", - "@prosemark/latex": "workspace:*", "@prosemark/paste-rich-text": "workspace:*", "@prosemark/render-html": "workspace:*", "@prosemark/spellcheck-frontend": "workspace:*", + "@prosemark/typst": "workspace:*", "codemirror": "^6.0.2", "dictionary-en": "^3.2.0", "nspell": "^2.1.5", @@ -88,6 +88,7 @@ "@prosemark/paste-rich-text": "workspace:*", "@prosemark/render-html": "workspace:*", "@prosemark/spellcheck-frontend": "workspace:*", + "@prosemark/typst": "workspace:*", "astro": "^5.6.1", "dictionary-en": "^3.2.0", "nspell": "^2.1.5", @@ -259,6 +260,30 @@ "@prosemark/core": "workspace:*", }, }, + "packages/typst": { + "name": "@prosemark/typst", + "version": "0.0.1", + "dependencies": { + "@codemirror/language": "^6.11.3", + "@codemirror/state": "^6.5.2", + "@codemirror/view": "^6.39.4", + "@lezer/common": "^1.5.2", + "@lezer/highlight": "^1.2.3", + "@lezer/markdown": "^1.6.3", + "@myriaddreamin/typst-ts-renderer": "0.7.0-rc2", + "@myriaddreamin/typst-ts-web-compiler": "0.7.0-rc2", + "@myriaddreamin/typst.ts": "0.7.0-rc2", + "@prosemark/core": "workspace:*", + }, + "devDependencies": { + "@eslint/js": "^9.39.2", + "@types/node": "^24.10.4", + "eslint": "^9.39.2", + "tsdown": "^0.15.6", + "typescript": "^5.9.3", + "typescript-eslint": "^8.50.0", + }, + }, "packages/vscode-extension-integrator": { "name": "@prosemark/vscode-extension-integrator", "version": "0.0.1", @@ -647,6 +672,12 @@ "@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="], + "@myriaddreamin/typst-ts-renderer": ["@myriaddreamin/typst-ts-renderer@0.7.0-rc2", "", {}, "sha512-god1tcb2YJDkQfA8gLGcAmykVGBpNKorqqDkXVy3InC18KRbsverJhlrHoONurNIU9JuIHoWjJ2D1ntpjPgzbA=="], + + "@myriaddreamin/typst-ts-web-compiler": ["@myriaddreamin/typst-ts-web-compiler@0.7.0-rc2", "", {}, "sha512-WFO/ecKUfeclld5uDxyjgpnIafKpp2LrS6T1vY+CHaSxCm099AneAQIYFg+OtX+NbFpJsLGCBFSw/qppJJmBAw=="], + + "@myriaddreamin/typst.ts": ["@myriaddreamin/typst.ts@0.7.0-rc2", "", { "dependencies": { "idb": "^7.1.1" }, "peerDependencies": { "@myriaddreamin/typst-ts-renderer": "^0.7.0-rc2", "@myriaddreamin/typst-ts-web-compiler": "^0.7.0-rc2" }, "optionalPeers": ["@myriaddreamin/typst-ts-renderer", "@myriaddreamin/typst-ts-web-compiler"] }, "sha512-VM8JqsRcL3AEJ5cuPBn/YvnGTXK/BRPlxdGB2bR48Of/8OIGaPiunv2QfZBIMBBrtbTygUOtAY9BZvkS1AFqgA=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], @@ -711,6 +742,8 @@ "@prosemark/spellcheck-frontend": ["@prosemark/spellcheck-frontend@workspace:packages/spellcheck-frontend"], + "@prosemark/typst": ["@prosemark/typst@workspace:packages/typst"], + "@prosemark/vscode-extension-integrator": ["@prosemark/vscode-extension-integrator@workspace:packages/vscode-extension-integrator"], "@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="], @@ -1461,6 +1494,8 @@ "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + "idb": ["idb@7.1.1", "", {}, "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ=="], + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], diff --git a/packages/latex/lib/main.ts b/packages/latex/lib/main.ts index be15f5a8..128040b5 100644 --- a/packages/latex/lib/main.ts +++ b/packages/latex/lib/main.ts @@ -331,12 +331,36 @@ const latexMathWidgetTheme = EditorView.theme({ display: 'inline-block', verticalAlign: 'middle', }, + // Starlight (and similar doc themes) set `.sl-markdown-content svg { display: block; + // height: auto }`, which breaks inline MathJax SVG layout inside the editor. + [`.${WIDGET_CLASS}[data-display="inline"] mjx-container`]: { + display: 'inline-block', + verticalAlign: 'middle', + maxWidth: 'none', + }, + [`.${WIDGET_CLASS}[data-display="inline"] mjx-container > svg`]: { + display: 'inline-block', + verticalAlign: 'middle', + height: 'unset', + maxWidth: 'none', + }, [`.${WIDGET_CLASS}[data-display="block"]`]: { display: 'block', textAlign: 'center', // Block widget docs: no vertical *margins* (they confuse layout); padding is OK. padding: '0.5em 0', }, + [`.${WIDGET_CLASS}[data-display="block"] mjx-container`]: { + display: 'block', + margin: '0 auto', + maxWidth: '100%', + }, + [`.${WIDGET_CLASS}[data-display="block"] mjx-container > svg`]: { + display: 'block', + margin: '0 auto', + height: 'unset', + maxWidth: '100%', + }, [`.${WIDGET_CLASS}-error`]: { color: '#b00020', fontFamily: 'monospace', diff --git a/packages/typst/.gitignore b/packages/typst/.gitignore new file mode 100644 index 00000000..de4d1f00 --- /dev/null +++ b/packages/typst/.gitignore @@ -0,0 +1,2 @@ +dist +node_modules diff --git a/packages/typst/README.md b/packages/typst/README.md new file mode 100644 index 00000000..48a5b0d1 --- /dev/null +++ b/packages/typst/README.md @@ -0,0 +1,84 @@ +# @prosemark/typst + +Typst math for ProseMark’s Markdown editor: `$...$` and `$$...$$`, rendered in the browser with [typst.ts](https://github.com/Myriad-Dreamin/typst.ts) ([`@myriaddreamin/typst.ts`](https://www.npmjs.com/package/@myriaddreamin/typst.ts)). + +The **`Math` / `MathMark` / `MathFormula`** Lezer nodes and **`mathMarkdownSyntaxExtension`** live in **`@prosemark/core`** and are part of **`prosemarkMarkdownSyntaxExtensions`**. This package adds typst.ts **widgets** and theme helpers; it **re-exports** the parser under **`typstMath*`** names if you only depend on `@prosemark/typst`. + +Body text inside the delimiters is passed to Typst as math: tight `$...$` uses inline math; display rules match `@prosemark/latex` (`$$...$$` or padded single-dollar → display). + +## Install + +```bash +bun add @prosemark/typst +``` + +**`@myriaddreamin/typst-ts-web-compiler`** and **`@myriaddreamin/typst-ts-renderer`** are **dependencies** (JS glue for typst.ts). By default, the **`.wasm`** binaries load from **jsDelivr** (pinned to the same version as this package’s typst.ts deps)—they are **not** bundled into your app. Override with **`compilerWasmUrl`** / **`rendererWasmUrl`** to self-host or use another CDN. + +## Usage + +```ts +import { markdown } from '@codemirror/lang-markdown'; +import { GFM } from '@lezer/markdown'; +import { prosemarkMarkdownSyntaxExtensions } from '@prosemark/core'; +import { + typstMarkdownSyntaxTheme, + typstMarkdownEditorExtensions, +} from '@prosemark/typst'; + +const extensions = [ + markdown({ + extensions: [GFM, prosemarkMarkdownSyntaxExtensions], + }), + ...typstMarkdownSyntaxTheme, + ...typstMarkdownEditorExtensions(), +]; +``` + +If you **do not** use `prosemarkMarkdownSyntaxExtensions`, add the parser from core (or the typst re-export): + +```ts +import { mathMarkdownSyntaxExtension } from '@prosemark/core'; +// or: import { typstMathMarkdownSyntaxExtension } from '@prosemark/typst'; +``` + +### Options + +```ts +import { + jsdelivrTypstWasmUrls, + typstMarkdownEditorExtensions, +} from '@prosemark/typst'; + +typstMarkdownEditorExtensions({ + renderCacheSize: 128, + ...jsdelivrTypstWasmUrls(), // default; explicit if you prefer +}); +``` + +To **bundle WASM with Vite** instead (e.g. offline), import the `.wasm` assets in your app and pass URLs: + +```ts +import compilerWasm from '@myriaddreamin/typst-ts-web-compiler/pkg/typst_ts_web_compiler_bg.wasm?url'; +import rendererWasm from '@myriaddreamin/typst-ts-renderer/pkg/typst_ts_renderer_bg.wasm?url'; + +typstMarkdownEditorExtensions({ + compilerWasmUrl: compilerWasm, + rendererWasmUrl: rendererWasm, +}); +``` + +## LaTeX vs Typst + +Do **not** enable **`latexMarkdownEditorExtensions`** and **`typstMarkdownEditorExtensions`** for the same editor: both replace **`Math`** nodes. Typst widgets use the class **`cm-typst-math`** (exported as **`typstMathWidgetClass`**); LaTeX/MathJax uses **`cm-latex-math`**. If you see `cm-latex-math` in the DOM, the editor is still loading **`@prosemark/latex`**, not this package. + +Try the live [Typst math demo](/demo/typst) on the docs site (the main [Demo](/demo) uses MathJax). + +## Limitations + +- **Browser only** — needs `window` and `document`. +- **WASM URLs** are fixed after the first successful init on the page. +- First load downloads WASM and font assets (typst.ts may fetch fonts from the network). + +### Block widgets and layout + +Block replace widgets avoid vertical **margins**; this package uses padding, **`requestMeasure`**, and **`ResizeObserver`** when available, matching the `@prosemark/latex` approach. diff --git a/packages/typst/eslint.config.ts b/packages/typst/eslint.config.ts new file mode 100644 index 00000000..53e7ef75 --- /dev/null +++ b/packages/typst/eslint.config.ts @@ -0,0 +1,3 @@ +import baseConfig from '../../eslint.config-base.ts'; + +export default baseConfig; diff --git a/packages/typst/lib/main.ts b/packages/typst/lib/main.ts new file mode 100644 index 00000000..1e28ee8d --- /dev/null +++ b/packages/typst/lib/main.ts @@ -0,0 +1,851 @@ +import { + Decoration, + EditorView, + ViewPlugin, + WidgetType, +} from '@codemirror/view'; +import { + foldableSyntaxFacet, + selectAllDecorationsOnSelectExtension, +} from '@prosemark/core'; +import type { EditorState, Extension } from '@codemirror/state'; +import { StateEffect, StateField } from '@codemirror/state'; +import type { SyntaxNodeRef } from '@lezer/common'; +import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'; +import { $typst } from '@myriaddreamin/typst.ts/contrib/snippet'; + +import { typstMathDelimiterTag, typstMathFormulaTag } from './markdown'; +import { + defaultCompilerWasmUrl, + defaultRendererWasmUrl, +} from './typstWasmUrls'; + +export { + defaultCompilerWasmUrl, + defaultRendererWasmUrl, + jsdelivrTypstWasmUrls, + TYPST_TS_VERSION, +} from './typstWasmUrls'; + +export { + typstMathDelimiterTag, + typstMathFormulaTag, + typstMathMarkdownSyntaxExtension, +} from './markdown'; + +/** CSS class on rendered math widget roots (span/div). */ +export const typstMathWidgetClass = 'cm-typst-math'; + +/** `data-typst-ink-fill` on rendered widget roots (resolved typst fill). */ +export const typstMathWidgetInkFillAttribute = 'data-typst-ink-fill'; + +/** Bump when widget DOM/debug attributes change (helps verify deploy cache). */ +const TYPST_MATH_WIDGET_VERSION = '5'; + +const WIDGET_CLASS = typstMathWidgetClass; +const WIDGET_ERROR_CLASS = `${WIDGET_CLASS}-error`; +const WIDGET_ERROR_MESSAGE_CLASS = `${WIDGET_CLASS}-error-message`; +const typstMathWidgetInkFillAttr = typstMathWidgetInkFillAttribute; + +export interface TypstMarkdownEditorOptions { + /** + * Max entries for the in-memory render cache (cloned SVG roots per hit). + * @default 128 + */ + renderCacheSize?: number; + /** + * URL for the web compiler `.wasm` (passed to typst.ts `getModule`). + * @default jsDelivr URL for `@myriaddreamin/typst-ts-web-compiler` (see {@link defaultCompilerWasmUrl}). + */ + compilerWasmUrl?: string; + /** + * URL for the renderer `.wasm` (passed to typst.ts `getModule`). + * @default jsDelivr URL for `@myriaddreamin/typst-ts-renderer` (see {@link defaultRendererWasmUrl}). + */ + rendererWasmUrl?: string; +} + +let typstInit: Promise | null = null; +let configuredCompilerUrl: string | null = null; +let configuredRendererUrl: string | null = null; + +const ensureTypst = ( + compilerWasmUrl: string, + rendererWasmUrl: string, +): Promise => { + if (typeof window === 'undefined' || typeof document === 'undefined') { + throw new Error( + '@prosemark/typst requires a browser environment (window/document).', + ); + } + + if ( + typstInit && + configuredCompilerUrl === compilerWasmUrl && + configuredRendererUrl === rendererWasmUrl + ) { + return typstInit; + } + + if ( + typstInit && + (configuredCompilerUrl !== compilerWasmUrl || + configuredRendererUrl !== rendererWasmUrl) + ) { + throw new Error( + 'Typst WASM URLs are fixed after the first load in this page.', + ); + } + + configuredCompilerUrl = compilerWasmUrl; + configuredRendererUrl = rendererWasmUrl; + typstInit = (async () => { + $typst.setCompilerInitOptions({ + getModule: () => compilerWasmUrl, + }); + $typst.setRendererInitOptions({ + getModule: () => rendererWasmUrl, + }); + await $typst.svg({ + mainContent: + '#set page(width: auto, height: auto, margin: 0pt, fill: none)\n$1$', + }); + })(); + + return typstInit; +}; + +/** Build a minimal Typst program that renders math in an auto-sized page. */ +const mathToTypstDocument = ( + body: string, + display: boolean, + inkFill: string, +): string => { + const src = body.trim(); + const page = + '#set page(width: auto, height: auto, margin: 0pt, fill: none)\n'; + const colorRule = `#show math.equation: set text(fill: ${inkFill})\n`; + if (display) { + return `${page}${colorRule}#align(center)[#block(inset: 4pt)[$ ${src} $]]`; + } + return `${page}${colorRule}$${src}$`; +}; + +/** Convert a resolved CSS color to a Typst `rgb("#…")` fill expression. */ +const cssColorToTypstFill = (cssColor: string): string => { + const trimmed = cssColor.trim(); + const rgbMatch = + /^rgba?\(\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)/.exec( + trimmed, + ); + if (rgbMatch) { + const parts = [rgbMatch[1], rgbMatch[2], rgbMatch[3]]; + if (parts.some((part) => part === undefined)) { + return 'rgb("#000000")'; + } + const hex = `#${parts + .map((part) => Math.round(Number(part)).toString(16).padStart(2, '0')) + .join('')}`; + return `rgb("${hex}")`; + } + if (trimmed.startsWith('#')) { + if (trimmed.length === 4) { + const r = trimmed[1]; + const g = trimmed[2]; + const b = trimmed[3]; + if (r && g && b) { + return `rgb("#${r}${r}${g}${g}${b}${b}")`; + } + } + return `rgb("${trimmed}")`; + } + return 'rgb("#000000")'; +}; + +const resolveEditorInkTypstFill = (view: EditorView): string => + cssColorToTypstFill(getComputedStyle(view.contentDOM).color); + +const typstInkFillEffect = StateEffect.define(); + +const typstInkFillField = StateField.define({ + create: () => 'rgb("#000000")', + update(value, tr) { + for (const effect of tr.effects) { + if (effect.is(typstInkFillEffect)) return effect.value; + } + return value; + }, +}); + +const typstInkFillSyncPlugin = ViewPlugin.fromClass( + class { + private inkFill = ''; + private observer: MutationObserver | undefined; + + constructor(private view: EditorView) { + this.inkFill = view.state.field(typstInkFillField); + this.sync(); + if (typeof MutationObserver !== 'undefined') { + this.observer = new MutationObserver(() => { + this.sync(); + }); + this.observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['data-theme', 'class', 'style'], + }); + } + } + + update(): void { + this.sync(); + } + + private sync(): void { + const next = resolveEditorInkTypstFill(this.view); + if (next === this.inkFill) return; + this.inkFill = next; + this.view.dispatch({ + effects: typstInkFillEffect.of(next), + selection: this.view.state.selection, + }); + } + + destroy(): void { + this.observer?.disconnect(); + } + }, +); + +interface RenderCacheEntry { + nodes: SVGSVGElement[]; +} + +class RenderLru { + private readonly max: number; + private readonly map = new Map(); + + constructor(max: number) { + this.max = max; + } + + get(key: string): SVGSVGElement[] | undefined { + const ent = this.map.get(key); + if (!ent) return undefined; + this.map.delete(key); + this.map.set(key, ent); + return ent.nodes; + } + + set(key: string, nodes: SVGSVGElement[]): void { + if (this.map.has(key)) this.map.delete(key); + this.map.set(key, { nodes }); + while (this.map.size > this.max) { + const iter = this.map.keys().next(); + if (iter.done) break; + this.map.delete(iter.value); + } + } +} + +let renderCache: RenderLru | null = null; + +const cacheKey = ( + compilerUrl: string, + rendererUrl: string, + display: boolean, + inkFill: string, + body: string, +): string => + `${compilerUrl}\n${rendererUrl}\n${display ? '1' : '0'}\n${inkFill}\n${body}`; + +/** typst.ts inline math uses ~8pt viewBoxes; baseline ≈ 7.513pt (empirical). */ +const TYPST_INLINE_MATH_VB_HEIGHT = 8; +const TYPST_INLINE_MATH_BASELINE_Y = TYPST_INLINE_MATH_VB_HEIGHT * (7.513 / 8); +const TYPST_INLINE_MATH_DESCENDER_PT = + TYPST_INLINE_MATH_VB_HEIGHT - TYPST_INLINE_MATH_BASELINE_Y; +/** typst SVG user units are pt; typst body text is ~12pt per em. */ +const TYPST_SVG_PT_PER_EM = 12; +/** Padding in typst pt units when expanding viewBox to ink bounds. */ +const INLINE_TYPST_MATH_INK_PADDING_PT = 0.35; + +const forceInlineSvgDisplay = (svg: SVGSVGElement): void => { + svg.style.display = 'inline'; + svg.querySelectorAll('svg').forEach((node) => { + node.style.display = 'inline'; + }); +}; + +const parseTranslateY = (transform: string): number | null => { + const re = /translate\s*\(\s*[^,\s)]+(?:\s*,\s*|\s+)(-?\d+(?:\.\d+)?)/; + const match = re.exec(transform); + const y = match?.[1]; + return y !== undefined ? Number.parseFloat(y) : null; +}; + +const collectTypstGlyphBaselineCandidates = (svg: SVGSVGElement): number[] => { + const ys: number[] = []; + svg.querySelectorAll('[transform]').forEach((el) => { + const y = parseTranslateY(el.getAttribute('transform') ?? ''); + if (y !== null && y > 0) ys.push(y); + }); + return ys; +}; + +const typstMathBaselineY = (svg: SVGSVGElement): number => { + const target = TYPST_INLINE_MATH_BASELINE_Y; + const candidates = collectTypstGlyphBaselineCandidates(svg); + if (candidates.length === 0) { + try { + const bbox = svg.getBBox(); + return bbox.y + bbox.height - TYPST_INLINE_MATH_DESCENDER_PT; + } catch { + return target; + } + } + + const best = candidates.reduce((chosen, y) => { + const chosenDist = Math.abs(chosen - target); + const yDist = Math.abs(y - target); + return yDist < chosenDist ? y : chosen; + }); + const bestDist = Math.abs(best - target); + + if (bestDist > 1.2) { + try { + const bbox = svg.getBBox(); + return bbox.y + bbox.height / 2; + } catch { + /* keep transform-based estimate */ + } + } + + return best; +}; + +/** + * typst.ts viewBoxes are often shorter than the ink (subscripts, fractions). + * Grow the viewBox to getBBox so line layout reserves enough vertical space. + */ +const expandTypstSvgViewBoxToInk = (svg: SVGSVGElement): void => { + let bbox: DOMRect; + try { + bbox = svg.getBBox(); + } catch { + return; + } + if (bbox.width === 0 && bbox.height === 0) return; + + const vb = svg.viewBox.baseVal; + const pad = INLINE_TYPST_MATH_INK_PADDING_PT; + const x = Math.min(vb.x, bbox.x - pad); + const y = Math.min(vb.y, bbox.y - pad); + const right = Math.max(vb.x + vb.width, bbox.x + bbox.width + pad); + const bottom = Math.max(vb.y + vb.height, bbox.y + bbox.height + pad); + const width = right - x; + const height = bottom - y; + if (width <= 0 || height <= 0) return; + + svg.setAttribute( + 'viewBox', + `${String(x)} ${String(y)} ${String(width)} ${String(height)}`, + ); + svg.removeAttribute('width'); + svg.removeAttribute('height'); +}; + +const typstSvgHeightEm = (viewHeight: number): number => + viewHeight / TYPST_SVG_PT_PER_EM; + +/** + * Inline SVG baselines default to the viewport bottom; typst math sits higher. + * Shift each fragment down so the typst baseline meets surrounding text. + */ +const alignInlineTypstSvgBaseline = (svg: SVGSVGElement): void => { + expandTypstSvgViewBoxToInk(svg); + + const vb = svg.viewBox.baseVal; + if (!vb.height) return; + + const baselineY = typstMathBaselineY(svg); + const slackBelowBaseline = vb.y + vb.height - baselineY; + const heightEm = typstSvgHeightEm(vb.height); + const offsetEm = (slackBelowBaseline / vb.height) * heightEm; + + svg.style.height = `${heightEm.toFixed(4)}em`; + svg.style.width = 'auto'; + svg.style.verticalAlign = `-${offsetEm.toFixed(4)}em`; +}; + +/** Scale display math to em units (typst width/height attrs are too small in CSS). */ +const sizeDisplayTypstSvg = (svg: SVGSVGElement): void => { + expandTypstSvgViewBoxToInk(svg); + + const vb = svg.viewBox.baseVal; + if (!vb.height) return; + + svg.style.display = 'block'; + svg.style.margin = '0 auto'; + svg.style.height = `${typstSvgHeightEm(vb.height).toFixed(4)}em`; + svg.style.width = 'auto'; + svg.removeAttribute('width'); + svg.removeAttribute('height'); +}; + +const alignInlineTypstWidgetBaselines = (wrap: HTMLElement): void => { + wrap.style.verticalAlign = 'baseline'; + wrap.querySelectorAll('svg').forEach((node) => { + if (node instanceof SVGSVGElement) { + alignInlineTypstSvgBaseline(node); + } + }); +}; + +const sizeDisplayTypstWidgetSvgs = (wrap: HTMLElement): void => { + wrap.querySelectorAll('svg').forEach((node) => { + if (node instanceof SVGSVGElement) { + sizeDisplayTypstSvg(node); + } + }); +}; + +const isBlackInk = (value: string): boolean => { + const v = value.trim().toLowerCase().replace(/\s/g, ''); + return ( + v === '#000' || + v === '#000000' || + v === 'black' || + v === 'rgb(0,0,0)' || + v === 'rgb(0%,0%,0%)' + ); +}; + +/** Fallback when typst still emits #000 (e.g. before theme color is resolved). */ +const applyCurrentColorToTypstSvg = (svg: SVGSVGElement): void => { + svg.style.color = 'inherit'; + for (const attr of ['fill', 'stroke'] as const) { + svg.querySelectorAll(`[${attr}]`).forEach((el) => { + const value = el.getAttribute(attr); + if (value && value !== 'none' && isBlackInk(value)) { + el.setAttribute(attr, 'currentColor'); + } + }); + } +}; + +/** + * typst.ts SVG includes selection overlays (`foreignObject` / `.tsel`) whose + * embedded CSS uses `position: fixed`, which breaks inline math in CodeMirror. + * Keep vector glyphs (`use` + `defs`) only. + */ +const prepareTypstSvgForWidget = ( + svg: SVGSVGElement, + inline: boolean, +): SVGSVGElement => { + svg.querySelectorAll('script').forEach((node) => { + node.remove(); + }); + svg.querySelectorAll('foreignObject').forEach((node) => { + node.remove(); + }); + svg.querySelectorAll('style').forEach((node) => { + node.remove(); + }); + svg.style.overflow = 'visible'; + if (inline) { + forceInlineSvgDisplay(svg); + } + applyCurrentColorToTypstSvg(svg); + return svg; +}; + +/** typst.ts may return one SVG document or several sibling `` fragments. */ +const typstSvgStringToElements = ( + raw: string, + inline: boolean, +): SVGSVGElement[] => { + const trimmed = raw.trim(); + const svgTagCount = trimmed.match(/]/gi)?.length ?? 0; + + if (svgTagCount <= 1) { + const doc = new DOMParser().parseFromString(trimmed, 'image/svg+xml'); + const el = doc.documentElement; + if ( + !doc.querySelector('parsererror') && + el.namespaceURI === 'http://www.w3.org/2000/svg' && + el.nodeName === 'svg' + ) { + return [prepareTypstSvgForWidget(el as unknown as SVGSVGElement, inline)]; + } + } + + const holder = document.createElement('div'); + holder.innerHTML = trimmed; + const roots = [...holder.children].filter( + (el): el is SVGSVGElement => el.tagName === 'svg', + ); + if (roots.length === 0) { + throw new Error('Typst did not return any element'); + } + return roots.map((el) => prepareTypstSvgForWidget(el, inline)); +}; + +const cloneSvgNodes = (nodes: SVGSVGElement[]): SVGSVGElement[] => + nodes.map((node) => node.cloneNode(true) as SVGSVGElement); + +const typstSvgRenderOptions = { + data_selection: { + body: true, + defs: true, + css: false, + js: false, + }, +} as const; + +const renderOrCloneFromCache = async ( + body: string, + display: boolean, + inkFill: string, + compilerUrl: string, + rendererUrl: string, +): Promise => { + const key = cacheKey(compilerUrl, rendererUrl, display, inkFill, body); + const cached = renderCache?.get(key); + if (cached) { + return cloneSvgNodes(cached); + } + + const svg = await $typst.svg({ + mainContent: mathToTypstDocument(body, display, inkFill), + ...typstSvgRenderOptions, + }); + const nodes = typstSvgStringToElements(svg, !display); + renderCache?.set(key, nodes); + return cloneSvgNodes(nodes); +}; + +const parseTypstDiagnosticMessages = (raw: string): string[] => { + const messages: string[] = []; + const messageRe = /message:\s*"((?:\\.|[^"\\])*)"/g; + for (let match = messageRe.exec(raw); match; match = messageRe.exec(raw)) { + const text = match[1]; + if (text !== undefined) { + messages.push(text.replace(/\\"/g, '"').replace(/\\\\/g, '\\')); + } + } + return messages; +}; + +/** + * Normalizes typst.ts / WASM failures into a single user-visible message. + * + * @internal Exported for unit tests. + */ +export function formatTypstRenderError(err: unknown): string { + const formatRaw = (raw: string): string | null => { + const parsed = parseTypstDiagnosticMessages(raw); + if (parsed.length > 0) return parsed.join('; '); + const trimmed = raw.trim(); + if (!trimmed) return null; + if (trimmed.startsWith('[') && trimmed.endsWith(']')) { + return 'Typst render failed'; + } + return trimmed; + }; + + if (err instanceof Error) { + return formatRaw(err.message) ?? 'Typst render failed'; + } + if (typeof err === 'string') { + return formatRaw(err) ?? 'Typst render failed'; + } + if (err && typeof err === 'object' && 'message' in err) { + const raw = (err as { message?: unknown }).message; + const str = typeof raw === 'string' ? raw : String(raw); + return formatRaw(str) ?? 'Typst render failed'; + } + return 'Typst render failed'; +} + +/** Populates a math widget with the render error message. */ +const populateTypstMathErrorDom = ( + wrap: HTMLElement, + err: unknown, + display: boolean, +): void => { + const message = formatTypstRenderError(err); + + const messageEl = document.createElement(display ? 'div' : 'span'); + messageEl.className = WIDGET_ERROR_MESSAGE_CLASS; + messageEl.setAttribute('role', 'alert'); + messageEl.textContent = message; + + wrap.replaceChildren(messageEl); + wrap.classList.add(WIDGET_ERROR_CLASS); + wrap.setAttribute('title', message); +}; + +const clearTypstMathWidgetError = (wrap: HTMLElement): void => { + wrap.classList.remove(WIDGET_ERROR_CLASS); + wrap.removeAttribute('title'); +}; + +const blockMathEstimatedHeightPx = 72; + +const typstWidgetResizeObservers = new WeakMap(); + +class TypstMathWidget extends WidgetType { + constructor( + public readonly body: string, + public readonly display: boolean, + public readonly inkFill: string, + public readonly compilerWasmUrl: string, + public readonly rendererWasmUrl: string, + ) { + super(); + } + + eq(other: TypstMathWidget): boolean { + return ( + this.body === other.body && + this.display === other.display && + this.inkFill === other.inkFill && + this.compilerWasmUrl === other.compilerWasmUrl && + this.rendererWasmUrl === other.rendererWasmUrl + ); + } + + get estimatedHeight(): number { + return this.display ? blockMathEstimatedHeightPx : -1; + } + + toDOM(view: EditorView): HTMLElement { + const wrap = document.createElement(this.display ? 'div' : 'span'); + wrap.className = WIDGET_CLASS; + wrap.setAttribute('data-typst-math', this.body); + wrap.setAttribute('data-display', this.display ? 'block' : 'inline'); + + if (typeof ResizeObserver !== 'undefined') { + const ro = new ResizeObserver(() => { + view.requestMeasure(); + }); + ro.observe(wrap); + typstWidgetResizeObservers.set(wrap, ro); + } + + wrap.setAttribute('data-typst-widget-version', TYPST_MATH_WIDGET_VERSION); + const inkFill = resolveEditorInkTypstFill(view); + wrap.setAttribute(typstMathWidgetInkFillAttr, inkFill); + + const renderGeneration = String(Math.random()); + wrap.dataset['typstRenderGeneration'] = renderGeneration; + + void ensureTypst(this.compilerWasmUrl, this.rendererWasmUrl) + .then(() => + renderOrCloneFromCache( + this.body, + this.display, + inkFill, + this.compilerWasmUrl, + this.rendererWasmUrl, + ), + ) + .then((nodes) => { + if ( + !wrap.isConnected || + wrap.dataset['typstRenderGeneration'] !== renderGeneration + ) { + return; + } + clearTypstMathWidgetError(wrap); + wrap.setAttribute(typstMathWidgetInkFillAttr, inkFill); + wrap.replaceChildren(...nodes); + if (this.display) { + sizeDisplayTypstWidgetSvgs(wrap); + } else { + alignInlineTypstWidgetBaselines(wrap); + } + view.requestMeasure(); + }) + .catch((err: unknown) => { + if ( + !wrap.isConnected || + wrap.dataset['typstRenderGeneration'] !== renderGeneration + ) { + return; + } + populateTypstMathErrorDom(wrap, err, this.display); + view.requestMeasure(); + }); + + return wrap; + } + + ignoreEvent(): boolean { + return false; + } + + destroy(dom: HTMLElement): void { + typstWidgetResizeObservers.get(dom)?.disconnect(); + typstWidgetResizeObservers.delete(dom); + dom.remove(); + } +} + +const typstMathSourceTheme = EditorView.theme({ + '.cm-typst-math-delimiter': { + color: 'var(--pm-typst-math-delimiter-color, var(--pm-link-color))', + }, + '.cm-typst-math-formula': { + color: 'var(--pm-typst-math-formula-color, inherit)', + fontFamily: `var( + --pm-typst-math-formula-font, + var( + --pm-code-font, + ui-monospace, + SFMono-Regular, + Menlo, + Monaco, + Consolas, + 'Liberation Mono', + 'Courier New', + monospace + ) + )`, + fontSize: '0.92em', + }, +}); + +export const typstMathSyntaxHighlighting = syntaxHighlighting( + HighlightStyle.define([ + { + tag: typstMathDelimiterTag, + class: 'cm-typst-math-delimiter', + }, + { + tag: typstMathFormulaTag, + class: 'cm-typst-math-formula', + }, + ]), +); + +const typstMathWidgetTheme = EditorView.theme({ + [`.${WIDGET_CLASS}:not(.${WIDGET_ERROR_CLASS})`]: { + display: 'inline-block', + verticalAlign: 'baseline', + maxWidth: '100%', + color: 'inherit', + }, + // typst.ts may emit several sibling/nested nodes; they default to block + // and stack vertically unless forced inline (block math centers via text-align). + [`.${WIDGET_CLASS}:not(.${WIDGET_ERROR_CLASS}) svg`]: { + maxWidth: '100%', + color: 'inherit', + }, + [`.${WIDGET_CLASS}[data-display="inline"]:not(.${WIDGET_ERROR_CLASS}) svg`]: { + display: 'inline', + verticalAlign: 'baseline', + width: 'auto', + }, + [`.${WIDGET_CLASS}[data-display="block"]:not(.${WIDGET_ERROR_CLASS}) svg`]: { + display: 'block', + margin: '0 auto', + width: 'auto', + }, + [`.${WIDGET_CLASS}[data-display="block"]:not(.${WIDGET_ERROR_CLASS})`]: { + display: 'block', + textAlign: 'center', + padding: '0.5em 0', + }, + [`.${WIDGET_ERROR_CLASS}`]: { + color: + 'var(--pm-typst-math-error-color, var(--pm-syntax-invalid, #c62828))', + backgroundColor: + 'var(--pm-typst-math-error-background-color, rgb(128 128 128 / 0.12))', + fontFamily: `var( + --pm-typst-math-formula-font, + var( + --pm-code-font, + ui-monospace, + SFMono-Regular, + Menlo, + Monaco, + Consolas, + 'Liberation Mono', + 'Courier New', + monospace + ) + )`, + borderRadius: '0.4rem', + padding: '0.2rem', + maxWidth: '100%', + boxSizing: 'border-box', + }, + [`.${WIDGET_CLASS}[data-display="block"].${WIDGET_ERROR_CLASS}`]: { + display: 'block', + textAlign: 'left', + padding: '0.5em 0.2rem', + }, + [`.${WIDGET_ERROR_CLASS}[data-display="inline"]`]: { + display: 'inline', + verticalAlign: 'baseline', + }, + [`.${WIDGET_ERROR_MESSAGE_CLASS}`]: { + fontSize: '0.85em', + lineHeight: 1.35, + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + }, + [`.${WIDGET_ERROR_CLASS}[data-display="inline"] .${WIDGET_ERROR_MESSAGE_CLASS}`]: + { + display: 'inline', + }, +}); + +export function typstMarkdownEditorExtensions( + options: TypstMarkdownEditorOptions = {}, +): Extension[] { + const compilerWasmUrl = options.compilerWasmUrl ?? defaultCompilerWasmUrl(); + const rendererWasmUrl = options.rendererWasmUrl ?? defaultRendererWasmUrl(); + const cacheSize = options.renderCacheSize ?? 128; + renderCache = cacheSize > 0 ? new RenderLru(cacheSize) : null; + + return [ + typstInkFillField, + typstInkFillSyncPlugin, + foldableSyntaxFacet.of({ + nodePath: 'Math', + buildDecorations: (state: EditorState, node: SyntaxNodeRef) => { + const opensDouble = + state.doc.sliceString(node.from, node.from + 2) === '$$'; + const innerFrom = opensDouble ? node.from + 2 : node.from + 1; + const innerTo = opensDouble ? node.to - 2 : node.to - 1; + const rawBody = state.doc.sliceString(innerFrom, innerTo); + const body = rawBody.trim(); + if (!body) return; + + const display = opensDouble || /^\s|\s$/.test(rawBody); + const inkFill = state.field(typstInkFillField); + + return Decoration.replace({ + widget: new TypstMathWidget( + body, + display, + inkFill, + compilerWasmUrl, + rendererWasmUrl, + ), + block: display, + inclusive: true, + proseMarkSkipAdjacentArrowReveal: true, + }).range(node.from, node.to); + }, + }), + typstMathWidgetTheme, + selectAllDecorationsOnSelectExtension(WIDGET_CLASS), + ]; +} + +export const typstMarkdownSyntaxTheme: Extension[] = [ + typstMathSyntaxHighlighting, + typstMathSourceTheme, +]; diff --git a/packages/typst/lib/markdown.ts b/packages/typst/lib/markdown.ts new file mode 100644 index 00000000..6c256a10 --- /dev/null +++ b/packages/typst/lib/markdown.ts @@ -0,0 +1,10 @@ +/** + * Math delimiters live in `@prosemark/core` as `Math` / `MathMark` / `MathFormula` + * and are included in `prosemarkMarkdownSyntaxExtensions`. Re-export under + * `typstMath*` names for consumers who only install `@prosemark/typst`. + */ +export { + mathDelimiterTag as typstMathDelimiterTag, + mathFormulaTag as typstMathFormulaTag, + mathMarkdownSyntaxExtension as typstMathMarkdownSyntaxExtension, +} from '@prosemark/core'; diff --git a/packages/typst/lib/typstWasmUrls.ts b/packages/typst/lib/typstWasmUrls.ts new file mode 100644 index 00000000..24208621 --- /dev/null +++ b/packages/typst/lib/typstWasmUrls.ts @@ -0,0 +1,28 @@ +/** Keep in sync with `@myriaddreamin/typst-ts-*` in `package.json`. */ +export const TYPST_TS_VERSION = '0.7.0-rc2'; + +const jsdelivrWasmUrl = (packageName: string, file: string): string => + `https://cdn.jsdelivr.net/npm/${packageName}@${TYPST_TS_VERSION}/${file}`; + +/** Default compiler `.wasm` URL (jsDelivr, pinned to {@link TYPST_TS_VERSION}). */ +export const defaultCompilerWasmUrl = (): string => + jsdelivrWasmUrl( + '@myriaddreamin/typst-ts-web-compiler', + 'pkg/typst_ts_web_compiler_bg.wasm', + ); + +/** Default renderer `.wasm` URL (jsDelivr, pinned to {@link TYPST_TS_VERSION}). */ +export const defaultRendererWasmUrl = (): string => + jsdelivrWasmUrl( + '@myriaddreamin/typst-ts-renderer', + 'pkg/typst_ts_renderer_bg.wasm', + ); + +/** jsDelivr WASM URLs for {@link typstMarkdownEditorExtensions} options. */ +export const jsdelivrTypstWasmUrls = (): { + compilerWasmUrl: string; + rendererWasmUrl: string; +} => ({ + compilerWasmUrl: defaultCompilerWasmUrl(), + rendererWasmUrl: defaultRendererWasmUrl(), +}); diff --git a/packages/typst/lib/wasm.d.ts b/packages/typst/lib/wasm.d.ts new file mode 100644 index 00000000..fd0c72e6 --- /dev/null +++ b/packages/typst/lib/wasm.d.ts @@ -0,0 +1,9 @@ +declare module '@myriaddreamin/typst-ts-web-compiler/pkg/typst_ts_web_compiler_bg.wasm?url' { + const url: string; + export default url; +} + +declare module '@myriaddreamin/typst-ts-renderer/pkg/typst_ts_renderer_bg.wasm?url' { + const url: string; + export default url; +} diff --git a/packages/typst/package.json b/packages/typst/package.json new file mode 100644 index 00000000..25c8eda9 --- /dev/null +++ b/packages/typst/package.json @@ -0,0 +1,59 @@ +{ + "name": "@prosemark/typst", + "version": "0.0.1", + "repository": { + "url": "https://github.com/jsimonrichard/ProseMark", + "type": "github" + }, + "author": { + "name": "J. Simon Richard", + "url": "https://jsimonrichard.com" + }, + "license": "MIT", + "type": "module", + "files": [ + "dist" + ], + "main": "./dist/main.js", + "module": "./dist/main.js", + "exports": { + ".": { + "types": "./dist/main.d.ts", + "import": "./dist/main.js" + } + }, + "scripts": { + "dev": "tsdown --watch", + "build": "tsdown", + "lint": "eslint --max-warnings=0", + "check-types": "tsc", + "ci:publish": "bash ../../scripts/ci-publish.sh" + }, + "keywords": [ + "codemirror", + "markdown", + "typst", + "math", + "typst.ts" + ], + "dependencies": { + "@codemirror/language": "^6.11.3", + "@codemirror/state": "^6.5.2", + "@codemirror/view": "^6.39.4", + "@lezer/common": "^1.5.2", + "@lezer/highlight": "^1.2.3", + "@lezer/markdown": "^1.6.3", + "@myriaddreamin/typst-ts-renderer": "0.7.0-rc2", + "@myriaddreamin/typst-ts-web-compiler": "0.7.0-rc2", + "@myriaddreamin/typst.ts": "0.7.0-rc2", + "@prosemark/core": "workspace:*" + }, + "devDependencies": { + "@eslint/js": "^9.39.2", + "@types/node": "^24.10.4", + "eslint": "^9.39.2", + "tsdown": "^0.15.6", + "typescript": "^5.9.3", + "typescript-eslint": "^8.50.0" + } +} diff --git a/packages/typst/tsconfig.json b/packages/typst/tsconfig.json new file mode 100644 index 00000000..76e6a7dd --- /dev/null +++ b/packages/typst/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "sourceRoot": "./lib", + "outDir": "./dist" + }, + "include": ["lib", "*.config.ts"] +} diff --git a/packages/typst/tsdown.config.ts b/packages/typst/tsdown.config.ts new file mode 100644 index 00000000..b73fe75e --- /dev/null +++ b/packages/typst/tsdown.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + entry: 'lib/main.ts', + dts: true, + sourcemap: true, +});