Skip to content

Commit b6aaba1

Browse files
Merge pull request #8717 from nextcloud/feat/reference_links
preserve Markdown reference links
2 parents 2eb20f6 + f565108 commit b6aaba1

9 files changed

Lines changed: 290 additions & 15 deletions

File tree

src/declarations.d.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,10 @@ declare module '@nextcloud/vue/composables/useIsMobile' {
1212
import { Ref } from 'vue'
1313
export function useIsMobile(): Ref<boolean>
1414
}
15+
16+
// We use `StateInline` in our custom referenceLinks markdown-it plugin
17+
declare module 'markdown-it/lib/rules_inline/link.mjs' {
18+
import type StateInline from 'markdown-it/lib/rules_inline/state_inline.mjs'
19+
const rule: (state: StateInline, silent: boolean) => boolean
20+
export default rule
21+
}

src/extensions/Markdown.js

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,10 +128,28 @@ const createMarkdownSerializer = ({ nodes, marks }) => {
128128
extractMarksToMarkdown(marks),
129129
),
130130
serialize(content, options) {
131-
return this.serializer.serialize(content, {
131+
// Extend serialize options to carry reference definitions (populated by `toMarkdown` callbacks in link mark)
132+
const referenceDefinitions = new Map()
133+
const body = this.serializer.serialize(content, {
132134
...options,
133135
tightLists: true,
136+
referenceDefinitions,
134137
})
138+
139+
if (referenceDefinitions.size === 0) {
140+
return body
141+
}
142+
143+
// Render references at the end of the document
144+
const referenceLines = [...referenceDefinitions.values()].map(
145+
({ label, href, title }) => {
146+
const titlePart = title ? ` "${title.replace(/"/g, '\\"')}"` : ''
147+
return `[${label}]: ${href}${titlePart}`
148+
},
149+
)
150+
return (
151+
body.replace(/\n+$/, '') + '\n\n' + referenceLines.join('\n') + '\n'
152+
)
135153
},
136154
}
137155
}

src/markdownit/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import hardbreak from './hardbreak.js'
1616
import keepSyntax from './keepSyntax.js'
1717
import mathematics from './mathematics.ts'
1818
import preview from './preview.js'
19+
import referenceLinks from './referenceLinks.ts'
1920
import splitMixedLists from './splitMixedLists.js'
2021
import taskLists from './taskLists.ts'
2122
import underline from './underline.js'
@@ -35,6 +36,7 @@ const markdownit = MarkdownIt('commonmark', { html: false, breaks: false })
3536
.use(keepSyntax)
3637
.use(markdownitMentions)
3738
.use(wikiLinks)
39+
.use(referenceLinks)
3840
.use(implicitFigures)
3941
.use(mark)
4042
.use(mathematics)

