diff --git a/packages/core-base/src/ast.ts b/packages/core-base/src/ast.ts new file mode 100644 index 000000000..98a367aa6 --- /dev/null +++ b/packages/core-base/src/ast.ts @@ -0,0 +1,123 @@ +import { NodeTypes } from '@intlify/message-compiler' +import { hasOwn, isObject } from '@intlify/shared' + +import type { + LinkedModifierNode, + LinkedNode, + MessageNode, + Node, + PluralNode, + ResourceNode +} from '@intlify/message-compiler' +import type { MessageType } from './runtime' + +export function isMessageAST(val: unknown): val is ResourceNode { + return ( + isObject(val) && + resolveType(val as Node) === 0 && + (hasOwn(val, 'b') || hasOwn(val, 'body')) + ) +} + +const PROPS_BODY = ['b', 'body'] + +export function resolveBody(node: ResourceNode) { + return resolveProps(node, PROPS_BODY) +} + +const PROPS_CASES = ['c', 'cases'] + +export function resolveCases(node: PluralNode) { + return resolveProps( + node, + PROPS_CASES, + [] + ) +} + +const PROPS_STATIC = ['s', 'static'] + +export function resolveStatic(node: MessageNode) { + return resolveProps(node, PROPS_STATIC) +} + +const PROPS_ITEMS = ['i', 'items'] + +export function resolveItems(node: MessageNode) { + return resolveProps( + node, + PROPS_ITEMS, + [] + ) +} + +const PROPS_TYPE = ['t', 'type'] + +export function resolveType(node: Node): ReturnType { + return resolveProps(node, PROPS_TYPE) +} + +const PROPS_VALUE = ['v', 'value'] + +export function resolveValue( + node: { v?: MessageType; value?: MessageType }, + type: NodeTypes +): MessageType { + const resolved = resolveProps( + node as Node, + PROPS_VALUE + ) as MessageType + if (resolved != null) { + return resolved + } else { + throw createUnhandleNodeError(type) + } +} + +const PROPS_MODIFIER = ['m', 'modifier'] + +export function resolveLinkedModifier(node: LinkedNode) { + return resolveProps(node, PROPS_MODIFIER) +} + +const PROPS_KEY = ['k', 'key'] + +export function resolveLinkedKey(node: LinkedNode) { + const resolved = resolveProps(node, PROPS_KEY) + if (resolved) { + return resolved + } else { + throw createUnhandleNodeError(NodeTypes.Linked) + } +} + +export function resolveProps( + node: Node, + props: string[], + defaultValue?: Default +): T | Default { + for (let i = 0; i < props.length; i++) { + const prop = props[i] + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (hasOwn(node, prop) && (node as any)[prop] != null) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (node as any)[prop] as T + } + } + return defaultValue as Default +} + +export const AST_NODE_PROPS_KEYS = [ + ...PROPS_BODY, + ...PROPS_CASES, + ...PROPS_STATIC, + ...PROPS_ITEMS, + ...PROPS_KEY, + ...PROPS_MODIFIER, + ...PROPS_VALUE, + ...PROPS_TYPE +] + +export function createUnhandleNodeError(type: NodeTypes) { + return new Error(`unhandled node type: ${type}`) +} diff --git a/packages/core-base/src/compilation.ts b/packages/core-base/src/compilation.ts index b7594c94c..a9da1c82e 100644 --- a/packages/core-base/src/compilation.ts +++ b/packages/core-base/src/compilation.ts @@ -3,22 +3,14 @@ import { defaultOnError, detectHtmlTag } from '@intlify/message-compiler' -import { - create, - format, - hasOwn, - isBoolean, - isObject, - isString, - warn -} from '@intlify/shared' -import { format as formatMessage, resolveType } from './format' +import { create, format, isBoolean, isString, warn } from '@intlify/shared' +import { isMessageAST } from './ast' +import { format as formatMessage } from './format' import type { CompileError, CompileOptions, CompilerResult, - Node, ResourceNode } from '@intlify/message-compiler' import type { MessageCompilerContext } from './context' @@ -39,14 +31,6 @@ export function clearCompileCache(): void { compileCache = create() } -export function isMessageAST(val: unknown): val is ResourceNode { - return ( - isObject(val) && - resolveType(val as Node) === 0 && - (hasOwn(val, 'b') || hasOwn(val, 'body')) - ) -} - function baseCompile( message: string, options: CompileOptions = {} diff --git a/packages/core-base/src/format.ts b/packages/core-base/src/format.ts index 0c2804620..e474a9836 100644 --- a/packages/core-base/src/format.ts +++ b/packages/core-base/src/format.ts @@ -1,8 +1,18 @@ import { NodeTypes } from '@intlify/message-compiler' import { hasOwn, isNumber } from '@intlify/shared' +import { + createUnhandleNodeError, + resolveBody, + resolveCases, + resolveItems, + resolveLinkedKey, + resolveLinkedModifier, + resolveStatic, + resolveType, + resolveValue +} from './ast' import type { - LinkedModifierNode, LinkedNode, ListNode, MessageNode, @@ -53,22 +63,6 @@ export function formatParts( } } -const PROPS_BODY = ['b', 'body'] - -function resolveBody(node: ResourceNode) { - return resolveProps(node, PROPS_BODY) -} - -const PROPS_CASES = ['c', 'cases'] - -function resolveCases(node: PluralNode) { - return resolveProps( - node, - PROPS_CASES, - [] - ) -} - export function formatMessageParts( ctx: MessageContext, node: MessageNode @@ -87,22 +81,6 @@ export function formatMessageParts( } } -const PROPS_STATIC = ['s', 'static'] - -function resolveStatic(node: MessageNode) { - return resolveProps(node, PROPS_STATIC) -} - -const PROPS_ITEMS = ['i', 'items'] - -function resolveItems(node: MessageNode) { - return resolveProps( - node, - PROPS_ITEMS, - [] - ) -} - type NodeValue = { v?: MessageType value?: MessageType @@ -160,63 +138,3 @@ export function formatMessagePart( throw new Error(`unhandled node on format message part: ${type}`) } } - -const PROPS_TYPE = ['t', 'type'] - -export function resolveType(node: Node) { - return resolveProps(node, PROPS_TYPE) -} - -const PROPS_VALUE = ['v', 'value'] - -function resolveValue( - node: { v?: MessageType; value?: MessageType }, - type: NodeTypes -): MessageType { - const resolved = resolveProps( - node as Node, - PROPS_VALUE - ) as MessageType - if (resolved) { - return resolved - } else { - throw createUnhandleNodeError(type) - } -} - -const PROPS_MODIFIER = ['m', 'modifier'] - -function resolveLinkedModifier(node: LinkedNode) { - return resolveProps(node, PROPS_MODIFIER) -} - -const PROPS_KEY = ['k', 'key'] - -function resolveLinkedKey(node: LinkedNode) { - const resolved = resolveProps(node, PROPS_KEY) - if (resolved) { - return resolved - } else { - throw createUnhandleNodeError(NodeTypes.Linked) - } -} - -function resolveProps( - node: Node, - props: string[], - defaultValue?: Default -): T | Default { - for (let i = 0; i < props.length; i++) { - const prop = props[i] - // eslint-disable-next-line @typescript-eslint/no-explicit-any - if (hasOwn(node, prop) && (node as any)[prop] != null) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return (node as any)[prop] as T - } - } - return defaultValue as Default -} - -function createUnhandleNodeError(type: NodeTypes) { - return new Error(`unhandled node type: ${type}`) -} diff --git a/packages/core-base/src/index.ts b/packages/core-base/src/index.ts index bf4a71ec1..d76d00327 100644 --- a/packages/core-base/src/index.ts +++ b/packages/core-base/src/index.ts @@ -6,6 +6,7 @@ export { createCompileError, ResourceNode } from '@intlify/message-compiler' +export { AST_NODE_PROPS_KEYS, isMessageAST } from './ast' export * from './compilation' export * from './context' export * from './datetime' diff --git a/packages/core-base/src/resolver.ts b/packages/core-base/src/resolver.ts index 68efd8587..1950996f9 100644 --- a/packages/core-base/src/resolver.ts +++ b/packages/core-base/src/resolver.ts @@ -1,4 +1,5 @@ import { isFunction, isObject } from '@intlify/shared' +import { AST_NODE_PROPS_KEYS, isMessageAST } from './ast' /** @VueI18nGeneral */ export type Path = string @@ -344,7 +345,16 @@ export function resolveValue(obj: unknown, path: Path): PathValue { let last = obj let i = 0 while (i < len) { - const val = last[hit[i]] + const key = hit[i] + /** + * NOTE: + * if `key` is intlify message format AST node key and `last` is intlify message format AST, skip it. + * because the AST node is not a key-value structure. + */ + if (AST_NODE_PROPS_KEYS.includes(key) && isMessageAST(last)) { + return null + } + const val = last[key] if (val === undefined) { return null } diff --git a/packages/core-base/src/translate.ts b/packages/core-base/src/translate.ts index 4880d10b9..bf3e8b4b7 100644 --- a/packages/core-base/src/translate.ts +++ b/packages/core-base/src/translate.ts @@ -17,7 +17,7 @@ import { measure, warn } from '@intlify/shared' -import { isMessageAST } from './compilation' +import { isMessageAST } from './ast' import { CoreContext, getAdditionalMeta, diff --git a/packages/core-base/test/compilation.test.ts b/packages/core-base/test/compilation.test.ts index abf2d90a5..47dff0dad 100644 --- a/packages/core-base/test/compilation.test.ts +++ b/packages/core-base/test/compilation.test.ts @@ -1,5 +1,4 @@ // utils -import * as shared from '@intlify/shared' vi.mock('@intlify/shared', async () => { const actual = await vi.importActual('@intlify/shared') return { @@ -9,7 +8,8 @@ vi.mock('@intlify/shared', async () => { }) import { baseCompile } from '@intlify/message-compiler' -import { compile, isMessageAST, clearCompileCache } from '../src/compilation' +import { isMessageAST } from '../src/ast' +import { clearCompileCache, compile } from '../src/compilation' import { createMessageContext as context } from '../src/runtime' const DEFAULT_CONTEXT = { locale: 'en', key: 'key' } diff --git a/packages/core-base/test/fixtures/ast.ts b/packages/core-base/test/fixtures/ast.ts new file mode 100644 index 000000000..e616e0134 --- /dev/null +++ b/packages/core-base/test/fixtures/ast.ts @@ -0,0 +1,126 @@ +export const ast = { + language: { + type: 0, + start: 0, + end: 9, + loc: { + start: { line: 1, column: 1, offset: 0 }, + end: { line: 1, column: 10, offset: 9 }, + source: 'Languages' + }, + body: { + type: 2, + start: 0, + end: 9, + loc: { + start: { line: 1, column: 1, offset: 0 }, + end: { line: 1, column: 10, offset: 9 } + }, + items: [ + { + type: 3, + start: 0, + end: 9, + loc: { + start: { line: 1, column: 1, offset: 0 }, + end: { line: 1, column: 10, offset: 9 } + } + } + ], + static: 'Languages' + } + }, + product: { + type: 0, + start: 0, + end: 7, + loc: { + start: { line: 1, column: 1, offset: 0 }, + end: { line: 1, column: 8, offset: 7 }, + source: 'Product' + }, + body: { + type: 2, + start: 0, + end: 7, + loc: { + start: { line: 1, column: 1, offset: 0 }, + end: { line: 1, column: 8, offset: 7 } + }, + items: [ + { + type: 3, + start: 0, + end: 7, + loc: { + start: { line: 1, column: 1, offset: 0 }, + end: { line: 1, column: 8, offset: 7 } + } + } + ], + static: 'Product' + } + }, + 'product.type': { + type: 0, + start: 0, + end: 12, + loc: { + start: { line: 1, column: 1, offset: 0 }, + end: { line: 1, column: 13, offset: 12 }, + source: 'Product type' + }, + body: { + type: 2, + start: 0, + end: 12, + loc: { + start: { line: 1, column: 1, offset: 0 }, + end: { line: 1, column: 13, offset: 12 } + }, + items: [ + { + type: 3, + start: 0, + end: 12, + loc: { + start: { line: 1, column: 1, offset: 0 }, + end: { line: 1, column: 13, offset: 12 } + } + } + ], + static: 'Product type' + } + }, + 'product.test.type': { + type: 0, + start: 0, + end: 17, + loc: { + start: { line: 1, column: 1, offset: 0 }, + end: { line: 1, column: 18, offset: 17 }, + source: 'Product test type' + }, + body: { + type: 2, + start: 0, + end: 17, + loc: { + start: { line: 1, column: 1, offset: 0 }, + end: { line: 1, column: 18, offset: 17 } + }, + items: [ + { + type: 3, + start: 0, + end: 17, + loc: { + start: { line: 1, column: 1, offset: 0 }, + end: { line: 1, column: 18, offset: 17 } + } + } + ], + static: 'Product test type' + } + } +} diff --git a/packages/core-base/test/resolver.test.ts b/packages/core-base/test/resolver.test.ts index 13285113f..a7ef5e574 100644 --- a/packages/core-base/test/resolver.test.ts +++ b/packages/core-base/test/resolver.test.ts @@ -1,4 +1,5 @@ import { parse, resolveValue } from '../src/resolver' +import { ast } from './fixtures/ast' test('parse', () => { expect(parse('a')).toEqual(['a']) @@ -89,40 +90,51 @@ test('parse', () => { expect(parse('🌐.🌐')).toEqual(['🌐', '🌐']) }) -test('resolveValue', () => { - // primitive - expect(resolveValue({ a: { b: 1 } }, 'a.b')).toEqual(1) - // whitespace - expect(resolveValue({ 'a c': 1 }, 'a c')).toEqual(1) - expect(resolveValue({ 'a\tc': 1 }, 'a\tc')).toEqual(null) - // object - expect(resolveValue({ a: { b: 1 } }, 'a')).toEqual({ b: 1 }) - expect(resolveValue({ a: { 'b c d': 1 } }, 'a.b c d')).toEqual(1) - // number key in object - expect( - resolveValue({ errors: { '1': 'error number 1' } }, 'errors[1]') - ).toEqual('error number 1') - // array index path - expect(resolveValue({ errors: ['error number 0'] }, 'errors[0]')).toEqual( - 'error number 0' - ) - // array path - expect(resolveValue({ errors: ['error number 0'] }, 'errors')).toEqual([ - 'error number 0' - ]) - // not found - expect(resolveValue({}, 'a.b')).toEqual(null) - // object primitive - expect(resolveValue(10, 'a.b')).toEqual(null) - // object null - expect(resolveValue(null, 'a.b')).toEqual(null) - // blanket term - expect(resolveValue({}, 'a.b.c[]')).toEqual(null) - // blanket middle - expect(resolveValue({}, 'a.b.c[]d')).toEqual(null) - // function - const fn = () => 1 - expect(resolveValue({ a: fn }, 'a.name')).toEqual(null) - expect(resolveValue({ a: fn }, 'a.toString')).toEqual(null) - expect(resolveValue({ a: fn }, 'a')).toEqual(fn) +describe('resolveValue', () => { + test('basic', () => { + // primitive + expect(resolveValue({ a: { b: 1 } }, 'a.b')).toEqual(1) + // whitespace + expect(resolveValue({ 'a c': 1 }, 'a c')).toEqual(1) + expect(resolveValue({ 'a\tc': 1 }, 'a\tc')).toEqual(null) + // object + expect(resolveValue({ a: { b: 1 } }, 'a')).toEqual({ b: 1 }) + expect(resolveValue({ a: { 'b c d': 1 } }, 'a.b c d')).toEqual(1) + // number key in object + expect( + resolveValue({ errors: { '1': 'error number 1' } }, 'errors[1]') + ).toEqual('error number 1') + // array index path + expect(resolveValue({ errors: ['error number 0'] }, 'errors[0]')).toEqual( + 'error number 0' + ) + // array path + expect(resolveValue({ errors: ['error number 0'] }, 'errors')).toEqual([ + 'error number 0' + ]) + // not found + expect(resolveValue({}, 'a.b')).toEqual(null) + // object primitive + expect(resolveValue(10, 'a.b')).toEqual(null) + // object null + expect(resolveValue(null, 'a.b')).toEqual(null) + // blanket term + expect(resolveValue({}, 'a.b.c[]')).toEqual(null) + // blanket middle + expect(resolveValue({}, 'a.b.c[]d')).toEqual(null) + // function + const fn = () => 1 + expect(resolveValue({ a: fn }, 'a.name')).toEqual(null) + expect(resolveValue({ a: fn }, 'a.toString')).toEqual(null) + expect(resolveValue({ a: fn }, 'a')).toEqual(fn) + // json path + expect(resolveValue({ 'a.b': 1 }, 'a.b')).toEqual(null) + }) + + test('ast', () => { + expect(resolveValue(ast, 'language')).toEqual(ast.language) + expect(resolveValue(ast, 'product')).toEqual(ast.product) + expect(resolveValue(ast, 'product.type')).toEqual(null) + expect(resolveValue(ast, 'product.test.type')).toEqual(null) + }) }) diff --git a/packages/core-base/test/translate.test.ts b/packages/core-base/test/translate.test.ts index f81c9ba36..027172685 100644 --- a/packages/core-base/test/translate.test.ts +++ b/packages/core-base/test/translate.test.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-empty-function, @typescript-eslint/no-explicit-any */ import { baseCompile } from '@intlify/message-compiler' +import { ast } from './fixtures/ast' // utils import * as shared from '@intlify/shared' @@ -1024,6 +1025,19 @@ describe('AST passing', () => { }) expect(translate(ctx, 'hi')).toEqual('hi kazupon !') }) + + test('json path key', () => { + const ctx = context({ + locale: 'en', + messages: { + en: ast + } + }) + expect(translate(ctx, 'languages')).toEqual('languages') + expect(translate(ctx, 'product')).toEqual('Product') + expect(translate(ctx, 'product.type')).toEqual('Product type') + expect(translate(ctx, 'product.test.type')).toEqual('Product test type') + }) }) test('locale detector', () => { diff --git a/packages/vue-i18n-core/src/utils.ts b/packages/vue-i18n-core/src/utils.ts index 1b6b01922..da42038c0 100644 --- a/packages/vue-i18n-core/src/utils.ts +++ b/packages/vue-i18n-core/src/utils.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { AST_NODE_PROPS_KEYS, isMessageAST } from '@intlify/core-base' import { create, deepCopy, @@ -48,6 +49,10 @@ export function handleFlatJson(obj: unknown): unknown { return obj } + if (isMessageAST(obj)) { + return obj + } + for (const key in obj as object) { // check key if (!hasOwn(obj, key)) { @@ -89,12 +94,26 @@ export function handleFlatJson(obj: unknown): unknown { } // update last object value, delete old property if (!hasStringValue) { - currentObj[subKeys[lastIndex]] = obj[key] - delete obj[key] + if (!isMessageAST(currentObj)) { + currentObj[subKeys[lastIndex]] = obj[key] + delete obj[key] + } else { + /** + * NOTE: + * if the last object is a message AST and subKeys[lastIndex] has message AST prop key, ignore to copy and key deletion + */ + if (!AST_NODE_PROPS_KEYS.includes(subKeys[lastIndex])) { + delete obj[key] + } + } } + // recursive process value if value is also a object - if (isObject(currentObj[subKeys[lastIndex]])) { - handleFlatJson(currentObj[subKeys[lastIndex]]) + if (!isMessageAST(currentObj)) { + const target = currentObj[subKeys[lastIndex]] + if (isObject(target)) { + handleFlatJson(target) + } } } } diff --git a/packages/vue-i18n-core/test/fixtures/ast.ts b/packages/vue-i18n-core/test/fixtures/ast.ts new file mode 100644 index 000000000..e616e0134 --- /dev/null +++ b/packages/vue-i18n-core/test/fixtures/ast.ts @@ -0,0 +1,126 @@ +export const ast = { + language: { + type: 0, + start: 0, + end: 9, + loc: { + start: { line: 1, column: 1, offset: 0 }, + end: { line: 1, column: 10, offset: 9 }, + source: 'Languages' + }, + body: { + type: 2, + start: 0, + end: 9, + loc: { + start: { line: 1, column: 1, offset: 0 }, + end: { line: 1, column: 10, offset: 9 } + }, + items: [ + { + type: 3, + start: 0, + end: 9, + loc: { + start: { line: 1, column: 1, offset: 0 }, + end: { line: 1, column: 10, offset: 9 } + } + } + ], + static: 'Languages' + } + }, + product: { + type: 0, + start: 0, + end: 7, + loc: { + start: { line: 1, column: 1, offset: 0 }, + end: { line: 1, column: 8, offset: 7 }, + source: 'Product' + }, + body: { + type: 2, + start: 0, + end: 7, + loc: { + start: { line: 1, column: 1, offset: 0 }, + end: { line: 1, column: 8, offset: 7 } + }, + items: [ + { + type: 3, + start: 0, + end: 7, + loc: { + start: { line: 1, column: 1, offset: 0 }, + end: { line: 1, column: 8, offset: 7 } + } + } + ], + static: 'Product' + } + }, + 'product.type': { + type: 0, + start: 0, + end: 12, + loc: { + start: { line: 1, column: 1, offset: 0 }, + end: { line: 1, column: 13, offset: 12 }, + source: 'Product type' + }, + body: { + type: 2, + start: 0, + end: 12, + loc: { + start: { line: 1, column: 1, offset: 0 }, + end: { line: 1, column: 13, offset: 12 } + }, + items: [ + { + type: 3, + start: 0, + end: 12, + loc: { + start: { line: 1, column: 1, offset: 0 }, + end: { line: 1, column: 13, offset: 12 } + } + } + ], + static: 'Product type' + } + }, + 'product.test.type': { + type: 0, + start: 0, + end: 17, + loc: { + start: { line: 1, column: 1, offset: 0 }, + end: { line: 1, column: 18, offset: 17 }, + source: 'Product test type' + }, + body: { + type: 2, + start: 0, + end: 17, + loc: { + start: { line: 1, column: 1, offset: 0 }, + end: { line: 1, column: 18, offset: 17 } + }, + items: [ + { + type: 3, + start: 0, + end: 17, + loc: { + start: { line: 1, column: 1, offset: 0 }, + end: { line: 1, column: 18, offset: 17 } + } + } + ], + static: 'Product test type' + } + } +} diff --git a/packages/vue-i18n-core/test/issues.test.ts b/packages/vue-i18n-core/test/issues.test.ts index 8ca2c22d1..f300e581e 100644 --- a/packages/vue-i18n-core/test/issues.test.ts +++ b/packages/vue-i18n-core/test/issues.test.ts @@ -31,6 +31,7 @@ import { withDirectives } from 'vue' import { createI18n, useI18n } from '../src/i18n' +import { ast } from './fixtures/ast' import { mount } from './helper' import type { ComponentOptions } from 'vue' @@ -1486,3 +1487,30 @@ describe('CVE-2024-52809', () => { expect(() => i18n.global.t('hello')).toThrow(`unhandled node type: 3`) }) }) + +describe('#2156', () => { + test('flatJson: false', () => { + const i18n = createI18n({ + locale: 'en', + messages: { + en: ast + } + }) + expect(i18n.global.t('product')).toEqual('Product') + expect(i18n.global.t('product.type')).toEqual('Product type') + expect(i18n.global.t('product.test.type')).toEqual('Product test type') + }) + + test('flatJson: true', () => { + const i18n = createI18n({ + locale: 'en', + flatJson: true, + messages: { + en: ast + } + }) + expect(i18n.global.t('product')).toEqual('Product') + expect(i18n.global.t('product.type')).toEqual('Product type') + expect(i18n.global.t('product.test.type')).toEqual('Product test type') + }) +}) diff --git a/packages/vue-i18n-core/test/utils.test.ts b/packages/vue-i18n-core/test/utils.test.ts index eb02db40e..52660e910 100644 --- a/packages/vue-i18n-core/test/utils.test.ts +++ b/packages/vue-i18n-core/test/utils.test.ts @@ -77,4 +77,9 @@ describe('handleFlatJson', () => { // @ts-ignore -- test expect(Object.prototype.pollutedKey).toBeUndefined() }) + + test('ast has json path', async () => { + const { ast } = await import('./fixtures/ast') + expect(handleFlatJson(ast)).toStrictEqual(ast) + }) })