From 05be5b91f82bc5e3367f35fc596805e85b5b50a6 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Thu, 7 Nov 2024 14:43:33 +0100 Subject: [PATCH 1/7] feat: add richTextToPlainText - Support generating plain text from Umbraco rich text - Refactor the setup, so models can be shared --- README.md | 21 ++++ examples/UmbracoRichText/src/RichText.tsx | 15 ++- package.json | 4 +- pnpm-lock.yaml | 110 ++++++++++-------- src/UmbracoRichText.tsx | 98 ++-------------- .../UmbracoRichText.browser.test.tsx | 7 +- src/index.ts | 7 +- src/types/RichTextTypes.ts | 87 ++++++++++++++ .../rich-text-converter.test.ts.snap | 3 + .../__tests__/rich-text-converter.test.ts | 45 +++++++ src/utils/rich-text-converter.ts | 93 +++++++++++++++ tsconfig.json | 3 +- 12 files changed, 341 insertions(+), 152 deletions(-) create mode 100644 src/types/RichTextTypes.ts create mode 100644 src/utils/__tests__/__snapshots__/rich-text-converter.test.ts.snap create mode 100644 src/utils/__tests__/rich-text-converter.test.ts create mode 100644 src/utils/rich-text-converter.ts diff --git a/README.md b/README.md index 37b8fd6..ef2f8e8 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,27 @@ declare module "@charlietango/react-umbraco" { } ``` +### `richTextToPlainText` + +A useful method to convert the rich text property to plain text. This can be +used to generate meta descriptions or other text-based properties. + +```ts +import { richTextToPlainText } from "@charlietango/react-umbraco"; + +const plainText = richTextToPlainText(richTextData); + +// Just the first paragraph +const firstParagraph = richTextToPlainText(richTextData, { + firstParagraph: true, +}); + +// Just the first 100 characters, truncated at the nearest word with an ellipsis +const first100Characters = richTextToPlainText(richTextData, { + maxLength: 100, +}); +``` + [npm-version-src]: diff --git a/examples/UmbracoRichText/src/RichText.tsx b/examples/UmbracoRichText/src/RichText.tsx index ff6e278..a185822 100644 --- a/examples/UmbracoRichText/src/RichText.tsx +++ b/examples/UmbracoRichText/src/RichText.tsx @@ -1,18 +1,18 @@ import { - type RenderBlockContext, + type UmbracoBlockContext, type RenderNodeContext, UmbracoRichText, } from '@charlietango/react-umbraco'; // replace with ApiBlockItemModel from your own Umbraco API openapi docs for type safety -type ApiBlockItemModel = { +interface ApiBlockItemModel { content: { id: 'youtube'; properties: { videoId: string; }; }; -}; +} declare module '@charlietango/react-umbraco' { interface UmbracoBlockItemModel extends ApiBlockItemModel {} @@ -27,7 +27,7 @@ function renderNode({ // discriminating on the tag name ensures correct typing for attributes switch (tag) { case 'img': { - return ; + return ; } case 'p': return

{children}

