diff --git a/.gitignore b/.gitignore index 7daf139f23..43dbe89224 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,12 @@ package-lock.json /dist *.tsbuildinfo +# --------------------------------------------------------------------------- +# Storybook +# --------------------------------------------------------------------------- +storybook-static/ +*.log.json + # --------------------------------------------------------------------------- # Python # --------------------------------------------------------------------------- diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 438fa30ad0..f4c0f4bdb0 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -5,6 +5,7 @@ "charliermarsh.ruff", "ms-vscode.powershell", "ms-python.python", - "ms-vscode.vscode-typescript-next" + "ms-vscode.vscode-typescript-next", + "highagency.pencildev" ] } diff --git a/bun.lock b/bun.lock index 6d6e6022d8..e9a617cdd1 100644 --- a/bun.lock +++ b/bun.lock @@ -103,10 +103,26 @@ "i18next": "26.0.4", "i18next-icu": "2.4.3", "lucide-react": "1.8.0", + "mermaid": "11.14.0", + "micromark": "4.0.2", + "micromark-core-commonmark": "2.0.3", + "micromark-util-classify-character": "2.0.1", + "micromark-util-types": "2.0.2", "react-i18next": "17.0.2", + "react-markdown": "10.1.0", + "rehype-raw": "7.0.0", + "rehype-sanitize": "6.0.0", + "remark-gfm": "4.0.1", + "shiki": "3.2.0", "tailwind-merge": "3.5.0", + "unist-util-visit": "5.1.0", + }, + "devDependencies": { + "@types/hast": "3.0.4", }, "peerDependencies": { + "react": "*", + "react-dom": "*", "vitest": "*", }, "optionalPeers": [ @@ -122,12 +138,7 @@ "@tanstack/react-router": "1.168.10", "framer-motion": "12.38.0", "lucide-react": "1.8.0", - "mermaid": "11.14.0", "minisearch": "7.2.0", - "react-markdown": "10.1.0", - "rehype-raw": "7.0.0", - "remark-gfm": "4.0.1", - "shiki": "3.2.0", }, "peerDependencies": { "react": "*", @@ -232,10 +243,6 @@ "lucide-react": "1.8.0", "mammoth": "1.12.0", "mermaid": "11.14.0", - "micromark": "4.0.2", - "micromark-core-commonmark": "2.0.3", - "micromark-util-classify-character": "2.0.1", - "micromark-util-types": "2.0.2", "mssql": "12.2.1", "mustache": "4.2.0", "mysql2": "3.21.1", @@ -255,7 +262,6 @@ "rehype-sanitize": "6.0.0", "remark-gfm": "4.0.1", "safe-regex2": "5.1.1", - "shiki": "4.0.2", "striptags": "3.2.0", "sucrase": "3.35.1", "swagger-ui-react": "5.32.2", @@ -1390,19 +1396,17 @@ "@sentry/vite-plugin": ["@sentry/vite-plugin@5.2.0", "", { "dependencies": { "@sentry/bundler-plugin-core": "5.2.0", "@sentry/rollup-plugin": "5.2.0" } }, "sha512-4Jo3ixBspso5HY81PDvZdRXkH9wYGVmcw/0a2IX9ejbyKBdHqkYg4IhAtNqGUAyGuHwwRS9Y1S+sCMvrXv6htw=="], - "@shikijs/core": ["@shikijs/core@4.0.2", "", { "dependencies": { "@shikijs/primitive": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw=="], - - "@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag=="], + "@shikijs/core": ["@shikijs/core@3.2.0", "", { "dependencies": { "@shikijs/types": "3.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-+5dPz8q6HgNqfQ28ycm/vA8dIVd2lNFOUqVRFCQLbs0KZ6emYI+1apLpX+wuL/aDSPLOkMgARwNjkA5UjGKS1Q=="], - "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg=="], + "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.2.0", "", { "dependencies": { "@shikijs/types": "3.2.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.1.0" } }, "sha512-1WrYfaz5YT5aTAIMbYQhxlSHc8ArX+hCDNAIdKRqJHzfWQ3xDgh3PTvrAly+RWGuvi5Q4NlvPlTBdlSAXN6Stg=="], - "@shikijs/langs": ["@shikijs/langs@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg=="], + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.2.0", "", { "dependencies": { "@shikijs/types": "3.2.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-3V7ko+YUAP02I4rUbDjCgvyM/H85hUIZBQAS19FjDcJMKL5SbjWTiG7TRKxX1V4ddxLxt2RO64wZinElp/3ngQ=="], - "@shikijs/primitive": ["@shikijs/primitive@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw=="], + "@shikijs/langs": ["@shikijs/langs@3.2.0", "", { "dependencies": { "@shikijs/types": "3.2.0" } }, "sha512-Qze5YIsp223AmC69VZDQolcrcYPrVa9wV6cW2kVqsDrSWlwhW2EQZEn1Iw2oQU1tGYVg8Hj/xdp8mOv+9zI0vg=="], - "@shikijs/themes": ["@shikijs/themes@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA=="], + "@shikijs/themes": ["@shikijs/themes@3.2.0", "", { "dependencies": { "@shikijs/types": "3.2.0" } }, "sha512-XfzMSTu6iMl2FZIwKykld2OzFKDDlm4KbZrzW6sbKXEeJ1xq61HX4x4bE4+REBFqbbrvAQM8EAH11m/E3cxYDg=="], - "@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="], + "@shikijs/types": ["@shikijs/types@3.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-1uOwfEO0vV+G8n/AO/6Yth7zshNdXvQ1pc4ygTrfE3cyuzVLukrZq72YkFUlsRijam7LvRTvnqL4aT5wx1X2Vw=="], "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], @@ -3444,7 +3448,7 @@ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - "shiki": ["shiki@4.0.2", "", { "dependencies": { "@shikijs/core": "4.0.2", "@shikijs/engine-javascript": "4.0.2", "@shikijs/engine-oniguruma": "4.0.2", "@shikijs/langs": "4.0.2", "@shikijs/themes": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ=="], + "shiki": ["shiki@3.2.0", "", { "dependencies": { "@shikijs/core": "3.2.0", "@shikijs/engine-javascript": "3.2.0", "@shikijs/engine-oniguruma": "3.2.0", "@shikijs/langs": "3.2.0", "@shikijs/themes": "3.2.0", "@shikijs/types": "3.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lOF6wkvZCRVQrdfGilyXclTKIjCWKujPAjD6fddLwtQ6eSmgj43pFDbjUmxivtElDRlsGO8G2dLeeRpwNY4wWg=="], "short-unique-id": ["short-unique-id@5.3.2", "", { "bin": { "short-unique-id": "bin/short-unique-id", "suid": "bin/short-unique-id" } }, "sha512-KRT/hufMSxXKEDSQujfVE0Faa/kZ51ihUcZQAcmP04t00DvPj7Ox5anHke1sJYUtzSuiT/Y5uyzg/W7bBEGhCg=="], @@ -3918,8 +3922,6 @@ "@tailwindcss/postcss/postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], - "@tale/webui/shiki": ["shiki@3.2.0", "", { "dependencies": { "@shikijs/core": "3.2.0", "@shikijs/engine-javascript": "3.2.0", "@shikijs/engine-oniguruma": "3.2.0", "@shikijs/langs": "3.2.0", "@shikijs/themes": "3.2.0", "@shikijs/types": "3.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lOF6wkvZCRVQrdfGilyXclTKIjCWKujPAjD6fddLwtQ6eSmgj43pFDbjUmxivtElDRlsGO8G2dLeeRpwNY4wWg=="], - "@tanstack/router-generator/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@tanstack/router-plugin/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], @@ -4168,18 +4170,6 @@ "@sentry/bundler-plugin-core/glob/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], - "@tale/webui/shiki/@shikijs/core": ["@shikijs/core@3.2.0", "", { "dependencies": { "@shikijs/types": "3.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-+5dPz8q6HgNqfQ28ycm/vA8dIVd2lNFOUqVRFCQLbs0KZ6emYI+1apLpX+wuL/aDSPLOkMgARwNjkA5UjGKS1Q=="], - - "@tale/webui/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.2.0", "", { "dependencies": { "@shikijs/types": "3.2.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.1.0" } }, "sha512-1WrYfaz5YT5aTAIMbYQhxlSHc8ArX+hCDNAIdKRqJHzfWQ3xDgh3PTvrAly+RWGuvi5Q4NlvPlTBdlSAXN6Stg=="], - - "@tale/webui/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.2.0", "", { "dependencies": { "@shikijs/types": "3.2.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-3V7ko+YUAP02I4rUbDjCgvyM/H85hUIZBQAS19FjDcJMKL5SbjWTiG7TRKxX1V4ddxLxt2RO64wZinElp/3ngQ=="], - - "@tale/webui/shiki/@shikijs/langs": ["@shikijs/langs@3.2.0", "", { "dependencies": { "@shikijs/types": "3.2.0" } }, "sha512-Qze5YIsp223AmC69VZDQolcrcYPrVa9wV6cW2kVqsDrSWlwhW2EQZEn1Iw2oQU1tGYVg8Hj/xdp8mOv+9zI0vg=="], - - "@tale/webui/shiki/@shikijs/themes": ["@shikijs/themes@3.2.0", "", { "dependencies": { "@shikijs/types": "3.2.0" } }, "sha512-XfzMSTu6iMl2FZIwKykld2OzFKDDlm4KbZrzW6sbKXEeJ1xq61HX4x4bE4+REBFqbbrvAQM8EAH11m/E3cxYDg=="], - - "@tale/webui/shiki/@shikijs/types": ["@shikijs/types@3.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-1uOwfEO0vV+G8n/AO/6Yth7zshNdXvQ1pc4ygTrfE3cyuzVLukrZq72YkFUlsRijam7LvRTvnqL4aT5wx1X2Vw=="], - "@tanstack/router-plugin/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], "@types/pg-pool/@types/pg/pg-protocol": ["pg-protocol@1.12.0", "", {}, "sha512-uOANXNRACNdElMXJ0tPz6RBM0XQ61nONGAwlt8da5zs/iUOOCLBQOHSXnrC6fMsvtjxbOJrZZl5IScGv+7mpbg=="], diff --git a/package.json b/package.json index dc056c5721..40de813df0 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,10 @@ "docker:test:image": "bash tests/container-image-test.sh", "docker:test:vulnerability": "bash tests/container-vulnerability-scan.sh", "commit": "bunx cz", - "storybook": "bun run --filter @tale/platform storybook", + "storybook": "bunx turbo run storybook", + "storybook:ui": "bun run --filter @tale/ui storybook", + "storybook:webui": "bun run --filter @tale/webui storybook", + "storybook:platform": "bun run --filter @tale/platform storybook", "gen": "bunx --bun plop --plopfile plopfile.ts", "gen:react-service": "bun run gen react-service", "gen:react-package": "bun run gen react-package", diff --git a/packages/ui/.oxlintrc.json b/packages/ui/.oxlintrc.json index d38bbcf5c7..904265ea9c 100644 --- a/packages/ui/.oxlintrc.json +++ b/packages/ui/.oxlintrc.json @@ -1,4 +1,13 @@ { "$schema": "../../node_modules/oxlint/configuration_schema.json", - "extends": ["../../.oxlintrc.json"] + "extends": ["../../.oxlintrc.json"], + "overrides": [ + { + "files": ["src/markdown/**/*.{ts,tsx}"], + "rules": { + "typescript/no-unsafe-type-assertion": "off", + "typescript/consistent-return": "off" + } + } + ] } diff --git a/packages/ui/.storybook/preview.tsx b/packages/ui/.storybook/preview.tsx index 9a3280236c..e496241989 100644 --- a/packages/ui/.storybook/preview.tsx +++ b/packages/ui/.storybook/preview.tsx @@ -1,7 +1,41 @@ import { withThemeByClassName } from '@storybook/addon-themes'; import type { Preview } from '@storybook/react'; +import { useMemo } from 'react'; +import type { DecoratorFunction } from 'storybook/internal/types'; + +import { ThemeContext } from '../src/theme'; import '../src/globals.css'; +import '../src/markdown/globals.css'; + +/** + * Bridge addon-themes' html-class toggle into the React `ThemeContext` so + * components reading `useTheme()` (CodeBlock, Mermaid, etc.) see the same + * state Storybook shows. Without this, `resolvedTheme` is pinned to + * `'light'` even when the iframe has `.dark` on ``. + */ +function WithTheme({ + Story, + context, +}: { + Story: Parameters[0]; + context: Parameters[1]; +}) { + const resolvedTheme = context.globals.theme === 'dark' ? 'dark' : 'light'; + const value = useMemo( + () => ({ + theme: resolvedTheme, + resolvedTheme, + setTheme: () => {}, + }), + [resolvedTheme], + ); + return ( + + + + ); +} const preview: Preview = { parameters: { @@ -22,6 +56,7 @@ const preview: Preview = { }, }, decorators: [ + (Story, context) => , withThemeByClassName({ themes: { light: '', dark: 'dark' }, defaultTheme: 'light', diff --git a/packages/ui/package.json b/packages/ui/package.json index 5acdadc32f..2d62380af3 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -2,7 +2,7 @@ "name": "@tale/ui", "version": "0.1.0", "private": true, - "description": "Shared Tale React component library", + "description": "Shared Tale React component library — primitives, theming, i18n + the markdown rendering pipeline (components, plugins, streaming).", "type": "module", "exports": { ".": "./src/index.ts", @@ -37,6 +37,30 @@ "./cn": "./src/lib/cn.ts", "./format": "./src/lib/format.ts", "./i18n-tests": "./src/i18n-tests/index.ts", + "./use-resize-observer": "./src/hooks/use-resize-observer.ts", + "./markdown": "./src/markdown/markdown.tsx", + "./markdown/routed-markdown": "./src/markdown/routed-markdown.tsx", + "./markdown/shiki": "./src/markdown/shiki.ts", + "./markdown/code-block": "./src/markdown/code-block.tsx", + "./markdown/anchored-heading": "./src/markdown/anchored-heading.tsx", + "./markdown/extract-toc": "./src/markdown/extract-toc.ts", + "./markdown/reading-time": "./src/markdown/reading-time.ts", + "./markdown/components/callout": "./src/markdown/components/callout.tsx", + "./markdown/components/code-group": "./src/markdown/components/code-group.tsx", + "./markdown/components/tabs": "./src/markdown/components/tabs.tsx", + "./markdown/components/steps": "./src/markdown/components/steps.tsx", + "./markdown/components/cards": "./src/markdown/components/cards.tsx", + "./markdown/components/frame": "./src/markdown/components/frame.tsx", + "./markdown/components/accordion": "./src/markdown/components/accordion.tsx", + "./markdown/components/mermaid": "./src/markdown/components/mermaid.tsx", + "./markdown/components/registry": "./src/markdown/components/registry.tsx", + "./markdown/streaming/incremental-markdown": "./src/markdown/streaming/incremental-markdown.tsx", + "./markdown/streaming/remend-markdown": "./src/markdown/streaming/remend-markdown.ts", + "./markdown/streaming/normalize-html-blocks": "./src/markdown/streaming/normalize-html-blocks.ts", + "./markdown/streaming/find-block-split": "./src/markdown/streaming/find-block-split.ts", + "./markdown/plugins/micromark-cjk-attention": "./src/markdown/plugins/micromark-cjk-attention.ts", + "./markdown/types": "./src/markdown/types.ts", + "./markdown/globals.css": "./src/markdown/globals.css", "./globals.css": "./src/globals.css", "./tailwind-preset": "./tailwind-preset.ts" }, @@ -59,10 +83,26 @@ "i18next": "26.0.4", "i18next-icu": "2.4.3", "lucide-react": "1.8.0", + "mermaid": "11.14.0", + "micromark": "4.0.2", + "micromark-core-commonmark": "2.0.3", + "micromark-util-classify-character": "2.0.1", + "micromark-util-types": "2.0.2", "react-i18next": "17.0.2", - "tailwind-merge": "3.5.0" + "react-markdown": "10.1.0", + "rehype-raw": "7.0.0", + "rehype-sanitize": "6.0.0", + "remark-gfm": "4.0.1", + "shiki": "3.2.0", + "tailwind-merge": "3.5.0", + "unist-util-visit": "5.1.0" + }, + "devDependencies": { + "@types/hast": "3.0.4" }, "peerDependencies": { + "react": "*", + "react-dom": "*", "vitest": "*" }, "peerDependenciesMeta": { diff --git a/packages/ui/postcss.config.mjs b/packages/ui/postcss.config.mjs new file mode 100644 index 0000000000..5d6d8457f7 --- /dev/null +++ b/packages/ui/postcss.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + '@tailwindcss/postcss': {}, + }, +}; + +export default config; diff --git a/packages/ui/src/globals.css b/packages/ui/src/globals.css index 2fa44743c4..18e554a4c9 100644 --- a/packages/ui/src/globals.css +++ b/packages/ui/src/globals.css @@ -91,9 +91,12 @@ --color-bg-muted: #1a1a1a; --color-bg-overlay: rgba(3, 7, 18, 0.8); + /* Body prose lifts a notch (gray-300 vs gray-400) for legibility on dark + backgrounds. fg-subtle slides up to fill the lower-contrast slot for + captions / muted metadata. */ --color-fg-base: #f3f4f6; - --color-fg-muted: #9ca3af; - --color-fg-subtle: #6b7280; + --color-fg-muted: #d1d5db; + --color-fg-subtle: #9ca3af; --color-fg-inverse: #030712; --color-border-base: #404040; diff --git a/packages/ui/src/hooks/use-resize-observer.ts b/packages/ui/src/hooks/use-resize-observer.ts new file mode 100644 index 0000000000..7f0a21f0f1 --- /dev/null +++ b/packages/ui/src/hooks/use-resize-observer.ts @@ -0,0 +1,39 @@ +import { useEffect, useRef } from 'react'; + +type ResizeCallback = (entry: ResizeObserverEntry, target: Element) => void; + +/** + * Subscribe a stable callback to ResizeObserver entries on the given element. + * + * The callback is stored in a ref so it can change every render without + * tearing down the observer; only `target` toggles re-create the observer. + * Pass `null`/`undefined` to disable. The callback fires once when the + * observer first attaches (per the ResizeObserver spec) — useful for + * initial-fit logic that needs the post-layout size. + * + * Example: + * ```ts + * const ref = useRef(null); + * useResizeObserver(ref.current, (entry) => { + * setSize({ w: entry.contentRect.width, h: entry.contentRect.height }); + * }); + * ``` + */ +export function useResizeObserver( + target: T | null | undefined, + callback: ResizeCallback, +): void { + const callbackRef = useRef(callback); + callbackRef.current = callback; + + useEffect(() => { + if (!target) return undefined; + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + callbackRef.current(entry, entry.target); + } + }); + observer.observe(target); + return () => observer.disconnect(); + }, [target]); +} diff --git a/packages/ui/src/markdown/anchored-heading.stories.tsx b/packages/ui/src/markdown/anchored-heading.stories.tsx new file mode 100644 index 0000000000..635ac1acfe --- /dev/null +++ b/packages/ui/src/markdown/anchored-heading.stories.tsx @@ -0,0 +1,54 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { AnchoredHeading } from './anchored-heading'; + +const meta = { + title: 'markdown/AnchoredHeading', + component: AnchoredHeading, + tags: ['autodocs'], + parameters: { layout: 'padded' }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const H1: Story = { + args: { + level: 'h1', + className: 'text-fg-base text-3xl font-semibold', + children: 'Heading 1 — anchor on hover', + }, +}; + +export const H2: Story = { + args: { + level: 'h2', + className: 'text-fg-base text-2xl font-semibold', + children: 'Heading 2 — anchor on hover', + }, +}; + +export const H3: Story = { + args: { + level: 'h3', + className: 'text-fg-base text-lg font-semibold', + children: 'Heading 3 — anchor on hover', + }, +}; + +export const H4: Story = { + args: { + level: 'h4', + className: 'text-fg-base text-base font-semibold', + children: 'Heading 4 — anchor on hover', + }, +}; + +export const LongTitle: Story = { + args: { + level: 'h2', + className: 'text-fg-base max-w-prose text-2xl font-semibold', + children: + 'A wrapped, multi-line heading that still slugifies the full text correctly', + }, +}; diff --git a/packages/webui/src/markdown/anchored-heading.tsx b/packages/ui/src/markdown/anchored-heading.tsx similarity index 98% rename from packages/webui/src/markdown/anchored-heading.tsx rename to packages/ui/src/markdown/anchored-heading.tsx index 354f89b62d..71956e60a4 100644 --- a/packages/webui/src/markdown/anchored-heading.tsx +++ b/packages/ui/src/markdown/anchored-heading.tsx @@ -138,7 +138,7 @@ export function AnchoredHeading({ // matching the Mintlify pattern. `print:hidden` keeps printed pages // free of the affordance. className={`text-fg-muted hover:text-fg-base ml-2 inline-flex size-6 items-center justify-center rounded-md align-middle opacity-0 transition-opacity group-hover:opacity-100 focus:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-current/20 print:hidden ${ - copied ? 'text-emerald-600 opacity-100' : '' + copied ? 'text-emerald-600 opacity-100 dark:text-emerald-400' : '' }`} > {copied ? ( diff --git a/packages/ui/src/markdown/code-block.stories.tsx b/packages/ui/src/markdown/code-block.stories.tsx new file mode 100644 index 0000000000..304d59d977 --- /dev/null +++ b/packages/ui/src/markdown/code-block.stories.tsx @@ -0,0 +1,114 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { CodeBlock } from './code-block'; + +const meta = { + title: 'markdown/CodeBlock', + component: CodeBlock, + tags: ['autodocs'], + parameters: { layout: 'padded' }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const TS_SAMPLE = `import { highlightCode } from '@tale/ui/markdown/shiki'; + +const result = await highlightCode( + 'console.log("hello tale");', + 'typescript', + 'light', +); +`; + +const BASH_SAMPLE = `# install dependencies +bun install + +# start the docs site on :3002 +bun run --filter @tale/docs dev +`; + +export const TypeScript: Story = { + args: { + code: TS_SAMPLE, + language: 'typescript', + filename: 'highlight.ts', + }, +}; + +export const Bash: Story = { + args: { + code: BASH_SAMPLE, + language: 'bash', + filename: 'README.md · quickstart', + }, +}; + +export const NoCopy: Story = { + args: { + code: TS_SAMPLE, + language: 'typescript', + hideCopy: true, + }, +}; + +const DIFF_SAMPLE = `diff --git a/migration.sql b/migration.sql +@@ -10,7 +10,7 @@ + ALTER TABLE customers +- ADD COLUMN email TEXT NOT NULL; ++ ADD COLUMN email TEXT NOT NULL DEFAULT ''; + COMMIT; +`; + +export const Diff: Story = { + args: { + code: DIFF_SAMPLE, + language: 'diff', + filename: 'migration.diff', + }, +}; + +const LONG_SAMPLE = `function highlight(code, language) { + // Hover any line to see the row-tint effect. + if (!code) return ''; + const tokens = tokenize(code, language); + return tokens + .map((token) => renderToken(token)) + .join(''); +} + +function tokenize(code, language) { + return []; +} + +function renderToken(token) { + return token.value; +} +`; + +export const RowHover: Story = { + args: { + code: LONG_SAMPLE, + language: 'javascript', + filename: 'highlight.js — hover the lines', + }, +}; + +const TRAILING_NEWLINE_SAMPLE = `# Five lines, exact line numbers +docker pull ghcr.io/tale-project/tale/tale-platform:1.2.0 + +# Latest release +docker pull ghcr.io/tale-project/tale/tale-platform:latest +`; + +// Regression story for the line-number bug: trailing newlines used to +// produce a Shiki line that the gutter never numbered, so the last visible +// row sat without an index. The screenshot in #1691-issue captures the +// exact state. Both lines and numbers must stay in lockstep. +export const LineNumbersWithTrailingNewline: Story = { + args: { + code: TRAILING_NEWLINE_SAMPLE, + language: 'bash', + filename: 'docker-pull.sh', + }, +}; diff --git a/packages/ui/src/markdown/code-block.tsx b/packages/ui/src/markdown/code-block.tsx new file mode 100644 index 0000000000..7efd212fca --- /dev/null +++ b/packages/ui/src/markdown/code-block.tsx @@ -0,0 +1,121 @@ +import { Check, Copy } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; + +import { cn } from '../lib/cn'; +import { HighlightedCode } from './highlighted-code'; + +interface CodeBlockProps { + code: string; + language?: string; + /** Optional filename label rendered above the block. */ + filename?: string; + /** Hide the copy button (e.g. inside CodeGroup tabs). */ + hideCopy?: boolean; + /** Hide the header bar (filename + copy) entirely. Useful when an outer + * wrapper (CodeGroup, custom card) provides its own chrome. */ + hideHeader?: boolean; + /** Force-show or hide the line-number gutter. Defaults to auto (lines > 3). + * Set to `true` for streaming surfaces so the gutter never appears + * mid-stream (which would shift the content rightward). */ + showLineNumbers?: boolean; + className?: string; +} + +/** + * Highlighted, copy-friendly code block. Rendered as a bordered card with + * an optional header bar (filename + copy button) on top and the + * highlighted source body — the same body the CodeGroup panels render so + * the two surfaces look identical. + * + * Falls back to a plain `
` while Shiki loads in the background so
+ * the layout never shifts.
+ */
+export function CodeBlock({
+  code,
+  language,
+  filename,
+  hideCopy,
+  hideHeader,
+  showLineNumbers,
+  className,
+}: CodeBlockProps) {
+  const headerLabel = filename ?? language ?? '';
+  return (
+    
+ {hideHeader ? null : ( + + )} + +
+ ); +} + +interface CodeBlockHeaderProps { + label: string; + hideCopy?: boolean; + code: string; +} + +function CodeBlockHeader({ label, hideCopy, code }: CodeBlockHeaderProps) { + return ( +
+ {label} + {hideCopy ? null : } +
+ ); +} + +function HeaderCopyButton({ code }: { code: string }) { + const [copied, setCopied] = useState(false); + const timerRef = useRef | null>(null); + + // Cancel any in-flight reset timer on unmount so we never call + // `setCopied(false)` after the component is gone. + useEffect(() => { + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, []); + + const handleCopy = async () => { + const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code; + try { + await navigator.clipboard.writeText(trimmed); + setCopied(true); + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => setCopied(false), 1500); + } catch (error) { + console.warn('[code-block] clipboard write failed', error); + } + }; + + return ( + + ); +} diff --git a/packages/webui/src/markdown/components/accordion.stories.tsx b/packages/ui/src/markdown/components/accordion.stories.tsx similarity index 98% rename from packages/webui/src/markdown/components/accordion.stories.tsx rename to packages/ui/src/markdown/components/accordion.stories.tsx index 4ff163b032..9bb5dcbefb 100644 --- a/packages/webui/src/markdown/components/accordion.stories.tsx +++ b/packages/ui/src/markdown/components/accordion.stories.tsx @@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/react'; import { Accordion, AccordionGroup } from './accordion'; const meta = { - title: 'webui/markdown/Accordion', + title: 'markdown/Accordion', component: Accordion, tags: ['autodocs'], parameters: { layout: 'padded' }, diff --git a/packages/webui/src/markdown/components/accordion.tsx b/packages/ui/src/markdown/components/accordion.tsx similarity index 98% rename from packages/webui/src/markdown/components/accordion.tsx rename to packages/ui/src/markdown/components/accordion.tsx index b349169c83..33ce486613 100644 --- a/packages/webui/src/markdown/components/accordion.tsx +++ b/packages/ui/src/markdown/components/accordion.tsx @@ -1,4 +1,3 @@ -import { cn } from '@tale/ui/cn'; import { ChevronDown } from 'lucide-react'; import type { ReactNode } from 'react'; import { @@ -12,6 +11,8 @@ import { useState, } from 'react'; +import { cn } from '../../lib/cn'; + interface AccordionGroupContextValue { openId: string | null; setOpenId: (id: string | null) => void; diff --git a/packages/webui/src/markdown/components/callout.stories.tsx b/packages/ui/src/markdown/components/callout.stories.tsx similarity index 98% rename from packages/webui/src/markdown/components/callout.stories.tsx rename to packages/ui/src/markdown/components/callout.stories.tsx index 0288cc290d..a2c066330b 100644 --- a/packages/webui/src/markdown/components/callout.stories.tsx +++ b/packages/ui/src/markdown/components/callout.stories.tsx @@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/react'; import { Callout } from './callout'; const meta = { - title: 'webui/markdown/Callout', + title: 'markdown/Callout', component: Callout, tags: ['autodocs'], argTypes: { diff --git a/packages/webui/src/markdown/components/callout.tsx b/packages/ui/src/markdown/components/callout.tsx similarity index 98% rename from packages/webui/src/markdown/components/callout.tsx rename to packages/ui/src/markdown/components/callout.tsx index ce3a4eae47..5b42d6d1fc 100644 --- a/packages/webui/src/markdown/components/callout.tsx +++ b/packages/ui/src/markdown/components/callout.tsx @@ -1,4 +1,3 @@ -import { cn } from '@tale/ui/cn'; import { AlertTriangle, CheckCircle2, @@ -10,6 +9,8 @@ import { } from 'lucide-react'; import type { ReactNode } from 'react'; +import { cn } from '../../lib/cn'; + type Tone = 'note' | 'tip' | 'info' | 'warning' | 'danger' | 'check'; const TONE_CONFIG: Record< diff --git a/packages/webui/src/markdown/components/cards.stories.tsx b/packages/ui/src/markdown/components/cards.stories.tsx similarity index 98% rename from packages/webui/src/markdown/components/cards.stories.tsx rename to packages/ui/src/markdown/components/cards.stories.tsx index 786de9b778..031baca58a 100644 --- a/packages/webui/src/markdown/components/cards.stories.tsx +++ b/packages/ui/src/markdown/components/cards.stories.tsx @@ -20,7 +20,7 @@ function withRouter(children: ReactNode) { } const meta = { - title: 'webui/markdown/CardGroup', + title: 'markdown/CardGroup', component: CardGroup, tags: ['autodocs'], parameters: { layout: 'padded' }, diff --git a/packages/webui/src/markdown/components/cards.tsx b/packages/ui/src/markdown/components/cards.tsx similarity index 88% rename from packages/webui/src/markdown/components/cards.tsx rename to packages/ui/src/markdown/components/cards.tsx index a932e250bb..ee5dd4b1f2 100644 --- a/packages/webui/src/markdown/components/cards.tsx +++ b/packages/ui/src/markdown/components/cards.tsx @@ -1,9 +1,12 @@ -import { cn } from '@tale/ui/cn'; import { Link } from '@tanstack/react-router'; import { ArrowUpRight } from 'lucide-react'; -import { DynamicIcon, type IconName } from 'lucide-react/dynamic'; +import { DynamicIcon, iconNames, type IconName } from 'lucide-react/dynamic'; import { type ReactNode, isValidElement } from 'react'; +import { cn } from '../../lib/cn'; + +const KNOWN_ICON_NAMES = new Set(iconNames); + interface CardProps { title?: string; /** @@ -20,6 +23,10 @@ interface CardProps { function renderIcon(icon: ReactNode | string | undefined): ReactNode { if (icon == null || icon === '') return null; if (typeof icon === 'string') { + // Validate against the known Lucide icon set so an unknown name renders + // *nothing* (no DOM, no flex gap, no broken-icon placeholder) rather + // than an empty `` shell. + if (!KNOWN_ICON_NAMES.has(icon)) return null; return ; } if (isValidElement(icon)) return icon; diff --git a/packages/webui/src/markdown/components/code-group.stories.tsx b/packages/ui/src/markdown/components/code-group.stories.tsx similarity index 98% rename from packages/webui/src/markdown/components/code-group.stories.tsx rename to packages/ui/src/markdown/components/code-group.stories.tsx index 0ded50e80c..5cb0be4d9f 100644 --- a/packages/webui/src/markdown/components/code-group.stories.tsx +++ b/packages/ui/src/markdown/components/code-group.stories.tsx @@ -4,7 +4,7 @@ import { CodeBlock } from '../code-block'; import { CodeGroup } from './code-group'; const meta = { - title: 'webui/markdown/CodeGroup', + title: 'markdown/CodeGroup', component: CodeGroup, tags: ['autodocs'], parameters: { layout: 'padded' }, diff --git a/packages/webui/src/markdown/components/code-group.tsx b/packages/ui/src/markdown/components/code-group.tsx similarity index 58% rename from packages/webui/src/markdown/components/code-group.tsx rename to packages/ui/src/markdown/components/code-group.tsx index ed0e0bf0bd..7a6816e63d 100644 --- a/packages/webui/src/markdown/components/code-group.tsx +++ b/packages/ui/src/markdown/components/code-group.tsx @@ -1,4 +1,3 @@ -import { cn } from '@tale/ui/cn'; import { Children, isValidElement, @@ -10,28 +9,39 @@ import { useState, } from 'react'; +import { cn } from '../../lib/cn'; +import { HighlightedCode } from '../highlighted-code'; + interface CodeGroupChildProps { - /** Mintlify uses ` ```lang ` fences inside ; the title is set via `filename` or the lang label. */ + /** Code source. Direct prop on a `` child. */ + code?: string; + /** Optional filename label rendered as the tab title. */ filename?: string; - /** Direct prop when authors use inside . */ + /** Optional language identifier (Shiki + tab fallback label). */ language?: string; + /** ` ```lang ` fence syntax sugar — preserved for Mintlify-style nesting. */ className?: string; + /** When `code` isn't passed directly, fall back to children text. */ children?: ReactNode; } interface CodeGroupProps { children?: ReactNode; + className?: string; } /** - * Mintlify-compatible ``: accepts code-block children and - * surfaces them as tabs labelled by their filename / language. Falls back - * to a single block when only one child is given. + * Mintlify-compatible `` — the surrounding chrome is owned by + * CodeGroup itself rather than nested under each `` child, so + * tabs sit flush with the code panel and the bordered card has a single + * outline. Each child contributes a tab labelled by `filename` / + * `language` and a panel rendering the highlighted source via the same + * `` body the standalone `` uses. * - * All panels stay mounted (inactive ones hidden) so switching tabs is - * instant and Shiki's already-rendered highlight survives the swap. + * All panels stay mounted (inactive ones hidden) so switching tabs + * preserves Shiki's already-rendered highlight. */ -export function CodeGroup({ children }: CodeGroupProps) { +export function CodeGroup({ children, className }: CodeGroupProps) { const items = Children.toArray(children).filter( isValidElement, ) as ReactElement[]; @@ -40,7 +50,6 @@ export function CodeGroup({ children }: CodeGroupProps) { const groupId = useId(); if (items.length === 0) return null; - if (items.length === 1) return <>{items[0]}; const focusTab = (index: number) => { const next = (index + items.length) % items.length; @@ -65,11 +74,16 @@ export function CodeGroup({ children }: CodeGroupProps) { }; return ( -
+
{items.map((child, i) => { const label = labelOf(child, i); @@ -91,10 +105,10 @@ export function CodeGroup({ children }: CodeGroupProps) { onClick={() => setActive(i)} onKeyDown={(e) => handleKeyDown(e, i)} className={cn( - 'rounded-t-md px-3 py-1.5 text-xs font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-current/30', + '-mb-px border-b-2 px-4 py-2 font-mono text-xs transition-colors focus:outline-none focus-visible:bg-bg-base/40', isActive - ? 'bg-bg-base text-fg-base' - : 'text-fg-muted hover:text-fg-base', + ? 'border-fg-base text-fg-base' + : 'text-fg-muted hover:text-fg-base border-transparent', )} > {label} @@ -102,15 +116,12 @@ export function CodeGroup({ children }: CodeGroupProps) { ); })}
- {/* - Render every panel once and toggle visibility instead of remounting. - Remounting would force the inner to re-run Shiki on each - tab switch, briefly flashing unhighlighted text. - */} {items.map((child, i) => { const isActive = i === active; const tabId = `${groupId}-tab-${i}`; const panelId = `${groupId}-panel-${i}`; + const code = extractCode(child); + const language = child.props.language ?? extractLanguage(child); return ( ); })} @@ -130,10 +141,21 @@ export function CodeGroup({ children }: CodeGroupProps) { function labelOf(child: ReactElement, i: number): string { const filename = child.props.filename; if (filename) return filename; - const language = child.props.language; + const language = child.props.language ?? extractLanguage(child); if (language) return language; + return `Tab ${i + 1}`; +} + +function extractCode(child: ReactElement): string { + if (typeof child.props.code === 'string') return child.props.code; + if (typeof child.props.children === 'string') return child.props.children; + return ''; +} + +function extractLanguage( + child: ReactElement, +): string | undefined { const className = child.props.className ?? ''; const langMatch = /language-([a-z0-9+-]+)/i.exec(className); - if (langMatch) return langMatch[1]; - return `Tab ${i + 1}`; + return langMatch?.[1]; } diff --git a/packages/webui/src/markdown/components/frame.stories.tsx b/packages/ui/src/markdown/components/frame.stories.tsx similarity index 97% rename from packages/webui/src/markdown/components/frame.stories.tsx rename to packages/ui/src/markdown/components/frame.stories.tsx index 031709531b..d08cb0afc8 100644 --- a/packages/webui/src/markdown/components/frame.stories.tsx +++ b/packages/ui/src/markdown/components/frame.stories.tsx @@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/react'; import { Frame } from './frame'; const meta = { - title: 'webui/markdown/Frame', + title: 'markdown/Frame', component: Frame, tags: ['autodocs'], parameters: { layout: 'padded' }, diff --git a/packages/ui/src/markdown/components/frame.tsx b/packages/ui/src/markdown/components/frame.tsx new file mode 100644 index 0000000000..ba30ed5996 --- /dev/null +++ b/packages/ui/src/markdown/components/frame.tsx @@ -0,0 +1,37 @@ +import type { ReactNode } from 'react'; + +import { cn } from '../../lib/cn'; + +interface FrameProps { + caption?: string; + children?: ReactNode; + className?: string; +} + +/** + * Bordered figure for screenshots / illustrations. Single border, no + * inner card-in-card chrome — the content sits directly on the bordered + * surface and the caption (if any) renders below. + * + * Embedded `` is forced to fill the frame width so authors don't + * have to size every screenshot manually. + */ +export function Frame({ caption, children, className }: FrameProps) { + return ( +
+
+ {children} +
+ {caption?.trim() ? ( +
+ {caption} +
+ ) : null} +
+ ); +} diff --git a/packages/ui/src/markdown/components/mermaid.stories.tsx b/packages/ui/src/markdown/components/mermaid.stories.tsx new file mode 100644 index 0000000000..0155d640da --- /dev/null +++ b/packages/ui/src/markdown/components/mermaid.stories.tsx @@ -0,0 +1,67 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { Mermaid } from './mermaid'; + +const meta = { + title: 'markdown/Mermaid', + component: Mermaid, + tags: ['autodocs'], + parameters: { layout: 'padded' }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const FLOWCHART = `flowchart LR + A[Browser] --> B[TanStack Start] + B --> C[Convex] + C --> D[RAG] + C --> E[Agent] +`; + +const SEQUENCE = `sequenceDiagram + participant U as User + participant A as Agent + participant K as Knowledge + U->>A: ask + A->>K: retrieve + K-->>A: context + A-->>U: answer +`; + +const STATE_DIAGRAM = `stateDiagram-v2 + [*] --> Idle + Idle --> Streaming + Streaming --> Idle + Streaming --> Error + Error --> Idle +`; + +const PIE = `pie title Cache hit ratio + "Hit" : 78 + "Miss" : 22 +`; + +const BROKEN = `flowchart TD + this is not valid mermaid syntax!! +`; + +export const Flowchart: Story = { + args: { chart: FLOWCHART }, +}; + +export const Sequence: Story = { + args: { chart: SEQUENCE }, +}; + +export const StateDiagram: Story = { + args: { chart: STATE_DIAGRAM }, +}; + +export const PieChart: Story = { + args: { chart: PIE }, +}; + +export const ParseError: Story = { + args: { chart: BROKEN }, +}; diff --git a/packages/webui/src/markdown/components/mermaid.tsx b/packages/ui/src/markdown/components/mermaid.tsx similarity index 62% rename from packages/webui/src/markdown/components/mermaid.tsx rename to packages/ui/src/markdown/components/mermaid.tsx index 5ef2573262..190804abcc 100644 --- a/packages/webui/src/markdown/components/mermaid.tsx +++ b/packages/ui/src/markdown/components/mermaid.tsx @@ -1,6 +1,4 @@ -import { cn } from '@tale/ui/cn'; -import { useTheme } from '@tale/ui/theme'; -import { Maximize2, Minus, Plus, RotateCcw } from 'lucide-react'; +import { AlertTriangle, Maximize2, Minus, Plus, RotateCcw } from 'lucide-react'; import { type PointerEvent as ReactPointerEvent, type WheelEvent as ReactWheelEvent, @@ -11,6 +9,10 @@ import { useState, } from 'react'; +import { useResizeObserver } from '../../hooks/use-resize-observer'; +import { cn } from '../../lib/cn'; +import { useTheme } from '../../theme'; + interface MermaidProps { /** The Mermaid DSL source. */ chart: string; @@ -20,6 +22,12 @@ interface MermaidProps { * light/dark re-renders the SVG with the matching palette. */ theme?: 'light' | 'dark'; + /** + * Render a placeholder skeleton instead of attempting to render the DSL. + * Used by the streaming markdown pipeline so partial mermaid blocks (no + * closing fence yet) don't flash a parse-error card every keystroke. + */ + streaming?: boolean; className?: string; } @@ -33,14 +41,24 @@ interface MermaidApi { let mermaidPromise: Promise | null = null; -/** Lazy-load mermaid the first time the component renders. */ +/** + * Lazy-load mermaid the first time the component renders. Clears the + * cached promise on rejection so a transient failure (network blip, + * dynamic-import error) doesn't permanently disable mermaid for the + * lifetime of the page. + */ function loadMermaid(): Promise { if (mermaidPromise) return mermaidPromise; - mermaidPromise = import('mermaid').then((mod) => { - const api = mod.default as MermaidApi; - api.initialize({ startOnLoad: false, securityLevel: 'strict' }); - return api; - }); + mermaidPromise = import('mermaid') + .then((mod) => { + const api = mod.default as MermaidApi; + api.initialize({ startOnLoad: false, securityLevel: 'strict' }); + return api; + }) + .catch((cause: unknown) => { + mermaidPromise = null; + throw cause; + }); return mermaidPromise; } @@ -72,7 +90,7 @@ const INITIAL_VIEWPORT: ViewportState = { zoom: 1, panX: 0, panY: 0 }; * so zooming in always anchors at the diagram's top-left and the page * itself never reflows under it. */ -export function Mermaid({ chart, theme, className }: MermaidProps) { +export function Mermaid({ chart, theme, streaming, className }: MermaidProps) { const { resolvedTheme } = useTheme(); const effectiveTheme: 'light' | 'dark' = theme ?? resolvedTheme; const [svg, setSvg] = useState(null); @@ -89,10 +107,16 @@ export function Mermaid({ chart, theme, className }: MermaidProps) { originPanX: number; originPanY: number; } | null>(null); + // Tracks whether the user has manually zoomed/panned. While false, the + // ResizeObserver below auto-refits the diagram so it stays fully visible + // when the stage resizes (fullscreen toggle, window resize). Once the + // user interacts, we leave their viewport alone. + const hasUserInteractedRef = useRef(false); const reactId = useId(); const id = `mermaid-${reactId.replace(/[:]/g, '-')}`; useEffect(() => { + if (streaming) return undefined; let cancelled = false; setError(null); setSvg(null); @@ -107,20 +131,48 @@ export function Mermaid({ chart, theme, className }: MermaidProps) { }) .then((result) => { if (cancelled) return; - // Mermaid emits `width="100%"` plus a viewBox. With no height, the - // SVG's intrinsic aspect ratio shrinks to fit the parent — wide - // flowcharts collapse into a thin strip. Pin the SVG to the viewBox - // dimensions so it renders at natural size. - const viewBox = /viewBox="0 0 ([\d.]+) ([\d.]+)"/.exec(result.svg); - let fixed = result.svg - .replace(/\swidth="[^"]*"/, '') - .replace(/\sheight="[^"]*"/, '') - .replace(/\sstyle="max-width:[^"]*"/, ''); - if (viewBox) { - const w = viewBox[1]; - const h = viewBox[2]; - fixed = fixed.replace(/` with `width="100%"` and a viewBox. + // With no height attribute the SVG collapses to its intrinsic + // aspect ratio inside the parent — wide flowcharts shrink to a + // thin strip. Pin the root to its viewBox dimensions so it + // renders at natural size; the stage layer above handles fit-zoom. + // + // Targeting only the *root* (not nested elements that may + // also carry `width`/`height` attributes) and accepting any + // viewBox origin — sequence diagrams emit `viewBox="-N -M W H"` + // with negative offsets for participant lines, so the previous + // `viewBox="0 0 ..."` regex skipped them and the SVG rendered + // with no fixed dimensions. + const fixed = result.svg.replace( + /]*)>/, + (_match, rawAttrs: string) => { + const viewBox = + /viewBox="(-?[\d.]+)\s+(-?[\d.]+)\s+([\d.]+)\s+([\d.]+)"/.exec( + rawAttrs, + ); + let cleaned = rawAttrs + .replace(/\swidth="[^"]*"/, '') + .replace(/\sheight="[^"]*"/, '') + .replace(/\sstyle="([^"]*)"/, (_m: string, styles: string) => { + const out = styles + .split(';') + .map((s) => s.trim()) + .filter( + (s) => + s && + !/^(?:max-width|max-height|width|height)\s*:/i.test(s), + ) + .join('; '); + return out ? ` style="${out}"` : ''; + }); + if (viewBox) { + const w = viewBox[3]; + const h = viewBox[4]; + cleaned = ` width="${w}" height="${h}"${cleaned}`; + } + return ``; + }, + ); setSvg(fixed); if (result.bindFunctions && containerRef.current) { result.bindFunctions(containerRef.current); @@ -135,8 +187,18 @@ export function Mermaid({ chart, theme, className }: MermaidProps) { }); return () => { cancelled = true; + // Mermaid mounts a temporary `
` to the document body + // while rendering — and on parse error leaves an SVG fragment + // (`
`) attached. Without cleanup, every revisit to a + // failing chart adds another stray fragment outside our component. + for (const stale of [id, `d${id}`]) { + const el = document.getElementById(stale); + if (el && el.parentElement === document.body) { + el.remove(); + } + } }; - }, [chart, effectiveTheme, id]); + }, [chart, effectiveTheme, id, streaming]); /** * Apply a zoom change while keeping `anchor` (a stage-relative point) @@ -159,6 +221,7 @@ export function Mermaid({ chart, theme, className }: MermaidProps) { typeof next === 'function' ? next(prev.zoom) : next, ); if (nextZoom === prev.zoom) return prev; + hasUserInteractedRef.current = true; const stage = stageRef.current; const ax = anchor?.x ?? (stage ? stage.clientWidth / 2 : 0); const ay = anchor?.y ?? (stage ? stage.clientHeight / 2 : 0); @@ -175,34 +238,54 @@ export function Mermaid({ chart, theme, className }: MermaidProps) { ); /** - * Recenter the diagram in the stage. The transform is anchored at - * `0 0`, so the SVG sits in the top-left corner by default; this offsets - * it to the middle. Used on first render and when the user clicks reset. + * Recenter the diagram in the stage at a zoom level that fits the + * full diagram inside the viewport. The transform is anchored at + * `0 0`, so the SVG sits in the top-left corner by default; this also + * offsets it to the middle. Capped at zoom 1 so small diagrams aren't + * blown up beyond their natural size. */ - const centerInStage = useCallback((zoom: number = 1): ViewportState => { + const fitInStage = useCallback((): ViewportState => { const stage = stageRef.current; const inner = containerRef.current?.querySelector('svg'); - if (!stage || !inner) return { zoom, panX: 0, panY: 0 }; + if (!stage || !inner) return INITIAL_VIEWPORT; const stageRect = stage.getBoundingClientRect(); const svgWidth = parseFloat(inner.getAttribute('width') ?? '0') || 0; const svgHeight = parseFloat(inner.getAttribute('height') ?? '0') || 0; + if (!svgWidth || !svgHeight || !stageRect.width || !stageRect.height) { + return INITIAL_VIEWPORT; + } + const fitZoom = clampZoom( + Math.min(1, stageRect.width / svgWidth, stageRect.height / svgHeight), + ); return { - zoom, - panX: (stageRect.width - svgWidth * zoom) / 2, - panY: (stageRect.height - svgHeight * zoom) / 2, + zoom: fitZoom, + panX: (stageRect.width - svgWidth * fitZoom) / 2, + panY: (stageRect.height - svgHeight * fitZoom) / 2, }; }, []); const reset = useCallback(() => { - setViewport(centerInStage(1)); - }, [centerInStage]); + setViewport(fitInStage()); + }, [fitInStage]); - // Center the diagram once the SVG has been injected so it starts in the - // middle of the stage rather than pinned at the top-left corner. + // Reset interaction tracking + run an initial fit each time the SVG + // mounts or the stage swaps between inline and fullscreen. Subsequent + // refits during the same SVG lifecycle are driven by the resize + // observer below. useEffect(() => { if (!svg) return; - setViewport(centerInStage(1)); - }, [svg, centerInStage]); + hasUserInteractedRef.current = false; + const raf = requestAnimationFrame(() => setViewport(fitInStage())); + return () => cancelAnimationFrame(raf); + }, [svg, isFullscreen, fitInStage]); + + // Refit on stage resize (browser resize, sidebar collapse, etc.) — but + // only while the user hasn't manually zoomed/panned. Their viewport + // sticks once they interact. + useResizeObserver(stageRef.current, () => { + if (hasUserInteractedRef.current) return; + setViewport(fitInStage()); + }); useEffect(() => { if (!isFullscreen) return undefined; @@ -231,6 +314,7 @@ export function Mermaid({ chart, theme, className }: MermaidProps) { originPanY: viewport.panY, }; setIsPanning(true); + hasUserInteractedRef.current = true; }; const handlePointerMove = (event: ReactPointerEvent) => { @@ -268,18 +352,60 @@ export function Mermaid({ chart, theme, className }: MermaidProps) { applyZoom((prev) => prev * (1 - event.deltaY * WHEEL_SENSITIVITY), anchor); }; + if (streaming) { + return ( +
+
+ Preparing diagram… +
+
+ ); + } + if (error) { return ( -
-        {chart}
-      
+
+ +
+
+ Diagram failed to render +
+
+ {error} +
+
+
+
+ + Show source + + Hide source + + +
+            {chart}
+          
+
+
); } diff --git a/packages/webui/src/markdown/components/registry.tsx b/packages/ui/src/markdown/components/registry.tsx similarity index 100% rename from packages/webui/src/markdown/components/registry.tsx rename to packages/ui/src/markdown/components/registry.tsx diff --git a/packages/webui/src/markdown/components/steps.stories.tsx b/packages/ui/src/markdown/components/steps.stories.tsx similarity index 98% rename from packages/webui/src/markdown/components/steps.stories.tsx rename to packages/ui/src/markdown/components/steps.stories.tsx index b7f318a9ad..48296b04b6 100644 --- a/packages/webui/src/markdown/components/steps.stories.tsx +++ b/packages/ui/src/markdown/components/steps.stories.tsx @@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/react'; import { Step, Steps } from './steps'; const meta = { - title: 'webui/markdown/Steps', + title: 'markdown/Steps', component: Steps, tags: ['autodocs'], parameters: { layout: 'padded' }, diff --git a/packages/webui/src/markdown/components/steps.tsx b/packages/ui/src/markdown/components/steps.tsx similarity index 97% rename from packages/webui/src/markdown/components/steps.tsx rename to packages/ui/src/markdown/components/steps.tsx index 2fc5c93156..ddc64dc869 100644 --- a/packages/webui/src/markdown/components/steps.tsx +++ b/packages/ui/src/markdown/components/steps.tsx @@ -1,4 +1,3 @@ -import { cn } from '@tale/ui/cn'; import { Children, isValidElement, @@ -6,6 +5,8 @@ import { type ReactNode, } from 'react'; +import { cn } from '../../lib/cn'; + interface StepProps { title?: string; children?: ReactNode; diff --git a/packages/webui/src/markdown/components/tabs.stories.tsx b/packages/ui/src/markdown/components/tabs.stories.tsx similarity index 97% rename from packages/webui/src/markdown/components/tabs.stories.tsx rename to packages/ui/src/markdown/components/tabs.stories.tsx index 17dfa89df9..fd1493a99b 100644 --- a/packages/webui/src/markdown/components/tabs.stories.tsx +++ b/packages/ui/src/markdown/components/tabs.stories.tsx @@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/react'; import { Tab, Tabs } from './tabs'; const meta = { - title: 'webui/markdown/Tabs', + title: 'markdown/Tabs', component: Tabs, tags: ['autodocs'], parameters: { diff --git a/packages/webui/src/markdown/components/tabs.tsx b/packages/ui/src/markdown/components/tabs.tsx similarity index 98% rename from packages/webui/src/markdown/components/tabs.tsx rename to packages/ui/src/markdown/components/tabs.tsx index 9eeb429059..83f25529ca 100644 --- a/packages/webui/src/markdown/components/tabs.tsx +++ b/packages/ui/src/markdown/components/tabs.tsx @@ -1,4 +1,3 @@ -import { cn } from '@tale/ui/cn'; import { Children, isValidElement, @@ -10,6 +9,8 @@ import { useState, } from 'react'; +import { cn } from '../../lib/cn'; + interface TabProps { title: string; children?: ReactNode; diff --git a/packages/webui/src/markdown/extract-toc.test.ts b/packages/ui/src/markdown/extract-toc.test.ts similarity index 100% rename from packages/webui/src/markdown/extract-toc.test.ts rename to packages/ui/src/markdown/extract-toc.test.ts diff --git a/packages/webui/src/markdown/extract-toc.ts b/packages/ui/src/markdown/extract-toc.ts similarity index 100% rename from packages/webui/src/markdown/extract-toc.ts rename to packages/ui/src/markdown/extract-toc.ts diff --git a/packages/ui/src/markdown/formatting.stories.tsx b/packages/ui/src/markdown/formatting.stories.tsx new file mode 100644 index 0000000000..ee1607a887 --- /dev/null +++ b/packages/ui/src/markdown/formatting.stories.tsx @@ -0,0 +1,84 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { Markdown } from './markdown'; + +const meta = { + title: 'markdown/Samples/Formatting', + component: Markdown, + tags: ['autodocs'], + parameters: { layout: 'padded' }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const INLINE = `# Inline formatting + +Body prose with **bold**, _italic_, ~~strikethrough~~, and \`inline code\`. +You can [link to docs](https://docs.tale.dev) or to a heading on the same +page like [#getting-started](#getting-started). External links open in a new +tab; internal anchor links scroll smoothly. + +Combine **_bold italic_** or **\`bold code\`** when emphasis stacks. The +muted text-fg-muted token keeps body prose calm so highlights pop. + +You can use a thematic break to separate sections: + +--- + +After the rule, the next block continues at full body weight. +`; + +const LISTS = `# Lists + +## Bullet list + +- First item +- Second item + - Nested item + - Another nested + - Three deep +- Third item + +## Ordered list + +1. Plan +2. Build +3. Ship + 1. Open PR + 2. Address review + 3. Merge + +## Task list (GFM) + +- [x] Migrate webui markdown +- [x] Stream chat into shared package +- [ ] Refactor legal-page +- [ ] Recreate Pencil designs +`; + +const QUOTES = `# Block quotes + +> A short quotation. The blockquote rail picks up the accent token. +> Multiple lines wrap into one quote block. + +> Nested quotes work too — +> +> > like this — nested level two +> > +> > > and even three levels deep, though that's rare in real prose. + +> A quote followed by **rich** _formatting_ and \`code\` still renders. +`; + +export const InlineElements: Story = { + args: { children: INLINE }, +}; + +export const Lists: Story = { + args: { children: LISTS }, +}; + +export const Blockquotes: Story = { + args: { children: QUOTES }, +}; diff --git a/packages/ui/src/markdown/globals.css b/packages/ui/src/markdown/globals.css new file mode 100644 index 0000000000..510b2930f1 --- /dev/null +++ b/packages/ui/src/markdown/globals.css @@ -0,0 +1,33 @@ +@import '../globals.css'; + +@source '../**/*.{ts,tsx}'; + +/* Page-level background + foreground that track the active theme. Loaded + standalone by Storybook so the iframe canvas inverts when the + addon-themes toggle flips `` to `.dark`. Consumers that own their + own app shell typically set their own body bg and override these. */ +html, +body { + background-color: var(--color-bg-base); + color: var(--color-fg-base); +} + +/* Shiki line numbering. Driven by a single `code-block-numbered` class on + the wrapper instead of Tailwind arbitrary-property utilities so the + selector chain (counter-reset on
, counter on each `.line::before`)
+   stays a single coherent block that's easy to debug. */
+.code-block-numbered pre {
+  counter-reset: line;
+}
+
+.code-block-numbered .line::before {
+  content: counter(line);
+  counter-increment: line;
+  display: inline-block;
+  width: 1.75rem;
+  margin-right: 1rem;
+  text-align: right;
+  color: var(--color-fg-subtle);
+  user-select: none;
+  -webkit-user-select: none;
+}
diff --git a/packages/ui/src/markdown/globals.css.d.ts b/packages/ui/src/markdown/globals.css.d.ts
new file mode 100644
index 0000000000..35306c6fc9
--- /dev/null
+++ b/packages/ui/src/markdown/globals.css.d.ts
@@ -0,0 +1 @@
+declare module '*.css';
diff --git a/packages/ui/src/markdown/headings.stories.tsx b/packages/ui/src/markdown/headings.stories.tsx
new file mode 100644
index 0000000000..d08d9833f3
--- /dev/null
+++ b/packages/ui/src/markdown/headings.stories.tsx
@@ -0,0 +1,65 @@
+import type { Meta, StoryObj } from '@storybook/react';
+
+import { Markdown } from './markdown';
+
+const meta = {
+  title: 'markdown/Samples/Headings',
+  component: Markdown,
+  tags: ['autodocs'],
+  parameters: { layout: 'padded' },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+const HEADINGS = `# Heading 1 — page title
+
+Body paragraph after a top-level heading. Use one h1 per page.
+
+## Heading 2 — section
+
+Body paragraph after a section heading. Headings cascade down by importance.
+
+### Heading 3 — subsection
+
+Body paragraph after a subsection heading.
+
+#### Heading 4 — subsubsection
+
+Body paragraph after a deep subsection.
+
+##### Heading 5
+
+Body paragraph.
+
+###### Heading 6
+
+Body paragraph.
+`;
+
+const HEADING_HIERARCHY = `# Top-level page
+
+## First section
+
+### A nested concept
+
+Some prose under the nested concept.
+
+### Another nested concept
+
+More prose.
+
+## Second section
+
+### Yet another concept
+
+Final paragraph.
+`;
+
+export const AllLevels: Story = {
+  args: { children: HEADINGS },
+};
+
+export const Hierarchy: Story = {
+  args: { children: HEADING_HIERARCHY },
+};
diff --git a/packages/ui/src/markdown/highlighted-code.tsx b/packages/ui/src/markdown/highlighted-code.tsx
new file mode 100644
index 0000000000..09d4f04e13
--- /dev/null
+++ b/packages/ui/src/markdown/highlighted-code.tsx
@@ -0,0 +1,159 @@
+import { Check, Copy } from 'lucide-react';
+import { useEffect, useMemo, useState } from 'react';
+
+import { cn } from '../lib/cn';
+import { useTheme } from '../theme';
+import { highlightCode } from './shiki';
+
+const LINE_NUMBER_THRESHOLD = 3;
+
+interface HighlightedCodeProps {
+  /** Source string. A single trailing newline is normalised away so the
+   * gutter count matches Shiki's emitted `` rows. */
+  code: string;
+  language?: string;
+  /** Force-show line numbers regardless of length. Defaults to auto
+   * (lines > 3). Set false to suppress the gutter for short snippets. */
+  showLineNumbers?: boolean;
+  /** Render a hover-revealed copy button anchored to the top-right. */
+  showCopyButton?: boolean;
+  className?: string;
+}
+
+/**
+ * Add per-line background tints to Shiki's diff output. Shiki wraps each
+ * source line in ``; we tag those whose first
+ * visible character is `+` or `-` so CSS can paint the whole row.
+ */
+function applyDiffLineBackgrounds(html: string): string {
+  if (typeof DOMParser === 'undefined') return html;
+  const doc = new DOMParser().parseFromString(html, 'text/html');
+  const lines = doc.querySelectorAll('.line');
+  for (const line of lines) {
+    const first = line.textContent?.[0];
+    if (first === '+') {
+      line.classList.add('diff-add', 'bg-green-500/15', 'dark:bg-green-500/10');
+    } else if (first === '-') {
+      line.classList.add('diff-del', 'bg-red-500/15', 'dark:bg-red-500/10');
+    }
+  }
+  const pre = doc.querySelector('pre');
+  if (pre) pre.classList.add('shiki-diff');
+  return doc.body.innerHTML;
+}
+
+/**
+ * Highlighted code body — the inner panel shared by `` and
+ * each `` tab. Owns the Shiki call, line-number toggle, diff
+ * tints, row-hover affordance, and (optionally) an inline copy button.
+ *
+ * Falls back to a plain `
` while Shiki loads so layout never shifts.
+ */
+export function HighlightedCode({
+  code,
+  language,
+  showLineNumbers,
+  showCopyButton,
+  className,
+}: HighlightedCodeProps) {
+  const { resolvedTheme } = useTheme();
+  const [html, setHtml] = useState(null);
+  const [copied, setCopied] = useState(false);
+
+  // Strip the trailing newline most fenced blocks carry. Both the gutter
+  // count and Shiki must tokenise the same string — otherwise Shiki emits
+  // an extra `` for the trailing newline while the
+  // gutter generates one fewer number, and the last visible row has no
+  // line number.
+  const normalisedCode = useMemo(
+    () => (code.endsWith('\n') ? code.slice(0, -1) : code),
+    [code],
+  );
+
+  useEffect(() => {
+    let cancelled = false;
+    void highlightCode(normalisedCode, language, resolvedTheme).then(
+      (result) => {
+        if (cancelled) return;
+        if (!result) {
+          // Oversized input or highlighter init failure — drop the cached
+          // html so the plain `
` fallback renders the new source.
+          setHtml(null);
+          return;
+        }
+        setHtml(
+          result.language === 'diff'
+            ? applyDiffLineBackgrounds(result.html)
+            : result.html,
+        );
+      },
+    );
+    return () => {
+      cancelled = true;
+    };
+  }, [normalisedCode, language, resolvedTheme]);
+
+  const handleCopy = async () => {
+    try {
+      await navigator.clipboard.writeText(normalisedCode);
+      setCopied(true);
+      setTimeout(() => setCopied(false), 1500);
+    } catch (error) {
+      console.warn('[code-block] clipboard write failed', error);
+    }
+  };
+
+  const lineCount = useMemo(
+    () => normalisedCode.split('\n').length,
+    [normalisedCode],
+  );
+  const numbered = showLineNumbers ?? lineCount > LINE_NUMBER_THRESHOLD;
+
+  return (
+    
+ {showCopyButton ? ( + + ) : null} + {html ? ( +
pre]:m-0 [&>pre]:bg-transparent! [&>pre]:p-0 [&>pre>code>.line]:leading-[1.6]', + showCopyButton && 'pr-12', + numbered && 'code-block-numbered', + )} + // oxlint-disable-next-line react/no-danger -- Shiki output is HTML by design + dangerouslySetInnerHTML={{ __html: html }} + /> + ) : ( +
+          {normalisedCode}
+        
+ )} +
+ ); +} diff --git a/packages/ui/src/markdown/markdown.stories.tsx b/packages/ui/src/markdown/markdown.stories.tsx new file mode 100644 index 0000000000..8ea80033e2 --- /dev/null +++ b/packages/ui/src/markdown/markdown.stories.tsx @@ -0,0 +1,99 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { mintlifyComponents } from './components/registry'; +import { Markdown } from './markdown'; + +const meta = { + title: 'markdown/Markdown', + component: Markdown, + tags: ['autodocs'], + parameters: { layout: 'padded' }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const SAMPLE = `# Agent concepts + +The mental model behind Tale agents — instructions, knowledge, tools, and models. + +## Instructions + +Instructions tell the agent **how** to behave. Use sentence case. + +## Tools + +Tools are how the agent **does things**. Examples include: + +- HTTP fetchers +- Vector-store retrievers +- Webhook senders + +\`\`\`typescript +import { defineAgent } from '@tale/agent'; + +export const concierge = defineAgent({ + name: 'concierge', + model: 'claude-opus-4-7', +}); +\`\`\` + +> Looking for the API? See [/develop/api-reference](/develop/api-reference). +`; + +export const Sample: Story = { + args: { children: SAMPLE }, +}; + +export const WithMintlifyComponents: Story = { + args: { + children: SAMPLE, + // oxlint-disable-next-line typescript/no-explicit-any -- mintlify keys aren't HTML element tags + components: mintlifyComponents as any, + }, +}; + +const TABLE_SAMPLE = `# Tables — sticky thead + row hover + +The wrapper is scrollable; the header pins to the top while the body scrolls +underneath. Hover any row to see the tint. + +| Region | Q1 | Q2 | Q3 | Q4 | YoY | +| --- | ---: | ---: | ---: | ---: | ---: | +| EU | 1.2k | 1.7k | 2.1k | 2.6k | +18% | +| NA | 2.0k | 2.4k | 2.9k | 3.5k | +9% | +| APAC | 0.6k | 0.9k | 1.2k | 1.5k | +33% | +| LATAM | 0.3k | 0.4k | 0.5k | 0.7k | +24% | +| MEA | 0.2k | 0.3k | 0.4k | 0.5k | +28% | +| Antarctic stations | 0.01k | 0.02k | 0.02k | 0.03k | +50% | +| Maritime fleet | 0.05k | 0.07k | 0.09k | 0.12k | +41% | +| Lunar relay | 0.001k | 0.002k | 0.005k | 0.011k | +900% | +| Mars probe | 0.0001k | 0.0001k | 0.0002k | 0.0005k | +400% | +| Deep space | 0.0001k | 0.0001k | 0.0001k | 0.0001k | 0% | +`; + +export const TableWithStickyHeader: Story = { + args: { children: TABLE_SAMPLE }, +}; + +const ALERTS_SAMPLE = `# GFM alerts → Callouts + +> [!NOTE] +> The Markdown component swaps GitHub-style alert blockquotes for callouts. + +> [!TIP] +> Use this for small lift-ups that don't warrant a full warning. + +> [!IMPORTANT] +> Important context the reader needs before proceeding. + +> [!WARNING] +> A risk worth pausing for. + +> [!CAUTION] +> Truly destructive — confirm before acting. +`; + +export const GfmAlerts: Story = { + args: { children: ALERTS_SAMPLE }, +}; diff --git a/packages/webui/src/markdown/markdown.tsx b/packages/ui/src/markdown/markdown.tsx similarity index 78% rename from packages/webui/src/markdown/markdown.tsx rename to packages/ui/src/markdown/markdown.tsx index 851f607112..e786bf1be2 100644 --- a/packages/webui/src/markdown/markdown.tsx +++ b/packages/ui/src/markdown/markdown.tsx @@ -1,4 +1,3 @@ -import { Link } from '@tanstack/react-router'; import { Children, type ComponentPropsWithoutRef, @@ -9,10 +8,12 @@ import ReactMarkdown, { type Components } from 'react-markdown'; import rehypeRaw from 'rehype-raw'; import remarkGfm from 'remark-gfm'; +import { cn } from '../lib/cn'; import { AnchoredHeading } from './anchored-heading'; import { CodeBlock } from './code-block'; import { Callout } from './components/callout'; import { Mermaid } from './components/mermaid'; +import { rehypeNumericColumns } from './plugins/rehype-numeric-columns'; type AlertTone = 'note' | 'tip' | 'info' | 'warning' | 'danger'; @@ -97,7 +98,12 @@ interface MarkdownProps { className?: string; } -const baseComponents: Components = { +/** + * Default `components` map applied by ``. Exported so streaming + * variants (IncrementalMarkdown) and other wrappers render visually + * identical prose without re-defining every element. + */ +export const baseComponents: Components = { h1: ({ children }) => ( ), a: ({ href, children }: ComponentPropsWithoutRef<'a'>) => { + // Render a plain anchor by default — keeps the package router-agnostic + // so it can run anywhere (Storybook, SSR, non-routed apps). Consumers + // that want SPA navigation pass their own `components.a` (e.g. a + // TanStack-Router-aware ) when calling . const isExternal = typeof href === 'string' && (href.startsWith('http://') || href.startsWith('https://')); - const isHash = typeof href === 'string' && href.startsWith('#'); - const className = - 'text-fg-base underline underline-offset-4 hover:no-underline'; - if (!href || isExternal || isHash) { - return ( - - {children} - - ); - } return ( - {children} - + ); }, strong: ({ children }) => ( @@ -256,35 +252,35 @@ const baseComponents: Components = { ); }, - pre: ({ children }) => { - // Pull the inner element to extract language + raw text so we can - // hand it to for Shiki highlighting + the copy button. Code - // blocks tagged `mermaid` go through the renderer instead. - const child = extractCodeChild(children); - if (!child) return
{children}
; - if (child.language === 'mermaid') return ; - return ; - }, + pre: makePreComponent(), hr: () =>
, table: ({ children }) => ( -
- - {children} -
+
+ {children}
), - thead: ({ children }) => {children}, + thead: ({ children }) => ( + {children} + ), tbody: ({ children }) => {children}, tr: ({ children }) => ( - {children} + + {children} + ), - th: ({ children }) => ( - + th: ({ children, className }: ComponentPropsWithoutRef<'th'>) => ( + {children} ), - td: ({ children }) => ( - + td: ({ children, className }: ComponentPropsWithoutRef<'td'>) => ( + {children} ), @@ -304,6 +300,34 @@ interface ExtractedCode { text: string; } +/** + * Build the `pre` Markdown component. Streaming surfaces pass + * `{ showLineNumbers: true, streamingMermaid: true }` so code blocks never + * shift content as new lines arrive and partial mermaid DSL doesn't try to + * render mid-stream. + */ +export function makePreComponent({ + showLineNumbers, + streamingMermaid, +}: { showLineNumbers?: boolean; streamingMermaid?: boolean } = {}): NonNullable< + Components['pre'] +> { + return function Pre({ children }) { + const child = extractCodeChild(children); + if (!child) return
{children}
; + if (child.language === 'mermaid') { + return ; + } + return ( + + ); + }; +} + function extractCodeChild(children: ReactNode): ExtractedCode | null { if (!children || typeof children !== 'object') return null; // children may be a single element or an array @@ -349,7 +373,10 @@ export function Markdown({ children, components, className }: MarkdownProps) { // tags like ``, ``, `` survive as hast nodes // and reach the components map below. Without it those tags are // dropped silently and only the prose between them renders. - rehypePlugins={[rehypeRaw]} + // `rehypeNumericColumns` walks each table and tags columns whose + // body cells are all numeric-like with `text-right`, so finance / + // metric tables read aligned without any author opt-in. + rehypePlugins={[rehypeRaw, rehypeNumericColumns]} components={{ ...baseComponents, ...components }} > {children} diff --git a/services/platform/app/features/chat/utils/__tests__/micromark-cjk-attention.test.ts b/packages/ui/src/markdown/plugins/__tests__/micromark-cjk-attention.test.ts similarity index 100% rename from services/platform/app/features/chat/utils/__tests__/micromark-cjk-attention.test.ts rename to packages/ui/src/markdown/plugins/__tests__/micromark-cjk-attention.test.ts diff --git a/services/platform/app/features/chat/utils/micromark-cjk-attention.ts b/packages/ui/src/markdown/plugins/micromark-cjk-attention.ts similarity index 93% rename from services/platform/app/features/chat/utils/micromark-cjk-attention.ts rename to packages/ui/src/markdown/plugins/micromark-cjk-attention.ts index 01973fdc81..f3c30ff92f 100644 --- a/services/platform/app/features/chat/utils/micromark-cjk-attention.ts +++ b/packages/ui/src/markdown/plugins/micromark-cjk-attention.ts @@ -108,6 +108,11 @@ export function micromarkCjkAttention() { * * Usage: * const REMARK_PLUGINS = [remarkCjkAttention, remarkGfm]; + * + * Typed loosely as `() => void` so it slots into `MarkdownOptions['remarkPlugins']` + * without a `Pluggable` adapter — unified's Processor.data() returns the + * generic Data shape, but we mutate the (untyped) `micromarkExtensions` + * field. The PluginThis interface narrows that single field for our use. */ // oxlint-disable oxc/no-this-in-exported-function -- remark plugin API requires this binding export function remarkCjkAttention(this: PluginThis) { diff --git a/packages/ui/src/markdown/plugins/rehype-numeric-columns.ts b/packages/ui/src/markdown/plugins/rehype-numeric-columns.ts new file mode 100644 index 0000000000..f31e032cec --- /dev/null +++ b/packages/ui/src/markdown/plugins/rehype-numeric-columns.ts @@ -0,0 +1,166 @@ +/** + * rehype-numeric-columns — Right-align table columns whose body cells are + * all numeric-like. + * + * "Numeric-like" covers: integers, decimals, signed numbers (`+5`, `-3.2`), + * percentages (`12%`), currencies (`$100`, `€9.99`), thousands-separated + * numbers (`1,234.56`, `1 000`), numbers with a trailing unit (`5kg`, + * `100 km/h`), ISO/slash dates (`2024-01-15`, `15/01/2024`) and times + * (`12:00`, `9:30 AM`). + * + * The plugin scans each `` in the HAST tree, looks at every body + * cell per column, and if every non-empty cell in a column is numeric-like + * it appends `text-right` to both the body cells AND the matching header + * cell (so the column reads consistently from header to data). Cells with + * an explicit `align`/`text-align` from GFM column-marker syntax + * (`| ---: |`) are preserved as-is. + */ + +import type { Element, ElementContent, Properties, Root } from 'hast'; +import { visit } from 'unist-util-visit'; + +const RIGHT_ALIGN_CLASS = 'text-right'; + +const NUMERIC_BODY = /^[-+]?[$€£¥₹]?\s?\d[\d,.  ]*(?:\.\d+)?$/; +const NUMERIC_WITH_SUFFIX = + /^[-+]?[$€£¥₹]?\s?\d[\d,.  ]*(?:\.\d+)?\s*(?:%|°[CFK]?|[A-Za-z]{1,8}(?:\/[A-Za-z]{1,4})?)$/; +const DATE = + /^\d{1,4}[-./]\d{1,2}[-./]\d{1,4}(?:[T ]\d{1,2}:\d{2}(?::\d{2})?)?$/; +const TIME = /^\d{1,2}:\d{2}(?::\d{2})?(?:\s*(?:AM|PM|am|pm))?$/; + +function isNumericLike(text: string): boolean { + const trimmed = text.trim(); + if (!trimmed) return false; + if (!/\d/.test(trimmed)) return false; + return ( + NUMERIC_BODY.test(trimmed) || + NUMERIC_WITH_SUFFIX.test(trimmed) || + DATE.test(trimmed) || + TIME.test(trimmed) + ); +} + +function getCellText(node: Element): string { + let text = ''; + const walk = (children: ElementContent[]) => { + for (const child of children) { + if (child.type === 'text') { + text += child.value; + } else if (child.type === 'element') { + walk(child.children); + } + } + }; + walk(node.children); + return text; +} + +function hasExplicitAlignment(props: Properties | undefined): boolean { + if (!props) return false; + if (props.align) return true; + const style = props.style; + if (typeof style === 'string' && /text-align/i.test(style)) return true; + return false; +} + +function appendClass(props: Properties, className: string): void { + const existing = props.className; + if (Array.isArray(existing)) { + if (!existing.includes(className)) existing.push(className); + } else if (typeof existing === 'string') { + props.className = existing + .split(/\s+/) + .filter(Boolean) + .concat(existing.split(/\s+/).includes(className) ? [] : [className]) + .join(' '); + } else { + props.className = [className]; + } +} + +function findChildElement( + parent: Element, + tagName: string, +): Element | undefined { + for (const child of parent.children) { + if (child.type === 'element' && child.tagName === tagName) return child; + } + return undefined; +} + +function eachRow(parent: Element): Element[] { + const rows: Element[] = []; + for (const child of parent.children) { + if (child.type === 'element' && child.tagName === 'tr') rows.push(child); + } + return rows; +} + +function eachCell(row: Element): Element[] { + const cells: Element[] = []; + for (const child of row.children) { + if ( + child.type === 'element' && + (child.tagName === 'td' || child.tagName === 'th') + ) { + cells.push(child); + } + } + return cells; +} + +export function rehypeNumericColumns() { + return (tree: Root) => { + visit(tree, 'element', (node) => { + if (node.tagName !== 'table') return; + const tbody = findChildElement(node, 'tbody'); + if (!tbody) return; + + const bodyRows = eachRow(tbody); + if (bodyRows.length === 0) return; + + // Bucket body cells by column index. + const columns: { cells: Element[]; texts: string[] }[] = []; + for (const row of bodyRows) { + const cells = eachCell(row); + cells.forEach((cell, colIdx) => { + if (!columns[colIdx]) columns[colIdx] = { cells: [], texts: [] }; + columns[colIdx].cells.push(cell); + columns[colIdx].texts.push(getCellText(cell)); + }); + } + + const numericColumns = columns.map(({ texts }) => { + const nonEmpty = texts.filter((t) => t.trim().length > 0); + if (nonEmpty.length === 0) return false; + return nonEmpty.every(isNumericLike); + }); + + // Apply text-right to body cells of numeric columns (skipping cells the + // author explicitly aligned via GFM column-marker syntax). + columns.forEach((col, idx) => { + if (!numericColumns[idx]) return; + for (const cell of col.cells) { + cell.properties = cell.properties ?? {}; + if (hasExplicitAlignment(cell.properties)) continue; + appendClass(cell.properties, RIGHT_ALIGN_CLASS); + } + }); + + // And to the matching header cells in thead (so the column header + // reads aligned with the data). + const thead = findChildElement(node, 'thead'); + if (thead) { + for (const headerRow of eachRow(thead)) { + const headerCells = eachCell(headerRow); + headerCells.forEach((cell, idx) => { + if (!numericColumns[idx]) return; + cell.properties = cell.properties ?? {}; + if (hasExplicitAlignment(cell.properties)) return; + appendClass(cell.properties, RIGHT_ALIGN_CLASS); + }); + } + } + }); + }; +} diff --git a/packages/webui/src/markdown/reading-time.ts b/packages/ui/src/markdown/reading-time.ts similarity index 100% rename from packages/webui/src/markdown/reading-time.ts rename to packages/ui/src/markdown/reading-time.ts diff --git a/packages/ui/src/markdown/routed-markdown.tsx b/packages/ui/src/markdown/routed-markdown.tsx new file mode 100644 index 0000000000..fcd5f69496 --- /dev/null +++ b/packages/ui/src/markdown/routed-markdown.tsx @@ -0,0 +1,85 @@ +import { Link } from '@tanstack/react-router'; +import { type ComponentPropsWithoutRef } from 'react'; +import type { Components } from 'react-markdown'; + +import { Markdown } from './markdown'; + +interface RoutedMarkdownProps { + children: string; + /** Override or extend the component map. Merged on top of the router-aware + * `a` override so consumers can still customise individual elements. */ + components?: Components; + className?: string; +} + +const ROUTER_LINK_CLASS = + 'text-fg-base underline underline-offset-4 hover:no-underline'; + +/** + * SPA-aware anchor used by docs/web. External `http(s)://` and bare + * fragment (`#anchor`) hrefs render as plain `` so target=_blank still + * works and on-page anchors don't go through the router; everything else + * defers to TanStack Router's `` for client-side navigation. + */ +/** + * Match any URI scheme (http, https, mailto, tel, ftp, sms, …) and the + * protocol-relative `//host` form. Anything that hits this RegExp must + * stay a plain ``; only "real" internal app paths go through the + * router. + */ +const NON_INTERNAL_HREF_RE = /^(?:[a-zA-Z][a-zA-Z0-9+.-]*:|\/\/)/; + +function RoutedAnchor({ href, children }: ComponentPropsWithoutRef<'a'>) { + const isExternal = + typeof href === 'string' && NON_INTERNAL_HREF_RE.test(href); + const isHttpExternal = + typeof href === 'string' && + (href.startsWith('http://') || href.startsWith('https://')); + const isHash = typeof href === 'string' && href.startsWith('#'); + + if (!href || isExternal || isHash) { + return ( + + {children} + + ); + } + + return ( + + {children} + + ); +} + +const ROUTED_COMPONENTS = { a: RoutedAnchor } satisfies Components; + +/** + * `` wrapper that swaps internal `` for TanStack-Router's + * `` so consumers inside a `` get SPA navigation + * for free. Storybook / SSR-without-router contexts should keep using + * the base `` from `@tale/ui/markdown`. + */ +export function RoutedMarkdown({ + children, + components, + className, +}: RoutedMarkdownProps) { + return ( + + {children} + + ); +} diff --git a/packages/ui/src/markdown/shiki.ts b/packages/ui/src/markdown/shiki.ts new file mode 100644 index 0000000000..90e94bda00 --- /dev/null +++ b/packages/ui/src/markdown/shiki.ts @@ -0,0 +1,221 @@ +/** + * Shared Shiki highlighter singleton. + * + * Strategy: 39 common grammars are statically imported so docs / web / + * platform get them on first paint. Anything else is lazy-loaded on + * demand via a runtime dynamic import. Shiki's JS regex engine keeps + * the bundle off the WASM oniguruma path. + */ + +import { createHighlighterCore, type HighlighterCore } from 'shiki/core'; +import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'; + +let highlighterPromise: Promise | null = null; + +function getHighlighter(): Promise { + if (!highlighterPromise) { + highlighterPromise = createHighlighterCore({ + themes: [ + import('shiki/themes/github-dark.mjs'), + import('shiki/themes/github-light.mjs'), + ], + langs: [ + import('shiki/langs/bash.mjs'), + import('shiki/langs/c.mjs'), + import('shiki/langs/cpp.mjs'), + import('shiki/langs/csharp.mjs'), + import('shiki/langs/css.mjs'), + import('shiki/langs/diff.mjs'), + import('shiki/langs/docker.mjs'), + import('shiki/langs/dotenv.mjs'), + import('shiki/langs/elixir.mjs'), + import('shiki/langs/go.mjs'), + import('shiki/langs/graphql.mjs'), + import('shiki/langs/hcl.mjs'), + import('shiki/langs/html.mjs'), + import('shiki/langs/http.mjs'), + import('shiki/langs/ini.mjs'), + import('shiki/langs/java.mjs'), + import('shiki/langs/javascript.mjs'), + import('shiki/langs/json.mjs'), + import('shiki/langs/jsx.mjs'), + import('shiki/langs/kotlin.mjs'), + import('shiki/langs/lua.mjs'), + import('shiki/langs/markdown.mjs'), + import('shiki/langs/nginx.mjs'), + import('shiki/langs/php.mjs'), + import('shiki/langs/powershell.mjs'), + import('shiki/langs/prisma.mjs'), + import('shiki/langs/python.mjs'), + import('shiki/langs/ruby.mjs'), + import('shiki/langs/rust.mjs'), + import('shiki/langs/scala.mjs'), + import('shiki/langs/scss.mjs'), + import('shiki/langs/sql.mjs'), + import('shiki/langs/swift.mjs'), + import('shiki/langs/toml.mjs'), + import('shiki/langs/tsx.mjs'), + import('shiki/langs/typescript.mjs'), + import('shiki/langs/xml.mjs'), + import('shiki/langs/yaml.mjs'), + import('shiki/langs/zig.mjs'), + ], + engine: createJavaScriptRegexEngine(), + }).catch((error) => { + highlighterPromise = null; + throw error; + }); + } + return highlighterPromise; +} + +const LANG_ALIASES: Record = { + plaintext: 'text', + txt: 'text', + py: 'python', + pyi: 'python', + pyw: 'python', + js: 'javascript', + mjs: 'javascript', + cjs: 'javascript', + ts: 'typescript', + mts: 'typescript', + cts: 'typescript', + sh: 'bash', + zsh: 'bash', + shell: 'bash', + shellscript: 'bash', + ps1: 'powershell', + yml: 'yaml', + md: 'markdown', + htm: 'html', + dockerfile: 'docker', + env: 'dotenv', + rs: 'rust', + rb: 'ruby', + kt: 'kotlin', + kts: 'kotlin', + cc: 'cpp', + cxx: 'cpp', + hpp: 'cpp', + hxx: 'cpp', + h: 'c', + cs: 'csharp', + ex: 'elixir', + exs: 'elixir', + gql: 'graphql', + terraform: 'hcl', + tf: 'hcl', +}; + +export function resolveLanguage(input: string | undefined): string { + if (!input) return 'text'; + const lower = input.toLowerCase(); + return LANG_ALIASES[lower] ?? lower; +} + +/** + * Cap on the input size we'll synchronously tokenize on the main thread. + * Above this, callers should fall back to a plain-text render — Shiki's + * `codeToHtml` is O(n) but blocking, and on a 250 KB document the freeze + * runs 300 ms-2 s on a mid-range laptop. + */ +export const MAX_SHIKI_BYTES = 64_000; + +export interface HighlightResult { + html: string; + language: string; +} + +type ShikiTheme = 'light' | 'dark' | 'github-light' | 'github-dark'; + +function normalizeTheme(theme: ShikiTheme): 'github-dark' | 'github-light' { + return theme === 'dark' || theme === 'github-dark' + ? 'github-dark' + : 'github-light'; +} + +/** + * Tokenize `code` into highlighted HTML. Returns `null` when: + * - `code.length` exceeds `MAX_SHIKI_BYTES` (caller should plain-text) + * - the underlying highlighter fails to initialize or render + * + * Languages outside the eager list are lazy-loaded on first request and + * cached for subsequent calls. Unknown grammars fall back to plaintext. + */ +export async function highlightCode( + code: string, + lang: string | undefined, + theme: ShikiTheme = 'light', +): Promise { + if (code.length > MAX_SHIKI_BYTES) return null; + + let highlighter: HighlighterCore; + try { + highlighter = await getHighlighter(); + } catch (err) { + console.warn('[shiki] highlighter init failed:', err); + return null; + } + + const resolvedTheme = normalizeTheme(theme); + const resolvedLang = resolveLanguage(lang); + + // Shiki's `text` grammar is a built-in no-highlight pass — there is no + // `shiki/langs/text.mjs` to load. Skip the load attempt entirely. + if (resolvedLang === 'text') { + try { + return { + html: highlighter.codeToHtml(code, { + lang: 'text', + theme: resolvedTheme, + }), + language: 'text', + }; + } catch (err) { + console.warn('[shiki] codeToHtml failed for lang="text":', err); + return null; + } + } + + const loaded = highlighter.getLoadedLanguages(); + if (!loaded.includes(resolvedLang)) { + try { + await highlighter.loadLanguage( + /* @vite-ignore */ import( + `shiki/langs/${resolvedLang}.mjs` + ) as Parameters[0], + ); + } catch (err) { + console.warn( + `[shiki] language "${resolvedLang}" not loadable, falling back to plaintext:`, + err, + ); + try { + return { + html: highlighter.codeToHtml(code, { + lang: 'text', + theme: resolvedTheme, + }), + language: 'text', + }; + } catch (htmlErr) { + console.warn('[shiki] plaintext fallback failed:', htmlErr); + return null; + } + } + } + + try { + return { + html: highlighter.codeToHtml(code, { + lang: resolvedLang, + theme: resolvedTheme, + }), + language: resolvedLang, + }; + } catch (err) { + console.warn(`[shiki] codeToHtml failed for lang="${resolvedLang}":`, err); + return null; + } +} diff --git a/services/platform/app/features/chat/utils/__tests__/find-block-split.test.ts b/packages/ui/src/markdown/streaming/__tests__/find-block-split.test.ts similarity index 100% rename from services/platform/app/features/chat/utils/__tests__/find-block-split.test.ts rename to packages/ui/src/markdown/streaming/__tests__/find-block-split.test.ts diff --git a/services/platform/app/features/chat/components/__tests__/incremental-markdown.test.tsx b/packages/ui/src/markdown/streaming/__tests__/incremental-markdown.test.tsx similarity index 96% rename from services/platform/app/features/chat/components/__tests__/incremental-markdown.test.tsx rename to packages/ui/src/markdown/streaming/__tests__/incremental-markdown.test.tsx index 568a4554a7..568282b5a4 100644 --- a/services/platform/app/features/chat/components/__tests__/incremental-markdown.test.tsx +++ b/packages/ui/src/markdown/streaming/__tests__/incremental-markdown.test.tsx @@ -7,9 +7,14 @@ import { IncrementalMarkdown } from '../incremental-markdown'; // HELPERS // ============================================================================ -/** Count visible cursor elements (useLayoutEffect hides duplicates via display:none) */ +/** + * Count visible cursor elements (useLayoutEffect hides duplicates via + * display:none). We match the cursor's specific class so other + * aria-hidden decorations rendered by `baseComponents` (anchor-link + * indicators, copy-button icons, etc.) don't get miscounted as cursors. + */ function countCursors(container: HTMLElement) { - const all = container.querySelectorAll('[aria-hidden="true"]'); + const all = container.querySelectorAll('.animate-cursor-blink'); let visible = 0; for (const el of all) { if (el.style.display !== 'none') visible++; diff --git a/services/platform/app/features/chat/utils/__tests__/normalize-html-blocks.test.ts b/packages/ui/src/markdown/streaming/__tests__/normalize-html-blocks.test.ts similarity index 98% rename from services/platform/app/features/chat/utils/__tests__/normalize-html-blocks.test.ts rename to packages/ui/src/markdown/streaming/__tests__/normalize-html-blocks.test.ts index c646b361e7..d3483fb2a5 100644 --- a/services/platform/app/features/chat/utils/__tests__/normalize-html-blocks.test.ts +++ b/packages/ui/src/markdown/streaming/__tests__/normalize-html-blocks.test.ts @@ -1,7 +1,7 @@ import { micromark } from 'micromark'; import { describe, expect, it } from 'vitest'; -import { micromarkCjkAttention } from '../micromark-cjk-attention'; +import { micromarkCjkAttention } from '../../plugins/micromark-cjk-attention'; import { normalizeHtmlBlocks } from '../normalize-html-blocks'; const renderHtml = (md: string): string => diff --git a/services/platform/app/features/chat/utils/__tests__/remend-markdown.test.ts b/packages/ui/src/markdown/streaming/__tests__/remend-markdown.test.ts similarity index 100% rename from services/platform/app/features/chat/utils/__tests__/remend-markdown.test.ts rename to packages/ui/src/markdown/streaming/__tests__/remend-markdown.test.ts diff --git a/services/platform/app/features/chat/utils/find-block-split.ts b/packages/ui/src/markdown/streaming/find-block-split.ts similarity index 100% rename from services/platform/app/features/chat/utils/find-block-split.ts rename to packages/ui/src/markdown/streaming/find-block-split.ts diff --git a/packages/ui/src/markdown/streaming/incremental-markdown.stories.tsx b/packages/ui/src/markdown/streaming/incremental-markdown.stories.tsx new file mode 100644 index 0000000000..62e4cae43f --- /dev/null +++ b/packages/ui/src/markdown/streaming/incremental-markdown.stories.tsx @@ -0,0 +1,96 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useEffect, useState } from 'react'; + +import { IncrementalMarkdown } from './incremental-markdown'; + +const meta = { + title: 'markdown/Streaming/IncrementalMarkdown', + component: IncrementalMarkdown, + tags: ['autodocs'], + parameters: { layout: 'padded' }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const SAMPLE = `# Streaming response + +The IncrementalMarkdown component splits content at the last block boundary +so completed blocks render once. Only the trailing block is re-parsed each +frame, keeping per-frame cost flat as the response grows. + +Some bullets while the model is mid-thought: + +- The cursor lands in the last text node +- Marker-only lines (\`\\n- \`, \`\\n# \`) are detected as empty and skipped +- Mid-block parsing is stable thanks to remendMarkdown auto-closing markers + +\`\`\`typescript +const stream = await openai.chat.completions.stream(...); +for await (const chunk of stream) { + bufferRef.current += chunk.delta; +} +\`\`\` + +After streaming finishes the cursor disappears. This sentence is being typed.`; + +/** + * Continuously reveals the full sample to demonstrate the typewriter cursor + * landing in the last rendered text element. Loops back to 0 after a brief + * pause so the story is always animating. + */ +function useReveal(content: string, charsPerSecond: number) { + const [position, setPosition] = useState(0); + useEffect(() => { + let raf = 0; + let last = performance.now(); + const tick = (now: number) => { + const dt = (now - last) / 1000; + last = now; + setPosition((prev) => { + const next = prev + dt * charsPerSecond; + if (next >= content.length + 40) return 0; + return next; + }); + raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + }, [content, charsPerSecond]); + return Math.min(Math.floor(position), content.length); +} + +function TypewriterDemo() { + const revealPosition = useReveal(SAMPLE, 80); + const isStreaming = revealPosition < SAMPLE.length; + return ( + + ); +} + +export const TypewriterReveal: Story = { + args: { content: SAMPLE, revealPosition: 0 }, + render: () => , +}; + +export const FullyRevealed: Story = { + args: { + content: SAMPLE, + revealPosition: SAMPLE.length, + showCursor: false, + }, +}; + +export const MidBlock: Story = { + args: { + content: SAMPLE, + revealPosition: Math.floor(SAMPLE.length * 0.3), + showCursor: true, + 'aria-busy': true, + }, +}; diff --git a/services/platform/app/features/chat/components/incremental-markdown.tsx b/packages/ui/src/markdown/streaming/incremental-markdown.tsx similarity index 85% rename from services/platform/app/features/chat/components/incremental-markdown.tsx rename to packages/ui/src/markdown/streaming/incremental-markdown.tsx index 34f2d30c78..2ed531ac8e 100644 --- a/services/platform/app/features/chat/components/incremental-markdown.tsx +++ b/packages/ui/src/markdown/streaming/incremental-markdown.tsx @@ -28,15 +28,38 @@ import rehypeRaw from 'rehype-raw'; import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'; import remarkGfm from 'remark-gfm'; -import type { - MarkdownComponentMap, - MarkdownComponentType, -} from '@/lib/utils/markdown-types'; +import { baseComponents, makePreComponent } from '../markdown'; +import { remarkCjkAttention } from '../plugins/micromark-cjk-attention'; +import { rehypeNumericColumns } from '../plugins/rehype-numeric-columns'; +import type { MarkdownComponentMap, MarkdownComponentType } from '../types'; +import { findBlockSplitPoint } from './find-block-split'; +import { normalizeHtmlBlocks } from './normalize-html-blocks'; +import { remendMarkdown } from './remend-markdown'; -import { findBlockSplitPoint } from '../utils/find-block-split'; -import { remarkCjkAttention } from '../utils/micromark-cjk-attention'; -import { normalizeHtmlBlocks } from '../utils/normalize-html-blocks'; -import { remendMarkdown } from '../utils/remend-markdown'; +/** + * Streaming defaults — the *exact same* component map `` uses, + * with two streaming-specific overrides: + * - `pre`: line numbers always on, so the gutter doesn't appear partway + * through and shift the content rightward when a code block crosses + * the threshold; + * - `pre` for `mermaid`: skeleton placeholder while the block is still + * in-progress (mermaid DSL is only valid once the closing fence + * arrives); the stable map renders mermaid normally. + * + * Trade-off: keeping `` for `pre` means Shiki re-tokenises on + * every reveal step. Storybook + casual streaming live with that; chat + * callers stream large code blocks via their own debounced highlighter + * passed through `components`. + */ +const STABLE_BASE = { + ...baseComponents, + pre: makePreComponent({ showLineNumbers: true }), +} as unknown as MarkdownComponentMap; + +const STREAMING_BASE = { + ...baseComponents, + pre: makePreComponent({ showLineNumbers: true, streamingMermaid: true }), +} as unknown as MarkdownComponentMap; const chatSanitizeSchema = { ...defaultSchema, @@ -58,13 +81,20 @@ const remarkDisableIndentedCode = function (this: { type PluginList = NonNullable; +// Cast through `as PluginList` because `remarkCjkAttention` and +// `remarkDisableIndentedCode` use narrowed `this`-types for type-safe +// data() access — narrower than unified's `Plugin` signature, but +// structurally compatible at runtime. const REMARK_PLUGINS: PluginList = [ remarkDisableIndentedCode, remarkCjkAttention, remarkGfm, -]; +] as PluginList; const REHYPE_PLUGINS: PluginList = [ rehypeRaw, + // Tag numeric columns BEFORE sanitize so the appended `text-right` + // class flows through (className survives the default sanitize schema). + rehypeNumericColumns, [rehypeSanitize, chatSanitizeSchema], ]; @@ -426,16 +456,29 @@ export function IncrementalMarkdown({ const streamContent = content.slice(splitIndex); const streamRevealLength = revealPosition - splitIndex; + // Two distinct base maps so the streaming half can defer mermaid renders + // (incomplete DSL) and pin code-block line numbers from the first row, + // while the stable half renders mermaid normally as soon as a block has + // graduated. Caller-provided overrides merge on top of each. + const stableMerged = useMemo( + () => ({ ...STABLE_BASE, ...components }), + [components], + ); + const streamingMerged = useMemo( + () => ({ ...STREAMING_BASE, ...components }), + [components], + ); + return (
{stableContent && ( - + )} {streamContent && ( )} diff --git a/services/platform/app/features/chat/utils/normalize-html-blocks.ts b/packages/ui/src/markdown/streaming/normalize-html-blocks.ts similarity index 100% rename from services/platform/app/features/chat/utils/normalize-html-blocks.ts rename to packages/ui/src/markdown/streaming/normalize-html-blocks.ts diff --git a/services/platform/app/features/chat/utils/remend-markdown.ts b/packages/ui/src/markdown/streaming/remend-markdown.ts similarity index 97% rename from services/platform/app/features/chat/utils/remend-markdown.ts rename to packages/ui/src/markdown/streaming/remend-markdown.ts index ac4f5aba0e..5696c9c680 100644 --- a/services/platform/app/features/chat/utils/remend-markdown.ts +++ b/packages/ui/src/markdown/streaming/remend-markdown.ts @@ -352,7 +352,10 @@ export function remendMarkdown(text: string): string { const lastDetails = result.lastIndexOf('', lastDetails)) { const textBefore = result.slice(0, lastDetails); - const fenceLines = (textBefore.match(/^`{3,}/gm) || []).length; + // Count both backtick AND tilde fences — a `
` inside a + // `~~~`-fenced block is just as inert as one inside ``` and we + // mustn't mistake it for live HTML. + const fenceLines = (textBefore.match(/^(`{3,}|~{3,})/gm) || []).length; if (fenceLines % 2 === 0) { const detailsContent = result.slice(lastDetails); if (detailsContent.includes('')) { @@ -395,6 +398,9 @@ function isCompleteSeparator(line: string, expectedCols: number): boolean { /** * Generate a GFM separator row for a given column count. + * remark-gfm + react-markdown accept a single dash per cell; existing + * tests + chat traffic depend on this exact shape, so don't change it + * without re-baselining `remend-markdown.test.ts`. * e.g. cols=3 → `| - | - | - |` */ function makeSeparator(cols: number): string { diff --git a/packages/ui/src/markdown/types.ts b/packages/ui/src/markdown/types.ts new file mode 100644 index 0000000000..d68adceb24 --- /dev/null +++ b/packages/ui/src/markdown/types.ts @@ -0,0 +1,6 @@ +import type { ComponentType } from 'react'; + +// oxlint-disable-next-line typescript/no-explicit-any -- Required by react-markdown's Components interface which uses ComponentType +export type MarkdownComponentType = ComponentType; + +export type MarkdownComponentMap = Record; diff --git a/packages/webui/package.json b/packages/webui/package.json index dd424dcc29..d903593b01 100644 --- a/packages/webui/package.json +++ b/packages/webui/package.json @@ -2,7 +2,7 @@ "name": "@tale/webui", "version": "0.1.0", "private": true, - "description": "Shared web/docs UI primitives, markdown pipeline, SEO + LLM helpers", + "description": "Shared web/docs layout, search, SEO + LLM helpers (markdown pipeline lives in @tale/ui/markdown)", "type": "module", "exports": { ".": "./src/index.ts", @@ -21,21 +21,6 @@ "./llm/build-llms-txt": "./src/llm/build-llms-txt.ts", "./llm/build-llms-full-txt": "./src/llm/build-llms-full-txt.ts", "./llm/page-as-markdown": "./src/llm/page-as-markdown.ts", - "./markdown/markdown": "./src/markdown/markdown.tsx", - "./markdown/shiki": "./src/markdown/shiki.ts", - "./markdown/code-block": "./src/markdown/code-block.tsx", - "./markdown/anchored-heading": "./src/markdown/anchored-heading.tsx", - "./markdown/extract-toc": "./src/markdown/extract-toc.ts", - "./markdown/reading-time": "./src/markdown/reading-time.ts", - "./markdown/components/callout": "./src/markdown/components/callout.tsx", - "./markdown/components/code-group": "./src/markdown/components/code-group.tsx", - "./markdown/components/tabs": "./src/markdown/components/tabs.tsx", - "./markdown/components/steps": "./src/markdown/components/steps.tsx", - "./markdown/components/cards": "./src/markdown/components/cards.tsx", - "./markdown/components/frame": "./src/markdown/components/frame.tsx", - "./markdown/components/accordion": "./src/markdown/components/accordion.tsx", - "./markdown/components/mermaid": "./src/markdown/components/mermaid.tsx", - "./markdown/components/registry": "./src/markdown/components/registry.tsx", "./search/dialog": "./src/search/dialog.tsx", "./search/build-index": "./src/search/build-index.ts", "./search/client": "./src/search/client.ts", @@ -67,12 +52,7 @@ "@tanstack/react-router": "1.168.10", "framer-motion": "12.38.0", "lucide-react": "1.8.0", - "mermaid": "11.14.0", - "minisearch": "7.2.0", - "react-markdown": "10.1.0", - "rehype-raw": "7.0.0", - "remark-gfm": "4.0.1", - "shiki": "3.2.0" + "minisearch": "7.2.0" }, "peerDependencies": { "react": "*", diff --git a/packages/webui/postcss.config.mjs b/packages/webui/postcss.config.mjs new file mode 100644 index 0000000000..5d6d8457f7 --- /dev/null +++ b/packages/webui/postcss.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + '@tailwindcss/postcss': {}, + }, +}; + +export default config; diff --git a/packages/webui/src/index.ts b/packages/webui/src/index.ts index 211ce7b00a..62773246a7 100644 --- a/packages/webui/src/index.ts +++ b/packages/webui/src/index.ts @@ -1,3 +1,3 @@ -// Barrel: prefer subpath imports (e.g. `@tale/webui/markdown/markdown`). -// Kept for type re-exports only. +// Barrel: prefer subpath imports (e.g. `@tale/webui/seo/document-meta`). +// Kept for type re-exports only. Markdown lives in `@tale/ui/markdown`. export type { RegionalLocale, SupportedLocale } from '@tale/ui/i18n/locales'; diff --git a/packages/webui/src/markdown/code-block.stories.tsx b/packages/webui/src/markdown/code-block.stories.tsx deleted file mode 100644 index 57ab31dfa8..0000000000 --- a/packages/webui/src/markdown/code-block.stories.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react'; - -import { CodeBlock } from './code-block'; - -const meta = { - title: 'webui/markdown/CodeBlock', - component: CodeBlock, - tags: ['autodocs'], - parameters: { layout: 'padded' }, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -const TS_SAMPLE = `import { highlightCode } from '@tale/webui/markdown/shiki'; - -const result = await highlightCode( - 'console.log("hello tale");', - 'typescript', - 'light', -); -`; - -const BASH_SAMPLE = `# install dependencies -bun install - -# start the docs site on :3002 -bun run --filter @tale/docs dev -`; - -export const TypeScript: Story = { - args: { - code: TS_SAMPLE, - language: 'typescript', - filename: 'highlight.ts', - }, -}; - -export const Bash: Story = { - args: { - code: BASH_SAMPLE, - language: 'bash', - filename: 'README.md · quickstart', - }, -}; - -export const NoCopy: Story = { - args: { - code: TS_SAMPLE, - language: 'typescript', - hideCopy: true, - }, -}; diff --git a/packages/webui/src/markdown/code-block.tsx b/packages/webui/src/markdown/code-block.tsx deleted file mode 100644 index e32e46f3b0..0000000000 --- a/packages/webui/src/markdown/code-block.tsx +++ /dev/null @@ -1,178 +0,0 @@ -import { cn } from '@tale/ui/cn'; -import { useTheme } from '@tale/ui/theme'; -import { Check, Copy } from 'lucide-react'; -import { useEffect, useMemo, useState } from 'react'; - -import { highlightCode } from './shiki'; - -interface CodeBlockProps { - code: string; - language?: string; - /** Optional filename label rendered above the block. */ - filename?: string; - /** Hide the copy button (e.g. inside CodeGroup tabs). */ - hideCopy?: boolean; - className?: string; -} - -const LINE_NUMBER_THRESHOLD = 3; - -/** - * Add per-line background tints to Shiki's diff output. Shiki wraps each - * source line in ``; we tag those whose first - * visible character is `+` or `-` so CSS can paint the whole row. Using - * opacity-based backgrounds keeps the rendering legible in both themes. - */ -function applyDiffLineBackgrounds(html: string): string { - if (typeof DOMParser === 'undefined') return html; - const doc = new DOMParser().parseFromString(html, 'text/html'); - const lines = doc.querySelectorAll('.line'); - for (const line of lines) { - const first = line.textContent?.[0]; - if (first === '+') { - line.classList.add('diff-add', 'bg-green-500/15', 'dark:bg-green-500/10'); - } else if (first === '-') { - line.classList.add('diff-del', 'bg-red-500/15', 'dark:bg-red-500/10'); - } - } - // Promote the .line spans to block layout so the bg fills the row width. - // We do this via a wrapping
 rule injected by the consumer's CSS;
-  // here we just mark the container so styles can target it.
-  const pre = doc.querySelector('pre');
-  if (pre) pre.classList.add('shiki-diff');
-  return doc.body.innerHTML;
-}
-
-function countLines(code: string): number {
-  // Trim the trailing newline most fenced blocks carry so an N-line block
-  // doesn't render as N+1 numbers.
-  const normalised = code.endsWith('\n') ? code.slice(0, -1) : code;
-  return normalised.split('\n').length;
-}
-
-/**
- * Highlighted, copy-friendly code block. Falls back to a plain `
`
- * while Shiki loads in the background so the layout never shifts.
- *
- * The copy button lives in the header bar (always rendered) so single-line
- * blocks don't reflow when the button mounts. Line numbers appear for
- * blocks with more than three lines — small snippets stay clean.
- */
-export function CodeBlock({
-  code,
-  language,
-  filename,
-  hideCopy,
-  className,
-}: CodeBlockProps) {
-  const { resolvedTheme } = useTheme();
-  const [html, setHtml] = useState(null);
-  const [copied, setCopied] = useState(false);
-
-  useEffect(() => {
-    let cancelled = false;
-    void highlightCode(code, language, resolvedTheme).then((result) => {
-      if (cancelled) return;
-      setHtml(
-        result.language === 'diff'
-          ? applyDiffLineBackgrounds(result.html)
-          : result.html,
-      );
-    });
-    return () => {
-      cancelled = true;
-    };
-  }, [code, language, resolvedTheme]);
-
-  const handleCopy = async () => {
-    try {
-      await navigator.clipboard.writeText(code);
-      setCopied(true);
-      setTimeout(() => setCopied(false), 1500);
-    } catch (error) {
-      console.warn('[code-block] clipboard write failed', error);
-    }
-  };
-
-  const lineCount = useMemo(() => countLines(code), [code]);
-  const showLineNumbers = lineCount > LINE_NUMBER_THRESHOLD;
-  const lineNumbers = useMemo(
-    () =>
-      showLineNumbers
-        ? Array.from({ length: lineCount }, (_, i) => i + 1)
-        : null,
-    [lineCount, showLineNumbers],
-  );
-
-  // Always render the header bar so the layout doesn't shift between
-  // labelled and bare blocks. The label slot collapses to whitespace when
-  // we have neither a filename nor a language tag.
-  const showHeader = true;
-  const headerLabel = filename ?? language ?? '';
-
-  return (
-    
- {showHeader ? ( -
- - {headerLabel} - {language && filename ? ( - {language} - ) : null} - - {hideCopy ? null : ( - - )} -
- ) : null} -
-
- {showLineNumbers && lineNumbers ? ( -
- {lineNumbers.map((n) => ( -
{n}
- ))} -
- ) : null} - {html ? ( -
- ) : ( -
-              {code}
-            
- )} -
-
-
- ); -} diff --git a/packages/webui/src/markdown/components/frame.tsx b/packages/webui/src/markdown/components/frame.tsx deleted file mode 100644 index cad5d15999..0000000000 --- a/packages/webui/src/markdown/components/frame.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { cn } from '@tale/ui/cn'; -import type { ReactNode } from 'react'; - -interface FrameProps { - caption?: string; - children?: ReactNode; - className?: string; -} - -export function Frame({ caption, children, className }: FrameProps) { - return ( -
-
- {children} -
- {caption?.trim() ? ( -
- {caption} -
- ) : null} -
- ); -} diff --git a/packages/webui/src/markdown/markdown.stories.tsx b/packages/webui/src/markdown/markdown.stories.tsx deleted file mode 100644 index 6cf7aa7e95..0000000000 --- a/packages/webui/src/markdown/markdown.stories.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react'; - -import { mintlifyComponents } from './components/registry'; -import { Markdown } from './markdown'; - -const meta = { - title: 'webui/markdown/Markdown', - component: Markdown, - tags: ['autodocs'], - parameters: { layout: 'padded' }, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -const SAMPLE = `# Agent concepts - -The mental model behind Tale agents — instructions, knowledge, tools, and models. - -## Instructions - -Instructions tell the agent **how** to behave. Use sentence case. - -## Tools - -Tools are how the agent **does things**. Examples include: - -- HTTP fetchers -- Vector-store retrievers -- Webhook senders - -\`\`\`typescript -import { defineAgent } from '@tale/agent'; - -export const concierge = defineAgent({ - name: 'concierge', - model: 'claude-opus-4-7', -}); -\`\`\` - -> Looking for the API? See [/develop/api-reference](/develop/api-reference). -`; - -export const Sample: Story = { - args: { children: SAMPLE }, -}; - -export const WithMintlifyComponents: Story = { - args: { - children: SAMPLE, - // oxlint-disable-next-line typescript/no-explicit-any -- mintlify keys aren't HTML element tags - components: mintlifyComponents as any, - }, -}; diff --git a/packages/webui/src/markdown/shiki.ts b/packages/webui/src/markdown/shiki.ts deleted file mode 100644 index c251988c99..0000000000 --- a/packages/webui/src/markdown/shiki.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * Shared Shiki highlighter singleton. JS regex engine + statically-imported - * common grammars keep the bundle small while making sure every language we - * actually use in docs/markdown highlights without needing a runtime - * resolver — Vite cannot bundle `shiki/langs/${lang}.mjs` reliably from a - * dynamic specifier, so we declare the list up front. - */ - -import { createHighlighterCore, type HighlighterCore } from 'shiki/core'; -import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'; - -let highlighterPromise: Promise | null = null; - -function getHighlighter(): Promise { - if (!highlighterPromise) { - highlighterPromise = createHighlighterCore({ - themes: [ - import('shiki/themes/github-dark.mjs'), - import('shiki/themes/github-light.mjs'), - ], - langs: [ - import('shiki/langs/bash.mjs'), - import('shiki/langs/css.mjs'), - import('shiki/langs/diff.mjs'), - import('shiki/langs/docker.mjs'), - import('shiki/langs/dotenv.mjs'), - import('shiki/langs/html.mjs'), - import('shiki/langs/http.mjs'), - import('shiki/langs/javascript.mjs'), - import('shiki/langs/json.mjs'), - import('shiki/langs/jsx.mjs'), - import('shiki/langs/markdown.mjs'), - import('shiki/langs/nginx.mjs'), - import('shiki/langs/powershell.mjs'), - import('shiki/langs/python.mjs'), - import('shiki/langs/sql.mjs'), - import('shiki/langs/tsx.mjs'), - import('shiki/langs/typescript.mjs'), - import('shiki/langs/xml.mjs'), - import('shiki/langs/yaml.mjs'), - ], - engine: createJavaScriptRegexEngine(), - }).catch((error) => { - highlighterPromise = null; - throw error; - }); - } - return highlighterPromise; -} - -const LANG_ALIASES: Record = { - plaintext: 'text', - txt: 'text', - py: 'python', - pyi: 'python', - pyw: 'python', - js: 'javascript', - mjs: 'javascript', - cjs: 'javascript', - ts: 'typescript', - mts: 'typescript', - cts: 'typescript', - sh: 'bash', - zsh: 'bash', - shell: 'bash', - shellscript: 'bash', - ps1: 'powershell', - yml: 'yaml', - md: 'markdown', - htm: 'html', - dockerfile: 'docker', - env: 'dotenv', -}; - -function resolveLang(input: string | undefined): string { - if (!input) return 'text'; - const lower = input.toLowerCase(); - return LANG_ALIASES[lower] ?? lower; -} - -export interface HighlightResult { - html: string; - language: string; -} - -export async function highlightCode( - code: string, - lang: string | undefined, - theme: 'light' | 'dark' = 'light', -): Promise { - const resolved = resolveLang(lang); - const highlighter = await getHighlighter(); - const loaded = highlighter.getLoadedLanguages(); - const final = loaded.includes(resolved) ? resolved : 'text'; - const html = highlighter.codeToHtml(code, { - lang: final, - theme: theme === 'dark' ? 'github-dark' : 'github-light', - }); - return { html, language: final }; -} diff --git a/services/docs/app/components/docs/docs-toc.tsx b/services/docs/app/components/docs/docs-toc.tsx index 5d065568d6..807200bdcf 100644 --- a/services/docs/app/components/docs/docs-toc.tsx +++ b/services/docs/app/components/docs/docs-toc.tsx @@ -1,5 +1,5 @@ import { cn } from '@tale/ui/cn'; -import type { TocEntry } from '@tale/webui/markdown/extract-toc'; +import type { TocEntry } from '@tale/ui/markdown/extract-toc'; import { useEffect, useState } from 'react'; import { useT } from '@/lib/i18n/client'; diff --git a/services/docs/app/pages/docs-page.tsx b/services/docs/app/pages/docs-page.tsx index 2f37c92d9d..3cf512bdd6 100644 --- a/services/docs/app/pages/docs-page.tsx +++ b/services/docs/app/pages/docs-page.tsx @@ -1,9 +1,9 @@ +import { mintlifyComponents } from '@tale/ui/markdown/components/registry'; +import { extractToc } from '@tale/ui/markdown/extract-toc'; +import { readingTimeMinutes } from '@tale/ui/markdown/reading-time'; +import { RoutedMarkdown } from '@tale/ui/markdown/routed-markdown'; import { PageActions } from '@tale/webui/ai/page-actions'; import { pageAsMarkdown } from '@tale/webui/llm/page-as-markdown'; -import { mintlifyComponents } from '@tale/webui/markdown/components/registry'; -import { extractToc } from '@tale/webui/markdown/extract-toc'; -import { Markdown } from '@tale/webui/markdown/markdown'; -import { readingTimeMinutes } from '@tale/webui/markdown/reading-time'; import { useDocumentMeta } from '@tale/webui/seo/document-meta'; import { buildArticleJsonLd, @@ -205,13 +205,13 @@ export function DocsPage({ locale, slug }: DocsPageProps) { ) : null}

- {doc.body} - +
diff --git a/services/platform/app/features/chat/components/canvas/__tests__/canvas-pane.test.tsx b/services/platform/app/features/chat/components/canvas/__tests__/canvas-pane.test.tsx index 4791f6375b..9eabf789d7 100644 --- a/services/platform/app/features/chat/components/canvas/__tests__/canvas-pane.test.tsx +++ b/services/platform/app/features/chat/components/canvas/__tests__/canvas-pane.test.tsx @@ -75,7 +75,7 @@ vi.mock('@/app/components/theme/theme-provider', () => ({ })); vi.mock('@/lib/utils/shiki', () => ({ - highlightCode: vi.fn(() => Promise.resolve('')), + highlightCode: vi.fn(() => Promise.resolve({ html: '', language: 'text' })), })); function OpenCanvasButton() { diff --git a/services/platform/app/features/chat/components/canvas/canvas-code-renderer.tsx b/services/platform/app/features/chat/components/canvas/canvas-code-renderer.tsx index c1e677efe2..ecd6c76bab 100644 --- a/services/platform/app/features/chat/components/canvas/canvas-code-renderer.tsx +++ b/services/platform/app/features/chat/components/canvas/canvas-code-renderer.tsx @@ -156,7 +156,7 @@ function CanvasCodeRendererComponent({ let cancelled = false; void highlightCode(code, language, shikiTheme).then((result) => { if (!cancelled && result) { - setHtml(extractShikiCodeContent(result)); + setHtml(extractShikiCodeContent(result.html)); } }); return () => { diff --git a/services/platform/app/features/chat/components/message-bubble/__tests__/code-block.test.tsx b/services/platform/app/features/chat/components/message-bubble/__tests__/code-block.test.tsx index b711df31f7..74c156c2d4 100644 --- a/services/platform/app/features/chat/components/message-bubble/__tests__/code-block.test.tsx +++ b/services/platform/app/features/chat/components/message-bubble/__tests__/code-block.test.tsx @@ -13,12 +13,14 @@ vi.mock('@/app/hooks/use-toast', () => ({ useToast: () => ({ toast: vi.fn() }), })); -// Mock Shiki — returns a predictable HTML string +// Mock Shiki — returns the `{ html, language }` shape the shared +// `@tale/ui/markdown/shiki` exports so callers extracting `.html` work. vi.mock('@/lib/utils/shiki', () => ({ - highlightCode: vi.fn((code: string) => - Promise.resolve( - `
${code}
`, - ), + highlightCode: vi.fn((code: string, language: string) => + Promise.resolve({ + html: `
${code}
`, + language, + }), ), })); diff --git a/services/platform/app/features/chat/components/message-bubble/code-block.tsx b/services/platform/app/features/chat/components/message-bubble/code-block.tsx index 5ebf6d5c4d..10acf7c650 100644 --- a/services/platform/app/features/chat/components/message-bubble/code-block.tsx +++ b/services/platform/app/features/chat/components/message-bubble/code-block.tsx @@ -49,7 +49,7 @@ export const HighlightedCode = memo(function HighlightedCode({ void highlightCode(code, lang, shikiTheme).then((result) => { if (!cancelled && result) { highlightedForRef.current = code; - setHtml(extractShikiCodeContent(result)); + setHtml(extractShikiCodeContent(result.html)); } }); }, HIGHLIGHT_DEBOUNCE_MS); diff --git a/services/platform/app/features/chat/components/typewriter-text.tsx b/services/platform/app/features/chat/components/typewriter-text.tsx index 04ca1b0113..2a9640f242 100644 --- a/services/platform/app/features/chat/components/typewriter-text.tsx +++ b/services/platform/app/features/chat/components/typewriter-text.tsx @@ -34,12 +34,12 @@ * /> */ +import { IncrementalMarkdown } from '@tale/ui/markdown/streaming/incremental-markdown'; import { memo, useRef, useEffect } from 'react'; import type { MarkdownComponentMap } from '@/lib/utils/markdown-types'; import { isStreamFrozen, useStreamBuffer } from '../hooks/use-stream-buffer'; -import { IncrementalMarkdown } from './incremental-markdown'; // ============================================================================ // TYPES diff --git a/services/platform/app/features/documents/components/document-preview-text.tsx b/services/platform/app/features/documents/components/document-preview-text.tsx index de6ec40108..050adc806c 100644 --- a/services/platform/app/features/documents/components/document-preview-text.tsx +++ b/services/platform/app/features/documents/components/document-preview-text.tsx @@ -43,8 +43,8 @@ export function DocumentPreviewText({ let cancelled = false; const lang = resolveLanguage(ext); - void highlightCode(content, lang, shikiTheme).then((html) => { - if (!cancelled) setHighlightedHtml(html || null); + void highlightCode(content, lang, shikiTheme).then((result) => { + if (!cancelled) setHighlightedHtml(result?.html ?? null); }); return () => { cancelled = true; diff --git a/services/platform/lib/utils/markdown-types.ts b/services/platform/lib/utils/markdown-types.ts index d68adceb24..c0790335c5 100644 --- a/services/platform/lib/utils/markdown-types.ts +++ b/services/platform/lib/utils/markdown-types.ts @@ -1,6 +1,3 @@ -import type { ComponentType } from 'react'; - -// oxlint-disable-next-line typescript/no-explicit-any -- Required by react-markdown's Components interface which uses ComponentType -export type MarkdownComponentType = ComponentType; - -export type MarkdownComponentMap = Record; +// Re-exported from @tale/ui so callers can keep their existing import path +// while the canonical types live in the shared package. +export type { MarkdownComponentMap } from '@tale/ui/markdown/types'; diff --git a/services/platform/lib/utils/shiki.ts b/services/platform/lib/utils/shiki.ts index 388729ffd4..5b31554a96 100644 --- a/services/platform/lib/utils/shiki.ts +++ b/services/platform/lib/utils/shiki.ts @@ -1,173 +1,4 @@ -/** - * Shared Shiki syntax highlighter singleton. - * Uses shiki/core with the JavaScript regex engine to avoid bundling - * the full WASM-based oniguruma engine and all grammars/themes. - * Lazy-initialized with on-demand language loading to minimize bundle size. - * Used by chat code blocks and document text preview. - */ - -import { createHighlighterCore, type HighlighterCore } from 'shiki/core'; -import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'; - -let highlighterPromise: Promise | null = null; - -function getHighlighter(): Promise { - if (!highlighterPromise) { - highlighterPromise = createHighlighterCore({ - themes: [ - import('shiki/themes/github-dark.mjs'), - import('shiki/themes/github-light.mjs'), - ], - langs: [], - engine: createJavaScriptRegexEngine(), - }).catch((error) => { - highlighterPromise = null; - throw error; - }); - } - return highlighterPromise; -} - -const LANG_ALIASES: Record = { - // Plain text — Shiki's built-in no-highlight grammar is named `text` - // (no `langs/text.mjs` file ships); without this alias every undefined- - // language render warns about a missing `langs/plaintext.mjs`. - plaintext: 'text', - txt: 'text', - // Python - py: 'python', - pyi: 'python', - pyw: 'python', - // JavaScript/TypeScript - js: 'javascript', - jsx: 'jsx', - mjs: 'javascript', - cjs: 'javascript', - ts: 'typescript', - tsx: 'tsx', - mts: 'typescript', - cts: 'typescript', - // Systems - rs: 'rust', - rb: 'ruby', - kt: 'kotlin', - kts: 'kotlin', - // Shell - sh: 'bash', - zsh: 'bash', - fish: 'fish', - ps1: 'powershell', - bat: 'bat', - cmd: 'bat', - // Config / data - yml: 'yaml', - md: 'markdown', - mdx: 'mdx', - // C/C++ - cc: 'cpp', - cxx: 'cpp', - hpp: 'cpp', - hxx: 'cpp', - h: 'c', - // Web - htm: 'html', - scss: 'scss', - sass: 'sass', - less: 'less', - // Other - ex: 'elixir', - exs: 'elixir', - gql: 'graphql', - tex: 'latex', - latex: 'latex', - pl: 'perl', - pm: 'perl', - r: 'r', -}; - -export function resolveLanguage(langOrExt: string): string { - const lower = langOrExt.toLowerCase(); - return LANG_ALIASES[lower] || lower; -} - -/** - * Cap on the input size we'll synchronously tokenize on the main thread. - * Above this, callers should fall back to a plain-text render — Shiki's - * `codeToHtml` is O(n) but blocking, and on a 250 KB document the freeze - * runs 300 ms-2 s on a mid-range laptop. - * - * Hunk views call shiki per-hunk (≤ a few KB each), so they sit well below - * this cap; only the settled-source full-document path can hit it. - */ -const MAX_SHIKI_BYTES = 64_000; - -/** - * Tokenize `code` into highlighted HTML. Returns `null` when: - * - `code.length` exceeds `MAX_SHIKI_BYTES` (caller should plain-text) - * - the underlying highlighter fails to initialize or render - * - * Note: Shiki's `codeToHtml` produces HTML built from a fixed grammar set - * with all user content escaped via innerText — there is no path for user - * code to inject HTML attributes or scripts. We deliberately do NOT wrap - * the output in `DOMPurify.sanitize`: it costs ~100-400 ms on large - * documents while removing nothing Shiki itself emits. - */ -export async function highlightCode( - code: string, - lang: string, - theme: 'github-dark' | 'github-light' = 'github-dark', -): Promise { - if (code.length > MAX_SHIKI_BYTES) return null; - - let hl: HighlighterCore; - try { - hl = await getHighlighter(); - } catch (err) { - console.warn('[shiki] highlighter init failed:', err); - return null; - } - - const resolvedLang = resolveLanguage(lang); - - // Shiki's `text` grammar is a built-in no-highlight pass — there is no - // `shiki/langs/text.mjs` to load. Skip the load attempt entirely; without - // this short-circuit we'd hit the catch path and log a spurious warning - // for every plaintext render. - if (resolvedLang === 'text') { - try { - return hl.codeToHtml(code, { lang: 'text', theme }); - } catch (err) { - console.warn('[shiki] codeToHtml failed for lang="text":', err); - return null; - } - } - - const loadedLangs = hl.getLoadedLanguages(); - if (!loadedLangs.includes(resolvedLang)) { - try { - await hl.loadLanguage( - /* @vite-ignore */ import( - `shiki/langs/${resolvedLang}.mjs` - ) as Parameters[0], - ); - } catch (err) { - console.warn( - `[shiki] language "${resolvedLang}" not loadable, falling back to plaintext:`, - err, - ); - try { - return hl.codeToHtml(code, { lang: 'text', theme }); - } catch (htmlErr) { - console.warn('[shiki] plaintext fallback failed:', htmlErr); - return null; - } - } - } - - try { - return hl.codeToHtml(code, { lang: resolvedLang, theme }); - } catch (err) { - console.warn(`[shiki] codeToHtml failed for lang="${resolvedLang}":`, err); - return null; - } -} +// Re-exported from @tale/ui so existing call sites keep their import +// path while the canonical singleton (and language list) lives in the +// shared package. +export { highlightCode, resolveLanguage } from '@tale/ui/markdown/shiki'; diff --git a/services/platform/package.json b/services/platform/package.json index c4d5f5b8fe..912fcdb9da 100644 --- a/services/platform/package.json +++ b/services/platform/package.json @@ -22,7 +22,7 @@ "test:browser-e2e": "bunx vitest --run --project browser-e2e", "test:ui": "bunx vitest --run --config vitest.ui.config.ts", "test:ui:watch": "bunx vitest --config vitest.ui.config.ts", - "storybook": "bunx --bun storybook dev -p 6006", + "storybook": "bunx --bun storybook dev -p 6009", "storybook:build": "bunx --bun storybook build -o storybook-static", "stress-test": "bun stress-tests/run-stress-test.ts", "stress-test:concurrent": "bun stress-tests/scenarios/concurrent-starts.ts", @@ -105,10 +105,6 @@ "lucide-react": "1.8.0", "mammoth": "1.12.0", "mermaid": "11.14.0", - "micromark": "4.0.2", - "micromark-core-commonmark": "2.0.3", - "micromark-util-classify-character": "2.0.1", - "micromark-util-types": "2.0.2", "mssql": "12.2.1", "mustache": "4.2.0", "mysql2": "3.21.1", @@ -128,7 +124,6 @@ "rehype-sanitize": "6.0.0", "remark-gfm": "4.0.1", "safe-regex2": "5.1.1", - "shiki": "4.0.2", "striptags": "3.2.0", "sucrase": "3.35.1", "swagger-ui-react": "5.32.2", diff --git a/tools/plop/templates/react-package/postcss.config.mjs b/tools/plop/templates/react-package/postcss.config.mjs new file mode 100644 index 0000000000..5d6d8457f7 --- /dev/null +++ b/tools/plop/templates/react-package/postcss.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + '@tailwindcss/postcss': {}, + }, +}; + +export default config; diff --git a/turbo.json b/turbo.json index 0603f88271..1799d26fd4 100644 --- a/turbo.json +++ b/turbo.json @@ -89,6 +89,10 @@ "cache": false, "outputs": [] }, + "storybook": { + "cache": false, + "persistent": true + }, "storybook:build": { "outputs": ["storybook-static/**"] },