Skip to content

Commit d92d73b

Browse files
authored
Merge pull request #38 from PolymerLabs/transform-scaffold
Scaffolding for transform mode
2 parents db2805e + 9e3fa3f commit d92d73b

24 files changed

+468
-12
lines changed

config.schema.json

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,22 @@
6565
],
6666
"type": "object"
6767
},
68+
"TransformOutputConfig": {
69+
"additionalProperties": false,
70+
"description": "Configuration specific to the `transform` output mode.",
71+
"properties": {
72+
"mode": {
73+
"enum": [
74+
"transform"
75+
],
76+
"type": "string"
77+
}
78+
},
79+
"required": [
80+
"mode"
81+
],
82+
"type": "object"
83+
},
6884
"XlbConfig": {
6985
"additionalProperties": false,
7086
"description": "Parse an XLB XML file. These files contain translations organized using the\nsame message names that we originally requested.\nConfiguration for XLB interchange format.",
@@ -130,7 +146,14 @@
130146
"description": "Localization interchange format and configuration specific to that format."
131147
},
132148
"output": {
133-
"$ref": "#/definitions/RuntimeOutputConfig",
149+
"anyOf": [
150+
{
151+
"$ref": "#/definitions/RuntimeOutputConfig"
152+
},
153+
{
154+
"$ref": "#/definitions/TransformOutputConfig"
155+
}
156+
],
134157
"description": "Set and configure the output mode."
135158
},
136159
"patches": {

package-lock.json

Lines changed: 7 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"fs-extra": "^9.0.0",
3333
"glob": "^7.1.6",
3434
"jsonschema": "^1.2.6",
35+
"lit-html": "^1.2.1",
3536
"minimist": "^1.2.5",
3637
"parse5": "^6.0.0",
3738
"source-map-support": "^0.5.19",
@@ -46,6 +47,7 @@
4647
"@types/minimist": "^1.2.0",
4748
"@types/node": "^14.0.1",
4849
"@types/parse5": "^5.0.2",
50+
"@types/prettier": "^2.0.1",
4951
"@types/xmldom": "^0.1.29",
5052
"@typescript-eslint/eslint-plugin": "^3.3.0",
5153
"@typescript-eslint/parser": "^3.3.0",
@@ -54,7 +56,6 @@
5456
"diff": "^4.0.2",
5557
"dir-compare": "^2.3.0",
5658
"eslint": "^7.0.0",
57-
"lit-html": "^1.2.1",
5859
"prettier": "^2.0.5",
5960
"rimraf": "^3.0.2",
6061
"typescript-json-schema": "^0.42.0"

src/cli.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import * as minimist from 'minimist';
1515
import {programFromTsConfig, printDiagnostics} from './typescript';
1616
import {extractMessagesFromProgram} from './program-analysis';
1717
import {runtimeOutput} from './outputters/runtime';
18+
import {transformOutput} from './outputters/transform';
1819
import {makeFormatter} from './formatters';
1920
import {ProgramMessage, Message} from './messages';
2021
import {KnownError, throwUnreachable} from './error';
@@ -94,9 +95,11 @@ async function runAndThrow(config: Config) {
9495

9596
if (config.output.mode === 'runtime') {
9697
runtimeOutput(messages, translationMap, config, config.output);
98+
} else if (config.output.mode === 'transform') {
99+
transformOutput(translationMap, config, program);
97100
} else {
98101
throwUnreachable(
99-
config.output.mode,
102+
config.output,
100103
`Internal error: unknown output mode ${
101104
(config.output as typeof config.output).mode
102105
}`

src/config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {Locale} from './locales';
1616
import {KnownError} from './error';
1717
import {FormatConfig} from './formatters';
1818
import {RuntimeOutputConfig} from './outputters/runtime';
19+
import {TransformOutputConfig} from './outputters/transform';
1920

2021
interface ConfigFile {
2122
/**
@@ -49,7 +50,7 @@ interface ConfigFile {
4950
/**
5051
* Set and configure the output mode.
5152
*/
52-
output: RuntimeOutputConfig;
53+
output: RuntimeOutputConfig | TransformOutputConfig;
5354

5455
/**
5556
* Optional string substitutions to apply to specific locale messages. Useful

src/formatters/xliff.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,13 @@ import {Config} from '../config';
1616
import {Locale} from '../locales';
1717
import {Formatter} from './index';
1818
import {KnownError} from '../error';
19-
import {Bundle, Message, ProgramMessage, Placeholder} from '../messages';
19+
import {
20+
Bundle,
21+
Message,
22+
ProgramMessage,
23+
Placeholder,
24+
makeMessageIdMap,
25+
} from '../messages';
2026
import {
2127
getOneElementByTagNameOrThrow,
2228
getNonEmptyAttributeOrThrow,
@@ -180,10 +186,7 @@ export class XliffFormatter implements Formatter {
180186
targetLocale: Locale,
181187
targetMessages: Message[]
182188
): string {
183-
const translationsByName = new Map<string, Message>();
184-
for (const message of targetMessages) {
185-
translationsByName.set(message.name, message);
186-
}
189+
const translationsByName = makeMessageIdMap(targetMessages);
187190

188191
const doc = new xmldom.DOMImplementation().createDocument('', '', null);
189192
const indent = (node: Element | Document, level = 0) =>

src/messages.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,3 +91,16 @@ export interface Placeholder {
9191
// END_BOLD, and also allow a way to define them in the template (e.g. a
9292
// "placeholder-name" element attribute or similar).
9393
}
94+
95+
/**
96+
* Given an array of messages, return a new map from message ID to message.
97+
*/
98+
export function makeMessageIdMap<T extends Message>(
99+
messages: T[]
100+
): Map<string, T> {
101+
const map = new Map<string, T>();
102+
for (const msg of messages) {
103+
map.set(msg.name, msg);
104+
}
105+
return map;
106+
}

src/outputters/transform.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/**
2+
* @license
3+
* Copyright (c) 2020 The Polymer Project Authors. All rights reserved.
4+
* This code may only be used under the BSD style license found at
5+
* http://polymer.github.io/LICENSE.txt The complete set of authors may be found
6+
* at http://polymer.github.io/AUTHORS.txt The complete set of contributors may
7+
* be found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by
8+
* Google as part of the polymer project is also subject to an additional IP
9+
* rights grant found at http://polymer.github.io/PATENTS.txt
10+
*/
11+
12+
import {Message} from '../messages';
13+
import {Locale} from '../locales';
14+
import {Config} from '../config';
15+
import * as ts from 'typescript';
16+
import * as pathLib from 'path';
17+
18+
/**
19+
* Configuration specific to the `transform` output mode.
20+
*/
21+
export interface TransformOutputConfig {
22+
mode: 'transform';
23+
}
24+
25+
/**
26+
* Compile and emit the given TypeScript program using the lit-localize
27+
* transformer.
28+
*/
29+
export function transformOutput(
30+
translationsByLocale: Map<Locale, Message[]>,
31+
config: Config,
32+
program: ts.Program
33+
) {
34+
// TODO(aomarks) It doesn't seem that it's possible for a TypeScript
35+
// transformer to emit a new file, so we just have to emit for each locale.
36+
// Need to do some more investigation into the best way to integrate this
37+
// transformation into a real project so that the user can still use --watch
38+
// and other tsc flags. It would also be nice to support the language server,
39+
// so that diagnostics will show up immediately in the editor.
40+
const opts = program.getCompilerOptions();
41+
const outRoot = opts.outDir || '.';
42+
for (const locale of [config.sourceLocale, ...config.targetLocales]) {
43+
let translations;
44+
if (locale !== config.sourceLocale) {
45+
translations = new Map<string, Message>();
46+
for (const message of translationsByLocale.get(locale) || []) {
47+
translations.set(message.name, message);
48+
}
49+
}
50+
opts.outDir = pathLib.join(outRoot, '/', locale);
51+
program.emit(undefined, undefined, undefined, undefined, {
52+
before: [litLocalizeTransform(translations)],
53+
});
54+
}
55+
}
56+
57+
/**
58+
* Return a TypeScript TransformerFactory for the lit-localize transformer.
59+
*/
60+
export function litLocalizeTransform(
61+
translations: Map<string, Message> | undefined
62+
): ts.TransformerFactory<ts.SourceFile> {
63+
return (context) => {
64+
const transformer = new Transformer(context, translations);
65+
return (file) => ts.visitNode(file, transformer.boundVisitNode);
66+
};
67+
}
68+
69+
/**
70+
* Implementation of the lit-localize TypeScript transformer.
71+
*/
72+
class Transformer {
73+
private context: ts.TransformationContext;
74+
boundVisitNode = this.visitNode.bind(this);
75+
76+
constructor(
77+
context: ts.TransformationContext,
78+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
79+
_translations: Map<string, Message> | undefined
80+
) {
81+
this.context = context;
82+
}
83+
84+
/**
85+
* Top-level delegating visitor for all nodes.
86+
*/
87+
visitNode(node: ts.Node): ts.VisitResult<ts.Node> {
88+
// TODO(aomarks) The transformer!
89+
return ts.visitEachChild(node, this.boundVisitNode, this.context);
90+
}
91+
}

src/tests/e2e-goldens-test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ export function e2eGoldensTest(
7070
'--no-install',
7171
'prettier',
7272
'--write',
73-
`${outputDir}/**/*.ts`,
73+
`${outputDir}/**/*.{ts,js}`,
7474
]);
7575

7676
if (process.env.UPDATE_TEST_GOLDENS) {

src/tests/transform.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/**
2+
* @license
3+
* Copyright (c) 2020 The Polymer Project Authors. All rights reserved.
4+
* This code may only be used under the BSD style license found at
5+
* http://polymer.github.io/LICENSE.txt The complete set of authors may be found
6+
* at http://polymer.github.io/AUTHORS.txt The complete set of contributors may
7+
* be found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by
8+
* Google as part of the polymer project is also subject to an additional IP
9+
* rights grant found at http://polymer.github.io/PATENTS.txt
10+
*/
11+
12+
import {e2eGoldensTest} from './e2e-goldens-test';
13+
14+
e2eGoldensTest('transform', ['--config=lit-localize.json']);

src/tests/transform.unit.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* @license
3+
* Copyright (c) 2020 The Polymer Project Authors. All rights reserved.
4+
* This code may only be used under the BSD style license found at
5+
* http://polymer.github.io/LICENSE.txt The complete set of authors may be found
6+
* at http://polymer.github.io/AUTHORS.txt The complete set of contributors may
7+
* be found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by
8+
* Google as part of the polymer project is also subject to an additional IP
9+
* rights grant found at http://polymer.github.io/PATENTS.txt
10+
*/
11+
12+
import {litLocalizeTransform} from '../outputters/transform';
13+
import * as ts from 'typescript';
14+
import {Message, makeMessageIdMap} from '../messages';
15+
import test, {ExecutionContext} from 'ava';
16+
import * as prettier from 'prettier';
17+
import {compileTsFragment, CompilerHostCache} from './compile-ts-fragment';
18+
19+
const cache = new CompilerHostCache();
20+
const IMPORT_MSG = `import { msg } from "./lib_client/index.js";\n`;
21+
const IMPORT_LIT_HTML = `import { html } from "lit-html";\n`;
22+
23+
/**
24+
* Compile the given fragment of TypeScript source code using the lit-localize
25+
* litLocalizeTransformer with the given translations. Check that there are no errors and
26+
* that the output matches (prettier-formatted).
27+
*/
28+
function checkTransform(
29+
t: ExecutionContext,
30+
inputTs: string,
31+
expectedJs: string,
32+
messages: Message[]
33+
) {
34+
// Rather than fuss with imports in all the test cases, this little hack
35+
// automatically imports for `msg` and `html` (assuming those strings aren't
36+
// used with any other meanings).
37+
if (inputTs.includes('msg')) {
38+
inputTs = IMPORT_MSG + inputTs;
39+
// Note we don't expect to see the `msg` import in the output JS, since it
40+
// should be un-used after litLocalizeTransformation.
41+
}
42+
if (inputTs.includes('html')) {
43+
inputTs = IMPORT_LIT_HTML + inputTs;
44+
expectedJs = IMPORT_LIT_HTML + expectedJs;
45+
}
46+
const options = ts.getDefaultCompilerOptions();
47+
options.target = ts.ScriptTarget.ES2015;
48+
options.module = ts.ModuleKind.ESNext;
49+
options.moduleResolution = ts.ModuleResolutionKind.NodeJs;
50+
// Don't automatically load typings from nodes_modules/@types, we're not using
51+
// them here, so it's a waste of time.
52+
options.typeRoots = [];
53+
const result = compileTsFragment(inputTs, options, cache, {
54+
before: [litLocalizeTransform(makeMessageIdMap(messages))],
55+
});
56+
57+
let formattedExpected = prettier.format(expectedJs, {parser: 'typescript'});
58+
let formattedActual;
59+
try {
60+
formattedActual = prettier.format(result.code || '', {
61+
parser: 'typescript',
62+
});
63+
} catch {
64+
// We might emit invalid TypeScript in a failing test. Rather than fail with
65+
// a Prettier parse exception, it's more useful to see a diff.
66+
formattedExpected = expectedJs;
67+
formattedActual = result.code;
68+
}
69+
t.is(formattedActual, formattedExpected);
70+
t.deepEqual(result.diagnostics, []);
71+
}
72+
73+
test('unchanged const', (t) => {
74+
const src = 'const foo = "foo";';
75+
checkTransform(t, src, src, []);
76+
});
77+
78+
test('unchanged html', (t) => {
79+
const src =
80+
'const foo = "foo"; const bar = "bar"; html`Hello ${foo} and ${bar}!`;';
81+
checkTransform(t, src, src, []);
82+
});
83+
84+
// TODO(aomarks) The actual tests!

testdata/transform/goldens/foo.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import {html} from 'lit-html';
2+
import {msg} from '../../../lib_client/index.js';
3+
4+
msg('string', 'Hello World!');
5+
6+
msg('lit', html`Hello <b><i>World!</i></b>`);
7+
8+
msg('variables_1', (name: string) => `Hello ${name}!`, 'World');
9+
10+
msg(
11+
'lit_variables_1',
12+
(url: string, name: string) =>
13+
html`Hello ${name}, click <a href="${url}">here</a>!`,
14+
'https://www.example.com/',
15+
'World'
16+
);
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"$schema": "../../../config.schema.json",
3+
"sourceLocale": "en",
4+
"targetLocales": ["es-419", "zh_CN"],
5+
"tsConfig": "tsconfig.json",
6+
"output": {
7+
"mode": "transform"
8+
},
9+
"interchange": {
10+
"format": "xliff",
11+
"xliffDir": "xliff/"
12+
}
13+
}

0 commit comments

Comments
 (0)