Skip to content

fix: remove unraw dependency #2271

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Open
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
2 changes: 1 addition & 1 deletion .github/workflows/size-limit.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: Size Testing

on:
pull_request_target:
pull_request:
types:
- opened
- synchronize
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<div align="center">
<h1>Lingui<sub>js</sub></h1>

🌍📖 A readable, automated, and optimized (3 kb) internationalization for JavaScript
🌍📖 A readable, automated, and optimized (2 kb) internationalization for JavaScript

<hr />

Expand Down Expand Up @@ -31,7 +31,7 @@ Lingui is an easy yet powerful internationalization (i18n) framework for global

- **Unopinionated** - Integrate Lingui into your existing workflow. It supports message keys as well as auto-generated messages. Translations are stored either in JSON or standard PO files, which are supported in almost all translation tools.

- **Lightweight and optimized** - Core library is less than [3 kB gzipped](https://bundlephobia.com/result?p=@lingui/core), React components are additional [1.4 kB gzipped](https://bundlephobia.com/result?p=@lingui/react).
- **Lightweight and optimized** - Core library is less than [2 kB gzipped](https://bundlephobia.com/result?p=@lingui/core), React components are additional [1.4 kB gzipped](https://bundlephobia.com/result?p=@lingui/react).

- **Active community** - Join the growing [community of developers](https://lingui.dev/community) who are using Lingui to build global products.

Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,15 +92,15 @@
{
"path": "./packages/core/dist/index.mjs",
"import": "{ i18n }",
"limit": "3.5 kB"
"limit": "2.25 kB"
},
{
"path": "./packages/detect-locale/dist/index.mjs",
"limit": "1 kB"
},
{
"path": "./packages/react/dist/index.mjs",
"limit": "2.3 kB",
"limit": "2 kB",
"ignore": [
"react"
]
Expand Down
3 changes: 1 addition & 2 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,7 @@
],
"dependencies": {
"@babel/runtime": "^7.20.13",
"@lingui/message-utils": "5.3.2",
"unraw": "^3.0.0"
"@lingui/message-utils": "5.3.2"
},
"devDependencies": {
"@lingui/jest-mocks": "*",
Expand Down
92 changes: 92 additions & 0 deletions packages/core/src/escapeSequences.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { ESCAPE_SEQUENCE_REGEX, decodeEscapeSequences } from "./escapeSequences"

describe("escapeSequences", () => {
describe("ESCAPE_SEQUENCE_REGEX", () => {
it("should detect valid Unicode sequences", () => {
expect(ESCAPE_SEQUENCE_REGEX.test("\\u0041")).toBe(true)
expect(ESCAPE_SEQUENCE_REGEX.test("text\\u0041end")).toBe(true)
})

it("should detect valid hex sequences", () => {
expect(ESCAPE_SEQUENCE_REGEX.test("\\x41")).toBe(true)
expect(ESCAPE_SEQUENCE_REGEX.test("text\\x41end")).toBe(true)
})

it("should not detect invalid sequences", () => {
expect(ESCAPE_SEQUENCE_REGEX.test("\\u")).toBe(false)
expect(ESCAPE_SEQUENCE_REGEX.test("\\u123")).toBe(false)
expect(ESCAPE_SEQUENCE_REGEX.test("\\x")).toBe(false)
expect(ESCAPE_SEQUENCE_REGEX.test("\\x1")).toBe(false)
expect(ESCAPE_SEQUENCE_REGEX.test("plain text")).toBe(false)
})
})

describe("decodeEscapeSequences", () => {
describe("valid sequences", () => {
it("should convert basic Unicode sequences", () => {
expect(decodeEscapeSequences("\\u0041")).toBe("A")
})

it("should convert basic hex sequences", () => {
expect(decodeEscapeSequences("\\x41")).toBe("A")
})

it("should convert mixed sequences", () => {
expect(decodeEscapeSequences("\\u0041\\x42")).toBe("AB")
})

it("should handle surrogate pairs", () => {
// 😀 emoji (U+1F600) as surrogate pair
expect(decodeEscapeSequences("\\uD83D\\uDE00")).toBe("😀")
})

it("should handle special Unicode characters", () => {
expect(decodeEscapeSequences("\\u00A0")).toBe("\u00A0") // non-breaking space
expect(decodeEscapeSequences("\\u00A9")).toBe("©") // copyright
expect(decodeEscapeSequences("\\u20AC")).toBe("€") // euro
})
})

describe("invalid sequences (should remain unchanged)", () => {
it("should leave incomplete sequences as-is", () => {
expect(decodeEscapeSequences("\\u")).toBe("\\u")
expect(decodeEscapeSequences("\\u0")).toBe("\\u0")
expect(decodeEscapeSequences("\\u00")).toBe("\\u00")
expect(decodeEscapeSequences("\\u000")).toBe("\\u000")
expect(decodeEscapeSequences("\\x")).toBe("\\x")
expect(decodeEscapeSequences("\\x0")).toBe("\\x0")
})

it("should leave sequences with invalid characters as-is", () => {
expect(decodeEscapeSequences("\\u$$$$")).toBe("\\u$$$$")
expect(decodeEscapeSequences("\\x$$")).toBe("\\x$$")
expect(decodeEscapeSequences("\\u-123")).toBe("\\u-123")
expect(decodeEscapeSequences("\\x-1")).toBe("\\x-1")
})
})

describe("mixed valid and invalid sequences", () => {
it("should convert valid and leave invalid unchanged", () => {
expect(decodeEscapeSequences("\\u0041\\u123\\x42")).toBe("A\\u123B")
expect(decodeEscapeSequences("\\u0041\\x4\\u0042")).toBe("A\\x4B")
})
})

describe("edge cases", () => {
it("should handle empty string", () => {
expect(decodeEscapeSequences("")).toBe("")
})

it("should handle strings without escape sequences", () => {
expect(decodeEscapeSequences("Hello World")).toBe("Hello World")
})

it("should work with case variations", () => {
expect(decodeEscapeSequences("\\u005a")).toBe("Z") // lowercase hex
expect(decodeEscapeSequences("\\u005A")).toBe("Z") // uppercase hex
expect(decodeEscapeSequences("\\x5a")).toBe("Z") // lowercase hex
expect(decodeEscapeSequences("\\x5A")).toBe("Z") // uppercase hex
})
})
})
})
21 changes: 21 additions & 0 deletions packages/core/src/escapeSequences.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Regex for detecting escape sequences (optimized for .test() without capturing groups)
export const ESCAPE_SEQUENCE_REGEX = /\\u[a-fA-F0-9]{4}|\\x[a-fA-F0-9]{2}/

/**
* Converts escape sequences (\uXXXX Unicode and \xXX hexadecimal) to their corresponding characters
*/
export const decodeEscapeSequences = (str: string) => {
return str.replace(
// Same pattern but with capturing groups for extracting values during replacement
/\\u([a-fA-F0-9]{4})|\\x([a-fA-F0-9]{2})/g,
(_, unicode, hex) => {
if (unicode) {
const codePoint = parseInt(unicode, 16)
return String.fromCharCode(codePoint)
} else {
const codePoint = parseInt(hex, 16)
return String.fromCharCode(codePoint)
}
}
)
}
14 changes: 14 additions & 0 deletions packages/core/src/i18n.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,20 @@ describe("I18n", () => {
expect(i18n._("Software development")).toEqual("Software­entwicklung")
})

it("._ should decode escape sequences in uncompiled string messages", () => {
const i18n = setupI18n({
locale: "en",
messages: { en: {} },
})

expect(i18n._("Hello\\u0020World")).toEqual("Hello World")
expect(i18n._("Hello\\x20World")).toEqual("Hello World")
expect(i18n._("Tab\\x09separated")).toEqual("Tab\tseparated")
expect(i18n._("Mixed\\u0020\\x41nd\\u0020escaped")).toEqual(
"Mixed And escaped"
)
})

it("._ should throw a meaningful error when locale is not set", () => {
const i18n = setupI18n({})
expect(() =>
Expand Down
8 changes: 4 additions & 4 deletions packages/core/src/i18n.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { interpolate, UNICODE_REGEX } from "./interpolate"
import { interpolate } from "./interpolate"
import { isString, isFunction } from "./essentials"
import { date, defaultLocale, number } from "./formats"
import { EventEmitter } from "./eventEmitter"
import { compileMessage } from "@lingui/message-utils/compileMessage"
import type { CompiledMessage } from "@lingui/message-utils/compileMessage"
import { decodeEscapeSequences, ESCAPE_SEQUENCE_REGEX } from "./escapeSequences"

export type MessageOptions = {
message?: string
Expand Down Expand Up @@ -295,9 +296,8 @@ Please compile your catalog first.
}
}

// hack for parsing unicode values inside a string to get parsed in react native environments
if (isString(translation) && UNICODE_REGEX.test(translation))
return JSON.parse(`"${translation}"`) as string
if (isString(translation) && ESCAPE_SEQUENCE_REGEX.test(translation))
return decodeEscapeSequences(translation)
if (isString(translation)) return translation

return interpolate(
Expand Down
11 changes: 3 additions & 8 deletions packages/core/src/interpolate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,8 @@ import {
time,
} from "./formats"
import { isString } from "./essentials"
import { unraw } from "unraw"
import { CompiledIcuChoices } from "@lingui/message-utils/compileMessage"

export const UNICODE_REGEX = /\\u[a-fA-F0-9]{4}|\\x[a-fA-F0-9]{2}/
import { decodeEscapeSequences, ESCAPE_SEQUENCE_REGEX } from "./escapeSequences"

const OCTOTHORPE_PH = "%__lingui_octothorpe__%"

Expand Down Expand Up @@ -148,11 +146,8 @@ export function interpolate(
}

const result = formatMessage(translation)
if (isString(result) && UNICODE_REGEX.test(result)) {
// convert raw unicode sequences back to normal strings
// note JSON.parse hack is not working as you might expect https://stackoverflow.com/a/57560631/2210610
// that's why special library for that purpose is used
return unraw(result)
if (isString(result) && ESCAPE_SEQUENCE_REGEX.test(result)) {
return decodeEscapeSequences(result)
}
if (isString(result)) return result
return result ? String(result) : ""
Expand Down
2 changes: 1 addition & 1 deletion website/docs/introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ Integrate Lingui into your existing workflow. It supports explicit message keys

### Lightweight and Optimized

Core library is less than [3 kB gzipped](https://bundlephobia.com/result?p=@lingui/core), React components are additional [1.4 kB gzipped](https://bundlephobia.com/result?p=@lingui/react).
Core library is less than [2 kB gzipped](https://bundlephobia.com/result?p=@lingui/core), React components are additional [1.4 kB gzipped](https://bundlephobia.com/result?p=@lingui/react).

### AI Translations Ready

Expand Down
8 changes: 0 additions & 8 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2722,7 +2722,6 @@ __metadata:
"@lingui/jest-mocks": "*"
"@lingui/message-utils": 5.3.2
unbuild: 2.0.0
unraw: ^3.0.0
peerDependencies:
"@lingui/babel-plugin-lingui-macro": 5.3.2
babel-plugin-macros: 2 || 3
Expand Down Expand Up @@ -15568,13 +15567,6 @@ __metadata:
languageName: node
linkType: hard

"unraw@npm:^3.0.0":
version: 3.0.0
resolution: "unraw@npm:3.0.0"
checksum: 19eee0bc500ce197d262b79723a2c8c81c1d716baaa2a62c48a4d0d6b9e1fd9d350c5df86262e51343d591ab9c8a47ed150317d0b867b2b65795cdc17ef69873
languageName: node
linkType: hard

"untyped@npm:^1.4.0":
version: 1.4.0
resolution: "untyped@npm:1.4.0"
Expand Down