Repository: intlify/vue-i18n-next (ts)

Issue:
Hyphens in placeholders causing error `Unterminated closing brace` in vue-i18n@9
### Reporting a bug?

Using a placeholder with hyphens in vue-i18n@9.11.1 is causing errors

```js
const messages = {
  en: {
    message: {
      "message-with-placeholder-using-hyphens":
        "My message with {placeholder-hyphens}.", 
    },
  },
};
// usage:
 $t("message.message-with-placeholder-using-hyphens", {
   "placeholder-hyphens": 'XXXX'
});
```

Error message
```Message compilation error: Unterminated closing brace
1  |  My message with {placeholder-hyphens}.
```

Using `<i18n-t>` is breaking component rendering
```
<i18n-t
      keypath="message.message-with-placeholder-using-hyphens"
      v-if="false"
    >
      <template #placeholder-hyphens>
        {{ $t("message.hello") }}
      </template>
    </i18n-t>
```
Demo: https://codesandbox.io/p/devbox/vuei18n-hyphen-vxst9x?file=%2Fsrc%2FApp.vue
(`<i18n>` can be enabled via `v-if="true"`)

### Expected behavior

Above message-syntax works in vue-i18n@8 


### Reproduction

https://codesandbox.io/p/devbox/vuei18n-hyphen-vxst9x?file=%2Fsrc%2FApp.vue


### System Info

```shell
System:
    OS: Windows 11 10.0.22631
    CPU: (20) x64 13th Gen Intel(R) Core(TM) i7-13700H
    Memory: 20.88 GB / 63.68 GB
  Binaries:
    Node: 20.9.0 - C:\Program Files\nodejs\node.EXE
    npm: 6.14.18 - C:\Program Files\nodejs\npm.CMD
  Browsers:
    Edge: Chromium (123.0.2420.81)
    Internet Explorer: 11.0.22621.1
```


### Screenshot

