From 5a1e4f2ed04dcf4a4733606b84cc4cadd369caf4 Mon Sep 17 00:00:00 2001 From: lutejka Date: Fri, 5 Sep 2025 09:21:06 +0200 Subject: [PATCH 1/2] Emit INVALID_TOKEN_IN_PLACEHOLDER instead of UNTERMINATED_CLOSING_BRACE when invalid token is in placeholder and update docs (#2252) --- docs/guide/essentials/syntax.md | 71 ++++++++++++ packages/message-compiler/src/tokenizer.ts | 20 ++++ .../test/tokenizer/named.test.ts | 108 +++++++++++++----- 3 files changed, 169 insertions(+), 30 deletions(-) diff --git a/docs/guide/essentials/syntax.md b/docs/guide/essentials/syntax.md index e5c2dc5b8..91329ff17 100644 --- a/docs/guide/essentials/syntax.md +++ b/docs/guide/essentials/syntax.md @@ -26,6 +26,10 @@ The locale messages is the resource specified by the `messages` option of `creat Named interpolation allows you to specify variables defined in JavaScript. In the locale message above, you can localize it by giving the JavaScript defined `msg` as a parameter to the translation function. +The variable name inside `{}` must starts with a letter (a-z, A-Z) or an underscore (`_`), followed by any combination of letters, digits, underscores (`_`), hyphens (`-`), or dollar signs (`$`). + +Examples: `{msg}`, `{_userName}`, `{user-id}`, `{total$}` + The following is an example of the use of `$t` in a template: ```html @@ -241,6 +245,73 @@ You can use the interpolations (Named, List, and Literal) for the key of Linked ::: +This example shows the use of modifiers (`@.lower`, `@.upper`, `@.capitalize`) combined with named, list, and literal interpolations. + + +```js +const messages = { + en: { + message: { + greeting: "Hello, @.lower:{'message.name'}! You have {count} new messages.", + name:"{name}" + }, + + welcome: "Welcome, @.upper:{'name'}! Today is @.capitalize:{'day'}.", + name: '{0}', + day: '{1}', + + literalMessage: "This is an email: foo{'@'}@.lower:domain", + domain: 'SHOUTING' + } +} +``` +### Named interpolation with modifier + +In `message.greeting`, we use a named interpolation for `{count}` and link to `message.name`, applying the .lower modifier. + +The key `message.name` contains `{name}`, which will be interpolated with the passed `name` param. + +The `message.greeting` is linked to the locale message key `message.name`. + +```html +

{{ $t('message.greeting', { name: 'Alice', count: 5 }) }}

+``` +As result, the below + +```html +

Hello, alice! You have 5 new messages.

+``` + +### List interpolation with modifier + +In this case, the values for `{0}` and `{1}` are passed as an array. The keys `name` and `day` are resolved using list interpolation and transformed with modifiers. + +```html +

{{ $t('welcome', ['bob', 'MONDAY']) }}

+``` + +As result, the below + +```html +

Welcome, BOB! Today is Monday.

+``` + +### Literal interpolation with modifier + +In this example, we use a literal string inside the message and apply the `.lower` modifier. + +```html +

{{ $t('literalMessage') }}

+``` + +Here, the modifier is applied to the content inside `domain`, and the `@` is preserved as literal output. + +As result, the below + +```html +

This is an email: foo@shouting

+``` + ## Special Characters The following characters used in the message format syntax are processed by the compiler as special characters: diff --git a/packages/message-compiler/src/tokenizer.ts b/packages/message-compiler/src/tokenizer.ts index 0ef63bf88..6f94bfe9b 100644 --- a/packages/message-compiler/src/tokenizer.ts +++ b/packages/message-compiler/src/tokenizer.ts @@ -484,6 +484,26 @@ export function createTokenizer( name += ch } + // Check if takeNamedIdentifierChar stoped because of invalid characters + const currentChar = scnr.currentChar() + if ( + currentChar && + currentChar !== '}' && + currentChar !== EOF && + currentChar !== SPACE && + currentChar !== NEW_LINE && + currentChar !== '\u3000' + ) { + const invalidPart = readInvalidIdentifier(scnr) + emitError( + CompileErrorCodes.INVALID_TOKEN_IN_PLACEHOLDER, + currentPosition(), + 0, + name + invalidPart + ) + return name + invalidPart + } + if (scnr.currentChar() === EOF) { emitError( CompileErrorCodes.UNTERMINATED_CLOSING_BRACE, diff --git a/packages/message-compiler/test/tokenizer/named.test.ts b/packages/message-compiler/test/tokenizer/named.test.ts index c7c3bb3c6..7405000a8 100644 --- a/packages/message-compiler/test/tokenizer/named.test.ts +++ b/packages/message-compiler/test/tokenizer/named.test.ts @@ -2,13 +2,13 @@ import { format } from '@intlify/shared' import { CompileErrorCodes, errorMessages } from '../../src/errors' import { createTokenizer, - TokenTypes, ERROR_DOMAIN, - parse + parse, + TokenTypes } from '../../src/tokenizer' -import type { TokenizeOptions } from '../../src/options' import type { CompileError } from '../../src/errors' +import type { TokenizeOptions } from '../../src/options' test('basic', () => { const tokenizer = createTokenizer('hi {name} !') @@ -645,32 +645,80 @@ describe('errors', () => { } ] as CompileError[]) }) - const items = [`$`, `-`] - for (const ch of items) { - test(`invalid '${ch}' in placeholder`, () => { - parse(`hi {${ch}} !`, options) - expect(errors).toEqual([ - { - code: CompileErrorCodes.INVALID_TOKEN_IN_PLACEHOLDER, - domain: ERROR_DOMAIN, - message: format( - errorMessages[CompileErrorCodes.INVALID_TOKEN_IN_PLACEHOLDER], - ch - ), - location: { - start: { - line: 1, - offset: 4, - column: 5 - }, - end: { - line: 1, - offset: 5, - column: 6 - } - } + + test.each([ + [ + '$', + { + start: { + line: 1, + offset: 4, + column: 5 + }, + end: { + line: 1, + offset: 5, + column: 6 + } + } + ], + [ + '-', + { + start: { + line: 1, + offset: 4, + column: 5 + }, + end: { + line: 1, + offset: 5, + column: 6 + } + } + ], + [ + 'àaa', + { + start: { + line: 1, + offset: 4, + column: 5 + }, + end: { + line: 1, + offset: 7, + column: 8 } - ] as CompileError[]) - }) - } + } + ], + [ + 'aàa', + { + start: { + line: 1, + offset: 4, + column: 5 + }, + end: { + line: 1, + offset: 7, + column: 8 + } + } + ] + ])(`invalid '%s' in placeholder`, (ch, location) => { + parse(`hi {${ch}} !`, options) + expect(errors).toEqual([ + { + code: CompileErrorCodes.INVALID_TOKEN_IN_PLACEHOLDER, + domain: ERROR_DOMAIN, + message: format( + errorMessages[CompileErrorCodes.INVALID_TOKEN_IN_PLACEHOLDER], + ch + ), + location + } + ] as CompileError[]) + }) }) From 20d5650d422e25a56cb8c9d7200e5c6518119c58 Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Fri, 5 Sep 2025 16:41:16 +0900 Subject: [PATCH 2/2] chore: drop node v18 --- .github/workflows/ci.yml | 8 ++++---- .github/workflows/nightly-release.yml | 2 +- .github/workflows/release.yml | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e5981e70b..2ab86cd45 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: strategy: matrix: os: [ubuntu-latest] - node: [18] + node: [20] runs-on: ${{ matrix.os }} @@ -47,7 +47,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, windows-latest, macos-latest] - node: [18] + node: [20] runs-on: ${{ matrix.os }} @@ -83,7 +83,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, windows-latest, macos-latest] - node: [18.19, 20, 22] + node: [20, 22, 24] runs-on: ${{ matrix.os }} @@ -116,7 +116,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, windows-latest, macos-latest] - node: [18.19, 20, 22] + node: [20, 22, 24] runs-on: ${{ matrix.os }} diff --git a/.github/workflows/nightly-release.yml b/.github/workflows/nightly-release.yml index 8c3b9df44..76ed8570d 100644 --- a/.github/workflows/nightly-release.yml +++ b/.github/workflows/nightly-release.yml @@ -23,7 +23,7 @@ jobs: - name: Setup node uses: actions/setup-node@v4 with: - node-version: 18.18 + node-version: 20 cache: pnpm - name: Install dependencies diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c5999e0ce..e3b1fa397 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,7 +27,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: 18 + node-version: 20 - name: Install dependencies run: pnpm install --no-frozen-lockfile