Repository: intlify/vue-i18n-next
Pull request #1795 (2024-04-12 01:08:49)

fix: deprecate named interpolation with modulo syntax
## summary
Named interpolation using modulo `%` is no longer supported in v10.
https://vue-i18n.intlify.dev/guide/essentials/syntax#rails-i18n-format

This PR allows warnings to be outpu, and `modulo` data to AST (`NamedNode`) generated by the compiler.

That will be used for linting by eslint-plugin-vue-i18n.
This plugin also easiliy migration to named interpolation.
ref: https://github.com/intlify/eslint-plugin-vue-i18n/issues/495



Patch:
diff --git a/packages/core-base/src/compilation.ts b/packages/core-base/src/compilation.ts
index 79e4b135..66ca10a4 100644
--- a/packages/core-base/src/compilation.ts
+++ b/packages/core-base/src/compilation.ts
@@ -1,6 +1,7 @@
 import { warn, format, isObject, isBoolean, isString } from '@intlify/shared'
 import {
   baseCompile as baseCompileCore,
+  CompileWarnCodes,
   defaultOnError,
   detectHtmlTag
 } from '@intlify/message-compiler'
@@ -11,7 +12,8 @@ import type {
   CompileOptions,
   CompileError,
   CompilerResult,
-  ResourceNode
+  ResourceNode,
+  CompileWarn
 } from '@intlify/message-compiler'
 import type { MessageFunction, MessageFunctions } from './runtime'
 import type { MessageCompilerContext } from './context'
@@ -27,6 +29,17 @@ function checkHtmlMessage(source: string, warnHtmlMessage?: boolean): void {
 const defaultOnCacheKey = (message: string): string => message
 let compileCache: unknown = Object.create(null)
 
+function onCompileWarn(_warn: CompileWarn): void {
+  if (_warn.code === CompileWarnCodes.USE_MODULO_SYNTAX) {
+    warn(
+      `The use of named interpolation with modulo syntax is deprecated. ` +
+        `It will be removed in v10.\n` +
+        `reference: https://vue-i18n.intlify.dev/guide/essentials/syntax#rails-i18n-format \n` +
+        `(message compiler warning message: ${_warn.message})`
+    )
+  }
+}
+
 export function clearCompileCache(): void {
   compileCache = Object.create(null)
 }
@@ -64,6 +77,11 @@ export const compileToFunction = <
     throw createCoreError(CoreErrorCodes.NOT_SUPPORT_NON_STRING_MESSAGE)
   }
 
