Skip to content
Snippets Groups Projects
Commit 8401d396 authored by Jacques Erasmus's avatar Jacques Erasmus :speech_balloon: Committed by Enrique Alcántara
Browse files

Generate package.json links

Generates package.json links via hljs plugin

Changelog: added
parent 9aef08f3
No related branches found
No related tags found
1 merge request!91886Generate dependency links for package.json files
Showing
with 168 additions and 5 deletions
......@@ -93,7 +93,6 @@ export const LFS_STORAGE = 'lfs';
* These are file types that we want the legacy (backend) syntax highlighter to highlight.
*/
export const LEGACY_FILE_TYPES = [
'package_json',
'gemfile',
'gemspec',
'composer_json',
......
......@@ -145,3 +145,5 @@ export const BIDI_CHAR_TOOLTIP = __(
export const HLJS_COMMENT_SELECTOR = 'hljs-comment';
export const HLJS_ON_AFTER_HIGHLIGHT = 'after:highlight';
export const NPM_URL = 'https://npmjs.com/package';
import { HLJS_ON_AFTER_HIGHLIGHT } from '../constants';
import wrapComments from './wrap_comments';
import linkDependencies from './link_dependencies';
/**
* Registers our plugins for Highlight.js
......@@ -8,6 +9,9 @@ import wrapComments from './wrap_comments';
*
* @param {Object} hljs - the Highlight.js instance.
*/
export const registerPlugins = (hljs) => {
export const registerPlugins = (hljs, fileType, rawContent) => {
hljs.addPlugin({ [HLJS_ON_AFTER_HIGHLIGHT]: wrapComments });
hljs.addPlugin({
[HLJS_ON_AFTER_HIGHLIGHT]: (result) => linkDependencies(result, fileType, rawContent),
});
};
import packageJsonLinker from './utils/package_json_linker';
const DEPENDENCY_LINKERS = {
package_json: packageJsonLinker,
};
/**
* Highlight.js plugin for generating links to dependencies when viewing dependency files.
*
* Plugin API: https://github.com/highlightjs/highlight.js/blob/main/docs/plugin-api.rst
*
* @param {Object} result - an object that represents the highlighted result from Highlight.js
* @param {String} fileType - a string containing the file type
* @param {String} rawContent - raw (non-highlighted) file content
*/
export default (result, fileType, rawContent) => {
if (DEPENDENCY_LINKERS[fileType]) {
try {
// eslint-disable-next-line no-param-reassign
result.value = DEPENDENCY_LINKERS[fileType](result, rawContent);
} catch (e) {
// Shallowed (do nothing), in this case the original unlinked dependencies will be rendered.
}
}
};
import { escape } from 'lodash';
import { setAttributes } from '~/lib/utils/dom_utils';
export const createLink = (href, innerText) => {
// eslint-disable-next-line @gitlab/require-i18n-strings
const rel = 'nofollow noreferrer noopener';
const link = document.createElement('a');
setAttributes(link, { href: escape(href), rel });
link.innerText = escape(innerText);
return link.outerHTML;
};
export const generateHLJSOpenTag = (type) => `<span class="hljs-${escape(type)}">&quot;`;
import { joinPaths } from '~/lib/utils/url_utility';
import { NPM_URL } from '../../constants';
import { createLink, generateHLJSOpenTag } from './dependency_linker_util';
const attrOpenTag = generateHLJSOpenTag('attr');
const stringOpenTag = generateHLJSOpenTag('string');
const closeTag = '&quot;</span>';
const DEPENDENCY_REGEX = new RegExp(
/*
* Detects dependencies inside of content that is highlighted by Highlight.js
* Example: <span class="hljs-attr">&quot;@babel/core&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;^7.18.5&quot;</span>
* Group 1: @babel/core
* Group 2: ^7.18.5
*/
`${attrOpenTag}(.*)${closeTag}.*${stringOpenTag}(.*[0-9].*)(${closeTag})`,
'gm',
);
const handleReplace = (original, packageName, version, dependenciesToLink) => {
const href = joinPaths(NPM_URL, packageName);
const packageLink = createLink(href, packageName);
const versionLink = createLink(href, version);
const closeAndOpenTag = `${closeTag}: ${attrOpenTag}`;
const dependencyToLink = dependenciesToLink[packageName];
if (dependencyToLink && dependencyToLink === version) {
return `${attrOpenTag}${packageLink}${closeAndOpenTag}${versionLink}${closeTag}`;
}
return original;
};
export default (result, raw) => {
const { dependencies, devDependencies, peerDependencies, optionalDependencies } = JSON.parse(raw);
const dependenciesToLink = {
...dependencies,
...devDependencies,
...peerDependencies,
...optionalDependencies,
};
return result.value.replace(DEPENDENCY_REGEX, (original, packageName, version) =>
handleReplace(original, packageName, version, dependenciesToLink),
);
};
......@@ -141,7 +141,7 @@ export default {
let detectedLanguage = language;
let highlightedContent;
if (this.hljs) {
registerPlugins(this.hljs);
registerPlugins(this.hljs, this.blob.fileType, this.content);
if (!detectedLanguage) {
const hljsHighlightAuto = this.hljs.highlightAuto(content);
highlightedContent = hljsHighlightAuto.value;
......
import packageJsonLinker from '~/vue_shared/components/source_viewer/plugins/utils/package_json_linker';
import linkDependencies from '~/vue_shared/components/source_viewer/plugins/link_dependencies';
import { PACKAGE_JSON_FILE_TYPE, PACKAGE_JSON_CONTENT } from './mock_data';
jest.mock('~/vue_shared/components/source_viewer/plugins/utils/package_json_linker');
describe('Highlight.js plugin for linking dependencies', () => {
const hljsResultMock = { value: 'test' };
it('calls packageJsonLinker for package_json file types', () => {
linkDependencies(hljsResultMock, PACKAGE_JSON_FILE_TYPE, PACKAGE_JSON_CONTENT);
expect(packageJsonLinker).toHaveBeenCalled();
});
});
export const PACKAGE_JSON_FILE_TYPE = 'package_json';
export const PACKAGE_JSON_CONTENT = '{ "dependencies": { "@babel/core": "^7.18.5" } }';
import {
createLink,
generateHLJSOpenTag,
} from '~/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util';
describe('createLink', () => {
it('generates a link with the correct attributes', () => {
const href = 'http://test.com';
const innerText = 'testing';
const result = `<a href="${href}" rel="nofollow noreferrer noopener">${innerText}</a>`;
expect(createLink(href, innerText)).toBe(result);
});
it('escapes the user-controlled content', () => {
const unescapedXSS = '<script>XSS</script>';
const escapedXSS = '&amp;lt;script&amp;gt;XSS&amp;lt;/script&amp;gt;';
const href = `http://test.com/${unescapedXSS}`;
const innerText = `testing${unescapedXSS}`;
const result = `<a href="http://test.com/${escapedXSS}" rel="nofollow noreferrer noopener">testing${escapedXSS}</a>`;
expect(createLink(href, innerText)).toBe(result);
});
});
describe('generateHLJSOpenTag', () => {
it('generates an open tag with the correct selector', () => {
const type = 'string';
const result = `<span class="hljs-${type}">&quot;`;
expect(generateHLJSOpenTag(type)).toBe(result);
});
});
import packageJsonLinker from '~/vue_shared/components/source_viewer/plugins/utils/package_json_linker';
import { PACKAGE_JSON_CONTENT } from '../mock_data';
describe('Highlight.js plugin for linking package.json dependencies', () => {
it('mutates the input value by wrapping dependency names and versions in anchors', () => {
const inputValue =
'<span class="hljs-attr">&quot;@babel/core&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;^7.18.5&quot;</span>';
const outputValue =
'<span class="hljs-attr">&quot;<a href="https://npmjs.com/package/@babel/core" rel="nofollow noreferrer noopener">@babel/core</a>&quot;</span>: <span class="hljs-attr">&quot;<a href="https://npmjs.com/package/@babel/core" rel="nofollow noreferrer noopener">^7.18.5</a>&quot;</span>';
const hljsResultMock = { value: inputValue };
const output = packageJsonLinker(hljsResultMock, PACKAGE_JSON_CONTENT);
expect(output).toBe(outputValue);
});
});
......@@ -40,8 +40,9 @@ describe('Source Viewer component', () => {
const chunk2 = generateContent('// Some source code 2', 70);
const content = chunk1 + chunk2;
const path = 'some/path.js';
const fileType = 'javascript';
const blamePath = 'some/blame/path.js';
const DEFAULT_BLOB_DATA = { language, rawTextBlob: content, path, blamePath };
const DEFAULT_BLOB_DATA = { language, rawTextBlob: content, path, blamePath, fileType };
const highlightedContent = `<span data-testid='test-highlighted' id='LC1'>${content}</span><span id='LC2'></span>`;
const createComponent = async (blob = {}) => {
......@@ -90,7 +91,7 @@ describe('Source Viewer component', () => {
beforeEach(() => createComponent({ language: mappedLanguage }));
it('registers our plugins for Highlight.js', () => {
expect(registerPlugins).toHaveBeenCalledWith(hljs);
expect(registerPlugins).toHaveBeenCalledWith(hljs, fileType, content);
});
it('registers the language definition', async () => {
......@@ -102,6 +103,13 @@ describe('Source Viewer component', () => {
);
});
it('registers json language definition if fileType is package_json', async () => {
await createComponent({ language: 'json', fileType: 'package_json' });
const languageDefinition = await import(`highlight.js/lib/languages/json`);
expect(hljs.registerLanguage).toHaveBeenCalledWith('json', languageDefinition.default);
});
it('highlights the first chunk', () => {
expect(hljs.highlight).toHaveBeenCalledWith(chunk1.trim(), { language: mappedLanguage });
});
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment