Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
- `searchListPredicate` property: Allows to filter the complete list of search options at once.
- Following optional BlueprintJs properties are forwarded now to override default behaviour: `noResults`, `createNewItemRenderer` and `itemRenderer`
- `isValidNewOption` property: Checks if an input string is or can be turned into a valid new option.
- `<Markdown />`
- Added `cutOff` property to set maximum number of raw Markdown characters to render
- new `utils` methods:
- `truncateMarkdownDisplay`: helper function to iterate over `Markdown` renderings to improve the experienced `cutOff` value

### Fixed

Expand All @@ -34,6 +38,9 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
- `Toaster.create` is now an async function
- `<MultiSelect />`
- by default, if no searchPredicate or searchListPredicate is defined, the filtering is done via case-insensitive multi-word filtering.
- `<StringPreviewContentBlobToggler />` uses now the `Markdown.cutOff` property
- this enables Markdown rendering even if the preview need to be shortened
- this may lead to slightly different preview lengths

### Deprecated

Expand Down Expand Up @@ -202,7 +209,7 @@ This is a major release, and it might be not compatible with your current usage
- Add `ModalContext` to track open/close state of all used application modals.
- Add `modalId` property to give a modal a unique ID for tracking purposes.
- `preventReactFlowEvents`: adds 'nopan', 'nowheel' and 'nodrag' classes to overlay classes in order to prevent react-flow to react to drag and pan actions in modals.
- new `utils` methods
- new `utils` methods
- `colorCalculateDistance()`: calculates the difference between 2 colors using the simple CIE76 formula
- `textToColorHash()`: calculates a color from a text string
- `reduceToText`: shrinks HTML content and React elements to plain text, used for `<TextReducer />`
Expand Down
18 changes: 15 additions & 3 deletions src/cmem/ContentBlobToggler/StringPreviewContentBlobToggler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React from "react";

import { utils } from "../../common";
import InlineText from "../../components/Typography/InlineText";
import { Markdown } from "../markdown/Markdown";
import { Markdown, markdownAllowedInlineElements } from "../markdown/Markdown";

import { ContentBlobToggler, ContentBlobTogglerProps } from "./ContentBlobToggler";

