diff --git a/.github/workflows/check-pull-request-health.yml b/.github/workflows/check-pull-request-health.yml
index 8f0ed4e..725f979 100644
--- a/.github/workflows/check-pull-request-health.yml
+++ b/.github/workflows/check-pull-request-health.yml
@@ -10,6 +10,12 @@ jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 60
+ strategy:
+ fail-fast: false
+ matrix:
+ react-major:
+ - "18"
+ - "19"
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -22,6 +28,10 @@ jobs:
- name: Install dependencies
run: npm ci --legacy-peer-deps
+ - name: Override React version for the compatibility check
+ if: matrix.react-major == '19'
+ run: npm install --legacy-peer-deps --no-save --package-lock=false react@19 react-dom@19 @types/react@19 @types/react-dom@19
+
- name: Check the codebase
run: npm run check
env:
diff --git a/README.md b/README.md
index cea19bc..3197ef3 100644
--- a/README.md
+++ b/README.md
@@ -180,16 +180,13 @@ For instance, you may be using [i18next](https://www.i18next.com/) which has a s
hello {{name}}
```
-You can define the formatting syntax of your string via `syntax` props.
+You can define the formatting syntax of your string via `syntax` props. Use `createSyntaxRule` so the token name capture group is explicit and validated.
```jsx
-import Interpolate, { TOKEN_PLACEHOLDER } from "react-interpolate"
+import Interpolate, { TOKEN_PLACEHOLDER, createSyntaxRule } from "react-interpolate"
const i18nNextSyntax = [
- {
- type: TOKEN_PLACEHOLDER,
- regex: /{{\s*(\w+)\s*}}/g
- }
+ createSyntaxRule(TOKEN_PLACEHOLDER, /{{\s*(\w+)\s*}}/g, 1)
]
// will output "hi steven"
diff --git a/package-checks/smoke.cjs b/package-checks/smoke.cjs
index c55380d..f628bfa 100644
--- a/package-checks/smoke.cjs
+++ b/package-checks/smoke.cjs
@@ -10,6 +10,13 @@ function assert(condition, message) {
async function main() {
const cjsPackage = require("@doist/react-interpolate");
const esmPackage = await import("@doist/react-interpolate");
+ const props = {
+ string: "{name}",
+ mapping: {
+ b: React.createElement("strong"),
+ name: "William",
+ },
+ };
assert(typeof cjsPackage === "object", "CJS package should be a namespace object");
assert(typeof cjsPackage.default === "function", "CJS default export should be a function");
@@ -19,17 +26,11 @@ async function main() {
"Named exports should match",
);
- const html = renderToStaticMarkup(
- React.createElement(cjsPackage.default, {
- string: "{name}",
- mapping: {
- b: React.createElement("strong"),
- name: "William",
- },
- }),
- );
+ const cjsHtml = renderToStaticMarkup(React.createElement(cjsPackage.default, props));
+ const esmHtml = renderToStaticMarkup(React.createElement(esmPackage.default, props));
- assert(html === "William", "Built package should render expected HTML");
+ assert(cjsHtml === "William", "CJS build should render expected HTML");
+ assert(esmHtml === "William", "ESM build should render expected HTML");
}
main().catch((error) => {
diff --git a/package-checks/types.cts b/package-checks/types.cts
index 15075e6..bc81f0f 100644
--- a/package-checks/types.cts
+++ b/package-checks/types.cts
@@ -3,7 +3,7 @@ import pkg = require("@doist/react-interpolate");
import type { ReactNode } from "react";
const mapping: pkg.Mapping = {
- b: (children?: ReactNode) => children ?? null,
+ b: (children: ReactNode) => children,
name: "William",
};
diff --git a/package-checks/types.mts b/package-checks/types.mts
index ff94e55..4595007 100644
--- a/package-checks/types.mts
+++ b/package-checks/types.mts
@@ -7,7 +7,7 @@ import Interpolate, {
import type { ReactNode } from "react";
const mapping: Mapping = {
- b: (children?: ReactNode) => children ?? null,
+ b: (children: ReactNode) => children,
name: "William",
};
diff --git a/package-lock.json b/package-lock.json
index e492f28..e34bdc5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -24,7 +24,7 @@
"vitest": "4.1.7"
},
"engines": {
- "node": "^22.18.0 || >=24.0.0",
+ "node": ">=20.0.0",
"npm": "^10.0.0 || ^11.0.0"
},
"peerDependencies": {
diff --git a/package.json b/package.json
index b260664..a444fea 100644
--- a/package.json
+++ b/package.json
@@ -70,7 +70,7 @@
"react": "^18.0.0 || ^19.0.0"
},
"engines": {
- "node": "^22.18.0 || >=24.0.0",
+ "node": ">=20.0.0",
"npm": "^10.0.0 || ^11.0.0"
}
}
diff --git a/src/index.ts b/src/index.ts
index 087b417..bdcf3ae 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,7 +1,7 @@
export { default } from "./interpolate";
export type { InterpolateProps, Mapping, MappingRenderFunction, MappingValue } from "./interpolate";
-export { SYNTAX_I18NEXT, SYNTAX_BUILT_IN } from "./syntax";
+export { createSyntaxRule, SYNTAX_I18NEXT, SYNTAX_BUILT_IN } from "./syntax";
export type { Syntax, SyntaxRule } from "./syntax";
export { TOKEN_PLACEHOLDER, TOKEN_OPEN_TAG, TOKEN_CLOSE_TAG, TOKEN_SELF_TAG } from "./constants";
diff --git a/src/interpolate.test.tsx b/src/interpolate.test.tsx
index f89c378..8dcceee 100644
--- a/src/interpolate.test.tsx
+++ b/src/interpolate.test.tsx
@@ -1,12 +1,11 @@
/* oxlint-disable react/display-name */
-import { cleanup, render } from "@testing-library/react";
+import { render } from "@testing-library/react";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import Interpolate, { type InterpolateProps } from "./interpolate";
import { SYNTAX_I18NEXT } from "./syntax";
afterEach(() => {
- cleanup();
vi.restoreAllMocks();
});
diff --git a/src/interpolate.tsx b/src/interpolate.tsx
index 4e03acf..ef071f4 100644
--- a/src/interpolate.tsx
+++ b/src/interpolate.tsx
@@ -10,7 +10,9 @@ import type { SyntaxNode } from "./node";
import parser from "./parser";
import type { Syntax } from "./syntax";
-export type MappingRenderFunction = (children?: React.ReactNode) => React.ReactNode;
+export type MappingRenderFunction =
+ | (() => React.ReactNode)
+ | ((children: React.ReactNode) => React.ReactNode);
export type MappingValue = React.ReactNode | MappingRenderFunction;
export type Mapping = Record;
@@ -44,7 +46,7 @@ function createElement(node: SyntaxNode, mapping: Mapping, keyPrefix: string): R
const value = mapping[nodeName];
if (typeof value === "function") {
- return value();
+ return (value as () => React.ReactNode)();
}
return value ?? React.createElement(nodeName, null);
@@ -59,7 +61,7 @@ function createElement(node: SyntaxNode, mapping: Mapping, keyPrefix: string): R
}
if (typeof value === "function") {
- return value(children);
+ return (value as (children: React.ReactNode) => React.ReactNode)(children);
}
if (React.isValidElement<{ children?: React.ReactNode }>(value)) {
@@ -87,7 +89,7 @@ function createElement(node: SyntaxNode, mapping: Mapping, keyPrefix: string): R
}
if (typeof value === "function") {
- return value();
+ return (value as () => React.ReactNode)();
}
return value;
diff --git a/src/lexer.ts b/src/lexer.ts
index 2e6db28..84f617f 100644
--- a/src/lexer.ts
+++ b/src/lexer.ts
@@ -7,7 +7,7 @@ import {
type SyntaxTokenType,
type TokenType,
} from "./constants";
-import type { Syntax } from "./syntax";
+import type { Syntax, SyntaxRule } from "./syntax";
interface BaseToken {
type: T;
@@ -39,21 +39,11 @@ export interface TextToken extends BaseToken {
type SyntaxToken = PlaceholderToken | OpenTagToken | CloseTagToken | SelfTagToken;
export type Token = PlaceholderToken | OpenTagToken | CloseTagToken | SelfTagToken | TextToken;
-function getMatchName(match: RegExpExecArray): string {
- const name = match[1];
-
- if (name === undefined) {
- throw new Error("Syntax rule must capture a token name");
- }
-
- return name;
-}
-
-function createSyntaxToken(type: SyntaxTokenType, match: RegExpExecArray): SyntaxToken {
+function createSyntaxToken(rule: SyntaxRule, match: RegExpExecArray): SyntaxToken {
return {
- type,
+ type: rule.type,
string: match[0],
- name: getMatchName(match),
+ name: rule.getName(match),
start: match.index,
end: match.index + match[0].length,
};
@@ -76,7 +66,7 @@ export default function lexer(string: string, syntax: Syntax): Token[] {
const matches: SyntaxToken[] = [];
for (const rule of syntax) {
- const { type, regex } = rule;
+ const { regex } = rule;
if (!regex.global) {
throw new Error("Syntax rule regex must use the global flag");
@@ -86,7 +76,7 @@ export default function lexer(string: string, syntax: Syntax): Token[] {
let result: RegExpExecArray | null;
while ((result = regex.exec(string)) !== null) {
- matches.push(createSyntaxToken(type, result));
+ matches.push(createSyntaxToken(rule, result));
}
}
diff --git a/src/parser.test.ts b/src/parser.test.ts
index 2556ff7..16c8fc6 100644
--- a/src/parser.test.ts
+++ b/src/parser.test.ts
@@ -1,4 +1,6 @@
+import { TOKEN_PLACEHOLDER } from "./constants";
import parser from "./parser";
+import { createSyntaxRule } from "./syntax";
describe("parser", () => {
const tests = [
@@ -45,10 +47,19 @@ describe("parser: custom syntax validation", () => {
expect(() => {
parser("hello {name}", [
{
- type: "TOKEN_PLACEHOLDER",
+ type: TOKEN_PLACEHOLDER,
regex: /{\s*(\w+)\s*}/,
+ getName(match) {
+ return match[1] ?? "";
+ },
},
]);
}).toThrow("Syntax rule regex must use the global flag");
});
+
+ test("syntax rules must capture a token name", () => {
+ expect(() => {
+ parser("hello {name}", [createSyntaxRule(TOKEN_PLACEHOLDER, /{\s*\w+\s*}/g, 1)]);
+ }).toThrow("Syntax rule must capture a token name");
+ });
});
diff --git a/src/syntax.ts b/src/syntax.ts
index ec14d68..0b9a6d1 100644
--- a/src/syntax.ts
+++ b/src/syntax.ts
@@ -9,38 +9,53 @@ import {
export interface SyntaxRule {
type: T;
regex: RegExp;
+ getName(match: RegExpExecArray): string;
}
export type Syntax = SyntaxRule[];
+function getCapturedName(match: RegExpExecArray, captureGroup: number): string {
+ const name = match[captureGroup];
+
+ if (name === undefined) {
+ throw new Error("Syntax rule must capture a token name");
+ }
+
+ return name;
+}
+
+export function createSyntaxRule(
+ type: T,
+ regex: RegExp,
+ captureGroup: number,
+): SyntaxRule {
+ if (!regex.global) {
+ throw new Error("Syntax rule regex must use the global flag");
+ }
+
+ if (!Number.isInteger(captureGroup) || captureGroup < 1) {
+ throw new Error("Syntax rule capture group must be a positive integer");
+ }
+
+ return {
+ type,
+ regex,
+ getName(match) {
+ return getCapturedName(match, captureGroup);
+ },
+ };
+}
+
export const SYNTAX_BUILT_IN: Syntax = [
- {
- type: TOKEN_PLACEHOLDER,
- regex: /{\s*(\w+)\s*}/g,
- },
- {
- type: TOKEN_OPEN_TAG,
- regex: /<(\w+)>/g,
- },
- {
- type: TOKEN_CLOSE_TAG,
- regex: /<\/(\w+)>/g,
- },
- { type: TOKEN_SELF_TAG, regex: /<(\w+)\s*\/>/g },
+ createSyntaxRule(TOKEN_PLACEHOLDER, /{\s*(\w+)\s*}/g, 1),
+ createSyntaxRule(TOKEN_OPEN_TAG, /<(\w+)>/g, 1),
+ createSyntaxRule(TOKEN_CLOSE_TAG, /<\/(\w+)>/g, 1),
+ createSyntaxRule(TOKEN_SELF_TAG, /<(\w+)\s*\/>/g, 1),
];
export const SYNTAX_I18NEXT: Syntax = [
- {
- type: TOKEN_PLACEHOLDER,
- regex: /{{\s*(\w+)\s*}}/g,
- },
- {
- type: TOKEN_OPEN_TAG,
- regex: /<(\w+)>/g,
- },
- {
- type: TOKEN_CLOSE_TAG,
- regex: /<\/(\w+)>/g,
- },
- { type: TOKEN_SELF_TAG, regex: /<(\w+)\s*\/>/g },
+ createSyntaxRule(TOKEN_PLACEHOLDER, /{{\s*(\w+)\s*}}/g, 1),
+ createSyntaxRule(TOKEN_OPEN_TAG, /<(\w+)>/g, 1),
+ createSyntaxRule(TOKEN_CLOSE_TAG, /<\/(\w+)>/g, 1),
+ createSyntaxRule(TOKEN_SELF_TAG, /<(\w+)\s*\/>/g, 1),
];