diff --git a/README.md b/README.md index a86a700..bef6589 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/__tests__/utils.test.ts b/src/__tests__/utils.test.ts index c371f89..c4a68e3 100644 --- a/src/__tests__/utils.test.ts +++ b/src/__tests__/utils.test.ts @@ -1,5 +1,6 @@ import { interpolate, + createReferenceStringFromHTML, localizePath, moveBaseLanguageToFirstIndex, localizeUrl, @@ -21,6 +22,7 @@ i18next.init({ translation: { hello: "Hello!", interpolationKey: "This is a <0>super cool sentence!", + interpolationKeySelfClosing: "This has an image <0> here", interpolationKeyNoHTML: "This is a reference string without any HTML tags!", }, @@ -29,6 +31,7 @@ i18next.init({ translation: { hello: "Bonjour !", interpolationKey: "Ceci est une phrase <0>super cool !", + interpolationKeySelfClosing: "Ceci a une image <0> ici", interpolationKeyNoHTML: "Ceci est une chaîne de caractères de référence, sans aucune balise HTML !", }, @@ -75,12 +78,28 @@ describe("interpolate(...)", () => { ); }); + it("interpolates localized string with self-closing HTML tags", () => { + i18next.changeLanguage("fr"); + const referenceString = 'This has an image here'; + const interpolatedStringFR = 'Ceci a une image ici'; + + expect(interpolate("interpolationKeySelfClosing", referenceString)).toBe( + interpolatedStringFR + ); + + i18next.changeLanguage("en"); + expect(interpolate("interpolationKeySelfClosing", referenceString)).toBe( + 'This has an image 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", @@ -91,6 +110,7 @@ describe("interpolate(...)", () => { }); it("with malformed HTML tags in default slot", () => { + i18next.changeLanguage("en"); expect( interpolate( "interpolationKeyNoHTML", @@ -101,6 +121,90 @@ describe("interpolate(...)", () => { }); }); +describe("createReferenceStringFromHTML(...)", () => { + it("replaces HTML elements", () => { + expect(createReferenceStringFromHTML("Single

element

")).toBe( + "Single <0>element" + ); + expect( + createReferenceStringFromHTML( + 'Multiple ' + ) + ).toBe("Multiple <0><1>Elements<2>Nested and with attributes"); + + expect( + createReferenceStringFromHTML( + '

Self closing tags

' + ) + ).toBe("<0>Self <1> closing tags"); + }); + + it("ignores allowed elements with no attributes", () => { + expect( + createReferenceStringFromHTML("

Text with emphasis

") + ).toEqual("<0>Text with emphasis"); + + expect( + createReferenceStringFromHTML( + '

Text with emphasis

' + ) + ).toEqual("<0>Text with <1>emphasis"); + + expect( + createReferenceStringFromHTML("Text with
div
") + ).toEqual("Text with <1>div"); + }); + + it("warns when contains separator strings", () => { + const keySeparator = i18next.options.keySeparator; + + expect( + createReferenceStringFromHTML( + `

Text with emphasis${keySeparator}

` + ) + ).toEqual(`<0>Text with emphasis${keySeparator}`); + + expect(console.warn).toHaveBeenCalled(); + }); + + it("collapses extra whitespace", () => { + expect( + createReferenceStringFromHTML("Single \n \t

element

") + ).toBe("Single <0>element"); + expect( + createReferenceStringFromHTML(" Trims

outer

text ") + ).toBe("Trims <0>outer text"); + }); + + it("does not cause duplicate elements with attributes to be collapsed", () => { + expect( + createReferenceStringFromHTML( + `onetwo` + ) + ).toBe("<0><1>one<2>two"); + }); +}); + +describe("interpolate(...) and createReferenceStringFromHTML(...)", () => { + it("methods undo each other", () => { + const html = + '

The text with image reconstructs properly

'; + const frHtml = + '

Le texte avec image se reconstruit correctement

'; + 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"); diff --git a/src/components/Trans.astro b/src/components/Trans.astro index 420a012..d446111 100644 --- a/src/components/Trans.astro +++ b/src/components/Trans.astro @@ -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); +} --- - + diff --git a/src/utils.ts b/src/utils.ts index 595d6a8..f557160 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -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( ``, `` @@ -97,6 +99,91 @@ export const interpolate = ( return interpolatedString; }; +/** + * Creates a reference string from an HTML string. The reverse of interpolate(), for use + * with 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 (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( + ``, + `` + ); + } + + 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 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 */