Skip to content

Commit

Permalink
add ts-pattern, adjust traversal methods, refactor
Browse files Browse the repository at this point in the history
  • Loading branch information
pokornyd committed Nov 29, 2024
1 parent 72f74c5 commit 0772dc9
Show file tree
Hide file tree
Showing 4 changed files with 103 additions and 55 deletions.
9 changes: 8 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,8 @@
"@portabletext/vue": "^1.0.11",
"browser-or-node": "^3.0.0",
"node-html-parser": "^6.1.13",
"short-unique-id": "^5.2.0"
"short-unique-id": "^5.2.0",
"ts-pattern": "^5.5.0"
},
"publishConfig": {
"access": "public",
Expand Down
72 changes: 57 additions & 15 deletions src/transformers/json-transformer/json-transformer.ts
Original file line number Diff line number Diff line change
@@ -1,59 +1,87 @@
import { DomNode } from "../../parser/parser-models.js";
import { match, P } from "ts-pattern";

import { DomHtmlNode, DomNode, DomTextNode } from "../../parser/parser-models.js";

export type NodeTransformer<T extends DomNode, U, V> = (
node: T,
processedItems: V[],
transformedSubnodes: V[],
context?: U,
) => V[];

export type AsyncNodeTransformer<T extends DomNode, U, V> = (
node: T,
processedItems: V[],
transformedSubnodes: V[],
context?: U,
) => Promise<V[]>;

export type Transformers<TContext, V> = {
text: NodeTransformer<DomTextNode, TContext, V>;
tag: Record<string, NodeTransformer<DomHtmlNode<any>, TContext, V>>;
};

export type AsyncTransformers<TContext, V> = {
text: AsyncNodeTransformer<DomTextNode, TContext, V>;
tag: Record<string, AsyncNodeTransformer<DomHtmlNode<any>, TContext, V>>;
};

/**
* Recursively traverses an array of `DomNodes`, transforming each node using the provided transform function.
* Recursively traverses an array of `DomNodes`, transforming each node using the provided transform function.
* The transformation begins from the deepest nodes and propagates intermediate results upwards through the recursion.
* You can optionally provide a context object and a handler to update it before a node is processed.
*
* @template TContext - The type of the context object used during traversal.
*
* @param {DomNode[]} nodes - The array of `DomNode` elements to traverse and transform.
* @param {NodeTransformer<DomNode, TContext, any>} transform - The function applied to each node to transform it. Takes current node, an array of already transformed subnodes and an optional context as arguments.
* @param {NodeTransformer<DomNode, TContext, V>} transform - The function applied to each node to transform it. Takes current node, an array of already transformed subnodes and an optional context as arguments.
* @param {TContext} [context={}] - The initial context object passed to the `transform` function and updated by the `contextHandler`. Empty object by default.
* @param {(node: DomNode, context: TContext) => TContext} [contextHandler] - An optional function that updates the context based on the current node.
*
* @returns {V[]} Flattened array of transformed nodes.
*
* @remarks
* - The function traverses and transforms the nodes in a depth-first manner.
* - The function traverses and transforms the nodes in a depth-first manner.
* - If a `contextHandler` is provided, it updates the context before passing it to child nodes traversal.
*/
export const traverseAndTransformNodes = <TContext, V>(
nodes: DomNode[],
transform: NodeTransformer<DomNode, TContext, V>,
transform: NodeTransformer<DomNode, TContext, V> | Transformers<TContext, V>,
context: TContext = {} as TContext,
contextHandler?: (node: DomNode, context: TContext) => TContext,
): V[] =>
nodes.flatMap(node => {
const updatedContext = contextHandler?.(node, context) ?? context;
): V[] => {
return nodes.flatMap(node => {
const updatedContext = contextHandler ? contextHandler(node, context) : context;

const children = node.type === "tag"
? traverseAndTransformNodes(node.children, transform, updatedContext, contextHandler)
: [];

return transform(node, children, updatedContext);
return match(transform)
.with(P.when(t => typeof t === "function"), transformFunc => transformFunc(node, children, updatedContext))
.otherwise(transformers =>
match(node)
.with({ type: "text" }, textNode => transformers.text(textNode, children, updatedContext))
.with({ type: "tag" }, tagNode => {
const transformer = transformers.tag[tagNode.tagName] ?? transformers.tag["*"];
if (transformer) {
return transformer(tagNode, children, updatedContext);
}

throw new Error(`No transformer specified for tag: ${tagNode.tagName}`);
})
.exhaustive()
);
});
};

/**
* Recursively traverses an array of `DomNodes`, transforming each node using the provided transform function in an asynchronous matter.
* Recursively traverses an array of `DomNodes`, transforming each node using the provided transform function in an asynchronous matter.
* The transformation begins from the deepest nodes and propagates intermediate results upwards through the recursion.
* You can optionally provide a context object and a handler to update it before a node is processed.
*
* @template TContext - The type of the context object used during traversal.
*
* @param {DomNode[]} nodes - The array of `DomNode` elements to traverse and transform.
* @param {NodeTransformer<DomNode, TContext, V>} transform - The function applied to each node to transform it. Takes current node, an array of already transformed subnodes and an optional context as arguments.
* @param {AsyncNodeTransformer<DomNode, TContext, V>} transform - The function applied to each node to transform it. Takes current node, an array of already transformed subnodes and an optional context as arguments.
* @param {TContext} [context={}] - The initial context object passed to the `transform` function and updated by the `contextHandler`. Empty object by default.
* @param {(node: DomNode, context: TContext) => TContext} [contextHandler] - An optional function that updates the context based on the current node.
*
Expand All @@ -65,7 +93,7 @@ export const traverseAndTransformNodes = <TContext, V>(
*/
export const traverseAndTransformNodesAsync = async <TContext, V>(
nodes: DomNode[],
transform: AsyncNodeTransformer<DomNode, TContext, V>,
transform: AsyncNodeTransformer<DomNode, TContext, V> | AsyncTransformers<TContext, V>,
context: TContext = {} as TContext,
contextHandler?: (node: DomNode, context: TContext) => TContext,
): Promise<V[]> => {
Expand All @@ -77,7 +105,21 @@ export const traverseAndTransformNodesAsync = async <TContext, V>(
? await traverseAndTransformNodesAsync(node.children, transform, updatedContext, contextHandler)
: [];

return transform(node, children, updatedContext);
return match(transform)
.with(P.when(t => typeof t === "function"), transformFunc => transformFunc(node, children, updatedContext))
.otherwise(transformers =>
match(node)
.with({ type: "text" }, textNode => transformers.text(textNode, children, updatedContext))
.with({ type: "tag" }, tagNode => {
const transformer = transformers.tag[tagNode.tagName] ?? transformers.tag["*"];
if (transformer) {
return transformer(tagNode, children, updatedContext);
}

throw new Error(`No transformer specified for tag: ${tagNode.tagName}`);
})
.exhaustive()
);
}),
);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { match } from "ts-pattern";

import {
DomHtmlNode,
DomNode,
Expand All @@ -9,6 +11,7 @@ import {
import {
BlockElement,
IgnoredElement,
NodeTransformer,
PortableTextComponentOrItem,
PortableTextExternalLink,
PortableTextImage,
Expand All @@ -24,9 +27,8 @@ import {
PortableTextTableRow,
Reference,
TextStyleElement,
NodeTransformer,
Transformers,
traverseAndTransformNodes,
ValidElement,
} from "../../transformers/index.js";
import {
blockElements,
Expand All @@ -45,8 +47,6 @@ import {
isExternalLink,
isItemLink,
isListBlock,
isText,
isValidElement,
randomUUID,
textStyleElements,
throwError,
Expand Down Expand Up @@ -162,11 +162,16 @@ const processBlock: NodeToPortableText<DomHtmlNode> = (node, processedSubnodes)
const processMark: NodeToPortableText<DomHtmlNode> = (node, processedSubnodes) => {
const { links, contentItemLinks, spans } = categorizeItems(processedSubnodes);
const key = randomUUID();
const mark = isExternalLink(node)
? (links.push(createExternalLink(key, node.attributes)), key) // comma operator used for assigning key
: isItemLink(node)
? (contentItemLinks.push(createItemLink(key, node.attributes["data-item-id"])), key)
: node.tagName;
const mark = match(node)
.when(isExternalLink, () => {
links.push(createExternalLink(key, node.attributes));
return key;
})
.when(isItemLink, (itemLinkNode) => {
contentItemLinks.push(createItemLink(key, itemLinkNode.attributes["data-item-id"]));
return key;
})
.otherwise(() => node.tagName);

const updatedSpans = spans.map(
s => ({ ...s, marks: [...(s.marks ?? []), mark] } as PortableTextSpan),
Expand Down Expand Up @@ -240,20 +245,10 @@ const processTable: NodeToPortableText<DomHtmlNode> = (_, processedSubnodes) =>
return [createTable(randomUUID(), rows)];
};

const processElement: NodeToPortableText<DomHtmlNode> = (node, processedSubnodes, listContext) =>
transformMap[node.tagName as ValidElement](node, processedSubnodes, listContext);

const processText: NodeToPortableText<DomTextNode> = (node) => [createSpan(randomUUID(), [], node.content)];

const ignoreProcessing: NodeToPortableText<DomHtmlNode> = (_, processedSubnodes) => processedSubnodes;

const toPortableText: NodeToPortableText<DomNode> = (node, processedSubnodes, listContext) =>
isText(node)
? processText(node, processedSubnodes)
: isValidElement(node)
? processElement(node, processedSubnodes, listContext)
: throwError(`Unsupported tag encountered: ${node.tagName}`);

/**
* Transforms a parsed tree into an array of Portable Text Blocks.
*
Expand All @@ -265,7 +260,7 @@ export const nodesToPortableText = (
): PortableTextObject[] =>
traverseAndTransformNodes(
parsedNodes,
toPortableText,
transformers,
{ depth: 0, type: "unknown" }, // initialization of list transformation context
updateListContext,
) as PortableTextObject[];
Expand All @@ -280,22 +275,25 @@ export const transformToPortableText = (
richText: string,
): PortableTextObject[] => nodesToPortableText(parse(richText));

const transformMap: Record<ValidElement, NodeToPortableText<DomHtmlNode<any>>> = {
...(Object.fromEntries(
blockElements.map((tagName) => [tagName, processBlock]),
) as Record<BlockElement, NodeToPortableText<DomHtmlNode>>),
...(Object.fromEntries(
textStyleElements.map((tagName) => [tagName, processMark]),
) as Record<TextStyleElement, NodeToPortableText<DomHtmlNode>>),
...(Object.fromEntries(
ignoredElements.map((tagName) => [tagName, ignoreProcessing]),
) as Record<IgnoredElement, NodeToPortableText<DomHtmlNode>>),
a: processMark,
li: processListItem,
table: processTable,
tr: processTableRow,
td: processTableCell,
br: processLineBreak,
img: processImage,
object: processLinkedItemOrComponent,
const transformers: Transformers<ListContext, PortableTextItem> = {
text: processText,
tag: {
...(Object.fromEntries(
blockElements.map((tagName) => [tagName, processBlock]),
) as Record<BlockElement, NodeToPortableText<DomHtmlNode>>),
...(Object.fromEntries(
textStyleElements.map((tagName) => [tagName, processMark]),
) as Record<TextStyleElement, NodeToPortableText<DomHtmlNode>>),
...(Object.fromEntries(
ignoredElements.map((tagName) => [tagName, ignoreProcessing]),
) as Record<IgnoredElement, NodeToPortableText<DomHtmlNode>>),
a: processMark,
li: processListItem,
table: processTable,
tr: processTableRow,
td: processTableCell,
br: processLineBreak,
img: processImage,
object: processLinkedItemOrComponent,
},
};

0 comments on commit 0772dc9

Please sign in to comment.