diff --git a/web/src/lib/components/editor/extensions/safeLink.ts b/web/src/lib/components/editor/extensions/safeLink.ts index 91606a9b9..e8ade4896 100644 --- a/web/src/lib/components/editor/extensions/safeLink.ts +++ b/web/src/lib/components/editor/extensions/safeLink.ts @@ -4,17 +4,24 @@ // or component-dependent may be imported into this module. import { mergeAttributes } from '@tiptap/core'; import Link from '@tiptap/extension-link'; +import { FOLLOWS_TITLE_PREFIX } from '$lib/utils/followsTitle'; // Extend Link to render data-href instead of href in the editor DOM. // This prevents mobile browsers from navigating when tapping links — // no href attribute means nothing for the browser to follow. // Mark attributes still store href, so markdown serialization and the // link popover work unchanged. +// +// The follows-title marker (BUG-3315, FOLLOWS_TITLE_PREFIX in $lib/utils/markdown) +// is dropped from the DOM too: it is the title AS LOADED, which after a rename +// is stale, so it must not show as a tooltip. The mark attribute itself is kept, +// because the save reads it. A title a user wrote is rendered as before. export const SafeLink = Link.extend({ renderHTML({ HTMLAttributes }) { const merged = mergeAttributes(this.options.HTMLAttributes, HTMLAttributes); - const { href, ...rest } = merged; - return ['a', { ...rest, 'data-href': href }, 0]; + const { href, title, ...rest } = merged; + const marker = typeof title === 'string' && title.startsWith(FOLLOWS_TITLE_PREFIX); + return ['a', { ...rest, ...(marker || title == null ? {} : { title }), 'data-href': href }, 0]; }, }); diff --git a/web/src/lib/utils/followsTitle.ts b/web/src/lib/utils/followsTitle.ts new file mode 100644 index 000000000..59369d907 --- /dev/null +++ b/web/src/lib/utils/followsTitle.ts @@ -0,0 +1,6 @@ +// The prefix of the follows-title marker (BUG-3315), shared by the markdown +// load/save functions ($lib/utils/markdown) and the editor's link mark +// (components/editor/extensions/safeLink), which hides it from the DOM. It is a +// module of its own so that safeLink, which the headless materializer bundles, +// imports nothing DOM-dependent. See followsTitleMarker in $lib/utils/markdown. +export const FOLLOWS_TITLE_PREFIX = 'pad-follows-title:'; diff --git a/web/src/lib/utils/markdown.renameMarker.svelte.test.ts b/web/src/lib/utils/markdown.renameMarker.svelte.test.ts new file mode 100644 index 000000000..75935ea31 --- /dev/null +++ b/web/src/lib/utils/markdown.renameMarker.svelte.test.ts @@ -0,0 +1,230 @@ +import { describe, it, expect } from 'vitest'; +import { createHeadlessEditor, flushPipeline } from '$lib/collab/materializer/entry'; +import { wikiLinksToMarkdown, cleanBrokenLinks } from './markdown'; +import type { Item } from '$lib/types'; + +// BUG-3315 U2: a link that FOLLOWS its target's title keeps following it across +// a rename, even on an item whose collaborative document froze the old text. +// +// A tab loads `[[TASK-1]]` as a link whose text is the title at that moment, +// and the text then lives in the document. Before U2, the next save compared +// that text with the CURRENT title only, so after a rename it wrote +// `[[TASK-1|Old Title]]` and pinned the old title for good. Now the load marks a +// title-following link with the link mark's existing `title` attribute (the +// text as loaded, so no schema change). The save then treats text that still +// equals the marker as "follows the title". A deliberate `[[REF|X]]` loads +// without the marker, so it survives a rename unchanged, as it should. +// +// Every leg goes through the REAL shared pipeline: the load, the headless +// editor with the live extension set, and flushPipeline. That is the same code +// the server materializer (TASK-2198) runs. + +function task(title: string): Item { + return { + id: 'id-1', + title, + slug: 'fixture', + collection_slug: 'tasks', + collection_prefix: 'TASK', + item_number: 1 + } as unknown as Item; +} + +type Doc = ReturnType; + +// Load `stored` with the item titled `loadTitle`, let `edit` touch the document +// (a tab's session), then serialize it: the op-log's frozen document. +function openAndEdit(stored: string, loadTitle: string, edit?: (ed: Doc) => void) { + const ed = createHeadlessEditor(); + try { + ed.commands.setContent(wikiLinksToMarkdown(stored, [task(loadTitle)], 'ws', 'u')); + edit?.(ed); + return { + md: (ed.storage as { markdown: { getMarkdown(): string } }).markdown.getMarkdown(), + html: ed.getHTML() + }; + } finally { + ed.destroy(); + } +} + +// A later tab replays that document and saves it against `index`. +const saveWith = (md: string, index: Item[]) => flushPipeline(md, index as never); + +describe('BUG-3315 U2: a rename does not pin the old title', () => { + it('an auto link follows a rename', () => { + const { md } = openAndEdit('see [[TASK-1]] here', 'Old Title'); + expect(saveWith(md, [task('New Title')])).toBe('see [[TASK-1]] here'); + }); + + it('a legacy title link follows a rename too', () => { + const { md } = openAndEdit('see [[Old Title]] here', 'Old Title'); + expect(saveWith(md, [task('New Title')])).toBe('see [[TASK-1]] here'); + }); + + it('a deliberate override survives a rename unchanged', () => { + const { md } = openAndEdit('see [[TASK-1|my label]] here', 'Old Title'); + expect(saveWith(md, [task('New Title')])).toBe('see [[TASK-1|my label]] here'); + }); + + it('a deliberate override that equals the old title is still deliberate', () => { + const { md } = openAndEdit('see [[TASK-1|Old Title]] here', 'Old Title'); + expect(saveWith(md, [task('New Title')])).toBe('see [[TASK-1|Old Title]] here'); + }); + + it('editing an auto link\'s text makes an override', () => { + const { md } = openAndEdit('see [[TASK-1]] here', 'Old Title', (ed) => { + // Replace the link's text "Old Title" (positions 5..14) with "Edited". + ed.commands.insertContentAt({ from: 5, to: 14 }, 'Edited'); + }); + expect(saveWith(md, [task('New Title')])).toBe('see [[TASK-1|Edited]] here'); + }); + + for (const title of ['say "hi" twice', 'a > b & c', 'back\\slash "q"', 'on /auth/* ']) { + it(`the marker survives a title with special characters (${JSON.stringify(title)})`, () => { + const { md } = openAndEdit('see [[TASK-1]] here', title); + expect(saveWith(md, [task('New Title')])).toBe('see [[TASK-1]] here'); + expect(saveWith(md, [task(title)])).toBe('see [[TASK-1]] here'); + }); + } + + it('a target missing at save time does not leak the marker into stored content', () => { + const { md } = openAndEdit('see [[TASK-1]] here', 'Old Title'); + expect(saveWith(md, [])).toBe('see [Old Title](/u/ws/tasks/TASK-1) here'); + }); + + it('the editor DOM shows no tooltip from the frozen title', () => { + const { html } = openAndEdit('see [[TASK-1]] here', 'Old Title'); + expect(html).toContain('data-href="/u/ws/tasks/TASK-1"'); + expect(html).not.toContain('title='); + }); + + // Guard (codex r1): the save's link match must not narrow what it accepted + // before the marker existed, e.g. an href whose last segment has a space. + it('a link to a slug with a space still converts, marker or not', () => { + const spaced = { ...task('Old Title'), slug: 'my slug', item_number: undefined } as unknown as Item; + expect(saveWith('see [Old Title](/u/ws/tasks/my slug) here', [spaced])).toBe('see [[Old Title]] here'); + expect(saveWith('see [Old Title](/u/ws/tasks/my slug "pad-follows-title:Old Title") here', [{ ...spaced, title: 'New Title' } as Item])).toBe('see [[New Title]] here'); + }); + + // Guard (codex r2): a title the USER wrote on an internal link is not a + // marker. The save leaves such a link exactly as before U2, and the DOM keeps + // the tooltip. + it('a user-authored titled internal link is left untouched', () => { + const typed = 'see [x](/u/ws/tasks/TASK-1 "my tip") here'; + expect(saveWith(typed, [task('Old Title')])).toBe(typed); + const ed = createHeadlessEditor(); + try { + ed.commands.setContent(typed); + expect(ed.getHTML()).toContain('title="my tip"'); + expect(saveWith((ed.storage as { markdown: { getMarkdown(): string } }).markdown.getMarkdown(), [task('Old Title')])).toBe(typed); + } finally { + ed.destroy(); + } + }); + + // THE INVARIANT (lead, before the GO): the marker never reaches stored + // markdown, on ANY path where a marked link does not convert back to + // [[...]]. Each case is a real save pipeline (flushPipeline, the same steps as + // both ItemDetail save paths) over a document a tab really produced. + describe('the marker never reaches stored markdown', () => { + const other = { ...task('Unrelated'), id: 'id-2', item_number: 2, slug: 'other' } as unknown as Item; + const hardBreak = (ed: Doc) => { + // Shift-Enter inside the link text: a hard break splits "Old|Title". + ed.commands.setTextSelection(8); + ed.commands.setHardBreak(); + }; + const boldPart = (ed: Doc) => { + ed.commands.setTextSelection({ from: 5, to: 8 }); + ed.commands.toggleBold(); + }; + const externalHref = (ed: Doc) => { + // The user edits the auto link's URL to an external address; the link + // mark keeps its attributes, the marker included. + ed.chain().setTextSelection({ from: 5, to: 14 }).extendMarkRange('link').updateAttributes('link', { href: 'https://example.com/x' }).run(); + }; + const cases: Array<[string, string, Item[], ((ed: Doc) => void)?]> = [ + ['target deleted (index lacks it)', 'see [[TASK-1]] here', [other]], + ['empty index (pipelines skip conversion)', 'see [[TASK-1]] here', []], + ['restricted viewer (target not visible)', 'see [[TASK-1]] here', [other]], + ['hard break inside the link text, target present', 'see [[TASK-1]] here', [task('Old Title')], hardBreak], + ['hard break inside the link text, target gone', 'see [[TASK-1]] here', [other], hardBreak], + ['partial bold inside the link, target gone', 'see [[TASK-1]] here', [other], boldPart], + ['link in a markdown table, target gone', '| a | b |\n| --- | --- |\n| [[TASK-1]] | x |', [other]], + ['link in an HTML-serialized table (list in a cell), target gone', '
  • [[TASK-1]]

