@atmo-dev/kit 0.7.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +102 -11
  2. package/dist/components/AccountMenu.svelte +102 -0
  3. package/dist/components/AccountMenu.svelte.d.ts +19 -0
  4. package/dist/components/Button.svelte +1 -1
  5. package/dist/components/ExternalEmbed.svelte +1 -1
  6. package/dist/components/LinkCard.svelte +1 -1
  7. package/dist/components/NestedComments.svelte +1 -1
  8. package/dist/components/PostContent.svelte +1 -1
  9. package/dist/components/PostVideo.svelte +1 -1
  10. package/dist/components/RichText.svelte +130 -17
  11. package/dist/components/RichText.svelte.d.ts +10 -3
  12. package/dist/components/legal/links.js +2 -9
  13. package/dist/events/EventComments.svelte +2 -1
  14. package/dist/events/EventView.svelte +10 -3
  15. package/dist/events/PostToBlueskyModal.svelte +4 -3
  16. package/dist/events/atproto-helpers.d.ts +6 -0
  17. package/dist/events/atproto-helpers.js +15 -1
  18. package/dist/events/conference.d.ts +3 -2
  19. package/dist/events/conference.js +4 -3
  20. package/dist/events/editor/save.d.ts +1 -1
  21. package/dist/events/editor/save.js +2 -2
  22. package/dist/events/event-view/EventLinksList.svelte +11 -2
  23. package/dist/events/event-view/ExternalRsvpNotice.svelte +20 -16
  24. package/dist/events/event-view/locations.js +1 -11
  25. package/dist/events/view-data.d.ts +9 -0
  26. package/dist/events-data.d.ts +1 -1
  27. package/dist/events-data.js +1 -1
  28. package/dist/index.d.ts +3 -0
  29. package/dist/index.js +2 -0
  30. package/dist/richtext/detect.d.ts +7 -0
  31. package/dist/richtext/detect.js +72 -0
  32. package/dist/richtext/document.d.ts +76 -0
  33. package/dist/richtext/document.js +95 -0
  34. package/dist/richtext/facets.d.ts +28 -0
  35. package/dist/richtext/facets.js +113 -0
  36. package/dist/richtext/markdown.d.ts +10 -0
  37. package/dist/richtext/markdown.js +236 -0
  38. package/dist/richtext/plain.d.ts +9 -0
  39. package/dist/richtext/plain.js +8 -0
  40. package/dist/richtext.d.ts +17 -0
  41. package/dist/richtext.js +12 -0
  42. package/dist/social/external-embeds.js +1 -1
  43. package/dist/social/profile.js +3 -3
  44. package/dist/social/richtext.d.ts +5 -8
  45. package/dist/social/richtext.js +3 -94
  46. package/dist/urls.d.ts +9 -0
  47. package/dist/urls.js +39 -0
  48. package/package.json +28 -16
@@ -2,8 +2,22 @@
2
2
  * Pure browser-safe atproto helpers used by the UI components. Does not depend
3
3
  * on any session/client state — those flow through the EditorAdapter.
4
4
  */