; @@ -42,7 +42,9 @@ function exhaustiveGuard(value: never): never { // provide handling of rendering blocks from Umbraco function renderBlock({ content, -}: RenderBlockContext): React.ReactNode | undefined { +}: UmbracoBlockContext): React.ReactNode | undefined { + if (!content) return undefined; + // discriminating on content.id ensures correct typing for the content properties based on the defined ApiBlockItemModel switch (content.id) { case 'youtube': @@ -54,9 +56,10 @@ function renderBlock({ target="_blank" >
- ▶️ + ▶
diff --git a/package.json b/package.json index bdf1ce9..60979f9 100644 --- a/package.json +++ b/package.json @@ -52,12 +52,12 @@ }, "devDependencies": { "@biomejs/biome": "^1.9.4", - "@types/node": "^22.8.5", + "@types/node": "^22.9.0", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.3", "@vitest/browser": "^2.1.4", - "bumpp": "^9.8.0", + "bumpp": "^9.8.1", "lint-staged": "^15.2.10", "playwright": "^1.48.2", "prettier": "^3.3.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index efba253..eadfedf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,8 +16,8 @@ importers: specifier: ^1.9.4 version: 1.9.4 '@types/node': - specifier: ^22.8.5 - version: 22.8.5 + specifier: ^22.9.0 + version: 22.9.0 '@types/react': specifier: ^18.3.12 version: 18.3.12 @@ -26,13 +26,13 @@ importers: version: 18.3.1 '@vitejs/plugin-react': specifier: ^4.3.3 - version: 4.3.3(vite@5.4.10(@types/node@22.8.5)) + version: 4.3.3(vite@5.4.10(@types/node@22.9.0)) '@vitest/browser': specifier: ^2.1.4 - version: 2.1.4(@types/node@22.8.5)(playwright@1.48.2)(typescript@5.6.3)(vite@5.4.10(@types/node@22.8.5))(vitest@2.1.4) + version: 2.1.4(@types/node@22.9.0)(playwright@1.48.2)(typescript@5.6.3)(vite@5.4.10(@types/node@22.9.0))(vitest@2.1.4) bumpp: - specifier: ^9.8.0 - version: 9.8.0 + specifier: ^9.8.1 + version: 9.8.1 lint-staged: specifier: ^15.2.10 version: 15.2.10 @@ -62,10 +62,10 @@ importers: version: 2.0.0(typescript@5.6.3) vite: specifier: ^5.4.10 - version: 5.4.10(@types/node@22.8.5) + version: 5.4.10(@types/node@22.9.0) vitest: specifier: ^2.1.4 - version: 2.1.4(@types/node@22.8.5)(@vitest/browser@2.1.4)(happy-dom@14.12.3)(jsdom@24.1.0)(msw@2.6.0(@types/node@22.8.5)(typescript@5.6.3)) + version: 2.1.4(@types/node@22.9.0)(@vitest/browser@2.1.4)(happy-dom@14.12.3)(jsdom@24.1.0)(msw@2.6.0(@types/node@22.9.0)(typescript@5.6.3)) vitest-browser-react: specifier: ^0.0.3 version: 0.0.3(@types/react-dom@18.3.1)(@types/react@18.3.12)(@vitest/browser@2.1.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@2.1.4) @@ -96,7 +96,7 @@ importers: version: 18.3.1 '@vitejs/plugin-react': specifier: ^4.3.3 - version: 4.3.3(vite@5.4.10(@types/node@22.8.5)) + version: 4.3.3(vite@5.4.10(@types/node@22.9.0)) autoprefixer: specifier: ^10.4.20 version: 10.4.20(postcss@8.4.47) @@ -111,7 +111,7 @@ importers: version: 5.6.3 vite: specifier: ^5.4.10 - version: 5.4.10(@types/node@22.8.5) + version: 5.4.10(@types/node@22.9.0) packages: @@ -1069,8 +1069,8 @@ packages: '@types/estree@1.0.6': resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} - '@types/node@22.8.5': - resolution: {integrity: sha512-5iYk6AMPtsMbkZqCO1UGF9W5L38twq11S2pYWkybGHH2ogPUvXWNlQqJBzuEZWKj/WRH+QTeiv6ySWqJtvIEgA==} + '@types/node@22.9.0': + resolution: {integrity: sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==} '@types/prop-types@15.7.13': resolution: {integrity: sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==} @@ -1248,8 +1248,8 @@ packages: resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==} engines: {node: '>=6'} - bumpp@9.8.0: - resolution: {integrity: sha512-RKHjvOpN6RGhh7LNXGQCRRTPJ99PvVJRcX7EvKKpgj+3nKV8DWDM+8O1TMK0UvcGWhE74PRrQpISqFTcq8wPSg==} + bumpp@9.8.1: + resolution: {integrity: sha512-25W55DZI/rq6FboM0Q5y8eHbUk9eNn9oZ4bg/I5kiWn8/rdZCw6iqML076akQiUOQGhrm6QDvSSn4PgQ48bS4A==} engines: {node: '>=10'} hasBin: true @@ -1377,6 +1377,10 @@ packages: resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} engines: {node: '>= 8'} + cross-spawn@7.0.5: + resolution: {integrity: sha512-ZVJrKKYunU38/76t0RMOulHOnUcbU9GbpWKAOZ0mhjr7CX6FVrH+4FrAapSOekrgFQ3f/8gwMEuIft0aKq6Hug==} + engines: {node: '>= 8'} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -3272,16 +3276,16 @@ snapshots: '@esbuild/win32-x64@0.21.5': optional: true - '@inquirer/confirm@5.0.1(@types/node@22.8.5)': + '@inquirer/confirm@5.0.1(@types/node@22.9.0)': dependencies: - '@inquirer/core': 10.0.1(@types/node@22.8.5) - '@inquirer/type': 3.0.0(@types/node@22.8.5) - '@types/node': 22.8.5 + '@inquirer/core': 10.0.1(@types/node@22.9.0) + '@inquirer/type': 3.0.0(@types/node@22.9.0) + '@types/node': 22.9.0 - '@inquirer/core@10.0.1(@types/node@22.8.5)': + '@inquirer/core@10.0.1(@types/node@22.9.0)': dependencies: '@inquirer/figures': 1.0.7 - '@inquirer/type': 3.0.0(@types/node@22.8.5) + '@inquirer/type': 3.0.0(@types/node@22.9.0) ansi-escapes: 4.3.2 cli-width: 4.1.0 mute-stream: 2.0.0 @@ -3294,9 +3298,9 @@ snapshots: '@inquirer/figures@1.0.7': {} - '@inquirer/type@3.0.0(@types/node@22.8.5)': + '@inquirer/type@3.0.0(@types/node@22.9.0)': dependencies: - '@types/node': 22.8.5 + '@types/node': 22.9.0 '@isaacs/cliui@8.0.2': dependencies: @@ -3351,7 +3355,7 @@ snapshots: '@jsdevtools/ez-spawn@3.0.4': dependencies: call-me-maybe: 1.0.2 - cross-spawn: 7.0.3 + cross-spawn: 7.0.5 string-argv: 0.3.2 type-detect: 4.1.0 @@ -3545,7 +3549,7 @@ snapshots: '@types/estree@1.0.6': {} - '@types/node@22.8.5': + '@types/node@22.9.0': dependencies: undici-types: 6.19.8 @@ -3566,28 +3570,28 @@ snapshots: '@types/tough-cookie@4.0.5': {} - '@vitejs/plugin-react@4.3.3(vite@5.4.10(@types/node@22.8.5))': + '@vitejs/plugin-react@4.3.3(vite@5.4.10(@types/node@22.9.0))': dependencies: '@babel/core': 7.26.0 '@babel/plugin-transform-react-jsx-self': 7.25.9(@babel/core@7.26.0) '@babel/plugin-transform-react-jsx-source': 7.25.9(@babel/core@7.26.0) '@types/babel__core': 7.20.5 react-refresh: 0.14.2 - vite: 5.4.10(@types/node@22.8.5) + vite: 5.4.10(@types/node@22.9.0) transitivePeerDependencies: - supports-color - '@vitest/browser@2.1.4(@types/node@22.8.5)(playwright@1.48.2)(typescript@5.6.3)(vite@5.4.10(@types/node@22.8.5))(vitest@2.1.4)': + '@vitest/browser@2.1.4(@types/node@22.9.0)(playwright@1.48.2)(typescript@5.6.3)(vite@5.4.10(@types/node@22.9.0))(vitest@2.1.4)': dependencies: '@testing-library/dom': 10.4.0 '@testing-library/user-event': 14.5.2(@testing-library/dom@10.4.0) - '@vitest/mocker': 2.1.4(msw@2.6.0(@types/node@22.8.5)(typescript@5.6.3))(vite@5.4.10(@types/node@22.8.5)) + '@vitest/mocker': 2.1.4(msw@2.6.0(@types/node@22.9.0)(typescript@5.6.3))(vite@5.4.10(@types/node@22.9.0)) '@vitest/utils': 2.1.4 magic-string: 0.30.12 - msw: 2.6.0(@types/node@22.8.5)(typescript@5.6.3) + msw: 2.6.0(@types/node@22.9.0)(typescript@5.6.3) sirv: 3.0.0 tinyrainbow: 1.2.0 - vitest: 2.1.4(@types/node@22.8.5)(@vitest/browser@2.1.4)(happy-dom@14.12.3)(jsdom@24.1.0)(msw@2.6.0(@types/node@22.8.5)(typescript@5.6.3)) + vitest: 2.1.4(@types/node@22.9.0)(@vitest/browser@2.1.4)(happy-dom@14.12.3)(jsdom@24.1.0)(msw@2.6.0(@types/node@22.9.0)(typescript@5.6.3)) ws: 8.18.0 optionalDependencies: playwright: 1.48.2 @@ -3605,14 +3609,14 @@ snapshots: chai: 5.1.2 tinyrainbow: 1.2.0 - '@vitest/mocker@2.1.4(msw@2.6.0(@types/node@22.8.5)(typescript@5.6.3))(vite@5.4.10(@types/node@22.8.5))': + '@vitest/mocker@2.1.4(msw@2.6.0(@types/node@22.9.0)(typescript@5.6.3))(vite@5.4.10(@types/node@22.9.0))': dependencies: '@vitest/spy': 2.1.4 estree-walker: 3.0.3 magic-string: 0.30.12 optionalDependencies: - msw: 2.6.0(@types/node@22.8.5)(typescript@5.6.3) - vite: 5.4.10(@types/node@22.8.5) + msw: 2.6.0(@types/node@22.9.0)(typescript@5.6.3) + vite: 5.4.10(@types/node@22.9.0) '@vitest/pretty-format@2.1.4': dependencies: @@ -3736,7 +3740,7 @@ snapshots: builtin-modules@3.3.0: {} - bumpp@9.8.0: + bumpp@9.8.1: dependencies: '@jsdevtools/ez-spawn': 3.0.4 c12: 1.11.2 @@ -3878,6 +3882,12 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + cross-spawn@7.0.5: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + cssesc@3.0.0: {} cssstyle@4.1.0: @@ -4043,7 +4053,7 @@ snapshots: execa@8.0.1: dependencies: - cross-spawn: 7.0.3 + cross-spawn: 7.0.5 get-stream: 8.0.1 human-signals: 5.0.0 is-stream: 3.0.0 @@ -4081,7 +4091,7 @@ snapshots: foreground-child@3.2.1: dependencies: - cross-spawn: 7.0.3 + cross-spawn: 7.0.5 signal-exit: 4.1.0 foreground-child@3.3.0: @@ -4526,12 +4536,12 @@ snapshots: ms@2.1.3: {} - msw@2.6.0(@types/node@22.8.5)(typescript@5.6.3): + msw@2.6.0(@types/node@22.9.0)(typescript@5.6.3): dependencies: '@bundled-es-modules/cookie': 2.0.0 '@bundled-es-modules/statuses': 1.0.1 '@bundled-es-modules/tough-cookie': 0.1.6 - '@inquirer/confirm': 5.0.1(@types/node@22.8.5) + '@inquirer/confirm': 5.0.1(@types/node@22.9.0) '@mswjs/interceptors': 0.36.7 '@open-draft/deferred-promise': 2.2.0 '@open-draft/until': 2.1.0 @@ -5120,12 +5130,12 @@ snapshots: util-deprecate@1.0.2: {} - vite-node@2.1.4(@types/node@22.8.5): + vite-node@2.1.4(@types/node@22.9.0): dependencies: cac: 6.7.14 debug: 4.3.7 pathe: 1.1.2 - vite: 5.4.10(@types/node@22.8.5) + vite: 5.4.10(@types/node@22.9.0) transitivePeerDependencies: - '@types/node' - less @@ -5137,29 +5147,29 @@ snapshots: - supports-color - terser - vite@5.4.10(@types/node@22.8.5): + vite@5.4.10(@types/node@22.9.0): dependencies: esbuild: 0.21.5 postcss: 8.4.47 rollup: 4.24.3 optionalDependencies: - '@types/node': 22.8.5 + '@types/node': 22.9.0 fsevents: 2.3.3 vitest-browser-react@0.0.3(@types/react-dom@18.3.1)(@types/react@18.3.12)(@vitest/browser@2.1.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@2.1.4): dependencies: - '@vitest/browser': 2.1.4(@types/node@22.8.5)(playwright@1.48.2)(typescript@5.6.3)(vite@5.4.10(@types/node@22.8.5))(vitest@2.1.4) + '@vitest/browser': 2.1.4(@types/node@22.9.0)(playwright@1.48.2)(typescript@5.6.3)(vite@5.4.10(@types/node@22.9.0))(vitest@2.1.4) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - vitest: 2.1.4(@types/node@22.8.5)(@vitest/browser@2.1.4)(happy-dom@14.12.3)(jsdom@24.1.0)(msw@2.6.0(@types/node@22.8.5)(typescript@5.6.3)) + vitest: 2.1.4(@types/node@22.9.0)(@vitest/browser@2.1.4)(happy-dom@14.12.3)(jsdom@24.1.0)(msw@2.6.0(@types/node@22.9.0)(typescript@5.6.3)) optionalDependencies: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 - vitest@2.1.4(@types/node@22.8.5)(@vitest/browser@2.1.4)(happy-dom@14.12.3)(jsdom@24.1.0)(msw@2.6.0(@types/node@22.8.5)(typescript@5.6.3)): + vitest@2.1.4(@types/node@22.9.0)(@vitest/browser@2.1.4)(happy-dom@14.12.3)(jsdom@24.1.0)(msw@2.6.0(@types/node@22.9.0)(typescript@5.6.3)): dependencies: '@vitest/expect': 2.1.4 - '@vitest/mocker': 2.1.4(msw@2.6.0(@types/node@22.8.5)(typescript@5.6.3))(vite@5.4.10(@types/node@22.8.5)) + '@vitest/mocker': 2.1.4(msw@2.6.0(@types/node@22.9.0)(typescript@5.6.3))(vite@5.4.10(@types/node@22.9.0)) '@vitest/pretty-format': 2.1.4 '@vitest/runner': 2.1.4 '@vitest/snapshot': 2.1.4 @@ -5175,12 +5185,12 @@ snapshots: tinyexec: 0.3.1 tinypool: 1.0.1 tinyrainbow: 1.2.0 - vite: 5.4.10(@types/node@22.8.5) - vite-node: 2.1.4(@types/node@22.8.5) + vite: 5.4.10(@types/node@22.9.0) + vite-node: 2.1.4(@types/node@22.9.0) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 22.8.5 - '@vitest/browser': 2.1.4(@types/node@22.8.5)(playwright@1.48.2)(typescript@5.6.3)(vite@5.4.10(@types/node@22.8.5))(vitest@2.1.4) + '@types/node': 22.9.0 + '@vitest/browser': 2.1.4(@types/node@22.9.0)(playwright@1.48.2)(typescript@5.6.3)(vite@5.4.10(@types/node@22.9.0))(vitest@2.1.4) happy-dom: 14.12.3 jsdom: 24.1.0 transitivePeerDependencies: diff --git a/src/UmbracoRichText.tsx b/src/UmbracoRichText.tsx index da00e1a..778870b 100644 --- a/src/UmbracoRichText.tsx +++ b/src/UmbracoRichText.tsx @@ -1,54 +1,12 @@ import { decode } from "html-entities"; import React from "react"; -import type { Overwrite } from "./utils/helper-types"; +import type { + RichTextElementModel, + RouteAttributes, + UmbracoBlockContext, +} from "./types/RichTextTypes"; import { parseStyle } from "./utils/parse-style"; -interface BaseBlockItemModel { - content?: { - id: string; - properties: { - [key: string]: unknown; - }; - }; - settings?: { - id: string; - properties: { - [key: string]: unknown; - }; - }; -} - -/** - * Override the block item model with Umbraco specific properties. - * This way you can get the full type safety of the Umbraco API you are using. - * - * **react-umbraco.d.ts** - * ```ts - * import { components } from '@/openapi/umbraco'; - * - * // Define the intermediate interface - * type ApiBlockItemModel = components['schemas']['ApiBlockItemModel']; - * - * declare module '@charlietango/react-umbraco' { - * interface UmbracoBlockItemModel extends ApiBlockItemModel {} - * } - * ``` - */ -export type UmbracoBlockItemModel = {}; - -export type RenderBlockContext = Overwrite< - BaseBlockItemModel, - UmbracoBlockItemModel ->; - -interface RouteAttributes { - path: string; - startItem: { - id: string; - path: string; - }; -} - interface NodeMeta { /** The tag of the parent element */ ancestor?: string; @@ -81,16 +39,8 @@ export type RenderNodeContext = { ); interface RichTextProps { - data: - | { - /** List as `string` so it matches generated type from Umbraco. In reality, the value of the root `tag` must be `#root` */ - tag: string; - attributes?: Record; - elements?: RichTextElementModel[]; - blocks?: Array; - } - | undefined; - renderBlock?: (block: RenderBlockContext) => React.ReactNode; + data: RichTextElementModel | undefined; + renderBlock?: (block: UmbracoBlockContext) => React.ReactNode; /** * Render an HTML node with custom logic. * @param node @@ -113,35 +63,6 @@ interface RichTextProps { }>; } -export type RichTextElementModel = - | { - tag: "#text"; - text: string; - } - | { - tag: "#comment"; - text: string; - } - | { - tag: "umb-rte-block"; - attributes: { - "content-id": string; - }; - elements: RichTextElementModel[]; - } - | { - tag: "umb-rte-block-inline"; - attributes: { - "content-id": string; - }; - elements: RichTextElementModel[]; - } - | { - tag: keyof React.JSX.IntrinsicElements; - attributes: Record & { route?: RouteAttributes }; - elements?: RichTextElementModel[]; - }; - /** * Render the individual elements of the rich text */ @@ -154,7 +75,7 @@ function RichTextElement({ meta, }: { element: RichTextElementModel; - blocks: Array | undefined; + blocks: Array | undefined; meta: | { ancestor?: string; @@ -163,7 +84,8 @@ function RichTextElement({ } | undefined; } & Pick) { - if (!element || element.tag === "#comment") return null; + if (!element || element.tag === "#comment" || element.tag === "#root") + return null; if (element.tag === "#text") { // Decode HTML entities in text nodes return decode(element.text); diff --git a/src/__tests__/UmbracoRichText.browser.test.tsx b/src/__tests__/UmbracoRichText.browser.test.tsx index 17880d6..4d3e0d4 100644 --- a/src/__tests__/UmbracoRichText.browser.test.tsx +++ b/src/__tests__/UmbracoRichText.browser.test.tsx @@ -1,5 +1,6 @@ import { render } from "vitest-browser-react"; -import { type RichTextElementModel, UmbracoRichText } from "../UmbracoRichText"; +import { UmbracoRichText } from "../UmbracoRichText"; +import type { RichTextElementModel } from "../types/RichTextTypes"; import fixture from "./__fixtures__/UmbracoRichText.fixture.json"; /** @@ -23,7 +24,9 @@ function nestedElements(...tags: string[]): RichTextElementModel[] { } it("should not render without #root tag in the root element ", () => { - const { container } = render(); + const { container } = render( + , + ); expect(container.innerHTML).toEqual(""); }); diff --git a/src/index.ts b/src/index.ts index ab07505..c5e18b2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ export { UmbracoRichText } from "./UmbracoRichText"; +export { richTextToPlainText } from "./utils/rich-text-converter"; +export type { RenderNodeContext } from "./UmbracoRichText"; export type { UmbracoBlockItemModel, - RenderBlockContext, - RenderNodeContext, -} from "./UmbracoRichText"; + UmbracoBlockContext, +} from "./types/RichTextTypes"; diff --git a/src/types/RichTextTypes.ts b/src/types/RichTextTypes.ts new file mode 100644 index 0000000..9256d5b --- /dev/null +++ b/src/types/RichTextTypes.ts @@ -0,0 +1,87 @@ +import type { Overwrite } from "../utils/helper-types"; + +interface BaseBlockItemModel { + content?: { + id: string; + properties: { + [key: string]: unknown; + }; + }; + settings?: { + id: string; + properties: { + [key: string]: unknown; + }; + }; +} + +/** + * Override the block item model with Umbraco specific properties. + * This way you can get the full type safety of the Umbraco API you are using. + * + * **react-umbraco.d.ts** + * ```ts + * import { components } from '@/openapi/umbraco'; + * + * // Define the intermediate interface + * type ApiBlockItemModel = components['schemas']['ApiBlockItemModel']; + * + * declare module '@charlietango/react-umbraco' { + * interface UmbracoBlockItemModel extends ApiBlockItemModel {} + * } + * ``` + */ +export interface UmbracoBlockItemModel { + _overrideImplementation?: never; +} + +export type UmbracoBlockContext = Overwrite< + BaseBlockItemModel, + Omit +>; + +export interface RouteAttributes { + path: string; + startItem: { + id: string; + path: string; + }; +} + +/** + * The possible values for the rich text node from Umbraco. + */ +export type RichTextElementModel = + | { + tag: "#root"; + attributes?: Record; + elements?: RichTextElementModel[]; + blocks?: Array; + } + | { + tag: "#text"; + text: string; + } + | { + tag: "#comment"; + text: string; + } + | { + tag: "umb-rte-block"; + attributes: { + "content-id": string; + }; + elements: RichTextElementModel[]; + } + | { + tag: "umb-rte-block-inline"; + attributes: { + "content-id": string; + }; + elements: RichTextElementModel[]; + } + | { + tag: keyof HTMLElementTagNameMap; + attributes: Record & { route?: RouteAttributes }; + elements?: RichTextElementModel[]; + }; diff --git a/src/utils/__tests__/__snapshots__/rich-text-converter.test.ts.snap b/src/utils/__tests__/__snapshots__/rich-text-converter.test.ts.snap new file mode 100644 index 0000000..940b1a5 --- /dev/null +++ b/src/utils/__tests__/__snapshots__/rich-text-converter.test.ts.snap @@ -0,0 +1,3 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`should convert rich text to plain text 1`] = `"What to expect from here on out What follows from here is just a bunch of absolute nonsense I've written to dogfood the plugin itself. It includes every sensible typographic element I could think of, like bold text, unordered lists, ordered lists, code blocks, block quotes, and even italics. It's important to cover all of these use cases for a few reasons: We want everything to look good out of the box. Really just the first reason, that's the whole point of the plugin. Here's a third pretend reason though a list with three items looks more realistic than a list with two items. Now we're going to try out another header style. Typography should be easy So that's a header for you — with any luck if we've done our job correctly that will look pretty reasonable. Something a wise person once told me about typography is: Typography is pretty important if you don't want your stuff to look like trash. Make it good then it won't be bad. It's probably important that images look okay here by default as well: Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of classical Latin literature from 45 BC, making it over 2000 years old. Now I'm going to show you an example of an unordered list to make sure that looks good, too: So here is the first item in this list. In this example we're keeping the items short. Later, we'll use longer, more complex list items. And that's the end of this section. What if we stack headings? We should make sure that looks good, too. Sometimes you have headings directly underneath each other. In those cases you often have to undo the top margin on the second heading because it usually looks better for the headings to be closer together than a paragraph followed by a heading should be. When a heading comes after a paragraph … When a heading comes after a paragraph, we need a bit more space, like I already mentioned above. Now let's see what a more complex list would look like. I often do this thing where list items have headings. For some reason I think this looks cool which is unfortunate because it's pretty annoying to get the styles right. I often have two or three paragraphs in these list items, too, so the hard part is getting the spacing between the paragraphs, list item heading, and separate list items to all make sense. Pretty tough honestly, you could make a strong argument that you just shouldn't write this way. Since this is a list, I need at least two items. I explained what I'm doing already in the previous list item, but a list wouldn't be a list if it only had one item, and we really want this to look realistic. That's why I've added this second list item so I actually have something to look at when writing the styles. It's not a bad idea to add a third item either. I think it probably would've been fine to just use two items but three is definitely not worse, and since I seem to be having no trouble making up arbitrary things to type, I might as well include it. I'm going to press Enter now. After this sort of list I usually have a closing statement or paragraph, because it kinda looks weird jumping right to a heading. Code should look okay by default. I think most people are going to use highlight.js or Prism or something if they want to style their code blocks but it wouldn't hurt to make them look okay out of the box, even with no syntax highlighting. Here's what a default tailwind.config.js file looks like at the time of writing: module.exports = { purge: [], theme: { extend: {}, }, variants: {}, plugins: [], } Hopefully that looks good enough to you. What about nested lists? Nested lists basically always look bad which is why editors like Medium don't even let you do it, but I guess since some of you goofballs are going to do it we have to carry the burden of at least making it work. Nested lists are rarely a good idea. You might feel like you are being really "organized" or something but you are just creating a gross shape on the screen that is hard to read. Nested navigation in UIs is a bad idea too, keep things as flat as possible. Nesting tons of folders in your source code is also not helpful. Since we need to have more items, here's another one. I'm not sure if we'll bother styling more than two levels deep. Two is already too much, three is guaranteed to be a bad idea. If you nest four levels deep you belong in prison. Two items isn't really a list, three is good though. Again please don't nest lists if you want people to actually read your content. Nobody wants to look at this. I'm upset that we even have to bother styling this. The most annoying thing about lists in Markdown is that
  • elements aren't given a child

    tag unless there are multiple paragraphs in the list item. That means I have to worry about styling that annoying situation too. For example, here's another nested list. But this time with a second paragraph. These list items won't have

    tags Because they are only one line each But in this second top-level list item, they will. This is especially annoying because of the spacing on this paragraph. As you can see here, because I've added a second line, this list item now has a

    tag. This is the second line I'm talking about by the way. Finally here's another list item so it's more like a list. A closing list item, but with no nested list, because why not? And finally a sentence to close off this section. We didn't forget about description lists Well, that's not exactly true, we first released this plugin back in 2020 and it took three years before we added description lists. But they're here now, so let's just be happy about that … okay? They can be great for things like FAQs. Why do you never see elephants hiding in trees? Because they're so good at it. Lorem ipsum dolor sit amet consectetur adipisicing elit. Quas cupiditate laboriosam fugiat. What do you call someone with no body and no nose? Nobody knows. Lorem ipsum dolor sit amet consectetur adipisicing elit. Culpa, voluptas ipsa quia excepturi, quibusdam natus exercitationem sapiente tempore labore voluptatem. Why can't you hear a pterodactyl go to the bathroom? Because the pee is silent. Lorem ipsum dolor sit amet, consectetur adipisicing elit. Ipsam, quas voluptatibus ex culpa ipsum, aspernatur blanditiis fugiat ullam magnam suscipit deserunt illum natus facilis atque vero consequatur! Quisquam, debitis error. There are other elements we need to style I almost forgot to mention links, like this link to the Tailwind CSS website. We almost made them blue but that's so yesterday, so we went with dark gray, feels edgier. We even included table styles, check it out: Wrestler Origin Finisher Bret "The Hitman" Hart Calgary, AB Sharpshooter Stone Cold Steve Austin Austin, TX Stone Cold Stunner Randy Savage Sarasota, FL Elbow Drop Vader Boulder, CO Vader Bomb Razor Ramon Chuluota, FL Razor's Edge We also need to make sure inline code looks good, like if I wanted to talk about elements or tell you the good news about @tailwindcss/typography. Sometimes I even use code in headings Even though it's probably a bad idea, and historically I've had a hard time making it look good. This "wrap the code blocks in backticks" trick works pretty well though really. Another thing I've done in the past is put a code tag inside of a link, like if I wanted to tell you about the tailwindcss/docs repository. I don't love that there is an underline below the backticks but it is absolutely not worth the madness it would require to avoid it. We haven't used an h4 yet But now we have. Please don't use h5 or h6 in your content, Medium only supports two heading levels for a reason, you animals. I honestly considered using a before pseudo-element to scream at you if you use an h5 or h6. We don't style them at all out of the box because h4 elements are already so small that they are the same size as the body copy. What are we supposed to do with an h5, make it smaller than the body copy? No thanks. We still need to think about stacked headings though. Let's make sure we don't screw that up with h4 elements, either. Phew, with any luck we have styled the headings above this text and they look pretty good. Let's add a closing paragraph here so things end with a decently sized block of text. I can't explain why I want things to end that way but I have to assume it's because I think things will look weird or unbalanced if there is a heading too close to the end of the document. What I've written here is probably long enough, but adding this final sentence can't hurt. Umbraco We shouldn't forget about internal links to Umbraco content. It's also interesting to see how Umbraco outputs images. The harbour at night "`; diff --git a/src/utils/__tests__/rich-text-converter.test.ts b/src/utils/__tests__/rich-text-converter.test.ts new file mode 100644 index 0000000..9af7b0e --- /dev/null +++ b/src/utils/__tests__/rich-text-converter.test.ts @@ -0,0 +1,45 @@ +import fixture from "../../__tests__/__fixtures__/UmbracoRichText.fixture.json"; +import type { RichTextElementModel } from "../../types/RichTextTypes"; +import { richTextToPlainText } from "../rich-text-converter"; + +test("should convert rich text to plain text", () => { + expect( + richTextToPlainText(fixture as RichTextElementModel), + ).toMatchSnapshot(); +}); + +test("should get the first paragraph", () => { + expect( + richTextToPlainText(fixture as RichTextElementModel, { + firstParagraph: true, + }), + ).toMatchInlineSnapshot( + `"What follows from here is just a bunch of absolute nonsense I've written to dogfood the plugin itself. It includes every sensible typographic element I could think of, like bold text, unordered lists, ordered lists, code blocks, block quotes, and even italics."`, + ); +}); + +test("should truncate text", () => { + expect( + richTextToPlainText(fixture as RichTextElementModel, { + maxLength: 60, + }), + ).toMatchInlineSnapshot( + `"What to expect from here on out What follows from here is..."`, + ); + expect( + richTextToPlainText(fixture as RichTextElementModel, { + maxLength: 290, + }), + ).toMatchInlineSnapshot( + `"What to expect from here on out What follows from here is just a bunch of absolute nonsense I've written to dogfood the plugin itself. It includes every sensible typographic element I could think of, like bold text, unordered lists, ordered lists, code blocks, block quotes, and even..."`, + ); + + // Avoid extra punctuation at the end of the string + expect( + richTextToPlainText(fixture as RichTextElementModel, { + maxLength: 135, + }), + ).toMatchInlineSnapshot( + `"What to expect from here on out What follows from here is just a bunch of absolute nonsense I've written to dogfood the plugin itself..."`, + ); +}); diff --git a/src/utils/rich-text-converter.ts b/src/utils/rich-text-converter.ts new file mode 100644 index 0000000..5f37359 --- /dev/null +++ b/src/utils/rich-text-converter.ts @@ -0,0 +1,93 @@ +import { decode } from "html-entities"; +import type { RichTextElementModel } from "../types/RichTextTypes"; + +const arrayContentLength = (arr: string[]) => arr.join("").length; + +/** + * Iterate over the rich text and find all text elements. + */ +function iterateRichText( + data: RichTextElementModel, + acc: string[], + options: Options, +) { + // Iterate over the elements in the rich text, and find all `#text` elements + if (data.tag === "#text") { + // Decode the text and remove any extra whitespace/line breaks + const decodedText = decode(data.text).trim().replace(/\s+/g, " "); + // If the text is the first element, or the first character is a special character, don't add a space + if (acc.length === 0 || decodedText.charAt(0).match(/[.,?!:;]/)) { + acc.push(decodedText); + } else { + acc.push(` ${decodedText}`); + } + return acc; + } + if (data.tag === "#comment") { + return acc; + } + + if (data.elements) { + for (let i = 0; i < data.elements.length; i++) { + iterateRichText(data.elements[i], acc, options); + if (options.maxLength && arrayContentLength(acc) >= options.maxLength) { + return acc; + } + } + } + return acc; +} + +/** + * Iterate over the rich text and find the first element with the given tag - that also has content. + */ +function findElement( + data: RichTextElementModel, + tag: RichTextElementModel["tag"], +): RichTextElementModel | undefined { + if (data.tag === tag) { + // Ensure the element is or has text content before returning it. + if (data.tag === "#text") return data; + return findElement(data, "#text") ? data : undefined; + } + if (data.tag === "#text" || data.tag === "#comment") { + return undefined; + } + + if (data.elements) { + for (let i = 0; i < data.elements.length; i++) { + const result = findElement(data.elements[i], tag); + if (result) { + return result; + } + } + } + return undefined; +} + +type Options = { + firstParagraph?: boolean; + maxLength?: number; +}; + +/** + * Convert an Umbraco RichText element to plain text. + * Optional specify that only the first paragraph (with text content) should be returned. + * When joining the text elements, it will trim whitespace, and add space between elements - Unless the first character is a special character, like a period or comma. + */ +export function richTextToPlainText( + data: RichTextElementModel, + options: Options = {}, +) { + // Iterate over the elements in the rich text, and find all `#text` elements + const content = options.firstParagraph ? findElement(data, "p") : data; + if (!content) { + return ""; + } + const output = iterateRichText(content, [], options).join(""); + if (options.maxLength && output.length > options.maxLength) { + // If the output is longer than the maxLength, truncate it to nearest word, and add ellipsis + return `${output.slice(0, options.maxLength).replace(/[.,]?\s+\S*$/, "")}...`; + } + return output; +} diff --git a/tsconfig.json b/tsconfig.json index 0cab6ac..a434243 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,5 +13,6 @@ "esModuleInterop": true, "skipDefaultLibCheck": true, "skipLibCheck": true - } + }, + "exclude": ["examples/**"] } From 1ab539c07c38c67dece6bd6fc621a468f286a8b2 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Fri, 8 Nov 2024 10:17:33 +0100 Subject: [PATCH 2/7] feat: support ignoreTags on `richTextToPlainText` --- README.md | 33 ++++++++++++++++--- .../__tests__/rich-text-converter.test.ts | 11 +++++++ src/utils/rich-text-converter.ts | 4 ++- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index ef2f8e8..7a66f76 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ choice. npm install @charlietango/react-umbraco ``` -### `` +## `` [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz_small.svg)](https://stackblitz.com/github/charlie-tango/react-umbraco/tree/main?file=examples/UmbracoRichText/src/RichText.tsx) @@ -84,7 +84,7 @@ function RichText({ data }) { } ``` -#### Blocks +### Blocks You can augment the `renderBlock` method with the generated OpenAPI types from Umbraco Content Delivery API. That way you can correctly filter the blocks you @@ -110,10 +110,28 @@ declare module "@charlietango/react-umbraco" { } ``` -### `richTextToPlainText` +## `richTextToPlainText` -A useful method to convert the rich text property to plain text. This can be -used to generate meta descriptions or other text-based properties. +A utility function to convert an Umbraco RichText element to plain text. This +can be useful for generating meta descriptions or other text-based properties. + +### Parameters + +- `data` (`RichTextElementModel`): The rich text element to be converted. +- `options` (`Options`, _optional_): An object to specify additional options. + - `firstParagraph` (`boolean`, _optional_): If `true`, only the first + paragraph with text content will be returned. + - `maxLength` (`number`, _optional_): The maximum length of the returned text. + If the text exceeds this length, it will be truncated to the nearest word + and an ellipsis will be added. + - `ignoreTags` (`Array`, _optional_): An array of tags to be ignored + during the conversion. + +### Returns + +- `string`: The plain text representation of the rich text element. + +### Example ```ts import { richTextToPlainText } from "@charlietango/react-umbraco"; @@ -129,6 +147,11 @@ const firstParagraph = richTextToPlainText(richTextData, { const first100Characters = richTextToPlainText(richTextData, { maxLength: 100, }); + +// Ignore certain tags, skipping their content +const ignoreTags = richTextToPlainText(richTextData, { + ignoreTags: ["h1", "h2", "ol", "figure"], +}); ``` diff --git a/src/utils/__tests__/rich-text-converter.test.ts b/src/utils/__tests__/rich-text-converter.test.ts index 9af7b0e..3bdd3ba 100644 --- a/src/utils/__tests__/rich-text-converter.test.ts +++ b/src/utils/__tests__/rich-text-converter.test.ts @@ -43,3 +43,14 @@ test("should truncate text", () => { `"What to expect from here on out What follows from here is just a bunch of absolute nonsense I've written to dogfood the plugin itself..."`, ); }); + +test("should ignore tags", () => { + expect( + richTextToPlainText(fixture as RichTextElementModel, { + maxLength: 290, + ignoreTags: ["h2", "strong", "em", "ol", "ul", "code", "blockquote"], + }), + ).toMatchInlineSnapshot( + `"What follows from here is just a bunch of absolute nonsense I've written to dogfood the plugin itself. It includes every sensible typographic element I could think of, like, unordered lists, ordered lists, code blocks, block quotes,. It's important to cover all of these use cases for a..."`, + ); +}); diff --git a/src/utils/rich-text-converter.ts b/src/utils/rich-text-converter.ts index 5f37359..1bd1419 100644 --- a/src/utils/rich-text-converter.ts +++ b/src/utils/rich-text-converter.ts @@ -23,7 +23,8 @@ function iterateRichText( } return acc; } - if (data.tag === "#comment") { + + if (data.tag === "#comment" || options.ignoreTags?.includes(data.tag)) { return acc; } @@ -68,6 +69,7 @@ function findElement( type Options = { firstParagraph?: boolean; maxLength?: number; + ignoreTags?: Array; }; /** From 0702500612e68bf403096d739b51450a2a0fd022 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Fri, 8 Nov 2024 11:04:03 +0100 Subject: [PATCH 3/7] fix: use typecards to simplify the type --- src/UmbracoRichText.tsx | 142 +++++++++++++++++-------------- src/types/RichTextTypes.ts | 54 ++++++++++-- src/utils/rich-text-converter.ts | 15 ++-- 3 files changed, 132 insertions(+), 79 deletions(-) diff --git a/src/UmbracoRichText.tsx b/src/UmbracoRichText.tsx index 778870b..cbba3d1 100644 --- a/src/UmbracoRichText.tsx +++ b/src/UmbracoRichText.tsx @@ -1,9 +1,13 @@ import { decode } from "html-entities"; import React from "react"; -import type { - RichTextElementModel, - RouteAttributes, - UmbracoBlockContext, +import { + type RichTextElementModel, + type RouteAttributes, + type UmbracoBlockContext, + isHtmlElement, + isRootElement, + isTextElement, + isUmbracoBlock, } from "./types/RichTextTypes"; import { parseStyle } from "./utils/parse-style"; @@ -86,34 +90,16 @@ function RichTextElement({ } & Pick) { if (!element || element.tag === "#comment" || element.tag === "#root") return null; - if (element.tag === "#text") { + + if (isTextElement(element)) { // Decode HTML entities in text nodes return decode(element.text); } - let children = element.elements?.map((node, index) => ( - - )); - if (!children?.length) children = undefined; - // If the tag is a block, skip the normal rendering and render the block - if ( - element.tag === "umb-rte-block" || - element.tag === "umb-rte-block-inline" - ) { + if (isUmbracoBlock(element)) { const block = blocks?.find( - (block) => block.content?.id === element.attributes["content-id"], + (block) => block.content?.id === element.attributes?.["content-id"], ); if (renderBlock && block) { return renderBlock(block); @@ -127,52 +113,76 @@ function RichTextElement({ return null; } - const { route, style, class: className, ...attributes } = element.attributes; - const defaultAttributes = htmlAttributes[element.tag]; - if (element.tag === "a" && route?.path) { - attributes.href = route?.path; - } + if (isHtmlElement(element)) { + const children: React.ReactNode = + element.elements.map((node, index) => ( + + )) || null; - if (className) { - if (defaultAttributes?.className) { - // Merge the default class with the class attribute - attributes.className = `${defaultAttributes.className} ${className}`; - } else { - attributes.className = className; + const { + route, + style, + class: className, + ...attributes + } = element.attributes; + const defaultAttributes = htmlAttributes[element.tag]; + if (element.tag === "a" && route?.path) { + attributes.href = route?.path; } - } - if (typeof style === "string") { - attributes.style = parseStyle(style); - } + if (className) { + if (defaultAttributes?.className) { + // Merge the default class with the class attribute + attributes.className = `${defaultAttributes.className} ${className}`; + } else { + attributes.className = className; + } + } - if (renderNode) { - const output = renderNode({ - // biome-ignore lint/suspicious/noExplicitAny: Avoid complicated TypeScript logic by using any. The type will be corrected in the implementation. - tag: element.tag as any, - attributes: { - ...defaultAttributes, - ...attributes, - } as Record, - children, - route, - meta: meta || {}, - }); + if (typeof style === "string") { + attributes.style = parseStyle(style); + } - if (output !== undefined) { - // If we got a valid output from the renderElement function, we return it - // `null` we will render nothing, but `undefined` fallback to the default element - return output; + if (renderNode) { + const output = renderNode({ + // biome-ignore lint/suspicious/noExplicitAny: Avoid complicated TypeScript logic by using any. The type will be corrected in the implementation. + tag: element.tag as any, + attributes: { + ...defaultAttributes, + ...attributes, + } as Record, + children, + route, + meta: meta || {}, + }); + + if (output !== undefined) { + // If we got a valid output from the renderElement function, we return it + // `null` we will render nothing, but `undefined` fallback to the default element + return output; + } } - } - return React.createElement( - element.tag, - htmlAttributes[element.tag] - ? { ...defaultAttributes, ...attributes } - : attributes, - children, - ); + return React.createElement( + element.tag, + htmlAttributes[element.tag] + ? { ...defaultAttributes, ...attributes } + : attributes, + children, + ); + } + return undefined; } /** @@ -180,7 +190,7 @@ function RichTextElement({ */ export function UmbracoRichText(props: RichTextProps) { const rootElement = props.data; - if (rootElement?.tag === "#root" && rootElement.elements) { + if (isRootElement(rootElement)) { return ( <> {rootElement.elements.map((element, index) => ( diff --git a/src/types/RichTextTypes.ts b/src/types/RichTextTypes.ts index 9256d5b..830b74f 100644 --- a/src/types/RichTextTypes.ts +++ b/src/types/RichTextTypes.ts @@ -1,5 +1,44 @@ import type { Overwrite } from "../utils/helper-types"; +export function isRootElement(data: RichTextElementModel | undefined): data is { + tag: "#root"; + attributes?: Record; + elements: RichTextElementModel[]; + blocks?: Array; +} { + return !!data && data.tag === "#root"; +} + +export function isTextElement( + data: RichTextElementModel, +): data is { tag: "#text"; text: string } { + return data.tag === "#text"; +} + +export function isCommentElement( + data: RichTextElementModel, +): data is { tag: "#comment"; text: string } { + return data.tag === "#comment"; +} + +export function isUmbracoBlock(data: RichTextElementModel): data is { + tag: string; + attributes: { + "content-id": string; + }; + elements: RichTextElementModel[]; +} { + return data.tag === "umb-rte-block" || data.tag === "umb-rte-block-inline"; +} + +export function isHtmlElement(data: RichTextElementModel): data is { + tag: keyof HTMLElementTagNameMap; + attributes: Record & { route?: RouteAttributes }; + elements: RichTextElementModel[]; +} { + return "elements" in data; +} + interface BaseBlockItemModel { content?: { id: string; @@ -67,14 +106,7 @@ export type RichTextElementModel = text: string; } | { - tag: "umb-rte-block"; - attributes: { - "content-id": string; - }; - elements: RichTextElementModel[]; - } - | { - tag: "umb-rte-block-inline"; + tag: "umb-rte-block" | "umb-rte-block-inline"; attributes: { "content-id": string; }; @@ -84,4 +116,10 @@ export type RichTextElementModel = tag: keyof HTMLElementTagNameMap; attributes: Record & { route?: RouteAttributes }; elements?: RichTextElementModel[]; + } + | { + tag: string; + attributes?: Record & { route?: RouteAttributes }; + elements?: RichTextElementModel[]; + blocks?: Array; }; diff --git a/src/utils/rich-text-converter.ts b/src/utils/rich-text-converter.ts index 1bd1419..935c674 100644 --- a/src/utils/rich-text-converter.ts +++ b/src/utils/rich-text-converter.ts @@ -1,5 +1,10 @@ import { decode } from "html-entities"; -import type { RichTextElementModel } from "../types/RichTextTypes"; +import { + type RichTextElementModel, + isCommentElement, + isHtmlElement, + isTextElement, +} from "../types/RichTextTypes"; const arrayContentLength = (arr: string[]) => arr.join("").length; @@ -12,7 +17,7 @@ function iterateRichText( options: Options, ) { // Iterate over the elements in the rich text, and find all `#text` elements - if (data.tag === "#text") { + if (isTextElement(data)) { // Decode the text and remove any extra whitespace/line breaks const decodedText = decode(data.text).trim().replace(/\s+/g, " "); // If the text is the first element, or the first character is a special character, don't add a space @@ -24,11 +29,11 @@ function iterateRichText( return acc; } - if (data.tag === "#comment" || options.ignoreTags?.includes(data.tag)) { + if (isCommentElement(data) || options.ignoreTags?.includes(data.tag)) { return acc; } - if (data.elements) { + if (isHtmlElement(data)) { for (let i = 0; i < data.elements.length; i++) { iterateRichText(data.elements[i], acc, options); if (options.maxLength && arrayContentLength(acc) >= options.maxLength) { @@ -55,7 +60,7 @@ function findElement( return undefined; } - if (data.elements) { + if (isHtmlElement(data)) { for (let i = 0; i < data.elements.length; i++) { const result = findElement(data.elements[i], tag); if (result) { From d8e00dbaccb956c0d0df24f2161e663a781da44c Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Fri, 8 Nov 2024 11:27:39 +0100 Subject: [PATCH 4/7] fix: correct types --- src/UmbracoRichText.tsx | 36 +++++++++++++++++--------------- src/types/RichTextTypes.ts | 18 +++++++++++++--- src/utils/rich-text-converter.ts | 15 +++++++------ 3 files changed, 43 insertions(+), 26 deletions(-) diff --git a/src/UmbracoRichText.tsx b/src/UmbracoRichText.tsx index cbba3d1..92abe4b 100644 --- a/src/UmbracoRichText.tsx +++ b/src/UmbracoRichText.tsx @@ -112,23 +112,25 @@ function RichTextElement({ return null; } - + let children: Array | undefined = undefined; if (isHtmlElement(element)) { - const children: React.ReactNode = - element.elements.map((node, index) => ( - - )) || null; + children = element.elements?.map((node, index) => ( + + )); + if (children?.length === 0) { + children = undefined; + } const { route, @@ -193,7 +195,7 @@ export function UmbracoRichText(props: RichTextProps) { if (isRootElement(rootElement)) { return ( <> - {rootElement.elements.map((element, index) => ( + {rootElement.elements?.map((element, index) => ( ; - elements: RichTextElementModel[]; + elements?: RichTextElementModel[]; blocks?: Array; } { return !!data && data.tag === "#root"; @@ -21,6 +21,12 @@ export function isCommentElement( return data.tag === "#comment"; } +export function hasElements( + data: RichTextElementModel, +): data is { tag: string; elements: RichTextElementModel[] } { + return "elements" in data; +} + export function isUmbracoBlock(data: RichTextElementModel): data is { tag: string; attributes: { @@ -34,9 +40,15 @@ export function isUmbracoBlock(data: RichTextElementModel): data is { export function isHtmlElement(data: RichTextElementModel): data is { tag: keyof HTMLElementTagNameMap; attributes: Record & { route?: RouteAttributes }; - elements: RichTextElementModel[]; + elements?: RichTextElementModel[]; } { - return "elements" in data; + return ( + "attributes" in data && + "tag" in data && + data.tag !== "#text" && + data.tag !== "#comment" && + data.tag !== "#root" + ); } interface BaseBlockItemModel { diff --git a/src/utils/rich-text-converter.ts b/src/utils/rich-text-converter.ts index 935c674..04b9d71 100644 --- a/src/utils/rich-text-converter.ts +++ b/src/utils/rich-text-converter.ts @@ -1,8 +1,8 @@ import { decode } from "html-entities"; import { type RichTextElementModel, + hasElements, isCommentElement, - isHtmlElement, isTextElement, } from "../types/RichTextTypes"; @@ -29,11 +29,14 @@ function iterateRichText( return acc; } - if (isCommentElement(data) || options.ignoreTags?.includes(data.tag)) { + if ( + isCommentElement(data) || + options.ignoreTags?.includes(data.tag as keyof HTMLElementTagNameMap) + ) { return acc; } - if (isHtmlElement(data)) { + if (hasElements(data)) { for (let i = 0; i < data.elements.length; i++) { iterateRichText(data.elements[i], acc, options); if (options.maxLength && arrayContentLength(acc) >= options.maxLength) { @@ -49,7 +52,7 @@ function iterateRichText( */ function findElement( data: RichTextElementModel, - tag: RichTextElementModel["tag"], + tag: string, ): RichTextElementModel | undefined { if (data.tag === tag) { // Ensure the element is or has text content before returning it. @@ -60,7 +63,7 @@ function findElement( return undefined; } - if (isHtmlElement(data)) { + if (hasElements(data)) { for (let i = 0; i < data.elements.length; i++) { const result = findElement(data.elements[i], tag); if (result) { @@ -74,7 +77,7 @@ function findElement( type Options = { firstParagraph?: boolean; maxLength?: number; - ignoreTags?: Array; + ignoreTags?: Array; }; /** From cde714850354c50b63adf61a95889e202878a51c Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Fri, 8 Nov 2024 12:01:55 +0100 Subject: [PATCH 5/7] fix: adjust comments --- src/types/RichTextTypes.ts | 35 ++++++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/src/types/RichTextTypes.ts b/src/types/RichTextTypes.ts index d7fadba..3e5ca4a 100644 --- a/src/types/RichTextTypes.ts +++ b/src/types/RichTextTypes.ts @@ -1,5 +1,8 @@ import type { Overwrite } from "../utils/helper-types"; +/** + * Type guard to check if the data is the root element. + */ export function isRootElement(data: RichTextElementModel | undefined): data is { tag: "#root"; attributes?: Record; @@ -9,24 +12,36 @@ export function isRootElement(data: RichTextElementModel | undefined): data is { return !!data && data.tag === "#root"; } +/** + * Type guard to check if the data is a text element. + */ export function isTextElement( data: RichTextElementModel, ): data is { tag: "#text"; text: string } { return data.tag === "#text"; } +/** + * Type guard to check if the data is a comment element. + */ export function isCommentElement( data: RichTextElementModel, ): data is { tag: "#comment"; text: string } { return data.tag === "#comment"; } +/** + * Type guard to check if the data has elements. Some HTML elements, like `` won't have elements. + */ export function hasElements( data: RichTextElementModel, ): data is { tag: string; elements: RichTextElementModel[] } { return "elements" in data; } +/** + * Type guard to check if the data is an Umbraco block element. Either block or inline block. + */ export function isUmbracoBlock(data: RichTextElementModel): data is { tag: string; attributes: { @@ -37,17 +52,21 @@ export function isUmbracoBlock(data: RichTextElementModel): data is { return data.tag === "umb-rte-block" || data.tag === "umb-rte-block-inline"; } +/** + * Type guard to check if the data is an HTML element. + * If data doesn't match of the other known elements, assume it's an HTML element. + */ export function isHtmlElement(data: RichTextElementModel): data is { tag: keyof HTMLElementTagNameMap; attributes: Record & { route?: RouteAttributes }; elements?: RichTextElementModel[]; } { return ( - "attributes" in data && "tag" in data && - data.tag !== "#text" && - data.tag !== "#comment" && - data.tag !== "#root" + !isTextElement(data) && + !isCommentElement(data) && + !isRootElement(data) && + !isUmbracoBlock(data) ); } @@ -86,6 +105,9 @@ export interface UmbracoBlockItemModel { _overrideImplementation?: never; } +/** + * The merged block context with the Umbraco block item model, and the user-defined block item model. + */ export type UmbracoBlockContext = Overwrite< BaseBlockItemModel, Omit @@ -129,9 +151,12 @@ export type RichTextElementModel = attributes: Record & { route?: RouteAttributes }; elements?: RichTextElementModel[]; } + // Fall back to a generic object, so we can handle the basic generated structure provided by the Umbraco OpenAPI spec. + // It only includes `tag: string` in the definition. | { tag: string; - attributes?: Record & { route?: RouteAttributes }; + text?: string; + attributes?: Record; elements?: RichTextElementModel[]; blocks?: Array; }; From 9ff95e3982997e23d2b9ec3bdb51ad37b37aff7c Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Fri, 8 Nov 2024 12:31:57 +0100 Subject: [PATCH 6/7] ci: publish compact pkg --- .github/workflows/pkg-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pkg-pr.yml b/.github/workflows/pkg-pr.yml index 4f32a7e..0e66ab6 100644 --- a/.github/workflows/pkg-pr.yml +++ b/.github/workflows/pkg-pr.yml @@ -17,4 +17,4 @@ jobs: - name: Build run: pnpm build - name: Publish preview package - run: pnpx pkg-pr-new publish --no-template + run: pnpx pkg-pr-new publish --no-template --compact From 9cfe1501726d81e5f7cb3ffa68943d87929d869d Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Fri, 8 Nov 2024 12:35:14 +0100 Subject: [PATCH 7/7] fix: go back to RenderBlockContext for the name --- src/UmbracoRichText.tsx | 6 +++--- src/index.ts | 2 +- src/types/RichTextTypes.ts | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/UmbracoRichText.tsx b/src/UmbracoRichText.tsx index 92abe4b..530ccea 100644 --- a/src/UmbracoRichText.tsx +++ b/src/UmbracoRichText.tsx @@ -1,9 +1,9 @@ import { decode } from "html-entities"; import React from "react"; import { + type RenderBlockContext, type RichTextElementModel, type RouteAttributes, - type UmbracoBlockContext, isHtmlElement, isRootElement, isTextElement, @@ -44,7 +44,7 @@ export type RenderNodeContext = { interface RichTextProps { data: RichTextElementModel | undefined; - renderBlock?: (block: UmbracoBlockContext) => React.ReactNode; + renderBlock?: (block: RenderBlockContext) => React.ReactNode; /** * Render an HTML node with custom logic. * @param node @@ -79,7 +79,7 @@ function RichTextElement({ meta, }: { element: RichTextElementModel; - blocks: Array | undefined; + blocks: Array | undefined; meta: | { ancestor?: string; diff --git a/src/index.ts b/src/index.ts index c5e18b2..f4671e8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,5 +3,5 @@ export { richTextToPlainText } from "./utils/rich-text-converter"; export type { RenderNodeContext } from "./UmbracoRichText"; export type { UmbracoBlockItemModel, - UmbracoBlockContext, + RenderBlockContext, } from "./types/RichTextTypes"; diff --git a/src/types/RichTextTypes.ts b/src/types/RichTextTypes.ts index 3e5ca4a..0717163 100644 --- a/src/types/RichTextTypes.ts +++ b/src/types/RichTextTypes.ts @@ -7,7 +7,7 @@ export function isRootElement(data: RichTextElementModel | undefined): data is { tag: "#root"; attributes?: Record; elements?: RichTextElementModel[]; - blocks?: Array; + blocks?: Array; } { return !!data && data.tag === "#root"; } @@ -108,7 +108,7 @@ export interface UmbracoBlockItemModel { /** * The merged block context with the Umbraco block item model, and the user-defined block item model. */ -export type UmbracoBlockContext = Overwrite< +export type RenderBlockContext = Overwrite< BaseBlockItemModel, Omit >; @@ -129,7 +129,7 @@ export type RichTextElementModel = tag: "#root"; attributes?: Record; elements?: RichTextElementModel[]; - blocks?: Array; + blocks?: Array; } | { tag: "#text"; @@ -158,5 +158,5 @@ export type RichTextElementModel = text?: string; attributes?: Record; elements?: RichTextElementModel[]; - blocks?: Array; + blocks?: Array; };