@silverbulletmd/silverbullet 2.4.2 → 2.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/README.md +19 -4
  2. package/client/asset_bundle/bundle.ts +3 -9
  3. package/client/data/datastore.ts +4 -5
  4. package/client/markdown_parser/constants.ts +5 -4
  5. package/client/plugos/hooks/code_widget.ts +3 -8
  6. package/client/plugos/hooks/command.ts +8 -8
  7. package/client/plugos/hooks/document_editor.ts +10 -15
  8. package/client/plugos/hooks/event.ts +33 -36
  9. package/client/plugos/hooks/mq.ts +17 -17
  10. package/client/plugos/hooks/plug_namespace.ts +3 -8
  11. package/client/plugos/hooks/slash_command.ts +13 -28
  12. package/client/plugos/hooks/syscall.ts +3 -3
  13. package/client/plugos/manifest_cache.ts +22 -15
  14. package/client/plugos/plug.ts +2 -6
  15. package/client/plugos/plug_compile.ts +79 -78
  16. package/client/plugos/protocol.ts +28 -28
  17. package/client/plugos/proxy_fetch.ts +7 -6
  18. package/client/plugos/sandboxes/web_worker_sandbox.ts +1 -1
  19. package/client/plugos/sandboxes/worker_sandbox.ts +18 -18
  20. package/client/plugos/syscalls/asset.ts +1 -3
  21. package/client/plugos/syscalls/code_widget.ts +1 -3
  22. package/client/plugos/syscalls/config.ts +1 -5
  23. package/client/plugos/syscalls/datastore.ts +1 -1
  24. package/client/plugos/syscalls/editor.ts +72 -69
  25. package/client/plugos/syscalls/event.ts +9 -12
  26. package/client/plugos/syscalls/fetch.ts +31 -23
  27. package/client/plugos/syscalls/index.ts +10 -1
  28. package/client/plugos/syscalls/jsonschema.ts +72 -32
  29. package/client/plugos/syscalls/language.ts +9 -5
  30. package/client/plugos/syscalls/markdown.ts +29 -7
  31. package/client/plugos/syscalls/mq.ts +4 -12
  32. package/client/plugos/syscalls/service_registry.ts +1 -4
  33. package/client/plugos/syscalls/shell.ts +2 -5
  34. package/client/plugos/syscalls/space.ts +1 -1
  35. package/client/plugos/syscalls/sync.ts +69 -60
  36. package/client/plugos/syscalls/system.ts +2 -3
  37. package/client/plugos/system.ts +6 -12
  38. package/client/plugos/worker_runtime.ts +12 -33
  39. package/client/space_lua/aggregates.ts +782 -0
  40. package/client/space_lua/ast.ts +42 -8
  41. package/client/space_lua/ast_narrow.ts +4 -2
  42. package/client/space_lua/eval.ts +886 -575
  43. package/client/space_lua/labels.ts +7 -12
  44. package/client/space_lua/liq_null.ts +6 -0
  45. package/client/space_lua/numeric.ts +5 -8
  46. package/client/space_lua/parse.ts +346 -120
  47. package/client/space_lua/query_collection.ts +926 -82
  48. package/client/space_lua/query_env.ts +26 -0
  49. package/client/space_lua/render_lua_markdown.ts +369 -0
  50. package/client/space_lua/rp.ts +5 -4
  51. package/client/space_lua/runtime.ts +288 -155
  52. package/client/space_lua/stdlib/format.ts +53 -39
  53. package/client/space_lua/stdlib/js.ts +3 -7
  54. package/client/space_lua/stdlib/load.ts +1 -3
  55. package/client/space_lua/stdlib/math.ts +84 -58
  56. package/client/space_lua/stdlib/net.ts +27 -17
  57. package/client/space_lua/stdlib/os.ts +81 -85
  58. package/client/space_lua/stdlib/pattern.ts +695 -0
  59. package/client/space_lua/stdlib/prng.ts +148 -0
  60. package/client/space_lua/stdlib/space_lua.ts +17 -23
  61. package/client/space_lua/stdlib/string.ts +102 -190
  62. package/client/space_lua/stdlib/string_pack.ts +490 -0
  63. package/client/space_lua/stdlib/table.ts +76 -16
  64. package/client/space_lua/stdlib.ts +53 -39
  65. package/client/space_lua/tonumber.ts +82 -42
  66. package/client/space_lua/util.ts +53 -15
  67. package/dist/plug-compile.js +55 -98
  68. package/package.json +27 -20
  69. package/plug-api/constants.ts +0 -32
  70. package/plug-api/lib/async.ts +20 -7
  71. package/plug-api/lib/crypto.ts +16 -17
  72. package/plug-api/lib/dates.ts +15 -7
  73. package/plug-api/lib/json.ts +11 -5
  74. package/plug-api/lib/limited_map.ts +1 -1
  75. package/plug-api/lib/native_fetch.ts +2 -0
  76. package/plug-api/lib/ref.ts +23 -23
  77. package/plug-api/lib/resolve.ts +7 -11
  78. package/plug-api/lib/tags.ts +13 -4
  79. package/plug-api/lib/transclusion.ts +10 -21
  80. package/plug-api/lib/tree.ts +165 -45
  81. package/plug-api/lib/yaml.ts +35 -25
  82. package/plug-api/syscalls/asset.ts +1 -1
  83. package/plug-api/syscalls/config.ts +1 -4
  84. package/plug-api/syscalls/editor.ts +15 -15
  85. package/plug-api/syscalls/jsonschema.ts +1 -3
  86. package/plug-api/syscalls/lua.ts +3 -9
  87. package/plug-api/syscalls/mq.ts +1 -4
  88. package/plug-api/syscalls/shell.ts +4 -1
  89. package/plug-api/syscalls/space.ts +3 -10
  90. package/plug-api/syscalls/system.ts +1 -4
  91. package/plug-api/syscalls/yaml.ts +2 -6
  92. package/plug-api/system_mock.ts +0 -1
  93. package/plug-api/types/client.ts +16 -1
  94. package/plug-api/types/event.ts +6 -4
  95. package/plug-api/types/manifest.ts +8 -9
  96. package/plugs/builtin_plugs.ts +2 -2
  97. package/client/plugos/sandboxes/deno_worker_sandbox.ts +0 -6
