fix(web): a renamed item's links follow the new title in collab-edited docs (BUG-3315 U2) (#1688)

* fix(web): a renamed item's links follow the new title in collab-edited docs (BUG-3315 U2)

A tab loads [[TASK-1]] as a link whose TEXT is the title at that moment,
and the text lives in the collaborative document from then on. After a
rename, the next save compared it with the current title only and wrote
[[TASK-1|Old Title]], pinning the old title for good.

The load now marks a link that follows the title with the link mark's
EXISTING `title` attribute, set to the text as loaded, so no schema change.
It is set only for a link with no explicit override whose text is the
title. The save treats text still equal to the marker as following the
title ([[REF]]). An edited text, or a deliberate [[REF|X]] (which loads
without the marker), is kept as an override.

The marker never reaches stored content: markdownToWikiLinks consumes it,
and cleanBrokenLinks, the last step of every save pipeline, strips any that
is left, because those pipelines skip markdownToWikiLinks on an empty
index. SafeLink keeps a same-origin link's `title` out of the DOM, so the
stale title is never shown as a tooltip.

Documents already in an op-log carry no marker and keep today's behaviour
until they are next loaded. The characterization test from #1684 is
flipped to the fixed answer.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* fix(web): keep the save's link match as wide as before the marker (BUG-3315 U2, codex r1)

The marker regex narrowed the href to exclude whitespace, so a link whose
last segment had a space stopped converting. The path is matched lazily
instead, ahead of the optional title, and the cleanBrokenLinks strip the
same way. A guard leg pins it.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* fix(web): the follows-title marker is prefixed, so a user's link title is never read or stripped (BUG-3315 U2, codex r2)

cleanBrokenLinks stripped EVERY same-origin link title, so a user-authored
[x](/path "tip") lost its tooltip on save. markdownToWikiLinks would also
read that title as a marker. The marker is now "pad-follows-title:<text>".
Only a title with that prefix is read, stripped, or hidden from the DOM. A
titled internal link a user wrote is left exactly as before U2, which never
converted it. The prefix lives in its own module (utils/followsTitle.ts),
so safeLink, which the materializer bundles, imports nothing DOM-dependent.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* fix(web): a follows-title marker straight from the load still reads as the title (BUG-3315 U2, codex r3)

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 those undone. The ')' findings of the same round did not reproduce;
A)B, f(x) = y and q"uote) convert and strip correctly on both paths.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* docs(web): the follows-title prefix is reserved (BUG-3315 U2, codex r4)

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* fix(web): the follows-title marker is stripped from every link at save, whatever its href (BUG-3315 U2)

The lead's gate before the GO: the marker never reaches stored markdown on
any path where a marked link does not convert back to [[...]]. A leg now
covers ten paths through real save pipelines: target deleted, empty index,
restricted viewer, hard break in the text, partial bold, markdown table,
HTML-serialized table, and a link whose URL the user edited to an external
address (target present, and empty index). It asserts no
'pad-follows-title' substring, with a guard that each document really
carried the marker.

The URL-edit path LEAKED: the mark keeps its attributes, and both strips
matched only same-origin hrefs. The final strip in cleanBrokenLinks now
removes the reserved-prefix title from any link. Red variants: that strip
off alone leaks 3 paths, all strips off leaks 9.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* fix(web): the marker strip touches only a real link's title, never code or prose (BUG-3315 U2, codex)

The broadened strip removed marker-shaped text wherever it appeared, so a
doc that mentions the marker (a code span, a fenced block, escaped-bracket
prose) lost text on save. Marker handling in markdownToWikiLinks and the
cleanBrokenLinks strip now skip fenced code blocks and inline code spans
(codeRanges), and the strip needs an unescaped '](' with a space-free href.
Four legs pin it: each goes red when its guard is removed.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* fix(web): marker strip counts backslash parity; code scan is linear (BUG-3315 U2, codex)

A link text ending in a backslash serializes as [a\\](...): its ']' follows
a backslash, but an even number of them, so it is a real link. The strip's
lookbehind now requires even parity, where it used to keep the marker. The
inline-span scan rescanned per opening run (267ms for 40,000 alternating
runs, 18x for 10x the input). It is now one pass with per-length pointers
and binary-searched ranges (9.6ms, 98ms at 400,000). The fence finding of
the same round did not reproduce: a closing fence with trailing spaces is
recognized. Legs pin both fixes.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* fix(web): an inline code span never pairs across a fenced block (BUG-3315 U2, codex r3)

Backtick runs are paired only inside their own gap between fences, which
also keeps every code range disjoint, as the binary search requires. A
leg pins it; it goes red when the gap is ignored.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* test(web): pin that a run inside a code span opens no second span (BUG-3315 U2, codex r4)

Codex r4 read the span pairing as able to overlap spans of different
lengths. It cannot, because the scan resumes after the closing run, so the
finding did not reproduce. This leg pins it, and goes red when the resume
is removed.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
This commit is contained in:
xarmian
2026-09-30 08:52:35 -04:00
committed by GitHub
parent 5124a5f83d
commit c7cd363173
5 changed files with 419 additions and 25 deletions
@@ -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];
},
});
+6
View File
@@ -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:';
@@ -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<typeof createHeadlessEditor>;
// 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/* <topic>']) {
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', '<table><tbody><tr><td><ul><li><p>[[TASK-1]]</p></li></ul></td></tr></tbody></table>', [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');
});
});
@@ -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');
});
}
+150 -14
View File
@@ -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<string, st
* `item` is null when nothing resolves (or the match lacks a collection
* slug); `displayText` is what the caller should render either way.
*/
function resolveWikiBody(body: string, items: Item[]): { item: Item | null; displayText: string } {
function resolveWikiBody(body: string, items: Item[]): { item: Item | null; displayText: string; followsTitle: boolean } {
// Split optional display override on the FIRST unescaped pipe. We do
// this up-front so REF_PATTERN can check the key alone (a ref like
// "BUG-585" contains no pipe, so this is a no-op for ref storage).
@@ -700,7 +701,7 @@ function resolveWikiBody(body: string, items: Item[]): { item: Item | null; disp
if (REF_PATTERN.test(key.trim())) {
const byRef = findItemByRef(items, key.trim());
if (byRef && byRef.collection_slug) {
return { item: byRef, displayText: displayOverride ?? byRef.title };
return { item: byRef, displayText: displayOverride ?? byRef.title, followsTitle: displayOverride == null };
}
// Intentional fall-through to the legacy title lookups below.
}
@@ -712,7 +713,7 @@ function resolveWikiBody(body: string, items: Item[]): { item: Item | null; disp
const fullBody = unescapeWikiBody(body);
const fullTitleItem = items.find(i => 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<string, number[]>();
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<string, number>();
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[] {