Commit 18e729fe authored by boojack's avatar boojack

chore(memo): simplify markdown task list rendering

parent 9e310bf9
import type { Element } from "hast";
import React from "react";
import { isMentionElement, isTagElement, isTaskListItemElement } from "@/types/markdown";
/**
* Creates a conditional component that renders different components
* based on AST node type detection
*
* @param CustomComponent - Custom component to render when condition matches
* @param DefaultComponent - Default component/element to render otherwise
* @param condition - Function to test AST node
* @returns Conditional wrapper component
*/
export const createConditionalComponent = <P extends Record<string, unknown>>(
CustomComponent: React.ComponentType<P>,
DefaultComponent: React.ComponentType<P> | keyof JSX.IntrinsicElements,
condition: (node: Element) => boolean,
) => {
return (props: P & { node?: Element }) => {
const { node, ...restProps } = props;
// Check AST node to determine which component to use
if (node && condition(node)) {
return <CustomComponent {...(restProps as P)} node={node} />;
}
// Render default component/element
if (typeof DefaultComponent === "string") {
return React.createElement(DefaultComponent, restProps);
}
return <DefaultComponent {...(restProps as P)} />;
};
};
// Re-export type guards for convenience
export { isMentionElement as isMentionNode, isTagElement as isTagNode, isTaskListItemElement as isTaskListItemNode };
import type { Element } from "hast";
import type { Components } from "react-markdown";
import ReactMarkdown from "react-markdown";
import rehypeKatex from "rehype-katex";
import rehypeRaw from "rehype-raw";
import rehypeSanitize from "rehype-sanitize";
import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import { isMentionElement, isTagElement, isTaskListItemElement } from "@/types/markdown";
import { rehypeHeadingId } from "@/utils/rehype-plugins/rehype-heading-id";
import { remarkDisableSetext } from "@/utils/remark-plugins/remark-disable-setext";
import { remarkMention } from "@/utils/remark-plugins/remark-mention";
import { remarkPreserveType } from "@/utils/remark-plugins/remark-preserve-type";
import { remarkSplitMixedTaskLists } from "@/utils/remark-plugins/remark-split-mixed-task-lists";
import { remarkTag } from "@/utils/remark-plugins/remark-tag";
import { CodeBlock } from "./CodeBlock";
import { SANITIZE_SCHEMA } from "./constants";
import { Mention } from "./Mention";
import { Blockquote, Heading, HorizontalRule, Image, InlineCode, Link, List, ListItem, Paragraph } from "./markdown";
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from "./Table";
import { Tag } from "./Tag";
import { TaskListItem } from "./TaskListItem";
import { TrustedIframe } from "./TrustedIframe";
interface MemoMarkdownRendererProps {
content: string;
resolvedMentionUsernames: Set<string>;
}
function getMentionUsername(node: Element, children?: React.ReactNode): string {
const dataMention = node.properties?.["data-mention"];
if (typeof dataMention === "string" && dataMention !== "") {
return dataMention;
}
const camelDataMention = (node.properties as Record<string, unknown> | undefined)?.dataMention;
if (typeof camelDataMention === "string" && camelDataMention !== "") {
return camelDataMention;
}
const text = Array.isArray(children) ? children.join("") : children;
if (typeof text === "string" && text.startsWith("@")) {
return text.slice(1).toLowerCase();
}
return "";
}
export const MemoMarkdownRenderer = ({ content, resolvedMentionUsernames }: MemoMarkdownRendererProps) => {
const markdownComponents: Components = {
input: ({ node, ...inputProps }) => {
if (node && isTaskListItemElement(node)) {
return <TaskListItem {...inputProps} node={node} />;
}
return <input {...inputProps} />;
},
span: ({ node, ...spanProps }) => {
if (node && isMentionElement(node)) {
const username = getMentionUsername(node, spanProps.children);
return <Mention {...spanProps} node={node} data-mention={username} resolved={resolvedMentionUsernames.has(username)} />;
}
if (node && isTagElement(node)) {
return <Tag {...spanProps} node={node} />;
}
return <span {...spanProps} />;
},
h1: ({ children, ...props }) => (
<Heading level={1} {...props}>
{children}
</Heading>
),
h2: ({ children, ...props }) => (
<Heading level={2} {...props}>
{children}
</Heading>
),
h3: ({ children, ...props }) => (
<Heading level={3} {...props}>
{children}
</Heading>
),
h4: ({ children, ...props }) => (
<Heading level={4} {...props}>
{children}
</Heading>
),
h5: ({ children, ...props }) => (
<Heading level={5} {...props}>
{children}
</Heading>
),
h6: ({ children, ...props }) => (
<Heading level={6} {...props}>
{children}
</Heading>
),
p: ({ children, ...props }) => <Paragraph {...props}>{children}</Paragraph>,
blockquote: ({ children, ...props }) => <Blockquote {...props}>{children}</Blockquote>,
hr: (props) => <HorizontalRule {...props} />,
ul: ({ children, ...props }) => <List {...props}>{children}</List>,
ol: ({ children, ...props }) => (
<List ordered {...props}>
{children}
</List>
),
li: ({ children, ...props }) => <ListItem {...props}>{children}</ListItem>,
a: ({ children, ...props }) => <Link {...props}>{children}</Link>,
code: ({ children, ...props }) => <InlineCode {...props}>{children}</InlineCode>,
iframe: TrustedIframe,
img: (props) => <Image {...props} />,
pre: CodeBlock,
table: ({ children, ...props }) => <Table {...props}>{children}</Table>,
thead: ({ children, ...props }) => <TableHead {...props}>{children}</TableHead>,
tbody: ({ children, ...props }) => <TableBody {...props}>{children}</TableBody>,
tr: ({ children, ...props }) => <TableRow {...props}>{children}</TableRow>,
th: ({ children, ...props }) => <TableHeaderCell {...props}>{children}</TableHeaderCell>,
td: ({ children, ...props }) => <TableCell {...props}>{children}</TableCell>,
};
return (
<ReactMarkdown
remarkPlugins={[
remarkDisableSetext,
remarkMath,
remarkGfm,
remarkSplitMixedTaskLists,
remarkBreaks,
remarkMention,
remarkTag,
remarkPreserveType,
]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, SANITIZE_SCHEMA], rehypeHeadingId, [rehypeKatex, { throwOnError: false, strict: false }]]}
components={markdownComponents}
>
{content}
</ReactMarkdown>
);
};
import type { Element } from "hast";
import { ChevronDown, ChevronUp } from "lucide-react"; import { ChevronDown, ChevronUp } from "lucide-react";
import { memo, useMemo } from "react"; import { memo, useMemo } from "react";
import ReactMarkdown from "react-markdown";
import rehypeKatex from "rehype-katex";
import rehypeRaw from "rehype-raw";
import rehypeSanitize from "rehype-sanitize";
import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import { rehypeHeadingId } from "@/utils/rehype-plugins/rehype-heading-id"; import { extractMentionUsernames } from "@/utils/remark-plugins/remark-mention";
import { remarkDisableSetext } from "@/utils/remark-plugins/remark-disable-setext"; import { COMPACT_MODE_CONFIG } from "./constants";
import { extractMentionUsernames, remarkMention } from "@/utils/remark-plugins/remark-mention";
import { remarkPreserveType } from "@/utils/remark-plugins/remark-preserve-type";
import { remarkSplitMixedTaskLists } from "@/utils/remark-plugins/remark-split-mixed-task-lists";
import { remarkTag } from "@/utils/remark-plugins/remark-tag";
import { CodeBlock } from "./CodeBlock";
import { isMentionNode, isTagNode, isTaskListItemNode } from "./ConditionalComponent";
import { COMPACT_MODE_CONFIG, SANITIZE_SCHEMA } from "./constants";
import { useCompactLabel, useCompactMode } from "./hooks"; import { useCompactLabel, useCompactMode } from "./hooks";
import { Mention } from "./Mention"; import { MemoMarkdownRenderer } from "./MemoMarkdownRenderer";
import { useResolvedMentionUsernames } from "./MentionResolutionContext"; import { useResolvedMentionUsernames } from "./MentionResolutionContext";
import { Blockquote, Heading, HorizontalRule, Image, InlineCode, Link, List, ListItem, Paragraph } from "./markdown";
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from "./Table";
import { Tag } from "./Tag";
import { TaskListItem } from "./TaskListItem";
import { TrustedIframe } from "./TrustedIframe";
import type { MemoContentProps } from "./types"; import type { MemoContentProps } from "./types";
function getMentionUsername(node: Element, children?: React.ReactNode): string {
const dataMention = node.properties?.["data-mention"];
if (typeof dataMention === "string" && dataMention !== "") {
return dataMention;
}
const camelDataMention = (node.properties as Record<string, unknown> | undefined)?.dataMention;
if (typeof camelDataMention === "string" && camelDataMention !== "") {
return camelDataMention;
}
const text = Array.isArray(children) ? children.join("") : children;
if (typeof text === "string" && text.startsWith("@")) {
return text.slice(1).toLowerCase();
}
return "";
}
const MemoContent = (props: MemoContentProps) => { const MemoContent = (props: MemoContentProps) => {
const { className, contentClassName, content, onClick, onDoubleClick } = props; const { className, contentClassName, content, onClick, onDoubleClick } = props;
const t = useTranslate(); const t = useTranslate();
...@@ -79,104 +40,7 @@ const MemoContent = (props: MemoContentProps) => { ...@@ -79,104 +40,7 @@ const MemoContent = (props: MemoContentProps) => {
onMouseUp={onClick} onMouseUp={onClick}
onDoubleClick={onDoubleClick} onDoubleClick={onDoubleClick}
> >
<ReactMarkdown <MemoMarkdownRenderer content={content} resolvedMentionUsernames={resolvedMentionUsernames} />
remarkPlugins={[
remarkDisableSetext,
remarkMath,
remarkGfm,
remarkSplitMixedTaskLists,
remarkBreaks,
remarkMention,
remarkTag,
remarkPreserveType,
]}
rehypePlugins={[
rehypeRaw,
[rehypeSanitize, SANITIZE_SCHEMA],
rehypeHeadingId,
[rehypeKatex, { throwOnError: false, strict: false }],
]}
components={{
// Child components consume from MemoViewContext directly
input: ((inputProps: React.ComponentProps<"input"> & { node?: Element }) => {
const { node, ...rest } = inputProps;
if (node && isTaskListItemNode(node)) {
return <TaskListItem {...inputProps} />;
}
return <input {...rest} />;
}) as React.ComponentType<React.ComponentProps<"input">>,
span: ((spanProps: React.ComponentProps<"span"> & { node?: Element }) => {
const { node, ...rest } = spanProps;
if (node && isMentionNode(node)) {
const username = getMentionUsername(node, spanProps.children);
return <Mention {...spanProps} data-mention={username} resolved={resolvedMentionUsernames.has(username)} />;
}
if (node && isTagNode(node)) {
return <Tag {...spanProps} />;
}
return <span {...rest} />;
}) as React.ComponentType<React.ComponentProps<"span">>,
// Headings
h1: ({ children, ...props }) => (
<Heading level={1} {...props}>
{children}
</Heading>
),
h2: ({ children, ...props }) => (
<Heading level={2} {...props}>
{children}
</Heading>
),
h3: ({ children, ...props }) => (
<Heading level={3} {...props}>
{children}
</Heading>
),
h4: ({ children, ...props }) => (
<Heading level={4} {...props}>
{children}
</Heading>
),
h5: ({ children, ...props }) => (
<Heading level={5} {...props}>
{children}
</Heading>
),
h6: ({ children, ...props }) => (
<Heading level={6} {...props}>
{children}
</Heading>
),
// Block elements
p: ({ children, ...props }) => <Paragraph {...props}>{children}</Paragraph>,
blockquote: ({ children, ...props }) => <Blockquote {...props}>{children}</Blockquote>,
hr: (props) => <HorizontalRule {...props} />,
// Lists
ul: ({ children, ...props }) => <List {...props}>{children}</List>,
ol: ({ children, ...props }) => (
<List ordered {...props}>
{children}
</List>
),
li: ({ children, ...props }) => <ListItem {...props}>{children}</ListItem>,
// Inline elements
a: ({ children, ...props }) => <Link {...props}>{children}</Link>,
code: ({ children, ...props }) => <InlineCode {...props}>{children}</InlineCode>,
iframe: TrustedIframe as React.ComponentType<React.ComponentProps<"iframe">>,
img: ({ ...props }) => <Image {...props} />,
// Code blocks
pre: CodeBlock,
// Tables
table: ({ children, ...props }) => <Table {...props}>{children}</Table>,
thead: ({ children, ...props }) => <TableHead {...props}>{children}</TableHead>,
tbody: ({ children, ...props }) => <TableBody {...props}>{children}</TableBody>,
tr: ({ children, ...props }) => <TableRow {...props}>{children}</TableRow>,
th: ({ children, ...props }) => <TableHeaderCell {...props}>{children}</TableHeaderCell>,
td: ({ children, ...props }) => <TableCell {...props}>{children}</TableCell>,
}}
>
{content}
</ReactMarkdown>
{showCompactMode === "ALL" && ( {showCompactMode === "ALL" && (
<div <div
className={cn( className={cn(
......
...@@ -20,8 +20,8 @@ export const List = ({ ordered, children, className, node: _node, ...domProps }: ...@@ -20,8 +20,8 @@ export const List = ({ ordered, children, className, node: _node, ...domProps }:
className={cn( className={cn(
"my-0 mb-2 list-outside", "my-0 mb-2 list-outside",
isTaskList isTaskList
? // Task list: no bullets, nested lists get left margin for indentation ? // Task list indentation is handled by task item grid columns.
"list-none [&_ul.contains-task-list]:ml-6" "list-none"
: // Regular list: standard padding and list style : // Regular list: standard padding and list style
cn("pl-6", ordered ? "list-decimal" : "list-disc"), cn("pl-6", ordered ? "list-decimal" : "list-disc"),
className, className,
...@@ -40,7 +40,6 @@ interface ListItemProps extends React.LiHTMLAttributes<HTMLLIElement>, ReactMark ...@@ -40,7 +40,6 @@ interface ListItemProps extends React.LiHTMLAttributes<HTMLLIElement>, ReactMark
/** /**
* List item component for both regular and task list items * List item component for both regular and task list items
* Detects task items via the "task-list-item" class added by remark-gfm * Detects task items via the "task-list-item" class added by remark-gfm
* Applies specialized styling for task checkboxes
*/ */
export const ListItem = ({ children, className, node: _node, ...domProps }: ListItemProps) => { export const ListItem = ({ children, className, node: _node, ...domProps }: ListItemProps) => {
const isTaskListItem = className?.includes(TASK_LIST_ITEM_CLASS); const isTaskListItem = className?.includes(TASK_LIST_ITEM_CLASS);
...@@ -49,11 +48,9 @@ export const ListItem = ({ children, className, node: _node, ...domProps }: List ...@@ -49,11 +48,9 @@ export const ListItem = ({ children, className, node: _node, ...domProps }: List
return ( return (
<li <li
className={cn( className={cn(
"mt-0.5 leading-6 list-none", "mt-0.5 leading-6 list-none grid grid-cols-[auto_1fr] items-center gap-x-2",
// Checkbox styling: margin and alignment "[&>ul]:col-start-2 [&>ul]:col-span-1 [&>ol]:col-start-2 [&>ol]:col-span-1",
"[&>button]:mr-2 [&>button]:align-middle", "[&>p:first-child]:contents [&>p:not(:first-child)]:col-start-2 [&>p:not(:first-child)]:col-span-1",
// Inline paragraph for task text
"[&>p]:inline [&>p]:m-0",
className, className,
)} )}
{...domProps} {...domProps}
......
# Markdown Components # Markdown Components
Modern, type-safe React components for rendering markdown content via react-markdown. Small React components used by `MemoMarkdownRenderer` to style HTML emitted by `react-markdown`.
## Architecture ## Responsibilities
### Component-Based Rendering - Keep element styling local to each semantic HTML element.
Following patterns from popular AI chat apps (ChatGPT, Claude, Perplexity), we use React components instead of CSS selectors for markdown rendering. This provides: - Strip the `node` prop from DOM output through `ReactMarkdownProps`.
- Preserve existing markdown behavior while avoiding structural fixes in CSS.
- **Type Safety**: Full TypeScript support with proper prop types ## Task Lists
- **Maintainability**: Components are easier to test, modify, and understand
- **Performance**: No CSS specificity conflicts, cleaner DOM
- **Modularity**: Each element is independently styled and documented
### Type System GFM task lists are normalized before rendering by `remarkSplitMixedTaskLists`.
All components extend `ReactMarkdownProps` which includes the AST `node` prop passed by react-markdown. This is explicitly destructured as `node: _node` to: - Mixed task/bullet lists are split into separate lists so regular bullets keep bullets.
1. Filter it from DOM props (avoids `node="[object Object]"` in HTML) - Single-block split items are rendered as tight list items, preventing accidental `<p>` wrappers.
2. Keep it available for advanced use cases (e.g., detecting task lists) - `ListItem` uses a two-column grid: checkbox/control in the first column, task text and nested content in the second.
3. Maintain type safety without `as any` casts - Loose task items keep paragraph structure; the first paragraph contributes its checkbox/text to the grid, while later paragraphs align with the text column.
### GFM Task Lists
Task lists (from remark-gfm) are handled by:
- **Detection**: `contains-task-list` and `task-list-item` classes from remark-gfm
- **Styling**: Tailwind utilities with arbitrary variants for nested elements
- **Checkboxes**: Custom `TaskListItem` component with Radix UI checkbox
- **Interactivity**: Updates memo content via `toggleTaskAtIndex` utility
### Component Patterns
Each component follows this structure:
```tsx
import { cn } from "@/lib/utils";
import type { ReactMarkdownProps } from "./types";
interface ComponentProps extends React.HTMLAttributes<HTMLElement>, ReactMarkdownProps {
children?: React.ReactNode;
// component-specific props
}
/**
* JSDoc description
*/
export const Component = ({ children, className, node: _node, ...props }: ComponentProps) => {
return (
<element className={cn("base-classes", className)} {...props}>
{children}
</element>
);
};
```
## Components
| Component | Element | Purpose |
|-----------|---------|---------|
| `Heading` | h1-h6 | Semantic headings with level-based styling |
| `Paragraph` | p | Compact paragraphs with consistent spacing |
| `Link` | a | External links with security attributes |
| `List` | ul/ol | Regular and GFM task lists |
| `ListItem` | li | List items with task checkbox support |
| `Blockquote` | blockquote | Quotes with left border accent |
| `InlineCode` | code | Inline code with background |
| `Image` | img | Responsive images with rounded corners |
| `HorizontalRule` | hr | Section separators |
## Styling Approach
- **Tailwind CSS**: All styling uses Tailwind utilities
- **Design Tokens**: Colors use CSS variables (e.g., `--primary`, `--muted-foreground`)
- **Responsive**: Max-width constraints, responsive images
- **Accessibility**: Semantic HTML, proper ARIA attributes via Radix UI
## Integration
Components are mapped to HTML elements in `MemoContent/index.tsx`:
```tsx
<ReactMarkdown
components={{
h1: ({ children }) => <Heading level={1}>{children}</Heading>,
p: ({ children, ...props }) => <Paragraph {...props}>{children}</Paragraph>,
// ... more mappings
}}
>
{content}
</ReactMarkdown>
```
## Future Enhancements
- [ ] Syntax highlighting themes for code blocks
- [ ] Table sorting/filtering interactions
- [ ] Image lightbox/zoom functionality
- [ ] Collapsible sections for long content
- [ ] Copy button for code blocks
import type { List, ListItem, Root } from "mdast"; import type { List, ListItem, Root } from "mdast";
import type { Parent } from "unist"; import type { Parent } from "unist";
const isTaskListItem = (item: ListItem): boolean => typeof item.checked === "boolean"; const isTaskItem = (item: ListItem): boolean => typeof item.checked === "boolean";
const isSingleBlockItem = (item: ListItem): boolean => item.children.length <= 1;
const hasLooseBlockItem = (item: ListItem): boolean => !isSingleBlockItem(item) && Boolean(item.spread);
const normalizeSplitListItems = (items: ListItem[]): ListItem[] =>
items.map((item) => (isSingleBlockItem(item) ? { ...item, spread: false } : item));
const splitMixedList = (list: List): List[] => { const splitMixedList = (list: List): List[] => {
const hasTaskItem = list.children.some(isTaskListItem); const hasTaskItem = list.children.some(isTaskItem);
const hasRegularItem = list.children.some((item) => !isTaskListItem(item)); const hasRegularItem = list.children.some((item) => !isTaskItem(item));
if (!hasTaskItem || !hasRegularItem) { if (!hasTaskItem || !hasRegularItem) {
return [list]; return [list];
...@@ -13,7 +20,7 @@ const splitMixedList = (list: List): List[] => { ...@@ -13,7 +20,7 @@ const splitMixedList = (list: List): List[] => {
const groups: Array<{ isTaskGroup: boolean; items: ListItem[] }> = []; const groups: Array<{ isTaskGroup: boolean; items: ListItem[] }> = [];
for (const item of list.children) { for (const item of list.children) {
const isTaskGroup = isTaskListItem(item); const isTaskGroup = isTaskItem(item);
const previousGroup = groups.at(-1); const previousGroup = groups.at(-1);
if (previousGroup && previousGroup.isTaskGroup === isTaskGroup) { if (previousGroup && previousGroup.isTaskGroup === isTaskGroup) {
...@@ -23,11 +30,14 @@ const splitMixedList = (list: List): List[] => { ...@@ -23,11 +30,14 @@ const splitMixedList = (list: List): List[] => {
} }
} }
return groups.map(({ isTaskGroup, items }) => ({ return groups.map(({ items }) => {
...list, const children = normalizeSplitListItems(items);
children: isTaskGroup ? items : items.map((item) => ({ ...item, spread: false })), return {
spread: isTaskGroup ? list.spread : false, ...list,
})); children,
spread: children.some(hasLooseBlockItem),
};
});
}; };
const splitMixedTaskListsInParent = (parent: Parent): void => { const splitMixedTaskListsInParent = (parent: Parent): void => {
......
...@@ -34,6 +34,8 @@ describe("memo content lists", () => { ...@@ -34,6 +34,8 @@ describe("memo content lists", () => {
expect(html).toContain('<li class="mt-0.5 leading-6">milk</li>'); expect(html).toContain('<li class="mt-0.5 leading-6">milk</li>');
expect(html).not.toContain('<li class="mt-0.5 leading-6">\n<p>milk</p>'); expect(html).not.toContain('<li class="mt-0.5 leading-6">\n<p>milk</p>');
expect(html).toContain(TASK_LIST_ITEM_CLASS); expect(html).toContain(TASK_LIST_ITEM_CLASS);
expect(html).toContain("grid grid-cols-[auto_1fr] items-center gap-x-2");
expect(html).not.toMatch(/<li class="[^"]*task-list-item[^"]*"><p\b/);
}); });
it("keeps compact styling for pure task lists", () => { it("keeps compact styling for pure task lists", () => {
...@@ -41,5 +43,25 @@ describe("memo content lists", () => { ...@@ -41,5 +43,25 @@ describe("memo content lists", () => {
expect(html).toMatch(/<ul class="[^"]*\blist-none\b[^"]*"/); expect(html).toMatch(/<ul class="[^"]*\blist-none\b[^"]*"/);
expect(html).not.toMatch(/<ul class="[^"]*\blist-disc\b[^"]*"/); expect(html).not.toMatch(/<ul class="[^"]*\blist-disc\b[^"]*"/);
expect(html).not.toMatch(/<li class="[^"]*task-list-item[^"]*"><p\b/);
});
it("keeps nested task lists on their own row", () => {
const html = renderListContent("- [ ] asdas\n - [ ] zzzz");
expect(html).toContain("grid grid-cols-[auto_1fr] items-center gap-x-2");
expect(html).toContain("[&amp;&gt;ul]:col-start-2");
expect(html).not.toContain("[&amp;_ul.contains-task-list]:ml-6");
expect(html).toContain("zzzz");
});
it("keeps loose task list paragraphs while aligning the first line", () => {
const html = renderListContent("- [ ] plan\n\n keep details\n\n- [ ] zzzz");
expect(html).toMatch(/<li class="[^"]*task-list-item[^"]*">\s*<p>/);
expect(html).toContain("[&amp;&gt;p:first-child]:contents");
expect(html).toContain("[&amp;&gt;p:not(:first-child)]:col-start-2");
expect(html).toContain("<p>keep details</p>");
expect(html).toContain("zzzz");
}); });
}); });
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment