Skip to content

Commit

Permalink
feat: load remote catalogs with remoteLoader() (#1080)
Browse files Browse the repository at this point in the history
  • Loading branch information
semoal authored Jun 8, 2021
1 parent f31afc5 commit e73a4b3
Show file tree
Hide file tree
Showing 14 changed files with 180 additions and 3 deletions.
4 changes: 4 additions & 0 deletions packages/cli/src/api/formats/csv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ const csv: CatalogFormatter = {
throw new Error(`Cannot read ${filename}: ${e.message}`)
}
},

parse(content) {
return deserialize(content)
}
}

export default csv
1 change: 1 addition & 0 deletions packages/cli/src/api/formats/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export type CatalogFormatter = {
options?: CatalogFormatOptionsInternal
): void
read(filename: string): CatalogType | null
parse(content): any
}

export default function getFormat(name: CatalogFormat): CatalogFormatter {
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/api/formats/lingui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ const lingui: CatalogFormatter = {
throw new Error(`Cannot read ${filename}: ${e.message}`)
}
},

parse(content) {
return content
}
}

export default lingui
4 changes: 4 additions & 0 deletions packages/cli/src/api/formats/minimal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ const minimal: CatalogFormatter = {
throw new Error(`Cannot read ${filename}: ${e.message}`)
}
},

parse(content: Record<string, any>) {
return deserialize(content)
}
}

export default minimal
2 changes: 1 addition & 1 deletion packages/cli/src/api/formats/po-gettext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,7 @@ const poGettext: CatalogFormatter & PoFormatter = {
return this.parse(raw)
},

