From 101090a3cf9dd888545f13f14c342971865ef7a0 Mon Sep 17 00:00:00 2001 From: Jonas Date: Sat, 6 Jun 2026 23:13:46 +0200 Subject: [PATCH] feat: preserve Markdown reference links Fixes: #5820 Adds new arguments to the ProseMirror link mark to persist information that the link was in reference syntax in Markdown. Augments the prosemirror-markdown Markdown serializer to carry references that get rendered in the serialized Markdown after the document content. Signed-off-by: Jonas --- src/declarations.d.ts | 7 ++ src/extensions/Markdown.js | 20 +++- src/markdownit/index.js | 2 + src/markdownit/referenceLinks.ts | 102 ++++++++++++++++++ src/marks/Link.ts | 66 ++++++++++-- src/tests/markdown.spec.js | 31 ++++-- src/tests/markdownit/commonmark.spec.js | 17 ++- src/tests/markdownit/referenceLinks.spec.ts | 48 +++++++++ .../marks/__snapshots__/Link.spec.js.snap | 10 ++ 9 files changed, 288 insertions(+), 15 deletions(-) create mode 100644 src/markdownit/referenceLinks.ts create mode 100644 src/tests/markdownit/referenceLinks.spec.ts diff --git a/src/declarations.d.ts b/src/declarations.d.ts index 8b262647e4b..53a675d8542 100644 --- a/src/declarations.d.ts +++ b/src/declarations.d.ts @@ -12,3 +12,10 @@ declare module '@nextcloud/vue/composables/useIsMobile' { import { Ref } from 'vue' export function useIsMobile(): Ref } + +// We use `StateInline` in our custom referenceLinks markdown-it plugin +declare module 'markdown-it/lib/rules_inline/link.mjs' { + import type StateInline from 'markdown-it/lib/rules_inline/state_inline.mjs' + const rule: (state: StateInline, silent: boolean) => boolean + export default rule +} diff --git a/src/extensions/Markdown.js b/src/extensions/Markdown.js index a44e9b19444..2a80b3858cc 100644 --- a/src/extensions/Markdown.js +++ b/src/extensions/Markdown.js @@ -128,10 +128,28 @@ const createMarkdownSerializer = ({ nodes, marks }) => { extractMarksToMarkdown(marks), ), serialize(content, options) { - return this.serializer.serialize(content, { + // Extend serialize options to carry reference definitions (populated by `toMarkdown` callbacks in link mark) + const referenceDefinitions = new Map() + const body = this.serializer.serialize(content, { ...options, tightLists: true, + referenceDefinitions, }) + + if (referenceDefinitions.size === 0) { + return body + } + + // Render references at the end of the document + const referenceLines = [...referenceDefinitions.values()].map( + ({ label, href, title }) => { + const titlePart = title ? ` "${title.replace(/"/g, '\\"')}"` : '' + return `[${label}]: ${href}${titlePart}` + }, + ) + return ( + body.replace(/\s*$/, '') + '\n\n' + referenceLines.join('\n') + '\n' + ) }, } } diff --git a/src/markdownit/index.js b/src/markdownit/index.js index d2ca8c7c809..8cbb9a9df9d 100644 --- a/src/markdownit/index.js +++ b/src/markdownit/index.js @@ -16,6 +16,7 @@ import hardbreak from './hardbreak.js' import keepSyntax from './keepSyntax.js' import mathematics from './mathematics.ts' import preview from './preview.js' +import referenceLinks from './referenceLinks.ts' import splitMixedLists from './splitMixedLists.js' import taskLists from './taskLists.ts' import underline from './underline.js' @@ -35,6 +36,7 @@ const markdownit = MarkdownIt('commonmark', { html: false, breaks: false }) .use(keepSyntax) .use(markdownitMentions) .use(wikiLinks) + .use(referenceLinks) .use(implicitFigures) .use(mark) .use(mathematics) diff --git a/src/markdownit/referenceLinks.ts b/src/markdownit/referenceLinks.ts new file mode 100644 index 00000000000..0607c6c8742 --- /dev/null +++ b/src/markdownit/referenceLinks.ts @@ -0,0 +1,102 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type MarkdownIt from 'markdown-it' +import type StateInline from 'markdown-it/lib/rules_inline/state_inline.mjs' + +import linkRule from 'markdown-it/lib/rules_inline/link.mjs' + +type RefType = 'full' | 'collapsed' | 'shortcut' + +/** + * Decide which reference form (if any) the wrapped link rule matched, + * by inspecting the source range it consumed. + * + * @param state - markdown-it inline parser state + * @param startPos - position the rule started at + * @param endPos - position the rule ended at + */ +function detectReferenceForm( + state: StateInline, + startPos: number, + endPos: number, +): { label: string; type: RefType } | undefined { + const savedPos = state.pos + const labelEnd = state.md.helpers.parseLinkLabel(state, startPos) + state.pos = savedPos + if (labelEnd < 0) { + return undefined + } + + const labelStart = startPos + 1 + const displayLabel = state.src.slice(labelStart, labelEnd) + const after = labelEnd + 1 + + if (after >= endPos) { + return { label: displayLabel, type: 'shortcut' } + } + + const code = state.src.charCodeAt(after) + + if (code === 0x28 /* ( */) { + // inline form + return undefined + } + + if (code !== 0x5b /* [ */) { + return { label: displayLabel, type: 'shortcut' } + } + + if (endPos === after + 2) { + return { label: displayLabel, type: 'collapsed' } + } + + return { label: state.src.slice(after + 1, endPos - 1), type: 'full' } +} + +/** + * Wrap an existing inline rule so that, when it succeeds via the reference + * branch, the freshly pushed token is tagged with reference metadata. + * + * @param original - upstream inline link rule + */ +function wrap(original: (state: StateInline, silent: boolean) => boolean) { + return (state: StateInline, silent: boolean): boolean => { + const start = state.pos + if (!original(state, silent)) { + return false + } + if (silent) { + return true + } + + const info = detectReferenceForm(state, start, state.pos) + if (!info) { + return true + } + + const targetType = 'link_open' + const target = state.tokens.findLast((t) => t.type === targetType) + if (target) { + target.attrSet('data-md-reference-label', info.label) + target.attrSet('data-md-reference-type', info.type) + } + + return true + } +} + +/** + * markdown-it plugin: preserve reference-style link syntax across + * the parse/serialize round-trip. + * + * Adds `data-md-reference-label` / `data-md-reference-type` attributes to + * `link_open` tokens emitted via the reference branch. + * + * @param md - markdown-it instance to extend + */ +export default function referenceLinks(md: MarkdownIt): void { + md.inline.ruler.at('link', wrap(linkRule)) +} diff --git a/src/marks/Link.ts b/src/marks/Link.ts index cf81f596c8b..bc255ae341e 100644 --- a/src/marks/Link.ts +++ b/src/marks/Link.ts @@ -8,6 +8,7 @@ import { getMarkRange, isMarkActive, markInputRule } from '@tiptap/core' import type { LinkOptions } from '@tiptap/extension-link' import TipTapLink, { isAllowedUri } from '@tiptap/extension-link' import type { Mark, Node } from '@tiptap/pm/model' +import { normalizeReference } from 'markdown-it/lib/common/utils.mjs' import type { MarkdownSerializerState } from 'prosemirror-markdown' import { defaultMarkdownSerializer } from 'prosemirror-markdown' import { domHref, parseHref } from '../helpers/links.js' @@ -119,6 +120,24 @@ const Link = TipTapLink.extend({ renderHTML: (attrs) => attrs.isWikiLink ? { 'data-wiki-link': 'true' } : {}, }, + referenceLabel: { + default: null, + parseHTML: (element) => + element.getAttribute('data-md-reference-label'), + renderHTML: (attrs) => + attrs.referenceLabel + ? { 'data-md-reference-label': attrs.referenceLabel } + : {}, + }, + referenceType: { + default: null, + parseHTML: (element) => + element.getAttribute('data-md-reference-type'), + renderHTML: (attrs) => + attrs.referenceType + ? { 'data-md-reference-type': attrs.referenceType } + : {}, + }, } }, @@ -285,13 +304,48 @@ const Link = TipTapLink.extend({ _parent: Node, _index: number, ) { - if (!mark.attrs.isWikiLink) { - const close = defaultMarkdownSerializer.marks.link.close - return typeof close === 'function' - ? close(state, mark, _parent, _index) - : close + if (mark.attrs.isWikiLink) { + return ']]' + } + if (mark.attrs.referenceLabel && mark.attrs.referenceType) { + const label = mark.attrs.referenceLabel as string + const type = mark.attrs.referenceType as + | 'shortcut' + | 'collapsed' + | 'full' + const defs = + // Cast `state.options` locally as `referenceDefinitions` doesn't exist in upstream type definition + ( + state.options as { + referenceDefinitions?: Map< + string, + { label: string; href: string; title: string | null } + > + } + ).referenceDefinitions + if (defs) { + const key = normalizeReference(label) + if (!defs.has(key)) { + defs.set(key, { + label, + href: mark.attrs.href as string, + title: (mark.attrs.title as string) ?? null, + }) + } + } + switch (type) { + case 'shortcut': + return ']' + case 'collapsed': + return '][]' + case 'full': + return `][${label}]` + } } - return ']]' + const close = defaultMarkdownSerializer.marks.link.close + return typeof close === 'function' + ? close(state, mark, _parent, _index) + : close }, mixable: true, expelEnclosingWhitespace: false, diff --git a/src/tests/markdown.spec.js b/src/tests/markdown.spec.js index 39272850330..81beec1da33 100644 --- a/src/tests/markdown.spec.js +++ b/src/tests/markdown.spec.js @@ -73,9 +73,8 @@ describe('Markdown though editor', () => { expect(markdownThroughEditor('[test](foo)')).toBe('[test](foo)') expect(markdownThroughEditor('[test](foo "bar")')).toBe('[test](foo "bar")') // Issue #2703 - expect(markdownThroughEditor('[bar\\\\]: /uri\n\n[bar\\\\]')).toBe( - '[bar\\\\](/uri)', - ) + const test2703 = '[bar\\\\]\n\n[bar\\\\]: /uri\n' + expect(markdownThroughEditor(test2703)).toBe(test2703) // Issue #4900 expect(markdownThroughEditor('[`code`](foo)')).toBe('[`code`](foo)') expect(markdownThroughEditor('[text with `code` inside](foo)')).toBe( @@ -86,6 +85,28 @@ describe('Markdown though editor', () => { expect(markdownThroughEditor('text [[wikiLink]] more')).toBe( 'text [[wikiLink]] more', ) + // Reference-style links (issue #5820) + const referenceShortcutTest = + 'Test with [Case-Sensitive Reference] in it.\n\n[Case-Sensitive Reference]: https://example.org/\n' + expect(markdownThroughEditor(referenceShortcutTest)).toBe( + referenceShortcutTest, + ) + const referenceCollapsedTest = + 'Test with [label][] in it.\n\n[label]: https://example.org/\n' + expect(markdownThroughEditor(referenceCollapsedTest)).toBe( + referenceCollapsedTest, + ) + const referenceFullTest = + 'Test with [display text][label] in it.\n\n[label]: https://example.org/ "title"\n' + expect(markdownThroughEditor(referenceFullTest)).toBe(referenceFullTest) + // References moved to the end of the document + expect( + markdownThroughEditor( + 'Test with [reference] in it.\n\n[reference]: /url\n\nsome extra paragraph\n', + ), + ).toBe( + 'Test with [reference] in it.\n\nsome extra paragraph\n\n[reference]: /url\n', + ) }) test('images', () => { // Inline images @@ -123,10 +144,6 @@ describe('Markdown though editor', () => { expect(markdownThroughEditor('```\n```')).toBe('```\n```') }) test('markdown untouched', () => { - // Issue #2703 - expect(markdownThroughEditor('[bar\\\\]: /uri\n\n[bar\\\\]')).toBe( - '[bar\\\\](/uri)', - ) expect(markdownThroughEditor('## Test \\')).toBe('## Test \\') expect(markdownThroughEditor('- [ [asd](sdf)')).toBe('- [ [asd](sdf)') }) diff --git a/src/tests/markdownit/commonmark.spec.js b/src/tests/markdownit/commonmark.spec.js index c32d1278a89..ad85cc622e5 100644 --- a/src/tests/markdownit/commonmark.spec.js +++ b/src/tests/markdownit/commonmark.spec.js @@ -33,6 +33,13 @@ describe('Commonmark', () => { 585, 586, 588, 589, 591, ] + const referenceLinkTests = [ + 23, 33, 192, 193, 194, 195, 196, 198, 200, 201, 202, 203, 204, 205, 206, 214, + 215, 216, 217, 218, 514, 515, 516, 517, 518, 527, 528, 529, 530, 531, 532, + 533, 534, 535, 539, 540, 541, 542, 543, 544, 549, 550, 553, 554, 555, 556, + 557, 558, 559, 560, 561, 562, 564, 565, 566, 568, 569, 570, 571, 593, + ] + spec.forEach((entry) => { // We do not support HTML if (entry.section === 'HTML blocks' || entry.section === 'Raw HTML') return @@ -47,13 +54,21 @@ describe('Commonmark', () => { .replace(//g, '') .replace(/<\/strong>/g, '') : entry.html + if (figureImageMarkdownTests.indexOf(entry.example) !== -1) { expected = expected .replace(/

