├── .vscode
├── mcp.json
└── settings.json
├── apps
└── example
│ ├── README.md
│ ├── src
│ ├── content
│ │ ├── files
│ │ │ ├── about.zh-CN.mdx
│ │ │ ├── projects.zh-CN.mdx
│ │ │ ├── about.de-CH.mdx
│ │ │ └── projects.de-CH.mdx
│ │ ├── folder
│ │ │ ├── zh-CN
│ │ │ │ ├── about.mdx
│ │ │ │ └── projects.mdx
│ │ │ └── de-CH
│ │ │ │ ├── about.mdx
│ │ │ │ └── projects.mdx
│ │ └── infile
│ │ │ ├── icon1.png
│ │ │ ├── icon2.png
│ │ │ ├── icon3.png
│ │ │ ├── main.yml
│ │ │ └── footer.yml
│ ├── translations
│ │ ├── zh-CN.json
│ │ └── extract.json
│ ├── site.config.ts
│ ├── layouts
│ │ └── Page.astro
│ ├── pages
│ │ ├── index.astro
│ │ ├── [...locale]
│ │ │ ├── [files]
│ │ │ │ ├── index.astro
│ │ │ │ └── [slug].astro
│ │ │ ├── [folder]
│ │ │ │ ├── index.astro
│ │ │ │ └── [slug].astro
│ │ │ └── astro-nanostores-i18n.astro
│ │ └── astro-loader-i18n.astro
│ └── content.config.ts
│ ├── .vscode
│ ├── extensions.json
│ └── launch.json
│ ├── .gitignore
│ ├── package.json
│ ├── tsconfig.json
│ ├── astro.config.ts
│ ├── project.json
│ └── public
│ └── favicon.svg
├── libs
├── astro-loader-i18n
│ ├── test
│ │ ├── __fixtures__
│ │ │ ├── config.ts
│ │ │ └── collections.ts
│ │ ├── collections
│ │ │ ├── create-i18n-collection.spec.ts
│ │ │ └── __snapshots__
│ │ │ │ └── create-i18n-collection.spec.ts.snap
│ │ ├── __mocks__
│ │ │ ├── logger.ts
│ │ │ ├── loader-context.ts
│ │ │ └── store.ts
│ │ ├── schemas
│ │ │ ├── i18n-content-schema.spec.ts
│ │ │ ├── i18n-loader-schema.spec.ts
│ │ │ └── __snapshots__
│ │ │ │ ├── i18n-loader-schema.spec.ts.snap
│ │ │ │ └── i18n-content-schema.spec.ts.snap
│ │ ├── loaders
│ │ │ ├── i18n-content-loader.spec.ts
│ │ │ ├── i18n-file-loader.spec.ts
│ │ │ ├── i18n-loader.spec.ts
│ │ │ └── __snapshots__
│ │ │ │ ├── i18n-loader.spec.ts.snap
│ │ │ │ ├── i18n-file-loader.spec.ts.snap
│ │ │ │ └── i18n-content-loader.spec.ts.snap
│ │ └── props-and-params
│ │ │ ├── i18n-props-and-params.spec.ts
│ │ │ └── __snapshots__
│ │ │ └── i18n-props-and-params.spec.ts.snap
│ ├── src
│ │ ├── vite-env.d.ts
│ │ ├── astro-loader-i18n.ts
│ │ ├── collections
│ │ │ └── create-i18n-collection.ts
│ │ ├── loaders
│ │ │ ├── i18n-content-loader.ts
│ │ │ ├── i18n-file-loader.ts
│ │ │ ├── i18n-loader.ts
│ │ │ └── create-content-loader.ts
│ │ ├── schemas
│ │ │ ├── i18n-content-schema.ts
│ │ │ └── i18n-loader-schema.ts
│ │ └── props-and-params
│ │ │ └── i18n-props-and-params.ts
│ ├── tsconfig.lib.json
│ ├── .gitignore
│ ├── tsconfig.json
│ ├── vite.config.ts
│ ├── project.json
│ ├── package.json
│ ├── CHANGELOG.md
│ └── README.md
├── astro-utils-i18n
│ ├── README.md
│ ├── tsconfig.lib.json
│ ├── src
│ │ ├── astro-utils-i18n.ts
│ │ └── utils
│ │ │ ├── collection.ts
│ │ │ ├── route.ts
│ │ │ └── path.ts
│ ├── test
│ │ └── utils
│ │ │ ├── __snapshots__
│ │ │ ├── route.spec.ts.snap
│ │ │ └── collection.spec.ts.snap
│ │ │ ├── route.spec.ts
│ │ │ ├── collection.spec.ts
│ │ │ └── path.spec.ts
│ ├── tsconfig.json
│ ├── vite.config.ts
│ ├── package.json
│ └── project.json
└── astro-nanostores-i18n
│ ├── src
│ ├── vite-env.d.ts
│ ├── virtual.d.ts
│ ├── middleware.ts
│ ├── runtime.ts
│ ├── integration.ts
│ └── bin
│ │ └── extract.ts
│ ├── tsconfig.lib.json
│ ├── test
│ ├── __snapshots__
│ │ ├── runtime.spec.ts.snap
│ │ └── integration.spec.ts.snap
│ ├── runtime.spec.ts
│ ├── middleware.spec.ts
│ ├── integration.spec.ts
│ └── bin
│ │ └── extract.spec.ts
│ ├── tsconfig.json
│ ├── project.json
│ ├── CHANGELOG.md
│ ├── vite.config.ts
│ ├── package.json
│ └── README.md
├── docs
├── 2025-05-15-infographic.png
├── 2025-05-15-astro-discord-showcase.md
├── 2025-05-15-astro-discord-feedback.md
└── 2025-07-23-astro-discord-showcase.md
├── pnpm-workspace.yaml
├── renovate.json
├── .gitignore
├── .prettierrc.json
├── .editorconfig
├── .changeset
└── config.json
├── vitest.config.ts
├── .devcontainer.json
├── tsconfig.base.json
├── eslint.config.mjs
├── nx.json
├── README.md
├── .github
├── workflows
│ ├── checks.yml
│ ├── registries.yml
│ ├── version.yml
│ └── packages.yml
└── instructions
│ └── nx.instructions.md
├── LICENSE
└── package.json
/.vscode/mcp.json:
--------------------------------------------------------------------------------
1 | {
2 | "servers": {}
3 | }
--------------------------------------------------------------------------------
/apps/example/README.md:
--------------------------------------------------------------------------------
1 | # astro-i18n examples
2 |
--------------------------------------------------------------------------------
/libs/astro-loader-i18n/test/__fixtures__/config.ts:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/libs/astro-utils-i18n/README.md:
--------------------------------------------------------------------------------
1 | # astro-utils-i18n
2 |
--------------------------------------------------------------------------------
/apps/example/src/content/files/about.zh-CN.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: 关于我
3 | ---
4 |
5 | 测试
6 |
--------------------------------------------------------------------------------
/apps/example/src/content/files/projects.zh-CN.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: 项目
3 | ---
4 |
5 | 测试
6 |
--------------------------------------------------------------------------------
/apps/example/src/content/folder/zh-CN/about.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: 关于我
3 | ---
4 |
5 | Test
6 |
--------------------------------------------------------------------------------
/apps/example/src/content/folder/zh-CN/projects.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: 项目
3 | ---
4 |
5 | 测试
6 |
--------------------------------------------------------------------------------
/apps/example/src/content/files/about.de-CH.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Über mich
3 | ---
4 |
5 | Test
6 |
--------------------------------------------------------------------------------
/apps/example/src/content/folder/de-CH/about.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Über mich
3 | ---
4 |
5 | Test
6 |
--------------------------------------------------------------------------------
/apps/example/src/content/folder/de-CH/projects.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Projekte
3 | ---
4 |
5 | 测试
6 |
--------------------------------------------------------------------------------
/apps/example/src/content/files/projects.de-CH.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Projekte
3 | ---
4 |
5 | Test
6 |
--------------------------------------------------------------------------------
/docs/2025-05-15-infographic.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/openscript/astro-i18n/HEAD/docs/2025-05-15-infographic.png
--------------------------------------------------------------------------------
/libs/astro-loader-i18n/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
--------------------------------------------------------------------------------
/libs/astro-nanostores-i18n/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - apps/*
3 | - libs/*
4 | onlyBuiltDependencies:
5 | - esbuild
6 | - nx
7 | - sharp
8 |
--------------------------------------------------------------------------------
/apps/example/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": ["astro-build.astro-vscode"],
3 | "unwantedRecommendations": []
4 | }
5 |
--------------------------------------------------------------------------------
/apps/example/src/content/infile/icon1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/openscript/astro-i18n/HEAD/apps/example/src/content/infile/icon1.png
--------------------------------------------------------------------------------
/apps/example/src/content/infile/icon2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/openscript/astro-i18n/HEAD/apps/example/src/content/infile/icon2.png
--------------------------------------------------------------------------------
/apps/example/src/content/infile/icon3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/openscript/astro-i18n/HEAD/apps/example/src/content/infile/icon3.png
--------------------------------------------------------------------------------
/apps/example/src/translations/zh-CN.json:
--------------------------------------------------------------------------------
1 | {
2 | "example": {
3 | "message": "某个消息。",
4 | "param": "带参数的消息:{irgendwas}"
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3 | "extends": [
4 | "config:recommended"
5 | ]
6 | }
7 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .pnpm-store
2 | node_modules
3 | dist
4 | coverage
5 | .nx/cache
6 | .nx/workspace-data
7 | vite.config.*.timestamp*
8 | vitest.config.*.timestamp*
9 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/prettierrc",
3 | "trailingComma": "es5",
4 | "tabWidth": 2,
5 | "semi": true,
6 | "singleQuote": false,
7 | "printWidth": 144
8 | }
9 |
--------------------------------------------------------------------------------
/libs/astro-loader-i18n/tsconfig.lib.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "declaration": true,
5 | "types": ["node", "vite/client"]
6 | },
7 | "include": ["src/**/*.ts"],
8 | "exclude": ["vite.config.ts", "test"]
9 | }
10 |
--------------------------------------------------------------------------------
/libs/astro-utils-i18n/tsconfig.lib.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "declaration": true,
5 | "types": ["node", "vite/client"]
6 | },
7 | "include": ["src/**/*.ts"],
8 | "exclude": ["vite.config.ts", "test"]
9 | }
10 |
--------------------------------------------------------------------------------
/libs/astro-nanostores-i18n/tsconfig.lib.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "declaration": true,
5 | "types": ["node", "vite/client"]
6 | },
7 | "include": ["src/**/*.ts"],
8 | "exclude": ["vite.config.ts", "test"]
9 | }
10 |
--------------------------------------------------------------------------------
/libs/astro-nanostores-i18n/test/__snapshots__/runtime.spec.ts.snap:
--------------------------------------------------------------------------------
1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2 |
3 | exports[`runtime.ts > should throw an error, if i18n was not initialized and used 1`] = `[Error: i18n not initialized. Call initializeI18n first.]`;
4 |
--------------------------------------------------------------------------------
/apps/example/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "0.2.0",
3 | "configurations": [
4 | {
5 | "command": "./node_modules/.bin/astro dev",
6 | "name": "Development server",
7 | "request": "launch",
8 | "type": "node-terminal"
9 | }
10 | ]
11 | }
12 |
--------------------------------------------------------------------------------
/apps/example/src/translations/extract.json:
--------------------------------------------------------------------------------
1 | {
2 | "example": {
3 | "message": "Irgend eine Nachricht.",
4 | "param": "Eine Nachricht mit einem Parameter: {irgendwas}",
5 | "count": {
6 | "one": "Ein Eintrag",
7 | "many": "{count} Einträge"
8 | }
9 | }
10 | }
--------------------------------------------------------------------------------
/libs/astro-utils-i18n/src/astro-utils-i18n.ts:
--------------------------------------------------------------------------------
1 | export { getAllUniqueKeys, pruneLocales } from "./utils/collection";
2 | export { joinPath, resolvePath, createContentPath, createTranslationId, parseLocale, trimRelativePath, trimSlashes } from "./utils/path";
3 | export { type SegmentTranslations, parseRoutePattern, buildPath } from "./utils/route";
4 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # https://EditorConfig.org
2 |
3 | # top-most EditorConfig file
4 | root = true
5 |
6 | [*]
7 | charset = utf-8
8 | end_of_line = lf
9 | indent_size = 2
10 | indent_style = space
11 | insert_final_newline = true
12 | trim_trailing_whitespace = false
13 |
14 | [{.*,*.md,*.json,*.toml,*.yml,*.json5}]
15 | indent_style = space
16 |
--------------------------------------------------------------------------------
/apps/example/src/site.config.ts:
--------------------------------------------------------------------------------
1 | export const C = {
2 | LOCALES: ["de-CH", "zh-CN"],
3 | DEFAULT_LOCALE: "de-CH" as const,
4 | SEGMENT_TRANSLATIONS: {
5 | "de-CH": {
6 | files: "dateien",
7 | folder: "ordner",
8 | },
9 | "zh-CN": {
10 | files: "files",
11 | folder: "folder",
12 | },
13 | },
14 | };
15 |
--------------------------------------------------------------------------------
/apps/example/src/layouts/Page.astro:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | astro-loader-i18n: example
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/apps/example/.gitignore:
--------------------------------------------------------------------------------
1 | # build output
2 | dist/
3 |
4 | # generated types
5 | .astro/
6 |
7 | # dependencies
8 | node_modules/
9 |
10 | # logs
11 | npm-debug.log*
12 | yarn-debug.log*
13 | yarn-error.log*
14 | pnpm-debug.log*
15 |
16 | # environment variables
17 | .env
18 | .env.production
19 |
20 | # macOS-specific files
21 | .DS_Store
22 |
23 | # jetbrains setting folder
24 | .idea/
25 |
--------------------------------------------------------------------------------
/apps/example/src/pages/index.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import Page from "../layouts/Page.astro";
3 | ---
4 |
5 |
6 | astro-i18n examples
7 | Examples:
8 |
16 |
17 |
--------------------------------------------------------------------------------
/libs/astro-loader-i18n/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/.changeset/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
3 | "changelog": "@changesets/cli/changelog",
4 | "commit": false,
5 | "fixed": [],
6 | "linked": [],
7 | "access": "restricted",
8 | "baseBranch": "main",
9 | "updateInternalDependencies": "patch",
10 | "ignore": [],
11 | "privatePackages": { "tag": false, "version": false }
12 | }
13 |
--------------------------------------------------------------------------------
/apps/example/src/content/infile/main.yml:
--------------------------------------------------------------------------------
1 | navigation:
2 | de-CH:
3 | - path: /projekte
4 | title: Projekte
5 | icon: ./icon1.png
6 | - path: /ueber-mich
7 | title: Über mich
8 | icon: ./icon1.png
9 | zh-CN:
10 | - path: /zh/projects
11 | title: 项目
12 | icon: ./icon1.png
13 | - path: /zh/about-me
14 | title: 关于我
15 | icon: ./icon1.png
16 |
--------------------------------------------------------------------------------
/apps/example/src/content/infile/footer.yml:
--------------------------------------------------------------------------------
1 | navigation:
2 | de-CH:
3 | - path: /impressum
4 | title: Impressum
5 | icon: ./icon1.png
6 | - path: /datenschutz
7 | title: Datenschutz
8 | icon: ./icon2.png
9 | zh-CN:
10 | - path: /zh/imprint
11 | title: 印记
12 | icon: ./icon3.png
13 | - path: /zh/data-protection
14 | title: 数据保护
15 | icon: ./icon3.png
16 |
--------------------------------------------------------------------------------
/apps/example/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "example",
3 | "type": "module",
4 | "private": true,
5 | "dependencies": {
6 | "@astrojs/mdx": "^4.3.12",
7 | "@nanostores/i18n": "^1.2.2",
8 | "astro": "^5.16.4",
9 | "astro-loader-i18n": "workspace:*",
10 | "astro-nanostores-i18n": "workspace:*",
11 | "limax": "^4.2.1",
12 | "sharp": "^0.34.5"
13 | },
14 | "scripts": {
15 | "i18n:extract": "extract-messages"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/apps/example/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | "../../tsconfig.base.json",
4 | "astro/tsconfigs/strict"
5 | ],
6 | "include": [
7 | ".astro/types.d.ts",
8 | "**/*",
9 | ],
10 | "exclude": [
11 | "dist"
12 | ],
13 | "compilerOptions": {
14 | "types": [
15 | "astro/client",
16 | ],
17 | "paths": {
18 | "astro-loader-i18n": [
19 | "../../libs/astro-loader-i18n/src"
20 | ],
21 | }
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/libs/astro-loader-i18n/test/collections/create-i18n-collection.spec.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from "vitest";
2 | import { createI18nCollection } from "../../src/collections/create-i18n-collection";
3 |
4 | describe("createI18nCollection", () => {
5 | it("should create a collection based on the provided locales and route pattern", () => {
6 | const locales = ["en", "de"];
7 | const routePattern = "/[...locale]/blog/[slug]";
8 | expect(createI18nCollection({ locales, routePattern })).toMatchSnapshot();
9 | });
10 | });
11 |
--------------------------------------------------------------------------------
/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vitest/config";
2 | import { nxViteTsPaths } from "@nx/vite/plugins/nx-tsconfig-paths.plugin";
3 |
4 | export default defineConfig({
5 | plugins: [nxViteTsPaths()],
6 | test: {
7 | globals: true,
8 | environment: "node",
9 | coverage: {
10 | reporter: ["text", "json", "html", "lcov"],
11 | exclude: ["node_modules/", "**/test/**", "**/*.spec.ts", "**/*.test.ts"],
12 | },
13 | reporters: ["verbose"],
14 | include: ["test/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
15 | },
16 | });
17 |
--------------------------------------------------------------------------------
/.devcontainer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "astro-i18n",
3 | "image": "mcr.microsoft.com/devcontainers/typescript-node:24-bookworm",
4 | "customizations": {
5 | "vscode": {
6 | "extensions": [
7 | "yzhang.markdown-all-in-one",
8 | "astro-build.astro-vscode",
9 | "dbaeumer.vscode-eslint",
10 | "redhat.vscode-yaml",
11 | "vitest.explorer",
12 | "unifiedjs.vscode-mdx"
13 | ]
14 | }
15 | },
16 | "forwardPorts": [
17 | 4321
18 | ],
19 | "containerEnv": {
20 | "ASTRO_TELEMETRY_DISABLED": "1"
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/libs/astro-nanostores-i18n/src/virtual.d.ts:
--------------------------------------------------------------------------------
1 | declare module "astro-nanostores-i18n:runtime" {
2 | import type { Components, Translations } from "@nanostores/i18n";
3 | export declare const currentLocale: import("nanostores").PreinitializedWritableAtom & object;
4 | export declare const initializeI18n: (defaultLocale: string, translations: Record) => void;
5 | export declare const useFormat: () => import("@nanostores/i18n").Formatter;
6 | export declare const useI18n: (componentName: string, baseTranslations: Body) => Body;
7 | }
8 |
--------------------------------------------------------------------------------
/libs/astro-loader-i18n/test/__mocks__/logger.ts:
--------------------------------------------------------------------------------
1 | import type { AstroIntegrationLogger } from "astro";
2 | import { vi } from "vitest";
3 |
4 | export class Logger implements AstroIntegrationLogger {
5 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
6 | public options!: any;
7 | public label = "mock";
8 |
9 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
10 | public fork(_label: string): AstroIntegrationLogger {
11 | return this;
12 | }
13 | public info = vi.fn();
14 | public warn = vi.fn();
15 | public error = vi.fn();
16 | public debug = vi.fn();
17 | }
18 |
--------------------------------------------------------------------------------
/libs/astro-utils-i18n/test/utils/__snapshots__/route.spec.ts.snap:
--------------------------------------------------------------------------------
1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2 |
3 | exports[`buildPath > should build valid paths 1`] = `"/blog/bli/bla/blub/comments/2"`;
4 |
5 | exports[`buildPath > should throw if is filled with a path and param is not spread 1`] = `[Error: The segment value "bli/bla/blub" for route segment "slug" contains a slash. Did you forget to add "..." to the route pattern?]`;
6 |
7 | exports[`buildPath > should throw if no segment matches a param 1`] = `[Error: No segment value found for route segment "slug". Did you forget to provide it?]`;
8 |
--------------------------------------------------------------------------------
/libs/astro-nanostores-i18n/src/middleware.ts:
--------------------------------------------------------------------------------
1 | import { defineMiddleware } from "astro:middleware";
2 | import { i18n } from "astro:config/client";
3 | import { parseLocale } from "astro-utils-i18n";
4 | import { currentLocale } from "astro-nanostores-i18n:runtime";
5 |
6 | const locales = i18n?.locales.flatMap((locale) => (typeof locale === "string" ? locale : locale.codes)) || [];
7 | const defaultLocale = i18n?.defaultLocale || "";
8 |
9 | export const onRequest = defineMiddleware(async (context, next) => {
10 | const locale = parseLocale(context.url.pathname, locales, defaultLocale);
11 | currentLocale.set(locale);
12 | return next();
13 | });
14 |
--------------------------------------------------------------------------------
/tsconfig.base.json:
--------------------------------------------------------------------------------
1 | {
2 | "compileOnSave": false,
3 | "compilerOptions": {
4 | "sourceMap": true,
5 | "declaration": false,
6 | "moduleResolution": "node",
7 | "emitDecoratorMetadata": true,
8 | "experimentalDecorators": true,
9 | "importHelpers": true,
10 | "module": "esnext",
11 | "target": "ES2020",
12 | "lib": [
13 | "ES2020"
14 | ],
15 | "skipLibCheck": true,
16 | "skipDefaultLibCheck": true,
17 | "paths": {
18 | "astro-loader-i18n": [
19 | "./libs/astro-loader-i18n"
20 | ]
21 | }
22 | },
23 | "exclude": [
24 | "node_modules",
25 | "tmp"
26 | ]
27 | }
28 |
--------------------------------------------------------------------------------
/libs/astro-loader-i18n/test/collections/__snapshots__/create-i18n-collection.spec.ts.snap:
--------------------------------------------------------------------------------
1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2 |
3 | exports[`createI18nCollection > should create a collection based on the provided locales and route pattern 1`] = `
4 | [
5 | {
6 | "data": {
7 | "basePath": "",
8 | "contentPath": "",
9 | "locale": "en",
10 | "translationId": "/[...locale]/blog/[slug]",
11 | },
12 | },
13 | {
14 | "data": {
15 | "basePath": "",
16 | "contentPath": "",
17 | "locale": "de",
18 | "translationId": "/[...locale]/blog/[slug]",
19 | },
20 | },
21 | ]
22 | `;
23 |
--------------------------------------------------------------------------------
/apps/example/astro.config.ts:
--------------------------------------------------------------------------------
1 | // @ts-check
2 | import mdx from "@astrojs/mdx";
3 | import { defineConfig } from "astro/config";
4 | import { C } from "./src/site.config";
5 | import nanostoresI18n from "astro-nanostores-i18n";
6 | import zhCN from "./src/translations/zh-CN.json";
7 |
8 | // https://astro.build/config
9 | export default defineConfig({
10 | server: {
11 | host: true,
12 | },
13 | i18n: {
14 | defaultLocale: C.DEFAULT_LOCALE,
15 | locales: C.LOCALES,
16 | },
17 | integrations: [
18 | mdx(),
19 | nanostoresI18n({
20 | translations: {
21 | "zh-CN": zhCN,
22 | },
23 | addMiddleware: true,
24 | }),
25 | ],
26 | });
27 |
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import globals from "globals";
2 | import pluginJs from "@eslint/js";
3 | import tseslint from "typescript-eslint";
4 | import pluginPrettierRecommended from "eslint-plugin-prettier/recommended";
5 |
6 | export default [
7 | {
8 | ignores: ["**/vite.config.*.timestamp*", "**/vitest.config.*.timestamp*", "**/dist/**", "**/.astro/**"],
9 | },
10 | { files: ["**/*.{js,mjs,cjs,ts}"] },
11 | { languageOptions: { globals: globals.node } },
12 | pluginJs.configs.recommended,
13 | ...tseslint.configs.recommended,
14 | pluginPrettierRecommended,
15 | {
16 | rules: {
17 | "no-console": ["warn", { allow: ["warn", "error"] }],
18 | },
19 | },
20 | ];
21 |
--------------------------------------------------------------------------------
/apps/example/project.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "../../node_modules/nx/schemas/project-schema.json",
3 | "name": "example",
4 | "sourceRoot": "apps/example/src",
5 | "projectType": "application",
6 | "targets": {
7 | "dev": {
8 | "executor": "nx:run-commands",
9 | "options": {
10 | "command": "astro dev",
11 | "cwd": "apps/example"
12 | },
13 | "dependsOn": [
14 | {
15 | "target": "build",
16 | "projects": "libs/astro-loader-i18n"
17 | }
18 | ]
19 | },
20 | "build": {
21 | "executor": "nx:run-commands",
22 | "options": {
23 | "command": "astro build",
24 | "cwd": "apps/example"
25 | }
26 | }
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/libs/astro-loader-i18n/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.base.json",
3 | "compilerOptions": {
4 | "target": "ES2020",
5 | "useDefineForClassFields": true,
6 | "module": "ESNext",
7 | "lib": ["ES2020"],
8 | "skipLibCheck": true,
9 |
10 | /* Bundler mode */
11 | "moduleResolution": "bundler",
12 | "allowImportingTsExtensions": true,
13 | "isolatedModules": true,
14 | "moduleDetection": "force",
15 | "noEmit": true,
16 |
17 | /* Linting */
18 | "strict": true,
19 | "noUnusedLocals": true,
20 | "noUnusedParameters": true,
21 | "noFallthroughCasesInSwitch": true,
22 | "noUncheckedSideEffectImports": true,
23 |
24 | "types": ["astro/client"],
25 | },
26 | "include": ["src", "test"]
27 | }
28 |
--------------------------------------------------------------------------------
/libs/astro-utils-i18n/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.base.json",
3 | "compilerOptions": {
4 | "target": "es2022",
5 | "useDefineForClassFields": true,
6 | "module": "esnext",
7 | "lib": ["es2022"],
8 | "skipLibCheck": true,
9 |
10 | /* Bundler mode */
11 | "moduleResolution": "bundler",
12 | "allowImportingTsExtensions": true,
13 | "isolatedModules": true,
14 | "moduleDetection": "force",
15 | "noEmit": true,
16 |
17 | /* Linting */
18 | "strict": true,
19 | "noUnusedLocals": true,
20 | "noUnusedParameters": true,
21 | "noFallthroughCasesInSwitch": true,
22 | "noUncheckedSideEffectImports": true,
23 |
24 | "types": ["astro/client"]
25 | },
26 | "include": ["src", "test"]
27 | }
28 |
--------------------------------------------------------------------------------
/libs/astro-nanostores-i18n/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.base.json",
3 | "compilerOptions": {
4 | "target": "es2022",
5 | "useDefineForClassFields": true,
6 | "module": "esnext",
7 | "lib": ["es2022"],
8 | "skipLibCheck": true,
9 |
10 | /* Bundler mode */
11 | "moduleResolution": "bundler",
12 | "allowImportingTsExtensions": true,
13 | "isolatedModules": true,
14 | "moduleDetection": "force",
15 | "noEmit": true,
16 |
17 | /* Linting */
18 | "strict": true,
19 | "noUnusedLocals": true,
20 | "noUnusedParameters": true,
21 | "noFallthroughCasesInSwitch": true,
22 | "noUncheckedSideEffectImports": true,
23 |
24 | "types": ["astro/client"]
25 | },
26 | "include": ["src", "test"]
27 | }
28 |
--------------------------------------------------------------------------------
/libs/astro-loader-i18n/src/astro-loader-i18n.ts:
--------------------------------------------------------------------------------
1 | export { i18nContentLoader } from "./loaders/i18n-content-loader";
2 | export { i18nFileLoader } from "./loaders/i18n-file-loader";
3 | export { i18nLoader } from "./loaders/i18n-loader";
4 | export { localized } from "./schemas/i18n-content-schema";
5 | export { extendI18nLoaderSchema, i18nLoaderSchema } from "./schemas/i18n-loader-schema";
6 | export { i18nPropsAndParams, i18nProps } from "./props-and-params/i18n-props-and-params";
7 | export { createI18nCollection } from "./collections/create-i18n-collection";
8 |
9 | import { resolvePath as _resolvePath } from "astro-utils-i18n";
10 |
11 | type resolvePathType = (...paths: Array) => string;
12 |
13 | export const resolvePath: resolvePathType = _resolvePath;
14 |
--------------------------------------------------------------------------------
/nx.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "./node_modules/nx/schemas/nx-schema.json",
3 | "extends": "nx/presets/npm.json",
4 | "defaultBase": "main",
5 | "workspaceLayout": {
6 | "appsDir": "apps",
7 | "libsDir": "libs"
8 | },
9 | "targetDefaults": {
10 | "build": {
11 | "dependsOn": [
12 | "^build"
13 | ],
14 | "cache": true
15 | },
16 | "dev": {
17 | "cache": true
18 | },
19 | "lint": {
20 | "cache": true,
21 | "inputs": [
22 | "default",
23 | "{workspaceRoot}/eslint.config.js"
24 | ]
25 | },
26 | "test": {
27 | "cache": true,
28 | "inputs": [
29 | "default",
30 | "{workspaceRoot}/vitest.config.ts"
31 | ]
32 | }
33 | },
34 | "useDaemonProcess": false
35 | }
36 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "typescript.tsdk": "node_modules/typescript/lib",
3 | "nxConsole.generateAiAgentRules": true,
4 | "editor.defaultFormatter": "dbaeumer.vscode-eslint",
5 | "editor.formatOnSave": true,
6 | "[typescript]": {
7 | "editor.defaultFormatter": "vscode.typescript-language-features"
8 | },
9 | "[javascript]": {
10 | "editor.defaultFormatter": "dbaeumer.vscode-eslint"
11 | },
12 | "[javascriptreact]": {
13 | "editor.defaultFormatter": "dbaeumer.vscode-eslint"
14 | },
15 | "[typescriptreact]": {
16 | "editor.defaultFormatter": "dbaeumer.vscode-eslint"
17 | },
18 | "[json]": {
19 | "editor.defaultFormatter": "dbaeumer.vscode-eslint"
20 | },
21 | "[jsonc]": {
22 | "editor.defaultFormatter": "dbaeumer.vscode-eslint"
23 | },
24 | }
25 |
--------------------------------------------------------------------------------
/apps/example/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
9 |
10 |
--------------------------------------------------------------------------------
/libs/astro-loader-i18n/test/schemas/i18n-content-schema.spec.ts:
--------------------------------------------------------------------------------
1 | import { expect, describe, it } from "vitest";
2 | import { localized } from "../../src/astro-loader-i18n";
3 | import { z } from "astro/zod";
4 |
5 | describe("i18nContentSchema", () => {
6 | it("should extend the schema", async () => {
7 | const schema = localized(z.string(), ["en", "fr", "de"] as const);
8 | expect(JSON.stringify(schema)).toMatchSnapshot();
9 | expect(schema.options[0].shape).toMatchSnapshot();
10 | });
11 | it("should extend the schema and make partial if option is set", async () => {
12 | const schema = localized(z.string(), ["en", "fr", "de"] as const, true);
13 | expect(JSON.stringify(schema)).toMatchSnapshot();
14 | expect(schema.options[0].shape).toMatchSnapshot();
15 | });
16 | });
17 |
--------------------------------------------------------------------------------
/libs/astro-loader-i18n/src/collections/create-i18n-collection.ts:
--------------------------------------------------------------------------------
1 | import { I18nLoaderEntry } from "../schemas/i18n-loader-schema";
2 |
3 | type Options = {
4 | locales: string[];
5 | routePattern: string;
6 | basePath?: string;
7 | };
8 |
9 | /**
10 | * Creates an internationalization (i18n) collection based on the provided options.
11 | *
12 | * @param options - Configuration options for the i18n collection.
13 | * @returns An array of objects representing the i18n collection for each locale.
14 | */
15 | export function createI18nCollection(options: Options): I18nLoaderEntry[] {
16 | const { locales, routePattern, basePath } = options;
17 |
18 | return locales.map((locale) => ({ data: { locale: locale, translationId: routePattern, contentPath: "", basePath: basePath || "" } }));
19 | }
20 |
--------------------------------------------------------------------------------
/libs/astro-loader-i18n/test/schemas/i18n-loader-schema.spec.ts:
--------------------------------------------------------------------------------
1 | import { expect, describe, it } from "vitest";
2 | import { extendI18nLoaderSchema } from "../../src/astro-loader-i18n";
3 | import { z } from "astro/zod";
4 | import { checkI18nLoaderCollection } from "../../src/schemas/i18n-loader-schema";
5 |
6 | describe("i18nLoaderSchema", () => {
7 | it("should extend the schema", () => {
8 | const schema = extendI18nLoaderSchema(z.object({ title: z.string() }));
9 | expect(JSON.stringify(schema.shape)).toMatchSnapshot();
10 | });
11 | it("should throw an error when checkI18nLoaderCollection fails", () => {
12 | const invalidData = [
13 | { data: { translationId: "1", locale: "en", contentPath: "" } },
14 | { data: { translationId: "2", locale: "fr" } },
15 | { data: { translationId: "3" } },
16 | ];
17 |
18 | expect(() => checkI18nLoaderCollection(invalidData)).toThrowErrorMatchingSnapshot();
19 | });
20 | });
21 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # astro-i18n
2 |
3 |
4 | [](https://codecov.io/github/openscript/astro-i18n)
5 |
6 | This monorepo contains tools to help you with i18n in Astro projects.
7 |
8 | ## Packages
9 |
10 | ### Public packages
11 |
12 | - [astro-loader-i18n](libs/astro-loader-i18n) is a **content loader** for internationalized content in [Astro](https://astro.build). It builds on top of Astro’s [`glob()` loader](https://docs.astro.build/en/reference/content-loader-reference/#glob-loader) and helps manage translations by detecting locales, mapping content, and enriching `getStaticPaths`.
13 | - [astro-nanostores-i18n](libs/astro-nanostores-i18n) is an **integration** of [@nanostores/i18n](https://github.com/nanostores/i18n) into [Astro](https://astro.build/).
14 |
15 | ### Private packages
16 |
17 | - [astro-utils-i18n](libs/astro-utils-i18n): A set of utilities for i18n in Astro projects. It provides functions to manage translations, extract messages, and more.
18 |
--------------------------------------------------------------------------------
/apps/example/src/pages/[...locale]/[files]/index.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import { createI18nCollection, i18nPropsAndParams } from "astro-loader-i18n";
3 | import { currentLocale } from "astro-nanostores-i18n:runtime";
4 | import { C } from "../../../site.config";
5 | import Page from "../../../layouts/Page.astro";
6 |
7 | export const getStaticPaths = async () => {
8 | const routePattern = "[...locale]/[files]";
9 | const collection = createI18nCollection({ locales: C.LOCALES, routePattern });
10 |
11 | return i18nPropsAndParams(collection, {
12 | defaultLocale: C.DEFAULT_LOCALE,
13 | routePattern,
14 | segmentTranslations: C.SEGMENT_TRANSLATIONS,
15 | });
16 | };
17 |
18 | const params = Astro.params;
19 | const props = Astro.props;
20 | ---
21 |
22 |
23 | Current locale: {currentLocale.get()}
24 |
25 | Params
26 |
27 | {JSON.stringify(params, null, 2)}
28 |
29 | Props
30 |
31 | {JSON.stringify(props, null, 2)}
32 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/apps/example/src/pages/[...locale]/[folder]/index.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import { createI18nCollection, i18nPropsAndParams } from "astro-loader-i18n";
3 | import { currentLocale } from "astro-nanostores-i18n:runtime";
4 | import { C } from "../../../site.config";
5 | import Page from "../../../layouts/Page.astro";
6 |
7 | export const getStaticPaths = async () => {
8 | const routePattern = "[...locale]/[folder]";
9 | const collection = createI18nCollection({ locales: C.LOCALES, routePattern });
10 |
11 | return i18nPropsAndParams(collection, {
12 | defaultLocale: C.DEFAULT_LOCALE,
13 | routePattern,
14 | segmentTranslations: C.SEGMENT_TRANSLATIONS,
15 | });
16 | };
17 |
18 | const params = Astro.params;
19 | const props = Astro.props;
20 | ---
21 |
22 |
23 | Current locale: {currentLocale.get()}
24 |
25 | Params
26 |
27 | {JSON.stringify(params, null, 2)}
28 |
29 | Props
30 |
31 | {JSON.stringify(props, null, 2)}
32 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/apps/example/src/pages/[...locale]/astro-nanostores-i18n.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import Page from "../../layouts/Page.astro";
3 | import { useI18n, useFormat } from "astro-nanostores-i18n:runtime";
4 | import { count, params } from "@nanostores/i18n";
5 |
6 | export const getStaticPaths = async () => {
7 | return [{ params: { locale: undefined } }, { params: { locale: "zh-CN" } }];
8 | };
9 |
10 | const messages = useI18n("example", {
11 | message: "Irgend eine Nachricht.",
12 | param: params("Eine Nachricht mit einem Parameter: {irgendwas}"),
13 | count: count({
14 | one: "Ein Eintrag",
15 | many: "{count} Einträge",
16 | }),
17 | });
18 |
19 | const format = useFormat();
20 | ---
21 |
22 |
23 | astro-nanostores-i18n
24 |
25 |
26 | 中文
27 |
28 |
29 | Deutsch
30 |
31 |
32 | {messages.message}
33 | {messages.param({ irgendwas: "Something" })}
34 | {format.time(new Date())}
35 |
36 |
--------------------------------------------------------------------------------
/docs/2025-05-15-astro-discord-showcase.md:
--------------------------------------------------------------------------------
1 | ## astro-loader-i18n 🗺
2 |
3 | I'm excited to share what I've been working on for the past few weeks: an Astro loader that simplifies the process of managing internationalization (i18n) in your Astro projects. This loader allows you to easily **parse locales from file names and folder structures based on glob patterns**, making it a breeze to **handle multiple languages** when your content is based on files.
4 |
5 | It is the first time I feel like to have something to contribute to the Astro community, and I hope you find it useful! 🙌
6 |
7 | If you have any questions or feedback, please feel free to reach out. I'm always open to suggestions and improvements. You can find the project on [GitHub](https://github.com/openscript/astro-loader-i18n). Disclaimer: I'm better at writing code than designing websites or infographics 😅
8 |
9 | There are already a few projects around using it, and I would love to see more! If you want to see it in action, check out my [Astro Theme International](https://github.com/openscript/astro-theme-international).
10 |
--------------------------------------------------------------------------------
/libs/astro-loader-i18n/src/loaders/i18n-content-loader.ts:
--------------------------------------------------------------------------------
1 | import { glob, Loader } from "astro/loaders";
2 | import { createContentLoader } from "./create-content-loader";
3 |
4 | type GlobOptions = Parameters[0];
5 |
6 | /**
7 | * A loader function for handling internationalization (i18n) content in an Astro project.
8 | * This loader processes files matching the specified glob pattern, associates them with locales,
9 | * and augments their data with i18n-specific metadata such as locale, translation ID, and content path.
10 | *
11 | * @param options - Configuration options for the glob pattern to match files.
12 | * @returns A loader object with a custom `load` method for processing i18n content.
13 | *
14 | * @throws Will throw an error if the `i18n` configuration is missing in the Astro config.
15 | */
16 | export function i18nContentLoader(options: GlobOptions): Loader {
17 | const globLoader = glob(options);
18 | const load = createContentLoader(globLoader, options.base);
19 | return {
20 | name: "i18n-content-loader",
21 | load,
22 | };
23 | }
24 |
--------------------------------------------------------------------------------
/libs/astro-loader-i18n/test/__mocks__/loader-context.ts:
--------------------------------------------------------------------------------
1 | import type { LoaderContext } from "astro/loaders";
2 | import { vi } from "vitest";
3 | import { Logger } from "./logger";
4 | import { Store } from "./store";
5 |
6 | export function createLoaderContext(context?: Partial): LoaderContext {
7 | return {
8 | collection: "testCollection",
9 | generateDigest: vi.fn().mockReturnValue("digest"),
10 | logger: new Logger(),
11 | parseData: async (props) => props.data,
12 | store: new Store(),
13 | meta: new Map(),
14 | config: {
15 | base: "/",
16 | root: new URL("file://"),
17 | srcDir: new URL("file://src/"),
18 | legacy: {
19 | collections: false,
20 | },
21 | i18n: {
22 | defaultLocale: "zh-CN",
23 | locales: ["de-CH", "en-US", "zh-CN"],
24 | routing: "manual",
25 | },
26 | } satisfies Partial as unknown as LoaderContext["config"],
27 | ...context,
28 | } satisfies Partial as unknown as LoaderContext;
29 | }
30 |
--------------------------------------------------------------------------------
/apps/example/src/pages/[...locale]/[files]/[slug].astro:
--------------------------------------------------------------------------------
1 | ---
2 | import { getCollection } from "astro:content";
3 | import { i18nPropsAndParams } from "astro-loader-i18n";
4 | import { C } from "../../../site.config";
5 | import Page from "../../../layouts/Page.astro";
6 | import sluggify from "limax";
7 |
8 | export const getStaticPaths = async () => {
9 | const routePattern = "[...locale]/[files]/[slug]";
10 | const collection = await getCollection("files");
11 |
12 | return i18nPropsAndParams(collection, {
13 | defaultLocale: C.DEFAULT_LOCALE,
14 | routePattern,
15 | segmentTranslations: C.SEGMENT_TRANSLATIONS,
16 | generateSegments: (entry) => ({ slug: sluggify(entry.data.title) }),
17 | });
18 | };
19 |
20 | const params = Astro.params;
21 | const props = Astro.props;
22 | ---
23 |
24 |
25 |
26 | Params
27 |
28 |
29 | {JSON.stringify(params, null, 2)}
30 |
31 |
32 | Props
33 |
34 |
35 | {JSON.stringify(props, null, 2)}
36 |
37 |
38 |
39 |
40 |
--------------------------------------------------------------------------------
/.github/workflows/checks.yml:
--------------------------------------------------------------------------------
1 | name: Checks
2 |
3 | on:
4 | pull_request:
5 | types:
6 | - opened
7 | - reopened
8 | - synchronize
9 | branches:
10 | - main
11 | push:
12 | branches:
13 | - main
14 |
15 | jobs:
16 | version:
17 | name: Check
18 | runs-on: ubuntu-latest
19 | steps:
20 | - name: Checkout
21 | uses: actions/checkout@v6
22 |
23 | - name: Setup pnpm
24 | uses: pnpm/action-setup@v4
25 |
26 | - name: Setup Node
27 | uses: actions/setup-node@v6
28 | with:
29 | node-version: 24
30 | registry-url: https://registry.npmjs.org
31 | cache: pnpm
32 |
33 | - name: Install dependencies
34 | run: pnpm install --frozen-lockfile
35 |
36 | - name: Build
37 | run: pnpm build
38 |
39 | - name: Lint
40 | run: pnpm lint
41 |
42 | - name: Test
43 | run: pnpm test
44 |
45 | - name: Submit coverage
46 | uses: codecov/codecov-action@v5
47 | env:
48 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
49 |
--------------------------------------------------------------------------------
/apps/example/src/pages/[...locale]/[folder]/[slug].astro:
--------------------------------------------------------------------------------
1 | ---
2 | import { getCollection } from "astro:content";
3 | import { i18nPropsAndParams } from "astro-loader-i18n";
4 | import { C } from "../../../site.config";
5 | import Page from "../../../layouts/Page.astro";
6 | import sluggify from "limax";
7 |
8 | export const getStaticPaths = async () => {
9 | const routePattern = "[...locale]/[folder]/[slug]";
10 | const collection = await getCollection("files");
11 |
12 | return i18nPropsAndParams(collection, {
13 | defaultLocale: C.DEFAULT_LOCALE,
14 | routePattern,
15 | segmentTranslations: C.SEGMENT_TRANSLATIONS,
16 | generateSegments: (entry) => ({ slug: sluggify(entry.data.title) }),
17 | });
18 | };
19 |
20 | const params = Astro.params;
21 | const props = Astro.props;
22 | ---
23 |
24 |
25 |
26 | Params
27 |
28 |
29 | {JSON.stringify(params, null, 2)}
30 |
31 |
32 | Props
33 |
34 |
35 | {JSON.stringify(props, null, 2)}
36 |
37 |
38 |
39 |
40 |
--------------------------------------------------------------------------------
/libs/astro-loader-i18n/src/loaders/i18n-file-loader.ts:
--------------------------------------------------------------------------------
1 | import { file, Loader } from "astro/loaders";
2 | import { createContentLoader } from "./create-content-loader";
3 |
4 | type FileOptions = Parameters;
5 |
6 | /**
7 | * A loader function for handling internationalization (i18n) content in an Astro project.
8 | * This loader processes a single data file (yaml, json) and augments their data with
9 | * i18n-specific metadata such as locale, translation ID, and content path.
10 | *
11 | * @param fileName - The name of the file to be processed.
12 | * @param options - Configuration options for matching the file.
13 | * @returns A loader object with a custom `load` method for processing i18n content.
14 | *
15 | * @throws Will throw an error if the `i18n` configuration is missing in the Astro config.
16 | */
17 | export function i18nFileLoader(fileName: FileOptions[0], options?: FileOptions[1]): Loader {
18 | const fileLoader = file(fileName, options);
19 | const load = createContentLoader(fileLoader);
20 | return {
21 | name: "i18n-file-loader",
22 | load,
23 | };
24 | }
25 |
--------------------------------------------------------------------------------
/libs/astro-nanostores-i18n/src/runtime.ts:
--------------------------------------------------------------------------------
1 | import { Components, createI18n, formatter, type Translations } from "@nanostores/i18n";
2 | import { atom } from "nanostores";
3 |
4 | export const currentLocale = atom("");
5 | let i18nInstance: ReturnType;
6 | let formatterInstance: ReturnType;
7 |
8 | export const initializeI18n = (defaultLocale: string, translations: Record) => {
9 | currentLocale.set(defaultLocale);
10 | if (!i18nInstance) {
11 | i18nInstance = createI18n(currentLocale, {
12 | baseLocale: defaultLocale,
13 | get: async () => ({}),
14 | cache: translations,
15 | isSSR: true,
16 | });
17 | }
18 | formatterInstance = formatter(currentLocale);
19 | };
20 |
21 | export const useFormat = () => formatterInstance.get();
22 |
23 | export const useI18n = (componentName: string, baseTranslations: Body) => {
24 | if (!i18nInstance) {
25 | throw new Error("i18n not initialized. Call initializeI18n first.");
26 | }
27 | return i18nInstance(componentName, baseTranslations).get();
28 | };
29 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Robin Bühler
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/libs/astro-nanostores-i18n/test/__snapshots__/integration.spec.ts.snap:
--------------------------------------------------------------------------------
1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2 |
3 | exports[`integration.ts > should inject the types for the virtual module 1`] = `
4 | "declare module "astro-nanostores-i18n:runtime" {
5 | import type { Components, Translations } from '@nanostores/i18n';
6 | export declare const currentLocale: import('nanostores').PreinitializedWritableAtom & object;
7 | export declare const initializeI18n: (defaultLocale: string, translations: Record) => void;
8 | export declare const useFormat: () => import('@nanostores/i18n').Formatter;
9 | export declare const useI18n: (componentName: string, baseTranslations: Body) => Body;
10 | }
11 | "
12 | `;
13 |
14 | exports[`integration.ts > should register virtual module 1`] = `
15 | {
16 | "imports": {
17 | "astro-nanostores-i18n:runtime": "import { initializeI18n, useFormat, useI18n, currentLocale } from "./runtime.js";
18 |
19 | initializeI18n("en", {});
20 |
21 | export { useFormat, useI18n, currentLocale };
22 | ",
23 | },
24 | "name": "astro-nanostores-i18n",
25 | }
26 | `;
27 |
--------------------------------------------------------------------------------
/libs/astro-loader-i18n/test/__mocks__/store.ts:
--------------------------------------------------------------------------------
1 | import type { DataStore } from "astro/loaders";
2 |
3 | export class Store implements DataStore {
4 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
5 | readonly #store = new Map();
6 |
7 | public get(key: string) {
8 | return this.#store.get(key);
9 | }
10 |
11 | public entries() {
12 | return Array.from(this.#store.entries());
13 | }
14 |
15 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
16 | public set(opts: any) {
17 | this.#store.set(opts.id, opts);
18 | return true;
19 | }
20 |
21 | public values() {
22 | return Array.from(this.#store.values());
23 | }
24 |
25 | public keys() {
26 | return Array.from(this.#store.keys());
27 | }
28 |
29 | public delete(key: string) {
30 | this.#store.delete(key);
31 | }
32 |
33 | public clear() {
34 | this.#store.clear();
35 | }
36 |
37 | public has(key: string) {
38 | return this.#store.has(key);
39 | }
40 |
41 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
42 | public addModuleImport(_fileName: string) {
43 | // Do nothing
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/libs/astro-utils-i18n/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import { nxViteTsPaths } from "@nx/vite/plugins/nx-tsconfig-paths.plugin";
3 | import dts from "vite-plugin-dts";
4 | import { codecovVitePlugin } from "@codecov/vite-plugin";
5 |
6 | export default defineConfig({
7 | root: __dirname,
8 | cacheDir: "../../node_modules/.vite/libs/astro-utils-i18n",
9 | plugins: [
10 | nxViteTsPaths(),
11 | dts({ entryRoot: "src", tsconfigPath: "tsconfig.lib.json" }),
12 | codecovVitePlugin({
13 | enableBundleAnalysis: process.env.CODECOV_TOKEN !== undefined,
14 | bundleName: "astro-utils-i18n",
15 | uploadToken: process.env.CODECOV_TOKEN,
16 | }),
17 | ],
18 | build: {
19 | sourcemap: true,
20 | emptyOutDir: true,
21 | lib: {
22 | entry: "src/astro-utils-i18n.ts",
23 | name: "astro-utils-i18n",
24 | formats: ["es"],
25 | },
26 | rollupOptions: {
27 | external: ["astro/loaders", "astro/zod"],
28 | output: {
29 | globals: {
30 | "astro/loaders": "astroLoaders",
31 | "astro/zod": "astroZod",
32 | },
33 | },
34 | },
35 | },
36 | });
37 |
--------------------------------------------------------------------------------
/libs/astro-loader-i18n/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import { nxViteTsPaths } from "@nx/vite/plugins/nx-tsconfig-paths.plugin";
3 | import dts from "vite-plugin-dts";
4 | import { codecovVitePlugin } from "@codecov/vite-plugin";
5 |
6 | export default defineConfig({
7 | root: __dirname,
8 | cacheDir: "../../node_modules/.vite/libs/astro-loader-i18n",
9 | plugins: [
10 | nxViteTsPaths(),
11 | dts({ entryRoot: "src", tsconfigPath: "tsconfig.lib.json" }),
12 | codecovVitePlugin({
13 | enableBundleAnalysis: process.env.CODECOV_TOKEN !== undefined,
14 | bundleName: "astro-loader-i18n",
15 | uploadToken: process.env.CODECOV_TOKEN,
16 | }),
17 | ],
18 | build: {
19 | sourcemap: true,
20 | emptyOutDir: true,
21 | lib: {
22 | entry: "src/astro-loader-i18n.ts",
23 | name: "astro-loader-i18n",
24 | formats: ["es"],
25 | },
26 | rollupOptions: {
27 | external: ["astro/loaders", "astro/zod"],
28 | output: {
29 | globals: {
30 | "astro/loaders": "astroLoaders",
31 | "astro/zod": "astroZod",
32 | },
33 | },
34 | },
35 | },
36 | });
37 |
--------------------------------------------------------------------------------
/libs/astro-loader-i18n/src/schemas/i18n-content-schema.ts:
--------------------------------------------------------------------------------
1 | import { z } from "astro/zod";
2 |
3 | /**
4 | * Creates a schema for localized content, supporting multiple locales.
5 | *
6 | * @template T - The Zod schema type for the content.
7 | * @template Locales - A tuple of locale strings.
8 | * @param schema - The base schema for the content.
9 | * @param locales - An array of locale strings to define the localization keys.
10 | * @param partial - Whether the schema should allow partial localization (optional).
11 | * @returns A Zod schema that validates either the localized object or the base schema.
12 | */
13 | export const localized = (schema: T, locales: Locales, partial?: boolean) => {
14 | const createObjectSchema = () =>
15 | z.object(
16 | locales.reduce(
17 | (acc, key) => {
18 | acc[key as Locales[number]] = schema;
19 | return acc;
20 | },
21 | {} as Record
22 | )
23 | );
24 |
25 | const objectSchema = partial ? createObjectSchema().partial() : createObjectSchema();
26 |
27 | return objectSchema.or(schema);
28 | };
29 |
--------------------------------------------------------------------------------
/docs/2025-05-15-astro-discord-feedback.md:
--------------------------------------------------------------------------------
1 | As you maybe have seen in the #showcase channel, I've been working on a [new Astro loader](https://github.com/openscript/astro-loader-i18n) that simplifies the process of managing internationalization. I've already developed the same for [Gatsby](https://github.com/openscript-ch/gatsby-plugin-i18n-l10n). I think I've maybe missed in some cases the Astro way of doing things or Astro has some rough edges. I've collected them and some of them I've previously discussed here.
2 |
3 | 1. Provide routing information to `getStaticPaths()` such as the `routePattern` to avoid manual repetition. Also see this pull request: https://github.com/withastro/astro/pull/13520 It would be great to proceed with this PR and get it eventually merged.
4 | 2. Allow to define custom parameters for `getStaticPaths()` like `paginate` from integrations and loaders. This makes integrating additional helpers for building `getStaticPaths()` way easier.
5 | 3. Allow to define different schemas for input (this already exists, today) and output (that is missing) of a loader. This is useful if a loader transforms the data. Currently the schema wouldn't match the output of the loader anymore.
6 |
7 | What do you think?
8 |
--------------------------------------------------------------------------------
/.github/workflows/registries.yml:
--------------------------------------------------------------------------------
1 | name: Deploy to registries
2 |
3 | on:
4 | workflow_call:
5 | inputs:
6 | ref:
7 | required: true
8 | type: string
9 |
10 | jobs:
11 | build-and-deploy:
12 | name: Build and deploy packages
13 | runs-on: ubuntu-latest
14 | permissions:
15 | id-token: write # Required for OIDC
16 | contents: read
17 | steps:
18 | - name: Checkout repo
19 | uses: actions/checkout@v6
20 | with:
21 | ref: ${{ inputs.ref }}
22 |
23 | - name: Setup pnpm
24 | uses: pnpm/action-setup@v4
25 |
26 | - name: Configure node for NPM as registry
27 | uses: actions/setup-node@v6
28 | with:
29 | node-version: 24
30 | cache: pnpm
31 | registry-url: https://registry.npmjs.org
32 |
33 | - name: Install dependencies
34 | run: pnpm install --frozen-lockfile
35 |
36 | - name: Prepare packages
37 | run: pnpm prepublish
38 |
39 | - name: Publish packages on NPM
40 | run: pnpm publish -r --no-git-checks --provenance --access public
41 | env:
42 | NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}'
43 | NPM_CONFIG_PROVENANCE: true
44 |
--------------------------------------------------------------------------------
/libs/astro-utils-i18n/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "astro-utils-i18n",
3 | "version": "0.0.1",
4 | "private": true,
5 | "description": "A collection of utilities for managing i18n in Astro.",
6 | "keywords": [
7 | "astro",
8 | "astro-component",
9 | "i18n",
10 | "tooling",
11 | "utils",
12 | "utility",
13 | "withastro"
14 | ],
15 | "homepage": "https://github.com/openscript/astro-i18n/libs/astro-utils-i18n",
16 | "license": "MIT",
17 | "author": "Robin Bühler",
18 | "repository": "github:openscript/astro-i18n",
19 | "type": "module",
20 | "main": "./dist/astro-utils-i18n.js",
21 | "types": "./dist/astro-utils-i18n.d.ts",
22 | "exports": {
23 | ".": {
24 | "import": "./dist/astro-utils-i18n.js",
25 | "types": "./dist/astro-utils-i18n.d.ts"
26 | }
27 | },
28 | "files": [
29 | "dist",
30 | "README.md",
31 | "LICENSE"
32 | ],
33 | "peerDependencies": {
34 | "astro": "^5.5.0"
35 | },
36 | "devDependencies": {
37 | "@codecov/vite-plugin": "^1.9.1",
38 | "@eslint/js": "^9.39.1",
39 | "@types/node": "^24.10.2",
40 | "astro": "^5.16.4",
41 | "eslint": "^9.39.1",
42 | "globals": "^16.5.0",
43 | "tslib": "^2.8.1",
44 | "typescript-eslint": "^8.49.0",
45 | "vite": "^7.2.7",
46 | "vite-plugin-dts": "^4.5.4"
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/libs/astro-utils-i18n/project.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "../../node_modules/nx/schemas/project-schema.json",
3 | "name": "astro-utils-i18n",
4 | "sourceRoot": "libs/astro-utils-i18n/src",
5 | "projectType": "library",
6 | "targets": {
7 | "build": {
8 | "executor": "@nx/vite:build",
9 | "outputs": ["{options.outputPath}"],
10 | "options": {
11 | "outputPath": "libs/astro-utils-i18n/dist",
12 | "generatePackageJson": false
13 | }
14 | },
15 | "dev": {
16 | "executor": "@nx/vite:build",
17 | "outputs": ["{options.outputPath}"],
18 | "options": {
19 | "outputPath": "libs/astro-utils-i18n/dist",
20 | "watch": true,
21 | "generatePackageJson": false
22 | }
23 | },
24 | "prepare": {
25 | "dependsOn": ["build"],
26 | "command": "cp LICENSE libs/astro-utils-i18n && rm libs/astro-utils-i18n/dist/package.json"
27 | },
28 | "test": {
29 | "executor": "@nx/vitest:test",
30 | "outputs": ["{options.reportsDirectory}"],
31 | "options": {
32 | "reportsDirectory": "./coverage",
33 | "coverage": true,
34 | "configFile": "{workspaceRoot}/vitest.config.ts"
35 | }
36 | },
37 | "lint": {
38 | "executor": "@nx/eslint:lint",
39 | "outputs": ["{options.outputFile}"]
40 | }
41 | },
42 | "tags": []
43 | }
44 |
--------------------------------------------------------------------------------
/libs/astro-loader-i18n/project.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "../../node_modules/nx/schemas/project-schema.json",
3 | "name": "astro-loader-i18n",
4 | "sourceRoot": "libs/astro-loader-i18n/src",
5 | "projectType": "library",
6 | "targets": {
7 | "build": {
8 | "executor": "@nx/vite:build",
9 | "outputs": ["{options.outputPath}"],
10 | "options": {
11 | "outputPath": "libs/astro-loader-i18n/dist",
12 | "generatePackageJson": false
13 | }
14 | },
15 | "dev": {
16 | "executor": "@nx/vite:build",
17 | "outputs": ["{options.outputPath}"],
18 | "options": {
19 | "outputPath": "libs/astro-loader-i18n/dist",
20 | "watch": true,
21 | "generatePackageJson": false
22 | }
23 | },
24 | "prepare": {
25 | "dependsOn": ["build"],
26 | "command": "cp LICENSE libs/astro-loader-i18n && rm libs/astro-loader-i18n/dist/package.json"
27 | },
28 | "test": {
29 | "executor": "@nx/vitest:test",
30 | "outputs": ["{options.reportsDirectory}"],
31 | "options": {
32 | "reportsDirectory": "./coverage",
33 | "coverage": true,
34 | "configFile": "{workspaceRoot}/vitest.config.ts"
35 | }
36 | },
37 | "lint": {
38 | "executor": "@nx/eslint:lint",
39 | "outputs": ["{options.outputFile}"]
40 | }
41 | },
42 | "tags": []
43 | }
44 |
--------------------------------------------------------------------------------
/apps/example/src/content.config.ts:
--------------------------------------------------------------------------------
1 | import { defineCollection, z } from "astro:content";
2 | import { extendI18nLoaderSchema, i18nContentLoader, i18nLoader, localized } from "astro-loader-i18n";
3 | import { C } from "./site.config";
4 |
5 | const filesCollection = defineCollection({
6 | loader: i18nLoader({ pattern: "**/[^_]*.{md,mdx}", base: "./src/content/files" }),
7 | schema: extendI18nLoaderSchema(
8 | z.object({
9 | title: z.string(),
10 | })
11 | ),
12 | });
13 | const folderCollection = defineCollection({
14 | loader: i18nLoader({ pattern: "**/[^_]*.{md,mdx}", base: "./src/content/folder" }),
15 | schema: extendI18nLoaderSchema(
16 | z.object({
17 | title: z.string(),
18 | })
19 | ),
20 | });
21 | const infileCollection = defineCollection({
22 | loader: i18nContentLoader({ pattern: "**/[^_]*.{yml,yaml}", base: "./src/content/infile" }),
23 | schema: ({ image }) =>
24 | extendI18nLoaderSchema(
25 | z.object({
26 | navigation: localized(
27 | z.array(
28 | z.object({
29 | path: z.string(),
30 | title: z.string(),
31 | icon: image(),
32 | })
33 | ),
34 | C.LOCALES
35 | ),
36 | })
37 | ),
38 | });
39 |
40 | export const collections = {
41 | files: filesCollection,
42 | folder: folderCollection,
43 | infile: infileCollection,
44 | };
45 |
--------------------------------------------------------------------------------
/libs/astro-nanostores-i18n/project.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "../../node_modules/nx/schemas/project-schema.json",
3 | "name": "astro-nanostores-i18n",
4 | "sourceRoot": "libs/astro-nanostores-i18n/src",
5 | "projectType": "library",
6 | "targets": {
7 | "build": {
8 | "executor": "@nx/vite:build",
9 | "outputs": ["{options.outputPath}"],
10 | "options": {
11 | "outputPath": "libs/astro-nanostores-i18n/dist",
12 | "generatePackageJson": false
13 | }
14 | },
15 | "dev": {
16 | "executor": "@nx/vite:build",
17 | "outputs": ["{options.outputPath}"],
18 | "options": {
19 | "outputPath": "libs/astro-nanostores-i18n/dist",
20 | "watch": true,
21 | "generatePackageJson": false
22 | }
23 | },
24 | "prepare": {
25 | "dependsOn": ["build"],
26 | "command": "cp LICENSE libs/astro-nanostores-i18n && rm libs/astro-nanostores-i18n/dist/package.json"
27 | },
28 | "test": {
29 | "executor": "@nx/vitest:test",
30 | "outputs": ["{options.reportsDirectory}"],
31 | "options": {
32 | "reportsDirectory": "./coverage",
33 | "coverage": true,
34 | "configFile": "{workspaceRoot}/vitest.config.ts"
35 | }
36 | },
37 | "lint": {
38 | "executor": "@nx/eslint:lint",
39 | "outputs": ["{options.outputFile}"]
40 | }
41 | },
42 | "tags": []
43 | }
44 |
--------------------------------------------------------------------------------
/libs/astro-nanostores-i18n/test/runtime.spec.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it, vi, beforeEach } from "vitest";
2 |
3 | describe("runtime.ts", () => {
4 | beforeEach(() => {
5 | vi.resetModules();
6 | });
7 | it("should have an empty current locale", async () => {
8 | const { currentLocale } = await vi.importActual("../src/runtime.ts");
9 | expect(currentLocale.get()).toBe("");
10 | });
11 | it("should throw an error, if i18n was not initialized and used", async () => {
12 | const { useI18n } = await vi.importActual("../src/runtime.ts");
13 | expect(() => useI18n("testComponent", {})).toThrowErrorMatchingSnapshot();
14 | });
15 | it("should set the current locale upon initialization of i18n", async () => {
16 | const { initializeI18n, currentLocale } = await vi.importActual("../src/runtime.ts");
17 | initializeI18n("en", {});
18 | expect(currentLocale.get()).toBe("en");
19 | });
20 | it("should create return an i18n instance", async () => {
21 | const { initializeI18n, useI18n } = await vi.importActual("../src/runtime.ts");
22 | initializeI18n("en", {});
23 | const i18n = useI18n("testComponent", { hello: "Hello" });
24 | expect(i18n).toBeDefined();
25 | expect(i18n).toEqual({ hello: "Hello" });
26 | });
27 | });
28 |
--------------------------------------------------------------------------------
/libs/astro-nanostores-i18n/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # astro-nanostores-i18n
2 |
3 | ## 0.2.4
4 |
5 | ### Patch Changes
6 |
7 | - 11229b5: Upgrade dependencies
8 |
9 | ## 0.2.3
10 |
11 | ### Patch Changes
12 |
13 | - 60d759c: Fix publication
14 |
15 | ## 0.2.2
16 |
17 | ### Patch Changes
18 |
19 | - cd8d776: Upgrade dependencies
20 | - 442af4e: Consolidate vitest configuration
21 |
22 | ## 0.2.1
23 |
24 | ### Patch Changes
25 |
26 | - 8b3d4f9: Revert non-param replacement
27 |
28 | ## 0.2.0
29 |
30 | ### Minor Changes
31 |
32 | - 414a64a: Replace non-params with segments when buildPath
33 |
34 | ## 0.1.6
35 |
36 | ### Patch Changes
37 |
38 | - 68ef82d: Make i18n messages indentifier configurable
39 |
40 | ## 0.1.5
41 |
42 | ### Patch Changes
43 |
44 | - 611f6ac: Upgrade dependencies
45 | - bd183ef: Upgrade dependencies
46 |
47 | ## 0.1.4
48 |
49 | ### Patch Changes
50 |
51 | - b8ab7fa: Create formatter instance on initialization
52 |
53 | ## 0.1.3
54 |
55 | ### Patch Changes
56 |
57 | - 7fd8cf7: Add peer dependency
58 | - 7bd69e1: Improve parse locale heuristic
59 |
60 | ## 0.1.2
61 |
62 | ### Patch Changes
63 |
64 | - cc41241: Enable SSR mode for @nanostores/i18n instance
65 |
66 | ## 0.1.1
67 |
68 | ### Patch Changes
69 |
70 | - 8960dba: Fix homepage path
71 |
72 | ## 0.1.0
73 |
74 | ### Minor Changes
75 |
76 | - 888c6f0: Improve README.md
77 |
78 | ## 0.0.2
79 |
80 | ### Patch Changes
81 |
82 | - 385f153: Refactor project
83 |
--------------------------------------------------------------------------------
/libs/astro-loader-i18n/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "astro-loader-i18n",
3 | "version": "0.10.9",
4 | "description": "An Astro content loader for i18n files and folder structures.",
5 | "keywords": [
6 | "astro",
7 | "astro-content-loader",
8 | "astro-loader",
9 | "i18n",
10 | "withastro"
11 | ],
12 | "homepage": "https://github.com/openscript/astro-i18n/tree/main/libs/astro-loader-i18n",
13 | "license": "MIT",
14 | "author": "Robin Bühler",
15 | "repository": "github:openscript/astro-i18n",
16 | "type": "module",
17 | "main": "./dist/astro-loader-i18n.js",
18 | "types": "./dist/astro-loader-i18n.d.ts",
19 | "exports": {
20 | ".": {
21 | "import": "./dist/astro-loader-i18n.js",
22 | "types": "./dist/astro-loader-i18n.d.ts"
23 | }
24 | },
25 | "files": [
26 | "dist",
27 | "README.md",
28 | "LICENSE"
29 | ],
30 | "peerDependencies": {
31 | "astro": "^5.5.0"
32 | },
33 | "devDependencies": {
34 | "@codecov/vite-plugin": "^1.9.1",
35 | "@eslint/js": "^9.39.1",
36 | "@types/node": "^24.10.2",
37 | "astro": "^5.16.4",
38 | "astro-utils-i18n": "workspace:*",
39 | "eslint": "^9.39.1",
40 | "globals": "^16.5.0",
41 | "tslib": "^2.8.1",
42 | "typescript": "^5.9.3",
43 | "typescript-eslint": "^8.49.0",
44 | "vite": "^7.2.7",
45 | "vite-plugin-dts": "^4.5.4"
46 | },
47 | "publishConfig": {
48 | "access": "public",
49 | "provenance": true
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "root",
3 | "private": true,
4 | "homepage": "https://github.com/openscript/astro-i18n",
5 | "license": "MIT",
6 | "author": "Robin Bühler",
7 | "repository": "github:openscript/astro-i18n",
8 | "packageManager": "pnpm@10.25.0",
9 | "type": "module",
10 | "scripts": {
11 | "dev": "pnpm exec nx run-many --nx-bail --target=dev --parallel 10 --output-style stream",
12 | "build": "pnpm exec nx run-many --nx-bail --target=build --parallel 10 --output-style stream",
13 | "lint": "eslint . --max-warnings 0",
14 | "test": "pnpm exec nx run-many --nx-bail --target=test --parallel 10 --output-style stream",
15 | "type-check": "tsc --noEmit",
16 | "prepublish": "pnpm exec nx run-many --target=prepare --parallel",
17 | "version": "changeset version",
18 | "tag": "changeset tag"
19 | },
20 | "devDependencies": {
21 | "@changesets/cli": "^2.29.8",
22 | "@eslint/js": "^9.39.1",
23 | "@nx/eslint": "22.2.0",
24 | "@nx/vite": "22.2.0",
25 | "@nx/vitest": "^22.2.0",
26 | "@nx/web": "22.2.0",
27 | "@types/node": "^24.10.2",
28 | "@vitest/coverage-v8": "^4.0.15",
29 | "@vitest/ui": "^4.0.15",
30 | "eslint": "^9.39.1",
31 | "eslint-config-prettier": "^10.1.8",
32 | "eslint-plugin-prettier": "^5.5.4",
33 | "globals": "^16.5.0",
34 | "jiti": "2.6.1",
35 | "nx": "22.2.0",
36 | "prettier": "^3.7.4",
37 | "typescript": "^5.9.3",
38 | "typescript-eslint": "^8.49.0",
39 | "vite": "^7.2.7",
40 | "vitest": "^4.0.15"
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/.github/workflows/version.yml:
--------------------------------------------------------------------------------
1 | name: Version packages
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | jobs:
9 | version-packages:
10 | name: Version packages
11 | runs-on: ubuntu-latest
12 | steps:
13 | - name: Checkout
14 | uses: actions/checkout@v6
15 |
16 | - name: Setup pnpm
17 | uses: pnpm/action-setup@v4
18 |
19 | - name: Setup Node
20 | uses: actions/setup-node@v6
21 | with:
22 | node-version: 24
23 | registry-url: https://registry.npmjs.org
24 | cache: pnpm
25 |
26 | - name: Install dependencies
27 | run: pnpm install --frozen-lockfile
28 |
29 | - name: Create Release Pull Request
30 | uses: changesets/action@v1
31 | id: changesets
32 | with:
33 | commit: "chore: publish new release"
34 | title: "chore: publish new release"
35 | env:
36 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
37 |
38 | - name: Get current branch
39 | id: get_branch
40 | run: echo "CURRENT_BRANCH=$(git branch --show-current)" >> $GITHUB_ENV
41 |
42 | - name: Update lock file
43 | if: env.CURRENT_BRANCH == 'changeset-release/main'
44 | run: pnpm install --lockfile-only
45 |
46 | - name: Commit lock file
47 | if: env.CURRENT_BRANCH == 'changeset-release/main'
48 | uses: stefanzweifel/git-auto-commit-action@v7
49 | with:
50 | commit_message: "chore: update lock file"
51 | branch: changeset-release/main
52 |
--------------------------------------------------------------------------------
/libs/astro-loader-i18n/test/loaders/i18n-content-loader.spec.ts:
--------------------------------------------------------------------------------
1 | import { beforeEach, describe, expect, it, vi } from "vitest";
2 | import { LoaderContext } from "astro/loaders";
3 | import { createLoaderContext } from "../__mocks__/loader-context";
4 | import { contentCollectionFixture } from "../__fixtures__/collections";
5 | import { i18nContentLoader } from "../../src/loaders/i18n-content-loader";
6 |
7 | vi.mock("astro/loaders", () => {
8 | return {
9 | glob: () => {
10 | return {
11 | load: vi.fn().mockImplementation(async (context: LoaderContext) => {
12 | contentCollectionFixture.forEach(async (entry) => {
13 | context.store.set({ ...entry, data: await context.parseData(entry) });
14 | });
15 | }),
16 | };
17 | },
18 | };
19 | });
20 |
21 | describe("i18nContentLoader", () => {
22 | let context: LoaderContext;
23 |
24 | beforeEach(() => {
25 | context = createLoaderContext();
26 | });
27 |
28 | it("should put common translation id and locale in data", async () => {
29 | const loader = i18nContentLoader({ pattern: "**/*.yml", base: "./src/content/gallery" });
30 | await loader.load(context);
31 |
32 | const entries = context.store.entries();
33 | expect(entries).toMatchSnapshot();
34 | });
35 |
36 | it("should throw error if i18n config is missing", async () => {
37 | const loader = i18nContentLoader({ pattern: "**/*.mdx", base: "./src/content/pages" });
38 | context.config.i18n = undefined;
39 |
40 | await expect(loader.load(context)).rejects.toThrowErrorMatchingSnapshot();
41 | });
42 | });
43 |
--------------------------------------------------------------------------------
/libs/astro-nanostores-i18n/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import { codecovVitePlugin } from "@codecov/vite-plugin";
3 | import { nxViteTsPaths } from "@nx/vite/plugins/nx-tsconfig-paths.plugin";
4 | import dts from "vite-plugin-dts";
5 |
6 | export default defineConfig({
7 | root: __dirname,
8 | cacheDir: "../../node_modules/.vite/libs/astro-nanostores-i18n",
9 | plugins: [
10 | nxViteTsPaths(),
11 | dts({ entryRoot: "src", tsconfigPath: "tsconfig.lib.json" }),
12 | codecovVitePlugin({
13 | enableBundleAnalysis: process.env.CODECOV_TOKEN !== undefined,
14 | bundleName: "astro-nanostores-i18n",
15 | uploadToken: process.env.CODECOV_TOKEN,
16 | }),
17 | ],
18 | build: {
19 | target: "es2022",
20 | sourcemap: true,
21 | emptyOutDir: true,
22 | lib: {
23 | entry: {
24 | integration: "src/integration.ts",
25 | runtime: "src/runtime.ts",
26 | middleware: "src/middleware.ts",
27 | "bin/extract": "src/bin/extract.ts",
28 | },
29 | formats: ["es"],
30 | },
31 | rollupOptions: {
32 | external: [
33 | "astro:middleware",
34 | "astro:config/client",
35 | "astro-integration-kit",
36 | "astro-nanostores-i18n:runtime",
37 | "node:fs/promises",
38 | "node:vm",
39 | "node:util",
40 | "@astrojs/compiler",
41 | "@astrojs/compiler/utils",
42 | "nanostores",
43 | "@nanostores/i18n",
44 | "fast-glob",
45 | "typescript",
46 | ],
47 | output: {
48 | globals: {},
49 | },
50 | },
51 | },
52 | });
53 |
--------------------------------------------------------------------------------
/.github/workflows/packages.yml:
--------------------------------------------------------------------------------
1 | name: Publish packages
2 |
3 | on:
4 | pull_request:
5 | types:
6 | - closed
7 | branches:
8 | - main
9 |
10 | jobs:
11 | publish-packages:
12 | if: github.event.pull_request.merged == true && github.head_ref == 'changeset-release/main'
13 | name: Publish packages
14 | runs-on: ubuntu-latest
15 | steps:
16 | - name: Checkout
17 | uses: actions/checkout@v6
18 | with:
19 | # This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits
20 | fetch-depth: 0
21 |
22 | - name: Setup pnpm
23 | uses: pnpm/action-setup@v4
24 |
25 | - name: Setup Node
26 | uses: actions/setup-node@v6
27 | with:
28 | node-version: 24
29 | registry-url: https://registry.npmjs.org
30 | cache: pnpm
31 |
32 | - name: Install dependencies
33 | run: pnpm install --frozen-lockfile
34 |
35 | - name: Publish
36 | uses: changesets/action@v1
37 | id: changesets
38 | with:
39 | version: pnpm version
40 | publish: pnpm tag
41 | env:
42 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
43 | outputs:
44 | ref: ${{ github.ref }}
45 | published: ${{ steps.changesets.outputs.published }}
46 |
47 | deliver-packages:
48 | name: Deliver packages
49 | uses: ./.github/workflows/registries.yml
50 | needs: publish-packages
51 | permissions:
52 | id-token: write
53 | contents: read
54 | with:
55 | ref: ${{ needs.publish-packages.outputs.ref }}
56 | secrets: inherit
57 | if: ${{ needs.publish-packages.outputs.published }}
58 |
--------------------------------------------------------------------------------
/libs/astro-loader-i18n/src/loaders/i18n-loader.ts:
--------------------------------------------------------------------------------
1 | import { glob, Loader, LoaderContext } from "astro/loaders";
2 | import { createContentPath, createTranslationId, parseLocale } from "astro-utils-i18n";
3 |
4 | type GlobOptions = Parameters[0];
5 |
6 | /**
7 | * Creates a custom i18n loader for Astro projects.
8 | *
9 | * @param options - Configuration options for the glob loader.
10 | * @returns A loader that integrates i18n functionality into the Astro build process.
11 | *
12 | * @throws If the `i18n` configuration is missing in the Astro config.
13 | */
14 | export function i18nLoader(options: GlobOptions): Loader {
15 | const globLoader = glob(options);
16 | return {
17 | name: "i18n-loader",
18 | load: async (context: LoaderContext) => {
19 | if (!context.config.i18n) throw new Error("i18n configuration is missing in your astro config");
20 |
21 | const { locales, defaultLocale } = context.config.i18n;
22 | const localeCodes = locales.flatMap((locale) => (typeof locale === "string" ? locale : locale.codes));
23 |
24 | const parseData = context.parseData;
25 | const parseDataProxy: typeof parseData = (props) => {
26 | if (!props.filePath) return parseData(props);
27 | const locale = parseLocale(props.filePath, localeCodes, defaultLocale);
28 | const translationId = createTranslationId(props.filePath, locale);
29 | const contentPath = createContentPath(props.filePath, options.base, locale);
30 | const basePath = context.config.base;
31 | return parseData({ ...props, data: { ...props.data, locale, translationId, contentPath, basePath } });
32 | };
33 | context.parseData = parseDataProxy;
34 |
35 | await globLoader.load(context);
36 | },
37 | };
38 | }
39 |
--------------------------------------------------------------------------------
/docs/2025-07-23-astro-discord-showcase.md:
--------------------------------------------------------------------------------
1 | ## astro-nanostores-i18n
2 |
3 | Hey Astro community! 👋
4 |
5 | Last few days, I've been working on a new integration for Astro that helps you to use [@nanostores/i18n](https://github.com/nanostores/i18n) in your projects. The key features are:
6 |
7 | - **Everything the original @nanostores/i18n provides**: It supports the same API and functionality, so you can use it seamlessly in your Astro projects.
8 | - **Automatic locale detection** based on the URL pathname via a middleware.
9 | - **Extract messages from your translations** by a included script that uses the Astro compiler to read your translations and extract the messages.
10 |
11 | Check it out here: https://github.com/openscript/astro-i18n/tree/main/libs/astro-nanostores-i18n
12 |
13 | Using `astro-nanostores-i18n` is straightforward:
14 |
15 | ```tsx
16 | ---
17 | import Page from "../layouts/Page.astro";
18 | import { useI18n, useFormat, currentLocale } from "astro-nanostores-i18n:runtime";
19 | import { count, params } from "@nanostores/i18n";
20 |
21 | // Override the current locale if needed
22 | currentLocale.set("zh-CN");
23 |
24 | // Name the constant `messages` to be able to use the extraction script.
25 | const messages = useI18n("example", {
26 | message: "Irgend eine Nachricht.",
27 | param: params("Eine Nachricht mit einem Parameter: {irgendwas}"),
28 | count: count({
29 | one: "Ein Eintrag",
30 | many: "{count} Einträge",
31 | }),
32 | });
33 |
34 | const format = useFormat();
35 | ---
36 |
37 |
38 | astro-nanostores-i18n
39 | {messages.message}
40 | {messages.param({ irgendwas: "Something" })}
41 | {format.time(new Date())}
42 |
43 | ```
44 |
45 | Let me know if you have any questions or feedback! I'm always open to suggestions and improvements.
46 |
47 |
--------------------------------------------------------------------------------
/libs/astro-nanostores-i18n/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "astro-nanostores-i18n",
3 | "version": "0.2.4",
4 | "description": "An integration of @nanostores/i18n into Astro.",
5 | "keywords": [
6 | "astro",
7 | "astro-component",
8 | "i18n",
9 | "tooling",
10 | "utils",
11 | "utility",
12 | "withastro"
13 | ],
14 | "homepage": "https://github.com/openscript/astro-i18n/tree/main/libs/astro-nanostores-i18n",
15 | "license": "MIT",
16 | "author": "Robin Bühler",
17 | "repository": "github:openscript/astro-i18n",
18 | "type": "module",
19 | "main": "./dist/integration.js",
20 | "types": "./dist/integration.d.ts",
21 | "exports": {
22 | ".": {
23 | "import": "./dist/integration.js",
24 | "types": "./dist/integration.d.ts"
25 | },
26 | "./middleware": {
27 | "import": "./dist/middleware.js",
28 | "types": "./dist/middleware.d.ts"
29 | }
30 | },
31 | "bin": {
32 | "extract-messages": "./dist/bin/extract.js"
33 | },
34 | "files": [
35 | "dist",
36 | "README.md",
37 | "LICENSE"
38 | ],
39 | "peerDependencies": {
40 | "@nanostores/i18n": "^1.2.0",
41 | "astro": "^5.5.0",
42 | "nanostores": "^1.0.1"
43 | },
44 | "devDependencies": {
45 | "@codecov/vite-plugin": "^1.9.1",
46 | "@eslint/js": "^9.39.1",
47 | "@types/node": "^24.10.2",
48 | "astro": "^5.16.4",
49 | "astro-utils-i18n": "workspace:*",
50 | "eslint": "^9.39.1",
51 | "globals": "^16.5.0",
52 | "tslib": "^2.8.1",
53 | "typescript-eslint": "^8.49.0",
54 | "vite": "^7.2.7",
55 | "vite-plugin-dts": "^4.5.4"
56 | },
57 | "publishConfig": {
58 | "access": "public",
59 | "provenance": true
60 | },
61 | "dependencies": {
62 | "@astrojs/compiler": "^2.13.0",
63 | "astro-integration-kit": "^0.19.1",
64 | "fast-glob": "^3.3.3",
65 | "nanostores": "^1.1.0",
66 | "typescript": "^5.9.3"
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/apps/example/src/pages/astro-loader-i18n.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import { getCollection } from "astro:content";
3 | import Page from "../layouts/Page.astro";
4 | import { Image } from "astro:assets";
5 |
6 | const filesCollection = await getCollection("files");
7 | const folderCollection = await getCollection("folder");
8 | const infileCollection = await getCollection("infile");
9 | ---
10 |
11 |
12 | astro-loader-i18n
13 | files
14 | {
15 | filesCollection.map((entry) => (
16 | <>
17 | {entry.filePath}
18 |
19 | {Object.entries(entry.data).map(([key, value]) => (
20 | <>
21 | {key}
22 | {value}
23 | >
24 | ))}
25 |
26 | >
27 | ))
28 | }
29 | folder
30 | {
31 | folderCollection.map((entry) => (
32 | <>
33 | {entry.filePath}
34 |
35 | {Object.entries(entry.data).map(([key, value]) => (
36 | <>
37 | {key}
38 | {value}
39 | >
40 | ))}
41 |
42 | >
43 | ))
44 | }
45 | infile
46 | {
47 | infileCollection.map((entry) => (
48 | <>
49 |
50 | {entry.filePath} / {entry.id}
51 |
52 |
53 | Icons
54 |
55 | {Array.isArray(entry.data.navigation)
56 | ? entry.data.navigation.map((item) => )
57 | : null}
58 |
59 | {Object.entries(entry.data).map(([key, value]) => (
60 | <>
61 | {key}
62 | {JSON.stringify(value, null, 2)}
63 | >
64 | ))}
65 |
66 | >
67 | ))
68 | }
69 |
70 |
--------------------------------------------------------------------------------
/libs/astro-loader-i18n/test/loaders/i18n-file-loader.spec.ts:
--------------------------------------------------------------------------------
1 | import { beforeEach, describe, expect, it, vi } from "vitest";
2 | import { LoaderContext } from "astro/loaders";
3 | import { createLoaderContext } from "../__mocks__/loader-context";
4 | import { contentFileFixture, contentFileWithoutFixture } from "../__fixtures__/collections";
5 | import { i18nFileLoader } from "../../src/loaders/i18n-file-loader";
6 |
7 | vi.mock("astro/loaders", () => {
8 | return {
9 | file: (filePath: string) => {
10 | return {
11 | load: vi.fn().mockImplementation(async (context: LoaderContext) => {
12 | const fixture = filePath.includes("omni.yml") ? contentFileWithoutFixture : contentFileFixture;
13 | fixture.forEach(async (entry) => {
14 | context.store.set({ ...entry, data: await context.parseData(entry) });
15 | });
16 | }),
17 | };
18 | },
19 | };
20 | });
21 |
22 | describe("i18nFileLoader", () => {
23 | let context: LoaderContext;
24 |
25 | beforeEach(() => {
26 | context = createLoaderContext();
27 | });
28 |
29 | it("should put common translation id and locale in data", async () => {
30 | const loader = i18nFileLoader("/content/gallery/space.yml");
31 | await loader.load(context);
32 |
33 | const entries = context.store.entries();
34 | expect(entries).toMatchSnapshot();
35 | });
36 |
37 | it("should handle input without i18n", async () => {
38 | const loader = i18nFileLoader("/content/gallery/omni.yml");
39 | await loader.load(context);
40 |
41 | const entries = context.store.entries();
42 | expect(entries).toMatchSnapshot();
43 | });
44 |
45 | it("should throw error if i18n config is missing", async () => {
46 | const loader = i18nFileLoader("/content/gallery/space.yml");
47 | context.config.i18n = undefined;
48 |
49 | await expect(loader.load(context)).rejects.toThrowErrorMatchingSnapshot();
50 | });
51 | });
52 |
--------------------------------------------------------------------------------
/libs/astro-loader-i18n/src/schemas/i18n-loader-schema.ts:
--------------------------------------------------------------------------------
1 | import { AnyZodObject, z } from "astro/zod";
2 |
3 | /**
4 | * Schema definition for the i18n loader configuration.
5 | * This schema validates the structure of the i18n loader object.
6 | *
7 | * Properties:
8 | * - `translationId` (string): A unique identifier for the translation.
9 | * - `locale` (string): The locale code (e.g., "en", "fr", "es") for the translation.
10 | * - `contentPath` (string): The path from the contents root to the content file.
11 | * - `basePath` (string): The base directory path of your website. This is used by i18nPropsAndParams to provide paths with a base path.
12 | */
13 | export const i18nLoaderSchema = z.object({
14 | translationId: z.string(),
15 | locale: z.string(),
16 | contentPath: z.string(),
17 | basePath: z.string(),
18 | });
19 |
20 | /**
21 | * Extends the base `i18nLoaderSchema` with additional schema definitions.
22 | *
23 | * @template Z - A Zod object schema that will be merged with the base schema.
24 | * @param schema - The Zod schema to extend the base `i18nLoaderSchema`.
25 | * @returns A new schema resulting from merging the base `i18nLoaderSchema` with the provided schema.
26 | */
27 | export const extendI18nLoaderSchema = (schema: Z) => i18nLoaderSchema.merge(schema);
28 |
29 | const i18nLoaderEntrySchema = z.object({
30 | data: i18nLoaderSchema,
31 | filePath: z.string().optional(),
32 | });
33 | export type I18nLoaderEntry = z.infer;
34 |
35 | const i18nLoaderCollectionSchema = z.array(i18nLoaderEntrySchema);
36 | export type I18nLoaderCollection = z.infer;
37 |
38 | export function checkI18nLoaderCollection(obj: unknown): asserts obj is I18nLoaderCollection {
39 | const result = i18nLoaderCollectionSchema.safeParse(obj);
40 |
41 | if (!result.success) {
42 | throw new Error(
43 | `Invalid collection entry was provided to astro-i18n-loader. Did you forget to use "extendI18nLoaderSchema" to extend the schema in your "content.config.js" definition? Validation failed with:\n\n${result.error}`
44 | );
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/libs/astro-loader-i18n/test/loaders/i18n-loader.spec.ts:
--------------------------------------------------------------------------------
1 | import { beforeEach, describe, expect, it, vi } from "vitest";
2 | import { i18nLoader } from "../../src/loaders/i18n-loader";
3 | import { LoaderContext } from "astro/loaders";
4 | import { createLoaderContext } from "../__mocks__/loader-context";
5 | import { folderCollectionFixture } from "../__fixtures__/collections";
6 |
7 | vi.mock("astro/loaders", () => {
8 | return {
9 | glob: () => {
10 | return {
11 | load: vi.fn().mockImplementation(async (context: LoaderContext) => {
12 | folderCollectionFixture.forEach(async (entry) => {
13 | context.store.set({ ...entry, data: await context.parseData(entry) });
14 | });
15 | }),
16 | };
17 | },
18 | };
19 | });
20 |
21 | describe("i18nLoader", () => {
22 | let context: LoaderContext;
23 |
24 | beforeEach(() => {
25 | context = createLoaderContext();
26 | });
27 |
28 | it("should put common translation id and locale in data", async () => {
29 | const loader = i18nLoader({ pattern: "**/*.mdx", base: "./src/content/pages" });
30 | await loader.load(context);
31 |
32 | const entries = context.store.entries();
33 | expect(entries).toMatchSnapshot();
34 | });
35 |
36 | it("should throw error if i18n config is missing", async () => {
37 | const loader = i18nLoader({ pattern: "**/*.mdx", base: "./src/content/pages" });
38 | context.config.i18n = undefined;
39 |
40 | await expect(loader.load(context)).rejects.toThrowErrorMatchingSnapshot();
41 | });
42 |
43 | it("should use locale.codes if locale is an object", async () => {
44 | const loader = i18nLoader({ pattern: "**/*.mdx", base: "./src/content/pages" });
45 | context.config.i18n = {
46 | defaultLocale: "zh-CN",
47 | routing: "manual",
48 | locales: [
49 | { codes: ["de-CH"], path: "/de" },
50 | { codes: ["en-US"], path: "/en" },
51 | { codes: ["zh-CN"], path: "/zh" },
52 | ],
53 | };
54 | await loader.load(context);
55 |
56 | const entries = context.store.entries();
57 | expect(entries).toMatchSnapshot();
58 | });
59 | });
60 |
--------------------------------------------------------------------------------
/libs/astro-utils-i18n/test/utils/__snapshots__/collection.spec.ts.snap:
--------------------------------------------------------------------------------
1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2 |
3 | exports[`getAllUniqueKeys > should return all unique keys of an object 1`] = `
4 | Set {
5 | "a",
6 | "b",
7 | "c",
8 | "d",
9 | "e",
10 | "g",
11 | "i",
12 | "k",
13 | }
14 | `;
15 |
16 | exports[`getAllUniqueKeys > should return all unique keys of an object nested in an array 1`] = `
17 | Set {
18 | "a",
19 | "z",
20 | "y",
21 | "de",
22 | "en",
23 | }
24 | `;
25 |
26 | exports[`getAllUniqueKeys > should return all unique keys of an object with arrays in arrays 1`] = `
27 | Set {
28 | "a",
29 | "z",
30 | "y",
31 | "de",
32 | "en",
33 | }
34 | `;
35 |
36 | exports[`pruneLocales > should prune locales with nested data 1`] = `
37 | {
38 | "array": [
39 | 1,
40 | 2,
41 | 3,
42 | ],
43 | "boolean": true,
44 | "nestedArray": [
45 | {
46 | "a": 13,
47 | "b": 14,
48 | },
49 | {
50 | "a": 15,
51 | "b": 16,
52 | },
53 | ],
54 | "nestedArrayWithObjects": [
55 | {
56 | "de": {
57 | "a": 29,
58 | "b": 30,
59 | },
60 | "en": {
61 | "a": 25,
62 | "b": 26,
63 | },
64 | "fr": {
65 | "a": 27,
66 | "b": 28,
67 | },
68 | },
69 | {
70 | "de": {
71 | "a": 35,
72 | "b": 36,
73 | },
74 | "en": {
75 | "a": 31,
76 | "b": 32,
77 | },
78 | "fr": {
79 | "a": 33,
80 | "b": 34,
81 | },
82 | },
83 | ],
84 | "nestedObject": {
85 | "a": 7,
86 | "b": 8,
87 | },
88 | "number": 123,
89 | "object": {
90 | "a": 1,
91 | "b": 2,
92 | },
93 | "string": "normal string",
94 | "title": "Title",
95 | }
96 | `;
97 |
98 | exports[`pruneLocales > should prune locales with no data 1`] = `{}`;
99 |
100 | exports[`pruneLocales > should prune locales with top level local data as strings 1`] = `[Error: Top-level locales are not allowed]`;
101 |
102 | exports[`pruneLocales > should prune locales with top level locale data as objects 1`] = `[Error: Top-level locales are not allowed]`;
103 |
--------------------------------------------------------------------------------
/libs/astro-nanostores-i18n/test/middleware.spec.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
2 | import { onRequest } from "../src/middleware";
3 | import { APIContext } from "astro";
4 |
5 | // Use vi.hoisted to ensure proper hoisting of mock variables
6 | const { mockCurrentLocaleSet, mockNext } = vi.hoisted(() => ({
7 | mockCurrentLocaleSet: vi.fn(),
8 | mockNext: vi.fn(),
9 | }));
10 |
11 | vi.mock("astro:middleware", () => ({
12 | defineMiddleware: unknown>(fn: T): T => fn,
13 | }));
14 |
15 | vi.mock("astro:config/client", () => ({
16 | i18n: {
17 | locales: ["en", "es", { codes: ["fr", "fr-CA"] }],
18 | defaultLocale: "en",
19 | },
20 | }));
21 |
22 | vi.mock("astro-nanostores-i18n:runtime", () => ({
23 | currentLocale: {
24 | set: mockCurrentLocaleSet,
25 | },
26 | }));
27 |
28 | describe("middleware.ts", async () => {
29 | beforeEach(() => {
30 | vi.clearAllMocks();
31 | });
32 |
33 | afterEach(() => {
34 | vi.restoreAllMocks();
35 | });
36 |
37 | it("should set the current locale based on the URL pathname", async () => {
38 | await onRequest({ url: { pathname: "/es/some-page" } } as APIContext, mockNext);
39 | expect(mockCurrentLocaleSet).toHaveBeenCalledWith("es");
40 | expect(mockNext).toHaveBeenCalled();
41 | });
42 |
43 | it("should handle default locale when no locale in pathname", async () => {
44 | await onRequest({ url: { pathname: "/some-page" } } as APIContext, mockNext);
45 | expect(mockCurrentLocaleSet).toHaveBeenCalledWith("en");
46 | expect(mockNext).toHaveBeenCalled();
47 | });
48 |
49 | it("should handle French locale", async () => {
50 | await onRequest({ url: { pathname: "/fr/some-page" } } as APIContext, mockNext);
51 | expect(mockCurrentLocaleSet).toHaveBeenCalledWith("fr");
52 | expect(mockNext).toHaveBeenCalled();
53 | });
54 |
55 | it("should handle French Canadian locale", async () => {
56 | await onRequest({ url: { pathname: "/fr-CA/some-page" } } as APIContext, mockNext);
57 | expect(mockCurrentLocaleSet).toHaveBeenCalledWith("fr-CA");
58 | expect(mockNext).toHaveBeenCalled();
59 | });
60 |
61 | it("should handle without prefix", async () => {
62 | await onRequest({ url: { pathname: "es" } } as APIContext, mockNext);
63 | expect(mockCurrentLocaleSet).toHaveBeenCalledWith("es");
64 | expect(mockNext).toHaveBeenCalled();
65 | });
66 | });
67 |
--------------------------------------------------------------------------------
/libs/astro-utils-i18n/test/utils/route.spec.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from "vitest";
2 | import { buildPath, parseRoutePattern } from "../../src/astro-utils-i18n";
3 |
4 | describe("parseRoutePattern", () => {
5 | it("should parse a static route pattern", () => {
6 | const result = parseRoutePattern("/blog/post");
7 | expect(result).toEqual([
8 | { spread: false, param: false, value: "blog" },
9 | { spread: false, param: false, value: "post" },
10 | ]);
11 | });
12 |
13 | it("should parse a dynamic route pattern", () => {
14 | const result = parseRoutePattern("/blog/[slug]");
15 | expect(result).toEqual([
16 | { spread: false, param: false, value: "blog" },
17 | { spread: false, param: true, value: "slug" },
18 | ]);
19 | });
20 |
21 | it("should parse a spread route pattern", () => {
22 | const result = parseRoutePattern("/[...locale]/blog/[slug]");
23 | expect(result).toEqual([
24 | { spread: true, param: true, value: "locale" },
25 | { spread: false, param: false, value: "blog" },
26 | { spread: false, param: true, value: "slug" },
27 | ]);
28 | });
29 |
30 | it("should parse a route pattern with trailing slash", () => {
31 | const result = parseRoutePattern("/blog/post/");
32 | expect(result).toEqual([
33 | { spread: false, param: false, value: "blog" },
34 | { spread: false, param: false, value: "post" },
35 | ]);
36 | });
37 |
38 | it("should parse a route pattern with only a slash", () => {
39 | const result = parseRoutePattern("/");
40 | expect(result).toEqual([{ spread: false, param: false, value: "/" }]);
41 | });
42 | });
43 |
44 | describe("buildPath", () => {
45 | it("should throw if no segment matches a param", () => {
46 | const routePattern = parseRoutePattern("/blog/[slug]");
47 | expect(() => buildPath(routePattern, { slug: undefined }, "/")).toThrowErrorMatchingSnapshot();
48 | });
49 | it("should throw if is filled with a path and param is not spread", () => {
50 | const routePattern = parseRoutePattern("/blog/[slug]");
51 | expect(() => buildPath(routePattern, { slug: "bli/bla/blub" }, "/")).toThrowErrorMatchingSnapshot();
52 | });
53 | it("should build valid paths", () => {
54 | const routePattern = parseRoutePattern("/blog/[...slug]/comments/[commentId]");
55 | expect(buildPath(routePattern, { slug: "bli/bla/blub", commentId: "2" }, "/")).toMatchSnapshot();
56 | });
57 | });
58 |
--------------------------------------------------------------------------------
/libs/astro-loader-i18n/test/schemas/__snapshots__/i18n-loader-schema.spec.ts.snap:
--------------------------------------------------------------------------------
1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2 |
3 | exports[`i18nLoaderSchema > should extend the schema 1`] = `"{"translationId":{"_def":{"checks":[],"typeName":"ZodString","coerce":false},"~standard":{"version":1,"vendor":"zod"}},"locale":{"_def":{"checks":[],"typeName":"ZodString","coerce":false},"~standard":{"version":1,"vendor":"zod"}},"contentPath":{"_def":{"checks":[],"typeName":"ZodString","coerce":false},"~standard":{"version":1,"vendor":"zod"}},"basePath":{"_def":{"checks":[],"typeName":"ZodString","coerce":false},"~standard":{"version":1,"vendor":"zod"}},"title":{"_def":{"checks":[],"typeName":"ZodString","coerce":false},"~standard":{"version":1,"vendor":"zod"}}}"`;
4 |
5 | exports[`i18nLoaderSchema > should throw an error when checkI18nLoaderCollection fails 1`] = `
6 | [Error: Invalid collection entry was provided to astro-i18n-loader. Did you forget to use "extendI18nLoaderSchema" to extend the schema in your "content.config.js" definition? Validation failed with:
7 |
8 | [
9 | {
10 | "code": "invalid_type",
11 | "expected": "string",
12 | "received": "undefined",
13 | "path": [
14 | 0,
15 | "data",
16 | "basePath"
17 | ],
18 | "message": "Required"
19 | },
20 | {
21 | "code": "invalid_type",
22 | "expected": "string",
23 | "received": "undefined",
24 | "path": [
25 | 1,
26 | "data",
27 | "contentPath"
28 | ],
29 | "message": "Required"
30 | },
31 | {
32 | "code": "invalid_type",
33 | "expected": "string",
34 | "received": "undefined",
35 | "path": [
36 | 1,
37 | "data",
38 | "basePath"
39 | ],
40 | "message": "Required"
41 | },
42 | {
43 | "code": "invalid_type",
44 | "expected": "string",
45 | "received": "undefined",
46 | "path": [
47 | 2,
48 | "data",
49 | "locale"
50 | ],
51 | "message": "Required"
52 | },
53 | {
54 | "code": "invalid_type",
55 | "expected": "string",
56 | "received": "undefined",
57 | "path": [
58 | 2,
59 | "data",
60 | "contentPath"
61 | ],
62 | "message": "Required"
63 | },
64 | {
65 | "code": "invalid_type",
66 | "expected": "string",
67 | "received": "undefined",
68 | "path": [
69 | 2,
70 | "data",
71 | "basePath"
72 | ],
73 | "message": "Required"
74 | }
75 | ]]
76 | `;
77 |
--------------------------------------------------------------------------------
/libs/astro-utils-i18n/src/utils/collection.ts:
--------------------------------------------------------------------------------
1 | export function getAllUniqueKeys(obj: Record, keys = new Set(), ignore?: boolean): Set {
2 | Object.entries(obj).forEach(([key, value]) => {
3 | if (!ignore) keys.add(key);
4 | if (value && typeof value === "object") {
5 | if (Array.isArray(value)) {
6 | value.forEach((item) => {
7 | if (item && typeof item === "object") {
8 | // Recurse into objects and arrays within arrays
9 | if (Array.isArray(item)) {
10 | // Wrap array in an object to reuse the function and ignore its key
11 | getAllUniqueKeys({ array: item }, keys, true);
12 | } else {
13 | getAllUniqueKeys(item as Record, keys);
14 | }
15 | }
16 | });
17 | } else {
18 | getAllUniqueKeys(value as Record, keys);
19 | }
20 | }
21 | });
22 | return keys;
23 | }
24 |
25 | function recursivePruneLocales(obj: Record, locales: string[], locale: string) {
26 | const result: Record = {};
27 |
28 | for (const [key, value] of Object.entries(obj)) {
29 | if (Array.isArray(value)) {
30 | result[key] = value.map((item) => {
31 | if (item && typeof item === "object" && !Array.isArray(item)) {
32 | return recursivePruneLocales(item as Record, locales, locale);
33 | }
34 | return item;
35 | });
36 | } else if (value && typeof value === "object") {
37 | const valueAsRecord = value as Record;
38 | const hasLocales = Object.keys(valueAsRecord).some((k) => locales.includes(k));
39 | let prunedValue: unknown | undefined = undefined;
40 |
41 | if (hasLocales) {
42 | prunedValue = valueAsRecord[locale] ?? undefined;
43 | } else {
44 | prunedValue = recursivePruneLocales(valueAsRecord, locales, locale);
45 | }
46 |
47 | result[key] = prunedValue;
48 | } else {
49 | result[key] = value;
50 | }
51 | }
52 |
53 | return result;
54 | }
55 |
56 | export function pruneLocales(obj: Record, locales: string[], locale: string) {
57 | if (Object.keys(obj).find((key) => locales.includes(key))) throw new Error("Top-level locales are not allowed");
58 | if (Object.keys(obj).length === 0) return obj;
59 |
60 | return recursivePruneLocales(obj, locales, locale);
61 | }
62 |
--------------------------------------------------------------------------------
/libs/astro-nanostores-i18n/src/integration.ts:
--------------------------------------------------------------------------------
1 | import type { ComponentsJSON } from "@nanostores/i18n";
2 | import type { AstroIntegration } from "astro";
3 | import { addVirtualImports, createResolver } from "astro-integration-kit";
4 | import { name } from "../package.json";
5 |
6 | type Options = {
7 | translations?: Record;
8 | addMiddleware?: boolean;
9 | };
10 |
11 | /**
12 | * Astro integration for nanostores-i18n.
13 | *
14 | * This integration sets up the i18n configuration and provides the necessary
15 | * stores for managing translations and locale settings.
16 | *
17 | * @returns {AstroIntegration} The Astro integration object.
18 | */
19 | const createPlugin = (options: Options): AstroIntegration => {
20 | const { resolve } = createResolver(import.meta.url);
21 | return {
22 | name,
23 | hooks: {
24 | "astro:config:setup": (params) => {
25 | const { config, logger, addMiddleware } = params;
26 |
27 | if (!config.i18n) {
28 | logger.error(
29 | `The ${name} integration requires the i18n configuration in your Astro config. Please add it to your astro.config.ts file.`
30 | );
31 | return;
32 | }
33 |
34 | addVirtualImports(params, {
35 | name,
36 | imports: {
37 | [`${name}:runtime`]: `import { initializeI18n, useFormat, useI18n, currentLocale } from "${resolve("./runtime.js")}";
38 |
39 | initializeI18n("${config.i18n.defaultLocale}", ${JSON.stringify(options.translations || {})});
40 |
41 | export { useFormat, useI18n, currentLocale };
42 | `,
43 | },
44 | });
45 |
46 | if (options.addMiddleware) {
47 | addMiddleware({
48 | entrypoint: `${name}/middleware`,
49 | order: "pre",
50 | });
51 | }
52 | },
53 | "astro:config:done": (params) => {
54 | const { injectTypes } = params;
55 | injectTypes({
56 | filename: `${name}.d.ts`,
57 | content: `declare module "${name}:runtime" {
58 | import type { Components, Translations } from '@nanostores/i18n';
59 | export declare const currentLocale: import('nanostores').PreinitializedWritableAtom & object;
60 | export declare const initializeI18n: (defaultLocale: string, translations: Record) => void;
61 | export declare const useFormat: () => import('@nanostores/i18n').Formatter;
62 | export declare const useI18n: (componentName: string, baseTranslations: Body) => Body;
63 | }
64 | `,
65 | });
66 | },
67 | },
68 | };
69 | };
70 |
71 | export default createPlugin;
72 |
--------------------------------------------------------------------------------
/libs/astro-utils-i18n/src/utils/route.ts:
--------------------------------------------------------------------------------
1 | import { resolvePath } from "./path";
2 |
3 | type Segments = Record;
4 | export type SegmentTranslations = Record;
5 | type RouteSegment = {
6 | spread: boolean;
7 | param: boolean;
8 | value: string;
9 | };
10 | type RoutePattern = RouteSegment[];
11 |
12 | /**
13 | * Parses a route pattern into an array of route segments.
14 | *
15 | * @param routePattern is a string that templates the a route (e.g. /[...locale]/blog/[slug])
16 | */
17 | export function parseRoutePattern(routePattern: string): RoutePattern {
18 | const segments = routePattern
19 | .split("/")
20 | .filter((segment) => segment !== "")
21 | .map((segment) => {
22 | if (segment.startsWith("[") && segment.endsWith("]")) {
23 | return {
24 | spread: segment.startsWith("[..."),
25 | param: true,
26 | value: segment.replace("[", "").replace("...", "").replace("]", ""),
27 | };
28 | }
29 |
30 | return {
31 | spread: false,
32 | param: false,
33 | value: segment,
34 | };
35 | });
36 |
37 | if (segments.length === 0) {
38 | return [{ spread: false, param: false, value: "/" }];
39 | }
40 |
41 | return segments;
42 | }
43 |
44 | /**
45 | * Constructs a full path by resolving a base path with a route pattern and corresponding segment values.
46 | *
47 | * @param routePattern - An array of route segments that define the structure of the route.
48 | * Each segment can either be a static value or a parameter.
49 | * @param segmentValues - An object mapping parameter names to their corresponding values.
50 | * These values are used to replace parameterized segments in the route pattern.
51 | * @param basePath - The base path to resolve the constructed route against.
52 | *
53 | * @returns The resolved path as a string.
54 | *
55 | * @throws {Error} If a required segment value for a parameterized route segment is missing
56 | * and the segment is not marked as a spread segment.
57 | */
58 | export function buildPath(routePattern: RoutePattern, segmentValues: Segments, basePath: string) {
59 | return resolvePath(
60 | basePath,
61 | ...routePattern.map((segment) => {
62 | if (segment.param) {
63 | if (!segmentValues[segment.value] && !segment.spread) {
64 | throw new Error(`No segment value found for route segment "${segment.value}". Did you forget to provide it?`);
65 | }
66 | if (segmentValues[segment.value]?.includes("/") && !segment.spread) {
67 | throw new Error(
68 | `The segment value "${segmentValues[segment.value]}" for route segment "${segment.value}" contains a slash. Did you forget to add "..." to the route pattern?`
69 | );
70 | }
71 |
72 | return segmentValues[segment.value];
73 | }
74 |
75 | return `${segment.value}`;
76 | })
77 | );
78 | }
79 |
--------------------------------------------------------------------------------
/libs/astro-utils-i18n/src/utils/path.ts:
--------------------------------------------------------------------------------
1 | const TRIM_SLASHES_PATTERN = /^\/|\/$/g;
2 | const TRIM_RELATIVE_PATTERN = /^\.{0,2}\/|\/$/g;
3 | const DOUBLE_POINTS_PATTERN = /\.{2}/g;
4 | const DOUBLE_SLASH_PATTERN = /\/{2}/g;
5 | const DIRNAME_PATTERN = /\/[^/]+$/g;
6 | const INDEX_COMMON_TRANSLATION_ID = "index";
7 | const createLocalePattern = (pathLocale: string) => new RegExp(`(?<=[./])${pathLocale}(?=[./])`, "i");
8 |
9 | export function joinPath(...paths: Array) {
10 | return paths.filter(Boolean).join("/");
11 | }
12 |
13 | /**
14 | * Resolves and joins multiple path segments into a single normalized path.
15 | *
16 | * This function trims leading and trailing slashes from string path segments
17 | * and ensures the resulting path starts with a single forward slash (`/`).
18 | * Non-string segments (e.g., numbers or `undefined`) are included as-is.
19 | *
20 | * @param paths - An array of path segments which can be strings, numbers, or `undefined`.
21 | * @returns A normalized path string starting with a forward slash.
22 | */
23 | export function resolvePath(...paths: Array) {
24 | const trimmedPaths = paths.map((path) => (typeof path === "string" ? path.replace(TRIM_SLASHES_PATTERN, "") : path));
25 | return `/${joinPath(...trimmedPaths)}`;
26 | }
27 |
28 | export const trimSlashes = (path: string) => {
29 | return path === "/" ? path : path.replace(TRIM_SLASHES_PATTERN, "");
30 | };
31 |
32 | export const trimRelativePath = (path: string) => {
33 | return path === "/" ? path : path.replace(TRIM_RELATIVE_PATTERN, "");
34 | };
35 |
36 | export const parseLocale = (path: string, locales: string[], defaultLocale: string) => {
37 | if (!path.startsWith("/")) path = `/${path}`;
38 | if (!path.endsWith("/")) path = `${path}/`;
39 | const localePattern = createLocalePattern(`(${locales.join("|")})`);
40 | const locale = path.match(localePattern)?.[0];
41 |
42 | return locale ? locale : defaultLocale;
43 | };
44 |
45 | export const createTranslationId = (path: string, locale?: string) => {
46 | if (locale) path = path.replace(createLocalePattern(locale), "");
47 | path = path.replace(DOUBLE_POINTS_PATTERN, ".");
48 | path = path.replace(DOUBLE_SLASH_PATTERN, "/");
49 | path = trimSlashes(path);
50 |
51 | return path === "" || path === "/" ? INDEX_COMMON_TRANSLATION_ID : path;
52 | };
53 |
54 | export const createContentPath = (path: string, base?: string | URL, locale?: string) => {
55 | let basePath = base ? (base instanceof URL ? base.pathname : base) : undefined;
56 | if (basePath) {
57 | basePath = trimRelativePath(basePath);
58 | const basePathIndex = path.indexOf(basePath);
59 | path = basePathIndex !== -1 ? path.slice(basePathIndex + basePath.length) : path;
60 | }
61 | path = createTranslationId(path, locale);
62 | if (path.includes(INDEX_COMMON_TRANSLATION_ID)) {
63 | path = path.replace(DIRNAME_PATTERN, "");
64 | }
65 | path = path.includes("/") ? path.replace(DIRNAME_PATTERN, "") : "";
66 |
67 | return path;
68 | };
69 |
--------------------------------------------------------------------------------
/libs/astro-nanostores-i18n/test/integration.spec.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it, vi } from "vitest";
2 | import integration from "../src/integration";
3 | import { AstroIntegrationLogger, BaseIntegrationHooks } from "astro";
4 | import { addVirtualImports } from "astro-integration-kit";
5 |
6 | vi.mock("astro-integration-kit", () => {
7 | return {
8 | addVirtualImports: vi.fn(),
9 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
10 | createResolver: (_base: string) => ({
11 | resolve: (path: string) => [path].join("/"),
12 | }),
13 | };
14 | });
15 |
16 | describe("integration.ts", () => {
17 | it("should have a valid integration export", () => {
18 | expect(integration).toBeDefined();
19 | expect(typeof integration).toBe("function");
20 | });
21 | it("should log an error if i18n config is missing", () => {
22 | const mockParams = {
23 | config: {},
24 | logger: {
25 | error: (message: string) => {
26 | expect(message).toContain("The astro-nanostores-i18n integration requires the i18n configuration");
27 | },
28 | },
29 | };
30 | const i = integration({});
31 | const hook = i.hooks["astro:config:setup"];
32 | expect(hook).toBeDefined();
33 | if (hook) {
34 | hook(mockParams as Parameters[0]);
35 | }
36 | });
37 | it("should register virtual module", () => {
38 | const mockParams = {
39 | config: { i18n: { defaultLocale: "en" } },
40 | logger: {} as AstroIntegrationLogger,
41 | };
42 | const i = integration({});
43 | const hook = i.hooks["astro:config:setup"];
44 | expect(hook).toBeDefined();
45 | if (hook) {
46 | hook(mockParams as Parameters[0]);
47 | }
48 | expect(vi.mocked(addVirtualImports).mock.calls.length).toBe(1);
49 | expect(vi.mocked(addVirtualImports).mock.calls[0][1]).toMatchSnapshot();
50 | });
51 | it("should inject the types for the virtual module", () => {
52 | const mockParams = {
53 | injectTypes: (params: { filename: string; content: string }) => {
54 | expect(params.filename).toBe("astro-nanostores-i18n.d.ts");
55 | expect(params.content).toMatchSnapshot();
56 | },
57 | };
58 | const i = integration({});
59 | const hook = i.hooks["astro:config:done"];
60 | expect(hook).toBeDefined();
61 | if (hook) {
62 | hook(mockParams as Parameters[0]);
63 | }
64 | });
65 | it("should add middleware if configured", () => {
66 | const mockParams = {
67 | addMiddleware: vi.fn(),
68 | config: { i18n: { defaultLocale: "en" } },
69 | };
70 | const i = integration({ addMiddleware: true });
71 | const hook = i.hooks["astro:config:setup"];
72 | expect(hook).toBeDefined();
73 | if (hook) {
74 | hook(mockParams as Parameters[0]);
75 | }
76 | expect(mockParams.addMiddleware).toHaveBeenCalledWith({
77 | entrypoint: "astro-nanostores-i18n/middleware",
78 | order: "pre",
79 | });
80 | });
81 | });
82 |
--------------------------------------------------------------------------------
/.github/instructions/nx.instructions.md:
--------------------------------------------------------------------------------
1 | ---
2 | applyTo: '**'
3 | ---
4 |
5 | // This file is automatically generated by Nx Console
6 |
7 | You are in an nx workspace using Nx 21.4.1 and pnpm as the package manager.
8 |
9 | You have access to the Nx MCP server and the tools it provides. Use them. Follow these guidelines in order to best help the user:
10 |
11 | # General Guidelines
12 | - When answering questions, use the nx_workspace tool first to gain an understanding of the workspace architecture
13 | - For questions around nx configuration, best practices or if you're unsure, use the nx_docs tool to get relevant, up-to-date docs!! Always use this instead of assuming things about nx configuration
14 | - If the user needs help with an Nx configuration or project graph error, use the 'nx_workspace' tool to get any errors
15 | - To help answer questions about the workspace structure or simply help with demonstrating how tasks depend on each other, use the 'nx_visualize_graph' tool
16 |
17 | # Generation Guidelines
18 | If the user wants to generate something, use the following flow:
19 |
20 | - learn about the nx workspace and any specifics the user needs by using the 'nx_workspace' tool and the 'nx_project_details' tool if applicable
21 | - get the available generators using the 'nx_generators' tool
22 | - decide which generator to use. If no generators seem relevant, check the 'nx_available_plugins' tool to see if the user could install a plugin to help them
23 | - get generator details using the 'nx_generator_schema' tool
24 | - you may use the 'nx_docs' tool to learn more about a specific generator or technology if you're unsure
25 | - decide which options to provide in order to best complete the user's request. Don't make any assumptions and keep the options minimalistic
26 | - open the generator UI using the 'nx_open_generate_ui' tool
27 | - wait for the user to finish the generator
28 | - read the generator log file using the 'nx_read_generator_log' tool
29 | - use the information provided in the log file to answer the user's question or continue with what they were doing
30 |
31 | # Running Tasks Guidelines
32 | If the user wants help with tasks or commands (which include keywords like "test", "build", "lint", or other similar actions), use the following flow:
33 | - Use the 'nx_current_running_tasks_details' tool to get the list of tasks (this can include tasks that were completed, stopped or failed).
34 | - If there are any tasks, ask the user if they would like help with a specific task then use the 'nx_current_running_task_output' tool to get the terminal output for that task/command
35 | - Use the terminal output from 'nx_current_running_task_output' to see what's wrong and help the user fix their problem. Use the appropriate tools if necessary
36 | - If the user would like to rerun the task or command, always use `nx run ` to rerun in the terminal. This will ensure that the task will run in the nx context and will be run the same way it originally executed
37 | - If the task was marked as "continuous" do not offer to rerun the task. This task is already running and the user can see the output in the terminal. You can use 'nx_current_running_task_output' to get the output of the task to verify the output.
38 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/libs/astro-loader-i18n/test/props-and-params/i18n-props-and-params.spec.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from "vitest";
2 | import { i18nProps, i18nPropsAndParams } from "../../src/props-and-params/i18n-props-and-params";
3 | import { SegmentTranslations } from "../../src/utils/route";
4 |
5 | const COLLECTION_FIXTURE = [
6 | {
7 | data: { locale: "de-CH", translationId: "magic.mdx", contentPath: "test", basePath: "/", slug: "slug-de-ch" },
8 | },
9 | {
10 | data: { locale: "zh-CN", translationId: "magic.mdx", contentPath: "test", basePath: "/", slug: "slug-zh-cn" },
11 | },
12 | ];
13 |
14 | describe("i18nPropsAndParams", () => {
15 | it("should generate paths for a valid collection", () => {
16 | const routePattern = "[...locale]/[blog]/posts/[...slug]";
17 | const segmentTranslations: SegmentTranslations = { "de-CH": { blog: "logbuch" }, "zh-CN": { blog: "blog" } };
18 | const result = i18nPropsAndParams(COLLECTION_FIXTURE, {
19 | routePattern,
20 | segmentTranslations,
21 | defaultLocale: "de-CH",
22 | generateSegments: (entry) => ({ slug: entry.data.slug }),
23 | });
24 | expect(result).toMatchSnapshot();
25 | });
26 | it("should generate full paths if prefixDefaultLocale is true", () => {
27 | const routePattern = "[...locale]/[blog]/posts/[...slug]";
28 | const segmentTranslations: SegmentTranslations = { "de-CH": { blog: "logbuch" }, "zh-CN": { blog: "blog" } };
29 | const result = i18nPropsAndParams(COLLECTION_FIXTURE, {
30 | routePattern,
31 | segmentTranslations,
32 | defaultLocale: "de-CH",
33 | prefixDefaultLocale: true,
34 | generateSegments: (entry) => ({ slug: entry.data.slug }),
35 | });
36 | expect(result).toMatchSnapshot();
37 | });
38 | it("should throw an error if an unknown locale is used", () => {
39 | const routePattern = "[...locale]/[blog]/posts/[...slug]";
40 | const segmentTranslations: SegmentTranslations = { "de-CH": { blog: "logbuch" }, "en-US": { blog: "blog" } };
41 | expect(() =>
42 | i18nPropsAndParams(COLLECTION_FIXTURE, {
43 | routePattern,
44 | segmentTranslations,
45 | defaultLocale: "de-CH",
46 | generateSegments: (entry) => ({ slug: entry.data.slug }),
47 | })
48 | ).toThrowErrorMatchingSnapshot();
49 | });
50 | it("should throw an error if not all params can be filled", () => {
51 | const routePattern = "[...locale]/[blog]/[unknown]/posts/[slug]";
52 | const segmentTranslations: SegmentTranslations = { "de-CH": { blog: "logbuch" }, "zh-CN": { blog: "blog" } };
53 | expect(() =>
54 | i18nPropsAndParams(COLLECTION_FIXTURE, {
55 | routePattern,
56 | segmentTranslations,
57 | defaultLocale: "de-CH",
58 | generateSegments: (entry) => ({ slug: entry.data.slug }),
59 | })
60 | ).toThrowErrorMatchingSnapshot();
61 | });
62 | });
63 |
64 | describe("i18nProps", () => {
65 | it("should generate paths for a valid collection", () => {
66 | const routePattern = "[...locale]/[blog]/posts/[...slug]";
67 | const segmentTranslations: SegmentTranslations = { "de-CH": { blog: "logbuch" }, "zh-CN": { blog: "blog" } };
68 | const result = i18nProps(COLLECTION_FIXTURE, {
69 | routePattern,
70 | segmentTranslations,
71 | defaultLocale: "de-CH",
72 | generateSegments: (entry) => ({ slug: entry.data.slug }),
73 | });
74 | expect(result).toMatchSnapshot();
75 | });
76 | });
77 |
--------------------------------------------------------------------------------
/libs/astro-nanostores-i18n/src/bin/extract.ts:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | import { readFile, writeFile } from "node:fs/promises";
4 | import { parseArgs } from "node:util";
5 | import { createContext, runInContext } from "node:vm";
6 | import { parse } from "@astrojs/compiler";
7 | import { is, walk } from "@astrojs/compiler/utils";
8 | import type { Messages } from "@nanostores/i18n";
9 | import glob from "fast-glob";
10 | import * as ts from "typescript";
11 |
12 | const { values } = parseArgs({
13 | args: process.argv.slice(2),
14 | options: {
15 | identifier: {
16 | type: "string",
17 | default: "messages",
18 | },
19 | glob: {
20 | type: "string",
21 | default: "./src/**/*.astro",
22 | },
23 | out: {
24 | type: "string",
25 | default: "./src/translations/extract.json",
26 | },
27 | help: {
28 | type: "boolean",
29 | short: "h",
30 | },
31 | },
32 | });
33 |
34 | if (values.help) {
35 | // eslint-disable-next-line no-console
36 | console.log(`
37 | Usage: extract-messages [options]
38 |
39 | Options:
40 | --identifier Variable name to extract messages from (default: "messages")
41 | --glob Glob pattern for finding Astro files (default: "./src/**/*.astro")
42 | --out Output path for messages file (default: "./src/translations/extract.json")
43 | --help, -h Show this help message
44 | `);
45 | process.exit(0);
46 | }
47 |
48 | const components = await glob(values.glob, { absolute: true });
49 | const allMessages: Record = {};
50 | const context = createContext({
51 | exports: {},
52 | Object,
53 | useI18n: (namespace: string, messages: Messages) => {
54 | allMessages[namespace] = { ...allMessages[namespace], ...messages };
55 | return messages;
56 | },
57 | params: (template: string) => template,
58 | count: (counts: Record) => counts,
59 | });
60 |
61 | function extractMessagesFromAST(code: string) {
62 | const sourceFile = ts.createSourceFile("temp.ts", code, ts.ScriptTarget.Latest, true);
63 |
64 | let messagesExport: string | undefined;
65 |
66 | function visit(node: ts.Node) {
67 | if (ts.isVariableStatement(node)) {
68 | const declaration = node.declarationList.declarations[0];
69 | if (ts.isIdentifier(declaration.name) && declaration.name.text === values.identifier && declaration.initializer) {
70 | messagesExport = node.getText();
71 | }
72 | }
73 |
74 | ts.forEachChild(node, visit);
75 | }
76 |
77 | visit(sourceFile);
78 | return messagesExport;
79 | }
80 |
81 | await Promise.all(
82 | components.map(async (file) => {
83 | const content = await readFile(file, "utf-8");
84 | const { ast } = await parse(content, { position: false });
85 |
86 | walk(ast, (node) => {
87 | if (is.frontmatter(node)) {
88 | const { value: code } = node;
89 |
90 | try {
91 | const extractedMessages = extractMessagesFromAST(code);
92 | if (extractedMessages) {
93 | const code = ts.transpile(extractedMessages);
94 | runInContext(code, context);
95 | }
96 | } catch (error: unknown) {
97 | console.error(`Error processing file ${file}:`, error);
98 | process.exit(1);
99 | }
100 | }
101 | });
102 | })
103 | );
104 |
105 | if (Object.keys(allMessages).length > 0) {
106 | const messagesJSON = Object.fromEntries(Object.entries(allMessages).map(([namespace, messages]) => [namespace, messages]));
107 |
108 | await writeFile(values.out, JSON.stringify(messagesJSON, null, 2));
109 | process.exit(0);
110 | } else {
111 | console.warn("No messages found in the provided components.");
112 | }
113 |
--------------------------------------------------------------------------------
/libs/astro-nanostores-i18n/README.md:
--------------------------------------------------------------------------------
1 | # astro-nanostores-i18n
2 |
3 | [](https://npmjs.org/astro-nanostores-i18n)
4 | [](https://npmjs.org/astro-nanostores-i18n)
5 |
6 | `astro-nanostores-i18n` is an integration of [@nanostores/i18n](https://github.com/nanostores/i18n) into [Astro](https://astro.build/).
7 |
8 | ## Usage
9 |
10 | 1. Install the package `astro-nanostores-i18n`:
11 |
12 | npm
13 |
14 | ```bash
15 | npm install astro-nanostores-i18n @nanostores/i18n nanostores
16 | ```
17 |
18 |
19 | yarn
20 |
21 | ```bash
22 | yarn add astro-nanostores-i18n @nanostores/i18n nanostores
23 | ```
24 |
25 |
26 | pnpm
27 |
28 | ```bash
29 | pnpm add astro-nanostores-i18n @nanostores/i18n nanostores
30 | ```
31 |
32 | 1. Add the integration to your `astro.config.mjs`:
33 |
34 | ```javascript
35 | import { defineConfig } from 'astro/config';
36 | import nanostoresI18n from 'astro-nanostores-i18n';
37 | import zhCN from "./src/translations/zh-CN.json";
38 |
39 | export default defineConfig({
40 | i18n: {
41 | defaultLocale: "de-CH",
42 | locales: ["de-CH", "zh-CN"],
43 | },
44 | integrations: [
45 | nanostoresI18n({
46 | // Load your translations here.
47 | translations: {
48 | "zh-CN": zhCN,
49 | },
50 | // Detects and sets the locale based on the URL pathname.
51 | // Default: false
52 | addMiddleware: true,
53 | }),
54 | ],
55 | });
56 | ```
57 | 1. Use the `useI18n` in your Astro pages or components:
58 | ```tsx
59 | ---
60 | import Page from "../layouts/Page.astro";
61 | import { useI18n, useFormat, currentLocale } from "astro-nanostores-i18n:runtime";
62 | import { count, params } from "@nanostores/i18n";
63 |
64 | // Override the current locale if needed
65 | currentLocale.set("zh-CN");
66 |
67 | // Name the constant `messages` to be able to use the extraction script.
68 | const messages = useI18n("example", {
69 | message: "Irgend eine Nachricht.",
70 | param: params("Eine Nachricht mit einem Parameter: {irgendwas}"),
71 | count: count({
72 | one: "Ein Eintrag",
73 | many: "{count} Einträge",
74 | }),
75 | });
76 |
77 | const format = useFormat();
78 | ---
79 |
80 |
81 | astro-nanostores-i18n
82 | {messages.message}
83 | {messages.param({ irgendwas: "Something" })}
84 | {format.time(new Date())}
85 |
86 | ```
87 |
88 | ### Extracting translations
89 |
90 | `astro-nanostores-i18n` provides a script to extract translations from your Astro components. You can add the following script to your `package.json`:
91 |
92 | ```json
93 | {
94 | "scripts": {
95 | "i18n:extract": "extract-messages"
96 | }
97 | }
98 | ```
99 |
100 | Then you can run the script to extract messages from your Astro components:
101 |
102 | ```bash
103 | npm run i18n:extract
104 | ```
105 |
106 | It has the following options:
107 |
108 | ```txt
109 | Usage: extract-messages [options]
110 |
111 | Options:
112 | --glob Glob pattern for finding Astro files (default: "./src/**/*.astro")
113 | --out Output path for messages file (default: "./src/translations/extract.json")
114 | --help, -h Show this help message
115 | ```
116 |
117 | ## Resources
118 |
119 | - https://lou.gg/blog/astro-integrations-explained
120 | - https://hideoo.dev/notes/starlight-plugin-share-data-with-astro-runtime/
121 |
--------------------------------------------------------------------------------
/libs/astro-loader-i18n/test/props-and-params/__snapshots__/i18n-props-and-params.spec.ts.snap:
--------------------------------------------------------------------------------
1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2 |
3 | exports[`i18nProps > should generate paths for a valid collection 1`] = `
4 | [
5 | {
6 | "data": {
7 | "basePath": "/",
8 | "contentPath": "test",
9 | "locale": "de-CH",
10 | "slug": "slug-de-ch",
11 | "translationId": "magic.mdx",
12 | },
13 | "translatedPath": "/logbuch/posts/slug-de-ch",
14 | "translations": {
15 | "de-CH": "/logbuch/posts/slug-de-ch",
16 | "zh-CN": "/zh-CN/blog/posts/slug-zh-cn",
17 | },
18 | },
19 | {
20 | "data": {
21 | "basePath": "/",
22 | "contentPath": "test",
23 | "locale": "zh-CN",
24 | "slug": "slug-zh-cn",
25 | "translationId": "magic.mdx",
26 | },
27 | "translatedPath": "/zh-CN/blog/posts/slug-zh-cn",
28 | "translations": {
29 | "de-CH": "/logbuch/posts/slug-de-ch",
30 | "zh-CN": "/zh-CN/blog/posts/slug-zh-cn",
31 | },
32 | },
33 | ]
34 | `;
35 |
36 | exports[`i18nPropsAndParams > should generate full paths if prefixDefaultLocale is true 1`] = `
37 | [
38 | {
39 | "params": {
40 | "blog": "logbuch",
41 | "locale": "de-CH",
42 | "slug": "slug-de-ch",
43 | },
44 | "props": {
45 | "data": {
46 | "basePath": "/",
47 | "contentPath": "test",
48 | "locale": "de-CH",
49 | "slug": "slug-de-ch",
50 | "translationId": "magic.mdx",
51 | },
52 | "translatedPath": "/de-CH/logbuch/posts/slug-de-ch",
53 | "translations": {
54 | "de-CH": "/de-CH/logbuch/posts/slug-de-ch",
55 | "zh-CN": "/zh-CN/blog/posts/slug-zh-cn",
56 | },
57 | },
58 | },
59 | {
60 | "params": {
61 | "blog": "blog",
62 | "locale": "zh-CN",
63 | "slug": "slug-zh-cn",
64 | },
65 | "props": {
66 | "data": {
67 | "basePath": "/",
68 | "contentPath": "test",
69 | "locale": "zh-CN",
70 | "slug": "slug-zh-cn",
71 | "translationId": "magic.mdx",
72 | },
73 | "translatedPath": "/zh-CN/blog/posts/slug-zh-cn",
74 | "translations": {
75 | "de-CH": "/de-CH/logbuch/posts/slug-de-ch",
76 | "zh-CN": "/zh-CN/blog/posts/slug-zh-cn",
77 | },
78 | },
79 | },
80 | ]
81 | `;
82 |
83 | exports[`i18nPropsAndParams > should generate paths for a valid collection 1`] = `
84 | [
85 | {
86 | "params": {
87 | "blog": "logbuch",
88 | "locale": undefined,
89 | "slug": "slug-de-ch",
90 | },
91 | "props": {
92 | "data": {
93 | "basePath": "/",
94 | "contentPath": "test",
95 | "locale": "de-CH",
96 | "slug": "slug-de-ch",
97 | "translationId": "magic.mdx",
98 | },
99 | "translatedPath": "/logbuch/posts/slug-de-ch",
100 | "translations": {
101 | "de-CH": "/logbuch/posts/slug-de-ch",
102 | "zh-CN": "/zh-CN/blog/posts/slug-zh-cn",
103 | },
104 | },
105 | },
106 | {
107 | "params": {
108 | "blog": "blog",
109 | "locale": "zh-CN",
110 | "slug": "slug-zh-cn",
111 | },
112 | "props": {
113 | "data": {
114 | "basePath": "/",
115 | "contentPath": "test",
116 | "locale": "zh-CN",
117 | "slug": "slug-zh-cn",
118 | "translationId": "magic.mdx",
119 | },
120 | "translatedPath": "/zh-CN/blog/posts/slug-zh-cn",
121 | "translations": {
122 | "de-CH": "/logbuch/posts/slug-de-ch",
123 | "zh-CN": "/zh-CN/blog/posts/slug-zh-cn",
124 | },
125 | },
126 | },
127 | ]
128 | `;
129 |
130 | exports[`i18nPropsAndParams > should throw an error if an unknown locale is used 1`] = `[Error: No slugs found for locale zh-CN]`;
131 |
132 | exports[`i18nPropsAndParams > should throw an error if not all params can be filled 1`] = `[Error: No segment value found for route segment "unknown". Did you forget to provide it?]`;
133 |
--------------------------------------------------------------------------------
/libs/astro-loader-i18n/test/loaders/__snapshots__/i18n-loader.spec.ts.snap:
--------------------------------------------------------------------------------
1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2 |
3 | exports[`i18nLoader > should put common translation id and locale in data 1`] = `
4 | [
5 | [
6 | "de-CH/about",
7 | {
8 | "body": "---
9 | title: Über mich
10 | ---
11 |
12 | Test
13 | ",
14 | "data": {
15 | "basePath": "/",
16 | "contentPath": "",
17 | "locale": "de-CH",
18 | "title": "Über mich",
19 | "translationId": "src/content/pages/about.mdx",
20 | },
21 | "filePath": "src/content/pages/de-CH/about.mdx",
22 | "id": "de-CH/about",
23 | },
24 | ],
25 | [
26 | "de-CH/projects",
27 | {
28 | "body": "---
29 | title: Projekte
30 | ---
31 |
32 | Test
33 | ",
34 | "data": {
35 | "basePath": "/",
36 | "contentPath": "subpath",
37 | "locale": "de-CH",
38 | "title": "Projekte",
39 | "translationId": "src/content/pages/subpath/projects.mdx",
40 | },
41 | "filePath": "src/content/pages/de-CH/subpath/projects.mdx",
42 | "id": "de-CH/projects",
43 | },
44 | ],
45 | [
46 | "zh-CN/about",
47 | {
48 | "body": "---
49 | title: 关于我
50 | ---
51 |
52 | 测试
53 | ",
54 | "data": {
55 | "basePath": "/",
56 | "contentPath": "",
57 | "locale": "zh-CN",
58 | "title": "关于我",
59 | "translationId": "src/content/pages/about.mdx",
60 | },
61 | "filePath": "src/content/pages/zh-CN/about.mdx",
62 | "id": "zh-CN/about",
63 | },
64 | ],
65 | [
66 | "zh-CN/projects",
67 | {
68 | "body": "---
69 | title: 项目
70 | ---
71 |
72 | 测试
73 | ",
74 | "data": {
75 | "basePath": "/",
76 | "contentPath": "subpath",
77 | "locale": "zh-CN",
78 | "title": "项目",
79 | "translationId": "src/content/pages/subpath/projects.mdx",
80 | },
81 | "filePath": "src/content/pages/zh-CN/subpath/projects.mdx",
82 | "id": "zh-CN/projects",
83 | },
84 | ],
85 | ]
86 | `;
87 |
88 | exports[`i18nLoader > should throw error if i18n config is missing 1`] = `[Error: i18n configuration is missing in your astro config]`;
89 |
90 | exports[`i18nLoader > should use locale.codes if locale is an object 1`] = `
91 | [
92 | [
93 | "de-CH/about",
94 | {
95 | "body": "---
96 | title: Über mich
97 | ---
98 |
99 | Test
100 | ",
101 | "data": {
102 | "basePath": "/",
103 | "contentPath": "",
104 | "locale": "de-CH",
105 | "title": "Über mich",
106 | "translationId": "src/content/pages/about.mdx",
107 | },
108 | "filePath": "src/content/pages/de-CH/about.mdx",
109 | "id": "de-CH/about",
110 | },
111 | ],
112 | [
113 | "de-CH/projects",
114 | {
115 | "body": "---
116 | title: Projekte
117 | ---
118 |
119 | Test
120 | ",
121 | "data": {
122 | "basePath": "/",
123 | "contentPath": "subpath",
124 | "locale": "de-CH",
125 | "title": "Projekte",
126 | "translationId": "src/content/pages/subpath/projects.mdx",
127 | },
128 | "filePath": "src/content/pages/de-CH/subpath/projects.mdx",
129 | "id": "de-CH/projects",
130 | },
131 | ],
132 | [
133 | "zh-CN/about",
134 | {
135 | "body": "---
136 | title: 关于我
137 | ---
138 |
139 | 测试
140 | ",
141 | "data": {
142 | "basePath": "/",
143 | "contentPath": "",
144 | "locale": "zh-CN",
145 | "title": "关于我",
146 | "translationId": "src/content/pages/about.mdx",
147 | },
148 | "filePath": "src/content/pages/zh-CN/about.mdx",
149 | "id": "zh-CN/about",
150 | },
151 | ],
152 | [
153 | "zh-CN/projects",
154 | {
155 | "body": "---
156 | title: 项目
157 | ---
158 |
159 | 测试
160 | ",
161 | "data": {
162 | "basePath": "/",
163 | "contentPath": "subpath",
164 | "locale": "zh-CN",
165 | "title": "项目",
166 | "translationId": "src/content/pages/subpath/projects.mdx",
167 | },
168 | "filePath": "src/content/pages/zh-CN/subpath/projects.mdx",
169 | "id": "zh-CN/projects",
170 | },
171 | ],
172 | ]
173 | `;
174 |
--------------------------------------------------------------------------------
/libs/astro-utils-i18n/test/utils/collection.spec.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from "vitest";
2 | import { getAllUniqueKeys, pruneLocales } from "../../src/astro-utils-i18n";
3 |
4 | describe("getAllUniqueKeys", () => {
5 | it("should return all unique keys of an object", () => {
6 | const result = getAllUniqueKeys({
7 | a: {
8 | b: {
9 | c: 1,
10 | d: 2,
11 | },
12 | e: 3,
13 | },
14 | b: 4,
15 | g: {
16 | a: 5,
17 | i: {
18 | c: 6,
19 | k: 7,
20 | },
21 | },
22 | });
23 | expect(result).toMatchSnapshot();
24 | });
25 |
26 | it("should return all unique keys of an object nested in an array", () => {
27 | const result = getAllUniqueKeys({
28 | a: [
29 | {
30 | z: 1,
31 | y: {
32 | de: 2,
33 | en: 3,
34 | },
35 | },
36 | {
37 | z: 4,
38 | y: {
39 | de: 5,
40 | en: 6,
41 | },
42 | },
43 | ],
44 | });
45 | expect(result).toMatchSnapshot();
46 | });
47 |
48 | it("should return all unique keys of an object with arrays in arrays", () => {
49 | const result = getAllUniqueKeys({
50 | a: [
51 | [
52 | {
53 | z: 1,
54 | y: [
55 | {
56 | de: 2,
57 | en: 3,
58 | },
59 | {
60 | de: 4,
61 | en: 5,
62 | },
63 | ],
64 | },
65 | ],
66 | [
67 | {
68 | z: 6,
69 | y: [
70 | {
71 | de: 7,
72 | en: 8,
73 | },
74 | {
75 | de: 9,
76 | en: 10,
77 | },
78 | ],
79 | },
80 | ],
81 | ],
82 | });
83 | expect(result).toMatchSnapshot();
84 | });
85 | });
86 |
87 | describe("pruneLocales", () => {
88 | it("should prune locales with top level locale data as objects", () => {
89 | const result = () => {
90 | pruneLocales(
91 | {
92 | en: { a: 1, b: 2 },
93 | fr: { a: 3, b: 4 },
94 | de: { a: 5, b: 6 },
95 | },
96 | ["en", "fr", "de"],
97 | "en"
98 | );
99 | };
100 | expect(result).toThrowErrorMatchingSnapshot();
101 | });
102 | it("should prune locales with top level local data as strings", () => {
103 | const result = () => {
104 | pruneLocales(
105 | {
106 | en: "Hello",
107 | fr: "Bonjour",
108 | de: "Hallo",
109 | },
110 | ["en", "fr", "de"],
111 | "en"
112 | );
113 | };
114 | expect(result).toThrowErrorMatchingSnapshot();
115 | });
116 | it("should prune locales with no data", () => {
117 | const result = pruneLocales({}, ["en", "fr", "de"], "en");
118 | expect(result).toMatchSnapshot();
119 | });
120 | it("should prune locales with nested data", () => {
121 | const result = pruneLocales(
122 | {
123 | title: { en: "Title", fr: "Titre", de: "Titel" },
124 | nestedObject: {
125 | en: { a: 7, b: 8 },
126 | fr: { a: 9, b: 10 },
127 | de: { a: 11, b: 12 },
128 | },
129 | nestedArray: {
130 | en: [
131 | { a: 13, b: 14 },
132 | { a: 15, b: 16 },
133 | ],
134 | fr: [
135 | { a: 17, b: 18 },
136 | { a: 19, b: 20 },
137 | ],
138 | de: [
139 | { a: 21, b: 22 },
140 | { a: 23, b: 24 },
141 | ],
142 | },
143 | nestedArrayWithObjects: [
144 | {
145 | en: { a: 25, b: 26 },
146 | fr: { a: 27, b: 28 },
147 | de: { a: 29, b: 30 },
148 | },
149 | {
150 | en: { a: 31, b: 32 },
151 | fr: { a: 33, b: 34 },
152 | de: { a: 35, b: 36 },
153 | },
154 | ],
155 | string: "normal string",
156 | number: 123,
157 | boolean: true,
158 | array: [1, 2, 3],
159 | object: { a: 1, b: 2 },
160 | },
161 | ["en", "fr", "de"],
162 | "en"
163 | );
164 | expect(result).toMatchSnapshot();
165 | });
166 | });
167 |
--------------------------------------------------------------------------------
/libs/astro-loader-i18n/src/props-and-params/i18n-props-and-params.ts:
--------------------------------------------------------------------------------
1 | import { checkI18nLoaderCollection, I18nLoaderEntry } from "../schemas/i18n-loader-schema";
2 | import { buildPath, parseRoutePattern, SegmentTranslations } from "astro-utils-i18n";
3 | import { CollectionEntry, CollectionKey } from "astro:content";
4 |
5 | /**
6 | * Configuration options for internationalization (i18n) routing.
7 | *
8 | * @template C - The type of the entry used for segment generation.
9 | * @property defaultLocale - The default locale to use when none is specified.
10 | * @property routePattern - The route pattern string used for matching and generating localized routes.
11 | * @property segmentTranslations - An object containing translations for route segments.
12 | * @property generateSegments - (Optional) A function that generates a mapping of segment names to their translations for a given entry.
13 | * @property localeParamName - (Optional) The name of the parameter used to specify the locale in routes.
14 | * @property prefixDefaultLocale - (Optional) Whether to prefix the default locale in generated routes.
15 | */
16 | type Config = {
17 | defaultLocale: string;
18 | routePattern: string;
19 | segmentTranslations: SegmentTranslations;
20 | generateSegments?: (entry: C) => Record;
21 | localeParamName?: string;
22 | prefixDefaultLocale?: boolean;
23 | };
24 |
25 | const defaultConfig = {
26 | localeParamName: "locale",
27 | prefixDefaultLocale: false,
28 | generateSegments: () => ({}),
29 | } as const;
30 |
31 | function getSegmentTranslations(entry: I18nLoaderEntry, c: Omit>, "routePattern">) {
32 | const { data } = entry;
33 | if (!c.segmentTranslations[data.locale]) throw new Error(`No slugs found for locale ${data.locale}`);
34 |
35 | const currentLocale = !c.prefixDefaultLocale && data.locale === c.defaultLocale ? undefined : data.locale;
36 | const segmentValues = {
37 | [c.localeParamName]: currentLocale,
38 | ...c.segmentTranslations[data.locale],
39 | ...c.generateSegments(entry as C),
40 | };
41 |
42 | return segmentValues;
43 | }
44 |
45 | function calculatePropsAndParams>(collection: C[] | I18nLoaderEntry[], config: Config) {
46 | checkI18nLoaderCollection(collection);
47 | const { routePattern, ...c } = { ...defaultConfig, ...config };
48 | const route = parseRoutePattern(routePattern);
49 |
50 | return collection.map((entry) => {
51 | const { translationId } = entry.data;
52 | const entryTranslations = collection.filter((e) => e.data.translationId === translationId);
53 | const translations = entryTranslations.reduce(
54 | (previous, current) => {
55 | return {
56 | ...previous,
57 | [current.data.locale]: buildPath(route, getSegmentTranslations(current, c), current.data.basePath),
58 | };
59 | },
60 | {} as Record
61 | );
62 |
63 | const params = getSegmentTranslations(entry, c);
64 | const translatedPath = buildPath(route, params, entry.data.basePath);
65 |
66 | return {
67 | params,
68 | props: {
69 | ...entry,
70 | translations,
71 | translatedPath,
72 | } as C[][number] & I18nLoaderEntry[][number] & { translations: Record; translatedPath: string },
73 | };
74 | });
75 | }
76 |
77 | /**
78 | * Processes a collection of entries and generates i18n-related properties and parameters.
79 | *
80 | * @template C - The type of the collection entries.
81 | * @param collection - The collection of entries or an i18n collection.
82 | * @param config - The configuration object for i18n processing.
83 | * @returns An array of objects containing `params` and `props` for each entry.
84 | * @throws {Error} If route segment translations or slug parameters are invalid.
85 | */
86 | export function i18nPropsAndParams>(collection: C[] | I18nLoaderEntry[], config: Config) {
87 | return calculatePropsAndParams(collection, config);
88 | }
89 |
90 | /**
91 | * Processes a collection of entries and generates i18n-related properties.
92 | *
93 | * @template C - The type of the collection entries.
94 | * @param collection - The collection of entries or an i18n collection.
95 | * @param config - The configuration object for i18n processing.
96 | * @returns An array of objects containing the `props` for each entry.
97 | * @throws {Error} If route segment translations or slug parameters are invalid.
98 | */
99 | export function i18nProps>(collection: C[] | I18nLoaderEntry[], config: Config) {
100 | return calculatePropsAndParams(collection, config).map(({ props }) => {
101 | return props;
102 | });
103 | }
104 |
--------------------------------------------------------------------------------
/libs/astro-loader-i18n/test/loaders/__snapshots__/i18n-file-loader.spec.ts.snap:
--------------------------------------------------------------------------------
1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2 |
3 | exports[`i18nFileLoader > should handle input without i18n 1`] = `
4 | [
5 | [
6 | "omni",
7 | {
8 | "data": {
9 | "basePath": "/",
10 | "contentPath": "src/content/gallery",
11 | "cover": "./animals1.jpg",
12 | "images": [
13 | {
14 | "src": "./animals1.jpg",
15 | },
16 | {
17 | "src": "./animals2.jpg",
18 | },
19 | {
20 | "src": "./animals3.jpg",
21 | },
22 | {
23 | "src": "./animals4.jpg",
24 | },
25 | {
26 | "src": "./animals5.jpg",
27 | },
28 | ],
29 | "locale": "und",
30 | "title": "Omni",
31 | "translationId": "src/content/gallery/omni.yml",
32 | },
33 | "filePath": "src/content/gallery/omni.yml",
34 | "id": "omni",
35 | },
36 | ],
37 | ]
38 | `;
39 |
40 | exports[`i18nFileLoader > should put common translation id and locale in data 1`] = `
41 | [
42 | [
43 | "space/de-CH",
44 | {
45 | "data": {
46 | "basePath": "/",
47 | "contentPath": "src/content/gallery",
48 | "cover": "./space1.jpg",
49 | "images": [
50 | {
51 | "src": "./space1.jpg",
52 | "title": "Weltraum1",
53 | },
54 | {
55 | "src": "./space2.jpg",
56 | "title": "Weltraum2",
57 | },
58 | {
59 | "src": "./space3.jpg",
60 | "title": "Weltraum3",
61 | },
62 | {
63 | "src": "./space4.jpg",
64 | "title": "Weltraum4",
65 | },
66 | {
67 | "src": "./space5.jpg",
68 | "title": "Weltraum5",
69 | },
70 | ],
71 | "locale": "de-CH",
72 | "title": "Weltraum",
73 | "translationId": "src/content/gallery/space.yml",
74 | },
75 | "filePath": "src/content/gallery/space.yml",
76 | "id": "space/de-CH",
77 | },
78 | ],
79 | [
80 | "space/zh-CN",
81 | {
82 | "data": {
83 | "basePath": "/",
84 | "contentPath": "src/content/gallery",
85 | "cover": "./space1.jpg",
86 | "images": [
87 | {
88 | "src": "./space1.jpg",
89 | "title": "Space1",
90 | },
91 | {
92 | "src": "./space2.jpg",
93 | "title": "Space2",
94 | },
95 | {
96 | "src": "./space3.jpg",
97 | "title": "Space3",
98 | },
99 | {
100 | "src": "./space4.jpg",
101 | "title": "Space4",
102 | },
103 | {
104 | "src": "./space5.jpg",
105 | "title": "Space5",
106 | },
107 | ],
108 | "locale": "zh-CN",
109 | "title": "太空",
110 | "translationId": "src/content/gallery/space.yml",
111 | },
112 | "filePath": "src/content/gallery/space.yml",
113 | "id": "space/zh-CN",
114 | },
115 | ],
116 | [
117 | "gallery/de-CH",
118 | {
119 | "data": {
120 | "basePath": "/",
121 | "contentPath": "src/content/gallery",
122 | "items": [
123 | {
124 | "label": "Robin improvisiert beim Arbeiten",
125 | "photo": "./team/robin.jpg",
126 | },
127 | {
128 | "label": "Robin präsentiert",
129 | "photo": "./team/robin-presents.jpeg",
130 | },
131 | {
132 | "label": "Unser Labor",
133 | "photo": "./team/our-lab.jpg",
134 | },
135 | {
136 | "label": "Team-Retreat 2024",
137 | "photo": "./team/retraite-2024.jpg",
138 | },
139 | ],
140 | "locale": "de-CH",
141 | "translationId": "src/content/gallery/gallery.yml",
142 | },
143 | "filePath": "src/content/gallery/gallery.yml",
144 | "id": "gallery/de-CH",
145 | },
146 | ],
147 | [
148 | "gallery/zh-CN",
149 | {
150 | "data": {
151 | "basePath": "/",
152 | "contentPath": "src/content/gallery",
153 | "items": [
154 | {
155 | "label": "Robin即兴工作",
156 | "photo": "./team/robin.jpg",
157 | },
158 | {
159 | "label": "Robin演示",
160 | "photo": "./team/robin-presents.jpeg",
161 | },
162 | {
163 | "label": "我们的实验室",
164 | "photo": "./team/our-lab.jpg",
165 | },
166 | {
167 | "label": "团队静修2024",
168 | "photo": "./team/retraite-2024.jpg",
169 | },
170 | ],
171 | "locale": "zh-CN",
172 | "translationId": "src/content/gallery/gallery.yml",
173 | },
174 | "filePath": "src/content/gallery/gallery.yml",
175 | "id": "gallery/zh-CN",
176 | },
177 | ],
178 | ]
179 | `;
180 |
181 | exports[`i18nFileLoader > should throw error if i18n config is missing 1`] = `[Error: i18n configuration is missing in your astro config]`;
182 |
--------------------------------------------------------------------------------
/libs/astro-loader-i18n/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # astro-loader-i18n
2 |
3 | ## 0.10.9
4 |
5 | ### Patch Changes
6 |
7 | - 11229b5: Upgrade dependencies
8 | - fbe39e4: Fix content loader asset handling
9 |
10 | ## 0.10.8
11 |
12 | ### Patch Changes
13 |
14 | - f6a3129: Remove npm token
15 |
16 | ## 0.10.7
17 |
18 | ### Patch Changes
19 |
20 | - 60d759c: Fix publication
21 |
22 | ## 0.10.6
23 |
24 | ### Patch Changes
25 |
26 | - 23ca11d: Fix npm publication
27 |
28 | ## 0.10.5
29 |
30 | ### Patch Changes
31 |
32 | - fc5c7dd: Add workflow call permissions
33 |
34 | ## 0.10.4
35 |
36 | ### Patch Changes
37 |
38 | - 61fcdd0: Fix npm publication
39 |
40 | ## 0.10.3
41 |
42 | ### Patch Changes
43 |
44 | - d22d179: Override npm token
45 |
46 | ## 0.10.2
47 |
48 | ### Patch Changes
49 |
50 | - dae4485: Fix npm publication
51 |
52 | ## 0.10.1
53 |
54 | ### Patch Changes
55 |
56 | - cfc76ef: Switch to OIDC provenance publication
57 |
58 | ## 0.10.0
59 |
60 | ### Minor Changes
61 |
62 | - fd1f6ee: Fix hot reload issue for file and content loader
63 |
64 | ### Patch Changes
65 |
66 | - cd8d776: Upgrade dependencies
67 | - 442af4e: Consolidate vitest configuration
68 |
69 | ## 0.9.1
70 |
71 | ### Patch Changes
72 |
73 | - 8b3d4f9: Revert non-param replacement
74 |
75 | ## 0.9.0
76 |
77 | ### Minor Changes
78 |
79 | - 414a64a: Replace non-params with segments when buildPath
80 |
81 | ## 0.8.1
82 |
83 | ### Patch Changes
84 |
85 | - 651a9e0: Support objects nested in arrays in i18n content
86 |
87 | ## 0.8.0
88 |
89 | ### Minor Changes
90 |
91 | - 8a33e03: Add i18n file loader
92 |
93 | ## 0.7.9
94 |
95 | ### Patch Changes
96 |
97 | - 42e7515: Fix resolvePath typing
98 |
99 | ## 0.7.8
100 |
101 | ### Patch Changes
102 |
103 | - 611f6ac: Upgrade dependencies
104 | - bd183ef: Upgrade dependencies
105 |
106 | ## 0.7.7
107 |
108 | ### Patch Changes
109 |
110 | - 7bd69e1: Improve parse locale heuristic
111 |
112 | ## 0.7.6
113 |
114 | ### Patch Changes
115 |
116 | - 8960dba: Fix homepage path
117 |
118 | ## 0.7.5
119 |
120 | ### Patch Changes
121 |
122 | - 65e4dfc: Upgrade dependencies
123 | - 385f153: Refactor project
124 |
125 | ## 0.7.4
126 |
127 | ### Patch Changes
128 |
129 | - 63cb021: Upgrade dependencies
130 |
131 | ## 0.7.3
132 |
133 | ### Patch Changes
134 |
135 | - 394c0cd: Upgrade dependencies
136 |
137 | ## 0.7.2
138 |
139 | ### Patch Changes
140 |
141 | - b714569: Upgrade dependencies
142 | - 275f508: Process object in arrays with i18n content loader
143 |
144 | ## 0.7.1
145 |
146 | ### Patch Changes
147 |
148 | - 2f47d28: Add i18nProps helper
149 |
150 | ## 0.7.0
151 |
152 | ### Minor Changes
153 |
154 | - eb5d972: feat: make segment generation per entry possible
155 |
156 | **This is a breaking change.** Add a `generateSegments` attribute to your `i18nPropsAndParams` configuration. See README.md for more details.
157 |
158 | ## 0.6.6
159 |
160 | ### Patch Changes
161 |
162 | - 7bed19e: Add `prefixDefaultLocale` option to `i18nPropsAndParams` (thank you @duy-the-developer)
163 |
164 | ## 0.6.5
165 |
166 | ### Patch Changes
167 |
168 | - 502ccc1: Enhance base path handling
169 | - bcaf1d8: Document public exports
170 |
171 | ## 0.6.4
172 |
173 | ### Patch Changes
174 |
175 | - 1ca5343: Prune slashes in `resolvePath`
176 |
177 | ## 0.6.3
178 |
179 | ### Patch Changes
180 |
181 | - 3172f8c: Export resolvePath helper function
182 |
183 | ## 0.6.2
184 |
185 | ### Patch Changes
186 |
187 | - 3b168c5: Append base_url to paths
188 |
189 | ## 0.6.1
190 |
191 | ### Patch Changes
192 |
193 | - d5e5e5e: Produce absolute paths as Astro also does
194 |
195 | ## 0.6.0
196 |
197 | ### Minor Changes
198 |
199 | - cd37b7e: Make all paths relative
200 |
201 | ## 0.5.2
202 |
203 | ### Patch Changes
204 |
205 | - 16caf12: Fix prune locales in nested arrays
206 |
207 | ## 0.5.1
208 |
209 | ### Patch Changes
210 |
211 | - 3714427: Resolve contentPath for index files
212 |
213 | ## 0.5.0
214 |
215 | ### Minor Changes
216 |
217 | - 2d99af3: Add contentPath to entries data
218 |
219 | ### Patch Changes
220 |
221 | - 799480d: Add undefined locale for unrecognized content
222 |
223 | ## 0.4.0
224 |
225 | ### Minor Changes
226 |
227 | - 302ee2f: Add i18n content loader
228 |
229 | ### Patch Changes
230 |
231 | - bfc9d46: Upgrade dependencies
232 |
233 | ## 0.3.1
234 |
235 | ### Patch Changes
236 |
237 | - 8f8ecf5: Improve `extendI18nLoaderSchema` return type
238 |
239 | ## 0.3.0
240 |
241 | ### Minor Changes
242 |
243 | - 47e73bd: Add `translatedPath` to props
244 |
245 | ## 0.2.7
246 |
247 | ### Patch Changes
248 |
249 | - fdd471b: Fix error in parsing of locales from paths
250 |
251 | ## 0.2.6
252 |
253 | ### Patch Changes
254 |
255 | - c304593: Enhance parseData inside i18nLoader
256 |
257 | ## 0.2.5
258 |
259 | ### Patch Changes
260 |
261 | - f8c74ef: Simplify parseDataProxy
262 |
263 | ## 0.2.4
264 |
265 | ### Patch Changes
266 |
267 | - bd303c5: Add source maps
268 |
269 | ## 0.2.3
270 |
271 | ### Patch Changes
272 |
273 | - bc5c8ea: Enhance error messages
274 | - 38134d0: Improve translation id generation
275 |
276 | ## 0.2.2
277 |
278 | ### Patch Changes
279 |
280 | - 21d4dad: Enhance type packaging
281 |
282 | ## 0.2.1
283 |
284 | ### Patch Changes
285 |
286 | - 6056aaa: Export extendI18nLoaderSchema as localizedSchema
287 | - b0cc973: Upgrade dependencies
288 |
289 | ## 0.2.0
290 |
291 | ### Minor Changes
292 |
293 | - aa75291: Enhance README.md
294 | - e406dbb: Improve `i18nPropsAndParams` type-safety
295 |
296 | ### Patch Changes
297 |
298 | - bc18996: Add getStaticPaths helpers
299 |
300 | ## 0.1.0
301 |
302 | ### Minor Changes
303 |
304 | - b745ea0: Add i18n loader and schemas
305 |
306 | ## 0.0.5
307 |
308 | ### Patch Changes
309 |
310 | - c9ffa98: Distribute LICENSE and README.md
311 |
312 | ## 0.0.4
313 |
314 | ### Patch Changes
315 |
316 | - dc7abbd: Add release management scripts
317 |
318 | ## 0.0.3
319 |
320 | ### Patch Changes
321 |
322 | - 708b192: Fix release management
323 |
324 | ## 0.0.2
325 |
326 | ### Patch Changes
327 |
328 | - 3c5f233: Add release management
329 |
--------------------------------------------------------------------------------
/libs/astro-loader-i18n/test/__fixtures__/collections.ts:
--------------------------------------------------------------------------------
1 | import { DataEntry } from "astro:content";
2 |
3 | export const filesCollectionFixture: DataEntry[] = [
4 | {
5 | id: "about.de-CH",
6 | filePath: "src/content/pages/about.de-CH.mdx",
7 | data: {
8 | title: "Über mich",
9 | },
10 | body: `---
11 | title: Über mich
12 | ---
13 |
14 | Test
15 | `,
16 | },
17 | {
18 | id: "projects.de-CH",
19 | filePath: "src/content/pages/subpath/projects.de-CH.mdx",
20 | data: {
21 | title: "Projekte",
22 | },
23 | body: `---
24 | title: Projekte
25 | ---
26 |
27 | Test
28 | `,
29 | },
30 | {
31 | id: "about.zh-CN",
32 | filePath: "src/content/pages/about.zh-CN.mdx",
33 | data: {
34 | title: "关于我",
35 | },
36 | body: `---
37 | title: 关于我
38 | ---
39 |
40 | 测试
41 | `,
42 | },
43 | {
44 | id: "projects.zh-CN",
45 | filePath: "src/content/pages/subpath/projects.zh-CN.mdx",
46 | data: {
47 | title: "项目",
48 | },
49 | body: `---
50 | title: 项目
51 | ---
52 |
53 | 测试
54 | `,
55 | },
56 | ];
57 |
58 | export const folderCollectionFixture: DataEntry[] = [
59 | {
60 | id: "de-CH/about",
61 | filePath: "src/content/pages/de-CH/about.mdx",
62 | data: {
63 | title: "Über mich",
64 | },
65 | body: `---
66 | title: Über mich
67 | ---
68 |
69 | Test
70 | `,
71 | },
72 | {
73 | id: "de-CH/projects",
74 | filePath: "src/content/pages/de-CH/subpath/projects.mdx",
75 | data: {
76 | title: "Projekte",
77 | },
78 | body: `---
79 | title: Projekte
80 | ---
81 |
82 | Test
83 | `,
84 | },
85 | {
86 | id: "zh-CN/about",
87 | filePath: "src/content/pages/zh-CN/about.mdx",
88 | data: {
89 | title: "关于我",
90 | },
91 | body: `---
92 | title: 关于我
93 | ---
94 |
95 | 测试
96 | `,
97 | },
98 | {
99 | id: "zh-CN/projects",
100 | filePath: "src/content/pages/zh-CN/subpath/projects.mdx",
101 | data: {
102 | title: "项目",
103 | },
104 | body: `---
105 | title: 项目
106 | ---
107 |
108 | 测试
109 | `,
110 | },
111 | ];
112 |
113 | export const contentCollectionFixture: DataEntry[] = [
114 | {
115 | id: "space",
116 | filePath: "src/content/gallery/space.yml",
117 | data: {
118 | title: {
119 | "de-CH": "Weltraum",
120 | "zh-CN": "太空",
121 | },
122 | cover: "./space1.jpg",
123 | images: [
124 | { src: "./space1.jpg", title: { "de-CH": "Weltraum1", "zh-CN": "Space1" } },
125 | { src: "./space2.jpg", title: { "de-CH": "Weltraum2", "zh-CN": "Space2" } },
126 | { src: "./space3.jpg", title: { "de-CH": "Weltraum3", "zh-CN": "Space3" } },
127 | { src: "./space4.jpg", title: { "de-CH": "Weltraum4", "zh-CN": "Space4" } },
128 | { src: "./space5.jpg", title: { "de-CH": "Weltraum5", "zh-CN": "Space5" } },
129 | ],
130 | },
131 | },
132 | {
133 | id: "nature",
134 | filePath: "src/content/gallery/nature.yml",
135 | data: {
136 | title: {
137 | "de-CH": "Natur",
138 | "zh-CN": "自然",
139 | },
140 | cover: "./nature1.jpg",
141 | images: [
142 | { src: "./nature1.jpg" },
143 | { src: "./nature2.jpg" },
144 | { src: "./nature3.jpg" },
145 | { src: "./nature4.jpg" },
146 | { src: "./nature5.jpg" },
147 | ],
148 | },
149 | },
150 | {
151 | id: "animals",
152 | filePath: "src/content/gallery/animals.yml",
153 | data: {
154 | title: {
155 | "de-CH": "Tiere",
156 | "zh-CN": "动物",
157 | },
158 | cover: "./animals1.jpg",
159 | images: [
160 | { src: "./animals1.jpg" },
161 | { src: "./animals2.jpg" },
162 | { src: "./animals3.jpg" },
163 | { src: "./animals4.jpg" },
164 | { src: "./animals5.jpg" },
165 | ],
166 | },
167 | },
168 | {
169 | id: "omni",
170 | filePath: "src/content/gallery/omni.yml",
171 | data: {
172 | title: "Omni",
173 | cover: "./animals1.jpg",
174 | images: [
175 | { src: "./animals1.jpg" },
176 | { src: "./animals2.jpg" },
177 | { src: "./animals3.jpg" },
178 | { src: "./animals4.jpg" },
179 | { src: "./animals5.jpg" },
180 | ],
181 | },
182 | },
183 | ];
184 |
185 | export const contentFileFixture: DataEntry[] = [
186 | {
187 | id: "space",
188 | filePath: "src/content/gallery/space.yml",
189 | data: {
190 | title: {
191 | "de-CH": "Weltraum",
192 | "zh-CN": "太空",
193 | },
194 | cover: "./space1.jpg",
195 | images: [
196 | { src: "./space1.jpg", title: { "de-CH": "Weltraum1", "zh-CN": "Space1" } },
197 | { src: "./space2.jpg", title: { "de-CH": "Weltraum2", "zh-CN": "Space2" } },
198 | { src: "./space3.jpg", title: { "de-CH": "Weltraum3", "zh-CN": "Space3" } },
199 | { src: "./space4.jpg", title: { "de-CH": "Weltraum4", "zh-CN": "Space4" } },
200 | { src: "./space5.jpg", title: { "de-CH": "Weltraum5", "zh-CN": "Space5" } },
201 | ],
202 | },
203 | },
204 | {
205 | id: "gallery",
206 | filePath: "src/content/gallery/gallery.yml",
207 | data: {
208 | items: [
209 | {
210 | photo: "./team/robin.jpg",
211 | label: {
212 | "de-CH": "Robin improvisiert beim Arbeiten",
213 | "zh-CN": "Robin即兴工作",
214 | },
215 | },
216 | {
217 | photo: "./team/robin-presents.jpeg",
218 | label: {
219 | "de-CH": "Robin präsentiert",
220 | "zh-CN": "Robin演示",
221 | },
222 | },
223 | {
224 | photo: "./team/our-lab.jpg",
225 | label: {
226 | "de-CH": "Unser Labor",
227 | "zh-CN": "我们的实验室",
228 | },
229 | },
230 | {
231 | photo: "./team/retraite-2024.jpg",
232 | label: {
233 | "de-CH": "Team-Retreat 2024",
234 | "zh-CN": "团队静修2024",
235 | },
236 | },
237 | ],
238 | },
239 | },
240 | ];
241 |
242 | export const contentFileWithoutFixture: DataEntry[] = [
243 | {
244 | id: "omni",
245 | filePath: "src/content/gallery/omni.yml",
246 | data: {
247 | title: "Omni",
248 | cover: "./animals1.jpg",
249 | images: [
250 | { src: "./animals1.jpg" },
251 | { src: "./animals2.jpg" },
252 | { src: "./animals3.jpg" },
253 | { src: "./animals4.jpg" },
254 | { src: "./animals5.jpg" },
255 | ],
256 | },
257 | },
258 | ];
259 |
--------------------------------------------------------------------------------
/libs/astro-loader-i18n/test/loaders/__snapshots__/i18n-content-loader.spec.ts.snap:
--------------------------------------------------------------------------------
1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2 |
3 | exports[`i18nContentLoader > should put common translation id and locale in data 1`] = `
4 | [
5 | [
6 | "space/de-CH",
7 | {
8 | "data": {
9 | "basePath": "/",
10 | "contentPath": "",
11 | "cover": "./space1.jpg",
12 | "images": [
13 | {
14 | "src": "./space1.jpg",
15 | "title": "Weltraum1",
16 | },
17 | {
18 | "src": "./space2.jpg",
19 | "title": "Weltraum2",
20 | },
21 | {
22 | "src": "./space3.jpg",
23 | "title": "Weltraum3",
24 | },
25 | {
26 | "src": "./space4.jpg",
27 | "title": "Weltraum4",
28 | },
29 | {
30 | "src": "./space5.jpg",
31 | "title": "Weltraum5",
32 | },
33 | ],
34 | "locale": "de-CH",
35 | "title": "Weltraum",
36 | "translationId": "src/content/gallery/space.yml",
37 | },
38 | "filePath": "src/content/gallery/space.yml",
39 | "id": "space/de-CH",
40 | },
41 | ],
42 | [
43 | "space/zh-CN",
44 | {
45 | "data": {
46 | "basePath": "/",
47 | "contentPath": "",
48 | "cover": "./space1.jpg",
49 | "images": [
50 | {
51 | "src": "./space1.jpg",
52 | "title": "Space1",
53 | },
54 | {
55 | "src": "./space2.jpg",
56 | "title": "Space2",
57 | },
58 | {
59 | "src": "./space3.jpg",
60 | "title": "Space3",
61 | },
62 | {
63 | "src": "./space4.jpg",
64 | "title": "Space4",
65 | },
66 | {
67 | "src": "./space5.jpg",
68 | "title": "Space5",
69 | },
70 | ],
71 | "locale": "zh-CN",
72 | "title": "太空",
73 | "translationId": "src/content/gallery/space.yml",
74 | },
75 | "filePath": "src/content/gallery/space.yml",
76 | "id": "space/zh-CN",
77 | },
78 | ],
79 | [
80 | "nature/de-CH",
81 | {
82 | "data": {
83 | "basePath": "/",
84 | "contentPath": "",
85 | "cover": "./nature1.jpg",
86 | "images": [
87 | {
88 | "src": "./nature1.jpg",
89 | },
90 | {
91 | "src": "./nature2.jpg",
92 | },
93 | {
94 | "src": "./nature3.jpg",
95 | },
96 | {
97 | "src": "./nature4.jpg",
98 | },
99 | {
100 | "src": "./nature5.jpg",
101 | },
102 | ],
103 | "locale": "de-CH",
104 | "title": "Natur",
105 | "translationId": "src/content/gallery/nature.yml",
106 | },
107 | "filePath": "src/content/gallery/nature.yml",
108 | "id": "nature/de-CH",
109 | },
110 | ],
111 | [
112 | "nature/zh-CN",
113 | {
114 | "data": {
115 | "basePath": "/",
116 | "contentPath": "",
117 | "cover": "./nature1.jpg",
118 | "images": [
119 | {
120 | "src": "./nature1.jpg",
121 | },
122 | {
123 | "src": "./nature2.jpg",
124 | },
125 | {
126 | "src": "./nature3.jpg",
127 | },
128 | {
129 | "src": "./nature4.jpg",
130 | },
131 | {
132 | "src": "./nature5.jpg",
133 | },
134 | ],
135 | "locale": "zh-CN",
136 | "title": "自然",
137 | "translationId": "src/content/gallery/nature.yml",
138 | },
139 | "filePath": "src/content/gallery/nature.yml",
140 | "id": "nature/zh-CN",
141 | },
142 | ],
143 | [
144 | "animals/de-CH",
145 | {
146 | "data": {
147 | "basePath": "/",
148 | "contentPath": "",
149 | "cover": "./animals1.jpg",
150 | "images": [
151 | {
152 | "src": "./animals1.jpg",
153 | },
154 | {
155 | "src": "./animals2.jpg",
156 | },
157 | {
158 | "src": "./animals3.jpg",
159 | },
160 | {
161 | "src": "./animals4.jpg",
162 | },
163 | {
164 | "src": "./animals5.jpg",
165 | },
166 | ],
167 | "locale": "de-CH",
168 | "title": "Tiere",
169 | "translationId": "src/content/gallery/animals.yml",
170 | },
171 | "filePath": "src/content/gallery/animals.yml",
172 | "id": "animals/de-CH",
173 | },
174 | ],
175 | [
176 | "animals/zh-CN",
177 | {
178 | "data": {
179 | "basePath": "/",
180 | "contentPath": "",
181 | "cover": "./animals1.jpg",
182 | "images": [
183 | {
184 | "src": "./animals1.jpg",
185 | },
186 | {
187 | "src": "./animals2.jpg",
188 | },
189 | {
190 | "src": "./animals3.jpg",
191 | },
192 | {
193 | "src": "./animals4.jpg",
194 | },
195 | {
196 | "src": "./animals5.jpg",
197 | },
198 | ],
199 | "locale": "zh-CN",
200 | "title": "动物",
201 | "translationId": "src/content/gallery/animals.yml",
202 | },
203 | "filePath": "src/content/gallery/animals.yml",
204 | "id": "animals/zh-CN",
205 | },
206 | ],
207 | [
208 | "omni",
209 | {
210 | "data": {
211 | "basePath": "/",
212 | "contentPath": "",
213 | "cover": "./animals1.jpg",
214 | "images": [
215 | {
216 | "src": "./animals1.jpg",
217 | },
218 | {
219 | "src": "./animals2.jpg",
220 | },
221 | {
222 | "src": "./animals3.jpg",
223 | },
224 | {
225 | "src": "./animals4.jpg",
226 | },
227 | {
228 | "src": "./animals5.jpg",
229 | },
230 | ],
231 | "locale": "und",
232 | "title": "Omni",
233 | "translationId": "src/content/gallery/omni.yml",
234 | },
235 | "filePath": "src/content/gallery/omni.yml",
236 | "id": "omni",
237 | },
238 | ],
239 | ]
240 | `;
241 |
242 | exports[`i18nContentLoader > should throw error if i18n config is missing 1`] = `[Error: i18n configuration is missing in your astro config]`;
243 |
--------------------------------------------------------------------------------
/libs/astro-loader-i18n/src/loaders/create-content-loader.ts:
--------------------------------------------------------------------------------
1 | import { createContentPath, createTranslationId, getAllUniqueKeys, pruneLocales } from "astro-utils-i18n";
2 | import { glob, Loader, LoaderContext } from "astro/loaders";
3 |
4 | const UNDETERMINED_LOCALE = "und";
5 | const IMAGE_IMPORT_PREFIX = "__ASTRO_IMAGE_";
6 | type GlobOptions = Parameters[0];
7 |
8 | // Helper to find all __ASTRO_IMAGE_ prefixed strings in an object tree
9 | function findAssetImports(obj: unknown, assets: string[] = []): string[] {
10 | if (typeof obj === "string" && obj.startsWith(IMAGE_IMPORT_PREFIX)) {
11 | assets.push(obj.replace(IMAGE_IMPORT_PREFIX, ""));
12 | } else if (Array.isArray(obj)) {
13 | obj.forEach((item) => findAssetImports(item, assets));
14 | } else if (obj && typeof obj === "object") {
15 | Object.values(obj).forEach((value) => findAssetImports(value, assets));
16 | }
17 | return assets;
18 | }
19 |
20 | export function createContentLoader(loader: Loader, base?: GlobOptions["base"]) {
21 | return async (context: LoaderContext) => {
22 | if (!context.config.i18n) throw new Error("i18n configuration is missing in your astro config");
23 |
24 | const { locales } = context.config.i18n;
25 | const localeCodes = locales.flatMap((locale) => (typeof locale === "string" ? locale : locale.codes));
26 |
27 | const parseData = context.parseData;
28 | const parseDataProxy: typeof parseData = (props) => {
29 | if (!props.filePath) return parseData(props);
30 | const locale = UNDETERMINED_LOCALE;
31 | const translationId = createTranslationId(props.filePath);
32 | const contentPath = createContentPath(props.filePath, base);
33 | const basePath = context.config.base;
34 | return parseData({ ...props, data: { ...props.data, locale, translationId, contentPath, basePath } });
35 | };
36 | context.parseData = parseDataProxy;
37 |
38 | // Create a proxy for the store to intercept set/delete/keys calls and transform entries on the fly
39 | // This ensures entry manipulation happens both on initial load AND during hot reload
40 | const originalStore = context.store;
41 | // Track which original IDs map to which locale-suffixed IDs
42 | const idToLocaleIds = new Map();
43 |
44 | // On subsequent runs, rebuild the mapping from existing store entries
45 | // Entries are stored as "originalId/locale", so we can reconstruct the mapping
46 | for (const key of originalStore.keys()) {
47 | const lastSlashIndex = key.lastIndexOf("/");
48 | if (lastSlashIndex !== -1) {
49 | const potentialLocale = key.substring(lastSlashIndex + 1);
50 | if (localeCodes.includes(potentialLocale)) {
51 | const originalId = key.substring(0, lastSlashIndex);
52 | const existing = idToLocaleIds.get(originalId) || [];
53 | existing.push(key);
54 | idToLocaleIds.set(originalId, existing);
55 | }
56 | }
57 | }
58 |
59 | const storeProxy = new Proxy(originalStore, {
60 | get(target, prop, receiver) {
61 | if (prop === "get") {
62 | // Don't proxy get() - let the glob loader think entries don't exist by original ID
63 | // This forces re-parsing on each run, which ensures images are correctly transformed
64 | // The performance cost is acceptable because content files are typically small
65 | return (key: string) => {
66 | // Only return the entry if it's not a locale-transformed ID
67 | // This way, original IDs won't find cached entries and will be re-parsed
68 | if (idToLocaleIds.has(key)) {
69 | return undefined;
70 | }
71 | return target.get(key);
72 | };
73 | }
74 | if (prop === "set") {
75 | return (entry: Parameters[0]) => {
76 | const entryLocales = Array.from(getAllUniqueKeys(entry.data)).filter((key) => localeCodes.includes(key));
77 | if (entryLocales.length === 0) {
78 | return target.set(entry);
79 | } else {
80 | let result = false;
81 | const localeIds: string[] = [];
82 | entryLocales.forEach((locale) => {
83 | const entryData = pruneLocales(entry.data, entryLocales, locale);
84 | const localeId = `${entry.id}/${locale}`;
85 | localeIds.push(localeId);
86 | const setResult = target.set({ ...entry, id: localeId, data: { ...entryData, locale } });
87 | if (setResult) result = true;
88 |
89 | // Manually register asset imports for this locale entry
90 | // This is needed because the store's set() returns early if digest matches,
91 | // skipping the addAssetImports call. We need to ensure images are registered on every run.
92 | // Find __ASTRO_IMAGE_ prefixed strings in the pruned data
93 | if (entry.filePath) {
94 | const assetImports = findAssetImports(entryData);
95 | if (assetImports.length > 0) {
96 | (target as unknown as { addAssetImports: (assets: string[], filePath: string) => void }).addAssetImports(
97 | assetImports,
98 | entry.filePath
99 | );
100 | }
101 | }
102 | });
103 | // Track the mapping from original ID to locale-suffixed IDs
104 | idToLocaleIds.set(entry.id, localeIds);
105 | return result;
106 | }
107 | };
108 | }
109 | if (prop === "delete") {
110 | return (key: string) => {
111 | // Check if this ID was transformed into locale-suffixed IDs
112 | const localeIds = idToLocaleIds.get(key);
113 | if (localeIds) {
114 | localeIds.forEach((localeId) => target.delete(localeId));
115 | idToLocaleIds.delete(key);
116 | } else {
117 | // Fallback: delete the key as-is (might be a non-i18n entry)
118 | target.delete(key);
119 | }
120 | };
121 | }
122 | if (prop === "keys") {
123 | // Return original IDs (without locale suffix) so glob loader's untouchedEntries works correctly
124 | return () => {
125 | const originalIds = new Set();
126 | for (const key of target.keys()) {
127 | // Check if this key is a locale-suffixed ID
128 | const lastSlashIndex = key.lastIndexOf("/");
129 | if (lastSlashIndex !== -1) {
130 | const potentialLocale = key.substring(lastSlashIndex + 1);
131 | if (localeCodes.includes(potentialLocale)) {
132 | originalIds.add(key.substring(0, lastSlashIndex));
133 | continue;
134 | }
135 | }
136 | // Not a locale-suffixed ID, return as-is
137 | originalIds.add(key);
138 | }
139 | return Array.from(originalIds);
140 | };
141 | }
142 | const value = Reflect.get(target, prop, receiver);
143 | // Bind methods to the original target to preserve private field access
144 | if (typeof value === "function") {
145 | return value.bind(target);
146 | }
147 | return value;
148 | },
149 | });
150 | context.store = storeProxy;
151 |
152 | await loader.load(context);
153 | };
154 | }
155 |
--------------------------------------------------------------------------------
/libs/astro-utils-i18n/test/utils/path.spec.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from "vitest";
2 | import {
3 | createContentPath,
4 | createTranslationId,
5 | joinPath,
6 | parseLocale,
7 | resolvePath,
8 | trimRelativePath,
9 | trimSlashes,
10 | } from "../../src/astro-utils-i18n";
11 |
12 | describe("joinPath", () => {
13 | it("should join paths", () => {
14 | expect(joinPath("en-US", "docs", "getting-started")).toBe("en-US/docs/getting-started");
15 | });
16 | it("should join paths with undefined", () => {
17 | expect(joinPath("en-US", undefined, "getting-started")).toBe("en-US/getting-started");
18 | });
19 | });
20 |
21 | describe("resolvePath", () => {
22 | it("should resolve path by joining and making it relative", () => {
23 | expect(resolvePath("en-US", "docs", "getting-started")).toBe("/en-US/docs/getting-started");
24 | });
25 | it("should resolve path by joining and making it relative with undefined", () => {
26 | expect(resolvePath("en-US", undefined, "getting-started")).toBe("/en-US/getting-started");
27 | });
28 | it("should prepend base URL", () => {
29 | expect(resolvePath("/base", "en-US", "docs", "getting-started")).toBe("/base/en-US/docs/getting-started");
30 | });
31 | it("should not prepend base URL if it's /", () => {
32 | expect(resolvePath("/", "en-US", "docs", "getting-started")).toBe("/en-US/docs/getting-started");
33 | });
34 | it("should trim slashes in segments", () => {
35 | expect(resolvePath("/base/", "en-US/", "/docs", "/getting-started/")).toBe("/base/en-US/docs/getting-started");
36 | });
37 | });
38 |
39 | describe("trimSlashes", () => {
40 | it("should trim slashes on both sides", () => {
41 | const trimmed = trimSlashes("/de/page/");
42 | expect(trimmed).toBe("de/page");
43 | });
44 | it("should return '/' when only slash is present", () => {
45 | const trimmed = trimSlashes("/");
46 | expect(trimmed).toBe("/");
47 | });
48 | it("should trim slashes on the left side", () => {
49 | const trimmed = trimSlashes("/de/page");
50 | expect(trimmed).toBe("de/page");
51 | });
52 | it("should trim slashes on the right side", () => {
53 | const trimmed = trimSlashes("de/page/");
54 | expect(trimmed).toBe("de/page");
55 | });
56 | });
57 |
58 | describe("trimRelativePath", () => {
59 | it("should trim relative on both sides", () => {
60 | const trimmed = trimRelativePath("./de/page/");
61 | expect(trimmed).toBe("de/page");
62 | });
63 | it("should trim more relative on both sides", () => {
64 | const trimmed = trimRelativePath("../de/page/");
65 | expect(trimmed).toBe("de/page");
66 | });
67 | it("should return '/' when only slash is present", () => {
68 | const trimmed = trimRelativePath("/");
69 | expect(trimmed).toBe("/");
70 | });
71 | it("should trim relative on the left side", () => {
72 | const trimmed = trimRelativePath("/de/page");
73 | expect(trimmed).toBe("de/page");
74 | });
75 | it("should trim relative on the right side", () => {
76 | const trimmed = trimRelativePath("de/page/");
77 | expect(trimmed).toBe("de/page");
78 | });
79 | });
80 |
81 | describe("parseLocale", () => {
82 | it("should extract locale from folder", () => {
83 | const locale = parseLocale("/de-CH/page", ["de-CH", "zh-CN"], "zh-CN");
84 | expect(locale).toBe("de-CH");
85 | });
86 | it("should extract locale from folder in between", () => {
87 | const locale = parseLocale("src/content/projects/de/project-a.mdx", ["de", "zh"], "zh");
88 | expect(locale).toBe("de");
89 | });
90 | it("should extract locale observing delimiters", () => {
91 | const locale = parseLocale("src/projects/deutsch/zh/project-a.mdx", ["de", "zh"], "zh");
92 | expect(locale).toBe("zh");
93 | });
94 | it("should extract locale from file suffix", () => {
95 | const locale = parseLocale("/some/path/page.de-CH.md", ["de-CH", "zh-CN"], "zh-CN");
96 | expect(locale).toBe("de-CH");
97 | });
98 | it("should return default locale when no locale is found", () => {
99 | const locale = parseLocale("/page", ["de-CH", "zh-CN"], "zh-CN");
100 | expect(locale).toBe("zh-CN");
101 | });
102 | it("should extract locale from relative path without anything more", () => {
103 | const locale = parseLocale("de-CH", ["de-CH", "zh-CN"], "zh-CN");
104 | expect(locale).toBe("de-CH");
105 | });
106 | it("should extract locale from absolute path without anything more", () => {
107 | const locale = parseLocale("/de-CH", ["de-CH", "zh-CN"], "zh-CN");
108 | expect(locale).toBe("de-CH");
109 | });
110 | it("should extract locale from relative path with trailing slash", () => {
111 | const locale = parseLocale("de-CH/", ["de-CH", "zh-CN"], "zh-CN");
112 | expect(locale).toBe("de-CH");
113 | });
114 | });
115 |
116 | describe("createTranslationId", () => {
117 | it("should create ID with no locale", () => {
118 | const id = createTranslationId("/page/", "zh-CN");
119 | expect(id).toBe("page");
120 | });
121 | it("should create ID for index without locale", () => {
122 | const id = createTranslationId("/", "zh-CN");
123 | expect(id).toBe("index");
124 | });
125 | it("should create ID for index with locale", () => {
126 | const id = createTranslationId("/zh-CN/", "zh-CN");
127 | expect(id).toBe("index");
128 | });
129 | it("should create ID with locale as prefix", () => {
130 | const id = createTranslationId("/zh-CN/page", "zh-CN");
131 | expect(id).toBe("page");
132 | });
133 | it("should create ID with locale as suffix", () => {
134 | const id = createTranslationId("/page/file.zh-CN.md", "zh-CN");
135 | expect(id).toBe("page/file.md");
136 | });
137 | it("should create ID with locale as suffix and trailing slash", () => {
138 | const id = createTranslationId("/page/file.zh-CN.md/", "zh-CN");
139 | expect(id).toBe("page/file.md");
140 | });
141 | it("should create ID with locale in between", () => {
142 | const id = createTranslationId("/page/zh-CN/file", "zh-CN");
143 | expect(id).toBe("page/file");
144 | });
145 | it("should create ID without alter other segments of the path as prefix", () => {
146 | const id = createTranslationId("project/de/deutsch/hackbraten.md", "de");
147 | expect(id).toBe("project/deutsch/hackbraten.md");
148 | });
149 | it("should create ID without alter other segments of the path as suffix", () => {
150 | const id = createTranslationId("/deutsch/hackbraten.de.md", "de");
151 | expect(id).toBe("deutsch/hackbraten.md");
152 | });
153 | });
154 |
155 | describe("createContentPath", () => {
156 | it("should extract content path", () => {
157 | const id = createContentPath(
158 | "/workspaces/astro-loader-i18n/apps/example/src/content/files/de-ch/subpath/to/the/content/about.mdx",
159 | "./src/content/files",
160 | "de-ch"
161 | );
162 | expect(id).toBe("subpath/to/the/content");
163 | });
164 | it("should extract content path without locale", () => {
165 | const id = createContentPath(
166 | "/workspaces/astro-loader-i18n/apps/example/src/content/files/subpath/to/the/content/about.mdx",
167 | "./src/content/files",
168 | "de-ch"
169 | );
170 | expect(id).toBe("subpath/to/the/content");
171 | });
172 | it("should extract content path on index path", () => {
173 | const id = createContentPath(
174 | "/workspaces/astro-loader-i18n/apps/example/src/content/files/de-ch/subpath/to/the/content/index.mdx",
175 | "./src/content/files",
176 | "de-ch"
177 | );
178 | expect(id).toBe("subpath/to/the");
179 | });
180 | });
181 |
--------------------------------------------------------------------------------
/libs/astro-nanostores-i18n/test/bin/extract.spec.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
2 | import { readFile } from "node:fs/promises";
3 | import { createContext, runInContext } from "node:vm";
4 | import { parse, ParseResult } from "@astrojs/compiler";
5 | import { is, walk } from "@astrojs/compiler/utils";
6 | import glob from "fast-glob";
7 | import * as ts from "typescript";
8 | import { Node } from "@astrojs/compiler/types";
9 |
10 | // Mock all external dependencies
11 | vi.mock("node:fs/promises");
12 | vi.mock("fast-glob");
13 | vi.mock("@astrojs/compiler");
14 | vi.mock("@astrojs/compiler/utils");
15 | vi.mock("node:vm");
16 |
17 | const mockReadFile = vi.mocked(readFile);
18 | const mockGlob = vi.mocked(glob);
19 | const mockParse = vi.mocked(parse);
20 | const mockWalk = vi.mocked(walk);
21 | const mockIs = vi.mocked(is);
22 | const mockCreateContext = vi.mocked(createContext);
23 | const mockRunInContext = vi.mocked(runInContext);
24 |
25 | describe("extract.ts", () => {
26 | const originalArgv = process.argv;
27 | const originalExit = process.exit;
28 | const originalConsoleWarn = console.warn;
29 | const originalConsoleError = console.error;
30 |
31 | beforeEach(() => {
32 | // Clear module cache to allow re-importing
33 | vi.resetModules();
34 |
35 | // Mock process.exit
36 | process.exit = vi.fn() as unknown as typeof process.exit;
37 | console.warn = vi.fn();
38 | console.error = vi.fn();
39 |
40 | // Setup default mock implementations
41 | mockGlob.mockResolvedValue([]);
42 | mockCreateContext.mockReturnValue({});
43 | mockRunInContext.mockReturnValue(undefined);
44 | });
45 |
46 | afterEach(() => {
47 | process.argv = originalArgv;
48 | process.exit = originalExit;
49 | console.warn = originalConsoleWarn;
50 | console.error = originalConsoleError;
51 | vi.restoreAllMocks();
52 | });
53 |
54 | describe("CLI argument parsing", () => {
55 | it("should display help when --help flag is provided", async () => {
56 | process.argv = ["node", "extract.ts", "--help"];
57 |
58 | // eslint-disable-next-line prettier/prettier
59 | const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => { });
60 |
61 | // Use dynamic import with cache busting
62 | await vi.importActual("../../src/bin/extract.ts");
63 |
64 | expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Usage: extract-messages [options]"));
65 | expect(process.exit).toHaveBeenCalledWith(0);
66 | });
67 |
68 | it("should use default values when no arguments provided", async () => {
69 | process.argv = ["node", "extract.ts"];
70 |
71 | // Use dynamic import with cache busting
72 | await vi.importActual("../../src/bin/extract.ts");
73 |
74 | expect(mockGlob).toHaveBeenCalledWith("./src/**/*.astro", { absolute: true });
75 | });
76 | });
77 |
78 | describe("file processing", () => {
79 | it("should process Astro files and extract messages", async () => {
80 | const mockAstroContent = `---
81 | const messages = useI18n("example", {
82 | greeting: "Hello World",
83 | farewell: "Goodbye"
84 | });
85 | ---
86 | {messages.greeting}
`;
87 |
88 | const mockAst = {
89 | type: "root",
90 | children: [
91 | {
92 | type: "frontmatter",
93 | value: `const messages = useI18n("example", {
94 | greeting: "Hello World",
95 | farewell: "Goodbye"
96 | });`,
97 | },
98 | ],
99 | };
100 |
101 | process.argv = ["node", "extract.ts"];
102 | mockGlob.mockResolvedValue(["/test/file.astro"]);
103 | mockReadFile.mockResolvedValue(mockAstroContent);
104 | mockParse.mockResolvedValue({ ast: mockAst } as ParseResult);
105 |
106 | // Mock walk to call the callback with frontmatter node
107 | mockWalk.mockImplementation((_ast, callback) => {
108 | callback(mockAst.children[0] as Node);
109 | });
110 |
111 | // Mock is.frontmatter to return true for frontmatter nodes
112 | mockIs.frontmatter = vi.fn().mockReturnValue(true) as typeof mockIs.frontmatter;
113 |
114 | await vi.importActual("../../src/bin/extract.ts");
115 |
116 | expect(mockReadFile).toHaveBeenCalledWith("/test/file.astro", "utf-8");
117 | expect(mockParse).toHaveBeenCalledWith(mockAstroContent, { position: false });
118 | expect(mockWalk).toHaveBeenCalled();
119 | });
120 |
121 | it("should handle files without messages", async () => {
122 | const mockAstroContent = `---
123 | const someOtherVariable = "test";
124 | ---
125 | No messages here
`;
126 |
127 | const mockAst = {
128 | type: "root",
129 | children: [
130 | {
131 | type: "frontmatter",
132 | value: `const someOtherVariable = "test";`,
133 | },
134 | ],
135 | };
136 |
137 | process.argv = ["node", "extract.ts"];
138 | mockGlob.mockResolvedValue(["/test/file.astro"]);
139 | mockReadFile.mockResolvedValue(mockAstroContent);
140 | mockParse.mockResolvedValue({ ast: mockAst } as ParseResult);
141 |
142 | mockWalk.mockImplementation((_ast, callback) => {
143 | callback(mockAst.children[0] as Node);
144 | });
145 |
146 | mockIs.frontmatter = vi.fn().mockReturnValue(true) as typeof mockIs.frontmatter;
147 |
148 | await vi.importActual("../../src/bin/extract.ts");
149 |
150 | expect(console.warn).toHaveBeenCalledWith("No messages found in the provided components.");
151 | });
152 | });
153 |
154 | describe("message extraction", () => {
155 | it("should extract messages from TypeScript AST", () => {
156 | // This tests the extractMessagesFromAST function indirectly
157 | const code = `const messages = useI18n("example", {
158 | greeting: "Hello",
159 | farewell: "Goodbye"
160 | });`;
161 |
162 | const sourceFile = ts.createSourceFile("temp.ts", code, ts.ScriptTarget.Latest, true);
163 | let messagesExport: string | undefined;
164 |
165 | function visit(node: ts.Node) {
166 | if (ts.isVariableStatement(node)) {
167 | const declaration = node.declarationList.declarations[0];
168 | if (ts.isIdentifier(declaration.name) && declaration.name.text === "messages" && declaration.initializer) {
169 | messagesExport = node.getText();
170 | }
171 | }
172 | ts.forEachChild(node, visit);
173 | }
174 |
175 | visit(sourceFile);
176 |
177 | expect(messagesExport).toBeDefined();
178 | expect(messagesExport).toContain("useI18n");
179 | expect(messagesExport).toContain("greeting");
180 | expect(messagesExport).toContain("farewell");
181 | });
182 |
183 | it("should not extract non-messages variables", () => {
184 | const code = `const otherVariable = "not messages";`;
185 |
186 | const sourceFile = ts.createSourceFile("temp.ts", code, ts.ScriptTarget.Latest, true);
187 | let messagesExport: string | undefined;
188 |
189 | function visit(node: ts.Node) {
190 | if (ts.isVariableStatement(node)) {
191 | const declaration = node.declarationList.declarations[0];
192 | if (ts.isIdentifier(declaration.name) && declaration.name.text === "messages" && declaration.initializer) {
193 | messagesExport = node.getText();
194 | }
195 | }
196 | ts.forEachChild(node, visit);
197 | }
198 |
199 | visit(sourceFile);
200 |
201 | expect(messagesExport).toBeUndefined();
202 | });
203 | });
204 |
205 | describe("output generation", () => {
206 | it("should write extracted messages to file", async () => {
207 | process.argv = ["node", "extract.ts", "--out", "/test/output.json"];
208 | mockGlob.mockResolvedValue([]);
209 |
210 | // Mock the context to simulate extracted messages
211 | const mockContext = {
212 | exports: {},
213 | Object,
214 | useI18n: vi.fn((_namespace: string, messages: unknown) => {
215 | return messages;
216 | }),
217 | params: vi.fn((template: string) => template),
218 | count: vi.fn((counts: Record) => counts),
219 | };
220 |
221 | mockCreateContext.mockReturnValue(mockContext);
222 |
223 | await vi.importActual("../../src/bin/extract.ts");
224 |
225 | // Since we have no files, it should warn about no messages
226 | expect(console.warn).toHaveBeenCalledWith("No messages found in the provided components.");
227 | });
228 |
229 | it("should use custom output path", async () => {
230 | process.argv = ["node", "extract.ts", "--out", "/custom/path/messages.json"];
231 | mockGlob.mockResolvedValue([]);
232 |
233 | await vi.importActual("../../src/bin/extract.ts");
234 |
235 | expect(console.warn).toHaveBeenCalledWith("No messages found in the provided components.");
236 | });
237 |
238 | it("should use custom glob pattern", async () => {
239 | process.argv = ["node", "extract.ts", "--glob", "./custom/**/*.astro"];
240 | mockGlob.mockResolvedValue([]);
241 |
242 | await vi.importActual("../../src/bin/extract.ts");
243 |
244 | expect(mockGlob).toHaveBeenCalledWith("./custom/**/*.astro", { absolute: true });
245 | });
246 | });
247 | });
248 |
--------------------------------------------------------------------------------
/libs/astro-loader-i18n/test/schemas/__snapshots__/i18n-content-schema.spec.ts.snap:
--------------------------------------------------------------------------------
1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2 |
3 | exports[`i18nContentSchema > should extend the schema 1`] = `"{"_def":{"options":[{"_def":{"unknownKeys":"strip","catchall":{"_def":{"typeName":"ZodNever"},"~standard":{"version":1,"vendor":"zod"}},"typeName":"ZodObject"},"~standard":{"version":1,"vendor":"zod"},"_cached":null},{"_def":{"checks":[],"typeName":"ZodString","coerce":false},"~standard":{"version":1,"vendor":"zod"}}],"typeName":"ZodUnion"},"~standard":{"version":1,"vendor":"zod"}}"`;
4 |
5 | exports[`i18nContentSchema > should extend the schema 2`] = `
6 | {
7 | "de": ZodString {
8 | "_def": {
9 | "checks": [],
10 | "coerce": false,
11 | "typeName": "ZodString",
12 | },
13 | "and": [Function],
14 | "array": [Function],
15 | "brand": [Function],
16 | "catch": [Function],
17 | "default": [Function],
18 | "describe": [Function],
19 | "isNullable": [Function],
20 | "isOptional": [Function],
21 | "nullable": [Function],
22 | "nullish": [Function],
23 | "optional": [Function],
24 | "or": [Function],
25 | "parse": [Function],
26 | "parseAsync": [Function],
27 | "pipe": [Function],
28 | "promise": [Function],
29 | "readonly": [Function],
30 | "refine": [Function],
31 | "refinement": [Function],
32 | "safeParse": [Function],
33 | "safeParseAsync": [Function],
34 | "spa": [Function],
35 | "superRefine": [Function],
36 | "transform": [Function],
37 | "~standard": {
38 | "validate": [Function],
39 | "vendor": "zod",
40 | "version": 1,
41 | },
42 | },
43 | "en": ZodString {
44 | "_def": {
45 | "checks": [],
46 | "coerce": false,
47 | "typeName": "ZodString",
48 | },
49 | "and": [Function],
50 | "array": [Function],
51 | "brand": [Function],
52 | "catch": [Function],
53 | "default": [Function],
54 | "describe": [Function],
55 | "isNullable": [Function],
56 | "isOptional": [Function],
57 | "nullable": [Function],
58 | "nullish": [Function],
59 | "optional": [Function],
60 | "or": [Function],
61 | "parse": [Function],
62 | "parseAsync": [Function],
63 | "pipe": [Function],
64 | "promise": [Function],
65 | "readonly": [Function],
66 | "refine": [Function],
67 | "refinement": [Function],
68 | "safeParse": [Function],
69 | "safeParseAsync": [Function],
70 | "spa": [Function],
71 | "superRefine": [Function],
72 | "transform": [Function],
73 | "~standard": {
74 | "validate": [Function],
75 | "vendor": "zod",
76 | "version": 1,
77 | },
78 | },
79 | "fr": ZodString {
80 | "_def": {
81 | "checks": [],
82 | "coerce": false,
83 | "typeName": "ZodString",
84 | },
85 | "and": [Function],
86 | "array": [Function],
87 | "brand": [Function],
88 | "catch": [Function],
89 | "default": [Function],
90 | "describe": [Function],
91 | "isNullable": [Function],
92 | "isOptional": [Function],
93 | "nullable": [Function],
94 | "nullish": [Function],
95 | "optional": [Function],
96 | "or": [Function],
97 | "parse": [Function],
98 | "parseAsync": [Function],
99 | "pipe": [Function],
100 | "promise": [Function],
101 | "readonly": [Function],
102 | "refine": [Function],
103 | "refinement": [Function],
104 | "safeParse": [Function],
105 | "safeParseAsync": [Function],
106 | "spa": [Function],
107 | "superRefine": [Function],
108 | "transform": [Function],
109 | "~standard": {
110 | "validate": [Function],
111 | "vendor": "zod",
112 | "version": 1,
113 | },
114 | },
115 | }
116 | `;
117 |
118 | exports[`i18nContentSchema > should extend the schema and make partial if option is set 1`] = `"{"_def":{"options":[{"_def":{"unknownKeys":"strip","catchall":{"_def":{"typeName":"ZodNever"},"~standard":{"version":1,"vendor":"zod"}},"typeName":"ZodObject"},"~standard":{"version":1,"vendor":"zod"},"_cached":null},{"_def":{"checks":[],"typeName":"ZodString","coerce":false},"~standard":{"version":1,"vendor":"zod"}}],"typeName":"ZodUnion"},"~standard":{"version":1,"vendor":"zod"}}"`;
119 |
120 | exports[`i18nContentSchema > should extend the schema and make partial if option is set 2`] = `
121 | {
122 | "de": ZodOptional {
123 | "_def": {
124 | "description": undefined,
125 | "errorMap": [Function],
126 | "innerType": ZodString {
127 | "_def": {
128 | "checks": [],
129 | "coerce": false,
130 | "typeName": "ZodString",
131 | },
132 | "and": [Function],
133 | "array": [Function],
134 | "brand": [Function],
135 | "catch": [Function],
136 | "default": [Function],
137 | "describe": [Function],
138 | "isNullable": [Function],
139 | "isOptional": [Function],
140 | "nullable": [Function],
141 | "nullish": [Function],
142 | "optional": [Function],
143 | "or": [Function],
144 | "parse": [Function],
145 | "parseAsync": [Function],
146 | "pipe": [Function],
147 | "promise": [Function],
148 | "readonly": [Function],
149 | "refine": [Function],
150 | "refinement": [Function],
151 | "safeParse": [Function],
152 | "safeParseAsync": [Function],
153 | "spa": [Function],
154 | "superRefine": [Function],
155 | "transform": [Function],
156 | "~standard": {
157 | "validate": [Function],
158 | "vendor": "zod",
159 | "version": 1,
160 | },
161 | },
162 | "typeName": "ZodOptional",
163 | },
164 | "and": [Function],
165 | "array": [Function],
166 | "brand": [Function],
167 | "catch": [Function],
168 | "default": [Function],
169 | "describe": [Function],
170 | "isNullable": [Function],
171 | "isOptional": [Function],
172 | "nullable": [Function],
173 | "nullish": [Function],
174 | "optional": [Function],
175 | "or": [Function],
176 | "parse": [Function],
177 | "parseAsync": [Function],
178 | "pipe": [Function],
179 | "promise": [Function],
180 | "readonly": [Function],
181 | "refine": [Function],
182 | "refinement": [Function],
183 | "safeParse": [Function],
184 | "safeParseAsync": [Function],
185 | "spa": [Function],
186 | "superRefine": [Function],
187 | "transform": [Function],
188 | "~standard": {
189 | "validate": [Function],
190 | "vendor": "zod",
191 | "version": 1,
192 | },
193 | },
194 | "en": ZodOptional {
195 | "_def": {
196 | "description": undefined,
197 | "errorMap": [Function],
198 | "innerType": ZodString {
199 | "_def": {
200 | "checks": [],
201 | "coerce": false,
202 | "typeName": "ZodString",
203 | },
204 | "and": [Function],
205 | "array": [Function],
206 | "brand": [Function],
207 | "catch": [Function],
208 | "default": [Function],
209 | "describe": [Function],
210 | "isNullable": [Function],
211 | "isOptional": [Function],
212 | "nullable": [Function],
213 | "nullish": [Function],
214 | "optional": [Function],
215 | "or": [Function],
216 | "parse": [Function],
217 | "parseAsync": [Function],
218 | "pipe": [Function],
219 | "promise": [Function],
220 | "readonly": [Function],
221 | "refine": [Function],
222 | "refinement": [Function],
223 | "safeParse": [Function],
224 | "safeParseAsync": [Function],
225 | "spa": [Function],
226 | "superRefine": [Function],
227 | "transform": [Function],
228 | "~standard": {
229 | "validate": [Function],
230 | "vendor": "zod",
231 | "version": 1,
232 | },
233 | },
234 | "typeName": "ZodOptional",
235 | },
236 | "and": [Function],
237 | "array": [Function],
238 | "brand": [Function],
239 | "catch": [Function],
240 | "default": [Function],
241 | "describe": [Function],
242 | "isNullable": [Function],
243 | "isOptional": [Function],
244 | "nullable": [Function],
245 | "nullish": [Function],
246 | "optional": [Function],
247 | "or": [Function],
248 | "parse": [Function],
249 | "parseAsync": [Function],
250 | "pipe": [Function],
251 | "promise": [Function],
252 | "readonly": [Function],
253 | "refine": [Function],
254 | "refinement": [Function],
255 | "safeParse": [Function],
256 | "safeParseAsync": [Function],
257 | "spa": [Function],
258 | "superRefine": [Function],
259 | "transform": [Function],
260 | "~standard": {
261 | "validate": [Function],
262 | "vendor": "zod",
263 | "version": 1,
264 | },
265 | },
266 | "fr": ZodOptional {
267 | "_def": {
268 | "description": undefined,
269 | "errorMap": [Function],
270 | "innerType": ZodString {
271 | "_def": {
272 | "checks": [],
273 | "coerce": false,
274 | "typeName": "ZodString",
275 | },
276 | "and": [Function],
277 | "array": [Function],
278 | "brand": [Function],
279 | "catch": [Function],
280 | "default": [Function],
281 | "describe": [Function],
282 | "isNullable": [Function],
283 | "isOptional": [Function],
284 | "nullable": [Function],
285 | "nullish": [Function],
286 | "optional": [Function],
287 | "or": [Function],
288 | "parse": [Function],
289 | "parseAsync": [Function],
290 | "pipe": [Function],
291 | "promise": [Function],
292 | "readonly": [Function],
293 | "refine": [Function],
294 | "refinement": [Function],
295 | "safeParse": [Function],
296 | "safeParseAsync": [Function],
297 | "spa": [Function],
298 | "superRefine": [Function],
299 | "transform": [Function],
300 | "~standard": {
301 | "validate": [Function],
302 | "vendor": "zod",
303 | "version": 1,
304 | },
305 | },
306 | "typeName": "ZodOptional",
307 | },
308 | "and": [Function],
309 | "array": [Function],
310 | "brand": [Function],
311 | "catch": [Function],
312 | "default": [Function],
313 | "describe": [Function],
314 | "isNullable": [Function],
315 | "isOptional": [Function],
316 | "nullable": [Function],
317 | "nullish": [Function],
318 | "optional": [Function],
319 | "or": [Function],
320 | "parse": [Function],
321 | "parseAsync": [Function],
322 | "pipe": [Function],
323 | "promise": [Function],
324 | "readonly": [Function],
325 | "refine": [Function],
326 | "refinement": [Function],
327 | "safeParse": [Function],
328 | "safeParseAsync": [Function],
329 | "spa": [Function],
330 | "superRefine": [Function],
331 | "transform": [Function],
332 | "~standard": {
333 | "validate": [Function],
334 | "vendor": "zod",
335 | "version": 1,
336 | },
337 | },
338 | }
339 | `;
340 |
--------------------------------------------------------------------------------
/libs/astro-loader-i18n/README.md:
--------------------------------------------------------------------------------
1 | # astro-loader-i18n
2 |
3 | [](https://npmjs.org/astro-loader-i18n)
4 | [](https://npmjs.org/astro-loader-i18n)
5 |
6 | `astro-loader-i18n` is a **content loader** for internationalized content in [Astro](https://astro.build). It builds on top of Astro’s [`glob()` loader](https://docs.astro.build/en/reference/content-loader-reference/#glob-loader) and helps manage translations by detecting locales, mapping content, and enriching `getStaticPaths`.
7 |
8 | ## Features
9 |
10 | ### ✅ Automatic locale detection
11 |
12 | - Extracts locale information from file names or folder structures:
13 |
14 | 📂 Folder structure example
15 |
16 | ```plaintext
17 | . (project root)
18 | ├── README.md
19 | └── src
20 | └── content
21 | └── pages
22 | ├── de-CH
23 | │ ├── about.mdx
24 | │ └── projects.mdx
25 | └── zh-CN
26 | ├── about.mdx
27 | └── projects.mdx
28 | ```
29 |
30 |
31 | 📄 File name suffix example
32 |
33 | ```plaintext
34 | . (project root)
35 | └── src
36 | └── content
37 | └── pages
38 | ├── about.de-CH.mdx
39 | ├── about.zh-CN.mdx
40 | ├── projects.de-CH.mdx
41 | └── projects.zh-CN.mdx
42 | ```
43 |
44 |
45 | - Supports nested folders:
46 |
47 | 📂 Nested folder structure example
48 |
49 | ```plaintext
50 | . (project root)
51 | └── src
52 | └── content
53 | └── pages
54 | ├── de-CH
55 | │ ├── about.mdx
56 | │ └── projects
57 | │ ├── project1.mdx
58 | │ └── project2.mdx
59 | └── zh-CN
60 | ├── about.mdx
61 | └── projects
62 | ├── project1.mdx
63 | └── project2.mdx
64 | ```
65 |
66 |
67 | ### ✅ Translation mapping
68 | - Generates a translation identifier to easily match different language versions of content.
69 |
70 | ### ✅ Schema support
71 | - Helps to define schemas for your localized content.
72 | - Add `translationId` and `locale` to the schema by using `extendI18nLoaderSchema`. You need this when using `i18nLoader` or `i18nContentLoader`.
73 | - When you have multiple locales in a single file, you can use `localized` to define the necessary schema. This is useful when using `i18nContentLoader`.
74 |
75 | ### ✅ `getStaticPaths()` helpers included
76 | - Includes a helper utility called `i18nPropsAndParams`
77 | - Helps to fill and translate URL params like `[...locale]/[files]/[slug]`, whereas `[...locale]` is the locale, `[files]` is a translated segment and `[slug]` is the slug of the title.
78 | - Adds a `translations` object to each entry, which contains paths to the corresponding content of all existing translations.
79 |
80 | ### ✅ Type safety
81 | - Keeps `Astro.props` type-safe.
82 |
83 | ## Usage
84 |
85 | 1. Install the package `astro-loader-i18n`:
86 |
87 | npm
88 |
89 | ```bash
90 | npm install astro-loader-i18n
91 | ```
92 |
93 |
94 | yarn
95 |
96 | ```bash
97 | yarn add astro-loader-i18n
98 | ```
99 |
100 |
101 | pnpm
102 |
103 | ```bash
104 | pnpm add astro-loader-i18n
105 | ```
106 |
107 |
108 | 2. Configure locales, a default locale and segments for example in a file called `site.config.ts`:
109 |
110 | ```typescript
111 | export const C = {
112 | LOCALES: ["de-CH", "zh-CN"],
113 | DEFAULT_LOCALE: "de-CH" as const,
114 | SEGMENT_TRANSLATIONS: {
115 | "de-CH": {
116 | files: "dateien",
117 | },
118 | "zh-CN": {
119 | files: "files",
120 | },
121 | },
122 | };
123 | ```
124 |
125 | 3. Configure i18n in `astro.config.ts`:
126 |
127 | ```typescript
128 | import { defineConfig } from "astro/config";
129 | import { C } from "./src/site.config";
130 |
131 | export default defineConfig({
132 | i18n: {
133 | locales: C.LOCALES,
134 | defaultLocale: C.DEFAULT_LOCALE,
135 | },
136 | });
137 | ```
138 |
139 | 4. Define collections using `astro-loader-i18n` in `content.config.ts`. Don't forget to use `extendI18nLoaderSchema` or `localized` to extend the schema with the i18n specific properties:
140 |
141 | ```typescript
142 | import { defineCollection, z } from "astro:content";
143 | import { extendI18nLoaderSchema, i18nContentLoader, i18nLoader, localized } from "astro-loader-i18n";
144 | import { C } from "./site.config";
145 |
146 | const filesCollection = defineCollection({
147 | loader: i18nLoader({ pattern: "**/[^_]*.{md,mdx}", base: "./src/content/files" }),
148 | schema: extendI18nLoaderSchema(
149 | z.object({
150 | title: z.string(),
151 | })
152 | ),
153 | });
154 | const folderCollection = defineCollection({
155 | loader: i18nLoader({ pattern: "**/[^_]*.{md,mdx}", base: "./src/content/folder" }),
156 | schema: extendI18nLoaderSchema(
157 | z.object({
158 | title: z.string(),
159 | })
160 | ),
161 | });
162 |
163 | /*
164 | Example of a content file:
165 | navigation:
166 | de-CH:
167 | - path: /projekte
168 | title: Projekte
169 | - path: /ueber-mich
170 | title: Über mich
171 | zh-CN:
172 | - path: /zh/projects
173 | title: 项目
174 | - path: /zh/about-me
175 | title: 关于我
176 | */
177 | const infileCollection = defineCollection({
178 | loader: i18nContentLoader({ pattern: "**/[^_]*.{yml,yaml}", base: "./src/content/infile" }),
179 | schema: extendI18nLoaderSchema( // `extendI18nLoaderSchema` defines `translationId` and `locale` for you in the schema.
180 | z.object({
181 | navigation: localized( // `localized` defines an object with the locale as key and applies the schema you provide to the value.
182 | z.array(
183 | z.object({
184 | path: z.string(),
185 | title: z.string(),
186 | })
187 | ),
188 | C.LOCALES
189 | ),
190 | })
191 | ),
192 | });
193 |
194 | export const collections = {
195 | files: filesCollection,
196 | folder: folderCollection,
197 | infile: infileCollection,
198 | };
199 | ```
200 |
201 | 5. Create content files in the defined structure:
202 | > ⚠️ WARNING
203 | > The content files need to be structured according to the locales defined in `astro.config.ts`.
204 |
205 | ```
206 | . (project root)
207 | └── src
208 | └── content
209 | └── pages
210 | ├── about.de-CH.mdx
211 | ├── about.zh-CN.mdx
212 | ├── projects.de-CH.mdx
213 | └── projects.zh-CN.mdx
214 | ```
215 |
216 | 6. Retrieve the `locale` and `translationId` identifier during rendering:
217 |
218 | ```typescript
219 | import { getCollection } from "astro:content";
220 |
221 | const pages = await getCollection("files");
222 | console.log(pages["data"].locale); // e.g. de-CH
223 | console.log(pages["data"].translationId); // e.g. src/content/files/about.mdx
224 | ```
225 |
226 | 7. Use `i18nPropsAndParams` to provide params and get available translations paths via the page props:
227 |
228 | ```typescript
229 | import { i18nPropsAndParams } from "astro-loader-i18n";
230 | import sluggify from "limax"; // sluggify is used to create a slug from the title
231 |
232 | export const getStaticPaths = async ({ routePattern }) => {
233 | // ⚠️ If you are using Astro <5.14.0, you need to hardcode the routePattern here.
234 | // see https://github.com/withastro/astro/pull/13520
235 | // const routePattern = "[...locale]/[files]/[slug]";
236 | const filesCollection = await getCollection("files");
237 |
238 | return i18nPropsAndParams(filesCollection, {
239 | defaultLocale: C.DEFAULT_LOCALE,
240 | routePattern,
241 | segmentTranslations: C.SEGMENT_TRANSLATIONS,
242 | // `generateSegments` is a function that generates per entry individual segments.
243 | generateSegments: (entry) => ({ slug: sluggify(entry.data.title) }),
244 | });
245 | };
246 | ```
247 |
248 | 8. Use `Astro.props.translations` to provide a same site language switcher.
249 |
250 | ### In-file localized content
251 |
252 | Sometimes to have multilingual content in a single file is more convenient. For example data for menus or galleries. This allows sharing untranslated content across locales.
253 |
254 | Use the `i18nContentLoader` loader to load in-file localized content. If you want to load a single file, you can use the `i18nFileLoader`.
255 |
256 | 1. Create a collection:
257 |
258 | 📄 Infile collection example
259 |
260 | ```plaintext
261 | . (project root)
262 | └── src
263 | └── content
264 | └── navigation
265 | ├── footer.yml
266 | └── main.yml
267 | ```
268 |
269 |
270 |
271 | 📄 Content of main.yml
272 |
273 | ```yaml
274 | # src/content/navigation/main.yml
275 | navigation:
276 | de-CH:
277 | - path: /projekte
278 | title: Projekte
279 | - path: /ueber-mich
280 | title: Über mich
281 | zh-CN:
282 | - path: /zh/projects
283 | title: 项目
284 | - path: /zh/about-me
285 | title: 关于我
286 | ```
287 |
288 |
289 |
290 | 1. Use `extendI18nLoaderSchema` and `localized` to define the schema:
291 |
292 | ```typescript
293 | const infileCollection = defineCollection({
294 | loader: i18nContentLoader({ pattern: "**/[^_]*.{yml,yaml}", base: "./src/content/infile" }),
295 | schema: extendI18nLoaderSchema( // `extendI18nLoaderSchema` defines `translationId` and `locale` for you in the schema.
296 | z.object({
297 | navigation: localized( // `localized` defines an object with the locale as key and applies the schema you provide to the value.
298 | z.array(
299 | z.object({
300 | path: z.string(),
301 | title: z.string(),
302 | })
303 | ),
304 | C.LOCALES
305 | ),
306 | })
307 | ),
308 | });
309 | ```
310 |
311 | 1. When you get the collection, you will receive for each locale the localized content. For example if you have two locales `de-CH` and `zh-CN` with two files `main.yml` and `footer.yml`, you will get four entries in the collection:
312 |
313 | ```typescript
314 | import { getCollection } from "astro:content";
315 |
316 | const navigation = await getCollection("infile");
317 | console.log(navigation[0].data.locale); // e.g. de-CH
318 | console.log(navigation[0].data.translationId); // e.g. src/content/infile/main.yml
319 | console.log(navigation[0].data.navigation); // e.g. [{ path: "/projekte", title: "Projekte" }, ...]
320 | ```
321 |
322 | ### Virtual i18n collections
323 |
324 | Sometimes you want to translate a page that is not based on i18n content. For example an index page or a 404 page.
325 |
326 | `createI18nCollection` allows you to create a virtual collection that is not based on any content:
327 |
328 | ```typescript
329 | export const getStaticPaths = async () => {
330 | const routePattern = "[...locale]/[files]";
331 | const collection = createI18nCollection({ locales: C.LOCALES, routePattern });
332 |
333 | return i18nPropsAndParams(collection, {
334 | defaultLocale: C.DEFAULT_LOCALE,
335 | routePattern,
336 | segmentTranslations: C.SEGMENT_TRANSLATIONS,
337 | });
338 | };
339 | ```
340 |
341 | ## API
342 |
343 | Below you can find a description of all exported functions and types.
344 |
345 | ### `i18nLoader`
346 |
347 | `i18nLoader` parses i18n information from file names or folder structures.
348 |
349 | As this is a wrapper around the `glob()` loader, you can use all options from the `glob()` loader. See the [Astro documentation](https://docs.astro.build/en/reference/content-loader-reference/#glob-loader) for more information.
350 |
351 | It adds the following properties to an entrys `data` object:
352 |
353 | - `locale`: The locale of the entry. This is either the folder name or the file name suffix.
354 | - `translationId`: The translation identifier. This helps to identify the same content in different languages.
355 | - `contentPath`: The path to the file relative to the content base path. This is useful if you want to add the folder names into the path. For example `src/content/pages/de-CH/deeply/nested/about.mdx` it would be `deeply/nested`.
356 | - `basePath`: The base path from the Astro config. This is a workaround, because from `getStaticPaths()` you don't have access to the base path and you need it for generating paths.
357 |
358 | ### `i18nContentLoader`
359 |
360 | `i18nContentLoader` creates multiple entries based on yaml or json files that contain i18n text fields.
361 |
362 | See [i18nLoader](#i18nloader) for more information.
363 |
364 | ### `i18nFileLoader`
365 |
366 | `i18nFileLoader` creates multiple entries based on a single yaml or json that contain i18n text fields.
367 |
368 | It is a wrapper around the `file()` loader. See the [Astro documentation](https://docs.astro.build/en/reference/content-loader-reference/#file-loader) for more information.
369 |
370 | ### `localized`
371 |
372 | `localized` is a helper function to define a schema for in-file localized content. It takes a schema and an array of locales and returns a schema that is an object with the locale as key and the schema as value.
373 |
374 | Parameters:
375 | - `schema`: The schema to apply to the value of the object.
376 | - `locales`: An array of locales to use as keys for the object.
377 | - `partial`: Optional. If `true`, not all locales need to be defined in the schema.
378 |
379 | ### `extendI18nLoaderSchema`
380 |
381 | `extendI18nLoaderSchema` is a helper function to extend the schema of the `i18nLoader` and `i18nContentLoader`. It adds the `translationId`, `locale`, `contentPath` and `basePath` properties to the schema.
382 |
383 | ### `i18nLoaderSchema`
384 |
385 | `i18nLoaderSchema` is a schema that is used by the `i18nLoader` and `i18nContentLoader`. It defines the properties that are added to the entrys `data` object.
386 |
387 | ### `i18nPropsAndParams`
388 |
389 | `i18nPropsAndParams` is a helper function to generate the `params` and `props` object for `getStaticPaths()` and to provide the translations object to the page props.
390 |
391 | Parameters:
392 | - `collection`: The collection to use. This can be a collection from `getCollection()` or a virtual collection created with `createI18nCollection()`.
393 | - `options`: An object with the following properties:
394 | - `defaultLocale`: The default locale to use.
395 | - `routePattern`: The route pattern to use. This is the pattern that is used in the `getStaticPaths()` function. Unfortunately there is no way to access the routePattern, that's why we need to define it here again.
396 | - `segmentTranslations`: An object with the segment translations. This is used to translate the segments in the URL.
397 | - `generateSegments`: (Optional) A function that generates the segments for each entry. This is useful if you want to generate slugs or other segments.
398 | - `localeParamName`: (Optional) The name of the locale parameter in the URL. This is used to generate the URL for the translations object.
399 | - `prefixDefaultLocale`: (Optional) If `true`, the default locale will be prefixed to the URL. This is useful if you want to have a clean URL for the default locale.
400 |
401 | It returns an object with `params` and `props`. `props` contains additionally a `translations` object with the paths to the corresponding content of all existing translations. The `translatedPath` is the current entry path.
402 |
403 | ### `createI18nCollection`
404 |
405 | `createI18nCollection` creates a virtual collection that is not based on any content. This is useful if you want to create a collection for a page that is not based on i18n content.
406 |
407 | ### `resolvePath`
408 |
409 | `resolvePath` is a helper function that connects path segments and deals with slashes.
410 |
411 | ## Examples
412 |
413 | Made by the author of `astro-loader-i18n`:
414 |
415 | - Test project ([Source](https://github.com/openscript/astro-loader-i18n/tree/main/apps/example)): Minimal example of how to use `astro-loader-i18n` with Astro.
416 | - Astro Theme International ([Demo](https://openscript.github.io/astro-theme-international/) / [Source](https://github.com/openscript/astro-theme-international)): A demo theme with the goal to be as international as possible.
417 | - r.obin.ch ([Demo](https://r.obin.ch) / [Source](https://github.com/openscript/r.obin.ch)): A personal website with a blog and projects, built with Astro and `astro-loader-i18n`.
418 |
419 | Made by the community:
420 | - eCamp3 ([Demo](https://www.ecamp3.ch) / [Source](https://github.com/ecamp/ecamp-site)): A website of an application for camp planning.
421 |
422 | ## Roadmap
423 |
424 | - [x] Add `i18nFileLoader` that is based on Astros `file()` loader
425 | - [ ] Improve types of params returned by `i18nPropsAndParams`
426 | - [ ] Include a language switcher Astro component
427 |
428 | ## Wish list
429 |
430 | To make internationalization easier, **Astro** could offer the following features:
431 |
432 | - [x] Provide routing information to `getStaticPaths()` such as the `routePattern` to avoid manual repetition. Also see this pull request: https://github.com/withastro/astro/pull/13520
433 | - [ ] Allow to define custom parameters for `getStaticPaths()` like `paginate` from integrations and loaders. This makes integrating additional helpers for building `getStaticPaths()` way easier.
434 | - [ ] Allow to define different schemas for input (this already exists, today) and output of a loader. This is useful if a loader transforms the data. Currently the schema wouldn't match the output of the loader anymore.
435 | - [ ] Allow to define additional custom properties from loaders apart from the `data` object, that are available inside `getStaticPaths()` and while rendering. This is useful if a loader calculates additional properties that later used in the template and are not necessarily part of the data object to avoid collisions with the user provided data.
436 |
--------------------------------------------------------------------------------