@silverbulletmd/silverbullet 2.10.0 → 2.12.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.
- package/README.md +6 -6
- package/client/asset_bundle/bundle.ts +0 -1
- package/client/config.ts +343 -0
- package/client/data/kv_primitives.ts +2 -2
- package/client/markdown_parser/constants.ts +5 -1
- package/client/plugos/hooks/command.ts +8 -6
- package/client/plugos/hooks/event.ts +60 -24
- package/client/plugos/hooks/mq.ts +0 -3
- package/client/plugos/hooks/slash_command.ts +3 -20
- package/client/plugos/hooks/syscall.ts +0 -4
- package/client/plugos/manifest_cache.ts +2 -34
- package/client/plugos/plug.ts +0 -3
- package/client/plugos/plug_compile.ts +3 -13
- package/client/plugos/sandboxes/sandbox.ts +2 -2
- package/client/plugos/sandboxes/worker_sandbox.ts +19 -9
- package/client/plugos/syscalls/capture.ts +104 -0
- package/client/plugos/syscalls/editor.ts +197 -56
- package/client/plugos/syscalls/event.ts +12 -9
- package/client/plugos/syscalls/fetch.ts +0 -4
- package/client/plugos/syscalls/icon.ts +46 -0
- package/client/plugos/syscalls/index.ts +33 -13
- package/client/plugos/syscalls/jsonschema.ts +0 -1
- package/client/plugos/syscalls/mq.ts +0 -1
- package/client/plugos/syscalls/navigator.ts +138 -0
- package/client/plugos/syscalls/search.ts +44 -0
- package/client/plugos/syscalls/shell.ts +0 -1
- package/client/plugos/syscalls/space.ts +250 -7
- package/client/plugos/syscalls/sync.ts +70 -4
- package/client/plugos/syscalls/system.ts +39 -0
- package/client/plugos/system.ts +9 -7
- package/client/plugos/types.ts +6 -6
- package/client/plugos/worker_runtime.ts +0 -2
- package/client/space_lua/aggregates.ts +6 -22
- package/client/space_lua/ast.ts +0 -5
- package/client/space_lua/ast_narrow.ts +0 -5
- package/client/space_lua/budget.ts +113 -0
- package/client/space_lua/budget_ui.ts +49 -0
- package/client/space_lua/eval.ts +113 -88
- package/client/space_lua/labels.ts +0 -4
- package/client/space_lua/numeric.ts +2 -7
- package/client/space_lua/parse.ts +0 -17
- package/client/space_lua/quarantine.ts +127 -0
- package/client/space_lua/query_collection.ts +18 -51
- package/client/space_lua/query_env.ts +0 -2
- package/client/space_lua/render_lua_markdown.ts +2 -7
- package/client/space_lua/render_widget.ts +18 -1
- package/client/space_lua/runtime.ts +64 -77
- package/client/space_lua/stdlib/format.ts +0 -12
- package/client/space_lua/stdlib/js.ts +0 -2
- package/client/space_lua/stdlib/load.ts +0 -1
- package/client/space_lua/stdlib/math.ts +0 -7
- package/client/space_lua/stdlib/net.ts +0 -4
- package/client/space_lua/stdlib/os.ts +0 -10
- package/client/space_lua/stdlib/pattern.ts +18 -23
- package/client/space_lua/stdlib/space_lua.ts +1 -2
- package/client/space_lua/stdlib/string_pack.ts +4 -7
- package/client/space_lua/stdlib/table.ts +0 -5
- package/client/space_lua/stdlib.ts +18 -31
- package/client/space_lua/tonumber.ts +0 -9
- package/dist/plug-compile.js +3 -4
- package/package.json +52 -41
- package/plug-api/lib/async.ts +1 -6
- package/plug-api/lib/collation.ts +22 -0
- package/plug-api/lib/crypto.ts +16 -15
- package/plug-api/lib/dates.ts +33 -0
- package/plug-api/lib/fuzzy.ts +282 -0
- package/plug-api/lib/json.ts +0 -6
- package/plug-api/lib/limited_map.ts +0 -2
- package/plug-api/lib/link_write.ts +27 -0
- package/plug-api/lib/ref.ts +28 -5
- package/plug-api/lib/resolve.ts +1 -9
- package/plug-api/lib/resolve_path.ts +290 -0
- package/plug-api/lib/shortcut.ts +15 -0
- package/plug-api/lib/tags.ts +0 -3
- package/plug-api/lib/transclusion.ts +28 -1
- package/plug-api/lib/tree.ts +0 -5
- package/plug-api/lib/yaml.ts +5 -44
- package/plug-api/syscall.ts +1 -5
- package/plug-api/syscalls/config.ts +2 -2
- package/plug-api/syscalls/editor.ts +75 -213
- package/plug-api/syscalls/icon.ts +18 -0
- package/plug-api/syscalls/index.ts +22 -4
- package/plug-api/syscalls/search.ts +11 -0
- package/plug-api/syscalls/space.ts +122 -5
- package/plug-api/syscalls/sync.ts +31 -2
- package/plug-api/syscalls/system.ts +46 -0
- package/plug-api/syscalls.ts +2 -0
- package/plug-api/system_mock.ts +13 -0
- package/plug-api/types/client.ts +20 -4
- package/plug-api/types/datastore.ts +11 -2
- package/plug-api/types/index.ts +10 -0
- package/plug-api/types/manifest.ts +7 -0
- package/plug-api/types/profile.ts +13 -0
- package/plug-api/types/revisions.ts +67 -0
- package/plug-api/ui/description.ts +64 -0
- package/plug-api/ui/hover.ts +69 -0
- package/plug-api/ui/index.ts +58 -2
- package/plug-api/ui/scroll.ts +30 -0
- package/plug-api/ui/tree_model.ts +285 -0
- package/plug-api/ui/tree_types.ts +67 -0
- package/plug-api/ui/use_fit_collapse.ts +67 -0
- package/plugs/builtin_plugs.ts +0 -1
- package/plugs/index/types.ts +9 -0
- package/plug-api/lib/memory_cache.ts +0 -21
- /package/plug-api/{ui → lib}/panel_styles.ts +0 -0
|
@@ -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
|
+
}
|
package/plug-api/lib/ref.ts
CHANGED
|
@@ -62,13 +62,29 @@ function normalizePath(path: string): Path {
|
|
|
62
62
|
path = path.slice(1);
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
if (
|
|
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
|
-
*
|
|
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
|
-
*
|
|
241
|
-
*
|
|
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
|
|
package/plug-api/lib/resolve.ts
CHANGED
|
@@ -1,14 +1,7 @@
|
|
|
1
1
|
import type { Path } from "@silverbulletmd/silverbullet/lib/ref";
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
* Determines wether a url points into the world wide web or to the local SB instance
|
|
5
|
-
*/
|
|
6
3
|
export function isLocalURL(url: string): boolean {
|
|
7
|
-
return (
|
|
8
|
-
!url.includes("://") &&
|
|
9
|
-
!url.startsWith("mailto:") &&
|
|
10
|
-
!url.startsWith("tel:")
|
|
11
|
-
);
|
|
4
|
+
return !/^[a-z][a-z0-9+.-]*:/i.test(url);
|
|
12
5
|
}
|
|
13
6
|
|
|
14
7
|
/**
|
|
@@ -66,7 +59,6 @@ export function resolveMarkdownLink(
|
|
|
66
59
|
* Turns an absolute path into a relative path, relative to some base directory. USE WITH CAUTION, definitely buggy
|
|
67
60
|
*/
|
|
68
61
|
export function absoluteToRelativePath(base: string, absolute: string): string {
|
|
69
|
-
// Remove leading /
|
|
70
62
|
base = base.startsWith("/") ? base.slice(1) : base;
|
|
71
63
|
absolute = absolute.startsWith("/") ? absolute.slice(1) : absolute;
|
|
72
64
|
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import type { Path } from "./ref.ts";
|
|
2
|
+
import { fileName, folderName } from "./resolve.ts";
|
|
3
|
+
|
|
4
|
+
export type ResolveResult = {
|
|
5
|
+
path: Path;
|
|
6
|
+
exists: boolean;
|
|
7
|
+
ambiguous: boolean;
|
|
8
|
+
candidates?: Path[];
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type PathIndex = {
|
|
12
|
+
has(path: string): boolean;
|
|
13
|
+
candidates(basename: string): Path[];
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export class BasenameIndex implements PathIndex {
|
|
17
|
+
private paths = new Set<string>();
|
|
18
|
+
private byBasename = new Map<string, Set<string>>();
|
|
19
|
+
// Keys whose bucket holds more than one path, maintained incrementally so a
|
|
20
|
+
// snapshot of every collision costs O(collisions), not a walk of the space.
|
|
21
|
+
private collidingKeys = new Set<string>();
|
|
22
|
+
|
|
23
|
+
rebuild(paths: Iterable<string>): void {
|
|
24
|
+
this.clear();
|
|
25
|
+
for (const path of paths) {
|
|
26
|
+
this.add(path);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
clear(): void {
|
|
31
|
+
this.paths.clear();
|
|
32
|
+
this.byBasename.clear();
|
|
33
|
+
this.collidingKeys.clear();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
add(path: string): void {
|
|
37
|
+
if (this.paths.has(path)) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
this.paths.add(path);
|
|
41
|
+
const key = basenameKey(path);
|
|
42
|
+
let bucket = this.byBasename.get(key);
|
|
43
|
+
if (!bucket) {
|
|
44
|
+
bucket = new Set();
|
|
45
|
+
this.byBasename.set(key, bucket);
|
|
46
|
+
}
|
|
47
|
+
bucket.add(path);
|
|
48
|
+
if (bucket.size > 1) {
|
|
49
|
+
this.collidingKeys.add(key);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
delete(path: string): void {
|
|
54
|
+
if (!this.paths.delete(path)) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const key = basenameKey(path);
|
|
58
|
+
const bucket = this.byBasename.get(key);
|
|
59
|
+
if (!bucket) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
bucket.delete(path);
|
|
63
|
+
if (bucket.size < 2) {
|
|
64
|
+
this.collidingKeys.delete(key);
|
|
65
|
+
}
|
|
66
|
+
if (bucket.size === 0) {
|
|
67
|
+
this.byBasename.delete(key);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
has(path: string): boolean {
|
|
72
|
+
return this.paths.has(path);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
candidates(basename: string): Path[] {
|
|
76
|
+
const bucket = this.byBasename.get(basename.toLowerCase());
|
|
77
|
+
return bucket ? ([...bucket] as Path[]) : [];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Every basename carried by more than one file, with the files that carry
|
|
82
|
+
* it. Under the "a bare link exists iff its basename is unique" invariant
|
|
83
|
+
* collisions are rare, so this is a small snapshot of a large space — which
|
|
84
|
+
* is what makes it cheap to ship across the sandbox boundary.
|
|
85
|
+
*/
|
|
86
|
+
collidingBuckets(): Record<string, Path[]> {
|
|
87
|
+
const result: Record<string, Path[]> = {};
|
|
88
|
+
for (const key of this.collidingKeys) {
|
|
89
|
+
result[key] = [...this.byBasename.get(key)!] as Path[];
|
|
90
|
+
}
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function basenameKey(path: string): string {
|
|
96
|
+
return fileName(path).toLowerCase();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function folderSegments(path: Path): string[] {
|
|
100
|
+
const folder = folderName(path);
|
|
101
|
+
return folder === "" ? [] : folder.split("/");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function sharedDepth(a: string[], b: string[]): number {
|
|
105
|
+
let i = 0;
|
|
106
|
+
while (i < a.length && i < b.length && a[i] === b[i]) {
|
|
107
|
+
i++;
|
|
108
|
+
}
|
|
109
|
+
return i;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Ranks same-basename candidates relative to the linking page: deepest shared
|
|
114
|
+
* folder prefix first, then shallowest, then lexicographic. Obsidian ranks the
|
|
115
|
+
* vault root above the linking page's own folder; inverting that is deliberate
|
|
116
|
+
* (see the design spec) — root-wins would resolve a link inside `docs/api/` to
|
|
117
|
+
* a sibling project's root-level file whenever a space is opened at a wider
|
|
118
|
+
* root. Root preference survives as the shallowest-path rule, since the root is
|
|
119
|
+
* always depth 0.
|
|
120
|
+
*/
|
|
121
|
+
export function rankCandidates(candidates: Path[], fromPage: Path): Path[] {
|
|
122
|
+
const from = folderSegments(fromPage);
|
|
123
|
+
return [...candidates].sort((a, b) => {
|
|
124
|
+
const aFolder = folderSegments(a);
|
|
125
|
+
const bFolder = folderSegments(b);
|
|
126
|
+
const proximity = sharedDepth(bFolder, from) - sharedDepth(aFolder, from);
|
|
127
|
+
if (proximity !== 0) {
|
|
128
|
+
return proximity;
|
|
129
|
+
}
|
|
130
|
+
const depth = aFolder.length - bFolder.length;
|
|
131
|
+
if (depth !== 0) {
|
|
132
|
+
return depth;
|
|
133
|
+
}
|
|
134
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* What a sandboxed caller gets back for one requested path: whether that exact
|
|
140
|
+
* path exists, plus every file sharing its basename. Ranking deliberately stays
|
|
141
|
+
* out of the syscall — it runs in the caller, from the same pure function the
|
|
142
|
+
* client uses, so there is only ever one implementation of it.
|
|
143
|
+
*/
|
|
144
|
+
export type PathLookup = {
|
|
145
|
+
exact: boolean;
|
|
146
|
+
candidates: Path[];
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Adapts a batch of lookups into a {@link PathIndex}. Exact-path answers stay
|
|
151
|
+
* keyed by the requested path, while candidates are re-keyed by basename —
|
|
152
|
+
* which is how both `resolvePath` and `writeLinkPath` ask for them. Keying
|
|
153
|
+
* candidates by the requested path instead silently returns none for anything
|
|
154
|
+
* in a folder.
|
|
155
|
+
*/
|
|
156
|
+
export function lookupIndex(lookups: Record<string, PathLookup>): PathIndex {
|
|
157
|
+
const byBasename = new Map<string, Path[]>();
|
|
158
|
+
for (const [path, lookup] of Object.entries(lookups)) {
|
|
159
|
+
byBasename.set(basenameKey(path), lookup.candidates);
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
has: (path) => lookups[path]?.exact ?? false,
|
|
163
|
+
candidates: (basename) => byBasename.get(basename.toLowerCase()) ?? [],
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* A {@link PathIndex} for *write-format decisions only*, built from just the
|
|
169
|
+
* colliding buckets of {@link BasenameIndex.collidingBuckets}. Enough for
|
|
170
|
+
* {@link writeLinkPath} on paths of files that exist: a basename outside the
|
|
171
|
+
* buckets is unique by construction, so `candidates` answers with a
|
|
172
|
+
* single-entry stand-in, and `has` only ever needs to look inside a bucket
|
|
173
|
+
* (every exact path a suffix could be shadowed by shares the suffix's own
|
|
174
|
+
* basename). Not a resolution index — do not hand it to `resolvePath` callers.
|
|
175
|
+
*/
|
|
176
|
+
export function collisionIndex(colliding: Record<string, Path[]>): PathIndex {
|
|
177
|
+
return {
|
|
178
|
+
has: (path) =>
|
|
179
|
+
colliding[basenameKey(path)]?.includes(path as Path) ?? false,
|
|
180
|
+
candidates: (basename) =>
|
|
181
|
+
colliding[basename.toLowerCase()] ?? ([basename] as Path[]),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export type LinkWriteFormat = "shortest" | "shortest-suffix" | "full-path";
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Renders a target path as link text. `shortest` writes the bare name only when
|
|
189
|
+
* that name is unique in the space — the rule that maintains the "a bare link
|
|
190
|
+
* exists iff its basename is unique" invariant on the write side — and the
|
|
191
|
+
* full path otherwise. `shortest-suffix` differs only in the colliding case:
|
|
192
|
+
* instead of the full path it writes the shortest path suffix that resolves
|
|
193
|
+
* back to exactly this file, which keeps even disambiguated links independent
|
|
194
|
+
* of where the space root sits.
|
|
195
|
+
*/
|
|
196
|
+
export function writeLinkPath(
|
|
197
|
+
target: Path,
|
|
198
|
+
format: LinkWriteFormat,
|
|
199
|
+
index: PathIndex,
|
|
200
|
+
): Path {
|
|
201
|
+
if (format === "full-path") {
|
|
202
|
+
return target;
|
|
203
|
+
}
|
|
204
|
+
const bare = fileName(target);
|
|
205
|
+
if (index.candidates(bare).length === 1) {
|
|
206
|
+
return bare;
|
|
207
|
+
}
|
|
208
|
+
if (format === "shortest") {
|
|
209
|
+
return target;
|
|
210
|
+
}
|
|
211
|
+
// Every rival a suffix could collide with shares the target's basename, so
|
|
212
|
+
// the basename bucket is a complete rival list and resolvePath can verify
|
|
213
|
+
// each candidate outright: rejected when it is another file's exact path,
|
|
214
|
+
// ambiguous, or resolving elsewhere. fromPage is irrelevant — only
|
|
215
|
+
// unambiguous resolutions are accepted, and those are page-independent.
|
|
216
|
+
const segments = target.split("/");
|
|
217
|
+
for (let i = segments.length - 2; i >= 1; i--) {
|
|
218
|
+
const candidate = segments.slice(i).join("/") as Path;
|
|
219
|
+
const resolution = resolvePath(candidate, "" as Path, index);
|
|
220
|
+
if (
|
|
221
|
+
resolution.exists &&
|
|
222
|
+
!resolution.ambiguous &&
|
|
223
|
+
resolution.path === target
|
|
224
|
+
) {
|
|
225
|
+
return candidate;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return target;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Resolves a parsed ref path against the space. Exact paths always win, so
|
|
233
|
+
* every link that resolves today keeps resolving to the same file. A bare name
|
|
234
|
+
* falls back to a space-wide basename lookup; more than one survivor means the
|
|
235
|
+
* "bare link iff unique basename" invariant is violated, which resolves
|
|
236
|
+
* deterministically but is reported as ambiguous so callers can flag it.
|
|
237
|
+
*
|
|
238
|
+
* A qualified path with no exact match falls back to a path-suffix lookup:
|
|
239
|
+
* `api/Auth` finds `docs/api/Auth.md` when that is the only file whose path
|
|
240
|
+
* ends that way. This is what lets a space opened at a wider root — the parent
|
|
241
|
+
* of the `docs/` folder it was authored in — keep its qualified links working,
|
|
242
|
+
* not just its bare ones, and it matches Obsidian accepting partial paths as
|
|
243
|
+
* linkpaths.
|
|
244
|
+
*/
|
|
245
|
+
export function resolvePath(
|
|
246
|
+
path: Path,
|
|
247
|
+
fromPage: Path,
|
|
248
|
+
index: PathIndex,
|
|
249
|
+
): ResolveResult {
|
|
250
|
+
if (path === "") {
|
|
251
|
+
return { path, exists: true, ambiguous: false };
|
|
252
|
+
}
|
|
253
|
+
if (index.has(path)) {
|
|
254
|
+
// An exact path match is fully determined by the link text, so it is never
|
|
255
|
+
// reported ambiguous — even when other files share the basename. For a
|
|
256
|
+
// root-level file the bare and qualified forms are the same string, so
|
|
257
|
+
// there is nothing the author could write instead and the warning could
|
|
258
|
+
// never be cleared.
|
|
259
|
+
return { path, exists: true, ambiguous: false };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// A bare name is just the degenerate suffix, so one matcher serves both
|
|
263
|
+
// lookups: a candidate matches when, `/`-prefixed, its path ends with `/`
|
|
264
|
+
// plus the ref.
|
|
265
|
+
const suffix = `/${path.toLowerCase()}`;
|
|
266
|
+
const candidates = index
|
|
267
|
+
.candidates(fileName(path))
|
|
268
|
+
.filter((candidate) => `/${candidate.toLowerCase()}`.endsWith(suffix));
|
|
269
|
+
if (candidates.length === 0) {
|
|
270
|
+
return { path, exists: false, ambiguous: false };
|
|
271
|
+
}
|
|
272
|
+
// The overwhelmingly common case under the bare-iff-unique invariant, on
|
|
273
|
+
// the per-rendered-link path: skip the case filter and the ranking sort.
|
|
274
|
+
if (candidates.length === 1) {
|
|
275
|
+
return { path: candidates[0], exists: true, ambiguous: false };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const caseSuffix = `/${path}`;
|
|
279
|
+
const exactCase = candidates.filter((candidate) =>
|
|
280
|
+
`/${candidate}`.endsWith(caseSuffix),
|
|
281
|
+
);
|
|
282
|
+
if (exactCase.length === 1) {
|
|
283
|
+
return { path: exactCase[0], exists: true, ambiguous: false };
|
|
284
|
+
}
|
|
285
|
+
const ranked = rankCandidates(
|
|
286
|
+
exactCase.length > 0 ? exactCase : candidates,
|
|
287
|
+
fromPage,
|
|
288
|
+
);
|
|
289
|
+
return { path: ranked[0], exists: true, ambiguous: true, candidates: ranked };
|
|
290
|
+
}
|
package/plug-api/lib/shortcut.ts
CHANGED
|
@@ -2,6 +2,21 @@ export const isMacLike =
|
|
|
2
2
|
typeof navigator !== "undefined" &&
|
|
3
3
|
/(Mac|iPhone|iPod|iPad)/i.test(navigator.platform);
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* The key hint a command shows: its platform-appropriate binding(s),
|
|
7
|
+
* prettified. Several bindings for one command are joined with `|`.
|
|
8
|
+
*/
|
|
9
|
+
export function keyboardHint(def: {
|
|
10
|
+
key?: string | string[];
|
|
11
|
+
mac?: string | string[];
|
|
12
|
+
}): string | undefined {
|
|
13
|
+
const binding = isMacLike && def.mac ? def.mac : def.key;
|
|
14
|
+
if (!binding) return undefined;
|
|
15
|
+
const shortcuts = Array.isArray(binding) ? binding : [binding];
|
|
16
|
+
if (shortcuts.length === 0) return undefined;
|
|
17
|
+
return shortcuts.map(prettifyShortcut).join(" | ");
|
|
18
|
+
}
|
|
19
|
+
|
|
5
20
|
export function prettifyShortcut(shortcut: string): string {
|
|
6
21
|
if (!isMacLike) return shortcut;
|
|
7
22
|
const pretty = shortcut
|
package/plug-api/lib/tags.ts
CHANGED
|
@@ -6,7 +6,6 @@ export function isMetaTag(tag: string): boolean {
|
|
|
6
6
|
/** Extract the name from hashtag text, removing # prefix and <angle brackets> if necessary */
|
|
7
7
|
export function extractHashtag(text: string): string {
|
|
8
8
|
if (text[0] !== "#") {
|
|
9
|
-
// you shouldn't call this function at all
|
|
10
9
|
console.error("extractHashtag called on already clean string", text);
|
|
11
10
|
return text;
|
|
12
11
|
} else if (text[1] === "<") {
|
|
@@ -14,11 +13,9 @@ export function extractHashtag(text: string): string {
|
|
|
14
13
|
// this is malformed: #<name but maybe we're trying to autocomplete
|
|
15
14
|
return text.slice(2);
|
|
16
15
|
} else {
|
|
17
|
-
// this is correct #<name>
|
|
18
16
|
return text.slice(2, -1);
|
|
19
17
|
}
|
|
20
18
|
} else {
|
|
21
|
-
// this is just #name
|
|
22
19
|
return text.slice(1);
|
|
23
20
|
}
|
|
24
21
|
}
|
|
@@ -3,9 +3,15 @@ import {
|
|
|
3
3
|
wikiLinkRegex,
|
|
4
4
|
} from "../../client/markdown_parser/constants.ts";
|
|
5
5
|
import {
|
|
6
|
+
encodeRef,
|
|
6
7
|
getNameFromPath,
|
|
7
8
|
parseToRef,
|
|
9
|
+
type Path,
|
|
8
10
|
} from "@silverbulletmd/silverbullet/lib/ref";
|
|
11
|
+
import {
|
|
12
|
+
type PathIndex,
|
|
13
|
+
resolvePath,
|
|
14
|
+
} from "@silverbulletmd/silverbullet/lib/resolve_path";
|
|
9
15
|
|
|
10
16
|
export type LinkType = "wikilink" | "markdownlink";
|
|
11
17
|
/**
|
|
@@ -83,7 +89,6 @@ export function parseTransclusion(text: string): Transclusion | null {
|
|
|
83
89
|
({ stringRef: url, alias } = match.groups);
|
|
84
90
|
linktype = "wikilink";
|
|
85
91
|
} else {
|
|
86
|
-
// We found no match
|
|
87
92
|
return null;
|
|
88
93
|
}
|
|
89
94
|
|
|
@@ -102,6 +107,28 @@ export function parseTransclusion(text: string): Transclusion | null {
|
|
|
102
107
|
};
|
|
103
108
|
}
|
|
104
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Rewrites a wiki-link transclusion's target to the path it resolves to, the
|
|
112
|
+
* same way plain wiki links resolve.
|
|
113
|
+
*/
|
|
114
|
+
export function resolveTransclusionUrl(
|
|
115
|
+
t: Transclusion,
|
|
116
|
+
fromPage: Path,
|
|
117
|
+
index: PathIndex,
|
|
118
|
+
): void {
|
|
119
|
+
if (t.linktype !== "wikilink") {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const ref = parseToRef(t.url);
|
|
123
|
+
if (!ref || ref.path === "") {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const resolution = resolvePath(ref.path, fromPage, index);
|
|
127
|
+
if (resolution.exists && resolution.path !== ref.path) {
|
|
128
|
+
t.url = encodeRef({ ...ref, path: resolution.path });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
105
132
|
export function nameFromTransclusion(t: Transclusion): string {
|
|
106
133
|
const ref = parseToRef(t.url);
|
|
107
134
|
if (!ref) {
|
package/plug-api/lib/tree.ts
CHANGED
|
@@ -114,7 +114,6 @@ export function replaceNodesMatching(
|
|
|
114
114
|
tree.children[i] = subst;
|
|
115
115
|
i++;
|
|
116
116
|
} else {
|
|
117
|
-
// null = delete
|
|
118
117
|
tree.children.splice(i, 1);
|
|
119
118
|
// don't increment i — next child shifted into this position
|
|
120
119
|
}
|
|
@@ -140,7 +139,6 @@ export async function replaceNodesMatchingAsync(
|
|
|
140
139
|
tree.children[i] = subst;
|
|
141
140
|
i++;
|
|
142
141
|
} else {
|
|
143
|
-
// null = delete
|
|
144
142
|
tree.children.splice(i, 1);
|
|
145
143
|
}
|
|
146
144
|
} else {
|
|
@@ -281,11 +279,9 @@ export function nodeAtPos(tree: ParseTree, pos: number): ParseTree | null {
|
|
|
281
279
|
for (const child of tree.children) {
|
|
282
280
|
const n = nodeAtPos(child, pos);
|
|
283
281
|
if (n && n.text !== undefined) {
|
|
284
|
-
// Got a text node, let's return its parent
|
|
285
282
|
return tree;
|
|
286
283
|
}
|
|
287
284
|
if (n) {
|
|
288
|
-
// Got it
|
|
289
285
|
return n;
|
|
290
286
|
}
|
|
291
287
|
}
|
|
@@ -321,7 +317,6 @@ export function normalizeTableRow(
|
|
|
321
317
|
// Fix leading-pipe mismatch: row has leading delimiter but header doesn't
|
|
322
318
|
if (headerHasLeadingDelim === false) {
|
|
323
319
|
if (row.children.length > 0 && row.children[0].type === "TableDelimiter") {
|
|
324
|
-
// Insert empty cell after the leading delimiter
|
|
325
320
|
row.children.splice(1, 0, { type: "TableCell", children: [] });
|
|
326
321
|
}
|
|
327
322
|
}
|