+  // set onWarn
+  if (__DEV__) {
+    context.onWarn = onCompileWarn
+  }
+
   if (__RUNTIME__) {
     __DEV__ &&
       warn(
@@ -107,6 +125,11 @@ export function compile<
   message: MessageSource,
   context: MessageCompilerContext
 ): MessageFunction<Message> {
+  // set onWarn
+  if (__DEV__) {
+    context.onWarn = onCompileWarn
+  }
+
   if (
     (__ESM_BROWSER__ ||
       __NODE_JS__ ||
diff --git a/packages/core-base/src/context.ts b/packages/core-base/src/context.ts
index 3c673f74..bf0a6455 100644
--- a/packages/core-base/src/context.ts
+++ b/packages/core-base/src/context.ts
@@ -66,14 +66,14 @@ export type LocaleMessageValue<Message = string> =
 export type LocaleMessageType<T, Message = string> = T extends string
   ? string
   : T extends () => Promise<infer P>
-    ? LocaleMessageDictionary<P, Message>
-    : T extends (...args: infer Arguments) => any
-      ? (...args: Arguments) => ReturnType<T>
-      : T extends Record<string, unknown>
-        ? LocaleMessageDictionary<T, Message>
-        : T extends Array<T>
-          ? { [K in keyof T]: T[K] }
-          : T
+  ? LocaleMessageDictionary<P, Message>
+  : T extends (...args: infer Arguments) => any
+  ? (...args: Arguments) => ReturnType<T>
+  : T extends Record<string, unknown>
+  ? LocaleMessageDictionary<T, Message>
+  : T extends Array<T>
+  ? { [K in keyof T]: T[K] }
+  : T
 
 /** @VueI18nGeneral */
 export type LocaleMessageDictionary<T, Message = string> = {
@@ -145,7 +145,7 @@ export type PostTranslationHandler<Message = string> = (
  */
 export type MessageCompilerContext = Pick<
   CompileOptions,
-  'onError' | 'onCacheKey'
+  'onError' | 'onCacheKey' | 'onWarn'
 > & {
   /**
    * Whether to allow the use locale messages of HTML formatting.
@@ -183,37 +183,37 @@ export type MessageCompiler<
 export interface CoreOptions<
   Message = string,
   Schema extends
-    {
-      message?: unknown
-      datetime?: unknown
-      number?: unknown
-    } = {
-      message: DefaultCoreLocaleMessageSchema,
-      datetime: DateTimeFormat,
-      number: NumberFormat
-    },
+  {
+    message?: unknown
+    datetime?: unknown
+    number?: unknown
+  } = {
+    message: DefaultCoreLocaleMessageSchema,
+    datetime: DateTimeFormat,
+    number: NumberFormat
+  },
   Locales extends
-    | {
-        messages: unknown
-        datetimeFormats: unknown
-        numberFormats: unknown
-      }
-    | string = Locale,
+  | {
+    messages: unknown
+    datetimeFormats: unknown
+    numberFormats: unknown
+  }
+  | string = Locale,
   MessagesLocales = Locales extends { messages: infer M }
-    ? M
-    : Locales extends string
-      ? Locales
-      : Locale,
+  ? M
+  : Locales extends string
+  ? Locales
+  : Locale,
   DateTimeFormatsLocales = Locales extends { datetimeFormats: infer D }
-      ? D
-      : Locales extends string
-        ? Locales
-        : Locale,
+  ? D
+  : Locales extends string
+  ? Locales
+  : Locale,
   NumberFormatsLocales = Locales extends { numberFormats: infer N }
-    ? N
-    : Locales extends string
-      ? Locales
-      : Locale,
+  ? N
+  : Locales extends string
+  ? Locales
+  : Locale,
   MessageSchema = Schema extends { message: infer M } ? M : DefaultCoreLocaleMessageSchema,
   DateTimeSchema = Schema extends { datetime: infer D } ? D : DateTimeFormat,
   NumberSchema = Schema extends { number: infer N } ? N : NumberFormat,
@@ -300,14 +300,14 @@ export type CoreContext<
   NumberFormats = {},
   LocaleType = Locale,
   ResourceLocales =
-    | PickupLocales<NonNullable<Messages>>
-    | PickupLocales<NonNullable<DateTimeFormats>>
-    | PickupLocales<NonNullable<NumberFormats>>,
+  | PickupLocales<NonNullable<Messages>>
+  | PickupLocales<NonNullable<DateTimeFormats>>
+  | PickupLocales<NonNullable<NumberFormats>>,
   Locales = IsNever<ResourceLocales> extends true
-    ? LocaleType extends LocaleDetector | Locale
-      ? LocaleType
-      : Locale
-    : ResourceLocales
+  ? LocaleType extends LocaleDetector | Locale
+  ? LocaleType
+  : Locale
+  : ResourceLocales
 > = CoreCommonContext<Message, Locales> &
   CoreTranslationContext<NonNullable<Messages>, Message> &
   CoreDateTimeContext<NonNullable<DateTimeFormats>> &
@@ -355,7 +355,7 @@ function getDefaultLinkedModifiers<
       return type === 'text' && isString(val)
         ? val.toUpperCase()
         : type === 'vnode' && isObject(val) && '__v_isVNode' in val
-        ? (val as any).children.toUpperCase()
+          ? (val as any).children.toUpperCase()
           : val
     },
     lower: (val: Message, type: string): MessageType<Message> => {
@@ -371,7 +371,7 @@ function getDefaultLinkedModifiers<
       return (type === 'text' && isString(val)
         ? capitalize(val)
         : type === 'vnode' && isObject(val) && '__v_isVNode' in val
-          ? capitalize( (val as any).children)
+          ? capitalize((val as any).children)
           : val) as MessageType<Message>
     }
   }
diff --git a/packages/core-base/src/errors.ts b/packages/core-base/src/errors.ts
index 353ebd2b..0d86ccbb 100644
--- a/packages/core-base/src/errors.ts
+++ b/packages/core-base/src/errors.ts
@@ -12,14 +12,14 @@ const code = CompileErrorCodes.__EXTEND_POINT__
 const inc = incrementer(code)
 
 export const CoreErrorCodes = {
-  INVALID_ARGUMENT: code, // 18
-  INVALID_DATE_ARGUMENT: inc(), // 19
-  INVALID_ISO_DATE_ARGUMENT: inc(), // 20
-  NOT_SUPPORT_NON_STRING_MESSAGE: inc(), // 21
-  NOT_SUPPORT_LOCALE_PROMISE_VALUE: inc(), // 22
-  NOT_SUPPORT_LOCALE_ASYNC_FUNCTION: inc(), // 23
-  NOT_SUPPORT_LOCALE_TYPE: inc(), // 24
-  __EXTEND_POINT__: inc() // 25
+  INVALID_ARGUMENT: code, // 17
+  INVALID_DATE_ARGUMENT: inc(), // 18
+  INVALID_ISO_DATE_ARGUMENT: inc(), // 19
+  NOT_SUPPORT_NON_STRING_MESSAGE: inc(), // 20
+  NOT_SUPPORT_LOCALE_PROMISE_VALUE: inc(), // 21
+  NOT_SUPPORT_LOCALE_ASYNC_FUNCTION: inc(), // 22
+  NOT_SUPPORT_LOCALE_TYPE: inc(), // 23
+  __EXTEND_POINT__: inc() // 24
 } as const
 
 export type CoreErrorCodes =
diff --git a/packages/core-base/src/warnings.ts b/packages/core-base/src/warnings.ts
index 61c1e564..db6e0b29 100644
--- a/packages/core-base/src/warnings.ts
+++ b/packages/core-base/src/warnings.ts
@@ -1,14 +1,18 @@
-import { format } from '@intlify/shared'
+import { format, incrementer } from '@intlify/shared'
+import { CompileWarnCodes } from '@intlify/message-compiler'
+
+const code = CompileWarnCodes.__EXTEND_POINT__
+const inc = incrementer(code)
 
 export const CoreWarnCodes = {
-  NOT_FOUND_KEY: 1,
-  FALLBACK_TO_TRANSLATE: 2,
-  CANNOT_FORMAT_NUMBER: 3,
-  FALLBACK_TO_NUMBER_FORMAT: 4,
-  CANNOT_FORMAT_DATE: 5,
-  FALLBACK_TO_DATE_FORMAT: 6,
-  EXPERIMENTAL_CUSTOM_MESSAGE_COMPILER: 7,
-  __EXTEND_POINT__: 8
+  NOT_FOUND_KEY: code, // 2
+  FALLBACK_TO_TRANSLATE: inc(), // 3
+  CANNOT_FORMAT_NUMBER: inc(), // 4
+  FALLBACK_TO_NUMBER_FORMAT: inc(), // 5
+  CANNOT_FORMAT_DATE: inc(), // 6
+  FALLBACK_TO_DATE_FORMAT: inc(), // 7
+  EXPERIMENTAL_CUSTOM_MESSAGE_COMPILER: inc(), // 8
+  __EXTEND_POINT__: inc() // 9
 } as const
 
 export type CoreWarnCodes = (typeof CoreWarnCodes)[keyof typeof CoreWarnCodes]
diff --git a/packages/message-compiler/src/index.ts b/packages/message-compiler/src/index.ts
index f4fa8bf8..9f129cf6 100644
--- a/packages/message-compiler/src/index.ts
+++ b/packages/message-compiler/src/index.ts
@@ -1,6 +1,7 @@
 export * from './location'
 export * from './nodes'
 export * from './options'
+export * from './warnings'
 export * from './errors'
 export * from './helpers'
 export * from './parser'
diff --git a/packages/message-compiler/src/nodes.ts b/packages/message-compiler/src/nodes.ts
index 658fa73a..ee8ba188 100644
--- a/packages/message-compiler/src/nodes.ts
+++ b/packages/message-compiler/src/nodes.ts
@@ -80,6 +80,7 @@ export interface TextNode extends Node {
 export interface NamedNode extends Node {
   type: NodeTypes.Named
   key: Identifier
+  modulo?: boolean
   /**
    * @internal `key` alias
    */
diff --git a/packages/message-compiler/src/options.ts b/packages/message-compiler/src/options.ts
index 46401056..f9597065 100644
--- a/packages/message-compiler/src/options.ts
+++ b/packages/message-compiler/src/options.ts
@@ -1,5 +1,7 @@
 import type { CompileError } from './errors'
+import type { CompileWarn } from './warnings'
 
+export type CompileWarnHandler = (warn: CompileWarn) => void
 export type CompileErrorHandler = (error: CompileError) => void
 export type CacheKeyHandler = (source: string) => string
 
@@ -11,10 +13,12 @@ export interface TokenizeOptions {
 export interface ParserOptions {
   location?: boolean // default true
   onCacheKey?: (source: string) => string
+  onWarn?: CompileWarnHandler
   onError?: CompileErrorHandler
 }
 
 export interface TransformOptions {
+  onWarn?: CompileWarnHandler
   onError?: CompileErrorHandler
 }
 
@@ -23,6 +27,7 @@ export interface CodeGenOptions {
   mode?: 'normal' | 'arrow' // default normal
   breakLineCode?: '\n' | ';' // default newline
   needIndent?: boolean // default true
+  onWarn?: CompileWarnHandler
   onError?: CompileErrorHandler
   // Generate source map?
   // - Default: false
diff --git a/packages/message-compiler/src/parser.ts b/packages/message-compiler/src/parser.ts
index 5b960802..a4619f72 100644
--- a/packages/message-compiler/src/parser.ts
+++ b/packages/message-compiler/src/parser.ts
@@ -1,5 +1,6 @@
 import { createLocation } from './location'
 import { createCompileError, CompileErrorCodes } from './errors'
+import { createCompileWarn, CompileWarnCodes } from './warnings'
 import { createTokenizer, TokenTypes } from './tokenizer'
 import { NodeTypes } from './nodes'
 import { assign } from '@intlify/shared'
@@ -57,7 +58,7 @@ function fromEscapeSequence(
 export function createParser(options: ParserOptions = {}): Parser {
   const location = options.location !== false
 
-  const { onError } = options
+  const { onError, onWarn } = options
   function emitError(
     tokenzer: Tokenizer,
     code: CompileErrorCodes,
@@ -77,6 +78,21 @@ export function createParser(options: ParserOptions = {}): Parser {
       onError(err)
     }
   }
+  function emitWarn(
+    tokenzer: Tokenizer,
+    code: CompileWarnCodes,
+    start: Position,
+    offset: number,
+    ...args: unknown[]
+  ): void {
+    const end = tokenzer.currentPosition()
+    end.offset += offset
+    end.column += offset
+    if (onWarn) {
+      const loc = location ? createLocation(start, end) : null
+      onWarn(createCompileWarn(code, loc, args))
+    }
+  }
 
   function startNode(type: NodeTypes, offset: number, loc: Position): Node {
     const node = { type } as Node
@@ -130,11 +146,18 @@ export function createParser(options: ParserOptions = {}): Parser {
     return node
   }
 
-  function parseNamed(tokenizer: Tokenizer, key: string): NamedNode {
+  function parseNamed(
+    tokenizer: Tokenizer,
+    key: string,
+    modulo?: boolean
+  ): NamedNode {
     const context = tokenizer.context()
     const { lastOffset: offset, lastStartLoc: loc } = context // get brace left loc
     const node = startNode(NodeTypes.Named, offset, loc) as NamedNode
     node.key = key
+    if (modulo === true) {
+      node.modulo = true
+    }
     tokenizer.nextToken() // skip brach right
     endNode(node, tokenizer.currentOffset(), tokenizer.currentPosition())
     return node
@@ -339,6 +362,7 @@ export function createParser(options: ParserOptions = {}): Parser {
     node.items = []
 
     let nextToken: Token | null = null
+    let modulo: boolean | null = null
     do {
       const token = nextToken || tokenizer.nextToken()
       nextToken = null
@@ -367,6 +391,9 @@ export function createParser(options: ParserOptions = {}): Parser {
           }
           node.items.push(parseList(tokenizer, token.value || ''))
           break
+        case TokenTypes.Modulo:
+          modulo = true
+          break
         case TokenTypes.Named:
           if (token.value == null) {
             emitError(
@@ -377,7 +404,17 @@ export function createParser(options: ParserOptions = {}): Parser {
               getTokenCaption(token)
             )
           }
-          node.items.push(parseNamed(tokenizer, token.value || ''))
+          node.items.push(parseNamed(tokenizer, token.value || '', !!modulo))
+          if (modulo) {
+            emitWarn(
+              tokenizer,
+              CompileWarnCodes.USE_MODULO_SYNTAX,
+              context.lastStartLoc,
+              0,
+              getTokenCaption(token)
+            )
+            modulo = null
+          }
           break
         case TokenTypes.Literal:
           if (token.value == null) {
diff --git a/packages/message-compiler/src/warnings.ts b/packages/message-compiler/src/warnings.ts
new file mode 100644
index 00000000..0bf66ec6
--- /dev/null
+++ b/packages/message-compiler/src/warnings.ts
@@ -0,0 +1,35 @@
+import { format } from '@intlify/shared'
+
+import type { SourceLocation } from './location'
+
+export const CompileWarnCodes = {
+  USE_MODULO_SYNTAX: 1,
+  __EXTEND_POINT__: 2
+} as const
+
+export interface CompileWarn {
+  message: string
+  code: number
+  location?: SourceLocation
+}
+
+export type CompileWarnCodes =
+  (typeof CompileWarnCodes)[keyof typeof CompileWarnCodes]
+
+/** @internal */
+export const warnMessages: { [code: number]: string } = {
+  [CompileWarnCodes.USE_MODULO_SYNTAX]: `Use modulo before '{{0}}'.`
+}
+
+export function createCompileWarn<T extends number>(
+  code: T,
+  loc: SourceLocation | null,
+  ...args: unknown[]
+): CompileWarn {
+  const msg = __DEV__ ? format(warnMessages[code] || '', ...(args || [])) : code
+  const message: CompileWarn = { message: String(msg), code }
+  if (loc) {
+    message.location = loc
+  }
+  return message
+}
diff --git a/packages/vue-i18n-core/src/errors.ts b/packages/vue-i18n-core/src/errors.ts
index 76327506..8a796d5a 100644
--- a/packages/vue-i18n-core/src/errors.ts
+++ b/packages/vue-i18n-core/src/errors.ts
@@ -10,31 +10,31 @@ const inc = incrementer(code)
 
 export const I18nErrorCodes = {
   // composer module errors
-  UNEXPECTED_RETURN_TYPE: code, // 26
+  UNEXPECTED_RETURN_TYPE: code, // 24
   // legacy module errors
-  INVALID_ARGUMENT: inc(), // 27
+  INVALID_ARGUMENT: inc(), // 25
   // i18n module errors
-  MUST_BE_CALL_SETUP_TOP: inc(), // 28
-  NOT_INSTALLED: inc(), // 29
-  NOT_AVAILABLE_IN_LEGACY_MODE: inc(), // 30
+  MUST_BE_CALL_SETUP_TOP: inc(), // 26
+  NOT_INSTALLED: inc(), // 27
+  NOT_AVAILABLE_IN_LEGACY_MODE: inc(), // 28
   // directive module errors
-  REQUIRED_VALUE: inc(), // 31
-  INVALID_VALUE: inc(), // 32
+  REQUIRED_VALUE: inc(), // 29
+  INVALID_VALUE: inc(), // 30
   // vue-devtools errors
-  CANNOT_SETUP_VUE_DEVTOOLS_PLUGIN: inc(), // 33
-  NOT_INSTALLED_WITH_PROVIDE: inc(), // 34
+  CANNOT_SETUP_VUE_DEVTOOLS_PLUGIN: inc(), // 31
+  NOT_INSTALLED_WITH_PROVIDE: inc(), // 32
   // unexpected error
-  UNEXPECTED_ERROR: inc(), // 35
+  UNEXPECTED_ERROR: inc(), // 33
   // not compatible legacy vue-i18n constructor
-  NOT_COMPATIBLE_LEGACY_VUE_I18N: inc(), // 36
+  NOT_COMPATIBLE_LEGACY_VUE_I18N: inc(), // 34
   // bridge support vue 2.x only
-  BRIDGE_SUPPORT_VUE_2_ONLY: inc(), // 37
+  BRIDGE_SUPPORT_VUE_2_ONLY: inc(), // 35
   // need to define `i18n` option in `allowComposition: true` and `useScope: 'local' at `useI18n``
-  MUST_DEFINE_I18N_OPTION_IN_ALLOW_COMPOSITION: inc(), // 38
+  MUST_DEFINE_I18N_OPTION_IN_ALLOW_COMPOSITION: inc(), // 36
   // Not available Compostion API in Legacy API mode. Please make sure that the legacy API mode is working properly
-  NOT_AVAILABLE_COMPOSITION_IN_LEGACY: inc(), // 39
+  NOT_AVAILABLE_COMPOSITION_IN_LEGACY: inc(), // 37
   // for enhancement
-  __EXTEND_POINT__: inc() // 40
+  __EXTEND_POINT__: inc() // 38
 } as const
 
 type I18nErrorCodes = (typeof I18nErrorCodes)[keyof typeof I18nErrorCodes]
diff --git a/rollup.config.mjs b/rollup.config.mjs
index fee0f19b..3f46124e 100644
--- a/rollup.config.mjs
+++ b/rollup.config.mjs
@@ -380,6 +380,8 @@ function createReplacePlugin(
       ? {
           'emitError(': `/*#__PURE__*/ emitError(`,
           'createCompileError(': `/*#__PURE__*/ createCompileError(`,
+          'emitWarn(': `/*#__PURE__*/ emitWarn(`,
+          'createCompileWarn(': `/*#__PURE__*/ createCompileWarn(`,
           'function createCoreError(': `/*#__PURE__*/ function createCoreError(`,
           'throw createCoreError(': `throw Error(`,
           'function createI18nError(': `/*#__PURE__*/ function createI18nError(`,


Test patch:
diff --git a/packages/core-base/test/compilation.test.ts b/packages/core-base/test/compilation.test.ts
index 97747fec..235f6ea6 100644
--- a/packages/core-base/test/compilation.test.ts
+++ b/packages/core-base/test/compilation.test.ts
@@ -1,3 +1,13 @@
+// utils
+import * as shared from '@intlify/shared'
+vi.mock('@intlify/shared', async () => {
+  const actual = await vi.importActual<object>('@intlify/shared')
+  return {
+    ...actual,
+    warn: vi.fn()
+  }
+})
+
 import { baseCompile } from '@intlify/message-compiler'
 import {
   compileToFunction,
@@ -9,7 +19,7 @@ import { createMessageContext as context } from '../src/runtime'
 
 const DEFAULT_CONTEXT = { locale: 'en', key: 'key' }
 
-beforeAll(() => {
+beforeEach(() => {
   clearCompileCache()
 })
 
@@ -50,6 +60,19 @@ describe('compileToFunction', () => {
     })
     expect(occured).toBe(true)
   })
+
+  test('modulo syntax warning', () => {
+    const mockWarn = vi.spyOn(shared, 'warn')
+    mockWarn.mockImplementation(() => {})
+
+    compileToFunction('hello %{name}!', {
+      ...DEFAULT_CONTEXT
+    })
+    expect(mockWarn).toHaveBeenCalledTimes(1)
+    expect(mockWarn.mock.calls[0][0]).includes(
+      `The use of named interpolation with modulo syntax is deprecated. It will be removed in v10.`
+    )
+  })
 })
 
 describe('compile', () => {
@@ -109,4 +132,15 @@ describe('compile', () => {
     })
     expect(occured).toBe(true)
   })
+
+  test('modulo syntax warning', () => {
+    const mockWarn = vi.spyOn(shared, 'warn')
+    mockWarn.mockImplementation(() => {})
+
+    compile('%{msg} world!', DEFAULT_CONTEXT)
+    expect(mockWarn).toHaveBeenCalledTimes(1)
+    expect(mockWarn.mock.calls[0][0]).includes(
+      `The use of named interpolation with modulo syntax is deprecated. It will be removed in v10.`
+    )
+  })
 })
diff --git a/packages/core-base/test/errors.test.ts b/packages/core-base/test/errors.test.ts
new file mode 100644
index 00000000..abaae8ba
--- /dev/null
+++ b/packages/core-base/test/errors.test.ts
@@ -0,0 +1,5 @@
+import { CoreErrorCodes } from '../src/errors'
+
+test('CoreErrorCodes', () => {
+  expect(CoreErrorCodes.INVALID_ARGUMENT).toBe(17)
+})
diff --git a/packages/core-base/test/warnings.test.ts b/packages/core-base/test/warnings.test.ts
new file mode 100644
index 00000000..211460d4
--- /dev/null
+++ b/packages/core-base/test/warnings.test.ts
@@ -0,0 +1,5 @@
+import { CoreWarnCodes } from '../src/warnings'
+
+test('CoreWarnCodes', () => {
+  expect(CoreWarnCodes.NOT_FOUND_KEY).toBe(2)
+})
diff --git a/packages/message-compiler/test/__snapshots__/compiler.test.ts.snap b/packages/message-compiler/test/__snapshots__/compiler.test.ts.snap
index 522700ee..d458a7b7 100644
--- a/packages/message-compiler/test/__snapshots__/compiler.test.ts.snap
+++ b/packages/message-compiler/test/__snapshots__/compiler.test.ts.snap
@@ -317,6 +317,83 @@ exports[`compiler options > optimize: false > ast 1`] = `
 }
 `;
 
+exports[`compiler options > warning > ast 1`] = `
+{
+  "body": {
+    "end": 12,
+    "items": [
+      {
+        "end": 6,
+        "key": "msg",
+        "loc": {
+          "end": {
+            "column": 7,
+            "line": 1,
+            "offset": 6,
+          },
+          "start": {
+            "column": 2,
+            "line": 1,
+            "offset": 1,
+          },
+        },
+        "modulo": true,
+        "start": 1,
+        "type": 4,
+      },
+      {
+        "end": 12,
+        "loc": {
+          "end": {
+            "column": 13,
+            "line": 1,
+            "offset": 12,
+          },
+          "start": {
+            "column": 7,
+            "line": 1,
+            "offset": 6,
+          },
+        },
+        "start": 6,
+        "type": 3,
+        "value": " world",
+      },
+    ],
+    "loc": {
+      "end": {
+        "column": 13,
+        "line": 1,
+        "offset": 12,
+      },
+      "start": {
+        "column": 1,
+        "line": 1,
+        "offset": 0,
+      },
+    },
+    "start": 0,
+    "type": 2,
+  },
+  "end": 12,
+  "loc": {
+    "end": {
+      "column": 13,
+      "line": 1,
+      "offset": 12,
+    },
+    "source": "%{msg} world",
+    "start": {
+      "column": 1,
+      "line": 1,
+      "offset": 0,
+    },
+  },
+  "start": 0,
+  "type": 0,
+}
+`;
+
 exports[`edge cases >  | | |  > code 1`] = `
 "function __msg__ (ctx) {
   const { normalize: _normalize, plural: _plural } = ctx
diff --git a/packages/message-compiler/test/compiler.test.ts b/packages/message-compiler/test/compiler.test.ts
index aaa26ff6..44abc140 100644
--- a/packages/message-compiler/test/compiler.test.ts
+++ b/packages/message-compiler/test/compiler.test.ts
@@ -110,6 +110,13 @@ describe('compiler options', () => {
     })
     expect(ast).toMatchSnapshot('ast')
   })
+
+  test('warning', () => {
+    const onWarn = vi.fn()
+    const { ast } = compile(`%{msg} world`, { onWarn })
+    expect(ast).toMatchSnapshot('ast')
+    expect(onWarn.mock.calls[0][0].message).toBe(`Use modulo before '{msg}'.`)
+  })
 })
 
 describe('edge cases', () => {
diff --git a/packages/message-compiler/test/parser/__snapshots__/modulo.test.ts.snap b/packages/message-compiler/test/parser/__snapshots__/modulo.test.ts.snap
index d11b6d9d..866afb0d 100644
--- a/packages/message-compiler/test/parser/__snapshots__/modulo.test.ts.snap
+++ b/packages/message-compiler/test/parser/__snapshots__/modulo.test.ts.snap
@@ -20,6 +20,7 @@ exports[`%{nickname} %{action} issue %{code} 1`] = `
             "offset": 1,
           },
         },
+        "modulo": true,
         "start": 1,
         "type": 4,
       },
@@ -56,6 +57,7 @@ exports[`%{nickname} %{action} issue %{code} 1`] = `
             "offset": 13,
           },
         },
+        "modulo": true,
         "start": 13,
         "type": 4,
       },
@@ -92,6 +94,7 @@ exports[`%{nickname} %{action} issue %{code} 1`] = `
             "offset": 29,
           },
         },
+        "modulo": true,
         "start": 29,
         "type": 4,
       },
@@ -431,6 +434,7 @@ exports[`hi %{name}! 1`] = `
             "offset": 4,
           },
         },
+        "modulo": true,
         "start": 4,
         "type": 4,
       },
diff --git a/packages/vue-i18n-core/test/errors.test.ts b/packages/vue-i18n-core/test/errors.test.ts
new file mode 100644
index 00000000..46678b06
--- /dev/null
+++ b/packages/vue-i18n-core/test/errors.test.ts
@@ -0,0 +1,5 @@
+import { I18nErrorCodes } from '../src/errors'
+
+test('I18nErrorCodes', () => {
+  expect(I18nErrorCodes.UNEXPECTED_RETURN_TYPE).toBe(24)
+})
diff --git a/packages/vue-i18n-core/test/warnings.test.ts b/packages/vue-i18n-core/test/warnings.test.ts
new file mode 100644
index 00000000..b418dfac
--- /dev/null
+++ b/packages/vue-i18n-core/test/warnings.test.ts
@@ -0,0 +1,5 @@
+import { I18nWarnCodes } from '../src/warnings'
+
+test('I18nWarnCodes', () => {
+  expect(I18nWarnCodes.FALLBACK_TO_ROOT).toBe(9)
+})