From aa3fb564585809797be1ba09a803227231de8b2e Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 1 Jun 2026 10:57:06 +0000 Subject: [PATCH] Parse Quarto logs and make the accent colour a single knob MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../features/pdf-preview/util/output-files.ts | 38 +++-- .../js/ide/log-parser/quarto-log-parser.ts | 147 ++++++++++++++++++ .../stylesheets/foundations/colors.scss | 31 +++- 3 files changed, 200 insertions(+), 16 deletions(-) create mode 100644 services/web/frontend/js/ide/log-parser/quarto-log-parser.ts diff --git a/services/web/frontend/js/features/pdf-preview/util/output-files.ts b/services/web/frontend/js/features/pdf-preview/util/output-files.ts index ab27bd3188..9acc0b0d82 100644 --- a/services/web/frontend/js/features/pdf-preview/util/output-files.ts +++ b/services/web/frontend/js/features/pdf-preview/util/output-files.ts @@ -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, diff --git a/services/web/frontend/js/ide/log-parser/quarto-log-parser.ts b/services/web/frontend/js/ide/log-parser/quarto-log-parser.ts new file mode 100644 index 0000000000..8362bb2997 --- /dev/null +++ b/services/web/frontend/js/ide/log-parser/quarto-log-parser.ts @@ -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() + + 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: [], + } +} diff --git a/services/web/frontend/stylesheets/foundations/colors.scss b/services/web/frontend/stylesheets/foundations/colors.scss index 0104b0897a..d9382b5dc8 100644 --- a/services/web/frontend/stylesheets/foundations/colors.scss +++ b/services/web/frontend/stylesheets/foundations/colors.scss @@ -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);