@atmo-dev/kit 0.7.1 → 0.8.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.
package/README.md CHANGED
@@ -121,7 +121,19 @@ The event UI formerly in `@atmo-dev/events-ui` lives in this package:
121
121
  - `@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
122
  - `@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
123
 
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.
124
+ 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:
125
+
126
+ ```ts
127
+ import { parseRichText } from '@atmo-dev/kit/richtext';
128
+
129
+ const description = event.description
130
+ ? parseRichText(event.description, { facets: event.facets, markdown: true, detectLinks: true })
131
+ : null;
132
+ ```
133
+
134
+ 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.
135
+
136
+ 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
137
 
126
138
  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
139
 
@@ -194,25 +206,43 @@ For now, `createGrainXrpcClient(fetchImpl?)` provides the Grain.social implement
194
206
 
195
207
  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
208
 
197
- ### Plain text and facets
209
+ ### Rich text
198
210
 
199
- `RichText` is the text renderer shared by posts and event descriptions:
211
+ `RichText` renders text shared by posts, event descriptions and other user content. Plain text can be passed directly:
200
212
 
201
213
  ```svelte
202
- <script lang="ts">
203
- import { RichText } from '@atmo-dev/kit';
204
- </script>
205
-
206
214
  <RichText
207
- text={description}
208
- {facets}
215
+ text={post.text}
216
+ facets={post.facets}
209
217
  {socialClient}
210
218
  mentionUrl={(actor) => `/p/${encodeURIComponent(actor)}`}
211
- detectLinks
212
219
  />
213
220
  ```
214
221
 
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.
222
+ For Markdown, parse with `@atmo-dev/kit/richtext` (no Svelte or DOM; works in Workers) and pass `content`:
223
+
224
+ ```ts
225
+ // +page.server.ts
226
+ import { parseRichText } from '@atmo-dev/kit/richtext';
227
+
228
+ const content = parseRichText(text, { facets, markdown: true, detectLinks: true });
229
+ ```
230
+
231
+ ```svelte
232
+ <RichText {content} {socialClient} headingOffset={1} />
233
+ ```
234
+
235
+ Each feature can be turned on separately:
236
+
237
+ - `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.
238
+ - `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.
239
+ - `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.
240
+
241
+ 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.
242
+
243
+ 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.
244
+
245
+ 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
246
 
217
247
  ### Special link embeds
218
248
 
@@ -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,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})`);
@@ -2,7 +2,7 @@
2
2
  import { getCDNImageBlobUrl } from './atproto-helpers.js';
3
3
  import { getEventThumbnail } from './event-media.js';
4
4
  import { Avatar, Button, ToggleGroup, ToggleGroupItem } from '../index.js';
5
- import { onMount } from 'svelte';
5
+ import { onMount, untrack } from 'svelte';
6
6
  import { InlineEditor, type Editor } from '../index.js';
7
7
  import DateTimePicker from './DateTimePicker.svelte';
8
8
  import TimezonePicker from './TimezonePicker.svelte';
@@ -185,22 +185,17 @@
185
185
  return s.slice(0, 16);
186
186
  }
187
187
 
188
- // Auto-set end date to 1 hour after start if empty
188
+ // Only a changed start should move the end. Watching endsAt as well would
189
+ // overwrite intermediate keystrokes (e.g. entering 6 before switching to PM).
189
190
  $effect(() => {
190
- if (startsAt && !endsAt) {
191
- endsAt = cdtToDatetimeLocal(parseDateTime(startsAt).add({ hours: 1 }).toString());
192
- }
193
- });
194
-
195
- // Auto-adjust end date if start moves past it
196
- $effect(() => {
197
- if (startsAt && endsAt) {
198
- const s = parseDateTime(startsAt);
199
- const e = parseDateTime(endsAt);
200
- if (s.compare(e) >= 0) {
191
+ const start = startsAt;
192
+ untrack(() => {
193
+ if (!start) return;
194
+ const s = parseDateTime(start);
195
+ if (!endsAt || s.compare(parseDateTime(endsAt)) >= 0) {
201
196
  endsAt = cdtToDatetimeLocal(s.add({ hours: 1 }).toString());
202
197
  }
203
- }
198
+ });
204
199
  });
205
200
 
206
201
  async function handleSubmit() {
@@ -209,6 +204,8 @@
209
204
  if (!name.trim()) return void (error = 'Name is required.');
210
205
  if (!startsAt) return void (error = 'Start date is required.');
211
206
  if (!endsAt) return void (error = 'End date is required.');
207
+ if (parseDateTime(endsAt).compare(parseDateTime(startsAt)) <= 0)
208
+ return void (error = 'End time must be after the start time.');
212
209
  if (!viewer.isLoggedIn || !viewer.did) return void (error = 'You must be logged in.');
213
210
 
214
211
  submitting = true;
@@ -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) {
@@ -4,7 +4,8 @@
4
4
  let { value = $bindable('') }: { value: string } = $props();
5
5
  let isOpen = $state(false);
6
6
  let search = $state('');
7
- const allTimezones = ['UTC', ...Intl.supportedValuesOf('timeZone')];
7
+ // WebKit already includes UTC; duplicate keys crash the list when opened.
8
+ const allTimezones = [...new Set(['UTC', ...Intl.supportedValuesOf('timeZone')])];
8
9
  // Memoized formatting results are not reactive state.
9
10
  // eslint-disable-next-line svelte/prefer-svelte-reactivity
10
11
  const offsets = new Map<string, string>();
@@ -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: {
@@ -2,8 +2,22 @@
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
+ const services = {
6
+ appview: 'https://public.api.bsky.app',
7
+ cdn: 'https://cdn.bsky.app'
8
+ };
9
+ /** Configure shared event components before rendering on the server and in the browser. */
10
+ export function configureAtproto(options) {
11
+ if (options.appview)
12
+ services.appview = options.appview.replace(/\/+$/, '');
13
+ if (options.cdn)
14
+ services.cdn = options.cdn.replace(/\/+$/, '');
15
+ }
16
+ export function appviewUrl() {
17
+ return services.appview;
18
+ }
5
19
  export function getCDNImageBlobUrl({ did, blob, format = 'webp' }) {
6
- return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@${format}`;
20
+ return `${services.cdn}/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@${format}`;
7
21
  }
8
22
  export function compressImage(file, maxSize = 900 * 1024, maxDimension = 2048) {
9
23
  return new Promise((resolve, reject) => {
@@ -60,6 +60,7 @@ export declare function parseEventUri(uri: string): {
60
60
  /**
61
61
  * Normalize the talk records belonging to `conferenceUri` into ScheduleEvents,
62
62
  * sorted by start time. Records whose `parentEvent.uri` doesn't match are
63
- * dropped, so it's safe to pass an over-broad list.
63
+ * dropped, so it's safe to pass an over-broad list. `descriptions` replaces
64
+ * record descriptions, keyed by URI, e.g. with plain text from Markdown.
64
65
  */
65
- export declare function toScheduleEvents(events: FlatEventRecord[], conferenceUri: string): ScheduleEvent[];
66
+ export declare function toScheduleEvents(events: FlatEventRecord[], conferenceUri: string, descriptions?: Record<string, string>): ScheduleEvent[];
@@ -35,9 +35,10 @@ export function parseEventUri(uri) {
35
35
  /**
36
36
  * Normalize the talk records belonging to `conferenceUri` into ScheduleEvents,
37
37
  * sorted by start time. Records whose `parentEvent.uri` doesn't match are
38
- * dropped, so it's safe to pass an over-broad list.
38
+ * dropped, so it's safe to pass an over-broad list. `descriptions` replaces
39
+ * record descriptions, keyed by URI, e.g. with plain text from Markdown.
39
40
  */
40
- export function toScheduleEvents(events, conferenceUri) {
41
+ export function toScheduleEvents(events, conferenceUri, descriptions) {
41
42
  return events
42
43
  .filter((e) => getParentEventRef(e)?.uri === conferenceUri)
43
44
  .map((e) => {
@@ -50,7 +51,7 @@ export function toScheduleEvents(events, conferenceUri) {
50
51
  start: e.startsAt,
51
52
  end: e.endsAt,
52
53
  room: ad.room,
53
- description: e.description,
54
+ description: descriptions?.[e.uri] ?? e.description,
54
55
  did: e.did,
55
56
  uri: e.uri,
56
57
  cid: e.cid,
@@ -98,6 +98,10 @@
98
98
  // to preserve the wall time across DST transitions.
99
99
  const baseStart = parseDateTime(startsAt);
100
100
  const baseEnd = endsAt ? parseDateTime(endsAt) : null;
101
+ if (baseEnd && baseEnd.compare(baseStart) <= 0) {
102
+ errorMsg = 'End time must be after the start time.';
103
+ return;
104
+ }
101
105
  const durationMs = baseEnd
102
106
  ? baseEnd.toDate(timezone).getTime() - baseStart.toDate(timezone).getTime()
103
107
  : 0;
@@ -1,4 +1,4 @@
1
- import { type Token } from '@atcute/bluesky-richtext-parser';
1
+ import type { Token } from '@atcute/bluesky-richtext-parser';
2
2
  import type { Facet } from '../../index.js';
3
3
  import type { FlatEventRecord } from '../contrail.js';
4
4
  import type { EventTheme } from '../theme.js';
@@ -1,5 +1,5 @@
1
1
  import { compressImage } from '../atproto-helpers.js';
2
- import { tokenize } from '@atcute/bluesky-richtext-parser';
2
+ import { detectTokens } from '../../richtext/detect.js';
3
3
  import { datetimeLocalToISO } from '../date-format.js';
4
4
  import { designs, resolveAccentColor } from '../thumbnails/designs.js';
5
5
  import { normalizeEventLocations } from './location.js';
@@ -123,7 +123,7 @@ export async function buildEventRecord(args) {
123
123
  const trimmedDescription = description.trim();
124
124
  if (trimmedDescription) {
125
125
  record.description = trimmedDescription;
126
- const tokens = tokenize(trimmedDescription);
126
+ const tokens = detectTokens(trimmedDescription);
127
127
  const facets = await tokensToFacets(tokens, resolveHandle);
128
128
  if (facets.length > 0)
129
129
  record.facets = facets;
@@ -1,7 +1,14 @@
1
1
  import type { AttendeeInfo, FlatEventRecord, HostProfile } from './contrail.js';
2
+ import type { RichTextDocument } from '../richtext/document.js';
2
3
  /** App-loaded data for EventView; fetching and authorization stay in the host. */
3
4
  export interface EventViewData {
4
5
  eventData: FlatEventRecord;
6
+ /**
7
+ * `eventData.description` parsed on the server, usually with
8
+ * `parseRichText(description, { facets, markdown: true, detectLinks: true })`.
9
+ * Without it, the description renders as plain text.
10
+ */
11
+ description?: RichTextDocument | null;
5
12
  actorDid: string;
6
13
  rkey: string;
7
14
  hostProfile?: HostProfile | null;
@@ -27,6 +34,8 @@ export interface EventViewData {
27
34
  playlistUrl: string;
28
35
  } | null;
29
36
  conferenceTalks?: FlatEventRecord[];
37
+ /** Plain-text talk descriptions keyed by talk URI, e.g. from `richTextToPlainText`. */
38
+ conferenceTalkDescriptions?: Record<string, string>;
30
39
  conferenceTimezone?: string;
31
40
  conferenceRsvpStatuses?: Record<string, string>;
32
41
  conferenceRsvpRkeys?: Record<string, string>;
@@ -10,6 +10,6 @@ export * from './events/linked-records.js';
10
10
  export * from './events/date-format.js';
11
11
  export * from './events/theme.js';
12
12
  export { getEventThumbnail, getEventExternalImage, type ExternalEventImage } from './events/event-media.js';
13
- export { getCDNImageBlobUrl, compressImage } from './events/atproto-helpers.js';
13
+ export { getCDNImageBlobUrl, compressImage, configureAtproto } from './events/atproto-helpers.js';
14
14
  export { getProfileUrl, getLocalProfileUrl } from './events/profile-url.js';
15
15
  export { normalizeCountryCode, normalizeEventLocations, LocationValidationError } from './events/editor/location.js';
@@ -4,6 +4,6 @@ export * from './events/linked-records.js';
4
4
  export * from './events/date-format.js';
5
5
  export * from './events/theme.js';
6
6
  export { getEventThumbnail, getEventExternalImage } from './events/event-media.js';
7
- export { getCDNImageBlobUrl, compressImage } from './events/atproto-helpers.js';
7
+ export { getCDNImageBlobUrl, compressImage, configureAtproto } from './events/atproto-helpers.js';
8
8
  export { getProfileUrl, getLocalProfileUrl } from './events/profile-url.js';
9
9
  export { normalizeCountryCode, normalizeEventLocations, LocationValidationError } from './events/editor/location.js';
package/dist/index.d.ts CHANGED
@@ -78,6 +78,7 @@ export { default as NestedComments } from './components/NestedComments.svelte';
78
78
  export { default as PostContent } from './components/PostContent.svelte';
79
79
  export { default as RichText } from './components/RichText.svelte';
80
80
  export type { RichTextProps } from './components/RichText.svelte';
81
+ export type { RichTextDocument, RichTextLinkOptions } from './richtext/document.js';
81
82
  export { default as ExternalEmbed } from './components/ExternalEmbed.svelte';
82
83
  export { defaultExternalEmbedProviders, youtubeEmbedProvider, klipyEmbedProvider, resolveExternalEmbed } from './social/external-embeds.js';
83
84
  export type { ExternalPostEmbed, ExternalEmbedProvider, ExternalEmbedPlayer } from './social/external-embeds.js';
@@ -0,0 +1,7 @@
1
+ import type { AutolinkToken, MentionToken, TextToken, TopicToken } from '@atcute/bluesky-richtext-parser';
2
+ export type DetectedToken = TextToken | MentionToken | TopicToken | AutolinkToken;
3
+ /**
4
+ * Plain-text URLs, mentions and hashtags, in linear time. Other text, including Markdown-like
5
+ * syntax, is returned as text tokens; joining every `raw` reproduces the input.
6
+ */
7
+ export declare function detectTokens(text: string): DetectedToken[];