Skip to content

Commit

Permalink
feat(i18n): add new i18n library and its documentation (#18)
Browse files Browse the repository at this point in the history
* feat: add i18n library

* feat(i18n): integration with basic example

* feat(i18n): add documentation and fix minor issues

* minor fixes

* fix types

* fix deps

* remove caching temporariliy
  • Loading branch information
wishrd authored Dec 21, 2024
1 parent 49b5e67 commit 55dbd13
Show file tree
Hide file tree
Showing 32 changed files with 582 additions and 15 deletions.
14 changes: 7 additions & 7 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,13 @@ jobs:
node-version-file: .nvmrc
cache: 'pnpm'

- name: Cache turbo build setup
uses: actions/cache@v4
with:
path: .turbo
key: ${{ runner.os }}-turbo-${{ github.sha }}
restore-keys: |
${{ runner.os }}-turbo-
# - name: Cache turbo build setup
# uses: actions/cache@v4
# with:
# path: .turbo
# key: ${{ runner.os }}-turbo-${{ github.sha }}
# restore-keys: |
# ${{ runner.os }}-turbo-

- name: Install dependencies
run: pnpm install
Expand Down
9 changes: 9 additions & 0 deletions apps/docs/astro.config.mjs
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
import svelte from '@astrojs/svelte';
import { i18n } from '@astro-tools/i18n';
import { reactiveTransferState } from '@astro-tools/reactive-transfer-state';
import { onClientDirective } from '@astro-tools/client-directives/on';
import { eventClientDirective } from '@astro-tools/client-directives/event';
import { clickClientDirective } from '@astro-tools/client-directives/click';
import { hoverClientDirective } from '@astro-tools/client-directives/hover';
import { timerClientDirective } from '@astro-tools/client-directives/timer';
import { viewportClientDirective } from '@astro-tools/client-directives/viewport';
import { i18nSchemaLoader } from './config/i18n-schema-loader.mjs';

export default defineConfig({
integrations: [
Expand All @@ -24,6 +26,10 @@ export default defineConfig({
{
label: 'State management',
autogenerate: { directory: 'state-management' },
},
{
label: 'Internationalization',
autogenerate: { directory: 'internationalization' },
}
],
customCss: [
Expand All @@ -32,6 +38,9 @@ export default defineConfig({
}),
svelte(),
reactiveTransferState(),
i18n({
loader: i18nSchemaLoader('./i18n/en-US.json'),
}),
onClientDirective({
directives: [
{ name: 'event', entrypoint: '@astro-tools/client-directives/event/directive' },
Expand Down
12 changes: 12 additions & 0 deletions apps/docs/config/i18n-schema-loader.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { readFile } from 'fs';

import { mapTranslations } from './map-translations.mjs';

export function i18nSchemaLoader(filePath) {
return () => new Promise((resolve, reject) => {
readFile(filePath, (err, data) => {
if (err) return reject(err);
resolve(mapTranslations(JSON.parse(data.toString())));
});
});
}
15 changes: 15 additions & 0 deletions apps/docs/config/map-translations.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
function getTranslations(propOrObj, stack = []) {
if (typeof propOrObj === 'string') {
return [{ key: stack.join('.'), value: propOrObj }];
}

const keys = Object.keys(propOrObj);
return keys.flatMap(key => getTranslations(propOrObj[key], stack.concat(key)));
}

export function mapTranslations(propOrObj) {
return getTranslations(propOrObj).reduce((obj, translation) => {
obj[translation.key] = translation.value;
return obj;
}, {});
}
11 changes: 11 additions & 0 deletions apps/docs/i18n/en-US.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"home": {
"title": "Sample page",
"description": "{companyName} sample page built by {ownerName}!"
},
"rating": {
"title": "Rate {library:string}!",
"stars": "This library has {stars:number} stars!",
"empty": "There are no votes yet!"
}
}
10 changes: 10 additions & 0 deletions apps/docs/i18n/es-ES.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"home": {
"title": "Página de ejemplo",
"description": "Página de ejemplo de {companyName} construida por {ownerName}"
},
"rating": {
"title": "Valora {library:string}!",
"stars": "Esta librería tiene {stars:number} estrellas!"
}
}
1 change: 1 addition & 0 deletions apps/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"@astrojs/svelte": "^6.0.2",
"@astro-tools/client-directives": "workspace:*",
"@astro-tools/docs-utils": "workspace:*",
"@astro-tools/i18n": "workspace:*",
"@astro-tools/reactive-transfer-state": "workspace:*",
"@astro-tools/transfer-state": "workspace:*",
"astro-integration-kit": "^0.17.0",
Expand Down
13 changes: 10 additions & 3 deletions apps/docs/src/content/docs/index.mdx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
---
title: Astro Tools
description: A set of packages that contains utilities to help you build Astro websites.
template: doc
description: A set of packages for building enterprise grade Astro websites.
template: splash
hero:
tagline: A set of packages that contains utilities to help you build Astro websites.
tagline: A set of packages for building enterprise grade Astro websites.
image:
file: ../../assets/houston.webp
actions:
Expand All @@ -15,3 +15,10 @@ hero:
icon: github
variant: minimal
---
import { LinkCard, CardGrid } from '@astrojs/starlight/components';

<CardGrid>
<LinkCard title="Client directives" description="More client directives for hydrating the Astro Islands as you never imagine!" href="/client-directives/on/"></LinkCard>
<LinkCard title="State management" description="Add state management to your Astro project, isolated per request and transferred to the client side!" href="/state-management/transfer-state/"></LinkCard>
<LinkCard title="Internationalization" description="Integrate runtime translations with type-checking when Astro i18n is not enough!" href="/internationalization/i18n/"></LinkCard>
</CardGrid>
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<script lang="ts">
import { Output } from '@astro-tools/docs-utils/components';
import { notifyHydration } from '@astro-tools/docs-utils/hydration/index.js';
import { t } from '@astro-tools:i18n';
import { onMount } from 'svelte';
export let id: string;
let count: number;
let hydrated = false;
onMount(() => {
count = 10;
hydrated = true;
notifyHydration(id);
});
</script>

<Output text={t('rating.title', { library: '@astro-tools/i18n' })} {hydrated} />
{#if count > 0}
<Output text={t('rating.stars', { stars: 5 })} {hydrated} />
{:else}
<!-- This translation is missing for es-ES -->
<Output text={t('rating.empty')} {hydrated} />
{/if}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
import { Output } from '@astro-tools/docs-utils/components';
import { t } from '@astro-tools:i18n';
import { useTranslations } from './use-translations';
import ClientSideExample from './ClientSideExample.svelte';
interface Props {
id: string;
}
const { id } = Astro.props;
await useTranslations('es-ES');
---
<Output text={t('home.title')}></Output>
<Output text={t('home.description', { ownerName: 'Doc', companyName: 'Astro Tools' })}></Output>
<br />
<button type="button" id="i18n-button">Click me to hydrate!</button>
<br />
<ClientSideExample {id} client:on="click #i18n-button" />
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { I18nTranslations } from '@astro-tools/i18n';

interface Translations {
[key: string]: Translations | string;
}

function getTranslations(propOrObj: Translations | string, stack: Array<string> = []): Array<{ key: string, value: string }> {
if (typeof propOrObj === 'string') {
return [{ key: stack.join('.'), value: propOrObj }];
}

const keys = Object.keys(propOrObj);
return keys.flatMap(key => getTranslations(propOrObj[key]!, stack.concat(key)));
}

export function mapTranslations(propOrObj: Translations): I18nTranslations {
return getTranslations(propOrObj).reduce<I18nTranslations>((obj, translation) => {
obj[translation.key] = translation.value;
return obj;
}, {});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { defineConfig } from 'astro/config';

import { i18n } from '@astro-tools/i18n';

import { i18nSchemaLoader } from '@/config/i18n-schema-loader.mjs';

export default defineConfig({
integrations: [
i18n({ loader: i18nSchemaLoader('./i18n/en-US.json') }),
],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { I18nTranslations } from '@astro-tools/i18n';

import { i18n } from '@astro-tools:i18n';

import { mapTranslations } from './map-translations';

async function translationsLoader(locale: string): Promise<I18nTranslations> {
switch (locale) {
case 'en-US':
return import('../../../../../../i18n/en-US.json')
.then(json => mapTranslations(json.default));
case 'es-ES':
return import('../../../../../../i18n/es-ES.json')
.then(json => mapTranslations(json.default));
default:
throw new Error(`Missing translations file for ${locale}`);
}
}

export async function useTranslations(locale: string, fallbackLocale?: string): Promise<void> {
await i18n({
locale,
fallbackLocale: fallbackLocale || 'en-US',
loader: translationsLoader,
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import typesRaw from '../../../../../.astro/integrations/_astro-tools_i18n/types.d.ts?raw';

export function getTranslateTypes(): string {
return typesRaw.split('\n').filter(line => line.includes('function t(')).join('\n');
}
129 changes: 129 additions & 0 deletions apps/docs/src/content/docs/internationalization/i18n.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
---
title: i18n
description: i18n integration for applications which have high number of locales and requires type-checking
sidebar:
order: 1
---
import { Code, Tabs, TabItem, Aside, Steps } from '@astrojs/starlight/components';

import { Example, ExamplePreview } from '@astro-tools/docs-utils/example';

import setupExampleRaw from './_examples/i18n/setup.ts?raw';
import i18nSchemaMapTranslationsRaw from '../../../../config/map-translations.mjs?raw';
import i18nSchemaLoaderRaw from '../../../../config/i18n-schema-loader.mjs?raw';
import enUSRaw from '../../../../i18n/en-US.json?raw';
import mapTranslationsRaw from './_examples/i18n/map-translations.ts?raw';
import useTranslationsRaw from './_examples/i18n/use-translations.ts?raw';
import i18nExampleRaw from './_examples/i18n/Example.astro?raw';
import i18nClientSideExampleRaw from './_examples/i18n/ClientSideExample.svelte?raw';

import I18nExample from './_examples/i18n/Example.astro';
import { getTranslateTypes } from './_helpers/get-translate-types';

The i18n integration provides runtime translations while keeps type-checking, preventing most of the runtime errors in this kind of libraries.

It is intended to be used for projects with a large number of languages or locales and websites with Astro Islands that require some translations.
For static or small projects, I'd recommend using the official [Astro i18n](https://docs.astro.build/en/guides/internationalization/) or any other existing type-safe libraries. In any case, give it a try!

## Format

The `key` of the translation can be any string in any format. The supported format for the translation is a `string` with interpolation values surrounded by `{}`, like the following example:
```text
Hello {name}!
```

Variables in the translations can be typed to `number` and `string` using the following format:
```
Hello {name:string}! click here to get {points:number} points!
```

<Aside type="note">We are improving the format to support plurals and more types!</Aside>

## Setup

For setting up the internationalization, include the integration in your Astro project:

<Steps>
1. Install the library and its dependencies using your preferred package manager:
```
npm i -D @astro-tools/i18n @astro-tools/transfer-state astro-integration-kit
```
2. Add the integration to your project configuration:
<Code title="astro.config.mjs" code={setupExampleRaw} lang="typescript" />
</Steps>

### Loader

To extract the typings properly, add the `loader` function which loads every translation from the main locale file following a specific model.

It is delegated to the project, allowing to store the translations in any format.

For example, you can use a loader like the referenced in the setup section, in Javascript or Typescript, depending on your configuration language:

<Example>
<Tabs>
<TabItem label="@/config/i18n-schema-loader.mjs">
<Code code={i18nSchemaLoaderRaw} lang="javascript" />
</TabItem>
<TabItem label="./map-translations.mjs">
<Code code={i18nSchemaMapTranslationsRaw} lang="javascript" />
</TabItem>
<TabItem label="./i18n/en-US.json">
<Code code={enUSRaw} lang="json" />
</TabItem>
</Tabs>
</Example>

The result is the overload functions of the function `t`, which can be used to render translations:
<Code title="types.d.ts" code={getTranslateTypes()} lang="typescript" />

## Use

The integration exposes the virtual module `@astro-tools:i18n` with the required functions for managing translations.
As we do for the integration, the loading of the translation files is delegated to the project.

### Configure

Before render any translation, configure the locale, fallback locale and the translation loader using the function `i18n()`:

```typescript
const esES = { title: '¡Hola mundo!' };
const enUS = { title: 'Hello world!' };

i18n({
locale: 'es-ES',
fallbackLocale: 'en-US',
loader: (locale) => locale === 'es-ES' ? esES : enUS,
});
```

It is recommended to create a wrapper with your own logic following DRY principle. For example, create a function `useTranslations`:

<Tabs>
<TabItem label="use-translations.ts">
<Code code={useTranslationsRaw} lang="typescript" />
</TabItem>
<TabItem label="map-translations.ts">
<Code code={mapTranslationsRaw} lang="typescript" />
</TabItem>
</Tabs>

### Translate

Then, for translating keys, just use the previous `useTranslations` function with the desired locale for the page being rendered and use the `t` function to render a translation.

If a translation does not exists in the configured `locale`, then the `fallbackLocale` will be used. If the translation still missing, the `key` will be rendered.

<Example>
<Tabs>
<TabItem label="Example.astro">
<Code code={i18nExampleRaw} lang="astro" />
</TabItem>
<TabItem label="ClientSideExample.svelte">
<Code code={i18nClientSideExampleRaw} lang="svelte" />
</TabItem>
</Tabs>
<ExamplePreview hydration="i18n-example">
<I18nExample id="i18n-example" />
</ExamplePreview>
</Example>
Loading

0 comments on commit 55dbd13

Please sign in to comment.