Properties4
2The transformation pipeline is the beating heart of Lithos, converting your raw Obsidian markdown into production-ready HTML. This multi-stage process preserves the integrity of your source files while applying sophisticated transformations that make Obsidian syntax work seamlessly on the web. Understanding this pipeline reveals how Lithos achieves true Obsidian compatibility without compromising performance or developer experience.
The Full Pipeline: Vault to HTML
The journey from markdown file to rendered web page involves seven distinct stages, each with specific responsibilities and error handling.
File Discovery via Symlink
The pipeline begins with Nuxt Content discovering markdown files. Rather than copying your vault into the project, Lithos uses a symlink strategy: the content/ directory is symbolically linked to your vault/ directory. This means Nuxt Content sees your notes as if they were inside the project, but they remain in their original location.
This symlink approach has profound implications. First, it ensures your vault is never modified by the build process. Every transformation happens in memory, preserving the source of truth. Second, it enables hot module reload during development—when you edit a note in Obsidian, the change propagates instantly to the dev server without manual refresh. Third, it allows Lithos to work with vaults stored anywhere on your filesystem, not just inside the project directory.
The symlink is typically created during project initialization or via the Lithos CLI. If you're manually setting up Lithos, running ln -s ../vault content (on Unix systems) or the equivalent on Windows establishes this critical link. Nuxt Content then treats the symlinked directory like any other content source, applying its file watchers and change detection mechanisms.
content:file:beforeParse Hook
Once Nuxt Content loads a markdown file, it passes through the content:file:beforeParse hook before any parsing occurs. This is where Lithos intervenes to perform raw text transformations that convert Obsidian syntax into formats Nuxt Content can understand.
The obsidian-transform module registers a handler for this hook, receiving the file object with its raw body string. At this stage, the content is pure markdown text—no AST, no parsed frontmatter, just a string. This is the ideal moment to apply regex-based substitutions that convert wikilinks, embeds, callouts, and other Obsidian-specific patterns into MDC components or standard markdown.
The module also injects file metadata at this stage. For example, it reads the file's modification time from the filesystem and inserts an mtime field into the frontmatter. This happens synchronously to avoid async complexity during the parsing phase. Similarly, the Daily Notes module uses this hook to detect date patterns and inject daily note metadata like isDailyNote: true and computed date fields.
Raw Text Transformations
The actual transformations happen through a series of regex replacements applied to the raw markdown string. Each transformation targets a specific Obsidian syntax pattern and converts it to a Nuxt-compatible equivalent.
Wikilinks: alias
Wikilinks are the most fundamental Obsidian feature. The pattern [[Page Name]] or [[page-name|Display Text]] must be converted into either standard markdown links or MDC component syntax. Lithos uses the remark-wiki-link plugin during the Remark phase, but link extraction happens here in the beforeParse hook.
The transformation extracts the target and alias from the wikilink syntax, then looks up the target in the permalinkMap (built during file discovery). This map resolves "Page Name" to its actual path, like /features/page-name, accounting for numeric prefixes, folder structure, and case sensitivity. The resolved path becomes the href for a standard markdown link, ensuring navigation works correctly.
// Before: [[Getting Started|Start here]]
// After: [Start here](/guide/getting-started)
If the target cannot be resolved (a broken link), Lithos can either leave it as-is (rendering as plain text), replace it with a special "broken link" component, or log a warning. The default behavior is graceful degradation: unresolved links render as plain text with a distinct style, preventing broken pages while signaling the issue visually.
Embeds:
Embeds use the exclamation mark prefix: ![[Other Note]]. These must be transformed into a custom MDC component that can fetch and render the target note's content. The transformation converts ![[target]] into ::note-embed{src="target"}, which Nuxt Content then parses as a component.
The NoteEmbed component, rendered on the client, uses Nuxt Content's queryContent API to fetch the target note and displays it in an expandable/collapsible card. This maintains the semantic meaning of the embed while adapting it to web UX patterns—inline embedding can be overwhelming, so the collapsible pattern gives readers control.
Image embeds (![[image.png]]) are handled similarly, but the component detects file extensions and renders an <img> tag instead of fetching markdown content. This automatic type detection simplifies authoring—you write embeds the same way in Obsidian, and Lithos figures out the rendering strategy.
Callouts: > !type Title
Obsidian callouts (also called admonitions) use a special blockquote syntax: > [!note] This is a note. Lithos transforms these into MDC component syntax: ::callout{type="note"}. The callout title and body are parsed and passed as component props.
The transformation must handle nested markdown within callouts, including inline formatting, lists, and code blocks. The regex carefully preserves indentation and structure, ensuring that complex callouts with multiple paragraphs or nested elements render correctly. The Callout component then applies Obsidian-style theming with color-coded icons and backgrounds based on the type (note, warning, tip, etc.).
Foldable callouts (> [!note]- Collapsed by default) are also detected, with the fold state passed as a prop. This enables progressive disclosure—readers expand callouts when they need additional context, keeping the main text uncluttered.
Inline Bases: dataview blocks
While Dataview is a plugin, not core Obsidian, Lithos supports basic inline query syntax by transforming ```dataview code blocks into ::base component calls. The transformation parses the dataview query (typically a simple TABLE or LIST FROM "folder" format) and maps it to equivalent ::base props like path, layout, and fields.
This transformation is lossy—Lithos doesn't support the full Dataview query language. Complex queries with expressions or formulas may not work. However, the most common pattern (list all notes in a folder, sort by date) translates cleanly, covering 80% of use cases without requiring users to learn a new syntax.
ABC Notation for Music
For users who embed musical notation using ABC notation (via plugins like ABC.js), Lithos can transform ```abc code blocks into a custom component that renders sheet music using the abcjs library. This is an optional transformation enabled via configuration, demonstrating the extensibility of the pipeline for domain-specific content types.
The transformation wraps the ABC code in a ::music-notation component, which renders on the client using a JavaScript library. This keeps the server-side transformation simple (just wrapping the code block) while delegating complex rendering to the client, where it can be hydrated interactively.
Remark/Rehype Plugin Chain
After the beforeParse transformations, Nuxt Content parses the markdown into an Abstract Syntax Tree (AST) using the Remark library. This AST then passes through a chain of Remark and Rehype plugins that apply further transformations.
remark-math and rehype-katex
Mathematical notation in LaTeX format (e.g., $\int x dx$ or $$\sum_{i=1}^n i$$) is handled by the remark-math and rehype-katex plugin pair. remark-math identifies math syntax in the markdown AST, and rehype-katex converts it to HTML with MathML or CSS-based rendering using the KaTeX library.
This two-stage approach is necessary because math syntax is markdown-level (it appears in the raw text), but rendering requires HTML-level transformations (inserting <span> elements with special classes and styles). The plugin chain enables this separation of concerns, keeping each transformation focused and composable.
KaTeX is preferred over MathJax because it's faster and produces smaller bundles. The server-side rendering means math appears instantly on page load, with no client-side JavaScript execution required for the initial render. This improves performance and accessibility, as screen readers can parse the generated MathML.
remark-wiki-link for Advanced Resolution
While basic wikilink transformation happens in the beforeParse hook, the remark-wiki-link plugin provides more sophisticated handling during the Remark phase. It has access to the full markdown AST, allowing it to resolve links in context—for example, handling links inside tables, list items, or nested blockquotes differently.
The plugin also integrates with the permalinkMap, using it to resolve ambiguous links. If multiple files have similar names (e.g., "Guide.md" in both /features/ and /examples/), the plugin can apply heuristics to choose the correct target based on folder proximity or explicit disambiguation in the link syntax.
This plugin is where Obsidian's "shortest unique path" logic is implemented. If you link to [[features/guide|Guide]] in Obsidian, the plugin resolves it to /features/guide instead of /examples/guide, matching Obsidian's behavior. This ensures high-fidelity compatibility with how links work in your local vault.
Custom Remark Plugins for Obsidian Syntax
Lithos includes several custom Remark plugins to handle edge cases in Obsidian syntax. For example, remark-obsidian-embeds processes embed syntax that was converted to ::note-embed{} in the beforeParse hook, ensuring the component AST node is structured correctly for Nuxt Content's MDC renderer.
Another custom plugin, remark-obsidian-tags, parses inline tags (e.g., #project/lithos) and converts them into structured data nodes. These tag nodes are indexed during the afterParse hook and made queryable via the content API. This allows you to create tag-based archive pages or filter content by tags in the Structured Data system.
These custom plugins demonstrate the extensibility of the pipeline. If you need to support a specific Obsidian plugin or community syntax, you can write a Remark plugin that integrates seamlessly into the existing chain, without modifying core Lithos code.
content:file:afterParse Hook
After the markdown is parsed and transformed into an AST, the content:file:afterParse hook fires. At this stage, the file object includes parsed frontmatter, a structured AST, and metadata like title and description. Lithos uses this hook for metadata extraction and graph construction.
Permalink Map Construction
The afterParse hook builds the permalinkMap, which maps every possible reference to a page (filename, title, aliases) to its canonical path. This map is critical for link resolution during the beforeParse phase of subsequent files.
For each file, the hook extracts the title, filename, and any aliases from the frontmatter. It then creates lowercase, normalized versions of each name and stores them in the map. For example, a file with title "Getting Started" and alias "Quick Start" creates map entries for "getting started", "quick start", and the filename "getting-started.md".
This map is stored in memory during the build process and serialized to .nuxt/permalink-map.json for use by API routes and client-side components. In development mode, the map is rebuilt on every file change to ensure link resolution stays accurate as you edit your vault.
Link Extraction for Graph
The afterParse hook also extracts all outgoing links from each file by traversing the AST and collecting link nodes. These links are stored in the extractedLinks map, which records which files link to which other files. This data structure forms the basis of the Interactive Graph and the Backlinks system.
Link extraction at the AST level is more reliable than regex-based extraction from raw text because the AST disambiguates links from code blocks, inline code, and escaped syntax. For example, if you include a literal wikilink in a code example ([[example]]), the AST knows it's code and excludes it from the link graph.
The extracted links are paired with the permalink map to resolve link targets. If a link points to "Getting Started", the map resolves it to /guide/getting-started, and the graph records an edge from the current file to that path. This two-step process (extract, then resolve) ensures the graph uses canonical paths consistently.
Metadata Enrichment
During the afterParse hook, Lithos also enriches file metadata based on conventions. For example, if a file is in a folder called "blog", it might automatically receive a type: blog property. If a file has a cover property in its frontmatter, it's marked as hasCover: true to enable optimized rendering logic.
This convention-based metadata reduces manual configuration. You don't need to tag every blog post as type: blog if you organize them in a blog folder—the system infers it. This "convention over configuration" principle keeps your markdown clean while enabling sophisticated querying and filtering.
AST Transformation: Obsidian Syntax to MDC
The final transformation stage converts the Remark AST (which represents markdown structure) into an MDC-compatible AST that Nuxt Content can render. MDC (Markdown Components) is Nuxt Content's extension to markdown that allows Vue components to be embedded using special syntax.
Component Node Injection
During this stage, special syntax like ::callout{type="note"} is converted into AST nodes of type component. These nodes include a name property (the component name) and a props object (the component's props). Nuxt Content's renderer then maps these nodes to Vue components, hydrating them on the client.
The transformation must preserve slot content. For example, a callout with body text becomes a component node with a default slot containing the body's AST. This ensures nested markdown (bold text, links, code blocks) inside the callout renders correctly as part of the component's slot content.
Handling Nested Components
Obsidian supports nested structures, like a callout inside a list item or an embed inside a callout. The AST transformation must respect this nesting, creating a hierarchical component tree that mirrors the original structure. This is handled by recursive traversal of the AST, where each node is transformed in context of its parent.
If nesting conflicts arise (e.g., a component that doesn't support slots receives slot content), the transformation logs a warning and gracefully degrades by flattening the structure. This defensive design prevents build failures from edge cases while alerting developers to potential issues.
Preserving Markdown in Component Props
Some components receive markdown-formatted text as props (e.g., the title prop of a callout might include bold or inline code). The transformation must either pre-render this markdown to HTML or pass it as a raw string for client-side rendering.
Lithos typically pre-renders prop markdown to HTML strings, ensuring they appear instantly without client-side processing. This is done using Remark's html renderer, which converts the prop's AST subtree to an HTML string. The component then receives the HTML and injects it using v-html, with appropriate sanitization to prevent XSS.
Graph Data Extraction and Serving
The graph data extracted during the afterParse hook is serialized and exposed via the /api/graph endpoint, making it available to both server-side rendering and client-side components.
Data Serialization
After all files are processed, Lithos writes two JSON files to .nuxt/: permalink-map.json and extracted-links.json. These files contain the resolved permalink mappings and the raw link graph, respectively. Storing them as JSON allows the data to be loaded synchronously by API routes without re-parsing markdown.
The serialization format is optimized for size and lookup speed. The permalink map is a flat object with keys being normalized names and values being paths. The extracted links are an array of [source, target] tuples, which is compact and easy to iterate. This simple format ensures fast cold starts during development and minimal bundle size in production.
/api/graph Endpoint Construction
The /api/graph route reads the serialized JSON files and constructs the full graph representation. It queries Nuxt Content for metadata about each node (title, tags, folder), then combines it with the link data to produce a JSON response with nodes and edges arrays.
This server-side construction ensures the graph is always consistent with the current content. In development mode, the API reads from the .nuxt/ directory, which is regenerated on file changes. In production (SSG mode), the API is pre-rendered to a static JSON file at /api/graph.json, ensuring instant load times without server processing.
The API also performs defensive filtering. Broken links (targets that don't resolve to real pages) are excluded from the edges array, preventing the graph from showing phantom nodes. Nodes with no incoming or outgoing edges (orphans) can optionally be included or excluded based on configuration, giving you control over graph density.
Error Resilience and Debugging
The pipeline is designed to fail gracefully, logging warnings instead of crashing when it encounters unexpected input.
Try-Catch Wrapping
Each transformation stage is wrapped in try-catch blocks. If a regex fails to match, a link can't be resolved, or a component prop is malformed, the error is caught, logged to the console, and the transformation is skipped. The original content passes through unchanged, ensuring the page still renders (even if not perfectly).
This defensive approach is critical for iterative development. You can write a note with experimental syntax, and the build won't fail—it'll just log a warning. You can then fix the syntax and rebuild without restarting the dev server or losing your place.
Parse Error Placeholders
If a file has frontmatter syntax errors (invalid YAML) or markdown syntax errors (unclosed code blocks), Lithos injects a visible error placeholder at the top of the rendered page. This placeholder includes the error message and the file path, making debugging straightforward.
The page still renders, showing all content below the error. This is better than a blank page or a build failure, as it allows you to see the context of the error and fix it in place. In production builds, these placeholders can be disabled (causing errors to log silently), but in development, they're invaluable.
Link Validation and Reporting
During the build, Lithos collects all unresolved wikilinks and outputs a summary report at the end. This report lists all broken links by source file, making it easy to audit your content for dead references. You can configure the build to fail (exit code 1) if broken links are detected, enforcing link hygiene in CI/CD pipelines.
The validation also detects ambiguous links (where multiple files could match the same wikilink) and warns about them. This prevents silent misrouting, where a link goes to the wrong page because two files have the same name in different folders.
npm run build regularly to see the link validation report. Fixing broken links improves SEO and reader experience, and the report makes it easy to find and fix them systematically.beforeParse and afterParse hooks to ensure deterministic ordering. Async operations (like fetching external data) should happen in Nuxt's build:before hook to avoid race conditions.