From bf19e982fe6692143e1935cc538044a94c37a789 Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Fri, 15 May 2026 15:38:48 +0200 Subject: [PATCH] feat(markdown): render TikZ fenced blocks to SVG Adds a markdown-it extractor for ```tikz blocks that emits a sanitized placeholder, plus a runTikz() that lazy-loads the obsidian-tikzjax 0.5.2 bundle from jsdelivr on first encounter, sanitizes the produced SVG with DOMPurify, and caches it in PouchDB keyed by sha256(source) so repeat views are instant and offline-capable. --- package.json | 1 + pnpm-lock.yaml | 186 ++---------------------- src/components/FluxNote.vue | 3 +- src/components/StackedNote.vue | 5 + src/components/StackedPublicNote.vue | 3 +- src/data/DataType.enum.ts | 3 +- src/data/models/TikzCache.ts | 6 + src/hooks/useMarkdown.hook.ts | 203 ++++++++++++++++++++++++++- src/styles/app.css | 30 ++++ src/views/PublicNoteView.vue | 3 +- 10 files changed, 265 insertions(+), 178 deletions(-) create mode 100644 src/data/models/TikzCache.ts diff --git a/package.json b/package.json index 2dc3295..35c2702 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "arktype": "^2.1.29", "comlink": "^4.4.2", "date-fns": "^4.1.0", + "dompurify": "^3.4.3", "events": "^3.3.0", "font-color-contrast": "^11.1.0", "fontfaceobserver": "^2.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 36c4c19..95f2e50 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,6 +68,9 @@ importers: date-fns: specifier: ^4.1.0 version: 4.1.0 + dompurify: + specifier: ^3.4.3 + version: 3.4.3 events: specifier: ^3.3.0 version: 3.3.0 @@ -200,7 +203,7 @@ importers: version: 12.1.1(eslint@8.57.1) eslint-plugin-unused-imports: specifier: ^4.4.1 - version: 4.4.1(@typescript-eslint/eslint-plugin@8.46.2(@typescript-eslint/parser@8.46.2(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1) + version: 4.4.1(eslint@8.57.1) esno: specifier: ^4.8.0 version: 4.8.0 @@ -2337,97 +2340,39 @@ packages: '@types/web-bluetooth@0.0.21': resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} - '@typescript-eslint/eslint-plugin@8.46.2': - resolution: {integrity: sha512-ZGBMToy857/NIPaaCucIUQgqueOiq7HeAKkhlvqVV4lm089zUFW6ikRySx2v+cAhKeUCPuWVHeimyk6Dw1iY3w==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - '@typescript-eslint/parser': ^8.46.2 - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/parser@8.46.2': - resolution: {integrity: sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.38.0': resolution: {integrity: sha512-dbK7Jvqcb8c9QfH01YB6pORpqX1mn5gDZc9n63Ak/+jD67oWXn3Gs0M6vddAN+eDXBCS5EmNWzbSxsn9SzFWWg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <5.9.0' - '@typescript-eslint/project-service@8.46.2': - resolution: {integrity: sha512-PULOLZ9iqwI7hXcmL4fVfIsBi6AN9YxRc0frbvmg8f+4hQAjQ5GYNKK0DIArNo+rOKmR/iBYwkpBmnIwin4wBg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/scope-manager@8.38.0': resolution: {integrity: sha512-WJw3AVlFFcdT9Ri1xs/lg8LwDqgekWXWhH3iAF+1ZM+QPd7oxQ6jvtW/JPwzAScxitILUIFs0/AnQ/UWHzbATQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/scope-manager@8.46.2': - resolution: {integrity: sha512-LF4b/NmGvdWEHD2H4MsHD8ny6JpiVNDzrSZr3CsckEgCbAGZbYM4Cqxvi9L+WqDMT+51Ozy7lt2M+d0JLEuBqA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.38.0': resolution: {integrity: sha512-Lum9RtSE3EroKk/bYns+sPOodqb2Fv50XOl/gMviMKNvanETUuUcC9ObRbzrJ4VSd2JalPqgSAavwrPiPvnAiQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <5.9.0' - '@typescript-eslint/tsconfig-utils@8.46.2': - resolution: {integrity: sha512-a7QH6fw4S57+F5y2FIxxSDyi5M4UfGF+Jl1bCGd7+L4KsaUY80GsiF/t0UoRFDHAguKlBaACWJRmdrc6Xfkkag==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/type-utils@8.46.2': - resolution: {integrity: sha512-HbPM4LbaAAt/DjxXaG9yiS9brOOz6fabal4uvUmaUYe6l3K1phQDMQKBRUrr06BQkxkvIZVVHttqiybM9nJsLA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/types@8.38.0': resolution: {integrity: sha512-wzkUfX3plUqij4YwWaJyqhiPE5UCRVlFpKn1oCRn2O1bJ592XxWJj8ROQ3JD5MYXLORW84063z3tZTb/cs4Tyw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/types@8.46.2': - resolution: {integrity: sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.38.0': resolution: {integrity: sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <5.9.0' - '@typescript-eslint/typescript-estree@8.46.2': - resolution: {integrity: sha512-f7rW7LJ2b7Uh2EiQ+7sza6RDZnajbNbemn54Ob6fRwQbgcIn+GWfyuHDHRYgRoZu1P4AayVScrRW+YfbTvPQoQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/utils@8.46.2': - resolution: {integrity: sha512-sExxzucx0Tud5tE0XqR0lT0psBQvEpnpiul9XbGUB1QwpWJJAps1O/Z7hJxLGiZLBKMCutjTzDgmd1muEhBnVg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/visitor-keys@8.38.0': resolution: {integrity: sha512-pWrTcoFNWuwHlA9CvlfSsGWs14JxfN1TH25zM5L7o0pRLhsoZkDnTsXfQRJBEWJoV5DL0jf+Z+sxiud+K0mq1g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/visitor-keys@8.46.2': - resolution: {integrity: sha512-tUFMXI4gxzzMXt4xpGJEsBsTox0XbNQ1y94EwlD/CuZwFcQP79xfQqMhau9HsRc/J0cAPA/HZt1dZPtGn9V/7w==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@ungap/structured-clone@1.2.0': resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@vite-pwa/assets-generator@1.0.2': resolution: {integrity: sha512-MCbrb508JZHqe7bUibmZj/lyojdhLRnfkmyXnkrCM2zVrjTgL89U8UEfInpKTvPeTnxsw2hmyZxnhsdNR6yhwg==} @@ -3447,6 +3392,9 @@ packages: dompurify@3.2.6: resolution: {integrity: sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==} + dompurify@3.4.3: + resolution: {integrity: sha512-VVwJidIJcp1hpg2OMXML3ZVRPYSZiq4aX7qBh83BSIpOaRDqI+qxhXjjIWnpzkOXhmp0L81lnoME1mnCc9H48A==} + domutils@3.1.0: resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==} @@ -4067,10 +4015,6 @@ packages: resolution: {integrity: sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==} engines: {node: '>= 4'} - ignore@7.0.4: - resolution: {integrity: sha512-gJzzk+PQNznz8ysRrC0aOkBNVRBDtE1n53IqyqEf3PXrYwomFs5q4pGMizBMJF+ykh03insJ27hB8gSrD2Hn8A==} - engines: {node: '>= 4'} - immutable@5.1.5: resolution: {integrity: sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==} @@ -8688,37 +8632,6 @@ snapshots: '@types/web-bluetooth@0.0.21': {} - '@typescript-eslint/eslint-plugin@8.46.2(@typescript-eslint/parser@8.46.2(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)': - dependencies: - '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 8.46.2(eslint@8.57.1)(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.46.2 - '@typescript-eslint/type-utils': 8.46.2(eslint@8.57.1)(typescript@5.9.3) - '@typescript-eslint/utils': 8.46.2(eslint@8.57.1)(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.46.2 - eslint: 8.57.1 - graphemer: 1.4.0 - ignore: 7.0.4 - natural-compare: 1.4.0 - ts-api-utils: 2.1.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - optional: true - - '@typescript-eslint/parser@8.46.2(eslint@8.57.1)(typescript@5.9.3)': - dependencies: - '@typescript-eslint/scope-manager': 8.46.2 - '@typescript-eslint/types': 8.46.2 - '@typescript-eslint/typescript-estree': 8.46.2(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.46.2 - debug: 4.4.3 - eslint: 8.57.1 - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - optional: true - '@typescript-eslint/project-service@8.38.0(typescript@5.9.3)': dependencies: '@typescript-eslint/tsconfig-utils': 8.38.0(typescript@5.9.3) @@ -8728,54 +8641,17 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.46.2(typescript@5.9.3)': - dependencies: - '@typescript-eslint/tsconfig-utils': 8.46.2(typescript@5.9.3) - '@typescript-eslint/types': 8.46.2 - debug: 4.4.3 - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - optional: true - '@typescript-eslint/scope-manager@8.38.0': dependencies: '@typescript-eslint/types': 8.38.0 '@typescript-eslint/visitor-keys': 8.38.0 - '@typescript-eslint/scope-manager@8.46.2': - dependencies: - '@typescript-eslint/types': 8.46.2 - '@typescript-eslint/visitor-keys': 8.46.2 - optional: true - '@typescript-eslint/tsconfig-utils@8.38.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/tsconfig-utils@8.46.2(typescript@5.9.3)': - dependencies: - typescript: 5.9.3 - optional: true - - '@typescript-eslint/type-utils@8.46.2(eslint@8.57.1)(typescript@5.9.3)': - dependencies: - '@typescript-eslint/types': 8.46.2 - '@typescript-eslint/typescript-estree': 8.46.2(typescript@5.9.3) - '@typescript-eslint/utils': 8.46.2(eslint@8.57.1)(typescript@5.9.3) - debug: 4.4.3 - eslint: 8.57.1 - ts-api-utils: 2.1.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - optional: true - '@typescript-eslint/types@8.38.0': {} - '@typescript-eslint/types@8.46.2': - optional: true - '@typescript-eslint/typescript-estree@8.38.0(typescript@5.9.3)': dependencies: '@typescript-eslint/project-service': 8.38.0(typescript@5.9.3) @@ -8792,46 +8668,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.46.2(typescript@5.9.3)': - dependencies: - '@typescript-eslint/project-service': 8.46.2(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.46.2(typescript@5.9.3) - '@typescript-eslint/types': 8.46.2 - '@typescript-eslint/visitor-keys': 8.46.2 - debug: 4.4.3 - fast-glob: 3.3.3 - is-glob: 4.0.3 - minimatch: 9.0.5 - semver: 7.6.3 - ts-api-utils: 2.1.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - optional: true - - '@typescript-eslint/utils@8.46.2(eslint@8.57.1)(typescript@5.9.3)': - dependencies: - '@eslint-community/eslint-utils': 4.7.0(eslint@8.57.1) - '@typescript-eslint/scope-manager': 8.46.2 - '@typescript-eslint/types': 8.46.2 - '@typescript-eslint/typescript-estree': 8.46.2(typescript@5.9.3) - eslint: 8.57.1 - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - optional: true - '@typescript-eslint/visitor-keys@8.38.0': dependencies: '@typescript-eslint/types': 8.38.0 eslint-visitor-keys: 4.2.1 - '@typescript-eslint/visitor-keys@8.46.2': - dependencies: - '@typescript-eslint/types': 8.46.2 - eslint-visitor-keys: 4.2.1 - optional: true - '@ungap/structured-clone@1.2.0': {} '@vite-pwa/assets-generator@1.0.2': @@ -10005,6 +9846,10 @@ snapshots: optionalDependencies: '@types/trusted-types': 2.0.7 + dompurify@3.4.3: + optionalDependencies: + '@types/trusted-types': 2.0.7 + domutils@3.1.0: dependencies: dom-serializer: 2.0.0 @@ -10192,11 +10037,9 @@ snapshots: dependencies: eslint: 8.57.1 - eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.46.2(@typescript-eslint/parser@8.46.2(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1): + eslint-plugin-unused-imports@4.4.1(eslint@8.57.1): dependencies: eslint: 8.57.1 - optionalDependencies: - '@typescript-eslint/eslint-plugin': 8.46.2(@typescript-eslint/parser@8.46.2(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) eslint-scope@7.2.2: dependencies: @@ -10768,9 +10611,6 @@ snapshots: ignore@5.2.4: {} - ignore@7.0.4: - optional: true - immutable@5.1.5: {} import-fresh@3.3.0: diff --git a/src/components/FluxNote.vue b/src/components/FluxNote.vue index dc4a687..82f4a2c 100644 --- a/src/components/FluxNote.vue +++ b/src/components/FluxNote.vue @@ -7,7 +7,7 @@ import SkeletonLoader from "@/components/SkeletonLoader.vue" import StackedNote from "@/components/StackedNote.vue" import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook" import { useLinks } from "@/hooks/useLinks.hook" -import { markdownBuilder } from "@/hooks/useMarkdown.hook" +import { markdownBuilder, runTikz } from "@/hooks/useMarkdown.hook" import { useNoteView } from "@/hooks/useNoteView.hook" import { useResizeContainer } from "@/hooks/useResizeContainer.hook" import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook" @@ -64,6 +64,7 @@ watch( async () => { await nextTick() listenToClick() + void runTikz(".note-display .tikz") }, { immediate: true } ) diff --git a/src/components/StackedNote.vue b/src/components/StackedNote.vue index 5b180d2..7171433 100644 --- a/src/components/StackedNote.vue +++ b/src/components/StackedNote.vue @@ -16,6 +16,7 @@ import { useLinks } from "@/hooks/useLinks.hook" import { renderCodeFile, runMermaid, + runTikz, useShikiji } from "@/hooks/useMarkdown.hook" import { useNoteFreshness } from "@/hooks/useNoteFreshness.hook" @@ -160,6 +161,10 @@ watch([content, mode], () => { runMermaid(`.note-${sha.value} .mermaid`) } + if (rawContent.value.includes("```tikz")) { + void runTikz(`.note-${sha.value} .tikz`) + } + if (isMarkdown.value && rawContent.value.includes("```")) { useShikiji() } diff --git a/src/components/StackedPublicNote.vue b/src/components/StackedPublicNote.vue index 1a68746..7a33604 100644 --- a/src/components/StackedPublicNote.vue +++ b/src/components/StackedPublicNote.vue @@ -5,7 +5,7 @@ import { useRoute } from "vue-router" import SkeletonLoader from "@/components/SkeletonLoader.vue" import { useATProtoLinks } from "@/hooks/useATProtoLinks.hook" -import { markdownBuilder } from "@/hooks/useMarkdown.hook" +import { markdownBuilder, runTikz } from "@/hooks/useMarkdown.hook" import { useNoteOverlay } from "@/hooks/useNoteOverlay.hook" import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook" import { getAuthor } from "@/modules/atproto/getAuthor" @@ -81,6 +81,7 @@ watch( async () => { await nextTick() listenToClick() + void runTikz(`.note-${classNameId.value} .tikz`) }, { immediate: true } ) diff --git a/src/data/DataType.enum.ts b/src/data/DataType.enum.ts index f5fe27b..d5d1320 100644 --- a/src/data/DataType.enum.ts +++ b/src/data/DataType.enum.ts @@ -7,5 +7,6 @@ export enum DataType { RepetitionCard = "RepetitionCard", History = "History", UserSettings = "UserSettings", - AtprotoSession = "AtprotoSession" + AtprotoSession = "AtprotoSession", + TikzCache = "TikzCache" } diff --git a/src/data/models/TikzCache.ts b/src/data/models/TikzCache.ts new file mode 100644 index 0000000..2b1d04c --- /dev/null +++ b/src/data/models/TikzCache.ts @@ -0,0 +1,6 @@ +import { DataType } from "@/data/DataType.enum" +import { Model } from "@/data/models/Model" + +export interface TikzCache extends Model { + svg: string +} diff --git a/src/hooks/useMarkdown.hook.ts b/src/hooks/useMarkdown.hook.ts index 41a6d07..88a9f85 100644 --- a/src/hooks/useMarkdown.hook.ts +++ b/src/hooks/useMarkdown.hook.ts @@ -15,11 +15,21 @@ import mermaid from "mermaid" import type { LanguageRegistration } from "shikiji-core" import { Ref, toValue } from "vue" +import { data } from "@/data/data" +import { DataType } from "@/data/DataType.enum" +import type { TikzCache } from "@/data/models/TikzCache" import alloyGrammar from "@/utils/alloy.tmLanguage.json" -import { decodeBase64ToUTF8 } from "@/utils/decodeBase64ToUTF8" +import { + decodeBase64ToUTF8, + encodeUTF8ToBase64 +} from "@/utils/decodeBase64ToUTF8" import { html5Media } from "@/utils/markdown/markdown-html5-media" import { markdownItTablerIcons } from "@/utils/markdown/markdown-it-tabler-icons" +const TIKZ_BUNDLE_URL = + "https://cdn.jsdelivr.net/gh/artisticat1/obsidian-tikzjax@0.5.2/tikzjax.js" +const TIKZ_RENDER_TIMEOUT_MS = 30000 + const markdownItMermaidExtractor = (md: MarkdownIt) => { const defaultFence = md.renderer.rules.fence || @@ -51,6 +61,37 @@ const markdownItMermaidExtractor = (md: MarkdownIt) => { } } +const markdownItTikzExtractor = (md: MarkdownIt) => { + const defaultFence = + md.renderer.rules.fence || + function ( + tokens: Array, + index: number, + options: Options, + _: unknown, + self: Renderer + ) { + return self.renderToken(tokens, index, options) + } + + md.renderer.rules.fence = function ( + tokens: Array, + index: number, + options: Options, + env: unknown, + self: Renderer + ) { + const token = tokens[index] + + if (token.info.trim() === "tikz") { + const encoded = encodeUTF8ToBase64(token.content) + return `
Rendering TikZ…
\n` + } + + return defaultFence(tokens, index, options, env, self) + } +} + const slugger = new GithubSlugger() let tabGroupCounter = 0 @@ -62,6 +103,7 @@ const md = new MarkdownIt({ quotes: ["«\xA0", "\xA0»", "‹\xA0", "\xA0›"] }) .use(markdownItMermaidExtractor) + .use(markdownItTikzExtractor) .use(html5Media) .use(blockEmbedPlugin, { youtube: { @@ -145,6 +187,165 @@ export const runMermaid = (querySelector: string) => { }) } +let tikzBundlePromise: Promise | null = null +let domPurifyPromise: Promise | null = null + +const ensureTikzBundle = (): Promise => { + if (tikzBundlePromise) return tikzBundlePromise + + tikzBundlePromise = new Promise((resolve, reject) => { + const existing = document.getElementById("tikzjax-bundle") + if (existing) { + resolve() + return + } + const script = document.createElement("script") + script.id = "tikzjax-bundle" + script.src = TIKZ_BUNDLE_URL + script.async = true + script.crossOrigin = "anonymous" + script.addEventListener("load", () => resolve()) + script.addEventListener("error", () => { + tikzBundlePromise = null + reject(new Error("Failed to load TikZ engine")) + }) + document.head.appendChild(script) + }) + + return tikzBundlePromise +} + +const ensureDomPurify = (): Promise => { + if (!domPurifyPromise) domPurifyPromise = import("dompurify") + return domPurifyPromise +} + +const sha256Hex = async (text: string): Promise => { + const buf = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(text) + ) + return Array.from(new Uint8Array(buf)) + .map((b) => b.toString(16).padStart(2, "0")) + .join("") +} + +const sanitizeSvg = async (svg: string): Promise => { + const DOMPurify = (await ensureDomPurify()).default + return DOMPurify.sanitize(svg, { + USE_PROFILES: { svg: true, svgFilters: true } + }) +} + +const tidyTikzSource = (s: string): string => + s + .replaceAll(" ", "") + .split("\n") + .map((line) => line.trim()) + .filter((line) => line) + .join("\n") + +const renderTikzError = (err: unknown, source: string): string => { + const msg = err instanceof Error ? err.message : String(err) + return ( + `
TikZ error` + + `
${md.utils.escapeHtml(msg)}
` + + `
source` + + `
${md.utils.escapeHtml(source)}
` + ) +} + +const renderOneTikzBlock = ( + el: HTMLElement, + source: string +): Promise => { + return new Promise((resolve, reject) => { + let settled = false + const timer = window.setTimeout(() => { + if (settled) return + settled = true + el.removeEventListener("tikzjax-load-finished", handler) + reject(new Error("TikZ render timed out")) + }, TIKZ_RENDER_TIMEOUT_MS) + + const handler = (e: Event) => { + if (settled) return + settled = true + window.clearTimeout(timer) + el.removeEventListener("tikzjax-load-finished", handler) + const target = e.target as Element | null + if (!target) { + reject(new Error("TikZ produced no output")) + return + } + resolve(target.outerHTML) + } + + el.addEventListener("tikzjax-load-finished", handler) + + while (el.firstChild) el.removeChild(el.firstChild) + const script = document.createElement("script") + script.type = "text/tikz" + script.textContent = source + el.appendChild(script) + }) +} + +export const runTikz = async (querySelector: string): Promise => { + const elements = Array.from( + document.querySelectorAll(querySelector) + ) + if (elements.length === 0) return + + await Promise.all( + elements.map(async (el) => { + if (el.dataset.tikzRendered) return + el.dataset.tikzRendered = "pending" + + const encoded = el.dataset.tikzSource + if (!encoded) { + el.dataset.tikzRendered = "error" + return + } + + const source = tidyTikzSource(decodeBase64ToUTF8(encoded)) + + let hash: string + try { + hash = await sha256Hex(source) + } catch (err) { + el.innerHTML = renderTikzError(err, source) + el.dataset.tikzRendered = "error" + return + } + + const cacheId = `${DataType.TikzCache}-${hash}` + const cached = await data.get(cacheId) + if (cached?.svg) { + el.innerHTML = cached.svg + el.dataset.tikzRendered = "true" + return + } + + try { + await ensureTikzBundle() + const rawSvg = await renderOneTikzBlock(el, source) + const sanitized = await sanitizeSvg(rawSvg) + el.innerHTML = sanitized + el.dataset.tikzRendered = "true" + void data.add({ + _id: cacheId, + $type: DataType.TikzCache, + svg: sanitized + } as TikzCache) + } catch (err) { + el.innerHTML = renderTikzError(err, source) + el.dataset.tikzRendered = "error" + } + }) + ) +} + const rules: RenderRuleRecord = { table_open: () => '
', diff --git a/src/styles/app.css b/src/styles/app.css index 1ca6486..e63ba90 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -188,6 +188,36 @@ pre.mermaid { background-color: var(--color-base-content); } +pre.tikz { + display: block; + overflow-x: auto; + text-align: center; + padding: 0.5rem; + background: transparent; +} + +pre.tikz svg { + max-width: 100%; + height: auto; +} + +.tikz-loading { + opacity: 0.6; + font-style: italic; +} + +.tikz-error { + border-left: 3px solid var(--color-error); + padding: 0.5rem 0.75rem; + background: color-mix(in srgb, var(--color-error) 10%, transparent); + text-align: left; +} + +.tikz-error pre { + white-space: pre-wrap; + font-size: 0.85em; +} + .markdown-alert { padding: 1rem; background-color: #ecf0f1; diff --git a/src/views/PublicNoteView.vue b/src/views/PublicNoteView.vue index b92dd6d..ddbd0a0 100644 --- a/src/views/PublicNoteView.vue +++ b/src/views/PublicNoteView.vue @@ -9,7 +9,7 @@ import SkeletonLoader from "@/components/SkeletonLoader.vue" import StackedPublicNote from "@/components/StackedPublicNote.vue" import ThemeSwap from "@/components/ThemeSwap.vue" import { useATProtoLinks } from "@/hooks/useATProtoLinks.hook" -import { markdownBuilder } from "@/hooks/useMarkdown.hook" +import { markdownBuilder, runTikz } from "@/hooks/useMarkdown.hook" import { useResizeContainer } from "@/hooks/useResizeContainer.hook" import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook" import { getAuthor } from "@/modules/atproto/getAuthor" @@ -120,6 +120,7 @@ watch( async () => { await nextTick() listenToClick() + void runTikz(".public-note-view .note-display .tikz") }, { immediate: true } )