botframework-webchat-component 4.19.0 → 4.19.1-main.20260529.6bcfcee

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 (84) hide show
  1. package/dist/_dtsroll-chunks/CwOuXmoL-botframework-webchat-styles.react.d.ts +68 -0
  2. package/dist/botframework-webchat-component.component.d.mts +1 -1
  3. package/dist/botframework-webchat-component.component.d.ts +1 -1
  4. package/dist/botframework-webchat-component.component.js +1 -1
  5. package/dist/botframework-webchat-component.component.mjs +1 -1
  6. package/dist/botframework-webchat-component.d.mts +1 -3
  7. package/dist/botframework-webchat-component.d.ts +1 -3
  8. package/dist/botframework-webchat-component.decorator.js +1 -1
  9. package/dist/botframework-webchat-component.decorator.js.map +1 -1
  10. package/dist/botframework-webchat-component.decorator.mjs +1 -1
  11. package/dist/botframework-webchat-component.decorator.mjs.map +1 -1
  12. package/dist/botframework-webchat-component.hook.js +1 -1
  13. package/dist/botframework-webchat-component.hook.mjs +1 -1
  14. package/dist/botframework-webchat-component.internal.d.mts +2 -6
  15. package/dist/botframework-webchat-component.internal.d.ts +2 -6
  16. package/dist/botframework-webchat-component.internal.js +1 -1
  17. package/dist/botframework-webchat-component.internal.js.map +1 -1
  18. package/dist/botframework-webchat-component.internal.mjs +1 -1
  19. package/dist/botframework-webchat-component.js +1 -1
  20. package/dist/botframework-webchat-component.js.map +1 -1
  21. package/dist/botframework-webchat-component.mjs +1 -1
  22. package/dist/botframework-webchat-component.mjs.map +1 -1
  23. package/dist/chunk-4EA5WZBJ.mjs +80 -0
  24. package/dist/chunk-4EA5WZBJ.mjs.map +1 -0
  25. package/dist/{chunk-H5YR7OLF.js → chunk-B2XHGOQH.js} +2 -2
  26. package/dist/{chunk-H5YR7OLF.js.map → chunk-B2XHGOQH.js.map} +1 -1
  27. package/dist/chunk-C2RHHZZQ.mjs +2 -0
  28. package/dist/chunk-C2RHHZZQ.mjs.map +1 -0
  29. package/dist/{chunk-TYPS3H4I.mjs → chunk-FIPA3BLZ.mjs} +2 -2
  30. package/dist/{chunk-TYPS3H4I.mjs.map → chunk-FIPA3BLZ.mjs.map} +1 -1
  31. package/dist/{chunk-LVVCSDZ4.mjs → chunk-HWDSXHRJ.mjs} +2 -2
  32. package/dist/chunk-JCX7GSY7.js +2 -0
  33. package/dist/{chunk-2R7BJ63Z.js.map → chunk-JCX7GSY7.js.map} +1 -1
  34. package/dist/{chunk-U6OWCHTQ.js → chunk-K4QNZHM5.js} +2 -2
  35. package/dist/chunk-K4QNZHM5.js.map +1 -0
  36. package/dist/{chunk-MOJMHOVH.js → chunk-OJOV52AD.js} +2 -2
  37. package/dist/{chunk-MOJMHOVH.js.map → chunk-OJOV52AD.js.map} +1 -1
  38. package/dist/{chunk-GTOP3WPD.mjs → chunk-WPWJFSZC.mjs} +2 -2
  39. package/dist/chunk-WPWJFSZC.mjs.map +1 -0
  40. package/dist/chunk-ZUKHLDZN.js +80 -0
  41. package/dist/chunk-ZUKHLDZN.js.map +1 -0
  42. package/dist/{component-BtSxgJS5.d.mts → component-BeCWAilk.d.mts} +34 -43
  43. package/dist/{component-Fyy8iCRE.d.ts → component-BtDQyqSP.d.ts} +34 -43
  44. package/dist/metafile-cjs.json +1 -1
  45. package/dist/metafile-esm.json +1 -1
  46. package/package.json +12 -14
  47. package/src/ActivityStatus/SendStatus/private/SendFailedRetry.tsx +31 -0
  48. package/src/BasicToast.js +4 -5
  49. package/src/BasicToaster.js +2 -3
  50. package/src/Composer.tsx +0 -20
  51. package/src/Utils/InlineMarkdown.tsx +121 -0
  52. package/src/Utils/LocalizedString.tsx +123 -124
  53. package/src/Utils/addTargetBlankToHyperlinks.spec.ts +52 -0
  54. package/src/Utils/addTargetBlankToHyperlinks.ts +14 -0
  55. package/src/boot/internal.ts +7 -2
  56. package/src/hooks/internal/WebChatUIContext.ts +0 -2
  57. package/src/hooks/useRenderMarkdownAsHTML.ts +5 -3
  58. package/src/hooks/useStreamingMarkdownWithDefinitions.ts +2 -2
  59. package/src/private/renderMarkdownInline.ts +19 -0
  60. package/src/providers/CustomElements/customElements/CodeBlock.ts +2 -2
  61. package/dist/_dtsroll-chunks/Cha1SOtx-botframework-webchat-styles.react.d.ts +0 -38
  62. package/dist/chunk-2R7BJ63Z.js +0 -2
  63. package/dist/chunk-A4NDFSZM.mjs +0 -77
  64. package/dist/chunk-A4NDFSZM.mjs.map +0 -1
  65. package/dist/chunk-CNTMOACS.mjs +0 -2
  66. package/dist/chunk-CNTMOACS.mjs.map +0 -1
  67. package/dist/chunk-GTOP3WPD.mjs.map +0 -1
  68. package/dist/chunk-U6OWCHTQ.js.map +0 -1
  69. package/dist/chunk-VDF6GQAL.js +0 -77
  70. package/dist/chunk-VDF6GQAL.js.map +0 -1
  71. package/src/ActivityStatus/SendStatus/private/SendFailedRetry.js +0 -28
  72. package/src/Utils/InlineMarkdown.js +0 -154
  73. package/src/Utils/addTargetBlankToHyperlinksMarkdown.js +0 -28
  74. package/src/Utils/addTargetBlankToHyperlinksMarkdown.spec.js +0 -45
  75. package/src/Utils/betterLinks.ts +0 -157
  76. package/src/Utils/parseDocumentFragmentFromString.ts +0 -9
  77. package/src/Utils/serializeDocumentFragmentIntoString.ts +0 -3
  78. package/src/Utils/updateMarkdownAttrs.js +0 -10
  79. package/src/Utils/updateMarkdownAttrs.spec.js +0 -71
  80. package/src/Utils/walkMarkdownTokens.js +0 -15
  81. package/src/Utils/walkMarkdownTokens.spec.js +0 -18
  82. package/src/hooks/internal/useInternalMarkdownIt.js +0 -7
  83. package/src/hooks/internal/useInternalRenderMarkdownInline.js +0 -9
  84. /package/dist/{chunk-LVVCSDZ4.mjs.map → chunk-HWDSXHRJ.mjs.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "botframework-webchat-component",
3
- "version": "4.19.0",
3
+ "version": "4.19.1-main.20260529.6bcfcee",
4
4
  "description": "React component of botframework-webchat",
5
5
  "main": "./dist/botframework-webchat-component.js",
6
6
  "types": "./dist/botframework-webchat-component.d.ts",
@@ -126,7 +126,7 @@
126
126
  "precommit:eslint": "../../node_modules/.bin/eslint --report-unused-disable-directives --max-warnings 0",
127
127
  "precommit:typecheck": "tsc --project ./src --emitDeclarationOnly false --esModuleInterop true --noEmit --pretty false",
128
128
  "preversion": "../../scripts/npm/preversion.sh",
129
- "start": "../../scripts/npm/notify-build.sh \"src\" \"../react-hooks/package.json\" \"../react-valibot/package.json\" \"../styles/package.json\""
129
+ "start": "../../scripts/npm/notify-build.sh \"src\" \"../component-better-link/package.json\" \"../react-hooks/package.json\" \"../react-valibot/package.json\" \"../styles/package.json\""
130
130
  },
