├── .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 | [![codecov](https://codecov.io/github/openscript/astro-i18n/graph/badge.svg?token=O2UYXUDEOT)](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 | 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) => {`Icon) 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 | [![NPM Downloads](https://img.shields.io/npm/dw/astro-nanostores-i18n)](https://npmjs.org/astro-nanostores-i18n) 4 | [![npm bundle size](https://img.shields.io/bundlephobia/min/astro-nanostores-i18n)](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 | [![NPM Downloads](https://img.shields.io/npm/dw/astro-loader-i18n)](https://npmjs.org/astro-loader-i18n) 4 | [![npm bundle size](https://img.shields.io/bundlephobia/min/astro-loader-i18n)](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 | --------------------------------------------------------------------------------