diff --git a/packages/@vuepress/markdown/__tests__/__snapshots__/tableOfContents.spec.js.snap b/packages/@vuepress/markdown/__tests__/__snapshots__/tableOfContents.spec.js.snap new file mode 100644 index 0000000000..6846ab71db --- /dev/null +++ b/packages/@vuepress/markdown/__tests__/__snapshots__/tableOfContents.spec.js.snap @@ -0,0 +1,28 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`tableOfContents should generate unique and valid links with html and emoji 1`] = ` +

H1

+

+
+ +
+

+

H2

+

H3 😄

+

H2

+

H3 + +

+`; diff --git a/packages/@vuepress/markdown/__tests__/fragments/toc.md b/packages/@vuepress/markdown/__tests__/fragments/toc.md new file mode 100644 index 0000000000..4afe4be1e7 --- /dev/null +++ b/packages/@vuepress/markdown/__tests__/fragments/toc.md @@ -0,0 +1,11 @@ +# H1 + +[[toc]] + +## H2 + +### H3 :smile: + +## H2 + +### H3 diff --git a/packages/@vuepress/markdown/__tests__/tableOfContents.spec.js b/packages/@vuepress/markdown/__tests__/tableOfContents.spec.js new file mode 100644 index 0000000000..9a510fbb94 --- /dev/null +++ b/packages/@vuepress/markdown/__tests__/tableOfContents.spec.js @@ -0,0 +1,24 @@ +import { getFragment } from '@vuepress/test-utils' +import { Md } from './util' +import emoji from 'markdown-it-emoji' +import anchor from 'markdown-it-anchor' +import toc from '../lib/tableOfContents' +import slugify from '../../shared-utils/lib/slugify.js' + +const md = Md() + .set({ html: true }) + .use(emoji) + .use(anchor, { + slugify, + permalink: true, + permalinkBefore: true, + permalinkSymbol: '#' + }).use(toc) + +describe('tableOfContents', () => { + test('should generate unique and valid links with html and emoji', () => { + const input = getFragment(__dirname, 'toc.md') + const output = md.render(input) + expect(output).toMatchSnapshot() + }) +}) diff --git a/packages/@vuepress/markdown/index.js b/packages/@vuepress/markdown/index.js index fae5948ab4..d9f3ba4c78 100644 --- a/packages/@vuepress/markdown/index.js +++ b/packages/@vuepress/markdown/index.js @@ -14,12 +14,11 @@ const componentPlugin = require('./lib/component') const hoistScriptStylePlugin = require('./lib/hoist') const convertRouterLinkPlugin = require('./lib/link') const snippetPlugin = require('./lib/snippet') +const tocPlugin = require('./lib/tableOfContents') const emojiPlugin = require('markdown-it-emoji') const anchorPlugin = require('markdown-it-anchor') -const tocPlugin = require('markdown-it-table-of-contents') const { slugify: _slugify, - parseHeaders, logger, chalk, normalizeConfig, moduleResolver: { getMarkdownItResolver } } = require('@vuepress/shared-utils') @@ -94,11 +93,7 @@ module.exports = (markdown = {}) => { .end() .plugin(PLUGINS.TOC) - .use(tocPlugin, [Object.assign({ - slugify, - includeLevel: [2, 3], - format: parseHeaders - }, toc)]) + .use(tocPlugin, [toc]) .end() if (lineNumbers) { diff --git a/packages/@vuepress/markdown/lib/tableOfContents.js b/packages/@vuepress/markdown/lib/tableOfContents.js new file mode 100644 index 0000000000..cdfa7a2067 --- /dev/null +++ b/packages/@vuepress/markdown/lib/tableOfContents.js @@ -0,0 +1,141 @@ +// Reference: https://github.com/Oktavilla/markdown-it-table-of-contents +const { slugify, parseHeaders } = require('@vuepress/shared-utils') + +const defaults = { + includeLevel: [2, 3], + containerClass: 'table-of-contents', + slugify, + markerPattern: /^\[\[toc\]\]/im, + listType: 'ul', + format: parseHeaders, + forceFullToc: false, + containerHeaderHtml: undefined, + containerFooterHtml: undefined, + transformLink: undefined +} + +module.exports = (md, options) => { + options = Object.assign({}, defaults, options) + var gStateTokens + + // Insert TOC rules after emphasis + md.inline.ruler.after('emphasis', 'toc', toc) + + function toc (state, silent) { + /** + * Reject if + * 1. in validation mode + * 2. token does not start with [ + * 3. it's not [[toc]] + */ + if (silent // validation mode + || state.src.charCodeAt(state.pos) !== 0x5B /* [ */ + || !options.markerPattern.test(state.src.substr(state.pos))) { + return false + } + + // Build content + state.push('toc_open', 'toc', 1) + state.push('toc_body', '', 0) + state.push('toc_close', 'toc', -1) + + // Update pos so the parser can continue + const newline = state.src.indexOf('\n', state.pos) + + state.pos = newline !== -1 + ? newline + : state.pos + state.posMax + 1 + + return true + } + + md.renderer.rules.toc_open = function () { + var tocOpenHtml = `