$t error and output
![image](https://github.com/kazupon/vue-i18n/assets/1399149/3865551d-c249-4f65-9339-948e1936aafe)
![image](https://github.com/kazupon/vue-i18n/assets/1399149/c07076dc-f111-47e3-8df2-948365eab4ad)


`<i18n-t>` error
![image](https://github.com/kazupon/vue-i18n/assets/1399149/495b74ca-e5e2-4aa2-a3ed-3db831a48d0e)



### Additional context

_No response_

### Validations

- [X] Read the [Contributing Guidelines](https://github.com/intlify/vue-i18n-next/blob/master/.github/CONTRIBUTING.md)
- [X] Read the [Documentation](https://vue-i18n.intlify.dev/)
- [X] Check that there isn't [already an issue](https://github.com/intlify/vue-i18n-next/issues) that reports the same bug to avoid creating a duplicate.
- [X] Check that this is a concrete bug. For Q&A open a [GitHub Discussions](https://github.com/intlify/vue-i18n-next/discussions)

Pull request:
fix: compile error for key with included hyphen in named interpolation
resolve #1796

Patch:
diff --git a/packages/message-compiler/src/tokenizer.ts b/packages/message-compiler/src/tokenizer.ts
index 6fd23357..594b7051 100644
--- a/packages/message-compiler/src/tokenizer.ts
+++ b/packages/message-compiler/src/tokenizer.ts
@@ -420,6 +420,16 @@ export function createTokenizer(
     return takeChar(scnr, closure)
   }
 
+  // NOTE: It is assumed that this function is used in conjunction with `takeIdentifierChar` for named.
+  // TODO: we need to refactor this function ...
+  function takeNamedIdentifierChar(scnr: Scanner): string | undefined | null {
+    const closure = (ch: string) => {
+      const cc = ch.charCodeAt(0)
+      return cc === 45 // -
+    }
+    return takeChar(scnr, closure)
+  }
+
   function takeDigit(scnr: Scanner): string | undefined | null {
     const closure = (ch: string) => {
       const cc = ch.charCodeAt(0)
@@ -503,7 +513,8 @@ export function createTokenizer(
 
     let ch: string | undefined | null = ''
     let name = ''
-    while ((ch = takeIdentifierChar(scnr))) {
+    // eslint-disable-next-line no-cond-assign
+    while ((ch = takeIdentifierChar(scnr) || takeNamedIdentifierChar(scnr))) {
       name += ch
     }
 
diff --git a/spec/syntax.ebnf b/spec/syntax.ebnf
index c285f0f3..326aaf63 100644
--- a/spec/syntax.ebnf
+++ b/spec/syntax.ebnf
@@ -1,5 +1,5 @@
 (*
- * Inltify message syntax v0.2
+ * Inltify message syntax v0.3
  * (vue-i18n compatible)
  *)
 
@@ -14,7 +14,7 @@ Message ::= (Text? (Placeholder | Linked)? Text?)+;
 Text ::= TextChar+;
 Placeholder ::= Named | List | StringLiteral;
 Modulo ::= "%";
-Named ::= Modulo? "{" Space? (Identifier) Space? "}";
+Named ::= Modulo? "{" Space? (NamedIdentifier) Space? "}";
 List ::= "{" Space? (NumberLiteral) Space? "}";
 Linked ::= "@" (LinkedModifier)? LinkedDelimiter LinkedRefer;
 LinkedRefer ::= LinkedKey | Placeholder;
@@ -41,6 +41,7 @@ Digits ::= [0-9]+;
 
 (* identifier *)
 Identifier ::= [a-zA-Z_] [a-zA-Z0-9_$]*;
+NamedIdentifier ::= [a-zA-Z_] [a-zA-Z0-9_\-$]*;
 
 (* whitespaces *)
 SpaceInline ::= #x0020; (* "\u0020" *)


Test patch:
diff --git a/packages/message-compiler/test/tokenizer/named.test.ts b/packages/message-compiler/test/tokenizer/named.test.ts
index 6bf9f7b5..6dba32a4 100644
--- a/packages/message-compiler/test/tokenizer/named.test.ts
+++ b/packages/message-compiler/test/tokenizer/named.test.ts
@@ -310,6 +310,57 @@ test('new line', () => {
   })
 })
 
+test('include hyphen', () => {
+  const tokenizer = createTokenizer('My Nickname is {nick-name}.')
+  expect(tokenizer.nextToken()).toEqual({
+    type: TokenTypes.Text,
+    value: 'My Nickname is ',
+    loc: {
+      start: { line: 1, column: 1, offset: 0 },
+      end: { line: 1, column: 16, offset: 15 }
+    }
+  })
+  expect(tokenizer.nextToken()).toEqual({
+    type: TokenTypes.BraceLeft,
+    value: '{',
+    loc: {
+      start: { line: 1, column: 16, offset: 15 },
+      end: { line: 1, column: 17, offset: 16 }
+    }
+  })
+  expect(tokenizer.nextToken()).toEqual({
+    type: TokenTypes.Named,
+    value: 'nick-name',
+    loc: {
+      start: { line: 1, column: 17, offset: 16 },
+      end: { line: 1, column: 26, offset: 25 }
+    }
+  })
+  expect(tokenizer.nextToken()).toEqual({
+    type: TokenTypes.BraceRight,
+    value: '}',
+    loc: {
+      start: { line: 1, column: 26, offset: 25 },
+      end: { line: 1, column: 27, offset: 26 }
+    }
+  })
+  expect(tokenizer.nextToken()).toEqual({
+    type: TokenTypes.Text,
+    value: '.',
+    loc: {
+      start: { line: 1, column: 27, offset: 26 },
+      end: { line: 1, column: 28, offset: 27 }
+    }
+  })
+  expect(tokenizer.nextToken()).toEqual({
+    type: TokenTypes.EOF,
+    loc: {
+      start: { line: 1, column: 28, offset: 27 },
+      end: { line: 1, column: 28, offset: 27 }
+    }
+  })
+})
+
 describe('modulo cases', () => {
   test('basic named: hi %{name} !', () => {
     const tokenizer = createTokenizer('hi %{name} !')
diff --git a/packages/vue-i18n-core/test/issues.test.ts b/packages/vue-i18n-core/test/issues.test.ts
index 550e288c..b9edbf81 100644
--- a/packages/vue-i18n-core/test/issues.test.ts
+++ b/packages/vue-i18n-core/test/issues.test.ts
@@ -1358,3 +1358,21 @@ test('issue #1738', async () => {
   expect(wrapper.find('#te1')?.textContent).toEqual(`true - expected true`)
   expect(wrapper.find('#te2')?.textContent).toEqual(`true - expected true`)
 })
+
+test('#1796', async () => {
+  const i18n = createI18n({
+    locale: 'en',
+    messages: {
+      en: {
+        hello: 'hello world',
+        'message-with-placeholder-using-hyphens':
+          'My message with {placeholder-hyphens}.'
+      }
+    }
+  })
+  expect(
+    i18n.global.t('message-with-placeholder-using-hyphens', {
+      'placeholder-hyphens': i18n.global.t('hello')
+    })
+  ).toEqual('My message with hello world.')
+})