Sekrab Garage

Twisting Angular localization

Multilingual Angular App hosted on Firebase and Surge with the same build

Angular August 22, 22
Subscribe to Sekrab Parts newsletter.

Last time we figured out a way to prefix all routes without changing the base href, using APP_BASE_HREF token in Angular, which helped us host on Netlify a same-build multilingual App. Today we shall try Firebase and Surge.sh.

anchorHosting on Firebase

I started writing the host configuration for Firebase thinking it had more to offer, but I was disappointed. The hosting configuration does not allow conditions which Netlify allows. We will resolve to a serverless function at one point, which is not provided on the Free plan. Let’s dig in.

anchorSetup Firebase locally

To be able to test locally, we need to have at least two files sitting in a host folder, and a global cli.

npm install -g firebase-tools

In StackBlitz find the Firebase dedicated host folder under /host-firebase

The two files are .firebaserc and firebase.json

// .firebaserc in the root of the firebase host
{
  "projects": {
    // does not have to exist in the cloud, we are running locally
    "default": "cr"
  }
}

The firebase.json we will build up with hosting rules, initially it looks like this:

// firebase.json in the root of the firebase host
{
  "hosting": [
    {
      "target": "web",
			// the public folder is where the build will go
      "public": "client", 
      "ignore": [
        "firebase.json",
        "**/.*",
        "**/node_modules/**"
      ],      
      "rewrites": [
				// TODO
      ]
    }
  ]
}

To host on Firebase a browser-only app, all files must sit in the same folder, like in Netlify hosting, including any external configuration. On StackBlitz find the example build under /host-firebase. Our writeindex task also copies into the same host/client folder as in Netlify setup (find example in /src/firebase/angular.json.)

To run locally, change directory into the host folder and run:

firebase emulators:start

Browse to http://localhost:5000 and I hope you see what I see. Let’s begin with the URL based app:

anchorURL driven apps

We face the same issue of differentiating static resources from friendly URLs as in Netlify hosting, but the solution of conditional rules is not available in Firebase, so we use APP_BASE_HREF right away.

Since all assets are served locally, we just need to rewrite the friendly URLs

// firebase.json for URL based app with APP_BASE_URL set in language script
"rewrites": [
  {
    "source": "/ar{,/**}",
    "destination": "/index.ar.html"
  },
	// default others
  {
    "source": "**",
    "destination": "/index.en.html"
  }
]

The missing opportunity is / root URL for a second time user. There are some solutions to that, some of them are hacky: making use of index.html, and others need a little more work: relying on Firebase i18n rewrite.

Make use of the root index.html

Create an empty HTML page with the following script

<!DOCTYPE html>
<html lang="en">
<head>
 <script>
  const cookie = document.cookie;
  let root = 'en';
  // use any cookie name, redirect according to value
  if (cookie.indexOf('cr-lang') > -1) {
    // this line was produced by CoPilot! Not bad!
    root = cookie.split('cr-lang=')[1].split(';')[0];
  }
  // replace URL, a client 301 redirect
  window.location.replace(`/${root}`);
 </script>
</head>
<body>

</body>
</html>

Don’t forget to add this index file to the angular.json assets to copy over to client folder.

Use Firebase i18n rewrites

For this to work, the files must be physically saved under en and ar folders. So we need to change our Angular builder, or gulp task a bit, to support this folder structure:

|-client/
|----en/
|------index.html
|----ar/
|------index.html
|--assets...

Our firebase.json then looks like this:

// to handle already selected language in cookies
// cookie name must be firebase-language-override
"i18n": {
  "root": "/"
},
"rewrites": [
  {
    "source": "/ar{,/**}",
    "destination": "/ar/index.html"
  },
  {
    "source": "**",
    "destination": "/en/index.html"
  }
]

We can adjust the builder to create this new structure easily, find the new code in StackBlitz under its own builder builder/locales/folders.ts:

// update builder to build folder structure
interface Options  {
	// ...
	// new property
  fileName?: string;
}

export default createBuilder(LocalizeIndex);

function LocalizeIndex(
    options: Options,
    context: BuilderContext,
): BuilderOutput {
    // ... 
		// instead of writing file, first create folder
    if (!existsSync(options.destination + '/' + lang.name)){
          mkdirSync(options.destination + '/' + lang.name);
    }
    // save file with index.html, base href = /
     writeFileSync(`${options.destination}/${lang.name}/${ options.fileName || 'index.html'}`, contents);

		// ...
    return { success: true };
}

In our config (or external configuration), set the cookie name to firebase-language-override

// src/app/config.ts
export const Config = {
  Res: {
    cookieName: 'firebase-language-override',
		//...
  },
};

Building, running locally, switching, works. Let’s move on to the cookie based solution.

anchorCookie driven apps

We back up a bit to the normal app, with no APP_BASE_HREF prefix. There are no conditional rewrite rules in Firebase, there is however an i18n solution, and there are serverless functions. Let’s try both:

Firebase i18n rewrites:

The following is the sequence of events in a hosting file:

  • browse to /, the host detects Language from browser and serves /en/index.html which is a physical file
  • browsing to /products however, in an Angular app, the host will try to serve /en/products/index.html which does not exist, so it tries to serve /en/404.html
  • this /en/404.html can have the same contents of the /en/index.html file and load the Angular app itself, but it is a bad practice since it registers as a 404
  • We can also create a JavaScript redirect in 404.html to / but this too is not a good solution since we would lose the friendly URL

