hello");
+ });
+
+ it("leaves a block holding an image alone, because that path loads it", () => {
+ expect(renderModelOutput('
')).toBe(
+ '
',
+ );
+ });
+
+ it("leaves a lone inline tag alone, because it is not a block", () => {
+ const markdown = "Try
bold for that.";
+ expect(renderModelOutput(markdown)).toBe(markdown);
+ });
+
+ it("keeps an answer that has nothing to render exactly as written", () => {
+ const plain = "Just a sentence, with a [link](https://example.com) and `code`.";
+ expect(renderModelOutput(plain)).toBe(plain);
+ });
+
+ it("repairs fences in model output but not in what a person typed", () => {
+ // A lone command is a fence when a model writes it, and a question about it
+ // is left as the person typed it.
+ expect(renderModelOutput("`pwd`")).toBe("```bash\npwd\n```");
+ expect(renderInlineContent("`pwd`")).toBe("`pwd`");
+ });
+
+ it("still renders maths and markup in what a person typed", () => {
+ expect(renderInlineContent("what is $x^2$?")).toBe("what is x²?");
+ expect(renderInlineContent("hi")).toBe("hi");
+ expect(renderInlineContent("```ts\nconst x = $a$;\n```")).toBe("```ts\nconst x = $a$;\n```");
+ });
+});
diff --git a/src/lib/code.ts b/src/lib/code.ts
index 429bc36..718b7ef 100644
--- a/src/lib/code.ts
+++ b/src/lib/code.ts
@@ -82,10 +82,14 @@ const FENCE = "```";
* bare ``` are the same two lines apart from context, so a pattern that only
* matched bare fences treated every closing fence as a new opening one and
* swallowed the rest of the answer.
+ *
+ * Exported because the maths and HTML passes have to skip fenced code for the
+ * same reason this file does, and a second copy of the pattern is a second
+ * place to get it wrong.
*/
-const OPENING_FENCE = /^(\s*)(`{3,}|~{3,})\s*([^\s`~]*)\s*$/;
+export const OPENING_FENCE = /^(\s*)(`{3,}|~{3,})\s*([^\s`~]*)\s*$/;
/** A closing fence is the marker and nothing else. */
-const CLOSING_FENCE = /^\s*(`{3,}|~{3,})\s*$/;
+export const CLOSING_FENCE = /^\s*(`{3,}|~{3,})\s*$/;
const STANDALONE_CODE_SPAN = /^\s*`([^`\n]+)`\s*[.!]?\s*$/;
/**
@@ -107,7 +111,7 @@ const STANDALONE_URL_SPAN = /^`([^`\n]+)`[.!]?$/;
const LIST_MARKER = /^([ \t]*(?:[-*+]|\d+[.)])[ \t]+)(.*)$/;
/** A GFM table row: it opens and closes with a pipe. */
-const TABLE_ROW = /^\s*\|.*\|\s*$/;
+export const TABLE_ROW = /^\s*\|.*\|\s*$/;
/**
* The delimiter row under a header.
*
@@ -115,7 +119,72 @@ const TABLE_ROW = /^\s*\|.*\|\s*$/;
* 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*$/;
+export const TABLE_DELIMITER_ROW = /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/;
+
+/**
+ * A run of backticks, and where the span it opens ends.
+ *
+ * The run length is part of the delimiter in markdown, and `` `a` `` inside a
+ * double-backtick span is content rather than a closer, so the search is for a
+ * run of exactly the same length. A run with no closer is literal text, so only
+ * the backticks are consumed.
+ */
+export function readCodeSpan(text: string, start: number): { raw: string; end: number } {
+ let length = 0;
+ while (text[start + length] === "`") length += 1;
+
+ const marker = "`".repeat(length);
+ let cursor = start + length;
+
+ while (cursor < text.length) {
+ const found = text.indexOf(marker, cursor);
+ if (found === -1) break;
+
+ if (text[found + length] === "`") {
+ cursor = found + length;
+ while (text[cursor] === "`") cursor += 1;
+ continue;
+ }
+
+ return { raw: text.slice(start, found + length), end: found + length };
+ }
+
+ return { raw: marker, end: start + length };
+}
+
+/**
+ * Applies `transform` to everything outside a code span, leaving the spans
+ * exactly as they were written.
+ *
+ * Every pass that rewrites a message needs this for the same reason: a
+ * `` `x^2` `` or a `` `
` `` is something being shown to the reader, not
+ * something to convert. Shared so the rule for what a span is exists once.
+ */
+export function mapOutsideCodeSpans(text: string, transform: (text: string) => string): string {
+ let out = "";
+ let index = 0;
+ let start = 0;
+ let found = false;
+
+ while (index < text.length) {
+ if (text[index] !== "`") {
+ index += 1;
+ continue;
+ }
+
+ found = true;
+ const span = readCodeSpan(text, index);
+ out += transform(text.slice(start, index));
+ out += span.raw;
+ index = span.end;
+ start = index;
+ }
+
+ if (!found) return transform(text);
+
+ out += transform(text.slice(start));
+ return out;
+}
function significantLines(code: string): string[] {
return code
@@ -308,22 +377,30 @@ function asBareUrl(candidate: string): string | undefined {
return trimmed.replace(TRAILING_SENTENCE_PUNCTUATION, "");
}
+/**
+ * A markdown link, escaped the way the parser needs it.
+ *
+ * Shared with the HTML pass, which rewrites an `` into the one shape
+ * Vicinae makes clickable. Square brackets in the *label* would end the link
+ * text early, and 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.
+ */
+export function markdownLink(label: string, destination: string): string {
+ const escaped = label.replace(/[[\]]/g, "\\$&");
+ const target = /[()\s]/.test(destination) ? `<${destination}>` : destination;
+ return `[${escaped}](${target})`;
+}
+
/**
* 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})`;
+ // An IPv6 literal such as `http://[::1]/` carries the brackets in the label,
+ // which `markdownLink` escapes. The destination tolerates brackets but not
+ // parentheses, which is what the pointy form is for.
+ return markdownLink(url, url);
}
/**
@@ -455,16 +532,172 @@ export function separateTables(markdown: string): string {
return out.join("\n");
}
+/**
+ * Fence languages that draw a label but no colour, and what to say instead.
+ *
+ * `syntax::highlight` in Vicinae resolves a language with
+ * `definitionForName(language)` and then `definitionForFileName("file." + language)`,
+ * so a name KSyntaxHighlighting has never heard of still gets printed above the
+ * block while producing no highlighting at all. A model reaching for "terminal"
+ * or "console" is describing a shell, so it is told a name that resolves. The
+ * `undefined` entries are the ones that carry no information whatsoever, and a
+ * bare fence is the honest rendering of those.
+ */
+const LANGUAGE_ALIASES: Record = {
+ console: "bash",
+ terminal: "bash",
+ shell: "bash",
+ commandline: "bash",
+ "command-line": "bash",
+ none: undefined,
+ output: undefined,
+ plain: undefined,
+ plaintext: undefined,
+ raw: undefined,
+ text: undefined,
+};
+
+/**
+ * Rewrites a fence label the renderer would print but not colour.
+ *
+ * A label the model chose is otherwise left alone: this only touches names that
+ * are known not to resolve.
+ */
+export function normalizeFenceLabels(markdown: string): string {
+ const lines = markdown.split("\n");
+ const out: string[] = [];
+ let index = 0;
+
+ while (index < lines.length) {
+ const opener = OPENING_FENCE.exec(lines[index]);
+ if (!opener) {
+ out.push(lines[index]);
+ index += 1;
+ continue;
+ }
+
+ const [, indent, marker, existing] = opener;
+ const key = existing.toLowerCase();
+
+ if (existing && key in LANGUAGE_ALIASES) {
+ const replacement = LANGUAGE_ALIASES[key];
+ out.push(replacement ? `${indent}${marker}${replacement}` : `${indent}${marker}`);
+ } else {
+ out.push(lines[index]);
+ }
+
+ index += 1;
+ while (index < lines.length && !CLOSING_FENCE.test(lines[index])) {
+ out.push(lines[index]);
+ index += 1;
+ }
+
+ if (index < lines.length) {
+ out.push(lines[index]);
+ index += 1;
+ }
+ }
+
+ return out.join("\n");
+}
+
+/** Box drawing and block elements, which is what a diagram is drawn with here. */
+const BOX_DRAWING = /[\u2500-\u257f\u2580-\u259f]/g;
+
+/**
+ * Whether a line is part of a picture rather than part of a sentence.
+ *
+ * Two box-drawing characters is the clear signal. The ASCII spelling of the same
+ * thing is a line that starts and ends with a border character, which is how a
+ * hand-written `+---+---+` table or an `| a | b |` row begins.
+ */
+function isArtLine(line: string): boolean {
+ const trimmed = line.trim();
+ if (trimmed.length === 0) return false;
+
+ if ((trimmed.match(BOX_DRAWING)?.length ?? 0) >= 2) return true;
+
+ return /^[+|].*[+|]$/.test(trimmed);
+}
+
+/**
+ * Whether a paragraph is a picture that has to keep its alignment.
+ *
+ * Deliberately cautious, because fencing prose is worse than leaving a diagram
+ * to reflow: at least two art lines are required and they have to make up half
+ * the paragraph, a real GFM table is handed to the table renderer instead, and
+ * an indented block is already a code block in markdown.
+ */
+function isAsciiArt(paragraph: string[]): boolean {
+ if (paragraph.length < 2) return false;
+
+ const isTable = paragraph.some(
+ (line, index) => TABLE_ROW.test(line) && TABLE_DELIMITER_ROW.test(paragraph[index + 1] ?? ""),
+ );
+ if (isTable) return false;
+
+ if (paragraph.some((line) => /^\s{4,}\S/.test(line))) return false;
+
+ const art = paragraph.filter(isArtLine).length;
+ return art >= 2 && art * 2 >= paragraph.length;
+}
+
+/**
+ * Fences a diagram that was written as plain text.
+ *
+ * A directory tree or a boxed diagram is drawn with spaces that carry the
+ * meaning, and a paragraph reflows them: `MdParagraph.qml` wraps, so the
+ * columns of an ASCII table land wherever the pane width puts them and the
+ * picture arrives scrambled. A fence is monospace and scrolls instead of
+ * wrapping, which is the one rendering that keeps the drawing intact.
+ */
+export function fenceAsciiArt(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;
+ }
+
+ // A paragraph is the run of non-blank lines, which is the unit that has to
+ // stay together: half a tree in a fence is no better than none of it.
+ let end = index;
+ while (end < lines.length && lines[end].trim().length > 0 && !OPENING_FENCE.test(lines[end])) end += 1;
+
+ const paragraph = lines.slice(index, end);
+ if (isAsciiArt(paragraph)) out.push(FENCE, ...paragraph, FENCE);
+ else out.push(...paragraph);
+
+ if (end === index) {
+ // A blank line, which is already a block boundary.
+ out.push(lines[end]);
+ index = end + 1;
+ continue;
+ }
+
+ index = end;
+ }
+
+ return out.join("\n");
+}
+
/**
* The full pre-render pass, in order.
*
* 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.
+ * has to exist before anything else looks at the line. Diagrams are fenced next,
+ * so every pass after this one sees them as code. Links are restored third 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.
+ * Labels are normalised last, which is the only pass that looks at a fence the
+ * model wrote itself.
*/
export function repairCodeBlocks(markdown: string): string {
- return labelCodeFences(promoteStandaloneCodeSpans(restoreUrlLinks(separateTables(markdown))));
+ return normalizeFenceLabels(
+ labelCodeFences(promoteStandaloneCodeSpans(restoreUrlLinks(fenceAsciiArt(separateTables(markdown))))),
+ );
}
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index 4962ec8..274cf61 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -80,6 +80,10 @@ Lead with the answer, then the detail. This renders in a narrow window, so stay
Use the markdown this window actually draws rather than flattening everything into prose. It renders tables, bulleted and numbered lists, headings, blockquotes, bold, italics, inline code and fenced code, so pick the shape that fits. A table is the right shape for comparing three or more things across the same few attributes, so write the table itself: never hand back a table as a bulleted list of rows, and never describe a table you could simply write. Keep it to about four narrow columns so it fits, give it a header row, and leave a blank line before it. A list is for steps or for independent items. Reach for a heading only when an answer genuinely has sections, since at this size a heading usually costs a line and buys nothing.
+Maths is drawn, so write it as LaTeX rather than as flattened text: $x^2$ inside a sentence, and a formula worth a line of its own written as $$...$$. Fractions, roots, sums, integrals, products, Greek letters, and subscripts and superscripts all render. Stacked notation does not, because the window is narrow, so \\frac{a}{b} is drawn as a/b and a matrix comes out linear. Prefer a formula that reads on one line, and keep whole formulas out of code fences.
+
+A small amount of HTML is drawn too, for the rare case where a visual carries it better than a sentence. Bold, italic, underline, strike, sub, sup and a span with a colour or a size all work, for example new, and a small HTML table is fine. Reach for that only when it earns its place, a status chip or a tiny grid, never to wrap an answer, and still write a comparison as a markdown table. A diagram drawn with characters is fine, but fence it so the columns stay monospaced.
+
Write plainly:
- Never use an em dash. Use a comma, a colon, or a full stop.
- Skip stock service-desk phrasing: "Certainly", "I'd be happy to help", "Great question", "I hope this helps", "Let me know if you need anything else". Warmth that means something beats politeness that means nothing.
diff --git a/src/lib/html.ts b/src/lib/html.ts
new file mode 100644
index 0000000..2afa1eb
--- /dev/null
+++ b/src/lib/html.ts
@@ -0,0 +1,450 @@
+import { mapOutsideCodeSpans, markdownLink } from "./code";
+
+/**
+ * Custom HTML rendering for the detail panel, and the fence around it.
+ *
+ * Raw HTML is not merely tolerated by Vicinae's markdown renderer, it is the
+ * only route to anything beyond what GFM offers. `markdown-model.cpp` passes
+ * `CMARK_NODE_HTML_INLINE` through unescaped, and `MdParagraph.qml` hands the
+ * result to a `TextEdit` with `textFormat: RichText`, so a `` with a
+ * colour, a ``, or a small `` really does draw. That is the
+ * extension point for a model that wants to show a status chip or a compact
+ * comparison instead of describing one.
+ *
+ * It is also a hole, because the same route carries whatever else the model
+ * writes: a `