From 9cbfa9529dda29931bd041c4aea13a9047a9c608 Mon Sep 17 00:00:00 2001 From: Evan You Date: Tue, 28 Apr 2020 17:50:28 -0400 Subject: [PATCH] export page data from page component --- lib/app/components/Content.js | 2 +- lib/app/composables/router.js | 17 ++-- lib/jsconfig.json | 1 - src/markdown/markdown.ts | 7 +- src/markdown/markdownToVue.ts | 35 -------- src/markdown/plugins/header.ts | 32 +++++++ src/markdown/plugins/link.ts | 13 ++- src/markdownToVue.ts | 88 +++++++++++++++++++ src/resolveConfig.ts | 17 +--- src/server.ts | 11 ++- src/tsconfig.json | 1 - src/utils/{parseHeaders.ts => parseHeader.ts} | 6 +- 12 files changed, 158 insertions(+), 72 deletions(-) delete mode 100644 src/markdown/markdownToVue.ts create mode 100644 src/markdown/plugins/header.ts create mode 100644 src/markdownToVue.ts rename src/utils/{parseHeaders.ts => parseHeader.ts} (95%) diff --git a/lib/app/components/Content.js b/lib/app/components/Content.js index 0642e3a5..d0903e55 100644 --- a/lib/app/components/Content.js +++ b/lib/app/components/Content.js @@ -4,6 +4,6 @@ import { useRoute } from '../composables/router' export const Content = { setup() { const route = useRoute() - return () => (route.component ? h(route.component) : null) + return () => (route.contentComponent ? h(route.contentComponent) : null) } } diff --git a/lib/app/composables/router.js b/lib/app/composables/router.js index 171b5613..b451cf7c 100644 --- a/lib/app/composables/router.js +++ b/lib/app/composables/router.js @@ -6,7 +6,8 @@ const NotFound = Theme.NotFound || (() => '404 Not Found') /** * @typedef {{ * path: string - * component: import('vue').Component | null + * contentComponent: import('vue').Component | null + * pageData: { path: string } | null * }} Route */ @@ -20,7 +21,8 @@ const RouteSymbol = Symbol() */ const getDefaultRoute = () => ({ path: location.pathname, - component: null + contentComponent: null, + pageData: null }) export function useRouter() { @@ -98,7 +100,10 @@ function loadPage(route, scrollPosition = 0) { import(`${pagePath}.md?t=${Date.now()}`) .then(async (m) => { if (route.path === pendingPath) { - route.component = m.default + route.contentComponent = m.default + route.pageData = m.__pageData + + console.log(route.pageData) await nextTick() window.scrollTo({ left: 0, @@ -108,8 +113,10 @@ function loadPage(route, scrollPosition = 0) { } }) .catch((err) => { - if (route.path === pendingPath) { - route.component = NotFound + if (!err.message.match(/fetch/)) { + throw err + } else if (route.path === pendingPath) { + route.contentComponent = NotFound } }) } diff --git a/lib/jsconfig.json b/lib/jsconfig.json index 7176ac8c..f12d243b 100644 --- a/lib/jsconfig.json +++ b/lib/jsconfig.json @@ -1,6 +1,5 @@ { "compilerOptions": { - "baseUrl": "../", "lib": ["ESNext", "DOM"], "moduleResolution": "node", "checkJs": true, diff --git a/src/markdown/markdown.ts b/src/markdown/markdown.ts index aa378562..6f5bcb36 100644 --- a/src/markdown/markdown.ts +++ b/src/markdown/markdown.ts @@ -1,5 +1,5 @@ import MarkdownIt from 'markdown-it' -import { parseHeaders } from '../utils/parseHeaders' +import { parseHeader } from '../utils/parseHeader' import { highlight } from './plugins/highlight' import { slugify } from './plugins/slugify' import { highlightLinePlugin } from './plugins/highlightLines' @@ -10,6 +10,7 @@ import { snippetPlugin } from './plugins/snippet' import { hoistPlugin } from './plugins/hoist' import { preWrapperPlugin } from './plugins/preWrapper' import { linkPlugin } from './plugins/link' +import { extractHeaderPlugin, Header } from './plugins/header' const emoji = require('markdown-it-emoji') const anchor = require('markdown-it-anchor') @@ -31,6 +32,7 @@ export interface MarkdownOpitons extends MarkdownIt.Options { export interface MarkdownParsedData { hoistedTags?: string[] links?: string[] + headers?: Header[] } export interface MarkdownRenderer { @@ -55,6 +57,7 @@ export const createMarkdownRenderer = ( .use(snippetPlugin) .use(hoistPlugin) .use(containerPlugin) + .use(extractHeaderPlugin) .use(linkPlugin, { target: '_blank', rel: 'noopener noreferrer', @@ -81,7 +84,7 @@ export const createMarkdownRenderer = ( { slugify, includeLevel: [2, 3], - format: parseHeaders + format: parseHeader }, options.toc ) diff --git a/src/markdown/markdownToVue.ts b/src/markdown/markdownToVue.ts deleted file mode 100644 index 51354fd0..00000000 --- a/src/markdown/markdownToVue.ts +++ /dev/null @@ -1,35 +0,0 @@ -import path from 'path' -import { createMarkdownRenderer, MarkdownOpitons } from './markdown' -import LRUCache from 'lru-cache' - -const matter = require('gray-matter') -const debug = require('debug')('vitepress:md') -const cache = new LRUCache({ max: 1024 }) - -export function createMarkdownToVueRenderFn( - root: string, - options: MarkdownOpitons = {} -) { - const md = createMarkdownRenderer(options) - - return (src: string, file: string) => { - file = path.relative(root, file) - const cached = cache.get(src) - if (cached) { - debug(`[cache hit] ${file}`) - return cached - } - const start = Date.now() - - const { content, data } = matter(src) - const { html } = md.render(content) - - // TODO validate links? - - const vueSrc = - `` + (data.hoistedTags || []).join('\n') - debug(`[render] ${file} in ${Date.now() - start}ms.`, data) - cache.set(src, vueSrc) - return vueSrc - } -} diff --git a/src/markdown/plugins/header.ts b/src/markdown/plugins/header.ts new file mode 100644 index 00000000..9187ec78 --- /dev/null +++ b/src/markdown/plugins/header.ts @@ -0,0 +1,32 @@ +import MarkdownIt from 'markdown-it' +import { MarkdownParsedData } from '../markdown' +import { deeplyParseHeader } from '../../utils/parseHeader' +import { slugify } from './slugify' + +export interface Header { + level: number + title: string + slug: string +} + +export const extractHeaderPlugin = ( + md: MarkdownIt & { __data: MarkdownParsedData }, + include = ['h2', 'h3'] +) => { + md.renderer.rules.heading_open = (tokens, i, options, env, self) => { + const token = tokens[i] + if (include.includes(token.tag)) { + const title = tokens[i + 1].content + const idAttr = token.attrs!.find(([name]) => name === 'id') + const slug = idAttr && idAttr[1] + const data = md.__data + const headers = data.headers || (data.headers = []) + headers.push({ + level: parseInt(token.tag.slice(1), 10), + title: deeplyParseHeader(title), + slug: slug || slugify(title) + }) + } + return self.renderToken(tokens, i, options) + } +} diff --git a/src/markdown/plugins/link.ts b/src/markdown/plugins/link.ts index 0aaf174d..b4effd95 100644 --- a/src/markdown/plugins/link.ts +++ b/src/markdown/plugins/link.ts @@ -18,12 +18,11 @@ export const linkPlugin = ( const hrefAttr = token.attrs![hrefIndex] const url = hrefAttr[1] const isExternal = /^https?:/.test(url) - const isSourceLink = /(\/|\.md|\.html)(#.*)?$/.test(url) if (isExternal) { Object.entries(externalAttrs).forEach(([key, val]) => { token.attrSet(key, val) }) - } else if (isSourceLink) { + } else if (!url.startsWith('#')) { normalizeHref(hrefAttr) } } @@ -31,13 +30,8 @@ export const linkPlugin = ( } function normalizeHref(hrefAttr: [string, string]) { - const data = md.__data let url = hrefAttr[1] - // convert link to filename and export it for existence check - const links = data.links || (data.links = []) - links.push(url) - const indexMatch = url.match(indexRE) if (indexMatch) { const [, path, , hash] = indexMatch @@ -51,6 +45,11 @@ export const linkPlugin = ( url = ensureBeginningDotSlash(url) } + // export it for existence check + const data = md.__data + const links = data.links || (data.links = []) + links.push(url) + // markdown-it encodes the uri hrefAttr[1] = decodeURI(url) } diff --git a/src/markdownToVue.ts b/src/markdownToVue.ts new file mode 100644 index 00000000..af809b81 --- /dev/null +++ b/src/markdownToVue.ts @@ -0,0 +1,88 @@ +import path from 'path' +import matter from 'gray-matter' +import LRUCache from 'lru-cache' +import { createMarkdownRenderer, MarkdownOpitons } from './markdown/markdown' +import { Header } from './markdown/plugins/header' +import { deeplyParseHeader } from './utils/parseHeader' + +const debug = require('debug')('vitepress:md') +const cache = new LRUCache({ max: 1024 }) + +export function createMarkdownToVueRenderFn( + root: string, + options: MarkdownOpitons = {} +) { + const md = createMarkdownRenderer(options) + + return (src: string, file: string, lastUpdated: number) => { + file = path.relative(root, file) + const cached = cache.get(src) + if (cached) { + debug(`[cache hit] ${file}`) + return cached + } + const start = Date.now() + + const { content, data: frontmatter } = matter(src) + const { html, data } = md.render(content) + + // TODO validate data.links? + + // inject page data + const additionalBlocks = injectPageData( + data.hoistedTags || [], + content, + frontmatter, + data.headers || [], + lastUpdated + ) + + const vueSrc = + `\n` + + additionalBlocks.join('\n') + debug(`[render] ${file} in ${Date.now() - start}ms.`) + cache.set(src, vueSrc) + return vueSrc + } +} + +const scriptRE = /<\/script>/ +function injectPageData( + tags: string[], + content: string, + frontmatter: object, + headers: Header[], + lastUpdated: number +) { + const code = `\nexport const __pageData = ${JSON.stringify({ + title: inferTitle(frontmatter, content), + frontmatter, + headers, + lastUpdated + })}` + + const existingScriptIndex = tags.findIndex((tag) => scriptRE.test(tag)) + if (existingScriptIndex > -1) { + tags[existingScriptIndex] = tags[existingScriptIndex].replace( + scriptRE, + code + `` + ) + } else { + tags.push(``) + } + + return tags +} + +const inferTitle = (frontmatter: any, content: string) => { + if (frontmatter.home) { + return 'Home' + } + if (frontmatter.title) { + return deeplyParseHeader(frontmatter.title) + } + const match = content.match(/^\s*#+\s+(.*)/m) + if (match) { + return deeplyParseHeader(match[1].trim()) + } +} diff --git a/src/resolveConfig.ts b/src/resolveConfig.ts index a43f4c43..dd6772cb 100644 --- a/src/resolveConfig.ts +++ b/src/resolveConfig.ts @@ -21,11 +21,6 @@ export interface SiteData { description: string base: string themeConfig: ThemeConfig - pages: PageData[] -} - -export interface PageData { - path: string } export interface ResolvedConfig { @@ -60,7 +55,7 @@ export async function resolveConfig(root: string): Promise { } export async function resolveSiteData(root: string): Promise { - // 1. load user config + // load user config const configPath = getConfigPath(root) let hasUserConfig = false try { @@ -73,16 +68,10 @@ export async function resolveSiteData(root: string): Promise { delete require.cache[configPath] const userConfig: UserConfig = hasUserConfig ? require(configPath) : {} - // 2. TODO scan pages data - - // 3. resolve site data - const site: SiteData = { + return { title: userConfig.title || 'VitePress', description: userConfig.description || 'A VitePress site', base: userConfig.base || '/', - themeConfig: userConfig.themeConfig || {}, - pages: [] + themeConfig: userConfig.themeConfig || {} } - - return site } diff --git a/src/server.ts b/src/server.ts index 0d4e530c..47409986 100644 --- a/src/server.ts +++ b/src/server.ts @@ -11,7 +11,7 @@ import { getConfigPath, resolveSiteData } from './resolveConfig' -import { createMarkdownToVueRenderFn } from './markdown/markdownToVue' +import { createMarkdownToVueRenderFn } from './markdownToVue' import { APP_PATH, SITE_DATA_REQUEST_PATH } from './utils/pathResolver' const debug = require('debug')('vitepress:serve') @@ -43,7 +43,12 @@ function createVitePressPlugin(config: ResolvedConfig): Plugin { if (file.endsWith('.md')) { debugHmr(`reloading ${file}`) const content = await cachedRead(null, file) - watcher.handleVueReload(file, Date.now(), markdownToVue(content, file)) + const timestamp = Date.now() + watcher.handleVueReload( + file, + timestamp, + markdownToVue(content, file, timestamp) + ) } }) @@ -85,7 +90,7 @@ function createVitePressPlugin(config: ResolvedConfig): Plugin { await cachedRead(ctx, file) // let vite know this is supposed to be treated as vue file ctx.vue = true - ctx.body = markdownToVue(ctx.body, file) + ctx.body = markdownToVue(ctx.body, file, ctx.lastModified.getTime()) debug(ctx.url, ctx.status) return next() } diff --git a/src/tsconfig.json b/src/tsconfig.json index f87a6b82..cf969577 100644 --- a/src/tsconfig.json +++ b/src/tsconfig.json @@ -1,6 +1,5 @@ { "compilerOptions": { - "baseUrl": "../", "outDir": "../dist", "module": "commonjs", "lib": ["ESNext"], diff --git a/src/utils/parseHeaders.ts b/src/utils/parseHeader.ts similarity index 95% rename from src/utils/parseHeaders.ts rename to src/utils/parseHeader.ts index 4d7c97d0..f13500ff 100644 --- a/src/utils/parseHeaders.ts +++ b/src/utils/parseHeader.ts @@ -46,7 +46,7 @@ const compose = (...processors: ((str: string) => string)[]) => { } // Unescape html, parse emojis and remove some md tokens. -export const parseHeaders = compose( +export const parseHeader = compose( unescapeHtml, parseEmojis, removeMarkdownTokens, @@ -56,7 +56,7 @@ export const parseHeaders = compose( // Also clean the html that isn't wrapped by code. // Because we want to support using VUE components in headers. // e.g. https://vuepress.vuejs.org/guide/using-vue.html#badge -export const deeplyParseHeaders = compose( +export const deeplyParseHeader = compose( removeNonCodeWrappedHTML, - parseHeaders + parseHeader )