5
+ const services = {
6
+ appview: 'https://public.api.bsky.app',
7
+ cdn: 'https://cdn.bsky.app'
8
+ };
9
+ /** Configure shared event components before rendering on the server and in the browser. */
10
+ export function configureAtproto(options) {
11
+ if (options.appview)
12
+ services.appview = options.appview.replace(/\/+$/, '');
13
+ if (options.cdn)
14
+ services.cdn = options.cdn.replace(/\/+$/, '');
15
+ }
16
+ export function appviewUrl() {
17
+ return services.appview;
18
+ }
5
19
  export function getCDNImageBlobUrl({ did, blob, format = 'webp' }) {
6
- return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@${format}`;
20
+ return `${services.cdn}/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@${format}`;
7
21
  }
8
22
  export function compressImage(file, maxSize = 900 * 1024, maxDimension = 2048) {
9
23
  return new Promise((resolve, reject) => {
@@ -60,6 +60,7 @@ export declare function parseEventUri(uri: string): {
60
60
  /**
61
61
  * Normalize the talk records belonging to `conferenceUri` into ScheduleEvents,
62
62
  * sorted by start time. Records whose `parentEvent.uri` doesn't match are
63
- * dropped, so it's safe to pass an over-broad list.
63
+ * dropped, so it's safe to pass an over-broad list. `descriptions` replaces
64
+ * record descriptions, keyed by URI, e.g. with plain text from Markdown.
64
65
  */
65
- export declare function toScheduleEvents(events: FlatEventRecord[], conferenceUri: string): ScheduleEvent[];
66
+ export declare function toScheduleEvents(events: FlatEventRecord[], conferenceUri: string, descriptions?: Record<string, string>): ScheduleEvent[];
@@ -35,9 +35,10 @@ export function parseEventUri(uri) {
35
35
  /**
36
36
  * Normalize the talk records belonging to `conferenceUri` into ScheduleEvents,
37
37
  * sorted by start time. Records whose `parentEvent.uri` doesn't match are
38
- * dropped, so it's safe to pass an over-broad list.
38
+ * dropped, so it's safe to pass an over-broad list. `descriptions` replaces
39
+ * record descriptions, keyed by URI, e.g. with plain text from Markdown.
39
40
  */
40
- export function toScheduleEvents(events, conferenceUri) {
41
+ export function toScheduleEvents(events, conferenceUri, descriptions) {
41
42
  return events
42
43
  .filter((e) => getParentEventRef(e)?.uri === conferenceUri)
43
44
  .map((e) => {
@@ -50,7 +51,7 @@ export function toScheduleEvents(events, conferenceUri) {
50
51
  start: e.startsAt,
51
52
  end: e.endsAt,
52
53
  room: ad.room,
53
- description: e.description,
54
+ description: descriptions?.[e.uri] ?? e.description,
54
55
  did: e.did,
55
56
  uri: e.uri,
56
57
  cid: e.cid,
@@ -1,4 +1,4 @@
1
- import { type Token } from '@atcute/bluesky-richtext-parser';
1
+ import type { Token } from '@atcute/bluesky-richtext-parser';
2
2
  import type { Facet } from '../../index.js';
3
3
  import type { FlatEventRecord } from '../contrail.js';
4
4
  import type { EventTheme } from '../theme.js';
@@ -1,5 +1,5 @@
1
1
  import { compressImage } from '../atproto-helpers.js';
2
- import { tokenize } from '@atcute/bluesky-richtext-parser';
2
+ import { detectTokens } from '../../richtext/detect.js';
3
3
  import { datetimeLocalToISO } from '../date-format.js';
4
4
  import { designs, resolveAccentColor } from '../thumbnails/designs.js';
5
5
  import { normalizeEventLocations } from './location.js';
@@ -123,7 +123,7 @@ export async function buildEventRecord(args) {
123
123
  const trimmedDescription = description.trim();
124
124
  if (trimmedDescription) {
125
125
  record.description = trimmedDescription;
126
- const tokens = tokenize(trimmedDescription);
126
+ const tokens = detectTokens(trimmedDescription);
127
127
  const facets = await tokensToFacets(tokens, resolveHandle);
128
128
  if (facets.length > 0)
129
129
  record.facets = facets;
@@ -1,14 +1,23 @@
1
1
  <script lang="ts">
2
2
  import { Icon } from '../../index.js';
3
3
  import { Link } from '@sketchyicons/svelte';
4
+ import { safeHttpUrl } from '../../urls.js';
4
5
  let { uris = [] }: { uris?: Array<{ uri: string; name?: string }> } = $props();
6
+ let links = $derived(
7
+ uris.flatMap((link) => {
8
+ const uri = safeHttpUrl(link?.uri);
9
+ return uri ? [{ ...link, uri }] : [];
10
+ })
11
+ );
5
12
  </script>
6
13
 
7
- {#if uris.length}
14
+ {#if links.length}
8
15
  <div>
9
16
  <p class="mb-3 text-xs font-medium text-muted">Links</p>
10
17
  <div class="space-y-3">
11
- {#each uris as link (link.name + link.uri)}
18
+ <!-- These rows are stateless; equivalent URLs and exact duplicate entries are valid. -->
19
+ <!-- eslint-disable-next-line svelte/require-each-key -->
20
+ {#each links as link}
12
21
  <a
13
22
  href={link.uri}
14
23
  target="_blank"
@@ -1,34 +1,38 @@
1
1
  <script lang="ts">
2
2
  import { ArrowUpRight } from '@sketchyicons/svelte';
3
3
  import { Button, Icon } from '../../index.js';
4
+ import { safeHttpUrl } from '../../urls.js';
4
5
 
5
6
  // Shown in place of the RSVP controls for imported events whose host opted out
6
7
  // of atmo RSVPs (additionalData.externalSource.rsvpMode === 'external_only').
7
8
  // Directs attendees to the original event page instead.
8
9
  let { url }: { url: string } = $props();
9
10
 
11
+ let href = $derived(safeHttpUrl(url));
10
12
  let host = $derived.by(() => {
11
13
  try {
12
- return new URL(url).hostname.replace(/^www\./, '');
14
+ return new URL(href ?? '').hostname.replace(/^www\./, '');
13
15
  } catch {
14
16
  return null;
15
17
  }
16
18
  });
17
19
  </script>
18
20
 
19
- <div
20
- class="mt-8 mb-2 flex flex-col gap-3 rounded-2xl border border-base-200 bg-base-100 p-4 sm:flex-row sm:items-center sm:justify-between dark:border-base-800 dark:bg-base-950/50"
21
- >
22
- <div class="min-w-0">
23
- <p class="text-sm font-semibold text-base-900 dark:text-base-50">RSVP on the original page</p>
24
- <p class="mt-0.5 text-xs text-base-600 dark:text-base-400">
25
- {host
26
- ? `This event is hosted on ${host}. Head there to RSVP.`
27
- : 'This event is hosted elsewhere. Open the original page to RSVP.'}
28
- </p>
21
+ {#if href}
22
+ <div
23
+ class="mt-8 mb-2 flex flex-col gap-3 rounded-2xl border border-base-200 bg-base-100 p-4 sm:flex-row sm:items-center sm:justify-between dark:border-base-800 dark:bg-base-950/50"
24
+ >
25
+ <div class="min-w-0">
26
+ <p class="text-sm font-semibold text-base-900 dark:text-base-50">RSVP on the original page</p>
27
+ <p class="mt-0.5 text-xs text-base-600 dark:text-base-400">
28
+ {host
29
+ ? `This event is hosted on ${host}. Head there to RSVP.`
30
+ : 'This event is hosted elsewhere. Open the original page to RSVP.'}
31
+ </p>
32
+ </div>
33
+ <Button {href} target="_blank" rel="noopener noreferrer" class="shrink-0">
34
+ {host ? `RSVP on ${host}` : 'Open original event'}
35
+ <Icon img={ArrowUpRight} size={16} class="shrink-0" />
36
+ </Button>
29
37
  </div>
30
- <Button href={url} target="_blank" rel="noopener noreferrer" class="shrink-0">
31
- {host ? `RSVP on ${host}` : 'Open original event'}
32
- <Icon img={ArrowUpRight} size={16} class="shrink-0" />
33
- </Button>
34
- </div>
38
+ {/if}
@@ -1,3 +1,4 @@
1
+ import { safeHttpUrl as webUrl } from '../../urls.js';
1
2
  function text(record, key) {
2
3
  const value = record[key];
3
4
  return typeof value === 'string' ? value.trim() || undefined : undefined;
@@ -20,17 +21,6 @@ function coordinateLabel({ lat, lng }) {
20
21
  function mapsUrl(query) {
21
22
  return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`;
22
23
  }
