Skip to content
Packages

@docvia/ir

The dependency-light foundation: the Intermediate Representation, shared error system, contract interfaces, and the AST to IR transform.

@docvia/ir is the foundational package of docvia. It defines the framework-agnostic Intermediate Representation (IR) that every other package consumes or produces, the shared error system (docviaError), the contract interfaces that bind the compiler, renderers, and plugins together, the canonical docviaConfig shape, and the AST to IR transform that converts a parsed HAST tree into a normalized IRDocument.

This package is intentionally dependency-light. It pulls in only github-slugger for stable heading IDs. Everything else in the docvia toolchain depends on @docvia/ir, so keeping it small keeps the whole graph fast to install and cheap to typecheck. It carries no runtime dependency on zod, unified, or any rendering framework.

Installation

pnpm add @docvia/ir

Requires Node.js >=20.0.0. The package ships as ESM only ("type": "module").

Exports

@docvia/ir exposes two entry points. The . entry carries the full set of types, the error class, and the transform re-export; the ./transform subpath is a lighter entry for code that only needs the AST to IR conversion.

SubpathModuleContents
../dist/index.mjsAll IR types, contract interfaces, the docviaError class, the docviaConfig shape, and a re-export of transformToIR.
./transform./dist/transform.mjstransformToIR and normalizeProps.
import { docviaError, transformToIR } from "@docvia/ir";
import type { IRDocument, IRNode, docviaConfig } from "@docvia/ir";

// Lighter subpath when you only need the transform
import { transformToIR, normalizeProps } from "@docvia/ir/transform";

Error system

docvia uses a single error class across every package so that callers can catch one type and branch on a discriminant code.

docviaErrorCode

A string-literal union identifying which subsystem raised the failure.

CodeRaised when
SCHEMA_ERRORFrontmatter is malformed or fails validation.
PARSE_ERRORMarkdown parsing or source-tree reading fails.
TRANSFORM_ERRORThe AST to IR transform fails.
RENDER_ERRORA renderer adapter fails to produce output.
PLUGIN_ERRORA plugin is invalid or throws inside a lifecycle hook.
CONFIG_ERRORThe config file cannot be loaded or is not an object.
ASSET_ERRORAn asset cannot be resolved or emitted.

class docviaError

class docviaError extends Error {
  readonly name: "docviaError";
  constructor(
    code: docviaErrorCode,
    message: string,
    file?: string,
    loc?: { readonly line: number; readonly column: number },
    cause?: Error,
  );
}
FieldTypeDescription
codedocviaErrorCodeDiscriminant identifying the failing subsystem. Readonly.
messagestringHuman-readable description (inherited from Error).
filestring | undefinedAbsolute or relative path of the file being processed, when known. Readonly.
loc{ line: number; column: number } | undefinedSource location of the failure, when known. Readonly.
causeError | undefinedThe underlying error that triggered this one, for stack chaining. Readonly.
name"docviaError"Always the literal string "docviaError".
import { docviaError } from "@docvia/ir";

try {
  // ...some docvia operation
} catch (err) {
  if (err instanceof docviaError) {
    console.error(`[${err.code}] ${err.message}`);
    if (err.file) console.error(`  in ${err.file}`);
  }
}

IR node model

The IR is a tree of IRNode values. It is deliberately framework-agnostic: renderers (@docvia/renderer-react, @docvia/renderer-svelte, etc.) translate this tree into framework-specific output.

IRNodeType

The closed set of node types a tree may contain.

TypeMeaning
headingA heading (h1 to h6); props.depth carries the level.
paragraphA block of inline content.
textA plain text leaf; props.value holds the string.
emphasisEmphasized (italic) inline content.
strongStrong (bold) inline content.
code-blockA fenced code block; carries lang, value, meta.
inline-codeInline code span; props.value holds the string.
linkA hyperlink; carries href and optional title.
imageAn image; carries src, alt, optional title.
listAn ordered or unordered list; props.ordered distinguishes them.
list-itemA single list entry.
tableA GFM table.
table-rowA table row.
table-cellA table cell; props.tag is "th" or "td".
blockquoteA blockquote.
thematic-breakA horizontal rule.
componentA block-level component instance (from a container directive).
component-inlineAn inline component instance (from a leaf directive).
elementA generic, framework-agnostic HTML element passthrough.
unknownReserved fallback for unrecognized input.

HydrationMode

type HydrationMode = "none" | "client:load" | "client:idle" | "client:visible";

Declares when a component node should hydrate on the client. none keeps the component static (server-rendered only); client:load hydrates immediately; client:idle defers to the browser's idle callback; client:visible hydrates when the element scrolls into view.

IRNode

