@atmo-dev/kit 0.7.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +102 -11
  2. package/dist/components/AccountMenu.svelte +102 -0
  3. package/dist/components/AccountMenu.svelte.d.ts +19 -0
  4. package/dist/components/Button.svelte +1 -1
  5. package/dist/components/ExternalEmbed.svelte +1 -1
  6. package/dist/components/LinkCard.svelte +1 -1
  7. package/dist/components/NestedComments.svelte +1 -1
  8. package/dist/components/PostContent.svelte +1 -1
  9. package/dist/components/PostVideo.svelte +1 -1
  10. package/dist/components/RichText.svelte +130 -17
  11. package/dist/components/RichText.svelte.d.ts +10 -3
  12. package/dist/components/legal/links.js +2 -9
  13. package/dist/events/EventComments.svelte +2 -1
  14. package/dist/events/EventView.svelte +10 -3
  15. package/dist/events/PostToBlueskyModal.svelte +4 -3
  16. package/dist/events/atproto-helpers.d.ts +6 -0
  17. package/dist/events/atproto-helpers.js +15 -1
  18. package/dist/events/conference.d.ts +3 -2
  19. package/dist/events/conference.js +4 -3
  20. package/dist/events/editor/save.d.ts +1 -1
  21. package/dist/events/editor/save.js +2 -2
  22. package/dist/events/event-view/EventLinksList.svelte +11 -2
  23. package/dist/events/event-view/ExternalRsvpNotice.svelte +20 -16
  24. package/dist/events/event-view/locations.js +1 -11
  25. package/dist/events/view-data.d.ts +9 -0
  26. package/dist/events-data.d.ts +1 -1
  27. package/dist/events-data.js +1 -1
  28. package/dist/index.d.ts +3 -0
  29. package/dist/index.js +2 -0
  30. package/dist/richtext/detect.d.ts +7 -0
  31. package/dist/richtext/detect.js +72 -0
  32. package/dist/richtext/document.d.ts +76 -0
  33. package/dist/richtext/document.js +95 -0
  34. package/dist/richtext/facets.d.ts +28 -0
  35. package/dist/richtext/facets.js +113 -0
  36. package/dist/richtext/markdown.d.ts +10 -0
  37. package/dist/richtext/markdown.js +236 -0
  38. package/dist/richtext/plain.d.ts +9 -0
  39. package/dist/richtext/plain.js +8 -0
  40. package/dist/richtext.d.ts +17 -0
  41. package/dist/richtext.js +12 -0
  42. package/dist/social/external-embeds.js +1 -1
  43. package/dist/social/profile.js +3 -3
  44. package/dist/social/richtext.d.ts +5 -8
  45. package/dist/social/richtext.js +3 -94
  46. package/dist/urls.d.ts +9 -0
  47. package/dist/urls.js +39 -0
  48. package/package.json +28 -16
package/README.md CHANGED
@@ -16,6 +16,67 @@ Svelte 5 and Tailwind CSS 4 components for Atmo apps.
16
16
  for a separated, filled creation action in the rail, mobile bar or drawer. The
17
17
  existing `primary` flag alone retains its subtle accent/spacing behavior.
18
18
 
