Skip to content

Commit

Permalink
feat: allow implicit key for <Trans> when omitting i18nKey prop
Browse files Browse the repository at this point in the history
closes #53
  • Loading branch information
jkjustjoshing authored Oct 15, 2022
1 parent 0b34796 commit c6f13e3
Show file tree
Hide file tree
Showing 4 changed files with 205 additions and 7 deletions.
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,10 +284,10 @@ import { Trans } from "astro-i18next/components";
#### Trans Props
| Prop name | Type (default) | Description |
| --------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| i18nKey | string (undefined) | Internationalization key to interpolate to. Can contain the namespace by prepending it in the form 'ns:key' (depending on i18next.options.nsSeparator) |
| ns | ?string (undefined) | Namespace to use. May also be embedded in i18nKey but not recommended when used in combination with natural language keys. |
| Prop name | Type (default) | Description |
| --------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| i18nKey | ?string (undefined) | Internationalization key to interpolate to. Can contain the namespace by prepending it in the form 'ns:key' (depending on i18next.options.nsSeparator). If omitted, a key is automatically generated using the content of the element. |
| ns | ?string (undefined) | Namespace to use. May also be embedded in i18nKey but not recommended when used in combination with natural language keys. |
### LanguageSelector component
Expand Down
104 changes: 104 additions & 0 deletions src/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
interpolate,
createReferenceStringFromHTML,
localizePath,
moveBaseLanguageToFirstIndex,
localizeUrl,
Expand All @@ -21,6 +22,7 @@ i18next.init({
translation: {
hello: "Hello!",
interpolationKey: "This is a <0>super cool</0> sentence!",
interpolationKeySelfClosing: "This has an image <0> here",
interpolationKeyNoHTML:
"This is a reference string without any HTML tags!",
},
Expand All @@ -29,6 +31,7 @@ i18next.init({
translation: {
hello: "Bonjour !",
interpolationKey: "Ceci est une phrase <0>super cool</0> !",
interpolationKeySelfClosing: "Ceci a une image <0> ici",
interpolationKeyNoHTML:
"Ceci est une chaîne de caractères de référence, sans aucune balise HTML !",
},
Expand Down Expand Up @@ -75,12 +78,28 @@ describe("interpolate(...)", () => {
);
});

it("interpolates localized string with self-closing HTML tags", () => {
i18next.changeLanguage("fr");
const referenceString = 'This has an image <img src="./img.png"> here';
const interpolatedStringFR = 'Ceci a une image <img src="./img.png"> ici';

expect(interpolate("interpolationKeySelfClosing", referenceString)).toBe(
interpolatedStringFR
);

i18next.changeLanguage("en");
expect(interpolate("interpolationKeySelfClosing", referenceString)).toBe(
'This has an image <img src="./img.png"> here'
);
});

it("with an i18nKey that is undefined", () => {
expect(interpolate("missingKey", referenceString)).toBe(referenceString);
expect(console.warn).toHaveBeenCalled();
});

it("with no HTML tags in default slot", () => {
i18next.changeLanguage("en");
expect(
interpolate(
"interpolationKeyNoHTML",
Expand All @@ -91,6 +110,7 @@ describe("interpolate(...)", () => {
});

it("with malformed HTML tags in default slot", () => {
i18next.changeLanguage("en");
expect(
interpolate(
"interpolationKeyNoHTML",
Expand All @@ -101,6 +121,90 @@ describe("interpolate(...)", () => {
});
});

describe("createReferenceStringFromHTML(...)", () => {
it("replaces HTML elements", () => {
expect(createReferenceStringFromHTML("Single <h1>element</h1>")).toBe(
"Single <0>element</0>"
);
expect(
createReferenceStringFromHTML(
'Multiple <div class="wrapper" id="head"><h1>Elements</h1><p>Nested and with attributes</p></div>'
)
).toBe("Multiple <0><1>Elements</1><2>Nested and with attributes</2></0>");

expect(
createReferenceStringFromHTML(
'<p>Self <img src="./img.png"> closing tags</p>'
)
).toBe("<0>Self <1> closing tags</0>");
});

it("ignores allowed elements with no attributes", () => {
expect(
createReferenceStringFromHTML("<p>Text with <em>emphasis</em></p>")
).toEqual("<0>Text with <em>emphasis</em></0>");

expect(
createReferenceStringFromHTML(
'<p>Text with <em class="test">emphasis</em></p>'
)
).toEqual("<0>Text with <1>emphasis</1></0>");

expect(
createReferenceStringFromHTML("<em>Text with <div>div</div></em>")
).toEqual("<em>Text with <1>div</1></em>");
});

it("warns when contains separator strings", () => {
const keySeparator = i18next.options.keySeparator;

expect(
createReferenceStringFromHTML(
`<p>Text with <em>emphasis</em>${keySeparator}</p>`
)
).toEqual(`<0>Text with <em>emphasis</em>${keySeparator}</0>`);

expect(console.warn).toHaveBeenCalled();
});

it("collapses extra whitespace", () => {
expect(
createReferenceStringFromHTML("Single \n \t <h1>element</h1>")
).toBe("Single <0>element</0>");
expect(
createReferenceStringFromHTML(" Trims <h1>outer</h1> text ")
).toBe("Trims <0>outer</0> text");
});

it("does not cause duplicate elements with attributes to be collapsed", () => {
expect(
createReferenceStringFromHTML(
`<span><span class="one">one</span><span class="two">two</span></span>`
)
).toBe("<0><1>one</0><2>two</0></0>");
});
});

describe("interpolate(...) and createReferenceStringFromHTML(...)", () => {
it("methods undo each other", () => {
const html =
'<p class="test1" id="test2"><strong>The</strong> text with <img src="./img.png"> image <em>reconstructs</em> properly</p>';
const frHtml =
'<p class="test1" id="test2"><strong>Le</strong> texte avec <img src="./img.png"> image <em>se reconstruit</em> correctement</p>';
const key = createReferenceStringFromHTML(html);
const frKey = createReferenceStringFromHTML(frHtml);
i18next.addResources("en", "translation", {
[key]: key,
});
i18next.addResources("fr", "translation", {
[key]: frKey,
});

i18next.changeLanguage("fr");
expect(interpolate(key, html)).toBe(frHtml);
});
});

describe("localizePath(...)", () => {
vi.spyOn(console, "warn");

Expand Down
13 changes: 10 additions & 3 deletions src/components/Trans.astro
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
---
import { interpolate } from "../utils";
import { interpolate, createReferenceStringFromHTML } from "../utils";
export interface Props {
i18nKey: string;
i18nKey?: string;
ns?: string;
}
const { i18nKey, ns } = Astro.props;
const referenceString = await Astro.slots.render("default");
let key: string;
if (typeof i18nKey === "string") {
key = i18nKey;
} else {
key = createReferenceStringFromHTML(referenceString);
}
---

<Fragment set:html={interpolate(i18nKey, referenceString, ns)} />
<Fragment set:html={interpolate(key, referenceString, ns)} />
87 changes: 87 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,12 @@ export const interpolate = (
let interpolatedString = localizedString;
for (let index = 0; index < referenceTags.length; index++) {
const referencedTag = referenceTags[index];
// Replace opening tags
interpolatedString = interpolatedString.replaceAll(
`<${index}>`,
`<${referencedTag.name}${referencedTag.attributes}>`
);
// Replace closing tags
interpolatedString = interpolatedString.replaceAll(
`</${index}>`,
`</${referencedTag.name}>`
Expand All @@ -97,6 +99,91 @@ export const interpolate = (
return interpolatedString;
};

/**
* Creates a reference string from an HTML string. The reverse of interpolate(), for use
* with <Trans> when not explicitly setting a key
*/
export const createReferenceStringFromHTML = (html: string) => {
// Allow these tags to carry through to the output
const allowedTags = ["strong", "br", "em", "i", "b"];

let forbiddenStrings: { key: string; str: string }[] = [];
if (i18next.options) {
forbiddenStrings = [
"keySeparator",
"nsSeparator",
"pluralSeparator",
"contextSeparator",
]
.map((key) => {
const str = i18next.options[key];
if (str) {
return {
key,
str: i18next.options[key],
};
}
return undefined;
})
.filter(function <T>(val: T | undefined): val is T {
return typeof val !== "undefined";
});
}

const tagsRegex = /<([\w\d]+)([^>]*)>/gi;

const referenceStringMatches = html.match(tagsRegex);

if (!referenceStringMatches) {
console.warn(
"WARNING(astro-i18next): default slot does not include any HTML tag to interpolate! You should use the `t` function directly."
);
return html;
}

const referenceTags = [];
referenceStringMatches.forEach((tagNode) => {
const [, name, attributes] = tagsRegex.exec(tagNode);
referenceTags.push({ name, attributes });

// reset regex state
tagsRegex.exec("");
});

let sanitizedString = html.replace(/\s+/g, " ").trim();
for (let index = 0; index < referenceTags.length; index++) {
const referencedTag = referenceTags[index];
if (
allowedTags.includes(referencedTag.name) &&
referencedTag.attributes.trim().length === 0
) {
continue;
}
sanitizedString = sanitizedString.replaceAll(
new RegExp(`<${referencedTag.name}[^>]*?\\s*\\/>`, "gi"),
`<${index}/>`
);
sanitizedString = sanitizedString.replaceAll(
`<${referencedTag.name}${referencedTag.attributes}>`,
`<${index}>`
);
sanitizedString = sanitizedString.replaceAll(
`</${referencedTag.name}>`,
`</${index}>`
);
}

for (let index = 0; index < forbiddenStrings.length; index++) {
const { key, str } = forbiddenStrings[index];
if (sanitizedString.includes(str)) {
console.warn(
`WARNING(astro-i18next): "${str}" was found in a <Trans> translation key, but it is also used as ${key}. Either explicitly set an i18nKey or change the value of ${key}.`
);
}
}
return sanitizedString;
};

/**
* Injects the given locale to a path
*/
Expand Down

0 comments on commit c6f13e3

Please sign in to comment.