image-gin

Publishing readiness: ESLint enforcement, eliminate any, replace innerHTML, route logging through FileLogger, replace hand-rolled YAML parser, bump to 0.1.0

Summary

Prepping image-gin for submission to the Obsidian community plugin marketplace. The sister plugin cite-wide was rejected last week for any usage — the same automated ObsidianReviewBot that auto-rejected it runs on every submission PR with no appeal mechanism, and the same audit had to happen here.

Plan that drove this session: context-v/plans/2026-05-03_Assuring-Obsidian-Community-Plugin-Requirements.md. Reference rules verbatim from the bot: ../../cite-wide/context-v/reminders/Obsidian-Type-Safety.md.

This pass executed every blocking phase of that plan: Phase 1 (ESLint), Phase 4 (any elimination), Phase 4.3-B (YAML parser replacement), Phase 5 (innerHTML removal), Phase 6 (console.*FileLogger), plus the version bump to 0.1.0. The plugin should now pass ObsidianReviewBot cleanly.

1. ESLint tightened to match the review bot, and wired into pnpm build

Old state: .eslintrc (legacy eslintrc format, no longer supported by ESLint 10), no-explicit-any set to warn, ESLint not invoked by any build script. The bot would have surfaced 41+ violations during submission review — the worst possible time.

New state: flat-config eslint.config.mjs with the four bot-blocking rules at error:

  • @typescript-eslint/no-explicit-any

  • @typescript-eslint/no-unnecessary-type-assertion

  • @typescript-eslint/no-floating-promises

  • @typescript-eslint/no-base-to-string

Plus consistent-type-imports (auto-fixable) and no-unused-vars ({ args: 'none' } to keep modal callback signatures alive). Type-aware rules (no-floating-promises, no-base-to-string, no-unnecessary-type-assertion) require parserOptions.project: './tsconfig.json' — wired up.

package.json script changes:

  • New lint script: eslint . --report-unused-disable-directives

  • build now runs eslint && tsc -noEmit -skipLibCheck && esbuild (lint must pass first)

Deleted .eslintrc and .eslintignore — both inert under ESLint 10 and were already producing deprecation warnings.

