@atmo-dev/kit 0.7.2 → 0.8.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.
@@ -0,0 +1,75 @@
1
+ import type { PreferredSocialClient } from '../social/preferred-client.js';
2
+ /**
3
+ * Parsed rich text. It is plain JSON, so it can be produced on the server and sent in page
4
+ * data. Renderers still treat it as untrusted and validate every node and destination.
5
+ */
6
+ export type RichTextDocument = {
7
+ version: 1;
8
+ /** Plain-text documents preserve whitespace; Markdown documents use explicit breaks. */
9
+ markdown: boolean;
10
+ children: RichTextBlock[];
11
+ };
12
+ export type RichTextBlock = {
13
+ type: 'paragraph';
14
+ children: RichTextInline[];
15
+ } | {
16
+ type: 'heading';
17
+ depth: 1 | 2 | 3 | 4 | 5 | 6;
18
+ children: RichTextInline[];
19
+ } | {
20
+ type: 'blockquote';
21
+ children: RichTextBlock[];
22
+ } | {
23
+ type: 'list';
24
+ ordered: boolean;
25
+ start?: number;
26
+ items: RichTextBlock[][];
27
+ } | {
28
+ type: 'codeBlock';
29
+ value: string;
30
+ } | {
31
+ type: 'thematicBreak';
32
+ };
33
+ export type RichTextInline = {
34
+ type: 'text';
35
+ value: string;
36
+ } | {
37
+ type: 'break';
38
+ } | {
39
+ type: 'strong' | 'emphasis' | 'delete';
40
+ children: RichTextInline[];
41
+ } | {
42
+ type: 'code';
43
+ value: string;
44
+ } | {
45
+ type: 'link';
46
+ url: string;
47
+ children: RichTextInline[];
48
+ }
49
+ /** A DID from a facet, or a handle detected in unfaceted text. */
50
+ | {
51
+ type: 'mention';
52
+ actor: string;
53
+ text: string;
54
+ } | {
55
+ type: 'tag';
56
+ tag: string;
57
+ text: string;
58
+ };
59
+ export type RichTextLinkOptions = {
60
+ socialClient?: PreferredSocialClient | null;
61
+ /** App-owned mention destination; defaults to the selected social client. */
62
+ mentionUrl?: (identifier: string) => string;
63
+ };
64
+ export declare function safeWebUrl(url: unknown): string | undefined;
65
+ /** Only app-relative paths or HTTP(S), including for app-owned mention callbacks. */
66
+ export declare function mentionHref(actor: unknown, options: RichTextLinkOptions): string | undefined;
67
+ export declare function tagHref(tag: unknown, options: RichTextLinkOptions): string | undefined;
68
+ /** Validated destination of a link, mention or tag node; undefined for anything else. */
69
+ export declare function inlineHref(node: unknown, options: RichTextLinkOptions): string | undefined;
70
+ /** Documents from the parser nest far less; deeper (tampered) nodes are cut off. */
71
+ export declare const MAX_RENDER_DEPTH = 32;
72
+ /** Text content of any node, including unknown ones. */
73
+ export declare function inlineText(node: Record<string, unknown>, depth?: number): string;
74
+ /** Readable text without Markdown syntax, for meta descriptions, previews and search. */
75
+ export declare function richTextToPlainText(content: RichTextDocument): string;
@@ -0,0 +1,105 @@
1
+ import { isActorIdentifier } from '@atcute/lexicons/syntax';
2
+ import { socialHashtagUrl, socialProfileUrl } from '../social/urls.js';
3
+ export function safeWebUrl(url) {
4
+ if (typeof url !== 'string' || !url)
5
+ return undefined;
6
+ try {
7
+ const parsed = new URL(url);
8
+ return ['https:', 'http:'].includes(parsed.protocol) && !parsed.username && !parsed.password
9
+ ? parsed.href
10
+ : undefined;
11
+ }
12
+ catch {
13
+ return undefined;
14
+ }
15
+ }
16
+ /** Only app-relative paths or HTTP(S), including for app-owned mention callbacks. */
17
+ export function mentionHref(actor, options) {
18
+ if (typeof actor !== 'string' || !isActorIdentifier(actor))
19
+ return undefined;
20
+ const href = options.mentionUrl?.(actor) ?? socialProfileUrl(actor, options.socialClient);
21
+ if (typeof href !== 'string' ||
22
+ Array.from(href).some((char) => char === '\\' || char.charCodeAt(0) <= 0x20 || char.charCodeAt(0) === 0x7f))
23
+ return undefined;
24
+ if (href.startsWith('/') && !href.startsWith('//'))
25
+ return href;
26
+ return safeWebUrl(href);
27
+ }
28
+ export function tagHref(tag, options) {
29
+ if (typeof tag !== 'string' || !tag)
30
+ return undefined;
31
+ try {
32
+ return socialHashtagUrl(tag, options.socialClient);
33
+ }
34
+ catch {
35
+ // Lone surrogates cannot be URI-encoded.
36
+ return undefined;
37
+ }
38
+ }
39
+ /** Validated destination of a link, mention or tag node; undefined for anything else. */
40
+ export function inlineHref(node, options) {
41
+ if (node === null || typeof node !== 'object')
42
+ return undefined;
43
+ const value = node;
44
+ if (value.type === 'link')
45
+ return safeWebUrl(value.url);
46
+ if (value.type === 'mention')
47
+ return mentionHref(value.actor, options);
48
+ if (value.type === 'tag')
49
+ return tagHref(value.tag, options);
50
+ }
51
+ function nodes(value) {
52
+ return Array.isArray(value)
53
+ ? value.filter((node) => node !== null && typeof node === 'object')
54
+ : [];
55
+ }
56
+ function string(value) {
57
+ return typeof value === 'string' ? value : '';
58
+ }
59
+ /** Documents from the parser nest far less; deeper (tampered) nodes are cut off. */
60
+ export const MAX_RENDER_DEPTH = 32;
61
+ /** Text content of any node, including unknown ones. */
62
+ export function inlineText(node, depth = 0) {
63
+ if (node.type === 'break')
64
+ return '\n';
65
+ if (depth > MAX_RENDER_DEPTH)
66
+ return '';
67
+ return (string(node.value) ||
68
+ string(node.text) ||
69
+ nodes(node.children)
70
+ .map((child) => inlineText(child, depth + 1))
71
+ .join(''));
72
+ }
73
+ function blockText(node, depth) {
74
+ if (depth > MAX_RENDER_DEPTH)
75
+ return '';
76
+ switch (node.type) {
77
+ case 'list':
78
+ return nodes(node.items)
79
+ .map((item, index) => {
80
+ const marker = node.ordered
81
+ ? `${(typeof node.start === 'number' ? node.start : 1) + index}. `
82
+ : '- ';
83
+ return marker + blocksText(item, '\n', depth + 1);
84
+ })
85
+ .join('\n');
86
+ case 'blockquote':
87
+ return blocksText(node.children, '\n\n', depth + 1);
88
+ case 'thematicBreak':
89
+ return '';
90
+ case 'codeBlock':
91
+ return string(node.value);
92
+ default:
93
+ return inlineText(node, depth);
94
+ }
95
+ }
96
+ function blocksText(value, separator = '\n\n', depth = 0) {
97
+ return nodes(value)
98
+ .map((block) => blockText(block, depth))
99
+ .filter(Boolean)
100
+ .join(separator);
101
+ }
102
+ /** Readable text without Markdown syntax, for meta descriptions, previews and search. */
103
+ export function richTextToPlainText(content) {
104
+ return blocksText(content?.children);
105
+ }
@@ -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 {};
@@ -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 { tokenize } from '@atcute/bluesky-richtext-parser';
1
+ import { detectTokens } from '../richtext/detect.js';
2
2
  import { safeWebUrl } from './richtext.js';
3
3
  import { socialHashtagUrl, socialProfileUrl } from './urls.js';
4
4
  /** Only web URLs; never accept executable schemes or disguised credentials. */
@@ -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;