overleaf/services/web/frontend/js/features/outline/outline-parser.js

129 lines
3.7 KiB
JavaScript
Raw Normal View History

const COMMAND_LEVELS = {
book: 10,
part: 20,
addpart: 20,
chapter: 30,
addchap: 30,
section: 40,
addsect: 40,
subsection: 50,
subsubsection: 60,
paragraph: 70,
subparagraph: 80,
}
/*
*
* RegExp matcher parts:
*
* REGEX_START: begining of line, any number of spaces, double \ (required)
* REGEX_COMMAND: any of the listed commands (required)
* REGEX_SPACING: spaces and * between groups (optional)
* REGEX_SHORT_TITLE: a text between square brackets (optional)
* REGEX_TITLE: a text between curly brackets (required)
*
*/
const REGEX_START = '^\\s*\\\\'
const REGEX_COMMAND = `(${Object.keys(COMMAND_LEVELS).join('|')})`
const REGEX_SPACING = '\\s?\\*?\\s?'
const REGEX_SHORT_TITLE = '(\\[([^\\]]+)\\])?'
const REGEX_TITLE = '{(.*)}'
const MATCHER = new RegExp(
`${REGEX_START}${REGEX_COMMAND}${REGEX_SPACING}${REGEX_SHORT_TITLE}${REGEX_SPACING}${REGEX_TITLE}`
)
function matchOutline(content) {
const lines = content.split('\n')
const flatOutline = []
lines.forEach((line, lineId) => {
const match = line.match(MATCHER)
if (!match) return
const [, command, , shortTitle, title] = match
flatOutline.push({
line: lineId + 1,
title: matchDisplayTitle(shortTitle || title),
level: COMMAND_LEVELS[command],
})
})
return flatOutline
}
const DISPLAY_TITLE_REGEX = /([^\\]*)\\([^{]+){([^}]+)}(.*)/
const END_OF_TITLE_REGEX = /^([^{}]*?({[^{}]*?}[^{}]*?)*)}/
/*
* Attempt to improve the display of the outline title for titles with commands.
* Either skip the command (for labels) or display the command's content instead
* of the entire command.
*
* e.g. "Label \\label{foo} between" => "Label between"
* e.g. "TT \\texttt{Bar}" => "TT Bar"
*
*/
function matchDisplayTitle(title) {
const closingBracketPosition = title.indexOf('}')
if (closingBracketPosition < 0) {
// simple title (no commands)
return title
}
// if there is anything outside the title def on the line, remove it
// before proceeding
const titleOnlyMatch = title.match(END_OF_TITLE_REGEX)
if (titleOnlyMatch) {
title = titleOnlyMatch[1]
}
const titleMatch = title.match(DISPLAY_TITLE_REGEX)
if (!titleMatch) {
// no contained commands; strip everything after the first closing bracket
return title.substring(0, closingBracketPosition)
}
const [, textBefore, command, commandContent, textAfter] = titleMatch
if (command === 'label') {
// label: don't display them at all
title = `${textBefore}${textAfter}`
} else {
// display the content of the command. Works well for formatting commands
title = `${textBefore}${commandContent}${textAfter}`
}
return title
}
function nestOutline(flatOutline) {
let parentOutlines = {}
let nestedOutlines = []
flatOutline.forEach(outline => {
const parentOutlineLevels = Object.keys(parentOutlines)
// find the nearest parent outline
const nearestParentLevel = parentOutlineLevels
.reverse()
.find(level => level < outline.level)
const parentOutline = parentOutlines[nearestParentLevel]
if (!parentOutline) {
// top level
nestedOutlines.push(outline)
} else if (!parentOutline.children) {
// first outline in this node
parentOutline.children = [outline]
} else {
// push outline to node
parentOutline.children.push(outline)
}
// store the outline as new parent at its level and forget lower-level
// outlines (if any) as they shouldn't get any children anymore
parentOutlines[outline.level] = outline
parentOutlineLevels
.filter(level => level > outline.level)
.forEach(level => delete parentOutlines[level])
})
return nestedOutlines
}
export { matchOutline, nestOutline }