Keep streamed result file links intact
This commit is contained in:
parent
086c58fcc3
commit
d9b06cf0b3
@ -1,11 +1,11 @@
|
||||
/** A half-open span containing a complete Markdown image or linked image. */
|
||||
/** A half-open span containing a complete Markdown link or image. */
|
||||
export type MarkdownImageRange = { start: number; end: number };
|
||||
|
||||
type Parsed = { kind: "complete"; end: number } | { kind: "pending" } | { kind: "invalid" };
|
||||
|
||||
// This scanner recognizes syntax only; rendering and URL safety remain the
|
||||
// responsibility of the existing Markdown renderer and image splitter.
|
||||
function readDestination(text: string, start: number): Parsed {
|
||||
function readDestination(text: string, start: number, allowWhitespace = false): Parsed {
|
||||
if (start >= text.length) return { kind: "pending" };
|
||||
if (text[start] !== "(") return { kind: "invalid" };
|
||||
let depth = 1;
|
||||
@ -16,7 +16,7 @@ function readDestination(text: string, start: number): Parsed {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (/\s/.test(char)) return { kind: "invalid" };
|
||||
if (char === "\n" || char === "\r" || (!allowWhitespace && /\s/.test(char))) return { kind: "invalid" };
|
||||
if (char === "(") depth += 1;
|
||||
if (char === ")" && --depth === 0) return { kind: "complete", end: i + 1 };
|
||||
}
|
||||
@ -47,7 +47,37 @@ function readImage(text: string, start: number, wrapped: boolean): Parsed {
|
||||
return { kind: "pending" };
|
||||
}
|
||||
|
||||
/** Locate completed image syntax and a potentially incomplete trailing image.
|
||||
function readLink(text: string, start: number): Parsed {
|
||||
let depth = 1;
|
||||
for (let i = start + 1; i < text.length; i += 1) {
|
||||
const char = text[i];
|
||||
// An unfinished bracket on an earlier line must not hide later output.
|
||||
if (char === "\n" || char === "\r") return { kind: "invalid" };
|
||||
if (char === "\\") {
|
||||
if (i + 1 >= text.length) return { kind: "pending" };
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === "[") depth += 1;
|
||||
if (char !== "]" || --depth !== 0) continue;
|
||||
if (text[i + 1] === "[") {
|
||||
for (let j = i + 2; j < text.length; j += 1) {
|
||||
if (text[j] === "\n" || text[j] === "\r") return { kind: "invalid" };
|
||||
if (text[j] === "\\") {
|
||||
if (j + 1 >= text.length) return { kind: "pending" };
|
||||
j += 1;
|
||||
continue;
|
||||
}
|
||||
if (text[j] === "]") return { kind: "complete", end: j + 1 };
|
||||
}
|
||||
return { kind: "pending" };
|
||||
}
|
||||
return readDestination(text, i + 1, true);
|
||||
}
|
||||
return { kind: "pending" };
|
||||
}
|
||||
|
||||
/** Locate complete Markdown links/images and an incomplete trailing construct.
|
||||
* Code spans/fences and escaped markers remain normal text. */
|
||||
export function scanMarkdownImageStreaming(text: string): {
|
||||
ranges: MarkdownImageRange[];
|
||||
@ -83,8 +113,9 @@ export function scanMarkdownImageStreaming(text: string): {
|
||||
const char = text[cursor];
|
||||
if (char !== "[" && char !== "!") { cursor += 1; continue; }
|
||||
const wrapped = char === "[";
|
||||
if (wrapped && cursor + 1 < text.length && text[cursor + 1] !== "!") { cursor += 1; continue; }
|
||||
const parsed = readImage(text, cursor, wrapped);
|
||||
const parsed = wrapped && text[cursor + 1] !== "!"
|
||||
? readLink(text, cursor)
|
||||
: readImage(text, cursor, wrapped);
|
||||
if (parsed.kind === "pending") return { ranges, pendingStart: cursor };
|
||||
if (parsed.kind === "complete") { ranges.push({ start: cursor, end: parsed.end }); cursor = parsed.end; }
|
||||
else cursor += 1;
|
||||
@ -92,7 +123,7 @@ export function scanMarkdownImageStreaming(text: string): {
|
||||
return { ranges, pendingStart: null };
|
||||
}
|
||||
|
||||
/** During active delivery, withhold only unfinished image syntax at the tail. */
|
||||
/** During active delivery, withhold only unfinished link/image syntax at the tail. */
|
||||
export function stableStreamingMarkdown(text: string, streaming: boolean): string {
|
||||
if (!streaming) return text;
|
||||
const { pendingStart } = scanMarkdownImageStreaming(text);
|
||||
@ -101,7 +132,7 @@ export function stableStreamingMarkdown(text: string, streaming: boolean): strin
|
||||
|
||||
export function advancePastAtomicRanges(length: number, target: number, ranges: MarkdownImageRange[]) {
|
||||
let position = Math.min(length, target);
|
||||
// Overlapping table/image ranges can require more than one jump.
|
||||
// Overlapping table/link ranges can require more than one jump.
|
||||
for (let changed = true; changed;) {
|
||||
changed = false;
|
||||
for (const range of ranges) {
|
||||
|
||||
@ -63,3 +63,42 @@ test("coalesces overlapping typewriter table and image ranges without looping",
|
||||
assert.equal(advancePastAtomicRanges(50, 36, ranges), 40);
|
||||
assert.equal(advancePastAtomicRanges(50, 50, ranges), 50);
|
||||
});
|
||||
|
||||
test("SKU result links are atomic across every typewriter cursor and live fragment", () => {
|
||||
const links = [
|
||||
"[beike-sku-template2-batch.json](https://scqny.jiansun.vip/ps-skill-api/results/job_test/795c72cb-batch.json)",
|
||||
"[beike-sku-template2-report.json](https://scqny.jiansun.vip/ps-skill-api/results/job_test/af588253-report.json)",
|
||||
];
|
||||
const text = `生成图\n${wrapped}\n可编辑文件\n- ${links[0]}\n- ${links[1]}\n完成`;
|
||||
const ranges = scanMarkdownImageStreaming(text).ranges;
|
||||
assert.equal(ranges.length, 3);
|
||||
for (const link of links) {
|
||||
const start = text.indexOf(link);
|
||||
for (let n = 1; n < link.length; n += 1) {
|
||||
const prefix = text.slice(0, start + n);
|
||||
assert.equal(stableStreamingMarkdown(prefix, true), text.slice(0, start), `link prefix ${n}`);
|
||||
}
|
||||
assert.equal(stableStreamingMarkdown(text.slice(0, start + link.length), true), text.slice(0, start + link.length));
|
||||
}
|
||||
let cursor = 0;
|
||||
while (cursor < text.length) {
|
||||
cursor = advancePastAtomicRanges(text.length, cursor + 1, ranges);
|
||||
assert.ok(ranges.every((range) => cursor <= range.start || cursor >= range.end), `split at ${cursor}`);
|
||||
}
|
||||
assert.equal(cursor, text.length);
|
||||
});
|
||||
|
||||
test("ordinary brackets, code, escaped markers and unfinished final links remain available", () => {
|
||||
for (const value of ["普通文字", "[状态] 已完成", "[引用][reference]", "\\[转义](https://example.com)", "`[code](https://example.com`", "~~~md\n[example](https://example.com\n~~~\n"]) {
|
||||
assert.equal(stableStreamingMarkdown(value, true), value);
|
||||
assert.equal(stableStreamingMarkdown(value, false), value);
|
||||
}
|
||||
for (const partial of ["[状态", "[文件](https://example.com/", "[文件]"]) {
|
||||
assert.equal(stableStreamingMarkdown(partial, false), partial);
|
||||
assert.equal(stableStreamingMarkdown(partial + "\n普通文字", true), partial + "\n普通文字");
|
||||
}
|
||||
const titleLink = '[文档](https://example.com/a "标题")';
|
||||
assert.deepEqual(scanMarkdownImageStreaming(titleLink).ranges, [{ start: 0, end: titleLink.length }]);
|
||||
const unsafeLink = "[危险链接](javascript:alert(1))";
|
||||
assert.equal(stableStreamingMarkdown(unsafeLink, false), unsafeLink);
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user