The table style is determined by the header row. After normalizing the header (filling empty cells between consecutive delimiters), extract two properties: the column count and whether the header starts with a delimiter. For each data row, normalization proceeds in three steps. First, fill in empty cells between consecutive delimiters (this handles empty cells in the middle of a row). Second, if the header has no leading delimiter but the row does, insert an empty cell after the row's leading delimiter (this corrects the one-position shift caused by the row having a leading pipe that the header lacks. Third, if the row has fewer cells than the header column count, pad with empty cells at the end (this handles rows that are shorter than the header because trailing pipes or cells were omitted). The same normalization is applied both in the renderer and the indexer. Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>
281 lines
6.9 KiB
TypeScript
281 lines
6.9 KiB
TypeScript
import { deepClone } from "@silverbulletmd/silverbullet/lib/json";
|
|
|
|
export type ParseTree = {
|
|
type?: string; // undefined === text node
|
|
from?: number;
|
|
to?: number;
|
|
text?: string;
|
|
children?: ParseTree[];
|
|
// Only present after running addParentPointers
|
|
parent?: ParseTree;
|
|
};
|
|
|
|
export function addParentPointers(tree: ParseTree) {
|
|
if (!tree.children) {
|
|
return;
|
|
}
|
|
for (const child of tree.children) {
|
|
child.parent = tree;
|
|
addParentPointers(child);
|
|
}
|
|
}
|
|
|
|
export function removeParentPointers(tree: ParseTree) {
|
|
delete tree.parent;
|
|
if (!tree.children) {
|
|
return;
|
|
}
|
|
for (const child of tree.children) {
|
|
removeParentPointers(child);
|
|
}
|
|
}
|
|
|
|
export function findParentMatching(
|
|
tree: ParseTree,
|
|
matchFn: (tree: ParseTree) => boolean,
|
|
): ParseTree | null {
|
|
let node = tree.parent;
|
|
while (node) {
|
|
if (matchFn(node)) {
|
|
return node;
|
|
}
|
|
node = node.parent;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function collectNodesOfType(
|
|
tree: ParseTree,
|
|
nodeType: string,
|
|
): ParseTree[] {
|
|
return collectNodesMatching(tree, (n) => n.type === nodeType);
|
|
}
|
|
|
|
export function collectNodesMatching(
|
|
tree: ParseTree,
|
|
matchFn: (tree: ParseTree) => boolean,
|
|
): ParseTree[] {
|
|
if (matchFn(tree)) {
|
|
return [tree];
|
|
}
|
|
let results: ParseTree[] = [];
|
|
if (tree.children) {
|
|
for (const child of tree.children) {
|
|
results = [...results, ...collectNodesMatching(child, matchFn)];
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
export async function collectNodesMatchingAsync(
|
|
tree: ParseTree,
|
|
matchFn: (tree: ParseTree) => Promise<boolean>,
|
|
): Promise<ParseTree[]> {
|
|
if (await matchFn(tree)) {
|
|
return [tree];
|
|
}
|
|
let results: ParseTree[] = [];
|
|
if (tree.children) {
|
|
for (const child of tree.children) {
|
|
results = [
|
|
...results,
|
|
...await collectNodesMatchingAsync(child, matchFn),
|
|
];
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
// return value: returning undefined = not matched, continue, null = delete, new node = replace
|
|
export function replaceNodesMatching(
|
|
tree: ParseTree,
|
|
substituteFn: (tree: ParseTree) => ParseTree | null | undefined,
|
|
) {
|
|
if (tree && tree.children) {
|
|
const children = tree.children.slice();
|
|
for (const child of children) {
|
|
const subst = substituteFn(child);
|
|
if (subst !== undefined) {
|
|
const pos = tree.children.indexOf(child);
|
|
if (subst) {
|
|
tree.children.splice(pos, 1, subst);
|
|
} else {
|
|
// null = delete
|
|
tree.children.splice(pos, 1);
|
|
}
|
|
} else {
|
|
replaceNodesMatching(child, substituteFn);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function replaceNodesMatchingAsync(
|
|
tree: ParseTree,
|
|
substituteFn: (tree: ParseTree) => Promise<ParseTree | null | undefined>,
|
|
) {
|
|
if (tree.children) {
|
|
const children = tree.children.slice();
|
|
for (const child of children) {
|
|
const subst = await substituteFn(child);
|
|
if (subst !== undefined) {
|
|
const pos = tree.children.indexOf(child);
|
|
if (subst) {
|
|
tree.children.splice(pos, 1, subst);
|
|
} else {
|
|
// null = delete
|
|
tree.children.splice(pos, 1);
|
|
}
|
|
} else {
|
|
await replaceNodesMatchingAsync(child, substituteFn);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export function findNodeMatching(
|
|
tree: ParseTree,
|
|
matchFn: (tree: ParseTree) => boolean,
|
|
): ParseTree | null {
|
|
return collectNodesMatching(tree, matchFn)[0];
|
|
}
|
|
|
|
export function findNodeOfType(
|
|
tree: ParseTree,
|
|
nodeType: string,
|
|
): ParseTree | null {
|
|
return collectNodesMatching(tree, (n) => n.type === nodeType)[0];
|
|
}
|
|
|
|
export function traverseTree(
|
|
tree: ParseTree,
|
|
// Return value = should stop traversal?
|
|
matchFn: (tree: ParseTree) => boolean,
|
|
): void {
|
|
// Do a collect, but ignore the result
|
|
collectNodesMatching(tree, matchFn);
|
|
}
|
|
|
|
export async function traverseTreeAsync(
|
|
tree: ParseTree,
|
|
// Return value = should stop traversal?
|
|
matchFn: (tree: ParseTree) => Promise<boolean>,
|
|
): Promise<void> {
|
|
// Do a collect, but ignore the result
|
|
await collectNodesMatchingAsync(tree, matchFn);
|
|
}
|
|
|
|
export function cloneTree(tree: ParseTree): ParseTree {
|
|
return deepClone(tree, ["parent"]);
|
|
}
|
|
|
|
// Finds non-text node at position
|
|
export function nodeAtPos(tree: ParseTree, pos: number): ParseTree | null {
|
|
if (pos < tree.from! || pos >= tree.to!) {
|
|
return null;
|
|
}
|
|
if (!tree.children) {
|
|
return tree;
|
|
}
|
|
for (const child of tree.children) {
|
|
const n = nodeAtPos(child, pos);
|
|
if (n && n.text !== undefined) {
|
|
// Got a text node, let's return its parent
|
|
return tree;
|
|
}
|
|
if (n) {
|
|
// Got it
|
|
return n;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Ensure a TableRow/TableHeader has a TableCell between every pair of
|
|
// TableDelimiters, and optionally pad to match columnCount.
|
|
// headerHasLeadingDelim indicates whether the header starts with a delimiter.
|
|
export function normalizeTableRow(
|
|
row: ParseTree,
|
|
columnCount?: number,
|
|
headerHasLeadingDelim?: boolean,
|
|
): void {
|
|
const children = row.children;
|
|
if (!children) return;
|
|
const normalized: ParseTree[] = [];
|
|
let lookingForCell = false;
|
|
for (const child of children) {
|
|
if (child.type === "TableDelimiter" && lookingForCell) {
|
|
normalized.push({ type: "TableCell", children: [] });
|
|
}
|
|
if (child.type === "TableDelimiter") {
|
|
lookingForCell = true;
|
|
}
|
|
if (child.type === "TableCell") {
|
|
lookingForCell = false;
|
|
}
|
|
normalized.push(child);
|
|
}
|
|
row.children = normalized;
|
|
|
|
// Fix leading-pipe mismatch: row has leading delimiter but header doesn't
|
|
if (headerHasLeadingDelim === false) {
|
|
if (row.children.length > 0 && row.children[0].type === "TableDelimiter") {
|
|
// Insert empty cell after the leading delimiter
|
|
row.children.splice(1, 0, { type: "TableCell", children: [] });
|
|
}
|
|
}
|
|
|
|
// Pad trailing empty cells to match header column count
|
|
if (columnCount !== undefined) {
|
|
let cellCount = 0;
|
|
for (const child of row.children) {
|
|
if (child.type === "TableCell") cellCount++;
|
|
}
|
|
while (cellCount < columnCount) {
|
|
row.children.push({ type: "TableCell", children: [] });
|
|
cellCount++;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Turn ParseTree back into text
|
|
export function renderToText(tree?: ParseTree): string {
|
|
if (!tree) {
|
|
return "";
|
|
}
|
|
const pieces: string[] = [];
|
|
if (tree.text !== undefined) {
|
|
return tree.text;
|
|
}
|
|
for (const child of tree.children!) {
|
|
pieces.push(renderToText(child));
|
|
}
|
|
return pieces.join("");
|
|
}
|
|
|
|
export function cleanTree(tree: ParseTree, omitTrimmable = true): ParseTree {
|
|
if (tree.type === "⚠") {
|
|
throw new Error(
|
|
`Parse error at pos ${tree.from}`,
|
|
);
|
|
}
|
|
if (tree.text !== undefined) {
|
|
return tree;
|
|
}
|
|
const ast: ParseTree = {
|
|
type: tree.type,
|
|
children: [],
|
|
from: tree.from,
|
|
to: tree.to,
|
|
};
|
|
for (const node of tree.children!) {
|
|
if (node.type && node.type !== "Comment") {
|
|
ast.children!.push(cleanTree(node, omitTrimmable));
|
|
}
|
|
if (node.text && (omitTrimmable && node.text.trim() || !omitTrimmable)) {
|
|
ast.children!.push(node);
|
|
}
|
|
}
|
|
return ast;
|
|
}
|