@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/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 vault's accent colour with
9
- * a single uppercase letter centred on it.
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
- return wrapPngAsIco(png, ICON_SIZE);
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(accent)}"/>
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(fg)}">${escText(letter)}</text>
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.
@@ -1 +1 @@
1
- {"version":3,"file":"favicon.js","sourceRoot":"","sources":["../src/favicon.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"favicon.js","sourceRoot":"","sources":["../src/favicon.ts"],"names":[],"mappings":"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"}
@@ -1,27 +1,37 @@
1
- // Minimum-viable Obsidian Bases support.
1
+ // Obsidian Bases support.
2
2
  //
3
- // Parses ```base / ```bases code fences inside markdown, evaluates the
4
- // query against the variant's visible page set, and emits a sortable +
5
- // filterable HTML table.
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
- // Supported (v1):
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: [{ type: table, name?, limit?, order?: [identifiers] }]
10
- // - properties: { <id>: { displayName? } }
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 (TODO):
20
- // - .base standalone files (only inline code fences for now)
21
- // - formulas, summaries, groupBy
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
- function renderBase(source, context, warnings) {
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
- const views = doc.views && doc.views.length > 0 ? doc.views : [{ type: "table" }];
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
- if (view.type !== "table") {
66
- if (warnings)
67
- warnings.push({ kind: "broken-link", target: `bases view type '${view.type}' (only 'table' is supported)` });
68
- blocks.push(errorBlock(`Bases: view type '${esc(view.type)}' is not supported (use 'table').`));
69
- continue;
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 === "object" && !Array.isArray(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
- // Pre-compute every row's display + raw value per column.
488
- const tbl = rows.map((row) => columns.map((id) => valueForColumn(id, row, context)));
489
- // Default sort: by first column, ascending.
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
- tbl.length = Math.min(tbl.length, view.limit);
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);