Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/plan-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ jobs:
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 18
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: 'Generate Explanation and Prep Changelogs'
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ jobs:
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 18
node-version: 24
# This creates an .npmrc that reads the NODE_AUTH_TOKEN environment variable
registry-url: 'https://registry.npmjs.org'
cache: pnpm
Expand Down
3 changes: 1 addition & 2 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
'use strict';

const path = require('path');
const { addPlugin, hasPlugin } = require('ember-cli-babel-plugin-helpers');
const defaults = require('./lib/babel/defaults');

Expand Down Expand Up @@ -55,7 +54,7 @@ module.exports = {
ext: 'hbs',
plugin: require('./template-plugin')(options),
baseDir: () => {
return path.resolve('./');
return __dirname;
},
};
},
Expand Down
8 changes: 7 additions & 1 deletion lib/babel/format-message-import-to-t.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
'use strict';

const isAppCode = require('./is-app-code');

/**
* in gjs / gts files, we need to change the formatMessage import to be the t import.
*/
Expand All @@ -9,7 +11,11 @@ module.exports = function (/* babel, pluginOptions */) {
return {
name: 'ember-intl/formatjs/format-message-import-to-t', // tea,
visitor: {
ImportDeclaration(path /*, state */) {
ImportDeclaration(path, state) {
if (!isAppCode(state.filename)) {
return;
}

if (path.node.source.value === 'ember-intl/helpers/format-message') {
path.node.source.value = 'ember-intl/helpers/t';

Expand Down
32 changes: 26 additions & 6 deletions lib/babel/format-message-replace-strings.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const { declare } = require('@babel/helper-plugin-utils');
const { interpolateName } = require('@formatjs/ts-transformer');
const defaults = require('./defaults');
const isAppCode = require('./is-app-code');

const getVariableValue = (args, variable, preserveWhitespace) => {
const property = args?.properties?.find?.(p => p.key?.name === variable);
Expand All @@ -23,6 +24,15 @@ const getVariableValue = (args, variable, preserveWhitespace) => {
return value;
};

/**
* Only the FormatJS authoring shape is ours to rewrite: an object literal carrying
* defaultMessage or id. ember-intl calls its own formatMessage(descriptor, values) with a
* variable, and rewriting that turns the raw id pattern into a translation key.
*/
const isFormatJsDescriptor = node =>
node?.type === 'ObjectExpression' &&
node.properties.some(p => p.key?.name === 'defaultMessage' || p.key?.name === 'id');

/**
* This plugin is specifically for updating the version of formatMessage from the intl service.
*
Expand All @@ -38,16 +48,25 @@ module.exports = declare((api, pluginOptions) => {
name: 'ember-intl/formatjs/format-message-replace',

visitor: {
CallExpression(path) {
CallExpression(path, state) {
if (!isAppCode(state.filename)) {
return;
}

if (
(path.node?.callee?.object?.name === 'intl' || // intl.formatMessage()
path.node?.callee?.object?.property?.name === 'intl') && // <obj>.intl.formatMessage()
path.node?.callee?.property?.name === 'formatMessage'
) {
path.node.callee.property.name = 't';

const args = path.node?.arguments;
const firstHash = args?.[0];

if (!isFormatJsDescriptor(firstHash)) {
return;
}

path.node.callee.property.name = 't';

const valuesHash = args?.[1];
const defaultMessage = getVariableValue(firstHash, 'defaultMessage', preserveWhitespace);
const description = getVariableValue(firstHash, 'description', true);
Expand All @@ -63,13 +82,14 @@ module.exports = declare((api, pluginOptions) => {
},
);

path.node.arguments[0] = {
const key = {
type: 'StringLiteral',
value: translationId || id,
};

path.node.arguments[1] = valuesHash;
path.node.arguments[2] = null;
// Assigning into the arguments by index leaves holes, which print as
// `intl.t('key', )`.
path.node.arguments = valuesHash ? [key, valuesHash] : [key];
}
},
},
Expand Down
20 changes: 20 additions & 0 deletions lib/babel/is-app-code.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
'use strict';

/**
* The transforms rewrite authoring sugar that only the app uses. Under Embroider + Vite,
* library code such as ember-intl's own goes through the same babel pass as the app's,
* so anything under node_modules is left alone, except the app itself when old Embroider
* stages it there.
*
* A missing filename counts as app code, so `babel.transform` calls without one keep
* working.
*
* @param {string | undefined} filename
*/
module.exports = function isAppCode(filename) {
if (!filename || !filename.includes('/node_modules/')) {
return true;
}

return filename.includes('.embroider/rewritten-app');
};
103 changes: 103 additions & 0 deletions node-tests/addon/test.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
const assert = require('node:assert/strict');
const { tmpdir } = require('node:os');
const { join } = require('node:path');

const addon = require('../../index');
const defaults = require('../../lib/babel/defaults');

// The parts under test never reach the rest of ember-cli, so a stub host is enough.
function build(app = { options: {} }) {
return Object.assign(Object.create(addon), {
ui: { writeLine() {} },
_findHost: () => app,
});
}

function registered(subject, type = 'parent') {
const added = [];

subject.setupPreprocessorRegistry(type, {
add(name, entry) {
added.push({ name, entry });
},
});

return added;
}

describe('addon: classic wiring', function () {
it('registers the template transform as an htmlbars ast plugin', function () {
const added = registered(build());

assert.equal(added.length, 1);
assert.equal(added[0].name, 'htmlbars-ast-plugin');
assert.equal(added[0].entry.ext, 'hbs');
assert.equal(typeof added[0].entry.plugin, 'function');
});

it('registers nothing for the addon itself', function () {
assert.deepEqual(registered(build(), 'self'), []);
});

it('registers a transform that rewrites app templates only', function () {
const [{ entry }] = registered(build());
const nameFor = moduleName => entry.plugin({ meta: {}, moduleName }).name;

assert.equal(
nameFor(join(process.cwd(), 'app', 'templates', 'application.hbs')),
'ember-intl/ember-formatjs/template-plugin',
);
assert.equal(
nameFor(join(process.cwd(), 'node_modules', 'some-addon', 'templates', 'application.hbs')),
'ember-intl/ember-formatjs/noop-template-plugin',
);
});

it('describes itself for parallel builds', function () {
const [{ entry }] = registered(build());
const { requireFile, buildUsing, params } = entry.parallelBabel;

// ember-cli rebuilds the plugin in a worker from exactly these three fields.
const rebuilt = require(requireFile)[buildUsing](params);

assert.equal(requireFile, require.resolve('../../index'));
assert.equal(rebuilt.name, entry.name);
assert.equal(rebuilt.ext, entry.ext);
});

it('gives ember-cli-htmlbars this package to hash for its cache key', function () {
const [{ entry }] = registered(build());
const cwd = process.cwd();

// The build runs from the app, not from this package.
process.chdir(tmpdir());
try {
assert.equal(entry.baseDir(), join(__dirname, '..', '..'));
} finally {
process.chdir(cwd);
}
});

it('falls back to the defaults', function () {
assert.deepEqual(build().addonOptions(), defaults);
});

it('lets the app override the options', function () {
const app = { options: { 'ember-formatjs': { idInterpolationPattern: '[sha512:contenthash:base64:10]' } } };
const options = build(app).addonOptions();

assert.equal(options.idInterpolationPattern, '[sha512:contenthash:base64:10]');
assert.equal(options.preserveWhitespace, defaults.preserveWhitespace);
});

it('adds the babel plugin to the app once', function () {
const app = { options: {} };
const subject = build(app);

subject._setupBabel();
subject._setupBabel();

assert.equal(app.options.babel.plugins.length, 1);
assert.equal(app.options.babel.plugins[0][0], require.resolve('../../babel-plugin'));
});
});
76 changes: 76 additions & 0 deletions node-tests/babel-plugin/rewrites/test.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
const assert = require('node:assert/strict');
const { join } = require('node:path');
const babel = require('@babel/core');

const plugin = require('../../../babel-plugin');

const APP_FILE = join(process.cwd(), 'app', 'controllers', 'greeting.js');
const ADDON_FILE = join(process.cwd(), 'node_modules', 'ember-intl', 'services', 'intl.js');

// babel-plugin-tester runs the output through prettier, which tidies away a malformed
// argument list, so these look at babel's own output.
function transform(code, filename = APP_FILE) {
return babel.transformSync(code, {
filename,
configFile: false,
babelrc: false,
plugins: [plugin],
}).code;
}

describe('babel-plugin: rewritten calls', function () {
it('passes only the key when there are no values', function () {
assert.equal(transform(`intl.formatMessage({ defaultMessage: 'Hello world' });`), `intl.t("t/eDuu");`);
});

it('passes the key and the values', function () {
assert.equal(
transform(`this.intl.formatMessage({ defaultMessage: 'Hello {name}' }, { name });`),
`this.intl.t("QM7ITA", {\n name\n});`,
);
});

it('uses an explicit id over the generated hash', function () {
assert.equal(
transform(`intl.formatMessage({ id: 'my.own.key', defaultMessage: 'Hello world' });`),
`intl.t("my.own.key");`,
);
});
});

describe('babel-plugin: what is not ours to rewrite', function () {
it("leaves ember-intl's own formatMessage(descriptor, values) alone", function () {
assert.equal(
transform(`this.intl.formatMessage(descriptor, values);`),
`this.intl.formatMessage(descriptor, values);`,
);
});

it('leaves an object that is not a message descriptor alone', function () {
assert.match(transform(`this.intl.formatMessage({ locale: 'en-us' });`), /this\.intl\.formatMessage\(/);
});

it('leaves library code alone', function () {
const source = `this.intl.formatMessage({ defaultMessage: 'Hello world' });`;

assert.match(transform(source, ADDON_FILE), /this\.intl\.formatMessage\(/);
});

it('leaves a format-message import in library code alone', function () {
const source = `import FormatMessage from 'ember-intl/helpers/format-message';\nFormatMessage;`;

assert.match(transform(source, ADDON_FILE), /'ember-intl\/helpers\/format-message'/);
});

it('still rewrites the format-message import in app code', function () {
const source = `import formatMessage from 'ember-intl/helpers/format-message';\nformatMessage;`;

assert.match(transform(source), /"ember-intl\/helpers\/t"/);
});

it('still rewrites an old-Embroider app staged under node_modules', function () {
const staged = join(process.cwd(), 'node_modules', '.embroider', 'rewritten-app', 'controllers', 'greeting.js');

assert.equal(transform(`intl.formatMessage({ defaultMessage: 'Hello world' });`, staged), `intl.t("t/eDuu");`);
});
});
Loading
Loading