@atmo-dev/kit 0.7.0 → 0.7.1
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 +22 -0
- package/dist/components/PostContent.svelte +3 -10
- package/dist/components/RichText.svelte +33 -0
- package/dist/components/RichText.svelte.d.ts +10 -0
- package/dist/events/EventView.svelte +12 -12
- package/dist/events/event-view/format.d.ts +0 -2
- package/dist/events/event-view/format.js +0 -74
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/social/richtext.d.ts +13 -3
- package/dist/social/richtext.js +70 -18
- package/package.json +1 -3
- package/dist/events/cal/sanitize.d.ts +0 -3
- package/dist/events/cal/sanitize.js +0 -25
package/README.md
CHANGED
|
@@ -121,6 +121,8 @@ 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.
|
|
125
|
+
|
|
124
126
|
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.
|
|
125
127
|
|
|
126
128
|
Apps implement `EditorAdapter` for authentication prompts, repository IO, uploads, save/delete navigation, and optional linked-record transport/indexing notifications. Keep sessions, Cloudflare bindings, app routes, server endpoints, caching and moderation in the app. The editor/view never imports a consumer's app state. Existing `EventView` page-data shape and RSVP behavior are preserved in this migration.
|
|
@@ -192,6 +194,26 @@ For now, `createGrainXrpcClient(fetchImpl?)` provides the Grain.social implement
|
|
|
192
194
|
|
|
193
195
|
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.
|
|
194
196
|
|
|
197
|
+
### Plain text and facets
|
|
198
|
+
|
|
199
|
+
`RichText` is the text renderer shared by posts and event descriptions:
|
|
200
|
+
|
|
201
|
+
```svelte
|
|
202
|
+
<script lang="ts">
|
|
203
|
+
import { RichText } from '@atmo-dev/kit';
|
|
204
|
+
</script>
|
|
205
|
+
|
|
206
|
+
<RichText
|
|
207
|
+
text={description}
|
|
208
|
+
{facets}
|
|
209
|
+
{socialClient}
|
|
210
|
+
mentionUrl={(actor) => `/p/${encodeURIComponent(actor)}`}
|
|
211
|
+
detectLinks
|
|
212
|
+
/>
|
|
213
|
+
```
|
|
214
|
+
|
|
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.
|
|
216
|
+
|
|
195
217
|
### Special link embeds
|
|
196
218
|
|
|
197
219
|
External links use an ordered provider registry. YouTube watch, short, live, embed, and `youtu.be` links get a click-to-load, privacy-enhanced player. No YouTube iframe or SDK is loaded before activation; the preview uses only the supplied AppView thumbnail. Klipy's `static.klipy.com/ii/...` media links render GIFs or looping, muted video using Bluesky's media proxy, preserving the GIF description as alt text. Klipy media autoplays and loops without native video controls or a caption row. A small corner-overlay pause/play button controls the animation; pausing video preserves its current frame. With reduced motion enabled, it stays on the preview until explicitly played. Ordinary Klipy page links without a usable media URL remain link cards; no metadata API or API key is needed. Unknown URLs and playback failures fall back to the original link card.
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
import { socialPostUrl } from '../social/urls.js';
|
|
6
6
|
import { focus } from '../styles.js';
|
|
7
7
|
import Icon from './Icon.svelte';
|
|
8
|
-
import {
|
|
8
|
+
import { safeWebUrl } from '../social/richtext.js';
|
|
9
|
+
import RichText from './RichText.svelte';
|
|
9
10
|
import type { ExternalEmbedProvider } from '../social/external-embeds.js';
|
|
10
11
|
import ExternalEmbed from './ExternalEmbed.svelte';
|
|
11
12
|
import PostVideo from './PostVideo.svelte';
|
|
@@ -28,15 +29,7 @@
|
|
|
28
29
|
};
|
|
29
30
|
</script>
|
|
30
31
|
|
|
31
|
-
{#if post.text}<
|
|
32
|
-
{#each richTextSegments(post.text, post.facets) as segment, index (index)}{#if segment.href}<a
|
|
33
|
-
href={segment.href}
|
|
34
|
-
target="_blank"
|
|
35
|
-
rel="noopener noreferrer nofollow"
|
|
36
|
-
class="text-action underline decoration-action/40 underline-offset-2 hover:decoration-action"
|
|
37
|
-
>{segment.text}</a
|
|
38
|
-
>{:else}{segment.text}{/if}{/each}
|
|
39
|
-
</p>{/if}
|
|
32
|
+
{#if post.text}<RichText text={post.text} facets={post.facets} class="text-sm" />{/if}
|
|
40
33
|
{#each post.embeds ?? [] as embed, index (index)}
|
|
41
34
|
<div class="mt-3">
|
|
42
35
|
{#if embed.type === 'external'}<ExternalEmbed {embed} providers={embedProviders} />
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { richTextSegments, type RichTextOptions } from '../social/richtext.js';
|
|
3
|
+
import { cn } from '../utils.js';
|
|
4
|
+
|
|
5
|
+
export type RichTextProps = RichTextOptions & {
|
|
6
|
+
text: string;
|
|
7
|
+
/** Public record data is validated at runtime, including malformed facets. */
|
|
8
|
+
facets?: unknown;
|
|
9
|
+
class?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
let {
|
|
13
|
+
text,
|
|
14
|
+
facets,
|
|
15
|
+
socialClient,
|
|
16
|
+
mentionUrl,
|
|
17
|
+
detectLinks = false,
|
|
18
|
+
class: className
|
|
19
|
+
}: RichTextProps = $props();
|
|
20
|
+
let segments = $derived(
|
|
21
|
+
richTextSegments(text, facets, { socialClient, mentionUrl, detectLinks })
|
|
22
|
+
);
|
|
23
|
+
</script>
|
|
24
|
+
|
|
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>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type RichTextOptions } from '../social/richtext.js';
|
|
2
|
+
export type RichTextProps = RichTextOptions & {
|
|
3
|
+
text: string;
|
|
4
|
+
/** Public record data is validated at runtime, including malformed facets. */
|
|
5
|
+
facets?: unknown;
|
|
6
|
+
class?: string;
|
|
7
|
+
};
|
|
8
|
+
declare const RichText: import("svelte").Component<RichTextProps, {}, "">;
|
|
9
|
+
type RichText = ReturnType<typeof RichText>;
|
|
10
|
+
export default RichText;
|
|
@@ -31,7 +31,8 @@
|
|
|
31
31
|
import { eventStreamLink, parseGrainLink } from './linked-records.js';
|
|
32
32
|
import AddToCalendarButton from './event-view/AddToCalendarButton.svelte';
|
|
33
33
|
import ExternalRsvpNotice from './event-view/ExternalRsvpNotice.svelte';
|
|
34
|
-
import
|
|
34
|
+
import RichText from '../components/RichText.svelte';
|
|
35
|
+
import { getLocalProfileUrl } from './profile-url.js';
|
|
35
36
|
import { getLocations, resolveGeoLocation, type GeoLocation } from './event-view/locations.js';
|
|
36
37
|
|
|
37
38
|
let {
|
|
@@ -108,13 +109,11 @@
|
|
|
108
109
|
let canSetEventComments = $state(false);
|
|
109
110
|
let isHost = $derived(!!viewer.did && viewer.did === did);
|
|
110
111
|
let hasComments = $derived(!!eventData.bskyPostRef?.showComments && !!eventData.bskyPostRef?.uri);
|
|
111
|
-
let
|
|
112
|
-
buildDescriptionHtml(eventData.description, eventData.facets, viewer.preferredSocialClient)
|
|
113
|
-
);
|
|
112
|
+
let hasDescription = $derived(!!eventData.description);
|
|
114
113
|
let aboutCommentsTab = $state<string>('about');
|
|
115
114
|
let photoLinks = $derived(eventData.uris?.filter((link) => parseGrainLink(link.uri)) ?? []);
|
|
116
115
|
let contentTabs = $derived([
|
|
117
|
-
...(
|
|
116
|
+
...(hasDescription ? [{ value: 'about', label: 'About' }] : []),
|
|
118
117
|
...(hasComments ? [{ value: 'comments', label: 'Comments' }] : []),
|
|
119
118
|
...(photoLinks.length ? [{ value: 'photos', label: 'Photos' }] : [])
|
|
120
119
|
]);
|
|
@@ -390,13 +389,14 @@
|
|
|
390
389
|
</p>
|
|
391
390
|
{/if}
|
|
392
391
|
{#if activeTab === 'about'}
|
|
393
|
-
<
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
392
|
+
<RichText
|
|
393
|
+
text={eventData.description ?? ''}
|
|
394
|
+
facets={eventData.facets}
|
|
395
|
+
mentionUrl={getLocalProfileUrl}
|
|
396
|
+
socialClient={viewer.preferredSocialClient}
|
|
397
|
+
detectLinks
|
|
398
|
+
class="text-base-700 dark:text-base-300"
|
|
399
|
+
/>
|
|
400
400
|
{:else if activeTab === 'comments' && eventData.bskyPostRef?.uri}
|
|
401
401
|
<EventComments
|
|
402
402
|
postUri={eventData.bskyPostRef.uri}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { type PreferredSocialClient } from '../../social.js';
|
|
2
1
|
export declare function formatMonth(date: Date): string;
|
|
3
2
|
export declare function formatDay(date: Date): number;
|
|
4
3
|
export declare function formatWeekday(date: Date): string;
|
|
@@ -7,4 +6,3 @@ export declare function formatTime(date: Date): string;
|
|
|
7
6
|
export declare function getModeLabel(mode: string): string;
|
|
8
7
|
/** Optional local accent class for an event mode. */
|
|
9
8
|
export declare function getModeColor(mode: string): string;
|
|
10
|
-
export declare function buildDescriptionHtml(description: string | undefined, facets: unknown, socialClient?: PreferredSocialClient | null): string | null;
|
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
import { marked } from 'marked';
|
|
2
|
-
import { tokenize } from '@atcute/bluesky-richtext-parser';
|
|
3
|
-
import { sanitize } from '../cal/sanitize.js';
|
|
4
|
-
import { getLocalProfileUrl } from '../profile-url.js';
|
|
5
|
-
import { socialHashtagUrl } from '../../social.js';
|
|
6
1
|
export function formatMonth(date) {
|
|
7
2
|
return date.toLocaleDateString('en-US', { month: 'short' }).toUpperCase();
|
|
8
3
|
}
|
|
@@ -41,72 +36,3 @@ export function getModeColor(mode) {
|
|
|
41
36
|
return 'amber';
|
|
42
37
|
return '';
|
|
43
38
|
}
|
|
44
|
-
const renderer = new marked.Renderer();
|
|
45
|
-
renderer.link = ({ href, text }) => {
|
|
46
|
-
const attributes = href.startsWith('/')
|
|
47
|
-
? ''
|
|
48
|
-
: ' target="_blank" rel="noopener noreferrer nofollow"';
|
|
49
|
-
const safeHref = href.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
50
|
-
return `<a${attributes} href="${safeHref}" class="text-accent-600 dark:text-accent-400 hover:underline">${text}</a>`;
|
|
51
|
-
};
|
|
52
|
-
function renderDescription(text, facets, socialClient) {
|
|
53
|
-
let result = text;
|
|
54
|
-
// Imported descriptions may have no facets. Keep existing Markdown links and
|
|
55
|
-
// code intact; only turn detected standalone mentions and hashtags into links.
|
|
56
|
-
if (!facets?.length) {
|
|
57
|
-
result = tokenize(text)
|
|
58
|
-
.map((token) => {
|
|
59
|
-
if (token.type === 'mention')
|
|
60
|
-
return `[${token.raw}](${getLocalProfileUrl(token.handle)})`;
|
|
61
|
-
if (token.type === 'topic')
|
|
62
|
-
return `[${token.raw}](<${socialHashtagUrl(token.name, socialClient)}>)`;
|
|
63
|
-
return token.raw;
|
|
64
|
-
})
|
|
65
|
-
.join('');
|
|
66
|
-
}
|
|
67
|
-
if (facets && facets.length > 0) {
|
|
68
|
-
const encoded = new TextEncoder().encode(text);
|
|
69
|
-
const decoder = new TextDecoder();
|
|
70
|
-
const sorted = [...facets].sort((a, b) => a.index.byteStart - b.index.byteStart);
|
|
71
|
-
const parts = [];
|
|
72
|
-
let cursor = 0;
|
|
73
|
-
for (const facet of sorted) {
|
|
74
|
-
const feature = facet.features?.[0];
|
|
75
|
-
if (!feature)
|
|
76
|
-
continue;
|
|
77
|
-
if (facet.index.byteStart < cursor)
|
|
78
|
-
continue;
|
|
79
|
-
const segmentText = decoder.decode(encoded.slice(facet.index.byteStart, facet.index.byteEnd));
|
|
80
|
-
let mdLink = null;
|
|
81
|
-
switch (feature.$type) {
|
|
82
|
-
case 'app.bsky.richtext.facet#mention': {
|
|
83
|
-
const handle = segmentText.startsWith('@') ? segmentText.slice(1) : segmentText;
|
|
84
|
-
mdLink = `[${segmentText}](${getLocalProfileUrl(feature.did || handle)})`;
|
|
85
|
-
break;
|
|
86
|
-
}
|
|
87
|
-
case 'app.bsky.richtext.facet#link':
|
|
88
|
-
mdLink = `[${segmentText}](${feature.uri})`;
|
|
89
|
-
break;
|
|
90
|
-
case 'app.bsky.richtext.facet#tag':
|
|
91
|
-
if (feature.tag)
|
|
92
|
-
mdLink = `[${segmentText}](<${socialHashtagUrl(feature.tag, socialClient)}>)`;
|
|
93
|
-
break;
|
|
94
|
-
}
|
|
95
|
-
if (mdLink) {
|
|
96
|
-
parts.push(decoder.decode(encoded.slice(cursor, facet.index.byteStart)));
|
|
97
|
-
parts.push(mdLink);
|
|
98
|
-
cursor = facet.index.byteEnd;
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
parts.push(decoder.decode(encoded.slice(cursor)));
|
|
102
|
-
result = parts.join('');
|
|
103
|
-
}
|
|
104
|
-
return marked.parse(result, { renderer });
|
|
105
|
-
}
|
|
106
|
-
export function buildDescriptionHtml(description, facets, socialClient) {
|
|
107
|
-
if (!description)
|
|
108
|
-
return null;
|
|
109
|
-
return sanitize(renderDescription(description, facets, socialClient), {
|
|
110
|
-
ADD_ATTR: ['target']
|
|
111
|
-
});
|
|
112
|
-
}
|
package/dist/index.d.ts
CHANGED
|
@@ -76,13 +76,15 @@ export { default as InlineEditor } from './components/InlineEditor.svelte';
|
|
|
76
76
|
export { default as PostComposer } from './components/PostComposer.svelte';
|
|
77
77
|
export { default as NestedComments } from './components/NestedComments.svelte';
|
|
78
78
|
export { default as PostContent } from './components/PostContent.svelte';
|
|
79
|
+
export { default as RichText } from './components/RichText.svelte';
|
|
80
|
+
export type { RichTextProps } from './components/RichText.svelte';
|
|
79
81
|
export { default as ExternalEmbed } from './components/ExternalEmbed.svelte';
|
|
80
82
|
export { defaultExternalEmbedProviders, youtubeEmbedProvider, klipyEmbedProvider, resolveExternalEmbed } from './social/external-embeds.js';
|
|
81
83
|
export type { ExternalPostEmbed, ExternalEmbedProvider, ExternalEmbedPlayer } from './social/external-embeds.js';
|
|
82
84
|
export { blueskyPostToPostData } from './social/posts.js';
|
|
83
85
|
export type { PostData, PostEmbed, BlueskyPostView } from './social/posts.js';
|
|
84
86
|
export { editorJsonToBlueskyPost, textToDoc, graphemeLength } from './social/richtext.js';
|
|
85
|
-
export type { Facet } from './social/richtext.js';
|
|
87
|
+
export type { Facet, RichTextOptions, RichTextSegment } from './social/richtext.js';
|
|
86
88
|
export type { Editor, JSONContent } from '@tiptap/core';
|
|
87
89
|
export { launchConfetti } from './confetti.js';
|
|
88
90
|
export { cn } from './utils.js';
|
package/dist/index.js
CHANGED
|
@@ -51,6 +51,7 @@ export { default as InlineEditor } from './components/InlineEditor.svelte';
|
|
|
51
51
|
export { default as PostComposer } from './components/PostComposer.svelte';
|
|
52
52
|
export { default as NestedComments } from './components/NestedComments.svelte';
|
|
53
53
|
export { default as PostContent } from './components/PostContent.svelte';
|
|
54
|
+
export { default as RichText } from './components/RichText.svelte';
|
|
54
55
|
export { default as ExternalEmbed } from './components/ExternalEmbed.svelte';
|
|
55
56
|
export { defaultExternalEmbedProviders, youtubeEmbedProvider, klipyEmbedProvider, resolveExternalEmbed } from './social/external-embeds.js';
|
|
56
57
|
export { blueskyPostToPostData } from './social/posts.js';
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { JSONContent } from '@tiptap/core';
|
|
2
|
+
import type { PreferredSocialClient } from './preferred-client.js';
|
|
2
3
|
export type Facet = {
|
|
3
4
|
index: {
|
|
4
5
|
byteStart: number;
|
|
@@ -11,15 +12,24 @@ export type Facet = {
|
|
|
11
12
|
tag?: string;
|
|
12
13
|
}[];
|
|
13
14
|
};
|
|
14
|
-
export declare function safeWebUrl(url:
|
|
15
|
+
export declare function safeWebUrl(url: unknown): string | undefined;
|
|
15
16
|
export declare function textToDoc(text: string): JSONContent;
|
|
16
17
|
/** Serialize mention nodes first, then detect links/tags using UTF-8 byte offsets. */
|
|
17
18
|
export declare function editorJsonToBlueskyPost(doc: JSONContent): {
|
|
18
19
|
text: string;
|
|
19
20
|
facets: Facet[];
|
|
20
21
|
};
|
|
21
|
-
export
|
|
22
|
+
export type RichTextOptions = {
|
|
23
|
+
socialClient?: PreferredSocialClient | null;
|
|
24
|
+
/** App-owned mention destination; defaults to the selected social client. */
|
|
25
|
+
mentionUrl?: (identifier: string) => string;
|
|
26
|
+
/** Detect plain-text links, mentions and hashtags only when there are no facets. */
|
|
27
|
+
detectLinks?: boolean;
|
|
28
|
+
};
|
|
29
|
+
export type RichTextSegment = {
|
|
22
30
|
text: string;
|
|
23
31
|
href?: string;
|
|
24
|
-
}
|
|
32
|
+
};
|
|
33
|
+
/** Plain text and validated links, never HTML or Markdown. Invalid facets leave text intact. */
|
|
34
|
+
export declare function richTextSegments(text: string, facets?: unknown, options?: RichTextOptions): RichTextSegment[];
|
|
25
35
|
export declare function graphemeLength(text: string): number;
|
package/dist/social/richtext.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { tokenize } from '@atcute/bluesky-richtext-parser';
|
|
2
|
-
import {
|
|
2
|
+
import { isDid } from '@atcute/lexicons/syntax';
|
|
3
|
+
import { socialHashtagUrl, socialProfileUrl } from './urls.js';
|
|
3
4
|
export function safeWebUrl(url) {
|
|
4
|
-
if (!url)
|
|
5
|
+
if (typeof url !== 'string' || !url)
|
|
5
6
|
return undefined;
|
|
6
7
|
try {
|
|
7
8
|
const parsed = new URL(url);
|
|
8
|
-
return ['https:', 'http:'].includes(parsed.protocol)
|
|
9
|
+
return ['https:', 'http:'].includes(parsed.protocol) && !parsed.username && !parsed.password
|
|
10
|
+
? parsed.href
|
|
11
|
+
: undefined;
|
|
9
12
|
}
|
|
10
13
|
catch {
|
|
11
14
|
return undefined;
|
|
@@ -67,27 +70,76 @@ export function editorJsonToBlueskyPost(doc) {
|
|
|
67
70
|
}
|
|
68
71
|
return { text, facets: facets.sort((a, b) => a.index.byteStart - b.index.byteStart) };
|
|
69
72
|
}
|
|
70
|
-
|
|
73
|
+
function record(value) {
|
|
74
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
75
|
+
}
|
|
76
|
+
/** Only app-relative paths or HTTP(S), including for app-owned mention callbacks. */
|
|
77
|
+
function safeMentionUrl(identifier, options) {
|
|
78
|
+
const href = options.mentionUrl?.(identifier) ?? socialProfileUrl(identifier, options.socialClient);
|
|
79
|
+
if (typeof href !== 'string' ||
|
|
80
|
+
Array.from(href).some((char) => char === '\\' || char.charCodeAt(0) <= 0x20 || char.charCodeAt(0) === 0x7f))
|
|
81
|
+
return undefined;
|
|
82
|
+
if (href.startsWith('/') && !href.startsWith('//'))
|
|
83
|
+
return href;
|
|
84
|
+
return safeWebUrl(href);
|
|
85
|
+
}
|
|
86
|
+
function featureUrl(feature, options) {
|
|
87
|
+
if (!record(feature))
|
|
88
|
+
return undefined;
|
|
89
|
+
switch (feature.$type) {
|
|
90
|
+
case 'app.bsky.richtext.facet#mention':
|
|
91
|
+
return typeof feature.did === 'string' && isDid(feature.did)
|
|
92
|
+
? safeMentionUrl(feature.did, options)
|
|
93
|
+
: undefined;
|
|
94
|
+
case 'app.bsky.richtext.facet#link':
|
|
95
|
+
return safeWebUrl(feature.uri);
|
|
96
|
+
case 'app.bsky.richtext.facet#tag':
|
|
97
|
+
return typeof feature.tag === 'string' && feature.tag && feature.tag.isWellFormed()
|
|
98
|
+
? socialHashtagUrl(feature.tag, options.socialClient)
|
|
99
|
+
: undefined;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** Plain text and validated links, never HTML or Markdown. Invalid facets leave text intact. */
|
|
103
|
+
export function richTextSegments(text, facets = [], options = {}) {
|
|
104
|
+
if ((!Array.isArray(facets) || !facets.length) && options.detectLinks) {
|
|
105
|
+
return Array.from(tokenize(text), (token) => ({
|
|
106
|
+
text: token.raw,
|
|
107
|
+
href: token.type === 'autolink'
|
|
108
|
+
? safeWebUrl(token.url)
|
|
109
|
+
: token.type === 'mention'
|
|
110
|
+
? safeMentionUrl(token.handle, options)
|
|
111
|
+
: token.type === 'topic'
|
|
112
|
+
? socialHashtagUrl(token.name, options.socialClient)
|
|
113
|
+
: undefined
|
|
114
|
+
}));
|
|
115
|
+
}
|
|
71
116
|
const encoded = new TextEncoder().encode(text);
|
|
72
117
|
const decoder = new TextDecoder();
|
|
73
118
|
const segments = [];
|
|
74
|
-
|
|
75
|
-
|
|
119
|
+
const valid = [];
|
|
120
|
+
// UTF-8 continuation bytes cannot start/end a facet.
|
|
121
|
+
const boundary = (offset) => offset === encoded.length || (encoded[offset] & 0xc0) !== 0x80;
|
|
122
|
+
for (const facet of Array.isArray(facets) ? facets : []) {
|
|
123
|
+
if (!record(facet) || !record(facet.index) || !Array.isArray(facet.features))
|
|
124
|
+
continue;
|
|
76
125
|
const { byteStart, byteEnd } = facet.index;
|
|
77
|
-
if (
|
|
78
|
-
|
|
79
|
-
byteStart
|
|
126
|
+
if (typeof byteStart !== 'number' ||
|
|
127
|
+
typeof byteEnd !== 'number' ||
|
|
128
|
+
!Number.isSafeInteger(byteStart) ||
|
|
129
|
+
!Number.isSafeInteger(byteEnd) ||
|
|
130
|
+
byteStart < 0 ||
|
|
80
131
|
byteEnd <= byteStart ||
|
|
81
|
-
byteEnd > encoded.length
|
|
132
|
+
byteEnd > encoded.length ||
|
|
133
|
+
!boundary(byteStart) ||
|
|
134
|
+
!boundary(byteEnd))
|
|
135
|
+
continue;
|
|
136
|
+
valid.push({ byteStart, byteEnd, features: facet.features });
|
|
137
|
+
}
|
|
138
|
+
let cursor = 0;
|
|
139
|
+
for (const { byteStart, byteEnd, features } of valid.sort((a, b) => a.byteStart - b.byteStart)) {
|
|
140
|
+
if (byteStart < cursor)
|
|
82
141
|
continue;
|
|
83
|
-
const
|
|
84
|
-
const href = feature?.did
|
|
85
|
-
? blueskyProfileUrl(feature.did)
|
|
86
|
-
: feature?.uri
|
|
87
|
-
? safeWebUrl(feature.uri)
|
|
88
|
-
: feature?.tag
|
|
89
|
-
? `https://bsky.app/hashtag/${encodeURIComponent(feature.tag)}`
|
|
90
|
-
: undefined;
|
|
142
|
+
const href = features.map((feature) => featureUrl(feature, options)).find(Boolean);
|
|
91
143
|
segments.push({ text: decoder.decode(encoded.slice(cursor, byteStart)) });
|
|
92
144
|
segments.push({ text: decoder.decode(encoded.slice(byteStart, byteEnd)), href });
|
|
93
145
|
cursor = byteEnd;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atmo-dev/kit",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "The shared UI foundation for Atmo apps.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -134,11 +134,9 @@
|
|
|
134
134
|
"@atcute/tid": "^1.1.4",
|
|
135
135
|
"@internationalized/date": "^3.12.4",
|
|
136
136
|
"@number-flow/svelte": "^0.4.2",
|
|
137
|
-
"dompurify": "^3.4.17",
|
|
138
137
|
"esm-env": "^1.2.2",
|
|
139
138
|
"h3-js": "^4.5.0",
|
|
140
139
|
"i18n-iso-countries": "^7.14.0",
|
|
141
|
-
"marked": "^18.1.2",
|
|
142
140
|
"svelte-boring-avatars": "^1.2.6",
|
|
143
141
|
"valibot": "^1.5.0",
|
|
144
142
|
"@atcute/lexicons": "^2.1.1",
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import { BROWSER } from 'esm-env';
|
|
2
|
-
// Lightweight regex-based sanitizer for SSR where DOMPurify is not available.
|
|
3
|
-
// Strips common XSS vectors.
|
|
4
|
-
function regexSanitize(html) {
|
|
5
|
-
return html
|
|
6
|
-
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script\s*>/gi, '')
|
|
7
|
-
.replace(/<iframe\b[^<]*(?:(?!<\/iframe>)<[^<]*)*<\/iframe\s*>/gi, '')
|
|
8
|
-
.replace(/<object\b[^<]*(?:(?!<\/object>)<[^<]*)*<\/object\s*>/gi, '')
|
|
9
|
-
.replace(/<embed\b[^>]*\/?>/gi, '')
|
|
10
|
-
.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style\s*>/gi, '')
|
|
11
|
-
.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '')
|
|
12
|
-
.replace(/href\s*=\s*["']?\s*javascript\s*:/gi, 'href="')
|
|
13
|
-
.replace(/src\s*=\s*["']?\s*javascript\s*:/gi, 'src="');
|
|
14
|
-
}
|
|
15
|
-
let _purify = null;
|
|
16
|
-
if (BROWSER) {
|
|
17
|
-
import('dompurify').then((mod) => {
|
|
18
|
-
_purify = (html, config) => mod.default.sanitize(html, config);
|
|
19
|
-
});
|
|
20
|
-
}
|
|
21
|
-
export function sanitize(dirty, config) {
|
|
22
|
-
if (_purify)
|
|
23
|
-
return _purify(dirty, config);
|
|
24
|
-
return regexSanitize(dirty);
|
|
25
|
-
}
|