Skip to content

Commit 733facc

Browse files
authored
fix: table rendering (#2829)
1 parent ac0d149 commit 733facc

10 files changed

Lines changed: 648 additions & 44 deletions

src/components/RichBlocks/blocks/ParagraphBlock.tsx

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
1-
import type { FC, PropsWithChildren } from 'react';
1+
import type { FC, PropsWithChildren, ReactNode } from 'react';
22
import { ParagraphBlockContainer } from '../RichBlocks.style';
33
import type { CommonBlockProps, ParagraphProps, TrackingKeys } from '../types';
44
import { RichBlocksVariant } from '../types';
55
import dynamic from 'next/dynamic';
6-
import { parseMarkdownTable } from '../utils/parseMarkdownTable';
6+
import { renderTableCellFromSegments } from '../renderers/renderTableCellFromSegments';
7+
import {
8+
parseMarkdownTable,
9+
parseMarkdownTableWithRanges,
10+
} from '../utils/parseMarkdownTable';
11+
import {
12+
buildPositionedInlineParts,
13+
serializeParagraphInlinesFromReact,
14+
} from '../utils/serializeParagraphInlinesFromReact';
715

816
const ParagraphRenderer = dynamic(() =>
917
import('../renderers/ParagraphRenderer').then((mod) => mod.ParagraphRenderer),
@@ -46,35 +54,53 @@ export const ParagraphBlock: FC<ParagraphBlockProps> = ({
4654
}
4755

4856
const paragraphChildren = children as Array<{ props: ParagraphProps }>;
57+
const firstText = String(paragraphChildren[0]?.props?.text ?? '');
4958

5059
if (
51-
paragraphChildren[0].props.text.includes('<JUMPER_CTA') &&
60+
firstText.includes('<JUMPER_CTA') &&
5261
variant === RichBlocksVariant.BlogArticle
5362
) {
54-
return (
55-
<CTARenderer
56-
text={paragraphChildren[0].props.text}
57-
trackingKeys={trackingKeys?.cta}
58-
/>
59-
);
63+
return <CTARenderer text={firstText} trackingKeys={trackingKeys?.cta} />;
6064
}
6165

6266
if (
63-
paragraphChildren[0].props.text.includes('<WIDGET') &&
67+
firstText.includes('<WIDGET') &&
6468
variant === RichBlocksVariant.BlogArticle
6569
) {
66-
return <WidgetRenderer text={paragraphChildren[0].props.text} />;
70+
return <WidgetRenderer text={firstText} />;
6771
}
6872

6973
if (
70-
paragraphChildren[0].props.text.includes('<INSTRUCTIONS') &&
74+
firstText.includes('<INSTRUCTIONS') &&
7175
variant === RichBlocksVariant.BlogArticle
7276
) {
73-
return <InstructionsRenderer text={paragraphChildren[0].props.text} />;
77+
return <InstructionsRenderer text={firstText} />;
7478
}
7579

76-
const tableData = parseMarkdownTable(paragraphChildren[0].props.text);
80+
const inlineNodes = children as ReactNode[];
81+
const tablePlain = serializeParagraphInlinesFromReact(inlineNodes);
82+
const tableData = parseMarkdownTable(tablePlain);
7783
if (tableData) {
84+
const { plain, positioned } = buildPositionedInlineParts(inlineNodes);
85+
const work = plain.trim();
86+
const lead = plain.length - plain.trimStart().length;
87+
const withRanges = parseMarkdownTableWithRanges(work);
88+
89+
if (withRanges && positioned.length > 0) {
90+
const mapCell = (r: { start: number; end: number }) =>
91+
renderTableCellFromSegments(
92+
plain,
93+
positioned,
94+
lead + r.start,
95+
lead + r.end,
96+
);
97+
return (
98+
<TableRenderer
99+
headers={withRanges.headerCellRanges.map(mapCell)}
100+
rows={withRanges.dataRowCellRanges.map((row) => row.map(mapCell))}
101+
/>
102+
);
103+
}
78104
return <TableRenderer headers={tableData.headers} rows={tableData.rows} />;
79105
}
80106

src/components/RichBlocks/renderers/TableRenderer.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,24 @@
1-
import type { FC } from 'react';
1+
import type { FC, ReactNode } from 'react';
22
import Table from '@mui/material/Table';
33
import TableBody from '@mui/material/TableBody';
44
import TableHead from '@mui/material/TableHead';
55
import TableRow from '@mui/material/TableRow';
6-
import type { ParsedTable } from '../utils/parseMarkdownTable';
76
import {
87
StyledTableContainer,
98
StyledHeaderCell,
109
StyledBodyCell,
1110
} from '../RichBlocks.style';
1211

13-
interface TableRendererProps extends ParsedTable {}
12+
/**
13+
* `ParsedTable` uses `string` cells; rich tables pass `ReactNode` from segment rendering.
14+
*/
15+
export interface TableRendererProps {
16+
headers: Array<string | ReactNode>;
17+
rows: Array<Array<string | ReactNode>>;
18+
}
1419

1520
export const TableRenderer: FC<TableRendererProps> = ({ headers, rows }) => (
16-
<StyledTableContainer>
21+
<StyledTableContainer sx={{ '& a': { marginLeft: 0 } }}>
1722
<Table size="small">
1823
<TableHead>
1924
<TableRow>
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { compact, filter, map } from 'lodash';
2+
import type { ReactNode } from 'react';
3+
import { Fragment } from 'react';
4+
import { LinkRenderer } from './LinkRenderer';
5+
import { renderTextWithOptionalBoldMarkdown } from './textWithOptionalBoldMarkdown';
6+
import type { PositionedPart } from '../utils/inlinePartTypes';
7+
import generateKey from 'src/app/lib/generateKey';
8+
9+
/**
10+
* Renders a slice [a, b) of the paragraph plain string using positioned inline parts.
11+
* `a` / `b` are indices into `plain` (untrimmed); caller maps work-space ranges with `lead` offset.
12+
*/
13+
export function renderTableCellFromSegments(
14+
plain: string,
15+
positioned: PositionedPart[],
16+
a: number,
17+
b: number,
18+
): ReactNode {
19+
if (a >= b) {
20+
return null;
21+
}
22+
const overlapping = filter(
23+
positioned,
24+
({ start, end }) => end > a && start < b,
25+
);
26+
const out = compact(
27+
map(overlapping, ({ start, end, part }) => {
28+
const lo = Math.max(a, start);
29+
const hi = Math.min(b, end);
30+
if (lo >= hi) {
31+
return null;
32+
}
33+
if (part.kind === 'text') {
34+
return (
35+
<Fragment key={generateKey(`c-${lo}-${hi}`)}>
36+
{renderTextWithOptionalBoldMarkdown(
37+
plain.slice(lo, hi),
38+
part.textProps,
39+
)}
40+
</Fragment>
41+
);
42+
}
43+
const labelSlice = plain.slice(lo, hi);
44+
return (
45+
<LinkRenderer
46+
key={generateKey(`l-${lo}`)}
47+
content={{
48+
url: part.url,
49+
children: [{ text: labelSlice }],
50+
}}
51+
/>
52+
);
53+
}),
54+
);
55+
if (out.length === 0) {
56+
return plain.slice(a, b) || null;
57+
}
58+
if (out.length === 1) {
59+
return out[0]!;
60+
}
61+
return <Fragment>{out}</Fragment>;
62+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import type { ReactNode } from 'react';
2+
import { Fragment } from 'react';
3+
import { TextRenderer } from './TextRenderer';
4+
import { HtmlRenderer } from './HtmlRenderer';
5+
import type { InlineTextProps } from '../utils/inlinePartTypes';
6+
import generateKey from 'src/app/lib/generateKey';
7+
8+
const BOLD_MD = /\*\*([^*]+)\*\*/g;
9+
10+
/**
11+
* Renders a text slice with `**...**` split into bold runs (pasted markdown in a cell).
12+
* Strapi `bold: true` is applied via `textProps` on the outer TextRenderer when no `**` is used.
13+
*/
14+
export function renderTextWithOptionalBoldMarkdown(
15+
text: string,
16+
textProps: InlineTextProps = {},
17+
): ReactNode {
18+
if (!text) {
19+
return null;
20+
}
21+
if (text.includes('<') && text.includes('>')) {
22+
return (
23+
<HtmlRenderer
24+
text={text}
25+
bold={textProps.bold}
26+
italic={textProps.italic}
27+
underline={textProps.underline}
28+
strikethrough={textProps.strikethrough}
29+
/>
30+
);
31+
}
32+
if (!text.includes('**')) {
33+
return <TextRenderer {...textProps} text={text} />;
34+
}
35+
const parts: ReactNode[] = [];
36+
let last = 0;
37+
BOLD_MD.lastIndex = 0;
38+
let m: RegExpExecArray | null;
39+
let k = 0;
40+
41+
while ((m = BOLD_MD.exec(text)) !== null) {
42+
if (m.index > last) {
43+
parts.push(
44+
<TextRenderer
45+
key={generateKey(`b-${k++}`)}
46+
{...textProps}
47+
text={text.slice(last, m.index)}
48+
/>,
49+
);
50+
}
51+
parts.push(
52+
<TextRenderer
53+
key={generateKey(`B-${k++}`)}
54+
{...textProps}
55+
text={m[1] ?? ''}
56+
bold
57+
/>,
58+
);
59+
last = m.index + m[0]!.length;
60+
}
61+
if (last < text.length) {
62+
parts.push(
63+
<TextRenderer
64+
key={generateKey(`b-${k++}`)}
65+
{...textProps}
66+
text={text.slice(last)}
67+
/>,
68+
);
69+
}
70+
if (parts.length === 0) {
71+
return <TextRenderer {...textProps} text={text} />;
72+
}
73+
return <Fragment>{parts}</Fragment>;
74+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import {
2+
Children,
3+
isValidElement,
4+
type ReactElement,
5+
type ReactNode,
6+
} from 'react';
7+
import { flatMap } from 'lodash';
8+
import type { InlinePart, InlineTextProps } from './inlinePartTypes';
9+
10+
type Recurse = (n: ReactNode) => InlinePart[];
11+
12+
/**
13+
* Public for tests; re-exported from serialize module.
14+
*/
15+
export function inlinePartsToPlainString(parts: InlinePart[]): string {
16+
return parts.map((x) => (x.kind === 'text' ? x.value : x.label)).join('');
17+
}
18+
19+
function readTextStyleProps(p: Record<string, unknown>): InlineTextProps {
20+
return {
21+
bold: p.bold as boolean | undefined,
22+
italic: p.italic as boolean | undefined,
23+
underline: p.underline as boolean | undefined,
24+
strikethrough: p.strikethrough as boolean | undefined,
25+
};
26+
}
27+
28+
const strapiTextHandler = {
29+
predicate: (el: ReactElement<Record<string, unknown>>) =>
30+
typeof el.props.text === 'string',
31+
toParts: (el: ReactElement<Record<string, unknown>>, _walk: Recurse) => {
32+
const p = el.props;
33+
return [
34+
{
35+
kind: 'text' as const,
36+
value: p.text as string,
37+
textProps: readTextStyleProps(p as Record<string, unknown>),
38+
},
39+
];
40+
},
41+
};
42+
43+
const strapiContentLinkHandler = {
44+
predicate: (el: ReactElement<Record<string, unknown>>) => {
45+
const c = el.props.content;
46+
return (
47+
c != null &&
48+
typeof c === 'object' &&
49+
(c as { type?: string }).type === 'link' &&
50+
typeof (c as { url?: string }).url === 'string'
51+
);
52+
},
53+
toParts: (el: ReactElement<Record<string, unknown>>, _walk: Recurse) => {
54+
const c = el.props.content as {
55+
url: string;
56+
children: Array<{ text?: string }>;
57+
};
58+
const { url, children: linkChildren = [] } = c;
59+
const label = linkChildren.map((x) => x.text ?? '').join('');
60+
return [{ kind: 'link' as const, url, label }];
61+
},
62+
};
63+
64+
const hrefLinkHandler = {
65+
predicate: (el: ReactElement<Record<string, unknown>>) =>
66+
typeof el.props.href === 'string',
67+
toParts: (el: ReactElement<Record<string, unknown>>, walk: Recurse) => {
68+
const ch = el.props.children;
69+
const inner = flatMap(Children.toArray(ch as ReactNode), (n) => walk(n));
70+
return [
71+
{
72+
kind: 'link' as const,
73+
url: el.props.href as string,
74+
label: inlinePartsToPlainString(inner),
75+
},
76+
];
77+
},
78+
};
79+
80+
const childrenRecursionHandler = {
81+
predicate: (el: ReactElement<Record<string, unknown>>) =>
82+
el.props.children != null,
83+
toParts: (el: ReactElement<Record<string, unknown>>, walk: Recurse) =>
84+
flatMap(Children.toArray(el.props.children as ReactNode), (n) => walk(n)),
85+
};
86+
87+
/**
88+
* OCP: add new entry here for new Strapi inline node shapes; do not change core walk.
89+
*/
90+
const INLINE_NODE_HANDLERS: ReadonlyArray<{
91+
predicate: (el: ReactElement<Record<string, unknown>>) => boolean;
92+
toParts: (
93+
el: ReactElement<Record<string, unknown>>,
94+
walk: Recurse,
95+
) => InlinePart[];
96+
}> = [
97+
strapiTextHandler,
98+
strapiContentLinkHandler,
99+
hrefLinkHandler,
100+
childrenRecursionHandler,
101+
];
102+
103+
export function walkInlines(node: ReactNode): InlinePart[] {
104+
if (node == null || node === false) {
105+
return [];
106+
}
107+
if (typeof node === 'string' || typeof node === 'number') {
108+
return [{ kind: 'text', value: String(node), textProps: {} }];
109+
}
110+
if (!isValidElement(node)) {
111+
return [];
112+
}
113+
const el = node as ReactElement<Record<string, unknown>>;
114+
for (const h of INLINE_NODE_HANDLERS) {
115+
if (h.predicate(el)) {
116+
return h.toParts(el, walkInlines);
117+
}
118+
}
119+
return [];
120+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { ParagraphProps } from '../types';
2+
3+
export type InlineTextProps = Pick<
4+
ParagraphProps,
5+
'bold' | 'italic' | 'underline' | 'strikethrough'
6+
>;
7+
8+
/**
9+
* A single run of plain text with Strapi-like modifiers, or a link.
10+
* @see serializeParagraphInlinesFromReact
11+
*/
12+
export type InlinePart =
13+
| { kind: 'text'; value: string; textProps: InlineTextProps }
14+
| { kind: 'link'; url: string; label: string };
15+
16+
export type PositionedPart = {
17+
start: number;
18+
end: number;
19+
part: InlinePart;
20+
};

0 commit comments

Comments
 (0)