From 914afe5343ca62c3698e33af5ac316e825470de0 Mon Sep 17 00:00:00 2001 From: dschom Date: Wed, 2 Oct 2024 10:14:47 -0700 Subject: [PATCH] bug(settings): Hash l10n files for cdn deployment Because: - We need cache busting file names when deploying new content to the CDN This Commit: - Adds grunt-hash job - Configures grunt has to hash ftl files and create mapping files - Adjusts AppLocalizationProvider to use the mapping file to load l10n files --- .gitignore | 1 + .../server/lib/configuration.js | 2 +- .../lib/AppLocalizationProvider.test.tsx | 11 ++++ .../fxa-react/lib/AppLocalizationProvider.tsx | 60 +++++++++++++++++-- packages/fxa-settings/Gruntfile.js | 20 +++++++ packages/fxa-settings/package.json | 5 +- yarn.lock | 23 +++++++ 7 files changed, 114 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index 8bd2b6dc78..2027796305 100644 --- a/.gitignore +++ b/.gitignore @@ -150,6 +150,7 @@ packages/fxa-react/test/ # fxa-settings packages/fxa-settings/fxa-content-server-l10n/ +packages/fxa-settings/public/static packages/fxa-settings/public/locales packages/fxa-settings/legal-docs/ packages/fxa-settings/public/legal-docs diff --git a/packages/fxa-content-server/server/lib/configuration.js b/packages/fxa-content-server/server/lib/configuration.js index 4ee0f4640d..2a63bf18c5 100644 --- a/packages/fxa-content-server/server/lib/configuration.js +++ b/packages/fxa-content-server/server/lib/configuration.js @@ -999,7 +999,7 @@ const conf = (module.exports = convict({ }, l10n: { baseUrl: { - default: '/settings/locales', + default: '/settings/static', doc: 'The path (or url) where ftl files are held.', env: 'L10N_BASE_URL', }, diff --git a/packages/fxa-react/lib/AppLocalizationProvider.test.tsx b/packages/fxa-react/lib/AppLocalizationProvider.test.tsx index 2df6943cd3..d3a570b47e 100644 --- a/packages/fxa-react/lib/AppLocalizationProvider.test.tsx +++ b/packages/fxa-react/lib/AppLocalizationProvider.test.tsx @@ -26,6 +26,17 @@ describe('', () => { } beforeAll(() => { + fetchMock.get( + '/static-asset-manifest.json', + ` + { + "/locales/en-US/greetings.ftl": "/locales/en-US/greetings.ftl", + "/locales/en-US/farewells.ftl": "/locales/en-US/farewells.ftl", + "/locales/es-ES/greetings.ftl": "/locales/es-ES/greetings.ftl", + "/locales/en-GB/greetings.ftl": "/locales/en-GB/greetings.ftl", + } + ` + ); fetchMock.get('/locales/en-US/greetings.ftl', 'hello = Hello\n'); fetchMock.get('/locales/en-US/farewells.ftl', 'goodbye = Goodbye\n'); fetchMock.get('/locales/es-ES/greetings.ftl', 'hello = Hola\n'); diff --git a/packages/fxa-react/lib/AppLocalizationProvider.tsx b/packages/fxa-react/lib/AppLocalizationProvider.tsx index c63edb74e6..903603eb36 100644 --- a/packages/fxa-react/lib/AppLocalizationProvider.tsx +++ b/packages/fxa-react/lib/AppLocalizationProvider.tsx @@ -7,9 +7,39 @@ import { LocalizationProvider, ReactLocalization } from '@fluent/react'; import React, { Component } from 'react'; import { EN_GB_LOCALES, parseAcceptLanguage } from '@fxa/shared/l10n'; -async function fetchMessages(baseDir: string, locale: string, bundle: string) { +/** + * Gets l10n messages from server + * @param baseDir The root location where locales folders are held + * @param locale The target language + * @param bundle The target bundle (ie main) + * @param mappings A set of mappings for static resources. + * @returns + */ +async function fetchMessages( + baseDir: string, + locale: string, + bundle: string, + mappings?: Record +) { try { - const response = await fetch(`${baseDir}/${locale}/${bundle}.ftl`); + // Build the path to l10n file + let path = `locales/${locale}/${bundle}.ftl`; + + // If mappings were proivided see if there is one for the path. This + // will be a location where the file path contains a hash in the file + // name + if (mappings) { + path = mappings[path]; + } + + // If we don't have mapped path, there are no l10n resources for this language. + if (!path) { + return ''; + } + + // Fetch the file and return the messages + const resolvedPath = `${baseDir}/${path}`; + const response = await fetch(resolvedPath); const messages = await response.text(); return messages; @@ -23,23 +53,41 @@ async function fetchMessages(baseDir: string, locale: string, bundle: string) { function fetchAllMessages( baseDir: string, locale: string, - bundles: Array + bundles: Array, + mappings?: Record ) { return Promise.all( - bundles.map((bndl) => fetchMessages(baseDir, locale, bndl)) + bundles.map((bndl) => fetchMessages(baseDir, locale, bndl, mappings)) ); } +async function fetchL10nHashedMappings(mappingUrl: string) { + try { + // These mappigns are currently generated with grunt. See grunt task hash-static + // in fxa-settings for an example of how the mappings are generated. + const mappingsResponse = await fetch(mappingUrl); + const json = await mappingsResponse.json(); + return json; + } catch (err) { + return undefined; + } +} + async function createFluentBundleGenerator( baseDir: string, currentLocales: Array, bundles: Array ) { + const mappings = await fetchL10nHashedMappings( + `${baseDir}/static-asset-manifest.json` + ); const fetched = await Promise.all( currentLocales .filter((l) => !EN_GB_LOCALES.includes(l)) .map(async (locale) => { - return { [locale]: await fetchAllMessages(baseDir, locale, bundles) }; + return { + [locale]: await fetchAllMessages(baseDir, locale, bundles, mappings), + }; }) ); @@ -84,7 +132,7 @@ type Props = { export default class AppLocalizationProvider extends Component { static defaultProps: Props = { - baseDir: '/locales', + baseDir: '', userLocales: ['en'], bundles: ['main'], children: React.createElement('div'), diff --git a/packages/fxa-settings/Gruntfile.js b/packages/fxa-settings/Gruntfile.js index 2e80723fff..244a6f2703 100644 --- a/packages/fxa-settings/Gruntfile.js +++ b/packages/fxa-settings/Gruntfile.js @@ -37,6 +37,23 @@ module.exports = function (grunt) { dest: 'test/settings.ftl', }, }, + hash: { + options: { + mapping: 'public/static/static-asset-manifest.json', // The file where the hashed file names will be stored + srcBasePath: 'public/', // the base Path you want to remove from the `key` string in the mapping file + destBasePath: 'public/static', + }, + locales: { + expand: true, + cwd: 'public/locales', + src: '**/*.ftl', + dest: 'public/static/locales/', + rename: function (dest, src) { + const lang = src.split('/')[0]; + return `${dest}/${lang}`; + }, + }, + }, watch: { ftl: { files: srcPaths, @@ -51,8 +68,11 @@ module.exports = function (grunt) { grunt.loadNpmTasks('grunt-contrib-copy'); grunt.loadNpmTasks('grunt-contrib-watch'); grunt.loadNpmTasks('grunt-contrib-concat'); + grunt.loadNpmTasks('grunt-hash'); grunt.registerTask('merge-ftl', ['copy:branding-ftl', 'concat:ftl']); grunt.registerTask('merge-ftl:test', ['concat:ftl-test']); grunt.registerTask('watch-ftl', ['watch:ftl']); + + grunt.registerTask('hash-static', ['hash']); }; diff --git a/packages/fxa-settings/package.json b/packages/fxa-settings/package.json index 776a8d4d6d..2524210042 100644 --- a/packages/fxa-settings/package.json +++ b/packages/fxa-settings/package.json @@ -5,7 +5,8 @@ "private": true, "scripts": { "prebuild": "nx l10n-prime && nx legal-prime", - "build": "nx build-l10n && nx build-ts && nx build-css && nx build-react", + "build": "nx build-l10n && nx build-static && nx build-ts && nx build-css && nx build-react", + "build-static": "yarn grunt hash-static", "build-ts": "tsc --build", "build-css": "NODE_ENV=production tailwindcss -i ./src/styles/tailwind.css -o ./src/styles/tailwind.out.css --postcss", "build-storybook": "NODE_ENV=production STORYBOOK_BUILD=1 yarn build-css && NODE_OPTIONS=--openssl-legacy-provider sb build && cp -r public/locales ./storybook-static/locales", @@ -237,6 +238,7 @@ "grunt-cli": "^1.4.3", "grunt-contrib-concat": "^2.1.0", "grunt-contrib-watch": "^1.1.0", + "grunt-hash": "^0.5.0", "jest-watch-typeahead": "0.6.5", "mutationobserver-shim": "^0.3.7", "nx": "18.3.1", @@ -245,6 +247,7 @@ "postcss-assets": "^6.0.0", "postcss-import": "^16.1.0", "prop-types": "^15.8.1", + "raw-loader": "^4.0.2", "sinon": "^15.0.1", "storybook": "^7.0.23", "storybook-addon-rtl": "^0.5.0", diff --git a/yarn.lock b/yarn.lock index fa15fab314..b6a926b040 100644 --- a/yarn.lock +++ b/yarn.lock @@ -41625,6 +41625,7 @@ fsevents@~2.1.1: grunt-cli: ^1.4.3 grunt-contrib-concat: ^2.1.0 grunt-contrib-watch: ^1.1.0 + grunt-hash: ^0.5.0 html-webpack-plugin: ^5.6.0 identity-obj-proxy: ^3.0.0 jest: ^29.7.0 @@ -41643,6 +41644,7 @@ fsevents@~2.1.1: postcss-normalize: ^10.0.1 postcss-preset-env: ^10.0.5 prop-types: ^15.8.1 + raw-loader: ^4.0.2 react-app-polyfill: ^3.0.0 react-async-hook: ^4.0.0 react-dev-utils: ^12.0.1 @@ -43337,6 +43339,15 @@ fsevents@~2.1.1: languageName: node linkType: hard +"grunt-hash@npm:^0.5.0": + version: 0.5.0 + resolution: "grunt-hash@npm:0.5.0" + bin: + grunt-hash: bin/grunt-hash + checksum: 2781250dbd476e8302085d816e60a94e0f94f9696e8cd434cecbb66dfc444a561d251409ae88e1f5d82be2996c390aa8681d5fb8f3638ba30e496417e61cf5cd + languageName: node + linkType: hard + "grunt-htmllint@npm:0.3.0": version: 0.3.0 resolution: "grunt-htmllint@npm:0.3.0" @@ -59754,6 +59765,18 @@ fsevents@~2.1.1: languageName: node linkType: hard +"raw-loader@npm:^4.0.2": + version: 4.0.2 + resolution: "raw-loader@npm:4.0.2" + dependencies: + loader-utils: ^2.0.0 + schema-utils: ^3.0.0 + peerDependencies: + webpack: ^4.0.0 || ^5.0.0 + checksum: 51cc1b0d0e8c37c4336b5318f3b2c9c51d6998ad6f56ea09612afcfefc9c1f596341309e934a744ae907177f28efc9f1654eacd62151e82853fcc6d37450e795 + languageName: node + linkType: hard + "rc@npm:^1.2.7": version: 1.2.8 resolution: "rc@npm:1.2.8"