diff --git a/src/cli/extract.ts b/src/cli/extract.ts index d4c251f..16e91ab 100644 --- a/src/cli/extract.ts +++ b/src/cli/extract.ts @@ -1,53 +1,35 @@ import { Node, ObjectExpression, - Property, ImportDeclaration, ImportSpecifier, CallExpression, Identifier, + Literal, } from 'estree' import delve from 'dlv' import { walk } from 'estree-walker' import { Ast } from 'svelte/types/compiler/interfaces' import { parse } from 'svelte/compiler' +import { deepSet } from './includes/deepSet' +import { getObjFromExpression } from './includes/getObjFromExpression' +import { Message } from './types' + const LIB_NAME = 'svelte-i18n' const DEFINE_MESSAGES_METHOD_NAME = 'defineMessages' -const FORMAT_METHOD_NAMES = new Set(['format', '_']) - -interface Message { - node: Node - meta: { - id?: string - default?: string - [key: string]: any - } -} - -const isNumberString = (n: string) => !Number.isNaN(parseInt(n)) - -function deepSet(obj: any, path: string, value: any) { - const parts = path.replace(/\[(\w+)\]/gi, '.$1').split('.') - return parts.reduce((ref, part, i) => { - if (part in ref) return (ref = ref[part]) - - if (i < parts.length - 1) { - if (isNumberString(parts[i + 1])) { - return (ref = ref[part] = []) - } - return (ref = ref[part] = {}) - } - - return (ref[part] = value) - }, obj) -} +const FORMAT_METHOD_NAMES = new Set(['format', '_', 't']) +const IGNORED_UTILITIES = new Set(['number', 'date', 'time']) function isFormatCall(node: Node, imports: Set) { if (node.type !== 'CallExpression') return false let identifier: Identifier - if (node.callee.type === 'MemberExpression') { + if ( + node.callee.type === 'MemberExpression' && + node.callee.property.type === 'Identifier' && + !IGNORED_UTILITIES.has(node.callee.property.name) + ) { identifier = node.callee.object as Identifier } else if (node.callee.type === 'Identifier') { identifier = node.callee @@ -92,22 +74,6 @@ function getFormatSpecifiers(decl: ImportDeclaration) { ) as ImportSpecifier[] } -function getObjFromExpression(exprNode: Node | ObjectExpression) { - if (exprNode.type !== 'ObjectExpression') return null - return exprNode.properties.reduce( - (acc, prop: Property) => { - // we only want primitives - if (prop.value.type !== 'Literal') return acc - if (prop.value.value !== Object(prop.value.value)) { - const key = (prop.key as Identifier).name as string - acc.meta[key] = prop.value.value - } - return acc - }, - { node: exprNode, meta: {} } - ) -} - export function collectFormatCalls(ast: Ast) { const importDecls = getLibImportDeclarations(ast) @@ -122,14 +88,14 @@ export function collectFormatCalls(ast: Ast) { if (imports.size === 0) return [] const calls: CallExpression[] = [] - function formatCallsWalker(node: Node) { + function enter(node: Node) { if (isFormatCall(node, imports)) { calls.push(node as CallExpression) this.skip() } } - walk(ast.instance as any, { enter: formatCallsWalker }) - walk(ast.html as any, { enter: formatCallsWalker }) + walk(ast.instance as any, { enter }) + walk(ast.html as any, { enter }) return calls } @@ -157,7 +123,9 @@ export function collectMessageDefinitions(ast: Ast) { }) return definitions.flatMap(definitionDict => - definitionDict.properties.map(propNode => propNode.value) + definitionDict.properties.map( + propNode => propNode.value as ObjectExpression + ) ) } @@ -173,19 +141,18 @@ export function collectMessages(markup: string): Message[] { return getObjFromExpression(pathNode) } - if (pathNode.type !== 'Literal' || typeof pathNode.value !== 'string') { - return null - } + const node = pathNode as Literal + const id = node.value as string if (options && options.type === 'ObjectExpression') { const messageObj = getObjFromExpression(options) - messageObj.meta.id = pathNode.value + messageObj.meta.id = id return messageObj } return { - node: pathNode, - meta: { id: pathNode.value }, + node, + meta: { id }, } }), ].filter(Boolean) diff --git a/src/cli/includes/deepSet.ts b/src/cli/includes/deepSet.ts new file mode 100644 index 0000000..d4517e8 --- /dev/null +++ b/src/cli/includes/deepSet.ts @@ -0,0 +1,17 @@ +const isNumberString = (n: string) => !Number.isNaN(parseInt(n)) + +export function deepSet(obj: any, path: string, value: any) { + const parts = path.replace(/\[(\w+)\]/gi, '.$1').split('.') + return parts.reduce((ref, part, i) => { + if (part in ref) return (ref = ref[part]) + + if (i < parts.length - 1) { + if (isNumberString(parts[i + 1])) { + return (ref = ref[part] = []) + } + return (ref = ref[part] = {}) + } + + return (ref[part] = value) + }, obj) +} diff --git a/src/cli/includes/getObjFromExpression.ts b/src/cli/includes/getObjFromExpression.ts new file mode 100644 index 0000000..a992817 --- /dev/null +++ b/src/cli/includes/getObjFromExpression.ts @@ -0,0 +1,18 @@ +import { ObjectExpression, Property, Identifier } from 'estree' + +import { Message } from '../types' + +export function getObjFromExpression(exprNode: ObjectExpression) { + return exprNode.properties.reduce( + (acc, prop: Property) => { + if (prop.value.type !== 'Literal') return acc + // we only want primitives + if (prop.value.value !== Object(prop.value.value)) { + const key = (prop.key as Identifier).name as string + acc.meta[key] = prop.value.value + } + return acc + }, + { node: exprNode, meta: {} } + ) +} diff --git a/src/cli/types/index.ts b/src/cli/types/index.ts new file mode 100644 index 0000000..9291827 --- /dev/null +++ b/src/cli/types/index.ts @@ -0,0 +1,10 @@ +import { Node } from 'estree' + +export interface Message { + node: Node + meta: { + id?: string + default?: string + [key: string]: any + } +} diff --git a/test/cli/extraction.test.ts b/test/cli/extract.test.ts similarity index 64% rename from test/cli/extraction.test.ts rename to test/cli/extract.test.ts index 77e192a..3f56577 100644 --- a/test/cli/extraction.test.ts +++ b/test/cli/extract.test.ts @@ -10,19 +10,33 @@ import { } from '../../src/cli/extract' describe('collecting format calls', () => { - it('should return nothing if there are no imports from the library', () => { - const ast = parse(``) + test('returns nothing if there are no imports', () => { + const ast = parse(``) const calls = collectFormatCalls(ast) expect(calls).toHaveLength(0) }) - it('should collect all format calls in the instance script', () => { + test('returns nothing if there are no format imports', () => { + const ast = parse( + `` + ) + const calls = collectFormatCalls(ast) + expect(calls).toHaveLength(0) + }) + + test('collects all format calls in the instance script', () => { const ast = parse(``) const calls = collectFormatCalls(ast) expect(calls).toHaveLength(2) @@ -30,24 +44,12 @@ describe('collecting format calls', () => { expect(calls[1]).toMatchObject({ type: 'CallExpression' }) }) - it('should collect all format calls with renamed imports', () => { + test('collects all format calls with renamed imports', () => { const ast = parse(``) - const calls = collectFormatCalls(ast) - expect(calls).toHaveLength(2) - expect(calls[0]).toMatchObject({ type: 'CallExpression' }) - expect(calls[1]).toMatchObject({ type: 'CallExpression' }) - }) - - it('should collect all format utility calls', () => { - const ast = parse(``) const calls = collectFormatCalls(ast) expect(calls).toHaveLength(3) @@ -55,24 +57,51 @@ describe('collecting format calls', () => { expect(calls[1]).toMatchObject({ type: 'CallExpression' }) expect(calls[2]).toMatchObject({ type: 'CallExpression' }) }) + + test('collects all string format utility calls', () => { + const ast = parse(``) + const calls = collectFormatCalls(ast) + expect(calls).toHaveLength(4) + expect(calls[0]).toMatchObject({ type: 'CallExpression' }) + expect(calls[1]).toMatchObject({ type: 'CallExpression' }) + expect(calls[2]).toMatchObject({ type: 'CallExpression' }) + expect(calls[3]).toMatchObject({ type: 'CallExpression' }) + }) + + test('ignores date, time and number calls', () => { + const ast = parse(``) + const calls = collectFormatCalls(ast) + expect(calls).toHaveLength(0) + }) }) describe('collecting message definitions', () => { - it('should return nothing if there are no imports from the library', () => { + test('returns nothing if there are no imports from the library', () => { const ast = parse( ``, + import foo from 'foo'; + import { dictionary } from 'svelte-i18n'; + ` ) expect(collectMessageDefinitions(ast)).toHaveLength(0) }) - it('should get all message definition objects', () => { + test('gets all message definition objects', () => { const ast = parse(``) const definitions = collectMessageDefinitions(ast) expect(definitions).toHaveLength(4) @@ -84,10 +113,11 @@ describe('collecting message definitions', () => { }) describe('collecting messages', () => { - it('should collect all messages in both instance and html ASTs', () => { + test('collects all messages in both instance and html ASTs', () => { const markup = ` +
{$_('msg_1')}
{$_({id: 'msg_2'})}
{$_('msg_3', { default: 'Message'})}
` + const messages = collectMessages(markup) + expect(messages).toHaveLength(7) expect(messages).toEqual( expect.arrayContaining([ @@ -114,15 +147,16 @@ describe('collecting messages', () => { expect.objectContaining({ meta: { id: 'enabled', default: 'Enabled' }, }), - ]), + ]) ) }) }) describe('messages extraction', () => { - it('should return a object built based on all found message paths', () => { - const markup = ` - + test('returns a object built based on all found message paths', () => { + const markup = `

{$_.title('title')}

{$_({ id: 'subtitle'})}

@@ -131,16 +165,16 @@ describe('messages extraction', () => { expect(dict).toMatchObject({ title: '', subtitle: '' }) }) - it('creates deep nested properties', () => { + test('creates deep nested properties', () => { const markup = `

{$_.title('home.page.title')}

{$_({ id: 'home.page.subtitle'})}

    -
  • {$_('list[0]')}
  • -
  • {$_('list[1]')}
  • -
  • {$_('list[2]')}
  • +
  • {$_('list.0')}
  • +
  • {$_('list.1')}
  • +
  • {$_('list.2')}
` const dict = extractMessages(markup) @@ -150,38 +184,38 @@ describe('messages extraction', () => { }) }) - it('creates a shallow dictionary', () => { + test('creates a shallow dictionary', () => { const markup = `

{$_.title('home.page.title')}

{$_({ id: 'home.page.subtitle'})}

    -
  • {$_('list[0]')}
  • -
  • {$_('list[1]')}
  • -
  • {$_('list[2]')}
  • +
  • {$_('list.0')}
  • +
  • {$_('list.1')}
  • +
  • {$_('list.2')}
` const dict = extractMessages(markup, { shallow: true }) expect(dict).toMatchObject({ 'home.page.title': '', 'home.page.subtitle': '', - 'list[0]': '', - 'list[1]': '', - 'list[2]': '', + 'list.0': '', + 'list.1': '', + 'list.2': '', }) }) - it('allow to pass a initial dictionary and only append non-existing props', () => { + test('allow to pass a initial dictionary and only append non-existing props', () => { const markup = `

{$_.title('home.page.title')}

{$_({ id: 'home.page.subtitle'})}

    -
  • {$_('list[0]')}
  • -
  • {$_('list[1]')}
  • -
  • {$_('list[2]')}
  • +
  • {$_('list.0')}
  • +
  • {$_('list.1')}
  • +
  • {$_('list.2')}
` const dict = extractMessages(markup, { @@ -205,7 +239,7 @@ describe('messages extraction', () => { }) }) - it('allow to pass a initial dictionary and only append shallow non-existing props', () => { + test('allow to pass a initial dictionary and only append shallow non-existing props', () => { const markup = `