From 8d391a2e5edce611bb12d4927fb64eb76a2c6344 Mon Sep 17 00:00:00 2001 From: Neeldhara Misra Date: Thu, 25 Jun 2026 04:22:41 +0530 Subject: [PATCH] Fix isolated site builds for Dokploy --- sites/art/astro.config.mjs | 6 +- sites/art/package-lock.json | 232 +++++++++++++- sites/art/package.json | 5 + sites/art/src/remark/callouts.mjs | 101 ++++++ sites/art/src/remark/code-fence-languages.mjs | 29 ++ sites/art/src/remark/inline-footnotes.mjs | 289 ++++++++++++++++++ sites/poetry/astro.config.mjs | 2 +- sites/poetry/package-lock.json | 232 +++++++++++++- sites/poetry/package.json | 5 + sites/poetry/src/remark/callouts.mjs | 101 ++++++ .../src/remark/code-fence-languages.mjs | 29 ++ sites/poetry/src/remark/inline-footnotes.mjs | 289 ++++++++++++++++++ sites/puzzles/astro.config.mjs | 6 +- sites/puzzles/package-lock.json | 232 +++++++++++++- sites/puzzles/package.json | 5 + sites/puzzles/src/remark/callouts.mjs | 101 ++++++ .../src/remark/code-fence-languages.mjs | 29 ++ sites/puzzles/src/remark/inline-footnotes.mjs | 289 ++++++++++++++++++ sites/reflections/astro.config.mjs | 6 +- sites/reflections/package-lock.json | 232 +++++++++++++- sites/reflections/package.json | 5 + sites/reflections/src/remark/callouts.mjs | 101 ++++++ .../src/remark/code-fence-languages.mjs | 29 ++ .../src/remark/inline-footnotes.mjs | 289 ++++++++++++++++++ sites/research/astro.config.mjs | 6 +- sites/research/package-lock.json | 232 +++++++++++++- sites/research/package.json | 5 + sites/research/src/remark/callouts.mjs | 101 ++++++ .../src/remark/code-fence-languages.mjs | 29 ++ .../research/src/remark/inline-footnotes.mjs | 289 ++++++++++++++++++ sites/reviews/astro.config.mjs | 6 +- sites/reviews/package-lock.json | 232 +++++++++++++- sites/reviews/package.json | 5 + sites/reviews/src/remark/callouts.mjs | 101 ++++++ .../src/remark/code-fence-languages.mjs | 29 ++ sites/reviews/src/remark/inline-footnotes.mjs | 289 ++++++++++++++++++ sites/vibes/astro.config.mjs | 6 +- sites/vibes/package-lock.json | 232 +++++++++++++- sites/vibes/package.json | 5 + sites/vibes/src/remark/callouts.mjs | 101 ++++++ .../vibes/src/remark/code-fence-languages.mjs | 29 ++ sites/vibes/src/remark/inline-footnotes.mjs | 289 ++++++++++++++++++ 42 files changed, 4590 insertions(+), 40 deletions(-) create mode 100644 sites/art/src/remark/callouts.mjs create mode 100644 sites/art/src/remark/code-fence-languages.mjs create mode 100644 sites/art/src/remark/inline-footnotes.mjs create mode 100644 sites/poetry/src/remark/callouts.mjs create mode 100644 sites/poetry/src/remark/code-fence-languages.mjs create mode 100644 sites/poetry/src/remark/inline-footnotes.mjs create mode 100644 sites/puzzles/src/remark/callouts.mjs create mode 100644 sites/puzzles/src/remark/code-fence-languages.mjs create mode 100644 sites/puzzles/src/remark/inline-footnotes.mjs create mode 100644 sites/reflections/src/remark/callouts.mjs create mode 100644 sites/reflections/src/remark/code-fence-languages.mjs create mode 100644 sites/reflections/src/remark/inline-footnotes.mjs create mode 100644 sites/research/src/remark/callouts.mjs create mode 100644 sites/research/src/remark/code-fence-languages.mjs create mode 100644 sites/research/src/remark/inline-footnotes.mjs create mode 100644 sites/reviews/src/remark/callouts.mjs create mode 100644 sites/reviews/src/remark/code-fence-languages.mjs create mode 100644 sites/reviews/src/remark/inline-footnotes.mjs create mode 100644 sites/vibes/src/remark/callouts.mjs create mode 100644 sites/vibes/src/remark/code-fence-languages.mjs create mode 100644 sites/vibes/src/remark/inline-footnotes.mjs diff --git a/sites/art/astro.config.mjs b/sites/art/astro.config.mjs index 9151a86..5efa078 100644 --- a/sites/art/astro.config.mjs +++ b/sites/art/astro.config.mjs @@ -9,9 +9,9 @@ import sitemap from "@astrojs/sitemap"; import react from "@astrojs/react"; // @ts-ignore - TailwindCSS v4 Vite plugin has type compatibility issues with Astro import tailwindcss from "@tailwindcss/vite"; -import remarkCallouts from "../../src/remark-callouts.mjs"; -import remarkCodeFenceLanguages from "../../src/remark-code-fence-languages.mjs"; -import remarkInlineFootnotes from "../../src/remark-inline-footnotes.mjs"; +import remarkCallouts from "./src/remark/callouts.mjs"; +import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs"; +import remarkInlineFootnotes from "./src/remark/inline-footnotes.mjs"; import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js"; const remarkPlugins = [ diff --git a/sites/art/package-lock.json b/sites/art/package-lock.json index 17e8dc6..5d158c8 100644 --- a/sites/art/package-lock.json +++ b/sites/art/package-lock.json @@ -36,12 +36,17 @@ "date-fns": "^4.1.0", "embla-carousel-autoplay": "^8.5.2", "embla-carousel-react": "^8.5.2", + "katex": "^0.17.0", "lucide-react": "^0.479.0", + "mdast-util-from-markdown": "^2.0.3", "react": "^19.0.0", "react-dom": "^19.0.0", "react-fast-marquee": "^1.6.5", "react-hook-form": "^7.54.2", "react-icons": "^5.4.0", + "rehype-katex": "^7.0.1", + "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", "tailwind-merge": "^3.0.2", "tailwindcss": "^4.0.12", "tailwindcss-animate": "^1.0.7", @@ -2826,6 +2831,60 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.4.3", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.0.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.4.3", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.0.2", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.11", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.9.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.9.0", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.0", + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.1.11", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.11.tgz", @@ -2970,6 +3029,12 @@ "@types/unist": "*" } }, + "node_modules/@types/katex": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz", + "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==", + "license": "MIT" + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -3979,6 +4044,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/common-ancestor-path": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/common-ancestor-path/-/common-ancestor-path-1.0.1.tgz", @@ -4691,6 +4765,21 @@ "uncrypto": "^0.1.3" } }, + "node_modules/hast-util-from-dom": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/hast-util-from-dom/-/hast-util-from-dom-5.0.1.tgz", + "integrity": "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==", + "license": "ISC", + "dependencies": { + "@types/hast": "^3.0.0", + "hastscript": "^9.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-from-html": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", @@ -4709,6 +4798,22 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-from-html-isomorphic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hast-util-from-html-isomorphic/-/hast-util-from-html-isomorphic-2.0.0.tgz", + "integrity": "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-dom": "^5.0.0", + "hast-util-from-html": "^2.0.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-from-parse5": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", @@ -5193,6 +5298,22 @@ "integrity": "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==", "license": "MIT" }, + "node_modules/katex": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.17.0.tgz", + "integrity": "sha512-Vdw0ATsQ9V+LuegM/BTwQqV/6cTl5lbGcIrU+BCgLxyf6bo38ybOr372tuSIxir3CN720flu1meYR6XzNMwQnw==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", @@ -5556,9 +5677,9 @@ } }, "node_modules/mdast-util-from-markdown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", - "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", @@ -5680,6 +5801,25 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-math": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-math/-/mdast-util-math-3.0.0.tgz", + "integrity": "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "longest-streak": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.1.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-mdx": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/mdast-util-mdx/-/mdast-util-mdx-3.0.0.tgz", @@ -6031,6 +6171,41 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/micromark-extension-math": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz", + "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==", + "license": "MIT", + "dependencies": { + "@types/katex": "^0.16.0", + "devlop": "^1.0.0", + "katex": "^0.16.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-math/node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, "node_modules/micromark-extension-mdx-expression": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/micromark-extension-mdx-expression/-/micromark-extension-mdx-expression-3.0.1.tgz", @@ -7291,6 +7466,41 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/rehype-katex": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz", + "integrity": "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/katex": "^0.16.0", + "hast-util-from-html-isomorphic": "^2.0.0", + "hast-util-to-text": "^4.0.0", + "katex": "^0.16.0", + "unist-util-visit-parents": "^6.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-katex/node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, "node_modules/rehype-parse": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/rehype-parse/-/rehype-parse-9.0.1.tgz", @@ -7369,6 +7579,22 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-math": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/remark-math/-/remark-math-6.0.0.tgz", + "integrity": "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-math": "^3.0.0", + "micromark-extension-math": "^3.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-mdx": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/remark-mdx/-/remark-mdx-3.1.0.tgz", diff --git a/sites/art/package.json b/sites/art/package.json index 97f5e9f..e47df79 100644 --- a/sites/art/package.json +++ b/sites/art/package.json @@ -48,12 +48,17 @@ "date-fns": "^4.1.0", "embla-carousel-autoplay": "^8.5.2", "embla-carousel-react": "^8.5.2", + "katex": "^0.17.0", "lucide-react": "^0.479.0", + "mdast-util-from-markdown": "^2.0.3", "react": "^19.0.0", "react-dom": "^19.0.0", "react-fast-marquee": "^1.6.5", "react-hook-form": "^7.54.2", "react-icons": "^5.4.0", + "rehype-katex": "^7.0.1", + "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", "tailwind-merge": "^3.0.2", "tailwindcss": "^4.0.12", "tailwindcss-animate": "^1.0.7", diff --git a/sites/art/src/remark/callouts.mjs b/sites/art/src/remark/callouts.mjs new file mode 100644 index 0000000..56f6a1f --- /dev/null +++ b/sites/art/src/remark/callouts.mjs @@ -0,0 +1,101 @@ +const CALLOUT_LABELS = new Map([ + ["aside", "Aside"], + ["caution", "Caution"], + ["note", "Note"], + ["tip", "Tip"], + ["warning", "Warning"], +]); + +function textFromInline(node) { + if (!node) return ""; + if (typeof node.value === "string") return node.value; + if (!Array.isArray(node.children)) return ""; + return node.children.map(textFromInline).join(""); +} + +function normalizeLabel(value) { + return value + .trim() + .replace(/^["“”]+|["“”]+$/g, "") + .replace(/:$/, "") + .toLowerCase(); +} + +function paragraphCalloutLabel(paragraph) { + if (paragraph?.type !== "paragraph") return null; + + const first = paragraph.children?.[0]; + if (first?.type === "text") { + const match = first.value.match(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i); + if (match) { + return CALLOUT_LABELS.get(normalizeLabel(match[1])); + } + } + + if (first?.type !== "strong") return null; + + const label = CALLOUT_LABELS.get(normalizeLabel(textFromInline(first))); + if (!label) return null; + + return label; +} + +function removeLeadingLabel(paragraph, label) { + if (paragraph?.type !== "paragraph") return; + + const first = paragraph.children?.[0]; + if (first?.type === "text") { + first.value = first.value.replace(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i, ""); + if (!first.value) paragraph.children.shift(); + return; + } + + const firstText = normalizeLabel(textFromInline(first)); + if (!first || first.type !== "strong" || firstText !== label.toLowerCase()) { + return; + } + + paragraph.children.shift(); + + const next = paragraph.children[0]; + if (next?.type === "text") { + next.value = next.value.replace(/^:\s*/, "").replace(/^\s+/, ""); + if (!next.value) paragraph.children.shift(); + } +} + +function transformBlockquote(node) { + if (node.type !== "blockquote") return; + + const first = node.children?.[0]; + const label = paragraphCalloutLabel(first); + if (!label) return; + + removeLeadingLabel(first, label); + if (first.children?.length === 0) { + node.children.shift(); + } + + node.data = { + hName: "aside", + hProperties: { + className: ["callout", `callout-${label.toLowerCase()}`], + dataCalloutLabel: label, + }, + }; +} + +function visit(node) { + transformBlockquote(node); + + if (!Array.isArray(node.children)) return; + for (const child of node.children) { + visit(child); + } +} + +export default function remarkCallouts() { + return function transformer(tree) { + visit(tree); + }; +} diff --git a/sites/art/src/remark/code-fence-languages.mjs b/sites/art/src/remark/code-fence-languages.mjs new file mode 100644 index 0000000..240c7ec --- /dev/null +++ b/sites/art/src/remark/code-fence-languages.mjs @@ -0,0 +1,29 @@ +const LANGUAGE_ALIASES = new Map([["ojs", "js"]]); + +const normalizeLanguage = (language) => { + if (!language) return language; + + const match = language.match(/^\{(.+)\}$/); + if (!match) return language; + + const normalized = match[1].trim().toLowerCase(); + return LANGUAGE_ALIASES.get(normalized) ?? normalized; +}; + +const visit = (node) => { + if (node.type === "code") { + node.lang = normalizeLanguage(node.lang); + } + + if (!Array.isArray(node.children)) return; + + for (const child of node.children) { + visit(child); + } +}; + +const remarkCodeFenceLanguages = () => (tree) => { + visit(tree); +}; + +export default remarkCodeFenceLanguages; diff --git a/sites/art/src/remark/inline-footnotes.mjs b/sites/art/src/remark/inline-footnotes.mjs new file mode 100644 index 0000000..ccdf9ee --- /dev/null +++ b/sites/art/src/remark/inline-footnotes.mjs @@ -0,0 +1,289 @@ +import { fromMarkdown } from "mdast-util-from-markdown"; + +const INLINE_FOOTNOTE_PREFIX = "inline-footnote"; + +const findInlineFootnote = (value, fromIndex = 0) => { + const start = value.indexOf("^[", fromIndex); + + if (start === -1) { + return null; + } + + let depth = 1; + + for (let index = start + 2; index < value.length; index += 1) { + const character = value[index]; + + if (character === "\\" && index + 1 < value.length) { + index += 1; + continue; + } + + if (character === "[") { + depth += 1; + continue; + } + + if (character === "]") { + depth -= 1; + + if (depth === 0) { + return { + start, + end: index + 1, + value: value.slice(start + 2, index).trim(), + }; + } + } + } + + return null; +}; + +const createFootnoteChildren = (value) => { + const tree = fromMarkdown(value); + + if (tree.children.length > 0) { + return tree.children; + } + + return [ + { + type: "paragraph", + children: [ + { + type: "text", + value, + }, + ], + }, + ]; +}; + +const createFootnoteParagraph = (children) => ({ + type: "paragraph", + children: + children.length > 0 + ? children + : [ + { + type: "text", + value: "", + }, + ], +}); + +const createFootnoteDefinition = (identifier, label, value) => ({ + type: "footnoteDefinition", + identifier, + label, + children: createFootnoteChildren(value), +}); + +const createFootnoteDefinitionFromChildren = (identifier, label, children) => ({ + type: "footnoteDefinition", + identifier, + label, + children: [createFootnoteParagraph(children)], +}); + +const createFootnoteReference = (context) => { + context.count += 1; + + const label = String(context.count); + const identifier = `${INLINE_FOOTNOTE_PREFIX}-${label}`; + + return { + identifier, + label, + node: { + type: "footnoteReference", + identifier, + label, + }, + }; +}; + +const findClosingBracket = (value) => { + for (let index = 0; index < value.length; index += 1) { + const character = value[index]; + + if (character === "\\" && index + 1 < value.length) { + index += 1; + continue; + } + + if (character === "]") { + return index; + } + } + + return -1; +}; + +const cloneNode = (node) => JSON.parse(JSON.stringify(node)); + +const splitTextNode = (node, context) => { + const replacements = []; + let cursor = 0; + let match = findInlineFootnote(node.value, cursor); + + while (match) { + if (match.start > cursor) { + replacements.push({ + type: "text", + value: node.value.slice(cursor, match.start), + }); + } + + if (match.value) { + const reference = createFootnoteReference(context); + replacements.push(reference.node); + + context.definitions.push( + createFootnoteDefinition(reference.identifier, reference.label, match.value), + ); + } else { + replacements.push({ + type: "text", + value: node.value.slice(match.start, match.end), + }); + } + + cursor = match.end; + match = findInlineFootnote(node.value, cursor); + } + + if (cursor < node.value.length) { + replacements.push({ + type: "text", + value: node.value.slice(cursor), + }); + } + + return replacements; +}; + +const splitAcrossInlineNodes = (children, startIndex, context) => { + const startNode = children[startIndex]; + const start = startNode.value.indexOf("^["); + + if (start === -1) { + return null; + } + + if (findInlineFootnote(startNode.value, start)) { + return null; + } + + const footnoteChildren = []; + const replacements = []; + const before = startNode.value.slice(0, start); + const initialText = startNode.value.slice(start + 2); + + if (before) { + replacements.push({ + type: "text", + value: before, + }); + } + + if (initialText) { + footnoteChildren.push({ + type: "text", + value: initialText, + }); + } + + for (let index = startIndex + 1; index < children.length; index += 1) { + const child = children[index]; + + if (child.type === "text") { + const closingIndex = findClosingBracket(child.value); + + if (closingIndex !== -1) { + const footnoteText = child.value.slice(0, closingIndex); + const after = child.value.slice(closingIndex + 1); + + if (footnoteText) { + footnoteChildren.push({ + type: "text", + value: footnoteText, + }); + } + + const reference = createFootnoteReference(context); + replacements.push(reference.node); + + if (after) { + replacements.push({ + type: "text", + value: after, + }); + } + + context.definitions.push( + createFootnoteDefinitionFromChildren( + reference.identifier, + reference.label, + footnoteChildren, + ), + ); + + return { + consumed: index - startIndex + 1, + replacements, + }; + } + + footnoteChildren.push(cloneNode(child)); + continue; + } + + footnoteChildren.push(cloneNode(child)); + } + + return null; +}; + +const visitChildren = (node, context) => { + if (!Array.isArray(node.children)) { + return; + } + + for (let index = 0; index < node.children.length; index += 1) { + const child = node.children[index]; + + if (child.type === "text" && child.value.includes("^[")) { + const acrossNodes = splitAcrossInlineNodes(node.children, index, context); + + if (acrossNodes) { + node.children.splice(index, acrossNodes.consumed, ...acrossNodes.replacements); + index += acrossNodes.replacements.length - 1; + continue; + } + + const replacements = splitTextNode(child, context); + node.children.splice(index, 1, ...replacements); + index += replacements.length - 1; + continue; + } + + visitChildren(child, context); + } +}; + +const remarkInlineFootnotes = () => (tree) => { + const context = { + count: 0, + definitions: [], + }; + + visitChildren(tree, context); + + if (context.definitions.length > 0) { + tree.children.push(...context.definitions); + } +}; + +export default remarkInlineFootnotes; diff --git a/sites/poetry/astro.config.mjs b/sites/poetry/astro.config.mjs index 1f85fb3..a38215d 100644 --- a/sites/poetry/astro.config.mjs +++ b/sites/poetry/astro.config.mjs @@ -8,7 +8,7 @@ import sitemap from "@astrojs/sitemap"; import react from "@astrojs/react"; // @ts-ignore - TailwindCSS v4 Vite plugin has type compatibility issues with Astro import tailwindcss from "@tailwindcss/vite"; -import remarkCallouts from "../../src/remark-callouts.mjs"; +import remarkCallouts from "./src/remark/callouts.mjs"; import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js"; const remarkPlugins = [remarkMath, remarkCallouts]; diff --git a/sites/poetry/package-lock.json b/sites/poetry/package-lock.json index 17e8dc6..5d158c8 100644 --- a/sites/poetry/package-lock.json +++ b/sites/poetry/package-lock.json @@ -36,12 +36,17 @@ "date-fns": "^4.1.0", "embla-carousel-autoplay": "^8.5.2", "embla-carousel-react": "^8.5.2", + "katex": "^0.17.0", "lucide-react": "^0.479.0", + "mdast-util-from-markdown": "^2.0.3", "react": "^19.0.0", "react-dom": "^19.0.0", "react-fast-marquee": "^1.6.5", "react-hook-form": "^7.54.2", "react-icons": "^5.4.0", + "rehype-katex": "^7.0.1", + "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", "tailwind-merge": "^3.0.2", "tailwindcss": "^4.0.12", "tailwindcss-animate": "^1.0.7", @@ -2826,6 +2831,60 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.4.3", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.0.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.4.3", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.0.2", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.11", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.9.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.9.0", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.0", + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.1.11", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.11.tgz", @@ -2970,6 +3029,12 @@ "@types/unist": "*" } }, + "node_modules/@types/katex": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz", + "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==", + "license": "MIT" + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -3979,6 +4044,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/common-ancestor-path": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/common-ancestor-path/-/common-ancestor-path-1.0.1.tgz", @@ -4691,6 +4765,21 @@ "uncrypto": "^0.1.3" } }, + "node_modules/hast-util-from-dom": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/hast-util-from-dom/-/hast-util-from-dom-5.0.1.tgz", + "integrity": "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==", + "license": "ISC", + "dependencies": { + "@types/hast": "^3.0.0", + "hastscript": "^9.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-from-html": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", @@ -4709,6 +4798,22 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-from-html-isomorphic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hast-util-from-html-isomorphic/-/hast-util-from-html-isomorphic-2.0.0.tgz", + "integrity": "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-dom": "^5.0.0", + "hast-util-from-html": "^2.0.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-from-parse5": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", @@ -5193,6 +5298,22 @@ "integrity": "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==", "license": "MIT" }, + "node_modules/katex": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.17.0.tgz", + "integrity": "sha512-Vdw0ATsQ9V+LuegM/BTwQqV/6cTl5lbGcIrU+BCgLxyf6bo38ybOr372tuSIxir3CN720flu1meYR6XzNMwQnw==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", @@ -5556,9 +5677,9 @@ } }, "node_modules/mdast-util-from-markdown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", - "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", @@ -5680,6 +5801,25 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-math": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-math/-/mdast-util-math-3.0.0.tgz", + "integrity": "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "longest-streak": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.1.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-mdx": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/mdast-util-mdx/-/mdast-util-mdx-3.0.0.tgz", @@ -6031,6 +6171,41 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/micromark-extension-math": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz", + "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==", + "license": "MIT", + "dependencies": { + "@types/katex": "^0.16.0", + "devlop": "^1.0.0", + "katex": "^0.16.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-math/node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, "node_modules/micromark-extension-mdx-expression": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/micromark-extension-mdx-expression/-/micromark-extension-mdx-expression-3.0.1.tgz", @@ -7291,6 +7466,41 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/rehype-katex": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz", + "integrity": "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/katex": "^0.16.0", + "hast-util-from-html-isomorphic": "^2.0.0", + "hast-util-to-text": "^4.0.0", + "katex": "^0.16.0", + "unist-util-visit-parents": "^6.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-katex/node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, "node_modules/rehype-parse": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/rehype-parse/-/rehype-parse-9.0.1.tgz", @@ -7369,6 +7579,22 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-math": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/remark-math/-/remark-math-6.0.0.tgz", + "integrity": "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-math": "^3.0.0", + "micromark-extension-math": "^3.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-mdx": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/remark-mdx/-/remark-mdx-3.1.0.tgz", diff --git a/sites/poetry/package.json b/sites/poetry/package.json index 97f5e9f..e47df79 100644 --- a/sites/poetry/package.json +++ b/sites/poetry/package.json @@ -48,12 +48,17 @@ "date-fns": "^4.1.0", "embla-carousel-autoplay": "^8.5.2", "embla-carousel-react": "^8.5.2", + "katex": "^0.17.0", "lucide-react": "^0.479.0", + "mdast-util-from-markdown": "^2.0.3", "react": "^19.0.0", "react-dom": "^19.0.0", "react-fast-marquee": "^1.6.5", "react-hook-form": "^7.54.2", "react-icons": "^5.4.0", + "rehype-katex": "^7.0.1", + "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", "tailwind-merge": "^3.0.2", "tailwindcss": "^4.0.12", "tailwindcss-animate": "^1.0.7", diff --git a/sites/poetry/src/remark/callouts.mjs b/sites/poetry/src/remark/callouts.mjs new file mode 100644 index 0000000..56f6a1f --- /dev/null +++ b/sites/poetry/src/remark/callouts.mjs @@ -0,0 +1,101 @@ +const CALLOUT_LABELS = new Map([ + ["aside", "Aside"], + ["caution", "Caution"], + ["note", "Note"], + ["tip", "Tip"], + ["warning", "Warning"], +]); + +function textFromInline(node) { + if (!node) return ""; + if (typeof node.value === "string") return node.value; + if (!Array.isArray(node.children)) return ""; + return node.children.map(textFromInline).join(""); +} + +function normalizeLabel(value) { + return value + .trim() + .replace(/^["“”]+|["“”]+$/g, "") + .replace(/:$/, "") + .toLowerCase(); +} + +function paragraphCalloutLabel(paragraph) { + if (paragraph?.type !== "paragraph") return null; + + const first = paragraph.children?.[0]; + if (first?.type === "text") { + const match = first.value.match(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i); + if (match) { + return CALLOUT_LABELS.get(normalizeLabel(match[1])); + } + } + + if (first?.type !== "strong") return null; + + const label = CALLOUT_LABELS.get(normalizeLabel(textFromInline(first))); + if (!label) return null; + + return label; +} + +function removeLeadingLabel(paragraph, label) { + if (paragraph?.type !== "paragraph") return; + + const first = paragraph.children?.[0]; + if (first?.type === "text") { + first.value = first.value.replace(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i, ""); + if (!first.value) paragraph.children.shift(); + return; + } + + const firstText = normalizeLabel(textFromInline(first)); + if (!first || first.type !== "strong" || firstText !== label.toLowerCase()) { + return; + } + + paragraph.children.shift(); + + const next = paragraph.children[0]; + if (next?.type === "text") { + next.value = next.value.replace(/^:\s*/, "").replace(/^\s+/, ""); + if (!next.value) paragraph.children.shift(); + } +} + +function transformBlockquote(node) { + if (node.type !== "blockquote") return; + + const first = node.children?.[0]; + const label = paragraphCalloutLabel(first); + if (!label) return; + + removeLeadingLabel(first, label); + if (first.children?.length === 0) { + node.children.shift(); + } + + node.data = { + hName: "aside", + hProperties: { + className: ["callout", `callout-${label.toLowerCase()}`], + dataCalloutLabel: label, + }, + }; +} + +function visit(node) { + transformBlockquote(node); + + if (!Array.isArray(node.children)) return; + for (const child of node.children) { + visit(child); + } +} + +export default function remarkCallouts() { + return function transformer(tree) { + visit(tree); + }; +} diff --git a/sites/poetry/src/remark/code-fence-languages.mjs b/sites/poetry/src/remark/code-fence-languages.mjs new file mode 100644 index 0000000..240c7ec --- /dev/null +++ b/sites/poetry/src/remark/code-fence-languages.mjs @@ -0,0 +1,29 @@ +const LANGUAGE_ALIASES = new Map([["ojs", "js"]]); + +const normalizeLanguage = (language) => { + if (!language) return language; + + const match = language.match(/^\{(.+)\}$/); + if (!match) return language; + + const normalized = match[1].trim().toLowerCase(); + return LANGUAGE_ALIASES.get(normalized) ?? normalized; +}; + +const visit = (node) => { + if (node.type === "code") { + node.lang = normalizeLanguage(node.lang); + } + + if (!Array.isArray(node.children)) return; + + for (const child of node.children) { + visit(child); + } +}; + +const remarkCodeFenceLanguages = () => (tree) => { + visit(tree); +}; + +export default remarkCodeFenceLanguages; diff --git a/sites/poetry/src/remark/inline-footnotes.mjs b/sites/poetry/src/remark/inline-footnotes.mjs new file mode 100644 index 0000000..ccdf9ee --- /dev/null +++ b/sites/poetry/src/remark/inline-footnotes.mjs @@ -0,0 +1,289 @@ +import { fromMarkdown } from "mdast-util-from-markdown"; + +const INLINE_FOOTNOTE_PREFIX = "inline-footnote"; + +const findInlineFootnote = (value, fromIndex = 0) => { + const start = value.indexOf("^[", fromIndex); + + if (start === -1) { + return null; + } + + let depth = 1; + + for (let index = start + 2; index < value.length; index += 1) { + const character = value[index]; + + if (character === "\\" && index + 1 < value.length) { + index += 1; + continue; + } + + if (character === "[") { + depth += 1; + continue; + } + + if (character === "]") { + depth -= 1; + + if (depth === 0) { + return { + start, + end: index + 1, + value: value.slice(start + 2, index).trim(), + }; + } + } + } + + return null; +}; + +const createFootnoteChildren = (value) => { + const tree = fromMarkdown(value); + + if (tree.children.length > 0) { + return tree.children; + } + + return [ + { + type: "paragraph", + children: [ + { + type: "text", + value, + }, + ], + }, + ]; +}; + +const createFootnoteParagraph = (children) => ({ + type: "paragraph", + children: + children.length > 0 + ? children + : [ + { + type: "text", + value: "", + }, + ], +}); + +const createFootnoteDefinition = (identifier, label, value) => ({ + type: "footnoteDefinition", + identifier, + label, + children: createFootnoteChildren(value), +}); + +const createFootnoteDefinitionFromChildren = (identifier, label, children) => ({ + type: "footnoteDefinition", + identifier, + label, + children: [createFootnoteParagraph(children)], +}); + +const createFootnoteReference = (context) => { + context.count += 1; + + const label = String(context.count); + const identifier = `${INLINE_FOOTNOTE_PREFIX}-${label}`; + + return { + identifier, + label, + node: { + type: "footnoteReference", + identifier, + label, + }, + }; +}; + +const findClosingBracket = (value) => { + for (let index = 0; index < value.length; index += 1) { + const character = value[index]; + + if (character === "\\" && index + 1 < value.length) { + index += 1; + continue; + } + + if (character === "]") { + return index; + } + } + + return -1; +}; + +const cloneNode = (node) => JSON.parse(JSON.stringify(node)); + +const splitTextNode = (node, context) => { + const replacements = []; + let cursor = 0; + let match = findInlineFootnote(node.value, cursor); + + while (match) { + if (match.start > cursor) { + replacements.push({ + type: "text", + value: node.value.slice(cursor, match.start), + }); + } + + if (match.value) { + const reference = createFootnoteReference(context); + replacements.push(reference.node); + + context.definitions.push( + createFootnoteDefinition(reference.identifier, reference.label, match.value), + ); + } else { + replacements.push({ + type: "text", + value: node.value.slice(match.start, match.end), + }); + } + + cursor = match.end; + match = findInlineFootnote(node.value, cursor); + } + + if (cursor < node.value.length) { + replacements.push({ + type: "text", + value: node.value.slice(cursor), + }); + } + + return replacements; +}; + +const splitAcrossInlineNodes = (children, startIndex, context) => { + const startNode = children[startIndex]; + const start = startNode.value.indexOf("^["); + + if (start === -1) { + return null; + } + + if (findInlineFootnote(startNode.value, start)) { + return null; + } + + const footnoteChildren = []; + const replacements = []; + const before = startNode.value.slice(0, start); + const initialText = startNode.value.slice(start + 2); + + if (before) { + replacements.push({ + type: "text", + value: before, + }); + } + + if (initialText) { + footnoteChildren.push({ + type: "text", + value: initialText, + }); + } + + for (let index = startIndex + 1; index < children.length; index += 1) { + const child = children[index]; + + if (child.type === "text") { + const closingIndex = findClosingBracket(child.value); + + if (closingIndex !== -1) { + const footnoteText = child.value.slice(0, closingIndex); + const after = child.value.slice(closingIndex + 1); + + if (footnoteText) { + footnoteChildren.push({ + type: "text", + value: footnoteText, + }); + } + + const reference = createFootnoteReference(context); + replacements.push(reference.node); + + if (after) { + replacements.push({ + type: "text", + value: after, + }); + } + + context.definitions.push( + createFootnoteDefinitionFromChildren( + reference.identifier, + reference.label, + footnoteChildren, + ), + ); + + return { + consumed: index - startIndex + 1, + replacements, + }; + } + + footnoteChildren.push(cloneNode(child)); + continue; + } + + footnoteChildren.push(cloneNode(child)); + } + + return null; +}; + +const visitChildren = (node, context) => { + if (!Array.isArray(node.children)) { + return; + } + + for (let index = 0; index < node.children.length; index += 1) { + const child = node.children[index]; + + if (child.type === "text" && child.value.includes("^[")) { + const acrossNodes = splitAcrossInlineNodes(node.children, index, context); + + if (acrossNodes) { + node.children.splice(index, acrossNodes.consumed, ...acrossNodes.replacements); + index += acrossNodes.replacements.length - 1; + continue; + } + + const replacements = splitTextNode(child, context); + node.children.splice(index, 1, ...replacements); + index += replacements.length - 1; + continue; + } + + visitChildren(child, context); + } +}; + +const remarkInlineFootnotes = () => (tree) => { + const context = { + count: 0, + definitions: [], + }; + + visitChildren(tree, context); + + if (context.definitions.length > 0) { + tree.children.push(...context.definitions); + } +}; + +export default remarkInlineFootnotes; diff --git a/sites/puzzles/astro.config.mjs b/sites/puzzles/astro.config.mjs index 9151a86..5efa078 100644 --- a/sites/puzzles/astro.config.mjs +++ b/sites/puzzles/astro.config.mjs @@ -9,9 +9,9 @@ import sitemap from "@astrojs/sitemap"; import react from "@astrojs/react"; // @ts-ignore - TailwindCSS v4 Vite plugin has type compatibility issues with Astro import tailwindcss from "@tailwindcss/vite"; -import remarkCallouts from "../../src/remark-callouts.mjs"; -import remarkCodeFenceLanguages from "../../src/remark-code-fence-languages.mjs"; -import remarkInlineFootnotes from "../../src/remark-inline-footnotes.mjs"; +import remarkCallouts from "./src/remark/callouts.mjs"; +import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs"; +import remarkInlineFootnotes from "./src/remark/inline-footnotes.mjs"; import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js"; const remarkPlugins = [ diff --git a/sites/puzzles/package-lock.json b/sites/puzzles/package-lock.json index 17e8dc6..5d158c8 100644 --- a/sites/puzzles/package-lock.json +++ b/sites/puzzles/package-lock.json @@ -36,12 +36,17 @@ "date-fns": "^4.1.0", "embla-carousel-autoplay": "^8.5.2", "embla-carousel-react": "^8.5.2", + "katex": "^0.17.0", "lucide-react": "^0.479.0", + "mdast-util-from-markdown": "^2.0.3", "react": "^19.0.0", "react-dom": "^19.0.0", "react-fast-marquee": "^1.6.5", "react-hook-form": "^7.54.2", "react-icons": "^5.4.0", + "rehype-katex": "^7.0.1", + "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", "tailwind-merge": "^3.0.2", "tailwindcss": "^4.0.12", "tailwindcss-animate": "^1.0.7", @@ -2826,6 +2831,60 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.4.3", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.0.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.4.3", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.0.2", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.11", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.9.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.9.0", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.0", + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.1.11", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.11.tgz", @@ -2970,6 +3029,12 @@ "@types/unist": "*" } }, + "node_modules/@types/katex": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz", + "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==", + "license": "MIT" + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -3979,6 +4044,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/common-ancestor-path": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/common-ancestor-path/-/common-ancestor-path-1.0.1.tgz", @@ -4691,6 +4765,21 @@ "uncrypto": "^0.1.3" } }, + "node_modules/hast-util-from-dom": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/hast-util-from-dom/-/hast-util-from-dom-5.0.1.tgz", + "integrity": "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==", + "license": "ISC", + "dependencies": { + "@types/hast": "^3.0.0", + "hastscript": "^9.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-from-html": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", @@ -4709,6 +4798,22 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-from-html-isomorphic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hast-util-from-html-isomorphic/-/hast-util-from-html-isomorphic-2.0.0.tgz", + "integrity": "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-dom": "^5.0.0", + "hast-util-from-html": "^2.0.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-from-parse5": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", @@ -5193,6 +5298,22 @@ "integrity": "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==", "license": "MIT" }, + "node_modules/katex": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.17.0.tgz", + "integrity": "sha512-Vdw0ATsQ9V+LuegM/BTwQqV/6cTl5lbGcIrU+BCgLxyf6bo38ybOr372tuSIxir3CN720flu1meYR6XzNMwQnw==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", @@ -5556,9 +5677,9 @@ } }, "node_modules/mdast-util-from-markdown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", - "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", @@ -5680,6 +5801,25 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-math": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-math/-/mdast-util-math-3.0.0.tgz", + "integrity": "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "longest-streak": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.1.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-mdx": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/mdast-util-mdx/-/mdast-util-mdx-3.0.0.tgz", @@ -6031,6 +6171,41 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/micromark-extension-math": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz", + "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==", + "license": "MIT", + "dependencies": { + "@types/katex": "^0.16.0", + "devlop": "^1.0.0", + "katex": "^0.16.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-math/node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, "node_modules/micromark-extension-mdx-expression": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/micromark-extension-mdx-expression/-/micromark-extension-mdx-expression-3.0.1.tgz", @@ -7291,6 +7466,41 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/rehype-katex": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz", + "integrity": "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/katex": "^0.16.0", + "hast-util-from-html-isomorphic": "^2.0.0", + "hast-util-to-text": "^4.0.0", + "katex": "^0.16.0", + "unist-util-visit-parents": "^6.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-katex/node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, "node_modules/rehype-parse": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/rehype-parse/-/rehype-parse-9.0.1.tgz", @@ -7369,6 +7579,22 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-math": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/remark-math/-/remark-math-6.0.0.tgz", + "integrity": "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-math": "^3.0.0", + "micromark-extension-math": "^3.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-mdx": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/remark-mdx/-/remark-mdx-3.1.0.tgz", diff --git a/sites/puzzles/package.json b/sites/puzzles/package.json index 97f5e9f..e47df79 100644 --- a/sites/puzzles/package.json +++ b/sites/puzzles/package.json @@ -48,12 +48,17 @@ "date-fns": "^4.1.0", "embla-carousel-autoplay": "^8.5.2", "embla-carousel-react": "^8.5.2", + "katex": "^0.17.0", "lucide-react": "^0.479.0", + "mdast-util-from-markdown": "^2.0.3", "react": "^19.0.0", "react-dom": "^19.0.0", "react-fast-marquee": "^1.6.5", "react-hook-form": "^7.54.2", "react-icons": "^5.4.0", + "rehype-katex": "^7.0.1", + "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", "tailwind-merge": "^3.0.2", "tailwindcss": "^4.0.12", "tailwindcss-animate": "^1.0.7", diff --git a/sites/puzzles/src/remark/callouts.mjs b/sites/puzzles/src/remark/callouts.mjs new file mode 100644 index 0000000..56f6a1f --- /dev/null +++ b/sites/puzzles/src/remark/callouts.mjs @@ -0,0 +1,101 @@ +const CALLOUT_LABELS = new Map([ + ["aside", "Aside"], + ["caution", "Caution"], + ["note", "Note"], + ["tip", "Tip"], + ["warning", "Warning"], +]); + +function textFromInline(node) { + if (!node) return ""; + if (typeof node.value === "string") return node.value; + if (!Array.isArray(node.children)) return ""; + return node.children.map(textFromInline).join(""); +} + +function normalizeLabel(value) { + return value + .trim() + .replace(/^["“”]+|["“”]+$/g, "") + .replace(/:$/, "") + .toLowerCase(); +} + +function paragraphCalloutLabel(paragraph) { + if (paragraph?.type !== "paragraph") return null; + + const first = paragraph.children?.[0]; + if (first?.type === "text") { + const match = first.value.match(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i); + if (match) { + return CALLOUT_LABELS.get(normalizeLabel(match[1])); + } + } + + if (first?.type !== "strong") return null; + + const label = CALLOUT_LABELS.get(normalizeLabel(textFromInline(first))); + if (!label) return null; + + return label; +} + +function removeLeadingLabel(paragraph, label) { + if (paragraph?.type !== "paragraph") return; + + const first = paragraph.children?.[0]; + if (first?.type === "text") { + first.value = first.value.replace(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i, ""); + if (!first.value) paragraph.children.shift(); + return; + } + + const firstText = normalizeLabel(textFromInline(first)); + if (!first || first.type !== "strong" || firstText !== label.toLowerCase()) { + return; + } + + paragraph.children.shift(); + + const next = paragraph.children[0]; + if (next?.type === "text") { + next.value = next.value.replace(/^:\s*/, "").replace(/^\s+/, ""); + if (!next.value) paragraph.children.shift(); + } +} + +function transformBlockquote(node) { + if (node.type !== "blockquote") return; + + const first = node.children?.[0]; + const label = paragraphCalloutLabel(first); + if (!label) return; + + removeLeadingLabel(first, label); + if (first.children?.length === 0) { + node.children.shift(); + } + + node.data = { + hName: "aside", + hProperties: { + className: ["callout", `callout-${label.toLowerCase()}`], + dataCalloutLabel: label, + }, + }; +} + +function visit(node) { + transformBlockquote(node); + + if (!Array.isArray(node.children)) return; + for (const child of node.children) { + visit(child); + } +} + +export default function remarkCallouts() { + return function transformer(tree) { + visit(tree); + }; +} diff --git a/sites/puzzles/src/remark/code-fence-languages.mjs b/sites/puzzles/src/remark/code-fence-languages.mjs new file mode 100644 index 0000000..240c7ec --- /dev/null +++ b/sites/puzzles/src/remark/code-fence-languages.mjs @@ -0,0 +1,29 @@ +const LANGUAGE_ALIASES = new Map([["ojs", "js"]]); + +const normalizeLanguage = (language) => { + if (!language) return language; + + const match = language.match(/^\{(.+)\}$/); + if (!match) return language; + + const normalized = match[1].trim().toLowerCase(); + return LANGUAGE_ALIASES.get(normalized) ?? normalized; +}; + +const visit = (node) => { + if (node.type === "code") { + node.lang = normalizeLanguage(node.lang); + } + + if (!Array.isArray(node.children)) return; + + for (const child of node.children) { + visit(child); + } +}; + +const remarkCodeFenceLanguages = () => (tree) => { + visit(tree); +}; + +export default remarkCodeFenceLanguages; diff --git a/sites/puzzles/src/remark/inline-footnotes.mjs b/sites/puzzles/src/remark/inline-footnotes.mjs new file mode 100644 index 0000000..ccdf9ee --- /dev/null +++ b/sites/puzzles/src/remark/inline-footnotes.mjs @@ -0,0 +1,289 @@ +import { fromMarkdown } from "mdast-util-from-markdown"; + +const INLINE_FOOTNOTE_PREFIX = "inline-footnote"; + +const findInlineFootnote = (value, fromIndex = 0) => { + const start = value.indexOf("^[", fromIndex); + + if (start === -1) { + return null; + } + + let depth = 1; + + for (let index = start + 2; index < value.length; index += 1) { + const character = value[index]; + + if (character === "\\" && index + 1 < value.length) { + index += 1; + continue; + } + + if (character === "[") { + depth += 1; + continue; + } + + if (character === "]") { + depth -= 1; + + if (depth === 0) { + return { + start, + end: index + 1, + value: value.slice(start + 2, index).trim(), + }; + } + } + } + + return null; +}; + +const createFootnoteChildren = (value) => { + const tree = fromMarkdown(value); + + if (tree.children.length > 0) { + return tree.children; + } + + return [ + { + type: "paragraph", + children: [ + { + type: "text", + value, + }, + ], + }, + ]; +}; + +const createFootnoteParagraph = (children) => ({ + type: "paragraph", + children: + children.length > 0 + ? children + : [ + { + type: "text", + value: "", + }, + ], +}); + +const createFootnoteDefinition = (identifier, label, value) => ({ + type: "footnoteDefinition", + identifier, + label, + children: createFootnoteChildren(value), +}); + +const createFootnoteDefinitionFromChildren = (identifier, label, children) => ({ + type: "footnoteDefinition", + identifier, + label, + children: [createFootnoteParagraph(children)], +}); + +const createFootnoteReference = (context) => { + context.count += 1; + + const label = String(context.count); + const identifier = `${INLINE_FOOTNOTE_PREFIX}-${label}`; + + return { + identifier, + label, + node: { + type: "footnoteReference", + identifier, + label, + }, + }; +}; + +const findClosingBracket = (value) => { + for (let index = 0; index < value.length; index += 1) { + const character = value[index]; + + if (character === "\\" && index + 1 < value.length) { + index += 1; + continue; + } + + if (character === "]") { + return index; + } + } + + return -1; +}; + +const cloneNode = (node) => JSON.parse(JSON.stringify(node)); + +const splitTextNode = (node, context) => { + const replacements = []; + let cursor = 0; + let match = findInlineFootnote(node.value, cursor); + + while (match) { + if (match.start > cursor) { + replacements.push({ + type: "text", + value: node.value.slice(cursor, match.start), + }); + } + + if (match.value) { + const reference = createFootnoteReference(context); + replacements.push(reference.node); + + context.definitions.push( + createFootnoteDefinition(reference.identifier, reference.label, match.value), + ); + } else { + replacements.push({ + type: "text", + value: node.value.slice(match.start, match.end), + }); + } + + cursor = match.end; + match = findInlineFootnote(node.value, cursor); + } + + if (cursor < node.value.length) { + replacements.push({ + type: "text", + value: node.value.slice(cursor), + }); + } + + return replacements; +}; + +const splitAcrossInlineNodes = (children, startIndex, context) => { + const startNode = children[startIndex]; + const start = startNode.value.indexOf("^["); + + if (start === -1) { + return null; + } + + if (findInlineFootnote(startNode.value, start)) { + return null; + } + + const footnoteChildren = []; + const replacements = []; + const before = startNode.value.slice(0, start); + const initialText = startNode.value.slice(start + 2); + + if (before) { + replacements.push({ + type: "text", + value: before, + }); + } + + if (initialText) { + footnoteChildren.push({ + type: "text", + value: initialText, + }); + } + + for (let index = startIndex + 1; index < children.length; index += 1) { + const child = children[index]; + + if (child.type === "text") { + const closingIndex = findClosingBracket(child.value); + + if (closingIndex !== -1) { + const footnoteText = child.value.slice(0, closingIndex); + const after = child.value.slice(closingIndex + 1); + + if (footnoteText) { + footnoteChildren.push({ + type: "text", + value: footnoteText, + }); + } + + const reference = createFootnoteReference(context); + replacements.push(reference.node); + + if (after) { + replacements.push({ + type: "text", + value: after, + }); + } + + context.definitions.push( + createFootnoteDefinitionFromChildren( + reference.identifier, + reference.label, + footnoteChildren, + ), + ); + + return { + consumed: index - startIndex + 1, + replacements, + }; + } + + footnoteChildren.push(cloneNode(child)); + continue; + } + + footnoteChildren.push(cloneNode(child)); + } + + return null; +}; + +const visitChildren = (node, context) => { + if (!Array.isArray(node.children)) { + return; + } + + for (let index = 0; index < node.children.length; index += 1) { + const child = node.children[index]; + + if (child.type === "text" && child.value.includes("^[")) { + const acrossNodes = splitAcrossInlineNodes(node.children, index, context); + + if (acrossNodes) { + node.children.splice(index, acrossNodes.consumed, ...acrossNodes.replacements); + index += acrossNodes.replacements.length - 1; + continue; + } + + const replacements = splitTextNode(child, context); + node.children.splice(index, 1, ...replacements); + index += replacements.length - 1; + continue; + } + + visitChildren(child, context); + } +}; + +const remarkInlineFootnotes = () => (tree) => { + const context = { + count: 0, + definitions: [], + }; + + visitChildren(tree, context); + + if (context.definitions.length > 0) { + tree.children.push(...context.definitions); + } +}; + +export default remarkInlineFootnotes; diff --git a/sites/reflections/astro.config.mjs b/sites/reflections/astro.config.mjs index 9151a86..5efa078 100644 --- a/sites/reflections/astro.config.mjs +++ b/sites/reflections/astro.config.mjs @@ -9,9 +9,9 @@ import sitemap from "@astrojs/sitemap"; import react from "@astrojs/react"; // @ts-ignore - TailwindCSS v4 Vite plugin has type compatibility issues with Astro import tailwindcss from "@tailwindcss/vite"; -import remarkCallouts from "../../src/remark-callouts.mjs"; -import remarkCodeFenceLanguages from "../../src/remark-code-fence-languages.mjs"; -import remarkInlineFootnotes from "../../src/remark-inline-footnotes.mjs"; +import remarkCallouts from "./src/remark/callouts.mjs"; +import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs"; +import remarkInlineFootnotes from "./src/remark/inline-footnotes.mjs"; import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js"; const remarkPlugins = [ diff --git a/sites/reflections/package-lock.json b/sites/reflections/package-lock.json index 17e8dc6..5d158c8 100644 --- a/sites/reflections/package-lock.json +++ b/sites/reflections/package-lock.json @@ -36,12 +36,17 @@ "date-fns": "^4.1.0", "embla-carousel-autoplay": "^8.5.2", "embla-carousel-react": "^8.5.2", + "katex": "^0.17.0", "lucide-react": "^0.479.0", + "mdast-util-from-markdown": "^2.0.3", "react": "^19.0.0", "react-dom": "^19.0.0", "react-fast-marquee": "^1.6.5", "react-hook-form": "^7.54.2", "react-icons": "^5.4.0", + "rehype-katex": "^7.0.1", + "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", "tailwind-merge": "^3.0.2", "tailwindcss": "^4.0.12", "tailwindcss-animate": "^1.0.7", @@ -2826,6 +2831,60 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.4.3", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.0.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.4.3", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.0.2", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.11", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.9.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.9.0", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.0", + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.1.11", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.11.tgz", @@ -2970,6 +3029,12 @@ "@types/unist": "*" } }, + "node_modules/@types/katex": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz", + "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==", + "license": "MIT" + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -3979,6 +4044,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/common-ancestor-path": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/common-ancestor-path/-/common-ancestor-path-1.0.1.tgz", @@ -4691,6 +4765,21 @@ "uncrypto": "^0.1.3" } }, + "node_modules/hast-util-from-dom": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/hast-util-from-dom/-/hast-util-from-dom-5.0.1.tgz", + "integrity": "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==", + "license": "ISC", + "dependencies": { + "@types/hast": "^3.0.0", + "hastscript": "^9.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-from-html": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", @@ -4709,6 +4798,22 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-from-html-isomorphic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hast-util-from-html-isomorphic/-/hast-util-from-html-isomorphic-2.0.0.tgz", + "integrity": "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-dom": "^5.0.0", + "hast-util-from-html": "^2.0.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-from-parse5": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", @@ -5193,6 +5298,22 @@ "integrity": "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==", "license": "MIT" }, + "node_modules/katex": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.17.0.tgz", + "integrity": "sha512-Vdw0ATsQ9V+LuegM/BTwQqV/6cTl5lbGcIrU+BCgLxyf6bo38ybOr372tuSIxir3CN720flu1meYR6XzNMwQnw==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", @@ -5556,9 +5677,9 @@ } }, "node_modules/mdast-util-from-markdown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", - "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", @@ -5680,6 +5801,25 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-math": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-math/-/mdast-util-math-3.0.0.tgz", + "integrity": "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "longest-streak": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.1.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-mdx": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/mdast-util-mdx/-/mdast-util-mdx-3.0.0.tgz", @@ -6031,6 +6171,41 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/micromark-extension-math": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz", + "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==", + "license": "MIT", + "dependencies": { + "@types/katex": "^0.16.0", + "devlop": "^1.0.0", + "katex": "^0.16.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-math/node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, "node_modules/micromark-extension-mdx-expression": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/micromark-extension-mdx-expression/-/micromark-extension-mdx-expression-3.0.1.tgz", @@ -7291,6 +7466,41 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/rehype-katex": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz", + "integrity": "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/katex": "^0.16.0", + "hast-util-from-html-isomorphic": "^2.0.0", + "hast-util-to-text": "^4.0.0", + "katex": "^0.16.0", + "unist-util-visit-parents": "^6.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-katex/node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, "node_modules/rehype-parse": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/rehype-parse/-/rehype-parse-9.0.1.tgz", @@ -7369,6 +7579,22 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-math": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/remark-math/-/remark-math-6.0.0.tgz", + "integrity": "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-math": "^3.0.0", + "micromark-extension-math": "^3.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-mdx": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/remark-mdx/-/remark-mdx-3.1.0.tgz", diff --git a/sites/reflections/package.json b/sites/reflections/package.json index 97f5e9f..e47df79 100644 --- a/sites/reflections/package.json +++ b/sites/reflections/package.json @@ -48,12 +48,17 @@ "date-fns": "^4.1.0", "embla-carousel-autoplay": "^8.5.2", "embla-carousel-react": "^8.5.2", + "katex": "^0.17.0", "lucide-react": "^0.479.0", + "mdast-util-from-markdown": "^2.0.3", "react": "^19.0.0", "react-dom": "^19.0.0", "react-fast-marquee": "^1.6.5", "react-hook-form": "^7.54.2", "react-icons": "^5.4.0", + "rehype-katex": "^7.0.1", + "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", "tailwind-merge": "^3.0.2", "tailwindcss": "^4.0.12", "tailwindcss-animate": "^1.0.7", diff --git a/sites/reflections/src/remark/callouts.mjs b/sites/reflections/src/remark/callouts.mjs new file mode 100644 index 0000000..56f6a1f --- /dev/null +++ b/sites/reflections/src/remark/callouts.mjs @@ -0,0 +1,101 @@ +const CALLOUT_LABELS = new Map([ + ["aside", "Aside"], + ["caution", "Caution"], + ["note", "Note"], + ["tip", "Tip"], + ["warning", "Warning"], +]); + +function textFromInline(node) { + if (!node) return ""; + if (typeof node.value === "string") return node.value; + if (!Array.isArray(node.children)) return ""; + return node.children.map(textFromInline).join(""); +} + +function normalizeLabel(value) { + return value + .trim() + .replace(/^["“”]+|["“”]+$/g, "") + .replace(/:$/, "") + .toLowerCase(); +} + +function paragraphCalloutLabel(paragraph) { + if (paragraph?.type !== "paragraph") return null; + + const first = paragraph.children?.[0]; + if (first?.type === "text") { + const match = first.value.match(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i); + if (match) { + return CALLOUT_LABELS.get(normalizeLabel(match[1])); + } + } + + if (first?.type !== "strong") return null; + + const label = CALLOUT_LABELS.get(normalizeLabel(textFromInline(first))); + if (!label) return null; + + return label; +} + +function removeLeadingLabel(paragraph, label) { + if (paragraph?.type !== "paragraph") return; + + const first = paragraph.children?.[0]; + if (first?.type === "text") { + first.value = first.value.replace(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i, ""); + if (!first.value) paragraph.children.shift(); + return; + } + + const firstText = normalizeLabel(textFromInline(first)); + if (!first || first.type !== "strong" || firstText !== label.toLowerCase()) { + return; + } + + paragraph.children.shift(); + + const next = paragraph.children[0]; + if (next?.type === "text") { + next.value = next.value.replace(/^:\s*/, "").replace(/^\s+/, ""); + if (!next.value) paragraph.children.shift(); + } +} + +function transformBlockquote(node) { + if (node.type !== "blockquote") return; + + const first = node.children?.[0]; + const label = paragraphCalloutLabel(first); + if (!label) return; + + removeLeadingLabel(first, label); + if (first.children?.length === 0) { + node.children.shift(); + } + + node.data = { + hName: "aside", + hProperties: { + className: ["callout", `callout-${label.toLowerCase()}`], + dataCalloutLabel: label, + }, + }; +} + +function visit(node) { + transformBlockquote(node); + + if (!Array.isArray(node.children)) return; + for (const child of node.children) { + visit(child); + } +} + +export default function remarkCallouts() { + return function transformer(tree) { + visit(tree); + }; +} diff --git a/sites/reflections/src/remark/code-fence-languages.mjs b/sites/reflections/src/remark/code-fence-languages.mjs new file mode 100644 index 0000000..240c7ec --- /dev/null +++ b/sites/reflections/src/remark/code-fence-languages.mjs @@ -0,0 +1,29 @@ +const LANGUAGE_ALIASES = new Map([["ojs", "js"]]); + +const normalizeLanguage = (language) => { + if (!language) return language; + + const match = language.match(/^\{(.+)\}$/); + if (!match) return language; + + const normalized = match[1].trim().toLowerCase(); + return LANGUAGE_ALIASES.get(normalized) ?? normalized; +}; + +const visit = (node) => { + if (node.type === "code") { + node.lang = normalizeLanguage(node.lang); + } + + if (!Array.isArray(node.children)) return; + + for (const child of node.children) { + visit(child); + } +}; + +const remarkCodeFenceLanguages = () => (tree) => { + visit(tree); +}; + +export default remarkCodeFenceLanguages; diff --git a/sites/reflections/src/remark/inline-footnotes.mjs b/sites/reflections/src/remark/inline-footnotes.mjs new file mode 100644 index 0000000..ccdf9ee --- /dev/null +++ b/sites/reflections/src/remark/inline-footnotes.mjs @@ -0,0 +1,289 @@ +import { fromMarkdown } from "mdast-util-from-markdown"; + +const INLINE_FOOTNOTE_PREFIX = "inline-footnote"; + +const findInlineFootnote = (value, fromIndex = 0) => { + const start = value.indexOf("^[", fromIndex); + + if (start === -1) { + return null; + } + + let depth = 1; + + for (let index = start + 2; index < value.length; index += 1) { + const character = value[index]; + + if (character === "\\" && index + 1 < value.length) { + index += 1; + continue; + } + + if (character === "[") { + depth += 1; + continue; + } + + if (character === "]") { + depth -= 1; + + if (depth === 0) { + return { + start, + end: index + 1, + value: value.slice(start + 2, index).trim(), + }; + } + } + } + + return null; +}; + +const createFootnoteChildren = (value) => { + const tree = fromMarkdown(value); + + if (tree.children.length > 0) { + return tree.children; + } + + return [ + { + type: "paragraph", + children: [ + { + type: "text", + value, + }, + ], + }, + ]; +}; + +const createFootnoteParagraph = (children) => ({ + type: "paragraph", + children: + children.length > 0 + ? children + : [ + { + type: "text", + value: "", + }, + ], +}); + +const createFootnoteDefinition = (identifier, label, value) => ({ + type: "footnoteDefinition", + identifier, + label, + children: createFootnoteChildren(value), +}); + +const createFootnoteDefinitionFromChildren = (identifier, label, children) => ({ + type: "footnoteDefinition", + identifier, + label, + children: [createFootnoteParagraph(children)], +}); + +const createFootnoteReference = (context) => { + context.count += 1; + + const label = String(context.count); + const identifier = `${INLINE_FOOTNOTE_PREFIX}-${label}`; + + return { + identifier, + label, + node: { + type: "footnoteReference", + identifier, + label, + }, + }; +}; + +const findClosingBracket = (value) => { + for (let index = 0; index < value.length; index += 1) { + const character = value[index]; + + if (character === "\\" && index + 1 < value.length) { + index += 1; + continue; + } + + if (character === "]") { + return index; + } + } + + return -1; +}; + +const cloneNode = (node) => JSON.parse(JSON.stringify(node)); + +const splitTextNode = (node, context) => { + const replacements = []; + let cursor = 0; + let match = findInlineFootnote(node.value, cursor); + + while (match) { + if (match.start > cursor) { + replacements.push({ + type: "text", + value: node.value.slice(cursor, match.start), + }); + } + + if (match.value) { + const reference = createFootnoteReference(context); + replacements.push(reference.node); + + context.definitions.push( + createFootnoteDefinition(reference.identifier, reference.label, match.value), + ); + } else { + replacements.push({ + type: "text", + value: node.value.slice(match.start, match.end), + }); + } + + cursor = match.end; + match = findInlineFootnote(node.value, cursor); + } + + if (cursor < node.value.length) { + replacements.push({ + type: "text", + value: node.value.slice(cursor), + }); + } + + return replacements; +}; + +const splitAcrossInlineNodes = (children, startIndex, context) => { + const startNode = children[startIndex]; + const start = startNode.value.indexOf("^["); + + if (start === -1) { + return null; + } + + if (findInlineFootnote(startNode.value, start)) { + return null; + } + + const footnoteChildren = []; + const replacements = []; + const before = startNode.value.slice(0, start); + const initialText = startNode.value.slice(start + 2); + + if (before) { + replacements.push({ + type: "text", + value: before, + }); + } + + if (initialText) { + footnoteChildren.push({ + type: "text", + value: initialText, + }); + } + + for (let index = startIndex + 1; index < children.length; index += 1) { + const child = children[index]; + + if (child.type === "text") { + const closingIndex = findClosingBracket(child.value); + + if (closingIndex !== -1) { + const footnoteText = child.value.slice(0, closingIndex); + const after = child.value.slice(closingIndex + 1); + + if (footnoteText) { + footnoteChildren.push({ + type: "text", + value: footnoteText, + }); + } + + const reference = createFootnoteReference(context); + replacements.push(reference.node); + + if (after) { + replacements.push({ + type: "text", + value: after, + }); + } + + context.definitions.push( + createFootnoteDefinitionFromChildren( + reference.identifier, + reference.label, + footnoteChildren, + ), + ); + + return { + consumed: index - startIndex + 1, + replacements, + }; + } + + footnoteChildren.push(cloneNode(child)); + continue; + } + + footnoteChildren.push(cloneNode(child)); + } + + return null; +}; + +const visitChildren = (node, context) => { + if (!Array.isArray(node.children)) { + return; + } + + for (let index = 0; index < node.children.length; index += 1) { + const child = node.children[index]; + + if (child.type === "text" && child.value.includes("^[")) { + const acrossNodes = splitAcrossInlineNodes(node.children, index, context); + + if (acrossNodes) { + node.children.splice(index, acrossNodes.consumed, ...acrossNodes.replacements); + index += acrossNodes.replacements.length - 1; + continue; + } + + const replacements = splitTextNode(child, context); + node.children.splice(index, 1, ...replacements); + index += replacements.length - 1; + continue; + } + + visitChildren(child, context); + } +}; + +const remarkInlineFootnotes = () => (tree) => { + const context = { + count: 0, + definitions: [], + }; + + visitChildren(tree, context); + + if (context.definitions.length > 0) { + tree.children.push(...context.definitions); + } +}; + +export default remarkInlineFootnotes; diff --git a/sites/research/astro.config.mjs b/sites/research/astro.config.mjs index 9151a86..5efa078 100644 --- a/sites/research/astro.config.mjs +++ b/sites/research/astro.config.mjs @@ -9,9 +9,9 @@ import sitemap from "@astrojs/sitemap"; import react from "@astrojs/react"; // @ts-ignore - TailwindCSS v4 Vite plugin has type compatibility issues with Astro import tailwindcss from "@tailwindcss/vite"; -import remarkCallouts from "../../src/remark-callouts.mjs"; -import remarkCodeFenceLanguages from "../../src/remark-code-fence-languages.mjs"; -import remarkInlineFootnotes from "../../src/remark-inline-footnotes.mjs"; +import remarkCallouts from "./src/remark/callouts.mjs"; +import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs"; +import remarkInlineFootnotes from "./src/remark/inline-footnotes.mjs"; import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js"; const remarkPlugins = [ diff --git a/sites/research/package-lock.json b/sites/research/package-lock.json index 17e8dc6..5d158c8 100644 --- a/sites/research/package-lock.json +++ b/sites/research/package-lock.json @@ -36,12 +36,17 @@ "date-fns": "^4.1.0", "embla-carousel-autoplay": "^8.5.2", "embla-carousel-react": "^8.5.2", + "katex": "^0.17.0", "lucide-react": "^0.479.0", + "mdast-util-from-markdown": "^2.0.3", "react": "^19.0.0", "react-dom": "^19.0.0", "react-fast-marquee": "^1.6.5", "react-hook-form": "^7.54.2", "react-icons": "^5.4.0", + "rehype-katex": "^7.0.1", + "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", "tailwind-merge": "^3.0.2", "tailwindcss": "^4.0.12", "tailwindcss-animate": "^1.0.7", @@ -2826,6 +2831,60 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.4.3", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.0.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.4.3", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.0.2", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.11", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.9.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.9.0", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.0", + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.1.11", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.11.tgz", @@ -2970,6 +3029,12 @@ "@types/unist": "*" } }, + "node_modules/@types/katex": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz", + "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==", + "license": "MIT" + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -3979,6 +4044,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/common-ancestor-path": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/common-ancestor-path/-/common-ancestor-path-1.0.1.tgz", @@ -4691,6 +4765,21 @@ "uncrypto": "^0.1.3" } }, + "node_modules/hast-util-from-dom": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/hast-util-from-dom/-/hast-util-from-dom-5.0.1.tgz", + "integrity": "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==", + "license": "ISC", + "dependencies": { + "@types/hast": "^3.0.0", + "hastscript": "^9.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-from-html": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", @@ -4709,6 +4798,22 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-from-html-isomorphic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hast-util-from-html-isomorphic/-/hast-util-from-html-isomorphic-2.0.0.tgz", + "integrity": "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-dom": "^5.0.0", + "hast-util-from-html": "^2.0.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-from-parse5": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", @@ -5193,6 +5298,22 @@ "integrity": "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==", "license": "MIT" }, + "node_modules/katex": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.17.0.tgz", + "integrity": "sha512-Vdw0ATsQ9V+LuegM/BTwQqV/6cTl5lbGcIrU+BCgLxyf6bo38ybOr372tuSIxir3CN720flu1meYR6XzNMwQnw==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", @@ -5556,9 +5677,9 @@ } }, "node_modules/mdast-util-from-markdown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", - "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", @@ -5680,6 +5801,25 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-math": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-math/-/mdast-util-math-3.0.0.tgz", + "integrity": "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "longest-streak": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.1.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-mdx": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/mdast-util-mdx/-/mdast-util-mdx-3.0.0.tgz", @@ -6031,6 +6171,41 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/micromark-extension-math": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz", + "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==", + "license": "MIT", + "dependencies": { + "@types/katex": "^0.16.0", + "devlop": "^1.0.0", + "katex": "^0.16.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-math/node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, "node_modules/micromark-extension-mdx-expression": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/micromark-extension-mdx-expression/-/micromark-extension-mdx-expression-3.0.1.tgz", @@ -7291,6 +7466,41 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/rehype-katex": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz", + "integrity": "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/katex": "^0.16.0", + "hast-util-from-html-isomorphic": "^2.0.0", + "hast-util-to-text": "^4.0.0", + "katex": "^0.16.0", + "unist-util-visit-parents": "^6.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-katex/node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, "node_modules/rehype-parse": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/rehype-parse/-/rehype-parse-9.0.1.tgz", @@ -7369,6 +7579,22 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-math": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/remark-math/-/remark-math-6.0.0.tgz", + "integrity": "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-math": "^3.0.0", + "micromark-extension-math": "^3.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-mdx": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/remark-mdx/-/remark-mdx-3.1.0.tgz", diff --git a/sites/research/package.json b/sites/research/package.json index 97f5e9f..e47df79 100644 --- a/sites/research/package.json +++ b/sites/research/package.json @@ -48,12 +48,17 @@ "date-fns": "^4.1.0", "embla-carousel-autoplay": "^8.5.2", "embla-carousel-react": "^8.5.2", + "katex": "^0.17.0", "lucide-react": "^0.479.0", + "mdast-util-from-markdown": "^2.0.3", "react": "^19.0.0", "react-dom": "^19.0.0", "react-fast-marquee": "^1.6.5", "react-hook-form": "^7.54.2", "react-icons": "^5.4.0", + "rehype-katex": "^7.0.1", + "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", "tailwind-merge": "^3.0.2", "tailwindcss": "^4.0.12", "tailwindcss-animate": "^1.0.7", diff --git a/sites/research/src/remark/callouts.mjs b/sites/research/src/remark/callouts.mjs new file mode 100644 index 0000000..56f6a1f --- /dev/null +++ b/sites/research/src/remark/callouts.mjs @@ -0,0 +1,101 @@ +const CALLOUT_LABELS = new Map([ + ["aside", "Aside"], + ["caution", "Caution"], + ["note", "Note"], + ["tip", "Tip"], + ["warning", "Warning"], +]); + +function textFromInline(node) { + if (!node) return ""; + if (typeof node.value === "string") return node.value; + if (!Array.isArray(node.children)) return ""; + return node.children.map(textFromInline).join(""); +} + +function normalizeLabel(value) { + return value + .trim() + .replace(/^["“”]+|["“”]+$/g, "") + .replace(/:$/, "") + .toLowerCase(); +} + +function paragraphCalloutLabel(paragraph) { + if (paragraph?.type !== "paragraph") return null; + + const first = paragraph.children?.[0]; + if (first?.type === "text") { + const match = first.value.match(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i); + if (match) { + return CALLOUT_LABELS.get(normalizeLabel(match[1])); + } + } + + if (first?.type !== "strong") return null; + + const label = CALLOUT_LABELS.get(normalizeLabel(textFromInline(first))); + if (!label) return null; + + return label; +} + +function removeLeadingLabel(paragraph, label) { + if (paragraph?.type !== "paragraph") return; + + const first = paragraph.children?.[0]; + if (first?.type === "text") { + first.value = first.value.replace(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i, ""); + if (!first.value) paragraph.children.shift(); + return; + } + + const firstText = normalizeLabel(textFromInline(first)); + if (!first || first.type !== "strong" || firstText !== label.toLowerCase()) { + return; + } + + paragraph.children.shift(); + + const next = paragraph.children[0]; + if (next?.type === "text") { + next.value = next.value.replace(/^:\s*/, "").replace(/^\s+/, ""); + if (!next.value) paragraph.children.shift(); + } +} + +function transformBlockquote(node) { + if (node.type !== "blockquote") return; + + const first = node.children?.[0]; + const label = paragraphCalloutLabel(first); + if (!label) return; + + removeLeadingLabel(first, label); + if (first.children?.length === 0) { + node.children.shift(); + } + + node.data = { + hName: "aside", + hProperties: { + className: ["callout", `callout-${label.toLowerCase()}`], + dataCalloutLabel: label, + }, + }; +} + +function visit(node) { + transformBlockquote(node); + + if (!Array.isArray(node.children)) return; + for (const child of node.children) { + visit(child); + } +} + +export default function remarkCallouts() { + return function transformer(tree) { + visit(tree); + }; +} diff --git a/sites/research/src/remark/code-fence-languages.mjs b/sites/research/src/remark/code-fence-languages.mjs new file mode 100644 index 0000000..240c7ec --- /dev/null +++ b/sites/research/src/remark/code-fence-languages.mjs @@ -0,0 +1,29 @@ +const LANGUAGE_ALIASES = new Map([["ojs", "js"]]); + +const normalizeLanguage = (language) => { + if (!language) return language; + + const match = language.match(/^\{(.+)\}$/); + if (!match) return language; + + const normalized = match[1].trim().toLowerCase(); + return LANGUAGE_ALIASES.get(normalized) ?? normalized; +}; + +const visit = (node) => { + if (node.type === "code") { + node.lang = normalizeLanguage(node.lang); + } + + if (!Array.isArray(node.children)) return; + + for (const child of node.children) { + visit(child); + } +}; + +const remarkCodeFenceLanguages = () => (tree) => { + visit(tree); +}; + +export default remarkCodeFenceLanguages; diff --git a/sites/research/src/remark/inline-footnotes.mjs b/sites/research/src/remark/inline-footnotes.mjs new file mode 100644 index 0000000..ccdf9ee --- /dev/null +++ b/sites/research/src/remark/inline-footnotes.mjs @@ -0,0 +1,289 @@ +import { fromMarkdown } from "mdast-util-from-markdown"; + +const INLINE_FOOTNOTE_PREFIX = "inline-footnote"; + +const findInlineFootnote = (value, fromIndex = 0) => { + const start = value.indexOf("^[", fromIndex); + + if (start === -1) { + return null; + } + + let depth = 1; + + for (let index = start + 2; index < value.length; index += 1) { + const character = value[index]; + + if (character === "\\" && index + 1 < value.length) { + index += 1; + continue; + } + + if (character === "[") { + depth += 1; + continue; + } + + if (character === "]") { + depth -= 1; + + if (depth === 0) { + return { + start, + end: index + 1, + value: value.slice(start + 2, index).trim(), + }; + } + } + } + + return null; +}; + +const createFootnoteChildren = (value) => { + const tree = fromMarkdown(value); + + if (tree.children.length > 0) { + return tree.children; + } + + return [ + { + type: "paragraph", + children: [ + { + type: "text", + value, + }, + ], + }, + ]; +}; + +const createFootnoteParagraph = (children) => ({ + type: "paragraph", + children: + children.length > 0 + ? children + : [ + { + type: "text", + value: "", + }, + ], +}); + +const createFootnoteDefinition = (identifier, label, value) => ({ + type: "footnoteDefinition", + identifier, + label, + children: createFootnoteChildren(value), +}); + +const createFootnoteDefinitionFromChildren = (identifier, label, children) => ({ + type: "footnoteDefinition", + identifier, + label, + children: [createFootnoteParagraph(children)], +}); + +const createFootnoteReference = (context) => { + context.count += 1; + + const label = String(context.count); + const identifier = `${INLINE_FOOTNOTE_PREFIX}-${label}`; + + return { + identifier, + label, + node: { + type: "footnoteReference", + identifier, + label, + }, + }; +}; + +const findClosingBracket = (value) => { + for (let index = 0; index < value.length; index += 1) { + const character = value[index]; + + if (character === "\\" && index + 1 < value.length) { + index += 1; + continue; + } + + if (character === "]") { + return index; + } + } + + return -1; +}; + +const cloneNode = (node) => JSON.parse(JSON.stringify(node)); + +const splitTextNode = (node, context) => { + const replacements = []; + let cursor = 0; + let match = findInlineFootnote(node.value, cursor); + + while (match) { + if (match.start > cursor) { + replacements.push({ + type: "text", + value: node.value.slice(cursor, match.start), + }); + } + + if (match.value) { + const reference = createFootnoteReference(context); + replacements.push(reference.node); + + context.definitions.push( + createFootnoteDefinition(reference.identifier, reference.label, match.value), + ); + } else { + replacements.push({ + type: "text", + value: node.value.slice(match.start, match.end), + }); + } + + cursor = match.end; + match = findInlineFootnote(node.value, cursor); + } + + if (cursor < node.value.length) { + replacements.push({ + type: "text", + value: node.value.slice(cursor), + }); + } + + return replacements; +}; + +const splitAcrossInlineNodes = (children, startIndex, context) => { + const startNode = children[startIndex]; + const start = startNode.value.indexOf("^["); + + if (start === -1) { + return null; + } + + if (findInlineFootnote(startNode.value, start)) { + return null; + } + + const footnoteChildren = []; + const replacements = []; + const before = startNode.value.slice(0, start); + const initialText = startNode.value.slice(start + 2); + + if (before) { + replacements.push({ + type: "text", + value: before, + }); + } + + if (initialText) { + footnoteChildren.push({ + type: "text", + value: initialText, + }); + } + + for (let index = startIndex + 1; index < children.length; index += 1) { + const child = children[index]; + + if (child.type === "text") { + const closingIndex = findClosingBracket(child.value); + + if (closingIndex !== -1) { + const footnoteText = child.value.slice(0, closingIndex); + const after = child.value.slice(closingIndex + 1); + + if (footnoteText) { + footnoteChildren.push({ + type: "text", + value: footnoteText, + }); + } + + const reference = createFootnoteReference(context); + replacements.push(reference.node); + + if (after) { + replacements.push({ + type: "text", + value: after, + }); + } + + context.definitions.push( + createFootnoteDefinitionFromChildren( + reference.identifier, + reference.label, + footnoteChildren, + ), + ); + + return { + consumed: index - startIndex + 1, + replacements, + }; + } + + footnoteChildren.push(cloneNode(child)); + continue; + } + + footnoteChildren.push(cloneNode(child)); + } + + return null; +}; + +const visitChildren = (node, context) => { + if (!Array.isArray(node.children)) { + return; + } + + for (let index = 0; index < node.children.length; index += 1) { + const child = node.children[index]; + + if (child.type === "text" && child.value.includes("^[")) { + const acrossNodes = splitAcrossInlineNodes(node.children, index, context); + + if (acrossNodes) { + node.children.splice(index, acrossNodes.consumed, ...acrossNodes.replacements); + index += acrossNodes.replacements.length - 1; + continue; + } + + const replacements = splitTextNode(child, context); + node.children.splice(index, 1, ...replacements); + index += replacements.length - 1; + continue; + } + + visitChildren(child, context); + } +}; + +const remarkInlineFootnotes = () => (tree) => { + const context = { + count: 0, + definitions: [], + }; + + visitChildren(tree, context); + + if (context.definitions.length > 0) { + tree.children.push(...context.definitions); + } +}; + +export default remarkInlineFootnotes; diff --git a/sites/reviews/astro.config.mjs b/sites/reviews/astro.config.mjs index 9151a86..5efa078 100644 --- a/sites/reviews/astro.config.mjs +++ b/sites/reviews/astro.config.mjs @@ -9,9 +9,9 @@ import sitemap from "@astrojs/sitemap"; import react from "@astrojs/react"; // @ts-ignore - TailwindCSS v4 Vite plugin has type compatibility issues with Astro import tailwindcss from "@tailwindcss/vite"; -import remarkCallouts from "../../src/remark-callouts.mjs"; -import remarkCodeFenceLanguages from "../../src/remark-code-fence-languages.mjs"; -import remarkInlineFootnotes from "../../src/remark-inline-footnotes.mjs"; +import remarkCallouts from "./src/remark/callouts.mjs"; +import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs"; +import remarkInlineFootnotes from "./src/remark/inline-footnotes.mjs"; import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js"; const remarkPlugins = [ diff --git a/sites/reviews/package-lock.json b/sites/reviews/package-lock.json index 17e8dc6..5d158c8 100644 --- a/sites/reviews/package-lock.json +++ b/sites/reviews/package-lock.json @@ -36,12 +36,17 @@ "date-fns": "^4.1.0", "embla-carousel-autoplay": "^8.5.2", "embla-carousel-react": "^8.5.2", + "katex": "^0.17.0", "lucide-react": "^0.479.0", + "mdast-util-from-markdown": "^2.0.3", "react": "^19.0.0", "react-dom": "^19.0.0", "react-fast-marquee": "^1.6.5", "react-hook-form": "^7.54.2", "react-icons": "^5.4.0", + "rehype-katex": "^7.0.1", + "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", "tailwind-merge": "^3.0.2", "tailwindcss": "^4.0.12", "tailwindcss-animate": "^1.0.7", @@ -2826,6 +2831,60 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.4.3", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.0.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.4.3", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.0.2", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.11", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.9.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.9.0", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.0", + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.1.11", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.11.tgz", @@ -2970,6 +3029,12 @@ "@types/unist": "*" } }, + "node_modules/@types/katex": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz", + "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==", + "license": "MIT" + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -3979,6 +4044,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/common-ancestor-path": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/common-ancestor-path/-/common-ancestor-path-1.0.1.tgz", @@ -4691,6 +4765,21 @@ "uncrypto": "^0.1.3" } }, + "node_modules/hast-util-from-dom": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/hast-util-from-dom/-/hast-util-from-dom-5.0.1.tgz", + "integrity": "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==", + "license": "ISC", + "dependencies": { + "@types/hast": "^3.0.0", + "hastscript": "^9.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-from-html": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", @@ -4709,6 +4798,22 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-from-html-isomorphic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hast-util-from-html-isomorphic/-/hast-util-from-html-isomorphic-2.0.0.tgz", + "integrity": "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-dom": "^5.0.0", + "hast-util-from-html": "^2.0.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-from-parse5": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", @@ -5193,6 +5298,22 @@ "integrity": "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==", "license": "MIT" }, + "node_modules/katex": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.17.0.tgz", + "integrity": "sha512-Vdw0ATsQ9V+LuegM/BTwQqV/6cTl5lbGcIrU+BCgLxyf6bo38ybOr372tuSIxir3CN720flu1meYR6XzNMwQnw==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", @@ -5556,9 +5677,9 @@ } }, "node_modules/mdast-util-from-markdown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", - "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", @@ -5680,6 +5801,25 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-math": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-math/-/mdast-util-math-3.0.0.tgz", + "integrity": "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "longest-streak": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.1.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-mdx": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/mdast-util-mdx/-/mdast-util-mdx-3.0.0.tgz", @@ -6031,6 +6171,41 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/micromark-extension-math": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz", + "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==", + "license": "MIT", + "dependencies": { + "@types/katex": "^0.16.0", + "devlop": "^1.0.0", + "katex": "^0.16.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-math/node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, "node_modules/micromark-extension-mdx-expression": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/micromark-extension-mdx-expression/-/micromark-extension-mdx-expression-3.0.1.tgz", @@ -7291,6 +7466,41 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/rehype-katex": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz", + "integrity": "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/katex": "^0.16.0", + "hast-util-from-html-isomorphic": "^2.0.0", + "hast-util-to-text": "^4.0.0", + "katex": "^0.16.0", + "unist-util-visit-parents": "^6.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-katex/node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, "node_modules/rehype-parse": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/rehype-parse/-/rehype-parse-9.0.1.tgz", @@ -7369,6 +7579,22 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-math": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/remark-math/-/remark-math-6.0.0.tgz", + "integrity": "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-math": "^3.0.0", + "micromark-extension-math": "^3.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-mdx": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/remark-mdx/-/remark-mdx-3.1.0.tgz", diff --git a/sites/reviews/package.json b/sites/reviews/package.json index 97f5e9f..e47df79 100644 --- a/sites/reviews/package.json +++ b/sites/reviews/package.json @@ -48,12 +48,17 @@ "date-fns": "^4.1.0", "embla-carousel-autoplay": "^8.5.2", "embla-carousel-react": "^8.5.2", + "katex": "^0.17.0", "lucide-react": "^0.479.0", + "mdast-util-from-markdown": "^2.0.3", "react": "^19.0.0", "react-dom": "^19.0.0", "react-fast-marquee": "^1.6.5", "react-hook-form": "^7.54.2", "react-icons": "^5.4.0", + "rehype-katex": "^7.0.1", + "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", "tailwind-merge": "^3.0.2", "tailwindcss": "^4.0.12", "tailwindcss-animate": "^1.0.7", diff --git a/sites/reviews/src/remark/callouts.mjs b/sites/reviews/src/remark/callouts.mjs new file mode 100644 index 0000000..56f6a1f --- /dev/null +++ b/sites/reviews/src/remark/callouts.mjs @@ -0,0 +1,101 @@ +const CALLOUT_LABELS = new Map([ + ["aside", "Aside"], + ["caution", "Caution"], + ["note", "Note"], + ["tip", "Tip"], + ["warning", "Warning"], +]); + +function textFromInline(node) { + if (!node) return ""; + if (typeof node.value === "string") return node.value; + if (!Array.isArray(node.children)) return ""; + return node.children.map(textFromInline).join(""); +} + +function normalizeLabel(value) { + return value + .trim() + .replace(/^["“”]+|["“”]+$/g, "") + .replace(/:$/, "") + .toLowerCase(); +} + +function paragraphCalloutLabel(paragraph) { + if (paragraph?.type !== "paragraph") return null; + + const first = paragraph.children?.[0]; + if (first?.type === "text") { + const match = first.value.match(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i); + if (match) { + return CALLOUT_LABELS.get(normalizeLabel(match[1])); + } + } + + if (first?.type !== "strong") return null; + + const label = CALLOUT_LABELS.get(normalizeLabel(textFromInline(first))); + if (!label) return null; + + return label; +} + +function removeLeadingLabel(paragraph, label) { + if (paragraph?.type !== "paragraph") return; + + const first = paragraph.children?.[0]; + if (first?.type === "text") { + first.value = first.value.replace(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i, ""); + if (!first.value) paragraph.children.shift(); + return; + } + + const firstText = normalizeLabel(textFromInline(first)); + if (!first || first.type !== "strong" || firstText !== label.toLowerCase()) { + return; + } + + paragraph.children.shift(); + + const next = paragraph.children[0]; + if (next?.type === "text") { + next.value = next.value.replace(/^:\s*/, "").replace(/^\s+/, ""); + if (!next.value) paragraph.children.shift(); + } +} + +function transformBlockquote(node) { + if (node.type !== "blockquote") return; + + const first = node.children?.[0]; + const label = paragraphCalloutLabel(first); + if (!label) return; + + removeLeadingLabel(first, label); + if (first.children?.length === 0) { + node.children.shift(); + } + + node.data = { + hName: "aside", + hProperties: { + className: ["callout", `callout-${label.toLowerCase()}`], + dataCalloutLabel: label, + }, + }; +} + +function visit(node) { + transformBlockquote(node); + + if (!Array.isArray(node.children)) return; + for (const child of node.children) { + visit(child); + } +} + +export default function remarkCallouts() { + return function transformer(tree) { + visit(tree); + }; +} diff --git a/sites/reviews/src/remark/code-fence-languages.mjs b/sites/reviews/src/remark/code-fence-languages.mjs new file mode 100644 index 0000000..240c7ec --- /dev/null +++ b/sites/reviews/src/remark/code-fence-languages.mjs @@ -0,0 +1,29 @@ +const LANGUAGE_ALIASES = new Map([["ojs", "js"]]); + +const normalizeLanguage = (language) => { + if (!language) return language; + + const match = language.match(/^\{(.+)\}$/); + if (!match) return language; + + const normalized = match[1].trim().toLowerCase(); + return LANGUAGE_ALIASES.get(normalized) ?? normalized; +}; + +const visit = (node) => { + if (node.type === "code") { + node.lang = normalizeLanguage(node.lang); + } + + if (!Array.isArray(node.children)) return; + + for (const child of node.children) { + visit(child); + } +}; + +const remarkCodeFenceLanguages = () => (tree) => { + visit(tree); +}; + +export default remarkCodeFenceLanguages; diff --git a/sites/reviews/src/remark/inline-footnotes.mjs b/sites/reviews/src/remark/inline-footnotes.mjs new file mode 100644 index 0000000..ccdf9ee --- /dev/null +++ b/sites/reviews/src/remark/inline-footnotes.mjs @@ -0,0 +1,289 @@ +import { fromMarkdown } from "mdast-util-from-markdown"; + +const INLINE_FOOTNOTE_PREFIX = "inline-footnote"; + +const findInlineFootnote = (value, fromIndex = 0) => { + const start = value.indexOf("^[", fromIndex); + + if (start === -1) { + return null; + } + + let depth = 1; + + for (let index = start + 2; index < value.length; index += 1) { + const character = value[index]; + + if (character === "\\" && index + 1 < value.length) { + index += 1; + continue; + } + + if (character === "[") { + depth += 1; + continue; + } + + if (character === "]") { + depth -= 1; + + if (depth === 0) { + return { + start, + end: index + 1, + value: value.slice(start + 2, index).trim(), + }; + } + } + } + + return null; +}; + +const createFootnoteChildren = (value) => { + const tree = fromMarkdown(value); + + if (tree.children.length > 0) { + return tree.children; + } + + return [ + { + type: "paragraph", + children: [ + { + type: "text", + value, + }, + ], + }, + ]; +}; + +const createFootnoteParagraph = (children) => ({ + type: "paragraph", + children: + children.length > 0 + ? children + : [ + { + type: "text", + value: "", + }, + ], +}); + +const createFootnoteDefinition = (identifier, label, value) => ({ + type: "footnoteDefinition", + identifier, + label, + children: createFootnoteChildren(value), +}); + +const createFootnoteDefinitionFromChildren = (identifier, label, children) => ({ + type: "footnoteDefinition", + identifier, + label, + children: [createFootnoteParagraph(children)], +}); + +const createFootnoteReference = (context) => { + context.count += 1; + + const label = String(context.count); + const identifier = `${INLINE_FOOTNOTE_PREFIX}-${label}`; + + return { + identifier, + label, + node: { + type: "footnoteReference", + identifier, + label, + }, + }; +}; + +const findClosingBracket = (value) => { + for (let index = 0; index < value.length; index += 1) { + const character = value[index]; + + if (character === "\\" && index + 1 < value.length) { + index += 1; + continue; + } + + if (character === "]") { + return index; + } + } + + return -1; +}; + +const cloneNode = (node) => JSON.parse(JSON.stringify(node)); + +const splitTextNode = (node, context) => { + const replacements = []; + let cursor = 0; + let match = findInlineFootnote(node.value, cursor); + + while (match) { + if (match.start > cursor) { + replacements.push({ + type: "text", + value: node.value.slice(cursor, match.start), + }); + } + + if (match.value) { + const reference = createFootnoteReference(context); + replacements.push(reference.node); + + context.definitions.push( + createFootnoteDefinition(reference.identifier, reference.label, match.value), + ); + } else { + replacements.push({ + type: "text", + value: node.value.slice(match.start, match.end), + }); + } + + cursor = match.end; + match = findInlineFootnote(node.value, cursor); + } + + if (cursor < node.value.length) { + replacements.push({ + type: "text", + value: node.value.slice(cursor), + }); + } + + return replacements; +}; + +const splitAcrossInlineNodes = (children, startIndex, context) => { + const startNode = children[startIndex]; + const start = startNode.value.indexOf("^["); + + if (start === -1) { + return null; + } + + if (findInlineFootnote(startNode.value, start)) { + return null; + } + + const footnoteChildren = []; + const replacements = []; + const before = startNode.value.slice(0, start); + const initialText = startNode.value.slice(start + 2); + + if (before) { + replacements.push({ + type: "text", + value: before, + }); + } + + if (initialText) { + footnoteChildren.push({ + type: "text", + value: initialText, + }); + } + + for (let index = startIndex + 1; index < children.length; index += 1) { + const child = children[index]; + + if (child.type === "text") { + const closingIndex = findClosingBracket(child.value); + + if (closingIndex !== -1) { + const footnoteText = child.value.slice(0, closingIndex); + const after = child.value.slice(closingIndex + 1); + + if (footnoteText) { + footnoteChildren.push({ + type: "text", + value: footnoteText, + }); + } + + const reference = createFootnoteReference(context); + replacements.push(reference.node); + + if (after) { + replacements.push({ + type: "text", + value: after, + }); + } + + context.definitions.push( + createFootnoteDefinitionFromChildren( + reference.identifier, + reference.label, + footnoteChildren, + ), + ); + + return { + consumed: index - startIndex + 1, + replacements, + }; + } + + footnoteChildren.push(cloneNode(child)); + continue; + } + + footnoteChildren.push(cloneNode(child)); + } + + return null; +}; + +const visitChildren = (node, context) => { + if (!Array.isArray(node.children)) { + return; + } + + for (let index = 0; index < node.children.length; index += 1) { + const child = node.children[index]; + + if (child.type === "text" && child.value.includes("^[")) { + const acrossNodes = splitAcrossInlineNodes(node.children, index, context); + + if (acrossNodes) { + node.children.splice(index, acrossNodes.consumed, ...acrossNodes.replacements); + index += acrossNodes.replacements.length - 1; + continue; + } + + const replacements = splitTextNode(child, context); + node.children.splice(index, 1, ...replacements); + index += replacements.length - 1; + continue; + } + + visitChildren(child, context); + } +}; + +const remarkInlineFootnotes = () => (tree) => { + const context = { + count: 0, + definitions: [], + }; + + visitChildren(tree, context); + + if (context.definitions.length > 0) { + tree.children.push(...context.definitions); + } +}; + +export default remarkInlineFootnotes; diff --git a/sites/vibes/astro.config.mjs b/sites/vibes/astro.config.mjs index 9151a86..5efa078 100644 --- a/sites/vibes/astro.config.mjs +++ b/sites/vibes/astro.config.mjs @@ -9,9 +9,9 @@ import sitemap from "@astrojs/sitemap"; import react from "@astrojs/react"; // @ts-ignore - TailwindCSS v4 Vite plugin has type compatibility issues with Astro import tailwindcss from "@tailwindcss/vite"; -import remarkCallouts from "../../src/remark-callouts.mjs"; -import remarkCodeFenceLanguages from "../../src/remark-code-fence-languages.mjs"; -import remarkInlineFootnotes from "../../src/remark-inline-footnotes.mjs"; +import remarkCallouts from "./src/remark/callouts.mjs"; +import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs"; +import remarkInlineFootnotes from "./src/remark/inline-footnotes.mjs"; import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js"; const remarkPlugins = [ diff --git a/sites/vibes/package-lock.json b/sites/vibes/package-lock.json index 17e8dc6..5d158c8 100644 --- a/sites/vibes/package-lock.json +++ b/sites/vibes/package-lock.json @@ -36,12 +36,17 @@ "date-fns": "^4.1.0", "embla-carousel-autoplay": "^8.5.2", "embla-carousel-react": "^8.5.2", + "katex": "^0.17.0", "lucide-react": "^0.479.0", + "mdast-util-from-markdown": "^2.0.3", "react": "^19.0.0", "react-dom": "^19.0.0", "react-fast-marquee": "^1.6.5", "react-hook-form": "^7.54.2", "react-icons": "^5.4.0", + "rehype-katex": "^7.0.1", + "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", "tailwind-merge": "^3.0.2", "tailwindcss": "^4.0.12", "tailwindcss-animate": "^1.0.7", @@ -2826,6 +2831,60 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.4.3", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.0.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.4.3", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.0.2", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.11", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.9.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.9.0", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.0", + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.1.11", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.11.tgz", @@ -2970,6 +3029,12 @@ "@types/unist": "*" } }, + "node_modules/@types/katex": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz", + "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==", + "license": "MIT" + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -3979,6 +4044,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/common-ancestor-path": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/common-ancestor-path/-/common-ancestor-path-1.0.1.tgz", @@ -4691,6 +4765,21 @@ "uncrypto": "^0.1.3" } }, + "node_modules/hast-util-from-dom": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/hast-util-from-dom/-/hast-util-from-dom-5.0.1.tgz", + "integrity": "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==", + "license": "ISC", + "dependencies": { + "@types/hast": "^3.0.0", + "hastscript": "^9.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-from-html": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", @@ -4709,6 +4798,22 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-from-html-isomorphic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hast-util-from-html-isomorphic/-/hast-util-from-html-isomorphic-2.0.0.tgz", + "integrity": "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-dom": "^5.0.0", + "hast-util-from-html": "^2.0.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-from-parse5": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", @@ -5193,6 +5298,22 @@ "integrity": "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==", "license": "MIT" }, + "node_modules/katex": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.17.0.tgz", + "integrity": "sha512-Vdw0ATsQ9V+LuegM/BTwQqV/6cTl5lbGcIrU+BCgLxyf6bo38ybOr372tuSIxir3CN720flu1meYR6XzNMwQnw==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", @@ -5556,9 +5677,9 @@ } }, "node_modules/mdast-util-from-markdown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", - "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", @@ -5680,6 +5801,25 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-math": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-math/-/mdast-util-math-3.0.0.tgz", + "integrity": "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "longest-streak": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.1.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-mdx": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/mdast-util-mdx/-/mdast-util-mdx-3.0.0.tgz", @@ -6031,6 +6171,41 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/micromark-extension-math": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz", + "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==", + "license": "MIT", + "dependencies": { + "@types/katex": "^0.16.0", + "devlop": "^1.0.0", + "katex": "^0.16.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-math/node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, "node_modules/micromark-extension-mdx-expression": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/micromark-extension-mdx-expression/-/micromark-extension-mdx-expression-3.0.1.tgz", @@ -7291,6 +7466,41 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/rehype-katex": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz", + "integrity": "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/katex": "^0.16.0", + "hast-util-from-html-isomorphic": "^2.0.0", + "hast-util-to-text": "^4.0.0", + "katex": "^0.16.0", + "unist-util-visit-parents": "^6.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-katex/node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, "node_modules/rehype-parse": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/rehype-parse/-/rehype-parse-9.0.1.tgz", @@ -7369,6 +7579,22 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-math": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/remark-math/-/remark-math-6.0.0.tgz", + "integrity": "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-math": "^3.0.0", + "micromark-extension-math": "^3.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-mdx": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/remark-mdx/-/remark-mdx-3.1.0.tgz", diff --git a/sites/vibes/package.json b/sites/vibes/package.json index 97f5e9f..e47df79 100644 --- a/sites/vibes/package.json +++ b/sites/vibes/package.json @@ -48,12 +48,17 @@ "date-fns": "^4.1.0", "embla-carousel-autoplay": "^8.5.2", "embla-carousel-react": "^8.5.2", + "katex": "^0.17.0", "lucide-react": "^0.479.0", + "mdast-util-from-markdown": "^2.0.3", "react": "^19.0.0", "react-dom": "^19.0.0", "react-fast-marquee": "^1.6.5", "react-hook-form": "^7.54.2", "react-icons": "^5.4.0", + "rehype-katex": "^7.0.1", + "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", "tailwind-merge": "^3.0.2", "tailwindcss": "^4.0.12", "tailwindcss-animate": "^1.0.7", diff --git a/sites/vibes/src/remark/callouts.mjs b/sites/vibes/src/remark/callouts.mjs new file mode 100644 index 0000000..56f6a1f --- /dev/null +++ b/sites/vibes/src/remark/callouts.mjs @@ -0,0 +1,101 @@ +const CALLOUT_LABELS = new Map([ + ["aside", "Aside"], + ["caution", "Caution"], + ["note", "Note"], + ["tip", "Tip"], + ["warning", "Warning"], +]); + +function textFromInline(node) { + if (!node) return ""; + if (typeof node.value === "string") return node.value; + if (!Array.isArray(node.children)) return ""; + return node.children.map(textFromInline).join(""); +} + +function normalizeLabel(value) { + return value + .trim() + .replace(/^["“”]+|["“”]+$/g, "") + .replace(/:$/, "") + .toLowerCase(); +} + +function paragraphCalloutLabel(paragraph) { + if (paragraph?.type !== "paragraph") return null; + + const first = paragraph.children?.[0]; + if (first?.type === "text") { + const match = first.value.match(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i); + if (match) { + return CALLOUT_LABELS.get(normalizeLabel(match[1])); + } + } + + if (first?.type !== "strong") return null; + + const label = CALLOUT_LABELS.get(normalizeLabel(textFromInline(first))); + if (!label) return null; + + return label; +} + +function removeLeadingLabel(paragraph, label) { + if (paragraph?.type !== "paragraph") return; + + const first = paragraph.children?.[0]; + if (first?.type === "text") { + first.value = first.value.replace(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i, ""); + if (!first.value) paragraph.children.shift(); + return; + } + + const firstText = normalizeLabel(textFromInline(first)); + if (!first || first.type !== "strong" || firstText !== label.toLowerCase()) { + return; + } + + paragraph.children.shift(); + + const next = paragraph.children[0]; + if (next?.type === "text") { + next.value = next.value.replace(/^:\s*/, "").replace(/^\s+/, ""); + if (!next.value) paragraph.children.shift(); + } +} + +function transformBlockquote(node) { + if (node.type !== "blockquote") return; + + const first = node.children?.[0]; + const label = paragraphCalloutLabel(first); + if (!label) return; + + removeLeadingLabel(first, label); + if (first.children?.length === 0) { + node.children.shift(); + } + + node.data = { + hName: "aside", + hProperties: { + className: ["callout", `callout-${label.toLowerCase()}`], + dataCalloutLabel: label, + }, + }; +} + +function visit(node) { + transformBlockquote(node); + + if (!Array.isArray(node.children)) return; + for (const child of node.children) { + visit(child); + } +} + +export default function remarkCallouts() { + return function transformer(tree) { + visit(tree); + }; +} diff --git a/sites/vibes/src/remark/code-fence-languages.mjs b/sites/vibes/src/remark/code-fence-languages.mjs new file mode 100644 index 0000000..240c7ec --- /dev/null +++ b/sites/vibes/src/remark/code-fence-languages.mjs @@ -0,0 +1,29 @@ +const LANGUAGE_ALIASES = new Map([["ojs", "js"]]); + +const normalizeLanguage = (language) => { + if (!language) return language; + + const match = language.match(/^\{(.+)\}$/); + if (!match) return language; + + const normalized = match[1].trim().toLowerCase(); + return LANGUAGE_ALIASES.get(normalized) ?? normalized; +}; + +const visit = (node) => { + if (node.type === "code") { + node.lang = normalizeLanguage(node.lang); + } + + if (!Array.isArray(node.children)) return; + + for (const child of node.children) { + visit(child); + } +}; + +const remarkCodeFenceLanguages = () => (tree) => { + visit(tree); +}; + +export default remarkCodeFenceLanguages; diff --git a/sites/vibes/src/remark/inline-footnotes.mjs b/sites/vibes/src/remark/inline-footnotes.mjs new file mode 100644 index 0000000..ccdf9ee --- /dev/null +++ b/sites/vibes/src/remark/inline-footnotes.mjs @@ -0,0 +1,289 @@ +import { fromMarkdown } from "mdast-util-from-markdown"; + +const INLINE_FOOTNOTE_PREFIX = "inline-footnote"; + +const findInlineFootnote = (value, fromIndex = 0) => { + const start = value.indexOf("^[", fromIndex); + + if (start === -1) { + return null; + } + + let depth = 1; + + for (let index = start + 2; index < value.length; index += 1) { + const character = value[index]; + + if (character === "\\" && index + 1 < value.length) { + index += 1; + continue; + } + + if (character === "[") { + depth += 1; + continue; + } + + if (character === "]") { + depth -= 1; + + if (depth === 0) { + return { + start, + end: index + 1, + value: value.slice(start + 2, index).trim(), + }; + } + } + } + + return null; +}; + +const createFootnoteChildren = (value) => { + const tree = fromMarkdown(value); + + if (tree.children.length > 0) { + return tree.children; + } + + return [ + { + type: "paragraph", + children: [ + { + type: "text", + value, + }, + ], + }, + ]; +}; + +const createFootnoteParagraph = (children) => ({ + type: "paragraph", + children: + children.length > 0 + ? children + : [ + { + type: "text", + value: "", + }, + ], +}); + +const createFootnoteDefinition = (identifier, label, value) => ({ + type: "footnoteDefinition", + identifier, + label, + children: createFootnoteChildren(value), +}); + +const createFootnoteDefinitionFromChildren = (identifier, label, children) => ({ + type: "footnoteDefinition", + identifier, + label, + children: [createFootnoteParagraph(children)], +}); + +const createFootnoteReference = (context) => { + context.count += 1; + + const label = String(context.count); + const identifier = `${INLINE_FOOTNOTE_PREFIX}-${label}`; + + return { + identifier, + label, + node: { + type: "footnoteReference", + identifier, + label, + }, + }; +}; + +const findClosingBracket = (value) => { + for (let index = 0; index < value.length; index += 1) { + const character = value[index]; + + if (character === "\\" && index + 1 < value.length) { + index += 1; + continue; + } + + if (character === "]") { + return index; + } + } + + return -1; +}; + +const cloneNode = (node) => JSON.parse(JSON.stringify(node)); + +const splitTextNode = (node, context) => { + const replacements = []; + let cursor = 0; + let match = findInlineFootnote(node.value, cursor); + + while (match) { + if (match.start > cursor) { + replacements.push({ + type: "text", + value: node.value.slice(cursor, match.start), + }); + } + + if (match.value) { + const reference = createFootnoteReference(context); + replacements.push(reference.node); + + context.definitions.push( + createFootnoteDefinition(reference.identifier, reference.label, match.value), + ); + } else { + replacements.push({ + type: "text", + value: node.value.slice(match.start, match.end), + }); + } + + cursor = match.end; + match = findInlineFootnote(node.value, cursor); + } + + if (cursor < node.value.length) { + replacements.push({ + type: "text", + value: node.value.slice(cursor), + }); + } + + return replacements; +}; + +const splitAcrossInlineNodes = (children, startIndex, context) => { + const startNode = children[startIndex]; + const start = startNode.value.indexOf("^["); + + if (start === -1) { + return null; + } + + if (findInlineFootnote(startNode.value, start)) { + return null; + } + + const footnoteChildren = []; + const replacements = []; + const before = startNode.value.slice(0, start); + const initialText = startNode.value.slice(start + 2); + + if (before) { + replacements.push({ + type: "text", + value: before, + }); + } + + if (initialText) { + footnoteChildren.push({ + type: "text", + value: initialText, + }); + } + + for (let index = startIndex + 1; index < children.length; index += 1) { + const child = children[index]; + + if (child.type === "text") { + const closingIndex = findClosingBracket(child.value); + + if (closingIndex !== -1) { + const footnoteText = child.value.slice(0, closingIndex); + const after = child.value.slice(closingIndex + 1); + + if (footnoteText) { + footnoteChildren.push({ + type: "text", + value: footnoteText, + }); + } + + const reference = createFootnoteReference(context); + replacements.push(reference.node); + + if (after) { + replacements.push({ + type: "text", + value: after, + }); + } + + context.definitions.push( + createFootnoteDefinitionFromChildren( + reference.identifier, + reference.label, + footnoteChildren, + ), + ); + + return { + consumed: index - startIndex + 1, + replacements, + }; + } + + footnoteChildren.push(cloneNode(child)); + continue; + } + + footnoteChildren.push(cloneNode(child)); + } + + return null; +}; + +const visitChildren = (node, context) => { + if (!Array.isArray(node.children)) { + return; + } + + for (let index = 0; index < node.children.length; index += 1) { + const child = node.children[index]; + + if (child.type === "text" && child.value.includes("^[")) { + const acrossNodes = splitAcrossInlineNodes(node.children, index, context); + + if (acrossNodes) { + node.children.splice(index, acrossNodes.consumed, ...acrossNodes.replacements); + index += acrossNodes.replacements.length - 1; + continue; + } + + const replacements = splitTextNode(child, context); + node.children.splice(index, 1, ...replacements); + index += replacements.length - 1; + continue; + } + + visitChildren(child, context); + } +}; + +const remarkInlineFootnotes = () => (tree) => { + const context = { + count: 0, + definitions: [], + }; + + visitChildren(tree, context); + + if (context.definitions.length > 0) { + tree.children.push(...context.definitions); + } +}; + +export default remarkInlineFootnotes;