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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"lodash": "^4.17.23",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-helmet-async": "^2.0.5"
"react-helmet-async": "^2.0.5",
"react-player": "^3.4.0"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is also test code, yeah? i.e. to make testing easier?

},
"devDependencies": {
"@babel/cli": "7.24.7",
Expand Down
21 changes: 21 additions & 0 deletions packages/gamut/src/Markdown/__tests__/Markdown.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,27 @@ describe('<Markdown />', () => {
expect(document.querySelectorAll('video').length).toEqual(1);
expect(screen.queryByTitle(mockTitle)).not.toBeInTheDocument();
});

it('Leaves <video> inside inline code alone when a real video follows', () => {
renderView({
text: 'The `<video>` element needs a closing tag.\n\n<video src="/example.webm" controls>\n Video not supported\n</video>',
videoOverride: { component: MarkdownVideo },
});
expect(document.querySelector('code')?.textContent).toEqual('<video>');
screen.getByTitle(mockTitle);
});

it('Leaves <video> inside fenced code blocks alone', () => {
renderView({
text: '```html\n<video src="/example.webm"></video>\n```',
videoOverride: { component: MarkdownVideo },
});
expect(document.querySelector('pre code')?.textContent).toContain(
'<video src="/example.webm"></video>'
);
expect(screen.queryByTitle(mockTitle)).not.toBeInTheDocument();
});

it('Wraps the markdown in a div by default (block)', () => {
renderView({ text: basicMarkdown });
expect(
Expand Down
18 changes: 3 additions & 15 deletions packages/gamut/src/Markdown/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
createInputOverride,
createTagOverride,
createVideoOverride,
createVideoParagraphOverride,
MarkdownOverrideSetting,
MarkdownOverrideSettings,
standardOverrides,
Expand All @@ -35,20 +36,6 @@ const preprocessingInstructions = createPreprocessingInstructions();

const isValidNode = () => true;

/**
* Puts each <video> block on its own lines so marked emits an HTML block.
* A single-line <video>...</video> is parsed as paragraph text, which wraps
* the video's block-level output in a <p>.
*/
const normalizeVideoBlocks = (text: string) =>
text.replace(
/(<video\b[^>]*>)([\s\S]*?)(<\/video>)/gi,
(_match, open: string, inner: string, close: string) => {
const body = inner.trim();
return `\n\n${open}\n${body ? `${body}\n` : ''}${close}\n\n`;
}
);

const MarkdownWrapper = styled.div<{ spacing: 'loose' | 'tight' | 'none' }>`
${({ theme, spacing }) => {
const spacingStyleFunction = markdownStyles[spacing];
Expand Down Expand Up @@ -144,6 +131,7 @@ export class Markdown extends PureComponent<MarkdownProps> {
component: Table,
allowedAttributes: ['style'],
}),
videoOverride && createVideoParagraphOverride('video'),
videoOverride &&
createVideoOverride({ tagName: 'video', Override: videoOverride }),
!skipDefaultOverrides.details &&
Expand All @@ -168,7 +156,7 @@ export class Markdown extends PureComponent<MarkdownProps> {
// Render markdown to html
const rawHtml = inline
? marked.parseInline(text, markedOptions)
: marked(normalizeVideoBlocks(text), markedOptions);
: marked(text, markedOptions);

const sanitizationConfig = {
...defaultSanitizationConfig,
Expand Down
22 changes: 21 additions & 1 deletion packages/gamut/src/Markdown/libs/overrides/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@ import camelCaseMap from 'html-to-react/lib/camel-case-attribute-names';
import get from 'lodash/get';
import * as React from 'react';

import { getLabel, isCheckboxParent, isInput, isLabelText } from './utils';
import {
getLabel,
isCheckboxParent,
isInput,
isLabelText,
isVideoParent,
} from './utils';

const processNodeDefinitions = HtmlToReact.ProcessNodeDefinitions();

Expand Down Expand Up @@ -214,6 +220,20 @@ export const createVideoOverride = ({
},
});

// Drops the <p> marked wraps around a single-line video, since the Video component renders block-level elements
export const createVideoParagraphOverride = (tagName: string) => ({
shouldProcessNode(node: HTMLToReactNode) {
return isVideoParent(node, tagName);
},
processNode(
_node: HTMLToReactNode,
children: React.ReactNode[],
key: React.Key
) {
return <React.Fragment key={key}>{children}</React.Fragment>;
},
});

// Allows <CodeBlock></CodeBlock> override and overrides of standard fenced codeblocks
export const createCodeBlockOverride = (
tagName: string,
Expand Down
12 changes: 12 additions & 0 deletions packages/gamut/src/Markdown/libs/overrides/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,18 @@ export const isCheckboxParent = (node: HTMLToReactNode, type: string) =>
node.children[0]?.attribs?.type === type
);

// a <p> that only holds video tags, i.e. a single-line <video></video> that marked parsed as paragraph text
export const isVideoParent = (node: HTMLToReactNode, tagName: string) =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: the name throws me off a little and the need to have tagName as a parameter

name: isVideoOnlyParagraph is a 🤖 suggestion.

parameter: doesn't hurt I guess, but given how specific the function name is, seems like it's always checking 'video' that it might not be needed

Boolean(
node.name === 'p' &&
node.children?.some((child) => child.name === tagName) &&
node.children.every(
(child) =>
child.name === tagName ||
(child.type === 'text' && !child.data.trim())
)
);

export const isLabelText = (node: HTMLToReactNode, type: string) => {
// we are using this text to create the accessible label, so are basically deduplicating it here
return Boolean(
Expand Down
14 changes: 14 additions & 0 deletions packages/styleguide/src/lib/Organisms/Markdown/example.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,20 @@

## Overview

### Tests tests tests

In addition to images, HTML also supports displaying videos

Preview: Docs Embedding videos within a site creates lively and informative pages that are attractive to visitors. Like the `<img>` element, the `<video>` element requires a src attribute with a link to the video source. Unlike the `<img>` element however, the `<video>` element requires an opening and a closing tag.

<video>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

this lead me down a rabbit hole of when someone might write raw HTML and markdown does not like that lol

`<video>` test testtest  ✅ 
Yoo yoo <pre><video> chck </video></pre> ❌ 
<p><code><video> chceeek </video></code></p> ❌

Seems like when using code fences (```) or inline backticks, the < and > characters get converted to &lt; and &gt; respectively, so there's no need for the override to kick in.

TL;DR: writing raw HTML in a markdown component is an edgecase, but probs not one worth worrying about


):)

<video src="myVideo.mp4" width="320" height="240" controls>
Video not supported
</video>

### Philosophy

Markdown is intended to be as easy-to-read and easy-to-write as is feasible.
Expand Down
1 change: 1 addition & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -13442,6 +13442,7 @@ __metadata:
react: "npm:^19.0.0"
react-dom: "npm:^19.0.0"
react-helmet-async: "npm:^2.0.5"
react-player: "npm:^3.4.0"
react-test-renderer: "npm:^19.0.0"
start-server-and-test: "npm:^2.1.5"
storybook: "npm:^10.3.1"
Expand Down
Loading