diff --git a/package.json b/package.json index 59e773cccbb9..07277e967ddf 100644 --- a/package.json +++ b/package.json @@ -1327,6 +1327,8 @@ "redux-thunk": "^2.4.2", "redux-thunks": "^1.0.0", "reflect-metadata": "^0.2.2", + "rehype-raw": "5.1.0", + "rehype-sanitize": "4.0.0", "remark-gfm": "1.0.0", "remark-parse-no-trim": "^8.0.4", "remark-stringify": "^8.0.3", diff --git a/renovate.json b/renovate.json index 8a97428359e2..c9dac09af9ef 100644 --- a/renovate.json +++ b/renovate.json @@ -756,6 +756,8 @@ "js-search", "openpgp", "remark-gfm", + "rehype-raw", + "rehype-sanitize", "yaml", "@types/js-search" ], diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/overview/markdown_renderers.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/overview/markdown_renderers.tsx index d45e4d811e98..ae3824d97d0f 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/overview/markdown_renderers.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/overview/markdown_renderers.tsx @@ -13,6 +13,8 @@ import { EuiTable, EuiTableRow, EuiTableRowCell, + EuiAccordion, + EuiSpacer, } from '@elastic/eui'; import React from 'react'; import type { MutableRefObject } from 'react'; @@ -114,5 +116,38 @@ export const markdownRenderers = ( ) => { return {props.alt}; }, + details: ({ children, node }) => { + const [summaryNode, ...bodyNodes] = React.Children.toArray(children).reduce< + [React.ReactElement | null, React.ReactNode[]] + >( + ([summary, body], child) => { + if (summary === null && React.isValidElement(child) && child.type === 'summary') { + return [child, body]; + } + return [summary, [...body, child]]; + }, + [null, []] + ); + + const summaryText = summaryNode + ? React.Children.toArray(summaryNode.props.children).join('') + : ''; + + const id = getAnchorId(children[0]?.toString(), node.position?.start.line); + + return ( + <> + + {bodyNodes} + + + + ); + }, }; }; diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/overview/readme.test.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/overview/readme.test.tsx index d8818fd90bd5..a7be49ee51bc 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/overview/readme.test.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/overview/readme.test.tsx @@ -10,9 +10,10 @@ import React from 'react'; import { createIntegrationsTestRendererMock } from '../../../../../../../mock'; import { Readme } from './readme'; +import { waitFor } from '@testing-library/dom'; describe('Readme', () => { - function render() { + function render(markdown: string | undefined) { const refs = { current: { set: jest.fn(), @@ -21,20 +22,111 @@ describe('Readme', () => { } as any; const testRenderer = createIntegrationsTestRendererMock(); return testRenderer.render( - + ); } - it('should render img tag with max width', () => { - const result = render(); - const img = result.getByAltText('Image'); + it('should render img tag with max width', async () => { + const result = render('# Test ![Image](../img/image.png)>'); - expect(img).toHaveStyle('max-width: 100%'); - expect(img).toHaveAttribute('src', '/mock/api/fleet/epm/packages/test/1.0.0/img/image.png'); + await waitFor(() => { + const img = result.getByAltText('Image'); + + expect(img).toHaveStyle('max-width: 100%'); + expect(img).toHaveAttribute('src', '/mock/api/fleet/epm/packages/test/1.0.0/img/image.png'); + }); + }); + + it('should render exported fields as accordions', async () => { + const result = render(` +# Test Integration + +This is a test integration. + +## Data streams + +### Logs + +This integration collects logs. + +#### Requirements and setup + +Some requirements and setup instructions. + +**Exported fields** + +| Field | Description | Type | +|---|---|---| +| @timestamp | Event timestamp. | date | +| integration.category | The log category name. | keyword | + +### Metrics + +Some metrics information. + +**Exported fields** + +| Field | Description | Type | Unit | Metric Type | +|---|---|---|---|---| +| @timestamp | Event timestamp. | date | | | +| integration.id | Some id | keyword | | | +`); + + await waitFor(() => { + const accordionSummaries = result.getAllByTestId('integrationsDocs.accordion'); + expect(accordionSummaries.length).toBe(2); + + const tables = result.container.querySelectorAll('table'); + expect(tables.length).toBe(2); + }); + }); + + it('should render empty markdown', async () => { + const result = render(''); + await waitFor(() => { + expect(result.container).not.toBeEmptyDOMElement(); + }); + }); + + it('should render even if markdown undefined', async () => { + const result = render(undefined); + await waitFor(() => { + const skeletonWrappers = result.getAllByTestId('euiSkeletonLoadingAriaWrapper'); + expect(skeletonWrappers.length).toBeGreaterThan(0); + + expect(result.container).not.toBeEmptyDOMElement(); + }); + }); + + it('should render correct if no exported fields found', async () => { + const result = render(` +# Test Integration + +This is a test integration. + +## Data streams + +### Logs + +This integration collects logs. + +#### Requirements and setup + +Some requirements and setup instructions. +`); + + await waitFor(() => { + expect(result.container).not.toBeEmptyDOMElement(); + + const accordion = result.queryByTestId('integrationsDocs.accordion'); + expect(accordion).not.toBeInTheDocument(); + }); + }); + + it('should remove script tags', async () => { + const result = render(''); + await waitFor(() => { + expect(result.queryByText('This should not run')).not.toBeInTheDocument(); + }); }); }); diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/overview/readme.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/overview/readme.tsx index 542e686bef05..a62776e9af75 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/overview/readme.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/overview/readme.tsx @@ -6,14 +6,17 @@ */ import { EuiText, EuiSkeletonText, EuiSpacer } from '@elastic/eui'; -import React from 'react'; +import React, { useMemo } from 'react'; import type { MutableRefObject } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; +import rehypeSanitize from 'rehype-sanitize'; +import rehypeRaw from 'rehype-raw'; import { useLinks } from '../../../../../hooks'; import { markdownRenderers } from './markdown_renderers'; +import MarkdownIt from 'markdown-it'; export function Readme({ packageName, @@ -38,21 +41,77 @@ export function Readme({ [toRelativeImage, packageName, version] ); + const wrapAllExportedFieldsTables = (content: string | undefined): string | undefined => { + if (!content) return content; + + const md = new MarkdownIt(); + const tokens = md.parse(content, {}); + const lines = content.split('\n'); + + const exportedFieldsLines: number[] = lines + .map((line, index) => (line.trim() === '**Exported fields**' ? index : -1)) + .filter((index) => index !== -1); + + if (exportedFieldsLines.length === 0) return content; + + const tableRanges: Array<[number, number]> = tokens + .filter((token) => token.type === 'table_open' && token.map) + .map((token) => token.map as [number, number]); + + const usedTables = new Set(); + const insertions: Array<{ summaryLine: number; start: number; end: number }> = []; + + for (const summaryLine of exportedFieldsLines) { + // Find the first table that starts after the summary line and hasn't been used yet + const table = tableRanges.find(([start], idx) => start > summaryLine && !usedTables.has(idx)); + if (table) { + const [start, end] = table; + usedTables.add(tableRanges.indexOf(table)); + insertions.push({ summaryLine, start, end }); + } + } + + const newLines: string[] = []; + let currentLine = 0; + + for (const { summaryLine, start, end } of insertions) { + newLines.push(...lines.slice(currentLine, summaryLine)); + currentLine = summaryLine + 1; + + newLines.push(...lines.slice(currentLine, start)); + currentLine = start; + + newLines.push('
Exported fields', ''); + newLines.push(...lines.slice(start, end)); + newLines.push('', '
'); + + currentLine = end; + } + + newLines.push(...lines.slice(currentLine)); + + return newLines.join('\n'); + }; + + const markdownWithCollapsable = useMemo(() => wrapAllExportedFieldsTables(markdown), [markdown]); + return ( <> - {markdown !== undefined ? ( + {markdownWithCollapsable !== undefined ? ( - {markdown} + {markdownWithCollapsable} ) : ( {/* simulates a long page of text loading */} + diff --git a/yarn.lock b/yarn.lock index 786ecc4dcf53..80d39252a9dc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20401,6 +20401,13 @@ hast-util-raw@^6.1.0: xtend "^4.0.0" zwitch "^1.0.0" +hast-util-sanitize@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/hast-util-sanitize/-/hast-util-sanitize-3.0.2.tgz#b0b783220af528ba8fe6999f092d138908678520" + integrity sha512-+2I0x2ZCAyiZOO/sb4yNLFmdwPBnyJ4PBkVTUMKMqBwYNA+lXSgOmoRXlJFazoyid9QPogRRKgKhVEodv181sA== + dependencies: + xtend "^4.0.0" + hast-util-to-html@^7.1.1: version "7.1.1" resolved "https://registry.yarnpkg.com/hast-util-to-html/-/hast-util-to-html-7.1.1.tgz#39818b8bbfcb8eaa87846a120b3875487b27d094" @@ -27903,7 +27910,7 @@ regjsparser@^0.9.1: dependencies: jsesc "~0.5.0" -rehype-raw@^5.1.0: +rehype-raw@5.1.0, rehype-raw@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/rehype-raw/-/rehype-raw-5.1.0.tgz#66d5e8d7188ada2d31bc137bc19a1000cf2c6b7e" integrity sha512-MDvHAb/5mUnif2R+0IPCYJU8WjHa9UzGtM/F4AVy5GixPlDZ1z3HacYy4xojDU+uBa+0X/3PIfyQI26/2ljJNA== @@ -27918,6 +27925,13 @@ rehype-react@^6.2.1: "@mapbox/hast-util-table-cell-style" "^0.2.0" hast-to-hyperscript "^9.0.0" +rehype-sanitize@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/rehype-sanitize/-/rehype-sanitize-4.0.0.tgz#b5241cf66bcedc49cd4e924a5f7a252f00a151ad" + integrity sha512-ZCr/iQRr4JeqPjun5i9CHHILVY7i45VnLu1CkkibDrSyFQ7dTLSvw8OIQpHhS4RSh9h/9GidxFw1bRb0LOxIag== + dependencies: + hast-util-sanitize "^3.0.0" + rehype-stringify@^8.0.0: version "8.0.0" resolved "https://registry.yarnpkg.com/rehype-stringify/-/rehype-stringify-8.0.0.tgz#9b6afb599bcf3165f10f93fc8548f9a03d2ec2ba"