19
+ ### Sidebar account menu
20
+
21
+ Use `AccountMenu` in a signed-in sidebar footer. Its avatar button opens just
22
+ **Go to profile** and **Sign out**; there is no account switching. `expanded`
23
+ adds the name and optional handle for wider sidebars and mobile drawers.
24
+
25
+ ```svelte
26
+ <IconRail label="Navigation" {items}>
27
+ {#snippet footer({ expanded, close })}
28
+ {#if viewer}
29
+ <AccountMenu
30
+ name={viewer.displayName || viewer.handle}
31
+ handle={viewer.handle}
32
+ avatar={viewer.avatar}
33
+ profileHref={`/p/${encodeURIComponent(viewer.did)}`}
34
+ {expanded}
35
+ onprofile={close}
36
+ onsignout={() => {
37
+ close();
38
+ signOut();
39
+ }}
40
+ />
41
+ {/if}
42
+ {/snippet}
43
+ </IconRail>
44
+ ```
45
+
46
+ Both components are exported from `@atmo-dev/kit`. Use `profileHref` for a native
47
+ link, or omit it and supply `onprofile` for app-owned navigation. Apps own the
48
+ profile route, session, sign-out request, and signed-out UI. The menu handles
49
+ keyboard navigation, dismissal, focus restoration and portaled theme inheritance.
50
+
51
+ ## Safe link destinations
52
+
53
+ Use the dependency-free `@atmo-dev/kit/urls` entry in server loaders and components:
54
+
55
+ ```ts
56
+ import { safeHttpUrl, safeLinkHref } from '@atmo-dev/kit/urls';
57
+
58
+ const website = safeHttpUrl(record.url);
59
+ const navigation = safeLinkHref(card.href);
60
+ ```
61
+
62
+ - `safeHttpUrl(unknown)` accepts absolute HTTP(S) URLs without usernames/passwords
63
+ and returns their normalized URL. Use it for untrusted external record URLs.
64
+ - `safeLinkHref(unknown)` additionally allows root-relative app paths, fragments,
65
+ `mailto:` and `tel:` actions. Protocol-relative paths and backslash/control-character
66
+ local/contact links are rejected. It does not resolve `./` or `../` relative paths.
67
+ - Invalid input returns `undefined`: pass the result to `href`, or omit the link.
68
+ Never fall back to the original untrusted value. `target="_blank"` is not URL validation.
69
+
70
+ The helpers perform no IO and import no Svelte, DOM or Markdown parser. They are also
71
+ exported from the main Kit entry. Existing `safeWebUrl` exports from richtext remain
72
+ aliases of `safeHttpUrl`. Rich-text mention callbacks and legal links retain their
73
+ stricter destination policies. Generic UI primitives such as Button still accept
74
+ app-owned routes; validate untrusted values before passing them in.
75
+
76
+ These are **navigation** policies, not HTML sanitizers or SSRF protection. Image,
77
+ iframe and media sources may require tighter policies. Server-side fetching needs
78
+ separate host/IP, DNS, redirect and response-content checks.
79
+
19
80
  ## AT record metadata (AT Tags)
20
81
 
21
82
  Use `AtTags` to let other clients discover which AT Protocol records and identities
@@ -121,7 +182,19 @@ The event UI formerly in `@atmo-dev/events-ui` lives in this package:
121
182
  - `@atmo-dev/kit/events-data`: structural event types, adapter contracts, conference/date helpers, URL helpers, theme values, linked-record types and thumbnail selection. This entry is safe in plain Node and server routes: no Svelte, CSS, maps or players are loaded. Browser helpers such as `compressImage` only access browser APIs when called.
122
183
  - `@atmo-dev/kit/events/editor`: `buildEventRecord` and `buildThumbnailMedia` for hosts that need explicit record construction. This separate entry includes browser-oriented thumbnail code; it is not part of the pure data API.
123
184
 
