Files
Verso/services/web/frontend/js/features/pdf-preview/util/output-files.ts
T
claudeandClaude Opus 4.8 d67bc77b0e
Build and Deploy Verso / deploy (push) Successful in 7m37s
Add a Typst compiler alongside Quarto and LaTeX
A project whose root file is a .typ file now compiles straight to PDF with
Typst, as a third engine beside Quarto (.qmd) and latexmk (.tex). Dispatch
stays purely extension-based.

CLSI:
- New TypstRunner.js: runs `quarto typst compile <main>.typ output.pdf` (reuses
  the Typst bundled in Quarto, so no extra binary / Docker change). stderr is
  merged into output.log.
- CompileManager: _isTypstFile + a TypstRunner branch in _getRunner, and
  TypstRunner added to the isRunning check and stopCompile kill list.
- RequestParser: 'typst' added to VALID_COMPILERS.

web:
- settings.defaults: 'typ' added to validRootDocExtensions and the text
  extensions (so .typ opens in the editor); 'typst' added to safeCompilers.
- output-files: the Quarto/Typst log parser (which already understands Typst
  `error:`/`warning:` + `┌─ file:line:col` diagnostics) now also handles .typ
  compiles, so their errors/warnings populate the log tabs.

Polish:
- New-project menu: "Blank Typst project" + "Example Typst project" in both the
  main and welcome dropdowns, backed by createBasicProject/createExampleProject
  flavour 'typst', a new mainbasic.typ template and an example-project-typst
  presentation (math, an image, a table, lists).
- Compiler dropdown gains a "Typst" option (cosmetic; dispatch is by extension).

README updated: three compilers side by side, with a Writing-a-Typst-document
section.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 12:56:30 +00:00

