mirror of https://github.com/vuejs/vitepress
311 lines
8.3 KiB
311 lines
8.3 KiB
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
|
|
}
|