Support formatting using djot

This commit is contained in:
dtonon 2026-05-11 16:14:59 +01:00
parent ca02419b13
commit 1af92c8399
3 changed files with 435 additions and 100 deletions

View file

@ -1,6 +1,15 @@
<script lang="ts">
import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata";
import * as nip19 from "@nostr/tools/nip19";
import { parse } from "@djot/djot";
import type {
Block as DjBlock,
Inline as DjInline,
Link as DjLink,
Image as DjImage,
Table as DjTable,
Reference,
} from "@djot/djot";
type Props = {
content: string;
@ -130,14 +139,34 @@
| { type: "link"; href: string; label: string }
| { type: "mention"; pubkey: string; entity: string; fallback: string }
| { type: "entity"; entity: string; label: string }
| { type: "thread-quote"; pubkey: string; eventId: string };
| { type: "thread-quote"; pubkey: string; eventId: string }
| { type: "strong"; children: Inline[] }
| { type: "em"; children: Inline[] }
| { type: "del"; children: Inline[] }
| { type: "code"; value: string }
| { type: "br" }
| { type: "image"; src: string; alt: string };
type Align = "default" | "left" | "right" | "center";
type TableCell = { align: Align; inlines: Inline[] };
type TableRow = { head: boolean; cells: TableCell[] };
type ListItem = { checked: boolean | null; blocks: Block[] };
type Block =
| { type: "image"; value: string }
| { type: "image"; src: string; alt: string }
| { type: "para"; inlines: Inline[] }
| { type: "blockquote"; blocks: Block[] };
const BLOCKQUOTE_LINE_RE = /^>\s?(.*)$/;
| { type: "blockquote"; blocks: Block[] }
| { type: "heading"; level: number; inlines: Inline[] }
| { type: "code"; lang: string; value: string }
| { type: "hr" }
| {
type: "list";
ordered: boolean;
start: number;
tight: boolean;
items: ListItem[];
}
| { type: "table"; rows: TableRow[] };
function decodeNostrInline(entity: string): Inline | null {
try {
@ -181,8 +210,9 @@
return null;
}
// Inline-only tokenizer: produces inline tokens for a single line of text.
// Used inside blockquote lines, where images are not supported.
// Leaf text pass: scans a plain-text run (a Djot `str` node) for nostr
// entities and bare URLs. Never runs on code — verbatim/code_block stay
// literal.
function tokenizeInline(text: string): Inline[] {
const inlines: Inline[] = [];
let last = 0;
@ -217,108 +247,293 @@
return inlines;
}
function isBlockquoteParagraph(text: string): boolean {
const lines = text.split("\n");
let hasQuoteLine = false;
for (const l of lines) {
if (l.length === 0) continue;
if (!BLOCKQUOTE_LINE_RE.test(l)) return false;
hasQuoteLine = true;
}
return hasQuoteLine;
// Whitelist link schemes: user-controlled hrefs are the one place a raw
// value reaches an <a>. Anything not http(s)/mailto renders as plain text.
function safeHref(dest: string): string | null {
const d = dest.trim();
if (/^https?:\/\//i.test(d)) return d;
if (/^mailto:/i.test(d)) return d;
if (/^[a-z][a-z0-9+.-]*:/i.test(d)) return null;
if (/\S\.\S/.test(d)) return `https://${d}`;
return null;
}
function tokenizeBlockquote(text: string): Block {
// Strip the > prefix per line, then route through the same paragraph
// pipeline so blockquotes get image support, pre-wrap newlines, and
// consistent paragraph handling.
const inner = text
.split("\n")
.map((l) => {
const m = l.match(BLOCKQUOTE_LINE_RE);
return m ? m[1] : l;
})
.join("\n");
const blocks: Block[] = [];
for (const para of inner.split(/\n{2,}/)) {
for (const b of tokenize(para)) blocks.push(b);
}
return { type: "blockquote", blocks };
function safeImg(dest: string): string | null {
const d = dest.trim();
if (/^https?:\/\//i.test(d)) return d;
if (/^[a-z][a-z0-9+.-]*:/i.test(d)) return null;
if (/\S\.\S/.test(d)) return `https://${d}`;
return null;
}
function tokenize(text: string): Block[] {
if (isBlockquoteParagraph(text)) return [tokenizeBlockquote(text)];
function inlineText(nodes: DjInline[]): string {
let s = "";
for (const n of nodes) {
if ("text" in n && typeof n.text === "string") s += n.text;
else if ("children" in n) s += inlineText(n.children as DjInline[]);
}
return s;
}
// Reference-style link/image targets, populated per parse.
let references: Record<string, Reference> = {};
function resolveDest(node: DjLink | DjImage): string | undefined {
if (node.destination !== undefined) return node.destination;
if (node.reference !== undefined)
return references[node.reference]?.destination;
return undefined;
}
function convertLink(node: DjLink): Inline {
const label = inlineText(node.children) || node.destination || "";
const dest = resolveDest(node);
if (!dest) return { type: "text", value: label };
if (/^nostr:/i.test(dest)) {
const entity = dest.slice(6).toLowerCase();
try {
nip19.decode(entity);
return { type: "link", href: `https://njump.me/${entity}`, label };
} catch {
return { type: "text", value: label };
}
}
const href = safeHref(dest);
if (!href) return { type: "text", value: label };
return { type: "link", href, label };
}
function convertImage(node: DjImage): Inline {
const alt = inlineText(node.children);
const dest = resolveDest(node);
const src = dest ? safeImg(dest) : null;
if (!src) return { type: "text", value: alt };
return { type: "image", src, alt };
}
function convertInline(node: DjInline): Inline[] {
switch (node.tag) {
case "str":
return tokenizeInline(node.text);
case "soft_break":
return [{ type: "text", value: " " }];
case "hard_break":
return [{ type: "br" }];
case "non_breaking_space":
return [{ type: "text", value: " " }];
case "verbatim":
return [{ type: "code", value: node.text }];
case "strong":
return [{ type: "strong", children: convertInlines(node.children) }];
case "emph":
return [{ type: "em", children: convertInlines(node.children) }];
case "delete":
return [{ type: "del", children: convertInlines(node.children) }];
case "mark":
case "insert":
case "span":
case "superscript":
case "subscript":
case "double_quoted":
case "single_quoted":
return convertInlines(node.children);
case "link":
return [convertLink(node)];
case "image":
return [convertImage(node)];
case "url": {
const href = safeHref(node.text);
return href
? [{ type: "link", href, label: node.text }]
: [{ type: "text", value: node.text }];
}
case "email":
return [
{ type: "link", href: `mailto:${node.text}`, label: node.text },
];
case "smart_punctuation":
return [{ type: "text", value: node.text }];
case "symb":
return [{ type: "text", value: `:${node.alias}:` }];
case "raw_inline":
case "inline_math":
case "display_math":
return [{ type: "text", value: node.text }];
default:
return [];
}
}
function convertInlines(nodes: DjInline[]): Inline[] {
const out: Inline[] = [];
for (const n of nodes) out.push(...convertInline(n));
return out;
}
// Split an inline run so images (markdown images or bare image URLs)
// become their own block, matching the standalone-image convention.
function splitImages(inlines: Inline[]): Block[] {
const blocks: Block[] = [];
let inlines: Inline[] = [];
let cur: Inline[] = [];
const flush = () => {
// Trim leading/trailing newlines so block images don't carry an extra
// visible line break under whitespace-pre-wrap.
while (inlines.length > 0) {
const first = inlines[0];
if (first.type !== "text") break;
const trimmed = first.value.replace(/^\n+/, "");
if (trimmed === "") inlines.shift();
while (cur.length) {
const f = cur[0];
if (f.type !== "text") break;
const t = f.value.replace(/^\s+/, "");
if (t === "") cur.shift();
else {
inlines[0] = { type: "text", value: trimmed };
cur[0] = { type: "text", value: t };
break;
}
}
while (inlines.length > 0) {
const last = inlines[inlines.length - 1];
if (last.type !== "text") break;
const trimmed = last.value.replace(/\n+$/, "");
if (trimmed === "") inlines.pop();
while (cur.length) {
const l = cur[cur.length - 1];
if (l.type !== "text") break;
const t = l.value.replace(/\s+$/, "");
if (t === "") cur.pop();
else {
inlines[inlines.length - 1] = { type: "text", value: trimmed };
cur[cur.length - 1] = { type: "text", value: t };
break;
}
}
if (inlines.some((i) => i.type !== "text" || i.value.trim()))
blocks.push({ type: "para", inlines });
inlines = [];
if (cur.some((i) => i.type !== "text" || i.value.trim()))
blocks.push({ type: "para", inlines: cur });
cur = [];
};
let last = 0;
for (const m of text.matchAll(URL_RE)) {
const start = m.index ?? 0;
let url = m[0];
const isNostr = /^nostr:/i.test(url);
let trailing = "";
if (!isNostr) {
const trail = url.match(TRAILING_PUNCT_RE);
if (trail) {
trailing = trail[0];
url = url.slice(0, -trailing.length);
}
}
if (start > last)
inlines.push({ type: "text", value: text.slice(last, start) });
if (isNostr) {
const entity = url.slice(6).toLowerCase();
const decoded = decodeNostrInline(entity);
if (decoded) inlines.push(decoded);
else inlines.push({ type: "text", value: m[0] });
} else {
const href = /^https?:\/\//i.test(url) ? url : `https://${url}`;
if (IMG_EXT_RE.test(url)) {
flush();
blocks.push({ type: "image", value: href });
if (trailing) inlines.push({ type: "text", value: trailing });
} else {
inlines.push({ type: "link", href, label: url });
if (trailing) inlines.push({ type: "text", value: trailing });
}
}
last = start + m[0].length;
for (const inl of inlines) {
if (inl.type === "image") {
flush();
blocks.push({ type: "image", src: inl.src, alt: inl.alt });
} else if (inl.type === "link" && IMG_EXT_RE.test(inl.href)) {
flush();
blocks.push({ type: "image", src: inl.href, alt: "" });
} else cur.push(inl);
}
if (last < text.length)
inlines.push({ type: "text", value: text.slice(last) });
flush();
return blocks;
}
const paragraphs = $derived(
content.split(/\n{2,}/).map((para) => tokenize(para)),
);
function convertTable(node: DjTable): Block {
const rows: TableRow[] = [];
for (const child of node.children) {
if (child.tag !== "row") continue;
rows.push({
head: child.head,
cells: child.children.map((cell) => ({
align: cell.align,
inlines: convertInlines(cell.children),
})),
});
}
return { type: "table", rows };
}
function convertBlock(node: DjBlock): Block[] {
switch (node.tag) {
case "para":
return splitImages(convertInlines(node.children));
case "heading":
return [
{
type: "heading",
level: node.level,
inlines: convertInlines(node.children),
},
];
case "thematic_break":
return [{ type: "hr" }];
case "section":
case "div":
return convertBlocks(node.children);
case "code_block":
return [
{
type: "code",
lang: node.lang ?? "",
value: node.text.replace(/\n$/, ""),
},
];
case "raw_block":
return [
{ type: "para", inlines: [{ type: "text", value: node.text }] },
];
case "block_quote":
return [{ type: "blockquote", blocks: convertBlocks(node.children) }];
case "bullet_list":
return [
{
type: "list",
ordered: false,
start: 1,
tight: node.tight,
items: node.children.map((li) => ({
checked: null,
blocks: convertBlocks(li.children),
})),
},
];
case "ordered_list":
return [
{
type: "list",
ordered: true,
start: node.start ?? 1,
tight: node.tight,
items: node.children.map((li) => ({
checked: null,
blocks: convertBlocks(li.children),
})),
},
];
case "task_list":
return [
{
type: "list",
ordered: false,
start: 1,
tight: node.tight,
items: node.children.map((li) => ({
checked: li.checkbox === "checked",
blocks: convertBlocks(li.children),
})),
},
];
case "table":
return [convertTable(node)];
case "definition_list": {
const out: Block[] = [];
for (const item of node.children) {
const [term, def] = item.children;
out.push({
type: "para",
inlines: [
{ type: "strong", children: convertInlines(term.children) },
],
});
out.push(...convertBlocks(def.children));
}
return out;
}
default:
return [];
}
}
function convertBlocks(nodes: DjBlock[]): Block[] {
const out: Block[] = [];
for (const n of nodes) out.push(...convertBlock(n));
return out;
}
const blocks = $derived.by<Block[]>(() => {
try {
const doc = parse(content);
references = { ...doc.references, ...doc.autoReferences };
return convertBlocks(doc.children);
} catch {
return [{ type: "para", inlines: [{ type: "text", value: content }] }];
}
});
let resolvedUsers = $state<Record<string, NostrUser>>({});
@ -328,15 +543,27 @@
for (const inline of inlines) {
if (inline.type === "mention" || inline.type === "thread-quote")
seen.add(inline.pubkey);
else if (
inline.type === "strong" ||
inline.type === "em" ||
inline.type === "del"
)
collect(inline.children);
}
};
const visit = (blocks: Block[]) => {
for (const block of blocks) {
if (block.type === "para") collect(block.inlines);
if (block.type === "para" || block.type === "heading")
collect(block.inlines);
else if (block.type === "blockquote") visit(block.blocks);
else if (block.type === "list")
for (const item of block.items) visit(item.blocks);
else if (block.type === "table")
for (const row of block.rows)
for (const cell of row.cells) collect(cell.inlines);
}
};
for (const blocks of paragraphs) visit(blocks);
visit(blocks);
for (const pubkey of seen) {
if (resolvedUsers[pubkey] || profiles[pubkey]) continue;
loadNostrUser(pubkey).then((u) => {
@ -347,7 +574,7 @@
</script>
{#snippet renderInlines(inlines: Inline[])}
{#each inlines as inline}
{#each inlines as inline (inline)}
{#if inline.type === "link"}
<a
href={inline.href}
@ -390,16 +617,54 @@
rel="noopener noreferrer"
class="text-brand hover:underline break-all">{inline.label}</a
>
{:else if inline.type === "strong"}
<strong>{@render renderInlines(inline.children)}</strong>
{:else if inline.type === "em"}
<em>{@render renderInlines(inline.children)}</em>
{:else if inline.type === "del"}
<del>{@render renderInlines(inline.children)}</del>
{:else if inline.type === "code"}
<code>{inline.value}</code>
{:else if inline.type === "br"}
<br />
{:else if inline.type === "image"}
<img
src={inline.src}
alt={inline.alt}
loading="lazy"
class="inline-block max-h-[1.5em] align-text-bottom"
/>
{:else}{inline.value}{/if}
{/each}
{/snippet}
{#snippet renderListItem(item: ListItem, tight: boolean)}
<li>
{#if item.checked !== null}
<input
type="checkbox"
checked={item.checked}
disabled
aria-label={item.checked ? "Completed" : "Not completed"}
class="mr-1 align-middle"
/>
{/if}
{#each item.blocks as b (b)}
{#if tight && b.type === "para"}
{@render renderInlines(b.inlines)}
{:else}
{@render renderBlocks([b])}
{/if}
{/each}
</li>
{/snippet}
{#snippet renderBlocks(blocks: Block[])}
{#each blocks as block}
{#each blocks as block (block)}
{#if block.type === "image"}
<img
src={block.value}
alt=""
src={block.src}
alt={block.alt}
loading="lazy"
class="block mx-auto w-full max-h-[80vh] object-contain rounded"
/>
@ -409,16 +674,72 @@
>
{@render renderBlocks(block.blocks)}
</blockquote>
{:else if block.type === "heading"}
{@const tag = `h${Math.min(block.level + 2, 6)}`}
<svelte:element this={tag}>
{@render renderInlines(block.inlines)}
</svelte:element>
{:else if block.type === "code"}
<pre class="overflow-x-auto"><code>{block.value}</code></pre>
{:else if block.type === "hr"}
<hr />
{:else if block.type === "list"}
{#if block.ordered}
<ol start={block.start !== 1 ? block.start : undefined}>
{#each block.items as item (item)}
{@render renderListItem(item, block.tight)}
{/each}
</ol>
{:else}
<ul>
{#each block.items as item (item)}
{@render renderListItem(item, block.tight)}
{/each}
</ul>
{/if}
{:else if block.type === "table"}
{@const headRows = block.rows.filter((r) => r.head)}
{@const bodyRows = block.rows.filter((r) => !r.head)}
<div class="overflow-x-auto my-4">
<table>
{#if headRows.length}
<thead>
{#each headRows as row (row)}
<tr>
{#each row.cells as cell (cell)}
<th
style:text-align={cell.align !== "default"
? cell.align
: null}>{@render renderInlines(cell.inlines)}</th
>
{/each}
</tr>
{/each}
</thead>
{/if}
<tbody>
{#each bodyRows as row (row)}
<tr>
{#each row.cells as cell (cell)}
<td
style:text-align={cell.align !== "default"
? cell.align
: null}>{@render renderInlines(cell.inlines)}</td
>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<p class="whitespace-pre-wrap">{@render renderInlines(block.inlines)}</p>
<p>{@render renderInlines(block.inlines)}</p>
{/if}
{/each}
{/snippet}
<div
class="prose leading-5 max-w-none text-neutral-700 [&_p]:my-3 [&_img]:my-5 [&_blockquote_p]:before:content-none [&_blockquote_p]:after:content-none"
class="prose leading-5 max-w-none text-neutral-700 [&_p]:my-3 [&_img]:my-5 [&_table]:my-0 [&_pre]:bg-neutral-100 [&_pre]:text-neutral-800 [&_:not(pre)>code]:rounded [&_:not(pre)>code]:bg-neutral-100 [&_:not(pre)>code]:px-1 [&_:not(pre)>code]:py-0.5 [&_blockquote_p]:before:content-none [&_blockquote_p]:after:content-none [&_code]:before:content-none [&_code]:after:content-none"
>
{#each paragraphs as blocks}
{@render renderBlocks(blocks)}
{/each}
{@render renderBlocks(blocks)}
</div>