mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-31 13:42:50 +00:00
fix(app): preserve parser-owned links and streaming tilde tails
This commit is contained in:
@@ -9,6 +9,24 @@ import {
|
||||
|
||||
const noFiles = () => false;
|
||||
describe("source-preserving prose", () => {
|
||||
it.each([
|
||||
[
|
||||
"https://example.com/?tags[]=a",
|
||||
"[https://example.com/?tags[]=a](<https://example.com/?tags%5B%5D=a>)",
|
||||
],
|
||||
["`src/[...]/file.ts`", "`src/[...]/file.ts`"],
|
||||
["[text []](https://example.com)", "[text []](https://example.com)"],
|
||||
["https://example.com/a*b*c", "[https://example.com/a](<https://example.com/a>)*b*c"],
|
||||
])("uses parser-owned link ranges: %s", (source, expected) => {
|
||||
expect(prepareProse([source], noFiles)).toBe(expected);
|
||||
});
|
||||
it.each([
|
||||
["Use ~", "Use "],
|
||||
["Use ~/", "Use \\~/"],
|
||||
["Use ~/.config", "Use \\~/.config"],
|
||||
])("keeps provisional tilde escapes attached: %s", (source, expected) => {
|
||||
expect(prepareProse([source], noFiles, true)).toBe(expected);
|
||||
});
|
||||
it.each([
|
||||
["Paths ~/.config and ~/.local are used.", "Paths \\~/.config and \\~/.local are used."],
|
||||
["~/a ~/b", "\\~/a \\~/b"],
|
||||
|
||||
@@ -276,7 +276,11 @@ function prepareSpan(context: InlinePreparation, span: InlineSpan) {
|
||||
preserveLinkScope(context, span, raw, link);
|
||||
} else if (code && raw.startsWith("`")) {
|
||||
prepareCode(context, span, raw, code);
|
||||
} else if (context.streaming && (raw.startsWith("[") || raw.startsWith("!["))) {
|
||||
} else if (
|
||||
context.streaming &&
|
||||
!contains(context.referenceRanges, span.start) &&
|
||||
(raw.startsWith("[") || raw.startsWith("!["))
|
||||
) {
|
||||
prepareUnfinishedLink(context, span, raw);
|
||||
} else if (
|
||||
context.streaming &&
|
||||
@@ -284,6 +288,7 @@ function prepareSpan(context: InlinePreparation, span: InlineSpan) {
|
||||
/^(?:\*{1,3}|_{1,3}|~{1,2}|`+)$/.test(raw)
|
||||
) {
|
||||
context.edits.push({ start: span.start, end: span.end, text: "" });
|
||||
protect(context.protectedRanges, span);
|
||||
}
|
||||
}
|
||||
function preserveUnresolvedReferences(context: InlinePreparation) {
|
||||
@@ -352,26 +357,51 @@ function prepareLinkifiedLinks(context: InlinePreparation) {
|
||||
if (!links.length) return;
|
||||
const destinations = new Map(links.map((link) => [link.attrGet("href"), link]));
|
||||
const { decoded, positions } = linkifySource(context);
|
||||
for (const match of context.parser.linkify.match(decoded) ?? []) {
|
||||
const start = positions[match.index]!;
|
||||
const end = positions[match.lastIndex]!;
|
||||
if (contains(context.referenceRanges, start)) continue;
|
||||
const link = destinations.get(context.parser.normalizeLink(match.url));
|
||||
if (!link) continue;
|
||||
const destination = link.attrGet("href")!;
|
||||
const payload: AssistantFileLinkSource = {
|
||||
href: destination,
|
||||
text: match.text,
|
||||
markup: "linkify",
|
||||
sourceInfo: "auto",
|
||||
};
|
||||
const href = context.canResolveCode(match.text)
|
||||
? FILE_LINK_SCHEME + encodeURIComponent(JSON.stringify(payload))
|
||||
: destination;
|
||||
insert(context, start, "[");
|
||||
insert(context, end, `](<${href}>)`);
|
||||
let cursor = 0;
|
||||
for (const text of context.trace.linkifyTexts) {
|
||||
if (!text) continue;
|
||||
let offset = decoded.indexOf(text, cursor);
|
||||
while (
|
||||
offset >= 0 &&
|
||||
contains(context.referenceRanges, positions[offset]!, positions[offset + text.length]!)
|
||||
)
|
||||
offset = decoded.indexOf(text, offset + 1);
|
||||
if (offset < 0) continue;
|
||||
cursor = offset + text.length;
|
||||
for (const match of context.parser.linkify.match(text) ?? []) {
|
||||
const link = destinations.get(context.parser.normalizeLink(match.url));
|
||||
if (!link) continue;
|
||||
wrapLinkifiedMatch(
|
||||
context,
|
||||
positions[offset + match.index]!,
|
||||
positions[offset + match.lastIndex]!,
|
||||
link.attrGet("href")!,
|
||||
match.text,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
function wrapLinkifiedMatch(
|
||||
context: InlinePreparation,
|
||||
start: number,
|
||||
end: number,
|
||||
destination: string,
|
||||
text: string,
|
||||
) {
|
||||
const payload: AssistantFileLinkSource = {
|
||||
href: destination,
|
||||
text,
|
||||
markup: "linkify",
|
||||
sourceInfo: "auto",
|
||||
};
|
||||
const href = context.canResolveCode(text)
|
||||
? FILE_LINK_SCHEME + encodeURIComponent(JSON.stringify(payload))
|
||||
: destination;
|
||||
protect(context.referenceRanges, { start, end });
|
||||
insert(context, start, "[");
|
||||
insert(context, end, `](<${href}>)`);
|
||||
}
|
||||
|
||||
function sourcePositions(
|
||||
token: Token,
|
||||
heading: Token | undefined,
|
||||
@@ -448,16 +478,20 @@ function compileBlock(
|
||||
task: tokens[index - 2]?.type === "list_item_open",
|
||||
edits: [],
|
||||
};
|
||||
// Link/code ownership is established before either references or provisional
|
||||
// brackets can be rewritten. Linkify uses the parser's emphasis-split text tokens.
|
||||
for (const span of trace.spans)
|
||||
if (span.tokens.some((child) => ["link_open", "code_inline", "image"].includes(child.type)))
|
||||
protect(context.referenceRanges, span);
|
||||
prepareLinkifiedLinks(context);
|
||||
for (const span of trace.spans) prepareSpan(context, span);
|
||||
if (trace.trim) {
|
||||
const tail = { start: token.content.length - trace.trim, end: token.content.length };
|
||||
context.edits.push({ ...tail, text: "" });
|
||||
protect(context.protectedRanges, tail);
|
||||
}
|
||||
preserveUnresolvedReferences(context);
|
||||
prepareText(context);
|
||||
prepareLinkifiedLinks(context);
|
||||
if (trace.trim)
|
||||
context.edits.push({
|
||||
start: token.content.length - trace.trim,
|
||||
end: token.content.length,
|
||||
text: "",
|
||||
});
|
||||
if (trace.suffix) insert(context, token.content.length, trace.suffix);
|
||||
const positions = sourcePositions(token, heading, lines, lineOffsets);
|
||||
for (const edit of context.edits)
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface InlineSpan {
|
||||
}
|
||||
export interface InlineTrace {
|
||||
spans: InlineSpan[];
|
||||
linkifyTexts: string[];
|
||||
suffix: string;
|
||||
trim: number;
|
||||
}
|
||||
@@ -21,7 +22,7 @@ export function createSourceParser(streaming: boolean) {
|
||||
const inlineTraces = new WeakMap<Token[], InlineTrace>();
|
||||
const parseInline = parser.inline.parse.bind(parser.inline);
|
||||
parser.inline.parse = (source, md, env, output) => {
|
||||
const trace: InlineTrace = { spans: [], suffix: "", trim: 0 };
|
||||
const trace: InlineTrace = { spans: [], linkifyTexts: [], suffix: "", trim: 0 };
|
||||
parseInline(source, md, { ...env, [TRACE]: trace }, output);
|
||||
inlineTraces.set(output, trace);
|
||||
};
|
||||
@@ -70,8 +71,15 @@ export function createSourceParser(streaming: boolean) {
|
||||
}
|
||||
parser.core.ruler.after("inline", "prose_inline_traces", (state) => {
|
||||
for (const token of state.tokens) {
|
||||
if (token.type === "inline" && token.children)
|
||||
traces.set(token, inlineTraces.get(token.children)!);
|
||||
if (token.type !== "inline" || !token.children) continue;
|
||||
const trace = inlineTraces.get(token.children)!;
|
||||
let linkDepth = 0;
|
||||
for (const child of token.children) {
|
||||
if (child.type === "link_open") linkDepth++;
|
||||
if (child.type === "link_close") linkDepth--;
|
||||
if (child.type === "text" && linkDepth === 0) trace.linkifyTexts.push(child.content);
|
||||
}
|
||||
traces.set(token, trace);
|
||||
}
|
||||
});
|
||||
return { parser, traces };
|
||||
|
||||
Reference in New Issue
Block a user