Move manifest.json/main.js to repo root (required by the review bot and BRAT), add styles.css to fix static-style-assignment lint errors, set up eslint-plugin-obsidianmd, and add a smoke test for parseGr. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
9.6 KiB
HANDOFF
Developer notes for anyone (including future me) picking this up.
What this is
A minimal Obsidian plugin that registers a markdown code block processor for the language gr and renders the block's content as an interactive force-directed SVG graph. No framework, no bundler, no runtime dependencies. The shipped plugin is three files: main.js, manifest.json, styles.css.
(There is now an npm-managed dev-tooling layer — eslint for linting and a plain-node smoke test — but nothing from it is bundled into or shipped with the plugin; see "Dev tooling" below. main.js is still hand-written, unbundled CommonJS.)
File map
manifest.json Obsidian plugin manifest (id: inline-graph), repo root
main.js Everything: parser, force simulation, SVG renderer, plugin class, repo root
styles.css CSS classes toggled from main.js (see "Dev tooling" re: why this exists), repo root
versions.json minAppVersion mapping, required if submitted to the community plugin store
package.json Dev tooling only (eslint, smoke test) — not part of the shipped plugin
eslint.config.mjs Lint config (eslint-plugin-obsidianmd's recommended rules)
test/ Plain-node smoke test for parseGr()
README.md, HANDOFF.md, LICENSE, .gitignore repo docs
manifest.json and main.js live at the repo root (moved here from a former inline-graph/ subfolder). This matches what BRAT and the community plugin store expect: the obsidian-releases review bot fetches manifest.json directly from the repo root via a raw GitHub URL on the default branch, and BRAT can install straight from the repo root too. GitHub release assets (manifest.json, main.js) are still what the Obsidian plugin installer and BRAT actually download at install time — see "Release process" below.
For manual installs, copy manifest.json and main.js into a folder named inline-graph inside Vault/.obsidian/plugins/ (the destination folder name is what matters to Obsidian, not the source repo layout) — see README.md for exact steps.
We deliberately chose "move" over "duplicate" (keeping a second copy under inline-graph/ in sync with the root copy) because a duplicate is a drift hazard: every future version bump would require remembering to update both copies, and a stale duplicate would fail store validation silently. A single root copy has no such failure mode.
main.js is plain CommonJS (require('obsidian'), module.exports). Obsidian loads it directly, which is why there is no build step. If you ever add npm dependencies you will need to introduce esbuild/rollup and bundle to main.js; until then, do not.
main.js structure
Three sections, top to bottom:
1. parseGr(source)
Regex-based, intentionally forgiving. Splits the block at the first [ into an edge part and a metadata part.
- Optional first-line directive
height=NNNis consumed before anything else and returned asheight(null if absent). It must be alone on the first line;height=anywhere else is treated as ordinary content. - Edge regex:
([A-Za-z0-9_]+)\s*(->|-)\s*([A-Za-z0-9_]+)applied globally;->marks a directed edge (the alternation tries->first, so ordering matters). Separators between pairs (;, newlines, whitespace) are never matched, so they need no handling. - Metadata regex:
id:{...}entries, withkey="value"pairs inside. - Every id seen in either section becomes a node. Defaults:
name= id,color= empty string (renderer falls back tovar(--interactive-accent)),text= empty (no tooltip). - Throws if zero nodes parse; the processor catches and renders the error message in a
<pre>.
Known limitation: ids are \w-only, so no spaces/CJK in ids (use name for display). Quotes inside text values are not escapable.
2. Renderer + simulation (render(container, nodes, edges))
- Builds one
<svg>(100% width; height = per-blockheightdirective or theHEIGHTconstant, 320px) plus an absolutely positioned tooltip<div>inside the block container. - A
<defs><marker>arrowhead is created per block with a randomized id (url(#id)resolves document-wide, so a fixed id would clash when a note has several graphs). Directed lines getmarker-end, anddraw()shortens their target endpoint byR + 3so the arrowhead sits at the circle's edge instead of underneath it. - Initial node positions on a ring to avoid degenerate overlaps.
- Simulation per tick:
- Pairwise repulsion, force
2500 / d²(constant inline) - Edge springs, rest length
90, stiffness0.04 - Weak centering pull
0.005 - Velocity damping
0.85, globalalphacooling*= 0.97, loop stops below0.005 - Positions clamped to the SVG bounds (extra 20px bottom margin for labels)
- Pairwise repulsion, force
- These constants are the tuning surface. If graphs look too cramped, raise repulsion or rest length.
- Drag: pointer events with
setPointerCapture, drags pin the node (fixed = true) and reheatalphato 0.3 so neighbors react. Coordinates are 1:1 with client pixels (no viewBox scaling), soclientX - rect.leftis safe. Do not add aviewBoxwithout also fixing the drag math. - Hover: grows circle radius 1.5x and positions the tooltip near the node, clamped to the right edge.
- The rAF loop checks
container.isConnectedeach frame and self-terminates when the block leaves the DOM, so no explicit teardown is needed. - Theming uses Obsidian CSS variables only:
--interactive-accent,--background-modifier-border,--text-muted,--background-primary,--text-normal. No hardcoded colors except user-provided ones.
3. Plugin class
registerMarkdownCodeBlockProcessor('gr', ...). Rendering is deferred one requestAnimationFrame because the container often has zero width at processor time; render measures container.clientWidth (floor 300).
Dev tooling (added for community-store submission)
npm install pulls in eslint, eslint-plugin-obsidianmd, and @typescript-eslint/parser as devDependencies — nothing here is bundled into main.js or shipped; it exists purely to run npm run lint locally. eslint-plugin-obsidianmd's recommended config is what pushed two real changes into main.js:
styles.cssexists now because the linter'sno-static-styles-assignmentrule (an error, not a warning) flags anyelement.style.x = '<literal>'. All the static/toggle-style assignments (position: relative,display: block/none, cursorgrab/grabbing) moved to CSS classes (inline-graph-container,inline-graph-svg,inline-graph-tooltip+.is-visible,inline-graph-node+.is-dragging) toggled viaclassList. Truly dynamic, per-frame values (tip.style.left/top, computed from node position) are untouched — those aren't literals and the rule doesn't flag them.require('obsidian')andmodule.exportsare declared as explicit globals formain.jsineslint.config.mjs, and@typescript-eslint/no-require-importsis turned off for that file. The base recommended config only adds Node/CommonJS globals whenmanifest.json'sisDesktopOnlyistrue; ours isfalse(this plugin works on mobile), butrequire/moduleare provided by Obsidian's own plugin loader on every platform, not just desktop — that's a gap in the linter's assumptions, not a reason to introduce a bundler.
npm run lint is standalone; there's no CI wiring beyond that (none existed before).
Things deliberately not done
- No settings tab. All tuning is constants in
main.js. - No click action on nodes.
Likely next features, in order of ask
- Click to open note: in the node setup, add a
clickhandler that callsapp.workspace.openLinkText(n.name, '')(or a dedicatedlink="..."property). Guard against firing after a drag (track movement distance on pointerup). ~10 lines.
Implemented in 0.2.0: directed edges (a->b) and the per-block height=NNN directive.
Release process (if submitting to the community plugin store)
- Bump
versioninmanifest.json(repo root) andpackage.json, and add the entry toversions.json. - Run
npm run lintandnpm test; both should be clean. - Create a GitHub release whose tag exactly matches the version (no
vprefix), attachingmanifest.json,main.js, andstyles.cssas release assets. - First-time only: PR your plugin entry to
obsidianmd/obsidian-releases(community-plugins.json). - Obsidian's review guidelines: https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines
Until then, BRAT installs work straight from the repo as long as releases carry all three files.
Testing
npm test runs test/parseGr.test.js, a dependency-free smoke test covering: full syntax (height directive + undirected/directed edges + metadata), edges-only with no metadata, an id that only appears in the metadata block, and the zero-nodes parse-error case.
main.js does require('obsidian') at module load time, so it can't be required as-is outside Obsidian, and the official obsidian npm package won't help — it ships only .d.ts type declarations with no runtime .js at all. The test works around this by temporarily intercepting Module._load so the single require('obsidian') call resolves to a trivial stub ({ Plugin: class {} }) while main.js loads, then restores normal resolution. parseGr itself is exposed as module.exports.parseGr (a static property on the exported Plugin subclass) purely for this test; Obsidian only ever uses the default export (the class itself) to instantiate the plugin, so this is invisible to it.
The renderer requires a DOM, so testing that remains manual in a vault. Keep a scratch note with a few gr blocks covering the same cases above, plus drag and hover interaction.