beez-ui 0.6.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.
Files changed (127) hide show
  1. package/CHANGELOG.md +150 -96
  2. package/README.md +33 -6
  3. package/dist/components/account-menu.d.ts +53 -0
  4. package/dist/components/account-menu.js +405 -0
  5. package/dist/components/alert.js +1 -1
  6. package/dist/components/animated-collapse.d.ts +19 -0
  7. package/dist/components/animated-collapse.js +81 -0
  8. package/dist/components/animated-count.d.ts +13 -0
  9. package/dist/components/animated-count.js +105 -0
  10. package/dist/components/animated-list-item.d.ts +25 -0
  11. package/dist/components/animated-list-item.js +132 -0
  12. package/dist/components/avatar.d.ts +2 -1
  13. package/dist/components/avatar.js +4 -3
  14. package/dist/components/bouncing-dots-loader.d.ts +19 -0
  15. package/dist/components/bouncing-dots-loader.js +47 -0
  16. package/dist/components/calendar.js +102 -0
  17. package/dist/components/carousel.js +61 -48
  18. package/dist/components/checkbox.js +1 -1
  19. package/dist/components/confirm-delete-button.d.ts +29 -0
  20. package/dist/components/confirm-delete-button.js +254 -0
  21. package/dist/components/data-table.js +34 -4
  22. package/dist/components/dropdown-menu.js +5 -2
  23. package/dist/components/empty-state.d.ts +24 -0
  24. package/dist/components/empty-state.js +37 -0
  25. package/dist/components/error-state.d.ts +20 -0
  26. package/dist/components/error-state.js +145 -0
  27. package/dist/components/external-browser-handoff.d.ts +27 -0
  28. package/dist/components/external-browser-handoff.js +306 -0
  29. package/dist/components/file-upload.d.ts +78 -0
  30. package/dist/components/file-upload.js +678 -0
  31. package/dist/components/filter-query-bar.js +11 -6
  32. package/dist/components/form.d.ts +5 -0
  33. package/dist/components/form.js +11 -9
  34. package/dist/components/info-popover.d.ts +23 -0
  35. package/dist/components/info-popover.js +158 -0
  36. package/dist/components/input-group.js +1 -1
  37. package/dist/components/input.js +1 -1
  38. package/dist/components/month-calendar-header.d.ts +65 -0
  39. package/dist/components/month-calendar-header.js +337 -0
  40. package/dist/components/month-grid.d.ts +59 -0
  41. package/dist/components/month-grid.js +301 -0
  42. package/dist/components/notification-bell.d.ts +50 -0
  43. package/dist/components/notification-bell.js +338 -0
  44. package/dist/components/notification-panel.d.ts +56 -0
  45. package/dist/components/notification-panel.js +281 -0
  46. package/dist/components/open-in-browser-cta.d.ts +22 -0
  47. package/dist/components/open-in-browser-cta.js +61 -0
  48. package/dist/components/pagination.d.ts +1 -0
  49. package/dist/components/pagination.js +9 -4
  50. package/dist/components/presence-swap.d.ts +22 -0
  51. package/dist/components/presence-swap.js +162 -0
  52. package/dist/components/progress-ring.d.ts +20 -0
  53. package/dist/components/progress-ring.js +53 -0
  54. package/dist/components/pwa-update-control.d.ts +18 -0
  55. package/dist/components/pwa-update-control.js +119 -0
  56. package/dist/components/radio-group.js +1 -1
  57. package/dist/components/reaction-button.d.ts +26 -0
  58. package/dist/components/reaction-button.js +114 -0
  59. package/dist/components/rich-link-editor.d.ts +25 -0
  60. package/dist/components/rich-link-editor.js +320 -0
  61. package/dist/components/rich-markdown-content.d.ts +10 -0
  62. package/dist/components/rich-markdown-content.js +56 -0
  63. package/dist/components/rich-text-content.d.ts +19 -0
  64. package/dist/components/rich-text-content.js +26 -0
  65. package/dist/components/select.js +1 -1
  66. package/dist/components/sheet.js +12 -9
  67. package/dist/components/sidebar.js +9 -5
  68. package/dist/components/skeleton.d.ts +6 -0
  69. package/dist/components/skeleton.js +7 -1
  70. package/dist/components/switch.js +1 -1
  71. package/dist/components/textarea.js +1 -1
  72. package/dist/components/typing-animation.js +42 -15
  73. package/dist/emoji-picker.d.ts +27 -0
  74. package/dist/emoji-picker.js +283 -0
  75. package/dist/hooks/use-horizontal-swipe.d.ts +23 -0
  76. package/dist/hooks/use-horizontal-swipe.js +80 -0
  77. package/dist/hooks/use-is-hydrated.d.ts +6 -0
  78. package/dist/hooks/use-is-hydrated.js +20 -0
  79. package/dist/hooks/use-minute-clock.d.ts +23 -0
  80. package/dist/hooks/use-minute-clock.js +147 -0
  81. package/dist/hooks/use-month-transition-direction.d.ts +21 -0
  82. package/dist/hooks/use-month-transition-direction.js +110 -0
  83. package/dist/hooks/use-rich-link-editor.d.ts +55 -0
  84. package/dist/hooks/use-rich-link-editor.js +515 -0
  85. package/dist/hooks/use-viewer-time-zone.d.ts +6 -0
  86. package/dist/hooks/use-viewer-time-zone.js +20 -0
  87. package/dist/index.d.ts +44 -0
  88. package/dist/index.js +43 -0
  89. package/dist/lib/browser-clipboard.d.ts +13 -0
  90. package/dist/lib/browser-clipboard.js +48 -0
  91. package/dist/lib/browser-navigation.d.ts +21 -0
  92. package/dist/lib/browser-navigation.js +42 -0
  93. package/dist/lib/column-filter-value.d.ts +12 -0
  94. package/dist/lib/column-filter-value.js +52 -0
  95. package/dist/lib/file-acceptance.d.ts +43 -0
  96. package/dist/lib/file-acceptance.js +107 -0
  97. package/dist/lib/format-file-size.d.ts +16 -0
  98. package/dist/lib/format-file-size.js +34 -0
  99. package/dist/lib/fuzzy-search.d.ts +17 -0
  100. package/dist/lib/fuzzy-search.js +183 -0
  101. package/dist/lib/horizontal-swipe.d.ts +23 -0
  102. package/dist/lib/horizontal-swipe.js +29 -0
  103. package/dist/lib/in-app-browser.d.ts +35 -0
  104. package/dist/lib/in-app-browser.js +80 -0
  105. package/dist/lib/month-grid.d.ts +42 -0
  106. package/dist/lib/month-grid.js +74 -0
  107. package/dist/lib/name-initials.d.ts +7 -0
  108. package/dist/lib/name-initials.js +11 -0
  109. package/dist/lib/rich-text/link-markdown-constants.d.ts +147 -0
  110. package/dist/lib/rich-text/link-markdown-constants.js +458 -0
  111. package/dist/lib/rich-text/link-markdown-types.d.ts +53 -0
  112. package/dist/lib/rich-text/link-markdown-types.js +1 -0
  113. package/dist/lib/rich-text/link-markdown.d.ts +193 -0
  114. package/dist/lib/rich-text/link-markdown.js +592 -0
  115. package/dist/lib/rich-text/rich-markdown.d.ts +41 -0
  116. package/dist/lib/rich-text/rich-markdown.js +105 -0
  117. package/dist/lib/rich-text/rich-text-editor-dom.d.ts +26 -0
  118. package/dist/lib/rich-text/rich-text-editor-dom.js +79 -0
  119. package/dist/motion/glide-indicator.d.ts +37 -3
  120. package/dist/motion/glide-indicator.js +37 -3
  121. package/dist/motion/motion-slot.js +47 -18
  122. package/dist/motion/surface-keyframes.js +18 -6
  123. package/dist/motion/tokens.d.ts +41 -2
  124. package/dist/motion/tokens.js +40 -1
  125. package/dist/providers/beez-ui-provider.js +49 -17
  126. package/dist/styles.css +1 -1
  127. package/package.json +212 -2
