Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions cli/templates/files/ai-agent/app/latex-delimiters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/**
* Normalise LaTeX delimiters before Markdown parsing.
*
* Two problems make this a pre-parse pass rather than a plugin.
*
* First, `\(` and `\[` cannot survive the Markdown parser. CommonMark treats a
* backslash before ASCII punctuation as a character escape, so `\(x\)` reaches
* the syntax tree as plain `(x)`. By the time any remark or rehype plugin runs,
* the delimiters are already gone, and a KaTeX DOM pass has nothing to match.
*
* Second, `$` cannot be trusted as a delimiter in this content. Assistant
* answers are full of currency: "18% tip on $84.50 ... totals $99.71". Enabling
* single dollar math against that text turns the prose between two amounts into
* a formula. So every dollar sign that the author wrote is escaped first, and
* the only unescaped dollars left in the document are the delimiters introduced
* here. That is what makes `singleDollarTextMath` safe downstream.
*
* Code is never rewritten. Fenced blocks and inline spans are copied through
* untouched, so a snippet containing `$` or `\(` keeps its exact source.
*/

/** A half-open source range that must be copied through verbatim. */
interface CodeSpan {
readonly start: number;
readonly end: number;
}

const FENCE_RE = /^[ \t]*(`{3,}|~{3,})/;

/**
* Locate fenced code blocks.
*
* A fence closes on the first later fence of the same character that is at
* least as long as the opener. An unterminated fence runs to end of input,
* matching how CommonMark treats it, so trailing text is not rewritten as if it
* were prose.
*/
function findFencedSpans(source: string): CodeSpan[] {
const spans: CodeSpan[] = [];
const lines = source.split("\n");
let offset = 0;
let open: { marker: string; start: number } | undefined;

for (const line of lines) {
const match = FENCE_RE.exec(line);
if (open === undefined) {
if (match?.[1]) open = { marker: match[1], start: offset };
} else if (
match?.[1] &&
match[1][0] === open.marker[0] &&
match[1].length >= open.marker.length
) {
spans.push({ start: open.start, end: offset + line.length });
open = undefined;
}
Comment thread
kojiwakayama marked this conversation as resolved.
Outdated
offset += line.length + 1;
}

if (open !== undefined) spans.push({ start: open.start, end: source.length });
return spans;
}

/** Locate inline code spans, skipping any that fall inside a fenced block. */
function findInlineSpans(source: string, fenced: readonly CodeSpan[]): CodeSpan[] {
const spans: CodeSpan[] = [];
const inFence = (index: number) => fenced.some((s) => index >= s.start && index < s.end);

let index = 0;
while (index < source.length) {
if (source[index] !== "`" || inFence(index)) {
index += 1;
continue;
}
let run = 0;
while (source[index + run] === "`") run += 1;
const marker = "`".repeat(run);
const close = source.indexOf(marker, index + run);
// An unclosed run is literal text, not a span, so leave it to be rewritten.
if (close === -1) {
index += run;
continue;
}
spans.push({ start: index, end: close + run });
index = close + run;
}

return spans;
}

const MATH_RE = /\\\[([\s\S]*?)\\\]|\\\(([\s\S]*?)\\\)/g;

