Files
narl-workspace/web/src/lib/markdown.ts
T
nvrl d8e72c688d files: a file belongs to a node, and a document holds its own
An upload while writing is now a file node of the document, through
POST /nodes/{id}/files, instead of a loose row of media that only the media
browser listed. It moves, seals, goes to the trash and comes back with the
document. Migration 0029 gives every embedded upload that had no node a
place under the document that embeds it (a living one before a trashed
one), keeping its media id, so no body changes; the sealed-folder triggers
now ask a document's folder for the files under it.

- nodes: `holds` and `place_for` say what may go where; a document holds
  files and nothing else, in moves, uploads and restores from the trash.
  A loaded document carries its files and whether its folder seals them.
- seal: a document moving into a sealed folder seals its files first.
- media: POST /media, GET /media and DELETE /media/{id} are gone, and with
  them MediaBrowser and :media.
- import: `plan/` beside `plan.md` comes back as the document's files, the
  shape export writes; an older export's media/ goes to the first document
  that embeds each file.
- sftp: a document's files are `<slug>/` beside its `.md`, listed once it
  has any, holding files only, moving with the `.md`.
- web: paste, drop and :upload while reading go to the document; the info
  sheet has a files section (Enter opens, i embeds, y copies how); the list
  shows +N on a document, za folds its files open, p drops marked files in.
2026-10-22 11:57:52 +02:00

200 lines
8.5 KiB
TypeScript

