├── .changeset
├── README.md
└── config.json
├── .github
└── workflows
│ ├── main.yml
│ └── publish.yml
├── .gitignore
├── .nvmrc
├── .vscode
└── launch.json
├── CHANGELOG.md
├── LICENSE
├── README.md
├── babel.config.js
├── docs
└── cover.png
├── jest.config.ts
├── jest.config.tsd.js
├── package-lock.json
├── package.json
├── src
├── __tests__
│ └── stringInterpolation.test.ts
├── __typetests__
│ └── stringInterpolation.test.tsx
└── index.ts
└── tsconfig.json
/.changeset/README.md:
--------------------------------------------------------------------------------
1 | # Changesets
2 |
3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
4 | with multi-package repos, or single-package repos to help you version and publish your code. You can
5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets)
6 |
7 | We have a quick list of common questions to get you started engaging with this project in
8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
9 |
--------------------------------------------------------------------------------
/.changeset/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json",
3 | "changelog": "@changesets/cli/changelog",
4 | "commit": false,
5 | "fixed": [],
6 | "linked": [],
7 | "access": "public",
8 | "baseBranch": "main",
9 | "updateInternalDependencies": "patch",
10 | "ignore": []
11 | }
12 |
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 | on:
3 | push:
4 | branches:
5 | - "**"
6 |
7 | jobs:
8 | build:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - uses: actions/checkout@v3
12 | - uses: actions/setup-node@v3
13 | with:
14 | node-version: 18
15 | cache: "npm"
16 | - run: npm ci
17 | - run: npm run lint && npm run test && npm run build
18 |
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | name: Publish
2 |
3 | on:
4 | push:
5 | branches:
6 | - "main"
7 |
8 | concurrency: ${{ github.workflow }}-${{ github.ref }}
9 |
10 | jobs:
11 | publish:
12 | name: Publish
13 | runs-on: ubuntu-latest
14 | steps:
15 | - name: Checkout Repo
16 | uses: actions/checkout@v3
17 |
18 | - name: Setup Node.js 18
19 | uses: actions/setup-node@v3
20 | with:
21 | node-version: 18
22 |
23 | - name: Install Dependencies
24 | run: npm ci
25 |
26 | - name: Create Release Pull Request or Publish
27 | id: changesets
28 | uses: changesets/action@v1
29 | with:
30 | # This expects you to have a script called release which does a build for your packages and calls changeset publish
31 | publish: npm run release
32 | env:
33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
34 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
35 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | 18
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | // Use IntelliSense to learn about possible attributes.
3 | // Hover to view descriptions of existing attributes.
4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5 | "version": "0.2.0",
6 | "configurations": [
7 | {
8 | // https://github.com/microsoft/vscode-recipes/tree/main/debugging-jest-tests
9 | // https://www.youtube.com/watch?v=_usf3xQ7wys
10 | "type": "node",
11 | "request": "launch",
12 | "name": "Jest Current File",
13 | "program": "${workspaceFolder}/node_modules/.bin/jest",
14 | "args": [
15 | "--runTestsByPath",
16 | "${relativeFile}",
17 | "--config",
18 | "jest.config.ts"
19 | ],
20 | "console": "integratedTerminal",
21 | "internalConsoleOptions": "neverOpen"
22 | }
23 | ]
24 | }
25 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # typed-string-interpolation
2 |
3 | ## 0.0.9
4 |
5 | ### Patch Changes
6 |
7 | - f54f580: Throw Error objects
8 |
9 | ## 0.0.8
10 |
11 | ### Patch Changes
12 |
13 | - 21fcfe9: Polyfill for String.matchAll
14 |
15 | ## 0.0.7
16 |
17 | ### Patch Changes
18 |
19 | - 4b9e63d: minification
20 |
21 | ## 0.0.6
22 |
23 | ### Patch Changes
24 |
25 | - 0c76def: Add concurrently and refined README
26 |
27 | ## 0.0.5
28 |
29 | ### Patch Changes
30 |
31 | - a04cb7e: Refine readme
32 |
33 | ## 0.0.4
34 |
35 | ### Patch Changes
36 |
37 | - 9ec3520: More additions to readme and package.json
38 |
39 | ## 0.0.3
40 |
41 | ### Patch Changes
42 |
43 | - 4f08b1b: Finalizing package
44 |
45 | ## 0.0.2
46 |
47 | ### Patch Changes
48 |
49 | - 32c3d71: Initial commit
50 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2023 Mikko Vänskä
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4 |
5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6 |
7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
8 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # `typed-string-interpolation`
2 |
3 | [String interpolation](https://en.wikipedia.org/wiki/String_interpolation) for `TypeScript` with correct return types based on passed in variables.
4 |
5 | 
6 |
7 | > Library used within a React app. Note that the library itself is framework agnostic and could be used in any TypeScript/JavaScript app.
8 |
9 | ## Main features
10 |
11 | - Replaces variables within a string with passed in variables
12 | - Sanity checks that correct variables were passed in
13 | - Returns the correct type based on passed in variable substitutions
14 | - Options to customize return, pattern matching and sanity checking
15 | - Both ES Module and CommonJS distributions available. Use anywhere!
16 | - Tiny footprint:
17 | - ES Module: `379B` (`533B` unpacked)
18 | - CommonJS: `612B` (`1.03kB` unpacked)
19 |
20 | ## Motivation
21 |
22 | String interpolation/variable substitution (i.e. injecting variables within text) is a really common operation when building single and multilingual applications alike. Existing string interpolation utilities within the most used `i18n` / `l10n` packages like `i18next` and `formatjs` come with massive overhead while lacking proper TypeScript infer support for the interpolation operation.
23 |
24 | This utility aims to provide a high quality string interpolation "primitive" to use as is or within other localization frameworks and tooling.
25 |
26 | ## Getting started
27 |
28 | Easiest way to get started is to play around with a [React example sandbox](https://codesandbox.io/p/sandbox/typed-string-interpolation-react-example-slpjgp?file=%2Fsrc%2Fmain.tsx).
29 |
30 | ### Install
31 |
32 | ```bash
33 | npm i typed-string-interpolation
34 | ```
35 |
36 | ### Usage
37 |
38 | ```ts
39 | // ES module
40 | import { stringInterpolation } from "typed-string-interpolation"
41 | // CommonJS
42 | const { stringInterpolation } = require("typed-string-interpolation")
43 | ```
44 |
45 | Returns a `string` when the result can be joined into a string.
46 |
47 | ```ts
48 | stringInterpolation("You have {{n}} messages", {
49 | n: 3,
50 | }) // "You have 3 messages"
51 | ```
52 |
53 | Returns an array when the result can't be joined into a `string`. This makes it really easy to use the utility with libraries like `react` or anything else.
54 |
55 | ```tsx
56 | stringInterpolation("You have {{n}} messages", {
57 | n: 3,
58 | }) // ["You have ", 3, " messages"]
59 | ```
60 |
61 | ## TypeScript support
62 |
63 | If the string can be joined you'll get back a `string` type. Otherwise a `union` type within an array is returned based on the passed in variables.
64 |
65 | ```ts
66 | stringInterpolation("You have {{n}} messages from {{person}}", {
67 | n: 3,
68 | person: "John",
69 | }) // : string
70 | ```
71 |
72 | ```tsx
73 | stringInterpolation("You have {{n}} messages from {{person}}", {
74 | n: 3,
75 | person: "John",
76 | }) // : (JSX.Element | string)[]
77 | ```
78 |
79 | ## Options
80 |
81 | Takes in an optional third parameter for options:
82 |
83 | ```js
84 | stringInterpolation(str, variables, options)
85 | ```
86 |
87 | ```ts
88 | type Options = {
89 | raw?: boolean // default: false
90 | pattern?: RegExp // default: new RegExp(/\{{([^{]+)}}/g)
91 | sanity?: boolean // default: true
92 | }
93 | ```
94 |
95 | `raw`
96 |
97 | Return the raw interpolation results without joining to string when you want full control for some reason.
98 |
99 | ```tsx
100 | stringInterpolation(
101 | "You have {{n}} messages from {{person}}",
102 | {
103 | n: 3,
104 | person: "John",
105 | },
106 | { raw: true }
107 | ) // : (number | string)[]
108 | ```
109 |
110 | `pattern`
111 |
112 | Provide your own `RegExp` pattern for variable matching. Must be defined as:
113 |
114 | ```ts
115 | pattern: new RegExp(/\{{([^{]+)}}/g)
116 | ```
117 |
118 | `sanity`
119 |
120 | If you want to live dangerously, sanity checking can be turned off.
121 |
122 | ```ts
123 | {
124 | sanity: false
125 | }
126 | ```
127 |
128 | Turning of sanity checking removes `throw` from:
129 |
130 | - empty string
131 | - string variables and passed in variables count mismatch
132 | - missing variables
133 |
134 | ## Contributing
135 |
136 | Easiest way to contribute is to open new issues for API suggestions and bugs.
137 |
138 | ### Contributing for a release
139 |
140 | Steps for contributing through a pull request:
141 |
142 | - Fork `main` on Github and clone fork locally
143 | - Install dependencies
144 | - `npm ci`
145 | - Make changes while running tests in watch mode
146 | - `npm run test:unit:all:watch`
147 | - This project has a `.vscode/launch.json` file containing configuration for running Jest tests with the VSCode debugger which makes it simple to step through logic excecution. Steps to use VSCode debugger:
148 | - Add a breakpoint to the source code
149 | - Open a Jest unit test file (`*.test.ts`)
150 | - Go to the VSCode debugger Tab (`shift` + `command` + `D` on MacOS) and select "Jest Current File" or optionally start the debug session from the command line (`shift` + `command` + `P` on MacOS) and type "Debug: Start debugging"
151 | - VSCode should open a new terminal window and attach the Jest instance to the debugger
152 | - Debugger should stop on the defined breakpoint in the source code
153 | - Once all changes are complete, create a new release with [changesets](https://github.com/changesets/changesets)
154 | - `npm run create-release`
155 | - Commit and push changes to fork
156 | - Open a pull request against the fork
157 | - If the PR needs changes before a merge to `main` can be made, push more changes to the fork until the PR is approved
158 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: [
3 | ["@babel/preset-env", { targets: { node: "current" } }],
4 | "@babel/preset-typescript",
5 | ],
6 | }
7 |
--------------------------------------------------------------------------------
/docs/cover.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vanska/typed-string-interpolation/d3c4a0006bad0ab9a82b4992b5e6bf19e89f6ad8/docs/cover.png
--------------------------------------------------------------------------------
/jest.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "jest"
2 |
3 | const config: Config = {
4 | verbose: true,
5 | testMatch: ["/src/__tests__/*.ts"],
6 | }
7 |
8 | export default config
9 |
--------------------------------------------------------------------------------
/jest.config.tsd.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | displayName: {
3 | color: "blue",
4 | name: "types",
5 | },
6 | runner: "jest-runner-tsd",
7 | testMatch: ["**/__typetests__/*.test.ts*"],
8 | }
9 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "typed-string-interpolation",
3 | "description": "String interpolation with correct return type based on passed variable substitutions",
4 | "author": "Mikko Vänskä",
5 | "repository": "https://github.com/vanska/typed-string-interpolation",
6 | "license": "MIT",
7 | "version": "0.0.9",
8 | "main": "dist/index.js",
9 | "module": "dist/index.mjs",
10 | "types": "dist/index.d.ts",
11 | "files": [
12 | "/dist"
13 | ],
14 | "private": false,
15 | "keywords": [
16 | "string",
17 | "interpolation",
18 | "substitution",
19 | "injection",
20 | "replace",
21 | "inject",
22 | "substitute",
23 | "interpolate",
24 | "variable",
25 | "variables",
26 | "type",
27 | "typed",
28 | "infer",
29 | "type-safe",
30 | "i18n",
31 | "l10n",
32 | "i18next",
33 | "formatjs"
34 | ],
35 | "scripts": {
36 | "lint": "tsc",
37 | "test:unit": "jest",
38 | "test:unit:types": "jest -c jest.config.tsd.js",
39 | "test:unit:watch": "jest --watch",
40 | "test:unit:types:watch": "jest -c jest.config.tsd.js --watch",
41 | "test:unit:all:watch": "concurrently --raw \"npm run test:unit:watch\" \"npm run test:unit:types:watch\"",
42 | "test": "npm run test:unit && npm run test:unit:types",
43 | "build": "tsup src/index.ts --format cjs,esm --dts --minify",
44 | "release": "npm run build && changeset publish",
45 | "create-release": "changeset"
46 | },
47 | "devDependencies": {
48 | "@babel/core": "^7.20.12",
49 | "@babel/preset-env": "^7.20.2",
50 | "@babel/preset-typescript": "^7.18.6",
51 | "@changesets/cli": "^2.26.0",
52 | "@tsd/typescript": "^4.9.4",
53 | "@types/jest": "^29.2.5",
54 | "babel-jest": "^29.3.1",
55 | "concurrently": "^8.2.0",
56 | "jest": "^29.3.1",
57 | "jest-runner-tsd": "^4.0.0",
58 | "ts-node": "^10.9.1",
59 | "tsd-lite": "^0.6.0",
60 | "tsup": "^6.5.0",
61 | "typescript": "^4.9.4"
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/src/__tests__/stringInterpolation.test.ts:
--------------------------------------------------------------------------------
1 | import { stringInterpolation } from "../index"
2 |
3 | describe("stringInterpolation()", () => {
4 | test("Empty", () => {
5 | expect(() => {
6 | stringInterpolation("", {
7 | world: "world",
8 | })
9 | }).toThrow("Empty string")
10 | })
11 | test("Incorrect variable count", () => {
12 | expect(() => {
13 | stringInterpolation("Hello {{world}}", {
14 | world: "world with varialbe",
15 | extraVariable: "this is unnecessary",
16 | })
17 | }).toThrow("Variable count mismatch")
18 | })
19 | test("Variable not found", () => {
20 | expect(() =>
21 | stringInterpolation("Hello {{world}}", {
22 | wrongVariable: "world",
23 | })
24 | ).toThrow("Variable 'world' not found")
25 | })
26 | test("Interpolate single variable", () => {
27 | expect(
28 | stringInterpolation("Hello {{world}}", {
29 | world: "world with variable",
30 | })
31 | ).toBe("Hello world with variable")
32 | })
33 | test("Interpolate single variable and return raw result with passed in option", () => {
34 | expect(
35 | stringInterpolation(
36 | "Hello {{world}}",
37 | {
38 | world: "world with variable",
39 | },
40 | { raw: true }
41 | )
42 | ).toStrictEqual(["Hello ", "world with variable"])
43 | })
44 | test("Interpolate two variables", () => {
45 | expect(
46 | stringInterpolation("Hello {{world}} and {{anotherVariable}}", {
47 | world: "world with variable",
48 | anotherVariable: "another variable",
49 | })
50 | ).toBe("Hello world with variable and another variable")
51 | })
52 | test("Interpolation variable contains a function", () => {
53 | expect(
54 | stringInterpolation("Hello {{world}} and {{anotherVariable}}", {
55 | world: "world with variable",
56 | anotherVariable: () => "another variable",
57 | })
58 | ).toStrictEqual([
59 | "Hello ",
60 | "world with variable",
61 | " and ",
62 | expect.any(Function),
63 | ])
64 | })
65 | })
66 |
--------------------------------------------------------------------------------
/src/__typetests__/stringInterpolation.test.tsx:
--------------------------------------------------------------------------------
1 | import { expectType } from "tsd-lite"
2 | import { stringInterpolation } from "../index"
3 |
4 | // ~~~~~~~~~~~~~~ TYPESCRIPT TYPE TESTING ~~~~~~~~~~~~~~
5 |
6 | expectType(
7 | stringInterpolation("hello {{one}}", {
8 | one: "one",
9 | })
10 | )
11 |
12 | expectType(
13 | stringInterpolation("hello {{one}}", {
14 | one: "one",
15 | })
16 | )
17 |
18 | expectType(
19 | stringInterpolation(
20 | "hello {{one}}",
21 | {
22 | one: "one",
23 | },
24 | { raw: true }
25 | )
26 | )
27 |
28 | expectType(
29 | stringInterpolation("hello {{one}} {{two}}", {
30 | one: "one",
31 | two: 2,
32 | })
33 | )
34 |
35 | expectType(
36 | stringInterpolation("hello {{one}}", {
37 | one: 2,
38 | })
39 | )
40 |
41 | expectType<
42 | (
43 | | string
44 | | number
45 | | {
46 | type: string
47 | props: {
48 | className: string
49 | children: string
50 | }
51 | }
52 | )[]
53 | >(
54 | stringInterpolation("hello {{one}} {{two}} {{three}}", {
55 | one: "one",
56 | two: 2,
57 | three: {
58 | type: "span",
59 | props: {
60 | className: "bold",
61 | children: "one",
62 | },
63 | },
64 | })
65 | )
66 |
67 | expectType<(string | number | Date)[]>(
68 | stringInterpolation("hello {{one}} {{two}} {{three}}", {
69 | one: "one",
70 | two: 2,
71 | three: new Date(),
72 | })
73 | )
74 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | type StringInterpolationOptions = {
2 | pattern?: RegExp
3 | sanity?: boolean
4 | raw?: Raw
5 | }
6 |
7 | type StringInterpolationReturn = Exclude<
8 | VariableValue,
9 | string | number
10 | > extends never
11 | ? Extract extends true
12 | ? (string | VariableValue)[]
13 | : string
14 | : (string | VariableValue)[]
15 |
16 | /**
17 | * String.matchAll polyfill
18 | * Used because no support in Safari <= 12
19 | * @see https://caniuse.com/mdn-javascript_builtins_string_matchall
20 | * @see https://stackoverflow.com/questions/58003217/how-to-use-the-string-prototype-matchall-polyfill
21 | */
22 | function matchAllPolyfill(string: string, pattern: RegExp) {
23 | let match
24 | const matches = []
25 |
26 | while ((match = pattern.exec(string))) matches.push(match)
27 |
28 | return matches
29 | }
30 |
31 | /**
32 | * Takes in a string containing variables and an object containing variables for interpolation. Accepts options.
33 | *
34 | * @example
35 | *
36 | * stringInterpolation("You have {{n}} messages", {
37 | n: 3,
38 | })
39 |
40 | stringInterpolation("You have {{n}} messages from {{person}}", {
41 | n: 3,
42 | person: "John",
43 | })
44 | */
45 | export function stringInterpolation<
46 | VariableValue extends any,
47 | OptionRaw extends boolean | undefined
48 | >(
49 | string: string,
50 | variables: Record,
51 | {
52 | pattern = new RegExp(/\{{([^{]+)}}/g),
53 | sanity = true,
54 | raw: rawOutput = false,
55 | }: StringInterpolationOptions = {}
56 | ): StringInterpolationReturn {
57 | if (!string && sanity) throw new Error("Empty string")
58 |
59 | // Find all variables within string
60 | const stringVariables = matchAllPolyfill(string, pattern)
61 |
62 | // No variables => no need to interpolate
63 | if (!stringVariables[0])
64 | return string as StringInterpolationReturn
65 |
66 | if (sanity) {
67 | // Sanity check string variables <-> passed in variables count
68 | const variableKeys = Object.keys(variables)
69 | // Checks whether variables parsed from string exist in passed argument
70 | if (stringVariables.length !== variableKeys.length)
71 | throw new Error("Variable count mismatch")
72 | for (const regExpMatchArray of stringVariables) {
73 | const variableKeyInString = regExpMatchArray[1]
74 | if (variableKeyInString && !variableKeys.includes(variableKeyInString))
75 | throw new Error(`Variable '${variableKeyInString}' not found`)
76 | }
77 | }
78 |
79 | // Create raw interpolation result
80 | const rawInterpolation = string
81 | .split(pattern)
82 | // Trim empty string from array end (Could propably be done with regex as well)
83 | .filter(Boolean)
84 | // Match parsed variables with passed in variables
85 | .map((splitItem) => {
86 | return variables[splitItem] ? variables[splitItem] : splitItem
87 | })
88 |
89 | // Checks if raw interpolation can be joined or not.
90 | // i.e. avoid printing [object Object | Array | Function | ...] within returned string.
91 | const canJoin = !rawInterpolation.filter(
92 | (i) => typeof i !== "string" && typeof i !== "number"
93 | )[0]
94 |
95 | if (canJoin && !rawOutput)
96 | return rawInterpolation.join("") as StringInterpolationReturn<
97 | VariableValue,
98 | OptionRaw
99 | >
100 |
101 | return rawInterpolation as StringInterpolationReturn
102 | }
103 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
4 | "module": "commonjs" /* Specify what module code is generated. */,
5 | "noEmit": true /* Disable emitting files from a compilation. */,
6 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */,
7 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
8 | "strict": true /* Enable all strict type-checking options. */,
9 | "jsx": "react"
10 | },
11 | "exclude": ["dist", "node_modules"]
12 | }
13 |
--------------------------------------------------------------------------------