interface IRNode {
  readonly type: IRNodeType;
  readonly props: Readonly<Record<string, unknown>>;
  readonly children: readonly IRNode[];
  readonly id?: string;
}
FieldTypeDescription
typeIRNodeTypeThe node's kind.
propsReadonly<Record<string, unknown>>Normalized attributes. Class names are stored under class (never className); style is an inline string, never an object.
childrenreadonly IRNode[]Child nodes. Leaf nodes (text, code-block, inline-code, image, component-inline) have an empty array.
idstring | undefinedStable per-node ID (node-0, node-1, …) assigned during transform; used as a hydration anchor.

Dependency and metadata types

Dependency

A discriminated union describing an external resource a document references. Used by the compiler for incremental rebuilds and asset emission.

type Dependency =
  | { readonly type: "file"; readonly path: string }
  | { readonly type: "asset"; readonly path: string }
  | { readonly type: "component"; readonly name: string };
VariantFieldsSource
filepathA relative link to another .md document.
assetpathA relative image reference.
componentnameA directive-based component instance.

For file and asset dependencies, path is resolved against the document's directory and normalized to forward slashes.

HeadingMeta

interface HeadingMeta {
  readonly depth: number;
  readonly text: string;
  readonly id: string;
}
FieldTypeDescription
depthnumberHeading level, 1 to 6.
textstringThe heading's plain-text content.
idstringSlugified, collision-free anchor ID.

FrontmatterData

The validated frontmatter shape attached to every document.

interface FrontmatterData {
  readonly title: string;
  readonly description: string;
  readonly slug?: string;
  readonly tags: readonly string[];
  readonly draft?: boolean;
  readonly order?: number;
  readonly [key: string]: unknown;
}
FieldTypeDescription
titlestringPage title. Required.
descriptionstringPage description. Defaults to an empty string.
slugstring | undefinedExplicit slug override; bypasses slug computation.
tagsreadonly string[]Tag list. Defaults to an empty array.
draftboolean | undefinedMarks the page as a draft.
ordernumber | undefinedSort hint for navigation.
[key: string]unknownArbitrary extra fields validated by an extension schema.

Document and page types

IRDocument

The complete compiled representation of one source file.

interface IRDocument {
  readonly slug: string;
  readonly frontmatter: FrontmatterData;
  readonly children: readonly IRNode[];
  readonly headings: readonly HeadingMeta[];
  readonly dependencies: readonly Dependency[];
  readonly contentHash: string;
}
FieldTypeDescription
slugstringThe page's route slug.
frontmatterFrontmatterDataValidated frontmatter.
childrenreadonly IRNode[]The IR node tree for the document body.
headingsreadonly HeadingMeta[]All headings in document order, for tables of contents.
dependenciesreadonly Dependency[]Deduplicated file, asset, and component references.
contentHashstringComposite content hash. Left empty by transformToIR; the compiler fills it in with config and dependency inputs.

PageMeta

A lightweight metadata record describing a compiled page, without the node tree.

interface PageMeta {
  readonly slug: string;
  readonly title: string;
  readonly description: string;
  readonly headings: readonly HeadingMeta[];
  readonly contentHash: string;
  readonly lastModified: number;
  readonly tags: readonly string[];
  readonly order?: number;
}
FieldTypeDescription
slugstringRoute slug.
titlestringPage title (from frontmatter).
descriptionstringPage description.
headingsreadonly HeadingMeta[]Heading metadata.
contentHashstringComposite content hash.
lastModifiednumberUnix-millisecond timestamp of the build.
tagsreadonly string[]Tags from frontmatter.
ordernumber | undefinedOptional sort hint.

Renderer contract

RenderedPage

The output a RendererAdapter produces for one page.

interface RenderedPage {
  readonly slug: string;
  readonly code: string;
  readonly contentHash: string;
  readonly map?: RawSourceMap;
  readonly assets?: readonly AssetReference[];
  readonly imports?: readonly string[];
}
FieldTypeDescription
slugstringThe rendered page's slug.
codestringGenerated module source code.
contentHashstringCarries through from the IRDocument.
mapRawSourceMap | undefinedOptional source map for code.
assetsreadonly AssetReference[] | undefinedAssets the renderer emitted.
importsreadonly string[] | undefinedModule specifiers the generated code imports.

RawSourceMap

A standard V3 source-map object: version, sources, names, mappings, plus optional file, sourceRoot, and sourcesContent.

AssetReference

interface AssetReference {
  readonly originalPath: string;
  readonly emittedPath: string;
  readonly hash: string;
}
FieldTypeDescription
originalPathstringPath of the asset in the source tree.
emittedPathstringPath the asset was emitted to.
hashstringContent hash of the asset.