/g, '

') .replace(/<\/p>/g, '
') } - const rendered = markdownit.render(entry.markdown) + let rendered = markdownit.render(entry.markdown) + + if (referenceLinkTests.indexOf(entry.example) !== -1) { + rendered = rendered.replace( + / data-md-reference-label="[^"]+" data-md-reference-type="[a-z]+"/g, + '', + ) + } // Ignore special markup for untouched markdown expect(normalize(rendered)).toBe(expected) diff --git a/src/tests/markdownit/referenceLinks.spec.ts b/src/tests/markdownit/referenceLinks.spec.ts new file mode 100644 index 00000000000..185e6191f3d --- /dev/null +++ b/src/tests/markdownit/referenceLinks.spec.ts @@ -0,0 +1,48 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import markdownit from '../../markdownit/index.js' + +describe('reference style links (markdown-it)', () => { + it('renders a reference link of type shortcut (omitted label)', () => { + expect( + markdownit.render( + 'text with [Some Reference] in it\n\n[Some Reference]: https://example.org', + ), + ).to.equal( + '

text with Some Reference in it

\n', + ) + }) + + it('renders a reference link of type collapsed (empty label)', () => { + expect( + markdownit.render( + 'text with [Some Reference][] in it\n\n[Some Reference]: https://example.org', + ), + ).to.equal( + '

