From e2ba0812aaba4ff5ecc2dc59f3ee3aaae55c6dc9 Mon Sep 17 00:00:00 2001 From: Daniel Gomes Date: Tue, 19 May 2026 17:10:46 -0300 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=F0=9F=90=9B=20resolve=20Slack=20dir?= =?UTF-8?q?ectives=20in=20section/mrkdwn=20(verbatim=20+=20non-verbatim)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slack directives like `<@U123>`, `<#C123>`, ``, ``, and `` now fire the matching hooks (`hooks.user`, `hooks.channel`, `hooks.usergroup`, `hooks.atHere` / `atChannel` / `atEveryone`, `hooks.date`) in both `verbatim: true` and `verbatim: false` modes — matching the behaviour of the rich_text path. - Verbatim mode no longer early-returns. It splits the input by directive boundaries and renders each segment, preserving non-directive text literally (no markdown sugar expansion — that's the point of verbatim). - Non-verbatim mode now masks fenced code, inline code, and directive atoms before the URL-rewrite / asterisk-doubling regex pass, then restores them before Yozora parses — so directives can't be mangled by the pre-pass and directive-shaped text inside code stays literal. - Broadcast tokenizer recognises `` / `` / `` natively, alongside the existing `@here` / `@everyone` / `@channel`. - Yozora's `autolink` and `autolink-extension` are unmounted; bare URL autolinking is handled by the existing `` / `` regex rewrite, and the autolink-extension was stealing directives like `` because of the embedded `@`. - mrkdwn sub-element hook payloads now include `style: undefined` to match the rich_text path shape (`{ id, name, style }`). - `<@U…|fallback>` and `` now split on `|` for data lookup + fallback display name (channel mention already did this). - `&` is decoded alongside `>` / `<` in text-object payloads so escaped ampersands don't leak into hrefs or visible text. Adds a vitest test suite (the repo previously had none) covering the directive × verbatim matrix, code-span suppression, escaped entities, hook payload shape, data-map resolution, and non-directive regression. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/fix-mrkdwn-directives.md | 5 + package.json | 14 +- pnpm-lock.yaml | 978 ++++++++++++++++++ .../composition_objects/text_object.tsx | 6 +- src/test/setup.ts | 7 + .../__tests__/directives.test.tsx | 254 +++++ .../markdown_parser/__tests__/render.tsx | 35 + src/utils/markdown_parser/directives.tsx | 82 ++ src/utils/markdown_parser/parser.tsx | 59 +- src/utils/markdown_parser/preparse.ts | 36 + .../sub_elements/slack_broadcast.tsx | 12 +- .../sub_elements/slack_channel_mention.tsx | 19 +- .../sub_elements/slack_user_group_mention.tsx | 22 +- .../sub_elements/slack_user_mention.tsx | 23 +- .../tokenizers/slack_broadcast/match.ts | 34 +- .../tokenizers/slack_broadcast/parse.ts | 7 +- vitest.config.ts | 11 + 17 files changed, 1545 insertions(+), 59 deletions(-) create mode 100644 .changeset/fix-mrkdwn-directives.md create mode 100644 src/test/setup.ts create mode 100644 src/utils/markdown_parser/__tests__/directives.test.tsx create mode 100644 src/utils/markdown_parser/__tests__/render.tsx create mode 100644 src/utils/markdown_parser/directives.tsx create mode 100644 src/utils/markdown_parser/preparse.ts create mode 100644 vitest.config.ts diff --git a/.changeset/fix-mrkdwn-directives.md b/.changeset/fix-mrkdwn-directives.md new file mode 100644 index 0000000..4a6d158 --- /dev/null +++ b/.changeset/fix-mrkdwn-directives.md @@ -0,0 +1,5 @@ +--- +"slack-blocks-to-jsx": patch +--- + +Resolve Slack directive atoms (`<@U…>`, `<#C…>`, ``, ``, ``) in `section`/`mrkdwn` text and other mrkdwn-typed text. Directives now fire the same hooks as the rich_text path in both `verbatim: true` and `verbatim: false` modes. Code-span content stays literal (directives inside `` `…` `` or ` ```…``` ` are not resolved). `&` is now decoded alongside the existing `>` / `<` decoding so link `href`s and visible text don't leak literal `&`. diff --git a/package.json b/package.json index 95946b1..6e2812f 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "dev:css": "postcss ./src/style.css -o ./dist/style.css --watch", "dev": "tsup --watch", "lint": "tsc", - "test": "node --test \"test/**/*.test.mjs\"", + "test": "vitest run && node --test \"test/**/*.test.mjs\"", + "test:watch": "vitest", "release": "node scripts/release.mjs", "release:dry": "node scripts/release.mjs --dry-run", "release:beta": "node scripts/release.mjs prerelease --preid=beta", @@ -44,19 +45,24 @@ "react-dom": "^17 || ^18 || ^19" }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@types/react": "^17 || ^18 || ^19", + "@types/react-dom": "^17 || ^18 || ^19", + "@vitejs/plugin-react": "^6.0.2", "@yozora/ast": "^2.3.2", "@yozora/character": "^2.3.2", "@yozora/core-tokenizer": "^2.3.2", "@yozora/parser": "^2.3.2", - "@types/react": "^17 || ^18 || ^19", - "@types/react-dom": "^17 || ^18 || ^19", "autoprefixer": "^10.4.17", "cssnano": "^6.0.3", + "happy-dom": "^20.9.0", "postcss": "^8.4.33", "postcss-cli": "^11.0.0", "postcss-nesting": "^12.0.2", "tailwindcss": "^3.4.1", "tsup": "^8.5.0", - "typescript": "^5.2.2" + "typescript": "^5.2.2", + "vitest": "^4.1.6" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9564bfe..af1efd7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,12 +24,21 @@ importers: specifier: ^4.0.1 version: 4.0.1 devDependencies: + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@17.0.26(@types/react@17.0.88))(@types/react@17.0.88)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ^17 || ^18 || ^19 version: 17.0.88 '@types/react-dom': specifier: ^17 || ^18 || ^19 version: 17.0.26(@types/react@17.0.88) + '@vitejs/plugin-react': + specifier: ^6.0.2 + version: 6.0.2(vite@8.0.16(@types/node@25.9.3)(esbuild@0.25.9)(jiti@1.21.7)(yaml@2.8.1)) '@yozora/ast': specifier: ^2.3.2 version: 2.3.12 @@ -48,6 +57,9 @@ importers: cssnano: specifier: ^6.0.3 version: 6.1.2(postcss@8.5.6) + happy-dom: + specifier: ^20.9.0 + version: 20.10.3 postcss: specifier: ^8.4.33 version: 8.5.6 @@ -66,13 +78,31 @@ importers: typescript: specifier: ^5.2.2 version: 5.9.2 + vitest: + specifier: ^4.1.6 + version: 4.1.8(@types/node@25.9.3)(happy-dom@20.10.3)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.25.9)(jiti@1.21.7)(yaml@2.8.1)) packages: + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@csstools/selector-resolve-nested@1.1.0': resolution: {integrity: sha512-uWvSaeRcHyeNenKg8tp17EVDRkpflmdyvbE0DHo6D/GdBb6PDnCYYU6gRpXhtICMGMcahQmj2zGxwFM/WC8hCg==} engines: {node: ^14 || ^16 || >=18} @@ -85,6 +115,15 @@ packages: peerDependencies: postcss-selector-parser: ^6.0.13 + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@esbuild/aix-ppc64@0.25.9': resolution: {integrity: sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==} engines: {node: '>=18'} @@ -258,6 +297,12 @@ packages: '@jridgewell/trace-mapping@0.3.30': resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==} + '@napi-rs/wasm-runtime@1.1.5': + resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -270,10 +315,111 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@oxc-project/types@0.133.0': + resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@rolldown/binding-android-arm64@1.0.3': + resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.3': + resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.3': + resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.3': + resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.3': + resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.3': + resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.3': + resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.3': + resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.3': + resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.3': + resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.3': + resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.3': + resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.3': + resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.3': + resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@rollup/rollup-android-arm-eabi@4.49.0': resolution: {integrity: sha512-rlKIeL854Ed0e09QGYFlmDNbka6I3EQFw7iZuugQjMb11KMpJCLPFL4ZPbMfaEhLADEL1yx0oujGkBQ7+qW3eA==} cpu: [arm] @@ -389,13 +535,51 @@ packages: resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@trysound/sax@0.2.0': resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} engines: {node: '>=10.13.0'} + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} @@ -411,6 +595,9 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@25.9.3': + resolution: {integrity: sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==} + '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} @@ -431,9 +618,57 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + '@vitejs/plugin-react@6.0.2': + resolution: {integrity: sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + + '@vitest/expect@4.1.8': + resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==} + + '@vitest/mocker@4.1.8': + resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.8': + resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==} + + '@vitest/runner@4.1.8': + resolution: {integrity: sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==} + + '@vitest/snapshot@4.1.8': + resolution: {integrity: sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==} + + '@vitest/spy@4.1.8': + resolution: {integrity: sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==} + + '@vitest/utils@4.1.8': + resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==} + '@yozora/ast-util@2.3.12': resolution: {integrity: sha512-FoHR0po90+FVx2SoTfsYGI8JFP8Pvkh9tOlcIPDXzYy1S/iYijzWjJDB7f5hYhsnM+EXcqYbNpWGs5y5gjsHZw==} engines: {node: '>= 16.0.0'} @@ -603,6 +838,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + ansi-styles@6.2.1: resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} engines: {node: '>=12'} @@ -617,6 +856,17 @@ packages: arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + autoprefixer@10.4.21: resolution: {integrity: sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==} engines: {node: ^10 || ^12 || >=14} @@ -649,6 +899,10 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-image-size@0.6.4: + resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==} + engines: {node: '>=4.0'} + bundle-require@5.1.0: resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -672,6 +926,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + char-regex@1.0.2: resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} engines: {node: '>=10'} @@ -728,6 +986,9 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -753,6 +1014,9 @@ packages: resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} engines: {node: '>= 6'} + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -803,6 +1067,10 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -812,6 +1080,12 @@ packages: dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -844,6 +1118,13 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + esbuild@0.25.9: resolution: {integrity: sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==} engines: {node: '>=18'} @@ -860,6 +1141,13 @@ packages: estree-util-is-identifier-name@3.0.0: resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -924,6 +1212,10 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + happy-dom@20.10.3: + resolution: {integrity: sha512-Hjdiy8RziuCcn5z04QI/rlsNuQoG8P0xxjgvsSMpi89cvIXIOcucQtiHS1yHSShxoBcSCeYqAskINmTiy/mlfw==} + engines: {node: '>=20.0.0'} + hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} @@ -937,6 +1229,10 @@ packages: html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} @@ -1000,6 +1296,80 @@ packages: jsonfile@6.2.0: resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -1030,9 +1400,16 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.18: resolution: {integrity: sha512-yi8swmWbO17qHhwIBNeeZxTceJMeBvWJaId6dyvTSOwTipqeHhMhOrz6513r1sOKnpvQ7zkhlG8tPrpilwTxHQ==} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} @@ -1179,6 +1556,10 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + minimatch@9.0.5: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} @@ -1201,6 +1582,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + node-emoji@2.2.0: resolution: {integrity: sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==} engines: {node: '>=18'} @@ -1227,6 +1613,10 @@ packages: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -1258,6 +1648,10 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + pify@2.3.0: resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} engines: {node: '>=0.10.0'} @@ -1520,10 +1914,18 @@ packages: postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + pretty-hrtime@1.0.3: resolution: {integrity: sha512-66hKPCr+72mlfiSjlEB1+45IjXSqvVAIy6mocupoww4tBFE9R9IhwwUGoI4G++Tc9Aq+2rxOt0RFU6gPcrte0A==} engines: {node: '>= 0.8'} @@ -1543,6 +1945,9 @@ packages: peerDependencies: react: ^18.3.1 + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-markdown@9.1.0: resolution: {integrity: sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==} peerDependencies: @@ -1564,6 +1969,10 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} @@ -1593,6 +2002,11 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rolldown@1.0.3: + resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + rollup@4.49.0: resolution: {integrity: sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -1612,6 +2026,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -1636,6 +2053,12 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -1655,6 +2078,10 @@ packages: resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} engines: {node: '>=12'} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} @@ -1696,13 +2123,28 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + tinyglobby@0.2.14: resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -1723,6 +2165,9 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsup@8.5.0: resolution: {integrity: sha512-VmBp77lWNQq6PfuMqCHD3xWl22vEoWsKajkF8t+yMBawlUS8JzEI+vOVMeuNZIuMML8qXRizFKi9oD5glKQVcQ==} engines: {node: '>=18'} @@ -1750,6 +2195,9 @@ packages: ufo@1.6.1: resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + unicode-emoji-modifier-base@1.0.0: resolution: {integrity: sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==} engines: {node: '>=4'} @@ -1791,9 +2239,97 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite@8.0.16: + resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.8: + resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.8 + '@vitest/browser-preview': 4.1.8 + '@vitest/browser-webdriverio': 4.1.8 + '@vitest/coverage-istanbul': 4.1.8 + '@vitest/coverage-v8': 4.1.8 + '@vitest/ui': 4.1.8 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + webidl-conversions@4.0.2: resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + whatwg-url@7.1.0: resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==} @@ -1802,6 +2338,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -1810,6 +2351,18 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -1832,8 +2385,20 @@ packages: snapshots: + '@adobe/css-tools@4.5.0': {} + '@alloc/quick-lru@5.2.0': {} + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/runtime@7.29.7': {} + '@csstools/selector-resolve-nested@1.1.0(postcss-selector-parser@6.1.2)': dependencies: postcss-selector-parser: 6.1.2 @@ -1842,6 +2407,22 @@ snapshots: dependencies: postcss-selector-parser: 6.1.2 + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild/aix-ppc64@0.25.9': optional: true @@ -1943,6 +2524,13 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -1955,9 +2543,62 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.19.1 + '@oxc-project/types@0.133.0': {} + '@pkgjs/parseargs@0.11.0': optional: true + '@rolldown/binding-android-arm64@1.0.3': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.3': + optional: true + + '@rolldown/binding-darwin-x64@1.0.3': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.3': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.3': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.3': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.3': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.3': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.3': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.3': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + '@rollup/rollup-android-arm-eabi@4.49.0': optional: true @@ -2020,12 +2661,58 @@ snapshots: '@sindresorhus/is@4.6.0': {} + '@standard-schema/spec@1.1.0': {} + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.5.0 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@17.0.26(@types/react@17.0.88))(@types/react@17.0.88)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 17.0.88 + '@types/react-dom': 17.0.26(@types/react@17.0.88) + '@trysound/sax@0.2.0': {} + '@tybys/wasm-util@0.10.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/aria-query@5.0.4': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + '@types/estree-jsx@1.0.5': dependencies: '@types/estree': 1.0.8 @@ -2042,6 +2729,10 @@ snapshots: '@types/ms@2.1.0': {} + '@types/node@25.9.3': + dependencies: + undici-types: 7.24.6 + '@types/prop-types@15.7.15': {} '@types/react-dom@17.0.26(@types/react@17.0.88)': @@ -2060,8 +2751,60 @@ snapshots: '@types/unist@3.0.3': {} + '@types/whatwg-mimetype@3.0.2': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 25.9.3 + '@ungap/structured-clone@1.3.0': {} + '@vitejs/plugin-react@6.0.2(vite@8.0.16(@types/node@25.9.3)(esbuild@0.25.9)(jiti@1.21.7)(yaml@2.8.1))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.0.16(@types/node@25.9.3)(esbuild@0.25.9)(jiti@1.21.7)(yaml@2.8.1) + + '@vitest/expect@4.1.8': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.8(vite@8.0.16(@types/node@25.9.3)(esbuild@0.25.9)(jiti@1.21.7)(yaml@2.8.1))': + dependencies: + '@vitest/spy': 4.1.8 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.16(@types/node@25.9.3)(esbuild@0.25.9)(jiti@1.21.7)(yaml@2.8.1) + + '@vitest/pretty-format@4.1.8': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.8': + dependencies: + '@vitest/utils': 4.1.8 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.8': + dependencies: + '@vitest/pretty-format': 4.1.8 + '@vitest/utils': 4.1.8 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.8': {} + + '@vitest/utils@4.1.8': + dependencies: + '@vitest/pretty-format': 4.1.8 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + '@yozora/ast-util@2.3.12': dependencies: '@yozora/ast': 2.3.12 @@ -2329,6 +3072,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.1: {} any-promise@1.3.0: {} @@ -2340,6 +3085,14 @@ snapshots: arg@5.0.2: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + + assertion-error@2.0.1: {} + autoprefixer@10.4.21(postcss@8.5.6): dependencies: browserslist: 4.25.4 @@ -2373,6 +3126,10 @@ snapshots: node-releases: 2.0.19 update-browserslist-db: 1.1.3(browserslist@4.25.4) + buffer-image-size@0.6.4: + dependencies: + '@types/node': 25.9.3 + bundle-require@5.1.0(esbuild@0.25.9): dependencies: esbuild: 0.25.9 @@ -2393,6 +3150,8 @@ snapshots: ccount@2.0.1: {} + chai@6.2.2: {} + char-regex@1.0.2: {} character-entities-html4@2.1.0: {} @@ -2443,6 +3202,8 @@ snapshots: consola@3.4.2: {} + convert-source-map@2.0.0: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -2473,6 +3234,8 @@ snapshots: css-what@6.2.2: {} + css.escape@1.5.1: {} + cssesc@3.0.0: {} cssnano-preset-default@6.1.2(postcss@8.5.6): @@ -2537,6 +3300,8 @@ snapshots: dequal@2.0.3: {} + detect-libc@2.1.2: {} + devlop@1.1.0: dependencies: dequal: 2.0.3 @@ -2545,6 +3310,10 @@ snapshots: dlv@1.1.3: {} + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 @@ -2575,6 +3344,10 @@ snapshots: entities@4.5.0: {} + entities@7.0.1: {} + + es-module-lexer@2.1.0: {} + esbuild@0.25.9: optionalDependencies: '@esbuild/aix-ppc64': 0.25.9 @@ -2610,6 +3383,12 @@ snapshots: estree-util-is-identifier-name@3.0.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + expect-type@1.3.0: {} + extend@3.0.2: {} fast-glob@3.3.3: @@ -2628,6 +3407,10 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -2677,6 +3460,19 @@ snapshots: graceful-fs@4.2.11: {} + happy-dom@20.10.3: + dependencies: + '@types/node': 25.9.3 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.1 + buffer-image-size: 0.6.4 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + hasown@2.0.2: dependencies: function-bind: 1.1.2 @@ -2707,6 +3503,8 @@ snapshots: html-url-attributes@3.0.1: {} + indent-string@4.0.0: {} + inline-style-parser@0.2.7: {} is-alphabetical@2.0.1: {} @@ -2760,6 +3558,55 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} @@ -2780,10 +3627,16 @@ snapshots: lru-cache@10.4.3: {} + lz-string@1.5.0: {} + magic-string@0.30.18: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + markdown-table@3.0.4: {} mdast-util-find-and-replace@3.0.2: @@ -3141,6 +3994,8 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + min-indent@1.0.1: {} + minimatch@9.0.5: dependencies: brace-expansion: 2.0.2 @@ -3164,6 +4019,8 @@ snapshots: nanoid@3.3.11: {} + nanoid@3.3.12: {} + node-emoji@2.2.0: dependencies: '@sindresorhus/is': 4.6.0 @@ -3185,6 +4042,8 @@ snapshots: object-hash@3.0.0: {} + obug@2.1.3: {} + package-json-from-dist@1.0.1: {} parse-entities@4.0.2: @@ -3214,6 +4073,8 @@ snapshots: picomatch@4.0.3: {} + picomatch@4.0.4: {} + pify@2.3.0: {} pirates@4.0.7: {} @@ -3449,12 +4310,24 @@ snapshots: postcss-value-parser@4.2.0: {} + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + postcss@8.5.6: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 source-map-js: 1.2.1 + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + pretty-hrtime@1.0.3: {} property-information@7.1.0: {} @@ -3469,6 +4342,8 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 + react-is@17.0.2: {} + react-markdown@9.1.0(@types/react@17.0.88)(react@18.3.1): dependencies: '@types/hast': 3.0.4 @@ -3501,6 +4376,11 @@ snapshots: readdirp@4.1.2: {} + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 @@ -3547,6 +4427,27 @@ snapshots: reusify@1.1.0: {} + rolldown@1.0.3: + dependencies: + '@oxc-project/types': 0.133.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.3 + '@rolldown/binding-darwin-arm64': 1.0.3 + '@rolldown/binding-darwin-x64': 1.0.3 + '@rolldown/binding-freebsd-x64': 1.0.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.3 + '@rolldown/binding-linux-arm64-gnu': 1.0.3 + '@rolldown/binding-linux-arm64-musl': 1.0.3 + '@rolldown/binding-linux-ppc64-gnu': 1.0.3 + '@rolldown/binding-linux-s390x-gnu': 1.0.3 + '@rolldown/binding-linux-x64-gnu': 1.0.3 + '@rolldown/binding-linux-x64-musl': 1.0.3 + '@rolldown/binding-openharmony-arm64': 1.0.3 + '@rolldown/binding-wasm32-wasi': 1.0.3 + '@rolldown/binding-win32-arm64-msvc': 1.0.3 + '@rolldown/binding-win32-x64-msvc': 1.0.3 + rollup@4.49.0: dependencies: '@types/estree': 1.0.8 @@ -3587,6 +4488,8 @@ snapshots: shebang-regex@3.0.0: {} + siginfo@2.0.0: {} + signal-exit@4.1.0: {} skin-tone@2.0.0: @@ -3603,6 +4506,10 @@ snapshots: space-separated-tokens@2.0.2: {} + stackback@0.0.2: {} + + std-env@4.1.0: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -3628,6 +4535,10 @@ snapshots: dependencies: ansi-regex: 6.2.0 + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + style-to-js@1.1.21: dependencies: style-to-object: 1.0.14 @@ -3701,13 +4612,24 @@ snapshots: dependencies: any-promise: 1.3.0 + tinybench@2.9.0: {} + tinyexec@0.3.2: {} + tinyexec@1.2.4: {} + tinyglobby@0.2.14: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyrainbow@3.1.0: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -3724,6 +4646,9 @@ snapshots: ts-interface-checker@0.1.13: {} + tslib@2.8.1: + optional: true + tsup@8.5.0(jiti@1.21.7)(postcss@8.5.6)(typescript@5.9.2)(yaml@2.8.1): dependencies: bundle-require: 5.1.0(esbuild@0.25.9) @@ -3756,6 +4681,8 @@ snapshots: ufo@1.6.1: {} + undici-types@7.24.6: {} + unicode-emoji-modifier-base@1.0.0: {} unified@11.0.5: @@ -3811,8 +4738,52 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + vite@8.0.16(@types/node@25.9.3)(esbuild@0.25.9)(jiti@1.21.7)(yaml@2.8.1): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.15 + rolldown: 1.0.3 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 25.9.3 + esbuild: 0.25.9 + fsevents: 2.3.3 + jiti: 1.21.7 + yaml: 2.8.1 + + vitest@4.1.8(@types/node@25.9.3)(happy-dom@20.10.3)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.25.9)(jiti@1.21.7)(yaml@2.8.1)): + dependencies: + '@vitest/expect': 4.1.8 + '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@25.9.3)(esbuild@0.25.9)(jiti@1.21.7)(yaml@2.8.1)) + '@vitest/pretty-format': 4.1.8 + '@vitest/runner': 4.1.8 + '@vitest/snapshot': 4.1.8 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.0.16(@types/node@25.9.3)(esbuild@0.25.9)(jiti@1.21.7)(yaml@2.8.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.9.3 + happy-dom: 20.10.3 + transitivePeerDependencies: + - msw + webidl-conversions@4.0.2: {} + whatwg-mimetype@3.0.0: {} + whatwg-url@7.1.0: dependencies: lodash.sortby: 4.7.0 @@ -3823,6 +4794,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -3835,6 +4811,8 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.1.0 + ws@8.21.0: {} + y18n@5.0.8: {} yaml@2.8.1: {} diff --git a/src/components/composition_objects/text_object.tsx b/src/components/composition_objects/text_object.tsx index aa61890..59f1a61 100644 --- a/src/components/composition_objects/text_object.tsx +++ b/src/components/composition_objects/text_object.tsx @@ -12,7 +12,11 @@ export const TextObject = (props: TextObjectProps) => { const { className = "" } = props; const { channels, users, hooks } = useGlobalData(); - const parsed = text.replace(/>/g, "> ").replace(/</g, "<"); + // Order matters: decode `>` and `<` before `&` so a literal escaped `>` + // (which arrives as `&gt;`) doesn't get double-decoded. + // The `>` → `"> "` trailing space is intentional — it keeps blockquote line detection + // (`^>` in parser.tsx) working when Slack escapes the `>` of a quote line. + const parsed = text.replace(/>/g, "> ").replace(/</g, "<").replace(/&/g, "&"); if (type === "plain_text") return ( diff --git a/src/test/setup.ts b/src/test/setup.ts new file mode 100644 index 0000000..2ba240b --- /dev/null +++ b/src/test/setup.ts @@ -0,0 +1,7 @@ +import "@testing-library/jest-dom/vitest"; +import { afterEach } from "vitest"; +import { cleanup } from "@testing-library/react"; + +afterEach(() => { + cleanup(); +}); diff --git a/src/utils/markdown_parser/__tests__/directives.test.tsx b/src/utils/markdown_parser/__tests__/directives.test.tsx new file mode 100644 index 0000000..3b61c14 --- /dev/null +++ b/src/utils/markdown_parser/__tests__/directives.test.tsx @@ -0,0 +1,254 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderMrkdwn } from "./render"; + +const VERBATIM_MODES: { name: string; verbatim: boolean }[] = [ + { name: "verbatim: false", verbatim: false }, + { name: "verbatim: true", verbatim: true }, +]; + +describe("user mention directives", () => { + VERBATIM_MODES.forEach(({ name, verbatim }) => { + describe(name, () => { + it("fires hooks.user with { id, name, style } when user is in data.users", () => { + const user = vi.fn(({ id, name }) => @{name}); + const { getByTestId } = renderMrkdwn(`Hi <@U123>!`, verbatim, { + data: { users: [{ id: "U123", name: "alice" }] }, + hooks: { user }, + }); + expect(user).toHaveBeenCalledTimes(1); + expect(user).toHaveBeenCalledWith({ id: "U123", name: "alice", style: undefined }); + expect(getByTestId("hook-user").textContent).toBe("@alice"); + }); + + it("fires hooks.user with raw id when user is not in data.users", () => { + const user = vi.fn(({ id, name }) => {name}); + renderMrkdwn(`Hi <@U999>!`, verbatim, { data: { users: [] }, hooks: { user } }); + expect(user).toHaveBeenCalledWith({ id: "U999", name: "U999", style: undefined }); + }); + + it("respects the |fallback label form for users", () => { + const user = vi.fn(({ id, name }) => {name}); + const { getByTestId } = renderMrkdwn(`Hi <@U777|fallback>!`, verbatim, { + data: { users: [] }, + hooks: { user }, + }); + expect(user).toHaveBeenCalledWith({ id: "U777", name: "fallback", style: undefined }); + expect(getByTestId("u").textContent).toBe("fallback"); + }); + + it("falls back to default span when hook is not provided", () => { + const { container } = renderMrkdwn(`Hi <@U123>!`, verbatim, { + data: { users: [{ id: "U123", name: "alice" }] }, + }); + const span = container.querySelector(".slack_user"); + expect(span).not.toBeNull(); + expect(span!.textContent).toBe("@alice"); + }); + }); + }); +}); + +describe("channel mention directives", () => { + VERBATIM_MODES.forEach(({ name, verbatim }) => { + describe(name, () => { + it("fires hooks.channel with resolved name", () => { + const channel = vi.fn(({ id, name }) => #{name}); + const { getByTestId } = renderMrkdwn(`See <#C123>!`, verbatim, { + data: { channels: [{ id: "C123", name: "general" }] }, + hooks: { channel }, + }); + expect(channel).toHaveBeenCalledWith({ id: "C123", name: "general", style: undefined }); + expect(getByTestId("c").textContent).toBe("#general"); + }); + + it("respects |fallback label form", () => { + const channel = vi.fn(({ id, name }) => #{name}); + renderMrkdwn(`See <#C999|fallback>!`, verbatim, { + data: { channels: [] }, + hooks: { channel }, + }); + expect(channel).toHaveBeenCalledWith({ id: "C999", name: "fallback", style: undefined }); + }); + }); + }); +}); + +describe("usergroup mention directives", () => { + VERBATIM_MODES.forEach(({ name, verbatim }) => { + describe(name, () => { + it("fires hooks.usergroup with resolved name", () => { + const usergroup = vi.fn(({ id, name }) => @{name}); + const { getByTestId } = renderMrkdwn(`Tag !`, verbatim, { + data: { user_groups: [{ id: "S123", name: "team" }] }, + hooks: { usergroup }, + }); + expect(usergroup).toHaveBeenCalledWith({ id: "S123", name: "team", style: undefined }); + expect(getByTestId("g").textContent).toBe("@team"); + }); + + it("respects |fallback label form", () => { + const usergroup = vi.fn(({ id, name }) => @{name}); + renderMrkdwn(`Tag !`, verbatim, { + data: { user_groups: [] }, + hooks: { usergroup }, + }); + expect(usergroup).toHaveBeenCalledWith({ id: "S999", name: "@team", style: undefined }); + }); + }); + }); +}); + +describe("broadcast directives", () => { + VERBATIM_MODES.forEach(({ name, verbatim }) => { + describe(name, () => { + it("fires hooks.atHere for ", () => { + const atHere = vi.fn(() => @here); + const { getByTestId } = renderMrkdwn(`Hey !`, verbatim, { hooks: { atHere } }); + expect(atHere).toHaveBeenCalledWith(undefined); + expect(getByTestId("h")).toBeInTheDocument(); + }); + + it("fires hooks.atChannel for ", () => { + const atChannel = vi.fn(() => @channel); + const { getByTestId } = renderMrkdwn(``, verbatim, { hooks: { atChannel } }); + expect(atChannel).toHaveBeenCalledWith(undefined); + expect(getByTestId("c")).toBeInTheDocument(); + }); + + it("fires hooks.atEveryone for ", () => { + const atEveryone = vi.fn(() => @everyone); + const { getByTestId } = renderMrkdwn(``, verbatim, { hooks: { atEveryone } }); + expect(atEveryone).toHaveBeenCalledWith(undefined); + expect(getByTestId("e")).toBeInTheDocument(); + }); + }); + }); +}); + +describe("date directives", () => { + VERBATIM_MODES.forEach(({ name, verbatim }) => { + describe(name, () => { + it("fires hooks.date with timestamp, format, link, fallback (no link)", () => { + const date = vi.fn(() => date!); + const { getByTestId } = renderMrkdwn( + `Posted `, + verbatim, + { hooks: { date } }, + ); + expect(date).toHaveBeenCalledWith({ + timestamp: "1717000000", + format: "{date_pretty}", + link: null, + fallback: "May 29", + }); + expect(getByTestId("d")).toBeInTheDocument(); + }); + + it("preserves the optional link", () => { + const date = vi.fn(() => date!); + renderMrkdwn( + `Posted `, + verbatim, + { hooks: { date } }, + ); + expect(date).toHaveBeenCalledWith({ + timestamp: "1717000000", + format: "{date_pretty}", + link: "https://example.test", + fallback: "May 29", + }); + }); + }); + }); +}); + +describe("code-span suppression", () => { + it("does not fire hooks.user for directives inside inline code (non-verbatim)", () => { + const user = vi.fn(); + const { container } = renderMrkdwn("Try \\`<@U123>\\`".replace(/\\`/g, "`"), false, { + hooks: { user }, + data: { users: [{ id: "U123", name: "alice" }] }, + }); + expect(user).not.toHaveBeenCalled(); + expect(container.textContent).toContain("<@U123>"); + }); + + it("does not fire hooks.user for directives inside fenced code", () => { + const user = vi.fn(); + const { container } = renderMrkdwn("```\n<@U123>\n```", false, { + hooks: { user }, + data: { users: [{ id: "U123", name: "alice" }] }, + }); + expect(user).not.toHaveBeenCalled(); + expect(container.textContent).toContain("<@U123>"); + }); +}); + +describe("escaped entities", () => { + it("decodes & in link URLs", () => { + const { container } = renderMrkdwn( + ``, + false, + ); + const anchor = container.querySelector("a"); + expect(anchor).not.toBeNull(); + expect(anchor!.getAttribute("href")).toBe("https://example.test/?a=1&b=2"); + expect(anchor!.textContent).toBe("report"); + }); +}); + +describe("regression — non-directive markdown", () => { + it("renders bold via *single-asterisk* in non-verbatim", () => { + const { container } = renderMrkdwn(`Hi *bold* world`, false); + expect(container.querySelector("strong")?.textContent).toBe("bold"); + }); + + it("renders inline code in non-verbatim", () => { + const { container } = renderMrkdwn("Try `code`", false); + expect(container.querySelector("code")?.textContent).toBe("code"); + }); + + it("does NOT bold *text* in verbatim mode (renders literal)", () => { + const { container } = renderMrkdwn(`Hi *bold* world`, true); + expect(container.querySelector("strong")).toBeNull(); + expect(container.textContent).toContain("*bold*"); + }); + + it("auto-links bare URLs in non-verbatim", () => { + const { container } = renderMrkdwn(`Visit `, false); + const anchor = container.querySelector("a"); + expect(anchor?.getAttribute("href")).toBe("https://example.com"); + }); + + it("does NOT auto-link bare URLs in verbatim", () => { + const { container } = renderMrkdwn(`Visit `, true); + expect(container.querySelector("a")).toBeNull(); + expect(container.textContent).toContain(""); + }); +}); + +describe("mixed content", () => { + it("resolves a user mention and renders an autolinked URL alongside it (non-verbatim)", () => { + const user = vi.fn(({ name }) => @{name}); + const { container, getByTestId } = renderMrkdwn( + `Hi <@U123>, see `, + false, + { hooks: { user }, data: { users: [{ id: "U123", name: "alice" }] } }, + ); + expect(getByTestId("u").textContent).toBe("@alice"); + const anchor = container.querySelector("a"); + expect(anchor?.getAttribute("href")).toBe("https://example.com"); + expect(anchor?.textContent).toBe("here"); + }); + + it("resolves a user mention next to literal text in verbatim", () => { + const user = vi.fn(({ name }) => @{name}); + const { container, getByTestId } = renderMrkdwn(`Hi <@U123>!`, true, { + hooks: { user }, + data: { users: [{ id: "U123", name: "alice" }] }, + }); + expect(getByTestId("u").textContent).toBe("@alice"); + expect(container.textContent).toContain("Hi "); + expect(container.textContent).toContain("!"); + }); +}); diff --git a/src/utils/markdown_parser/__tests__/render.tsx b/src/utils/markdown_parser/__tests__/render.tsx new file mode 100644 index 0000000..c7c05ee --- /dev/null +++ b/src/utils/markdown_parser/__tests__/render.tsx @@ -0,0 +1,35 @@ +import { render, RenderResult } from "@testing-library/react"; +import { ReactNode } from "react"; +import { GlobalProvider, GlobalStore } from "../../../store"; +import { TextObject } from "../../../components/composition_objects/text_object"; + +type Data = { + users?: GlobalStore["users"]; + channels?: GlobalStore["channels"]; + user_groups?: GlobalStore["user_groups"]; +}; + +type Options = { + data?: Data; + hooks?: GlobalStore["hooks"]; +}; + +export const renderMrkdwn = ( + text: string, + verbatim: boolean, + options: Options = {}, +): RenderResult => { + return render( + + + , + ); +}; + +export const renderWithProvider = (children: ReactNode, options: Options = {}): RenderResult => { + return render( + + {children} + , + ); +}; diff --git a/src/utils/markdown_parser/directives.tsx b/src/utils/markdown_parser/directives.tsx new file mode 100644 index 0000000..36ae7ab --- /dev/null +++ b/src/utils/markdown_parser/directives.tsx @@ -0,0 +1,82 @@ +import { ReactNode } from "react"; +import { + SlackBroadcast, + SlackChannelMention, + SlackDate, + SlackUserGroupMention, + SlackUserMention, +} from "./sub_elements"; +import { + SlackBroadcastSubElement, + SlackDateSubElement, +} from "./types"; + +export const DIRECTIVE_USER = /<@[^|>\s]+(?:\|[^>]*)?>/; +export const DIRECTIVE_CHANNEL = /<#[^|>\s]+(?:\|[^>]*)?>/; +export const DIRECTIVE_USERGROUP = /\s]+(?:\|[^>]*)?>/; +export const DIRECTIVE_BROADCAST = //; +export const DIRECTIVE_DATE = /]+>/; + +const sources = [ + DIRECTIVE_USER, + DIRECTIVE_CHANNEL, + DIRECTIVE_USERGROUP, + DIRECTIVE_BROADCAST, + DIRECTIVE_DATE, +].map((r) => r.source); + +export const DIRECTIVE_PATTERN_GLOBAL = new RegExp(sources.join("|"), "g"); + +const DATE_PARSE = /]*)>/; + +export const renderDirective = (raw: string, key: number | string): ReactNode => { + if (raw.startsWith("<@")) { + const value = raw.slice(2, -1); + return ; + } + if (raw.startsWith("<#")) { + const value = raw.slice(2, -1); + return ; + } + if (raw.startsWith(" + ); + } + if (raw === "" || raw === "" || raw === "") { + const value = raw.slice(2, -1) as SlackBroadcastSubElement["value"]; + return ; + } + if (raw.startsWith("; + } + return raw; +}; + +export const renderTextWithDirectives = (text: string): ReactNode[] => { + const result: ReactNode[] = []; + let lastIndex = 0; + let match: RegExpExecArray | null; + const pattern = new RegExp(DIRECTIVE_PATTERN_GLOBAL.source, "g"); + while ((match = pattern.exec(text)) !== null) { + if (match.index > lastIndex) { + result.push(text.slice(lastIndex, match.index)); + } + result.push(renderDirective(match[0], `d-${match.index}`)); + lastIndex = match.index + match[0].length; + } + if (lastIndex < text.length) { + result.push(text.slice(lastIndex)); + } + return result; +}; diff --git a/src/utils/markdown_parser/parser.tsx b/src/utils/markdown_parser/parser.tsx index 474e5e9..3923a5d 100644 --- a/src/utils/markdown_parser/parser.tsx +++ b/src/utils/markdown_parser/parser.tsx @@ -1,7 +1,9 @@ import YozoraParser from "@yozora/parser"; import { ReactNode } from "react"; import { GlobalStore } from "../../store"; +import { renderTextWithDirectives } from "./directives"; import { Blockquote, Code, Paragraph } from "./elements"; +import { maskProtectedRegions } from "./preparse"; import { SlackBroadcastTokenizer, SlackChannelMentionTokenizer, @@ -14,6 +16,12 @@ import { MarkdownElement } from "./types"; const parser = new YozoraParser() .unmountTokenizer("@yozora/tokenizer-list") + // Slack directives like `` and `<@U1|name>` contain `@` chars in their + // fallback labels. Yozora's autolink tokenizers misread these as email autolinks and steal + // them from our directive tokenizers. Disable both — bare URL autolinking is already handled + // upstream by the `` / `` regex rewrites that produce `[url](url)` markdown links. + .unmountTokenizer("@yozora/tokenizer-autolink") + .unmountTokenizer("@yozora/tokenizer-autolink-extension") .useTokenizer(new SlackUserMentionTokenizer()) .useTokenizer(new SlackChannelMentionTokenizer()) .useTokenizer(new SlackUserGroupMentionTokenizer()) @@ -29,7 +37,6 @@ type Options = { hooks: GlobalStore["hooks"]; }; -// Helper function to check if a string is a valid URL function isValidURL(string: string) { try { new URL(string); @@ -42,15 +49,35 @@ function isValidURL(string: string) { export const markdown_parser = (markdown: string, options: Options): ReactNode => { if (!markdown) return null; - // If verbatim is true, return plain text without any parsing + // In verbatim mode, Slack semantics say markdown formatting (`*bold*`, `_italic_`, `~strike~`, + // bare URLs, code spans) should render as literal text, but Slack-formed directives are atoms + // that must still resolve through hooks. Split the text by directive boundaries and render + // each segment, preserving newlines as
s. if (options.verbatim) { - return
{markdown}
; + const segments = renderTextWithDirectives(markdown); + return ( +
+ {segments.map((segment, i) => { + if (typeof segment === "string") { + return renderVerbatimText(segment, i); + } + return segment; + })} +
+ ); } let text_string = markdown; - // TRANSFORM ``` TO MAKE IT A CODE BLOCK INSTEAD OF INLINE CODE BLOCK + // Normalize fenced code so yozora can recognize ``` blocks. text_string = text_string.replace(/```/g, `\n\`\`\`\n`); + + // Mask fenced code, inline code, and directive atoms so the regex transforms below + // cannot mangle their interiors. Restore before handing to yozora — yozora's own + // tokenizers parse the original directive/code text. + const mask = maskProtectedRegions(text_string); + text_string = mask.masked; + // REPLACE SINGLE asterisk WITH DOUBLE asterisk text_string = text_string.replace(/(? { return "\n\n" + "LBKS".repeat(extraNewlines.length); }); // Insert a blank line after blockquote lines if the next line is not a blockquote - // This ensures only the line starting with '>' is treated as the blockquote. text_string = text_string.replace(/^>.*$(?!\n>)/gm, "$&\n"); - // REPLACE with @here - text_string = text_string.replace(//g, "@here"); - // REPLACE with @everyone - text_string = text_string.replace(//g, "@everyone"); - // REPLACE with @channel - text_string = text_string.replace(//g, "@channel"); + // Restore the originally-masked fenced code, inline code, and directive atoms so yozora + // sees their native shape. Directive tokenizers will tokenize them at parse time. + text_string = mask.restore(text_string); const parsed_data = parser.parse(text_string); @@ -109,3 +129,14 @@ export const markdown_parser = (markdown: string, options: Options): ReactNode = ); }; + +const renderVerbatimText = (text: string, baseKey: number): ReactNode => { + if (!text.includes("\n")) return text; + const lines = text.split("\n"); + const out: ReactNode[] = []; + lines.forEach((line, idx) => { + if (idx > 0) out.push(
); + if (line) out.push(line); + }); + return {out}; +}; diff --git a/src/utils/markdown_parser/preparse.ts b/src/utils/markdown_parser/preparse.ts new file mode 100644 index 0000000..e998bcb --- /dev/null +++ b/src/utils/markdown_parser/preparse.ts @@ -0,0 +1,36 @@ +import { DIRECTIVE_PATTERN_GLOBAL } from "./directives"; + +const PLACEHOLDER_OPEN = ""; +const PLACEHOLDER_CLOSE = ""; +const PLACEHOLDER_PATTERN = new RegExp(`${PLACEHOLDER_OPEN}([0-9a-z]+)${PLACEHOLDER_CLOSE}`, "g"); + +const FENCED_CODE = /```\n[\s\S]*?\n```/g; +const INLINE_CODE = /`[^`\n]+`/g; + +type Mask = { + masked: string; + restore: (input: string) => string; +}; + +const createMask = (input: string, patterns: RegExp[]): Mask => { + const tokens: string[] = []; + let masked = input; + for (const pattern of patterns) { + masked = masked.replace(pattern, (match) => { + const id = tokens.length.toString(36); + tokens.push(match); + return `${PLACEHOLDER_OPEN}${id}${PLACEHOLDER_CLOSE}`; + }); + } + return { + masked, + restore: (s) => + s.replace(PLACEHOLDER_PATTERN, (full, id) => { + const idx = parseInt(id, 36); + return tokens[idx] ?? full; + }), + }; +}; + +export const maskProtectedRegions = (input: string): Mask => + createMask(input, [FENCED_CODE, INLINE_CODE, DIRECTIVE_PATTERN_GLOBAL]); diff --git a/src/utils/markdown_parser/sub_elements/slack_broadcast.tsx b/src/utils/markdown_parser/sub_elements/slack_broadcast.tsx index 7b5afe7..1ea2ad9 100644 --- a/src/utils/markdown_parser/sub_elements/slack_broadcast.tsx +++ b/src/utils/markdown_parser/sub_elements/slack_broadcast.tsx @@ -9,9 +9,13 @@ export const SlackBroadcast = (props: Props) => { const { element } = props; const { hooks } = useGlobalData(); - if (element.value === "here" && hooks.atHere) return <>{hooks.atHere()}; - if (element.value === "everyone" && hooks.atEveryone) return <>{hooks.atEveryone()}; - if (element.value === "channel" && hooks.atChannel) return <>{hooks.atChannel()}; + if (element.value === "here" && hooks.atHere) return <>{hooks.atHere(undefined)}; + if (element.value === "everyone" && hooks.atEveryone) return <>{hooks.atEveryone(undefined)}; + if (element.value === "channel" && hooks.atChannel) return <>{hooks.atChannel(undefined)}; - return @{element.value}; + return ( + + @{element.value} + + ); }; diff --git a/src/utils/markdown_parser/sub_elements/slack_channel_mention.tsx b/src/utils/markdown_parser/sub_elements/slack_channel_mention.tsx index f43240f..b5fb9c6 100644 --- a/src/utils/markdown_parser/sub_elements/slack_channel_mention.tsx +++ b/src/utils/markdown_parser/sub_elements/slack_channel_mention.tsx @@ -9,27 +9,26 @@ export const SlackChannelMention = (props: Props) => { const { element } = props; const { hooks, channels } = useGlobalData(); - const channel_id = element.value; - const channel = channels.find( - (u) => u.id === channel_id.split("|")[0] || u.name === channel_id.split("|")[0], - ); - const label = channel?.name || channel_id.split("|")[1] || channel_id.split("|")[0] || channel_id; + const raw = element.value; + const id_part = raw.split("|")[0] ?? raw; + const fallback_label = raw.split("|")[1]; + const channel = channels.find((u) => u.id === id_part || u.name === id_part); + const label = channel?.name || fallback_label || id_part; if (hooks.channel) { return ( <> {hooks.channel( - channel || { - id: channel_id.split("|")[0] || channel_id, - name: label, - }, + channel + ? { ...channel, style: undefined } + : { id: id_part, name: label, style: undefined }, )} ); } return ( - + #{label} ); diff --git a/src/utils/markdown_parser/sub_elements/slack_user_group_mention.tsx b/src/utils/markdown_parser/sub_elements/slack_user_group_mention.tsx index 4c8a61a..8b59712 100644 --- a/src/utils/markdown_parser/sub_elements/slack_user_group_mention.tsx +++ b/src/utils/markdown_parser/sub_elements/slack_user_group_mention.tsx @@ -9,14 +9,26 @@ export const SlackUserGroupMention = (props: Props) => { const { element } = props; const { hooks, user_groups } = useGlobalData(); - const group_id = element.value; - const group = user_groups.find((u) => u.id === group_id || u.name === group_id); - const label = group?.name || group_id; + const raw = element.value; + const id_part = raw.split("|")[0] ?? raw; + const fallback_label = raw.split("|")[1]; + const group = user_groups.find((u) => u.id === id_part || u.name === id_part); + const label = group?.name || fallback_label || id_part; - if (hooks.usergroup) return <>{hooks.usergroup(group || { id: group_id, name: label })}; + if (hooks.usergroup) { + return ( + <> + {hooks.usergroup( + group + ? { ...group, style: undefined } + : { id: id_part, name: label, style: undefined }, + )} + + ); + } return ( - + @{label} ); diff --git a/src/utils/markdown_parser/sub_elements/slack_user_mention.tsx b/src/utils/markdown_parser/sub_elements/slack_user_mention.tsx index c02705f..a726504 100644 --- a/src/utils/markdown_parser/sub_elements/slack_user_mention.tsx +++ b/src/utils/markdown_parser/sub_elements/slack_user_mention.tsx @@ -9,13 +9,26 @@ export const SlackUserMention = (props: Props) => { const { element } = props; const { hooks, users } = useGlobalData(); - const user_id = element.value; - const user = users.find((u) => u.id === user_id || u.name === user_id); - if (hooks.user) return <>{hooks.user(user || { id: user_id, name: user_id })}; - const label = user?.name || user_id; + const raw = element.value; + const id_part = raw.split("|")[0] ?? raw; + const fallback_label = raw.split("|")[1]; + const user = users.find((u) => u.id === id_part || u.name === id_part); + const label = user?.name || fallback_label || id_part; + + if (hooks.user) { + return ( + <> + {hooks.user( + user + ? { ...user, style: undefined } + : { id: id_part, name: label, style: undefined }, + )} + + ); + } return ( - + @{label} ); diff --git a/src/utils/markdown_parser/tokenizers/slack_broadcast/match.ts b/src/utils/markdown_parser/tokenizers/slack_broadcast/match.ts index 1585a85..94de468 100644 --- a/src/utils/markdown_parser/tokenizers/slack_broadcast/match.ts +++ b/src/utils/markdown_parser/tokenizers/slack_broadcast/match.ts @@ -7,6 +7,9 @@ import type { } from "@yozora/core-tokenizer"; import { SlackBroadcastType, type IDelimiter, type IThis, type IToken, type T } from "./types"; +const AT_TARGETS = ["@everyone", "@here", "@channel"]; +const BRACKET_TARGETS = ["", "", ""]; + export const match: IMatchInlineHookCreator = function (api) { return { findDelimiter, processSingleDelimiter }; @@ -15,22 +18,27 @@ export const match: IMatchInlineHookCreator = func const blockStartIndex: number = api.getBlockStartIndex(); const blockEndIndex: number = api.getBlockEndIndex(); - const targets = ["@everyone", "@here", "@channel"]; const potentialDelimiters: IDelimiter[] = []; for (let i = blockStartIndex; i < blockEndIndex; ++i) { - if (nodePoints[i]?.codePoint === AsciiCodePoint.AT_SIGN) { - for (const target of targets) { - if (matchTarget(nodePoints, i, target)) { - potentialDelimiters.push({ - type: "full", - startIndex: i, - endIndex: i + target.length, - thickness: target.length, - }); - i += target.length - 1; // Skip past the matched target - break; - } + const cp = nodePoints[i]?.codePoint; + const targets = + cp === AsciiCodePoint.AT_SIGN + ? AT_TARGETS + : cp === AsciiCodePoint.OPEN_ANGLE + ? BRACKET_TARGETS + : null; + if (!targets) continue; + for (const target of targets) { + if (matchTarget(nodePoints, i, target)) { + potentialDelimiters.push({ + type: "full", + startIndex: i, + endIndex: i + target.length, + thickness: target.length, + }); + i += target.length - 1; + break; } } } diff --git a/src/utils/markdown_parser/tokenizers/slack_broadcast/parse.ts b/src/utils/markdown_parser/tokenizers/slack_broadcast/parse.ts index c794b33..5789304 100644 --- a/src/utils/markdown_parser/tokenizers/slack_broadcast/parse.ts +++ b/src/utils/markdown_parser/tokenizers/slack_broadcast/parse.ts @@ -1,5 +1,5 @@ import type { INodePoint } from "@yozora/character"; -import { calcStringFromNodePoints } from "@yozora/character"; +import { AsciiCodePoint, calcStringFromNodePoints } from "@yozora/character"; import type { IParseInlineHookCreator } from "@yozora/core-tokenizer"; import { SlackBroadcastType, type INode, type IThis, type IToken, type T } from "./types"; @@ -8,8 +8,9 @@ export const parse: IParseInlineHookCreator = function parse: (tokens) => tokens.map((token) => { const nodePoints: ReadonlyArray = api.getNodePoints(); - let startIndex: number = token.startIndex + 1; // skip @ - let endIndex: number = token.endIndex; + const isBracket = nodePoints[token.startIndex]?.codePoint === AsciiCodePoint.OPEN_ANGLE; + const startIndex = isBracket ? token.startIndex + 2 : token.startIndex + 1; + const endIndex = isBracket ? token.endIndex - 1 : token.endIndex; const value = calcStringFromNodePoints(nodePoints, startIndex, endIndex); const node: INode = api.shouldReservePosition diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..ded5e82 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + test: { + environment: "happy-dom", + setupFiles: ["./src/test/setup.ts"], + include: ["src/**/*.{test,spec}.{ts,tsx}"], + }, +}); From c158e4ac4440fa90bd5054cd36752c1cfd86af51 Mon Sep 17 00:00:00 2001 From: Daniel Gomes Date: Wed, 20 May 2026 12:34:06 -0300 Subject: [PATCH 2/3] =?UTF-8?q?refactor:=20=F0=9F=94=A7=20simplify=20verba?= =?UTF-8?q?tim=20handling=20to=20match=20Slack's=20empirical=20behavior?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Empirically tested in Slack's section/mrkdwn renderer (side-by-side Block Kit Builder, both verbatim modes). Findings: - Verbatim:true does NOT suppress markdown formatting (`*bold*`, `_italic_`, `~strike~`, `` `code` ``) — Slack renders these the same in both modes. - Verbatim:true does NOT suppress code spans or angle-bracket URLs. - The ONE thing verbatim:true does suppress in section/mrkdwn (that affects this library) is bare-form `@here` / `@channel` / `@everyone` (without the `` brackets). Slack interpolates them as chips in verbatim:false and renders them as plain text in verbatim:true. Implementation changes: - Drop the separate "split by directive boundaries, render rest as literal text" verbatim path. Both modes now flow through the same pipeline. - Build two yozora parser instances. The verbatim instance constructs SlackBroadcastTokenizer with `matchTypedBroadcast: false` so bare `@here` etc. stay as plain text. Bracket-form `` etc. still resolve in both modes. - Delete `directives.tsx` (no longer needed). Inline DIRECTIVE_PATTERN helpers into `preparse.ts`. Tests: - Flip the two tests that asserted the wrong verbatim behavior ("does NOT bold *text* in verbatim" → "renders *bold* in verbatim (matches Slack)", same for `` autolinking). - Rename code-span suppression block to call out the known divergence (Slack resolves directives inside code spans; this library doesn't — tracked as a follow-up since it needs custom tokenization). - Add per-broadcast-target tests for the new typed-broadcast suppression. - Add a DOM-equality test confirming verbatim and non-verbatim produce identical output for non-typed-broadcast content. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/fix-mrkdwn-directives.md | 2 +- .../__tests__/directives.test.tsx | 103 ++++++++++++- src/utils/markdown_parser/directives.tsx | 82 ---------- src/utils/markdown_parser/parser.tsx | 74 ++++----- src/utils/markdown_parser/preparse.ts | 22 ++- .../tokenizers/slack_broadcast/match.ts | 141 +++++++++--------- .../tokenizers/slack_broadcast/tokenizer.ts | 9 +- .../tokenizers/slack_broadcast/types.ts | 8 +- 8 files changed, 229 insertions(+), 212 deletions(-) delete mode 100644 src/utils/markdown_parser/directives.tsx diff --git a/.changeset/fix-mrkdwn-directives.md b/.changeset/fix-mrkdwn-directives.md index 4a6d158..f82016b 100644 --- a/.changeset/fix-mrkdwn-directives.md +++ b/.changeset/fix-mrkdwn-directives.md @@ -2,4 +2,4 @@ "slack-blocks-to-jsx": patch --- -Resolve Slack directive atoms (`<@U…>`, `<#C…>`, ``, ``, ``) in `section`/`mrkdwn` text and other mrkdwn-typed text. Directives now fire the same hooks as the rich_text path in both `verbatim: true` and `verbatim: false` modes. Code-span content stays literal (directives inside `` `…` `` or ` ```…``` ` are not resolved). `&` is now decoded alongside the existing `>` / `<` decoding so link `href`s and visible text don't leak literal `&`. +Resolve Slack directive atoms (`<@U…>`, `<#C…>`, ``, ``, ``) in `section`/`mrkdwn` text and other mrkdwn-typed text. Directives now fire the same hooks as the `rich_text` path. `verbatim: true` matches Slack's empirical behavior — it suppresses bare-form `@here` / `@channel` / `@everyone` interpolation but is otherwise a no-op (markdown sugar, code spans, angle-bracket URLs, and structured `` directives all render the same in both modes). `&` is now decoded alongside `>` / `<` so escaped ampersands don't leak into hrefs or visible text. diff --git a/src/utils/markdown_parser/__tests__/directives.test.tsx b/src/utils/markdown_parser/__tests__/directives.test.tsx index 3b61c14..614c9b3 100644 --- a/src/utils/markdown_parser/__tests__/directives.test.tsx +++ b/src/utils/markdown_parser/__tests__/directives.test.tsx @@ -162,7 +162,13 @@ describe("date directives", () => { }); }); -describe("code-span suppression", () => { +// KNOWN DIVERGENCE FROM SLACK: Slack's own renderer resolves directives that appear inside +// inline code (`` `<@U…>` ``) — empirically confirmed in Slack's section/mrkdwn rendering. +// This library currently keeps them literal (CommonMark-style code-span opacity), which is +// what most React/markdown consumers expect. Resolving directives inside code spans is +// tracked as a follow-up; it requires either a custom inline-code tokenizer or an AST-walk +// after yozora parse. +describe("code-span suppression (known divergence from Slack)", () => { it("does not fire hooks.user for directives inside inline code (non-verbatim)", () => { const user = vi.fn(); const { container } = renderMrkdwn("Try \\`<@U123>\\`".replace(/\\`/g, "`"), false, { @@ -208,22 +214,103 @@ describe("regression — non-directive markdown", () => { expect(container.querySelector("code")?.textContent).toBe("code"); }); - it("does NOT bold *text* in verbatim mode (renders literal)", () => { + // Verbatim is effectively a no-op in Slack's renderer (empirically verified — see PR + // description). Markdown formatting, code spans, directives, and angle-bracket URLs all + // render the same in both modes. Slack only suppresses bare-URL autolinking in verbatim + // mode, which this library doesn't do in either mode anyway. + + it("renders *bold* in verbatim mode (matches Slack)", () => { const { container } = renderMrkdwn(`Hi *bold* world`, true); - expect(container.querySelector("strong")).toBeNull(); - expect(container.textContent).toContain("*bold*"); + expect(container.querySelector("strong")?.textContent).toBe("bold"); }); - it("auto-links bare URLs in non-verbatim", () => { + it("renders as a link in non-verbatim", () => { const { container } = renderMrkdwn(`Visit `, false); const anchor = container.querySelector("a"); expect(anchor?.getAttribute("href")).toBe("https://example.com"); }); - it("does NOT auto-link bare URLs in verbatim", () => { + it("renders as a link in verbatim too (matches Slack)", () => { const { container } = renderMrkdwn(`Visit `, true); - expect(container.querySelector("a")).toBeNull(); - expect(container.textContent).toContain(""); + const anchor = container.querySelector("a"); + expect(anchor?.getAttribute("href")).toBe("https://example.com"); + }); + + it("renders as a link in verbatim too (matches Slack)", () => { + const { container } = renderMrkdwn(`Visit `, true); + const anchor = container.querySelector("a"); + expect(anchor?.getAttribute("href")).toBe("https://example.com"); + expect(anchor?.textContent).toBe("click"); + }); + + it("renders inline code in verbatim too", () => { + const { container } = renderMrkdwn("Try `code`", true); + expect(container.querySelector("code")?.textContent).toBe("code"); + }); +}); + +describe("typed-broadcast suppression in verbatim mode (matches Slack)", () => { + // Slack renders bare `@here` / `@channel` / `@everyone` (without `` brackets) as chips + // in verbatim:false and as plain text in verbatim:true. This is the only verbatim difference + // this library cares about — empirically verified against Slack's section/mrkdwn renderer. + + it("fires hooks.atHere for typed @here in non-verbatim", () => { + const atHere = vi.fn(() => @here); + const { getByTestId } = renderMrkdwn(`Hello @here folks`, false, { hooks: { atHere } }); + expect(atHere).toHaveBeenCalledTimes(1); + expect(getByTestId("h")).toBeInTheDocument(); + }); + + it("does NOT fire hooks.atHere for typed @here in verbatim", () => { + const atHere = vi.fn(); + const { container } = renderMrkdwn(`Hello @here folks`, true, { hooks: { atHere } }); + expect(atHere).not.toHaveBeenCalled(); + expect(container.textContent).toContain("@here"); + }); + + it("still fires hooks.atHere for bracket-form in verbatim", () => { + const atHere = vi.fn(() => @here); + const { getByTestId } = renderMrkdwn(`Hello folks`, true, { hooks: { atHere } }); + expect(atHere).toHaveBeenCalledTimes(1); + expect(getByTestId("h")).toBeInTheDocument(); + }); + + it("does NOT fire hooks.atChannel for typed @channel in verbatim", () => { + const atChannel = vi.fn(); + renderMrkdwn(`@channel`, true, { hooks: { atChannel } }); + expect(atChannel).not.toHaveBeenCalled(); + }); + + it("does NOT fire hooks.atEveryone for typed @everyone in verbatim", () => { + const atEveryone = vi.fn(); + renderMrkdwn(`@everyone`, true, { hooks: { atEveryone } }); + expect(atEveryone).not.toHaveBeenCalled(); + }); +}); + +describe("verbatim and non-verbatim render identically (except typed broadcasts)", () => { + const payload = + "broadcasts: \n" + + "date: \n" + + "sugar: *bold* _italic_ ~strike~ `code`\n" + + "url angle: \n" + + "url angle+label: "; + + it("produces identical DOM markup for the same payload", () => { + const hooks = { + atHere: () => @here, + atChannel: () => @channel, + atEveryone: () => @everyone, + date: () => date, + }; + const a = renderMrkdwn(payload, true, { hooks }); + const verbatimHtml = a.container.innerHTML; + a.unmount(); + + const b = renderMrkdwn(payload, false, { hooks }); + const nonVerbatimHtml = b.container.innerHTML; + + expect(verbatimHtml).toBe(nonVerbatimHtml); }); }); diff --git a/src/utils/markdown_parser/directives.tsx b/src/utils/markdown_parser/directives.tsx deleted file mode 100644 index 36ae7ab..0000000 --- a/src/utils/markdown_parser/directives.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { ReactNode } from "react"; -import { - SlackBroadcast, - SlackChannelMention, - SlackDate, - SlackUserGroupMention, - SlackUserMention, -} from "./sub_elements"; -import { - SlackBroadcastSubElement, - SlackDateSubElement, -} from "./types"; - -export const DIRECTIVE_USER = /<@[^|>\s]+(?:\|[^>]*)?>/; -export const DIRECTIVE_CHANNEL = /<#[^|>\s]+(?:\|[^>]*)?>/; -export const DIRECTIVE_USERGROUP = /\s]+(?:\|[^>]*)?>/; -export const DIRECTIVE_BROADCAST = //; -export const DIRECTIVE_DATE = /]+>/; - -const sources = [ - DIRECTIVE_USER, - DIRECTIVE_CHANNEL, - DIRECTIVE_USERGROUP, - DIRECTIVE_BROADCAST, - DIRECTIVE_DATE, -].map((r) => r.source); - -export const DIRECTIVE_PATTERN_GLOBAL = new RegExp(sources.join("|"), "g"); - -const DATE_PARSE = /]*)>/; - -export const renderDirective = (raw: string, key: number | string): ReactNode => { - if (raw.startsWith("<@")) { - const value = raw.slice(2, -1); - return ; - } - if (raw.startsWith("<#")) { - const value = raw.slice(2, -1); - return ; - } - if (raw.startsWith(" - ); - } - if (raw === "" || raw === "" || raw === "") { - const value = raw.slice(2, -1) as SlackBroadcastSubElement["value"]; - return ; - } - if (raw.startsWith("; - } - return raw; -}; - -export const renderTextWithDirectives = (text: string): ReactNode[] => { - const result: ReactNode[] = []; - let lastIndex = 0; - let match: RegExpExecArray | null; - const pattern = new RegExp(DIRECTIVE_PATTERN_GLOBAL.source, "g"); - while ((match = pattern.exec(text)) !== null) { - if (match.index > lastIndex) { - result.push(text.slice(lastIndex, match.index)); - } - result.push(renderDirective(match[0], `d-${match.index}`)); - lastIndex = match.index + match[0].length; - } - if (lastIndex < text.length) { - result.push(text.slice(lastIndex)); - } - return result; -}; diff --git a/src/utils/markdown_parser/parser.tsx b/src/utils/markdown_parser/parser.tsx index 3923a5d..f570e0e 100644 --- a/src/utils/markdown_parser/parser.tsx +++ b/src/utils/markdown_parser/parser.tsx @@ -1,7 +1,6 @@ import YozoraParser from "@yozora/parser"; import { ReactNode } from "react"; import { GlobalStore } from "../../store"; -import { renderTextWithDirectives } from "./directives"; import { Blockquote, Code, Paragraph } from "./elements"; import { maskProtectedRegions } from "./preparse"; import { @@ -14,23 +13,37 @@ import { } from "./tokenizers"; import { MarkdownElement } from "./types"; -const parser = new YozoraParser() - .unmountTokenizer("@yozora/tokenizer-list") - // Slack directives like `` and `<@U1|name>` contain `@` chars in their - // fallback labels. Yozora's autolink tokenizers misread these as email autolinks and steal - // them from our directive tokenizers. Disable both — bare URL autolinking is already handled - // upstream by the `` / `` regex rewrites that produce `[url](url)` markdown links. - .unmountTokenizer("@yozora/tokenizer-autolink") - .unmountTokenizer("@yozora/tokenizer-autolink-extension") - .useTokenizer(new SlackUserMentionTokenizer()) - .useTokenizer(new SlackChannelMentionTokenizer()) - .useTokenizer(new SlackUserGroupMentionTokenizer()) - .useTokenizer(new SlackBroadcastTokenizer()) - .useTokenizer(new SlackDateTokenizer()) - .useTokenizer(new SlackEmojiTokenizer()); +// Slack directives like `` and `<@U1|name>` contain `@` chars in their +// fallback labels. Yozora's autolink tokenizers misread these as email autolinks and steal +// them from our directive tokenizers. Disable both — bare URL autolinking is already handled +// upstream by the `` / `` regex rewrites that produce `[url](url)` markdown links. +const buildParser = (matchTypedBroadcast: boolean) => + new YozoraParser() + .unmountTokenizer("@yozora/tokenizer-list") + .unmountTokenizer("@yozora/tokenizer-autolink") + .unmountTokenizer("@yozora/tokenizer-autolink-extension") + .useTokenizer(new SlackUserMentionTokenizer()) + .useTokenizer(new SlackChannelMentionTokenizer()) + .useTokenizer(new SlackUserGroupMentionTokenizer()) + .useTokenizer(new SlackBroadcastTokenizer({ matchTypedBroadcast })) + .useTokenizer(new SlackDateTokenizer()) + .useTokenizer(new SlackEmojiTokenizer()); + +// Slack's `verbatim` flag is effectively a no-op in section/mrkdwn rendering EXCEPT for one +// case: it suppresses interpolation of typed-out `@here` / `@channel` / `@everyone` (without +// the `` brackets). Empirically verified — see PR description for the side-by-side. We +// honor that by using a parser without the bare-form broadcast match in verbatim mode. +const parserDefault = buildParser(true); +const parserVerbatim = buildParser(false); type Options = { markdown: boolean; + // In Slack's renderer, `verbatim` only changes two things in section/mrkdwn (empirically + // verified): it suppresses bare-URL autolinking, and it suppresses interpolation of bare + // `@here` / `@channel` / `@everyone`. Everything else — directives in `<…>` form, markdown + // sugar, code spans, angle-bracket URLs — renders identically in both modes. This library + // doesn't autolink bare URLs in either mode, so the only thing we gate on `verbatim` is the + // bare-broadcast tokenizer match. verbatim: boolean; users: GlobalStore["users"]; channels: GlobalStore["channels"]; @@ -49,24 +62,6 @@ function isValidURL(string: string) { export const markdown_parser = (markdown: string, options: Options): ReactNode => { if (!markdown) return null; - // In verbatim mode, Slack semantics say markdown formatting (`*bold*`, `_italic_`, `~strike~`, - // bare URLs, code spans) should render as literal text, but Slack-formed directives are atoms - // that must still resolve through hooks. Split the text by directive boundaries and render - // each segment, preserving newlines as
s. - if (options.verbatim) { - const segments = renderTextWithDirectives(markdown); - return ( -
- {segments.map((segment, i) => { - if (typeof segment === "string") { - return renderVerbatimText(segment, i); - } - return segment; - })} -
- ); - } - let text_string = markdown; // Normalize fenced code so yozora can recognize ``` blocks. @@ -112,7 +107,7 @@ export const markdown_parser = (markdown: string, options: Options): ReactNode = // sees their native shape. Directive tokenizers will tokenize them at parse time. text_string = mask.restore(text_string); - const parsed_data = parser.parse(text_string); + const parsed_data = (options.verbatim ? parserVerbatim : parserDefault).parse(text_string); const elements = parsed_data.children as unknown as MarkdownElement[]; @@ -129,14 +124,3 @@ export const markdown_parser = (markdown: string, options: Options): ReactNode = ); }; - -const renderVerbatimText = (text: string, baseKey: number): ReactNode => { - if (!text.includes("\n")) return text; - const lines = text.split("\n"); - const out: ReactNode[] = []; - lines.forEach((line, idx) => { - if (idx > 0) out.push(
); - if (line) out.push(line); - }); - return {out}; -}; diff --git a/src/utils/markdown_parser/preparse.ts b/src/utils/markdown_parser/preparse.ts index e998bcb..57afe12 100644 --- a/src/utils/markdown_parser/preparse.ts +++ b/src/utils/markdown_parser/preparse.ts @@ -1,5 +1,3 @@ -import { DIRECTIVE_PATTERN_GLOBAL } from "./directives"; - const PLACEHOLDER_OPEN = ""; const PLACEHOLDER_CLOSE = ""; const PLACEHOLDER_PATTERN = new RegExp(`${PLACEHOLDER_OPEN}([0-9a-z]+)${PLACEHOLDER_CLOSE}`, "g"); @@ -7,6 +5,26 @@ const PLACEHOLDER_PATTERN = new RegExp(`${PLACEHOLDER_OPEN}([0-9a-z]+)${PLACEHOL const FENCED_CODE = /```\n[\s\S]*?\n```/g; const INLINE_CODE = /`[^`\n]+`/g; +// Slack-formed directive atoms. Pre-masked before the URL-rewrite regex pass so it cannot +// mangle their interiors (defense in depth: `isValidURL` happens to leave directives alone +// today, but the protection is incidental, not deliberate). +const DIRECTIVE_USER = /<@[^|>\s]+(?:\|[^>]*)?>/; +const DIRECTIVE_CHANNEL = /<#[^|>\s]+(?:\|[^>]*)?>/; +const DIRECTIVE_USERGROUP = /\s]+(?:\|[^>]*)?>/; +const DIRECTIVE_BROADCAST = //; +const DIRECTIVE_DATE = /]+>/; + +const DIRECTIVE_PATTERN_GLOBAL = new RegExp( + [ + DIRECTIVE_USER.source, + DIRECTIVE_CHANNEL.source, + DIRECTIVE_USERGROUP.source, + DIRECTIVE_BROADCAST.source, + DIRECTIVE_DATE.source, + ].join("|"), + "g", +); + type Mask = { masked: string; restore: (input: string) => string; diff --git a/src/utils/markdown_parser/tokenizers/slack_broadcast/match.ts b/src/utils/markdown_parser/tokenizers/slack_broadcast/match.ts index 94de468..f854fbc 100644 --- a/src/utils/markdown_parser/tokenizers/slack_broadcast/match.ts +++ b/src/utils/markdown_parser/tokenizers/slack_broadcast/match.ts @@ -10,87 +10,90 @@ import { SlackBroadcastType, type IDelimiter, type IThis, type IToken, type T } const AT_TARGETS = ["@everyone", "@here", "@channel"]; const BRACKET_TARGETS = ["", "", ""]; -export const match: IMatchInlineHookCreator = function (api) { - return { findDelimiter, processSingleDelimiter }; +export const createMatch = ( + matchTypedBroadcast: boolean, +): IMatchInlineHookCreator => + function (api) { + return { findDelimiter, processSingleDelimiter }; - function* findDelimiter(): IResultOfFindDelimiters { - const nodePoints: ReadonlyArray = api.getNodePoints(); - const blockStartIndex: number = api.getBlockStartIndex(); - const blockEndIndex: number = api.getBlockEndIndex(); + function* findDelimiter(): IResultOfFindDelimiters { + const nodePoints: ReadonlyArray = api.getNodePoints(); + const blockStartIndex: number = api.getBlockStartIndex(); + const blockEndIndex: number = api.getBlockEndIndex(); - const potentialDelimiters: IDelimiter[] = []; + const potentialDelimiters: IDelimiter[] = []; - for (let i = blockStartIndex; i < blockEndIndex; ++i) { - const cp = nodePoints[i]?.codePoint; - const targets = - cp === AsciiCodePoint.AT_SIGN - ? AT_TARGETS - : cp === AsciiCodePoint.OPEN_ANGLE - ? BRACKET_TARGETS - : null; - if (!targets) continue; - for (const target of targets) { - if (matchTarget(nodePoints, i, target)) { - potentialDelimiters.push({ - type: "full", - startIndex: i, - endIndex: i + target.length, - thickness: target.length, - }); - i += target.length - 1; - break; + for (let i = blockStartIndex; i < blockEndIndex; ++i) { + const cp = nodePoints[i]?.codePoint; + const targets = + cp === AsciiCodePoint.AT_SIGN && matchTypedBroadcast + ? AT_TARGETS + : cp === AsciiCodePoint.OPEN_ANGLE + ? BRACKET_TARGETS + : null; + if (!targets) continue; + for (const target of targets) { + if (matchTarget(nodePoints, i, target)) { + potentialDelimiters.push({ + type: "full", + startIndex: i, + endIndex: i + target.length, + thickness: target.length, + }); + i += target.length - 1; + break; + } } } - } - let pIndex = 0; - let lastEndIndex = -1; - let currentDelimiter: IDelimiter | null = null; - while (pIndex < potentialDelimiters.length) { - const [startIndex, endIndex] = yield currentDelimiter; + let pIndex = 0; + let lastEndIndex = -1; + let currentDelimiter: IDelimiter | null = null; + while (pIndex < potentialDelimiters.length) { + const [startIndex, endIndex] = yield currentDelimiter; - if (lastEndIndex === endIndex) { - if (currentDelimiter == null || currentDelimiter.startIndex >= startIndex) continue; - } - lastEndIndex = endIndex; + if (lastEndIndex === endIndex) { + if (currentDelimiter == null || currentDelimiter.startIndex >= startIndex) continue; + } + lastEndIndex = endIndex; - for (; pIndex < potentialDelimiters.length; ++pIndex) { - const delimiter = potentialDelimiters[pIndex]!; - if (delimiter.startIndex >= startIndex) { - currentDelimiter = { - type: "full", - startIndex: delimiter.startIndex, - endIndex: delimiter.endIndex, - thickness: delimiter.thickness, - }; - break; + for (; pIndex < potentialDelimiters.length; ++pIndex) { + const delimiter = potentialDelimiters[pIndex]!; + if (delimiter.startIndex >= startIndex) { + currentDelimiter = { + type: "full", + startIndex: delimiter.startIndex, + endIndex: delimiter.endIndex, + thickness: delimiter.thickness, + }; + break; + } } } } - } - function matchTarget( - nodePoints: ReadonlyArray, - startIndex: number, - target: string, - ): boolean { - for (let j = 0; j < target.length; ++j) { - if (nodePoints[startIndex + j]?.codePoint !== target.charCodeAt(j)) { - return false; + function matchTarget( + nodePoints: ReadonlyArray, + startIndex: number, + target: string, + ): boolean { + for (let j = 0; j < target.length; ++j) { + if (nodePoints[startIndex + j]?.codePoint !== target.charCodeAt(j)) { + return false; + } } + return true; } - return true; - } - function processSingleDelimiter( - delimiter: IDelimiter, - ): IResultOfProcessSingleDelimiter { - const token: IToken = { - nodeType: SlackBroadcastType, - startIndex: delimiter.startIndex, - endIndex: delimiter.endIndex, - thickness: delimiter.thickness, - }; - return [token]; - } -}; + function processSingleDelimiter( + delimiter: IDelimiter, + ): IResultOfProcessSingleDelimiter { + const token: IToken = { + nodeType: SlackBroadcastType, + startIndex: delimiter.startIndex, + endIndex: delimiter.endIndex, + thickness: delimiter.thickness, + }; + return [token]; + } + }; diff --git a/src/utils/markdown_parser/tokenizers/slack_broadcast/tokenizer.ts b/src/utils/markdown_parser/tokenizers/slack_broadcast/tokenizer.ts index ff8e985..acc460c 100644 --- a/src/utils/markdown_parser/tokenizers/slack_broadcast/tokenizer.ts +++ b/src/utils/markdown_parser/tokenizers/slack_broadcast/tokenizer.ts @@ -4,7 +4,7 @@ import type { IParseInlineHookCreator, } from "@yozora/core-tokenizer"; import { BaseInlineTokenizer, TokenizerPriority } from "@yozora/core-tokenizer"; -import { match } from "./match"; +import { createMatch } from "./match"; import { parse } from "./parse"; import { SlackBroadcastType, @@ -20,13 +20,14 @@ export class SlackBroadcastTokenizer extends BaseInlineTokenizer implements IInlineTokenizer { + public override readonly match: IMatchInlineHookCreator; + public override readonly parse: IParseInlineHookCreator = parse; + constructor(props: ITokenizerProps = {}) { super({ name: SlackBroadcastType, priority: props.priority || TokenizerPriority.ATOMIC, }); + this.match = createMatch(props.matchTypedBroadcast ?? true); } - - public override readonly match: IMatchInlineHookCreator = match; - public override readonly parse: IParseInlineHookCreator = parse; } diff --git a/src/utils/markdown_parser/tokenizers/slack_broadcast/types.ts b/src/utils/markdown_parser/tokenizers/slack_broadcast/types.ts index 2ed174c..893fd66 100644 --- a/src/utils/markdown_parser/tokenizers/slack_broadcast/types.ts +++ b/src/utils/markdown_parser/tokenizers/slack_broadcast/types.ts @@ -20,4 +20,10 @@ export interface IDelimiter extends ITokenDelimiter { } export type IThis = ITokenizer; -export type ITokenizerProps = Partial; +export type ITokenizerProps = Partial & { + // When false, the tokenizer only matches the bracket-form `` etc. and ignores bare + // `@here` / `@everyone` / `@channel`. Slack suppresses typed-broadcast interpolation in + // `verbatim: true` mode (empirically verified), so the verbatim parser instance should set + // this to false. Defaults to true. + matchTypedBroadcast?: boolean; +}; From 7f87c5d642c098a8ea18921e8df134a18a93dba2 Mon Sep 17 00:00:00 2001 From: Daniel Gomes Date: Wed, 20 May 2026 13:19:41 -0300 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=F0=9F=90=9B=20defer=20HTML=20entity?= =?UTF-8?q?=20decoding=20to=20leaf=20renderers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-typed text in mrkdwn payloads contains Slack-escaped `<`, `>`, `&` chars as `<`, `>`, `&`. Decoding these in `text_object.tsx` BEFORE markdown_parser was incorrect: a literal `<@U123>` typed by a user (delivered as `<@U123>`) decoded to `<@U123>` and got tokenized as a real user mention, firing `hooks.user` and producing a chip when Slack itself would render literal `<@U123>` text. Slack's own renderer tokenizes the raw payload first and decodes entities only at render time — confirmed empirically in section/mrkdwn side-by-side. This change matches that: - Remove entity decoding from text_object.tsx. - Decode in the leaf renderers that emit user-visible text: Text, HTML, InlineCode (and its plain-code path), the fenced Code element, and Link (for the href; label children go through Text). - New tests cover escaped `<@U123>`, `<#C123>`, `` staying literal, `&` `<` `>` decoding in plain text, and entity decoding inside inline and fenced code. The `>` → "> " trailing-space hack (which existed to make blockquote detection survive entity escaping) is removed. Slack itself does not render blockquotes in section/mrkdwn, so user-typed `> quote` (delivered as `> quote`) no longer renders as a blockquote — which better matches Slack's behavior. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/fix-mrkdwn-directives.md | 2 +- .../composition_objects/text_object.tsx | 13 ++--- .../__tests__/directives.test.tsx | 50 +++++++++++++++++++ src/utils/markdown_parser/decode_entities.ts | 11 ++++ src/utils/markdown_parser/elements/code.tsx | 3 +- .../markdown_parser/sub_elements/html.tsx | 8 +-- .../sub_elements/inline_code.tsx | 6 ++- .../markdown_parser/sub_elements/link.tsx | 6 ++- .../markdown_parser/sub_elements/text.tsx | 8 +-- 9 files changed, 87 insertions(+), 20 deletions(-) create mode 100644 src/utils/markdown_parser/decode_entities.ts diff --git a/.changeset/fix-mrkdwn-directives.md b/.changeset/fix-mrkdwn-directives.md index f82016b..32d91ed 100644 --- a/.changeset/fix-mrkdwn-directives.md +++ b/.changeset/fix-mrkdwn-directives.md @@ -2,4 +2,4 @@ "slack-blocks-to-jsx": patch --- -Resolve Slack directive atoms (`<@U…>`, `<#C…>`, ``, ``, ``) in `section`/`mrkdwn` text and other mrkdwn-typed text. Directives now fire the same hooks as the `rich_text` path. `verbatim: true` matches Slack's empirical behavior — it suppresses bare-form `@here` / `@channel` / `@everyone` interpolation but is otherwise a no-op (markdown sugar, code spans, angle-bracket URLs, and structured `` directives all render the same in both modes). `&` is now decoded alongside `>` / `<` so escaped ampersands don't leak into hrefs or visible text. +Resolve Slack directive atoms (`<@U…>`, `<#C…>`, ``, ``, ``) in `section`/`mrkdwn` text and other mrkdwn-typed text. Directives now fire the same hooks as the `rich_text` path. `verbatim: true` matches Slack's empirical behavior — it suppresses bare-form `@here` / `@channel` / `@everyone` interpolation but is otherwise a no-op (markdown sugar, code spans, angle-bracket URLs, and structured `` directives all render the same in both modes). HTML entity decoding (`<`, `>`, `&`) is deferred from input pre-processing to leaf renderers, so escaped sequences like `<@U123>` (user typed `<@U123>` literally) stay literal instead of being incorrectly resolved as a user mention — matching Slack's renderer. diff --git a/src/components/composition_objects/text_object.tsx b/src/components/composition_objects/text_object.tsx index 59f1a61..94e429d 100644 --- a/src/components/composition_objects/text_object.tsx +++ b/src/components/composition_objects/text_object.tsx @@ -12,22 +12,19 @@ export const TextObject = (props: TextObjectProps) => { const { className = "" } = props; const { channels, users, hooks } = useGlobalData(); - // Order matters: decode `>` and `<` before `&` so a literal escaped `>` - // (which arrives as `&gt;`) doesn't get double-decoded. - // The `>` → `"> "` trailing space is intentional — it keeps blockquote line detection - // (`^>` in parser.tsx) working when Slack escapes the `>` of a quote line. - const parsed = text.replace(/>/g, "> ").replace(/</g, "<").replace(/&/g, "&"); - + // HTML entity decoding is deferred to the leaf renderers (Text, HTML, InlineCode, Code, + // Link) so escaped sequences like `<@U123>` (user typed `<@U123>` literally) don't + // get tokenized as directives. See decode_entities.ts. if (type === "plain_text") return (
- {markdown_parser(parsed, { markdown: false, verbatim, users, channels, hooks })} + {markdown_parser(text, { markdown: false, verbatim, users, channels, hooks })}
); return (
- {markdown_parser(parsed, { markdown: true, verbatim, users, channels, hooks })} + {markdown_parser(text, { markdown: true, verbatim, users, channels, hooks })}
); }; diff --git a/src/utils/markdown_parser/__tests__/directives.test.tsx b/src/utils/markdown_parser/__tests__/directives.test.tsx index 614c9b3..263545c 100644 --- a/src/utils/markdown_parser/__tests__/directives.test.tsx +++ b/src/utils/markdown_parser/__tests__/directives.test.tsx @@ -201,6 +201,56 @@ describe("escaped entities", () => { expect(anchor!.getAttribute("href")).toBe("https://example.test/?a=1&b=2"); expect(anchor!.textContent).toBe("report"); }); + + // Slack escapes literal `<`, `>`, `&` in user-typed text. If the user typed `<@U123>` + // literally (not as a directive), Slack delivers `<@U123>` in the payload. Our + // renderer must keep this as literal text — NOT resolve it as a user mention. Slack's + // own renderer behaves the same way (empirically verified — the "escaped" row in the PR + // description's side-by-side stays as literal `<@U123>`). + it("does NOT resolve a user-typed escaped directive as a mention", () => { + const user = vi.fn(); + const { container } = renderMrkdwn(`escaped: <@U123>`, false, { + hooks: { user }, + data: { users: [{ id: "U123", name: "alice" }] }, + }); + expect(user).not.toHaveBeenCalled(); + expect(container.textContent).toContain("<@U123>"); + }); + + it("does NOT resolve a user-typed escaped channel mention", () => { + const channel = vi.fn(); + const { container } = renderMrkdwn(`escaped: <#C123>`, false, { + hooks: { channel }, + data: { channels: [{ id: "C123", name: "general" }] }, + }); + expect(channel).not.toHaveBeenCalled(); + expect(container.textContent).toContain("<#C123>"); + }); + + it("does NOT resolve a user-typed escaped broadcast", () => { + const atHere = vi.fn(); + const { container } = renderMrkdwn(`escaped: <!here>`, false, { + hooks: { atHere }, + }); + expect(atHere).not.toHaveBeenCalled(); + expect(container.textContent).toContain(""); + }); + + it("decodes escaped entities in plain text rendering", () => { + const { container } = renderMrkdwn(`a & b < c > d`, false); + expect(container.textContent).toContain("a & b < c > d"); + }); + + it("decodes escaped entities inside inline code", () => { + const { container } = renderMrkdwn("`<script>alert(1)</script>`", false); + expect(container.querySelector("code")?.textContent).toBe(""); + }); + + it("decodes escaped entities inside fenced code", () => { + const { container } = renderMrkdwn("```\n<script>\n```", false); + const code = container.querySelector("code"); + expect(code?.textContent).toContain("