diff --git a/__tests__/node/utils/deeplyParseHeader.spec.ts b/__tests__/node/utils/deeplyParseHeader.spec.ts deleted file mode 100644 index 976073a4a17..00000000000 --- a/__tests__/node/utils/deeplyParseHeader.spec.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { test, expect } from 'vitest' -import { deeplyParseHeader } from 'node/utils/parseHeader' - -test('deeplyParseHeader', () => { - const asserts: Record = { - // remove tail html - '# `H1` ': '# H1', - '# *H1* ': '# H1', - - // reserve code-wrapped tail html - '# `H1` ``': '# H1 ', - '# *H1* ``': '# H1 ', - - // remove leading html - '# `H1`': '# H1', - '# *H1*': '# H1', - - // reserve code-wrapped leading html - '# `` `H1`': '# H1', - '# `` *H1*': '# H1', - - // remove middle html - '# `H1` `H2`': '# H1 H2', - '# `H1` `H2`': '# H1 H2', - - // reserve middle html - '# `H1` `` `H2`': '# H1 H2', - '# `H1` `` `H2`': '# H1 H2' - } - - Object.keys(asserts).forEach((input) => { - expect(deeplyParseHeader(input)).toBe(asserts[input]) - }) -}) diff --git a/__tests__/node/utils/parseHeader.spec.ts b/__tests__/node/utils/parseHeader.spec.ts deleted file mode 100644 index 790f4373449..00000000000 --- a/__tests__/node/utils/parseHeader.spec.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, test, expect } from 'vitest' -import { parseHeader } from 'node/utils/parseHeader' - -describe('parseHeader', () => { - test('should unescape html', () => { - const input = `<div :id="'app'">` - expect(parseHeader(input)).toBe(`
`) - }) - - test('should remove markdown tokens correctly', () => { - const asserts: Record = { - // vuepress #238 - '[vue](vuejs.org)': 'vue', - '`vue`': 'vue', - '*vue*': 'vue', - '**vue**': 'vue', - '***vue***': 'vue', - _vue_: 'vue', - '\\_vue\\_': '_vue_', - '\\*vue\\*': '*vue*', - '\\!vue\\!': '!vue!', - - // vuepress #2688 - '[vue](vuejs.org) / [vue](vuejs.org)': 'vue / vue', - '[\\](vuejs.org)': '', - - // vuepress #564 For multiple markdown tokens - '`a` and `b`': 'a and b', - '***bold and italic***': 'bold and italic', - '**bold** and *italic*': 'bold and italic', - - // escaping \$ - '\\$vue': '$vue' - } - Object.keys(asserts).forEach((input) => { - expect(parseHeader(input)).toBe(asserts[input]) - }) - }) -}) diff --git a/__tests__/node/utils/removeNonCodeWrappedHTML.spec.ts b/__tests__/node/utils/removeNonCodeWrappedHTML.spec.ts deleted file mode 100644 index 17d506fcb43..00000000000 --- a/__tests__/node/utils/removeNonCodeWrappedHTML.spec.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { test, expect } from 'vitest' -import { removeNonCodeWrappedHTML } from 'node/utils/parseHeader' - -test('removeNonCodeWrappedHTML', () => { - const asserts: Record = { - // Remove tail html - '# H1 ': '# H1 ', - '# H1': '# H1', - '# H1 ': '# H1 ', - '# H1': '# H1', - '# H1 ': '# H1 ', - '# H1': '# H1', - '# H1 ': '# H1 ', - '# H1': '# H1', - - // Reserve code-wrapped tail html - '# H1 ``': '# H1 ``', - '# H1 ``': '# H1 ``', - '# H1 ``': '# H1 ``', - '# H1 ``': '# H1 ``', - - // Remove leading html - '# H1': '# H1', - '# H1': '# H1', - '# H1': '# H1', - '# H1': '# H1', - '# H1': '# H1', - '# H1': '# H1', - '# H1': '# H1', - '# H1': '# H1', - - // Reserve code-wrapped leading html - '# `` H1': '# `` H1', - '# `` H1': '# `` H1', - '# `` H1': '# `` H1', - '# `` H1': '# `` H1', - - // Remove middle html - '# H1 H2': '# H1 H2', - '# H1 H2': '# H1 H2', - '# H1 H2': '# H1 H2', - '# H1 H2': '# H1 H2', - - // Reserve code-wrapped middle html - '# H1 `` H2': '# H1 `` H2', - '# H1 `` H2': '# H1 `` H2', - '# H1 `` H2': '# H1 `` H2', - '# H1 `` H2': '# H1 `` H2', - - // vuepress #2688 - '# \\': '# \\' - } - - Object.keys(asserts).forEach((input) => { - expect(removeNonCodeWrappedHTML(input)).toBe(asserts[input]) - }) -}) diff --git a/package.json b/package.json index 54b2999b56c..0a165b753b9 100644 --- a/package.json +++ b/package.json @@ -95,7 +95,9 @@ "@mdit-vue/plugin-component": "^0.10.0", "@mdit-vue/plugin-frontmatter": "^0.10.0", "@mdit-vue/plugin-headers": "^0.10.0", + "@mdit-vue/plugin-title": "^0.10.0", "@mdit-vue/plugin-toc": "^0.10.0", + "@mdit-vue/shared": "^0.10.0", "@mdit-vue/types": "^0.10.0", "@rollup/plugin-alias": "^3.1.9", "@rollup/plugin-commonjs": "^22.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a705c2fe51..1f2eaa1e513 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,7 +9,9 @@ importers: '@mdit-vue/plugin-component': ^0.10.0 '@mdit-vue/plugin-frontmatter': ^0.10.0 '@mdit-vue/plugin-headers': ^0.10.0 + '@mdit-vue/plugin-title': ^0.10.0 '@mdit-vue/plugin-toc': ^0.10.0 + '@mdit-vue/shared': ^0.10.0 '@mdit-vue/types': ^0.10.0 '@rollup/plugin-alias': ^3.1.9 '@rollup/plugin-commonjs': ^22.0.2 @@ -94,7 +96,9 @@ importers: '@mdit-vue/plugin-component': 0.10.0 '@mdit-vue/plugin-frontmatter': 0.10.0 '@mdit-vue/plugin-headers': 0.10.0 + '@mdit-vue/plugin-title': 0.10.0 '@mdit-vue/plugin-toc': 0.10.0 + '@mdit-vue/shared': 0.10.0 '@mdit-vue/types': 0.10.0 '@rollup/plugin-alias': 3.1.9_rollup@2.78.0 '@rollup/plugin-commonjs': 22.0.2_rollup@2.78.0 @@ -423,6 +427,15 @@ packages: markdown-it: 13.0.1 dev: true + /@mdit-vue/plugin-title/0.10.0: + resolution: {integrity: sha512-odJ9vIazAHiomjCEEFwHNuPnmDtx/FGOYrf9xUfi3tjG9r/JZW+G++AABxvevTozwpGlpU+wkpJ7mTr+rNtBrw==} + dependencies: + '@mdit-vue/shared': 0.10.0 + '@mdit-vue/types': 0.10.0 + '@types/markdown-it': 12.2.3 + markdown-it: 13.0.1 + dev: true + /@mdit-vue/plugin-toc/0.10.0: resolution: {integrity: sha512-P9aNy4jtqfjI08wUYGT/HVd5x/IpTjgSnNdJ3lU52qAO5AeFsW3v4gt+NmW0lO8We0S2YDEONRHBuBN6r40y6A==} dependencies: diff --git a/src/node/markdown/markdown.ts b/src/node/markdown/markdown.ts index 922c667b7a0..652f20f75cd 100644 --- a/src/node/markdown/markdown.ts +++ b/src/node/markdown/markdown.ts @@ -11,6 +11,7 @@ import { headersPlugin, type HeadersPluginOptions } from '@mdit-vue/plugin-headers' +import { titlePlugin } from '@mdit-vue/plugin-title' import { tocPlugin, type TocPluginOptions } from '@mdit-vue/plugin-toc' import { IThemeRegistration } from 'shiki' import { highlight } from './plugins/highlight' @@ -106,6 +107,7 @@ export const createMarkdownRenderer = async ( slugify, ...options.headers } as HeadersPluginOptions) + .use(titlePlugin) .use(tocPlugin, { slugify, ...options.toc diff --git a/src/node/markdownToVue.ts b/src/node/markdownToVue.ts index 5a7c93474e3..80281aa48b5 100644 --- a/src/node/markdownToVue.ts +++ b/src/node/markdownToVue.ts @@ -2,14 +2,15 @@ import fs from 'fs' import path from 'path' import c from 'picocolors' import LRUCache from 'lru-cache' +import { resolveTitleFromToken } from '@mdit-vue/shared' import { PageData, HeadConfig, EXTERNAL_URL_RE, CleanUrlsMode } from './shared' import { slash } from './utils/slash' -import { deeplyParseHeader } from './utils/parseHeader' import { getGitTimestamp } from './utils/getGitTimestamp' import { createMarkdownRenderer, type MarkdownEnv, - type MarkdownOptions + type MarkdownOptions, + type MarkdownRenderer } from './markdown' import _debug from 'debug' @@ -83,7 +84,7 @@ export async function createMarkdownToVueRenderFn( } const html = md.render(src, env) const data = md.__data - const { content = '', frontmatter = {}, headers = [] } = env + const { frontmatter = {}, headers = [], title = '' } = env // validate data.links const deadLinks: string[] = [] @@ -129,7 +130,7 @@ export async function createMarkdownToVueRenderFn( } const pageData: PageData = { - title: inferTitle(frontmatter, content), + title: inferTitle(md, frontmatter, title), titleTemplate: frontmatter.titleTemplate as any, description: inferDescription(frontmatter), frontmatter, @@ -243,18 +244,21 @@ function genPageDataCode(tags: string[], data: PageData, replaceRegex: RegExp) { return tags } -const inferTitle = (frontmatter: Record, content: string) => { - if (frontmatter.title) { - return deeplyParseHeader(frontmatter.title) - } - - const match = content.match(/^\s*#+\s+(.*)/m) - - if (match) { - return deeplyParseHeader(match[1].trim()) +const inferTitle = ( + md: MarkdownRenderer, + frontmatter: Record, + title: string +) => { + if (typeof frontmatter.title === 'string') { + const titleToken = md.parseInline(frontmatter.title, {})[0] + if (titleToken) { + return resolveTitleFromToken(titleToken, { + shouldAllowHtml: false, + shouldEscapeText: false + }) + } } - - return '' + return title } const inferDescription = (frontmatter: Record) => { diff --git a/src/node/utils/parseHeader.ts b/src/node/utils/parseHeader.ts deleted file mode 100644 index 6258184d8ec..00000000000 --- a/src/node/utils/parseHeader.ts +++ /dev/null @@ -1,68 +0,0 @@ -// Since VuePress needs to extract the header from the markdown source -// file and display it in the sidebar or title (#238), this file simply -// removes some unnecessary elements to make header displays well at -// sidebar or title. -// -// But header's parsing in the markdown content is done by the markdown -// loader based on markdown-it. markdown-it parser will will always keep -// HTML in headers, so in VuePress, after being parsed by the markdown -// loader, the raw HTML in headers will finally be parsed by Vue-loader. -// so that we can write HTML/Vue in the header. One exception is the HTML -// wrapped by (markdown token: '`') tag. -import emojiData from 'markdown-it-emoji/lib/data/full.json' - -const parseEmojis = (str: string) => { - return str.replace( - /:(.+?):/g, - (placeholder, key) => (emojiData as any)[key] || placeholder - ) -} - -const unescapeHtml = (html: string) => - html - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/:/g, ':') - .replace(/</g, '<') - .replace(/>/g, '>') - -const removeMarkdownTokens = (str: string) => - str - .replace(/(\[(.[^\]]+)\]\((.[^)]+)\))/g, '$2') // []() - .replace(/(`|\*{1,3}|_)(.*?[^\\])\1/g, '$2') // `{t}` | *{t}* | **{t}** | ***{t}*** | _{t}_ - .replace(/(\\)(\*|_|`|\!|<|\$)/g, '$2') // remove escape char '\' - -const removeCustomAnchor = (str: string) => - str.replace(/\{#([a-zA-Z0-9\-_]+?)\}\s*$/, '') // {#custom-header} - -const trim = (str: string) => str.trim() - -// This method remove the raw HTML but reserve the HTML wrapped by ``. -// e.g. -// Input: " b", Output: "b" -// Input: "`` b", Output: "`` b" -export const removeNonCodeWrappedHTML = (str: string) => { - return String(str).replace(/(^|[^><`\\])<.*>([^><`]|$)/g, '$1$2') -} - -const compose = (...processors: ((str: string) => string)[]) => { - if (processors.length === 0) return (input: string) => input - if (processors.length === 1) return processors[0] - return processors.reduce((prev, next) => { - return (str) => next(prev(str)) - }) -} - -// Unescape html, parse emojis and remove some md tokens. -export const parseHeader = compose( - unescapeHtml, - parseEmojis, - removeCustomAnchor, - removeMarkdownTokens, - trim -) - -// 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 deeplyParseHeader = compose(removeNonCodeWrappedHTML, parseHeader)