Expand Down Expand Up @@ -57,7 +57,7 @@ export function StringPreviewContentBlobToggler({
startExtended,
useOnly,
renderPreviewAsMarkdown = false,
allowedHtmlElementsInPreview,
allowedHtmlElementsInPreview = markdownAllowedInlineElements,
noTogglerContentSuffix,
firstNonEmptyLineOnly,
...otherContentBlobTogglerProps
Expand Down Expand Up @@ -90,7 +90,19 @@ export function StringPreviewContentBlobToggler({
previewMaxLength &&
utils.reduceToText(previewContent, { decodeHtmlEntities: true }).length > previewMaxLength
) {
previewContent = utils.reduceToText(previewContent, { decodeHtmlEntities: true }).slice(0, previewMaxLength);
previewContent = renderPreviewAsMarkdown
? utils.truncateMarkdownDisplay(
<Markdown
key="markdown-content"
allowedElements={allowedHtmlElementsInPreview}
cutOff={previewMaxLength}
cutOffSuffix={""}
>
{previewString}
</Markdown>,
{ decodeHtmlEntities: true },
)
: utils.reduceToText(previewContent, { decodeHtmlEntities: true }).slice(0, previewMaxLength);
enableToggler = true;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const Template: StoryFn<typeof StringPreviewContentBlobToggler> = (args) => (
);

const initialTeststring =
"A library for GUI elements.\nIn order to create graphical user interfaces, please have look at the documentation at [Github](https://github.com/eccenca/gui-elements).";
"# A library for [GUI elements](https://github.com/eccenca/gui-elements).\nIn order to create graphical user interfaces, please\n* have look at the documentation at [Github](https://github.com/eccenca/gui-elements).";

export const Default = Template.bind({});
Default.args = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,8 @@ describe("StringPreviewContentBlobToggler", () => {
{...(StringPreviewContentBlobTogglerStory.args as StringPreviewContentBlobTogglerProps)}
/>,
);
textMustExist(queryByText, "A library for GUI elements.");
textMustNotExist(
queryByText,
"In order to create graphical user interfaces, please have look at the documentation at",
);
textMustExist(queryByText, "A library for");
textMustNotExist(queryByText, "documentation at");
textMustExist(queryByText, "show more");
});
it("should display full view if `startExtended` is enabled, and show toggler to reduce", () => {
Expand All @@ -37,10 +34,8 @@ describe("StringPreviewContentBlobToggler", () => {
startExtended
/>,
);
textMustExist(
queryByText,
"In order to create graphical user interfaces, please have look at the documentation at",
);
textMustExist(queryByText, "In order to create graphical user interfaces, please");
textMustExist(queryByText, "have look at the documentation at");
textMustExist(queryByText, "show less");
});
it('should display only first content line on `useOnly={"firstNonEmptyLine"}`', () => {
Expand All @@ -50,7 +45,7 @@ describe("StringPreviewContentBlobToggler", () => {
useOnly={"firstNonEmptyLine"}
/>,
);
textMustExist(queryByText, "A library for GUI elements.");
textMustExist(queryByText, "A library for");
textMustNotExist(queryByText, "In order to create");
});
it('should use first Markdown paragraph as preview content on `useOnly={"firstMarkdownSection"}` but shorten it', () => {
Expand All @@ -60,9 +55,9 @@ describe("StringPreviewContentBlobToggler", () => {
useOnly={"firstMarkdownSection"}
/>,
);
textMustExist(queryByText, "A library for GUI elements.");
textMustExist(queryByText, "A library for");
textMustExist(queryByText, "In order to create");
textMustNotExist(queryByText, "please have look at the documentation at");
textMustNotExist(queryByText, "documentation at");
});
it("should display full preview and no toggler if content is short enough", () => {
const { queryByText } = render(
Expand All @@ -71,11 +66,9 @@ describe("StringPreviewContentBlobToggler", () => {
previewMaxLength={144}
/>,
);
textMustExist(queryByText, "A library for GUI elements.");
textMustExist(
queryByText,
"In order to create graphical user interfaces, please have look at the documentation at",
);
textMustExist(queryByText, "A library for");
textMustExist(queryByText, "In order to create graphical user interfaces, please");
textMustExist(queryByText, "have look at the documentation at");
textMustNotExist(queryByText, "https://github.com/"); // test if Markdown was rendered
textMustNotExist(queryByText, "show more");
});
Expand All @@ -87,11 +80,7 @@ describe("StringPreviewContentBlobToggler", () => {
renderPreviewAsMarkdown={false}
/>,
);
textMustExist(queryByText, "A library for GUI elements.");
textMustExist(
queryByText,
"In order to create graphical user interfaces, please have look at the documentation at",
);
textMustExist(queryByText, "A library for [GUI elements]"); // raw Markdown link syntax visible
textMustExist(queryByText, "https://github.com/"); // test if Markdown was rendered
textMustExist(queryByText, "show more");
});
Expand Down
94 changes: 94 additions & 0 deletions src/cmem/markdown/Markdown.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,97 @@ A line with some <strong>HTML code</strong> inside.
[^1]: This is the text related to the the footnote referrer.
`,
};

export const CutOff = Template.bind({});

const cutOffContent = `This component renders Markdown content safely. It supports **GitHub Flavoured Markdown**, syntax highlighting for code blocks, and definition lists.

You can:
* configure _link targets_
* add custom __rehype__ plugins
* and filter content through an allowed elements list
A third paragraph that will not appear once the cutOff limit is reached.`;

CutOff.args = {
children: cutOffContent,
cutOff: cutOffContent.indexOf("filter"),
};

export const CutOffWithCodeFence = Template.bind({});

CutOffWithCodeFence.args = {
children: `A short paragraph before the code block.
Here is an important code example:
\`\`\`json
{
"host": "localhost",
"port": 8080,
"debug": true
}
\`\`\`

This paragraph comes after the code block and should not appear when the cutOff limit falls inside the fence above.
`,
cutOff: 110,
cutOffSuffix: "...",
};

const indentedCodeFenceContent = `Intro.

\`\`\`ts
const first = 1;
const second = 2;
\`\`\`

Outro.`;

export const CutOffWithIndentedCodeFence = Template.bind({});

CutOffWithIndentedCodeFence.args = {
children: indentedCodeFenceContent,
cutOff: indentedCodeFenceContent.indexOf("first"),
cutOffSuffix: "...",
};

export const CutOffWithLinks = Template.bind({});

CutOffWithLinks.args = {
children: Array.from(
{ length: 20 },
(_, index) => `[open item ${index + 1}](https://example.com/item/${index + 1})`,
).join(" "),
cutOff: 80,
cutOffSuffix: "...",
};

export const CutOffWithFenceAndLink = Template.bind({});

CutOffWithFenceAndLink.args = {
children: `A short paragraph before the code block.

\`\`\`ts
const status = "ready";
const nextStep = "open details";
\`\`\`

~~~ts
some code here
~~~
Continue with the [detailed implementation guide](https://example.com/docs/implementation/very/long/path) after the code block.`,
cutOff: 153,
cutOffSuffix: "...",
};

export const CutOffWithTable = Template.bind({});

CutOffWithTable.args = {
children: `| Name | Value |
| --- | --- |
| Alpha | First visible row |
| Beta | Second visible row |
| Gamma | Row that should not be partially rendered |

This paragraph comes after the table and should not appear in the preview.`,
cutOff: 90,
cutOffSuffix: "...",
};
134 changes: 134 additions & 0 deletions src/cmem/markdown/Markdown.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import React from "react";
import { render } from "@testing-library/react";

import { truncateMarkdownDisplay } from "../../common/utils/truncateMarkdownDisplay";

import { Markdown } from "./Markdown";

describe("Markdown", () => {
it("keeps markdown links valid when cutOff is calculated from rendered link labels", () => {
const linkHeavy = Array.from({ length: 20 }, (_, i) => `[click](https://example.com/${i})`).join(" ");
const { container } = render(<Markdown cutOff={60}>{linkHeavy}</Markdown>);

expect(container.querySelectorAll("a").length).toBeGreaterThan(0);
expect(container.textContent).toContain("click");
expect(container.textContent).not.toContain("https://example.com");
});

it("backs up before a fenced code block when cutOff falls inside it after a paragraph boundary", () => {
const content = [
"Intro.",
"",
"```",
"const first = 1;",
"const second = 2;",
"const third = 3;",
"```",
"",
"Outro.",
].join("\n");

const { container } = render(<Markdown cutOff={35}>{content}</Markdown>);

expect(container.querySelector("pre")).toBeFalsy();
expect(container.textContent).toContain("Intro.");
expect(container.textContent).not.toContain("const first");
expect(container.textContent).not.toContain("Outro");
});

it("backs up before a fenced code block when cutOff falls inside it after preceding text without a paragraph boundary", () => {
const content = [
"A short paragraph before the code block.",
"Here is an important code example:",
"```json",
"{",
' "host": "localhost"',
"}",
"```",
"",
"After fence.",
].join("\n");

const { container } = render(<Markdown cutOff={content.indexOf("localhost")}>{content}</Markdown>);

expect(container.querySelector("pre")).toBeFalsy();
expect(container.textContent).toContain("Here is an important code example:");
expect(container.textContent).not.toContain("localhost");
expect(container.textContent).not.toContain("After fence");
});

it("renders a valid table when cutOff falls inside a markdown table", () => {
const content = [
"| Name | Value |",
"| --- | --- |",
"| alpha | one |",
"| beta | two |",
"| gamma | three |",
"",
"After table.",
].join("\n");

const { container } = render(<Markdown cutOff={55}>{content}</Markdown>);

expect(container.querySelector("table")).toBeTruthy();
expect(container.textContent).toContain("Name");
expect(container.textContent).toContain("alpha");
expect(container.textContent).not.toContain("After table");
});

it("keeps a complete list when cutOff falls after it without a paragraph boundary", () => {
const content = `This component renders Markdown content safely. It supports **GitHub Flavoured Markdown**, syntax highlighting for code blocks, and definition lists.

You can:
* configure _link targets_
* add custom __rehype__ plugins
* and filter content through an allowed elements list
A third paragraph that will not appear once the cutOff limit is reached.`;

const { container } = render(<Markdown cutOff={300}>{content}</Markdown>);

expect(container.textContent).toContain("You can:");
expect(container.textContent).toContain("configure link targets");
expect(container.textContent).toContain("add custom rehype plugins");
expect(container.textContent).toContain("and filter content through an allowed elements list");
expect(container.textContent).not.toContain("A third paragraph");
});

it("keeps complete fenced blocks before a following link with display cutOff", () => {
const content = [
"A short paragraph before the code block.",
"",
"```ts",
'const status = "ready";',
'const nextStep = "open details";',
"```",
"",
"~~~ts",
"some code here",
"~~~",
"Continue with the [detailed implementation guide](https://example.com/docs/implementation/very/long/path) after the code block.",
].join("\n");

for (const cutOff of [152, 153]) {
const { container } = render(truncateMarkdownDisplay(<Markdown cutOff={cutOff}>{content}</Markdown>));

expect(container.querySelectorAll("pre")).toHaveLength(2);
expect(container.textContent).toContain('const status = "ready";');
expect(container.textContent).toContain("some code here");
expect(container.textContent).toContain("Continue with the");
expect(container.textContent).not.toContain("https://example.com");
}
});

it("renders a link at the end of long content when cutOff is absent", () => {
const content = `${Array.from({ length: 40 }, (_, index) => `Long visible paragraph part ${index + 1}.`).join(
" ",
)} [final reference](https://example.com/final-reference)`;

const { container } = render(<Markdown>{content}</Markdown>);

expect(container.textContent).toContain("Long visible paragraph part 1.");
expect(container.textContent).toContain("final reference");
expect(container.querySelector('a[href="https://example.com/final-reference"]')).toBeTruthy();
});
});
Loading
Loading