348 lines
10 KiB
TypeScript

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'
import { enablePdfCaching } from './pdf-caching-flags'
import { debugConsole } from '@/utils/debugging'
import { dirname, findEntityByPath } from '@/features/file-tree/util/path'
import '@/utils/readable-stream-async-iterator-polyfill'
import { EDITOR_SESSION_ID } from '@/features/pdf-preview/util/metrics'
import { LogEntry } from './types'
import { CompileResponseData, PDFFile } from '@ol-types/compile'
import { LatexLogEntry } from '@/ide/log-parser/latex-log-parser'
import { Annotation } from '@ol-types/annotation'
import { Folder } from '@ol-types/folder'
// Warnings that may disappear after a second LaTeX pass
const TRANSIENT_WARNING_REGEX = /^(Reference|Citation).+undefined on input line/
const MAX_LOG_SIZE = 1024 * 1024 // 1MB
const MAX_BIB_LOG_SIZE_PER_FILE = MAX_LOG_SIZE
export function handleOutputFiles(
outputFiles: Map<string, PDFFile>,
projectId: string,
data: CompileResponseData
): PDFFile | null {
// Accept either a PDF or an HTML output (e.g. RevealJS presentation)
const outputFile =
outputFiles.get('output.pdf') ?? outputFiles.get('output.html')
if (!outputFile) return null
outputFile.editorId = outputFile.editorId || EDITOR_SESSION_ID
outputFile.clsiCacheShard = data.clsiCacheShard || 'cache'
// build the URL for viewing the PDF in the preview UI
const params = new URLSearchParams()
if (data.compileGroup) {
params.set('compileGroup', data.compileGroup)
}
if (data.clsiServerId) {
params.set('clsiserverid', data.clsiServerId)
}
if (enablePdfCaching) {
// Tag traffic that uses the pdf caching logic.
params.set('enable_pdf_caching', 'true')
}
outputFile.pdfUrl = `${buildURL(outputFile, data.pdfDownloadDomain)}?${params}`
if (data.fromCache) {
outputFile.pdfDownloadUrl = outputFile.downloadURL
} else {
// build the URL for downloading the PDF
params.set('popupDownload', 'true') // save PDF download as file
params.set('editorId', outputFile.editorId)
outputFile.pdfDownloadUrl = `/download/project/${projectId}/build/${outputFile.build}/output/${outputFile.path}?${params}`
}
return outputFile
}
let nextEntryId = 1
function generateEntryKey(): string {
return 'compile-log-entry-' + nextEntryId++
}
type LogResult = {
log: string | null
logEntries: {
errors: LogEntry[]
warnings: LogEntry[]
typesetting: LogEntry[]
all: LogEntry[]
}
}
export async function handleLogFiles(
outputFiles: Map<string, PDFFile>,
data: CompileResponseData,
signal: AbortSignal
): Promise<LogResult> {
const result: LogResult = {
log: null,
logEntries: {
all: [],
errors: [],
warnings: [],
typesetting: [],
},
}
function accumulateResults(
newEntries: {
errors?: (LatexLogEntry | BibLogEntry)[]
warnings?: (LatexLogEntry | BibLogEntry)[]
typesetting?: (LatexLogEntry | BibLogEntry)[]
all?: (LatexLogEntry | BibLogEntry)[]
},
type?: string
) {
for (const key of Object.keys(result.logEntries) as Array<
keyof typeof result.logEntries
>) {
if (newEntries[key]) {
for (const entry of newEntries[key]) {
if (type) {
// Type casting as we are mutating LatexLogEntry | BibLogEntry into a LogEntry
;(entry as LogEntry).type = type
}
if (entry.file) {
entry.file = normalizeFilePath(entry.file)
}
;(entry as LogEntry).key = generateEntryKey()
}
result.logEntries[key].push(...(newEntries[key] as LogEntry[]))
}
}
}
const logFile = outputFiles.get('output.log')
if (logFile) {
result.log = await fetchFileWithSizeLimit(
buildURL(logFile, data.pdfDownloadDomain),
signal,
MAX_LOG_SIZE
)
try {
// Quarto (.qmd/.md/.Rmd) and bare Typst (.typ) compiles 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 (usesQuartoLogParser(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))
}
accumulateResults({ errors, warnings, typesetting })
}
} catch (e) {
debugConsole.warn(e) // ignore failure to parse the log file, but log a warning
}
}
const blgFiles: PDFFile[] = []
for (const [filename, file] of outputFiles) {
if (filename.endsWith('.blg')) {
blgFiles.push(file)
}
}
for (const blgFile of blgFiles) {
const log = await fetchFileWithSizeLimit(
buildURL(blgFile, data.pdfDownloadDomain),
signal,
MAX_BIB_LOG_SIZE_PER_FILE
)
try {
const { errors, warnings } = new BibLogParser(log, {
maxErrors: 100,
}).parse()
accumulateResults({ errors, warnings }, 'BibTeX:')
} catch (e) {
// BibLog parsing errors are ignored
}
}
result.logEntries.all = [
...result.logEntries.errors,
...result.logEntries.warnings,
...result.logEntries.typesetting,
]
return result
}
export function buildLogEntryAnnotations(
entries: LogEntry[],
fileTreeData: Folder,
rootDocId?: string | null
): Record<string, Annotation[]> {
const rootDocDirname = rootDocId ? dirname(fileTreeData, rootDocId) : null
const logEntryAnnotations: Record<string, Annotation[]> = {}
const seenLine: Record<number, boolean> = {}
for (const entry of entries) {
if (entry.file) {
entry.file = normalizeFilePath(entry.file, rootDocDirname)
const entity = findEntityByPath(fileTreeData, entry.file)?.entity
if (entity) {
if (!(entity._id in logEntryAnnotations)) {
logEntryAnnotations[entity._id] = []
}
const annotation: Annotation = {
id: entry.key,
entryIndex: logEntryAnnotations[entity._id].length, // used for maintaining the order of items on the same line
row: (entry.line || 1) - 1,
type: entry.level === 'error' ? 'error' : 'warning',
text: entry.message ?? '',
source: 'compile', // NOTE: this is used in Ace for filtering the annotations
ruleId: entry.ruleId,
command: entry.command,
}
// set firstOnLine for the first non-typesetting annotation on a line
if (entry.level !== 'typesetting') {
if (!seenLine[entry.line || 0]) {
annotation.firstOnLine = true
seenLine[entry.line || 0] = true
}
}
logEntryAnnotations[entity._id].push(annotation)
}
}
}
return logEntryAnnotations
}
export const buildRuleCounts = (
entries: LogEntry[] = []
): Record<string, number> => {
const counts: Record<string, number> = {}
for (const entry of entries) {
const key = `${entry.level}_${entry.ruleId}`
counts[key] = counts[key] ? counts[key] + 1 : 1
}
return counts
}
export const buildRuleDeltas = (
ruleCounts: Record<string, number>,
previousRuleCounts: Record<string, number>
): Record<string, number> => {
const counts: Record<string, number> = {}
for (const [key, value] of Object.entries(ruleCounts)) {
const previousValue = previousRuleCounts[key] ?? 0
counts[`delta_${key}`] = value - previousValue
}
for (const [key, value] of Object.entries(previousRuleCounts)) {
if (!(key in ruleCounts)) {
counts[key] = 0
counts[`delta_${key}`] = -value
}
}
return counts
}
function buildURL(file: PDFFile, pdfDownloadDomain?: string): string {
if (file.build && pdfDownloadDomain) {
// Downloads from the compiles domain must include a build id.
// The build id is used implicitly for access control.
return `${pdfDownloadDomain}${file.url}`
}
// Go through web instead, which uses mongo for checking project access.
return `${window.origin}${file.url}`
}
function normalizeFilePath(
path: string,
rootDocDirname?: string | null
): string {
path = path.replace(/\/\//g, '/')
path = path.replace(
/^.*\/compiles\/[0-9a-f]{24}(-[0-9a-f]{24})?\/(\.\/)?/,
''
)
path = path.replace(/^\/compile\//, '')
if (rootDocDirname) {
path = path.replace(/^\.\//, rootDocDirname + '/')
}
return path
}
function isTransientWarning(warning: LatexLogEntry): boolean {
return TRANSIENT_WARNING_REGEX.test(warning.message || '')
}
// Mirrors CompileManager's runner dispatch in CLSI: both the Quarto runner
// (.qmd/.md/.Rmd) and the Typst runner (.typ) emit Typst-style diagnostics, so
// we pick the Quarto/Typst log parser for either, keyed on the root extension.
const QUARTO_TYPST_ROOT_REGEX = /\.(qmd|md|rmd|typ)$/i
function usesQuartoLogParser(data: CompileResponseData): boolean {
return QUARTO_TYPST_ROOT_REGEX.test(data.options?.rootResourcePath || '')
}
async function fetchFileWithSizeLimit(
url: string,
signal: AbortSignal,
maxSize: number
): Promise<string> {
let result = ''
try {
const abortController = new AbortController()
// abort fetching the log file if the main signal is aborted
signal.addEventListener('abort', () => {
abortController.abort()
})
const response = await fetch(url, {
signal: abortController.signal,
})
if (!response.ok) {
throw new Error('Failed to fetch log file')
}
const reader = response.body?.pipeThrough(new TextDecoderStream())
if (reader) {
for await (const chunk of reader) {
result += chunk
if (result.length > maxSize) {
abortController.abort()
}
}
}
} catch (e) {
debugConsole.warn(e) // ignore failure to fetch the log file, but log a warning
}
return result
}