diff --git a/crates/atuin-desktop-runtime/src/blocks/script.rs b/crates/atuin-desktop-runtime/src/blocks/script.rs index feb2ea35..fa659714 100644 --- a/crates/atuin-desktop-runtime/src/blocks/script.rs +++ b/crates/atuin-desktop-runtime/src/blocks/script.rs @@ -261,7 +261,7 @@ impl BlockBehavior for Script { .filter(|line| line.is_stdout) .map(|line| line.text.clone()) .collect::>() - .join("\n"); + .join(""); let _ = context .update_active_context(self.id, move |ctx| { @@ -1139,6 +1139,27 @@ echo "Successfully wrote to $ATUIN_OUTPUT_VARS" // 3. The fs_var integration is working } + #[test] + fn test_stdout_preserves_markdown_table_formatting() { + let output = ScriptExecutionOutput { + exit_code: Some(0), + output: vec![ + OutputLine::stdout("# Script output\n".to_string()), + OutputLine::stdout("\n".to_string()), + OutputLine::stdout("| Column 1 | Column 2 |\n".to_string()), + OutputLine::stdout("| --- | --- |\n".to_string()), + OutputLine::stdout("| Value 1 | Value 2 |\n".to_string()), + ], + }; + + assert_eq!( + output.stdout().as_deref(), + Some( + "# Script output\n\n| Column 1 | Column 2 |\n| --- | --- |\n| Value 1 | Value 2 |\n" + ) + ); + } + #[tokio::test] async fn test_ssh_host_parsing() { assert_eq!( diff --git a/docs/docs/blocks/executable/markdown-render.md b/docs/docs/blocks/executable/markdown-render.md index 244fcfb7..1d1f6904 100644 --- a/docs/docs/blocks/executable/markdown-render.md +++ b/docs/docs/blocks/executable/markdown-render.md @@ -30,6 +30,10 @@ Open the rendered markdown in a fullscreen modal for easier reading of long cont All rendered content is fully selectable and copyable, making it easy to extract information from the output. +### GitHub-like Presentation + +Rendered content uses GitHub-like markdown styling in both light and dark mode, including tables, task lists, blockquotes, and syntax-highlighted fenced code blocks. + ## Example Workflow A common pattern is using a Script block to generate markdown content, then displaying it with a Markdown Render block: @@ -48,16 +52,17 @@ Save the output to a variable (e.g., `release_notes`), then reference it in your ## Supported Markdown -The block supports GitHub Flavored Markdown (GFM), including: +The block supports GitHub Flavored Markdown (GFM) with GitHub-like styling, including: -- Headers and paragraphs -- **Bold**, *italic*, and ~~strikethrough~~ text -- Ordered and unordered lists -- Code blocks with syntax highlighting -- Tables +- Headings, paragraphs, and horizontal rules +- **Bold**, *italic*, `inline code`, and ~~strikethrough~~ text +- Ordered, unordered, and task lists +- Fenced and indented code blocks +- Syntax highlighting for common fenced code block languages +- Tables, including column alignment - Links and images - Blockquotes -- Task lists +- Footnotes ## View Mode vs Edit Mode diff --git a/markdown-render-fixture.md b/markdown-render-fixture.md new file mode 100644 index 00000000..357b04cf --- /dev/null +++ b/markdown-render-fixture.md @@ -0,0 +1,230 @@ +# Markdown Render Fixture + +Use this file to test the `markdown_render` block against the Markdown + GFM syntax that the current renderer should support. + +## Headings + +# Heading 1 +## Heading 2 +### Heading 3 +#### Heading 4 +##### Heading 5 +###### Heading 6 + +## Paragraphs + +This is a normal paragraph with enough text to check spacing, wrapping, and line height in the rendered output. + +This paragraph includes a +soft line break inside the same paragraph. + +This line ends with two spaces +so the next line should render as a hard break. + +## Emphasis + +This text includes *italic*, **bold**, ***bold italic***, ~~strikethrough~~, and `inline code`. + +You can also mix them together: **bold with `inline code` inside**, *italic with a [link](https://example.com)*, and ~~strikethrough with **bold**~~. + +## Links + +Inline link: [Atuin](https://atuin.sh) + +Autolink: + +Bare URL literal: https://example.com/releases/latest + +Email autolink literal: support@example.com + +Reference-style link: [Renderer docs][renderer-docs] + +[renderer-docs]: https://example.com/markdown-render + +## Blockquotes + +> This is a simple blockquote. + +> A blockquote can contain multiple paragraphs. +> +> It can also contain other Markdown: +> - a list item +> - another list item +> +> And a nested quote: +> > Nested quote content + +## Lists + +Unordered list: + +- First item +- Second item +- Third item + +Ordered list: + +1. First ordered item +2. Second ordered item +3. Third ordered item + +Nested lists: + +- Parent item + - Nested child item + - Another nested child item +- Second parent item + +1. Ordered parent + 1. Nested ordered child + 2. Another nested ordered child +2. Second ordered parent + +## Task Lists + +- [x] Completed task +- [ ] Incomplete task +- [x] Completed task with `inline code` +- [ ] Incomplete task with a [link](https://example.com) + +## Code + +Inline code example: `const answer = 42;` + +Fenced code block with language: + +```ts +type User = { + id: string; + name: string; + active: boolean; +}; + +const user: User = { + id: "u_123", + name: "Taylor", + active: true, +}; + +console.log(user.name); +``` + +Fenced code block without language: + +``` +Plain fenced code block +with multiple lines +and no language hint. +``` + +Indented code block: + + SELECT id, name + FROM users + WHERE active = true + ORDER BY name ASC; + +## Tables + +Simple table: + +| Column 1 | Column 2 | +| --- | --- | +| Value 1 | Value 2 | +| Value 3 | Value 4 | + +Alignment table: + +| Left | Right | Center | +| --- | ---: | :---: | +| left text | 123 | centered | +| more left text | 4567 | more centered | + +Table with inline formatting: + +| Syntax | Example | Notes | +| --- | --- | --- | +| Bold | **strong** | Should keep emphasis | +| Italic | *emphasis* | Should keep italics | +| Code | `npm test` | Should keep inline code | +| Link | [Example](https://example.com) | Should stay clickable | + +## Horizontal Rules + +--- + +Content after the first rule. + +*** + +Content after the second rule. + +___ + +## Images + +Image from the app public directory: + +![Vite Logo](/vite.svg) + +Linked image: + +[![Tauri Logo](/tauri.svg)](https://tauri.app) + +## Footnotes + +Here is a statement with a footnote.[^note-one] + +Here is another footnote reference.[^note-two] + +[^note-one]: This is the first footnote. +[^note-two]: This footnote includes **formatting**, `inline code`, and a [link](https://example.com). + +## Escaping + +\*This should not be italic\* + +\[This should not become a link\](https://example.com) + +\# This should not become a heading + +## Raw HTML + +
+ Expandable HTML block +

This content is written in raw HTML inside the Markdown document.

+
+ +Ctrl + K + +## Mixed Content Stress Test + +> ### Quoted heading +> +> - [x] Task inside quote +> - [ ] Another task inside quote +> +> | Name | Status | Score | +> | --- | ---: | :---: | +> | Alpha | done | 10 | +> | Beta | pending | 7 | +> +> ```bash +> echo "quoted code block" +> ``` + +1. Ordered item with a paragraph. + + Additional paragraph text inside the same list item. + +2. Ordered item with a nested unordered list: + - child item one + - child item two + +3. Ordered item with a nested blockquote: + + > Nested quote inside a list item + +## End + +If all of the above renders correctly, the current `markdown_render` styling is covering the main Markdown and GFM cases we expect to support. diff --git a/src/components/runbooks/editor/blocks/MarkdownRender/index.test.ts b/src/components/runbooks/editor/blocks/MarkdownRender/index.test.ts index 971db5ff..a135f7ee 100644 --- a/src/components/runbooks/editor/blocks/MarkdownRender/index.test.ts +++ b/src/components/runbooks/editor/blocks/MarkdownRender/index.test.ts @@ -20,6 +20,15 @@ vi.mock("@/tracking", () => ({ default: vi.fn(), })); +vi.mock("@/state/store", () => ({ + useStore: (selector: (state: { functionalColorMode: "light" | "dark" }) => unknown) => + selector({ functionalColorMode: "light" }), +})); + +vi.mock("@tauri-apps/plugin-shell", () => ({ + open: vi.fn(), +})); + import { insertMarkdownRender } from "./index"; describe("MarkdownRender", () => { diff --git a/src/components/runbooks/editor/components/Markdown.test.tsx b/src/components/runbooks/editor/components/Markdown.test.tsx new file mode 100644 index 00000000..cf9654bd --- /dev/null +++ b/src/components/runbooks/editor/components/Markdown.test.tsx @@ -0,0 +1,59 @@ +/** + * @vitest-environment jsdom + */ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, test, vi } from "vitest"; + +vi.mock("@/state/store", () => ({ + useStore: (selector: (state: { functionalColorMode: "light" | "dark" }) => unknown) => + selector({ functionalColorMode: "light" }), +})); + +vi.mock("@tauri-apps/plugin-shell", () => ({ + open: vi.fn(), +})); + +import Markdown, { normalizeMarkdownCodeLanguage } from "./Markdown"; + +describe("Markdown", () => { + test("normalizes common fenced-code language aliases", () => { + expect(normalizeMarkdownCodeLanguage("ts")).toBe("typescript"); + expect(normalizeMarkdownCodeLanguage("sh")).toBe("bash"); + expect(normalizeMarkdownCodeLanguage("yml")).toBe("yaml"); + expect(normalizeMarkdownCodeLanguage("")).toBe(""); + }); + + test("renders fenced code blocks with syntax token markup", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("markdown-code-block"); + expect(markup).toContain("token keyword"); + expect(markup).toContain("token operator"); + }); + + test("preserves non-code GFM structures when rendering to React elements", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain(""); + expect(markup).toContain('align="right"'); + expect(markup).toContain('type="checkbox"'); + expect(markup).toContain('data-footnotes=""'); + expect(markup).toContain('data-footnote-ref=""'); + }); +}); diff --git a/src/components/runbooks/editor/components/Markdown.tsx b/src/components/runbooks/editor/components/Markdown.tsx index ef71811c..38b9cf2b 100644 --- a/src/components/runbooks/editor/components/Markdown.tsx +++ b/src/components/runbooks/editor/components/Markdown.tsx @@ -1,12 +1,247 @@ +import type { CSSProperties, ReactNode } from "react"; +import { createElement } from "react"; +import { Highlight, themes } from "prism-react-renderer"; +import Prism from "prismjs"; import { micromark } from "micromark"; import { gfm, gfmHtml } from "micromark-extension-gfm"; import { open } from "@tauri-apps/plugin-shell"; +import { useStore } from "@/state/store"; + +import "prismjs/components/prism-bash"; +import "prismjs/components/prism-json"; +import "prismjs/components/prism-jsx"; +import "prismjs/components/prism-markdown"; +import "prismjs/components/prism-python"; +import "prismjs/components/prism-rust"; +import "prismjs/components/prism-sql"; +import "prismjs/components/prism-toml"; +import "prismjs/components/prism-tsx"; +import "prismjs/components/prism-typescript"; +import "prismjs/components/prism-yaml"; interface MarkdownProps { content?: string; } +const MARKDOWN_CODE_LANGUAGE_ALIASES: Record = { + cjs: "javascript", + js: "javascript", + jsx: "jsx", + md: "markdown", + py: "python", + rs: "rust", + sh: "bash", + shell: "bash", + text: "", + toml: "toml", + ts: "typescript", + tsx: "tsx", + yml: "yaml", + zsh: "bash", +}; + +const BOOLEAN_ATTRIBUTES = new Set(["checked", "disabled", "open"]); +const VOID_ELEMENTS = new Set([ + "area", + "base", + "br", + "col", + "embed", + "hr", + "img", + "input", + "link", + "meta", + "param", + "source", + "track", + "wbr", +]); + +const ATTRIBUTE_NAME_MAP: Record = { + class: "className", + colspan: "colSpan", + for: "htmlFor", + rowspan: "rowSpan", + tabindex: "tabIndex", +}; + +export function normalizeMarkdownCodeLanguage(language?: string | null): string { + if (!language) { + return ""; + } + + const normalized = language.trim().toLowerCase(); + return MARKDOWN_CODE_LANGUAGE_ALIASES[normalized] ?? normalized; +} + +export function renderMarkdownHtml(content: string): string { + return micromark(content, { + extensions: [gfm()], + htmlExtensions: [gfmHtml()], + }); +} + +function parseInlineStyle(styleText: string): CSSProperties { + const style: Record = {}; + + for (const declaration of styleText.split(";")) { + const [property, ...valueParts] = declaration.split(":"); + if (!property || valueParts.length === 0) { + continue; + } + + const cssProperty = property.trim(); + const cssValue = valueParts.join(":").trim(); + if (!cssProperty || !cssValue) { + continue; + } + + const camelCasedProperty = cssProperty.replace(/-([a-z])/g, (_, char: string) => + char.toUpperCase(), + ); + style[camelCasedProperty] = cssValue; + } + + return style; +} + +function elementAttributesToProps(element: Element): Record { + const props: Record = {}; + + for (const attribute of Array.from(element.attributes)) { + if (attribute.name.startsWith("on")) { + continue; + } + + if (attribute.name === "checked") { + props.defaultChecked = true; + continue; + } + + if (attribute.name === "style") { + const style = parseInlineStyle(attribute.value); + if (Object.keys(style).length > 0) { + props.style = style; + } + continue; + } + + const propName = ATTRIBUTE_NAME_MAP[attribute.name] ?? attribute.name; + if (BOOLEAN_ATTRIBUTES.has(attribute.name) && attribute.value === "") { + props[propName] = true; + continue; + } + + props[propName] = attribute.value; + } + + return props; +} + +function extractCodeLanguage(codeElement: Element): string { + const className = codeElement.getAttribute("class") ?? ""; + const languageClass = className + .split(/\s+/) + .find((entry) => entry.startsWith("language-")); + + return normalizeMarkdownCodeLanguage(languageClass?.replace("language-", "")); +} + +function MarkdownCodeBlock({ + code, + language, + isDark, +}: { + code: string; + language: string; + isDark: boolean; +}) { + const theme = isDark ? themes.oneDark : themes.github; + const grammar = language ? Prism.languages[language] : undefined; + + if (!grammar) { + return ( +
+        {code}
+      
+ ); + } + + return ( + + {({ style, tokens, getLineProps, getTokenProps }) => ( +
+          
+            {tokens.map((line, lineIndex) => (
+              
+ {line.map((token, tokenIndex) => ( + + ))} +
+ ))} +
+
+ )} +
+ ); +} + +function renderMarkdownNode(node: ChildNode, key: string, isDark: boolean): ReactNode { + if (node.nodeType === node.TEXT_NODE) { + return node.textContent; + } + + if (node.nodeType !== node.ELEMENT_NODE) { + return null; + } + + const element = node as Element; + + if (element.tagName === "PRE") { + const firstChild = element.firstElementChild; + if (firstChild?.tagName === "CODE") { + return ( + + ); + } + } + + const children = Array.from(element.childNodes).map((child, index) => + renderMarkdownNode(child, `${key}.${index}`, isDark), + ); + const tagName = element.tagName.toLowerCase(); + + if (VOID_ELEMENTS.has(tagName)) { + return createElement(tagName, { key, ...elementAttributesToProps(element) }); + } + + return createElement(tagName, { key, ...elementAttributesToProps(element) }, children); +} + +export function renderMarkdownNodes(content: string, isDark: boolean): ReactNode[] { + const html = renderMarkdownHtml(content); + const template = document.createElement("template"); + template.innerHTML = html; + + return Array.from(template.content.childNodes).map((node, index) => + renderMarkdownNode(node, `markdown-${index}`, isDark), + ); +} + export default function Markdown(props: MarkdownProps) { + const isDark = useStore((state) => state.functionalColorMode === "dark"); + const handleLinkClick = (e: React.MouseEvent) => { const target = e.target as HTMLElement; const link = target.closest("a"); @@ -19,18 +254,9 @@ export default function Markdown(props: MarkdownProps) { } }; - const renderMarkdown = (content: string): string => { - return micromark(content, { - extensions: [gfm()], - htmlExtensions: [gfmHtml()], - }); - }; - return ( -
+
+ {renderMarkdownNodes(props.content || "", isDark)} +
); } diff --git a/src/styles.css b/src/styles.css index 65ad8b20..e9df5898 100644 --- a/src/styles.css +++ b/src/styles.css @@ -161,7 +161,7 @@ html:not([data-platform="macos"]) .sidebar-bg { } /* Reset all styles to browser default for the release notes so we don't have to implement them all */ -.markdown-content, .markdown-content * { +.markdown-content { all: revert !important; } @@ -170,6 +170,303 @@ html:not([data-platform="macos"]) .sidebar-bg { margin-top: 0 !important; } +.markdown-content { + font-size: 16px !important; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif !important; + color: inherit !important; + line-height: 1.5 !important; + overflow-wrap: anywhere !important; + word-break: normal !important; +} + +.markdown-content h1, +.markdown-content h2, +.markdown-content h3, +.markdown-content h4, +.markdown-content h5, +.markdown-content h6 { + line-height: 1.25 !important; + font-weight: 600 !important; + margin: 1.5rem 0 1rem !important; +} + +.markdown-content h1 { + border-bottom: 1px solid rgb(209 213 219) !important; + font-size: 2em !important; + padding-bottom: 0.3em !important; +} + +.markdown-content h2 { + border-bottom: 1px solid rgb(209 213 219) !important; + font-size: 1.5em !important; + padding-bottom: 0.3em !important; +} + +.markdown-content h3 { + font-size: 1.25em !important; +} + +.markdown-content h4 { + font-size: 1em !important; +} + +.markdown-content h5 { + font-size: 0.875em !important; +} + +.markdown-content h6 { + color: rgb(87 96 106) !important; + font-size: 0.85em !important; +} + +.dark .markdown-content h1, +.dark .markdown-content h2 { + border-bottom-color: rgb(48 54 61) !important; +} + +.dark .markdown-content h6 { + color: rgb(139 148 158) !important; +} + +.markdown-content p, +.markdown-content ul, +.markdown-content ol, +.markdown-content blockquote, +.markdown-content pre, +.markdown-content details, +.markdown-content hr, +.markdown-content section[data-footnotes] { + margin: 0 0 16px !important; +} + +.markdown-content ul, +.markdown-content ol { + padding-left: 2em !important; +} + +.markdown-content li + li { + margin-top: 0.25em !important; +} + +.markdown-content li > p { + margin-top: 16px !important; + margin-bottom: 16px !important; +} + +.markdown-content a { + color: rgb(9 105 218) !important; + text-decoration: none !important; +} + +.markdown-content a:hover { + text-decoration: underline !important; +} + +.dark .markdown-content a { + color: rgb(88 166 255) !important; +} + +.markdown-content blockquote { + border-left: 0.25em solid rgb(209 213 219) !important; + color: rgb(101 109 118) !important; + padding: 0 1em !important; +} + +.dark .markdown-content blockquote { + border-left-color: rgb(63 70 77) !important; + color: rgb(139 148 158) !important; +} + +.markdown-content pre { + background-color: rgb(246 248 250) !important; + border: 1px solid rgb(208 215 222) !important; + border-radius: 6px !important; + display: block !important; + overflow-x: auto !important; + font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace !important; + font-size: 85% !important; + line-height: 1.45 !important; + padding: 16px !important; +} + +.dark .markdown-content pre { + background-color: rgb(22 27 34) !important; + border-color: rgb(48 54 61) !important; +} + +.markdown-content code { + background: rgb(175 184 193 / 0.2) !important; + border-radius: 6px !important; + font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace !important; + font-size: 85% !important; + margin: 0 !important; + padding: 0.2em 0.4em !important; +} + +.dark .markdown-content code { + background: rgb(110 118 129 / 0.4) !important; +} + +.markdown-content pre code { + background-color: transparent !important; + border-radius: 0 !important; + color: inherit !important; + display: block !important; + font-size: 100% !important; + margin: 0 !important; + padding: 0 !important; +} + +.markdown-content pre.markdown-code-block > code, +.markdown-content pre.markdown-code-block > code > div { + background-color: transparent !important; +} + +.markdown-content table { + border-collapse: collapse !important; + display: block !important; + margin: 0 0 16px !important; + max-width: 100% !important; + overflow: auto !important; + width: max-content !important; +} + +.markdown-content th, +.markdown-content td { + border: 1px solid rgb(209 213 219) !important; + padding: 6px 13px !important; +} + +.markdown-content th:not([align]), +.markdown-content td:not([align]), +.markdown-content th[align="left"], +.markdown-content td[align="left"] { + text-align: left !important; +} + +.markdown-content th[align="center"], +.markdown-content td[align="center"] { + text-align: center !important; +} + +.markdown-content th[align="right"], +.markdown-content td[align="right"] { + text-align: right !important; +} + +.markdown-content th { + font-weight: 600 !important; +} + +.markdown-content tr { + background: transparent !important; + border-top: 1px solid rgb(209 213 219) !important; +} + +.markdown-content tbody tr:nth-child(2n) { + background: rgb(246 248 250) !important; +} + +.dark .markdown-content th, +.dark .markdown-content td { + border-color: rgb(48 54 61) !important; +} + +.dark .markdown-content tr { + border-top-color: rgb(48 54 61) !important; +} + +.dark .markdown-content tbody tr:nth-child(2n) { + background: rgb(22 27 34) !important; +} + +.markdown-content hr { + background-color: rgb(209 213 219) !important; + border: 0 !important; + height: 0.25em !important; + margin: 24px 0 !important; + padding: 0 !important; +} + +.dark .markdown-content hr { + background-color: rgb(48 54 61) !important; +} + +.markdown-content img { + background-color: transparent !important; + box-sizing: content-box !important; + height: auto !important; + max-width: 100% !important; +} + +.markdown-content input[type="checkbox"] { + width: 1rem !important; + height: 1rem !important; + margin: 0 0.5em 0.15em 0 !important; + vertical-align: middle !important; +} + +.markdown-content li:has(> input[type="checkbox"]) { + list-style: none !important; + margin-left: -1.5em !important; + padding-left: 0 !important; +} + +.markdown-content kbd { + background-color: rgb(255 255 255) !important; + border: 1px solid rgb(209 217 224) !important; + border-bottom-color: rgb(175 184 193) !important; + border-radius: 6px !important; + box-shadow: inset 0 -1px 0 rgb(175 184 193) !important; + color: rgb(31 35 40) !important; + display: inline-block !important; + font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace !important; + font-size: 11px !important; + line-height: 10px !important; + padding: 3px 5px !important; + vertical-align: middle !important; +} + +.dark .markdown-content kbd { + background-color: rgb(22 27 34) !important; + border-color: rgb(48 54 61) !important; + border-bottom-color: rgb(110 118 129) !important; + box-shadow: inset 0 -1px 0 rgb(110 118 129) !important; + color: rgb(230 237 243) !important; +} + +.markdown-content details { + padding: 0 !important; +} + +.markdown-content summary { + cursor: pointer !important; + font-weight: 600 !important; +} + +.markdown-content section[data-footnotes] { + border-top: 1px solid rgb(209 213 219) !important; + font-size: 0.875em !important; + margin-top: 24px !important; + padding-top: 16px !important; +} + +.markdown-content .sr-only { + width: 1px !important; + height: 1px !important; + padding: 0 !important; + margin: -1px !important; + overflow: hidden !important; + clip: rect(0, 0, 0, 0) !important; + white-space: nowrap !important; + border: 0 !important; + position: absolute !important; +} + +.dark .markdown-content section[data-footnotes] { + border-top-color: rgb(48 54 61) !important; +} + /* Pulsing glow animation for pause block continue button */ @keyframes pulse-glow { 0%, 100% {