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
54 changes: 40 additions & 14 deletions src/components/RichBlocks/blocks/ParagraphBlock.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
import type { FC, PropsWithChildren } from 'react';
import type { FC, PropsWithChildren, ReactNode } from 'react';
import { ParagraphBlockContainer } from '../RichBlocks.style';
import type { CommonBlockProps, ParagraphProps, TrackingKeys } from '../types';
import { RichBlocksVariant } from '../types';
import dynamic from 'next/dynamic';
import { parseMarkdownTable } from '../utils/parseMarkdownTable';
import { renderTableCellFromSegments } from '../renderers/renderTableCellFromSegments';
import {
parseMarkdownTable,
parseMarkdownTableWithRanges,
} from '../utils/parseMarkdownTable';
import {
buildPositionedInlineParts,
serializeParagraphInlinesFromReact,
} from '../utils/serializeParagraphInlinesFromReact';

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

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

if (
paragraphChildren[0].props.text.includes('<JUMPER_CTA') &&
firstText.includes('<JUMPER_CTA') &&
variant === RichBlocksVariant.BlogArticle
) {
return (
<CTARenderer
text={paragraphChildren[0].props.text}
trackingKeys={trackingKeys?.cta}
/>
);
return <CTARenderer text={firstText} trackingKeys={trackingKeys?.cta} />;
}

if (
paragraphChildren[0].props.text.includes('<WIDGET') &&
firstText.includes('<WIDGET') &&
variant === RichBlocksVariant.BlogArticle
) {
return <WidgetRenderer text={paragraphChildren[0].props.text} />;
return <WidgetRenderer text={firstText} />;
}

if (
paragraphChildren[0].props.text.includes('<INSTRUCTIONS') &&
firstText.includes('<INSTRUCTIONS') &&
variant === RichBlocksVariant.BlogArticle
) {
return <InstructionsRenderer text={paragraphChildren[0].props.text} />;
return <InstructionsRenderer text={firstText} />;
}

const tableData = parseMarkdownTable(paragraphChildren[0].props.text);
const inlineNodes = children as ReactNode[];
const tablePlain = serializeParagraphInlinesFromReact(inlineNodes);
const tableData = parseMarkdownTable(tablePlain);
if (tableData) {
const { plain, positioned } = buildPositionedInlineParts(inlineNodes);
const work = plain.trim();
const lead = plain.length - plain.trimStart().length;
const withRanges = parseMarkdownTableWithRanges(work);

if (withRanges && positioned.length > 0) {
const mapCell = (r: { start: number; end: number }) =>
renderTableCellFromSegments(
plain,
positioned,
lead + r.start,
lead + r.end,
);
return (
<TableRenderer
headers={withRanges.headerCellRanges.map(mapCell)}
rows={withRanges.dataRowCellRanges.map((row) => row.map(mapCell))}
/>
);
}
return <TableRenderer headers={tableData.headers} rows={tableData.rows} />;
}

Expand Down
13 changes: 9 additions & 4 deletions src/components/RichBlocks/renderers/TableRenderer.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,24 @@
import type { FC } from 'react';
import type { FC, ReactNode } from 'react';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import type { ParsedTable } from '../utils/parseMarkdownTable';
import {
StyledTableContainer,
StyledHeaderCell,
StyledBodyCell,
} from '../RichBlocks.style';

interface TableRendererProps extends ParsedTable {}
/**
* `ParsedTable` uses `string` cells; rich tables pass `ReactNode` from segment rendering.
*/
export interface TableRendererProps {
headers: Array<string | ReactNode>;
rows: Array<Array<string | ReactNode>>;
}

