From 463c61588e923467d094a1c48d22daea163f8d9e Mon Sep 17 00:00:00 2001 From: Shinyu Murakami Date: Thu, 12 Oct 2023 19:44:51 +0900 Subject: [PATCH] feat: Support non-sectionize headings, enclosed by equal number of hashes (#172) This add the following heading auto-sectioning rule: - Do not sectionize if the heading line starts with `#`s and ends with equal or greater number of `#`s. - `### Not Sectionize ###` (enclosed by equal number of `#`s) -- not sectionize - `### Sectionize ##` (insufficient number of closing `#`s) -- sectionize resolves #155 --- docs/ja/vfm.md | 3 +++ docs/vfm.md | 3 +++ src/index.ts | 2 +- src/plugins/section.ts | 28 +++++++++++++++++-- tests/section.test.ts | 20 ++++++++++++++ tests/utils.ts | 61 +++++++++++++++++++++++------------------- 6 files changed, 86 insertions(+), 31 deletions(-) diff --git a/docs/ja/vfm.md b/docs/ja/vfm.md index 6ed893a..e4f6583 100644 --- a/docs/ja/vfm.md +++ b/docs/ja/vfm.md @@ -564,6 +564,9 @@ ruby rt { 見出しを階層的なセクションにします。 +- 見出しの行が `#` ではじまり同数以上の `#` で終わる場合はセクションを分けません + - `### Not Sectionize ###` (同じ数の `#` で囲まれている) -- セクション分けしない + - `### Sectionize ##` (閉じの `#` の数が足りない) -- セクション分けする - 親が `blockquote` の場合はセクションを分けません - 見出しの深さへ一致するように、セクションの `levelN` クラスを設定します - 見出しの `id` 属性値をセクションの `aria-labelledby` 属性へ値をコピーします diff --git a/docs/vfm.md b/docs/vfm.md index 068017c..05eb44e 100644 --- a/docs/vfm.md +++ b/docs/vfm.md @@ -564,6 +564,9 @@ If want to escape the delimiter pipe `|`, add `\` immediately before it. Make the heading a hierarchical section. +- Do not sectionize if the heading line starts with `#`s and ends with equal or greater number of `#`s. + - `### Not Sectionize ###` (enclosed by equal number of `#`s) -- not sectionize + - `### Sectionize ##` (insufficient number of closing `#`s) -- sectionize - Do not sectionize if parent is `blockquote`. - Set the `levelN` class in the section to match the heading depth. - Copy the value of the `id` attribute of the heading to the `aria-labelledby` attribute of the section. diff --git a/src/index.ts b/src/index.ts index ac3608e..872aaf3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -114,7 +114,7 @@ export function VFM( const processor = unified() .use(markdown(hardLineBreaks, math)) - .data('settings', { position: false }) + .data('settings', { position: true }) .use(html); if (replace) { diff --git a/src/plugins/section.ts b/src/plugins/section.ts index e6bcbcc..1a41ed9 100644 --- a/src/plugins/section.ts +++ b/src/plugins/section.ts @@ -7,6 +7,7 @@ */ import { Parent } from 'mdast'; +import { VFile } from 'vfile'; import findAfter from 'unist-util-find-after'; import visit from 'unist-util-visit-parents'; @@ -31,6 +32,23 @@ const createProperties = (depth: number, node: any): KeyValue => { return properties; }; +/** + * Check if the heading has a non-section mark (sufficient number of closing hashes). + * @param node Node of Markdown AST. + * @param file Virtual file. + * @returns `true` if the node has a non-section mark. + */ +const hasNonSectionMark = (node: any, file: VFile): boolean => { + const startOffset = node.position?.start.offset ?? 0; + const endOffset = node.position?.end.offset ?? 0; + const text = file.toString().slice(startOffset, endOffset); + const depth = node.depth; + if ((/[ \t](#+)$/.exec(text)?.[1]?.length ?? 0) >= depth) { + return true; + } + return false; +}; + /** * Wrap the header in sections. * - Do not sectionize if parent is `blockquote`. @@ -39,7 +57,10 @@ const createProperties = (depth: number, node: any): KeyValue => { * @param ancestors Parents. * @todo handle `@subtitle` properly. */ -const sectionize = (node: any, ancestors: Parent[]) => { +const sectionizeIfRequired = (node: any, ancestors: Parent[], file: VFile) => { + if (hasNonSectionMark(node, file)) { + return; + } const parent = ancestors[ancestors.length - 1]; if (parent.type === 'blockquote') { return; @@ -96,7 +117,10 @@ const sectionize = (node: any, ancestors: Parent[]) => { * Process Markdown AST. * @returns Transformer. */ -export const mdast = () => (tree: any) => { +export const mdast = () => (tree: any, file: VFile) => { + const sectionize = (node: Node, ancestors: Parent[]) => { + sectionizeIfRequired(node, ancestors, file); + }; for (let depth = MAX_HEADING_DEPTH; depth > 0; depth--) { visit( tree, diff --git a/tests/section.test.ts b/tests/section.test.ts index 8fd108a..9a9ae99 100644 --- a/tests/section.test.ts +++ b/tests/section.test.ts @@ -40,6 +40,26 @@ it('Disable section with blockquote heading', () => { expect(received).toBe(expected); }); +it('Disable section with closing hashes', () => { + const md = '### Not Sectionize ###'; + const received = stringify(md, { partial: true }); + const expected = ` +

Not Sectionize

+`; + expect(received).toBe(expected); +}); + +it('Do not disable section with insufficient closing hashes', () => { + const md = '### Sectionize ##'; + const received = stringify(md, { partial: true }); + const expected = ` +
+

Sectionize

+
+`; + expect(received).toBe(expected); +}); + it(' is not heading', () => { const md = '####### こんにちは {.test}'; const received = stringify(md, { partial: true, disableFormatHtml: true }); diff --git a/tests/utils.ts b/tests/utils.ts index 4b7e349..d1fdfa9 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -8,31 +8,36 @@ import { StringifyMarkdownOptions, VFM } from '../src'; * @param expectedHtml Expected HTML string. * @param options Option for convert Markdown to VFM (HTML). */ -export const buildProcessorTestingCode = ( - input: string, - expectedMdast: string, - expectedHtml: string, - { - style = undefined, - partial = true, - title = undefined, - language = undefined, - replace = undefined, - hardLineBreaks = false, - disableFormatHtml = true, - math = false, - }: StringifyMarkdownOptions = {}, -) => (): any => { - const vfm = VFM({ - style, - partial, - title, - language, - replace, - hardLineBreaks, - disableFormatHtml, - math, - }).freeze(); - expect(unistInspect.noColor(vfm.parse(input))).toBe(expectedMdast.trim()); - expect(String(vfm.processSync(input))).toBe(expectedHtml); -}; +export const buildProcessorTestingCode = + ( + input: string, + expectedMdast: string, + expectedHtml: string, + { + style = undefined, + partial = true, + title = undefined, + language = undefined, + replace = undefined, + hardLineBreaks = false, + disableFormatHtml = true, + math = false, + }: StringifyMarkdownOptions = {}, + ) => + (): any => { + const vfm = VFM({ + style, + partial, + title, + language, + replace, + hardLineBreaks, + disableFormatHtml, + math, + }).freeze(); + const R = / \(.+?\)$/gm; // Remove position information + expect(unistInspect.noColor(vfm.parse(input)).replace(R, '')).toBe( + expectedMdast.trim(), + ); + expect(String(vfm.processSync(input))).toBe(expectedHtml); + };