links: a rename or a move no longer breaks them

A document links to another by its path, `[the plan](/n/work/plan)`, or
now by its title, `[[Plan]]` or `[[Plan|the plan]]`. What each document
links to is kept in a new `links` table, rebuilt whenever its text is
written: from the browser, over the mount, or by an import. A title is
looked for in the linking document's space first, then in every other.

Renaming or moving a document, or a folder above it, rewrites the links
to it in every document that has one, in the same transaction: the path
to the new one, a title to the new name, with the label left as it was.
Each rewritten document keeps a revision of what it said before. A link
to something in the trash keeps its row, so it follows the thing when it
is restored somewhere else. A document created, or renamed, under a title
that a link was waiting for is found by that link from then on.

The reader draws `[[titles]]` as links, and a title no document has yet
as a dimmed one that offers to write that document. `gf` follows either
from the editor, and `[[` there completes titles from the search. `gb`
and `:backlinks` list the documents that link to the open one, with the
line each does it on; the line under the title counts them. A share page
shows these links as plain text, as it does every /n/ link.

The documents written before this are indexed once, when the server
starts and finds the table empty.
This commit is contained in:
nvrl committed 2026-10-20 20:20:40 +02:00
1 parent fc2be66b0c
commit a42f9074a2
23 files changed
+1281 -35

No files matched your search

