aboutsummaryrefslogtreecommitdiffstats
path: root/server/sonar-web/design-system
diff options
context:
space:
mode:
Diffstat (limited to 'server/sonar-web/design-system')
-rw-r--r--server/sonar-web/design-system/src/components/CodeSyntaxHighlighter.tsx10
-rw-r--r--server/sonar-web/design-system/src/components/__tests__/__snapshots__/CodeSnippet-test.tsx.snap27
-rw-r--r--server/sonar-web/design-system/src/sonar-aligned/hljs/HljsUnderlinePlugin.ts203
-rw-r--r--server/sonar-web/design-system/src/sonar-aligned/hljs/__tests__/HljsUnderlinePlugin-test.ts187
-rw-r--r--server/sonar-web/design-system/src/sonar-aligned/hljs/index.ts20
-rw-r--r--server/sonar-web/design-system/src/sonar-aligned/index.ts1
6 files changed, 448 insertions, 0 deletions
diff --git a/server/sonar-web/design-system/src/components/CodeSyntaxHighlighter.tsx b/server/sonar-web/design-system/src/components/CodeSyntaxHighlighter.tsx
index 015b567447e..502cc4a9eae 100644
--- a/server/sonar-web/design-system/src/components/CodeSyntaxHighlighter.tsx
+++ b/server/sonar-web/design-system/src/components/CodeSyntaxHighlighter.tsx
@@ -25,6 +25,7 @@ import cobol from 'highlightjs-cobol';
import abap from 'highlightjs-sap-abap';
import tw from 'twin.macro';
import { themeColor, themeContrast } from '../helpers/theme';
+import { hljsUnderlinePlugin } from '../sonar-aligned/hljs/HljsUnderlinePlugin';
hljs.registerLanguage('abap', abap);
hljs.registerLanguage('apex', apex);
@@ -38,6 +39,8 @@ hljs.registerAliases('secrets', { languageName: 'markdown' });
hljs.registerAliases('web', { languageName: 'xml' });
hljs.registerAliases(['cloudformation', 'kubernetes'], { languageName: 'yaml' });
+hljs.addPlugin(hljsUnderlinePlugin);
+
interface Props {
className?: string;
htmlAsString: string;
@@ -153,6 +156,13 @@ const StyledSpan = styled.span`
color: ${themeColor('codeSnippetPreprocessingDirective')};
}
+ .sonar-underline {
+ text-decoration: underline ${themeColor('codeLineIssueSquiggle')};
+ text-decoration: underline ${themeColor('codeLineIssueSquiggle')} wavy;
+ text-decoration-thickness: 2px;
+ text-decoration-skip-ink: none;
+ }
+
&.code-wrap {
${tw`sw-whitespace-pre-wrap`}
${tw`sw-break-all`}
diff --git a/server/sonar-web/design-system/src/components/__tests__/__snapshots__/CodeSnippet-test.tsx.snap b/server/sonar-web/design-system/src/components/__tests__/__snapshots__/CodeSnippet-test.tsx.snap
index 905780b769e..838e266cad2 100644
--- a/server/sonar-web/design-system/src/components/__tests__/__snapshots__/CodeSnippet-test.tsx.snap
+++ b/server/sonar-web/design-system/src/components/__tests__/__snapshots__/CodeSnippet-test.tsx.snap
@@ -147,6 +147,15 @@ exports[`should highlight code content correctly 1`] = `
color: rgb(47,103,48);
}
+.emotion-6 .sonar-underline {
+ -webkit-text-decoration: underline rgb(253,162,155);
+ text-decoration: underline rgb(253,162,155);
+ -webkit-text-decoration: underline rgb(253,162,155) wavy;
+ text-decoration: underline rgb(253,162,155) wavy;
+ text-decoration-thickness: 2px;
+ text-decoration-skip-ink: none;
+}
+
.emotion-6.code-wrap {
white-space: pre-wrap;
word-break: break-all;
@@ -352,6 +361,15 @@ exports[`should show full size when multiline with no editing 1`] = `
color: rgb(47,103,48);
}
+.emotion-6 .sonar-underline {
+ -webkit-text-decoration: underline rgb(253,162,155);
+ text-decoration: underline rgb(253,162,155);
+ -webkit-text-decoration: underline rgb(253,162,155) wavy;
+ text-decoration: underline rgb(253,162,155) wavy;
+ text-decoration-thickness: 2px;
+ text-decoration-skip-ink: none;
+}
+
.emotion-6.code-wrap {
white-space: pre-wrap;
word-break: break-all;
@@ -561,6 +579,15 @@ exports[`should show reduced size when single line with no editing 1`] = `
color: rgb(47,103,48);
}
+.emotion-6 .sonar-underline {
+ -webkit-text-decoration: underline rgb(253,162,155);
+ text-decoration: underline rgb(253,162,155);
+ -webkit-text-decoration: underline rgb(253,162,155) wavy;
+ text-decoration: underline rgb(253,162,155) wavy;
+ text-decoration-thickness: 2px;
+ text-decoration-skip-ink: none;
+}
+
.emotion-6.code-wrap {
white-space: pre-wrap;
word-break: break-all;
diff --git a/server/sonar-web/design-system/src/sonar-aligned/hljs/HljsUnderlinePlugin.ts b/server/sonar-web/design-system/src/sonar-aligned/hljs/HljsUnderlinePlugin.ts
new file mode 100644
index 00000000000..e1aec179d9a
--- /dev/null
+++ b/server/sonar-web/design-system/src/sonar-aligned/hljs/HljsUnderlinePlugin.ts
@@ -0,0 +1,203 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import { HighlightResult } from 'highlight.js';
+
+interface UnderlineRangePosition {
+ cursorOffset: number;
+ line: number;
+}
+
+interface UnderlineRange {
+ end: UnderlineRangePosition;
+ start: UnderlineRangePosition;
+}
+
+export class HljsUnderlinePlugin {
+ static readonly SPAN_REGEX = '<\\/?span[^>]*>';
+
+ static readonly TOKEN_PREFIX = 'SNR_TGXRJVF'; // Random string to avoid conflicts with real code
+
+ static readonly TOKEN_SUFFIX_START = '_START';
+
+ static readonly TOKEN_SUFFIX_END = '_END';
+
+ static readonly TOKEN_START =
+ HljsUnderlinePlugin.TOKEN_PREFIX + HljsUnderlinePlugin.TOKEN_SUFFIX_START;
+
+ static readonly TOKEN_END =
+ HljsUnderlinePlugin.TOKEN_PREFIX + HljsUnderlinePlugin.TOKEN_SUFFIX_END;
+
+ static readonly OPEN_TAG = '<span data-testid="hljs-sonar-underline" class="sonar-underline">';
+
+ static readonly CLOSE_TAG = '</span>';
+
+ /**
+ * Add a pair of tokens to the source code to mark the start and end of the content to be underlined.
+ */
+ tokenize(source: string[], ranges: UnderlineRange[]): string[] {
+ // Order ranges by start position, ascending
+ ranges.sort((a, b) => {
+ if (a.start.line === b.start.line) {
+ return a.start.cursorOffset - b.start.cursorOffset;
+ }
+ return a.start.line - b.start.line;
+ });
+
+ // We want to merge overlapping ranges to ensure the underline markup doesn't intesect with itself in the after hook
+ const simplifiedRanges: UnderlineRange[] = [];
+ let currentRange = ranges[0];
+ for (let i = 1; i < ranges.length; i++) {
+ const nextRange = ranges[i];
+
+ if (
+ currentRange.start.line <= nextRange.start.line &&
+ currentRange.start.cursorOffset <= nextRange.start.cursorOffset &&
+ currentRange.end.line >= nextRange.end.line &&
+ currentRange.end.cursorOffset >= nextRange.end.cursorOffset
+ ) {
+ // Range is contained in the current range. Do nothing
+ } else if (
+ currentRange.end.line >= nextRange.start.line &&
+ currentRange.end.cursorOffset >= nextRange.start.cursorOffset
+ ) {
+ // Ranges overlap
+ currentRange.end = nextRange.end;
+ } else {
+ simplifiedRanges.push(currentRange);
+ currentRange = nextRange;
+ }
+ }
+ simplifiedRanges.push(currentRange);
+
+ // Add tokens to the source code, from the end to the start to avoid messing up the indices
+ for (let i = simplifiedRanges.length - 1; i >= 0; i--) {
+ const range = simplifiedRanges[i];
+
+ source[range.end.line] = [
+ source[range.end.line].slice(0, range.end.cursorOffset),
+ HljsUnderlinePlugin.TOKEN_END,
+ source[range.end.line].slice(range.end.cursorOffset),
+ ].join('');
+
+ // If there are lines between the start and end, we re-tokenize each line
+ if (range.end.line !== range.start.line) {
+ source[range.end.line] = HljsUnderlinePlugin.TOKEN_START + source[range.end.line];
+ for (let j = range.end.line - 1; j > range.start.line; j--) {
+ source[j] = [
+ HljsUnderlinePlugin.TOKEN_START,
+ source[j],
+ HljsUnderlinePlugin.TOKEN_END,
+ ].join('');
+ }
+ source[range.start.line] += HljsUnderlinePlugin.TOKEN_END;
+ }
+
+ source[range.start.line] = [
+ source[range.start.line].slice(0, range.start.cursorOffset),
+ HljsUnderlinePlugin.TOKEN_START,
+ source[range.start.line].slice(range.start.cursorOffset),
+ ].join('');
+ }
+
+ return source;
+ }
+
+ 'after:highlight'(result: HighlightResult) {
+ const re = new RegExp(HljsUnderlinePlugin.TOKEN_START, 'g');
+ re.lastIndex = 0;
+ let match = re.exec(result.value);
+ while (match) {
+ result.value = this.replaceTokens(result.value, match.index);
+ match = re.exec(result.value);
+ }
+ }
+
+ /**
+ * Whether the content is intersecting with HTML <span> tags added by HLJS or this plugin.
+ */
+ isIntersectingHtmlMarkup(content: string) {
+ const re = new RegExp(HljsUnderlinePlugin.SPAN_REGEX, 'g');
+ let depth = 0;
+ let intersecting = false;
+ let tag = re.exec(content);
+ while (tag) {
+ if (tag[0].startsWith('</')) {
+ depth--;
+ } else {
+ depth++;
+ }
+
+ // If at any point we're closing one-too-many tag, we're intersecting
+ if (depth < 0) {
+ intersecting = true;
+ break;
+ }
+
+ tag = re.exec(content);
+ }
+
+ // If at the end we're not at 0, we're intersecting
+ intersecting = intersecting || depth !== 0;
+
+ return intersecting;
+ }
+
+ /**
+ * Replace a pair of tokens and everything between with the appropriate HTML markup to underline the content.
+ */
+ private replaceTokens(htmlMarkup: string, startTokenIndex: number) {
+ const endTagIndex = htmlMarkup.indexOf(HljsUnderlinePlugin.TOKEN_END);
+
+ // Just in case the end tag is before the start tag (or the end tag isn't found)
+ if (endTagIndex <= startTokenIndex) {
+ return htmlMarkup;
+ }
+
+ let content = htmlMarkup.slice(
+ startTokenIndex + HljsUnderlinePlugin.TOKEN_START.length,
+ endTagIndex,
+ );
+
+ // If intersecting, we highlight in a safe way
+ // We could always use this method, but this creates visual artifacts in the underline wave
+ if (this.isIntersectingHtmlMarkup(content)) {
+ content = content.replace(
+ new RegExp(HljsUnderlinePlugin.SPAN_REGEX, 'g'),
+ (tag) => `${HljsUnderlinePlugin.CLOSE_TAG}${tag}${HljsUnderlinePlugin.OPEN_TAG}`,
+ );
+ }
+
+ // If no intersection, it's safe to add the tags
+ const stringRegex = [
+ HljsUnderlinePlugin.TOKEN_START,
+ '(.+?)',
+ HljsUnderlinePlugin.TOKEN_END,
+ ].join('');
+ htmlMarkup = htmlMarkup.replace(
+ new RegExp(stringRegex, 's'),
+ `${HljsUnderlinePlugin.OPEN_TAG}${content}${HljsUnderlinePlugin.CLOSE_TAG}`,
+ );
+
+ return htmlMarkup;
+ }
+}
+
+const hljsUnderlinePlugin = new HljsUnderlinePlugin();
+export { hljsUnderlinePlugin };
diff --git a/server/sonar-web/design-system/src/sonar-aligned/hljs/__tests__/HljsUnderlinePlugin-test.ts b/server/sonar-web/design-system/src/sonar-aligned/hljs/__tests__/HljsUnderlinePlugin-test.ts
new file mode 100644
index 00000000000..5e3b0db0ff2
--- /dev/null
+++ b/server/sonar-web/design-system/src/sonar-aligned/hljs/__tests__/HljsUnderlinePlugin-test.ts
@@ -0,0 +1,187 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import { HighlightResult } from 'highlight.js';
+import { HljsUnderlinePlugin, hljsUnderlinePlugin } from '../HljsUnderlinePlugin';
+
+const START_TOKEN = HljsUnderlinePlugin.TOKEN_START;
+const END_TOKEN = HljsUnderlinePlugin.TOKEN_END;
+
+describe('should add tokens', () => {
+ it('with multiple overlapping ranges', () => {
+ expect(
+ hljsUnderlinePlugin.tokenize(
+ ['line1', 'line2', 'line3', 'line4', 'line5'],
+ [
+ {
+ start: { line: 1, cursorOffset: 2 },
+ end: { line: 2, cursorOffset: 2 },
+ },
+ {
+ start: { line: 3, cursorOffset: 2 },
+ end: { line: 3, cursorOffset: 4 },
+ },
+ {
+ start: { line: 1, cursorOffset: 1 },
+ end: { line: 1, cursorOffset: 3 },
+ },
+ ],
+ ),
+ ).toEqual([
+ 'line1',
+ `l${START_TOKEN}ine2${END_TOKEN}`,
+ `${START_TOKEN}li${END_TOKEN}ne3`,
+ `li${START_TOKEN}ne${END_TOKEN}4`,
+ 'line5',
+ ]);
+ });
+
+ it('highlight multiple issues on the same line', () => {
+ expect(
+ hljsUnderlinePlugin.tokenize(
+ ['line1', 'line2', 'line3', 'line4', 'line5'],
+ [
+ {
+ start: { line: 1, cursorOffset: 1 },
+ end: { line: 1, cursorOffset: 2 },
+ },
+ {
+ start: { line: 1, cursorOffset: 3 },
+ end: { line: 1, cursorOffset: 4 },
+ },
+ ],
+ ),
+ ).toEqual([
+ 'line1',
+ `l${START_TOKEN}i${END_TOKEN}n${START_TOKEN}e${END_TOKEN}2`,
+ 'line3',
+ 'line4',
+ 'line5',
+ ]);
+ });
+
+ it('highlight multiple successive lines', () => {
+ expect(
+ hljsUnderlinePlugin.tokenize(
+ ['line1', 'line2', 'line3', 'line4', 'line5'],
+ [
+ {
+ start: { line: 1, cursorOffset: 2 },
+ end: { line: 4, cursorOffset: 4 },
+ },
+ ],
+ ),
+ ).toEqual([
+ 'line1',
+ `li${START_TOKEN}ne2${END_TOKEN}`,
+ `${START_TOKEN}line3${END_TOKEN}`,
+ `${START_TOKEN}line4${END_TOKEN}`,
+ `${START_TOKEN}line${END_TOKEN}5`,
+ ]);
+ });
+});
+
+describe('should detect html markup intersection', () => {
+ it.each([
+ '... <span a="b"> ....',
+ '... </span> ...',
+ '<span> ...',
+ '... </span>',
+ '... </span> ... <span a="b"> ...',
+ '... <span><span a="b"> ... </span> ...',
+ '... <span> ... <span a="b"> ... </span> ... </span> ... </span> ...',
+ ])('should detect intersection (%s)', (code) => {
+ expect(hljsUnderlinePlugin.isIntersectingHtmlMarkup(code)).toBe(true);
+ });
+
+ it.each([
+ '... <span a="b"> ... </span> ...',
+ '<span> ... </span> ... <span> ... <span class="abc"><span> ... </span></span> ... </span>',
+ ])('should not detect intersection (%s)', (code) => {
+ expect(hljsUnderlinePlugin.isIntersectingHtmlMarkup(code)).toBe(false);
+ });
+});
+
+describe('underline plugin should work', () => {
+ it('should underline on different lines', () => {
+ const result = {
+ value: ['line1', `l${START_TOKEN}ine2`, 'line3', `lin${END_TOKEN}e4`, 'line5'].join('\n'),
+ } as HighlightResult;
+
+ hljsUnderlinePlugin['after:highlight'](result);
+
+ expect(result.value).toEqual(
+ [
+ 'line1',
+ `l${HljsUnderlinePlugin.OPEN_TAG}ine2`,
+ 'line3',
+ `lin${HljsUnderlinePlugin.CLOSE_TAG}e4`,
+ 'line5',
+ ].join('\n'),
+ );
+ });
+
+ it('should underline on same lines', () => {
+ const result = {
+ value: ['line1', `l${START_TOKEN}ine${END_TOKEN}2`, 'line3'].join('\n'),
+ } as HighlightResult;
+
+ hljsUnderlinePlugin['after:highlight'](result);
+
+ expect(result.value).toEqual(
+ [
+ 'line1',
+ `l${HljsUnderlinePlugin.OPEN_TAG}ine${HljsUnderlinePlugin.CLOSE_TAG}2`,
+ 'line3',
+ ].join('\n'),
+ );
+ });
+
+ it('should not underline if end tag is before start tag', () => {
+ const result = {
+ value: ['line1', `l${END_TOKEN}ine${START_TOKEN}2`, 'line3'].join('\n'),
+ } as HighlightResult;
+
+ hljsUnderlinePlugin['after:highlight'](result);
+
+ expect(result.value).toEqual(['line1', `l${END_TOKEN}ine${START_TOKEN}2`, 'line3'].join('\n'));
+ });
+
+ it('should not underline if there is no end tag', () => {
+ const result = {
+ value: ['line1', `l${START_TOKEN}ine2`, 'line3'].join('\n'),
+ } as HighlightResult;
+
+ hljsUnderlinePlugin['after:highlight'](result);
+
+ expect(result.value).toEqual(['line1', `l${START_TOKEN}ine2`, 'line3'].join('\n'));
+ });
+
+ it('should underline even when intersecting html markup', () => {
+ const result = {
+ value: `.. <span class="hljs-keyword"> .${START_TOKEN}. <span class="hljs-keyword"> .. </span> .. </span> .. ${END_TOKEN} ..`,
+ } as HighlightResult;
+
+ hljsUnderlinePlugin['after:highlight'](result);
+
+ expect(result.value).toEqual(
+ `.. <span class="hljs-keyword"> .${HljsUnderlinePlugin.OPEN_TAG}. ${HljsUnderlinePlugin.CLOSE_TAG}<span class="hljs-keyword">${HljsUnderlinePlugin.OPEN_TAG} .. ${HljsUnderlinePlugin.CLOSE_TAG}</span>${HljsUnderlinePlugin.OPEN_TAG} .. ${HljsUnderlinePlugin.CLOSE_TAG}</span>${HljsUnderlinePlugin.OPEN_TAG} .. ${HljsUnderlinePlugin.CLOSE_TAG} ..`,
+ );
+ });
+});
diff --git a/server/sonar-web/design-system/src/sonar-aligned/hljs/index.ts b/server/sonar-web/design-system/src/sonar-aligned/hljs/index.ts
new file mode 100644
index 00000000000..b7394666777
--- /dev/null
+++ b/server/sonar-web/design-system/src/sonar-aligned/hljs/index.ts
@@ -0,0 +1,20 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+export { hljsUnderlinePlugin } from './HljsUnderlinePlugin';
diff --git a/server/sonar-web/design-system/src/sonar-aligned/index.ts b/server/sonar-web/design-system/src/sonar-aligned/index.ts
index 380e82087fb..2a116ed0d06 100644
--- a/server/sonar-web/design-system/src/sonar-aligned/index.ts
+++ b/server/sonar-web/design-system/src/sonar-aligned/index.ts
@@ -20,4 +20,5 @@
export * from './components';
export * from './helpers';
+export * from './hljs';
export * from './types';