import DOMPurify from 'dompurify'
import { marked, type TokenizerAndRendererExtension } from 'marked'
import { highlighted } from './code.svelte'
import { mathEngine } from './math.svelte'
import { typeset } from './typst.svelte'
import { hardWrapped, titleHref } from './util'
marked.setOptions({ gfm: true, breaks: true })
/*
* Math the way GitHub writes it: `$x^2$` inline, `$$…$$` on lines of its own
* or inline for display, and a ```math block. A `$` opening on a space or
* closing before a digit is a price, not math — "$5 and $10" stays text — and
* `\$` is a dollar sign as ever.
*/
const DISPLAY = /^\$\$((?:\\.|[^\\$])+?)\$\$/
const INLINE = /^\$(?!\s)((?:\\.|[^\\$\n])+?)(?<!\s)\$(?!\d)/
const mathBlock: TokenizerAndRendererExtension = {
name: 'mathBlock',
level: 'block',
start: (src) => src.match(/^\$\$/m)?.index,
tokenizer(src) {
const match = /^\$\$((?:\\.|[^\\$])+?)\$\$[^\S\n]*(?:\n+|$)/.exec(src)
if (match) return { type: 'mathBlock', raw: match[0], text: match[1].trim() }
},
renderer: (token) => tex(token.text, true),
}
const mathInline: TokenizerAndRendererExtension = {
name: 'mathInline',
level: 'inline',
start: (src) => src.indexOf('$'),
tokenizer(src) {
const display = DISPLAY.exec(src)
if (display) return { type: 'mathInline', raw: display[0], text: display[1].trim(), display: true }
const inline = INLINE.exec(src)
if (inline) return { type: 'mathInline', raw: inline[0], text: inline[1], display: false }
},
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 }
/** The share the page is read under, whose server route typesets its typst. */
let shareToken: string | undefined
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>`
},
}
/*
* A line break typed in the browser is meant: the diary is written a line at a
* time, so `breaks` is on. A paragraph an editor wrapped at its width is the
* exception, and its breaks are read as the spaces they stand for. A break
* asked for outright — two spaces or a `\` at the end of the line — stays.
*/
type Inline = { type: string; raw: string; tokens?: Inline[] }
function unwrap(tokens: Inline[]) {
for (const [at, token] of tokens.entries()) {
if (token.type === 'br' && /^ ?\n$/.test(token.raw)) tokens[at] = { type: 'text', raw: token.raw, text: ' ' } as Inline
else if (token.tokens) unwrap(token.tokens)
}
}
marked.use({
extensions: [mathBlock, mathInline, wikilink],
walkTokens(token) {
if (token.type === 'paragraph' && hardWrapped(token.raw)) unwrap(token.tokens as Inline[])
},
renderer: {
code(token) {
if (token.lang === 'math') return tex(token.text, true)
if (token.lang === 'typst' || token.lang === 'typ') return typst(token.text)
const html = token.lang ? highlighted(token.text, token.lang) : null
if (html === null) return false
return `<pre><code class="language-${escapeHtml(token.lang!.split(/\s/)[0])}">${html}</code></pre>\n`
},
},
})
/** TeX as KaTeX sets it, or as its source while KaTeX is still loading. */
function tex(source: string, display: boolean): string {
const katex = mathEngine()
if (!katex) {
const text = escapeHtml(source)
return display ? `<pre class="math">${text}</pre>` : `<code class="math">${text}</code>`
}
// An error shows in place, in red, rather than throwing away the page.
return katex.renderToString(source, { displayMode: display, throwOnError: false })
}
/*
* A ```typst block as the SVG the server set it in, shown as an image: each
* SVG names its glyphs with the same ids, which inline on one page would
* draw each other's letters. Until it arrives, and when it does not compile,
* the source shows — with what typst said under it.
*/
function typst(source: string): string {
const answer = typeset(source, shareToken)
if (answer && 'svg' in answer) {
const data = encodeURIComponent(answer.svg)
return `<figure class="typst"><img src="data:image/svg+xml;charset=utf-8,${data}" alt="${escapeHtml(source)}" /></figure>`
}
const error = answer ? `<pre class="typst-error">${escapeHtml(answer.error)}</pre>` : ''
return `<pre class="typst-source${answer ? '' : ' pending'}"><code>${escapeHtml(source)}</code></pre>${error}`
}
/*
* A link that opens another window leaves it no handle on this one. Only
* `_blank` is kept as a target: a named one reuses whatever window has that
* name, and browsers give `noopener` to `_blank` alone.
*/
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (node.tagName !== 'A' || !node.hasAttribute('target')) return
if (node.getAttribute('target') === '_blank') node.setAttribute('rel', 'noopener noreferrer')
else node.removeAttribute('target')
})
const SANITIZE = {
// `semantics` and `annotation` carry KaTeX's MathML, for screen readers.
ADD_TAGS: ['video', 'audio', 'source', 'semantics', 'annotation'],
ADD_ATTR: ['controls', 'target', 'loading', 'playsinline', 'preload', 'encoding'],
}
function escapeHtml(text: string): string {
return text.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;')
}
/**
* Render entry markdown to sanitised HTML. On a share page, media URLs are
* rewritten to the public route under the share's token. That route asks for
* 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. `wikilinks` says where the `[[titles]]`
* lead, as the server answered for the document.
*/
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 }
shareToken = share?.token
const html = marked.parse(md, { async: false }) as string
if (!share) return DOMPurify.sanitize(html, SANITIZE)
// On the parsed page, not on its text: a pattern over HTML can end inside
// an attribute and turn what was quoted there into markup.
const page = DOMPurify.sanitize(html, { ...SANITIZE, RETURN_DOM_FRAGMENT: true })
for (const link of page.querySelectorAll('a[href^="/n/"]')) {
const text = document.createElement('span')
text.className = 'private-link'
text.title = 'a page of the private workspace'
text.append(...link.childNodes)
link.replaceWith(text)
}
const holder = document.createElement('div')
holder.append(page)
return holder.innerHTML
}
/** The markdown snippet that embeds a file node: a picture, a player, or a link to it. */
export function embedSnippet(node: { name: string; file: { url: string; mime: string } | null }): string {
const file = node.file
if (!file) return ''
const name = node.name.replaceAll(']', '')
if (file.mime.startsWith('image/')) return `![${name}](${file.url})`
if (file.mime.startsWith('video/')) return `<video src="${file.url}" controls playsinline></video>`
if (file.mime.startsWith('audio/')) return `<audio src="${file.url}" controls></audio>`
return `[${name}](${file.url})`
}