diff --git a/devs.md b/devs.md index 034df69c..a6b2aa77 100644 --- a/devs.md +++ b/devs.md @@ -47,6 +47,12 @@ npm run test:e2e:browser-apps:interop # Run WebApp → WebPeer → CLI in Com npm run test:e2e:obsidian:local-suite # Run the real Obsidian local suite ``` +### Production minification + +Production builds remove unused declarations and shorten local identifiers while retaining class and function names used by module discovery and diagnostic stacks. `terser.config.mjs` also preserves property names, top-level bindings, and identifiers visible to direct `eval` calls. Keep these boundaries when changing compression settings; `test/build/terserConfig.unit.spec.ts` checks the generated code against host properties, persisted keys, reflective names, and exception stacks. + +Compare bundle bytes and gzip bytes from the same source, dependency artefacts, and build inputs. Validate import-time registration and the resulting plug-in in the affected real-runtime workflows. The iOS syntax check is one part of compatibility verification; it does not measure performance or replace physical-device testing. + ### Tips Select the narrowest unit, integration, CLI E2E, or real Obsidian E2E command that owns the behaviour being changed. The obsolete mocked browser Harness has been retired. diff --git a/esbuild.config.mjs b/esbuild.config.mjs index e2e16e8e..ab7684a6 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -194,7 +194,7 @@ const context = await esbuild.context({ platform: "browser", metafile: true, sourcemap: prod ? false : "inline", - treeShaking: false, + treeShaking: true, outfile: "main_org.js", mainFields: ["browser", "module", "main"], minifyWhitespace: false, diff --git a/eslint.config.mjs b/eslint.config.mjs index bcbd8688..20a5a0dc 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -120,6 +120,13 @@ export default defineConfig([ "@typescript-eslint/restrict-template-expressions": "warn", }, }, + { + files: ["src/apps/cli/vite.config.ts", "src/apps/webapp/vite.config.ts"], + rules: { + // Vite loads these configurations before the application source alias is available. + "livesync-imports/prefer-source-alias": "off", + }, + }, { files: ["src/apps/browser/**/*.{ts,svelte}", "src/apps/webapp/**/*.ts"], rules: { diff --git a/src/apps/cli/vite.config.ts b/src/apps/cli/vite.config.ts index a3212a34..905a016b 100644 --- a/src/apps/cli/vite.config.ts +++ b/src/apps/cli/vite.config.ts @@ -1,18 +1,11 @@ import { defaultServerConditions, defaultServerMainFields, defineConfig } from "vite"; import { svelte } from "@sveltejs/vite-plugin-svelte"; -import { fileURLToPath, fs, isBuiltin, path } from "@vrtmrz/livesync-commonlib/node"; +import { fileURLToPath, isBuiltin, path } from "@vrtmrz/livesync-commonlib/node"; +import { readVersion } from "../readVersion"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const resolve = (...args: string[]) => path.resolve(...args).replace(/\\/g, "/"); const repoRoot = path.resolve(__dirname, "../../.."); -function readVersion(filePath: string): string | undefined { - const parsed: unknown = JSON.parse(fs.readFileSync(filePath, "utf-8")); - if (typeof parsed !== "object" || parsed === null || !("version" in parsed)) { - return undefined; - } - return typeof parsed.version === "string" ? parsed.version : undefined; -} - const packageVersion = readVersion(path.resolve(repoRoot, "package.json")); const manifestVersion = readVersion(path.resolve(repoRoot, "manifest.json")); // https://vite.dev/config/ diff --git a/src/apps/readVersion.ts b/src/apps/readVersion.ts new file mode 100644 index 00000000..d6bc09cc --- /dev/null +++ b/src/apps/readVersion.ts @@ -0,0 +1,10 @@ +import { fs } from "@vrtmrz/livesync-commonlib/node"; + +/** Read a string-valued version field from a JSON file during Node builds. */ +export function readVersion(filePath: string): string | undefined { + const parsed: unknown = JSON.parse(fs.readFileSync(filePath, "utf-8")); + if (typeof parsed !== "object" || parsed === null || !("version" in parsed)) { + return undefined; + } + return typeof parsed.version === "string" ? parsed.version : undefined; +} diff --git a/src/apps/readVersion.unit.spec.ts b/src/apps/readVersion.unit.spec.ts new file mode 100644 index 00000000..3de40238 --- /dev/null +++ b/src/apps/readVersion.unit.spec.ts @@ -0,0 +1,36 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { readVersion } from "./readVersion"; + +describe("readVersion", () => { + let directory: string; + + beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), "livesync-read-version-")); + }); + + afterEach(() => { + rmSync(directory, { recursive: true, force: true }); + }); + + it("returns only string version values", () => { + const filePath = join(directory, "version.json"); + writeFileSync(filePath, JSON.stringify({ version: "1.2.3" })); + expect(readVersion(filePath)).toBe("1.2.3"); + + for (const contents of ["null", "[]", "{}", '{"version": 42}', '{"version": null}']) { + writeFileSync(filePath, contents); + expect(readVersion(filePath)).toBeUndefined(); + } + }); + + it("preserves file read and JSON parse errors", () => { + expect(() => readVersion(join(directory, "missing.json"))).toThrow(); + + const filePath = join(directory, "invalid.json"); + writeFileSync(filePath, "not JSON"); + expect(() => readVersion(filePath)).toThrow(SyntaxError); + }); +}); diff --git a/src/apps/webapp/vite.config.ts b/src/apps/webapp/vite.config.ts index e48b74d1..4564fd31 100644 --- a/src/apps/webapp/vite.config.ts +++ b/src/apps/webapp/vite.config.ts @@ -1,17 +1,10 @@ import { defineConfig } from "vite"; import { svelte } from "@sveltejs/vite-plugin-svelte"; -import { fileURLToPath, fs, path } from "@vrtmrz/livesync-commonlib/node"; +import { fileURLToPath, path } from "@vrtmrz/livesync-commonlib/node"; +import { readVersion } from "../readVersion"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(__dirname, "../../.."); -function readVersion(filePath: string): string | undefined { - const parsed: unknown = JSON.parse(fs.readFileSync(filePath, "utf-8")); - if (typeof parsed !== "object" || parsed === null || !("version" in parsed)) { - return undefined; - } - return typeof parsed.version === "string" ? parsed.version : undefined; -} - const packageVersion = readVersion(path.resolve(repoRoot, "package.json")); const manifestVersion = readVersion(path.resolve(repoRoot, "manifest.json")); // https://vite.dev/config/ diff --git a/terser.config.mjs b/terser.config.mjs index e15bc67a..feae72e9 100644 --- a/terser.config.mjs +++ b/terser.config.mjs @@ -60,7 +60,12 @@ const terserOption = { hoist_vars: true, // toplevel: "vars", }, - mangle: false, + // Keep property keys, reflective names, and eval-visible bindings stable. + mangle: { + properties: false, + toplevel: false, + eval: false, + }, ecma: 2018, // specify one of: 5, 2015, 2016, etc. enclose: false, // or specify true, or "args:values" diff --git a/test/build/terserConfig.unit.spec.ts b/test/build/terserConfig.unit.spec.ts new file mode 100644 index 00000000..83672bbf --- /dev/null +++ b/test/build/terserConfig.unit.spec.ts @@ -0,0 +1,58 @@ +import { runInNewContext } from "node:vm"; +import { describe, expect, it } from "vitest"; +import { minify } from "terser"; +import { terserOption } from "../../terser.config.mjs"; + +const fixture = ` + const host = require("obsidian"); + class ModuleLog { + constructor(configuration) { this.savedSettings = configuration; } + read(longParameterName) { + const longIntermediateValue = host.prefix + longParameterName; + return { savedSettings: this.savedSettings, message: longIntermediateValue }; + } + } + function renderMessage(inputValue) { return new ModuleLog({ persistedField: "unchanged" }).read(inputValue); } + function readWithEval(longEvalParameter) { return eval("longEvalParameter"); } + function failWithName() { throw new Error("fixture failure"); } + module.exports = { ModuleLog, renderMessage, readWithEval, failWithName }; +`; + +describe("production minifier", () => { + it("shortens local identifiers while preserving host properties, module names, and diagnostics", async () => { + const result = await minify(fixture, { ...terserOption, sourceMap: false }); + expect(result.code).toBeDefined(); + const sandbox = { + module: { + exports: {} as { + ModuleLog: { name: string }; + renderMessage(input: string): unknown; + readWithEval(input: string): string; + failWithName(): never; + }, + }, + require: (name: string) => { + expect(name).toBe("obsidian"); + return { prefix: "host:" }; + }, + }; + runInNewContext(result.code!, sandbox); + const api = sandbox.module.exports; + expect(api.ModuleLog.name).toBe("ModuleLog"); + expect(api.renderMessage.name).toBe("renderMessage"); + expect(api.renderMessage("sample")).toEqual({ + savedSettings: { persistedField: "unchanged" }, + message: "host:sample", + }); + expect(api.readWithEval("evaluated")).toBe("evaluated"); + let failure: Error | undefined; + try { + api.failWithName(); + } catch (error) { + failure = error as Error; + } + expect(failure?.stack).toContain("failWithName"); + expect(result.code).not.toContain("longParameterName"); + expect(result.code).not.toContain("longIntermediateValue"); + }); +}); diff --git a/updates.md b/updates.md index 32f0f46a..6228f032 100644 --- a/updates.md +++ b/updates.md @@ -12,6 +12,11 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi ## Unreleased +### Maintenance + +- The plug-in bundle is smaller, reducing the download for installation and updates. + - Builds remove unused code and shorten local identifiers while retaining class and function names for diagnostics. + ### Diagnostics - Service log calls stay with the plug-in instance that registered them. Its log connection is released after the full shutdown, so stale instances cannot receive later service messages.