svelte-docinfo

static analysis for TypeScript and Svelte 📜

repo 0.1.0 npm

npm i -D svelte-docinfo

introduction #

svelte-docinfo extracts JSON describing the exports of TypeScript and Svelte modules for open-ended use cases like docs, code search, and dev tools. It uses svelte2tsx and the TypeScript compiler API to resolve types, track exports, and extract semantic details. The npm package has a Vite plugin, CLI, and programmatic API.

Dependencies are minimal and the tool's scope is limited to data, not presentation. These docs were made using the data produced by svelte-docinfo, mainly the API reference, with fuz_ui components.

svelte-docinfo is largely inspired by sveld, but instead of AST-only inspection it uses the TypeScript compiler API for richer information, and also analyzes TypeScript modules. See the comparison below.

This is an early release. There are gaps to fill and design choices still open, but Svelte 5 coverage seems good and it's used in production websites. Please open issues for bugs, and discussions for everything else!

AI disclosure: the code and docs beyond the intro were written by Claude Code with uneven human guidance.

Install
#

Published as svelte-docinfo to npm:

npm install svelte-docinfo -D

Usage
#

The tool's main function is outputting JSON, and there are several integration paths, in rough order from most opinionated to most flexible:

For SvelteKit and Vite projects, the Vite plugin is the recommended path. It runs the analysis at build time and serves the result as a virtual module with HMR:

import {modules} from 'virtual:svelte-docinfo';

Run the CLI to inspect a project from the command line:

# Analyze the current project and print JSON to stdout npx svelte-docinfo # Analyze a specific directory and write to a file npx svelte-docinfo ./packages/my-lib -o docs/library.json

For standalone use or custom build tools, two functions cover most cases. analyzeFromFiles handles file discovery automatically:

import {analyzeFromFiles} from 'svelte-docinfo'; const {modules, diagnostics} = await analyzeFromFiles({ projectRoot: process.cwd(), });

If your build tool already has file contents in memory, use analyze directly to skip file discovery. See the build tools guide for the full integration surface:

import {analyze, createSourceOptions} from 'svelte-docinfo'; const {modules} = await analyze({ sourceFiles: [{id: '/path/to/file.ts', content: '...'}], sourceOptions: createSourceOptions('/project'), });

For long-lived consumers (Vite plugin, LSP-style tools) that re-analyze the same source set repeatedly, createAnalysisSession returns a persistent handle backed by a TypeScript LanguageService. Parsed ASTs and svelte2tsx output are reused across calls. The one-shot analyze and analyzeFromFiles are thin wrappers over single-use sessions. See the session guide for the full incremental API.

See the API reference for all exported functions and types.

Not supported
#

A few constructs are silently skipped: standalone namespace Foo {} declarations (namespace re-exports are supported), decorators, and per-parameter doc fields beyond @param descriptions. ParameterJson deliberately doesn't carry @example/@deprecated/@since/@see/@throws. Per the TSDoc spec, those tags are scoped to the function symbol and live on the parent declaration.

Key features
#

  • full type resolution: infers complex types without manual annotations, including generics, imported types, and inferred return types, with source locations for every declaration
  • TSDoc/JSDoc parsing: extracts standard tags (@param, @returns, @example, @deprecated, etc.) plus @nodocs to exclude from docs and @mutates to flag side effects
  • Svelte 5 components: analyzes components via svelte2tsx, extracting prop types, defaults, bindability, snippet parameters, children detection, and exported template snippets
  • Svelte 5 reactivity runes: detects $state, $state.raw, $derived, and $derived.by on variables and class fields and exposes them via the reactivity field. Detection is syntactic, so the same patterns can be captured in any analyzed file
  • re-export tracking: alsoExportedFrom arrays, aliasOf for renames, default-slot entries named "default", and export * from patterns
  • dependency graphs: tracks imports between modules and computes dependents
  • function overloads: captures all public overload signatures with per-overload JSDoc
  • build-tool agnostic: works with any source: file system, build pipeline, or in-memory
  • diagnostic collection: accumulates warnings and errors without halting, so you can report problems in batch

Compared to sveld
#

svelte-docinfo is largely inspired by sveld, a Svelte component documentation generator that walks the AST and infers types from JSDoc annotations and literal values. svelte-docinfo instead uses the TypeScript compiler API (via svelte2tsx) as its source of truth, so it resolves imported types, generics, and complex inferred types without requiring @type annotations. It also analyzes TypeScript modules, not just .svelte files.

svelte-docinfo additionally tracks re-exports across modules, computes dependency graphs, and records source locations. It does not currently support Svelte 4 features like legacy slots, dispatched events, or the context API. Svelte 5 replaces most of these with snippets and callback props.

cli #

Run in any directory with TypeScript or Svelte source files. Prints JSON describing your project's exports to stdout.

Basic usage
#

npx svelte-docinfo # analyze the current directory npx svelte-docinfo ./packages/my-lib # analyze a specific directory npx svelte-docinfo -o output.json # write to a file instead npx svelte-docinfo --pretty # pretty-print the JSON output

Discovery defaults to package.json exports, falling back to glob. -i forces explicit patterns; --discovery glob skips exports; --discovery exports is strict (fails when exports is missing).

--source-dir sets the source directory (default src/lib, repeatable for monorepos) and seeds the implicit include glob. --source-root controls module-path stripping in the output (defaults to the single --source-dir or their longest common prefix).

Compact JSON pairs well with jq:

npx svelte-docinfo | jq '.modules | length' # count modules npx svelte-docinfo | jq -r '.modules[].declarations[].name' # list all exported names

JSON goes to stdout; info, warnings, and errors go to stderr, so the terminal interleaves them, but > and | capture clean JSON. -q/--quiet silences info; warnings and errors still print.

Options
#