` + + if (options.containerHeaderHtml) { + tocOpenHtml += options.containerHeaderHtml + } + + return tocOpenHtml + } + + md.renderer.rules.toc_close = function () { + var tocFooterHtml = '' + + if (options.containerFooterHtml) { + tocFooterHtml = options.containerFooterHtml + } + + return tocFooterHtml + `

` + } + + md.renderer.rules.toc_body = function () { + if (options.forceFullToc) { + /* + + Renders full TOC even if the hierarchy of headers contains + a header greater than the first appearing header + + ## heading 2 + ### heading 3 + # heading 1 + + Result TOC: + - heading 2 + - heading 3 + - heading 1 + */ + let tocBody = '' + + for (let pos = 0; pos < gStateTokens.length;) { + const [nextPos, subBody] = renderChildrenTokens(pos, gStateTokens) + pos = nextPos + tocBody += subBody + } + + return tocBody + } else { + return renderChildrenTokens(0, gStateTokens)[1] + } + } + + function renderChildrenTokens (pos, tokens) { + const headings = [] + for (let i = pos, currentLevel; i < tokens.length; i++) { + const level = tokens[i].tag && parseInt(tokens[i].tag.substr(1, 1)) + if (tokens[i].type === 'heading_close' && options.includeLevel.indexOf(level) > -1 && tokens[i - 1].type === 'inline') { + // init currentLevel at first round + currentLevel = currentLevel || level + + if (level > currentLevel) { + const [nextPos, subHeadings] = renderChildrenTokens(i, tokens) + i = nextPos - 1 + // nest ul into parent li + const last = headings.pop() + headings.push(last.slice(0, last.length - 5)) + headings.push(subHeadings + '') + continue + } else if (level < currentLevel) { + return [i, `<${options.listType}>${headings.join('')}`] + } + + // get content from previous inline token + const content = tokens[i - 1].content + // instead of slugify the content directly, try to find id created by markdown-it-anchor first + let link = '#' + (tokens[i - 2].attrGet('id') || options.slugify(content)) + link = typeof options.transformLink === 'function' ? options.transformLink(link) : link + + let element = `

  • ` + element += typeof options.format === 'function' ? options.format(content) : content + element += `
  • ` + headings.push(element) + } + } + return [tokens.length, `<${options.listType}>${headings.join('')}`] + } + + // Catch all the tokens for iteration later + md.core.ruler.push('grab_state', state => { + gStateTokens = state.tokens.slice(0) + }) +} diff --git a/packages/@vuepress/markdown/package.json b/packages/@vuepress/markdown/package.json index d9ef64aefc..32ed63b9c6 100644 --- a/packages/@vuepress/markdown/package.json +++ b/packages/@vuepress/markdown/package.json @@ -27,7 +27,6 @@ "markdown-it-anchor": "^5.0.2", "markdown-it-chain": "^1.3.0", "markdown-it-emoji": "^1.4.0", - "markdown-it-table-of-contents": "^0.4.0", "prismjs": "^1.13.0" }, "devDependencies": { diff --git a/yarn.lock b/yarn.lock index ab0d6c343d..b8fcd5e1a2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8779,10 +8779,6 @@ markdown-it-emoji@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/markdown-it-emoji/-/markdown-it-emoji-1.4.0.tgz#9bee0e9a990a963ba96df6980c4fddb05dfb4dcc" -markdown-it-table-of-contents@^0.4.0: - version "0.4.4" - resolved "https://registry.yarnpkg.com/markdown-it-table-of-contents/-/markdown-it-table-of-contents-0.4.4.tgz#3dc7ce8b8fc17e5981c77cc398d1782319f37fbc" - markdown-it@^8.4.1: version "8.4.2" resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-8.4.2.tgz#386f98998dc15a37722aa7722084f4020bdd9b54"