Skip to content

Commit 064d0e1

Browse files
authored
Merge pull request #7 from oleast/feat/convert-options
2 parents 8dc33a0 + 67aad0e commit 064d0e1

File tree

6 files changed

+136
-83
lines changed

6 files changed

+136
-83
lines changed

src/converters.ts

Lines changed: 19 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,12 @@
1-
import {
2-
appendMarksToChildren,
3-
isBlockType,
4-
isInlineType,
5-
isMarkType,
6-
} from "./utils";
7-
import type { HTMLTagName, HTMLTextNode, TagConverter } from "./types";
1+
import { isBlockType, isInlineType, isMarkType } from "./utils";
2+
import type { HTMLTagName, TagConverter, TextConverter } from "./types";
83
import {
94
Block,
105
BLOCKS,
116
Inline,
127
INLINES,
138
Mark,
149
MARKS,
15-
Text,
1610
} from "@contentful/rich-text-types";
1711

1812
const DEFAULT_NODE_TYPE_FOR_HTML_TAG: Partial<
@@ -53,41 +47,38 @@ const getDefaultNodeTypeForHtmlTag = (
5347
return DEFAULT_NODE_TYPE_FOR_HTML_TAG[tagName];
5448
};
5549

56-
export const convertTagToBlock: TagConverter = (node, next) => {
50+
export const convertTagToBlock: TagConverter<Block> = (node, next) => {
5751
const nodeType = getDefaultNodeTypeForHtmlTag(node.tagName);
5852
if (!nodeType || !isBlockType(nodeType)) {
5953
return [];
6054
}
61-
const block: Block = {
55+
return {
6256
nodeType,
63-
content: next(node) as Array<Block | Inline | Text>,
57+
content: next(node),
6458
data: {},
6559
};
66-
return block;
6760
};
6861

69-
export const convertTagToInline: TagConverter = (node, next) => {
62+
export const convertTagToInline: TagConverter<Inline> = (node, next) => {
7063
const nodeType = getDefaultNodeTypeForHtmlTag(node.tagName);
7164
if (!nodeType || !isInlineType(nodeType)) {
7265
return [];
7366
}
74-
const inline: Inline = {
67+
return {
7568
nodeType,
76-
content: next(node) as Array<Inline | Text>,
69+
content: next(node),
7770
data: {},
7871
};
79-
return inline;
8072
};
8173

82-
export const convertTagToHyperlink: TagConverter = (node, next) => {
83-
const inline: Inline = {
74+
export const convertTagToHyperlink: TagConverter<Inline> = (node, next) => {
75+
return {
8476
nodeType: INLINES.HYPERLINK,
85-
content: next(node) as Array<Inline | Text>,
77+
content: next(node),
8678
data: {
8779
uri: node.attrs.href,
8880
},
8981
};
90-
return inline;
9182
};
9283

9384
export const convertTagToMark: TagConverter = (node, next) => {
@@ -98,15 +89,18 @@ export const convertTagToMark: TagConverter = (node, next) => {
9889
const mark: Mark = {
9990
type: nodeType,
10091
};
101-
return appendMarksToChildren(mark, node, next);
92+
return next(node, mark);
93+
};
94+
95+
export const convertTagToChildren: TagConverter<Block> = (node, next) => {
96+
return next(node);
10297
};
10398

104-
export const convertTextNodeToText = (node: HTMLTextNode): Text => {
105-
const textNode: Text = {
99+
export const convertTextNodeToText: TextConverter = (node, marks) => {
100+
return {
106101
nodeType: "text",
107-
marks: [],
102+
marks,
108103
value: node.value,
109104
data: {},
110105
};
111-
return textNode;
112106
};

src/htmlStringToDocument.ts

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,30 @@ import {
33
convertTagToBlock,
44
convertTagToHyperlink,
55
convertTextNodeToText,
6+
convertTagToChildren,
67
} from "./converters";
78
import { parseHtml } from "./parseHtml";
8-
import { TopLevelBlock, Document } from "@contentful/rich-text-types";
9+
import {
10+
TopLevelBlock,
11+
Document,
12+
Mark,
13+
Text,
14+
Inline,
15+
Block,
16+
} from "@contentful/rich-text-types";
917
import type {
1018
HTMLNode,
1119
HTMLTagName,
1220
Next,
1321
Options,
22+
OptionsWithDefaults,
1423
TagConverter,
1524
} from "./types";
16-
import { createDocumentNode } from "./utils";
25+
import { createDocumentNode, getAsList } from "./utils";
1726

18-
const DEFAULT_TAG_CONVERTERS: Partial<Record<HTMLTagName, TagConverter>> = {
27+
const DEFAULT_TAG_CONVERTERS: Partial<
28+
Record<HTMLTagName, TagConverter<Block | Inline | Text>>
29+
> = {
1930
h1: convertTagToBlock,
2031
h2: convertTagToBlock,
2132
h3: convertTagToBlock,
@@ -42,25 +53,32 @@ const DEFAULT_TAG_CONVERTERS: Partial<Record<HTMLTagName, TagConverter>> = {
4253
a: convertTagToHyperlink,
4354
};
4455

45-
const mapHtmlNodeToRichTextNode = (node: HTMLNode, options: Options) => {
56+
const mapHtmlNodeToRichTextNode = (
57+
node: HTMLNode,
58+
marks: Mark[],
59+
options: OptionsWithDefaults
60+
) => {
61+
const { convertText, convertTag } = options;
4662
if (node.type === "text") {
47-
const textConverter = options.convertText ?? convertTextNodeToText;
48-
return textConverter(node);
63+
return convertText(node, marks);
4964
}
5065

51-
const next: Next = (node) => {
66+
const mapChildren: Next = (node, mark) => {
67+
const newMarks = mark ? getAsList(mark) : [];
68+
const allMarks = newMarks.concat(marks);
5269
if (node.type === "element") {
5370
return node.children.flatMap((child) =>
54-
mapHtmlNodeToRichTextNode(child, options)
71+
mapHtmlNodeToRichTextNode(child, allMarks, options)
5572
);
5673
}
57-
return mapHtmlNodeToRichTextNode(node, options);
74+
return getAsList(mapHtmlNodeToRichTextNode(node, allMarks, options));
75+
};
76+
77+
const next: Next = (node, marks) => {
78+
return mapChildren(node, marks);
5879
};
5980

60-
const tagConverter =
61-
options?.convertTag?.[node.tagName] ??
62-
DEFAULT_TAG_CONVERTERS[node.tagName] ??
63-
next;
81+
const tagConverter = convertTag?.[node.tagName] ?? convertTagToChildren;
6482
const convertedNode = tagConverter(node, next);
6583
return convertedNode;
6684
};
@@ -69,9 +87,16 @@ export const htmlStringToDocument = (
6987
htmlString: string,
7088
options: Options = {}
7189
): Document => {
90+
const optionsWithDefaults: OptionsWithDefaults = {
91+
convertTag: {
92+
...DEFAULT_TAG_CONVERTERS,
93+
...options.convertTag,
94+
},
95+
convertText: options.convertText ?? convertTextNodeToText,
96+
};
7297
const parsedHtml = parseHtml(htmlString);
7398
const richTextNodes = parsedHtml.flatMap((node) =>
74-
mapHtmlNodeToRichTextNode(node, options)
99+
mapHtmlNodeToRichTextNode(node, [], optionsWithDefaults)
75100
);
76101
return createDocumentNode(richTextNodes as TopLevelBlock[]);
77102
};

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,6 @@ export type {
77
Options,
88
ConvertTagOptions,
99
TagConverter,
10+
TextConverter,
11+
Next,
1012
} from "./types";

src/test/index.test.ts

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,14 @@ import {
44
Block,
55
BLOCKS,
66
Document,
7-
Inline,
8-
Text,
7+
Mark,
98
TopLevelBlock,
109
} from "@contentful/rich-text-types";
1110
import { htmlStringToDocument } from "../htmlStringToDocument";
1211

1312
import { describe, expect, it } from "vitest";
1413
import { EXAMPLE_RICH_TEXT } from "./example";
15-
import { createDocumentNode, getAsList } from "../utils";
14+
import { createDocumentNode } from "../utils";
1615
import * as helpers from "./helpers";
1716

1817
// https://www.contentful.com/developers/docs/tutorials/general/getting-started-with-rich-text-field-type/
@@ -50,31 +49,19 @@ const richTextDocument: Document = {
5049
const htmlString = documentToHtmlString(EXAMPLE_RICH_TEXT);
5150

5251
describe("Parse HTML string to Contentful Document", () => {
53-
/*it("Parse string to generic HTML nodes", () => {
54-
const htmlNodes = parseHtml(CISION_EXAMPLE);
55-
expect(htmlNodes).toEqual([]);
56-
});*/
57-
58-
/*it("Parse HTML string to Contentful Rich Text", () => {
59-
const htmlNodes = htmlStringToDocument(CISION_EXAMPLE);
60-
const newHtmlString = documentToHtmlString(htmlNodes);
61-
expect(newHtmlString).toEqual(CISION_EXAMPLE);
62-
});*/
63-
6452
it("Parse HTML string to Contentful Rich Text", () => {
6553
const htmlNodes = htmlStringToDocument(htmlString);
6654
const newHtmlString = documentToHtmlString(htmlNodes);
6755
expect(newHtmlString).toEqual(htmlString);
6856
});
6957

7058
it("Handles a simple convert option from 'div' to 'paragraph'", () => {
71-
const divToParagraphConverter: TagConverter = (node, next) => {
72-
const paragraph: Block = {
59+
const divToParagraphConverter: TagConverter<Block> = (node, next) => {
60+
return {
7361
nodeType: BLOCKS.PARAGRAPH,
74-
content: getAsList(next(node)) as Array<Block | Inline | Text>,
62+
content: next(node),
7563
data: {},
7664
};
77-
return paragraph;
7865
};
7966
const matchText = "This is text in a div";
8067
const htmlNodes = htmlStringToDocument(`<div>${matchText}</div>`, {
@@ -92,4 +79,37 @@ describe("Parse HTML string to Contentful Document", () => {
9279
createDocumentNode([matchNode as TopLevelBlock])
9380
);
9481
});
82+
83+
it("Handles a complex convert option from 'span' with bold class to 'paragraph' and 'bold' mark", () => {
84+
const styledSpanToMarkedParagraphConverter: TagConverter<Block> = (
85+
node,
86+
next
87+
) => {
88+
const isBold = node.attrs.class === "bold";
89+
const marks = isBold ? ({ type: "bold" } satisfies Mark) : undefined;
90+
return {
91+
nodeType: BLOCKS.PARAGRAPH,
92+
content: next(node, marks),
93+
data: {},
94+
};
95+
};
96+
const matchText = "text";
97+
const htmlNodes = htmlStringToDocument(
98+
`<span class="bold">${matchText}</span>`,
99+
{
100+
convertTag: {
101+
span: styledSpanToMarkedParagraphConverter,
102+
},
103+
}
104+
);
105+
106+
const matchNode = createDocumentNode([
107+
helpers.createBlock(
108+
BLOCKS.PARAGRAPH,
109+
helpers.createText(matchText, { type: "bold" })
110+
),
111+
] as TopLevelBlock[]);
112+
113+
expect(htmlNodes).toMatchObject(matchNode);
114+
});
95115
});

src/types.ts

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import type { Mark, Node, Text } from "@contentful/rich-text-types";
1+
import type {
2+
Block,
3+
Inline,
4+
Mark,
5+
Node,
6+
Text,
7+
} from "@contentful/rich-text-types";
28

39
export type HTMLTagName = keyof HTMLElementTagNameMap;
410

@@ -16,19 +22,42 @@ export interface HTMLElementNode {
1622

1723
export type HTMLNode = HTMLElementNode | HTMLTextNode;
1824

25+
export type ContentfulNodeContent<TNodeType extends AnyContentfulNode> =
26+
TNodeType extends Block
27+
? Block["content"][0]
28+
: TNodeType extends Inline
29+
? Inline["content"][0]
30+
: TNodeType extends Mark
31+
? Block["content"][0]
32+
: TNodeType extends Text
33+
? Text
34+
: never;
35+
1936
export type AnyContentfulNode = Node | Mark | Text;
20-
export type ConverterResult = AnyContentfulNode | Array<AnyContentfulNode>;
37+
export type ConverterResult<TNodeType extends AnyContentfulNode> =
38+
| ContentfulNodeContent<TNodeType>
39+
| Array<ContentfulNodeContent<TNodeType>>;
40+
41+
export type Next<TNodeType extends AnyContentfulNode = Block | Inline | Text> =
42+
(
43+
node: HTMLNode,
44+
marks?: Mark | Mark[]
45+
) => Array<ContentfulNodeContent<TNodeType>>;
2146

22-
export type Next = (node: HTMLNode) => ConverterResult;
47+
export type TextConverter = (node: HTMLTextNode, marks: Mark[]) => Text;
2348

24-
export type TagConverter = (
49+
export type TagConverter<
50+
TNodeType extends AnyContentfulNode = Block | Inline | Text
51+
> = (
2552
node: HTMLElementNode,
26-
next: Next
27-
) => ConverterResult;
53+
next: Next<TNodeType>
54+
) => ConverterResult<TNodeType>;
2855

2956
export type ConvertTagOptions = Record<HTMLTagName | string, TagConverter>;
3057

31-
export interface Options {
32-
convertTag?: ConvertTagOptions;
33-
convertText?: (node: HTMLTextNode) => Text;
58+
export interface OptionsWithDefaults {
59+
convertTag: ConvertTagOptions;
60+
convertText: TextConverter;
3461
}
62+
63+
export type Options = Partial<OptionsWithDefaults>;

src/utils.ts

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { Next, ConverterResult, HTMLNode } from "./types";
21
import {
32
BLOCKS,
43
Document,
@@ -46,22 +45,6 @@ export const isNodeTypeText = (node: Node | Text | Mark): node is Text => {
4645
return false;
4746
};
4847

49-
export const appendMarksToChildren = (
50-
mark: Mark | Mark[],
51-
node: HTMLNode,
52-
next: Next
53-
): ConverterResult => {
54-
const children = next(node);
55-
const childrenAsList = getAsList(children);
56-
const marksAsList = getAsList(mark);
57-
return childrenAsList.map((child) => {
58-
if (isNodeTypeText(child)) {
59-
child.marks = [...child.marks, ...marksAsList];
60-
}
61-
return child;
62-
});
63-
};
64-
6548
export const createDocumentNode = (
6649
content: TopLevelBlock[],
6750
data: NodeData = {}

0 commit comments

Comments
 (0)