@silverbulletmd/silverbullet 2.9.0 → 2.11.0

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 (123) hide show
  1. package/README.md +38 -25
  2. package/client/asset_bundle/bundle.ts +0 -1
  3. package/client/config.ts +343 -0
  4. package/client/markdown_parser/constants.ts +3 -1
  5. package/client/plugos/hooks/command.ts +0 -5
  6. package/client/plugos/hooks/event.ts +0 -6
  7. package/client/plugos/hooks/mq.ts +0 -3
  8. package/client/plugos/hooks/slash_command.ts +1 -7
  9. package/client/plugos/hooks/syscall.ts +24 -14
  10. package/client/plugos/manifest_cache.ts +0 -32
  11. package/client/plugos/plug.ts +0 -3
  12. package/client/plugos/plug_compile.ts +3 -13
  13. package/client/plugos/sandboxes/worker_sandbox.ts +19 -9
  14. package/client/plugos/syscalls/asset.ts +61 -23
  15. package/client/plugos/syscalls/clientStore.ts +32 -6
  16. package/client/plugos/syscalls/client_code_widget.ts +9 -4
  17. package/client/plugos/syscalls/code_widget.ts +26 -11
  18. package/client/plugos/syscalls/config.ts +146 -30
  19. package/client/plugos/syscalls/datastore.ts +146 -40
  20. package/client/plugos/syscalls/editor.ts +1518 -722
  21. package/client/plugos/syscalls/event.ts +44 -13
  22. package/client/plugos/syscalls/fetch.ts +120 -83
  23. package/client/plugos/syscalls/icon.ts +46 -0
  24. package/client/plugos/syscalls/index.ts +267 -121
  25. package/client/plugos/syscalls/jsonschema.ts +107 -9
  26. package/client/plugos/syscalls/language.ts +38 -12
  27. package/client/plugos/syscalls/markdown.ts +166 -50
  28. package/client/plugos/syscalls/mq.ts +92 -20
  29. package/client/plugos/syscalls/navigator.ts +143 -0
  30. package/client/plugos/syscalls/schema_introspection.ts +33 -0
  31. package/client/plugos/syscalls/search.ts +44 -0
  32. package/client/plugos/syscalls/service_registry.ts +74 -20
  33. package/client/plugos/syscalls/shell.ts +51 -21
  34. package/client/plugos/syscalls/space.ts +412 -113
  35. package/client/plugos/syscalls/sync.ts +103 -20
  36. package/client/plugos/syscalls/system.ts +274 -139
  37. package/client/plugos/system.ts +28 -10
  38. package/client/plugos/worker_runtime.ts +0 -2
  39. package/client/space_lua/aggregates.ts +0 -16
  40. package/client/space_lua/api_documentation.ts +107 -0
  41. package/client/space_lua/ast.ts +13 -5
  42. package/client/space_lua/ast_narrow.ts +0 -5
  43. package/client/space_lua/budget.ts +113 -0
  44. package/client/space_lua/budget_ui.ts +49 -0
  45. package/client/space_lua/eval.ts +125 -92
  46. package/client/space_lua/labels.ts +0 -4
  47. package/client/space_lua/numeric.ts +0 -5
  48. package/client/space_lua/parse.ts +243 -125
  49. package/client/space_lua/pretty_print.ts +128 -54
  50. package/client/space_lua/quarantine.ts +127 -0
  51. package/client/space_lua/query_collection.ts +7 -51
  52. package/client/space_lua/query_env.ts +0 -2
  53. package/client/space_lua/render_lua_markdown.ts +2 -7
  54. package/client/space_lua/render_widget.ts +173 -0
  55. package/client/space_lua/runtime.ts +133 -92
  56. package/client/space_lua/stdlib/crypto.ts +8 -3
  57. package/client/space_lua/stdlib/encoding.ts +27 -9
  58. package/client/space_lua/stdlib/format.ts +0 -12
  59. package/client/space_lua/stdlib/js.ts +136 -23
  60. package/client/space_lua/stdlib/load.ts +24 -16
  61. package/client/space_lua/stdlib/math.ts +331 -133
  62. package/client/space_lua/stdlib/net.ts +57 -13
  63. package/client/space_lua/stdlib/os.ts +111 -54
  64. package/client/space_lua/stdlib/pattern.ts +10 -15
  65. package/client/space_lua/stdlib/space_lua.ts +344 -21
  66. package/client/space_lua/stdlib/string.ts +282 -104
  67. package/client/space_lua/stdlib/string_pack.ts +58 -22
  68. package/client/space_lua/stdlib/table.ts +195 -45
  69. package/client/space_lua/stdlib.ts +354 -125
  70. package/client/space_lua/syscalls.ts +270 -0
  71. package/client/space_lua/tonumber.ts +0 -9
  72. package/dist/plug-compile.js +2 -3
  73. package/package.json +15 -5
  74. package/plug-api/lib/async.ts +1 -6
  75. package/plug-api/lib/collation.ts +22 -0
  76. package/plug-api/lib/crypto.ts +16 -15
  77. package/plug-api/lib/dates.ts +33 -0
  78. package/plug-api/lib/fuzzy.ts +282 -0
  79. package/plug-api/lib/json.ts +0 -6
  80. package/plug-api/lib/limited_map.ts +0 -2
  81. package/plug-api/lib/link_write.ts +27 -0
  82. package/plug-api/{ui → lib}/panel_styles.ts +4 -3
  83. package/plug-api/lib/ref.ts +124 -5
  84. package/plug-api/lib/resolve.ts +0 -1
  85. package/plug-api/lib/resolve_path.ts +290 -0
  86. package/plug-api/lib/shortcut.ts +17 -1
  87. package/plug-api/lib/tags.ts +0 -3
  88. package/plug-api/lib/transclusion.ts +28 -1
  89. package/plug-api/lib/tree.ts +0 -5
  90. package/plug-api/lib/yaml.ts +3 -42
  91. package/plug-api/syscall.ts +1 -5
  92. package/plug-api/syscalls/config.ts +7 -5
  93. package/plug-api/syscalls/editor.ts +70 -208
  94. package/plug-api/syscalls/icon.ts +18 -0
  95. package/plug-api/syscalls/index.ts +39 -5
  96. package/plug-api/syscalls/jsonschema.ts +9 -0
  97. package/plug-api/syscalls/lua.ts +7 -0
  98. package/plug-api/syscalls/search.ts +11 -0
  99. package/plug-api/syscalls/shell.ts +0 -1
  100. package/plug-api/syscalls/space.ts +122 -5
  101. package/plug-api/syscalls/sync.ts +23 -2
  102. package/plug-api/syscalls/system.ts +53 -0
  103. package/plug-api/syscalls.ts +2 -0
  104. package/plug-api/system_mock.ts +15 -2
  105. package/plug-api/types/client.ts +4 -3
  106. package/plug-api/types/datastore.ts +11 -2
  107. package/plug-api/types/index.ts +62 -1
  108. package/plug-api/types/manifest.ts +22 -4
  109. package/plug-api/types/profile.ts +13 -0
  110. package/plug-api/types/revisions.ts +67 -0
  111. package/plug-api/ui/cx.ts +1 -3
  112. package/plug-api/ui/description.ts +64 -0
  113. package/plug-api/ui/hover.ts +69 -0
  114. package/plug-api/ui/index.ts +61 -2
  115. package/plug-api/ui/scroll.ts +30 -0
  116. package/plug-api/ui/slugify.ts +44 -0
  117. package/plug-api/ui/tree_model.ts +263 -0
  118. package/plug-api/ui/tree_types.ts +66 -0
  119. package/plug-api/ui/use_fit_collapse.ts +67 -0
  120. package/plugs/builtin_plugs.ts +0 -1
  121. package/plugs/index/types.ts +9 -0
  122. package/client/plugos/syscalls/lua.ts +0 -81
  123. package/plug-api/lib/memory_cache.ts +0 -21
