Parse Quarto logs and make the accent colour a single knob
Build and Deploy Verso / deploy (push) Successful in 11m55s

Quarto compiles (.qmd/.md/.Rmd, dispatched to QuartoRunner) write
Typst/Pandoc/Quarto diagnostics to output.log that the LaTeX log parser
does not understand, so the Errors/Warnings tabs stayed empty. Add a
dedicated quarto-log-parser that recognises Typst `error:`/`warning:`
(+ `┌─ file:line:col`), Pandoc `[WARNING]`/`[ERROR]`, Quarto CLI/Deno
`ERROR:`/`WARNING:`, and knitr `Quitting from lines`. handleLogFiles now
routes to it when the root file is a Quarto file (mirrors CLSI dispatch),
otherwise the LaTeX path is unchanged.

Also decouple the UI accent from the green ramp. The framework already
funnels every primary/accent surface (primary buttons, Bootstrap
$primary/$success, --btn-primary-background) through the --bg-accent-*
tokens; those just happened to point at Overleaf green. Introduce a
single $accent knob in foundations/colors.scss (with auto-derived
hover/tint shades) and repoint the accent tokens at it, defaulting to
the Verso/Quarto blue. Re-skinning the whole UI is now a one-line edit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude
2026-06-01 10:57:06 +00:00
parent e87bbfe5b0
commit aa3fb56458
3 changed files with 200 additions and 16 deletions
@@ -1,4 +1,5 @@
import HumanReadableLogs from '../../../ide/human-readable-logs/HumanReadableLogs'
import parseQuartoLog from '../../../ide/log-parser/quarto-log-parser'
import BibLogParser, {
BibLogEntry,
} from '../../../ide/log-parser/bib-log-parser'
@@ -129,19 +130,28 @@ export async function handleLogFiles(
MAX_LOG_SIZE
)
try {
let { errors, warnings, typesetting } = HumanReadableLogs.parse(
result.log,
{
ignoreDuplicates: true,
// Quarto compiles (.qmd/.md/.Rmd, dispatched to QuartoRunner in CLSI by
// root-file extension) produce Typst/Pandoc/Quarto diagnostics that the
// LaTeX log parser does not understand. Route those to a dedicated parser
// so their errors and warnings populate the log tabs like LaTeX ones.
if (isQuartoCompile(data)) {
const { errors, warnings, typesetting } = parseQuartoLog(result.log)
accumulateResults({ errors, warnings, typesetting })
} else {
let { errors, warnings, typesetting } = HumanReadableLogs.parse(
result.log,
{
ignoreDuplicates: true,
}
)
if (data.status === 'stopped-on-first-error') {
// Hide warnings that could disappear after a second pass
warnings = warnings.filter(warning => !isTransientWarning(warning))
}
)
if (data.status === 'stopped-on-first-error') {
// Hide warnings that could disappear after a second pass
warnings = warnings.filter(warning => !isTransientWarning(warning))
accumulateResults({ errors, warnings, typesetting })
}
accumulateResults({ errors, warnings, typesetting })
} catch (e) {
debugConsole.warn(e) // ignore failure to parse the log file, but log a warning
}
@@ -291,6 +301,14 @@ function isTransientWarning(warning: LatexLogEntry): boolean {
return TRANSIENT_WARNING_REGEX.test(warning.message || '')
}
// Mirror of CompileManager._isQuartoFile in CLSI: the runner is chosen by the
// root file's extension, so we detect Quarto compiles the same way client-side.
const QUARTO_ROOT_REGEX = /\.(qmd|md|rmd)$/i
function isQuartoCompile(data: CompileResponseData): boolean {
return QUARTO_ROOT_REGEX.test(data.options?.rootResourcePath || '')
}
async function fetchFileWithSizeLimit(
url: string,
signal: AbortSignal,
@@ -0,0 +1,147 @@
import { LatexLogEntry, ParseResult } from './latex-log-parser'
// Parser for the combined stdout/stderr that `quarto render` writes to
// output.log (see services/clsi/app/js/QuartoRunner.js). Quarto orchestrates
// several tools, each with its own diagnostic style:
//
// - Typst (the engine for .qmd -> PDF): emits
// error: unexpected end of block comment
// ┌─ main.typ:5:10
// ...and the analogous `warning: ...` form. Older builds use `-->` instead
// of the box-drawing arrow.
// - Pandoc (markdown -> typst/html): emits `[WARNING] ...` / `[ERROR] ...`.
// - Quarto CLI itself (YAML validation, project errors, Deno crashes): emits
// `ERROR: ...` / `WARNING: ...` (upper-case) or `error: Uncaught ...`.
// - knitr/R (.Rmd / executable cells): emits `Quitting from lines 3-7 (x.qmd)`
// followed by an `Error: ...` message.
//
// This is deliberately a flat, line-oriented parser rather than the stateful
// LaTeX one: Quarto's output has no nested-file `(...)` structure to track.
// It returns the same ParseResult shape so the rest of the log pipeline
// (HumanReadableLogs consumers, the errors/warnings tabs, editor annotations)
// can treat Quarto entries exactly like LaTeX ones.
// eslint-disable-next-line no-control-regex
const ANSI_REGEX = /\x1b\[[0-9;]*m/g
// Typst / Deno: `error: message`, `warning: message` (lower-case prefix)
const LOWER_DIAG_REGEX = /^(error|warning): (.*)$/
// Quarto CLI: `ERROR: message`, `WARNING: message` (upper-case prefix)
const UPPER_DIAG_REGEX = /^(ERROR|WARNING): (.*)$/
// Pandoc: `[WARNING] message`, `[ERROR] message`, `[INFO] message`
const PANDOC_REGEX = /^\[(WARNING|ERROR|INFO)\] (.*)$/
// knitr/R: `Quitting from lines 3-7 (slides.qmd)`
const R_QUITTING_REGEX = /^Quitting from lines? (\d+)(?:-\d+)?\s*(?:\(([^)]+)\))?/
// A typst diagnostic location line: ` ┌─ main.typ:5:10` / ` --> main.typ:5:10`
const TYPST_LOCATION_REGEX = /(?:[┌╭]─|-->)\s*(.+?):(\d+):(\d+)/
function stripAnsi(line: string): string {
return line.replace(ANSI_REGEX, '')
}
function isDiagnosticStart(trimmed: string): boolean {
return (
LOWER_DIAG_REGEX.test(trimmed) ||
UPPER_DIAG_REGEX.test(trimmed) ||
PANDOC_REGEX.test(trimmed)
)
}
export default function parseQuartoLog(rawLog: string): ParseResult {
const lines = rawLog.replace(/\r\n?/g, '\n').split('\n')
const data: LatexLogEntry[] = []
let pendingLocation: { file?: string; line?: number } = {}
for (let i = 0; i < lines.length; i++) {
const clean = stripAnsi(lines[i])
const trimmed = clean.trimStart()
// Remember the most recent knitr location; it precedes the `Error:` line.
const quitting = trimmed.match(R_QUITTING_REGEX)
if (quitting) {
pendingLocation = {
line: parseInt(quitting[1], 10),
file: quitting[2],
}
continue
}
let level: LatexLogEntry['level'] | null = null
let message: string | null = null
let m: RegExpMatchArray | null
if ((m = trimmed.match(LOWER_DIAG_REGEX))) {
level = m[1] === 'error' ? 'error' : 'warning'
message = m[2]
} else if ((m = trimmed.match(UPPER_DIAG_REGEX))) {
level = m[1] === 'ERROR' ? 'error' : 'warning'
message = m[2]
} else if ((m = trimmed.match(PANDOC_REGEX))) {
if (m[1] === 'INFO') continue // pandoc INFO lines are not actionable
level = m[1] === 'ERROR' ? 'error' : 'warning'
message = m[2]
}
if (level === null || message === null) continue
// Accumulate any following indented/diagnostic lines (the typst box, a Deno
// stack trace, R traceback) as the entry's content, and pick up a
// file:line:col location from the typst box if present. Stop at a blank
// line or the start of the next diagnostic.
let file = pendingLocation.file
let line: number | null = pendingLocation.line ?? null
let content = clean
let j = i + 1
for (; j < lines.length; j++) {
const next = stripAnsi(lines[j])
if (next.trim() === '') break
if (isDiagnosticStart(next.trimStart())) break
content += '\n' + next
const loc = next.match(TYPST_LOCATION_REGEX)
if (loc && !file) {
file = loc[1]
line = parseInt(loc[2], 10)
}
}
i = j - 1
data.push({
line,
file,
level,
message: message.trim(),
content,
raw: content,
})
pendingLocation = {}
}
return postProcess(data)
}
function postProcess(data: LatexLogEntry[]): ParseResult {
const all: LatexLogEntry[] = []
const byLevel: Record<'error' | 'warning' | 'typesetting', LatexLogEntry[]> = {
error: [],
warning: [],
typesetting: [],
}
const seen = new Set<string>()
for (const entry of data) {
if (seen.has(entry.raw)) continue
seen.add(entry.raw)
byLevel[entry.level]?.push(entry)
all.push(entry)
}
return {
errors: byLevel.error,
warnings: byLevel.warning,
typesetting: byLevel.typesetting,
all,
files: [],
}
}
@@ -1,3 +1,5 @@
@use 'sass:color';
// This file provides CSS and Sass variables for colors as full color values.
// To combine one of these colors with an alpha value, use Sass's built-in
// rgba() function.
@@ -5,6 +7,23 @@
// Note that colors used by Bootstrap's Sass are used in calculations and
// therefore cannot contain CSS variables
/* ====== Brand accent ======
*
* THE SINGLE KNOB. This one colour drives every "primary"/accent surface in
* the whole app: primary buttons, Bootstrap's $primary and $success, active
* accents, table highlights, etc. They all flow through the --bg-accent-*
* tokens defined further down, which point here.
*
* To re-skin the entire UI, change $accent. The hover (darker) and subtle
* background (lighter) shades are derived from it automatically, so a single
* edit keeps the palette coherent.
*
* (Was Overleaf green #098842; now the Verso/Quarto blue.)
*/
$accent: #447099;
$accent-dark: color.adjust($accent, $lightness: -8%); // hover / pressed
$accent-light: color.adjust($accent, $lightness: 46%); // subtle tinted bg
/* ====== Sass color variables ====== */
/* Neutral */
@@ -64,9 +83,9 @@ $bg-dark-primary: $neutral-90;
$bg-dark-secondary: $neutral-80;
$bg-dark-tertiary: $neutral-70;
$bg-dark-disabled: $neutral-70;
$bg-accent-01: $green-50;
$bg-accent-02: $green-60;
$bg-accent-03: $green-10;
$bg-accent-01: $accent;
$bg-accent-02: $accent-dark;
$bg-accent-03: $accent-light;
$bg-danger-01: $red-50;
$bg-danger-02: $red-60;
$bg-danger-03: $red-10;
@@ -176,9 +195,9 @@ $link-ui-visited-dark: $blue-40;
--bg-dark-secondary: var(--neutral-80);
--bg-dark-tertiary: var(--neutral-70);
--bg-dark-disabled: var(--neutral-70);
--bg-accent-01: var(--green-50);
--bg-accent-02: var(--green-60);
--bg-accent-03: var(--green-10);
--bg-accent-01: #{$accent};
--bg-accent-02: #{$accent-dark};
--bg-accent-03: #{$accent-light};
--bg-danger-01: var(--red-50);
--bg-danger-02: var(--red-60);
--bg-danger-03: var(--red-10);