Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/check-pull-request-health.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] This matrix now runs npm run check twice, but only typecheck is React-version-sensitive; check:format and check:lint are identical on both legs. Splitting those invariant checks into a single non-matrix job, or running them only on one leg, would avoid a full duplicate pass without reducing the React 18/19 coverage.

fail-fast: false
matrix:
react-major:
- "18"
- "19"
steps:
- name: Checkout repository
uses: actions/checkout@v4
Expand All @@ -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:
Expand Down
9 changes: 3 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
21 changes: 11 additions & 10 deletions package-checks/smoke.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<b>{name}</b>",
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");
Expand All @@ -19,17 +26,11 @@ async function main() {
"Named exports should match",
);

const html = renderToStaticMarkup(
React.createElement(cjsPackage.default, {
string: "<b>{name}</b>",
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 === "<strong>William</strong>", "Built package should render expected HTML");
assert(cjsHtml === "<strong>William</strong>", "CJS build should render expected HTML");
assert(esmHtml === "<strong>William</strong>", "ESM build should render expected HTML");
}

main().catch((error) => {
Expand Down
2 changes: 1 addition & 1 deletion package-checks/types.cts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
};

Expand Down
2 changes: 1 addition & 1 deletion package-checks/types.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
};

Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -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";

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] createSyntaxRule is now part of the published API, but the package checks added in this PR still don't exercise it in either runtime or type-only fixtures. That leaves the new export outside the contract verification this PR is tightening. Add one package smoke assertion and one type import for createSyntaxRule so CJS/ESM packaging regressions on this helper get caught.

export type { Syntax, SyntaxRule } from "./syntax";

export { TOKEN_PLACEHOLDER, TOKEN_OPEN_TAG, TOKEN_CLOSE_TAG, TOKEN_SELF_TAG } from "./constants";
Expand Down
3 changes: 1 addition & 2 deletions src/interpolate.test.tsx
Original file line number Diff line number Diff line change
@@ -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();
});

Expand Down
10 changes: 6 additions & 4 deletions src/interpolate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 =

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] This union widens Mapping in a way the runtime can't honor. Because Mapping is just Record<string, MappingValue>, name: (children: ReactNode) => ... now type-checks for placeholders and self-closing tags too, but those branches still invoke render functions with no arguments. That means published typings now accept code that can misrender or throw at runtime. Please keep the callable contract invokable from both call sites (for example an optional-arg/bivariant function type), or split placeholder/tag renderers into separate mapping types.

| (() => React.ReactNode)
| ((children: React.ReactNode) => React.ReactNode);
export type MappingValue = React.ReactNode | MappingRenderFunction;
export type Mapping = Record<string, MappingValue>;

Expand Down Expand Up @@ -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);
Expand All @@ -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)) {
Expand Down Expand Up @@ -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;
Expand Down
22 changes: 6 additions & 16 deletions src/lexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
type SyntaxTokenType,
type TokenType,
} from "./constants";
import type { Syntax } from "./syntax";
import type { Syntax, SyntaxRule } from "./syntax";

interface BaseToken<T extends TokenType> {
type: T;
Expand Down Expand Up @@ -39,21 +39,11 @@ export interface TextToken extends BaseToken<typeof TOKEN_TEXT> {
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<SyntaxTokenType>, 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,
};
Expand All @@ -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");
Expand All @@ -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));
}
}

Expand Down
13 changes: 12 additions & 1 deletion src/parser.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { TOKEN_PLACEHOLDER } from "./constants";
import parser from "./parser";
import { createSyntaxRule } from "./syntax";

describe("parser", () => {
const tests = [
Expand Down Expand Up @@ -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)]);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] This only covers the error path for missing captures. It still doesn't test the new behavior createSyntaxRule was added for: selecting the token name from an explicit capture group. A regression where the helper always read match[1] would still pass. Add one custom-syntax parse case with captureGroup > 1 so the helper's main contract is covered.

}).toThrow("Syntax rule must capture a token name");
});
});
67 changes: 41 additions & 26 deletions src/syntax.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,38 +9,53 @@ import {
export interface SyntaxRule<T extends SyntaxTokenType = SyntaxTokenType> {
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<T extends SyntaxTokenType>(
type: T,
regex: RegExp,
captureGroup: number,
): SyntaxRule<T> {
if (!regex.global) {
throw new Error("Syntax rule regex must use the global flag");
}

if (!Number.isInteger(captureGroup) || captureGroup < 1) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] The immediate validation errors inside createSyntaxRule (such as an invalid captureGroup and the preceding missing global flag check) are entirely untested. Add test coverage to verify these synchronous parameter validations.

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