Skip to content

feat: add plural support #284

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/.yarn/
/.pnp*
/.vscode/
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please, keep this in your local ignore config.


# Created by https://www.gitignore.io/api/node
# Edit at https://www.gitignore.io/?templates=node
Expand Down
2 changes: 1 addition & 1 deletion rollup.config.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import pkg from "./package.json" with { type: "json" };
import pkg from "./package.json" assert { type: "json" };

import { babel } from "@rollup/plugin-babel";
import { nodeResolve } from "@rollup/plugin-node-resolve";
Expand Down
2 changes: 2 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface Config {
keyAsDefaultValue: boolean | string[];
keyAsDefaultValueForDerivedKeys: boolean;
discardOldKeys: boolean;
ignoreExistingValues: boolean;
jsonSpace: string | number;
customTransComponents: readonly [string, string][];
customUseTranslationHooks: readonly [string, string][];
Expand Down Expand Up @@ -102,6 +103,7 @@ export function parseConfig(opts: Partial<Config>): Config {
true,
),
discardOldKeys: coalesce(opts.discardOldKeys, false),
ignoreExistingValues: coalesce(opts.ignoreExistingValues, false),
jsonSpace: coalesce(opts.jsonSpace, 2),
customTransComponents,
customUseTranslationHooks,
Expand Down
20 changes: 18 additions & 2 deletions src/exporters/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ function getDefaultValue(
keyAsDefaultValueEnabled &&
(keyAsDefaultValueForDerivedKeys || !key.isDerivedKey)
) {
defaultValue = key.cleanKey;
defaultValue = key.key;
}

const useI18nextDefaultValueEnabled =
Expand All @@ -100,6 +100,22 @@ function getDefaultValue(
defaultValue = key.parsedOptions.defaultValue;
}

// Use defaultValue_somekey
if (
key.parsedOptions?.defaultValues?.length > 0 &&
useI18nextDefaultValueForDerivedKeys &&
key.isDerivedKey
) {
const derivedIdentifier = `${config.pluralSeparator}${key.cleanKey.split(config.pluralSeparator).pop()}`;
const foundValue = key.parsedOptions.defaultValues.find(
([defaultValueKey]) => defaultValueKey === derivedIdentifier,
);

if (foundValue != null) {
defaultValue = foundValue[1];
}
}

return defaultValue;
}

Expand Down Expand Up @@ -162,7 +178,7 @@ export default function exportTranslationKeys(
file: translationFile,
key: k,
value:
previousValue === undefined
config.ignoreExistingValues || previousValue === undefined
? getDefaultValue(k, locale, config)
: previousValue,
});
Expand Down
154 changes: 154 additions & 0 deletions src/extractors/commons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,3 +336,157 @@ export function getAliasedTBindingName(

return tFunctionNames.find((name) => path.scope.bindings[name]);
}

/**
* Try to find "t" in an object spread. Useful when looking for the "t" key
* in a spread object. e.g. const {t} = props;
*
* @param path object pattern
* @returns t identifier or null of it was not found in the object pattern.
*/
function findTFunctionIdentifierInObjectPattern(
path: BabelCore.NodePath<BabelTypes.ObjectPattern>,
): BabelCore.NodePath<BabelTypes.Identifier> | null {
const props = path.get("properties");

for (const prop of props) {
if (prop.isObjectProperty()) {
const key = prop.get("key");
if (!Array.isArray(key) && key.isIdentifier() && key.node.name === "t") {
return key;
}
}
}

return null;
}

/**
* Check whether a node path is the callee of a call expression.
*
* @param path the node to check.
* @returns true if the path is the callee of a call expression.
*/
function isCallee(path: BabelCore.NodePath): path is BabelCore.NodePath & {
parentPath: BabelCore.NodePath<BabelTypes.CallExpression>;
} {
return !!(
path.parentPath?.isCallExpression() &&
path === path.parentPath.get("callee")
);
}

