metafetch

A cosmetic quoting bug was sitting on top of silent data loss

Metafetch was wrapping every tag in quotes. Annoying, but harmless. Chasing it turned up two bugs underneath that weren't harmless at all — one of which was quietly destroying inline tag arrays on every fetch.

Why Care?

You run a metadata fetch on a note. Metafetch's job is to add a handful of og_ fields — title, image, description. It has no business touching anything else in your frontmatter.

Except it rewrites the whole block. Every write path reads the entire frontmatter, parses it into an object, and re-serializes all of it. So keys metafetch never asked about — tags, authors, related — come back in metafetch's house style whether you wanted that or not.

The reported symptom was cosmetic: your tags came back wrapped in quotes. - ai became - "ai". Obsidian still reads it, nothing breaks, but every fetch churns the file and muddies the diff.

The reported symptom was the pleasant version of the problem. Underneath it, if your tags happened to be written inline — tags: [augmented-intelligence, ai], a perfectly ordinary way to write them — metafetch had no parse case for that shape at all. It fell through to the string branch and came back as:

YAML
tags: "[augmented-intelligence, ai]"

That is no longer an array. It's one string that looks like an array. Obsidian's tag pane loses the note, and nothing anywhere tells you it happened. Silent, on every fetch, since the beginning.

What's New?

  • Tags come back the way you wrote them. Array items are quoted only when YAML actually requires it. - augmented-intelligence stays bare.

  • Inline arrays survive. tags: [a, b] now parses as an array instead of being flattened into a quoted string. It gets normalized to block style on the way out.

  • Wikilinks keep their quotes — deliberately, because they need them. More on why below; it's the counterintuitive part.

  • A stray---- bug in the error path is fixed. One regex was missing its ^ anchor and could mangle the body of a note that had no frontmatter.

  • Metafetch has tests now. Sixteen of them, no framework, no new dependency, wired to pnpm test.

The rule that was right, in a place it was wrong

The quoting came from a genuinely good decision. Here's the comment that's been sitting above it:

TS
// Always double-quote string values. URLs (og_image, og_favicon, etc.)
// routinely contain ?, =, +, (, ), # and other YAML-unsafe chars; quoting
// unconditionally is simpler and safer than enumerating every risky case.

That's correct. Metafetch writes URLs into frontmatter constantly, URLs are full of characters YAML treats as syntax, and blanket-quoting them beats maintaining a list of everything that could bite you.

Then the array branch was written to match it, one function down:

TS
const items = value.map(item => {
  const escaped = String(item).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
  return `  - "${escaped}"`;   // ← unconditional
}).join('\n');

Consistency was the motive, and it's usually the right instinct. But a bare slug like augmented-intelligence was never at risk of anything. The rule got applied where the danger it guards against doesn't exist.

The fix keeps the scalar rule exactly as it was and makes item quoting conditional — quote when YAML needs it, which means: empty, padded with whitespace, opening with an indicator character, containing : or # or a comma, multiline, or a bare form that would change type when read back (true, no, null, numbers, dates).

Here's the one that surprised us, and the reason we're writing it down.

Quoting looks like the thing that would break an Obsidian wikilink. The instinct — ours too — is that authors: - "[[Ada Lovelace]]" is over-quoted and the quotes should come off.

They should not. Run it through js-yaml, which is the parser Obsidian itself uses:

YAMLParses to
- [[Ada Lovelace]][[["Ada Lovelace"]]] — a doubly-nested array
- "[[Ada Lovelace]]"["[[Ada Lovelace]]"] — the string you wanted

[ and ] are YAML flow-sequence indicators. Unquoted, [[Ada Lovelace]] is a sequence containing a sequence containing the scalar Ada Lovelace — the link is gone as a string before Obsidian's link resolver ever gets a look at it. That's why Obsidian's own "add link to property" UI writes the quotes for you.

So the new predicate's leading-indicator check is doing real work here, not cosmetic work. It catches the [, keeps every wikilink quoted, and still lets - ai go bare. Alias ([[Doc|Alias]]) and heading ([[Doc#Section]]) forms ride along on the same rule.

