Skip to content
Open
Show file tree
Hide file tree
Changes from all 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);
});
});
22 changes: 15 additions & 7 deletions frontend/src/components/data-table/columns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { Maps } from "@/utils/maps";
import { Objects } from "@/utils/objects";
import { EmotionCacheProvider } from "../editor/output/EmotionCacheProvider";
import { JsonOutput } from "../editor/output/JsonOutput";
import { CopyClipboardIcon } from "../icons/copy-icon";
import { Button } from "../ui/button";
import { Checkbox } from "../ui/checkbox";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
Expand All @@ -33,7 +34,7 @@ import {
INDEX_COLUMN_NAME,
} from "./types";
import { uniformSample } from "./uniformSample";
import { parseContent, UrlDetector } from "./url-detector";
import { MarkdownUrlDetector, parseContent, UrlDetector } from "./url-detector";

// Artificial limit to display long strings
const MAX_STRING_LENGTH = 50;
Expand Down Expand Up @@ -354,11 +355,18 @@ const PopoutColumn = ({
align="start"
alignOffset={10}
>
<PopoverClose className="absolute top-2 right-2">
<Button variant="link" size="xs">
{buttonText ?? "Close"}
</Button>
</PopoverClose>
<div className="absolute top-2 right-2">
<CopyClipboardIcon
value={rawStringValue}
className="w-2.5 h-2.5"
tooltip={false}
/>
<PopoverClose>
<Button variant="link" size="xs">
{buttonText ?? "Close"}
</Button>
</PopoverClose>
</div>
{children}
</PopoverContent>
</Popover>
Expand Down Expand Up @@ -531,7 +539,7 @@ export function renderCellValue<TData, TValue>({
buttonText="X"
wrapped={isWrapped}
>
<UrlDetector parts={parts} />
<MarkdownUrlDetector content={stringValue} parts={parts} />
</PopoutColumn>
);
}
Expand Down
45 changes: 45 additions & 0 deletions frontend/src/components/data-table/url-detector.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
/* Copyright 2024 Marimo. All rights reserved. */

import { marked } from "marked";
import { useState } from "react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Events } from "@/utils/events";
import { MarkdownRenderer } from "../chat/markdown-renderer";

const urlRegex = /(https?:\/\/\S+)/;
const imageRegex = /\.(png|jpe?g|gif|webp|svg|ico)(\?.*)?$/i;
Expand Down Expand Up @@ -82,6 +84,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 Expand Up @@ -109,3 +140,17 @@ const URLAnchor = ({ url }: { url: string }) => {
</a>
);
};

// Wrapper component so that we call isMarkdown only on trigger
export const MarkdownUrlDetector = ({
content,
parts,
}: {
content: string;
parts: ContentPart[];
}) => {
if (isMarkdown(content)) {
return <MarkdownRenderer content={content} />;
}
return <UrlDetector parts={parts} />;
};
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