UNPKG

@diplodoc/transform

Version:

A simple transformer of text in YFM (Yandex Flavored Markdown) to HTML

271 lines (227 loc) 7.22 kB
import type Token from 'markdown-it/lib/token'; import type {Logger} from 'src/transform/log'; import type {CacheContext, StateCore} from '../../typings'; import type {MarkdownItPluginCb, MarkdownItPluginOpts} from '../typings'; import type {MarkdownItIncluded} from '../includes/types'; import url from 'url'; import {bold} from 'chalk'; import path, {isAbsolute, parse, relative, resolve} from 'path'; import { PAGE_LINK_REGEXP, defaultTransformLink, findBlockTokens, getHrefTokenAttr, headingInfo, isLocalUrl, } from '../../utils'; import {getFileTokens, isFileExists} from '../../utilsFS'; function getTitleFromTokens(tokens: Token[]) { let title = ''; let i = 0; while (i < tokens.length) { const token = tokens[i]; if (token?.type === 'heading_open') { const info = headingInfo(tokens, i); title = info?.title ?? ''; break; } i += 1; } return title; } type Options = { hash: string | null; file: string; state: StateCore; opts: object; isEmptyLink: boolean; tokens: Token[]; idx: number; nextToken: Token; href: string; currentPath: string; log: Logger; cache?: CacheContext; }; const getTitle = (md: MarkdownItIncluded, id: string | null, options: Options) => { const {file, state, opts} = options; // Check the existed included store and extract it const included = md.included?.[file]; const fileTokens = getFileTokens( file, state, { ...opts, disableLint: true, disableTitleRefSubstitution: true, disableCircularError: true, inheritVars: false, }, included, ); const sourceTokens = id ? findBlockTokens(fileTokens, id) : fileTokens; return getTitleFromTokens(sourceTokens); }; const addTitle = (md: MarkdownItIncluded, options: Options) => { const {hash, state, isEmptyLink, tokens, idx, nextToken, href, currentPath, log, cache} = options; const id = hash && hash.slice(1); const key = [id, path].join('::'); const title = cache?.get(key) ?? getTitle(md, id, options); cache?.set(key, title); if (title) { let textToken; if (isEmptyLink) { textToken = new state.Token('text', '', 0); tokens.splice(idx + 1, 0, textToken); } else { textToken = nextToken; } textToken.content = title; } else { log.warn(`Title not found: ${bold(href)} in ${bold(currentPath)}`); } }; interface ProcOpts extends MarkdownItPluginOpts { transformLink: (v: string) => string; notFoundCb: (v: string) => void; needSkipLinkFn: (v: string) => boolean; getPublicPath: (options: ProcOpts, v?: string) => string; } function getDefaultPublicPath( { file, path, }: { file?: string; path?: string; }, input?: string | null, ) { return relative(parse(path || '').dir, input || file || ''); } // eslint-disable-next-line complexity function processLink( md: MarkdownItIncluded, state: StateCore, tokens: Token[], idx: number, opts: ProcOpts, ) { const { path: startPath, root, transformLink, notFoundCb, needSkipLinkFn, log, getPublicPath = getDefaultPublicPath, cache, skipLinkFileCheck = false, } = opts; const currentPath = state.env.path || startPath; const linkToken = tokens[idx]; const nextToken = tokens[idx + 1]; const originalHref = getHrefTokenAttr(linkToken); if (!originalHref) { log.error(`Empty link in ${bold(startPath)}`); return; } const {pathname, hash} = url.parse(originalHref); let file; let fileExists; let isPageFile; if (!isLocalUrl(originalHref)) { linkToken.attrSet('target', '_blank'); linkToken.attrSet('rel', 'noreferrer noopener'); return; } if (pathname) { file = resolve(path.parse(currentPath).dir, pathname); fileExists = skipLinkFileCheck || isFileExists(file); isPageFile = PAGE_LINK_REGEXP.test(pathname); if (isPageFile && !fileExists) { let needShowError = true; if (needSkipLinkFn) { needShowError = !needSkipLinkFn(originalHref); } if (notFoundCb && needShowError) { notFoundCb(file.replace(root, '')); } if (needShowError) { log.error(`Link is unreachable: ${bold(originalHref)} in ${bold(currentPath)}`); } } } else if (hash) { file = startPath; fileExists = true; isPageFile = true; } else { return; } const isEmptyLink = nextToken.type === 'link_close'; const isTitleRefLink = nextToken.type === 'text' && nextToken.content === '{#T}'; if ( (isEmptyLink || isTitleRefLink) && fileExists && isPageFile && !state.env.disableTitleRefSubstitution ) { addTitle(md, { hash, file, state, opts, isEmptyLink, tokens, idx, nextToken, href: originalHref, currentPath, log, cache, }); } const patchedHref = !isAbsolute(originalHref) && !originalHref.includes('//') ? url.format({ ...url.parse(originalHref), pathname: getPublicPath(opts, file), }) : originalHref; const linkHrefTransformer = transformLink || defaultTransformLink; linkToken.attrSet('href', linkHrefTransformer(patchedHref)); } const index: MarkdownItPluginCb<ProcOpts & Options> = (md: MarkdownItIncluded, opts) => { const plugin = (state: StateCore) => { const tokens = state.tokens; let i = 0; while (i < tokens.length) { if (tokens[i].type === 'inline') { const childrenTokens = tokens[i].children || []; let j = 0; while (j < childrenTokens.length) { const token = childrenTokens[j]; const isLinkOpenToken = token.type === 'link_open'; const tokenClass = token.attrGet('class'); /* Don't process anchor links */ const isYfmAnchor = tokenClass ? tokenClass.includes('yfm-anchor') : false; const wasProcessedBefore = Boolean(token.meta?.yfmLinkPluginProcessed); if (isLinkOpenToken && !wasProcessedBefore && !isYfmAnchor) { processLink(md, state, childrenTokens, j, opts); token.meta ??= {}; token.meta.yfmLinkPluginProcessed = true; } j++; } } i++; } }; try { md.core.ruler.before('includes', 'links', plugin); } catch (e) { md.core.ruler.push('links', plugin); } }; export = index;