pte-interpolation-react 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,161 @@
1
+ # pte-interpolation-react
2
+
3
+ [![npm version](https://img.shields.io/npm/v/pte-interpolation-react.svg?style=flat-square)](https://www.npmjs.com/package/pte-interpolation-react)
4
+
5
+ React rendering library that resolves dynamic variable inline blocks in [Portable Text](https://portabletext.org/) to actual values. Wraps [`@portabletext/react`](https://github.com/portabletext/react-portabletext) with automatic variable substitution.
6
+
7
+ Part of [sanity-pte-interpolation](https://github.com/jordanl17/sanity-pte-interpolation). For adding variable picker inline blocks to Sanity Studio, see [`sanity-plugin-pte-interpolation`](https://www.npmjs.com/package/sanity-plugin-pte-interpolation).
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ npm install pte-interpolation-react @portabletext/react
13
+ ```
14
+
15
+ ### Peer dependencies
16
+
17
+ - `react` ^18.0.0 || ^19.0.0
18
+
19
+ ## Usage
20
+
21
+ ### Drop-in component (recommended)
22
+
23
+ `<InterpolatedPortableText>` is a drop-in replacement for `<PortableText>` that handles variable resolution automatically:
24
+
25
+ ```tsx
26
+ import {InterpolatedPortableText} from 'pte-interpolation-react'
27
+
28
+ function EmailPreview({body, recipient}) {
29
+ return (
30
+ <InterpolatedPortableText
31
+ value={body}
32
+ interpolationValues={{
33
+ firstName: recipient.firstName,
34
+ lastName: recipient.lastName,
35
+ email: recipient.email,
36
+ }}
37
+ />
38
+ )
39
+ }
40
+ ```
41
+
42
+ Variables render as `<span data-variable-key="firstName">Jo</span>`, making them easy to target with CSS for styling or highlighting.
43
+
44
+ ### With custom Portable Text components
45
+
46
+ Pass your own `components` prop and they will be merged with the interpolation types:
47
+
48
+ ```tsx
49
+ <InterpolatedPortableText
50
+ value={body}
51
+ interpolationValues={values}
52
+ components={{
53
+ marks: {
54
+ link: ({children, value}) => <a href={value.href}>{children}</a>,
55
+ },
56
+ }}
57
+ />
58
+ ```
59
+
60
+ ### Custom fallback for missing values
61
+
62
+ By default, unresolved variables render as `{variableKey}` (e.g. `{firstName}`). Provide a `fallback` function to customise this:
63
+
64
+ ```tsx
65
+ <InterpolatedPortableText
66
+ value={body}
67
+ interpolationValues={values}
68
+ fallback={(variableKey) => `[missing: ${variableKey}]`}
69
+ />
70
+ ```
71
+
72
+ ### Low-level API
73
+
74
+ If you need full control over component merging, use `createInterpolationComponents` directly with `<PortableText>` from `@portabletext/react`:
75
+
76
+ ```tsx
77
+ import {useMemo} from 'react'
78
+ import {PortableText} from '@portabletext/react'
79
+ import {createInterpolationComponents} from 'pte-interpolation-react'
80
+
81
+ function EmailPreview({body, values, customComponents}) {
82
+ const components = useMemo(() => {
83
+ const interpolation = createInterpolationComponents(values)
84
+ return {
85
+ ...customComponents,
86
+ types: {
87
+ ...customComponents.types,
88
+ ...interpolation.types,
89
+ },
90
+ }
91
+ }, [values, customComponents])
92
+
93
+ return <PortableText value={body} components={components} />
94
+ }
95
+ ```
96
+
97
+ ## Authoring Variables in Sanity Studio
98
+
99
+ This package handles the **rendering** side. To add the variable picker to Sanity Studio's Portable Text Editor, use [`sanity-plugin-pte-interpolation`](https://www.npmjs.com/package/sanity-plugin-pte-interpolation):
100
+
101
+ ```ts
102
+ import {interpolationVariables} from 'sanity-plugin-pte-interpolation'
103
+
104
+ defineField({
105
+ name: 'body',
106
+ type: 'array',
107
+ of: [
108
+ interpolationVariables([
109
+ {id: 'firstName', name: 'First name'},
110
+ {id: 'email', name: 'Email address'},
111
+ ]),
112
+ ],
113
+ })
114
+ ```
115
+
116
+ ## Data Shape
117
+
118
+ Variable blocks in stored Portable Text look like this:
119
+
120
+ ```json
121
+ {
122
+ "_type": "block",
123
+ "children": [
124
+ {"_type": "span", "text": "Hello, "},
125
+ {"_type": "pteInterpolationVariable", "variableKey": "firstName"},
126
+ {"_type": "span", "text": "!"}
127
+ ]
128
+ }
129
+ ```
130
+
131
+ The `variableKey` maps to the `id` defined in the Studio variable definitions and the keys in the `interpolationValues` record.
132
+
133
+ ## API Reference
134
+
135
+ ### `<InterpolatedPortableText>`
136
+
137
+ | Prop | Type | Description |
138
+ | --------------------- | --------------------------------- | -------------------------------------------------------------------------- |
139
+ | `value` | `PortableTextBlock[]` | Portable Text content from Sanity |
140
+ | `interpolationValues` | `Record<string, string>` | Map of variable IDs to their resolved values |
141
+ | `components` | `PortableTextComponents` | Optional custom Portable Text components (merged with interpolation types) |
142
+ | `fallback` | `(variableKey: string) => string` | Optional function for unresolved variables (defaults to `{variableKey}`) |
143
+
144
+ Also accepts all other props from `@portabletext/react`'s `<PortableText>`.
145
+
146
+ ### `createInterpolationComponents(values, fallback?)`
147
+
148
+ Returns a `PortableTextComponents` object with a `types.pteInterpolationVariable` renderer. Use this when you need manual control over component merging.
149
+
150
+ | Parameter | Type | Description |
151
+ | ---------- | --------------------------------- | ------------------------------------------ |
152
+ | `values` | `Record<string, string>` | Map of variable IDs to values |
153
+ | `fallback` | `(variableKey: string) => string` | Optional fallback for unresolved variables |
154
+
155
+ ### `VARIABLE_TYPE_PREFIX`
156
+
157
+ The constant `'pteInterpolationVariable'` — the `_type` string used for variable inline blocks. Exported for advanced use cases.
158
+
159
+ ## License
160
+
161
+ MIT
package/dist/index.cjs CHANGED
@@ -1,8 +1,40 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: !0 });
3
- var jsxRuntime = require("react/jsx-runtime");
4
- function InterpolatedText({ blocks, values }) {
5
- return /* @__PURE__ */ jsxRuntime.jsx("pre", { children: /* @__PURE__ */ jsxRuntime.jsx("code", { children: JSON.stringify({ blocks, values }, null, 2) }) });
3
+ var jsxRuntime = require("react/jsx-runtime"), react$1 = require("@portabletext/react"), react = require("react");
4
+ const VARIABLE_TYPE_PREFIX = "pteInterpolationVariable";
5
+ function defaultFallback(variableKey) {
6
+ return `{${variableKey}}`;
6
7
  }
7
- exports.InterpolatedText = InterpolatedText;
8
+ function createInterpolationComponents(values, fallback = defaultFallback) {
9
+ function VariableComponent(props) {
10
+ const { variableKey } = props.value, resolvedValue = values[variableKey] !== void 0 ? values[variableKey] : fallback(variableKey);
11
+ return /* @__PURE__ */ jsxRuntime.jsx("span", { "data-variable-key": variableKey, children: resolvedValue });
12
+ }
13
+ return {
14
+ types: {
15
+ [VARIABLE_TYPE_PREFIX]: VariableComponent
16
+ }
17
+ };
18
+ }
19
+ function InterpolatedPortableText({
20
+ interpolationValues,
21
+ components: userComponents,
22
+ fallback,
23
+ ...rest
24
+ }) {
25
+ const mergedComponents = react.useMemo(() => {
26
+ const interpolationComponents = createInterpolationComponents(interpolationValues, fallback);
27
+ return userComponents ? {
28
+ ...userComponents,
29
+ types: {
30
+ ...userComponents.types,
31
+ ...interpolationComponents.types
32
+ }
33
+ } : interpolationComponents;
34
+ }, [interpolationValues, fallback, userComponents]);
35
+ return /* @__PURE__ */ jsxRuntime.jsx(react$1.PortableText, { ...rest, components: mergedComponents });
36
+ }
37
+ exports.InterpolatedPortableText = InterpolatedPortableText;
38
+ exports.VARIABLE_TYPE_PREFIX = VARIABLE_TYPE_PREFIX;
39
+ exports.createInterpolationComponents = createInterpolationComponents;
8
40
  //# sourceMappingURL=index.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.cjs","sources":["../src/InterpolatedText.tsx"],"sourcesContent":["import type {InterpolatedTextProps} from './types'\n\n/** @public */\nexport function InterpolatedText({blocks, values}: InterpolatedTextProps) {\n return (\n <pre>\n <code>{JSON.stringify({blocks, values}, null, 2)}</code>\n </pre>\n )\n}\n"],"names":["jsx"],"mappings":";;;AAGO,SAAS,iBAAiB,EAAC,QAAQ,UAAgC;AACxE,SACEA,2BAAAA,IAAC,OAAA,EACC,UAAAA,2BAAAA,IAAC,QAAA,EAAM,UAAA,KAAK,UAAU,EAAC,QAAQ,OAAA,GAAS,MAAM,CAAC,GAAE,GACnD;AAEJ;;"}
1
+ {"version":3,"file":"index.cjs","sources":["../src/constants.ts","../src/createInterpolationComponents.tsx","../src/InterpolatedPortableText.tsx"],"sourcesContent":["/** @public */\nexport const VARIABLE_TYPE_PREFIX = 'pteInterpolationVariable'\n","import type {PortableTextComponents, PortableTextTypeComponentProps} from '@portabletext/react'\n\nimport {VARIABLE_TYPE_PREFIX} from './constants'\nimport type {\n InterpolationFallback,\n InterpolationValues,\n PteInterpolationVariableBlock,\n} from './types'\n\nfunction defaultFallback(variableKey: string): string {\n return `{${variableKey}}`\n}\n\n/** @public */\nexport function createInterpolationComponents(\n values: InterpolationValues,\n fallback: InterpolationFallback = defaultFallback,\n): PortableTextComponents {\n function VariableComponent(props: PortableTextTypeComponentProps<PteInterpolationVariableBlock>) {\n const {variableKey} = props.value\n const resolvedValue =\n values[variableKey] !== undefined ? values[variableKey] : fallback(variableKey)\n\n return <span data-variable-key={variableKey}>{resolvedValue}</span>\n }\n\n return {\n types: {\n [VARIABLE_TYPE_PREFIX]: VariableComponent,\n },\n }\n}\n","import {PortableText} from '@portabletext/react'\nimport {useMemo} from 'react'\n\nimport {createInterpolationComponents} from './createInterpolationComponents'\nimport type {InterpolatedPortableTextProps} from './types'\n\n/** @public */\nexport function InterpolatedPortableText({\n interpolationValues,\n components: userComponents,\n fallback,\n ...rest\n}: InterpolatedPortableTextProps) {\n const mergedComponents = useMemo(() => {\n const interpolationComponents = createInterpolationComponents(interpolationValues, fallback)\n\n if (!userComponents) {\n return interpolationComponents\n }\n\n return {\n ...userComponents,\n types: {\n ...userComponents.types,\n ...interpolationComponents.types,\n },\n }\n }, [interpolationValues, fallback, userComponents])\n\n return <PortableText {...rest} components={mergedComponents} />\n}\n"],"names":["jsx","useMemo","PortableText"],"mappings":";;;AACO,MAAM,uBAAuB;ACQpC,SAAS,gBAAgB,aAA6B;AACpD,SAAO,IAAI,WAAW;AACxB;AAGO,SAAS,8BACd,QACA,WAAkC,iBACV;AACxB,WAAS,kBAAkB,OAAsE;AAC/F,UAAM,EAAC,YAAA,IAAe,MAAM,OACtB,gBACJ,OAAO,WAAW,MAAM,SAAY,OAAO,WAAW,IAAI,SAAS,WAAW;AAEhF,WAAOA,2BAAAA,IAAC,QAAA,EAAK,qBAAmB,aAAc,UAAA,eAAc;AAAA,EAC9D;AAEA,SAAO;AAAA,IACL,OAAO;AAAA,MACL,CAAC,oBAAoB,GAAG;AAAA,IAAA;AAAA,EAC1B;AAEJ;ACxBO,SAAS,yBAAyB;AAAA,EACvC;AAAA,EACA,YAAY;AAAA,EACZ;AAAA,EACA,GAAG;AACL,GAAkC;AAChC,QAAM,mBAAmBC,MAAAA,QAAQ,MAAM;AACrC,UAAM,0BAA0B,8BAA8B,qBAAqB,QAAQ;AAE3F,WAAK,iBAIE;AAAA,MACL,GAAG;AAAA,MACH,OAAO;AAAA,QACL,GAAG,eAAe;AAAA,QAClB,GAAG,wBAAwB;AAAA,MAAA;AAAA,IAC7B,IARO;AAAA,EAUX,GAAG,CAAC,qBAAqB,UAAU,cAAc,CAAC;AAElD,SAAOD,2BAAAA,IAACE,QAAAA,cAAA,EAAc,GAAG,MAAM,YAAY,kBAAkB;AAC/D;;;;"}
package/dist/index.d.cts CHANGED
@@ -1,16 +1,45 @@
1
1
  import {JSX} from 'react/jsx-runtime'
2
- import type {PortableTextBlock} from '@portabletext/react'
2
+ import type {PortableTextComponents} from '@portabletext/react'
3
+ import type {PortableTextProps} from '@portabletext/react'
3
4
 
4
5
  /** @public */
5
- export declare function InterpolatedText({blocks, values}: InterpolatedTextProps): JSX.Element
6
+ export declare function createInterpolationComponents(
7
+ values: InterpolationValues,
8
+ fallback?: InterpolationFallback,
9
+ ): PortableTextComponents
6
10
 
7
11
  /** @public */
8
- export declare interface InterpolatedTextProps {
9
- blocks: PortableTextBlock[]
10
- values: InterpolationValues
12
+ export declare function InterpolatedPortableText({
13
+ interpolationValues,
14
+ components: userComponents,
15
+ fallback,
16
+ ...rest
17
+ }: InterpolatedPortableTextProps): JSX.Element
18
+
19
+ /** @public */
20
+ export declare interface InterpolatedPortableTextProps extends Omit<
21
+ PortableTextProps,
22
+ 'components'
23
+ > {
24
+ interpolationValues: InterpolationValues
25
+ components?: PortableTextComponents
26
+ fallback?: InterpolationFallback
11
27
  }
12
28
 
29
+ /** @public */
30
+ export declare type InterpolationFallback = (variableKey: string) => string
31
+
13
32
  /** @public */
14
33
  export declare type InterpolationValues = Record<string, string>
15
34
 
35
+ /** @public */
36
+ export declare interface PteInterpolationVariableBlock {
37
+ _type: 'pteInterpolationVariable'
38
+ _key: string
39
+ variableKey: string
40
+ }
41
+
42
+ /** @public */
43
+ export declare const VARIABLE_TYPE_PREFIX = 'pteInterpolationVariable'
44
+
16
45
  export {}
package/dist/index.d.ts CHANGED
@@ -1,16 +1,45 @@
1
1
  import {JSX} from 'react/jsx-runtime'
2
- import type {PortableTextBlock} from '@portabletext/react'
2
+ import type {PortableTextComponents} from '@portabletext/react'
3
+ import type {PortableTextProps} from '@portabletext/react'
3
4
 
4
5
  /** @public */
5
- export declare function InterpolatedText({blocks, values}: InterpolatedTextProps): JSX.Element
6
+ export declare function createInterpolationComponents(
7
+ values: InterpolationValues,
8
+ fallback?: InterpolationFallback,
9
+ ): PortableTextComponents
6
10
 
7
11
  /** @public */
8
- export declare interface InterpolatedTextProps {
9
- blocks: PortableTextBlock[]
10
- values: InterpolationValues
12
+ export declare function InterpolatedPortableText({
13
+ interpolationValues,
14
+ components: userComponents,
15
+ fallback,
16
+ ...rest
17
+ }: InterpolatedPortableTextProps): JSX.Element
18
+
19
+ /** @public */
20
+ export declare interface InterpolatedPortableTextProps extends Omit<
21
+ PortableTextProps,
22
+ 'components'
23
+ > {
24
+ interpolationValues: InterpolationValues
25
+ components?: PortableTextComponents
26
+ fallback?: InterpolationFallback
11
27
  }
12
28
 
29
+ /** @public */
30
+ export declare type InterpolationFallback = (variableKey: string) => string
31
+
13
32
  /** @public */
14
33
  export declare type InterpolationValues = Record<string, string>
15
34
 
35
+ /** @public */
36
+ export declare interface PteInterpolationVariableBlock {
37
+ _type: 'pteInterpolationVariable'
38
+ _key: string
39
+ variableKey: string
40
+ }
41
+
42
+ /** @public */
43
+ export declare const VARIABLE_TYPE_PREFIX = 'pteInterpolationVariable'
44
+
16
45
  export {}
package/dist/index.js CHANGED
@@ -1,8 +1,42 @@
1
1
  import { jsx } from "react/jsx-runtime";
2
- function InterpolatedText({ blocks, values }) {
3
- return /* @__PURE__ */ jsx("pre", { children: /* @__PURE__ */ jsx("code", { children: JSON.stringify({ blocks, values }, null, 2) }) });
2
+ import { PortableText } from "@portabletext/react";
3
+ import { useMemo } from "react";
4
+ const VARIABLE_TYPE_PREFIX = "pteInterpolationVariable";
5
+ function defaultFallback(variableKey) {
6
+ return `{${variableKey}}`;
7
+ }
8
+ function createInterpolationComponents(values, fallback = defaultFallback) {
9
+ function VariableComponent(props) {
10
+ const { variableKey } = props.value, resolvedValue = values[variableKey] !== void 0 ? values[variableKey] : fallback(variableKey);
11
+ return /* @__PURE__ */ jsx("span", { "data-variable-key": variableKey, children: resolvedValue });
12
+ }
13
+ return {
14
+ types: {
15
+ [VARIABLE_TYPE_PREFIX]: VariableComponent
16
+ }
17
+ };
18
+ }
19
+ function InterpolatedPortableText({
20
+ interpolationValues,
21
+ components: userComponents,
22
+ fallback,
23
+ ...rest
24
+ }) {
25
+ const mergedComponents = useMemo(() => {
26
+ const interpolationComponents = createInterpolationComponents(interpolationValues, fallback);
27
+ return userComponents ? {
28
+ ...userComponents,
29
+ types: {
30
+ ...userComponents.types,
31
+ ...interpolationComponents.types
32
+ }
33
+ } : interpolationComponents;
34
+ }, [interpolationValues, fallback, userComponents]);
35
+ return /* @__PURE__ */ jsx(PortableText, { ...rest, components: mergedComponents });
4
36
  }
5
37
  export {
6
- InterpolatedText
38
+ InterpolatedPortableText,
39
+ VARIABLE_TYPE_PREFIX,
40
+ createInterpolationComponents
7
41
  };
8
42
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sources":["../src/InterpolatedText.tsx"],"sourcesContent":["import type {InterpolatedTextProps} from './types'\n\n/** @public */\nexport function InterpolatedText({blocks, values}: InterpolatedTextProps) {\n return (\n <pre>\n <code>{JSON.stringify({blocks, values}, null, 2)}</code>\n </pre>\n )\n}\n"],"names":[],"mappings":";AAGO,SAAS,iBAAiB,EAAC,QAAQ,UAAgC;AACxE,SACE,oBAAC,OAAA,EACC,UAAA,oBAAC,QAAA,EAAM,UAAA,KAAK,UAAU,EAAC,QAAQ,OAAA,GAAS,MAAM,CAAC,GAAE,GACnD;AAEJ;"}
1
+ {"version":3,"file":"index.js","sources":["../src/constants.ts","../src/createInterpolationComponents.tsx","../src/InterpolatedPortableText.tsx"],"sourcesContent":["/** @public */\nexport const VARIABLE_TYPE_PREFIX = 'pteInterpolationVariable'\n","import type {PortableTextComponents, PortableTextTypeComponentProps} from '@portabletext/react'\n\nimport {VARIABLE_TYPE_PREFIX} from './constants'\nimport type {\n InterpolationFallback,\n InterpolationValues,\n PteInterpolationVariableBlock,\n} from './types'\n\nfunction defaultFallback(variableKey: string): string {\n return `{${variableKey}}`\n}\n\n/** @public */\nexport function createInterpolationComponents(\n values: InterpolationValues,\n fallback: InterpolationFallback = defaultFallback,\n): PortableTextComponents {\n function VariableComponent(props: PortableTextTypeComponentProps<PteInterpolationVariableBlock>) {\n const {variableKey} = props.value\n const resolvedValue =\n values[variableKey] !== undefined ? values[variableKey] : fallback(variableKey)\n\n return <span data-variable-key={variableKey}>{resolvedValue}</span>\n }\n\n return {\n types: {\n [VARIABLE_TYPE_PREFIX]: VariableComponent,\n },\n }\n}\n","import {PortableText} from '@portabletext/react'\nimport {useMemo} from 'react'\n\nimport {createInterpolationComponents} from './createInterpolationComponents'\nimport type {InterpolatedPortableTextProps} from './types'\n\n/** @public */\nexport function InterpolatedPortableText({\n interpolationValues,\n components: userComponents,\n fallback,\n ...rest\n}: InterpolatedPortableTextProps) {\n const mergedComponents = useMemo(() => {\n const interpolationComponents = createInterpolationComponents(interpolationValues, fallback)\n\n if (!userComponents) {\n return interpolationComponents\n }\n\n return {\n ...userComponents,\n types: {\n ...userComponents.types,\n ...interpolationComponents.types,\n },\n }\n }, [interpolationValues, fallback, userComponents])\n\n return <PortableText {...rest} components={mergedComponents} />\n}\n"],"names":[],"mappings":";;;AACO,MAAM,uBAAuB;ACQpC,SAAS,gBAAgB,aAA6B;AACpD,SAAO,IAAI,WAAW;AACxB;AAGO,SAAS,8BACd,QACA,WAAkC,iBACV;AACxB,WAAS,kBAAkB,OAAsE;AAC/F,UAAM,EAAC,YAAA,IAAe,MAAM,OACtB,gBACJ,OAAO,WAAW,MAAM,SAAY,OAAO,WAAW,IAAI,SAAS,WAAW;AAEhF,WAAO,oBAAC,QAAA,EAAK,qBAAmB,aAAc,UAAA,eAAc;AAAA,EAC9D;AAEA,SAAO;AAAA,IACL,OAAO;AAAA,MACL,CAAC,oBAAoB,GAAG;AAAA,IAAA;AAAA,EAC1B;AAEJ;ACxBO,SAAS,yBAAyB;AAAA,EACvC;AAAA,EACA,YAAY;AAAA,EACZ;AAAA,EACA,GAAG;AACL,GAAkC;AAChC,QAAM,mBAAmB,QAAQ,MAAM;AACrC,UAAM,0BAA0B,8BAA8B,qBAAqB,QAAQ;AAE3F,WAAK,iBAIE;AAAA,MACL,GAAG;AAAA,MACH,OAAO;AAAA,QACL,GAAG,eAAe;AAAA,QAClB,GAAG,wBAAwB;AAAA,MAAA;AAAA,IAC7B,IARO;AAAA,EAUX,GAAG,CAAC,qBAAqB,UAAU,cAAc,CAAC;AAElD,SAAO,oBAAC,cAAA,EAAc,GAAG,MAAM,YAAY,kBAAkB;AAC/D;"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pte-interpolation-react",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "React components for rendering Portable Text with interpolated values",
@@ -47,6 +47,10 @@
47
47
  },
48
48
  "devDependencies": {
49
49
  "@sanity/pkg-utils": "^10.4.4",
50
+ "@types/react": "^19.2.14",
51
+ "@types/react-dom": "^19.2.3",
52
+ "react": "^19.2.4",
53
+ "react-dom": "^19.2.4",
50
54
  "rimraf": "^6.1.3",
51
55
  "typescript": "~5.9.3"
52
56
  },
@@ -0,0 +1,31 @@
1
+ import {PortableText} from '@portabletext/react'
2
+ import {useMemo} from 'react'
3
+
4
+ import {createInterpolationComponents} from './createInterpolationComponents'
5
+ import type {InterpolatedPortableTextProps} from './types'
6
+
7
+ /** @public */
8
+ export function InterpolatedPortableText({
9
+ interpolationValues,
10
+ components: userComponents,
11
+ fallback,
12
+ ...rest
13
+ }: InterpolatedPortableTextProps) {
14
+ const mergedComponents = useMemo(() => {
15
+ const interpolationComponents = createInterpolationComponents(interpolationValues, fallback)
16
+
17
+ if (!userComponents) {
18
+ return interpolationComponents
19
+ }
20
+
21
+ return {
22
+ ...userComponents,
23
+ types: {
24
+ ...userComponents.types,
25
+ ...interpolationComponents.types,
26
+ },
27
+ }
28
+ }, [interpolationValues, fallback, userComponents])
29
+
30
+ return <PortableText {...rest} components={mergedComponents} />
31
+ }
@@ -0,0 +1,204 @@
1
+ import type {PortableTextComponents} from '@portabletext/react'
2
+ import {renderToStaticMarkup} from 'react-dom/server'
3
+ import {describe, expect, it} from 'vitest'
4
+
5
+ import {VARIABLE_TYPE_PREFIX} from '../constants'
6
+ import {InterpolatedPortableText} from '../InterpolatedPortableText'
7
+ import {
8
+ emptyBlocksContent,
9
+ multiBlockContent,
10
+ multipleVariablesBlock,
11
+ plainTextBlock,
12
+ singleVariableBlock,
13
+ styledTextWithVariableBlock,
14
+ } from './fixtures'
15
+
16
+ describe('InterpolatedPortableText', () => {
17
+ it('renders single variable resolved inline', () => {
18
+ const html = renderToStaticMarkup(
19
+ <InterpolatedPortableText
20
+ value={singleVariableBlock}
21
+ interpolationValues={{firstName: 'Jordan'}}
22
+ />,
23
+ )
24
+ expect(html).toContain('Hello, ')
25
+ expect(html).toContain('<span data-variable-key="firstName">Jordan</span>')
26
+ expect(html).toContain('!')
27
+ })
28
+
29
+ it('renders multiple variables in one block', () => {
30
+ const html = renderToStaticMarkup(
31
+ <InterpolatedPortableText
32
+ value={multipleVariablesBlock}
33
+ interpolationValues={{
34
+ firstName: 'Jordan',
35
+ lastName: 'Lawrence',
36
+ email: 'jordan@example.com',
37
+ }}
38
+ />,
39
+ )
40
+ expect(html).toContain('<span data-variable-key="firstName">Jordan</span>')
41
+ expect(html).toContain('<span data-variable-key="lastName">Lawrence</span>')
42
+ expect(html).toContain('<span data-variable-key="email">jordan@example.com</span>')
43
+ })
44
+
45
+ it('renders plain text unchanged', () => {
46
+ const html = renderToStaticMarkup(
47
+ <InterpolatedPortableText value={plainTextBlock} interpolationValues={{}} />,
48
+ )
49
+ expect(html).toContain('No variables here.')
50
+ })
51
+
52
+ it('renders fallback for missing values', () => {
53
+ const html = renderToStaticMarkup(
54
+ <InterpolatedPortableText value={singleVariableBlock} interpolationValues={{}} />,
55
+ )
56
+ expect(html).toContain('{firstName}')
57
+ })
58
+
59
+ it('uses custom fallback prop', () => {
60
+ const html = renderToStaticMarkup(
61
+ <InterpolatedPortableText
62
+ value={singleVariableBlock}
63
+ interpolationValues={{}}
64
+ fallback={(key) => `[${key}]`}
65
+ />,
66
+ )
67
+ expect(html).toContain('[firstName]')
68
+ })
69
+
70
+ it('preserves user components alongside interpolation types', () => {
71
+ const userComponents: PortableTextComponents = {
72
+ block: {
73
+ normal: ({children}) => <div data-testid="custom-block">{children}</div>,
74
+ },
75
+ }
76
+
77
+ const html = renderToStaticMarkup(
78
+ <InterpolatedPortableText
79
+ value={singleVariableBlock}
80
+ interpolationValues={{firstName: 'Jordan'}}
81
+ components={userComponents}
82
+ />,
83
+ )
84
+ expect(html).toContain('data-testid="custom-block"')
85
+ expect(html).toContain('<span data-variable-key="firstName">Jordan</span>')
86
+ })
87
+
88
+ it('renders variables across multiple blocks', () => {
89
+ const html = renderToStaticMarkup(
90
+ <InterpolatedPortableText
91
+ value={multiBlockContent}
92
+ interpolationValues={{
93
+ firstName: 'Jordan',
94
+ email: 'jordan@example.com',
95
+ }}
96
+ />,
97
+ )
98
+ expect(html).toContain('<span data-variable-key="firstName">Jordan</span>')
99
+ expect(html).toContain('<span data-variable-key="email">jordan@example.com</span>')
100
+ expect(html).toContain('Dear ')
101
+ expect(html).toContain('Your email is ')
102
+ })
103
+
104
+ it('interpolation types take precedence over user types with same key', () => {
105
+ const userComponents: PortableTextComponents = {
106
+ types: {
107
+ [VARIABLE_TYPE_PREFIX]: () => <span>user-override</span>,
108
+ },
109
+ }
110
+
111
+ const html = renderToStaticMarkup(
112
+ <InterpolatedPortableText
113
+ value={singleVariableBlock}
114
+ interpolationValues={{firstName: 'Jordan'}}
115
+ components={userComponents}
116
+ />,
117
+ )
118
+ expect(html).not.toContain('user-override')
119
+ expect(html).toContain('<span data-variable-key="firstName">Jordan</span>')
120
+ })
121
+
122
+ it('renders user custom types alongside interpolation types', () => {
123
+ const customWidgetBlock = [
124
+ {
125
+ _type: 'block',
126
+ _key: 'block-1',
127
+ style: 'normal' as const,
128
+ markDefs: [],
129
+ children: [
130
+ {_type: 'span', _key: 'span-1', text: 'Hello ', marks: []},
131
+ {_type: 'pteInterpolationVariable', _key: 'var-1', variableKey: 'firstName'},
132
+ ],
133
+ },
134
+ {
135
+ _type: 'customWidget',
136
+ _key: 'widget-1',
137
+ label: 'Click me',
138
+ },
139
+ ]
140
+
141
+ const userComponents: PortableTextComponents = {
142
+ types: {
143
+ customWidget: ({value}: {value: {label: string}}) => (
144
+ <button data-testid="widget">{value.label}</button>
145
+ ),
146
+ },
147
+ }
148
+
149
+ const html = renderToStaticMarkup(
150
+ <InterpolatedPortableText
151
+ value={customWidgetBlock}
152
+ interpolationValues={{firstName: 'Jordan'}}
153
+ components={userComponents}
154
+ />,
155
+ )
156
+ expect(html).toContain('data-testid="widget"')
157
+ expect(html).toContain('Click me')
158
+ expect(html).toContain('<span data-variable-key="firstName">Jordan</span>')
159
+ })
160
+
161
+ it('preserves user mark components', () => {
162
+ const userComponents: PortableTextComponents = {
163
+ marks: {
164
+ strong: ({children}) => <strong data-testid="custom-strong">{children}</strong>,
165
+ },
166
+ }
167
+
168
+ const html = renderToStaticMarkup(
169
+ <InterpolatedPortableText
170
+ value={styledTextWithVariableBlock}
171
+ interpolationValues={{firstName: 'Jordan'}}
172
+ components={userComponents}
173
+ />,
174
+ )
175
+ expect(html).toContain('data-testid="custom-strong"')
176
+ expect(html).toContain('<span data-variable-key="firstName">Jordan</span>')
177
+ })
178
+
179
+ it('renders without errors when value array is empty', () => {
180
+ const html = renderToStaticMarkup(
181
+ <InterpolatedPortableText value={emptyBlocksContent} interpolationValues={{}} />,
182
+ )
183
+ expect(html).toBe('')
184
+ })
185
+
186
+ it('default fallback renders exactly {variableKey} format', () => {
187
+ const html = renderToStaticMarkup(
188
+ <InterpolatedPortableText value={singleVariableBlock} interpolationValues={{}} />,
189
+ )
190
+ expect(html).toContain('<span data-variable-key="firstName">{firstName}</span>')
191
+ })
192
+
193
+ it('renders styled text marks alongside variables', () => {
194
+ const html = renderToStaticMarkup(
195
+ <InterpolatedPortableText
196
+ value={styledTextWithVariableBlock}
197
+ interpolationValues={{firstName: 'Jordan'}}
198
+ />,
199
+ )
200
+ expect(html).toContain('<strong>Welcome </strong>')
201
+ expect(html).toContain('<span data-variable-key="firstName">Jordan</span>')
202
+ expect(html).toContain('<em> aboard</em>')
203
+ })
204
+ })
@@ -0,0 +1,115 @@
1
+ import {PortableText} from '@portabletext/react'
2
+ import {renderToStaticMarkup} from 'react-dom/server'
3
+ import {describe, expect, it} from 'vitest'
4
+
5
+ import {VARIABLE_TYPE_PREFIX} from '../constants'
6
+ import {createInterpolationComponents} from '../createInterpolationComponents'
7
+ import {
8
+ consecutiveVariablesBlock,
9
+ multipleVariablesBlock,
10
+ singleVariableBlock,
11
+ variableOnlyBlock,
12
+ } from './fixtures'
13
+
14
+ describe('createInterpolationComponents', () => {
15
+ it('returns object with types.pteInterpolationVariable component', () => {
16
+ const components = createInterpolationComponents({firstName: 'Jordan'})
17
+ expect(components.types).toBeDefined()
18
+ expect(components.types).toHaveProperty('pteInterpolationVariable')
19
+ })
20
+
21
+ it('resolves variable value from the values map', () => {
22
+ const components = createInterpolationComponents({firstName: 'Jordan'})
23
+ const html = renderToStaticMarkup(
24
+ <PortableText value={singleVariableBlock} components={components} />,
25
+ )
26
+ expect(html).toContain('Jordan')
27
+ expect(html).toContain('data-variable-key="firstName"')
28
+ })
29
+
30
+ it('renders {variableKey} fallback for missing values', () => {
31
+ const components = createInterpolationComponents({})
32
+ const html = renderToStaticMarkup(
33
+ <PortableText value={singleVariableBlock} components={components} />,
34
+ )
35
+ expect(html).toContain('{firstName}')
36
+ })
37
+
38
+ it('renders empty string when value is explicitly ""', () => {
39
+ const components = createInterpolationComponents({firstName: ''})
40
+ const html = renderToStaticMarkup(
41
+ <PortableText value={singleVariableBlock} components={components} />,
42
+ )
43
+ expect(html).toContain('<span data-variable-key="firstName"></span>')
44
+ expect(html).not.toContain('{firstName}')
45
+ })
46
+
47
+ it('calls custom fallback function for missing values', () => {
48
+ const customFallback = (variableKey: string) => `[MISSING: ${variableKey}]`
49
+ const components = createInterpolationComponents({}, customFallback)
50
+ const html = renderToStaticMarkup(
51
+ <PortableText value={singleVariableBlock} components={components} />,
52
+ )
53
+ expect(html).toContain('[MISSING: firstName]')
54
+ })
55
+
56
+ it('renders inside <span> with data-variable-key attribute', () => {
57
+ const components = createInterpolationComponents({
58
+ firstName: 'Jordan',
59
+ lastName: 'Lawrence',
60
+ email: 'jordan@example.com',
61
+ })
62
+ const html = renderToStaticMarkup(
63
+ <PortableText value={multipleVariablesBlock} components={components} />,
64
+ )
65
+ expect(html).toContain('<span data-variable-key="firstName">Jordan</span>')
66
+ expect(html).toContain('<span data-variable-key="lastName">Lawrence</span>')
67
+ expect(html).toContain('<span data-variable-key="email">jordan@example.com</span>')
68
+ })
69
+
70
+ it('returns components with only a types key', () => {
71
+ const components = createInterpolationComponents({firstName: 'Jordan'})
72
+ expect(Object.keys(components)).toEqual(['types'])
73
+ })
74
+
75
+ it('registers component under VARIABLE_TYPE_PREFIX key', () => {
76
+ const components = createInterpolationComponents({firstName: 'Jordan'})
77
+ expect(Object.keys(components.types!)).toEqual([VARIABLE_TYPE_PREFIX])
78
+ })
79
+
80
+ it('falls back for all variables when values map is empty', () => {
81
+ const components = createInterpolationComponents({})
82
+ const html = renderToStaticMarkup(
83
+ <PortableText value={multipleVariablesBlock} components={components} />,
84
+ )
85
+ expect(html).toContain('{firstName}')
86
+ expect(html).toContain('{lastName}')
87
+ expect(html).toContain('{email}')
88
+ })
89
+
90
+ it('escapes HTML special characters in values', () => {
91
+ const components = createInterpolationComponents({firstName: '<script>alert("xss")</script>'})
92
+ const html = renderToStaticMarkup(
93
+ <PortableText value={singleVariableBlock} components={components} />,
94
+ )
95
+ expect(html).not.toContain('<script>')
96
+ expect(html).toContain('&lt;script&gt;')
97
+ })
98
+
99
+ it('renders consecutive variables without interference', () => {
100
+ const components = createInterpolationComponents({firstName: 'Jordan', lastName: 'Lawrence'})
101
+ const html = renderToStaticMarkup(
102
+ <PortableText value={consecutiveVariablesBlock} components={components} />,
103
+ )
104
+ expect(html).toContain('<span data-variable-key="firstName">Jordan</span>')
105
+ expect(html).toContain('<span data-variable-key="lastName">Lawrence</span>')
106
+ })
107
+
108
+ it('renders a variable as the only child in a block', () => {
109
+ const components = createInterpolationComponents({firstName: 'Jordan'})
110
+ const html = renderToStaticMarkup(
111
+ <PortableText value={variableOnlyBlock} components={components} />,
112
+ )
113
+ expect(html).toContain('<span data-variable-key="firstName">Jordan</span>')
114
+ })
115
+ })
@@ -0,0 +1,106 @@
1
+ import type {PortableTextBlock} from '@portabletext/react'
2
+
3
+ export const singleVariableBlock: PortableTextBlock[] = [
4
+ {
5
+ _type: 'block',
6
+ _key: 'block-1',
7
+ style: 'normal',
8
+ markDefs: [],
9
+ children: [
10
+ {_type: 'span', _key: 'span-1', text: 'Hello, ', marks: []},
11
+ {_type: 'pteInterpolationVariable', _key: 'var-1', variableKey: 'firstName'},
12
+ {_type: 'span', _key: 'span-2', text: '!', marks: []},
13
+ ],
14
+ },
15
+ ]
16
+
17
+ export const multipleVariablesBlock: PortableTextBlock[] = [
18
+ {
19
+ _type: 'block',
20
+ _key: 'block-1',
21
+ style: 'normal',
22
+ markDefs: [],
23
+ children: [
24
+ {_type: 'span', _key: 'span-1', text: 'Name: ', marks: []},
25
+ {_type: 'pteInterpolationVariable', _key: 'var-1', variableKey: 'firstName'},
26
+ {_type: 'span', _key: 'span-2', text: ' ', marks: []},
27
+ {_type: 'pteInterpolationVariable', _key: 'var-2', variableKey: 'lastName'},
28
+ {_type: 'span', _key: 'span-3', text: ', Email: ', marks: []},
29
+ {_type: 'pteInterpolationVariable', _key: 'var-3', variableKey: 'email'},
30
+ ],
31
+ },
32
+ ]
33
+
34
+ export const plainTextBlock: PortableTextBlock[] = [
35
+ {
36
+ _type: 'block',
37
+ _key: 'block-1',
38
+ style: 'normal',
39
+ markDefs: [],
40
+ children: [{_type: 'span', _key: 'span-1', text: 'No variables here.', marks: []}],
41
+ },
42
+ ]
43
+
44
+ export const variableOnlyBlock: PortableTextBlock[] = [
45
+ {
46
+ _type: 'block',
47
+ _key: 'block-1',
48
+ style: 'normal',
49
+ markDefs: [],
50
+ children: [{_type: 'pteInterpolationVariable', _key: 'var-1', variableKey: 'firstName'}],
51
+ },
52
+ ]
53
+
54
+ export const consecutiveVariablesBlock: PortableTextBlock[] = [
55
+ {
56
+ _type: 'block',
57
+ _key: 'block-1',
58
+ style: 'normal',
59
+ markDefs: [],
60
+ children: [
61
+ {_type: 'pteInterpolationVariable', _key: 'var-1', variableKey: 'firstName'},
62
+ {_type: 'pteInterpolationVariable', _key: 'var-2', variableKey: 'lastName'},
63
+ ],
64
+ },
65
+ ]
66
+
67
+ export const styledTextWithVariableBlock: PortableTextBlock[] = [
68
+ {
69
+ _type: 'block',
70
+ _key: 'block-1',
71
+ style: 'normal',
72
+ markDefs: [],
73
+ children: [
74
+ {_type: 'span', _key: 'span-1', text: 'Welcome ', marks: ['strong']},
75
+ {_type: 'pteInterpolationVariable', _key: 'var-1', variableKey: 'firstName'},
76
+ {_type: 'span', _key: 'span-2', text: ' aboard', marks: ['em']},
77
+ ],
78
+ },
79
+ ]
80
+
81
+ export const emptyBlocksContent: PortableTextBlock[] = []
82
+
83
+ export const multiBlockContent: PortableTextBlock[] = [
84
+ {
85
+ _type: 'block',
86
+ _key: 'block-1',
87
+ style: 'normal',
88
+ markDefs: [],
89
+ children: [
90
+ {_type: 'span', _key: 'span-1', text: 'Dear ', marks: []},
91
+ {_type: 'pteInterpolationVariable', _key: 'var-1', variableKey: 'firstName'},
92
+ {_type: 'span', _key: 'span-2', text: ',', marks: []},
93
+ ],
94
+ },
95
+ {
96
+ _type: 'block',
97
+ _key: 'block-2',
98
+ style: 'normal',
99
+ markDefs: [],
100
+ children: [
101
+ {_type: 'span', _key: 'span-3', text: 'Your email is ', marks: []},
102
+ {_type: 'pteInterpolationVariable', _key: 'var-2', variableKey: 'email'},
103
+ {_type: 'span', _key: 'span-4', text: '.', marks: []},
104
+ ],
105
+ },
106
+ ]
@@ -0,0 +1,2 @@
1
+ /** @public */
2
+ export const VARIABLE_TYPE_PREFIX = 'pteInterpolationVariable'
@@ -0,0 +1,32 @@
1
+ import type {PortableTextComponents, PortableTextTypeComponentProps} from '@portabletext/react'
2
+
3
+ import {VARIABLE_TYPE_PREFIX} from './constants'
4
+ import type {
5
+ InterpolationFallback,
6
+ InterpolationValues,
7
+ PteInterpolationVariableBlock,
8
+ } from './types'
9
+
10
+ function defaultFallback(variableKey: string): string {
11
+ return `{${variableKey}}`
12
+ }
13
+
14
+ /** @public */
15
+ export function createInterpolationComponents(
16
+ values: InterpolationValues,
17
+ fallback: InterpolationFallback = defaultFallback,
18
+ ): PortableTextComponents {
19
+ function VariableComponent(props: PortableTextTypeComponentProps<PteInterpolationVariableBlock>) {
20
+ const {variableKey} = props.value
21
+ const resolvedValue =
22
+ values[variableKey] !== undefined ? values[variableKey] : fallback(variableKey)
23
+
24
+ return <span data-variable-key={variableKey}>{resolvedValue}</span>
25
+ }
26
+
27
+ return {
28
+ types: {
29
+ [VARIABLE_TYPE_PREFIX]: VariableComponent,
30
+ },
31
+ }
32
+ }
package/src/index.ts CHANGED
@@ -1,2 +1,9 @@
1
- export {InterpolatedText} from './InterpolatedText'
2
- export type {InterpolatedTextProps, InterpolationValues} from './types'
1
+ export {InterpolatedPortableText} from './InterpolatedPortableText'
2
+ export {createInterpolationComponents} from './createInterpolationComponents'
3
+ export {VARIABLE_TYPE_PREFIX} from './constants'
4
+ export type {
5
+ InterpolatedPortableTextProps,
6
+ InterpolationFallback,
7
+ InterpolationValues,
8
+ PteInterpolationVariableBlock,
9
+ } from './types'
package/src/types.ts CHANGED
@@ -1,10 +1,21 @@
1
- import type {PortableTextBlock} from '@portabletext/react'
1
+ import type {PortableTextComponents, PortableTextProps} from '@portabletext/react'
2
2
 
3
3
  /** @public */
4
4
  export type InterpolationValues = Record<string, string>
5
5
 
6
6
  /** @public */
7
- export interface InterpolatedTextProps {
8
- blocks: PortableTextBlock[]
9
- values: InterpolationValues
7
+ export type InterpolationFallback = (variableKey: string) => string
8
+
9
+ /** @public */
10
+ export interface PteInterpolationVariableBlock {
11
+ _type: 'pteInterpolationVariable'
12
+ _key: string
13
+ variableKey: string
14
+ }
15
+
16
+ /** @public */
17
+ export interface InterpolatedPortableTextProps extends Omit<PortableTextProps, 'components'> {
18
+ interpolationValues: InterpolationValues
19
+ components?: PortableTextComponents
20
+ fallback?: InterpolationFallback
10
21
  }
@@ -1,10 +0,0 @@
1
- import type {InterpolatedTextProps} from './types'
2
-
3
- /** @public */
4
- export function InterpolatedText({blocks, values}: InterpolatedTextProps) {
5
- return (
6
- <pre>
7
- <code>{JSON.stringify({blocks, values}, null, 2)}</code>
8
- </pre>
9
- )
10
- }
@@ -1,9 +0,0 @@
1
- import {describe, expect, it} from 'vitest'
2
- import {InterpolatedText} from '../InterpolatedText'
3
-
4
- describe('InterpolatedText', () => {
5
- it('should be defined', () => {
6
- expect(InterpolatedText).toBeDefined()
7
- expect(typeof InterpolatedText).toBe('function')
8
- })
9
- })