/** Outside maths a dollar is currency, so hide it from the math tokenizer. */
function escapeProseDollars(text: string): string {
return text.replace(/\$/g, "\\$");
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

/**
* Inside maths a dollar has to stop being a dollar character entirely.
*
* `remark-math` closes an inline expression at the first `$` it meets and does
* not honour a backslash escape, so `\textbf{ \$99.71 }` silently fails to
* render rather than producing a dollar sign. `\textdollar` is the TeX spelling
* that survives, and the braces keep it from swallowing a following digit.
*/
function convertMathDollars(body: string): string {
return body.replace(/\$/g, "{\\textdollar}");
}

/** Promote LaTeX delimiters to remark-math syntax, per region. */
function convertProse(text: string): string {
let result = "";
let cursor = 0;

for (const match of text.matchAll(MATH_RE)) {
const index = match.index ?? 0;
result += escapeProseDollars(text.slice(cursor, index));
const display = match[1] !== undefined;
const body = convertMathDollars(display ? match[1] : (match[2] ?? ""));
result += display ? `$$${body}$$` : `$${body}$`;
cursor = index + match[0].length;
}

result += escapeProseDollars(text.slice(cursor));
return result;
}

/**
* Rewrite `\(inline\)` and `\[display\]` into `$inline$` and `$$display$$`,
* leaving every code region and every author-written `$` untouched.
*/
export function normalizeLatexDelimiters(source: string): string {
const fenced = findFencedSpans(source);
const protectedSpans = [...fenced, ...findInlineSpans(source, fenced)]
.sort((a, b) => a.start - b.start);

let result = "";
let cursor = 0;
for (const span of protectedSpans) {
if (span.start < cursor) continue;
result += convertProse(source.slice(cursor, span.start));
result += source.slice(span.start, span.end);
cursor = span.end;
}
result += convertProse(source.slice(cursor));
return result;
}
13 changes: 13 additions & 0 deletions cli/templates/files/ai-agent/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,19 @@ export default function RootLayout({
<Head>
<title>Assistant</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
{/*
KaTeX ships its glyphs as ~60 font files referenced by relative URL
from this stylesheet, so it is loaded from the published package
rather than bundled: that keeps the fonts resolvable. The version is
pinned to the `katex` dependency the renderer draws with, and the
integrity hash is checked because this is a third-party fetch.
*/}
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/katex@0.18.1/dist/katex.min.css"
integrity="sha384-1vdNCNel6Tx/NQa8IR1mGOGKsbGreCkOPfbtPPnUURJ5Tu2PRVfQ/7KLZC+Pi1p1"
crossOrigin="anonymous"
/>
</Head>
{children}
</>
Expand Down
57 changes: 56 additions & 1 deletion cli/templates/files/ai-agent/app/markdown-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,70 @@

import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import katex from "katex";
import type { MarkdownRendererProps } from "veryfront/markdown";
import { normalizeLatexDelimiters } from "./latex-delimiters.ts";

/**
* Render one `remark-math` node with KaTeX.
*
* `rehype-katex` is deliberately not used. It reparses KaTeX's output through
* `hast-util-from-html-isomorphic`, which selects its browser branch under this
* runtime and fails server-side with "DOMParser is not defined", taking the
* whole page render down with it. `katex.renderToString` is pure string work
* with no DOM, so it is safe in SSR and in the browser alike.
*
* The markup is trusted because KaTeX generated it: input is escaped during
* rendering, `trust` defaults to false so `\href` and friends stay inert, and
* `throwOnError: false` turns a bad expression into a visible KaTeX error
* instead of a failed render.
*/
function MathCode(
{ className, children, ...props }: {
className?: string;
children?: React.ReactNode;
[key: string]: unknown;
},
): React.JSX.Element {
Comment thread
kojiwakayama marked this conversation as resolved.
Outdated
const classes = className ?? "";
const isDisplay = classes.includes("math-display");

if (!isDisplay && !classes.includes("math-inline")) {
return <code className={className} {...props}>{children}</code>;
}

const html = katex.renderToString(String(children ?? ""), {
displayMode: isDisplay,
throwOnError: false,
});

return isDisplay
? <div dangerouslySetInnerHTML={{ __html: html }} />
: <span dangerouslySetInnerHTML={{ __html: html }} />;
Comment thread
kojiwakayama marked this conversation as resolved.
Outdated
}

/**
* Rich Markdown for assistant answers.
*
* `veryfront/markdown` presents plain source until a renderer is installed, so
* this component supplies one. Swap in any renderer that accepts
* `MarkdownRendererProps` to change how answers are parsed and rendered.
*
* `normalizeLatexDelimiters` runs before parsing because `\(` and `\[` cannot
* survive it: CommonMark reads a backslash before ASCII punctuation as a
* character escape, so `\(x\)` reaches the syntax tree as plain `(x)` and no
* later plugin can recover it. That same pass is what makes dollar-delimited
* maths safe here, by escaping every dollar the author wrote so the money in
* "totals $99.71" is never mistaken for a formula.
*/
export function MarkdownRenderer({ source }: MarkdownRendererProps): React.JSX.Element {
return <ReactMarkdown remarkPlugins={[remarkGfm]}>{source}</ReactMarkdown>;
return (
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
components={{ code: MathCode }}
>
{normalizeLatexDelimiters(source)}
</ReactMarkdown>
);
}
146 changes: 146 additions & 0 deletions cli/templates/files/coding-agent/app/latex-delimiters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/**
* Normalise LaTeX delimiters before Markdown parsing.
*
* Two problems make this a pre-parse pass rather than a plugin.
*
* First, `\(` and `\[` cannot survive the Markdown parser. CommonMark treats a
* backslash before ASCII punctuation as a character escape, so `\(x\)` reaches
* the syntax tree as plain `(x)`. By the time any remark or rehype plugin runs,
* the delimiters are already gone, and a KaTeX DOM pass has nothing to match.
*
* Second, `$` cannot be trusted as a delimiter in this content. Assistant
* answers are full of currency: "18% tip on $84.50 ... totals $99.71". Enabling
* single dollar math against that text turns the prose between two amounts into
* a formula. So every dollar sign that the author wrote is escaped first, and
* the only unescaped dollars left in the document are the delimiters introduced
* here. That is what makes `singleDollarTextMath` safe downstream.
*
* Code is never rewritten. Fenced blocks and inline spans are copied through
* untouched, so a snippet containing `$` or `\(` keeps its exact source.
*/

/** A half-open source range that must be copied through verbatim. */
interface CodeSpan {
readonly start: number;
readonly end: number;
}

const FENCE_RE = /^[ \t]*(`{3,}|~{3,})/;

/**
* Locate fenced code blocks.
*
* A fence closes on the first later fence of the same character that is at
* least as long as the opener. An unterminated fence runs to end of input,
* matching how CommonMark treats it, so trailing text is not rewritten as if it
* were prose.
*/
function findFencedSpans(source: string): CodeSpan[] {
const spans: CodeSpan[] = [];
const lines = source.split("\n");
let offset = 0;
let open: { marker: string; start: number } | undefined;

for (const line of lines) {
const match = FENCE_RE.exec(line);
if (open === undefined) {
if (match?.[1]) open = { marker: match[1], start: offset };
} else if (
match?.[1] &&
match[1][0] === open.marker[0] &&
match[1].length >= open.marker.length
) {
spans.push({ start: open.start, end: offset + line.length });
open = undefined;
}
offset += line.length + 1;
}

if (open !== undefined) spans.push({ start: open.start, end: source.length });
return spans;
}

/** Locate inline code spans, skipping any that fall inside a fenced block. */
function findInlineSpans(source: string, fenced: readonly CodeSpan[]): CodeSpan[] {
const spans: CodeSpan[] = [];
const inFence = (index: number) => fenced.some((s) => index >= s.start && index < s.end);

let index = 0;
while (index < source.length) {
if (source[index] !== "`" || inFence(index)) {
index += 1;
continue;
}
let run = 0;
while (source[index + run] === "`") run += 1;
const marker = "`".repeat(run);
const close = source.indexOf(marker, index + run);
// An unclosed run is literal text, not a span, so leave it to be rewritten.
if (close === -1) {
index += run;
continue;
}
spans.push({ start: index, end: close + run });
index = close + run;
}

return spans;
}

const MATH_RE = /\\\[([\s\S]*?)\\\]|\\\(([\s\S]*?)\\\)/g;

/** Outside maths a dollar is currency, so hide it from the math tokenizer. */
function escapeProseDollars(text: string): string {
return text.replace(/\$/g, "\\$");
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
}

/**
* Inside maths a dollar has to stop being a dollar character entirely.
*
* `remark-math` closes an inline expression at the first `$` it meets and does
* not honour a backslash escape, so `\textbf{ \$99.71 }` silently fails to
* render rather than producing a dollar sign. `\textdollar` is the TeX spelling
* that survives, and the braces keep it from swallowing a following digit.
*/
function convertMathDollars(body: string): string {
return body.replace(/\$/g, "{\\textdollar}");
}

/** Promote LaTeX delimiters to remark-math syntax, per region. */
function convertProse(text: string): string {
let result = "";
let cursor = 0;

for (const match of text.matchAll(MATH_RE)) {
const index = match.index ?? 0;
result += escapeProseDollars(text.slice(cursor, index));
const display = match[1] !== undefined;
const body = convertMathDollars(display ? match[1] : (match[2] ?? ""));
result += display ? `$$${body}$$` : `$${body}$`;
cursor = index + match[0].length;
}

result += escapeProseDollars(text.slice(cursor));
return result;
}

/**
* Rewrite `\(inline\)` and `\[display\]` into `$inline$` and `$$display$$`,
* leaving every code region and every author-written `$` untouched.
*/
export function normalizeLatexDelimiters(source: string): string {
const fenced = findFencedSpans(source);
const protectedSpans = [...fenced, ...findInlineSpans(source, fenced)]
.sort((a, b) => a.start - b.start);

let result = "";
let cursor = 0;
for (const span of protectedSpans) {
if (span.start < cursor) continue;
result += convertProse(source.slice(cursor, span.start));
result += source.slice(span.start, span.end);
cursor = span.end;
}
result += convertProse(source.slice(cursor));
return result;
}
Loading
Loading