feat(workspace): links to single bullets
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m8s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m8s
- copy a link to a bullet from its action bubble or pick a bullet in the link dialog; [[Note#^id|Note › excerpt]] opens the note, jumps to and highlights the bullet, or says it is gone - wiki links render inline with their label and are clickable; the duplicate chip below the text is gone test(e2e): run each suite with its own container, image tag and port, so parallel runs from another working copy no longer remove our server Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d408e7d307
commit
1a49e6b304
12 files changed
+284
-45
No files matched your search
@@ -0,0 +1,21 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildBlockLink, parseWikiLink, wikiLinkText } from '../wikiLink';
|
||||
|
||||
describe('wiki links', () => {
|
||||
it('parses plain, labelled, section and bullet links', () => {
|
||||
expect(parseWikiLink('Homelab')).toEqual({ title: 'Homelab', blockId: undefined, label: undefined });
|
||||
expect(parseWikiLink('Homelab|hier')).toEqual({ title: 'Homelab', blockId: undefined, label: 'hier' });
|
||||
expect(parseWikiLink('Homelab#Abschnitt')).toEqual({ title: 'Homelab', blockId: undefined, label: undefined });
|
||||
expect(parseWikiLink('Homelab#^blk_a_1|Homelab › Backup')).toEqual({
|
||||
title: 'Homelab', blockId: 'blk_a_1', label: 'Homelab › Backup',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds a bullet link that parses back', () => {
|
||||
const link = buildBlockLink('Homelab [neu]', 'blk_x', 'Backups liefen bisher nur manuell – das soll sich ändern #backup');
|
||||
expect(link).toBe('[[Homelab neu#^blk_x|Homelab neu › Backups liefen bisher nur manuell – das…]]');
|
||||
const parsed = parseWikiLink(link.slice(2, -2));
|
||||
expect(parsed.blockId).toBe('blk_x');
|
||||
expect(wikiLinkText(parsed)).toBe('Homelab neu › Backups liefen bisher nur manuell – das…');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { safeHref } from './safeUrl';
|
||||
import { parseWikiLink, wikiLinkText } from './wikiLink';
|
||||
|
||||
/**
|
||||
* Parses inline markdown:
|
||||
@@ -9,9 +10,11 @@ import { safeHref } from './safeUrl';
|
||||
* - `code`
|
||||
* - [label](url)
|
||||
* - #hashtag (only when `options.onTagClick` is given), rendered inline as a clickable chip
|
||||
* - [[wiki link]] shown by its label (clickable with `options.onWikiLinkClick`)
|
||||
*/
|
||||
export interface InlineMarkdownOptions {
|
||||
onTagClick?: (tag: string) => void;
|
||||
onWikiLinkClick?: (title: string, blockId?: string) => void;
|
||||
}
|
||||
|
||||
function renderInlineTokens(text: string, options?: InlineMarkdownOptions): React.ReactNode[] {
|
||||
@@ -19,7 +22,7 @@ function renderInlineTokens(text: string, options?: InlineMarkdownOptions): Reac
|
||||
|
||||
const tagAlt = options?.onTagClick ? '|#[a-zA-Z0-9äöüÄÖÜß_-]+' : '';
|
||||
const regex = new RegExp(
|
||||
'(`[^`]+`|\\*\\*[^*]+\\*\\*|__[^_]+__|~~[^~]+~~|\\*[^*]+\\*|_[^_]+_|\\[([^\\]]+)\\]\\(([^)]+)\\)' + tagAlt + ')',
|
||||
'(\\[\\[[^\\]]+\\]\\]|`[^`]+`|\\*\\*[^*]+\\*\\*|__[^_]+__|~~[^~]+~~|\\*[^*]+\\*|_[^_]+_|\\[([^\\]]+)\\]\\(([^)]+)\\)' + tagAlt + ')',
|
||||
'g'
|
||||
);
|
||||
|
||||
@@ -34,7 +37,31 @@ function renderInlineTokens(text: string, options?: InlineMarkdownOptions): Reac
|
||||
const token = match[0];
|
||||
const key = `${match.index}-${token}`;
|
||||
|
||||
if (token.startsWith('#')) {
|
||||
if (token.startsWith('[[') && token.endsWith(']]')) {
|
||||
const link = parseWikiLink(token.slice(2, -2));
|
||||
const onClick = options?.onWikiLinkClick;
|
||||
elements.push(
|
||||
onClick ? (
|
||||
<span
|
||||
key={key}
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick(link.title, link.blockId);
|
||||
}}
|
||||
className="text-status-info underline decoration-status-info/40 underline-offset-2 hover:decoration-status-info cursor-pointer"
|
||||
title={link.blockId ? `Zum Punkt in „${link.title}“` : `Notiz öffnen: ${link.title}`}
|
||||
>
|
||||
{wikiLinkText(link)}
|
||||
</span>
|
||||
) : (
|
||||
<span key={key} className="text-status-info">
|
||||
{wikiLinkText(link)}
|
||||
</span>
|
||||
)
|
||||
);
|
||||
} else if (token.startsWith('#')) {
|
||||
const prevChar = match.index > 0 ? text[match.index - 1] : '';
|
||||
if (prevChar && !/[\s(]/.test(prevChar)) {
|
||||
// e.g. an URL fragment like https://x/#anchor: keep as plain text
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Wiki links: [[Titel]], [[Titel|Anzeigetext]] and links to a single bullet [[Titel#^blockId|Anzeigetext]].
|
||||
// A missing bullet just opens the note (links are not updated when a bullet moves to another note).
|
||||
|
||||
export interface WikiLink {
|
||||
title: string;
|
||||
blockId?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/** Parses the inside of [[…]]. */
|
||||
export function parseWikiLink(inner: string): WikiLink {
|
||||
const [target, ...rest] = inner.split('|');
|
||||
const label = rest.join('|').trim() || undefined;
|
||||
const hash = target.indexOf('#');
|
||||
const title = (hash === -1 ? target : target.slice(0, hash)).trim();
|
||||
const anchor = hash === -1 ? '' : target.slice(hash + 1).trim();
|
||||
return { title, blockId: anchor.startsWith('^') ? anchor.slice(1) || undefined : undefined, label };
|
||||
}
|
||||
|
||||
/** Text shown for a link: its label, else the note title (with » for a bullet). */
|
||||
export function wikiLinkText(link: WikiLink): string {
|
||||
if (link.label) return link.label;
|
||||
return link.blockId ? `${link.title} »` : link.title;
|
||||
}
|
||||
|
||||
/** Link to one bullet; the label is a short excerpt of the bullet so the reader knows what it points to. */
|
||||
export function buildBlockLink(title: string, blockId: string, blockContent: string): string {
|
||||
const clean = (s: string) => s.replace(/[[\]|]/g, '').replace(/\s+/g, ' ').trim();
|
||||
let excerpt = clean(blockContent);
|
||||
if (excerpt.length > 40) excerpt = `${excerpt.slice(0, 39).trimEnd()}…`;
|
||||
const label = excerpt ? `${clean(title)} › ${excerpt}` : clean(title);
|
||||
return `[[${clean(title)}#^${blockId}|${label}]]`;
|
||||
}
|
||||
Reference in new issue
Block a user