diff --git a/web_src/js/features/copycontent.js b/web_src/js/features/copycontent.js index 3d3b2a697ecbf..bc2d24133d499 100644 --- a/web_src/js/features/copycontent.js +++ b/web_src/js/features/copycontent.js @@ -1,6 +1,7 @@ import {clippie} from 'clippie'; import {showTemporaryTooltip} from '../modules/tippy.js'; import {convertImage} from '../utils.js'; +import {getFileViewFileText} from '../utils/misc.js'; import {GET} from '../modules/fetch.js'; const {i18n} = window.config; @@ -36,8 +37,7 @@ export function initCopyContent() { btn.classList.remove('is-loading', 'small-loading-icon'); } } else { // text, read from DOM - const lineEls = document.querySelectorAll('.file-view .lines-code'); - content = Array.from(lineEls, (el) => el.textContent).join(''); + content = getFileViewFileText(); } // try copy original first, if that fails and it's an image, convert it to png diff --git a/web_src/js/index.js b/web_src/js/index.js index abf0d469d18ed..bac327264634f 100644 --- a/web_src/js/index.js +++ b/web_src/js/index.js @@ -22,7 +22,6 @@ import {initStopwatch} from './features/stopwatch.js'; import {initFindFileInRepo} from './features/repo-findfile.js'; import {initCommentContent, initMarkupContent} from './markup/content.js'; import {initPdfViewer} from './render/pdf.js'; - import {initUserAuthOauth2} from './features/user-auth.js'; import { initRepoIssueDue, @@ -85,6 +84,7 @@ import {initRepoRecentCommits} from './features/recent-commits.js'; import {initRepoDiffCommitBranchesAndTags} from './features/repo-diff-commit.js'; import {initDirAuto} from './modules/dirauto.js'; import {initRepositorySearch} from './features/repo-search.js'; +import {initFileView} from './render/code.js'; // Init Gitea's Fomantic settings initGiteaFomantic(); @@ -186,4 +186,5 @@ onDomReady(() => { initRepoDiffView(); initPdfViewer(); initScopedAccessTokenCategories(); + initFileView(); }); diff --git a/web_src/js/render/code.js b/web_src/js/render/code.js new file mode 100644 index 0000000000000..54b56068f747d --- /dev/null +++ b/web_src/js/render/code.js @@ -0,0 +1,53 @@ +import {getFileViewFilePath, getFileViewFileText, createLink} from '../utils/misc.js'; +import {basename, isObject} from '../utils.js'; + +export function initFileView() { + if (document.querySelector('.file-view.code-view')) { + const fileName = basename(getFileViewFilePath()); + if (fileName === 'package.json') { + processPackageJson(); + } + } +} + +function processPackageJson() { + let obj; + try { + obj = JSON.parse(getFileViewFileText()); + } catch { + return; + } + if (!isObject(obj)) return; + + const packages = new Set(); + + for (const key of [ + 'dependencies', + 'dependenciesMeta', + 'devDependencies', + 'optionalDependencies', + 'overrides', + 'peerDependencies', + 'peerDependenciesMeta', + 'resolutions', + ]) { + for (const packageName of Object.keys(obj?.[key] || {})) { + packages.add(packageName); + } + } + + // match chroma NameTag token to detect JSON object keys + for (const el of document.querySelectorAll('.code-inner .nt')) { + const jsonKey = el.textContent.replace(/^"(.*)"$/, '$1'); + if (packages.has(jsonKey)) { + const link = createLink({ + external: true, + className: 'suppressed', + textContent: jsonKey, + href: `https://www.npmjs.com/package/${jsonKey}`, + }); + el.textContent = ''; + el.append('"', link, '"'); + } + } +} diff --git a/web_src/js/utils/misc.js b/web_src/js/utils/misc.js new file mode 100644 index 0000000000000..711043eb1b0b9 --- /dev/null +++ b/web_src/js/utils/misc.js @@ -0,0 +1,32 @@ +// returns a file's path from repo root, including a leading slash +export function getFileViewFilePath() { + const pathWithRepo = document.querySelector('.repo-path')?.textContent?.trim(); + return `/${pathWithRepo.split('/').filter((_, i) => i !== 0).join('/')}`; +} + +// returns a file's text content +export function getFileViewFileText() { + const lineEls = document.querySelectorAll('.file-view .lines-code'); + return Array.from(lineEls, (el) => el.textContent).join(''); +} + +// create a link with suitable attributes. `props` is a object of props with these additional options: +// - `external`: whether the link is external and should open in new tab +// remarks: +// - no `noopener` attribute for external because browser defaults to it with target `_blank` +// - no `noreferrer` attribute for external because we use `` +export function createLink(props = {}) { + const a = document.createElement('a'); + + if (props.external) { + delete props.external; + a.target = '_blank'; + a.rel = 'nofollow'; + } + + for (const [key, value] of Object.entries(props)) { + a[key] = value; + } + + return a; +} diff --git a/web_src/js/utils/misc.test.js b/web_src/js/utils/misc.test.js new file mode 100644 index 0000000000000..79b8e0eeef03b --- /dev/null +++ b/web_src/js/utils/misc.test.js @@ -0,0 +1,15 @@ +import {createLink} from './misc.js'; + +test('createLink', () => { + const internalLink = createLink({href: 'https://example.com', textContent: 'example'}); + expect(internalLink.tagName).toEqual('A'); + expect(internalLink.href).toEqual('https://example.com/'); + expect(internalLink.textContent).toEqual('example'); + expect(internalLink.target).toEqual(''); + + const externalLink = createLink({href: 'https://example.com', textContent: 'example', external: true}); + expect(externalLink.tagName).toEqual('A'); + expect(externalLink.href).toEqual('https://example.com/'); + expect(externalLink.textContent).toEqual('example'); + expect(externalLink.target).toEqual('_blank'); +});