Skip to content

markdown: <TOC/> component, fix #1275 [WIP] #1307

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 11 commits into from
Closed
4 changes: 4 additions & 0 deletions packages/@vuepress/core/lib/app/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import Content from './components/Content.js'
import ContentSlotsDistributor from './components/ContentSlotsDistributor'
import OutboundLink from './components/OutboundLink.vue'
import ClientOnly from './components/ClientOnly'
import TOC from '@vuepress/markdown/components/TOC.vue'

// suggest dev server restart on base change
if (module.hot) {
Expand Down Expand Up @@ -43,6 +44,9 @@ Vue.component('OutboundLink', OutboundLink)
// component for client-only content
Vue.component('ClientOnly', ClientOnly)

// table of contents
Vue.component('TOC', TOC)

// global helper for adding base path to absolute urls
Vue.prototype.$withBase = function (path) {
const base = this.$site.base
Expand Down
11 changes: 9 additions & 2 deletions packages/@vuepress/core/lib/prepare/Page.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,11 @@ module.exports = class Page {
enhancers = [],
preRender = {}
}) {
// relative path
let relPath

if (this._filePath) {
relPath = path.relative(this._context.sourceDir, this._filePath)
logger.developer(`static_route`, chalk.cyan(this.path))
this._content = await fs.readFile(this._filePath, 'utf-8')
} else if (this._content) {
Expand Down Expand Up @@ -118,7 +122,10 @@ module.exports = class Page {
}

if (excerpt) {
const { html } = markdown.render(excerpt)
const { html } = markdown.render(excerpt, {
frontmatter: this.frontmatter,
relPath
})
this.excerpt = html
}
} else if (this._filePath.endsWith('.vue')) {
Expand Down Expand Up @@ -231,7 +238,7 @@ module.exports = class Page {
/**
* Execute the page enhancers. A enhancer could do following things:
*
* 1. Modify page's frontmetter.
* 1. Modify page's frontmatter.
* 2. Add extra field to the page.
*
* @api private
Expand Down
9 changes: 8 additions & 1 deletion packages/@vuepress/markdown-loader/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,14 @@ module.exports = function (src) {

// the render method has been augmented to allow plugins to
// register data during render
const { html, data: { hoistedTags, links }, dataBlockString } = markdown.render(content)
const {
html,
data: { hoistedTags, links },
dataBlockString
} = markdown.render(content, {
frontmatter: frontmatter.data,
relPath: path.relative(sourceDir, file)
})

// check if relative links are valid
links && links.forEach(link => {
Expand Down
23 changes: 23 additions & 0 deletions packages/@vuepress/markdown/components/HeaderList.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<template>
<component :is="listType[0]">
<li v-for="(item, index) in items" :key="index">
<router-link :to="'#' + item.slug" v-text="item.title" />
<HeaderList v-if="item.children" :items="item.children" :list-type="innerListType" />
</li>
</component>
</template>

<script>

export default {
name: 'HeaderList',
props: ['items', 'listType'],

computed: {
innerListType () {
return this.listType.slice(Math.min(this.listType.length - 1, 1))
}
}
}

</script>
65 changes: 65 additions & 0 deletions packages/@vuepress/markdown/components/TOC.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<template>
<div>
<p class="header" v-html="containerHeaderHtml" />
<HeaderList :items="groupedHeaders" :list-type="listTypes" />
<p class="footer" v-html="containerFooterHtml" />
</div>
</template>

<script>

import HeaderList from './HeaderList.vue'

export default {
props: {
listType: {
type: [String, Array],
default: 'ul'
},
includeLevel: {
type: Array,
default: () => [2, 3]
},
containerHeaderHtml: String,
containerFooterHtml: String
},

components: { HeaderList },

computed: {
listTypes () {
return typeof this.listType === 'string' ? [this.listType] : this.listType
},
groupedHeaders () {
return this.groupHeaders(this.$page.headers).list
}
},

methods: {
groupHeaders (headers, startLevel = 1) {
const list = []
let index = 0
while (index < headers.length) {
const header = headers[index]
if (header.level < startLevel) break
if (header.level > startLevel) {
const result = this.groupHeaders(headers.slice(index), header.level)
if (list.length) {
list[list.length - 1].children = result.list
} else {
list.push(...result.list)
}
index += result.index
} else {
if (header.level <= this.includeLevel[1] && header.level >= this.includeLevel[0]) {
list.push({ ...header })
}
index += 1
}
}
return { list, index }
}
}
}

</script>
10 changes: 3 additions & 7 deletions packages/@vuepress/markdown/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ const markdownSlotsContainersPlugin = require('./lib/markdownSlotsContainers')
const snippetPlugin = require('./lib/snippet')
const emojiPlugin = require('markdown-it-emoji')
const anchorPlugin = require('markdown-it-anchor')
const tocPlugin = require('markdown-it-table-of-contents')
const { parseHeaders, slugify: _slugify, logger, chalk } = require('@vuepress/shared-utils')
const tocPlugin = require('./lib/tableOfContents')
const { slugify: _slugify, logger, chalk } = require('@vuepress/shared-utils')

/**
* Create markdown by config.
Expand Down Expand Up @@ -96,11 +96,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) {
Expand Down
19 changes: 12 additions & 7 deletions packages/@vuepress/markdown/lib/link.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@
// 1. adding target="_blank" to external links
// 2. converting internal links to <router-link>

const url = require('url')

const indexRE = /(^|.*\/)(index|readme).md(#?.*)$/i

module.exports = (md, externalAttrs) => {
let hasOpenRouterLink = false
let hasOpenExternalLink = false

md.renderer.rules.link_open = (tokens, idx, options, env, self) => {
const { relPath } = env
const token = tokens[idx]
const hrefIndex = token.attrIndex('href')
if (hrefIndex >= 0) {
Expand All @@ -25,20 +28,27 @@ module.exports = (md, externalAttrs) => {
}
} else if (isSourceLink) {
hasOpenRouterLink = true
tokens[idx] = toRouterLink(token, link)
tokens[idx] = toRouterLink(token, link, relPath)
}
}
return self.renderToken(tokens, idx, options)
}

function toRouterLink (token, link) {
function toRouterLink (token, link, relPath) {
link[0] = 'to'
let to = link[1]

// convert link to filename and export it for existence check
const links = md.$data.links || (md.$data.links = [])
links.push(to)

// relative path usage.
if (!to.startsWith('/')) {
to = relPath
? url.resolve('/' + relPath, to)
: ensureBeginningDotSlash(to)
}

const indexMatch = to.match(indexRE)
if (indexMatch) {
const [, path, , hash] = indexMatch
Expand All @@ -49,11 +59,6 @@ module.exports = (md, externalAttrs) => {
.replace(/\.md(#.*)$/, '.html$1')
}

// relative path usage.
if (!to.startsWith('/')) {
to = ensureBeginningDotSlash(to)
}

// markdown-it encodes the uri
link[1] = decodeURI(to)

Expand Down
80 changes: 80 additions & 0 deletions packages/@vuepress/markdown/lib/tableOfContents.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// reference: https://github.com/Oktavilla/markdown-it-table-of-contents

const defaults = {
includeLevel: [2, 3],
containerClass: 'table-of-contents',
markerPattern: /^\[\[toc\]\]/im,
listType: 'ul',
containerHeaderHtml: '',
containerFooterHtml: ''
}

module.exports = (md, options) => {
options = Object.assign({}, defaults, options)
const tocRegexp = options.markerPattern

function toc (state, silent) {
var token
var match

// Reject if the token does not start with [
if (state.src.charCodeAt(state.pos) !== 0x5B /* [ */) {
return false
}
// Don't run any pairs in validation mode
if (silent) {
return false
}

// Detect TOC markdown
match = tocRegexp.exec(state.src)
match = !match ? [] : match.filter(function (m) { return m })
if (match.length < 1) {
return false
}

// Build content
token = state.push('toc_open', 'toc', 1)
token.markup = '[[toc]]'
token = state.push('toc_body', '', 0)
token = state.push('toc_close', 'toc', -1)

// Update pos so the parser can continue
var newline = state.src.indexOf('\n')
if (newline !== -1) {
state.pos = state.pos + newline
} else {
state.pos = state.pos + state.posMax + 1
}

return true
}

md.renderer.rules.toc_open = function () {
return vBindEscape`<TOC
:class=${options.containerClass}
:list-type=${options.listType}
:include-level=${options.includeLevel}
:container-header-html=${options.containerHeaderHtml}
:container-footer-html=${options.containerFooterHtml}
>`
}

md.renderer.rules.toc_close = function () {
return `</TOC>`
}

// Insert TOC
md.inline.ruler.after('emphasis', 'toc', toc)
}

/** escape double quotes in v-bind derivatives */
function vBindEscape (strs, ...args) {
return strs.reduce((prev, curr, index) => {
return prev + curr + (index >= args.length
? ''
: `"${JSON.stringify(args[index])
.replace(/"/g, "'")
.replace(/([^\\])(\\\\)*\\'/g, (_, char) => char + '\\u0022')}"`)
}, '')
}
1 change: 0 additions & 1 deletion packages/@vuepress/markdown/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
"markdown-it-chain": "^1.3.0",
"markdown-it-container": "^2.0.0",
"markdown-it-emoji": "^1.4.0",
"markdown-it-table-of-contents": "^0.4.0",
"prismjs": "^1.13.0"
},
"author": "Evan You",
Expand Down
7 changes: 6 additions & 1 deletion packages/docs/docs/config/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,12 @@ The key and value pair will be added to `<a>` tags that points to an external li
- Type: `Object`
- Default: `{ includeLevel: [2, 3] }`

