Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* Copyright 2024 Marimo. All rights reserved. */
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { parseContent, UrlDetector } from "../url-detector";
import { isMarkdown, parseContent, UrlDetector } from "../url-detector";

describe("parseContent", () => {
it("handles data URIs", () => {
Expand Down Expand Up @@ -167,3 +167,94 @@ describe("UrlDetector", () => {
expect(mockStopPropagation).toHaveBeenCalled();
});
});

describe("isMarkdown", () => {
it("returns true for headings", () => {
expect(isMarkdown("# Heading 1")).toBe(true);
expect(isMarkdown("## Heading 2")).toBe(true);
expect(isMarkdown("### Heading 3")).toBe(true);
expect(isMarkdown("Heading\n===")).toBe(true);
expect(isMarkdown("Heading\n---")).toBe(true);
});

it.fails("returns true for bold text", () => {
expect(isMarkdown("**bold**")).toBe(true);
expect(isMarkdown("__bold__")).toBe(true);
});

it.fails("returns true for italic text", () => {
expect(isMarkdown("*italic*")).toBe(true);
expect(isMarkdown("_italic_")).toBe(true);
});

it("returns false for inline code", () => {
expect(isMarkdown("`code`")).toBe(false);
expect(isMarkdown("Text with `inline code` in it")).toBe(false);
});

it("returns true for code blocks", () => {
expect(isMarkdown("```\ncode block\n```")).toBe(true);
expect(isMarkdown("```python\ndef hello():\n pass\n```")).toBe(true);
});

it("returns true for lists", () => {
expect(isMarkdown("- item 1\n- item 2")).toBe(true);
expect(isMarkdown("* item 1\n* item 2")).toBe(true);
expect(isMarkdown("1. item 1\n2. item 2")).toBe(true);
});

it("returns true for blockquotes", () => {
expect(isMarkdown("> This is a quote")).toBe(true);
expect(isMarkdown("> Quote line 1\n> Quote line 2")).toBe(true);
});

it("returns true for horizontal rules", () => {
expect(isMarkdown("---")).toBe(true);
expect(isMarkdown("***")).toBe(true);
expect(isMarkdown("___")).toBe(true);
});

it("returns true for tables", () => {
expect(
isMarkdown("| col1 | col2 |\n|------|------|\n| val1 | val2 |"),
).toBe(true);
});

it("returns true for HTML tags", () => {
expect(isMarkdown("<div>content</div>")).toBe(true);
expect(isMarkdown("<br />")).toBe(true);
});

it.fails("returns true for escaped characters", () => {
expect(isMarkdown("\\*not bold\\*")).toBe(true);
expect(isMarkdown("\\# not a heading")).toBe(true);
});

it("returns false for plain text", () => {
expect(isMarkdown("Just plain text")).toBe(false);
expect(isMarkdown("No markdown here")).toBe(false);
});

it("returns false for empty string", () => {
expect(isMarkdown("")).toBe(false);
});

it("returns false for plain URLs without markdown syntax", () => {
expect(isMarkdown("https://example.com")).toBe(false);
expect(isMarkdown("Visit https://marimo.io for more")).toBe(false);
});

it("returns false for plain text with numbers", () => {
expect(isMarkdown("123 456 789")).toBe(false);
});

it.fails("returns true for mixed markdown and plain text", () => {
expect(isMarkdown("Plain text with **bold** in it")).toBe(true);
expect(isMarkdown("Start with text\n\n# Then a heading")).toBe(true);
});

it.fails("returns true for markdown with URLs", () => {
expect(isMarkdown("[Link](https://example.com)")).toBe(true);
expect(isMarkdown("Visit [marimo](https://marimo.io)")).toBe(true);
});
});
9 changes: 7 additions & 2 deletions frontend/src/components/data-table/columns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { type DateFormat, exactDateTime, getDateFormat } from "@/utils/dates";
import { Logger } from "@/utils/Logger";
import { Maps } from "@/utils/maps";
import { Objects } from "@/utils/objects";
import { MarkdownRenderer } from "../chat/markdown-renderer";
import { EmotionCacheProvider } from "../editor/output/EmotionCacheProvider";
import { JsonOutput } from "../editor/output/JsonOutput";
import { Button } from "../ui/button";
Expand All @@ -33,7 +34,7 @@ import {
INDEX_COLUMN_NAME,
} from "./types";
import { uniformSample } from "./uniformSample";
import { parseContent, UrlDetector } from "./url-detector";
import { isMarkdown, parseContent, UrlDetector } from "./url-detector";

// Artificial limit to display long strings
const MAX_STRING_LENGTH = 50;
Expand Down Expand Up @@ -531,7 +532,11 @@ export function renderCellValue<TData, TValue>({
buttonText="X"
wrapped={isWrapped}
>
<UrlDetector parts={parts} />
{isMarkdown(stringValue) ? (
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is expensive to do on each string and likely won't have that many hits. i'd prefer to not do this. was there an issue or reason that sparked this?

Copy link
Contributor Author

@Light2Dark Light2Dark Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, we don't need to do this, but it does offer a better view for markdown & images in the table

This logic should only trigger when the popup is clicked, so can be optimized (or already is but not obvious..)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isMarkdown seems to be called on call values regardless of being clicked.

Copy link
Contributor Author

@Light2Dark Light2Dark Nov 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrapped in a component to make sure it's called on trigger. If there's still a perf concern, we can remove this.

<MarkdownRenderer content={stringValue} />
) : (
<UrlDetector parts={parts} />
)}
</PopoutColumn>
);
}
Expand Down
30 changes: 30 additions & 0 deletions frontend/src/components/data-table/url-detector.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/* Copyright 2024 Marimo. All rights reserved. */

import { marked } from "marked";
import { useState } from "react";
import {
Popover,
Expand Down Expand Up @@ -82,6 +83,35 @@ export function parseContent(text: string): ContentPart[] {
});
}

export function isMarkdown(text: string): boolean {
const tokens = marked.lexer(text);

const commonMarkdownIndicators = [
"space",
"code",
"fences",
"heading",
"hr",
"link",
"blockquote",
"list",
"html",
"def",
"table",
"lheading",
"escape",
"tag",
"reflink",
"strong",
"codespan",
"url",
];

return commonMarkdownIndicators.some((type) =>
tokens.some((token) => token.type === type),
);
}

export const UrlDetector = ({ parts }: { parts: ContentPart[] }) => {
const markup = parts.map((part, idx) => {
if (part.type === "url") {
Expand Down
7 changes: 7 additions & 0 deletions frontend/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ export default defineConfig({
hooks: "parallel", // Maintain parallel hook execution from Vitest 1.x
},
watch: false,
server: {
deps: {
// Inline streamdown so it gets processed by Vite's transform pipeline.
// This allows CSS imports from streamdown's dependencies (e.g., katex CSS) to be processed.
inline: [/streamdown/],
},
},
},
resolve: {
tsconfigPaths: true,
Expand Down
51 changes: 51 additions & 0 deletions marimo/_smoke_tests/tables/markdown_example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import marimo

__generated_with = "0.17.6"
app = marimo.App(width="medium")


@app.cell
def _():
import marimo as mo
return (mo,)


@app.cell
def _(markdown_sample, mo):
mo.ui.table(
{
"heading": ["### Hello" * 8],
"code_blocks": ["```python\n print('hi')\n```" * 3],
"lists": ["- item 1\n* item 2\n1. item 3" * 3],
"markdown_sample": [markdown_sample],
}
)
return


@app.cell
def _():
markdown_sample = """
# Markdown showcase

## Features:
- **Lists:**
- Bullet points
- Numbered lists

- **Emphasis:** *italic*, **bold**, ***bold italic***
- **Links:** [Visit OpenAI](https://www.openai.com)
- **Images:**

![Picture](https://picsum.photos/200/300)

- **Blockquotes:**

> Markdown makes writing simple and beautiful!

Enjoy exploring markdown's versatility!"""
return (markdown_sample,)


if __name__ == "__main__":
app.run()
Loading