fix: raise citation markers and stop adjacent ones merging (#22)

This commit was merged in pull request #22.
This commit is contained in:
2026-10-07 08:19:58 +00:00
parent fcc6d26c9d
commit 0705d5ec2f
7 changed files with 103 additions and 25 deletions
+43 -7
View File
@@ -104,13 +104,46 @@ export interface CitationResult {
}
/**
* A citation marker the model wrote, in the two shapes it reaches for.
* One citation marker, in the two shapes a model reaches for, plus any markers
* written straight after it.
*
* Adjacent markers are matched as a single run, because a raised digit carries no
* separator of its own: `[1][2]` drawn as `¹²` reads as twelve, or as twenty one, and
* either way as one number rather than as two sources.
*
* The trailing guards are what keep this off markdown the model wrote on purpose:
* `](` starts a link, and `]:` is a reference definition. The digit list allows
* `[1, 3]`, which is how a sentence resting on two sources is usually written.
* `[1, 3]`, which is how a sentence resting on two sources is often written.
*/
const CITATION_MARKER = /\[\^?(\d+(?:\s*,\s*\d+)*)\](?![(:])/g;
const CITATION_RUN = /\[\^?\d+(?:\s*,\s*\d+)*\](?![(:])(?:[ \t]*\[\^?\d+(?:\s*,\s*\d+)*\](?![(:]))*/g;
/** The numbers inside one run, in the order they were written. */
const RUN_NUMBERS = /\d+/g;
/** The digits a superscript marker is built from, in order. */
const DIGITS = "0123456789";
const SUPERSCRIPT = "⁰¹²³⁴⁵⁶⁷⁸⁹";
/**
* A number as a superscript, which is what a citation mark is.
*
* Unicode rather than a `<sup>` tag on purpose. This is the label of a markdown
* link, and a plain label cannot be broken by any part of the pipeline, while
* inline HTML in a label asks two renderers to agree about something neither this
* extension nor its tests can see. The glyphs are raised by design, so the mark
* still reads as a superscript even when a font lacks the rarer ones and Qt falls
* back for them.
*/
function superscript(index: number): string {
let out = "";
for (const digit of String(index)) {
const at = DIGITS.indexOf(digit);
out += at >= 0 ? SUPERSCRIPT[at] : digit;
}
return out;
}
/**
* A footnote definition line, which is `[^1]: title, url` on a line of its own.
@@ -165,7 +198,8 @@ function stripDefinitions(text: string, known: Set<number>): string {
}
/**
* `[1]`, `[^1]` and `[1, 3]` as links, plus the numbers that became links.
* `[1]`, `[^1]`, `[1, 3]` and `[1][2]` as superscript links, plus the numbers that
* became links.
*
* One run of prose only: fences are the caller's business, because the caller
* already walks the document in segments for the maths pass and there is no
@@ -179,8 +213,8 @@ export function linkCitations(text: string, sources: Source[]): CitationResult {
const prose = stripDefinitions(text, new Set(byIndex.keys()));
const linked = mapOutsideCodeSpans(prose, (chunk) =>
chunk.replace(CITATION_MARKER, (whole: string, digits: string) => {
const wanted = digits.split(",").map((part) => Number(part.trim()));
chunk.replace(CITATION_RUN, (whole: string) => {
const wanted = [...whole.matchAll(RUN_NUMBERS)].map((match) => Number(match[0]));
const links: string[] = [];
for (const index of wanted) {
@@ -190,10 +224,12 @@ export function linkCitations(text: string, sources: Source[]): CitationResult {
// marker that is partly a link reads as a citation that works.
if (!href) return whole;
links.push(markdownLink(`[${index}]`, href));
links.push(markdownLink(superscript(index), href));
}
for (const index of wanted) cited.add(index);
// Separated even when the model wrote them touching: `¹, ²` is two citations,
// where `¹²` is a number, and the reader has no way to tell it from a real one.
return links.join(", ");
}),
);