Skip to content

Commit

Permalink
feat(@formatjs/cli): support .vue SFC files
Browse files Browse the repository at this point in the history
  • Loading branch information
longlho committed Jan 4, 2021
1 parent 96fc3b1 commit 24d6d1b
Show file tree
Hide file tree
Showing 13 changed files with 302 additions and 40 deletions.
2 changes: 2 additions & 0 deletions package.json
Expand Up @@ -62,6 +62,8 @@
"@typescript-eslint/eslint-plugin": "^4.0.0",
"@typescript-eslint/parser": "^4.11.0",
"@typescript-eslint/typescript-estree": "^4.11.0",
"@vue/compiler-core": "^3.0.5",
"@vue/compiler-sfc": "^3.0.5",
"@vue/test-utils": "2.0.0-beta.13",
"benchmark": "^2.1.4",
"chalk": "^4.0.0",
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/BUILD
Expand Up @@ -43,6 +43,7 @@ SRC_DEPS = [
"@npm//chalk",
"@npm//json-stable-stringify",
"@npm//@types/json-stable-stringify",
"@npm//@vue/compiler-sfc",
]

ts_compile(
Expand All @@ -57,6 +58,7 @@ jest_test(
srcs = [
"package.json",
"tests/extract/unit.test.ts",
"tests/extract/vue_extractor.test.ts",
"tests/extract/__snapshots__/unit.test.ts.snap",
] + SRCS,
deps = [
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/package.json
Expand Up @@ -36,6 +36,8 @@
"@types/lodash": "^4.14.150",
"@types/loud-rejection": "^2.0.0",
"@types/node": "14",
"@vue/compiler-core": "^3.0.0",
"@vue/compiler-sfc": "^3.0.5",
"chalk": "^4.0.0",
"commander": "^6.1.0",
"fast-glob": "^3.2.4",
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/cli.ts
Expand Up @@ -79,6 +79,11 @@ is imported from \`moduleSourceName\` to make sure variable alias
works. This option does not do that so it's less safe.`,
(val: string) => val.split(',')
)
.option(
'--additional-function-names <comma-separated-names>',
`Additional function names to extract messages from, e.g: \`['$t']\`.`,
(val: string) => val.split(',')
)
.option(
'--extract-from-format-message-call',
`Opt-in to extract from \`intl.formatMessage\` call with the same restrictions, e.g: has
Expand Down Expand Up @@ -124,6 +129,7 @@ to be called with object literal such as \`intl.formatMessage({ id: 'foo', defau
extractSourceLocation: cmdObj.extractSourceLocation,
removeDefaultMessage: cmdObj.removeDefaultMessage,
additionalComponentNames: cmdObj.additionalComponentNames,
additionalFunctionNames: cmdObj.additionalFunctionNames,
extractFromFormatMessageCall: cmdObj.extractFromFormatMessageCall,
throws: cmdObj.throws,
pragma: cmdObj.pragma,
Expand Down
49 changes: 13 additions & 36 deletions packages/cli/src/extract.ts
Expand Up @@ -2,14 +2,14 @@ import {warn, getStdinAsString} from './console_utils';
import {readFile, outputFile} from 'fs-extra';
import {
interpolateName,
transform,
Opts,
MessageDescriptor,
} from '@formatjs/ts-transformer';
import ts from 'typescript';

import {resolveBuiltinFormatter, Formatter} from './formatters';
import stringify from 'json-stable-stringify';

import {parseFile} from './vue_extractor';
import {parseScript} from './parse_script';
export interface ExtractionResult<M = Record<string, string>> {
/**
* List of extracted messages
Expand Down Expand Up @@ -93,6 +93,10 @@ function processFile(

opts = {
...opts,
additionalComponentNames: [
'$formatMessage',
...(opts.additionalComponentNames || []),
],
onMsgExtracted(_, msgs) {
if (opts.extractSourceLocation) {
msgs = msgs.map(msg => ({
Expand Down Expand Up @@ -125,39 +129,12 @@ function processFile(
),
};
}
let output;
try {
output = ts.transpileModule(source, {
compilerOptions: {
allowJs: true,
target: ts.ScriptTarget.ESNext,
noEmit: true,
experimentalDecorators: true,
},
reportDiagnostics: true,
fileName: fn,
transformers: {
before: [transform(opts)],
},
});
} catch (e) {
e.message = `Error processing file ${fn}
${e.message || ''}`;
throw e;
}
if (output.diagnostics) {
const errs = output.diagnostics.filter(
d => d.category === ts.DiagnosticCategory.Error
);
if (errs.length) {
throw new Error(
ts.formatDiagnosticsWithColorAndContext(errs, {
getCanonicalFileName: fileName => fileName,
getCurrentDirectory: () => process.cwd(),
getNewLine: () => ts.sys.newLine,
})
);
}

const scriptParseFn = parseScript(opts, fn);
if (fn.endsWith('.vue')) {
parseFile(source, fn, scriptParseFn);
} else {
scriptParseFn(source);
}

if (meta) {
Expand Down
46 changes: 46 additions & 0 deletions packages/cli/src/parse_script.ts
@@ -0,0 +1,46 @@
import {Opts, transform} from '@formatjs/ts-transformer';
import ts from 'typescript';

/**
* Invoid TypeScript module transpilation with our TS transformer
* @param opts Formatjs TS Transformer opt
* @param fn filename
*/
export function parseScript(opts: Opts, fn?: string) {
return (source: string) => {
let output;
try {
output = ts.transpileModule(source, {
compilerOptions: {
allowJs: true,
target: ts.ScriptTarget.ESNext,
noEmit: true,
experimentalDecorators: true,
},
reportDiagnostics: true,
fileName: fn,
transformers: {
before: [transform(opts)],
},
});
} catch (e) {
e.message = `Error processing file ${fn}
${e.message || ''}`;
throw e;
}
if (output.diagnostics) {
const errs = output.diagnostics.filter(
d => d.category === ts.DiagnosticCategory.Error
);
if (errs.length) {
throw new Error(
ts.formatDiagnosticsWithColorAndContext(errs, {
getCanonicalFileName: fileName => fileName,
getCurrentDirectory: () => process.cwd(),
getNewLine: () => ts.sys.newLine,
})
);
}
}
};
}
59 changes: 59 additions & 0 deletions packages/cli/src/vue_extractor.ts
@@ -0,0 +1,59 @@
import {parse} from '@vue/compiler-sfc';
import {
TemplateChildNode,
NodeTypes,
SimpleExpressionNode,
} from '@vue/compiler-core';

export type ScriptParseFn = (source: string) => void;

function walk(node: TemplateChildNode, visitor: (node: any) => void) {
if (
node.type === NodeTypes.TEXT ||
node.type === NodeTypes.COMMENT ||
node.type === NodeTypes.IF ||
node.type === NodeTypes.TEXT_CALL
) {
return;
}
visitor(node);

if (node.type === NodeTypes.INTERPOLATION) {
visitor(node.content);
} else {
node.children.forEach((n: any) => walk(n, visitor));
}
}

function templateSimpleExpressionNodeVisitor(parseScriptFn: ScriptParseFn) {
return (n: any) => {
if (n.type !== NodeTypes.SIMPLE_EXPRESSION) {
return;
}

const {content} = n as SimpleExpressionNode;
parseScriptFn(content);
};
}

export function parseFile(
source: string,
filename: string,
parseScriptFn: ScriptParseFn
): any {
const {descriptor, errors} = parse(source, {
filename,
});
if (errors.length) {
throw errors[0];
}
const {script, template} = descriptor;

if (template) {
walk(template.ast, templateSimpleExpressionNodeVisitor(parseScriptFn));
}

if (script) {
parseScriptFn(script.content);
}
}
13 changes: 13 additions & 0 deletions packages/cli/tests/extract/__snapshots__/integration.test.ts.snap
Expand Up @@ -666,3 +666,16 @@ Object {
",
}
`;
exports[`vue 1`] = `
Object {
"1ebd4": Object {
"defaultMessage": "in script",
"description": "in script desc",
},
"f6d14": Object {
"defaultMessage": "in template",
"description": "in template desc",
},
}
`;
8 changes: 8 additions & 0 deletions packages/cli/tests/extract/integration.test.ts
Expand Up @@ -196,3 +196,11 @@ test('invalid syntax should throw', async () => {
);
}).rejects.toThrowError('TS1005');
}, 20000);