src/markdownit/referenceLinks.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import type MarkdownIt from 'markdown-it'
7+
import type StateInline from 'markdown-it/lib/rules_inline/state_inline.mjs'
8+
9+
import linkRule from 'markdown-it/lib/rules_inline/link.mjs'
10+
11+
type RefType = 'full' | 'collapsed' | 'shortcut'
12+
13+
/**
14+
* Decide which reference form (if any) the wrapped link rule matched,
15+
* by inspecting the source range it consumed.
16+
*
17+
* @param state - markdown-it inline parser state
18+
* @param startPos - position the rule started at
19+
* @param endPos - position the rule ended at
20+
*/
21+
function detectReferenceForm(
22+
state: StateInline,
23+
startPos: number,
24+
endPos: number,
25+
): { label: string; type: RefType } | undefined {
26+
const savedPos = state.pos
27+
const labelEnd = state.md.helpers.parseLinkLabel(state, startPos)
28+
state.pos = savedPos
29+
if (labelEnd < 0) {
30+
return undefined
31+
}
32+
33+
const labelStart = startPos + 1
34+
const displayLabel = state.src.slice(labelStart, labelEnd)
35+
const after = labelEnd + 1
36+
37+
if (after >= endPos) {
38+
return { label: displayLabel, type: 'shortcut' }
39+
}
40+
41+
const code = state.src.charCodeAt(after)
42+
43+
if (code === 0x28 /* ( */) {
44+
// inline form
45+
return undefined
46+
}
47+
48+
if (code !== 0x5b /* [ */) {
49+
return { label: displayLabel, type: 'shortcut' }
50+
}
51+
52+
if (endPos === after + 2) {
53+
return { label: displayLabel, type: 'collapsed' }
54+
}
55+
56+
return { label: state.src.slice(after + 1, endPos - 1), type: 'full' }
57+
}
58+
59+
/**
60+
* Wrap an existing inline rule so that, when it succeeds via the reference
61+
* branch, the freshly pushed token is tagged with reference metadata.
62+
*
63+
* @param original - upstream inline link rule
64+
*/
65+
function wrap(original: (state: StateInline, silent: boolean) => boolean) {
66+
return (state: StateInline, silent: boolean): boolean => {
67+
const start = state.pos
68+
if (!original(state, silent)) {
69+
return false
70+
}
71+
if (silent) {
72+
return true
73+
}
74+
75+
const info = detectReferenceForm(state, start, state.pos)
76+
if (!info) {
77+
return true
78+
}
79+
80+
const targetType = 'link_open'
81+
const target = state.tokens.findLast((t) => t.type === targetType)
82+
if (target) {
83+
target.attrSet('data-md-reference-label', info.label)
84+
target.attrSet('data-md-reference-type', info.type)
85+
}
86+
87+
return true
88+
}
89+
}
90+
91+
/**
92+
* markdown-it plugin: preserve reference-style link syntax across
93+
* the parse/serialize round-trip.
94+
*
95+
* Adds `data-md-reference-label` / `data-md-reference-type` attributes to
96+
* `link_open` tokens emitted via the reference branch.
97+
*
98+
* @param md - markdown-it instance to extend
99+
*/
100+
export default function referenceLinks(md: MarkdownIt): void {
101+
md.inline.ruler.at('link', wrap(linkRule))
102+
}

src/marks/Link.ts

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { getMarkRange, isMarkActive, markInputRule } from '@tiptap/core'
88
import type { LinkOptions } from '@tiptap/extension-link'
99
import TipTapLink, { isAllowedUri } from '@tiptap/extension-link'
1010
import type { Mark, Node } from '@tiptap/pm/model'
11+
import { normalizeReference } from 'markdown-it/lib/common/utils.mjs'
1112
import type { MarkdownSerializerState } from 'prosemirror-markdown'
1213
import { defaultMarkdownSerializer } from 'prosemirror-markdown'
1314
import { domHref, parseHref } from '../helpers/links.js'
@@ -119,6 +120,24 @@ const Link = TipTapLink.extend<RelativePathLinkOptions>({
119120
renderHTML: (attrs) =>
120121
attrs.isWikiLink ? { 'data-wiki-link': 'true' } : {},
121122
},
123+
referenceLabel: {
124+
default: null,
125+
parseHTML: (element) =>
126+
element.getAttribute('data-md-reference-label'),
127+
renderHTML: (attrs) =>
128+
attrs.referenceLabel
129+
? { 'data-md-reference-label': attrs.referenceLabel }
130+
: {},
131+
},
132+
referenceType: {
133+
default: null,
134+
parseHTML: (element) =>
135+
element.getAttribute('data-md-reference-type'),
136+
renderHTML: (attrs) =>
137+
attrs.referenceType
138+
? { 'data-md-reference-type': attrs.referenceType }
139+
: {},
140+
},
122141
}
123142
},
124143

