From b326b51e81e7497cb5bc86ee824631c2390b44fe Mon Sep 17 00:00:00 2001 From: limina1 Date: Thu, 6 Nov 2025 11:48:07 -0500 Subject: [PATCH] Add wiki link support to compose editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements [[term]], [[w:term]], and [[d:term]] wiki link syntax: - New wiki_links.ts utility for parsing and tag generation - Extract wiki links from content and generate w/d tags - CodeMirror syntax highlighting (violet/cyan/amber) - Preview displays extracted wiki tags separately from hashtags - Tutorial documentation for wiki link usage Per WIKI_TAG_SPEC.md: - [[term]] and [[w:term]] generate w-tags (references/mentions) - [[d:term]] generates d-tags (definitions) - Custom display text: [[term|display text]] 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/lib/components/ZettelEditor.svelte | 176 +++++++++++++++++--- src/lib/utils/publication_tree_processor.ts | 9 + src/lib/utils/wiki_links.ts | 145 ++++++++++++++++ 3 files changed, 310 insertions(+), 20 deletions(-) create mode 100644 src/lib/utils/wiki_links.ts diff --git a/src/lib/components/ZettelEditor.svelte b/src/lib/components/ZettelEditor.svelte index 6ca51b5..1ca01bc 100644 --- a/src/lib/components/ZettelEditor.svelte +++ b/src/lib/components/ZettelEditor.svelte @@ -23,6 +23,7 @@ } from "$lib/utils/asciidoc_publication_parser"; import { getNdkContext } from "$lib/ndk"; import Asciidoctor from "asciidoctor"; + import { extractWikiLinks } from "$lib/utils/wiki_links"; // Initialize Asciidoctor processor const asciidoctor = Asciidoctor(); @@ -350,6 +351,45 @@ provide: (f) => EditorView.decorations.from(f), }); + // State field to track wiki link decorations + const wikiLinkDecorations = StateField.define({ + create(state) { + return createWikiLinkDecorations(state); + }, + update(decorations, tr) { + // Update decorations when content changes + if (tr.docChanged) { + return createWikiLinkDecorations(tr.state); + } + return decorations.map(tr.changes); + }, + provide: (f) => EditorView.decorations.from(f), + }); + + // Function to create wiki link decorations + function createWikiLinkDecorations(state: EditorState): DecorationSet { + const ranges: Array<{ from: number; to: number; decoration: any }> = []; + const content = state.doc.toString(); + const wikiLinks = extractWikiLinks(content); + + for (const link of wikiLinks) { + const className = + link.type === 'auto' + ? 'cm-wiki-link-auto' + : link.type === 'w' + ? 'cm-wiki-link-ref' + : 'cm-wiki-link-def'; + + ranges.push({ + from: link.startIndex, + to: link.endIndex, + decoration: Decoration.mark({ class: className }), + }); + } + + return RangeSet.of(ranges.map((r) => r.decoration.range(r.from, r.to))); + } + // Function to create header decorations based on parsed sections function createHeaderDecorations( state: EditorState, @@ -682,6 +722,28 @@ fontWeight: "500", fontStyle: "italic", }, + // Wiki links + ".cm-wiki-link-auto": { + color: "#8B5CF6", // violet-500 for [[term]] (auto) + fontWeight: "500", + backgroundColor: "rgba(139, 92, 246, 0.1)", + padding: "2px 4px", + borderRadius: "3px", + }, + ".cm-wiki-link-ref": { + color: "#06B6D4", // cyan-500 for [[w:term]] (reference) + fontWeight: "500", + backgroundColor: "rgba(6, 182, 212, 0.1)", + padding: "2px 4px", + borderRadius: "3px", + }, + ".cm-wiki-link-def": { + color: "#F59E0B", // amber-500 for [[d:term]] (definition) + fontWeight: "500", + backgroundColor: "rgba(245, 158, 11, 0.1)", + padding: "2px 4px", + borderRadius: "3px", + }, }); const state = EditorState.create({ @@ -690,6 +752,7 @@ basicSetup, markdown(), // AsciiDoc is similar to markdown syntax headerDecorations, + wikiLinkDecorations, headerHighlighting, EditorView.updateListener.of((update) => { if (update.docChanged) { @@ -962,16 +1025,38 @@ {section.title} - - {#if section.tags && section.tags.length > 0} -
- {#each section.tags as tag} - - #{tag[1]} - - {/each} + + {@const tTags = section.tags?.filter((tag) => tag[0] === 't') || []} + {@const wTags = section.tags?.filter((tag) => tag[0] === 'w') || []} + + {#if tTags.length > 0 || wTags.length > 0} +
+ + {#if tTags.length > 0} +
+ {#each tTags as tag} + + #{tag[1]} + + {/each} +
+ {/if} + + + {#if wTags.length > 0} +
+ {#each wTags as tag} + + 🔗 {tag[2] || tag[1]} + + {/each} +
+ {/if}
{/if}
@@ -1001,16 +1086,38 @@ )} - - {#if section.tags && section.tags.length > 0} -
- {#each section.tags as tag} - - #{tag[1]} - - {/each} + + {@const tTags = section.tags?.filter((tag) => tag[0] === 't') || []} + {@const wTags = section.tags?.filter((tag) => tag[0] === 'w') || []} + + {#if tTags.length > 0 || wTags.length > 0} +
+ + {#if tTags.length > 0} +
+ {#each tTags as tag} + + #{tag[1]} + + {/each} +
+ {/if} + + + {#if wTags.length > 0} +
+ {#each wTags as tag} + + 🔗 {tag[2] || tag[1]} + + {/each} + {/if} @@ -1274,6 +1381,35 @@ Understanding the nature of knowledge...
+ +
+

+ Wiki Links +

+

+ Create semantic links between content using wiki link syntax: +

+
    +
  • + [[term]] + - Auto link (queries both w and d tags) +
  • +
  • + [[w:term]] + - Reference/mention (backward link) +
  • +
  • + [[d:term]] + - Definition link (forward link) +
  • +
  • + Custom text: [[term|display text]] +
  • +
+

+ Example: "The concept of [[Knowledge Graphs]] enables..." creates a w-tag automatically. +

+
diff --git a/src/lib/utils/publication_tree_processor.ts b/src/lib/utils/publication_tree_processor.ts index 0cf36a8..38a59d8 100644 --- a/src/lib/utils/publication_tree_processor.ts +++ b/src/lib/utils/publication_tree_processor.ts @@ -11,6 +11,7 @@ import { PublicationTree } from "$lib/data_structures/publication_tree"; import { NDKEvent } from "@nostr-dev-kit/ndk"; import type NDK from "@nostr-dev-kit/ndk"; import { getMimeTags } from "$lib/utils/mime"; +import { extractWikiLinks, wikiLinksToTags } from "$lib/utils/wiki_links"; // For debugging tree structure const DEBUG = process.env.DEBUG_TREE_PROCESSOR === "true"; @@ -674,6 +675,14 @@ function createContentEvent( // Add segment attributes as tags addSectionAttributesToTags(tags, segment.attributes); + // Extract and add wiki link tags from content + const wikiLinks = extractWikiLinks(segment.content); + if (wikiLinks.length > 0) { + const wikiTags = wikiLinksToTags(wikiLinks); + tags.push(...wikiTags); + console.log(`[TreeProcessor] Added ${wikiTags.length} wiki link tags:`, wikiTags); + } + event.tags = tags; event.content = segment.content; diff --git a/src/lib/utils/wiki_links.ts b/src/lib/utils/wiki_links.ts new file mode 100644 index 0000000..0cf811e --- /dev/null +++ b/src/lib/utils/wiki_links.ts @@ -0,0 +1,145 @@ +/** + * Wiki link parsing and tag generation utilities + * Supports [[term]], [[w:term]], and [[d:term]] syntax + */ + +export interface WikiLink { + fullMatch: string; + type: 'w' | 'd' | 'auto'; // auto means [[term]] without explicit prefix + term: string; + displayText: string; + startIndex: number; + endIndex: number; +} + +/** + * Extracts all wiki links from AsciiDoc content. + * Supports three formats: + * - [[term]] - Auto (will query both w and d tags) + * - [[w:term]] - Explicit reference/mention (backward link) + * - [[d:term]] - Explicit definition (forward link) + */ +export function extractWikiLinks(content: string): WikiLink[] { + const wikiLinks: WikiLink[] = []; + + // Match [[prefix:term]] or [[term]] + // Captures: optional prefix (w: or d:), term, optional display text after | + const regex = /\[\[(?:(w|d):)?([^\]|]+)(?:\|([^\]]+))?\]\]/g; + + let match; + while ((match = regex.exec(content)) !== null) { + const prefix = match[1]; // 'w', 'd', or undefined + const term = match[2].trim(); + const customDisplay = match[3]?.trim(); + + wikiLinks.push({ + fullMatch: match[0], + type: prefix ? (prefix as 'w' | 'd') : 'auto', + term, + displayText: customDisplay || term, + startIndex: match.index, + endIndex: match.index + match[0].length, + }); + } + + return wikiLinks; +} + +/** + * Converts a term to a clean tag format (lowercase, hyphenated). + * Example: "Knowledge Graphs" -> "knowledge-graphs" + */ +export function termToTag(term: string): string { + return term + .toLowerCase() + .trim() + .replace(/\s+/g, '-') + .replace(/[^a-z0-9-]/g, ''); +} + +/** + * Generates Nostr event tags from wiki links. + * Format: ['w', 'tag-slug', 'Display Text'] or ['d', 'tag-slug'] + */ +export function wikiLinksToTags(wikiLinks: WikiLink[]): string[][] { + const tags: string[][] = []; + + for (const link of wikiLinks) { + const tagSlug = termToTag(link.term); + + if (link.type === 'w' || link.type === 'auto') { + // Reference tag includes display text + tags.push(['w', tagSlug, link.displayText]); + } + + if (link.type === 'd') { + // Definition tag (no display text, it IS the thing) + tags.push(['d', tagSlug]); + } + } + + return tags; +} + +/** + * Replaces wiki link syntax with HTML for preview rendering. + * Can be customized for different rendering styles. + */ +export function renderWikiLinksToHtml( + content: string, + options: { + linkClass?: string; + wLinkClass?: string; + dLinkClass?: string; + onClickHandler?: (type: 'w' | 'd' | 'auto', term: string) => string; + } = {}, +): string { + const { + linkClass = 'wiki-link', + wLinkClass = 'wiki-link-reference', + dLinkClass = 'wiki-link-definition', + onClickHandler, + } = options; + + return content.replace( + /\[\[(?:(w|d):)?([^\]|]+)(?:\|([^\]]+))?\]\]/g, + (match, prefix, term, customDisplay) => { + const displayText = customDisplay?.trim() || term.trim(); + const type = prefix ? prefix : 'auto'; + const tagSlug = termToTag(term); + + // Determine CSS classes + let classes = linkClass; + if (type === 'w') classes += ` ${wLinkClass}`; + else if (type === 'd') classes += ` ${dLinkClass}`; + + // Generate href or onclick + const action = onClickHandler + ? `onclick="${onClickHandler(type, tagSlug)}"` + : `href="#wiki/${type}/${encodeURIComponent(tagSlug)}"`; + + // Add title attribute showing the type + const title = + type === 'w' + ? 'Wiki reference (mentions this concept)' + : type === 'd' + ? 'Wiki definition (defines this concept)' + : 'Wiki link (searches both references and definitions)'; + + return `${displayText}`; + }, + ); +} + +/** + * Converts wiki links to plain text (for content storage). + * Preserves the display text if custom, otherwise uses the term. + */ +export function wikiLinksToPlainText(content: string): string { + return content.replace( + /\[\[(?:w|d:)?([^\]|]+)(?:\|([^\]]+))?\]\]/g, + (match, term, customDisplay) => { + return customDisplay?.trim() || term.trim(); + }, + ); +}