Lix Plugin .md

Plugin for Lix that tracks changes in Markdown files.

It parses Markdown into the @opral/markdown-wc AST, tracks top-level blocks as entities, and renders rich diffs via HTML Diff.

Installation

npm install @lix-js/sdk @lix-js/plugin-md

Quick start

import { openLix } from "@lix-js/sdk";
import { plugin as markdownPlugin } from "@lix-js/plugin-md";

const lix = await openLix({ providePlugins: [markdownPlugin] });

Insert a Markdown file

const file = await lix.db
	.insertInto("file")
	.values({
		path: "/notes.md",
		data: new TextEncoder().encode(`# Heading\n\nFirst paragraph.`),
	})
	.returningAll()
	.executeTakeFirstOrThrow();

Update the file

await lix.db
	.updateTable("file")
	.set({
		data: new TextEncoder().encode(
			`# Heading\n\nFirst paragraph.\n\nNew note.`,
		),
	})
	.where("id", "=", file.id)
	.execute();

Query file history

Retrieve previous versions of the file:

const history = await lix.db
	.selectFrom("file_history")
	.where("path", "=", "/notes.md")
	.select(["data", "lixcol_commit_id"])
	.execute();

for (const version of history) {
	const content = new TextDecoder().decode(version.data);
	console.log(`Commit ${version.lixcol_commit_id}: ${content}`);
}

Advanced usage

Query all headings in a file

Each markdown block (heading, paragraph, list, etc.) is stored as a structured entity, enabling queries like "get all headings":

const headings = await lix.db
	.selectFrom("state")
	.where("file_id", "=", file.id)
	.where("schema_key", "=", "markdown_wc_heading")
	.select(["entity_id", "snapshot_content"])
	.execute();

for (const heading of headings) {
	const node = heading.snapshot_content;
	const depth = node.depth; // 1-6
	const text = node.children?.[0]?.value;
	console.log(`H${depth}: ${text}`);
}

Query the history of a specific heading

Track how a specific block changed over time using state_history:

// Get the history of a specific heading across all checkpoints
const headingHistory = await lix.db
	.selectFrom("state_history")
	.where("entity_id", "=", headingEntityId)
	.where("schema_key", "=", "markdown_wc_heading")
	.where("root_commit_id", "=", latestCommitId)
	.orderBy("depth", "asc")
	.select(["snapshot_content", "depth", "commit_id"])
	.execute();

for (const state of headingHistory) {
	const text = state.snapshot_content.children?.[0]?.value;
	console.log(`Depth ${state.depth}: "${text}" (commit: ${state.commit_id})`);
}
// Depth 0: "Updated Title" (commit: abc123)
// Depth 1: "Original Title" (commit: def456)

Programmatically update markdown content

Useful for rich-text editing frameworks like TipTap, or AI agents that update slices of a markdown document without rewriting the entire file:

// Update just the heading, leaving other blocks unchanged
await lix.db
	.updateTable("state")
	.set({
		snapshot_content: {
			type: "heading",
			depth: 1,
			data: { id: entityId },
			children: [{ type: "text", value: "Updated Title" }],
		},
	})
	.where("entity_id", "=", entityId)
	.where("schema_key", "=", "markdown_wc_heading")
	.where("file_id", "=", file.id)
	.execute();

// The file content is automatically updated by the plugin

Query document structure

Get the ordered list of block IDs to understand document structure:

const doc = await lix.db
	.selectFrom("state")
	.where("file_id", "=", file.id)
	.where("schema_key", "=", "markdown_wc_document")
	.where("entity_id", "=", "root")
	.select("snapshot_content")
	.executeTakeFirst();

const blockOrder = doc?.snapshot_content?.order; // ["heading_1", "para_1", "para_2"]

Schemas

The plugin uses markdown-wc schemas to represent markdown AST nodes. Each top-level block is stored as an entity with its own schema.

Document order schema

Schema keyDescription
markdown_wc_documentStores the order array of block entity IDs

Block-level schemas (persisted as entities)

Schema keyNode typeDescription
markdown_wc_headingheadingHeading blocks (h1-h6, depth: 1-6)
markdown_wc_paragraphparagraphParagraph blocks
markdown_wc_listlistList blocks (ordered/unordered)
markdown_wc_blockquoteblockquoteBlockquote blocks
markdown_wc_codecodeFenced code blocks (lang, value)
markdown_wc_tabletableGFM tables
markdown_wc_thematic_breakthematicBreakHorizontal rules
markdown_wc_htmlhtmlRaw HTML blocks
markdown_wc_yamlyamlYAML frontmatter

Inline schemas (nested within blocks)

Schema keyNode typeDescription
markdown_wc_texttextPlain text
markdown_wc_strongstrongBold text
markdown_wc_emphasisemphasisItalic text
markdown_wc_deletedeleteStrikethrough text
markdown_wc_linklinkLinks (url, title)
markdown_wc_imageimageImages (url, alt, title)
markdown_wc_inline_codeinlineCodeInline code
markdown_wc_breakbreakHard line breaks
markdown_wc_list_itemlistItemList items (checked for task lists)
markdown_wc_table_rowtableRowTable rows
markdown_wc_table_celltableCellTable cells

Snapshot content structure

Each block's snapshot_content follows the mdast structure:

// Heading example
{
	type: "heading",
	depth: 2,
	data: { id: "block_abc123" },
	children: [
		{ type: "text", value: "Section Title" }
	]
}

// Paragraph with formatting
{
	type: "paragraph",
	data: { id: "block_def456" },
	children: [
		{ type: "text", value: "This is " },
		{ type: "strong", children: [{ type: "text", value: "bold" }] },
		{ type: "text", value: " text." }
	]
}

// List example
{
	type: "list",
	ordered: false,
	data: { id: "block_ghi789" },
	children: [
		{
			type: "listItem",
			checked: null, // or true/false for task lists
			children: [
				{ type: "paragraph", children: [{ type: "text", value: "Item 1" }] }
			]
		}
	]
}

How it works

  • Block-level entities: Each top-level mdast node (paragraphs, headings, lists, tables, code blocks, etc.) is stored as its own entity. The root entity keeps the ordering of those blocks.
  • Stable IDs without markup: IDs are minted automatically and kept out of the serialized Markdown, so you do not need to add markers to your documents.
  • Nested awareness: Nested nodes (list items, table cells, inline spans) get ephemeral IDs during diffing to align edits but are not persisted as separate entities.
  • Similarity-based matching: The detector uses textual similarity and position hints to decide whether a block was edited, moved, inserted, or deleted, even when headings or paragraphs change slightly.
  • Apply & render: applyChanges rebuilds Markdown from stored snapshots, and renderDiff produces an HTML diff (using @lix-js/html-diff) that highlights before/after content with data-diff-key markers.

Limitations and tips

  • Changes are tracked per top-level block; inline-level differences are aggregated into the parent block.
  • Replacing an entire block with unrelated content will be treated as a delete + insert instead of a modification.
  • Large documents are supported, but providing reasonably distinct headings/paragraphs improves block matching when content is rearranged.