Options for [markdown-it-table-of-contents](https://github.com/Oktavilla/markdown-it-table-of-contents). (Note: prefer `markdown.slugify` if you want to customize header ids.)
This attribute will control the behaviour of `[[TOC]]`. We design this interface as close as possible to the options of [markdown-it-table-of-contents](https://github.com/Oktavilla/markdown-it-table-of-contents). However, there are some little differences:

1. `slugify`, `format` and `forceFullToc` are not supported at present.
2. In addition to supporting `String` type, `listType` also supports passing in an array representing the list type for each level. For example, when `listType` is set to `['ol', 'ul']`, a three-level list will show the structure of `<ol><li><ul><li><ul><li>`.

We also provide a global component `<TOC>` which allows for more free control by passing props directly to `<TOC>`.

### markdown.extendMarkdown

Expand Down
10 changes: 8 additions & 2 deletions packages/docs/docs/guide/markdown.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,15 +108,21 @@ A list of all emojis available can be found [here](https://github.com/markdown-i

**Input**

```
```md
[[toc]]
```

or

```md
<TOC/>
```

**Output**

[[toc]]

Rendering of TOC can be configured using the [`markdown.toc`](../config/README.md#markdown-toc) option.
Rendering of TOC can be configured using the [`markdown.toc`](../config/README.md#markdown-toc) option, or as props of TOC component, like `<TOC list-type="ol" :include-level="[2, Infinity]"/>`.

## Custom Containers

Expand Down
7 changes: 6 additions & 1 deletion packages/docs/docs/zh/config/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,12 @@ VuePress 提供了一种添加额外样式的简便方法。你可以创建一
- 类型: `Object`
- 默认值: `{ includeLevel: [2, 3] }`

[markdown-it-table-of-contents](https://github.com/Oktavilla/markdown-it-table-of-contents) 的选项。
这个值将会控制 `[[TOC]]` 默认行为。我们将其设计成了尽可能与 [markdown-it-table-of-contents](https://github.com/Oktavilla/markdown-it-table-of-contents) 兼容的选项。不过有一些微小的区别:

1. 暂不支持 `slugify`,`format` 和 `forceFullToc`。
2. `listType` 除了支持 `String` 类型外,还支持传入一个数组,表示每一层级的列表类型。比如当设置 `listType` 为 `['ol', 'ul']` 时,一个三级的列表将呈现出 `<ol><li><ul><li><ul><li>` 的结构。

此外,我们还提供了全局组件 `<TOC>`,可以通过直接向 `<TOC>` 传递属性实现更加自由的控制。

### markdown.extendMarkdown

Expand Down
Loading