From 133be0833bdacd419bdb50ff5812c9d81f4f0f57 Mon Sep 17 00:00:00 2001 From: Maxim Karpov Date: Fri, 26 Apr 2024 14:56:00 +0300 Subject: [PATCH] feat: add relative path resolution for links --- src/transform/headings.ts | 15 ++-- src/transform/md.ts | 5 +- src/transform/plugins/anchors/index.ts | 27 ++++--- src/transform/plugins/links/collect.ts | 3 +- src/transform/plugins/links/constants.ts | 1 - src/transform/plugins/links/index.ts | 26 ++++--- src/transform/plugins/typings.ts | 1 + src/transform/typings.ts | 4 +- src/transform/utils.ts | 35 +++++++++ test/anchors.test.ts | 38 +++++----- test/data/links.ts | 4 +- test/links.test.ts | 22 ++++-- test/utils.test.ts | 92 ++++++++++++++++++++++++ test/utils.ts | 2 +- 14 files changed, 213 insertions(+), 62 deletions(-) delete mode 100644 src/transform/plugins/links/constants.ts create mode 100644 test/utils.test.ts diff --git a/src/transform/headings.ts b/src/transform/headings.ts index 6702df97..aa1e9628 100644 --- a/src/transform/headings.ts +++ b/src/transform/headings.ts @@ -17,11 +17,13 @@ function getHref(token: Token) { return '#' + (token.attrGet('id') || ''); } -export = function getHeadings(tokens: Token[], needFlatListHeadings?: boolean) { - return needFlatListHeadings ? getFlatListHeadings(tokens) : getFilteredHeadings(tokens); +export = function getHeadings(tokens: Token[], needFlatListHeadings?: boolean, href = '') { + return needFlatListHeadings + ? getFlatListHeadings(tokens, href) + : getFilteredHeadings(tokens, href); }; -function getFilteredHeadings(tokens: Token[]) { +function getFilteredHeadings(tokens: Token[], href: string) { const headings: Heading[] = []; let parents = [headings]; let prevLevel; @@ -33,7 +35,7 @@ function getFilteredHeadings(tokens: Token[]) { if (isHeading && level >= 2) { const entry = { title: getTitle(tokens[i + 1]), - href: getHref(tokens[i]), + href: href + getHref(tokens[i]), level, }; let closestParent = parents[parents.length - 1]; @@ -66,7 +68,8 @@ function getFilteredHeadings(tokens: Token[]) { return headings; } -function getFlatListHeadings(tokens: Token[]) { + +function getFlatListHeadings(tokens: Token[], href: string) { const headings: Heading[] = []; for (let i = 0; i < tokens.length; i++) { @@ -79,7 +82,7 @@ function getFlatListHeadings(tokens: Token[]) { headings.push({ title: getTitle(tokens[i + 1]), - href: getHref(tokens[i]), + href: href + getHref(tokens[i]), level, }); } diff --git a/src/transform/md.ts b/src/transform/md.ts index 1f271200..1079b8ba 100644 --- a/src/transform/md.ts +++ b/src/transform/md.ts @@ -9,6 +9,7 @@ import attrs from 'markdown-it-attrs'; import extractTitle from './title'; import getHeadings from './headings'; import sanitizeHtml from './sanitize'; +import {getPublicPath} from './utils'; function initMarkdownit(options: OptionsType) { const {allowHTML = false, linkify = false, breaks = true, highlightLangs = {}} = options; @@ -85,6 +86,8 @@ function initParser(md: MarkdownIt, options: OptionsType, env: EnvType) { return (input: string) => { const {extractTitle: extractTitleOption, needTitle, needFlatListHeadings = false} = options; + const href = getPublicPath(options); + let tokens = md.parse(input, env); if (extractTitleOption) { @@ -104,7 +107,7 @@ function initParser(md: MarkdownIt, options: OptionsType, env: EnvType) { env.title = extractTitle(tokens).title; } - env.headings = getHeadings(tokens, needFlatListHeadings); + env.headings = getHeadings(tokens, needFlatListHeadings, href); return tokens; }; diff --git a/src/transform/plugins/anchors/index.ts b/src/transform/plugins/anchors/index.ts index c6dfdd9c..4564c9f2 100644 --- a/src/transform/plugins/anchors/index.ts +++ b/src/transform/plugins/anchors/index.ts @@ -1,7 +1,7 @@ import {bold} from 'chalk'; import GithubSlugger from 'github-slugger'; -import {headingInfo} from '../../utils'; +import {getPublicPath, headingInfo} from '../../utils'; import {CUSTOM_ID_EXCEPTION, CUSTOM_ID_REGEXP} from './constants'; import StateCore from 'markdown-it/lib/rules_core/state_core'; import Token from 'markdown-it/lib/token'; @@ -10,14 +10,20 @@ import {MarkdownItPluginCb} from '../typings'; const slugify: (str: string, opts: {}) => string = require('slugify'); -function createLinkTokens(state: StateCore, id: string, title: string, setId = false) { +function createLinkTokens( + state: StateCore, + id: string, + title: string, + setId = false, + href: string, +) { const open = new state.Token('link_open', 'a', 1); const close = new state.Token('link_close', 'a', -1); if (setId) { open.attrSet('id', id); } - open.attrSet('href', '#' + id); + open.attrSet('href', href + '#' + id); open.attrSet('class', 'yfm-anchor'); open.attrSet('aria-hidden', 'true'); @@ -58,6 +64,7 @@ const removeCustomId = (content: string) => { return content; }; + const removeCustomIds = (token: Token) => { token.content = removeCustomId(token.content); token.children?.forEach((child) => { @@ -68,18 +75,20 @@ const removeCustomIds = (token: Token) => { interface Options { extractTitle?: boolean; supportGithubAnchors?: boolean; + transformLink: (v: string) => string; } -const index: MarkdownItPluginCb = ( - md, - {extractTitle, path, log, supportGithubAnchors}, -) => { +const index: MarkdownItPluginCb = (md, options) => { + const {extractTitle, path, log, supportGithubAnchors} = options; + const plugin = (state: StateCore) => { /* Do not use the plugin if it is included in the file */ if (state.env.includes && state.env.includes.length) { return; } + const href = getPublicPath(options, state.env.path); + const ids: Record = {}; const tokens = state.tokens; let i = 0; @@ -132,12 +141,12 @@ const index: MarkdownItPluginCb = ( const anchorTitle = removeCustomId(title).replace(/`/g, ''); allAnchorIds.forEach((customId) => { const setId = id !== customId; - const linkTokens = createLinkTokens(state, customId, anchorTitle, setId); + const linkTokens = createLinkTokens(state, customId, anchorTitle, setId, href); inlineToken.children?.unshift(...linkTokens); if (supportGithubAnchors) { - const ghLinkTokens = createLinkTokens(state, ghId, anchorTitle, true); + const ghLinkTokens = createLinkTokens(state, ghId, anchorTitle, true, href); inlineToken.children?.unshift(...ghLinkTokens); } }); diff --git a/src/transform/plugins/links/collect.ts b/src/transform/plugins/links/collect.ts index bc9118ae..e0c6d2f6 100644 --- a/src/transform/plugins/links/collect.ts +++ b/src/transform/plugins/links/collect.ts @@ -1,10 +1,9 @@ import MarkdownIt from 'markdown-it'; import {sep} from 'path'; import url from 'url'; -import {getHrefTokenAttr, isLocalUrl} from '../../utils'; +import {PAGE_LINK_REGEXP, getHrefTokenAttr, isLocalUrl} from '../../utils'; import {getSinglePageAnchorId, resolveRelativePath} from '../../utilsFS'; import index from './index'; -import {PAGE_LINK_REGEXP} from './constants'; const replaceLinkHref = (input: string, href: string, newHref: string) => { /* Try not replace include syntax */ diff --git a/src/transform/plugins/links/constants.ts b/src/transform/plugins/links/constants.ts deleted file mode 100644 index 8ce9b0ce..00000000 --- a/src/transform/plugins/links/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const PAGE_LINK_REGEXP = /\.(md|ya?ml)$/i; diff --git a/src/transform/plugins/links/index.ts b/src/transform/plugins/links/index.ts index f98e671e..9f316cce 100644 --- a/src/transform/plugins/links/index.ts +++ b/src/transform/plugins/links/index.ts @@ -1,23 +1,21 @@ import url from 'url'; import {bold} from 'chalk'; -import {findBlockTokens, getHrefTokenAttr, headingInfo, isLocalUrl} from '../../utils'; +import { + PAGE_LINK_REGEXP, + defaultTransformLink, + findBlockTokens, + getHrefTokenAttr, + getPublicPath, + headingInfo, + isLocalUrl, +} from '../../utils'; import {getFileTokens, isFileExists} from '../../utilsFS'; -import {PAGE_LINK_REGEXP} from './constants'; import Token from 'markdown-it/lib/token'; import {Logger} from 'src/transform/log'; import {MarkdownItPluginCb, MarkdownItPluginOpts} from '../typings'; -import path, {isAbsolute, parse, relative, resolve} from 'path'; +import path, {isAbsolute, resolve} from 'path'; import {StateCore} from 'src/transform/typings'; -function defaultTransformLink(href: string) { - const parsed = url.parse(href); - - return url.format({ - ...parsed, - pathname: parsed.pathname?.replace(PAGE_LINK_REGEXP, '.html'), - }); -} - function getTitleFromTokens(tokens: Token[]) { let title = ''; @@ -164,8 +162,8 @@ function processLink(state: StateCore, tokens: Token[], idx: number, opts: ProcO } let newPathname = ''; - if (!isAbsolute(href) && currentPath !== startPath) { - newPathname = relative(parse(startPath).dir, file); + if (!isAbsolute(href)) { + newPathname = getPublicPath(opts, file); href = url.format({ ...url.parse(href), diff --git a/src/transform/plugins/typings.ts b/src/transform/plugins/typings.ts index 157154b7..94c6f833 100644 --- a/src/transform/plugins/typings.ts +++ b/src/transform/plugins/typings.ts @@ -6,6 +6,7 @@ export interface MarkdownItPluginOpts { log: Logger; lang: 'ru' | 'en' | 'es' | 'fr' | 'cs' | 'ar' | 'he'; root: string; + rootPublicPath: string; isLintRun: boolean; } diff --git a/src/transform/typings.ts b/src/transform/typings.ts index 7857a564..8c4246c5 100644 --- a/src/transform/typings.ts +++ b/src/transform/typings.ts @@ -43,8 +43,10 @@ export interface OptionsType { // eslint-disable-next-line @typescript-eslint/no-explicit-any plugins?: MarkdownItPluginCb[]; highlightLangs?: HighlightLangMap; - root?: string; extractChangelogs?: boolean; + root?: string; + rootPublicPath?: string; + transformLink?: (href: string) => string; [x: string]: unknown; } diff --git a/src/transform/utils.ts b/src/transform/utils.ts index bace0d63..c7dab48b 100644 --- a/src/transform/utils.ts +++ b/src/transform/utils.ts @@ -1,3 +1,5 @@ +import url from 'url'; +import {relative, resolve} from 'path'; import Token from 'markdown-it/lib/token'; export function isLocalUrl(url: string) { @@ -89,3 +91,36 @@ export function getHrefTokenAttr(token: Token) { return href; } + +export const PAGE_LINK_REGEXP = /\.(md|ya?ml)$/i; + +export function defaultTransformLink(href: string) { + const parsed = url.parse(href); + href = url.format({ + ...parsed, + pathname: parsed.pathname?.replace(PAGE_LINK_REGEXP, '.html'), + }); + + return href; +} + +export function getPublicPath( + { + path, + root, + rootPublicPath, + transformLink, + }: { + path?: string; + root?: string; + rootPublicPath?: string; + transformLink?: (href: string) => string; + }, + input?: string | null, +) { + const currentPath = input || path || ''; + const filePath = relative(resolve(root || '', rootPublicPath || ''), currentPath); + const transformer = transformLink || defaultTransformLink; + const href = transformer(filePath); + return href; +} diff --git a/test/anchors.test.ts b/test/anchors.test.ts index fdfef5c6..6431a59d 100644 --- a/test/anchors.test.ts +++ b/test/anchors.test.ts @@ -5,7 +5,7 @@ import anchors from '../src/transform/plugins/anchors'; import {log} from '../src/transform/log'; import transform from '../src/transform'; -const mocksPath = require.resolve('./utils.ts'); +const mocksPath = require.resolve('./mocks/link.md'); const transformYfm = (text: string) => { const { result: {html}, @@ -24,14 +24,14 @@ describe('Anchors', () => { it('should add single anchor with auto naming', () => { expect(transformYfm('## Test\n' + '\n' + 'Content\n')).toBe( - '

Test

\n' + + '

Test

\n' + '

Content

\n', ); }); it('should add single anchor', () => { expect(transformYfm('## Test {#test1}\n' + '\n' + 'Content\n')).toBe( - '

Test

\n' + + '

Test

\n' + '

Content

\n', ); }); @@ -39,9 +39,9 @@ describe('Anchors', () => { it('should add multiple anchors', () => { expect(transformYfm('## Test {#test1} {#test2} {#test3}\n' + '\n' + 'Content\n')).toBe( '

' + - '' + - '' + - 'Test

\n' + + '' + + '' + + 'Test\n' + '

Content

\n', ); }); @@ -53,12 +53,12 @@ describe('Anchors', () => { '\n' + 'Content before include\n' + '\n' + - '{% include [test](./mocks/include-anchor.md) %}\n', + '{% include [test](./include-anchor.md) %}\n', ), ).toBe( - '

Test

\n' + + '

Test

\n' + '

Content before include

\n' + - '

Title

\n' + + '

Title

\n' + '

Content

\n', ); }); @@ -70,15 +70,15 @@ describe('Anchors', () => { '\n' + 'Content before include\n' + '\n' + - '{% include [test](./mocks/include-multiple-anchors.md) %}\n', + '{% include [test](./include-multiple-anchors.md) %}\n', ), ).toBe( - '

Test

\n' + + '

Test

\n' + '

Content before include

\n' + '

' + - '' + - '' + - 'Title

\n' + + '' + + '' + + 'Title\n' + '

Content

\n', ); }); @@ -86,7 +86,7 @@ describe('Anchors', () => { it('should be transliterated correctly', () => { expect(transformYfm('## Максимальный размер дисков \n' + '\n' + 'Content\n')).toBe( '

' + - '' + + '' + 'Максимальный размер дисков' + '

\n' + '

Content

\n', @@ -96,7 +96,7 @@ describe('Anchors', () => { it('should be removed fences after transliteration', () => { expect(transformYfm('## `Test`\n' + '\n' + 'Content\n')).toBe( '

' + - 'Test' + + 'Test' + '

\n' + '

Content

\n', ); @@ -107,13 +107,13 @@ describe('Anchors', () => { transformYfm( 'Content before include\n' + '\n' + - '{% include [file](./mocks/folder-with-#-sharp/file-with-#-sharp.md#anchor) %}\n' + + '{% include [file](./folder-with-#-sharp/file-with-#-sharp.md#anchor) %}\n' + '\n' + 'After include', ), ).toBe( '

Content before include

\n' + - '

Subtitle

\n' + + '

Subtitle

\n' + '

Subcontent

\n' + '

After include

\n', ); @@ -122,7 +122,7 @@ describe('Anchors', () => { it('should add anchor with auto naming, using entire heading text', () => { expect(transformYfm('## _Lorem ~~ipsum **dolor** sit~~ amet_\n\nParagraph\n')).toBe( '

' + - '' + 'Lorem ipsum dolor sit amet

\n' + '

Paragraph

\n', diff --git a/test/data/links.ts b/test/data/links.ts index 5716124b..0626f611 100644 --- a/test/data/links.ts +++ b/test/data/links.ts @@ -86,7 +86,7 @@ export const title = [ { type: 'link_open', tag: 'a', - attrs: [['href', './mocks/link.html']], + attrs: [['href', 'mocks/link.html']], map: null, nesting: 1, level: 0, @@ -301,7 +301,7 @@ export const customTitle = [ { type: 'link_open', tag: 'a', - attrs: [['href', './mocks/link.html']], + attrs: [['href', 'mocks/link.html']], map: null, nesting: 1, level: 0, diff --git a/test/links.test.ts b/test/links.test.ts index a7eb9281..532289be 100644 --- a/test/links.test.ts +++ b/test/links.test.ts @@ -23,6 +23,10 @@ const transformYfm = (text: string, path?: string, extraOpts?: OptionsType) => { return html; }; +const expectObject = (a: unknown, b: unknown) => { + expect(JSON.parse(JSON.stringify(a))).toEqual(JSON.parse(JSON.stringify(b))); +}; + describe('Links', () => { test('Should create link with custom title', () => { const result = callPlugin( @@ -41,7 +45,7 @@ describe('Links', () => { }, ); - expect(result).toEqual(customTitle); + expectObject(result, customTitle); }); test('Should create link with title from target', () => { @@ -61,7 +65,7 @@ describe('Links', () => { const input = '[{#T}](./mocks/include-link.md)'; const result = transformYfm(input); - expect(result).toEqual('

Title

\n'); + expect(result).toEqual('

Title

\n'); }); test('Should create link with title from target with circular include', () => { @@ -69,7 +73,7 @@ describe('Links', () => { const input = readFileSync(inputPath, 'utf8'); const result = transformYfm(input, inputPath); - expect(result).toEqual('

First

\n

Second

\n'); + expect(result).toEqual('

First

\n

Second

\n'); }); test('Should create link with title from target with circular link', () => { @@ -77,7 +81,7 @@ describe('Links', () => { const input = readFileSync(inputPath, 'utf8'); const result = transformYfm(input, inputPath); - expect(result).toEqual('

First

\n

Second

\n'); + expect(result).toEqual('

First

\n

Second

\n'); }); test('Should create link with the absolute path', () => { @@ -105,11 +109,13 @@ describe('Links', () => { transformYfm(input, inputPath, { transformLink: (href: string) => { + href = href.replace('.md', ''); result = href; + return href; }, }); - expect(result).toEqual('../link/'); + expect(result).toEqual('../link'); }); test('Should call the "transformLink" callback for absolute link', () => { @@ -120,7 +126,9 @@ describe('Links', () => { transformYfm(input, inputPath, { transformLink: (href: string) => { + href = href.replace('.md', ''); result = href; + return href; }, }); @@ -135,11 +143,13 @@ describe('Links', () => { transformYfm(input, inputPath, { transformLink: (href: string) => { + href = href.replace('.md', ''); result = href; + return href; }, }); - expect(result).toEqual(''); + expect(result).toEqual('external-link'); }); }); }); diff --git a/test/utils.test.ts b/test/utils.test.ts new file mode 100644 index 00000000..cf85c966 --- /dev/null +++ b/test/utils.test.ts @@ -0,0 +1,92 @@ +import {PAGE_LINK_REGEXP, defaultTransformLink, getPublicPath} from '../src/transform/utils'; + +const assert = require('assert'); + +describe('PAGE_LINK_REGEXP', function () { + it('make sure .md is page', function () { + assert.equal(PAGE_LINK_REGEXP.test('abc/page.md'), true); + }); + + it('make sure .yaml is page', function () { + assert.equal(PAGE_LINK_REGEXP.test('abc/page.yaml'), true); + }); + + it('make sure .yml is page', function () { + assert.equal(PAGE_LINK_REGEXP.test('abc/page.yml'), true); + }); + + it('make sure .html is not page', function () { + assert.equal(PAGE_LINK_REGEXP.test('abc/page.html'), false); + }); + + it('make sure .mdm is not page', function () { + assert.equal(PAGE_LINK_REGEXP.test('abc/page.mdm'), false); + }); +}); + +describe('defaultTransformLink', function () { + it('make sure .md is removed', function () { + assert.equal(defaultTransformLink('abc/page.md'), 'abc/page.html'); + }); + + it('make sure .yaml is removed', function () { + assert.equal(defaultTransformLink('abc/page.yaml'), 'abc/page.html'); + }); + + it('make sure .yml is removed', function () { + assert.equal(defaultTransformLink('abc/page.yml'), 'abc/page.html'); + }); + + it('make sure empty is not changed', function () { + assert.equal(defaultTransformLink('abc/page'), 'abc/page'); + }); + + it('make sure .test is not changed', function () { + assert.equal(defaultTransformLink('abc/page.test'), 'abc/page.test'); + }); +}); + +describe('getPublicPath', function () { + it('make sure local path includes rootPublicPath', function () { + assert.equal( + getPublicPath({ + path: './ru/test.md', + root: './', + rootPublicPath: 'ru', + }), + 'test.html', + ); + }); + + it('make sure local path does not include rootPublicPath', function () { + assert.equal( + getPublicPath({ + path: './ru/test.md', + root: './', + rootPublicPath: '', + }), + 'ru/test.html', + ); + }); + + it('make sure local path with only path', function () { + assert.equal( + getPublicPath({ + path: './test.md', + }), + 'test.html', + ); + }); + + it('make sure local path with only input path', function () { + assert.equal( + getPublicPath( + { + path: './test.md', + }, + './test2.md', + ), + 'test2.html', + ); + }); +}); diff --git a/test/utils.ts b/test/utils.ts index aa925452..c2a2d8ea 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -13,7 +13,7 @@ type DeepPartial = T extends object const md = new MarkdownIt(); -export function callPlugin( +export function callPlugin( plugin: MarkdownItPluginCb, tokens: Token[], opts?: Partial,