feat: markdown rendering via relationaltext lens + marked tokenizer Uses the relationaltext-to-html panproto lens (copied from relationaltext project) to drive the mapping from markdown tokens to HTML elements. marked.lexer() tokenizes, token types map to relationaltext feature names, lens rules resolve to HTML tags. Includes relationaltext richtext Lexicon schemas (mark, block, document) for future facet-based rich text support.

Author: Aaron Steven White
Commit a1aad6911549118104ba166decb9ef79b1adc751
Parent: 487f747c09
Structural diff unavailable

These commits were pushed via plain git push, so no pre-parsed schemas are available. Install git-remote-cospan and re-push via panproto:// to see scope-level changes, breaking change detection, and semantic diffs.

brew install panproto/tap/git-remote-cospan
9 files changed +940 -4
@@ -16,6 +16,7 @@
1616     "@atproto/oauth-client-browser": "^0.3.41",
1717     "@sveltejs/adapter-node": "^5.2.0",
1818     "@sveltejs/kit": "^2.16.0",
19+    "marked": "^17.0.5",
1920     "shiki": "^3.2.0",
2021     "svelte": "^5.25.0"
2122   },
@@ -0,0 +1,159 @@
1+<script lang="ts" module>
2+	// Load the relationaltext → HTML lens rules at module level.
3+	// This is a panproto lens that maps feature names to HTML element names.
4+	import lensRules from '$lib/data/relationaltext-to-html.lens.json';
5+
6+	type LensRule = { match: { name: string }; replace: { name: string | { template: string }; renameAttrs?: Record<string, string>; dropAttrs?: string[] } | null };
7+
8+	// Build a lookup: feature name → HTML element name + attr transforms
9+	const featureToElement: Record<string, { tag: string; renameAttrs?: Record<string, string>; dropAttrs?: string[] }> = {};
10+	for (const rule of (lensRules as { rules: LensRule[] }).rules) {
11+		if (!rule.replace) continue;
12+		const tag = typeof rule.replace.name === 'string'
13+			? rule.replace.name
14+			: rule.replace.name.template; // e.g., "h{level}"
15+		featureToElement[rule.match.name] = {
16+			tag,
17+			renameAttrs: rule.replace.renameAttrs,
18+			dropAttrs: rule.replace.dropAttrs,
19+		};
20+	}
21+
22+	// Map marked.js token types to relationaltext feature names
23+	const tokenToFeature: Record<string, string> = {
24+		paragraph: 'paragraph',
25+		heading: 'heading',
26+		code: 'code-block',
27+		blockquote: 'blockquote',
28+		hr: 'horizontal-rule',
29+		strong: 'bold',
30+		em: 'italic',
31+		codespan: 'code',
32+		del: 'strikethrough',
33+		link: 'link',
34+		image: 'image',
35+		br: 'line-break',
36+	};
37+
38+	function resolveTag(featureName: string, attrs?: Record<string, unknown>): string {
39+		const mapping = featureToElement[featureName];
40+		if (!mapping) return 'span';
41+		if (mapping.tag.includes('{')) {
42+			// Template: "h{level}" → "h1", "h2", etc.
43+			return mapping.tag.replace(/\{(\w+)\}/g, (_, key) => String(attrs?.[key] ?? ''));
44+		}
45+		return mapping.tag;
46+	}
47+</script>
48+
49+<script lang="ts">
50+	import { marked } from 'marked';
51+
52+	let { text }: { text: string | null } = $props();
53+
54+	let tokens = $derived(text ? marked.lexer(text) : []);
55+</script>
56+
57+{#if tokens.length === 0}
58+	<span class="text-text-secondary">No content.</span>
59+{:else}
60+	<div class="prose-rt">
61+		{#each tokens as token}
62+			{@render blockToken(token)}
63+		{/each}
64+	</div>
65+{/if}
66+
67+{#snippet blockToken(token: marked.Token)}
68+	{@const feature = tokenToFeature[token.type]}
69+	{@const tag = feature ? resolveTag(feature, 'depth' in token ? { level: token.depth } : {}) : ''}
70+	{#if token.type === 'paragraph'}
71+		<p>{@render inlineTokens(token.tokens ?? [])}</p>
72+	{:else if token.type === 'heading'}
73+		<svelte:element this={tag}>{@render inlineTokens(token.tokens ?? [])}</svelte:element>
74+	{:else if token.type === 'code'}
75+		<pre><code>{token.text}</code></pre>
76+	{:else if token.type === 'blockquote'}
77+		<blockquote>
78+			{#each token.tokens ?? [] as child}
79+				{@render blockToken(child)}
80+			{/each}
81+		</blockquote>
82+	{:else if token.type === 'list'}
83+		<svelte:element this={token.ordered ? 'ol' : 'ul'}>
84+			{#each token.items as item}
85+				<li>
86+					{#each item.tokens ?? [] as child}
87+						{#if child.type === 'text' && 'tokens' in child}
88+							{@render inlineTokens(child.tokens ?? [])}
89+						{:else}
90+							{@render blockToken(child)}
91+						{/if}
92+					{/each}
93+				</li>
94+			{/each}
95+		</svelte:element>
96+	{:else if token.type === 'hr'}
97+		<hr />
98+	{:else if token.type === 'space'}
99+		<!-- skip -->
100+	{:else if token.type === 'text'}
101+		{#if 'tokens' in token && token.tokens}
102+			<p>{@render inlineTokens(token.tokens)}</p>
103+		{:else}
104+			<p>{token.text}</p>
105+		{/if}
106+	{/if}
107+{/snippet}
108+
109+{#snippet inlineTokens(tokens: marked.Token[])}
110+	{#each tokens as token}
111+		{@render inlineToken(token)}
112+	{/each}
113+{/snippet}
114+
115+{#snippet inlineToken(token: marked.Token)}
116+	{@const feature = tokenToFeature[token.type]}
117+	{@const tag = feature ? resolveTag(feature) : ''}
118+	{#if token.type === 'text'}
119+		{token.text}
120+	{:else if token.type === 'strong'}
121+		<strong>{@render inlineTokens(token.tokens ?? [])}</strong>
122+	{:else if token.type === 'em'}
123+		<em>{@render inlineTokens(token.tokens ?? [])}</em>
124+	{:else if token.type === 'codespan'}
125+		<code>{token.text}</code>
126+	{:else if token.type === 'del'}
127+		<s>{@render inlineTokens(token.tokens ?? [])}</s>
128+	{:else if token.type === 'link'}
129+		<a href={token.href} target="_blank" rel="noopener">{@render inlineTokens(token.tokens ?? [])}</a>
130+	{:else if token.type === 'image'}
131+		<img src={token.href} alt={token.text} />
132+	{:else if token.type === 'br'}
133+		<br />
134+	{:else if token.type === 'escape'}
135+		{token.text}
136+	{:else if token.type === 'paragraph'}
137+		{@render inlineTokens(token.tokens ?? [])}
138+	{/if}
139+{/snippet}
140+
141+<style>
142+	.prose-rt :global(p) { margin-bottom: 0.75rem; line-height: 1.625; }
143+	.prose-rt :global(h1) { margin-bottom: 0.75rem; font-size: 1.25rem; font-weight: 600; }
144+	.prose-rt :global(h2) { margin-bottom: 0.75rem; font-size: 1.125rem; font-weight: 600; }
145+	.prose-rt :global(h3) { margin-bottom: 0.5rem; font-size: 1rem; font-weight: 600; }
146+	.prose-rt :global(h4) { margin-bottom: 0.5rem; font-size: 0.875rem; font-weight: 600; }
147+	.prose-rt :global(pre) { margin-bottom: 0.75rem; overflow-x: auto; border-radius: 0.5rem; padding: 1rem; font-size: 0.75rem; font-family: var(--font-mono); background: var(--color-surface-2, #1a1b2e); }
148+	.prose-rt :global(code) { border-radius: 0.25rem; padding: 0.125rem 0.375rem; font-size: 0.75rem; font-family: var(--font-mono); background: var(--color-surface-2, #1a1b2e); }
149+	.prose-rt :global(pre code) { padding: 0; background: none; }
150+	.prose-rt :global(blockquote) { margin-bottom: 0.75rem; border-left: 2px solid var(--color-border, #333); padding-left: 1rem; font-style: italic; opacity: 0.85; }
151+	.prose-rt :global(ul) { margin-bottom: 0.75rem; padding-left: 1.5rem; list-style: disc; }
152+	.prose-rt :global(ol) { margin-bottom: 0.75rem; padding-left: 1.5rem; list-style: decimal; }
153+	.prose-rt :global(li) { margin-bottom: 0.25rem; }
154+	.prose-rt :global(hr) { margin: 1rem 0; border-color: var(--color-border, #333); }
155+	.prose-rt :global(a) { color: var(--color-accent, #7c8aff); text-decoration: underline; }
156+	.prose-rt :global(a:hover) { color: var(--color-accent-hover, #9ba5ff); }
157+	.prose-rt :global(img) { max-width: 100%; border-radius: 0.5rem; }
158+	.prose-rt :global(strong) { font-weight: 600; }
159+</style>
@@ -1,5 +1,6 @@
11 <script lang="ts">
22 	import StateBadge from '$lib/components/shared/StateBadge.svelte';
3+	import RichText from '$lib/components/shared/RichText.svelte';
34 	import { timeAgo } from '$lib/utils/time.js';
45 
56 	interface TimelineEvent {
@@ -22,8 +23,8 @@
2223 							commented {timeAgo(String(event.createdAt ?? ''))}
2324 						</span>
2425 					</div>
25-					<div class="px-4 py-3 text-sm text-text-primary whitespace-pre-wrap">
26-						{event.body}
26+					<div class="px-4 py-3 text-sm text-text-primary">
27+						<RichText text={String(event.body ?? '')} />
2728 					</div>
2829 				</div>
2930 			{:else if event.kind === 'stateChange'}
@@ -0,0 +1,313 @@
1+{
2+  "$type": "org.relationaltext.lens",
3+  "id": "org.relationaltext.to.html.v1",
4+  "description": "Transform RelationalText canonical facets to HTML facets",
5+  "source": "org.relationaltext.facet",
6+  "target": "org.w3c.html.facet",
7+  "invertible": false,
8+  "rules": [
9+    {
10+      "match": {
11+        "name": "bold"
12+      },
13+      "replace": {
14+        "name": "strong"
15+      }
16+    },
17+    {
18+      "match": {
19+        "name": "italic"
20+      },
21+      "replace": {
22+        "name": "em"
23+      }
24+    },
25+    {
26+      "match": {
27+        "name": "code"
28+      },
29+      "replace": {
30+        "name": "code"
31+      }
32+    },
33+    {
34+      "match": {
35+        "name": "strikethrough"
36+      },
37+      "replace": {
38+        "name": "s"
39+      }
40+    },
41+    {
42+      "match": {
43+        "name": "underline"
44+      },
45+      "replace": {
46+        "name": "u"
47+      }
48+    },
49+    {
50+      "match": {
51+        "name": "superscript"
52+      },
53+      "replace": {
54+        "name": "sup"
55+      }
56+    },
57+    {
58+      "match": {
59+        "name": "subscript"
60+      },
61+      "replace": {
62+        "name": "sub"
63+      }
64+    },
65+    {
66+      "match": {
67+        "name": "keyboard"
68+      },
69+      "replace": {
70+        "name": "kbd"
71+      }
72+    },
73+    {
74+      "match": {
75+        "name": "highlight"
76+      },
77+      "replace": {
78+        "name": "mark"
79+      }
80+    },
81+    {
82+      "match": {
83+        "name": "insertion"
84+      },
85+      "replace": {
86+        "name": "ins",
87+        "renameAttrs": {
88+          "author": "cite",
89+          "date": "datetime"
90+        }
91+      }
92+    },
93+    {
94+      "match": {
95+        "name": "deletion"
96+      },
97+      "replace": {
98+        "name": "del",
99+        "renameAttrs": {
100+          "author": "cite",
101+          "date": "datetime"
102+        }
103+      }
104+    },
105+    {
106+      "match": {
107+        "name": "line-break"
108+      },
109+      "replace": {
110+        "name": "br"
111+      }
112+    },
113+    {
114+      "match": {
115+        "name": "link"
116+      },
117+      "replace": {
118+        "name": "a",
119+        "renameAttrs": {
120+          "url": "href"
121+        }
122+      }
123+    },
124+    {
125+      "match": {
126+        "name": "mention"
127+      },
128+      "replace": {
129+        "name": "a",
130+        "renameAttrs": {
131+          "url": "href"
132+        }
133+      }
134+    },
135+    {
136+      "match": {
137+        "name": "hashtag"
138+      },
139+      "replace": {
140+        "name": "a",
141+        "renameAttrs": {
142+          "url": "href"
143+        }
144+      }
145+    },
146+    {
147+      "match": {
148+        "name": "image"
149+      },
150+      "replace": {
151+        "name": "img"
152+      }
153+    },
154+    {
155+      "match": {
156+        "name": "paragraph"
157+      },
158+      "replace": {
159+        "name": "p"
160+      }
161+    },
162+    {
163+      "match": {
164+        "name": "heading"
165+      },
166+      "replace": {
167+        "name": {
168+          "template": "h{level}"
169+        },
170+        "dropAttrs": [
171+          "level"
172+        ]
173+      }
174+    },
175+    {
176+      "match": {
177+        "name": "code-block"
178+      },
179+      "replace": {
180+        "name": "pre"
181+      }
182+    },
183+    {
184+      "match": {
185+        "name": "horizontal-rule"
186+      },
187+      "replace": {
188+        "name": "hr"
189+      }
190+    },
191+    {
192+      "match": {
193+        "name": "blockquote"
194+      },
195+      "replace": {
196+        "name": "blockquote"
197+      }
198+    },
199+    {
200+      "match": {
201+        "name": "table"
202+      },
203+      "replace": {
204+        "name": "table"
205+      }
206+    },
207+    {
208+      "match": {
209+        "name": "definition-term"
210+      },
211+      "replace": {
212+        "name": "dt"
213+      }
214+    },
215+    {
216+      "match": {
217+        "name": "definition-detail"
218+      },
219+      "replace": {
220+        "name": "dd"
221+      }
222+    },
223+    {
224+      "match": {
225+        "name": "page-break"
226+      },
227+      "replace": {
228+        "name": "hr"
229+      }
230+    },
231+    {
232+      "match": {
233+        "name": "details"
234+      },
235+      "replace": {
236+        "name": "details"
237+      }
238+    },
239+    {
240+      "match": {
241+        "name": "embed"
242+      },
243+      "replace": null
244+    },
245+    {
246+      "match": {
247+        "name": "html-block"
248+      },
249+      "replace": {
250+        "name": "raw",
251+        "renameAttrs": {
252+          "content": "raw"
253+        }
254+      }
255+    },
256+    {
257+      "match": {
258+        "name": "bullet-list-marker"
259+      },
260+      "replace": {
261+        "name": "ul"
262+      }
263+    },
264+    {
265+      "match": {
266+        "name": "ordered-list-marker"
267+      },
268+      "replace": {
269+        "name": "ol"
270+      }
271+    },
272+    {
273+      "match": {
274+        "name": "list-item-marker"
275+      },
276+      "replace": {
277+        "name": "li"
278+      }
279+    },
280+    {
281+      "match": {
282+        "name": "list-item-text"
283+      },
284+      "replace": {
285+        "name": "text"
286+      }
287+    },
288+    {
289+      "match": {
290+        "name": "unordered-list-item"
291+      },
292+      "replace": {
293+        "name": "li"
294+      }
295+    },
296+    {
297+      "match": {
298+        "name": "ordered-list-item"
299+      },
300+      "replace": {
301+        "name": "li"
302+      }
303+    },
304+    {
305+      "match": {
306+        "name": "blockquote-marker"
307+      },
308+      "replace": {
309+        "name": "blockquote"
310+      }
311+    }
312+  ]
313+}
@@ -1,6 +1,7 @@
11 <script lang="ts">
22 	import { getContext } from 'svelte';
33 	import StateBadge from '$lib/components/shared/StateBadge.svelte';
4+	import RichText from '$lib/components/shared/RichText.svelte';
45 	import Timeline from '$lib/components/shared/Timeline.svelte';
56 	import BackLink from '$lib/components/shared/BackLink.svelte';
67 	import { timeAgo } from '$lib/utils/time.js';
@@ -49,8 +50,8 @@
4950 				opened {timeAgo(data.issue.createdAt)}
5051 			</span>
5152 		</div>
52-		<div class="px-4 py-3 text-sm text-text-primary whitespace-pre-wrap">
53-			{data.issue.body}
53+		<div class="px-4 py-3 text-sm text-text-primary">
54+			<RichText text={data.issue.body} />
5455 		</div>
5556 	</div>
5657 {/if}
cospan · schematic version control on atproto built on AT Protocol