perf: cut redundant per-item work on map + table load

Map grid: the per-hex render loop called getTerrainFromFile +
getIconOverrideFromFile + getGmIconsFromFile, each re-running
getAbstractFileByPath + getFileCache — 3 identical cache lookups per hex
(~11.5k redundant ops on a 3843-hex map). Fetch frontmatter once and feed
three new pure extractors (terrainFromFm/iconOverrideFromFm/gmIconsFromFm).
Reuse the bg-image layer across renders (bgLayerEl/bgLayerSrc) so
renderGrid()'s viewportEl.empty() no longer re-decodes the image each time.
Tag painted hexes with .duckmage-hex-painted (setHexColor) and switch the
bg-image empty-hex rule to a class selector instead of a slower
:not([style*="background-color"]) attribute-substring match.

Table view: memoise the name→entry palette Map per region instead of
rebuilding it per row, and re-filter only the rows just filled in each batch
(applyFilters(rows?)) instead of re-scanning all rows after every batch.

Adds dev/bg-hex-recalc-bench + unit tests for the new pure extractors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
isaprettycoolguy@protonmail.com 2026-06-30 07:01:23 -04:00
parent e74bc0d6f6
commit b69131f16d
7 changed files with 340 additions and 36 deletions

View file

@ -0,0 +1,125 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<style>
body { margin: 0; font-family: monospace; }
.viewport { position: relative; will-change: transform; }
.bg-layer {
position: absolute; top: 0; left: 0; transform-origin: 0 0;
transform: scale(4.84); z-index: 0; pointer-events: none;
}
.bg-layer img { display: block; max-width: none; max-height: none; }
.grid { position: relative; display: flex; flex-direction: row; align-items: flex-start; }
.col { display: flex; flex-direction: column; margin-right: -1.1em; }
.col-offset { margin-top: 1.9em; }
.hex {
width: 4.4em; height: 3.81em; font-size: 8px;
clip-path: polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%);
background-color: #2a4d3a;
position: relative;
}
.hex:hover::after { content: ""; position: absolute; inset: 0; background: rgba(255,255,255,.2); }
/* ── The two competing rules — only ONE is active at a time ── */
/* A: attribute-substring selector (current code) */
.mode-attr.has-bg-image .hex:not([style*="background-color"]) { background-color: transparent; }
/* B: class selector (proposed) */
.mode-class.has-bg-image .hex:not(.has-terrain) { background-color: transparent; }
</style>
</head>
<body>
<div id="root"></div>
<script>
const COLS = 63, ROWS = 61; // chult map dimensions
const TERRAIN_FRACTION = 0.55; // ~half painted, rest show the image
const HEX_COUNT = COLS * ROWS;
const root = document.getElementById("root");
// A 1107×1499 image analogue (canvas data URL — comparable paint cost, no fetch).
const c = document.createElement("canvas");
c.width = 1107; c.height = 1499;
const ctx = c.getContext("2d");
const grd = ctx.createLinearGradient(0, 0, 1107, 1499);
grd.addColorStop(0, "#3b5"); grd.addColorStop(1, "#247");
ctx.fillStyle = grd; ctx.fillRect(0, 0, 1107, 1499);
const IMG_URL = c.toDataURL("image/png");
let painted = 0;
// Build the full map subtree once, fresh — this is what renderGrid() does on
// open: empty() the viewport then recreate everything. `config`:
// "none" → no bg image, no has-bg-image class (the "fast" baseline)
// "attr" → bg image + has-bg-image + the current [style*=…] empty-hex rule
// "class" → bg image + has-bg-image + the proposed .has-terrain rule
function build(config) {
root.className = "";
if (config !== "none") root.classList.add("has-bg-image", config === "attr" ? "mode-attr" : "mode-class");
root.replaceChildren();
const viewport = document.createElement("div");
viewport.className = "viewport";
if (config !== "none") {
const bg = document.createElement("div");
bg.className = "bg-layer";
const img = document.createElement("img");
img.src = IMG_URL;
bg.appendChild(img);
viewport.appendChild(bg);
}
const grid = document.createElement("div");
grid.className = "grid";
painted = 0;
for (let x = 0; x < COLS; x++) {
const col = document.createElement("div");
col.className = "col" + (x % 2 ? " col-offset" : "");
for (let y = 0; y < ROWS; y++) {
const hex = document.createElement("div");
hex.className = "hex";
if (((x * 31 + y * 17) % 100) / 100 < TERRAIN_FRACTION) {
hex.style.backgroundColor = "#2a4d3a"; // inline → matches [style*=…]
hex.classList.add("has-terrain"); // class → matches :not(.has-terrain)
painted++;
}
col.appendChild(hex);
}
grid.appendChild(col);
}
viewport.appendChild(grid);
root.appendChild(viewport);
}
// Time a full rebuild + forced style/layout flush — the open-time cost.
function bench(config, iters) {
for (let i = 0; i < 3; i++) { build(config); void document.body.offsetHeight; } // warm
const samples = [];
for (let i = 0; i < iters; i++) {
const t0 = performance.now();
build(config);
void document.body.offsetHeight; // force recalc + layout
samples.push(performance.now() - t0);
}
samples.sort((a, b) => a - b);
return {
median: samples[Math.floor(samples.length / 2)],
mean: samples.reduce((s, v) => s + v, 0) / samples.length,
p95: samples[Math.floor(samples.length * 0.95)],
};
}
window.__runBench = (iters = 40) => {
const none1 = bench("none", iters);
const attr1 = bench("attr", iters);
const cls1 = bench("class", iters);
const none2 = bench("none", iters);
const attr2 = bench("attr", iters);
const cls2 = bench("class", iters);
const avg = (a, b, k) => (a[k] + b[k]) / 2;
const pack = (a, b) => ({ median: avg(a, b, "median"), mean: avg(a, b, "mean"), p95: avg(a, b, "p95") });
return {
hexCount: HEX_COUNT, painted, empty: HEX_COUNT - painted, iters,
none: pack(none1, none2), attr: pack(attr1, attr2), cls: pack(cls1, cls2),
};
};
document.title = "ready";
</script>
</body>
</html>

