Skip to content

Commit

Permalink
Support translator comments (#28)
Browse files Browse the repository at this point in the history
  • Loading branch information
tomasr8 authored Nov 19, 2024
1 parent 7993ad2 commit ba17597
Show file tree
Hide file tree
Showing 8 changed files with 433 additions and 146 deletions.
254 changes: 132 additions & 122 deletions package-lock.json

Large diffs are not rendered by default.

7 changes: 3 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "react-jsx-i18n",
"version": "0.8.0",
"version": "0.9.0",
"description": "Provides gettext-enhanced React components, a babel plugin for extracting the strings and a script to compile translated strings to a format usable by the components.",
"main": "client/index.js",
"types": "client/index.d.ts",
Expand Down Expand Up @@ -31,7 +31,6 @@
},
"homepage": "https://github.com/indico/react-jsx-i18n#readme",
"dependencies": {
"babel-plugin-extract-text": "^2.0.0",
"chalk": "^2.4.2",
"gettext-parser": "^4.0.3",
"glob": "^7.1.6",
Expand All @@ -42,8 +41,8 @@
"peerDependencies": {
"@babel/core": "*",
"@babel/preset-react": "*",
"react": "*",
"prop-types": "*"
"prop-types": "*",
"react": "*"
},
"devDependencies": {
"@babel/cli": "^7.8.4",
Expand Down
65 changes: 56 additions & 9 deletions src/tools/extract-plugin.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {relative} from 'path';
import cleanJSXElementLiteralChild from '@babel/types/lib/utils/react/cleanJSXElementLiteralChild';

const TRANSLATOR_COMMENT_TAG = 'i18n:';

const collapseWhitespace = (string, trim = true) => {
// for translated strings we never want consecutive or surrounding whitespace
if (!string) {
Expand Down Expand Up @@ -114,16 +116,17 @@ const getContext = path => {
return contextAttr ? contextAttr.value.value : undefined;
};

const processTranslate = (cfg, path, state, types) => {
const processTranslate = (cfg, path, state, comment, types) => {
const translatableString = processElement(path, types, true);
return {
msgid: translatableString,
msgctxt: getContext(path),
extracted: comment,
reference: getLocation(cfg, path, state),
};
};

const processTranslateString = (cfg, path, state, funcName, types) => {
const processTranslateString = (cfg, path, state, comment, types) => {
const args = path.node.arguments;
if (args.length === 0) {
throw path.buildCodeFrameError('Translate.string() called with no arguments');
Expand All @@ -136,10 +139,11 @@ const processTranslateString = (cfg, path, state, funcName, types) => {
msgid,
msgctxt,
reference: getLocation(cfg, path, state),
extracted: comment,
};
};

const processPluralTranslate = (cfg, path, state, types) => {
const processPluralTranslate = (cfg, path, state, comment, types) => {
let singularPath, pluralPath;
path
.get('children')
Expand Down Expand Up @@ -171,11 +175,12 @@ const processPluralTranslate = (cfg, path, state, types) => {
msgid: processElement(singularPath, types, true),
msgid_plural: processElement(pluralPath, types, true),
msgctxt: getContext(path),
extracted: comment,
reference: getLocation(cfg, path, state),
};
};

const processPluralTranslateString = (cfg, path, state, funcName, types) => {
const processPluralTranslateString = (cfg, path, state, comment, types) => {
const args = path.node.arguments;
if (args.length < 2) {
throw path.buildCodeFrameError('PluralTranslate.string() called with less than 2 arguments');
Expand All @@ -191,20 +196,61 @@ const processPluralTranslateString = (cfg, path, state, funcName, types) => {
msgid_plural,
msgctxt,
reference: getLocation(cfg, path, state),
extracted: comment,
};
};

function getPrecedingComment(line, comments) {
if (!comments[line - 1]) {
return;
}
const comment = [];
for (let i = line - 1; i >= 0; i--) {
if (comments[i]?.length > 0) {
comment.push(comments[i].join('\n'));
} else {
break;
}
}
return comment.reverse().join('\n');
}

const makeI18nPlugin = cfg => {
const entries = [];
let currFile = '';
let translatorComments = {};
const i18nPlugin = ({types}) => {
return {
visitor: {
Program(path, state) {
if (currFile !== state.file.opts.filename) {
// clear translator comments when the file changes
currFile = state.file.opts.filename;
translatorComments = {};
}
path.container.comments
.filter(comment => comment.value.trim().startsWith(TRANSLATOR_COMMENT_TAG))
.forEach(comment => {
const endLine = comment.loc.end.line;
if (!translatorComments[endLine]) {
translatorComments[endLine] = [];
}
translatorComments[endLine].push(
comment.value
.trim()
.slice(TRANSLATOR_COMMENT_TAG.length)
.trimStart()
);
});
},
JSXElement(path, state) {
const elementName = path.node.openingElement.name.name;
const line = path.node.loc.start.line;
const comment = getPrecedingComment(line, translatorComments);
if (elementName === 'Translate') {
entries.push(processTranslate(cfg, path, state, types));
entries.push(processTranslate(cfg, path, state, comment, types));
} else if (elementName === 'PluralTranslate') {
entries.push(processPluralTranslate(cfg, path, state, types));
entries.push(processPluralTranslate(cfg, path, state, comment, types));
}
},
CallExpression(path, state) {
Expand All @@ -226,11 +272,12 @@ const makeI18nPlugin = cfg => {
return;
}
// we got a proper call of one of our translation functions
const qualifiedFuncName = `${elementName}.${funcName}`;
const line = path.node.loc.start.line;
const comment = getPrecedingComment(line, translatorComments);
if (elementName === 'Translate') {
entries.push(processTranslateString(cfg, path, state, qualifiedFuncName, types));
entries.push(processTranslateString(cfg, path, state, comment, types));
} else if (elementName === 'PluralTranslate') {
entries.push(processPluralTranslateString(cfg, path, state, qualifiedFuncName, types));
entries.push(processPluralTranslateString(cfg, path, state, comment, types));
}
},
},
Expand Down
80 changes: 70 additions & 10 deletions src/tools/extract.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import * as babel from '@babel/core';
import gettextParser from 'gettext-parser';
import moment from 'moment-timezone';
import {mergeEntries} from 'babel-plugin-extract-text/src/builders';
import makeI18nPlugin from './extract-plugin';

const extractFromFiles = (files, cfg, headers = undefined, highlightErrors = true) => {
Expand Down Expand Up @@ -31,16 +30,77 @@ const extractFromFiles = (files, cfg, headers = undefined, highlightErrors = tru
return {errors};
}

const data = mergeEntries({}, entries);
data.headers = headers || {
'POT-Creation-Date': moment().format('YYYY-MM-YY HH:mmZZ'),
'Content-Type': 'text/plain; charset=utf-8',
'Content-Transfer-Encoding': '8bit',
'MIME-Version': '1.0',
'Generated-By': 'react-jsx-i18n-extract',
};

const data = mergeEntries(entries, headers);
return {pot: gettextParser.po.compile(data).toString()};
};

export default extractFromFiles;

function mergeEntries(entries, headers) {
const data = {
charset: 'UTF-8',
headers: headers || {
'POT-Creation-Date': moment().format('YYYY-MM-YY HH:mmZZ'),
'Content-Type': 'text/plain; charset=utf-8',
'Content-Transfer-Encoding': '8bit',
'MIME-Version': '1.0',
'Generated-By': 'react-jsx-i18n-extract',
},
translations: {},
};

entries.forEach(entry => {
const {msgid, msgid_plural: msgidPlural, msgctxt, extracted, reference} = entry;
const context = entry.msgctxt || '';

if (!data.translations[context]) {
data.translations[context] = {};
}

let existingEntry = data.translations[context][msgid];
if (!existingEntry) {
existingEntry = data.translations[context][msgid] = {};
}

const existingPlural = existingEntry.msgid_plural;
const existingTranslation = existingEntry.msgstr;
const existingExtracted = existingEntry.comments?.extracted;
const existingReference = existingEntry.comments?.reference;

data.translations[context][msgid] = {
msgid,
msgctxt,
msgstr: mergeTranslation(existingTranslation, msgidPlural ? ['', ''] : ['']),
msgid_plural: existingPlural || msgidPlural,
comments: {
reference: mergeReference(existingReference, reference),
extracted: mergeExtracted(existingExtracted, extracted),
},
};
});

return data;
}

function mergeReference(existingReference, newReference) {
if (existingReference) {
if (!newReference || existingReference.includes(newReference)) {
return existingReference;
}

return `${existingReference}\n${newReference}`;
}

return newReference;
}

function mergeExtracted(existingExtracted, extracted) {
return mergeReference(existingExtracted, extracted);
}

function mergeTranslation(existingTranslation, newTranslation) {
if (existingTranslation && existingTranslation.length === 2) {
return existingTranslation;
}
return newTranslation;
}
76 changes: 76 additions & 0 deletions test-data/comments1.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/* eslint-disable babel/quotes, react/prop-types, react/jsx-curly-brace-presence */

import React from 'react';
import {makeComponents, Singular, Plural, Param} from '../src/client';
const {Translate, PluralTranslate} = makeComponents();

// i18n: foo comment 1
Translate.string('foo');
// i18n: plural foo comment 1
PluralTranslate.string('foo', 'foos', 42);

// i18n: foo comment 2
Translate.string('foo');
// i18n: plural foo comment 2
PluralTranslate.string('foo', 'foos', 42);

/* i18n: multiline
comment
*/
Translate.string('multiline');

/* i18n: multiline
* comment with leading asterisks
*/
Translate.string('multiline');

// No 'i18n:' prefix, should not be extracted
Translate.string('foo');

// i18n: Space between the comment and the 'Translate' call, this should not be extracted

Translate.string('foo');

// i18n: Both strings
// i18n: should be extracted
Translate.string('bar');

/* i18n: multiple
multiline comments
*/
/* i18n: are not guaranteed to work
because you should not be using this anyway
*/
Translate.string('bar');

// i18n: Only the last comment should be extracted
// some other comment
// i18n: translator comment
Translate.string('baz');

export function TestComponent() {
return (
<div>
{/* i18n: Title */}
<Translate>Hello & World</Translate>
{/* i18n: multiple */} {/* i18n: translator */}
{/* i18n: comments */}
<Translate>foo bar</Translate>
{/* i18n: rat counter */}
<PluralTranslate count={42}>
<Singular>
You have <Param name="count" value={1} /> rat.
</Singular>
<Plural>
You have <Param name="count" value={2} /> rats.
</Plural>
</PluralTranslate>
{/* i18n: xxx comment */}
{Translate.string('xxx')}
{() => {
// i18n: yyy comment
return PluralTranslate.string('yyy', 'yyys', 42);
}}
</div>
);
}
10 changes: 10 additions & 0 deletions test-data/comments2.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/* eslint-disable babel/quotes, react/prop-types, react/jsx-curly-brace-presence */

import {makeComponents} from '../src/client';
const {Translate, PluralTranslate} = makeComponents();

// Keep this call on the same line as the corresponding call in comments1.jsx
// This tests that the comments do not get mixed up when babel loads another file
Translate.string('baz');
// Same for this call
PluralTranslate.string('baz', 'bazs', 42);
Loading

0 comments on commit ba17597

Please sign in to comment.