124
- Event descriptions are plain text with AT Protocol facets, rendered by the shared `RichText` component using Svelte text and links—not HTML. Markdown and raw HTML are displayed literally; existing records are not rewritten. Line breaks are preserved. Facet mentions link to local profiles, hashtags use the viewer's preferred social client, and link facets accept only HTTP(S) URLs without credentials. Malformed, overlapping or invalid UTF-8 facet ranges are ignored without dropping text. Descriptions without facets detect plain-text links, mentions and hashtags, with no network requests or Markdown interpretation. There is no sanitizer or asynchronous browser-only fallback.
185
+ Event descriptions are Markdown with AT Protocol facets. Parse them on the server and pass the result as `data.description`, so the Markdown parser stays out of the client bundle:
186
+
187
+ ```ts
188
+ import { parseRichText } from '@atmo-dev/kit/richtext';
189
+
190
+ const description = event.description
191
+ ? parseRichText(event.description, { facets: event.facets, markdown: true, detectLinks: true })
192
+ : null;
193
+ ```
194
+
195
+ Without `data.description`, `EventView` shows the description as plain text with facet links. Description headings render one level below the event title, and the Bluesky share card uses `richTextToPlainText(description)`. Conference schedules show talk descriptions as short previews: pass `conferenceTalkDescriptions`, plain text keyed by talk URI, so Markdown syntax does not appear there. See [Rich text](#rich-text) for what is supported.
196
+
197
+ Event components read Bluesky's public AppView and CDN by default. Apps using other services call `configureAtproto({ appview, cdn })` from `events-data` once, before rendering, on both the server and the client (for example at the top of the root layout).
125
198
 
126
199
  Event cards and covers prefer native `media` images, then an HTTPS `poster.url`, then a URI named `Event Image` (laugh.town compatibility). `getEventExternalImage(event)` from `events-data` returns `{ url, alt, aspectRatio? }` or `null`, validating URLs and optional poster dimensions. It performs no IO; apps must apply their own allowlist/redirect policy before fetching an external image server-side.
127
200
 
@@ -194,25 +267,43 @@ For now, `createGrainXrpcClient(fetchImpl?)` provides the Grain.social implement
194
267
 
195
268
  Use `blueskyPostToPostData(postView)` to convert public AppView posts, including `repostCount`. `PostContent` supports images, galleries, external link cards, HLS video, quoted posts (including their embeds), record-with-media combinations, feeds, lists, starter packs, and moderation-service cards. Missing, blocked, and detached quotes have readable placeholders; unknown embed variants show an unavailable fallback. Quote nesting is capped at three levels. Non-post record cards use Bluesky routes, since other clients may not implement them. Apps still own fetching and moderation.
196
269
 
197
- ### Plain text and facets
270
+ ### Rich text
198
271
 
199
- `RichText` is the text renderer shared by posts and event descriptions:
272
+ `RichText` renders text shared by posts, event descriptions and other user content. Plain text can be passed directly:
200
273
 
201
274
  ```svelte
202
- <script lang="ts">
203
- import { RichText } from '@atmo-dev/kit';
204
- </script>
205
-
206
275
  <RichText
207
- text={description}
208
- {facets}
276
+ text={post.text}
277
+ facets={post.facets}
209
278
  {socialClient}
210
279
  mentionUrl={(actor) => `/p/${encodeURIComponent(actor)}`}
211
- detectLinks
212
280
  />
213
281
  ```
214
282
 
215
- Only `text` is required. By default, only explicit facets become links. `detectLinks` enables plain-text URL, mention and hashtag detection when no facets are supplied. `socialClient` selects mention/hashtag destinations; `mentionUrl` overrides mention routing with an app-owned HTTP(S) URL or root-relative path. Local links stay in the same tab; external links use `noopener noreferrer nofollow ugc`. The component renders a paragraph, preserves whitespace, wraps long text, and accepts `class`. Text is never parsed as HTML or Markdown. Post text retains Bluesky facet destinations by default.
283
+ For Markdown, parse with `@atmo-dev/kit/richtext` (no Svelte or DOM; works in Workers) and pass `content`:
284
+
285
+ ```ts
286
+ // +page.server.ts
287
+ import { parseRichText } from '@atmo-dev/kit/richtext';
288
+
289
+ const content = parseRichText(text, { facets, markdown: true, detectLinks: true });
290
+ ```
291
+
292
+ ```svelte
293
+ <RichText {content} {socialClient} headingOffset={1} />
294
+ ```
295
+
296
+ Each feature can be turned on separately:
297
+
298
+ - `markdown`: CommonMark plus `~~strikethrough~~`. Single newlines are line breaks. Raw HTML is shown as written. Images are never loaded; they become links labeled by their alt text. Tables and other extensions stay literal text. Text over 10,000 characters, with more than 1,000 `*`, `_`, `~` or `[` characters, or with more than 8 nested quote or list markers on a line, is rendered as plain text, because the parser slows down quadratically on some inputs. Without `markdown`, text is plain and whitespace is preserved.
299
+ - `facets`: record facets are validated at runtime. Malformed, overlapping or mid-character ranges are ignored without dropping text. In Markdown, facets apply to formatted text such as `**@alice**`; facets in code, inside Markdown links, across Markdown syntax or on text changed by escapes or entities are ignored.
300
+ - `detectLinks`: detects URLs, mentions and hashtags when there are no facets, outside code and existing links. Detection uses the mention, hashtag and URL rules of `@atcute/bluesky-richtext-parser` in one linear pass. Its `tokenize` also parses emphasis and backtracks badly on runs of `*` (a hundred take minutes), so do not run it on untrusted text; `detectTokens` from `@atmo-dev/kit/richtext` returns compatible tokens.
301
+
302
+ Links accept only HTTP(S) URLs without credentials. `socialClient` selects mention and hashtag destinations; `mentionUrl` overrides mention routing with an HTTP(S) URL or root-relative path. Local links stay in the same tab; external links use `noopener noreferrer nofollow ugc`. Post text keeps Bluesky destinations by default.
303
+
304
+ The parsed `RichTextDocument` is plain JSON with a `version`, independent of the viewer, so it can be sent in page data and cached publicly. `RichText` still treats it as untrusted: it renders only known elements, checks every link again and shows unknown nodes as text. Prefer parsing per request (a few milliseconds) over storing documents. `richTextToPlainText(content)` returns text without Markdown syntax for meta descriptions and previews.
305
+
306
+ To parse in the browser, for example in an editor preview, load the parser on demand with `await import('@atmo-dev/kit/richtext')` so it is not part of the initial bundle.
216
307
 
217
308
  ### Special link embeds
218
309
 
@@ -0,0 +1,102 @@
1
+ <script lang="ts">
2
+ import { DropdownMenu } from 'bits-ui';
3
+ import { UserRound, LogOut } from '@sketchyicons/svelte';
4
+ import Avatar from './Avatar.svelte';
5
+ import Button from './Button.svelte';
6
+ import Icon from './Icon.svelte';
7
+ import { cn } from '../utils.js';
8
+ import { readTheme, observeTheme } from '../theme.js';
9
+
10
+ let {
11
+ name,
12
+ handle,
13
+ avatar,
14
+ profileHref,
15
+ onprofile,
16
+ onsignout,
17
+ expanded = false,
18
+ open = $bindable(false),
19
+ label = 'Account menu',
20
+ class: className
21
+ }: {
22
+ name: string;
23
+ handle?: string;
24
+ avatar?: string;
25
+ onsignout: () => void;
26
+ expanded?: boolean;
27
+ open?: boolean;
28
+ label?: string;
29
+ class?: string;
30
+ } & (
31
+ | { profileHref: string; onprofile?: () => void }
32
+ | { profileHref?: undefined; onprofile: () => void }
33
+ ) = $props();
34
+
35
+ let origin = $state<HTMLButtonElement | HTMLAnchorElement | null>(null);
36
+ let inherited = $state('');
37
+ $effect(() => {
38
+ if (!open || !origin) return;
39
+ const sync = () => (inherited = readTheme(origin!));
40
+ sync();
41
+ return observeTheme(origin, sync);
42
+ });
43
+ const itemClass =
44
+ 'min-h-11 w-full justify-start gap-3 px-3 text-sm text-ink outline-none data-highlighted:bg-recess data-highlighted:text-ink';
45
+ </script>
46
+
47
+ <DropdownMenu.Root bind:open>
48
+ <DropdownMenu.Trigger>
49
+ {#snippet child({ props })}
50
+ <Button
51
+ {...props}
52
+ bind:ref={origin}
53
+ variant="ghost"
54
+ aria-label={label}
55
+ data-account-menu-trigger
56
+ class={cn(
57
+ 'gap-3 p-1.5 data-[state=open]:bg-recess',
58
+ expanded ? 'w-full justify-start px-2' : 'size-11 max-sm:size-10',
59
+ className
60
+ )}
61
+ >
62
+ <Avatar {name} src={avatar} size={30} aria-hidden="true" />
63
+ {#if expanded}
64
+ <span class="min-w-0 text-left">
65
+ <span class="block truncate text-sm text-ink">{name}</span>
66
+ {#if handle}<span class="block truncate text-xs text-muted"
67
+ >@{handle.replace(/^@/, '')}</span
68
+ >{/if}
69
+ </span>
70
+ {/if}
71
+ </Button>
72
+ {/snippet}
73
+ </DropdownMenu.Trigger>
74
+ <DropdownMenu.Portal>
75
+ <DropdownMenu.Content
76
+ side="top"
77
+ align="start"
78
+ sideOffset={8}
79
+ collisionPadding={12}
80
+ loop
81
+ aria-label={label}
82
+ data-slot="account-menu"
83
+ style={inherited}
84
+ class="z-60 w-56 max-w-[calc(100vw-1.5rem)] rounded-panel border border-line bg-page p-1.5 font-sans text-ink outline-none"
85
+ >
86
+ <DropdownMenu.Item onSelect={onprofile} textValue="Go to profile">
87
+ {#snippet child({ props })}
88
+ <Button {...props} href={profileHref} variant="ghost" class={itemClass}>
89
+ <Icon img={UserRound} size={20} class="text-muted" />Go to profile
90
+ </Button>
91
+ {/snippet}
92
+ </DropdownMenu.Item>
93
+ <DropdownMenu.Item onSelect={onsignout} textValue="Sign out">
94
+ {#snippet child({ props })}
95
+ <Button {...props} variant="ghost" class={itemClass}>
96
+ <Icon img={LogOut} size={20} class="text-muted" />Sign out
97
+ </Button>
98
+ {/snippet}
99
+ </DropdownMenu.Item>
100
+ </DropdownMenu.Content>
101
+ </DropdownMenu.Portal>
102
+ </DropdownMenu.Root>
@@ -0,0 +1,19 @@
1
+ type $$ComponentProps = {
2
+ name: string;
3
+ handle?: string;
4
+ avatar?: string;
5
+ onsignout: () => void;
6
+ expanded?: boolean;
7
+ open?: boolean;
8
+ label?: string;
9
+ class?: string;
10
+ } & ({
11
+ profileHref: string;
12
+ onprofile?: () => void;
13
+ } | {
14
+ profileHref?: undefined;
15
+ onprofile: () => void;
16
+ });
17
+ declare const AccountMenu: import("svelte").Component<$$ComponentProps, {}, "open">;
18
+ type AccountMenu = ReturnType<typeof AccountMenu>;
19
+ export default AccountMenu;
@@ -61,7 +61,7 @@
61
61
  {...rest}
62
62
  bind:this={ref}
63
63
  href={disabled || loading ? undefined : href}
64
- role={disabled || loading ? 'link' : undefined}
64
+ role={disabled || loading ? 'link' : rest.role}
65
65
  class={classes}
66
66
  data-slot="button"
67
67
  data-variant={variant}
@@ -6,7 +6,7 @@
6
6
  type ExternalEmbedProvider,
7
7
  type ExternalPostEmbed
8
8
  } from '../social/external-embeds.js';
9
- import { safeWebUrl } from '../social/richtext.js';
9
+ import { safeHttpUrl as safeWebUrl } from '../urls.js';
10
10
  import { focus } from '../styles.js';
11
11
  import Icon from './Icon.svelte';
12
12
  import LinkCard from './LinkCard.svelte';
@@ -2,7 +2,7 @@
2
2
  import type { HTMLAnchorAttributes } from 'svelte/elements';
3
3
  import { cn } from '../utils.js';
4
4
  import { focus } from '../styles.js';
5
- import { safeWebUrl } from '../social/richtext.js';
5
+ import { safeHttpUrl as safeWebUrl } from '../urls.js';
6
6
  let {
7
7
  href,
8
8
  meta,
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { PostData } from '../social/posts.js';
3
3
  import type { ExternalEmbedProvider } from '../social/external-embeds.js';
4
- import { safeWebUrl } from '../social/richtext.js';
4
+ import { safeHttpUrl as safeWebUrl } from '../urls.js';
5
5
  import { socialPostUrl, socialProfileUrl } from '../social/urls.js';
6
6
  import type { PreferredSocialClient } from '../social/preferred-client.js';
7
7
  import { Heart, MessageCircle, Repeat2 } from '@sketchyicons/svelte';
@@ -5,7 +5,7 @@
5
5
  import { socialPostUrl } from '../social/urls.js';
6
6
  import { focus } from '../styles.js';
7
7
  import Icon from './Icon.svelte';
8
- import { safeWebUrl } from '../social/richtext.js';
8
+ import { safeHttpUrl as safeWebUrl } from '../urls.js';
9
9
  import RichText from './RichText.svelte';
10
10
  import type { ExternalEmbedProvider } from '../social/external-embeds.js';
11
11
  import ExternalEmbed from './ExternalEmbed.svelte';
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { safeWebUrl } from '../social/richtext.js';
2
+ import { safeHttpUrl as safeWebUrl } from '../urls.js';
3
3
  import Icon from './Icon.svelte';
4
4
  import { ArrowUpRight } from '@sketchyicons/svelte';
5
5
  let {
@@ -1,33 +1,146 @@
1
1
  <script lang="ts">
2
- import { richTextSegments, type RichTextOptions } from '../social/richtext.js';
2
+ import {
3
+ inlineHref,
4
+ inlineText,
5
+ MAX_RENDER_DEPTH,
6
+ type RichTextDocument,
7
+ type RichTextLinkOptions
8
+ } from '../richtext/document.js';
9
+ import { parsePlainText } from '../richtext/plain.js';
3
10
  import { cn } from '../utils.js';
4
11
 
5
- export type RichTextProps = RichTextOptions & {
6
- text: string;
12
+ export type RichTextProps = RichTextLinkOptions & {
13
+ /** From `parseRichText`, usually on the server. Takes precedence over `text`. */
14
+ content?: RichTextDocument | null;
15
+ /** Plain text, parsed here; Markdown requires `content`. */
16
+ text?: string;
7
17
  /** Public record data is validated at runtime, including malformed facets. */
8
18
  facets?: unknown;
19
+ /** Detect plain-text links, mentions and hashtags when there are no facets. */
20
+ detectLinks?: boolean;
21
+ /** Added to Markdown heading levels, e.g. 1 renders `#` as h2 below a page's h1. */
22
+ headingOffset?: number;
9
23
  class?: string;
10
24
  };
11
25
 
12
26
  let {
13
- text,
27
+ content,
28
+ text = '',
14
29
  facets,
30
+ detectLinks = false,
15
31
  socialClient,
16
32
  mentionUrl,
17
- detectLinks = false,
33
+ headingOffset = 0,
18
34
  class: className
19
35
  }: RichTextProps = $props();
20
- let segments = $derived(
21
- richTextSegments(text, facets, { socialClient, mentionUrl, detectLinks })
22
- );
36
+
37
+ let doc = $derived(content ?? parsePlainText(text, { facets, detectLinks }));
38
+
39
+ type Node = Record<string, unknown>;
40
+
41
+ function isNode(value: unknown): value is Node {
42
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
43
+ }
44
+ function nodes(value: unknown): Node[] {
45
+ return Array.isArray(value) ? value.filter(isNode) : [];
46
+ }
47
+ function items(value: unknown): unknown[] {
48
+ return Array.isArray(value) ? value.filter(Array.isArray) : [];
49
+ }
50
+ function string(value: unknown): string {
51
+ return typeof value === 'string' ? value : '';
52
+ }
53
+
54
+ function href(node: Node): string | undefined {
55
+ return inlineHref(node, { socialClient, mentionUrl });
56
+ }
57
+
58
+ function heading(depth: unknown): string {
59
+ const level = (Number.isInteger(depth) ? (depth as number) : 6) + Math.trunc(headingOffset);
60
+ return `h${Math.min(6, Math.max(1, level))}`;
61
+ }
62
+
63
+ const linkClass =
64
+ 'text-action underline decoration-action/40 underline-offset-2 hover:decoration-action';
65
+ const codeClass = 'rounded bg-recess px-1 py-0.5 font-mono text-[0.875em]';
23
66
  </script>
24
67
 
25
- <p class={cn('leading-relaxed wrap-anywhere whitespace-pre-wrap text-ink', className)}>
26
- {#each segments as segment, index (index)}{#if segment.href}<a
27
- href={segment.href}
28
- target={segment.href.startsWith('/') ? undefined : '_blank'}
29
- rel={segment.href.startsWith('/') ? undefined : 'noopener noreferrer nofollow ugc'}
30
- class="text-action underline decoration-action/40 underline-offset-2 hover:decoration-action"
31
- >{segment.text}</a
32
- >{:else}{segment.text}{/if}{/each}
33
- </p>
68
+ {#snippet inlines(children: unknown, inLink: boolean, depth: number)}
69
+ {#each nodes(children) as node, index (index)}
70
+ {#if depth > MAX_RENDER_DEPTH}{inlineText(node, depth)}
71
+ {:else if node.type === 'text'}{string(node.value)}
72
+ {:else if node.type === 'break'}<br />
73
+ {:else if node.type === 'strong'}<strong class="font-semibold"
74
+ >{@render inlines(node.children, inLink, depth + 1)}</strong
75
+ >
76
+ {:else if node.type === 'emphasis'}<em>{@render inlines(node.children, inLink, depth + 1)}</em>
77
+ {:else if node.type === 'delete'}<del>{@render inlines(node.children, inLink, depth + 1)}</del>
78
+ {:else if node.type === 'code'}<code class={codeClass}>{string(node.value)}</code>
79
+ {:else if node.type === 'link' || node.type === 'mention' || node.type === 'tag'}
80
+ {@const url = inLink ? undefined : href(node)}
81
+ {#if url}<a
82
+ href={url}
83
+ target={url.startsWith('/') ? undefined : '_blank'}
84
+ rel={url.startsWith('/') ? undefined : 'noopener noreferrer nofollow ugc'}
85
+ class={linkClass}
86
+ >{#if node.type === 'link'}{@render inlines(
87
+ node.children,
88
+ true,
89
+ depth + 1
90
+ )}{:else}{string(node.text)}{/if}</a
91
+ >
92
+ {:else if node.type === 'link'}{@render inlines(node.children, inLink, depth + 1)}
93
+ {:else}{string(node.text)}{/if}
94
+ {:else}{inlineText(node, depth)}{/if}
95
+ {/each}
96
+ {/snippet}
97
+
98
+ {#snippet blocks(children: unknown, depth: number)}
99
+ {#each nodes(children) as node, index (index)}
100
+ {#if depth > MAX_RENDER_DEPTH}<p>{inlineText(node, depth)}</p>
101
+ {:else if node.type === 'paragraph'}<p>{@render inlines(node.children, false, depth + 1)}</p>
102
+ {:else if node.type === 'heading'}<svelte:element
103
+ this={heading(node.depth)}
104
+ class="{node.depth === 1
105
+ ? 'text-xl'
106
+ : node.depth === 2
107
+ ? 'text-lg'
108
+ : 'text-base'} leading-snug font-semibold"
109
+ >{@render inlines(node.children, false, depth + 1)}</svelte:element
110
+ >
111
+ {:else if node.type === 'blockquote'}<blockquote class="space-y-3 border-l-2 border-line pl-4">
112
+ {@render blocks(node.children, depth + 1)}
113
+ </blockquote>
114
+ {:else if node.type === 'list'}<svelte:element
115
+ this={node.ordered === true ? 'ol' : 'ul'}
116
+ start={node.ordered === true && Number.isInteger(node.start)
117
+ ? (node.start as number)
118
+ : undefined}
119
+ class={cn('space-y-1 pl-6', node.ordered === true ? 'list-decimal' : 'list-disc')}
120
+ >
121
+ {#each items(node.items) as item, itemIndex (itemIndex)}
122
+ <li class="space-y-3">{@render blocks(item, depth + 1)}</li>
123
+ {/each}
124
+ </svelte:element>
125
+ {:else if node.type === 'codeBlock'}<pre
126
+ class="overflow-x-auto rounded-md bg-recess p-3 font-mono text-[0.875em]"><code
127
+ >{string(node.value)}</code
128
+ ></pre>
129
+ {:else if node.type === 'thematicBreak'}<hr class="border-line" />
130
+ {:else}<p>{inlineText(node, depth)}</p>{/if}
131
+ {/each}
132
+ {/snippet}
133
+
134
+ {#if doc.markdown}
135
+ <div class={cn('space-y-3 leading-relaxed wrap-anywhere text-ink', className)}>
136
+ {@render blocks(doc.children, 0)}
137
+ </div>
138
+ {:else}
139
+ <p class={cn('leading-relaxed wrap-anywhere whitespace-pre-wrap text-ink', className)}>
140
+ {#each nodes(doc.children) as block, index (index)}{@render inlines(
141
+ block.children,
142
+ false,
143
+ 1
144
+ )}{/each}
145
+ </p>
146
+ {/if}
@@ -1,8 +1,15 @@
1
- import { type RichTextOptions } from '../social/richtext.js';
2
- export type RichTextProps = RichTextOptions & {
3
- text: string;
1
+ import { type RichTextDocument, type RichTextLinkOptions } from '../richtext/document.js';
2
+ export type RichTextProps = RichTextLinkOptions & {
3
+ /** From `parseRichText`, usually on the server. Takes precedence over `text`. */
4
+ content?: RichTextDocument | null;
5
+ /** Plain text, parsed here; Markdown requires `content`. */
6
+ text?: string;
4
7
  /** Public record data is validated at runtime, including malformed facets. */
5
8
  facets?: unknown;
9
+ /** Detect plain-text links, mentions and hashtags when there are no facets. */
10
+ detectLinks?: boolean;
11
+ /** Added to Markdown heading levels, e.g. 1 renders `#` as h2 below a page's h1. */
12
+ headingOffset?: number;
6
13
  class?: string;
7
14
  };
8
15
  declare const RichText: import("svelte").Component<RichTextProps, {}, "">;
@@ -1,3 +1,4 @@
1
+ import { safeHttpUrl } from '../../urls.js';
1
2
  /** Same-origin absolute paths and web URLs only. Never executable, credentialed,
2
3
  * protocol-relative, or backslash-normalized URLs. Mail is allowed explicitly. */
3
4
  export function safeLegalHref(value, allowMail = false) {
@@ -9,15 +10,7 @@ export function safeLegalHref(value, allowMail = false) {
9
10
  return value;
10
11
  if (allowMail && /^mailto:[^@<>?&#%]+@[^@<>?&#%]+\.[^@<>?&#%]+$/.test(value))
11
12
  return value;
12
- try {
13
- const url = new URL(value);
14
- if (['http:', 'https:'].includes(url.protocol) && !url.username && !url.password)
15
- return url.href;
16
- }
17
- catch {
18
- /* Invalid URLs remain non-clickable. */
19
- }
20
- return undefined;
13
+ return safeHttpUrl(value);
21
14
  }
22
15
  const labels = {
23
16
  terms: 'Terms',
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { NestedComments, blueskyPostToPostData, type PostData } from '../index.js';
3
+ import { appviewUrl } from './atproto-helpers.js';
3
4
 
4
5
  import { socialClientInfo, socialPostUrl, type PreferredSocialClient } from '../social.js';
5
6
 
@@ -34,7 +35,7 @@
34
35
  errorMessage = null;
35
36
  try {
36
37
  const res = await fetch(
37
- `https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri=${encodeURIComponent(uri)}&depth=6`,
38
+ `${appviewUrl()}/xrpc/app.bsky.feed.getPostThread?uri=${encodeURIComponent(uri)}&depth=6`,
38
39
  { signal }
39
40
  );
40
41
  if (!res.ok) throw new Error(`Failed to load thread (${res.status})`);
@@ -32,6 +32,7 @@
32
32
  import AddToCalendarButton from './event-view/AddToCalendarButton.svelte';
33
33
  import ExternalRsvpNotice from './event-view/ExternalRsvpNotice.svelte';
34
34
  import RichText from '../components/RichText.svelte';
35
+ import { richTextToPlainText } from '../richtext/document.js';
35
36
  import { getLocalProfileUrl } from './profile-url.js';
36
37
  import { getLocations, resolveGeoLocation, type GeoLocation } from './event-view/locations.js';
37
38
 
@@ -109,7 +110,7 @@
109
110
  let canSetEventComments = $state(false);
110
111
  let isHost = $derived(!!viewer.did && viewer.did === did);
111
112
  let hasComments = $derived(!!eventData.bskyPostRef?.showComments && !!eventData.bskyPostRef?.uri);
112
- let hasDescription = $derived(!!eventData.description);
113
+ let hasDescription = $derived(!!eventData.description?.trim());
113
114
  let aboutCommentsTab = $state<string>('about');
114
115
  let photoLinks = $derived(eventData.uris?.filter((link) => parseGrainLink(link.uri)) ?? []);
115
116
  let contentTabs = $derived([
@@ -203,7 +204,9 @@
203
204
  let eventActor = $derived(hostProfile?.handle || did);
204
205
  let conferenceRooms = $derived(getConferenceRooms(eventData));
205
206
  let scheduleEvents = $derived(
206
- isConference ? toScheduleEvents(data.conferenceTalks ?? [], eventUri) : []
207
+ isConference
208
+ ? toScheduleEvents(data.conferenceTalks ?? [], eventUri, data.conferenceTalkDescriptions)
209
+ : []
207
210
  );
208
211
 
209
212
  // Imported events can opt out of atmo's own RSVPs (rsvpMode === 'external_only').
@@ -390,11 +393,13 @@
390
393
  {/if}
391
394
  {#if activeTab === 'about'}
392
395
  <RichText
396
+ content={data.description}
393
397
  text={eventData.description ?? ''}
394
398
  facets={eventData.facets}
395
399
  mentionUrl={getLocalProfileUrl}
396
400
  socialClient={viewer.preferredSocialClient}
397
401
  detectLinks
402
+ headingOffset={1}
398
403
  class="text-base-700 dark:text-base-300"
399
404
  />
400
405
  {:else if activeTab === 'comments' && eventData.bskyPostRef?.uri}
@@ -482,7 +487,9 @@
482
487
  {canSetEventComments}
483
488
  eventDid={did}
484
489
  eventRkey={rkey}
485
- eventDescription={eventData.description}
490
+ eventDescription={data.description
491
+ ? richTextToPlainText(data.description)
492
+ : eventData.description}
486
493
  {adapter}
487
494
  {viewer}
488
495
  />
@@ -12,8 +12,9 @@
12
12
  graphemeLength,
13
13
  type Editor
14
14
  } from '../index.js';
15
- import { tokenize } from '@atcute/bluesky-richtext-parser';
15
+ import { detectTokens } from '../richtext/detect.js';
16
16
  import { tokensToFacets } from './editor/save.js';
17
+ import { appviewUrl } from './atproto-helpers.js';
17
18
  import type { EditorAdapter, EditorViewer } from './editor/adapter.js';
18
19
 
19
20
  let {
@@ -44,7 +45,7 @@
44
45
  onPosted?: (ref: { uri: string; cid: string; showComments: boolean }) => void;
45
46
  } = $props();
46
47
 
47
- const searchActors = createActorSearch();
48
+ const searchActors = createActorSearch({ host: appviewUrl() });
48
49
  let text = $state('');
49
50
  let editor = $state<Editor | null>(null);
50
51
  let prefilledForOpen = $state(false);
@@ -104,7 +105,7 @@
104
105
  return;
105
106
  }
106
107
  // Also recognize handles typed or pasted without selecting a suggestion.
107
- const detected = await tokensToFacets(tokenize(text), (handle) =>
108
+ const detected = await tokensToFacets(detectTokens(text), (handle) =>
108
109
  adapter.resolveHandle(handle)
109
110
  );
110
111
  for (const facet of detected) {
@@ -2,6 +2,12 @@
2
2
  * Pure browser-safe atproto helpers used by the UI components. Does not depend
3
3
  * on any session/client state — those flow through the EditorAdapter.
4
4
  */
5
+ /** Configure shared event components before rendering on the server and in the browser. */
6
+ export declare function configureAtproto(options: {
7
+ appview?: string;
8
+ cdn?: string;
9
+ }): void;
10
+ export declare function appviewUrl(): string;
5
11
  export declare function getCDNImageBlobUrl({ did, blob, format }: {
6
12
  did: string;
7
13
  blob: {