← Changelog

remarkLosslessWikilinks ships in 0.3.0 — site-resolved Obsidian wikilinks for the LFM pipeline

0.3.0 lands a new plugin that turns Obsidian's [[Page|Display]] syntax into proper hyperlinks — with one design constraint baked in: LFM never decides where a wikilink points. Sites supply a resolver function; LFM owns the syntax. The package now does internal vs external routing, anchors, alias displays, and graceful plain-text fallback for unresolved links — all behind one new option on parseMarkdown.

Why Care?

LFM has supported Obsidian-flavored callouts (> [!info]) since 0.1.0. The other half of Obsidian’s authoring vocabulary — wikilinks — has been conspicuously missing. Authors writing in an Obsidian vault use [[Vocabulary/Polyrepo|Polyrepo]] and [[concepts/Naming Conventions]] the way most of us use ordinary markdown links. When that content shipped to a static site, the wikilinks rendered as raw [[...]] text. Reader sees [[Vocabulary/Polyrepo|Polyrepo]] mid-paragraph and thinks “broken site.”

0.3.0 fixes it — but with a deliberate constraint that took some work to land:

LFM never decides where a wikilink points. A wikilink like [[Vocabulary/Polyrepo]] resolves to lossless.group/more-about/polyrepo from one site, glossary.example.com/polyrepo from another, and /notes/polyrepo (local route) from a third. Each site has different content collections, different external destinations, different “this is local vs external” rules. Picking a default would be wrong for every consumer except the one we picked.

So the new plugin takes a resolver function from the consumer and asks it to map each wikilink to a URL. The plugin owns the syntax: regex parsing, MDAST splice, link-node shape, code-fence skipping. The site owns the destinations: which prefix routes where, what’s local vs external, what’s intentionally parked.

flowchart LR
    subgraph LFM ["@lossless-group/lfm — owns syntax"]
        Regex[wikilink regex]
        Walker[hand-rolled MDAST walker]
        Splice[splice text node into<br/>text + link + text]
        Regex --> Walker --> Splice
    end

    subgraph Site ["consumer site — owns destinations"]
        Resolver["resolver(input)<br/>→ { url, isLocal, display } | null"]
    end

    Splice -->|"input"| Resolver
    Resolver -->|"resolution"| Splice

    classDef lfm fill:#9138E0,stroke:#fff,color:#fff
    classDef site fill:#22A6B5,stroke:#fff,color:#fff
    class Regex,Walker,Splice lfm
    class Resolver site

Rendered output for resolved wikilinks is a standard link MDAST node — no custom node type, no custom renderer component. Whatever your AstroMarkdown / rehype-react / Markdoc surface does for regular links also handles wikilinks. The only change is two CSS classes (wikilink wikilink--local, wikilink wikilink--external) on the anchor for sites that want to style them.

What’s New?

Walks every text MDAST node and replaces wikilink syntax matches in place. Runs after parse, before render. Supported syntax shapes:

AuthoredResolves to
[[Page]]<a href="resolved-url">Page</a>
[[Page|Display]]<a href="resolved-url">Display</a>
[[folder/Page]]<a href="resolved-url">Page</a> (display = last segment, deslugified)
[[folder/Page|Display]]<a href="resolved-url">Display</a>
[[Page#Section]]<a href="resolved-url#section">Page</a>
[[folder/Page#Section|Display]]<a href="resolved-url#section">Display</a>

Wikilinks inside fenced code blocks, inline code, and HTML stay literal — the walker skips those node types. Documenting wikilink syntax in code examples now works the way you’d expect.

Quick-start

import { parseMarkdown } from '@lossless-group/lfm';

const tree = await parseMarkdown(content, {
  wikilinks: {
    resolver: (input) => {
      const path = input.path.toLowerCase();

      if (path.startsWith('essays/')) {
        const slug = path.slice('essays/'.length).replace(/\s+/g, '-');
        return {
          url: `/essays/${slug}`,
          isLocal: true,                    // → wikilink--local class, no target=_blank
          display: input.display ?? input.path.split('/').pop() ?? '',
        };
      }

      if (path.startsWith('vocabulary/') || path.startsWith('concepts/')) {
        const slug = path.replace(/^[^/]+\//, '').replace(/\s+/g, '-');
        return {
          url: `https://www.lossless.group/more-about/${slug}`,
          isLocal: false,                   // → wikilink--external + target=_blank
          display: input.display ?? input.path.split('/').pop() ?? '',
        };
      }

      return null;                          // unresolved → plain text fallback
    },
    onUnresolved: (input) => {
      console.log(`[wikilinks] unresolved: ${input.raw}`);
    },
  },
});

Resolver contract

import type {
  WikilinkResolverInput,
  WikilinkResolution,
  WikilinkOptions,
} from '@lossless-group/lfm';

interface WikilinkResolverInput {
  path: string;          // "Vocabulary/Build Systems" — original casing
  anchor: string | null; // "Section Heading" or null (no `#`)
  display: string | null;// author-supplied, or null
  raw: string;           // the literal "[[...]]"
}

interface WikilinkResolution {
  url: string;          // "/essays/foo-bar" or "https://..."
  isLocal: boolean;     // drives class + target attributes
  display: string;      // final visible anchor text
  classes?: string[];   // extra CSS classes after the base `wikilink wikilink--{local|external}`
}

interface WikilinkOptions {
  resolver: (input: WikilinkResolverInput) => WikilinkResolution | null;
  onUnresolved?: (input: WikilinkResolverInput) => void;
}

Operating principle: supporting 40% of intended wikilinks is better than supporting none. When the resolver returns null, the plugin emits a plain text MDAST node containing only the display string (or the path’s last segment, with .md stripped and hyphens turned to spaces). No <a>, no class, no markup hint that anything was ever a wikilink. The reader sees ordinary prose and isn’t punished for an incomplete config.

flowchart TD
    Match["[[Vocabulary/Polyrepo|Polyrepo]]"]
    Resolve{resolver returns?}
    Match --> Resolve

    Resolve -->|"WikilinkResolution"| Anchor["&lt;a class='wikilink wikilink--external'<br/>target='_blank'<br/>href='https://...'&gt;Polyrepo&lt;/a&gt;"]
    Resolve -->|"null"| Plain["plain text node:<br/>Polyrepo"]
    Plain --> Callback["onUnresolved(input)<br/>(optional)"]

    classDef ok fill:#22A6B5,stroke:#fff,color:#fff
    classDef plain fill:#1f2937,stroke:#94a3b8,color:#fff
    class Anchor ok
    class Plain,Callback plain

The onUnresolved callback is the suggested integration point for an audit log — a per-build report of paths that fell through every rule. The reference site (mpstaton-site) pipes it into a regenerate-on-demand markdown file that grouped 354 unique wikilink paths across 15 prefixes; 208 of them resolved on the first pass via 7 prefix rules. The remaining 146 either matched explicitly-deferred prefixes (no public destination yet) or were bare wikilinks pointing at vault-root stubs. None of them broke the page.

Plugin pipeline order

remarkLosslessWikilinks slots between remarkLosslessCallouts and remarkOgFetcher in the remarkLfm preset. The order matters for two reasons:

flowchart LR
    Parse[remarkParse]
    GFM[remarkGfm]
    Directive[remarkDirective]
    Callouts[remarkCallouts]
    Citations[remarkCitations]
    Wikilinks[remarkLosslessWikilinks]
    Og[remarkOgFetcher<br/>opt-in]
    LinkPreview[remarkLinkPreview]

    Parse --> GFM --> Directive --> Callouts --> Citations --> Wikilinks --> Og --> LinkPreview

    classDef new fill:#04E5E5,stroke:#000,color:#000
    class Wikilinks new
  1. After callouts so wikilinks inside > [!info] callout bodies still resolve. (The plan codified in [[Maintain-Lossless-Markdown-and-Extended-Markdown-Render-Pipeline]] §7 — callouts are containers; everything LFM does should work nested inside one.)
  2. Before the og-fetcher and link-preview annotators so wikilinks-resolved-to-external-URLs can pick up OG metadata and link-preview hover cards on the same equal footing as ordinary markdown links.

Pass wikilinks: false (or simply omit it) to skip the plugin entirely. It’s opt-in by default precisely because no resolver = no destinations = nothing to do.

A Real Resolver

The reference implementation in mpstaton-site is the most opinionated example. Three rule shapes carry it:

// PrefixRule — most common
{ prefix: 'tooling/',    template: 'https://lossless.group/toolkit/{slug}' }
{ prefix: 'vocabulary/', template: 'https://lossless.group/more-about/{slug}' }
{ prefix: 'essays/',     template: '/essays/{slug}', isLocal: true }

// ExactRule — one-off overrides
{ path: 'projects/special-case', url: 'https://example.com/somewhere-else' }

// DeferredRule — deliberately parked, won't pollute the audit
{ prefix: 'organizations/', reason: 'No public destination yet' }

The resolver iterates exact rules first (overrides win), then prefix rules (first match in declared order wins), then deferred prefixes (return null, don’t log to audit), then unconditionally null for everything else. ~60 lines total. It’s a worthwhile reference even for sites that just need a flat URL map; the data-as-config pattern scales without rewriting the resolver function as the rule count grows.

Full reference: sites/mpstaton-site/scripts/wikilink-rules.ts in the astro-knots repo.

What’s Coming

The other planned work for 0.3.0 — renaming the existing four plugins (remarkCallouts, remarkCitations, remarkLinkPreview, remarkOgFetcher) into the remarkLossless* namespace, plus renaming createLfmProcessor() → lfmPipeline() — is deferred to a future minor. Today’s release is additive only: every existing import still works exactly as before. The new plugin is the only behavioral change. We’ll bundle the namespace cleanup with the next batch of changes that warrants a coordinated breaking release.

  • [[Wikilink-Resolution-System]] (astro-knots/context-v/plans) — the architectural plan that drove this feature.
  • [[mpstaton-site/2026-05-08_02]] — the consumer-side story: audit → rules → plugin → live rendering.
  • 0.2.3 ([[2026-05-08_01]]) — earlier today, callout regex fixes (multi-line, hyphen-friendly types, foldable syntax).