Skip to content

Commit b43557d

Browse files
authoredOct 5, 2023
feat(helpers): support attribute shorthand for map serializer (#321)
Co-authored-by: lihbr <lihbr@users.noreply.github.com>
1 parent 1e22703 commit b43557d

File tree

5 files changed

+312
-129
lines changed

5 files changed

+312
-129
lines changed
 

‎src/helpers/asHTML.ts

+152-62
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import {
2-
Element,
32
RichTextFunctionSerializer,
43
RichTextMapSerializer,
54
RichTextMapSerializerFunction,
@@ -43,11 +42,26 @@ export type HTMLRichTextFunctionSerializer = (
4342
*
4443
* Unlike a typical `@prismicio/richtext` map serializer, this serializer
4544
* converts the `children` property to a single string rather than an array of
46-
* strings.
45+
* strings and accepts shorthand declarations.
4746
*
4847
* @see Templating rich text and title fields from Prismic {@link https://prismic.io/docs/template-content-vanilla-javascript#rich-text-and-title}
4948
*/
5049
export type HTMLRichTextMapSerializer = {
50+
[P in keyof RichTextMapSerializer<string>]: P extends RichTextMapSerializer<string>["span"]
51+
? HTMLStrictRichTextMapSerializer[P]
52+
: HTMLStrictRichTextMapSerializer[P] | HTMLRichTextMapSerializerShorthand;
53+
};
54+
55+
/**
56+
* Serializes a node from a rich text or title field with a map to HTML
57+
*
58+
* Unlike a typical `@prismicio/richtext` map serializer, this serializer
59+
* converts the `children` property to a single string rather than an array of
60+
* strings but doesn't accept shorthand declarations.
61+
*
62+
* @see Templating rich text and title fields from Prismic {@link https://prismic.io/docs/template-content-vanilla-javascript#rich-text-and-title}
63+
*/
64+
export type HTMLStrictRichTextMapSerializer = {
5165
[P in keyof RichTextMapSerializer<string>]: (payload: {
5266
type: Parameters<HTMLRichTextMapSerializerFunction<P>>[0]["type"];
5367
node: Parameters<HTMLRichTextMapSerializerFunction<P>>[0]["node"];
@@ -105,6 +119,21 @@ type ExtractTextTypeGeneric<T> = T extends RichTextMapSerializerFunction<
105119
? U
106120
: never;
107121

122+
/**
123+
* A shorthand definition for {@link HTMLRichTextMapSerializer} element types.
124+
*/
125+
export type HTMLRichTextMapSerializerShorthand = {
126+
/**
127+
* Classes to apply to the element type.
128+
*/
129+
class?: string;
130+
131+
/**
132+
* Other attributes to apply to the element type.
133+
*/
134+
[Attribute: string]: string | boolean | null | undefined;
135+
};
136+
108137
/**
109138
* Serializes a node from a rich text or title field with a map or a function to
110139
* HTML
@@ -117,57 +146,113 @@ export type HTMLRichTextSerializer =
117146
| HTMLRichTextFunctionSerializer;
118147

119148
/**
120-
* Creates a default HTML rich text serializer with a given link resolver
121-
* providing sensible and safe defaults for every node type
149+
* Creates a HTML rich text serializer with a given link resolver and provide
150+
* sensible and safe defaults for every node type
122151
*
123152
* @internal
124153
*/
125-
const createDefaultHTMLRichTextSerializer = (
154+
const createHTMLRichTextSerializer = (
126155
linkResolver: LinkResolverFunction | undefined | null,
156+
serializer?: HTMLRichTextMapSerializer | null,
127157
): RichTextFunctionSerializer<string> => {
128-
return (_type, node, text, children, _key) => {
129-
switch (node.type) {
130-
case Element.heading1:
131-
return serializeStandardTag("h1", node, children);
132-
case Element.heading2:
133-
return serializeStandardTag("h2", node, children);
134-
case Element.heading3:
135-
return serializeStandardTag("h3", node, children);
136-
case Element.heading4:
137-
return serializeStandardTag("h4", node, children);
138-
case Element.heading5:
139-
return serializeStandardTag("h5", node, children);
140-
case Element.heading6:
141-
return serializeStandardTag("h6", node, children);
142-
case Element.paragraph:
143-
return serializeStandardTag("p", node, children);
144-
case Element.preformatted:
145-
return serializePreFormatted(node);
146-
case Element.strong:
147-
return serializeStandardTag("strong", node, children);
148-
case Element.em:
149-
return serializeStandardTag("em", node, children);
150-
case Element.listItem:
151-
return serializeStandardTag("li", node, children);
152-
case Element.oListItem:
153-
return serializeStandardTag("li", node, children);
154-
case Element.list:
155-
return serializeStandardTag("ul", node, children);
156-
case Element.oList:
157-
return serializeStandardTag("ol", node, children);
158-
case Element.image:
159-
return serializeImage(linkResolver, node);
160-
case Element.embed:
161-
return serializeEmbed(node);
162-
case Element.hyperlink:
163-
return serializeHyperlink(linkResolver, node, children);
164-
case Element.label:
165-
return serializeStandardTag("span", node, children);
166-
case Element.span:
167-
default:
168-
return serializeSpan(text);
158+
const useSerializerOrDefault = <
159+
BlockType extends keyof RichTextMapSerializer<string>,
160+
>(
161+
nodeSerializerOrShorthand: HTMLRichTextMapSerializer[BlockType],
162+
defaultWithShorthand: NonNullable<
163+
HTMLStrictRichTextMapSerializer[BlockType]
164+
>,
165+
): NonNullable<HTMLStrictRichTextMapSerializer[BlockType]> => {
166+
if (typeof nodeSerializerOrShorthand === "function") {
167+
return ((payload) => {
168+
return (
169+
(
170+
nodeSerializerOrShorthand as HTMLStrictRichTextMapSerializer[BlockType]
171+
)(payload) || defaultWithShorthand(payload)
172+
);
173+
}) as NonNullable<HTMLStrictRichTextMapSerializer[BlockType]>;
169174
}
175+
176+
return defaultWithShorthand;
170177
};
178+
179+
const mapSerializer: Required<HTMLStrictRichTextMapSerializer> = {
180+
heading1: useSerializerOrDefault<"heading1">(
181+
serializer?.heading1,
182+
serializeStandardTag<"heading1">("h1", serializer?.heading1),
183+
),
184+
heading2: useSerializerOrDefault<"heading2">(
185+
serializer?.heading2,
186+
serializeStandardTag<"heading2">("h2", serializer?.heading2),
187+
),
188+
heading3: useSerializerOrDefault<"heading3">(
189+
serializer?.heading3,
190+
serializeStandardTag<"heading3">("h3", serializer?.heading3),
191+
),
192+
heading4: useSerializerOrDefault<"heading4">(
193+
serializer?.heading4,
194+
serializeStandardTag<"heading4">("h4", serializer?.heading4),
195+
),
196+
heading5: useSerializerOrDefault<"heading5">(
197+
serializer?.heading5,
198+
serializeStandardTag<"heading5">("h5", serializer?.heading5),
199+
),
200+
heading6: useSerializerOrDefault<"heading6">(
201+
serializer?.heading6,
202+
serializeStandardTag<"heading6">("h6", serializer?.heading6),
203+
),
204+
paragraph: useSerializerOrDefault<"paragraph">(
205+
serializer?.paragraph,
206+
serializeStandardTag<"paragraph">("p", serializer?.paragraph),
207+
),
208+
preformatted: useSerializerOrDefault<"preformatted">(
209+
serializer?.preformatted,
210+
serializePreFormatted(serializer?.preformatted),
211+
),
212+
strong: useSerializerOrDefault<"strong">(
213+
serializer?.strong,
214+
serializeStandardTag<"strong">("strong", serializer?.strong),
215+
),
216+
em: useSerializerOrDefault<"em">(
217+
serializer?.em,
218+
serializeStandardTag<"em">("em", serializer?.em),
219+
),
220+
listItem: useSerializerOrDefault<"listItem">(
221+
serializer?.listItem,
222+
serializeStandardTag<"listItem">("li", serializer?.listItem),
223+
),
224+
oListItem: useSerializerOrDefault<"oListItem">(
225+
serializer?.oListItem,
226+
serializeStandardTag<"oListItem">("li", serializer?.oListItem),
227+
),
228+
list: useSerializerOrDefault<"list">(
229+
serializer?.list,
230+
serializeStandardTag<"list">("ul", serializer?.list),
231+
),
232+
oList: useSerializerOrDefault<"oList">(
233+
serializer?.oList,
234+
serializeStandardTag<"oList">("ol", serializer?.oList),
235+
),
236+
image: useSerializerOrDefault<"image">(
237+
serializer?.image,
238+
serializeImage(linkResolver, serializer?.image),
239+
),
240+
embed: useSerializerOrDefault<"embed">(
241+
serializer?.embed,
242+
serializeEmbed(serializer?.embed),
243+
),
244+
hyperlink: useSerializerOrDefault<"hyperlink">(
245+
serializer?.hyperlink,
246+
serializeHyperlink(linkResolver, serializer?.hyperlink),
247+
),
248+
label: useSerializerOrDefault<"label">(
249+
serializer?.label,
250+
serializeStandardTag<"label">("span", serializer?.label),
251+
),
252+
span: useSerializerOrDefault<"span">(serializer?.span, serializeSpan()),
253+
};
254+
255+
return wrapMapSerializerWithStringChildren(mapSerializer);
171256
};
172257

173258
/**
@@ -180,7 +265,7 @@ const createDefaultHTMLRichTextSerializer = (
180265
* @returns A regular function serializer
181266
*/
182267
const wrapMapSerializerWithStringChildren = (
183-
mapSerializer: HTMLRichTextMapSerializer,
268+
mapSerializer: HTMLStrictRichTextMapSerializer,
184269
): RichTextFunctionSerializer<string> => {
185270
const modifiedMapSerializer = {} as RichTextMapSerializer<string>;
186271

@@ -292,22 +377,27 @@ export const asHTML: {
292377

293378
let serializer: RichTextFunctionSerializer<string>;
294379
if (config.serializer) {
295-
serializer = composeSerializers(
296-
typeof config.serializer === "object"
297-
? wrapMapSerializerWithStringChildren(config.serializer)
298-
: (type, node, text, children, key) =>
299-
// TypeScript doesn't narrow the type correctly here since it is now in a callback function, so we have to cast it here.
300-
(config.serializer as HTMLRichTextFunctionSerializer)(
301-
type,
302-
node,
303-
text,
304-
children.join(""),
305-
key,
306-
),
307-
createDefaultHTMLRichTextSerializer(config.linkResolver),
308-
);
380+
if (typeof config.serializer === "function") {
381+
serializer = composeSerializers(
382+
(type, node, text, children, key) =>
383+
// TypeScript doesn't narrow the type correctly here since it is now in a callback function, so we have to cast it here.
384+
(config.serializer as HTMLRichTextFunctionSerializer)(
385+
type,
386+
node,
387+
text,
388+
children.join(""),
389+
key,
390+
),
391+
createHTMLRichTextSerializer(config.linkResolver),
392+
);
393+
} else {
394+
serializer = createHTMLRichTextSerializer(
395+
config.linkResolver,
396+
config.serializer,
397+
);
398+
}
309399
} else {
310-
serializer = createDefaultHTMLRichTextSerializer(config.linkResolver);
400+
serializer = createHTMLRichTextSerializer(config.linkResolver);
311401
}
312402

313403
return serialize(richTextField, serializer).join(

‎src/lib/serializerHelpers.ts

+141-67
Original file line numberDiff line numberDiff line change
@@ -1,101 +1,175 @@
1+
import { RichTextMapSerializer } from "@prismicio/richtext";
2+
13
import { LinkType } from "../types/value/link";
2-
import {
3-
RTBlockNode,
4-
RTEmbedNode,
5-
RTImageNode,
6-
RTInlineNode,
7-
RTLinkNode,
8-
RTPreformattedNode,
9-
RichTextNodeType,
10-
} from "../types/value/richText";
4+
import { RTAnyNode } from "../types/value/richText";
115

6+
import {
7+
HTMLRichTextMapSerializer,
8+
HTMLStrictRichTextMapSerializer,
9+
} from "../helpers/asHTML";
1210
import { LinkResolverFunction, asLink } from "../helpers/asLink";
1311

1412
import { escapeHTML } from "./escapeHTML";
1513

16-
export const getLabel = (node: RTBlockNode | RTInlineNode): string => {
17-
return "data" in node && "label" in node.data
18-
? ` class="${node.data.label}"`
19-
: "";
14+
type Attributes = Record<string, string | boolean | null | undefined>;
15+
const formatAttributes = (node: RTAnyNode, attributes: Attributes): string => {
16+
const _attributes = { ...attributes };
17+
18+
// Add label to attributes
19+
if ("data" in node && "label" in node.data && node.data.label) {
20+
_attributes.class = _attributes.class
21+
? `${_attributes.class} ${node.data.label}`
22+
: node.data.label;
23+
}
24+
25+
const result = [];
26+
27+
for (const key in _attributes) {
28+
const value = _attributes[key];
29+
30+
if (value) {
31+
if (typeof value === "boolean") {
32+
result.push(key);
33+
} else {
34+
result.push(`${key}="${escapeHTML(value)}"`);
35+
}
36+
}
37+
}
38+
39+
// Add a space at the beginning if there's any result
40+
if (result.length) {
41+
result.unshift("");
42+
}
43+
44+
return result.join(" ");
45+
};
46+
47+
const getGeneralAttributes = (
48+
serializerOrShorthand?: HTMLRichTextMapSerializer[keyof HTMLRichTextMapSerializer],
49+
): Attributes => {
50+
return serializerOrShorthand && typeof serializerOrShorthand !== "function"
51+
? serializerOrShorthand
52+
: {};
2053
};
2154

22-
export const serializeStandardTag = (
55+
export const serializeStandardTag = <
56+
BlockType extends keyof RichTextMapSerializer<string>,
57+
>(
2358
tag: string,
24-
node: RTBlockNode | RTInlineNode,
25-
children: string[],
26-
): string => {
27-
return `<${tag}${getLabel(node)}>${children.join("")}</${tag}>`;
59+
serializerOrShorthand?: HTMLRichTextMapSerializer[BlockType],
60+
): NonNullable<HTMLStrictRichTextMapSerializer[BlockType]> => {
61+
const generalAttributes = getGeneralAttributes(serializerOrShorthand);
62+
63+
return (({ node, children }) => {
64+
return `<${tag}${formatAttributes(
65+
node,
66+
generalAttributes,
67+
)}>${children}</${tag}>`;
68+
}) as NonNullable<HTMLStrictRichTextMapSerializer[BlockType]>;
2869
};
2970

30-
export const serializePreFormatted = (node: RTPreformattedNode): string => {
31-
return `<pre${getLabel(node)}>${escapeHTML(node.text)}</pre>`;
71+
export const serializePreFormatted = (
72+
serializerOrShorthand?: HTMLRichTextMapSerializer["preformatted"],
73+
): NonNullable<HTMLStrictRichTextMapSerializer["preformatted"]> => {
74+
const generalAttributes = getGeneralAttributes(serializerOrShorthand);
75+
76+
return ({ node }) => {
77+
return `<pre${formatAttributes(node, generalAttributes)}>${escapeHTML(
78+
node.text,
79+
)}</pre>`;
80+
};
3281
};
3382

3483
export const serializeImage = (
3584
linkResolver:
3685
| LinkResolverFunction<string | null | undefined>
3786
| undefined
3887
| null,
39-
node: RTImageNode,
40-
): string => {
41-
let imageTag = `<img src="${node.url}" alt="${escapeHTML(node.alt)}"${
42-
node.copyright ? ` copyright="${escapeHTML(node.copyright)}"` : ""
43-
} />`;
44-
45-
// If the image has a link, we wrap it with an anchor tag
46-
if (node.linkTo) {
47-
imageTag = serializeHyperlink(
48-
linkResolver,
49-
{
50-
type: RichTextNodeType.hyperlink,
51-
data: node.linkTo,
52-
start: 0,
53-
end: 0,
54-
},
55-
[imageTag],
56-
);
57-
}
88+
serializerOrShorthand?: HTMLRichTextMapSerializer["image"],
89+
): NonNullable<HTMLStrictRichTextMapSerializer["image"]> => {
90+
const generalAttributes = getGeneralAttributes(serializerOrShorthand);
91+
92+
return ({ node }) => {
93+
const attributes = {
94+
...generalAttributes,
95+
src: node.url,
96+
alt: node.alt,
97+
copyright: node.copyright,
98+
};
99+
100+
let imageTag = `<img${formatAttributes(node, attributes)} />`;
101+
102+
// If the image has a link, we wrap it with an anchor tag
103+
if (node.linkTo) {
104+
imageTag = serializeHyperlink(linkResolver)({
105+
type: "hyperlink",
106+
node: {
107+
type: "hyperlink",
108+
data: node.linkTo,
109+
start: 0,
110+
end: 0,
111+
},
112+
text: "",
113+
children: imageTag,
114+
key: "",
115+
})!;
116+
}
58117

59-
return `<p class="block-img">${imageTag}</p>`;
118+
return `<p class="block-img">${imageTag}</p>`;
119+
};
60120
};
61121

62-
export const serializeEmbed = (node: RTEmbedNode): string => {
63-
return `<div data-oembed="${node.oembed.embed_url}" data-oembed-type="${
64-
node.oembed.type
65-
}" data-oembed-provider="${node.oembed.provider_name}"${getLabel(node)}>${
66-
node.oembed.html
67-
}</div>`;
122+
export const serializeEmbed = (
123+
serializerOrShorthand?: HTMLRichTextMapSerializer["embed"],
124+
): NonNullable<HTMLStrictRichTextMapSerializer["embed"]> => {
125+
const generalAttributes = getGeneralAttributes(serializerOrShorthand);
126+
127+
return ({ node }) => {
128+
const attributes = {
129+
...generalAttributes,
130+
["data-oembed"]: node.oembed.embed_url,
131+
["data-oembed-type"]: node.oembed.type,
132+
["data-oembed-provider"]: node.oembed.provider_name,
133+
};
134+
135+
return `<div${formatAttributes(node, attributes)}>${
136+
node.oembed.html
137+
}</div>`;
138+
};
68139
};
69140

70141
export const serializeHyperlink = (
71142
linkResolver:
72143
| LinkResolverFunction<string | null | undefined>
73144
| undefined
74145
| null,
75-
node: RTLinkNode,
76-
children: string[],
77-
): string => {
78-
switch (node.data.link_type) {
79-
case LinkType.Web: {
80-
return `<a href="${escapeHTML(node.data.url)}" ${
81-
node.data.target ? `target="${node.data.target}" ` : ""
82-
}rel="noopener noreferrer"${getLabel(node)}>${children.join("")}</a>`;
83-
}
146+
serializerOrShorthand?: HTMLRichTextMapSerializer["hyperlink"],
147+
): NonNullable<HTMLStrictRichTextMapSerializer["hyperlink"]> => {
148+
const generalAttributes = getGeneralAttributes(serializerOrShorthand);
84149

85-
case LinkType.Document: {
86-
return `<a href="${asLink(node.data, linkResolver)}"${getLabel(
87-
node,
88-
)}>${children.join("")}</a>`;
89-
}
150+
return ({ node, children }): string => {
151+
const attributes = {
152+
...generalAttributes,
153+
};
90154

91-
case LinkType.Media: {
92-
return `<a href="${node.data.url}"${getLabel(node)}>${children.join(
93-
"",
94-
)}</a>`;
155+
if (node.data.link_type === LinkType.Web) {
156+
attributes.href = node.data.url;
157+
attributes.target = node.data.target;
158+
attributes.rel = "noopener noreferrer";
159+
} else if (node.data.link_type === LinkType.Document) {
160+
attributes.href = asLink(node.data, { linkResolver });
161+
} else if (node.data.link_type === LinkType.Media) {
162+
attributes.href = node.data.url;
95163
}
96-
}
164+
165+
return `<a${formatAttributes(node, attributes)}>${children}</a>`;
166+
};
97167
};
98168

99-
export const serializeSpan = (content?: string): string => {
100-
return content ? escapeHTML(content).replace(/\n/g, "<br />") : "";
169+
export const serializeSpan = (): NonNullable<
170+
HTMLStrictRichTextMapSerializer["span"]
171+
> => {
172+
return ({ text }): string => {
173+
return text ? escapeHTML(text).replace(/\n/g, "<br />") : "";
174+
};
101175
};

‎test/__fixtures__/htmlRichTextMapSerializer.ts

+2
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,6 @@ export const htmlRichTextMapSerializer: prismic.HTMLRichTextMapSerializer = {
44
heading1: ({ children }) => `<h2>${children}</h2>`,
55
// `undefined` serializers should be treated the same as not including it.
66
heading2: undefined,
7+
// `undefined` returning serializers should fallback to default serializer.
8+
heading3: () => undefined,
79
};

‎test/__snapshots__/helpers-asHTML.test.ts.snap

+2
Large diffs are not rendered by default.

‎test/helpers-asHTML.test.ts

+15
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,21 @@ it("serializes with a custom map serializer", () => {
5454
);
5555
});
5656

57+
it("serializes with a custom shorthand map serializer", () => {
58+
expect(
59+
asHTML(richTextFixture.en, {
60+
linkResolver,
61+
serializer: {
62+
heading1: { class: "text-xl", "data-heading": true },
63+
heading2: {
64+
xss: 'https://example.org" onmouseover="alert(document.cookie);',
65+
},
66+
label: { class: "shorthand" },
67+
},
68+
}),
69+
).toMatchSnapshot();
70+
});
71+
5772
it("escapes external links to prevent XSS", () => {
5873
expect(asHTML(richTextFixture.xss, { linkResolver })).toMatchSnapshot();
5974
});

0 commit comments

Comments
 (0)
Please sign in to comment.