Merge pull request #35680 from overleaf/mfb-migrate-bibtex-parser-to-core-feature

Migrate BibTeX parser to core frontend feature

GitOrigin-RevId: 274f64d31c1027c5eea9ec569e54dece57307220
This commit is contained in:
Maria Florencia Besteiro Gonzalez
2026-09-17 11:36:26 +00:00
committed by Copybot
parent f9a2a0ad6c
commit 4132731016
10 changed files with 2339 additions and 0 deletions
@@ -0,0 +1,13 @@
export type BibEntryInput = {
key: string
label: string
helperText: string
}
export type BibEntryType = {
key: string
label: string
fields: string[]
optionalFields: string[]
requiredFields: (string | string[])[]
}
@@ -0,0 +1,174 @@
import { BibtexFieldValue } from './bibtex-field-value.mts'
import { getEntryType } from './entry-types.mts'
import { BibtexNameList } from './bibtex-name.mts'
type Fields = ReadonlyMap<string, BibtexFieldValue>
type BibtexEntryOptions = {
type?: string
key?: string
fields?: Fields
id?: string
updatedAt?: Date
}
/**
* Immutable BibTeX entry content. Position information lives separately on
* `PositionedBibtexEntry` so `BibtexEntry` references can stay stable across
* pure position shifts (e.g. when typing above an entry).
*/
export class BibtexEntry {
/**
* Entry type
*/
readonly type: string
/**
* Citation key
*/
readonly key: string
/**
* Fields
*/
readonly fields: Fields
/**
* Last updated timestamp (set when loaded from the library)
*/
readonly updatedAt: Date | undefined
private readonly _id: string
/**
* Stable identifier. Defaults to the citation key for in-project entries;
* set to the database record ID for library entries.
*/
get id(): string {
return this._id || this.key
}
constructor({
type = 'article',
key = '',
fields = new Map(),
id = '',
updatedAt,
}: BibtexEntryOptions = {}) {
this.type = type
this.key = key
this.fields = fields
this._id = id
this.updatedAt = updatedAt
}
setType(type: string) {
return new BibtexEntry({ ...this, type, id: this._id })
}
setKey(key: string) {
return new BibtexEntry({ ...this, key, id: this._id })
}
getField(field: string) {
return this.fields.get(field) ?? new BibtexFieldValue()
}
getFields() {
return this.fields.keys()
}
getFieldsArray() {
return Array.from(this.fields.entries()).map(([key, value]) => ({
key,
value,
}))
}
/** Returns all authors (and editors, as fallback) as a parsed BibtexNameList. */
getAuthors(): BibtexNameList {
const authorStr =
this.getField('author').toDisplayString() ||
this.getField('editor').toDisplayString()
return new BibtexNameList(authorStr)
}
getTitle(): string {
return (
this.getField('title').toDisplayString() ||
this.getField('subtitle').toDisplayString()
)
}
getYear(): string {
const year = this.getField('year').toDisplayString()
if (year) return year
const dateStr = this.getField('date').toDisplayString()
const match = dateStr.match(/^\d{4}/)
return match ? match[0] : ''
}
hasField(field: string) {
return this.fields.has(field)
}
hasFields() {
return this.fields.size > 0
}
setField(field: string, value: BibtexFieldValue) {
const fields = new Map(this.fields)
fields.set(field, value)
return new BibtexEntry({ ...this, fields, id: this._id })
}
deleteField(field: string) {
const fields = new Map(this.fields)
fields.delete(field)
return new BibtexEntry({ ...this, fields, id: this._id })
}
toString(): string {
let bibEntry = `@${this.type}{${this.key},\n`
for (const [field, value] of this.fields) {
bibEntry += ` ${field} = ${value.toString()},\n`
}
bibEntry += '}'
return bibEntry
}
getWarnings(): BibtexEntryWarning[] {
const warnings: BibtexEntryWarning[] = []
const missingFieldGroups = this.getMissingFieldGroups()
if (missingFieldGroups.length > 0) {
warnings.push(new MissingFieldsWarning(missingFieldGroups))
}
return warnings
}
getMissingFieldGroups(): string[][] {
const entryType = getEntryType(this.type)
const requiredFields = entryType.requiredFields
const missingFields: string[][] = []
for (const requiredField of requiredFields) {
const group = Array.isArray(requiredField)
? requiredField
: [requiredField]
if (!group.some(field => this.hasField(field))) {
missingFields.push(group)
}
}
return missingFields
}
}
export class MissingFieldsWarning {
fieldGroups: string[][]
constructor(fieldGroups: string[][]) {
this.fieldGroups = fieldGroups
}
}
export type BibtexEntryWarning = MissingFieldsWarning
@@ -0,0 +1,278 @@
import { latexToUnicode } from './latex-to-unicode.mts'
import { purifySpecialChars } from './bibtex-purify.mts'
const IDENTIFIER_SYMBOLS = '!$&*+./:;<>?^`_|[]-'
class StringLiteral {
private value: string
constructor(value: string) {
this.value = value
}
toString() {
return `{${this.value}}`
}
toEditableString() {
return this.value.replaceAll('#', '##')
}
toDisplayString() {
let s = this.value
s = latexToUnicode(s)
s = purifySpecialChars(s)
s = s.replaceAll(/[~\s]+/g, ' ')
s = s.replaceAll(/[{}\\]/g, '')
return s
}
}
class NumberLiteral {
private value: string
constructor(value: string) {
this.value = value
}
toString() {
return this.value
}
toEditableString() {
return this.value
}
toDisplayString() {
return this.value
}
}
class NamedString {
private name: string
constructor(name: string) {
this.name = name
}
toString() {
return this.name
}
toEditableString() {
return `#${this.name}#`
}
toDisplayString() {
return this.name
}
}
type Part = StringLiteral | NumberLiteral | NamedString
export class BibtexFieldValue {
private readonly parts: ReadonlyArray<Part> = []
private bibtexString: string | null = null
private displayString: string | null = null
private editableString: string | null = null
constructor(parts: Part[] = []) {
this.parts = parts
}
addString(value: string) {
return new BibtexFieldValue([...this.parts, new StringLiteral(value)])
}
addNumber(value: string) {
return new BibtexFieldValue([...this.parts, new NumberLiteral(value)])
}
addNamedString(name: string) {
return new BibtexFieldValue([...this.parts, new NamedString(name)])
}
private invalidateCache() {
this.bibtexString = null
this.editableString = null
this.displayString = null
}
toString() {
if (this.bibtexString == null) {
if (this.parts.length === 0) {
return '{}'
}
this.bibtexString = this.parts.map(part => part.toString()).join(' # ')
}
return this.bibtexString
}
/**
* Convert a BibtexFieldValue to editable field text.
*
* Steps:
* 1. String literals are copied as-is, but every '#' is escaped as '##'.
* 2. Numbers are copied as text.
* 3. Named strings are wrapped as '#name#'.
* 4. All parts are concatenated directly.
*
* Example:
* BibTeX:
* {This is } # author # {'s } # {#1 } # book # { from } # 1995
* Editable:
* This is #author#'s ##1 #book# from 1995
*
* Example:
* BibTeX:
* "The " # DS # " handbook"
* Editable:
* The #DS# handbook
*/
toEditableString() {
if (this.editableString == null) {
this.editableString = this.parts
.map(part => part.toEditableString())
.join('')
}
return this.editableString
}
/**
* Convert a BibtexFieldValue to a string suitable for display.
*
* - Replace tildes with spaces
* - Replace successions of whitespace characters with a single space
* - Trim leading and trailing whitespace
* - Remove curly braces and backslashes
*/
toDisplayString() {
if (this.displayString == null) {
let s = this.parts.map(part => part.toDisplayString()).join('')
s = s.replaceAll(/^\s+|\s+$/g, '')
this.displayString = s
}
return this.displayString
}
/**
* Make a new BibtexFieldValue from an editable string
*
* Scan left-to-right:
* 1. '##' becomes a literal '#'.
* 2. '#identifier#' becomes a named string part.
* 3. Any other '#' is treated as a literal '#'.
* 4. Literal runs become braced strings and parts are joined with ' # '.
*
* Identifier rule:
* - First char: letter or one of ! $ & * + . / : ; < > ? ^ ` _ | [ ] -
* - Following chars: same set plus digits.
* - Cannot start with a digit.
*
* Example:
* Editable:
* This is #author#'s #1 book (#numberone) and here's a ##tag##
* BibTeX:
* {This is } # author # {'s #1 book (#numberone) and here's a #tag#}
*
* Example:
* Editable:
* This is from 1995
* BibTeX:
* {This is from 1995}
*/
static fromEditableString(editable: string) {
let bibtexValue = new BibtexFieldValue()
let currentString = ''
let index = 0
while (index < editable.length) {
if (editable[index] !== '#') {
currentString += editable[index]
index += 1
continue
}
const nextIndex = index + 1
const nextChar = editable[nextIndex]
if (nextChar === '#') {
currentString += '#'
index += 2
continue
}
const identifierEnd = findBibtexIdentifierEndExclusive(
editable,
nextIndex
)
if (identifierEnd !== null && editable[identifierEnd] === '#') {
if (currentString !== '') {
bibtexValue = bibtexValue.addString(currentString)
currentString = ''
}
bibtexValue = bibtexValue.addNamedString(
editable.slice(nextIndex, identifierEnd)
)
index = identifierEnd + 1
continue
}
currentString += '#'
index += 1
}
if (currentString !== '') {
bibtexValue = bibtexValue.addString(currentString)
}
return bibtexValue
}
}
function findBibtexIdentifierEndExclusive(
text: string,
startIndex: number
): number | null {
if (startIndex > text.length) {
throw new Error('startIndex must not be greater than text length')
}
if (
startIndex === text.length ||
!isBibtexIdentifierStartChar(text[startIndex])
) {
return null
}
let index = startIndex + 1
while (
index < text.length &&
isBibtexIdentifierContinuationChar(text[index])
) {
index += 1
}
return index
}
function isBibtexIdentifierStartChar(char: string): boolean {
return isAsciiLetter(char) || IDENTIFIER_SYMBOLS.includes(char)
}
function isBibtexIdentifierContinuationChar(char: string): boolean {
return isBibtexIdentifierStartChar(char) || isDigit(char)
}
function isAsciiLetter(char: string): boolean {
return /^[A-Za-z]$/.test(char)
}
function isDigit(char: string): boolean {
return /^[0-9]$/.test(char)
}
export const __test = {
findBibtexIdentifierEndExclusive,
}
@@ -0,0 +1,190 @@
type VonLast = { von: string; last: string }
type FirstVonLast = { first: string; von: string; last: string }
/**
* Splits "von Last" (the left-hand segment in BibTeX comma notation) into its
* von particle and Last components. The final word is always part of Last;
* preceding lowercase-initial words are the von particle.
*/
function parseVonLast(vonLast: string): VonLast {
const words = vonLast.split(/\s+/).filter(Boolean)
if (words.length === 0) return { von: '', last: '' }
if (words.length === 1) return { von: '', last: words[0] }
const mandatory = words[words.length - 1]
const preceding = words.slice(0, -1)
let lastVonIdx = -1
for (let i = preceding.length - 1; i >= 0; i--) {
if (/^[a-z]/.test(preceding[i])) {
lastVonIdx = i
break
}
}
if (lastVonIdx === -1) return { von: '', last: words.join(' ') }
return {
von: preceding.slice(0, lastVonIdx + 1).join(' '),
last: [...preceding.slice(lastVonIdx + 1), mandatory].join(' '),
}
}
/**
* Parses a "First von Last" name (no commas) into its three components. The
* final word is always part of Last; uppercase words following the last
* lowercase (von) word extend Last; lowercase words form the von particle.
*/
function parseFirstVonLast(name: string): FirstVonLast {
const words = name.split(/\s+/).filter(Boolean)
if (words.length === 0) return { first: '', von: '', last: '' }
if (words.length === 1) return { first: '', von: '', last: words[0] }
const mandatory = words[words.length - 1]
const preceding = words.slice(0, -1)
let firstVonIdx = -1
let lastVonIdx = -1
for (let i = 0; i < preceding.length; i++) {
if (/^[a-z]/.test(preceding[i])) {
if (firstVonIdx === -1) firstVonIdx = i
lastVonIdx = i
}
}
if (lastVonIdx === -1) {
return { first: preceding.join(' '), von: '', last: mandatory }
}
return {
first: preceding.slice(0, firstVonIdx).join(' '),
von: preceding.slice(firstVonIdx, lastVonIdx + 1).join(' '),
last: [...preceding.slice(lastVonIdx + 1), mandatory].join(' '),
}
}
/**
* A single BibTeX author name, parsed into its constituents (First, von, Last,
* Suffix) from any of the three standard BibTeX name notations:
*
* "First von Last"
* "von Last, First"
* "von Last, Suffix, First"
*/
export class BibtexName {
readonly first: string
readonly von: string
readonly last: string
readonly suffix: string
constructor(raw: string) {
const trimmed = raw.trim()
const segments = trimmed.split(',').map(s => s.trim())
if (segments.length >= 3) {
// "von Last, Suffix, First"
const { von, last } = parseVonLast(segments[0])
this.von = von
this.last = last
this.suffix = segments[1]
this.first = segments.slice(2).join(' ').trim()
} else if (segments.length === 2) {
// "von Last, First"
const { von, last } = parseVonLast(segments[0])
this.von = von
this.last = last
this.suffix = ''
this.first = segments[1]
} else {
// "First von Last"
const { first, von, last } = parseFirstVonLast(trimmed)
this.first = first
this.von = von
this.last = last
this.suffix = ''
}
}
/** Returns the name in "First von Last Suffix" display order. */
toFirstLast(): string {
return [this.first, this.von, this.last, this.suffix]
.filter(Boolean)
.join(' ')
}
/** Returns the "von Last" component (without First or Suffix). */
toLast(): string {
return [this.von, this.last].filter(Boolean).join(' ')
}
/** Returns the name in "von Last, First" display order (BibTeX comma notation). */
toLastFirst(): string {
const vonLast = [this.von, this.last].filter(Boolean).join(' ')
return this.first ? `${vonLast}, ${this.first}` : vonLast
}
}
/**
* A parsed BibTeX author/editor field. Names are split on " and "
* (case-insensitive). If the last token is "others" (case-insensitive), it is
* not included in `names` and `hasOthers` is set to `true` instead.
*
* Examples:
* "John Smith and Jane Doe" → names=[Smith, Doe], hasOthers=false
* "John Smith and others" → names=[Smith], hasOthers=true
* "others" → names=[others], hasOthers=false
* "others and John Smith" → names=[others, Smith], hasOthers=false
*/
export class BibtexNameList {
readonly names: BibtexName[]
readonly hasOthers: boolean
constructor(raw: string) {
if (!raw.trim()) {
this.names = []
this.hasOthers = false
return
}
const parts = raw
.split(/\s+and\s+/i)
.map(s => s.trim())
.filter(Boolean)
if (
parts.length > 1 &&
parts[parts.length - 1].toLowerCase() === 'others'
) {
this.hasOthers = true
this.names = parts.slice(0, -1).map(s => new BibtexName(s))
} else {
this.hasOthers = false
this.names = parts.map(s => new BibtexName(s))
}
}
/**
* Joins the names into a formatted display string using the Oxford comma.
* When `hasOthers` is true, renders "et al." in place of the omitted token.
*
* Examples with `format = n => n.toFirstLast()`:
* [Smith] hasOthers=false → "Jane Smith"
* [Smith, Doe] hasOthers=false → "Jane Smith and John Doe"
* [Smith, Doe, Jones] → "Jane Smith, John Doe, and Bob Jones"
* [Smith] hasOthers=true → "Jane Smith et al."
*/
join(format: (name: BibtexName) => string = n => n.toFirstLast()): string {
const { names, hasOthers } = this
if (names.length === 0) return ''
const formatted = names.map(format)
if (hasOthers) {
if (formatted.length === 1) return `${formatted[0]} et al.`
return `${formatted.join(', ')}, et al.`
}
if (formatted.length === 1) return formatted[0]
if (formatted.length === 2) return `${formatted[0]} and ${formatted[1]}`
return `${formatted.slice(0, -1).join(', ')}, and ${formatted[formatted.length - 1]}`
}
/**
* Returns a short author string using family names only. For 3+ names or
* when `hasOthers` is true, returns the first surname followed by "et al.".
* For exactly 2 names, joins their surnames with " & ".
*/
summarize(): string {
const { names, hasOthers } = this
if (names.length === 0) return ''
const surnames = names.map(n => [n.von, n.last].filter(Boolean).join(' '))
if (hasOthers || surnames.length > 2) return `${surnames[0]} et al.`
return surnames.join(' & ')
}
}
@@ -0,0 +1,198 @@
import { SyntaxNodeRef, Tree as LezerTree } from '@lezer/common'
import {
Junk,
Entry,
EntryType,
CitationKey,
Field,
FieldName,
StringLiteral,
NumberLiteral,
StringName,
} from '@/features/source-editor/lezer-bibtex/bibtex.terms.mjs'
import { parser as lezerParser } from '@/features/source-editor/lezer-bibtex/bibtex.mjs'
import { BibtexEntry } from './bibtex-entry.mts'
import { PositionedBibtexEntry } from './positioned-bibtex-entry'
import { BibtexFieldValue } from './bibtex-field-value.mts'
type GetText = (from: number, to: number) => string
export type ParseResult = {
entries: BibtexEntry[]
junk: string
}
export function parseBibtex(input: string): ParseResult {
const tree = lezerParser.parse(input)
const parser = new BibtexParser(tree, (from, to) => input.slice(from, to))
parser.parse()
return {
entries: parser.entries.map(positioned => positioned.entry),
junk: parser.junk,
}
}
/**
* Walks a single Entry's subtree, accumulating type / key / fields, and
* yields a `BibtexEntry` via `build()`. The walker fires `enter` and `leave`
* events that the caller must dispatch — this matches lezer's
* `tree.iterate` enter/leave semantics so it can be plugged into both
* whole-tree parsers and incremental projection visitors.
*/
export class BibtexEntryAccumulator {
private type: string | null = null
private key: string | null = null
private fields: Map<string, BibtexFieldValue> = new Map()
private fieldName: string | null = null
private fieldValue: BibtexFieldValue = new BibtexFieldValue()
reset() {
this.type = null
this.key = null
this.fields = new Map()
this.fieldName = null
this.fieldValue = new BibtexFieldValue()
}
/**
* Returns false when the node is fully handled and the caller should not
* descend into its children, matching lezer's iterate API.
*/
enter(node: SyntaxNodeRef, getText: GetText): boolean {
const type = node.type
if (type.is(EntryType)) {
this.type = getText(node.from, node.to).toLowerCase()
return false
}
if (type.is(CitationKey)) {
this.key = getText(node.from, node.to)
return false
}
if (type.is(FieldName)) {
this.fieldName = getText(node.from, node.to).toLowerCase()
return false
}
if (type.is(StringLiteral)) {
// Strip surrounding quotes/braces and collapse multi-line indentation.
let s = getText(node.from + 1, node.to - 1)
s = s.replaceAll(/\n\s*/g, ' ')
this.fieldValue = this.fieldValue.addString(s)
return false
}
if (type.is(NumberLiteral)) {
this.fieldValue = this.fieldValue.addNumber(getText(node.from, node.to))
return false
}
if (type.is(StringName)) {
this.fieldValue = this.fieldValue.addNamedString(
getText(node.from, node.to)
)
return false
}
return true
}
leave(node: SyntaxNodeRef) {
if (node.type.is(Field)) {
if (this.fieldName != null) {
this.fields.set(this.fieldName, this.fieldValue)
}
this.fieldName = null
this.fieldValue = new BibtexFieldValue()
}
}
/**
* Constructs a BibtexEntry from the accumulated state, or returns null if
* the entry was missing a type or key.
*/
build(): BibtexEntry | null {
if (this.type == null || this.key == null) return null
return new BibtexEntry({
type: this.type,
key: this.key,
fields: this.fields,
})
}
}
class BibtexParser {
tree: LezerTree
getText: GetText
lineAt: ((pos: number) => number) | undefined
entries: PositionedBibtexEntry[] = []
junk: string = ''
seenKeys: Set<string> = new Set()
duplicateKeys: Set<string> = new Set()
private accumulator: BibtexEntryAccumulator = new BibtexEntryAccumulator()
private inEntry: boolean = false
private entryFrom: number = 0
private entryTo: number = 0
constructor(
tree: LezerTree,
getText: GetText,
options: { lineAt?: (pos: number) => number } = {}
) {
this.tree = tree
this.getText = getText
this.lineAt = options.lineAt
}
parse() {
this.tree.iterate({
enter: this.enterNode.bind(this),
leave: this.leaveNode.bind(this),
})
}
enterNode(node: SyntaxNodeRef) {
const type = node.type
if (type.is(Entry)) {
this.accumulator.reset()
this.inEntry = true
this.entryFrom = node.from
this.entryTo = node.to
return true
}
if (type.is(Junk)) {
this.junk += this.getText(node.from, node.to) + '\n'
return false
}
if (!this.inEntry) {
return true
}
return this.accumulator.enter(node, this.getText)
}
leaveNode(node: SyntaxNodeRef) {
if (node.type.is(Entry)) {
this.storeEntry()
this.inEntry = false
} else if (this.inEntry) {
this.accumulator.leave(node)
}
}
private storeEntry() {
const entry = this.accumulator.build()
if (!entry) return
if (this.seenKeys.has(entry.key)) {
this.duplicateKeys.add(entry.key)
return
}
this.seenKeys.add(entry.key)
this.entries.push(
Object.assign(new PositionedBibtexEntry(), {
entry,
from: this.entryFrom,
to: this.entryTo,
line: this.lineAt ? this.lineAt(this.entryFrom) : 0,
toLine: this.lineAt ? this.lineAt(this.entryTo) : 0,
})
)
}
}
@@ -0,0 +1,49 @@
/**
* Implements the BibTeX purify$ function for special characters
*
* Special characters are first level groupings of curly braces that start with
* a backslash (e.g. "This is a special character: {\textbf{hello}}"). Inside
* special characters, LaTeX commands are removed.
*
* For more details, see Tame the BeaST, section 10
* (https://tug.ctan.org/info/bibtex/tamethebeast/ttb_en.pdf)
*/
export function purifySpecialChars(s: string) {
let result = ''
let currSpecialChar: string | null = null
let openBraces = 0
for (let i = 0; i < s.length; i++) {
const c = s[i]
if (c === '{') {
openBraces += 1
} else if (c === '}') {
openBraces -= 1
} else if (c === '\\' && openBraces === 1) {
// Open a special character
currSpecialChar = ''
}
if (currSpecialChar != null && openBraces === 0) {
// We just closed a special char: remove all LaTeX command and add to the
// result
result += currSpecialChar.replaceAll(/\\([A-Za-z]+|[^A-Za-z])\s*/g, '')
currSpecialChar = null
}
// Accumulate the character either in the current special character or in
// the final result
if (currSpecialChar != null) {
currSpecialChar += c
} else {
result += c
}
}
// If the string is malformed, just append the unprocessed special character
// as-is
if (currSpecialChar != null) {
result += currSpecialChar
}
return result
}
@@ -0,0 +1,68 @@
import { BibtexNameList } from './bibtex-name.mts'
const YEAR_REGEX = /^\d{4}$/
/**
* Generates the base citation key ("Surname" + "Year", stripped of any
* non-alphanumeric characters) from a parsed author list and a year string.
* Returns '' if there's no first author or no valid 4-digit year — the
* caller uses this to know that no suggestion is possible yet.
*
* Examples: "John Doe" + "1994" -> "Doe1994"
* "John von Neumann" + "1965" -> "vonNeumann1965"
* "Catherine O'Hara" + "2012" -> "OHara2012"
*/
export function generateBaseCitationKey({
authors,
year,
}: {
authors: BibtexNameList
year: string
}): string {
const firstAuthor = authors.names[0]
if (!firstAuthor) return ''
const surname = firstAuthor.toLast().replace(/[^A-Za-z0-9]/g, '')
if (!surname) return ''
if (!YEAR_REGEX.test(year.trim())) return ''
return surname + year.trim()
}
/**
* Generates lowercase-letter suffixes in bijective base-26 order: '', 'a',
* 'b', ..., 'z', 'aa', 'ab', ..., 'zz', 'aaa', ...
*/
function* suffixes(): Generator<string> {
yield ''
let length = 1
while (true) {
const total = 26 ** length
for (let i = 0; i < total; i++) {
let n = i
let suffix = ''
for (let j = 0; j < length; j++) {
suffix = String.fromCharCode(97 + (n % 26)) + suffix
n = Math.floor(n / 26)
}
yield suffix
}
length++
}
}
/**
* Picks the first available citation key of the form `base`, `base` + 'a',
* `base` + 'b', etc. that isn't already taken. Compares candidates by exact
* string equality against `takenKeys`, not by prefix — `takenKeys` may
* contain unrelated keys that happen to share the same prefix (e.g. a
* manually-named `Doe1994Workshop`), which must not be mistaken for a
* suffix collision.
*/
export function pickAvailableKey(base: string, takenKeys: string[]): string {
const taken = new Set(takenKeys)
for (const suffix of suffixes()) {
const candidate = base + suffix
if (!taken.has(candidate)) return candidate
}
// unreachable: suffixes() is infinite
throw new Error('unreachable')
}
@@ -0,0 +1,479 @@
import type { BibEntryType } from './bibtex-entry-schema.mts'
const entryTypes: Map<string, BibEntryType> = new Map()
entryTypes.set('article', {
key: 'article',
label: 'Article',
fields: ['author', 'title', 'journal', 'journaltitle', 'year', 'date'],
requiredFields: [
'author',
'title',
['journal', 'journaltitle'],
['year', 'date'],
],
optionalFields: [
'translator',
'annotator',
'commentator',
'subtitle',
'titleaddon',
'editor',
'editora',
'editorb',
'editorc',
'journalsubtitle',
'journaltitleaddon',
'issuetitle',
'issuesubtitle',
'issuetitleaddon',
'language',
'origlanguage',
'series',
'volume',
'number',
'eid',
'issue',
'month',
'pages',
'version',
'note',
'issn',
'addendum',
'pubstate',
'doi',
'eprint',
'eprintclass',
'eprinttype',
'url',
'urldate',
],
})
entryTypes.set('artwork', {
key: 'artwork',
label: 'Artwork',
fields: ['author', 'editor', 'title', 'year', 'date'],
requiredFields: [],
optionalFields: [],
})
entryTypes.set('audio', {
key: 'audio',
label: 'Audio',
fields: ['author', 'editor', 'title', 'year', 'date'],
requiredFields: [],
optionalFields: [],
})
entryTypes.set('book', {
key: 'book',
label: 'Book',
fields: ['author', 'editor', 'title', 'publisher', 'year', 'date'],
requiredFields: [['author', 'editor'], 'title', ['year', 'date']],
optionalFields: [],
})
entryTypes.set('bookinbook', {
key: 'bookinbook',
label: 'Book in book',
fields: [
'author',
'editor',
'title',
'booktitle',
'chapter',
'pages',
'publisher',
'year',
'date',
],
requiredFields: ['author', 'title', 'booktitle', ['year', 'date']],
optionalFields: [],
})
entryTypes.set('booklet', {
key: 'booklet',
label: 'Booklet',
fields: ['author', 'editor', 'title', 'year', 'date'],
requiredFields: ['title'],
optionalFields: [],
})
entryTypes.set('commentary', {
key: 'commentary',
label: 'Commentary',
fields: ['author', 'editor', 'title', 'year', 'date'],
requiredFields: [],
optionalFields: [],
})
entryTypes.set('conference', {
key: 'conference',
label: 'Conference',
fields: ['author', 'title', 'booktitle', 'year', 'date'],
requiredFields: ['author', 'title', 'booktitle', ['year', 'date']],
optionalFields: [],
})
entryTypes.set('collection', {
key: 'collection',
label: 'Collection',
fields: ['editor', 'title', 'year', 'date'],
requiredFields: ['editor', 'title', ['year', 'date']],
optionalFields: [],
})
entryTypes.set('dataset', {
key: 'dataset',
label: 'Dataset',
fields: ['author', 'editor', 'title', 'year', 'date'],
requiredFields: [['author', 'editor'], 'title', ['year', 'date']],
optionalFields: [],
})
entryTypes.set('electronic', {
key: 'electronic',
label: 'Electronic resource',
fields: ['author', 'editor', 'title', 'year', 'date', 'doi', 'eprint', 'url'],
requiredFields: [
['author', 'editor'],
'title',
['year', 'date'],
['doi', 'eprint', 'url'],
],
optionalFields: [],
})
entryTypes.set('image', {
key: 'image',
label: 'Image',
fields: ['author', 'editor', 'title', 'year', 'date'],
requiredFields: [],
optionalFields: [],
})
entryTypes.set('inproceedings', {
key: 'inproceedings',
label: 'In proceedings',
fields: ['author', 'title', 'booktitle', 'year', 'date'],
requiredFields: ['author', 'title', 'booktitle', ['year', 'date']],
optionalFields: [],
})
entryTypes.set('inbook', {
key: 'inbook',
label: 'In book',
fields: [
'author',
'editor',
'title',
'booktitle',
'chapter',
'pages',
'publisher',
'year',
'date',
],
requiredFields: [['author', 'editor'], 'title', ['year', 'date']],
optionalFields: [],
})
entryTypes.set('incollection', {
key: 'incollection',
label: 'In collection',
fields: ['author', 'title', 'booktitle', 'publisher', 'year', 'date'],
requiredFields: ['author', 'title', 'booktitle', ['year', 'date']],
optionalFields: [],
})
entryTypes.set('inreference', {
key: 'inreference',
label: 'In reference',
fields: ['author', 'editor', 'title', 'booktitle', 'year', 'date'],
requiredFields: ['author', 'title', 'editor', 'booktitle', ['year', 'date']],
optionalFields: [],
})
entryTypes.set('jurisdiction', {
key: 'jurisdiction',
label: 'Jurisdiction',
fields: ['author', 'editor', 'title', 'year', 'date'],
requiredFields: [],
optionalFields: [],
})
entryTypes.set('manual', {
key: 'manual',
label: 'Manual',
fields: ['author', 'editor', 'title', 'year', 'date'],
requiredFields: ['title'],
optionalFields: [],
})
entryTypes.set('mastersthesis', {
key: 'mastersthesis',
label: "Master's thesis",
fields: ['author', 'title', 'institution', 'school', 'year', 'date'],
requiredFields: [
'author',
'title',
['school', 'institution'],
['year', 'date'],
],
optionalFields: [],
})
entryTypes.set('misc', {
key: 'misc',
label: 'Miscellaneous',
fields: ['author', 'editor', 'title', 'year', 'date'],
requiredFields: [],
optionalFields: [],
})
entryTypes.set('movie', {
key: 'movie',
label: 'Movie',
fields: ['author', 'editor', 'title', 'year', 'date'],
requiredFields: [],
optionalFields: [],
})
entryTypes.set('music', {
key: 'music',
label: 'Music',
fields: ['author', 'editor', 'title', 'year', 'date'],
requiredFields: [],
optionalFields: [],
})
entryTypes.set('mvbook', {
key: 'mvbook',
label: 'Multi-volume book',
fields: ['author', 'title', 'year', 'date'],
requiredFields: ['author', 'title', ['year', 'date']],
optionalFields: [],
})
entryTypes.set('mvcollection', {
key: 'mvcollection',
label: 'Multi-volume collection',
fields: ['editor', 'title', 'year', 'date'],
requiredFields: ['editor', 'title', ['year', 'date']],
optionalFields: [],
})
entryTypes.set('mvproceedings', {
key: 'mvproceedings',
label: 'Multi-volume proceedings',
fields: ['title', 'year', 'date'],
requiredFields: ['title', ['year', 'date']],
optionalFields: [],
})
entryTypes.set('mvreference', {
key: 'mvreference',
label: 'Multi-volume reference',
fields: ['editor', 'title', 'year', 'date'],
requiredFields: ['editor', 'title', ['year', 'date']],
optionalFields: [],
})
entryTypes.set('legal', {
key: 'legal',
label: 'Legal',
fields: ['author', 'editor', 'title', 'year', 'date'],
requiredFields: [],
optionalFields: [],
})
entryTypes.set('legislation', {
key: 'legislation',
label: 'Legislation',
fields: ['author', 'editor', 'title', 'year', 'date'],
requiredFields: [],
optionalFields: [],
})
entryTypes.set('letter', {
key: 'letter',
label: 'Letter',
fields: ['author', 'editor', 'title', 'year', 'date'],
requiredFields: [],
optionalFields: [],
})
entryTypes.set('online', {
key: 'online',
label: 'Online resource',
fields: ['author', 'editor', 'title', 'year', 'date', 'doi', 'eprint', 'url'],
requiredFields: [
['author', 'editor'],
'title',
['year', 'date'],
['doi', 'eprint', 'url'],
],
optionalFields: [],
})
entryTypes.set('patent', {
key: 'patent',
label: 'Patent',
fields: ['author', 'title', 'number', 'year', 'date'],
requiredFields: ['author', 'title', 'number', ['year', 'date']],
optionalFields: [],
})
entryTypes.set('performance', {
key: 'performance',
label: 'Performance',
fields: ['author', 'editor', 'title', 'year', 'date'],
requiredFields: [],
optionalFields: [],
})
entryTypes.set('periodical', {
key: 'periodical',
label: 'Periodical',
fields: ['editor', 'title', 'year', 'date'],
requiredFields: ['editor', 'title', ['year', 'date']],
optionalFields: [],
})
entryTypes.set('phdthesis', {
key: 'phdthesis',
label: 'PhD thesis',
fields: ['author', 'title', 'institution', 'school', 'year', 'date'],
requiredFields: [
'author',
'title',
['school', 'institution'],
['year', 'date'],
],
optionalFields: [],
})
entryTypes.set('proceedings', {
key: 'proceedings',
label: 'Proceedings',
fields: ['title', 'year', 'date'],
requiredFields: ['title', ['year', 'date']],
optionalFields: [],
})
entryTypes.set('reference', {
key: 'reference',
label: 'Reference',
fields: ['editor', 'title', 'year', 'date'],
requiredFields: ['editor', 'title', ['year', 'date']],
optionalFields: [],
})
entryTypes.set('report', {
key: 'report',
label: 'Report',
fields: ['author', 'title', 'type', 'institution', 'year', 'date'],
requiredFields: ['author', 'title', 'type', 'institution', ['year', 'date']],
optionalFields: [],
})
entryTypes.set('review', {
key: 'review',
label: 'Review',
fields: ['author', 'editor', 'title', 'year', 'date'],
requiredFields: [],
optionalFields: [],
})
entryTypes.set('software', {
key: 'software',
label: 'Software',
fields: ['author', 'editor', 'title', 'year', 'date'],
requiredFields: [],
optionalFields: [],
})
entryTypes.set('standard', {
key: 'standard',
label: 'Standard',
fields: ['author', 'editor', 'title', 'year', 'date'],
requiredFields: [],
optionalFields: [],
})
entryTypes.set('suppbook', {
key: 'suppbook',
label: 'Supplemental material in book',
fields: [
'author',
'editor',
'title',
'booktitle',
'chapter',
'pages',
'publisher',
'year',
'date',
],
requiredFields: ['author', 'title', 'booktitle', ['year', 'date']],
optionalFields: [],
})
entryTypes.set('suppcollection', {
key: 'suppcollection',
label: 'Supplemental material in collection',
fields: ['author', 'editor', 'title', 'booktitle', 'year', 'date'],
requiredFields: ['author', 'title', 'editor', 'booktitle', ['year', 'date']],
optionalFields: [],
})
entryTypes.set('suppperiodical', {
key: 'suppperiodical',
label: 'Supplemental material in periodical',
fields: ['author', 'title', 'journaltitle', 'year', 'date'],
optionalFields: [],
requiredFields: ['author', 'title', 'journaltitle', ['year', 'date']],
})
entryTypes.set('techreport', {
key: 'techreport',
label: 'Tech report',
fields: ['author', 'title', 'institution', 'year', 'date'],
requiredFields: ['author', 'title', 'institution', ['year', 'date']],
optionalFields: [],
})
entryTypes.set('thesis', {
key: 'thesis',
label: 'Thesis',
fields: ['author', 'title', 'type', 'institution', 'year', 'date'],
requiredFields: ['author', 'title', 'type', 'institution', ['year', 'date']],
optionalFields: [],
})
entryTypes.set('unpublished', {
key: 'unpublished',
label: 'Unpublished',
fields: ['author', 'title', 'year', 'date', 'note'],
requiredFields: ['author', 'title'],
optionalFields: [],
})
entryTypes.set('video', {
key: 'video',
label: 'Video',
fields: ['author', 'editor', 'title', 'year', 'date'],
requiredFields: [],
optionalFields: [],
})
entryTypes.set('www', {
key: 'www',
label: 'WWW',
fields: ['author', 'editor', 'title', 'year', 'date', 'doi', 'eprint', 'url'],
requiredFields: [
['author', 'editor'],
'title',
['year', 'date'],
['doi', 'eprint', 'url'],
],
optionalFields: [],
})
function getMiscEntryType(): BibEntryType | undefined {
return entryTypes.get('misc')
}
export function isEntryType(key: string): boolean {
return entryTypes.has(key)
}
export function getEntryType(key: string): BibEntryType {
const entryType = entryTypes.get(key)
if (entryType != null) {
return entryType
} else {
return generateCustomEntryType(key)
}
}
export function getAllEntryTypes(): BibEntryType[] {
return Array.from(entryTypes.values())
}
export function getAllEntryKeys(): string[] {
return Array.from(entryTypes.keys()) ?? []
}
export function generateCustomEntryType(key: string): BibEntryType {
const miscEntryType = getMiscEntryType()
return {
key,
label: key,
fields: miscEntryType?.fields || [],
optionalFields: miscEntryType?.optionalFields || [],
requiredFields: miscEntryType?.requiredFields || [],
}
}
export function getTypeFields(type: string): string[] {
return (
(!isEntryType(type)
? getMiscEntryType()?.fields
: getEntryType(type)?.fields) || []
)
}
@@ -0,0 +1,876 @@
export function latexToUnicode(value: string) {
let s = value
// First, replace the LaTeX commands we know about that take no argument
//
// A LaTeX command is one of:
//
// - a backslash followed by letters (\textbf) and optional spaces
// - a backslash followed by a single non-letter (\^) and optional spaces
s = s.replaceAll(/\\([a-zA-Z]+|[^a-zA-Z])\s*/g, noArgsReplacer)
// Next, replace the LaTeX commands we know about that take one argument
//
// This first regex identifies arguments that are not inside braces. These
// can be:
//
// - a single character (\'e)
// - another LaTeX command (\^\alpha) - we don't support args for that command
s = s.replaceAll(
/\\([a-zA-Z]+|[^a-zA-Z])\s*([^{\\]|\\[^a-zA-Z]|[a-zA-Z]+)/g,
singleArgReplacer
)
// This second regex identifies arguments inside braces. We support two levels
// of braces. For example:
//
// - \'{e}
// - \mkbibquote{\'{e}cole}
//
// Because we support two levels of nested braces, we also need to run this
// twice.
for (let i = 0; i < 2; i++) {
s = s.replaceAll(
/\\([a-zA-Z]+|[^a-zA-Z])\s*{([^{}]*(?:{[^{}]*}[^{}]*)*)}/g,
singleArgReplacer
)
}
return s.normalize('NFC')
}
function noArgsReplacer(match: string, command: string) {
return NO_ARGS_REPLACEMENTS[command] ?? match
}
function singleArgReplacer(match: string, command: string, arg: string) {
if (command === 'mkbibquote') {
return `"${arg}"`
}
const replacement = SINGLE_ARG_REPLACEMENTS[command]?.[arg]
if (replacement != null) {
return replacement
}
if (arg.length === 1) {
const combiningChar = COMBINING_CHAR_REPLACEMENTS[command]
if (combiningChar != null) {
return arg + combiningChar
}
}
return match
}
/**
* List of replacements from bib2json (https://github.com/mayanklahiri/bib2json)
*
* Copyright 2012-13 Mayank Lahiri
* mlahiri@gmail.com
* Released under the BSD License.
*
* Modifications 2016 Sharelatex
* Modifications 2017-2020 Overleaf
*/
const NO_ARGS_REPLACEMENTS: Record<string, string> = {
aa: 'å',
AA: 'Å',
AE: 'Æ',
ae: 'æ',
aleph: 'ℵ',
allequal: '≌',
Alpha: 'Α',
alpha: 'α',
amalg: '⨿',
angle: '∠',
Angle: '⦜',
approxeq: '≊',
approxnotequal: '≆',
approx: '≈',
aquarius: '♒',
aries: '♈',
arrowwaveleft: '↜',
arrowwaveright: '↝',
ast: '*',
asymp: '≍',
backepsilon: '϶',
backprime: '‵',
backsimeq: '⋍',
backsim: '∽',
barwedge: '⌅',
because: '∵',
Beta: 'Β',
beta: 'β',
beth: 'ℶ',
between: '≬',
bigcap: '⋂',
bigcirc: '◯',
bigcup: '⋃',
bigtriangledown: '▽',
bigtriangleup: '△',
blacklozenge: '⧫',
blacksquare: '▪',
blacktriangledown: '▾',
blacktriangleleft: '◂',
blacktriangleright: '▸',
blacktriangle: '▴',
bowtie: '⋈',
boxdot: '⊡',
boxminus: '⊟',
boxplus: '⊞',
boxtimes: '⊠',
bullet: '∙',
Bumpeq: '≎',
bumpeq: '≏',
cancer: '♋',
capricornus: '♑',
cap: '∩',
Cap: '⋒',
cdots: '⋯',
cdot: '·',
Chi: 'Χ',
chi: 'χ',
circeq: '≗',
circlearrowleft: '↺',
circlearrowright: '↻',
circledast: '⊛',
circledcirc: '⊚',
circleddash: '⊝',
circledS: 'Ⓢ',
circ: '∘',
clockoint: '⨏',
clwintegral: '∱',
Colon: '∷',
complement: '∁',
cong: '≅',
coprod: '∐',
cup: '∪',
Cup: '⋓',
curlyeqprec: '⋞',
curlyeqsucc: '⋟',
curlyvee: '⋎',
curlywedge: '⋏',
curvearrowleft: '↶',
curvearrowright: '↷',
daleth: 'ℸ',
dashv: '⊣',
dblarrowupdown: '⇅',
ddddot: '⃜',
dddot: '⃛',
Delta: 'Δ',
delta: 'δ',
DH: 'Ð',
dh: 'ð',
diagup: '╱',
diamond: '⋄',
Digamma: 'Ϝ',
digamma: 'ϝ',
divideontimes: '⋇',
div: '÷',
DJ: 'Đ',
dj: 'đ',
doteqdot: '≑',
doteq: '≐',
dotplus: '∔',
DownArrowBar: '⤓',
downarrow: '↓',
Downarrow: '⇓',
DownArrowUpArrow: '⇵',
downdownarrows: '⇊',
downharpoonleft: '⇃',
downharpoonright: '⇂',
DownLeftRightVector: '⥐',
DownLeftTeeVector: '⥞',
DownLeftVectorBar: '⥖',
DownRightTeeVector: '⥟',
DownRightVectorBar: '⥗',
downslopeellipsis: '⋱',
eighthnote: '♪',
Elolarr: '⥀',
Elorarr: '⥁',
ElOr: '⩖',
Elroang: '⦆',
Elxsqcup: '⨆',
Elxuplus: '⨄',
ElzAnd: '⩓',
Elzbar: '̶',
Elzbtdl: 'ɬ',
ElzCint: '⨍',
Elzcirfb: '◒',
Elzcirfl: '◐',
Elzcirfr: '◑',
Elzclomeg: 'ɷ',
Elzddfnc: '⦙',
Elzdefas: '⧋',
Elzdlcorn: '⎣',
Elzdshfnc: '┆',
Elzdyogh: 'ʤ',
Elzesh: 'ʃ',
Elzfhr: 'ɾ',
Elzglst: 'ʔ',
Elzhlmrk: 'ˑ',
ElzInf: '⨇',
Elzinglst: 'ʖ',
Elzinvv: 'ʌ',
Elzinvw: 'ʍ',
ElzLap: '⧊',
Elzlmrk: 'ː',
Elzlow: '˕',
Elzlpargt: '⦠',
Elzltlmr: 'ɱ',
Elzltln: 'ɲ',
Elzminhat: '⩟',
Elzopeno: 'ɔ',
ElzOr: '⩔',
Elzpalh: '̡',
Elzpbgam: 'ɤ',
Elzpgamma: 'ɣ',
Elzpscrv: 'ʋ',
Elzpupsil: 'ʊ',
Elzrais: '˔',
Elzrarrx: '⥇',
Elzreapos: '‛',
Elzreglst: 'ʕ',
Elzrh: '̢',
ElzRlarr: '⥂',
ElzrLarr: '⥄',
Elzrl: 'ɼ',
Elzrtld: 'ɖ',
Elzrtll: 'ɭ',
Elzrtln: 'ɳ',
Elzrtlr: 'ɽ',
Elzrtls: 'ʂ',
Elzrtlt: 'ʈ',
Elzrtlz: 'ʐ',
Elzrttrnr: 'ɻ',
Elzrvbull: '◘',
Elzsbbrg: '̪',
Elzsblhr: '˓',
Elzsbrhr: '˒',
Elzschwa: 'ə',
Elzsqfl: '◧',
Elzsqfnw: '┙',
Elzsqfr: '◨',
Elzsqfse: '◪',
Elzsqspne: '⋥',
ElzSup: '⨈',
Elztdcol: '⫶',
Elztesh: 'ʧ',
Elztfnc: '⦀',
ElzThr: '⨅',
ElzTimes: '⨯',
Elztrna: 'ɐ',
Elztrnh: 'ɥ',
Elztrnmlr: 'ɰ',
Elztrnm: 'ɯ',
Elztrnrl: 'ɺ',
Elztrnr: 'ɹ',
Elztrnsa: 'ɒ',
Elztrnt: 'ʇ',
Elztrny: 'ʎ',
Elzverti: 'ˌ',
Elzverts: 'ˈ',
Elzvrecto: '▯',
Elzxh: 'ħ',
Elzxl: '̵',
Elzxrat: '℞',
Elzyogh: 'ʒ',
Epsilon: 'Ε',
epsilon: 'ε',
eqcirc: '≖',
eqslantgtr: '⪖',
eqslantless: '⪕',
Equal: '⩵',
equiv: '≡',
estimates: '≙',
Eta: 'Η',
eta: 'η',
eth: 'ƪ',
exists: '∃',
fallingdotseq: '≒',
flat: '♭',
forall: '∀',
forcesextra: '⊨',
frown: '⌢',
Gamma: 'Γ',
gamma: 'γ',
gemini: '♊',
geqq: '≧',
geqslant: '⩾',
geq: '≥',
gg: '≫',
gimel: 'ℷ',
gnapprox: '⪊',
gneqq: '≩',
gneq: '⪈',
gnsim: '⋧',
greaterequivlnt: '≳',
gtrapprox: '⪆',
gtrdot: '⋗',
gtreqless: '⋛',
gtreqqless: '⪌',
gtrless: '≷',
guillemotleft: '«',
guillemotright: '»',
guilsinglleft: '‹',
guilsinglright: '›',
hermitconjmatrix: '⊹',
homothetic: '∻',
hookleftarrow: '↩',
hookrightarrow: '↪',
hslash: 'ℏ',
image: '⊷',
infty: '∞',
intercal: '⊺',
int: '∫',
in: '∈',
Iota: 'Ι',
iota: 'ι',
i: 'ı',
jupiter: '♃',
Kappa: 'Κ',
kappa: 'κ',
Koppa: 'Ϟ',
Lambda: 'Λ',
lambda: 'λ',
langle: '〈',
lazysinv: '∾',
lbrace: '{',
lceil: '⌈',
ldots: '…',
leftarrowtail: '↢',
leftarrow: '←',
Leftarrow: '⇐',
LeftDownTeeVector: '⥡',
LeftDownVectorBar: '⥙',
leftharpoondown: '↽',
leftharpoonup: '↼',
leftleftarrows: '⇇',
leftrightarrows: '⇆',
leftrightarrow: '↔',
Leftrightarrow: '⇔',
leftrightharpoons: '⇋',
leftrightsquigarrow: '↭',
LeftRightVector: '⥎',
LeftTeeVector: '⥚',
leftthreetimes: '⋋',
LeftTriangleBar: '⧏',
LeftUpDownVector: '⥑',
LeftUpTeeVector: '⥠',
LeftUpVectorBar: '⥘',
LeftVectorBar: '⥒',
leo: '♌',
leqq: '≦',
leqslant: '⩽',
leq: '≤',
lessapprox: '⪅',
lessdot: '⋖',
lesseqgtr: '⋚',
lesseqqgtr: '⪋',
lessequivlnt: '≲',
lessgtr: '≶',
lfloor: '⌊',
libra: '♎',
llcorner: '⌞',
Lleftarrow: '⇚',
ll: '≪',
lmoustache: '⎰',
lnapprox: '⪉',
lneqq: '≨',
lneq: '⪇',
lnot: '¬',
lnsim: '⋦',
longleftarrow: '⟵',
Longleftarrow: '⟸',
longleftrightarrow: '⟷',
Longleftrightarrow: '⟺',
longmapsto: '⟼',
longrightarrow: '⟶',
Longrightarrow: '⟹',
looparrowleft: '↫',
looparrowright: '↬',
lozenge: '◊',
lrcorner: '⌟',
Lsh: '↰',
ltimes: '⋉',
L: 'Ł',
l: 'ł',
male: '♂',
mapsto: '↦',
measuredangle: '∡',
mercury: '☿',
mho: '℧',
mid: '∣',
mp: '∓',
multimap: '⊸',
mu: 'μ',
nabla: '∇',
natural: '♮',
nearrow: '↗',
neptune: '♆',
NestedGreaterGreater: '⪢',
NestedLessLess: '⪡',
nexists: '∄',
NG: 'Ŋ',
ng: 'ŋ',
ni: '∋',
nleftarrow: '↚',
nLeftarrow: '⇍',
nleftrightarrow: '↮',
nLeftrightarrow: '⇎',
nmid: '∤',
nolinebreak: '⁠',
notgreaterless: '≹',
notlessgreater: '≸',
nparallel: '∦',
nrightarrow: '↛',
nRightarrow: '⇏',
ntrianglelefteq: '⋬',
ntriangleleft: '⋪',
ntrianglerighteq: '⋭',
ntriangleright: '⋫',
nu: 'ν',
nvdash: '⊬',
nvDash: '⊭',
nVdash: '⊮',
nVDash: '⊯',
nwarrow: '↖',
odot: '⊙',
OE: 'Œ',
oe: 'œ',
oint: '∮',
Omega: 'Ω',
omega: 'ω',
ominus: '⊖',
openbracketleft: '〚',
openbracketright: '〛',
oplus: '⊕',
original: '⊶',
oslash: '⊘',
otimes: '⊗',
O: 'Ø',
o: 'ø',
parallel: '∥',
partial: '∂',
perp: '⊥',
perspcorrespond: '⩞',
Phi: 'Φ',
phi: 'ϕ',
pisces: '♓',
pitchfork: '⋔',
Pi: 'Π',
pi: 'π',
pluto: '♇',
pm: '±',
precapprox: '⪷',
preccurlyeq: '≼',
precedesnotsimilar: '⋨',
preceq: '⪯',
precnapprox: '⪹',
precneqq: '⪵',
prec: '≺',
prod: '∏',
propto: '∝',
Psi: 'Ψ',
psi: 'ψ',
quarternote: '♩',
rangle: '〉',
rbrace: '}',
rceil: '⌉',
recorder: '⌕',
ReverseUpEquilibrium: '⥯',
rfloor: '⌋',
Rho: 'Ρ',
rho: 'ρ',
rightanglearc: '⊾',
rightangle: '∟',
rightarrowtail: '↣',
rightarrow: '→',
Rightarrow: '⇒',
RightDownTeeVector: '⥝',
RightDownVectorBar: '⥕',
rightharpoondown: '⇁',
rightharpoonup: '⇀',
rightleftarrows: '⇄',
rightleftharpoons: '⇌',
rightmoon: '☾',
rightrightarrows: '⇉',
rightsquigarrow: '⇝',
RightTeeVector: '⥛',
rightthreetimes: '⋌',
RightTriangleBar: '⧐',
RightUpDownVector: '⥏',
RightUpTeeVector: '⥜',
RightUpVectorBar: '⥔',
RightVectorBar: '⥓',
risingdotseq: '≓',
rmoustache: '⎱',
RoundImplies: '⥰',
Rrightarrow: '⇛',
Rsh: '↱',
rtimes: '⋊',
RuleDelayed: '⧴',
sagittarius: '♐',
Sampi: 'Ϡ',
saturn: '♄',
scorpio: '♏',
searrow: '↘',
setminus: '∖',
sharp: '♯',
Sigma: 'Σ',
sigma: 'σ',
simeq: '≃',
sim: '∼',
smile: '⌣',
space: ' ',
sphericalangle: '∢',
sqcap: '⊓',
sqcup: '⊔',
sqrint: '⨖',
sqsubseteq: '⊑',
sqsubset: '⊏',
sqsupseteq: '⊒',
sqsupset: '⊐',
square: '□',
ss: 'ß',
starequal: '≛',
star: '⋆',
Stigma: 'Ϛ',
subseteqq: '⫅',
subseteq: '⊆',
subsetneqq: '⫋',
subsetneq: '⊊',
subset: '⊂',
Subset: '⋐',
succapprox: '≿',
succcurlyeq: '≽',
succeq: '⪰',
succnapprox: '⪺',
succneqq: '⪶',
succnsim: '⋩',
succ: '≻',
sum: '∑',
supseteqq: '⫆',
supseteq: '⊇',
supsetneqq: '⫌',
supsetneq: '⊋',
supset: '⊃',
Supset: '⋑',
surd: '√',
surfintegral: '∯',
swarrow: '↙',
taurus: '♉',
Tau: 'Τ',
tau: 'τ',
textasciiacute: '´',
textasciibreve: '˘',
textasciicaron: 'ˇ',
textasciidieresis: '¨',
textasciigrave: '`',
textasciimacron: '¯',
textasciitilde: '~',
textbackslash: '\\',
textbrokenbar: '¦',
textbullet: '•',
textcent: '¢',
textcopyright: '©',
textcurrency: '¤',
textdaggerdbl: '‡',
textdagger: '†',
textdegree: '°',
textdollar: '$',
textdoublepipe: 'ǂ',
textemdash: '—',
textendash: '–',
texteuro: '€',
textexclamdown: '¡',
texthvlig: 'ƕ',
textnrleg: 'ƞ',
textonehalf: '½',
textonequarter: '¼',
textordfeminine: 'ª',
textordmasculine: 'º',
textparagraph: '¶',
textperiodcentered: '˙',
textpertenthousand: '‱',
textperthousand: '‰',
textphi: 'ɸ',
textquestiondown: '¿',
textquotedblleft: '“',
textquotedblright: '”',
textquotesingle: "'",
textregistered: '®',
textsection: '§',
textsterling: '£',
texttheta: 'θ',
textTheta: 'ϴ',
textthreequarters: '¾',
texttildelow: '˜',
texttimes: '×',
texttrademark: '™',
textturnk: 'ʞ',
textvartheta: 'ϑ',
textvisiblespace: '␣',
textyen: '¥',
therefore: '∴',
Theta: 'Θ',
TH: 'Þ',
th: 'þ',
tildetrpl: '≋',
top: '⊤',
triangledown: '▿',
trianglelefteq: '⊴',
triangleleft: '◃',
triangleq: '≜',
trianglerighteq: '⊵',
triangleright: '▹',
truestate: '⊧',
twoheadleftarrow: '↞',
twoheadrightarrow: '↠',
ulcorner: '⌜',
UpArrowBar: '⤒',
uparrow: '↑',
Uparrow: '⇑',
updownarrow: '↕',
Updownarrow: '⇕',
UpEquilibrium: '⥮',
upharpoonleft: '↿',
upharpoonright: '↾',
uplus: '⊎',
upsilon: 'υ',
Upsilon: 'ϒ',
upslopeellipsis: '⋰',
upuparrows: '⇈',
uranus: '♅',
urcorner: '⌝',
varepsilon: 'ɛ',
varkappa: 'ϰ',
varnothing: '∅',
varphi: 'φ',
varpi: 'ϖ',
varrho: 'ϱ',
varsigma: 'ς',
vartriangleleft: '⊲',
vartriangleright: '⊳',
vartriangle: '▵',
vdash: '⊢',
Vdash: '⊩',
VDash: '⊫',
vdots: '⋮',
veebar: '⊻',
vee: '∨',
venus: '♀',
vert: '|',
Vert: '‖',
verymuchgreater: '⋙',
verymuchless: '⋘',
virgo: '♍',
volintegral: '∰',
Vvdash: '⊪',
wedge: '∧',
wp: '℘',
wr: '≀',
Xi: 'Ξ',
xi: 'ξ',
Zeta: 'Ζ',
zeta: 'ζ',
}
const SINGLE_ARG_REPLACEMENTS: Record<string, Record<string, string>> = {
mathbb: {
A: '𝔸',
B: '𝔹',
C: 'ℂ',
D: '𝔻',
E: '𝔼',
F: '𝔽',
G: '𝔾',
H: 'ℍ',
I: '𝕀',
J: '𝕁',
K: '𝕂',
L: '𝕃',
M: '𝕄',
N: 'ℕ',
O: '𝕆',
P: 'ℙ',
Q: 'ℚ',
R: 'ℝ',
S: '𝕊',
T: '𝕋',
U: '𝕌',
V: '𝕍',
W: '𝕎',
X: '𝕏',
Y: '𝕐',
Z: 'ℤ',
a: '𝕒',
b: '𝕓',
c: '𝕔',
d: '𝕕',
e: '𝕖',
f: '𝕗',
g: '𝕘',
h: '𝕙',
i: '𝕚',
j: '𝕛',
k: '𝕜',
l: '𝕝',
m: '𝕞',
n: '𝕟',
o: '𝕠',
p: '𝕡',
q: '𝕢',
r: '𝕣',
s: '𝕤',
t: '𝕥',
u: '𝕦',
v: '𝕧',
w: '𝕨',
x: '𝕩',
y: '𝕪',
z: '𝕫',
},
mathfrak: {
A: '𝔄',
B: '𝔅',
C: 'ℭ',
D: '𝔇',
E: '𝔈',
F: '𝔉',
G: '𝔊',
H: 'ℌ',
I: 'ℑ',
J: '𝔍',
K: '𝔎',
L: '𝔏',
M: '𝔐',
N: '𝔑',
O: '𝔒',
P: '𝔓',
Q: '𝔔',
R: 'ℜ',
S: '𝔖',
T: '𝔗',
U: '𝔘',
V: '𝔙',
W: '𝔚',
X: '𝔛',
Y: '𝔜',
Z: 'ℨ',
a: '𝔞',
b: '𝔟',
c: '𝔠',
d: '𝔡',
e: '𝔢',
f: '𝔣',
g: '𝔤',
h: '𝔥',
i: '𝔦',
j: '𝔧',
k: '𝔨',
l: '𝔩',
m: '𝔪',
n: '𝔫',
o: '𝔬',
p: '𝔭',
q: '𝔮',
r: '𝔯',
s: '𝔰',
t: '𝔱',
u: '𝔲',
v: '𝔳',
w: '𝔴',
x: '𝔵',
y: '𝔶',
z: '𝔷',
},
mathscr: {
A: '𝒜',
B: 'ℬ',
C: '𝒞',
D: '𝒟',
E: 'ℰ',
F: 'ℱ',
G: '𝒢',
H: 'ℋ',
I: 'ℐ',
J: '𝒥',
K: '𝒦',
L: 'ℒ',
M: 'ℳ',
N: '𝒩',
O: '𝒪',
P: '𝒫',
Q: '𝒬',
R: 'ℛ',
S: '𝒮',
T: '𝒯',
U: '𝒰',
V: '𝒱',
W: '𝒲',
X: '𝒳',
Y: '𝒴',
Z: '𝒵',
a: '𝒶',
b: '𝒷',
c: '𝒸',
d: '𝒹',
e: 'ℯ',
f: '𝒻',
g: 'ℊ',
h: '𝒽',
i: '𝒾',
j: '𝒿',
k: '𝓀',
l: 'ℓ',
m: '𝓂',
n: '𝓃',
o: 'ℴ',
p: '𝓅',
q: '𝓆',
r: '𝓇',
s: '𝓈',
t: '𝓉',
u: '𝓊',
v: '𝓋',
w: '𝓌',
x: '𝓍',
y: '𝓎',
z: '𝓏',
},
}
const COMBINING_CHAR_REPLACEMENTS: Record<string, string> = {
"'": '\u0301',
'"': '\u0308',
'.': '\u0307',
'=': '\u0304',
'^': '\u0302',
'`': '\u0300',
'~': '\u0303',
H: '\u030B',
acute: '\u0301',
b: '\u0331',
bar: '\u0305',
breve: '\u0306',
c: '\u0327',
check: '\u030C',
d: '\u0323',
dot: '\u0307',
ddot: '\u0308',
doubleunderline: '\u0333',
grave: '\u0300',
hat: '\u0302',
mathring: '\u030A',
overline: '\u0305',
r: '\u030A',
not: '\u0338',
slash: '\u0338',
strikethrough: '\u0335',
tilde: '\u0303',
u: '\u0306',
underline: '\u0332',
v: '\u030C',
vec: '\u20d7',
}
@@ -0,0 +1,14 @@
import { ProjectionItem } from '@/features/source-editor/utils/tree-operations/projection'
import { BibtexEntry } from './bibtex-entry.mts'
/**
* A BibtexEntry placed in a CodeMirror document. The `entry` reference is
* preserved across pure position shifts so identity-sensitive consumers
* (React.memo, MiniSearch indexes) can skip work when only positions changed.
*
* `from`/`to`/`line`/`toLine` come from `ProjectionItem` and are kept in sync
* by the projection module on every transaction.
*/
export class PositionedBibtexEntry extends ProjectionItem {
readonly entry: BibtexEntry = new BibtexEntry()
}