131
131
  "pinDependencies": {
132
132
  "@types/jest": [
@@ -141,10 +141,6 @@
141
141
  "1",
142
142
  "@>=2 does not support IE Mode"
143
143
  ],
144
- "markdown-it": [
145
- "13",
146
- "markdown-it@14.1.0 has module field and it is breaking Webpack 4 because cross loading CJS and ESM"
147
- ],
148
144
  "react": [
149
145
  "16.8.6",
150
146
  "using react@16.8.6 to make sure this is the minimum supported version"
@@ -166,6 +162,7 @@
166
162
  },
167
163
  "localDependencies": {
168
164
  "@msinternal/botframework-webchat-base": "development",
165
+ "@msinternal/botframework-webchat-component-better-link": "development",
169
166
  "@msinternal/botframework-webchat-react-hooks": "development",
170
167
  "@msinternal/botframework-webchat-react-valibot": "development",
171
168
  "@msinternal/botframework-webchat-styles": "development",
@@ -176,11 +173,12 @@
176
173
  "@babel/preset-env": "^7.29.2",
177
174
  "@babel/preset-react": "^7.28.5",
178
175
  "@babel/preset-typescript": "^7.28.5",
179
- "@msinternal/botframework-webchat-base": "4.19.0",
180
- "@msinternal/botframework-webchat-react-hooks": "4.19.0",
181
- "@msinternal/botframework-webchat-react-valibot": "4.19.0",
182
- "@msinternal/botframework-webchat-styles": "4.19.0",
183
- "@msinternal/botframework-webchat-tsconfig": "4.19.0",
176
+ "@msinternal/botframework-webchat-base": "4.19.1-main.20260529.6bcfcee",
177
+ "@msinternal/botframework-webchat-component-better-link": "4.19.1-main.20260529.6bcfcee",
178
+ "@msinternal/botframework-webchat-react-hooks": "4.19.1-main.20260529.6bcfcee",
179
+ "@msinternal/botframework-webchat-react-valibot": "4.19.1-main.20260529.6bcfcee",
180
+ "@msinternal/botframework-webchat-styles": "4.19.1-main.20260529.6bcfcee",
181
+ "@msinternal/botframework-webchat-tsconfig": "4.19.1-main.20260529.6bcfcee",
184
182
  "@types/dom-speech-recognition": "^0.0.9",
185
183
  "@types/jest": "^29.5.14",
186
184
  "@types/mdast": "^4.0.4",
@@ -196,16 +194,16 @@
196
194
  "dependencies": {
197
195
  "@emotion/css": "11.13.5",
198
196
  "base64-js": "1.5.1",
199
- "botframework-webchat-api": "4.19.0",
200
- "botframework-webchat-core": "4.19.0",
197
+ "botframework-webchat-api": "4.19.1-main.20260529.6bcfcee",
198
+ "botframework-webchat-core": "4.19.1-main.20260529.6bcfcee",
201
199
  "classnames": "2.5.1",
202
200
  "compute-scroll-into-view": "1.0.20",
203
201
  "deep-freeze-strict": "1.1.1",
204
- "markdown-it": "13.0.2",
205
202
  "math-random": "2.0.1",
206
203
  "mdast-util-from-markdown": "2.0.3",
207
204
  "memoize-one": "6.0.0",
208
205
  "merge-refs": "2.0.0",
206
+ "micromark": "4.0.2",
209
207
  "prop-types": "15.8.1",
210
208
  "punycode": "2.3.1",
211
209
  "react-chain-of-responsibility": "0.4.2",
@@ -0,0 +1,31 @@
1
+ import { validateProps } from '@msinternal/botframework-webchat-react-valibot';
2
+ import { useLocalizer } from 'botframework-webchat-api/hook.js';
3
+ import React, { useCallback } from 'react';
4
+ import { function_, object, pipe, readonly, type InferInput } from 'valibot';
5
+
6
+ import InlineMarkdown from '../../../Utils/InlineMarkdown';
7
+
8
+ const MARKDOWN_REFERENCES = ['RETRY'];
9
+
10
+ const sendFailedRetryPropsSchema = pipe(
11
+ object({
12
+ onRetryClick: function_()
13
+ }),
14
+ readonly()
15
+ );
16
+
17
+ type SendFailedRetryProps = InferInput<typeof sendFailedRetryPropsSchema>;
18
+
19
+ const SendFailedRetry = (props: SendFailedRetryProps) => {
20
+ const { onRetryClick } = validateProps(sendFailedRetryPropsSchema, props);
21
+
22
+ const handleReference = useCallback(({ data }) => data === 'RETRY' && onRetryClick(), [onRetryClick]);
23
+ const localize = useLocalizer();
24
+
25
+ const sendFailedText = localize('ACTIVITY_STATUS_SEND_FAILED_RETRY');
26
+
27
+ return <InlineMarkdown markdown={sendFailedText} onReference={handleReference} references={MARKDOWN_REFERENCES} />;
28
+ };
29
+
30
+ export default SendFailedRetry;
31
+ export { sendFailedRetryPropsSchema, type SendFailedRetryProps };
package/src/BasicToast.js CHANGED
@@ -6,13 +6,13 @@ import classNames from 'classnames';
6
6
  import PropTypes from 'prop-types';
7
7
  import React, { useCallback, useMemo } from 'react';
8
8
 
9
+ import ScreenReaderText from './ScreenReaderText';
9
10
  import DismissIcon from './Toast/DismissIcon';
10
11
  import NotificationIcon from './Toast/NotificationIcon';
11
12
  import randomId from './Utils/randomId';
12
- import ScreenReaderText from './ScreenReaderText';
13
- import useInternalRenderMarkdownInline from './hooks/internal/useInternalRenderMarkdownInline';
14
- import useStyleSet from './hooks/useStyleSet';
15
13
  import { useStyleToEmotionObject } from './hooks/internal/styleToEmotionObject';
14
+ import useStyleSet from './hooks/useStyleSet';
15
+ import renderMarkdownInline from './private/renderMarkdownInline';
16
16
 
17
17
  const { useDismissNotification, useLocalizer } = hooks;
18
18
 
@@ -29,11 +29,10 @@ const BasicToast = ({ notification: { alt, id, level, message = '' } }) => {
29
29
  const contentId = useMemo(() => `webchat__toast__${randomId()}`, []);
30
30
  const localize = useLocalizer();
31
31
  const dismissNotification = useDismissNotification();
32
- const renderMarkdownInline = useInternalRenderMarkdownInline();
33
32
  const rootClassName = useStyleToEmotionObject()(ROOT_STYLE) + '';
34
33
 
35
34
  const handleDismiss = useCallback(() => dismissNotification(id), [dismissNotification, id]);
36
- const html = useMemo(() => ({ __html: renderMarkdownInline(message) }), [message, renderMarkdownInline]);
35
+ const html = useMemo(() => ({ __html: renderMarkdownInline(message) }), [message]);
37
36
 
38
37
  return (
39
38
  <div
@@ -9,9 +9,9 @@ import CollapseIcon from './Toast/CollapseIcon';
9
9
  import ExpandIcon from './Toast/ExpandIcon';
10
10
  import NotificationIcon from './Toast/NotificationIcon';
11
11
  import randomId from './Utils/randomId';
12
- import useInternalRenderMarkdownInline from './hooks/internal/useInternalRenderMarkdownInline';
13
12
  import { useStyleToEmotionObject } from './hooks/internal/styleToEmotionObject';
14
13
  import useStyleSet from './hooks/useStyleSet';
14
+ import renderMarkdownInline from './private/renderMarkdownInline';
15
15
  import { useLiveRegion } from './providers/LiveRegionTwin';
16
16
 
17
17
  const { useDebouncedNotifications, useLocalizer, useRenderToast } = hooks;
@@ -77,7 +77,6 @@ const BasicToaster = ({ className }) => {
77
77
  const [expanded, setExpanded] = useState(false);
78
78
  const localizeWithPlural = useLocalizer({ plural: true });
79
79
  const renderToast = useRenderToast();
80
- const renderMarkdownInline = useInternalRenderMarkdownInline();
81
80
  const rootClassName = useStyleToEmotionObject()(ROOT_STYLE) + '';
82
81
 
83
82
  const handleToggleExpand = useCallback(() => setExpanded(!expanded), [expanded, setExpanded]);
@@ -142,7 +141,7 @@ const BasicToaster = ({ className }) => {
142
141
  }
143
142
 
144
143
  return toAnnounce.length > 0 && <Fragment>{toAnnounce}</Fragment>;
145
- }, [renderMarkdownInline, sortedNotifications]);
144
+ }, [sortedNotifications]);
146
145
 
147
146
  return (
148
147
  <div
package/src/Composer.tsx CHANGED
@@ -24,7 +24,6 @@ import { DecoratorComposer, type DecoratorMiddleware } from 'botframework-webcha
24
24
  import { type LegacyActivityMiddleware, type Polymiddleware } from 'botframework-webchat-api/middleware.js';
25
25
  import { StoreDebugAPIRegistry, type StoreDebugAPI } from 'botframework-webchat-core/internal.js';
26
26
  import classNames from 'classnames';
27
- import MarkdownIt from 'markdown-it';
28
27
  import PropTypes from 'prop-types';
29
28
  import React, { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
30
29
  import { Composer as SayComposer } from 'react-say';
@@ -65,7 +64,6 @@ import CSSCustomPropertiesContainer from './Styles/CSSCustomPropertiesContainer'
65
64
  import ComponentStylesheet from './stylesheet/ComponentStylesheet';
66
65
  import { type ContextOf } from './types/ContextOf';
67
66
  import { type FocusTranscriptInit } from './types/internal/FocusTranscriptInit';
68
- import addTargetBlankToHyperlinksMarkdown from './Utils/addTargetBlankToHyperlinksMarkdown';
69
67
  import downscaleImageToDataURL from './Utils/downscaleImageToDataURL';
70
68
  import mapMap from './Utils/mapMap';
71
69
 
@@ -188,23 +186,9 @@ const ComposerCore = ({
188
186
  const [referenceGrammarID] = useReferenceGrammarID();
189
187
  const [styleOptions] = useStyleOptions();
190
188
  const focusTranscriptCallbacksRef = useRef<((init: FocusTranscriptInit) => Promise<void>)[]>([]);
191
- const internalMarkdownIt = useMemo(() => new MarkdownIt(), []);
192
189
  const scrollToCallbacksRef = useRef([]);
193
190
  const scrollToEndCallbacksRef = useRef([]);
194
191
 
195
- const internalRenderMarkdownInline = useMemo(
196
- () => markdown => {
197
- const tree = internalMarkdownIt.parseInline(markdown);
198
-
199
- // TODO: Use "betterLink" plugin.
200
- // We should add rel="noopener noreferrer" and target="_blank"
201
- const patchedTree = addTargetBlankToHyperlinksMarkdown(tree);
202
-
203
- return internalMarkdownIt.renderer.render(patchedTree);
204
- },
205
- [internalMarkdownIt]
206
- );
207
-
208
192
  const styleToEmotionObject = useStyleToEmotionObject();
209
193
 
210
194
  const patchedStyleSet = useMemo(
@@ -289,8 +273,6 @@ const ComposerCore = ({
289
273
  dispatchScrollPosition,
290
274
  dispatchTranscriptFocusByActivityKey,
291
275
  focusTranscriptCallbacksRef,
292
- internalMarkdownItState: [internalMarkdownIt],
293
- internalRenderMarkdownInline,
294
276
  nonce,
295
277
  numTranscriptFocusObservers,
296
278
  observeScrollPosition,
@@ -308,8 +290,6 @@ const ComposerCore = ({
308
290
  dispatchScrollPosition,
309
291
  dispatchTranscriptFocusByActivityKey,
310
292
  focusTranscriptCallbacksRef,
311
- internalMarkdownIt,
312
- internalRenderMarkdownInline,
313
293
  nonce,
314
294
  numTranscriptFocusObservers,
315
295
  observeScrollPosition,
@@ -0,0 +1,121 @@
1
+ /* eslint react/no-danger: "off" */
2
+
3
+ import {
4
+ betterLinkDocumentMod,
5
+ parseDocumentFragmentFromString,
6
+ serializeDocumentFragmentIntoString,
7
+ stripParagraphContainer
8
+ } from '@msinternal/botframework-webchat-component-better-link';
9
+ import { validateProps } from '@msinternal/botframework-webchat-react-valibot';
10
+ import { useStyleOptions } from 'botframework-webchat-api/hook.js';
11
+ import { micromark } from 'micromark';
12
+ import React, { useCallback, useMemo } from 'react';
13
+ import {
14
+ args,
15
+ array,
16
+ function_,
17
+ instance,
18
+ object,
19
+ optional,
20
+ parse,
21
+ pipe,
22
+ readonly,
23
+ string,
24
+ tuple,
25
+ union,
26
+ type InferInput
27
+ } from 'valibot';
28
+
29
+ import { useStyleToEmotionObject } from '../hooks/internal/styleToEmotionObject';
30
+ import createCustomEvent from './createCustomEvent';
31
+
32
+ const referenceEventSchema = union([instance(Event), object({ data: string() })]);
33
+
34
+ const inlineMarkdownPropsSchema = pipe(
35
+ object({
36
+ markdown: string(),
37
+ onReference: pipe(function_(), args(tuple([referenceEventSchema]))),
38
+ references: optional(array(string()))
39
+ }),
40
+ readonly()
41
+ );
42
+
43
+ type InlineMarkdownProps = InferInput<typeof inlineMarkdownPropsSchema>;
44
+
45
+ const InlineMarkdown = (props: InlineMarkdownProps) => {
46
+ const { markdown, onReference, references } = validateProps(inlineMarkdownPropsSchema, props);
47
+
48
+ const [{ accent }] = useStyleOptions();
49
+ const styleToClassName = useStyleToEmotionObject();
50
+
51
+ // We inlined the style here because this style is:
52
+ // 1. Internal to Web Chat
53
+ // 2. Not customizable from developers (other than setting `styleOptions.accent`)
54
+ const className = useMemo(
55
+ () =>
56
+ styleToClassName({
57
+ '& button[data-markdown-href]': {
58
+ appearance: 'none',
59
+ backgroundColor: 'transparent',
60
+ border: 0,
61
+ color: accent,
62
+ cursor: 'pointer',
63
+ fontFamily: 'inherit',
64
+ fontSize: 'inherit',
65
+ padding: 0
66
+ },
67
+ '@media screen and (forced-colors: active)': {
68
+ '& button[data-markdown-href]': {
69
+ color: 'LinkText',
70
+ textDecoration: 'underline'
71
+ }
72
+ }
73
+ }) + '',
74
+ [accent, styleToClassName]
75
+ );
76
+
77
+ const html = useMemo(() => {
78
+ let markdownWithLinkReferenceDefinitions = markdown;
79
+
80
+ if (references?.length) {
81
+ markdownWithLinkReferenceDefinitions += '\n\n';
82
+ }
83
+
84
+ for (const reference of references || []) {
85
+ markdownWithLinkReferenceDefinitions += `[${reference}]: #${reference}\n`;
86
+ }
87
+
88
+ const documentFragment = parseDocumentFragmentFromString(micromark(markdownWithLinkReferenceDefinitions));
89
+
90
+ // Turn "<a href="#retry">Retry</a>" into "<button type="button" data-markdown-ref="#retry">Retry</button>"
91
+ betterLinkDocumentMod(documentFragment, href => {
92
+ if (href?.startsWith('#')) {
93
+ return { asButton: true, dataset: { markdownHref: href } };
94
+ }
95
+ });
96
+
97
+ return { __html: stripParagraphContainer(serializeDocumentFragmentIntoString(documentFragment)) };
98
+ }, [markdown, references]);
99
+
100
+ const handleClick = useCallback(
101
+ event => {
102
+ event.stopPropagation();
103
+
104
+ const href = event.target.getAttribute('value') ?? undefined;
105
+ const reference = href?.startsWith('#') ? href.slice(1) : href;
106
+
107
+ if (reference) {
108
+ const event = createCustomEvent('reference', { data: reference });
109
+
110
+ parse(referenceEventSchema, event);
111
+ onReference?.(event);
112
+ }
113
+ },
114
+ [onReference]
115
+ );
116
+
117
+ return <span className={className} dangerouslySetInnerHTML={html} onClick={handleClick} />;
118
+ };
119
+
120
+ export default InlineMarkdown;
121
+ export { inlineMarkdownPropsSchema, type InlineMarkdownProps };
@@ -1,143 +1,142 @@
1
- /* eslint react/no-danger: "off" */
2
-
3
- import { hooks } from 'botframework-webchat-api';
1
+ import {
2
+ betterLinkDocumentMod,
3
+ parseDocumentFragmentFromString,
4
+ serializeDocumentFragmentIntoString,
5
+ stripParagraphContainer,
6
+ type BetterLinkDocumentModDecoration
7
+ } from '@msinternal/botframework-webchat-component-better-link';
8
+ import { validateProps } from '@msinternal/botframework-webchat-react-valibot';
9
+ import { useLocalizer } from 'botframework-webchat-api/hook.js';
4
10
  import { onErrorResumeNext } from 'botframework-webchat-core';
5
- import MarkdownIt from 'markdown-it';
11
+ import { micromark } from 'micromark';
6
12
  import React, { memo, useMemo } from 'react';
7
- import betterLinks, { type BetterLinkEnv, type LinkOptions } from './betterLinks';
13
+ import {
14
+ array,
15
+ intersect,
16
+ is,
17
+ number,
18
+ object,
19
+ optional,
20
+ pipe,
21
+ readonly,
22
+ string,
23
+ tupleWithRest,
24
+ union,
25
+ type InferInput,
26
+ type InferOutput
27
+ } from 'valibot';
8
28
 
9
29
  const allowedSchemes = ['data', 'http', 'https', 'ftp', 'mailto', 'sip', 'tel'];
10
30
 
11
- const linkDefinitions = [];
12
-
13
- const externalLinkAlt = '';
14
-
15
- const defaultDecorateLink = (href: string, textContent: string, linkOptions?: LinkOptions): LinkOptions | undefined => {
16
- const decoration: LinkOptions = {
17
- rel: 'noopener noreferrer',
18
- target: '_blank',
19
- wrapZeroWidthSpace: true,
20
- ...linkOptions
21
- };
22
-
23
- const ariaLabelSegments: string[] = [textContent];
24
- const classes: Set<string> = new Set();
25
- const linkDefinition = linkDefinitions.find(({ url }) => url === href);
26
- const protocol = onErrorResumeNext(() => new URL(href).protocol);
27
-
28
- if (linkDefinition) {
29
- ariaLabelSegments.push(
30
- linkDefinition.title || onErrorResumeNext(() => new URL(linkDefinition.url).host) || linkDefinition.url
31
- );
32
-
33
- // linkDefinition.identifier is uppercase, while linkDefinition.label is as-is.
34
- linkDefinition.label === textContent && classes.add('render-markdown__pure-identifier');
35
- }
36
-
37
- // For links that would be sanitized out, let's turn them into a button so we could handle them later.
38
- if (!allowedSchemes.map(scheme => `${scheme}:`).includes(protocol)) {
39
- decoration.asButton ??= true;
40
-
41
- classes.add('render-markdown__citation');
42
- } else if (protocol === 'http:' || protocol === 'https:') {
43
- decoration.iconClassName = [decoration.iconClassName, 'render-markdown__external-link-icon']
44
- .filter((className: string | undefined) => className)
45
- .join(' ');
46
-
47
- ariaLabelSegments.push(externalLinkAlt);
48
- }
49
-
50
- // The first segment is textContent. Putting textContent is aria-label is useless.
51
- if (ariaLabelSegments.length > 1) {
52
- // If "aria-label" is already applied, do not overwrite it.
53
- decoration.ariaLabel ??= (value: string) => value || ariaLabelSegments.join(' ');
54
- }
55
-
56
- if (typeof linkOptions?.className === 'string') {
57
- classes.add(linkOptions.className);
58
- }
59
-
60
- // Resolve className
61
- const classNamesString = Array.from(classes).join(' ');
62
- if (linkOptions?.className && linkOptions?.className instanceof Function) {
63
- decoration.className = linkOptions.className(classNamesString);
64
- } else {
65
- decoration.className = classNamesString;
66
- }
67
-
68
- // By default, Markdown-It will set "title" to the link title in link definition.
69
-
70
- // However, "title" may be narrated by screen reader:
71
- // - Edge
72
- // - <a> will narrate "aria-label" but not "title"
73
- // - <button> will narrate both "aria-label" and "title"
74
- // - NVDA
75
- // - <a> will narrate both "aria-label" and "title"
76
- // - <button> will narrate both "aria-label" and "title"
77
-
78
- // Title makes it very difficult to control narrations by the screen reader. Thus, we are disabling it in favor of "aria-label".
79
- // This will not affect our accessibility compliance but UX. We could use a non-native tooltip or other forms of visual hint.
80
-
81
- decoration.title ??= false;
82
-
83
- return decoration;
84
- };
31
+ const pluralPropsSchema = pipe(
32
+ object({
33
+ stringIds: object({
34
+ zero: optional(string()),
35
+ one: optional(string()),
36
+ two: optional(string()),
37
+ few: optional(string()),
38
+ many: optional(string()),
39
+ other: string()
40
+ }),
41
+ values: pipe(tupleWithRest([number()], union([string()])), readonly())
42
+ }),
43
+ readonly()
44
+ );
45
+
46
+ const singularPropsSchema = pipe(
47
+ object({
48
+ stringIds: string(),
49
+ values: optional(pipe(array(string()), readonly()))
50
+ }),
51
+ readonly()
52
+ );
53
+
54
+ const localizedStringPropsSchema = intersect([
55
+ pipe(
56
+ object({
57
+ className: optional(string()),
58
+ linkClassName: optional(string())
59
+ }),
60
+ readonly()
61
+ ),
62
+ union([pluralPropsSchema, singularPropsSchema])
63
+ ]);
85
64
 
86
- const { useLocalizer } = hooks;
65
+ type LocalizedStringProps = InferInput<typeof localizedStringPropsSchema>;
66
+ type PluralProps = InferOutput<typeof pluralPropsSchema>;
87
67
 
88
- type Plural = {
89
- zero?: string;
90
- one?: string;
91
- two?: string;
92
- few?: string;
93
- many?: string;
94
- other: string;
95
- };
68
+ function isPlural(props: InferOutput<typeof localizedStringPropsSchema>): props is PluralProps {
69
+ return is(pluralPropsSchema, props);
70
+ }
96
71
 
97
- const markdownIt = new MarkdownIt().use(betterLinks);
72
+ const LocalizedString = (props: LocalizedStringProps) => {
73
+ const { className, linkClassName } = validateProps(localizedStringPropsSchema, props);
98
74
 
99
- type PluralProps = Readonly<{
100
- stringIds: Plural;
101
- values: readonly [number, ...(number | string)[]] | undefined;
102
- }>;
75
+ const localizePlural = useLocalizer({ plural: true });
76
+ const localizeSingular = useLocalizer();
103
77
 
104
- type SingularProps = Readonly<{
105
- stringIds: string;
106
- values?: readonly (number | string)[] | undefined;
107
- }>;
78
+ // TODO: Add test.
79
+ const externalLinkAlt = localizeSingular('MARKDOWN_EXTERNAL_LINK_ALT');
108
80
 
109
- type Props = Readonly<{
110
- className?: string | undefined;
111
- linkClassName?: string | undefined;
112
- onDecorateLink?: ((href: string, textContent: string) => LinkOptions | undefined) | undefined;
113
- }> &
114
- (SingularProps | PluralProps);
81
+ const html = useMemo(() => {
82
+ let localized: string;
115
83
 
116
- function isPlural(props: Props): props is PluralProps {
117
- return typeof props.stringIds !== 'string';
118
- }
84
+ if (isPlural(props)) {
85
+ const { stringIds, values } = props;
119
86
 
120
- const LocalizedString = (props: Props) => {
121
- const { className, linkClassName, onDecorateLink = defaultDecorateLink, stringIds, values } = props;
122
- const localize = useLocalizer(isPlural(props) && { plural: true });
123
- const env = useMemo<BetterLinkEnv>(
124
- () => ({
125
- linkOptions: {
126
- className: linkClassName
127
- },
128
- decorateLink: onDecorateLink
129
- }),
130
- [linkClassName, onDecorateLink]
131
- );
87
+ localized = localizePlural(stringIds, ...values);
88
+ } else {
89
+ const { stringIds, values } = props;
132
90
 
133
- const html = useMemo(
134
- () => ({
135
- __html: markdownIt.renderer.render(markdownIt.parseInline(localize(stringIds, ...(values ?? [])), env), env)
136
- }),
137
- [env, localize, stringIds, values]
138
- );
91
+ localized = localizeSingular(stringIds, ...(values || []));
92
+ }
93
+
94
+ const documentFragment = parseDocumentFragmentFromString(micromark(localized));
95
+
96
+ betterLinkDocumentMod(documentFragment, (href, textContent) => {
97
+ const decoration: BetterLinkDocumentModDecoration = {
98
+ rel: 'noopener noreferrer',
99
+ target: '_blank',
100
+ wrapZeroWidthSpace: true
101
+ };
102
+
103
+ const classNames = new Set<string>([linkClassName]);
104
+ const protocol = onErrorResumeNext<string>(() => new URL(href).protocol);
105
+
106
+ // For links that would be sanitized out, let's turn them into a button so we could handle them later.
107
+ if (!allowedSchemes.map(scheme => `${scheme}:`).includes(protocol)) {
108
+ // TODO: Add test.
109
+ decoration.asButton = true;
110
+ } else if (protocol === 'http:' || protocol === 'https:') {
111
+ // TODO: Add test.
112
+ decoration.ariaLabel = value => [value || textContent, externalLinkAlt].filter(Boolean).join(' ');
113
+ decoration.iconClassName = 'render-markdown__external-link-icon';
114
+ }
115
+
116
+ decoration.className = Array.from(classNames).join(' ');
117
+
118
+ // However, "title" may be narrated by screen reader:
119
+ // - Edge
120
+ // - <a> will narrate "aria-label" but not "title"
121
+ // - <button> will narrate both "aria-label" and "title"
122
+ // - NVDA
123
+ // - <a> will narrate both "aria-label" and "title"
124
+ // - <button> will narrate both "aria-label" and "title"
125
+
126
+ // Title makes it very difficult to control narrations by the screen reader. Thus, we are disabling it in favor of "aria-label".
127
+ // This will not affect our accessibility compliance but UX. We could use a non-native tooltip or other forms of visual hint.
128
+
129
+ decoration.title = false;
130
+
131
+ return decoration;
132
+ });
133
+
134
+ return Object.freeze({ __html: stripParagraphContainer(serializeDocumentFragmentIntoString(documentFragment)) });
135
+ }, [externalLinkAlt, linkClassName, localizePlural, localizeSingular, props]);
139
136
 
137
+ // eslint-disable-next-line react/no-danger
140
138
  return <span className={className} dangerouslySetInnerHTML={html} />;
141
139
  };
142
140
 
143
141
  export default memo(LocalizedString);
142
+ export { localizedStringPropsSchema, type LocalizedStringProps };
@@ -0,0 +1,52 @@
1
+ /** @jest-environment @happy-dom/jest-environment */
2
+ /// <reference types="jest" />
3
+
4
+ import { micromark } from 'micromark';
5
+
6
+ import {
7
+ parseDocumentFragmentFromString,
8
+ serializeDocumentFragmentIntoString
9
+ } from '@msinternal/botframework-webchat-component-better-link';
10
+ import addTargetBlankToHyperlinks from './addTargetBlankToHyperlinks';
11
+
12
+ test('add to external links', () => {
13
+ const documentFragment = parseDocumentFragmentFromString(micromark('Hello, [Microsoft](https://microsoft.com/)!'));
14
+
15
+ addTargetBlankToHyperlinks(documentFragment);
16
+
17
+ const actual = serializeDocumentFragmentIntoString(documentFragment);
18
+
19
+ expect(actual).toBe(
20
+ '<p xmlns="http://www.w3.org/1999/xhtml">Hello, <a href="https://microsoft.com/" rel="noopener noreferrer" target="_blank">Microsoft</a>!</p>'
21
+ );
22
+ });
23
+
24
+ test("don't add for hashes", () => {
25
+ const documentFragment = parseDocumentFragmentFromString(micromark('Hello, [Microsoft](#microsoft)!'));
26
+
27
+ addTargetBlankToHyperlinks(documentFragment);
28
+
29
+ const actual = serializeDocumentFragmentIntoString(documentFragment);
30
+
31
+ expect(actual).toBe(`<p xmlns="http://www.w3.org/1999/xhtml">Hello, <a href="#microsoft">Microsoft</a>!</p>`);
32
+ });
33
+
34
+ test("don't add for searches", () => {
35
+ const documentFragment = parseDocumentFragmentFromString(micromark('Hello, [Microsoft](?q=microsoft)!'));
36
+
37
+ addTargetBlankToHyperlinks(documentFragment);
38
+
39
+ const actual = serializeDocumentFragmentIntoString(documentFragment);
40
+
41
+ expect(actual).toBe(`<p xmlns="http://www.w3.org/1999/xhtml">Hello, <a href="?q=microsoft">Microsoft</a>!</p>`);
42
+ });
43
+
44
+ test("don't add for cross references", () => {
45
+ const documentFragment = parseDocumentFragmentFromString(micromark('Hello, [Microsoft]!\n\n[Microsoft]: #microsoft'));
46
+
47
+ addTargetBlankToHyperlinks(documentFragment);
48
+
49
+ const actual = serializeDocumentFragmentIntoString(documentFragment);
50
+
51
+ expect(actual).toBe(`<p xmlns="http://www.w3.org/1999/xhtml">Hello, <a href="#microsoft">Microsoft</a>!</p>\n`);
52
+ });