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/lib')
-rw-r--r--app/assets/javascripts/lib/dompurify.js37
-rw-r--r--app/assets/javascripts/lib/gfm/index.js50
-rw-r--r--app/assets/javascripts/lib/graphql.js1
-rw-r--r--app/assets/javascripts/lib/markdown_it.js11
-rw-r--r--app/assets/javascripts/lib/prosemirror_markdown_serializer.js4
-rw-r--r--app/assets/javascripts/lib/utils/text_markdown.js242
-rw-r--r--app/assets/javascripts/lib/utils/url_utility.js25
-rw-r--r--app/assets/javascripts/lib/utils/yaml.js21
8 files changed, 339 insertions, 52 deletions
diff --git a/app/assets/javascripts/lib/dompurify.js b/app/assets/javascripts/lib/dompurify.js
index a01c6df0003..3e28ca2a0f7 100644
--- a/app/assets/javascripts/lib/dompurify.js
+++ b/app/assets/javascripts/lib/dompurify.js
@@ -33,6 +33,22 @@ const removeUnsafeHref = (node, attr) => {
};
/**
+ * Appends 'noopener' & 'noreferrer' to rel
+ * attr values to prevent reverse tabnabbing.
+ *
+ * @param {String} rel
+ * @returns {String}
+ */
+const appendSecureRelValue = (rel) => {
+ const attributes = new Set(rel ? rel.toLowerCase().split(' ') : []);
+
+ attributes.add('noopener');
+ attributes.add('noreferrer');
+
+ return Array.from(attributes).join(' ');
+};
+
+/**
* Sanitize icons' <use> tag attributes, to safely include
* svgs such as in:
*
@@ -57,4 +73,25 @@ addHook('afterSanitizeAttributes', (node) => {
}
});
+const TEMPORARY_ATTRIBUTE = 'data-temp-href-target';
+
+addHook('beforeSanitizeAttributes', (node) => {
+ if (node.tagName === 'A' && node.hasAttribute('target')) {
+ node.setAttribute(TEMPORARY_ATTRIBUTE, node.getAttribute('target'));
+ }
+});
+
+addHook('afterSanitizeAttributes', (node) => {
+ if (node.tagName === 'A' && node.hasAttribute(TEMPORARY_ATTRIBUTE)) {
+ node.setAttribute('target', node.getAttribute(TEMPORARY_ATTRIBUTE));
+ node.removeAttribute(TEMPORARY_ATTRIBUTE);
+ if (node.getAttribute('target') === '_blank') {
+ const rel = node.getAttribute('rel');
+ node.setAttribute('rel', appendSecureRelValue(rel));
+ }
+ }
+});
+
export const sanitize = (val, config) => dompurifySanitize(val, { ...defaultConfig, ...config });
+
+export { isValidAttribute } from 'dompurify';
diff --git a/app/assets/javascripts/lib/gfm/index.js b/app/assets/javascripts/lib/gfm/index.js
index 92118c8929f..eaf653e9924 100644
--- a/app/assets/javascripts/lib/gfm/index.js
+++ b/app/assets/javascripts/lib/gfm/index.js
@@ -1,10 +1,15 @@
import { pick } from 'lodash';
+import normalize from 'mdurl/encode';
import { unified } from 'unified';
import remarkParse from 'remark-parse';
+import remarkFrontmatter from 'remark-frontmatter';
import remarkGfm from 'remark-gfm';
import remarkRehype, { all } from 'remark-rehype';
import rehypeRaw from 'rehype-raw';
+const skipFrontmatterHandler = (language) => (h, node) =>
+ h(node.position, 'frontmatter', { language }, [{ type: 'text', value: node.value }]);
+
const skipRenderingHandlers = {
footnoteReference: (h, node) =>
h(node.position, 'footnoteReference', { identifier: node.identifier, label: node.label }, []),
@@ -19,12 +24,57 @@ const skipRenderingHandlers = {
h(node.position, 'codeBlock', { language: node.lang, meta: node.meta }, [
{ type: 'text', value: node.value },
]),
+ definition: (h, node) => {
+ const title = node.title ? ` "${node.title}"` : '';
+
+ return h(
+ node.position,
+ 'referenceDefinition',
+ { identifier: node.identifier, url: node.url, title: node.title },
+ [{ type: 'text', value: `[${node.identifier}]: ${node.url}${title}` }],
+ );
+ },
+ linkReference: (h, node) => {
+ const definition = h.definition(node.identifier);
+
+ return h(
+ node.position,
+ 'a',
+ {
+ href: normalize(definition.url ?? ''),
+ identifier: node.identifier,
+ isReference: 'true',
+ title: definition.title,
+ },
+ all(h, node),
+ );
+ },
+ imageReference: (h, node) => {
+ const definition = h.definition(node.identifier);
+
+ return h(
+ node.position,
+ 'img',
+ {
+ src: normalize(definition.url ?? ''),
+ alt: node.alt,
+ identifier: node.identifier,
+ isReference: 'true',
+ title: definition.title,
+ },
+ all(h, node),
+ );
+ },
+ toml: skipFrontmatterHandler('toml'),
+ yaml: skipFrontmatterHandler('yaml'),
+ json: skipFrontmatterHandler('json'),
};
const createParser = ({ skipRendering = [] }) => {
return unified()
.use(remarkParse)
.use(remarkGfm)
+ .use(remarkFrontmatter, ['yaml', 'toml', { type: 'json', marker: ';' }])
.use(remarkRehype, {
allowDangerousHtml: true,
handlers: {
diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js
index cfcce234bfb..98e45f95b38 100644
--- a/app/assets/javascripts/lib/graphql.js
+++ b/app/assets/javascripts/lib/graphql.js
@@ -221,6 +221,7 @@ export default (resolvers = {}, config = {}) => {
ac = new ApolloClient({
typeDefs,
link: appLink,
+ connectToDevTools: process.env.NODE_ENV !== 'production',
cache: new InMemoryCache({
...cacheConfig,
typePolicies: {
diff --git a/app/assets/javascripts/lib/markdown_it.js b/app/assets/javascripts/lib/markdown_it.js
new file mode 100644
index 00000000000..0b7a553737d
--- /dev/null
+++ b/app/assets/javascripts/lib/markdown_it.js
@@ -0,0 +1,11 @@
+/**
+ * This module replaces markdown-it with an empty function. markdown-it
+ * is a dependency of the prosemirror-markdown package. prosemirror-markdown
+ * uses markdown-it to parse markdown and produce an AST. However, the
+ * features that use prosemirror-markdown in the GitLab application do not
+ * require markdown parsing.
+ *
+ * Replacing markdown-it with this empty function removes unnecessary javascript
+ * from the production builds.
+ */
+export default () => {};
diff --git a/app/assets/javascripts/lib/prosemirror_markdown_serializer.js b/app/assets/javascripts/lib/prosemirror_markdown_serializer.js
index 6473683c3af..5e621ca3216 100644
--- a/app/assets/javascripts/lib/prosemirror_markdown_serializer.js
+++ b/app/assets/javascripts/lib/prosemirror_markdown_serializer.js
@@ -1,3 +1 @@
-// Import from `src/to_markdown` to avoid unnecessary bundling of unused libs
-// https://gitlab.com/gitlab-org/gitlab/-/merge_requests/79859
-export * from 'prosemirror-markdown/src/to_markdown';
+export { MarkdownSerializer, defaultMarkdownSerializer } from 'prosemirror-markdown';
diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js
index 243de48948c..9f4e12a3010 100644
--- a/app/assets/javascripts/lib/utils/text_markdown.js
+++ b/app/assets/javascripts/lib/utils/text_markdown.js
@@ -4,12 +4,14 @@ import Shortcuts from '~/behaviors/shortcuts/shortcuts';
import { insertText } from '~/lib/utils/common_utils';
const LINK_TAG_PATTERN = '[{text}](url)';
+const INDENT_CHAR = ' ';
+const INDENT_LENGTH = 2;
// at the start of a line, find any amount of whitespace followed by
// a bullet point character (*+-) and an optional checkbox ([ ] [x])
// OR a number with a . after it and an optional checkbox ([ ] [x])
// followed by one or more whitespace characters
-const LIST_LINE_HEAD_PATTERN = /^(?<indent>\s*)(?<leader>((?<isUl>[*+-])|(?<isOl>\d+\.))( \[([xX\s])\])?\s)(?<content>.)?/;
+const LIST_LINE_HEAD_PATTERN = /^(?<indent>\s*)(?<leader>((?<isUl>[*+-])|(?<isOl>\d+\.))( \[([xX~\s])\])?\s)(?<content>.)?/;
// detect a horizontal rule that might be mistaken for a list item (not full pattern for an <hr>)
const HR_PATTERN = /^((\s{0,3}-+\s*-+\s*-+\s*[\s-]*)|(\s{0,3}\*+\s*\*+\s*\*+\s*[\s*]*))$/;
@@ -24,33 +26,104 @@ function addBlockTags(blockTag, selected) {
return `${blockTag}\n${selected}\n${blockTag}`;
}
-function lineBefore(text, textarea, trimNewlines = true) {
- let split = text.substring(0, textarea.selectionStart);
-
- if (trimNewlines) {
- split = split.trim();
- }
+/**
+ * Returns the line of text that is before the first line
+ * of the current selection
+ *
+ * @param {String} text - the text of the targeted text area
+ * @param {Object} textArea - the targeted text area
+ * @returns {String}
+ */
+function lineBeforeSelection(text, textArea) {
+ let split = text.substring(0, textArea.selectionStart);
split = split.split('\n');
- return split[split.length - 1];
-}
+ // Last item, at -1, is the line where the start of selection is.
+ // Line before selection is therefore at -2
+ const lineBefore = split[split.length - 2];
-function lineAfter(text, textarea, trimNewlines = true) {
- let split = text.substring(textarea.selectionEnd);
+ return lineBefore === undefined ? '' : lineBefore;
+}
- if (trimNewlines) {
- split = split.trim();
- } else {
- // remove possible leading newline to get at the real line
- split = split.replace(/^\n/, '');
- }
+/**
+ * Returns the line of text that is after the last line
+ * of the current selection
+ *
+ * @param {String} text - the text of the targeted text area
+ * @param {Object} textArea - the targeted text area
+ * @returns {String}
+ */
+function lineAfterSelection(text, textArea) {
+ let split = text.substring(textArea.selectionEnd);
+ // remove possible leading newline to get at the real line
+ split = split.replace(/^\n/, '');
split = split.split('\n');
return split[0];
}
+/**
+ * Returns the text lines that encompass the current selection
+ *
+ * @param {Object} textArea - the targeted text area
+ * @returns {Object}
+ */
+function linesFromSelection(textArea) {
+ const text = textArea.value;
+ const { selectionStart, selectionEnd } = textArea;
+
+ let startPos = text[selectionStart] === '\n' ? selectionStart - 1 : selectionStart;
+ startPos = text.lastIndexOf('\n', startPos) + 1;
+
+ let endPos = selectionEnd === selectionStart ? selectionEnd : selectionEnd - 1;
+ endPos = text.indexOf('\n', endPos);
+ if (endPos < 0) endPos = text.length;
+
+ const selectedRange = text.substring(startPos, endPos);
+ const lines = selectedRange.split('\n');
+
+ return {
+ lines,
+ selectionStart,
+ selectionEnd,
+ startPos,
+ endPos,
+ };
+}
+
+/**
+ * Set the selection of a textarea such that it maintains the
+ * previous selection before the lines were indented/outdented
+ *
+ * @param {Object} textArea - the targeted text area
+ * @param {Number} selectionStart - start position of original selection
+ * @param {Number} selectionEnd - end position of original selection
+ * @param {Number} lineStart - start pos of first line
+ * @param {Number} firstLineChange - number of characters changed on first line
+ * @param {Number} totalChanged - total number of characters changed
+ */
+function setNewSelectionRange(
+ textArea,
+ selectionStart,
+ selectionEnd,
+ lineStart,
+ firstLineChange,
+ totalChanged,
+) {
+ let newStart = Math.max(lineStart, selectionStart + firstLineChange);
+ let newEnd = Math.max(lineStart, selectionEnd + totalChanged);
+
+ if (selectionStart === selectionEnd) {
+ newEnd = newStart;
+ } else if (selectionStart === lineStart) {
+ newStart = lineStart;
+ }
+
+ textArea.setSelectionRange(newStart, newEnd);
+}
+
function convertMonacoSelectionToAceFormat(sel) {
return {
start: {
@@ -93,7 +166,8 @@ function editorBlockTagText(text, blockTag, selected, editor) {
function blockTagText(text, textArea, blockTag, selected) {
const shouldRemoveBlock =
- lineBefore(text, textArea) === blockTag && lineAfter(text, textArea) === blockTag;
+ lineBeforeSelection(text, textArea) === blockTag &&
+ lineAfterSelection(text, textArea) === blockTag;
if (shouldRemoveBlock) {
// To remove the block tag we have to select the line before & after
@@ -312,9 +386,100 @@ function updateText({ textArea, tag, cursorOffset, blockTag, wrap, select, tagCo
});
}
+/**
+ * Indents selected lines to the right by 2 spaces
+ *
+ * @param {Object} textArea - the targeted text area
+ */
+function indentLines(textArea) {
+ const { lines, selectionStart, selectionEnd, startPos, endPos } = linesFromSelection(textArea);
+ const shiftedLines = [];
+ let totalAdded = 0;
+
+ textArea.setSelectionRange(startPos, endPos);
+
+ lines.forEach((line) => {
+ line = INDENT_CHAR.repeat(INDENT_LENGTH) + line;
+ totalAdded += INDENT_LENGTH;
+
+ shiftedLines.push(line);
+ });
+
+ const textToInsert = shiftedLines.join('\n');
+
+ insertText(textArea, textToInsert);
+ setNewSelectionRange(textArea, selectionStart, selectionEnd, startPos, INDENT_LENGTH, totalAdded);
+}
+
+/**
+ * Outdents selected lines to the left by 2 spaces
+ *
+ * @param {Object} textArea - the targeted text area
+ */
+function outdentLines(textArea) {
+ const { lines, selectionStart, selectionEnd, startPos, endPos } = linesFromSelection(textArea);
+ const shiftedLines = [];
+ let totalRemoved = 0;
+ let removedFromFirstline = -1;
+ let removedFromLine = 0;
+
+ textArea.setSelectionRange(startPos, endPos);
+
+ lines.forEach((line) => {
+ removedFromLine = 0;
+
+ if (line.length > 0) {
+ // need to count how many spaces are actually removed, so can't use `replace`
+ while (removedFromLine < INDENT_LENGTH && line[removedFromLine] === INDENT_CHAR) {
+ removedFromLine += 1;
+ }
+
+ if (removedFromLine > 0) {
+ line = line.slice(removedFromLine);
+ totalRemoved += removedFromLine;
+ }
+ }
+
+ if (removedFromFirstline === -1) removedFromFirstline = removedFromLine;
+ shiftedLines.push(line);
+ });
+
+ const textToInsert = shiftedLines.join('\n');
+
+ if (totalRemoved > 0) insertText(textArea, textToInsert);
+
+ setNewSelectionRange(
+ textArea,
+ selectionStart,
+ selectionEnd,
+ startPos,
+ -removedFromFirstline,
+ -totalRemoved,
+ );
+}
+
+function handleIndentOutdent(e, textArea) {
+ if (e.altKey || e.ctrlKey || e.shiftKey) return;
+ if (!e.metaKey) return;
+
+ switch (e.key) {
+ case ']':
+ e.preventDefault();
+ indentLines(textArea);
+ break;
+ case '[':
+ e.preventDefault();
+ outdentLines(textArea);
+ break;
+ default:
+ break;
+ }
+}
+
/* eslint-disable @gitlab/require-i18n-strings */
function handleSurroundSelectedText(e, textArea) {
if (!gon.markdown_surround_selection) return;
+ if (e.metaKey) return;
if (textArea.selectionStart === textArea.selectionEnd) return;
const keys = {
@@ -348,13 +513,13 @@ function handleSurroundSelectedText(e, textArea) {
/**
* Returns the content for a new line following a list item.
*
- * @param {Object} result - regex match of the current line
- * @param {Object?} nextLineResult - regex match of the next line
+ * @param {Object} listLineMatch - regex match of the current line
+ * @param {Object?} nextLineMatch - regex match of the next line
* @returns string with the new list item
*/
-function continueOlText(result, nextLineResult) {
- const { indent, leader } = result.groups;
- const { indent: nextIndent, isOl: nextIsOl } = nextLineResult?.groups ?? {};
+function continueOlText(listLineMatch, nextLineMatch) {
+ const { indent, leader } = listLineMatch.groups;
+ const { indent: nextIndent, isOl: nextIsOl } = nextLineMatch?.groups ?? {};
const [numStr, postfix = ''] = leader.split('.');
@@ -368,20 +533,20 @@ function handleContinueList(e, textArea) {
if (!(e.key === 'Enter')) return;
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
if (textArea.selectionStart !== textArea.selectionEnd) return;
- // prevent unintended line breaks were inserted using Japanese IME on MacOS
+ // prevent unintended line breaks inserted using Japanese IME on MacOS
if (compositioningNoteText) return;
- const currentLine = lineBefore(textArea.value, textArea, false);
- const result = currentLine.match(LIST_LINE_HEAD_PATTERN);
+ const firstSelectedLine = linesFromSelection(textArea).lines[0];
+ const listLineMatch = firstSelectedLine.match(LIST_LINE_HEAD_PATTERN);
- if (result) {
- const { leader, indent, content, isOl } = result.groups;
- const prevLineEmpty = !content;
+ if (listLineMatch) {
+ const { leader, indent, content, isOl } = listLineMatch.groups;
+ const emptyListItem = !content;
- if (prevLineEmpty) {
- // erase previous empty list item - select the text and allow the
- // natural line feed erase the text
- textArea.selectionStart = textArea.selectionStart - result[0].length;
+ if (emptyListItem) {
+ // erase empty list item - select the text and allow the
+ // natural line feed to erase the text
+ textArea.selectionStart = textArea.selectionStart - listLineMatch[0].length;
return;
}
@@ -389,17 +554,17 @@ function handleContinueList(e, textArea) {
// Behaviors specific to either `ol` or `ul`
if (isOl) {
- const nextLine = lineAfter(textArea.value, textArea, false);
- const nextLineResult = nextLine.match(LIST_LINE_HEAD_PATTERN);
+ const nextLine = lineAfterSelection(textArea.value, textArea);
+ const nextLineMatch = nextLine.match(LIST_LINE_HEAD_PATTERN);
- itemToInsert = continueOlText(result, nextLineResult);
+ itemToInsert = continueOlText(listLineMatch, nextLineMatch);
} else {
- if (currentLine.match(HR_PATTERN)) return;
+ if (firstSelectedLine.match(HR_PATTERN)) return;
itemToInsert = `${indent}${leader}`;
}
- itemToInsert = itemToInsert.replace(/\[x\]/i, '[ ]');
+ itemToInsert = itemToInsert.replace(/\[[x~]\]/i, '[ ]');
e.preventDefault();
@@ -419,6 +584,7 @@ export function keypressNoteText(e) {
if ($(textArea).atwho?.('isSelecting')) return;
+ handleIndentOutdent(e, textArea);
handleContinueList(e, textArea);
handleSurroundSelectedText(e, textArea);
}
diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js
index ff60fd2aecb..ca90eee69c7 100644
--- a/app/assets/javascripts/lib/utils/url_utility.js
+++ b/app/assets/javascripts/lib/utils/url_utility.js
@@ -397,6 +397,7 @@ export function relativePathToAbsolute(path, basePath) {
const absolute = isAbsolute(basePath);
const base = absolute ? basePath : `file:///${basePath}`;
const url = new URL(path, base);
+ url.pathname = url.pathname.replace(/\/\/+/g, '/');
return absolute ? url.href : decodeURIComponent(url.pathname);
}
@@ -668,3 +669,27 @@ export function constructWebIDEPath({
webIDEUrl(`/${sourceProjectFullPath}/merge_requests/${iid}`),
);
}
+
+/**
+ * Examples
+ *
+ * http://gitlab.com => gitlab.com
+ * https://gitlab.com => gitlab.com
+ *
+ * @param {String} url
+ * @returns A url without a protocol / scheme
+ */
+export const removeUrlProtocol = (url) => url.replace(/^\w+:\/?\/?/, '');
+
+/**
+ * Examples
+ *
+ * https://www.gitlab.com/path/ => https://www.gitlab.com/path
+ * https://www.gitlab.com/?query=search => https://www.gitlab.com?query=search
+ * https://www.gitlab.com/#fragment => https://www.gitlab.com#fragment
+ *
+ * @param {String} url
+ * @returns A URL that does not have a path that ends with slash
+ */
+export const removeLastSlashInUrlPath = (url) =>
+ url.replace(/\/$/, '').replace(/\/(\?|#){1}([^/]*)$/, '$1$2');
diff --git a/app/assets/javascripts/lib/utils/yaml.js b/app/assets/javascripts/lib/utils/yaml.js
index 9270d388342..48f34624140 100644
--- a/app/assets/javascripts/lib/utils/yaml.js
+++ b/app/assets/javascripts/lib/utils/yaml.js
@@ -16,18 +16,17 @@ function getPath(ancestry) {
function getFirstChildNode(collection) {
let firstChildKey;
- let type;
- switch (collection.constructor.name) {
- case 'YAMLSeq': // eslint-disable-line @gitlab/require-i18n-strings
- return collection.items.find((i) => isNode(i));
- case 'YAMLMap': // eslint-disable-line @gitlab/require-i18n-strings
- firstChildKey = collection.items[0]?.key;
- if (!firstChildKey) return undefined;
- return isScalar(firstChildKey) ? firstChildKey : new Scalar(firstChildKey);
- default:
- type = collection.constructor?.name || typeof collection;
- throw Error(`Cannot identify a child Node for type ${type}`);
+ if (isSeq(collection)) {
+ return collection.items.find((i) => isNode(i));
}
+ if (isMap(collection)) {
+ firstChildKey = collection.items[0]?.key;
+ if (!firstChildKey) return undefined;
+ return isScalar(firstChildKey) ? firstChildKey : new Scalar(firstChildKey);
+ }
+ throw Error(
+ `Cannot identify a child Node for Collection. Expecting a YAMLMap or a YAMLSeq. Got: ${collection}`,
+ );
}
function moveMetaPropsToFirstChildNode(collection) {