@atmo-dev/kit 0.2.1 → 0.3.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
@@ -9,6 +9,47 @@ Svelte 5 and Tailwind CSS 4 components for Atmo apps.
9
9
  - Releases: `pnpm changeset`, then merge the generated release PR. [Details](.changeset/README.md).
10
10
  - License: [MIT](LICENSE).
11
11
 
12
+ ## Read-only profiles
13
+
14
+ `UserProfile` displays a Bluesky profile header: banner, avatar, name, handle, pronouns, optional counts, bio, modern profile links, and a DID-based “View on Bluesky” link. It has no editing or follow behavior. An optional `actions` snippet holds app-owned controls. `metrics={[{ label: 'events created', value: 12 }]}` replaces the default Bluesky counts; `metrics={[]}` hides all counts.
15
+
16
+ ```svelte
17
+ <script lang="ts">
18
+ import { UserProfile, blueskyProfileToProfileData } from '@atmo-dev/kit';
19
+
20
+ // Fetch and apply moderation in the app, not in the component.
21
+ let { profileView } = $props();
22
+ let profile = $derived(blueskyProfileToProfileData(profileView));
23
+ </script>
24
+
25
+ {#if profile}<UserProfile {profile} />{/if}
26
+ ```
27
+
28
+ `ProfileData` can also be supplied directly. Bios remain escaped plain text with detected HTTP(S) links, mentions and hashtags; newlines are preserved. There is no HTML/Markdown interpretation, DOM sanitizer, client-only rendering, or mention-resolution request. Link destinations and images reject executable schemes and credential-bearing URLs. External links use `noopener noreferrer`; user links also use `nofollow ugc`.
29
+
30
+ `blueskyProfileToProfileData` accepts an `app.bsky.actor.getProfile` view and validates optional fields. Its `links` must be hydrated `app.bsky.actor.link` views (`url`, optional `title` and `icon` image URL), not the profile record's ordered strongRefs. It omits unresolved refs and labeled links conservatively, deduplicates destinations, and displays at most ten. The legacy `website` field is deliberately ignored. Apps own hydration, account moderation and any more specific link-moderation policy. Supplied icons render as small decorative images; unsafe or failed icons fall back to text-only links. No link metadata or destination is fetched by the component.
31
+
32
+ ## Preferred social client
33
+
34
+ `SocialClientPicker` is a native form field group for Bluesky, mu, Blacksky, Witchsky, or a custom web client. Pass the viewer's saved `value` (a `PreferredSocialClient`, or `null` for Bluesky) and optionally `disabled`. It submits `client` and, for custom clients, `customUrl`. The app owns the form, save button, authentication, and feedback.
35
+
36
+ The dedicated public record is `social.atmo.actor.preferredClient/self`:
37
+
38
+ ```json
39
+ { "$type": "social.atmo.actor.preferredClient", "client": "mu" }
40
+ ```
41
+
42
+ For a custom client, use `"client": "custom"` and `"customUrl": "https://example.com"`. This lexicon stores **only social-client selection**, not general preferences. Its schema is available at `@atmo-dev/kit/lexicons/social.atmo.actor.preferredClient.json`.
43
+
44
+ Framework-free helpers are exported from `@atmo-dev/kit/social` (and the main entry point):
45
+
46
+ - `PREFERRED_CLIENT_COLLECTION`, `PREFERRED_CLIENT_RKEY`, `PREFERRED_CLIENT_SCOPE` for repository IO and OAuth metadata/incremental permission requests. The scope grants create/update only on this collection; request it when the user chooses to authorize this setting, not during normal login or signup.
47
+ - `parsePreferredSocialClient(record)` validates repository records or form input; invalid, unknown or absent selections return `null`.
48
+ - `preferredSocialClientRecord(selection)` builds a validated record. Never write merely because a read failed; distinguish `RecordNotFound` from PDS/network failures, and use `swapRecord` to guard updates.
49
+ - `socialClientInfo(selection)`, `socialProfileUrl(actor, selection)`, `socialPostUrl(atUri, selection)`, and `socialHashtagUrl(tag, selection)` default to Bluesky. `socialPostUrl` accepts feed-post AT URIs and returns `null` for invalid ones. Custom URLs must be absolute HTTP(S), without credentials, query or fragment, and support `/profile/:actor` routes. Base paths are preserved.
50
+
51
+ Read the signed-in viewer's record from their PDS, include the parsed selection in viewer data, and pass it to `<UserProfile {profile} socialClient={viewer.preferredSocialClient} />`. The profile's external button and handle link use that client. Pass the same `socialClient` prop to `NestedComments` for author, reply and like links, including nested replies. Changing the selection updates these links without changing the stored post data. Bio hashtags also use the selected client. Supply an app-owned `mentionUrl` callback to `UserProfile` (for example, `(actor) => '/p/' + encodeURIComponent(actor)`) to route bio mentions locally in the same tab; without it they open the selected social client. Post-content facets retain their existing Bluesky links. Apps remain responsible for server-side authorization, IO, incremental authorization when access is missing, and invalidating viewer data after saves.
52
+
12
53
  ## Login identifiers
