/* * SPDX-FileCopyrightText: Original: (c) 2018 Fabio Zendhi Nagao / Modifications: (c) 2023 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: MIT */ import { Optional } from '@mrdrogdrog/optional' import { encode as htmlencode } from 'html-entities' import MarkdownIt from 'markdown-it' import StateBlock from 'markdown-it/lib/rules_block/state_block.js' import Token from 'markdown-it/lib/token.js' import { TocAst } from './toc-ast.js' import { renderAstToHtml } from './toc-body-renderer.js' import { defaultOptions, TocOptions } from './toc-options.js' class Plugin { private readonly tocOptions: TocOptions private currentAst?: TocAst public readonly START_LEVEL_ATTRIBUTE_NAME = 'startLevel' public readonly END_LEVEL_ATTRIBUTE_NAME = 'endLevel' private readonly TOC_PLACEHOLDER = /^(?:\[\[toc(?::(\d+):(\d+))?]]|\[toc(?::(\d+):(\d+))?])$/i public constructor(md: MarkdownIt, tocOptions?: Partial) { this.tocOptions = { ...defaultOptions, ...tocOptions } md.renderer.rules.tocOpen = this.renderTocOpen.bind(this) md.renderer.rules.tocClose = this.renderTocClose.bind(this) md.renderer.rules.tocBody = this.renderTocBody.bind(this) md.core.ruler.push('generateTocAst', (state) => this.generateTocAst(state.tokens)) md.block.ruler.before('heading', 'toc', this.generateTocToken.bind(this), { alt: ['paragraph', 'reference', 'blockquote'] }) } private generateTocToken(state: StateBlock, startLine: number, _endLine: number, silent: boolean): boolean { const pos = state.bMarks[startLine] + state.tShift[startLine] const max = state.eMarks[startLine] // use whitespace as a line tokenizer and extract the first token // to test against the placeholder anchored pattern, rejecting if false const lineFirstToken = state.src.slice(pos, max).split(' ')[0] const matches = this.TOC_PLACEHOLDER.exec(lineFirstToken) if (matches === null) { return false } if (silent) { return true } state.line = startLine + 1 const tocOpenToken = state.push('tocOpen', 'nav', 1) tocOpenToken.markup = '' tocOpenToken.map = [startLine, state.line] const tocBodyToken = state.push('tocBody', '', 0) tocBodyToken.markup = '' tocBodyToken.map = [startLine, state.line] tocBodyToken.children = [] const startLevel = matches[3] const endLevel = matches[4] if (startLevel !== undefined && endLevel !== undefined) { tocBodyToken.attrSet(this.START_LEVEL_ATTRIBUTE_NAME, startLevel) tocBodyToken.attrSet(this.END_LEVEL_ATTRIBUTE_NAME, endLevel) } const tocCloseToken = state.push('tocClose', 'nav', -1) tocCloseToken.markup = '' return true } private generateTocAst(tokens: Token[]) { this.currentAst = this.headings2ast(tokens) this.tocOptions.callback?.(this.currentAst) } private renderTocOpen(): string { const id = this.tocOptions.containerId ? ` id="${htmlencode(this.tocOptions.containerId)}"` : '' return `` } private renderTocClose(): string { return '' } private createNumberRangeArray(from: number, to: number): number[] { return Array.from(Array(to - from + 1).keys()).map((value) => value + from) } private renderTocBody(tokens: Token[], index: number): string { const bodyToken = tokens[index] const startLevel = Optional.ofNullable(bodyToken?.attrGet(this.START_LEVEL_ATTRIBUTE_NAME)) .map(parseInt) .filter(isFinite) .orElse(null) const endLevel = Optional.ofNullable(bodyToken?.attrGet(this.END_LEVEL_ATTRIBUTE_NAME)) .map(parseInt) .filter(isFinite) .orElse(null) const modifiedTocOptions = startLevel !== null && endLevel !== null && startLevel <= endLevel ? { ...this.tocOptions, level: this.createNumberRangeArray(startLevel, endLevel) } : this.tocOptions return this.currentAst ? renderAstToHtml(this.currentAst, modifiedTocOptions) : '' } private headings2ast(tokens: Token[]): TocAst { const ast: TocAst = { level: 0, name: '', children: [] } const stack = [ast] for (let tokenIndex = 0; tokenIndex < tokens.length; tokenIndex++) { const token = tokens[tokenIndex] if (token.type !== 'heading_open') { continue } const nextToken = tokens[tokenIndex + 1] const key = (nextToken?.children ?? []) .filter((token) => this.tocOptions.allowedTokenTypes.includes(token.type)) .reduce((s, t) => s + t.content, '') const node: TocAst = { level: parseInt(token.tag.slice(1), 10), name: key, children: [] } if (node.level > stack[0].level) { stack[0].children.push(node) stack.unshift(node) } else if (node.level === stack[0].level) { stack[1].children.push(node) stack[0] = node } else { while (node.level <= stack[0].level) stack.shift() stack[0].children.push(node) stack.unshift(node) } } return ast } } /** * Creates a new TOC plugin. * * @param md The markdown-it instance that should be configured * @param options The additional options that configure the plugin */ export const toc: MarkdownIt.PluginWithOptions> = (md, options) => new Plugin(md, options)