Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/feat-markdown-textblock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@stackwright/core": minor
"@stackwright/types": minor
---

feat(core,types): add format: markdown to TextBlock for CommonMark rendering via micromark
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,8 @@ The YAML key is the key used inside `content_items` entries. All types inherit `

| Type | Fields |
|---|---|
| `TextBlock` | `text` (string), `textSize` (TypographyVariant), `textColor`? (string) |
| `ButtonContent` | `text` (string), `textSize` (TypographyVariant), `textColor`? (string), `variant` (`text` | `outlined` | `contained`), `variantSize`? (`small` | `medium` | `large`), `href`? (string), `action`? (string), `icon`? (MediaItem), `alignment`? (`left` | `center` | `right`), `bgColor`? (string) |
| `TextBlock` | `text` (string), `textSize` (TypographyVariant), `textColor`? (string), `format`? (`plain` | `markdown`) |
| `ButtonContent` | `text` (string), `textSize` (TypographyVariant), `textColor`? (string), `format`? (`plain` | `markdown`), `variant` (`text` | `outlined` | `contained`), `variantSize`? (`small` | `medium` | `large`), `href`? (string), `action`? (string), `icon`? (MediaItem), `alignment`? (`left` | `center` | `right`), `bgColor`? (string) |
| `MediaItem` | Discriminated union: `type: "media"` \| `type: "icon"` \| `type: "image"` \| `type: "video"`. `type` field is required and acts as discriminator. |
| `ImageContent` | `label` (string), `color`? (string), `background`? (string), `src` (string), `alt`? (string), `height`? (number | string), `width`? (number | string), `style`? (`contained` | `overflow`), `type` ("image"), `aspect_ratio`? (number) |
| `IconContent` | `label` (string), `color`? (string), `background`? (string), `src` (string), `alt`? (string), `height`? (number | string), `width`? (number | string), `style`? (`contained` | `overflow`), `type` ("icon"), `size`? (number | TypographyVariant) |
Expand Down
4 changes: 2 additions & 2 deletions examples/stackwright-docs/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ The YAML key is the key used inside `content_items` entries. All types inherit `

| Type | Fields |
|---|---|
| `TextBlock` | `text` (string), `textSize` (TypographyVariant), `textColor`? (string) |
| `ButtonContent` | `text` (string), `textSize` (TypographyVariant), `textColor`? (string), `variant` (`text` | `outlined` | `contained`), `variantSize`? (`small` | `medium` | `large`), `href`? (string), `action`? (string), `icon`? (MediaItem), `alignment`? (`left` | `center` | `right`), `bgColor`? (string) |
| `TextBlock` | `text` (string), `textSize` (TypographyVariant), `textColor`? (string), `format`? (`plain` | `markdown`) |
| `ButtonContent` | `text` (string), `textSize` (TypographyVariant), `textColor`? (string), `format`? (`plain` | `markdown`), `variant` (`text` | `outlined` | `contained`), `variantSize`? (`small` | `medium` | `large`), `href`? (string), `action`? (string), `icon`? (MediaItem), `alignment`? (`left` | `center` | `right`), `bgColor`? (string) |
| `MediaItem` | Discriminated union: `type: "media"` \| `type: "icon"` \| `type: "image"` \| `type: "video"`. `type` field is required and acts as discriminator. |
| `ImageContent` | `label` (string), `color`? (string), `background`? (string), `src` (string), `alt`? (string), `height`? (number | string), `width`? (number | string), `style`? (`contained` | `overflow`), `type` ("image"), `aspect_ratio`? (number) |
| `IconContent` | `label` (string), `color`? (string), `background`? (string), `src` (string), `alt`? (string), `height`? (number | string), `width`? (number | string), `style`? (`contained` | `overflow`), `type` ("icon"), `size`? (number | TypographyVariant) |
Expand Down
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
"@stackwright/themes": "workspace:*",
"@stackwright/types": "workspace:*",
"js-yaml": "^4.1.0",
"micromark": "^4.0.1",
"prismjs": "^1.30.0",
"uuid": "^13.0.0",
"zod": "^4.4.3"
Expand Down
111 changes: 67 additions & 44 deletions packages/core/src/components/base/TextGrid.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React from 'react';
import { micromark } from 'micromark';
import { TextBlock } from '@stackwright/types';
import { v4 as uuidv4 } from 'uuid';
import { useSafeTheme } from '../../hooks/useSafeTheme';
Expand All @@ -13,6 +14,16 @@ interface TextGridProps {
};
}

/**
* Renders a TextBlock using CommonMark via micromark.
* micromark does NOT pass through raw HTML by default (allowDangerousHtml: false),
* so the output is XSS-safe by construction — no sanitization library needed.
*/
function renderMarkdown(text: string, color?: string): React.ReactNode {
const html = micromark(text);
return <div style={{ color: color ?? 'inherit' }} dangerouslySetInnerHTML={{ __html: html }} />;
}