text with Some Reference in it

\n', + ) + }) + + it('renders a reference link of type full (separate label)', () => { + expect( + markdownit.render( + 'text with [Some Reference][ref] in it\n\n[ref]: https://example.org', + ), + ).to.equal( + '

text with Some Reference in it

\n', + ) + }) + + it('renders a reference link with paragraphs after reference', () => { + expect( + markdownit.render( + 'text with [reference] in it\n\n[reference]: /url\n\nsome extra content.', + ), + ).to.equal( + '

text with reference in it

\n

some extra content.

\n', + ) + }) +}) diff --git a/src/tests/marks/__snapshots__/Link.spec.js.snap b/src/tests/marks/__snapshots__/Link.spec.js.snap index d7eeca85c01..7648eb81672 100644 --- a/src/tests/marks/__snapshots__/Link.spec.js.snap +++ b/src/tests/marks/__snapshots__/Link.spec.js.snap @@ -11,6 +11,8 @@ exports[`Link extension integrated in the editor > Should only update link the a "attrs": { "href": "updated.de", "isWikiLink": false, + "referenceLabel": null, + "referenceType": null, "title": null, }, "type": "link", @@ -25,6 +27,8 @@ exports[`Link extension integrated in the editor > Should only update link the a "attrs": { "href": "not-example.org", "isWikiLink": false, + "referenceLabel": null, + "referenceType": null, "title": null, }, "type": "link", @@ -52,6 +56,8 @@ exports[`Link extension integrated in the editor > should insert new link if non "attrs": { "href": "example.org", "isWikiLink": false, + "referenceLabel": null, + "referenceType": null, "title": null, }, "type": "link", @@ -75,6 +81,8 @@ exports[`Link extension integrated in the editor > should insert new link if non "attrs": { "href": "https://example.org", "isWikiLink": false, + "referenceLabel": null, + "referenceType": null, "title": null, }, "type": "link", @@ -111,6 +119,8 @@ exports[`Link extension integrated in the editor > should update link if anchor "attrs": { "href": "updated.de", "isWikiLink": false, + "referenceLabel": null, + "referenceType": null, "title": null, }, "type": "link",