UNPKG

@unified-latex/unified-latex-util-replace

Version:

Functions for modifying a unified-latex AST

378 lines (377 loc) 16.5 kB
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" }); let _unified_latex_unified_latex_util_visit = require("@unified-latex/unified-latex-util-visit"); let _unified_latex_unified_latex_util_match = require("@unified-latex/unified-latex-util-match"); let _unified_latex_unified_latex_util_trim = require("@unified-latex/unified-latex-util-trim"); let _unified_latex_unified_latex_util_split = require("@unified-latex/unified-latex-util-split"); //#region libs/replace-node.ts /** * Recursively replace nodes in `ast`. The `visitor` function is called on each node. If * `visitor` returns a node or an array of nodes, those nodes replace the node passed to `visitor`. * If `null` is returned, the node is deleted. If `undefined` is returned, no replacement happens. */ function replaceNode(ast, visitor) { (0, _unified_latex_unified_latex_util_visit.visit)(ast, { leave: (node, info) => { let replacement = visitor(node, info); if (typeof replacement === "undefined" || replacement === node) return; if (!info.containingArray || info.index == null) throw new Error("Trying to replace node, but cannot find containing array"); if (replacement === null || Array.isArray(replacement) && replacement.length === 0) { info.containingArray.splice(info.index, 1); return info.index; } if (!Array.isArray(replacement)) replacement = [replacement]; info.containingArray.splice(info.index, 1, ...replacement); return info.index + replacement.length; } }); } //#endregion //#region libs/utils/significant-node.ts /** * Returns the first non-whitespace/non-comment node in `nodes`. If there is no such * node, `null` is returned. */ function firstSignificantNode(nodes, parbreaksAreInsignificant) { const index = firstSignificantNodeIndex(nodes, parbreaksAreInsignificant); if (index == null) return null; return nodes[index]; } /** * Returns the last non-whitespace/non-comment node in `nodes`. If there is no such * node, `null` is returned. */ function lastSignificantNode(nodes, parbreaksAreInsignificant) { const index = lastSignificantNodeIndex(nodes, parbreaksAreInsignificant); if (index == null) return null; return nodes[index]; } /** * Returns the index of the last non-whitespace/non-comment node in `nodes`. If there is no such * node, `null` is returned. */ function lastSignificantNodeIndex(nodes, parbreaksAreInsignificant) { for (let i = nodes.length - 1; i >= 0; i--) { const node = nodes[i]; if (_unified_latex_unified_latex_util_match.match.whitespace(node) || _unified_latex_unified_latex_util_match.match.comment(node) || parbreaksAreInsignificant && _unified_latex_unified_latex_util_match.match.parbreak(node)) continue; return i; } } /** * Returns the index of the first non-whitespace/non-comment node in `nodes`. If there is no such * node, `null` is returned. */ function firstSignificantNodeIndex(nodes, parbreaksAreInsignificant) { for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; if (_unified_latex_unified_latex_util_match.match.whitespace(node) || _unified_latex_unified_latex_util_match.match.comment(node) || parbreaksAreInsignificant && _unified_latex_unified_latex_util_match.match.parbreak(node)) continue; return i; } } //#endregion //#region libs/utils/join-without-excess-whitespace.ts /** * Is the node space-like? I.e., is it whitespace or * a comment with leading whitespace? */ function isSpaceLike(node) { return _unified_latex_unified_latex_util_match.match.whitespace(node) || _unified_latex_unified_latex_util_match.match.comment(node) && Boolean(node.leadingWhitespace); } /** * Similar to `head.push(...tail)` except that whitespace at the start * of `tail` and the end of `head` is collapsed. */ function joinWithoutExcessWhitespace(head, tail) { if (tail.length === 0) return; if (head.length === 0) { head.push(...tail); return; } const headEnd = head[head.length - 1]; const tailStart = tail[0]; if (_unified_latex_unified_latex_util_match.match.whitespace(headEnd) && _unified_latex_unified_latex_util_match.match.whitespace(tailStart)) { head.push(...tail.slice(1)); return; } if (!isSpaceLike(headEnd) || !isSpaceLike(tailStart)) { if (_unified_latex_unified_latex_util_match.match.whitespace(headEnd) && _unified_latex_unified_latex_util_match.match.comment(tailStart)) { const comment = { type: "comment", content: tailStart.content, sameline: true, leadingWhitespace: true }; tail = tail.slice(1); (0, _unified_latex_unified_latex_util_trim.trimStart)(tail); head.pop(); head.push(comment, ...tail); return; } head.push(...tail); return; } if (_unified_latex_unified_latex_util_match.match.comment(headEnd) && _unified_latex_unified_latex_util_match.match.comment(tailStart)) { if (tailStart.leadingWhitespace || tailStart.sameline) { head.push({ type: "comment", content: tailStart.content }, ...tail.slice(1)); return; } head.push(...tail); return; } let comment = _unified_latex_unified_latex_util_match.match.comment(headEnd) ? headEnd : tailStart; if (!_unified_latex_unified_latex_util_match.match.comment(comment)) throw new Error(`Expected a comment but found ${JSON.stringify(comment)}`); if (!comment.leadingWhitespace || !comment.sameline) comment = { type: "comment", content: comment.content, leadingWhitespace: true, sameline: true }; head.pop(); head.push(comment, ...tail.slice(1)); } //#endregion //#region libs/utils/wrap-significant-content.ts /** * Wraps `content` in the specified wrapper. This command is roughly equivalent to * `wrapper(content)` except that leading and trailing whitespace and comments are extracted * from `content` and moved to the front or back of the return array. For example, * `[" ", "foo", "bar", "% xxx"]` -> `[" ", wrapped(["foo", "bar"]), "% xxx"]`. * */ function wrapSignificantContent(content, wrapper) { let hoistUntil = 0; let hoistAfter = content.length; for (let i = 0; i < content.length; i++) { if (_unified_latex_unified_latex_util_match.match.whitespace(content[i]) || _unified_latex_unified_latex_util_match.match.comment(content[i])) { hoistUntil = i + 1; continue; } break; } for (let j = content.length - 1; j >= 0; j--) { if (_unified_latex_unified_latex_util_match.match.whitespace(content[j]) || _unified_latex_unified_latex_util_match.match.comment(content[j])) { hoistAfter = j; continue; } break; } if (hoistUntil === 0 && hoistAfter === content.length) return ensureArray(wrapper(content)); const frontMatter = content.slice(0, hoistUntil); const middle = content.slice(hoistUntil, hoistAfter); const backMatter = content.slice(hoistAfter, content.length); return frontMatter.concat(wrapper(middle), backMatter); } function ensureArray(x) { if (!Array.isArray(x)) return [x]; return x; } //#endregion //#region libs/utils/replace-streaming-command-in-array.ts /** * Replace commands identified by `isStreamingCommand` with the return value of `replacer`. * E.g., the array `[head, streamingCommand, ...tail]` will become `[head, replacer(tail, streamingCommand)]`. * This function does not split based on parbreaks/etc.. It is right-associative and returns * the streaming commands that were encountered. */ function replaceStreamingCommandInArray(nodes, isStreamingCommand, replacer) { while (nodes.length > 0 && isStreamingCommand(nodes[nodes.length - 1])) { nodes.pop(); (0, _unified_latex_unified_latex_util_trim.trimEnd)(nodes); } const foundStreamingCommands = []; for (let i = nodes.length - 1; i >= 0; i--) { const node = nodes[i]; if (isStreamingCommand(node)) { const wrapper = (content) => replacer(content, node); let tail = nodes.slice(i + 1); (0, _unified_latex_unified_latex_util_trim.trimStart)(tail); tail = wrapSignificantContent(tail, wrapper); foundStreamingCommands.push(node); nodes.splice(i); joinWithoutExcessWhitespace(nodes, tail); } } return { foundStreamingCommands }; } //#endregion //#region libs/replace-streaming-command.ts /** * Process streaming commands in a group. If needed, "escape" the group. * For example, `{\bfseries xx}` -> `\textbf{xx}`, but `{foo \bfseries xx}` -> `{foo \textbf{xx}}`. */ function replaceStreamingCommandInGroup(group, isStreamingCommand, replacer, options) { const content = group.content; let popFromGroup = isStreamingCommand(firstSignificantNode(content)); let innerProcessed = replaceStreamingCommand(content, isStreamingCommand, replacer, options); if (innerProcessed.length === 0) return []; if (popFromGroup) return innerProcessed; else return [{ type: "group", content: innerProcessed }]; } /** * Given a group or a node array, look for streaming commands (e.g., `\bfseries`) and replace them * with the specified macro. The "arguments" of the streaming command are passed to `replacer` and the return * value of `replacer` is inserted into the stream. * * By default, this command will split at parbreaks (since commands like `\textbf{...} do not accept parbreaks in their * contents) and call `replacer` multiple times, once per paragraph. * * Commands are also split at environments and at any macros listed in `macrosThatBreakPars`. */ function replaceStreamingCommand(ast, isStreamingCommand, replacer, options) { if (typeof isStreamingCommand !== "function") throw new Error(`'isStreamingCommand' must be a function, not '${typeof isStreamingCommand}'`); const { macrosThatBreakPars = [ "part", "chapter", "section", "subsection", "subsubsection", "vspace", "smallskip", "medskip", "bigskip", "hfill" ], environmentsThatDontBreakPars = [] } = options || {}; let processedContent = []; if (_unified_latex_unified_latex_util_match.match.group(ast)) processedContent = replaceStreamingCommandInGroup(ast, isStreamingCommand, replacer); if (Array.isArray(ast)) { const nodes = ast; let scanIndex = nodes.length; let sliceIndex = scanIndex; while (scanIndex > 0 && (isStreamingCommand(nodes[scanIndex - 1]) || _unified_latex_unified_latex_util_match.match.whitespace(nodes[scanIndex - 1]))) { scanIndex--; if (isStreamingCommand(nodes[scanIndex])) sliceIndex = scanIndex; } if (sliceIndex !== nodes.length) nodes.splice(sliceIndex); const macroThatBreaks = _unified_latex_unified_latex_util_match.match.createMacroMatcher(macrosThatBreakPars); const envThatDoesntBreak = _unified_latex_unified_latex_util_match.match.createEnvironmentMatcher(environmentsThatDontBreakPars); const isPar = (node) => _unified_latex_unified_latex_util_match.match.parbreak(node) || _unified_latex_unified_latex_util_match.match.macro(node, "par") || macroThatBreaks(node) || _unified_latex_unified_latex_util_match.match.environment(node) && !envThatDoesntBreak(node) || node.type === "displaymath"; const splitByPar = (0, _unified_latex_unified_latex_util_split.splitOnCondition)(nodes, isPar); splitByPar.separators = splitByPar.separators.map((sep) => _unified_latex_unified_latex_util_match.match.macro(sep, "par") ? { type: "parbreak" } : sep); const replacers = []; let segments = splitByPar.segments.map((segment) => { if (segment.length === 0) return segment; function applyAccumulatedReplacers(nodes) { if (replacers.length === 0) return nodes; return wrapSignificantContent(nodes, composeReplacers(replacers)); } const { foundStreamingCommands } = replaceStreamingCommandInArray(segment, isStreamingCommand, replacer); const ret = applyAccumulatedReplacers(segment); foundStreamingCommands.forEach((macro) => { replacers.push((nodes) => { const ret = replacer(nodes, macro); if (!Array.isArray(ret)) return [ret]; return ret; }); }); return ret; }); if (segments.length > 1) segments.forEach((segment, i) => { if (i === 0) (0, _unified_latex_unified_latex_util_trim.trimEnd)(segment); else if (i === segments.length - 1) (0, _unified_latex_unified_latex_util_trim.trimStart)(segment); else (0, _unified_latex_unified_latex_util_trim.trim)(segment); }); processedContent = (0, _unified_latex_unified_latex_util_split.unsplitOnMacro)({ segments, macros: splitByPar.separators }); } return processedContent; } /** * Given a sequence of replacer functions `[f, g, h]` return * `h \circ g \circ f` * * @param {((nodes: Ast.Node[]) => Ast.Node)[]} replacers * @returns {(nodes: Ast.Node[]) => Ast.Node} */ function composeReplacers(replacers) { if (replacers.length === 0) throw new Error("Cannot compose zero replacement functions"); return (nodes) => { let ret = nodes; for (let i = 0; i < replacers.length; i++) { const func = replacers[i]; ret = func(ret); } return ret; }; } //#endregion //#region libs/replace-node-during-visit.ts /** * Replaces the current node with `replacement`. It is assumed that the current * node is in an array that is a child of a parent element. If this is not the case, * the function will error. */ function replaceNodeDuringVisit(replacement, info) { const parent = info.parents[0]; if (!parent) throw new Error(`Cannot replace node: parent not found`); const container = parent[info.key]; if (!Array.isArray(container)) throw new Error(`Cannot replace node: containing array not found`); if (info.index == null) throw new Error(`Cannot replace node: node index undefined`); if (!Array.isArray(replacement)) container[info.index] = replacement; else container.splice(info.index, 1, ...replacement); } //#endregion //#region libs/unified-latex-streaming-command.ts /** * Unified plugin to replace all found streaming commands with their argument-style equivalents. * This only applies to sections of the tree with no math ancestor. * * @param options.replacer A record of macro names and replacer functions. A replacer function accepts content and the original streaming command and is expected to return the argument-style command. It may be called multiple times per streaming command. */ var unifiedLatexReplaceStreamingCommands = function unifiedLatexReplaceStreamingCommands(options) { const { replacers = {} } = options || {}; const isReplaceable = _unified_latex_unified_latex_util_match.match.createMacroMatcher(replacers); return (tree) => { (0, _unified_latex_unified_latex_util_visit.visit)(tree, (group, info) => { if (info.context.hasMathModeAncestor || !group.content.some(isReplaceable)) return; let fixed = replaceStreamingCommand(group, isReplaceable, (content, command) => { return replacers[command.content](content, command); }); if (!info.containingArray || info.index == null) return; const prevToken = info.containingArray[info.index - 1]; const nextToken = info.containingArray[info.index + 1]; if (_unified_latex_unified_latex_util_match.match.whitespaceLike(prevToken) && _unified_latex_unified_latex_util_match.match.whitespaceLike(fixed[0])) (0, _unified_latex_unified_latex_util_trim.trimStart)(fixed); if (_unified_latex_unified_latex_util_match.match.whitespaceLike(nextToken) && _unified_latex_unified_latex_util_match.match.whitespaceLike(fixed[fixed.length - 1])) (0, _unified_latex_unified_latex_util_trim.trimEnd)(fixed); replaceNodeDuringVisit(fixed, info); }, { test: _unified_latex_unified_latex_util_match.match.group }); (0, _unified_latex_unified_latex_util_visit.visit)(tree, (nodes, info) => { if (info.context.hasMathModeAncestor || !nodes.some(isReplaceable)) return; const replaced = replaceStreamingCommand(nodes, isReplaceable, (content, command) => { return replacers[command.content](content, command); }); if (replaced !== nodes) { nodes.length = 0; nodes.push(...replaced); } }, { includeArrays: true, test: Array.isArray }); }; }; //#endregion //#region index.ts /** * ## What is this? * * Functions to help modify a `unified-latex` Abstract Syntax Tree (AST). * * ## When should I use this? * * If you want to recursively replace particular AST nodes. */ //#endregion exports.firstSignificantNode = firstSignificantNode; exports.firstSignificantNodeIndex = firstSignificantNodeIndex; exports.lastSignificantNode = lastSignificantNode; exports.lastSignificantNodeIndex = lastSignificantNodeIndex; exports.replaceNode = replaceNode; exports.replaceNodeDuringVisit = replaceNodeDuringVisit; exports.replaceStreamingCommand = replaceStreamingCommand; exports.replaceStreamingCommandInGroup = replaceStreamingCommandInGroup; exports.unifiedLatexReplaceStreamingCommands = unifiedLatexReplaceStreamingCommands; //# sourceMappingURL=index.cjs.map