@@ -0,0 +1,282 @@
1
+ function normalize(s: string): string {
2
+ return s
3
+ .normalize("NFD")
4
+ .replace(/\p{Diacritic}/gu, "")
5
+ .toLowerCase();
6
+ }
7
+
8
+ const WORD_BOUNDARY_CHARS = new Set(["/", "-", "_", " ", ".", ":"]);
9
+
10
+ function isWordBoundaryAt(
11
+ original: string,
12
+ lowered: string,
13
+ i: number,
14
+ ): boolean {
15
+ if (i === 0) return true;
16
+ const prev = lowered[i - 1];
17
+ if (WORD_BOUNDARY_CHARS.has(prev)) return true;
18
+ // CamelCase boundary: previous original char is lowercase, current is uppercase
19
+ const origPrev = original[i - 1];
20
+ const origCur = original[i];
21
+ if (
22
+ origPrev &&
23
+ origCur &&
24
+ origPrev === origPrev.toLowerCase() &&
25
+ origPrev !== origPrev.toUpperCase() &&
26
+ origCur === origCur.toUpperCase() &&
27
+ origCur !== origCur.toLowerCase()
28
+ ) {
29
+ return true;
30
+ }
31
+ return false;
32
+ }
33
+
34
+ function bestSubstringEditDistance(token: string, candidate: string): number {
35
+ const tl = token.length;
36
+ const cl = candidate.length;
37
+ let prevPrev = new Array(cl + 1).fill(0);
38
+ let prev = new Array(cl + 1).fill(0);
39
+ let curr = new Array(cl + 1);
40
+
41
+ for (let i = 1; i <= tl; i++) {
42
+ curr[0] = i;
43
+ for (let j = 1; j <= cl; j++) {
44
+ const cost = token[i - 1] === candidate[j - 1] ? 0 : 1;
45
+ let v = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
46
+ if (
47
+ i > 1 &&
48
+ j > 1 &&
49
+ token[i - 1] === candidate[j - 2] &&
50
+ token[i - 2] === candidate[j - 1]
51
+ ) {
52
+ v = Math.min(v, prevPrev[j - 2] + 1);
53
+ }
54
+ curr[j] = v;
55
+ }
56
+ const tmp = prevPrev;
57
+ prevPrev = prev;
58
+ prev = curr;
59
+ curr = tmp;
60
+ }
61
+
62
+ let best = prev[0];
63
+ for (let j = 1; j <= cl; j++) {
64
+ if (prev[j] < best) best = prev[j];
65
+ }
66
+ return best;
67
+ }
68
+
69
+ export function typoScore(token: string, candidate: string): number {
70
+ if (token.length < 4) return 0;
71
+ const max = Math.min(2, Math.floor(token.length / 4));
72
+ if (max < 1) return 0;
73
+
74
+ const bestDist = bestSubstringEditDistance(token, candidate);
75
+ if (bestDist > max) return 0;
76
+ return 0.25 - 0.05 * bestDist; // 0.25 / 0.20 / 0.15
77
+ }
78
+
79
+ function subsequenceScore(
80
+ token: string,
81
+ candidate: string,
82
+ candidateOriginal: string,
83
+ ): number {
84
+ // Greedy left-to-right match; track contiguous runs and word-boundary hits.
85
+ let ti = 0;
86
+ let firstMatch = -1;
87
+ let lastMatch = -1;
88
+ let contiguousRunSum = 0;
89
+ let currentRun = 0;
90
+ let boundaryHits = 0;
91
+ let prevMatchCi = -2;
92
+
93
+ for (let ci = 0; ci < candidate.length && ti < token.length; ci++) {
94
+ if (candidate[ci] === token[ti]) {
95
+ if (firstMatch < 0) firstMatch = ci;
96
+ lastMatch = ci;
97
+ if (isWordBoundaryAt(candidateOriginal, candidate, ci)) boundaryHits++;
98
+ if (ci === prevMatchCi + 1) {
99
+ currentRun++;
100
+ } else {
101
+ contiguousRunSum += currentRun * currentRun;
102
+ currentRun = 1;
103
+ }
104
+ prevMatchCi = ci;
105
+ ti++;
106
+ }
107
+ }
108
+ contiguousRunSum += currentRun * currentRun;
109
+
110
+ if (ti < token.length) return 0; // not a subsequence at all
111
+
112
+ // Normalize: token length over span (penalizes wide spans)
113
+ const span = lastMatch - firstMatch + 1;
114
+ const density = token.length / span; // in (0, 1]
115
+ const boundaryBonus = boundaryHits / token.length; // in [0, 1]
116
+ const contiguityBonus = contiguousRunSum / (token.length * token.length); // in (0, 1]
117
+
118
+ // Weighted combination scaled into [0.30, 0.65]
119
+ const raw = 0.5 * density + 0.3 * contiguityBonus + 0.2 * boundaryBonus;
120
+ return 0.3 + raw * 0.35;
121
+ }
122
+
123
+ export function scoreToken(token: string, candidate: string): number {
124
+ if (!token) return 0;
125
+ const t = normalize(token);
126
+ const cOrig = candidate;
127
+ const c = normalize(candidate);
128
+
129
+ // Tier 1: exact equality
130
+ if (c === t) return 1.0;
131
+
132
+ // Tier 2: prefix
133
+ if (c.startsWith(t)) {
134
+ return isWordBoundaryAt(cOrig, c, 0) ? 0.95 : 0.9;
135
+ }
136
+
137
+ // Tier 3: substring. Search for the best occurrence — a word-boundary
138
+ // match wins even if it appears later than a non-boundary match.
139
+ {
140
+ let bestIdx = -1;
141
+ let bestBoundary = false;
142
+ let idx = c.indexOf(t);
143
+ while (idx >= 0) {
144
+ const boundary = isWordBoundaryAt(cOrig, c, idx);
145
+ if (bestIdx < 0 || (boundary && !bestBoundary)) {
146
+ bestIdx = idx;
147
+ bestBoundary = boundary;
148
+ if (boundary) break; // can't get better
149
+ }
150
+ idx = c.indexOf(t, idx + 1);
151
+ }
152
+ if (bestIdx >= 0) {
153
+ // For very short tokens (<=2 chars), only count substring matches at
154
+ // word boundaries — otherwise short tokens generate too much noise.
155
+ if (t.length > 2 || bestBoundary) {
156
+ return bestBoundary ? 0.8 : 0.75;
157
+ }
158
+ // fall through for short non-boundary substring matches
159
+ }
160
+ }
161
+
162
+ // Tier 4: subsequence (short tokens skip this tier; substring at boundary
163
+ // is the only way for them to match)
164
+ if (t.length > 2) {
165
+ const subseq = subsequenceScore(t, c, cOrig);
166
+ if (subseq > 0) return subseq;
167
+ }
168
+
169
+ // Tier 5: bounded typo
170
+ const typo = typoScore(t, c);
171
+ if (typo > 0) return typo;
172
+
173
+ return 0;
174
+ }
175
+
176
+ export type RankField = { weight: number; segments?: boolean };
177
+ export type RankOptions<T> = {
178
+ fields?: Record<string, number | RankField>;
179
+ orderId?: (obj: T) => number;
180
+ };
181
+
182
+ const DEFAULT_FIELDS: Record<string, number | RankField> = {
183
+ name: { weight: 1.0, segments: true },
184
+ displayName: 0.9,
185
+ aliases: 0.85,
186
+ };
187
+
188
+ /**
189
+ * Whether `phrase` equals one of `obj`'s ranked fields verbatim (normalized).
190
+ * Per-token scoring (below) never penalizes a candidate for carrying *extra*
191
+ * words the phrase didn't ask for -- "Foo" and "Foo Bar" score identically
192
+ * against the phrase "Foo", since each of the phrase's tokens matches
193
+ * somewhere in either candidate just as well. That leaves same-score ties
194
+ * exactly where a user's full, exact phrase should still win outright, which
195
+ * is what this tie-break (see `rank`'s sort) is for.
196
+ */
197
+ function hasExactFieldMatch<T extends Record<string, any>>(
198
+ obj: T,
199
+ normalizedPhrase: string,
200
+ fields: Record<string, number | RankField>,
201
+ ): boolean {
202
+ for (const fieldName of Object.keys(fields)) {
203
+ const raw = obj[fieldName];
204
+ const values: unknown[] = Array.isArray(raw)
205
+ ? raw
206
+ : raw != null
207
+ ? [raw]
208
+ : [];
209
+ for (const value of values) {
210
+ if (normalize(String(value)) === normalizedPhrase) return true;
211
+ }
212
+ }
213
+ return false;
214
+ }
215
+
216
+ export function rank<T extends Record<string, any>>(
217
+ objects: T[],
218
+ phrase: string,
219
+ options: RankOptions<T> = {},
220
+ ): (T & { score: number })[] {
221
+ const fields = options.fields ?? DEFAULT_FIELDS;
222
+ const orderId = options.orderId ?? (() => 0);
223
+ const normalizedPhrase = normalize(phrase);
224
+ const tokens = normalizedPhrase.split(/\s+/).filter((t) => t.length > 0);
225
+ if (tokens.length === 0) {
226
+ return [...objects]
227
+ .sort(
228
+ (a, b) =>
229
+ orderId(a) - orderId(b) ||
230
+ String(a.name ?? "").localeCompare(String(b.name ?? "")),
231
+ )
232
+ .map((o) => ({ ...o, score: 1 }));
233
+ }
234
+ const results: (T & { score: number })[] = [];
235
+ for (const obj of objects) {
236
+ let product = 1;
237
+ let excluded = false;
238
+ for (const token of tokens) {
239
+ let best = 0;
240
+ for (const [fieldName, fieldSpec] of Object.entries(fields)) {
241
+ const spec: RankField =
242
+ typeof fieldSpec === "number" ? { weight: fieldSpec } : fieldSpec;
243
+ const raw = obj[fieldName];
244
+ const values: string[] = Array.isArray(raw)
245
+ ? raw.map(String)
246
+ : raw != null
247
+ ? [String(raw)]
248
+ : [];
249
+ for (const value of values) {
250
+ if (spec.segments && value.includes("/")) {
251
+ const last = value.slice(value.lastIndexOf("/") + 1);
252
+ best = Math.max(
253
+ best,
254
+ scoreToken(token, last) * spec.weight,
255
+ scoreToken(token, value) * spec.weight * 0.85,
256
+ );
257
+ } else {
258
+ best = Math.max(best, scoreToken(token, value) * spec.weight);
259
+ }
260
+ }
261
+ }
262
+ if (best === 0) {
263
+ excluded = true;
264
+ break;
265
+ }
266
+ product *= best;
267
+ }
268
+ if (!excluded) {
269
+ results.push({ ...obj, score: product ** (1 / tokens.length) });
270
+ }
271
+ }
272
+ return results.sort((a, b) => {
273
+ if (b.score !== a.score) return b.score - a.score;
274
+ const aExact = hasExactFieldMatch(a, normalizedPhrase, fields);
275
+ const bExact = hasExactFieldMatch(b, normalizedPhrase, fields);
276
+ if (aExact !== bExact) return aExact ? -1 : 1;
277
+ return (
278
+ orderId(a) - orderId(b) ||
279
+ String(a.name ?? "").localeCompare(String(b.name ?? ""))
280
+ );
281
+ });
282
+ }
@@ -50,7 +50,6 @@ export function deepEqual(a: any, b: any): boolean {
50
50
  * @param d the date to convert
51
51
  */
52
52
  export function cleanStringDate(d: Date): string {
53
- // If no significant time, return a date string only
54
53
  if (
55
54
  d.getUTCHours() === 0 &&
56
55
  d.getUTCMinutes() === 0 &&
@@ -85,7 +84,6 @@ export function cleanupJSON(a: any): any {
85
84
  if (Array.isArray(a)) {
86
85
  return a.map(cleanupJSON);
87
86
  }
88
- // If a is a date, convert to a string
89
87
  if (a instanceof Date) {
90
88
  return cleanStringDate(a);
91
89
  }
@@ -106,17 +104,14 @@ export function cleanupJSON(a: any): any {
106
104
  }
107
105
 
108
106
  export function deepClone<T>(obj: T, ignoreKeys: string[] = []): T {
109
- // Handle null, undefined, or primitive types (string, number, boolean, symbol, bigint)
110
107
  if (obj === null || typeof obj !== "object") {
111
108
  return obj;
112
109
  }
113
110
 
114
- // Handle Date
115
111
  if (obj instanceof Date) {
116
112
  return new Date(obj.getTime()) as any;
117
113
  }
118
114
 
119
- // Handle Array
120
115
  if (Array.isArray(obj)) {
121
116
  const arrClone: any[] = [];
122
117
  for (let i = 0; i < obj.length; i++) {
@@ -125,7 +120,6 @@ export function deepClone<T>(obj: T, ignoreKeys: string[] = []): T {
125
120
  return arrClone as any;
126
121
  }
127
122
 
128
- // Handle Object
129
123
  if (obj instanceof Object) {
130
124
  const objClone: { [key: string]: any } = {};
131
125
  for (const key in obj) {
@@ -31,7 +31,6 @@ export class LimitedMap<V> {
31
31
  }, ttl);
32
32
  }
33
33
  if (this.map.size >= this.maxSize) {
34
- // Remove the oldest key before adding a new one
35
34
  const oldestKey = this.getOldestKey();
36
35
  this.map.delete(oldestKey!);
37
36
  }
@@ -41,7 +40,6 @@ export class LimitedMap<V> {
41
40
  get(key: string): V | undefined {
42
41
  const entry = this.map.get(key);
43
42
  if (entry) {
44
- // Update the last accessed timestamp
45
43
  entry.la = Date.now();
46
44
  return entry.value;
47
45
  }
@@ -0,0 +1,27 @@
1
+ import { config } from "../syscalls.ts";
2
+ import { getNameFromPath, type Path } from "./ref.ts";
3
+ import {
4
+ type LinkWriteFormat,
5
+ type PathIndex,
6
+ writeLinkPath,
7
+ } from "./resolve_path.ts";
8
+
9
+ /**
10
+ * The space's configured link write format — the one place its config key and
11
+ * default live. Plug-side only (reads config through a syscall).
12
+ */
13
+ export function linkWriteFormat(): Promise<LinkWriteFormat> {
14
+ return config.get<LinkWriteFormat>("linkWriteFormat", "full-path");
15
+ }
16
+
17
+ /**
18
+ * Renders a page path as the link text SilverBullet writes for it under the
19
+ * given format.
20
+ */
21
+ export function writtenLinkText(
22
+ path: Path,
23
+ format: LinkWriteFormat,
24
+ index: PathIndex,
25
+ ): string {
26
+ return getNameFromPath(writeLinkPath(path, format, index));
27
+ }
@@ -15,9 +15,10 @@ export type PanelStylesOptions = {
15
15
  * Order: components first (base), then space styles (so user theming wins),
16
16
  * leaving any plug-specific CSS you append afterwards highest-precedence.
17
17
  */
18
- export async function panelStyles(
19
- { components = true, spaceStyles = true }: PanelStylesOptions = {},
20
- ): Promise<string> {
18
+ export async function panelStyles({
19
+ components = true,
20
+ spaceStyles = true,
21
+ }: PanelStylesOptions = {}): Promise<string> {
21
22
  let out = "";
22
23
  if (components) {
23
24
  out += `<link rel="stylesheet" href=".client/components.css">`;
@@ -62,13 +62,29 @@ function normalizePath(path: string): Path {
62
62
  path = path.slice(1);
63
63
  }
64
64
 
65
- if (/.+\.[a-zA-Z0-9]+$/.test(path) || path === "") {
65
+ if (endsInExtension(path) || path === "") {
66
66
  return path as Path;
67
67
  }
68
68
 
69
69
  return `${path}.md`;
70
70
  }
71
71
 
72
+ function endsInExtension(path: string): boolean {
73
+ const dot = path.lastIndexOf(".");
74
+ if (dot < 1 || dot === path.length - 1) {
75
+ return false;
76
+ }
77
+ for (let i = dot + 1; i < path.length; i++) {
78
+ const c = path.charCodeAt(i);
79
+ const alphanumeric =
80
+ (c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122);
81
+ if (!alphanumeric) {
82
+ return false;
83
+ }
84
+ }
85
+ return true;
86
+ }
87
+
72
88
  /**
73
89
  * Determines wether a name conforms to all the requirments.
74
90
  */
@@ -93,8 +109,7 @@ export function isValidPath(path: string): path is Path {
93
109
  }
94
110
 
95
111
  /**
96
- * ONLY TOUCH THIS IF YOU REALLY KNOW WHAT YOU ARE DOING. THIS REGEX IS INTEGRAL
97
- * TO THE INNER WORKINGS OF SILVERBULLET AND CHANGES COULD INTRODUCE MAJOR BUGS
112
+ * Shared reference grammar for parsing and validation; changes affect both.
98
113
  */
99
114
  const refRegex =
100
115
  /^(?<meta>\^)?(?<path>(?!.*\.[a-zA-Z0-9]+\.md$)(?!\/?(\.|\^))(?!.*(?:\/|^)\.{1,2}(?:\/|$)|.*\/{2})(?!.*(?:\]\]|\[\[))[^@#|<>$]*)(@(?<pos>\d+)|@[Ll](?<line>\d+)(?:[Cc](?<col>\d+))?|#\s*(?<header>.*)|\$(?<anchor>[A-Za-z_][A-Za-z0-9_/:-]*))?$/;
@@ -141,9 +156,113 @@ export function parseToRef(stringRef: string): Ref | null {
141
156
  }
142
157
 
143
158
  /**
144
- * The inverse of {@link parseToRef}, encodes a ref object into a reference string.
145
- * It tries to produce the shortest valid representation
159
+ * Coerces a ref-or-string (including the legacy `{ page, pos, header }` shape)
160
+ * into a {@link Ref} and validates its structure, throwing on malformed input.
161
+ * Used wherever an external caller (e.g. a syscall) hands in a ref that may be a
162
+ * string or a legacy object.
146
163
  */
164
+ export function coerceAndValidateRef(ref: Ref | string): Ref {
165
+ if (typeof ref === "string") {
166
+ const parsedRef = parseToRef(ref);
167
+ if (!parsedRef) {
168
+ throw new Error("Unable to parse string as ref");
169
+ }
170
+ ref = parsedRef;
171
+ }
172
+
173
+ if (
174
+ // @ts-expect-error: Legacy support
175
+ ref.page !== undefined
176
+ ) {
177
+ console.warn(
178
+ "You are using legacy navigation syntax (`{ page, pos, header }`), this will be phased out in the future",
179
+ );
180
+
181
+ const legacyRef = ref as unknown as {
182
+ kind: "page" | "document";
183
+ page: string;
184
+ pos?: number | { line: number; column: number };
185
+ header?: string;
186
+ meta?: boolean;
187
+ };
188
+
189
+ legacyRef.kind ??= "page";
190
+
191
+ let details: Ref["details"];
192
+
193
+ if (typeof legacyRef.pos === "number") {
194
+ details = { type: "position", pos: legacyRef.pos };
195
+ } else if (legacyRef.pos) {
196
+ details = {
197
+ type: "linecolumn",
198
+ line: legacyRef.pos.line,
199
+ column: legacyRef.pos.column,
200
+ };
201
+ } else if (legacyRef.header) {
202
+ details = { type: "header", header: legacyRef.header };
203
+ }
204
+
205
+ ref = {
206
+ path: (legacyRef.kind === "page"
207
+ ? `${legacyRef.page}.md`
208
+ : legacyRef.page) as Path,
209
+ details,
210
+ meta: legacyRef.meta,
211
+ };
212
+ }
213
+
214
+ if (!isValidPath(ref.path) && ref.path !== "") {
215
+ throw new Error("Path passed in ref is invalid");
216
+ } else if (typeof ref.meta !== "boolean" && ref.meta !== undefined) {
217
+ throw new Error("ref.meta has to be of type `boolean`");
218
+ } else if (ref.details !== undefined && typeof ref.details !== "object") {
219
+ throw new Error("ref.details has to be of type `object` or `undefined`");
220
+ } else if (
221
+ ref.details &&
222
+ !["position", "linecolumn", "header", "anchor"].includes(ref.details.type)
223
+ ) {
224
+ throw new Error(
225
+ "ref.details.type has to be 'position', 'linecolumn', 'header' or 'anchor'",
226
+ );
227
+ }
228
+
229
+ if (ref.details?.type === "position" && typeof ref.details.pos !== "number") {
230
+ throw new Error("ref.details.pos has to be of type `number`");
231
+ } else if (
232
+ ref.details?.type === "header" &&
233
+ typeof ref.details.header !== "string"
234
+ ) {
235
+ throw new Error("ref.details.header has to be of type `string`");
236
+ } else if (
237
+ ref.details?.type === "linecolumn" &&
238
+ typeof ref.details.line !== "number" &&
239
+ typeof ref.details.column !== "number"
240
+ ) {
241
+ throw new Error(
242
+ "ref.details.line and ref.details.column has to be of type `number`",
243
+ );
244
+ } else if (
245
+ ref.details?.type === "anchor" &&
246
+ typeof ref.details.name !== "string"
247
+ ) {
248
+ throw new Error("ref.details.name has to be of type `string`");
249
+ }
250
+
251
+ return ref;
252
+ }
253
+
254
+ /**
255
+ * Renders a ref as wiki link *text*, preserving the `^` meta prefix.
256
+ *
257
+ * {@link encodeRef} deliberately drops it — it also builds page URLs, where a
258
+ * caret would address a page that does not exist — so anything rewriting a link
259
+ * in a document must use this instead, or `[[^Library/Std]]` silently loses its
260
+ * caret.
261
+ */
262
+ export function encodeLinkText(ref: Ref): string {
263
+ return (ref.meta ? "^" : "") + encodeRef(ref);
264
+ }
265
+
147
266
  export function encodeRef(ref: Ref): string {
148
267
  let stringRef: string = ref.path;
149
268
 
@@ -66,7 +66,6 @@ export function resolveMarkdownLink(
66
66
  * Turns an absolute path into a relative path, relative to some base directory. USE WITH CAUTION, definitely buggy
67
67
  */
68
68
  export function absoluteToRelativePath(base: string, absolute: string): string {
69
- // Remove leading /
70
69
  base = base.startsWith("/") ? base.slice(1) : base;
71
70
  absolute = absolute.startsWith("/") ? absolute.slice(1) : absolute;
72
71