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
;
},
+ 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 >');
- 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"