diff --git a/gems/canvas_i18nliner/spec/helpers/html_reporter.js b/gems/canvas_i18nliner/js/errors.js similarity index 70% rename from gems/canvas_i18nliner/spec/helpers/html_reporter.js rename to gems/canvas_i18nliner/js/errors.js index f01886c9520..cc17362b94d 100644 --- a/gems/canvas_i18nliner/spec/helpers/html_reporter.js +++ b/gems/canvas_i18nliner/js/errors.js @@ -1,5 +1,5 @@ /* - * Copyright (C) 2019 - present Instructure, Inc. + * Copyright (C) 2022 - present Instructure, Inc. * * This file is part of Canvas. * @@ -16,10 +16,8 @@ * with this program. If not, see . */ -var HtmlReporter = require('jasmine-pretty-html-reporter').Reporter; -var path = require('path'); +const { default: Errors } = require("@instructure/i18nliner/dist/lib/errors"); -// options object -jasmine.getEnv().addReporter(new HtmlReporter({ - path: path.join(__dirname,'../../../../tmp/spec_results') -})); +Errors.register("UnscopedTranslateCall"); + +module.exports = Errors \ No newline at end of file diff --git a/gems/canvas_i18nliner/js/scoped_esm_extractor.js b/gems/canvas_i18nliner/js/scoped_esm_extractor.js new file mode 100644 index 00000000000..5c0125da841 --- /dev/null +++ b/gems/canvas_i18nliner/js/scoped_esm_extractor.js @@ -0,0 +1,176 @@ +/* + * Copyright (C) 2022 - present Instructure, Inc. + * + * This file is part of Canvas. + * + * Canvas is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3 of the License. + * + * Canvas is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License along + * with this program. If not, see . + */ + +const createScopedTranslateCall = require("./scoped_translate_call") +const Errors = require("./errors"); +const { default: I18nJsExtractor } = require("@instructure/i18nliner/dist/lib/extractors/i18n_js_extractor"); +const { default: TranslateCall } = require("@instructure/i18nliner/dist/lib/extractors/translate_call"); + +const ScopedTranslateCall = createScopedTranslateCall(TranslateCall); +const CANVAS_I18N_PACKAGE = '@canvas/i18n' +const CANVAS_I18N_USE_SCOPE_SPECIFIER = 'useScope' +const CANVAS_I18N_RECEIVER = 'I18n' + +// This extractor implementation is suitable for ES modules where a module +// imports the "useScope" function from the @canvas/i18n package and assigns the +// output of a call to that function to a receiver named exactly "I18n". Calls +// to the "t" or "translate" methods on that receiver will use the scope +// supplied to that "useScope" call. +// +// import { useScope } from '@canvas/i18n' +// +// const I18n = useScope('foo') +// +// I18n.t('my_key', 'Hello world!') +// // => { "foo": { "my_key": "Hello World" } } +// +// The extractor looks for the I18n receiver defined in the current lexical +// scope of the call to I18n.t(): +// +// function a() { +// const I18n = useScope('foo') +// I18n.t('my_key', 'Key in foo') // => foo.my_key +// } +// +// function b() { +// const I18n = useScope('bar') +// I18n.t('my_key', 'Key in bar') // => bar.my_key +// } +// +// Note that the receiver MUST be identified as "I18n". The (base) extractor +// will fail to recognize any translate calls if the output of useScope is +// assigned to a receiver with a different identifier. With that said, the +// identifier for useScope can be renamed at will: +// +// // this is OK: +// import { useScope as useI18nScope } from '@canvas/i18n' +// const I18n = useI18nScope('foo') +// +// // this is NOT ok: +// import { useScope } from '@canvas/i18n' +// const smth = useScope('foo') +// +class ScopedESMExtractor extends I18nJsExtractor { + constructor() { + super(...arguments) + + // the identifier for the "useScope" specifier imported from @canvas/i18n, + // which may be renamed + this.useScopeIdentifier = null + + // mapping of "I18n" receivers to the (i18n) scopes they were assigned in + // the call to useScope + this.receiverScopeMapping = new WeakMap() + }; + + enter(path) { + // import { useScope } from '@canvas/i18n' + // ^^^^^^^^ + // import { useScope as blah } from '@canvas/i18n' + // ^^^^ + if (!this.useScopeIdentifier && path.type === 'ImportDeclaration') { + trackUseScopeIdentifier.call(this, path); + } + // let I18n + // ^^^^ + // I18n = useScope('foo') + // ^^^ + // (this happens in CoffeeScript when compiled to JS) + else if (this.useScopeIdentifier && path.type === 'AssignmentExpression') { + indexScopeFromAssignment.call(this, path) + } + // const I18n = useScope('foo') + // ^^^^ ^^^ + else if (this.useScopeIdentifier && path.type === 'VariableDeclarator') { + indexScopeFromDeclaration.call(this, path) + } + + return super.enter(...arguments) + }; + + buildTranslateCall(line, method, args, path) { + const binding = path.scope.getBinding(CANVAS_I18N_RECEIVER) + const scope = this.receiverScopeMapping.get(binding) + + if (scope) { + return new ScopedTranslateCall(line, method, args, scope); + } + else { + throw new Errors.UnscopedTranslateCall(line) + } + }; +}; + +function trackUseScopeIdentifier({ node }) { + if ( + node.source && + node.source.type === 'StringLiteral' && + node.source.value === CANVAS_I18N_PACKAGE + ) { + const specifier = node.specifiers.find(x => + x.type === 'ImportSpecifier'&& + x.imported && + x.imported.type === 'Identifier' && + x.imported.name === CANVAS_I18N_USE_SCOPE_SPECIFIER + ) + + if ( + specifier && + specifier.type === 'ImportSpecifier' && + specifier.local && + specifier.local.type === 'Identifier' && + specifier.local.name + ) { + this.useScopeIdentifier = specifier.local.name + } + } +}; + +function indexScopeFromAssignment(path) { + return indexScope.call(this, path, path.node.left, path.node.right) +}; + +function indexScopeFromDeclaration(path) { + return indexScope.call(this, path, path.node.id, path.node.init) +}; + +// left: Identifier +// right: CallExpression +function indexScope(path, left, right) { + if ( + left && + left.type === 'Identifier' && + left.name === CANVAS_I18N_RECEIVER && + right && + right.type === 'CallExpression' && + right.callee && + right.callee.type === 'Identifier' && + right.callee.name === this.useScopeIdentifier && + right.arguments && + right.arguments.length === 1 && + right.arguments[0].type === 'StringLiteral' && + right.arguments[0].value + ) { + this.receiverScopeMapping.set( + path.scope.getBinding(CANVAS_I18N_RECEIVER), + right.arguments[0].value + ) + } +}; + +module.exports = ScopedESMExtractor; diff --git a/gems/canvas_i18nliner/js/scoped_i18n_js_extractor.js b/gems/canvas_i18nliner/js/scoped_i18n_js_extractor.js index 26dc800b094..8a625e84349 100644 --- a/gems/canvas_i18nliner/js/scoped_i18n_js_extractor.js +++ b/gems/canvas_i18nliner/js/scoped_i18n_js_extractor.js @@ -16,9 +16,7 @@ * with this program. If not, see . */ -var Errors = require("@instructure/i18nliner/dist/lib/errors").default; -Errors.register("UnscopedTranslateCall"); - +var Errors = require("./errors"); var TranslateCall = require("@instructure/i18nliner/dist/lib/extractors/translate_call").default; var ScopedTranslateCall = require("./scoped_translate_call")(TranslateCall); diff --git a/gems/canvas_i18nliner/package.json b/gems/canvas_i18nliner/package.json index a75009d478f..72b1b8301d0 100644 --- a/gems/canvas_i18nliner/package.json +++ b/gems/canvas_i18nliner/package.json @@ -15,11 +15,10 @@ }, "devDependencies": { "coffee-script": "^1.12.4", - "jasmine": "^2.5.3", - "jasmine-pretty-html-reporter": "^0.2.5" + "jest": "^26" }, "peerDependencies": {}, "scripts": { - "test": "./node_modules/.bin/jasmine" + "test": "jest" } } diff --git a/gems/canvas_i18nliner/spec/fixtures/js/app/esm/esm.js b/gems/canvas_i18nliner/spec/fixtures/js/app/esm/esm.js new file mode 100644 index 00000000000..cb74933d4b8 --- /dev/null +++ b/gems/canvas_i18nliner/spec/fixtures/js/app/esm/esm.js @@ -0,0 +1,21 @@ +/* + * Copyright (C) 2022 - present Instructure, Inc. + * + * This file is part of Canvas. + * + * Canvas is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3 of the License. + * + * Canvas is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License along + * with this program. If not, see . + */ + +import I18n from 'i18n!esm' + +I18n.t('my_key', 'Hello world') diff --git a/gems/canvas_i18nliner/spec/i18nliner_spec.js b/gems/canvas_i18nliner/spec/i18nliner.test.js similarity index 77% rename from gems/canvas_i18nliner/spec/i18nliner_spec.js rename to gems/canvas_i18nliner/spec/i18nliner.test.js index 54fefd7caeb..30102eb36b9 100644 --- a/gems/canvas_i18nliner/spec/i18nliner_spec.js +++ b/gems/canvas_i18nliner/spec/i18nliner.test.js @@ -1,5 +1,5 @@ /* - * Copyright (C) 2015 - present Instructure, Inc. + * Copyright (C) 2022 - present Instructure, Inc. * * This file is part of Canvas. * @@ -20,8 +20,23 @@ const mkdirp = require("mkdirp"); const { I18nliner } = require("../js/main"); const scanner = require("../js/scanner"); +class PanickyCheck extends I18nliner.Commands.Check { + // don't print to TTY + print() {}; + + // and do throw errors + checkWrapper(file, checker) { + try { + checker(file) + } + catch (e) { + throw new Error(e.message) + } + }; +}; + var subject = function(dir) { - var command = new I18nliner.Commands.Check({}); + var command = new PanickyCheck({}); scanner.scanFilesFromI18nrc(scanner.loadConfigFromDirectory(dir)) command.run(); return command.translations.masterHash.translations; @@ -52,15 +67,15 @@ describe("I18nliner", function() { }); it('throws if no scope was specified', () => { - const command = new I18nliner.Commands.Check({}); - const origDir = process.cwd(); + const command = new PanickyCheck({}); - scanner.scanFilesFromI18nrc(scanner.loadConfigFromDirectory('spec/fixtures/hbs-missing-i18n-scope')) - command.checkFiles(); + scanner.scanFilesFromI18nrc( + scanner.loadConfigFromDirectory('spec/fixtures/hbs-missing-i18n-scope') + ) - expect(command.isSuccess()).toBeFalsy() - expect(command.errors.length).toEqual(1) - expect(command.errors[0]).toMatch(/expected i18nScope for Handlebars template to be specified/) + expect(() => { + command.checkFiles() + }).toThrowError(/expected i18nScope for Handlebars template to be specified/) }) }); @@ -69,6 +84,9 @@ describe("I18nliner", function() { expect(subject("spec/fixtures/js")).toEqual({ absolute_key: "Absolute key", inferred_key_c49e3743: "Inferred key", + esm: { + my_key: 'Hello world' + }, foo: { relative_key: "Relative key" }, diff --git a/gems/canvas_i18nliner/spec/scoped_esm_extractor.test.js b/gems/canvas_i18nliner/spec/scoped_esm_extractor.test.js new file mode 100644 index 00000000000..4c320632246 --- /dev/null +++ b/gems/canvas_i18nliner/spec/scoped_esm_extractor.test.js @@ -0,0 +1,150 @@ +/* + * Copyright (C) 2022 - present Instructure, Inc. + * + * This file is part of Canvas. + * + * Canvas is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3 of the License. + * + * Canvas is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License along + * with this program. If not, see . + */ + +const JsProcessor = require('i18nliner/dist/lib/processors/js_processor')['default']; +const ScopedESMExtractor = require('../js/scoped_esm_extractor'); +const dedent = require('dedent') + +describe('ScopedESMExtractor', () => { + it('tracks scope through the call to @canvas/i18n#useScope', () => { + expect(extract(dedent` + import { useScope } from '@canvas/i18n' + const I18n = useScope('foo') + I18n.t('keyed', 'something') + `).translations.translations).toEqual({ + foo: { + keyed: 'something' + } + }); + }) + + it('tracks scope through the call to a renamed @canvas/i18n#useScope specifier', () => { + expect(extract(dedent` + import { useScope as useI18nScope } from '@canvas/i18n' + const I18n = useI18nScope('foo') + I18n.t('keyed', 'something') + `).translations.translations).toEqual({ + foo: { + keyed: 'something' + } + }); + }) + + it('tracks scope through assignment', () => { + expect(extract(dedent` + import { useScope } from '@canvas/i18n' + + let I18n + + I18n = useScope('foo') + I18n.t('keyed', 'something') + `).translations.translations).toEqual({ + foo: { + keyed: 'something' + } + }); + }) + + it('resolves (i18n) scopes across different lexical scopes', () => { + const { translations: translationHash } = extract(dedent` + import { useScope } from '@canvas/i18n' + + function a() { + const I18n = useScope('foo') + + I18n.t('key', 'hello') + I18n.t('inferred') + } + + function b() { + let I18n + + I18n = useScope('bar') + I18n.t('key', 'world') + I18n.t('inferred') + } + `) + + expect(translationHash.translations).toEqual({ + foo: { key: 'hello' }, + bar: { key: 'world' }, + inferred_7cf5962e: 'inferred' + }) + }) + + it('extracts translations', () => { + expect(extract(dedent` + import { useScope } from '@canvas/i18n' + + const I18n = useScope('foo') + + I18n.t('#absolute', 'Unscoped') + I18n.t('inferred') + I18n.t('keyed', 'Keyed') + I18n.t('nested.keyed', 'Nested') + `).translations.translations).toEqual({ + absolute: 'Unscoped', + foo: { + keyed: 'Keyed', + nested: { + keyed: 'Nested' + } + }, + inferred_7cf5962e: 'inferred' + }); + }) + + it('throws if no scope was defined by the time t() was called', () => { + expect(() => { + extract(dedent` + import I18n from '@canvas/i18n' + I18n.t('hello') + `) + }).toThrow(/unscoped translate call/); + }) + + it('throws if no scope was defined using the "useScope" specifier', () => { + expect(() => { + extract(dedent` + import { useScope } from '@canvas/i18n' + const I18n = somethingElse('foo') + I18n.t('hello') + `) + }).toThrow(/unscoped translate call/); + }) + + it('throws if no scope was defined using the "useScope" interface from @canvas/i18n', () => { + expect(() => { + extract(dedent` + function useScope() {} + const I18n = useScope('foo') + I18n.t('hello') + `) + }).toThrow(/unscoped translate call/); + }) +}); + +function extract(source) { + const extractor = new ScopedESMExtractor({ + ast: JsProcessor.prototype.parse(source) + }) + + extractor.run() + + return extractor; +} diff --git a/gems/canvas_i18nliner/spec/scoped_hbs_extractor_spec.js b/gems/canvas_i18nliner/spec/scoped_hbs_extractor.test.js similarity index 100% rename from gems/canvas_i18nliner/spec/scoped_hbs_extractor_spec.js rename to gems/canvas_i18nliner/spec/scoped_hbs_extractor.test.js diff --git a/gems/canvas_i18nliner/spec/support/jasmine.json b/gems/canvas_i18nliner/spec/support/jasmine.json deleted file mode 100644 index 3ea316690ab..00000000000 --- a/gems/canvas_i18nliner/spec/support/jasmine.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "spec_dir": "spec", - "spec_files": [ - "**/*[sS]pec.js" - ], - "helpers": [ - "helpers/**/*.js" - ], - "stopSpecOnExpectationFailure": false, - "random": false -} diff --git a/yarn.lock b/yarn.lock index 21a028cbcc3..76f26ea07b5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13069,7 +13069,7 @@ glob@^5.0.15: once "^1.3.0" path-is-absolute "^1.0.0" -glob@^7, glob@^7.0.0, glob@^7.0.3, glob@^7.0.5, glob@^7.0.6, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6: +glob@^7, glob@^7.0.0, glob@^7.0.3, glob@^7.0.5, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6: version "7.1.7" resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.7.tgz#3b193e9233f01d42d0b3f78294bbeeb418f94a90" integrity sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ== @@ -15393,25 +15393,6 @@ iterate-value@^1.0.2: es-get-iterator "^1.0.2" iterate-iterator "^1.0.1" -jasmine-core@~2.99.0: - version "2.99.1" - resolved "https://registry.yarnpkg.com/jasmine-core/-/jasmine-core-2.99.1.tgz#e6400df1e6b56e130b61c4bcd093daa7f6e8ca15" - integrity sha1-5kAN8ea1bhMLYcS80JPap/boyhU= - -jasmine-pretty-html-reporter@^0.2.5: - version "0.2.5" - resolved "https://registry.yarnpkg.com/jasmine-pretty-html-reporter/-/jasmine-pretty-html-reporter-0.2.5.tgz#c61b7528bf06df386d5ef4360d7d029149692bc1" - integrity sha1-xht1KL8G3zhtXvQ2DX0CkUlpK8E= - -jasmine@^2.5.3: - version "2.99.0" - resolved "https://registry.yarnpkg.com/jasmine/-/jasmine-2.99.0.tgz#8ca72d102e639b867c6489856e0e18a9c7aa42b7" - integrity sha1-jKctEC5jm4Z8ZImFbg4YqceqQrc= - dependencies: - exit "^0.1.2" - glob "^7.0.6" - jasmine-core "~2.99.0" - jest-canvas-mock@^2: version "2.3.1" resolved "https://registry.yarnpkg.com/jest-canvas-mock/-/jest-canvas-mock-2.3.1.tgz#9535d14bc18ccf1493be36ac37dd349928387826"