Sekrab Garage

Using a builder with multilingual apps

Prerendering in Angular - Part IV

AngularHosting October 5, 22
Subscribe to Sekrab Parts newsletter.
Series:

Angular prerendering

There are many reasons to prerender, and many not to. Each application finds its own use case to prerender, while static site generation is all about prerendering, a huge website like stackoverflow avoids it. Angular recently added universal builder for prerendering, but in this series, I want to investigate other ways.
  1. two years ago
    Prerender routes in Express server for Angular - Part I
  2. two years ago
    Prerendering in Angular - Part II
  3. two years ago
    Prerendering in Angular - Part III
  4. two years ago
    Prerendering in Angular - Part IV

The last piece of our quest is to adapt our Angular Prerender Builder to create multiple folders, per language, and add the index files into it. Hosting those files depends on the server we are using, and we covered different hosts back in Twisting Angular Localization post.

If you are going to make the best use of this article, I suggest you read the above post first.

At this point, I understand how reliant I have become on older posts, to release the dependency as much as possible, I will use some hard coded values.

Find the new builder in StackBlitz project under prerender-builder/prerender-multi folder.

anchorCustom index file

As we previously did with the express prerender function, we have custom index.[lang].html files created via a builder we spoke of in our Replacing i18n series. In our multilingual prerenderer, we need to loop through the supported languages, and pass the right index file. We can pass the supported language as part of the schema, or we can pass them from a wiretindex builder (which we built in the article linked above).

I am not going to use the index builder in our StackBlitz to simplify matters, but if you do, it would be fetched the same way the browser target and the server target are fetched, like this:

// optional
// index.ts, inside execute function
// let schema have options.indexTarget to point to writeindex target
// get writeindex target to read supported languages and other options
const indexTarget = targetFromTargetString(options.indexTarget); 
const indexOptions = (await context.getTargetOptions(indexTarget)) as any;

In our StackBlitz, let’s add the needed options directly to the schema. The following elements are needed to figure out where the index file is.

// new options to add to schema
interface Options {
  destination: string; // this is where the index files are
  languages: string[]; // the supported languages, in 2-char codes
  localePath: string; // this is where the locale scripts sits inside the browser target
}

Remember we still need to create a server build, to generate the main.js. Whether with ngExpressEngine or not, it must at least export the AppServerModule and the renderModule function.

anchorLanguage files

This part is specific to our chosen way of localization, which is to embed a JavaScript that runs on both client and server, with the translation keys, inside cr.resources declared variable. Here is a shortcut to the English locale used.

This is embedded in index.en.url.html. For example:

<!-- host/index/index.en.url.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <base href="/en/" />
		<!-- this file has the resources and embedded in the index file -->
		<script src="locale/cr-en.js" defer></script>
		...
</html>

The Angular renderModule function has no context, so we need to impose one. In our case, the resources are defined in a global variable: cr. In worker.ts we add the following global definition:

// to worker.ts, add the cr resources group like we did previously
// wait for execution to populate
// if you set noImplicitAny in tsconfig, this can be casted to any
// (<any>global).cr = {};
global.cr = {};

In PreRender function we import the script, and populate

// in worker.ts, we need to import the file, which assigns the global.cr[language],
// then we need to populate our server global.cr.resources
await import(localePath);
global['cr'].resources = global['cr'][language];

And finally we construct output paths containing the language:

// worker.ts: correct route and language, like ./client/en/route/index.html
const outputFolderPath = path.join(clientPath, language, route);

We change the model passed to PreRender to contain the missing information: language and localePath

// worker.ts
// change the render options, and change the PreRender signature
export interface RenderOptions {
	indexFile: string; // this is now the full path of the index file
  // ...
  // add language, and the locale script path
  language: string;
  localePath: string;
}

anchorThe main loop

Now back to our index.ts, we need to loop through the languages, and prepare two paths: the index path, and the locale path.