13
54
 
14
55
  `LoginForm` and `LoginDialog` accept handles and DIDs by default. Handles are trimmed, stripped of leading `@` characters, and lowercased. DIDs are trimmed only: method-specific case and percent encoding are preserved. `initialHandle` also accepts a DID; its existing name is retained for compatibility.
@@ -1,19 +1,27 @@
1
1
  <script lang="ts">
2
2
  import type { PostData } from '../social/posts.js';
3
3
  import { safeWebUrl } from '../social/richtext.js';
4
- import { blueskyProfileUrl } from '../social/urls.js';
4
+ import { socialPostUrl, socialProfileUrl } from '../social/urls.js';
5
+ import type { PreferredSocialClient } from '../social/preferred-client.js';
5
6
  import Avatar from './Avatar.svelte';
6
7
  import PostContent from './PostContent.svelte';
7
8
  import NestedComments from './NestedComments.svelte';
8
- let { comments }: { comments: PostData[] } = $props();
9
+ let {
10
+ comments,
11
+ socialClient
12
+ }: {
13
+ comments: PostData[];
14
+ socialClient?: PreferredSocialClient | null;
15
+ } = $props();
9
16
  </script>
10
17
 
11
18
  <div class="space-y-6" data-slot="comments">
12
19
  {#each comments as comment (comment.uri)}
20
+ {@const href = socialPostUrl(comment.uri, socialClient) ?? safeWebUrl(comment.href)}
13
21
  <article class="min-w-0">
14
22
  <div class="flex items-start gap-3">
15
23
  <a
16
- href={blueskyProfileUrl(comment.author.did)}
24
+ href={socialProfileUrl(comment.author.did, socialClient)}
17
25
  target="_blank"
18
26
  rel="noopener noreferrer"
19
27
  class="shrink-0"
@@ -22,7 +30,7 @@
22
30
  <div class="min-w-0 flex-1">
23
31
  <div class="mb-1 flex flex-wrap items-baseline gap-x-2 text-xs">
24
32
  <a
25
- href={safeWebUrl(comment.href)}
33
+ {href}
26
34
  target="_blank"
27
35
  rel="noopener noreferrer"
28
36
  class="font-medium text-ink hover:underline">{comment.author.name}</a
@@ -38,24 +46,17 @@
38
46
  </div>
39
47
  <PostContent post={comment} />
40
48
  <div class="mt-2 flex gap-5 text-[.6875rem] text-muted">
41
- <a
42
- href={safeWebUrl(comment.href)}
43
- target="_blank"
44
- rel="noopener noreferrer"
45
- class="hover:text-action"
49
+ <a {href} target="_blank" rel="noopener noreferrer" class="hover:text-action"
46
50
  >Reply{#if comment.replyCount}
47
51
  · {comment.replyCount}{/if}</a
48
- ><a
49
- href={safeWebUrl(comment.href)}
50
- target="_blank"
51
- rel="noopener noreferrer"
52
- class="hover:text-action">{comment.likeCount ?? 0} likes</a
52
+ ><a {href} target="_blank" rel="noopener noreferrer" class="hover:text-action"
53
+ >{comment.likeCount ?? 0} likes</a
53
54
  >
54
55
  </div>
55
56
  </div>
56
57
  </div>
57
58
  {#if comment.replies?.length}<div class="mt-4 border-l border-line pl-3 sm:ml-4 sm:pl-6">
58
- <NestedComments comments={comment.replies} />
59
+ <NestedComments comments={comment.replies} {socialClient} />
59
60
  </div>{/if}
60
61
  </article>
61
62
  {/each}
@@ -1,7 +1,9 @@
1
1
  import type { PostData } from '../social/posts.js';
2
+ import type { PreferredSocialClient } from '../social/preferred-client.js';
2
3
  import NestedComments from './NestedComments.svelte';
3
4
  type $$ComponentProps = {
4
5
  comments: PostData[];
6
+ socialClient?: PreferredSocialClient | null;
5
7
  };
6
8
  declare const NestedComments: import("svelte").Component<$$ComponentProps, {}, "">;
7
9
  type NestedComments = ReturnType<typeof NestedComments>;
@@ -0,0 +1,50 @@
1
+ <script lang="ts" module>
2
+ import type { PreferredSocialClient } from '../social/preferred-client.js';
3
+ export type SocialClientPickerProps = {
4
+ value?: PreferredSocialClient | null;
5
+ disabled?: boolean;
6
+ };
7
+ </script>
8
+
9
+ <script lang="ts">
10
+ import { socialClients } from '../social/preferred-client.js';
11
+ import Select from './Select.svelte';
12
+ import Input from './Input.svelte';
13
+ import Label from './Label.svelte';
14
+
15
+ let { value = null, disabled = false }: SocialClientPickerProps = $props();
16
+ const id = $props.id();
17
+ let client: string = $derived(value?.client ?? 'bluesky');
18
+ let customUrl = $derived(value?.client === 'custom' ? value.customUrl : '');
19
+ </script>
20
+
21
+ <div class="grid gap-4">
22
+ <div class="grid gap-2">
23
+ <Label for={`${id}-client`}>Preferred social app</Label>
24
+ <Select id={`${id}-client`} name="client" bind:value={client} {disabled}>
25
+ {#each socialClients as option (option.id)}
26
+ <option value={option.id}>{option.label}</option>
27
+ {/each}
28
+ <option value="custom">Custom</option>
29
+ </Select>
30
+ </div>
31
+ {#if client === 'custom'}
32
+ <div class="grid gap-2">
33
+ <Label for={`${id}-url`}>Client URL</Label>
34
+ <Input
35
+ id={`${id}-url`}
36
+ name="customUrl"
37
+ type="url"
38
+ bind:value={customUrl}
39
+ {disabled}
40
+ required
41
+ maxlength={2048}
42
+ placeholder="https://your-client.example"
43
+ aria-describedby={`${id}-hint`}
44
+ />
45
+ <p id={`${id}-hint`} class="text-sm text-muted">
46
+ Use a web client with Bluesky-style profile links.
47
+ </p>
48
+ </div>
49
+ {/if}
50
+ </div>
@@ -0,0 +1,8 @@
1
+ import type { PreferredSocialClient } from '../social/preferred-client.js';
2
+ export type SocialClientPickerProps = {
3
+ value?: PreferredSocialClient | null;
4
+ disabled?: boolean;
5
+ };
6
+ declare const SocialClientPicker: import("svelte").Component<SocialClientPickerProps, {}, "">;
7
+ type SocialClientPicker = ReturnType<typeof SocialClientPicker>;
8
+ export default SocialClientPicker;
@@ -0,0 +1,151 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { cn } from '../utils.js';
4
+ import {
5
+ profileDescriptionSegments,
6
+ safeProfileUrl,
7
+ visibleProfileLinks,
8
+ type ProfileData
9
+ } from '../social/profile.js';
10
+ import { socialProfileUrl } from '../social/urls.js';
11
+ import { socialClientInfo, type PreferredSocialClient } from '../social/preferred-client.js';
12
+ import Avatar from './Avatar.svelte';
13
+ import Button from './Button.svelte';
14
+
15
+ export type UserProfileProps = {
16
+ profile: ProfileData;
17
+ /** The viewer's choice, not the profile owner's. Defaults to Bluesky. */
18
+ socialClient?: PreferredSocialClient | null;
19
+ /** Route bio mentions to an app-owned profile instead of the social client. */
20
+ mentionUrl?: (identifier: string) => string;
21
+ actions?: Snippet;
22
+ /** App-owned metrics replace the default Bluesky counts, including an empty array. */
23
+ metrics?: { label: string; value: number }[];
24
+ class?: string;
25
+ };
26
+ let {
27
+ profile,
28
+ socialClient,
29
+ mentionUrl,
30
+ actions,
31
+ metrics,
32
+ class: className
33
+ }: UserProfileProps = $props();
34
+ let displayedMetrics = $derived(
35
+ metrics ??
36
+ [
37
+ { value: profile.followersCount, label: 'followers' },
38
+ { value: profile.followsCount, label: 'following' },
39
+ { value: profile.postsCount, label: 'posts' }
40
+ ].filter((metric) => metric.value !== undefined)
41
+ );
42
+ let name = $derived(profile.displayName || profile.handle || profile.did);
43
+ let href = $derived(socialProfileUrl(profile.did, socialClient));
44
+ let clientLabel = $derived(socialClientInfo(socialClient).label);
45
+ let failedBanner = $state<string>();
46
+ let bannerSrc = $derived(safeProfileUrl(profile.banner));
47
+ let banner = $derived(bannerSrc !== failedBanner ? bannerSrc : undefined);
48
+ let links = $derived(visibleProfileLinks(profile.links));
49
+ let failedLinkIcons = $state<Record<string, string>>({});
50
+ let pronouns = $derived(profile.pronouns?.trim().toLowerCase());
51
+ </script>
52
+
53
+ <section class={cn('min-w-0 text-ink', className)} aria-label="Profile">
54
+ {#if banner}
55
+ <div
56
+ data-slot="profile-banner"
57
+ class="relative aspect-3/1 w-full overflow-hidden rounded-panel bg-recess"
58
+ >
59
+ <img
60
+ src={banner}
61
+ alt=""
62
+ width="1500"
63
+ height="500"
64
+ class="absolute inset-0 size-full object-cover"
65
+ loading="eager"
66
+ fetchpriority="high"
67
+ decoding="async"
68
+ referrerpolicy="no-referrer"
69
+ onerror={(event) => (failedBanner = event.currentTarget.getAttribute('src') ?? undefined)}
70
+ />
71
+ </div>
72
+ {/if}
73
+ <div class={cn('flex flex-wrap items-start justify-between gap-4', banner ? 'px-4' : '')}>
74
+ <Avatar
75
+ {name}
76
+ src={safeProfileUrl(profile.avatar)}
77
+ size={96}
78
+ class={cn('relative size-20 sm:size-24', banner && '-mt-10 ring-4 ring-page sm:-mt-12')}
79
+ />
80
+ <div class={cn('flex flex-wrap items-center gap-2', banner && 'pt-3')}>
81
+ <Button {href} target="_blank" rel="noopener noreferrer" variant="secondary" size="sm">
82
+ View on {clientLabel}
83
+ </Button>
84
+ {@render actions?.()}
85
+ </div>
86
+ </div>
87
+ <div class="mt-4 min-w-0">
88
+ <h1 class="text-2xl font-semibold tracking-tight wrap-anywhere sm:text-3xl">
89
+ <bdi>{name}</bdi>
90
+ </h1>
91
+ <div class="mt-1 flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm text-muted">
92
+ {#if profile.handle}<a
93
+ {href}
94
+ target="_blank"
95
+ rel="noopener noreferrer"
96
+ class="min-w-0 wrap-anywhere hover:underline"><bdi>@{profile.handle}</bdi></a
97
+ >{/if}
98
+ {#if pronouns}<span aria-label="Pronouns"><bdi>{pronouns}</bdi></span>{/if}
99
+ </div>
100
+ </div>
101
+ {#if displayedMetrics.length}
102
+ <dl aria-label="Profile statistics" class="mt-4 flex flex-wrap gap-x-4 gap-y-1 text-sm">
103
+ {#each displayedMetrics as metric (metric.label)}
104
+ {#if metric.value !== undefined}<div class="flex gap-1">
105
+ <dd class="font-semibold">{metric.value.toLocaleString('en')}</dd>
106
+ <dt class="text-muted">{metric.label}</dt>
107
+ </div>{/if}
108
+ {/each}
109
+ </dl>
110
+ {/if}
111
+ {#if profile.description}
112
+ <p class="mt-4 text-sm leading-relaxed wrap-anywhere whitespace-pre-wrap">
113
+ {#each profileDescriptionSegments( profile.description, { socialClient, mentionUrl } ) as segment, index (index)}{#if segment.href}<a
114
+ href={segment.href}
115
+ target={segment.href.startsWith('/') ? undefined : '_blank'}
116
+ rel={segment.href.startsWith('/') ? undefined : 'noopener noreferrer nofollow ugc'}
117
+ class="text-action underline decoration-action/40 underline-offset-2 hover:decoration-action"
118
+ >{segment.text}</a
119
+ >{:else}{segment.text}{/if}{/each}
120
+ </p>
121
+ {/if}
122
+ {#if links.length}
123
+ <ul aria-label="Profile links" class="mt-4 flex flex-wrap gap-2">
124
+ {#each links as link (link.url)}
125
+ <li class="max-w-full min-w-0">
126
+ <a
127
+ href={link.url}
128
+ target="_blank"
129
+ rel="noopener noreferrer nofollow ugc"
130
+ class="inline-flex max-w-full items-center gap-2 rounded-ui border border-line px-3 py-1.5 text-sm hover:bg-recess"
131
+ >
132
+ {#if link.icon && failedLinkIcons[link.url] !== link.icon}
133
+ <img
134
+ src={link.icon}
135
+ alt=""
136
+ width="16"
137
+ height="16"
138
+ class="size-4 shrink-0 rounded-sm object-contain"
139
+ loading="lazy"
140
+ decoding="async"
141
+ referrerpolicy="no-referrer"
142
+ onerror={() => (failedLinkIcons[link.url] = link.icon ?? '')}
143
+ />
144
+ {/if}
145
+ <span class="min-w-0 wrap-anywhere">{link.title || new URL(link.url).hostname}</span>
146
+ </a>
147
+ </li>
148
+ {/each}
149
+ </ul>
150
+ {/if}
151
+ </section>
@@ -0,0 +1,20 @@
1
+ import type { Snippet } from 'svelte';
2
+ import { type ProfileData } from '../social/profile.js';
3
+ import { type PreferredSocialClient } from '../social/preferred-client.js';
4
+ export type UserProfileProps = {
5
+ profile: ProfileData;
6
+ /** The viewer's choice, not the profile owner's. Defaults to Bluesky. */
7
+ socialClient?: PreferredSocialClient | null;
8
+ /** Route bio mentions to an app-owned profile instead of the social client. */
9
+ mentionUrl?: (identifier: string) => string;
10
+ actions?: Snippet;
11
+ /** App-owned metrics replace the default Bluesky counts, including an empty array. */
12
+ metrics?: {
13
+ label: string;
14
+ value: number;
15
+ }[];
16
+ class?: string;
17
+ };
18
+ declare const UserProfile: import("svelte").Component<UserProfileProps, {}, "">;
19
+ type UserProfile = ReturnType<typeof UserProfile>;
20
+ export default UserProfile;
package/dist/index.d.ts CHANGED
@@ -5,6 +5,9 @@ export { default as Input } from './components/Input.svelte';
5
5
  export type { InputProps } from './components/Input.svelte';
6
6
  export { default as Textarea } from './components/Textarea.svelte';
7
7
  export { default as Select } from './components/Select.svelte';
8
+ export { default as SocialClientPicker } from './components/SocialClientPicker.svelte';
9
+ export type { SocialClientPickerProps } from './components/SocialClientPicker.svelte';
10
+ export * from './social.js';
8
11
  export { default as Checkbox } from './components/Checkbox.svelte';
9
12
  export { default as Label } from './components/Label.svelte';
10
13
  export { default as PageTitle } from './components/PageTitle.svelte';
@@ -12,6 +15,10 @@ export { default as Heading } from './components/Heading.svelte';
12
15
  export { default as Subheading } from './components/Subheading.svelte';
13
16
  export { default as Surface } from './components/Surface.svelte';
14
17
  export { default as Avatar } from './components/Avatar.svelte';
18
+ export { default as UserProfile } from './components/UserProfile.svelte';
19
+ export type { UserProfileProps } from './components/UserProfile.svelte';
20
+ export { blueskyProfileToProfileData } from './social/profile.js';
21
+ export type { ProfileData, ProfileLink } from './social/profile.js';
15
22
  export { default as InsetImage } from './components/InsetImage.svelte';
16
23
  export { default as Badge } from './components/Badge.svelte';
17
24
  export { default as Modal } from './components/Modal.svelte';
package/dist/index.js CHANGED
@@ -3,6 +3,8 @@ export { default as IconButton } from './components/IconButton.svelte';
3
3
  export { default as Input } from './components/Input.svelte';
4
4
  export { default as Textarea } from './components/Textarea.svelte';
5
5
  export { default as Select } from './components/Select.svelte';
6
+ export { default as SocialClientPicker } from './components/SocialClientPicker.svelte';
7
+ export * from './social.js';
6
8
  export { default as Checkbox } from './components/Checkbox.svelte';
7
9
  export { default as Label } from './components/Label.svelte';
8
10
  export { default as PageTitle } from './components/PageTitle.svelte';
@@ -10,6 +12,8 @@ export { default as Heading } from './components/Heading.svelte';
10
12
  export { default as Subheading } from './components/Subheading.svelte';
11
13
  export { default as Surface } from './components/Surface.svelte';
12
14
  export { default as Avatar } from './components/Avatar.svelte';
15
+ export { default as UserProfile } from './components/UserProfile.svelte';
16
+ export { blueskyProfileToProfileData } from './social/profile.js';
13
17
  export { default as InsetImage } from './components/InsetImage.svelte';
14
18
  export { default as Badge } from './components/Badge.svelte';
15
19
  export { default as Modal } from './components/Modal.svelte';
@@ -0,0 +1,29 @@
1
+ {
2
+ "lexicon": 1,
3
+ "id": "social.atmo.actor.preferredClient",
4
+ "defs": {
5
+ "main": {
6
+ "type": "record",
7
+ "description": "An actor's public preferred Bluesky-compatible social client. Other preferences belong in separate records.",
8
+ "key": "literal:self",
9
+ "record": {
10
+ "type": "object",
11
+ "required": ["client"],
12
+ "properties": {
13
+ "client": {
14
+ "type": "string",
15
+ "description": "The preferred social client. custom requires customUrl.",
16
+ "knownValues": ["bluesky", "mu", "blacksky", "witchsky", "custom"],
17
+ "maxLength": 64
18
+ },
19
+ "customUrl": {
20
+ "type": "string",
21
+ "format": "uri",
22
+ "maxLength": 2048,
23
+ "description": "For custom clients only: an absolute HTTP(S) base URL without credentials, query or fragment. Must support /profile/:actor links."
24
+ }
25
+ }
26
+ }
27
+ }
28
+ }
29
+ }
@@ -0,0 +1,36 @@
1
+ /** Public, cross-app social-client selection in the actor's repository. */
2
+ export declare const PREFERRED_CLIENT_COLLECTION = "social.atmo.actor.preferredClient";
3
+ export declare const PREFERRED_CLIENT_RKEY = "self";
4
+ export declare const PREFERRED_CLIENT_SCOPE = "repo:social.atmo.actor.preferredClient?action=create&action=update";
5
+ export declare const socialClients: readonly [{
6
+ readonly id: "bluesky";
7
+ readonly label: "Bluesky";
8
+ readonly url: "https://bsky.app";
9
+ }, {
10
+ readonly id: "mu";
11
+ readonly label: "mu";
12
+ readonly url: "https://mu.social";
13
+ }, {
14
+ readonly id: "blacksky";
15
+ readonly label: "Blacksky";
16
+ readonly url: "https://blacksky.community";
17
+ }, {
18
+ readonly id: "witchsky";
19
+ readonly label: "Witchsky";
20
+ readonly url: "https://witchsky.app";
21
+ }];
22
+ export type SocialClientId = (typeof socialClients)[number]['id'] | 'custom';
23
+ export type PreferredSocialClient = {
24
+ client: Exclude<SocialClientId, 'custom'>;
25
+ } | {
26
+ client: 'custom';
27
+ customUrl: string;
28
+ };
29
+ /** Require an absolute web URL. Keep path prefixes for self-hosted clients. */
30
+ export declare function normalizeSocialClientUrl(value: string): string | null;
31
+ export declare function parsePreferredSocialClient(value: unknown): PreferredSocialClient | null;
32
+ export declare function preferredSocialClientRecord(preference: PreferredSocialClient): Record<string, unknown>;
33
+ export declare function socialClientInfo(preference?: PreferredSocialClient | null): {
34
+ label: string;
35
+ url: string;
36
+ };
@@ -0,0 +1,48 @@
1
+ /** Public, cross-app social-client selection in the actor's repository. */
2
+ export const PREFERRED_CLIENT_COLLECTION = 'social.atmo.actor.preferredClient';
3
+ export const PREFERRED_CLIENT_RKEY = 'self';
4
+ export const PREFERRED_CLIENT_SCOPE = `repo:${PREFERRED_CLIENT_COLLECTION}?action=create&action=update`;
5
+ export const socialClients = [
6
+ { id: 'bluesky', label: 'Bluesky', url: 'https://bsky.app' },
7
+ { id: 'mu', label: 'mu', url: 'https://mu.social' },
8
+ { id: 'blacksky', label: 'Blacksky', url: 'https://blacksky.community' },
9
+ { id: 'witchsky', label: 'Witchsky', url: 'https://witchsky.app' }
10
+ ];
11
+ /** Require an absolute web URL. Keep path prefixes for self-hosted clients. */
12
+ export function normalizeSocialClientUrl(value) {
13
+ try {
14
+ const url = new URL(value.trim());
15
+ if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password)
16
+ return null;
17
+ if (url.search || url.hash || url.href.length > 2048)
18
+ return null;
19
+ return url.href.replace(/\/+$/, '');
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
25
+ export function parsePreferredSocialClient(value) {
26
+ if (!value || typeof value !== 'object' || !('client' in value))
27
+ return null;
28
+ if (value.client === 'custom') {
29
+ const customUrl = 'customUrl' in value && typeof value.customUrl === 'string'
30
+ ? normalizeSocialClientUrl(value.customUrl)
31
+ : null;
32
+ return customUrl ? { client: 'custom', customUrl } : null;
33
+ }
34
+ const client = socialClients.find((client) => client.id === value.client);
35
+ return client ? { client: client.id } : null;
36
+ }
37
+ export function preferredSocialClientRecord(preference) {
38
+ const client = parsePreferredSocialClient(preference);
39
+ if (!client)
40
+ throw new Error('Choose a client and enter a valid client URL.');
41
+ return { $type: PREFERRED_CLIENT_COLLECTION, ...client };
42
+ }
43
+ export function socialClientInfo(preference) {
44
+ const client = parsePreferredSocialClient(preference) ?? { client: 'bluesky' };
45
+ return client.client === 'custom'
46
+ ? { label: 'Custom client', url: client.customUrl }
47
+ : socialClients.find((entry) => entry.id === client.client);
48
+ }
@@ -0,0 +1,36 @@
1
+ import type { PreferredSocialClient } from './preferred-client.js';
2
+ /** Hydrated app.bsky.actor.link data, not the legacy profile.website field. */
3
+ export type ProfileLink = {
4
+ url: string;
5
+ title?: string;
6
+ icon?: string;
7
+ };
8
+ export type ProfileData = {
9
+ did: string;
10
+ handle?: string;
11
+ displayName?: string;
12
+ avatar?: string;
13
+ banner?: string;
14
+ description?: string;
15
+ pronouns?: string;
16
+ links?: ProfileLink[];
17
+ followersCount?: number;
18
+ followsCount?: number;
19
+ postsCount?: number;
20
+ createdAt?: string;
21
+ };
22
+ /** Only web URLs; never accept executable schemes or disguised credentials. */
23
+ export declare function safeProfileUrl(value: string | undefined): string | undefined;
24
+ /** Bios are plain text with detected facets, never HTML or Markdown. No network requests. */
25
+ export type ProfileDescriptionOptions = {
26
+ socialClient?: PreferredSocialClient | null;
27
+ /** App-owned mention destination; omit to use the selected social client. */
28
+ mentionUrl?: (identifier: string) => string;
29
+ };
30
+ export declare function profileDescriptionSegments(text: string, options?: ProfileDescriptionOptions): {
31
+ text: string;
32
+ href: string | undefined;
33
+ }[];
34
+ export declare function visibleProfileLinks(links?: ProfileLink[]): ProfileLink[];
35
+ /** Validate public AppView data at the boundary, including newer optional fields. */
36
+ export declare function blueskyProfileToProfileData(value: unknown): ProfileData | undefined;
@@ -0,0 +1,78 @@
1
+ import { tokenize } from '@atcute/bluesky-richtext-parser';
2
+ import { safeWebUrl } from './richtext.js';
3
+ import { socialHashtagUrl, socialProfileUrl } from './urls.js';
4
+ /** Only web URLs; never accept executable schemes or disguised credentials. */
5
+ export function safeProfileUrl(value) {
6
+ if (!value ||
7
+ Array.from(value).some((char) => char.charCodeAt(0) <= 0x20 || char.charCodeAt(0) === 0x7f))
8
+ return undefined;
9
+ const href = safeWebUrl(value);
10
+ if (!href)
11
+ return undefined;
12
+ const url = new URL(href);
13
+ return url.username || url.password ? undefined : href;
14
+ }
15
+ export function profileDescriptionSegments(text, options = {}) {
16
+ return Array.from(tokenize(text), (token) => ({
17
+ text: token.raw,
18
+ href: token.type === 'autolink'
19
+ ? safeProfileUrl(token.url)
20
+ : token.type === 'mention'
21
+ ? (options.mentionUrl?.(token.handle) ??
22
+ socialProfileUrl(token.handle, options.socialClient))
23
+ : token.type === 'topic'
24
+ ? socialHashtagUrl(token.name, options.socialClient)
25
+ : undefined
26
+ }));
27
+ }
28
+ export function visibleProfileLinks(links = []) {
29
+ const seen = new Set();
30
+ const result = [];
31
+ for (const link of links) {
32
+ const url = safeProfileUrl(link.url);
33
+ if (!url || seen.has(url))
34
+ continue;
35
+ seen.add(url);
36
+ result.push({ url, title: link.title?.trim() || undefined, icon: safeProfileUrl(link.icon) });
37
+ if (result.length === 10)
38
+ break;
39
+ }
40
+ return result;
41
+ }
42
+ /** Validate public AppView data at the boundary, including newer optional fields. */
43
+ export function blueskyProfileToProfileData(value) {
44
+ if (!value || typeof value !== 'object')
45
+ return undefined;
46
+ const p = value;
47
+ if (typeof p.did !== 'string' || !p.did.startsWith('did:'))
48
+ return undefined;
49
+ const text = (key) => (typeof p[key] === 'string' ? p[key] : undefined);
50
+ const count = (key) => typeof p[key] === 'number' && Number.isSafeInteger(p[key]) && p[key] >= 0 ? p[key] : undefined;
51
+ const links = [];
52
+ for (const item of Array.isArray(p.links) ? p.links : []) {
53
+ if (!item || typeof item !== 'object' || typeof item.url !== 'string')
54
+ continue;
55
+ // Without viewer-specific moderation preferences, omit labeled links conservatively.
56
+ if (Array.isArray(item.labels) && item.labels.some((label) => !label?.neg))
57
+ continue;
58
+ links.push({
59
+ url: item.url,
60
+ title: typeof item.title === 'string' ? item.title : undefined,
61
+ icon: typeof item.icon === 'string' ? item.icon : undefined
62
+ });
63
+ }
64
+ return {
65
+ did: p.did,
66
+ handle: text('handle'),
67
+ displayName: text('displayName'),
68
+ avatar: safeProfileUrl(text('avatar')),
69
+ banner: safeProfileUrl(text('banner')),
70
+ description: text('description'),
71
+ pronouns: text('pronouns'),
72
+ links: visibleProfileLinks(links),
73
+ followersCount: count('followersCount'),
74
+ followsCount: count('followsCount'),
75
+ postsCount: count('postsCount'),
76
+ createdAt: text('createdAt')
77
+ };
78
+ }
@@ -1,2 +1,9 @@
1
+ import { type PreferredSocialClient } from './preferred-client.js';
1
2
  /** Bluesky expects literal DIDs in its routes, including colons and did:web escapes. */
2
3
  export declare function blueskyProfileUrl(identifier: string): string;
4
+ /** Supported clients share Bluesky's /profile/:actor route. */
5
+ export declare function socialProfileUrl(identifier: string, client?: PreferredSocialClient | null): string;
6
+ /** Open a hashtag in the viewer's preferred Bluesky-compatible client. */
7
+ export declare function socialHashtagUrl(tag: string, client?: PreferredSocialClient | null): string;
8
+ /** Open a feed-post AT URI in the viewer's preferred Bluesky-compatible client. */
9
+ export declare function socialPostUrl(uri: string, client?: PreferredSocialClient | null): string | null;
@@ -1,6 +1,22 @@
1
- import { isDid } from '@atcute/lexicons/syntax';
1
+ import { isActorIdentifier, isDid, isRecordKey } from '@atcute/lexicons/syntax';
2
+ import { socialClientInfo } from './preferred-client.js';
2
3
  /** Bluesky expects literal DIDs in its routes, including colons and did:web escapes. */
3
4
  export function blueskyProfileUrl(identifier) {
5
+ return socialProfileUrl(identifier);
6
+ }
7
+ /** Supported clients share Bluesky's /profile/:actor route. */
8
+ export function socialProfileUrl(identifier, client) {
4
9
  const actor = isDid(identifier) ? identifier : encodeURIComponent(identifier);
5
- return `https://bsky.app/profile/${actor}`;
10
+ return `${socialClientInfo(client).url}/profile/${actor}`;
11
+ }
12
+ /** Open a hashtag in the viewer's preferred Bluesky-compatible client. */
13
+ export function socialHashtagUrl(tag, client) {
14
+ return `${socialClientInfo(client).url}/hashtag/${encodeURIComponent(tag)}`;
15
+ }
16
+ /** Open a feed-post AT URI in the viewer's preferred Bluesky-compatible client. */
17
+ export function socialPostUrl(uri, client) {
18
+ const match = /^at:\/\/([^/]+)\/app\.bsky\.feed\.post\/([^/]+)$/.exec(uri);
19
+ if (!match || !isActorIdentifier(match[1]) || !isRecordKey(match[2]))
20
+ return null;
21
+ return `${socialProfileUrl(match[1], client)}/post/${encodeURIComponent(match[2])}`;
6
22
  }
@@ -0,0 +1,3 @@
1
+ export { PREFERRED_CLIENT_COLLECTION, PREFERRED_CLIENT_RKEY, PREFERRED_CLIENT_SCOPE, socialClients, normalizeSocialClientUrl, parsePreferredSocialClient, preferredSocialClientRecord, socialClientInfo } from './social/preferred-client.js';
2
+ export type { SocialClientId, PreferredSocialClient } from './social/preferred-client.js';
3
+ export { blueskyProfileUrl, socialProfileUrl, socialPostUrl, socialHashtagUrl } from './social/urls.js';
package/dist/social.js ADDED
@@ -0,0 +1,2 @@
1
+ export { PREFERRED_CLIENT_COLLECTION, PREFERRED_CLIENT_RKEY, PREFERRED_CLIENT_SCOPE, socialClients, normalizeSocialClientUrl, parsePreferredSocialClient, preferredSocialClientRecord, socialClientInfo } from './social/preferred-client.js';
2
+ export { blueskyProfileUrl, socialProfileUrl, socialPostUrl, socialHashtagUrl } from './social/urls.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atmo-dev/kit",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "The shared UI foundation for Atmo apps.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -27,6 +27,11 @@
27
27
  "svelte": "./dist/index.js",
28
28
  "default": "./dist/index.js"
29
29
  },
30
+ "./social": {
31
+ "types": "./dist/social.d.ts",
32
+ "default": "./dist/social.js"
33
+ },
34
+ "./lexicons/social.atmo.actor.preferredClient.json": "./dist/lexicons/social.atmo.actor.preferredClient.json",
30
35
  "./legal": {
31
36
  "types": "./dist/legal.d.ts",
32
37
  "default": "./dist/legal.js"