The folder structure would look like this

|-client/
|----en/
|------index.html
|------404.html
|----ar/
|------index.html
|------404.html
|--assets...

That’s a reason too many to bog me down, to top it all, the localized 404.html does not run locally in the emulator, so I could not test it. Moving on.

Firebase serverless function

The Spark free plan does not run functions in Firebase, the first paid plan: Blaze however; is quite generous in its free tier. You can try this locally, but to deploy it, you need a Blaze subscription.

To run functions locally, without having a real application, you need to do the following:

  • Create functions folder inside the firebase host folder (find it in StackBlitz under host-firebase)
  • Create a functions/package.json
// package.json inside functions need nothing else
{
	// that's the only dependency you need to install
  "dependencies": {
    "firebase-functions": "^3.22.0"
  },
  // add this, or in firebase.json add runtime, see below
  "engines": {
    "node": "16"
  }
}
  • Add engines node to package.json as above, following documentation of firebase:
The package.json file created during initialization contains an important key: "engines": {"node": "10"}. This specifies your Node.js version for writing and deploying functions. You can select other supported versions.

Or following an error message in my command line, you can create it in firebase.json:

// firebase.json in root of host, undocumented
{
	"hosting": ...
	"functions": {
		"runtime": "nodejs16"
	}
}
  • Install the only dependency you need inside the functions folder firebase-functions
  • In host, run firebase emulators:start
  • Ignore funny warning messages and browse normally

The function we want to create is a catch-all to send the right index.[lang].html according to a cookie value:

// functions/index.js
const functions = require('firebase-functions');

exports.serveLocalized = functions.https.onRequest((req, res) => {
  // find cr-lang cookie
	// Pssst: you can require a config.json 
  // if you use external configuration to read the cookie name
  const cookie = req.headers.cookie;
  let lang = 'en';
  if (cookie.indexOf('cr-lang') > -1) {
    // CoPilot!
    lang = cookie.split('cr-lang=')[1].split(';')[0];
  }
  // serve the right index file
  res.status(200).sendFile(`index.${lang}.html`, {root: '../client/'});
});

Under hosting in firebase.json

// ...
"rewrites": [
  {
    "source": "**",
    "function": "serveLocalized"
  }
]

Now run emulator, and browse to http://locahost:5000

The rule in Firebase hosting is: if the file physically exists it shall be served first. So assets are statically served.

We previously created external configuration in Angular, one of the benefits for this project is now we can have the same build hosted by different hosts, each has its own configuration. We can also access this configuration in Firebase functions with a simple require statement.

Testing, switching languages in cookies, works. Moving on.

anchorHosting on Surge

When I decided to try and host it on the Free subscription of Surge, I had little hope, because the ROUTE file is not supported. But I was pleasantly surprised. Surge.sh has a publishing tool. To use, make sure you have installed surge globally:

npm install surge -g

Also, the CNAME file should be in the published folder, we shall include the file in our assets array in angular.json.

To publish, we move into our host folder, and run surge ./client (assuming we build into client sub folder).

Find the example host under StackBlitz /host-surge folder.

anchorThe only available option

We do not have many options:

  • Cookie based apps need conditional rewrite, which is not supported
  • URL based with base href set to a specific language, need to rewrite assets, which also is not an option
  • With APP_BASE_URL, the served file is index.html, and if not found, 200.html on root, but in either case, we would not know which index.[lang].html to serve

That leaves us with one option:

  • Use APP_BASE_HREF
  • Create physical language folders, for each language.
|-client/
|----en/
|------200.html
|----ar/
|------200.html
|--assets...
  • With our Angular builder (or Gulp task) writeindex, generate 200.html in each language folder, which will handle rewriting /en/products/… to /en/200.html.
  • Instead of making index.html the default Angular app (which works fine), we can make it redirect via JavaScript to the correct sub folder according to a cookie.
<!DOCTYPE html>
<html lang="en">
<head>
 <script>
  const cookie = document.cookie;
  let root = 'en';
  // use any cookie name, redirect according to value
  if (cookie.indexOf('cr-lang') > -1) {
    root = cookie.split('cr-lang=')[1].split(';')[0];
  }
  // replace URL, a client 301 redirect
  window.location.replace(`/${root}`);
 </script>
</head>
<body>
</body>
</html>

Surging… running, redirecting, switching language, celebrating. 💃🏼💃🏼

I’m actually impressed and pleased with Surge

anchorConclusion

The last bit of the puzzle to twist Angular localization, is extracting the translatable keys into the right JavaScript file. To do that, and to conclude our series, come back next week. 😴

Thank you for reading yet another long article, did you spot the squiggles under the rock?

  1. two years ago
    Alternative way to localize in Angular
  2. two years ago
    Serving multilingual Angular application with ExpressJS
  3. two years ago
    Serving the same Angular build with different URLs
  4. two years ago
    Serving a different index.html in an Angular build for different languages
  5. two years ago
    Currency Angular pipe, UI language switch, and a verdict
  6. two years ago
    Pre-generating multiple index files using Angular Builders and Gulp tasks to serve a multilingual Angular app
  7. two years ago
    Using Angular APP_BASE_HREF token to serve multilingual apps, and hosting on Netlify
  8. two years ago
    Multilingual Angular App hosted on Firebase and Surge with the same build
  9. two years ago
    Alternative way to localized in Angular: Conclusion and final enhancements