# Rich-text display

> Renders a structured content tree (paragraphs, headings, lists, quotes, images, code and rules) through a fixed set of Svelte parts, with a bounded reading column, per-node snippet overrides and no HTML strings.

- ID: `cmp_rich_text_display_01`
- Slug: `rich-text-display-01`
- Version: `1.0.0` (current)
- Status: published
- Published: 2026-10-21T20:55:28Z
- Updated: 2026-10-21
- Available versions: `1.0.0`
- Kind: control
- Primary category: `typography`
- Detail page: https://pagesugar.com/components/rich-text-display-01?variant=neutral
- Preview: https://pagesugar.com/preview/rich-text-display-01

## Variants

| Variant | Label | Default | Artifact digest |
| --- | --- | --- | --- |
| `neutral` | Neutral | yes | `sha256-5425b69915b6f550f40c95b55f98cc56ac0d274b85b41c794b54a7795043d5ad` |
| `blue` | Blue links | no | `sha256-1ef7dbb8858763098d65e0325adb5229a5157558491d3bad9ee6e07a91717e87` |

## Runtime and compatibility

- Runtime: svelte
- Svelte: 5
- SvelteKit required: no (portable Svelte component)
- Imports: The source imports its own files by relative path only. Examples import from $lib/...; SvelteKit 3 projects have no $lib alias by default, so import from your #lib subpath (with the .svelte extension) or a relative path instead.
- Tailwind CSS: 4
- SSR: supported
- Requires client-side JavaScript: no
- Integration level: presentational
- Appearance modes: light
- Suggested directory: `src/lib/components/rich-text-display-01`

## Dependencies

No third-party runtime packages.

## Services

No external services required.

## Usage

Pass content as an array of plain nodes and it renders them as an article body. It does not parse Markdown or HTML strings, render embeds or iframes, highlight code or fetch and resize images: convert your CMS format to the node shape, and use a renderer override for highlighting or an image component.

Required props: `content`

```svelte
<script lang="ts">
	import RichText from '$lib/components/rich-text-display-01/RichText.svelte';
	import type { RichNode } from '$lib/components/rich-text-display-01/types';

	const content: RichNode[] = [
		{ type: 'heading', level: 2, children: [{ type: 'text', text: 'Start from milestones' }] },
		{
			type: 'paragraph',
			children: [
				{ type: 'text', text: 'Every task hangs off a ' },
				{ type: 'text', text: 'milestone', marks: ['strong'] },
				{ type: 'text', text: '. Read the ' },
				{ type: 'link', href: '/docs/milestones', children: [{ type: 'text', text: 'guide' }] },
				{ type: 'text', text: '.' }
			]
		}
	];
</script>

<h1 class="text-4xl font-semibold">How we plan a quarter</h1>
<RichText {content} class="mt-10" onUnknownNode={(node) => console.warn('Unsupported node', node)} />
```

Limitations:

- No Markdown or HTML parser is bundled. Convert Portable Text, mdast, Lexical or your CMS's format to the node shape first; usage.md shows a small mdast converter.
- Embeds, tables and any other node type render nothing. Report them with onUnknownNode, or add the type yourself as a part in Blocks.svelte.
- Code is rendered as plain text. Highlighting belongs in a renderers.code override.
- Images are rendered as given, lazily, with width and height when the content supplies them. Without both, the space is not reserved before the image loads.
- The missing-alt warning uses import.meta.env.DEV, so it appears only in development under Vite or another bundler that defines it.
- Tones and hairlines default to mixes of the surrounding text colour. If that colour is light grey, set --rich-text-display-ink and --rich-text-display-muted.

## Usage guide

### Rich-text display

An article body from structured content. You pass an array of plain nodes; it renders them
through a fixed set of Svelte parts, one per node type, and never treats text as HTML. Use it
for blog posts, help articles, CMS pages and product descriptions.

```svelte
<script lang="ts">
	import RichText from '$lib/components/rich-text-display-01/RichText.svelte';
	import type { RichNode } from '$lib/components/rich-text-display-01/types';

	let { data } = $props();
	const content: RichNode[] = data.article.body;
</script>

<article class="mx-auto max-w-3xl px-4">
	<h1 class="text-4xl font-semibold tracking-tight">{data.article.title}</h1>
	<RichText {content} class="mt-10" />
</article>
```

#### The node shape

```ts
type RichInline =
	| { type: 'text'; text: string; marks?: ('strong' | 'em' | 'code')[] }
	| { type: 'link'; href: string; children: RichInline[]; newTab?: boolean };

type RichBlock =
	| { type: 'paragraph'; children: RichInline[] }
	| { type: 'heading'; level?: 1 | 2 | 3 | 4 | 5 | 6; children: RichInline[]; id?: string }
	| { type: 'list'; ordered?: boolean; start?: number; children: RichListItem[] }
	| { type: 'quote'; children: (RichParagraph | RichInline)[]; attribution?: string; cite?: string }
	| {
			type: 'image';
			src: string;
			alt?: string;
			decorative?: boolean;
			caption?: string;
			width?: number;
			height?: number;
	  }
	| { type: 'code'; text: string; language?: string; caption?: string }
	| { type: 'rule' };

// A list item holds inline content, optionally followed by a nested list.
type RichListItem = { type: 'item'; children: (RichInline | RichList)[] };
```

`types.ts` has the exact types with comments. Anything else in the tree, or a node missing a
field it needs, is passed to `onUnknownNode` and renders nothing. Empty paragraphs and lists
are dropped quietly, so they never leave a gap.

#### The layout

The root is a two-column grid. Text sits in the first column, `minmax(0, 30em)`, which holds
about 65 characters to the line at either density. Images and code blocks take
`col-span-full` and step out to the root's full width, so the article reads as set type with
framed media rather than a stack of blocks of equal width. Give the root a max width to decide
how far they step out; on a phone both columns are the screen.

Spacing is four steps, set as variables on the root: items in a list, a heading to its text,
one paragraph to the next, and the larger space before a heading and around figures and rules.
The rules target the root's direct children inside `@layer components`, so your override
markup gets the same rhythm, and a margin class on it still wins.

#### Headings

A heading's level picks its size; `headingOffset` is added to pick its element. With the page
title as the `h1`, leave the offset at 0 and content level 2 renders as `h2`. Under a page that
already uses `h2` for the section, pass `headingOffset={1}`: the same node becomes an `h3` and
looks the same. A level 1 node is read as level 2, and nothing goes past `h6`.

#### Overrides

Every node type can be replaced with a snippet. It receives the node and a context: `children`
renders the node's children the default way, `level` is the heading element's level, and
`href` is the link's href after the safety check.

```svelte
{#snippet code(node, { children })}
	<Highlighted language={node.language} code={node.text} class="col-span-full" />
{/snippet}

{#snippet heading(node, { children, level })}
	<svelte:element this={`h${level}`} id={node.id} class="text-2xl font-semibold">
		<a href={`#${node.id}`}>{@render children()}</a>
	</svelte:element>
{/snippet}

