@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.
- package/README.md +102 -11
- package/dist/components/AccountMenu.svelte +102 -0
- package/dist/components/AccountMenu.svelte.d.ts +19 -0
- package/dist/components/Button.svelte +1 -1
- package/dist/components/ExternalEmbed.svelte +1 -1
- package/dist/components/LinkCard.svelte +1 -1
- package/dist/components/NestedComments.svelte +1 -1
- package/dist/components/PostContent.svelte +1 -1
- package/dist/components/PostVideo.svelte +1 -1
- package/dist/components/RichText.svelte +130 -17
- package/dist/components/RichText.svelte.d.ts +10 -3
- package/dist/components/legal/links.js +2 -9
- package/dist/events/EventComments.svelte +2 -1
- package/dist/events/EventView.svelte +10 -3
- package/dist/events/PostToBlueskyModal.svelte +4 -3
- package/dist/events/atproto-helpers.d.ts +6 -0
- package/dist/events/atproto-helpers.js +15 -1
- package/dist/events/conference.d.ts +3 -2
- package/dist/events/conference.js +4 -3
- package/dist/events/editor/save.d.ts +1 -1
- package/dist/events/editor/save.js +2 -2
- package/dist/events/event-view/EventLinksList.svelte +11 -2
- package/dist/events/event-view/ExternalRsvpNotice.svelte +20 -16
- package/dist/events/event-view/locations.js +1 -11
- package/dist/events/view-data.d.ts +9 -0
- package/dist/events-data.d.ts +1 -1
- package/dist/events-data.js +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/richtext/detect.d.ts +7 -0
- package/dist/richtext/detect.js +72 -0
- package/dist/richtext/document.d.ts +76 -0
- package/dist/richtext/document.js +95 -0
- package/dist/richtext/facets.d.ts +28 -0
- package/dist/richtext/facets.js +113 -0
- package/dist/richtext/markdown.d.ts +10 -0
- package/dist/richtext/markdown.js +236 -0
- package/dist/richtext/plain.d.ts +9 -0
- package/dist/richtext/plain.js +8 -0
- package/dist/richtext.d.ts +17 -0
- package/dist/richtext.js +12 -0
- package/dist/social/external-embeds.js +1 -1
- package/dist/social/profile.js +3 -3
- package/dist/social/richtext.d.ts +5 -8
- package/dist/social/richtext.js +3 -94
- package/dist/urls.d.ts +9 -0
- package/dist/urls.js +39 -0
- 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
|
|
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
|
-
###
|
|
270
|
+
### Rich text
|
|
198
271
|
|
|
199
|
-
`RichText`
|
|
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={
|
|
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
|
-
|
|
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;
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
type ExternalEmbedProvider,
|
|
7
7
|
type ExternalPostEmbed
|
|
8
8
|
} from '../social/external-embeds.js';
|
|
9
|
-
import { safeWebUrl } from '../
|
|
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 '../
|
|
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 '../
|
|
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 '../
|
|
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,33 +1,146 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import {
|
|
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 =
|
|
6
|
-
text
|
|
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
|
-
|
|
27
|
+
content,
|
|
28
|
+
text = '',
|
|
14
29
|
facets,
|
|
30
|
+
detectLinks = false,
|
|
15
31
|
socialClient,
|
|
16
32
|
mentionUrl,
|
|
17
|
-
|
|
33
|
+
headingOffset = 0,
|
|
18
34
|
class: className
|
|
19
35
|
}: RichTextProps = $props();
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
26
|
-
{#each
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
>{
|
|
32
|
-
>
|
|
33
|
-
</
|
|
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
|
|
2
|
-
export type RichTextProps =
|
|
3
|
-
text
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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={
|
|
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 {
|
|
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(
|
|
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: {
|