aa3fb56458
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>
148 lines
4.9 KiB
TypeScript
148 lines
4.9 KiB
TypeScript
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: [],
|
|
}
|
|
}
|