View file

@ -0,0 +1,32 @@
#!/usr/bin/env node
/** Headless benchmark: isolates the style-recalc cost of the current
* `:not([style*="background-color"])` attribute-substring selector vs a
* class-based `:not(.has-terrain)` selector, at the real chult map's hex
* count (63×61). A :hover state change over the grid forces exactly this
* recalc, so this measures the per-mousemove jank a bg image introduces.
*
* Relative A/B only absolute ms will differ from the user's machine, but
* the ratio between the two selectors is what we're after.
*/
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { withBrowser } from "/mnt/c/Users/markr/work/tools/chromium-driver/lib/browser.mjs";
const here = path.dirname(fileURLToPath(import.meta.url));
const sandbox = path.join(here, "bg-hex-recalc-bench.html");
const result = await withBrowser(async ({ page }) => {
await page.goto(pathToFileURL(sandbox).href, { waitUntil: "networkidle0" });
await page.waitForFunction(() => document.title === "ready", { timeout: 10000 });
return page.evaluate(() => window.__runBench(40));
});
const f = (n) => n.toFixed(2).padStart(8);
console.log(`\nFull grid rebuild + layout flush (the renderGrid open-time cost)`);
console.log(`hexes=${result.hexCount} painted=${result.painted} empty=${result.empty} iters=${result.iters}\n`);
console.log(` median mean p95 (ms)`);
console.log(`no bg image ${f(result.none.median)} ${f(result.none.mean)} ${f(result.none.p95)} baseline`);
console.log(`bg + attr selector ${f(result.attr.median)} ${f(result.attr.mean)} ${f(result.attr.p95)} (current)`);
console.log(`bg + class selector ${f(result.cls.median)} ${f(result.cls.mean)} ${f(result.cls.p95)} (proposed)`);
console.log(`\nbg overhead (attr vs none): ${(result.attr.median - result.none.median).toFixed(2)} ms/render`);
console.log(`selector saving (attr→class): ${(result.attr.median - result.cls.median).toFixed(2)} ms/render\n`);

View file

