diff --git a/CHANGELOG.md b/CHANGELOG.md index de8ad030b3..af40717856 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -90,6 +90,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Fixes [#4866](https://github.com/microsoft/BotFramework-WebChat/issues/4866). Citation modal show fill screen width on mobile device and various fit-and-finish, by [@compulim](https://github.com/compulim), in PR [#4867](https://github.com/microsoft/BotFramework-WebChat/pull/4867) - Fixes [#4878](https://github.com/microsoft/BotFramework-WebChat/issues/4878). `createStore` should return type of `Redux.Store`, by [@compulim](https://github.com/compulim), in PR [#4877](https://github.com/microsoft/BotFramework-WebChat/pull/4877) - Fixes [#4957](https://github.com/microsoft/BotFramework-WebChat/issues/4957). Native chevron of the accordion in citation should be hidden, by [@compulim](https://github.com/compulim), in PR [#4958](https://github.com/microsoft/BotFramework-WebChat/pull/4958) +- Fixes [#4870](https://github.com/microsoft/BotFramework-WebChat/issues/4870). Originator should use `claimInterpreter` instead of `ReplyAction/provider`, by [@compulim](https://github.com/compulim), in PR [#4910](https://github.com/microsoft/BotFramework-WebChat/pull/4910) ### Added diff --git a/__tests__/__image_snapshots__/html/badge-js-link-definition-should-display-text-ellipsis-1-snap.png b/__tests__/__image_snapshots__/html/badge-js-link-definition-should-display-text-ellipsis-1-snap.png new file mode 100644 index 0000000000..03942ac07b Binary files /dev/null and b/__tests__/__image_snapshots__/html/badge-js-link-definition-should-display-text-ellipsis-1-snap.png differ diff --git a/__tests__/__image_snapshots__/html/citation-accordion-js-citation-accordion-should-expand-and-collapse-on-click-1-snap.png b/__tests__/__image_snapshots__/html/citation-accordion-js-citation-accordion-should-expand-and-collapse-on-click-1-snap.png index 4c7c9181f8..fbe057cb8b 100644 Binary files a/__tests__/__image_snapshots__/html/citation-accordion-js-citation-accordion-should-expand-and-collapse-on-click-1-snap.png and b/__tests__/__image_snapshots__/html/citation-accordion-js-citation-accordion-should-expand-and-collapse-on-click-1-snap.png differ diff --git a/__tests__/__image_snapshots__/html/citation-accordion-js-citation-accordion-should-expand-and-collapse-on-click-3-snap.png b/__tests__/__image_snapshots__/html/citation-accordion-js-citation-accordion-should-expand-and-collapse-on-click-3-snap.png index 4c7c9181f8..fbe057cb8b 100644 Binary files a/__tests__/__image_snapshots__/html/citation-accordion-js-citation-accordion-should-expand-and-collapse-on-click-3-snap.png and b/__tests__/__image_snapshots__/html/citation-accordion-js-citation-accordion-should-expand-and-collapse-on-click-3-snap.png differ diff --git a/__tests__/__image_snapshots__/html/citation-basic-js-citation-should-display-1-snap.png b/__tests__/__image_snapshots__/html/citation-basic-js-citation-should-display-1-snap.png index 4c7c9181f8..fbe057cb8b 100644 Binary files a/__tests__/__image_snapshots__/html/citation-basic-js-citation-should-display-1-snap.png and b/__tests__/__image_snapshots__/html/citation-basic-js-citation-should-display-1-snap.png differ diff --git a/__tests__/__image_snapshots__/html/citation-show-modal-close-button-js-citation-modal-dialog-should-close-when-clicking-on-close-button-1-snap.png b/__tests__/__image_snapshots__/html/citation-show-modal-close-button-js-citation-modal-dialog-should-close-when-clicking-on-close-button-1-snap.png index 346730d066..f4a4d368ef 100644 Binary files a/__tests__/__image_snapshots__/html/citation-show-modal-close-button-js-citation-modal-dialog-should-close-when-clicking-on-close-button-1-snap.png and b/__tests__/__image_snapshots__/html/citation-show-modal-close-button-js-citation-modal-dialog-should-close-when-clicking-on-close-button-1-snap.png differ diff --git a/__tests__/__image_snapshots__/html/citation-show-modal-close-button-js-citation-modal-dialog-should-close-when-clicking-on-close-button-2-snap.png b/__tests__/__image_snapshots__/html/citation-show-modal-close-button-js-citation-modal-dialog-should-close-when-clicking-on-close-button-2-snap.png index d815b9a913..7216d2c56e 100644 Binary files a/__tests__/__image_snapshots__/html/citation-show-modal-close-button-js-citation-modal-dialog-should-close-when-clicking-on-close-button-2-snap.png and b/__tests__/__image_snapshots__/html/citation-show-modal-close-button-js-citation-modal-dialog-should-close-when-clicking-on-close-button-2-snap.png differ diff --git a/__tests__/__image_snapshots__/html/citation-show-modal-close-escape-js-citation-modal-dialog-should-close-when-escape-key-is-pressed-1-snap.png b/__tests__/__image_snapshots__/html/citation-show-modal-close-escape-js-citation-modal-dialog-should-close-when-escape-key-is-pressed-1-snap.png index 346730d066..f4a4d368ef 100644 Binary files a/__tests__/__image_snapshots__/html/citation-show-modal-close-escape-js-citation-modal-dialog-should-close-when-escape-key-is-pressed-1-snap.png and b/__tests__/__image_snapshots__/html/citation-show-modal-close-escape-js-citation-modal-dialog-should-close-when-escape-key-is-pressed-1-snap.png differ diff --git a/__tests__/__image_snapshots__/html/citation-show-modal-close-escape-js-citation-modal-dialog-should-close-when-escape-key-is-pressed-2-snap.png b/__tests__/__image_snapshots__/html/citation-show-modal-close-escape-js-citation-modal-dialog-should-close-when-escape-key-is-pressed-2-snap.png index d815b9a913..7216d2c56e 100644 Binary files a/__tests__/__image_snapshots__/html/citation-show-modal-close-escape-js-citation-modal-dialog-should-close-when-escape-key-is-pressed-2-snap.png and b/__tests__/__image_snapshots__/html/citation-show-modal-close-escape-js-citation-modal-dialog-should-close-when-escape-key-is-pressed-2-snap.png differ diff --git a/__tests__/__image_snapshots__/html/citation-show-modal-link-definitions-js-citation-modal-dialog-should-show-when-clicking-on-citation-in-link-definitions-1-snap.png b/__tests__/__image_snapshots__/html/citation-show-modal-link-definitions-js-citation-modal-dialog-should-show-when-clicking-on-citation-in-link-definitions-1-snap.png index 346730d066..f4a4d368ef 100644 Binary files a/__tests__/__image_snapshots__/html/citation-show-modal-link-definitions-js-citation-modal-dialog-should-show-when-clicking-on-citation-in-link-definitions-1-snap.png and b/__tests__/__image_snapshots__/html/citation-show-modal-link-definitions-js-citation-modal-dialog-should-show-when-clicking-on-citation-in-link-definitions-1-snap.png differ diff --git a/__tests__/__image_snapshots__/html/citation-show-modal-link-definitions-js-citation-modal-dialog-should-show-when-clicking-on-citation-in-link-definitions-2-snap.png b/__tests__/__image_snapshots__/html/citation-show-modal-link-definitions-js-citation-modal-dialog-should-show-when-clicking-on-citation-in-link-definitions-2-snap.png index 9e0380631a..09d7171f43 100644 Binary files a/__tests__/__image_snapshots__/html/citation-show-modal-link-definitions-js-citation-modal-dialog-should-show-when-clicking-on-citation-in-link-definitions-2-snap.png and b/__tests__/__image_snapshots__/html/citation-show-modal-link-definitions-js-citation-modal-dialog-should-show-when-clicking-on-citation-in-link-definitions-2-snap.png differ diff --git a/__tests__/__image_snapshots__/html/citation-show-modal-markdown-js-citation-modal-dialog-should-show-when-clicking-on-citation-in-markdown-1-snap.png b/__tests__/__image_snapshots__/html/citation-show-modal-markdown-js-citation-modal-dialog-should-show-when-clicking-on-citation-in-markdown-1-snap.png index 346730d066..f4a4d368ef 100644 Binary files a/__tests__/__image_snapshots__/html/citation-show-modal-markdown-js-citation-modal-dialog-should-show-when-clicking-on-citation-in-markdown-1-snap.png and b/__tests__/__image_snapshots__/html/citation-show-modal-markdown-js-citation-modal-dialog-should-show-when-clicking-on-citation-in-markdown-1-snap.png differ diff --git a/__tests__/__image_snapshots__/html/citation-show-modal-markdown-js-citation-modal-dialog-should-show-when-clicking-on-citation-in-markdown-2-snap.png b/__tests__/__image_snapshots__/html/citation-show-modal-markdown-js-citation-modal-dialog-should-show-when-clicking-on-citation-in-markdown-2-snap.png index 9e0380631a..09d7171f43 100644 Binary files a/__tests__/__image_snapshots__/html/citation-show-modal-markdown-js-citation-modal-dialog-should-show-when-clicking-on-citation-in-markdown-2-snap.png and b/__tests__/__image_snapshots__/html/citation-show-modal-markdown-js-citation-modal-dialog-should-show-when-clicking-on-citation-in-markdown-2-snap.png differ diff --git a/__tests__/__image_snapshots__/html/citation-show-modal-width-desktop-js-citation-modal-dialog-should-show-60-on-desktop-1-snap.png b/__tests__/__image_snapshots__/html/citation-show-modal-width-desktop-js-citation-modal-dialog-should-show-60-on-desktop-1-snap.png index 75e8aa7530..93c24c8b91 100644 Binary files a/__tests__/__image_snapshots__/html/citation-show-modal-width-desktop-js-citation-modal-dialog-should-show-60-on-desktop-1-snap.png and b/__tests__/__image_snapshots__/html/citation-show-modal-width-desktop-js-citation-modal-dialog-should-show-60-on-desktop-1-snap.png differ diff --git a/__tests__/__image_snapshots__/html/citation-show-modal-width-desktop-js-citation-modal-dialog-should-show-60-on-desktop-2-snap.png b/__tests__/__image_snapshots__/html/citation-show-modal-width-desktop-js-citation-modal-dialog-should-show-60-on-desktop-2-snap.png index 5aa7747814..472efe68c5 100644 Binary files a/__tests__/__image_snapshots__/html/citation-show-modal-width-desktop-js-citation-modal-dialog-should-show-60-on-desktop-2-snap.png and b/__tests__/__image_snapshots__/html/citation-show-modal-width-desktop-js-citation-modal-dialog-should-show-60-on-desktop-2-snap.png differ diff --git a/__tests__/__image_snapshots__/html/citation-show-modal-width-mobile-js-citation-modal-dialog-should-show-full-width-on-mobile-device-1-snap.png b/__tests__/__image_snapshots__/html/citation-show-modal-width-mobile-js-citation-modal-dialog-should-show-full-width-on-mobile-device-1-snap.png index 4c7c9181f8..fbe057cb8b 100644 Binary files a/__tests__/__image_snapshots__/html/citation-show-modal-width-mobile-js-citation-modal-dialog-should-show-full-width-on-mobile-device-1-snap.png and b/__tests__/__image_snapshots__/html/citation-show-modal-width-mobile-js-citation-modal-dialog-should-show-full-width-on-mobile-device-1-snap.png differ diff --git a/__tests__/__image_snapshots__/html/citation-show-modal-width-mobile-js-citation-modal-dialog-should-show-full-width-on-mobile-device-2-snap.png b/__tests__/__image_snapshots__/html/citation-show-modal-width-mobile-js-citation-modal-dialog-should-show-full-width-on-mobile-device-2-snap.png index 346730d066..f4a4d368ef 100644 Binary files a/__tests__/__image_snapshots__/html/citation-show-modal-width-mobile-js-citation-modal-dialog-should-show-full-width-on-mobile-device-2-snap.png and b/__tests__/__image_snapshots__/html/citation-show-modal-width-mobile-js-citation-modal-dialog-should-show-full-width-on-mobile-device-2-snap.png differ diff --git a/__tests__/__image_snapshots__/html/provenance-basic-js-provenance-should-display-1-snap.png b/__tests__/__image_snapshots__/html/claim-interpreter-js-originator-using-claim-interpreter-should-display-1-snap.png similarity index 100% rename from __tests__/__image_snapshots__/html/provenance-basic-js-provenance-should-display-1-snap.png rename to __tests__/__image_snapshots__/html/claim-interpreter-js-originator-using-claim-interpreter-should-display-1-snap.png diff --git a/__tests__/__image_snapshots__/html/identifier-as-string-js-link-definition-should-display-identifier-of-type-string-1-snap.png b/__tests__/__image_snapshots__/html/identifier-as-string-js-link-definition-should-display-identifier-of-type-string-1-snap.png new file mode 100644 index 0000000000..fe21654123 Binary files /dev/null and b/__tests__/__image_snapshots__/html/identifier-as-string-js-link-definition-should-display-identifier-of-type-string-1-snap.png differ diff --git a/__tests__/__image_snapshots__/html/reference-js-link-definition-should-reference-sample-1-snap.png b/__tests__/__image_snapshots__/html/reference-js-link-definition-should-reference-sample-1-snap.png new file mode 100644 index 0000000000..cbed31026e Binary files /dev/null and b/__tests__/__image_snapshots__/html/reference-js-link-definition-should-reference-sample-1-snap.png differ diff --git a/__tests__/__image_snapshots__/html/reply-action-js-originator-using-reply-action-should-display-1-snap.png b/__tests__/__image_snapshots__/html/reply-action-js-originator-using-reply-action-should-display-1-snap.png new file mode 100644 index 0000000000..b2f5044c72 Binary files /dev/null and b/__tests__/__image_snapshots__/html/reply-action-js-originator-using-reply-action-should-display-1-snap.png differ diff --git a/__tests__/__image_snapshots__/html/wrap-zero-width-space-js-link-definition-should-word-wrap-pure-identifier-to-next-line-but-not-text-content-1-snap.png b/__tests__/__image_snapshots__/html/wrap-zero-width-space-js-link-definition-should-word-wrap-pure-identifier-to-next-line-but-not-text-content-1-snap.png new file mode 100644 index 0000000000..c4bf3863dc Binary files /dev/null and b/__tests__/__image_snapshots__/html/wrap-zero-width-space-js-link-definition-should-word-wrap-pure-identifier-to-next-line-but-not-text-content-1-snap.png differ diff --git a/__tests__/hooks/useRenderMarkdownAsHTML.js b/__tests__/hooks/useRenderMarkdownAsHTML.js index b20b06f984..ef1a95af4f 100644 --- a/__tests__/hooks/useRenderMarkdownAsHTML.js +++ b/__tests__/hooks/useRenderMarkdownAsHTML.js @@ -41,7 +41,7 @@ test('renderMarkdown should add accessibility text for external links', async () await expect( pageObjects.runHook('useRenderMarkdownAsHTML', [], fn => fn('Click [here](https://aka.ms/) to find out more.')) ).resolves.toMatchInlineSnapshot(` - "

Click here to find out more.

+ "

Click \u200Bhere\u200B to find out more.

" `); }); @@ -52,7 +52,7 @@ test('renderMarkdown should add accessibility text for external links with yue', await expect( pageObjects.runHook('useRenderMarkdownAsHTML', [], fn => fn('Click [here](https://aka.ms/) to find out more.')) ).resolves.toMatchInlineSnapshot(` - "

Click here to find out more.

+ "

Click \u200Bhere\u200B to find out more.

" `); }); diff --git a/__tests__/html/accessibility.heroCard.heading.html b/__tests__/html/accessibility.heroCard.heading.html index de025141c2..041af67cf2 100644 --- a/__tests__/html/accessibility.heroCard.heading.html +++ b/__tests__/html/accessibility.heroCard.heading.html @@ -25,7 +25,7 @@ expect(document.querySelector('.ac-textBlock[role="heading"]')).toHaveProperty( 'innerText', - 'Details about image 1' + '\u200BDetails about image 1\u200B' ); }); diff --git a/__tests__/html/linkDefinition/badge.html b/__tests__/html/linkDefinition/badge.html new file mode 100644 index 0000000000..0882140703 --- /dev/null +++ b/__tests__/html/linkDefinition/badge.html @@ -0,0 +1,148 @@ + + + + + + + + + +
+ + + diff --git a/__tests__/html/linkDefinition/badge.js b/__tests__/html/linkDefinition/badge.js new file mode 100644 index 0000000000..a0b319ac03 --- /dev/null +++ b/__tests__/html/linkDefinition/badge.js @@ -0,0 +1,5 @@ +/** @jest-environment ./packages/test/harness/src/host/jest/WebDriverEnvironment.js */ + +describe('link definition', () => { + test('should display text ellipsis', () => runHTML('linkDefinition/badge.html')); +}); diff --git a/__tests__/html/linkDefinition/identifierAsString.html b/__tests__/html/linkDefinition/identifierAsString.html new file mode 100644 index 0000000000..181aba3a76 --- /dev/null +++ b/__tests__/html/linkDefinition/identifierAsString.html @@ -0,0 +1,63 @@ + + + + + + + + + +
+ + + diff --git a/__tests__/html/linkDefinition/identifierAsString.js b/__tests__/html/linkDefinition/identifierAsString.js new file mode 100644 index 0000000000..021ad74a0b --- /dev/null +++ b/__tests__/html/linkDefinition/identifierAsString.js @@ -0,0 +1,5 @@ +/** @jest-environment ./packages/test/harness/src/host/jest/WebDriverEnvironment.js */ + +describe('link definition', () => { + test('should display identifier of type string', () => runHTML('linkDefinition/identifierAsString.html')); +}); diff --git a/__tests__/html/linkDefinition/reference.html b/__tests__/html/linkDefinition/reference.html new file mode 100644 index 0000000000..05001f60c9 --- /dev/null +++ b/__tests__/html/linkDefinition/reference.html @@ -0,0 +1,168 @@ + + + + + + + + + +
+ + + diff --git a/__tests__/html/linkDefinition/reference.js b/__tests__/html/linkDefinition/reference.js new file mode 100644 index 0000000000..9f9f92b6d4 --- /dev/null +++ b/__tests__/html/linkDefinition/reference.js @@ -0,0 +1,5 @@ +/** @jest-environment ./packages/test/harness/src/host/jest/WebDriverEnvironment.js */ + +describe('link definition', () => { + test('should reference sample', () => runHTML('linkDefinition/reference.html')); +}); diff --git a/__tests__/html/linkDefinition/wrapZeroWidthSpace.html b/__tests__/html/linkDefinition/wrapZeroWidthSpace.html new file mode 100644 index 0000000000..a8ae5f5c0a --- /dev/null +++ b/__tests__/html/linkDefinition/wrapZeroWidthSpace.html @@ -0,0 +1,34 @@ + + + + + + + + + +
+ + + diff --git a/__tests__/html/linkDefinition/wrapZeroWidthSpace.js b/__tests__/html/linkDefinition/wrapZeroWidthSpace.js new file mode 100644 index 0000000000..b76d010668 --- /dev/null +++ b/__tests__/html/linkDefinition/wrapZeroWidthSpace.js @@ -0,0 +1,5 @@ +/** @jest-environment ./packages/test/harness/src/host/jest/WebDriverEnvironment.js */ + +describe('link definition', () => { + test('should word-wrap pure identifier to next line but not text content', () => runHTML('linkDefinition/wrapZeroWidthSpace.html')); +}); diff --git a/__tests__/html/markdown.attributes.curlyBrackets.html b/__tests__/html/markdown.attributes.curlyBrackets.html index 7afe8d5642..c752f6217c 100644 --- a/__tests__/html/markdown.attributes.curlyBrackets.html +++ b/__tests__/html/markdown.attributes.curlyBrackets.html @@ -1,4 +1,4 @@ - + @@ -85,9 +85,10 @@ notRecognizedItems, otherCasesNotProcessedItems, otherCasesProcessedItems - ] = [].map.call(document.querySelectorAll('.webchat__bubble__content .webchat__text-content__markdown ul'), list => [ - ...list.querySelectorAll('li') - ]); + ] = [].map.call( + document.querySelectorAll('.webchat__bubble__content .webchat__text-content__markdown ul'), + list => [...list.querySelectorAll('li')] + ); // THEN: Hello {World} and Hello {1} should be kept as-is. expect(regressionItems.shift()).toHaveProperty('innerText', 'Hello {World}'); @@ -100,33 +101,33 @@ // [Link](https://bing.com/){aria-label="This is a label"} should return ` expect(supportedItems.shift()).toHaveProperty( 'innerText', - 'Link should return ' + '\u200BLink\u200B should return ' ); expect(anchors.shift()).toHaveAttributes({ 'aria-label': 'This is a label' }); // [Link](https://bing.com/){aria-label=Hello} should return `` - expect(supportedItems.shift()).toHaveProperty('innerText', 'Link should return '); + expect(supportedItems.shift()).toHaveProperty('innerText', '\u200BLink\u200B should return '); expect(anchors.shift()).toHaveAttributes({ 'aria-label': 'Hello' }); // [Link](https://bing.com/){aria-label=} should return `` - expect(supportedItems.shift()).toHaveProperty('innerText', 'Link should return '); + expect(supportedItems.shift()).toHaveProperty('innerText', '\u200BLink\u200B should return '); expect(anchors.shift()).toHaveAttributes({ 'aria-label': 0 }); // [Link](https://bing.com/){aria-label} should return `` - expect(supportedItems.shift()).toHaveProperty('innerText', 'Link should return '); + expect(supportedItems.shift()).toHaveProperty('innerText', '\u200BLink\u200B should return '); expect(anchors.shift()).toHaveAttributes({ 'aria-label': 0 }); // [Link](https://bing.com/){ aria-label=" This is a label with many whitespaces " } should return `` // Spaces before `aria-label` or after the last quote, is supported by `markdown-it-attrs`. expect(supportedItems.shift()).toHaveProperty( 'innerText', - 'Link should return ' + '\u200BLink\u200B should return ' ); expect(anchors.shift()).toHaveAttributes({ 'aria-label': ' This is a label with many whitespaces ' }); // [Link](https://bing.com/){aria-label=a"b"c} should return `` // `aria-label=a"b"c` is supported by `markdown-it-attrs`, will turn into `aria-label="a"b"c"`. - expect(supportedItems.shift()).toHaveProperty('innerText', 'Link should return '); + expect(supportedItems.shift()).toHaveProperty('innerText', '\u200BLink\u200B should return '); expect(anchors.shift()).toHaveAttributes({ 'aria-label': 'a"b"c' }); // THEN: Invalid or unrecognized curly brackets should left untouched. @@ -137,16 +138,16 @@ // Although "aria-label=This" is recognized, "is ignored" is not valid. The whole bracelet is ignored. expect(notRecognizedItems.shift()).toHaveProperty( 'innerText', - 'Link{aria-label=This is ignored} should left untouched' + '\u200BLink\u200B{aria-label=This is ignored} should left untouched' ); // [Link](https://bing.com/){aria-label ="This is a label with whitespace before equal sign"} should left untouched // A space between or after the equal sign (=) is not valid in `markdown-it-attrs`. expect(notRecognizedItems.shift()).toHaveProperty( 'innerText', - 'Link{aria-label =“This is a label with whitespace before equal sign”} should left untouched' + '\u200BLink\u200B{aria-label =“This is a label with whitespace before equal sign”} should left untouched' ); - expect(notRecognizedItems.shift()).toHaveProperty('innerText', 'Link{.ignored} should left untouched'); + expect(notRecognizedItems.shift()).toHaveProperty('innerText', '\u200BLink\u200B{.ignored} should left untouched'); // [Link](https://bing.com/){.ignored} should left untouched // Class is not supported in Web Chat. @@ -156,7 +157,7 @@ // "onload" is not a recognized attribute. expect(notRecognizedItems.shift()).toHaveProperty( 'innerText', - 'Link{onload=“javascript:void()”} should left untouched' + '\u200BLink\u200B{onload=“javascript:void()”} should left untouched' ); // THEN: Curly brackets not processed by `markdown-it-attrs` should left untouched. diff --git a/__tests__/html/provenance.basic.html b/__tests__/html/originator/claimInterpreter.html similarity index 71% rename from __tests__/html/provenance.basic.html rename to __tests__/html/originator/claimInterpreter.html index 33166e7c7a..6443341bde 100644 --- a/__tests__/html/provenance.basic.html +++ b/__tests__/html/originator/claimInterpreter.html @@ -26,12 +26,11 @@ entities: [ { '@context': 'https://schema.org', - '@type': 'ReplyAction', - type: 'https://schema.org/ReplyAction', - provider: { - '@context': 'https://schema.org', + '@type': 'Claim', + type: 'https://schema.org/Claim', + claimInterpreter: { '@type': 'Project', - name: 'Surfaced by Azure OpenAI', + slogan: 'Surfaced by Azure OpenAI', url: 'https://www.microsoft.com/en-us/ai/responsible-ai' } } @@ -43,12 +42,12 @@ await host.snapshot(); const [activityStatus] = pageElements.activityStatuses(); - const provenanceLink = activityStatus.querySelector('a'); + const originatorLink = activityStatus.querySelector('a'); - expect(provenanceLink).toBeTruthy(); - expect(provenanceLink.getAttribute('href')).toBe('https://www.microsoft.com/en-us/ai/responsible-ai'); - expect(provenanceLink.getAttribute('rel')).toBe('noopener noreferrer'); - expect(provenanceLink.getAttribute('target')).toBe('_blank'); + expect(originatorLink).toBeTruthy(); + expect(originatorLink.getAttribute('href')).toBe('https://www.microsoft.com/en-us/ai/responsible-ai'); + expect(originatorLink.getAttribute('rel')).toBe('noopener noreferrer'); + expect(originatorLink.getAttribute('target')).toBe('_blank'); }); diff --git a/__tests__/html/originator/claimInterpreter.js b/__tests__/html/originator/claimInterpreter.js new file mode 100644 index 0000000000..429b048258 --- /dev/null +++ b/__tests__/html/originator/claimInterpreter.js @@ -0,0 +1,5 @@ +/** @jest-environment ./packages/test/harness/src/host/jest/WebDriverEnvironment.js */ + +describe('originator using claimInterpreter', () => { + test('should display', () => runHTML('originator/claimInterpreter.html')); +}); diff --git a/__tests__/html/originator/replyAction.html b/__tests__/html/originator/replyAction.html new file mode 100644 index 0000000000..4ddf98d2d0 --- /dev/null +++ b/__tests__/html/originator/replyAction.html @@ -0,0 +1,66 @@ + + + + + + + + + +
+ + + diff --git a/__tests__/html/originator/replyAction.js b/__tests__/html/originator/replyAction.js new file mode 100644 index 0000000000..eff0d96f1d --- /dev/null +++ b/__tests__/html/originator/replyAction.js @@ -0,0 +1,5 @@ +/** @jest-environment ./packages/test/harness/src/host/jest/WebDriverEnvironment.js */ + +describe('originator using ReplyAction', () => { + test('should display', () => runHTML('originator/replyAction.html')); +}); diff --git a/__tests__/html/provenance.basic.js b/__tests__/html/provenance.basic.js deleted file mode 100644 index 8324e08f2f..0000000000 --- a/__tests__/html/provenance.basic.js +++ /dev/null @@ -1,5 +0,0 @@ -/** @jest-environment ./packages/test/harness/src/host/jest/WebDriverEnvironment.js */ - -describe('provenance', () => { - test('should display', () => runHTML('provenance.basic.html')); -}); diff --git a/packages/bundle/src/__tests__/renderMarkdown.spec.js b/packages/bundle/src/__tests__/renderMarkdown.spec.js index 2ddde87410..927e51785d 100644 --- a/packages/bundle/src/__tests__/renderMarkdown.spec.js +++ b/packages/bundle/src/__tests__/renderMarkdown.spec.js @@ -54,7 +54,7 @@ describe('renderMarkdown', () => { expect(renderMarkdown('[example](https://sample.com){aria-label="Sample label"}', styleOptions)) .toMatchInlineSnapshot(` - "

example

+ "

\u200Bexample\u200B

" `); }); @@ -65,7 +65,7 @@ describe('renderMarkdown', () => { expect(renderMarkdown('[example](https://sample.com){aria-label="Sample label"}', styleOptions, options)) .toMatchInlineSnapshot(` - "

example

+ "

\u200Bexample\u200B

" `); }); @@ -74,7 +74,7 @@ describe('renderMarkdown', () => { const styleOptions = { markdownRespectCRLF: true }; expect(renderMarkdown(`[example@test.com](sip:example@test.com)`, styleOptions)).toBe( - '

example@test.com

\n' + '

\u200Bexample@test.com\u200B

\n' ); }); @@ -82,7 +82,7 @@ describe('renderMarkdown', () => { const styleOptions = { markdownRespectCRLF: true }; expect(renderMarkdown(`[(505)503-4455](tel:505-503-4455)`, styleOptions)).toBe( - '

(505)503-4455

\n' + '

\u200B(505)503-4455\u200B

\n' ); }); diff --git a/packages/bundle/src/markdown/markdownItPlugins/betterLink.ts b/packages/bundle/src/markdown/markdownItPlugins/betterLink.ts index e841bef644..5ff035d1fb 100644 --- a/packages/bundle/src/markdown/markdownItPlugins/betterLink.ts +++ b/packages/bundle/src/markdown/markdownItPlugins/betterLink.ts @@ -1,5 +1,5 @@ -import iterator from 'markdown-it-for-inline'; import MarkdownIt from 'markdown-it'; +import iterator from 'markdown-it-for-inline'; // Put a transparent pixel instead of the "open in new window" icon, so developers can easily modify the icon in CSS. const TRANSPARENT_GIF = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; @@ -30,11 +30,19 @@ type Decoration = { /** Value of "title" attribute of the link. If set to `false`, remove existing attribute. */ title?: AttributeSetter; + + /** Wraps the link with zero-width space. */ + wrapZeroWidthSpace?: boolean; }; // This is used for parsing Markdown for external links. const internalMarkdownIt = new MarkdownIt(); +const ZERO_WIDTH_SPACE_TOKEN = { + content: '\u200b', + type: 'text' +}; + function setTokenAttribute(attrs: Array<[string, string]>, name: string, value?: AttributeSetter) { const index = attrs.findIndex(entry => entry[0] === name); @@ -61,52 +69,65 @@ const betterLink = ( ): typeof MarkdownIt => markdown.use(iterator, 'url_new_win', 'link_open', (tokens, index) => { const indexOfLinkCloseToken = tokens.indexOf(tokens.slice(index + 1).find(({ type }) => type === 'link_close')); - const token = tokens[+index]; + // eslint-disable-next-line no-magic-numbers + const updatedTokens = tokens.splice(index, ~indexOfLinkCloseToken ? indexOfLinkCloseToken - index + 1 : 2); - const [, href] = token.attrs.find(([name]) => name === 'href'); - const nodesInLink = tokens.slice(index + 1, indexOfLinkCloseToken); + try { + const [linkOpenToken] = updatedTokens; + const linkCloseToken = updatedTokens[updatedTokens.length - 1]; - const textContent = nodesInLink - .filter(({ type }) => type === 'text') - .map(({ content }) => content) - .join(' '); + const [, href] = linkOpenToken.attrs.find(([name]) => name === 'href'); + const nodesInLink = updatedTokens.slice(1, updatedTokens.length - 1); - const decoration = decorate(href, textContent); + const textContent = nodesInLink + .filter(({ type }) => type === 'text') + .map(({ content }) => content) + .join(' '); - if (!decoration) { - return; - } + const decoration = decorate(href, textContent); - const { ariaLabel, asButton, className, iconAlt, iconClassName, rel, target, title } = decoration; + if (!decoration) { + return; + } - setTokenAttribute(token.attrs, 'aria-label', ariaLabel); - setTokenAttribute(token.attrs, 'class', className); - setTokenAttribute(token.attrs, 'title', title); + const { ariaLabel, asButton, className, iconAlt, iconClassName, rel, target, title, wrapZeroWidthSpace } = + decoration; - if (iconClassName) { - const iconTokens = internalMarkdownIt.parseInline(`![](${TRANSPARENT_GIF})`)[0].children; + setTokenAttribute(linkOpenToken.attrs, 'aria-label', ariaLabel); + setTokenAttribute(linkOpenToken.attrs, 'class', className); + setTokenAttribute(linkOpenToken.attrs, 'title', title); - setTokenAttribute(iconTokens[0].attrs, 'class', iconClassName); - setTokenAttribute(iconTokens[0].attrs, 'title', iconAlt); + if (iconClassName) { + const iconTokens = internalMarkdownIt.parseInline(`![](${TRANSPARENT_GIF})`)[0].children; - // Add an icon before . - ~indexOfLinkCloseToken && tokens.splice(indexOfLinkCloseToken, 0, ...iconTokens); - } + setTokenAttribute(iconTokens[0].attrs, 'class', iconClassName); + setTokenAttribute(iconTokens[0].attrs, 'title', iconAlt); + + // Add an icon before . + // eslint-disable-next-line no-magic-numbers + updatedTokens.splice(-1, 0, ...iconTokens); + } - if (asButton) { - setTokenAttribute(token.attrs, 'href', false); + if (asButton) { + setTokenAttribute(linkOpenToken.attrs, 'href', false); - token.tag = 'button'; + linkOpenToken.tag = 'button'; - setTokenAttribute(token.attrs, 'type', 'button'); - setTokenAttribute(token.attrs, 'value', href); + setTokenAttribute(linkOpenToken.attrs, 'type', 'button'); + setTokenAttribute(linkOpenToken.attrs, 'value', href); - if (~indexOfLinkCloseToken) { - tokens[+indexOfLinkCloseToken].tag = 'button'; + linkCloseToken.tag = 'button'; + } else { + setTokenAttribute(linkOpenToken.attrs, 'rel', rel); + setTokenAttribute(linkOpenToken.attrs, 'target', target); } - } else { - setTokenAttribute(token.attrs, 'rel', rel); - setTokenAttribute(token.attrs, 'target', target); + + if (wrapZeroWidthSpace) { + updatedTokens.splice(0, 0, ZERO_WIDTH_SPACE_TOKEN); + updatedTokens.splice(Infinity, 0, ZERO_WIDTH_SPACE_TOKEN); + } + } finally { + tokens.splice(index, 0, ...updatedTokens); } }); diff --git a/packages/bundle/src/markdown/renderMarkdown.ts b/packages/bundle/src/markdown/renderMarkdown.ts index 846fa0f848..d3a63e3494 100644 --- a/packages/bundle/src/markdown/renderMarkdown.ts +++ b/packages/bundle/src/markdown/renderMarkdown.ts @@ -2,9 +2,9 @@ import { onErrorResumeNext } from 'botframework-webchat-core'; import MarkdownIt from 'markdown-it'; import sanitizeHTML from 'sanitize-html'; -import { pre as respectCRLFPre } from './markdownItPlugins/respectCRLF'; import ariaLabel, { post as ariaLabelPost, pre as ariaLabelPre } from './markdownItPlugins/ariaLabel'; import betterLink from './markdownItPlugins/betterLink'; +import { pre as respectCRLFPre } from './markdownItPlugins/respectCRLF'; import iterateLinkDefinitions from './private/iterateLinkDefinitions'; const SANITIZE_HTML_OPTIONS = Object.freeze({ @@ -88,7 +88,8 @@ export default function render( .use(betterLink, (href: string, textContent: string): BetterLinkDecoration | undefined => { const decoration: BetterLinkDecoration = { rel: 'noopener noreferrer', - target: '_blank' + target: '_blank', + wrapZeroWidthSpace: true }; const ariaLabelSegments: string[] = [textContent]; @@ -101,10 +102,12 @@ export default function render( linkDefinition.title || onErrorResumeNext(() => new URL(linkDefinition.url).host) || linkDefinition.url ); - linkDefinition.identifier === textContent && classes.add('webchat__render-markdown__pure-identifier'); + // linkDefinition.identifier is uppercase, while linkDefinition.label is as-is. + linkDefinition.label === textContent && classes.add('webchat__render-markdown__pure-identifier'); } - if (protocol === 'cite:') { + // For links that would be sanitized out, let's turn them into a button so we could handle them later. + if (!SANITIZE_HTML_OPTIONS.allowedSchemes.map(scheme => `${scheme}:`).includes(protocol)) { decoration.asButton = true; classes.add('webchat__render-markdown__citation'); diff --git a/packages/component/package-lock.json b/packages/component/package-lock.json index a21ebbfb44..4e77c87c41 100644 --- a/packages/component/package-lock.json +++ b/packages/component/package-lock.json @@ -13,6 +13,7 @@ "base64-js": "1.5.1", "classnames": "2.3.2", "compute-scroll-into-view": "1.0.20", + "deep-freeze-strict": "^1.1.1", "event-target-shim": "6.0.2", "markdown-it": "13.0.2", "math-random": "2.0.1", @@ -2771,6 +2772,11 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/deep-freeze-strict": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/deep-freeze-strict/-/deep-freeze-strict-1.1.1.tgz", + "integrity": "sha512-QemROZMM2IvhAcCFvahdX2Vbm4S/txeq5rFYU9fh4mQP79WTMW5c/HkQ2ICl1zuzcDZdPZ6zarDxQeQMsVYoNA==" + }, "node_modules/define-properties": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.4.tgz", @@ -6892,6 +6898,11 @@ "character-entities": "^2.0.0" } }, + "deep-freeze-strict": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/deep-freeze-strict/-/deep-freeze-strict-1.1.1.tgz", + "integrity": "sha512-QemROZMM2IvhAcCFvahdX2Vbm4S/txeq5rFYU9fh4mQP79WTMW5c/HkQ2ICl1zuzcDZdPZ6zarDxQeQMsVYoNA==" + }, "define-properties": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.4.tgz", diff --git a/packages/component/package.json b/packages/component/package.json index 3db8cb0958..7cbeeb3b8f 100644 --- a/packages/component/package.json +++ b/packages/component/package.json @@ -100,6 +100,7 @@ "botframework-webchat-core": "0.0.0-0", "classnames": "2.3.2", "compute-scroll-into-view": "1.0.20", + "deep-freeze-strict": "^1.1.1", "event-target-shim": "6.0.2", "markdown-it": "13.0.2", "math-random": "2.0.1", diff --git a/packages/component/src/ActivityStatus/OthersActivityStatus.tsx b/packages/component/src/ActivityStatus/OthersActivityStatus.tsx index ac76fb25ff..6ab15ed222 100644 --- a/packages/component/src/ActivityStatus/OthersActivityStatus.tsx +++ b/packages/component/src/ActivityStatus/OthersActivityStatus.tsx @@ -1,54 +1,80 @@ -import { type WebChatActivity } from 'botframework-webchat-core'; +import { + getOrgSchemaMessage, + OrgSchemaAction, + OrgSchemaProject, + parseAction, + parseClaim, + warnOnce, + type WebChatActivity +} from 'botframework-webchat-core'; import classNames from 'classnames'; -import React, { memo, type ReactNode, useMemo } from 'react'; +import React, { memo, useMemo, type ReactNode } from 'react'; -import { isReplyAction, type ReplyAction } from '../types/external/OrgSchema/ReplyAction'; -import { isThing, type Thing } from '../types/external/OrgSchema/Thing'; -import { isVoteAction, type VoteAction } from '../types/external/OrgSchema/VoteAction'; -import { type TypeOfArray } from '../types/internal/TypeOfArray'; +import useStyleSet from '../hooks/useStyleSet'; +import dereferenceBlankNodes from '../Utils/JSONLinkedData/dereferenceBlankNodes'; import Feedback from './private/Feedback/Feedback'; import Originator from './private/Originator'; import Slotted from './Slotted'; import Timestamp from './Timestamp'; -import useStyleSet from '../hooks/useStyleSet'; -type WebChatEntity = TypeOfArray>; +type Props = Readonly<{ activity: WebChatActivity }>; -type DownvoteAction = VoteAction & { actionOption: 'downvote' }; -type UpvoteAction = VoteAction & { actionOption: 'upvote' }; +const warnRootLevelThings = warnOnce( + 'Root-level things are being deprecated, please relate all things to `entities[@id=""]` instead. This feature will be removed in 2025-03-06.' +); -function isDownvoteAction(voteAction: VoteAction): voteAction is DownvoteAction { - return voteAction.actionOption === 'downvote'; -} +const OthersActivityStatus = memo(({ activity }: Props) => { + const [{ sendStatus }] = useStyleSet(); + const { timestamp } = activity; + const graph = useMemo(() => dereferenceBlankNodes(activity.entities || []), [activity.entities]); -function isUpvoteAction(voteAction: VoteAction): voteAction is UpvoteAction { - return voteAction.actionOption === 'upvote'; -} + const messageThing = useMemo(() => getOrgSchemaMessage(graph), [graph]); -type Props = Readonly<{ activity: WebChatActivity }>; + const claimInterpreter = useMemo(() => { + try { + if (messageThing) { + return parseClaim((messageThing?.citation || [])[0])?.claimInterpreter; + } -const OthersActivityStatus = memo(({ activity }: Props) => { - const [{ sendStatus }] = useStyleSet(); - const entities = activity.entities as Array | undefined; + const [firstClaim] = graph.filter(({ type }) => type === 'https://schema.org/Claim').map(parseClaim); - const replyAction = entities?.find( - (entity): entity is ReplyAction => isThing(entity) && isReplyAction(entity) - ); + if (firstClaim) { + warnRootLevelThings(); - const { timestamp } = activity; + return firstClaim?.claimInterpreter; + } - const voteActions = useMemo>( - () => - Object.freeze( - new Set( - (entities || []).filter( - (entity): entity is DownvoteAction | UpvoteAction => - isThing(entity) && isVoteAction(entity) && (isDownvoteAction(entity) || isUpvoteAction(entity)) - ) - ) - ), - [entities] - ); + const replyAction = parseAction(graph.find(({ type }) => type === 'https://schema.org/ReplyAction')); + + if (replyAction) { + warnRootLevelThings(); + + return replyAction?.provider; + } + } catch { + // Intentionally left blank. + } + }, [graph, messageThing]); + + const feedbackActions = useMemo | undefined>(() => { + try { + const reactActions = (messageThing?.potentialAction || []).filter( + ({ '@type': type }) => type === 'LikeAction' || type === 'DislikeAction' + ); + + if (reactActions.length) { + return Object.freeze(new Set(reactActions)); + } + + const voteActions = graph.filter(({ type }) => type === 'https://schema.org/VoteAction').map(parseAction); + + if (voteActions.length) { + return Object.freeze(new Set(voteActions)); + } + } catch { + // Intentionally left blank. + } + }, [graph, messageThing]); return ( @@ -56,10 +82,10 @@ const OthersActivityStatus = memo(({ activity }: Props) => { () => [ timestamp && , - replyAction && , - voteActions.size && + claimInterpreter && , + feedbackActions?.size && ].filter(Boolean), - [replyAction, timestamp, voteActions] + [claimInterpreter, timestamp, feedbackActions] )} ); diff --git a/packages/component/src/ActivityStatus/private/Feedback/Feedback.tsx b/packages/component/src/ActivityStatus/private/Feedback/Feedback.tsx index 7cd465e8ef..b5686dc31f 100644 --- a/packages/component/src/ActivityStatus/private/Feedback/Feedback.tsx +++ b/packages/component/src/ActivityStatus/private/Feedback/Feedback.tsx @@ -1,36 +1,37 @@ import { hooks } from 'botframework-webchat-api'; +import { type OrgSchemaAction } from 'botframework-webchat-core'; +import React, { Fragment, memo, useEffect, useState, type PropsWithChildren } from 'react'; import { useRefFrom } from 'use-ref-from'; -import React, { Fragment, memo, type PropsWithChildren, useState, useEffect } from 'react'; -import { type VoteAction } from '../../../types/external/OrgSchema/VoteAction'; import FeedbackVoteButton from './private/VoteButton'; const { usePonyfill, usePostActivity } = hooks; type Props = Readonly< PropsWithChildren<{ - voteActions: ReadonlySet; + actions: ReadonlySet; }> >; const DEBOUNCE_TIMEOUT = 500; -const Feedback = memo(({ voteActions }: Props) => { +const Feedback = memo(({ actions }: Props) => { const [{ clearTimeout, setTimeout }] = usePonyfill(); - const [selectedVoteAction, setSelectedVoteAction] = useState(); + const [selectedAction, setSelectedAction] = useState(); const postActivity = usePostActivity(); const postActivityRef = useRefFrom(postActivity); useEffect(() => { - if (!selectedVoteAction) { + if (!selectedAction) { return; } const timeout = setTimeout( () => + // TODO: We should update this to use W3C Hydra.1 postActivityRef.current({ - entities: [selectedVoteAction], + entities: [selectedAction], name: 'webchat:activity-status/feedback', type: 'event' } as any), @@ -38,16 +39,16 @@ const Feedback = memo(({ voteActions }: Props) => { ); return () => clearTimeout(timeout); - }, [clearTimeout, postActivityRef, selectedVoteAction, setTimeout]); + }, [clearTimeout, postActivityRef, selectedAction, setTimeout]); return ( - {Array.from(voteActions).map((voteAction, index) => ( + {Array.from(actions).map((action, index) => ( ))} diff --git a/packages/component/src/ActivityStatus/private/Feedback/private/VoteButton.tsx b/packages/component/src/ActivityStatus/private/Feedback/private/VoteButton.tsx index 2a372d07de..c3bf888a54 100644 --- a/packages/component/src/ActivityStatus/private/Feedback/private/VoteButton.tsx +++ b/packages/component/src/ActivityStatus/private/Feedback/private/VoteButton.tsx @@ -1,28 +1,34 @@ -import React, { memo, useCallback } from 'react'; +import { onErrorResumeNext, parseVoteAction, type OrgSchemaAction } from 'botframework-webchat-core'; +import React, { memo, useCallback, useMemo } from 'react'; import { useRefFrom } from 'use-ref-from'; -import { type VoteAction } from '../../../../types/external/OrgSchema/VoteAction'; import ThumbsButton from './ThumbButton'; type Props = Readonly<{ - onClick?: (voteAction: VoteAction) => void; + action: OrgSchemaAction; + onClick?: (action: OrgSchemaAction) => void; pressed: boolean; - voteAction: VoteAction; }>; -const FeedbackVoteButton = memo(({ onClick, pressed, voteAction }: Props) => { +const FeedbackVoteButton = memo(({ action, onClick, pressed }: Props) => { const onClickRef = useRefFrom(onClick); - const voteActionRef = useRefFrom(voteAction); + const voteActionRef = useRefFrom(action); + + const direction = useMemo(() => { + if ( + action['@type'] === 'DislikeAction' || + (action['@type'] === 'VoteAction' && + onErrorResumeNext(() => parseVoteAction(action))?.actionOption === 'downvote') + ) { + return 'down'; + } + + return 'up'; + }, [action]); const handleClick = useCallback(() => onClickRef.current?.(voteActionRef.current), [onClickRef, voteActionRef]); - return ( - - ); + return ; }); FeedbackVoteButton.displayName = 'FeedbackVoteButton'; diff --git a/packages/component/src/ActivityStatus/private/Originator.tsx b/packages/component/src/ActivityStatus/private/Originator.tsx index 2f6aa2c0c5..90fb0eadd3 100644 --- a/packages/component/src/ActivityStatus/private/Originator.tsx +++ b/packages/component/src/ActivityStatus/private/Originator.tsx @@ -1,14 +1,12 @@ +import { type OrgSchemaProject } from 'botframework-webchat-core'; import React, { memo } from 'react'; -import { type ReplyAction } from '../../types/external/OrgSchema/ReplyAction'; +type Props = Readonly<{ project: OrgSchemaProject }>; -type Props = Readonly<{ replyAction: ReplyAction }>; +const Originator = memo(({ project }: Props) => { + const { name, slogan, url } = project; -const Originator = memo(({ replyAction }: Props) => { - const { description, provider } = replyAction; - - const text = description || provider?.name; - const url = provider?.url; + const text = slogan || name; return url ? ( ; const TextAttachment: FC = memo(({ activity, attachment: { content, contentType } }: Props) => ( - + )); TextAttachment.displayName = 'TextAttachment'; diff --git a/packages/component/src/Attachment/Text/TextContent.tsx b/packages/component/src/Attachment/Text/TextContent.tsx index acbc364bee..bfe65ab816 100644 --- a/packages/component/src/Attachment/Text/TextContent.tsx +++ b/packages/component/src/Attachment/Text/TextContent.tsx @@ -7,17 +7,17 @@ import useRenderMarkdownAsHTML from '../../hooks/useRenderMarkdownAsHTML'; import { type WebChatActivity } from 'botframework-webchat-core'; type Props = Readonly<{ + activity: WebChatActivity; contentType?: string; - entities?: WebChatActivity['entities']; text: string; }>; -const TextContent: FC = memo(({ contentType = 'text/plain', entities, text }: Props) => { +const TextContent: FC = memo(({ activity, contentType = 'text/plain', text }: Props) => { const supportMarkdown = !!useRenderMarkdownAsHTML(); return text ? ( contentType === 'text/markdown' && supportMarkdown ? ( - + ) : ( ) diff --git a/packages/component/src/Attachment/Text/private/CitationItem.tsx b/packages/component/src/Attachment/Text/private/CitationItem.tsx deleted file mode 100644 index d83cb530ae..0000000000 --- a/packages/component/src/Attachment/Text/private/CitationItem.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { useRefFrom } from 'use-ref-from'; -import React, { memo, type MouseEventHandler, useCallback } from 'react'; - -import ItemBody from './ItemBody'; - -type Props = Readonly<{ - identifier: string; - onClick?: (url: string) => void; - title?: string; - url: string; -}>; - -const CitationItem = memo(({ identifier, onClick, title, url }: Props) => { - const onClickRef = useRefFrom(onClick); - const urlHref = useRefFrom(url); - - const handleClick = useCallback>( - () => onClickRef.current?.(urlHref.current), - [onClickRef, urlHref] - ); - - return ( - - ); -}); - -CitationItem.displayName = 'CitationItem'; - -export default CitationItem; diff --git a/packages/component/src/Attachment/Text/private/ItemBody.tsx b/packages/component/src/Attachment/Text/private/ItemBody.tsx deleted file mode 100644 index 46451c6186..0000000000 --- a/packages/component/src/Attachment/Text/private/ItemBody.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import React, { memo } from 'react'; - -import Badge from './Badge'; -import OpenInNewWindowIcon from './OpenInNewWindowIcon'; - -type Props = Readonly<{ - identifier: string; - isExternal?: boolean; - title: string; -}>; - -const ItemBody = memo(({ identifier, isExternal, title }: Props) => ( -
- {identifier ? : null} -
{title}
- {isExternal ? : null} -
-)); - -ItemBody.displayName = 'ItemBody'; - -export default ItemBody; diff --git a/packages/component/src/Attachment/Text/private/LinkDefinitions.tsx b/packages/component/src/Attachment/Text/private/LinkDefinitions.tsx deleted file mode 100644 index e571ae4d8b..0000000000 --- a/packages/component/src/Attachment/Text/private/LinkDefinitions.tsx +++ /dev/null @@ -1,67 +0,0 @@ -// @ts-expect-error TS1479 should be fixed when bumping to typescript@5. -import { fromMarkdown } from 'mdast-util-from-markdown'; -import { hooks } from 'botframework-webchat-api'; -import { onErrorResumeNext } from 'botframework-webchat-core'; -import { type Definition } from 'mdast'; -import classNames from 'classnames'; -import React, { memo } from 'react'; - -import Chevron from './Chevron'; -import CitationItem from './CitationItem'; -import URLItem from './URLItem'; -import useStyleSet from '../../../hooks/useStyleSet'; - -const { useLocalizer } = hooks; - -type Props = Readonly<{ - markdown: string; - onCitationClick?: (url: string) => void; -}>; - -const REFERENCE_LIST_HEADER_IDS = { - one: 'REFERENCE_LIST_HEADER_ONE', - few: 'REFERENCE_LIST_HEADER_FEW', - many: 'REFERENCE_LIST_HEADER_MANY', - other: 'REFERENCE_LIST_HEADER_OTHER', - two: 'REFERENCE_LIST_HEADER_TWO' -}; - -function isCitation(url: string): boolean { - return onErrorResumeNext(() => new URL(url).protocol) === 'cite:'; -} - -const LinkDefinitions = memo(({ markdown, onCitationClick }: Props) => { - const [{ linkDefinitions }] = useStyleSet(); - const definitions = fromMarkdown(markdown).children.filter((node): node is Definition => node.type === 'definition'); - const localizeWithPlural = useLocalizer({ plural: true }); - - const headerText = localizeWithPlural(REFERENCE_LIST_HEADER_IDS, definitions.length); - - return definitions.length > 0 ? ( -
- - {headerText} - -
- {definitions.map(definition => ( -
- {isCitation(definition.url) ? ( - - ) : ( - - )} -
- ))} -
-
- ) : null; -}); - -LinkDefinitions.displayName = 'LinkDefinitions'; - -export default LinkDefinitions; diff --git a/packages/component/src/Attachment/Text/private/MarkdownTextContent.tsx b/packages/component/src/Attachment/Text/private/MarkdownTextContent.tsx index 8dede8dc6a..3d94061cc5 100644 --- a/packages/component/src/Attachment/Text/private/MarkdownTextContent.tsx +++ b/packages/component/src/Attachment/Text/private/MarkdownTextContent.tsx @@ -1,27 +1,48 @@ import { hooks } from 'botframework-webchat-api'; -import { useRefFrom } from 'use-ref-from'; +import { + getOrgSchemaMessage, + onErrorResumeNext, + parseClaim, + type OrgSchemaClaim, + type WebChatActivity +} from 'botframework-webchat-core'; import classNames from 'classnames'; -import React, { memo, type MouseEventHandler, useCallback, useMemo } from 'react'; +import type { Definition } from 'mdast'; +// @ts-expect-error TS1479 should be fixed when bumping to typescript@5. +import { fromMarkdown } from 'mdast-util-from-markdown'; +import React, { memo, useCallback, useMemo, type MouseEventHandler } from 'react'; +import { useRefFrom } from 'use-ref-from'; -import { isClaim, type Claim } from '../../../types/external/OrgSchema/Claim'; -import { isThing } from '../../../types/external/OrgSchema/Thing'; +import { LinkDefinitionItem, LinkDefinitions } from '../../../LinkDefinition/index'; +import dereferenceBlankNodes from '../../../Utils/JSONLinkedData/dereferenceBlankNodes'; +import useRenderMarkdownAsHTML from '../../../hooks/useRenderMarkdownAsHTML'; +import useStyleSet from '../../../hooks/useStyleSet'; +import useShowModal from '../../../providers/ModalDialog/useShowModal'; import { type PropsOf } from '../../../types/PropsOf'; -import { type WebChatActivity } from 'botframework-webchat-core'; import CitationModalContext from './CitationModalContent'; +import MessageSensitivityLabel from './MessageSensitivityLabel'; import isHTMLButtonElement from './isHTMLButtonElement'; -import LinkDefinitions from './LinkDefinitions'; -import useRenderMarkdownAsHTML from '../../../hooks/useRenderMarkdownAsHTML'; -import useShowModal from '../../../providers/ModalDialog/useShowModal'; -import useStyleSet from '../../../hooks/useStyleSet'; const { useLocalizer } = hooks; +type Entry = { + claim?: OrgSchemaClaim | undefined; + handleClick?: (() => void) | undefined; + key: string; + markdownDefinition: Definition; + url?: string | undefined; +}; + type Props = Readonly<{ - entities?: WebChatActivity['entities']; + activity: WebChatActivity; markdown: string; }>; -const MarkdownTextContent = memo(({ entities, markdown }: Props) => { +function isCitationURL(url: string): boolean { + return onErrorResumeNext(() => new URL(url))?.protocol === 'cite:'; +} + +const MarkdownTextContent = memo(({ activity, markdown }: Props) => { const [ { citationModalDialog: citationModalDialogStyleSet, @@ -29,11 +50,12 @@ const MarkdownTextContent = memo(({ entities, markdown }: Props) => { textContent: textContentStyleSet } ] = useStyleSet(); - const entitiesRef = useRefFrom(entities); + const localize = useLocalizer(); + const graph = useMemo(() => dereferenceBlankNodes(activity.entities || []), [activity.entities]); const renderMarkdownAsHTML = useRenderMarkdownAsHTML(); const showModal = useShowModal(); - const localize = useLocalizer(); + const messageThing = useMemo(() => getOrgSchemaMessage(graph), [graph]); const citationModalDialogLabel = localize('CITATION_MODEL_DIALOG_ALT'); @@ -46,27 +68,75 @@ const MarkdownTextContent = memo(({ entities, markdown }: Props) => { [renderMarkdownAsHTML, markdown] ); + const markdownDefinitions = useMemo( + () => fromMarkdown(markdown).children.filter((node): node is Definition => node.type === 'definition'), + [markdown] + ); + const showClaimModal = useCallback( - (claim: Claim) => { - showModal(() => , { - 'aria-label': claim.alternateName || claim.name || citationModalDialogLabel, + (title, text, altText) => { + showModal(() => , { + 'aria-label': altText || title || citationModalDialogLabel, className: classNames('webchat__citation-modal-dialog', citationModalDialogStyleSet) }); }, [citationModalDialogStyleSet, citationModalDialogLabel, showModal] ); - const handleCitationClick = useCallback['onCitationClick']>( - url => { - const claim = entities.find( - (entity): entity is Claim => isThing(entity) && isClaim(entity) && entity['@id'] === url - ); - - claim && showClaimModal(claim); - }, - [entities, showClaimModal] + const entries = useMemo( + () => + Object.freeze( + markdownDefinitions.map(markdownDefinition => { + const messageCitation = messageThing?.citation + ?.map(parseClaim) + .find(({ position }) => '' + position === markdownDefinition.identifier); + + if (messageCitation) { + return { + claim: messageCitation, + key: markdownDefinition.url, + handleClick: + messageCitation?.appearance && !messageCitation.appearance.url + ? () => + showClaimModal( + markdownDefinition.title, + messageCitation.appearance.text, + messageCitation.alternateName + ) + : undefined, + markdownDefinition, + url: messageCitation?.appearance ? messageCitation.appearance.url : markdownDefinition.url + }; + } + + const rootLevelClaim = graph + .filter(({ type }) => type === 'https://schema.org/Claim') + .map(parseClaim) + .find(({ '@id': id }) => id === markdownDefinition.url); + + if (rootLevelClaim) { + return { + claim: rootLevelClaim, + key: markdownDefinition.url, + handleClick: isCitationURL(rootLevelClaim['@id']) + ? () => showClaimModal(markdownDefinition.title, rootLevelClaim.text, rootLevelClaim.alternateName) + : undefined, + markdownDefinition + }; + } + + return { + key: markdownDefinition.url, + markdownDefinition, + url: markdownDefinition.url + }; + }) + ), + [graph, markdownDefinitions, messageThing, showClaimModal] ); + const entriesRef = useRefFrom(entries); + const handleClick = useCallback>( event => { // Find out what
+ ); +}); + +LinkDefinitionItem.displayName = 'LinkDefinitionItem'; + +export default LinkDefinitionItem; diff --git a/packages/component/src/LinkDefinition/LinkDefinitions.tsx b/packages/component/src/LinkDefinition/LinkDefinitions.tsx new file mode 100644 index 0000000000..b03034ae4f --- /dev/null +++ b/packages/component/src/LinkDefinition/LinkDefinitions.tsx @@ -0,0 +1,61 @@ +import { hooks } from 'botframework-webchat-api'; +import classNames from 'classnames'; +import React, { Children, memo, type ComponentType, type ReactNode } from 'react'; + +import useStyleSet from '../hooks/useStyleSet'; +import { type PropsOf } from '../types/PropsOf'; +import Chevron from './private/Chevron'; + +const { useLocalizer } = hooks; +const { count: childrenCount, map: childrenMap } = Children; + +type Props = Readonly<{ + accessoryComponentType: TAccessory; + accessoryProps: PropsOf; + children?: ReactNode | undefined; +}>; + +const REFERENCE_LIST_HEADER_IDS = { + one: 'REFERENCE_LIST_HEADER_ONE', + few: 'REFERENCE_LIST_HEADER_FEW', + many: 'REFERENCE_LIST_HEADER_MANY', + other: 'REFERENCE_LIST_HEADER_OTHER', + two: 'REFERENCE_LIST_HEADER_TWO' +}; + +const LinkDefinitions = ({ + accessoryComponentType, + accessoryProps, + children +}: Props) => { + const [{ linkDefinitions }] = useStyleSet(); + const localizeWithPlural = useLocalizer({ plural: true }); + + const headerText = localizeWithPlural(REFERENCE_LIST_HEADER_IDS, childrenCount(children)); + + return ( +
+ +
{headerText}
+ +
+ {accessoryComponentType && ( +
+ {React.createElement(accessoryComponentType, accessoryProps)} +
+ )} +
+
+ {childrenMap(children, child => ( +
+ {child} +
+ ))} +
+
+ ); +}; + +LinkDefinitions.displayName = 'LinkDefinitions'; + +export default memo(LinkDefinitions); diff --git a/packages/component/src/LinkDefinition/index.ts b/packages/component/src/LinkDefinition/index.ts new file mode 100644 index 0000000000..99545df902 --- /dev/null +++ b/packages/component/src/LinkDefinition/index.ts @@ -0,0 +1,4 @@ +import LinkDefinitionItem from './LinkDefinitionItem'; +import LinkDefinitions from './LinkDefinitions'; + +export { LinkDefinitionItem, LinkDefinitions }; diff --git a/packages/component/src/Attachment/Text/private/Badge.tsx b/packages/component/src/LinkDefinition/private/Badge.tsx similarity index 100% rename from packages/component/src/Attachment/Text/private/Badge.tsx rename to packages/component/src/LinkDefinition/private/Badge.tsx diff --git a/packages/component/src/Attachment/Text/private/Chevron.tsx b/packages/component/src/LinkDefinition/private/Chevron.tsx similarity index 100% rename from packages/component/src/Attachment/Text/private/Chevron.tsx rename to packages/component/src/LinkDefinition/private/Chevron.tsx diff --git a/packages/component/src/LinkDefinition/private/ItemBody.tsx b/packages/component/src/LinkDefinition/private/ItemBody.tsx new file mode 100644 index 0000000000..fba2ab3fbd --- /dev/null +++ b/packages/component/src/LinkDefinition/private/ItemBody.tsx @@ -0,0 +1,35 @@ +import React, { memo } from 'react'; + +import Badge from './Badge'; +import OpenInNewWindowIcon from './OpenInNewWindowIcon'; + +type Props = Readonly<{ + badgeName?: string; + badgeTitle?: string; + identifier?: string; + isExternal?: boolean; + text: string; +}>; + +const ItemBody = memo(({ badgeName, badgeTitle, identifier, isExternal, text }: Props) => ( +
+ {identifier ? : null} +
+
+
+ {text} +
+ {isExternal ? : null} +
+ {badgeName && ( +
+ {badgeName} +
+ )} +
+
+)); + +ItemBody.displayName = 'ItemBody'; + +export default ItemBody; diff --git a/packages/component/src/Attachment/Text/private/OpenInNewWindowIcon.tsx b/packages/component/src/LinkDefinition/private/OpenInNewWindowIcon.tsx similarity index 100% rename from packages/component/src/Attachment/Text/private/OpenInNewWindowIcon.tsx rename to packages/component/src/LinkDefinition/private/OpenInNewWindowIcon.tsx diff --git a/packages/component/src/Attachment/Text/private/extractHostnameWithSubdomain.spec.ts b/packages/component/src/LinkDefinition/private/extractHostnameWithSubdomain.spec.ts similarity index 100% rename from packages/component/src/Attachment/Text/private/extractHostnameWithSubdomain.spec.ts rename to packages/component/src/LinkDefinition/private/extractHostnameWithSubdomain.spec.ts diff --git a/packages/component/src/Attachment/Text/private/extractHostnameWithSubdomain.ts b/packages/component/src/LinkDefinition/private/extractHostnameWithSubdomain.ts similarity index 100% rename from packages/component/src/Attachment/Text/private/extractHostnameWithSubdomain.ts rename to packages/component/src/LinkDefinition/private/extractHostnameWithSubdomain.ts diff --git a/packages/component/src/Styles/CustomPropertyNames.ts b/packages/component/src/Styles/CustomPropertyNames.ts index fa9d9667de..06bfc6300b 100644 --- a/packages/component/src/Styles/CustomPropertyNames.ts +++ b/packages/component/src/Styles/CustomPropertyNames.ts @@ -1,6 +1,7 @@ const CustomPropertyNames = Object.freeze({ // Make sure key names does not have JavaScript forbidden names. ColorAccent: '--webchat__color--accent', + ColorSubtle: '--webchat__color--subtle', ColorTimestamp: '--webchat__color--timestamp', FontPrimary: '--webchat__font--primary', FontSizeSmall: '--webchat__font-size--small', diff --git a/packages/component/src/Styles/StyleSet/CSSCustomProperties.ts b/packages/component/src/Styles/StyleSet/CSSCustomProperties.ts index a97816d6e6..08635feedc 100644 --- a/packages/component/src/Styles/StyleSet/CSSCustomProperties.ts +++ b/packages/component/src/Styles/StyleSet/CSSCustomProperties.ts @@ -33,6 +33,7 @@ export default function createCSSCustomPropertiesStyle({ // - We MUST NOT put runtime variables here, e.g. sendTimeout // - This is because we cannot programmatically know when the sendTimeout change [CustomPropertyNames.ColorAccent]: accent, + [CustomPropertyNames.ColorSubtle]: subtle, [CustomPropertyNames.ColorTimestamp]: timestampColor || subtle, // Maybe we should not need this if we allow web devs to override CSS variables for certain components. [CustomPropertyNames.FontPrimary]: primaryFont, [CustomPropertyNames.FontSizeSmall]: fontSizeSmall, diff --git a/packages/component/src/Styles/StyleSet/LinkDefinitions.ts b/packages/component/src/Styles/StyleSet/LinkDefinitions.ts index 4a769e42c8..736787b064 100644 --- a/packages/component/src/Styles/StyleSet/LinkDefinitions.ts +++ b/packages/component/src/Styles/StyleSet/LinkDefinitions.ts @@ -9,17 +9,20 @@ import CSSTokens from '../CSSTokens'; export default function createLinkDefinitionsStyleSet() { return { '&.webchat__link-definitions': { - '&[open] .webchat__link-definitions__header::after': { - transform: 'rotate(0deg)' - }, + // '&[open] .webchat__link-definitions__header::after': { + // transform: 'rotate(0deg)' + // }, '.webchat__link-definitions__header': { - fontFamily: "Calibri, 'Helvetica Neue', Arial, 'sans-serif'", - fontSize: '80%', + alignItems: 'center', + display: 'flex', + fontFamily: CSSTokens.FontPrimary, + fontSize: CSSTokens.FontSizeSmall, + gap: 4, listStyle: 'none', [LIGHT_THEME_SELECTOR]: { - color: '#616161' + color: '#616161' // TODO: Should we use subtle color instead? }, [DARK_THEME_SELECTOR]: { @@ -28,15 +31,46 @@ export default function createLinkDefinitionsStyleSet() { } }, + '.webchat__link-definitions__header-text': { + flexShrink: 0 + }, + '.webchat__link-definitions__header::-webkit-details-marker': { display: 'none' }, + '.webchat__link-definitions__header-chevron': { + flexShrink: 0 + }, + '&:not([open]) .webchat__link-definitions__header-chevron': { - marginBottom: '-0.1em', transform: 'rotate(-180deg)' }, + '.webchat__link-definitions__header-filler': { + flexGrow: 1 + }, + + '.webchat__link-definitions__header-accessory': { + overflow: 'hidden' + }, + + '.webchat__link-definitions__message-sensitivity-label': { + display: 'flex', + gap: 4 + }, + + '.webchat__link-definitions__message-sensitivity-label-icon': { + color: 'CanvasText', + flexShrink: 0 + }, + + '.webchat__link-definitions__message-sensitivity-label-text': { + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap' + }, + '.webchat__link-definitions__list': { display: 'flex', flexDirection: 'column', @@ -52,6 +86,7 @@ export default function createLinkDefinitionsStyleSet() { '.webchat__link-definitions__badge': { alignItems: 'center', + alignSelf: 'flex-start', borderRadius: '4px', borderStyle: 'solid', borderWidth: 1, @@ -119,7 +154,8 @@ export default function createLinkDefinitionsStyleSet() { fontFamily: 'inherit', fontSize: 'inherit', overflow: 'hidden', - padding: 0 + padding: 0, + textAlign: 'initial' // By default, texts inside button are centered. }, '.webchat__link-definitions__list-item-body': { @@ -127,23 +163,51 @@ export default function createLinkDefinitionsStyleSet() { display: 'flex', fontFamily: "Calibri, 'Helvetica Neue', Arial, 'sans-serif'", gap: 4, - padding: 4, + padding: 4 + }, - [NOT_FORCED_COLORS_SELECTOR]: { - color: CSSTokens.ColorAccent - } + '.webchat__link-definitions__list-item-body-main': { + display: 'flex', + flexDirection: 'column', + gap: 2, + overflow: 'hidden' }, - '.webchat__link-definitions__list-item-text': { + '.webchat__link-definitions__list-item-main-text': { + alignItems: 'baseline', + display: 'flex', + gap: 4 + }, + + '.webchat__link-definitions__list-item-badge, .webchat__link-definitions__list-item-text': { overflow: 'hidden', - textDecoration: 'underline', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, + '.webchat__link-definitions__list-item-text': { + textDecoration: 'underline', + + [NOT_FORCED_COLORS_SELECTOR]: { + color: CSSTokens.ColorAccent + } + }, + + '.webchat__link-definitions__list-item-badge': { + fontSize: CSSTokens.FontSizeSmall, + + [NOT_FORCED_COLORS_SELECTOR]: { + color: CSSTokens.ColorSubtle + } + }, + '.webchat__link-definitions__open-in-new-window-icon': { flexShrink: 0, // When text is too long, make sure the chevron is not squeezed. - paddingRight: 4 // When text is too long and chevron is on far right, this will align the chevron so it's not too far. + paddingRight: 4, // When text is too long and chevron is on far right, this will align the chevron so it's not too far. + + [NOT_FORCED_COLORS_SELECTOR]: { + color: CSSTokens.ColorAccent + } } } }; diff --git a/packages/component/src/Styles/StyleSet/RenderMarkdown.ts b/packages/component/src/Styles/StyleSet/RenderMarkdown.ts index 3683ba6fe4..bf25a5e173 100644 --- a/packages/component/src/Styles/StyleSet/RenderMarkdown.ts +++ b/packages/component/src/Styles/StyleSet/RenderMarkdown.ts @@ -1,5 +1,5 @@ -import { FORCED_COLORS_SELECTOR, NOT_FORCED_COLORS_SELECTOR } from './Constants'; import CSSTokens from '../CSSTokens'; +import { FORCED_COLORS_SELECTOR, NOT_FORCED_COLORS_SELECTOR } from './Constants'; // This style is for accompanying result of `renderMarkdown()`. // Mostly, it should only styles elements that are generated/modified during `renderMarkdown()`. @@ -32,6 +32,10 @@ export default function createMarkdownStyle() { } }, + '& .webchat__render-markdown__pure-identifier': { + whiteSpace: 'nowrap' + }, + '& .webchat__render-markdown__pure-identifier::after': { content: "']'" }, diff --git a/packages/component/src/Utils/JSONLinkedData/BlankNode.ts b/packages/component/src/Utils/JSONLinkedData/BlankNode.ts new file mode 100644 index 0000000000..c16d01ad59 --- /dev/null +++ b/packages/component/src/Utils/JSONLinkedData/BlankNode.ts @@ -0,0 +1,13 @@ +/** + * A blank node identifier is a string starting with `"_:"` + * + * @see https://json-ld.github.io/json-ld.org/spec/latest/json-ld/#dfn-blank-node-identifiers + */ +export type BlankNodeIdentifier = `_:${string}`; + +/** + * A blank node is a node with `@id` starting with `"_:"` + * + * @see https://json-ld.github.io/json-ld.org/spec/latest/json-ld/#dfn-blank-nodes + */ +export type BlankNode = { '@id': BlankNodeIdentifier }; diff --git a/packages/component/src/Utils/JSONLinkedData/__snapshots__/dereferenceBlankNodes.spec.ts.snap b/packages/component/src/Utils/JSONLinkedData/__snapshots__/dereferenceBlankNodes.spec.ts.snap new file mode 100644 index 0000000000..8f18a565f5 --- /dev/null +++ b/packages/component/src/Utils/JSONLinkedData/__snapshots__/dereferenceBlankNodes.spec.ts.snap @@ -0,0 +1,92 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should dereference unconnected blank nodes in array in cyclic fashion 1`] = ` +Object { + "@type": "Book", + "about": Array [ + Object { + "@id": "_:isaac", + "@type": "Person", + "name": "Isaac Stevens", + "worksFor": Object { + "@id": "_:uw", + "@type": "Organization", + "founder": [Circular], + "name": "University of Washington", + }, + }, + Object { + "@id": "_:uw", + "@type": "Organization", + "founder": Object { + "@id": "_:isaac", + "@type": "Person", + "name": "Isaac Stevens", + "worksFor": [Circular], + }, + "name": "University of Washington", + }, + ], + "author": Object { + "@type": "Person", + "name": "Edmond S. Meany", + }, + "name": "Governors of Washington: territorial and state", +} +`; + +exports[`should dereference unconnected blank nodes in cyclic fashion 1`] = ` +Object { + "@type": "Book", + "author": Object { + "@id": "_:isaac", + "@type": "Person", + "name": "Isaac Stevens", + "worksFor": Object { + "@id": "_:uw", + "@type": "Organization", + "founder": [Circular], + "name": "University of Washington", + }, + }, + "name": "Report of Explorations for a Route for the Pacific Railroad near the 47th and 49th Parallels of North Latitude, from St. Paul, Minnesota, to Puget Sound", + "sourceOrganization": Object { + "@id": "_:uw", + "@type": "Organization", + "founder": Object { + "@id": "_:isaac", + "@type": "Person", + "name": "Isaac Stevens", + "worksFor": [Circular], + }, + "name": "University of Washington", + }, +} +`; + +exports[`should dereference unconnected blank nodes in the graph 1`] = ` +Array [ + Object { + "@id": "_:isaac", + "@type": "Person", + "name": "Isaac Stevens", + "worksFor": Object { + "@id": "_:uw", + "@type": "Organization", + "founder": [Circular], + "name": "University of Washington", + }, + }, + Object { + "@id": "_:uw", + "@type": "Organization", + "founder": Object { + "@id": "_:isaac", + "@type": "Person", + "name": "Isaac Stevens", + "worksFor": [Circular], + }, + "name": "University of Washington", + }, +] +`; diff --git a/packages/component/src/Utils/JSONLinkedData/dereferenceBlankNodes.spec.ts b/packages/component/src/Utils/JSONLinkedData/dereferenceBlankNodes.spec.ts new file mode 100644 index 0000000000..f1be6c4de9 --- /dev/null +++ b/packages/component/src/Utils/JSONLinkedData/dereferenceBlankNodes.spec.ts @@ -0,0 +1,175 @@ +import dereferenceBlankNodes from './dereferenceBlankNodes'; + +test('should leave non-blank node as-is', () => { + const object = { + '@type': 'Thing', + first: { '@type': 'Thing' }, + second: { '@type': 'Thing', text: 'Hello, World!' }, + third: { text: 'Aloha!' } + }; + + const input = [object]; + const output = dereferenceBlankNodes(input); + + expect(output).not.toBe(input); + + const [nextObject] = output as any; + + expect(nextObject).not.toBe(object); + + expect(nextObject).toEqual({ + '@type': 'Thing', + first: { '@type': 'Thing' }, + second: { '@type': 'Thing', text: 'Hello, World!' }, + third: { text: 'Aloha!' } + }); + + expect(Object.isFrozen(nextObject)).toBe(true); + expect(Object.isFrozen(nextObject.first)).toBe(true); + expect(Object.isFrozen(nextObject.second)).toBe(true); + expect(Object.isFrozen(nextObject.third)).toBe(true); +}); + +test('should dereference unconnected blank nodes in cyclic fashion', () => { + const object = { + '@type': 'Book', + author: { + '@type': 'Person', + '@id': '_:isaac', + name: 'Isaac Stevens', + worksFor: { '@type': 'Organization', '@id': '_:uw' } + }, + name: 'Report of Explorations for a Route for the Pacific Railroad near the 47th and 49th Parallels of North Latitude, from St. Paul, Minnesota, to Puget Sound', + sourceOrganization: { + '@type': 'Organization', + '@id': '_:uw', + name: 'University of Washington', + founder: { '@type': 'Person', '@id': '_:isaac' } + } + }; + + const input = [object]; + const output = dereferenceBlankNodes(input); + + expect(output).not.toBe(input); + + const [nextObject] = output as any; + + expect(nextObject).toMatchSnapshot(); + + expect(nextObject).not.toBe(object); + expect(nextObject.author).not.toBe(object.author); + expect(nextObject.sourceOrganization).not.toBe(object.sourceOrganization); + + expect(nextObject.author.worksFor).toBe(nextObject.sourceOrganization); + expect(nextObject.sourceOrganization.founder).toBe(nextObject.author); + + expect(nextObject.author.name).toBe('Isaac Stevens'); + expect(nextObject.author.worksFor.name).toBe('University of Washington'); + expect(nextObject.sourceOrganization.name).toBe('University of Washington'); + expect(nextObject.sourceOrganization.founder.name).toBe('Isaac Stevens'); + + expect(Object.isFrozen(nextObject)).toBe(true); + expect(Object.isFrozen(nextObject.author)).toBe(true); + expect(Object.isFrozen(nextObject.sourceOrganization)).toBe(true); +}); + +test('should dereference unconnected blank nodes in array in cyclic fashion', () => { + const object = { + '@type': 'Book', + author: { '@type': 'Person', name: 'Edmond S. Meany' }, + about: [ + { + '@type': 'Person', + '@id': '_:isaac', + name: 'Isaac Stevens', + worksFor: { + '@type': 'Organization', + '@id': '_:uw', + name: 'University of Washington', + founder: { '@type': 'Person', '@id': '_:isaac' } + } + }, + { + '@type': 'Organization', + '@id': '_:uw' + } + ], + name: 'Governors of Washington: territorial and state' + }; + + const input = [object]; + const output = dereferenceBlankNodes(input); + + expect(output).not.toBe(input); + + const [nextObject] = output as any; + + expect(nextObject).not.toBe(object); + expect(nextObject).toMatchSnapshot(); + + expect(nextObject.about[0].worksFor).toBe(nextObject.about[1]); + expect(nextObject.about[1].founder).toBe(nextObject.about[0]); + + expect(nextObject.about[0].name).toBe('Isaac Stevens'); + expect(nextObject.about[0].worksFor.name).toBe('University of Washington'); + expect(nextObject.about[1].name).toBe('University of Washington'); + expect(nextObject.about[1].founder.name).toBe('Isaac Stevens'); +}); + +test('should dereference unconnected blank nodes in the graph', () => { + const input = [ + { + '@type': 'Person', + '@id': '_:isaac', + name: 'Isaac Stevens', + worksFor: { + '@type': 'Organization', + '@id': '_:uw', + name: 'University of Washington', + founder: { '@type': 'Person', '@id': '_:isaac' } + } + }, + { + '@type': 'Organization', + '@id': '_:uw' + } + ]; + + const output = dereferenceBlankNodes(input) as any; + + expect(output).not.toBe(input); + expect(output).toMatchSnapshot(); + + expect(output[0].worksFor).toBe(output[1]); + expect(output[1].founder).toBe(output[0]); + + expect(output[0].name).toBe('Isaac Stevens'); + expect(output[0].worksFor.name).toBe('University of Washington'); + expect(output[1].name).toBe('University of Washington'); + expect(output[1].founder.name).toBe('Isaac Stevens'); + + expect(Object.isFrozen(output)).toBe(true); + expect(Object.isFrozen(output[0])).toBe(true); + expect(Object.isFrozen(output[1])).toBe(true); +}); + +test('should not dereference unconnectable blank nodes', () => { + const object = { first: { '@id': '_:a' } }; + + const [nextObject] = dereferenceBlankNodes([object]); + + expect(nextObject).toEqual({ first: { '@id': '_:a' } }); + + expect(Object.isFrozen(nextObject)).toBe(true); +}); + +test('should not dereference unconnectable blank nodes in an array', () => { + const object = { first: [{ '@id': '_:a' }] }; + + const [nextObject] = dereferenceBlankNodes([object]); + + expect(nextObject).toEqual({ first: [{ '@id': '_:a' }] }); + + expect(Object.isFrozen(nextObject)).toBe(true); +}); diff --git a/packages/component/src/Utils/JSONLinkedData/dereferenceBlankNodes.ts b/packages/component/src/Utils/JSONLinkedData/dereferenceBlankNodes.ts new file mode 100644 index 0000000000..a90c0e0b35 --- /dev/null +++ b/packages/component/src/Utils/JSONLinkedData/dereferenceBlankNodes.ts @@ -0,0 +1,86 @@ +import deepFreeze from 'deep-freeze-strict'; + +import { type BlankNode } from './BlankNode'; +import getSafeOwnPropertyNames from './getSafeOwnPropertyNames'; +import isBlankNode from './isBlankNode'; +import isUnconnectedBlankNode from './isUnconnectedBlankNode'; +import visitOnce from './visitOnce'; + +function dereferenceBlankNodesInline(objects: object[]): void { + const blankNodeIdMap = new Map(); + + const objectsToVisit1: any[] = [objects]; + const shouldVisit1 = visitOnce(); + + while (objectsToVisit1.length) { + const object = objectsToVisit1.shift(); + + if (!object) { + continue; + } + + const indices = getSafeOwnPropertyNames(object); + + for (const index of indices) { + // eslint-disable-next-line security/detect-object-injection + const value = object[index]; + + if (isBlankNode(value) && !isUnconnectedBlankNode(value)) { + blankNodeIdMap.set(value['@id'], value); + } + + shouldVisit1(value) && objectsToVisit1.push(value); + } + } + + const objectsToVisit2: unknown[] = [objects]; + const shouldVisit2 = visitOnce(); + + while (objectsToVisit2.length) { + const object = objectsToVisit2.shift(); + + if (!object) { + continue; + } + + const indices = getSafeOwnPropertyNames(object); + + for (const index of indices) { + // eslint-disable-next-line security/detect-object-injection + const value = object[index]; + + if (isBlankNode(value) && isUnconnectedBlankNode(value)) { + const blankNode = blankNodeIdMap.get(value['@id']); + + if (blankNode) { + // eslint-disable-next-line security/detect-object-injection + object[index] = blankNode; + } + } else { + shouldVisit2(value) && objectsToVisit2.push(value); + } + } + } +} + +/** + * Dereferences all unconnected blank nodes to their corresponding blank node. This is done by replacing all unconnected blank nodes in a graph and purposefully introduce cyclic dependencies to help querying the graph. + * + * This function will always return a new instance of all objects in the graph. + * + * This function assumes the graph conforms to JSON-LD, notably: + * + * - For nodes that share the same blank node identifier, one of them should be connected and the other must be unconnected + * - If none of them are connected node, these unconnected blank node will not be replaced + * + * @see https://json-ld.github.io/json-ld.org/spec/latest/json-ld/#data-model-overview + * @param graph A list of nodes in the graph. + * @returns A structured clone of graph with unconnected blank nodes replaced by their corresponding blank node. + */ +export default function dereferenceBlankNodes(graph: T[]): readonly T[] { + const nextObjects = structuredClone(graph); + + dereferenceBlankNodesInline(nextObjects); + + return deepFreeze(nextObjects); +} diff --git a/packages/component/src/Utils/JSONLinkedData/getSafeOwnPropertyNames.ts b/packages/component/src/Utils/JSONLinkedData/getSafeOwnPropertyNames.ts new file mode 100644 index 0000000000..19765ea17d --- /dev/null +++ b/packages/component/src/Utils/JSONLinkedData/getSafeOwnPropertyNames.ts @@ -0,0 +1,13 @@ +const DANGEROUS_PROPERTY_NAMES = ['__proto__', 'constructor', 'prototype']; + +/** + * Returns a whitelisted own-property names. + * + * If `null` or `undefined` is passed, an empty list will be returned. + * + * @param object An object (including primitives). + * @returns A whitelisted own-property names. + */ +export default function getSafeOwnPropertyNames(object: unknown): string[] { + return Object.getOwnPropertyNames(object).filter(name => !DANGEROUS_PROPERTY_NAMES.includes(name)); +} diff --git a/packages/component/src/Utils/JSONLinkedData/isBlankNode.spec.ts b/packages/component/src/Utils/JSONLinkedData/isBlankNode.spec.ts new file mode 100644 index 0000000000..294232044c --- /dev/null +++ b/packages/component/src/Utils/JSONLinkedData/isBlankNode.spec.ts @@ -0,0 +1,18 @@ +import isBlankNode from './isBlankNode'; + +test('passing an object with @id of "_:b1" should return true', () => + expect(isBlankNode({ '@id': '_:b1' })).toBe(true)); + +test('passing an object with @id of "https://example.com/" should return false', () => + expect(isBlankNode({ '@id': 'https://example.com/' })).toBe(false)); + +test('passing an object without @id should return false', () => expect(isBlankNode({})).toBe(false)); + +test('passing an object with @id of a number should return false', () => + expect(isBlankNode({ '@id': 123 })).toBe(false)); + +test('passing a number should return false', () => expect(isBlankNode(0)).toBe(false)); + +test('passing a function should return false', () => expect(isBlankNode(() => 0)).toBe(false)); + +test('passing a string should return false', () => expect(isBlankNode('Hello, World!')).toBe(false)); diff --git a/packages/component/src/Utils/JSONLinkedData/isBlankNode.ts b/packages/component/src/Utils/JSONLinkedData/isBlankNode.ts new file mode 100644 index 0000000000..758536b92b --- /dev/null +++ b/packages/component/src/Utils/JSONLinkedData/isBlankNode.ts @@ -0,0 +1,19 @@ +import { type BlankNode } from './BlankNode'; + +/** + * A blank node is a node with `@id` starting with `"_:"` + * + * @see https://json-ld.github.io/json-ld.org/spec/latest/json-ld/#dfn-blank-nodes + * @param node A node. + * @returns `true`, if the node is a blank node, otherwise, `false`. + */ +export default function isBlankNode(node: unknown): node is BlankNode { + // TODO: Do we restrict to plain object or just anything? + if (node) { + const id = node['@id']; + + return typeof id === 'string' && id.startsWith('_:'); + } + + return false; +} diff --git a/packages/component/src/Utils/JSONLinkedData/isUnconnectedBlankNode.spec.ts b/packages/component/src/Utils/JSONLinkedData/isUnconnectedBlankNode.spec.ts new file mode 100644 index 0000000000..3349bba3ea --- /dev/null +++ b/packages/component/src/Utils/JSONLinkedData/isUnconnectedBlankNode.spec.ts @@ -0,0 +1,13 @@ +import isUnconnectedBlankNode from './isUnconnectedBlankNode'; + +test('passing a blank node identifier should return true', () => + expect(isUnconnectedBlankNode({ '@id': '_:b1' })).toBe(true)); + +test('passing a blank node should return false', () => + expect(isUnconnectedBlankNode({ '@id': '_:b1', name: 'John Doe' })).toBe(false)); + +test('passing a number should return false', () => expect(isUnconnectedBlankNode(0 as any)).toBe(false)); + +test('passing a function should return false', () => expect(isUnconnectedBlankNode((() => 0) as any)).toBe(false)); + +test('passing a string should return false', () => expect(isUnconnectedBlankNode('Hello, World!' as any)).toBe(false)); diff --git a/packages/component/src/Utils/JSONLinkedData/isUnconnectedBlankNode.ts b/packages/component/src/Utils/JSONLinkedData/isUnconnectedBlankNode.ts new file mode 100644 index 0000000000..f6043d6b28 --- /dev/null +++ b/packages/component/src/Utils/JSONLinkedData/isUnconnectedBlankNode.ts @@ -0,0 +1,13 @@ +import { type BlankNode } from './BlankNode'; +import isBlankNode from './isBlankNode'; + +/** + * An unconnected blank node is a blank node without any edge or properties. + * + * @see https://json-ld.github.io/json-ld.org/spec/latest/json-ld/#data-model + * @param object A blank node. + * @returns `true`, if the blank node is unconnected, otherwise, `false`. + */ +export default function isUnconnectedBlankNode(object: T): boolean { + return isBlankNode(object) && Object.getOwnPropertyNames(object).every(name => name.startsWith('@')); +} diff --git a/packages/component/src/Utils/JSONLinkedData/visitOnce.ts b/packages/component/src/Utils/JSONLinkedData/visitOnce.ts new file mode 100644 index 0000000000..527a4b960e --- /dev/null +++ b/packages/component/src/Utils/JSONLinkedData/visitOnce.ts @@ -0,0 +1,13 @@ +export default function visitOnce(): (value: T) => boolean { + const visited = new Set(); + + return (value: T) => { + if (visited.has(value)) { + return false; + } + + visited.add(value); + + return true; + }; +} diff --git a/packages/component/src/types/external/OrgSchema/Claim.spec.ts b/packages/component/src/types/external/OrgSchema/Claim.spec.ts deleted file mode 100644 index 5c87841643..0000000000 --- a/packages/component/src/types/external/OrgSchema/Claim.spec.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { isClaim } from './Claim'; - -test('should return true for Claim', () => - expect(isClaim({ '@context': 'https://schema.org', '@type': 'Claim', type: 'https://schema.org/Claim' })).toBe(true)); - -test('should return false for Person', () => - expect(isClaim({ '@context': 'https://schema.org', '@type': 'Person', type: 'https://schema.org/Person' })).toBe( - false - )); diff --git a/packages/component/src/types/external/OrgSchema/Claim.ts b/packages/component/src/types/external/OrgSchema/Claim.ts deleted file mode 100644 index 7a02536bea..0000000000 --- a/packages/component/src/types/external/OrgSchema/Claim.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { isThingOf, type Thing } from './Thing'; - -/** - * A [Claim](https://schema.org/Claim) in Schema.org represents a specific, factually-oriented claim that could be the [itemReviewed](https://schema.org/itemReviewed) in a [ClaimReview](https://schema.org/ClaimReview). The content of a claim can be summarized with the [text](https://schema.org/text) property. Variations on well known claims can have their common identity indicated via [sameAs](https://schema.org/sameAs) links, and summarized with a name. Ideally, a [Claim](https://schema.org/Claim) description includes enough contextual information to minimize the risk of ambiguity or inclarity. In practice, many claims are better understood in the context in which they appear or the interpretations provided by claim reviews. - * - * Beyond [ClaimReview](https://schema.org/ClaimReview), the Claim type can be associated with related creative works - for example a [ScholarlyArticle](https://schema.org/ScholarlyArticle) or [Question](https://schema.org/Question) might be about some [Claim](https://schema.org/Claim). - * - * This is partial implementation of https://schema.org/Claim. - * - * @see https://schema.org/Claim. - */ -export type Claim = Thing<'Claim'> & { - /** The textual content of this CreativeWork. */ - text?: string; - - /** The name of the item. */ - name?: string; - - /** URL of the item. */ - url?: string; -}; - -export function isClaim(thing: Thing): thing is Claim { - return isThingOf(thing, 'Claim'); -} diff --git a/packages/component/src/types/external/OrgSchema/Project.spec.ts b/packages/component/src/types/external/OrgSchema/Project.spec.ts deleted file mode 100644 index 88315521e6..0000000000 --- a/packages/component/src/types/external/OrgSchema/Project.spec.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { isProject } from './Project'; - -test('should return true for Project', () => - expect(isProject({ '@context': 'https://schema.org', '@type': 'Project', type: 'https://schema.org/Project' })).toBe( - true - )); - -test('should return false for Person', () => - expect(isProject({ '@context': 'https://schema.org', '@type': 'Person', type: 'https://schema.org/Person' })).toBe( - false - )); diff --git a/packages/component/src/types/external/OrgSchema/Project.ts b/packages/component/src/types/external/OrgSchema/Project.ts deleted file mode 100644 index 40fc9928f8..0000000000 --- a/packages/component/src/types/external/OrgSchema/Project.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { isThingOf, type Thing } from './Thing'; - -/** - * An enterprise (potentially individual but typically collaborative), planned to achieve a particular aim. Use properties from [Organization](https://schema.org/Organization), [subOrganization](https://schema.org/subOrganization)/[parentOrganization](https://schema.org/parentOrganization) to indicate project sub-structures. - * - * This is partial implementation of https://schema.org/Project. - * - * @see https://schema.org/Project - */ -export type Project = Thing<'Project'> & { - /** The name of the item. */ - name: string; - - /** URL of the item. */ - url: string; -}; - -export function isProject(thing: Thing): thing is Project { - return isThingOf(thing, 'Project'); -} diff --git a/packages/component/src/types/external/OrgSchema/ReplyAction.spec.ts b/packages/component/src/types/external/OrgSchema/ReplyAction.spec.ts deleted file mode 100644 index 505ce58345..0000000000 --- a/packages/component/src/types/external/OrgSchema/ReplyAction.spec.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { isReplyAction } from './ReplyAction'; - -test('should return true for ReplyAction', () => - expect( - isReplyAction({ '@context': 'https://schema.org', '@type': 'ReplyAction', type: 'https://schema.org/ReplyAction' }) - ).toBe(true)); - -test('should return false for Person', () => - expect( - isReplyAction({ '@context': 'https://schema.org', '@type': 'Person', type: 'https://schema.org/Person' }) - ).toBe(false)); diff --git a/packages/component/src/types/external/OrgSchema/ReplyAction.ts b/packages/component/src/types/external/OrgSchema/ReplyAction.ts deleted file mode 100644 index 6fea61de58..0000000000 --- a/packages/component/src/types/external/OrgSchema/ReplyAction.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { isThingOf, type Thing } from './Thing'; -import { type Project } from './Project'; - -/** - * The act of responding to a question/message asked/sent by the object. Related to [AskAction](https://schema.org/AskAction). - * - * This is partial implementation of https://schema.org/ReplyAction. - * - * @see https://schema.org/ReplyAction - */ -export type ReplyAction = Thing<'ReplyAction'> & { - /** A description of the item. */ - description?: string; - - /** The service provider, service operator, or service performer; the goods producer. Another party (a seller) may offer those services or goods on behalf of the provider. A provider may also serve as the seller. Supersedes [carrier](https://schema.org/carrier). */ - provider?: Project; -}; - -export function isReplyAction(thing: Thing): thing is ReplyAction { - return isThingOf(thing, 'ReplyAction'); -} diff --git a/packages/component/src/types/external/OrgSchema/Thing.spec.ts b/packages/component/src/types/external/OrgSchema/Thing.spec.ts deleted file mode 100644 index 8a71b46bbd..0000000000 --- a/packages/component/src/types/external/OrgSchema/Thing.spec.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { isThing, isThingOf } from './Thing'; - -describe('isThing', () => { - describe.each([['Thing'], ['Person']])('of %s', type => { - test('should return true with @context, @type, and type', () => - expect(isThing({ '@context': 'https://schema.org', '@type': type, type: `https://schema.org/${type}` })).toBe( - true - )); - }); - - test('should return false with conflicting type', () => - expect(isThing({ '@context': 'https://schema.org', '@type': 'Action', type: `https://schema.org/Person` })).toBe( - false - )); - - test('should return false when conflict with unknown @context', () => - expect(isThing({ '@context': 'https://abc.com', type: `https://schema.org/Person` } as any)).toBe(false)); - - test('should return false when conflict without @context', () => - expect(isThing({ '@type': 'Action', type: `https://schema.org/Person` } as any)).toBe(false)); - - test('should return false for empty array', () => expect(isThing([] as any)).toBe(false)); - test('should return false for false', () => expect(isThing(false as any)).toBe(false)); - test('should return false for null', () => expect(isThing(null as any)).toBe(false)); - test('should return false for number', () => expect(isThing(0 as any)).toBe(false)); - test('should return false for plain object', () => expect(isThing({} as any)).toBe(false)); - test('should return false for string', () => expect(isThing('Hello, World!' as any)).toBe(false)); - test('should return false for undefined', () => expect(isThing(undefined as any)).toBe(false)); -}); - -describe('isThingOf', () => { - test('should return true for Person with @context, @type, and type', () => - expect( - isThingOf({ '@context': 'https://schema.org', '@type': 'Person', type: 'https://schema.org/Person' }, 'Person') - ).toBe(true)); - - test('should return false for Person with @context and @type of Action but type of Person', () => - expect( - isThingOf({ '@context': 'https://schema.org', '@type': 'Action', type: 'https://schema.org/Person' }, 'Person') - ).toBe(false)); - - test('should return false for Person with @context and @type of Person but type of Action', () => - expect( - isThingOf({ '@context': 'https://schema.org', '@type': 'Person', type: 'https://schema.org/Action' }, 'Person') - ).toBe(false)); - - test('should return false for Person with @context, @type, and type of Action', () => - expect( - isThingOf({ '@context': 'https://schema.org', '@type': 'Action', type: 'https://schema.org/Action' }, 'Person') - ).toBe(false)); - - test('should return false for empty array', () => expect(isThing([] as any)).toBe(false)); - test('should return false for false', () => expect(isThing(false as any)).toBe(false)); - test('should return false for null', () => expect(isThing(null as any)).toBe(false)); - test('should return false for number', () => expect(isThing(0 as any)).toBe(false)); - test('should return false for plain object', () => expect(isThing({} as any)).toBe(false)); - test('should return false for string', () => expect(isThing('Hello, World!' as any)).toBe(false)); - test('should return false for undefined', () => expect(isThing(undefined as any)).toBe(false)); -}); diff --git a/packages/component/src/types/external/OrgSchema/Thing.ts b/packages/component/src/types/external/OrgSchema/Thing.ts deleted file mode 100644 index 7aae6df6ee..0000000000 --- a/packages/component/src/types/external/OrgSchema/Thing.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { type OrgSchemaThing } from 'botframework-webchat-core'; - -/** - * The most generic type of item. - * - * This is partial implementation of https://schema.org/Thing. - * - * @see https://schema.org/Thing - */ -export type Thing = OrgSchemaThing & { - '@id'?: string; - - /** An alias for the item. */ - alternateName?: string; - - /** The name of the item. */ - name?: string; -}; - -export function isThing(thing: { '@context'?: string; '@type'?: string; type?: string }): thing is Thing { - if (typeof thing === 'object' && thing) { - return ( - '@context' in thing && - '@type' in thing && - 'type' in thing && - thing['@context'] === 'https://schema.org' && - typeof thing['@type'] === 'string' && - thing.type === `https://schema.org/${thing['@type']}` - ); - } - - return false; -} - -export function isThingOf( - thing: { '@context'?: string; '@type'?: string; type?: string }, - type: T -): thing is Thing { - if (typeof thing === 'object' && thing) { - return ( - '@context' in thing && - '@type' in thing && - 'type' in thing && - thing['@context'] === 'https://schema.org' && - thing['@type'] === type && - thing.type === `https://schema.org/${type}` - ); - } - - return false; -} diff --git a/packages/component/src/types/external/OrgSchema/VoteAction.spec.ts b/packages/component/src/types/external/OrgSchema/VoteAction.spec.ts deleted file mode 100644 index 028d8b79dd..0000000000 --- a/packages/component/src/types/external/OrgSchema/VoteAction.spec.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { isVoteAction } from './VoteAction'; - -test('should return true for VoteAction', () => - expect( - isVoteAction({ '@context': 'https://schema.org', '@type': 'VoteAction', type: 'https://schema.org/VoteAction' }) - ).toBe(true)); - -test('should return false for Person', () => - expect(isVoteAction({ '@context': 'https://schema.org', '@type': 'Person', type: 'https://schema.org/Person' })).toBe( - false - )); diff --git a/packages/component/src/types/external/OrgSchema/VoteAction.ts b/packages/component/src/types/external/OrgSchema/VoteAction.ts deleted file mode 100644 index 3bcbe70766..0000000000 --- a/packages/component/src/types/external/OrgSchema/VoteAction.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { isThingOf, type Thing } from './Thing'; - -/** - * The act of expressing a preference from a fixed/finite/structured set of choices/options. - * - * This is partial implementation of https://schema.org/VoteAction. - * - * @see https://schema.org/VoteAction - */ -export type VoteAction = Thing<'VoteAction'> & { - /** A sub property of object. The options subject to this action. Supersedes [option](https://schema.org/option). */ - actionOption?: string; -}; - -export function isVoteAction(thing: Thing): thing is VoteAction { - return isThingOf(thing, 'VoteAction'); -} diff --git a/packages/core/package-lock.json b/packages/core/package-lock.json index d0391c3b88..6b150366e3 100644 --- a/packages/core/package-lock.json +++ b/packages/core/package-lock.json @@ -18,7 +18,8 @@ "redux": "5.0.0", "redux-devtools-extension": "2.13.9", "redux-saga": "1.2.3", - "simple-update-in": "2.2.0" + "simple-update-in": "2.2.0", + "valibot": "^0.29.0" }, "devDependencies": { "@babel/cli": "^7.18.10", @@ -5225,6 +5226,11 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/valibot": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-0.29.0.tgz", + "integrity": "sha512-JhZn08lwZPhAamOCfBwBkv/btQt4KeQhekULPH8crH053zUCLSOGEF2zKExu3bFf245tsj6J1dY0ysd/jUiMIQ==" + }, "node_modules/validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", @@ -8982,6 +8988,11 @@ "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", "dev": true }, + "valibot": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-0.29.0.tgz", + "integrity": "sha512-JhZn08lwZPhAamOCfBwBkv/btQt4KeQhekULPH8crH053zUCLSOGEF2zKExu3bFf245tsj6J1dY0ysd/jUiMIQ==" + }, "validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", diff --git a/packages/core/package.json b/packages/core/package.json index 5091acb3eb..6cf01df292 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -79,7 +79,8 @@ "redux": "5.0.0", "redux-devtools-extension": "2.13.9", "redux-saga": "1.2.3", - "simple-update-in": "2.2.0" + "simple-update-in": "2.2.0", + "valibot": "^0.29.0" }, "engines": { "node": ">=12.0.0" diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b53f051703..fe1efd09c6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,18 +1,9 @@ -import * as ActivityClientState from './constants/ActivityClientState'; -import * as DictateState from './constants/DictateState'; import clearSuggestedActions from './actions/clearSuggestedActions'; import connect from './actions/connect'; -import createStore, { - withDevTools as createStoreWithDevTools, - withOptions as createStoreWithOptions -} from './createStore'; import disconnect from './actions/disconnect'; import dismissNotification from './actions/dismissNotification'; import emitTypingIndicator from './actions/emitTypingIndicator'; -import isForbiddenPropertyName from './utils/isForbiddenPropertyName'; import markActivity from './actions/markActivity'; -import OneOrMany from './types/OneOrMany'; -import onErrorResumeNext from './utils/onErrorResumeNext'; import postActivity from './actions/postActivity'; import sendEvent from './actions/sendEvent'; import sendFiles from './actions/sendFiles'; @@ -26,14 +17,33 @@ import setNotification from './actions/setNotification'; import setSendBox from './actions/setSendBox'; import setSendTimeout from './actions/setSendTimeout'; import setSendTypingIndicator from './actions/setSendTypingIndicator'; -import singleToArray from './utils/singleToArray'; import startDictate from './actions/startDictate'; import startSpeakingActivity from './actions/startSpeakingActivity'; import stopDictate from './actions/stopDictate'; import stopSpeakingActivity from './actions/stopSpeakingActivity'; import submitSendBox from './actions/submitSendBox'; +import * as ActivityClientState from './constants/ActivityClientState'; +import * as DictateState from './constants/DictateState'; +import createStore, { + withDevTools as createStoreWithDevTools, + withOptions as createStoreWithOptions +} from './createStore'; +import OneOrMany from './types/OneOrMany'; +import { parseAction } from './types/external/OrgSchema/Action'; +import { parseClaim } from './types/external/OrgSchema/Claim'; +import { parseCreativeWork } from './types/external/OrgSchema/CreativeWork'; +import { parseDefinedTerm } from './types/external/OrgSchema/DefinedTerm'; +import { parseProject } from './types/external/OrgSchema/Project'; +import { parseThing } from './types/external/OrgSchema/Thing'; +import { parseVoteAction } from './types/external/OrgSchema/VoteAction'; +import getOrgSchemaMessage from './utils/getOrgSchemaMessage'; +import isForbiddenPropertyName from './utils/isForbiddenPropertyName'; +import onErrorResumeNext from './utils/onErrorResumeNext'; +import singleToArray from './utils/singleToArray'; import warnOnce from './utils/warnOnce'; +import type { GlobalScopePonyfill } from './types/GlobalScopePonyfill'; +import type { WebChatActivity } from './types/WebChatActivity'; import type { DirectLineActivity } from './types/external/DirectLineActivity'; import type { DirectLineAnimationCard } from './types/external/DirectLineAnimationCard'; import type { DirectLineAttachment } from './types/external/DirectLineAttachment'; @@ -47,27 +57,38 @@ import type { DirectLineSignInCard } from './types/external/DirectLineSignInCard import type { DirectLineSuggestedAction } from './types/external/DirectLineSuggestedAction'; import type { DirectLineThumbnailCard } from './types/external/DirectLineThumbnailCard'; import type { DirectLineVideoCard } from './types/external/DirectLineVideoCard'; -import type { GlobalScopePonyfill } from './types/GlobalScopePonyfill'; import type { Observable } from './types/external/Observable'; -import type { OrgSchemaThing } from './types/external/OrgSchemaThing'; -import type { WebChatActivity } from './types/WebChatActivity'; +import type { Action as OrgSchemaAction } from './types/external/OrgSchema/Action'; +import type { Claim as OrgSchemaClaim } from './types/external/OrgSchema/Claim'; +import type { CreativeWork as OrgSchemaCreativeWork } from './types/external/OrgSchema/CreativeWork'; +import type { DefinedTerm as OrgSchemaDefinedTerm } from './types/external/OrgSchema/DefinedTerm'; +import type { Project as OrgSchemaProject } from './types/external/OrgSchema/Project'; +import type { Thing as OrgSchemaThing } from './types/external/OrgSchema/Thing'; const Constants = { ActivityClientState, DictateState }; const version = process.env.npm_package_version; export { + Constants, clearSuggestedActions, connect, - Constants, createStore, createStoreWithDevTools, createStoreWithOptions, disconnect, dismissNotification, emitTypingIndicator, + getOrgSchemaMessage, isForbiddenPropertyName, markActivity, onErrorResumeNext, + parseAction, + parseClaim, + parseCreativeWork, + parseDefinedTerm, + parseProject, + parseThing, + parseVoteAction, postActivity, sendEvent, sendFiles, @@ -98,16 +119,21 @@ export type { DirectLineAudioCard, DirectLineCardAction, DirectLineHeroCard, - DirectLineOAuthCard, DirectLineJSBotConnection, + DirectLineOAuthCard, DirectLineReceiptCard, DirectLineSignInCard, DirectLineSuggestedAction, DirectLineThumbnailCard, DirectLineVideoCard, + GlobalScopePonyfill, Observable, OneOrMany, - GlobalScopePonyfill, + OrgSchemaAction, + OrgSchemaClaim, + OrgSchemaCreativeWork, + OrgSchemaDefinedTerm, + OrgSchemaProject, OrgSchemaThing, WebChatActivity }; diff --git a/packages/core/src/types/WebChatActivity.ts b/packages/core/src/types/WebChatActivity.ts index e26c572fc9..eeaf4321cb 100644 --- a/packages/core/src/types/WebChatActivity.ts +++ b/packages/core/src/types/WebChatActivity.ts @@ -8,9 +8,9 @@ // - "conversationUpdate" activity is never sent to Web Chat, thus, it is not defined import type { AnyAnd } from './AnyAnd'; +// import type { AsEntity, Thing } from './external/OrgSchema/Thing'; import type { DirectLineAttachment } from './external/DirectLineAttachment'; import type { DirectLineSuggestedAction } from './external/DirectLineSuggestedAction'; -import type { OrgSchemaThing } from './external/OrgSchemaThing'; type SupportedRole = 'bot' | 'channel' | 'user'; type SupportedSendStatus = 'sending' | 'send failed' | 'sent'; @@ -111,8 +111,9 @@ type ClientCapabilitiesEntity = { type Entity = | ClientCapabilitiesEntity - | OrgSchemaThing - | AnyAnd<{ type: Exclude }>; + // Schema.org thing in the first level of entities field must have "type" field of "string". + // | AsEntity + | { type: string }; // Channel account - https://github.com/Microsoft/botframework-sdk/blob/main/specs/botframework-activity/botframework-activity.md#channel-account type ChannelAcount = { diff --git a/packages/core/src/types/external/OrgSchema/Action.spec.ts b/packages/core/src/types/external/OrgSchema/Action.spec.ts new file mode 100644 index 0000000000..dc68c20b18 --- /dev/null +++ b/packages/core/src/types/external/OrgSchema/Action.spec.ts @@ -0,0 +1,35 @@ +import { parseAction } from './Action'; + +describe('Action', () => { + describe('actionStatus', () => { + test('should parse', () => + expect( + parseAction({ + '@type': 'Action', + actionStatus: 'ActiveActionStatus' + }) + ).toEqual({ + '@type': 'Action', + actionStatus: 'ActiveActionStatus' + })); + + test('should change invalid into undefined', () => { + try { + expect( + parseAction({ + '@type': 'Action', + actionStatus: 'ABC' + }) + ).toEqual({ + '@type': 'Action', + actionStatus: undefined + }); + } catch (err) { + console.error(err); + console.error(err.issues[0].input); + + throw err; + } + }); + }); +}); diff --git a/packages/core/src/types/external/OrgSchema/Action.ts b/packages/core/src/types/external/OrgSchema/Action.ts new file mode 100644 index 0000000000..d69047f849 --- /dev/null +++ b/packages/core/src/types/external/OrgSchema/Action.ts @@ -0,0 +1,52 @@ +import { lazy, parse, string, union, value, type ObjectEntries, type StringSchema } from 'valibot'; + +import orgSchemaProperty from './private/orgSchemaProperty'; +import { project, type Project } from './Project'; +import { thing, type Thing } from './Thing'; + +/** + * An action performed by a direct agent and indirect participants upon a direct object. Optionally happens at a location with the help of an inanimate instrument. The execution of the action may produce a result. Specific action sub-type documentation specifies the exact expectation of each argument/role. + * + * See also [blog post](http://blog.schema.org/2014/04/announcing-schemaorg-actions.html) and [Actions overview document](https://schema.org/docs/actions.html). + * + * This is partial implementation of https://schema.org/Action. + * + * @see https://schema.org/Action + */ +export type Action = Thing & { + /** + * Indicates the current disposition of the Action. + * + * @see https://schema.org/actionStatus + */ + actionStatus?: + | 'ActiveActionStatus' + | 'CompletedActionStatu' + | 'FailedActionStatus' + | 'PotentialActionStatus' + | undefined; + + /** + * The service provider, service operator, or service performer; the goods producer. Another party (a seller) may offer those services or goods on behalf of the provider. A provider may also serve as the seller. Supersedes [carrier](https://schema.org/carrier). + * + * @see https://schema.org/provider + */ + provider?: Project | undefined; +}; + +export const action = (entries?: TEntries | undefined) => + thing({ + actionStatus: orgSchemaProperty( + union([ + string([value('ActiveActionStatus')]) as StringSchema<'ActiveActionStatus'>, + string([value('CompletedActionStatus')]) as StringSchema<'CompletedActionStatus'>, + string([value('FailedActionStatus')]) as StringSchema<'FailedActionStatus'>, + string([value('PotentialActionStatus')]) as StringSchema<'PotentialActionStatus'> + ]) + ), + provider: orgSchemaProperty(lazy(() => project())), + + ...entries + }); + +export const parseAction = (data: unknown): Action => parse(action(), data); diff --git a/packages/core/src/types/external/OrgSchema/Claim.spec.ts b/packages/core/src/types/external/OrgSchema/Claim.spec.ts new file mode 100644 index 0000000000..a409826118 --- /dev/null +++ b/packages/core/src/types/external/OrgSchema/Claim.spec.ts @@ -0,0 +1,45 @@ +import { parseClaim } from './Claim'; + +describe('Claim', () => { + test('should parse appearance', () => + expect( + parseClaim({ + '@type': 'Claim', + appearance: { + '@type': 'Book', + name: 'Business @ the Speed of Thought' + } + }) + ).toEqual({ + '@type': 'Claim', + appearance: { + '@type': 'Book', + name: 'Business @ the Speed of Thought' + } + })); + + test('should parse claimInterpreter', () => + expect( + parseClaim({ + '@type': 'Claim', + claimInterpreter: { + '@type': 'Project', + slogan: 'Empower every person and every organization on the planet to achieve more.' + } + }) + ).toEqual({ + '@type': 'Claim', + claimInterpreter: { + '@type': 'Project', + slogan: 'Empower every person and every organization on the planet to achieve more.' + } + })); + + describe('should parse position', () => { + test('as a number', () => + expect(parseClaim({ '@type': 'Claim', position: 1 })).toEqual({ '@type': 'Claim', position: 1 })); + + test('as a string', () => + expect(parseClaim({ '@type': 'Claim', position: 'First' })).toEqual({ '@type': 'Claim', position: 'First' })); + }); +}); diff --git a/packages/core/src/types/external/OrgSchema/Claim.ts b/packages/core/src/types/external/OrgSchema/Claim.ts new file mode 100644 index 0000000000..206544f566 --- /dev/null +++ b/packages/core/src/types/external/OrgSchema/Claim.ts @@ -0,0 +1,48 @@ +import { lazy, number, parse, string, union, type ObjectEntries } from 'valibot'; + +import { creativeWork, type CreativeWork } from './CreativeWork'; +import { project, type Project } from './Project'; +import orgSchemaProperty from './private/orgSchemaProperty'; + +/** + * A [Claim](https://schema.org/Claim) in Schema.org represents a specific, factually-oriented claim that could be the [itemReviewed](https://schema.org/itemReviewed) in a [ClaimReview](https://schema.org/ClaimReview). The content of a claim can be summarized with the [text](https://schema.org/text) property. Variations on well known claims can have their common identity indicated via [sameAs](https://schema.org/sameAs) links, and summarized with a name. Ideally, a [Claim](https://schema.org/Claim) description includes enough contextual information to minimize the risk of ambiguity or inclarity. In practice, many claims are better understood in the context in which they appear or the interpretations provided by claim reviews. + * + * Beyond [ClaimReview](https://schema.org/ClaimReview), the Claim type can be associated with related creative works - for example a [ScholarlyArticle](https://schema.org/ScholarlyArticle) or [Question](https://schema.org/Question) might be about some [Claim](https://schema.org/Claim). + * + * This is partial implementation of https://schema.org/Claim. + * + * @see https://schema.org/Claim. + */ +export type Claim = CreativeWork & { + /** + * Indicates an occurrence of a [Claim](https://schema.org/Claim) in some [CreativeWork](https://schema.org/CreativeWork). + * + * @see https://schema.org/appearance. + */ + appearance?: CreativeWork | undefined; + + /** + * For a [Claim](https://schema.org/Claim) interpreted from [MediaObject](https://schema.org/MediaObject) content sed to indicate a claim contained, implied or refined from the content of a [MediaObject](https://schema.org/MediaObject). + * + * @see https://schema.org/claimInterpreter. + */ + claimInterpreter?: Project | undefined; + + /** + * The position of an item in a series or sequence of items. + * + * @see https://schema.org/position. + */ + position?: number | string; +}; + +export const claim = (entries?: TEntries | undefined) => + creativeWork({ + appearance: orgSchemaProperty(lazy(() => creativeWork())), + claimInterpreter: orgSchemaProperty(lazy(() => project())), + position: orgSchemaProperty(union([number(), string()])), + + ...entries + }); + +export const parseClaim = (data: unknown): Claim => parse(claim(), data); diff --git a/packages/core/src/types/external/OrgSchema/CreativeWork.parseCreativeWork.spec.ts b/packages/core/src/types/external/OrgSchema/CreativeWork.parseCreativeWork.spec.ts new file mode 100644 index 0000000000..9dff5c0162 --- /dev/null +++ b/packages/core/src/types/external/OrgSchema/CreativeWork.parseCreativeWork.spec.ts @@ -0,0 +1,51 @@ +import { parseCreativeWork } from './CreativeWork'; + +describe('CreativeWork', () => { + test('should parse properties from Thing', () => + expect( + parseCreativeWork({ + '@type': 'CreativeWork', + name: 'Business @ the Speed of Thought' + }) + ).toEqual({ + '@type': 'CreativeWork', + name: 'Business @ the Speed of Thought' + })); + + test('should parse thing of Book type', () => + expect( + parseCreativeWork({ + '@type': 'Book', + name: 'Business @ the Speed of Thought' + }) + ).toEqual({ + '@type': 'Book', + name: 'Business @ the Speed of Thought' + })); + + test('should parse citation (singular)', () => + expect( + parseCreativeWork({ + '@type': 'Book', + name: 'Business @ the Speed of Thought', + citation: { '@type': 'Book', name: 'The Road Ahead' } + }) + ).toEqual({ + '@type': 'Book', + name: 'Business @ the Speed of Thought', + citation: [{ '@type': 'Book', name: 'The Road Ahead' }] + })); + + test('should parse citation (plural)', () => + expect( + parseCreativeWork({ + '@type': 'Book', + name: 'Business @ the Speed of Thought', + citation: [{ '@type': 'Book', name: 'The Road Ahead' }] + }) + ).toEqual({ + '@type': 'Book', + name: 'Business @ the Speed of Thought', + citation: [{ '@type': 'Book', name: 'The Road Ahead' }] + })); +}); diff --git a/packages/core/src/types/external/OrgSchema/CreativeWork.ts b/packages/core/src/types/external/OrgSchema/CreativeWork.ts new file mode 100644 index 0000000000..0e5ce3e8a4 --- /dev/null +++ b/packages/core/src/types/external/OrgSchema/CreativeWork.ts @@ -0,0 +1,83 @@ +import { lazy, parse, string, union, type ObjectEntries } from 'valibot'; + +import { definedTerm, type DefinedTerm } from './DefinedTerm'; +import orgSchemaProperties from './private/orgSchemaProperties'; +import orgSchemaProperty from './private/orgSchemaProperty'; +import { thing, type Thing } from './Thing'; + +/** + * The most generic kind of creative work, including books, movies, photographs, software programs, etc. + * + * This is partial implementation of https://schema.org/CreativeWork. + * + * @see https://schema.org/CreativeWork + */ +// Due to limitations of TypeScript, when using valibot.lazy(), the output type must be explicitly set. +export type CreativeWork = Thing & { + /** + * An abstract is a short description that summarizes a [CreativeWork](https://schema.org/CreativeWork). + * + * @see https://schema.org/abstract + */ + abstract?: string | undefined; + + /** + * The author of this content or rating. Please note that author is special in that HTML 5 provides a special mechanism for indicating authorship via the rel tag. That is equivalent to this and may be used interchangeably. + * + * @see https://schema.org/author + */ + author?: string | undefined; + + /** + * A citation or reference to another creative work, such as another publication, web page, scholarly article, etc. + * + * @see https://schema.org/citation + */ + citation?: readonly CreativeWork[] | undefined; + + /** + * Keywords or tags used to describe some item. Multiple textual entries in a keywords list are typically delimited by commas, or by repeating the property. + * + * @see https://schema.org/keywords + */ + keywords?: readonly (DefinedTerm | string)[] | undefined; + + /** + * A pattern that something has, for example 'polka dot', 'striped', 'Canadian flag'. Values are typically expressed as text, although links to controlled value schemes are also supported. + * + * @see https://schema.org/pattern + */ + pattern?: DefinedTerm | undefined; + + /** + * The textual content of this CreativeWork. + * + * @see https://schema.org/text + */ + text?: string | undefined; + + /** + * The schema.org [usageInfo](https://schema.org/usageInfo) property indicates further information about a [CreativeWork](https://schema.org/CreativeWork). This property is applicable both to works that are freely available and to those that require payment or other transactions. It can reference additional information, e.g. community expectations on preferred linking and citation conventions, as well as purchasing details. For something that can be commercially licensed, usageInfo can provide detailed, resource-specific information about licensing options. + * + * This property can be used alongside the license property which indicates license(s) applicable to some piece of content. The usageInfo property can provide information about other licensing options, e.g. acquiring commercial usage rights for an image that is also available under non-commercial creative commons licenses. + */ + usageInfo?: CreativeWork | undefined; +}; + +export const creativeWork = (entries?: TEntries | undefined) => + thing({ + // For forward compatibility, we did not enforce @type must be "CreativeWork" or any other subtypes. + // In future, if Schema.org introduced a new subtype of CreativeWork, we should still able to parse that one as a CreativeWork. + + abstract: orgSchemaProperty(string()), + author: orgSchemaProperties(string()), + citation: orgSchemaProperties(lazy(() => creativeWork())), + keywords: orgSchemaProperties(union([lazy(() => definedTerm()), string()])), + pattern: orgSchemaProperty(lazy(() => definedTerm())), + text: orgSchemaProperty(string()), + usageInfo: orgSchemaProperty(lazy(() => creativeWork())), + + ...entries + }); + +export const parseCreativeWork = (data: unknown): CreativeWork => parse(creativeWork(), data); diff --git a/packages/core/src/types/external/OrgSchema/DefinedTerm.ts b/packages/core/src/types/external/OrgSchema/DefinedTerm.ts new file mode 100644 index 0000000000..544fd27f9a --- /dev/null +++ b/packages/core/src/types/external/OrgSchema/DefinedTerm.ts @@ -0,0 +1,37 @@ +import { parse, string, type ObjectEntries } from 'valibot'; + +import { thing, type Thing } from './Thing'; +import orgSchemaProperty from './private/orgSchemaProperty'; + +export const definedTerm = (entries?: TEntries | undefined) => + thing({ + inDefinedTermSet: orgSchemaProperty(string()), + termCode: orgSchemaProperty(string()), + + ...entries + }); + +/** + * A word, name, acronym, phrase, etc. with a formal definition. Often used in the context of category or subject classification, glossaries or dictionaries, product or creative work types, etc. Use the name property for the term being defined, use termCode if the term has an alpha-numeric code allocated, use description to provide the definition of the term. + * + * This is partial implementation of https://schema.org/DefinedTerm. + * + * @see https://schema.org/DefinedTerm + */ +export type DefinedTerm = Thing & { + /** + * A [DefinedTermSet](https://schema.org/DefinedTermSet) that contains this term. + * + * @see https://schema.org/inDefinedTermSet + */ + inDefinedTermSet?: string | undefined; + + /** + * A code that identifies this [DefinedTerm](https://schema.org/DefinedTerm) within a [DefinedTermSet](https://schema.org/DefinedTermSet). + * + * @see https://schema.org/termCode + */ + termCode?: string | undefined; +}; + +export const parseDefinedTerm = (data: unknown): DefinedTerm => parse(definedTerm(), data); diff --git a/packages/core/src/types/external/OrgSchema/Project.parseProject.spec.ts b/packages/core/src/types/external/OrgSchema/Project.parseProject.spec.ts new file mode 100644 index 0000000000..2b6841a81d --- /dev/null +++ b/packages/core/src/types/external/OrgSchema/Project.parseProject.spec.ts @@ -0,0 +1,17 @@ +import { parseProject } from './Project'; + +describe('Project', () => { + test('should parse', () => { + expect( + parseProject({ + '@type': 'Project', + name: 'Microsoft', + slogan: 'Empower every person and every organization on the planet to achieve more.' + }) + ).toEqual({ + '@type': 'Project', + name: 'Microsoft', + slogan: 'Empower every person and every organization on the planet to achieve more.' + }); + }); +}); diff --git a/packages/core/src/types/external/OrgSchema/Project.ts b/packages/core/src/types/external/OrgSchema/Project.ts new file mode 100644 index 0000000000..ddaa60b766 --- /dev/null +++ b/packages/core/src/types/external/OrgSchema/Project.ts @@ -0,0 +1,29 @@ +import { parse, string, type ObjectEntries } from 'valibot'; + +import { thing, type Thing } from './Thing'; +import orgSchemaProperty from './private/orgSchemaProperty'; + +export const project = (entries?: TEntries | undefined) => + thing({ + slogan: orgSchemaProperty(string()), + + ...entries + }); + +/** + * An enterprise (potentially individual but typically collaborative), planned to achieve a particular aim. Use properties from [Organization](https://schema.org/Organization), [subOrganization](https://schema.org/subOrganization)/[parentOrganization](https://schema.org/parentOrganization) to indicate project sub-structures. + * + * This is partial implementation of https://schema.org/Project. + * + * @see https://schema.org/Project + */ +export type Project = Thing & { + /** + * A slogan or motto associated with the item. + * + * @see https://schema.org/slogan + */ + slogan?: string | undefined; +}; + +export const parseProject = (data: unknown): Project => parse(project(), data); diff --git a/packages/core/src/types/external/OrgSchema/Thing.parseThing.spec.ts b/packages/core/src/types/external/OrgSchema/Thing.parseThing.spec.ts new file mode 100644 index 0000000000..d42a302c5f --- /dev/null +++ b/packages/core/src/types/external/OrgSchema/Thing.parseThing.spec.ts @@ -0,0 +1,54 @@ +import { parseThing } from './Thing'; + +describe('Thing', () => { + test('should parse', () => { + expect( + parseThing({ + '@type': 'Thing', + name: 'John Doe' + }) + ).toEqual({ + '@type': 'Thing', + name: 'John Doe' + }); + }); + + test('should parse unknown @type', () => { + expect( + parseThing({ + '@type': 'Unknown', + name: 'John Doe' + }) + ).toEqual({ + '@type': 'Unknown', + name: 'John Doe' + }); + }); + + // This is an intentional drift from JSON-LD. + // Assuming expecting an object of Thing while the actual is CreativeWork. + // If unknown properties are removed, we will remove properties that are solely for CreativeWork. + test('should not remove unknown properties', () => { + expect( + parseThing({ + '@type': 'Thing', + something: 1 + }) + ).toEqual({ + '@type': 'Thing', + something: 1 + }); + }); + + test('should set invalid properties to undefined', () => { + expect( + parseThing({ + '@type': 'Thing', + name: 1 + }) + ).toEqual({ + '@type': 'Thing', + name: undefined + }); + }); +}); diff --git a/packages/core/src/types/external/OrgSchema/Thing.ts b/packages/core/src/types/external/OrgSchema/Thing.ts new file mode 100644 index 0000000000..32780aaa09 --- /dev/null +++ b/packages/core/src/types/external/OrgSchema/Thing.ts @@ -0,0 +1,94 @@ +import { type EmptyObject } from 'type-fest'; +import { lazy, object, optional, parse, string, unknown, value, type ObjectEntries, type StringSchema } from 'valibot'; + +import { action, type Action } from './Action'; +import orgSchemaProperties from './private/orgSchemaProperties'; +import orgSchemaProperty from './private/orgSchemaProperty'; + +/** + * The most generic type of item. + * + * This is partial implementation of https://schema.org/Thing. + * + * @see https://schema.org/Thing + */ +export type Thing = { + '@context'?: 'https://schema.org' | undefined; + '@id'?: string | undefined; + '@type': string; + + /** + * An additional type for the item, typically used for adding more specific types from external vocabularies in microdata syntax. This is a relationship between something and a class that the thing is in. Typically the value is a URI-identified RDF class, and in this case corresponds to the use of rdf:type in RDF. Text values can be used sparingly, for cases where useful information can be added without their being an appropriate schema to reference. In the case of text values, the class label should follow the schema.org [style guide](https://schema.org/docs/styleguide.html). + * + * @see https://schema.org/additionalType + */ + additionalType?: string | undefined; + + /** + * An alias for the item. + * + * @see https://schema.org/alternateName + */ + alternateName?: string | undefined; + + /** + * A description of the item. + * + * @see https://schema.org/description + */ + description?: string | undefined; + + /** + * The name of the item. + * + * @see https://schema.org/name + */ + name?: string | undefined; + + /** + * Indicates a potential Action, which describes an idealized action in which this thing would play an 'object' role. + * + * @see https://schema.org/potentialAction + */ + potentialAction?: readonly Action[] | undefined; + + /** + * URL of the item. + * + * @see https://schema.org/url + */ + url?: string | undefined; +}; + +const thingEntries = { + '@context': optional(string([value('https://schema.org')]) as StringSchema<'https://schema.org'>), + '@id': optional(string()), + '@type': string(), + + additionalType: orgSchemaProperty(string()), + alternateName: orgSchemaProperty(string()), + description: orgSchemaProperty(string()), + name: orgSchemaProperty(string()), + potentialAction: orgSchemaProperties(lazy(() => action())), + url: orgSchemaProperty(string()) +}; + +export const thing = (entries?: TEntries | undefined) => + object( + { + ...thingEntries, + ...entries + }, + // Forward compatibility is the reason why we use unknown() here and not adhere to JSON-LD which drop unknown fields. + // + // Example: + // - CreativeWork.editor must be type of Person (or any of its subtypes, Patient) + // - Without unknown(), when we parse the CreativeWork, we will drop Patient.diagnosis + // - That means, CreativeWork.editor.diagnosis will be unset despite CreativeWork.editor is of type Patient + // + // We can drop unknown() if there is a way to keep CreativeWork.editor.diagnosis. + // It is okay to drop future/unsupported properties. But not today/supported properties. + unknown() + ); + +export const parseThing = (data: unknown): Thing => parse(thing(), data); diff --git a/packages/core/src/types/external/OrgSchema/VoteAction.spec.ts b/packages/core/src/types/external/OrgSchema/VoteAction.spec.ts new file mode 100644 index 0000000000..76cf89730b --- /dev/null +++ b/packages/core/src/types/external/OrgSchema/VoteAction.spec.ts @@ -0,0 +1,35 @@ +import { parseVoteAction } from './VoteAction'; + +describe('VoteAction', () => { + describe('actionOption', () => { + test('should parse', () => + expect( + parseVoteAction({ + '@type': 'VoteAction', + actionOption: 'upvote' + }) + ).toEqual({ + '@type': 'VoteAction', + actionOption: 'upvote' + })); + + test('should change invalid into undefined', () => { + try { + expect( + parseVoteAction({ + '@type': 'Action', + actionOption: 123 + }) + ).toEqual({ + '@type': 'Action', + actionOption: undefined + }); + } catch (err) { + console.error(err); + console.error(err.issues[0].input); + + throw err; + } + }); + }); +}); diff --git a/packages/core/src/types/external/OrgSchema/VoteAction.ts b/packages/core/src/types/external/OrgSchema/VoteAction.ts new file mode 100644 index 0000000000..481eb08e64 --- /dev/null +++ b/packages/core/src/types/external/OrgSchema/VoteAction.ts @@ -0,0 +1,31 @@ +import { parse, string, type ObjectEntries } from 'valibot'; + +import { action, type Action } from './Action'; +import orgSchemaProperty from './private/orgSchemaProperty'; + +/** + * An action performed by a direct agent and indirect participants upon a direct object. Optionally happens at a location with the help of an inanimate instrument. The execution of the action may produce a result. Specific action sub-type documentation specifies the exact expectation of each argument/role. + * + * See also [blog post](http://blog.schema.org/2014/04/announcing-schemaorg-actions.html) and [Actions overview document](https://schema.org/docs/actions.html). + * + * This is partial implementation of https://schema.org/Action. + * + * @see https://schema.org/Action + */ +export type VoteAction = Action & { + /** + * A sub property of object. The options subject to this action. Supersedes [option](https://schema.org/option). + * + * @see https://schema.org/VoteAction + */ + actionOption?: string | undefined; +}; + +export const voteAction = (entries?: TEntries | undefined) => + action({ + actionOption: orgSchemaProperty(string()), + + ...entries + }); + +export const parseVoteAction = (data: unknown): VoteAction => parse(voteAction(), data); diff --git a/packages/core/src/types/external/OrgSchema/private/orgSchemaProperties.spec.ts b/packages/core/src/types/external/OrgSchema/private/orgSchemaProperties.spec.ts new file mode 100644 index 0000000000..66d0e1f9d5 --- /dev/null +++ b/packages/core/src/types/external/OrgSchema/private/orgSchemaProperties.spec.ts @@ -0,0 +1,41 @@ +import { parse, string } from 'valibot'; + +import orgSchemaProperties from './orgSchemaProperties'; + +describe('orgSchemaProperties', () => { + describe('singular', () => { + test('should transform into plural', () => { + expect(parse(orgSchemaProperties(string()), 'abc')).toEqual(['abc']); + }); + + test('should turn item of invalid type into undefined', () => { + expect(parse(orgSchemaProperties(string()), 0)).toBeUndefined(); + }); + + test('should turn null into undefined', () => { + expect(parse(orgSchemaProperties(string()), null)).toBeUndefined(); + }); + + test('should keep undefined as-is', () => { + expect(parse(orgSchemaProperties(string()), undefined)).toBeUndefined(); + }); + }); + + describe('plural', () => { + test('should keep as-is', () => { + expect(parse(orgSchemaProperties(string()), ['abc', 'xyz'])).toEqual(['abc', 'xyz']); + }); + + test('should keep empty array as-is', () => { + expect(parse(orgSchemaProperties(string()), [])).toEqual([]); + }); + + test('should remove items with invalid type (all)', () => { + expect(parse(orgSchemaProperties(string()), [0])).toEqual([]); + }); + + test('should remove items with invalid type (some)', () => { + expect(parse(orgSchemaProperties(string()), ['abc', 0, 'xyz'])).toEqual(['abc', 'xyz']); + }); + }); +}); diff --git a/packages/core/src/types/external/OrgSchema/private/orgSchemaProperties.ts b/packages/core/src/types/external/OrgSchema/private/orgSchemaProperties.ts new file mode 100644 index 0000000000..f4b6de69b2 --- /dev/null +++ b/packages/core/src/types/external/OrgSchema/private/orgSchemaProperties.ts @@ -0,0 +1,23 @@ +import { + array, + fallback, + parse, + transform, + type ArraySchema, + type BaseSchema, + type FallbackInfo, + type Output, + type UndefinedSchema +} from 'valibot'; + +import orgSchemaProperty from './orgSchemaProperty'; + +const singularToArray = (schema: T): ArraySchema | UndefinedSchema => + fallback | UndefinedSchema, (info?: FallbackInfo) => Output>( + transform(array(schema), value => value.filter(value => typeof value !== 'undefined')), + value => (value?.input ? [parse(schema, value?.input)] : undefined) + ); + +export default function orgSchemaProperties(schema: T) { + return singularToArray(orgSchemaProperty(schema)); +} diff --git a/packages/core/src/types/external/OrgSchema/private/orgSchemaProperty.spec.ts b/packages/core/src/types/external/OrgSchema/private/orgSchemaProperty.spec.ts new file mode 100644 index 0000000000..a0eec9f423 --- /dev/null +++ b/packages/core/src/types/external/OrgSchema/private/orgSchemaProperty.spec.ts @@ -0,0 +1,21 @@ +import { parse, string } from 'valibot'; + +import orgSchemaProperty from './orgSchemaProperty'; + +describe('orgSchemaProperty', () => { + test('should keep valid type as-is', () => { + expect(parse(orgSchemaProperty(string()), 'abc')).toBe('abc'); + }); + + test('should keep undefined as-is', () => { + expect(parse(orgSchemaProperty(string()), undefined)).toBeUndefined(); + }); + + test('should convert invalid type to undefined', () => { + expect(parse(orgSchemaProperty(string()), null)).toBeUndefined(); + }); + + test('should take the first item from an array', () => { + expect(parse(orgSchemaProperty(string()), ['abc', 'def', 'xyz'])).toBe('abc'); + }); +}); diff --git a/packages/core/src/types/external/OrgSchema/private/orgSchemaProperty.ts b/packages/core/src/types/external/OrgSchema/private/orgSchemaProperty.ts new file mode 100644 index 0000000000..9121c6476c --- /dev/null +++ b/packages/core/src/types/external/OrgSchema/private/orgSchemaProperty.ts @@ -0,0 +1,6 @@ +import { array, fallback, optional, transform, union, type BaseSchema } from 'valibot'; + +const orgSchemaProperty = (schema: T) => + fallback(optional(union([transform(array(schema), array => array[0]), schema])), undefined); + +export default orgSchemaProperty; diff --git a/packages/core/src/types/external/OrgSchemaThing.ts b/packages/core/src/types/external/OrgSchemaThing.ts deleted file mode 100644 index 83aefa66d3..0000000000 --- a/packages/core/src/types/external/OrgSchemaThing.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * The most generic type of item. - * - * This is partial implementation of https://schema.org/Thing. - * - * @see https://schema.org/Thing - */ -export type OrgSchemaThing = { - '@context': 'https://schema.org'; - '@type': T; - type: `https://schema.org/${T}`; -}; diff --git a/packages/core/src/utils/getOrgSchemaMessage.spec.ts b/packages/core/src/utils/getOrgSchemaMessage.spec.ts new file mode 100644 index 0000000000..a45af71ba0 --- /dev/null +++ b/packages/core/src/utils/getOrgSchemaMessage.spec.ts @@ -0,0 +1,22 @@ +import getOrgSchemaMessage from './getOrgSchemaMessage'; + +test('should get message', () => { + const expected = { + '@context': 'https://schema.org', + '@id': '', + '@type': 'Message', + type: 'https://schema.org/Message' + }; + + expect(getOrgSchemaMessage([expected])).toEqual(expected); +}); + +test('should not get message without @id of empty string', () => { + const expected = { + '@context': 'https://schema.org', + '@type': 'Message', + type: 'https://schema.org/Message' + }; + + expect(getOrgSchemaMessage([expected])).toBeUndefined(); +}); diff --git a/packages/core/src/utils/getOrgSchemaMessage.ts b/packages/core/src/utils/getOrgSchemaMessage.ts new file mode 100644 index 0000000000..e751aac8bc --- /dev/null +++ b/packages/core/src/utils/getOrgSchemaMessage.ts @@ -0,0 +1,21 @@ +import { type WebChatActivity } from '../types/WebChatActivity'; +import { parseCreativeWork, type CreativeWork } from '../types/external/OrgSchema/CreativeWork'; +import { parseThing } from '../types/external/OrgSchema/Thing'; + +export default function getOrgSchemaMessage( + graph: readonly WebChatActivity['entities'][0][] +): CreativeWork | undefined { + const messageEntity = (graph || []).find(entity => { + const isThing = entity.type?.startsWith('https://schema.org/'); + + if (isThing) { + const thing = parseThing(entity); + + return thing['@id'] === ''; + } + }); + + const message = messageEntity && parseCreativeWork(messageEntity); + + return message && parseCreativeWork(message); +}