@silverbulletmd/silverbullet 2.10.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 (100) hide show
  1. package/README.md +4 -4
  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 +0 -4
  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/editor.ts +128 -53
  15. package/client/plugos/syscalls/event.ts +0 -2
  16. package/client/plugos/syscalls/fetch.ts +0 -4
  17. package/client/plugos/syscalls/icon.ts +46 -0
  18. package/client/plugos/syscalls/index.ts +33 -13
  19. package/client/plugos/syscalls/jsonschema.ts +0 -1
  20. package/client/plugos/syscalls/mq.ts +0 -1
  21. package/client/plugos/syscalls/navigator.ts +143 -0
  22. package/client/plugos/syscalls/search.ts +44 -0
  23. package/client/plugos/syscalls/shell.ts +0 -1
  24. package/client/plugos/syscalls/space.ts +250 -7
  25. package/client/plugos/syscalls/sync.ts +56 -4
  26. package/client/plugos/syscalls/system.ts +38 -0
  27. package/client/plugos/system.ts +7 -5
  28. package/client/plugos/worker_runtime.ts +0 -2
  29. package/client/space_lua/aggregates.ts +0 -16
  30. package/client/space_lua/ast.ts +0 -5
  31. package/client/space_lua/ast_narrow.ts +0 -5
  32. package/client/space_lua/budget.ts +113 -0
  33. package/client/space_lua/budget_ui.ts +49 -0
  34. package/client/space_lua/eval.ts +113 -88
  35. package/client/space_lua/labels.ts +0 -4
  36. package/client/space_lua/numeric.ts +0 -5
  37. package/client/space_lua/parse.ts +0 -17
  38. package/client/space_lua/quarantine.ts +127 -0
  39. package/client/space_lua/query_collection.ts +3 -47
  40. package/client/space_lua/query_env.ts +0 -2
  41. package/client/space_lua/render_lua_markdown.ts +2 -7
  42. package/client/space_lua/render_widget.ts +18 -1
  43. package/client/space_lua/runtime.ts +50 -70
  44. package/client/space_lua/stdlib/format.ts +0 -12
  45. package/client/space_lua/stdlib/js.ts +0 -2
  46. package/client/space_lua/stdlib/load.ts +0 -1
  47. package/client/space_lua/stdlib/math.ts +0 -7
  48. package/client/space_lua/stdlib/net.ts +0 -4
  49. package/client/space_lua/stdlib/os.ts +0 -10
  50. package/client/space_lua/stdlib/pattern.ts +10 -15
  51. package/client/space_lua/stdlib/space_lua.ts +1 -2
  52. package/client/space_lua/stdlib/string_pack.ts +0 -3
  53. package/client/space_lua/stdlib/table.ts +0 -5
  54. package/client/space_lua/stdlib.ts +18 -31
  55. package/client/space_lua/tonumber.ts +0 -9
  56. package/dist/plug-compile.js +2 -3
  57. package/package.json +13 -3
  58. package/plug-api/lib/async.ts +1 -6
  59. package/plug-api/lib/collation.ts +22 -0
  60. package/plug-api/lib/crypto.ts +16 -15
  61. package/plug-api/lib/dates.ts +33 -0
  62. package/plug-api/lib/fuzzy.ts +282 -0
  63. package/plug-api/lib/json.ts +0 -6
  64. package/plug-api/lib/limited_map.ts +0 -2
  65. package/plug-api/lib/link_write.ts +27 -0
  66. package/plug-api/lib/ref.ts +28 -5
  67. package/plug-api/lib/resolve.ts +0 -1
  68. package/plug-api/lib/resolve_path.ts +290 -0
  69. package/plug-api/lib/shortcut.ts +15 -0
  70. package/plug-api/lib/tags.ts +0 -3
  71. package/plug-api/lib/transclusion.ts +28 -1
  72. package/plug-api/lib/tree.ts +0 -5
  73. package/plug-api/lib/yaml.ts +3 -42
  74. package/plug-api/syscall.ts +1 -5
  75. package/plug-api/syscalls/config.ts +2 -2
  76. package/plug-api/syscalls/editor.ts +59 -213
  77. package/plug-api/syscalls/icon.ts +18 -0
  78. package/plug-api/syscalls/index.ts +22 -4
  79. package/plug-api/syscalls/search.ts +11 -0
  80. package/plug-api/syscalls/space.ts +122 -5
  81. package/plug-api/syscalls/sync.ts +23 -2
  82. package/plug-api/syscalls/system.ts +46 -0
  83. package/plug-api/syscalls.ts +2 -0
  84. package/plug-api/system_mock.ts +13 -0
  85. package/plug-api/types/client.ts +4 -3
  86. package/plug-api/types/datastore.ts +11 -2
  87. package/plug-api/types/index.ts +10 -0
  88. package/plug-api/types/profile.ts +13 -0
  89. package/plug-api/types/revisions.ts +67 -0
  90. package/plug-api/ui/description.ts +64 -0
  91. package/plug-api/ui/hover.ts +69 -0
  92. package/plug-api/ui/index.ts +58 -2
  93. package/plug-api/ui/scroll.ts +30 -0
  94. package/plug-api/ui/tree_model.ts +263 -0
  95. package/plug-api/ui/tree_types.ts +66 -0
  96. package/plug-api/ui/use_fit_collapse.ts +67 -0
  97. package/plugs/builtin_plugs.ts +0 -1
  98. package/plugs/index/types.ts +9 -0
  99. package/plug-api/lib/memory_cache.ts +0 -21
  100. /package/plug-api/{ui → lib}/panel_styles.ts +0 -0
