feat: snip a region into chat, and make links clickable (#14)
This commit was merged in pull request #14.
This commit is contained in:
+190
-3
@@ -88,6 +88,35 @@ const OPENING_FENCE = /^(\s*)(`{3,}|~{3,})\s*([^\s`~]*)\s*$/;
|
||||
const CLOSING_FENCE = /^\s*(`{3,}|~{3,})\s*$/;
|
||||
const STANDALONE_CODE_SPAN = /^\s*`([^`\n]+)`\s*[.!]?\s*$/;
|
||||
|
||||
/**
|
||||
* A line that is nothing but a bare http(s) URL, give or take a bullet.
|
||||
*
|
||||
* Vicinae only makes a URL clickable when markdown presents it as a link. Its
|
||||
* parser attaches the `table` and `strikethrough` GFM extensions and nothing
|
||||
* else, so the `autolink` extension is off: a bare URL renders as plain dead
|
||||
* text. `MarkdownModel` turns `CMARK_NODE_LINK` into `<a href=...>`, and the
|
||||
* selection controller opens that anchor on release, so `[label](url)` is the
|
||||
* one form the user can actually click.
|
||||
*/
|
||||
const BARE_URL = /^https?:\/\/\S+$/;
|
||||
/** Sentence punctuation a model appends to a URL that is not part of it. */
|
||||
const TRAILING_SENTENCE_PUNCTUATION = /[.,;:!?]+$/;
|
||||
/** One inline code span that holds nothing but a URL. */
|
||||
const STANDALONE_URL_SPAN = /^`([^`\n]+)`[.!]?$/;
|
||||
/** A bullet or numbered-list marker, kept so a list of links stays a list. */
|
||||
const LIST_MARKER = /^([ \t]*(?:[-*+]|\d+[.)])[ \t]+)(.*)$/;
|
||||
|
||||
/** A GFM table row: it opens and closes with a pipe. */
|
||||
const TABLE_ROW = /^\s*\|.*\|\s*$/;
|
||||
/**
|
||||
* The delimiter row under a header.
|
||||
*
|
||||
* The test is not how many dashes there are, because GFM wants only one per
|
||||
* column. It is that the line could be nothing else: pipes, dashes, colons and
|
||||
* spaces, with at least one dash to tell it apart from a row of empty cells.
|
||||
*/
|
||||
const TABLE_DELIMITER_ROW = /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/;
|
||||
|
||||
function significantLines(code: string): string[] {
|
||||
return code
|
||||
.split("\n")
|
||||
@@ -272,12 +301,170 @@ export function labelCodeFences(markdown: string): string {
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
/** A URL with the sentence punctuation after it removed. */
|
||||
function asBareUrl(candidate: string): string | undefined {
|
||||
const trimmed = candidate.trim();
|
||||
if (!BARE_URL.test(trimmed)) return undefined;
|
||||
return trimmed.replace(TRAILING_SENTENCE_PUNCTUATION, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a URL as a markdown link, using the address itself as the label so the
|
||||
* destination stays readable rather than turning into a generic word.
|
||||
*
|
||||
* A destination containing parentheses (plenty of Wikipedia articles) has to
|
||||
* be wrapped in pointy brackets or the parser reads the first `)` as the end of
|
||||
* the destination and leaves the rest as trailing prose.
|
||||
*/
|
||||
function asLink(url: string): string {
|
||||
// Square brackets in the *label* would end the link text early, which is why
|
||||
// an IPv6 literal such as `http://[::1]/` needs them escaped. The destination
|
||||
// tolerates brackets, but not parentheses, which is what the pointy form is
|
||||
// for.
|
||||
const label = url.replace(/[[\]]/g, "\\$&");
|
||||
const destination = /[()]/.test(url) ? `<${url}>` : url;
|
||||
return `[${label}](${destination})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The URLs of a fence whose every non-empty line is one, or `undefined` when
|
||||
* the block is real code.
|
||||
*
|
||||
* Deliberately narrow: one line that is not a URL disqualifies the whole block,
|
||||
* so a `curl` invocation that happens to contain an address is never touched.
|
||||
*/
|
||||
function urlOnlyBlock(lines: string[]): string[] | undefined {
|
||||
const urls: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.length === 0) continue;
|
||||
|
||||
const url = asBareUrl(trimmed);
|
||||
if (!url) return undefined;
|
||||
urls.push(url);
|
||||
}
|
||||
|
||||
return urls.length > 0 ? urls : undefined;
|
||||
}
|
||||
|
||||
/** Rewrites a line that is only a URL, or only a span holding one, into a link. */
|
||||
function linkifyLine(line: string): string {
|
||||
const marker = LIST_MARKER.exec(line);
|
||||
const prefix = marker ? marker[1] : (line.match(/^[ \t]*/)?.[0] ?? "");
|
||||
const rest = marker ? marker[2] : line.slice(prefix.length);
|
||||
|
||||
const span = STANDALONE_URL_SPAN.exec(rest.trim());
|
||||
const url = asBareUrl(span ? span[1] : rest);
|
||||
|
||||
return url ? `${prefix}${asLink(url)}` : line;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns URLs the model wrote as code back into links.
|
||||
*
|
||||
* This is the reported bug: asked for a few GitHub projects, the model answers
|
||||
* with a fenced block of bare addresses. A fence renders through
|
||||
* `MdCodeBlock.qml` as a dark panel with a Copy button and no link handling at
|
||||
* all, so the one thing the user wants to do with a URL is the one thing they
|
||||
* cannot. The same applies to a lone `` `https://...` `` span.
|
||||
*
|
||||
* Three shapes are recovered, and only these:
|
||||
*
|
||||
* 1. A fence whose entire body is URLs.
|
||||
* 2. A line that is exactly one inline span holding a URL.
|
||||
* 3. A line that is exactly a URL, bulleted or not.
|
||||
*
|
||||
* Existing fences holding anything else are copied through untouched, so real
|
||||
* code is never rewritten.
|
||||
*/
|
||||
export function restoreUrlLinks(markdown: string): string {
|
||||
const lines = markdown.split("\n");
|
||||
const out: string[] = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < lines.length) {
|
||||
if (!OPENING_FENCE.test(lines[index])) {
|
||||
out.push(linkifyLine(lines[index]));
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const body: string[] = [];
|
||||
let cursor = index + 1;
|
||||
while (cursor < lines.length && !CLOSING_FENCE.test(lines[cursor])) {
|
||||
body.push(lines[cursor]);
|
||||
cursor += 1;
|
||||
}
|
||||
|
||||
const closing = cursor < lines.length;
|
||||
const urls = urlOnlyBlock(body);
|
||||
|
||||
if (urls) out.push(...urls.map(asLink));
|
||||
else {
|
||||
out.push(lines[index], ...body);
|
||||
if (closing) out.push(lines[cursor]);
|
||||
}
|
||||
|
||||
index = cursor + (closing ? 1 : 0);
|
||||
}
|
||||
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts the blank line a table needs to start at all.
|
||||
*
|
||||
* A GFM table cannot interrupt a paragraph, so a table written straight after a
|
||||
* line of prose is not a table: it renders as a paragraph of pipes and dashes,
|
||||
* which is how a perfectly good answer turns into noise. The blank line looks
|
||||
* unnecessary, so models leave it out.
|
||||
*
|
||||
* Only a real table start is touched, which is a row followed by a delimiter
|
||||
* row, so prose that happens to contain a pipe is left alone. Fences are copied
|
||||
* through untouched for the same reason, since a table shown as example code
|
||||
* must stay exactly as written.
|
||||
*/
|
||||
export function separateTables(markdown: string): string {
|
||||
const lines = markdown.split("\n");
|
||||
const out: string[] = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < lines.length) {
|
||||
if (OPENING_FENCE.test(lines[index])) {
|
||||
index = copyFence(lines, out, index);
|
||||
continue;
|
||||
}
|
||||
|
||||
const startsTable =
|
||||
TABLE_ROW.test(lines[index]) && index + 1 < lines.length && TABLE_DELIMITER_ROW.test(lines[index + 1]);
|
||||
|
||||
// Only when something would otherwise swallow it. A table already at a block
|
||||
// boundary, after a blank line or after a fenced block, needs nothing: the
|
||||
// fence has already ended the preceding block.
|
||||
const previous = out[out.length - 1];
|
||||
const atBlockBoundary =
|
||||
previous === undefined || previous.trim().length === 0 || CLOSING_FENCE.test(previous);
|
||||
|
||||
if (startsTable && !atBlockBoundary) out.push("");
|
||||
|
||||
out.push(lines[index]);
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* The full pre-render pass, in order.
|
||||
*
|
||||
* Promotion runs first so a promoted block is seen as an ordinary fence by the
|
||||
* labeller, which is also harmless when promotion already inferred a language.
|
||||
* Tables are separated first because they change block boundaries: a blank line
|
||||
* has to exist before anything else looks at the line. Links are restored next
|
||||
* so a recovered URL leaves no span for the promoter to fence again. Promotion
|
||||
* then runs before labelling, so a promoted block is seen as an ordinary fence
|
||||
* by the labeller, which is also harmless when promotion already inferred a
|
||||
* language.
|
||||
*/
|
||||
export function repairCodeBlocks(markdown: string): string {
|
||||
return labelCodeFences(promoteStandaloneCodeSpans(markdown));
|
||||
return labelCodeFences(promoteStandaloneCodeSpans(restoreUrlLinks(separateTables(markdown))));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user