parse(raw) {
parse(raw: string) {
const po = PO.parse(raw)
convertPluralsToICU(po.items, po.headers.Language)
return deserialize(indexItems(po.items))
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/api/formats/po.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ const po: CatalogFormatter & PoFormatter = {
return this.parse(raw)
},

parse(raw) {
parse(raw: string) {
const po = PO.parse(raw)
validateItems(po.items)
return deserialize(indexItems(po.items))
Expand Down
3 changes: 3 additions & 0 deletions packages/loader/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
declare type RemoteLoaderMessages<T> = string | Record<string, any> | T;
export declare function remoteLoader<T>(locale: string, messages: RemoteLoaderMessages<T>, fallbackMessages?: RemoteLoaderMessages<T>): any;
export {};
1 change: 1 addition & 0 deletions packages/loader/index.js
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { default } from "./src"
export { remoteLoader } from "./src/remoteLoader"
1 change: 1 addition & 0 deletions packages/loader/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"name": "@lingui/loader",
"version": "3.9.0",
"description": "webpack loader for lingui message catalogs",
"types": "index.d.ts",
"main": "index.js",
"author": {
"name": "Tomáš Ehrlich",
Expand Down
2 changes: 2 additions & 0 deletions packages/loader/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default } from "./webpackLoader"
export { remoteLoader } from "./remoteLoader"
95 changes: 95 additions & 0 deletions packages/loader/src/remoteLoader.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { remoteLoader } from "./remoteLoader"
import fs from "fs"
import path from "path"

describe("remote-loader", () => {
it("should compile correctly JSON messages coming from the fly", async () => {
const unlink = createConfig("minimal")
const messages = await simulatedJsonResponse()
expect(remoteLoader("en", messages)).toMatchInlineSnapshot(
`/*eslint-disable*/module.exports={messages:{"property.key":"value","{0} Deposited":[["0"]," Deposited"],"{0} Strategy":[["0"]," Strategy"]}};`
)
unlink()
})

it("should compile correctly .po messages coming from the fly", async () => {
const unlink = createConfig("po")
const messages = await simulatedPoResponse()
expect(remoteLoader("en", messages)).toMatchInlineSnapshot(
`/*eslint-disable*/module.exports={messages:{"Hello World":"Hello World","My name is {name}":["My name is ",["name"]]}};`
)
unlink()
})

describe("fallbacks", () => {
it("should fallback correctly to the fallback collection", async () => {
const unlink = createConfig("minimal")
const messages = await simulatedJsonResponse(true)
const fallbackMessages = await simulatedJsonResponse()

expect(
remoteLoader("en", messages, fallbackMessages)
).toMatchInlineSnapshot(
`/*eslint-disable*/module.exports={messages:{"property.key":"value","{0} Deposited":[["0"]," Deposited"],"{0} Strategy":[["0"]," Strategy"]}};`
)
unlink()
})

it("should fallback to compiled fallback", async () => {
const unlink = createConfig("po")
const messages = await simulatedPoResponse("es")
const fallbackMessages = await simulatedPoCompiledFile()

expect(
remoteLoader("en", messages, fallbackMessages)
).toMatchInlineSnapshot(
`/*eslint-disable*/module.exports={messages:{"Hello World":"Hello World","My name is {name}":["My name is ",["name"]]}};`
)
unlink()
})
})
})

function simulatedJsonResponse(nully?: boolean) {
return new Promise((resolve) => {
resolve({
"property.key": nully ? "" : "value",
"{0} Deposited": "{0} Deposited",
"{0} Strategy": "{0} Strategy",
})
})
}

function simulatedPoResponse(locale = "en") {
return new Promise((resolve) => {
const file = fs.readFileSync(
path.join(__dirname, "..", "test/locale/" + locale + "/messages.po"),
"utf-8"
)
resolve(file)
})
}

function simulatedPoCompiledFile() {
return new Promise((resolve) => {
resolve({
"Hello World": "Hello World",
"My name is {name}": ["My name is ", ["name"]],
})
})
}

function createConfig(format: string) {
const filename = `${process.cwd()}/.linguirc`
const config = `
{
'locales': ['en'],
'catalogs': [{
'path': 'locale/{locale}/messages'
}],
'format': '${format}'
}
`
fs.writeFileSync(filename, config)
return () => fs.unlinkSync(filename)
}
57 changes: 57 additions & 0 deletions packages/loader/src/remoteLoader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import R from "ramda"
import { getConfig } from "@lingui/conf"
import { createCompiledCatalog, getFormat } from "@lingui/cli/api"

type RemoteLoaderMessages<T> = string | Record<string, any> | T

export function remoteLoader<T>(locale: string, messages: RemoteLoaderMessages<T>, fallbackMessages?: RemoteLoaderMessages<T>) {
const config = getConfig()

// When format is minimal, everything works fine because are .json files,
// but when is csv, .po, .po-gettext needs to be parsed to something interpretable
let parsedMessages;
let parsedFallbackMessages;
if (config.format) {
const formatter = getFormat(config.format)
if (fallbackMessages) {
// we do this because, people could just import the fallback and import the ./en/messages.js
// generated by lingui and the use case of format .po but fallback as .json module could be perfectly valid
parsedFallbackMessages = typeof fallbackMessages === "object" ? getFormat("minimal").parse(fallbackMessages) : formatter.parse(fallbackMessages)
}

parsedMessages = formatter.parse(messages)
} else {
throw new Error(`
*format* value in the Lingui configuration is required to make this loader 100% functional
Read more about this here: https://lingui.js.org/ref/conf.html#format
`)
}


const mapTranslationsToInterporlatedString = R.mapObjIndexed(
(_, key) => {
// if there's fallback and translation is empty, return the fallback
if (parsedMessages[key].translation === "" && parsedFallbackMessages?.[key]?.translation) {
return parsedFallbackMessages[key].translation
}

return parsedMessages[key].translation
},
parsedMessages
)

// In production we don't want untranslated strings. It's better to use message
// keys as a last resort.
// In development, however, we want to catch missing strings with `missing` parameter
// of I18nProvider (React) or setupI18n (core) and therefore we need to get
// empty translations if missing.
const strict = process.env.NODE_ENV !== "production"
return createCompiledCatalog(locale, mapTranslationsToInterporlatedString, {
strict,
...config,
namespace: config.compileNamespace,
pseudoLocale: config.pseudoLocale,
})

}

Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ try {
JavascriptGenerator = require("webpack/lib/JavascriptGenerator")
} catch (error) {
if (error.code !== "MODULE_NOT_FOUND") {
throw e
throw error
}
}

Expand Down
5 changes: 5 additions & 0 deletions packages/loader/test/locale/es/messages.po
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
msgid "Hello World"
msgstr ""

msgid "My name is {name}"
msgstr "My name is {name}"

1 comment on commit e73a4b3

@vercel
Copy link

@vercel vercel bot commented on e73a4b3 Jun 8, 2021

Choose a reason for hiding this comment

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

Please sign in to comment.