', [other]], + ['URL edited to an external address, target present', 'see [[TASK-1]] here', [task('Old Title')], externalHref], + ['URL edited to an external address, empty index', 'see [[TASK-1]] here', [], externalHref], + ]; + for (const [name, stored, index, edit] of cases) { + it(name, () => { + const { md } = openAndEdit(stored, 'Old Title', edit); + // Guard: the document really carries the marker, or the case proves nothing. + expect(md).toContain('pad-follows-title'); + expect(saveWith(md, index)).not.toContain('pad-follows-title'); + }); + } + // ...and the strip touches ONLY a real link's title (codex, after the + // leak fix). Text that merely mentions the marker, as a doc about this + // feature does, is content and must survive a save byte-identical. + for (const [name, body] of [ + ['inline code span', 'the marker is `[Title](/u/ws/tasks/TASK-1 "pad-follows-title:Title")` in storage'], + ['fenced code block', 'before\n\n```\n[T](/x "pad-follows-title:T")\n```\n\nafter'], + ['prose with escaped brackets', 'plain \\[T\\](/x "pad-follows-title:T") text'], + ['a bare mention', 'writes "pad-follows-title:x") as a title'], + ] as const) { + it(`text that mentions the marker is untouched (${name})`, () => { + expect(saveWith(body, [])).toBe(body); + expect(saveWith(body, [task('Title')])).toBe(body); + }); + } + + // Codex r2 of the code-aware strip: a link text ending in a backslash is + // serialized `[a\\](...)`. Its `]` follows a backslash, but an even + // number of them, so it is a real link and its marker must go. + it('a real link whose text ends in a backslash still loses its marker', () => { + const { md } = openAndEdit('see [[TASK-1]] here', 'C:\\'); + expect(md).toContain('pad-follows-title'); + expect(saveWith(md, [])).not.toContain('pad-follows-title'); + }); + + // Codex r3: a backtick before a fence must not pair with one after it. A + // span crossing the fence overlapped the fence's range, and the binary + // search then misread offsets. Here the text after the fence is a real + // inline span, and the marker-shaped text in it must survive. + it('an inline span does not pair across a fenced block', () => { + const body = '`\n```\ncode\n```\n` [T](/x "pad-follows-title:T") `'; + expect(saveWith(body, [])).toBe(body); + }); + + // Codex r4 (did not reproduce, pinned): runs of different lengths must not + // form overlapping spans. In "`a ``b ` LINK `` " the single-backtick span + // closes at the third run, and the "``" inside it is content, not an + // opener, so LINK sits OUTSIDE code and its marker must go. + it('a run inside a span does not open a second, overlapping span', () => { + const body = '`a ``b ` [x](/u/ws/tasks/TASK-1 "pad-follows-title:x") ``'; + expect(saveWith(body, [])).toBe('`a ``b ` [x](/u/ws/tasks/TASK-1) ``'); + }); + + // ...and the code scan is linear. Receipt, local: 40,000 alternating + // backtick runs took 267ms under the first version (18x for 10x the input) + // and take 9.6ms now (98ms at 400,000). The bound sits between, with room + // for a slower CI machine. + it('the code scan stays linear on many backtick runs', () => { + const parts: string[] = []; + for (let i = 0; i < 40000; i++) parts.push(i % 2 ? '`` x ' : '` x '); + const body = parts.join('') + '[T](/u/ws/tasks/TASK-1 "pad-follows-title:T")'; + const t0 = performance.now(); + cleanBrokenLinks(body); + expect(performance.now() - t0).toBeLessThan(150); + }); + + it('the guard is live: a marked document does carry the marker before the save', () => { + expect(openAndEdit('see [[TASK-1]] here', 'Old Title').md).toContain('pad-follows-title'); + }); + }); + + it('with no rename, nothing changes', () => { + const { md } = openAndEdit('a [[TASK-1]] and [[TASK-1|label]] b', 'Same'); + expect(saveWith(md, [task('Same')])).toBe('a [[TASK-1]] and [[TASK-1|label]] b'); + }); +}); diff --git a/web/src/lib/utils/markdown.renameOverride.test.ts b/web/src/lib/utils/markdown.renameOverride.test.ts index 826002b6f..3dd58433b 100644 --- a/web/src/lib/utils/markdown.renameOverride.test.ts +++ b/web/src/lib/utils/markdown.renameOverride.test.ts @@ -2,17 +2,22 @@ import { describe, it, expect } from 'vitest'; import { wikiLinksToMarkdown, markdownToWikiLinks } from './markdown'; import type { Item } from '$lib/types'; -// CHARACTERIZATION of BUG-3315, and it pins the DEFECT: when BUG-3315 is fixed, -// the first two cases are expected to fail and should be rewritten to the fixed -// answer, not deleted. +// BUG-3315, at the string level. This began as a CHARACTERIZATION pinning the +// defect (#1684); U2 flipped the first two cases to the fixed answer, as that +// version's header said to. The editor-level legs live in +// markdown.renameMarker.svelte.test.ts. // -// A tab loading an item turns each wiki link into a link mark whose TEXT is the +// THE DEFECT, for the record: a tab loading an item turns each wiki link into a link mark whose TEXT is the // target's title at that moment (wikiLinksToMarkdown), and from then on that text // lives in the collaborative document. After the target is renamed, the next tab // replays the same document, and its flush (markdownToWikiLinks against the // post-rename index) sees text that no longer equals the title. So it stores a // display OVERRIDE, pinning the old title for good. // +// THE FIX: the load marks a link that follows the title with the link's `title` +// attribute (the text as loaded), and the save treats text still equal to it as +// following the title. +// // Both stored spellings behave the same. `[[Old Title]]` is the form the // server's title-rename cascade rewrites; `[[TASK-1]]` is a form it never // touches. So the cascade neither causes nor prevents this (BUG-3313, @@ -38,14 +43,24 @@ function taskTitled(title: string): Item { const before = [taskTitled('Old Title')]; const after = [taskTitled('New Title')]; -describe('a rename pins the old title on an op-logged item (BUG-3315 characterization)', () => { +describe('a rename no longer pins the old title on an op-logged item (BUG-3315)', () => { for (const stored of ['see [[Old Title]] here', 'see [[TASK-1]] here']) { it(`document seeded before the rename from ${JSON.stringify(stored)}`, () => { const doc = wikiLinksToMarkdown(stored, before, 'ws', 'u'); - // Guard: the link text in the document is the pre-rename title. Without - // this, a change to the seed could make the next assertion pass vacuously. - expect(doc).toBe('see [Old Title](/u/ws/tasks/TASK-1) here'); - expect(markdownToWikiLinks(doc, after)).toBe('see [[TASK-1|Old Title]] here'); + // Guard: the link text in the document is the pre-rename title, and it + // carries the marker. Without this, a change to the seed could make the + // next assertion pass vacuously. + expect(doc).toBe('see [Old Title](/u/ws/tasks/TASK-1 "pad-follows-title:Old Title") here'); + expect(markdownToWikiLinks(doc, after)).toBe('see [[TASK-1]] here'); + }); + } + + // Straight from the load, without the editor in between, the marker still + // carries the load's escapes (codex r3); it must still read as the title. + for (const special of ['A & B', 'back\\slash', 'say "hi"']) { + it(`the marker straight from the load follows a rename (${JSON.stringify(special)})`, () => { + const doc = wikiLinksToMarkdown('see [[TASK-1]] here', [taskTitled(special)], 'ws', 'u'); + expect(markdownToWikiLinks(doc, after)).toBe('see [[TASK-1]] here'); }); } diff --git a/web/src/lib/utils/markdown.ts b/web/src/lib/utils/markdown.ts index c7fc901ee..eab29b301 100644 --- a/web/src/lib/utils/markdown.ts +++ b/web/src/lib/utils/markdown.ts @@ -1,6 +1,7 @@ import { marked, Renderer, type Tokens } from 'marked'; import DOMPurify from 'dompurify'; import type { Item } from '$lib/types'; +import { FOLLOWS_TITLE_PREFIX } from './followsTitle'; import { itemUrlId } from '$lib/types'; import { type AttachmentResolver, @@ -688,7 +689,7 @@ export function wikiDisplayText(body: string, titleByRef: ReadonlyMap i.title.toLowerCase() === fullBody.toLowerCase()); if (fullTitleItem && fullTitleItem.collection_slug) { - return { item: fullTitleItem, displayText: fullTitleItem.title }; + return { item: fullTitleItem, displayText: fullTitleItem.title, followsTitle: true }; } // Collection-qualified legacy form whose title contains a pipe. if (fullBody.includes('/')) { @@ -723,7 +724,7 @@ function resolveWikiBody(body: string, items: Item[]): { item: Item | null; disp i.collection_slug === qualColl ); if (qualItem && qualItem.collection_slug) { - return { item: qualItem, displayText: qualItem.title }; + return { item: qualItem, displayText: qualItem.title, followsTitle: true }; } } } @@ -747,7 +748,7 @@ function resolveWikiBody(body: string, items: Item[]): { item: Item | null; disp } } - return { item: item && item.collection_slug ? item : null, displayText }; + return { item: item && item.collection_slug ? item : null, displayText, followsTitle: displayOverride == null }; } /** @@ -779,7 +780,7 @@ export function wikiLinksToMarkdown(content: string, items: Item[], workspaceSlu const sameWsItem = findItemByRef(items, xw.ref); if (sameWsItem && sameWsItem.collection_slug) { const text = xw.display ?? sameWsItem.title; - return `[${escapeMarkdownLinkText(text)}](${prefix}/${sameWsItem.collection_slug}/${itemUrlId(sameWsItem)})`; + return `[${escapeMarkdownLinkText(text)}](${prefix}/${sameWsItem.collection_slug}/${itemUrlId(sameWsItem)}${followsTitleMarker(xw.display == null, text, sameWsItem)})`; } // Ref didn't resolve in the current workspace — leave the // original wiki-link verbatim, matching the legacy fall-through @@ -794,9 +795,9 @@ export function wikiLinksToMarkdown(content: string, items: Item[], workspaceSlu // Same-workspace resolution — shared with renderMarkdown via // resolveWikiBody (see its doc comment for the resolution order). - const { item, displayText } = resolveWikiBody(body, items); + const { item, displayText, followsTitle } = resolveWikiBody(body, items); if (item && item.collection_slug) { - return `[${escapeMarkdownLinkText(displayText)}](${prefix}/${item.collection_slug}/${itemUrlId(item)})`; + return `[${escapeMarkdownLinkText(displayText)}](${prefix}/${item.collection_slug}/${itemUrlId(item)}${followsTitleMarker(followsTitle, displayText, item)})`; } // Unresolved: leave the original [[X]] text alone. Emitting a // [text](broken) link here would hijack content that legitimately @@ -846,7 +847,8 @@ export function markdownToWikiLinks(markdown: string, items: Item[]): string { // contain backslash-escaped chars (\[, \], \\) that tiptap-markdown emits // when serializing link text. The capture allows `\.` sequences so we // don't terminate on an escaped `]` that's really part of the display. - return withXwRefs.replace(/\[((?:\\.|[^\]\\])+)\]\(\/(?:[^/]+\/){2,3}([^)]+)\)/g, (_match, rawText: string, slugOrRef: string) => { + const code = withXwRefs.includes(FOLLOWS_TITLE_PREFIX) ? codeRanges(withXwRefs) : []; + return withXwRefs.replace(/\[((?:\\.|[^\]\\])+)\]\((\/(?:[^/]+\/){2,3}([^)]+?))(?: "((?:\\"|[^"])*)")?\)/g, (_match, rawText: string, path: string, slugOrRef: string, rawMarker: string | undefined, offset: number) => { const item = items.find(i => { if (i.slug === slugOrRef) return true; if (i.item_number && i.collection_prefix) { @@ -854,7 +856,17 @@ export function markdownToWikiLinks(markdown: string, items: Item[]): string { } return false; }); - if (!item) return _match; + // The follows-title marker is load-time state and never belongs in stored + // content, so a link whose target is gone keeps its text and href only. + // A title that is not ours is the user's: the link is left exactly as it + // was, which is what this function did with any titled link before the + // marker existed. + if (rawMarker !== undefined && !rawMarker.startsWith(FOLLOWS_TITLE_PREFIX)) return _match; + // Marker-shaped text inside code is content, not a link. + if (rawMarker !== undefined && inRanges(code, offset)) return _match; + if (!item) return rawMarker === undefined ? _match : `[${rawText}](${path})`; + const marker = + rawMarker === undefined ? undefined : rawMarker.slice(FOLLOWS_TITLE_PREFIX.length).replace(/\\"/g, '"'); // tiptap-markdown emits backslash-escaped brackets in the link text // (e.g. "Use \[\[ to link"); unescape before comparing/emitting. @@ -868,16 +880,121 @@ export function markdownToWikiLinks(markdown: string, items: Item[]): string { // Prefer ref-based storage. Omit |Display if it matches the // current item title (renaming the item updates the link text // automatically on next load). - if (displayText === item.title) { + // Text still equal to the follows-title marker is a link nobody edited + // that loaded under an earlier title: it follows the title (BUG-3315). + if (displayText === item.title || followsMarker(displayText, marker)) { return `[[${ref}]]`; } return `[[${ref}|${escapeWikiBody(displayText)}]]`; } // Legacy fallback for items without a ref. - return `[[${escapeWikiBody(displayText)}]]`; + return `[[${escapeWikiBody(followsMarker(displayText, marker) ? item.title : displayText)}]]`; }); } +// The load-time marker for a link that FOLLOWS its target's title (BUG-3315), +// carried in the link mark's existing `title` attribute: +// `[Title](href "pad-follows-title:Title")`. The prefix is what tells it from a +// title a user wrote, which is never read, stripped or hidden (codex r2). The +// prefix is RESERVED: a title a user writes that itself begins with it is read as +// a marker (codex r4, ruled by design; nothing in the product writes one). +// It records the text as loaded, and only for a link with no explicit override +// whose text IS the title. The save then tells "nobody edited this, it was the +// title then" (follows the title, so `[[REF]]`) from a deliberate or edited +// display. It never reaches stored content: markdownToWikiLinks consumes it, and +// SafeLink keeps it out of the DOM so it is not shown as a stale tooltip. +// Inside a CommonMark link title, `\`, `"` and `&` would be read as an escape, +// the end of the title and an entity, so each is backslash-escaped. +function followsTitleMarker(followsTitle: boolean, text: string, item: Item): string { + if (!followsTitle || text !== item.title) return ''; + return ` "${FOLLOWS_TITLE_PREFIX}${text.replace(/[\\"&]/g, '\\$&')}"`; +} + +// The [start, end) ranges of `markdown` inside fenced code blocks and inline +// code spans, which are content and never link syntax. Marker handling +// (BUG-3315) skips them, so a doc that MENTIONS the marker, as one about this +// feature does, survives a save. Deliberately simple CommonMark: a fence is a +// line of 3+ backticks or tildes (up to 3 spaces of indent), closed by the same +// character at least as long; an inline span is a backtick run closed by a run +// of the same length. +function codeRanges(markdown: string): Array<[number, number]> { + const fences: Array<[number, number]> = []; + const fence = /^ {0,3}(`{3,}|~{3,})[^\n]*$/gm; + let open: { at: number; ch: string; len: number } | null = null; + let m: RegExpExecArray | null; + while ((m = fence.exec(markdown)) !== null) { + const run = m[1]; + if (open === null) open = { at: m.index, ch: run[0], len: run.length }; + else if (run[0] === open.ch && run.length >= open.len && m[0].trim() === run) { + fences.push([open.at, m.index + m[0].length]); + open = null; + } + } + if (open !== null) fences.push([open.at, markdown.length]); + + // Inline spans, in ONE left-to-right pass (codex: the first version rescanned + // for each opening run and went superlinear). Collect the backtick runs + // outside fences, then pair each unconsumed run with the next run of the + // same length. Everything between is inside the span. A per-length pointer + // only moves forward. + // A span never crosses a fenced block (block structure comes first), so a + // run is only paired inside its own gap between fences. `gap` numbers it, + // and the pairing key is (gap, length), which also keeps every range + // disjoint, as inRanges' binary search requires (codex r3). + const runs: Array<{ at: number; len: number; gap: number }> = []; + const tick = /`+/g; + let f = 0; + while ((m = tick.exec(markdown)) !== null) { + while (f < fences.length && fences[f][1] <= m.index) f++; + if (f < fences.length && m.index >= fences[f][0]) continue; + runs.push({ at: m.index, len: m[0].length, gap: f }); + } + const key = (r: { len: number; gap: number }) => `${r.gap}:${r.len}`; + const byLen = new Map(); + runs.forEach((r, i) => { + const list = byLen.get(key(r)); + if (list) list.push(i); + else byLen.set(key(r), [i]); + }); + const ptr = new Map(); + const spans: Array<[number, number]> = []; + for (let i = 0; i < runs.length; i++) { + const k = key(runs[i]); + const list = byLen.get(k)!; + let p = ptr.get(k) ?? 0; + while (p < list.length && list[p] <= i) p++; + ptr.set(k, p); + if (p === list.length) continue; + const j = list[p]; + spans.push([runs[i].at, runs[j].at + runs[j].len]); + i = j; + } + return [...fences, ...spans].sort((a, b) => a[0] - b[0]); +} + +// Whether offset `i` falls in one of the sorted, non-overlapping `ranges`. +function inRanges(ranges: Array<[number, number]>, i: number): boolean { + let lo = 0; + let hi = ranges.length - 1; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + if (i < ranges[mid][0]) hi = mid - 1; + else if (i >= ranges[mid][1]) lo = mid + 1; + else return true; + } + return false; +} + +// Whether a link's text is still the text its follows-title marker recorded. +// After a trip through the editor the marker comes back as the serializer +// writes a title (only `"` escaped), so it is compared RAW. Straight from +// wikiLinksToMarkdown it still carries the load's `\\`, `\"` and `\&` +// escapes, so it is also compared with backslash escapes undone (codex r3). +function followsMarker(text: string, marker: string | undefined): boolean { + if (marker === undefined) return false; + return text === marker || text === marker.replace(/\\([\\"&])/g, '$1'); +} + // Escape a link's display text so the editor's markdown parser reads it as // LITERAL text. Brackets would terminate or unbalance the span, and the rest are // the characters its inline rules act on (BUG-3315): `*` `_` and `~` become @@ -936,10 +1053,29 @@ function unescapeMarkdownLinkText(s: string): string { } /** - * Convert [[broken]] placeholder links back to wiki syntax + * Convert [[broken]] placeholder links back to wiki syntax. + * + * It is the LAST step of every save pipeline (both ItemDetail save paths and + * the materializer's flushPipeline), which skip markdownToWikiLinks when the + * link index is empty. So it is also where a follows-title marker (BUG-3315, + * followsTitleMarker) that nothing consumed is dropped from EVERY link: + * the marker is load-time state and must never reach stored content. */ export function cleanBrokenLinks(markdown: string): string { - return markdown.replace(/\[([^\]]+)\]\(broken\)/g, '[[$1]]'); + const cleaned = markdown.replace(/\[([^\]]+)\]\(broken\)/g, '[[$1]]'); + if (!cleaned.includes(FOLLOWS_TITLE_PREFIX)) return cleaned; + const code = codeRanges(cleaned); + return cleaned + // Any link, whatever its href: a user who edits an auto link's URL keeps + // the marker on the mark, and an external href is never converted. (An + // HTML-serialized table does not carry link titles, measured, so there is + // no HTML form to strip.) + // Only a real link's title: a `](` preceded by an EVEN number of backslashes + // (a link text ending in `\\` is still a link), a space-free href, and + // never inside code (see codeRanges). + .replace(/((?<=(?:^|[^\\])(?:\\\\)*)\]\((?:\\.|[^)\s\\])+) "pad-follows-title:(?:\\"|[^"])*"\)/g, (m: string, head: string, offset: number) => + inRanges(code, offset) ? m : `${head})` + ); } export function parseTags(tagsJson: string): string[] {