diff --git a/src/nodes/footnote-definition.ts b/src/nodes/footnote-definition.ts new file mode 100644 index 0000000..582ca15 --- /dev/null +++ b/src/nodes/footnote-definition.ts @@ -0,0 +1,17 @@ +import { NodeKind } from './node-kind' +import { Node } from './node' + +export class FootnoteDefinition implements Node { + readonly children: Node[] = [] + readonly label: string + readonly kind = NodeKind.FootnoteDefinition + + constructor (label: string, children: Node[]) { + this.label = label + this.children = children + } + + toMarkdown () { + return `[^${this.label}]:${this.children.map(x => x.toMarkdown()).join('')}` + } +} diff --git a/src/nodes/footnote-reference.ts b/src/nodes/footnote-reference.ts new file mode 100644 index 0000000..a97390f --- /dev/null +++ b/src/nodes/footnote-reference.ts @@ -0,0 +1,16 @@ +import { NodeKind } from './node-kind' +import { Node } from './node' + +export class FootnoteReference implements Node { + readonly children: Node[] = [] + readonly label: string + readonly kind = NodeKind.FootnoteReference + + constructor (label: string) { + this.label = label + } + + toMarkdown () { + return `[^${this.label}]` + } +} diff --git a/src/nodes/node-kind.ts b/src/nodes/node-kind.ts index 467a580..17accfb 100644 --- a/src/nodes/node-kind.ts +++ b/src/nodes/node-kind.ts @@ -26,6 +26,8 @@ export const enum NodeKind { CalloutItem = 1 << 23, Highlight = 1 << 24, CJK = 1 << 25, + FootnoteReference = 1 << 26, + FootnoteDefinition = 1 << 27, // combinations All = -1, @@ -36,6 +38,6 @@ export const enum NodeKind { Link = InlineLink | ReferenceLink, Image = InlineImage | ReferenceImage, Style = Strong | Emphasis | Strikethrough | Highlight, - Inline = NaturualConstructs | InlineCode | Link | Style | HTMLTag | InlineImage | ReferenceImage | Math, + Inline = NaturualConstructs | InlineCode | Link | Style | HTMLTag | InlineImage | ReferenceImage | Math | FootnoteReference, Block = ~Inline, } diff --git a/src/parser/parse.ts b/src/parser/parse.ts index 63da735..9b70d4a 100644 --- a/src/parser/parse.ts +++ b/src/parser/parse.ts @@ -17,6 +17,8 @@ import { HTMLTag } from '../nodes/html-tag' import { Punctuation } from '../nodes/punctuation' import { ReferenceLink } from '../nodes/reference-link' import { ReferenceDefinition } from '../nodes/reference-definition' +import { FootnoteReference } from '../nodes/footnote-reference' +import { FootnoteDefinition } from '../nodes/footnote-definition' import { InlineLink } from '../nodes/inline-link' import { Node } from '../nodes/node' import { InlineCode, InlineCodeDelimiter } from '../nodes/inline-code' @@ -58,6 +60,7 @@ export function parse (str: string, options: NormalizedPadMarkdownOptions): Docu let listPrefix = '' let codeLang = '' let calloutType = '' + let footnoteLabel = '' const blockquotePrefix: BlockquotePrefix = '>' let strongDelimiter: StrongDelimiter = '**' let emphasisDelimiter: EmphasisDelimiter = '*' @@ -178,6 +181,21 @@ export function parse (str: string, options: NormalizedPadMarkdownOptions): Docu resolve(new Blank(c)) i++ } + else if (state === State.FootnoteLabel && c === ']') { + if (c2 === ']:') { + footnoteLabel = popMarkdown() + i += 2 + push(State.FootnoteDefinition) + } else { + resolve(new FootnoteReference(popMarkdown())) + i++ + } + } + else if (state === State.FootnoteDefinition && c === '\n') { + resolve(new FootnoteDefinition(footnoteLabel, popNodes())) + resolve(new Blank(c)) + i++ + } else if (state === State.LinkText && c === ']') { linkText = popNodes() if (c2 === ']:') { @@ -247,6 +265,10 @@ export function parse (str: string, options: NormalizedPadMarkdownOptions): Docu i += 2 } // state === State.Text + else if (c2 === '[^' && allow(NodeKind.FootnoteReference)) { + push(State.FootnoteLabel) + i += 2 + } else if (c === '[' && allow(NodeKind.Link)) { push(State.LinkText) i++ @@ -423,6 +445,9 @@ export function parse (str: string, options: NormalizedPadMarkdownOptions): Docu case State.LinkText: resolve(Punctuation.create('['), ...popNodes()) break + case State.FootnoteLabel: + resolve(Punctuation.create('['), Punctuation.create('^'), ...popNodes()) + break case State.ReferingUrl: resolve(new SquareQuoted(linkText), Punctuation.create('('), ...popNodes()) break @@ -460,6 +485,9 @@ export function parse (str: string, options: NormalizedPadMarkdownOptions): Docu case State.ReferenceLinkUrl: resolve(new ReferenceDefinition(linkText, popMarkdown())) break + case State.FootnoteDefinition: + resolve(new FootnoteDefinition(footnoteLabel, popNodes())) + break case State.OrderedListItem: resolve(new OrderedListItem(listPrefix, popNodes())) break diff --git a/src/parser/state-masks.ts b/src/parser/state-masks.ts index 36d4576..e951b75 100644 --- a/src/parser/state-masks.ts +++ b/src/parser/state-masks.ts @@ -13,6 +13,8 @@ export const stateMasks = { [State.ImageText]: ~NodeKind.Inline | NodeKind.Image, [State.ImageReferingUrl]: ~NodeKind.Text, [State.ReferenceLinkUrl]: ~NodeKind.Text, + [State.FootnoteLabel]: ~NodeKind.Text, + [State.FootnoteDefinition]: NodeKind.Block, [State.HTMLTag]: ~NodeKind.Text, [State.ReferingID]: ~NodeKind.Text, [State.BlockCodeLang]: ~NodeKind.Text, diff --git a/src/parser/state.ts b/src/parser/state.ts index 9d13c7e..4d37993 100644 --- a/src/parser/state.ts +++ b/src/parser/state.ts @@ -4,6 +4,8 @@ export enum State { ReferingUrl, // [xx]( ReferingID, // [xx][ ReferenceLinkUrl, // [xx]: + FootnoteLabel, // [^ + FootnoteDefinition, // [^xx]: ImageText, // ![ ImageReferingUrl, // ![xx]( ImageReferingID, // ![xx][ diff --git a/test/parser/parse.spec.ts b/test/parser/parse.spec.ts index bf8f786..6820893 100644 --- a/test/parser/parse.spec.ts +++ b/test/parser/parse.spec.ts @@ -199,6 +199,108 @@ describe('parse()', () => { }) }) + describe('FootnoteReference/FootnoteDefinition', () => { + it('should parse footnote reference', () => { + const doc = parse('text[^1]', options) + expect(doc.children).toHaveLength(2) + + const [text, ref] = doc.children + expect(text).toMatchObject({ + kind: NodeKind.AlphabetNumeric, + text: 'text' + }) + expect(ref).toMatchObject({ + kind: NodeKind.FootnoteReference, + label: '1' + }) + expect(ref.toMarkdown()).toEqual('[^1]') + }) + + it('should parse footnote reference with non-numeric label', () => { + const doc = parse('[^foo-bar]', options) + expect(doc.children).toHaveLength(1) + expect(doc.children[0]).toMatchObject({ + kind: NodeKind.FootnoteReference, + label: 'foo-bar' + }) + expect(doc.children[0].toMarkdown()).toEqual('[^foo-bar]') + }) + + it('should parse footnote definition and split its body into inline nodes', () => { + const doc = parse('[^1]: 脚注的具体解释内容123结束', options) + expect(doc.children).toHaveLength(1) + + const dfn = doc.children[0] + expect(dfn).toMatchObject({ + kind: NodeKind.FootnoteDefinition, + label: '1' + }) + expect(dfn.toMarkdown()).toEqual('[^1]: 脚注的具体解释内容123结束') + + const numberNode = dfn.children.find( + (n: any) => n.kind === NodeKind.AlphabetNumeric && n.text === '123' + ) + expect(numberNode).toBeDefined() + }) + + it('should parse multiple footnote definitions separated by newline', () => { + const doc = parse('[^1]: foo\n[^2]: bar', options) + expect(doc.children).toHaveLength(3) + + const [dfn1, blank, dfn2] = doc.children + expect(dfn1).toMatchObject({ + kind: NodeKind.FootnoteDefinition, + label: '1' + }) + expect(dfn1.toMarkdown()).toEqual('[^1]: foo') + + expect(blank).toMatchObject({ + kind: NodeKind.Blank, + char: '\n' + }) + + expect(dfn2).toMatchObject({ + kind: NodeKind.FootnoteDefinition, + label: '2' + }) + expect(dfn2.toMarkdown()).toEqual('[^2]: bar') + }) + + it('should close unterminated footnote definition at EOF (no trailing newline)', () => { + const doc = parse('[^1]: foo', options) + expect(doc.children).toHaveLength(1) + expect(doc.children[0]).toMatchObject({ + kind: NodeKind.FootnoteDefinition, + label: '1' + }) + expect(doc.children[0].toMarkdown()).toEqual('[^1]: foo') + }) + + it('should not break existing ReferenceLink/ReferenceDefinition parsing', () => { + const doc1 = parse('[foo][bar]', options) + expect(doc1.children[0]).toMatchObject({ + kind: NodeKind.ReferenceLink, + target: 'bar' + }) + + const doc2 = parse('[foo]: http://harttle.land', options) + expect(doc2.children[0]).toMatchObject({ + kind: NodeKind.ReferenceDefinition, + target: ' http://harttle.land' + }) + }) + + // Known limitation: kept consistent with ReferenceLinkUrl's existing lenient + // behavior — a "[^x]:" appearing mid-line (not at the start of a block) is + // still parsed as a footnote definition, swallowing the rest of the line. + it('should (leniently) treat "[^x]:" mid-line as a footnote definition, matching ReferenceLinkUrl behavior', () => { + const doc = parse('text [^1]: not really a definition', options) + const dfn = doc.children.find((n: any) => n.kind === NodeKind.FootnoteDefinition) + expect(dfn).toBeDefined() + expect((dfn as any).label).toEqual('1') + }) + }) + describe('HTMLTag', () => { it('should parse anchor tag', () => { const doc = parse('', options) diff --git a/test/transformers/pad-markdown.spec.ts b/test/transformers/pad-markdown.spec.ts index 9dd6ad0..f031c07 100644 --- a/test/transformers/pad-markdown.spec.ts +++ b/test/transformers/pad-markdown.spec.ts @@ -208,6 +208,27 @@ describe('padding()', () => { }) }) + describe('footnotes', () => { + it('should pad inside footnote definition body', () => { + expect(padMarkdown('[^1]: 说明内容123结束')) + .toEqual('[^1]: 说明内容 123 结束') + }) + it('should not pad the footnote label itself', () => { + expect(padMarkdown('text[^1]more text')) + .toEqual('text [^1] more text') + }) + it('should not pad non-numeric footnote labels', () => { + expect(padMarkdown('[^foo-bar]: 说明内容abc结束')) + .toEqual('[^foo-bar]: 说明内容 abc 结束') + expect(padMarkdown('[^id不应该pad]: 说明内容123结束')) + .toEqual('[^id不应该pad]: 说明内容 123 结束') + }) + it('should pad between footnote reference and surrounding CJK text', () => { + expect(padMarkdown('这是正文[^1]继续写')) + .toEqual('这是正文 [^1] 继续写') + }) + }) + describe('images', () => { it('should pad between footnote links', () => { expect(padMarkdown('refer to![foo][bar]please'))