/**
* Find T function calls from a props assignment. Prop assignment can occur
* in function parameters (i.e. "function Component(props)" or
* "function Component({t})") or in a variable declarator (i.e.
* "const props = …" or "const {t} = props").
*
* @param propsId identifier for the prop assignment. e.g. "props" or "{t}"
* @returns Call expressions to t function.
*/
export function findTFunctionCallsFromPropsAssignment(
propsId: BabelCore.NodePath,
): BabelCore.NodePath<BabelTypes.CallExpression>[] {
const tReferences = Array<BabelCore.NodePath>();

const body = propsId.parentPath?.get("body");
if (body === undefined || Array.isArray(body)) return [];
const scope = body.scope;

if (propsId.isObjectPattern()) {
// got "function MyComponent({t, other, props})"
// or "const {t, other, props} = this.props"
// we want to find references to "t"
const tFunctionIdentifier = findTFunctionIdentifierInObjectPattern(propsId);
if (tFunctionIdentifier === null) return [];
const tBinding = scope.bindings[tFunctionIdentifier.node.name];
tReferences.push(...tBinding.referencePaths);
} else if (propsId.isIdentifier()) {
// got "function MyComponent(props)"
// or "const props = this.props"
// we want to find references to props.t
const references = scope.bindings[propsId.node.name].referencePaths;
for (const reference of references) {
if (reference.parentPath?.isMemberExpression()) {
const prop = reference.parentPath.get("property");
if (
!Array.isArray(prop) &&
prop.isIdentifier() &&
prop.node.name === "t"
) {
tReferences.push(reference.parentPath);
}
}
}
}

// We have candidates. Let's see if t references are actual calls to the t
// function
const tCalls = Array<BabelCore.NodePath<BabelTypes.CallExpression>>();
for (const tCall of tReferences) {
if (isCallee(tCall)) {
tCalls.push(tCall.parentPath);
}
}

return tCalls;
}

/**
* Find all t function calls in a class component.
* @param path node path to the class component.
*/
export function findTFunctionCallsInClassComponent(
path: BabelCore.NodePath<BabelTypes.ClassDeclaration>,
propertyName: string,
): BabelCore.NodePath<BabelTypes.CallExpression>[] {
const result = Array<BabelCore.NodePath<BabelTypes.CallExpression>>();

const thisVisitor: BabelCore.Visitor = {
ThisExpression(path) {
if (!path.parentPath.isMemberExpression()) return;

const propProperty = path.parentPath.get("property");
if (Array.isArray(propProperty) || !propProperty.isIdentifier()) return;
if (propProperty.node.name !== propertyName) return;

// Ok, this is interesting, we have something with "this.props"

if (path.parentPath.parentPath.isMemberExpression()) {
// We have something in the form "this.props.xxxx".

const tIdentifier = path.parentPath.parentPath.get("property");
if (Array.isArray(tIdentifier) || !tIdentifier.isIdentifier()) return;
if (tIdentifier.node.name !== "t") return;

// We have something in the form "this.props.t". Let's see if it's an
// actual function call or an assignment.
const tExpression = path.parentPath.parentPath;
if (isCallee(tExpression)) {
// Simple case. Direct call to "this.props.t()"
result.push(tExpression.parentPath);
} else if (tExpression.parentPath.isVariableDeclarator()) {
// Hard case. const t = this.props.t;
// Let's loop through all references to t.
const id = tExpression.parentPath.get("id");
if (!id.isIdentifier()) return;
for (const reference of id.scope.bindings[id.node.name]
.referencePaths) {
if (isCallee(reference)) {
result.push(reference.parentPath);
}
}
}
} else if (path.parentPath.parentPath.isVariableDeclarator()) {
// We have something in the form "const props = this.props"
// Or "const {t} = this.props"
const id = path.parentPath.parentPath.get("id");
result.push(...findTFunctionCallsFromPropsAssignment(id));
}
},
};
path.traverse(thisVisitor);

return result;
}
67 changes: 67 additions & 0 deletions src/extractors/getClassMember.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import * as BabelCore from "@babel/core";
import * as BabelTypes from "@babel/types";

import { CommentHint, getCommentHintForPath } from "../comments";
import { Config } from "../config";
import { ExtractedKey } from "../keys";

import { findTFunctionCallsInClassComponent } from "./commons";
import extractTFunction from "./tFunction";

