Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(docs): cross-document wiki-style links (#72)

Add [[Document Name]] wiki-link syntax for cross-document navigation.

- Pure logic module (wiki-links.ts): parseWikiLinks, resolveDocByName,
buildDocUrl, wikiLinkLabel
- TipTap extension (wiki-link.ts): inline node with input rule that
converts [[...]] to clickable wiki links
- 20 unit tests covering parsing, resolution, and URL building
- CSS styling for wiki links with hover state and unresolved indicator

+378
+19
src/css/app.css
··· 6624 6624 } 6625 6625 } 6626 6626 6627 + /* Wiki links */ 6628 + a.wiki-link { 6629 + color: var(--color-teal); 6630 + text-decoration: none; 6631 + border-bottom: 1px dashed var(--color-teal); 6632 + cursor: pointer; 6633 + padding: 0 0.1em; 6634 + border-radius: 2px; 6635 + transition: background 0.15s; 6636 + } 6637 + a.wiki-link:hover { 6638 + background: oklch(0.85 0.06 195 / 0.2); 6639 + } 6640 + a.wiki-link[href="#"] { 6641 + color: var(--color-text-faint); 6642 + border-bottom-color: var(--color-text-faint); 6643 + font-style: italic; 6644 + } 6645 + 6627 6646 /* Version badge */ 6628 6647 .version-badge { 6629 6648 position: fixed;
+123
src/docs/extensions/wiki-link.ts
··· 1 + /** 2 + * Wiki Link — TipTap inline node for [[Document Name]] cross-links. 3 + * 4 + * Renders as a clickable link with a special wiki-link class. 5 + * Stores the document name as text; resolution happens at render time 6 + * via the document list fetched from the server. 7 + */ 8 + 9 + import { Node, mergeAttributes, InputRule } from '@tiptap/core'; 10 + 11 + export interface WikiLinkOptions { 12 + HTMLAttributes: Record<string, string>; 13 + } 14 + 15 + declare module '@tiptap/core' { 16 + interface Commands<ReturnType> { 17 + wikiLink: { 18 + insertWikiLink: (docName: string, docId?: string, docType?: string) => ReturnType; 19 + }; 20 + } 21 + } 22 + 23 + export const WikiLink = Node.create<WikiLinkOptions>({ 24 + name: 'wikiLink', 25 + 26 + group: 'inline', 27 + 28 + inline: true, 29 + 30 + atom: true, 31 + 32 + addOptions() { 33 + return { 34 + HTMLAttributes: {}, 35 + }; 36 + }, 37 + 38 + addAttributes() { 39 + return { 40 + docName: { 41 + default: '', 42 + parseHTML: (el: HTMLElement) => el.getAttribute('data-wiki-name') || el.textContent || '', 43 + renderHTML: (attrs: Record<string, string>) => ({ 'data-wiki-name': attrs.docName }), 44 + }, 45 + docId: { 46 + default: null, 47 + parseHTML: (el: HTMLElement) => el.getAttribute('data-wiki-id'), 48 + renderHTML: (attrs: Record<string, string | null>) => { 49 + if (!attrs.docId) return {}; 50 + return { 'data-wiki-id': attrs.docId }; 51 + }, 52 + }, 53 + docType: { 54 + default: 'doc', 55 + parseHTML: (el: HTMLElement) => el.getAttribute('data-wiki-type') || 'doc', 56 + renderHTML: (attrs: Record<string, string>) => ({ 'data-wiki-type': attrs.docType }), 57 + }, 58 + }; 59 + }, 60 + 61 + parseHTML() { 62 + return [ 63 + { tag: 'a[data-wiki-name]' }, 64 + ]; 65 + }, 66 + 67 + renderHTML({ node, HTMLAttributes }) { 68 + const docName = node.attrs.docName || ''; 69 + const docId = node.attrs.docId; 70 + const docType = node.attrs.docType || 'doc'; 71 + 72 + const href = docId 73 + ? `/${docType === 'sheet' ? 'sheets' : 'docs'}/${docId}` 74 + : '#'; 75 + 76 + return [ 77 + 'a', 78 + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { 79 + class: 'wiki-link', 80 + href, 81 + 'data-wiki-name': docName, 82 + }), 83 + `[[${docName}]]`, 84 + ]; 85 + }, 86 + 87 + renderText({ node }) { 88 + return `[[${node.attrs.docName}]]`; 89 + }, 90 + 91 + addCommands() { 92 + return { 93 + insertWikiLink: 94 + (docName: string, docId?: string, docType?: string) => 95 + ({ commands }) => { 96 + return commands.insertContent({ 97 + type: this.name, 98 + attrs: { docName, docId: docId || null, docType: docType || 'doc' }, 99 + }); 100 + }, 101 + }; 102 + }, 103 + 104 + addInputRules() { 105 + return [ 106 + new InputRule({ 107 + // Match [[document name]] at word boundary or start of line 108 + find: /\[\[([^\]]+)\]\]$/, 109 + handler: ({ state, range, match }) => { 110 + const docName = match[1].trim(); 111 + if (!docName) return; 112 + 113 + const nodeType = state.schema.nodes[this.name]; 114 + if (!nodeType) return; 115 + 116 + const node = nodeType.create({ docName }); 117 + const tr = state.tr.replaceWith(range.from, range.to, node); 118 + return tr; 119 + }, 120 + }), 121 + ]; 122 + }, 123 + });
+2
src/docs/main.ts
··· 43 43 import { SearchReplace } from './search-replace.js'; 44 44 import { TabSupport } from './tab-support.js'; 45 45 import { MarkdownAutoformat } from './extensions/markdown-autoformat.js'; 46 + import { WikiLink } from './extensions/wiki-link.js'; 46 47 import { exportPdf } from './pdf-export.js'; 47 48 import { exportDocx } from './docx-export.js'; 48 49 import { importDocx, isValidDocx } from './docx-import.js'; ··· 157 158 }), 158 159 TabSupport, 159 160 MarkdownAutoformat, 161 + WikiLink, 160 162 createSlashCommands({ 161 163 items: (query) => { 162 164 return filterCommands(query).map(item => ({
+99
src/wiki-links.ts
··· 1 + /** 2 + * Wiki Links — cross-document [[link]] resolution. 3 + * 4 + * Pure logic module: parses [[...]] syntax, resolves document names to IDs, 5 + * builds navigation URLs. DOM rendering handled by the TipTap extension. 6 + */ 7 + 8 + export interface WikiLink { 9 + name: string; 10 + start: number; 11 + end: number; 12 + } 13 + 14 + export interface DocRef { 15 + id: string; 16 + name: string; 17 + type: 'doc' | 'sheet'; 18 + } 19 + 20 + /** 21 + * Extract all [[...]] wiki-link references from a text string. 22 + * Returns the link name and its character offsets. 23 + */ 24 + export function parseWikiLinks(text: string): WikiLink[] { 25 + if (!text) return []; 26 + 27 + const links: WikiLink[] = []; 28 + const regex = /\[\[([^\]]+)\]\]/g; 29 + let match; 30 + 31 + while ((match = regex.exec(text)) !== null) { 32 + const name = match[1].trim(); 33 + if (name) { 34 + links.push({ 35 + name, 36 + start: match.index, 37 + end: match.index + match[0].length, 38 + }); 39 + } 40 + } 41 + 42 + return links; 43 + } 44 + 45 + /** 46 + * Resolve a document name to a DocRef by fuzzy-matching against known documents. 47 + * Matching priority: 48 + * 1. Exact match (case-insensitive) 49 + * 2. Starts-with match (case-insensitive) 50 + * 3. Contains match (case-insensitive) 51 + * Returns null if no match found. 52 + */ 53 + export function resolveDocByName( 54 + name: string, 55 + documents: DocRef[], 56 + ): DocRef | null { 57 + if (!name || !documents.length) return null; 58 + 59 + const lower = name.toLowerCase(); 60 + 61 + // Exact match 62 + const exact = documents.find(d => d.name.toLowerCase() === lower); 63 + if (exact) return exact; 64 + 65 + // Starts-with match 66 + const startsWith = documents.find(d => 67 + d.name.toLowerCase().startsWith(lower), 68 + ); 69 + if (startsWith) return startsWith; 70 + 71 + // Contains match 72 + const contains = documents.find(d => 73 + d.name.toLowerCase().includes(lower), 74 + ); 75 + if (contains) return contains; 76 + 77 + return null; 78 + } 79 + 80 + /** 81 + * Build a navigation URL for a document. 82 + * Does NOT include the encryption key (that's in the URL fragment, added by the caller). 83 + */ 84 + export function buildDocUrl(docId: string, type: 'doc' | 'sheet'): string { 85 + const prefix = type === 'sheet' ? '/sheets/' : '/docs/'; 86 + return `${prefix}${docId}`; 87 + } 88 + 89 + /** 90 + * Extract a display label for a wiki link. 91 + * If the doc is resolved, returns the actual doc name. 92 + * Otherwise returns the raw link text. 93 + */ 94 + export function wikiLinkLabel( 95 + linkName: string, 96 + resolved: DocRef | null, 97 + ): string { 98 + return resolved ? resolved.name : linkName; 99 + }
+135
tests/wiki-links.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + parseWikiLinks, 4 + resolveDocByName, 5 + buildDocUrl, 6 + wikiLinkLabel, 7 + type DocRef, 8 + } from '../src/wiki-links.js'; 9 + 10 + describe('Wiki Links', () => { 11 + describe('parseWikiLinks', () => { 12 + it('extracts single wiki link', () => { 13 + const links = parseWikiLinks('See [[Meeting Notes]] for details'); 14 + expect(links).toHaveLength(1); 15 + expect(links[0]).toEqual({ 16 + name: 'Meeting Notes', 17 + start: 4, 18 + end: 21, 19 + }); 20 + }); 21 + 22 + it('extracts multiple wiki links', () => { 23 + const links = parseWikiLinks('Link to [[Doc A]] and [[Doc B]]'); 24 + expect(links).toHaveLength(2); 25 + expect(links[0].name).toBe('Doc A'); 26 + expect(links[1].name).toBe('Doc B'); 27 + }); 28 + 29 + it('returns empty array for no links', () => { 30 + expect(parseWikiLinks('No links here')).toEqual([]); 31 + }); 32 + 33 + it('returns empty array for empty string', () => { 34 + expect(parseWikiLinks('')).toEqual([]); 35 + }); 36 + 37 + it('trims whitespace from link names', () => { 38 + const links = parseWikiLinks('[[ spaced ]]'); 39 + expect(links[0].name).toBe('spaced'); 40 + }); 41 + 42 + it('ignores empty brackets', () => { 43 + const links = parseWikiLinks('[[ ]]'); 44 + expect(links).toHaveLength(0); 45 + }); 46 + 47 + it('handles nested brackets gracefully', () => { 48 + const links = parseWikiLinks('[[outer [inner] text]]'); 49 + // Regex stops at first ], so this won't match the full thing 50 + expect(links).toHaveLength(0); 51 + }); 52 + 53 + it('handles links at start and end of text', () => { 54 + const links = parseWikiLinks('[[Start]] middle [[End]]'); 55 + expect(links).toHaveLength(2); 56 + expect(links[0].name).toBe('Start'); 57 + expect(links[1].name).toBe('End'); 58 + }); 59 + }); 60 + 61 + describe('resolveDocByName', () => { 62 + const docs: DocRef[] = [ 63 + { id: 'a1', name: 'Meeting Notes', type: 'doc' }, 64 + { id: 'b2', name: 'Budget 2026', type: 'sheet' }, 65 + { id: 'c3', name: 'Project Plan', type: 'doc' }, 66 + { id: 'd4', name: 'Weekly Planner', type: 'doc' }, 67 + ]; 68 + 69 + it('finds exact match (case-insensitive)', () => { 70 + const result = resolveDocByName('meeting notes', docs); 71 + expect(result).toEqual(docs[0]); 72 + }); 73 + 74 + it('finds starts-with match', () => { 75 + const result = resolveDocByName('Budget', docs); 76 + expect(result).toEqual(docs[1]); 77 + }); 78 + 79 + it('finds contains match', () => { 80 + const result = resolveDocByName('Plan', docs); 81 + expect(result).toEqual(docs[2]); 82 + }); 83 + 84 + it('prefers exact over starts-with', () => { 85 + const docsWithOverlap: DocRef[] = [ 86 + { id: '1', name: 'Plan', type: 'doc' }, 87 + { id: '2', name: 'Planning Doc', type: 'doc' }, 88 + ]; 89 + const result = resolveDocByName('Plan', docsWithOverlap); 90 + expect(result!.id).toBe('1'); 91 + }); 92 + 93 + it('prefers starts-with over contains', () => { 94 + const docsWithOverlap: DocRef[] = [ 95 + { id: '1', name: 'My Project Plan', type: 'doc' }, 96 + { id: '2', name: 'Project Roadmap', type: 'doc' }, 97 + ]; 98 + const result = resolveDocByName('Project', docsWithOverlap); 99 + expect(result!.id).toBe('2'); 100 + }); 101 + 102 + it('returns null for no match', () => { 103 + expect(resolveDocByName('Nonexistent', docs)).toBeNull(); 104 + }); 105 + 106 + it('returns null for empty name', () => { 107 + expect(resolveDocByName('', docs)).toBeNull(); 108 + }); 109 + 110 + it('returns null for empty documents', () => { 111 + expect(resolveDocByName('Test', [])).toBeNull(); 112 + }); 113 + }); 114 + 115 + describe('buildDocUrl', () => { 116 + it('builds doc URL', () => { 117 + expect(buildDocUrl('abc123', 'doc')).toBe('/docs/abc123'); 118 + }); 119 + 120 + it('builds sheet URL', () => { 121 + expect(buildDocUrl('xyz789', 'sheet')).toBe('/sheets/xyz789'); 122 + }); 123 + }); 124 + 125 + describe('wikiLinkLabel', () => { 126 + it('returns resolved doc name when available', () => { 127 + const ref: DocRef = { id: '1', name: 'Real Name', type: 'doc' }; 128 + expect(wikiLinkLabel('some link', ref)).toBe('Real Name'); 129 + }); 130 + 131 + it('returns raw link name when unresolved', () => { 132 + expect(wikiLinkLabel('some link', null)).toBe('some link'); 133 + }); 134 + }); 135 + });