@@ -2,6 +2,39 @@ export function niceDate(d: Date): string {
2
2
  return localDateString(d).split("T")[0];
3
3
  }
4
4
 
5
+ /** Largest-unit-first, so the cascade below divides down to the right one. */
6
+ const RELATIVE_DIVISIONS: {
7
+ amount: number;
8
+ unit: Intl.RelativeTimeFormatUnit;
9
+ }[] = [
10
+ { amount: 60, unit: "second" },
11
+ { amount: 60, unit: "minute" },
12
+ { amount: 24, unit: "hour" },
13
+ { amount: 7, unit: "day" },
14
+ { amount: 4.34524, unit: "week" },
15
+ { amount: 12, unit: "month" },
16
+ ];
17
+
18
+ /**
19
+ * "10 minutes ago", "yesterday", "3 months ago". `numeric: "auto"` is what
20
+ * turns the -1 cases into words rather than "1 day ago".
21
+ */
22
+ export function relativeTime(
23
+ timestamp: number,
24
+ locale?: string,
25
+ now: number = Date.now(),
26
+ ): string {
27
+ const formatter = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
28
+ let duration = (timestamp - now) / 1000;
29
+ for (const { amount, unit } of RELATIVE_DIVISIONS) {
30
+ if (Math.abs(duration) < amount) {
31
+ return formatter.format(Math.round(duration), unit);
32
+ }
33
+ duration /= amount;
34
+ }
35
+ return formatter.format(Math.round(duration), "year");
36
+ }
37
+
5
38
  export function localDateString(d: Date): string {
6
39
  return (
7
40
  d.getFullYear() +
@@ -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
+ }
@@ -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_/:-]*))?$/;
@@ -237,9 +252,17 @@ export function coerceAndValidateRef(ref: Ref | string): Ref {
237
252
  }
238
253
 
239
254
  /**
240
- * The inverse of {@link parseToRef}, encodes a ref object into a reference string.
241
- * It tries to produce the shortest valid representation
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.
242
261
  */
262
+ export function encodeLinkText(ref: Ref): string {
263
+ return (ref.meta ? "^" : "") + encodeRef(ref);
264
+ }
265
+
243
266
  export function encodeRef(ref: Ref): string {
244
267
  let stringRef: string = ref.path;
245
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