@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
@@ -0,0 +1,113 @@
1
+ import { isDid } from '@atcute/lexicons/syntax';
2
+ import { safeWebUrl } from './document.js';
3
+ import { detectTokens } from './detect.js';
4
+ function record(value) {
5
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
6
+ }
7
+ function target(feature) {
8
+ if (!record(feature))
9
+ return undefined;
10
+ switch (feature.$type) {
11
+ case 'app.bsky.richtext.facet#mention':
12
+ return typeof feature.did === 'string' && isDid(feature.did)
13
+ ? { type: 'mention', actor: feature.did }
14
+ : undefined;
15
+ case 'app.bsky.richtext.facet#link': {
16
+ const url = safeWebUrl(feature.uri);
17
+ return url ? { type: 'link', url } : undefined;
18
+ }
19
+ case 'app.bsky.richtext.facet#tag':
20
+ return typeof feature.tag === 'string' && feature.tag
21
+ ? { type: 'tag', tag: feature.tag }
22
+ : undefined;
23
+ }
24
+ }
25
+ /** UTF-8 byte offset -> UTF-16 offset, for code point boundaries only. */
26
+ function byteOffsets(text) {
27
+ const offsets = new Map([[0, 0]]);
28
+ let bytes = 0;
29
+ for (let index = 0; index < text.length;) {
30
+ const code = text.codePointAt(index);
31
+ // Lone surrogates encode as U+FFFD (3 bytes).
32
+ bytes += code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0x10000 ? 3 : 4;
33
+ index += code > 0xffff ? 2 : 1;
34
+ offsets.set(bytes, index);
35
+ }
36
+ return offsets;
37
+ }
38
+ /**
39
+ * Untrusted record facets -> sorted, non-overlapping ranges with safe destinations.
40
+ * Malformed facets, unsupported features and ranges inside a code point are ignored.
41
+ */
42
+ export function resolveFacets(text, facets) {
43
+ if (!Array.isArray(facets) || !facets.length)
44
+ return [];
45
+ const offsets = byteOffsets(text);
46
+ const ranges = [];
47
+ for (const facet of facets) {
48
+ if (!record(facet) || !record(facet.index) || !Array.isArray(facet.features))
49
+ continue;
50
+ const { byteStart, byteEnd } = facet.index;
51
+ if (typeof byteStart !== 'number' || typeof byteEnd !== 'number')
52
+ continue;
53
+ const start = offsets.get(byteStart);
54
+ const end = offsets.get(byteEnd);
55
+ if (start === undefined || end === undefined || end <= start)
56
+ continue;
57
+ const resolved = facet.features.map(target).find(Boolean);
58
+ if (resolved)
59
+ ranges.push({ start, end, target: resolved });
60
+ }
61
+ ranges.sort((a, b) => a.start - b.start);
62
+ let cursor = 0;
63
+ return ranges.filter((range) => {
64
+ if (range.start < cursor)
65
+ return false;
66
+ cursor = range.end;
67
+ return true;
68
+ });
69
+ }
70
+ export function hasFacets(facets) {
71
+ return Array.isArray(facets) && facets.length > 0;
72
+ }
73
+ function facetNode(target, text) {
74
+ return target.type === 'link'
75
+ ? { type: 'link', url: target.url, children: [{ type: 'text', value: text }] }
76
+ : target.type === 'mention'
77
+ ? { type: 'mention', actor: target.actor, text }
78
+ : { type: 'tag', tag: target.tag, text };
79
+ }
80
+ /** Split text at ranges given in offsets of `text` itself. */
81
+ export function applyRanges(text, ranges) {
82
+ const result = [];
83
+ let cursor = 0;
84
+ for (const { start, end, target } of ranges) {
85
+ if (start > cursor)
86
+ result.push({ type: 'text', value: text.slice(cursor, start) });
87
+ result.push(facetNode(target, text.slice(start, end)));
88
+ cursor = end;
89
+ }
90
+ if (cursor < text.length || !result.length)
91
+ result.push({ type: 'text', value: text.slice(cursor) });
92
+ return result;
93
+ }
94
+ /** Plain-text URL, mention and hashtag detection for text without facets. */
95
+ export function detectLinks(text) {
96
+ const result = [];
97
+ for (const token of detectTokens(text)) {
98
+ const url = token.type === 'autolink' ? safeWebUrl(token.url) : undefined;
99
+ const node = url
100
+ ? { type: 'link', url, children: [{ type: 'text', value: token.raw }] }
101
+ : token.type === 'mention'
102
+ ? { type: 'mention', actor: token.handle, text: token.raw }
103
+ : token.type === 'topic'
104
+ ? { type: 'tag', tag: token.name, text: token.raw }
105
+ : { type: 'text', value: token.raw };
106
+ const previous = result.at(-1);
107
+ if (node.type === 'text' && previous?.type === 'text')
108
+ previous.value += node.value;
109
+ else
110
+ result.push(node);
111
+ }
112
+ return result;
113
+ }
@@ -0,0 +1,10 @@
1
+ import { type RichTextDocument } from './document.js';
2
+ import { type PlainTextOptions } from './plain.js';
3
+ /** Whether Markdown parsing could be slow; checked in linear time. */
4
+ export declare function exceedsMarkdownLimits(text: string): boolean;
5
+ /**
6
+ * CommonMark plus ~~strikethrough~~, with facets applied to the parsed text. Raw HTML is shown
7
+ * literally, images become links and only HTTP(S) destinations are kept. Text that exceeds
8
+ * the parser's limits is returned as a plain-text document.
9
+ */
10
+ export declare function parseMarkdown(text: string, options?: PlainTextOptions): RichTextDocument;
@@ -0,0 +1,236 @@
1
+ import { fromMarkdown } from 'mdast-util-from-markdown';
2
+ import { gfmStrikethrough } from 'micromark-extension-gfm-strikethrough';
3
+ import { gfmStrikethroughFromMarkdown } from 'mdast-util-gfm-strikethrough';
4
+ import { safeWebUrl } from './document.js';
5
+ import { applyRanges, detectLinks, hasFacets, resolveFacets } from './facets.js';
6
+ import { parsePlainText } from './plain.js';
7
+ // Deeper nesting is shown as literal source instead of recursing further.
8
+ const MAX_DEPTH = 16;
9
+ // micromark slows down quadratically with nested containers (thousands of `>` or `- ` on a
10
+ // line take seconds) and grows with delimiter runs, so long, delimiter-heavy or deeply nested
11
+ // text is rendered as plain text instead. Real descriptions stay far below these limits.
12
+ const MAX_LENGTH = 10_000;
13
+ const MAX_DELIMITERS = 1_000;
14
+ const MAX_LINE_CONTAINERS = 8;
15
+ const MAX_LIST_INDENT = 32;
16
+ const CONTAINER_RE = /[ \t]*(?:>|(?:[-*+]|\d{1,9}[.)])(?=[ \t]|$))/y;
17
+ const LIST_RE = /[ \t]*(?:[-*+]|\d{1,9}[.)])(?:[ \t]|$)/y;
18
+ /** Whether Markdown parsing could be slow; checked in linear time. */
19
+ export function exceedsMarkdownLimits(text) {
20
+ if (text.length > MAX_LENGTH)
21
+ return true;
22
+ let delimiters = 0;
23
+ for (let index = 0; index < text.length; index++) {
24
+ const char = text[index];
25
+ if ((char === '*' || char === '_' || char === '~' || char === '[') &&
26
+ ++delimiters > MAX_DELIMITERS)
27
+ return true;
28
+ }
29
+ for (const line of text.split('\n')) {
30
+ let containers = 0;
31
+ CONTAINER_RE.lastIndex = 0;
32
+ while (CONTAINER_RE.exec(line)) {
33
+ if (++containers > MAX_LINE_CONTAINERS)
34
+ return true;
35
+ }
36
+ const indent = /^[ \t]*/.exec(line)[0].replaceAll('\t', ' ').length;
37
+ LIST_RE.lastIndex = 0;
38
+ if (indent > MAX_LIST_INDENT && LIST_RE.test(line))
39
+ return true;
40
+ }
41
+ return false;
42
+ }
43
+ function literal(node, context) {
44
+ const start = node.position?.start.offset;
45
+ const end = node.position?.end.offset;
46
+ return start === undefined || end === undefined ? '' : context.source.slice(start, end);
47
+ }
48
+ /** Single newlines are line breaks, as in plain-text descriptions. */
49
+ function withBreaks(nodes) {
50
+ return nodes.flatMap((node) => node.type === 'text' && node.value.includes('\n')
51
+ ? node.value
52
+ .split('\n')
53
+ .flatMap((value, index) => [
54
+ ...(index ? [{ type: 'break' }] : []),
55
+ ...(value ? [{ type: 'text', value }] : [])
56
+ ])
57
+ : [node]);
58
+ }
59
+ /**
60
+ * Facets index the Markdown source; a text node's value can differ from its source because
61
+ * of escapes, entities or stripped indentation. Facets apply only where the facet's source
62
+ * text appears unchanged within a single text node.
63
+ */
64
+ function facetText(node, context) {
65
+ const start = node.position?.start.offset;
66
+ const end = node.position?.end.offset;
67
+ const value = node.value;
68
+ if (start === undefined || end === undefined)
69
+ return [{ type: 'text', value }];
70
+ const inside = context.ranges.filter((range) => range.start >= start && range.end <= end);
71
+ if (!inside.length)
72
+ return [{ type: 'text', value }];
73
+ const source = context.source.slice(start, end);
74
+ const map = [];
75
+ let cursor = 0;
76
+ for (let index = 0; index < source.length; index++) {
77
+ if (cursor < value.length && source[index] === value[cursor])
78
+ map[index] = cursor++;
79
+ }
80
+ if (cursor !== value.length)
81
+ return [{ type: 'text', value }];
82
+ const local = [];
83
+ for (const range of inside) {
84
+ const from = map[range.start - start];
85
+ const to = map[range.end - 1 - start];
86
+ if (from === undefined || to === undefined)
87
+ continue;
88
+ if (value.slice(from, to + 1) !== context.source.slice(range.start, range.end))
89
+ continue;
90
+ local.push({ start: from, end: to + 1, target: range.target });
91
+ }
92
+ return applyRanges(value, local);
93
+ }
94
+ function inline(nodes, context, depth, inLink = false) {
95
+ const result = [];
96
+ for (const node of nodes) {
97
+ if (depth > MAX_DEPTH && node.type !== 'text') {
98
+ result.push({ type: 'text', value: literal(node, context) });
99
+ continue;
100
+ }
101
+ switch (node.type) {
102
+ case 'text':
103
+ result.push(...(inLink
104
+ ? [{ type: 'text', value: node.value }]
105
+ : context.detect
106
+ ? detectLinks(node.value)
107
+ : facetText(node, context)));
108
+ break;
109
+ case 'emphasis':
110
+ case 'strong':
111
+ case 'delete':
112
+ result.push({
113
+ type: node.type,
114
+ children: inline(node.children, context, depth + 1, inLink)
115
+ });
116
+ break;
117
+ case 'inlineCode':
118
+ result.push({ type: 'code', value: node.value });
119
+ break;
120
+ case 'break':
121
+ result.push({ type: 'break' });
122
+ break;
123
+ case 'link':
124
+ case 'linkReference': {
125
+ const url = safeWebUrl(node.type === 'link' ? node.url : context.definitions.get(node.identifier)?.url);
126
+ const children = inline(node.children, context, depth + 1, true);
127
+ if (url && !inLink)
128
+ result.push({ type: 'link', url, children });
129
+ else
130
+ result.push(...children);
131
+ break;
132
+ }
133
+ case 'image':
134
+ case 'imageReference': {
135
+ // Images are never loaded; a safe image becomes a link labeled by its alt text.
136
+ const url = safeWebUrl(node.type === 'image' ? node.url : context.definitions.get(node.identifier)?.url);
137
+ const alt = node.alt ?? '';
138
+ if (url && !inLink)
139
+ result.push({ type: 'link', url, children: [{ type: 'text', value: alt || url }] });
140
+ else if (alt)
141
+ result.push({ type: 'text', value: alt });
142
+ break;
143
+ }
144
+ default:
145
+ // Raw HTML and anything unsupported is shown as written.
146
+ result.push({
147
+ type: 'text',
148
+ value: node.type === 'html' ? node.value : literal(node, context)
149
+ });
150
+ }
151
+ }
152
+ return withBreaks(result);
153
+ }
154
+ function blocks(nodes, context, depth) {
155
+ const result = [];
156
+ for (const node of nodes) {
157
+ if (depth > MAX_DEPTH) {
158
+ result.push({
159
+ type: 'paragraph',
160
+ children: withBreaks([{ type: 'text', value: literal(node, context) }])
161
+ });
162
+ continue;
163
+ }
164
+ switch (node.type) {
165
+ case 'paragraph':
166
+ result.push({ type: 'paragraph', children: inline(node.children, context, depth) });
167
+ break;
168
+ case 'heading':
169
+ result.push({
170
+ type: 'heading',
171
+ depth: node.depth,
172
+ children: inline(node.children, context, depth)
173
+ });
174
+ break;
175
+ case 'blockquote':
176
+ result.push({ type: 'blockquote', children: blocks(node.children, context, depth + 1) });
177
+ break;
178
+ case 'list':
179
+ result.push({
180
+ type: 'list',
181
+ ordered: !!node.ordered,
182
+ ...(node.ordered && typeof node.start === 'number' ? { start: node.start } : {}),
183
+ items: node.children.map((item) => blocks(item.children, context, depth + 1))
184
+ });
185
+ break;
186
+ case 'code':
187
+ result.push({ type: 'codeBlock', value: node.value });
188
+ break;
189
+ case 'thematicBreak':
190
+ result.push({ type: 'thematicBreak' });
191
+ break;
192
+ case 'definition':
193
+ break;
194
+ default:
195
+ result.push({
196
+ type: 'paragraph',
197
+ children: withBreaks([
198
+ { type: 'text', value: node.type === 'html' ? node.value : literal(node, context) }
199
+ ])
200
+ });
201
+ }
202
+ }
203
+ return result;
204
+ }
205
+ function collectDefinitions(node, definitions, depth = 0) {
206
+ if (node.type === 'definition') {
207
+ if (!definitions.has(node.identifier))
208
+ definitions.set(node.identifier, node);
209
+ }
210
+ else if ('children' in node && depth <= MAX_DEPTH + 1) {
211
+ for (const child of node.children)
212
+ collectDefinitions(child, definitions, depth + 1);
213
+ }
214
+ }
215
+ /**
216
+ * CommonMark plus ~~strikethrough~~, with facets applied to the parsed text. Raw HTML is shown
217
+ * literally, images become links and only HTTP(S) destinations are kept. Text that exceeds
218
+ * the parser's limits is returned as a plain-text document.
219
+ */
220
+ export function parseMarkdown(text, options = {}) {
221
+ if (exceedsMarkdownLimits(text))
222
+ return parsePlainText(text, options);
223
+ const tree = fromMarkdown(text, {
224
+ extensions: [gfmStrikethrough({ singleTilde: false })],
225
+ mdastExtensions: [gfmStrikethroughFromMarkdown()]
226
+ });
227
+ const definitions = new Map();
228
+ collectDefinitions(tree, definitions);
229
+ const context = {
230
+ source: text,
231
+ ranges: resolveFacets(text, options.facets),
232
+ detect: !hasFacets(options.facets) && !!options.detectLinks,
233
+ definitions
234
+ };
235
+ return { version: 1, markdown: true, children: blocks(tree.children, context, 0) };
236
+ }
@@ -0,0 +1,9 @@
1
+ import type { RichTextDocument } from './document.js';
2
+ export type PlainTextOptions = {
3
+ /** Public record facets; validated at runtime. */
4
+ facets?: unknown;
5
+ /** Detect plain-text links, mentions and hashtags only when there are no facets. */
6
+ detectLinks?: boolean;
7
+ };
8
+ /** Plain text with facet links. Small enough to run in the browser. */
9
+ export declare function parsePlainText(text: string, options?: PlainTextOptions): RichTextDocument;
@@ -0,0 +1,8 @@
1
+ import { applyRanges, detectLinks, hasFacets, resolveFacets } from './facets.js';
2
+ /** Plain text with facet links. Small enough to run in the browser. */
3
+ export function parsePlainText(text, options = {}) {
4
+ const children = !hasFacets(options.facets) && options.detectLinks
5
+ ? detectLinks(text)
6
+ : applyRanges(text, resolveFacets(text, options.facets));
7
+ return { version: 1, markdown: false, children: [{ type: 'paragraph', children }] };
8
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Rich text parsing without Svelte or a DOM. Parse on the server and pass the document to
3
+ * `RichText` so the Markdown parser stays out of client bundles.
4
+ */
5
+ import { parseMarkdown } from './richtext/markdown.js';
6
+ import { parsePlainText, type PlainTextOptions } from './richtext/plain.js';
7
+ import type { RichTextDocument } from './richtext/document.js';
8
+ export type RichTextParseOptions = PlainTextOptions & {
9
+ /** Parse CommonMark and ~~strikethrough~~. Otherwise, text is plain and whitespace is kept. */
10
+ markdown?: boolean;
11
+ };
12
+ export declare function parseRichText(text: string, options?: RichTextParseOptions): RichTextDocument;
13
+ export { parseMarkdown, parsePlainText };
14
+ export { richTextToPlainText, safeWebUrl } from './richtext/document.js';
15
+ export type { RichTextDocument, RichTextBlock, RichTextInline, RichTextLinkOptions } from './richtext/document.js';
16
+ export type { PlainTextOptions };
17
+ export { detectTokens, type DetectedToken } from './richtext/detect.js';
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Rich text parsing without Svelte or a DOM. Parse on the server and pass the document to
3
+ * `RichText` so the Markdown parser stays out of client bundles.
4
+ */
5
+ import { parseMarkdown } from './richtext/markdown.js';
6
+ import { parsePlainText } from './richtext/plain.js';
7
+ export function parseRichText(text, options = {}) {
8
+ return options.markdown ? parseMarkdown(text, options) : parsePlainText(text, options);
9
+ }
10
+ export { parseMarkdown, parsePlainText };
11
+ export { richTextToPlainText, safeWebUrl } from './richtext/document.js';
12
+ export { detectTokens } from './richtext/detect.js';
@@ -1,4 +1,4 @@
1
- import { safeWebUrl } from './richtext.js';
1
+ import { safeHttpUrl as safeWebUrl } from '../urls.js';
2
2
  function youtubeStart(value) {
3
3
  if (!value)
4
4
  return 0;
@@ -1,5 +1,5 @@
1
- import { tokenize } from '@atcute/bluesky-richtext-parser';
2
- import { safeWebUrl } from './richtext.js';
1
+ import { detectTokens } from '../richtext/detect.js';
2
+ import { safeHttpUrl as safeWebUrl } from '../urls.js';
3
3
  import { socialHashtagUrl, socialProfileUrl } from './urls.js';
4
4
  /** Only web URLs; never accept executable schemes or disguised credentials. */
5
5
  export function safeProfileUrl(value) {
@@ -13,7 +13,7 @@ export function safeProfileUrl(value) {
13
13
  return url.username || url.password ? undefined : href;
14
14
  }
15
15
  export function profileDescriptionSegments(text, options = {}) {
16
- return Array.from(tokenize(text), (token) => ({
16
+ return Array.from(detectTokens(text), (token) => ({
17
17
  text: token.raw,
18
18
  href: token.type === 'autolink'
19
19
  ? safeProfileUrl(token.url)
@@ -1,5 +1,6 @@
1
1
  import type { JSONContent } from '@tiptap/core';
2
- import type { PreferredSocialClient } from './preferred-client.js';
2
+ import type { RichTextLinkOptions } from '../richtext/document.js';
3
+ export { safeWebUrl } from '../richtext/document.js';
3
4
  export type Facet = {
4
5
  index: {
5
6
  byteStart: number;
@@ -12,24 +13,20 @@ export type Facet = {
12
13
  tag?: string;
13
14
  }[];
14
15
  };
15
- export declare function safeWebUrl(url: unknown): string | undefined;
16
16
  export declare function textToDoc(text: string): JSONContent;
17
17
  /** Serialize mention nodes first, then detect links/tags using UTF-8 byte offsets. */
18
18
  export declare function editorJsonToBlueskyPost(doc: JSONContent): {
19
19
  text: string;
20
20
  facets: Facet[];
21
21
  };
22
- export type RichTextOptions = {
23
- socialClient?: PreferredSocialClient | null;
24
- /** App-owned mention destination; defaults to the selected social client. */
25
- mentionUrl?: (identifier: string) => string;
22
+ /** @deprecated Use `RichTextLinkOptions` with `detectLinks` on `RichText` or `parseRichText`. */
23
+ export type RichTextOptions = RichTextLinkOptions & {
26
24
  /** Detect plain-text links, mentions and hashtags only when there are no facets. */
27
25
  detectLinks?: boolean;
28
26
  };
27
+ /** @deprecated `RichText` now renders a `RichTextDocument`. */
29
28
  export type RichTextSegment = {
30
29
  text: string;
31
30
  href?: string;
32
31
  };
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[];
35
32
  export declare function graphemeLength(text: string): number;
@@ -1,19 +1,5 @@
1
- import { tokenize } from '@atcute/bluesky-richtext-parser';
2
- import { isDid } from '@atcute/lexicons/syntax';
3
- import { socialHashtagUrl, socialProfileUrl } from './urls.js';
4
- export function safeWebUrl(url) {
5
- if (typeof url !== 'string' || !url)
6
- return undefined;
7
- try {
8
- const parsed = new URL(url);
9
- return ['https:', 'http:'].includes(parsed.protocol) && !parsed.username && !parsed.password
10
- ? parsed.href
11
- : undefined;
12
- }
13
- catch {
14
- return undefined;
15
- }
16
- }
1
+ import { detectTokens } from '../richtext/detect.js';
2
+ export { safeWebUrl } from '../richtext/document.js';
17
3
  export function textToDoc(text) {
18
4
  return {
19
5
  type: 'doc',
@@ -52,7 +38,7 @@ export function editorJsonToBlueskyPost(doc) {
52
38
  });
53
39
  const facets = [...mentions];
54
40
  let byteStart = 0;
55
- for (const token of tokenize(text)) {
41
+ for (const token of detectTokens(text)) {
56
42
  const byteEnd = byteStart + bytes(token.raw);
57
43
  if (!mentions.some((m) => byteStart < m.index.byteEnd && byteEnd > m.index.byteStart)) {
58
44
  if (token.type === 'autolink')
@@ -70,83 +56,6 @@ export function editorJsonToBlueskyPost(doc) {
70
56
  }
71
57
  return { text, facets: facets.sort((a, b) => a.index.byteStart - b.index.byteStart) };
72
58
  }
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
- }
116
- const encoded = new TextEncoder().encode(text);
117
- const decoder = new TextDecoder();
118
- const segments = [];
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;
125
- const { byteStart, byteEnd } = facet.index;
126
- if (typeof byteStart !== 'number' ||
127
- typeof byteEnd !== 'number' ||
128
- !Number.isSafeInteger(byteStart) ||
129
- !Number.isSafeInteger(byteEnd) ||
130
- byteStart < 0 ||
131
- byteEnd <= byteStart ||
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)
141
- continue;
142
- const href = features.map((feature) => featureUrl(feature, options)).find(Boolean);
143
- segments.push({ text: decoder.decode(encoded.slice(cursor, byteStart)) });
144
- segments.push({ text: decoder.decode(encoded.slice(byteStart, byteEnd)), href });
145
- cursor = byteEnd;
146
- }
147
- segments.push({ text: decoder.decode(encoded.slice(cursor)) });
148
- return segments;
149
- }
150
59
  export function graphemeLength(text) {
151
60
  return Array.from(new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(text))
152
61
  .length;
package/dist/urls.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ /** Untrusted external destinations: absolute HTTP(S) URLs without credentials.
2
+ * This validates browser destinations, not hosts for server-side fetching (SSRF).
3
+ */
4
+ export declare function safeHttpUrl(value: unknown): string | undefined;
5
+ /** Navigation also supports root-relative app paths, fragments and mail/phone actions.
6
+ * Use safeHttpUrl instead when a record field must contain an external website.
7
+ * Invalid destinations return undefined so renderers omit href rather than falling back to it.
8
+ */
9
+ export declare function safeLinkHref(value: unknown): string | undefined;
package/dist/urls.js ADDED
@@ -0,0 +1,39 @@
1
+ /** Untrusted external destinations: absolute HTTP(S) URLs without credentials.
2
+ * This validates browser destinations, not hosts for server-side fetching (SSRF).
3
+ */
4
+ export function safeHttpUrl(value) {
5
+ if (typeof value !== 'string' || !value)
6
+ return undefined;
7
+ try {
8
+ const url = new URL(value);
9
+ return (url.protocol === 'https:' || url.protocol === 'http:') && !url.username && !url.password
10
+ ? url.href
11
+ : undefined;
12
+ }
13
+ catch {
14
+ return undefined;
15
+ }
16
+ }
17
+ /** Navigation also supports root-relative app paths, fragments and mail/phone actions.
18
+ * Use safeHttpUrl instead when a record field must contain an external website.
19
+ * Invalid destinations return undefined so renderers omit href rather than falling back to it.
20
+ */
21
+ export function safeLinkHref(value) {
22
+ const http = safeHttpUrl(value);
23
+ if (http)
24
+ return http;
25
+ if (typeof value !== 'string' || !value)
26
+ return undefined;
27
+ // Never normalize an ambiguous local/contact link into a different destination.
28
+ if (Array.from(value).some((c) => c === '\\' || c.charCodeAt(0) <= 0x20 || c.charCodeAt(0) === 0x7f))
29
+ return undefined;
30
+ if (value.startsWith('#') || (value.startsWith('/') && !value.startsWith('//')))
31
+ return value;
32
+ try {
33
+ const url = new URL(value);
34
+ return url.protocol === 'mailto:' || url.protocol === 'tel:' ? url.href : undefined;
35
+ }
36
+ catch {
37
+ return undefined;
38
+ }
39
+ }