diff --git a/package.json b/package.json index 5602fb5a02..3fa5aed8f1 100644 --- a/package.json +++ b/package.json @@ -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" }, "devDependencies": { "@babel/cli": "7.24.7", diff --git a/packages/gamut/src/Markdown/__tests__/Markdown.test.tsx b/packages/gamut/src/Markdown/__tests__/Markdown.test.tsx index f89d8f48e4..90cea2daa5 100644 --- a/packages/gamut/src/Markdown/__tests__/Markdown.test.tsx +++ b/packages/gamut/src/Markdown/__tests__/Markdown.test.tsx @@ -213,6 +213,27 @@ describe('', () => { expect(document.querySelectorAll('video').length).toEqual(1); expect(screen.queryByTitle(mockTitle)).not.toBeInTheDocument(); }); + + it('Leaves inside inline code alone when a real video follows', () => { + renderView({ + text: 'The `` element needs a closing tag.\n\n\n Video not supported\n', + videoOverride: { component: MarkdownVideo }, + }); + expect(document.querySelector('code')?.textContent).toEqual(''); + screen.getByTitle(mockTitle); + }); + + it('Leaves inside fenced code blocks alone', () => { + renderView({ + text: '```html\n\n```', + videoOverride: { component: MarkdownVideo }, + }); + expect(document.querySelector('pre code')?.textContent).toContain( + '' + ); + expect(screen.queryByTitle(mockTitle)).not.toBeInTheDocument(); + }); + it('Wraps the markdown in a div by default (block)', () => { renderView({ text: basicMarkdown }); expect( diff --git a/packages/gamut/src/Markdown/index.tsx b/packages/gamut/src/Markdown/index.tsx index 6aea782ab3..b8445b5829 100644 --- a/packages/gamut/src/Markdown/index.tsx +++ b/packages/gamut/src/Markdown/index.tsx @@ -12,6 +12,7 @@ import { createInputOverride, createTagOverride, createVideoOverride, + createVideoParagraphOverride, MarkdownOverrideSetting, MarkdownOverrideSettings, standardOverrides, @@ -35,20 +36,6 @@ const preprocessingInstructions = createPreprocessingInstructions(); const isValidNode = () => true; -/** - * Puts each block on its own lines so marked emits an HTML block. - * A single-line ... is parsed as paragraph text, which wraps - * the video's block-level output in a . - */ -const normalizeVideoBlocks = (text: string) => - text.replace( - /(]*>)([\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]; @@ -144,6 +131,7 @@ export class Markdown extends PureComponent { component: Table, allowedAttributes: ['style'], }), + videoOverride && createVideoParagraphOverride('video'), videoOverride && createVideoOverride({ tagName: 'video', Override: videoOverride }), !skipDefaultOverrides.details && @@ -168,7 +156,7 @@ export class Markdown extends PureComponent { // Render markdown to html const rawHtml = inline ? marked.parseInline(text, markedOptions) - : marked(normalizeVideoBlocks(text), markedOptions); + : marked(text, markedOptions); const sanitizationConfig = { ...defaultSanitizationConfig, diff --git a/packages/gamut/src/Markdown/libs/overrides/index.tsx b/packages/gamut/src/Markdown/libs/overrides/index.tsx index 81c0bbe77b..71d91665a0 100644 --- a/packages/gamut/src/Markdown/libs/overrides/index.tsx +++ b/packages/gamut/src/Markdown/libs/overrides/index.tsx @@ -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(); @@ -214,6 +220,20 @@ export const createVideoOverride = ({ }, }); +// Drops the 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 {children}; + }, +}); + // Allows override and overrides of standard fenced codeblocks export const createCodeBlockOverride = ( tagName: string, diff --git a/packages/gamut/src/Markdown/libs/overrides/utils.tsx b/packages/gamut/src/Markdown/libs/overrides/utils.tsx index 4e359e2333..12587b95a8 100644 --- a/packages/gamut/src/Markdown/libs/overrides/utils.tsx +++ b/packages/gamut/src/Markdown/libs/overrides/utils.tsx @@ -10,6 +10,18 @@ export const isCheckboxParent = (node: HTMLToReactNode, type: string) => node.children[0]?.attribs?.type === type ); +// a that only holds video tags, i.e. a single-line that marked parsed as paragraph text +export const isVideoParent = (node: HTMLToReactNode, tagName: string) => + 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( diff --git a/packages/styleguide/src/lib/Organisms/Markdown/example.md b/packages/styleguide/src/lib/Organisms/Markdown/example.md index 8d9568d347..19dcc50ea7 100644 --- a/packages/styleguide/src/lib/Organisms/Markdown/example.md +++ b/packages/styleguide/src/lib/Organisms/Markdown/example.md @@ -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 `` element, the `` element requires a src attribute with a link to the video source. Unlike the `` element however, the `` element requires an opening and a closing tag. + + + +):) + + + Video not supported + + ### Philosophy Markdown is intended to be as easy-to-read and easy-to-write as is feasible. diff --git a/yarn.lock b/yarn.lock index 2894975226..26c0df1911 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"
. - */ -const normalizeVideoBlocks = (text: string) => - text.replace( - /(]*>)([\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]; @@ -144,6 +131,7 @@ export class Markdown extends PureComponent { component: Table, allowedAttributes: ['style'], }), + videoOverride && createVideoParagraphOverride('video'), videoOverride && createVideoOverride({ tagName: 'video', Override: videoOverride }), !skipDefaultOverrides.details && @@ -168,7 +156,7 @@ export class Markdown extends PureComponent { // Render markdown to html const rawHtml = inline ? marked.parseInline(text, markedOptions) - : marked(normalizeVideoBlocks(text), markedOptions); + : marked(text, markedOptions); const sanitizationConfig = { ...defaultSanitizationConfig, diff --git a/packages/gamut/src/Markdown/libs/overrides/index.tsx b/packages/gamut/src/Markdown/libs/overrides/index.tsx index 81c0bbe77b..71d91665a0 100644 --- a/packages/gamut/src/Markdown/libs/overrides/index.tsx +++ b/packages/gamut/src/Markdown/libs/overrides/index.tsx @@ -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(); @@ -214,6 +220,20 @@ export const createVideoOverride = ({ }, }); +// Drops the 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 {children}; + }, +}); + // Allows override and overrides of standard fenced codeblocks export const createCodeBlockOverride = ( tagName: string, diff --git a/packages/gamut/src/Markdown/libs/overrides/utils.tsx b/packages/gamut/src/Markdown/libs/overrides/utils.tsx index 4e359e2333..12587b95a8 100644 --- a/packages/gamut/src/Markdown/libs/overrides/utils.tsx +++ b/packages/gamut/src/Markdown/libs/overrides/utils.tsx @@ -10,6 +10,18 @@ export const isCheckboxParent = (node: HTMLToReactNode, type: string) => node.children[0]?.attribs?.type === type ); +// a that only holds video tags, i.e. a single-line that marked parsed as paragraph text +export const isVideoParent = (node: HTMLToReactNode, tagName: string) => + 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( diff --git a/packages/styleguide/src/lib/Organisms/Markdown/example.md b/packages/styleguide/src/lib/Organisms/Markdown/example.md index 8d9568d347..19dcc50ea7 100644 --- a/packages/styleguide/src/lib/Organisms/Markdown/example.md +++ b/packages/styleguide/src/lib/Organisms/Markdown/example.md @@ -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 `` element, the `` element requires a src attribute with a link to the video source. Unlike the `` element however, the `` element requires an opening and a closing tag. + + + +):) + + + Video not supported + + ### Philosophy Markdown is intended to be as easy-to-read and easy-to-write as is feasible. diff --git a/yarn.lock b/yarn.lock index 2894975226..26c0df1911 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"
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 {children}; + }, +}); + // Allows override and overrides of standard fenced codeblocks export const createCodeBlockOverride = ( tagName: string, diff --git a/packages/gamut/src/Markdown/libs/overrides/utils.tsx b/packages/gamut/src/Markdown/libs/overrides/utils.tsx index 4e359e2333..12587b95a8 100644 --- a/packages/gamut/src/Markdown/libs/overrides/utils.tsx +++ b/packages/gamut/src/Markdown/libs/overrides/utils.tsx @@ -10,6 +10,18 @@ export const isCheckboxParent = (node: HTMLToReactNode, type: string) => node.children[0]?.attribs?.type === type ); +// a
that only holds video tags, i.e. a single-line that marked parsed as paragraph text +export const isVideoParent = (node: HTMLToReactNode, tagName: string) => + 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( diff --git a/packages/styleguide/src/lib/Organisms/Markdown/example.md b/packages/styleguide/src/lib/Organisms/Markdown/example.md index 8d9568d347..19dcc50ea7 100644 --- a/packages/styleguide/src/lib/Organisms/Markdown/example.md +++ b/packages/styleguide/src/lib/Organisms/Markdown/example.md @@ -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 `` element, the `` element requires a src attribute with a link to the video source. Unlike the `` element however, the `` element requires an opening and a closing tag. + + + +):) + + + Video not supported + + ### Philosophy Markdown is intended to be as easy-to-read and easy-to-write as is feasible. diff --git a/yarn.lock b/yarn.lock index 2894975226..26c0df1911 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"