export const TableRenderer: FC<TableRendererProps> = ({ headers, rows }) => (
<StyledTableContainer>
<StyledTableContainer sx={{ '& a': { marginLeft: 0 } }}>
<Table size="small">
<TableHead>
<TableRow>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { compact, filter, map } from 'lodash';
import type { ReactNode } from 'react';
import { Fragment } from 'react';
import { LinkRenderer } from './LinkRenderer';
import { renderTextWithOptionalBoldMarkdown } from './textWithOptionalBoldMarkdown';
import type { PositionedPart } from '../utils/inlinePartTypes';
import generateKey from 'src/app/lib/generateKey';

/**
* Renders a slice [a, b) of the paragraph plain string using positioned inline parts.
* `a` / `b` are indices into `plain` (untrimmed); caller maps work-space ranges with `lead` offset.
*/
export function renderTableCellFromSegments(
plain: string,
positioned: PositionedPart[],
a: number,
b: number,
): ReactNode {
if (a >= b) {
return null;
}
const overlapping = filter(
positioned,
({ start, end }) => end > a && start < b,
);
const out = compact(
map(overlapping, ({ start, end, part }) => {
const lo = Math.max(a, start);
const hi = Math.min(b, end);
if (lo >= hi) {
return null;
}
if (part.kind === 'text') {
return (
<Fragment key={generateKey(`c-${lo}-${hi}`)}>
{renderTextWithOptionalBoldMarkdown(
plain.slice(lo, hi),
part.textProps,
)}
</Fragment>
);
}
const labelSlice = plain.slice(lo, hi);
return (
<LinkRenderer
key={generateKey(`l-${lo}`)}
content={{
url: part.url,
children: [{ text: labelSlice }],
}}
/>
);
}),
);
if (out.length === 0) {
return plain.slice(a, b) || null;
}
if (out.length === 1) {
return out[0]!;
}
return <Fragment>{out}</Fragment>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { ReactNode } from 'react';
import { Fragment } from 'react';
import { TextRenderer } from './TextRenderer';
import { HtmlRenderer } from './HtmlRenderer';
import type { InlineTextProps } from '../utils/inlinePartTypes';
import generateKey from 'src/app/lib/generateKey';

const BOLD_MD = /\*\*([^*]+)\*\*/g;

/**
* Renders a text slice with `**...**` split into bold runs (pasted markdown in a cell).
* Strapi `bold: true` is applied via `textProps` on the outer TextRenderer when no `**` is used.
*/
export function renderTextWithOptionalBoldMarkdown(
text: string,
textProps: InlineTextProps = {},
): ReactNode {
if (!text) {
return null;
}
if (text.includes('<') && text.includes('>')) {
return (
<HtmlRenderer
text={text}
bold={textProps.bold}
italic={textProps.italic}
underline={textProps.underline}
strikethrough={textProps.strikethrough}
/>
);
}
if (!text.includes('**')) {
return <TextRenderer {...textProps} text={text} />;
}
const parts: ReactNode[] = [];
let last = 0;
BOLD_MD.lastIndex = 0;
let m: RegExpExecArray | null;
let k = 0;

while ((m = BOLD_MD.exec(text)) !== null) {
if (m.index > last) {
parts.push(
<TextRenderer
key={generateKey(`b-${k++}`)}
{...textProps}
text={text.slice(last, m.index)}
/>,
);
}
parts.push(
<TextRenderer
key={generateKey(`B-${k++}`)}
{...textProps}
text={m[1] ?? ''}
bold
/>,
);
last = m.index + m[0]!.length;
}
if (last < text.length) {
parts.push(
<TextRenderer
key={generateKey(`b-${k++}`)}
{...textProps}
text={text.slice(last)}
/>,
);
}
if (parts.length === 0) {
return <TextRenderer {...textProps} text={text} />;
}
return <Fragment>{parts}</Fragment>;
}
120 changes: 120 additions & 0 deletions src/components/RichBlocks/utils/inlineNodeWalker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import {
Children,
isValidElement,
type ReactElement,
type ReactNode,
} from 'react';
import { flatMap } from 'lodash';
import type { InlinePart, InlineTextProps } from './inlinePartTypes';

type Recurse = (n: ReactNode) => InlinePart[];

/**
* Public for tests; re-exported from serialize module.
*/
export function inlinePartsToPlainString(parts: InlinePart[]): string {
return parts.map((x) => (x.kind === 'text' ? x.value : x.label)).join('');
}

function readTextStyleProps(p: Record<string, unknown>): InlineTextProps {
return {
bold: p.bold as boolean | undefined,
italic: p.italic as boolean | undefined,
underline: p.underline as boolean | undefined,
strikethrough: p.strikethrough as boolean | undefined,
};
}

const strapiTextHandler = {
predicate: (el: ReactElement<Record<string, unknown>>) =>
typeof el.props.text === 'string',
toParts: (el: ReactElement<Record<string, unknown>>, _walk: Recurse) => {
const p = el.props;
return [
{
kind: 'text' as const,
value: p.text as string,
textProps: readTextStyleProps(p as Record<string, unknown>),
},
];
},
};

const strapiContentLinkHandler = {
predicate: (el: ReactElement<Record<string, unknown>>) => {
const c = el.props.content;
return (
c != null &&
typeof c === 'object' &&
(c as { type?: string }).type === 'link' &&
typeof (c as { url?: string }).url === 'string'
);
},
toParts: (el: ReactElement<Record<string, unknown>>, _walk: Recurse) => {
const c = el.props.content as {
url: string;
children: Array<{ text?: string }>;
};
const { url, children: linkChildren = [] } = c;
const label = linkChildren.map((x) => x.text ?? '').join('');
return [{ kind: 'link' as const, url, label }];
},
};

const hrefLinkHandler = {
predicate: (el: ReactElement<Record<string, unknown>>) =>
typeof el.props.href === 'string',
toParts: (el: ReactElement<Record<string, unknown>>, walk: Recurse) => {
const ch = el.props.children;
const inner = flatMap(Children.toArray(ch as ReactNode), (n) => walk(n));
return [
{
kind: 'link' as const,
url: el.props.href as string,
label: inlinePartsToPlainString(inner),
},
];
},
};

const childrenRecursionHandler = {
predicate: (el: ReactElement<Record<string, unknown>>) =>
el.props.children != null,
toParts: (el: ReactElement<Record<string, unknown>>, walk: Recurse) =>
flatMap(Children.toArray(el.props.children as ReactNode), (n) => walk(n)),
};

/**
* OCP: add new entry here for new Strapi inline node shapes; do not change core walk.
*/
const INLINE_NODE_HANDLERS: ReadonlyArray<{
predicate: (el: ReactElement<Record<string, unknown>>) => boolean;
toParts: (
el: ReactElement<Record<string, unknown>>,
walk: Recurse,
) => InlinePart[];
}> = [
strapiTextHandler,
strapiContentLinkHandler,
hrefLinkHandler,
childrenRecursionHandler,
];

export function walkInlines(node: ReactNode): InlinePart[] {
if (node == null || node === false) {
return [];
}
if (typeof node === 'string' || typeof node === 'number') {
return [{ kind: 'text', value: String(node), textProps: {} }];
}
if (!isValidElement(node)) {
return [];
}
const el = node as ReactElement<Record<string, unknown>>;
for (const h of INLINE_NODE_HANDLERS) {
if (h.predicate(el)) {
return h.toParts(el, walkInlines);
}
}
return [];
}
20 changes: 20 additions & 0 deletions src/components/RichBlocks/utils/inlinePartTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { ParagraphProps } from '../types';

export type InlineTextProps = Pick<
ParagraphProps,
'bold' | 'italic' | 'underline' | 'strikethrough'
>;

/**
* A single run of plain text with Strapi-like modifiers, or a link.
* @see serializeParagraphInlinesFromReact
*/
export type InlinePart =
| { kind: 'text'; value: string; textProps: InlineTextProps }
| { kind: 'link'; url: string; label: string };

export type PositionedPart = {
start: number;
end: number;
part: InlinePart;
};
Loading
Loading