test('vue', async () => {
const {stdout} = await exec(
`${BIN_PATH} extract '${join(__dirname, 'vue/*.vue')}'`
);

expect(JSON.parse(stdout)).toMatchSnapshot();
});
17 changes: 17 additions & 0 deletions packages/cli/tests/extract/vue/comp.vue
@@ -0,0 +1,17 @@
<template>
<p>
{{
$formatMessage({
defaultMessage: 'in template',
description: 'in template desc',
})
}}
</p>
</template>

<script>
intl.formatMessage({
defaultMessage: 'in script',
description: 'in script desc',
});
</script>
50 changes: 50 additions & 0 deletions packages/cli/tests/extract/vue_extractor.test.ts
@@ -0,0 +1,50 @@
import {MessageDescriptor} from '@formatjs/ts-transformer';
import {parseScript} from '../../src/parse_script';
import {parseFile} from '../../src/vue_extractor';

test('vue_extractor', function () {
let messages: MessageDescriptor[] = [];

parseFile(
`
<template>
<p>
{{
$formatMessage({
defaultMessage: 'in template',
description: 'in template desc',
})
}}
</p>
</template>
<script>
intl.formatMessage({
defaultMessage: 'in script',
description: 'in script desc',
});
</script>
`,
'comp.vue',
parseScript({
additionalFunctionNames: ['$formatMessage'],
extractFromFormatMessageCall: true,
onMsgExtracted(_, msgs) {
messages = messages.concat(msgs);
},
overrideIdFn: '[contenthash:5]',
})
);
expect(messages).toEqual([
{
defaultMessage: 'in template',
description: 'in template desc',
id: 'f6d14',
},
{
defaultMessage: 'in script',
description: 'in script desc',
id: '1ebd4',
},
]);
});
6 changes: 5 additions & 1 deletion website/docs/tooling/cli.md
Expand Up @@ -92,7 +92,7 @@ formatjs extract --help
For example:

```sh
formatjs extract "src/**/*.{ts,tsx}" --out-file lang.json
formatjs extract "src/**/*.{ts,tsx,vue}" --out-file lang.json
```

:::caution
Expand Down Expand Up @@ -130,6 +130,10 @@ Whether the metadata about the location of the message in the source file should
Additional component names to extract messages from, e.g: `['FormattedFooBarMessage']`. **NOTE**: By default we check for the fact that `FormattedMessage` is imported from `moduleSourceName` to make sure variable alias works. This option does not do that so it's less safe.
### `--additional-function-names [comma-separated-names]`
Additional function names to extract messages from, e.g: `['$t']`.
### `--extract-from-format-message-call`
Opt-in to extract from `intl.formatMessage` call with the same restrictions, e.g: has to be called with object literal such as `intl.formatMessage({ id: 'foo', defaultMessage: 'bar', description: 'baz'})` (default: `false`)
Expand Down

0 comments on commit 24d6d1b

Please sign in to comment.