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
lintscript:eslint . --report-unused-disable-directivesbuildnow runseslint && 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-assertion — bytes.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 any → unknown
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
RecraftStyleParamsdiscriminated union type:TSexport 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
RecraftGenerationResponseinterface for the API response shape (only the fields we actually access —data[].urlandcreated).Replaced
let data: anywith anisRecord-narrowed view ontoRecraftGenerationResponse. Dropped the surrounding try/catch — Obsidian'srequestUrlreturns already-parsed JSON, so thetypeof response.json === 'function'paranoia check +await response.json()branch was dead code.Changed
saveImagesignature fromPromise<TFile>toPromise<TFile | null>and replacedreturn null as anywith an honestreturn null. The function returns null in the absolute-path branch where the file lives outside the vault and there's noTFileto construct. The single caller (CurrentFileModal.handleGenerate) already discards the return value, so no caller updates were needed — the falseTFilereturn type was just a type-system lie that was waiting to bite.
src/services/imagekitService.ts — extractTagsFromFrontmatter
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.ts — getStyleParams return type + a real bug
getStyleParams(): any → getStyleParams(): 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:
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:
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.ts — getErrorMessage(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'sensureCacheFolder()callsrc/modals/MagnificModal.ts× 2 — initial-search trigger andcacheAndDisplayImagesfrom insideperformSearchsrc/modals/CurrentFileModal.ts— Generate button click handlersrc/modals/ConvertLocalImagesForCurrentFile.ts— Convert button click handlersrc/modals/BatchDirectoryConvertLocalToRemote.ts× 2 — Search and Convert button handlerssrc/settings/settings.ts:648—loadCacheStatscall 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 | undefinedasNumber(v: unknown): number | undefinedasStringArray(v: unknown): string[]asDate(v: unknown): string | undefinedisRecord(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:
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.*:
initialize(vault)was never called anywhere. Without an initialized vault,addEntryjust queuessaveLogscalls forever (memory leak) and never persists.loadLogsusedvault.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:onloadnow callslogger.initialize(this.app.vault)immediately afterloadSettings.loadLogsandsaveLogsboth usevault.adapter.exists/adapter.read/adapter.writeconsistently — these see real filesystem paths including.obsidian/.mkdirfor 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 toconsole.*, 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.jsonversion:0.0.9→0.1.0package.jsonversion:0.0.9→0.1.0versions.json: added"0.1.0": "1.8.10"(kept the0.0.9entry 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 -skipLibCheckpasses, esbuild produces cleanmain.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 insideFileLogger.saveLogs(where falling back toconsole.errorafter a logger-write failure is the only sensible behavior).All four versions in sync at
0.1.0(manifest.json,package.json,versions.jsonkey,versions.jsonvalue).
Real bugs surfaced and fixed by this pass (not just type errors)
CurrentFileModal.loadExistingPrompt— non-string frontmatterimage_promptvalues (e.g.image_prompt: 42) would have been silently coerced into astringfield and rendered as garbage. Now narrowed viaasString.FileLogger— was never going to persist anything regardless of how manylogger.*calls existed, becauseinitializewas never called andloadLogsused a vault method that can't see.obsidian/paths. Fixed both, plus moved the log file out of the user's vault root.recraftImageService.ts:208—return null as anywas claiming to return aTFilebut returningnull. 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.recraftImageService.ts:127— dead try/catch aroundresponse.jsonparse with paranoidtypeof === 'function'branching. Obsidian'srequestUrlalways 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 readingimage_promptto 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 theimage_promptfield from the modal textareaupdateImagePathInFrontmatter— writes<size>_imagekeys with the URL of a generated image
The single-key write pattern is now uniform:
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:
For each frontmatter image: upload to ImageKit, then
frontmatter[key] = uploadResult.urlin the in-memory objectFor 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
processFrontMatterdirectly — 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 singlevault.read(file)+ N string replacements + onevault.modify(file, updated)flushes them. The body re-read happens after allprocessFrontMattercalls, 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
ConvertLocalImagesForCurrentFilepreviously held the file pointer through severalawaitcalls without a local alias. Refactored toconst file = this.currentFile;once at the top, since theif (!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 outcurrentFilebetween awaits.)CurrentFileModal.loadExistingPromptno longer does avault.read—metadataCacheis in-memory and synchronous. Faster modal open for files with noimage_promptset.extractTagsFromFrontmatternow receives Obsidian'sFrontMatterCacheshape (Obsidian types it asany-leaking, but the function signature already expectsunknownand narrows internally withisRecord). 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.js54 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 buildsrc/utils/coerce.ts—asString/asNumber/isRecord/etc. helpers
Deleted:
.eslintrc,.eslintignore— legacy, ESLint 10 ignores themsrc/utils/yamlFrontmatter.ts— hand-rolled YAML parser, replaced by Obsidian'smetadataCache+fileManager.processFrontMatterAPIs (§6)
Modified:
package.json,manifest.json,versions.json— version bump + lint scriptmain.ts— logger initialization wired into onloadsrc/types/obsidian.d.ts— deleted deadcommands: anyaugmentation + unused importssrc/utils/logger.ts—any→unknown, refactored to use adapter API, moved log file to plugin folder, added initializationsrc/utils/yamlFrontmatter.ts—any→unknown,String(value)fallbacks →JSON.stringifysrc/services/recraftImageService.ts— addedRecraftStyleParamsand response interfaces, removed dead try/catch, fixednull as any, console → loggersrc/services/imagekitService.ts— typedextractTagsFromFrontmatter, console → loggersrc/services/imageCacheService.ts—voidon constructor's fire-and-forget call, console → loggersrc/services/magnificService.ts— console → loggersrc/modals/CurrentFileModal.ts— typedgetStyleParams, narrowed frontmatter read,voidon click handler, console → loggersrc/modals/ConvertLocalImagesForCurrentFile.ts—FileSystemAdapternarrowing replaces adapter cast,voidon click handler, console → loggersrc/modals/BatchDirectoryConvertLocalToRemote.ts— typed error message,voidon two click handlers, console → loggersrc/modals/MagnificModal.ts—voidon two async chain calls, console → loggersrc/settings/settings.ts— dropped two unused catch params,voidonloadCacheStats, replacedinnerHTMLwithcreateDiv, console → logger