+101
View File
@@ -0,0 +1,101 @@
<script lang="ts" module>
import type { Backlink } from './api'
import { workspace } from './store.svelte'
/** What Enter and a click do: the linking document is opened. */
export function openBacklink(link: Backlink) {
workspace.overlay = 'none'
return workspace.guard(() => workspace.openPath(link.path))
}
</script>
<script lang="ts">
let sheet = $state<HTMLDivElement>()
$effect(() => {
const index = workspace.backlinksCursor
sheet?.querySelector<HTMLElement>(`[data-index="${index}"]`)?.scrollIntoView({ block: 'nearest' })
})
</script>
<div class="sheet" bind:this={sheet}>
<header>
<span class="label rule">
<span class="marker"></span><span class="title">backlinks</span>
{workspace.open?.name.trim() || workspace.open?.slug} · linked from {workspace.backlinks.length}
</span>
<button onclick={() => (workspace.overlay = 'none')}>esc</button>
</header>
<ul>
{#each workspace.backlinks as link, index (link.id)}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
<li
class:at={index === workspace.backlinksCursor}
data-index={index}
onpointerenter={() => (workspace.backlinksCursor = index)}
onclick={() => openBacklink(link)}
>
<span class="name">{link.name.trim() || link.slug}</span>
<span class="muted">{link.path}</span>
{#if link.context}<span class="context">{link.context}</span>{/if}
</li>
{:else}
<li class="muted">nothing links here yet — a link is [text](/n/…) or [[title]].</li>
{/each}
</ul>
<footer class="label">
<span class="kbd">j</span><span class="kbd">k</span> move
<span class="gap"></span>
<span class="kbd">enter</span> open
<span class="gap"></span>
<span class="kbd">q</span><span class="kbd">esc</span> close
</footer>
</div>
<style>
/* A free-standing panel, as the desktop draws one: hull, one rim, the frame's radius. */
.sheet {
display: flex;
flex-direction: column;
gap: 18px;
max-height: 100%;
background: color-mix(in oklab, var(--hull) 97%, transparent);
border: 1px solid var(--surface-alt);
border-radius: var(--frame-radius);
padding: 20px 24px;
animation: resolve var(--t-slow) var(--ease-snap) both;
width: min(100%, 92ch);
overflow: hidden;
}
header { display: flex; align-items: center; gap: 12px; }
header .label { flex: 1; }
header .marker { background: var(--light); }
header .title { color: var(--light); }
ul { list-style: none; margin: 0; padding: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 2px; }
li {
display: flex;
flex-direction: column;
padding: 6px 8px;
border-radius: var(--radius-m);
cursor: pointer;
transition: background-color var(--t-base) var(--ease-glide);
}
li:hover { background: var(--surface); }
li.at { background: var(--surface-alt); box-shadow: inset 2px 0 0 var(--light); }
.name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--fg); font-family: var(--font-sans); font-size: 14px; }
.muted { font-size: 11px; }
.context { font-size: 12px; color: var(--subtle); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
footer { display: flex; align-items: center; gap: 4px; font-size: 10px; }
footer .kbd { margin-right: 2px; }
footer .gap { width: 14px; }
@media (max-width: 767px), (pointer: coarse) {
footer { display: none; }
}
</style>
+8 -1
View File
@@ -21,7 +21,7 @@
const focused = $derived(workspace.tile === tile)
const doc = $derived(tile.open!)
const html = $derived(renderMarkdown(tile.editing ? tile.draft.body : doc.body))
const html = $derived(renderMarkdown(tile.editing ? tile.draft.body : doc.body, { wikilinks: doc.wikilinks }))
const words = $derived(
(tile.editing ? tile.draft.body : doc.body).split(/\s+/).filter(Boolean).length,
)
@@ -161,6 +161,10 @@
<dd>{relative(doc.updated_at)}</dd>
<dt>words</dt>
<dd>{words}<span class="unit">words</span></dd>
{#if doc.backlinks > 0}
<dt>linked</dt>
<dd><button class="linked" title="what links here (gb)" onclick={() => runCommand('backlinks')}>from {doc.backlinks}</button></dd>
{/if}
</dl>
<div class="actions">
@@ -298,6 +302,9 @@
}
.meta dt { color: var(--muted); letter-spacing: 0.18em; text-transform: uppercase; }
.meta dd { margin: 0 10px 0 0; color: var(--fg); }
/* Read as the rest of the line, and still a way to the backlinks. */
.meta .linked { all: unset; cursor: pointer; text-decoration: underline; text-decoration-color: var(--overlay); text-underline-offset: 4px; }
.meta .linked:hover, .meta .linked:focus-visible { color: var(--light); }
.actions { display: flex; gap: 4px; flex-wrap: wrap; flex: none; }
.actions .on { color: var(--light); border-color: var(--light-dim); }
+36
View File
@@ -133,6 +133,7 @@
</script>
<script lang="ts">
import { autocompletion, type CompletionContext, type CompletionResult } from '@codemirror/autocomplete'
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'
import { markdown, markdownLanguage } from '@codemirror/lang-markdown'
import { HighlightStyle, indentOnInput, syntaxHighlighting } from '@codemirror/language'
@@ -146,6 +147,7 @@
} from '@codemirror/view'
import { tags } from '@lezer/highlight'
import { onDestroy, onMount } from 'svelte'
import { api } from './api'
import { hooks } from './commands'
import { embedSnippet } from './markdown'
import { workspace, type Tile } from './store.svelte'
@@ -200,6 +202,16 @@
display: 'flex',
alignItems: 'center',
},
'.cm-tooltip': {
backgroundColor: 'var(--hull)',
color: 'var(--fg)',
border: '1px solid var(--rim)',
},
'.cm-tooltip-autocomplete > ul > li[aria-selected]': {
backgroundColor: 'var(--surface-alt)',
color: 'var(--light)',
},
'.cm-completionDetail': { color: 'var(--muted)', fontStyle: 'normal', marginLeft: '1ch' },
'.cm-vim-panel input': {
fontFamily: 'inherit',
fontSize: 'inherit',
@@ -212,6 +224,29 @@
{ dark: true },
)
/*
* `[[` offers the titles of documents as the search finds them, each with
* where it sits; picking one closes the brackets.
*/
async function titleCompletions(context: CompletionContext): Promise<CompletionResult | null> {
const typed = context.matchBefore(/\[\[[^[\]|\n]*/)
if (!typed) return null
const query = typed.text.slice(2).trim()
if (!query) return null
const found = await api.listNodes({ q: query }).catch(() => [])
if (context.aborted) return null
const options = found
.filter((node) => node.kind === 'document' || node.kind === 'file')
.filter((node) => node.id !== tile.open?.id)
.map((node) => {
// What a `[[link]]` can hold: a name with no bracket or bar in it, or else the slug.
const name = node.name.trim()
const title = name && !/[[\]|\n]/.test(name) ? name : node.slug
return { label: title, detail: node.location, apply: `${title}]]` }
})
return { from: typed.from + 2, options }
}
/** The `#` lines being written, as `gO` lists them, and the one the cursor is under. */
function editorHeadings() {
if (!view) return { headings: [], current: 0 }
@@ -264,6 +299,7 @@
highlightActiveLine(),
EditorState.allowMultipleSelections.of(true),
markdown({ base: markdownLanguage, codeLanguages: [] }),
autocompletion({ override: [titleCompletions], icons: false }),
syntaxHighlighting(markdownHighlight),
EditorView.lineWrapping,
placeholder('write…'),
+2
View File
@@ -44,6 +44,8 @@
['x · dd', 'delete what has focus — the row in the list, the document in its pane — into the trash (asks y/N)'],
['u', 'undo the last delete: it comes back out of the trash — :trash has the rest'],
['gh', "the open document's earlier versions, as diffs — Enter brings one back"],
['gb', 'the documents that link to the open one — [[title]] links by title, [[ completes one while writing'],
[':', 'command line — Tab / Shift-Tab cycle the matches, then the argument; ↑ ↓ recall what ran before'],
['Ctrl-S', 'save, from anywhere'],
['?', 'this screen — in the editor too'],
+1 -1
View File
@@ -19,7 +19,7 @@
let article = $state<HTMLElement>()
let position = $state('All')
// Links into the private workspace come back as their text; see renderMarkdown.
const html = $derived(doc ? renderMarkdown(doc.body, { token }) : '')
const html = $derived(doc ? renderMarkdown(doc.body, { share: { token } }) : '')
const title = $derived(doc?.name.trim() ?? '')
const words = $derived(doc ? doc.body.split(/\s+/).filter(Boolean).length : 0)
// The outline gets a window of its own only when there is something to outline.
+16
View File
@@ -49,6 +49,19 @@ export interface Crumb {
export interface WorkspaceNode extends NodeSummary {
body: string
path: Crumb[]
/** How many documents link here. */
backlinks: number
/** Where each `[[title]]` in the body leads, as an /n/ path; a title that leads nowhere is missing. */
wikilinks: Record<string, string>
}
/** A document that links to the one asked about, and the line it does it on. */
export interface Backlink {
id: number
name: string
slug: string
path: string
context: string
}
/** A space as the overview shows it: itself, and what was last written in it. */
@@ -429,6 +442,9 @@ export const api = {
revision: (id: number, rev: number) => request<Revision>(`/nodes/${id}/revisions/${rev}`),
/** The documents that link to this one, the last written first. */
backlinks: (id: number) => request<Backlink[]>(`/nodes/${id}/backlinks`),
/** Into the trash; `purged` when it was in the trash already and is now gone. */
deleteNode: (id: number) =>
request<{ ok: true; removed: number; purged: boolean }>(`/nodes/${id}`, { method: 'DELETE' }),
+26 -2
View File
@@ -2,7 +2,7 @@ import { tick } from 'svelte'
import { api, ApiError, isContainer, type BackupLogin, type BackupStatus } from './api'
import { workspace } from './store.svelte'
import { emptyTrash } from './Trash.svelte'
import { formatDay, parseDay, parseDue, relative, sealedNote, type Heading, type ScrollAmount, type SidewaysAmount } from './util'
import { formatDay, parseDay, parseDue, relative, sealedNote, titleHref, type Heading, type ScrollAmount, type SidewaysAmount } from './util'
export type { ScrollAmount, SidewaysAmount }
@@ -76,13 +76,24 @@ const requireOpen = () => {
return workspace.open
}
const TITLE_HREF = titleHref('')
/**
* Where a link in a document goes. `/n/` links are the workspace's own — the
* importer writes them between documents — and are opened in place; anything
* importer writes them between documents — and are opened in place, as is a
* `[[title]]`, or the offer to write it when no document has it; anything
* else would take the workspace's tab with it, so it gets a tab of its own,
* as does a workspace link asked for with `gx`.
*/
export function followLink(href: string, tab = false) {
if (href.startsWith(TITLE_HREF)) {
const title = decodeURIComponent(href.slice(TITLE_HREF.length))
const path = workspace.open?.wikilinks[title]
if (tab) {
if (!path) return workspace.say(`no document called "${title}" yet`, 'error')
href = `/n/${path}`
} else return void workspace.guard(() => workspace.followTitle(title))
}
if (href.startsWith('/n/') && !tab) {
const path = href.slice(3).split('#')[0]
return void workspace.guard(() => workspace.openPath(decodeURIComponent(path)))
@@ -789,6 +800,19 @@ export const commands: CommandSpec[] = [
})
},
},
{
name: 'backlinks',
aliases: ['bl'],
group: 'documents',
help: 'the documents that link to the open one — j/k, Enter opens one (gb)',
run: async () => {
if (!requireOpen()) return
await workspace.guard(async () => {
await workspace.loadBacklinks()
workspace.overlay = 'backlinks'
})
},
},
{
name: 'trash',
args: '[purge]',
+39 -3
View File
@@ -1,6 +1,7 @@
import DOMPurify from 'dompurify'
import { marked, type TokenizerAndRendererExtension } from 'marked'
import { mathEngine } from './math.svelte'
import { titleHref } from './util'
marked.setOptions({ gfm: true, breaks: true })
@@ -37,8 +38,38 @@ const mathInline: TokenizerAndRendererExtension = {
renderer: (token) => tex(token.text, token.display),
}
/*
* `[[Plan]]` and `[[Plan|the plan]]`: a link to a document by its title. The
* server says where each title leads, since it is the one that knows the
* titles; one that leads nowhere is drawn dimmed, and following it offers to
* write the document.
*/
const WIKILINK = /^\[\[([^[\]|\n]+)(?:\|([^[\]\n]*))?\]\]/
/** What the page being rendered says about its `[[titles]]`, for the renderer. */
let wikiContext: { leads: Record<string, string>; sharing: boolean } = { leads: {}, sharing: false }
const wikilink: TokenizerAndRendererExtension = {
name: 'wikilink',
level: 'inline',
start: (src) => src.indexOf('[['),
tokenizer(src) {
const match = WIKILINK.exec(src)
const target = match?.[1].trim()
if (match && target) return { type: 'wikilink', raw: match[0], target, label: match[2]?.trim() || target }
},
renderer: (token) => {
const label = escapeHtml(token.label)
// The page it leads to is behind the login, as any /n/ link on a share is.
if (wikiContext.sharing) return `<span class="private-link" title="a page of the private workspace">${label}</span>`
const path = wikiContext.leads[token.target]
if (path) return `<a href="/n/${encodeURI(path)}" class="wikilink">${label}</a>`
return `<a href="${titleHref(token.target)}" class="wikilink missing" title="not written yet — follow it to write it">${label}</a>`
},
}
marked.use({
extensions: [mathBlock, mathInline],
extensions: [mathBlock, mathInline, wikilink],
renderer: {
code(token) {
return token.lang === 'math' ? tex(token.text, true) : false
@@ -84,13 +115,18 @@ function escapeHtml(text: string): string {
* the cookie reading the document with its key handed out, so the key itself
* never sits in a URL a proxy could log. A link to another document, an
* address inside the private workspace that would lead the reader to its
* login page, is kept as its text.
* login page, is kept as its text. `wikilinks` says where the `[[titles]]`
* lead, as the server answered for the document.
*/
export function renderMarkdown(source: string, share?: { token: string }): string {
export function renderMarkdown(
source: string,
{ share, wikilinks = {} }: { share?: { token: string }; wikilinks?: Record<string, string> } = {},
): string {
const md = share
? source.replaceAll('/api/media/', `/api/share/${encodeURIComponent(share.token)}/media/`)
: source
wikiContext = { leads: wikilinks, sharing: !!share }
const html = marked.parse(md, { async: false }) as string
if (!share) return DOMPurify.sanitize(html, SANITIZE)
+48 -1
View File
@@ -5,6 +5,7 @@ import {
unreachable,
type Agenda,
type AgendaCard,
type Backlink,
type Board,
type Card,
type Crumb,
@@ -281,7 +282,7 @@ class Workspace {
pane = $state<Pane>('list')
flash = $state<Flash>(null)
overlay = $state<'none' | 'help' | 'media' | 'overview' | 'agenda' | 'actions' | 'verify' | 'outline' | 'trash' | 'history'>('none')
overlay = $state<'none' | 'help' | 'media' | 'overview' | 'agenda' | 'actions' | 'verify' | 'outline' | 'trash' | 'history' | 'backlinks'>('none')
/** `gO`: the headings of what is on screen, to jump between from the keyboard. */
outline = $state.raw<Heading[]>([])
outlineCursor = $state(0)
@@ -317,6 +318,9 @@ class Workspace {
history = $state<RevisionSummary[]>([])
historyCursor = $state(0)
revision = $state.raw<Revision | null>(null)
/** `:backlinks`: the documents that link to the open one. */
backlinks = $state<Backlink[]>([])
backlinksCursor = $state(0)
overview = $state<SpaceOverview[]>([])
overviewCursor = $state(0)
@@ -1096,6 +1100,29 @@ class Workspace {
this.say(`restored the version from ${formatStamp(revision.saved_at)} — :history has the one it replaced`)
}
/**
* Follow a `[[title]]`: to the document it names, or — when there is none
* — to a new one called that, here, after asking. Which document a title
* names the server says when the text is written, so it is written first.
*/
async followTitle(title: string) {
await this.flushTile()
const path = this.open?.wikilinks[title]
if (path) return this.openPath(path)
if (!(await this.ask(`no document called "${title}" yet — write it in ${this.here}?`))) return
await this.createDocument(title)
}
/** `:backlinks`: what links to the open document, the last written first. */
async loadBacklinks() {
const open = this.open
if (!open) return
// A link typed and not yet written is not one the server knows about.
await this.flush()
this.backlinks = await api.backlinks(open.id)
this.backlinksCursor = 0
}
async loadTrash() {
this.trash = await api.trash()
this.trashCursor = Math.min(this.trashCursor, Math.max(this.trash.length - 1, 0))
@@ -1135,10 +1162,28 @@ class Workspace {
}
if (this.path.some((crumb) => crumb.id === id)) this.path = saved.path
if (saved.kind === 'space') this.spaces = await api.listSpaces()
await this.reread()
await this.refresh()
this.say(`renamed to ${saved.slug}`)
}
/**
* Read the open documents again after a rename or a move: their paths may
* have changed with it, and their text too, where the server rewrote a
* link to what moved. One being written is left as it is — saving it is
* then refused, and `:e!` takes the rewritten text.
*/
async reread() {
for (const tile of this.tiles) {
if (!tile.open || tile.editing || tile.dirty) continue
const node = await api.getNode(tile.open.id).catch(() => null)
if (!node) continue
tile.open = node
tile.draft = { name: node.name, body: node.body, created_at: node.created_at }
}
if (this.open) this.#syncUrl(this.open.path)
}
/**
* Delete a space and everything in it. The app has to land somewhere
* afterwards, so it falls into the next space — or, if that was the last
@@ -1168,6 +1213,7 @@ class Workspace {
/** Reparent the node under the cursor, for `:mv`. */
async moveNode(id: number, parentId: number) {
await api.moveNode(id, parentId)
await this.reread()
await this.refresh()
this.say(`moved #${id}`)
}
@@ -1194,6 +1240,7 @@ class Workspace {
)
const gone = new Set(nodes.map((n) => n.id))
this.marked = this.marked.filter((n) => !gone.has(n.id))
if (moved > 0) await this.reread()
await this.refresh(nodes.length === 1 ? nodes[0].id : undefined)
const what = nodes.length === 1 ? nodes[0].name.trim() || nodes[0].slug : `${moved}`
const already = unchanged > 0 ? ` — ${unchanged} already ${unchanged === 1 ? 'was' : 'were'}` : ''
+13 -4
View File
@@ -234,16 +234,19 @@ export function sourceHeadings(source: string): { text: string; level: number; l
/**
* The link the cursor stands on in a line of markdown — `[text](href)`, an
* embed's `![alt](src)`, `<https://…>` or a bare address — or else the first
* embed's `![alt](src)`, `[[title]]`, `<https://…>` or a bare address — or else the first
* one after it on the line, as vim's `gf` looks ahead to the next name.
*/
export function linkAt(line: string, column: number): string | null {
const pattern =
/!?\[[^\]]*\]\(\s*<?([^)\s>]+)>?(?:\s+"[^"]*")?\s*\)|<((?:https?:\/\/|\/n\/)[^>\s]+)>|((?:https?:\/\/|\/n\/)[^\s<>()]+)/g
/\[\[([^[\]|\n]+)(?:\|[^[\]\n]*)?\]\]|!?\[[^\]]*\]\(\s*<?([^)\s>]+)>?(?:\s+"[^"]*")?\s*\)|<((?:https?:\/\/|\/n\/)[^>\s]+)>|((?:https?:\/\/|\/n\/)[^\s<>()]+)/g
let ahead: string | null = null
for (const match of line.matchAll(pattern)) {
// A sentence that ends on an address ends with a full stop, not the address.
const href = match[1] ?? match[2] ?? match[3].replace(/[.,;:!?]+$/, '')
// A sentence that ends on a bare address ends with a full stop, not the address.
const href = match[1]?.trim()
? titleHref(match[1].trim())
: (match[2] ?? match[3] ?? match[4]?.replace(/[.,;:!?]+$/, ''))
if (!href) continue
const start = match.index
if (column >= start && column < start + match[0].length) return href
if (start >= column) ahead ??= href
@@ -251,6 +254,12 @@ export function linkAt(line: string, column: number): string | null {
return ahead
}
/**
* Where a `[[title]]` points when the page cannot say where it leads: the
* title itself, for following it to look it up, or to offer to write it.
*/
export const titleHref = (title: string) => `/n/?title=${encodeURIComponent(title)}`
/** What a share link's reader will miss: a share never serves a sealed file. */
export const sealedNote = (sealed: number) =>
sealed > 0 ? ` — ${sealed} sealed file${sealed === 1 ? '' : 's'} in it stay hidden from readers` : ''