<RichText {content} renderers={{ code, heading }} />
```

#### Links

Only `http`, `https`, `mailto` and `tel` URLs, relative paths and fragments become links. A
`javascript:` or `data:` href renders the link's text without a link. Absolute `http` and
`https` links, and links with `newTab`, get `rel="noopener noreferrer"` (change it with
`linkRel`). `newTab` also adds visually hidden "(opens in new tab)" text.

#### Converting from Markdown or a CMS

No parser is bundled. A converter from mdast (the tree `remark` produces) is a few lines:

```ts
import type { RichBlock, RichInline } from '$lib/components/rich-text-display-01/types';

function inline(node: any, marks: ('strong' | 'em' | 'code')[] = []): RichInline[] {
	switch (node.type) {
		case 'text':
			return [{ type: 'text', text: node.value, marks }];
		case 'inlineCode':
			return [{ type: 'text', text: node.value, marks: [...marks, 'code'] }];
		case 'strong':
		case 'emphasis': {
			const mark = node.type === 'strong' ? 'strong' : 'em';
			return node.children.flatMap((child: any) => inline(child, [...marks, mark]));
		}
		case 'link':
			return [
				{
					type: 'link',
					href: node.url,
					children: node.children.flatMap((c: any) => inline(c, marks))
				}
			];
		default:
			return [];
	}
}
```

Blocks follow the same pattern: `paragraph`, `heading` (`depth` becomes `level`), `list` and
`listItem`, `blockquote`, `image`, `code` (`value` becomes `text`, `lang` becomes `language`)
and `thematicBreak` as `rule`. Portable Text maps the same way: `block` with a `style` becomes a
paragraph or heading, `listItem` blocks are grouped into lists, and `markDefs` become links.

#### Colour

Every tone is a mix of the surrounding text colour: muted 68%, hairlines 12%, the code
surface 4%. On a dark band, set the band's text colour and leave the tokens alone:

```svelte
<section class="bg-zinc-950 text-zinc-50">
	<RichText {content} />
</section>
```

On a tinted page, set the text colour to your ink (a warm brown on cream) and the rest follows.
To tie links to your buttons, set `--rich-text-display-accent`.

## Props

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `content` | `RichNode[]` | yes |  | The content tree as plain data: paragraph, heading, list (of item), quote, image, code and rule blocks, holding text and link inline nodes. Text is never read as HTML. |
| `headingOffset` | 0 \| 1 \| 2 | no | `0` | Added to each heading's level to pick its element, clamped to h2 to h6. The size follows the level in the content, so the look does not change. |
| `renderers` | `RichRenderers` | no |  | Snippets keyed by node type (paragraph, heading, list, item, quote, image, code, rule, text, link). Each receives the node and a context with a children snippet holding the default rendering of its children, plus level for headings and the checked href for links. |
| `linkRel` | `string` | no | `'noopener noreferrer'` | rel on links to another site (absolute http or https) and on links that open a new tab. An empty string sets none. |
| `onUnknownNode` | `(node: unknown) => void` | no |  | Called with each node of an unsupported type, or missing a field it needs (an image with no usable src). Those nodes render nothing. Runs during the server render and again in the browser. |
| `density` | 'spacious' \| 'compact' | no | `'spacious'` | Spacious is 16 px text on 28 px leading for articles; compact is 14 px on tighter spacing for product descriptions and panels. |
| `class` | `string` | no |  | Extra classes on the root, for width and placement. The root renders only when there is content. |

## Customization

Retone it with six --rich-text-display-\* variables; by default everything is mixed from the surrounding text colour, so on a dark or tinted page set the text colour and leave the tokens alone. Override any node type with a snippet through renderers.

- Colour: the text takes the surrounding colour; muted is a 68% mix of it, hairlines 12% and the code surface 4%. On a dark band set the band's text colour (text-zinc-50 on bg-zinc-950) and every tone follows.
- Links: by default they are the text colour with a 40% underline that turns solid on hover. Set --rich-text-display-accent to your brand colour (the blue palette uses #1d4ed8) and keep it at 4.5:1 against the background; it also colours the focus ring.
- Quote rule: --rich-text-display-quote-border colours the one-pixel line beside quotes; it is the text colour by default.
- Measure: the reading column is minmax(0, 30em) in the root's grid, about 65 characters. Images and code use col-span-full to step out to the root's full width; give the root a max width (max-w-3xl) to set how far.
- Rhythm: four spacing steps are variables on the root (--\_flow-item, --\_flow-tight, --\_flow, --\_flow-section), with smaller values under .rich-text-display--compact. Change them there rather than adding margins to parts.
- Overrides: renderers.code receives the node and a context; render your highlighter's output and add col-span-full to keep the step-out. Your markup gets the same rhythm as the defaults because the spacing rules target the root's direct children.
- New node types: add a part under parts/, a case in Blocks.svelte and prepare.ts, and the type to types.ts.

| Token | Public CSS variable |
| --- | --- |
| `accent` | `--rich-text-display-accent` |
| `ink` | `--rich-text-display-ink` |
| `muted` | `--rich-text-display-muted` |
| `hairline` | `--rich-text-display-hairline` |
| `surface` | `--rich-text-display-surface` |
| `quoteBorder` | `--rich-text-display-quote-border` |

## Accessibility

- Headings render as h2 to h6 only: a level 1 node is treated as level 2, and headingOffset shifts them under your page heading. Keep one h1 on the page yourself.
- Lists are native ul and ol with li children; ordered lists keep their start number. Quotes are a blockquote inside a figure, with the attribution in a figcaption and the source URL in cite.
- Images with decorative: true render alt="". An image with no alt that is not marked decorative renders without an alt attribute, so audits catch it, and logs a warning in development.
- Links are underlined at rest, so they do not rely on colour (WCAG 1.4.1). Links with newTab add target="\_blank" and visually hidden text "(opens in new tab)". Focus shows a 2 px ring in the accent with a 2 px offset.
- Code blocks are focusable regions named by their caption and language, so a keyboard user can scroll a long line into view. Code is set left to right inside right-to-left content.
- Long words and URLs wrap with overflow-wrap: anywhere, so content never forces horizontal scrolling at 320 px.
- Letter-spacing is removed under dir="rtl" and for Chinese, Japanese and Korean; Arabic and CJK get more leading. Set lang and dir on the root or an ancestor for content in another language.

Known limitations:

- The component cannot check that alt text is accurate or that headings are in order; that is the content's job.
- Colour contrast follows the surrounding text colour and is not checked by the component; check any token you set.

## License

- Declared source: MIT
- Default license approval is pending. See https://pagesugar.com/docs/license.

## Source

- Palette: Neutral (`neutral`)
- Entry: `RichText.svelte`
- Suggested directory: `src/lib/components/rich-text-display-01`
- Files: 13
- Artifact digest: `sha256-5425b69915b6f550f40c95b55f98cc56ac0d274b85b41c794b54a7795043d5ad`

Paths below are relative to the suggested directory. Copy the files as they are;
they import nothing from this site.

#### `RichText.svelte`

Role: entry · 5878 bytes · SHA-256 `651de1296d40fc38862bbe15dd671c06a56ba99cb229261cbc558de781b67308`

```svelte
<script lang="ts">
	import { setContext, untrack } from 'svelte';
	import Blocks from './parts/Blocks.svelte';
	import { SETTINGS, prepare, type PreparedContent, type RichTextSettings } from './prepare';
	import type { RichDensity, RichNode, RichRenderers } from './types';

	interface Props {
		/** The content tree: plain data, never HTML. */
		content: RichNode[];
		/** Shifts heading elements down so content headings sit under the page heading. */
		headingOffset?: 0 | 1 | 2;
		/** Snippets that replace the default output for one node type each. */
		renderers?: RichRenderers;
		/** rel on links that leave the site or open a new tab. */
		linkRel?: string;
		/** Called with each node the component does not support; those nodes render nothing. */
		onUnknownNode?: (node: unknown) => void;
		/** Vertical rhythm and size: spacious for articles, compact for descriptions and panels. */
		density?: RichDensity;
		/** Extra classes on the root, for width and placement. */
		class?: string;
	}

	let {
		content,
		headingOffset = 0,
		renderers,
		linkRel = 'noopener noreferrer',
		onUnknownNode,
		density = 'spacious',
		class: className
	}: Props = $props();

	setContext<RichTextSettings>(SETTINGS, {
		get renderers() {
			return renderers ?? {};
		},
		get headingOffset() {
			return headingOffset;
		},
		get linkRel() {
			return linkRel;
		},
		get density() {
			return density === 'compact' ? 'compact' : 'spacious';
		}
	});

	const prepared = $derived(prepare(content));
	const compact = $derived(density === 'compact');

	// Only Vite-style bundlers define import.meta.env; anywhere else the warning stays quiet.
	const DEV = (import.meta as { env?: { DEV?: boolean } }).env?.DEV === true;

	function report({ unknown, missingAlt }: PreparedContent) {
		for (const node of unknown) onUnknownNode?.(node);
		if (DEV) {
			for (const image of missingAlt) {
				console.warn(
					`rich-text-display: image "${image.src}" has no alt text. Describe it, or set decorative: true if it adds nothing the text does not say.`
				);
			}
		}
	}

	// The server reports once while rendering; the browser reports after mount and on each change.
	if (typeof window === 'undefined') report(untrack(() => prepared));
	$effect(() => {
		const current = prepared;
		untrack(() => report(current));
	});