RendererAdapter

The interface a rendering backend must implement.

interface RendererAdapter {
  readonly name: string;
  renderPage(doc: IRDocument): Promise<RenderedPage>;
  renderManifest(pages: readonly PageMeta[]): Promise<string>;
}
MemberSignatureDescription
namestringAdapter identifier, e.g. "react".
renderPage(doc: IRDocument) => Promise<RenderedPage>Renders one document into a module.
renderManifest(pages: readonly PageMeta[]) => Promise<string>Renders a manifest module covering all pages.

Compiler contract

FileEntry

A single source file read from disk.

interface FileEntry {
  readonly path: string;
  readonly relativePath: string;
  readonly content: string;
  readonly hash: string;
}
FieldTypeDescription
pathstringAbsolute path to the file.
relativePathstringPath relative to the source directory, forward-slashed.
contentstringRaw file contents.
hashstringHash of the file contents.

CompilerOptions

interface CompilerOptions {
  readonly sourceDir: string;
  readonly outDir: string;
  readonly renderer: RendererAdapter;
  readonly plugins: readonly docviaPlugin[];
  readonly config: docviaConfig;
  readonly projectRoot?: string;
  readonly incremental?: boolean;
}
FieldTypeDescription
sourceDirstringDirectory containing markdown sources.
outDirstringDirectory the generated module graph is written to.
rendererRendererAdapterThe rendering backend.
pluginsreadonly docviaPlugin[]Plugins to run through the pipeline.
configdocviaConfigThe resolved configuration object.
projectRootstring | undefinedRoot for resolving relative paths and emitting docvia-env.d.ts. Defaults to process.cwd().
incrementalboolean | undefinedWhen true (default), uses the on-disk cache; pass false to force a full rebuild.

CompileResult

interface CompileResult {
  readonly pages: readonly PageMeta[];
  readonly searchIndex?: string;
  readonly duration: number;
  readonly stats: {
    readonly total: number;
    readonly compiled: number;
    readonly cached: number;
  };
}
FieldTypeDescription
pagesreadonly PageMeta[]Metadata for every compiled page.
searchIndexstring | undefinedOptional serialized search index.
durationnumberWall-clock build time in milliseconds.
stats.totalnumberTotal source files discovered.
stats.compilednumberFiles compiled fresh this run.
stats.cachednumberFiles served from the incremental cache.

Plugin contract

HookPhase

type HookPhase = "pre" | "normal" | "post";

Plugins run in phase order: all pre plugins, then all normal, then all post. Within a phase, priority breaks the tie. The default phase is normal.

docviaPlugin

interface docviaPlugin {
  readonly name: string;
  readonly version: string;
  readonly phase?: HookPhase;
  readonly priority?: number;
  cacheKey?(): string;
  beforeParse?(file: FileEntry): Promise<FileEntry> | FileEntry;
  afterParse?(ast: unknown, file: FileEntry): Promise<unknown> | unknown;
  beforeTransform?(ast: unknown, meta: FrontmatterData): Promise<unknown> | unknown;
  afterTransform?(doc: IRDocument): Promise<IRDocument> | IRDocument;
  beforeRender?(doc: IRDocument): Promise<IRDocument> | IRDocument;
}
MemberTypeDescription
namestringUnique plugin name. Required.
versionstringPlugin version. Required.
phaseHookPhase | undefinedPipeline phase. Defaults to "normal".
prioritynumber | undefinedTie-breaker within a phase; lower runs first. Defaults to 100.
cacheKey() => stringReturns a string folded into the build cache key.
beforeParsehookRuns on the raw FileEntry before markdown parsing.
afterParsehookRuns on the parsed AST.
beforeTransformhookRuns on the AST just before the IR transform.
afterTransformhookRuns on the IRDocument after the transform.
beforeRenderhookRuns on the IRDocument just before rendering.

Config types

ComponentConfig

interface ComponentConfig {
  readonly path: string;
  readonly hydrate?: boolean;
  readonly defaultProps?: Record<string, unknown>;
}
FieldTypeDescription
pathstringModule path to the component.
hydrateboolean | undefinedWhether the component hydrates on the client.
defaultPropsRecord<string, unknown> | undefinedProps merged into every instance.

CollectionConfig

interface CollectionConfig {
  readonly name: string;
  readonly sourceDir: string;
  readonly baseUrl?: string;
}
FieldTypeDescription
namestringCollection identifier.
sourceDirstringSource directory for the collection.
baseUrlstring | undefinedURL prefix for the collection's routes.

FrontmatterSchema

A duck-typed interface compatible with z.ZodObject<any>. It is defined here so @docvia/ir can describe the frontmatter config field without depending on zod. It exposes a safeParse(data) method returning a success/error result and a readonly shape record.

docviaConfig

The resolved configuration object consumed across the toolchain.

interface docviaConfig {
  readonly sourceDir: string;
  readonly outDir: string;
  readonly plugins: readonly docviaPlugin[];
  readonly renderer?: RendererAdapter;
  readonly components?: Record<string, ComponentConfig>;
  readonly collections?: readonly CollectionConfig[];
  readonly frontmatter?: FrontmatterSchema;
  readonly markdown: { readonly remarkPlugins: readonly unknown[] };
  readonly syntax: {
    readonly highlighter: "shiki" | "prism";
    readonly theme: string;
    readonly langs: readonly string[];
  };
  readonly theme: {
    readonly name: string;
    readonly options: Readonly<Record<string, unknown>>;
  };
}
FieldTypeDescription
sourceDirstringDefault markdown source directory.
outDirstringOutput directory for the generated module graph.
pluginsreadonly docviaPlugin[]Configured plugins.
rendererRendererAdapter | undefinedRendering backend.
componentsRecord<string, ComponentConfig> | undefinedComponent registry, keyed by directive name.
collectionsreadonly CollectionConfig[] | undefinedNamed content collections.
frontmatterFrontmatterSchema | undefinedZod schema extending the base frontmatter validation.
markdown.remarkPluginsreadonly unknown[]User remark plugins inserted into the parse pipeline.
syntax.highlighter"shiki" | "prism"Syntax highlighter backend.
syntax.themestringHighlighter theme name.
syntax.langsreadonly string[]Languages to preload.
theme.namestringSite theme name.
theme.optionsReadonly<Record<string, unknown>>Theme-specific options.

The AST to IR transform

transformToIR

function transformToIR(
  ast: HastRoot,
  frontmatter: FrontmatterData,
  filePath: string,
): IRDocument

Available from both @docvia/ir and @docvia/ir/transform. It walks a HAST (HTML AST) tree and produces a normalized IRDocument.

During the walk it:

  • Normalizes props. Runs every element's attributes through normalizeProps so className becomes class and style objects become inline strings.
  • Collects headings. Records each h1 to h6 with its depth, plain text, and a collision-free slug generated by github-slugger.
  • Collects dependencies. Relative .md links become file dependencies, relative image src values become asset dependencies, and directives become component dependencies. Duplicates are removed.
  • Drops blocked tags. script, iframe, object, and embed elements are silently removed for security.
  • Maps directives to component nodes. Container directives become component nodes, leaf directives become component-inline nodes. Directive attributes (passed through as data-prop-* attributes) are decoded, type-coerced, and a hydrate value is extracted.
  • Maps semantic tags. Known HTML tags (p, ul, a, img, strong, etc.) become their semantic IR node types; unrecognized tags fall through to a generic element node.
  • Assigns stable IDs. Every node gets a sequential node-N id for hydration anchoring.
  • Computes the slug. See computeSlug below.

The returned IRDocument.contentHash is left as an empty string; the compiler computes the real composite hash later.

normalizeProps

function normalizeProps(
  properties?: Record<string, unknown>,
): Record<string, unknown>

Available from @docvia/ir/transform. Enforces the IR prop contract:

  • null and undefined values are dropped.
  • className (a string or HAST string array) is joined and re-emitted as class.
  • A style object is flattened to a key:value;key:value inline string.
  • All other keys pass through unchanged.

Slug computation

transformToIR derives the document slug with the following rules. If frontmatter.slug is set, it is used verbatim. Otherwise the file path is transformed:

  • Backslashes are converted to forward slashes.
  • A trailing .md extension is stripped.
  • A trailing /index segment is stripped.
  • If the result is empty, the slug becomes "index".

So guide/intro.md yields guide/intro, guide/index.md yields guide, and index.md yields index.

Usage example

import { parseMarkdown } from "@docvia/core";
import { extractFrontmatter, validateFrontmatter } from "@docvia/schema";
import { transformToIR, docviaError } from "@docvia/ir";
import type { IRDocument } from "@docvia/ir";

async function buildDocument(
  rawSource: string,
  relativePath: string,
): Promise<IRDocument> {
  try {
    const { data, content } = extractFrontmatter(rawSource);
    const frontmatter = validateFrontmatter(data, relativePath);
    const { ast } = await parseMarkdown(content);
    return transformToIR(ast, frontmatter, relativePath);
  } catch (err) {
    if (err instanceof docviaError) {
      throw new Error(`[${err.code}] ${relativePath}: ${err.message}`);
    }
    throw err;
  }
}