Browse Source

Add wiki link support to compose editor

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 <noreply@anthropic.com>
master
limina1 4 months ago
parent
commit
b326b51e81
  1. 176
      src/lib/components/ZettelEditor.svelte
  2. 9
      src/lib/utils/publication_tree_processor.ts
  3. 145
      src/lib/utils/wiki_links.ts

176
src/lib/components/ZettelEditor.svelte

@ -23,6 +23,7 @@ @@ -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 @@ @@ -350,6 +351,45 @@
provide: (f) => EditorView.decorations.from(f),
});
// State field to track wiki link decorations
const wikiLinkDecorations = StateField.define<DecorationSet>({
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 @@ @@ -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 @@ @@ -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 @@ @@ -962,16 +1025,38 @@
{section.title}
</h2>
<!-- Tags (blue for index events) -->
{#if section.tags && section.tags.length > 0}
<div class="flex flex-wrap gap-2">
{#each section.tags as tag}
<span
class="bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 px-2 py-1 rounded-full text-xs font-medium"
>
#{tag[1]}
</span>
{/each}
<!-- Tags and wiki links -->
{@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}
<div class="space-y-2">
<!-- Hashtags (t-tags) -->
{#if tTags.length > 0}
<div class="flex flex-wrap gap-2">
{#each tTags as tag}
<span
class="bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 px-2 py-1 rounded-full text-xs font-medium"
>
#{tag[1]}
</span>
{/each}
</div>
{/if}
<!-- Wiki links (w-tags) -->
{#if wTags.length > 0}
<div class="flex flex-wrap gap-2">
{#each wTags as tag}
<span
class="bg-cyan-100 text-cyan-800 dark:bg-cyan-900 dark:text-cyan-200 px-2 py-1 rounded text-xs font-medium border border-cyan-300 dark:border-cyan-700"
title="Wiki reference: {tag[1]}"
>
🔗 {tag[2] || tag[1]}
</span>
{/each}
</div>
{/if}
</div>
{/if}
</div>
@ -1001,16 +1086,38 @@ @@ -1001,16 +1086,38 @@
)}
</div>
<!-- Tags (green for content events) -->
{#if section.tags && section.tags.length > 0}
<div class="flex flex-wrap gap-2">
{#each section.tags as tag}
<span
class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-2 py-1 rounded-full text-xs font-medium"
>
#{tag[1]}
</span>
{/each}
<!-- Tags and wiki links (green for content events) -->
{@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}
<div class="space-y-2">
<!-- Hashtags (t-tags) -->
{#if tTags.length > 0}
<div class="flex flex-wrap gap-2">
{#each tTags as tag}
<span
class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-2 py-1 rounded-full text-xs font-medium"
>
#{tag[1]}
</span>
{/each}
</div>
{/if}
<!-- Wiki links (w-tags) -->
{#if wTags.length > 0}
<div class="flex flex-wrap gap-2">
{#each wTags as tag}
<span
class="bg-cyan-100 text-cyan-800 dark:bg-cyan-900 dark:text-cyan-200 px-2 py-1 rounded text-xs font-medium border border-cyan-300 dark:border-cyan-700"
title="Wiki reference: {tag[1]}"
>
🔗 {tag[2] || tag[1]}
</span>
{/each}
</div}
{/if}
</div>
{/if}
@ -1274,6 +1381,35 @@ Understanding the nature of knowledge... @@ -1274,6 +1381,35 @@ Understanding the nature of knowledge...
</li>
</ul>
</div>
<div>
<h4 class="font-medium text-gray-900 dark:text-gray-100 mb-2">
Wiki Links
</h4>
<p class="text-xs mb-2">
Create semantic links between content using wiki link syntax:
</p>
<ul class="space-y-2 text-xs">
<li>
<code class="bg-violet-100 dark:bg-violet-900/30 px-1 py-0.5 rounded">[[term]]</code>
<span class="text-gray-600 dark:text-gray-400">- Auto link (queries both w and d tags)</span>
</li>
<li>
<code class="bg-cyan-100 dark:bg-cyan-900/30 px-1 py-0.5 rounded">[[w:term]]</code>
<span class="text-gray-600 dark:text-gray-400">- Reference/mention (backward link)</span>
</li>
<li>
<code class="bg-amber-100 dark:bg-amber-900/30 px-1 py-0.5 rounded">[[d:term]]</code>
<span class="text-gray-600 dark:text-gray-400">- Definition link (forward link)</span>
</li>
<li class="mt-2">
<strong>Custom text:</strong> <code class="bg-gray-100 dark:bg-gray-800 px-1 py-0.5 rounded">[[term|display text]]</code>
</li>
</ul>
<p class="text-xs mt-2 text-gray-600 dark:text-gray-400">
Example: "The concept of [[Knowledge Graphs]] enables..." creates a w-tag automatically.
</p>
</div>
</div>
</div>
</div>

9
src/lib/utils/publication_tree_processor.ts

@ -11,6 +11,7 @@ import { PublicationTree } from "$lib/data_structures/publication_tree"; @@ -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( @@ -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;

145
src/lib/utils/wiki_links.ts

@ -0,0 +1,145 @@ @@ -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 `<a class="${classes}" ${action} title="${title}" data-wiki-type="${type}" data-wiki-term="${tagSlug}">${displayText}</a>`;
},
);
}
/**
* 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();
},
);
}
Loading…
Cancel
Save