@lit/localize
Advanced tools
Comparing version 0.7.0-pre.1 to 0.7.0
@@ -10,4 +10,29 @@ # Changelog | ||
## Changed | ||
<!-- ### Changed --> | ||
## [0.7.0] - 2021-03-12 | ||
- **[BREAKING]** Templates can now contain arbitrary expressions, and no longer | ||
need to be wrapped in a function. | ||
Before: | ||
```ts | ||
msg((name) => html`Hello <b>${name}</b>!`, {args: [getUsername()]}); | ||
``` | ||
After: | ||
```ts | ||
msg(html`Hello <b>${getUsername()}</b>!`); | ||
``` | ||
Plain strings containing expressions must now be tagged with the new `str` | ||
tag. This allows lit-localize to access dynamic values at runtime. | ||
```ts | ||
import {msg, str} from 'lit-localize'; | ||
msg(str`Hello ${name}`); | ||
``` | ||
- **[BREAKING]** The `lit-localize` CLI now must always take one of two | ||
@@ -14,0 +39,0 @@ commands: `extract` or `build`. Previously, both of these steps were always |
@@ -31,4 +31,4 @@ "use strict"; | ||
`; | ||
const validCommands = ['build', 'extract']; | ||
const isValidCommand = (str) => validCommands.includes(str); | ||
const commands = ['build', 'extract']; | ||
const isCommand = (str) => commands.includes(str); | ||
async function runAndExit() { | ||
@@ -75,2 +75,4 @@ const exitCode = await runAndLog(process.argv); | ||
if (command === 'extract') { | ||
// TODO(aomarks) Don't even require the user to have configured their output | ||
// mode if they're just doing extraction. | ||
console.log('Extracting messages'); | ||
@@ -123,8 +125,8 @@ const { messages, errors } = localizer.extractSourceMessages(); | ||
throw new error_1.KnownError(`Missing command argument. ` + | ||
`Valid commands: ${[...validCommands].join(', ')}`); | ||
`Valid commands: ${[...commands].join(', ')}`); | ||
} | ||
const command = args._[0]; | ||
if (!isValidCommand(command)) { | ||
if (!isCommand(command)) { | ||
throw new error_1.KnownError(`Invalid command ${command}}. ` + | ||
`Valid commands: ${[...validCommands].join(', ')}`); | ||
`Valid commands: ${[...commands].join(', ')}`); | ||
} | ||
@@ -131,0 +133,0 @@ if (args._.length > 1) { |
@@ -20,2 +20,6 @@ /** | ||
* concrete classes: TransformLitLocalizer, RuntimeLitLocalizer. | ||
* | ||
* TODO(aomarks) This set of classes is probably too monolithic. Let's split | ||
* things up into Extractor, Builders, and Formatters classes (actually | ||
* Formatter already exists, but it needs a better name). | ||
*/ | ||
@@ -48,4 +52,11 @@ export declare abstract class LitLocalizer { | ||
* files into a Map keyed by locale ID. | ||
* | ||
* TODO(aomarks) Add an async version. This is synchronous as a conceit to our | ||
* Rollup integration, because | ||
* @rollup/typescript-plugin runs tsc in the Rollup buildStart hook, which we | ||
* cannot preempt because that is the earliest hook, and they run in parallel | ||
* (see https://github.com/rollup/rollup/issues/2826). We'd prefer to read | ||
* translation files in parallel when we can. | ||
*/ | ||
readTranslations(): ReadTranslationsResult; | ||
readTranslationsSync(): ReadTranslationsResult; | ||
/** | ||
@@ -52,0 +63,0 @@ * Check that all translations are valid given the current set of source |
@@ -16,2 +16,6 @@ "use strict"; | ||
* concrete classes: TransformLitLocalizer, RuntimeLitLocalizer. | ||
* | ||
* TODO(aomarks) This set of classes is probably too monolithic. Let's split | ||
* things up into Extractor, Builders, and Formatters classes (actually | ||
* Formatter already exists, but it needs a better name). | ||
*/ | ||
@@ -51,4 +55,11 @@ class LitLocalizer { | ||
* files into a Map keyed by locale ID. | ||
* | ||
* TODO(aomarks) Add an async version. This is synchronous as a conceit to our | ||
* Rollup integration, because | ||
* @rollup/typescript-plugin runs tsc in the Rollup buildStart hook, which we | ||
* cannot preempt because that is the earliest hook, and they run in parallel | ||
* (see https://github.com/rollup/rollup/issues/2826). We'd prefer to read | ||
* translation files in parallel when we can. | ||
*/ | ||
readTranslations() { | ||
readTranslationsSync() { | ||
if (!this._translations) { | ||
@@ -68,3 +79,3 @@ const localeMessagesMap = new Map(); | ||
validateTranslations() { | ||
const { translations } = this.readTranslations(); | ||
const { translations } = this.readTranslationsSync(); | ||
const { messages } = this.extractSourceMessages(); | ||
@@ -90,3 +101,3 @@ const placeholderErrors = messages_1.validateLocalizedPlaceholders(messages, translations); | ||
const { messages } = this.extractSourceMessages(); | ||
const { translations } = this.readTranslations(); | ||
const { translations } = this.readTranslationsSync(); | ||
const sorted = messages_1.sortProgramMessages([...messages]); | ||
@@ -93,0 +104,0 @@ await this.formatter.writeOutput(sorted, translations); |
@@ -43,13 +43,2 @@ /** | ||
/** | ||
* If this message was written as a function, the names of the parameters that | ||
* the function takes. | ||
* | ||
* E.g. given: | ||
* msg('foo', (bar: string, baz: number) => `foo ${bar} ${baz}`, 'a', 4) | ||
* | ||
* Then params is: | ||
* [ 'bar', 'baz' ] | ||
*/ | ||
params?: string[]; | ||
/** | ||
* True if this message was tagged as a lit-html template, or was a function | ||
@@ -56,0 +45,0 @@ * that returned a lit-html template. |
@@ -41,6 +41,3 @@ /** | ||
}); | ||
/** | ||
* TODO | ||
*/ | ||
build(): Promise<void>; | ||
} |
@@ -15,2 +15,3 @@ "use strict"; | ||
const pathLib = require("path"); | ||
const ts = require("typescript"); | ||
const index_js_1 = require("../index.js"); | ||
@@ -28,9 +29,6 @@ /** | ||
} | ||
/** | ||
* TODO | ||
*/ | ||
async build() { | ||
this.assertTranslationsAreValid(); | ||
const { messages } = this.extractSourceMessages(); | ||
const { translations } = this.readTranslations(); | ||
const { translations } = this.readTranslationsSync(); | ||
await runtimeOutput(messages, translations, this.config, this.config.output); | ||
@@ -137,2 +135,15 @@ } | ||
function makeMessageString(contents, canon) { | ||
// Translations can modify the order of expressions in a template. We encode | ||
// local expression order by replacing the value with the index number. It's | ||
// okay to lose the original value, because at runtime we always substitute | ||
// the source locale value anyway (because of variable scoping). | ||
// | ||
// For example, if some placeholders were reordered from [0 1 2] to [2 0 1], | ||
// then we'll generate a template like: html`foo ${2} bar ${0} baz ${1}` | ||
const placeholderOrder = new Map(canon.contents | ||
.filter((value) => typeof value !== 'string') | ||
.map((placeholder, idx) => [ | ||
placeholder.untranslatable, | ||
idx, | ||
])); | ||
const fragments = []; | ||
@@ -144,16 +155,19 @@ for (const content of contents) { | ||
else { | ||
fragments.push(content.untranslatable); | ||
const template = typescript_1.parseStringAsTemplateLiteral(content.untranslatable); | ||
if (ts.isNoSubstitutionTemplateLiteral(template)) { | ||
fragments.push(template.text); | ||
} | ||
else { | ||
fragments.push(template.head.text); | ||
for (const span of template.templateSpans) { | ||
// Substitute the value with the index (see note above). | ||
fragments.push('${' + placeholderOrder.get(content.untranslatable) + '}'); | ||
fragments.push(span.literal.text); | ||
} | ||
} | ||
} | ||
} | ||
const tag = canon.isLitTemplate ? 'html' : ''; | ||
const msgStr = `${tag}\`${fragments.join('')}\``; | ||
if (canon.params !== undefined && canon.params.length > 0) { | ||
return `(${canon.params | ||
.map((param) => `${param}: any`) | ||
.join(', ')}) => ${msgStr}`; | ||
} | ||
else { | ||
return msgStr; | ||
} | ||
return `${tag}\`${fragments.join('')}\``; | ||
} | ||
//# sourceMappingURL=runtime.js.map |
@@ -34,3 +34,3 @@ "use strict"; | ||
this.assertTranslationsAreValid(); | ||
const { translations } = this.readTranslations(); | ||
const { translations } = this.readTranslationsSync(); | ||
await transformOutput(translations, this.config, this.config.output, this.program); | ||
@@ -47,3 +47,3 @@ } | ||
transformers() { | ||
const { translations } = this.readTranslations(); | ||
const { translations } = this.readTranslationsSync(); | ||
const locales = [this.config.sourceLocale, ...this.config.targetLocales]; | ||
@@ -202,14 +202,22 @@ const factories = new Map(); | ||
const [templateArg, optionsArg] = call.arguments; | ||
const templateResult = program_analysis_1.extractTemplate(templateArg, this.sourceFile); | ||
const templateResult = program_analysis_1.extractTemplate(templateArg, this.sourceFile, this.typeChecker); | ||
if (templateResult.error) { | ||
throw new Error(templateResult.error.toString()); | ||
throw new Error(typescript_1.stringifyDiagnostics([templateResult.error])); | ||
} | ||
const { isLitTemplate: isLitTagged, params: paramNames, } = templateResult.result; | ||
const { isLitTemplate: isLitTagged } = templateResult.result; | ||
let { template } = templateResult.result; | ||
const optionsResult = program_analysis_1.extractOptions(optionsArg, this.sourceFile); | ||
if (optionsResult.error) { | ||
throw new Error(optionsResult.error.toString()); | ||
throw new Error(typescript_1.stringifyDiagnostics([optionsResult.error])); | ||
} | ||
const options = optionsResult.result; | ||
const id = options.id ?? program_analysis_1.generateMsgIdFromAstNode(template, isLitTagged); | ||
const sourceExpressions = new Map(); | ||
if (ts.isTemplateExpression(template)) { | ||
for (const span of template.templateSpans) { | ||
// TODO(aomarks) Support less brittle/more readable placeholder keys. | ||
const key = this.sourceFile.text.slice(span.expression.pos, span.expression.end); | ||
sourceExpressions.set(key, span.expression); | ||
} | ||
} | ||
// If translations are available, replace the source template from the | ||
@@ -225,27 +233,22 @@ // second argument with the corresponding translation. | ||
.join(''); | ||
// Note that we assume localized placeholder contents have already been | ||
// validated against the source code to confirm that HTML and template | ||
// literal expressions have not been corrupted or manipulated during | ||
// localization (though moving their position is OK). | ||
template = parseStringAsTemplateLiteral(templateLiteralBody); | ||
template = typescript_1.parseStringAsTemplateLiteral(templateLiteralBody); | ||
if (ts.isTemplateExpression(template)) { | ||
const newParts = []; | ||
newParts.push(template.head.text); | ||
for (const span of template.templateSpans) { | ||
const expressionKey = templateLiteralBody.slice(span.expression.pos - 1, span.expression.end - 1); | ||
const sourceExpression = sourceExpressions.get(expressionKey); | ||
if (sourceExpression === undefined) { | ||
throw new Error(`Expression in translation does not appear in source.` + | ||
`\nLocale: ${this.locale}` + | ||
`\nExpression: ${expressionKey}`); | ||
} | ||
newParts.push(sourceExpression); | ||
newParts.push(span.literal.text); | ||
} | ||
template = makeTemplateLiteral(newParts); | ||
} | ||
} | ||
// TODO(aomarks) Emit a warning that a translation was missing. | ||
} | ||
// If our second argument was a function, then any template expressions in | ||
// our template are scoped to that function. The arguments to that function | ||
// are the 3rd and onwards arguments to our `msg` function, so we must | ||
// substitute those arguments into the expressions. | ||
// | ||
// Given: msg((name) => html`Hello <b>${name}</b>`, {args: ["World"]}) | ||
// Generate: html`Hello <b>${"World"}</b>` | ||
if (ts.isArrowFunction(templateArg) && ts.isTemplateExpression(template)) { | ||
if (!paramNames || !options.args) { | ||
throw new error_1.KnownError('Internal error, expected paramNames and options.args to be defined'); | ||
} | ||
const paramValues = new Map(); | ||
for (let i = 0; i < paramNames.length; i++) { | ||
paramValues.set(paramNames[i], options.args[i]); | ||
} | ||
template = this.substituteIdentsInExpressions(template, paramValues); | ||
} | ||
// Nothing more to do with a simple string. | ||
@@ -379,22 +382,2 @@ if (ts.isStringLiteral(template)) { | ||
/** | ||
* Parse the given string as though it were the body of a template literal | ||
* (backticks should not be included), and return its TypeScript AST node | ||
* representation. | ||
*/ | ||
function parseStringAsTemplateLiteral(templateLiteralBody) { | ||
const file = ts.createSourceFile('__DUMMY__.ts', '`' + templateLiteralBody + '`', ts.ScriptTarget.ESNext, false, ts.ScriptKind.JS); | ||
if (file.statements.length !== 1) { | ||
throw new error_1.KnownError('Internal error: expected 1 statement'); | ||
} | ||
const statement = file.statements[0]; | ||
if (!ts.isExpressionStatement(statement)) { | ||
throw new error_1.KnownError('Internal error: expected expression statement'); | ||
} | ||
const expression = statement.expression; | ||
if (!ts.isTemplateLiteral(expression)) { | ||
throw new error_1.KnownError('Internal error: expected template literal expression'); | ||
} | ||
return expression; | ||
} | ||
/** | ||
* Given an array of strings and template expressions (as generated by | ||
@@ -401,0 +384,0 @@ * `recursivelyFlattenTemplate`), create the simplest TemplateLiteral node, |
@@ -38,3 +38,3 @@ /** | ||
*/ | ||
export declare function extractTemplate(templateArg: ts.Node, file: ts.SourceFile): ResultOrError<ExtractedTemplate, ts.DiagnosticWithLocation>; | ||
export declare function extractTemplate(templateArg: ts.Node, file: ts.SourceFile, typeChecker: ts.TypeChecker): ResultOrError<ExtractedTemplate, ts.DiagnosticWithLocation>; | ||
export declare function generateMsgIdFromAstNode(template: ts.TemplateLiteral | ts.StringLiteral, isHtmlTagged: boolean): string; | ||
@@ -53,2 +53,6 @@ /** | ||
export declare function isMsgCall(node: ts.Node, typeChecker: ts.TypeChecker): node is ts.CallExpression; | ||
/** | ||
* Return whether a node is a string tagged with our special `str` tag. | ||
*/ | ||
export declare function isStrTaggedTemplate(node: ts.Node, typeChecker: ts.TypeChecker): node is ts.TaggedTemplateExpression; | ||
export {}; |
@@ -8,3 +8,3 @@ "use strict"; | ||
Object.defineProperty(exports, "__esModule", { value: true }); | ||
exports.isMsgCall = exports.isLitTemplate = exports.isStaticString = exports.generateMsgIdFromAstNode = exports.extractTemplate = exports.extractOptions = exports.extractMessagesFromProgram = void 0; | ||
exports.isStrTaggedTemplate = exports.isMsgCall = exports.isLitTemplate = exports.isStaticString = exports.generateMsgIdFromAstNode = exports.extractTemplate = exports.extractOptions = exports.extractMessagesFromProgram = void 0; | ||
const ts = require("typescript"); | ||
@@ -72,7 +72,7 @@ const parse5 = require("parse5"); | ||
const [templateArg, optionsArg] = node.arguments; | ||
const templateResult = extractTemplate(templateArg, file); | ||
const templateResult = extractTemplate(templateArg, file, program.getTypeChecker()); | ||
if (templateResult.error) { | ||
return { error: templateResult.error }; | ||
} | ||
const { contents, template, params, isLitTemplate } = templateResult.result; | ||
const { contents, template, isLitTemplate } = templateResult.result; | ||
const optionsResult = extractOptions(optionsArg, file); | ||
@@ -90,3 +90,2 @@ if (optionsResult.error) { | ||
contents, | ||
params, | ||
isLitTemplate, | ||
@@ -168,5 +167,5 @@ descStack: descStack.map((desc) => desc.text), | ||
*/ | ||
function extractTemplate(templateArg, file) { | ||
function extractTemplate(templateArg, file, typeChecker) { | ||
if (isStaticString(templateArg)) { | ||
// E.g. 'Hello World' | ||
// E.g. 'Hello World', `Hello World` | ||
return { | ||
@@ -181,30 +180,19 @@ result: { | ||
if (isLitTemplate(templateArg)) { | ||
if (ts.isNoSubstitutionTemplateLiteral(templateArg.template)) { | ||
// E.g. html`Hello <b>World</b>` | ||
return { | ||
result: { | ||
template: templateArg.template, | ||
contents: combineAdjacentPlaceholders(replaceHtmlWithPlaceholders(templateArg.template.text)), | ||
isLitTemplate: true, | ||
}, | ||
}; | ||
} | ||
// E.g. html`Hello ${who}` | ||
return { | ||
error: typescript_1.createDiagnostic(file, templateArg, `To use a variable, pass an arrow function.`), | ||
}; | ||
// E.g. html`Hello <b>${name}</b>` | ||
return paramTemplate(templateArg, file); | ||
} | ||
if (ts.isTemplateExpression(templateArg)) { | ||
// E.g. `Hello ${who}` | ||
if (isStrTaggedTemplate(templateArg, typeChecker)) { | ||
// E.g. str`Hello ${who}` | ||
return paramTemplate(templateArg, file); | ||
} | ||
if (ts.isTemplateExpression(templateArg) || | ||
ts.isTaggedTemplateExpression(templateArg)) { | ||
// E.g. `Hello ${who}`, wrongTag`Hello ${who}` | ||
return { | ||
error: typescript_1.createDiagnostic(file, templateArg, `To use a variable, pass an arrow function.`), | ||
error: typescript_1.createDiagnostic(file, templateArg, `String literal with expressions must use the str tag`), | ||
}; | ||
} | ||
if (ts.isArrowFunction(templateArg)) { | ||
// E.g. (who) => html`Hello ${who}` | ||
return functionTemplate(templateArg, file); | ||
} | ||
return { | ||
error: typescript_1.createDiagnostic(file, templateArg, `Expected second argument to msg() to be a string, a lit-html ` + | ||
`template, or an arrow function that returns one of those.`), | ||
error: typescript_1.createDiagnostic(file, templateArg, `Expected first argument to msg() to be a string or lit-html ` + | ||
`template.`), | ||
}; | ||
@@ -237,53 +225,28 @@ } | ||
* Extract a message from calls like: | ||
* (name) => `Hello ${name}` | ||
* (name) => html`Hello <b>${name}</b>` | ||
* str`Hello ${name}` | ||
* html`Hello <b>${name}</b>` | ||
*/ | ||
function functionTemplate(fn, file) { | ||
if (fn.parameters.length === 0) { | ||
return { | ||
error: typescript_1.createDiagnostic(file, fn, `Expected template function to have at least one parameter. ` + | ||
`Use a regular string or lit-html template if there are no variables.`), | ||
}; | ||
} | ||
const params = []; | ||
for (const param of fn.parameters) { | ||
if (!ts.isIdentifier(param.name)) { | ||
return { | ||
error: typescript_1.createDiagnostic(file, param, `Expected template function parameter to be an identifier`), | ||
}; | ||
} | ||
params.push(param.name.text); | ||
} | ||
const body = fn.body; | ||
if (!ts.isTemplateExpression(body) && | ||
!ts.isNoSubstitutionTemplateLiteral(body) && | ||
!isLitTemplate(body)) { | ||
return { | ||
error: typescript_1.createDiagnostic(file, body, `Expected template function to return a template string literal ` + | ||
`or a lit-html template, without braces`), | ||
}; | ||
} | ||
const template = isLitTemplate(body) ? body.template : body; | ||
function paramTemplate(arg, file) { | ||
const parts = []; | ||
if (ts.isTemplateExpression(template)) { | ||
const spans = template.templateSpans; | ||
parts.push(template.head.text); | ||
for (const span of spans) { | ||
if (!ts.isIdentifier(span.expression) || | ||
!params.includes(span.expression.text)) { | ||
return { | ||
error: typescript_1.createDiagnostic(file, span.expression, `Placeholder must be one of the following identifiers: ` + | ||
params.join(', ')), | ||
}; | ||
} | ||
const identifier = span.expression.text; | ||
parts.push({ identifier }); | ||
parts.push(span.literal.text); | ||
} | ||
const template = ts.isTaggedTemplateExpression(arg) ? arg.template : arg; | ||
let spans; | ||
if (ts.isNoSubstitutionTemplateLiteral(template)) { | ||
spans = []; | ||
parts.push(template.text); | ||
} | ||
else { | ||
// A NoSubstitutionTemplateLiteral. No spans. | ||
parts.push(template.text); | ||
spans = template.templateSpans; | ||
parts.push(template.head.text); | ||
} | ||
const isLit = isLitTemplate(body); | ||
const printer = ts.createPrinter({ | ||
newLine: ts.NewLineKind.LineFeed, | ||
noEmitHelpers: true, | ||
}); | ||
for (const span of spans) { | ||
parts.push({ | ||
identifier: printer.printNode(ts.EmitHint.Unspecified, span.expression, file), | ||
}); | ||
parts.push(span.literal.text); | ||
} | ||
const isLit = isLitTemplate(arg); | ||
const contents = isLit | ||
@@ -294,7 +257,7 @@ ? replaceExpressionsAndHtmlWithPlaceholders(parts) | ||
: { untranslatable: '${' + part.identifier + '}' }); | ||
const combined = combineAdjacentPlaceholders(contents); | ||
return { | ||
result: { | ||
template, | ||
contents: combineAdjacentPlaceholders(contents), | ||
params, | ||
contents: combined, | ||
isLitTemplate: isLit, | ||
@@ -539,3 +502,9 @@ }, | ||
} | ||
const type = typeChecker.getTypeAtLocation(node.expression); | ||
let type; | ||
try { | ||
type = typeChecker.getTypeAtLocation(node.expression); | ||
} | ||
catch { | ||
return false; | ||
} | ||
const props = typeChecker.getPropertiesOfType(type); | ||
@@ -546,2 +515,20 @@ return props.some((prop) => prop.escapedName === '_LIT_LOCALIZE_MSG_'); | ||
/** | ||
* Return whether a node is a string tagged with our special `str` tag. | ||
*/ | ||
function isStrTaggedTemplate(node, typeChecker) { | ||
if (!ts.isTaggedTemplateExpression(node)) { | ||
return false; | ||
} | ||
let tag; | ||
try { | ||
tag = typeChecker.getTypeAtLocation(node.tag); | ||
} | ||
catch { | ||
return false; | ||
} | ||
const props = typeChecker.getPropertiesOfType(tag); | ||
return props.some((prop) => prop.escapedName === '_LIT_LOCALIZE_STR_'); | ||
} | ||
exports.isStrTaggedTemplate = isStrTaggedTemplate; | ||
/** | ||
* Check for messages that have the same ID. For those with the same ID and the | ||
@@ -548,0 +535,0 @@ * same content, de-duplicate them. For those with the same ID and different |
@@ -7,2 +7,9 @@ /** | ||
import * as ts from 'typescript'; | ||
export interface LocaleTransformer { | ||
locale: string; | ||
localeTransformer: { | ||
type: 'program'; | ||
factory: (program: ts.Program) => ts.TransformerFactory<ts.SourceFile>; | ||
}; | ||
} | ||
/** | ||
@@ -34,8 +41,2 @@ * Return an array of locales with associated TypeScript transformer factories | ||
*/ | ||
export declare const localeTransformers: (configPath?: string) => { | ||
locale: string; | ||
localeTransformer: { | ||
type: 'program'; | ||
factory: (program: ts.Program) => ts.TransformerFactory<ts.SourceFile>; | ||
}; | ||
}[]; | ||
export declare const localeTransformers: (configPath?: string) => Array<LocaleTransformer>; |
@@ -16,4 +16,8 @@ /** | ||
/** | ||
* Nicely log an error for the given TypeScript diagnostic object. | ||
* Create a nice string for the given TypeScript diagnostic objects. | ||
*/ | ||
export declare function stringifyDiagnostics(diagnostics: ts.Diagnostic[]): string; | ||
/** | ||
* Nicely log an error for the given TypeScript diagnostic objects. | ||
*/ | ||
export declare function printDiagnostics(diagnostics: ts.Diagnostic[]): void; | ||
@@ -25,1 +29,7 @@ /** | ||
export declare function escapeStringToEmbedInTemplateLiteral(unescaped: string): string; | ||
/** | ||
* Parse the given string as though it were the body of a template literal | ||
* (backticks should not be included), and return its TypeScript AST node | ||
* representation. | ||
*/ | ||
export declare function parseStringAsTemplateLiteral(templateLiteralBody: string): ts.TemplateLiteral; |
@@ -8,3 +8,3 @@ "use strict"; | ||
Object.defineProperty(exports, "__esModule", { value: true }); | ||
exports.escapeStringToEmbedInTemplateLiteral = exports.printDiagnostics = exports.createDiagnostic = exports.programFromTsConfig = void 0; | ||
exports.parseStringAsTemplateLiteral = exports.escapeStringToEmbedInTemplateLiteral = exports.printDiagnostics = exports.stringifyDiagnostics = exports.createDiagnostic = exports.programFromTsConfig = void 0; | ||
const ts = require("typescript"); | ||
@@ -48,6 +48,6 @@ const path = require("path"); | ||
/** | ||
* Nicely log an error for the given TypeScript diagnostic object. | ||
* Create a nice string for the given TypeScript diagnostic objects. | ||
*/ | ||
function printDiagnostics(diagnostics) { | ||
console.error(ts.formatDiagnosticsWithColorAndContext(diagnostics, { | ||
function stringifyDiagnostics(diagnostics) { | ||
return ts.formatDiagnosticsWithColorAndContext(diagnostics, { | ||
getCanonicalFileName(name) { | ||
@@ -62,4 +62,11 @@ return name; | ||
}, | ||
})); | ||
}); | ||
} | ||
exports.stringifyDiagnostics = stringifyDiagnostics; | ||
/** | ||
* Nicely log an error for the given TypeScript diagnostic objects. | ||
*/ | ||
function printDiagnostics(diagnostics) { | ||
console.error(stringifyDiagnostics(diagnostics)); | ||
} | ||
exports.printDiagnostics = printDiagnostics; | ||
@@ -77,2 +84,23 @@ /** | ||
exports.escapeStringToEmbedInTemplateLiteral = escapeStringToEmbedInTemplateLiteral; | ||
/** | ||
* Parse the given string as though it were the body of a template literal | ||
* (backticks should not be included), and return its TypeScript AST node | ||
* representation. | ||
*/ | ||
function parseStringAsTemplateLiteral(templateLiteralBody) { | ||
const file = ts.createSourceFile('__DUMMY__.ts', '`' + templateLiteralBody + '`', ts.ScriptTarget.ESNext, false, ts.ScriptKind.JS); | ||
if (file.statements.length !== 1) { | ||
throw new Error('Internal error: expected 1 statement'); | ||
} | ||
const statement = file.statements[0]; | ||
if (!ts.isExpressionStatement(statement)) { | ||
throw new Error('Internal error: expected expression statement'); | ||
} | ||
const expression = statement.expression; | ||
if (!ts.isTemplateLiteral(expression)) { | ||
throw new Error('Internal error: expected template literal expression'); | ||
} | ||
return expression; | ||
} | ||
exports.parseStringAsTemplateLiteral = parseStringAsTemplateLiteral; | ||
//# sourceMappingURL=typescript.js.map |
@@ -41,3 +41,3 @@ /** | ||
*/ | ||
export declare type TemplateLike = string | TemplateResult | ((...args: any[]) => string) | ((...args: any[]) => TemplateResult); | ||
export declare type TemplateLike = string | TemplateResult | StrResult; | ||
/** | ||
@@ -164,3 +164,24 @@ * A mapping from template ID to template. | ||
}; | ||
export interface StrResult { | ||
strTag: true; | ||
strings: TemplateStringsArray; | ||
values: unknown[]; | ||
} | ||
/** | ||
* Tag that allows expressions to be used in localized non-HTML template | ||
* strings. | ||
* | ||
* Example: msg(str`Hello ${this.user}!`); | ||
* | ||
* The Lit html tag can also be used for this purpose, but HTML will need to be | ||
* escaped, and there is a small overhead for HTML parsing. | ||
* | ||
* Untagged template strings with expressions aren't supported by lit-localize | ||
* because they don't allow for values to be captured at runtime. | ||
*/ | ||
declare const _str: (strings: TemplateStringsArray, ...values: unknown[]) => StrResult; | ||
export declare const str: typeof _str & { | ||
_LIT_LOCALIZE_STR_?: never; | ||
}; | ||
/** | ||
* Make a string or lit-html template localizable. | ||
@@ -173,4 +194,2 @@ * | ||
* omitted, an id will be automatically generated from the template strings. | ||
* - args: In the case that `template` is a function, it will be invoked with | ||
* these arguments. | ||
*/ | ||
@@ -180,12 +199,7 @@ export declare function _msg(template: string, options?: { | ||
}): string; | ||
export declare function _msg(template: TemplateResult, options?: { | ||
export declare function _msg(template: StrResult, options?: { | ||
id?: string; | ||
}): TemplateResult; | ||
export declare function _msg<F extends (...args: any[]) => string>(fn: F, options: { | ||
id?: string; | ||
args: Parameters<F>; | ||
}): string; | ||
export declare function _msg<F extends (...args: any[]) => TemplateResult>(fn: F, options: { | ||
export declare function _msg(template: TemplateResult, options?: { | ||
id?: string; | ||
args: Parameters<F>; | ||
}): TemplateResult; | ||
@@ -192,0 +206,0 @@ export declare const msg: typeof _msg & { |
@@ -6,3 +6,3 @@ /** | ||
*/ | ||
import { generateMsgId, HASH_DELIMITER } from './id-generation.js'; | ||
import { generateMsgId } from './id-generation.js'; | ||
/** | ||
@@ -159,4 +159,22 @@ * Name of the event dispatched to `window` whenever a locale change starts, | ||
}; | ||
/** | ||
* Tag that allows expressions to be used in localized non-HTML template | ||
* strings. | ||
* | ||
* Example: msg(str`Hello ${this.user}!`); | ||
* | ||
* The Lit html tag can also be used for this purpose, but HTML will need to be | ||
* escaped, and there is a small overhead for HTML parsing. | ||
* | ||
* Untagged template strings with expressions aren't supported by lit-localize | ||
* because they don't allow for values to be captured at runtime. | ||
*/ | ||
const _str = (strings, ...values) => ({ | ||
strTag: true, | ||
strings, | ||
values, | ||
}); | ||
export const str = _str; | ||
export function _msg(template, options) { | ||
var _a, _b; | ||
var _a; | ||
if (templates) { | ||
@@ -166,29 +184,61 @@ const id = (_a = options === null || options === void 0 ? void 0 : options.id) !== null && _a !== void 0 ? _a : generateId(template); | ||
if (localized) { | ||
template = localized; | ||
if (typeof localized === 'string') { | ||
// E.g. "Hello World!" | ||
return localized; | ||
} | ||
else if ('strTag' in localized) { | ||
// E.g. str`Hello ${name}!` | ||
// | ||
// Localized templates have ${number} in place of real template | ||
// expressions. They can't have real template values, because the | ||
// variable scope would be wrong. The number tells us the index of the | ||
// source value to substitute in its place, because expressions can be | ||
// moved to a different position during translation. | ||
return joinStringsAndValues(localized.strings, | ||
// Cast `template` because its type wasn't automatically narrowed (but | ||
// we know it must be the same type as `localized`). | ||
template.values, localized.values); | ||
} | ||
else { | ||
// E.g. html`Hello <b>${name}</b>!` | ||
// | ||
// We have to keep our own mapping of expression ordering because we do | ||
// an in-place update of `values`, and otherwise we'd lose ordering for | ||
// subsequent renders. | ||
let order = expressionOrders.get(localized); | ||
if (order === undefined) { | ||
order = localized.values; | ||
expressionOrders.set(localized, order); | ||
} | ||
// Cast `localized.values` because it's readonly. | ||
localized.values = order.map((i) => template.values[i]); | ||
return localized; | ||
} | ||
} | ||
} | ||
if (typeof template === 'function') { | ||
return template(...((_b = options === null || options === void 0 ? void 0 : options.args) !== null && _b !== void 0 ? _b : [])); | ||
if (typeof template !== 'string' && 'strTag' in template) { | ||
// E.g. str`Hello ${name}!` in original source locale. | ||
return joinStringsAndValues(template.strings, template.values); | ||
} | ||
return template; | ||
} | ||
/** | ||
* Render the result of a `str` tagged template to a string. Note we don't need | ||
* to do this for Lit templates, since Lit itself handles rendering. | ||
*/ | ||
const joinStringsAndValues = (strings, values, valueOrder) => { | ||
let concat = strings[0]; | ||
for (let i = 1; i < strings.length; i++) { | ||
concat += values[valueOrder ? valueOrder[i - 1] : i - 1]; | ||
concat += strings[i]; | ||
} | ||
return concat; | ||
}; | ||
const expressionOrders = new WeakMap(); | ||
const hashCache = new Map(); | ||
function generateId(template) { | ||
if (typeof template === 'function') { | ||
const numParams = template.length; | ||
// Note that by using HASH_DELIMITER as the template parameter here, we can | ||
// skip splitting and re-joining when we perform the hash. It's safe to do | ||
// this because we enforce that template expressions are only identifiers | ||
// that reference function parameters. | ||
const params = []; | ||
// Note Array.fill is not supported in IE11. | ||
for (let i = 0; i < numParams; i++) { | ||
params[i] = HASH_DELIMITER; | ||
} | ||
template = template(...params); | ||
} | ||
const strings = typeof template === 'string' ? template : template.strings; | ||
let id = hashCache.get(strings); | ||
if (id === undefined) { | ||
id = generateMsgId(strings, typeof template !== 'string'); | ||
id = generateMsgId(strings, typeof template !== 'string' && !('strTag' in template)); | ||
hashCache.set(strings, id); | ||
@@ -195,0 +245,0 @@ } |
{ | ||
"name": "@lit/localize", | ||
"version": "0.7.0-pre.1", | ||
"version": "0.7.0", | ||
"publishConfig": { | ||
@@ -5,0 +5,0 @@ "access": "public" |
@@ -324,5 +324,5 @@ # @lit/localize | ||
### `msg(template: string|TemplateResult|Function, options?: {id?: string, args?: any[]}) => string|TemplateResult` | ||
### `msg(template: TemplateResult|string|StrResult, options?: {id?: string}) => string|TemplateResult` | ||
Make a string or lit-html template localizable. | ||
Make lit-html template or string localizable. | ||
@@ -333,38 +333,28 @@ The `options.id` parameter is an optional project-wide unique identifier for | ||
The `template` parameter can have any of these types: | ||
The `template` parameter can take any of these forms: | ||
- A plain string with no placeholders: | ||
- A Lit [`html`](https://lit-html.polymer-project.org/guide/writing-templates) | ||
tagged string that may contain embedded HTML and arbitrary expressions. HTML | ||
markup and template expressions are automatically encoded into placeholders. | ||
Placeholders can be seen and re-ordered by translators, but they can't be | ||
modified. | ||
```typescript | ||
msg('Hello World!'); | ||
msg(html`Hello <b>${getUsername()}</b>!`); | ||
``` | ||
- A lit-html | ||
[`TemplateResult`](https://lit-html.polymer-project.org/api/classes/_lit_html_.templateresult.html) | ||
that may contain embedded HTML: | ||
- A plain string with no expressions. | ||
```typescript | ||
msg(html`Hello <b>World</b>!`); | ||
msg('Hello World!'); | ||
``` | ||
- A function that returns a [template | ||
literal](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals) | ||
string that may contain placeholders. Placeholders may only reference | ||
parameters of the function. The function will be invoked with the | ||
`options.args` array as its parameters: | ||
- A `str` tagged string with arbitrary expressions. This `str` tag is required | ||
if your plain string has expressions, because lit-localize needs dynamic | ||
access to the template's `values` at runtime. | ||
```typescript | ||
msg((name) => `Hello ${name}!`, {args: [getUsername()]}); | ||
msg(str`Hello ${getUsername()}!`); | ||
``` | ||
- A function that returns a lit-html | ||
[`TemplateResult`](https://lit-html.polymer-project.org/api/classes/_lit_html_.templateresult.html) | ||
that may contain embedded HTML, and may contain placeholders. Placeholders may | ||
only reference parameters of the function. The function will be invoked with | ||
the `options.args` array as its parameters: | ||
```typescript | ||
msg((name) => html`Hello <b>${name}</b>!`, {args: [getUsername()]}); | ||
``` | ||
In transform mode, calls to this function are replaced with the static localized | ||
@@ -377,2 +367,7 @@ template for each emitted locale. For example: | ||
### `str(strings: TemplateStringsArray, ...values: unknown[]): StrResult` | ||
Template tag function that allows string literals containing expressions to be | ||
localized. | ||
### `LOCALE_STATUS_EVENT` | ||
@@ -379,0 +374,0 @@ |
@@ -8,3 +8,3 @@ /** | ||
import {TemplateResult} from 'lit-html'; | ||
import {generateMsgId, HASH_DELIMITER} from './id-generation.js'; | ||
import {generateMsgId} from './id-generation.js'; | ||
@@ -51,7 +51,3 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ | ||
*/ | ||
export type TemplateLike = | ||
| string | ||
| TemplateResult | ||
| ((...args: any[]) => string) | ||
| ((...args: any[]) => TemplateResult); | ||
export type TemplateLike = string | TemplateResult | StrResult; | ||
@@ -84,3 +80,3 @@ /** | ||
// Mifiring eslint rule | ||
// Misfiring eslint rule | ||
// eslint-disable-next-line @typescript-eslint/no-unused-vars | ||
@@ -304,3 +300,32 @@ declare global { | ||
export interface StrResult { | ||
strTag: true; | ||
strings: TemplateStringsArray; | ||
values: unknown[]; | ||
} | ||
/** | ||
* Tag that allows expressions to be used in localized non-HTML template | ||
* strings. | ||
* | ||
* Example: msg(str`Hello ${this.user}!`); | ||
* | ||
* The Lit html tag can also be used for this purpose, but HTML will need to be | ||
* escaped, and there is a small overhead for HTML parsing. | ||
* | ||
* Untagged template strings with expressions aren't supported by lit-localize | ||
* because they don't allow for values to be captured at runtime. | ||
*/ | ||
const _str = ( | ||
strings: TemplateStringsArray, | ||
...values: unknown[] | ||
): StrResult => ({ | ||
strTag: true, | ||
strings, | ||
values, | ||
}); | ||
export const str: typeof _str & {_LIT_LOCALIZE_STR_?: never} = _str; | ||
/** | ||
* Make a string or lit-html template localizable. | ||
@@ -313,6 +338,5 @@ * | ||
* omitted, an id will be automatically generated from the template strings. | ||
* - args: In the case that `template` is a function, it will be invoked with | ||
* these arguments. | ||
*/ | ||
export function _msg(template: string, options?: {id?: string}): string; | ||
export function _msg(template: StrResult, options?: {id?: string}): string; | ||
@@ -324,15 +348,5 @@ export function _msg( | ||
export function _msg<F extends (...args: any[]) => string>( | ||
fn: F, | ||
options: {id?: string; args: Parameters<F>} | ||
): string; | ||
export function _msg<F extends (...args: any[]) => TemplateResult>( | ||
fn: F, | ||
options: {id?: string; args: Parameters<F>} | ||
): TemplateResult; | ||
export function _msg( | ||
template: TemplateLike, | ||
options?: {id?: string; args?: any[]} | ||
options?: {id?: string} | ||
): string | TemplateResult { | ||
@@ -343,7 +357,42 @@ if (templates) { | ||
if (localized) { | ||
template = localized; | ||
if (typeof localized === 'string') { | ||
// E.g. "Hello World!" | ||
return localized; | ||
} else if ('strTag' in localized) { | ||
// E.g. str`Hello ${name}!` | ||
// | ||
// Localized templates have ${number} in place of real template | ||
// expressions. They can't have real template values, because the | ||
// variable scope would be wrong. The number tells us the index of the | ||
// source value to substitute in its place, because expressions can be | ||
// moved to a different position during translation. | ||
return joinStringsAndValues( | ||
localized.strings, | ||
// Cast `template` because its type wasn't automatically narrowed (but | ||
// we know it must be the same type as `localized`). | ||
(template as TemplateResult).values, | ||
localized.values as number[] | ||
); | ||
} else { | ||
// E.g. html`Hello <b>${name}</b>!` | ||
// | ||
// We have to keep our own mapping of expression ordering because we do | ||
// an in-place update of `values`, and otherwise we'd lose ordering for | ||
// subsequent renders. | ||
let order = expressionOrders.get(localized); | ||
if (order === undefined) { | ||
order = localized.values as number[]; | ||
expressionOrders.set(localized, order); | ||
} | ||
// Cast `localized.values` because it's readonly. | ||
(localized as { | ||
values: TemplateResult['values']; | ||
}).values = order.map((i) => (template as TemplateResult).values[i]); | ||
return localized; | ||
} | ||
} | ||
} | ||
if (typeof template === 'function') { | ||
return template(...(options?.args ?? [])); | ||
if (typeof template !== 'string' && 'strTag' in template) { | ||
// E.g. str`Hello ${name}!` in original source locale. | ||
return joinStringsAndValues(template.strings, template.values); | ||
} | ||
@@ -353,23 +402,31 @@ return template; | ||
/** | ||
* Render the result of a `str` tagged template to a string. Note we don't need | ||
* to do this for Lit templates, since Lit itself handles rendering. | ||
*/ | ||
const joinStringsAndValues = ( | ||
strings: TemplateStringsArray, | ||
values: Readonly<unknown[]>, | ||
valueOrder?: number[] | ||
) => { | ||
let concat = strings[0]; | ||
for (let i = 1; i < strings.length; i++) { | ||
concat += values[valueOrder ? valueOrder[i - 1] : i - 1]; | ||
concat += strings[i]; | ||
} | ||
return concat; | ||
}; | ||
const expressionOrders = new WeakMap<TemplateResult, number[]>(); | ||
const hashCache = new Map<TemplateStringsArray | string, string>(); | ||
function generateId(template: TemplateLike): string { | ||
if (typeof template === 'function') { | ||
const numParams = template.length; | ||
// Note that by using HASH_DELIMITER as the template parameter here, we can | ||
// skip splitting and re-joining when we perform the hash. It's safe to do | ||
// this because we enforce that template expressions are only identifiers | ||
// that reference function parameters. | ||
const params = []; | ||
// Note Array.fill is not supported in IE11. | ||
for (let i = 0; i < numParams; i++) { | ||
params[i] = HASH_DELIMITER; | ||
} | ||
template = template(...params); | ||
} | ||
const strings = typeof template === 'string' ? template : template.strings; | ||
let id = hashCache.get(strings); | ||
if (id === undefined) { | ||
id = generateMsgId(strings, typeof template !== 'string'); | ||
id = generateMsgId( | ||
strings, | ||
typeof template !== 'string' && !('strTag' in template) | ||
); | ||
hashCache.set(strings, id); | ||
@@ -376,0 +433,0 @@ } |
@@ -33,6 +33,6 @@ /** | ||
const validCommands = ['build', 'extract'] as const; | ||
type Command = typeof validCommands[number]; | ||
const isValidCommand = (str: string): str is Command => | ||
validCommands.includes(str as Command); | ||
const commands = ['build', 'extract'] as const; | ||
type Command = typeof commands[number]; | ||
const isCommand = (str: string): str is Command => | ||
commands.includes(str as Command); | ||
@@ -85,2 +85,4 @@ interface CliOptions { | ||
if (command === 'extract') { | ||
// TODO(aomarks) Don't even require the user to have configured their output | ||
// mode if they're just doing extraction. | ||
console.log('Extracting messages'); | ||
@@ -145,10 +147,10 @@ const {messages, errors} = localizer.extractSourceMessages(); | ||
`Missing command argument. ` + | ||
`Valid commands: ${[...validCommands].join(', ')}` | ||
`Valid commands: ${[...commands].join(', ')}` | ||
); | ||
} | ||
const command = args._[0]; | ||
if (!isValidCommand(command)) { | ||
if (!isCommand(command)) { | ||
throw new KnownError( | ||
`Invalid command ${command}}. ` + | ||
`Valid commands: ${[...validCommands].join(', ')}` | ||
`Valid commands: ${[...commands].join(', ')}` | ||
); | ||
@@ -155,0 +157,0 @@ } |
@@ -36,2 +36,6 @@ /** | ||
* concrete classes: TransformLitLocalizer, RuntimeLitLocalizer. | ||
* | ||
* TODO(aomarks) This set of classes is probably too monolithic. Let's split | ||
* things up into Extractor, Builders, and Formatters classes (actually | ||
* Formatter already exists, but it needs a better name). | ||
*/ | ||
@@ -87,4 +91,11 @@ export abstract class LitLocalizer { | ||
* files into a Map keyed by locale ID. | ||
* | ||
* TODO(aomarks) Add an async version. This is synchronous as a conceit to our | ||
* Rollup integration, because | ||
* @rollup/typescript-plugin runs tsc in the Rollup buildStart hook, which we | ||
* cannot preempt because that is the earliest hook, and they run in parallel | ||
* (see https://github.com/rollup/rollup/issues/2826). We'd prefer to read | ||
* translation files in parallel when we can. | ||
*/ | ||
readTranslations(): ReadTranslationsResult { | ||
readTranslationsSync(): ReadTranslationsResult { | ||
if (!this._translations) { | ||
@@ -105,3 +116,3 @@ const localeMessagesMap = new Map<Locale, Array<Message>>(); | ||
validateTranslations(): ValidateTranslationsResult { | ||
const {translations} = this.readTranslations(); | ||
const {translations} = this.readTranslationsSync(); | ||
const {messages} = this.extractSourceMessages(); | ||
@@ -132,3 +143,3 @@ const placeholderErrors = validateLocalizedPlaceholders( | ||
const {messages} = this.extractSourceMessages(); | ||
const {translations} = this.readTranslations(); | ||
const {translations} = this.readTranslationsSync(); | ||
const sorted = sortProgramMessages([...messages]); | ||
@@ -135,0 +146,0 @@ await this.formatter.writeOutput(sorted, translations); |
@@ -50,14 +50,2 @@ /** | ||
/** | ||
* If this message was written as a function, the names of the parameters that | ||
* the function takes. | ||
* | ||
* E.g. given: | ||
* msg('foo', (bar: string, baz: number) => `foo ${bar} ${baz}`, 'a', 4) | ||
* | ||
* Then params is: | ||
* [ 'bar', 'baz' ] | ||
*/ | ||
params?: string[]; | ||
/** | ||
* True if this message was tagged as a lit-html template, or was a function | ||
@@ -64,0 +52,0 @@ * that returned a lit-html template. |
@@ -12,5 +12,9 @@ /** | ||
import {KnownError} from '../error'; | ||
import {escapeStringToEmbedInTemplateLiteral} from '../typescript'; | ||
import { | ||
escapeStringToEmbedInTemplateLiteral, | ||
parseStringAsTemplateLiteral, | ||
} from '../typescript'; | ||
import * as fsExtra from 'fs-extra'; | ||
import * as pathLib from 'path'; | ||
import * as ts from 'typescript'; | ||
import {LitLocalizer} from '../index.js'; | ||
@@ -60,9 +64,6 @@ | ||
/** | ||
* TODO | ||
*/ | ||
async build() { | ||
this.assertTranslationsAreValid(); | ||
const {messages} = this.extractSourceMessages(); | ||
const {translations} = this.readTranslations(); | ||
const {translations} = this.readTranslationsSync(); | ||
await runtimeOutput( | ||
@@ -213,2 +214,18 @@ messages, | ||
): string { | ||
// Translations can modify the order of expressions in a template. We encode | ||
// local expression order by replacing the value with the index number. It's | ||
// okay to lose the original value, because at runtime we always substitute | ||
// the source locale value anyway (because of variable scoping). | ||
// | ||
// For example, if some placeholders were reordered from [0 1 2] to [2 0 1], | ||
// then we'll generate a template like: html`foo ${2} bar ${0} baz ${1}` | ||
const placeholderOrder = new Map<string, number>( | ||
canon.contents | ||
.filter((value) => typeof value !== 'string') | ||
.map((placeholder, idx) => [ | ||
(placeholder as Placeholder).untranslatable, | ||
idx, | ||
]) | ||
); | ||
const fragments = []; | ||
@@ -219,14 +236,20 @@ for (const content of contents) { | ||
} else { | ||
fragments.push(content.untranslatable); | ||
const template = parseStringAsTemplateLiteral(content.untranslatable); | ||
if (ts.isNoSubstitutionTemplateLiteral(template)) { | ||
fragments.push(template.text); | ||
} else { | ||
fragments.push(template.head.text); | ||
for (const span of template.templateSpans) { | ||
// Substitute the value with the index (see note above). | ||
fragments.push( | ||
'${' + placeholderOrder.get(content.untranslatable) + '}' | ||
); | ||
fragments.push(span.literal.text); | ||
} | ||
} | ||
} | ||
} | ||
const tag = canon.isLitTemplate ? 'html' : ''; | ||
const msgStr = `${tag}\`${fragments.join('')}\``; | ||
if (canon.params !== undefined && canon.params.length > 0) { | ||
return `(${canon.params | ||
.map((param) => `${param}: any`) | ||
.join(', ')}) => ${msgStr}`; | ||
} else { | ||
return msgStr; | ||
} | ||
return `${tag}\`${fragments.join('')}\``; | ||
} |
@@ -19,3 +19,7 @@ /** | ||
import {KnownError} from '../error'; | ||
import {escapeStringToEmbedInTemplateLiteral} from '../typescript'; | ||
import { | ||
escapeStringToEmbedInTemplateLiteral, | ||
stringifyDiagnostics, | ||
parseStringAsTemplateLiteral, | ||
} from '../typescript'; | ||
import * as pathLib from 'path'; | ||
@@ -69,3 +73,3 @@ import {LitLocalizer} from '../index.js'; | ||
this.assertTranslationsAreValid(); | ||
const {translations} = this.readTranslations(); | ||
const {translations} = this.readTranslationsSync(); | ||
await transformOutput( | ||
@@ -88,3 +92,3 @@ translations, | ||
transformers(): Map<Locale, TypeScriptTransformerFactoryFactory> { | ||
const {translations} = this.readTranslations(); | ||
const {translations} = this.readTranslationsSync(); | ||
const locales = [this.config.sourceLocale, ...this.config.targetLocales]; | ||
@@ -328,10 +332,11 @@ const factories = new Map<Locale, TypeScriptTransformerFactoryFactory>(); | ||
const templateResult = extractTemplate(templateArg, this.sourceFile); | ||
const templateResult = extractTemplate( | ||
templateArg, | ||
this.sourceFile, | ||
this.typeChecker | ||
); | ||
if (templateResult.error) { | ||
throw new Error(templateResult.error.toString()); | ||
throw new Error(stringifyDiagnostics([templateResult.error])); | ||
} | ||
const { | ||
isLitTemplate: isLitTagged, | ||
params: paramNames, | ||
} = templateResult.result; | ||
const {isLitTemplate: isLitTagged} = templateResult.result; | ||
let {template} = templateResult.result; | ||
@@ -341,3 +346,3 @@ | ||
if (optionsResult.error) { | ||
throw new Error(optionsResult.error.toString()); | ||
throw new Error(stringifyDiagnostics([optionsResult.error])); | ||
} | ||
@@ -347,2 +352,14 @@ const options = optionsResult.result; | ||
const sourceExpressions = new Map<string, ts.Expression>(); | ||
if (ts.isTemplateExpression(template)) { | ||
for (const span of template.templateSpans) { | ||
// TODO(aomarks) Support less brittle/more readable placeholder keys. | ||
const key = this.sourceFile.text.slice( | ||
span.expression.pos, | ||
span.expression.end | ||
); | ||
sourceExpressions.set(key, span.expression); | ||
} | ||
} | ||
// If translations are available, replace the source template from the | ||
@@ -360,7 +377,25 @@ // second argument with the corresponding translation. | ||
.join(''); | ||
// Note that we assume localized placeholder contents have already been | ||
// validated against the source code to confirm that HTML and template | ||
// literal expressions have not been corrupted or manipulated during | ||
// localization (though moving their position is OK). | ||
template = parseStringAsTemplateLiteral(templateLiteralBody); | ||
if (ts.isTemplateExpression(template)) { | ||
const newParts = []; | ||
newParts.push(template.head.text); | ||
for (const span of template.templateSpans) { | ||
const expressionKey = templateLiteralBody.slice( | ||
span.expression.pos - 1, | ||
span.expression.end - 1 | ||
); | ||
const sourceExpression = sourceExpressions.get(expressionKey); | ||
if (sourceExpression === undefined) { | ||
throw new Error( | ||
`Expression in translation does not appear in source.` + | ||
`\nLocale: ${this.locale}` + | ||
`\nExpression: ${expressionKey}` | ||
); | ||
} | ||
newParts.push(sourceExpression); | ||
newParts.push(span.literal.text); | ||
} | ||
template = makeTemplateLiteral(newParts); | ||
} | ||
} | ||
@@ -370,22 +405,2 @@ // TODO(aomarks) Emit a warning that a translation was missing. | ||
// If our second argument was a function, then any template expressions in | ||
// our template are scoped to that function. The arguments to that function | ||
// are the 3rd and onwards arguments to our `msg` function, so we must | ||
// substitute those arguments into the expressions. | ||
// | ||
// Given: msg((name) => html`Hello <b>${name}</b>`, {args: ["World"]}) | ||
// Generate: html`Hello <b>${"World"}</b>` | ||
if (ts.isArrowFunction(templateArg) && ts.isTemplateExpression(template)) { | ||
if (!paramNames || !options.args) { | ||
throw new KnownError( | ||
'Internal error, expected paramNames and options.args to be defined' | ||
); | ||
} | ||
const paramValues = new Map<string, ts.Expression>(); | ||
for (let i = 0; i < paramNames.length; i++) { | ||
paramValues.set(paramNames[i], options.args[i]); | ||
} | ||
template = this.substituteIdentsInExpressions(template, paramValues); | ||
} | ||
// Nothing more to do with a simple string. | ||
@@ -551,33 +566,2 @@ if (ts.isStringLiteral(template)) { | ||
/** | ||
* Parse the given string as though it were the body of a template literal | ||
* (backticks should not be included), and return its TypeScript AST node | ||
* representation. | ||
*/ | ||
function parseStringAsTemplateLiteral( | ||
templateLiteralBody: string | ||
): ts.TemplateLiteral { | ||
const file = ts.createSourceFile( | ||
'__DUMMY__.ts', | ||
'`' + templateLiteralBody + '`', | ||
ts.ScriptTarget.ESNext, | ||
false, | ||
ts.ScriptKind.JS | ||
); | ||
if (file.statements.length !== 1) { | ||
throw new KnownError('Internal error: expected 1 statement'); | ||
} | ||
const statement = file.statements[0]; | ||
if (!ts.isExpressionStatement(statement)) { | ||
throw new KnownError('Internal error: expected expression statement'); | ||
} | ||
const expression = statement.expression; | ||
if (!ts.isTemplateLiteral(expression)) { | ||
throw new KnownError( | ||
'Internal error: expected template literal expression' | ||
); | ||
} | ||
return expression; | ||
} | ||
/** | ||
* Given an array of strings and template expressions (as generated by | ||
@@ -584,0 +568,0 @@ * `recursivelyFlattenTemplate`), create the simplest TemplateLiteral node, |
@@ -103,7 +103,11 @@ /** | ||
const templateResult = extractTemplate(templateArg, file); | ||
const templateResult = extractTemplate( | ||
templateArg, | ||
file, | ||
program.getTypeChecker() | ||
); | ||
if (templateResult.error) { | ||
return {error: templateResult.error}; | ||
} | ||
const {contents, template, params, isLitTemplate} = templateResult.result; | ||
const {contents, template, isLitTemplate} = templateResult.result; | ||
@@ -123,3 +127,2 @@ const optionsResult = extractOptions(optionsArg, file); | ||
contents, | ||
params, | ||
isLitTemplate, | ||
@@ -245,6 +248,7 @@ descStack: descStack.map((desc) => desc.text), | ||
templateArg: ts.Node, | ||
file: ts.SourceFile | ||
file: ts.SourceFile, | ||
typeChecker: ts.TypeChecker | ||
): ResultOrError<ExtractedTemplate, ts.DiagnosticWithLocation> { | ||
if (isStaticString(templateArg)) { | ||
// E.g. 'Hello World' | ||
// E.g. 'Hello World', `Hello World` | ||
return { | ||
@@ -260,26 +264,16 @@ result: { | ||
if (isLitTemplate(templateArg)) { | ||
if (ts.isNoSubstitutionTemplateLiteral(templateArg.template)) { | ||
// E.g. html`Hello <b>World</b>` | ||
return { | ||
result: { | ||
template: templateArg.template, | ||
contents: combineAdjacentPlaceholders( | ||
replaceHtmlWithPlaceholders(templateArg.template.text) | ||
), | ||
isLitTemplate: true, | ||
}, | ||
}; | ||
} | ||
// E.g. html`Hello ${who}` | ||
return { | ||
error: createDiagnostic( | ||
file, | ||
templateArg, | ||
`To use a variable, pass an arrow function.` | ||
), | ||
}; | ||
// E.g. html`Hello <b>${name}</b>` | ||
return paramTemplate(templateArg, file); | ||
} | ||
if (ts.isTemplateExpression(templateArg)) { | ||
// E.g. `Hello ${who}` | ||
if (isStrTaggedTemplate(templateArg, typeChecker)) { | ||
// E.g. str`Hello ${who}` | ||
return paramTemplate(templateArg, file); | ||
} | ||
if ( | ||
ts.isTemplateExpression(templateArg) || | ||
ts.isTaggedTemplateExpression(templateArg) | ||
) { | ||
// E.g. `Hello ${who}`, wrongTag`Hello ${who}` | ||
return { | ||
@@ -289,3 +283,3 @@ error: createDiagnostic( | ||
templateArg, | ||
`To use a variable, pass an arrow function.` | ||
`String literal with expressions must use the str tag` | ||
), | ||
@@ -295,7 +289,2 @@ }; | ||
if (ts.isArrowFunction(templateArg)) { | ||
// E.g. (who) => html`Hello ${who}` | ||
return functionTemplate(templateArg, file); | ||
} | ||
return { | ||
@@ -305,4 +294,4 @@ error: createDiagnostic( | ||
templateArg, | ||
`Expected second argument to msg() to be a string, a lit-html ` + | ||
`template, or an arrow function that returns one of those.` | ||
`Expected first argument to msg() to be a string or lit-html ` + | ||
`template.` | ||
), | ||
@@ -340,75 +329,34 @@ }; | ||
* Extract a message from calls like: | ||
* (name) => `Hello ${name}` | ||
* (name) => html`Hello <b>${name}</b>` | ||
* str`Hello ${name}` | ||
* html`Hello <b>${name}</b>` | ||
*/ | ||
function functionTemplate( | ||
fn: ts.ArrowFunction, | ||
function paramTemplate( | ||
arg: ts.TaggedTemplateExpression | ts.TemplateExpression, | ||
file: ts.SourceFile | ||
): ResultOrError<ExtractedTemplate, ts.DiagnosticWithLocation> { | ||
if (fn.parameters.length === 0) { | ||
return { | ||
error: createDiagnostic( | ||
file, | ||
fn, | ||
`Expected template function to have at least one parameter. ` + | ||
`Use a regular string or lit-html template if there are no variables.` | ||
), | ||
}; | ||
const parts: Array<string | {identifier: string}> = []; | ||
const template = ts.isTaggedTemplateExpression(arg) ? arg.template : arg; | ||
let spans; | ||
if (ts.isNoSubstitutionTemplateLiteral(template)) { | ||
spans = []; | ||
parts.push(template.text); | ||
} else { | ||
spans = template.templateSpans; | ||
parts.push(template.head.text); | ||
} | ||
const params = []; | ||
for (const param of fn.parameters) { | ||
if (!ts.isIdentifier(param.name)) { | ||
return { | ||
error: createDiagnostic( | ||
file, | ||
param, | ||
`Expected template function parameter to be an identifier` | ||
), | ||
}; | ||
} | ||
params.push(param.name.text); | ||
} | ||
const body = fn.body; | ||
if ( | ||
!ts.isTemplateExpression(body) && | ||
!ts.isNoSubstitutionTemplateLiteral(body) && | ||
!isLitTemplate(body) | ||
) { | ||
return { | ||
error: createDiagnostic( | ||
file, | ||
body, | ||
`Expected template function to return a template string literal ` + | ||
`or a lit-html template, without braces` | ||
const printer = ts.createPrinter({ | ||
newLine: ts.NewLineKind.LineFeed, | ||
noEmitHelpers: true, | ||
}); | ||
for (const span of spans) { | ||
parts.push({ | ||
identifier: printer.printNode( | ||
ts.EmitHint.Unspecified, | ||
span.expression, | ||
file | ||
), | ||
}; | ||
}); | ||
parts.push(span.literal.text); | ||
} | ||
const template = isLitTemplate(body) ? body.template : body; | ||
const parts: Array<string | {identifier: string}> = []; | ||
if (ts.isTemplateExpression(template)) { | ||
const spans = template.templateSpans; | ||
parts.push(template.head.text); | ||
for (const span of spans) { | ||
if ( | ||
!ts.isIdentifier(span.expression) || | ||
!params.includes(span.expression.text) | ||
) { | ||
return { | ||
error: createDiagnostic( | ||
file, | ||
span.expression, | ||
`Placeholder must be one of the following identifiers: ` + | ||
params.join(', ') | ||
), | ||
}; | ||
} | ||
const identifier = span.expression.text; | ||
parts.push({identifier}); | ||
parts.push(span.literal.text); | ||
} | ||
} else { | ||
// A NoSubstitutionTemplateLiteral. No spans. | ||
parts.push(template.text); | ||
} | ||
const isLit = isLitTemplate(body); | ||
const isLit = isLitTemplate(arg); | ||
const contents = isLit | ||
@@ -421,7 +369,7 @@ ? replaceExpressionsAndHtmlWithPlaceholders(parts) | ||
); | ||
const combined = combineAdjacentPlaceholders(contents); | ||
return { | ||
result: { | ||
template, | ||
contents: combineAdjacentPlaceholders(contents), | ||
params, | ||
contents: combined, | ||
isLitTemplate: isLit, | ||
@@ -707,3 +655,8 @@ }, | ||
} | ||
const type = typeChecker.getTypeAtLocation(node.expression); | ||
let type; | ||
try { | ||
type = typeChecker.getTypeAtLocation(node.expression); | ||
} catch { | ||
return false; | ||
} | ||
const props = typeChecker.getPropertiesOfType(type); | ||
@@ -714,2 +667,22 @@ return props.some((prop) => prop.escapedName === '_LIT_LOCALIZE_MSG_'); | ||
/** | ||
* Return whether a node is a string tagged with our special `str` tag. | ||
*/ | ||
export function isStrTaggedTemplate( | ||
node: ts.Node, | ||
typeChecker: ts.TypeChecker | ||
): node is ts.TaggedTemplateExpression { | ||
if (!ts.isTaggedTemplateExpression(node)) { | ||
return false; | ||
} | ||
let tag; | ||
try { | ||
tag = typeChecker.getTypeAtLocation(node.tag); | ||
} catch { | ||
return false; | ||
} | ||
const props = typeChecker.getPropertiesOfType(tag); | ||
return props.some((prop) => prop.escapedName === '_LIT_LOCALIZE_STR_'); | ||
} | ||
/** | ||
* Check for messages that have the same ID. For those with the same ID and the | ||
@@ -716,0 +689,0 @@ * same content, de-duplicate them. For those with the same ID and different |
@@ -14,2 +14,10 @@ /** | ||
export interface LocaleTransformer { | ||
locale: string; | ||
localeTransformer: { | ||
type: 'program'; | ||
factory: (program: ts.Program) => ts.TransformerFactory<ts.SourceFile>; | ||
}; | ||
} | ||
/** | ||
@@ -43,9 +51,3 @@ * Return an array of locales with associated TypeScript transformer factories | ||
configPath = './lit-localize.json' | ||
): Array<{ | ||
locale: string; | ||
localeTransformer: { | ||
type: 'program'; | ||
factory: (program: ts.Program) => ts.TransformerFactory<ts.SourceFile>; | ||
}; | ||
}> => { | ||
): Array<LocaleTransformer> => { | ||
const config = readConfigFileAndWriteSchema(configPath); | ||
@@ -52,0 +54,0 @@ if (config.output.mode !== 'transform') { |
@@ -57,18 +57,23 @@ /** | ||
/** | ||
* Nicely log an error for the given TypeScript diagnostic object. | ||
* Create a nice string for the given TypeScript diagnostic objects. | ||
*/ | ||
export function stringifyDiagnostics(diagnostics: ts.Diagnostic[]): string { | ||
return ts.formatDiagnosticsWithColorAndContext(diagnostics, { | ||
getCanonicalFileName(name: string) { | ||
return name; | ||
}, | ||
getCurrentDirectory() { | ||
return process.cwd(); | ||
}, | ||
getNewLine() { | ||
return '\n'; | ||
}, | ||
}); | ||
} | ||
/** | ||
* Nicely log an error for the given TypeScript diagnostic objects. | ||
*/ | ||
export function printDiagnostics(diagnostics: ts.Diagnostic[]): void { | ||
console.error( | ||
ts.formatDiagnosticsWithColorAndContext(diagnostics, { | ||
getCanonicalFileName(name: string) { | ||
return name; | ||
}, | ||
getCurrentDirectory() { | ||
return process.cwd(); | ||
}, | ||
getNewLine() { | ||
return '\n'; | ||
}, | ||
}) | ||
); | ||
console.error(stringifyDiagnostics(diagnostics)); | ||
} | ||
@@ -88,1 +93,30 @@ | ||
} | ||
/** | ||
* Parse the given string as though it were the body of a template literal | ||
* (backticks should not be included), and return its TypeScript AST node | ||
* representation. | ||
*/ | ||
export function parseStringAsTemplateLiteral( | ||
templateLiteralBody: string | ||
): ts.TemplateLiteral { | ||
const file = ts.createSourceFile( | ||
'__DUMMY__.ts', | ||
'`' + templateLiteralBody + '`', | ||
ts.ScriptTarget.ESNext, | ||
false, | ||
ts.ScriptKind.JS | ||
); | ||
if (file.statements.length !== 1) { | ||
throw new Error('Internal error: expected 1 statement'); | ||
} | ||
const statement = file.statements[0]; | ||
if (!ts.isExpressionStatement(statement)) { | ||
throw new Error('Internal error: expected expression statement'); | ||
} | ||
const expression = statement.expression; | ||
if (!ts.isTemplateLiteral(expression)) { | ||
throw new Error('Internal error: expected template literal expression'); | ||
} | ||
return expression; | ||
} |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
369460
7488
678