Files
Verso/services/clsi/app/js/RequestParser.js
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

298 lines
8.3 KiB
JavaScript

import { promisify } from 'node:util'
import settings from '@overleaf/settings'
import OutputCacheManager from './OutputCacheManager.js'
const VALID_COMPILERS = [
'quarto',
'typst',
'pdflatex',
'latex',
'xelatex',
'lualatex',
]
const MAX_TIMEOUT = 600
const EDITOR_ID_REGEX = /^[a-f0-9-]{36}$/ // UUID
const HISTORY_ID_REGEX = /^([0-9a-f]{24}|[1-9][0-9]{0,9})$/ // mongo id or postgres id
function parse(body, callback) {
const response = {}
if (body.compile == null) {
return callback(
new Error('top level object should have a compile attribute')
)
}
const { compile } = body
if (!compile.options) {
compile.options = {}
}
try {
response.metricsOpts = {
path: _parseAttribute('metricsPath', compile.options.metricsPath, {
default: '',
type: 'string',
}),
method: _parseAttribute('metricsMethod', compile.options.metricsMethod, {
default: '',
type: 'string',
}),
// Will be populated later. Must always be populated for prom library.
compile: 'initial',
}
response.compiler = _parseAttribute('compiler', compile.options.compiler, {
validValues: VALID_COMPILERS,
default: 'quarto',
type: 'string',
})
response.compileFromClsiCache = _parseAttribute(
'compileFromClsiCache',
compile.options.compileFromClsiCache,
{ default: false, type: 'boolean' }
)
response.populateClsiCache = _parseAttribute(
'populateClsiCache',
compile.options.populateClsiCache,
{ default: false, type: 'boolean' }
)
response.enablePdfCaching = _parseAttribute(
'enablePdfCaching',
compile.options.enablePdfCaching,
{
default: false,
type: 'boolean',
}
)
response.pdfCachingMinChunkSize = _parseAttribute(
'pdfCachingMinChunkSize',
compile.options.pdfCachingMinChunkSize,
{
default: settings.pdfCachingMinChunkSize,
type: 'number',
}
)
response.timeout = _parseAttribute('timeout', compile.options.timeout, {
default: MAX_TIMEOUT,
type: 'number',
})
response.imageName = _parseAttribute(
'imageName',
compile.options.imageName,
{
type: 'string',
validValues:
settings.clsi &&
settings.clsi.docker &&
settings.clsi.docker.allowedImages,
}
)
response.draft = _parseAttribute('draft', compile.options.draft, {
default: false,
type: 'boolean',
})
response.stopOnFirstError = _parseAttribute(
'stopOnFirstError',
compile.options.stopOnFirstError,
{
default: false,
type: 'boolean',
}
)
response.check = _parseAttribute('check', compile.options.check, {
type: 'string',
})
response.flags = _parseAttribute('flags', compile.options.flags, {
default: [],
type: 'object',
})
if (settings.allowedCompileGroups) {
response.compileGroup = _parseAttribute(
'compileGroup',
compile.options.compileGroup,
{
validValues: settings.allowedCompileGroups,
default: '',
type: 'string',
}
)
}
// The syncType specifies whether the request contains all
// resources (full) or only those resources to be updated
// in-place (incremental).
response.syncType = _parseAttribute('syncType', compile.options.syncType, {
validValues: [
'full',
'incremental',
'history-full',
'history-incremental',
],
type: 'string',
})
// The syncState is an identifier passed in with the request
// which has the property that it changes when any resource is
// added, deleted, moved or renamed.
//
// on syncType full the syncState identifier is passed in and
// stored
//
// on syncType incremental the syncState identifier must match
// the stored value
response.syncState = _parseAttribute(
'syncState',
compile.options.syncState,
{ type: 'string' }
)
if (response.timeout > MAX_TIMEOUT) {
response.timeout = MAX_TIMEOUT
}
response.timeout = response.timeout * 1000 // milliseconds
response.resources = (compile.resources || []).map(resource =>
_parseResource(resource)
)
response.historyId = _parseAttribute(
'historyId',
compile.options.historyId,
{ type: 'string', regex: HISTORY_ID_REGEX }
)
response.baseHistoryVersion = _parseAttribute(
'baseHistoryVersion',
compile.baseHistoryVersion,
{ type: 'number' }
)
response.globalBlobs = _parseAttribute('globalBlobs', compile.globalBlobs, {
type: 'array',
})
// The snapshot and changes are validated when loading them in editor-core.
response.rawSnapshot = compile.rawSnapshot
response.rawChangeOperations = compile.rawChangeOperations
response.isCompileFromHistory = !!response.rawChangeOperations
// v1 conversions / submissions
if (compile.filestoreBlobPrefix) {
response.filestoreBlobPrefix = _checkPath(compile.filestoreBlobPrefix)
}
// clsi-perf
response.clsiPerfVariant = _parseAttribute(
'clsiPerfVariant',
compile.options.clsiPerfVariant,
{ type: 'string' }
)
const rootResourcePath = _parseAttribute(
'rootResourcePath',
compile.rootResourcePath,
{
default: 'main.qmd',
type: 'string',
}
)
response.rootResourcePath = _checkPath(rootResourcePath)
response.editorId = _parseAttribute('editorId', compile.options.editorId, {
type: 'string',
regex: EDITOR_ID_REGEX,
})
response.buildId = _parseAttribute('buildId', compile.options.buildId, {
type: 'string',
regex: OutputCacheManager.BUILD_REGEX,
})
} catch (error1) {
const error = error1
return callback(error)
}
callback(null, response)
}
function _parseResource(resource) {
let modified
if (resource.path == null || typeof resource.path !== 'string') {
throw new Error('all resources should have a path attribute')
}
if (resource.modified != null) {
modified = new Date(resource.modified)
if (isNaN(modified.getTime())) {
throw new Error(
`resource modified date could not be understood: ${resource.modified}`
)
}
}
if (resource.url == null && resource.content == null) {
throw new Error(
'all resources should have either a url or content attribute'
)
}
if (resource.content != null && typeof resource.content !== 'string') {
throw new Error('content attribute should be a string')
}
if (resource.url != null && typeof resource.url !== 'string') {
throw new Error('url attribute should be a string')
}
if (resource.fallbackURL && typeof resource.fallbackURL !== 'string') {
throw new Error('fallbackURL attribute should be a string')
}
return {
path: resource.path,
modified,
url: resource.url,
fallbackURL: resource.fallbackURL,
content: resource.content,
}
}
function _parseAttribute(name, attribute, options) {
if (attribute != null) {
if (options.validValues != null) {
if (options.validValues.indexOf(attribute) === -1) {
throw new Error(
`${name} attribute should be one of: ${options.validValues.join(
', '
)}`
)
}
}
if (options.type === 'array') {
if (!Array.isArray(attribute)) {
throw new Error(`${name} attribute should be an array`)
}
} else if (options.type != null) {
// eslint-disable-next-line valid-typeof
if (typeof attribute !== options.type) {
throw new Error(`${name} attribute should be a ${options.type}`)
}
}
if (options.type === 'string' && options.regex instanceof RegExp) {
if (!options.regex.test(attribute)) {
throw new Error(
`${name} attribute does not match regex ${options.regex}`
)
}
}
} else {
if (options.default != null) {
return options.default
}
}
return attribute
}
function _checkPath(path) {
// check that the request does not use a relative path
for (const dir of Array.from(path.split('/'))) {
if (dir === '..') {
throw new Error('relative path in root resource')
}
}
return path
}
export default { parse, MAX_TIMEOUT, promises: { parse: promisify(parse) } }