diff --git a/app/assets/javascripts/content_editor/constants/index.js b/app/assets/javascripts/content_editor/constants/index.js index a39a243ec6b2f2f92e289b87168a604ef65aca55..c7fe64a5fb630e19eca0807cae27b6d1ffcedbb5 100644 --- a/app/assets/javascripts/content_editor/constants/index.js +++ b/app/assets/javascripts/content_editor/constants/index.js @@ -58,3 +58,9 @@ export const EXTENSION_PRIORITY_LOWER = 75; */ export const EXTENSION_PRIORITY_DEFAULT = 100; export const EXTENSION_PRIORITY_HIGHEST = 200; + +/** + * See lib/gitlab/file_type_detection.rb + */ +export const SAFE_VIDEO_EXT = ['mp4', 'm4v', 'mov', 'webm', 'ogv']; +export const SAFE_AUDIO_EXT = ['mp3', 'oga', 'ogg', 'spx', 'wav']; diff --git a/app/assets/javascripts/content_editor/extensions/sourcemap.js b/app/assets/javascripts/content_editor/extensions/sourcemap.js index f9de71f601b61c1202943e82880fa5ccc7dac621..ef02bc872e26319eb33bd04d06e42971abb4dc8d 100644 --- a/app/assets/javascripts/content_editor/extensions/sourcemap.js +++ b/app/assets/javascripts/content_editor/extensions/sourcemap.js @@ -1,4 +1,5 @@ import { Extension } from '@tiptap/core'; +import Audio from './audio'; import Blockquote from './blockquote'; import Bold from './bold'; import BulletList from './bullet_list'; @@ -25,12 +26,14 @@ import Table from './table'; import TableCell from './table_cell'; import TableHeader from './table_header'; import TableRow from './table_row'; +import Video from './video'; export default Extension.create({ addGlobalAttributes() { return [ { types: [ + Audio.name, Bold.name, Blockquote.name, BulletList.name, @@ -56,6 +59,7 @@ export default Extension.create({ TableCell.name, TableHeader.name, TableRow.name, + Video.name, ...HTMLNodes.map((htmlNode) => htmlNode.name), ], attributes: { diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js index 472a0a4815b5efdc566c109b68ebad8585706429..12de6c41f775636a4a46af62b32953df920299fa 100644 --- a/app/assets/javascripts/content_editor/services/markdown_serializer.js +++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js @@ -108,7 +108,10 @@ const defaultSerializerConfig = { }, nodes: { - [Audio.name]: renderPlayable, + [Audio.name]: preserveUnchanged({ + render: renderPlayable, + inline: true, + }), [Blockquote.name]: preserveUnchanged((state, node) => { if (node.attrs.multiline) { state.write('>>>'); @@ -220,7 +223,10 @@ const defaultSerializerConfig = { else renderBulletList(state, node); }), [Text.name]: defaultMarkdownSerializer.nodes.text, - [Video.name]: renderPlayable, + [Video.name]: preserveUnchanged({ + render: renderPlayable, + inline: true, + }), [WordBreak.name]: (state) => state.write('<wbr>'), ...HTMLNodes.reduce((serializers, htmlNode) => { return { diff --git a/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js b/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js index 8a15633708f83c86c226615314beb63163d79824..e5b78fa3d9bfc4f14ac56137fb59554c30ab599b 100644 --- a/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js +++ b/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js @@ -1,7 +1,10 @@ import { render } from '~/lib/gfm'; import { isValidAttribute } from '~/lib/dompurify'; +import { SAFE_AUDIO_EXT, SAFE_VIDEO_EXT } from '../constants'; import { createProseMirrorDocFromMdastTree } from './hast_to_prosemirror_converter'; +const ALL_AUDIO_VIDEO_EXT = [...SAFE_AUDIO_EXT, ...SAFE_VIDEO_EXT]; + const wrappableTags = ['img', 'br', 'code', 'i', 'em', 'b', 'strong', 'a', 'strike', 's', 'del']; const isTaskItem = (hastNode) => { @@ -17,6 +20,26 @@ const getTableCellAttrs = (hastNode) => ({ rowspan: parseInt(hastNode.properties.rowSpan, 10) || 1, }); +const getMediaAttrs = (hastNode) => ({ + src: hastNode.properties.src, + canonicalSrc: hastNode.properties.identifier ?? hastNode.properties.src, + isReference: hastNode.properties.isReference === 'true', + title: hastNode.properties.title, + alt: hastNode.properties.alt, +}); + +const isMediaTag = (hastNode) => hastNode.tagName === 'img' && Boolean(hastNode.properties); + +const extractMediaFileExtension = (url) => { + try { + const parsedUrl = new URL(url, window.location.origin); + + return /\.(\w+)$/.exec(parsedUrl.pathname)?.[1] ?? null; + } catch { + return null; + } +}; + const factorySpecs = { blockquote: { type: 'block', selector: 'blockquote' }, paragraph: { type: 'block', selector: 'p' }, @@ -121,16 +144,26 @@ const factorySpecs = { selector: 'pre', wrapInParagraph: true, }, + audio: { + type: 'inline', + selector: (hastNode) => + isMediaTag(hastNode) && + SAFE_AUDIO_EXT.includes(extractMediaFileExtension(hastNode.properties.src)), + getAttrs: getMediaAttrs, + }, image: { type: 'inline', - selector: 'img', - getAttrs: (hastNode) => ({ - src: hastNode.properties.src, - canonicalSrc: hastNode.properties.identifier ?? hastNode.properties.src, - isReference: hastNode.properties.isReference === 'true', - title: hastNode.properties.title, - alt: hastNode.properties.alt, - }), + selector: (hastNode) => + isMediaTag(hastNode) && + !ALL_AUDIO_VIDEO_EXT.includes(extractMediaFileExtension(hastNode.properties.src)), + getAttrs: getMediaAttrs, + }, + video: { + type: 'inline', + selector: (hastNode) => + isMediaTag(hastNode) && + SAFE_VIDEO_EXT.includes(extractMediaFileExtension(hastNode.properties.src)), + getAttrs: getMediaAttrs, }, hardBreak: { type: 'inline', diff --git a/glfm_specification/example_snapshots/examples_index.yml b/glfm_specification/example_snapshots/examples_index.yml index 4c1123383527f157028c52ee2115513871cd9caa..bd295baaad0bb994c5417ac6c04864c7e4d9dad1 100644 --- a/glfm_specification/example_snapshots/examples_index.yml +++ b/glfm_specification/example_snapshots/examples_index.yml @@ -2042,3 +2042,15 @@ 07_03_00__gitlab_specific_markdown__front_matter__005: spec_txt_example_position: 683 source_specification: gitlab +07_04_00__gitlab_specific_markdown__audio__001: + spec_txt_example_position: 684 + source_specification: gitlab +07_04_00__gitlab_specific_markdown__audio__002: + spec_txt_example_position: 685 + source_specification: gitlab +07_05_00__gitlab_specific_markdown__video__001: + spec_txt_example_position: 686 + source_specification: gitlab +07_05_00__gitlab_specific_markdown__video__002: + spec_txt_example_position: 687 + source_specification: gitlab diff --git a/glfm_specification/example_snapshots/html.yml b/glfm_specification/example_snapshots/html.yml index 9aeb54ef4deaf86e88c918d0968d7c676a6ec933..196cd4f748dbca0ee1cd0a07cd1bcecc25c009ee 100644 --- a/glfm_specification/example_snapshots/html.yml +++ b/glfm_specification/example_snapshots/html.yml @@ -7820,3 +7820,33 @@ wysiwyg: |- <hr> <h2>title: YAML front matter</h2> +07_04_00__gitlab_specific_markdown__audio__001: + canonical: | + <p><audio src="audio.oga" title="audio title"></audio></p> + static: |- + <p data-sourcepos="1:1-1:33" dir="auto"><span class="media-container audio-container"><audio src="audio.oga" controls="true" data-setup="{}" data-title="audio title"></audio><a href="audio.oga" target="_blank" rel="noopener noreferrer" title="Download 'audio title'">audio title</a></span></p> + wysiwyg: |- + <p><span class="media-container audio-container"><audio src="audio.oga" controls="true" data-setup="{}" data-title="audio"></audio><a href="audio.oga">audio</a></span></p> +07_04_00__gitlab_specific_markdown__audio__002: + canonical: | + <p><audio src="audio.oga" title="audio title"></audio></p> + static: |- + <p data-sourcepos="3:1-3:15" dir="auto"><span class="media-container audio-container"><audio src="audio.oga" controls="true" data-setup="{}" data-title="audio title"></audio><a href="audio.oga" target="_blank" rel="noopener noreferrer" title="Download 'audio title'">audio title</a></span></p> + wysiwyg: |- + <pre>[audio]: audio.oga "audio title"</pre> + <p><span class="media-container audio-container"><audio src="audio.oga" controls="true" data-setup="{}" data-title="audio"></audio><a href="audio.oga">audio</a></span></p> +07_05_00__gitlab_specific_markdown__video__001: + canonical: | + <p><video src="video.m4v" title="video title"></video></p> + static: |- + <p data-sourcepos="1:1-1:33" dir="auto"><span class="media-container video-container"><video src="video.m4v" controls="true" data-setup="{}" data-title="video title" width="400" preload="metadata"></video><a href="video.m4v" target="_blank" rel="noopener noreferrer" title="Download 'video title'">video title</a></span></p> + wysiwyg: |- + <p><span class="media-container video-container"><video src="video.m4v" controls="true" data-setup="{}" data-title="video"></video><a href="video.m4v">video</a></span></p> +07_05_00__gitlab_specific_markdown__video__002: + canonical: | + <p><video src="video.mov" title="video title"></video></p> + static: |- + <p data-sourcepos="3:1-3:15" dir="auto"><span class="media-container video-container"><video src="video.mov" controls="true" data-setup="{}" data-title="video title" width="400" preload="metadata"></video><a href="video.mov" target="_blank" rel="noopener noreferrer" title="Download 'video title'">video title</a></span></p> + wysiwyg: |- + <pre>[video]: video.mov "video title"</pre> + <p><span class="media-container video-container"><video src="video.mov" controls="true" data-setup="{}" data-title="video"></video><a href="video.mov">video</a></span></p> diff --git a/glfm_specification/example_snapshots/markdown.yml b/glfm_specification/example_snapshots/markdown.yml index 0d2387434d1b2e55917ed417a3f9586d35f7d912..c2b2caacb5bdbe6348e13b39f0bee814813ccb77 100644 --- a/glfm_specification/example_snapshots/markdown.yml +++ b/glfm_specification/example_snapshots/markdown.yml @@ -2227,3 +2227,15 @@ --- title: YAML front matter --- +07_04_00__gitlab_specific_markdown__audio__001: | +  +07_04_00__gitlab_specific_markdown__audio__002: | + [audio]: audio.oga "audio title" + + ![audio][audio] +07_05_00__gitlab_specific_markdown__video__001: | +  +07_05_00__gitlab_specific_markdown__video__002: | + [video]: video.mov "video title" + + ![video][video] diff --git a/glfm_specification/example_snapshots/prosemirror_json.yml b/glfm_specification/example_snapshots/prosemirror_json.yml index 35f8314b1bc3be0f085d955b951d06c8a6a69090..02330019a8d8a01bb89dc43e22118d04fcf8dc6e 100644 --- a/glfm_specification/example_snapshots/prosemirror_json.yml +++ b/glfm_specification/example_snapshots/prosemirror_json.yml @@ -20782,3 +20782,111 @@ } ] } +07_04_00__gitlab_specific_markdown__audio__001: |- + { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "audio", + "attrs": { + "uploading": false, + "src": "audio.oga", + "canonicalSrc": "audio.oga", + "alt": "audio" + } + } + ] + } + ] + } +07_04_00__gitlab_specific_markdown__audio__002: |- + { + "type": "doc", + "content": [ + { + "type": "referenceDefinition", + "attrs": { + "identifier": "audio", + "url": "audio.oga", + "title": "audio title" + }, + "content": [ + { + "type": "text", + "text": "[audio]: audio.oga \"audio title\"" + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "audio", + "attrs": { + "uploading": false, + "src": "audio.oga", + "canonicalSrc": "audio", + "alt": "audio" + } + } + ] + } + ] + } +07_05_00__gitlab_specific_markdown__video__001: |- + { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "video", + "attrs": { + "uploading": false, + "src": "video.m4v", + "canonicalSrc": "video.m4v", + "alt": "video" + } + } + ] + } + ] + } +07_05_00__gitlab_specific_markdown__video__002: |- + { + "type": "doc", + "content": [ + { + "type": "referenceDefinition", + "attrs": { + "identifier": "video", + "url": "video.mov", + "title": "video title" + }, + "content": [ + { + "type": "text", + "text": "[video]: video.mov \"video title\"" + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "video", + "attrs": { + "uploading": false, + "src": "video.mov", + "canonicalSrc": "video", + "alt": "video" + } + } + ] + } + ] + } diff --git a/glfm_specification/input/gitlab_flavored_markdown/glfm_canonical_examples.txt b/glfm_specification/input/gitlab_flavored_markdown/glfm_canonical_examples.txt index 3fa59a9f7b1f016443f8bf1aebd204803694b8c4..fd540a905bd6730b8fcefb6d25c471e32cfb7707 100644 --- a/glfm_specification/input/gitlab_flavored_markdown/glfm_canonical_examples.txt +++ b/glfm_specification/input/gitlab_flavored_markdown/glfm_canonical_examples.txt @@ -199,3 +199,54 @@ title: YAML front matter <hr> <h2>title: YAML front matter</h2> ```````````````````````````````` + +## Audio + +See +[audio](https://docs.gitlab.com/ee/user/markdown.html#audio) in the GitLab Flavored Markdown documentation. + +GLFM renders image elements as an audio player as long as the resource’s file extension is +one of the following supported audio extensions `.mp3`, `.oga`, `.ogg`, `.spx`, and `.wav`. +Audio ignore the alternative text part of an image declaration. + +```````````````````````````````` example gitlab audio + +. +<p><audio src="audio.oga" title="audio title"></audio></p> +```````````````````````````````` + +Reference definitions work audio as well: + +```````````````````````````````` example gitlab audio +[audio]: audio.oga "audio title" + +![audio][audio] +. +<p><audio src="audio.oga" title="audio title"></audio></p> +```````````````````````````````` + +## Video + +See +[videos](https://docs.gitlab.com/ee/user/markdown.html#videos) in the GitLab Flavored Markdown documentation. + +GLFM renders image elements as a video player as long as the resource’s file extension is +one of the following supported video extensions `.mp4`, `.m4v`, `.mov`, `.webm`, and `.ogv`. +Videos ignore the alternative text part of an image declaration. + + +```````````````````````````````` example gitlab video + +. +<p><video src="video.m4v" title="video title"></video></p> +```````````````````````````````` + +Reference definitions work video as well: + +```````````````````````````````` example gitlab video +[video]: video.mov "video title" + +![video][video] +. +<p><video src="video.mov" title="video title"></video></p> +```````````````````````````````` diff --git a/glfm_specification/output/spec.txt b/glfm_specification/output/spec.txt index 49018b0222038fb6375255beb624f520f7f15634..2939e0d2fbde513d8e7803dad3165f8cb351236d 100644 --- a/glfm_specification/output/spec.txt +++ b/glfm_specification/output/spec.txt @@ -9802,6 +9802,57 @@ title: YAML front matter <h2>title: YAML front matter</h2> ```````````````````````````````` +## Audio + +See +[audio](https://docs.gitlab.com/ee/user/markdown.html#audio) in the GitLab Flavored Markdown documentation. + +GLFM renders image elements as an audio player as long as the resource’s file extension is +one of the following supported audio extensions `.mp3`, `.oga`, `.ogg`, `.spx`, and `.wav`. +Audio ignore the alternative text part of an image declaration. + +```````````````````````````````` example gitlab audio + +. +<p><audio src="audio.oga" title="audio title"></audio></p> +```````````````````````````````` + +Reference definitions work audio as well: + +```````````````````````````````` example gitlab audio +[audio]: audio.oga "audio title" + +![audio][audio] +. +<p><audio src="audio.oga" title="audio title"></audio></p> +```````````````````````````````` + +## Video + +See +[videos](https://docs.gitlab.com/ee/user/markdown.html#videos) in the GitLab Flavored Markdown documentation. + +GLFM renders image elements as a video player as long as the resource’s file extension is +one of the following supported video extensions `.mp4`, `.m4v`, `.mov`, `.webm`, and `.ogv`. +Videos ignore the alternative text part of an image declaration. + + +```````````````````````````````` example gitlab video + +. +<p><video src="video.m4v" title="video title"></video></p> +```````````````````````````````` + +Reference definitions work video as well: + +```````````````````````````````` example gitlab video +[video]: video.mov "video title" + +![video][video] +. +<p><video src="video.mov" title="video title"></video></p> +```````````````````````````````` + <!-- END TESTS --> # Appendix: A parsing strategy diff --git a/spec/frontend/content_editor/remark_markdown_processing_spec.js b/spec/frontend/content_editor/remark_markdown_processing_spec.js index 7ae0a7c13c151153c91289e8263903cc1bdec908..09b9e7020af47df3b1d284aa81cbeff25a197e56 100644 --- a/spec/frontend/content_editor/remark_markdown_processing_spec.js +++ b/spec/frontend/content_editor/remark_markdown_processing_spec.js @@ -1,3 +1,4 @@ +import Audio from '~/content_editor/extensions/audio'; import Bold from '~/content_editor/extensions/bold'; import Blockquote from '~/content_editor/extensions/blockquote'; import BulletList from '~/content_editor/extensions/bullet_list'; @@ -25,13 +26,16 @@ import TableRow from '~/content_editor/extensions/table_row'; import TableCell from '~/content_editor/extensions/table_cell'; import TaskList from '~/content_editor/extensions/task_list'; import TaskItem from '~/content_editor/extensions/task_item'; +import Video from '~/content_editor/extensions/video'; import remarkMarkdownDeserializer from '~/content_editor/services/remark_markdown_deserializer'; import markdownSerializer from '~/content_editor/services/markdown_serializer'; +import { SAFE_VIDEO_EXT, SAFE_AUDIO_EXT } from '~/content_editor/constants'; import { createTestEditor, createDocBuilder } from './test_utils'; const tiptapEditor = createTestEditor({ extensions: [ + Audio, Blockquote, Bold, BulletList, @@ -57,6 +61,7 @@ const tiptapEditor = createTestEditor({ TableCell, TaskList, TaskItem, + Video, ...HTMLNodes, ], }); @@ -65,6 +70,7 @@ const { builders: { doc, paragraph, + audio, bold, blockquote, bulletList, @@ -91,10 +97,12 @@ const { tableCell, taskItem, taskList, + video, }, } = createDocBuilder({ tiptapEditor, names: { + audio: { nodeType: Audio.name }, blockquote: { nodeType: Blockquote.name }, bold: { markType: Bold.name }, bulletList: { nodeType: BulletList.name }, @@ -120,6 +128,7 @@ const { tableRow: { nodeType: TableRow.name }, taskItem: { nodeType: TaskItem.name }, taskList: { nodeType: TaskList.name }, + video: { nodeType: Video.name }, ...HTMLNodes.reduce( (builders, htmlNode) => ({ ...builders, @@ -1233,6 +1242,44 @@ title: 'layout' ), ), }, + ...SAFE_AUDIO_EXT.map((extension) => { + const src = `http://test.host/video.${extension}`; + const markdown = ``; + + return { + markdown, + expectedDoc: doc( + paragraph( + source(markdown), + audio({ + ...source(markdown), + canonicalSrc: src, + src, + alt: 'audio', + }), + ), + ), + }; + }), + ...SAFE_VIDEO_EXT.map((extension) => { + const src = `http://test.host/video.${extension}`; + const markdown = ``; + + return { + markdown, + expectedDoc: doc( + paragraph( + source(markdown), + video({ + ...source(markdown), + canonicalSrc: src, + src, + alt: 'video', + }), + ), + ), + }; + }), ]; const runOnly = examples.find((example) => example.only === true); diff --git a/spec/frontend/content_editor/render_html_and_json_for_all_examples.js b/spec/frontend/content_editor/render_html_and_json_for_all_examples.js index 4a57c7b19429926c5889678ce7e979579fc63e4b..fc88c0ea44540af535d7ca768e8b051ae4693e6e 100644 --- a/spec/frontend/content_editor/render_html_and_json_for_all_examples.js +++ b/spec/frontend/content_editor/render_html_and_json_for_all_examples.js @@ -1,6 +1,7 @@ import { DOMSerializer } from 'prosemirror-model'; // TODO: DRY up duplication with spec/frontend/content_editor/services/markdown_serializer_spec.js // See https://gitlab.com/groups/gitlab-org/-/epics/7719#plan +import Audio from '~/content_editor/extensions/audio'; import Blockquote from '~/content_editor/extensions/blockquote'; import Bold from '~/content_editor/extensions/bold'; import BulletList from '~/content_editor/extensions/bullet_list'; @@ -35,11 +36,13 @@ import TableHeader from '~/content_editor/extensions/table_header'; import TableRow from '~/content_editor/extensions/table_row'; import TaskItem from '~/content_editor/extensions/task_item'; import TaskList from '~/content_editor/extensions/task_list'; +import Video from '~/content_editor/extensions/video'; import createMarkdownDeserializer from '~/content_editor/services/remark_markdown_deserializer'; import { createTestEditor } from 'jest/content_editor/test_utils'; const tiptapEditor = createTestEditor({ extensions: [ + Audio, Blockquote, Bold, BulletList, @@ -74,6 +77,7 @@ const tiptapEditor = createTestEditor({ TableRow, TaskItem, TaskList, + Video, ], }); diff --git a/spec/frontend/content_editor/services/markdown_serializer_spec.js b/spec/frontend/content_editor/services/markdown_serializer_spec.js index 0e5281be9bf29e1e8c7c7e931add42f595c54f22..56394c85e8b26c652acbabdafbf593db018ae720 100644 --- a/spec/frontend/content_editor/services/markdown_serializer_spec.js +++ b/spec/frontend/content_editor/services/markdown_serializer_spec.js @@ -1,3 +1,4 @@ +import Audio from '~/content_editor/extensions/audio'; import Blockquote from '~/content_editor/extensions/blockquote'; import Bold from '~/content_editor/extensions/bold'; import BulletList from '~/content_editor/extensions/bullet_list'; @@ -33,6 +34,7 @@ import TableHeader from '~/content_editor/extensions/table_header'; import TableRow from '~/content_editor/extensions/table_row'; import TaskItem from '~/content_editor/extensions/task_item'; import TaskList from '~/content_editor/extensions/task_list'; +import Video from '~/content_editor/extensions/video'; import markdownSerializer from '~/content_editor/services/markdown_serializer'; import remarkMarkdownDeserializer from '~/content_editor/services/remark_markdown_deserializer'; import { createTestEditor, createDocBuilder } from '../test_utils'; @@ -41,6 +43,7 @@ jest.mock('~/emoji'); const tiptapEditor = createTestEditor({ extensions: [ + Audio, Blockquote, Bold, BulletList, @@ -73,6 +76,7 @@ const tiptapEditor = createTestEditor({ TableRow, TaskItem, TaskList, + Video, ...HTMLMarks, ...HTMLNodes, ], @@ -80,6 +84,7 @@ const tiptapEditor = createTestEditor({ const { builders: { + audio, doc, blockquote, bold, @@ -114,6 +119,7 @@ const { tableRow, taskItem, taskList, + video, }, } = createDocBuilder({ tiptapEditor, @@ -1230,6 +1236,21 @@ paragraph ); }); + it('serializes audio and video elements', () => { + expect( + serialize( + paragraph( + audio({ alt: 'audio', canonicalSrc: 'audio.mp3' }), + ' and ', + video({ alt: 'video', canonicalSrc: 'video.mov' }), + ), + ), + ).toBe( + ` + and `.trimLeft(), + ); + }); + const defaultEditAction = (initialContent) => { tiptapEditor.chain().setContent(initialContent.toJSON()).insertContent(' modified').run(); };