// index.ts
// change the renderer, pass 'options' from execute function
async function _renderUniversal(
  routes: string[],
  context: BuilderContext,
  clientPath: string,
  serverPath: string,
  // passing options
  options: IOptions
): Promise<BuilderOutput> {
  // ... the changes are as follows
  try {
    // loop through options languages
    for (const lang of options.languages) {
      // create path to pass to worker for example: './index/index.lang.url.html'
      const indexFile = path.resolve(context.workspaceRoot, getIndexOutputFile(options, lang));

      // check existence of locale else skip silently, client/locale/cr-en.js for example
      const langLocalePath = path.join(clientPath,`${options.localePath}/cr-${lang}.js`);

      if (!fs.existsSync(langLocalePath)) {
        context.logger.error(`Skipping locale ${lang}`);
        continue;
      }
			// then the results map
      const results = (await Promise.all(
        routes.map((route) => {
          const options: RenderOptions = {
            // ...
            // adding these
            localePath: langLocalePath,
            language: lang
          };
          // ...
        })
      )) as RenderResult[];
      // ...
    }
		// ...
  }
  return { success: true };
}
// the index outpfile can be as simple or as complicated as the project setup needs
function getIndexOutputFile(options: any, lang: string): string {
  // index/index.lang.url.html as an example
  return `${options.destination}/index.${lang}.url.html`;
  // could be client/en/index.html, like in Surge host
}

Back to angular.json, we assign the required params in a new target:

"prerender-multi": {
  "builder": "./prerender-builder:prerender-multi",
  "options": {
    "browserTarget": "cr:build:production",
    "serverTarget": "cr:server:production",
    "routes": ["/projects", "/projects/1"],
    "languages": ["en", "ar", "tr"],
		// where are the index files?
    "destination": "./host/index",
		// where in browser target do the locale scripts sit? 
    "localePath": "locale"
  }
}

Run ng run cr:prerender-multi creates language folders under the browser target, notice that I did not group them under a static folder as I did in Part II of this series, to simplify matters, and to be more realistic: we use Angular builder when we don’t have SSR in mind (otherwise use Express to prerender), to host on hosts like Netlify, which favors static files over routing rules. This is why it is important to place the static files directly under the public hosting folder (in our case, client folder).

anchorPrerendering the home index.html

A note about creating a prerendered version of the homepage itself, like en/index.html. If we generate a static index.html, things will work as expected, but there is a price to pay. All non static pages, will load the index.html first before it kicks off JavaScript to hydrate. That is bad!

  • For SEO, the server version is that static physical file of index, no matter what route is requested
  • For user experience. the site may flicker the static index before it reroutes.

In hosts like Netlify, or Firebase where we deliberately create the language sub folders, I would avoid generating the root statically.

// if you have this, avoid prerendering root
// in firebase host
"i18n": {
  "root": "/"
},
"rewrites": [
  {
    "source": "/ar{,/**}",
    "destination": "/ar/index.html"
  },
  {
    "source": "**",
    "destination": "/en/index.html"
  }
]

// in netlify
# [[redirects]]
 from = "/en/*"
 to = "/en/index.html"
 status = 200

But if we use index.[lang].html on root, and serve it as a rewrite, or as the case is with Surge /en/200.html file, serving a static root is not a problem.

// if you have this, prerendering root is ok
// in firebase host
"rewrites": [
  {
    "source": "/ar{,/**}",
    "destination": "/index.ar.html"
  },
  {
    "source": "**",
    "destination": "/index.en.html"
  }
]

// in netlify
[[redirects]]
  from = "/en/*"
  to = "/index.en.html"
  status = 200

I hope I covered all corners of this beast. If you have questions about creating a single build per multiple languages, hosting on different hosts, or prerendering for different languages, or you have an idea or suggestion, please hit the comment box, or the twitter link to let me know.

Let me know if there are other subjects in Angular you would like to see ripped apart 😁

  1. two years ago
    Prerender routes in Express server for Angular - Part I
  2. two years ago
    Prerendering in Angular - Part II
  3. two years ago
    Prerendering in Angular - Part III
  4. two years ago
    Prerendering in Angular - Part IV