FlagDescription
[project-root]project root directory (default: cwd)
-i, --include <pattern>include pattern (repeatable, replaces exports discovery)
-e, --exclude <pattern>exclude glob, applied at discovery and analysis (repeatable; fully replaces defaults, so it does not merge with **/*.test.ts, **/*.spec.ts)
-o, --output <file>output file (default: stdout; pass - for explicit stdout, so -o "$OUT" works when $OUT=-)
--discovery <mode>auto | exports | glob (default: auto: exports first, glob fallback). exports is strict and fails when package.json exports is missing.
--dist-dir <dir>dist directory for exports discovery (default: dist)
--source-dir <dir>source directory relative to project root (default: src/lib). Repeatable for monorepos; also seeds the implicit include glob.
--source-root <dir>source root for module-path stripping (default: single source-dir or longest common prefix)
--on-duplicates <mode>dispatch on duplicate declaration names: throw | warn (default: emit duplicate_declaration diagnostic, no dispatch)
--only <pattern>glob filter applied to module paths in output (repeatable). Full project is still analyzed (re-exports/dependents stay correct); diagnostics aren't filtered
--no-resolve-dependenciesdisable dependency resolution
--prettypretty-print JSON output (default: compact)
-q, --quietsuppress info messages on stderr (warnings and errors still print)
-V, --versionshow version number

Exit codes: 0 success, 1 analysis errors, 2 CLI errors.

For SvelteKit and Vite projects where the analysis feeds into your app bundle, see the Vite plugin.

vite plugin #

The Vite plugin is the recommended path for SvelteKit and Vite projects. It runs analysis at build time and serves the result as 'virtual:svelte-docinfo'; in dev mode it watches source files and sends HMR updates as you edit.

Setup
#

  1. Add the plugin to vite.config.ts:

    import {defineConfig} from 'vite'; import {sveltekit} from '@sveltejs/kit/vite'; import svelteDocinfo from 'svelte-docinfo/vite.js'; export default defineConfig({ plugins: [sveltekit(), svelteDocinfo()], });
  2. Add TypeScript support in your app.d.ts:

    /// <reference types="svelte-docinfo/virtual-svelte-docinfo.js" />
  3. Import the virtual module anywhere in your app:

    import {modules, diagnostics} from 'virtual:svelte-docinfo'; // or use the default export: import data from 'virtual:svelte-docinfo'; // data.modules and data.diagnostics are the same as the named exports

    Both exports match the programmatic AnalyzeResultJson shape. See diagnostics for what flows through diagnostics.

If TypeScript reports Cannot find module 'virtual:svelte-docinfo', ensure the /// <reference> line is in your app.d.ts.

Options
#

All options are optional; the minimal call uses defaults (package.json exports discovery, glob fallback):

svelteDocinfo()

Every option, with its default:

import svelteDocinfo from 'svelte-docinfo/vite.js'; svelteDocinfo({ // Project root directory. Default: Vite's resolved config.root. projectRoot: process.cwd(), // Glob patterns for file discovery. Forces glob mode under discovery: 'auto'. // Default: undefined (use exports discovery). include: ['src/**/*.ts', 'src/**/*.svelte'], // Exclude globs. When provided, fully replaces the default // ['**/*.test.ts', '**/*.spec.ts'] — re-include those patterns // explicitly if you want them filtered. exclude: ['**/*.test.ts', '**/*.spec.ts'], // Discovery strategy: 'auto' | 'exports' | 'glob'. Default: 'auto'. // 'auto' → exports first, glob fallback // 'exports' → strict; throws if package.json exports is missing // 'glob' → skip exports, use glob patterns discovery: 'auto', // Dist directory for exports discovery. Default: 'dist'. distDir: 'dist', // Resolve module dependency graph. Default: true. resolveDependencies: true, // Dispatch on duplicate declaration names across modules. // 'throw' | 'warn' | (duplicates, log) => void. // Default: undefined — the duplicate_declaration diagnostic still emits, // but no extra dispatch fires. Set to 'throw' to fail fast on duplicates. onDuplicates: undefined, // Partial overrides for default source options (SvelteKit src/lib layout). // Merged into createSourceOptions(projectRoot, sourceOptions). sourceOptions: {sourcePaths: ['src/lib']}, // HMR debounce in ms. Default: 100. hmrDebounceMs: 100, })

The plugin runs the same pipeline as analyzeFromFiles internally: discover via discoverSourceFiles, resolve dependencies, then analyze. sourceOptions is merged with defaults via createSourceOptions before discovery; hmrDebounceMs only affects the dev-mode watcher.

CLI vs Vite plugin
#

The CLI calls analyzeFromFiles once, so use it for CI pipelines and one-off generation. The plugin owns a persistent createAnalysisSession, so HMR re-analyses reuse parsed TypeScript ASTs and svelte2tsx output across cycles. Use it when the analysis feeds the SvelteKit/Vite bundle. See the session guide if you're driving a session directly (custom bundler, LSP, etc.).

How it works
#

The plugin hooks into four Vite lifecycle stages:

  1. configResolved: throws synchronously when discovery: 'exports' is combined with include, so contradictory configs fail at startup rather than at first analysis
  2. buildStart: discovers the source set, reads file contents, and runs createAnalysisSession's analyze; caches the serialized JSON result
  3. resolveId / load: serves the cached result as virtual:svelte-docinfo, a JavaScript module exporting modules, diagnostics, and a default {modules, diagnostics}
  4. configureServer: watches source directories for changes, debounces re-analysis, and sends HMR updates only when the output actually changes. The session diffs incoming files by content equality, so unchanged files skip re-parsing entirely.

architecture #

svelte-docinfo analyzes its own source and draws the result below. Each node is one module under src/lib/; each arrow is an internal import. Layers fall out of the import graph itself, not asserted in prose but observed in data. Hover any module to isolate its immediate neighbors; click to jump to its API page.

How to read it
#

  • Arrows point from importer to imported: X depends on Y. Reads with gravity, so dependencies fall downward toward the foundation.
  • Layers are assigned by longest-path-from-sink. A module's layer is one plus the deepest layer it transitively depends on. Sinks (no internal imports) land at the bottom.
  • Hover a module to isolate its immediate neighbors. Edges split by direction: what the module depends on and what depends on it.

What the layers say
#

The bottom rows are the primitives that nothing in the library depends on transitively: path normalization, types, concurrency caps, the diagnostics schema. The middle rows are the per-kind TypeScript extractors and the file-system helpers. The top rows are the orchestrators: the persistent session on top of core analysis, then the one-shot wrappers, then the vite plugin and cli entries.

Across 30 modules and 69 internal imports, the graph is naturally acyclic, so no back-edges had to be reversed to lay it out.

How this was drawn
#

The layout is precomputed at build time by dependency_graph.gen.json.ts, which reads library.json (itself generated by library.gen.ts via analyze) and runs a Sugiyama-style layered layout: longest-path-from-sink for layer assignment, dummy nodes on long edges, median-heuristic crossing reduction. The result is a small JSON sibling the Svelte component renders to SVG. No layout libraries. About 300 lines, end to end.

session #

createAnalysisSession returns a persistent analysis handle backed by a TypeScript LanguageService. Use it when the same source set is re-analyzed repeatedly (Vite plugin, LSP-style tools) so parsed ASTs, svelte2tsx output, and the dependency graph are reused across cycles. The one-shot analyze and analyzeFromFiles are thin wrappers over single-use sessions.

Construction
#

import {createAnalysisSession, createSourceOptions} from 'svelte-docinfo'; const session = createAnalysisSession({ sourceOptions: createSourceOptions(process.cwd()), // Optional: session-default ImportResolver. Lazily constructed // (TS + tsconfig) on first use when omitted, unless every batch // arrives fully pre-resolved. // resolveImport: myResolver, // Optional: logger for session-level messages. // log: console, });

AnalysisSessionOptions requires a fully-constructed ModuleSourceOptions. Use createSourceOptions to merge with DEFAULT_SOURCE_OPTIONS. The session re-runs normalizeSourceOptions (idempotent) but does not apply any further defaults.

tsconfig and compilerOptions flow to both the underlying LanguageService and the lazy default ImportResolver. User-supplied compilerOptions merge over the parsed tsconfig per key, but the tsconfig file is still required.

Lifecycle
#

The session exposes a small incremental surface — add, update, remove — for owning the source set:

MethodPurpose
setFile(file, opts?)Ingest one file's content. Idempotent on cache hit. Returns SetFileResult
setFiles(files, opts?)Ingest a batch. Additive, never removes. Returns SetFilesResult with both aggregate and per-file views
deleteFile(id)Drop a file from the session and evict it from the LS
has(id)Whether the given absolute path is currently owned
list()Snapshot of owned file IDs (insertion order)
query(opts?)Run a two-phase analysis pass against the current owned set. Returns AnalyzeResultJson
allIngestDiagnostics()Cumulative ingest-time diagnostics across every owned entry
dispose()Release LS resources and clear the owned set. Session must not be used after

Concurrency. The session is not safe across overlapping calls, so serialize externally. The LS underneath is sync, but the resolver phase awaits I/O for async resolvers (Vite/Rollup), so setFile / setFiles cross await boundaries. query is sync and reads the current owned set; await any pending ingest first.

Cache-hit semantics
#

Each owned entry is all-or-nothing per cache decision: either every cached artifact for that file is reused, or every artifact is recomputed. Whether a re-ingest cache-hits is mode-discriminated:

  • lex+resolve (default): cache key is (content, resolverIdentity). Match requires byte-for-byte content equality AND identity equality on the ImportResolver.
  • pre-resolved (caller supplies SourceFileInfo.dependencies): cache key is (content, dependencies-element-wise-equal). A fresh array with identical contents cache-hits (so [...filer.deps.keys()]-per-call works); any length, element, or order difference cache-misses.

A mode flip (an entry previously ingested as lex+resolve now arrives with dependencies, or vice versa) always cache-misses and rewrites the entry.

On a cache hit, SetFileResult.changed is false and the cached ingest diagnostics are returned with no work run. On a miss, the entry is rewritten. The LS push happens on miss for TS/JS files and for Svelte files with a successful svelte2tsx virtual; CSS/JSON and transform-failed Svelte rewrite the entry without touching the LS.

ImportResolver and identity
#

ImportResolver is a token pair: {resolve, identity}. identity is a stable opaque token (string or symbol) that keys the resolve cache alongside content.

import type {ImportResolver} from 'svelte-docinfo'; const myResolver: ImportResolver = { identity: 'vite-plugin-container', resolve: (specifier, fromFile) => { // return absolute path, or null for externals return null; }, };

Identity is required (not optional). The naive alternative, keying on the function reference, would silently destroy cache reuse when callers wrap the resolver in a fresh closure per call (a common Vite/Rollup pattern). Opaque tokens lift the responsibility to the caller, where it can be done correctly: reuse the same string/symbol across calls if the resolution semantics haven't changed.

When neither AnalysisSessionOptions.resolveImport nor a per-call SetFileOptions.resolveImport is supplied, the session lazily constructs a TS + tsconfig default with a fresh symbol identity on first use. That laziness matters: if every file in every batch arrives fully pre-resolved (SourceFileInfo.dependencies populated), the default is never built and loadTsconfig is never called, saving a multi-second ts.createProgram on cold start.

Trust mode for pre-resolved dependencies
#

When a caller supplies SourceFileInfo.dependencies, the session accepts the array unconditionally and skips its own resolve pass. A buggy caller-side resolver skews ModuleJson.dependencies / dependents with no warning. The lex+resolve fallback is always grounded in syntactic imports, so switch to it if you don't control the dependency source. See build-tool integration for the full trust contract and type-only-edge policy.

Diagnostics: ingest-time vs query-time
#

Diagnostic kinds split into two categories with different lifecycles:

  • Ingest-time (transform_failed, source_map_failed, import_parse_failed, resolver_failed): surfaced via setFile / setFiles returns and durable on the owned entry. Survive subsequent query calls until the entry is replaced or deleted.
  • Query-time (the rest): recomputed on every query call. Returned in AnalyzeResultJson.diagnostics.

query() returns analysis-pass diagnostics only; it does NOT include ingest diagnostics. Concat with prior setFile / setFiles returns for the full picture, or call allIngestDiagnostics() for the cumulative ingest view across every owned entry:

const queryResult = session.query(); const fullDiagnostics = [ ...session.allIngestDiagnostics(), ...queryResult.diagnostics, ];

allIngestDiagnostics is the publish path for long-lived consumers. It lets the Vite plugin republish the cumulative ingest picture on every HMR cycle without tracking per-batch returns, and lets an LSP push a complete diagnostic set to the client on demand. Cheap: walks the owned map.

Discovery-time diagnostics (module_unreadable from discoverFromExports) are a third category. The session doesn't run discovery, so direct consumers own those: track them in a side-channel field for HMR survival as the Vite plugin does, or run discovery once and merge into the first query.

Worked example: incremental loop
#

Sketch of an LSP-style edit/save/query loop. Each setFile updates a single file; query reanalyzes the whole owned set but reuses parsed ASTs and svelte2tsx output for unchanged files.

import {createAnalysisSession, createSourceOptions, hasErrors} from 'svelte-docinfo'; const session = createAnalysisSession({ sourceOptions: createSourceOptions(process.cwd()), }); // Initial population. await session.setFiles([ {id: '/abs/src/lib/a.ts', content: '...'}, {id: '/abs/src/lib/b.ts', content: '...'}, ]); // Edit: one file changed. const {changed, diagnostics: ingest} = await session.setFile({ id: '/abs/src/lib/a.ts', content: '... new content ...', }); if (changed) { const {modules, diagnostics: pass} = session.query(); const all = [...session.allIngestDiagnostics(), ...pass]; if (hasErrors(all)) { // surface to the editor } } // Tear down. session.dispose();

For HMR / file-watcher consumers, setFiles on the batch of changed paths in one call is preferable to looping setFile: it shares the ensureLexerReady warmup and runs the resolve phase in parallel with a bounded worker pool.

When to use one-shot APIs instead
#

If you only call analysis once (CLI, CI pipeline, one-off doc generation), use analyze or analyzeFromFiles directly. They create a single-use session, run it, and dispose. There's no caching benefit to keeping a session alive across one call. See session.ts for the full type definitions and diagnostics for the diagnostic kinds.

build tools #

svelte-docinfo is build-tool agnostic. Files come in through SourceFileInfo objects (absolute path plus content, optionally with pre-resolved dependencies), and the analyzer never touches disk on its own. This page covers the integration surface for embedding analysis inside a bundler, watcher, or LSP-style tool that doesn't fit the bundled Vite plugin.

SourceFileInfo
#

Every analysis entry point, including analyze, createAnalysisSession's setFile / setFiles, and even discoverSourceFiles's output, operates on SourceFileInfo:

interface SourceFileInfo { id: string; // absolute path (native ok at boundary) content: string; // file contents dependencies?: string[]; // optional: pre-resolved deps (opt-in) }
  • id: absolute path. Native paths are accepted at the public-API boundary and posixified at ingest (Windows backslash paths become forward-slash internally). After ingest, every comparison and output field uses POSIX form.
  • content: required, since analysis functions don't read from disk. Files may come from any source: filesystem, build pipeline, in-memory editor buffer.
  • dependencies: opt-in. When supplied, the session skips its lex+resolve pass for this entry and treats the array as authoritative. See "Pre-resolved dependencies" below.

Reverse edges (dependents) are computed inside the analyzer from forward edges, never caller-supplied.

Three entry points by ownership
#

Pick by what your tool already owns:

UseWhen
analyzeFromFilesYou own a project root and want everything in one call: file discovery, dependency resolution, analysis. CLI-style.
analyzeYou already hold file contents in memory (a Rollup/esbuild plugin with files in the bundle graph). Single-pass, one-shot.
createAnalysisSessionYou re-analyze the same source set across many cycles (Vite, watch mode, LSP). See the session guide.

The one-shot APIs are thin wrappers over single-use sessions. They share the same two-phase analysis loop, same diagnostic model, same output shape.

discoverSourceFiles (standalone)
#

discoverSourceFiles runs file discovery without analysis. Useful when your tool wants to know the source set up front (for watcher-glob registration, count displays, pre-flight checks) but defers analysis to a separate pass.

import {discoverSourceFiles, createSourceOptions} from 'svelte-docinfo'; const {files, diagnostics} = await discoverSourceFiles({ sourceOptions: createSourceOptions(process.cwd()), // discovery: 'auto' | 'exports' | 'glob' (default 'auto') // include: ['src/**/*.ts'] (forces glob under 'auto') // distDir: 'dist' (for exports discovery) }); // files: Array<SourceFileInfo> — content already loaded from disk // diagnostics: module_unreadable for any file the exports map names // but readFile failed on (permission denied, FS error)

Discovery strategies match the CLI and Vite plugin: 'auto' tries package.json exports first and falls back to glob; 'exports' is strict (throws if exports is missing); 'glob' skips exports entirely. Providing include under 'auto' collapses the chain to glob immediately, since honoring it from exports discovery would silently drop the user's filter on packages with an exports field.

module_unreadable is the discovery-time diagnostic; the session doesn't run discovery, so direct session consumers own it. analyzeFromFiles merges discovery diagnostics into the final return before handing back to the caller.

Pre-resolved dependencies (fast path)
#

Build tools that already maintain a dependency graph (Gro's filer, Rollup's bundle graph, webpack's module graph) can hand it over by populating SourceFileInfo.dependencies with absolute paths. The session then skips its lex+resolve pass for that entry entirely.

import type {SourceFileInfo} from 'svelte-docinfo'; // inside your build-tool integration const files: Array<SourceFileInfo> = [...filer.modules].map(([id, mod]) => ({ id, content: mod.content, dependencies: [...mod.dependencies.keys()], // absolute paths })); await session.setFiles(files);

The cache key for the entry shifts to (content, dependencies-element-wise-equal) instead of (content, resolverIdentity). A fresh array per call cache-hits cleanly as long as the contents match, with no upstream memoization needed. The session snapshots the array at ingest, so mid-flight mutation of the caller's array won't produce false hits.

Trust contract
#

The pre-resolved path is trust mode: the session does not cross-check declared dependencies against the file's content. Two consequences a buggy resolver upstream will hit silently:

  • Edges declared in dependencies but absent from content are accepted as-is.
  • Edges present in content but missing from dependencies are silently omitted.

There's no diagnostic for either case, because legitimate cross-batch sequences (declare-then-set, declare-then-delete) would produce noise. The lex+resolve fallback path has no such hole; its edges are always grounded in syntactic imports. If you don't fully trust the dependency source you're handing in, use lex+resolve and pay the parse cost.

Type-only edges
#

Whether import type {X} from './x' shows up as a dependency is the caller's decision under the pre-resolved path. Two common stances:

  • Keep type-only edges: what default lex+resolve does (es-module-lexer doesn't distinguish them). The output's ModuleJson.dependencies matches the syntactic import set.
  • Drop type-only edges: what Gro's filer (parse_imports with ignore_types=true) does. Type imports aren't runtime deps; the output reflects runtime graph only.

Both are valid; the pre-resolved path defers the policy to the caller. Switching from lex+resolve to pre-resolved-via-Gro will visibly remove type-only edges from ModuleJson.dependencies / dependents for affected modules.

ImportResolver (lex+resolve path)
#

When you don't hand over pre-resolved dependencies, the session lexes specifiers from content and passes them through an ImportResolver. Build-tool integrations typically want to wire their own resolver in so module resolution matches what the rest of the build does:

import {createAnalysisSession, createSourceOptions} from 'svelte-docinfo'; import type {ImportResolver} from 'svelte-docinfo'; const resolver: ImportResolver = { identity: 'my-bundler@v1', resolve: (specifier, fromFile) => bundler.resolve(specifier, fromFile), }; const session = createAnalysisSession({ sourceOptions: createSourceOptions(process.cwd()), resolveImport: resolver, });

identity is the stable cache token. See the session guide for why it's required and how to choose one. When omitted entirely, the session lazily constructs a TS + tsconfig default on first use, but only if at least one file in any batch lacks dependencies. Fully pre-resolved batches never trigger the default and skip the loadTsconfig call entirely.

Source options and the source root
#

createSourceOptions builds the ModuleSourceOptions the session needs. Customize for non-default project layouts:

import {createSourceOptions} from 'svelte-docinfo'; // Default: single sourcePaths=['src/lib'], standard test/spec exclude. const opts = createSourceOptions(process.cwd()); // Monorepo: multiple source directories, optional explicit sourceRoot. const monorepoOpts = createSourceOptions(process.cwd(), { sourcePaths: ['packages/a/src', 'packages/b/src'], // sourceRoot derived as longest common prefix when omitted. }); // Custom exclude (replaces defaults entirely — no merge). const customOpts = createSourceOptions(process.cwd(), { exclude: ['**/*.test.ts', '**/*.spec.ts', '**/fixtures/**'], });

sourceRoot controls module-path stripping in ModuleJson.path: pass '.' for project-relative paths. exclude is the single source of truth, applied at both discovery and analysis time, so a file dropped here never shows up in the output regardless of how it was discovered.

Paths: POSIX-form contract
#

Every path stored, compared, or used as a Map/Set key inside the analyzer is POSIX form (forward slashes). Native paths are accepted at the public-API boundary and posixified at ingest, so Windows callers can hand in C:\\repo\\src\\lib\\foo.ts without thinking about it, but the resulting ModuleJson.path, Diagnostic.file, and session list() output report POSIX form.

Out of scope: drive-letter case normalization (C:\\ vs c:\\) and Windows extended-length \\\\?\\ prefixes. See paths.ts for the chokepoint implementation.

Concurrency caps
#

Two bounded-concurrency caps protect against runaway parallelism in the integration paths:

  • MAX_FILE_CONCURRENCY caps parallel readFile calls (used by files.globFiles, exports.discoverFromExports).
  • MAX_RESOLVE_CONCURRENCY caps parallel resolver calls (used by the session's phase-2 lex+resolve pass).

Same numerical value today, named separately for future independent tuning. Both run through a fail-fast, order-preserving worker pool (map_concurrent in concurrency.ts).

output format #

svelte-docinfo outputs JSON describing your project's exported API. The data format is a hierarchy: modules contain declarations, and some declarations contain members or props.

Top-level structure
#

Programmatic entry points (analyze, analyzeFromFiles) return both modules and accumulated diagnostics:

{ modules: ModuleJson[], diagnostics: Diagnostic[] }

All surfaces emit this shape. The CLI's stdout JSON and the Vite plugin's virtual module both expose modules and diagnostics (matching AnalyzeResultJson). The CLI runs output through compactReplacer so empty arrays strip on the wire (an empty-project run emits {}); parse JSON consumers through AnalyzeResultJson to restore Zod defaults.

ModuleJson
#

A ModuleJson describes a single source file and its exports:

  • path: file path relative to the source root (e.g., "math.ts")
  • declarations: exported items from this module
  • moduleComment: file-level JSDoc comment, if present
  • dependencies: paths of modules this file imports
  • dependents: paths of modules that import this file
  • starExports: export * from './module' patterns

Array fields (declarations, dependencies, etc.) are omitted from JSON when empty and default to [] at runtime after parsing.

DeclarationJson
#

Each declaration is a DeclarationJson, a discriminated union on the kind field with nine variants:

  • "function": adds parameters, returnType, returnDescription, overloads
  • "variable": adds optional defaultValue (from @default), plus reactivity when the initializer is a Svelte rune ($state, $state.raw, $derived, $derived.by)
  • "class": adds members, extends, implements
  • "interface": adds members, extends
  • "type": adds members, intersects
  • "enum": adds members (enum values)
  • "component": adds props, intersects, acceptsChildren, lang (Svelte components)
  • "snippet": adds parameters (exported Svelte template snippets)
  • "namespace": adds module (the source module path projected under this binding); synthesized for export * as ns from './x'

Shared fields on all variants:

  • name, kind: identity. Default exports carry name === "default" (see Re-exports below)
  • docComment: JSDoc comment text
  • typeSignature: full type as a string
  • sourceLine: line number in the source file
  • modifiers: e.g., "readonly", "static", "getter"
  • genericParams: type parameters with constraints and defaults
  • examples, deprecatedMessage, seeAlso, throws, since: from standard JSDoc tags
  • mutates: from the non-standard @mutates tag, stored as Record<string, string> mapping target keys to descriptions. Keys are typically parameter names but compound paths (this.foo) and external state references are accepted as-is
  • alsoExportedFrom: modules that re-export this declaration
  • aliasOf: original name if this is a renamed re-export
  • partial: true when extraction failed partway through the declaration, indicating incomplete data

Declarations tagged with @nodocs are excluded from the output entirely and are also excluded from duplicate name checking.

Re-exports
#

Re-exports are encoded with two shapes, chosen by content:

  • Same-name: the canonical declaration carries an alsoExportedFrom array listing the modules that re-export it. One declaration, multiple import paths.
  • Renamed: a synthesized declaration appears in the re-exporting module with aliasOf: {module, name} pointing at the canonical. Inherits typeSignature, docComment, parameters, reactivity, and defaultValue from the canonical; sourceLine is undefined.
  • Star exports: export * from './x' patterns are tracked separately on ModuleJson.starExports and don't synthesize per-declaration entries.

When a re-export statement carries its own JSDoc or @nodocs, an alias is also synthesized in the re-exporting module so the local content has somewhere to live, even when the name is unchanged. The trigger is "presence of local content," not "presence of rename." Local doc-comment fields apply first and stick; canonical fields only fill gaps. @nodocs on a re-export suppresses both the link and the synthesis.

Default-slot entries carry name === "default" (see the shared-fields note above for why). Renames out of the default slot (export {default as Foo} from './x') carry name: "Foo" and aliasOf: {module, name: "default"}. Duplicate-name checks skip "default" since the default slot is module-scoped per the JS spec.

Namespace re-exports (export * as ns from './x') synthesize a NamespaceDeclarationJson with module pointing at the source the namespace projects. Consumers render ns.a / ns.b by reading the source module's declarations; namespaces don't inline members.

MemberJson
#

Classes, interfaces, types, and enums can contain MemberJson entries in their members arrays. MemberJson is a discriminated union on kind with three variants:

  • "function": methods and call signatures. Adds parameters, returnType, returnDescription, overloads
  • "constructor": class constructors and construct signatures. Adds parameters, overloads
  • "variable": properties, accessors, and index signatures. Adds optional defaultValue (from @default), plus reactivity for class fields initialized with a Svelte rune

Member kind is restricted to these three variants. Nesting is exactly one level deep: members never contain their own members.

Member name is the user-chosen identifier in most cases, but three synthesized sentinels appear when no source identifier exists: "constructor" (class constructor), "(construct)" (construct signature on an interface or type alias), and "(call)" (call signature on an interface or type alias).

ComponentPropJson
#

Component declarations have a props array of ComponentPropJson entries:

  • name, type: prop name and TypeScript type
  • optional: whether the prop is optional
  • description: from JSDoc on the prop
  • defaultValue: default value as a string, if present
  • bindable: set when the prop is declared with the $bindable() rune, so <Foo bind:value /> is supported. Modeled here (not via the variable-level reactivity field) because $props/$bindable are component-prop concerns
  • parameters: structured parameters for snippet-typed props (e.g., Snippet<[text: string]>), absent for non-snippet props
  • examples, deprecatedMessage, seeAlso, throws, since: symbol-scope JSDoc tags parsed from the prop's own doc comment (same shape as the declaration shared fields)

Asymmetry with ParameterJson. Props carry the symbol-scope tag fields above; function parameters deliberately don't. A prop is a named slot with its own documentation surface. A parameter is positional, and its @example/@deprecated/@since/@see/@throws belong on the enclosing function symbol per the TSDoc spec. Per-parameter content lives on ParameterJson.description from @param only.

ParameterJson
#

Functions, snippets, constructors, and snippet-typed component props use ParameterJson entries in their parameters arrays:

  • name: parameter name (e.g., "options", "...args")
  • type: resolved TypeScript type as a string
  • optional: whether the parameter has a ? token
  • rest: whether the parameter uses rest syntax (...args)
  • description: from @param JSDoc
  • defaultValue: default value expression from the source, if present

OverloadJson
#

Functions and constructors with multiple signatures use OverloadJson entries in their overloads arrays. Each overload captures only signature-scope content, the fields that can vary meaningfully per signature:

  • typeSignature: the full overload signature as a string
  • parameters: parameter list for this overload, with per-overload @param descriptions
  • returnType: return type for this overload (functions only)
  • genericParams: type parameters for this overload
  • docComment: per-overload JSDoc text, if present
  • returnDescription: from @returns on this overload

Symbol-scope JSDoc tags (@example, @deprecated, @since, @see, @throws, @mutates) describe the function as a whole and live on the parent declaration only, not duplicated per overload. The primary overload's JSDoc feeds the parent's symbol-level extraction; placing one of those tags on a non-primary overload signature emits a misplaced_tag warning and the tag is dropped (no synthetic content, no silent loss). Typo'd or stale @param keys produce unknown_param warnings the same way.

Reactivity
#

The reactivity field appears on VariableDeclarationJson and VariableMemberJson when the initializer is a value-producing Svelte rune call: $state, $state.raw, $derived, or $derived.by. Detection is purely syntactic and runs on every analyzed file regardless of extension, capturing the same patterns in a plain .ts file as in .svelte.ts or a component's <script>.

It covers variables (top-level and class fields). Function parameters and destructured bindings are not annotated even when the value flows from a rune. $props and $bindable are component-prop concerns and surface on ComponentPropJson's bindable field instead.

GenericParamJson
#

Declarations and members with type parameters use GenericParamJson entries in their genericParams arrays:

  • name: type parameter name (e.g., "T")
  • constraint: extends constraint, if present
  • defaultType: default type, if present

Working with type strings
#

Type signatures are opaque strings produced by the TypeScript compiler. To discover which in-project declaration names appear in a type string (e.g., for rendering clickable links), use findTypeReferences:

import {findTypeReferences} from 'svelte-docinfo'; const names = new Set(modules.flatMap(m => m.declarations.map(d => d.name))); findTypeReferences('Map<string, ModuleJson[]>', names); // => ['ModuleJson']

When scanning many type strings against the same set of names, pre-compile the patterns with buildTypeReferencePatterns to avoid recompiling regexes on every call.

Compact JSON and absent-as-false
#

By default, output uses compact JSON via compactReplacer: empty arrays, false booleans, and undefined fields are stripped, so optional, acceptsChildren, partial, rest, bindable, and similar fields vanish from the wire form when their value is the default. After parsing with the Zod schemas from types.ts (or AnalyzeResultJson for the full {modules, diagnostics} envelope), all defaults are restored, and the round-trip is lossless.

Raw-JSON consumers (e.g., jq, hand-rolled pipelines that skip .parse()) must treat absent as false; a literal decl.optional === false check silently fails because the key is gone. Use the schemas, or truthy/falsy checks (if (decl.optional) …) on raw JSON.

Examples
#

A TypeScript function:

{ "modules": [ { "path": "math.ts", "declarations": [ { "name": "clamp", "kind": "function", "docComment": "Clamp a number to a range.", "typeSignature": "(value: number, min: number, max: number): number", "parameters": [ {"name": "value", "type": "number"}, {"name": "min", "type": "number"}, {"name": "max", "type": "number"} ], "returnType": "number", "sourceLine": 2 } ] } ] }

A Svelte component with a snippet prop, children, and an exported snippet:

{ "modules": [ { "path": "Card.svelte", "declarations": [ { "name": "Card", "kind": "component", "docComment": "A card with a customizable header.", "acceptsChildren": true, "props": [ {"name": "title", "type": "string"}, { "name": "header", "type": "Snippet<[title: string]>", "optional": true, "description": "Custom header rendering.", "parameters": [ {"name": "title", "type": "string"} ] } ], "sourceLine": 1 }, { "name": "card_footer", "kind": "snippet", "docComment": "Default footer snippet.", "typeSignature": "Snippet<[text: string]>", "parameters": [ {"name": "text", "type": "string"} ], "sourceLine": 12 } ] } ] }

A rune module exporting reactive state (e.g., a .svelte.ts file):

{ "modules": [ { "path": "counter.svelte.ts", "declarations": [ { "name": "count", "kind": "variable", "typeSignature": "number", "reactivity": "$state", "sourceLine": 1 }, { "name": "doubled", "kind": "variable", "typeSignature": "number", "reactivity": "$derived", "sourceLine": 2 } ] } ] }

A function defined in math.ts and re-exported under a new name from the barrel index.ts. The canonical entry carries alsoExportedFrom if any module re-exports it under the same name. Renames synthesize a separate declaration with aliasOf:

{ "modules": [ { "path": "math.ts", "declarations": [ { "name": "clamp", "kind": "function", "typeSignature": "(value: number, min: number, max: number): number", "sourceLine": 2 } ] }, { "path": "index.ts", "declarations": [ { "name": "clampNumber", "kind": "function", "typeSignature": "(value: number, min: number, max: number): number", "aliasOf": {"module": "math.ts", "name": "clamp"} } ], "starExports": ["other.ts"] } ] }

See the types module for the full Zod schemas, and the API reference for all exported types.

tags #

svelte-docinfo extracts TSDoc/JSDoc tags from source comments and surfaces them as structured fields on the output. This page lists every tag that is parsed, where its value lands, and the rules that decide which symbol receives it.

Supported tags
#

TagWhere it lands
@paramParameterJson.description on the matching parameter. Unmatched keys emit unknown_param
@returnsreturnDescription on functions, function members, and per-overload OverloadJson. @return is not accepted
@throwsthrows array on the parent declaration. {Type} hints are extracted as the leading error type
@exampleexamples array on the parent declaration
@deprecateddeprecatedMessage on the parent declaration. Empty body still marks the symbol deprecated
@seeseeAlso array. Plain URLs, {@link} syntax, and module names all preserved in their source form
@sincesince string on the parent declaration
@defaultdefaultValue on variable declarations and variable members; falls back for ComponentPropJson.defaultValue when no destructuring default is present
@mutatesmutates record. Non-standard; same format as @param: @mutates key - description
@nodocsExcludes the declaration from output entirely; also excludes it from flat-namespace duplicate checking
@modulePromotes the comment to ModuleJson.moduleComment instead of attaching to a declaration

Symbol-scope vs signature-scope
#

Two tags vary per overload signature; the rest describe the symbol as a whole.

  • Signature-scope: @param and @returns. These flow to the matching overload's parameters[i].description / returnDescription. Each overload can carry its own.
  • Symbol-scope: @example, @deprecated, @since, @see, @throws, @mutates, @default, @nodocs. These describe the symbol as a whole and live on the parent declaration only.

Placing a symbol-scope tag on a non-primary overload emits misplaced_tag and the tag is dropped, with no synthetic content and no silent loss. Move it to the primary signature (typically the first overload, or the implementation signature's JSDoc which feeds the symbol-level extraction). See diagnostics for the diagnostic details.

/** * @example double(2) // 4 * @deprecated use `scale` instead */ export function double(n: number): number; export function double(n: bigint): bigint; export function double(n: number | bigint): number | bigint { return (n as any) * 2; }

@param matching and unknown_param
#

@param keys are matched against actual parameter names. The leading - separator is stripped (TypeScript's parser keeps it as syntax, not content). Destructured parameters match the outer binding name, not inner properties.

When a key doesn't match any parameter (a typo, a stale doc after a rename, or a description of a destructured property), the description is dropped and an unknown_param diagnostic fires with the orphaned key. Fix the JSDoc rather than relying on the silent fallback.

@mutates
#

Non-standard tag for documenting mutations to parameters or external state. Same key - description format as @param, but keys are not validated against the parameter list. Anything goes:

  • a parameter name: @mutates options - sets defaults in place
  • a compound path: @mutates this.cache - inserts the result
  • an external state reference: @mutates global_registry - registers the handler

The output is a Record<string, string> mapping each key to its description. Consumers decide how to render or group by key shape.

@nodocs
#

@nodocs on a declaration removes it from the analysis output entirely. Two follow-on effects:

  • Flat-namespace duplicate checking skips it: a hidden helper named parse can coexist with a public parse in another module without triggering duplicate_declaration.
  • Re-export synthesis is suppressed: @nodocs on a re-export statement drops both the alsoExportedFrom link and any synthesized alias declaration. The canonical entry stays untouched.

@module
#

A comment tagged @module attaches to the file rather than to the next declaration. The text lands on ModuleJson.moduleComment and is suppressed from any declaration docComment that might otherwise capture it.

/** * Date math utilities. * * @module */ export function add_days(d: Date, n: number): Date { /* ... */ }

Svelte files have a second module-comment source: an HTML comment directly above <script>. When both an HTML and a JSDoc @module comment supply a value for the same target, duplicate_comment fires with commentType: "module_comment". The same diagnostic covers declaration-level collisions (commentType: "doc_comment").

Re-exports inherit comments selectively
#

A re-export that carries its own JSDoc synthesizes an alias in the re-exporting module so the local content has somewhere to live, even when the name is unchanged. See Re-exports for the full encoding rules and merge order.

Tag-related diagnostics
#

Three diagnostic kinds surface tag-handling problems:

  • misplaced_tag: symbol-scope tag on a non-primary overload signature
  • unknown_param: @param key with no matching parameter
  • duplicate_comment: two sources supplied a comment for the same target (HTML + JSDoc @module, or any other collision)

All three are warnings; analysis still completes and the declaration is included. See tsdoc.ts for the parser internals and diagnostics for the full diagnostic schema.

diagnostics #

Analysis accumulates errors and warnings without halting. A failing declaration is marked partial: true and the rest of the module still analyzes. Detail lands in an array of Diagnostic entries, alongside modules in the result.

Two-tier error model
#

Accumulated (non-fatal): appended to the Diagnostic array, analysis continues. Covers type resolution failures, member or prop extraction failures, and JSDoc tag misuse. The return value is still valid but may carry partial: true on affected declarations.

Thrown (fatal): a small set of setup-level conditions throws from public entry points: missing tsconfig.json, Svelte <5 detected, or discovery: 'exports' mode with no resolvable exports. Wrap the top-level analyze / analyzeFromFiles call if you want to handle these. svelte2tsx transformation failures are not thrown; they flow as transform_failed diagnostics.

Shape
#

diagnostics is a plain Array<Diagnostic>, no wrapper, no methods. Round-trips through JSON.stringify / z.array(Diagnostic).parse, so it serializes alongside modules in CLI output and rehydrates cleanly.

{ modules: ModuleJson[], diagnostics: Diagnostic[] }

Each Diagnostic carries:

  • kind: discriminant, one per failure mode (see table below)
  • severity: "error" or "warning"
  • file: POSIX-form, project-relative (no leading ./). Rejoin with projectRoot for absolute paths.
  • line, column: 1-based, optional. Absent when there's no precise AST node (e.g., a module-level skip)
  • message: human-readable description
  • additional fields specific to the variant: symbolName, className, tagName, etc.

Diagnostic kinds
#

Severity is stable per kind: every kind below is warning severity except transform_failed and module_unreadable, which are always error.

kindWhen it fires
type_extraction_failedTrigger: type resolution threw on a symbol. Consequence: declaration included with partial: true and empty typeSignature.
signature_analysis_failedTrigger: function or method signature analysis threw, usually circular generics or unresolved call signatures. Consequence: declaration included with partial: true; parameters and overloads may be empty.
class_member_failedTrigger: one member of a class couldn't be analyzed. Consequence: member included with partial: true; siblings still extract normally.
svelte_prop_failedTrigger: a Svelte component prop type couldn't be resolved through the checker. Consequence: per-prop type resolution failures fall back to "any" for that prop with siblings unaffected; when the whole $props<T>() annotation type is itself unresolvable, the component's props array drops to empty.
module_skippedTrigger: whole module skipped during the analysis pass. reason narrows to "not_in_program", "no_analyzer", or "requires_program". Consequence: module absent from modules[].
module_unreadableTrigger: file named in package.json exports exists but readFile failed (permission denied, FS error). Consequence: file dropped from the discovered set. Discovery-time.
import_parse_failedTrigger: import parsing failed during dependency resolution. Consequence: the dependency edge is dropped; the module itself still analyzes. Ingest-time.
duplicate_commentTrigger: two sources supplied a comment for the same target (HTML @component + script JSDoc, or multiple @module comments). commentType narrows to "module_comment" or "doc_comment". Consequence: the higher-priority source wins: JSDoc for doc comments; instance <script> > <script module> > HTML comment for module comments.
misplaced_tagTrigger: symbol-scope tag (@example, @deprecated, @since, @see, @throws, @mutates, @default, @nodocs) found on a non-primary overload signature. Consequence: the tag is dropped; move it to the primary signature to keep it.
unknown_paramTrigger: @param key didn't match any actual parameter (typo or stale doc after a rename). Consequence: the description is dropped.
duplicate_declarationTrigger: a declaration name appears in more than one module, so the flat-namespace assumption collides. declarationName and modules name the conflict. Consequence: always emitted; onDuplicates only controls whether to additionally throw, log, or invoke a callback.
transform_failedTrigger: svelte2tsx threw on a .svelte file. Consequence: the file's ModuleJson is synthesized as a placeholder (partial: true, empty declarations). Ingest-time.
source_map_failedTrigger: source map parsing failed for a Svelte virtual file. Consequence: analysis continues using virtual positions, so downstream line/column may point into the svelte2tsx output rather than the original .svelte source. Ingest-time.
resolver_failedTrigger: import resolver threw on a specifier (vs. legitimately returning null for externals). specifier names the failing import. Consequence: the dependency edge is dropped. Ingest-time.

Each variant is a strict Zod object with its own extra fields. Use byKind to narrow to a specific variant for typed access:

import {byKind} from 'svelte-docinfo'; for (const d of byKind(diagnostics, 'misplaced_tag')) { // d.tagName, d.functionName, d.file, d.line are typed console.warn(`${d.functionName}: move @${d.tagName} to the primary overload`); }

severity vs partial
#

severity says how loud to be about a problem; partial: true says a specific declaration or member has incomplete data, typically from type_extraction_failed, signature_analysis_failed, class_member_failed, or svelte_prop_failed. Branch on partial directly; no need to cross-reference diagnostics by file and line.

Helpers
#

The diagnostics array is a plain Array<Diagnostic>: construct with [] and mutate with Array.push. Read helpers:

Consuming diagnostics
#

The CLI always emits the structured diagnostics field alongside modules in JSON output, and also prints warnings and errors to stderr:

npx svelte-docinfo | jq '.diagnostics | group_by(.kind) | map({kind: .[0].kind, count: length})'

Programmatically:

import {analyzeFromFiles, errorsOf, formatDiagnostic, byKind} from 'svelte-docinfo'; const {modules, diagnostics} = await analyzeFromFiles({projectRoot: process.cwd()}); // File paths in diagnostics are already project-relative. for (const d of errorsOf(diagnostics)) { console.error(formatDiagnostic(d)); } // Specific check: any @param typos? const stale = byKind(diagnostics, 'unknown_param'); if (stale.length) { console.warn(`${stale.length} stale @param tag(s); fix or remove`); }

The Vite plugin's virtual module exports both modules and diagnostics, so SvelteKit apps can render a doc-warnings page without re-running analysis:

import {modules, diagnostics} from 'virtual:svelte-docinfo'; import {hasErrors} from 'svelte-docinfo'; if (hasErrors(diagnostics)) { // surface in the UI or fail the build }

Absence rule
#

Optional scalar fields (line, column) drop on serialize per the same compact-output rules as the rest of the schema. See output format for the full rule. The Vite plugin's virtual module exposes modules and diagnostics as separate ES module exports, so they're always present even when empty. See diagnostics.ts for the Zod schemas and helper signatures.

api #

Browse the full api docs.