@@ -285,13 +304,48 @@ const Link = TipTapLink.extend<RelativePathLinkOptions>({
285304
_parent: Node,
286305
_index: number,
287306
) {
288-
if (!mark.attrs.isWikiLink) {
289-
const close = defaultMarkdownSerializer.marks.link.close
290-
return typeof close === 'function'
291-
? close(state, mark, _parent, _index)
292-
: close
307+
if (mark.attrs.isWikiLink) {
308+
return ']]'
309+
}
310+
if (mark.attrs.referenceLabel && mark.attrs.referenceType) {
311+
const label = mark.attrs.referenceLabel as string
312+
const type = mark.attrs.referenceType as
313+
| 'shortcut'
314+
| 'collapsed'
315+
| 'full'
316+
const defs =
317+
// Cast `state.options` locally as `referenceDefinitions` doesn't exist in upstream type definition
318+
(
319+
state.options as {
320+
referenceDefinitions?: Map<
321+
string,
322+
{ label: string; href: string; title: string | null }
323+
>
324+
}
325+
).referenceDefinitions
326+
if (defs) {
327+
const key = normalizeReference(label)
328+
if (!defs.has(key)) {
329+
defs.set(key, {
330+
label,
331+
href: mark.attrs.href as string,
332+
title: (mark.attrs.title as string) ?? null,
333+
})
334+
}
335+
}
336+
switch (type) {
337+
case 'shortcut':
338+
return ']'
339+
case 'collapsed':
340+
return '][]'
341+
case 'full':
342+
return `][${label}]`
343+
}
293344
}
294-
return ']]'
345+
const close = defaultMarkdownSerializer.marks.link.close
346+
return typeof close === 'function'
347+
? close(state, mark, _parent, _index)
348+
: close
295349
},
296350
mixable: true,
297351
expelEnclosingWhitespace: false,

src/tests/markdown.spec.js

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,10 @@ describe('Markdown though editor', () => {
7373
expect(markdownThroughEditor('[test](foo)')).toBe('[test](foo)')
7474
expect(markdownThroughEditor('[test](foo "bar")')).toBe('[test](foo "bar")')
7575
// Issue #2703
76-
expect(markdownThroughEditor('[bar\\\\]: /uri\n\n[bar\\\\]')).toBe(
77-
'[bar\\\\](/uri)',
78-
)
76+
const test2703 = '[bar\\\\]\n\n[bar\\\\]: /uri\n'
77+
const test2703ReferenceFirst = '[bar\\\\]: /uri\n\n[bar\\\\]\n'
78+
expect(markdownThroughEditor(test2703)).toBe(test2703)
79+
expect(markdownThroughEditor(test2703ReferenceFirst)).toBe(test2703)
7980
// Issue #4900
8081
expect(markdownThroughEditor('[`code`](foo)')).toBe('[`code`](foo)')
8182
expect(markdownThroughEditor('[text with `code` inside](foo)')).toBe(
@@ -86,6 +87,28 @@ describe('Markdown though editor', () => {
8687
expect(markdownThroughEditor('text [[wikiLink]] more')).toBe(
8788
'text [[wikiLink]] more',
8889
)
90+
// Reference-style links (issue #5820)
91+
const referenceShortcutTest =
92+
'Test with [Case-Sensitive Reference] in it.\n\n[Case-Sensitive Reference]: https://example.org/\n'
93+
expect(markdownThroughEditor(referenceShortcutTest)).toBe(
94+
referenceShortcutTest,
95+
)
96+
const referenceCollapsedTest =
97+
'Test with [label][] in it.\n\n[label]: https://example.org/\n'
98+
expect(markdownThroughEditor(referenceCollapsedTest)).toBe(
99+
referenceCollapsedTest,
100+
)
101+
const referenceFullTest =
102+
'Test with [display text][label] in it.\n\n[label]: https://example.org/ "title"\n'
103+
expect(markdownThroughEditor(referenceFullTest)).toBe(referenceFullTest)
104+
// References moved to the end of the document
105+
expect(
106+
markdownThroughEditor(
107+
'Test with [reference] in it.\n\n[reference]: /url\n\nsome extra paragraph\n',
108+
),
109+
).toBe(
110+
'Test with [reference] in it.\n\nsome extra paragraph\n\n[reference]: /url\n',
111+
)
89112
})
90113
test('images', () => {
91114
// Inline images
@@ -123,10 +146,6 @@ describe('Markdown though editor', () => {
123146
expect(markdownThroughEditor('```\n```')).toBe('```\n```')
124147
})
125148
test('markdown untouched', () => {
126-
// Issue #2703
127-
expect(markdownThroughEditor('[bar\\\\]: /uri\n\n[bar\\\\]')).toBe(
128-
'[bar\\\\](/uri)',
129-
)
130149
expect(markdownThroughEditor('## Test \\')).toBe('## Test \\')
131150
expect(markdownThroughEditor('- [ [asd](sdf)')).toBe('- [ [asd](sdf)')
132151
})

src/tests/markdownit/commonmark.spec.js

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@ describe('Commonmark', () => {
3434
585, 586, 588, 589, 591,
3535
]
3636

37+
const referenceLinkTests = [
38+
23, 33, 192, 193, 194, 195, 196, 198, 200, 201, 202, 203, 204, 205, 206, 214,
39+
215, 216, 217, 218, 514, 515, 516, 517, 518, 527, 528, 529, 530, 531, 532,
40+
533, 534, 535, 539, 540, 541, 542, 543, 544, 549, 550, 553, 554, 555, 556,
41+
557, 558, 559, 560, 561, 562, 564, 565, 566, 568, 569, 570, 571, 593,
42+
]
43+
3744
spec.forEach((entry) => {
3845
// We do not support HTML
3946
if (entry.section === 'HTML blocks' || entry.section === 'Raw HTML') return
@@ -48,13 +55,21 @@ describe('Commonmark', () => {
4855
.replace(/<strong>/g, '<u>')
4956
.replace(/<\/strong>/g, '</u>')
5057
: entry.html
58+
5159
if (figureImageMarkdownTests.indexOf(entry.example) !== -1) {
5260
expected = expected
5361
.replace(/<p>/g, '<figure>')
5462
.replace(/<\/p>/g, '</figure>')
5563
}
5664

57-
const rendered = markdownit.render(entry.markdown)
65+
let rendered = markdownit.render(entry.markdown)
66+
67+
if (referenceLinkTests.indexOf(entry.example) !== -1) {
68+
rendered = rendered.replace(
69+
/ data-md-reference-label="[^"]+" data-md-reference-type="[a-z]+"/g,
70+
'',
71+
)
72+
}
5873

5974
// Ignore special markup for untouched markdown
6075
expect(normalize(rendered)).toBe(expected)
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import markdownit from '../../markdownit/index.js'
7+
8+
describe('reference style links (markdown-it)', () => {
9+
it('renders a reference link of type shortcut (omitted label)', () => {
10+
expect(
11+
markdownit.render(
12+
'text with [Some Reference] in it\n\n[Some Reference]: https://example.org',
13+
),
14+
).to.equal(
15+
'<p>text with <a href="https://example.org" data-md-reference-label="Some Reference" data-md-reference-type="shortcut">Some Reference</a> in it</p>\n',
16+
)
17+
})
18+
19+
it('renders a reference link of type collapsed (empty label)', () => {
20+
expect(
21+
markdownit.render(
22+
'text with [Some Reference][] in it\n\n[Some Reference]: https://example.org',
23+
),
24+
).to.equal(
25+
'<p>text with <a href="https://example.org" data-md-reference-label="Some Reference" data-md-reference-type="collapsed">Some Reference</a> in it</p>\n',
26+
)
27+
})
28+
29+
it('renders a reference link of type full (separate label)', () => {
30+
expect(
31+
markdownit.render(
32+
'text with [Some Reference][ref] in it\n\n[ref]: https://example.org',
33+
),
34+
).to.equal(
35+
'<p>text with <a href="https://example.org" data-md-reference-label="ref" data-md-reference-type="full">Some Reference</a> in it</p>\n',
36+
)
37+
})
38+
39+
it('renders a reference link with paragraphs after reference', () => {
40+
expect(
41+
markdownit.render(
42+
'text with [reference] in it\n\n[reference]: /url\n\nsome extra content.',
43+
),
44+
).to.equal(
45+
'<p>text with <a href="/url" data-md-reference-label="reference" data-md-reference-type="shortcut">reference</a> in it</p>\n<p>some extra content.</p>\n',
46+
)
47+
})
48+
})

0 commit comments

Comments
 (0)