@wizzlethorpe/vaults 0.1.0 → 0.4.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 +3 -3
- package/dist/auth.js +1 -1
- package/dist/auth.js.map +1 -1
- package/dist/build.js +108 -28
- package/dist/build.js.map +1 -1
- package/dist/commands/init.js +4 -4
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/preview.js +2 -2
- package/dist/commands/preview.js.map +1 -1
- package/dist/commands/push.js +7 -7
- package/dist/commands/push.js.map +1 -1
- package/dist/commands/role.js +3 -3
- package/dist/commands/role.js.map +1 -1
- package/dist/favicon.js +1 -1
- package/dist/obsidian.js +1 -1
- package/dist/render/auth-template.js +20 -20
- package/dist/render/bases.js +807 -0
- package/dist/render/bases.js.map +1 -0
- package/dist/render/callouts.js +2 -2
- package/dist/render/callouts.js.map +1 -1
- package/dist/render/embed.js +14 -4
- package/dist/render/embed.js.map +1 -1
- package/dist/render/layout.js +86 -8
- package/dist/render/layout.js.map +1 -1
- package/dist/render/pipeline.js +19 -1
- package/dist/render/pipeline.js.map +1 -1
- package/dist/render/preview.js +1 -1
- package/dist/render/preview.js.map +1 -1
- package/dist/render/styles.js +198 -34
- package/dist/render/styles.js.map +1 -1
- package/dist/render/wikilink.js +1 -1
- package/dist/render/wikilink.js.map +1 -1
- package/dist/settings.js +1 -6
- package/dist/settings.js.map +1 -1
- package/package.json +9 -4
|
@@ -0,0 +1,807 @@
|
|
|
1
|
+
// Obsidian Bases support.
|
|
2
|
+
//
|
|
3
|
+
// Two entry points:
|
|
4
|
+
// 1. The remark plugin parses ```base / ```bases code fences inline.
|
|
5
|
+
// 2. renderBase() is exported so embed.ts can render ![[Foo]] when
|
|
6
|
+
// Foo.base exists in the vault.
|
|
7
|
+
//
|
|
8
|
+
// Both paths share the same parser + evaluator + view renderers.
|
|
9
|
+
//
|
|
10
|
+
// Supported (v3):
|
|
11
|
+
// - filters: string expression OR { and|or|not: [expr|tree] } tree
|
|
12
|
+
// - views: table | cards | list. table is sortable; cards is a
|
|
13
|
+
// grid of clickable cards with cover images; list is a
|
|
14
|
+
// compact bulleted list.
|
|
15
|
+
// - sort: [{ column, direction }] honored on every view type;
|
|
16
|
+
// multi-key (later entries break ties from earlier ones).
|
|
17
|
+
// - formulas: top-level `formulas: { name: expr }` block. Reference
|
|
18
|
+
// as `formula.name` from order, filters, and other
|
|
19
|
+
// formulas. Memoized per row; cycles raise an error.
|
|
20
|
+
// - properties: { <id>: { displayName? } }; works for note.X,
|
|
21
|
+
// file.X, and formula.X column ids.
|
|
22
|
+
// - identifiers: file.{name,basename,path,folder,ext,mtime,ctime,tags}
|
|
23
|
+
// note.X / bare X (frontmatter), formula.X
|
|
24
|
+
// - operators: == != < <= > >= && || ! + (string concat / numeric add)
|
|
25
|
+
// - methods: file.hasTag("..."), file.inFolder("..."),
|
|
26
|
+
// stringValue.contains("..."), .startsWith, .endsWith,
|
|
27
|
+
// .lower, .upper
|
|
28
|
+
// - literals: strings (double or single quoted), numbers, true/false, null
|
|
29
|
+
// - cards-only: image: <prop>, imageFit: cover|contain, imageAspectRatio
|
|
30
|
+
//
|
|
31
|
+
// Deferred:
|
|
32
|
+
// - summaries, groupBy
|
|
33
|
+
// - map view type
|
|
34
|
+
// - duration arithmetic (now() - "1 week" etc.)
|
|
35
|
+
//
|
|
36
|
+
// Unknown view types and unknown YAML keys are warned-on, not fatal —
|
|
37
|
+
// real .base files in the wild include undocumented fields.
|
|
38
|
+
import { visit } from "unist-util-visit";
|
|
39
|
+
import yaml from "js-yaml";
|
|
40
|
+
const BASE_LANG_RE = /^bases?$/i;
|
|
41
|
+
// ── Public plugin entry ────────────────────────────────────────────────────
|
|
42
|
+
export function basesPlugin(opts) {
|
|
43
|
+
return () => (tree) => {
|
|
44
|
+
visit(tree, "code", (node, index, parent) => {
|
|
45
|
+
if (!node.lang || !BASE_LANG_RE.test(node.lang))
|
|
46
|
+
return;
|
|
47
|
+
if (!parent || index == null)
|
|
48
|
+
return;
|
|
49
|
+
const html = renderBase(node.value || "", opts.context, opts.warnings);
|
|
50
|
+
const replacement = { type: "html", value: html };
|
|
51
|
+
parent.children.splice(index, 1, replacement);
|
|
52
|
+
});
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Public entry point. Renders a base's YAML source as HTML. If `viewName`
|
|
57
|
+
* is given, only the matching view is rendered (used for the
|
|
58
|
+
* `![[MyBase#ViewName]]` embed form).
|
|
59
|
+
*/
|
|
60
|
+
export function renderBase(source, context, warnings, viewName) {
|
|
61
|
+
let doc;
|
|
62
|
+
try {
|
|
63
|
+
doc = yaml.load(source) ?? {};
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
return errorBlock(`Failed to parse base YAML: ${err.message}`);
|
|
67
|
+
}
|
|
68
|
+
const allRows = collectRows(context);
|
|
69
|
+
try {
|
|
70
|
+
setupFormulas(allRows, doc);
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
return errorBlock(`Formula error: ${err.message}`);
|
|
74
|
+
}
|
|
75
|
+
let baseRows;
|
|
76
|
+
try {
|
|
77
|
+
baseRows = doc.filters ? allRows.filter((row) => evalFilter(doc.filters, row)) : allRows;
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
return errorBlock(`Filter error: ${err.message}`);
|
|
81
|
+
}
|
|
82
|
+
let views = doc.views && doc.views.length > 0 ? doc.views : [{ type: "table" }];
|
|
83
|
+
if (viewName) {
|
|
84
|
+
const matched = views.filter((v) => v.name === viewName);
|
|
85
|
+
if (matched.length === 0) {
|
|
86
|
+
return errorBlock(`Bases: no view named '${esc(viewName)}'.`);
|
|
87
|
+
}
|
|
88
|
+
views = matched;
|
|
89
|
+
}
|
|
90
|
+
const blocks = [];
|
|
91
|
+
for (const view of views) {
|
|
92
|
+
try {
|
|
93
|
+
if (view.type === "table") {
|
|
94
|
+
blocks.push(renderTableView(view, baseRows, doc, context));
|
|
95
|
+
}
|
|
96
|
+
else if (view.type === "cards") {
|
|
97
|
+
blocks.push(renderCardsView(view, baseRows, doc, context));
|
|
98
|
+
}
|
|
99
|
+
else if (view.type === "list") {
|
|
100
|
+
blocks.push(renderListView(view, baseRows, doc));
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
if (warnings)
|
|
104
|
+
warnings.push({ kind: "broken-link", target: `bases view type '${view.type}'` });
|
|
105
|
+
blocks.push(errorBlock(`Bases: view type '${esc(view.type)}' is not supported.`));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
// Errors here come from formula evaluation, expression parsing, or
|
|
110
|
+
// bad sort keys; surface them inline so the rest of the page still
|
|
111
|
+
// renders rather than aborting the build.
|
|
112
|
+
blocks.push(errorBlock(`Bases: ${err.message}`));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return blocks.join("\n");
|
|
116
|
+
}
|
|
117
|
+
const FORMULA_VISITING = Symbol("formula-visiting");
|
|
118
|
+
/**
|
|
119
|
+
* Parse the doc's formulas once and attach them to each row alongside an
|
|
120
|
+
* empty memo cache. Lazily evaluated by `resolveFormula` on first access.
|
|
121
|
+
*/
|
|
122
|
+
function setupFormulas(rows, doc) {
|
|
123
|
+
if (!doc.formulas)
|
|
124
|
+
return;
|
|
125
|
+
const exprs = {};
|
|
126
|
+
for (const [k, v] of Object.entries(doc.formulas)) {
|
|
127
|
+
if (typeof v !== "string")
|
|
128
|
+
continue;
|
|
129
|
+
try {
|
|
130
|
+
exprs[k] = parseExpr(v);
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
throw new Error(`'${k}': ${err.message}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
for (const row of rows) {
|
|
137
|
+
row.formulaExprs = exprs;
|
|
138
|
+
row.formulaCache = new Map();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function resolveFormula(key, row) {
|
|
142
|
+
if (!row.formulaExprs || !row.formulaCache)
|
|
143
|
+
return undefined;
|
|
144
|
+
const expr = row.formulaExprs[key];
|
|
145
|
+
if (!expr)
|
|
146
|
+
return undefined;
|
|
147
|
+
if (row.formulaCache.has(key)) {
|
|
148
|
+
const v = row.formulaCache.get(key);
|
|
149
|
+
if (v === FORMULA_VISITING)
|
|
150
|
+
throw new Error(`Formula cycle: ${key}`);
|
|
151
|
+
return v;
|
|
152
|
+
}
|
|
153
|
+
row.formulaCache.set(key, FORMULA_VISITING);
|
|
154
|
+
const value = evalExpr(expr, row);
|
|
155
|
+
row.formulaCache.set(key, value);
|
|
156
|
+
return value;
|
|
157
|
+
}
|
|
158
|
+
function collectRows(context) {
|
|
159
|
+
// pages map has multiple keys (basename slug, path slug, aliases) per page.
|
|
160
|
+
// Dedupe by `path` so each page appears once.
|
|
161
|
+
const seen = new Set();
|
|
162
|
+
const rows = [];
|
|
163
|
+
for (const page of context.pages.values()) {
|
|
164
|
+
if (seen.has(page.path))
|
|
165
|
+
continue;
|
|
166
|
+
seen.add(page.path);
|
|
167
|
+
rows.push({ page, fm: page.frontmatter ?? {} });
|
|
168
|
+
}
|
|
169
|
+
// Sort by path so output is stable across runs.
|
|
170
|
+
rows.sort((a, b) => a.page.path.localeCompare(b.page.path));
|
|
171
|
+
return rows;
|
|
172
|
+
}
|
|
173
|
+
// ── Filter tree evaluator ──────────────────────────────────────────────────
|
|
174
|
+
function evalFilter(tree, row) {
|
|
175
|
+
if (typeof tree === "string")
|
|
176
|
+
return toBool(evalExpr(parseExpr(tree), row));
|
|
177
|
+
if ("and" in tree)
|
|
178
|
+
return tree.and.every((t) => evalFilter(t, row));
|
|
179
|
+
if ("or" in tree)
|
|
180
|
+
return tree.or.some((t) => evalFilter(t, row));
|
|
181
|
+
if ("not" in tree)
|
|
182
|
+
return !tree.not.every((t) => evalFilter(t, row));
|
|
183
|
+
throw new Error("Unknown filter shape: " + JSON.stringify(tree));
|
|
184
|
+
}
|
|
185
|
+
function toBool(v) {
|
|
186
|
+
if (v == null)
|
|
187
|
+
return false;
|
|
188
|
+
if (typeof v === "boolean")
|
|
189
|
+
return v;
|
|
190
|
+
if (typeof v === "number")
|
|
191
|
+
return v !== 0 && !Number.isNaN(v);
|
|
192
|
+
if (typeof v === "string")
|
|
193
|
+
return v.length > 0;
|
|
194
|
+
if (Array.isArray(v))
|
|
195
|
+
return v.length > 0;
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
const OP_TWO = new Set(["==", "!=", "<=", ">=", "&&", "||"]);
|
|
199
|
+
const OP_ONE = new Set(["<", ">", "!", "+", "-", "*", "/", "%"]);
|
|
200
|
+
function tokenize(src) {
|
|
201
|
+
const toks = [];
|
|
202
|
+
let i = 0;
|
|
203
|
+
while (i < src.length) {
|
|
204
|
+
const c = src[i];
|
|
205
|
+
if (/\s/.test(c)) {
|
|
206
|
+
i++;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (c === '"' || c === "'") {
|
|
210
|
+
const quote = c;
|
|
211
|
+
let j = i + 1;
|
|
212
|
+
let v = "";
|
|
213
|
+
while (j < src.length && src[j] !== quote) {
|
|
214
|
+
if (src[j] === "\\" && j + 1 < src.length) {
|
|
215
|
+
v += src[j + 1];
|
|
216
|
+
j += 2;
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
v += src[j];
|
|
220
|
+
j++;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (j >= src.length)
|
|
224
|
+
throw new Error("Unterminated string literal");
|
|
225
|
+
toks.push({ t: "str", v });
|
|
226
|
+
i = j + 1;
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (/[0-9]/.test(c) || (c === "-" && /[0-9]/.test(src[i + 1] ?? ""))) {
|
|
230
|
+
let j = i + (c === "-" ? 1 : 0);
|
|
231
|
+
while (j < src.length && /[0-9.]/.test(src[j]))
|
|
232
|
+
j++;
|
|
233
|
+
toks.push({ t: "num", v: Number(src.slice(i, j)) });
|
|
234
|
+
i = j;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (/[A-Za-z_]/.test(c)) {
|
|
238
|
+
let j = i + 1;
|
|
239
|
+
while (j < src.length && /[A-Za-z0-9_]/.test(src[j]))
|
|
240
|
+
j++;
|
|
241
|
+
toks.push({ t: "id", v: src.slice(i, j) });
|
|
242
|
+
i = j;
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (c === "(") {
|
|
246
|
+
toks.push({ t: "lp" });
|
|
247
|
+
i++;
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (c === ")") {
|
|
251
|
+
toks.push({ t: "rp" });
|
|
252
|
+
i++;
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (c === ",") {
|
|
256
|
+
toks.push({ t: "comma" });
|
|
257
|
+
i++;
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (c === ".") {
|
|
261
|
+
toks.push({ t: "dot" });
|
|
262
|
+
i++;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
const two = src.slice(i, i + 2);
|
|
266
|
+
if (OP_TWO.has(two)) {
|
|
267
|
+
toks.push({ t: "op", v: two });
|
|
268
|
+
i += 2;
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (OP_ONE.has(c)) {
|
|
272
|
+
toks.push({ t: "op", v: c });
|
|
273
|
+
i++;
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
throw new Error(`Unexpected character '${c}' at position ${i}`);
|
|
277
|
+
}
|
|
278
|
+
toks.push({ t: "end" });
|
|
279
|
+
return toks;
|
|
280
|
+
}
|
|
281
|
+
const BINARY_PREC = {
|
|
282
|
+
"||": 1, "&&": 2,
|
|
283
|
+
"==": 3, "!=": 3, "<": 3, "<=": 3, ">": 3, ">=": 3,
|
|
284
|
+
"+": 4, "-": 4,
|
|
285
|
+
"*": 5, "/": 5, "%": 5,
|
|
286
|
+
};
|
|
287
|
+
function parseExpr(src) {
|
|
288
|
+
const toks = tokenize(src);
|
|
289
|
+
let pos = 0;
|
|
290
|
+
const peek = () => toks[pos];
|
|
291
|
+
const eat = (kind) => {
|
|
292
|
+
const t = toks[pos];
|
|
293
|
+
if (t.t !== kind)
|
|
294
|
+
throw new Error(`Expected ${kind}, got ${t.t} at token ${pos}`);
|
|
295
|
+
pos++;
|
|
296
|
+
return t;
|
|
297
|
+
};
|
|
298
|
+
const parsePrimary = () => {
|
|
299
|
+
const t = peek();
|
|
300
|
+
if (t.t === "num") {
|
|
301
|
+
pos++;
|
|
302
|
+
return { type: "lit", v: t.v };
|
|
303
|
+
}
|
|
304
|
+
if (t.t === "str") {
|
|
305
|
+
pos++;
|
|
306
|
+
return { type: "lit", v: t.v };
|
|
307
|
+
}
|
|
308
|
+
if (t.t === "id") {
|
|
309
|
+
pos++;
|
|
310
|
+
if (t.v === "true")
|
|
311
|
+
return { type: "lit", v: true };
|
|
312
|
+
if (t.v === "false")
|
|
313
|
+
return { type: "lit", v: false };
|
|
314
|
+
if (t.v === "null")
|
|
315
|
+
return { type: "lit", v: null };
|
|
316
|
+
return { type: "id", name: t.v };
|
|
317
|
+
}
|
|
318
|
+
if (t.t === "lp") {
|
|
319
|
+
pos++;
|
|
320
|
+
const e = parseBinary(0);
|
|
321
|
+
eat("rp");
|
|
322
|
+
return e;
|
|
323
|
+
}
|
|
324
|
+
if (t.t === "op" && (t.v === "!" || t.v === "-" || t.v === "+")) {
|
|
325
|
+
pos++;
|
|
326
|
+
return { type: "unary", op: t.v, arg: parsePrimary() };
|
|
327
|
+
}
|
|
328
|
+
throw new Error(`Unexpected token ${JSON.stringify(t)}`);
|
|
329
|
+
};
|
|
330
|
+
const parseSuffix = (e) => {
|
|
331
|
+
while (true) {
|
|
332
|
+
const t = peek();
|
|
333
|
+
if (t.t === "dot") {
|
|
334
|
+
pos++;
|
|
335
|
+
const id = eat("id");
|
|
336
|
+
e = { type: "member", obj: e, name: id.v };
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
if (t.t === "lp") {
|
|
340
|
+
pos++;
|
|
341
|
+
const args = [];
|
|
342
|
+
if (peek().t !== "rp") {
|
|
343
|
+
args.push(parseBinary(0));
|
|
344
|
+
while (peek().t === "comma") {
|
|
345
|
+
pos++;
|
|
346
|
+
args.push(parseBinary(0));
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
eat("rp");
|
|
350
|
+
e = { type: "call", callee: e, args };
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
return e;
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
const parseBinary = (minPrec) => {
|
|
357
|
+
let left = parseSuffix(parsePrimary());
|
|
358
|
+
while (true) {
|
|
359
|
+
const t = peek();
|
|
360
|
+
if (t.t !== "op" || !(t.v in BINARY_PREC))
|
|
361
|
+
break;
|
|
362
|
+
const prec = BINARY_PREC[t.v];
|
|
363
|
+
if (prec < minPrec)
|
|
364
|
+
break;
|
|
365
|
+
pos++;
|
|
366
|
+
const right = parseBinary(prec + 1);
|
|
367
|
+
left = { type: "binary", op: t.v, left, right };
|
|
368
|
+
}
|
|
369
|
+
return left;
|
|
370
|
+
};
|
|
371
|
+
const expr = parseBinary(0);
|
|
372
|
+
if (peek().t !== "end")
|
|
373
|
+
throw new Error("Unexpected trailing tokens");
|
|
374
|
+
return expr;
|
|
375
|
+
}
|
|
376
|
+
// ── Expression evaluator ───────────────────────────────────────────────────
|
|
377
|
+
function evalExpr(e, row) {
|
|
378
|
+
switch (e.type) {
|
|
379
|
+
case "lit": return e.v;
|
|
380
|
+
case "id": return resolveIdentifier(e.name, row);
|
|
381
|
+
case "member": {
|
|
382
|
+
const obj = evalExpr(e.obj, row);
|
|
383
|
+
// For `file.X` and `note.X` chains, treat the parent as a namespace
|
|
384
|
+
// identifier (the parser produces id("file") then member chain).
|
|
385
|
+
if (e.obj.type === "id" && (e.obj.name === "file" || e.obj.name === "note" || e.obj.name === "formula")) {
|
|
386
|
+
return resolveIdentifier(`${e.obj.name}.${e.name}`, row);
|
|
387
|
+
}
|
|
388
|
+
// Otherwise generic property access. Methods like .lower / .contains
|
|
389
|
+
// are handled in `call` below; here we just unwrap the value.
|
|
390
|
+
if (obj == null)
|
|
391
|
+
return undefined;
|
|
392
|
+
if (typeof obj === "string" || Array.isArray(obj)) {
|
|
393
|
+
// Expose .length on strings and arrays so `name.length` works as
|
|
394
|
+
// a sort key without needing the explicit method-call form.
|
|
395
|
+
if (e.name === "length")
|
|
396
|
+
return obj.length;
|
|
397
|
+
return undefined;
|
|
398
|
+
}
|
|
399
|
+
if (typeof obj === "object") {
|
|
400
|
+
return obj[e.name];
|
|
401
|
+
}
|
|
402
|
+
return undefined;
|
|
403
|
+
}
|
|
404
|
+
case "call": {
|
|
405
|
+
// Method calls have a `member` callee: `<obj>.<method>(args)`.
|
|
406
|
+
if (e.callee.type === "member") {
|
|
407
|
+
const args = e.args.map((a) => evalExpr(a, row));
|
|
408
|
+
// file.hasTag / file.inFolder / file.hasLink are special-cased.
|
|
409
|
+
if (e.callee.obj.type === "id" && e.callee.obj.name === "file") {
|
|
410
|
+
return callFileMethod(e.callee.name, args, row);
|
|
411
|
+
}
|
|
412
|
+
const target = evalExpr(e.callee.obj, row);
|
|
413
|
+
return callValueMethod(target, e.callee.name, args);
|
|
414
|
+
}
|
|
415
|
+
// Bare function calls (e.g. `link(...)` if/when we add them).
|
|
416
|
+
if (e.callee.type === "id") {
|
|
417
|
+
const args = e.args.map((a) => evalExpr(a, row));
|
|
418
|
+
return callGlobalFunction(e.callee.name, args);
|
|
419
|
+
}
|
|
420
|
+
throw new Error("Unsupported call shape");
|
|
421
|
+
}
|
|
422
|
+
case "unary": {
|
|
423
|
+
const v = evalExpr(e.arg, row);
|
|
424
|
+
if (e.op === "!")
|
|
425
|
+
return !toBool(v);
|
|
426
|
+
if (e.op === "-")
|
|
427
|
+
return -(Number(v) || 0);
|
|
428
|
+
if (e.op === "+")
|
|
429
|
+
return Number(v) || 0;
|
|
430
|
+
throw new Error("Unknown unary operator: " + e.op);
|
|
431
|
+
}
|
|
432
|
+
case "binary": {
|
|
433
|
+
const l = evalExpr(e.left, row);
|
|
434
|
+
const r = evalExpr(e.right, row);
|
|
435
|
+
switch (e.op) {
|
|
436
|
+
case "&&": return toBool(l) ? r : l;
|
|
437
|
+
case "||": return toBool(l) ? l : r;
|
|
438
|
+
case "==": return looseEq(l, r);
|
|
439
|
+
case "!=": return !looseEq(l, r);
|
|
440
|
+
case "<": return compare(l, r) < 0;
|
|
441
|
+
case "<=": return compare(l, r) <= 0;
|
|
442
|
+
case ">": return compare(l, r) > 0;
|
|
443
|
+
case ">=": return compare(l, r) >= 0;
|
|
444
|
+
case "+": return typeof l === "string" || typeof r === "string"
|
|
445
|
+
? `${l ?? ""}${r ?? ""}`
|
|
446
|
+
: (Number(l) || 0) + (Number(r) || 0);
|
|
447
|
+
case "-": return (Number(l) || 0) - (Number(r) || 0);
|
|
448
|
+
case "*": return (Number(l) || 0) * (Number(r) || 0);
|
|
449
|
+
case "/": return (Number(l) || 0) / (Number(r) || 1);
|
|
450
|
+
case "%": return (Number(l) || 0) % (Number(r) || 1);
|
|
451
|
+
}
|
|
452
|
+
throw new Error("Unknown binary operator: " + e.op);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
function looseEq(a, b) {
|
|
457
|
+
if (a == null || b == null)
|
|
458
|
+
return a === b;
|
|
459
|
+
if (typeof a === typeof b)
|
|
460
|
+
return a === b;
|
|
461
|
+
// Allow "5" == 5 type comparisons because frontmatter is YAML-loose.
|
|
462
|
+
return String(a) === String(b);
|
|
463
|
+
}
|
|
464
|
+
function compare(a, b) {
|
|
465
|
+
if (typeof a === "number" && typeof b === "number")
|
|
466
|
+
return a - b;
|
|
467
|
+
if (a instanceof Date && b instanceof Date)
|
|
468
|
+
return a.getTime() - b.getTime();
|
|
469
|
+
return String(a ?? "").localeCompare(String(b ?? ""), undefined, { numeric: true, sensitivity: "base" });
|
|
470
|
+
}
|
|
471
|
+
// ── Identifier resolution ──────────────────────────────────────────────────
|
|
472
|
+
function resolveIdentifier(name, row) {
|
|
473
|
+
if (name.startsWith("file."))
|
|
474
|
+
return fileProperty(name.slice(5), row);
|
|
475
|
+
if (name.startsWith("note."))
|
|
476
|
+
return row.fm[name.slice(5)];
|
|
477
|
+
if (name.startsWith("formula."))
|
|
478
|
+
return resolveFormula(name.slice(8), row);
|
|
479
|
+
// Bare identifier: resolve against frontmatter (Obsidian shorthand).
|
|
480
|
+
return row.fm[name];
|
|
481
|
+
}
|
|
482
|
+
function fileProperty(prop, row) {
|
|
483
|
+
const path = row.page.path;
|
|
484
|
+
const segments = path.split("/");
|
|
485
|
+
const filename = segments[segments.length - 1];
|
|
486
|
+
const basename = filename.replace(/\.[^.]+$/, "");
|
|
487
|
+
const ext = filename.includes(".") ? filename.slice(filename.lastIndexOf(".") + 1) : "";
|
|
488
|
+
const folder = segments.length > 1 ? segments.slice(0, -1).join("/") : "";
|
|
489
|
+
switch (prop) {
|
|
490
|
+
case "name": return basename;
|
|
491
|
+
case "basename": return basename;
|
|
492
|
+
case "path": return path;
|
|
493
|
+
case "folder": return folder;
|
|
494
|
+
case "ext": return ext;
|
|
495
|
+
case "mtime": return row.page.mtime ? new Date(row.page.mtime * 1000) : null;
|
|
496
|
+
case "ctime": return row.page.birthtime ? new Date(row.page.birthtime * 1000) : null;
|
|
497
|
+
case "tags": {
|
|
498
|
+
const t = row.fm.tags;
|
|
499
|
+
if (Array.isArray(t))
|
|
500
|
+
return t.map(String);
|
|
501
|
+
if (typeof t === "string")
|
|
502
|
+
return [t];
|
|
503
|
+
return [];
|
|
504
|
+
}
|
|
505
|
+
default: return undefined;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
function callFileMethod(name, args, row) {
|
|
509
|
+
switch (name) {
|
|
510
|
+
case "hasTag": {
|
|
511
|
+
const tags = fileProperty("tags", row) || [];
|
|
512
|
+
const want = String(args[0] ?? "").replace(/^#/, "");
|
|
513
|
+
return tags.some((tag) => tag.replace(/^#/, "") === want);
|
|
514
|
+
}
|
|
515
|
+
case "inFolder": {
|
|
516
|
+
const folder = String(fileProperty("folder", row) || "");
|
|
517
|
+
const want = String(args[0] ?? "");
|
|
518
|
+
return folder === want || folder.startsWith(want + "/");
|
|
519
|
+
}
|
|
520
|
+
case "hasLink": {
|
|
521
|
+
// Not modelled in our index; deferred. Evaluate to false so filter
|
|
522
|
+
// semantics stay sane.
|
|
523
|
+
return false;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
throw new Error(`Unknown file method: file.${name}`);
|
|
527
|
+
}
|
|
528
|
+
function callValueMethod(target, name, args) {
|
|
529
|
+
if (target == null)
|
|
530
|
+
return null;
|
|
531
|
+
if (typeof target === "string") {
|
|
532
|
+
switch (name) {
|
|
533
|
+
case "contains": return target.toLowerCase().includes(String(args[0] ?? "").toLowerCase());
|
|
534
|
+
case "startsWith": return target.startsWith(String(args[0] ?? ""));
|
|
535
|
+
case "endsWith": return target.endsWith(String(args[0] ?? ""));
|
|
536
|
+
case "lower": return target.toLowerCase();
|
|
537
|
+
case "upper": return target.toUpperCase();
|
|
538
|
+
case "length": return target.length;
|
|
539
|
+
case "trim": return target.trim();
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
if (Array.isArray(target)) {
|
|
543
|
+
switch (name) {
|
|
544
|
+
case "contains": return target.some((v) => looseEq(v, args[0]));
|
|
545
|
+
case "length": return target.length;
|
|
546
|
+
case "join": return target.map(String).join(String(args[0] ?? ", "));
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
if (typeof target === "number") {
|
|
550
|
+
switch (name) {
|
|
551
|
+
case "abs": return Math.abs(target);
|
|
552
|
+
case "round": return Math.round(target);
|
|
553
|
+
case "floor": return Math.floor(target);
|
|
554
|
+
case "ceil": return Math.ceil(target);
|
|
555
|
+
case "toFixed": return target.toFixed(Number(args[0] ?? 0));
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
throw new Error(`Method '${name}' not supported on ${typeof target}`);
|
|
559
|
+
}
|
|
560
|
+
function callGlobalFunction(name, args) {
|
|
561
|
+
switch (name) {
|
|
562
|
+
case "if": return toBool(args[0]) ? args[1] : args[2];
|
|
563
|
+
case "min": return Math.min(...args.map(Number));
|
|
564
|
+
case "max": return Math.max(...args.map(Number));
|
|
565
|
+
case "now":
|
|
566
|
+
case "today": return new Date();
|
|
567
|
+
case "number": return Number(args[0]);
|
|
568
|
+
}
|
|
569
|
+
throw new Error(`Unknown function: ${name}`);
|
|
570
|
+
}
|
|
571
|
+
// ── Render a table view ────────────────────────────────────────────────────
|
|
572
|
+
function renderTableView(view, allRows, doc, context) {
|
|
573
|
+
let rows = allRows;
|
|
574
|
+
if (view.filters) {
|
|
575
|
+
rows = rows.filter((row) => evalFilter(view.filters, row));
|
|
576
|
+
}
|
|
577
|
+
const columns = view.order && view.order.length > 0 ? view.order : ["file.name"];
|
|
578
|
+
const labels = columns.map((id) => columnLabel(id, doc));
|
|
579
|
+
// Apply view-level sort (or default to alphabetical by title) before
|
|
580
|
+
// materializing cells; sort needs raw values, not rendered HTML.
|
|
581
|
+
rows = applySort(rows, view.sort);
|
|
582
|
+
if (view.limit && view.limit > 0)
|
|
583
|
+
rows = rows.slice(0, view.limit);
|
|
584
|
+
const tbl = rows.map((row) => columns.map((id) => valueForColumn(id, row, context)));
|
|
585
|
+
const header = labels.map((l, i) => `<th data-col="${i}" tabindex="0">${esc(l)}</th>`).join("");
|
|
586
|
+
const body = tbl.map((cells, ri) => {
|
|
587
|
+
const tds = cells.map((c) => `<td data-raw="${escAttr(toSortKey(c.raw))}">${c.html}</td>`).join("");
|
|
588
|
+
return `<tr data-row="${ri}">${tds}</tr>`;
|
|
589
|
+
}).join("");
|
|
590
|
+
const caption = view.name ? `<div class="bases-caption">${esc(view.name)}</div>` : "";
|
|
591
|
+
return `<div class="bases-block">
|
|
592
|
+
${caption}
|
|
593
|
+
<div class="bases-toolbar">
|
|
594
|
+
<input type="search" class="bases-filter" placeholder="Filter…" aria-label="Filter table">
|
|
595
|
+
<span class="bases-count" data-total="${tbl.length}">${tbl.length} ${tbl.length === 1 ? "row" : "rows"}</span>
|
|
596
|
+
</div>
|
|
597
|
+
<div class="bases-scroll">
|
|
598
|
+
<table class="bases-table">
|
|
599
|
+
<thead><tr>${header}</tr></thead>
|
|
600
|
+
<tbody>${body}</tbody>
|
|
601
|
+
</table>
|
|
602
|
+
</div>
|
|
603
|
+
</div>`;
|
|
604
|
+
}
|
|
605
|
+
// ── Cards view ─────────────────────────────────────────────────────────────
|
|
606
|
+
const COVER_IMG_RE = /!\[\[([^\[\]\n|#]+\.(?:png|jpe?g|webp|gif|svg|avif|tiff?))(?:\|[^\]]*)?\]\]/i;
|
|
607
|
+
function renderCardsView(view, allRows, doc, context) {
|
|
608
|
+
let rows = allRows;
|
|
609
|
+
if (view.filters)
|
|
610
|
+
rows = rows.filter((row) => evalFilter(view.filters, row));
|
|
611
|
+
rows = applySort(rows, view.sort);
|
|
612
|
+
if (view.limit && view.limit > 0)
|
|
613
|
+
rows = rows.slice(0, view.limit);
|
|
614
|
+
// Up to 2 metadata fields shown under the title (skipping file.name).
|
|
615
|
+
const metaCols = (view.order ?? []).filter((c) => c !== "file.name").slice(0, 2);
|
|
616
|
+
const aspectStyle = view.imageAspectRatio ? `aspect-ratio: ${escAttr(view.imageAspectRatio)};` : "";
|
|
617
|
+
const fit = view.imageFit === "contain" ? "contain" : "cover";
|
|
618
|
+
const cards = rows.map((row) => {
|
|
619
|
+
const href = "/" + row.page.path.replace(/\.md$/i, "").split("/").map(encodeURIComponent).join("/");
|
|
620
|
+
const cover = findCoverImage(row, view.image, context);
|
|
621
|
+
const coverHtml = cover
|
|
622
|
+
? `<div class="bases-card-cover" style="${aspectStyle}background-image: url('${escAttr(cover)}'); background-size: ${fit}; background-position: center;"></div>`
|
|
623
|
+
: "";
|
|
624
|
+
const metaHtml = metaCols
|
|
625
|
+
.map((id) => renderValue(resolveIdentifier(id, row)))
|
|
626
|
+
.filter(Boolean)
|
|
627
|
+
.map((v) => `<div class="bases-card-meta">${v}</div>`)
|
|
628
|
+
.join("");
|
|
629
|
+
return `<a class="bases-card" href="${escAttr(href)}">
|
|
630
|
+
${coverHtml}
|
|
631
|
+
<div class="bases-card-body">
|
|
632
|
+
<div class="bases-card-title">${esc(row.page.title)}</div>
|
|
633
|
+
${metaHtml}
|
|
634
|
+
</div>
|
|
635
|
+
</a>`;
|
|
636
|
+
}).join("");
|
|
637
|
+
const caption = view.name ? `<div class="bases-caption">${esc(view.name)}</div>` : "";
|
|
638
|
+
return `<div class="bases-block bases-cards-block">
|
|
639
|
+
${caption}
|
|
640
|
+
<div class="bases-toolbar">
|
|
641
|
+
<input type="search" class="bases-filter" placeholder="Filter…" aria-label="Filter cards">
|
|
642
|
+
<span class="bases-count" data-total="${rows.length}">${rows.length} ${rows.length === 1 ? "card" : "cards"}</span>
|
|
643
|
+
</div>
|
|
644
|
+
<div class="bases-cards">${cards}</div>
|
|
645
|
+
</div>`;
|
|
646
|
+
}
|
|
647
|
+
// ── List view ──────────────────────────────────────────────────────────────
|
|
648
|
+
function renderListView(view, allRows, doc) {
|
|
649
|
+
let rows = allRows;
|
|
650
|
+
if (view.filters)
|
|
651
|
+
rows = rows.filter((row) => evalFilter(view.filters, row));
|
|
652
|
+
rows = applySort(rows, view.sort);
|
|
653
|
+
if (view.limit && view.limit > 0)
|
|
654
|
+
rows = rows.slice(0, view.limit);
|
|
655
|
+
// Optional inline meta after the title (joined with bullets).
|
|
656
|
+
const metaCols = (view.order ?? []).filter((c) => c !== "file.name");
|
|
657
|
+
const items = rows.map((row) => {
|
|
658
|
+
const href = "/" + row.page.path.replace(/\.md$/i, "").split("/").map(encodeURIComponent).join("/");
|
|
659
|
+
const meta = metaCols
|
|
660
|
+
.map((id) => renderValue(resolveIdentifier(id, row)))
|
|
661
|
+
.filter(Boolean)
|
|
662
|
+
.join(' <span class="bases-list-sep">·</span> ');
|
|
663
|
+
const metaSpan = meta ? `<span class="bases-list-meta">${meta}</span>` : "";
|
|
664
|
+
return `<li><a class="internal internal-link" href="${escAttr(href)}">${esc(row.page.title)}</a>${metaSpan}</li>`;
|
|
665
|
+
}).join("");
|
|
666
|
+
// Keep `doc` in the signature for symmetry with the other view renderers,
|
|
667
|
+
// even though list rendering doesn't currently consult properties.
|
|
668
|
+
void doc;
|
|
669
|
+
const caption = view.name ? `<div class="bases-caption">${esc(view.name)}</div>` : "";
|
|
670
|
+
return `<div class="bases-block bases-list-block">
|
|
671
|
+
${caption}
|
|
672
|
+
<ul class="bases-list">${items}</ul>
|
|
673
|
+
</div>`;
|
|
674
|
+
}
|
|
675
|
+
// ── Sorting ────────────────────────────────────────────────────────────────
|
|
676
|
+
/**
|
|
677
|
+
* Multi-key stable sort honoring `view.sort`. Each entry contributes one
|
|
678
|
+
* comparison; later entries break ties from earlier ones. Direction
|
|
679
|
+
* defaults to ASC. With no spec, sort alphabetically by page title so
|
|
680
|
+
* output stays deterministic regardless of which view first ran.
|
|
681
|
+
*/
|
|
682
|
+
function applySort(rows, spec) {
|
|
683
|
+
if (!spec || spec.length === 0) {
|
|
684
|
+
return [...rows].sort((a, b) => compare(a.page.title, b.page.title));
|
|
685
|
+
}
|
|
686
|
+
return [...rows].sort((a, b) => {
|
|
687
|
+
for (const s of spec) {
|
|
688
|
+
const av = sortKeyFor(s.column, a);
|
|
689
|
+
const bv = sortKeyFor(s.column, b);
|
|
690
|
+
const c = compare(av, bv);
|
|
691
|
+
if (c !== 0)
|
|
692
|
+
return s.direction === "DESC" ? -c : c;
|
|
693
|
+
}
|
|
694
|
+
return 0;
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
function sortKeyFor(id, row) {
|
|
698
|
+
if (id === "file.name" || id === "file.basename")
|
|
699
|
+
return row.page.title;
|
|
700
|
+
return resolveIdentifier(id, row);
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* Cover-image source for a card. Preference order:
|
|
704
|
+
* 1. The view's `image:` setting names a frontmatter property → use that.
|
|
705
|
+
* 2. Look for the first `![[image.ext]]` embed in the page body.
|
|
706
|
+
* Returns the served (post-compression) URL, or null.
|
|
707
|
+
*/
|
|
708
|
+
function findCoverImage(row, prop, context) {
|
|
709
|
+
let raw = null;
|
|
710
|
+
if (prop) {
|
|
711
|
+
const v = row.fm[prop];
|
|
712
|
+
if (typeof v === "string" && v.length > 0)
|
|
713
|
+
raw = v;
|
|
714
|
+
}
|
|
715
|
+
if (!raw) {
|
|
716
|
+
// Look up the page's markdown source via context.markdownContent (keyed
|
|
717
|
+
// by basename or path slug; we use path slug for uniqueness).
|
|
718
|
+
const slug = slugifySimple(row.page.path.replace(/\.md$/i, ""));
|
|
719
|
+
const source = context.markdownContent.get(slug);
|
|
720
|
+
if (source) {
|
|
721
|
+
const m = COVER_IMG_RE.exec(source);
|
|
722
|
+
if (m && m[1])
|
|
723
|
+
raw = m[1];
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
if (!raw)
|
|
727
|
+
return null;
|
|
728
|
+
// Strip a leading `![[` / trailing `]]` if the user set a wikilink-style
|
|
729
|
+
// value (`cover: ![[portrait.webp]]`), then look up in the image index.
|
|
730
|
+
raw = raw.replace(/^!\[\[/, "").replace(/\]\]$/, "").split("|")[0].trim();
|
|
731
|
+
const image = context.images.get(slugifySimple(raw.split("/").pop() || raw));
|
|
732
|
+
if (image)
|
|
733
|
+
return "/" + image.outputPath.split("/").map(encodeURIComponent).join("/");
|
|
734
|
+
// Already a URL or path: use as-is.
|
|
735
|
+
return raw.startsWith("http") ? raw : "/" + raw.split("/").map(encodeURIComponent).join("/");
|
|
736
|
+
}
|
|
737
|
+
// Mirror the slugify in build.ts without taking a dependency on the renderer's
|
|
738
|
+
// slug.ts (which imports from a sibling module). Same algorithm.
|
|
739
|
+
function slugifySimple(s) {
|
|
740
|
+
return s
|
|
741
|
+
.normalize("NFKD")
|
|
742
|
+
.replace(/[̀-ͯ]/g, "")
|
|
743
|
+
.toLowerCase()
|
|
744
|
+
.replace(/\.md$/i, "")
|
|
745
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
746
|
+
.replace(/^-+|-+$/g, "");
|
|
747
|
+
}
|
|
748
|
+
function valueForColumn(id, row, context) {
|
|
749
|
+
// file.name renders as a link to the page.
|
|
750
|
+
if (id === "file.name" || id === "file.basename") {
|
|
751
|
+
const title = row.page.title;
|
|
752
|
+
const href = "/" + row.page.path.replace(/\.md$/i, "").split("/").map(encodeURIComponent).join("/");
|
|
753
|
+
return { html: `<a class="internal internal-link" href="${escAttr(href)}">${esc(title)}</a>`, raw: title };
|
|
754
|
+
}
|
|
755
|
+
const v = resolveIdentifier(id, row);
|
|
756
|
+
return { html: renderValue(v), raw: v };
|
|
757
|
+
}
|
|
758
|
+
function renderValue(v) {
|
|
759
|
+
if (v == null)
|
|
760
|
+
return "";
|
|
761
|
+
if (Array.isArray(v))
|
|
762
|
+
return v.map((x) => renderValue(x)).filter(Boolean).join(", ");
|
|
763
|
+
if (v instanceof Date)
|
|
764
|
+
return esc(v.toISOString().slice(0, 10));
|
|
765
|
+
if (typeof v === "boolean")
|
|
766
|
+
return v ? "✓" : "";
|
|
767
|
+
if (typeof v === "string") {
|
|
768
|
+
// Render `[[wikilinks]]` in property values as plain styled text — full
|
|
769
|
+
// wikilink resolution happens later in the wikilink plugin, which only
|
|
770
|
+
// sees text nodes; the bases plugin emits HTML.
|
|
771
|
+
return esc(v);
|
|
772
|
+
}
|
|
773
|
+
return esc(String(v));
|
|
774
|
+
}
|
|
775
|
+
function toSortKey(v) {
|
|
776
|
+
if (v == null)
|
|
777
|
+
return "";
|
|
778
|
+
if (v instanceof Date)
|
|
779
|
+
return String(v.getTime());
|
|
780
|
+
if (Array.isArray(v))
|
|
781
|
+
return v.map(String).join(",");
|
|
782
|
+
return String(v);
|
|
783
|
+
}
|
|
784
|
+
function columnLabel(id, doc) {
|
|
785
|
+
const explicit = doc.properties?.[id]?.displayName;
|
|
786
|
+
if (explicit)
|
|
787
|
+
return explicit;
|
|
788
|
+
if (id.startsWith("note."))
|
|
789
|
+
return id.slice(5);
|
|
790
|
+
if (id.startsWith("formula."))
|
|
791
|
+
return id.slice(8);
|
|
792
|
+
if (id.startsWith("file.")) {
|
|
793
|
+
const tail = id.slice(5);
|
|
794
|
+
return tail.charAt(0).toUpperCase() + tail.slice(1);
|
|
795
|
+
}
|
|
796
|
+
return id;
|
|
797
|
+
}
|
|
798
|
+
function errorBlock(message) {
|
|
799
|
+
return `<div class="bases-block bases-error">${esc(message)}</div>`;
|
|
800
|
+
}
|
|
801
|
+
function esc(s) {
|
|
802
|
+
return String(s ?? "").replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c]));
|
|
803
|
+
}
|
|
804
|
+
function escAttr(s) {
|
|
805
|
+
return String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
|
806
|
+
}
|
|
807
|
+
//# sourceMappingURL=bases.js.map
|