/**
* Parse function or class declaration (likely components) to find whether
* they are wrapped with "withTranslation()" HOC, and if so, extract all the
* translations that come from the "t" function injected in the component
* properties.
*
* @param path node path to the component
* @param config plugin configuration
* @param commentHints parsed comment hints
*/
export default function extractGetClassMember(
path: BabelCore.NodePath<BabelTypes.Function | BabelTypes.ClassDeclaration>,
config: Config,
commentHints: CommentHint[] = [],
): ExtractedKey[] {
if (!path.isClassDeclaration()) {
return [];
}
const tCalls = config.i18nextInstanceNames.reduce(
(accumulator, instanceName) => [
...accumulator,
...findTFunctionCallsInClassComponent(path, instanceName),
],
[] as BabelCore.NodePath<BabelTypes.CallExpression>[],
);

// Extract namespace
let ns: string | null;
const nsCommentHint = getCommentHintForPath(path, "NAMESPACE", commentHints);
if (nsCommentHint) {
// We got a comment hint, take its value as namespace.
ns = nsCommentHint.value;
} else {
// TODO - extract from constructor parameter with reflection
}

let keys = Array<ExtractedKey>();
for (const tCall of tCalls) {
keys = [
...keys,
...extractTFunction(tCall, config, commentHints, true).map((k) => ({
// Add namespace if it was not explicitely set in t() call.
...k,
parsedOptions: {
...k.parsedOptions,
ns: k.parsedOptions.ns || ns,
},
})),
];
}

return keys.map((k) => ({
...k,
sourceNodes: [path.node, ...k.sourceNodes],
extractorName: extractGetClassMember.name,
}));
}
3 changes: 3 additions & 0 deletions src/extractors/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ExtractionError } from "./commons";
import extractCustomTransComponent from "./customTransComponent";
import extractCustomUseTranslationHook from "./customUseTranslationHook";
import extractGetClassMember from "./getClassMember";
import extractGetFixedTFunction from "./getFixedTFunction";
import extractI18nextInstance from "./i18nextInstance";
import extractTFunction from "./tFunction";
Expand All @@ -22,6 +23,7 @@ export const EXTRACTORS_PRIORITIES = [
extractGetFixedTFunction.name,
extractTranslationRenderProp.name,
extractWithTranslationHOC.name,
extractGetClassMember.name,
extractI18nextInstance.name,
extractTFunction.name,
];
Expand All @@ -34,6 +36,7 @@ export default {
extractGetFixedTFunction,
extractTranslationRenderProp,
extractWithTranslationHOC,
extractGetClassMember,
extractI18nextInstance,
extractTFunction,
};
37 changes: 36 additions & 1 deletion src/extractors/tFunction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
evaluateIfConfident,
findKeyInObjectExpression,
parseI18NextOptionsFromCommentHints,
iterateObjectExpression,
} from "./commons";

/**
Expand Down Expand Up @@ -49,9 +50,11 @@ function parseTCallOptions(
const res: ExtractedKey["parsedOptions"] = {
contexts: false,
hasCount: false,
ordinal: false,
ns: null,
keyPrefix: null,
defaultValue: null,
defaultValues: [],
};

if (!path) return res;
Expand Down Expand Up @@ -83,6 +86,28 @@ function parseTCallOptions(
const keyPrefixNodeValue = keyPrefixNode.get("value");
res.keyPrefix = evaluateIfConfident(keyPrefixNodeValue);
}

const ordinalNode = findKeyInObjectExpression(path, "ordinal");
if (ordinalNode != null && ordinalNode.isObjectProperty()) {
res.ordinal = evaluateIfConfident(ordinalNode.get("value"));
}

// Support defaultValue_someKey
res.defaultValues = Array.from(iterateObjectExpression(path)).reduce(
(accumulator, [key, node]) => {
if (!key.startsWith("defaultValue_") || !node.isObjectProperty()) {
return accumulator;
}
return [
...accumulator,
[
key.replace("defaultValue", "").replace("_ordinal", ""),
evaluateIfConfident(node.get("value")),
],
];
},
[] as [string, string][],
);
}

return res;
Expand Down Expand Up @@ -112,10 +137,20 @@ function extractTCall(
);
}

const tSecondParamValue = evaluateIfConfident(args[1]);

let parsedTCallOptions;
if (typeof tSecondParamValue === "string") {
parsedTCallOptions = parseTCallOptions(args[2]);
parsedTCallOptions.defaultValue = tSecondParamValue;
} else {
parsedTCallOptions = parseTCallOptions(args[1]);
}

return {
key: keyEvaluation,
parsedOptions: {
...parseTCallOptions(args[1]),
...parsedTCallOptions,
...parseI18NextOptionsFromCommentHints(path, commentHints),
},
sourceNodes: [path.node],
Expand Down
Loading