@ -59,9 +59,33 @@ export function getFrontMatter(app: App, path: string) {
return frontmatter;
}
/**
* Pure extractors that read already-fetched frontmatter. The hex-grid render
* loop fetches frontmatter ONCE per hex (getAbstractFileByPath + getFileCache)
* and feeds it to all three instead of calling the three `*FromFile` helpers
* below, which each re-fetch the same cache (3× the lookups per hex, ~11.5k
* redundant ops on a 3843-hex map).
*/
export function terrainFromFm(fm: Frontmatter | null | undefined): string | null {
return typeof fm?.terrain === "string" ? fm.terrain : null;
}
export function iconOverrideFromFm(fm: Frontmatter | null | undefined): string | null {
return typeof fm?.icon === "string" ? fm.icon : null;
}
export function gmIconsFromFm(fm: Frontmatter | null | undefined): string[] {
if (!fm) return [];
const arr = fm["gm-icons"];
if (Array.isArray(arr)) {
return arr.filter((v): v is string => typeof v === "string");
}
const legacy = fm["gm-icon"];
return typeof legacy === "string" ? [legacy] : [];
}
export function getTerrainFromFile(app: App, path: string): string | null {
const terrain = getFrontMatter(app, path)?.terrain;
return typeof terrain === "string" ? terrain : null;
return terrainFromFm(getFrontMatter(app, path));
}
export async function setTerrainInFile(
@ -224,8 +248,7 @@ export async function setRegionStyleInFile(
}
export function getIconOverrideFromFile(app: App, path: string): string | null {
const icon = getFrontMatter(app, path)?.icon;
return typeof icon === "string" ? icon : null;
return iconOverrideFromFm(getFrontMatter(app, path));
}
export async function setIconOverrideInFile(
@ -263,14 +286,7 @@ export function getGmIconFromFile(app: App, path: string): string | null {
* singular key `gm-icon` if present so existing notes Just Work.
*/
export function getGmIconsFromFile(app: App, path: string): string[] {
const fm = getFrontMatter(app, path);
if (!fm) return [];
const arr = fm["gm-icons"];
if (Array.isArray(arr)) {
return arr.filter((v): v is string => typeof v === "string");
}
const legacy = fm["gm-icon"];
return typeof legacy === "string" ? [legacy] : [];
return gmIconsFromFm(getFrontMatter(app, path));
}
/**

View file

@ -4,7 +4,10 @@ import { normalizeFolder, getIconUrl, createIconEl } from "../utils";
import {
getTerrainFromFile,
getIconOverrideFromFile,
getGmIconsFromFile,
getFrontMatter,
terrainFromFm,
iconOverrideFromFm,
gmIconsFromFm,
setGmIconsInFile,
setTerrainInFile,
setIconOverrideInFile,
@ -178,6 +181,10 @@ export class HexMapView extends ItemView {
private pendingZoomPivot: { cx: number; cy: number } | null = null;
private zoomFrameId: number | null = null;
private viewportEl: HTMLElement | null = null;
// Cached bg-image layer reused across renders (see renderBackgroundImage) so
// renderGrid()'s viewportEl.empty() doesn't force an image re-decode each time.
private bgLayerEl: HTMLElement | null = null;
private bgLayerSrc: string | null = null;
private drawingMode:
| "path"
| "terrain"
@ -2037,16 +2044,42 @@ export class HexMapView extends ItemView {
const bg = map.backgroundImage;
viewportEl.toggleClass("has-bg-image", !!bg?.path);
viewportEl.toggleClass("is-bg-calibrating", this.bgCalibrating && !!bg?.path);
if (!bg?.path) return;
if (!bg?.path) {
this.bgLayerEl = null;
this.bgLayerSrc = null;
return;
}
const file = this.app.vault.getAbstractFileByPath(bg.path);
if (!(file instanceof TFile)) return;
if (!(file instanceof TFile)) {
this.bgLayerEl = null;
this.bgLayerSrc = null;
return;
}
const src = this.app.vault.adapter.getResourcePath(bg.path);
// Reuse the cached layer+img across renders when the image is unchanged.
// renderGrid() calls viewportEl.empty() every render, which detaches the
// layer but leaves our JS reference (and its already-decoded bitmap) alive.
// Re-attaching it avoids recreating the <img> and forcing the browser to
// re-decode + re-rasterise the (large, scaled) image on every single
// render — the churn that makes editing a bg-image map sluggish. We skip
// reuse during calibration so its drag/wheel handlers are wired exactly as
// before (fresh layer each render, as the old code did).
if (this.bgLayerEl && this.bgLayerSrc === src && !this.bgCalibrating) {
viewportEl.prepend(this.bgLayerEl);
this.applyBgLayerVars(this.bgLayerEl, bg);
return;
}
const layer = viewportEl.createDiv({ cls: "duckmage-bg-image-layer" });
this.applyBgLayerVars(layer, bg);
const img = layer.createEl("img", { cls: "duckmage-bg-image" });
img.src = this.app.vault.adapter.getResourcePath(bg.path);
img.src = src;
img.alt = "";
img.draggable = false;
this.bgLayerEl = layer;
this.bgLayerSrc = src;
if (this.bgCalibrating) this.attachCalibrationHandlers(layer, map);
}
@ -2818,9 +2851,15 @@ export class HexMapView extends ItemView {
const addHex = (parent: HTMLElement, x: number, y: number) => {
const path = folder ? `${folder}/${x}_${y}.md` : `${x}_${y}.md`;
const exists = existingHexPaths.has(path);
// Fetch this hex's frontmatter ONCE; terrain/icon/gm-icons all derive
// from it. Calling getTerrainFromFile + getIconOverrideFromFile +
// getGmIconsFromFile separately would re-run getAbstractFileByPath +
// getFileCache three times per hex (~11.5k redundant lookups on a
// 3843-hex map).
const fm = getFrontMatter(this.app, path);
const terrainKey = terrainOverrides?.has(path)
? terrainOverrides.get(path)!
: getTerrainFromFile(this.app, path);
: terrainFromFm(fm);
const terrainEntry = terrainKey != null ? paletteByName.get(terrainKey) : undefined;
const hexEl = parent.createDiv({
@ -2829,11 +2868,11 @@ export class HexMapView extends ItemView {
});
hexEl.tabIndex = -1;
if (terrainEntry?.color) hexEl.style.backgroundColor = terrainEntry.color;
this.setHexColor(hexEl, terrainEntry?.color);
const iconOverride = iconOverrides?.has(path)
? iconOverrides.get(path)!
: getIconOverrideFromFile(this.app, path);
: iconOverrideFromFm(fm);
if (iconOverride) {
// Render terrain icon as hidden fallback — shown by CSS when overrides are off
if (terrainEntry?.icon) {
@ -2874,7 +2913,7 @@ export class HexMapView extends ItemView {
// list lands on the dataset immediately, no race.
const gmIcons = gmIconsOverrides?.has(path)
? gmIconsOverrides.get(path)!
: getGmIconsFromFile(this.app, path);
: gmIconsFromFm(fm);
if (gmIcons.length > 0) hexEl.dataset.gmIcons = JSON.stringify(gmIcons);
if (this.selectedHex?.x === x && this.selectedHex?.y === y)
@ -3232,7 +3271,7 @@ export class HexMapView extends ItemView {
`[data-x="${hx}"][data-y="${hy}"]`,
);
if (hexEl) {
hexEl.style.backgroundColor = entry?.color ?? "";
this.setHexColor(hexEl, entry?.color);
hexEl.querySelector(".duckmage-hex-icon")?.remove();
hexEl.querySelector(".duckmage-hex-dot")?.remove();
if (entry?.icon) {
@ -3567,6 +3606,24 @@ export class HexMapView extends ItemView {
// writes: the in-flight one and then the final value. No writes are lost; no
// stale intermediate value can overwrite a newer one.
/**
* Set (or clear) a hex cell's terrain background color, keeping the
* `duckmage-hex-painted` marker class in sync. The bg-image stylesheet uses
* that class rather than a `:not([style*="background-color"])`
* attribute-substring selector, which is measurably slower for the engine to
* match across the thousands of hexes on a large map to decide which empty
* hexes fall transparent over the image.
*/
private setHexColor(hexEl: HTMLElement, color: string | undefined | null): void {
if (color) {
hexEl.style.backgroundColor = color;
hexEl.addClass("duckmage-hex-painted");
} else {
hexEl.style.removeProperty("background-color");
hexEl.removeClass("duckmage-hex-painted");
}
}
private applyTerrainToHexEl(
hexEl: HTMLElement,
terrain: string | null,
@ -3574,7 +3631,7 @@ export class HexMapView extends ItemView {
const palette = this.plugin.getMapPalette(this.activeMapName);
const entry =
terrain != null ? palette.find((p) => p.name === terrain) : undefined;
hexEl.style.backgroundColor = entry?.color ?? "";
this.setHexColor(hexEl, entry?.color);
hexEl.querySelector(".duckmage-hex-icon")?.remove();
hexEl.querySelector(".duckmage-hex-dot")?.remove();
if (entry?.icon) {

View file

@ -5,6 +5,7 @@ import {
VIEW_TYPE_HEX_TABLE,
VIEW_TYPE_RANDOM_TABLES,
} from "../constants";
import type { TerrainColor } from "../types";
import { getAllSectionData } from "../sections";
import { getTerrainFromFile } from "../frontmatter";
import { normalizeFolder, makeTableTemplate } from "../utils";
@ -39,6 +40,11 @@ export class HexTableView extends ItemView {
private scrollEl: HTMLElement | null = null;
private updateTimers = new Map<string, ReturnType<typeof setTimeout>>();
private loadGeneration = 0;
// Memoised name→entry palette maps, keyed by region. fillRow runs once per
// hex per load (skeleton + fill batch) so rebuilding this Map inline cost
// O(rows × palette) Map insertions; memoising makes it O(palette) per region.
// Cleared at the start of every loadTable so palette edits take effect.
private paletteMapCache = new Map<string, Map<string, TerrainColor>>();
// Sort state
private sortPrimary: "x" | "y" = "x";
@ -361,6 +367,7 @@ export class HexTableView extends ItemView {
async loadTable(): Promise<void> {
if (!this.scrollEl) return;
const gen = ++this.loadGeneration;
this.paletteMapCache.clear();
this.scrollEl.empty();
this.scrollEl.createSpan({
@ -477,12 +484,15 @@ export class HexTableView extends ItemView {
slice.map((f) => getAllSectionData(this.app, f.path)),
);
if (gen !== this.loadGeneration) return false;
const batchRows: HTMLTableRowElement[] = [];
for (let j = 0; j < slice.length; j++) {
const { path, x, y, region } = slice[j];
const { text, links } = sectionData[j];
this.fillRow(rows[start + j], path, x, y, region, text, links);
batchRows.push(rows[start + j]);
}
this.applyFilters();
// Only the rows we just filled gained data — re-filter just those.
this.applyFilters(batchRows);
return true;
};
@ -555,12 +565,19 @@ export class HexTableView extends ItemView {
this.applyFilters();
}
private applyFilters(): void {
/**
* Re-evaluate row visibility against the current filters. Each row's
* visibility depends only on its own dataset, so a `rows` subset can be
* passed to filter just newly-filled rows `loadTable` does this after each
* fill batch so Phase 2 stays O(rows) total instead of O(rows × batches).
* Called with no argument (all rows) when the filter state itself changes.
*/
private applyFilters(rows?: Iterable<HTMLTableRowElement>): void {
if (!this.scrollEl) return;
const tbody = this.scrollEl.querySelector("tbody");
if (!tbody) return;
for (const tr of Array.from(tbody.rows)) {
for (const tr of rows ?? Array.from(tbody.rows)) {
const x = Number(tr.dataset.hexX);
const y = Number(tr.dataset.hexY);
const terrain = tr.dataset.terrain ?? "";
@ -594,6 +611,22 @@ export class HexTableView extends ItemView {
// ── Row rendering ─────────────────────────────────────────────────────────
/**
* Nameentry palette map for a region, memoised for the current load.
* First-wins on duplicate names matches HexMapView's `.find()` behaviour.
*/
private getPaletteMap(region: string): Map<string, TerrainColor> {
let m = this.paletteMapCache.get(region);
if (!m) {
m = new Map<string, TerrainColor>();
for (const p of this.plugin.getMapPalette(region)) {
if (!m.has(p.name)) m.set(p.name, p);
}
this.paletteMapCache.set(region, m);
}
return m;
}
private fillRow(
tr: HTMLTableRowElement,
path: string,
@ -605,13 +638,7 @@ export class HexTableView extends ItemView {
): void {
tr.empty();
const palette = this.plugin.getMapPalette(region);
// First-wins on duplicate-named palette entries — matches HexMapView's
// .find() behaviour.
const paletteMap = new Map<string, typeof palette[number]>();
for (const p of palette) {
if (!paletteMap.has(p.name)) paletteMap.set(p.name, p);
}
const paletteMap = this.getPaletteMap(region);
const terrainName = getTerrainFromFile(this.app, path);
const hasTown = (links.get("towns") ?? []).length > 0;

View file

@ -268,9 +268,12 @@
.duckmage-calibration-handle-se { bottom: -9px; right: -9px; cursor: nwse-resize; transform: scale(var(--duckmage-cal-scale, 1)); }
/* When a background image is present, hexes without a terrain become visually
transparent so the underlying image shows through. Hexes with a terrain
color set inline (via style="background-color: …") render normally. */
.duckmage-hex-map-viewport.has-bg-image .duckmage-hex:not([style*="background-color"]) {
transparent so the underlying image shows through. Hexes with a terrain are
tagged `.duckmage-hex-painted` (see setHexColor) and render normally. A class
selector is used here rather than a `:not([style*="background-color"])`
attribute-substring match because the engine matches it far more cheaply
across the thousands of hexes on a large map. */
.duckmage-hex-map-viewport.has-bg-image .duckmage-hex:not(.duckmage-hex-painted) {
background-color: transparent;
}

View file

@ -1,7 +1,7 @@
import { describe, it, mock } from "node:test";
import expect from "expect";
import { TFile } from "obsidian";
import { getTerrainFromFile, setTerrainInFile, getIconOverrideFromFile, setIconOverrideInFile, getSubmapFromFile, setSubmapInFile } from "../src/frontmatter";
import { getTerrainFromFile, setTerrainInFile, getIconOverrideFromFile, setIconOverrideInFile, getSubmapFromFile, setSubmapInFile, terrainFromFm, iconOverrideFromFm, gmIconsFromFm } from "../src/frontmatter";
/** Build a minimal mock App backed by an in-memory string. */
function makeApp(filePath: string, initialContent: string) {
@ -359,3 +359,47 @@ describe("updateSubmapReferences", () => {
expect(getContent("world/hexes/map-a/1_1.md")).toContain("duckmage-submap: other-map");
});
});
// ── Pure frontmatter extractors (used by the hex-grid render loop, which
// fetches frontmatter once per hex and feeds it to all three) ──────────────
describe("terrainFromFm", () => {
it("returns the terrain string", () => {
expect(terrainFromFm({ terrain: "forest" } as any)).toBe("forest");
});
it("returns null for missing/non-string/nullish frontmatter", () => {
expect(terrainFromFm({ icon: "x.png" } as any)).toBeNull();
expect(terrainFromFm({ terrain: 3 } as any)).toBeNull();
expect(terrainFromFm(null)).toBeNull();
expect(terrainFromFm(undefined)).toBeNull();
});
});
describe("iconOverrideFromFm", () => {
it("returns the icon string", () => {
expect(iconOverrideFromFm({ icon: "castle.png" } as any)).toBe("castle.png");
});
it("returns null for missing/non-string/nullish frontmatter", () => {
expect(iconOverrideFromFm({ terrain: "forest" } as any)).toBeNull();
expect(iconOverrideFromFm({ icon: true } as any)).toBeNull();
expect(iconOverrideFromFm(null)).toBeNull();
});
});
describe("gmIconsFromFm", () => {
it("returns the gm-icons array, filtered to strings", () => {
expect(gmIconsFromFm({ "gm-icons": ["a.png", "b.png"] } as any)).toEqual(["a.png", "b.png"]);
expect(gmIconsFromFm({ "gm-icons": ["a.png", 2, null, "b.png"] } as any)).toEqual(["a.png", "b.png"]);
});
it("falls back to legacy single gm-icon", () => {
expect(gmIconsFromFm({ "gm-icon": "legacy.png" } as any)).toEqual(["legacy.png"]);
});
it("prefers gm-icons array over legacy gm-icon", () => {
expect(gmIconsFromFm({ "gm-icons": ["a.png"], "gm-icon": "legacy.png" } as any)).toEqual(["a.png"]);
});
it("returns [] for missing/nullish frontmatter", () => {
expect(gmIconsFromFm({} as any)).toEqual([]);
expect(gmIconsFromFm(null)).toEqual([]);
expect(gmIconsFromFm(undefined)).toEqual([]);
});
});