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 `