From 1170658ac7b8b8181acab86abf41a817ca41ccec Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 18:56:30 +0200 Subject: [PATCH 1/4] refactor: move record helpers into utility --- src/getVaultRenameHandler.ts | 2 +- src/persistence/codecUtils.ts | 15 +-------------- src/persistence/collectionCodecs.ts | 2 +- src/persistence/episodeCodec.ts | 2 +- src/persistence/merge.ts | 3 ++- src/persistence/podNotesData.ts | 4 +++- src/services/FeedRepository.ts | 3 ++- src/services/publishedTranscript/parse.ts | 2 +- src/settingsTransfer.ts | 4 +++- src/utility/isPlainObject.ts | 3 +++ src/utility/mapRecord.ts | 10 ++++++++++ src/utility/networkRequest.ts | 2 +- src/utility/noteFrontmatter.ts | 2 +- 13 files changed, 30 insertions(+), 24 deletions(-) create mode 100644 src/utility/isPlainObject.ts create mode 100644 src/utility/mapRecord.ts diff --git a/src/getVaultRenameHandler.ts b/src/getVaultRenameHandler.ts index 84480def..8e26fc2e 100644 --- a/src/getVaultRenameHandler.ts +++ b/src/getVaultRenameHandler.ts @@ -2,7 +2,7 @@ import { TFile, type EventRef, type TAbstractFile } from "obsidian"; import { get } from "svelte/store"; import type PodNotes from "./main"; import { downloadedEpisodes, favorites, playlists, queue } from "./store"; -import { mapRecord } from "./persistence/codecUtils"; +import { mapRecord } from "src/utility/mapRecord"; import { writePlayerSettings } from "./store/persistence"; import type DownloadedEpisode from "./types/DownloadedEpisode"; import type { Episode } from "./types/Episode"; diff --git a/src/persistence/codecUtils.ts b/src/persistence/codecUtils.ts index 92fee6c1..0b29d034 100644 --- a/src/persistence/codecUtils.ts +++ b/src/persistence/codecUtils.ts @@ -1,4 +1,4 @@ -import { ownRecord } from "src/utility/ownRecord"; +import { isPlainObject } from "src/utility/isPlainObject"; export type UnknownRecord = Record; @@ -157,15 +157,6 @@ export function copySafeObject(value: UnknownRecord): UnknownRecord { return copy; } -export function mapRecord( - value: Record, - mapper: (entry: T) => R, -): Record { - const result = ownRecord(); - for (const [key, entry] of Object.entries(value)) result[key] = mapper(entry); - return result; -} - export function isOption( options: Readonly>, value: unknown, @@ -187,10 +178,6 @@ export function readOption( return fallback; } -export function isPlainObject(value: unknown): value is UnknownRecord { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - export function warn(warnings: Set, path: string, reason: string): void { warnings.add(`${path}: ${reason}`); } diff --git a/src/persistence/collectionCodecs.ts b/src/persistence/collectionCodecs.ts index f4645d3b..e429afd1 100644 --- a/src/persistence/collectionCodecs.ts +++ b/src/persistence/collectionCodecs.ts @@ -4,9 +4,9 @@ import type { PodcastFeed } from "src/types/PodcastFeed"; import type { PodNote } from "src/types/PodNotes"; import type { PlayedEpisode } from "src/types/PlayedEpisode"; import { ownRecord } from "src/utility/ownRecord"; +import { isPlainObject } from "src/utility/isPlainObject"; import { copySafeObject, - isPlainObject, readBoolean, readNonNegativeNumber, readString, diff --git a/src/persistence/episodeCodec.ts b/src/persistence/episodeCodec.ts index 30156020..e483a969 100644 --- a/src/persistence/episodeCodec.ts +++ b/src/persistence/episodeCodec.ts @@ -1,9 +1,9 @@ import type { PlaylistSettings } from "src/constants"; import type { Episode } from "src/types/Episode"; import type { Playlist } from "src/types/Playlist"; +import { isPlainObject } from "src/utility/isPlainObject"; import { copySafeObject, - isPlainObject, readBoolean, readString, setOptionalFiniteNumber, diff --git a/src/persistence/merge.ts b/src/persistence/merge.ts index 1727712b..86d751ec 100644 --- a/src/persistence/merge.ts +++ b/src/persistence/merge.ts @@ -2,7 +2,8 @@ import { DEFAULT_SETTINGS } from "src/constants"; import type { Episode } from "src/types/Episode"; import { getEpisodeKey } from "src/utility/episodeKey"; import { ownRecord } from "src/utility/ownRecord"; -import { isPlainObject, type UnknownRecord } from "./codecUtils"; +import { isPlainObject } from "src/utility/isPlainObject"; +import type { UnknownRecord } from "./codecUtils"; import { toDocument } from "./podNotesData"; /** diff --git a/src/persistence/podNotesData.ts b/src/persistence/podNotesData.ts index a79e1f88..d945b32e 100644 --- a/src/persistence/podNotesData.ts +++ b/src/persistence/podNotesData.ts @@ -1,6 +1,8 @@ import { DEFAULT_SETTINGS } from "src/constants"; import type { IPodNotesSettings } from "src/types/IPodNotesSettings"; -import { DANGEROUS_KEYS, isPlainObject, mapRecord, type UnknownRecord } from "./codecUtils"; +import { isPlainObject } from "src/utility/isPlainObject"; +import { mapRecord } from "src/utility/mapRecord"; +import { DANGEROUS_KEYS, type UnknownRecord } from "./codecUtils"; import { encodeEpisode, encodePlaylist, diff --git a/src/services/FeedRepository.ts b/src/services/FeedRepository.ts index 9af6dcd3..04d988be 100644 --- a/src/services/FeedRepository.ts +++ b/src/services/FeedRepository.ts @@ -6,7 +6,8 @@ import FeedDocumentParser, { type ParsedFeedEpisodes, type TranscriptLink, } from "src/parser/feedDocumentParser"; -import { DANGEROUS_KEYS, isPlainObject } from "src/persistence/codecUtils"; +import { isPlainObject } from "src/utility/isPlainObject"; +import { DANGEROUS_KEYS } from "src/persistence/codecUtils"; import { decodeDate, newestFirst } from "src/persistence/dateCodec"; import type { Episode } from "src/types/Episode"; import type { PodcastFeed } from "src/types/PodcastFeed"; diff --git a/src/services/publishedTranscript/parse.ts b/src/services/publishedTranscript/parse.ts index 932800d3..75cab8dc 100644 --- a/src/services/publishedTranscript/parse.ts +++ b/src/services/publishedTranscript/parse.ts @@ -1,4 +1,4 @@ -import { isPlainObject } from "src/persistence/codecUtils"; +import { isPlainObject } from "src/utility/isPlainObject"; import { COMMON_NAMED_ENTITIES } from "src/utility/htmlEntities"; export type TranscriptFormat = "vtt" | "srt" | "json" | "html" | "text"; diff --git a/src/settingsTransfer.ts b/src/settingsTransfer.ts index b437f5a1..59faab2e 100644 --- a/src/settingsTransfer.ts +++ b/src/settingsTransfer.ts @@ -1,5 +1,7 @@ import { DEFAULT_SETTINGS } from "./constants"; -import { DANGEROUS_KEYS, isPlainObject, mapRecord } from "./persistence/codecUtils"; +import { isPlainObject } from "src/utility/isPlainObject"; +import { mapRecord } from "src/utility/mapRecord"; +import { DANGEROUS_KEYS } from "./persistence/codecUtils"; import { decodePodNotesData, encodePodNotesData, diff --git a/src/utility/isPlainObject.ts b/src/utility/isPlainObject.ts new file mode 100644 index 00000000..43c0291d --- /dev/null +++ b/src/utility/isPlainObject.ts @@ -0,0 +1,3 @@ +export function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/utility/mapRecord.ts b/src/utility/mapRecord.ts new file mode 100644 index 00000000..34ce5fc6 --- /dev/null +++ b/src/utility/mapRecord.ts @@ -0,0 +1,10 @@ +import { ownRecord } from "./ownRecord"; + +export function mapRecord( + value: Record, + mapper: (entry: T) => R, +): Record { + const result = ownRecord(); + for (const [key, entry] of Object.entries(value)) result[key] = mapper(entry); + return result; +} diff --git a/src/utility/networkRequest.ts b/src/utility/networkRequest.ts index aa9ee662..1c32edb3 100644 --- a/src/utility/networkRequest.ts +++ b/src/utility/networkRequest.ts @@ -1,5 +1,5 @@ import { requestUrl, type RequestUrlResponse } from "obsidian"; -import { isPlainObject } from "src/persistence/codecUtils"; +import { isPlainObject } from "src/utility/isPlainObject"; import { assertFetchableUrl } from "./assertFetchableUrl"; import { ownRecord } from "./ownRecord"; diff --git a/src/utility/noteFrontmatter.ts b/src/utility/noteFrontmatter.ts index a51e43e1..0f4e4bcc 100644 --- a/src/utility/noteFrontmatter.ts +++ b/src/utility/noteFrontmatter.ts @@ -1,5 +1,5 @@ import { parseYaml } from "obsidian"; -import { isPlainObject } from "src/persistence/codecUtils"; +import { isPlainObject } from "src/utility/isPlainObject"; /** * Adds each field the content's frontmatter lacks, before its closing line, or as a new From 0eaa5186ea6938433d0454da5fb867b2d6046830 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 18:56:35 +0200 Subject: [PATCH 2/4] build: guard Svelte size wins and drop unused resize bindings --- scripts/svelte-size-guards.ts | 33 ++++++++ tests/tooling/resizeContract.test.ts | 106 +++++++++++++++++++++++++ tests/tooling/svelteSizeGuards.test.ts | 79 ++++++++++++++++++ vite.config.ts | 12 +++ 4 files changed, 230 insertions(+) create mode 100644 scripts/svelte-size-guards.ts create mode 100644 tests/tooling/resizeContract.test.ts create mode 100644 tests/tooling/svelteSizeGuards.test.ts diff --git a/scripts/svelte-size-guards.ts b/scripts/svelte-size-guards.ts new file mode 100644 index 00000000..112f3c78 --- /dev/null +++ b/scripts/svelte-size-guards.ts @@ -0,0 +1,33 @@ +import { normalizePath, type Plugin } from "vite"; + +export function svelteSizeGuards(): Plugin { + return { + name: "podnotes-svelte-size-guards", + moduleParsed(info) { + if ( + info.importedIds.some((id) => + normalizePath(id).endsWith("/svelte/src/internal/flags/legacy.js"), + ) + ) { + this.error( + `${info.id} compiled in legacy mode; remove runes={false} and use runes.`, + ); + } + }, + generateBundle(_options, bundle) { + for (const output of Object.values(bundle)) { + if (output.type !== "chunk") continue; + for (const [id, module] of Object.entries(output.modules)) { + if ( + normalizePath(id).includes("/node_modules/clsx/") && + module.renderedLength > 0 + ) { + this.error( + `${output.fileName} includes clsx (${id}); use class directives or a string template instead of class={expression}.`, + ); + } + } + } + }, + }; +} diff --git a/tests/tooling/resizeContract.test.ts b/tests/tooling/resizeContract.test.ts new file mode 100644 index 00000000..c6131618 --- /dev/null +++ b/tests/tooling/resizeContract.test.ts @@ -0,0 +1,106 @@ +// @vitest-environment jsdom +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { build } from "vite"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const root = process.cwd(); + +describe("Svelte resize bindings in the production build", { timeout: 20_000 }, () => { + let dir: string; + + beforeEach(() => { + const scratch = path.join(root, ".obsidian-e2e-artifacts"); + mkdirSync(scratch, { recursive: true }); + dir = mkdtempSync(path.join(scratch, "resize-contract-")); + vi.stubEnv("NODE_ENV", "production"); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + rmSync(dir, { recursive: true, force: true }); + }); + + async function buildComponent(source: string) { + writeFileSync(path.join(dir, "Component.svelte"), source); + writeFileSync(path.join(dir, "effect.js"), "globalThis.podnotesToolingEffect = true;"); + const entry = path.join(dir, "entry.js"); + writeFileSync( + entry, + ` + import { mount, unmount, flushSync } from "svelte"; + import Component from "./Component.svelte"; + import "./effect.js"; + export function didImportEffect() { return globalThis.podnotesToolingEffect === true; } + export { flushSync }; + export function start(target) { + const instance = mount(Component, { target }); + flushSync(); + return () => unmount(instance); + } + `, + ); + const result = await build({ + configFile: path.join(root, "vite.config.ts"), + configLoader: "bundle", + mode: "production", + logLevel: "silent", + build: { write: false, lib: { entry } }, + }); + if (!Array.isArray(result)) throw new Error("Expected library build outputs"); + const chunk = result + .flatMap((result) => result.output) + .find((output) => output.fileName === "main.js"); + if (chunk?.type !== "chunk") throw new Error("Expected a JavaScript chunk"); + return chunk.code; + } + + it("omits ResizeObserver when no component binds its size", async () => { + const code = await buildComponent("

Plain markup

"); + expect(code).not.toContain("ResizeObserver"); + }); + + it("updates a used size binding and stops observing when unmounted", async () => { + let resize: ((entries: { target: Element }[]) => void) | undefined; + const observe = vi.fn(); + const unobserve = vi.fn(); + vi.stubGlobal( + "ResizeObserver", + class { + constructor(callback: typeof resize) { + resize = callback; + } + observe = observe; + unobserve = unobserve; + }, + ); + const code = await buildComponent( + "
{width}
", + ); + vi.stubGlobal("podnotesToolingEffect", false); + const exports: { + start?: (target: HTMLElement) => () => Promise; + flushSync?: () => void; + didImportEffect?: () => boolean; + } = {}; + new Function("exports", "module", code)(exports, { exports }); + expect(exports.didImportEffect?.()).toBe(true); + const target = document.createElement("div"); + document.body.append(target); + const cleanup = exports.start?.(target); + const element = target.firstElementChild; + try { + if (!element) throw new Error("The component did not mount"); + expect(observe).toHaveBeenCalledExactlyOnceWith(element, { box: "border-box" }); + Object.defineProperty(element, "clientWidth", { value: 240 }); + resize?.([{ target: element }]); + exports.flushSync?.(); + expect(element.textContent).toBe("240"); + } finally { + await cleanup?.(); + target.remove(); + } + expect(unobserve).toHaveBeenCalledExactlyOnceWith(element); + }); +}); diff --git a/tests/tooling/svelteSizeGuards.test.ts b/tests/tooling/svelteSizeGuards.test.ts new file mode 100644 index 00000000..978ea8c7 --- /dev/null +++ b/tests/tooling/svelteSizeGuards.test.ts @@ -0,0 +1,79 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { build } from "vite"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const root = fileURLToPath(new URL("../..", import.meta.url)); + +describe("Svelte size guards in the production build", { timeout: 20_000 }, () => { + let dir: string; + + beforeEach(() => { + const scratch = path.join(root, ".obsidian-e2e-artifacts"); + mkdirSync(scratch, { recursive: true }); + dir = mkdtempSync(path.join(scratch, "svelte-size-guards-")); + vi.stubEnv("NODE_ENV", "production"); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + rmSync(dir, { recursive: true, force: true }); + }); + + function buildComponent(source: string, extra = "") { + writeFileSync(path.join(dir, "Component.svelte"), source); + const entry = path.join(dir, "entry.js"); + writeFileSync( + entry, + `export { default as Component } from "./Component.svelte";\n${extra}`, + ); + return build({ + configFile: path.join(root, "vite.config.ts"), + configLoader: "bundle", + mode: "production", + logLevel: "silent", + build: { write: false, lib: { entry } }, + }); + } + + it.each([ + ["no runes syntax", "

Plain markup

"], + [ + "a string class", + '

Text

', + ], + [ + "a class directive", + "

Text

", + ], + ])("accepts a component with %s", async (_name, source) => { + await expect(buildComponent(source)).resolves.toBeDefined(); + }); + + it("rejects legacy prop declarations", async () => { + await expect( + buildComponent("

{value}

"), + ).rejects.toThrow(/Cannot use.*export let.*runes mode/); + }); + + it("rejects a component overriding runes mode even without legacy state", async () => { + await expect( + buildComponent("

Legacy

"), + ).rejects.toThrow(/Component\.svelte.*compiled in legacy mode/); + }); + + it("rejects a dynamic class that retains clsx after minification", async () => { + await expect( + buildComponent( + "

Text

", + ), + ).rejects.toThrow(/main\.js.*includes clsx/); + }); + + it("rejects a direct clsx dependency", async () => { + await expect( + buildComponent("

Plain markup

", 'export { clsx } from "clsx";'), + ).rejects.toThrow(/main\.js.*includes clsx/); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 585beee1..648a12f1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,6 +7,7 @@ import { createBuildArtifactPlugin } from "./scripts/build-artifacts.ts"; import { stripCssComments } from "./scripts/css-comments.ts"; import { compactCss } from "./scripts/compact-css.ts"; import { svelteWithoutHydration } from "./scripts/svelte-without-hydration.ts"; +import { svelteSizeGuards } from "./scripts/svelte-size-guards.ts"; import { syntaxFloor } from "./scripts/syntax-floor.ts"; // Obsidian's Android app sets no WebView minimum and its own code is ES2020, and its @@ -68,6 +69,7 @@ export default defineConfig(({ mode }) => { }, }, compilerOptions: { + runes: true, css: "injected", discloseVersion: false, // Svelte skips a style whose id is already in the document, and Obsidian @@ -77,6 +79,7 @@ export default defineConfig(({ mode }) => { }), // Development builds keep their source maps, which this transform does not update. isProd && svelteWithoutHydration(), + svelteSizeGuards(), syntaxFloor(), createBuildArtifactPlugin({ isProduction: isProd, outDir, rootDir }), ], @@ -105,6 +108,15 @@ export default defineConfig(({ mode }) => { terserOptions: { compress: { pure_getters: false, passes: 2 }, mangle: false }, rollupOptions: { external, + treeshake: { + moduleSideEffects: [ + { + // ES2020 lowering makes this class's static field look effectful even when unused. + test: /[\\/]svelte[\\/]src[\\/]internal[\\/]client[\\/]dom[\\/]elements[\\/]bindings[\\/]size\.js$/, + sideEffects: false, + }, + ], + }, output: { entryFileNames: "main.js", codeSplitting: false, From d6e63f23597b8f500b7beb5766b17dd34edd7bda Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 18:56:35 +0200 Subject: [PATCH 3/4] chore: restore desktop after screenshot sets --- .claude/skills/verify/screens.mjs | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/.claude/skills/verify/screens.mjs b/.claude/skills/verify/screens.mjs index 8dc0b514..415c9aff 100644 --- a/.claude/skills/verify/screens.mjs +++ b/.claude/skills/verify/screens.mjs @@ -33,6 +33,7 @@ // shoots .obsidian-e2e-artifacts/screens/