Skip to content

Commit

Permalink
Split compiled message catalogs into multiple files (#1407)
Browse files Browse the repository at this point in the history
This is an attempt to resolve one of the big pain points present in lingui, which is that lingui only creates a single monolithic message file.

Right now this is solved by a script called `localebuilder.js` that parses the `messages.po` created by `lingui extract` and the `messages.js` created by `lingui compile` and attempts to associate AST nodes representing message definitions with the source files they're present in, as documented in the references of `messages.po`.

The JS is parsed by Babel, which is already a dependency, while the PO is parsed by pofile, a library that lingui already uses, so in the end we don't bring in any new dependencies.

We then split up the `messages.js` by slicing it up into "chunks": right now just a "base" chunk and a "norent" one, putting all the messages that come from `frontend/lib/norent` into one compiled message catalog and everything else into another.  This leaves it up to us to make sure that anything in our code which uses components from that directory also loads its corresponding message catalog, which isn't awesome, but it's still way better than having a single monolithic bundle.
  • Loading branch information
toolness authored May 8, 2020
1 parent a979a8f commit aa6abd9
Show file tree
Hide file tree
Showing 16 changed files with 521 additions and 84 deletions.
2 changes: 1 addition & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ node_modules/
tsc-build/
coverage/
locales/_build
locales/**/messages.js
locales/**/*.js
locales/**/*.mo
frontend/static/frontend/*.js
frontend/static/frontend/*.css
Expand Down
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ node_modules/
tsc-build/
coverage/
locales/_build
locales/**/messages.js
locales/**/*.js
locales/**/*.mo
frontend/static/frontend/*.js
frontend/static/frontend/*.css
Expand Down
2 changes: 1 addition & 1 deletion .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ node_modules

# These files are auto-generated.
locales/_build/**/*.json
locales/**/messages.js
locales/**/*.js
frontend/lib/queries
lambda.js
frontend/static
Expand Down
93 changes: 65 additions & 28 deletions frontend/lib/i18n-lingui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,53 +3,45 @@ import { Catalog } from "@lingui/core";
import loadable, { LoadableLibrary } from "@loadable/component";
import { I18nProvider } from "@lingui/react";
import i18n, { SupportedLocale } from "./i18n";
import { setupI18n as linguiSetupI18n } from "@lingui/core";
import { setupI18n as linguiSetupI18n, Catalogs } from "@lingui/core";

/**
* We use code splitting to make sure that we only load the message
* catalog needed for our currently selected locale.
* We use code splitting to make sure that we only load message
* catalogs needed for our currently selected locale.
*
* This defines the type of component whose children prop is a
* callable that receives a Lingui message catalog as its only
* argument.
*/
export type LoadableCatalog = LoadableLibrary<Catalog>;

const EnCatalog: LoadableCatalog = loadable.lib(
() => import("../../locales/en/messages") as any
);

const EsCatalog: LoadableCatalog = loadable.lib(
() => import("../../locales/es/messages") as any
);
/**
* Maps supported locales to components that load a Lingui message
* catalog for them.
*/
export type LinguiCatalogMap = {
[k in SupportedLocale]: LoadableCatalog;
};

/**
* Returns a component that loads the Lingui message catalog for
* the given locale.
* The "base" catalog, which contains the most common strings.
*/
function getLinguiCatalogForLanguage(locale: SupportedLocale): LoadableCatalog {
switch (locale) {
case "en":
return EnCatalog;
case "es":
return EsCatalog;
}
}
const BaseCatalogMap: LinguiCatalogMap = {
en: loadable.lib(() => import("../../locales/en/base.chunk") as any),
es: loadable.lib(() => import("../../locales/es/base.chunk") as any),
};

const SetupI18n: React.FC<
LinguiI18nProps & {
locale: string;
locale: SupportedLocale;
catalog: Catalog;
}
> = (props) => {
const { locale, catalog } = props;

// This useMemo() call might be overkill. -AV
const ourLinguiI18n = useMemo(() => {
li18n.load({
[locale]: catalog,
});
li18n.activate(locale);
mergeIntoLinguiCatalog(locale, catalog);
return li18n;
}, [locale, catalog]);

Expand All @@ -66,7 +58,7 @@ export type LinguiI18nProps = {
};

/**
* Loads the Lingui message catalog for the currently selected
* Loads the Lingui base message catalog for the currently selected
* locale, as dictated by our global i18n module. Children
* will then be rendered with the catalog loaded and ready
* to translate.
Expand All @@ -81,8 +73,7 @@ export type LinguiI18nProps = {
*/
export const LinguiI18n: React.FC<LinguiI18nProps> = (props) => {
const locale = i18n.locale;

const Catalog = getLinguiCatalogForLanguage(locale);
const Catalog = BaseCatalogMap[locale];

return (
<Catalog fallback={<p>Loading locale data...</p>}>
Expand All @@ -91,10 +82,56 @@ export const LinguiI18n: React.FC<LinguiI18nProps> = (props) => {
);
};

/**
* Creates a component that will load an auxiliary message catalog for
* Lingui in the currently selected locale, as dictated by our global i18n module.
*/
export function createLinguiCatalogLoader(
catalogMap: LinguiCatalogMap
): React.FC<LinguiI18nProps> {
return (props) => {
const locale = i18n.locale;
const Catalog = catalogMap[locale];

return (
<Catalog fallback={<p>Loading locale data...</p>}>
{(catalog) => {
mergeIntoLinguiCatalog(locale, catalog);
return props.children;
}}
</Catalog>
);
};
}

/**
* A global instance of Lingui's I18n object, which can be used to perform
* localization outside of React JSX. Note that this object is populated
* by the <LinguiI18n> component, however, so it should only really be
* used by components that exist below it in the hierarchy.
*/
export const li18n = linguiSetupI18n();

/**
* Internal global we maintain to keep track of all the messages we
* can translate across our various catalog chunks.
*/
const catalogs: Catalogs = {};

/**
* Merge the given catalog for the given locale into our global
* catalog and activate the locale (if it's not already active).
*/
function mergeIntoLinguiCatalog(locale: SupportedLocale, catalog: Catalog) {
const emptyCatalog: Catalog = { messages: {} };
const currentCatalog: Catalog = catalogs[locale] || emptyCatalog;
catalogs[locale] = {
languageData: catalog.languageData,
messages: {
...currentCatalog.messages,
...catalog.messages,
},
};
li18n.load(catalogs);
li18n.activate(locale);
}
10 changes: 8 additions & 2 deletions frontend/lib/norent/site.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,17 @@ import { NorentHelmet } from "./components/helmet";
import { NorentLetterEmailToUserStaticPage } from "./letter-email-to-user";
import { Trans } from "@lingui/macro";
import { LocalizedNationalMetadataProvider } from "./letter-builder/national-metadata";
import { createLinguiCatalogLoader } from "../i18n-lingui";

function getRoutesForPrimaryPages() {
return new Set(getNorentRoutesForPrimaryPages());
}

const NorentLinguiI18n = createLinguiCatalogLoader({
en: loadable.lib(() => import("../../../locales/en/norent.chunk") as any),
es: loadable.lib(() => import("../../../locales/es/norent.chunk") as any),
});

const LoadableDevRoutes = loadable(() => friendlyLoad(import("../dev/dev")), {
fallback: <LoadingPage />,
});
Expand Down Expand Up @@ -136,7 +142,7 @@ const NorentSite = React.forwardRef<HTMLDivElement, AppSiteProps>(
</Link>
);
return (
<>
<NorentLinguiI18n>
<section
className={classnames(
isPrimaryPage
Expand Down Expand Up @@ -170,7 +176,7 @@ const NorentSite = React.forwardRef<HTMLDivElement, AppSiteProps>(
</div>
</section>
<NorentFooter />
</>
</NorentLinguiI18n>
);
}
);
Expand Down
70 changes: 70 additions & 0 deletions frontend/localebuilder/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import fs from "fs";
import path from "path";
import { parseCompiledMessages } from "./parse-compiled-messages";
import { parseExtractedMessages } from "./parse-extracted-messages";
import {
MessageCatalogSplitterChunkConfig,
MessageCatalogSplitter,
} from "./message-catalog-splitter";
import {
MessageCatalogPaths,
getAllMessageCatalogPaths,
} from "./message-catalog-paths";

const MY_DIR = __dirname;

const LOCALES_DIR = path.resolve(path.join(MY_DIR, "..", "..", "locales"));

/**
* This encapsulates our criteria for splitting up Lingui's
* single message catalog into separate individual "chunks".
*/
const SPLIT_CHUNK_CONFIGS: MessageCatalogSplitterChunkConfig[] = [
/**
* Any strings that are *only* present in the norent directory
* will go into their own chunk.
*/
{
name: "norent",
test: (s) => s.startsWith("frontend/lib/norent/"),
},
/**
* Everything else goes into a separate chunk.
*/
{
name: "base",
test: (s) => true,
},
];

/**
* Split up the message catalog for a single locale.
*/
function processLocale(paths: MessageCatalogPaths) {
console.log(`Processing locale '${paths.locale}'.`);

const messagesJs = fs.readFileSync(paths.js, {
encoding: "utf-8",
});
const compiled = parseCompiledMessages(messagesJs);
const messagesPo = fs.readFileSync(paths.po, {
encoding: "utf-8",
});
var extracted = parseExtractedMessages(messagesPo);
const splitter = new MessageCatalogSplitter(extracted, compiled, {
locale: paths.locale,
rootDir: paths.rootDir,
chunks: SPLIT_CHUNK_CONFIGS,
});
splitter.split();
}

/**
* Main function to run the localebuilder CLI.
*/
export function run() {
const allPaths = getAllMessageCatalogPaths(LOCALES_DIR);
for (let paths of allPaths) {
processLocale(paths);
}
}
53 changes: 53 additions & 0 deletions frontend/localebuilder/message-catalog-paths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import fs from "fs";
import path from "path";

/**
* Represents information about filesystem
* paths to a particular locale's message catalog.
*/
export type MessageCatalogPaths = {
/** The locale this object represents (e.g. 'en'). */
locale: string;

/**
* The root directory where the locale's catalog
* is kept (e.g. '/foo/en`).
*/
rootDir: string;

/**
* The absolute path to the compiled message catalog
* (JavaScript) for the locale.
*/
js: string;

/**
* The absolute path to the extracted message catalog
* (PO) for the locale.
*/
po: string;
};

/**
* Given a root directory where all locale data lives,
* returns a list of path information about every locale.
*/
export function getAllMessageCatalogPaths(
rootDir: string
): MessageCatalogPaths[] {
const result: MessageCatalogPaths[] = [];

fs.readdirSync(rootDir).forEach((filename) => {
if (/^[._]/.test(filename)) return;
const abspath = path.join(rootDir, filename);
const stat = fs.statSync(abspath);
if (!stat.isDirectory()) return;
const js = path.join(abspath, "messages.js");
const po = path.join(abspath, "messages.po");
if (fs.existsSync(js) && fs.existsSync(po)) {
result.push({ locale: filename, rootDir: abspath, js, po });
}
});

return result;
}
Loading

0 comments on commit aa6abd9

Please sign in to comment.