There's a small accidental virtue in metafetch's hand-rolled parser worth keeping: it's lenient on the way in. It reads an unquoted wikilink as a plain string, so a note that was already missing its quotes gets them added on the next fetch. Metafetch repairs that class of file instead of compounding it.

The fix that almost broke the thing it was next to

Teaching the parser about inline arrays is a three-line change. The obvious version:

TS
if (value.startsWith('[') && value.endsWith(']')) {
  // parse as a flow sequence
}

That is also, of course, an exact description of [[Some Note]].

The first draft shipped that check, and it turned related: [[Some Note]] into related: / - "[Some Note]" — a link shredded into a one-item array of garbage. It was caught immediately, because by then there was a test asserting wikilink scalars survive round-tripping. It would not have been caught by reading the diff; it looked completely reasonable.

The branch now carries an isWikilink() guard. The near-miss is the argument for the tests more than any of the fixes are.

Under the hood

Everything lives in src/utils/yamlFrontmatter.ts, which is hand-rolled on purpose — no YAML library, so the plugin bundle stays small and the quoting behavior stays fully under our control. Four new helpers:

HelperJob
needsYamlQuoting()Is a quote required for correctness here, or just habit?
isWikilink()Guards the flow-sequence branch against [[Note]]
splitFlowSequence()Splits [a, "b, c"] on commas outside quotes
unquoteScalar()The unquote-and-unescape logic that was copy-pasted in two branches

One judgment call worth flagging: an item containing a comma. - a, b is technically valid bare YAML in block context — js-yaml reads it as the string "a, b", so a strict reading says leave it alone. We quote it anyway. It's ambiguous to a human reading the file and it breaks the moment anything reflows that list into flow style.

The new test file esbuild-bundles the util into a temp dir and imports it, so there's no framework and nothing added to package.json's dependencies. Each of the sixteen cases asserts four things: the parsed object, the emitted text, idempotence — re-parsing our own output has to be a fixed point, which is what actually stops repeated fetches from churning your files — and agreement with js-yaml when it happens to be installed, skipped gracefully when it isn't.

Also in this ship: the toolchain, and three things we couldn't upgrade

Since we were in here anyway, we swept the dependencies. Most of it is unremarkable — @typescript-eslint to 8.67.0, eslint to 10.8.1, esbuild to 0.28.2. Three of them were more interesting than a version number, and one of them is worth knowing about if you maintain an Obsidian plugin.

Obsidian 1.13 quietly added Plugin.settings

Bumping the obsidian types from 1.12.3 to 1.13.1 broke the build:

main.ts(11,5): error TS2612: Property 'settings' will overwrite the base
property in 'Plugin'.

Obsidian 1.13.0 added settings?: unknown to the Plugin base class, with docs telling subclasses to "declare a concrete type on your subclass to type it." Almost every plugin in the wild already has a settings field, so almost every plugin will hit this. The fix is one keyword:

TS
// before
settings!: MetafetchSettings;
// after
declare settings: MetafetchSettings;

declare makes it a type-only refinement of the inherited property. Without it, TypeScript emits a real field declaration that clobbers the base with undefined at construction — so this is not just a way to quiet the error, it's the correct shape.

We also stopped shipping "obsidian": "latest"

