Support formatting using djot
This commit is contained in:
parent
ca02419b13
commit
1af92c8399
3 changed files with 435 additions and 100 deletions
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue