From 9c81a11a5f99c8cb92b9f99e23c31ec7a4a59c7d Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:29:35 -0400 Subject: [PATCH 1/3] fix: contain oversized Markdown math accessibly --- e2e/tests/smoke.blog.spec.ts | 55 +++++++++++++++++++ packages/cli/package.json | 2 +- packages/stack/package.json | 2 +- packages/stack/registry/btst-ai-chat.json | 4 +- packages/stack/registry/btst-blog.json | 4 +- .../shared/markdown-content-styles.css | 13 +++++ .../ui/src/components/markdown-content.tsx | 7 +++ packages/ui/src/styles/markdown-content.css | 13 +++++ 8 files changed, 94 insertions(+), 6 deletions(-) diff --git a/e2e/tests/smoke.blog.spec.ts b/e2e/tests/smoke.blog.spec.ts index 5d92167e8..5f70e9d8a 100644 --- a/e2e/tests/smoke.blog.spec.ts +++ b/e2e/tests/smoke.blog.spec.ts @@ -174,6 +174,61 @@ test("post detail page renders", async ({ page }) => { expect(errors, `Console errors detected: \n${errors.join("\n")}`).toEqual([]); }); +test("oversized math stays readable without overflowing the article", async ({ + page, + request, +}) => { + const longMath = String.raw`\underbrace{abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz}_{\text{long expression}}`; + await createPost(request, { + title: "Math layout regression", + slug: "math-layout-regression", + excerpt: "Inline and display math remain accessible on narrow screens.", + content: `Ordinary inline $E=mc^2$ stays in the paragraph.\n\nLong inline $${longMath}$ ends here.\n\n$$\n${longMath}\n$$\n\n| Value | Result |\n| --- | --- |\n| one | two |`, + published: true, + }); + await page.goto("/pages/blog/math-layout-regression", { + waitUntil: "networkidle", + }); + const math = page.locator(".markdown-body .katex"); + await expect(math).toHaveCount(3); + await page.evaluate(() => document.fonts.ready); + for (const width of [320, 390, 1280]) { + await page.setViewportSize({ width, height: 900 }); + await expect + .poll(() => + page.evaluate(() => document.documentElement.scrollWidth - innerWidth), + ) + .toBeLessThanOrEqual(1); + await expect(page.getByText("ends here.", { exact: false })).toBeVisible(); + await expect( + page.getByRole("cell", { name: "two", exact: true }), + ).toBeVisible(); + for (const expression of await math.all()) { + await expect(expression.locator("math")).toHaveCount(1); + await expect(expression.locator(".katex-html")).toHaveAttribute( + "aria-hidden", + "true", + ); + const bounds = await expression.evaluate((node) => ({ + width: node.clientWidth, + content: node.scrollWidth, + })); + if (bounds.content <= bounds.width + 1) continue; + await expression.focus(); + await expect(expression).toBeFocused(); + for (let step = 0; step < 20; step++) { + await page.keyboard.press("ArrowRight"); + await page.waitForTimeout(50); + } + await expect + .poll(() => + expression.evaluate((node) => node.scrollLeft + node.clientWidth), + ) + .toBeGreaterThanOrEqual(bounds.content - 1); + } + } +}); + test("sitemap.xml includes blog pages", async ({ request }) => { const response = await request.get("/sitemap.xml"); expect(response.ok()).toBeTruthy(); diff --git a/packages/cli/package.json b/packages/cli/package.json index 8d8aa33c0..fd49dd7d0 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@btst/codegen", - "version": "0.2.1", + "version": "0.2.2", "description": "BTST project scaffolding and CLI passthrough commands.", "repository": { "type": "git", diff --git a/packages/stack/package.json b/packages/stack/package.json index d4277e6c2..6aac68371 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -1,6 +1,6 @@ { "name": "@btst/stack", - "version": "3.0.2", + "version": "3.0.3", "description": "A composable, plugin-based library for building full-stack applications.", "repository": { "type": "git", diff --git a/packages/stack/registry/btst-ai-chat.json b/packages/stack/registry/btst-ai-chat.json index 16463d564..1b6c3883e 100644 --- a/packages/stack/registry/btst-ai-chat.json +++ b/packages/stack/registry/btst-ai-chat.json @@ -158,7 +158,7 @@ { "path": "ui/components/markdown-content.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport {\n\tcreateElement,\n\tisValidElement,\n\tuseMemo,\n\tuseRef,\n\tuseState,\n\tuseEffect,\n} from \"react\";\nimport type {\n\tComponentPropsWithoutRef,\n\tComponentType,\n\tMouseEventHandler,\n\tReactNode,\n} from \"react\";\nimport ReactMarkdown from \"react-markdown\";\nimport type { Components } from \"react-markdown\";\nimport rehypeHighlight from \"rehype-highlight\";\nimport rehypeRaw from \"rehype-raw\";\nimport remarkGfm from \"remark-gfm\";\nimport { CopyIcon, CheckIcon } from \"lucide-react\";\n\n// Default Link component\nconst DefaultLink = (props: React.ComponentProps<\"a\">) => {\n\treturn ;\n};\n\n// Default Image component\nconst DefaultImage = (props: React.ImgHTMLAttributes) => {\n\treturn ;\n};\n\n// Utility to detect if markdown contains math syntax\nfunction containsMath(markdown: string): boolean {\n\treturn (\n\t\t/\\$\\$[\\s\\S]+?\\$\\$/.test(markdown) || // Display math\n\t\t/(?;\n\t\treturn getNodeText(props.children as ReactNode);\n\t}\n\treturn \"\";\n}\n\n// Deterministic hash function for generating consistent IDs\nfunction simpleHash(str: string): string {\n\tlet hash = 0;\n\tfor (let i = 0; i < str.length; i++) {\n\t\tconst char = str.charCodeAt(i);\n\t\thash = (hash << 5) - hash + char;\n\t\thash = hash & hash;\n\t}\n\treturn Math.abs(hash).toString(36).slice(0, 6);\n}\n\nfunction isCheckboxElement(\n\tnode: ReactNode,\n): node is ReturnType {\n\treturn (\n\t\tisValidElement(node) &&\n\t\t(node.type as unknown) === \"input\" &&\n\t\t(node.props as { type?: string }).type === \"checkbox\"\n\t);\n}\n\nfunction createTaskListItemRenderer() {\n\treturn function LiRenderer(\n\t\tprops: React.LiHTMLAttributes & { children?: ReactNode },\n\t) {\n\t\tconst { className, children, ...rest } = props;\n\t\tconst isTaskItem = (className ?? \"\").split(\" \").includes(\"task-list-item\");\n\t\tif (!isTaskItem) {\n\t\t\treturn (\n\t\t\t\t
  • \n\t\t\t\t\t{children}\n\t\t\t\t
  • \n\t\t\t);\n\t\t}\n\n\t\tconst childArray = Array.isArray(children) ? children : [children];\n\t\tconst checkboxNode = childArray.find(isCheckboxElement);\n\t\tconst nonCheckboxChildren = childArray.filter((c) => !isCheckboxElement(c));\n\n\t\tconst labelText = getNodeText(nonCheckboxChildren as unknown as ReactNode);\n\t\tconst baseId = slugify(labelText || \"task-item\");\n\t\tconst hashSuffix = simpleHash(labelText || \"task-item\");\n\t\tconst uniqueId = `${baseId}-${hashSuffix}`;\n\n\t\treturn (\n\t\t\t
  • \n\t\t\t\t{checkboxNode\n\t\t\t\t\t? createElement(checkboxNode.type, {\n\t\t\t\t\t\t\t...checkboxNode.props,\n\t\t\t\t\t\t\tid: uniqueId,\n\t\t\t\t\t\t\t\"aria-label\": labelText || \"Task item\",\n\t\t\t\t\t\t})\n\t\t\t\t\t: null}\n\t\t\t\t\n\t\t\t
  • \n\t\t);\n\t};\n}\n\ntype HeadingTag = \"h1\" | \"h2\" | \"h3\" | \"h4\" | \"h5\" | \"h6\";\nfunction createHeadingRenderer(tag: T) {\n\treturn function HeadingRenderer(props: ComponentPropsWithoutRef) {\n\t\tconst { children, ...rest } = props as { children: ReactNode };\n\t\tconst text = getNodeText(children);\n\t\tconst id = slugify(text);\n\t\treturn createElement(\n\t\t\ttag,\n\t\t\t{ id, ...(rest as object) },\n\t\t\tchildren,\n\t\t\ttext ? (\n\t\t\t\t\n\t\t\t\t\t#\n\t\t\t\t
    \n\t\t\t) : null,\n\t\t);\n\t};\n}\n\ntype CodeProps = React.HTMLAttributes & {\n\tinline?: boolean;\n\tnode?: unknown;\n};\n\nfunction CodeRenderer({ inline, className, children, ...rest }: CodeProps) {\n\tconst hasLanguage = /language-([a-z0-9-]+)/i.test(className ?? \"\");\n\tconst isInline = inline ?? !hasLanguage;\n\tif (isInline) {\n\t\treturn (\n\t\t\t\n\t\t\t\t{children}\n\t\t\t\n\t\t);\n\t}\n\treturn (\n\t\t\n\t\t\t{children}\n\t\t\n\t);\n}\n\nfunction PreRenderer(props: React.HTMLAttributes) {\n\tconst { children, ...rest } = props;\n\tconst child = Array.isArray(children) ? children[0] : children;\n\tlet language: string | undefined;\n\tif (isValidElement(child)) {\n\t\tconst className = (child.props as { className?: string }).className;\n\t\tconst match = /language-([a-z0-9-]+)/i.exec(className ?? \"\");\n\t\tlanguage = match?.[1];\n\t}\n\tconst label = (language ?? \"text\").toUpperCase();\n\n\tconst preRef = useRef(null);\n\tconst [copied, setCopied] = useState(false);\n\tconst resetTimerRef = useRef(null);\n\n\tlet codeText = \"\";\n\tif (isValidElement(child)) {\n\t\tconst childProps = child.props as { children?: ReactNode };\n\t\tcodeText = getNodeText(childProps.children as ReactNode);\n\t}\n\tconst normalized = codeText.endsWith(\"\\n\") ? codeText.slice(0, -1) : codeText;\n\tconst lineCount = Math.max(1, normalized.split(\"\\n\").length);\n\tconst digitCount = String(lineCount).length;\n\t\n\tconst onCopy: MouseEventHandler = async (e) => {\n\t\te.preventDefault();\n\t\te.stopPropagation();\n\t\ttry {\n\t\t\tconst text = preRef.current?.textContent ?? \"\";\n\t\t\tif (\n\t\t\t\ttext &&\n\t\t\t\ttypeof navigator !== \"undefined\" &&\n\t\t\t\tnavigator.clipboard?.writeText\n\t\t\t) {\n\t\t\t\tawait navigator.clipboard.writeText(text);\n\t\t\t\tsetCopied(true);\n\t\t\t\tif (resetTimerRef.current) window.clearTimeout(resetTimerRef.current);\n\t\t\t\tresetTimerRef.current = window.setTimeout(() => {\n\t\t\t\t\tsetCopied(false);\n\t\t\t\t\tresetTimerRef.current = null;\n\t\t\t\t}, 2000);\n\t\t\t}\n\t\t} catch {}\n\t};\n\t\n\treturn (\n\t\t\n\t\t\t
    \n\t\t\t\t{label}\n\t\t\t\t\n\t\t\t\t\t{copied ? : }\n\t\t\t\t\n\t\t\t
    \n\t\t\t
    \n\t\t\t\t
      \n\t\t\t\t\t{Array.from({ length: lineCount }).map((_, idx) => (\n\t\t\t\t\t\t
    1. {idx + 1}
    2. \n\t\t\t\t\t))}\n\t\t\t\t
    \n\t\t\t\t
    \n\t\t\t\t\t{children}\n\t\t\t\t
    \n\t\t\t
    \n\t\t\n\t);\n}\n\nexport type MarkdownContentProps = {\n\t/** The markdown string to render */\n\tmarkdown: string;\n\t/** Additional CSS class name */\n\tclassName?: string;\n\t/** Variant for different use cases - 'default' for full blog posts, 'chat' for compact chat messages */\n\tvariant?: \"default\" | \"chat\";\n\t/** Custom Link component for navigation */\n\tLinkComponent?: ComponentType & Record>;\n\t/** Custom Image component for images */\n\tImageComponent?: ComponentType & Record>;\n};\n\nexport function MarkdownContent({\n\tmarkdown,\n\tclassName,\n\tvariant = \"default\",\n\tLinkComponent = DefaultLink,\n\tImageComponent = DefaultImage,\n}: MarkdownContentProps) {\n\tconst [mathPlugins, setMathPlugins] = useState<{\n\t\tremarkMath: unknown;\n\t\trehypeKatex: unknown;\n\t} | null>(null);\n\tconst [isLoadingMath, setIsLoadingMath] = useState(false);\n\n\tconst hasMath = useMemo(() => containsMath(markdown), [markdown]);\n\n\t// Dynamically load math plugins and CSS only if needed\n\tuseEffect(() => {\n\t\tif (!hasMath || mathPlugins || isLoadingMath) return;\n\n\t\tsetIsLoadingMath(true);\n\n\t\t// Dynamically inject KaTeX CSS into the document\n\t\tconst katexCSSId = \"katex-css\";\n\t\tif (typeof document !== \"undefined\" && !document.getElementById(katexCSSId)) {\n\t\t\tconst link = document.createElement(\"link\");\n\t\t\tlink.id = katexCSSId;\n\t\t\tlink.rel = \"stylesheet\";\n\t\t\tlink.href =\n\t\t\t\t\"https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css\";\n\t\t\tlink.integrity =\n\t\t\t\t\"sha384-nB0miv6/jRmo5UMMR1wu3Gz6NLsoTkbqJghGIsx//Rlm+ZU03BU6SQNC66uf4l5+\";\n\t\t\tlink.crossOrigin = \"anonymous\";\n\t\t\tdocument.head.appendChild(link);\n\n\t\t\t// Add font-display override for KaTeX fonts\n\t\t\tconst fontDisplayStyleId = \"katex-font-display-override\";\n\t\t\tif (!document.getElementById(fontDisplayStyleId)) {\n\t\t\t\tconst style = document.createElement(\"style\");\n\t\t\t\tstyle.id = fontDisplayStyleId;\n\t\t\t\tstyle.textContent = `\n\t\t\t\t\t@font-face {\n\t\t\t\t\t\tfont-family: 'KaTeX_Main';\n\t\t\t\t\t\tfont-style: normal;\n\t\t\t\t\t\tfont-weight: normal;\n\t\t\t\t\t\tfont-display: swap;\n\t\t\t\t\t\tsrc: url('https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/fonts/KaTeX_Main-Regular.woff2') format('woff2');\n\t\t\t\t\t}\n\t\t\t\t\t@font-face {\n\t\t\t\t\t\tfont-family: 'KaTeX_Math';\n\t\t\t\t\t\tfont-style: italic;\n\t\t\t\t\t\tfont-weight: normal;\n\t\t\t\t\t\tfont-display: swap;\n\t\t\t\t\t\tsrc: url('https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/fonts/KaTeX_Math-Italic.woff2') format('woff2');\n\t\t\t\t\t}\n\t\t\t\t`;\n\t\t\t\tdocument.head.appendChild(style);\n\t\t\t}\n\t\t}\n\n\t\tPromise.all([import(\"remark-math\"), import(\"rehype-katex\")])\n\t\t\t.then(([remarkMathModule, rehypeKatexModule]) => {\n\t\t\t\tsetMathPlugins({\n\t\t\t\t\tremarkMath: remarkMathModule.default,\n\t\t\t\t\trehypeKatex: rehypeKatexModule.default,\n\t\t\t\t});\n\t\t\t})\n\t\t\t.catch((error) => {\n\t\t\t\tconsole.error(\"Failed to load math plugins:\", error);\n\t\t\t})\n\t\t\t.finally(() => {\n\t\t\t\tsetIsLoadingMath(false);\n\t\t\t});\n\t}, [hasMath, mathPlugins, isLoadingMath]);\n\n\t// Create anchor renderer with custom Link component\n\tconst AnchorRenderer = useMemo(() => {\n\t\treturn function AnchorRendererComponent(\n\t\t\tprops: React.AnchorHTMLAttributes,\n\t\t) {\n\t\t\tconst { href = \"\", children, className: anchorClassName, ...rest } = props;\n\t\t\treturn (\n\t\t\t\t\n\t\t\t\t\t{children}\n\t\t\t\t\n\t\t\t);\n\t\t};\n\t}, [LinkComponent]);\n\n\t// Create image renderer with custom Image component\n\tconst ImgRenderer = useMemo(() => {\n\t\treturn function ImgRendererComponent(\n\t\t\tprops: React.ImgHTMLAttributes,\n\t\t) {\n\t\t\tconst {\n\t\t\t\tsrc = \"\",\n\t\t\t\talt = \"\",\n\t\t\t\tclassName: imgClassName,\n\t\t\t\twidth,\n\t\t\t\theight,\n\t\t\t\tstyle,\n\t\t\t\t...rest\n\t\t\t} = props;\n\n\t\t\tconst imageProps: React.ComponentProps = {\n\t\t\t\tsrc,\n\t\t\t\talt,\n\t\t\t\tclassName: imgClassName as string,\n\t\t\t\tstyle,\n\t\t\t\t...rest,\n\t\t\t};\n\n\t\t\tif (width != null && height != null) {\n\t\t\t\timageProps.width = width as number;\n\t\t\t\timageProps.height = height as number;\n\t\t\t} else {\n\t\t\t\treturn (\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t);\n\t\t\t}\n\n\t\t\treturn ;\n\t\t};\n\t}, [ImageComponent]);\n\n\tconst components = useMemo(() => {\n\t\treturn {\n\t\t\ta: AnchorRenderer,\n\t\t\timg: ImgRenderer,\n\t\t\tcode: CodeRenderer,\n\t\t\tpre: PreRenderer,\n\t\t\th1: createHeadingRenderer(\"h1\"),\n\t\t\th2: createHeadingRenderer(\"h2\"),\n\t\t\th3: createHeadingRenderer(\"h3\"),\n\t\t\th4: createHeadingRenderer(\"h4\"),\n\t\t\th5: createHeadingRenderer(\"h5\"),\n\t\t\th6: createHeadingRenderer(\"h6\"),\n\t\t\tli: createTaskListItemRenderer(),\n\t\t};\n\t}, [AnchorRenderer, ImgRenderer]);\n\n\tconst remarkPlugins = useMemo(() => {\n\t\tconst plugins: unknown[] = [remarkGfm];\n\t\tif (hasMath && mathPlugins?.remarkMath) {\n\t\t\tplugins.push(mathPlugins.remarkMath);\n\t\t}\n\t\treturn plugins as never;\n\t}, [hasMath, mathPlugins]);\n\n\tconst rehypePlugins = useMemo(() => {\n\t\tconst plugins: unknown[] = [rehypeRaw, rehypeHighlight];\n\t\tif (hasMath && mathPlugins?.rehypeKatex) {\n\t\t\tplugins.push(mathPlugins.rehypeKatex);\n\t\t}\n\t\treturn plugins as never;\n\t}, [hasMath, mathPlugins]);\n\n\tconst variantClass = variant === \"chat\" ? \"markdown-content-chat\" : \"\";\n\n\treturn (\n\t\t
    \n\t\t\t
    \n\t\t\t\t
    \n\t\t\t\t\t\n\t\t\t\t\t\t{markdown}\n\t\t\t\t\t\n\t\t\t\t
    \n\t\t\t
    \n\t\t
    \n\t);\n}\n", + "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport {\n\tcreateElement,\n\tisValidElement,\n\tuseMemo,\n\tuseRef,\n\tuseState,\n\tuseEffect,\n} from \"react\";\nimport type {\n\tComponentPropsWithoutRef,\n\tComponentType,\n\tMouseEventHandler,\n\tReactNode,\n} from \"react\";\nimport ReactMarkdown from \"react-markdown\";\nimport type { Components } from \"react-markdown\";\nimport rehypeHighlight from \"rehype-highlight\";\nimport rehypeRaw from \"rehype-raw\";\nimport remarkGfm from \"remark-gfm\";\nimport { CopyIcon, CheckIcon } from \"lucide-react\";\n\n// Default Link component\nconst DefaultLink = (props: React.ComponentProps<\"a\">) => {\n\treturn ;\n};\n\n// Default Image component\nconst DefaultImage = (props: React.ImgHTMLAttributes) => {\n\treturn ;\n};\n\n// Utility to detect if markdown contains math syntax\nfunction containsMath(markdown: string): boolean {\n\treturn (\n\t\t/\\$\\$[\\s\\S]+?\\$\\$/.test(markdown) || // Display math\n\t\t/(?;\n\t\treturn getNodeText(props.children as ReactNode);\n\t}\n\treturn \"\";\n}\n\n// Deterministic hash function for generating consistent IDs\nfunction simpleHash(str: string): string {\n\tlet hash = 0;\n\tfor (let i = 0; i < str.length; i++) {\n\t\tconst char = str.charCodeAt(i);\n\t\thash = (hash << 5) - hash + char;\n\t\thash = hash & hash;\n\t}\n\treturn Math.abs(hash).toString(36).slice(0, 6);\n}\n\nfunction isCheckboxElement(\n\tnode: ReactNode,\n): node is ReturnType {\n\treturn (\n\t\tisValidElement(node) &&\n\t\t(node.type as unknown) === \"input\" &&\n\t\t(node.props as { type?: string }).type === \"checkbox\"\n\t);\n}\n\nfunction createTaskListItemRenderer() {\n\treturn function LiRenderer(\n\t\tprops: React.LiHTMLAttributes & { children?: ReactNode },\n\t) {\n\t\tconst { className, children, ...rest } = props;\n\t\tconst isTaskItem = (className ?? \"\").split(\" \").includes(\"task-list-item\");\n\t\tif (!isTaskItem) {\n\t\t\treturn (\n\t\t\t\t
  • \n\t\t\t\t\t{children}\n\t\t\t\t
  • \n\t\t\t);\n\t\t}\n\n\t\tconst childArray = Array.isArray(children) ? children : [children];\n\t\tconst checkboxNode = childArray.find(isCheckboxElement);\n\t\tconst nonCheckboxChildren = childArray.filter((c) => !isCheckboxElement(c));\n\n\t\tconst labelText = getNodeText(nonCheckboxChildren as unknown as ReactNode);\n\t\tconst baseId = slugify(labelText || \"task-item\");\n\t\tconst hashSuffix = simpleHash(labelText || \"task-item\");\n\t\tconst uniqueId = `${baseId}-${hashSuffix}`;\n\n\t\treturn (\n\t\t\t
  • \n\t\t\t\t{checkboxNode\n\t\t\t\t\t? createElement(checkboxNode.type, {\n\t\t\t\t\t\t\t...checkboxNode.props,\n\t\t\t\t\t\t\tid: uniqueId,\n\t\t\t\t\t\t\t\"aria-label\": labelText || \"Task item\",\n\t\t\t\t\t\t})\n\t\t\t\t\t: null}\n\t\t\t\t\n\t\t\t
  • \n\t\t);\n\t};\n}\n\ntype HeadingTag = \"h1\" | \"h2\" | \"h3\" | \"h4\" | \"h5\" | \"h6\";\nfunction createHeadingRenderer(tag: T) {\n\treturn function HeadingRenderer(props: ComponentPropsWithoutRef) {\n\t\tconst { children, ...rest } = props as { children: ReactNode };\n\t\tconst text = getNodeText(children);\n\t\tconst id = slugify(text);\n\t\treturn createElement(\n\t\t\ttag,\n\t\t\t{ id, ...(rest as object) },\n\t\t\tchildren,\n\t\t\ttext ? (\n\t\t\t\t\n\t\t\t\t\t#\n\t\t\t\t
    \n\t\t\t) : null,\n\t\t);\n\t};\n}\n\ntype CodeProps = React.HTMLAttributes & {\n\tinline?: boolean;\n\tnode?: unknown;\n};\n\nfunction CodeRenderer({ inline, className, children, ...rest }: CodeProps) {\n\tconst hasLanguage = /language-([a-z0-9-]+)/i.test(className ?? \"\");\n\tconst isInline = inline ?? !hasLanguage;\n\tif (isInline) {\n\t\treturn (\n\t\t\t\n\t\t\t\t{children}\n\t\t\t\n\t\t);\n\t}\n\treturn (\n\t\t\n\t\t\t{children}\n\t\t\n\t);\n}\n\nfunction PreRenderer(props: React.HTMLAttributes) {\n\tconst { children, ...rest } = props;\n\tconst child = Array.isArray(children) ? children[0] : children;\n\tlet language: string | undefined;\n\tif (isValidElement(child)) {\n\t\tconst className = (child.props as { className?: string }).className;\n\t\tconst match = /language-([a-z0-9-]+)/i.exec(className ?? \"\");\n\t\tlanguage = match?.[1];\n\t}\n\tconst label = (language ?? \"text\").toUpperCase();\n\n\tconst preRef = useRef(null);\n\tconst [copied, setCopied] = useState(false);\n\tconst resetTimerRef = useRef(null);\n\n\tlet codeText = \"\";\n\tif (isValidElement(child)) {\n\t\tconst childProps = child.props as { children?: ReactNode };\n\t\tcodeText = getNodeText(childProps.children as ReactNode);\n\t}\n\tconst normalized = codeText.endsWith(\"\\n\") ? codeText.slice(0, -1) : codeText;\n\tconst lineCount = Math.max(1, normalized.split(\"\\n\").length);\n\tconst digitCount = String(lineCount).length;\n\t\n\tconst onCopy: MouseEventHandler = async (e) => {\n\t\te.preventDefault();\n\t\te.stopPropagation();\n\t\ttry {\n\t\t\tconst text = preRef.current?.textContent ?? \"\";\n\t\t\tif (\n\t\t\t\ttext &&\n\t\t\t\ttypeof navigator !== \"undefined\" &&\n\t\t\t\tnavigator.clipboard?.writeText\n\t\t\t) {\n\t\t\t\tawait navigator.clipboard.writeText(text);\n\t\t\t\tsetCopied(true);\n\t\t\t\tif (resetTimerRef.current) window.clearTimeout(resetTimerRef.current);\n\t\t\t\tresetTimerRef.current = window.setTimeout(() => {\n\t\t\t\t\tsetCopied(false);\n\t\t\t\t\tresetTimerRef.current = null;\n\t\t\t\t}, 2000);\n\t\t\t}\n\t\t} catch {}\n\t};\n\t\n\treturn (\n\t\t\n\t\t\t
    \n\t\t\t\t{label}\n\t\t\t\t\n\t\t\t\t\t{copied ? : }\n\t\t\t\t\n\t\t\t
    \n\t\t\t
    \n\t\t\t\t
      \n\t\t\t\t\t{Array.from({ length: lineCount }).map((_, idx) => (\n\t\t\t\t\t\t
    1. {idx + 1}
    2. \n\t\t\t\t\t))}\n\t\t\t\t
    \n\t\t\t\t
    \n\t\t\t\t\t{children}\n\t\t\t\t
    \n\t\t\t
    \n\t\t\n\t);\n}\n\nexport type MarkdownContentProps = {\n\t/** The markdown string to render */\n\tmarkdown: string;\n\t/** Additional CSS class name */\n\tclassName?: string;\n\t/** Variant for different use cases - 'default' for full blog posts, 'chat' for compact chat messages */\n\tvariant?: \"default\" | \"chat\";\n\t/** Custom Link component for navigation */\n\tLinkComponent?: ComponentType & Record>;\n\t/** Custom Image component for images */\n\tImageComponent?: ComponentType & Record>;\n};\n\nexport function MarkdownContent({\n\tmarkdown,\n\tclassName,\n\tvariant = \"default\",\n\tLinkComponent = DefaultLink,\n\tImageComponent = DefaultImage,\n}: MarkdownContentProps) {\n\tconst [mathPlugins, setMathPlugins] = useState<{\n\t\tremarkMath: unknown;\n\t\trehypeKatex: unknown;\n\t} | null>(null);\n\tconst [isLoadingMath, setIsLoadingMath] = useState(false);\n\n\tconst hasMath = useMemo(() => containsMath(markdown), [markdown]);\n\n\t// Dynamically load math plugins and CSS only if needed\n\tuseEffect(() => {\n\t\tif (!hasMath || mathPlugins || isLoadingMath) return;\n\n\t\tsetIsLoadingMath(true);\n\n\t\t// Dynamically inject KaTeX CSS into the document\n\t\tconst katexCSSId = \"katex-css\";\n\t\tif (typeof document !== \"undefined\" && !document.getElementById(katexCSSId)) {\n\t\t\tconst link = document.createElement(\"link\");\n\t\t\tlink.id = katexCSSId;\n\t\t\tlink.rel = \"stylesheet\";\n\t\t\tlink.href =\n\t\t\t\t\"https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css\";\n\t\t\tlink.integrity =\n\t\t\t\t\"sha384-nB0miv6/jRmo5UMMR1wu3Gz6NLsoTkbqJghGIsx//Rlm+ZU03BU6SQNC66uf4l5+\";\n\t\t\tlink.crossOrigin = \"anonymous\";\n\t\t\tdocument.head.appendChild(link);\n\n\t\t\t// Add font-display override for KaTeX fonts\n\t\t\tconst fontDisplayStyleId = \"katex-font-display-override\";\n\t\t\tif (!document.getElementById(fontDisplayStyleId)) {\n\t\t\t\tconst style = document.createElement(\"style\");\n\t\t\t\tstyle.id = fontDisplayStyleId;\n\t\t\t\tstyle.textContent = `\n\t\t\t\t\t@font-face {\n\t\t\t\t\t\tfont-family: 'KaTeX_Main';\n\t\t\t\t\t\tfont-style: normal;\n\t\t\t\t\t\tfont-weight: normal;\n\t\t\t\t\t\tfont-display: swap;\n\t\t\t\t\t\tsrc: url('https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/fonts/KaTeX_Main-Regular.woff2') format('woff2');\n\t\t\t\t\t}\n\t\t\t\t\t@font-face {\n\t\t\t\t\t\tfont-family: 'KaTeX_Math';\n\t\t\t\t\t\tfont-style: italic;\n\t\t\t\t\t\tfont-weight: normal;\n\t\t\t\t\t\tfont-display: swap;\n\t\t\t\t\t\tsrc: url('https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/fonts/KaTeX_Math-Italic.woff2') format('woff2');\n\t\t\t\t\t}\n\t\t\t\t`;\n\t\t\t\tdocument.head.appendChild(style);\n\t\t\t}\n\t\t}\n\n\t\tPromise.all([import(\"remark-math\"), import(\"rehype-katex\")])\n\t\t\t.then(([remarkMathModule, rehypeKatexModule]) => {\n\t\t\t\tsetMathPlugins({\n\t\t\t\t\tremarkMath: remarkMathModule.default,\n\t\t\t\t\trehypeKatex: rehypeKatexModule.default,\n\t\t\t\t});\n\t\t\t})\n\t\t\t.catch((error) => {\n\t\t\t\tconsole.error(\"Failed to load math plugins:\", error);\n\t\t\t})\n\t\t\t.finally(() => {\n\t\t\t\tsetIsLoadingMath(false);\n\t\t\t});\n\t}, [hasMath, mathPlugins, isLoadingMath]);\n\n\t// Create anchor renderer with custom Link component\n\tconst AnchorRenderer = useMemo(() => {\n\t\treturn function AnchorRendererComponent(\n\t\t\tprops: React.AnchorHTMLAttributes,\n\t\t) {\n\t\t\tconst { href = \"\", children, className: anchorClassName, ...rest } = props;\n\t\t\treturn (\n\t\t\t\t\n\t\t\t\t\t{children}\n\t\t\t\t\n\t\t\t);\n\t\t};\n\t}, [LinkComponent]);\n\n\t// Create image renderer with custom Image component\n\tconst ImgRenderer = useMemo(() => {\n\t\treturn function ImgRendererComponent(\n\t\t\tprops: React.ImgHTMLAttributes,\n\t\t) {\n\t\t\tconst {\n\t\t\t\tsrc = \"\",\n\t\t\t\talt = \"\",\n\t\t\t\tclassName: imgClassName,\n\t\t\t\twidth,\n\t\t\t\theight,\n\t\t\t\tstyle,\n\t\t\t\t...rest\n\t\t\t} = props;\n\n\t\t\tconst imageProps: React.ComponentProps = {\n\t\t\t\tsrc,\n\t\t\t\talt,\n\t\t\t\tclassName: imgClassName as string,\n\t\t\t\tstyle,\n\t\t\t\t...rest,\n\t\t\t};\n\n\t\t\tif (width != null && height != null) {\n\t\t\t\timageProps.width = width as number;\n\t\t\t\timageProps.height = height as number;\n\t\t\t} else {\n\t\t\t\treturn (\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t);\n\t\t\t}\n\n\t\t\treturn ;\n\t\t};\n\t}, [ImageComponent]);\n\n\tconst components = useMemo(() => {\n\t\treturn {\n\t\t\ta: AnchorRenderer,\n\t\t\timg: ImgRenderer,\n\t\t\tspan: ({ node: _node, className, ...props }) => (\n\t\t\t\t\n\t\t\t),\n\t\t\tcode: CodeRenderer,\n\t\t\tpre: PreRenderer,\n\t\t\th1: createHeadingRenderer(\"h1\"),\n\t\t\th2: createHeadingRenderer(\"h2\"),\n\t\t\th3: createHeadingRenderer(\"h3\"),\n\t\t\th4: createHeadingRenderer(\"h4\"),\n\t\t\th5: createHeadingRenderer(\"h5\"),\n\t\t\th6: createHeadingRenderer(\"h6\"),\n\t\t\tli: createTaskListItemRenderer(),\n\t\t};\n\t}, [AnchorRenderer, ImgRenderer]);\n\n\tconst remarkPlugins = useMemo(() => {\n\t\tconst plugins: unknown[] = [remarkGfm];\n\t\tif (hasMath && mathPlugins?.remarkMath) {\n\t\t\tplugins.push(mathPlugins.remarkMath);\n\t\t}\n\t\treturn plugins as never;\n\t}, [hasMath, mathPlugins]);\n\n\tconst rehypePlugins = useMemo(() => {\n\t\tconst plugins: unknown[] = [rehypeRaw, rehypeHighlight];\n\t\tif (hasMath && mathPlugins?.rehypeKatex) {\n\t\t\tplugins.push(mathPlugins.rehypeKatex);\n\t\t}\n\t\treturn plugins as never;\n\t}, [hasMath, mathPlugins]);\n\n\tconst variantClass = variant === \"chat\" ? \"markdown-content-chat\" : \"\";\n\n\treturn (\n\t\t
    \n\t\t\t
    \n\t\t\t\t
    \n\t\t\t\t\t\n\t\t\t\t\t\t{markdown}\n\t\t\t\t\t\n\t\t\t\t
    \n\t\t\t
    \n\t\t
    \n\t);\n}\n", "target": "src/components/ui/markdown-content.tsx" }, { @@ -170,7 +170,7 @@ { "path": "ui/styles/markdown-content.css", "type": "registry:file", - "content": ".markdown-content .markdown-inner .markdown-body {\n\tfont-family: var(--crepe-font-default);\n\tcolor: var(--crepe-color-on-background);\n\tbackground: var(--crepe-color-background);\n\tpadding: 0px;\n}\n\n.markdown-content .markdown-inner .markdown-body img {\n\tvertical-align: bottom;\n\tmax-width: 100%;\n}\n\n.markdown-content .markdown-inner .markdown-body .markdown-image-wrapper {\n\tdisplay: block;\n\tposition: relative;\n\twidth: 100%;\n\taspect-ratio: 16 / 9;\n\tmin-height: 200px;\n}\n\n.markdown-content .markdown-inner .markdown-body .markdown-image-wrapper > div {\n\twidth: 100%;\n\theight: 100%;\n}\n\n.markdown-content .markdown-inner .markdown-body .markdown-image-wrapper img {\n\tobject-fit: contain;\n}\n\n.markdown-content .markdown-inner .markdown-body h1,\n.markdown-content .markdown-inner .markdown-body h2,\n.markdown-content .markdown-inner .markdown-body h3,\n.markdown-content .markdown-inner .markdown-body h4,\n.markdown-content .markdown-inner .markdown-body h5,\n.markdown-content .markdown-inner .markdown-body h6 {\n\tfont-family: var(--crepe-font-title);\n\tfont-weight: 400;\n\tpadding: 2px 0;\n\tscroll-margin-top: 56px;\n}\n\n.markdown-content .markdown-inner .markdown-body h1 .heading-anchor,\n.markdown-content .markdown-inner .markdown-body h2 .heading-anchor,\n.markdown-content .markdown-inner .markdown-body h3 .heading-anchor,\n.markdown-content .markdown-inner .markdown-body h4 .heading-anchor,\n.markdown-content .markdown-inner .markdown-body h5 .heading-anchor,\n.markdown-content .markdown-inner .markdown-body h6 .heading-anchor {\n\topacity: 0;\n\tmargin-left: 8px;\n\tcolor: var(--crepe-color-on-surface-variant);\n\ttext-decoration: none;\n\ttransition: opacity 0.15s ease-in-out;\n}\n\n.markdown-content .markdown-inner .markdown-body h1:hover .heading-anchor,\n.markdown-content .markdown-inner .markdown-body h2:hover .heading-anchor,\n.markdown-content .markdown-inner .markdown-body h3:hover .heading-anchor,\n.markdown-content .markdown-inner .markdown-body h4:hover .heading-anchor,\n.markdown-content .markdown-inner .markdown-body h5:hover .heading-anchor,\n.markdown-content .markdown-inner .markdown-body h6:hover .heading-anchor {\n\topacity: 1;\n}\n\n.markdown-content .markdown-inner .markdown-body h1 {\n\tfont-size: 42px;\n\tline-height: 50px;\n\tmargin-top: 32px;\n}\n\n.markdown-content .markdown-inner .markdown-body h2 {\n\tfont-size: 36px;\n\tline-height: 44px;\n\tmargin-top: 28px;\n}\n\n.markdown-content .markdown-inner .markdown-body h3 {\n\tfont-size: 32px;\n\tline-height: 40px;\n\tmargin-top: 24px;\n}\n\n.markdown-content .markdown-inner .markdown-body h4 {\n\tfont-size: 28px;\n\tline-height: 36px;\n\tmargin-top: 20px;\n}\n\n.markdown-content .markdown-inner .markdown-body h5 {\n\tfont-size: 24px;\n\tline-height: 32px;\n\tmargin-top: 16px;\n}\n\n.markdown-content .markdown-inner .markdown-body h6 {\n\tfont-size: 18px;\n\tfont-weight: 700;\n\tline-height: 28px;\n\tmargin-top: 16px;\n}\n\n.markdown-content .markdown-inner .markdown-body p {\n\tfont-size: 16px;\n\tline-height: 24px;\n\tpadding: 4px 0;\n}\n\n.markdown-content .markdown-inner .markdown-body a {\n\tcolor: var(--crepe-color-primary);\n\ttext-decoration: underline;\n}\n\n.markdown-content .markdown-inner .markdown-body code {\n\tcolor: var(--crepe-color-inline-code);\n\tbackground: color-mix(\n\t\tin srgb,\n\t\tvar(--crepe-color-inline-area),\n\t\ttransparent 40%\n\t);\n\tfont-family: var(--crepe-font-code);\n\tpadding: 0 2px;\n\tborder-radius: 4px;\n\tfont-size: 87.5%;\n\tdisplay: inline-block;\n\tline-height: 1.4286;\n}\n\n.markdown-content .markdown-inner .markdown-body pre {\n\tbackground: color-mix(\n\t\tin srgb,\n\t\tvar(--crepe-color-inline-area),\n\t\ttransparent 40%\n\t);\n\tpadding: 10px;\n\tborder-radius: 4px;\n\toverflow-x: auto;\n}\n\n.markdown-content .markdown-inner .markdown-body pre code {\n\tpadding: 0;\n\tbackground: transparent;\n}\n\n/* Ensure highlight.js themes color tokens inside our containers */\n.markdown-content .markdown-inner .markdown-body pre code.hljs,\n.markdown-content .markdown-inner .markdown-body code.hljs {\n\tdisplay: block;\n\toverflow-x: auto;\n}\n\n/* Dark mode overrides for highlight.js tokens that have poor visibility */\n@media (prefers-color-scheme: dark) {\n\t.markdown-content .markdown-inner .markdown-body .hljs-attr {\n\t\tcolor: #ff9ac1;\n\t}\n\t.markdown-content .markdown-inner .markdown-body .hljs-params {\n\t\tcolor: #ffb86c;\n\t}\n\t.markdown-content .markdown-inner .markdown-body .hljs-punctuation {\n\t\tcolor: #c9d1d9;\n\t}\n}\n\n/* Also support Tailwind dark class for dark mode */\n.dark .markdown-content .markdown-inner .markdown-body .hljs-attr {\n\tcolor: #ff9ac1;\n}\n.dark .markdown-content .markdown-inner .markdown-body .hljs-params {\n\tcolor: #ffb86c;\n}\n.dark .markdown-content .markdown-inner .markdown-body .hljs-punctuation {\n\tcolor: #c9d1d9;\n}\n\n.markdown-content .markdown-inner .markdown-code-block {\n\tdisplay: block;\n\tposition: relative;\n\tpadding: 40px 8px 20px 20px;\n\tmargin: 4px 0;\n}\n\n.markdown-content .markdown-inner .markdown-code-block pre {\n\tbackground: transparent;\n\tpadding: 0;\n\tborder-radius: 0;\n}\n\n/* Layout wrapper to host gutter and code content side-by-side */\n.markdown-content .markdown-inner .markdown-code-block .code-content {\n\tdisplay: grid;\n\tgrid-template-columns: var(--code-gutter-width, 3ch) 1fr;\n\tgap: 0.35rem;\n\talign-items: stretch;\n}\n\n/* Gutter line numbers */\n.markdown-content .markdown-inner .markdown-code-block .line-numbers {\n\tlist-style: none;\n\tmargin: 0;\n\tuser-select: none;\n\tcolor: var(--crepe-color-on-surface-variant);\n\ttext-align: end;\n\tdisplay: flex;\n\tflex-direction: column;\n\tjustify-content: space-between;\n\talign-items: center;\n\theight: 100%;\n}\n\n.markdown-content .markdown-inner .markdown-code-block .line-numbers li {\n\tfont-family: var(--crepe-font-code);\n\tfont-size: 12px;\n}\n\n.markdown-content .markdown-inner .markdown-code-block .code-toolbar {\n\tposition: absolute;\n\ttop: 8px;\n\tleft: 18px;\n\tright: 18px;\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: space-between;\n\tpointer-events: none;\n\topacity: 0;\n\ttransition: opacity 0.15s ease-in-out;\n}\n\n.markdown-content .markdown-inner .markdown-code-block:hover .code-toolbar {\n\topacity: 1;\n}\n\n/* Also reveal toolbar when focused via keyboard for accessibility */\n.markdown-content .markdown-inner .markdown-code-block:focus-within .code-toolbar {\n\topacity: 1;\n}\n\n.markdown-content .markdown-inner .markdown-code-block .language-label {\n\tpointer-events: auto;\n\tfont-family: var(--crepe-font-default);\n\tfont-size: 12px;\n\tfont-weight: 600;\n\tline-height: 16px;\n\tcolor: var(--crepe-color-on-surface-variant);\n\tbackground: var(--crepe-color-surface-low);\n\tpadding: 2px 8px;\n\tborder-radius: 4px;\n}\n\n.markdown-content .markdown-inner .markdown-code-block .copy-button {\n\tpointer-events: auto;\n\tdisplay: inline-flex;\n\talign-items: center;\n\tjustify-content: center;\n\tbackground: var(--crepe-color-surface-low);\n\tcolor: var(--crepe-color-on-surface-variant);\n\tborder-radius: 4px;\n\tpadding: 4px 8px;\n\theight: 26px;\n\twidth: 32px;\n\tcursor: pointer;\n\ttransition: background-color 0.2s ease-in-out;\n}\n\n.markdown-content .markdown-inner .markdown-code-block .copy-button:hover {\n\tbackground: var(--crepe-color-hover);\n}\n\n.markdown-content .markdown-inner .markdown-body blockquote {\n\tposition: relative;\n\tpadding-left: 40px;\n\tpadding-top: 0;\n\tpadding-bottom: 0;\n\tbox-sizing: content-box;\n\tmargin: 4px 0;\n}\n\n.markdown-content .markdown-inner .markdown-body blockquote::before {\n\tcontent: \"\";\n\twidth: 4px;\n\tleft: 0;\n\ttop: 4px;\n\tbottom: 4px;\n\tposition: absolute;\n\tbackground: var(--crepe-color-selected);\n\tborder-radius: 100px;\n}\n\n.markdown-content .markdown-inner .markdown-body hr {\n\tborder: none;\n\tbackground-color: color-mix(\n\t\tin srgb,\n\t\tvar(--crepe-color-outline),\n\t\ttransparent 80%\n\t);\n\tbackground-clip: content-box;\n\tpadding: 6px 0;\n\theight: 13px;\n\tposition: relative;\n}\n\n.markdown-content .markdown-inner .markdown-body ul:not(.contains-task-list) {\n\tpadding: 0;\n\tlist-style-type: disc;\n\tmargin-left: 26px;\n}\n\n.markdown-content\n\t.markdown-inner\n\t.markdown-body\n\tul:not(.contains-task-list)\n\tli::marker {\n\tcolor: var(--crepe-color-outline);\n\tfont-size: 1.2em;\n}\n\n.markdown-content .markdown-inner .markdown-body ol {\n\tpadding: 0;\n\tlist-style-type: decimal;\n\tmargin-left: 20px;\n}\n\n.markdown-content .markdown-inner .markdown-body li.task-list-item {\n\tposition: relative;\n\tlist-style: none;\n\tpadding-left: 28px;\n}\n\n.markdown-content\n\t.markdown-inner\n\t.markdown-body\n\tli.task-list-item\n\tinput[type=\"checkbox\"] {\n\twidth: 20px;\n\theight: 20px;\n\tposition: absolute;\n\tleft: 0;\n\tmargin: 0;\n}\n\n.markdown-content .markdown-inner .markdown-body table {\n\tborder-collapse: collapse;\n\ttable-layout: fixed;\n\twidth: 100%;\n\tmargin: 4px 0;\n}\n\n.markdown-content .markdown-inner .markdown-body th,\n.markdown-content .markdown-inner .markdown-body td {\n\tborder: 1px solid\n\t\tcolor-mix(in srgb, var(--crepe-color-outline), transparent 80%);\n\tpadding: 4px 16px;\n\toverflow-wrap: anywhere;\n}\n\n/* Simplified chat variant - more compact for chat messages */\n.markdown-content-chat .markdown-inner .markdown-body {\n\tpadding: 0;\n}\n\n.markdown-content-chat .markdown-inner .markdown-body h1,\n.markdown-content-chat .markdown-inner .markdown-body h2,\n.markdown-content-chat .markdown-inner .markdown-body h3,\n.markdown-content-chat .markdown-inner .markdown-body h4,\n.markdown-content-chat .markdown-inner .markdown-body h5,\n.markdown-content-chat .markdown-inner .markdown-body h6 {\n\tmargin-top: 12px;\n}\n\n.markdown-content-chat .markdown-inner .markdown-body h1 {\n\tfont-size: 24px;\n\tline-height: 32px;\n}\n\n.markdown-content-chat .markdown-inner .markdown-body h2 {\n\tfont-size: 20px;\n\tline-height: 28px;\n}\n\n.markdown-content-chat .markdown-inner .markdown-body h3 {\n\tfont-size: 18px;\n\tline-height: 26px;\n}\n\n.markdown-content-chat .markdown-inner .markdown-body p {\n\tfont-size: 14px;\n\tline-height: 22px;\n\tpadding: 2px 0;\n}\n\n.markdown-content-chat .markdown-inner .markdown-code-block {\n\tpadding: 32px 8px 12px 12px;\n}\n", + "content": ".markdown-content .markdown-inner .markdown-body {\n\tfont-family: var(--crepe-font-default);\n\tcolor: var(--crepe-color-on-background);\n\tbackground: var(--crepe-color-background);\n\tpadding: 0px;\n}\n\n.markdown-content .markdown-inner .markdown-body img {\n\tvertical-align: bottom;\n\tmax-width: 100%;\n}\n\n.markdown-content .markdown-inner .markdown-body .markdown-image-wrapper {\n\tdisplay: block;\n\tposition: relative;\n\twidth: 100%;\n\taspect-ratio: 16 / 9;\n\tmin-height: 200px;\n}\n\n.markdown-content .markdown-inner .markdown-body .markdown-image-wrapper > div {\n\twidth: 100%;\n\theight: 100%;\n}\n\n.markdown-content .markdown-inner .markdown-body .markdown-image-wrapper img {\n\tobject-fit: contain;\n}\n\n.markdown-content .markdown-inner .markdown-body h1,\n.markdown-content .markdown-inner .markdown-body h2,\n.markdown-content .markdown-inner .markdown-body h3,\n.markdown-content .markdown-inner .markdown-body h4,\n.markdown-content .markdown-inner .markdown-body h5,\n.markdown-content .markdown-inner .markdown-body h6 {\n\tfont-family: var(--crepe-font-title);\n\tfont-weight: 400;\n\tpadding: 2px 0;\n\tscroll-margin-top: 56px;\n}\n\n.markdown-content .markdown-inner .markdown-body h1 .heading-anchor,\n.markdown-content .markdown-inner .markdown-body h2 .heading-anchor,\n.markdown-content .markdown-inner .markdown-body h3 .heading-anchor,\n.markdown-content .markdown-inner .markdown-body h4 .heading-anchor,\n.markdown-content .markdown-inner .markdown-body h5 .heading-anchor,\n.markdown-content .markdown-inner .markdown-body h6 .heading-anchor {\n\topacity: 0;\n\tmargin-left: 8px;\n\tcolor: var(--crepe-color-on-surface-variant);\n\ttext-decoration: none;\n\ttransition: opacity 0.15s ease-in-out;\n}\n\n.markdown-content .markdown-inner .markdown-body h1:hover .heading-anchor,\n.markdown-content .markdown-inner .markdown-body h2:hover .heading-anchor,\n.markdown-content .markdown-inner .markdown-body h3:hover .heading-anchor,\n.markdown-content .markdown-inner .markdown-body h4:hover .heading-anchor,\n.markdown-content .markdown-inner .markdown-body h5:hover .heading-anchor,\n.markdown-content .markdown-inner .markdown-body h6:hover .heading-anchor {\n\topacity: 1;\n}\n\n.markdown-content .markdown-inner .markdown-body h1 {\n\tfont-size: 42px;\n\tline-height: 50px;\n\tmargin-top: 32px;\n}\n\n.markdown-content .markdown-inner .markdown-body h2 {\n\tfont-size: 36px;\n\tline-height: 44px;\n\tmargin-top: 28px;\n}\n\n.markdown-content .markdown-inner .markdown-body h3 {\n\tfont-size: 32px;\n\tline-height: 40px;\n\tmargin-top: 24px;\n}\n\n.markdown-content .markdown-inner .markdown-body h4 {\n\tfont-size: 28px;\n\tline-height: 36px;\n\tmargin-top: 20px;\n}\n\n.markdown-content .markdown-inner .markdown-body h5 {\n\tfont-size: 24px;\n\tline-height: 32px;\n\tmargin-top: 16px;\n}\n\n.markdown-content .markdown-inner .markdown-body h6 {\n\tfont-size: 18px;\n\tfont-weight: 700;\n\tline-height: 28px;\n\tmargin-top: 16px;\n}\n\n.markdown-content .markdown-inner .markdown-body p {\n\tfont-size: 16px;\n\tline-height: 24px;\n\tpadding: 4px 0;\n}\n\n.markdown-content .markdown-inner .markdown-body a {\n\tcolor: var(--crepe-color-primary);\n\ttext-decoration: underline;\n}\n\n.markdown-content .markdown-inner .markdown-body code {\n\tcolor: var(--crepe-color-inline-code);\n\tbackground: color-mix(\n\t\tin srgb,\n\t\tvar(--crepe-color-inline-area),\n\t\ttransparent 40%\n\t);\n\tfont-family: var(--crepe-font-code);\n\tpadding: 0 2px;\n\tborder-radius: 4px;\n\tfont-size: 87.5%;\n\tdisplay: inline-block;\n\tline-height: 1.4286;\n}\n\n.markdown-content .markdown-inner .markdown-body pre {\n\tbackground: color-mix(\n\t\tin srgb,\n\t\tvar(--crepe-color-inline-area),\n\t\ttransparent 40%\n\t);\n\tpadding: 10px;\n\tborder-radius: 4px;\n\toverflow-x: auto;\n}\n\n.markdown-content .markdown-inner .markdown-body pre code {\n\tpadding: 0;\n\tbackground: transparent;\n}\n\n/* Ensure highlight.js themes color tokens inside our containers */\n.markdown-content .markdown-inner .markdown-body pre code.hljs,\n.markdown-content .markdown-inner .markdown-body code.hljs {\n\tdisplay: block;\n\toverflow-x: auto;\n}\n\n/* Dark mode overrides for highlight.js tokens that have poor visibility */\n@media (prefers-color-scheme: dark) {\n\t.markdown-content .markdown-inner .markdown-body .hljs-attr {\n\t\tcolor: #ff9ac1;\n\t}\n\t.markdown-content .markdown-inner .markdown-body .hljs-params {\n\t\tcolor: #ffb86c;\n\t}\n\t.markdown-content .markdown-inner .markdown-body .hljs-punctuation {\n\t\tcolor: #c9d1d9;\n\t}\n}\n\n/* Also support Tailwind dark class for dark mode */\n.dark .markdown-content .markdown-inner .markdown-body .hljs-attr {\n\tcolor: #ff9ac1;\n}\n.dark .markdown-content .markdown-inner .markdown-body .hljs-params {\n\tcolor: #ffb86c;\n}\n.dark .markdown-content .markdown-inner .markdown-body .hljs-punctuation {\n\tcolor: #c9d1d9;\n}\n\n.markdown-content .markdown-inner .markdown-code-block {\n\tdisplay: block;\n\tposition: relative;\n\tpadding: 40px 8px 20px 20px;\n\tmargin: 4px 0;\n}\n\n.markdown-content .markdown-inner .markdown-code-block pre {\n\tbackground: transparent;\n\tpadding: 0;\n\tborder-radius: 0;\n}\n\n/* Layout wrapper to host gutter and code content side-by-side */\n.markdown-content .markdown-inner .markdown-code-block .code-content {\n\tdisplay: grid;\n\tgrid-template-columns: var(--code-gutter-width, 3ch) 1fr;\n\tgap: 0.35rem;\n\talign-items: stretch;\n}\n\n/* Gutter line numbers */\n.markdown-content .markdown-inner .markdown-code-block .line-numbers {\n\tlist-style: none;\n\tmargin: 0;\n\tuser-select: none;\n\tcolor: var(--crepe-color-on-surface-variant);\n\ttext-align: end;\n\tdisplay: flex;\n\tflex-direction: column;\n\tjustify-content: space-between;\n\talign-items: center;\n\theight: 100%;\n}\n\n.markdown-content .markdown-inner .markdown-code-block .line-numbers li {\n\tfont-family: var(--crepe-font-code);\n\tfont-size: 12px;\n}\n\n.markdown-content .markdown-inner .markdown-code-block .code-toolbar {\n\tposition: absolute;\n\ttop: 8px;\n\tleft: 18px;\n\tright: 18px;\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: space-between;\n\tpointer-events: none;\n\topacity: 0;\n\ttransition: opacity 0.15s ease-in-out;\n}\n\n.markdown-content .markdown-inner .markdown-code-block:hover .code-toolbar {\n\topacity: 1;\n}\n\n/* Also reveal toolbar when focused via keyboard for accessibility */\n.markdown-content .markdown-inner .markdown-code-block:focus-within .code-toolbar {\n\topacity: 1;\n}\n\n.markdown-content .markdown-inner .markdown-code-block .language-label {\n\tpointer-events: auto;\n\tfont-family: var(--crepe-font-default);\n\tfont-size: 12px;\n\tfont-weight: 600;\n\tline-height: 16px;\n\tcolor: var(--crepe-color-on-surface-variant);\n\tbackground: var(--crepe-color-surface-low);\n\tpadding: 2px 8px;\n\tborder-radius: 4px;\n}\n\n.markdown-content .markdown-inner .markdown-code-block .copy-button {\n\tpointer-events: auto;\n\tdisplay: inline-flex;\n\talign-items: center;\n\tjustify-content: center;\n\tbackground: var(--crepe-color-surface-low);\n\tcolor: var(--crepe-color-on-surface-variant);\n\tborder-radius: 4px;\n\tpadding: 4px 8px;\n\theight: 26px;\n\twidth: 32px;\n\tcursor: pointer;\n\ttransition: background-color 0.2s ease-in-out;\n}\n\n.markdown-content .markdown-inner .markdown-code-block .copy-button:hover {\n\tbackground: var(--crepe-color-hover);\n}\n\n.markdown-content .markdown-inner .markdown-body blockquote {\n\tposition: relative;\n\tpadding-left: 40px;\n\tpadding-top: 0;\n\tpadding-bottom: 0;\n\tbox-sizing: content-box;\n\tmargin: 4px 0;\n}\n\n.markdown-content .markdown-inner .markdown-body blockquote::before {\n\tcontent: \"\";\n\twidth: 4px;\n\tleft: 0;\n\ttop: 4px;\n\tbottom: 4px;\n\tposition: absolute;\n\tbackground: var(--crepe-color-selected);\n\tborder-radius: 100px;\n}\n\n.markdown-content .markdown-inner .markdown-body hr {\n\tborder: none;\n\tbackground-color: color-mix(\n\t\tin srgb,\n\t\tvar(--crepe-color-outline),\n\t\ttransparent 80%\n\t);\n\tbackground-clip: content-box;\n\tpadding: 6px 0;\n\theight: 13px;\n\tposition: relative;\n}\n\n.markdown-content .markdown-inner .markdown-body ul:not(.contains-task-list) {\n\tpadding: 0;\n\tlist-style-type: disc;\n\tmargin-left: 26px;\n}\n\n.markdown-content\n\t.markdown-inner\n\t.markdown-body\n\tul:not(.contains-task-list)\n\tli::marker {\n\tcolor: var(--crepe-color-outline);\n\tfont-size: 1.2em;\n}\n\n.markdown-content .markdown-inner .markdown-body ol {\n\tpadding: 0;\n\tlist-style-type: decimal;\n\tmargin-left: 20px;\n}\n\n.markdown-content .markdown-inner .markdown-body li.task-list-item {\n\tposition: relative;\n\tlist-style: none;\n\tpadding-left: 28px;\n}\n\n.markdown-content\n\t.markdown-inner\n\t.markdown-body\n\tli.task-list-item\n\tinput[type=\"checkbox\"] {\n\twidth: 20px;\n\theight: 20px;\n\tposition: absolute;\n\tleft: 0;\n\tmargin: 0;\n}\n\n.markdown-content .markdown-inner .markdown-body table {\n\tborder-collapse: collapse;\n\ttable-layout: fixed;\n\twidth: 100%;\n\tmargin: 4px 0;\n}\n\n.markdown-content .markdown-inner .markdown-body th,\n.markdown-content .markdown-inner .markdown-body td {\n\tborder: 1px solid\n\t\tcolor-mix(in srgb, var(--crepe-color-outline), transparent 80%);\n\tpadding: 4px 16px;\n\toverflow-wrap: anywhere;\n}\n\n.markdown-content .markdown-inner .markdown-body .katex {\n\tdisplay: inline-block;\n\tmax-width: 100%;\n\toverflow-x: auto;\n\ttext-align: start;\n\tvertical-align: middle;\n}\n\n.markdown-content .markdown-inner .markdown-body .katex:focus-visible {\n\toutline: 2px solid currentColor;\n\toutline-offset: 2px;\n}\n\n/* Simplified chat variant - more compact for chat messages */\n.markdown-content-chat .markdown-inner .markdown-body {\n\tpadding: 0;\n}\n\n.markdown-content-chat .markdown-inner .markdown-body h1,\n.markdown-content-chat .markdown-inner .markdown-body h2,\n.markdown-content-chat .markdown-inner .markdown-body h3,\n.markdown-content-chat .markdown-inner .markdown-body h4,\n.markdown-content-chat .markdown-inner .markdown-body h5,\n.markdown-content-chat .markdown-inner .markdown-body h6 {\n\tmargin-top: 12px;\n}\n\n.markdown-content-chat .markdown-inner .markdown-body h1 {\n\tfont-size: 24px;\n\tline-height: 32px;\n}\n\n.markdown-content-chat .markdown-inner .markdown-body h2 {\n\tfont-size: 20px;\n\tline-height: 28px;\n}\n\n.markdown-content-chat .markdown-inner .markdown-body h3 {\n\tfont-size: 18px;\n\tline-height: 26px;\n}\n\n.markdown-content-chat .markdown-inner .markdown-body p {\n\tfont-size: 14px;\n\tline-height: 22px;\n\tpadding: 2px 0;\n}\n\n.markdown-content-chat .markdown-inner .markdown-code-block {\n\tpadding: 32px 8px 12px 12px;\n}\n", "target": "src/styles/markdown-content.css" } ], diff --git a/packages/stack/registry/btst-blog.json b/packages/stack/registry/btst-blog.json index 1454e2a34..efe81e770 100644 --- a/packages/stack/registry/btst-blog.json +++ b/packages/stack/registry/btst-blog.json @@ -262,7 +262,7 @@ { "path": "btst/blog/client/components/shared/markdown-content-styles.css", "type": "registry:file", - "content": "/* Blog-specific overrides for the shared markdown-content component */\n/* These styles extend/override the base styles from @workspace/ui/markdown-content.css */\n\n.markdown-content .markdown-inner .markdown-body {\n\tfont-family: var(--crepe-font-default);\n\tcolor: var(--crepe-color-on-background);\n\tbackground: var(--crepe-color-background);\n\tpadding: 0px;\n}\n\n.markdown-content .markdown-inner .markdown-body img {\n\tvertical-align: bottom;\n\tmax-width: 100%;\n}\n\n.markdown-content .markdown-inner .markdown-body .markdown-image-wrapper {\n\tdisplay: block;\n\tposition: relative;\n\twidth: 100%;\n\taspect-ratio: 16 / 9;\n\tmin-height: 200px;\n}\n\n.markdown-content .markdown-inner .markdown-body .markdown-image-wrapper > div {\n\twidth: 100%;\n\theight: 100%;\n}\n\n.markdown-content .markdown-inner .markdown-body .markdown-image-wrapper img {\n\tobject-fit: contain;\n}\n\n.markdown-content .markdown-inner .markdown-body h1,\n.markdown-content .markdown-inner .markdown-body h2,\n.markdown-content .markdown-inner .markdown-body h3,\n.markdown-content .markdown-inner .markdown-body h4,\n.markdown-content .markdown-inner .markdown-body h5,\n.markdown-content .markdown-inner .markdown-body h6 {\n\tfont-family: var(--crepe-font-title);\n\tfont-weight: 400;\n\tpadding: 2px 0;\n\tscroll-margin-top: 56px;\n}\n\n.markdown-content .markdown-inner .markdown-body h1 .heading-anchor,\n.markdown-content .markdown-inner .markdown-body h2 .heading-anchor,\n.markdown-content .markdown-inner .markdown-body h3 .heading-anchor,\n.markdown-content .markdown-inner .markdown-body h4 .heading-anchor,\n.markdown-content .markdown-inner .markdown-body h5 .heading-anchor,\n.markdown-content .markdown-inner .markdown-body h6 .heading-anchor {\n\topacity: 0;\n\tmargin-left: 8px;\n\tcolor: var(--crepe-color-on-surface-variant);\n\ttext-decoration: none;\n\ttransition: opacity 0.15s ease-in-out;\n}\n\n.markdown-content .markdown-inner .markdown-body h1:hover .heading-anchor,\n.markdown-content .markdown-inner .markdown-body h2:hover .heading-anchor,\n.markdown-content .markdown-inner .markdown-body h3:hover .heading-anchor,\n.markdown-content .markdown-inner .markdown-body h4:hover .heading-anchor,\n.markdown-content .markdown-inner .markdown-body h5:hover .heading-anchor,\n.markdown-content .markdown-inner .markdown-body h6:hover .heading-anchor {\n\topacity: 1;\n}\n\n.markdown-content .markdown-inner .markdown-body h1 {\n\tfont-size: 42px;\n\tline-height: 50px;\n\tmargin-top: 32px;\n}\n\n.markdown-content .markdown-inner .markdown-body h2 {\n\tfont-size: 36px;\n\tline-height: 44px;\n\tmargin-top: 28px;\n}\n\n.markdown-content .markdown-inner .markdown-body h3 {\n\tfont-size: 32px;\n\tline-height: 40px;\n\tmargin-top: 24px;\n}\n\n.markdown-content .markdown-inner .markdown-body h4 {\n\tfont-size: 28px;\n\tline-height: 36px;\n\tmargin-top: 20px;\n}\n\n.markdown-content .markdown-inner .markdown-body h5 {\n\tfont-size: 24px;\n\tline-height: 32px;\n\tmargin-top: 16px;\n}\n\n.markdown-content .markdown-inner .markdown-body h6 {\n\tfont-size: 18px;\n\tfont-weight: 700;\n\tline-height: 28px;\n\tmargin-top: 16px;\n}\n\n.markdown-content .markdown-inner .markdown-body p {\n\tfont-size: 16px;\n\tline-height: 24px;\n\tpadding: 4px 0;\n}\n\n.markdown-content .markdown-inner .markdown-body a {\n\tcolor: var(--crepe-color-primary);\n\ttext-decoration: underline;\n}\n\n.markdown-content .markdown-inner .markdown-body code {\n\tcolor: var(--crepe-color-inline-code);\n\tbackground: color-mix(\n\t\tin srgb,\n\t\tvar(--crepe-color-inline-area),\n\t\ttransparent 40%\n\t);\n\tfont-family: var(--crepe-font-code);\n\tpadding: 0 2px;\n\tborder-radius: 4px;\n\tfont-size: 87.5%;\n\tdisplay: inline-block;\n\tline-height: 1.4286;\n}\n\n.markdown-content .markdown-inner .markdown-body pre {\n\tbackground: color-mix(\n\t\tin srgb,\n\t\tvar(--crepe-color-inline-area),\n\t\ttransparent 40%\n\t);\n\tpadding: 10px;\n\tborder-radius: 4px;\n\toverflow-x: auto;\n}\n\n.markdown-content .markdown-inner .markdown-body pre code {\n\tpadding: 0;\n\tbackground: transparent;\n}\n\n/* Ensure highlight.js themes color tokens inside our containers */\n.markdown-content .markdown-inner .markdown-body pre code.hljs,\n.markdown-content .markdown-inner .markdown-body code.hljs {\n\tdisplay: block;\n\toverflow-x: auto;\n}\n\n/* Dark mode overrides for highlight.js tokens that have poor visibility */\n@media (prefers-color-scheme: dark) {\n\t.markdown-content .markdown-inner .markdown-body .hljs-attr {\n\t\tcolor: #ff9ac1;\n\t}\n\t.markdown-content .markdown-inner .markdown-body .hljs-params {\n\t\tcolor: #ffb86c;\n\t}\n\t.markdown-content .markdown-inner .markdown-body .hljs-punctuation {\n\t\tcolor: #c9d1d9;\n\t}\n}\n\n/* Also support Tailwind dark class for dark mode */\n.dark .markdown-content .markdown-inner .markdown-body .hljs-attr {\n\tcolor: #ff9ac1;\n}\n.dark .markdown-content .markdown-inner .markdown-body .hljs-params {\n\tcolor: #ffb86c;\n}\n.dark .markdown-content .markdown-inner .markdown-body .hljs-punctuation {\n\tcolor: #c9d1d9;\n}\n\n.markdown-content .markdown-inner .markdown-code-block {\n\tdisplay: block;\n\tposition: relative;\n\tpadding: 40px 8px 20px 20px;\n\tmargin: 4px 0;\n}\n\n.markdown-content .markdown-inner .markdown-code-block pre {\n\tbackground: transparent;\n\tpadding: 0;\n\tborder-radius: 0;\n}\n\n/* Layout wrapper to host gutter and code content side-by-side */\n.markdown-content .markdown-inner .markdown-code-block .code-content {\n\tdisplay: grid;\n\tgrid-template-columns: var(--code-gutter-width, 3ch) 1fr;\n\tgap: 0.35rem;\n\talign-items: stretch;\n}\n\n/* Gutter line numbers */\n.markdown-content .markdown-inner .markdown-code-block .line-numbers {\n\tlist-style: none;\n\tmargin: 0;\n\tuser-select: none;\n\tcolor: var(--crepe-color-on-surface-variant);\n\ttext-align: end;\n\tdisplay: flex;\n\tflex-direction: column;\n\tjustify-content: space-between;\n\talign-items: center;\n\theight: 100%;\n}\n\n.markdown-content .markdown-inner .markdown-code-block .line-numbers li {\n\tfont-family: var(--crepe-font-code);\n\tfont-size: 12px;\n}\n\n.markdown-content .markdown-inner .markdown-code-block .code-toolbar {\n\tposition: absolute;\n\ttop: 8px;\n\tleft: 18px;\n\tright: 18px;\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: space-between;\n\tpointer-events: none;\n\topacity: 0;\n\ttransition: opacity 0.15s ease-in-out;\n}\n\n.markdown-content .markdown-inner .markdown-code-block:hover .code-toolbar {\n\topacity: 1;\n}\n\n/* Also reveal toolbar when focused via keyboard for accessibility */\n.markdown-content .markdown-inner\n\t.markdown-code-block:focus-within\n\t.code-toolbar {\n\topacity: 1;\n}\n\n.markdown-content .markdown-inner .markdown-code-block .language-label {\n\tpointer-events: auto;\n\tfont-family: var(--crepe-font-default);\n\tfont-size: 12px;\n\tfont-weight: 600;\n\tline-height: 16px;\n\tcolor: var(--crepe-color-on-surface-variant);\n\tbackground: var(--crepe-color-surface-low);\n\tpadding: 2px 8px;\n\tborder-radius: 4px;\n}\n\n.markdown-content .markdown-inner .markdown-code-block .copy-button {\n\tpointer-events: auto;\n\tdisplay: inline-flex;\n\talign-items: center;\n\tjustify-content: center;\n\tbackground: var(--crepe-color-surface-low);\n\tcolor: var(--crepe-color-on-surface-variant);\n\tborder-radius: 4px;\n\tpadding: 4px 8px;\n\theight: 26px;\n\twidth: 32px;\n\tcursor: pointer;\n\ttransition: background-color 0.2s ease-in-out;\n}\n\n.markdown-content .markdown-inner .markdown-code-block .copy-button:hover {\n\tbackground: var(--crepe-color-hover);\n}\n\n.markdown-content .markdown-inner .markdown-body blockquote {\n\tposition: relative;\n\tpadding-left: 40px;\n\tpadding-top: 0;\n\tpadding-bottom: 0;\n\tbox-sizing: content-box;\n\tmargin: 4px 0;\n}\n\n.markdown-content .markdown-inner .markdown-body blockquote::before {\n\tcontent: \"\";\n\twidth: 4px;\n\tleft: 0;\n\ttop: 4px;\n\tbottom: 4px;\n\tposition: absolute;\n\tbackground: var(--crepe-color-selected);\n\tborder-radius: 100px;\n}\n\n.markdown-content .markdown-inner .markdown-body hr {\n\tborder: none;\n\tbackground-color: color-mix(\n\t\tin srgb,\n\t\tvar(--crepe-color-outline),\n\t\ttransparent 80%\n\t);\n\tbackground-clip: content-box;\n\tpadding: 6px 0;\n\theight: 13px;\n\tposition: relative;\n}\n\n.markdown-content .markdown-inner .markdown-body ul:not(.contains-task-list) {\n\tpadding: 0;\n\tlist-style-type: disc;\n\tmargin-left: 26px;\n}\n\n.markdown-content\n\t.markdown-inner\n\t.markdown-body\n\tul:not(.contains-task-list)\n\tli::marker {\n\tcolor: var(--crepe-color-outline);\n\tfont-size: 1.2em;\n}\n\n.markdown-content .markdown-inner .markdown-body ol {\n\tpadding: 0;\n\tlist-style-type: decimal;\n\tmargin-left: 20px;\n}\n\n.markdown-content .markdown-inner .markdown-body li.task-list-item {\n\tposition: relative;\n\tlist-style: none;\n\tpadding-left: 28px;\n}\n\n.markdown-content\n\t.markdown-inner\n\t.markdown-body\n\tli.task-list-item\n\tinput[type=\"checkbox\"] {\n\twidth: 20px;\n\theight: 20px;\n\tposition: absolute;\n\tleft: 0;\n\tmargin: 0;\n}\n\n.markdown-content .markdown-inner .markdown-body table {\n\tborder-collapse: collapse;\n\ttable-layout: fixed;\n\twidth: 100%;\n\tmargin: 4px 0;\n}\n\n.markdown-content .markdown-inner .markdown-body th,\n.markdown-content .markdown-inner .markdown-body td {\n\tborder: 1px solid\n\t\tcolor-mix(in srgb, var(--crepe-color-outline), transparent 80%);\n\tpadding: 4px 16px;\n\toverflow-wrap: anywhere;\n}\n", + "content": "/* Blog-specific overrides for the shared markdown-content component */\n/* These styles extend/override the base styles from @workspace/ui/markdown-content.css */\n\n.markdown-content .markdown-inner .markdown-body {\n\tfont-family: var(--crepe-font-default);\n\tcolor: var(--crepe-color-on-background);\n\tbackground: var(--crepe-color-background);\n\tpadding: 0px;\n}\n\n.markdown-content .markdown-inner .markdown-body img {\n\tvertical-align: bottom;\n\tmax-width: 100%;\n}\n\n.markdown-content .markdown-inner .markdown-body .katex {\n\tdisplay: inline-block;\n\tmax-width: 100%;\n\toverflow-x: auto;\n\ttext-align: start;\n\tvertical-align: middle;\n}\n\n.markdown-content .markdown-inner .markdown-body .katex:focus-visible {\n\toutline: 2px solid currentColor;\n\toutline-offset: 2px;\n}\n\n.markdown-content .markdown-inner .markdown-body .markdown-image-wrapper {\n\tdisplay: block;\n\tposition: relative;\n\twidth: 100%;\n\taspect-ratio: 16 / 9;\n\tmin-height: 200px;\n}\n\n.markdown-content .markdown-inner .markdown-body .markdown-image-wrapper > div {\n\twidth: 100%;\n\theight: 100%;\n}\n\n.markdown-content .markdown-inner .markdown-body .markdown-image-wrapper img {\n\tobject-fit: contain;\n}\n\n.markdown-content .markdown-inner .markdown-body h1,\n.markdown-content .markdown-inner .markdown-body h2,\n.markdown-content .markdown-inner .markdown-body h3,\n.markdown-content .markdown-inner .markdown-body h4,\n.markdown-content .markdown-inner .markdown-body h5,\n.markdown-content .markdown-inner .markdown-body h6 {\n\tfont-family: var(--crepe-font-title);\n\tfont-weight: 400;\n\tpadding: 2px 0;\n\tscroll-margin-top: 56px;\n}\n\n.markdown-content .markdown-inner .markdown-body h1 .heading-anchor,\n.markdown-content .markdown-inner .markdown-body h2 .heading-anchor,\n.markdown-content .markdown-inner .markdown-body h3 .heading-anchor,\n.markdown-content .markdown-inner .markdown-body h4 .heading-anchor,\n.markdown-content .markdown-inner .markdown-body h5 .heading-anchor,\n.markdown-content .markdown-inner .markdown-body h6 .heading-anchor {\n\topacity: 0;\n\tmargin-left: 8px;\n\tcolor: var(--crepe-color-on-surface-variant);\n\ttext-decoration: none;\n\ttransition: opacity 0.15s ease-in-out;\n}\n\n.markdown-content .markdown-inner .markdown-body h1:hover .heading-anchor,\n.markdown-content .markdown-inner .markdown-body h2:hover .heading-anchor,\n.markdown-content .markdown-inner .markdown-body h3:hover .heading-anchor,\n.markdown-content .markdown-inner .markdown-body h4:hover .heading-anchor,\n.markdown-content .markdown-inner .markdown-body h5:hover .heading-anchor,\n.markdown-content .markdown-inner .markdown-body h6:hover .heading-anchor {\n\topacity: 1;\n}\n\n.markdown-content .markdown-inner .markdown-body h1 {\n\tfont-size: 42px;\n\tline-height: 50px;\n\tmargin-top: 32px;\n}\n\n.markdown-content .markdown-inner .markdown-body h2 {\n\tfont-size: 36px;\n\tline-height: 44px;\n\tmargin-top: 28px;\n}\n\n.markdown-content .markdown-inner .markdown-body h3 {\n\tfont-size: 32px;\n\tline-height: 40px;\n\tmargin-top: 24px;\n}\n\n.markdown-content .markdown-inner .markdown-body h4 {\n\tfont-size: 28px;\n\tline-height: 36px;\n\tmargin-top: 20px;\n}\n\n.markdown-content .markdown-inner .markdown-body h5 {\n\tfont-size: 24px;\n\tline-height: 32px;\n\tmargin-top: 16px;\n}\n\n.markdown-content .markdown-inner .markdown-body h6 {\n\tfont-size: 18px;\n\tfont-weight: 700;\n\tline-height: 28px;\n\tmargin-top: 16px;\n}\n\n.markdown-content .markdown-inner .markdown-body p {\n\tfont-size: 16px;\n\tline-height: 24px;\n\tpadding: 4px 0;\n}\n\n.markdown-content .markdown-inner .markdown-body a {\n\tcolor: var(--crepe-color-primary);\n\ttext-decoration: underline;\n}\n\n.markdown-content .markdown-inner .markdown-body code {\n\tcolor: var(--crepe-color-inline-code);\n\tbackground: color-mix(\n\t\tin srgb,\n\t\tvar(--crepe-color-inline-area),\n\t\ttransparent 40%\n\t);\n\tfont-family: var(--crepe-font-code);\n\tpadding: 0 2px;\n\tborder-radius: 4px;\n\tfont-size: 87.5%;\n\tdisplay: inline-block;\n\tline-height: 1.4286;\n}\n\n.markdown-content .markdown-inner .markdown-body pre {\n\tbackground: color-mix(\n\t\tin srgb,\n\t\tvar(--crepe-color-inline-area),\n\t\ttransparent 40%\n\t);\n\tpadding: 10px;\n\tborder-radius: 4px;\n\toverflow-x: auto;\n}\n\n.markdown-content .markdown-inner .markdown-body pre code {\n\tpadding: 0;\n\tbackground: transparent;\n}\n\n/* Ensure highlight.js themes color tokens inside our containers */\n.markdown-content .markdown-inner .markdown-body pre code.hljs,\n.markdown-content .markdown-inner .markdown-body code.hljs {\n\tdisplay: block;\n\toverflow-x: auto;\n}\n\n/* Dark mode overrides for highlight.js tokens that have poor visibility */\n@media (prefers-color-scheme: dark) {\n\t.markdown-content .markdown-inner .markdown-body .hljs-attr {\n\t\tcolor: #ff9ac1;\n\t}\n\t.markdown-content .markdown-inner .markdown-body .hljs-params {\n\t\tcolor: #ffb86c;\n\t}\n\t.markdown-content .markdown-inner .markdown-body .hljs-punctuation {\n\t\tcolor: #c9d1d9;\n\t}\n}\n\n/* Also support Tailwind dark class for dark mode */\n.dark .markdown-content .markdown-inner .markdown-body .hljs-attr {\n\tcolor: #ff9ac1;\n}\n.dark .markdown-content .markdown-inner .markdown-body .hljs-params {\n\tcolor: #ffb86c;\n}\n.dark .markdown-content .markdown-inner .markdown-body .hljs-punctuation {\n\tcolor: #c9d1d9;\n}\n\n.markdown-content .markdown-inner .markdown-code-block {\n\tdisplay: block;\n\tposition: relative;\n\tpadding: 40px 8px 20px 20px;\n\tmargin: 4px 0;\n}\n\n.markdown-content .markdown-inner .markdown-code-block pre {\n\tbackground: transparent;\n\tpadding: 0;\n\tborder-radius: 0;\n}\n\n/* Layout wrapper to host gutter and code content side-by-side */\n.markdown-content .markdown-inner .markdown-code-block .code-content {\n\tdisplay: grid;\n\tgrid-template-columns: var(--code-gutter-width, 3ch) 1fr;\n\tgap: 0.35rem;\n\talign-items: stretch;\n}\n\n/* Gutter line numbers */\n.markdown-content .markdown-inner .markdown-code-block .line-numbers {\n\tlist-style: none;\n\tmargin: 0;\n\tuser-select: none;\n\tcolor: var(--crepe-color-on-surface-variant);\n\ttext-align: end;\n\tdisplay: flex;\n\tflex-direction: column;\n\tjustify-content: space-between;\n\talign-items: center;\n\theight: 100%;\n}\n\n.markdown-content .markdown-inner .markdown-code-block .line-numbers li {\n\tfont-family: var(--crepe-font-code);\n\tfont-size: 12px;\n}\n\n.markdown-content .markdown-inner .markdown-code-block .code-toolbar {\n\tposition: absolute;\n\ttop: 8px;\n\tleft: 18px;\n\tright: 18px;\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: space-between;\n\tpointer-events: none;\n\topacity: 0;\n\ttransition: opacity 0.15s ease-in-out;\n}\n\n.markdown-content .markdown-inner .markdown-code-block:hover .code-toolbar {\n\topacity: 1;\n}\n\n/* Also reveal toolbar when focused via keyboard for accessibility */\n.markdown-content .markdown-inner\n\t.markdown-code-block:focus-within\n\t.code-toolbar {\n\topacity: 1;\n}\n\n.markdown-content .markdown-inner .markdown-code-block .language-label {\n\tpointer-events: auto;\n\tfont-family: var(--crepe-font-default);\n\tfont-size: 12px;\n\tfont-weight: 600;\n\tline-height: 16px;\n\tcolor: var(--crepe-color-on-surface-variant);\n\tbackground: var(--crepe-color-surface-low);\n\tpadding: 2px 8px;\n\tborder-radius: 4px;\n}\n\n.markdown-content .markdown-inner .markdown-code-block .copy-button {\n\tpointer-events: auto;\n\tdisplay: inline-flex;\n\talign-items: center;\n\tjustify-content: center;\n\tbackground: var(--crepe-color-surface-low);\n\tcolor: var(--crepe-color-on-surface-variant);\n\tborder-radius: 4px;\n\tpadding: 4px 8px;\n\theight: 26px;\n\twidth: 32px;\n\tcursor: pointer;\n\ttransition: background-color 0.2s ease-in-out;\n}\n\n.markdown-content .markdown-inner .markdown-code-block .copy-button:hover {\n\tbackground: var(--crepe-color-hover);\n}\n\n.markdown-content .markdown-inner .markdown-body blockquote {\n\tposition: relative;\n\tpadding-left: 40px;\n\tpadding-top: 0;\n\tpadding-bottom: 0;\n\tbox-sizing: content-box;\n\tmargin: 4px 0;\n}\n\n.markdown-content .markdown-inner .markdown-body blockquote::before {\n\tcontent: \"\";\n\twidth: 4px;\n\tleft: 0;\n\ttop: 4px;\n\tbottom: 4px;\n\tposition: absolute;\n\tbackground: var(--crepe-color-selected);\n\tborder-radius: 100px;\n}\n\n.markdown-content .markdown-inner .markdown-body hr {\n\tborder: none;\n\tbackground-color: color-mix(\n\t\tin srgb,\n\t\tvar(--crepe-color-outline),\n\t\ttransparent 80%\n\t);\n\tbackground-clip: content-box;\n\tpadding: 6px 0;\n\theight: 13px;\n\tposition: relative;\n}\n\n.markdown-content .markdown-inner .markdown-body ul:not(.contains-task-list) {\n\tpadding: 0;\n\tlist-style-type: disc;\n\tmargin-left: 26px;\n}\n\n.markdown-content\n\t.markdown-inner\n\t.markdown-body\n\tul:not(.contains-task-list)\n\tli::marker {\n\tcolor: var(--crepe-color-outline);\n\tfont-size: 1.2em;\n}\n\n.markdown-content .markdown-inner .markdown-body ol {\n\tpadding: 0;\n\tlist-style-type: decimal;\n\tmargin-left: 20px;\n}\n\n.markdown-content .markdown-inner .markdown-body li.task-list-item {\n\tposition: relative;\n\tlist-style: none;\n\tpadding-left: 28px;\n}\n\n.markdown-content\n\t.markdown-inner\n\t.markdown-body\n\tli.task-list-item\n\tinput[type=\"checkbox\"] {\n\twidth: 20px;\n\theight: 20px;\n\tposition: absolute;\n\tleft: 0;\n\tmargin: 0;\n}\n\n.markdown-content .markdown-inner .markdown-body table {\n\tborder-collapse: collapse;\n\ttable-layout: fixed;\n\twidth: 100%;\n\tmargin: 4px 0;\n}\n\n.markdown-content .markdown-inner .markdown-body th,\n.markdown-content .markdown-inner .markdown-body td {\n\tborder: 1px solid\n\t\tcolor-mix(in srgb, var(--crepe-color-outline), transparent 80%);\n\tpadding: 4px 16px;\n\toverflow-wrap: anywhere;\n}\n", "target": "src/components/btst/blog/client/components/shared/markdown-content-styles.css" }, { @@ -412,7 +412,7 @@ { "path": "ui/components/markdown-content.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport {\n\tcreateElement,\n\tisValidElement,\n\tuseMemo,\n\tuseRef,\n\tuseState,\n\tuseEffect,\n} from \"react\";\nimport type {\n\tComponentPropsWithoutRef,\n\tComponentType,\n\tMouseEventHandler,\n\tReactNode,\n} from \"react\";\nimport ReactMarkdown from \"react-markdown\";\nimport type { Components } from \"react-markdown\";\nimport rehypeHighlight from \"rehype-highlight\";\nimport rehypeRaw from \"rehype-raw\";\nimport remarkGfm from \"remark-gfm\";\nimport { CopyIcon, CheckIcon } from \"lucide-react\";\n\n// Default Link component\nconst DefaultLink = (props: React.ComponentProps<\"a\">) => {\n\treturn ;\n};\n\n// Default Image component\nconst DefaultImage = (props: React.ImgHTMLAttributes) => {\n\treturn ;\n};\n\n// Utility to detect if markdown contains math syntax\nfunction containsMath(markdown: string): boolean {\n\treturn (\n\t\t/\\$\\$[\\s\\S]+?\\$\\$/.test(markdown) || // Display math\n\t\t/(?;\n\t\treturn getNodeText(props.children as ReactNode);\n\t}\n\treturn \"\";\n}\n\n// Deterministic hash function for generating consistent IDs\nfunction simpleHash(str: string): string {\n\tlet hash = 0;\n\tfor (let i = 0; i < str.length; i++) {\n\t\tconst char = str.charCodeAt(i);\n\t\thash = (hash << 5) - hash + char;\n\t\thash = hash & hash;\n\t}\n\treturn Math.abs(hash).toString(36).slice(0, 6);\n}\n\nfunction isCheckboxElement(\n\tnode: ReactNode,\n): node is ReturnType {\n\treturn (\n\t\tisValidElement(node) &&\n\t\t(node.type as unknown) === \"input\" &&\n\t\t(node.props as { type?: string }).type === \"checkbox\"\n\t);\n}\n\nfunction createTaskListItemRenderer() {\n\treturn function LiRenderer(\n\t\tprops: React.LiHTMLAttributes & { children?: ReactNode },\n\t) {\n\t\tconst { className, children, ...rest } = props;\n\t\tconst isTaskItem = (className ?? \"\").split(\" \").includes(\"task-list-item\");\n\t\tif (!isTaskItem) {\n\t\t\treturn (\n\t\t\t\t
  • \n\t\t\t\t\t{children}\n\t\t\t\t
  • \n\t\t\t);\n\t\t}\n\n\t\tconst childArray = Array.isArray(children) ? children : [children];\n\t\tconst checkboxNode = childArray.find(isCheckboxElement);\n\t\tconst nonCheckboxChildren = childArray.filter((c) => !isCheckboxElement(c));\n\n\t\tconst labelText = getNodeText(nonCheckboxChildren as unknown as ReactNode);\n\t\tconst baseId = slugify(labelText || \"task-item\");\n\t\tconst hashSuffix = simpleHash(labelText || \"task-item\");\n\t\tconst uniqueId = `${baseId}-${hashSuffix}`;\n\n\t\treturn (\n\t\t\t
  • \n\t\t\t\t{checkboxNode\n\t\t\t\t\t? createElement(checkboxNode.type, {\n\t\t\t\t\t\t\t...checkboxNode.props,\n\t\t\t\t\t\t\tid: uniqueId,\n\t\t\t\t\t\t\t\"aria-label\": labelText || \"Task item\",\n\t\t\t\t\t\t})\n\t\t\t\t\t: null}\n\t\t\t\t\n\t\t\t
  • \n\t\t);\n\t};\n}\n\ntype HeadingTag = \"h1\" | \"h2\" | \"h3\" | \"h4\" | \"h5\" | \"h6\";\nfunction createHeadingRenderer(tag: T) {\n\treturn function HeadingRenderer(props: ComponentPropsWithoutRef) {\n\t\tconst { children, ...rest } = props as { children: ReactNode };\n\t\tconst text = getNodeText(children);\n\t\tconst id = slugify(text);\n\t\treturn createElement(\n\t\t\ttag,\n\t\t\t{ id, ...(rest as object) },\n\t\t\tchildren,\n\t\t\ttext ? (\n\t\t\t\t\n\t\t\t\t\t#\n\t\t\t\t
    \n\t\t\t) : null,\n\t\t);\n\t};\n}\n\ntype CodeProps = React.HTMLAttributes & {\n\tinline?: boolean;\n\tnode?: unknown;\n};\n\nfunction CodeRenderer({ inline, className, children, ...rest }: CodeProps) {\n\tconst hasLanguage = /language-([a-z0-9-]+)/i.test(className ?? \"\");\n\tconst isInline = inline ?? !hasLanguage;\n\tif (isInline) {\n\t\treturn (\n\t\t\t\n\t\t\t\t{children}\n\t\t\t\n\t\t);\n\t}\n\treturn (\n\t\t\n\t\t\t{children}\n\t\t\n\t);\n}\n\nfunction PreRenderer(props: React.HTMLAttributes) {\n\tconst { children, ...rest } = props;\n\tconst child = Array.isArray(children) ? children[0] : children;\n\tlet language: string | undefined;\n\tif (isValidElement(child)) {\n\t\tconst className = (child.props as { className?: string }).className;\n\t\tconst match = /language-([a-z0-9-]+)/i.exec(className ?? \"\");\n\t\tlanguage = match?.[1];\n\t}\n\tconst label = (language ?? \"text\").toUpperCase();\n\n\tconst preRef = useRef(null);\n\tconst [copied, setCopied] = useState(false);\n\tconst resetTimerRef = useRef(null);\n\n\tlet codeText = \"\";\n\tif (isValidElement(child)) {\n\t\tconst childProps = child.props as { children?: ReactNode };\n\t\tcodeText = getNodeText(childProps.children as ReactNode);\n\t}\n\tconst normalized = codeText.endsWith(\"\\n\") ? codeText.slice(0, -1) : codeText;\n\tconst lineCount = Math.max(1, normalized.split(\"\\n\").length);\n\tconst digitCount = String(lineCount).length;\n\t\n\tconst onCopy: MouseEventHandler = async (e) => {\n\t\te.preventDefault();\n\t\te.stopPropagation();\n\t\ttry {\n\t\t\tconst text = preRef.current?.textContent ?? \"\";\n\t\t\tif (\n\t\t\t\ttext &&\n\t\t\t\ttypeof navigator !== \"undefined\" &&\n\t\t\t\tnavigator.clipboard?.writeText\n\t\t\t) {\n\t\t\t\tawait navigator.clipboard.writeText(text);\n\t\t\t\tsetCopied(true);\n\t\t\t\tif (resetTimerRef.current) window.clearTimeout(resetTimerRef.current);\n\t\t\t\tresetTimerRef.current = window.setTimeout(() => {\n\t\t\t\t\tsetCopied(false);\n\t\t\t\t\tresetTimerRef.current = null;\n\t\t\t\t}, 2000);\n\t\t\t}\n\t\t} catch {}\n\t};\n\t\n\treturn (\n\t\t\n\t\t\t
    \n\t\t\t\t{label}\n\t\t\t\t\n\t\t\t\t\t{copied ? : }\n\t\t\t\t\n\t\t\t
    \n\t\t\t
    \n\t\t\t\t
      \n\t\t\t\t\t{Array.from({ length: lineCount }).map((_, idx) => (\n\t\t\t\t\t\t
    1. {idx + 1}
    2. \n\t\t\t\t\t))}\n\t\t\t\t
    \n\t\t\t\t
    \n\t\t\t\t\t{children}\n\t\t\t\t
    \n\t\t\t
    \n\t\t\n\t);\n}\n\nexport type MarkdownContentProps = {\n\t/** The markdown string to render */\n\tmarkdown: string;\n\t/** Additional CSS class name */\n\tclassName?: string;\n\t/** Variant for different use cases - 'default' for full blog posts, 'chat' for compact chat messages */\n\tvariant?: \"default\" | \"chat\";\n\t/** Custom Link component for navigation */\n\tLinkComponent?: ComponentType & Record>;\n\t/** Custom Image component for images */\n\tImageComponent?: ComponentType & Record>;\n};\n\nexport function MarkdownContent({\n\tmarkdown,\n\tclassName,\n\tvariant = \"default\",\n\tLinkComponent = DefaultLink,\n\tImageComponent = DefaultImage,\n}: MarkdownContentProps) {\n\tconst [mathPlugins, setMathPlugins] = useState<{\n\t\tremarkMath: unknown;\n\t\trehypeKatex: unknown;\n\t} | null>(null);\n\tconst [isLoadingMath, setIsLoadingMath] = useState(false);\n\n\tconst hasMath = useMemo(() => containsMath(markdown), [markdown]);\n\n\t// Dynamically load math plugins and CSS only if needed\n\tuseEffect(() => {\n\t\tif (!hasMath || mathPlugins || isLoadingMath) return;\n\n\t\tsetIsLoadingMath(true);\n\n\t\t// Dynamically inject KaTeX CSS into the document\n\t\tconst katexCSSId = \"katex-css\";\n\t\tif (typeof document !== \"undefined\" && !document.getElementById(katexCSSId)) {\n\t\t\tconst link = document.createElement(\"link\");\n\t\t\tlink.id = katexCSSId;\n\t\t\tlink.rel = \"stylesheet\";\n\t\t\tlink.href =\n\t\t\t\t\"https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css\";\n\t\t\tlink.integrity =\n\t\t\t\t\"sha384-nB0miv6/jRmo5UMMR1wu3Gz6NLsoTkbqJghGIsx//Rlm+ZU03BU6SQNC66uf4l5+\";\n\t\t\tlink.crossOrigin = \"anonymous\";\n\t\t\tdocument.head.appendChild(link);\n\n\t\t\t// Add font-display override for KaTeX fonts\n\t\t\tconst fontDisplayStyleId = \"katex-font-display-override\";\n\t\t\tif (!document.getElementById(fontDisplayStyleId)) {\n\t\t\t\tconst style = document.createElement(\"style\");\n\t\t\t\tstyle.id = fontDisplayStyleId;\n\t\t\t\tstyle.textContent = `\n\t\t\t\t\t@font-face {\n\t\t\t\t\t\tfont-family: 'KaTeX_Main';\n\t\t\t\t\t\tfont-style: normal;\n\t\t\t\t\t\tfont-weight: normal;\n\t\t\t\t\t\tfont-display: swap;\n\t\t\t\t\t\tsrc: url('https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/fonts/KaTeX_Main-Regular.woff2') format('woff2');\n\t\t\t\t\t}\n\t\t\t\t\t@font-face {\n\t\t\t\t\t\tfont-family: 'KaTeX_Math';\n\t\t\t\t\t\tfont-style: italic;\n\t\t\t\t\t\tfont-weight: normal;\n\t\t\t\t\t\tfont-display: swap;\n\t\t\t\t\t\tsrc: url('https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/fonts/KaTeX_Math-Italic.woff2') format('woff2');\n\t\t\t\t\t}\n\t\t\t\t`;\n\t\t\t\tdocument.head.appendChild(style);\n\t\t\t}\n\t\t}\n\n\t\tPromise.all([import(\"remark-math\"), import(\"rehype-katex\")])\n\t\t\t.then(([remarkMathModule, rehypeKatexModule]) => {\n\t\t\t\tsetMathPlugins({\n\t\t\t\t\tremarkMath: remarkMathModule.default,\n\t\t\t\t\trehypeKatex: rehypeKatexModule.default,\n\t\t\t\t});\n\t\t\t})\n\t\t\t.catch((error) => {\n\t\t\t\tconsole.error(\"Failed to load math plugins:\", error);\n\t\t\t})\n\t\t\t.finally(() => {\n\t\t\t\tsetIsLoadingMath(false);\n\t\t\t});\n\t}, [hasMath, mathPlugins, isLoadingMath]);\n\n\t// Create anchor renderer with custom Link component\n\tconst AnchorRenderer = useMemo(() => {\n\t\treturn function AnchorRendererComponent(\n\t\t\tprops: React.AnchorHTMLAttributes,\n\t\t) {\n\t\t\tconst { href = \"\", children, className: anchorClassName, ...rest } = props;\n\t\t\treturn (\n\t\t\t\t\n\t\t\t\t\t{children}\n\t\t\t\t\n\t\t\t);\n\t\t};\n\t}, [LinkComponent]);\n\n\t// Create image renderer with custom Image component\n\tconst ImgRenderer = useMemo(() => {\n\t\treturn function ImgRendererComponent(\n\t\t\tprops: React.ImgHTMLAttributes,\n\t\t) {\n\t\t\tconst {\n\t\t\t\tsrc = \"\",\n\t\t\t\talt = \"\",\n\t\t\t\tclassName: imgClassName,\n\t\t\t\twidth,\n\t\t\t\theight,\n\t\t\t\tstyle,\n\t\t\t\t...rest\n\t\t\t} = props;\n\n\t\t\tconst imageProps: React.ComponentProps = {\n\t\t\t\tsrc,\n\t\t\t\talt,\n\t\t\t\tclassName: imgClassName as string,\n\t\t\t\tstyle,\n\t\t\t\t...rest,\n\t\t\t};\n\n\t\t\tif (width != null && height != null) {\n\t\t\t\timageProps.width = width as number;\n\t\t\t\timageProps.height = height as number;\n\t\t\t} else {\n\t\t\t\treturn (\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t);\n\t\t\t}\n\n\t\t\treturn ;\n\t\t};\n\t}, [ImageComponent]);\n\n\tconst components = useMemo(() => {\n\t\treturn {\n\t\t\ta: AnchorRenderer,\n\t\t\timg: ImgRenderer,\n\t\t\tcode: CodeRenderer,\n\t\t\tpre: PreRenderer,\n\t\t\th1: createHeadingRenderer(\"h1\"),\n\t\t\th2: createHeadingRenderer(\"h2\"),\n\t\t\th3: createHeadingRenderer(\"h3\"),\n\t\t\th4: createHeadingRenderer(\"h4\"),\n\t\t\th5: createHeadingRenderer(\"h5\"),\n\t\t\th6: createHeadingRenderer(\"h6\"),\n\t\t\tli: createTaskListItemRenderer(),\n\t\t};\n\t}, [AnchorRenderer, ImgRenderer]);\n\n\tconst remarkPlugins = useMemo(() => {\n\t\tconst plugins: unknown[] = [remarkGfm];\n\t\tif (hasMath && mathPlugins?.remarkMath) {\n\t\t\tplugins.push(mathPlugins.remarkMath);\n\t\t}\n\t\treturn plugins as never;\n\t}, [hasMath, mathPlugins]);\n\n\tconst rehypePlugins = useMemo(() => {\n\t\tconst plugins: unknown[] = [rehypeRaw, rehypeHighlight];\n\t\tif (hasMath && mathPlugins?.rehypeKatex) {\n\t\t\tplugins.push(mathPlugins.rehypeKatex);\n\t\t}\n\t\treturn plugins as never;\n\t}, [hasMath, mathPlugins]);\n\n\tconst variantClass = variant === \"chat\" ? \"markdown-content-chat\" : \"\";\n\n\treturn (\n\t\t
    \n\t\t\t
    \n\t\t\t\t
    \n\t\t\t\t\t\n\t\t\t\t\t\t{markdown}\n\t\t\t\t\t\n\t\t\t\t
    \n\t\t\t
    \n\t\t
    \n\t);\n}\n", + "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport {\n\tcreateElement,\n\tisValidElement,\n\tuseMemo,\n\tuseRef,\n\tuseState,\n\tuseEffect,\n} from \"react\";\nimport type {\n\tComponentPropsWithoutRef,\n\tComponentType,\n\tMouseEventHandler,\n\tReactNode,\n} from \"react\";\nimport ReactMarkdown from \"react-markdown\";\nimport type { Components } from \"react-markdown\";\nimport rehypeHighlight from \"rehype-highlight\";\nimport rehypeRaw from \"rehype-raw\";\nimport remarkGfm from \"remark-gfm\";\nimport { CopyIcon, CheckIcon } from \"lucide-react\";\n\n// Default Link component\nconst DefaultLink = (props: React.ComponentProps<\"a\">) => {\n\treturn ;\n};\n\n// Default Image component\nconst DefaultImage = (props: React.ImgHTMLAttributes) => {\n\treturn ;\n};\n\n// Utility to detect if markdown contains math syntax\nfunction containsMath(markdown: string): boolean {\n\treturn (\n\t\t/\\$\\$[\\s\\S]+?\\$\\$/.test(markdown) || // Display math\n\t\t/(?;\n\t\treturn getNodeText(props.children as ReactNode);\n\t}\n\treturn \"\";\n}\n\n// Deterministic hash function for generating consistent IDs\nfunction simpleHash(str: string): string {\n\tlet hash = 0;\n\tfor (let i = 0; i < str.length; i++) {\n\t\tconst char = str.charCodeAt(i);\n\t\thash = (hash << 5) - hash + char;\n\t\thash = hash & hash;\n\t}\n\treturn Math.abs(hash).toString(36).slice(0, 6);\n}\n\nfunction isCheckboxElement(\n\tnode: ReactNode,\n): node is ReturnType {\n\treturn (\n\t\tisValidElement(node) &&\n\t\t(node.type as unknown) === \"input\" &&\n\t\t(node.props as { type?: string }).type === \"checkbox\"\n\t);\n}\n\nfunction createTaskListItemRenderer() {\n\treturn function LiRenderer(\n\t\tprops: React.LiHTMLAttributes & { children?: ReactNode },\n\t) {\n\t\tconst { className, children, ...rest } = props;\n\t\tconst isTaskItem = (className ?? \"\").split(\" \").includes(\"task-list-item\");\n\t\tif (!isTaskItem) {\n\t\t\treturn (\n\t\t\t\t
  • \n\t\t\t\t\t{children}\n\t\t\t\t
  • \n\t\t\t);\n\t\t}\n\n\t\tconst childArray = Array.isArray(children) ? children : [children];\n\t\tconst checkboxNode = childArray.find(isCheckboxElement);\n\t\tconst nonCheckboxChildren = childArray.filter((c) => !isCheckboxElement(c));\n\n\t\tconst labelText = getNodeText(nonCheckboxChildren as unknown as ReactNode);\n\t\tconst baseId = slugify(labelText || \"task-item\");\n\t\tconst hashSuffix = simpleHash(labelText || \"task-item\");\n\t\tconst uniqueId = `${baseId}-${hashSuffix}`;\n\n\t\treturn (\n\t\t\t
  • \n\t\t\t\t{checkboxNode\n\t\t\t\t\t? createElement(checkboxNode.type, {\n\t\t\t\t\t\t\t...checkboxNode.props,\n\t\t\t\t\t\t\tid: uniqueId,\n\t\t\t\t\t\t\t\"aria-label\": labelText || \"Task item\",\n\t\t\t\t\t\t})\n\t\t\t\t\t: null}\n\t\t\t\t\n\t\t\t
  • \n\t\t);\n\t};\n}\n\ntype HeadingTag = \"h1\" | \"h2\" | \"h3\" | \"h4\" | \"h5\" | \"h6\";\nfunction createHeadingRenderer(tag: T) {\n\treturn function HeadingRenderer(props: ComponentPropsWithoutRef) {\n\t\tconst { children, ...rest } = props as { children: ReactNode };\n\t\tconst text = getNodeText(children);\n\t\tconst id = slugify(text);\n\t\treturn createElement(\n\t\t\ttag,\n\t\t\t{ id, ...(rest as object) },\n\t\t\tchildren,\n\t\t\ttext ? (\n\t\t\t\t\n\t\t\t\t\t#\n\t\t\t\t
    \n\t\t\t) : null,\n\t\t);\n\t};\n}\n\ntype CodeProps = React.HTMLAttributes & {\n\tinline?: boolean;\n\tnode?: unknown;\n};\n\nfunction CodeRenderer({ inline, className, children, ...rest }: CodeProps) {\n\tconst hasLanguage = /language-([a-z0-9-]+)/i.test(className ?? \"\");\n\tconst isInline = inline ?? !hasLanguage;\n\tif (isInline) {\n\t\treturn (\n\t\t\t\n\t\t\t\t{children}\n\t\t\t\n\t\t);\n\t}\n\treturn (\n\t\t\n\t\t\t{children}\n\t\t\n\t);\n}\n\nfunction PreRenderer(props: React.HTMLAttributes) {\n\tconst { children, ...rest } = props;\n\tconst child = Array.isArray(children) ? children[0] : children;\n\tlet language: string | undefined;\n\tif (isValidElement(child)) {\n\t\tconst className = (child.props as { className?: string }).className;\n\t\tconst match = /language-([a-z0-9-]+)/i.exec(className ?? \"\");\n\t\tlanguage = match?.[1];\n\t}\n\tconst label = (language ?? \"text\").toUpperCase();\n\n\tconst preRef = useRef(null);\n\tconst [copied, setCopied] = useState(false);\n\tconst resetTimerRef = useRef(null);\n\n\tlet codeText = \"\";\n\tif (isValidElement(child)) {\n\t\tconst childProps = child.props as { children?: ReactNode };\n\t\tcodeText = getNodeText(childProps.children as ReactNode);\n\t}\n\tconst normalized = codeText.endsWith(\"\\n\") ? codeText.slice(0, -1) : codeText;\n\tconst lineCount = Math.max(1, normalized.split(\"\\n\").length);\n\tconst digitCount = String(lineCount).length;\n\t\n\tconst onCopy: MouseEventHandler = async (e) => {\n\t\te.preventDefault();\n\t\te.stopPropagation();\n\t\ttry {\n\t\t\tconst text = preRef.current?.textContent ?? \"\";\n\t\t\tif (\n\t\t\t\ttext &&\n\t\t\t\ttypeof navigator !== \"undefined\" &&\n\t\t\t\tnavigator.clipboard?.writeText\n\t\t\t) {\n\t\t\t\tawait navigator.clipboard.writeText(text);\n\t\t\t\tsetCopied(true);\n\t\t\t\tif (resetTimerRef.current) window.clearTimeout(resetTimerRef.current);\n\t\t\t\tresetTimerRef.current = window.setTimeout(() => {\n\t\t\t\t\tsetCopied(false);\n\t\t\t\t\tresetTimerRef.current = null;\n\t\t\t\t}, 2000);\n\t\t\t}\n\t\t} catch {}\n\t};\n\t\n\treturn (\n\t\t\n\t\t\t
    \n\t\t\t\t{label}\n\t\t\t\t\n\t\t\t\t\t{copied ? : }\n\t\t\t\t\n\t\t\t
    \n\t\t\t
    \n\t\t\t\t
      \n\t\t\t\t\t{Array.from({ length: lineCount }).map((_, idx) => (\n\t\t\t\t\t\t
    1. {idx + 1}
    2. \n\t\t\t\t\t))}\n\t\t\t\t
    \n\t\t\t\t
    \n\t\t\t\t\t{children}\n\t\t\t\t
    \n\t\t\t
    \n\t\t\n\t);\n}\n\nexport type MarkdownContentProps = {\n\t/** The markdown string to render */\n\tmarkdown: string;\n\t/** Additional CSS class name */\n\tclassName?: string;\n\t/** Variant for different use cases - 'default' for full blog posts, 'chat' for compact chat messages */\n\tvariant?: \"default\" | \"chat\";\n\t/** Custom Link component for navigation */\n\tLinkComponent?: ComponentType & Record>;\n\t/** Custom Image component for images */\n\tImageComponent?: ComponentType & Record>;\n};\n\nexport function MarkdownContent({\n\tmarkdown,\n\tclassName,\n\tvariant = \"default\",\n\tLinkComponent = DefaultLink,\n\tImageComponent = DefaultImage,\n}: MarkdownContentProps) {\n\tconst [mathPlugins, setMathPlugins] = useState<{\n\t\tremarkMath: unknown;\n\t\trehypeKatex: unknown;\n\t} | null>(null);\n\tconst [isLoadingMath, setIsLoadingMath] = useState(false);\n\n\tconst hasMath = useMemo(() => containsMath(markdown), [markdown]);\n\n\t// Dynamically load math plugins and CSS only if needed\n\tuseEffect(() => {\n\t\tif (!hasMath || mathPlugins || isLoadingMath) return;\n\n\t\tsetIsLoadingMath(true);\n\n\t\t// Dynamically inject KaTeX CSS into the document\n\t\tconst katexCSSId = \"katex-css\";\n\t\tif (typeof document !== \"undefined\" && !document.getElementById(katexCSSId)) {\n\t\t\tconst link = document.createElement(\"link\");\n\t\t\tlink.id = katexCSSId;\n\t\t\tlink.rel = \"stylesheet\";\n\t\t\tlink.href =\n\t\t\t\t\"https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css\";\n\t\t\tlink.integrity =\n\t\t\t\t\"sha384-nB0miv6/jRmo5UMMR1wu3Gz6NLsoTkbqJghGIsx//Rlm+ZU03BU6SQNC66uf4l5+\";\n\t\t\tlink.crossOrigin = \"anonymous\";\n\t\t\tdocument.head.appendChild(link);\n\n\t\t\t// Add font-display override for KaTeX fonts\n\t\t\tconst fontDisplayStyleId = \"katex-font-display-override\";\n\t\t\tif (!document.getElementById(fontDisplayStyleId)) {\n\t\t\t\tconst style = document.createElement(\"style\");\n\t\t\t\tstyle.id = fontDisplayStyleId;\n\t\t\t\tstyle.textContent = `\n\t\t\t\t\t@font-face {\n\t\t\t\t\t\tfont-family: 'KaTeX_Main';\n\t\t\t\t\t\tfont-style: normal;\n\t\t\t\t\t\tfont-weight: normal;\n\t\t\t\t\t\tfont-display: swap;\n\t\t\t\t\t\tsrc: url('https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/fonts/KaTeX_Main-Regular.woff2') format('woff2');\n\t\t\t\t\t}\n\t\t\t\t\t@font-face {\n\t\t\t\t\t\tfont-family: 'KaTeX_Math';\n\t\t\t\t\t\tfont-style: italic;\n\t\t\t\t\t\tfont-weight: normal;\n\t\t\t\t\t\tfont-display: swap;\n\t\t\t\t\t\tsrc: url('https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/fonts/KaTeX_Math-Italic.woff2') format('woff2');\n\t\t\t\t\t}\n\t\t\t\t`;\n\t\t\t\tdocument.head.appendChild(style);\n\t\t\t}\n\t\t}\n\n\t\tPromise.all([import(\"remark-math\"), import(\"rehype-katex\")])\n\t\t\t.then(([remarkMathModule, rehypeKatexModule]) => {\n\t\t\t\tsetMathPlugins({\n\t\t\t\t\tremarkMath: remarkMathModule.default,\n\t\t\t\t\trehypeKatex: rehypeKatexModule.default,\n\t\t\t\t});\n\t\t\t})\n\t\t\t.catch((error) => {\n\t\t\t\tconsole.error(\"Failed to load math plugins:\", error);\n\t\t\t})\n\t\t\t.finally(() => {\n\t\t\t\tsetIsLoadingMath(false);\n\t\t\t});\n\t}, [hasMath, mathPlugins, isLoadingMath]);\n\n\t// Create anchor renderer with custom Link component\n\tconst AnchorRenderer = useMemo(() => {\n\t\treturn function AnchorRendererComponent(\n\t\t\tprops: React.AnchorHTMLAttributes,\n\t\t) {\n\t\t\tconst { href = \"\", children, className: anchorClassName, ...rest } = props;\n\t\t\treturn (\n\t\t\t\t\n\t\t\t\t\t{children}\n\t\t\t\t\n\t\t\t);\n\t\t};\n\t}, [LinkComponent]);\n\n\t// Create image renderer with custom Image component\n\tconst ImgRenderer = useMemo(() => {\n\t\treturn function ImgRendererComponent(\n\t\t\tprops: React.ImgHTMLAttributes,\n\t\t) {\n\t\t\tconst {\n\t\t\t\tsrc = \"\",\n\t\t\t\talt = \"\",\n\t\t\t\tclassName: imgClassName,\n\t\t\t\twidth,\n\t\t\t\theight,\n\t\t\t\tstyle,\n\t\t\t\t...rest\n\t\t\t} = props;\n\n\t\t\tconst imageProps: React.ComponentProps = {\n\t\t\t\tsrc,\n\t\t\t\talt,\n\t\t\t\tclassName: imgClassName as string,\n\t\t\t\tstyle,\n\t\t\t\t...rest,\n\t\t\t};\n\n\t\t\tif (width != null && height != null) {\n\t\t\t\timageProps.width = width as number;\n\t\t\t\timageProps.height = height as number;\n\t\t\t} else {\n\t\t\t\treturn (\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t);\n\t\t\t}\n\n\t\t\treturn ;\n\t\t};\n\t}, [ImageComponent]);\n\n\tconst components = useMemo(() => {\n\t\treturn {\n\t\t\ta: AnchorRenderer,\n\t\t\timg: ImgRenderer,\n\t\t\tspan: ({ node: _node, className, ...props }) => (\n\t\t\t\t\n\t\t\t),\n\t\t\tcode: CodeRenderer,\n\t\t\tpre: PreRenderer,\n\t\t\th1: createHeadingRenderer(\"h1\"),\n\t\t\th2: createHeadingRenderer(\"h2\"),\n\t\t\th3: createHeadingRenderer(\"h3\"),\n\t\t\th4: createHeadingRenderer(\"h4\"),\n\t\t\th5: createHeadingRenderer(\"h5\"),\n\t\t\th6: createHeadingRenderer(\"h6\"),\n\t\t\tli: createTaskListItemRenderer(),\n\t\t};\n\t}, [AnchorRenderer, ImgRenderer]);\n\n\tconst remarkPlugins = useMemo(() => {\n\t\tconst plugins: unknown[] = [remarkGfm];\n\t\tif (hasMath && mathPlugins?.remarkMath) {\n\t\t\tplugins.push(mathPlugins.remarkMath);\n\t\t}\n\t\treturn plugins as never;\n\t}, [hasMath, mathPlugins]);\n\n\tconst rehypePlugins = useMemo(() => {\n\t\tconst plugins: unknown[] = [rehypeRaw, rehypeHighlight];\n\t\tif (hasMath && mathPlugins?.rehypeKatex) {\n\t\t\tplugins.push(mathPlugins.rehypeKatex);\n\t\t}\n\t\treturn plugins as never;\n\t}, [hasMath, mathPlugins]);\n\n\tconst variantClass = variant === \"chat\" ? \"markdown-content-chat\" : \"\";\n\n\treturn (\n\t\t
    \n\t\t\t
    \n\t\t\t\t
    \n\t\t\t\t\t\n\t\t\t\t\t\t{markdown}\n\t\t\t\t\t\n\t\t\t\t
    \n\t\t\t
    \n\t\t
    \n\t);\n}\n", "target": "src/components/ui/markdown-content.tsx" }, { diff --git a/packages/stack/src/plugins/blog/client/components/shared/markdown-content-styles.css b/packages/stack/src/plugins/blog/client/components/shared/markdown-content-styles.css index b7ad6d0ee..2b0f05b04 100644 --- a/packages/stack/src/plugins/blog/client/components/shared/markdown-content-styles.css +++ b/packages/stack/src/plugins/blog/client/components/shared/markdown-content-styles.css @@ -13,6 +13,19 @@ max-width: 100%; } +.markdown-content .markdown-inner .markdown-body .katex { + display: inline-block; + max-width: 100%; + overflow-x: auto; + text-align: start; + vertical-align: middle; +} + +.markdown-content .markdown-inner .markdown-body .katex:focus-visible { + outline: 2px solid currentColor; + outline-offset: 2px; +} + .markdown-content .markdown-inner .markdown-body .markdown-image-wrapper { display: block; position: relative; diff --git a/packages/ui/src/components/markdown-content.tsx b/packages/ui/src/components/markdown-content.tsx index 96e049cdb..78eee169e 100644 --- a/packages/ui/src/components/markdown-content.tsx +++ b/packages/ui/src/components/markdown-content.tsx @@ -386,6 +386,13 @@ export function MarkdownContent({ return { a: AnchorRenderer, img: ImgRenderer, + span: ({ node: _node, className, ...props }) => ( + + ), code: CodeRenderer, pre: PreRenderer, h1: createHeadingRenderer("h1"), diff --git a/packages/ui/src/styles/markdown-content.css b/packages/ui/src/styles/markdown-content.css index 774de307a..29891c34e 100644 --- a/packages/ui/src/styles/markdown-content.css +++ b/packages/ui/src/styles/markdown-content.css @@ -351,6 +351,19 @@ overflow-wrap: anywhere; } +.markdown-content .markdown-inner .markdown-body .katex { + display: inline-block; + max-width: 100%; + overflow-x: auto; + text-align: start; + vertical-align: middle; +} + +.markdown-content .markdown-inner .markdown-body .katex:focus-visible { + outline: 2px solid currentColor; + outline-offset: 2px; +} + /* Simplified chat variant - more compact for chat messages */ .markdown-content-chat .markdown-inner .markdown-body { padding: 0; From 76b346b2383effe2f4c4c16b27d33e758eb89aa7 Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:37:44 -0400 Subject: [PATCH 2/3] fix(cli): install the Markdown patch in new projects --- packages/cli/src/utils/__tests__/package-installer.test.ts | 2 +- packages/cli/src/utils/package-installer.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/utils/__tests__/package-installer.test.ts b/packages/cli/src/utils/__tests__/package-installer.test.ts index f3b26a32c..db36b4a5a 100644 --- a/packages/cli/src/utils/__tests__/package-installer.test.ts +++ b/packages/cli/src/utils/__tests__/package-installer.test.ts @@ -21,7 +21,7 @@ describe("installInitDependencies", () => { }); const installArguments = execa.mock.calls[0]?.[1] as string[]; - expect(installArguments).toContain("@btst/stack@3.1.0"); + expect(installArguments).toContain("@btst/stack@3.1.1"); expect(installArguments).toContain("next-themes@0.4.6"); expect(installArguments).toContain("@btst/adapter-drizzle@2.2.3"); expect(installArguments).toContain("drizzle-orm@0.45.2"); diff --git a/packages/cli/src/utils/package-installer.ts b/packages/cli/src/utils/package-installer.ts index 5761aede8..1517ba2b5 100644 --- a/packages/cli/src/utils/package-installer.ts +++ b/packages/cli/src/utils/package-installer.ts @@ -44,7 +44,7 @@ export async function installInitDependencies(input: { }); const packages = [ - "@btst/stack@3.1.0", + "@btst/stack@3.1.1", "@btst/yar@1.3.2", "@tanstack/react-query@5.100.14", "next-themes@0.4.6", From 72d0b8026011cc117de4e8c3e90fb8f65d5d9c10 Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:54:55 -0400 Subject: [PATCH 3/3] Clean up the math layout fixture after verification --- e2e/tests/smoke.blog.spec.ts | 85 ++++++++++++++++++++---------------- 1 file changed, 48 insertions(+), 37 deletions(-) diff --git a/e2e/tests/smoke.blog.spec.ts b/e2e/tests/smoke.blog.spec.ts index 5f70e9d8a..cea4808d6 100644 --- a/e2e/tests/smoke.blog.spec.ts +++ b/e2e/tests/smoke.blog.spec.ts @@ -179,53 +179,64 @@ test("oversized math stays readable without overflowing the article", async ({ request, }) => { const longMath = String.raw`\underbrace{abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz}_{\text{long expression}}`; - await createPost(request, { + const post = await createPost(request, { title: "Math layout regression", slug: "math-layout-regression", excerpt: "Inline and display math remain accessible on narrow screens.", content: `Ordinary inline $E=mc^2$ stays in the paragraph.\n\nLong inline $${longMath}$ ends here.\n\n$$\n${longMath}\n$$\n\n| Value | Result |\n| --- | --- |\n| one | two |`, published: true, }); - await page.goto("/pages/blog/math-layout-regression", { - waitUntil: "networkidle", - }); - const math = page.locator(".markdown-body .katex"); - await expect(math).toHaveCount(3); - await page.evaluate(() => document.fonts.ready); - for (const width of [320, 390, 1280]) { - await page.setViewportSize({ width, height: 900 }); - await expect - .poll(() => - page.evaluate(() => document.documentElement.scrollWidth - innerWidth), - ) - .toBeLessThanOrEqual(1); - await expect(page.getByText("ends here.", { exact: false })).toBeVisible(); - await expect( - page.getByRole("cell", { name: "two", exact: true }), - ).toBeVisible(); - for (const expression of await math.all()) { - await expect(expression.locator("math")).toHaveCount(1); - await expect(expression.locator(".katex-html")).toHaveAttribute( - "aria-hidden", - "true", - ); - const bounds = await expression.evaluate((node) => ({ - width: node.clientWidth, - content: node.scrollWidth, - })); - if (bounds.content <= bounds.width + 1) continue; - await expression.focus(); - await expect(expression).toBeFocused(); - for (let step = 0; step < 20; step++) { - await page.keyboard.press("ArrowRight"); - await page.waitForTimeout(50); - } + try { + await page.goto("/pages/blog/math-layout-regression", { + waitUntil: "networkidle", + }); + const math = page.locator(".markdown-body .katex"); + await expect(math).toHaveCount(3); + await page.evaluate(() => document.fonts.ready); + for (const width of [320, 390, 1280]) { + await page.setViewportSize({ width, height: 900 }); await expect .poll(() => - expression.evaluate((node) => node.scrollLeft + node.clientWidth), + page.evaluate( + () => document.documentElement.scrollWidth - innerWidth, + ), ) - .toBeGreaterThanOrEqual(bounds.content - 1); + .toBeLessThanOrEqual(1); + await expect( + page.getByText("ends here.", { exact: false }), + ).toBeVisible(); + await expect( + page.getByRole("cell", { name: "two", exact: true }), + ).toBeVisible(); + for (const expression of await math.all()) { + await expect(expression.locator("math")).toHaveCount(1); + await expect(expression.locator(".katex-html")).toHaveAttribute( + "aria-hidden", + "true", + ); + const bounds = await expression.evaluate((node) => ({ + width: node.clientWidth, + content: node.scrollWidth, + })); + if (bounds.content <= bounds.width + 1) continue; + await expression.focus(); + await expect(expression).toBeFocused(); + for (let step = 0; step < 20; step++) { + await page.keyboard.press("ArrowRight"); + await page.waitForTimeout(50); + } + await expect + .poll(() => + expression.evaluate((node) => node.scrollLeft + node.clientWidth), + ) + .toBeGreaterThanOrEqual(bounds.content - 1); + } } + } finally { + const deleted = await request.delete(`/api/data/posts/${post.id}`, { + headers: mockAuthHeaders(), + }); + expect(deleted.ok()).toBeTruthy(); } });