obsidian was in dependencies, pinned to the literal string "latest". Two problems. It's external in our esbuild config and never enters the bundle, so it's build-time-only and belongs in devDependencies (which is where Obsidian's own sample plugin puts it). And "latest" means any fresh install can silently jump API versions — the opposite of what you want from the one dependency that defines your compatibility surface. Now ^1.13.1, in devDependencies.

minAppVersion stays at 1.8.10, deliberately

Tempting to bump it after compiling against the 1.13 API. Don't — and the reason is worth spelling out, because versions.json is the least-understood file in a plugin repo.

It maps plugin version → minAppVersion, and Obsidian serves each user the newest plugin version whose minAppVersion their app satisfies. So raising minAppVersion doesn't document a requirement, it freezes every user below that Obsidian version at their last compatible release. Metafetch calls no 1.13-only API — the declare fix above is purely compile-time — so bumping it would strand 1.8–1.12 users to buy nothing.

Compiling against newer types is not the same as requiring a newer app.

TypeScript 7: no, and not for the reason you'd guess

TypeScript 7.0.2 is out and our source typechecks clean under it. Obsidian's own obsidian.d.ts typechecks clean under it too, with skipLibCheck off. The blocker is downstream:

$ node -e "console.log(typeof require('typescript').createProgram)"
undefined

TypeScript 7 is the native Go port. node_modules/typescript/lib/ holds tsc.js and getExePath.js and not much else — the JS compiler API is gone. typescript-eslint needs it for type-aware linting, and says so in its peer range: typescript: ">=4.8.4 <6.1.0". Not untested — declared unsupported.

6.0.3 is the ceiling until typescript-eslint ships TS 7 support.

Worth flagging a trap we walked into: our build script runs tsc -noEmit -skipLibCheck, and that flag skips obsidian.d.ts entirely. A green build was not evidence the API surface was compatible. We re-ran everything without it before believing any of the above.

@types/node 26 exists, but nothing here runs it

Node 26 shipped 2026-05-05 and is at 26.7.0 — but it's in the "Current" phase, not LTS until 2026-10-28. More to the point, "Node version" means two unrelated things for an Obsidian plugin. The runtime one is Electron's bundled Node, and it's moot here because metafetch imports zero node: modules. The build one is what actually matters, and release.yml pins node-version: 22.

So @types/node@26 was describing a runtime neither CI nor any dev machine uses, and since tsconfig.json only includes **/*.ts, the two files that do import node: modules aren't typechecked anyway. It was completely inert — neither breaking nor verified. Back to ^22, matching CI. Revisit in late October.

ESLint had silently stopped running

The repo had a .eslintrc. ESLint 9 dropped it as the default and ESLint 10 removed it. So lint wasn't failing — it was a no-op, and had been for a while. The moment a flat config went in, 61 findings appeared.

eslint.config.mjs now carries every rule from the old config, in three tiers: type-aware linting for **/*.ts against tsconfig, Node globals and untyped rules for **/*.mjs (build and test scripts aren't in tsconfig's include, and aiming a typed rule at a file outside the program is a hard error, not a finding), and ignores for the bundle.

22 findings were autofixable. The other 12 were all real:

  • two catch (error) blocks where the binding was never used

  • eight dead imports in our obsidian.d.ts augmentation — but note the import line itself is load-bearing: it's what makes the file a module, which is what makes declare module 'obsidian' an augmentation rather than a declaration that replaces Obsidian's types wholesale. The names were dead; the import is not. There's a comment there now so nobody finishes the job.

  • two interface Setting { constructor(...) } declarations, which declare a method literally named constructor rather than a construct signature. They never did what they looked like.

0 errors now. The 27 remaining warnings are all no-explicit-any, which was set to warn on purpose — left as a visible backlog rather than papered over.

What's Next

One known bug is deliberately still open: nested mappings get flattened. A site: key with indented children comes back as site: [] with the children promoted to top-level keys. Rarer than tags in our vaults, but a total loss rather than a cosmetic one when it hits.

That one isn't a quoting question, it's a parser-architecture question — either teach the parser to capture nested blocks verbatim and re-emit them byte-for-byte, or adopt a real YAML library and re-open every quoting decision that made this file worth hand-rolling. It gets its own change and its own regression suite.

Two smaller threads left hanging on purpose. src/types/obsidian.d.ts looks vestigial — it re-declares Setting, Editor, and TFile members that the real obsidian types package now provides, and probably predates having that package as a proper dependency. Deleting it may well be right, but that's a types-surface change, not lint cleanup. And isDesktopOnly: true in the manifest looks over-restrictive now that every fetch path goes through Obsidian's requestUrl, which works fine on mobile.

The full audit — all three bugs, the call-site inventory that explains why metafetch touches tags at all, and the reasoning behind each fix — is written up in context-v/issues/Metafetch-Wraps-Tags-Array-Items-In-Quotes.md in the parent content-farm repo.