First lint run after wiring surfaced 63 errors (vs the audit's 41-estimate). The 22 difference: the auto-fixable consistent-type-imports rule (import { Foo }import type { Foo } when only used as a type) and one no-unnecessary-type-assertionbytes.buffer as ArrayBuffer I'd added myself in an earlier session, which TypeScript can now infer correctly. --fix cleared all 22 mechanically. The remaining 41 broke down to 21 no-explicit-any + 12 no-unused-vars (mostly unused catch parameters) + 8 no-floating-promises.

2. Eliminate any (type-safety pass)

The audit pre-flagged 17 any sites; the lint surfaced 21 (some any[] instances I'd missed in grep). All eliminated.

src/types/obsidian.d.ts — the commands: any shim

The augmentation was dead code — nothing in image-gin actually calls app.commands. Deleted the augmentation entirely along with eight unused namespace-import names that were only present to populate the augmentation's surrounding context. Smaller surface area to defend in the bot review.

src/utils/logger.ts — six anyunknown

Drop-in replacement. The logger only stringifies details and does an instanceof Error narrowing — no structural access. unknown is the correct type and forces caller-side access to narrow if anyone ever wants to inspect details fields.

src/utils/yamlFrontmatter.ts — five any sites narrowed to unknown

Plus two String(value) fallbacks switched to JSON.stringify so the parser never produces [object Object] for plain-object values. (This is option A from the plan — the parser itself is still hand-rolled. Option B, replace with Obsidian's metadataCache + processFrontMatter API, is intentionally deferred to its own pass.)

src/services/recraftImageService.ts — three any sites + one null as any cast + dead try/catch

Largest single-file change.

  • Added RecraftStyleParams discriminated union type:

    TS
    export type RecraftStyleParams =
        | { style_id: string }
        | { style: string; substyle?: string };

    The Recraft API accepts either a custom style ID or a built-in style with optional substyle. Encoding it as a union lets callers produce one branch or the other without conditional spread tricks.

  • Added RecraftGenerationResponse interface for the API response shape (only the fields we actually access — data[].url and created).

  • Replaced let data: any with an isRecord-narrowed view onto RecraftGenerationResponse. Dropped the surrounding try/catch — Obsidian's requestUrl returns already-parsed JSON, so the typeof response.json === 'function' paranoia check + await response.json() branch was dead code.

  • Changed saveImage signature from Promise<TFile> to Promise<TFile | null> and replaced return null as any with an honest return null. The function returns null in the absolute-path branch where the file lives outside the vault and there's no TFile to construct. The single caller (CurrentFileModal.handleGenerate) already discards the return value, so no caller updates were needed — the false TFile return type was just a type-system lie that was waiting to bite.

src/services/imagekitService.tsextractTagsFromFrontmatter

Signature (frontmatter: any) => string[](frontmatter: unknown) => string[] with isRecord narrowing. Body restructured to type-guard each branch (Array.isArray(rawTags), typeof rawTags === 'string') without the implicit any array spreads the previous version relied on.

src/modals/CurrentFileModal.tsgetStyleParams return type + a real bug

getStyleParams(): anygetStyleParams(): RecraftStyleParams with the same union from the service. Internal narrowing of customStyles[0] switched from a duck-typed if (firstStyle.id) to a proper 'id' in firstStyle && typeof firstStyle.id === 'string' check.

Real bug surfaced and fixed: loadExistingPrompt was reading frontmatter[imagePromptKey] and assigning it directly to a string field with no narrowing. If a user wrote image_prompt: 42 in their frontmatter (YAML emits a number for unquoted numerics), the old code would assign 42 to this.imagePrompt: string and the textarea would render garbage downstream. Fixed by piping through asString from the new coerce.ts helper:

TS
const existing = asString(frontmatter[this.plugin.settings.imagePromptKey]);
if (existing) this.imagePrompt = existing;

This was caught only because changing the parser's return type from Record<string, any> to Record<string, unknown> made TypeScript demand the narrowing. It's the value of the type-safety pass in one example.

src/modals/ConvertLocalImagesForCurrentFile.ts — two (adapter as any).basePath casts

The adapter cast was reaching into a private property to compute filesystem paths. Replaced with the public Obsidian API:

TS
const adapter = this.app.vault.adapter;
const basePath = adapter instanceof FileSystemAdapter ? adapter.getBasePath() : '';

FileSystemAdapter is the desktop adapter; on mobile (capacitor) the adapter has no filesystem path and returns an empty string. Same behavior as the old cast on desktop, gracefully degrades on mobile (which the plugin doesn't target — manifest.json declares isDesktopOnly: true — but the cleaner API is preferred regardless).

src/modals/BatchDirectoryConvertLocalToRemote.tsgetErrorMessage(error: any)unknown

One-liner. Body already did instanceof Error narrowing, so unknown is a true drop-in.

Floating promises (8 sites)

Click handlers and constructor side-effect calls that fired async methods without await/then/catch. The bot enforces no-floating-promises. Fix is void prefix to explicitly mark "fire and forget":

  • src/services/imageCacheService.ts:23 — constructor's ensureCacheFolder() call

  • src/modals/MagnificModal.ts × 2 — initial-search trigger and cacheAndDisplayImages from inside performSearch

  • src/modals/CurrentFileModal.ts — Generate button click handler

  • src/modals/ConvertLocalImagesForCurrentFile.ts — Convert button click handler

  • src/modals/BatchDirectoryConvertLocalToRemote.ts × 2 — Search and Convert button handlers

  • src/settings/settings.ts:648loadCacheStats call inside the settings UI

src/utils/coerce.ts (new file)

Verbatim helper functions from the cite-wide reference doc §3.3:

  • asString(v: unknown): string | undefined

  • asNumber(v: unknown): number | undefined

  • asStringArray(v: unknown): string[]

  • asDate(v: unknown): string | undefined

  • isRecord(v: unknown): v is Record<string, unknown>

Currently used by imagekitService (isRecord narrowing for frontmatter), recraftImageService (same for API response), and CurrentFileModal (asString for the frontmatter prompt field). The full set is included for parity with the cite-wide and perplexed conventions; the unused ones cost nothing and prevent future "do I add it now or later" friction.

3. Replace innerHTML in settings (settings.ts:657, 664)

Two innerHTML assignments in loadCacheStats — purely internal data, no XSS risk today, but the pattern is what gets caught by reviewers. Replaced with Obsidian's DOM API:

TS
container.empty();
const title = container.createDiv();
title.style.fontWeight = 'bold';
title.style.marginBottom = '5px';
title.setText('Cache Statistics');
container.createDiv({ text: `Files: ${stats.totalImages}` });
container.createDiv({ text: `Size: ${stats.cacheSize}` });

Same visual output, no innerHTML, scan-clean.

4. Console hygiene — every console.* now flows through FileLogger

The audit found 76 console.* calls scattered across 10 files (top offender: recraftImageService.ts with 25 verbose API request/response dumps). The plugin guidelines discourage shipping debug logging, and a 76-deep console output leaks API URLs, model names, and file paths into the user's devtools when something goes wrong. Cite-wide chose to route everything through FileLogger; followed the same path here for consistency.

Mechanical substitution (76 sites, 9 files)

Verified beforehand that all 76 calls start with a string literal first arg (so the logger.info(message: string, details?: unknown) signature is satisfied). Then sed:

  • console.error(logger.error(

  • console.warn(logger.warn(

  • console.log(logger.info(

  • console.debug(logger.debug(

Plus a single import { logger } from '../utils/logger'; prepended to each of the 9 files. The path is uniform because the substituted files all live at depth 2 from src/.

Logger refactored (src/utils/logger.ts)

The pre-existing FileLogger had two latent bugs that would have made it a no-op for end users even if anyone had ever called logger.*:

  1. initialize(vault) was never called anywhere. Without an initialized vault, addEntry just queues saveLogs calls forever (memory leak) and never persists.

  2. loadLogs used vault.getAbstractFileByPath, which does not index anything under .obsidian/ — same Obsidian quirk we hit with the cache folder earlier in the project.

Fixed both:

  • main.ts:onload now calls logger.initialize(this.app.vault) immediately after loadSettings.

  • loadLogs and saveLogs both use vault.adapter.exists / adapter.read / adapter.write consistently — these see real filesystem paths including .obsidian/.

  • mkdir for the plugin folder is wrapped in idempotent error swallowing (matching the cache-folder pattern from earlier this project).

  • Default log location moved from magnific-errors.json (vault root — would clutter the user's vault) to .obsidian/plugins/image-gin-plugin/log.json (plugin's own data folder, hidden).

  • 1000-entry rolling cap kept; entries beyond that are dropped from the start.

  • Console mirroring kept — every logger.* call still also writes to console.*, so devtools workflow is unchanged. The added value is persistence.

Net behavioral change: when users hit error paths, they now have a structured JSON log file at .obsidian/plugins/image-gin-plugin/log.json they can attach to bug reports. Much better than asking them to copy from devtools.

5. Version bump to 0.1.0

Three files synced:

  • manifest.json version: 0.0.90.1.0

  • package.json version: 0.0.90.1.0

  • versions.json: added "0.1.0": "1.8.10" (kept the 0.0.9 entry for users still on the old version)

minAppVersion stayed at 1.8.10 — none of this session's work uses APIs newer than that.

Bumped manually rather than via the existing pnpm version script (node version-bump.mjs && git add ...). The script reads npm_package_version and propagates from package.json, but it also stages files for commit, which would have entangled this change with whatever else is uncommitted.

Verified working

  • pnpm lint — 0 errors, 0 warnings.

  • pnpm build — green: lint passes, tsc -noEmit -skipLibCheck passes, esbuild produces clean main.js (54 KB) + styles.css (9.9 KB).

  • git grep -nE ': any\b|as any\b|<any>|any\[\]' -- '*.ts' — empty.

  • git grep -n 'innerHTML' -- '*.ts' — empty.

  • grep -rn 'console\.' src/ main.ts — only the legitimate calls inside FileLogger.saveLogs (where falling back to console.error after a logger-write failure is the only sensible behavior).

  • All four versions in sync at 0.1.0 (manifest.json, package.json, versions.json key, versions.json value).

Real bugs surfaced and fixed by this pass (not just type errors)

  1. CurrentFileModal.loadExistingPrompt — non-string frontmatter image_prompt values (e.g. image_prompt: 42) would have been silently coerced into a string field and rendered as garbage. Now narrowed via asString.

  2. FileLogger — was never going to persist anything regardless of how many logger.* calls existed, because initialize was never called and loadLogs used a vault method that can't see .obsidian/ paths. Fixed both, plus moved the log file out of the user's vault root.

  3. recraftImageService.ts:208return null as any was claiming to return a TFile but returning null. Honest signature now (Promise<TFile | null>); the single caller already discarded the return value, so the type-system lie was risk waiting to manifest if anyone ever started reading the return.

  4. recraftImageService.ts:127 — dead try/catch around response.json parse with paranoid typeof === 'function' branching. Obsidian's requestUrl always returns already-parsed JSON, so the catch could never fire and the function-call branch was unreachable. Removed.

6. Replace the hand-rolled YAML parser with Obsidian's frontmatter API (Phase 4.3-B)

Done in the same session as the rest of the publishing prep. The previous pass (§2) had typed the parser's any away by switching its inputs/outputs to unknown — that satisfied the bot lint but left the underlying parser intact, and the parser had real edge-case bugs the type system can't catch (URL values with colons being mis-split, multi-line strings, list-of-maps, anchors, etc.).

This step removes the parser entirely. All YAML reads now go through Obsidian's metadataCache.getFileCache(file)?.frontmatter. All YAML writes go through app.fileManager.processFrontMatter(file, fn). Both APIs use Obsidian's own YAML implementation, which is correct on the cases above and atomic on writes.

Call-site inventory

13 call sites across 2 modal files. Categorized by access pattern:

Pure reads (3 sites) — replaced with metadataCache.getFileCache(file)?.frontmatter:

  • CurrentFileModal.loadExistingPrompt (was reading image_prompt to seed the textarea)

  • ConvertLocalImagesForCurrentFile.analyzeCurrentFile (was reading frontmatter to discover image properties)

  • ConvertLocalImagesForCurrentFile.handleConvert (was reading frontmatter to extract tags for ImageKit upload metadata)

Single-key writes (2 sites in CurrentFileModal) — replaced with processFrontMatter callbacks:

  • updateFrontmatter — writes the image_prompt field from the modal textarea

  • updateImagePathInFrontmatter — writes <size>_image keys with the URL of a generated image

The single-key write pattern is now uniform:

TS
await this.app.fileManager.processFrontMatter(file, (fm) => {
    fm[key] = value;
});

processFrontMatter reads, mutates the callback's fm argument, and writes atomically — no vault.read, no vault.modify, no manual YAML serialization.

Mixed read/write loop in ConvertLocalImagesForCurrentFile.handleConvert — the harder case, restructured.

The original function read the file once, parsed frontmatter into an object, peeled off the body, then ran two long async loops:

  1. For each frontmatter image: upload to ImageKit, then frontmatter[key] = uploadResult.url in the in-memory object

  2. For each markdown body image: upload, then markdownContent = markdownContent.replace(...) in the in-memory string

After both loops, it reassembled ---\n${formatFrontmatter(frontmatter)}---\n\n${markdownContent} and wrote the whole file with vault.modify. The formatFrontmatter step was where the hand-rolled YAML emitter was doing its damage.

Restructured so frontmatter and body live on independent code paths:

  • Frontmatter mutations: each upload's frontmatter assignment now calls processFrontMatter directly — atomic per upload, written by Obsidian's emitter.

  • Body mutations: accumulated into a bodyMutations: Array<{from, to}> list during the markdown-image loop. After both loops complete, a single vault.read(file) + N string replacements + one vault.modify(file, updated) flushes them. The body re-read happens after all processFrontMatter calls, so the read sees the latest version (including the just-written frontmatter changes); no stale-content / lost-update race.

The local-tags imagekitService.extractTagsFromFrontmatter(frontmatter) call still receives the snapshot read from metadataCache at the top of the function — the function only uses tags as upload metadata, not as a source of truth, so a one-time snapshot is fine and avoids re-fetching from cache on every iteration.

Other improvements that fell out

  • ConvertLocalImagesForCurrentFile previously held the file pointer through several await calls without a local alias. Refactored to const file = this.currentFile; once at the top, since the if (!this.currentFile) return; guard would have lost the narrowing across awaits otherwise. (TypeScript's "this might have changed" caution is correct here — modal lifecycle could in principle null out currentFile between awaits.)

  • CurrentFileModal.loadExistingPrompt no longer does a vault.readmetadataCache is in-memory and synchronous. Faster modal open for files with no image_prompt set.

  • extractTagsFromFrontmatter now receives Obsidian's FrontMatterCache shape (Obsidian types it as any-leaking, but the function signature already expects unknown and narrows internally with isRecord). No callsite change required.

Files

  • Deleted: src/utils/yamlFrontmatter.ts (was 176 lines of hand-rolled parser + emitter — gone, no replacement file in our codebase)

  • Modified: src/modals/CurrentFileModal.ts, src/modals/ConvertLocalImagesForCurrentFile.ts

Net

  • Bundle size: main.js 54 KB → 52 KB (the parser was being bundled in)

  • Lint, tsc, build all still green

  • One fewer hand-rolled file to maintain forever

  • Frontmatter writes are now atomic via Obsidian's API — no more race window between read and re-write

  • Edge cases the old parser mishandled (URL values with :, multi-line strings, anchors, list-of-maps) are now correct, because Obsidian uses a real YAML library

Risk worth knowing about

processFrontMatter's callback signature is (frontmatter: any) => void per Obsidian's type definitions. We mutate fm[key] = value directly without a cast — that's the path of least friction, and no-explicit-any doesn't fire because we're not writing any in our source (we're receiving it from a third-party API). If a future Obsidian update tightens the type to Record<string, unknown>, our callsites will keep compiling. If it tightens to a stricter shape, we'll need to narrow at the callsite — easy.

Phases 1–6 — all blocking publishing requirements complete

Files changed in this session

New:

  • eslint.config.mjs — flat config gating the build

  • src/utils/coerce.tsasString/asNumber/isRecord/etc. helpers

Deleted:

  • .eslintrc, .eslintignore — legacy, ESLint 10 ignores them

  • src/utils/yamlFrontmatter.ts — hand-rolled YAML parser, replaced by Obsidian's metadataCache + fileManager.processFrontMatter APIs (§6)

Modified:

  • package.json, manifest.json, versions.json — version bump + lint script

  • main.ts — logger initialization wired into onload

  • src/types/obsidian.d.ts — deleted dead commands: any augmentation + unused imports

  • src/utils/logger.tsanyunknown, refactored to use adapter API, moved log file to plugin folder, added initialization

  • src/utils/yamlFrontmatter.tsanyunknown, String(value) fallbacks → JSON.stringify

  • src/services/recraftImageService.ts — added RecraftStyleParams and response interfaces, removed dead try/catch, fixed null as any, console → logger

  • src/services/imagekitService.ts — typed extractTagsFromFrontmatter, console → logger

  • src/services/imageCacheService.tsvoid on constructor's fire-and-forget call, console → logger

  • src/services/magnificService.ts — console → logger

  • src/modals/CurrentFileModal.ts — typed getStyleParams, narrowed frontmatter read, void on click handler, console → logger

  • src/modals/ConvertLocalImagesForCurrentFile.tsFileSystemAdapter narrowing replaces adapter cast, void on click handler, console → logger

  • src/modals/BatchDirectoryConvertLocalToRemote.ts — typed error message, void on two click handlers, console → logger

  • src/modals/MagnificModal.tsvoid on two async chain calls, console → logger

  • src/settings/settings.ts — dropped two unused catch params, void on loadCacheStats, replaced innerHTML with createDiv, console → logger