Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b7da8296c | |||
| e6773c6baf | |||
| 2c8dad08f6 | |||
| 7fecaf491a | |||
| 9c7a10aa39 | |||
| bbf532d282 | |||
| eada1e9979 | |||
| 2f88ad124d | |||
| a398127522 | |||
| 083b195462 | |||
| 0656ddfe52 | |||
| 056d9a7f47 | |||
| 8ed44cc352 | |||
| 52ebff6286 | |||
| 7c0ec9dd39 | |||
| e9a34a5bd8 | |||
| f9fc0d9905 | |||
| d7ca7b194d | |||
| 47cf84f20b | |||
| 2d3e64da92 | |||
| 2db6e63162 | |||
| f2b7034b51 | |||
| 4c6032bce0 | |||
| 2fdb155547 | |||
| 75bc3bcc73 | |||
| 1b773fdda0 | |||
| 019b4041a8 | |||
| 4aca4aaac6 | |||
| f9d46aabeb | |||
| 54ab282efc | |||
| f976c5ba92 | |||
| f5a94c0ced |
@@ -26,13 +26,12 @@ cypress/results/
|
||||
# Ace themes for conversion
|
||||
frontend/js/features/source-editor/themes/ace/
|
||||
|
||||
# Compiled parser files
|
||||
# Compiled parser files (latex/bibtex are generated by webpack plugin at build time)
|
||||
frontend/js/features/source-editor/lezer-latex/latex.mjs
|
||||
frontend/js/features/source-editor/lezer-latex/latex.terms.mjs
|
||||
frontend/js/features/source-editor/lezer-bibtex/bibtex.mjs
|
||||
frontend/js/features/source-editor/lezer-bibtex/bibtex.terms.mjs
|
||||
frontend/js/features/source-editor/lezer-typst/typst.mjs
|
||||
frontend/js/features/source-editor/lezer-typst/typst.terms.mjs
|
||||
# typst compiled files are committed (generated via node scripts/lezer-latex/generate.mjs)
|
||||
|
||||
!**/fixtures/**/*.log
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
import Metrics from '@overleaf/metrics'
|
||||
import ProjectGetter from '../Project/ProjectGetter.mjs'
|
||||
import { Project } from '../../models/Project.mjs'
|
||||
import CompileManager from './CompileManager.mjs'
|
||||
import ClsiManager from './ClsiManager.mjs'
|
||||
import logger from '@overleaf/logger'
|
||||
@@ -8,6 +9,7 @@ import Settings from '@overleaf/settings'
|
||||
import Errors from '../Errors/Errors.js'
|
||||
import SessionManager from '../Authentication/SessionManager.mjs'
|
||||
import { userCanInstallPython } from './PythonVenvGate.mjs'
|
||||
import TokenAccessHandler from '../TokenAccess/TokenAccessHandler.mjs'
|
||||
import { RateLimiter } from '../../infrastructure/RateLimiter.mjs'
|
||||
import Validation from '../../infrastructure/Validation.mjs'
|
||||
import Path from 'node:path'
|
||||
@@ -205,7 +207,8 @@ const _CompileController = {
|
||||
// Allow building a per-project Python venv from requirements.txt only for
|
||||
// the project owner and invited collaborators — never anonymous or
|
||||
// link-sharing users.
|
||||
options.allowPythonInstall = await userCanInstallPython(userId, projectId)
|
||||
const anonToken = TokenAccessHandler.getRequestToken(req, projectId)
|
||||
options.allowPythonInstall = await userCanInstallPython(userId, projectId, anonToken)
|
||||
|
||||
let {
|
||||
enablePdfCaching,
|
||||
@@ -300,6 +303,26 @@ const _CompileController = {
|
||||
? getOutputFilesArchiveSpecification(projectId, userId, buildId)
|
||||
: null
|
||||
|
||||
// Persist quarto output flavor so the project-list badge can distinguish
|
||||
// RevealJS presentations from PDF documents without needing a compile.
|
||||
// options.compiler is not sent by the frontend, so we read the stored
|
||||
// compiler from the DB. Done fire-and-forget so it never delays the response.
|
||||
if (status === 'success') {
|
||||
const isHtml = outputFiles.some(f => f.path === 'output.html')
|
||||
ProjectGetter.promises
|
||||
.getProject(projectId, { compiler: 1 })
|
||||
.then(project => {
|
||||
if (project?.compiler !== 'quarto') return
|
||||
return Project.updateOne(
|
||||
{ _id: projectId },
|
||||
{ quartoFlavor: isHtml ? 'revealjs' : 'pdf' }
|
||||
).exec()
|
||||
})
|
||||
.catch(err =>
|
||||
logger.warn({ err, projectId }, 'failed to update quartoFlavor')
|
||||
)
|
||||
}
|
||||
|
||||
res.json({
|
||||
status,
|
||||
outputFiles,
|
||||
|
||||
@@ -4,11 +4,12 @@ import AuthorizationManager from '../Authorization/AuthorizationManager.mjs'
|
||||
|
||||
// Whether this user may have the compiler install a project's requirements.txt
|
||||
// into a cached venv (so Quarto's Python cells can use libraries beyond the
|
||||
// bundled base set). Gated to the project owner + invited collaborators (any
|
||||
// role): ignorePublicAccess excludes link-sharing/public and anonymous users,
|
||||
// who fall back to the base Python interpreter. Returns false when the feature
|
||||
// is disabled or the privilege check fails.
|
||||
export async function userCanInstallPython(userId, projectId) {
|
||||
// bundled base set). Allowed for any user who can access the project — owner,
|
||||
// invited collaborators, token-link users, and public-project readers — since
|
||||
// the set of packages to install is already controlled by requirements.vrf
|
||||
// (writable only by project members with write access). Returns false when the
|
||||
// feature is disabled, the privilege check fails, or the user has no access.
|
||||
export async function userCanInstallPython(userId, projectId, token = null) {
|
||||
if (!Settings.enableProjectPythonVenv) {
|
||||
return false
|
||||
}
|
||||
@@ -17,8 +18,7 @@ export async function userCanInstallPython(userId, projectId) {
|
||||
await AuthorizationManager.promises.getPrivilegeLevelForProject(
|
||||
userId,
|
||||
projectId,
|
||||
null,
|
||||
{ ignorePublicAccess: true }
|
||||
token
|
||||
)
|
||||
return Boolean(privilegeLevel)
|
||||
} catch (err) {
|
||||
|
||||
@@ -681,7 +681,7 @@ async function _getProjects(
|
||||
const results = await Promise.all([
|
||||
ProjectGetter.promises.findAllUsersProjects(
|
||||
userId,
|
||||
'name lastUpdated lastUpdatedBy publicAccesLevel archived trashed owner_ref tokens compiler'
|
||||
'name lastUpdated lastUpdatedBy publicAccesLevel archived trashed owner_ref tokens compiler quartoFlavor'
|
||||
),
|
||||
TagsHandler.promises.getAllTags(userId),
|
||||
])
|
||||
@@ -826,6 +826,7 @@ function _formatProjectInfo(project, accessLevel, source, userId) {
|
||||
archived,
|
||||
trashed,
|
||||
compiler: project.compiler,
|
||||
quartoFlavor: project.quartoFlavor,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -880,6 +881,7 @@ async function _injectProjectUsers(projects) {
|
||||
: users[project.owner_ref.toString()],
|
||||
owner_ref: undefined,
|
||||
compiler: project.compiler,
|
||||
quartoFlavor: project.quartoFlavor,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ export const ProjectSchema = new Schema(
|
||||
version: { type: Number }, // incremented for every change in the project structure (folders and filenames)
|
||||
publicAccesLevel: { type: String, default: 'private' },
|
||||
compiler: { type: String, default: settings.defaultLatexCompiler },
|
||||
quartoFlavor: { type: String, enum: ['revealjs', 'pdf'] },
|
||||
spellCheckLanguage: { type: String, default: 'en' },
|
||||
deletedByExternalDataSource: { type: Boolean, default: false },
|
||||
description: { type: String, default: '' },
|
||||
|
||||
@@ -8,6 +8,7 @@ import { usePermissionsContext } from '@/features/ide-react/context/permissions-
|
||||
import FileTreeActionButton from './file-tree-action-button'
|
||||
import { useRailContext } from '../../ide-react/context/rail-context'
|
||||
import PythonRequirementsModal from './python-requirements-modal'
|
||||
import { useProjectSettingsContext } from '@/features/editor-left-menu/context/project-settings-context'
|
||||
|
||||
export default function FileTreeActionButtons({
|
||||
fileTreeExpanded,
|
||||
@@ -19,6 +20,8 @@ export default function FileTreeActionButtons({
|
||||
const { write } = usePermissionsContext()
|
||||
const { handlePaneCollapse } = useRailContext()
|
||||
const [showPythonModal, setShowPythonModal] = useState(false)
|
||||
const { compiler } = useProjectSettingsContext()
|
||||
const isQuarto = compiler === 'quarto'
|
||||
|
||||
const {
|
||||
canCreate,
|
||||
@@ -112,7 +115,7 @@ export default function FileTreeActionButtons({
|
||||
iconType="delete"
|
||||
/>
|
||||
)}
|
||||
{write && (
|
||||
{write && isQuarto && (
|
||||
<FileTreeActionButton
|
||||
id="python-packages"
|
||||
description={t('python_packages')}
|
||||
|
||||
+12
-3
@@ -4,12 +4,21 @@ import { ProjectCompiler } from '../../../../../../../types/project-settings'
|
||||
// Map the stored compiler engine to the document format the project produces.
|
||||
// CLSI dispatches the real engine from the root file's extension, but the
|
||||
// compiler field is a faithful, cheap proxy for the project's format.
|
||||
function formatLabel(compiler: ProjectCompiler | undefined): {
|
||||
function formatLabel(
|
||||
compiler: ProjectCompiler | undefined,
|
||||
quartoFlavor: 'revealjs' | 'pdf' | undefined
|
||||
): {
|
||||
label: string
|
||||
variant: 'quarto' | 'typst' | 'latex'
|
||||
variant: 'quarto-slides' | 'quarto' | 'typst' | 'latex'
|
||||
} {
|
||||
switch (compiler) {
|
||||
case 'quarto':
|
||||
if (quartoFlavor === 'revealjs') {
|
||||
return { label: 'Quarto Slides', variant: 'quarto-slides' }
|
||||
}
|
||||
if (quartoFlavor === 'pdf') {
|
||||
return { label: 'Quarto PDF', variant: 'quarto' }
|
||||
}
|
||||
return { label: 'Quarto', variant: 'quarto' }
|
||||
case 'typst':
|
||||
return { label: 'Typst', variant: 'typst' }
|
||||
@@ -24,7 +33,7 @@ type FormatCellProps = {
|
||||
}
|
||||
|
||||
export default function FormatCell({ project }: FormatCellProps) {
|
||||
const { label, variant } = formatLabel(project.compiler)
|
||||
const { label, variant } = formatLabel(project.compiler, project.quartoFlavor)
|
||||
|
||||
return (
|
||||
<span
|
||||
|
||||
@@ -46,5 +46,6 @@ export const classHighlighter = tagHighlighter([
|
||||
{ tag: tags.invalid, class: 'tok-invalid' },
|
||||
{ tag: tags.punctuation, class: 'tok-punctuation' },
|
||||
// additional
|
||||
{ tag: tags.attributeName, class: 'tok-attributeName' },
|
||||
{ tag: tags.attributeValue, class: 'tok-attributeValue' },
|
||||
])
|
||||
|
||||
@@ -203,6 +203,9 @@ const staticTheme = EditorView.theme({
|
||||
alignItems: 'center',
|
||||
fontWeight: 'normal',
|
||||
},
|
||||
// Bold and italic markup (e.g. *strong* _emphasis_ in Typst and Markdown)
|
||||
'.tok-strong': { fontWeight: 'bold' },
|
||||
'.tok-emphasis': { fontStyle: 'italic' },
|
||||
'.cm-selectionLayer': {
|
||||
zIndex: -10,
|
||||
},
|
||||
|
||||
+38
-17
@@ -23,32 +23,53 @@ const LEVELS: NestingLevel[] = [
|
||||
// after it, so this stays clear of code.
|
||||
const HEADING_REGEX = /^(=+)[ \t]+(.*\S)[ \t]*$/
|
||||
|
||||
// Count unescaped '$' signs on a line to track math-mode parity.
|
||||
function countDollars(text: string): number {
|
||||
let count = 0
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (text[i] === '\\') { i++; continue }
|
||||
if (text[i] === '$') count++
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
function computeOutline(
|
||||
state: EditorState
|
||||
): ProjectionResult<FlatOutlineItem> {
|
||||
const items: FlatOutlineItem[] = []
|
||||
// Track whether we are inside a multi-line display math block.
|
||||
// Each line with an odd number of unescaped '$' toggles the flag.
|
||||
let inMath = false
|
||||
|
||||
for (let n = 1; n <= state.doc.lines; n++) {
|
||||
const line = state.doc.line(n)
|
||||
const match = HEADING_REGEX.exec(line.text)
|
||||
if (!match) continue
|
||||
const text = line.text
|
||||
|
||||
const depth = match[1].length
|
||||
const level = LEVELS[Math.min(depth, LEVELS.length) - 1]
|
||||
// Strip a trailing line comment, then a trailing label.
|
||||
const title = match[2]
|
||||
.replace(/\s*\/\/.*$/, '')
|
||||
.replace(/\s*<[\w-]+>\s*$/, '')
|
||||
.trim()
|
||||
// Only attempt heading detection when not inside a math block.
|
||||
// (e.g. '= b+c$' on the second line of '$ a \n= b+c$' must be skipped.)
|
||||
if (!inMath) {
|
||||
const match = HEADING_REGEX.exec(text)
|
||||
if (match) {
|
||||
const depth = match[1].length
|
||||
const level = LEVELS[Math.min(depth, LEVELS.length) - 1]
|
||||
// Strip a trailing line comment, then a trailing label.
|
||||
const title = match[2]
|
||||
.replace(/\s*\/\/.*$/, '')
|
||||
.replace(/\s*<[\w-]+>\s*$/, '')
|
||||
.trim()
|
||||
|
||||
items.push({
|
||||
line: n,
|
||||
toLine: n,
|
||||
title,
|
||||
from: line.from,
|
||||
to: line.to,
|
||||
level,
|
||||
} as FlatOutlineItem)
|
||||
items.push({
|
||||
line: n,
|
||||
toLine: n,
|
||||
title,
|
||||
from: line.from,
|
||||
to: line.to,
|
||||
level,
|
||||
} as FlatOutlineItem)
|
||||
}
|
||||
}
|
||||
|
||||
if (countDollars(text) % 2 === 1) inMath = !inMath
|
||||
}
|
||||
|
||||
return { items, status: ProjectionStatus.Complete }
|
||||
|
||||
@@ -14,9 +14,8 @@ import { typstDocumentOutline } from './document-outline'
|
||||
// Note on tree structure: rules starting with a lowercase letter in the grammar
|
||||
// are inline (no tree node), so their children are promoted to the parent.
|
||||
// E.g. codeArgItem, codeValue, callSuffix, codeArgList are all inline.
|
||||
// Therefore:
|
||||
// - The named-argument key "CodeIdent" is a *direct* child of CodeArgs.
|
||||
// - Positional arguments that are identifiers are wrapped in CallExpr.
|
||||
// Named arg keys emit CodeArgKey (not CodeIdent) via codeIdentTokenizer,
|
||||
// so CodeArgKey appears at the same level as other codeArgItem children.
|
||||
|
||||
export const TypstLanguage = LRLanguage.define({
|
||||
name: 'typst',
|
||||
@@ -50,11 +49,13 @@ export const TypstLanguage = LRLanguage.define({
|
||||
CodeBool: t.atom,
|
||||
|
||||
// Identifiers:
|
||||
// - direct child of CallExpr → function/method name
|
||||
// - direct child of CodeArgs → named argument key (key: value syntax)
|
||||
// - everywhere else → plain variable
|
||||
'CallExpr/CodeIdent': t.function(t.variableName),
|
||||
'CodeArgs/CodeIdent': t.attributeName,
|
||||
// CodeExpr/CodeIdent — bare #func (no args) → function style
|
||||
// FuncExpr/CodeIdent — func call with args/method (#func(...), link.with(url)) → function style
|
||||
// CodeArgKey — named arg key (tokenizer pre-disambiguates on ':') → attributeName
|
||||
// CodeIdent — plain variable/constant reference (e.g. 'left', 'center') → variable
|
||||
'CodeExpr/CodeIdent': t.function(t.variableName),
|
||||
'FuncExpr/CodeIdent': t.function(t.variableName),
|
||||
CodeArgKey: t.attributeName,
|
||||
CodeIdent: t.variableName,
|
||||
|
||||
// Literals in code mode
|
||||
@@ -73,8 +74,11 @@ export const TypstLanguage = LRLanguage.define({
|
||||
MathContent: t.string,
|
||||
|
||||
// Markup emphasis
|
||||
'Strong/"*" Strong/StrongText': t.strong,
|
||||
'Emphasis/"_" Emphasis/EmphText': t.emphasis,
|
||||
'Strong/"*" Strong/StrongBody': t.strong,
|
||||
'Emphasis/"_" Emphasis/EmphBody': t.emphasis,
|
||||
|
||||
// Bare URLs (https://... / http://...)
|
||||
URL: t.string,
|
||||
|
||||
// Labels (<name>) and references (@name)
|
||||
'Label/"<" Label/">" Label/LabelName': t.labelName,
|
||||
@@ -97,6 +101,9 @@ const typstHighlightStyle = HighlightStyle.define([
|
||||
{ tag: t.heading, fontWeight: 'bold' },
|
||||
{ tag: t.strong, fontWeight: 'bold' },
|
||||
{ tag: t.emphasis, fontStyle: 'italic' },
|
||||
// Named arg keys (fill:, caption:, columns:…) — amber colour that reads
|
||||
// well on both light and dark backgrounds, independent of theme CSS.
|
||||
{ tag: t.attributeName, color: '#c47900' },
|
||||
])
|
||||
|
||||
export const typst = () => {
|
||||
|
||||
@@ -8,22 +8,50 @@ import {
|
||||
RawBlockBody,
|
||||
RawBlockClose,
|
||||
RawInlineContent,
|
||||
CodeBlockBody,
|
||||
BlockCommentBody,
|
||||
LineCommentContent,
|
||||
MathContent,
|
||||
CodeKeyword,
|
||||
CodeIdent,
|
||||
CodeArgKey,
|
||||
StrongBody,
|
||||
EmphBody,
|
||||
} from './typst.terms.mjs'
|
||||
|
||||
const BACKTICK = 96 // `
|
||||
const SLASH = 47 // /
|
||||
const STAR = 42 // *
|
||||
const NEWLINE = 10 // \n
|
||||
const EQUALS = 61 // =
|
||||
const SPACE = 32 //
|
||||
const TAB = 9 // \t
|
||||
const DOLLAR = 36 // $
|
||||
const BACKTICK = 96 // `
|
||||
const SLASH = 47 // /
|
||||
const STAR = 42 // *
|
||||
const NEWLINE = 10 // \n
|
||||
const EQUALS = 61 // =
|
||||
const SPACE = 32 //
|
||||
const TAB = 9 // \t
|
||||
const DOLLAR = 36 // $
|
||||
const OPEN_BRACE = 123 // {
|
||||
const CLOSE_BRACE = 125 // }
|
||||
const HASH = 35 // #
|
||||
const UNDERSCORE = 95 // _
|
||||
const DOT = 46 // .
|
||||
const OPEN_PAREN = 40 // (
|
||||
const COMMA = 44 // ,
|
||||
const COLON = 58 // :
|
||||
const SEMICOLON = 59 // ;
|
||||
const OPEN_ANGLE = 60 // <
|
||||
const CLOSE_ANGLE = 62 // >
|
||||
const PLUS = 43 // +
|
||||
|
||||
const KEYWORDS = new Set([
|
||||
'let', 'set', 'show', 'import', 'include',
|
||||
'if', 'else', 'for', 'while', 'return',
|
||||
'break', 'continue', 'in', 'as',
|
||||
'and', 'or', 'not', 'context',
|
||||
])
|
||||
|
||||
const BOOLS = new Set(['true', 'false', 'none', 'auto'])
|
||||
|
||||
const isAlpha = ch => (ch >= 65 && ch <= 90) || (ch >= 97 && ch <= 122)
|
||||
const isDigit = ch => ch >= 48 && ch <= 57
|
||||
const isIdentHead = ch => isAlpha(ch) || ch === UNDERSCORE
|
||||
const isIdentTail = ch => isAlpha(ch) || isDigit(ch) || ch === UNDERSCORE || ch === 45
|
||||
|
||||
// ── headingTokenizer ────────────────────────────────────────────────────
|
||||
// Emits HeadingMark — the "=+" prefix plus the trailing whitespace.
|
||||
@@ -62,6 +90,17 @@ export const headingTitleTokenizer = new ExternalTokenizer(
|
||||
while (input.next !== -1 && input.next !== NEWLINE) {
|
||||
if (input.next === SLASH &&
|
||||
(input.peek(1) === SLASH || input.peek(1) === STAR)) break
|
||||
// Stop before a trailing '<label>' so it is parsed as a Label node
|
||||
// rather than being merged into the heading title text.
|
||||
// Only stops when '<' is immediately followed by a valid label name and '>'.
|
||||
if (input.next === OPEN_ANGLE) {
|
||||
const ch = input.peek(1)
|
||||
if (isAlpha(ch) || isDigit(ch) || ch === UNDERSCORE) {
|
||||
let j = 2
|
||||
while (isIdentTail(input.peek(j)) || input.peek(j) === DOT || input.peek(j) === COLON) j++
|
||||
if (input.peek(j) === CLOSE_ANGLE) break
|
||||
}
|
||||
}
|
||||
input.advance()
|
||||
hasContent = true
|
||||
}
|
||||
@@ -105,6 +144,20 @@ export const rawTokenizer = new ExternalTokenizer(
|
||||
}
|
||||
|
||||
if (stack.canShift(RawBlockBody)) {
|
||||
// Guard: must genuinely follow a RawBlockOpen (which ends with \n).
|
||||
// Walk backward past any lang tag (A-Za-z0-9) and require ```.
|
||||
// This blocks spurious LALR-merged states from consuming body text.
|
||||
if (input.peek(-1) !== NEWLINE) return
|
||||
let back = -2
|
||||
while (
|
||||
(input.peek(back) >= 65 && input.peek(back) <= 90) ||
|
||||
(input.peek(back) >= 97 && input.peek(back) <= 122) ||
|
||||
(input.peek(back) >= 48 && input.peek(back) <= 57)
|
||||
) { back-- }
|
||||
if (input.peek(back) !== BACKTICK ||
|
||||
input.peek(back - 1) !== BACKTICK ||
|
||||
input.peek(back - 2) !== BACKTICK) return
|
||||
|
||||
let hasContent = false
|
||||
while (input.next !== -1) {
|
||||
if (
|
||||
@@ -136,36 +189,6 @@ export const rawInlineTokenizer = new ExternalTokenizer(
|
||||
{ contextual: false }
|
||||
)
|
||||
|
||||
// ── codeBlockTokenizer ──────────────────────────────────────────────────
|
||||
// Emits CodeBlockBody — the interior of a #{ ... } code block.
|
||||
// Tracks brace nesting depth so that inner braces (e.g. #{ f({ x }) })
|
||||
// are included in the body rather than closing the outer block.
|
||||
export const codeBlockTokenizer = new ExternalTokenizer(
|
||||
(input, _stack) => {
|
||||
// The opening '{' has already been consumed by the grammar rule.
|
||||
let depth = 1
|
||||
let hasContent = false
|
||||
while (input.next !== -1) {
|
||||
const ch = input.next
|
||||
if (ch === OPEN_BRACE) {
|
||||
depth++
|
||||
input.advance()
|
||||
hasContent = true
|
||||
} else if (ch === CLOSE_BRACE) {
|
||||
if (depth === 1) break // leave this '}' for the grammar rule
|
||||
depth--
|
||||
input.advance()
|
||||
hasContent = true
|
||||
} else {
|
||||
input.advance()
|
||||
hasContent = true
|
||||
}
|
||||
}
|
||||
if (hasContent) input.acceptToken(CodeBlockBody)
|
||||
},
|
||||
{ contextual: false }
|
||||
)
|
||||
|
||||
// ── blockCommentTokenizer ───────────────────────────────────────────────
|
||||
// Emits BlockCommentBody — the interior of a /* ... */ comment.
|
||||
// Typst supports nested block comments (/* /* inner */ outer */), so this
|
||||
@@ -215,9 +238,13 @@ export const lineCommentContentTokenizer = new ExternalTokenizer(
|
||||
)
|
||||
|
||||
// ── mathContentTokenizer ────────────────────────────────────────────────
|
||||
// Emits MathContent — everything between the $...$ delimiters (no newlines).
|
||||
// External rather than a @tokens rule for the same reason as LineCommentContent:
|
||||
// ![$\n]+ overlaps with spaces, '<', '@', and other literals in merged states.
|
||||
// Emits MathContent — one line of content between the $...$ delimiters.
|
||||
// Stops at '$' or '\n' so each token is bounded to a single line.
|
||||
//
|
||||
// The grammar uses MathContent* (not MathContent?) so multi-line display
|
||||
// math ($ ... \n ... $) is handled by multiple MathContent tokens, one per
|
||||
// line, with @skip consuming the newlines in between. This keeps each
|
||||
// token short and prevents a stray '$' from consuming the whole document.
|
||||
export const mathContentTokenizer = new ExternalTokenizer(
|
||||
(input, _stack) => {
|
||||
let hasContent = false
|
||||
@@ -229,3 +256,174 @@ export const mathContentTokenizer = new ExternalTokenizer(
|
||||
},
|
||||
{ contextual: false }
|
||||
)
|
||||
|
||||
// ── codeKeywordTokenizer ─────────────────────────────────────────────────
|
||||
// Emits CodeKeyword (let, set, for, while, in, …) ONLY when the preceding
|
||||
// character is '#', i.e. we are immediately after the '#' sigil in a CodeExpr.
|
||||
//
|
||||
// The peek(-1)==='#' guard is what prevents LALR state-merging from causing
|
||||
// these tokens to fire in body-text positions. Common English words like
|
||||
// "in", "for", "while", "return" appear in markup paragraphs; without the
|
||||
// guard they would be highlighted as keywords due to LALR-merged states where
|
||||
// CodeKeyword is technically in the valid set.
|
||||
export const codeKeywordTokenizer = new ExternalTokenizer(
|
||||
(input, stack) => {
|
||||
if (!stack.canShift(CodeKeyword)) return
|
||||
// Valid positions: after '#', ':', '{' (code block start), or ';'.
|
||||
// Walk back past optional whitespace.
|
||||
let back = -1
|
||||
while (input.peek(back) === SPACE || input.peek(back) === TAB || input.peek(back) === NEWLINE) back--
|
||||
const kwPrev = input.peek(back)
|
||||
if (kwPrev !== HASH && kwPrev !== COLON && kwPrev !== OPEN_BRACE && kwPrev !== SEMICOLON) return
|
||||
|
||||
// Peek ahead to read the full identifier without advancing.
|
||||
let len = 0
|
||||
while (true) {
|
||||
const ch = input.peek(len)
|
||||
if (isIdentHead(ch) || (len > 0 && isIdentTail(ch))) { len++ } else { break }
|
||||
}
|
||||
|
||||
if (len === 0) return
|
||||
|
||||
const chars = []
|
||||
for (let i = 0; i < len; i++) chars.push(input.peek(i))
|
||||
const word = String.fromCharCode(...chars)
|
||||
|
||||
if (!KEYWORDS.has(word)) return
|
||||
|
||||
for (let i = 0; i < len; i++) input.advance()
|
||||
input.acceptToken(CodeKeyword)
|
||||
},
|
||||
{ contextual: true }
|
||||
)
|
||||
|
||||
// ── codeIdentTokenizer ───────────────────────────────────────────────────
|
||||
// Emits CodeIdent — identifier tokens inside code expressions (#ident,
|
||||
// #func(args), #obj.method, etc.).
|
||||
//
|
||||
// Moving CodeIdent from @tokens to an external tokenizer allows a
|
||||
// character-level guard: we only emit when the preceding non-whitespace
|
||||
// character is one of '#', '.', '(', ',' — genuine code-context positions.
|
||||
// This stops the token from firing in markup body text where LALR-merged
|
||||
// states would otherwise cause '_italic_' to be consumed as one big
|
||||
// CodeIdent (since '_' is a valid identHead) instead of opening Emphasis.
|
||||
//
|
||||
// Keywords and bools are excluded so codeKeywordTokenizer / CodeBool can
|
||||
// handle them without conflict.
|
||||
//
|
||||
// The backward scan runs BEFORE any canShift gate. canShift(CodeArgKey) is
|
||||
// unreliable (LALR state merging can suppress it even at genuine arg-key
|
||||
// positions, e.g. 'caption:' after a complex nested call like 'table(...)').
|
||||
// We derive couldBeArgKey from character-level evidence ('(' or ',') and use
|
||||
// that to decide whether to continue even when canShift(CodeIdent) is false.
|
||||
export const codeIdentTokenizer = new ExternalTokenizer(
|
||||
(input, stack) => {
|
||||
const couldBeIdent = stack.canShift(CodeIdent)
|
||||
|
||||
// Walk back past whitespace — primary context discriminator.
|
||||
let back = -1
|
||||
while (input.peek(back) === SPACE || input.peek(back) === TAB || input.peek(back) === NEWLINE) back--
|
||||
const prev = input.peek(back)
|
||||
|
||||
if (prev !== HASH && prev !== DOT && prev !== OPEN_PAREN && prev !== COMMA && prev !== EQUALS && prev !== COLON && prev !== PLUS) {
|
||||
if (!isIdentTail(prev)) {
|
||||
// prev is a structural delimiter (e.g. ')' after a function call, '{' at
|
||||
// block start, '}' after a nested block). These are valid statement-start
|
||||
// positions inside a CodeBlock's codeStatement* list. Trust canShift —
|
||||
// it's reliable in the grammar-parsed code-block states.
|
||||
if (!couldBeIdent) return
|
||||
} else {
|
||||
// prev looks like the tail of a preceding word — scan back to find '#' or ':'.
|
||||
// Accepting ':' lets multi-word chains like 'show sel: set text' work.
|
||||
let b = back
|
||||
while (isIdentTail(input.peek(b))) b--
|
||||
while (input.peek(b) === SPACE || input.peek(b) === TAB || input.peek(b) === NEWLINE) b--
|
||||
const chainEnd = input.peek(b)
|
||||
if (chainEnd !== HASH && chainEnd !== COLON) {
|
||||
// Could be second+ statement in a code block (e.g. after 'let x = 1').
|
||||
if (!couldBeIdent) return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// In arg-delimiter positions ('(' or ',') we may emit CodeArgKey regardless
|
||||
// of canShift(CodeIdent) — LALR merging can suppress canShift(CodeIdent)
|
||||
// after a complex first argument (e.g. figure(table(...), caption: ...)).
|
||||
// ':' and '=' are value positions, NOT arg-key positions.
|
||||
const couldBeArgKey = prev === OPEN_PAREN || prev === COMMA
|
||||
if (!couldBeIdent && !couldBeArgKey) return
|
||||
|
||||
// Must start with an identifier head character.
|
||||
if (!isIdentHead(input.next)) return
|
||||
|
||||
// Peek ahead to read the full identifier.
|
||||
let len = 0
|
||||
while (true) {
|
||||
const ch = input.peek(len)
|
||||
if (len === 0 ? isIdentHead(ch) : isIdentTail(ch)) { len++ } else { break }
|
||||
}
|
||||
if (len === 0) return
|
||||
|
||||
const chars = []
|
||||
for (let i = 0; i < len; i++) chars.push(input.peek(i))
|
||||
const word = String.fromCharCode(...chars)
|
||||
|
||||
// Let codeKeywordTokenizer handle keywords; let CodeBool handle bools.
|
||||
if (KEYWORDS.has(word) || BOOLS.has(word)) return
|
||||
|
||||
// Emit CodeArgKey when this identifier is immediately followed by ':'.
|
||||
// Only applies in arg-delimiter positions (couldBeArgKey).
|
||||
let isArgKey = false
|
||||
if (couldBeArgKey) {
|
||||
let afterLen = len
|
||||
while (input.peek(afterLen) === SPACE || input.peek(afterLen) === TAB) afterLen++
|
||||
isArgKey = (input.peek(afterLen) === COLON)
|
||||
}
|
||||
|
||||
for (let i = 0; i < len; i++) input.advance()
|
||||
if (isArgKey) {
|
||||
input.acceptToken(CodeArgKey)
|
||||
} else if (couldBeIdent) {
|
||||
input.acceptToken(CodeIdent)
|
||||
}
|
||||
},
|
||||
{ contextual: true }
|
||||
)
|
||||
|
||||
// ── strongBodyTokenizer ──────────────────────────────────────────────────
|
||||
// Emits StrongBody — the content between the '*' delimiters of a Strong node.
|
||||
//
|
||||
// contextual: true — only fires when StrongBody is in the valid set, i.e.
|
||||
// inside Strong → "*" . StrongBody? "*". This state is very specific and
|
||||
// is not merged with item* by Lezer's aggressive LALR merging, so canShift
|
||||
// is a reliable guard here.
|
||||
//
|
||||
// Reads everything up to the first '*' or newline (Typst bold does not span
|
||||
// lines). A trailing '*' that is the closing delimiter is left for the
|
||||
// grammar rule to consume.
|
||||
export const strongBodyTokenizer = new ExternalTokenizer(
|
||||
(input, _stack) => {
|
||||
let hasContent = false
|
||||
while (input.next !== -1 && input.next !== STAR && input.next !== NEWLINE) {
|
||||
input.advance()
|
||||
hasContent = true
|
||||
}
|
||||
if (hasContent) input.acceptToken(StrongBody)
|
||||
},
|
||||
{ contextual: true }
|
||||
)
|
||||
|
||||
// ── emphBodyTokenizer ────────────────────────────────────────────────────
|
||||
// Emits EmphBody — the content between the '_' delimiters of an Emphasis node.
|
||||
// Same design as strongBodyTokenizer; stops at '_' or newline.
|
||||
export const emphBodyTokenizer = new ExternalTokenizer(
|
||||
(input, _stack) => {
|
||||
let hasContent = false
|
||||
while (input.next !== -1 && input.next !== UNDERSCORE && input.next !== NEWLINE) {
|
||||
input.advance()
|
||||
hasContent = true
|
||||
}
|
||||
if (hasContent) input.acceptToken(EmphBody)
|
||||
},
|
||||
{ contextual: true }
|
||||
)
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
// headingTitleTokenizer — HeadingTitle: the title text to end of line
|
||||
// rawTokenizer — triple-backtick raw block open/body/close
|
||||
// rawInlineTokenizer — single-backtick raw inline content
|
||||
// codeBlockTokenizer — brace-depth tracking inside #{ ... }
|
||||
// blockCommentTokenizer — depth-tracked nested /* ... */ comments
|
||||
// codeIdentTokenizer — CodeIdent: identifier, only fires in code context
|
||||
// strongBodyTokenizer — StrongBody: content inside *...*
|
||||
// emphBodyTokenizer — EmphBody: content inside _..._
|
||||
|
||||
@top Document { item* }
|
||||
|
||||
@@ -24,8 +26,9 @@ item {
|
||||
Label |
|
||||
Ref |
|
||||
Escape |
|
||||
Newline |
|
||||
MarkupContent
|
||||
URL |
|
||||
MarkupContent |
|
||||
ClosingSquare
|
||||
}
|
||||
|
||||
// ── Headings ──────────────────────────────────────────────────────────────
|
||||
@@ -58,63 +61,140 @@ RawInline { "`" RawInlineContent? "`" }
|
||||
// #[ ... ] — content block (re-parses as markup items)
|
||||
CodeExpr { "#" codeExprBody }
|
||||
|
||||
// codeExprBody: forms valid after '#' in markup, or after ':' / '=' in a
|
||||
// keyword-body. FuncExpr handles ident+callSuffix(s); bare CodeIdent handles
|
||||
// a plain variable reference (#x). No CallExpr with callSuffix* here — that
|
||||
// *-quantifier makes both shift and reduce carry !call precedence (a tie that
|
||||
// @right cannot resolve reliably once codeStatement* state-merging is in play).
|
||||
codeExprBody {
|
||||
KeywordExpr |
|
||||
AtomExpr |
|
||||
CallExpr |
|
||||
FuncExpr |
|
||||
CodeIdent |
|
||||
CodeBlock |
|
||||
ContentBlock
|
||||
}
|
||||
|
||||
// CallExpr? covers '#set text(size: 12pt)', '#show heading: ...', etc.
|
||||
// The optional CallExpr is only shifted when the next token is CodeIdent,
|
||||
// so there is no shift/reduce conflict with other items that follow keywords.
|
||||
KeywordExpr { CodeKeyword CallExpr? }
|
||||
// callOrValue covers the subject of a keyword expression (#set text, #show link,
|
||||
// #import "pkg", #let name). keywordBody is exclusive: ':' for show-rule bodies
|
||||
// and '=' for let-binding values (a keyword expression never has both).
|
||||
// Two precedences:
|
||||
// call @right — prefer extending callSuffixes (FuncExpr) over completing the
|
||||
// FuncExpr and letting '(' start a new statement. The `!call` marker
|
||||
// encodes the shift as (call << 2) and the FuncExpr reduce as
|
||||
// (call << 2) - 1 (due to @right); shift > reduce, so callSuffix
|
||||
// chains are greedily extended. Without @right both actions have
|
||||
// the same numeric precedence and the conflict is unresolved.
|
||||
// kw — prefer CodeKeyword !kw callOrValueAndBody over CodeKeyword keywordBody?
|
||||
// when an identifier follows the keyword. shift = kw << 2, reduce
|
||||
// (second alternative) = 0; kw > 0, no @right needed.
|
||||
// add — resolves the shift/reduce conflict when a '+' follows a codeArgValue:
|
||||
// SHIFT '+' (extend codeArgValue → codeArgValue !add "+" codeValue): prec add
|
||||
// REDUCE codeArgItem → codeArgValue (complete arg): prec 0
|
||||
// add > 0 → shift wins, so 0.8pt + brand stays as one arg value.
|
||||
@precedence { call @right, kw, add }
|
||||
|
||||
// KeywordExpr: used in markup-level code (#show, #let, #set …) AND nested
|
||||
// inside codeExprBody (e.g. the RHS after ':' in a show-rule).
|
||||
// Same two-alternative structure as codeStatement: the !kw on the first
|
||||
// alternative gives the shift prec kw > 0 over the unannotated reduce of the
|
||||
// second alternative (prec 0). This avoids the call-vs-call tie that arises
|
||||
// from the old `callOrValue?` optional pattern.
|
||||
KeywordExpr {
|
||||
CodeKeyword !kw callOrValueAndBody |
|
||||
CodeKeyword keywordBody?
|
||||
}
|
||||
|
||||
// callOrValue: FuncExpr for "ident(args)" / "ident.method", bare CodeIdent for
|
||||
// a plain name, CodeString for string subjects like #import "pkg".
|
||||
// FuncExpr requires at least one callSuffix, so at [CodeIdent ·] seeing '(':
|
||||
// SHIFT (start callSuffixes, prec call) vs REDUCE bare CodeIdent (prec 0).
|
||||
// call > 0 → shift wins cleanly.
|
||||
callOrValue { FuncExpr | CodeIdent | CodeString }
|
||||
keywordBody { ":" codeExprBody | "=" codeValue }
|
||||
AtomExpr { CodeBool }
|
||||
|
||||
CallExpr { CodeIdent callSuffix* }
|
||||
// codeStatement is the unit inside a CodeBlock's brace body.
|
||||
// Two explicit alternatives for the keyword case avoid the LALR ambiguity
|
||||
// that arises from codeStatement* merging when callOrValue? is optional.
|
||||
// The !kw annotation on the first alternative (shift callOrValueAndBody) has
|
||||
// higher precedence than the bare reduce of the second alternative (prec 0),
|
||||
// so 'show strong: …' grabs 'strong' as callOrValue rather than completing
|
||||
// KeywordExpr early with empty callOrValue.
|
||||
codeStatement {
|
||||
CodeKeyword !kw callOrValueAndBody |
|
||||
CodeKeyword keywordBody? |
|
||||
codeValue |
|
||||
";"
|
||||
}
|
||||
callOrValueAndBody { callOrValue keywordBody? }
|
||||
|
||||
// FuncExpr: identifier followed by one-or-more call suffixes.
|
||||
// callSuffixes uses explicit left-recursion (not +) so the !call annotation
|
||||
// on the recursive extension point gives the shift prec call vs the unannotated
|
||||
// reduce of codeValue → FuncExpr (prec 0) — shift wins, no @right tie.
|
||||
callSuffixes { callSuffix | callSuffixes !call callSuffix }
|
||||
FuncExpr { CodeIdent !call callSuffixes }
|
||||
callSuffix {
|
||||
CodeArgs |
|
||||
"." CodeIdent
|
||||
"." CodeIdent |
|
||||
ContentBlock
|
||||
}
|
||||
|
||||
CodeArgs { "(" codeArgList? ")" }
|
||||
codeArgList { codeArgItem ("," codeArgItem)* ","? }
|
||||
codeArgItem {
|
||||
CodeIdent ":" codeValue |
|
||||
codeValue
|
||||
CodeArgKey ":" codeArgValue |
|
||||
codeArgValue
|
||||
}
|
||||
|
||||
// codeArgValue extends codeValue with '+' chaining for expressions like
|
||||
// `stroke: 0.8pt + brand` or `fill: base + overlay`.
|
||||
// Left-recursive rule: LALR state for codeArgValue · seeing '+':
|
||||
// SHIFT '+' (extend, !add prec): prec add > 0
|
||||
// REDUCE codeArgItem → codeArgValue (complete): prec 0
|
||||
// add > 0 → shift wins cleanly. No @right needed (strict dominance).
|
||||
// Only used inside CodeArgs, so codeStatement* LALR-merging does not apply.
|
||||
codeArgValue { codeValue | codeArgValue !add "+" codeValue }
|
||||
|
||||
codeValue {
|
||||
CodeString |
|
||||
CodeNumber |
|
||||
CodeBool |
|
||||
CallExpr |
|
||||
FuncExpr |
|
||||
CodeIdent |
|
||||
ContentBlock |
|
||||
CodeBlock |
|
||||
InlineMath
|
||||
InlineMath |
|
||||
CodeArray
|
||||
}
|
||||
|
||||
// CodeBlockBody depth-tracks braces so #{ let x = { 1 } } parses correctly.
|
||||
CodeBlock { "{" CodeBlockBody? "}" }
|
||||
// Typst array / tuple / dictionary literal: (a, b) or (key: val, …)
|
||||
// Reuses codeArgList so named-key entries like (auto, 1fr) work too.
|
||||
CodeArray { "(" codeArgList? ")" }
|
||||
|
||||
// CodeBlock parses its content as a codeStatement* list so that keywords
|
||||
// (show, let, set…) and identifiers inside braces receive proper highlighting.
|
||||
CodeBlock { "{" codeStatement* "}" }
|
||||
// ContentBlock re-enters markup mode, allowing #[*bold* text].
|
||||
ContentBlock { "[" item* "]" }
|
||||
|
||||
// ── Math ──────────────────────────────────────────────────────────────────
|
||||
// Both inline ($x^2$) and display ($ x^2 $) math use the same node type.
|
||||
InlineMath { "$" MathContent? "$" }
|
||||
// MathContent* (not ?) allows multi-line display math: each line becomes one
|
||||
// MathContent token (stopping at '\n'), and @skip consumes the newlines between.
|
||||
InlineMath { "$" MathContent* "$" }
|
||||
|
||||
// ── Markup formatting ─────────────────────────────────────────────────────
|
||||
// Cross-nesting of Strong/Emphasis is intentionally excluded to avoid a
|
||||
// mutual-recursion cycle (Strong→Emphasis→Strong) that causes state explosion
|
||||
// in the Lezer LR automaton builder. StrongText includes '_' and EmphText
|
||||
// includes '*', so the nested delimiters are treated as plain text inside the
|
||||
// opposite construct rather than producing error nodes.
|
||||
Strong { "*" strongItem* "*" }
|
||||
strongItem { CodeExpr | InlineMath | RawInline | Label | Ref | StrongText }
|
||||
|
||||
Emphasis { "_" emphItem* "_" }
|
||||
emphItem { CodeExpr | InlineMath | RawInline | Label | Ref | EmphText }
|
||||
// Strong and Emphasis use flat external body tokens (StrongBody / EmphBody)
|
||||
// rather than recursive strongItem* / emphItem* loops. The loop approach
|
||||
// triggered LALR state merging that caused item*-level tokens (MarkupContent,
|
||||
// CodeIdent) to win over StrongText/EmphText inside the construct, so the
|
||||
// body nodes were never produced. The flat external tokens are contextual
|
||||
// (canShift only fires inside Strong/Emphasis) and reliably avoid those
|
||||
// merged states.
|
||||
Strong { "*" StrongBody? "*" }
|
||||
Emphasis { "_" EmphBody? "_" }
|
||||
|
||||
// ── Labels and references ─────────────────────────────────────────────────
|
||||
Label { "<" LabelName ">" }
|
||||
@@ -142,10 +222,6 @@ Escape { "\\" EscapeChar }
|
||||
RawInlineContent
|
||||
}
|
||||
|
||||
@external tokens codeBlockTokenizer from "./tokens.mjs" {
|
||||
CodeBlockBody
|
||||
}
|
||||
|
||||
@external tokens blockCommentTokenizer from "./tokens.mjs" {
|
||||
BlockCommentBody
|
||||
}
|
||||
@@ -158,30 +234,44 @@ Escape { "\\" EscapeChar }
|
||||
MathContent
|
||||
}
|
||||
|
||||
@external tokens codeKeywordTokenizer from "./tokens.mjs" {
|
||||
CodeKeyword
|
||||
}
|
||||
|
||||
// CodeIdent is external so codeIdentTokenizer can apply a character-level
|
||||
// guard: it only emits when the preceding non-whitespace character is one of
|
||||
// '#', '.', '(', ',' — i.e. genuinely inside a code expression. This stops
|
||||
// the token from firing in markup body text, where LALR state merging would
|
||||
// otherwise cause the entire token (including any leading '_') to be consumed
|
||||
// as a code identifier instead of letting '_' open an Emphasis.
|
||||
// CodeArgKey is emitted by the same tokenizer when an identifier is immediately
|
||||
// followed by ':' — the tokenizer pre-disambiguates named arg keys so the LALR
|
||||
// parser does not need to choose between codeArgItem alternatives on lookahead.
|
||||
@external tokens codeIdentTokenizer from "./tokens.mjs" {
|
||||
CodeIdent,
|
||||
CodeArgKey
|
||||
}
|
||||
|
||||
@external tokens strongBodyTokenizer from "./tokens.mjs" {
|
||||
StrongBody
|
||||
}
|
||||
|
||||
@external tokens emphBodyTokenizer from "./tokens.mjs" {
|
||||
EmphBody
|
||||
}
|
||||
|
||||
// ── Regular tokens ────────────────────────────────────────────────────────
|
||||
@tokens {
|
||||
// Horizontal whitespace only. Newlines are kept as explicit Newline items
|
||||
// so that HeadingMark (which checks start-of-line via input.peek(-1)) can
|
||||
// reliably detect newlines in the raw input stream.
|
||||
spaces { $[ \t]+ }
|
||||
|
||||
// Keywords take precedence over identifiers when they match fully
|
||||
// (e.g. "let" → CodeKeyword, "letter" → CodeIdent).
|
||||
CodeKeyword {
|
||||
"let" | "set" | "show" | "import" | "include" |
|
||||
"if" | "else" | "for" | "while" | "return" |
|
||||
"break" | "continue" | "in" | "as" |
|
||||
"and" | "or" | "not" | "context"
|
||||
}
|
||||
// All whitespace including newlines. Heading detection still works because
|
||||
// headingTokenizer uses input.peek(-1) on the raw character stream — it sees
|
||||
// the '\n' byte regardless of what @skip consumes at the token level.
|
||||
// Including '\n' here lets multi-line code expressions (e.g. #figure(\n ...\n))
|
||||
// parse without error instead of triggering Lezer error recovery.
|
||||
spaces { $[ \t\n\r]+ }
|
||||
|
||||
// Boolean / null literals — distinct from keywords for highlighting.
|
||||
CodeBool { "true" | "false" | "none" | "auto" }
|
||||
|
||||
// General identifier: [A-Za-z_][A-Za-z0-9_-]*
|
||||
CodeIdent { identHead identTail* }
|
||||
identHead { @asciiLetter | "_" }
|
||||
identTail { @asciiLetter | @digit | "_" | "-" }
|
||||
|
||||
// Double-quoted string with backslash escapes (no single-quoted strings in Typst).
|
||||
CodeString { '"' (!["\\\n] | "\\" _)* '"' }
|
||||
|
||||
@@ -191,41 +281,42 @@ Escape { "\\" EscapeChar }
|
||||
("pt" | "mm" | "cm" | "in" | "em" | "rem" | "fr" | "deg" | "rad" | "%")?
|
||||
}
|
||||
|
||||
// Text tokens for markup contexts; each excludes its own delimiters.
|
||||
// HeadingText, LineCommentContent, and MathContent are external tokens
|
||||
// (see above) — broad "read-to-delimiter" tokens that would otherwise
|
||||
// conflict with every other literal token in LALR-merged states.
|
||||
// '<' is excluded from StrongText/EmphText so that Label ('<' LabelName '>')
|
||||
// is recognised inside strong/emphasis rather than consumed as plain text.
|
||||
StrongText { ![\n*$#`<@\\]+ }
|
||||
EmphText { ![\n_$#`<@\\]+ }
|
||||
// URL: bare https:// or http:// links in markup text. Matched as a single
|
||||
// token so '://' is never split into ':' + LineComment '//…'. Stops at
|
||||
// whitespace and angle brackets (labels use '<…>').
|
||||
URL { ("https" | "http") "://" (![ \t\n<>])* }
|
||||
|
||||
// Regular markup: excludes all special-character starters plus whitespace
|
||||
// (whitespace is handled by @skip). The '/' is excluded so that '//' and
|
||||
// '/*' are not accidentally consumed as plain text.
|
||||
MarkupContent { ![\n \t=*_$#/<@`\\]+ }
|
||||
// '/*' are not accidentally consumed as plain text. ']' is excluded so
|
||||
// that ContentBlock { "[" item* "]" } can always close reliably — a bare
|
||||
// ']' in body text is matched as ClosingSquare instead.
|
||||
MarkupContent { ![\n \t\]=*_$#/<@`\\]+ }
|
||||
|
||||
// Fallback for a bare ']' in markup text (outside any ContentBlock).
|
||||
// Inside ContentBlock the literal "]" terminal wins via @precedence.
|
||||
ClosingSquare { "]" }
|
||||
|
||||
// Label names: identifiers with optional dots/colons (e.g. <sec:intro>).
|
||||
LabelName { (identHead | @digit) (identTail | "." | ":")* }
|
||||
RefName { identHead identTail* }
|
||||
LabelName { (@asciiLetter | "_" | @digit) (@asciiLetter | @digit | "_" | "-" | "." | ":")* }
|
||||
RefName { (@asciiLetter | "_") (@asciiLetter | @digit | "_" | "-")* }
|
||||
|
||||
// Escape: any single character after backslash.
|
||||
EscapeChar { _ }
|
||||
|
||||
// Newline item — kept out of @skip so heading detection works.
|
||||
Newline { "\n" }
|
||||
|
||||
// Resolve ambiguities: more-specific tokens win over broader catch-alls.
|
||||
// EscapeChar > spaces: after '\', EscapeChar must win over the skip token
|
||||
// (both match \t; without this, '\t' would be mis-tokenized).
|
||||
// "(" > "." > "]" > text tokens: after '#' CodeIdent, callSuffix delimiters
|
||||
// must win over MarkupContent/StrongText/EmphText in merged states.
|
||||
// LineCommentContent and MathContent are external tokens — not listed here.
|
||||
// "_" added after CodeIdent: KeywordExpr { CodeKeyword CallExpr? } merges
|
||||
// the post-keyword state with markup states where "_" starts Emphasis.
|
||||
// CodeIdent wins so '#set _name(...)' is tokenised correctly; in pure markup
|
||||
// states CodeIdent is not in the valid set so "_" still opens Emphasis.
|
||||
@precedence { CodeKeyword CodeBool CodeIdent EscapeChar "(" "." "]" "_" spaces MarkupContent StrongText EmphText }
|
||||
// Resolve ambiguities in merged states:
|
||||
// EscapeChar > spaces: after '\', EscapeChar must win over the skip token.
|
||||
// "(" > "." > "]": callSuffix delimiters must win over MarkupContent after
|
||||
// a code identifier (merged states expose these to the markup tokenizer).
|
||||
// "_" > MarkupContent: '_' must open Emphasis rather than being swallowed
|
||||
// by MarkupContent (redundant since '_' is in MarkupContent's exclusion
|
||||
// set, but kept for clarity).
|
||||
// CodeIdent and StrongText/EmphText are now external tokens — not listed.
|
||||
// "[" > MarkupContent: ContentBlock callSuffix wins in merged code/markup states.
|
||||
// CodeString > MarkupContent: '"' starts a string literal after a keyword.
|
||||
// ":" > MarkupContent: keywordBody ':' wins over markup colon in code states.
|
||||
// URL > MarkupContent: 'https://' / 'http://' wins over plain markup text.
|
||||
@precedence { CodeBool EscapeChar CodeString URL "[" ":" "(" "." "+" "]" ClosingSquare "_" spaces MarkupContent }
|
||||
}
|
||||
|
||||
@skip { spaces }
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,45 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
export const
|
||||
HeadingMark = 1,
|
||||
HeadingTitle = 2,
|
||||
RawBlockOpen = 3,
|
||||
RawBlockBody = 4,
|
||||
RawBlockClose = 5,
|
||||
RawInlineContent = 6,
|
||||
BlockCommentBody = 7,
|
||||
LineCommentContent = 8,
|
||||
MathContent = 9,
|
||||
CodeKeyword = 10,
|
||||
CodeIdent = 11,
|
||||
CodeArgKey = 12,
|
||||
StrongBody = 13,
|
||||
EmphBody = 14,
|
||||
Document = 15,
|
||||
Heading = 16,
|
||||
LineComment = 17,
|
||||
BlockComment = 18,
|
||||
RawBlock = 19,
|
||||
RawInline = 20,
|
||||
CodeExpr = 21,
|
||||
KeywordExpr = 22,
|
||||
FuncExpr = 23,
|
||||
CodeArgs = 24,
|
||||
CodeString = 25,
|
||||
CodeNumber = 26,
|
||||
CodeBool = 27,
|
||||
ContentBlock = 28,
|
||||
CodeBlock = 29,
|
||||
InlineMath = 30,
|
||||
CodeArray = 31,
|
||||
AtomExpr = 32,
|
||||
Strong = 33,
|
||||
Emphasis = 34,
|
||||
Label = 35,
|
||||
LabelName = 36,
|
||||
Ref = 37,
|
||||
RefName = 38,
|
||||
Escape = 39,
|
||||
EscapeChar = 40,
|
||||
URL = 41,
|
||||
MarkupContent = 42,
|
||||
ClosingSquare = 43
|
||||
@@ -73,7 +73,10 @@
|
||||
},
|
||||
".tok-variableName": {
|
||||
"color": "#9b859d"
|
||||
},
|
||||
".tok-attributeName": {
|
||||
"color": "#F4BF75"
|
||||
}
|
||||
},
|
||||
"dark": true
|
||||
}
|
||||
}
|
||||
@@ -74,7 +74,10 @@
|
||||
},
|
||||
".tok-variableName": {
|
||||
"color": "#FF80E1"
|
||||
},
|
||||
".tok-attributeName": {
|
||||
"color": "#FFD700"
|
||||
}
|
||||
},
|
||||
"dark": true
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,10 @@
|
||||
},
|
||||
".tok-attributeValue": {
|
||||
"color": "rgb(0, 64, 128)"
|
||||
},
|
||||
".tok-attributeName": {
|
||||
"color": "#994409"
|
||||
}
|
||||
},
|
||||
"dark": false
|
||||
}
|
||||
}
|
||||
@@ -67,7 +67,10 @@
|
||||
},
|
||||
".tok-attributeValue": {
|
||||
"color": "#234A97"
|
||||
},
|
||||
".tok-attributeName": {
|
||||
"color": "#7B3814"
|
||||
}
|
||||
},
|
||||
"dark": false
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,10 @@
|
||||
},
|
||||
".tok-list": {
|
||||
"color": "rgb(185, 6, 144)"
|
||||
},
|
||||
".tok-attributeName": {
|
||||
"color": "#994409"
|
||||
}
|
||||
},
|
||||
"dark": false
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,10 @@
|
||||
".tok-regexp": {
|
||||
"color": "#009926",
|
||||
"fontWeight": "normal"
|
||||
},
|
||||
".tok-attributeName": {
|
||||
"color": "#735C0F"
|
||||
}
|
||||
},
|
||||
"dark": false
|
||||
}
|
||||
}
|
||||
@@ -71,7 +71,10 @@
|
||||
".tok-comment": {
|
||||
"fontStyle": "italic",
|
||||
"color": "#00E060"
|
||||
},
|
||||
".tok-attributeName": {
|
||||
"color": "#F4BF75"
|
||||
}
|
||||
},
|
||||
"dark": true
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,10 @@
|
||||
},
|
||||
".tok-operator": {
|
||||
"color": "#EBDAB4"
|
||||
},
|
||||
".tok-attributeName": {
|
||||
"color": "#FABD2F"
|
||||
}
|
||||
},
|
||||
"dark": true
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,10 @@
|
||||
".tok-comment": {
|
||||
"fontStyle": "italic",
|
||||
"color": "#BC9458"
|
||||
},
|
||||
".tok-attributeName": {
|
||||
"color": "#DA4939"
|
||||
}
|
||||
},
|
||||
"dark": true
|
||||
}
|
||||
}
|
||||
@@ -70,7 +70,10 @@
|
||||
},
|
||||
".tok-variableName": {
|
||||
"color": "#FF80E1"
|
||||
},
|
||||
".tok-attributeName": {
|
||||
"color": "#d4c96e"
|
||||
}
|
||||
},
|
||||
"dark": true
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,10 @@
|
||||
},
|
||||
".tok-list": {
|
||||
"color": "#8F5B26"
|
||||
},
|
||||
".tok-attributeName": {
|
||||
"color": "#994409"
|
||||
}
|
||||
},
|
||||
"dark": false
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,10 @@
|
||||
},
|
||||
".tok-number": {
|
||||
"color": "#5A5CAD"
|
||||
},
|
||||
".tok-attributeName": {
|
||||
"color": "#7B3F00"
|
||||
}
|
||||
},
|
||||
"dark": false
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,10 @@
|
||||
},
|
||||
".tok-variableName": {
|
||||
"color": "#C1C144"
|
||||
},
|
||||
".tok-attributeName": {
|
||||
"color": "#ACA0DC"
|
||||
}
|
||||
},
|
||||
"dark": true
|
||||
}
|
||||
}
|
||||
@@ -72,7 +72,10 @@
|
||||
},
|
||||
".tok-list": {
|
||||
"color": "rgb(185, 6, 144)"
|
||||
},
|
||||
".tok-attributeName": {
|
||||
"color": "#994409"
|
||||
}
|
||||
},
|
||||
"dark": false
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,10 @@
|
||||
},
|
||||
".tok-attributeValue": {
|
||||
"color": "#7587A6"
|
||||
},
|
||||
".tok-attributeName": {
|
||||
"color": "#CF6A4C"
|
||||
}
|
||||
},
|
||||
"dark": true
|
||||
}
|
||||
}
|
||||
@@ -407,15 +407,19 @@ ul.project-list-filters {
|
||||
white-space: nowrap;
|
||||
|
||||
&.project-format-badge-quarto {
|
||||
background-color: #447099;
|
||||
background-color: #447099; // Quarto blue (PDF output)
|
||||
}
|
||||
|
||||
&.project-format-badge-quarto-slides {
|
||||
background-color: #e4637c; // RevealJS pink-red
|
||||
}
|
||||
|
||||
&.project-format-badge-typst {
|
||||
background-color: #ee6331;
|
||||
background-color: #239dad; // typst.app brand blue
|
||||
}
|
||||
|
||||
&.project-format-badge-latex {
|
||||
background-color: #72994e;
|
||||
background-color: #098842; // Overleaf brand green
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ export type ProjectApi = {
|
||||
accessLevel: ProjectAccessLevel
|
||||
source: Source
|
||||
compiler?: ProjectCompiler
|
||||
quartoFlavor?: 'revealjs' | 'pdf'
|
||||
}
|
||||
|
||||
export type Project = MergeAndOverride<
|
||||
|
||||
Reference in New Issue
Block a user