import fs from 'fs' import path from 'path' import c from 'picocolors' import LRUCache from 'lru-cache' import { resolveTitleFromToken } from '@mdit-vue/shared' import { SiteConfig } from './config' import { PageData, HeadConfig, EXTERNAL_URL_RE, CleanUrlsMode } from './shared' import { slash } from './utils/slash' import { getGitTimestamp } from './utils/getGitTimestamp' import { createMarkdownRenderer, type MarkdownEnv, type MarkdownOptions, type MarkdownRenderer } from './markdown' import _debug from 'debug' const debug = _debug('vitepress:md') const cache = new LRUCache<string, MarkdownCompileResult>({ max: 1024 }) const includesRE = /<!--\s*@include:\s*(.*?)\s*-->/g export interface MarkdownCompileResult { vueSrc: string pageData: PageData deadLinks: string[] includes: string[] } export function clearCache() { cache.clear() } export async function createMarkdownToVueRenderFn( srcDir: string, options: MarkdownOptions = {}, pages: string[], userDefines: Record<string, any> | undefined, isBuild = false, base = '/', includeLastUpdatedData = false, cleanUrls: CleanUrlsMode = 'disabled', siteConfig: SiteConfig | null = null ) { const md = await createMarkdownRenderer(srcDir, options, base) pages = pages.map((p) => slash(p.replace(/\.md$/, ''))) const replaceRegex = genReplaceRegexp(userDefines, isBuild) return async ( src: string, file: string, publicDir: string ): Promise<MarkdownCompileResult> => { const relativePath = slash(path.relative(srcDir, file)) const dir = path.dirname(file) const cacheKey = JSON.stringify({ src, file }) const cached = cache.get(cacheKey) if (cached) { debug(`[cache hit] ${relativePath}`) return cached } const start = Date.now() // resolve includes let includes: string[] = [] src = src.replace(includesRE, (m, m1) => { try { const includePath = path.join(dir, m1) const content = fs.readFileSync(includePath, 'utf-8') includes.push(slash(includePath)) return content } catch (error) { return m // silently ignore error if file is not present } }) // reset env before render const env: MarkdownEnv = { path: file, relativePath, cleanUrls } const html = md.render(src, env) const { frontmatter = {}, headers = [], links = [], sfcBlocks, title = '' } = env // validate data.links const deadLinks: string[] = [] const recordDeadLink = (url: string) => { console.warn( c.yellow( `\n(!) Found dead link ${c.cyan(url)} in file ${c.white( c.dim(file) )}\nIf it is intended, you can use:\n ${c.cyan( `<a href="${url}" target="_blank" rel="noreferrer">${url}</a>` )}` ) ) deadLinks.push(url) } if (links) { const dir = path.dirname(file) for (let url of links) { if (/\.(?!html|md)\w+($|\?)/i.test(url)) continue if (url.replace(EXTERNAL_URL_RE, '').startsWith('//localhost:')) { recordDeadLink(url) continue } url = url.replace(/[?#].*$/, '').replace(/\.(html|md)$/, '') if (url.endsWith('/')) url += `index` const resolved = decodeURIComponent( slash( url.startsWith('/') ? url.slice(1) : path.relative(srcDir, path.resolve(dir, url)) ) ) if ( !pages.includes(resolved) && !fs.existsSync(path.resolve(dir, publicDir, `${resolved}.html`)) ) { recordDeadLink(url) } } } let pageData: PageData = { title: inferTitle(md, frontmatter, title), titleTemplate: frontmatter.titleTemplate as any, description: inferDescription(frontmatter), frontmatter, headers, relativePath } if (includeLastUpdatedData) { pageData.lastUpdated = await getGitTimestamp(file) } if (siteConfig?.transformPageData) { const dataToMerge = await siteConfig.transformPageData(pageData) if (dataToMerge) { pageData = { ...pageData, ...dataToMerge } } } const vueSrc = [ ...injectPageDataCode( sfcBlocks?.scripts.map((item) => item.content) ?? [], pageData, replaceRegex ), `<template><div>${replaceConstants( html, replaceRegex, vueTemplateBreaker )}</div></template>`, ...(sfcBlocks?.styles.map((item) => item.content) ?? []), ...(sfcBlocks?.customBlocks.map((item) => item.content) ?? []) ].join('\n') debug(`[render] ${file} in ${Date.now() - start}ms.`) const result = { vueSrc, pageData, deadLinks, includes } cache.set(cacheKey, result) return result } } const scriptRE = /<\/script>/ const scriptLangTsRE = /<\s*script[^>]*\blang=['"]ts['"][^>]*/ const scriptSetupRE = /<\s*script[^>]*\bsetup\b[^>]*/ const scriptClientRE = /<\s*script[^>]*\bclient\b[^>]*/ const defaultExportRE = /((?:^|\n|;)\s*)export(\s*)default/ const namedDefaultExportRE = /((?:^|\n|;)\s*)export(.+)as(\s*)default/ const jsStringBreaker = '\u200b' const vueTemplateBreaker = '<wbr>' function genReplaceRegexp( userDefines: Record<string, any> = {}, isBuild: boolean ): RegExp { // `process.env` need to be handled in both dev and build // @see https://github.com/vitejs/vite/blob/cad27ee8c00bbd5aeeb2be9bfb3eb164c1b77885/packages/vite/src/node/plugins/clientInjections.ts#L57-L64 const replacements = ['process.env'] if (isBuild) { replacements.push('import.meta', ...Object.keys(userDefines)) } return new RegExp( `\\b(${replacements .map((key) => key.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&')) .join('|')})`, 'g' ) } /** * To avoid env variables being replaced by vite: * - insert `'\u200b'` char into those strings inside js string (page data) * - insert `<wbr>` tag into those strings inside html string (vue template) * * @see https://vitejs.dev/guide/env-and-mode.html#production-replacement */ function replaceConstants(str: string, replaceRegex: RegExp, breaker: string) { return str.replace(replaceRegex, (_) => `${_[0]}${breaker}${_.slice(1)}`) } function injectPageDataCode( tags: string[], data: PageData, replaceRegex: RegExp ) { const dataJson = JSON.stringify(data) const code = `\nexport const __pageData = JSON.parse(${JSON.stringify( replaceConstants(dataJson, replaceRegex, jsStringBreaker) )})` const existingScriptIndex = tags.findIndex((tag) => { return ( scriptRE.test(tag) && !scriptSetupRE.test(tag) && !scriptClientRE.test(tag) ) }) const isUsingTS = tags.findIndex((tag) => scriptLangTsRE.test(tag)) > -1 if (existingScriptIndex > -1) { const tagSrc = tags[existingScriptIndex] // user has <script> tag inside markdown // if it doesn't have export default it will error out on build const hasDefaultExport = defaultExportRE.test(tagSrc) || namedDefaultExportRE.test(tagSrc) tags[existingScriptIndex] = tagSrc.replace( scriptRE, code + (hasDefaultExport ? `` : `\nexport default {name:'${data.relativePath}'}`) + `</script>` ) } else { tags.unshift( `<script ${isUsingTS ? 'lang="ts"' : ''}>${code}\nexport default {name:'${ data.relativePath }'}</script>` ) } return tags } const inferTitle = ( md: MarkdownRenderer, frontmatter: Record<string, any>, title: string ) => { if (typeof frontmatter.title === 'string') { const titleToken = md.parseInline(frontmatter.title, {})[0] if (titleToken) { return resolveTitleFromToken(titleToken, { shouldAllowHtml: false, shouldEscapeText: false }) } } return title } const inferDescription = (frontmatter: Record<string, any>) => { const { description, head } = frontmatter if (description !== undefined) { return description } return (head && getHeadMetaContent(head, 'description')) || '' } const getHeadMetaContent = ( head: HeadConfig[], name: string ): string | undefined => { if (!head || !head.length) { return undefined } const meta = head.find(([tag, attrs = {}]) => { return tag === 'meta' && attrs.name === name && attrs.content }) return meta && meta[1].content }