@@ -77,8 +77,9 @@ export function isValidName(name: string): boolean {
77
77
  // If the name, parses as a link and doesn't provide any other info we can be
78
78
  // sure it was only parsed as a path and that the path then conforms to all
79
79
  // the requirements
80
- return !!ref && !ref.details && !ref.meta && name !== "" &&
81
- encodeRef(ref) === name;
80
+ return (
81
+ !!ref && !ref.details && !ref.meta && name !== "" && encodeRef(ref) === name
82
+ );
82
83
  }
83
84
 
84
85
  /**
@@ -87,7 +88,7 @@ export function isValidName(name: string): boolean {
87
88
  export function isValidPath(path: string): path is Path {
88
89
  const ref = parseToRef(path);
89
90
 
90
- return !!ref && ref.path === path;
91
+ return !!ref && ref.path === path && path !== "";
91
92
  }
92
93
 
93
94
  /**
@@ -95,7 +96,7 @@ export function isValidPath(path: string): path is Path {
95
96
  * TO THE INNER WORKINGS OF SILVERBULLET AND CHANGES COULD INTRODUCE MAJOR BUGS
96
97
  */
97
98
  const refRegex =
98
- /^(?<meta>\^)?(?<path>(?!.*\.[a-zA-Z0-9]+\.md$)(?!\/?(\.|\^))(?!.*(?:\/|^)\.{1,2}(?:\/|$)|.*\/{2})(?!.*(?:\]\]|\[\[))[^@#\|<>]*)(@(?<pos>\d+)|@[Ll](?<line>\d+)(?:[Cc](?<col>\d+))?|#\s*(?<header>.*))?$/;
99
+ /^(?<meta>\^)?(?<path>(?!.*\.[a-zA-Z0-9]+\.md$)(?!\/?(\.|\^))(?!.*(?:\/|^)\.{1,2}(?:\/|$)|.*\/{2})(?!.*(?:\]\]|\[\[))[^@#|<>]*)(@(?<pos>\d+)|@[Ll](?<line>\d+)(?:[Cc](?<col>\d+))?|#\s*(?<header>.*))?$/;
99
100
 
100
101
  /**
101
102
  * Parses a reference string into a ref object.
@@ -118,13 +119,13 @@ export function parseToRef(stringRef: string): Ref | null {
118
119
  if (groups.pos !== undefined) {
119
120
  ref.details = {
120
121
  type: "position",
121
- pos: parseInt(groups.pos),
122
+ pos: parseInt(groups.pos, 10),
122
123
  };
123
124
  } else if (groups.line !== undefined) {
124
125
  ref.details = {
125
126
  type: "linecolumn",
126
- line: parseInt(groups.line),
127
- column: groups.col !== undefined ? parseInt(groups.col) : 1,
127
+ line: parseInt(groups.line, 10),
128
+ column: groups.col !== undefined ? parseInt(groups.col, 10) : 1,
128
129
  };
129
130
  } else if (groups.header !== undefined) {
130
131
  ref.details = {
@@ -206,23 +207,22 @@ export function getOffsetFromHeader(
206
207
  parseTree: ParseTree,
207
208
  header: string,
208
209
  ): number {
209
- const node = findNodeMatching(
210
- parseTree,
211
- (subTree) => {
212
- if (!subTree.type || !subTree.type.startsWith("ATXHeading")) {
213
- return false;
214
- }
215
-
216
- const mark = findNodeOfType(subTree, "HeaderMark");
217
- if (!mark || mark.from === undefined || mark.to === undefined) {
218
- return false;
219
- }
220
-
221
- return renderToText(subTree)
210
+ const node = findNodeMatching(parseTree, (subTree) => {
211
+ if (!subTree.type || !subTree.type.startsWith("ATXHeading")) {
212
+ return false;
213
+ }
214
+
215
+ const mark = findNodeOfType(subTree, "HeaderMark");
216
+ if (!mark || mark.from === undefined || mark.to === undefined) {
217
+ return false;
218
+ }
219
+
220
+ return (
221
+ renderToText(subTree)
222
222
  .slice(mark.to - mark.from)
223
- .trimStart() === header.trim();
224
- },
225
- );
223
+ .trimStart() === header.trim()
224
+ );
225
+ });
226
226
 
227
227
  if (!node) {
228
228
  return -1;
@@ -4,9 +4,11 @@ import type { Path } from "@silverbulletmd/silverbullet/lib/ref";
4
4
  * Determines wether a url points into the world wide web or to the local SB instance
5
5
  */
6
6
  export function isLocalURL(url: string): boolean {
7
- return !url.includes("://") &&
7
+ return (
8
+ !url.includes("://") &&
8
9
  !url.startsWith("mailto:") &&
9
- !url.startsWith("tel:");
10
+ !url.startsWith("tel:")
11
+ );
10
12
  }
11
13
 
12
14
  /**
@@ -22,10 +24,7 @@ export function fileName(name: string | Path): string | Path {
22
24
  return name.split("/").pop()!;
23
25
  }
24
26
 
25
- const builtinPrefixes = [
26
- "tag:",
27
- "search:",
28
- ];
27
+ const builtinPrefixes = ["tag:", "search:"];
29
28
 
30
29
  /**
31
30
  * Builtin pages are pages which SB should automatically consider as existing
@@ -51,11 +50,8 @@ export function resolveMarkdownLink(
51
50
  if (relative.startsWith("/")) {
52
51
  return relative.slice(1);
53
52
  } else {
54
- const splitAbsolute = absolute
55
- .split("/")
56
- .slice(0, -1);
57
- const splitRelative = relative
58
- .split("/");
53
+ const splitAbsolute = absolute.split("/").slice(0, -1);
54
+ const splitRelative = relative.split("/");
59
55
 
60
56
  while (splitRelative && splitRelative[0] === "..") {
61
57
  splitAbsolute.pop();
@@ -1,15 +1,24 @@
1
+ /** Check if a tag marks a page as a meta page (exact "meta" or "meta/..." subtag) */
2
+ export function isMetaTag(tag: string): boolean {
3
+ return tag === "meta" || tag.startsWith("meta/");
4
+ }
5
+
1
6
  /** Extract the name from hashtag text, removing # prefix and <angle brackets> if necessary */
2
7
  export function extractHashtag(text: string): string {
3
- if (text[0] !== "#") { // you shouldn't call this function at all
8
+ if (text[0] !== "#") {
9
+ // you shouldn't call this function at all
4
10
  console.error("extractHashtag called on already clean string", text);
5
11
  return text;
6
12
  } else if (text[1] === "<") {
7
- if (text.slice(-1) !== ">") { // this is malformed: #<name but maybe we're trying to autocomplete
13
+ if (text.slice(-1) !== ">") {
14
+ // this is malformed: #<name but maybe we're trying to autocomplete
8
15
  return text.slice(2);
9
- } else { // this is correct #<name>
16
+ } else {
17
+ // this is correct #<name>
10
18
  return text.slice(2, -1);
11
19
  }
12
- } else { // this is just #name
20
+ } else {
21
+ // this is just #name
13
22
  return text.slice(1);
14
23
  }
15
24
  }
@@ -1,7 +1,3 @@
1
- import {
2
- isLocalURL,
3
- resolveMarkdownLink,
4
- } from "@silverbulletmd/silverbullet/lib/resolve";
5
1
  import {
6
2
  mdLinkRegex,
7
3
  wikiLinkRegex,
@@ -34,9 +30,10 @@ export type ContentDimensions = {
34
30
  * Parse an alias, possibly containing dimensions into an object
35
31
  * @example "alias", "alias|100", "alias|100x200", "100", "100x200"
36
32
  */
37
- export function parseDimensionFromAlias(
38
- text: string,
39
- ): { alias: string; dimension?: ContentDimensions } {
33
+ export function parseDimensionFromAlias(text: string): {
34
+ alias: string;
35
+ dimension?: ContentDimensions;
36
+ } {
40
37
  let alias: string;
41
38
  let dim: ContentDimensions | undefined;
42
39
  if (text.includes("|")) {
@@ -45,19 +42,19 @@ export function parseDimensionFromAlias(
45
42
  const [width, height] = dimPart.split("x");
46
43
  dim = {};
47
44
  if (width) {
48
- dim.width = parseInt(width);
45
+ dim.width = parseInt(width, 10);
49
46
  }
50
47
  if (height) {
51
- dim.height = parseInt(height);
48
+ dim.height = parseInt(height, 10);
52
49
  }
53
50
  } else if (/^[x\d]/.test(text)) {
54
51
  const [width, height] = text.split("x");
55
52
  dim = {};
56
53
  if (width) {
57
- dim.width = parseInt(width);
54
+ dim.width = parseInt(width, 10);
58
55
  }
59
56
  if (height) {
60
- dim.height = parseInt(height);
57
+ dim.height = parseInt(height, 10);
61
58
  }
62
59
  alias = "";
63
60
  } else {
@@ -71,10 +68,8 @@ export function parseDimensionFromAlias(
71
68
  * Parses a transclusion of the type `![[]]` or `![]()`
72
69
  * @param text
73
70
  */
74
- export function parseTransclusion(
75
- text: string,
76
- ): Transclusion | null {
77
- let url, alias = undefined;
71
+ export function parseTransclusion(text: string): Transclusion | null {
72
+ let url, alias;
78
73
  let linktype: LinkType = "markdownlink";
79
74
  // TODO: Take in the tree and use tree nodes to get url and alias (Applies to all regex uses)
80
75
  mdLinkRegex.lastIndex = 0;
@@ -83,12 +78,6 @@ export function parseTransclusion(
83
78
  if ((match = mdLinkRegex.exec(text)) && match.groups) {
84
79
  ({ url, title: alias } = match.groups);
85
80
 
86
- if (isLocalURL(url)) {
87
- url = resolveMarkdownLink(
88
- client.currentName(),
89
- decodeURI(url),
90
- );
91
- }
92
81
  linktype = "markdownlink";
93
82
  } else if ((match = wikiLinkRegex.exec(text)) && match.groups) {
94
83
  ({ stringRef: url, alias } = match.groups);
@@ -1,5 +1,3 @@
1
- import { deepClone } from "@silverbulletmd/silverbullet/lib/json";
2
-
3
1
  export type ParseTree = {
4
2
  type?: string; // undefined === text node
5
3
  from?: number;
@@ -21,7 +19,7 @@ export function addParentPointers(tree: ParseTree) {
21
19
  }
22
20
 
23
21
  export function removeParentPointers(tree: ParseTree) {
24
- delete tree.parent;
22
+ tree.parent = undefined;
25
23
  if (!tree.children) {
26
24
  return;
27
25
  }
@@ -51,38 +49,53 @@ export function collectNodesOfType(
51
49
  return collectNodesMatching(tree, (n) => n.type === nodeType);
52
50
  }
53
51
 
54
- export function collectNodesMatching(
52
+ function collectNodesMatchingInternal(
55
53
  tree: ParseTree,
56
54
  matchFn: (tree: ParseTree) => boolean,
57
- ): ParseTree[] {
55
+ results: ParseTree[],
56
+ ): void {
58
57
  if (matchFn(tree)) {
59
- return [tree];
58
+ results.push(tree);
59
+ return;
60
60
  }
61
- let results: ParseTree[] = [];
62
61
  if (tree.children) {
63
62
  for (const child of tree.children) {
64
- results = [...results, ...collectNodesMatching(child, matchFn)];
63
+ collectNodesMatchingInternal(child, matchFn, results);
65
64
  }
66
65
  }
66
+ }
67
+
68
+ export function collectNodesMatching(
69
+ tree: ParseTree,
70
+ matchFn: (tree: ParseTree) => boolean,
71
+ ): ParseTree[] {
72
+ const results: ParseTree[] = [];
73
+ collectNodesMatchingInternal(tree, matchFn, results);
67
74
  return results;
68
75
  }
69
76
 
70
- export async function collectNodesMatchingAsync(
77
+ async function collectNodesMatchingAsyncInternal(
71
78
  tree: ParseTree,
72
79
  matchFn: (tree: ParseTree) => Promise<boolean>,
73
- ): Promise<ParseTree[]> {
80
+ results: ParseTree[],
81
+ ): Promise<void> {
74
82
  if (await matchFn(tree)) {
75
- return [tree];
83
+ results.push(tree);
84
+ return;
76
85
  }
77
- let results: ParseTree[] = [];
78
86
  if (tree.children) {
79
87
  for (const child of tree.children) {
80
- results = [
81
- ...results,
82
- ...await collectNodesMatchingAsync(child, matchFn),
83
- ];
88
+ await collectNodesMatchingAsyncInternal(child, matchFn, results);
84
89
  }
85
90
  }
91
+ }
92
+
93
+ export async function collectNodesMatchingAsync(
94
+ tree: ParseTree,
95
+ matchFn: (tree: ParseTree) => Promise<boolean>,
96
+ ): Promise<ParseTree[]> {
97
+ const results: ParseTree[] = [];
98
+ await collectNodesMatchingAsyncInternal(tree, matchFn, results);
86
99
  return results;
87
100
  }
88
101
 
@@ -91,20 +104,23 @@ export function replaceNodesMatching(
91
104
  tree: ParseTree,
92
105
  substituteFn: (tree: ParseTree) => ParseTree | null | undefined,
93
106
  ) {
94
- if (tree && tree.children) {
95
- const children = tree.children.slice();
96
- for (const child of children) {
107
+ if (tree?.children) {
108
+ let i = 0;
109
+ while (i < tree.children.length) {
110
+ const child = tree.children[i];
97
111
  const subst = substituteFn(child);
98
112
  if (subst !== undefined) {
99
- const pos = tree.children.indexOf(child);
100
113
  if (subst) {
101
- tree.children.splice(pos, 1, subst);
114
+ tree.children[i] = subst;
115
+ i++;
102
116
  } else {
103
117
  // null = delete
104
- tree.children.splice(pos, 1);
118
+ tree.children.splice(i, 1);
119
+ // don't increment i — next child shifted into this position
105
120
  }
106
121
  } else {
107
122
  replaceNodesMatching(child, substituteFn);
123
+ i++;
108
124
  }
109
125
  }
110
126
  }
@@ -115,19 +131,21 @@ export async function replaceNodesMatchingAsync(
115
131
  substituteFn: (tree: ParseTree) => Promise<ParseTree | null | undefined>,
116
132
  ) {
117
133
  if (tree.children) {
118
- const children = tree.children.slice();
119
- for (const child of children) {
134
+ let i = 0;
135
+ while (i < tree.children.length) {
136
+ const child = tree.children[i];
120
137
  const subst = await substituteFn(child);
121
138
  if (subst !== undefined) {
122
- const pos = tree.children.indexOf(child);
123
139
  if (subst) {
124
- tree.children.splice(pos, 1, subst);
140
+ tree.children[i] = subst;
141
+ i++;
125
142
  } else {
126
143
  // null = delete
127
- tree.children.splice(pos, 1);
144
+ tree.children.splice(i, 1);
128
145
  }
129
146
  } else {
130
147
  await replaceNodesMatchingAsync(child, substituteFn);
148
+ i++;
131
149
  }
132
150
  }
133
151
  }
@@ -137,36 +155,88 @@ export function findNodeMatching(
137
155
  tree: ParseTree,
138
156
  matchFn: (tree: ParseTree) => boolean,
139
157
  ): ParseTree | null {
140
- return collectNodesMatching(tree, matchFn)[0];
158
+ if (matchFn(tree)) {
159
+ return tree;
160
+ }
161
+ if (tree.children) {
162
+ for (const child of tree.children) {
163
+ const result = findNodeMatching(child, matchFn);
164
+ if (result) {
165
+ return result;
166
+ }
167
+ }
168
+ }
169
+ return null;
141
170
  }
142
171
 
143
172
  export function findNodeOfType(
144
173
  tree: ParseTree,
145
174
  nodeType: string,
146
175
  ): ParseTree | null {
147
- return collectNodesMatching(tree, (n) => n.type === nodeType)[0];
176
+ if (tree.type === nodeType) {
177
+ return tree;
178
+ }
179
+ if (tree.children) {
180
+ for (const child of tree.children) {
181
+ const result = findNodeOfType(child, nodeType);
182
+ if (result) {
183
+ return result;
184
+ }
185
+ }
186
+ }
187
+ return null;
148
188
  }
149
189
 
150
190
  export function traverseTree(
151
191
  tree: ParseTree,
152
- // Return value = should stop traversal?
192
+ // Return value = should stop traversal into children?
153
193
  matchFn: (tree: ParseTree) => boolean,
154
194
  ): void {
155
- // Do a collect, but ignore the result
156
- collectNodesMatching(tree, matchFn);
195
+ if (matchFn(tree)) {
196
+ return;
197
+ }
198
+ if (tree.children) {
199
+ for (const child of tree.children) {
200
+ traverseTree(child, matchFn);
201
+ }
202
+ }
157
203
  }
158
204
 
159
205
  export async function traverseTreeAsync(
160
206
  tree: ParseTree,
161
- // Return value = should stop traversal?
207
+ // Return value = should stop traversal into children?
162
208
  matchFn: (tree: ParseTree) => Promise<boolean>,
163
209
  ): Promise<void> {
164
- // Do a collect, but ignore the result
165
- await collectNodesMatchingAsync(tree, matchFn);
210
+ if (await matchFn(tree)) {
211
+ return;
212
+ }
213
+ if (tree.children) {
214
+ for (const child of tree.children) {
215
+ await traverseTreeAsync(child, matchFn);
216
+ }
217
+ }
166
218
  }
167
219
 
168
220
  export function cloneTree(tree: ParseTree): ParseTree {
169
- return deepClone(tree, ["parent"]);
221
+ if (tree.text !== undefined) {
222
+ return {
223
+ from: tree.from,
224
+ to: tree.to,
225
+ text: tree.text,
226
+ };
227
+ }
228
+ const clone: ParseTree = {
229
+ type: tree.type,
230
+ from: tree.from,
231
+ to: tree.to,
232
+ };
233
+ if (tree.children) {
234
+ clone.children = new Array(tree.children.length);
235
+ for (let i = 0; i < tree.children.length; i++) {
236
+ clone.children[i] = cloneTree(tree.children[i]);
237
+ }
238
+ }
239
+ return clone;
170
240
  }
171
241
 
172
242
  // Finds non-text node at position
@@ -182,7 +252,8 @@ export function nodeAtPos(tree: ParseTree, pos: number): ParseTree | null {
182
252
  if (n && n.text !== undefined) {
183
253
  // Got a text node, let's return its parent
184
254
  return tree;
185
- } else if (n) {
255
+ }
256
+ if (n) {
186
257
  // Got it
187
258
  return n;
188
259
  }
@@ -190,26 +261,75 @@ export function nodeAtPos(tree: ParseTree, pos: number): ParseTree | null {
190
261
  return null;
191
262
  }
192
263
 
264
+ // Ensure a TableRow/TableHeader has a TableCell between every pair of
265
+ // TableDelimiters, and optionally pad to match columnCount.
266
+ // headerHasLeadingDelim indicates whether the header starts with a delimiter.
267
+ export function normalizeTableRow(
268
+ row: ParseTree,
269
+ columnCount?: number,
270
+ headerHasLeadingDelim?: boolean,
271
+ ): void {
272
+ const children = row.children;
273
+ if (!children) return;
274
+ const normalized: ParseTree[] = [];
275
+ let lookingForCell = false;
276
+ for (const child of children) {
277
+ if (child.type === "TableDelimiter" && lookingForCell) {
278
+ normalized.push({ type: "TableCell", children: [] });
279
+ }
280
+ if (child.type === "TableDelimiter") {
281
+ lookingForCell = true;
282
+ }
283
+ if (child.type === "TableCell") {
284
+ lookingForCell = false;
285
+ }
286
+ normalized.push(child);
287
+ }
288
+ row.children = normalized;
289
+
290
+ // Fix leading-pipe mismatch: row has leading delimiter but header doesn't
291
+ if (headerHasLeadingDelim === false) {
292
+ if (row.children.length > 0 && row.children[0].type === "TableDelimiter") {
293
+ // Insert empty cell after the leading delimiter
294
+ row.children.splice(1, 0, { type: "TableCell", children: [] });
295
+ }
296
+ }
297
+
298
+ // Pad trailing empty cells to match header column count
299
+ if (columnCount !== undefined) {
300
+ let cellCount = 0;
301
+ for (const child of row.children) {
302
+ if (child.type === "TableCell") cellCount++;
303
+ }
304
+ while (cellCount < columnCount) {
305
+ row.children.push({ type: "TableCell", children: [] });
306
+ cellCount++;
307
+ }
308
+ }
309
+ }
310
+
193
311
  // Turn ParseTree back into text
194
312
  export function renderToText(tree?: ParseTree): string {
195
313
  if (!tree) {
196
314
  return "";
197
315
  }
198
- const pieces: string[] = [];
199
316
  if (tree.text !== undefined) {
200
317
  return tree.text;
201
318
  }
202
- for (const child of tree.children!) {
203
- pieces.push(renderToText(child));
319
+ const children = tree.children!;
320
+ if (children.length === 1) {
321
+ return renderToText(children[0]);
322
+ }
323
+ let result = "";
324
+ for (const child of children) {
325
+ result += renderToText(child);
204
326
  }
205
- return pieces.join("");
327
+ return result;
206
328
  }
207
329
 
208
330
  export function cleanTree(tree: ParseTree, omitTrimmable = true): ParseTree {
209
331
  if (tree.type === "⚠") {
210
- throw new Error(
211
- `Parse error at pos ${tree.from}`,
212
- );
332
+ throw new Error(`Parse error at pos ${tree.from}`);
213
333
  }
214
334
  if (tree.text !== undefined) {
215
335
  return tree;
@@ -224,7 +344,7 @@ export function cleanTree(tree: ParseTree, omitTrimmable = true): ParseTree {
224
344
  if (node.type && node.type !== "Comment") {
225
345
  ast.children!.push(cleanTree(node, omitTrimmable));
226
346
  }
227
- if (node.text && (omitTrimmable && node.text.trim() || !omitTrimmable)) {
347
+ if (node.text && ((omitTrimmable && node.text.trim()) || !omitTrimmable)) {
228
348
  ast.children!.push(node);
229
349
  }
230
350
  }