Parse Quarto logs and make the accent colour a single knob
Build and Deploy Verso / deploy (push) Successful in 11m55s
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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user