@wizzlethorpe/vaults 0.2.0 → 0.5.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/dist/build.js +241 -44
- package/dist/build.js.map +1 -1
- package/dist/favicon.js +8 -31
- package/dist/favicon.js.map +1 -1
- package/dist/render/bases.js +324 -29
- package/dist/render/bases.js.map +1 -1
- package/dist/render/callouts.js +4 -1
- package/dist/render/callouts.js.map +1 -1
- package/dist/render/cover.js +41 -0
- package/dist/render/cover.js.map +1 -0
- package/dist/render/embed.js +10 -0
- package/dist/render/embed.js.map +1 -1
- package/dist/render/layout.js +207 -30
- package/dist/render/layout.js.map +1 -1
- package/dist/render/pipeline.js +11 -5
- package/dist/render/pipeline.js.map +1 -1
- package/dist/render/styles.js +354 -23
- package/dist/render/styles.js.map +1 -1
- package/dist/settings.js +13 -3
- package/dist/settings.js.map +1 -1
- package/package.json +4 -1
package/dist/favicon.js
CHANGED
|
@@ -5,29 +5,29 @@ const ICON_SIZE = 32;
|
|
|
5
5
|
/**
|
|
6
6
|
* Render the favicon for a vault to an ICO buffer. If the user pointed
|
|
7
7
|
* `settings.favicon` at a real file, we resize that image; otherwise we
|
|
8
|
-
* generate a default; a rounded square in the
|
|
9
|
-
* a single uppercase letter centred
|
|
8
|
+
* generate a default; a rounded square in the theme background colour with
|
|
9
|
+
* a single uppercase letter centred in the vault accent colour.
|
|
10
10
|
*/
|
|
11
11
|
export async function buildFavicon(opts) {
|
|
12
12
|
const png = opts.faviconPath
|
|
13
13
|
? await renderUserImage(opts.vaultPath, opts.faviconPath)
|
|
14
|
-
: await renderDefaultIcon(opts.letter, opts.accentColor);
|
|
15
|
-
|
|
14
|
+
: await renderDefaultIcon(opts.letter, opts.backgroundColor, opts.accentColor);
|
|
15
|
+
// Modern browsers support PNG favicons; return directly.
|
|
16
|
+
return png;
|
|
16
17
|
}
|
|
17
18
|
async function renderUserImage(vaultPath, faviconPath) {
|
|
18
19
|
const abs = isAbsolute(faviconPath) ? faviconPath : join(vaultPath, faviconPath);
|
|
19
20
|
const source = await readFile(abs);
|
|
20
21
|
return sharp(source).resize(ICON_SIZE, ICON_SIZE, { fit: "cover" }).png().toBuffer();
|
|
21
22
|
}
|
|
22
|
-
async function renderDefaultIcon(letter, accent) {
|
|
23
|
-
const fg = bestForeground(accent);
|
|
23
|
+
async function renderDefaultIcon(letter, bg, accent) {
|
|
24
24
|
// Round-cornered square with a single letter centred. Embedded as an
|
|
25
25
|
// SVG so sharp rasterises it cleanly at the target size.
|
|
26
26
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
|
27
|
-
<rect width="32" height="32" rx="5" fill="${escAttr(
|
|
27
|
+
<rect width="32" height="32" rx="5" fill="${escAttr(bg)}"/>
|
|
28
28
|
<text x="16" y="22" font-family="Iowan Old Style, Palatino Linotype, Georgia, serif"
|
|
29
29
|
font-size="22" font-weight="700" text-anchor="middle"
|
|
30
|
-
fill="${escAttr(
|
|
30
|
+
fill="${escAttr(accent)}">${escText(letter)}</text>
|
|
31
31
|
</svg>`;
|
|
32
32
|
return sharp(Buffer.from(svg)).resize(ICON_SIZE, ICON_SIZE).png().toBuffer();
|
|
33
33
|
}
|
|
@@ -35,29 +35,6 @@ async function renderDefaultIcon(letter, accent) {
|
|
|
35
35
|
* Wrap a PNG buffer in an ICO container. Modern browsers accept PNG-in-ICO
|
|
36
36
|
* for any size; the dirent's width/height bytes are advisory.
|
|
37
37
|
*
|
|
38
|
-
* Layout:
|
|
39
|
-
* ICONDIR (6 bytes) reserved=0, type=1, count=1
|
|
40
|
-
* ICONDIRENTRY (16 bytes) width, height, ..., size, offset
|
|
41
|
-
* <PNG bytes>
|
|
42
|
-
*/
|
|
43
|
-
function wrapPngAsIco(png, size) {
|
|
44
|
-
const HEADER = 6 + 16;
|
|
45
|
-
const buf = Buffer.alloc(HEADER + png.length);
|
|
46
|
-
buf.writeUInt16LE(0, 0); // reserved
|
|
47
|
-
buf.writeUInt16LE(1, 2); // type: icon
|
|
48
|
-
buf.writeUInt16LE(1, 4); // image count
|
|
49
|
-
buf.writeUInt8(size === 256 ? 0 : size, 6); // width (0 = 256)
|
|
50
|
-
buf.writeUInt8(size === 256 ? 0 : size, 7); // height
|
|
51
|
-
buf.writeUInt8(0, 8); // palette
|
|
52
|
-
buf.writeUInt8(0, 9); // reserved
|
|
53
|
-
buf.writeUInt16LE(1, 10); // colour planes
|
|
54
|
-
buf.writeUInt16LE(32, 12); // bits per pixel
|
|
55
|
-
buf.writeUInt32LE(png.length, 14); // bytes of image data
|
|
56
|
-
buf.writeUInt32LE(HEADER, 18); // offset to image data
|
|
57
|
-
png.copy(buf, HEADER);
|
|
58
|
-
return buf;
|
|
59
|
-
}
|
|
60
|
-
/**
|
|
61
38
|
* Pick black or white as the letter colour against `accent` so the icon
|
|
62
39
|
* stays readable for whatever colour the user picked. Uses W3C relative
|
|
63
40
|
* luminance.
|
package/dist/favicon.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"favicon.js","sourceRoot":"","sources":["../src/favicon.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAC5C,OAAO,EAAE,IAAI,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AAC7C,OAAO,KAAK,MAAM,OAAO,CAAC;AAE1B,MAAM,SAAS,GAAG,EAAE,CAAC;AAErB;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,YAAY,CAAC,
|
|
1
|
+
{"version":3,"file":"favicon.js","sourceRoot":"","sources":["../src/favicon.ts"],"names":[],"mappings":"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"}
|
package/dist/render/bases.js
CHANGED
|
@@ -1,27 +1,37 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Obsidian Bases support.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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.
|
|
6
7
|
//
|
|
7
|
-
//
|
|
8
|
+
// Both paths share the same parser + evaluator + view renderers.
|
|
9
|
+
//
|
|
10
|
+
// Supported (v3):
|
|
8
11
|
// - filters: string expression OR { and|or|not: [expr|tree] } tree
|
|
9
|
-
// - views:
|
|
10
|
-
//
|
|
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.
|
|
11
22
|
// - identifiers: file.{name,basename,path,folder,ext,mtime,ctime,tags}
|
|
12
|
-
// note.X / bare X (frontmatter)
|
|
23
|
+
// note.X / bare X (frontmatter), formula.X
|
|
13
24
|
// - operators: == != < <= > >= && || ! + (string concat / numeric add)
|
|
14
25
|
// - methods: file.hasTag("..."), file.inFolder("..."),
|
|
15
26
|
// stringValue.contains("..."), .startsWith, .endsWith,
|
|
16
27
|
// .lower, .upper
|
|
17
28
|
// - literals: strings (double or single quoted), numbers, true/false, null
|
|
29
|
+
// - cards-only: image: <prop>, imageFit: cover|contain, imageAspectRatio
|
|
18
30
|
//
|
|
19
|
-
// Deferred
|
|
20
|
-
// -
|
|
21
|
-
// -
|
|
22
|
-
// - cards/list/map view types
|
|
31
|
+
// Deferred:
|
|
32
|
+
// - summaries, groupBy
|
|
33
|
+
// - map view type
|
|
23
34
|
// - duration arithmetic (now() - "1 week" etc.)
|
|
24
|
-
// - regex / list filter chains
|
|
25
35
|
//
|
|
26
36
|
// Unknown view types and unknown YAML keys are warned-on, not fatal —
|
|
27
37
|
// real .base files in the wild include undocumented fields.
|
|
@@ -42,7 +52,12 @@ export function basesPlugin(opts) {
|
|
|
42
52
|
});
|
|
43
53
|
};
|
|
44
54
|
}
|
|
45
|
-
|
|
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) {
|
|
46
61
|
let doc;
|
|
47
62
|
try {
|
|
48
63
|
doc = yaml.load(source) ?? {};
|
|
@@ -50,8 +65,13 @@ function renderBase(source, context, warnings) {
|
|
|
50
65
|
catch (err) {
|
|
51
66
|
return errorBlock(`Failed to parse base YAML: ${err.message}`);
|
|
52
67
|
}
|
|
53
|
-
// Evaluate filters once for the whole doc; views can additionally narrow.
|
|
54
68
|
const allRows = collectRows(context);
|
|
69
|
+
try {
|
|
70
|
+
setupFormulas(allRows, doc);
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
return errorBlock(`Formula error: ${err.message}`);
|
|
74
|
+
}
|
|
55
75
|
let baseRows;
|
|
56
76
|
try {
|
|
57
77
|
baseRows = doc.filters ? allRows.filter((row) => evalFilter(doc.filters, row)) : allRows;
|
|
@@ -59,19 +79,101 @@ function renderBase(source, context, warnings) {
|
|
|
59
79
|
catch (err) {
|
|
60
80
|
return errorBlock(`Filter error: ${err.message}`);
|
|
61
81
|
}
|
|
62
|
-
|
|
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
|
+
}
|
|
63
90
|
const blocks = [];
|
|
64
91
|
for (const view of views) {
|
|
65
|
-
|
|
66
|
-
if (
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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}`));
|
|
70
113
|
}
|
|
71
|
-
blocks.push(renderTableView(view, baseRows, doc, context));
|
|
72
114
|
}
|
|
115
|
+
// Multi-view bases render as a tabbed container; single-view (or
|
|
116
|
+
// viewName-pinned, where the user already chose one) renders bare so
|
|
117
|
+
// simple cases stay simple. The tab strip uses each view's `name` for
|
|
118
|
+
// its label; missing names fall back to "View N".
|
|
119
|
+
if (blocks.length > 1)
|
|
120
|
+
return wrapAsTabs(blocks, views);
|
|
73
121
|
return blocks.join("\n");
|
|
74
122
|
}
|
|
123
|
+
function wrapAsTabs(blocks, views) {
|
|
124
|
+
const tabs = blocks.map((_, i) => {
|
|
125
|
+
const label = views[i]?.name?.trim() || `View ${i + 1}`;
|
|
126
|
+
const isActive = i === 0;
|
|
127
|
+
return `<button type="button" role="tab" data-bases-tab="${i}"`
|
|
128
|
+
+ ` aria-selected="${isActive ? "true" : "false"}"`
|
|
129
|
+
+ ` tabindex="${isActive ? "0" : "-1"}"`
|
|
130
|
+
+ ` class="bases-tab${isActive ? " bases-tab-active" : ""}">`
|
|
131
|
+
+ esc(label) + `</button>`;
|
|
132
|
+
}).join("");
|
|
133
|
+
const panels = blocks.map((html, i) => `<div class="bases-tab-panel" role="tabpanel" data-bases-tab-panel="${i}"${i === 0 ? "" : " hidden"}>${html}</div>`).join("");
|
|
134
|
+
return `<div class="bases-tabbed"><div class="bases-tab-strip" role="tablist">${tabs}</div>${panels}</div>`;
|
|
135
|
+
}
|
|
136
|
+
const FORMULA_VISITING = Symbol("formula-visiting");
|
|
137
|
+
/**
|
|
138
|
+
* Parse the doc's formulas once and attach them to each row alongside an
|
|
139
|
+
* empty memo cache. Lazily evaluated by `resolveFormula` on first access.
|
|
140
|
+
*/
|
|
141
|
+
function setupFormulas(rows, doc) {
|
|
142
|
+
if (!doc.formulas)
|
|
143
|
+
return;
|
|
144
|
+
const exprs = {};
|
|
145
|
+
for (const [k, v] of Object.entries(doc.formulas)) {
|
|
146
|
+
if (typeof v !== "string")
|
|
147
|
+
continue;
|
|
148
|
+
try {
|
|
149
|
+
exprs[k] = parseExpr(v);
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
throw new Error(`'${k}': ${err.message}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
for (const row of rows) {
|
|
156
|
+
row.formulaExprs = exprs;
|
|
157
|
+
row.formulaCache = new Map();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function resolveFormula(key, row) {
|
|
161
|
+
if (!row.formulaExprs || !row.formulaCache)
|
|
162
|
+
return undefined;
|
|
163
|
+
const expr = row.formulaExprs[key];
|
|
164
|
+
if (!expr)
|
|
165
|
+
return undefined;
|
|
166
|
+
if (row.formulaCache.has(key)) {
|
|
167
|
+
const v = row.formulaCache.get(key);
|
|
168
|
+
if (v === FORMULA_VISITING)
|
|
169
|
+
throw new Error(`Formula cycle: ${key}`);
|
|
170
|
+
return v;
|
|
171
|
+
}
|
|
172
|
+
row.formulaCache.set(key, FORMULA_VISITING);
|
|
173
|
+
const value = evalExpr(expr, row);
|
|
174
|
+
row.formulaCache.set(key, value);
|
|
175
|
+
return value;
|
|
176
|
+
}
|
|
75
177
|
function collectRows(context) {
|
|
76
178
|
// pages map has multiple keys (basename slug, path slug, aliases) per page.
|
|
77
179
|
// Dedupe by `path` so each page appears once.
|
|
@@ -306,7 +408,14 @@ function evalExpr(e, row) {
|
|
|
306
408
|
// are handled in `call` below; here we just unwrap the value.
|
|
307
409
|
if (obj == null)
|
|
308
410
|
return undefined;
|
|
309
|
-
if (typeof obj === "
|
|
411
|
+
if (typeof obj === "string" || Array.isArray(obj)) {
|
|
412
|
+
// Expose .length on strings and arrays so `name.length` works as
|
|
413
|
+
// a sort key without needing the explicit method-call form.
|
|
414
|
+
if (e.name === "length")
|
|
415
|
+
return obj.length;
|
|
416
|
+
return undefined;
|
|
417
|
+
}
|
|
418
|
+
if (typeof obj === "object") {
|
|
310
419
|
return obj[e.name];
|
|
311
420
|
}
|
|
312
421
|
return undefined;
|
|
@@ -384,6 +493,8 @@ function resolveIdentifier(name, row) {
|
|
|
384
493
|
return fileProperty(name.slice(5), row);
|
|
385
494
|
if (name.startsWith("note."))
|
|
386
495
|
return row.fm[name.slice(5)];
|
|
496
|
+
if (name.startsWith("formula."))
|
|
497
|
+
return resolveFormula(name.slice(8), row);
|
|
387
498
|
// Bare identifier: resolve against frontmatter (Obsidian shorthand).
|
|
388
499
|
return row.fm[name];
|
|
389
500
|
}
|
|
@@ -484,13 +595,12 @@ function renderTableView(view, allRows, doc, context) {
|
|
|
484
595
|
}
|
|
485
596
|
const columns = view.order && view.order.length > 0 ? view.order : ["file.name"];
|
|
486
597
|
const labels = columns.map((id) => columnLabel(id, doc));
|
|
487
|
-
//
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
const sortIdx = 0;
|
|
491
|
-
tbl.sort((a, b) => compare(a[sortIdx].raw, b[sortIdx].raw));
|
|
598
|
+
// Apply view-level sort (or default to alphabetical by title) before
|
|
599
|
+
// materializing cells; sort needs raw values, not rendered HTML.
|
|
600
|
+
rows = applySort(rows, view.sort);
|
|
492
601
|
if (view.limit && view.limit > 0)
|
|
493
|
-
|
|
602
|
+
rows = rows.slice(0, view.limit);
|
|
603
|
+
const tbl = rows.map((row) => columns.map((id) => valueForColumn(id, row, context)));
|
|
494
604
|
const header = labels.map((l, i) => `<th data-col="${i}" tabindex="0">${esc(l)}</th>`).join("");
|
|
495
605
|
const body = tbl.map((cells, ri) => {
|
|
496
606
|
const tds = cells.map((c) => `<td data-raw="${escAttr(toSortKey(c.raw))}">${c.html}</td>`).join("");
|
|
@@ -511,6 +621,189 @@ function renderTableView(view, allRows, doc, context) {
|
|
|
511
621
|
</div>
|
|
512
622
|
</div>`;
|
|
513
623
|
}
|
|
624
|
+
// ── Cards view ─────────────────────────────────────────────────────────────
|
|
625
|
+
const COVER_IMG_RE = /!\[\[([^\[\]\n|#]+\.(?:png|jpe?g|webp|gif|svg|avif|tiff?))(?:\|[^\]]*)?\]\]/i;
|
|
626
|
+
function renderCardsView(view, allRows, doc, context) {
|
|
627
|
+
let rows = allRows;
|
|
628
|
+
if (view.filters)
|
|
629
|
+
rows = rows.filter((row) => evalFilter(view.filters, row));
|
|
630
|
+
rows = applySort(rows, view.sort);
|
|
631
|
+
if (view.limit && view.limit > 0)
|
|
632
|
+
rows = rows.slice(0, view.limit);
|
|
633
|
+
// Up to 2 metadata fields shown under the title (skipping file.name).
|
|
634
|
+
const metaCols = (view.order ?? []).filter((c) => c !== "file.name").slice(0, 2);
|
|
635
|
+
// The HTML pipeline's sanitize schema strips `style` attributes from divs,
|
|
636
|
+
// so we can't drive cover layout from inline CSS. Encode imageFit as a
|
|
637
|
+
// class instead; aspect-ratio rides on a CSS variable set via the only
|
|
638
|
+
// attribute we keep, `class` (the variable is read by styles.css).
|
|
639
|
+
const fitClass = view.imageFit === "contain" ? "bases-card-cover-contain" : "bases-card-cover-cover";
|
|
640
|
+
const aspectClass = view.imageAspectRatio ? aspectRatioClass(view.imageAspectRatio) : "";
|
|
641
|
+
const cards = rows.map((row) => {
|
|
642
|
+
const href = "/" + row.page.path.replace(/\.md$/i, "").split("/").map(encodeURIComponent).join("/");
|
|
643
|
+
const cover = findCoverImage(row, view.image, context);
|
|
644
|
+
const coverHtml = cover
|
|
645
|
+
? `<div class="bases-card-cover ${fitClass}${aspectClass ? " " + aspectClass : ""}"><img src="${escAttr(cover)}" alt="" loading="lazy"></div>`
|
|
646
|
+
: "";
|
|
647
|
+
const metaHtml = metaCols
|
|
648
|
+
.map((id) => renderValue(resolveIdentifier(id, row)))
|
|
649
|
+
.filter(Boolean)
|
|
650
|
+
.map((v) => `<div class="bases-card-meta">${v}</div>`)
|
|
651
|
+
.join("");
|
|
652
|
+
return `<a class="bases-card" href="${escAttr(href)}">
|
|
653
|
+
${coverHtml}
|
|
654
|
+
<div class="bases-card-body">
|
|
655
|
+
<div class="bases-card-title">${esc(row.page.title)}</div>
|
|
656
|
+
${metaHtml}
|
|
657
|
+
</div>
|
|
658
|
+
</a>`;
|
|
659
|
+
}).join("");
|
|
660
|
+
const caption = view.name ? `<div class="bases-caption">${esc(view.name)}</div>` : "";
|
|
661
|
+
return `<div class="bases-block bases-cards-block">
|
|
662
|
+
${caption}
|
|
663
|
+
<div class="bases-toolbar">
|
|
664
|
+
<input type="search" class="bases-filter" placeholder="Filter…" aria-label="Filter cards">
|
|
665
|
+
<span class="bases-count" data-total="${rows.length}">${rows.length} ${rows.length === 1 ? "card" : "cards"}</span>
|
|
666
|
+
</div>
|
|
667
|
+
<div class="bases-cards">${cards}</div>
|
|
668
|
+
</div>`;
|
|
669
|
+
}
|
|
670
|
+
// ── List view ──────────────────────────────────────────────────────────────
|
|
671
|
+
function renderListView(view, allRows, doc) {
|
|
672
|
+
let rows = allRows;
|
|
673
|
+
if (view.filters)
|
|
674
|
+
rows = rows.filter((row) => evalFilter(view.filters, row));
|
|
675
|
+
rows = applySort(rows, view.sort);
|
|
676
|
+
if (view.limit && view.limit > 0)
|
|
677
|
+
rows = rows.slice(0, view.limit);
|
|
678
|
+
// Optional inline meta after the title (joined with bullets).
|
|
679
|
+
const metaCols = (view.order ?? []).filter((c) => c !== "file.name");
|
|
680
|
+
const items = rows.map((row) => {
|
|
681
|
+
const href = "/" + row.page.path.replace(/\.md$/i, "").split("/").map(encodeURIComponent).join("/");
|
|
682
|
+
const meta = metaCols
|
|
683
|
+
.map((id) => renderValue(resolveIdentifier(id, row)))
|
|
684
|
+
.filter(Boolean)
|
|
685
|
+
.join(' <span class="bases-list-sep">·</span> ');
|
|
686
|
+
const metaSpan = meta ? `<span class="bases-list-meta">${meta}</span>` : "";
|
|
687
|
+
return `<li><a class="internal internal-link" href="${escAttr(href)}">${esc(row.page.title)}</a>${metaSpan}</li>`;
|
|
688
|
+
}).join("");
|
|
689
|
+
// Keep `doc` in the signature for symmetry with the other view renderers,
|
|
690
|
+
// even though list rendering doesn't currently consult properties.
|
|
691
|
+
void doc;
|
|
692
|
+
const caption = view.name ? `<div class="bases-caption">${esc(view.name)}</div>` : "";
|
|
693
|
+
return `<div class="bases-block bases-list-block">
|
|
694
|
+
${caption}
|
|
695
|
+
<ul class="bases-list">${items}</ul>
|
|
696
|
+
</div>`;
|
|
697
|
+
}
|
|
698
|
+
// ── Sorting ────────────────────────────────────────────────────────────────
|
|
699
|
+
/**
|
|
700
|
+
* Multi-key stable sort honoring `view.sort`. Each entry contributes one
|
|
701
|
+
* comparison; later entries break ties from earlier ones. Direction
|
|
702
|
+
* defaults to ASC. With no spec, sort alphabetically by page title so
|
|
703
|
+
* output stays deterministic regardless of which view first ran.
|
|
704
|
+
*/
|
|
705
|
+
function applySort(rows, spec) {
|
|
706
|
+
if (!spec || spec.length === 0) {
|
|
707
|
+
return [...rows].sort((a, b) => compare(a.page.title, b.page.title));
|
|
708
|
+
}
|
|
709
|
+
return [...rows].sort((a, b) => {
|
|
710
|
+
for (const s of spec) {
|
|
711
|
+
const av = sortKeyFor(s.column, a);
|
|
712
|
+
const bv = sortKeyFor(s.column, b);
|
|
713
|
+
const c = compare(av, bv);
|
|
714
|
+
if (c !== 0)
|
|
715
|
+
return s.direction === "DESC" ? -c : c;
|
|
716
|
+
}
|
|
717
|
+
return 0;
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
function sortKeyFor(id, row) {
|
|
721
|
+
if (id === "file.name" || id === "file.basename")
|
|
722
|
+
return row.page.title;
|
|
723
|
+
return resolveIdentifier(id, row);
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* Cover-image source for a card. Preference order:
|
|
727
|
+
* 1. The view's `image:` setting names a frontmatter property → use that.
|
|
728
|
+
* 2. Look for the first `![[image.ext]]` embed in the page body.
|
|
729
|
+
* Returns the served (post-compression) URL, or null.
|
|
730
|
+
*/
|
|
731
|
+
function findCoverImage(row, prop, context) {
|
|
732
|
+
let raw = null;
|
|
733
|
+
if (prop) {
|
|
734
|
+
const v = row.fm[prop];
|
|
735
|
+
if (typeof v === "string" && v.length > 0)
|
|
736
|
+
raw = v;
|
|
737
|
+
}
|
|
738
|
+
// Fall back to the per-page resolved cover (image: frontmatter, or first
|
|
739
|
+
// body embed when settings.auto_image is on). Already a served URL.
|
|
740
|
+
if (!raw && row.page.coverImage)
|
|
741
|
+
return row.page.coverImage;
|
|
742
|
+
if (!raw) {
|
|
743
|
+
// Last-resort body scan: covers older callers that pre-date PageMeta.coverImage.
|
|
744
|
+
const slug = slugifySimple(row.page.path.replace(/\.md$/i, ""));
|
|
745
|
+
const source = context.markdownContent.get(slug);
|
|
746
|
+
if (source) {
|
|
747
|
+
const m = COVER_IMG_RE.exec(source);
|
|
748
|
+
if (m && m[1])
|
|
749
|
+
raw = m[1];
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
if (!raw)
|
|
753
|
+
return null;
|
|
754
|
+
// Strip a leading `![[` / trailing `]]` if the user set a wikilink-style
|
|
755
|
+
// value (`cover: ![[portrait.webp]]`), then look up in the image index.
|
|
756
|
+
raw = raw.replace(/^!\[\[/, "").replace(/\]\]$/, "").split("|")[0].trim();
|
|
757
|
+
const image = context.images.get(slugifySimple(raw.split("/").pop() || raw));
|
|
758
|
+
if (image)
|
|
759
|
+
return "/" + image.outputPath.split("/").map(encodeURIComponent).join("/");
|
|
760
|
+
// Already a URL or path: use as-is.
|
|
761
|
+
return raw.startsWith("http") ? raw : "/" + raw.split("/").map(encodeURIComponent).join("/");
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* Pick the closest pre-defined CSS class for the requested aspect ratio. We
|
|
765
|
+
* can't drive aspect-ratio from inline style (the sanitize schema strips it)
|
|
766
|
+
* so we offer a curated set covering the common photo/video/portrait shapes.
|
|
767
|
+
* `1` (square), `1.5` (3:2), `1.333` (4:3), `1.778` (16:9), `0.75` (3:4),
|
|
768
|
+
* `0.667` (2:3). Numeric strings like "1.5" and ratio strings like "3/2" or
|
|
769
|
+
* "3 / 2" are both accepted.
|
|
770
|
+
*/
|
|
771
|
+
function aspectRatioClass(value) {
|
|
772
|
+
const raw = String(value).trim();
|
|
773
|
+
let n;
|
|
774
|
+
const m = /^(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)$/.exec(raw);
|
|
775
|
+
if (m)
|
|
776
|
+
n = Number(m[1]) / Number(m[2]);
|
|
777
|
+
else
|
|
778
|
+
n = Number(raw);
|
|
779
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
780
|
+
return "";
|
|
781
|
+
const buckets = [
|
|
782
|
+
[1, "bases-card-cover-1x1"],
|
|
783
|
+
[1.5, "bases-card-cover-3x2"],
|
|
784
|
+
[1.333, "bases-card-cover-4x3"],
|
|
785
|
+
[1.778, "bases-card-cover-16x9"],
|
|
786
|
+
[0.75, "bases-card-cover-3x4"],
|
|
787
|
+
[0.667, "bases-card-cover-2x3"],
|
|
788
|
+
];
|
|
789
|
+
let best = buckets[0];
|
|
790
|
+
for (const b of buckets) {
|
|
791
|
+
if (Math.abs(b[0] - n) < Math.abs(best[0] - n))
|
|
792
|
+
best = b;
|
|
793
|
+
}
|
|
794
|
+
return best[1];
|
|
795
|
+
}
|
|
796
|
+
// Mirror the slugify in build.ts without taking a dependency on the renderer's
|
|
797
|
+
// slug.ts (which imports from a sibling module). Same algorithm.
|
|
798
|
+
function slugifySimple(s) {
|
|
799
|
+
return s
|
|
800
|
+
.normalize("NFKD")
|
|
801
|
+
.replace(/[̀-ͯ]/g, "")
|
|
802
|
+
.toLowerCase()
|
|
803
|
+
.replace(/\.md$/i, "")
|
|
804
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
805
|
+
.replace(/^-+|-+$/g, "");
|
|
806
|
+
}
|
|
514
807
|
function valueForColumn(id, row, context) {
|
|
515
808
|
// file.name renders as a link to the page.
|
|
516
809
|
if (id === "file.name" || id === "file.basename") {
|
|
@@ -553,6 +846,8 @@ function columnLabel(id, doc) {
|
|
|
553
846
|
return explicit;
|
|
554
847
|
if (id.startsWith("note."))
|
|
555
848
|
return id.slice(5);
|
|
849
|
+
if (id.startsWith("formula."))
|
|
850
|
+
return id.slice(8);
|
|
556
851
|
if (id.startsWith("file.")) {
|
|
557
852
|
const tail = id.slice(5);
|
|
558
853
|
return tail.charAt(0).toUpperCase() + tail.slice(1);
|