23
- function webUrl(uri) {
24
- try {
25
- const url = new URL(uri);
26
- if (url.protocol === 'https:' || url.protocol === 'http:')
27
- return url.href;
28
- }
29
- catch {
30
- // Invalid URLs render as non-interactive location text.
31
- }
32
- return undefined;
33
- }
34
24
  function formatLocation(value) {
35
25
  if (!value || typeof value !== 'object')
36
26
  return null;
@@ -1,7 +1,14 @@
1
1
  import type { AttendeeInfo, FlatEventRecord, HostProfile } from './contrail.js';
2
+ import type { RichTextDocument } from '../richtext/document.js';
2
3
  /** App-loaded data for EventView; fetching and authorization stay in the host. */
3
4
  export interface EventViewData {
4
5
  eventData: FlatEventRecord;
6
+ /**
7
+ * `eventData.description` parsed on the server, usually with
8
+ * `parseRichText(description, { facets, markdown: true, detectLinks: true })`.
9
+ * Without it, the description renders as plain text.
10
+ */
11
+ description?: RichTextDocument | null;
5
12
  actorDid: string;
6
13
  rkey: string;
7
14
  hostProfile?: HostProfile | null;
@@ -27,6 +34,8 @@ export interface EventViewData {
27
34
  playlistUrl: string;
28
35
  } | null;
29
36
  conferenceTalks?: FlatEventRecord[];
37
+ /** Plain-text talk descriptions keyed by talk URI, e.g. from `richTextToPlainText`. */
38
+ conferenceTalkDescriptions?: Record<string, string>;
30
39
  conferenceTimezone?: string;
31
40
  conferenceRsvpStatuses?: Record<string, string>;
32
41
  conferenceRsvpRkeys?: Record<string, string>;
@@ -10,6 +10,6 @@ export * from './events/linked-records.js';
10
10
  export * from './events/date-format.js';
11
11
  export * from './events/theme.js';
12
12
  export { getEventThumbnail, getEventExternalImage, type ExternalEventImage } from './events/event-media.js';
13
- export { getCDNImageBlobUrl, compressImage } from './events/atproto-helpers.js';
13
+ export { getCDNImageBlobUrl, compressImage, configureAtproto } from './events/atproto-helpers.js';
14
14
  export { getProfileUrl, getLocalProfileUrl } from './events/profile-url.js';
15
15
  export { normalizeCountryCode, normalizeEventLocations, LocationValidationError } from './events/editor/location.js';
@@ -4,6 +4,6 @@ export * from './events/linked-records.js';
4
4
  export * from './events/date-format.js';
5
5
  export * from './events/theme.js';
6
6
  export { getEventThumbnail, getEventExternalImage } from './events/event-media.js';
7
- export { getCDNImageBlobUrl, compressImage } from './events/atproto-helpers.js';
7
+ export { getCDNImageBlobUrl, compressImage, configureAtproto } from './events/atproto-helpers.js';
8
8
  export { getProfileUrl, getLocalProfileUrl } from './events/profile-url.js';
9
9
  export { normalizeCountryCode, normalizeEventLocations, LocationValidationError } from './events/editor/location.js';
package/dist/index.d.ts CHANGED
@@ -54,6 +54,7 @@ export type { IconNode, SketchyIconProps } from '@sketchyicons/svelte';
54
54
  export { default as IconRail } from './components/IconRail.svelte';
55
55
  export type { IconRailItem } from './components/IconRail.svelte';
56
56
  export { default as WorkspaceSidebar } from './components/WorkspaceSidebar.svelte';
57
+ export { default as AccountMenu } from './components/AccountMenu.svelte';
57
58
  export { default as LoginForm } from './components/login/LoginForm.svelte';
58
59
  export type { LoginFormProps } from './components/login/LoginForm.svelte';
59
60
  export { default as LoginDialog } from './components/login/LoginDialog.svelte';
@@ -76,8 +77,10 @@ export { default as InlineEditor } from './components/InlineEditor.svelte';
76
77
  export { default as PostComposer } from './components/PostComposer.svelte';
77
78
  export { default as NestedComments } from './components/NestedComments.svelte';
78
79
  export { default as PostContent } from './components/PostContent.svelte';
80
+ export { safeHttpUrl, safeLinkHref } from './urls.js';
79
81
  export { default as RichText } from './components/RichText.svelte';
80
82
  export type { RichTextProps } from './components/RichText.svelte';
83
+ export type { RichTextDocument, RichTextLinkOptions } from './richtext/document.js';
81
84
  export { default as ExternalEmbed } from './components/ExternalEmbed.svelte';
82
85
  export { defaultExternalEmbedProviders, youtubeEmbedProvider, klipyEmbedProvider, resolveExternalEmbed } from './social/external-embeds.js';
83
86
  export type { ExternalPostEmbed, ExternalEmbedProvider, ExternalEmbedPlayer } from './social/external-embeds.js';
package/dist/index.js CHANGED
@@ -34,6 +34,7 @@ export { default as Tabs } from './components/Tabs.svelte';
34
34
  export { default as Icon } from './components/Icon.svelte';
35
35
  export { default as IconRail } from './components/IconRail.svelte';
36
36
  export { default as WorkspaceSidebar } from './components/WorkspaceSidebar.svelte';
37
+ export { default as AccountMenu } from './components/AccountMenu.svelte';
37
38
  export { default as LoginForm } from './components/login/LoginForm.svelte';
38
39
  export { default as LoginDialog } from './components/login/LoginDialog.svelte';
39
40
  export { default as SignupPermissions } from './components/login/SignupPermissions.svelte';
@@ -51,6 +52,7 @@ export { default as InlineEditor } from './components/InlineEditor.svelte';
51
52
  export { default as PostComposer } from './components/PostComposer.svelte';
52
53
  export { default as NestedComments } from './components/NestedComments.svelte';
53
54
  export { default as PostContent } from './components/PostContent.svelte';
55
+ export { safeHttpUrl, safeLinkHref } from './urls.js';
54
56
  export { default as RichText } from './components/RichText.svelte';
55
57
  export { default as ExternalEmbed } from './components/ExternalEmbed.svelte';
56
58
  export { defaultExternalEmbedProviders, youtubeEmbedProvider, klipyEmbedProvider, resolveExternalEmbed } from './social/external-embeds.js';
@@ -0,0 +1,7 @@
1
+ import type { AutolinkToken, MentionToken, TextToken, TopicToken } from '@atcute/bluesky-richtext-parser';
2
+ export type DetectedToken = TextToken | MentionToken | TopicToken | AutolinkToken;
3
+ /**
4
+ * Plain-text URLs, mentions and hashtags, in linear time. Other text, including Markdown-like
5
+ * syntax, is returned as text tokens; joining every `raw` reproduces the input.
6
+ */
7
+ export declare function detectTokens(text: string): DetectedToken[];
@@ -0,0 +1,72 @@
1
+ // Mention, hashtag and URL rules from @atcute/bluesky-richtext-parser. Its `tokenize` also
2
+ // parses Markdown-like emphasis with super-linear backtracking (a few dozen `*` take seconds),
3
+ // so untrusted text is scanned here in one linear pass instead.
4
+ const MENTION_RE = /[@@]([a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*(?:\.[a-zA-Z]{2,}))($|\s|\p{P})/uy;
5
+ const TOPIC_RE = /(?:#(?!\ufe0f|\u20e3)|#)([\p{N}]*[\p{L}\p{M}\p{Pc}][\p{L}\p{M}\p{Pc}\p{N}]*)($|\s|\p{P})/uy;
6
+ const AUTOLINK_RE = /https?:\/\/\S+/y;
7
+ const ESCAPE_RE = /\\[^0-9A-Za-z\s]/y;
8
+ // Mentions and hashtags start a word: after whitespace, punctuation or formatting characters.
9
+ const BOUNDARY_RE = /[\s(){}/\\[\]\-|:;'".,=+*_~`]/;
10
+ function trimAutolink(url) {
11
+ let end = url.length;
12
+ while (end > 0 && '.,;'.includes(url[end - 1]))
13
+ end--;
14
+ if (end > 0 && url[end - 1] === ')' && url.lastIndexOf('(', end - 1) === -1)
15
+ end--;
16
+ return url.slice(0, end);
17
+ }
18
+ function match(re, text, index) {
19
+ re.lastIndex = index;
20
+ return re.exec(text);
21
+ }
22
+ /**
23
+ * Plain-text URLs, mentions and hashtags, in linear time. Other text, including Markdown-like
24
+ * syntax, is returned as text tokens; joining every `raw` reproduces the input.
25
+ */
26
+ export function detectTokens(text) {
27
+ const tokens = [];
28
+ let textStart = 0;
29
+ const flush = (end) => {
30
+ if (end > textStart) {
31
+ const raw = text.slice(textStart, end);
32
+ tokens.push({ type: 'text', raw, content: raw });
33
+ }
34
+ };
35
+ const push = (index, token) => {
36
+ flush(index);
37
+ tokens.push(token);
38
+ textStart = index + token.raw.length;
39
+ return textStart;
40
+ };
41
+ for (let index = 0; index < text.length;) {
42
+ const char = text[index];
43
+ const atWordStart = index === 0 || BOUNDARY_RE.test(text[index - 1]);
44
+ let found;
45
+ if (char === '\\' && (found = match(ESCAPE_RE, text, index))) {
46
+ index += found[0].length;
47
+ }
48
+ else if (char === 'h' && (found = match(AUTOLINK_RE, text, index))) {
49
+ const url = trimAutolink(found[0]);
50
+ index = url ? push(index, { type: 'autolink', raw: url, url }) : index + 1;
51
+ }
52
+ else if (atWordStart &&
53
+ (char === '@' || char === '@') &&
54
+ (found = match(MENTION_RE, text, index)) &&
55
+ found[2] !== '@') {
56
+ const raw = found[0].slice(0, found[0].length - found[2].length);
57
+ index = push(index, { type: 'mention', raw, handle: found[1] });
58
+ }
59
+ else if (atWordStart &&
60
+ (char === '#' || char === '#') &&
61
+ (found = match(TOPIC_RE, text, index)) &&
62
+ found[2] !== '#') {
63
+ const raw = found[0].slice(0, found[0].length - found[2].length);
64
+ index = push(index, { type: 'topic', raw, name: found[1] });
65
+ }
66
+ else {
67
+ index++;
68
+ }
69
+ }
70
+ flush(text.length);
71
+ return tokens;
72
+ }
@@ -0,0 +1,76 @@
1
+ import type { PreferredSocialClient } from '../social/preferred-client.js';
2
+ import { safeHttpUrl as safeWebUrl } from '../urls.js';
3
+ export { safeWebUrl };
4
+ /**
5
+ * Parsed rich text. It is plain JSON, so it can be produced on the server and sent in page
6
+ * data. Renderers still treat it as untrusted and validate every node and destination.
7
+ */
8
+ export type RichTextDocument = {
9
+ version: 1;
10
+ /** Plain-text documents preserve whitespace; Markdown documents use explicit breaks. */
11
+ markdown: boolean;
12
+ children: RichTextBlock[];
13
+ };
14
+ export type RichTextBlock = {
15
+ type: 'paragraph';
16
+ children: RichTextInline[];
17
+ } | {
18
+ type: 'heading';
19
+ depth: 1 | 2 | 3 | 4 | 5 | 6;
20
+ children: RichTextInline[];
21
+ } | {
22
+ type: 'blockquote';
23
+ children: RichTextBlock[];
24
+ } | {
25
+ type: 'list';
26
+ ordered: boolean;
27
+ start?: number;
28
+ items: RichTextBlock[][];
29
+ } | {
30
+ type: 'codeBlock';
31
+ value: string;
32
+ } | {
33
+ type: 'thematicBreak';
34
+ };
35
+ export type RichTextInline = {
36
+ type: 'text';
37
+ value: string;
38
+ } | {
39
+ type: 'break';
40
+ } | {
41
+ type: 'strong' | 'emphasis' | 'delete';
42
+ children: RichTextInline[];
43
+ } | {
44
+ type: 'code';
45
+ value: string;
46
+ } | {
47
+ type: 'link';
48
+ url: string;
49
+ children: RichTextInline[];
50
+ }
51
+ /** A DID from a facet, or a handle detected in unfaceted text. */
52
+ | {
53
+ type: 'mention';
54
+ actor: string;
55
+ text: string;
56
+ } | {
57
+ type: 'tag';
58
+ tag: string;
59
+ text: string;
60
+ };
61
+ export type RichTextLinkOptions = {
62
+ socialClient?: PreferredSocialClient | null;
63
+ /** App-owned mention destination; defaults to the selected social client. */
64
+ mentionUrl?: (identifier: string) => string;
65
+ };
66
+ /** Only app-relative paths or HTTP(S), including for app-owned mention callbacks. */
67
+ export declare function mentionHref(actor: unknown, options: RichTextLinkOptions): string | undefined;
68
+ export declare function tagHref(tag: unknown, options: RichTextLinkOptions): string | undefined;
69
+ /** Validated destination of a link, mention or tag node; undefined for anything else. */
70
+ export declare function inlineHref(node: unknown, options: RichTextLinkOptions): string | undefined;
71
+ /** Documents from the parser nest far less; deeper (tampered) nodes are cut off. */
72
+ export declare const MAX_RENDER_DEPTH = 32;
73
+ /** Text content of any node, including unknown ones. */
74
+ export declare function inlineText(node: Record<string, unknown>, depth?: number): string;
75
+ /** Readable text without Markdown syntax, for meta descriptions, previews and search. */
76
+ export declare function richTextToPlainText(content: RichTextDocument): string;
@@ -0,0 +1,95 @@
1
+ import { isActorIdentifier } from '@atcute/lexicons/syntax';
2
+ import { socialHashtagUrl, socialProfileUrl } from '../social/urls.js';
3
+ import { safeHttpUrl as safeWebUrl } from '../urls.js';
4
+ // Backwards-compatible export for existing richtext and social consumers.
5
+ export { safeWebUrl };
6
+ /** Only app-relative paths or HTTP(S), including for app-owned mention callbacks. */
7
+ export function mentionHref(actor, options) {
8
+ if (typeof actor !== 'string' || !isActorIdentifier(actor))
9
+ return undefined;
10
+ const href = options.mentionUrl?.(actor) ?? socialProfileUrl(actor, options.socialClient);
11
+ if (typeof href !== 'string' ||
12
+ Array.from(href).some((char) => char === '\\' || char.charCodeAt(0) <= 0x20 || char.charCodeAt(0) === 0x7f))
13
+ return undefined;
14
+ if (href.startsWith('/') && !href.startsWith('//'))
15
+ return href;
16
+ return safeWebUrl(href);
17
+ }
18
+ export function tagHref(tag, options) {
19
+ if (typeof tag !== 'string' || !tag)
20
+ return undefined;
21
+ try {
22
+ return socialHashtagUrl(tag, options.socialClient);
23
+ }
24
+ catch {
25
+ // Lone surrogates cannot be URI-encoded.
26
+ return undefined;
27
+ }
28
+ }
29
+ /** Validated destination of a link, mention or tag node; undefined for anything else. */
30
+ export function inlineHref(node, options) {
31
+ if (node === null || typeof node !== 'object')
32
+ return undefined;
33
+ const value = node;
34
+ if (value.type === 'link')
35
+ return safeWebUrl(value.url);
36
+ if (value.type === 'mention')
37
+ return mentionHref(value.actor, options);
38
+ if (value.type === 'tag')
39
+ return tagHref(value.tag, options);
40
+ }
41
+ function nodes(value) {
42
+ return Array.isArray(value)
43
+ ? value.filter((node) => node !== null && typeof node === 'object')
44
+ : [];
45
+ }
46
+ function string(value) {
47
+ return typeof value === 'string' ? value : '';
48
+ }
49
+ /** Documents from the parser nest far less; deeper (tampered) nodes are cut off. */
50
+ export const MAX_RENDER_DEPTH = 32;
51
+ /** Text content of any node, including unknown ones. */
52
+ export function inlineText(node, depth = 0) {
53
+ if (node.type === 'break')
54
+ return '\n';
55
+ if (depth > MAX_RENDER_DEPTH)
56
+ return '';
57
+ return (string(node.value) ||
58
+ string(node.text) ||
59
+ nodes(node.children)
60
+ .map((child) => inlineText(child, depth + 1))
61
+ .join(''));
62
+ }
63
+ function blockText(node, depth) {
64
+ if (depth > MAX_RENDER_DEPTH)
65
+ return '';
66
+ switch (node.type) {
67
+ case 'list':
68
+ return nodes(node.items)
69
+ .map((item, index) => {
70
+ const marker = node.ordered
71
+ ? `${(typeof node.start === 'number' ? node.start : 1) + index}. `
72
+ : '- ';
73
+ return marker + blocksText(item, '\n', depth + 1);
74
+ })
75
+ .join('\n');
76
+ case 'blockquote':
77
+ return blocksText(node.children, '\n\n', depth + 1);
78
+ case 'thematicBreak':
79
+ return '';
80
+ case 'codeBlock':
81
+ return string(node.value);
82
+ default:
83
+ return inlineText(node, depth);
84
+ }
85
+ }
86
+ function blocksText(value, separator = '\n\n', depth = 0) {
87
+ return nodes(value)
88
+ .map((block) => blockText(block, depth))
89
+ .filter(Boolean)
90
+ .join(separator);
91
+ }
92
+ /** Readable text without Markdown syntax, for meta descriptions, previews and search. */
93
+ export function richTextToPlainText(content) {
94
+ return blocksText(content?.children);
95
+ }
@@ -0,0 +1,28 @@
1
+ import { type RichTextInline } from './document.js';
2
+ type FacetTarget = {
3
+ type: 'link';
4
+ url: string;
5
+ } | {
6
+ type: 'mention';
7
+ actor: string;
8
+ } | {
9
+ type: 'tag';
10
+ tag: string;
11
+ };
12
+ /** A validated facet, in UTF-16 offsets of the source text. */
13
+ export type FacetRange = {
14
+ start: number;
15
+ end: number;
16
+ target: FacetTarget;
17
+ };
18
+ /**
19
+ * Untrusted record facets -> sorted, non-overlapping ranges with safe destinations.
20
+ * Malformed facets, unsupported features and ranges inside a code point are ignored.
21
+ */
22
+ export declare function resolveFacets(text: string, facets: unknown): FacetRange[];
23
+ export declare function hasFacets(facets: unknown): boolean;
24
+ /** Split text at ranges given in offsets of `text` itself. */
25
+ export declare function applyRanges(text: string, ranges: FacetRange[]): RichTextInline[];
26
+ /** Plain-text URL, mention and hashtag detection for text without facets. */
27
+ export declare function detectLinks(text: string): RichTextInline[];
28
+ export {};