/** * Vite plugin for svelte-docinfo. * * Drives a long-lived `AnalysisSession` against a debounce-batched watcher. * Every file change funnels into a `pendingChanges` Map and flushes via one * `setFiles` + one `query` per debounce window — multi-file editor saves * collapse to a single re-analysis instead of N serialized round-trips. * * The plugin no longer maintains a `fileCache.content` mirror — `setFile` * returns `{changed: boolean}` so HMR invalidation reacts to that flag * directly. `session.list()` / `session.has()` cover owned-set introspection. * * Consumers import the analysis result: * * ```ts * import {modules, diagnostics} from 'virtual:svelte-docinfo'; * ``` * * For TypeScript support, add to your `app.d.ts`: * * ```ts * /// <reference types="svelte-docinfo/virtual-svelte-docinfo.js" /> * ``` * * @example * ```ts * // vite.config.ts * import svelteDocinfo from 'svelte-docinfo/vite.js'; * * export default defineConfig({ * plugins: [ * sveltekit(), * svelteDocinfo(), * ], * }); * ``` * * @module */ import {readFile} from 'node:fs/promises'; import type {Logger as ViteLogger, Plugin, ViteDevServer} from 'vite'; import {createAnalysisSession, type AnalysisSession} from './session.js'; import type {OnDuplicates} from './analyze-core.js'; import {discoverSourceFiles, type Discovery} from './discovery.js'; import type {SourceFileInfo} from './source.js'; import type {Diagnostic} from './diagnostics.js'; import type {AnalysisLog} from './log.js'; import {compactReplacer} from './declaration-helpers.js'; import { createSourceOptions, isSource, type ModuleSourceOptions, type SourceOptionsDefaults, } from './source-config.js'; import {noDepsResolver, type ImportResolver} from './dep-resolver.js'; import {toPosixPath} from './paths.js'; const VIRTUAL_ID = 'virtual:svelte-docinfo'; const RESOLVED_VIRTUAL_ID = '\0virtual:svelte-docinfo'; /** Stable resolver identity for Vite's plugin-container resolver in dev mode. */ const VITE_DEV_IDENTITY = 'vite-plugin-container'; /** Options for the `svelteDocinfo` Vite plugin. */ export interface VitePluginSvelteDocinfoOptions { /** * Absolute path to project root directory. * Defaults to Vite's resolved `config.root`. */ projectRoot?: string; /** * Glob patterns to include (relative to `projectRoot`). * * When provided under the default `discovery: 'auto'`, collapses the chain * to glob (exports discovery is skipped). Combining `include` with * `discovery: 'exports'` throws at config-resolve time — `exports` mode * has no concept of include patterns. * * When omitted, the glob fallback derives an include from * `sourceOptions.sourcePaths` via `deriveIncludePatterns`, so custom * `sourcePaths` survive the fallback instead of silently defaulting to * `src/lib`. */ include?: Array<string>; /** * Glob patterns to exclude, applied at both discovery and analysis time. * * When provided, **fully replaces** `sourceOptions.exclude` (no array * merge) — the default test and spec filters are dropped unless * re-included explicitly. */ exclude?: Array<string>; /** * Whether to resolve import dependencies. * * When `false`, the session's resolver returns null for every specifier, * so module dependencies/dependents stay empty. * * @default true */ resolveDependencies?: boolean; /** * Discovery strategy for source files. * * @default 'auto' * @see {@link Discovery} */ discovery?: Discovery; /** * Dist directory name relative to project root, used for exports-based discovery. * @default 'dist' */ distDir?: string; /** * Partial overrides for default source options (SvelteKit `src/lib` layout). */ sourceOptions?: Partial<SourceOptionsDefaults>; /** Behavior when duplicate declaration names are found across modules. */ onDuplicates?: OnDuplicates; /** * HMR debounce delay in milliseconds. Coalesces rapid file changes during dev. * @default 100 */ hmrDebounceMs?: number; } /** * Vite plugin: analyzes TypeScript and Svelte source files via a persistent * `AnalysisSession` and serves the result as `virtual:svelte-docinfo`. * * In dev mode, watches source files and triggers debounced HMR updates on * changes via `setFiles` batches drained from a pending-changes Map. * In build mode, runs analysis once during `buildStart`. */ const svelteDocinfo = (options: VitePluginSvelteDocinfoOptions = {}): Plugin => { const { include, exclude, resolveDependencies = true, discovery, distDir, sourceOptions, onDuplicates, hmrDebounceMs = 100, } = options; let projectRoot: string; let resolvedSourceOptions: ModuleSourceOptions; let logger: ViteLogger | null = null; let isDev = false; let modulesJson = '[]'; let diagnosticsJson = '[]'; let cachedModuleCode: string | null = null; let server: ViteDevServer | null = null; let session: AnalysisSession | null = null; // Discovery diagnostics survive across HMR cycles (the discovery pass only // runs at cold start). Tracked here because the session has no concept of // "discovery" — it ingests files the plugin hands to it. let discoveryDiagnostics: Array<Diagnostic> = []; // ── Resolver wiring ────────────────────────────────────────────────────── // Dev resolver: server.pluginContainer.resolveId. Stable identity across // the dev session — cache hits work across HMR cycles. const viteDevResolver: ImportResolver = { resolve: async (specifier, fromFile) => { try { const r = await server!.pluginContainer.resolveId(specifier, fromFile); return r?.id ?? null; } catch { return null; } }, identity: VITE_DEV_IDENTITY, }; // ── In-flight analysis tracking ───────────────────────────────────────── // Initial analysis runs once in buildStart; HMR flushes run on debounce. // `load()` awaits both so we never serve stale JSON to a freshly-importing // module. let initialPromise: Promise<void> | null = null; let hmrFlushPromise: Promise<void> | null = null; const waitInflight = async (): Promise<void> => { if (initialPromise) await initialPromise; if (hmrFlushPromise) await hmrFlushPromise; }; // Pending-changes Map: path → newContent (string) or unlink marker (null). // Drained by `flushBatch`; survives across multiple flush cycles when new // events arrive mid-flight. const pendingChanges = new Map<string, string | null>(); // Per-path event sequence. Watcher events (change/add/unlink) bump the // counter and stamp the path with the new value; async handlers (the // `change` handler awaits readFile before enqueueing) capture the seq at // dispatch and drop their result if a newer event has stamped the path // in between. Without this, an `unlink` arriving during a `change`'s // in-flight readFile gets clobbered when the read resolves with stale // content for a now-deleted file. let eventSeqCounter = 0; const eventSeqs = new Map<string, number>(); const stampEvent = (path: string): number => { const seq = ++eventSeqCounter; eventSeqs.set(path, seq); return seq; }; const isLatestEvent = (path: string, seq: number): boolean => eventSeqs.get(path) === seq; const buildModuleCode = (): string => { if (cachedModuleCode !== null) return cachedModuleCode; const lines = [ `export const modules = ${modulesJson};`, `export const diagnostics = ${diagnosticsJson};`, `export default {modules, diagnostics};`, ]; if (isDev) { lines.push(`if (import.meta.hot) { import.meta.hot.accept(); }`); } cachedModuleCode = lines.join('\n'); return cachedModuleCode; }; const updateOutputFromQuery = ( modules: ReadonlyArray<unknown>, queryDiagnostics: ReadonlyArray<Diagnostic>, ): boolean => { // `JSON.stringify([], compactReplacer)` returns the JS `undefined` because // the replacer strips empty arrays — we embed the JSON into a template // literal where that would interpolate as the literal text `"undefined"`, // so handle the empty-modules case explicitly. const newModulesJson = modules.length === 0 ? '[]' : JSON.stringify(modules, compactReplacer); // Pull cumulative ingest diagnostics from the session — every owned // entry's `ingestDiagnostics` are walked there. Discovery diagnostics // don't pass through the session (file discovery happens before ingest), // so we maintain those separately and merge at publish time. const ingest = session ? session.allIngestDiagnostics() : []; const merged: Array<Diagnostic> = [...ingest, ...discoveryDiagnostics, ...queryDiagnostics]; const newDiagnosticsJson = JSON.stringify(merged); if (newModulesJson === modulesJson && newDiagnosticsJson === diagnosticsJson) return false; modulesJson = newModulesJson; diagnosticsJson = newDiagnosticsJson; cachedModuleCode = null; return true; }; const sendHmrInvalidation = (): void => { if (!server) return; const mod = server.moduleGraph.getModuleById(RESOLVED_VIRTUAL_ID); if (!mod) return; server.moduleGraph.invalidateModule(mod); // Hardcoded Vite URL encoding for \0 prefix on virtual modules. // Not a documented Vite public API — if Vite changes the encoding in a // future release, HMR silently stops firing (initial load still works // through the public resolveId/load hooks). Same pattern as // vite_plugin_fuz_css; update both if Vite exposes a proper API. const hmrPath = '/@id/__x00__virtual:svelte-docinfo'; server.hot.send({ type: 'update', updates: [ { type: 'js-update', path: hmrPath, acceptedPath: hmrPath, timestamp: Date.now(), }, ], }); }; const errorLog = (err: unknown): void => { const log: Pick<AnalysisLog, 'error'> = logger ?? {error: (msg) => console.error(msg)}; const message = err instanceof Error ? (err.stack ?? err.message) : String(err); log.error(`[svelte-docinfo] ${message}`); }; const isWatchedFile = (file: string): boolean => isSource(file, resolvedSourceOptions); // ── Initial analysis (cold start in buildStart) ────────────────────────── const runInitialAnalysis = async (): Promise<void> => { if (!session) return; const {files, diagnostics} = await discoverSourceFiles({ sourceOptions: resolvedSourceOptions, include, discovery, distDir, log: logger ?? undefined, }); discoveryDiagnostics = diagnostics; await session.setFiles(files); const result = session.query({onDuplicates, log: logger ?? undefined}); updateOutputFromQuery(result.modules, result.diagnostics); }; // ── HMR flush: drain pendingChanges via setFiles + query ───────────────── const flushBatch = async (): Promise<void> => { if (initialPromise) await initialPromise; if (!session) return; if (pendingChanges.size === 0) return; // Drain. New events arriving during this flush land in pendingChanges // and are picked up by the surrounding while-loop in `scheduleFlush`. const drained = [...pendingChanges]; pendingChanges.clear(); const toAdd: Array<SourceFileInfo> = []; const toDelete: Array<string> = []; for (const [path, content] of drained) { if (content === null) toDelete.push(path); else toAdd.push({id: path, content}); } // Apply deletions first so re-adds (rare but possible: unlink+add same // path within one debounce window) work correctly. for (const path of toDelete) await session.deleteFile(path); let anyChanged = toDelete.length > 0; if (toAdd.length > 0) { const ingest = await session.setFiles(toAdd); if (ingest.changedIds.size > 0) anyChanged = true; } if (!anyChanged) return; // `updateOutputFromQuery` pulls the cumulative ingest diagnostics from // `session.allIngestDiagnostics()` itself — no per-batch survival // tracking needed at the plugin layer. const result = session.query({onDuplicates, log: logger ?? undefined}); const updated = updateOutputFromQuery(result.modules, result.diagnostics); if (updated) sendHmrInvalidation(); }; const scheduleFlush = (): Promise<void> => { if (hmrFlushPromise) return hmrFlushPromise; hmrFlushPromise = (async () => { await new Promise<void>((r) => setTimeout(r, hmrDebounceMs)); try { while (pendingChanges.size > 0) { await flushBatch(); } } catch (err) { errorLog(err); } finally { hmrFlushPromise = null; } })(); return hmrFlushPromise; }; const enqueueChange = (path: string, content: string | null): void => { pendingChanges.set(path, content); // `eventSeqs[path]` is no longer needed once the latest event has been // enqueued: any older in-flight handler will fail `isLatestEvent` // against `undefined`, and any newer event will re-stamp on arrival. // Bounds the Map's growth in long-running dev sessions with churn. eventSeqs.delete(path); void scheduleFlush(); }; return { name: 'vite-plugin-svelte-docinfo', configResolved(config) { projectRoot = options.projectRoot ?? config.root; // `exclude` shortcut overrides `sourceOptions.exclude` (same precedence // as `analyzeFromFiles`). const mergedSourceOptions = exclude !== undefined ? {...sourceOptions, exclude} : sourceOptions; resolvedSourceOptions = createSourceOptions(projectRoot, mergedSourceOptions); // Reject contradictory discovery config upfront so failures are at // config-load time rather than first analysis. if (discovery === 'exports' && include) { throw new Error( "svelte-docinfo: discovery: 'exports' is incompatible with `include`. " + "Use discovery: 'glob' (with include) or remove include for strict exports mode.", ); } logger = config.logger; isDev = config.command === 'serve'; }, async buildStart() { // Choose resolver based on mode; create the session here (after // configResolved set sourceOptions; for dev, after configureServer // set `server`). let sessionResolver: ImportResolver | undefined; if (!resolveDependencies) { sessionResolver = noDepsResolver; } else if (server) { sessionResolver = viteDevResolver; } else { // Build mode: resolve via the session's TS-based default // (`createDefaultResolver`, reached by leaving `resolveImport` // undefined) rather than Rollup's `this.resolve`. `this.resolve` // mutates the active build's module graph, so resolving a bare // package specifier from the analyzed source — e.g. `vite`, // imported (type-only) by this module — pulls the whole // toolchain (vite → rollup → esbuild) into the client bundle. // It tree-shakes away, but floods the log with "externalized for // browser" warnings. `ts.resolveModuleName` is side-effect-free // and honors tsconfig `paths` (incl. SvelteKit's generated // `$lib`), which covers internal dependency edges. Node builtins // are already filtered before the resolver (see `session.ts`). sessionResolver = undefined; } // Recreate session on each buildStart so `vite build --watch` cycles // get a fresh LS — the document registry across builds isn't a // guaranteed-stable contract from Vite's side. session?.dispose(); session = createAnalysisSession({ sourceOptions: resolvedSourceOptions, resolveImport: sessionResolver, log: logger ?? undefined, }); initialPromise = runInitialAnalysis().finally(() => { initialPromise = null; }); await initialPromise; }, resolveId(id) { if (id === VIRTUAL_ID) return RESOLVED_VIRTUAL_ID; return undefined; }, async load(id) { if (id !== RESOLVED_VIRTUAL_ID) return undefined; // Wait for any in-flight analysis (initial or HMR-triggered) so we // never serve stale JSON to a freshly-importing module. await waitInflight(); return buildModuleCode(); }, configureServer(devServer) { server = devServer; devServer.watcher.on('change', (rawFile) => { // Watcher emits native-separator paths on Windows. Posixify at // the boundary so eventSeqs/pendingChanges keys, isWatchedFile // checks, and downstream session calls all share one canonical id. const file = toPosixPath(rawFile); if (!isWatchedFile(file)) return; // Stamp the event before awaiting readFile so a later unlink/add // for the same path bumps the seq and our resolution drops below. const seq = stampEvent(file); // `.catch(errorLog)` — a sync throw before the first `await` would // otherwise become an unhandled rejection. void (async () => { // Re-read the file. Editors that touch mtime without changing // content (formatters saving idempotently) trigger spurious // events; the session's content-equality check in setFile // handles that case — `changed: false` short-circuits the // HMR invalidation. let newContent: string; try { newContent = await readFile(file, 'utf-8'); } catch { // File became unreadable between event and read — treat as unlink. if (isLatestEvent(file, seq)) enqueueChange(file, null); return; } // A newer event (unlink/add/change) stamped this path while we // were reading — discard our result so we don't overwrite it // with stale content (e.g. ingesting a since-deleted file). if (!isLatestEvent(file, seq)) return; enqueueChange(file, newContent); })().catch(errorLog); }); devServer.watcher.on('add', (rawFile) => { const file = toPosixPath(rawFile); if (!isWatchedFile(file)) return; const seq = stampEvent(file); void (async () => { try { const content = await readFile(file, 'utf-8'); if (!isLatestEvent(file, seq)) return; enqueueChange(file, content); } catch (err) { errorLog(err); } })().catch(errorLog); }); devServer.watcher.on('unlink', (rawFile) => { const file = toPosixPath(rawFile); // Use session.has() as the gate: paths outside source paths were // never owned, so nothing to do. if (!session?.has(file)) return; stampEvent(file); enqueueChange(file, null); }); devServer.httpServer?.on('close', () => { session?.dispose(); session = null; }); }, }; }; export default svelteDocinfo;
{ "path": "vite.ts", "declarations": [ { "name": "VitePluginSvelteDocinfoOptions", "kind": "interface", "docComment": "Options for the `svelteDocinfo` Vite plugin.", "typeSignature": "VitePluginSvelteDocinfoOptions", "sourceLine": 67, "members": [ { "name": "projectRoot", "kind": "variable", "docComment": "Absolute path to project root directory.\nDefaults to Vite's resolved `config.root`.", "typeSignature": "string", "optional": true }, { "name": "include", "kind": "variable", "docComment": "Glob patterns to include (relative to `projectRoot`).\n\nWhen provided under the default `discovery: 'auto'`, collapses the chain\nto glob (exports discovery is skipped). Combining `include` with\n`discovery: 'exports'` throws at config-resolve time — `exports` mode\nhas no concept of include patterns.\n\nWhen omitted, the glob fallback derives an include from\n`sourceOptions.sourcePaths` via `deriveIncludePatterns`, so custom\n`sourcePaths` survive the fallback instead of silently defaulting to\n`src/lib`.", "typeSignature": "Array<string>", "optional": true }, { "name": "exclude", "kind": "variable", "docComment": "Glob patterns to exclude, applied at both discovery and analysis time.\n\nWhen provided, **fully replaces** `sourceOptions.exclude` (no array\nmerge) — the default test and spec filters are dropped unless\nre-included explicitly.", "typeSignature": "Array<string>", "optional": true }, { "name": "resolveDependencies", "kind": "variable", "docComment": "Whether to resolve import dependencies.\n\nWhen `false`, the session's resolver returns null for every specifier,\nso module dependencies/dependents stay empty.", "typeSignature": "boolean", "optional": true, "defaultValue": "true" }, { "name": "discovery", "kind": "variable", "docComment": "Discovery strategy for source files.", "typeSignature": "Discovery", "seeAlso": [ "{@link Discovery}" ], "optional": true, "defaultValue": "'auto'" }, { "name": "distDir", "kind": "variable", "docComment": "Dist directory name relative to project root, used for exports-based discovery.", "typeSignature": "string", "optional": true, "defaultValue": "'dist'" }, { "name": "sourceOptions", "kind": "variable", "docComment": "Partial overrides for default source options (SvelteKit `src/lib` layout).", "typeSignature": "Partial<SourceOptionsDefaults>", "optional": true }, { "name": "onDuplicates", "kind": "variable", "docComment": "Behavior when duplicate declaration names are found across modules.", "typeSignature": "OnDuplicates", "optional": true }, { "name": "hmrDebounceMs", "kind": "variable", "docComment": "HMR debounce delay in milliseconds. Coalesces rapid file changes during dev.", "typeSignature": "number", "optional": true, "defaultValue": "100" } ] }, { "name": "default", "kind": "function", "docComment": "Vite plugin: analyzes TypeScript and Svelte source files via a persistent\n`AnalysisSession` and serves the result as `virtual:svelte-docinfo`.\n\nIn dev mode, watches source files and triggers debounced HMR updates on\nchanges via `setFiles` batches drained from a pending-changes Map.\nIn build mode, runs analysis once during `buildStart`.", "typeSignature": "(options?: VitePluginSvelteDocinfoOptions): Plugin<any>", "sourceLine": 137, "parameters": [ { "name": "options", "type": "VitePluginSvelteDocinfoOptions", "defaultValue": "{}" } ], "returnType": "Plugin<any>" } ], "moduleComment": "Vite plugin for svelte-docinfo.\n\nDrives a long-lived `AnalysisSession` against a debounce-batched watcher.\nEvery file change funnels into a `pendingChanges` Map and flushes via one\n`setFiles` + one `query` per debounce window — multi-file editor saves\ncollapse to a single re-analysis instead of N serialized round-trips.\n\nThe plugin no longer maintains a `fileCache.content` mirror — `setFile`\nreturns `{changed: boolean}` so HMR invalidation reacts to that flag\ndirectly. `session.list()` / `session.has()` cover owned-set introspection.\n\nConsumers import the analysis result:\n\n```ts\nimport {modules, diagnostics} from 'virtual:svelte-docinfo';\n```\n\nFor TypeScript support, add to your `app.d.ts`:\n\n```ts\n/// <reference types=\"svelte-docinfo/virtual-svelte-docinfo.js\" />\n```\n\n@example\n```ts\n// vite.config.ts\nimport svelteDocinfo from 'svelte-docinfo/vite.js';\n\nexport default defineConfig({\n plugins: [\n sveltekit(),\n svelteDocinfo(),\n ],\n});\n```", "dependencies": [ "analyze-core.ts", "declaration-helpers.ts", "dep-resolver.ts", "diagnostics.ts", "discovery.ts", "log.ts", "paths.ts", "session.ts", "source-config.ts", "source.ts" ] }