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? — what was actually happening to your notes
What's New? — the short list
Wikilinks: the quotes are load-bearing — counterintuitive, and it bit us
Under the hood — helpers, judgment calls, the test suite
Also in this ship: the toolchain — Obsidian 1.13's
Plugin.settings, why TypeScript 7 is a no,minAppVersiondisciplineWhat's Next — what's still open
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:
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-intelligencestays 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:
// 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:
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).
Wikilinks: the quotes are load-bearing
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:
| YAML | Parses 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:
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:
| Helper | Job |
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:
// 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 usedeight dead imports in our
obsidian.d.tsaugmentation — but note the import line itself is load-bearing: it's what makes the file a module, which is what makesdeclare 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 namedconstructorrather 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.