logo
  • Docs
  • Plugins
  • API Reference
    Overview
    JSON
    CSV
    Markdown
    ProseMirror
    Build your own
    Previous pageCSVNext pageProseMirror
    npm
    @lix-js/plugin-md
    GitHub
    View source code

    #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.