/**
* Renders a string with inline markdown: **bold**, *italic*, `code`.
* Returns an array of React nodes safe to embed in JSX.
Expand Down Expand Up @@ -85,52 +96,64 @@ export function TextGrid({ content, config }: TextGridProps) {

return (
<>
{content.map((textItem) => (
<div key={uuidv4()}>
{textItem.text
.split('\n')
.filter((line) => line.trim() !== '')
.map((line) => {
const lineBlock: TextBlock = {
...textItem,
text: line,
};
return (
<div
key={uuidv4()}
style={{
display: 'flex',
alignItems: 'center',
gap: theme.spacing.md,
marginBottom: theme.spacing.xs,
}}
>
{startsWithBullet(line) && listIcon && (
<span
style={{
color: theme.colors.primary,
}}
>
{listIcon}
</span>
)}
{content.map((textItem) => {
// Markdown mode: pass the entire text to micromark, skip line-splitting and special chars
if (textItem.format === 'markdown') {
return (
<div key={uuidv4()}>
{renderMarkdown(textItem.text, textItem.textColor ?? theme.colors.text)}
</div>
);
}

{startsWithListNumber(line) && (
<span
style={{
color: theme.colors.primary,
}}
>
{listNumber++}.
</span>
)}
// Plain mode (default): existing line-splitting + bullet/list/special char logic
return (
<div key={uuidv4()}>
{textItem.text
.split('\n')
.filter((line) => line.trim() !== '')
.map((line) => {
const lineBlock: TextBlock = {
...textItem,
text: line,
};
return (
<div
key={uuidv4()}
style={{
display: 'flex',
alignItems: 'center',
gap: theme.spacing.md,
marginBottom: theme.spacing.xs,
}}
>
{startsWithBullet(line) && listIcon && (
<span
style={{
color: theme.colors.primary,
}}
>
{listIcon}
</span>
)}

{startsWithListNumber(line) && (
<span
style={{
color: theme.colors.primary,
}}
>
{listNumber++}.
</span>
)}

{renderText(lineBlock)}
</div>
);
})}
</div>
))}
{renderText(lineBlock)}
</div>
);
})}
</div>
);
})}
</>
);
}
103 changes: 103 additions & 0 deletions packages/core/test/components/text-block.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -345,3 +345,106 @@ describe('TextBlockGrid', () => {
expect(screen.getByText('After divider')).toBeInTheDocument();
});
});

describe('TextGrid — markdown format', () => {
it('renders bold text when format is markdown', () => {
const { container } = render(
<TextBlockGrid
label="md-test"
textBlocks={[{ text: '**Bold text**', textSize: 'body1', format: 'markdown' }]}
/>
);
expect(container.querySelector('strong')).toBeTruthy();
expect(container.querySelector('strong')?.textContent).toBe('Bold text');
});

it('renders italic text when format is markdown', () => {
const { container } = render(
<TextBlockGrid
label="md-test"
textBlocks={[{ text: '*Italic text*', textSize: 'body1', format: 'markdown' }]}
/>
);
expect(container.querySelector('em')).toBeTruthy();
expect(container.querySelector('em')?.textContent).toBe('Italic text');
});

it('renders a link when format is markdown', () => {
const { container } = render(
<TextBlockGrid
label="md-test"
textBlocks={[
{
text: '[Visit site](https://example.com)',
textSize: 'body1',
format: 'markdown',
},
]}
/>
);
const link = container.querySelector('a');
expect(link).toBeTruthy();
expect(link?.getAttribute('href')).toBe('https://example.com');
expect(link?.textContent).toBe('Visit site');
});

it('does NOT render raw HTML (XSS-safe by construction)', () => {
const { container } = render(
<TextBlockGrid
label="md-test"
textBlocks={[
{
text: '<script>alert("xss")</script> safe text',
textSize: 'body1',
format: 'markdown',
},
]}
/>
);
// micromark strips raw HTML by default — no script tag should appear
expect(container.querySelector('script')).toBeNull();
expect(container.textContent).toContain('safe text');
});

it('renders a markdown list when format is markdown', () => {
const { container } = render(
<TextBlockGrid
label="md-test"
textBlocks={[
{
text: '- Item one\n- Item two\n- Item three',
textSize: 'body1',
format: 'markdown',
},
]}
/>
);
const listItems = container.querySelectorAll('li');
expect(listItems.length).toBe(3);
expect(listItems[0].textContent).toBe('Item one');
expect(listItems[1].textContent).toBe('Item two');
});

it('backward compat: plain format (no format field) unchanged behavior', () => {
const { container } = render(
<TextBlockGrid
label="plain-test"
textBlocks={[{ text: '**Not bold** in plain mode', textSize: 'body1' }]}
/>
);
// Plain mode uses renderInlineMarkdown — **text** is rendered as <strong> via React children
// (not via dangerouslySetInnerHTML). Existing behavior is preserved.
expect(container.querySelector('strong')).toBeTruthy();
expect(container.querySelector('strong')?.textContent).toBe('Not bold');
});

it('backward compat: explicit format: plain unchanged behavior', () => {
render(
<TextBlockGrid
label="plain-test"
textBlocks={[{ text: 'Regular text', textSize: 'body1', format: 'plain' }]}
/>
);
expect(screen.getByText('Regular text')).toBeInTheDocument();
});
});
Loading
Loading