Support formatting using djot
This commit is contained in:
parent
ca02419b13
commit
1af92c8399
3 changed files with 435 additions and 100 deletions
13
package-lock.json
generated
13
package-lock.json
generated
|
|
@ -8,6 +8,7 @@
|
||||||
"name": "squalk",
|
"name": "squalk",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@djot/djot": "^0.3.2",
|
||||||
"@nostr/gadgets": "npm:@jsr/nostr__gadgets@^0.1.8",
|
"@nostr/gadgets": "npm:@jsr/nostr__gadgets@^0.1.8",
|
||||||
"@nostr/tools": "npm:@jsr/nostr__tools@^2.23.3"
|
"@nostr/tools": "npm:@jsr/nostr__tools@^2.23.3"
|
||||||
},
|
},
|
||||||
|
|
@ -37,6 +38,18 @@
|
||||||
"vite": "^8.0.7"
|
"vite": "^8.0.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@djot/djot": {
|
||||||
|
"version": "0.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@djot/djot/-/djot-0.3.2.tgz",
|
||||||
|
"integrity": "sha512-joMKR24B8rxueyFiJbpZAqEiypjvOyzTxzkhyr0q5mM/sUBaOD3unna/9IxtOotFugViyYlkIRaiXg3xM//zxg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"djot": "lib/cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=17.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.10.0",
|
"version": "1.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@
|
||||||
"vite": "^8.0.7"
|
"vite": "^8.0.7"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@djot/djot": "^0.3.2",
|
||||||
"@nostr/gadgets": "npm:@jsr/nostr__gadgets@^0.1.8",
|
"@nostr/gadgets": "npm:@jsr/nostr__gadgets@^0.1.8",
|
||||||
"@nostr/tools": "npm:@jsr/nostr__tools@^2.23.3"
|
"@nostr/tools": "npm:@jsr/nostr__tools@^2.23.3"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,15 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata";
|
import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata";
|
||||||
import * as nip19 from "@nostr/tools/nip19";
|
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 = {
|
type Props = {
|
||||||
content: string;
|
content: string;
|
||||||
|
|
@ -130,14 +139,34 @@
|
||||||
| { type: "link"; href: string; label: string }
|
| { type: "link"; href: string; label: string }
|
||||||
| { type: "mention"; pubkey: string; entity: string; fallback: string }
|
| { type: "mention"; pubkey: string; entity: string; fallback: string }
|
||||||
| { type: "entity"; entity: string; label: 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 Block =
|
||||||
| { type: "image"; value: string }
|
| { type: "image"; src: string; alt: string }
|
||||||
| { type: "para"; inlines: Inline[] }
|
| { type: "para"; inlines: Inline[] }
|
||||||
| { type: "blockquote"; blocks: Block[] };
|
| { type: "blockquote"; blocks: Block[] }
|
||||||
|
| { type: "heading"; level: number; inlines: Inline[] }
|
||||||
const BLOCKQUOTE_LINE_RE = /^>\s?(.*)$/;
|
| { 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 {
|
function decodeNostrInline(entity: string): Inline | null {
|
||||||
try {
|
try {
|
||||||
|
|
@ -181,8 +210,9 @@
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inline-only tokenizer: produces inline tokens for a single line of text.
|
// Leaf text pass: scans a plain-text run (a Djot `str` node) for nostr
|
||||||
// Used inside blockquote lines, where images are not supported.
|
// entities and bare URLs. Never runs on code — verbatim/code_block stay
|
||||||
|
// literal.
|
||||||
function tokenizeInline(text: string): Inline[] {
|
function tokenizeInline(text: string): Inline[] {
|
||||||
const inlines: Inline[] = [];
|
const inlines: Inline[] = [];
|
||||||
let last = 0;
|
let last = 0;
|
||||||
|
|
@ -217,108 +247,293 @@
|
||||||
return inlines;
|
return inlines;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isBlockquoteParagraph(text: string): boolean {
|
// Whitelist link schemes: user-controlled hrefs are the one place a raw
|
||||||
const lines = text.split("\n");
|
// value reaches an <a>. Anything not http(s)/mailto renders as plain text.
|
||||||
let hasQuoteLine = false;
|
function safeHref(dest: string): string | null {
|
||||||
for (const l of lines) {
|
const d = dest.trim();
|
||||||
if (l.length === 0) continue;
|
if (/^https?:\/\//i.test(d)) return d;
|
||||||
if (!BLOCKQUOTE_LINE_RE.test(l)) return false;
|
if (/^mailto:/i.test(d)) return d;
|
||||||
hasQuoteLine = true;
|
if (/^[a-z][a-z0-9+.-]*:/i.test(d)) return null;
|
||||||
}
|
if (/\S\.\S/.test(d)) return `https://${d}`;
|
||||||
return hasQuoteLine;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function tokenizeBlockquote(text: string): Block {
|
function safeImg(dest: string): string | null {
|
||||||
// Strip the > prefix per line, then route through the same paragraph
|
const d = dest.trim();
|
||||||
// pipeline so blockquotes get image support, pre-wrap newlines, and
|
if (/^https?:\/\//i.test(d)) return d;
|
||||||
// consistent paragraph handling.
|
if (/^[a-z][a-z0-9+.-]*:/i.test(d)) return null;
|
||||||
const inner = text
|
if (/\S\.\S/.test(d)) return `https://${d}`;
|
||||||
.split("\n")
|
return null;
|
||||||
.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 tokenize(text: string): Block[] {
|
function inlineText(nodes: DjInline[]): string {
|
||||||
if (isBlockquoteParagraph(text)) return [tokenizeBlockquote(text)];
|
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[] = [];
|
const blocks: Block[] = [];
|
||||||
let inlines: Inline[] = [];
|
let cur: Inline[] = [];
|
||||||
const flush = () => {
|
const flush = () => {
|
||||||
// Trim leading/trailing newlines so block images don't carry an extra
|
while (cur.length) {
|
||||||
// visible line break under whitespace-pre-wrap.
|
const f = cur[0];
|
||||||
while (inlines.length > 0) {
|
if (f.type !== "text") break;
|
||||||
const first = inlines[0];
|
const t = f.value.replace(/^\s+/, "");
|
||||||
if (first.type !== "text") break;
|
if (t === "") cur.shift();
|
||||||
const trimmed = first.value.replace(/^\n+/, "");
|
|
||||||
if (trimmed === "") inlines.shift();
|
|
||||||
else {
|
else {
|
||||||
inlines[0] = { type: "text", value: trimmed };
|
cur[0] = { type: "text", value: t };
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
while (inlines.length > 0) {
|
while (cur.length) {
|
||||||
const last = inlines[inlines.length - 1];
|
const l = cur[cur.length - 1];
|
||||||
if (last.type !== "text") break;
|
if (l.type !== "text") break;
|
||||||
const trimmed = last.value.replace(/\n+$/, "");
|
const t = l.value.replace(/\s+$/, "");
|
||||||
if (trimmed === "") inlines.pop();
|
if (t === "") cur.pop();
|
||||||
else {
|
else {
|
||||||
inlines[inlines.length - 1] = { type: "text", value: trimmed };
|
cur[cur.length - 1] = { type: "text", value: t };
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (inlines.some((i) => i.type !== "text" || i.value.trim()))
|
if (cur.some((i) => i.type !== "text" || i.value.trim()))
|
||||||
blocks.push({ type: "para", inlines });
|
blocks.push({ type: "para", inlines: cur });
|
||||||
inlines = [];
|
cur = [];
|
||||||
};
|
};
|
||||||
let last = 0;
|
for (const inl of inlines) {
|
||||||
for (const m of text.matchAll(URL_RE)) {
|
if (inl.type === "image") {
|
||||||
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();
|
flush();
|
||||||
blocks.push({ type: "image", value: href });
|
blocks.push({ type: "image", src: inl.src, alt: inl.alt });
|
||||||
if (trailing) inlines.push({ type: "text", value: trailing });
|
} else if (inl.type === "link" && IMG_EXT_RE.test(inl.href)) {
|
||||||
} else {
|
flush();
|
||||||
inlines.push({ type: "link", href, label: url });
|
blocks.push({ type: "image", src: inl.href, alt: "" });
|
||||||
if (trailing) inlines.push({ type: "text", value: trailing });
|
} else cur.push(inl);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
last = start + m[0].length;
|
|
||||||
}
|
|
||||||
if (last < text.length)
|
|
||||||
inlines.push({ type: "text", value: text.slice(last) });
|
|
||||||
flush();
|
flush();
|
||||||
return blocks;
|
return blocks;
|
||||||
}
|
}
|
||||||
|
|
||||||
const paragraphs = $derived(
|
function convertTable(node: DjTable): Block {
|
||||||
content.split(/\n{2,}/).map((para) => tokenize(para)),
|
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>>({});
|
let resolvedUsers = $state<Record<string, NostrUser>>({});
|
||||||
|
|
||||||
|
|
@ -328,15 +543,27 @@
|
||||||
for (const inline of inlines) {
|
for (const inline of inlines) {
|
||||||
if (inline.type === "mention" || inline.type === "thread-quote")
|
if (inline.type === "mention" || inline.type === "thread-quote")
|
||||||
seen.add(inline.pubkey);
|
seen.add(inline.pubkey);
|
||||||
|
else if (
|
||||||
|
inline.type === "strong" ||
|
||||||
|
inline.type === "em" ||
|
||||||
|
inline.type === "del"
|
||||||
|
)
|
||||||
|
collect(inline.children);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const visit = (blocks: Block[]) => {
|
const visit = (blocks: Block[]) => {
|
||||||
for (const block of blocks) {
|
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 === "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) {
|
for (const pubkey of seen) {
|
||||||
if (resolvedUsers[pubkey] || profiles[pubkey]) continue;
|
if (resolvedUsers[pubkey] || profiles[pubkey]) continue;
|
||||||
loadNostrUser(pubkey).then((u) => {
|
loadNostrUser(pubkey).then((u) => {
|
||||||
|
|
@ -347,7 +574,7 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet renderInlines(inlines: Inline[])}
|
{#snippet renderInlines(inlines: Inline[])}
|
||||||
{#each inlines as inline}
|
{#each inlines as inline (inline)}
|
||||||
{#if inline.type === "link"}
|
{#if inline.type === "link"}
|
||||||
<a
|
<a
|
||||||
href={inline.href}
|
href={inline.href}
|
||||||
|
|
@ -390,16 +617,54 @@
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="text-brand hover:underline break-all">{inline.label}</a
|
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}
|
{:else}{inline.value}{/if}
|
||||||
{/each}
|
{/each}
|
||||||
{/snippet}
|
{/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[])}
|
{#snippet renderBlocks(blocks: Block[])}
|
||||||
{#each blocks as block}
|
{#each blocks as block (block)}
|
||||||
{#if block.type === "image"}
|
{#if block.type === "image"}
|
||||||
<img
|
<img
|
||||||
src={block.value}
|
src={block.src}
|
||||||
alt=""
|
alt={block.alt}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
class="block mx-auto w-full max-h-[80vh] object-contain rounded"
|
class="block mx-auto w-full max-h-[80vh] object-contain rounded"
|
||||||
/>
|
/>
|
||||||
|
|
@ -409,16 +674,72 @@
|
||||||
>
|
>
|
||||||
{@render renderBlocks(block.blocks)}
|
{@render renderBlocks(block.blocks)}
|
||||||
</blockquote>
|
</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}
|
{:else}
|
||||||
<p class="whitespace-pre-wrap">{@render renderInlines(block.inlines)}</p>
|
<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>{@render renderInlines(block.inlines)}</p>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<div
|
<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)}
|
{@render renderBlocks(blocks)}
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue