diff --git a/package-lock.json b/package-lock.json index 4ebdae123f6..4b9d4d0d4e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59991,7 +59991,9 @@ "license": "Apache-2.0", "dependencies": { "@coveo/bueno": "0.45.9", - "@coveo/headless": "2.68.0" + "@coveo/headless": "2.68.0", + "dompurify": "3.1.5", + "marked": "12.0.2" }, "devDependencies": { "@ckeditor/jsdoc-plugins": "39.9.1", @@ -60607,6 +60609,11 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "packages/quantic/node_modules/dompurify": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.5.tgz", + "integrity": "sha512-lwG+n5h8QNpxtyrJW/gJWckL+1/DQiYMX8f7t8Z2AZTPw1esVrqjI63i7Zc2Gz0aKzLVMYC1V1PL/ky+aY/NgA==" + }, "packages/quantic/node_modules/eslint-plugin-jest": { "version": "28.2.0", "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-28.2.0.tgz", @@ -60709,6 +60716,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "packages/quantic/node_modules/marked": { + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-12.0.2.tgz", + "integrity": "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "packages/quantic/node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", diff --git a/packages/quantic/.gitignore b/packages/quantic/.gitignore index 1d1913f33f3..0c4a988d58b 100755 --- a/packages/quantic/.gitignore +++ b/packages/quantic/.gitignore @@ -38,6 +38,8 @@ scratch-def.ci.json # Static resources copied from node_modules force-app/main/default/staticresources/coveoheadless force-app/main/default/staticresources/coveobueno +force-app/main/default/staticresources/marked +force-app/main/default/staticresources/dompurify # Test Coverage & Report coverage diff --git a/packages/quantic/copy-static-resources.js b/packages/quantic/copy-static-resources.js index b5a46fdb8ab..80cd35fbde0 100644 --- a/packages/quantic/copy-static-resources.js +++ b/packages/quantic/copy-static-resources.js @@ -12,7 +12,45 @@ const copy = async (source, dest) => { }; const main = async () => { - console.info('Begin copy.'); + console.info('Begin copy static resources'); + await copyHeadless(); + await copyBueno(); + await copyMarked(); + await copyDompurify(); +}; + +const copyDompurify = async () => { + console.info('Begin copy DOMPurify.'); + + await mkdir('./force-app/main/default/staticresources/dompurify', { + recursive: true, + }); + + await copy( + '../../node_modules/dompurify/dist/purify.min.js', + './force-app/main/default/staticresources/dompurify/purify.min.js' + ); + + console.info('DOMPurify copied.'); +}; + +const copyMarked = async () => { + console.info('Begin copy Marked.'); + + await mkdir('./force-app/main/default/staticresources/marked', { + recursive: true, + }); + + await copy( + '../../node_modules/marked/marked.min.js', + './force-app/main/default/staticresources/marked/marked.min.js' + ); + + console.info('Marked copied.'); +}; + +const copyHeadless = async () => { + console.info('Begin copy Headless.'); await mkdir( './force-app/main/default/staticresources/coveoheadless/case-assist', @@ -30,15 +68,6 @@ const main = async () => { './force-app/main/default/staticresources/coveoheadless/definitions/', {recursive: true} ); - await mkdir('./force-app/main/default/staticresources/coveobueno/browser', { - recursive: true, - }); - await mkdir( - './force-app/main/default/staticresources/coveobueno/definitions', - { - recursive: true, - } - ); await copy( '../../node_modules/@coveo/headless/dist/quantic/headless.js', './force-app/main/default/staticresources/coveoheadless/headless.js' @@ -59,6 +88,22 @@ const main = async () => { '../../node_modules/@coveo/headless/dist/definitions', './force-app/main/default/staticresources/coveoheadless/definitions' ); + + console.info('Headless copied.'); +}; + +const copyBueno = async () => { + console.info('Begin copy Bueno.'); + + await mkdir('./force-app/main/default/staticresources/coveobueno/browser', { + recursive: true, + }); + await mkdir( + './force-app/main/default/staticresources/coveobueno/definitions', + { + recursive: true, + } + ); await copy( '../../node_modules/@coveo/bueno/dist/browser/bueno.js', './force-app/main/default/staticresources/coveobueno/browser/bueno.js' @@ -68,7 +113,7 @@ const main = async () => { './force-app/main/default/staticresources/coveobueno/definitions' ); - console.info('Headless copied.'); + console.info('Bueno copied.'); }; main().then(() => { diff --git a/packages/quantic/cypress/e2e/default-2/generatedAnswer/generated-answer-expectations.ts b/packages/quantic/cypress/e2e/default-2/generatedAnswer/generated-answer-expectations.ts index 52853729437..433e78f01bb 100644 --- a/packages/quantic/cypress/e2e/default-2/generatedAnswer/generated-answer-expectations.ts +++ b/packages/quantic/cypress/e2e/default-2/generatedAnswer/generated-answer-expectations.ts @@ -155,12 +155,30 @@ function generatedAnswerExpectations(selector: GeneratedAnswerSelector) { .log(`the generated answer should contain "${answer}"`); }, + generatedAnswerContentContainsHTML: (findSelector: string) => { + selector + .generatedAnswerContentContainer() + .find(findSelector) + .log( + `the generated answer content should contain an element matching "${findSelector}"` + ); + }, + + generatedAnswerContentContainsText: (text: string) => { + selector + .generatedAnswerContentContainer() + .contains(text) + .log( + `the generated answer content should contain text matching "${text}"` + ); + }, + generatedAnswerIsStreaming: (isStreaming: boolean) => { selector - .generatedAnswer() + .generatedAnswerContentContainer() .should( isStreaming ? 'have.class' : 'not.have.class', - 'generated-answer__answer--streaming' + 'generated-answer-content__answer--streaming' ) .log(`the generated answer ${should(isStreaming)} be streaming`); }, diff --git a/packages/quantic/cypress/e2e/default-2/generatedAnswer/generated-answer-selectors.ts b/packages/quantic/cypress/e2e/default-2/generatedAnswer/generated-answer-selectors.ts index 2d3228b06b6..4e0ce4703b9 100644 --- a/packages/quantic/cypress/e2e/default-2/generatedAnswer/generated-answer-selectors.ts +++ b/packages/quantic/cypress/e2e/default-2/generatedAnswer/generated-answer-selectors.ts @@ -14,6 +14,7 @@ export interface GeneratedAnswerSelector extends ComponentSelector { retryButton: () => CypressSelector; toggleGeneratedAnswerButton: () => CypressSelector; generatedAnswerContent: () => CypressSelector; + generatedAnswerContentContainer: () => CypressSelector; feedbackModal: () => CypressSelector; feedbackOption: (index: number) => CypressSelector; feedbackSubmitButton: () => CypressSelector; @@ -77,6 +78,10 @@ export const GeneratedAnswerSelectors: GeneratedAnswerSelector = { GeneratedAnswerSelectors.get().find( '[data-cy="generated-answer__content"]' ), + generatedAnswerContentContainer: () => + GeneratedAnswerSelectors.get().find( + '[data-cy="generated-answer__content-container"]' + ), feedbackModal: () => cy.get('lightning-modal'), feedbackOption: (index: number) => cy.get('lightning-modal').find('lightning-radio-group input').eq(index), diff --git a/packages/quantic/cypress/e2e/default-2/generatedAnswer/generated-answer.cypress.ts b/packages/quantic/cypress/e2e/default-2/generatedAnswer/generated-answer.cypress.ts index b127c8fafc9..62e6408ddea 100644 --- a/packages/quantic/cypress/e2e/default-2/generatedAnswer/generated-answer.cypress.ts +++ b/packages/quantic/cypress/e2e/default-2/generatedAnswer/generated-answer.cypress.ts @@ -215,6 +215,177 @@ describe('quantic-generated-answer', () => { }); }); + describe('handling text/markdown', () => { + const streamId = crypto.randomUUID(); + const genQaMarkdownTypePayload = { + payloadType: 'genqa.headerMessageType', + payload: JSON.stringify({ + answerStyle: 'default', + contentFormat: 'text/markdown', + }), + }; + + beforeEach(() => { + mockSearchWithGeneratedAnswer(streamId, param.useCase); + }); + + it('should display the generated answer content in markdown format', () => { + const genQaMarkdownTextPayload = { + payloadType: 'genqa.messageType', + payload: JSON.stringify({ + textDelta: '# Some markdown text', + }), + finishReason: 'COMPLETED', + }; + mockStreamResponse(streamId, [ + genQaMarkdownTypePayload, + genQaMarkdownTextPayload, + ]); + visitGeneratedAnswer({ + useCase: param.useCase, + }); + Expect.displayGeneratedAnswerCard(true); + }); + + it('should properly create divs instead of headings', () => { + const genQaMarkdownTextPayload = { + payloadType: 'genqa.messageType', + payload: JSON.stringify({ + textDelta: '# level1\n ## level2\n ### level3', + }), + finishReason: 'COMPLETED', + }; + mockStreamResponse(streamId, [ + genQaMarkdownTypePayload, + genQaMarkdownTextPayload, + ]); + visitGeneratedAnswer({ + useCase: param.useCase, + }); + Expect.displayGeneratedAnswerCard(true); + Expect.generatedAnswerContentContainsHTML( + 'div[data-level="answer-heading-1"]' + ); + Expect.generatedAnswerContentContainsText('level1'); + Expect.generatedAnswerContentContainsHTML( + 'div[data-level="answer-heading-2"]' + ); + Expect.generatedAnswerContentContainsText('level2'); + Expect.generatedAnswerContentContainsHTML( + 'div[data-level="answer-heading-3"]' + ); + Expect.generatedAnswerContentContainsText('level3'); + }); + + it('should properly close bold tags even before receiving the closing tag', () => { + const genQaMarkdownTextPayload = { + payloadType: 'genqa.messageType', + payload: JSON.stringify({ + textDelta: '**bold text', + }), + finishReason: 'COMPLETED', + }; + mockStreamResponse(streamId, [ + genQaMarkdownTypePayload, + genQaMarkdownTextPayload, + ]); + visitGeneratedAnswer({ + useCase: param.useCase, + }); + Expect.displayGeneratedAnswerCard(true); + Expect.generatedAnswerContentContainsHTML('strong'); + Expect.generatedAnswerContentContainsText('bold text'); + }); + + it('should properly close code tags even before receiving the closing tag', () => { + const genQaMarkdownTextPayload = { + payloadType: 'genqa.messageType', + payload: JSON.stringify({ + textDelta: '`code text', + }), + finishReason: 'COMPLETED', + }; + mockStreamResponse(streamId, [ + genQaMarkdownTypePayload, + genQaMarkdownTextPayload, + ]); + visitGeneratedAnswer({ + useCase: param.useCase, + }); + Expect.displayGeneratedAnswerCard(true); + Expect.generatedAnswerContentContainsHTML('code'); + Expect.generatedAnswerContentContainsText('code text'); + }); + + it('should properly render lists as ul and li', () => { + const genQaMarkdownTextPayload = { + payloadType: 'genqa.messageType', + payload: JSON.stringify({ + textDelta: '- list item 1\n- list item 2', + }), + finishReason: 'COMPLETED', + }; + mockStreamResponse(streamId, [ + genQaMarkdownTypePayload, + genQaMarkdownTextPayload, + ]); + visitGeneratedAnswer({ + useCase: param.useCase, + }); + Expect.displayGeneratedAnswerCard(true); + Expect.generatedAnswerContentContainsHTML('ul'); + Expect.generatedAnswerContentContainsHTML('li'); + Expect.generatedAnswerContentContainsText('list item 1'); + Expect.generatedAnswerContentContainsText('list item 2'); + }); + + it('should properly render tables as scrollable-tables', () => { + const genQaMarkdownTextPayload = { + payloadType: 'genqa.messageType', + payload: JSON.stringify({ + textDelta: + '| Tables | Are | Cool |\n|----------|-------------|------|\n| col1 | col2 | col3 |', + }), + finishReason: 'COMPLETED', + }; + mockStreamResponse(streamId, [ + genQaMarkdownTypePayload, + genQaMarkdownTextPayload, + ]); + visitGeneratedAnswer({ + useCase: param.useCase, + }); + Expect.displayGeneratedAnswerCard(true); + Expect.generatedAnswerContentContainsHTML( + 'div.scrollable-table > table > thead' + ); + Expect.generatedAnswerContentContainsHTML('th'); + Expect.generatedAnswerContentContainsText('Tables Are Cool'); + Expect.generatedAnswerContentContainsText('col1 col2 col3'); + }); + + it('should properly render code blocks', () => { + const genQaMarkdownTextPayload = { + payloadType: 'genqa.messageType', + payload: JSON.stringify({ + textDelta: '```\nconst foo = "bar";\nconsole.log(foo);\n```', + }), + finishReason: 'COMPLETED', + }; + mockStreamResponse(streamId, [ + genQaMarkdownTypePayload, + genQaMarkdownTextPayload, + ]); + visitGeneratedAnswer({ + useCase: param.useCase, + }); + Expect.displayGeneratedAnswerCard(true); + Expect.generatedAnswerContentContainsHTML('pre > code'); + Expect.generatedAnswerContentContainsText('const foo = "bar";'); + Expect.generatedAnswerContentContainsText('console.log(foo);'); + }); + }); + describe('when a custom value is provided to the fields to include in citations attribute', () => { const streamId = crypto.randomUUID(); const customFields = 'foo,bar'; diff --git a/packages/quantic/cypress/page-objects/search.ts b/packages/quantic/cypress/page-objects/search.ts index 7ebb75217d5..ff8610fc40d 100644 --- a/packages/quantic/cypress/page-objects/search.ts +++ b/packages/quantic/cypress/page-objects/search.ts @@ -524,8 +524,20 @@ export function mockStreamResponse(streamId: string, body: unknown) { url: `**/machinelearning/streaming/${streamId}`, }, (request) => { - request.reply(200, `data: ${JSON.stringify(body)} \n\n`, { - 'content-type': 'text/event-stream', + let bodyText = ''; + if (!Array.isArray(body)) { + bodyText = `data: ${JSON.stringify(body)} \n\n`; + } else { + body.forEach((data) => { + bodyText += `data: ${JSON.stringify(data)} \n\n`; + }); + } + request.reply({ + statusCode: 200, + body: bodyText, + headers: { + 'content-type': 'text/event-stream', + }, }); } ).as(getStreamInterceptAlias(streamId).substring(1)); diff --git a/packages/quantic/force-app/main/default/.eslintrc.json b/packages/quantic/force-app/main/default/.eslintrc.json index a6a6c116306..c78044ffbe6 100644 --- a/packages/quantic/force-app/main/default/.eslintrc.json +++ b/packages/quantic/force-app/main/default/.eslintrc.json @@ -7,7 +7,9 @@ "CoveoHeadlessCaseAssist": "readonly", "CoveoHeadlessInsight": "readonly", "CoveoHeadlessRecommendation": "readonly", - "Bueno": "readonly" + "Bueno": "readonly", + "DOMPurify": "readonly", + "marked": "readonly" }, "rules": { "no-unused-expressions": ["error", {"allowTernary": true}] diff --git a/packages/quantic/force-app/main/default/lwc/jsconfig.json b/packages/quantic/force-app/main/default/lwc/jsconfig.json index 7e57491b147..7f5b4ae8ac7 100644 --- a/packages/quantic/force-app/main/default/lwc/jsconfig.json +++ b/packages/quantic/force-app/main/default/lwc/jsconfig.json @@ -79,6 +79,9 @@ ], "c/quanticRadioButtonsGroup": [ "quanticRadioButtonsGroup/quanticRadioButtonsGroup.js" + ], + "c/quanticGeneratedAnswerContent": [ + "quanticGeneratedAnswerContent/quanticGeneratedAnswerContent.js" ] } }, diff --git a/packages/quantic/force-app/main/default/lwc/quanticCitation/quanticCitation.js b/packages/quantic/force-app/main/default/lwc/quanticCitation/quanticCitation.js index 45647e596b7..229118c4b6e 100644 --- a/packages/quantic/force-app/main/default/lwc/quanticCitation/quanticCitation.js +++ b/packages/quantic/force-app/main/default/lwc/quanticCitation/quanticCitation.js @@ -16,9 +16,9 @@ const debounceDurationBeforeHoverMs = 200; */ export default class QuanticCitation extends NavigationMixin(LightningElement) { /** + * The citation item information. * @api * @type {{title: string, index: number, text: string, clickUri: string, fields: object}} - * The citation item information. */ @api citation; /** diff --git a/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswer/quanticGeneratedAnswer.js b/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswer/quanticGeneratedAnswer.js index c115f8c3b3a..74cf19ea81f 100644 --- a/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswer/quanticGeneratedAnswer.js +++ b/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswer/quanticGeneratedAnswer.js @@ -201,7 +201,10 @@ export default class QuanticGeneratedAnswer extends LightningElement { return this.headless.buildGeneratedAnswer(engine, { initialState: { isVisible: initialVisibility, - responseFormat: {answerStyle: this.answerStyle}, + responseFormat: { + answerStyle: this.answerStyle, + contentFormat: ['text/markdown', 'text/plain'], + }, }, fieldsToIncludeInCitations: this.citationFields, }); @@ -404,6 +407,10 @@ export default class QuanticGeneratedAnswer extends LightningElement { return this?.state?.citations; } + get answerContentFormat() { + return this?.state?.answerContentFormat; + } + get shouldDisplayCitations() { const hasCitations = !!this.citations?.length; return hasCitations && !this.isAnswerCollapsed; @@ -443,9 +450,7 @@ export default class QuanticGeneratedAnswer extends LightningElement { ? 'generated-answer__answer--expanded' : 'generated-answer__answer--collapsed'; } - return `generated-answer__answer ${ - this.isStreaming ? 'generated-answer__answer--streaming' : '' - } ${collapsedStateClass}`; + return `generated-answer__answer ${collapsedStateClass}`; } get hasRetryableError() { diff --git a/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswer/templates/generatedAnswer.css b/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswer/templates/generatedAnswer.css index 8a9e0cf61a9..0f9a53fb16e 100644 --- a/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswer/templates/generatedAnswer.css +++ b/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswer/templates/generatedAnswer.css @@ -7,8 +7,6 @@ .generated-answer__answer { white-space: pre-wrap; - line-height: 1.5rem; - font-size: 15px; overflow: hidden; position: relative; } @@ -23,17 +21,6 @@ max-width: 425px; } -.generated-answer__answer--streaming > span::after { - content: ''; - width: 0.5rem; - height: 0.9rem; - margin-left: 0.1rem; - background: var(--lwc-brandAccessible, #0176d3); - display: inline-block; - animation: cursor-blink 1.5s steps(2, start) infinite; - vertical-align: baseline; -} - .generated-answer__answer--collapsed { max-height: var(--maxHeight, 250px); } diff --git a/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswer/templates/generatedAnswer.html b/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswer/templates/generatedAnswer.html index 2455b08f0d0..69e16573d1f 100644 --- a/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswer/templates/generatedAnswer.html +++ b/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswer/templates/generatedAnswer.html @@ -45,7 +45,12 @@ class="generated-answer__content slds-var-m-top_medium" >
Hello, world!
'); + }); + + it('should transform markdown code to HTML', () => {
+ const text = '```javascript\nconst foo = "bar";\n```';
+ const result = transformMarkdownToHtml(text, marked);
+ expect(removeLineBreaks(result)).toEqual(
+ 'const foo = "bar";
'
+ );
+ });
+
+ it('should transform markdown heading to HTML with data-level="answer-heading-${level}"', () => {
+ const text = '# Hello, world!';
+ const result = transformMarkdownToHtml(text, marked);
+ expect(removeLineBreaks(result)).toEqual(
+ 'Hello, world!'
+ );
+ });
+
+ it('should transform markdown list item to HTML ', () => {
+ const text = '- Hello, world!';
+ const result = transformMarkdownToHtml(text, marked);
+ expect(removeLineBreaks(result)).toEqual(
+ '- Hello, world!
'
+ );
+ });
+
+ it('should transform markdown table to HTML ', () => {
+ const text = '| Header |\n| ------ |\n| Cell |';
+ const result = transformMarkdownToHtml(text, marked);
+ expect(removeLineBreaks(result)).toEqual(
+ 'Header Cell
'
+ );
+ });
+
+ it('should complete unclosed inline elements such as bold, italic, and code', () => {
+ const textBold = '**bold';
+ const resultBold = transformMarkdownToHtml(textBold, marked);
+ expect(removeLineBreaks(resultBold)).toEqual(
+ 'bold
'
+ );
+
+ const textItalic = '*italic';
+ const resultItalic = transformMarkdownToHtml(textItalic, marked);
+ expect(removeLineBreaks(resultItalic)).toEqual('italic
');
+
+ const textCode = '`code';
+ const resultCode = transformMarkdownToHtml(textCode, marked);
+ expect(removeLineBreaks(resultCode)).toEqual('code
');
+ });
+ });
+});
diff --git a/packages/quantic/force-app/main/default/lwc/quanticUtils/markdownUtils.js b/packages/quantic/force-app/main/default/lwc/quanticUtils/markdownUtils.js
new file mode 100644
index 00000000000..88e87d83f03
--- /dev/null
+++ b/packages/quantic/force-app/main/default/lwc/quanticUtils/markdownUtils.js
@@ -0,0 +1,118 @@
+import DOMPURIFY from '@salesforce/resourceUrl/dompurify';
+import MARKED_JS from '@salesforce/resourceUrl/marked';
+// @ts-ignore
+import {loadScript} from 'lightning/platformResourceLoader';
+
+// Any number of `*` between 1 and 3, or a single backtick, followed by a word character or whitespace character
+const unclosedElement = /(\*{1,3}|`)($|\w[\w\s]*$)/;
+
+/**
+ * Complete unclosed elements such as bold, italic, and code.
+ * @param {string} text
+ * @returns {string}
+ */
+const completeUnclosedElement = (text) => {
+ const match = unclosedElement.exec(text);
+ if (match) {
+ const symbol = match[1];
+
+ const replacements = {
+ '***': '$2',
+ '**': '$2',
+ '*': '$2',
+ '`': '$2
',
+ };
+
+ return text.replace(unclosedElement, replacements[symbol]);
+ }
+
+ return text;
+};
+
+const escapeHtml = (text) => {
+ return text
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+};
+
+const customRenderer = {
+ code(code) {
+ return `${escapeHtml(code)}
`;
+ },
+
+ /**
+ * Custom Marked renderer to replace heading elements with div elements.
+ * @param {string} text
+ * @param {string} level
+ */
+ heading(text, level) {
+ return `${text}`;
+ },
+
+ /**
+ * Returns escaped HTML.
+ * @param {string} text
+ * @returns
+ */
+ html(text) {
+ return escapeHtml(text);
+ },
+
+ /**
+ * Custom Marked renderer to remove wrapping `` element around list item content.
+ * @param {string} text The element text content.
+ * @returns {string} The list item element to render.
+ */
+ listitem(text) {
+ const unwrappedText = text.replace(/^
]*>/, '').replace(/<\/p>\n?/, '');
+ const withClosedElement = completeUnclosedElement(unwrappedText);
+ return `
${withClosedElement} `;
+ },
+
+ /**
+ * Custom Marked renderer to wrap `` element in a scrolling container.
+ * @param {string} header The table header content.
+ * @param {string} body The table body content.
+ * @returns {string} The element to render.
+ */
+ table(header, body) {
+ return `${header}${body}
`;
+ },
+
+ /**
+ * Custom Marked renderer to complete unclosed inline elements such as bold, italic, and code.
+ * @param {string} text The text content.
+ * @returns {string} The corrected text content.
+ */
+ text(text) {
+ return completeUnclosedElement(text);
+ },
+};
+
+/**
+ * Transform markdown text to HTML
+ * @param {string} text
+ * @param {object} marked The marked library object
+ * @returns {string} HTML text corresponding to markdown
+ */
+const transformMarkdownToHtml = (text, marked) => {
+ marked.use({renderer: customRenderer});
+ return marked.parse(text);
+};
+
+/**
+ * Load the libraries Marked and DOMPurify.
+ * @param element
+ * @returns {Promise}
+ */
+const loadMarkdownDependencies = (element) => {
+ return Promise.all([
+ loadScript(element, MARKED_JS + '/marked.min.js'),
+ loadScript(element, DOMPURIFY + '/purify.min.js'),
+ ]);
+};
+
+export {transformMarkdownToHtml, loadMarkdownDependencies};
diff --git a/packages/quantic/force-app/main/default/lwc/quanticUtils/quanticUtils.js b/packages/quantic/force-app/main/default/lwc/quanticUtils/quanticUtils.js
index f719958f43a..50564f301f0 100644
--- a/packages/quantic/force-app/main/default/lwc/quanticUtils/quanticUtils.js
+++ b/packages/quantic/force-app/main/default/lwc/quanticUtils/quanticUtils.js
@@ -31,6 +31,8 @@ import pastYear_plural from '@salesforce/label/c.quantic_PastYear_plural';
/** @typedef {import("coveo").Result} Result */
+export * from './markdownUtils';
+
export class Debouncer {
_timeout;
diff --git a/packages/quantic/force-app/main/default/staticresources/dompurify.resource-meta.xml b/packages/quantic/force-app/main/default/staticresources/dompurify.resource-meta.xml
new file mode 100644
index 00000000000..8b42614c3f4
--- /dev/null
+++ b/packages/quantic/force-app/main/default/staticresources/dompurify.resource-meta.xml
@@ -0,0 +1,6 @@
+
+
+ application/zip
+ DOMPurify is a DOM-only, super-fast, uber-tolerant XSS sanitizer for HTML.
+ Public
+
\ No newline at end of file
diff --git a/packages/quantic/force-app/main/default/staticresources/marked.resource-meta.xml b/packages/quantic/force-app/main/default/staticresources/marked.resource-meta.xml
new file mode 100644
index 00000000000..051e590cee6
--- /dev/null
+++ b/packages/quantic/force-app/main/default/staticresources/marked.resource-meta.xml
@@ -0,0 +1,6 @@
+
+
+ application/zip
+ A markdown parser built for speed.
+ Public
+
\ No newline at end of file
diff --git a/packages/quantic/package.json b/packages/quantic/package.json
index 4daef9c8319..7cfa90574a8 100644
--- a/packages/quantic/package.json
+++ b/packages/quantic/package.json
@@ -46,7 +46,9 @@
},
"dependencies": {
"@coveo/bueno": "0.45.9",
- "@coveo/headless": "2.68.0"
+ "@coveo/headless": "2.68.0",
+ "dompurify": "3.1.5",
+ "marked": "12.0.2"
},
"engines": {
"node": ">=14.0.0"
diff --git a/packages/quantic/typings/lwc/dompurify.resource.d.ts b/packages/quantic/typings/lwc/dompurify.resource.d.ts
new file mode 100644
index 00000000000..f9d158254ed
--- /dev/null
+++ b/packages/quantic/typings/lwc/dompurify.resource.d.ts
@@ -0,0 +1,4 @@
+declare module '@salesforce/resourceUrl/dompurify' {
+ var DOMPurify: string;
+ export default DOMPurify;
+}
\ No newline at end of file
diff --git a/packages/quantic/typings/lwc/marked.resource.d.ts b/packages/quantic/typings/lwc/marked.resource.d.ts
new file mode 100644
index 00000000000..e816fee1319
--- /dev/null
+++ b/packages/quantic/typings/lwc/marked.resource.d.ts
@@ -0,0 +1,4 @@
+declare module '@salesforce/resourceUrl/marked' {
+ var marked: string;
+ export default marked;
+}
\ No newline at end of file