Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/static_site_editor')
-rw-r--r--app/assets/javascripts/static_site_editor/components/edit_area.vue4
-rw-r--r--app/assets/javascripts/static_site_editor/pages/home.vue6
-rw-r--r--app/assets/javascripts/static_site_editor/rich_content_editor/constants.js57
-rw-r--r--app/assets/javascripts/static_site_editor/rich_content_editor/modals/add_image/add_image_modal.vue134
-rw-r--r--app/assets/javascripts/static_site_editor/rich_content_editor/modals/add_image/upload_image_tab.vue56
-rw-r--r--app/assets/javascripts/static_site_editor/rich_content_editor/modals/insert_video_modal.vue91
-rw-r--r--app/assets/javascripts/static_site_editor/rich_content_editor/rich_content_editor.vue150
-rw-r--r--app/assets/javascripts/static_site_editor/rich_content_editor/services/build_custom_renderer.js42
-rw-r--r--app/assets/javascripts/static_site_editor/rich_content_editor/services/build_html_to_markdown_renderer.js109
-rw-r--r--app/assets/javascripts/static_site_editor/rich_content_editor/services/editor_service.js116
-rw-r--r--app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token.js63
-rw-r--r--app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_attribute_definition.js7
-rw-r--r--app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_embedded_ruby_text.js9
-rw-r--r--app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_font_awesome_html_inline.js11
-rw-r--r--app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_heading.js6
-rw-r--r--app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_html_block.js23
-rw-r--r--app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_identifier_instance_text.js40
-rw-r--r--app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_identifier_paragraph.js40
-rw-r--r--app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_list_item.js6
-rw-r--r--app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_softbreak.js7
-rw-r--r--app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_utils.js38
-rw-r--r--app/assets/javascripts/static_site_editor/rich_content_editor/services/sanitize_html.js22
-rw-r--r--app/assets/javascripts/static_site_editor/rich_content_editor/toolbar_item.vue31
23 files changed, 1064 insertions, 4 deletions
diff --git a/app/assets/javascripts/static_site_editor/components/edit_area.vue b/app/assets/javascripts/static_site_editor/components/edit_area.vue
index a51a4f9f604..ea775eff358 100644
--- a/app/assets/javascripts/static_site_editor/components/edit_area.vue
+++ b/app/assets/javascripts/static_site_editor/components/edit_area.vue
@@ -1,7 +1,7 @@
<script>
+import { EDITOR_TYPES } from '~/static_site_editor/rich_content_editor/constants';
+import RichContentEditor from '~/static_site_editor/rich_content_editor/rich_content_editor.vue';
import parseSourceFile from '~/static_site_editor/services/parse_source_file';
-import { EDITOR_TYPES } from '~/vue_shared/components/rich_content_editor/constants';
-import RichContentEditor from '~/vue_shared/components/rich_content_editor/rich_content_editor.vue';
import imageRepository from '../image_repository';
import formatter from '../services/formatter';
import renderImage from '../services/renderers/render_image';
diff --git a/app/assets/javascripts/static_site_editor/pages/home.vue b/app/assets/javascripts/static_site_editor/pages/home.vue
index 49a2ca03ace..beec1b515ad 100644
--- a/app/assets/javascripts/static_site_editor/pages/home.vue
+++ b/app/assets/javascripts/static_site_editor/pages/home.vue
@@ -1,5 +1,5 @@
<script>
-import { deprecatedCreateFlash as createFlash } from '~/flash';
+import createFlash from '~/flash';
import Tracking from '~/tracking';
import EditArea from '../components/edit_area.vue';
@@ -45,7 +45,9 @@ export default {
return !this.appData.isSupportedContent;
},
error() {
- createFlash(LOAD_CONTENT_ERROR);
+ createFlash({
+ message: LOAD_CONTENT_ERROR,
+ });
},
},
},
diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/constants.js b/app/assets/javascripts/static_site_editor/rich_content_editor/constants.js
new file mode 100644
index 00000000000..cbb30baa488
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/rich_content_editor/constants.js
@@ -0,0 +1,57 @@
+import { __ } from '~/locale';
+
+export const CUSTOM_EVENTS = {
+ openAddImageModal: 'gl_openAddImageModal',
+ openInsertVideoModal: 'gl_openInsertVideoModal',
+};
+
+export const YOUTUBE_URL = 'https://www.youtube.com';
+
+export const YOUTUBE_EMBED_URL = `${YOUTUBE_URL}/embed`;
+
+export const ALLOWED_VIDEO_ORIGINS = [YOUTUBE_URL];
+
+/* eslint-disable @gitlab/require-i18n-strings */
+export const TOOLBAR_ITEM_CONFIGS = [
+ { icon: 'heading', event: 'openHeadingSelect', classes: 'tui-heading', tooltip: __('Headings') },
+ { icon: 'bold', command: 'Bold', tooltip: __('Add bold text') },
+ { icon: 'italic', command: 'Italic', tooltip: __('Add italic text') },
+ { icon: 'strikethrough', command: 'Strike', tooltip: __('Add strikethrough text') },
+ { isDivider: true },
+ { icon: 'quote', command: 'Blockquote', tooltip: __('Insert a quote') },
+ { icon: 'link', event: 'openPopupAddLink', tooltip: __('Add a link') },
+ { isDivider: true },
+ { icon: 'list-bulleted', command: 'UL', tooltip: __('Add a bullet list') },
+ { icon: 'list-numbered', command: 'OL', tooltip: __('Add a numbered list') },
+ { icon: 'list-task', command: 'Task', tooltip: __('Add a task list') },
+ { icon: 'list-indent', command: 'Indent', tooltip: __('Indent') },
+ { icon: 'list-outdent', command: 'Outdent', tooltip: __('Outdent') },
+ { isDivider: true },
+ { icon: 'dash', command: 'HR', tooltip: __('Add a line') },
+ { icon: 'table', event: 'openPopupAddTable', classes: 'tui-table', tooltip: __('Add a table') },
+ { icon: 'doc-image', event: CUSTOM_EVENTS.openAddImageModal, tooltip: __('Insert an image') },
+ { icon: 'live-preview', event: CUSTOM_EVENTS.openInsertVideoModal, tooltip: __('Insert video') },
+ { isDivider: true },
+ { icon: 'code', command: 'Code', tooltip: __('Insert inline code') },
+ { icon: 'doc-code', command: 'CodeBlock', tooltip: __('Insert a code block') },
+];
+
+export const EDITOR_TYPES = {
+ markdown: 'markdown',
+ wysiwyg: 'wysiwyg',
+};
+
+export const EDITOR_HEIGHT = '100%';
+
+export const EDITOR_PREVIEW_STYLE = 'horizontal';
+
+export const IMAGE_TABS = { UPLOAD_TAB: 0, URL_TAB: 1 };
+
+export const MAX_FILE_SIZE = 2097152; // 2Mb
+
+export const VIDEO_ATTRIBUTES = {
+ width: '560',
+ height: '315',
+ frameBorder: '0',
+ allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture',
+};
diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/modals/add_image/add_image_modal.vue b/app/assets/javascripts/static_site_editor/rich_content_editor/modals/add_image/add_image_modal.vue
new file mode 100644
index 00000000000..82060d2e4ad
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/rich_content_editor/modals/add_image/add_image_modal.vue
@@ -0,0 +1,134 @@
+<script>
+import { GlModal, GlFormGroup, GlFormInput, GlTabs, GlTab } from '@gitlab/ui';
+import { isSafeURL, joinPaths } from '~/lib/utils/url_utility';
+import { __ } from '~/locale';
+import { IMAGE_TABS } from '../../constants';
+import UploadImageTab from './upload_image_tab.vue';
+
+export default {
+ components: {
+ UploadImageTab,
+ GlModal,
+ GlFormGroup,
+ GlFormInput,
+ GlTabs,
+ GlTab,
+ },
+ props: {
+ imageRoot: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ file: null,
+ urlError: null,
+ imageUrl: null,
+ description: null,
+ tabIndex: IMAGE_TABS.UPLOAD_TAB,
+ uploadImageTab: null,
+ };
+ },
+ modalTitle: __('Image details'),
+ okTitle: __('Insert image'),
+ urlTabTitle: __('Link to an image'),
+ urlLabel: __('Image URL'),
+ descriptionLabel: __('Description'),
+ uploadTabTitle: __('Upload an image'),
+ computed: {
+ altText() {
+ return this.description;
+ },
+ },
+ methods: {
+ show() {
+ this.file = null;
+ this.urlError = null;
+ this.imageUrl = null;
+ this.description = null;
+ this.tabIndex = IMAGE_TABS.UPLOAD_TAB;
+
+ this.$refs.modal.show();
+ },
+ onOk(event) {
+ if (this.tabIndex === IMAGE_TABS.UPLOAD_TAB) {
+ this.submitFile(event);
+ return;
+ }
+ this.submitURL(event);
+ },
+ setFile(file) {
+ this.file = file;
+ },
+ submitFile(event) {
+ const { file, altText } = this;
+ const { uploadImageTab } = this.$refs;
+
+ uploadImageTab.validateFile();
+
+ if (uploadImageTab.fileError) {
+ event.preventDefault();
+ return;
+ }
+
+ const imageUrl = joinPaths(this.imageRoot, file.name);
+
+ this.$emit('addImage', { imageUrl, file, altText: altText || file.name });
+ },
+ submitURL(event) {
+ if (!this.validateUrl()) {
+ event.preventDefault();
+ return;
+ }
+
+ const { imageUrl, altText } = this;
+
+ this.$emit('addImage', { imageUrl, altText: altText || imageUrl });
+ },
+ validateUrl() {
+ if (!isSafeURL(this.imageUrl)) {
+ this.urlError = __('Please provide a valid URL');
+ this.$refs.urlInput.$el.focus();
+ return false;
+ }
+
+ return true;
+ },
+ },
+};
+</script>
+<template>
+ <gl-modal
+ ref="modal"
+ modal-id="add-image-modal"
+ :title="$options.modalTitle"
+ :ok-title="$options.okTitle"
+ @ok="onOk"
+ >
+ <gl-tabs v-model="tabIndex">
+ <!-- Upload file Tab -->
+ <gl-tab :title="$options.uploadTabTitle">
+ <upload-image-tab ref="uploadImageTab" @input="setFile" />
+ </gl-tab>
+
+ <!-- By URL Tab -->
+ <gl-tab :title="$options.urlTabTitle">
+ <gl-form-group
+ class="gl-mt-5 gl-mb-3"
+ :label="$options.urlLabel"
+ label-for="url-input"
+ :state="!Boolean(urlError)"
+ :invalid-feedback="urlError"
+ >
+ <gl-form-input id="url-input" ref="urlInput" v-model="imageUrl" />
+ </gl-form-group>
+ </gl-tab>
+ </gl-tabs>
+
+ <!-- Description Input -->
+ <gl-form-group :label="$options.descriptionLabel" label-for="description-input">
+ <gl-form-input id="description-input" ref="descriptionInput" v-model="description" />
+ </gl-form-group>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/modals/add_image/upload_image_tab.vue b/app/assets/javascripts/static_site_editor/rich_content_editor/modals/add_image/upload_image_tab.vue
new file mode 100644
index 00000000000..9baa7f286d7
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/rich_content_editor/modals/add_image/upload_image_tab.vue
@@ -0,0 +1,56 @@
+<script>
+import { GlFormGroup } from '@gitlab/ui';
+import { __ } from '~/locale';
+import { MAX_FILE_SIZE } from '../../constants';
+
+export default {
+ components: {
+ GlFormGroup,
+ },
+ data() {
+ return {
+ file: null,
+ fileError: null,
+ };
+ },
+ fileLabel: __('Select file'),
+ methods: {
+ onInput(event) {
+ [this.file] = event.target.files;
+
+ this.validateFile();
+
+ if (!this.fileError) {
+ this.$emit('input', this.file);
+ }
+ },
+ validateFile() {
+ this.fileError = null;
+
+ if (!this.file) {
+ this.fileError = __('Please choose a file');
+ } else if (this.file.size > MAX_FILE_SIZE) {
+ this.fileError = __('Maximum file size is 2MB. Please select a smaller file.');
+ }
+ },
+ },
+};
+</script>
+<template>
+ <gl-form-group
+ class="gl-mt-5 gl-mb-3"
+ :label="$options.fileLabel"
+ label-for="file-input"
+ :state="!Boolean(fileError)"
+ :invalid-feedback="fileError"
+ >
+ <input
+ id="file-input"
+ ref="fileInput"
+ class="gl-mt-3 gl-mb-2"
+ type="file"
+ accept="image/*"
+ @input="onInput"
+ />
+ </gl-form-group>
+</template>
diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/modals/insert_video_modal.vue b/app/assets/javascripts/static_site_editor/rich_content_editor/modals/insert_video_modal.vue
new file mode 100644
index 00000000000..99bb2080610
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/rich_content_editor/modals/insert_video_modal.vue
@@ -0,0 +1,91 @@
+<script>
+import { GlModal, GlFormGroup, GlFormInput, GlSprintf } from '@gitlab/ui';
+import { isSafeURL } from '~/lib/utils/url_utility';
+import { __ } from '~/locale';
+import { YOUTUBE_URL, YOUTUBE_EMBED_URL } from '../constants';
+
+export default {
+ components: {
+ GlModal,
+ GlFormGroup,
+ GlFormInput,
+ GlSprintf,
+ },
+ data() {
+ return {
+ url: null,
+ urlError: null,
+ description: __(
+ 'If the YouTube URL is https://www.youtube.com/watch?v=0t1DgySidms then the video ID is %{id}',
+ ),
+ };
+ },
+ modalTitle: __('Insert a video'),
+ okTitle: __('Insert video'),
+ label: __('YouTube URL or ID'),
+ methods: {
+ show() {
+ this.urlError = null;
+ this.url = null;
+
+ this.$refs.modal.show();
+ },
+ onPrimary(event) {
+ this.submitURL(event);
+ },
+ submitURL(event) {
+ const url = this.generateUrl();
+
+ if (!url) {
+ event.preventDefault();
+ return;
+ }
+
+ this.$emit('insertVideo', url);
+ },
+ generateUrl() {
+ let { url } = this;
+ const reYouTubeId = /^[A-z0-9]*$/;
+ const reYouTubeUrl = RegExp(`${YOUTUBE_URL}/(embed/|watch\\?v=)([A-z0-9]+)`);
+
+ if (reYouTubeId.test(url)) {
+ url = `${YOUTUBE_EMBED_URL}/${url}`;
+ } else if (reYouTubeUrl.test(url)) {
+ url = `${YOUTUBE_EMBED_URL}/${reYouTubeUrl.exec(url)[2]}`;
+ }
+
+ if (!isSafeURL(url) || !reYouTubeUrl.test(url)) {
+ this.urlError = __('Please provide a valid YouTube URL or ID');
+ this.$refs.urlInput.$el.focus();
+ return null;
+ }
+
+ return url;
+ },
+ },
+};
+</script>
+<template>
+ <gl-modal
+ ref="modal"
+ size="sm"
+ modal-id="insert-video-modal"
+ :title="$options.modalTitle"
+ :ok-title="$options.okTitle"
+ @primary="onPrimary"
+ >
+ <gl-form-group
+ :label="$options.label"
+ label-for="video-modal-url-input"
+ :state="!Boolean(urlError)"
+ :invalid-feedback="urlError"
+ >
+ <gl-form-input id="video-modal-url-input" ref="urlInput" v-model="url" />
+ <gl-sprintf slot="description" :message="description" class="text-gl-muted">
+ <template #id>
+ <strong>{{ __('0t1DgySidms') }}</strong>
+ </template>
+ </gl-sprintf>
+ </gl-form-group>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/rich_content_editor.vue b/app/assets/javascripts/static_site_editor/rich_content_editor/rich_content_editor.vue
new file mode 100644
index 00000000000..8988dab85d2
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/rich_content_editor/rich_content_editor.vue
@@ -0,0 +1,150 @@
+<script>
+import 'codemirror/lib/codemirror.css';
+import '@toast-ui/editor/dist/toastui-editor.css';
+
+import { EDITOR_TYPES, EDITOR_HEIGHT, EDITOR_PREVIEW_STYLE, CUSTOM_EVENTS } from './constants';
+import AddImageModal from './modals/add_image/add_image_modal.vue';
+import InsertVideoModal from './modals/insert_video_modal.vue';
+
+import {
+ registerHTMLToMarkdownRenderer,
+ getEditorOptions,
+ addCustomEventListener,
+ removeCustomEventListener,
+ addImage,
+ getMarkdown,
+ insertVideo,
+} from './services/editor_service';
+
+export default {
+ components: {
+ ToastEditor: () =>
+ import(/* webpackChunkName: 'toast_editor' */ '@toast-ui/vue-editor').then(
+ (toast) => toast.Editor,
+ ),
+ AddImageModal,
+ InsertVideoModal,
+ },
+ props: {
+ content: {
+ type: String,
+ required: true,
+ },
+ options: {
+ type: Object,
+ required: false,
+ default: () => null,
+ },
+ initialEditType: {
+ type: String,
+ required: false,
+ default: EDITOR_TYPES.wysiwyg,
+ },
+ height: {
+ type: String,
+ required: false,
+ default: EDITOR_HEIGHT,
+ },
+ previewStyle: {
+ type: String,
+ required: false,
+ default: EDITOR_PREVIEW_STYLE,
+ },
+ imageRoot: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ editorApi: null,
+ previousMode: null,
+ };
+ },
+ computed: {
+ editorInstance() {
+ return this.$refs.editor;
+ },
+ customEventListeners() {
+ return [
+ { event: CUSTOM_EVENTS.openAddImageModal, listener: this.onOpenAddImageModal },
+ { event: CUSTOM_EVENTS.openInsertVideoModal, listener: this.onOpenInsertVideoModal },
+ ];
+ },
+ },
+ created() {
+ this.editorOptions = getEditorOptions(this.options);
+ },
+ beforeDestroy() {
+ this.removeListeners();
+ },
+ methods: {
+ addListeners(editorApi) {
+ this.customEventListeners.forEach(({ event, listener }) => {
+ addCustomEventListener(editorApi, event, listener);
+ });
+
+ editorApi.eventManager.listen('changeMode', this.onChangeMode);
+ },
+ removeListeners() {
+ this.customEventListeners.forEach(({ event, listener }) => {
+ removeCustomEventListener(this.editorApi, event, listener);
+ });
+
+ this.editorApi.eventManager.removeEventHandler('changeMode', this.onChangeMode);
+ },
+ resetInitialValue(newVal) {
+ this.editorInstance.invoke('setMarkdown', newVal);
+ },
+ onContentChanged() {
+ this.$emit('input', getMarkdown(this.editorInstance));
+ },
+ onLoad(editorApi) {
+ this.editorApi = editorApi;
+
+ registerHTMLToMarkdownRenderer(editorApi);
+
+ this.addListeners(editorApi);
+
+ this.$emit('load', { formattedMarkdown: editorApi.getMarkdown() });
+ },
+ onOpenAddImageModal() {
+ this.$refs.addImageModal.show();
+ },
+ onAddImage({ imageUrl, altText, file }) {
+ const image = { imageUrl, altText };
+
+ if (file) {
+ this.$emit('uploadImage', { file, imageUrl });
+ }
+
+ addImage(this.editorInstance, image, file);
+ },
+ onOpenInsertVideoModal() {
+ this.$refs.insertVideoModal.show();
+ },
+ onInsertVideo(url) {
+ insertVideo(this.editorInstance, url);
+ },
+ onChangeMode(newMode) {
+ this.$emit('modeChange', newMode);
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <toast-editor
+ ref="editor"
+ :initial-value="content"
+ :options="editorOptions"
+ :preview-style="previewStyle"
+ :initial-edit-type="initialEditType"
+ :height="height"
+ @change="onContentChanged"
+ @load="onLoad"
+ />
+ <add-image-modal ref="addImageModal" :image-root="imageRoot" @addImage="onAddImage" />
+ <insert-video-modal ref="insertVideoModal" @insertVideo="onInsertVideo" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/build_custom_renderer.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/build_custom_renderer.js
new file mode 100644
index 00000000000..6ffd280e005
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/build_custom_renderer.js
@@ -0,0 +1,42 @@
+import { union, mapValues } from 'lodash';
+import renderAttributeDefinition from './renderers/render_attribute_definition';
+import renderFontAwesomeHtmlInline from './renderers/render_font_awesome_html_inline';
+import renderHeading from './renderers/render_heading';
+import renderBlockHtml from './renderers/render_html_block';
+import renderIdentifierInstanceText from './renderers/render_identifier_instance_text';
+import renderIdentifierParagraph from './renderers/render_identifier_paragraph';
+import renderListItem from './renderers/render_list_item';
+import renderSoftbreak from './renderers/render_softbreak';
+
+const htmlInlineRenderers = [renderFontAwesomeHtmlInline];
+const htmlBlockRenderers = [renderBlockHtml];
+const headingRenderers = [renderHeading];
+const paragraphRenderers = [renderIdentifierParagraph, renderBlockHtml];
+const textRenderers = [renderIdentifierInstanceText, renderAttributeDefinition];
+const listItemRenderers = [renderListItem];
+const softbreakRenderers = [renderSoftbreak];
+
+const executeRenderer = (renderers, node, context) => {
+ const availableRenderer = renderers.find((renderer) => renderer.canRender(node, context));
+
+ return availableRenderer ? availableRenderer.render(node, context) : context.origin();
+};
+
+const buildCustomHTMLRenderer = (customRenderers) => {
+ const renderersByType = {
+ ...customRenderers,
+ htmlBlock: union(htmlBlockRenderers, customRenderers?.htmlBlock),
+ htmlInline: union(htmlInlineRenderers, customRenderers?.htmlInline),
+ heading: union(headingRenderers, customRenderers?.heading),
+ item: union(listItemRenderers, customRenderers?.listItem),
+ paragraph: union(paragraphRenderers, customRenderers?.paragraph),
+ text: union(textRenderers, customRenderers?.text),
+ softbreak: union(softbreakRenderers, customRenderers?.softbreak),
+ };
+
+ return mapValues(renderersByType, (renderers) => {
+ return (node, context) => executeRenderer(renderers, node, context);
+ });
+};
+
+export default buildCustomHTMLRenderer;
diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/build_html_to_markdown_renderer.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/build_html_to_markdown_renderer.js
new file mode 100644
index 00000000000..273e0a59963
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/build_html_to_markdown_renderer.js
@@ -0,0 +1,109 @@
+/* eslint-disable @gitlab/require-i18n-strings */
+import { defaults, repeat } from 'lodash';
+
+const DEFAULTS = {
+ subListIndentSpaces: 4,
+ unorderedListBulletChar: '-',
+ incrementListMarker: false,
+ strong: '*',
+ emphasis: '_',
+};
+
+const countIndentSpaces = (text) => {
+ const matches = text.match(/^\s+/m);
+
+ return matches ? matches[0].length : 0;
+};
+
+const buildHTMLToMarkdownRender = (baseRenderer, formattingPreferences = {}) => {
+ const {
+ subListIndentSpaces,
+ unorderedListBulletChar,
+ incrementListMarker,
+ strong,
+ emphasis,
+ } = defaults(formattingPreferences, DEFAULTS);
+ const sublistNode = 'LI OL, LI UL';
+ const unorderedListItemNode = 'UL LI';
+ const orderedListItemNode = 'OL LI';
+ const emphasisNode = 'EM, I';
+ const strongNode = 'STRONG, B';
+ const headingNode = 'H1, H2, H3, H4, H5, H6';
+ const preCodeNode = 'PRE CODE';
+
+ return {
+ TEXT_NODE(node) {
+ return baseRenderer.getSpaceControlled(
+ baseRenderer.trim(baseRenderer.getSpaceCollapsedText(node.nodeValue)),
+ node,
+ );
+ },
+ /*
+ * This converter overwrites the default indented list converter
+ * to allow us to parameterize the number of indent spaces for
+ * sublists.
+ *
+ * See the original implementation in
+ * https://github.com/nhn/tui.editor/blob/master/libs/to-mark/src/renderer.basic.js#L161
+ */
+ [sublistNode](node, subContent) {
+ const baseResult = baseRenderer.convert(node, subContent);
+ // Default to 1 to prevent possible divide by 0
+ const firstLevelIndentSpacesCount = countIndentSpaces(baseResult) || 1;
+ const reindentedList = baseResult
+ .split('\n')
+ .map((line) => {
+ const itemIndentSpacesCount = countIndentSpaces(line);
+ const nestingLevel = Math.ceil(itemIndentSpacesCount / firstLevelIndentSpacesCount);
+ const indentSpaces = repeat(' ', subListIndentSpaces * nestingLevel);
+
+ return line.replace(/^ +/, indentSpaces);
+ })
+ .join('\n');
+
+ return reindentedList;
+ },
+ [unorderedListItemNode](node, subContent) {
+ const baseResult = baseRenderer.convert(node, subContent);
+ const formatted = baseResult.replace(/^(\s*)([*|-])/, `$1${unorderedListBulletChar}`);
+ const { attributeDefinition } = node.dataset;
+
+ return attributeDefinition ? `${formatted.trimRight()}\n${attributeDefinition}\n` : formatted;
+ },
+ [orderedListItemNode](node, subContent) {
+ const baseResult = baseRenderer.convert(node, subContent);
+
+ return incrementListMarker ? baseResult : baseResult.replace(/^(\s*)\d+?\./, '$11.');
+ },
+ [emphasisNode](node, subContent) {
+ const result = baseRenderer.convert(node, subContent);
+
+ return result.replace(/(^[*_]{1}|[*_]{1}$)/g, emphasis);
+ },
+ [strongNode](node, subContent) {
+ const result = baseRenderer.convert(node, subContent);
+ const strongSyntax = repeat(strong, 2);
+
+ return result.replace(/^[*_]{2}/, strongSyntax).replace(/[*_]{2}$/, strongSyntax);
+ },
+ [headingNode](node, subContent) {
+ const result = baseRenderer.convert(node, subContent);
+ const { attributeDefinition } = node.dataset;
+
+ return attributeDefinition ? `${result.trimRight()}\n${attributeDefinition}\n\n` : result;
+ },
+ [preCodeNode](node, subContent) {
+ const isReferenceDefinition = Boolean(node.dataset.sseReferenceDefinition);
+
+ return isReferenceDefinition
+ ? `\n\n${node.innerText}\n\n`
+ : baseRenderer.convert(node, subContent);
+ },
+ IMG(node) {
+ const { originalSrc } = node.dataset;
+ return `![${node.alt}](${originalSrc || node.src})`;
+ },
+ };
+};
+
+export default buildHTMLToMarkdownRender;
diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/editor_service.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/editor_service.js
new file mode 100644
index 00000000000..026a4069d9b
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/editor_service.js
@@ -0,0 +1,116 @@
+import { defaults } from 'lodash';
+import Vue from 'vue';
+import { TOOLBAR_ITEM_CONFIGS, VIDEO_ATTRIBUTES } from '../constants';
+import ToolbarItem from '../toolbar_item.vue';
+import buildCustomHTMLRenderer from './build_custom_renderer';
+import buildHtmlToMarkdownRenderer from './build_html_to_markdown_renderer';
+import sanitizeHTML from './sanitize_html';
+
+const buildWrapper = (propsData) => {
+ const instance = new Vue({
+ render(createElement) {
+ return createElement(ToolbarItem, propsData);
+ },
+ });
+
+ instance.$mount();
+ return instance.$el;
+};
+
+const buildVideoIframe = (src) => {
+ const wrapper = document.createElement('figure');
+ const iframe = document.createElement('iframe');
+ const videoAttributes = { ...VIDEO_ATTRIBUTES, src };
+ const wrapperClasses = ['gl-relative', 'gl-h-0', 'video_container'];
+ const iframeClasses = ['gl-absolute', 'gl-top-0', 'gl-left-0', 'gl-w-full', 'gl-h-full'];
+
+ wrapper.setAttribute('contenteditable', 'false');
+ wrapper.classList.add(...wrapperClasses);
+ iframe.classList.add(...iframeClasses);
+ Object.assign(iframe, videoAttributes);
+
+ wrapper.appendChild(iframe);
+
+ return wrapper;
+};
+
+const buildImg = (alt, originalSrc, file) => {
+ const img = document.createElement('img');
+ const src = file ? URL.createObjectURL(file) : originalSrc;
+ const attributes = { alt, src };
+
+ if (file) {
+ img.dataset.originalSrc = originalSrc;
+ }
+
+ Object.assign(img, attributes);
+
+ return img;
+};
+
+export const generateToolbarItem = (config) => {
+ const { icon, classes, event, command, tooltip, isDivider } = config;
+
+ if (isDivider) {
+ return 'divider';
+ }
+
+ return {
+ type: 'button',
+ options: {
+ el: buildWrapper({ props: { icon, tooltip }, class: classes }),
+ event,
+ command,
+ },
+ };
+};
+
+export const addCustomEventListener = (editorApi, event, handler) => {
+ editorApi.eventManager.addEventType(event);
+ editorApi.eventManager.listen(event, handler);
+};
+
+export const removeCustomEventListener = (editorApi, event, handler) =>
+ editorApi.eventManager.removeEventHandler(event, handler);
+
+export const addImage = ({ editor }, { altText, imageUrl }, file) => {
+ if (editor.isWysiwygMode()) {
+ const img = buildImg(altText, imageUrl, file);
+ editor.getSquire().insertElement(img);
+ } else {
+ editor.insertText(`![${altText}](${imageUrl})`);
+ }
+};
+
+export const insertVideo = ({ editor }, url) => {
+ const videoIframe = buildVideoIframe(url);
+
+ if (editor.isWysiwygMode()) {
+ editor.getSquire().insertElement(videoIframe);
+ } else {
+ editor.insertText(videoIframe.outerHTML);
+ }
+};
+
+export const getMarkdown = (editorInstance) => editorInstance.invoke('getMarkdown');
+
+/**
+ * This function allow us to extend Toast UI HTML to Markdown renderer. It is
+ * a temporary measure because Toast UI does not provide an API
+ * to achieve this goal.
+ */
+export const registerHTMLToMarkdownRenderer = (editorApi) => {
+ const { renderer } = editorApi.toMarkOptions;
+
+ Object.assign(editorApi.toMarkOptions, {
+ renderer: renderer.constructor.factory(renderer, buildHtmlToMarkdownRenderer(renderer)),
+ });
+};
+
+export const getEditorOptions = (externalOptions) => {
+ return defaults({
+ customHTMLRenderer: buildCustomHTMLRenderer(externalOptions?.customRenderers),
+ toolbarItems: TOOLBAR_ITEM_CONFIGS.map((toolbarItem) => generateToolbarItem(toolbarItem)),
+ customHTMLSanitizer: (html) => sanitizeHTML(html),
+ });
+};
diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token.js
new file mode 100644
index 00000000000..638e5fd6f60
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token.js
@@ -0,0 +1,63 @@
+const buildToken = (type, tagName, props) => {
+ return { type, tagName, ...props };
+};
+
+const TAG_TYPES = {
+ block: 'div',
+ inline: 'a',
+};
+
+// Open helpers (singular and multiple)
+
+const buildUneditableOpenToken = (tagType = TAG_TYPES.block) =>
+ buildToken('openTag', tagType, {
+ attributes: { contenteditable: false },
+ classNames: [
+ 'gl-px-4 gl-py-2 gl-my-5 gl-opacity-5 gl-bg-gray-100 gl-user-select-none gl-cursor-not-allowed',
+ ],
+ });
+
+export const buildUneditableOpenTokens = (token, tagType = TAG_TYPES.block) => {
+ return [buildUneditableOpenToken(tagType), token];
+};
+
+// Close helpers (singular and multiple)
+
+export const buildUneditableCloseToken = (tagType = TAG_TYPES.block) =>
+ buildToken('closeTag', tagType);
+
+export const buildUneditableCloseTokens = (token, tagType = TAG_TYPES.block) => {
+ return [token, buildUneditableCloseToken(tagType)];
+};
+
+// Complete helpers (open plus close)
+
+export const buildTextToken = (content) => buildToken('text', null, { content });
+
+export const buildUneditableBlockTokens = (token) => {
+ return [...buildUneditableOpenTokens(token), buildUneditableCloseToken()];
+};
+
+export const buildUneditableInlineTokens = (token) => {
+ return [
+ ...buildUneditableOpenTokens(token, TAG_TYPES.inline),
+ buildUneditableCloseToken(TAG_TYPES.inline),
+ ];
+};
+
+export const buildUneditableHtmlAsTextTokens = (node) => {
+ /*
+ Toast UI internally appends ' data-tomark-pass ' attribute flags so it can target certain
+ nested nodes for internal use during Markdown <=> WYSIWYG conversions. In our case, we want
+ to prevent HTML being rendered completely in WYSIWYG mode and thus we use a `text` vs. `html`
+ type when building the token. However, in doing so, we need to strip out the ` data-tomark-pass `
+ to prevent their persistence within the `text` content as the user did not intend these as edits.
+
+ https://github.com/nhn/tui.editor/blob/cc54ec224fc3a4b6e5a2b19a71650959f41adc0e/apps/editor/src/js/convertor.js#L72
+ */
+ const regex = / data-tomark-pass /gm;
+ const content = node.literal.replace(regex, '');
+ const htmlAsTextToken = buildToken('text', null, { content });
+
+ return [buildUneditableOpenToken(), htmlAsTextToken, buildUneditableCloseToken()];
+};
diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_attribute_definition.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_attribute_definition.js
new file mode 100644
index 00000000000..bd419447a48
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_attribute_definition.js
@@ -0,0 +1,7 @@
+import { isAttributeDefinition } from './render_utils';
+
+const canRender = ({ literal }) => isAttributeDefinition(literal);
+
+const render = () => ({ type: 'html', content: '<!-- sse-attribute-definition -->' });
+
+export default { canRender, render };
diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_embedded_ruby_text.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_embedded_ruby_text.js
new file mode 100644
index 00000000000..0e122f598e5
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_embedded_ruby_text.js
@@ -0,0 +1,9 @@
+import { renderUneditableLeaf as render } from './render_utils';
+
+const embeddedRubyRegex = /(^<%.+%>$)/;
+
+const canRender = ({ literal }) => {
+ return embeddedRubyRegex.test(literal);
+};
+
+export default { canRender, render };
diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_font_awesome_html_inline.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_font_awesome_html_inline.js
new file mode 100644
index 00000000000..572f6e3cf9d
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_font_awesome_html_inline.js
@@ -0,0 +1,11 @@
+import { buildUneditableInlineTokens } from './build_uneditable_token';
+
+const fontAwesomeRegexOpen = /<i class="fa.+>/;
+
+const canRender = ({ literal }) => {
+ return fontAwesomeRegexOpen.test(literal);
+};
+
+const render = (_, { origin }) => buildUneditableInlineTokens(origin());
+
+export default { canRender, render };
diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_heading.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_heading.js
new file mode 100644
index 00000000000..71026fd0d65
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_heading.js
@@ -0,0 +1,6 @@
+import {
+ renderWithAttributeDefinitions as render,
+ willAlwaysRender as canRender,
+} from './render_utils';
+
+export default { render, canRender };
diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_html_block.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_html_block.js
new file mode 100644
index 00000000000..710b807275b
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_html_block.js
@@ -0,0 +1,23 @@
+import { getURLOrigin } from '~/lib/utils/url_utility';
+import { ALLOWED_VIDEO_ORIGINS } from '../../constants';
+import { buildUneditableHtmlAsTextTokens } from './build_uneditable_token';
+
+const isVideoFrame = (html) => {
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(html, 'text/html');
+ const {
+ children: { length },
+ } = doc;
+ const iframe = doc.querySelector('iframe');
+ const origin = iframe && getURLOrigin(iframe.getAttribute('src'));
+
+ return length === 1 && ALLOWED_VIDEO_ORIGINS.includes(origin);
+};
+
+const canRender = ({ type, literal }) => {
+ return type === 'htmlBlock' && !isVideoFrame(literal);
+};
+
+const render = (node) => buildUneditableHtmlAsTextTokens(node);
+
+export default { canRender, render };
diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_identifier_instance_text.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_identifier_instance_text.js
new file mode 100644
index 00000000000..d770dd18d7f
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_identifier_instance_text.js
@@ -0,0 +1,40 @@
+import { buildTextToken, buildUneditableInlineTokens } from './build_uneditable_token';
+
+/*
+Use case examples:
+- Majority: two bracket pairs, back-to-back, each with content (including spaces)
+ - `[environment terraform plans][terraform]`
+ - `[an issue labelled `~"main:broken"`][broken-main-issues]`
+- Minority: two bracket pairs the latter being empty or only one pair with content (including spaces)
+ - `[this link][]`
+ - `[this link]`
+
+Regexp notes:
+ - `(?:\[.+?\]){1}`: Always one bracket pair with content (including spaces)
+ - `(?:\[\]|\[.+?\])?`: Optional second pair that may or may not contain content (including spaces)
+ - `(?!:)`: Never followed by a `:` which is reserved for identifier definition syntax (`[identifier]: /the-link`)
+ - Each of the three parts is non-captured, but the match as a whole is captured
+*/
+const identifierInstanceRegex = /((?:\[.+?\]){1}(?:\[\]|\[.+?\])?(?!:))/g;
+
+const isIdentifierInstance = (literal) => {
+ // Reset lastIndex as global flag in regexp are stateful (https://stackoverflow.com/a/11477448)
+ identifierInstanceRegex.lastIndex = 0;
+ return identifierInstanceRegex.test(literal);
+};
+
+const canRender = ({ literal }) => isIdentifierInstance(literal);
+
+const tokenize = (text) => {
+ const matches = text.split(identifierInstanceRegex);
+ const tokens = matches.map((match) => {
+ const token = buildTextToken(match);
+ return isIdentifierInstance(match) ? buildUneditableInlineTokens(token) : token;
+ });
+
+ return tokens.flat();
+};
+
+const render = (_, { origin }) => tokenize(origin().content);
+
+export default { canRender, render };
diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_identifier_paragraph.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_identifier_paragraph.js
new file mode 100644
index 00000000000..4829f0f2243
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_identifier_paragraph.js
@@ -0,0 +1,40 @@
+const identifierRegex = /(^\[.+\]: .+)/;
+
+const isIdentifier = (text) => {
+ return identifierRegex.test(text);
+};
+
+const canRender = (node, context) => {
+ return isIdentifier(context.getChildrenText(node));
+};
+
+const getReferenceDefinitions = (node, definitions = '') => {
+ if (!node) {
+ return definitions;
+ }
+
+ const definition = node.type === 'text' ? node.literal : '\n';
+
+ return getReferenceDefinitions(node.next, `${definitions}${definition}`);
+};
+
+const render = (node, { skipChildren }) => {
+ const content = getReferenceDefinitions(node.firstChild);
+
+ skipChildren();
+
+ return [
+ {
+ type: 'openTag',
+ tagName: 'pre',
+ classNames: ['code-block', 'language-markdown'],
+ attributes: { 'data-sse-reference-definition': true },
+ },
+ { type: 'openTag', tagName: 'code' },
+ { type: 'text', content },
+ { type: 'closeTag', tagName: 'code' },
+ { type: 'closeTag', tagName: 'pre' },
+ ];
+};
+
+export default { canRender, render };
diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_list_item.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_list_item.js
new file mode 100644
index 00000000000..71026fd0d65
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_list_item.js
@@ -0,0 +1,6 @@
+import {
+ renderWithAttributeDefinitions as render,
+ willAlwaysRender as canRender,
+} from './render_utils';
+
+export default { render, canRender };
diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_softbreak.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_softbreak.js
new file mode 100644
index 00000000000..c004e839821
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_softbreak.js
@@ -0,0 +1,7 @@
+const canRender = (node) => ['emph', 'strong'].includes(node.parent?.type);
+const render = () => ({
+ type: 'text',
+ content: ' ',
+});
+
+export default { canRender, render };
diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_utils.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_utils.js
new file mode 100644
index 00000000000..eff5dbf59f2
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_utils.js
@@ -0,0 +1,38 @@
+import {
+ buildUneditableBlockTokens,
+ buildUneditableOpenTokens,
+ buildUneditableCloseToken,
+} from './build_uneditable_token';
+
+export const renderUneditableLeaf = (_, { origin }) => buildUneditableBlockTokens(origin());
+
+export const renderUneditableBranch = (_, { entering, origin }) =>
+ entering ? buildUneditableOpenTokens(origin()) : buildUneditableCloseToken();
+
+const attributeDefinitionRegexp = /(^{:.+}$)/;
+
+export const isAttributeDefinition = (text) => attributeDefinitionRegexp.test(text);
+
+const findAttributeDefinition = (node) => {
+ const literal =
+ node?.next?.firstChild?.literal || node?.firstChild?.firstChild?.next?.next?.literal; // for headings // for list items;
+
+ return isAttributeDefinition(literal) ? literal : null;
+};
+
+export const renderWithAttributeDefinitions = (node, { origin }) => {
+ const attributes = findAttributeDefinition(node);
+ const token = origin();
+
+ if (token.type === 'openTag' && attributes) {
+ Object.assign(token, {
+ attributes: {
+ 'data-attribute-definition': attributes,
+ },
+ });
+ }
+
+ return token;
+};
+
+export const willAlwaysRender = () => true;
diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/sanitize_html.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/sanitize_html.js
new file mode 100644
index 00000000000..486d88466b7
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/sanitize_html.js
@@ -0,0 +1,22 @@
+import createSanitizer from 'dompurify';
+import { getURLOrigin } from '~/lib/utils/url_utility';
+import { ALLOWED_VIDEO_ORIGINS } from '../constants';
+
+const sanitizer = createSanitizer(window);
+const ADD_TAGS = ['iframe'];
+
+sanitizer.addHook('uponSanitizeElement', (node) => {
+ if (node.tagName !== 'IFRAME') {
+ return;
+ }
+
+ const origin = getURLOrigin(node.getAttribute('src'));
+
+ if (!ALLOWED_VIDEO_ORIGINS.includes(origin)) {
+ node.remove();
+ }
+});
+
+const sanitize = (content) => sanitizer.sanitize(content, { ADD_TAGS });
+
+export default sanitize;
diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/toolbar_item.vue b/app/assets/javascripts/static_site_editor/rich_content_editor/toolbar_item.vue
new file mode 100644
index 00000000000..85a67c087bb
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/rich_content_editor/toolbar_item.vue
@@ -0,0 +1,31 @@
+<script>
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlIcon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ icon: {
+ type: String,
+ required: true,
+ },
+ tooltip: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <button
+ v-gl-tooltip="{ title: tooltip }"
+ :aria-label="tooltip"
+ class="p-0 gl-display-flex toolbar-button"
+ >
+ <gl-icon class="gl-mx-auto gl-align-self-center" :name="icon" />
+ </button>
+</template>