diff --git a/packages/frontend/@n8n/design-system/src/components/N8nMarkdown/Markdown.stories.ts b/packages/frontend/@n8n/design-system/src/components/N8nMarkdown/Markdown.stories.ts index b5e51396cb..27559ca16e 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nMarkdown/Markdown.stories.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nMarkdown/Markdown.stories.ts @@ -67,3 +67,19 @@ WithCheckboxes.args = { content: '__TODO__\n- [ ] Buy milk\n- [X] Buy socks\n', loading: false, }; + +const TemplateWithYoutubeEmbed: StoryFn = (args, { argTypes }) => ({ + setup: () => ({ args }), + props: Object.keys(argTypes), + components: { + N8nMarkdown, + }, + template: '', +}); + +export const WithYoutubeEmbed = TemplateWithYoutubeEmbed.bind({}); +WithYoutubeEmbed.args = { + content: + "## I'm markdown \n**Please check** this out. [Guide](https://docs.n8n.io/workflows/sticky-notes/)\n@[youtube](ZCuL2e4zC_4)\n", + loading: false, +}; diff --git a/packages/frontend/@n8n/design-system/src/components/N8nMarkdown/Markdown.test.ts b/packages/frontend/@n8n/design-system/src/components/N8nMarkdown/Markdown.test.ts index 63b085a159..784ad0f861 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nMarkdown/Markdown.test.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nMarkdown/Markdown.test.ts @@ -85,5 +85,22 @@ describe('components', () => { '<input type=“text” data-testid=“text-input” value=“Something”/>', ); }); + + it('should render YouTube embed player', () => { + const wrapper = render(N8nMarkdown, { + global: { + directives: { + n8nHtml, + }, + }, + props: { + content: '@[youtube](ZCuL2e4zC_4)\n', + }, + }); + + expect(wrapper.html()).toContain( + '

', + ); + }); }); }); diff --git a/packages/frontend/@n8n/design-system/src/components/N8nMarkdown/Markdown.vue b/packages/frontend/@n8n/design-system/src/components/N8nMarkdown/Markdown.vue index cbeb350dec..3688cc0265 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nMarkdown/Markdown.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nMarkdown/Markdown.vue @@ -7,6 +7,7 @@ import markdownTaskLists from 'markdown-it-task-lists'; import { computed, ref } from 'vue'; import xss, { friendlyAttrValue, whiteList } from 'xss'; +import { markdownYoutubeEmbed, YOUTUBE_EMBED_SRC_REGEX, type YoutubeEmbedConfig } from './youtube'; import { escapeMarkdown, toggleCheckbox } from '../../utils/markdown'; import N8nLoading from '../N8nLoading'; @@ -19,6 +20,7 @@ interface Options { markdown: MarkdownOptions; linkAttributes: markdownLink.Config; tasklists: markdownTaskLists.Config; + youtube: YoutubeEmbedConfig; } interface MarkdownProps { @@ -58,6 +60,7 @@ const props = withDefaults(defineProps(), { label: true, labelAfter: true, }, + youtube: {}, }), }); @@ -67,11 +70,22 @@ const { options } = props; const md = new Markdown(options.markdown) .use(markdownLink, options.linkAttributes) .use(markdownEmoji) - .use(markdownTaskLists, options.tasklists); + .use(markdownTaskLists, options.tasklists) + .use(markdownYoutubeEmbed, options.youtube); const xssWhiteList = { ...whiteList, label: ['class', 'for'], + iframe: [ + 'width', + 'height', + 'src', + 'title', + 'frameborder', + 'allow', + 'referrerpolicy', + 'allowfullscreen', + ], }; const htmlContent = computed(() => { @@ -112,6 +126,19 @@ const htmlContent = computed(() => { return ''; } } + + if (tag === 'iframe') { + if (name === 'src') { + // Only allow YouTube as src for iframes embeds + if (YOUTUBE_EMBED_SRC_REGEX.test(value)) { + return `src=${friendlyAttrValue(value)}`; + } else { + return ''; + } + } + return; + } + // Return nothing, means keep the default handling measure return; }, @@ -195,15 +222,18 @@ const onCheckboxChange = (index: number) => {