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), ];