@@ -0,0 +1,592 @@
1
+ /**
2
+ * Pure helpers for the rich-text link layer: URL normalization, markdown
3
+ * link (de)serialization, plain-text autolink parsing, and editor link
4
+ * bookkeeping. None of these touch the DOM, so they are unit-testable in
5
+ * isolation and shared by the renderer, the editor hook, and persistence.
6
+ */
7
+ import { KNOWN_TOP_LEVEL_DOMAINS, LINK_MARKDOWN_ALLOWED_PROTOCOL, LINK_MARKDOWN_ESCAPE_PATTERN, LINK_MARKDOWN_ESCAPE_VALUE, LINK_MARKDOWN_FORMAT, LINK_MARKDOWN_MATCH_GROUP, LINK_MARKDOWN_URL_PAREN, LINK_PATTERN, LINK_PROTOCOL_PREFIX, PREVIEW_LINK_KEY_SEPARATOR, RICH_LINK_KIND, RICH_PREVIEW_LINK_SOURCE, RICH_TEXT_EDITOR_TEXT, RICH_TEXT_EDITOR_WORD_DIRECTION, RICH_TEXT_SEGMENT_TYPE, TEXT_DIFF_FALLBACK_INDEX } from "./link-markdown-constants.js";
8
+ const EMAIL_LOCAL_PART_SEPARATOR = "@";
9
+ /** Separator between the labels of a host name (`sub.example.com`). */
10
+ const LINK_HOST_LABEL_SEPARATOR = ".";
11
+ /** A linkable host needs at least a domain label and a TLD label. */
12
+ const MINIMUM_HOST_LABELS = 2;
13
+ /**
14
+ * Normalizes a raw markdown URL into a safe absolute `http(s)` URL, or `null`
15
+ * when it is not a usable web link. Bare domains gain an `https://` prefix.
16
+ *
17
+ * A candidate that contains whitespace is returned as its parsed `href`, which
18
+ * percent-encodes those characters. The renderer's `LINK_PATTERN.markdown` URL
19
+ * group forbids whitespace, so persisting the raw candidate would serialize a
20
+ * `[text](url with space)` link that cannot be read back and renders as broken
21
+ * markdown. Whitespace-free candidates are returned verbatim to preserve their
22
+ * exact form (no trailing slash, stable synchronization checks).
23
+ *
24
+ * A scheme-less candidate that parses into userinfo (`john.doe@example.com`) is
25
+ * rejected: the `bareDomain` pattern allows `@` in its trailing class, so an
26
+ * email or literal slips through and prepending `https://` would yield
27
+ * `https://john.doe@example.com`, where the browser reads `john.doe` as
28
+ * credentials for `example.com`. That turns the value into a misleading
29
+ * outbound link, against the convention that email addresses are not linked.
30
+ * The check uses the parsed `username`/`password` rather than a naive `@` scan
31
+ * so a legitimate `@` past the authority (a path or query) still links.
32
+ */
33
+ export function normalizeMarkdownUrl(rawUrl) {
34
+ const trimmedUrl = rawUrl?.trim();
35
+ if (!trimmedUrl) {
36
+ return null;
37
+ }
38
+ const hasExplicitProtocol = LINK_PATTERN.protocolPrefix.test(trimmedUrl);
39
+ const candidateUrl = hasExplicitProtocol ? trimmedUrl : LINK_PATTERN.bareDomain.test(trimmedUrl) ? LINK_PROTOCOL_PREFIX.default + trimmedUrl : null;
40
+ if (!candidateUrl) {
41
+ return null;
42
+ }
43
+ try {
44
+ const url = new URL(candidateUrl);
45
+ if (url.protocol !== LINK_MARKDOWN_ALLOWED_PROTOCOL.http && url.protocol !== LINK_MARKDOWN_ALLOWED_PROTOCOL.https) {
46
+ return null;
47
+ }
48
+ if (!hasExplicitProtocol && (url.username || url.password)) {
49
+ return null;
50
+ }
51
+ return LINK_PATTERN.containsWhitespace.test(candidateUrl) ? url.href : candidateUrl;
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+ /**
57
+ * Extracts the top-level domain (the last dot-separated label of the host) from
58
+ * a bare or scheme-prefixed candidate, lowercased, or `null` when the candidate
59
+ * has no host label past a dot. The host ends at the first path, query,
60
+ * fragment, or port boundary so a path segment is never mistaken for the TLD.
61
+ */
62
+ export function getCandidateTopLevelDomain(candidate) {
63
+ const host = candidate.replace(LINK_PATTERN.protocolPrefix, "").split(LINK_PATTERN.hostBoundary)[0];
64
+ const labels = host.split(LINK_HOST_LABEL_SEPARATOR);
65
+ if (labels.length < MINIMUM_HOST_LABELS) {
66
+ return null;
67
+ }
68
+ return labels[labels.length - 1].toLowerCase() || null;
69
+ }
70
+ /**
71
+ * Normalizes a candidate for the automatic detection path. Scheme-prefixed URLs
72
+ * are trusted and normalized as-is; a scheme-less bare domain is only accepted
73
+ * when its TLD is a recognized public suffix, so prose abbreviations such as
74
+ * `EE.UU.` or `China.Por` stay plain text instead of resolving to bogus hosts.
75
+ * Explicit paste-as-link flows keep using `normalizeMarkdownUrl` directly and
76
+ * stay permissive.
77
+ */
78
+ export function normalizeAutolinkUrl(candidate) {
79
+ if (LINK_PATTERN.protocolPrefix.test(candidate)) {
80
+ return normalizeMarkdownUrl(candidate);
81
+ }
82
+ const topLevelDomain = getCandidateTopLevelDomain(candidate);
83
+ if (!topLevelDomain || !KNOWN_TOP_LEVEL_DOMAINS.has(topLevelDomain)) {
84
+ return null;
85
+ }
86
+ return normalizeMarkdownUrl(candidate);
87
+ }
88
+ /** Strips the `http(s)://` prefix from a value for synchronization checks. */
89
+ export function removeLinkProtocolPrefix(value) {
90
+ return value.trim().replace(LINK_PATTERN.protocolPrefix, "");
91
+ }
92
+ /** Whether a link's visible text matches its URL ignoring the protocol. */
93
+ export function isLinkSynchronized(text, url) {
94
+ return removeLinkProtocolPrefix(text) === removeLinkProtocolPrefix(url);
95
+ }
96
+ /** Returns the protocol prefix of a URL, defaulting to `https://`. */
97
+ export function getLinkProtocolPrefix(url) {
98
+ const protocolPrefix = url.match(LINK_PATTERN.protocolPrefix)?.[0];
99
+ return protocolPrefix ?? LINK_PROTOCOL_PREFIX.default;
100
+ }
101
+ /**
102
+ * Rebuilds the URL of a synchronized link after its visible text changed, so a
103
+ * link whose label is the URL keeps tracking the edited text.
104
+ */
105
+ export function getSynchronizedLinkUrl(text, currentUrl) {
106
+ const trimmedText = text.trim();
107
+ if (!trimmedText) {
108
+ return null;
109
+ }
110
+ return normalizeMarkdownUrl(LINK_PATTERN.protocolPrefix.test(trimmedText) ? trimmedText : getLinkProtocolPrefix(currentUrl) + trimmedText);
111
+ }
112
+ /** Whether the value contains only whitespace. */
113
+ export function isWhitespaceOnly(value) {
114
+ return LINK_PATTERN.whitespace.test(value);
115
+ }
116
+ /**
117
+ * Computes the range a word-wise deletion (Ctrl/Alt+Backspace/Delete) should
118
+ * remove from a collapsed caret, mirroring native word deletion.
119
+ */
120
+ export function getWordDeletionRange(input) {
121
+ if (input.selectionRange.start !== input.selectionRange.end) {
122
+ return input.selectionRange;
123
+ }
124
+ if (input.direction === RICH_TEXT_EDITOR_WORD_DIRECTION.forward) {
125
+ let end = input.selectionRange.end;
126
+ while (end < input.text.length && isWhitespaceOnly(input.text[end])) {
127
+ end += 1;
128
+ }
129
+ while (end < input.text.length && !isWhitespaceOnly(input.text[end])) {
130
+ end += 1;
131
+ }
132
+ return {
133
+ end,
134
+ start: input.selectionRange.start
135
+ };
136
+ }
137
+ let start = input.selectionRange.start;
138
+ while (start > 0 && isWhitespaceOnly(input.text[start - 1])) {
139
+ start -= 1;
140
+ }
141
+ while (start > 0 && !isWhitespaceOnly(input.text[start - 1])) {
142
+ start -= 1;
143
+ }
144
+ return {
145
+ end: input.selectionRange.end,
146
+ start
147
+ };
148
+ }
149
+ /**
150
+ * Finds the index of the `)` that closes a balanced single-level paren pair
151
+ * opened at `openIndex`, or `null` when the run cannot form one the markdown URL
152
+ * group accepts. A pair is balanced only when nothing but non-paren,
153
+ * non-whitespace characters sit between the parens, mirroring the `\([^()\s]*\)`
154
+ * alternative in `LINK_PATTERN.markdown`.
155
+ */
156
+ function findBalancedParenCloseIndex(url, openIndex) {
157
+ for (let index = openIndex + 1; index < url.length; index += 1) {
158
+ const character = url[index];
159
+ if (character === LINK_MARKDOWN_URL_PAREN.close) {
160
+ return index;
161
+ }
162
+ if (character === LINK_MARKDOWN_URL_PAREN.open || isWhitespaceOnly(character)) {
163
+ return null;
164
+ }
165
+ }
166
+ return null;
167
+ }
168
+ /**
169
+ * Percent-encodes the parentheses a URL cannot keep literal inside a
170
+ * `[text](url)` target, so an explicit link whose URL contains an unmatched or
171
+ * nested `(`/`)` (for example `https://example.com/a)b`) serializes to markdown
172
+ * that `LINK_PATTERN.markdown` reads back as the same link instead of truncating
173
+ * the target at the stray paren and spilling the rest into plain text.
174
+ *
175
+ * The URL group only matches balanced single-level `(...)` pairs, so those are
176
+ * preserved verbatim (keeping links like `.../Foo_(bar)` intact); every other
177
+ * paren becomes `%28`/`%29`, which is decoded back to the literal character when
178
+ * the link is opened.
179
+ */
180
+ export function escapeMarkdownLinkUrl(url) {
181
+ let escapedUrl = "";
182
+ let index = 0;
183
+ while (index < url.length) {
184
+ const character = url[index];
185
+ if (character === LINK_MARKDOWN_URL_PAREN.open) {
186
+ const closeIndex = findBalancedParenCloseIndex(url, index);
187
+ if (closeIndex !== null) {
188
+ escapedUrl += url.slice(index, closeIndex + 1);
189
+ index = closeIndex + 1;
190
+ continue;
191
+ }
192
+ escapedUrl += LINK_MARKDOWN_URL_PAREN.encodedOpen;
193
+ index += 1;
194
+ continue;
195
+ }
196
+ if (character === LINK_MARKDOWN_URL_PAREN.close) {
197
+ escapedUrl += LINK_MARKDOWN_URL_PAREN.encodedClose;
198
+ index += 1;
199
+ continue;
200
+ }
201
+ escapedUrl += character;
202
+ index += 1;
203
+ }
204
+ return escapedUrl;
205
+ }
206
+ /** Builds a `[text](url)` markdown link, escaping the label and the URL. */
207
+ export function buildMarkdownLinkFromSelection(text, url) {
208
+ return LINK_MARKDOWN_FORMAT.openLabel + escapeMarkdownLinkText(text) + LINK_MARKDOWN_FORMAT.openUrl + escapeMarkdownLinkUrl(url) + LINK_MARKDOWN_FORMAT.closeUrl;
209
+ }
210
+ /** Escapes characters that would break a markdown link label. */
211
+ export function escapeMarkdownLinkText(text) {
212
+ return text.replace(LINK_MARKDOWN_ESCAPE_PATTERN.backslash, LINK_MARKDOWN_ESCAPE_VALUE.escapedBackslash).replace(LINK_MARKDOWN_ESCAPE_PATTERN.lineBreak, LINK_MARKDOWN_ESCAPE_VALUE.escapedLineBreak).replace(LINK_MARKDOWN_ESCAPE_PATTERN.openLabel, LINK_MARKDOWN_ESCAPE_VALUE.escapedOpenLabel).replace(LINK_MARKDOWN_ESCAPE_PATTERN.closeLabel, LINK_MARKDOWN_ESCAPE_VALUE.escapedCloseLabel);
213
+ }
214
+ /** Reverses `escapeMarkdownLinkText`, restoring line breaks and literals. */
215
+ export function unescapeMarkdownLinkText(text) {
216
+ let unescapedText = "";
217
+ for (let index = 0; index < text.length; index += 1) {
218
+ const currentCharacter = text[index];
219
+ const nextCharacter = text[index + 1];
220
+ if (currentCharacter === LINK_MARKDOWN_ESCAPE_VALUE.backslash && nextCharacter) {
221
+ unescapedText += nextCharacter === LINK_MARKDOWN_ESCAPE_VALUE.lineBreakToken ? RICH_TEXT_EDITOR_TEXT.lineBreak : nextCharacter;
222
+ index += 1;
223
+ } else {
224
+ unescapedText += currentCharacter;
225
+ }
226
+ }
227
+ return unescapedText;
228
+ }
229
+ /** Builds a stable React key for a preview link segment. */
230
+ export function buildPreviewLinkKey(segment) {
231
+ return [
232
+ segment.source,
233
+ segment.id ?? String(segment.start),
234
+ segment.url
235
+ ].join(PREVIEW_LINK_KEY_SEPARATOR.value);
236
+ }
237
+ /** Creates a plain-text rendered segment. */
238
+ export function createTextSegment(text) {
239
+ return {
240
+ text,
241
+ type: RICH_TEXT_SEGMENT_TYPE.text
242
+ };
243
+ }
244
+ /** Creates a link rendered segment. */
245
+ export function createLinkSegment(text, url) {
246
+ return {
247
+ text,
248
+ type: RICH_TEXT_SEGMENT_TYPE.link,
249
+ url
250
+ };
251
+ }
252
+ /** Creates a plain-text editor preview segment. */
253
+ export function createTextPreviewSegment(text) {
254
+ return {
255
+ text,
256
+ type: RICH_TEXT_SEGMENT_TYPE.text
257
+ };
258
+ }
259
+ /** Creates a link editor preview segment with a stable key. */
260
+ export function createLinkPreviewSegment(input) {
261
+ return {
262
+ ...input,
263
+ key: buildPreviewLinkKey(input),
264
+ type: RICH_TEXT_SEGMENT_TYPE.link
265
+ };
266
+ }
267
+ /** Splits a matched bare URL from any trailing sentence punctuation. */
268
+ export function splitBareUrlMatch(matchedUrl) {
269
+ const urlText = matchedUrl.replace(LINK_PATTERN.trailingPunctuation, "");
270
+ return {
271
+ trailingText: matchedUrl.slice(urlText.length),
272
+ urlText
273
+ };
274
+ }
275
+ /** Whether two `[start, end)` ranges overlap. */
276
+ export function rangesOverlap(firstRange, secondRange) {
277
+ return firstRange.start < secondRange.end && secondRange.start < firstRange.end;
278
+ }
279
+ /** Whether a matched bare URL is actually part of an email address. */
280
+ export function isBareUrlMatchInsideEmail(input) {
281
+ if (LINK_PATTERN.protocolPrefix.test(input.matchedUrl)) {
282
+ return false;
283
+ }
284
+ return input.matchedUrl.includes(EMAIL_LOCAL_PART_SEPARATOR) || input.matchedIndex > 0 && input.content[input.matchedIndex - 1] === EMAIL_LOCAL_PART_SEPARATOR;
285
+ }
286
+ /** Parses plain text into rendered segments, auto-linking bare URLs. */
287
+ export function parseBareUrlSegments(content) {
288
+ const segments = [];
289
+ let currentIndex = 0;
290
+ for (const match of content.matchAll(LINK_PATTERN.bareUrl)) {
291
+ const matchedUrl = match[0];
292
+ const { trailingText, urlText } = splitBareUrlMatch(matchedUrl);
293
+ const matchedIndex = match.index ?? 0;
294
+ const isEmailDomain = isBareUrlMatchInsideEmail({
295
+ content,
296
+ matchedIndex,
297
+ matchedUrl
298
+ });
299
+ const safeUrl = normalizeAutolinkUrl(urlText);
300
+ if (matchedIndex > currentIndex) {
301
+ segments.push(createTextSegment(content.slice(currentIndex, matchedIndex)));
302
+ }
303
+ segments.push(safeUrl && !isEmailDomain ? createLinkSegment(urlText, safeUrl) : createTextSegment(urlText));
304
+ if (trailingText) {
305
+ segments.push(createTextSegment(trailingText));
306
+ }
307
+ currentIndex = matchedIndex + matchedUrl.length;
308
+ }
309
+ if (currentIndex < content.length) {
310
+ segments.push(createTextSegment(content.slice(currentIndex)));
311
+ }
312
+ return segments;
313
+ }
314
+ /** Parses plain text into editor preview segments, auto-linking bare URLs. */
315
+ export function parseBareUrlPreviewSegments(content, offset, suppressedLinks) {
316
+ const segments = [];
317
+ let currentIndex = 0;
318
+ for (const match of content.matchAll(LINK_PATTERN.bareUrl)) {
319
+ const matchedUrl = match[0];
320
+ const { trailingText, urlText } = splitBareUrlMatch(matchedUrl);
321
+ const matchedIndex = match.index ?? 0;
322
+ const absoluteStart = offset + matchedIndex;
323
+ const absoluteEnd = absoluteStart + urlText.length;
324
+ const isEmailDomain = isBareUrlMatchInsideEmail({
325
+ content,
326
+ matchedIndex,
327
+ matchedUrl
328
+ });
329
+ const safeUrl = normalizeAutolinkUrl(urlText);
330
+ const isSuppressed = suppressedLinks.some((suppressedLink) => rangesOverlap(suppressedLink, {
331
+ end: absoluteEnd,
332
+ start: absoluteStart
333
+ }));
334
+ if (matchedIndex > currentIndex) {
335
+ segments.push(createTextPreviewSegment(content.slice(currentIndex, matchedIndex)));
336
+ }
337
+ segments.push(safeUrl && !isEmailDomain && !isSuppressed ? createLinkPreviewSegment({
338
+ end: absoluteEnd,
339
+ source: RICH_PREVIEW_LINK_SOURCE.automatic,
340
+ start: absoluteStart,
341
+ text: urlText,
342
+ url: safeUrl
343
+ }) : createTextPreviewSegment(urlText));
344
+ if (trailingText) {
345
+ segments.push(createTextPreviewSegment(trailingText));
346
+ }
347
+ currentIndex = matchedIndex + matchedUrl.length;
348
+ }
349
+ if (currentIndex < content.length) {
350
+ segments.push(createTextPreviewSegment(content.slice(currentIndex)));
351
+ }
352
+ return segments;
353
+ }
354
+ /**
355
+ * Parses the editor's plain-text content plus its tracked links into preview
356
+ * segments, layering explicit links over auto-detected bare URLs.
357
+ */
358
+ export function parsePreviewSegments(content, links) {
359
+ const segments = [];
360
+ const visibleLinks = links.filter((link) => link.end > link.start).sort((firstLink, secondLink) => firstLink.start - secondLink.start);
361
+ const suppressedLinks = visibleLinks.filter((link) => link.kind === RICH_LINK_KIND.suppressed);
362
+ let currentIndex = 0;
363
+ visibleLinks.forEach((link) => {
364
+ if (link.start < currentIndex) {
365
+ return;
366
+ }
367
+ if (link.start > currentIndex) {
368
+ segments.push(...parseBareUrlPreviewSegments(content.slice(currentIndex, link.start), currentIndex, suppressedLinks));
369
+ }
370
+ const text = content.slice(link.start, link.end);
371
+ segments.push(link.kind === RICH_LINK_KIND.explicit ? createLinkPreviewSegment({
372
+ end: link.end,
373
+ id: link.id,
374
+ source: RICH_PREVIEW_LINK_SOURCE.explicit,
375
+ start: link.start,
376
+ text,
377
+ url: link.url
378
+ }) : createTextPreviewSegment(text));
379
+ currentIndex = link.end;
380
+ });
381
+ if (currentIndex < content.length) {
382
+ segments.push(...parseBareUrlPreviewSegments(content.slice(currentIndex), currentIndex, suppressedLinks));
383
+ }
384
+ return segments;
385
+ }
386
+ /**
387
+ * Whether a markdown link whose URL is not a safe web link is a genuine
388
+ * suppressed autolink (`[some-url.com](#)`) produced by the editor, rather than
389
+ * literal text that merely looks like a markdown link (`[PDF](pendiente)`).
390
+ *
391
+ * Suppression only applies when the label itself is a normalizable URL, which
392
+ * mirrors `serializeEditorContent`. Any other `[text](url)` whose URL cannot be
393
+ * normalized is treated as authored plain text and preserved verbatim.
394
+ */
395
+ export function isSuppressedAutolinkMarkdown(linkText, linkUrl) {
396
+ return linkUrl === LINK_MARKDOWN_FORMAT.suppressedUrl && normalizeMarkdownUrl(linkText) !== null;
397
+ }
398
+ /**
399
+ * Parses persisted content (markdown links plus bare URLs) into rendered
400
+ * segments for read-only display.
401
+ */
402
+ export function parseRichTextSegments(content) {
403
+ const segments = [];
404
+ let currentIndex = 0;
405
+ for (const match of content.matchAll(LINK_PATTERN.markdown)) {
406
+ const matchedMarkdown = match[0];
407
+ const linkText = unescapeMarkdownLinkText(match[LINK_MARKDOWN_MATCH_GROUP.text] ?? "");
408
+ const linkUrl = match[LINK_MARKDOWN_MATCH_GROUP.url] ?? "";
409
+ const matchedIndex = match.index ?? 0;
410
+ const safeUrl = normalizeMarkdownUrl(linkUrl);
411
+ if (matchedIndex > currentIndex) {
412
+ segments.push(...parseBareUrlSegments(content.slice(currentIndex, matchedIndex)));
413
+ }
414
+ segments.push(safeUrl ? createLinkSegment(linkText, safeUrl) : isSuppressedAutolinkMarkdown(linkText, linkUrl) ? createTextSegment(linkText) : createTextSegment(matchedMarkdown));
415
+ currentIndex = matchedIndex + matchedMarkdown.length;
416
+ }
417
+ if (currentIndex < content.length) {
418
+ segments.push(...parseBareUrlSegments(content.slice(currentIndex)));
419
+ }
420
+ return segments;
421
+ }
422
+ /**
423
+ * Converts persisted markdown into the editor's plain-text content plus the
424
+ * tracked links (explicit and suppressed) needed to re-render it for editing.
425
+ */
426
+ export function deserializeMarkdownForEditor(content) {
427
+ const links = [];
428
+ let displayContent = "";
429
+ let currentIndex = 0;
430
+ for (const match of content.matchAll(LINK_PATTERN.markdown)) {
431
+ const matchedMarkdown = match[0];
432
+ const linkText = unescapeMarkdownLinkText(match[LINK_MARKDOWN_MATCH_GROUP.text] ?? "");
433
+ const linkUrl = match[LINK_MARKDOWN_MATCH_GROUP.url] ?? "";
434
+ const matchedIndex = match.index ?? 0;
435
+ const safeUrl = normalizeMarkdownUrl(linkUrl);
436
+ displayContent += content.slice(currentIndex, matchedIndex);
437
+ if (safeUrl) {
438
+ const linkStart = displayContent.length;
439
+ displayContent += linkText;
440
+ links.push({
441
+ end: displayContent.length,
442
+ id: crypto.randomUUID(),
443
+ isSynced: isLinkSynchronized(linkText, safeUrl),
444
+ kind: RICH_LINK_KIND.explicit,
445
+ start: linkStart,
446
+ url: safeUrl
447
+ });
448
+ } else if (isSuppressedAutolinkMarkdown(linkText, linkUrl)) {
449
+ const linkStart = displayContent.length;
450
+ displayContent += linkText;
451
+ links.push({
452
+ end: displayContent.length,
453
+ id: crypto.randomUUID(),
454
+ kind: RICH_LINK_KIND.suppressed,
455
+ start: linkStart
456
+ });
457
+ } else {
458
+ displayContent += matchedMarkdown;
459
+ }
460
+ currentIndex = matchedIndex + matchedMarkdown.length;
461
+ }
462
+ displayContent += content.slice(currentIndex);
463
+ return {
464
+ content: displayContent,
465
+ links
466
+ };
467
+ }
468
+ /**
469
+ * Serializes the editor's plain-text content plus tracked links back into
470
+ * persisted markdown, emitting `[text](url)` for explicit links and
471
+ * `[text](#)` for suppressed auto-detected URLs.
472
+ */
473
+ export function serializeEditorContent(content, links) {
474
+ const persistedLinks = links.filter((link) => link.end > link.start).sort((firstLink, secondLink) => firstLink.start - secondLink.start);
475
+ let serializedContent = "";
476
+ let currentIndex = 0;
477
+ persistedLinks.forEach((link) => {
478
+ if (link.start < currentIndex) {
479
+ return;
480
+ }
481
+ serializedContent += content.slice(currentIndex, link.start);
482
+ serializedContent += buildMarkdownLinkFromSelection(content.slice(link.start, link.end), link.kind === RICH_LINK_KIND.explicit ? link.url : LINK_MARKDOWN_FORMAT.suppressedUrl);
483
+ currentIndex = link.end;
484
+ });
485
+ return serializedContent + content.slice(currentIndex);
486
+ }
487
+ /**
488
+ * Computes the minimal changed range between two strings: the common prefix
489
+ * end (`start`), the changed-region ends in each string, and the length delta.
490
+ */
491
+ export function getTextDiff(input) {
492
+ const minLength = Math.min(input.previousText.length, input.nextText.length);
493
+ let start = 0;
494
+ while (start < minLength && input.previousText[start] === input.nextText[start]) {
495
+ start += 1;
496
+ }
497
+ if (start === minLength && input.previousText.length === input.nextText.length) {
498
+ return {
499
+ delta: 0,
500
+ endInNextText: start,
501
+ endInPreviousText: start,
502
+ start: TEXT_DIFF_FALLBACK_INDEX.notFound
503
+ };
504
+ }
505
+ let previousEnd = input.previousText.length;
506
+ let nextEnd = input.nextText.length;
507
+ while (previousEnd > start && nextEnd > start && input.previousText[previousEnd - 1] === input.nextText[nextEnd - 1]) {
508
+ previousEnd -= 1;
509
+ nextEnd -= 1;
510
+ }
511
+ return {
512
+ delta: nextEnd - previousEnd,
513
+ endInNextText: nextEnd,
514
+ endInPreviousText: previousEnd,
515
+ start
516
+ };
517
+ }
518
+ /**
519
+ * Adjusts tracked link ranges after the editor's plain text changed, shifting,
520
+ * shrinking, or dropping links and keeping synchronized links in step with
521
+ * their visible text.
522
+ *
523
+ * A suppressed autolink is dropped once its visible text is no longer a
524
+ * normalizable URL, mirroring `isSuppressedAutolinkMarkdown`: with nothing left
525
+ * to suppress, the edited text serializes as plain text instead of `[text](#)`,
526
+ * which would otherwise be rendered back as literal markdown.
527
+ */
528
+ export function getLinksAfterTextChange(input) {
529
+ const diff = getTextDiff({
530
+ nextText: input.nextText,
531
+ previousText: input.previousText
532
+ });
533
+ if (diff.start === TEXT_DIFF_FALLBACK_INDEX.notFound) {
534
+ return input.links;
535
+ }
536
+ const insertedText = input.nextText.slice(diff.start, diff.endInNextText);
537
+ const deletedText = input.previousText.slice(diff.start, diff.endInPreviousText);
538
+ return input.links.map((link) => {
539
+ if (diff.endInPreviousText <= link.start) {
540
+ return {
541
+ ...link,
542
+ end: link.end + diff.delta,
543
+ start: link.start + diff.delta
544
+ };
545
+ }
546
+ if (diff.start > link.end) {
547
+ return link;
548
+ }
549
+ if (diff.start === link.end && isWhitespaceOnly(insertedText)) {
550
+ return link;
551
+ }
552
+ // A change that begins exactly at the link's exclusive end boundary and
553
+ // removes text only deletes characters past the link, so the link's own
554
+ // text is untouched and must stay intact. This holds regardless of
555
+ // whether the removed text was whitespace; gating it on whitespace would
556
+ // wrongly shrink the link when a non-whitespace character right after it
557
+ // (for example deleting "X" from "abcX" where only "abc" is linked) is
558
+ // removed. Pure insertions still fall through so non-whitespace typed
559
+ // against the boundary can extend the link.
560
+ if (diff.start === link.end && deletedText.length > 0) {
561
+ return link;
562
+ }
563
+ // When the change starts before the link's start, it deleted part of the
564
+ // link's prefix, so the surviving link text now begins where the changed
565
+ // region ends in the new text. Clamping start there (instead of leaving
566
+ // the stale offset) keeps the link anchored to the remaining text and
567
+ // excludes any replacement text from the link.
568
+ const nextStart = diff.start < link.start ? diff.endInNextText : link.start;
569
+ // A deletion can start inside the link and run past its end. Only the
570
+ // removed characters that fell within the link should shrink it; the ones
571
+ // deleted beyond `link.end` were never part of the link, so adding them
572
+ // back to `diff.delta` keeps them from being subtracted from `link.end`.
573
+ const deletedLengthPastLinkEnd = Math.max(0, diff.endInPreviousText - link.end);
574
+ return {
575
+ ...link,
576
+ end: Math.max(nextStart, link.end + diff.delta + deletedLengthPastLinkEnd),
577
+ start: nextStart
578
+ };
579
+ }).filter((link) => link.end > link.start).filter((link) => link.kind !== RICH_LINK_KIND.suppressed || normalizeMarkdownUrl(input.nextText.slice(link.start, link.end)) !== null).map((link) => {
580
+ if (link.kind !== RICH_LINK_KIND.explicit) {
581
+ return link;
582
+ }
583
+ const linkText = input.nextText.slice(link.start, link.end);
584
+ const isSynced = link.isSynced || isLinkSynchronized(linkText, link.url);
585
+ const syncedUrl = isSynced ? getSynchronizedLinkUrl(linkText, link.url) : null;
586
+ return {
587
+ ...link,
588
+ isSynced: Boolean(syncedUrl),
589
+ url: syncedUrl ?? link.url
590
+ };
591
+ });
592
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Block-level markdown subset for long-form content such as descriptions and stories.
3
+ *
4
+ * It builds on top of the shared link parser (`parseRichTextSegments`) without
5
+ * touching it, adding paragraphs, unordered lists (`- item` / `* item`) and
6
+ * inline bold (`**text**`). Everything else stays plain text, so the renderer
7
+ * never interprets raw HTML.
8
+ */
9
+ export declare const RICH_MARKDOWN_BLOCK_TYPE: {
10
+ readonly list: "list";
11
+ readonly paragraph: "paragraph";
12
+ };
13
+ export declare const RICH_MARKDOWN_INLINE_TYPE: {
14
+ readonly bold: "bold";
15
+ readonly link: "link";
16
+ readonly text: "text";
17
+ };
18
+ export type RichMarkdownInlineSegment = {
19
+ text: string;
20
+ type: typeof RICH_MARKDOWN_INLINE_TYPE.bold;
21
+ } | {
22
+ text: string;
23
+ type: typeof RICH_MARKDOWN_INLINE_TYPE.text;
24
+ } | {
25
+ text: string;
26
+ type: typeof RICH_MARKDOWN_INLINE_TYPE.link;
27
+ url: string;
28
+ };
29
+ export type RichMarkdownBlock = {
30
+ segments: RichMarkdownInlineSegment[];
31
+ type: typeof RICH_MARKDOWN_BLOCK_TYPE.paragraph;
32
+ } | {
33
+ items: RichMarkdownInlineSegment[][];
34
+ type: typeof RICH_MARKDOWN_BLOCK_TYPE.list;
35
+ };
36
+ /**
37
+ * Flattens the parsed content into plain text (formatting stripped) and trims it
38
+ * to `maxLength`, for SEO descriptions and previews.
39
+ */
40
+ export declare function buildRichMarkdownExcerpt(content: string, maxLength: number): string;
41
+ export declare function parseRichMarkdownBlocks(content: string): RichMarkdownBlock[];