</script>

{#if prepared.blocks.length}
	<div
		class={[
			'rich-text-display grid grid-cols-[minmax(0,30em)_minmax(0,1fr)] [overflow-wrap:anywhere] text-(--_ink)',
			compact
				? 'rich-text-display--compact text-sm leading-[1.6] tracking-normal'
				: 'text-base leading-[1.75] tracking-[-0.011em] rtl:tracking-normal',
			className
		]}
	>
		<Blocks blocks={prepared.blocks} />
	</div>
{/if}

<style>
	/*
	 * Public tokens: set --rich-text-display-* on the root or any ancestor. By default the text
	 * takes the surrounding colour and the rest are mixes of it, so the same content reads on a
	 * white page, a dark band or a tinted card without a change.
	 */
	.rich-text-display {
		--_accent: var(--rich-text-display-accent, currentColor);
		--_ink: var(--rich-text-display-ink, currentColor);
		--_muted: var(--rich-text-display-muted, color-mix(in srgb, currentColor 68%, transparent));
		--_hairline: var(
			--rich-text-display-hairline,
			color-mix(in srgb, currentColor 12%, transparent)
		);
		--_surface: var(--rich-text-display-surface, color-mix(in srgb, currentColor 4%, transparent));
		--_quote-border: var(--rich-text-display-quote-border, currentColor);
		/* The resting underline is a softer mix of the link colour; hover draws it in full. */
		--_link-line: color-mix(in srgb, var(--_accent) 40%, transparent);

		/* Vertical rhythm: four steps, closer inside a group than between groups. */
		--_flow-item: 0.5rem;
		--_flow-tight: 0.75rem;
		--_flow: 1.25rem;
		--_flow-section: 2.5rem;
	}

	.rich-text-display--compact {
		--_flow-item: 0.25rem;
		--_flow-tight: 0.5rem;
		--_flow: 0.75rem;
		--_flow-section: 1.5rem;
	}

	/*
	 * The rhythm is a relation between blocks, and some blocks are your override markup, so it
	 * sits in the components layer: a margin or column class on your own element still wins.
	 * Text sits in the reading column; images and code take col-span-full and step out past it.
	 * Headings take more space above than below, so each one belongs to what follows it.
	 */
	@layer components {
		.rich-text-display > :global(*) {
			grid-column: 1;
		}

		.rich-text-display > :global(* + *) {
			margin-block-start: var(--_flow);
		}

		.rich-text-display > :global(* + :is(figure, hr)),
		.rich-text-display > :global(:is(figure, hr) + *),
		.rich-text-display > :global(* + :is(h2, h3, h4, h5, h6)) {
			margin-block-start: var(--_flow-section);
		}

		.rich-text-display > :global(:is(h2, h3, h4, h5, h6) + *) {
			margin-block-start: var(--_flow-tight);
		}
	}

	/* Arabic letterforms run taller and deeper than Latin, so the text opens its leading. */
	.rich-text-display:lang(ar),
	.rich-text-display:lang(fa),
	.rich-text-display:lang(ur) {
		line-height: 1.9;
	}

	/*
	 * Chinese, Japanese and Korean: no Latin tracking, a little more leading, and lines that
	 * break at sensible points. Korean keeps its words whole; Japanese breaks at phrases.
	 */
	.rich-text-display:lang(zh),
	.rich-text-display:lang(ja),
	.rich-text-display:lang(ko),
	.rich-text-display:lang(zh) :global(:is(h2, h3, h4, h5, h6, blockquote)),
	.rich-text-display:lang(ja) :global(:is(h2, h3, h4, h5, h6, blockquote)),
	.rich-text-display:lang(ko) :global(:is(h2, h3, h4, h5, h6, blockquote)) {
		letter-spacing: 0;
		line-break: strict;
	}

	.rich-text-display:lang(zh),
	.rich-text-display:lang(ja),
	.rich-text-display:lang(ko) {
		line-height: 1.85;
	}

	.rich-text-display:lang(ko) {
		word-break: keep-all;
	}

	@supports (word-break: auto-phrase) {
		.rich-text-display:lang(ja) {
			word-break: auto-phrase;
		}
	}
</style>
```

#### `parts/Blocks.svelte`

Role: component · 928 bytes · SHA-256 `3178e5ec8e6bd73c3b463fa9e5261cce8654bcac862710262e39fd28b142e710`

```svelte
<script lang="ts">
	import type { RichBlock } from '../types';
	import CodeBlock from './CodeBlock.svelte';
	import Figure from './Figure.svelte';
	import Heading from './Heading.svelte';
	import List from './List.svelte';
	import Paragraph from './Paragraph.svelte';
	import Quote from './Quote.svelte';
	import Rule from './Rule.svelte';

	interface Props {
		/** Checked blocks from prepare(); unknown nodes are already gone. */
		blocks: RichBlock[];
	}

	let { blocks }: Props = $props();
</script>

{#each blocks as node, i (i)}
	{#if node.type === 'paragraph'}
		<Paragraph {node} />
	{:else if node.type === 'heading'}
		<Heading {node} />
	{:else if node.type === 'list'}
		<List {node} />
	{:else if node.type === 'quote'}
		<Quote {node} />
	{:else if node.type === 'image'}
		<Figure {node} />
	{:else if node.type === 'code'}
		<CodeBlock {node} />
	{:else if node.type === 'rule'}
		<Rule {node} />
	{/if}
{/each}
```

#### `parts/CodeBlock.svelte`

Role: component · 1606 bytes · SHA-256 `0b89d3de4564cdd933bd96740e66e6d969c835d67191684b38eb4b47556cbae8`

```svelte
<script lang="ts">
	import { getContext } from 'svelte';
	import { SETTINGS, type RichTextSettings } from '../prepare';
	import type { RichCodeNode } from '../types';

	interface Props {
		node: RichCodeNode;
	}

	let { node }: Props = $props();

	const settings = getContext<RichTextSettings>(SETTINGS);
	const override = $derived(settings.renderers.code);
	const label = $derived([node.caption, node.language].filter(Boolean).join(', ') || 'Code sample');
</script>

{#snippet children()}{/snippet}

{#if override}
	{@render override(node, { children })}
{:else}
	<figure
		class="rich-text-display__code col-span-full overflow-hidden rounded-xl bg-(--_surface) ring-1 ring-(--_hairline) ring-inset"
	>
		{#if node.caption || node.language}
			<figcaption
				class="flex items-baseline justify-between gap-3 border-b border-(--_hairline) px-5 py-2 text-xs leading-[1.35] text-(--_muted)"
			>
				<span class="min-w-0">{node.caption ?? ''}</span>
				{#if node.language}<span class="shrink-0 font-mono" dir="ltr">{node.language}</span>{/if}
			</figcaption>
		{/if}
		<!-- Focusable and named so a keyboard can scroll a long line into view (axe: scrollable-region-focusable). -->
		<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
		<pre
			tabindex="0"
			role="region"
			aria-label={label}
			dir="ltr"
			class="overflow-x-auto p-5 font-mono text-[0.8125rem] leading-[1.7] tracking-normal [tab-size:2] text-(--_ink) focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-(--_accent)"><code
				data-language={node.language}>{node.text}</code
			></pre>
	</figure>
{/if}
```

#### `parts/Figure.svelte`

Role: component · 1401 bytes · SHA-256 `89d7f5226ac7e8d31ad88f1b5c80d0d93f6f7ac551b37d03da0b6eaa859a3cac`

```svelte
<script lang="ts">
	import { getContext } from 'svelte';
	import { SETTINGS, type RichTextSettings } from '../prepare';
	import type { RichImageNode } from '../types';

	interface Props {
		node: RichImageNode;
	}

	let { node }: Props = $props();

	const settings = getContext<RichTextSettings>(SETTINGS);
	const override = $derived(settings.renderers.image);

	/*
	 * A decorative image gets alt="". An image with no alt that is not marked decorative keeps
	 * no alt attribute at all, so your own accessibility checks still catch it; the component
	 * warns about it in development instead of hiding it.
	 */
	const alt = $derived(node.decorative ? '' : node.alt?.trim() ? node.alt : undefined);
</script>

{#snippet children()}{/snippet}

{#if override}
	{@render override(node, { children })}
{:else}
	<!-- A subgrid of the root, so the caption keeps the reading column while the image steps out. -->
	<figure class="rich-text-display__figure col-span-full grid grid-cols-subgrid">
		<img
			src={node.src}
			{alt}
			width={node.width}
			height={node.height}
			loading="lazy"
			decoding="async"
			class="col-span-full block h-auto w-full rounded-xl bg-(--_surface) outline -outline-offset-1 outline-(--_hairline)"
		/>
		{#if node.caption}
			<figcaption class="mt-3 text-[0.8125rem] leading-[1.45] text-pretty text-(--_muted)">
				{node.caption}
			</figcaption>
		{/if}
	</figure>
{/if}
```

#### `parts/Heading.svelte`

Role: component · 1579 bytes · SHA-256 `d179b859e602b3fa414343d4de669a723537f2598c46594f59fd3519e914d5c0`

```svelte
<script lang="ts">
	import { getContext } from 'svelte';
	import { SETTINGS, headingElementLevel, headingRank, type RichTextSettings } from '../prepare';
	import type { RichDensity, RichHeadingNode } from '../types';
	import Inlines from './Inlines.svelte';

	interface Props {
		node: RichHeadingNode;
	}

	let { node }: Props = $props();

	const settings = getContext<RichTextSettings>(SETTINGS);
	const override = $derived(settings.renderers.heading);

	/*
	 * The element comes from the rank plus headingOffset; the size comes from the rank alone, so
	 * moving the content under a deeper page heading changes the outline, not the look.
	 */
	const level = $derived(headingElementLevel(node.level, settings.headingOffset));
	const rank = $derived(headingRank(node.level));

	const SIZES: Record<RichDensity, Record<2 | 3 | 4, string>> = {
		spacious: {
			2: 'text-2xl leading-[1.25] tracking-[-0.019em]',
			3: 'text-xl leading-[1.3] tracking-[-0.016em]',
			4: 'text-base leading-[1.4] tracking-[-0.011em]'
		},
		compact: {
			2: 'text-lg leading-[1.3] tracking-[-0.014em]',
			3: 'text-base leading-[1.35] tracking-[-0.011em]',
			4: 'text-sm leading-[1.4] tracking-normal'
		}
	};
</script>

{#snippet children()}<Inlines nodes={node.children} />{/snippet}

{#if override}
	{@render override(node, { children, level })}
{:else}
	<svelte:element
		this={`h${level}`}
		id={node.id}
		class={[
			'rich-text-display__heading font-semibold text-balance text-(--_ink) rtl:tracking-normal',
			SIZES[settings.density][rank]
		]}>{@render children()}</svelte:element
	>
{/if}
```

#### `parts/Inlines.svelte`

Role: component · 1507 bytes · SHA-256 `1dcca7b158e7fd0989e16b47be9507474ad3831401f6ba41eec3f92c8e1c9093`

```svelte
<script lang="ts">
	import { getContext } from 'svelte';
	import { SETTINGS, type RichTextSettings } from '../prepare';
	import type { RichInline, RichMark } from '../types';
	import Link from './Link.svelte';

	interface Props {
		/** Inline nodes in reading order. */
		nodes: RichInline[];
	}

	let { nodes }: Props = $props();

	const settings = getContext<RichTextSettings>(SETTINGS);
	const override = $derived(settings.renderers.text);

	/* Marks nest in a fixed order, strong outside em outside code, whatever order they arrive in. */
	const ORDER: RichMark[] = ['strong', 'em', 'code'];
	const ordered = (marks: RichMark[] = []) => ORDER.filter((mark) => marks.includes(mark));
</script>

<!-- No whitespace between tags here: inside a line of text it would render as a space. -->
{#snippet marked(text: string, marks: RichMark[])}{#if marks[0] === 'strong'}<strong
			class="font-semibold">{@render marked(text, marks.slice(1))}</strong
		>{:else if marks[0] === 'em'}<em>{@render marked(text, marks.slice(1))}</em
		>{:else if marks[0] === 'code'}<code
			class="rounded-sm bg-(--_surface) [box-decoration-break:clone] px-1 font-mono text-[0.875em]"
			>{text}</code
		>{:else}{text}{/if}{/snippet}

{#each nodes as node, i (i)}{#if node.type === 'link'}<Link
			{node}
		/>{:else if override}{#snippet children()}{@render marked(
				node.text,
				ordered(node.marks)
			)}{/snippet}{@render override(node, {
			children
		})}{:else}{@render marked(node.text, ordered(node.marks))}{/if}{/each}
```

#### `parts/Link.svelte`

Role: component · 1245 bytes · SHA-256 `fa682c2f9c2c1d3c76b7b7672f61b8fa0e80af2065019cad8e0dcc1beadfa73e`

```svelte
<script lang="ts">
	import { getContext } from 'svelte';
	import { SETTINGS, isExternal, safeHref, type RichTextSettings } from '../prepare';
	import type { RichLinkNode } from '../types';
	import Inlines from './Inlines.svelte';

	interface Props {
		node: RichLinkNode;
	}

	let { node }: Props = $props();

	const settings = getContext<RichTextSettings>(SETTINGS);
	const override = $derived(settings.renderers.link);
	// A refused href (javascript:, data:) renders the link's text with no link at all.
	const href = $derived(safeHref(node.href));
	const rel = $derived(
		href && (node.newTab || isExternal(href)) ? settings.linkRel || undefined : undefined
	);
</script>

{#snippet children()}<Inlines nodes={node.children} />{/snippet}

{#if override}{@render override(node, { children, href })}{:else if href}<a
		{href}
		{rel}
		target={node.newTab ? '_blank' : undefined}
		class="rounded-xs text-(--_accent) underline decoration-(--_link-line) decoration-1 underline-offset-[0.22em] hover:decoration-(--_accent) focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--_accent)"
		>{@render children()}{#if node.newTab}<span class="sr-only"> (opens in new tab)</span>{/if}</a
	>{:else}{@render children()}{/if}
```

#### `parts/List.svelte`

Role: component · 2873 bytes · SHA-256 `e25a631d14b7cdfb5b648a56b312ebc5b7596c5db98222fe31ed65b99b0b751e`

```svelte
<script lang="ts">
	import { getContext } from 'svelte';
	import { SETTINGS, type RichTextSettings } from '../prepare';
	import type { RichInline, RichListItemNode, RichListNode } from '../types';
	import Inlines from './Inlines.svelte';
	import List from './List.svelte';

	interface Props {
		node: RichListNode;
		/** Nesting depth; 0 at the top. Picks the bullet shape. */
		depth?: number;
	}

	let { node, depth = 0 }: Props = $props();

	const settings = getContext<RichTextSettings>(SETTINGS);
	const override = $derived(settings.renderers.list);
	const itemOverride = $derived(settings.renderers.item);

	/* Bullets change shape with depth, as a reader expects; numbers stay numbers. */
	const BULLETS = ['list-disc', 'list-[circle]', 'list-[square]'];
	const marker = $derived(node.ordered ? 'list-decimal' : BULLETS[Math.min(depth, 2)]);

	/** Consecutive inline nodes stay together as one run of text. */
	function runs(children: RichListItemNode['children']) {
		const out: (RichInline[] | RichListNode)[] = [];
		for (const child of children) {
			if (child.type === 'list') out.push(child);
			else if (Array.isArray(out.at(-1))) (out.at(-1) as RichInline[]).push(child);
			else out.push([child]);
		}
		return out;
	}
</script>

{#snippet item(entry: RichListItemNode)}
	{#each runs(entry.children) as run, i (i)}
		{#if Array.isArray(run)}<Inlines nodes={run} />{:else}<List node={run} depth={depth + 1} />{/if}
	{/each}
{/snippet}

{#snippet items()}
	{#each node.children as entry, i (i)}
		{#if itemOverride}
			{#snippet children()}{@render item(entry)}{/snippet}
			{@render itemOverride(entry, { children })}
		{:else}
			<li class="ps-1">{@render item(entry)}</li>
		{/if}
	{/each}
{/snippet}

{#if override}
	{@render override(node, { children: items })}
{:else}
	<svelte:element
		this={node.ordered ? 'ol' : 'ul'}
		start={node.ordered && node.start !== 1 ? node.start : undefined}
		class={[
			'rich-text-display__list ps-5 marker:text-(--_muted)',
			node.ordered && 'marker:tabular-nums',
			marker,
			depth > 0 && 'mt-1'
		]}
	>
		{@render items()}
	</svelte:element>
{/if}

<style>
	/* Items are a relation between our own elements, kept here rather than as [&>li+li]. */
	.rich-text-display__list > :global(li + li) {
		margin-block-start: var(--_flow-item);
	}

	/* Inside a nested list the details sit closest of all, a third of the gap between steps. */
	.rich-text-display__list :global(.rich-text-display__list > li + li) {
		margin-block-start: 0.25rem;
	}

	/*
	 * After an item that holds a nested list, the next step sits a paragraph's space away, so
	 * the main sequence reads apart from the details under each step.
	 */
	.rich-text-display__list > :global(li:has(> :is(ul, ol)) + li),
	.rich-text-display__list :global(.rich-text-display__list > li:has(> :is(ul, ol)) + li) {
		margin-block-start: var(--_flow);
	}
</style>
```

#### `parts/Paragraph.svelte`

Role: component · 609 bytes · SHA-256 `c7e65e5882fd90e9332b10f679aa1855dd2c3bfb97033811a9800366801a8105`

```svelte
<script lang="ts">
	import { getContext } from 'svelte';
	import { SETTINGS, type RichTextSettings } from '../prepare';
	import type { RichParagraphNode } from '../types';
	import Inlines from './Inlines.svelte';

	interface Props {
		node: RichParagraphNode;
	}

	let { node }: Props = $props();

	const settings = getContext<RichTextSettings>(SETTINGS);
	const override = $derived(settings.renderers.paragraph);
</script>

{#snippet children()}<Inlines nodes={node.children} />{/snippet}

{#if override}
	{@render override(node, { children })}
{:else}
	<p class="text-pretty">{@render children()}</p>
{/if}
```

#### `parts/Quote.svelte`

Role: component · 1323 bytes · SHA-256 `df4145c47c6f76b74a9079a05b880b14fabe1d734dee302334afff1aacc1738c`

```svelte
<script lang="ts">
	import { getContext } from 'svelte';
	import { SETTINGS, type RichTextSettings } from '../prepare';
	import type { RichQuoteNode } from '../types';
	import Paragraph from './Paragraph.svelte';

	interface Props {
		node: RichQuoteNode;
	}

	let { node }: Props = $props();

	const settings = getContext<RichTextSettings>(SETTINGS);
	const override = $derived(settings.renderers.quote);
	const compact = $derived(settings.density === 'compact');
</script>

{#snippet children()}
	<!-- prepare() has already gathered any loose inline nodes into paragraphs. -->
	{#each node.children as paragraph, i (i)}
		{#if paragraph.type === 'paragraph'}<Paragraph node={paragraph} />{/if}
	{/each}
{/snippet}

{#if override}
	{@render override(node, { children })}
{:else}
	<figure class="rich-text-display__quote">
		<blockquote
			cite={node.cite}
			class={[
				'grid gap-(--_flow) border-s border-(--_quote-border) ps-5 text-(--_ink) rtl:tracking-normal',
				compact
					? 'text-base leading-[1.55] tracking-[-0.011em]'
					: 'text-lg leading-[1.6] tracking-[-0.014em]'
			]}
		>
			{@render children()}
		</blockquote>
		{#if node.attribution}
			<figcaption class="mt-3 ps-5 text-[0.8125rem] leading-[1.45] tracking-normal text-(--_muted)">
				{node.attribution}
			</figcaption>
		{/if}
	</figure>
{/if}
```

#### `parts/Rule.svelte`

Role: component · 518 bytes · SHA-256 `e38cb1849c86406e3a2ebdd47910eda285e2c6122dcfe424f8c0ac709b385ed1`

```svelte
<script lang="ts">
	import { getContext } from 'svelte';
	import { SETTINGS, type RichTextSettings } from '../prepare';
	import type { RichRuleNode } from '../types';

	interface Props {
		node: RichRuleNode;
	}

	let { node }: Props = $props();

	const settings = getContext<RichTextSettings>(SETTINGS);
	const override = $derived(settings.renderers.rule);
</script>

{#snippet children()}{/snippet}

{#if override}
	{@render override(node, { children })}
{:else}
	<hr class="h-px border-0 bg-(--_hairline)" />
{/if}
```

#### `prepare.ts`

Role: helper · 8698 bytes · SHA-256 `602de89b53c6f7ecf5e1a23fc80be17cad8ee66c628a733d3d66fee47d2026fa`

```ts
import type {
	RichBlock,
	RichDensity,
	RichImageNode,
	RichInline,
	RichListItemNode,
	RichListNode,
	RichMark,
	RichParagraphNode,
	RichRenderers
} from './types';

/** Settings every part reads, shared through context so nested parts need no prop drilling. */
export interface RichTextSettings {
	readonly renderers: RichRenderers;
	readonly headingOffset: number;
	readonly linkRel: string;
	readonly density: RichDensity;
}

export const SETTINGS = Symbol('rich-text-display');

/** The content after checking, plus what was dropped on the way. */
export interface PreparedContent {
	blocks: RichBlock[];
	/** Nodes of an unsupported type, or missing a field they need. They render nothing. */
	unknown: unknown[];
	/** Images with no alt that are not marked decorative. */
	missingAlt: RichImageNode[];
}

const MARKS = new Set<RichMark>(['strong', 'em', 'code']);
const SAFE_PROTOCOL = /^(?:https?|mailto|tel):/i;
const ANY_PROTOCOL = /^[a-z][a-z\d+.-]*:/i;

type Loose = Record<string, unknown>;

const isObject = (value: unknown): value is Loose =>
	typeof value === 'object' && value !== null && !Array.isArray(value);

const list = (value: unknown): unknown[] => (Array.isArray(value) ? value : []);

/**
 * The href to render, or undefined when it is not safe to link. Relative paths, fragments and
 * queries pass; absolute URLs pass only as http, https, mailto or tel. The value is first
 * cleaned the way a browser's URL parser cleans it (tabs and newlines removed anywhere, control
 * characters and spaces trimmed from the ends), and that cleaned value is both what is checked
 * and what is returned, so "java\tscript:" is caught and nothing checked differs from what is
 * rendered.
 */
export function safeHref(value: unknown): string | undefined {
	if (typeof value !== 'string') return undefined;
	const href = value
		.replace(/[\t\n\r]/g, '')
		// eslint-disable-next-line no-control-regex
		.replace(/^[\u0000- ]+|[\u0000- ]+$/g, '');
	if (!href) return undefined;
	if (!ANY_PROTOCOL.test(href)) return href;
	return SAFE_PROTOCOL.test(href) ? href : undefined;
}

/** An absolute http(s) URL or a protocol-relative one: it leaves the site. */
export const isExternal = (href: string): boolean => /^(?:https?:)?\/\//i.test(href);

/** Image sources follow the same rule without mailto and tel. */
const safeSrc = (value: unknown): string | undefined => {
	const src = safeHref(value);
	return src && !/^(?:mailto|tel):/i.test(src) ? src : undefined;
};

/**
 * The element level for a heading: its rank (1 counts as 2) plus the offset, kept between h2
 * and h6 so content never emits a second h1 or an invalid h7.
 */
export function headingElementLevel(level: unknown, offset: number): 2 | 3 | 4 | 5 | 6 {
	const rank = typeof level === 'number' && Number.isFinite(level) ? Math.trunc(level) : 2;
	const shift = Number.isFinite(offset) ? Math.trunc(offset) : 0;
	return Math.min(6, Math.max(2, Math.max(2, rank) + shift)) as 2 | 3 | 4 | 5 | 6;
}

/** The visual rank of a heading, from its rank in the content and not from the offset. */
export function headingRank(level: unknown): 2 | 3 | 4 {
	const rank = typeof level === 'number' && Number.isFinite(level) ? Math.trunc(level) : 2;
	return Math.min(4, Math.max(2, rank)) as 2 | 3 | 4;
}

/**
 * Check a content tree and return only what can be rendered. Unknown or malformed nodes are
 * collected instead of rendered; empty paragraphs and lists are dropped quietly. Nothing here
 * has side effects, so the result can be derived and reported separately.
 */
export function prepare(content: unknown): PreparedContent {
	const unknown: unknown[] = [];
	const missingAlt: RichImageNode[] = [];

	/** Inline content. Inside a link, a nested link keeps its text and loses its anchor. */
	function inline(nodes: unknown, inLink = false): RichInline[] {
		const out: RichInline[] = [];
		for (const node of list(nodes)) {
			if (isObject(node) && node.type === 'text' && typeof node.text === 'string') {
				if (!node.text) continue;
				const marks = list(node.marks).filter((m): m is RichMark => MARKS.has(m as RichMark));
				out.push({ ...node, type: 'text', text: node.text, marks: [...new Set(marks)] });
			} else if (
				isObject(node) &&
				node.type === 'link' &&
				typeof node.href === 'string' &&
				(node.children === undefined || Array.isArray(node.children))
			) {
				const children = inline(node.children, true);
				if (inLink) {
					out.push(...children);
					continue;
				}
				out.push({
					...node,
					type: 'link',
					href: node.href,
					newTab: node.newTab === true,
					children: children.length ? children : [{ type: 'text', text: node.href }]
				});
			} else {
				unknown.push(node);
			}
		}
		return out;
	}

	/** A container whose children field is missing or not an array is malformed: report it. */
	function malformed(node: Loose): boolean {
		if (Array.isArray(node.children)) return false;
		unknown.push(node);
		return true;
	}

	function paragraph(node: Loose): RichParagraphNode | undefined {
		if (malformed(node)) return undefined;
		const children = inline(node.children);
		return children.length ? { ...node, type: 'paragraph', children } : undefined;
	}

	function listNode(node: Loose): RichListNode | undefined {
		if (malformed(node)) return undefined;
		const items: RichListItemNode[] = [];
		for (const item of list(node.children)) {
			if (!isObject(item) || item.type !== 'item' || !Array.isArray(item.children)) {
				unknown.push(item);
				continue;
			}
			const children: RichListItemNode['children'] = [];
			for (const child of list(item.children)) {
				if (isObject(child) && child.type === 'list') {
					const nested = listNode(child);
					if (nested) children.push(nested);
				} else {
					children.push(...inline([child]));
				}
			}
			if (children.length) items.push({ ...item, type: 'item', children });
		}
		if (!items.length) return undefined;
		const start = typeof node.start === 'number' && Number.isInteger(node.start) ? node.start : 1;
		return { ...node, type: 'list', ordered: node.ordered === true, start, children: items };
	}

	function block(node: unknown): RichBlock | undefined {
		if (!isObject(node)) {
			unknown.push(node);
			return undefined;
		}
		switch (node.type) {
			case 'paragraph':
				return paragraph(node);
			case 'heading': {
				if (malformed(node)) return undefined;
				const children = inline(node.children);
				return children.length ? ({ ...node, type: 'heading', children } as RichBlock) : undefined;
			}
			case 'list':
				return listNode(node);
			case 'quote': {
				if (malformed(node)) return undefined;
				// A run of inline nodes reads as one paragraph, so a quote can be a single string.
				const children: RichParagraphNode[] = [];
				let run: unknown[] = [];
				const flush = () => {
					if (run.length) {
						const p = paragraph({ type: 'paragraph', children: run });
						if (p) children.push(p);
					}
					run = [];
				};
				for (const child of list(node.children)) {
					if (isObject(child) && child.type === 'paragraph') {
						flush();
						const p = paragraph(child);
						if (p) children.push(p);
					} else run.push(child);
				}
				flush();
				if (!children.length) return undefined;
				return {
					...node,
					type: 'quote',
					children,
					attribution: typeof node.attribution === 'string' ? node.attribution : undefined,
					cite: safeHref(node.cite)
				};
			}
			case 'image': {
				const src = safeSrc(node.src);
				if (!src) break;
				const size = (v: unknown) =>
					typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.round(v) : undefined;
				const image: RichImageNode = {
					...node,
					type: 'image',
					src,
					alt: typeof node.alt === 'string' ? node.alt : undefined,
					decorative: node.decorative === true,
					caption: typeof node.caption === 'string' && node.caption ? node.caption : undefined,
					width: size(node.width),
					height: size(node.height)
				};
				if (!image.decorative && !image.alt?.trim()) missingAlt.push(image);
				return image;
			}
			case 'code':
				if (typeof node.text !== 'string') break;
				return {
					...node,
					type: 'code',
					text: node.text.replace(/\n+$/, ''),
					language: typeof node.language === 'string' && node.language ? node.language : undefined,
					caption: typeof node.caption === 'string' && node.caption ? node.caption : undefined
				};
			case 'rule':
				return { ...node, type: 'rule' };
		}
		unknown.push(node);
		return undefined;
	}

	const blocks: RichBlock[] = [];
	for (const node of list(content)) {
		const checked = block(node);
		if (checked) blocks.push(checked);
	}
	return { blocks, unknown, missingAlt };
}
```

#### `types.ts`

Role: types · 3741 bytes · SHA-256 `1c5a70331d2a20b9a4bd7c7b479d5771f8c944322afbf4396933fe2bb3fcd842`

```ts
import type { Snippet } from 'svelte';

/** Inline formatting a text node can carry. Applied in this order, outermost first. */
export type RichMark = 'strong' | 'em' | 'code';

/** A run of plain text. Text is always rendered as text, never as HTML. */
export interface RichTextNode {
	type: 'text';
	text: string;
	marks?: RichMark[];
}

/**
 * A link around inline content. Only http, https, mailto and tel URLs, and relative paths and
 * fragments, become links; anything else (javascript:, data:) renders its text without one.
 */
export interface RichLinkNode {
	type: 'link';
	href: string;
	/** Link text. A link nested inside another keeps its text and loses its own anchor. */
	children: RichInline[];
	/** Open in a new tab. Adds target="_blank" and a visually hidden "(opens in new tab)". */
	newTab?: boolean;
}

export type RichInline = RichTextNode | RichLinkNode;

export interface RichParagraphNode {
	type: 'paragraph';
	children: RichInline[];
}

export interface RichHeadingNode {
	type: 'heading';
	/** Rank inside the content. 1 is treated as 2; the rendered element adds headingOffset. */
	level?: 1 | 2 | 3 | 4 | 5 | 6;
	children: RichInline[];
	/** Optional id for anchor links. Keep it unique on the page. */
	id?: string;
}

/** One list item: inline content, optionally followed by a nested list. */
export interface RichListItemNode {
	type: 'item';
	children: (RichInline | RichListNode)[];
}

export interface RichListNode {
	type: 'list';
	/** Numbered list (ol) instead of bullets (ul). */
	ordered?: boolean;
	/** First number of an ordered list. */
	start?: number;
	children: RichListItemNode[];
}

export interface RichQuoteNode {
	type: 'quote';
	/** Paragraphs, or inline content that is read as one paragraph. */
	children: (RichParagraphNode | RichInline)[];
	/** Who said it, shown under the quote. */
	attribution?: string;
	/** Source URL, set as the blockquote's cite attribute. */
	cite?: string;
}

export interface RichImageNode {
	type: 'image';
	src: string;
	/** Text alternative. Leave it out only with decorative: true. */
	alt?: string;
	/** Marks an image that adds nothing the text does not say; it renders with alt="". */
	decorative?: boolean;
	caption?: string;
	/** Intrinsic size in pixels. Supply both so the space is reserved before the image loads. */
	width?: number;
	height?: number;
}

export interface RichCodeNode {
	type: 'code';
	/** The code, rendered as text with whitespace kept. */
	text: string;
	/** Language name, for a label and for a renderer override that highlights. */
	language?: string;
	caption?: string;
}

export interface RichRuleNode {
	type: 'rule';
}

/** A top-level block. */
export type RichBlock =
	| RichParagraphNode
	| RichHeadingNode
	| RichListNode
	| RichQuoteNode
	| RichImageNode
	| RichCodeNode
	| RichRuleNode;

/** Any node the component renders. */
export type RichNode = RichBlock | RichInline | RichListItemNode;

export type RichNodeType = RichNode['type'];

/** What an override receives besides the node. */
export interface RichRenderContext {
	/**
	 * The default rendering of the node's children: the inline content of a paragraph, heading
	 * or link, the items of a list, the paragraphs of a quote. Empty for images, code and rules.
	 */
	children: Snippet;
	/** For headings: the element level after headingOffset, 2 to 6. */
	level?: 2 | 3 | 4 | 5 | 6;
	/** For links: the href after the safety check, or undefined when it was refused. */
	href?: string;
}

/** Override snippets, one per node type. Each replaces the default output for that type. */
export type RichRenderers = {
	[K in RichNodeType]?: Snippet<[Extract<RichNode, { type: K }>, RichRenderContext]>;
};

export type RichDensity = 'spacious' | 'compact';
```

## Artifacts

### Neutral (`neutral`) (default)

- Artifact digest: `sha256-5425b69915b6f550f40c95b55f98cc56ac0d274b85b41c794b54a7795043d5ad`
- Entry: `RichText.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_rich_text_display_01/1.0.0/neutral/sha256-5425b69915b6f550f40c95b55f98cc56ac0d274b85b41c794b54a7795043d5ad/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_rich_text_display_01/1.0.0/neutral/sha256-5425b69915b6f550f40c95b55f98cc56ac0d274b85b41c794b54a7795043d5ad/bundle.zip (19230 bytes, sha256 `c73341d342164e3f30feabe3d0de7a862a727f987a2ec10a59c515a4bac3cf57`)

Files:

- `RichText.svelte` (entry, 5878 bytes): https://pagesugar.com/artifacts/cmp_rich_text_display_01/1.0.0/neutral/sha256-5425b69915b6f550f40c95b55f98cc56ac0d274b85b41c794b54a7795043d5ad/source/RichText.svelte
- `parts/Blocks.svelte` (component, 928 bytes): https://pagesugar.com/artifacts/cmp_rich_text_display_01/1.0.0/neutral/sha256-5425b69915b6f550f40c95b55f98cc56ac0d274b85b41c794b54a7795043d5ad/source/parts/Blocks.svelte
- `parts/CodeBlock.svelte` (component, 1606 bytes): https://pagesugar.com/artifacts/cmp_rich_text_display_01/1.0.0/neutral/sha256-5425b69915b6f550f40c95b55f98cc56ac0d274b85b41c794b54a7795043d5ad/source/parts/CodeBlock.svelte
- `parts/Figure.svelte` (component, 1401 bytes): https://pagesugar.com/artifacts/cmp_rich_text_display_01/1.0.0/neutral/sha256-5425b69915b6f550f40c95b55f98cc56ac0d274b85b41c794b54a7795043d5ad/source/parts/Figure.svelte
- `parts/Heading.svelte` (component, 1579 bytes): https://pagesugar.com/artifacts/cmp_rich_text_display_01/1.0.0/neutral/sha256-5425b69915b6f550f40c95b55f98cc56ac0d274b85b41c794b54a7795043d5ad/source/parts/Heading.svelte
- `parts/Inlines.svelte` (component, 1507 bytes): https://pagesugar.com/artifacts/cmp_rich_text_display_01/1.0.0/neutral/sha256-5425b69915b6f550f40c95b55f98cc56ac0d274b85b41c794b54a7795043d5ad/source/parts/Inlines.svelte
- `parts/Link.svelte` (component, 1245 bytes): https://pagesugar.com/artifacts/cmp_rich_text_display_01/1.0.0/neutral/sha256-5425b69915b6f550f40c95b55f98cc56ac0d274b85b41c794b54a7795043d5ad/source/parts/Link.svelte
- `parts/List.svelte` (component, 2873 bytes): https://pagesugar.com/artifacts/cmp_rich_text_display_01/1.0.0/neutral/sha256-5425b69915b6f550f40c95b55f98cc56ac0d274b85b41c794b54a7795043d5ad/source/parts/List.svelte
- `parts/Paragraph.svelte` (component, 609 bytes): https://pagesugar.com/artifacts/cmp_rich_text_display_01/1.0.0/neutral/sha256-5425b69915b6f550f40c95b55f98cc56ac0d274b85b41c794b54a7795043d5ad/source/parts/Paragraph.svelte
- `parts/Quote.svelte` (component, 1323 bytes): https://pagesugar.com/artifacts/cmp_rich_text_display_01/1.0.0/neutral/sha256-5425b69915b6f550f40c95b55f98cc56ac0d274b85b41c794b54a7795043d5ad/source/parts/Quote.svelte
- `parts/Rule.svelte` (component, 518 bytes): https://pagesugar.com/artifacts/cmp_rich_text_display_01/1.0.0/neutral/sha256-5425b69915b6f550f40c95b55f98cc56ac0d274b85b41c794b54a7795043d5ad/source/parts/Rule.svelte
- `prepare.ts` (helper, 8698 bytes): https://pagesugar.com/artifacts/cmp_rich_text_display_01/1.0.0/neutral/sha256-5425b69915b6f550f40c95b55f98cc56ac0d274b85b41c794b54a7795043d5ad/source/prepare.ts
- `types.ts` (types, 3741 bytes): https://pagesugar.com/artifacts/cmp_rich_text_display_01/1.0.0/neutral/sha256-5425b69915b6f550f40c95b55f98cc56ac0d274b85b41c794b54a7795043d5ad/source/types.ts
