├── .npmrc ├── apps └── astro │ ├── public │ ├── astro-i18n │ │ ├── en │ │ │ └── test.json │ │ └── fr │ │ │ └── test.json │ └── favicon.svg │ ├── src │ ├── pages │ │ ├── dir │ │ │ └── en.json │ │ ├── _i18n │ │ │ └── _en.json │ │ ├── about │ │ │ ├── _i18n │ │ │ │ ├── en.json │ │ │ │ └── fr.a-propos.json │ │ │ └── index.astro │ │ ├── product │ │ │ ├── [id] │ │ │ │ ├── _en.json │ │ │ │ └── index.astro │ │ │ ├── _fr.produit.json │ │ │ ├── _en.json │ │ │ └── index.astro │ │ ├── fr │ │ │ ├── index.astro │ │ │ ├── test │ │ │ │ └── index.astro │ │ │ ├── groupe │ │ │ │ ├── index.astro │ │ │ │ └── interieur │ │ │ │ │ └── index.astro │ │ │ ├── a-propos │ │ │ │ └── index.astro │ │ │ └── produit │ │ │ │ ├── index.astro │ │ │ │ └── [id] │ │ │ │ └── index.astro │ │ ├── group │ │ │ ├── index.astro │ │ │ └── inner │ │ │ │ └── index.astro │ │ ├── 404.astro │ │ ├── index.astro │ │ └── test.astro │ ├── env.d.ts │ ├── i18n │ │ ├── admin │ │ │ └── en.json │ │ ├── pages │ │ │ ├── about │ │ │ │ ├── en.json │ │ │ │ ├── fr.json │ │ │ │ └── i18n │ │ │ │ │ ├── en.json │ │ │ │ │ └── fr.json │ │ │ └── product │ │ │ │ ├── en.json │ │ │ │ └── fr.json │ │ └── common │ │ │ ├── fr.json │ │ │ └── en.json │ ├── components │ │ └── ReactComponent.tsx │ ├── middleware │ │ └── index.ts │ └── layouts │ │ └── Layout.astro │ ├── tests │ ├── index.ts │ ├── l.test.ts │ └── t.test.ts │ ├── astro.config.ts │ ├── .prettierrc.cjs │ ├── vitest.config.ts │ ├── tsconfig.json │ ├── .eslintrc.cjs │ ├── package.json │ └── astro-i18n.config.ts ├── pnpm-workspace.yaml ├── .prettierrc.cjs ├── .eslintrc.cjs ├── astro-i18n ├── .prettierrc.cjs ├── src │ ├── core │ │ ├── translation │ │ │ ├── constants │ │ │ │ ├── variant.constants.ts │ │ │ │ ├── translation.constants.ts │ │ │ │ └── translation-patterns.constants.ts │ │ │ ├── enums │ │ │ │ └── value-type.enum.ts │ │ │ ├── errors │ │ │ │ ├── formatters │ │ │ │ │ ├── invalid-date.error.ts │ │ │ │ │ ├── formatter-not-found.error.ts │ │ │ │ │ ├── invalid-formatter-value.error copy.ts │ │ │ │ │ └── invalid-formatter-param.error.ts │ │ │ │ ├── variant │ │ │ │ │ ├── non-string-variant.error.ts │ │ │ │ │ ├── invalid-variant-priority.error.ts │ │ │ │ │ ├── invalid-variant-property-key.error.ts │ │ │ │ │ └── invalid-variant-property-value.error.ts │ │ │ │ ├── untrimmed-string.error.ts │ │ │ │ └── interpolation │ │ │ │ │ └── unknown-value.error.ts │ │ │ ├── guards │ │ │ │ ├── primitive.guard.ts │ │ │ │ ├── deep-string-record.guard.ts │ │ │ │ ├── serialized-formatters.guard.ts │ │ │ │ └── translation-map.guard.ts │ │ │ ├── functions │ │ │ │ ├── formatter.functions.ts │ │ │ │ ├── parsing.functions.ts │ │ │ │ ├── variant │ │ │ │ │ ├── variant-matching.functions.ts │ │ │ │ │ └── variant-parsing.functions.ts │ │ │ │ ├── matching.functions.ts │ │ │ │ ├── interpolation │ │ │ │ │ ├── interpolation-parsing.functions.ts │ │ │ │ │ └── interpolation-matching.functions.ts │ │ │ │ └── translation.functions.ts │ │ │ ├── classes │ │ │ │ ├── interpolation.class.ts │ │ │ │ ├── formatter-bank.class.ts │ │ │ │ ├── variant.class.ts │ │ │ │ └── translation-bank.class.ts │ │ │ ├── types │ │ │ │ └── index.ts │ │ │ └── formatters │ │ │ │ └── default.formatters.ts │ │ ├── cli │ │ │ ├── types │ │ │ │ └── index.ts │ │ │ ├── errors │ │ │ │ └── invalid-command.error.ts │ │ │ ├── constants │ │ │ │ └── cli-patterns.constants.ts │ │ │ └── commands │ │ │ │ ├── generate-pages.command.ts │ │ │ │ ├── extract-keys.command.ts │ │ │ │ └── install.command.ts │ │ ├── state │ │ │ ├── enums │ │ │ │ └── environment.enum.ts │ │ │ ├── singletons │ │ │ │ └── astro-i18n.singleton.ts │ │ │ ├── errors │ │ │ │ ├── cannot-redirect.error.ts │ │ │ │ ├── not-initialized.error.ts │ │ │ │ ├── already-initialized.error.ts │ │ │ │ ├── missing-config-argument.error.ts │ │ │ │ ├── no-filesystem.error.ts │ │ │ │ ├── invalid-environment.error.ts │ │ │ │ └── serialized-state-not-found.error.ts │ │ │ ├── types │ │ │ │ └── index.ts │ │ │ └── guards │ │ │ │ └── serialized-astro-i18n.guard.ts │ │ ├── routing │ │ │ ├── types │ │ │ │ └── index.ts │ │ │ ├── constants │ │ │ │ └── routing-patterns.constants.ts │ │ │ ├── guards │ │ │ │ └── segment-translations.guard.ts │ │ │ ├── functions │ │ │ │ └── index.ts │ │ │ └── classes │ │ │ │ └── segment-bank.class.ts │ │ ├── config │ │ │ ├── errors │ │ │ │ ├── config-not-found.error.ts │ │ │ │ ├── root-not-found.error.ts │ │ │ │ └── mixed-primary-secondary.error.ts │ │ │ ├── constants │ │ │ │ ├── config-patterns.constants.ts │ │ │ │ └── path-patterns.constants.ts │ │ │ ├── guards │ │ │ │ ├── config-routes.guard.ts │ │ │ │ ├── config-translations.guard.ts │ │ │ │ └── config.guard.ts │ │ │ ├── types │ │ │ │ └── index.ts │ │ │ ├── classes │ │ │ │ └── config.class.ts │ │ │ └── functions │ │ │ │ └── config.functions.ts │ │ ├── page │ │ │ ├── errors │ │ │ │ ├── pages-not-found.error.ts │ │ │ │ └── invalid-translation-file-pattern.error.ts │ │ │ ├── types │ │ │ │ └── index.ts │ │ │ ├── constants │ │ │ │ └── page-patterns.constants.ts │ │ │ ├── functions │ │ │ │ ├── frontmatter.functions.ts │ │ │ │ └── page.functions.ts │ │ │ └── classes │ │ │ │ └── page.class.ts │ │ └── astro │ │ │ ├── middleware │ │ │ └── index.ts │ │ │ └── types │ │ │ └── index.ts │ ├── env.d.ts │ ├── constants │ │ ├── meta.constants.ts │ │ └── app.constants.ts │ ├── errors │ │ └── unreachable-code.error.ts │ ├── index.ts │ └── bin.ts ├── lib │ ├── regex │ │ ├── types │ │ │ └── index.ts │ │ ├── index.ts │ │ └── classes │ │ │ ├── regex-builder.class.ts │ │ │ └── regex.class.ts │ ├── argv │ │ ├── error │ │ │ ├── no-arguments-found.error.ts │ │ │ ├── process-undefined.error.ts │ │ │ └── invalid-process-argv.error.ts │ │ ├── types │ │ │ └── index.ts │ │ └── index.ts │ ├── async-node │ │ ├── errors │ │ │ ├── invalid-path.error.ts │ │ │ ├── invalid-json.error.ts │ │ │ ├── file-not-found.error.ts │ │ │ ├── dependency-not-found.error.ts │ │ │ └── invalid-file-type.error.ts │ │ ├── types │ │ │ └── index.ts │ │ ├── classes │ │ │ └── async-node.class.ts │ │ └── functions │ │ │ ├── path.functions.ts │ │ │ ├── fs.functions.ts │ │ │ └── import.functions.ts │ ├── array │ │ └── index.ts │ ├── error │ │ └── index.ts │ ├── object │ │ └── index.ts │ └── ts │ │ └── guards │ │ └── index.ts ├── .eslintrc.cjs ├── tsconfig.json ├── bin │ ├── pre-build.cjs │ ├── post-package.cjs │ ├── build.cjs │ ├── pre-package.cjs │ └── version.cjs ├── LICENSE └── package.json ├── .eslintignore ├── img ├── json_translations.png └── logo.svg ├── tsconfig.json ├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md └── FUNDING.yml ├── .gitignore ├── LICENCE ├── package.json └── turbo.json /.npmrc: -------------------------------------------------------------------------------- 1 | auto-install-peers = true 2 | -------------------------------------------------------------------------------- /apps/astro/public/astro-i18n/en/test.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /apps/astro/public/astro-i18n/fr/test.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /apps/astro/src/pages/dir/en.json: -------------------------------------------------------------------------------- 1 | { "test": "test" } 2 | -------------------------------------------------------------------------------- /apps/astro/src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /apps/astro/src/i18n/admin/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "admin": "admin-en" 3 | } 4 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "apps/*" 3 | - "astro-i18n" 4 | -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ...require("af-prettierrc"), 3 | } 4 | -------------------------------------------------------------------------------- /apps/astro/src/pages/_i18n/_en.json: -------------------------------------------------------------------------------- 1 | { 2 | "index-test": "index-test-en" 3 | } 4 | -------------------------------------------------------------------------------- /apps/astro/src/pages/about/_i18n/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "about-test": "about-test-en" 3 | } 4 | -------------------------------------------------------------------------------- /apps/astro/src/pages/product/[id]/_en.json: -------------------------------------------------------------------------------- 1 | { 2 | "[id]-test": "[id]-test-en" 3 | } 4 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ["af-typescript"], 4 | } 5 | -------------------------------------------------------------------------------- /astro-i18n/.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ...require("af-prettierrc"), 3 | } 4 | -------------------------------------------------------------------------------- /apps/astro/src/i18n/pages/about/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "root-about-test": "root-about-test-en" 3 | } 4 | -------------------------------------------------------------------------------- /apps/astro/src/i18n/pages/about/fr.json: -------------------------------------------------------------------------------- 1 | { 2 | "root-about-test": "root-about-test-fr" 3 | } 4 | -------------------------------------------------------------------------------- /apps/astro/src/pages/about/_i18n/fr.a-propos.json: -------------------------------------------------------------------------------- 1 | { 2 | "about-test": "about-test-fr" 3 | } 4 | -------------------------------------------------------------------------------- /apps/astro/src/pages/product/_fr.produit.json: -------------------------------------------------------------------------------- 1 | { 2 | "product-test": "product-test-fr" 3 | } 4 | -------------------------------------------------------------------------------- /apps/astro/src/i18n/common/fr.json: -------------------------------------------------------------------------------- 1 | { 2 | "index_common_translation": "index_common_translation" 3 | } -------------------------------------------------------------------------------- /apps/astro/src/i18n/pages/product/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "root-product-test": "root-product-test-en" 3 | } 4 | -------------------------------------------------------------------------------- /apps/astro/src/i18n/pages/product/fr.json: -------------------------------------------------------------------------------- 1 | { 2 | "root-product-test": "root-product-test-fr" 3 | } 4 | -------------------------------------------------------------------------------- /apps/astro/src/i18n/pages/about/i18n/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "root-about-test-2": "root-about-test-2-en" 3 | } 4 | -------------------------------------------------------------------------------- /apps/astro/src/i18n/pages/about/i18n/fr.json: -------------------------------------------------------------------------------- 1 | { 2 | "root-about-test-2": "root-about-test-2-fr" 3 | } 4 | -------------------------------------------------------------------------------- /apps/astro/tests/index.ts: -------------------------------------------------------------------------------- 1 | import { astroI18n } from "astro-i18n" 2 | 3 | await astroI18n.initialize() 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | esbuild 3 | dist 4 | *.js 5 | *.cjs 6 | *.mjs 7 | *.d.ts 8 | astro-i18n/**/*.astro -------------------------------------------------------------------------------- /astro-i18n/src/core/translation/constants/variant.constants.ts: -------------------------------------------------------------------------------- 1 | export const VARIANT_PRIORITY_KEY = "$priority" 2 | -------------------------------------------------------------------------------- /img/json_translations.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Alexandre-Fernandez/astro-i18n/HEAD/img/json_translations.png -------------------------------------------------------------------------------- /astro-i18n/lib/regex/types/index.ts: -------------------------------------------------------------------------------- 1 | export type ExecResult = { 2 | range: [number, number] 3 | match: string[] 4 | } 5 | -------------------------------------------------------------------------------- /apps/astro/src/i18n/common/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "common": "common-en", 3 | "index_common_translation": "index_common_translation" 4 | } -------------------------------------------------------------------------------- /apps/astro/src/pages/fr/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Page from "../index.astro" 3 | const { props } = Astro 4 | --- 5 | -------------------------------------------------------------------------------- /apps/astro/src/pages/fr/test/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Page from "../../test.astro" 3 | const { props } = Astro 4 | --- 5 | -------------------------------------------------------------------------------- /apps/astro/src/pages/fr/groupe/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Page from "../../group/index.astro" 3 | const { props } = Astro 4 | --- 5 | -------------------------------------------------------------------------------- /apps/astro/src/pages/fr/a-propos/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Page from "../../about/index.astro" 3 | const { props } = Astro 4 | --- 5 | -------------------------------------------------------------------------------- /apps/astro/src/pages/fr/produit/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Page from "../../product/index.astro" 3 | const { props } = Astro 4 | --- 5 | -------------------------------------------------------------------------------- /astro-i18n/src/env.d.ts: -------------------------------------------------------------------------------- 1 | interface ImportMeta { 2 | env: { 3 | MODE: "development" | "production" 4 | PROD: boolean 5 | DEV: boolean 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /apps/astro/src/pages/fr/groupe/interieur/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Page from "../../../group/inner/index.astro" 3 | const { props } = Astro 4 | --- 5 | -------------------------------------------------------------------------------- /astro-i18n/src/core/translation/constants/translation.constants.ts: -------------------------------------------------------------------------------- 1 | export const TRANSLATION_KEY_SEPARATOR = "." 2 | export const COMMON_TRANSLATIONS_GROUP = "common" 3 | -------------------------------------------------------------------------------- /astro-i18n/src/core/cli/types/index.ts: -------------------------------------------------------------------------------- 1 | export type FlatConfigTranslations = { 2 | [group: string]: { 3 | [locale: string]: { 4 | [key: string]: string 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /astro-i18n/src/core/state/enums/environment.enum.ts: -------------------------------------------------------------------------------- 1 | enum Environment { 2 | NONE = "none", 3 | NODE = "node", 4 | BROWSER = "browser", 5 | } 6 | 7 | export default Environment 8 | -------------------------------------------------------------------------------- /astro-i18n/src/core/state/singletons/astro-i18n.singleton.ts: -------------------------------------------------------------------------------- 1 | import AstroI18n from "@src/core/state/classes/astro-i18n.class" 2 | 3 | export const astroI18n = new AstroI18n() 4 | -------------------------------------------------------------------------------- /astro-i18n/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ["af-typescript"], 3 | parserOptions: { 4 | project: "./tsconfig.json", 5 | tsconfigRootDir: __dirname, 6 | }, 7 | } 8 | -------------------------------------------------------------------------------- /apps/astro/src/components/ReactComponent.tsx: -------------------------------------------------------------------------------- 1 | import { astroI18n } from "astro-i18n" 2 | 3 | export default function ReactComponent() { 4 | return

{astroI18n.t("commonBasic")}

5 | } 6 | -------------------------------------------------------------------------------- /astro-i18n/src/core/routing/types/index.ts: -------------------------------------------------------------------------------- 1 | export type SegmentTranslations = { 2 | [locale: string]: { 3 | [untranslated: string]: { 4 | [otherLocale: string]: string 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /astro-i18n/src/core/cli/errors/invalid-command.error.ts: -------------------------------------------------------------------------------- 1 | class InvalidCommand extends Error { 2 | constructor() { 3 | super(`Invalid command.`) 4 | } 5 | } 6 | 7 | export default InvalidCommand 8 | -------------------------------------------------------------------------------- /astro-i18n/src/core/translation/enums/value-type.enum.ts: -------------------------------------------------------------------------------- 1 | export enum ValueType { 2 | UNDEFINED, 3 | NULL, 4 | BOOLEAN, 5 | NUMBER, 6 | VARIABLE, 7 | STRING, 8 | OBJECT, 9 | ARRAY, 10 | } 11 | -------------------------------------------------------------------------------- /apps/astro/src/pages/product/_en.json: -------------------------------------------------------------------------------- 1 | { 2 | "product-test": "product-test-en (default)", 3 | "product-test{{ n: 2 }}": "product-test-en (n: 2)", 4 | "product-interpolation": "I have '{# test>upper #}'" 5 | } 6 | -------------------------------------------------------------------------------- /astro-i18n/lib/argv/error/no-arguments-found.error.ts: -------------------------------------------------------------------------------- 1 | class NoArgumentsFound extends Error { 2 | constructor() { 3 | super("No CLI arguments were found.") 4 | } 5 | } 6 | 7 | export default NoArgumentsFound 8 | -------------------------------------------------------------------------------- /astro-i18n/lib/argv/error/process-undefined.error.ts: -------------------------------------------------------------------------------- 1 | class ProcessUndefined extends Error { 2 | constructor() { 3 | super("`process` global is undefined.") 4 | } 5 | } 6 | 7 | export default ProcessUndefined 8 | -------------------------------------------------------------------------------- /apps/astro/astro.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "astro/config"; 2 | 3 | import react from "@astrojs/react"; 4 | 5 | // https://astro.build/config 6 | export default defineConfig({ 7 | integrations: [react()] 8 | }); -------------------------------------------------------------------------------- /astro-i18n/lib/argv/error/invalid-process-argv.error.ts: -------------------------------------------------------------------------------- 1 | class InvalidProcessArgv extends Error { 2 | constructor() { 3 | super("An unexpected format of `process.argv` was found.") 4 | } 5 | } 6 | 7 | export default InvalidProcessArgv 8 | -------------------------------------------------------------------------------- /astro-i18n/lib/async-node/errors/invalid-path.error.ts: -------------------------------------------------------------------------------- 1 | class InvalidPath extends Error { 2 | constructor(path?: string) { 3 | super(path ? `Invalid path (${path}).` : "Invalid path.") 4 | } 5 | } 6 | 7 | export default InvalidPath 8 | -------------------------------------------------------------------------------- /astro-i18n/lib/regex/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Regex } from "@lib/regex/classes/regex.class" 2 | export { default as RegexBuilder } from "@lib/regex/classes/regex-builder.class" 3 | export type { ExecResult } from "@lib/regex/types" 4 | -------------------------------------------------------------------------------- /astro-i18n/src/constants/meta.constants.ts: -------------------------------------------------------------------------------- 1 | export const PACKAGE_NAME = "astro-i18n" 2 | export const CONFIG_NAME = `${PACKAGE_NAME}.config` 3 | export const REPOSITORY_ISSUES_URL = 4 | "github.com/alexandre-fernandez/astro-i18n/issues" 5 | -------------------------------------------------------------------------------- /astro-i18n/src/core/state/errors/cannot-redirect.error.ts: -------------------------------------------------------------------------------- 1 | class CannotRedirect extends Error { 2 | constructor() { 3 | super("Could not redirect, is this code running in the server ?") 4 | } 5 | } 6 | 7 | export default CannotRedirect 8 | -------------------------------------------------------------------------------- /astro-i18n/src/core/state/errors/not-initialized.error.ts: -------------------------------------------------------------------------------- 1 | class NotInitialized extends Error { 2 | constructor() { 3 | super("Cannot perform operation, astro-i18n is not initialized.") 4 | } 5 | } 6 | 7 | export default NotInitialized 8 | -------------------------------------------------------------------------------- /astro-i18n/src/constants/app.constants.ts: -------------------------------------------------------------------------------- 1 | export const CALLBACK_BREAK = "break" 2 | export const SUPPORTED_CONFIG_FORMATS = ["json", "js", "cjs", "mjs", "ts"] 3 | export const DEFAULT_TRANSLATION_DIRNAME = "i18n" 4 | export const PAGES_DIRNAME = "pages" 5 | -------------------------------------------------------------------------------- /astro-i18n/src/core/state/errors/already-initialized.error.ts: -------------------------------------------------------------------------------- 1 | class AlreadyInitialized extends Error { 2 | constructor() { 3 | super("Cannot initialize astro-i18n, it already has been initialized.") 4 | } 5 | } 6 | 7 | export default AlreadyInitialized 8 | -------------------------------------------------------------------------------- /astro-i18n/src/core/translation/errors/formatters/invalid-date.error.ts: -------------------------------------------------------------------------------- 1 | class InvalidDate extends Error { 2 | constructor(value?: unknown) { 3 | super(value ? `Invalid date (${value}).` : "Invalid date.") 4 | } 5 | } 6 | 7 | export default InvalidDate 8 | -------------------------------------------------------------------------------- /astro-i18n/src/core/translation/errors/variant/non-string-variant.error.ts: -------------------------------------------------------------------------------- 1 | class NonStringVariant extends Error { 2 | constructor() { 3 | super("Cannot use a variant on a key which value is not a string.") 4 | } 5 | } 6 | 7 | export default NonStringVariant 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "@lib": ["./astro-i18n/lib"], 6 | "@lib/*": ["./astro-i18n/lib/*"], 7 | "@src": ["./astro-i18n/src"], 8 | "@src/*": ["./astro-i18n/src/*"] 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /apps/astro/.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ...require("af-prettierrc"), 3 | plugins: [require.resolve("prettier-plugin-astro")], 4 | overrides: [ 5 | { 6 | files: "*.astro", 7 | options: { 8 | parser: "astro", 9 | }, 10 | }, 11 | ], 12 | } 13 | -------------------------------------------------------------------------------- /apps/astro/src/middleware/index.ts: -------------------------------------------------------------------------------- 1 | import { sequence } from "astro/middleware" 2 | import { useAstroI18n } from "astro-i18n" 3 | 4 | const astroI18n = useAstroI18n( 5 | undefined /* config */, 6 | undefined /* custom formatters */, 7 | ) 8 | 9 | export const onRequest = sequence(astroI18n) -------------------------------------------------------------------------------- /astro-i18n/lib/argv/types/index.ts: -------------------------------------------------------------------------------- 1 | export type Command = { name: string; options: readonly string[] } 2 | 3 | export type ParsedArgv = { 4 | node: string 5 | filename: string 6 | command: string | null 7 | args: string[] 8 | options: { [name: string]: string | true } 9 | } 10 | -------------------------------------------------------------------------------- /astro-i18n/src/core/config/errors/config-not-found.error.ts: -------------------------------------------------------------------------------- 1 | import { CONFIG_NAME } from "@src/constants/meta.constants" 2 | 3 | class ConfigNotFound extends Error { 4 | constructor() { 5 | super(`Unable to find ${CONFIG_NAME}.`) 6 | } 7 | } 8 | 9 | export default ConfigNotFound 10 | -------------------------------------------------------------------------------- /astro-i18n/src/core/state/errors/missing-config-argument.error.ts: -------------------------------------------------------------------------------- 1 | class MissingConfigArgument extends Error { 2 | constructor() { 3 | super( 4 | `A config must be provided when not in a node or a browser environment.`, 5 | ) 6 | } 7 | } 8 | 9 | export default MissingConfigArgument 10 | -------------------------------------------------------------------------------- /astro-i18n/src/core/state/errors/no-filesystem.error.ts: -------------------------------------------------------------------------------- 1 | class NoFilesystem extends Error { 2 | constructor(message?: string) { 3 | super( 4 | message 5 | ? `Cannot use filesystem: ${message}` 6 | : "Cannot use filesystem.", 7 | ) 8 | } 9 | } 10 | 11 | export default NoFilesystem 12 | -------------------------------------------------------------------------------- /astro-i18n/src/core/page/errors/pages-not-found.error.ts: -------------------------------------------------------------------------------- 1 | import { PAGES_DIRNAME } from "@src/constants/app.constants" 2 | 3 | class PagesNotFound extends Error { 4 | constructor() { 5 | super(`Could not find astro "${PAGES_DIRNAME}" directory.`) 6 | } 7 | } 8 | 9 | export default PagesNotFound 10 | -------------------------------------------------------------------------------- /astro-i18n/src/core/routing/constants/routing-patterns.constants.ts: -------------------------------------------------------------------------------- 1 | import { Regex } from "@lib/regex" 2 | 3 | export const ROUTE_PARAM_PATTERN = new Regex(/\[(\.{3})?([\w-]+)]/) 4 | 5 | export const URL_PATTERN = new Regex( 6 | /(?:https?:\/{2})?[\w#%+.:=@~-]{1,256}\.[\d()A-Za-z]{1,6}\b[\w#%&()+./:=?@~-]*/, 7 | ) 8 | -------------------------------------------------------------------------------- /astro-i18n/src/core/state/errors/invalid-environment.error.ts: -------------------------------------------------------------------------------- 1 | class InvalidEnvironment extends Error { 2 | constructor(message?: string) { 3 | super( 4 | message 5 | ? `Invalid environment: ${message}` 6 | : "Invalid environment.", 7 | ) 8 | } 9 | } 10 | 11 | export default InvalidEnvironment 12 | -------------------------------------------------------------------------------- /astro-i18n/lib/async-node/errors/invalid-json.error.ts: -------------------------------------------------------------------------------- 1 | class InvalidJson extends Error { 2 | constructor(path?: string) { 3 | super( 4 | path 5 | ? `Invalid format, could not parse JSON (${path}).` 6 | : "Invalid format, could not parse JSON.", 7 | ) 8 | } 9 | } 10 | 11 | export default InvalidJson 12 | -------------------------------------------------------------------------------- /astro-i18n/lib/async-node/errors/file-not-found.error.ts: -------------------------------------------------------------------------------- 1 | class FileNotFound extends Error { 2 | constructor(path?: string) { 3 | super( 4 | path 5 | ? `No file was found for the given path (${path}).` 6 | : "No file was found for the given path.", 7 | ) 8 | } 9 | } 10 | 11 | export default FileNotFound 12 | -------------------------------------------------------------------------------- /astro-i18n/src/core/translation/errors/formatters/formatter-not-found.error.ts: -------------------------------------------------------------------------------- 1 | class FormatterNotFound extends Error { 2 | constructor(name?: string) { 3 | super( 4 | name 5 | ? `Formatter "${name}" was not found.` 6 | : "Formatter not found.", 7 | ) 8 | } 9 | } 10 | 11 | export default FormatterNotFound 12 | -------------------------------------------------------------------------------- /astro-i18n/src/core/translation/errors/untrimmed-string.error.ts: -------------------------------------------------------------------------------- 1 | class UntrimmedString extends Error { 2 | constructor(value = "") { 3 | super( 4 | value 5 | ? `Cannot procces untrimmed value ("${value}").` 6 | : "Cannot procces untrimmed value", 7 | ) 8 | } 9 | } 10 | 11 | export default UntrimmedString 12 | -------------------------------------------------------------------------------- /astro-i18n/src/core/config/errors/root-not-found.error.ts: -------------------------------------------------------------------------------- 1 | class RootNotFound extends Error { 2 | constructor(instructions?: string) { 3 | super( 4 | instructions 5 | ? `Unable to find project root. ${instructions}` 6 | : "Unable to find project root.", 7 | ) 8 | } 9 | } 10 | 11 | export default RootNotFound 12 | -------------------------------------------------------------------------------- /apps/astro/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import tsconfigPaths from "vite-tsconfig-paths" 2 | import { configDefaults, defineConfig, type UserConfig } from "vitest/config" 3 | 4 | export default defineConfig({ 5 | test: { 6 | ...configDefaults, 7 | setupFiles: ["tests/index.ts"], 8 | }, 9 | plugins: [(tsconfigPaths as any)()], 10 | }) as UserConfig 11 | -------------------------------------------------------------------------------- /astro-i18n/src/core/page/types/index.ts: -------------------------------------------------------------------------------- 1 | import type { DeepStringRecord } from "@src/core/translation/types" 2 | 3 | export interface PageProps { 4 | name: string 5 | route: string 6 | path: string 7 | translations: { 8 | [locale: string]: DeepStringRecord 9 | } 10 | routes: { 11 | [secondaryLocale: string]: { 12 | [segment: string]: string 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /astro-i18n/src/core/translation/errors/variant/invalid-variant-priority.error.ts: -------------------------------------------------------------------------------- 1 | class InvalidVariantPriority extends Error { 2 | constructor(value?: string) { 3 | super( 4 | value 5 | ? `Variant priority must be of type number (found: (${value})).` 6 | : "Variant priority must be of type number.", 7 | ) 8 | } 9 | } 10 | 11 | export default InvalidVariantPriority 12 | -------------------------------------------------------------------------------- /astro-i18n/lib/async-node/errors/dependency-not-found.error.ts: -------------------------------------------------------------------------------- 1 | class DependencyNotFound extends Error { 2 | constructor(dependency?: string) { 3 | super( 4 | dependency 5 | ? `Peer dependency \`${dependency}\` was not found, if it's not already done try installing it.` 6 | : "A dependency was not found.", 7 | ) 8 | } 9 | } 10 | 11 | export default DependencyNotFound 12 | -------------------------------------------------------------------------------- /apps/astro/src/pages/about/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Layout from "../../layouts/Layout.astro" 3 | --- 4 | 5 | 6 |
/about
7 |
8 | 9 | 20 | -------------------------------------------------------------------------------- /apps/astro/src/pages/group/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Layout from "../../layouts/Layout.astro" 3 | --- 4 | 5 | 6 |
/fr
7 |
8 | 9 | 20 | -------------------------------------------------------------------------------- /apps/astro/src/pages/product/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Layout from "../../layouts/Layout.astro" 3 | --- 4 | 5 | 6 |
/product
7 |
8 | 9 | 20 | -------------------------------------------------------------------------------- /astro-i18n/src/core/state/errors/serialized-state-not-found.error.ts: -------------------------------------------------------------------------------- 1 | import { PACKAGE_NAME } from "@src/constants/meta.constants" 2 | 3 | class SerializedStateNotFound extends Error { 4 | constructor() { 5 | super( 6 | `Could not find the serialized ${PACKAGE_NAME} state in the DOM. Check your ${PACKAGE_NAME} config.`, 7 | ) 8 | } 9 | } 10 | 11 | export default SerializedStateNotFound 12 | -------------------------------------------------------------------------------- /apps/astro/src/pages/group/inner/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Layout from "../../../layouts/Layout.astro" 3 | --- 4 | 5 | 6 |
/fr
7 |
8 | 9 | 20 | -------------------------------------------------------------------------------- /apps/astro/src/pages/404.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Layout from "../layouts/Layout.astro" 3 | 4 | const data = Astro.locals 5 | --- 6 | 7 | 8 |
404 {JSON.stringify(data)}
9 |
10 | 11 | 12 | 13 | 22 | -------------------------------------------------------------------------------- /astro-i18n/lib/array/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Similar to `Array.prototype.map` but replaces items on the same array instead 3 | * of returning a new one. 4 | */ 5 | export function replace( 6 | callback: (value: T, index: number, array: T[]) => unknown, 7 | array: T[], 8 | ) { 9 | for (let i = 0; i < array.length; i += 1) { 10 | array[i] = callback(array[i] as T, i, array) as T 11 | } 12 | 13 | return array 14 | } 15 | -------------------------------------------------------------------------------- /astro-i18n/lib/async-node/errors/invalid-file-type.error.ts: -------------------------------------------------------------------------------- 1 | class InvalidFileType extends Error { 2 | constructor(supportedFormats: string[] = []) { 3 | super( 4 | supportedFormats.length > 0 5 | ? `Invalid file type, supported formats are: "${supportedFormats.join( 6 | '", "', 7 | )}"` 8 | : "Invalid file type, format not supported.", 9 | ) 10 | } 11 | } 12 | 13 | export default InvalidFileType 14 | -------------------------------------------------------------------------------- /astro-i18n/src/core/translation/errors/variant/invalid-variant-property-key.error.ts: -------------------------------------------------------------------------------- 1 | class InvalidVariantPropertyKey extends Error { 2 | constructor(key?: string) { 3 | super( 4 | key 5 | ? `Invalid variant property key (${key}), it must be a valid variable name.` 6 | : `Invalid variant property value, it must be a valid variable name.`, 7 | ) 8 | } 9 | } 10 | 11 | export default InvalidVariantPropertyKey 12 | -------------------------------------------------------------------------------- /astro-i18n/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "af-tsconfig", 3 | "compilerOptions": { 4 | "target": "es2022", 5 | "module": "es2022", 6 | "emitDeclarationOnly": true, 7 | "declaration": true, 8 | "outDir": "./dist", 9 | "baseUrl": ".", 10 | "paths": { 11 | "@src": ["./src"], 12 | "@src/*": ["./src/*"], 13 | "@lib": ["./lib"], 14 | "@lib/*": ["./lib/*"] 15 | } 16 | }, 17 | "include": ["src", "lib"] 18 | } 19 | -------------------------------------------------------------------------------- /astro-i18n/src/core/config/errors/mixed-primary-secondary.error.ts: -------------------------------------------------------------------------------- 1 | class MixedPrimarySecondary extends Error { 2 | constructor(primaryLocale?: string) { 3 | super( 4 | primaryLocale 5 | ? `Your primaryLocale ("${primaryLocale}") cannot be contained in your secondaryLocales array.` 6 | : `Your primaryLocale cannot be contained in your secondaryLocales array.`, 7 | ) 8 | } 9 | } 10 | 11 | export default MixedPrimarySecondary 12 | -------------------------------------------------------------------------------- /apps/astro/src/pages/fr/produit/[id]/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Page from "../../../product/[id]/index.astro" 3 | import { getStaticPaths as proxyGetStaticPaths } from "../../../product/[id]/index.astro" 4 | /* @ts-ignore */ 5 | export const getStaticPaths = (props) => proxyGetStaticPaths({ ...props, astroI18n: { locale: "fr", route: "/produit/[id]", primaryLocale: "en", secondaryLocales: ["fr"] } }) 6 | const { props } = Astro 7 | --- 8 | -------------------------------------------------------------------------------- /astro-i18n/bin/pre-build.cjs: -------------------------------------------------------------------------------- 1 | const { existsSync, lstatSync, copyFileSync } = require("fs") 2 | const { join } = require("path") 3 | 4 | const README = "README.md" 5 | const ASTRO_I18N_DIR = join(__dirname, "..") 6 | const README_PATH = join(ASTRO_I18N_DIR, README) 7 | 8 | if (existsSync(README_PATH) && lstatSync(README_PATH).isFile()) { 9 | const MONOREPO_README = join(ASTRO_I18N_DIR, "..", README) 10 | copyFileSync(README_PATH, MONOREPO_README) 11 | } 12 | -------------------------------------------------------------------------------- /astro-i18n/lib/error/index.ts: -------------------------------------------------------------------------------- 1 | import UnreachableCode from "@src/errors/unreachable-code.error" 2 | 3 | export function never(): never { 4 | throw new UnreachableCode() 5 | } 6 | 7 | export function throwError(error = new Error("Something went wrong.")): never { 8 | throw error 9 | } 10 | 11 | export function throwFalsy(): never { 12 | throw new TypeError( 13 | `Expected a truthy value, found a falsy one (false, NaN, 0, undefined, null or "").`, 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /astro-i18n/bin/post-package.cjs: -------------------------------------------------------------------------------- 1 | const { existsSync, lstatSync, copyFileSync, unlinkSync } = require("fs") 2 | const { join } = require("path") 3 | 4 | const ASTRO_I18N_DIR = join(__dirname, "..") 5 | const PACKAGE_JSON_PATH = join(ASTRO_I18N_DIR, "package.json") 6 | 7 | const BACKUP_PATH = `${PACKAGE_JSON_PATH}.bk` 8 | if (existsSync(BACKUP_PATH) && lstatSync(BACKUP_PATH).isFile()) { 9 | copyFileSync(BACKUP_PATH, PACKAGE_JSON_PATH) 10 | unlinkSync(BACKUP_PATH) 11 | } 12 | -------------------------------------------------------------------------------- /astro-i18n/lib/async-node/types/index.ts: -------------------------------------------------------------------------------- 1 | import type esbuild from "esbuild" 2 | 3 | export type AsyncNodeJsCache = { 4 | path: typeof import("node:path") 5 | fs: typeof import("node:fs") 6 | url: typeof import("node:url") 7 | module: typeof import("node:module") & { 8 | Module: { 9 | _nodeModulePaths: (dir: string) => string[] 10 | _resolveFilename: (request: string, module: any) => string 11 | } 12 | } 13 | } 14 | 15 | export type Esbuild = typeof esbuild 16 | -------------------------------------------------------------------------------- /apps/astro/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "af-tsconfig", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "module": "es2022", 6 | "target": "es2022", 7 | "paths": { 8 | "@lib": ["../../astro-i18n/lib"], 9 | "@lib/*": ["../../astro-i18n/lib/*"], 10 | "@src": ["../../astro-i18n/src", "./src"], 11 | "@src/*": ["../../astro-i18n/src/*", "./src/*"] 12 | }, 13 | "jsx": "react-jsx", 14 | "jsxImportSource": "react" 15 | }, 16 | "exclude": ["dist"] 17 | } 18 | -------------------------------------------------------------------------------- /apps/astro/src/layouts/Layout.astro: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 21 | -------------------------------------------------------------------------------- /astro-i18n/src/core/config/constants/config-patterns.constants.ts: -------------------------------------------------------------------------------- 1 | import { Regex } from "@lib/regex" 2 | import { SUPPORTED_CONFIG_FORMATS } from "@src/constants/app.constants" 3 | import { PACKAGE_NAME } from "@src/constants/meta.constants" 4 | 5 | export const ASTRO_I18N_CONFIG_PATTERN = Regex.fromString( 6 | `${PACKAGE_NAME}\\.config\\.(${SUPPORTED_CONFIG_FORMATS.join("|")})`, 7 | ) 8 | 9 | export const ASTRO_CONFIG_PATTERN = Regex.fromString( 10 | `astro\\.config\\.(js|cjs|mjs|ts)`, 11 | ) 12 | -------------------------------------------------------------------------------- /apps/astro/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ["af-typescript"], 3 | parserOptions: { 4 | project: "./tsconfig.json", 5 | tsconfigRootDir: __dirname, 6 | }, 7 | overrides: [ 8 | { 9 | files: ["*.astro"], 10 | parser: "astro-eslint-parser", 11 | parserOptions: { 12 | extraFileExtensions: [".astro"], 13 | project: `${__dirname}/tsconfig.json`, 14 | }, 15 | }, 16 | ], 17 | rules: { 18 | "unicorn/text-encoding-identifier-case": "off", 19 | "no-undef": "off", 20 | }, 21 | } 22 | -------------------------------------------------------------------------------- /astro-i18n/src/core/state/types/index.ts: -------------------------------------------------------------------------------- 1 | import type { SerializedConfig } from "@src/core/config/types" 2 | import type { SegmentTranslations } from "@src/core/routing/types" 3 | import type { 4 | SerializedFormatters, 5 | SerializedTranslationMap, 6 | } from "@src/core/translation/types" 7 | 8 | export type SerializedAstroI18n = { 9 | locale: string 10 | route: string 11 | config: SerializedConfig 12 | translations: SerializedTranslationMap 13 | segments: SegmentTranslations 14 | formatters: SerializedFormatters 15 | } 16 | -------------------------------------------------------------------------------- /astro-i18n/src/core/translation/errors/interpolation/unknown-value.error.ts: -------------------------------------------------------------------------------- 1 | class UnknownValue extends Error { 2 | constructor(value = "") { 3 | if (value) { 4 | const maxPreview = 20 5 | const preview = value.slice(0, maxPreview) 6 | if (preview.length === maxPreview) { 7 | value = `${preview.slice(0, maxPreview - 3)}...` 8 | } 9 | } 10 | 11 | super( 12 | value 13 | ? `Cannot parse unknown value (${value}).` 14 | : "Cannot parse unknown value", 15 | ) 16 | } 17 | } 18 | 19 | export default UnknownValue 20 | -------------------------------------------------------------------------------- /astro-i18n/src/core/config/constants/path-patterns.constants.ts: -------------------------------------------------------------------------------- 1 | import { Regex } from "@lib/regex" 2 | 3 | export const NODE_MODULES_SEGMENT_PATTERN = new Regex(/[/\\]?node_modules/) 4 | 5 | export const NODE_MODULES_PATH_PATTERN = new Regex(/.+?node_modules/) 6 | 7 | export const PACKAGE_DENO_JSON_PATTERN = new Regex(/(?:package|deno)\.json/) 8 | 9 | export const PACKAGE_JSON_PATTERN = new Regex(/package\.json/) 10 | 11 | export const DENO_JSON_PATTERN = new Regex(/deno\.jsonc?/) 12 | 13 | export const DEPS_TS_PATTERN = new Regex(/deps\.ts/) 14 | -------------------------------------------------------------------------------- /astro-i18n/src/core/translation/errors/variant/invalid-variant-property-value.error.ts: -------------------------------------------------------------------------------- 1 | const acceptedTypes = 2 | "undefined, null, number, string, boolean and flat arrays of these types." 3 | 4 | class InvalidVariantPropertyValue extends Error { 5 | constructor(value?: string) { 6 | super( 7 | value 8 | ? `Invalid variant property value (${value}), accepted types are: ${acceptedTypes}.` 9 | : `Invalid variant property value, accepted types are: ${acceptedTypes}.`, 10 | ) 11 | } 12 | } 13 | 14 | export default InvalidVariantPropertyValue 15 | -------------------------------------------------------------------------------- /astro-i18n/src/errors/unreachable-code.error.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PACKAGE_NAME, 3 | REPOSITORY_ISSUES_URL, 4 | } from "@src/constants/meta.constants" 5 | 6 | class UnreachableCode extends Error { 7 | constructor(location?: string) { 8 | super( 9 | location 10 | ? `${PACKAGE_NAME}: Unreachable code executed (at "${location}"), please open an issue at "${REPOSITORY_ISSUES_URL}".` 11 | : `${PACKAGE_NAME}: Unreachable code executed, please open an issue at "${REPOSITORY_ISSUES_URL}".`, 12 | ) 13 | } 14 | } 15 | 16 | export default UnreachableCode 17 | -------------------------------------------------------------------------------- /astro-i18n/src/core/config/guards/config-routes.guard.ts: -------------------------------------------------------------------------------- 1 | import { isObject } from "@lib/ts/guards" 2 | import type { ConfigRoutes } from "@src/core/config/types" 3 | 4 | export function isConfigRoutes( 5 | configRoutes: unknown, 6 | ): configRoutes is ConfigRoutes { 7 | if (!isObject(configRoutes)) return false 8 | 9 | for (const value of Object.values(configRoutes)) { 10 | if (!isObject(value)) return false 11 | for (const routes of Object.values(value)) { 12 | if (typeof routes !== "string") return false 13 | } 14 | } 15 | 16 | return true 17 | } 18 | -------------------------------------------------------------------------------- /astro-i18n/src/core/translation/errors/formatters/invalid-formatter-value.error copy.ts: -------------------------------------------------------------------------------- 1 | class InvalidFormatterValue extends TypeError { 2 | constructor(message?: string, formatter?: string) { 3 | if (message) { 4 | super( 5 | formatter 6 | ? `Invalid formatter (${formatter}) value: ${message}` 7 | : `Invalid formatter value: ${message}`, 8 | ) 9 | } else { 10 | super( 11 | formatter 12 | ? `Invalid formatter (${formatter}) value.` 13 | : "Invalid formatter value.", 14 | ) 15 | } 16 | } 17 | } 18 | 19 | export default InvalidFormatterValue 20 | -------------------------------------------------------------------------------- /astro-i18n/src/core/translation/errors/formatters/invalid-formatter-param.error.ts: -------------------------------------------------------------------------------- 1 | class InvalidFormatterParam extends TypeError { 2 | constructor(message?: string, formatter?: string) { 3 | if (message) { 4 | super( 5 | formatter 6 | ? `Invalid formatter (${formatter}) parameter: ${message}` 7 | : `Invalid formatter parameter: ${message}`, 8 | ) 9 | } else { 10 | super( 11 | formatter 12 | ? `Invalid formatter (${formatter}) parameter.` 13 | : "Invalid formatter parameter.", 14 | ) 15 | } 16 | } 17 | } 18 | 19 | export default InvalidFormatterParam 20 | -------------------------------------------------------------------------------- /apps/astro/src/pages/product/[id]/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Layout from "../../../layouts/Layout.astro" 3 | import { createGetStaticPaths } from "astro-i18n" 4 | 5 | export const getStaticPaths = createGetStaticPaths(() => { 6 | return [ 7 | { 8 | params: { 9 | id: 123, 10 | }, 11 | }, 12 | ] 13 | }) 14 | --- 15 | 16 | 17 |
/product/[id]
18 |
19 | 20 | 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: For feature requests use the discussions tab. 4 | title: '' 5 | labels: '' 6 | assignees: Alexandre-Fernandez 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Mandatory reproduction repository** 24 | Add a git repository to reproduce the bug. 25 | -------------------------------------------------------------------------------- /astro-i18n/src/core/translation/guards/primitive.guard.ts: -------------------------------------------------------------------------------- 1 | import type { Primitive } from "@src/core/translation/types" 2 | 3 | export function isPrimitive(primitive: unknown): primitive is Primitive { 4 | return ( 5 | primitive === null || 6 | typeof primitive === "undefined" || 7 | typeof primitive === "boolean" || 8 | typeof primitive === "number" || 9 | typeof primitive === "string" 10 | ) 11 | } 12 | 13 | export function isPrimitiveArray( 14 | primitives: unknown, 15 | ): primitives is Primitive[] { 16 | if (!Array.isArray(primitives)) return false 17 | return primitives.every((primitive) => isPrimitive(primitive)) 18 | } 19 | -------------------------------------------------------------------------------- /astro-i18n/src/core/page/errors/invalid-translation-file-pattern.error.ts: -------------------------------------------------------------------------------- 1 | import { Regex } from "@lib/regex" 2 | 3 | class InvalidTranslationFilePattern extends Error { 4 | constructor(pattern?: Regex) { 5 | super( 6 | pattern 7 | ? `Invalid pattern (${pattern.source}) given, the pattern should match the route name at index 1 (optional), the route locale at index 2 and the translated name at index 3 (optional).` 8 | : "Invalid pattern given, the pattern should match the route name at index 1 (optional), the route locale at index 2 and the translated name at index 3 (optional).", 9 | ) 10 | } 11 | } 12 | 13 | export default InvalidTranslationFilePattern 14 | -------------------------------------------------------------------------------- /astro-i18n/src/core/page/constants/page-patterns.constants.ts: -------------------------------------------------------------------------------- 1 | import { Regex } from "@lib/regex" 2 | 3 | export const ASTRO_COMPONENT_ROUTE_NAME_PATTERN = new Regex( 4 | /(\/[^\s/]+)?(\/[^\s/]+)\.astro$/, 5 | ) 6 | 7 | export const FRONTMATTER_PATTERN = new Regex(/^---\n([\S\s]+)\n---\n/) 8 | 9 | export const PRERENDER_EXPORT_PATTERN = new Regex( 10 | /export\s*(?:const\s+prerender|var\s+prerender|let\s+prerender|prerender)(?:\s*=\s*)?(true|false)?|export\s*?{\s*?prerender\s*?}/, 11 | ) 12 | 13 | export const GET_STATIC_PATHS_EXPORT_PATTERN = new Regex( 14 | /export\s+(?:async\s+)?(?:function\s+|const\s+|var\s+|let\s+)getStaticPaths|export\s*{\s*getStaticPaths\s*}/, 15 | ) 16 | -------------------------------------------------------------------------------- /astro-i18n/src/core/cli/constants/cli-patterns.constants.ts: -------------------------------------------------------------------------------- 1 | import { Regex } from "@lib/regex" 2 | import { PACKAGE_NAME } from "@src/constants/meta.constants" 3 | 4 | export const GENERATED_TYPES_PATTERN = Regex.fromString( 5 | `\\/{2}\\s+###>\\s+${PACKAGE_NAME}\\/type-generation\\s+###\\n([\\s\\S]*)\\/{2}\\s+###<\\s+${PACKAGE_NAME}\\/type-generation ###`, 6 | ) 7 | 8 | export const TRANSLATION_FUNCTION_PATTERN = new Regex( 9 | /t\s*\(\s*["'`](#[^#]+#)?([\S\s]*?)["'`]\s*[),]/g, 10 | ) 11 | 12 | export const MIDDLEWARE_FILENAME_PATTERN = new Regex( 13 | /middleware\.(?:ts|js|cjs|mjs)$/, 14 | ) 15 | 16 | export const INDEX_FILENAME_PATTERN = new Regex(/index\.(?:ts|js|cjs|mjs)$/) 17 | -------------------------------------------------------------------------------- /astro-i18n/bin/build.cjs: -------------------------------------------------------------------------------- 1 | const esbuild = require("esbuild") 2 | 3 | // core 4 | esbuild.build({ 5 | entryPoints: ["src/index.ts"], 6 | bundle: true, 7 | minify: true, 8 | external: ["esbuild"], 9 | outdir: "dist/src", 10 | platform: "node", 11 | target: "node18", 12 | format: "esm", 13 | sourcemap: false, 14 | sourcesContent: false, 15 | }) 16 | 17 | // cli 18 | esbuild.build({ 19 | entryPoints: ["src/bin.ts"], 20 | bundle: true, 21 | minify: true, 22 | external: ["esbuild"], 23 | outdir: "dist/src", 24 | platform: "node", 25 | target: "node18", 26 | format: "cjs", 27 | outExtension: { 28 | ".js": ".cjs", 29 | }, 30 | sourcemap: false, 31 | sourcesContent: false, 32 | }) 33 | -------------------------------------------------------------------------------- /apps/astro/public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 10 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [alexandre-fernandez] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /astro-i18n/src/core/routing/guards/segment-translations.guard.ts: -------------------------------------------------------------------------------- 1 | import { isObject } from "@lib/ts/guards" 2 | import type { SegmentTranslations } from "@src/core/routing/types" 3 | 4 | export function isSegmentTranslations( 5 | segmentTranslations: unknown, 6 | ): segmentTranslations is SegmentTranslations { 7 | if (!isObject(segmentTranslations)) return false 8 | 9 | for (const untranslatedSegments of Object.values(segmentTranslations)) { 10 | if (!isObject(untranslatedSegments)) return false 11 | 12 | for (const otherLocales of Object.values(untranslatedSegments)) { 13 | if (!isObject(otherLocales)) return false 14 | 15 | for (const translation of Object.values(otherLocales)) { 16 | if (typeof translation !== "string") return false 17 | } 18 | } 19 | } 20 | 21 | return true 22 | } 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | node_modules 3 | .pnp 4 | .pnp.js 5 | 6 | # testing 7 | coverage 8 | 9 | # next.js 10 | .next/ 11 | out/ 12 | 13 | # logs 14 | logs 15 | *.log 16 | npm-debug.log* 17 | yarn-debug.log* 18 | yarn-error.log* 19 | pnpm-debug.log* 20 | lerna-debug.log* 21 | 22 | # local env files 23 | .env 24 | .env.local 25 | .env.development.local 26 | .env.test.local 27 | .env.production.local 28 | .env.production 29 | *.local 30 | 31 | # turbo 32 | .turbo 33 | 34 | # vercel 35 | .vercel 36 | 37 | # editor directories and files 38 | .vscode/* 39 | !.vscode/extensions.json 40 | .idea 41 | .DS_Store 42 | *.suo 43 | *.ntvs* 44 | *.njsproj 45 | *.sln 46 | *.sw? 47 | *.pem 48 | 49 | # build 50 | dist 51 | build 52 | 53 | # astro 54 | .astro/ 55 | 56 | # backup 57 | *.bk 58 | 59 | # apps 60 | apps/* 61 | !apps/astro/* -------------------------------------------------------------------------------- /astro-i18n/src/core/translation/guards/deep-string-record.guard.ts: -------------------------------------------------------------------------------- 1 | import { isObject } from "@lib/ts/guards" 2 | import type { DeepStringRecord } from "@src/core/translation/types" 3 | 4 | export function isDeepStringRecord( 5 | deepStringRecord: unknown, 6 | root = true, 7 | ): deepStringRecord is DeepStringRecord { 8 | if (root) { 9 | // Record 10 | if (!isObject(deepStringRecord)) return false 11 | for (const value of Object.values(deepStringRecord)) { 12 | if (!isDeepStringRecord(value, false)) return false 13 | } 14 | return true 15 | } 16 | // string | Record 17 | if (typeof deepStringRecord === "string") return true 18 | if (!isObject(deepStringRecord)) return false 19 | for (const value of Object.values(deepStringRecord)) { 20 | if (!isDeepStringRecord(value, false)) return false 21 | } 22 | return true 23 | } 24 | -------------------------------------------------------------------------------- /apps/astro/src/pages/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import ReactComponent from "../components/ReactComponent" 3 | import Layout from "../layouts/Layout.astro" 4 | 5 | const data = Astro.locals 6 | --- 7 | 8 | 9 |
10 |
11 |

Astro.locals:

12 | 13 |

{JSON.stringify(data)}

14 |
15 | 16 |
17 |

Server singleton

18 | 19 |

azeazea

20 |
21 | 22 |
23 |

React client:idle

24 | 25 | 26 |
27 | 28 |
29 |

React client:only

30 | 31 | 32 |
33 |
34 |
35 | 36 | 37 | 38 | 49 | -------------------------------------------------------------------------------- /apps/astro/src/pages/test.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import ReactComponent from "../components/ReactComponent" 3 | import Layout from "../layouts/Layout.astro" 4 | 5 | const data = Astro.locals 6 | --- 7 | 8 | 9 |
10 |
11 |

Astro.locals:

12 | 13 |

{JSON.stringify(data)}

14 |
15 | 16 |
17 |

Server singleton

18 | 19 |

azeazea

20 |
21 | 22 |
23 |

React client:idle

24 | 25 | 26 |
27 | 28 |
29 |

React client:only

30 | 31 | 32 |
33 |
34 |
35 | 36 | 37 | 38 | 49 | -------------------------------------------------------------------------------- /astro-i18n/src/core/translation/constants/translation-patterns.constants.ts: -------------------------------------------------------------------------------- 1 | import { Regex, RegexBuilder } from "@lib/regex" 2 | 3 | export const FIRST_VARCHAR_PATTERN = new Regex(/[$A-Z_a-z]/) 4 | 5 | export const VARNAME_PATTERN = RegexBuilder.fromRegex(FIRST_VARCHAR_PATTERN) 6 | .appendPattern("[\\w$]*") 7 | .build() 8 | 9 | export const NUMBER_PATTERN = new Regex(/-?\d+(?:\.\d+)?/) 10 | 11 | export const EMPTY_PATTERN = new Regex(/^\s*$/) 12 | 13 | export const VARIANT_PATTERN = new Regex(/{{(.+)}}/) 14 | 15 | export const INTERPOLATION_PATTERN = new Regex(/{#(.+?)#}/) 16 | 17 | export const INTERPOLATION_ALIAS_PATTERN = Regex.fromString( 18 | `\\(\\s*(${VARNAME_PATTERN.source})\\s*\\)`, 19 | ) 20 | 21 | export const INTERPOLATION_ARGUMENTLESS_FORMATTER_PATTERN = Regex.fromString( 22 | `>\\s*(${VARNAME_PATTERN.source})\\s*(\\()?`, 23 | ) 24 | 25 | export const FUNCTION_PATTERN = new Regex( 26 | /(?:function)?\s*[\w$]*\s*\(([\S\s]*?)\)\s*(?:=>)?\s*{?([\S\s]*)/, 27 | ) 28 | -------------------------------------------------------------------------------- /astro-i18n/src/core/translation/guards/serialized-formatters.guard.ts: -------------------------------------------------------------------------------- 1 | import { isObject, isStringArray } from "@lib/ts/guards" 2 | import type { SerializedFormatters } from "@src/core/translation/types" 3 | 4 | export function isSerializedFormatters( 5 | serializedFormatters: unknown, 6 | ): serializedFormatters is SerializedFormatters { 7 | if (!isObject(serializedFormatters)) return false 8 | 9 | for (const serializedFormatter of Object.values(serializedFormatters)) { 10 | if (!isObject(serializedFormatter)) return false 11 | 12 | const entries = Object.entries(serializedFormatter) 13 | if (entries.length < 2) return false 14 | 15 | for (const [key, value] of entries) { 16 | switch (key) { 17 | case "args": { 18 | if (!isStringArray(value)) return false 19 | break 20 | } 21 | case "body": { 22 | if (typeof value !== "string") return false 23 | break 24 | } 25 | default: { 26 | break 27 | } 28 | } 29 | } 30 | } 31 | 32 | return true 33 | } 34 | -------------------------------------------------------------------------------- /astro-i18n/lib/async-node/classes/async-node.class.ts: -------------------------------------------------------------------------------- 1 | import type { AsyncNodeJsCache } from "@lib/async-node/types" 2 | 3 | class AsyncNode { 4 | static #cache: Partial = {} 5 | 6 | static get path() { 7 | return this.#getModule("path") 8 | } 9 | 10 | static get posix() { 11 | // eslint-disable-next-line github/no-then 12 | return this.path.then(({ posix }) => posix) 13 | } 14 | 15 | static get fs() { 16 | return this.#getModule("fs") 17 | } 18 | 19 | static get url() { 20 | return this.#getModule("url") 21 | } 22 | 23 | static get module() { 24 | return this.#getModule("module") 25 | } 26 | 27 | static async #getModule( 28 | name: T, 29 | ): Promise { 30 | if (this.#cache[name]) return this.#cache[name]! 31 | 32 | const module: AsyncNodeJsCache[T] = await import( 33 | `node:${name}` /* @vite-ignore */ 34 | ) 35 | this.#cache[name] = module 36 | 37 | return module 38 | } 39 | } 40 | 41 | export default AsyncNode 42 | -------------------------------------------------------------------------------- /astro-i18n/src/index.ts: -------------------------------------------------------------------------------- 1 | import { astroI18n } from "@src/core/state/singletons/astro-i18n.singleton" 2 | /* 3 | 4 | 5 | 6 | ###> astro-i18n/exports ### */ 7 | export { defineAstroI18nConfig } from "@src/core/config/functions/config.functions" 8 | 9 | export { useAstroI18n } from "@src/core/astro/middleware" 10 | 11 | export { astroI18n } from "@src/core/state/singletons/astro-i18n.singleton" 12 | 13 | export { createGetStaticPaths } from "@src/core/page/functions/frontmatter.functions" 14 | 15 | export const t = astroI18n.t.bind(astroI18n) 16 | 17 | export const l = astroI18n.l.bind(astroI18n) 18 | /* ###< astro-i18n/exports ### 19 | 20 | 21 | 22 | ###> astro-i18n/types ### */ 23 | export type { 24 | AstroI18nConfig, 25 | ConfigTranslations as Translations, 26 | ConfigTranslationLoadingRules as TranslationLoadingRules, 27 | ConfigRoutes as SegmentTranslations, 28 | } from "@src/core/config/types" 29 | 30 | export type { Formatters as TranslationFormatters } from "@src/core/translation/types" 31 | /* ###< astro-i18n/types ### */ 32 | -------------------------------------------------------------------------------- /astro-i18n/src/bin.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { parseArgv } from "@lib/argv" 3 | import InvalidCommand from "@src/core/cli/errors/invalid-command.error" 4 | import generatePagesCommand, { 5 | generatePages, 6 | } from "@src/core/cli/commands/generate-pages.command" 7 | import generateTypesCommand, { 8 | generateTypes, 9 | } from "@src/core/cli/commands/generate-types.command" 10 | import extractCommand, { 11 | extract, 12 | } from "@src/core/cli/commands/extract-keys.command" 13 | import installCommand, { install } from "@src/core/cli/commands/install.command" 14 | 15 | const argv = parseArgv([ 16 | generatePagesCommand, 17 | generateTypesCommand, 18 | extractCommand, 19 | installCommand, 20 | ]) 21 | 22 | const cli = { 23 | [generatePagesCommand.name]: generatePages, 24 | [generateTypesCommand.name]: generateTypes, 25 | [extractCommand.name]: extract, 26 | [installCommand.name]: install, 27 | } 28 | 29 | if (!argv.command || !(cli as any)[argv.command]) { 30 | throw new InvalidCommand() 31 | } 32 | 33 | ;(cli as any)[argv.command](argv) 34 | -------------------------------------------------------------------------------- /astro-i18n/bin/pre-package.cjs: -------------------------------------------------------------------------------- 1 | const { 2 | readFileSync, 3 | existsSync, 4 | lstatSync, 5 | copyFileSync, 6 | writeFileSync, 7 | } = require("fs") 8 | const { join } = require("path") 9 | 10 | const ASTRO_I18N_DIR = join(__dirname, "..") 11 | const PACKAGE_JSON_PATH = join(ASTRO_I18N_DIR, "package.json") 12 | const BUILD_SRC_DIR = join("dist", "src") 13 | 14 | if (!existsSync(PACKAGE_JSON_PATH) || !lstatSync(PACKAGE_JSON_PATH).isFile()) { 15 | throw new Error("Missing `package.json`.") 16 | } 17 | 18 | // get package.json 19 | const packageJson = JSON.parse( 20 | readFileSync(PACKAGE_JSON_PATH, { encoding: "utf8" }), 21 | ) 22 | 23 | // backup 24 | copyFileSync(PACKAGE_JSON_PATH, `${PACKAGE_JSON_PATH}.bk`) 25 | 26 | // update to build path 27 | packageJson.main = "./" + join(BUILD_SRC_DIR, `index.js`) 28 | packageJson.types = "./" + join(BUILD_SRC_DIR, `index.d.ts`) 29 | packageJson.exports["."].import = "./" + join(BUILD_SRC_DIR, `index.js`) 30 | packageJson.exports["."].types = "./" + join(BUILD_SRC_DIR, `index.d.ts`) 31 | 32 | writeFileSync(PACKAGE_JSON_PATH, JSON.stringify(packageJson, null, "\t"), { 33 | encoding: "utf8", 34 | }) 35 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Alexandre Fernandez 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 | -------------------------------------------------------------------------------- /astro-i18n/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Alexandre Fernandez 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. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "astro-i18n-monorepo", 3 | "private": true, 4 | "scripts": { 5 | "dev": "turbo run dev", 6 | "test": "turbo run test", 7 | "build": "turbo run build", 8 | "astro-i18n:build": "turbo run build --filter=astro-i18n", 9 | "lint": "turbo run lint", 10 | "format": "prettier --write \"**/*.{ts,tsx,md}\"", 11 | "i18n:generate:pages": "turbo run i18n:generate:pages", 12 | "i18n:generate:types": "turbo run i18n:generate:types", 13 | "i18n:extract": "turbo run i18n:extract", 14 | "i18n:install": "turbo run i18n:install", 15 | "npm:publish:patch": "turbo run npm:publish:patch --filter=astro-i18n", 16 | "npm:publish:minor": "turbo run npm:publish:minor --filter=astro-i18n", 17 | "npm:publish:major": "turbo run npm:publish:major --filter=astro-i18n", 18 | "npm:pack": "turbo run npm:pack --filter=astro-i18n" 19 | }, 20 | "devDependencies": { 21 | "af-prettierrc": "latest", 22 | "eslint": "^7.32.0", 23 | "eslint-config-af-typescript": "latest", 24 | "prettier": "^2.8.8", 25 | "turbo": "^1.10.13", 26 | "typescript": "^5.2.2" 27 | }, 28 | "packageManager": "pnpm@7.15.0", 29 | "engines": { 30 | "node": ">=18.0.0" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /astro-i18n/lib/regex/classes/regex-builder.class.ts: -------------------------------------------------------------------------------- 1 | import Regex from "@lib/regex/classes/regex.class" 2 | 3 | class RegexBuilder { 4 | #source: string 5 | 6 | #flags: string 7 | 8 | constructor({ source, flags }: RegExp) { 9 | this.#source = source 10 | this.#flags = flags 11 | } 12 | 13 | static fromRegex(regex: Regex) { 14 | return new RegexBuilder(regex.regexp) 15 | } 16 | 17 | appendPattern(pattern: string) { 18 | this.#source = `${this.#source}${pattern}` 19 | 20 | return this 21 | } 22 | 23 | matchTrimifiable() { 24 | this.#source = `\\s*${this.#source}\\s*` 25 | 26 | return this 27 | } 28 | 29 | assertStarting() { 30 | this.#source = `^${this.#source}` 31 | 32 | return this 33 | } 34 | 35 | assertEnding() { 36 | this.#source = `${this.#source}$` 37 | 38 | return this 39 | } 40 | 41 | addGroup() { 42 | this.#source = `(${this.#source})` 43 | 44 | return this 45 | } 46 | 47 | addGlobalFlag() { 48 | if (!this.#flags.includes("g")) { 49 | this.#flags = `${this.#flags}g` 50 | } 51 | 52 | return this 53 | } 54 | 55 | build() { 56 | return new Regex(new RegExp(this.#source, this.#flags)) 57 | } 58 | } 59 | 60 | export default RegexBuilder 61 | -------------------------------------------------------------------------------- /astro-i18n/src/core/routing/functions/index.ts: -------------------------------------------------------------------------------- 1 | import { Regex } from "@lib/regex" 2 | import { 3 | ROUTE_PARAM_PATTERN, 4 | URL_PATTERN, 5 | } from "@src/core/routing/constants/routing-patterns.constants" 6 | 7 | export function extractRouteParameters(route: string) { 8 | const parameters: string[] = [] 9 | ROUTE_PARAM_PATTERN.exec(route, ({ match }) => { 10 | if (!match[2]) return 11 | parameters.push(match[2]) 12 | }) 13 | return parameters 14 | } 15 | 16 | export function isUrl(url: string) { 17 | return URL_PATTERN.test(url) 18 | } 19 | 20 | /** 21 | * Converts a page route such as `"/posts/[id]"` to a `Regex` that can be used 22 | * to match routes. 23 | */ 24 | export function pageRouteToRegex(route: string) { 25 | let pattern = "" 26 | let lastIndex = 0 27 | ROUTE_PARAM_PATTERN.exec(route, ({ match, range: [start, end] }) => { 28 | pattern += route.slice(lastIndex, start) 29 | if (!match[2]) { 30 | lastIndex = end 31 | return 32 | } 33 | pattern += match[1] ? "[\\w/-]+" : "[\\w-]+" 34 | lastIndex = end 35 | }) 36 | pattern += route.slice(lastIndex) 37 | return Regex.fromString( 38 | pattern.replaceAll("/", "\\/"), // this will also escape the slash in "[\\w/-]+" 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "globalDependencies": ["**/.env.*local"], 4 | "pipeline": { 5 | "lint": {}, 6 | "test": {}, 7 | "dev": { 8 | "cache": false, 9 | "persistent": true 10 | }, 11 | "build": { 12 | "dependsOn": ["^build"], 13 | "outputs": ["dist/**", "!.next/cache/**"] 14 | }, 15 | "i18n:generate:pages": { 16 | "dependsOn": ["build"], 17 | "cache": false 18 | }, 19 | "i18n:generate:types": { 20 | "dependsOn": ["build"], 21 | "cache": false 22 | }, 23 | "i18n:extract": { 24 | "dependsOn": ["build"], 25 | "cache": false 26 | }, 27 | "i18n:install": { 28 | "dependsOn": ["build"], 29 | "cache": false 30 | }, 31 | "npm:publish": { 32 | "dependsOn": ["lint", "test", "build"], 33 | "cache": false 34 | }, 35 | "npm:publish:patch": { 36 | "dependsOn": ["lint", "test", "build"], 37 | "cache": false 38 | }, 39 | "npm:publish:minor": { 40 | "dependsOn": ["lint", "test", "build"], 41 | "cache": false 42 | }, 43 | "npm:publish:major": { 44 | "dependsOn": ["lint", "test", "build"], 45 | "cache": false 46 | }, 47 | "npm:pack": { 48 | "dependsOn": ["lint", "test", "build"], 49 | "cache": false 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /astro-i18n/src/core/page/functions/frontmatter.functions.ts: -------------------------------------------------------------------------------- 1 | import { astroI18n } from "@src/core/state/singletons/astro-i18n.singleton" 2 | import type { 3 | GetStaticPathsItem, 4 | GetStaticPathsProps, 5 | } from "@src/core/astro/types" 6 | 7 | /** 8 | * Workaround function to make astroI18n work inside getStaticPaths. 9 | * This is because Astro's getStaticPaths runs before everything which doesn't 10 | * allows astroI18n to update its state automatically. 11 | */ 12 | export function createGetStaticPaths( 13 | callback: ( 14 | props: GetStaticPathsProps, 15 | ) => GetStaticPathsItem[] | Promise, 16 | ) { 17 | return async ( 18 | props: GetStaticPathsProps & { 19 | astroI18n?: { 20 | locale: string 21 | } 22 | }, 23 | ) => { 24 | if (!astroI18n.isInitialized) { 25 | await astroI18n.internals.waitInitialization() 26 | } 27 | astroI18n.internals.setPrivateProperties({ 28 | isGetStaticPaths: true, 29 | locale: props.astroI18n 30 | ? props.astroI18n.locale 31 | : astroI18n.primaryLocale, 32 | route: "", 33 | // because getStaticPaths runs before the middleware and because the 34 | // runned code is bundled in a js chunk (import.meta.url won't work) 35 | // we cannot know the route 36 | }) 37 | return callback(props) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /astro-i18n/src/core/config/types/index.ts: -------------------------------------------------------------------------------- 1 | import type { DeepStringRecord } from "@src/core/translation/types" 2 | 3 | export interface AstroI18nConfig { 4 | primaryLocale: string 5 | 6 | secondaryLocales: string[] 7 | 8 | fallbackLocale: string 9 | 10 | showPrimaryLocale: boolean 11 | 12 | trailingSlash: "always" | "never" 13 | 14 | run: "server" | "client+server" 15 | 16 | translations: ConfigTranslations 17 | 18 | translationLoadingRules: ConfigTranslationLoadingRules 19 | 20 | translationDirectory: ConfigTranslationDirectory 21 | 22 | routes: ConfigRoutes 23 | 24 | srcDir: string 25 | } 26 | 27 | export type ConfigTranslationLoadingRules = { 28 | /** Regex patterns for matching groups to load. */ 29 | groups: string[] 30 | /** Regex patterns where groups will be loaded. */ 31 | routes: string[] 32 | }[] 33 | 34 | export type ConfigTranslationDirectory = { 35 | i18n?: string 36 | pages?: string 37 | } 38 | 39 | export type ConfigTranslations = { 40 | [group: string]: { 41 | [locale: string]: DeepStringRecord 42 | } 43 | } 44 | 45 | export type ConfigRoutes = { 46 | [secondaryLocale: string]: { 47 | [segment: string]: string 48 | } 49 | } 50 | 51 | export type SerializedConfig = { 52 | primaryLocale: string 53 | secondaryLocales: string[] 54 | showPrimaryLocale: boolean 55 | trailingSlash: "always" | "never" 56 | } 57 | -------------------------------------------------------------------------------- /apps/astro/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "astro", 3 | "private": true, 4 | "type": "module", 5 | "version": "0.0.1", 6 | "scripts": { 7 | "prepare": "astro telemetry disable", 8 | "dev": "astro dev", 9 | "start": "astro dev", 10 | "preview": "astro preview", 11 | "astro:build": "astro build", 12 | "astro": "astro", 13 | "test": "vitest", 14 | "i18n:install": "astro-i18n install", 15 | "i18n:extract": "astro-i18n extract", 16 | "i18n:generate:pages": "astro-i18n generate:pages --purge", 17 | "i18n:generate:types": "astro-i18n generate:types", 18 | "i18n:sync": "npm run i18n:generate:pages && npm run i18n:generate:types" 19 | }, 20 | "devDependencies": { 21 | "af-prettierrc": "latest", 22 | "af-tsconfig": "latest", 23 | "astro": "^3.0.7", 24 | "eslint": "^8.40.0", 25 | "eslint-config-af-typescript": "latest", 26 | "eslint-plugin-astro": "^0.27.0", 27 | "prettier": "^2.8.8", 28 | "prettier-plugin-astro": "^0.8.1", 29 | "typescript": "^5.2.2", 30 | "vite-tsconfig-paths": "^4.2.1", 31 | "vitest": "^0.34.5" 32 | }, 33 | "dependencies": { 34 | "@astrojs/react": "^2.2.1", 35 | "@types/react": "^18.0.21", 36 | "@types/react-dom": "^18.0.6", 37 | "astro-i18n": "workspace:*", 38 | "esbuild": "^0.17.18", 39 | "react": "^18.0.0", 40 | "react-dom": "^18.0.0" 41 | }, 42 | "peerDependencies": { 43 | "astro": "^2.5.0" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /astro-i18n/lib/async-node/functions/path.functions.ts: -------------------------------------------------------------------------------- 1 | import AsyncNode from "@lib/async-node/classes/async-node.class" 2 | 3 | export async function toPosixPath(path: string) { 4 | const { sep, posix } = await AsyncNode.path 5 | return path.split(sep).join(posix.sep) 6 | } 7 | 8 | export async function toWindowsPath(path: string) { 9 | const { sep, win32 } = await AsyncNode.path 10 | return path.split(sep).join(win32.sep) 11 | } 12 | 13 | export async function popPath(path: string) { 14 | const sep = await getPathSeparator(path) 15 | const segments = path.split(sep) 16 | segments.pop() 17 | return segments.join(sep) 18 | } 19 | 20 | export async function splitPath(path: string) { 21 | const sep = await getPathSeparator(path) 22 | return path.split(sep) 23 | } 24 | 25 | export function isRootPath(path: string) { 26 | switch (path) { 27 | case "": { 28 | return true 29 | } 30 | case "/": { 31 | return true 32 | } 33 | case "\\": { 34 | return true 35 | } 36 | default: { 37 | // path has no separators 38 | return !/[/\\]/.test(path) 39 | } 40 | } 41 | } 42 | 43 | async function getPathSeparator(path: string) { 44 | const { sep } = await AsyncNode.path 45 | const forward = path.split("/").length - 1 46 | const backward = path.split("\\").length - 1 47 | 48 | if (forward > backward) return "/" 49 | if (backward > forward) return "\\" 50 | return sep 51 | } 52 | -------------------------------------------------------------------------------- /astro-i18n/src/core/translation/functions/formatter.functions.ts: -------------------------------------------------------------------------------- 1 | import { RegexBuilder } from "@lib/regex" 2 | import { FUNCTION_PATTERN } from "@src/core/translation/constants/translation-patterns.constants" 3 | import type { 4 | Formatters, 5 | SerializedFormatter, 6 | SerializedFormatters, 7 | } from "@src/core/translation/types" 8 | 9 | const functionMatcher = RegexBuilder.fromRegex(FUNCTION_PATTERN) 10 | .assertStarting() 11 | .assertEnding() 12 | .build() 13 | .toMatcher() 14 | 15 | export function serializeFormatter(fn: Function) { 16 | const matched = functionMatcher(fn.toString()) 17 | if (!matched) return { args: [], body: "" } as SerializedFormatter 18 | const [, args, body] = matched.match 19 | return { 20 | args: parseArguments(args || ""), 21 | body: parseBody(body || ""), 22 | } as SerializedFormatter 23 | } 24 | 25 | export function deserializeFormatters(formatters: SerializedFormatters) { 26 | const deserialized: Formatters = {} 27 | 28 | for (const [name, formatter] of Object.entries(formatters)) { 29 | // eslint-disable-next-line no-new-func 30 | deserialized[name] = new Function( 31 | ...formatter.args, 32 | formatter.body, 33 | ) as (value: unknown, ...args: unknown[]) => unknown 34 | } 35 | 36 | return deserialized 37 | } 38 | 39 | function parseArguments(args: string) { 40 | return args 41 | .trim() 42 | .split(",") 43 | .map((arg) => arg.trim()) 44 | } 45 | 46 | function parseBody(body: string) { 47 | const trimmed = body.trim() 48 | return trimmed.endsWith("}") ? body.slice(0, -1) : body 49 | } 50 | -------------------------------------------------------------------------------- /astro-i18n/src/core/state/guards/serialized-astro-i18n.guard.ts: -------------------------------------------------------------------------------- 1 | import { isObject } from "@lib/ts/guards" 2 | import { isSerializedConfig } from "@src/core/config/guards/config.guard" 3 | import { isSegmentTranslations } from "@src/core/routing/guards/segment-translations.guard" 4 | import { isSerializedFormatters } from "@src/core/translation/guards/serialized-formatters.guard" 5 | import { isTranslationMap } from "@src/core/translation/guards/translation-map.guard" 6 | import type { SerializedAstroI18n } from "@src/core/state/types" 7 | 8 | export function isSerializedAstroI18n( 9 | serializedAstroI18n: unknown, 10 | ): serializedAstroI18n is SerializedAstroI18n { 11 | if (!isObject(serializedAstroI18n)) return false 12 | 13 | const entries = Object.entries(serializedAstroI18n) 14 | if (entries.length < 5) return false 15 | 16 | for (const [key, value] of entries) { 17 | switch (key) { 18 | case "locale": { 19 | if (typeof value !== "string") return false 20 | break 21 | } 22 | case "route": { 23 | if (typeof value !== "string") return false 24 | break 25 | } 26 | case "config": { 27 | if (!isSerializedConfig(value)) return false 28 | break 29 | } 30 | case "translations": { 31 | if (!isTranslationMap(value)) return false 32 | break 33 | } 34 | case "segments": { 35 | if (!isSegmentTranslations(value)) return false 36 | break 37 | } 38 | case "formatters": { 39 | if (!isSerializedFormatters(value)) return false 40 | break 41 | } 42 | default: { 43 | return false 44 | } 45 | } 46 | } 47 | 48 | return true 49 | } 50 | -------------------------------------------------------------------------------- /astro-i18n/src/core/translation/functions/parsing.functions.ts: -------------------------------------------------------------------------------- 1 | import { never } from "@lib/error" 2 | import { CALLBACK_BREAK } from "@src/constants/app.constants" 3 | 4 | /** 5 | * Traverses a `string` taking the object/array/string depth into account. 6 | * For example if you are inside curly brackets `depth` will be `1`. Expanding 7 | * on that example, if you additionally are inside array brackets `depth` will 8 | * be `2`, and so on. 9 | */ 10 | export function depthAwareforEach( 11 | string: string, 12 | callback: ( 13 | char: string, 14 | i: number, 15 | depth: number, 16 | isOpening: boolean, 17 | isClosing: boolean, 18 | ) => unknown, 19 | ) { 20 | let depth = 0 21 | let isString = false 22 | let quoteType = null as string | null 23 | // eslint-disable-next-line unicorn/no-for-loop 24 | for (let i = 0; i < string.length; i += 1) { 25 | const char = string[i] || never() 26 | const isQuote = char === '"' || char === "'" || char === "`" 27 | 28 | let isOpening = false 29 | let isClosing = false 30 | 31 | if (char === "{" || char === "[") { 32 | depth += 1 33 | isOpening = true 34 | } else if (char === "}" || char === "]") { 35 | depth -= 1 36 | isClosing = true 37 | } else if (!isString && isQuote) { 38 | depth += 1 39 | isString = true 40 | quoteType = char 41 | isOpening = true 42 | } else if (isString && char === quoteType) { 43 | depth -= 1 44 | isString = false 45 | quoteType = null 46 | isClosing = true 47 | } 48 | 49 | const isBreak = 50 | callback(char, i, depth, isOpening, isClosing) === CALLBACK_BREAK 51 | if (isBreak) break 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /astro-i18n/src/core/translation/classes/interpolation.class.ts: -------------------------------------------------------------------------------- 1 | import { matchInterpolation } from "@src/core/translation/functions/interpolation/interpolation-matching.functions" 2 | import { parseInterpolationValue } from "@src/core/translation/functions/interpolation/interpolation-parsing.functions" 3 | import type { 4 | TranslationProperties, 5 | Formatters, 6 | } from "@src/core/translation/types" 7 | 8 | /** 9 | * A translation interpolation. 10 | * It has 3 main parts, the value, the alias and the formatters. For example: 11 | * `"{# value(alias)>formatter #}"`, here the value would be a variable that 12 | * would get its value from the `"alias"` property in the user-provided 13 | * properties. 14 | * Interpolation can also be used as values, for example every value in an 15 | * object can be parsed as an interpolation, this allows for all values to be 16 | * variables, have formatters, aliases, etc... 17 | */ 18 | class Interpolation { 19 | raw: string 20 | 21 | value: unknown 22 | 23 | alias: string | null = null 24 | 25 | /** 26 | * @param interpolation A translation interpolation, for example for an 27 | * interpolation string such as `"{# prop1(alias)>formatter #}"` only 28 | * `"prop1(alias)>formatter"` should be passed. 29 | */ 30 | constructor( 31 | interpolation: string, 32 | properties: TranslationProperties, 33 | availableFormatters: Formatters, 34 | ) { 35 | const { raw, value, type, alias, formatters } = 36 | matchInterpolation(interpolation) 37 | 38 | this.raw = raw 39 | 40 | this.alias = alias 41 | 42 | this.value = parseInterpolationValue( 43 | value, 44 | alias, 45 | type, 46 | formatters, 47 | properties, 48 | availableFormatters, 49 | ) 50 | } 51 | } 52 | 53 | export default Interpolation 54 | -------------------------------------------------------------------------------- /astro-i18n/bin/version.cjs: -------------------------------------------------------------------------------- 1 | const { readFileSync, writeFileSync } = require("fs") 2 | const { join } = require("path") 3 | const { execSync } = require("child_process") 4 | 5 | if (!Array.isArray(process.argv)) throw new Error("Cannot parse argv.\n") 6 | 7 | /** @type {string} */ 8 | const mode = String(process.argv.slice(2)[0]).toUpperCase() 9 | 10 | const ASTRO_I18N_DIR = join(__dirname, "..") 11 | const PACKAGE_JSON_PATH = join(ASTRO_I18N_DIR, "package.json") 12 | 13 | /** @type {{ version: string }} */ 14 | const packageJson = JSON.parse( 15 | readFileSync(PACKAGE_JSON_PATH, { encoding: "utf8" }), 16 | ) 17 | if ( 18 | typeof packageJson !== "object" || 19 | typeof packageJson.version !== "string" 20 | ) { 21 | throw new Error("Invalid package.json.\n") 22 | } 23 | 24 | const [major, minor, patch] = packageJson.version 25 | .split(".") 26 | .map((version) => parseInt(version)) 27 | 28 | switch (mode) { 29 | case "PATCH": { 30 | update(major, minor, patch + 1) 31 | break 32 | } 33 | case "MINOR": { 34 | update(major, minor + 1, 0) 35 | break 36 | } 37 | case "MAJOR": { 38 | update(major + 1, 0, 0) 39 | break 40 | } 41 | default: { 42 | throw new Error( 43 | '`undefined` or invalid argument.\n\tValid values are: "patch", "minor" or "major" (case insensitive).\n', 44 | ) 45 | } 46 | } 47 | 48 | /** 49 | * @param {number} major 50 | * @param {number} minor 51 | * @param {number} patch 52 | */ 53 | function update(major, minor, patch) { 54 | const version = `${major}.${minor}.${patch}` 55 | packageJson.version = version 56 | writeFileSync( 57 | PACKAGE_JSON_PATH, 58 | `${JSON.stringify(packageJson, null, "\t")}\n`, 59 | ) 60 | execSync(`git add "${ASTRO_I18N_DIR}/package.json"`, { 61 | cwd: ASTRO_I18N_DIR, 62 | }) 63 | execSync(`git commit -m "${version}"`, { cwd: ASTRO_I18N_DIR }) 64 | } 65 | -------------------------------------------------------------------------------- /astro-i18n/lib/argv/index.ts: -------------------------------------------------------------------------------- 1 | import InvalidProcessArgv from "@lib/argv/error/invalid-process-argv.error" 2 | import NoArgumentsFound from "@lib/argv/error/no-arguments-found.error" 3 | import ProcessUndefined from "@lib/argv/error/process-undefined.error" 4 | import type { Command, ParsedArgv } from "@lib/argv/types" 5 | 6 | export function parseArgv(commands: Command[]) { 7 | if (!process || !process.argv) throw new ProcessUndefined() 8 | 9 | const [node, filename, ...params] = process.argv as string[] 10 | if (!node || !filename) throw new InvalidProcessArgv() 11 | if (params.length === 0) throw new NoArgumentsFound() 12 | 13 | const parsed: ParsedArgv = { 14 | node, 15 | filename, 16 | command: null as string | null, 17 | args: [] as string[], 18 | options: {} as { [name: string]: string | true }, 19 | } 20 | 21 | let isParsingOptions = false 22 | for (const [i, current] of params.entries()) { 23 | // command 24 | if (i === 0) { 25 | if (!commands.some((cmd) => cmd.name === current)) return parsed 26 | parsed.command = current 27 | continue 28 | } 29 | // options 30 | if (current.startsWith("-") || isParsingOptions) { 31 | if (!current.startsWith("--")) return parsed 32 | isParsingOptions = true 33 | 34 | const options = 35 | commands.find((cmd) => cmd.name === parsed.command)?.options || 36 | never() 37 | const cur = current.replace("--", "") 38 | 39 | const equalIndex = cur.indexOf("=") 40 | // no option value 41 | if (equalIndex < 0) { 42 | if (!options.includes(cur)) return parsed 43 | parsed.options[cur] = true 44 | continue 45 | } 46 | // parsing option value 47 | parsed.options[cur.slice(0, equalIndex)] = cur.slice(equalIndex + 1) 48 | continue 49 | } 50 | // arguments 51 | parsed.args.push(current) 52 | } 53 | 54 | return parsed 55 | } 56 | 57 | function never(): never { 58 | throw new Error("Unreachable code executed.") 59 | } 60 | -------------------------------------------------------------------------------- /astro-i18n/src/core/translation/types/index.ts: -------------------------------------------------------------------------------- 1 | import type { ExecResult } from "@lib/regex" 2 | import type Variant from "@src/core/translation/classes/variant.class" 3 | 4 | export type Match = ExecResult 5 | 6 | export type Matcher = (string: string) => Match | null 7 | 8 | export type FormatterMatch = { name: string; args: string[] } 9 | 10 | export type Formatter = (value: unknown, ...args: unknown[]) => unknown 11 | 12 | export type Formatters = Record 13 | 14 | export type VariantProperty = { 15 | name: string 16 | values: Primitive[] 17 | } 18 | 19 | export type Primitive = undefined | null | boolean | string | number 20 | 21 | export type DeepStringRecord = { 22 | [key: string]: string | DeepStringRecord 23 | } 24 | 25 | export type TranslationMap = { 26 | [group: string]: { 27 | [locale: string]: ComputedTranslations 28 | } 29 | } 30 | 31 | export type ComputedTranslations = { 32 | [key: string]: { 33 | default?: string 34 | variants: Variant[] 35 | } 36 | } 37 | 38 | export type LoadDirectives = { 39 | [route: string]: string[] 40 | } 41 | 42 | export type TranslationProperties = Record 43 | 44 | export type VariantProperties = { 45 | raw?: string 46 | priority?: number 47 | properties?: VariantProperty[] 48 | value?: string 49 | } 50 | 51 | export type SerializedTranslationMap = { 52 | [group: string]: { 53 | [locale: string]: SerializedComputedTranslations 54 | } 55 | } 56 | 57 | export type SerializedFormatters = { 58 | [name: string]: SerializedFormatter 59 | } 60 | 61 | export type SerializedFormatter = { 62 | args: string[] 63 | body: string 64 | } 65 | 66 | export type TranslationVariables = { 67 | interpolationVars: string[] 68 | variantVars: { name: string; values: Primitive[] }[] 69 | isVariantRequired: boolean 70 | } 71 | 72 | type SerializedComputedTranslations = { 73 | [key: string]: { 74 | default?: string 75 | variants: Required[] 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /astro-i18n/src/core/config/guards/config-translations.guard.ts: -------------------------------------------------------------------------------- 1 | import { isArray, isObject, isStringArray } from "@lib/ts/guards" 2 | import { isDeepStringRecord } from "@src/core/translation/guards/deep-string-record.guard" 3 | import type { 4 | ConfigTranslationDirectory, 5 | ConfigTranslationLoadingRules, 6 | ConfigTranslations, 7 | } from "@src/core/config/types" 8 | 9 | export function isConfigTranslations( 10 | configTranslations: unknown, 11 | ): configTranslations is ConfigTranslations { 12 | if (!isObject(configTranslations)) return false 13 | 14 | for (const value of Object.values(configTranslations)) { 15 | if (!isObject(value)) return false 16 | for (const translations of Object.values(value)) { 17 | if (!isDeepStringRecord(translations)) return false 18 | } 19 | } 20 | 21 | return true 22 | } 23 | 24 | export function isConfigTranslationLoadingRules( 25 | loadingRules: unknown, 26 | ): loadingRules is ConfigTranslationLoadingRules { 27 | if (!isArray(loadingRules)) return false 28 | 29 | for (const rule of loadingRules) { 30 | if (!isObject(rule)) return false 31 | const entries = Object.entries(rule) 32 | if (entries.length < 2) return false 33 | for (const [key, array] of entries) { 34 | switch (key) { 35 | case "groups": { 36 | if (!isStringArray(array)) return false 37 | break 38 | } 39 | case "routes": { 40 | if (!isStringArray(array)) return false 41 | break 42 | } 43 | default: { 44 | return false 45 | } 46 | } 47 | } 48 | } 49 | 50 | return true 51 | } 52 | 53 | export function isConfigTranslationDirectory( 54 | translationDirectory: unknown, 55 | ): translationDirectory is ConfigTranslationDirectory { 56 | if (!isObject(translationDirectory)) return false 57 | for (const [key, name] of Object.entries(translationDirectory)) { 58 | if (key !== "i18n" && key !== "pages") return false 59 | if (typeof name !== "string") return false 60 | } 61 | 62 | return true 63 | } 64 | -------------------------------------------------------------------------------- /astro-i18n/src/core/translation/functions/variant/variant-matching.functions.ts: -------------------------------------------------------------------------------- 1 | import { never } from "@lib/error" 2 | import { ValueType } from "@src/core/translation/enums/value-type.enum" 3 | import UntrimmedString from "@src/core/translation/errors/untrimmed-string.error" 4 | import InvalidVariantPropertyValue from "@src/core/translation/errors/variant/invalid-variant-property-value.error" 5 | import { 6 | matchArray, 7 | matchBoolean, 8 | matchNull, 9 | matchNumber, 10 | matchString, 11 | matchUndefined, 12 | } from "@src/core/translation/functions/matching.functions" 13 | 14 | /** 15 | * Matches a variant's property value. 16 | * @param value for example `"a string value"` 17 | */ 18 | export function matchVariantValue(value: string) { 19 | if (/^\s/.test(value)) throw new UntrimmedString(value) 20 | 21 | let matched = matchUndefined(value) 22 | if (matched) { 23 | return { 24 | value: matched.match[0] || never(), 25 | type: ValueType.UNDEFINED, 26 | end: matched.range[1], 27 | } 28 | } 29 | 30 | matched = matchNull(value) 31 | if (matched) { 32 | return { 33 | value: matched.match[0] || never(), 34 | type: ValueType.NULL, 35 | end: matched.range[1], 36 | } 37 | } 38 | 39 | matched = matchBoolean(value) 40 | if (matched) { 41 | return { 42 | value: matched.match[0] || never(), 43 | type: ValueType.BOOLEAN, 44 | end: matched.range[1], 45 | } 46 | } 47 | 48 | matched = matchNumber(value) 49 | if (matched) { 50 | return { 51 | value: matched.match[0] || never(), 52 | type: ValueType.NUMBER, 53 | end: matched.range[1], 54 | } 55 | } 56 | 57 | matched = matchString(value) 58 | if (matched) { 59 | return { 60 | value: matched.match[0] || never(), 61 | type: ValueType.STRING, 62 | end: matched.range[1], 63 | } 64 | } 65 | 66 | matched = matchArray(value) 67 | if (matched) { 68 | return { 69 | value: matched.match[0] || never(), 70 | type: ValueType.ARRAY, 71 | end: matched.range[1], 72 | } 73 | } 74 | 75 | throw new InvalidVariantPropertyValue(value) 76 | } 77 | -------------------------------------------------------------------------------- /apps/astro/tests/l.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable sonarjs/no-duplicate-string */ 2 | import { expect, test } from "vitest" 3 | import { astroI18n } from "astro-i18n" 4 | 5 | test("Show/hide primary locale.", () => { 6 | astroI18n.internals.config.showPrimaryLocale = true 7 | astroI18n.route = "/en" 8 | expect(astroI18n.l("/")).toBe("/en") 9 | astroI18n.route = "/fr" 10 | expect(astroI18n.l("/")).toBe("/fr") 11 | astroI18n.internals.config.showPrimaryLocale = false 12 | astroI18n.route = "/en" 13 | expect(astroI18n.l("/")).toBe("/") 14 | astroI18n.route = "/fr" 15 | expect(astroI18n.l("/")).toBe("/fr") 16 | }) 17 | 18 | test("Segment translations.", () => { 19 | astroI18n.route = "/" 20 | expect(astroI18n.l("/")).toBe("/") 21 | astroI18n.route = "/fr" 22 | expect(astroI18n.l("/")).toBe("/fr") 23 | astroI18n.route = "/group/inner" 24 | expect(astroI18n.l("/group/inner")).toBe("/group/inner") 25 | astroI18n.route = "/fr/groupe/interieur" 26 | expect(astroI18n.l("/group/inner")).toBe("/fr/groupe/interieur") 27 | }) 28 | 29 | test("Trailing slash.", () => { 30 | astroI18n.internals.config.trailingSlash = "always" 31 | astroI18n.route = "/" 32 | expect(astroI18n.l("/")).toBe("/") 33 | astroI18n.route = "/fr" 34 | expect(astroI18n.l("/")).toBe("/fr/") 35 | astroI18n.route = "/group/inner" 36 | expect(astroI18n.l("/group/inner")).toBe("/group/inner/") 37 | astroI18n.internals.config.trailingSlash = "never" 38 | astroI18n.route = "/" 39 | expect(astroI18n.l("/")).toBe("/") 40 | astroI18n.route = "/fr" 41 | expect(astroI18n.l("/")).toBe("/fr") 42 | astroI18n.route = "/group/inner" 43 | expect(astroI18n.l("/group/inner")).toBe("/group/inner") 44 | }) 45 | 46 | test("Locale detection.", () => { 47 | astroI18n.route = "/group" 48 | expect(astroI18n.l("/groupe/interieur")).toBe("/group/inner") 49 | astroI18n.route = "/interieur" 50 | expect(astroI18n.l("/groupe/interieur")).toBe("/fr/groupe/interieur") 51 | }) 52 | 53 | test("Route parameters.", () => { 54 | astroI18n.route = "/" 55 | expect( 56 | astroI18n.l("/[param1]/[param2]", { param1: "foo", param2: "bar" }), 57 | ).toBe("/foo/bar") 58 | }) 59 | -------------------------------------------------------------------------------- /astro-i18n/lib/async-node/functions/fs.functions.ts: -------------------------------------------------------------------------------- 1 | import AsyncNode from "@lib/async-node/classes/async-node.class" 2 | import InvalidPath from "@lib/async-node/errors/invalid-path.error" 3 | import { 4 | splitPath, 5 | toPosixPath, 6 | } from "@lib/async-node/functions/path.functions" 7 | 8 | export async function isDirectory(path: string) { 9 | if (!path) return false 10 | const { existsSync, lstatSync } = await AsyncNode.fs 11 | return existsSync(path) && lstatSync(path).isDirectory() 12 | } 13 | 14 | export async function isFile(path: string) { 15 | if (!path) return false 16 | const { existsSync, lstatSync } = await AsyncNode.fs 17 | return existsSync(path) && !lstatSync(path).isDirectory() 18 | } 19 | 20 | export async function canRead(path: string) { 21 | const { accessSync, constants } = await AsyncNode.fs 22 | try { 23 | accessSync(path, constants.R_OK) 24 | return true 25 | } catch (_) { 26 | return false 27 | } 28 | } 29 | 30 | export async function forEachDirectory( 31 | startingDirectory: string, 32 | callback: (directory: string, contents: string[]) => unknown, 33 | ) { 34 | const { readdirSync } = await AsyncNode.fs 35 | 36 | const contents = readdirSync(startingDirectory) 37 | 38 | await callback(await toPosixPath(startingDirectory), contents) 39 | 40 | for (const content of contents) { 41 | const path = `${startingDirectory}/${content}` 42 | if (!(await isDirectory(path))) continue 43 | await forEachDirectory(path, callback) 44 | } 45 | } 46 | 47 | export async function writeNestedFile(path: string, data: string) { 48 | const { sep, join } = await AsyncNode.path 49 | const { writeFileSync, mkdirSync } = await AsyncNode.fs 50 | 51 | const segments = await splitPath(path) 52 | 53 | const file = segments.pop() 54 | if (!file) throw new InvalidPath() 55 | const directory = segments.join(sep) 56 | 57 | mkdirSync(directory, { recursive: true }) 58 | writeFileSync(join(directory, file), data, { encoding: "utf8" }) 59 | } 60 | 61 | export async function removeDirectory(path: string) { 62 | const { rmSync } = await AsyncNode.fs 63 | rmSync(path, { recursive: true, force: true }) 64 | } 65 | -------------------------------------------------------------------------------- /astro-i18n/lib/object/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Merges `object` into `base` 3 | * @param options.mode `"fill"` merges when the property doesn't exist, 4 | * `"replace"` replaces duplicate properties, default `"replace"`. 5 | * @param options.mutable `base` will be modified when `true`, otherwise the 6 | * modifications will happen on a deep clone, default `true`. 7 | * @returns `undefined` unless when `options.mutable` is set to true. 8 | */ 9 | export function merge( 10 | base: Record, 11 | object: Record, 12 | options?: Partial<{ 13 | mode: "fill" | "replace" 14 | mutable: boolean 15 | }>, 16 | ) { 17 | const { mode, mutable }: NonNullable = { 18 | mode: "replace", 19 | mutable: true, 20 | ...options, 21 | } 22 | const merged = mutable ? base : structuredClone(base) 23 | 24 | for (const [key, objectValue] of Object.entries(object)) { 25 | const baseValue = merged[key] 26 | if (Object.hasOwn(merged, key)) { 27 | if (isRecord(baseValue) && isRecord(objectValue)) { 28 | merge(baseValue, objectValue) 29 | } else if (mode === "replace") { 30 | // only replace when both are not objects 31 | merged[key] = object[key] 32 | } 33 | continue 34 | } 35 | merged[key] = object[key] 36 | } 37 | 38 | return mutable ? undefined : merged 39 | } 40 | 41 | /** 42 | * Sets a property on an object. The property can be nested, if duplicate keys 43 | * are found they will be overriden. 44 | */ 45 | export function setObjectProperty( 46 | obj: Record, 47 | key: string | string[], 48 | value: unknown, 49 | ) { 50 | const keys = typeof key === "string" ? [key] : key 51 | 52 | let prev = obj 53 | for (const [i, key] of keys.entries()) { 54 | // isLastKey 55 | if (i === keys.length - 1) { 56 | prev[key] = value 57 | break 58 | } 59 | 60 | if (!isRecord(prev[key])) prev[key] = {} 61 | 62 | prev = prev[key] as Record 63 | } 64 | } 65 | 66 | function isRecord(record: unknown): record is Record { 67 | if (typeof record !== "object") return false 68 | return Object.getPrototypeOf(record) === Object.prototype 69 | } 70 | -------------------------------------------------------------------------------- /astro-i18n/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "astro-i18n", 3 | "version": "2.2.4", 4 | "author": "alexandre-fernandez", 5 | "description": "A TypeScript-first internationalization library for Astro.", 6 | "keywords": [ 7 | "astro", 8 | "i18n", 9 | "l10n", 10 | "internationalization", 11 | "localization", 12 | "typescript", 13 | "astro-component", 14 | "seo", 15 | "accessibility" 16 | ], 17 | "license": "MIT", 18 | "homepage": "https://github.com/alexandre-fernandez/astro-i18n", 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/alexandre-fernandez/astro-i18n" 22 | }, 23 | "main": "./src/index.ts", 24 | "bin": "./dist/src/bin.cjs", 25 | "types": "./src/index.d.ts", 26 | "files": [ 27 | "dist" 28 | ], 29 | "exports": { 30 | ".": { 31 | "import": "./src/index.ts", 32 | "types": "./src/index.d.ts" 33 | } 34 | }, 35 | "scripts": { 36 | "prepare": "npm run build", 37 | "test": "../apps/astro/node_modules/.bin/vitest run --root ../apps/astro", 38 | "build": "tsc --project tsconfig.json && tsc-alias -p tsconfig.json && node bin/pre-build.cjs && node bin/build.cjs", 39 | "lint": "eslint --ignore-path ../.eslintignore .", 40 | "npm:publish": "node bin/pre-package.cjs && npm publish ; node bin/post-package.cjs", 41 | "npm:publish:patch": "node bin/version.cjs patch && npm run npm:publish", 42 | "npm:publish:minor": "node bin/version.cjs minor && npm run npm:publish", 43 | "npm:publish:major": "node bin/version.cjs major && npm run npm:publish", 44 | "npm:pack": "node bin/pre-package.cjs && npm pack --pack-destination=.. ; node bin/post-package.cjs" 45 | }, 46 | "devDependencies": { 47 | "@types/node": "^20.1.4", 48 | "af-prettierrc": "latest", 49 | "af-tsconfig": "latest", 50 | "astro": "^2.7.2", 51 | "cpy-cli": "^5.0.0", 52 | "esbuild": "^0.17.19", 53 | "eslint": "^8.40.0", 54 | "eslint-config-af-typescript": "latest", 55 | "tsc-alias": "^1.8.6", 56 | "typescript": "^5.0.4" 57 | }, 58 | "peerDependencies": { 59 | "esbuild": "0.x" 60 | }, 61 | "contributors": [ 62 | { 63 | "name": "Alexandre Fernandez", 64 | "url": "https://github.com/alexandre-fernandez" 65 | } 66 | ] 67 | } 68 | -------------------------------------------------------------------------------- /astro-i18n/lib/ts/guards/index.ts: -------------------------------------------------------------------------------- 1 | export function assert( 2 | value: unknown, 3 | guard: (value: unknown) => value is T, 4 | expectedType?: string, 5 | ): asserts value is T { 6 | if (!guard(value)) { 7 | let valueAsString = "" 8 | 9 | if (value) { 10 | if (typeof value === "object") { 11 | valueAsString = `\n${value.constructor.name}\n${JSON.stringify( 12 | value, 13 | null, 14 | 4, 15 | )}` 16 | } else if (typeof value === "symbol") { 17 | valueAsString = `Symbol("${value.description}")` 18 | } else { 19 | valueAsString = 20 | typeof value === "string" ? `"${value}"` : `${value}` 21 | } 22 | } else { 23 | valueAsString = `${value}` 24 | } 25 | 26 | if (!expectedType && guard.name.startsWith("is")) { 27 | expectedType = guard.name.slice(2) 28 | } 29 | 30 | throw new TypeError( 31 | expectedType 32 | ? `Unexpected type (expecting \`${expectedType}\`), found: ${valueAsString}` 33 | : `Unexpected type, found: ${valueAsString}`, 34 | ) 35 | } 36 | } 37 | 38 | export function isNumber(number: unknown): number is number { 39 | return typeof number === "number" && !Number.isNaN(number) 40 | } 41 | 42 | export function isDate(date: unknown): date is Date { 43 | return date instanceof Date && !Number.isNaN(date.getTime()) 44 | } 45 | 46 | export function isArray(array: unknown): array is unknown[] { 47 | return Array.isArray(array) 48 | } 49 | 50 | export function isStringArray(array: unknown): array is string[] { 51 | return isArray(array) && array.every((item) => typeof item === "string") 52 | } 53 | 54 | export function isObject(object: unknown): object is object { 55 | return !!object && typeof object === "object" 56 | } 57 | 58 | export function isRecord(record: unknown): record is Record { 59 | if (!isObject(record)) return false 60 | return Object.getPrototypeOf(record) === Object.prototype 61 | } 62 | 63 | export function isStringRecord( 64 | record: unknown, 65 | ): record is Record { 66 | if (!isRecord(record)) return false 67 | for (const value of Object.values(record)) { 68 | if (typeof value !== "string") return false 69 | } 70 | return true 71 | } 72 | -------------------------------------------------------------------------------- /astro-i18n/lib/regex/classes/regex.class.ts: -------------------------------------------------------------------------------- 1 | import type { ExecResult } from "@lib/regex/types" 2 | 3 | class Regex { 4 | static readonly BREAK = "break" 5 | 6 | regexp: RegExp 7 | 8 | constructor(regexp: RegExp) { 9 | this.regexp = new RegExp(regexp.source, regexp.flags) 10 | } 11 | 12 | static fromString(source: string, flags?: string) { 13 | return new Regex(new RegExp(source, flags)) 14 | } 15 | 16 | get source() { 17 | return this.regexp.source 18 | } 19 | 20 | get flags() { 21 | return this.regexp.flags 22 | } 23 | 24 | add(regexp: RegExp) { 25 | this.regexp = new RegExp( 26 | `${this.regexp.source}${regexp.source}`, 27 | this.regexp.flags, 28 | ) 29 | 30 | return this 31 | } 32 | 33 | test(string: string) { 34 | return this.regexp.test(string) 35 | } 36 | 37 | exec(string: string, callback: (match: ExecResult) => unknown) { 38 | const iterator = this.#iterateExec(string) 39 | 40 | let current = iterator.next() 41 | 42 | while (!current.done) { 43 | const result = callback(current.value) 44 | if (result === Regex.BREAK) break 45 | current = iterator.next() 46 | } 47 | 48 | return this 49 | } 50 | 51 | match(string: string): ExecResult | null { 52 | let result: ExecResult | null = null 53 | 54 | this.exec(string, (match) => { 55 | result = match 56 | return Regex.BREAK 57 | }) 58 | 59 | return result 60 | } 61 | 62 | clone() { 63 | return new Regex(new RegExp(this.regexp.source, this.regexp.flags)) 64 | } 65 | 66 | toMatcher() { 67 | return this.match.bind(this) 68 | } 69 | 70 | *#iterateExec(string: string): Generator { 71 | let globalRegexp = this.regexp 72 | if (!globalRegexp.flags.includes("g")) { 73 | globalRegexp = new RegExp( 74 | this.regexp.source, 75 | `${this.regexp.flags}g`, 76 | ) 77 | } 78 | 79 | let match: RegExpExecArray | null = globalRegexp.exec(string) 80 | 81 | while (match !== null) { 82 | if (match) { 83 | yield { 84 | range: [match.index, match.index + match[0].length], 85 | match: [...match], 86 | } 87 | } 88 | match = globalRegexp.exec(string) 89 | } 90 | } 91 | } 92 | 93 | export default Regex 94 | -------------------------------------------------------------------------------- /astro-i18n/src/core/translation/classes/formatter-bank.class.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | import { 3 | capitalize, 4 | default_falsy, 5 | default_non_string, 6 | default_nullish, 7 | intl_format_date, 8 | intl_format_number, 9 | lower, 10 | upper, 11 | json, 12 | } from "@src/core/translation/formatters/default.formatters" 13 | import { serializeFormatter } from "@src/core/translation/functions/formatter.functions" 14 | import type { 15 | Formatters, 16 | SerializedFormatter, 17 | SerializedFormatters, 18 | } from "@src/core/translation/types" 19 | 20 | /** 21 | * This class stores formatters to be able to serialize only the custom 22 | * formatters. 23 | */ 24 | class FormatterBank { 25 | #default: Formatters = { 26 | upper, 27 | uppercase: upper, 28 | lower, 29 | lowercase: lower, 30 | capitalize, 31 | json, 32 | default_nullish, 33 | default: default_falsy, 34 | default_falsy, 35 | default_non_string, 36 | intl_format_number, 37 | intl_format_date, 38 | } 39 | 40 | #custom: Formatters 41 | 42 | #merged: Formatters 43 | 44 | constructor(customFormatters: Formatters = {}) { 45 | this.#custom = customFormatters 46 | this.#merged = { ...this.#default, ...this.#custom } 47 | } 48 | 49 | get default() { 50 | return this.#default 51 | } 52 | 53 | get custom() { 54 | return this.#custom 55 | } 56 | 57 | addFormaters(formatters: Formatters) { 58 | for (const [name, formatter] of Object.entries(formatters)) { 59 | if (this.#merged[name]) continue 60 | this.#merged[name] = formatter 61 | this.#custom[name] = formatter 62 | } 63 | 64 | return this 65 | } 66 | 67 | clear() { 68 | this.#custom = {} 69 | this.#merged = {} 70 | } 71 | 72 | toClientSideObject() { 73 | const serializable: { [name: string]: SerializedFormatter } = {} 74 | 75 | for (const [name, formatter] of Object.entries(this.#custom)) { 76 | serializable[name] = serializeFormatter(formatter) 77 | } 78 | 79 | return serializable as SerializedFormatters 80 | } 81 | 82 | toObject() { 83 | return this.#merged 84 | } 85 | 86 | toString() { 87 | return JSON.stringify(this.toObject(), null, "\t") 88 | } 89 | } 90 | 91 | export default FormatterBank 92 | -------------------------------------------------------------------------------- /astro-i18n/src/core/astro/middleware/index.ts: -------------------------------------------------------------------------------- 1 | import { isObject } from "@lib/ts/guards" 2 | import { astroI18n } from "@src/core/state/singletons/astro-i18n.singleton" 3 | import type { AstroI18nConfig } from "@src/core/config/types" 4 | import type { AstroMiddleware } from "@src/core/astro/types" 5 | import type { Formatters } from "@src/core/translation/types" 6 | 7 | /** 8 | * The `astro-i18n` middleware. 9 | */ 10 | export function useAstroI18n( 11 | config?: Partial | string, 12 | formatters?: Formatters, 13 | ) { 14 | if (!config /* empty string */) config = undefined 15 | if (isObject(config) && Object.keys(config).length === 0) config = undefined 16 | astroI18n.initialize(config, formatters) 17 | 18 | return (async (ctx, next) => { 19 | if (ctx.url.pathname.startsWith("/_")) return next() 20 | 21 | // init 22 | if (!astroI18n.isInitialized) { 23 | await astroI18n.internals.waitInitialization() 24 | } 25 | if (import.meta.env.DEV) { 26 | await astroI18n.internals.reinitalize(config, formatters) 27 | } 28 | 29 | // removing isGetStaticPaths 30 | astroI18n.internals.setPrivateProperties({ 31 | isGetStaticPaths: false, 32 | origin: ctx.url.origin, 33 | }) 34 | 35 | // setting route 36 | astroI18n.route = ctx.url.pathname 37 | 38 | if (astroI18n.internals.config.run !== "client+server") return next() 39 | 40 | const response = await next() 41 | 42 | // was redirected ? 43 | const redirection = astroI18n.internals.getAndClearRedirection() 44 | if (redirection) return redirection 45 | 46 | // is html ? 47 | if (!response.headers.get("content-type")?.includes("html")) { 48 | return response 49 | } 50 | if (response.bodyUsed) return response 51 | 52 | let body = await response.text() 53 | 54 | // serializing astro-i18n into the html 55 | const closingHeadIndex = body.indexOf("") 56 | if (closingHeadIndex > 0) { 57 | body = 58 | body.slice(0, closingHeadIndex) + 59 | astroI18n.internals.toHtml() + 60 | body.slice(closingHeadIndex) 61 | } 62 | 63 | return new Response(body, { 64 | status: response.status, 65 | statusText: response.statusText, 66 | headers: response.headers, 67 | }) 68 | }) satisfies AstroMiddleware as (...args: any[]) => any 69 | } 70 | -------------------------------------------------------------------------------- /astro-i18n/src/core/cli/commands/generate-pages.command.ts: -------------------------------------------------------------------------------- 1 | import AsyncNode from "@lib/async-node/classes/async-node.class" 2 | import { toPosixPath } from "@lib/async-node/functions/path.functions" 3 | import { 4 | isDirectory, 5 | removeDirectory, 6 | writeNestedFile, 7 | } from "@lib/async-node/functions/fs.functions" 8 | import InvalidCommand from "@src/core/cli/errors/invalid-command.error" 9 | import RootNotFound from "@src/core/config/errors/root-not-found.error" 10 | import { getProjectPages } from "@src/core/page/functions/page.functions" 11 | import { astroI18n } from "@src/core/state/singletons/astro-i18n.singleton" 12 | import type { Command, ParsedArgv } from "@lib/argv/types" 13 | import { PAGES_DIRNAME } from "@src/constants/app.constants" 14 | 15 | const cmd = { 16 | name: "generate:pages", 17 | options: ["purge", "root"], 18 | } as const satisfies Command 19 | 20 | export async function generatePages({ command, options }: ParsedArgv) { 21 | if (command !== cmd.name) throw new InvalidCommand() 22 | const { join } = await AsyncNode.path 23 | 24 | const root = await toPosixPath( 25 | typeof options["root"] === "string" ? options["root"] : process.cwd(), 26 | ) 27 | if (!(await isDirectory(root))) throw new RootNotFound() 28 | 29 | await astroI18n.initialize() 30 | 31 | const pagesDirectory = join( 32 | root, 33 | `${astroI18n.internals.config.srcDir}/${PAGES_DIRNAME}`, 34 | ) 35 | 36 | if (options["purge"]) { 37 | for (const locale of astroI18n.secondaryLocales) { 38 | const secondaryLocaleDir = join(pagesDirectory, locale) 39 | if (!isDirectory(secondaryLocaleDir)) continue 40 | await removeDirectory(secondaryLocaleDir) 41 | } 42 | } 43 | 44 | const pages = await getProjectPages(root, astroI18n.internals.config) 45 | 46 | for (const page of pages) { 47 | if (page.route === "/404") continue 48 | for (const locale of astroI18n.secondaryLocales) { 49 | const proxyRoute = astroI18n.l(page.route, undefined, { 50 | targetLocale: locale, 51 | }) 52 | 53 | const proxy = await page.getProxy(proxyRoute, astroI18n) 54 | if (!proxy) continue 55 | 56 | await writeNestedFile( 57 | join(pagesDirectory, proxyRoute, "index.astro"), 58 | proxy, 59 | ) 60 | } 61 | } 62 | } 63 | 64 | export default cmd 65 | -------------------------------------------------------------------------------- /astro-i18n/src/core/routing/classes/segment-bank.class.ts: -------------------------------------------------------------------------------- 1 | import { setObjectProperty } from "@lib/object" 2 | import type Config from "@src/core/config/classes/config.class" 3 | import type { ConfigRoutes } from "@src/core/config/types" 4 | import type { SegmentTranslations } from "@src/core/routing/types" 5 | 6 | class SegmentBank { 7 | #primaryLocale: string 8 | 9 | #segments: SegmentTranslations 10 | 11 | constructor(translations: SegmentTranslations = {}, primaryLocale = "") { 12 | this.#segments = translations 13 | this.#primaryLocale = primaryLocale 14 | } 15 | 16 | static fromConfig({ routes, primaryLocale }: Config) { 17 | return new SegmentBank({}, primaryLocale).addSegments(routes) 18 | } 19 | 20 | get(segment: string, segmentLocale: string, targetLocale: string) { 21 | return this.#segments[segmentLocale]?.[segment]?.[targetLocale] || null 22 | } 23 | 24 | getSegmentLocales(segment: string) { 25 | const locales: string[] = [] 26 | 27 | for (const [locale, segments] of Object.entries(this.#segments)) { 28 | if (segments[segment]) { 29 | locales.push(locale) 30 | } 31 | } 32 | 33 | return locales 34 | } 35 | 36 | addSegments(segments: ConfigRoutes) { 37 | const entries = Object.entries(segments) 38 | 39 | for (const [locale, segments] of entries) { 40 | const otherLocales = entries.filter( 41 | ([loc]) => loc !== locale && loc !== this.#primaryLocale, 42 | ) 43 | 44 | for (const [primarySeg, localeSeg] of Object.entries(segments)) { 45 | // adding segment to the primary locale translations 46 | setObjectProperty( 47 | this.#segments, 48 | [this.#primaryLocale, primarySeg, locale], 49 | localeSeg, 50 | ) 51 | 52 | // adding segment to the current secondary locale translations 53 | setObjectProperty( 54 | this.#segments, 55 | [locale, localeSeg, this.#primaryLocale], 56 | primarySeg, 57 | ) 58 | 59 | // adding segment to all other locale translations 60 | for (const [otherLocale, otherSegments] of otherLocales) { 61 | if (otherSegments[primarySeg]) { 62 | setObjectProperty( 63 | this.#segments, 64 | [locale, localeSeg, otherLocale], 65 | otherSegments[primarySeg], 66 | ) 67 | } 68 | } 69 | } 70 | } 71 | 72 | return this 73 | } 74 | 75 | clear() { 76 | this.#primaryLocale = "" 77 | this.#segments = {} 78 | } 79 | 80 | toClientSideObject() { 81 | return { ...this.#segments } 82 | } 83 | 84 | toObject() { 85 | return { 86 | primaryLocale: this.#primaryLocale, 87 | segments: this.#segments, 88 | } 89 | } 90 | 91 | toString() { 92 | return JSON.stringify(this.toObject(), null, "\t") 93 | } 94 | } 95 | 96 | export default SegmentBank 97 | -------------------------------------------------------------------------------- /astro-i18n/src/core/translation/functions/variant/variant-parsing.functions.ts: -------------------------------------------------------------------------------- 1 | import { CALLBACK_BREAK } from "@src/constants/app.constants" 2 | import { ValueType } from "@src/core/translation/enums/value-type.enum" 3 | import { depthAwareforEach } from "@src/core/translation/functions/parsing.functions" 4 | import { matchVariantValue } from "@src/core/translation/functions/variant/variant-matching.functions" 5 | import { isPrimitive } from "@src/core/translation/guards/primitive.guard" 6 | import InvalidVariantPropertyValue from "@src/core/translation/errors/variant/invalid-variant-property-value.error" 7 | 8 | /** 9 | * Creates the variant's property value for the given `value`. 10 | * @param value for example: `"1.5"`. 11 | */ 12 | export function parseVariantValue(value: string, type: ValueType) { 13 | let parsed: unknown 14 | 15 | switch (type) { 16 | case ValueType.UNDEFINED: { 17 | parsed = undefined 18 | break 19 | } 20 | case ValueType.NULL: { 21 | parsed = null 22 | break 23 | } 24 | case ValueType.BOOLEAN: { 25 | if (value === "true") { 26 | parsed = true 27 | break 28 | } 29 | if (value === "false") { 30 | parsed = false 31 | break 32 | } 33 | // fallthrough (default case if not true or false) 34 | } 35 | case ValueType.NUMBER: { 36 | parsed = value.includes(".") 37 | ? Number.parseFloat(value) 38 | : Number.parseInt(value, 10) 39 | break 40 | } 41 | case ValueType.STRING: { 42 | parsed = value.slice(1, -1) 43 | break 44 | } 45 | case ValueType.ARRAY: { 46 | parsed = parseArray(value) 47 | break 48 | } 49 | default: { 50 | throw new InvalidVariantPropertyValue(value) 51 | } 52 | } 53 | 54 | return parsed 55 | } 56 | 57 | /** 58 | * Creates the variant property of type array. 59 | */ 60 | function parseArray(array: string) { 61 | const parsed: unknown[] = [] 62 | 63 | let value = "" 64 | depthAwareforEach(array, (char, _, depth, isOpening) => { 65 | if (depth === 0) { 66 | const { value: matchedValue, type } = matchVariantValue( 67 | value.trim(), 68 | ) 69 | const parsedValue = parseVariantValue(matchedValue, type) 70 | if (!isPrimitive(parsedValue)) { 71 | throw new InvalidVariantPropertyValue(value) 72 | } 73 | parsed.push(parsedValue) 74 | return CALLBACK_BREAK 75 | } 76 | 77 | if (depth === 1) { 78 | if (isOpening) return null // ignore opening bracket 79 | if (char === ",") { 80 | const { value: matchedValue, type } = matchVariantValue( 81 | value.trim(), 82 | ) 83 | parsed.push(parseVariantValue(matchedValue, type)) 84 | value = "" 85 | return null 86 | } 87 | } 88 | 89 | value += char 90 | return null 91 | }) 92 | 93 | return parsed 94 | } 95 | -------------------------------------------------------------------------------- /astro-i18n/src/core/astro/types/index.ts: -------------------------------------------------------------------------------- 1 | import type { AstroIntegration } from "astro" 2 | import type { defineMiddleware } from "astro/middleware" 3 | 4 | export type GetStaticPathsItem = { 5 | params: Record 6 | props?: Record 7 | } 8 | 9 | export type AstroMiddleware = Parameters[0] 10 | 11 | export type AstroHooks = { 12 | "config:setup": NonNullable 13 | "config:done": NonNullable 14 | "server:setup": NonNullable 15 | "server:start": NonNullable 16 | "server:done": NonNullable 17 | "build:start": NonNullable 18 | "build:setup": NonNullable 19 | "build:generated": NonNullable< 20 | AstroIntegration["hooks"]["astro:build:generated"] 21 | > 22 | "build:ssr": NonNullable 23 | "build:done": NonNullable 24 | } 25 | 26 | export type GetStaticPathsProps = { 27 | paginate: Function 28 | rss: Function 29 | } 30 | 31 | export interface AstroContent { 32 | id: string 33 | collection: string 34 | data: Record 35 | slug: string 36 | body: string 37 | } 38 | 39 | export interface AstroGlobal { 40 | clientAddress: string 41 | cookies: AstroCookies 42 | url: URL 43 | params: Record 44 | props: Record 45 | request: Request 46 | response: ResponseInit & { 47 | readonly headers: Headers 48 | } 49 | redirect(path: string, status?: 301 | 302 | 303 | 307 | 308): Response 50 | slots: Record & { 51 | has(slotName: string): boolean 52 | render(slotName: string, args?: any[]): Promise 53 | } 54 | site: URL | undefined 55 | generator: string 56 | __renderMarkdown?: (md: string) => Promise 57 | } 58 | 59 | type AstroCookies = { 60 | delete( 61 | key: string, 62 | options?: Pick, 63 | ): void 64 | get(key: string): { 65 | value: string | undefined 66 | json(): any 67 | number(): number 68 | boolean(): boolean 69 | } 70 | has(key: string): boolean 71 | set( 72 | key: string, 73 | value: string | Record, 74 | options?: AstroCookieSetOptions, 75 | ): void 76 | headers(): Generator 77 | } 78 | 79 | interface AstroCookieSetOptions { 80 | domain?: string 81 | expires?: Date 82 | httpOnly?: boolean 83 | maxAge?: number 84 | path?: string 85 | sameSite?: boolean | "lax" | "none" | "strict" 86 | secure?: boolean 87 | } 88 | -------------------------------------------------------------------------------- /astro-i18n/src/core/config/guards/config.guard.ts: -------------------------------------------------------------------------------- 1 | import { isObject, isStringArray } from "@lib/ts/guards" 2 | import { isConfigRoutes } from "@src/core/config/guards/config-routes.guard" 3 | import { 4 | isConfigTranslationDirectory, 5 | isConfigTranslationLoadingRules, 6 | isConfigTranslations, 7 | } from "@src/core/config/guards/config-translations.guard" 8 | import type { AstroI18nConfig, SerializedConfig } from "@src/core/config/types" 9 | 10 | export function isPartialConfig( 11 | config: unknown, 12 | ): config is Partial { 13 | if (!isObject(config)) return false 14 | 15 | for (const [key, value] of Object.entries(config)) { 16 | switch (key) { 17 | case "primaryLocale": { 18 | if (typeof value !== "string") return false 19 | break 20 | } 21 | case "secondaryLocales": { 22 | if (!isStringArray(value)) return false 23 | break 24 | } 25 | case "fallbackLocale": { 26 | if (typeof value !== "string") return false 27 | break 28 | } 29 | case "showPrimaryLocale": { 30 | if (typeof value !== "boolean") return false 31 | break 32 | } 33 | case "trailingSlash": { 34 | if (value !== "always" && value !== "never") return false 35 | break 36 | } 37 | case "run": { 38 | if (value !== "server" && value !== "client+server") { 39 | return false 40 | } 41 | break 42 | } 43 | case "translations": { 44 | if (!isConfigTranslations(value)) return false 45 | break 46 | } 47 | case "translationLoadingRules": { 48 | if (!isConfigTranslationLoadingRules(value)) return false 49 | break 50 | } 51 | case "translationDirectory": { 52 | if (!isConfigTranslationDirectory(value)) return false 53 | break 54 | } 55 | case "routes": { 56 | if (!isConfigRoutes(value)) return false 57 | break 58 | } 59 | case "srcDir": { 60 | if (typeof value !== "string") return false 61 | break 62 | } 63 | default: { 64 | return false 65 | } 66 | } 67 | } 68 | 69 | return true 70 | } 71 | 72 | export function isSerializedConfig( 73 | serializedConfig: unknown, 74 | ): serializedConfig is SerializedConfig { 75 | if (!isObject(serializedConfig)) return false 76 | 77 | for (const [key, value] of Object.entries(serializedConfig)) { 78 | switch (key) { 79 | case "primaryLocale": { 80 | if (typeof value !== "string") return false 81 | break 82 | } 83 | case "secondaryLocales": { 84 | if (!isStringArray(value)) return false 85 | break 86 | } 87 | case "showPrimaryLocale": { 88 | if (typeof value !== "boolean") return false 89 | break 90 | } 91 | case "trailingSlash": { 92 | if (value !== "always" && value !== "never") return false 93 | break 94 | } 95 | default: { 96 | return false 97 | } 98 | } 99 | } 100 | 101 | return true 102 | } 103 | -------------------------------------------------------------------------------- /astro-i18n/lib/async-node/functions/import.functions.ts: -------------------------------------------------------------------------------- 1 | import AsyncNode from "@lib/async-node/classes/async-node.class" 2 | import DependencyNotFound from "@lib/async-node/errors/dependency-not-found.error" 3 | import FileNotFound from "@lib/async-node/errors/file-not-found.error" 4 | import InvalidFileType from "@lib/async-node/errors/invalid-file-type.error" 5 | import InvalidJson from "@lib/async-node/errors/invalid-json.error" 6 | import { isFile } from "@lib/async-node/functions/fs.functions" 7 | import type { Esbuild } from "@lib/async-node/types" 8 | 9 | export async function importScript( 10 | filename: string, 11 | ): Promise> { 12 | let esbuild: Esbuild | null = null 13 | esbuild = await import("esbuild") 14 | 15 | const supportedExtensions = /\.(js|cjs|mjs|ts)$/ 16 | if (!isFile(filename)) throw new FileNotFound(filename) 17 | if (!supportedExtensions.test(filename)) { 18 | throw new InvalidFileType(["js", "cjs", "mjs", "ts"]) 19 | } 20 | 21 | try { 22 | const { outputFiles } = await esbuild.build({ 23 | entryPoints: [filename], 24 | bundle: true, 25 | external: ["esbuild"], 26 | format: "cjs", 27 | platform: "node", 28 | write: false, 29 | }) 30 | const commonJs = new TextDecoder().decode(outputFiles[0]?.contents) 31 | 32 | return commonJs 33 | ? extractCommonJsExports( 34 | commonJs, 35 | filename.replace(supportedExtensions, ".cjs"), 36 | ) 37 | : {} 38 | } catch { 39 | throw new DependencyNotFound("esbuild") 40 | } 41 | } 42 | 43 | export async function importJson(filename: string) { 44 | if (!isFile(filename)) throw new FileNotFound(filename) 45 | if (!/\.json$/.test(filename)) throw new InvalidFileType(["json"]) 46 | 47 | const { readFileSync } = await AsyncNode.fs 48 | 49 | const json = readFileSync(filename, { encoding: "utf8" }) 50 | 51 | try { 52 | return JSON.parse(json) as unknown 53 | } catch (_) { 54 | throw new InvalidJson(filename) 55 | } 56 | } 57 | 58 | async function extractCommonJsExports(commonJs: string, filename: string) { 59 | const { Module } = await AsyncNode.module 60 | const dirname = filename.split("/").slice(0, -1).join("/") 61 | const global = { 62 | module: new Module(filename), 63 | require(id: string) { 64 | return this.module.require(id) 65 | }, 66 | } 67 | 68 | // eslint-disable-next-line no-underscore-dangle 69 | global.module.paths = Module._nodeModulePaths(dirname) 70 | global.module.filename = filename 71 | global.module.exports = {} 72 | global.require = (id) => global.module.require(id) 73 | Object.assign(global.require, { 74 | // eslint-disable-next-line no-underscore-dangle 75 | resolve: (req: string) => Module._resolveFilename(req, global.module), 76 | }) 77 | 78 | // eslint-disable-next-line no-new-func 79 | new Function( 80 | "exports", 81 | "require", 82 | "module", 83 | "__filename", 84 | "__dirname", 85 | commonJs, 86 | )(global.module.exports, global.require, global.module, filename, dirname) 87 | 88 | return global.module.exports 89 | } 90 | -------------------------------------------------------------------------------- /astro-i18n/src/core/translation/functions/matching.functions.ts: -------------------------------------------------------------------------------- 1 | import { RegexBuilder } from "@lib/regex" 2 | import { 3 | EMPTY_PATTERN, 4 | NUMBER_PATTERN, 5 | VARNAME_PATTERN, 6 | } from "@src/core/translation/constants/translation-patterns.constants" 7 | import type { Matcher } from "@src/core/translation/types" 8 | 9 | export const matchNumber: Matcher = RegexBuilder.fromRegex(NUMBER_PATTERN) 10 | .assertStarting() 11 | .build() 12 | .toMatcher() 13 | 14 | export const matchVariable: Matcher = RegexBuilder.fromRegex(VARNAME_PATTERN) 15 | .assertStarting() 16 | .build() 17 | .toMatcher() 18 | 19 | export function matchEmpty(string: string): ReturnType { 20 | if (EMPTY_PATTERN.test(string)) { 21 | return { 22 | range: [0, 0], 23 | match: [""], 24 | } 25 | } 26 | 27 | return null 28 | } 29 | 30 | export function matchUndefined(string: string): ReturnType { 31 | if (string.startsWith("undefined")) { 32 | return { 33 | range: [0, 9], 34 | match: ["undefined"], 35 | } 36 | } 37 | 38 | return null 39 | } 40 | 41 | export function matchNull(string: string): ReturnType { 42 | if (string.startsWith("null")) { 43 | return { 44 | range: [0, 4], 45 | match: ["null"], 46 | } 47 | } 48 | 49 | return null 50 | } 51 | 52 | export function matchBoolean(string: string): ReturnType { 53 | if (string.startsWith("true")) { 54 | return { 55 | range: [0, 4], 56 | match: ["true"], 57 | } 58 | } 59 | 60 | if (string.startsWith("false")) { 61 | return { 62 | range: [0, 5], 63 | match: ["false"], 64 | } 65 | } 66 | 67 | return null 68 | } 69 | 70 | export function matchString(string: string): ReturnType { 71 | const quoteType = string[0] 72 | if (quoteType !== '"' && quoteType !== "'" && quoteType !== "`") return null 73 | 74 | let end = string.slice(1).indexOf(quoteType) 75 | if (end === -1) return null 76 | end += 2 // adding first char back + last char 77 | 78 | return { 79 | range: [0, end], 80 | match: [string.slice(0, end)], 81 | } 82 | } 83 | 84 | export function matchObject(string: string): ReturnType { 85 | if (!string.startsWith("{")) return null 86 | 87 | let depth = 0 88 | for (let i = 0; i < string.length; i += 1) { 89 | const char = string[i] 90 | 91 | if (char === "{") depth += 1 92 | else if (char === "}") depth -= 1 93 | 94 | if (depth === 0) { 95 | const end = i + 1 96 | return { 97 | range: [0, end], 98 | match: [string.slice(0, end)], 99 | } 100 | } 101 | } 102 | 103 | return null 104 | } 105 | 106 | export function matchArray(string: string): ReturnType { 107 | if (!string.startsWith("[")) return null 108 | 109 | let depth = 0 110 | for (let i = 0; i < string.length; i += 1) { 111 | const char = string[i] 112 | 113 | if (char === "[") depth += 1 114 | else if (char === "]") depth -= 1 115 | 116 | if (depth === 0) { 117 | const end = i + 1 118 | return { 119 | range: [0, end], 120 | match: [string.slice(0, end)], 121 | } 122 | } 123 | } 124 | 125 | return null 126 | } 127 | -------------------------------------------------------------------------------- /img/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 53 | -------------------------------------------------------------------------------- /astro-i18n/src/core/translation/guards/translation-map.guard.ts: -------------------------------------------------------------------------------- 1 | import { isArray, isObject } from "@lib/ts/guards" 2 | import type { 3 | ComputedTranslations, 4 | TranslationMap, 5 | VariantProperties, 6 | VariantProperty, 7 | } from "@src/core/translation/types" 8 | 9 | export function isTranslationMap( 10 | translationMap: unknown, 11 | ): translationMap is TranslationMap { 12 | if (!isObject(translationMap)) return false 13 | 14 | for (const groupLocales of Object.values(translationMap)) { 15 | if (!isObject(groupLocales)) return false 16 | 17 | for (const computedTranslations of Object.values(groupLocales)) { 18 | if (!isComputedTranslations(computedTranslations)) return false 19 | } 20 | } 21 | 22 | return true 23 | } 24 | 25 | function isComputedTranslations( 26 | computedTranslations: unknown, 27 | ): computedTranslations is ComputedTranslations { 28 | if (!isObject(computedTranslations)) return false 29 | 30 | for (const computedTranslation of Object.values(computedTranslations)) { 31 | if (!isObject(computedTranslation)) return false 32 | 33 | for (const [key, value] of Object.entries(computedTranslation)) { 34 | switch (key) { 35 | case "default": { 36 | if (typeof value !== "string") return false 37 | break 38 | } 39 | case "variants": { 40 | if (!isArray(value)) return false 41 | if (!value.every((variant) => isVariantObject(variant))) { 42 | return false 43 | } 44 | break 45 | } 46 | default: { 47 | return false 48 | } 49 | } 50 | } 51 | } 52 | 53 | return true 54 | } 55 | 56 | function isVariantObject( 57 | variant: unknown, 58 | ): variant is Required { 59 | if (!isObject(variant)) return false 60 | 61 | const entries = Object.entries(variant) 62 | if (entries.length < 4) return false 63 | 64 | for (const [key, value] of entries) { 65 | switch (key) { 66 | case "raw": { 67 | if (typeof value !== "string") return false 68 | break 69 | } 70 | case "priority": { 71 | if (typeof value !== "number") return false 72 | break 73 | } 74 | case "properties": { 75 | if (!isArray(value)) return false 76 | if (!value.every((property) => isVariantProperty(property))) { 77 | return false 78 | } 79 | break 80 | } 81 | case "value": { 82 | if (typeof value !== "string") return false 83 | break 84 | } 85 | default: { 86 | return false 87 | } 88 | } 89 | } 90 | 91 | return true 92 | } 93 | 94 | function isVariantProperty( 95 | variantProperty: unknown, 96 | ): variantProperty is VariantProperty { 97 | if (!isObject(variantProperty)) return false 98 | 99 | const entries = Object.entries(variantProperty) 100 | if (entries.length < 2) return false 101 | 102 | for (const [key, value] of entries) { 103 | switch (key) { 104 | case "name": { 105 | if (typeof value !== "string") return false 106 | break 107 | } 108 | case "values": { 109 | if (!isArray(value)) return false 110 | if ( 111 | !value.every((val) => { 112 | return ( 113 | typeof val === "boolean" || 114 | typeof val === "string" || 115 | typeof val === "number" || 116 | val == null 117 | ) 118 | }) 119 | ) { 120 | return false 121 | } 122 | break 123 | } 124 | default: { 125 | return false 126 | } 127 | } 128 | } 129 | 130 | return true 131 | } 132 | -------------------------------------------------------------------------------- /apps/astro/astro-i18n.config.ts: -------------------------------------------------------------------------------- 1 | import { defineAstroI18nConfig } from "astro-i18n" 2 | 3 | export default defineAstroI18nConfig({ 4 | primaryLocale: "en", 5 | secondaryLocales: ["fr"], 6 | showPrimaryLocale: false, 7 | translationLoadingRules: [ 8 | { 9 | routes: ["/group$"], 10 | groups: ["^group"], 11 | }, 12 | { 13 | routes: ["/group"], 14 | groups: ["^group2"], 15 | }, 16 | ], 17 | translationDirectory: {}, 18 | translations: { 19 | common: { 20 | en: { 21 | commonBasic: "en.commonBasic", 22 | commonVariant: "en.commonVariant (default value)", 23 | "commonVariant{{ n: -2 }}": "en.commonVariant (n === -2)", 24 | "commonVariant{{ n: 2 }}": "en.commonVariant (n === 2)", 25 | "commonVariant{{ n: 2, x: 'text' }}": 26 | "en.commonVariant (n === 2 && x === 'text')", 27 | "commonVariant{{ n: 3 }}": "en.commonVariant (n === 3)", 28 | "commonVariant{{ n: 3, $priority: 100 }}": 29 | "en.commonVariant (n === 3 && $priority === 100)", 30 | "commonVariant{{ n: [4, 'text', true] }}": 31 | "en.commonVariant (n === 4 || n === 'text' || 'n === true')", 32 | commonInterpolation: 33 | "en.commonInterpolation ({# value>json(format>default(false)) #})", 34 | commonInterpolationAlias: 35 | "en.commonInterpolation ({# value>json(format(alias)) #})", 36 | commonInterpolationChained: 37 | "en.commonInterpolation ({# value>json(format(alias))>uppercase #})", 38 | commonInterpolationCurrency: 39 | "en.commonInterpolation ({# value>intl_format_number({ style: 'currency', currency: currencyCode }, 'fr') #})", 40 | commonFallback: "en.commonFallback", 41 | nested: { 42 | commonNested: "en.commonNested", 43 | }, 44 | }, 45 | fr: { 46 | commonBasic: "fr.commonBasic", 47 | commonVariant: "fr.commonVariant (default value)", 48 | "commonVariant{{ n: -2 }}": "fr.commonVariant (n === -2)", 49 | "commonVariant{{ n: 2 }}": "fr.commonVariant (n === 2)", 50 | "commonVariant{{ n: 2, x: 'text' }}": 51 | "fr.commonVariant (n === 2 && x === 'text')", 52 | "commonVariant{{ n: 3 }}": "fr.commonVariant (n === 3)", 53 | "commonVariant{{ n: 3, $priority: 100 }}": 54 | "fr.commonVariant (n === 3 && $priority === 100)", 55 | "commonVariant{{ n: [4, 'text', true] }}": 56 | "fr.commonVariant (n === 4 || n === 'text' || 'n === true')", 57 | commonInterpolation: 58 | "fr.commonInterpolation ({# value>json(format>default(false)) #})", 59 | commonInterpolationAlias: 60 | "fr.commonInterpolation ({# value>json(format(alias)) #})", 61 | commonInterpolationChained: 62 | "fr.commonInterpolation ({# value>json(format(alias))>uppercase #})", 63 | commonInterpolationCurrency: 64 | "fr.commonInterpolation ({# value>intl_format_number({ style: 'currency', currency: currencyCode }, 'fr') #})", 65 | nested: { 66 | commonNested: "fr.commonNested", 67 | }, 68 | }, 69 | }, 70 | "/page": { 71 | en: { 72 | pageTranslation: "en.pageTranslation", 73 | }, 74 | fr: { 75 | pageTranslation: "fr.pageTranslation", 76 | }, 77 | }, 78 | "/page/[id]": { 79 | en: { 80 | paramTranslation: "en.paramTranslation", 81 | }, 82 | fr: { 83 | paramTranslation: "fr.paramTranslation", 84 | }, 85 | }, 86 | group1: { 87 | en: { 88 | groupTranslation1: "en.groupTranslation1", 89 | }, 90 | fr: { 91 | groupTranslation1: "fr.groupTranslation1", 92 | }, 93 | }, 94 | group2: { 95 | en: { 96 | groupTranslation2: "en.groupTranslation2", 97 | }, 98 | fr: { 99 | groupTranslation2: "fr.groupTranslation2", 100 | }, 101 | }, 102 | }, 103 | routes: { 104 | fr: { 105 | about: "a-propos", 106 | product: "produit", 107 | inner: "interieur", 108 | group: "groupe", 109 | }, 110 | }, 111 | }) 112 | -------------------------------------------------------------------------------- /astro-i18n/src/core/translation/formatters/default.formatters.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | import { isDate, isNumber, isObject } from "@lib/ts/guards" 3 | import { astroI18n } from "@src/core/state/singletons/astro-i18n.singleton" 4 | import InvalidDate from "@src/core/translation/errors/formatters/invalid-date.error" 5 | import InvalidFormatterParam from "@src/core/translation/errors/formatters/invalid-formatter-param.error" 6 | import InvalidFormatterValue from "@src/core/translation/errors/formatters/invalid-formatter-value.error copy" 7 | 8 | export function upper(value: unknown) { 9 | if (typeof value !== "string") { 10 | throw new InvalidFormatterValue( 11 | `Received value is not a string, found "${value}".`, 12 | "upper", 13 | ) 14 | } 15 | return value.toUpperCase() 16 | } 17 | 18 | export function lower(value: unknown) { 19 | if (typeof value !== "string") { 20 | throw new InvalidFormatterValue( 21 | `Received value is not a string, found "${value}".`, 22 | "lower", 23 | ) 24 | } 25 | return value.toLowerCase() 26 | } 27 | 28 | export function capitalize(value: unknown) { 29 | if (typeof value !== "string") { 30 | throw new InvalidFormatterValue( 31 | `Received value is not a string, found "${value}".`, 32 | "capitalize", 33 | ) 34 | } 35 | return `${value.slice(0, 1).toUpperCase()}${value.slice(1).toLowerCase()}` 36 | } 37 | 38 | export function default_nullish(value: unknown, defaultValue: unknown) { 39 | return value == null ? defaultValue : value 40 | } 41 | 42 | export function default_falsy(value: unknown, defaultValue: unknown) { 43 | return value || defaultValue 44 | } 45 | 46 | export function default_non_string(value: unknown, defaultValue: unknown) { 47 | return typeof value === "string" ? value : defaultValue 48 | } 49 | 50 | export function json(value: unknown, format: unknown = true) { 51 | if (typeof value === "symbol") { 52 | throw new InvalidFormatterValue( 53 | `Received value cannot be a symbol, found "${value.toString()}".`, 54 | "json", 55 | ) 56 | } 57 | if (typeof format !== "boolean") { 58 | throw new InvalidFormatterParam( 59 | `format must be a boolean, found "${format}".`, 60 | "json", 61 | ) 62 | } 63 | return format ? JSON.stringify(value, null, "\t") : JSON.stringify(value) 64 | } 65 | 66 | export function intl_format_number( 67 | value: unknown, 68 | options: unknown = {}, 69 | locale: unknown = astroI18n.locale, 70 | ) { 71 | // value 72 | if (typeof value === "string") value = Number.parseFloat(value) 73 | if (!isNumber(value)) { 74 | throw new InvalidFormatterValue( 75 | `Received value is not a number, found "${value}".`, 76 | "intl_format_number", 77 | ) 78 | } 79 | // options 80 | if (!isObject(options)) { 81 | throw new InvalidFormatterParam( 82 | `options must be an object, found "${options}".`, 83 | "intl_format_number", 84 | ) 85 | } 86 | // locale 87 | if (typeof locale !== "string") { 88 | throw new InvalidFormatterParam( 89 | `locale must be a string, found "${locale}".`, 90 | "intl_format_number", 91 | ) 92 | } 93 | 94 | return new Intl.NumberFormat(locale, options).format(value) 95 | } 96 | 97 | export function intl_format_date( 98 | value: unknown, 99 | options: unknown = {}, 100 | locale: unknown = astroI18n.locale, 101 | ) { 102 | // value 103 | if ( 104 | typeof value !== "string" && 105 | typeof value !== "number" && 106 | !(value instanceof Date) 107 | ) { 108 | throw new InvalidFormatterValue( 109 | `Received value is not a string, number or Date, found "${value}".`, 110 | "intl_format_date", 111 | ) 112 | } 113 | value = value instanceof Date ? value : new Date(value) 114 | if (!isDate(value)) { 115 | throw new InvalidDate(value) 116 | } 117 | // options 118 | if (!isObject(options)) { 119 | throw new InvalidFormatterParam( 120 | `options must be an object, found "${options}".`, 121 | "intl_format_date", 122 | ) 123 | } 124 | // locale 125 | if (typeof locale !== "string") { 126 | throw new InvalidFormatterParam( 127 | `locale must be a string, found "${locale}".`, 128 | "intl_format_date", 129 | ) 130 | } 131 | 132 | return new Intl.DateTimeFormat(locale, options) 133 | } 134 | -------------------------------------------------------------------------------- /astro-i18n/src/core/translation/functions/interpolation/interpolation-parsing.functions.ts: -------------------------------------------------------------------------------- 1 | import { CALLBACK_BREAK } from "@src/constants/app.constants" 2 | import Interpolation from "@src/core/translation/classes/interpolation.class" 3 | import { ValueType } from "@src/core/translation/enums/value-type.enum" 4 | import UnknownValue from "@src/core/translation/errors/interpolation/unknown-value.error" 5 | import { depthAwareforEach } from "@src/core/translation/functions/parsing.functions" 6 | import type { 7 | FormatterMatch, 8 | Formatters, 9 | TranslationProperties, 10 | } from "@src/core/translation/types" 11 | 12 | /** 13 | * Creates the interpolation value for the given `value`. 14 | * @param value for example: `"{ prop: interpolationValue }"`. 15 | */ 16 | export function parseInterpolationValue( 17 | value: string, 18 | alias: string | null, 19 | type: ValueType, 20 | formatters: FormatterMatch[], 21 | properties: TranslationProperties, 22 | availableFormatters: Formatters, 23 | ) { 24 | let parsed: unknown 25 | 26 | switch (type) { 27 | case ValueType.UNDEFINED: { 28 | parsed = undefined 29 | break 30 | } 31 | case ValueType.NULL: { 32 | parsed = null 33 | break 34 | } 35 | case ValueType.BOOLEAN: { 36 | if (value === "true") { 37 | parsed = true 38 | break 39 | } 40 | if (value === "false") { 41 | parsed = false 42 | break 43 | } 44 | // fallthrough (default case if not true or false) 45 | } 46 | case ValueType.NUMBER: { 47 | parsed = value.includes(".") 48 | ? Number.parseFloat(value) 49 | : Number.parseInt(value, 10) 50 | break 51 | } 52 | case ValueType.VARIABLE: { 53 | if (alias) { 54 | parsed = properties[alias] 55 | break 56 | } 57 | parsed = properties[value] 58 | break 59 | } 60 | case ValueType.STRING: { 61 | parsed = value.slice(1, -1) 62 | break 63 | } 64 | case ValueType.OBJECT: { 65 | parsed = parseObject(value, properties, availableFormatters) 66 | break 67 | } 68 | case ValueType.ARRAY: { 69 | parsed = parseArray(value, properties, availableFormatters) 70 | break 71 | } 72 | default: { 73 | throw new UnknownValue(value) 74 | } 75 | } 76 | 77 | // chain formatters 78 | for (const { name, args: rawArgs } of formatters) { 79 | const formatter = availableFormatters[name] 80 | if (!formatter) return undefined 81 | 82 | const args = rawArgs.map( 83 | (arg) => 84 | new Interpolation(arg, properties, availableFormatters).value, 85 | ) 86 | parsed = formatter(parsed, ...args) 87 | } 88 | 89 | return parsed 90 | } 91 | 92 | /** 93 | * Creates the interpolation value of type object. 94 | */ 95 | function parseObject( 96 | object: string, 97 | properties: TranslationProperties, 98 | availableFormatters: Formatters, 99 | ) { 100 | const parsed: Record = {} 101 | 102 | let key = "" 103 | let value = "" 104 | let isKey = true 105 | depthAwareforEach(object, (char, _, depth, isOpening) => { 106 | if (depth === 0) { 107 | parsed[key] = new Interpolation( 108 | value, 109 | properties, 110 | availableFormatters, 111 | ).value 112 | return CALLBACK_BREAK 113 | } 114 | 115 | if (depth === 1) { 116 | if (isKey) { 117 | if (isOpening) return null // ignore opening bracket 118 | if (/\s/.test(char)) return null 119 | if (char === ":") { 120 | isKey = false 121 | return null 122 | } 123 | key += char 124 | } else if (char === ",") { 125 | parsed[key] = new Interpolation( 126 | value, 127 | properties, 128 | availableFormatters, 129 | ).value 130 | key = "" 131 | value = "" 132 | isKey = true 133 | return null 134 | } 135 | } 136 | 137 | if (!isKey) value += char 138 | return null 139 | }) 140 | 141 | return parsed 142 | } 143 | 144 | /** 145 | * Creates the interpolation value of type array. 146 | */ 147 | function parseArray( 148 | array: string, 149 | properties: TranslationProperties, 150 | availableFormatters: Formatters, 151 | ) { 152 | const parsed: unknown[] = [] 153 | 154 | let value = "" 155 | depthAwareforEach(array, (char, _, depth, isOpening) => { 156 | if (depth === 0) { 157 | parsed.push( 158 | new Interpolation(value, properties, availableFormatters).value, 159 | ) 160 | return CALLBACK_BREAK 161 | } 162 | 163 | if (depth === 1) { 164 | if (isOpening) return null // ignore opening bracket 165 | if (char === ",") { 166 | parsed.push( 167 | new Interpolation(value, properties, availableFormatters) 168 | .value, 169 | ) 170 | value = "" 171 | return null 172 | } 173 | } 174 | 175 | value += char 176 | return null 177 | }) 178 | 179 | return parsed 180 | } 181 | -------------------------------------------------------------------------------- /astro-i18n/src/core/translation/functions/translation.functions.ts: -------------------------------------------------------------------------------- 1 | import { never } from "@lib/error" 2 | import Interpolation from "@src/core/translation/classes/interpolation.class" 3 | import Variant from "@src/core/translation/classes/variant.class" 4 | import NonStringVariant from "@src/core/translation/errors/variant/non-string-variant.error" 5 | import { 6 | INTERPOLATION_PATTERN, 7 | VARIANT_PATTERN, 8 | } from "@src/core/translation/constants/translation-patterns.constants" 9 | import { TRANSLATION_KEY_SEPARATOR } from "@src/core/translation/constants/translation.constants" 10 | import type { 11 | ComputedTranslations, 12 | DeepStringRecord, 13 | Formatters, 14 | SerializedTranslationMap, 15 | TranslationMap, 16 | TranslationProperties, 17 | } from "@src/core/translation/types" 18 | 19 | /** 20 | * Transforms a DeepStringRecord into ComputedTranslations. 21 | * Basically it flattens the DeepStringRecord and groups variants together. 22 | */ 23 | export function computeDeepStringRecord( 24 | deepStringRecord: DeepStringRecord, 25 | path = "", 26 | computed: ComputedTranslations = {}, 27 | ) { 28 | for (const [curKey, curValue] of Object.entries(deepStringRecord)) { 29 | if (typeof curValue === "string") { 30 | const { match, range } = VARIANT_PATTERN.match(curKey) || {} 31 | // no variant => default 32 | if (!match?.[1] || !range) { 33 | const key = 34 | `${path}${TRANSLATION_KEY_SEPARATOR}${curKey}`.replace( 35 | /^\./, 36 | "", 37 | ) 38 | 39 | if (computed[key]) { 40 | computed[key]!.default = curValue 41 | continue 42 | } 43 | computed[key] = { 44 | default: curValue, 45 | variants: [], 46 | } 47 | continue 48 | } 49 | // variant 50 | const key = `${path}${TRANSLATION_KEY_SEPARATOR}${ 51 | curKey.slice(0, range[0]) + curKey.slice(range[1]) 52 | }`.replace(/^\./, "") 53 | 54 | if (computed[key]) { 55 | computed[key]!.variants.push( 56 | Variant.fromString(match[1], curValue), 57 | ) 58 | continue 59 | } 60 | computed[key] = { 61 | variants: [Variant.fromString(match[1], curValue)], 62 | } 63 | continue 64 | } 65 | 66 | if (VARIANT_PATTERN.test(curKey)) throw new NonStringVariant() 67 | 68 | computeDeepStringRecord( 69 | curValue, 70 | `${path}${TRANSLATION_KEY_SEPARATOR}${curKey}`.replace(/^\./, ""), 71 | computed, 72 | ) 73 | } 74 | 75 | return computed 76 | } 77 | 78 | /** 79 | * Resolves every interpolation in the given string. 80 | */ 81 | export function interpolate( 82 | translation: string, 83 | properties: TranslationProperties, 84 | formatters: Formatters, 85 | ) { 86 | const results: { value: string; range: [number, number] }[] = [] 87 | 88 | INTERPOLATION_PATTERN.exec(translation, ({ match, range }) => { 89 | if (!match[1]) return 90 | 91 | const { value } = new Interpolation(match[1], properties, formatters) 92 | 93 | results.push({ value: unknowntoString(value), range }) 94 | }) 95 | 96 | let interpolated = translation 97 | 98 | for (let i = results.length - 1; i >= 0; i -= 1) { 99 | const { value, range } = results[i] || never() 100 | interpolated = 101 | interpolated.slice(0, range[0]) + 102 | value + 103 | interpolated.slice(range[1]) 104 | } 105 | 106 | return interpolated 107 | } 108 | 109 | /** 110 | * Finds the best way to represent the unknown value as a string. 111 | */ 112 | export function unknowntoString(value: unknown) { 113 | switch (typeof value) { 114 | case "undefined": { 115 | return "" 116 | } 117 | case "string": { 118 | return value 119 | } 120 | case "bigint": { 121 | return value.toString() 122 | } 123 | case "number": { 124 | return value.toString() 125 | } 126 | case "boolean": { 127 | return value.toString() 128 | } 129 | case "symbol": { 130 | return `Symbol(${value.description})` || "Symbol" 131 | } 132 | case "function": { 133 | return value.name 134 | } 135 | case "object": { 136 | if (!value) return "" // null 137 | if ( 138 | Object.hasOwn(value, "toString") && 139 | typeof value.toString === "function" 140 | ) { 141 | return value.toString() 142 | } 143 | return JSON.stringify(value, null, "\t") 144 | } 145 | default: { 146 | return "" 147 | } 148 | } 149 | } 150 | 151 | export function deserializeTranslationMap( 152 | serialized: SerializedTranslationMap, 153 | ): TranslationMap { 154 | for (const groupLocales of Object.values(serialized)) { 155 | for (const computedTranslations of Object.values(groupLocales)) { 156 | for (const translation of Object.values(computedTranslations)) { 157 | translation.variants = translation.variants.map( 158 | (variant) => new Variant(variant), 159 | ) 160 | } 161 | } 162 | } 163 | 164 | return serialized as TranslationMap 165 | } 166 | -------------------------------------------------------------------------------- /apps/astro/tests/t.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable sonarjs/no-duplicate-string */ 2 | import { expect, test } from "vitest" 3 | import { astroI18n } from "astro-i18n" 4 | 5 | test("Common translations are accessible on every page.", () => { 6 | astroI18n.route = "/" 7 | expect(astroI18n.t("commonBasic")).toBe("en.commonBasic") 8 | expect(astroI18n.t("nested.commonNested")).toBe("en.commonNested") 9 | astroI18n.route = "/fr" 10 | expect(astroI18n.t("commonBasic")).toBe("fr.commonBasic") 11 | expect(astroI18n.t("nested.commonNested")).toBe("fr.commonNested") 12 | 13 | astroI18n.route = "/page" 14 | expect(astroI18n.t("commonBasic")).toBe("en.commonBasic") 15 | astroI18n.route = "/fr/page" 16 | expect(astroI18n.t("commonBasic")).toBe("fr.commonBasic") 17 | }) 18 | 19 | test("Page translations are only accessible on their page.", () => { 20 | astroI18n.route = "/" 21 | expect(astroI18n.t("pageTranslation")).not.toBe("en.pageTranslation") 22 | astroI18n.route = "/fr" 23 | expect(astroI18n.t("pageTranslation")).not.toBe("fr.pageTranslation") 24 | 25 | astroI18n.route = "/page" 26 | expect(astroI18n.t("pageTranslation")).toBe("en.pageTranslation") 27 | astroI18n.route = "/fr/page" 28 | expect(astroI18n.t("pageTranslation")).toBe("fr.pageTranslation") 29 | 30 | astroI18n.route = "/page/param_1" 31 | expect(astroI18n.t("paramTranslation")).toBe("en.paramTranslation") 32 | astroI18n.route = "/fr/page/param_1" 33 | expect(astroI18n.t("paramTranslation")).toBe("fr.paramTranslation") 34 | }) 35 | 36 | test("`t`'s locale override.", () => { 37 | astroI18n.route = "/" 38 | expect(astroI18n.t("commonBasic", undefined, { locale: "fr" })).toBe( 39 | "fr.commonBasic", 40 | ) 41 | }) 42 | 43 | test("`t`'s route override.", () => { 44 | astroI18n.route = "/" 45 | expect(astroI18n.t("pageTranslation", undefined, { route: "/page" })).toBe( 46 | "en.pageTranslation", 47 | ) 48 | }) 49 | 50 | test("Translation variants.", () => { 51 | astroI18n.route = "/" 52 | expect(astroI18n.t("commonVariant")).toBe( 53 | "en.commonVariant (default value)", 54 | ) 55 | expect(astroI18n.t("commonVariant", { n: -2 })).toBe( 56 | "en.commonVariant (n === -2)", 57 | ) 58 | expect(astroI18n.t("commonVariant", { n: -1 })).toBe( 59 | "en.commonVariant (n === -2)", 60 | ) 61 | expect(astroI18n.t("commonVariant", { n: 0 })).toBe( 62 | "en.commonVariant (n === -2)", 63 | ) 64 | expect(astroI18n.t("commonVariant", { n: 1 })).toBe( 65 | "en.commonVariant (n === 2)", 66 | ) 67 | expect(astroI18n.t("commonVariant", { n: 2 })).toBe( 68 | "en.commonVariant (n === 2)", 69 | ) 70 | expect(astroI18n.t("commonVariant", { n: 2, x: "text" })).toBe( 71 | "en.commonVariant (n === 2 && x === 'text')", 72 | ) 73 | expect(astroI18n.t("commonVariant", { n: 3 })).toBe( 74 | "en.commonVariant (n === 3 && $priority === 100)", 75 | ) 76 | expect(astroI18n.t("commonVariant", { n: 4 })).toBe( 77 | "en.commonVariant (n === 4 || n === 'text' || 'n === true')", 78 | ) 79 | expect(astroI18n.t("commonVariant", { n: "text" })).toBe( 80 | "en.commonVariant (n === 4 || n === 'text' || 'n === true')", 81 | ) 82 | expect(astroI18n.t("commonVariant", { n: true })).toBe( 83 | "en.commonVariant (n === 4 || n === 'text' || 'n === true')", 84 | ) 85 | }) 86 | 87 | test("Translation interpolations.", () => { 88 | astroI18n.route = "/" 89 | let value: any = "test" 90 | expect(astroI18n.t("commonInterpolation", { value })).toBe( 91 | 'en.commonInterpolation ("test")', 92 | ) 93 | value = { object: "value" } 94 | expect(astroI18n.t("commonInterpolation", { value })).toBe( 95 | `en.commonInterpolation (${JSON.stringify(value)})`, 96 | ) 97 | expect( 98 | astroI18n.t("commonInterpolationAlias", { value, alias: false }), 99 | ).toBe(`en.commonInterpolation (${JSON.stringify(value)})`) 100 | expect( 101 | astroI18n.t("commonInterpolationChained", { value, alias: false }), 102 | ).toBe(`en.commonInterpolation (${JSON.stringify(value).toUpperCase()})`) 103 | value = 69 104 | expect( 105 | astroI18n.t("commonInterpolationCurrency", { 106 | value, 107 | currencyCode: "EUR", 108 | }), 109 | ).toBe(`en.commonInterpolation (${value},00${String.fromCodePoint(160)}€)`) 110 | }) 111 | 112 | test("Translation load directives.", () => { 113 | astroI18n.route = "/" 114 | expect(astroI18n.t("groupTranslation1")).not.toBe("en.groupTranslation1") 115 | expect(astroI18n.t("groupTranslation2")).not.toBe("en.groupTranslation2") 116 | astroI18n.route = "/group" 117 | expect(astroI18n.t("groupTranslation1")).toBe("en.groupTranslation1") 118 | expect(astroI18n.t("groupTranslation2")).toBe("en.groupTranslation2") 119 | astroI18n.route = "/group/inner" 120 | expect(astroI18n.t("groupTranslation1")).not.toBe("en.groupTranslation1") 121 | expect(astroI18n.t("groupTranslation2")).toBe("en.groupTranslation2") 122 | }) 123 | 124 | test("Fallback locale.", () => { 125 | astroI18n.route = "/" 126 | expect(astroI18n.t("commonFallback", undefined, { locale: "fr" })).toBe( 127 | "en.commonFallback", 128 | ) 129 | }) 130 | -------------------------------------------------------------------------------- /astro-i18n/src/core/page/classes/page.class.ts: -------------------------------------------------------------------------------- 1 | import AsyncNode from "@lib/async-node/classes/async-node.class" 2 | import { 3 | FRONTMATTER_PATTERN, 4 | GET_STATIC_PATHS_EXPORT_PATTERN, 5 | PRERENDER_EXPORT_PATTERN, 6 | } from "@src/core/page/constants/page-patterns.constants" 7 | import type { PageProps } from "@src/core/page/types" 8 | import type AstroI18n from "@src/core/state/classes/astro-i18n.class" 9 | import type { DeepStringRecord } from "@src/core/translation/types" 10 | 11 | class Page implements PageProps { 12 | #name: string 13 | 14 | #route: string 15 | 16 | #path: string 17 | 18 | // page translations 19 | #translations: { [locale: string]: DeepStringRecord } 20 | 21 | // page segment translations 22 | #routes: { [secondaryLocale: string]: { [segment: string]: string } } 23 | 24 | #hasGetStaticPaths: boolean | undefined = undefined 25 | 26 | #prerender: boolean | null | undefined = undefined 27 | 28 | #content: string | null = null 29 | 30 | #frontmatter: string | null = null 31 | 32 | constructor({ name, route, path, translations, routes }: PageProps) { 33 | this.#name = name 34 | this.#route = route 35 | this.#path = path 36 | this.#translations = translations 37 | this.#routes = routes 38 | } 39 | 40 | get name() { 41 | return this.#name 42 | } 43 | 44 | get route() { 45 | return this.#route 46 | } 47 | 48 | get path() { 49 | return this.#path 50 | } 51 | 52 | get translations() { 53 | return this.#translations 54 | } 55 | 56 | get routes() { 57 | return this.#routes 58 | } 59 | 60 | async getContent() { 61 | if (this.#content) return this.#content 62 | const { readFileSync } = await AsyncNode.fs 63 | this.#content = readFileSync(this.#path, { encoding: "utf8" }) 64 | this.#frontmatter = 65 | FRONTMATTER_PATTERN.match(this.#content)?.match[0] || null 66 | return this.#content 67 | } 68 | 69 | async getFrontmatter() { 70 | if (this.#frontmatter) return this.#frontmatter 71 | this.getContent() 72 | return this.#frontmatter 73 | } 74 | 75 | async hasGetStaticPaths() { 76 | if (typeof this.#hasGetStaticPaths !== "undefined") { 77 | return this.#hasGetStaticPaths 78 | } 79 | const { readFileSync } = await AsyncNode.fs 80 | const data = readFileSync(this.#path, { encoding: "utf8" }) 81 | const frontmatter = FRONTMATTER_PATTERN.match(data)?.match[0] 82 | this.#hasGetStaticPaths = frontmatter 83 | ? GET_STATIC_PATHS_EXPORT_PATTERN.test(frontmatter) 84 | : false 85 | return this.#hasGetStaticPaths 86 | } 87 | 88 | async prerender() { 89 | if (typeof this.#prerender !== "undefined") { 90 | return this.#prerender 91 | } 92 | const { readFileSync } = await AsyncNode.fs 93 | const data = readFileSync(this.#path, { encoding: "utf8" }) 94 | const frontmatter = FRONTMATTER_PATTERN.match(data)?.match[0] 95 | if (!frontmatter) { 96 | this.#prerender = false 97 | return this.#prerender 98 | } 99 | const { match } = PRERENDER_EXPORT_PATTERN.match(frontmatter) || {} 100 | if (!match) this.#prerender = null // no prerender 101 | else if (match[1]) this.#prerender = match[1] === "true" // assignation 102 | else this.#prerender = true // no assignation (default to true) 103 | return this.#prerender 104 | } 105 | 106 | /** 107 | * @param route The translated route for which we are making the proxy. 108 | */ 109 | async getProxy(route: string, astroI18n: AstroI18n) { 110 | let srcPagesEndIndex: number | null = this.path.lastIndexOf("src/pages") 111 | srcPagesEndIndex = 112 | srcPagesEndIndex < 0 ? null : srcPagesEndIndex + "src/pages".length 113 | if (!srcPagesEndIndex) return null 114 | let proxy = "" 115 | 116 | route = route.replace(/\/$/, "") || "/" 117 | 118 | // import page 119 | const pathFromPages = this.#path.slice(srcPagesEndIndex) 120 | const depth = route.split("/").length - 1 121 | const importPath = `${"../".repeat(depth)}${pathFromPages.slice(1)}` 122 | proxy += `---\nimport Page from "${importPath}"\n` 123 | 124 | // export getStaticPaths 125 | if (await this.hasGetStaticPaths()) { 126 | const { locale, route: localessRoute } = 127 | astroI18n.internals.splitLocaleAndRoute(route) 128 | proxy += `import { getStaticPaths as proxyGetStaticPaths } from "${importPath}"\n/* @ts-ignore */\nexport const getStaticPaths = (props) => proxyGetStaticPaths({ ...props, astroI18n: ` 129 | proxy += `{ locale: "${locale}", route: "${localessRoute}", primaryLocale: "${ 130 | astroI18n.primaryLocale 131 | }", secondaryLocales: ["${astroI18n.secondaryLocales.join( 132 | '", "', 133 | )}"] } })\n` 134 | } 135 | 136 | // export prerender 137 | const prerender = await this.prerender() 138 | if (typeof prerender === "boolean") { 139 | proxy += prerender 140 | ? "export const prerender = true\n" 141 | : "export const prerender = false\n" 142 | } 143 | 144 | proxy += "const { props } = Astro\n---\n" 145 | 146 | return proxy 147 | } 148 | } 149 | 150 | export default Page 151 | -------------------------------------------------------------------------------- /astro-i18n/src/core/cli/commands/extract-keys.command.ts: -------------------------------------------------------------------------------- 1 | import { importJson } from "@lib/async-node/functions/import.functions" 2 | import { assert } from "@lib/ts/guards" 3 | import { never } from "@lib/error" 4 | import { merge, setObjectProperty } from "@lib/object" 5 | import { 6 | isDirectory, 7 | isFile, 8 | writeNestedFile, 9 | } from "@lib/async-node/functions/fs.functions" 10 | import AsyncNode from "@lib/async-node/classes/async-node.class" 11 | import { toPosixPath } from "@lib/async-node/functions/path.functions" 12 | import InvalidCommand from "@src/core/cli/errors/invalid-command.error" 13 | import RootNotFound from "@src/core/config/errors/root-not-found.error" 14 | import { astroI18n } from "@src/core/state/singletons/astro-i18n.singleton" 15 | import { isDeepStringRecord } from "@src/core/translation/guards/deep-string-record.guard" 16 | import { getProjectPages } from "@src/core/page/functions/page.functions" 17 | import { TRANSLATION_FUNCTION_PATTERN } from "@src/core/cli/constants/cli-patterns.constants" 18 | import { 19 | DEFAULT_TRANSLATION_DIRNAME, 20 | PAGES_DIRNAME, 21 | } from "@src/constants/app.constants" 22 | import type { Command, ParsedArgv } from "@lib/argv/types" 23 | import type { FlatConfigTranslations } from "@src/core/cli/types" 24 | import type { DeepStringRecord } from "@src/core/translation/types" 25 | 26 | const cmd = { 27 | name: "extract", 28 | options: ["root"], 29 | } as const satisfies Command 30 | 31 | export async function extract({ command, options }: ParsedArgv) { 32 | if (command !== cmd.name) throw new InvalidCommand() 33 | const { join } = await AsyncNode.path 34 | 35 | const root = await toPosixPath( 36 | typeof options["root"] === "string" ? options["root"] : process.cwd(), 37 | ) 38 | if (!(await isDirectory(root))) throw new RootNotFound() 39 | 40 | await astroI18n.initialize() 41 | 42 | const groupTranslations: FlatConfigTranslations = {} 43 | const pageTranslations: FlatConfigTranslations = {} 44 | 45 | // extracting translations from pages 46 | const pages = await getProjectPages(root, astroI18n.internals.config) 47 | for (const page of pages) { 48 | TRANSLATION_FUNCTION_PATTERN.exec( 49 | await page.getContent(), 50 | ({ match }) => { 51 | const key = match[2] 52 | if (!key) return 53 | 54 | let translations = groupTranslations 55 | let group = match[1] || "" 56 | if (match[1]) { 57 | group = match[1].replace(/^#/, "").replace(/#$/, "") 58 | } else { 59 | translations = pageTranslations 60 | group = page.route 61 | } 62 | 63 | // primary locale 64 | setObjectProperty( 65 | translations, 66 | [group || never(), astroI18n.primaryLocale, key], 67 | key, 68 | ) 69 | // secondary locales 70 | for (const secondaryLocale of astroI18n.secondaryLocales) { 71 | setObjectProperty( 72 | translations, 73 | [group || never(), secondaryLocale, key], 74 | `${key}`, 75 | ) 76 | } 77 | }, 78 | ) 79 | } 80 | 81 | const directories = { 82 | i18n: DEFAULT_TRANSLATION_DIRNAME, 83 | pages: DEFAULT_TRANSLATION_DIRNAME, 84 | ...astroI18n.internals.config.translationDirectory, 85 | } 86 | const translationDirectory = join( 87 | root, 88 | astroI18n.internals.config.srcDir, 89 | directories.i18n, 90 | ) 91 | 92 | // filling main i18n dir group translations 93 | for (const [group, locales] of Object.entries(groupTranslations)) { 94 | for (const [locale, translations] of Object.entries(locales)) { 95 | const path = join(translationDirectory, group, `${locale}.json`) 96 | const imported: DeepStringRecord = (await isFile(path)) 97 | ? await importDeepStringRecord(path) 98 | : {} 99 | merge(imported, translations, { mode: "fill" }) 100 | writeNestedFile(path, JSON.stringify(imported, null, "\t")) 101 | } 102 | } 103 | 104 | // filling main i18n dir page translations (group === page.route) 105 | for (const [group, locales] of Object.entries(pageTranslations)) { 106 | for (const [locale, translations] of Object.entries(locales)) { 107 | const directory = join( 108 | translationDirectory, 109 | PAGES_DIRNAME, 110 | ...group.replace(/^\//, "").replace(/\/$/, "").split("/"), 111 | ) 112 | const [flatPath, nestedPath] = [ 113 | join(directory, `${locale}.json`), 114 | join(directory, directories.pages, `${locale}.json`), 115 | ] 116 | let path = (await isFile(nestedPath)) ? nestedPath : "" 117 | if (!path) path = (await isFile(flatPath)) ? flatPath : "" 118 | 119 | const imported: DeepStringRecord = path 120 | ? await importDeepStringRecord(path) 121 | : {} 122 | merge(imported, translations, { mode: "fill" }) 123 | writeNestedFile( 124 | path || nestedPath, // if no path was found use nested one 125 | JSON.stringify(imported, null, "\t"), 126 | ) 127 | } 128 | } 129 | } 130 | 131 | async function importDeepStringRecord(filename: string) { 132 | const json = await importJson(filename) 133 | assert(json, isDeepStringRecord) 134 | return json 135 | } 136 | 137 | export default cmd 138 | -------------------------------------------------------------------------------- /astro-i18n/src/core/translation/classes/variant.class.ts: -------------------------------------------------------------------------------- 1 | import InvalidVariantPriority from "@src/core/translation/errors/variant/invalid-variant-priority.error" 2 | import InvalidVariantPropertyKey from "@src/core/translation/errors/variant/invalid-variant-property-key.error" 3 | import InvalidVariantPropertyValue from "@src/core/translation/errors/variant/invalid-variant-property-value.error" 4 | import { depthAwareforEach } from "@src/core/translation/functions/parsing.functions" 5 | import { matchVariable } from "@src/core/translation/functions/matching.functions" 6 | import { matchVariantValue } from "@src/core/translation/functions/variant/variant-matching.functions" 7 | import { isPrimitiveArray } from "@src/core/translation/guards/primitive.guard" 8 | import { parseVariantValue } from "@src/core/translation/functions/variant/variant-parsing.functions" 9 | import { VARIANT_PRIORITY_KEY } from "@src/core/translation/constants/variant.constants" 10 | import type { 11 | TranslationProperties, 12 | VariantProperties, 13 | VariantProperty, 14 | } from "@src/core/translation/types" 15 | 16 | class Variant { 17 | raw 18 | 19 | priority 20 | 21 | properties: VariantProperty[] 22 | 23 | value 24 | 25 | constructor({ raw, priority, properties, value }: VariantProperties = {}) { 26 | this.raw = raw || "" 27 | this.priority = priority || 0 28 | this.properties = properties || [] 29 | this.value = value || "" 30 | } 31 | 32 | /** 33 | * @param variant The variant part of the translation, for example for a 34 | * variant string `"{{ prop1: true }}"` only `"prop1: true"` should be 35 | * passed. 36 | * @param value The translation value, this will only be stored for later 37 | * retrieval. 38 | */ 39 | static fromString(variant: string, value: string) { 40 | const translationVariant = new Variant() 41 | 42 | translationVariant.value = value 43 | 44 | const trimmed = variant.trim() 45 | translationVariant.raw = trimmed 46 | 47 | // checking properties 48 | let propKey = "" 49 | let propValue = "" 50 | let isKey = true 51 | depthAwareforEach(trimmed, (char, i, depth) => { 52 | const isLast = i === trimmed.length - 1 53 | 54 | if (isKey) { 55 | if (/\s/.test(char)) return null 56 | if (char === ":") { 57 | isKey = false 58 | return null 59 | } 60 | propKey += char 61 | return null 62 | } 63 | 64 | if (depth > 0) { 65 | // string or array 66 | propValue += char 67 | return null 68 | } 69 | 70 | if (isLast) propValue += char 71 | 72 | if (char === "," || isLast) { 73 | // key & value are filled, matching them... 74 | const matchedKey = matchVariable(propKey.trim())?.match[0] 75 | if (!matchedKey) throw new InvalidVariantPropertyKey(propKey) 76 | const { value: matchedValue, type } = matchVariantValue( 77 | propValue.trim(), 78 | ) 79 | 80 | // checking for priority key 81 | if (matchedKey === VARIANT_PRIORITY_KEY) { 82 | const priority = parseVariantValue(matchedValue, type) 83 | if (typeof priority !== "number") { 84 | throw new InvalidVariantPriority(matchedValue) 85 | } 86 | translationVariant.priority = priority * 0.001 87 | return null 88 | } 89 | 90 | // parsing values 91 | const parsedValue = parseVariantValue(matchedValue, type) 92 | const values: unknown[] = Array.isArray(parsedValue) 93 | ? parsedValue 94 | : [parsedValue] 95 | 96 | if (!isPrimitiveArray(values)) { 97 | throw new InvalidVariantPropertyValue(propValue) 98 | } 99 | 100 | translationVariant.properties.push({ 101 | name: matchedKey, 102 | values, 103 | }) 104 | 105 | propKey = "" 106 | propValue = "" 107 | isKey = true 108 | return null 109 | } 110 | 111 | propValue += char 112 | return null 113 | }) 114 | 115 | return translationVariant 116 | } 117 | 118 | /** 119 | * Calculates the variant's matching score for the given properties. 120 | */ 121 | calculateMatchingScore(properties: TranslationProperties) { 122 | let score = 0 123 | 124 | for (const { name, values } of this.properties) { 125 | if (!Object.hasOwn(properties, name)) continue 126 | const property = properties[name] 127 | 128 | const valueScores: number[] = [] 129 | for (const value of values) { 130 | if (typeof property === "number" && typeof value === "number") { 131 | const difference = property - value 132 | if (difference === 0) { 133 | valueScores.push(1000) 134 | continue 135 | } 136 | // we remove 1 because `difference === 1` would give same score as `difference === 0` 137 | valueScores.push(Math.abs(1000 / difference) - 1) 138 | continue 139 | } 140 | if (property === value) { 141 | valueScores.push(1000) 142 | } 143 | } 144 | if (valueScores.length === 0) continue 145 | 146 | score += Math.max(...valueScores) 147 | } 148 | 149 | // priority only applies on matches 150 | return score > 0 ? score + this.priority : score 151 | } 152 | } 153 | 154 | export default Variant 155 | -------------------------------------------------------------------------------- /astro-i18n/src/core/config/classes/config.class.ts: -------------------------------------------------------------------------------- 1 | import { popPath, toPosixPath } from "@lib/async-node/functions/path.functions" 2 | import { never } from "@lib/error" 3 | import { merge } from "@lib/object" 4 | import { assert } from "@lib/ts/guards" 5 | import { getProjectPages } from "@src/core/page/functions/page.functions" 6 | import RootNotFound from "@src/core/config/errors/root-not-found.error" 7 | import { 8 | autofindAstroI18nConfig, 9 | autofindProjectRoot, 10 | getProjectTranslationGroups, 11 | hasAstroConfig, 12 | } from "@src/core/config/functions/config.functions" 13 | import AsyncNode from "@lib/async-node/classes/async-node.class" 14 | import ConfigNotFound from "@src/core/config/errors/config-not-found.error" 15 | import { 16 | importJson, 17 | importScript, 18 | } from "@lib/async-node/functions/import.functions" 19 | import { isPartialConfig } from "@src/core/config/guards/config.guard" 20 | import MixedPrimarySecondary from "@src/core/config/errors/mixed-primary-secondary.error" 21 | import type { 22 | AstroI18nConfig, 23 | ConfigTranslationLoadingRules, 24 | ConfigRoutes, 25 | ConfigTranslationDirectory, 26 | ConfigTranslations, 27 | SerializedConfig, 28 | } from "@src/core/config/types" 29 | 30 | class Config implements AstroI18nConfig { 31 | primaryLocale 32 | 33 | secondaryLocales: string[] 34 | 35 | fallbackLocale 36 | 37 | showPrimaryLocale 38 | 39 | trailingSlash: "always" | "never" 40 | 41 | run: "server" | "client+server" 42 | 43 | translations: ConfigTranslations 44 | 45 | translationLoadingRules: ConfigTranslationLoadingRules 46 | 47 | translationDirectory: ConfigTranslationDirectory 48 | 49 | routes: ConfigRoutes 50 | 51 | path: string 52 | 53 | srcDir: string 54 | 55 | constructor( 56 | { 57 | primaryLocale, 58 | secondaryLocales, 59 | fallbackLocale, 60 | showPrimaryLocale, 61 | trailingSlash, 62 | run, 63 | translations, 64 | translationLoadingRules, 65 | translationDirectory, 66 | routes, 67 | srcDir, 68 | }: Partial = {}, 69 | path = "", 70 | ) { 71 | this.primaryLocale = primaryLocale || "en" 72 | this.secondaryLocales = secondaryLocales || [] 73 | this.fallbackLocale = fallbackLocale ?? (primaryLocale || "") 74 | this.showPrimaryLocale = showPrimaryLocale || false 75 | this.trailingSlash = trailingSlash || "never" 76 | this.run = run || "client+server" 77 | this.translations = translations || {} 78 | this.translationLoadingRules = translationLoadingRules || [] 79 | this.translationDirectory = translationDirectory || {} 80 | this.routes = routes || {} 81 | this.path = path || "" 82 | this.srcDir = srcDir || "src" 83 | 84 | if (this.secondaryLocales.includes(this.primaryLocale)) { 85 | throw new MixedPrimarySecondary(this.primaryLocale) 86 | } 87 | } 88 | 89 | get pages() { 90 | return Object.keys(this.translations).filter((group) => 91 | group.startsWith("/"), 92 | ) 93 | } 94 | 95 | static async fromFilesystem(configPath: string | null = null) { 96 | const { fileURLToPath } = await AsyncNode.url 97 | 98 | // find from PWD 99 | if (!configPath) { 100 | let pwd = "" 101 | 102 | if (typeof process !== "undefined") { 103 | pwd = process.env["PWD"] || "" 104 | } 105 | 106 | configPath = await autofindAstroI18nConfig(await toPosixPath(pwd)) 107 | } 108 | 109 | // find from CWD 110 | if (!configPath) { 111 | let cwd = "" 112 | 113 | if (typeof process !== "undefined") { 114 | cwd = process.cwd() 115 | } 116 | 117 | configPath = await autofindAstroI18nConfig(await toPosixPath(cwd)) 118 | } 119 | 120 | // find from current module 121 | if (!configPath) { 122 | let filename = "" 123 | 124 | if ( 125 | typeof import.meta === "object" && 126 | typeof import.meta.url === "string" 127 | ) { 128 | filename = fileURLToPath(import.meta.url) 129 | } else if (typeof __filename === "string") { 130 | filename = __filename 131 | } 132 | 133 | if (filename) { 134 | configPath = await autofindAstroI18nConfig( 135 | await toPosixPath(filename), 136 | ) 137 | } 138 | } 139 | 140 | if (!configPath) throw new ConfigNotFound() 141 | 142 | const partialConfig = configPath.endsWith(".json") 143 | ? await importJson(configPath) 144 | : (await importScript(configPath))["default"] 145 | 146 | assert(partialConfig, isPartialConfig, "AstroI18nConfig") 147 | 148 | return new Config( 149 | partialConfig, 150 | configPath, 151 | ).loadFilesystemTranslations() 152 | } 153 | 154 | /** 155 | * Loads all translations & routes from the filesystem and merges them into 156 | * the config 157 | */ 158 | async loadFilesystemTranslations() { 159 | if (!this.path) return this 160 | 161 | // find project root 162 | let root = await popPath(this.path) 163 | if (!(await hasAstroConfig(root))) { 164 | const found = await autofindProjectRoot(this.path) 165 | if (!found) throw new RootNotFound() 166 | root = found 167 | } 168 | 169 | const pages = await getProjectPages(root, this) 170 | // merging page translations & routes to the config 171 | for (const page of pages) { 172 | if (!this.translations[page.route]) { 173 | this.translations[page.route] = {} 174 | } 175 | merge(this.translations[page.route] || never(), page.translations) 176 | if (!this.routes) this.routes = {} 177 | merge(this.routes, page.routes) 178 | } 179 | 180 | const groups = await getProjectTranslationGroups(root, this) 181 | // merging translation groups to the config 182 | merge(this.translations, groups) 183 | 184 | return this 185 | } 186 | 187 | toClientSideObject() { 188 | return { 189 | primaryLocale: this.primaryLocale, 190 | secondaryLocales: this.secondaryLocales, 191 | showPrimaryLocale: this.showPrimaryLocale, 192 | trailingSlash: this.trailingSlash, 193 | } as SerializedConfig 194 | } 195 | 196 | toObject() { 197 | return { 198 | primaryLocale: this.primaryLocale, 199 | secondaryLocales: this.secondaryLocales, 200 | fallbackLocale: this.fallbackLocale, 201 | showPrimaryLocale: this.showPrimaryLocale, 202 | trailingSlash: this.trailingSlash, 203 | run: this.run, 204 | translations: this.translations, 205 | translationLoadingRules: this.translationLoadingRules, 206 | translationDirectory: this.translationDirectory, 207 | routes: this.routes, 208 | } 209 | } 210 | 211 | toString() { 212 | return JSON.stringify(this.toObject(), null, "\t") 213 | } 214 | } 215 | 216 | export default Config 217 | -------------------------------------------------------------------------------- /astro-i18n/src/core/cli/commands/install.command.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | import { isRecord } from "@lib/ts/guards" 3 | import { merge } from "@lib/object" 4 | import { importJson } from "@lib/async-node/functions/import.functions" 5 | import { RegexBuilder } from "@lib/regex" 6 | import { toPosixPath } from "@lib/async-node/functions/path.functions" 7 | import AsyncNode from "@lib/async-node/classes/async-node.class" 8 | import { 9 | isDirectory, 10 | isFile, 11 | writeNestedFile, 12 | } from "@lib/async-node/functions/fs.functions" 13 | import InvalidCommand from "@src/core/cli/errors/invalid-command.error" 14 | import RootNotFound from "@src/core/config/errors/root-not-found.error" 15 | import { ASTRO_I18N_CONFIG_PATTERN } from "@src/core/config/constants/config-patterns.constants" 16 | import { 17 | INDEX_FILENAME_PATTERN, 18 | MIDDLEWARE_FILENAME_PATTERN, 19 | } from "@src/core/cli/constants/cli-patterns.constants" 20 | import { PACKAGE_NAME } from "@src/constants/meta.constants" 21 | import type { Command, ParsedArgv } from "@lib/argv/types" 22 | import { COMMON_TRANSLATIONS_GROUP } from "@src/core/translation/constants/translation.constants" 23 | import { DEFAULT_TRANSLATION_DIRNAME } from "@src/constants/app.constants" 24 | 25 | const cmd = { 26 | name: "install", 27 | options: ["root", "serverless"], 28 | } as const satisfies Command 29 | 30 | export async function install({ command, options }: ParsedArgv) { 31 | if (command !== cmd.name) throw new InvalidCommand() 32 | const { writeFileSync } = await AsyncNode.fs 33 | const { join } = await AsyncNode.path 34 | 35 | const root = await toPosixPath( 36 | typeof options["root"] === "string" ? options["root"] : process.cwd(), 37 | ) 38 | if (!(await isDirectory(root))) throw new RootNotFound() 39 | 40 | const isTypescript = await hasTsconfig(root) 41 | 42 | // add commands in package.json 43 | const pkgPath = join(root, "package.json") 44 | const pkg = (await isFile(pkgPath)) ? await importJson(pkgPath) : null 45 | if (pkg && isRecord(pkg)) { 46 | merge(pkg, { 47 | scripts: { 48 | "i18n:extract": "astro-i18n extract", 49 | "i18n:generate:pages": "astro-i18n generate:pages --purge", 50 | "i18n:generate:types": "astro-i18n generate:types", 51 | "i18n:sync": 52 | "npm run i18n:generate:pages && npm run i18n:generate:types", 53 | }, 54 | }) 55 | writeFileSync(pkgPath, `${JSON.stringify(pkg, null, "\t")}\n`) 56 | } 57 | 58 | await (options["serverless"] 59 | ? installServerless(root, isTypescript) 60 | : installNode(root, isTypescript)) 61 | } 62 | 63 | /** 64 | * Node 65 | */ 66 | async function installNode(root: string, isTypescript: boolean) { 67 | const { writeFileSync, readdirSync } = await AsyncNode.fs 68 | const { join } = await AsyncNode.path 69 | 70 | const astroI18nConfigPath = await getAstroI18nConfigPath(root) 71 | 72 | // create default config file 73 | if (!astroI18nConfigPath) { 74 | writeFileSync( 75 | join(root, `${PACKAGE_NAME}.config.${isTypescript ? "ts" : "js"}`), 76 | ` 77 | import { defineAstroI18nConfig } from "astro-i18n" 78 | 79 | export default defineAstroI18nConfig({ 80 | primaryLocale: "en", // default app locale 81 | secondaryLocales: [], // other supported locales 82 | fallbackLocale: "en", // fallback locale (on missing translation) 83 | trailingSlash: "never", // "never" or "always" 84 | run: "client+server", // "client+server" or "server" 85 | showPrimaryLocale: false, // "/en/about" vs "/about" 86 | translationLoadingRules: [], // per page group loading 87 | translationDirectory: {}, // translation directory names 88 | translations: {}, // { [translation_group1]: { [locale1]: {}, ... } } 89 | routes: {}, // { [secondary_locale1]: { about: "about-translated", ... } } 90 | }) 91 | `.trim(), 92 | ) 93 | } 94 | 95 | // add default middleware 96 | if (!(await getMiddlewarePath(root))) { 97 | writeNestedFile( 98 | join( 99 | root, 100 | "src", 101 | "middleware", 102 | `index.${isTypescript ? "ts" : "js"}`, 103 | ), 104 | ` 105 | import { sequence } from "astro/middleware" 106 | import { useAstroI18n } from "astro-i18n" 107 | 108 | const astroI18n = useAstroI18n( 109 | undefined /* config */, 110 | undefined /* custom formatters */, 111 | ) 112 | 113 | export const onRequest = sequence(astroI18n) 114 | `.trim(), 115 | ) 116 | } 117 | 118 | // creating common translation directory 119 | const i18nDir = join(root, "src", DEFAULT_TRANSLATION_DIRNAME) 120 | let hasCommonDir = false 121 | for (const content of readdirSync(root)) { 122 | const path = join(root, content) 123 | if (!(await isDirectory(path))) continue 124 | if (content === "node_modules") continue 125 | for (const filename of readdirSync(path)) { 126 | if ( 127 | filename === COMMON_TRANSLATIONS_GROUP && 128 | (await isDirectory(filename)) 129 | ) { 130 | hasCommonDir = true 131 | break 132 | } 133 | } 134 | } 135 | if (!(await isDirectory(i18nDir)) && !hasCommonDir) { 136 | writeNestedFile( 137 | join(i18nDir, COMMON_TRANSLATIONS_GROUP, "en.json"), 138 | `${JSON.stringify( 139 | { 140 | your_common: "translations here", 141 | they: { can: "be nested" }, 142 | }, 143 | null, 144 | "\t", 145 | )}\n`, 146 | ) 147 | } 148 | } 149 | 150 | /** 151 | * Serverless 152 | */ 153 | async function installServerless(root: string, isTypescript: boolean) { 154 | const { writeFileSync } = await AsyncNode.fs 155 | const { join } = await AsyncNode.path 156 | 157 | const astroI18nConfigPath = await getAstroI18nConfigPath(root) 158 | 159 | // create default config file 160 | if (!astroI18nConfigPath) { 161 | writeFileSync( 162 | join(root, `${PACKAGE_NAME}.config.${isTypescript ? "ts" : "js"}`), 163 | ` 164 | import { defineAstroI18nConfig } from "astro-i18n" 165 | 166 | export default defineAstroI18nConfig({ 167 | primaryLocale: "en", // default app locale 168 | secondaryLocales: [], // other supported locales 169 | fallbackLocale: "en", // fallback locale (on missing translation) 170 | trailingSlash: "never", // "never" or "always" 171 | run: "client+server", // "client+server" or "server" 172 | showPrimaryLocale: false, // "/en/about" vs "/about" 173 | translationLoadingRules: [], // per page group loading 174 | translationDirectory: {}, // translation directory names 175 | translations: {}, // { [translation_group1]: { [locale1]: {}, ... } } 176 | routes: {}, // { [secondary_locale1]: { about: "about-translated", ... } } 177 | }) 178 | `.trim(), 179 | ) 180 | } 181 | 182 | // add default middleware 183 | if (!(await getMiddlewarePath(root))) { 184 | writeNestedFile( 185 | join( 186 | root, 187 | "src", 188 | "middleware", 189 | `index.${isTypescript ? "ts" : "js"}`, 190 | ), 191 | ` 192 | import { sequence } from "astro/middleware" 193 | import { useAstroI18n } from "astro-i18n" 194 | import astroI18nConfig from "../../astro-i18n.config" 195 | 196 | const astroI18n = useAstroI18n( 197 | astroI18nConfig, 198 | undefined /* custom formatters */, 199 | ) 200 | 201 | export const onRequest = sequence(astroI18n) 202 | `.trim(), 203 | ) 204 | } 205 | } 206 | 207 | async function getMiddlewarePath(root: string) { 208 | // src/middleware.js|ts (Alternatively, you can create src/middleware/index.js|ts.) 209 | const { readdirSync } = await AsyncNode.fs 210 | const { join } = await AsyncNode.path 211 | 212 | const src = join(root, "src") 213 | if (!(await isDirectory(src))) return null 214 | 215 | for (const filename of readdirSync(src)) { 216 | const path = join(src, filename) 217 | // flat 218 | if (MIDDLEWARE_FILENAME_PATTERN.test(filename)) { 219 | return path 220 | } 221 | // nested 222 | if (filename === "middleware" && (await isDirectory(filename))) { 223 | for (const middlewareFilename of readdirSync(path)) { 224 | if (INDEX_FILENAME_PATTERN.test(filename)) { 225 | return join(src, "middleware", middlewareFilename) 226 | } 227 | } 228 | } 229 | } 230 | return null 231 | } 232 | 233 | async function getAstroI18nConfigPath(root: string) { 234 | const { readdirSync } = await AsyncNode.fs 235 | const pattern = RegexBuilder.fromRegex(ASTRO_I18N_CONFIG_PATTERN) 236 | .assertEnding() 237 | .build() 238 | const content = readdirSync(root) 239 | for (const filename of content) { 240 | if (!(await isFile(filename))) continue 241 | if (pattern.test(filename)) return filename 242 | } 243 | return null 244 | } 245 | 246 | async function hasTsconfig(root: string) { 247 | const { join } = await AsyncNode.path 248 | return isFile(join(root, "tsconfig.json")) 249 | } 250 | 251 | export default cmd 252 | -------------------------------------------------------------------------------- /astro-i18n/src/core/page/functions/page.functions.ts: -------------------------------------------------------------------------------- 1 | import AsyncNode from "@lib/async-node/classes/async-node.class" 2 | import { importJson } from "@lib/async-node/functions/import.functions" 3 | import { throwError, never } from "@lib/error" 4 | import { merge } from "@lib/object" 5 | import { assert } from "@lib/ts/guards" 6 | import { Regex } from "@lib/regex" 7 | import { 8 | forEachDirectory, 9 | isDirectory, 10 | } from "@lib/async-node/functions/fs.functions" 11 | import PagesNotFound from "@src/core/page/errors/pages-not-found.error" 12 | import { ASTRO_COMPONENT_ROUTE_NAME_PATTERN } from "@src/core/page/constants/page-patterns.constants" 13 | import Page from "@src/core/page/classes/page.class" 14 | import InvalidTranslationFilePattern from "@src/core/page/errors/invalid-translation-file-pattern.error" 15 | import { isDeepStringRecord } from "@src/core/translation/guards/deep-string-record.guard" 16 | import { 17 | DEFAULT_TRANSLATION_DIRNAME, 18 | PAGES_DIRNAME, 19 | } from "@src/constants/app.constants" 20 | import type { PageProps } from "@src/core/page/types" 21 | import type { AstroI18nConfig } from "@src/core/config/types" 22 | 23 | /** 24 | * Fetches all the pages and their translations from the project. 25 | * Looks in `"src/pages/locale.json"` or `"src/pages/i18n/locale.json"` or 26 | * `"src/i18n/pages/locale.json"` or `"src/i18n/pages/i18n/locale.json"`. 27 | */ 28 | export async function getProjectPages( 29 | projectRoot: string, 30 | config: Partial = {}, 31 | ) { 32 | const { join } = await AsyncNode.path 33 | const pagesDir = `${projectRoot}/${config.srcDir}/${PAGES_DIRNAME}` 34 | if (!(await isDirectory(pagesDir))) throw new PagesNotFound() 35 | 36 | const pageData: { [route: string]: Partial } = {} 37 | const secondaryLocalePaths = (config.secondaryLocales || []).map( 38 | (locale) => `/${config.srcDir}/${PAGES_DIRNAME}/${locale}`, 39 | ) 40 | const $directory: AstroI18nConfig["translationDirectory"] = { 41 | i18n: DEFAULT_TRANSLATION_DIRNAME, 42 | pages: DEFAULT_TRANSLATION_DIRNAME, 43 | ...config.translationDirectory, 44 | } 45 | const locales = [ 46 | config.primaryLocale || "en", 47 | ...(config.secondaryLocales || []), 48 | ] 49 | const translationFilePattern = Regex.fromString( 50 | `(\\/[^\\/\\s]+)?(?:\\/_?${$directory.pages})?\\/_?(${locales.join( 51 | "|", 52 | )})(\\.[^\\.\\s]+)?\\.json$`, 53 | ) 54 | const pageTranslationDirPattern = Regex.fromString(`_?${$directory.pages}`) 55 | 56 | const primaryLocaleDir = config.showPrimaryLocale 57 | ? join(pagesDir, config.primaryLocale || "en") 58 | : pagesDir 59 | 60 | // get all pages and their translations in the pages directory 61 | await forEachDirectory(primaryLocaleDir, async (dir, contents) => { 62 | if (secondaryLocalePaths.some((path) => dir.includes(path))) { 63 | return 64 | } 65 | for (const content of contents) { 66 | const path = `${dir}/${content}` 67 | if (await isDirectory(path)) continue 68 | 69 | const relative = path.replace(pagesDir, "") // pages based path 70 | 71 | // component 72 | if (relative.endsWith(".astro")) { 73 | const { match, range } = 74 | ASTRO_COMPONENT_ROUTE_NAME_PATTERN.match(relative) || {} 75 | if (!match || !match[2] || !range) continue 76 | 77 | let name = "index" 78 | let route = "/" 79 | 80 | // root page 81 | if (!match[1]) { 82 | if (match[2] !== "/index") { 83 | name = match[2].replace("/", "") 84 | route = `/${name}` 85 | } 86 | } 87 | // dir index (/posts/index.astro) 88 | else if (match[2] === "/index") { 89 | name = match[1].replace("/", "") 90 | route = `${relative.slice(0, range[0])}/${name}` 91 | } 92 | // page (/posts/[slug].astro) 93 | else { 94 | name = match[2].replace("/", "") 95 | route = `${relative.slice( 96 | 0, 97 | range[0] + match[1].length, 98 | )}/${name}` 99 | } 100 | 101 | if (name.startsWith("_")) continue // ignore if private 102 | 103 | pageData[route] = { ...pageData[route], name, route, path } 104 | continue 105 | } 106 | 107 | // translations 108 | if (!relative.endsWith(".json")) continue 109 | 110 | const { match, range } = 111 | translationFilePattern.match(relative) || {} 112 | if (!match || !range) continue 113 | 114 | let route = `${relative.slice(0, range[0])}${match[1] || "/"}` 115 | if (pageTranslationDirPattern.test(route)) route = "/" 116 | const locale = match[2] || never() 117 | const name = route.split("/").slice(-1).join("") || "index" 118 | const translatedName = match[3] ? match[3].replace(".", "") : null 119 | 120 | const localeTranslations = await importJson(path) 121 | assert( 122 | localeTranslations, 123 | isDeepStringRecord, 124 | `${locale}.PageTranslations`, 125 | ) 126 | // merging with existing locale translations 127 | merge( 128 | localeTranslations, 129 | pageData[route]?.translations?.[locale] || {}, 130 | ) 131 | 132 | const translations = { 133 | ...pageData[route]?.translations, 134 | [locale]: localeTranslations, 135 | } 136 | 137 | const routes = translatedName 138 | ? { 139 | ...pageData[route]?.routes, 140 | [locale]: { 141 | [name]: translatedName, 142 | }, 143 | } 144 | : { ...pageData[route]?.routes } 145 | 146 | pageData[route] = { 147 | ...pageData[route], 148 | name, 149 | route, 150 | translations, 151 | routes, 152 | } 153 | } 154 | }) 155 | 156 | const pages: PageProps[] = [] 157 | for (const page of Object.values(pageData)) { 158 | if (!page.path || !page.name || !page.route) continue 159 | // all pages that have an astro component (defined page.path) 160 | pages.push({ translations: {}, routes: {}, ...page } as any) 161 | } 162 | 163 | const i18nPagesDir = `${projectRoot}/${config.srcDir}/${$directory.i18n}/${PAGES_DIRNAME}` 164 | 165 | if (!(await isDirectory(i18nPagesDir))) { 166 | return pages.map((page) => new Page(page)) 167 | } 168 | 169 | // merging translations from the root i18n dir 170 | for (const page of pages) { 171 | let dir = `${i18nPagesDir}${page.route}`.replace(/\/$/, "") 172 | if (!(await isDirectory(dir))) continue 173 | 174 | let pageTranslations = await getSrcPageTranslations( 175 | dir, 176 | page.name, 177 | translationFilePattern, 178 | ) 179 | for (const { translations, routes } of pageTranslations) { 180 | merge(page.translations, translations) 181 | merge(page.routes, routes) 182 | } 183 | 184 | // also checking nested translation folder 185 | dir = `${dir}/${$directory.pages}` 186 | if (!(await isDirectory(dir))) continue 187 | 188 | pageTranslations = await getSrcPageTranslations( 189 | dir, 190 | page.name, 191 | translationFilePattern, 192 | ) 193 | for (const { translations, routes } of pageTranslations) { 194 | merge(page.translations, translations) 195 | merge(page.routes, routes) 196 | } 197 | } 198 | 199 | return pages.map((page) => new Page(page)) 200 | } 201 | 202 | /** 203 | * Meant to be used inside getProjectPages to avoid repetition. 204 | * @param i18nDir A directory in `"src/{i18n}/pages"`. 205 | * @param pageName The page name for which we are fetching the translations. 206 | * @param pattern The pattern matching the translation files we are looking for. 207 | * It should match the route name at index 1 (optional), the route locale at 208 | * index 2 and the translated name at index 3 (optional). 209 | */ 210 | async function getSrcPageTranslations( 211 | i18nDir: string, 212 | pageName: string, 213 | pattern: Regex, 214 | ) { 215 | const { readdirSync } = await AsyncNode.fs 216 | 217 | const results: { 218 | translations: PageProps["translations"] 219 | routes: PageProps["routes"] 220 | }[] = [] 221 | 222 | for (const content of readdirSync(i18nDir, { encoding: "utf8" })) { 223 | const { match } = pattern.match(`/${content}`) || {} 224 | if (!match) continue 225 | 226 | const locale = 227 | match[2] || throwError(new InvalidTranslationFilePattern()) 228 | const translatedName = match[3] ? match[3].replace(".", "") : null 229 | const localeTranslations = await importJson(`${i18nDir}/${content}`) 230 | assert( 231 | localeTranslations, 232 | isDeepStringRecord, 233 | `${locale}.PageTranslations`, 234 | ) 235 | 236 | results.push({ 237 | translations: { 238 | [locale]: localeTranslations, 239 | }, 240 | routes: translatedName 241 | ? { 242 | [locale]: { 243 | [pageName]: translatedName, 244 | }, 245 | } 246 | : {}, 247 | }) 248 | } 249 | 250 | return results 251 | } 252 | -------------------------------------------------------------------------------- /astro-i18n/src/core/translation/functions/interpolation/interpolation-matching.functions.ts: -------------------------------------------------------------------------------- 1 | import { never } from "@lib/error" 2 | import { RegexBuilder } from "@lib/regex" 3 | import { CALLBACK_BREAK } from "@src/constants/app.constants" 4 | import { 5 | INTERPOLATION_ALIAS_PATTERN, 6 | INTERPOLATION_ARGUMENTLESS_FORMATTER_PATTERN, 7 | } from "@src/core/translation/constants/translation-patterns.constants" 8 | import { ValueType } from "@src/core/translation/enums/value-type.enum" 9 | import UnknownValue from "@src/core/translation/errors/interpolation/unknown-value.error" 10 | import UntrimmedString from "@src/core/translation/errors/untrimmed-string.error" 11 | import { 12 | matchArray, 13 | matchBoolean, 14 | matchEmpty, 15 | matchNull, 16 | matchNumber, 17 | matchObject, 18 | matchString, 19 | matchUndefined, 20 | matchVariable, 21 | } from "@src/core/translation/functions/matching.functions" 22 | import { depthAwareforEach } from "@src/core/translation/functions/parsing.functions" 23 | import type { FormatterMatch, Matcher } from "@src/core/translation/types" 24 | 25 | /** 26 | * Matches an interpolation's alias. 27 | * @param alias gor example "`(alias_name)>formatter1...`" 28 | */ 29 | const matchInterpolationAlias: Matcher = RegexBuilder.fromRegex( 30 | INTERPOLATION_ALIAS_PATTERN, 31 | ) 32 | .assertStarting() 33 | .build() 34 | .toMatcher() 35 | 36 | /** 37 | * Matches a formatter until its first parenthesis (start of arguments). 38 | * @param formatter for example `">formatter1("true, "arg2", 1.5)>formatter2..."` 39 | */ 40 | const matchArgumentlessFormatter: Matcher = RegexBuilder.fromRegex( 41 | INTERPOLATION_ARGUMENTLESS_FORMATTER_PATTERN, 42 | ) 43 | .assertStarting() 44 | .build() 45 | .toMatcher() 46 | 47 | /** 48 | * Matches every part of an interpolation and returns them separately. 49 | * @param interpolation for example `"'value'(alias)>formatter(arg)"`. 50 | */ 51 | export function matchInterpolation(interpolation: string) { 52 | interpolation = interpolation.trim() 53 | 54 | const raw = interpolation 55 | 56 | const { 57 | value, 58 | type, 59 | end: valueEnd, 60 | } = matchInterpolationValue(interpolation) 61 | 62 | interpolation = interpolation.slice(valueEnd).trim() 63 | 64 | let alias = null 65 | 66 | const aliasMatch = matchInterpolationAlias(interpolation) 67 | if (aliasMatch) { 68 | const { match, range } = aliasMatch 69 | 70 | alias = match[1] || never() 71 | 72 | interpolation = interpolation.slice(range[1]).trim() 73 | } 74 | 75 | const formatters: FormatterMatch[] = [] 76 | 77 | while (interpolation.length > 0) { 78 | if (interpolation[0] === ")") interpolation = interpolation.slice(1) 79 | const { match, range } = matchArgumentlessFormatter(interpolation) || {} 80 | if (!match?.[1] || !range) break 81 | 82 | interpolation = interpolation.slice(range[1]).trim() 83 | 84 | if (!match[2]) { 85 | formatters.push({ 86 | name: match[1], 87 | args: [], 88 | }) 89 | continue 90 | } 91 | 92 | const { args, end } = matchFormatterArguments(interpolation) 93 | interpolation = interpolation.slice(end).trim() 94 | 95 | formatters.push({ 96 | name: match[1], 97 | args, 98 | }) 99 | } 100 | 101 | return { 102 | raw, 103 | value, 104 | type, 105 | alias, 106 | formatters, 107 | } 108 | } 109 | 110 | export function matchInterpolationVariables(interpolation: string) { 111 | const variables: string[] = [] 112 | 113 | interpolation = interpolation.trim() 114 | const { type, value, alias, formatters } = matchInterpolation(interpolation) 115 | 116 | for (const formatter of formatters) { 117 | for (const arg of formatter.args) { 118 | for (const variable of matchInterpolationVariables(arg)) { 119 | variables.push(variable) 120 | } 121 | } 122 | } 123 | 124 | switch (type) { 125 | case ValueType.VARIABLE: { 126 | variables.push(alias || value) 127 | break 128 | } 129 | // match object vars 130 | case ValueType.OBJECT: { 131 | let value = "" 132 | let isKey = true 133 | depthAwareforEach(interpolation, (char, _, depth, isOpening) => { 134 | if (depth === 0) { 135 | for (const variable of matchInterpolationVariables(value)) { 136 | variables.push(variable) 137 | } 138 | return CALLBACK_BREAK 139 | } 140 | 141 | if (isKey) { 142 | if (isOpening && char === "{") return null // ignore opening bracket 143 | if (/\s/.test(char)) return null 144 | if (char === ":") { 145 | isKey = false 146 | return null 147 | } 148 | } else if (char === ",") { 149 | for (const variable of matchInterpolationVariables(value)) { 150 | variables.push(variable) 151 | } 152 | value = "" 153 | isKey = true 154 | return null 155 | } 156 | 157 | if (!isKey) value += char 158 | return null 159 | }) 160 | break 161 | } 162 | // match array vars 163 | case ValueType.ARRAY: { 164 | let value = "" 165 | depthAwareforEach(interpolation, (char, _, depth, isOpening) => { 166 | if (depth === 0) { 167 | for (const variable of matchInterpolationVariables(value)) { 168 | variables.push(variable) 169 | } 170 | return CALLBACK_BREAK 171 | } 172 | 173 | if (isOpening && char === "[") return null // ignore opening bracket 174 | if (char === ",") { 175 | for (const variable of matchInterpolationVariables(value)) { 176 | variables.push(variable) 177 | } 178 | value = "" 179 | return null 180 | } 181 | 182 | value += char 183 | return null 184 | }) 185 | break 186 | } 187 | default: { 188 | break 189 | } 190 | } 191 | 192 | return variables 193 | } 194 | 195 | /** 196 | * Matches an interpolation's value. 197 | * @param value for example "`{ prop: varName }(alias)>formatter1...`" 198 | */ 199 | function matchInterpolationValue(value: string) { 200 | if (/^\s+\S/.test(value)) throw new UntrimmedString(value) 201 | 202 | let matched = matchEmpty(value) 203 | if (matched) { 204 | return { 205 | value: "undefined", 206 | type: ValueType.UNDEFINED, 207 | end: matched.range[1], 208 | } 209 | } 210 | 211 | matched = matchUndefined(value) 212 | if (matched) { 213 | return { 214 | value: matched.match[0] || never(), 215 | type: ValueType.UNDEFINED, 216 | end: matched.range[1], 217 | } 218 | } 219 | 220 | matched = matchNull(value) 221 | if (matched) { 222 | return { 223 | value: matched.match[0] || never(), 224 | type: ValueType.NULL, 225 | end: matched.range[1], 226 | } 227 | } 228 | 229 | matched = matchBoolean(value) 230 | if (matched) { 231 | return { 232 | value: matched.match[0] || never(), 233 | type: ValueType.BOOLEAN, 234 | end: matched.range[1], 235 | } 236 | } 237 | 238 | matched = matchNumber(value) 239 | if (matched) { 240 | return { 241 | value: matched.match[0] || never(), 242 | type: ValueType.NUMBER, 243 | end: matched.range[1], 244 | } 245 | } 246 | 247 | matched = matchVariable(value) 248 | if (matched) { 249 | return { 250 | value: matched.match[0] || never(), 251 | type: ValueType.VARIABLE, 252 | end: matched.range[1], 253 | } 254 | } 255 | 256 | matched = matchString(value) 257 | if (matched) { 258 | return { 259 | value: matched.match[0] || never(), 260 | type: ValueType.STRING, 261 | end: matched.range[1], 262 | } 263 | } 264 | 265 | matched = matchObject(value) 266 | if (matched) { 267 | return { 268 | value: matched.match[0] || never(), 269 | type: ValueType.OBJECT, 270 | end: matched.range[1], 271 | } 272 | } 273 | 274 | matched = matchArray(value) 275 | if (matched) { 276 | return { 277 | value: matched.match[0] || never(), 278 | type: ValueType.ARRAY, 279 | end: matched.range[1], 280 | } 281 | } 282 | 283 | throw new UnknownValue(value) 284 | } 285 | 286 | /** 287 | * Matches the formatter's arguments until the end of the formatter 288 | * (closing `")"`). 289 | * @param args The formatter's arguments, starts after the first `"("`. 290 | * For example `"true, "arg2", 1.5)>formatter2..."`. 291 | */ 292 | function matchFormatterArguments(args: string) { 293 | const result = { 294 | args: [] as string[], 295 | end: 0, 296 | } 297 | 298 | let current = "" 299 | let hasOpeningParenthesis = false 300 | depthAwareforEach(args, (char, i, depth) => { 301 | if (char === "(") hasOpeningParenthesis = true 302 | 303 | if (depth > 0) { 304 | current += char 305 | return null 306 | } 307 | 308 | if (char === ",") { 309 | result.args.push(current.trim()) 310 | current = "" 311 | return null 312 | } 313 | 314 | if (char === ")") { 315 | if (hasOpeningParenthesis) current += char 316 | result.args.push(current.trim()) 317 | result.end = i + 1 318 | current = "" 319 | return CALLBACK_BREAK 320 | } 321 | 322 | current += char 323 | return null 324 | }) 325 | 326 | return result 327 | } 328 | -------------------------------------------------------------------------------- /astro-i18n/src/core/config/functions/config.functions.ts: -------------------------------------------------------------------------------- 1 | import { setObjectProperty } from "@lib/object" 2 | import AsyncNode from "@lib/async-node/classes/async-node.class" 3 | import { 4 | canRead, 5 | isDirectory, 6 | isFile, 7 | } from "@lib/async-node/functions/fs.functions" 8 | import { importJson } from "@lib/async-node/functions/import.functions" 9 | import { isRootPath, popPath } from "@lib/async-node/functions/path.functions" 10 | import { Regex, RegexBuilder } from "@lib/regex" 11 | import { assert, isRecord } from "@lib/ts/guards" 12 | import { 13 | DEFAULT_TRANSLATION_DIRNAME, 14 | PAGES_DIRNAME, 15 | } from "@src/constants/app.constants" 16 | import { PACKAGE_NAME } from "@src/constants/meta.constants" 17 | import { 18 | ASTRO_CONFIG_PATTERN, 19 | ASTRO_I18N_CONFIG_PATTERN, 20 | } from "@src/core/config/constants/config-patterns.constants" 21 | import { isDeepStringRecord } from "@src/core/translation/guards/deep-string-record.guard" 22 | import { 23 | DENO_JSON_PATTERN, 24 | DEPS_TS_PATTERN, 25 | NODE_MODULES_PATH_PATTERN, 26 | NODE_MODULES_SEGMENT_PATTERN, 27 | PACKAGE_DENO_JSON_PATTERN, 28 | PACKAGE_JSON_PATTERN, 29 | } from "@src/core/config/constants/path-patterns.constants" 30 | import type { 31 | AstroI18nConfig, 32 | ConfigTranslations, 33 | } from "@src/core/config/types" 34 | import { COMMON_TRANSLATIONS_GROUP } from "@src/core/translation/constants/translation.constants" 35 | import type { TranslationMap } from "@src/core/translation/types" 36 | 37 | const astroI18nConfigPattern = RegexBuilder.fromRegex(ASTRO_I18N_CONFIG_PATTERN) 38 | .assertEnding() 39 | .build() 40 | 41 | const astroConfigPattern = RegexBuilder.fromRegex(ASTRO_CONFIG_PATTERN) 42 | .assertEnding() 43 | .build() 44 | 45 | /** 46 | * Typed astro-i18n config definition. 47 | */ 48 | export function defineAstroI18nConfig(config: Partial) { 49 | return config 50 | } 51 | 52 | /** 53 | * Separates ConfigTranslations common group from the route groups and the other 54 | * extra groups. 55 | */ 56 | export function categorizeConfigTranslationsGroups( 57 | configTranslations: ConfigTranslations | TranslationMap, 58 | ) { 59 | const groups = { 60 | routes: [] as string[], 61 | extra: [] as string[], 62 | common: undefined as string | undefined, 63 | } 64 | for (const key of Object.keys(configTranslations)) { 65 | if (key.startsWith("/")) { 66 | groups.routes.push(key) 67 | continue 68 | } 69 | if (key === COMMON_TRANSLATIONS_GROUP) { 70 | groups.common = COMMON_TRANSLATIONS_GROUP 71 | continue 72 | } 73 | groups.extra.push(key) 74 | } 75 | return groups 76 | } 77 | 78 | /** 79 | * Extracts all the non-page translation groups from the main astro-i18n 80 | * directory. 81 | * Looks for `"/i18n/group/locale.json"` files. 82 | */ 83 | export async function getProjectTranslationGroups( 84 | projectRoot: string, 85 | config: Partial = {}, 86 | ) { 87 | const i18nDir = `${projectRoot}/${config.srcDir}/${ 88 | config.translationDirectory?.i18n || DEFAULT_TRANSLATION_DIRNAME 89 | }` 90 | 91 | const groups: ConfigTranslations = {} 92 | 93 | if (!(await isDirectory(i18nDir))) return groups 94 | 95 | const { readdirSync } = await AsyncNode.fs 96 | 97 | const locales = [ 98 | config.primaryLocale || "en", 99 | ...(config.secondaryLocales || []), 100 | ] 101 | const translationFilePattern = Regex.fromString( 102 | `(${locales.join("|")})\\.json`, 103 | ) 104 | 105 | for (const group of readdirSync(i18nDir)) { 106 | if (group === PAGES_DIRNAME) continue 107 | const path = `${i18nDir}/${group}` 108 | if (!(await isDirectory(path))) continue 109 | 110 | for (const file of readdirSync(path)) { 111 | const { match } = translationFilePattern.match(file) || {} 112 | if (!match?.[1]) continue 113 | const locale = match[1] 114 | 115 | const translations = await importJson(`${path}/${file}`) 116 | assert( 117 | translations, 118 | isDeepStringRecord, 119 | `${locale}.GroupTranslations`, 120 | ) 121 | setObjectProperty(groups, [group, locale], translations) 122 | } 123 | } 124 | 125 | return groups 126 | } 127 | 128 | /** 129 | * Crawls directories looking for an astro-i18n's config file and returns its 130 | * path. 131 | */ 132 | export async function autofindAstroI18nConfig(startingPath: string) { 133 | return searchProjectRootPattern( 134 | exitNodeModules(startingPath), 135 | astroI18nConfigPattern.regexp, 136 | ) 137 | } 138 | 139 | /** 140 | * Crawls directories looking for an Astro's config file and returns the 141 | * directory where its contained. 142 | * If a package.json is found it also checks if astro-i18n is in the dependencies. 143 | */ 144 | export async function autofindProjectRoot(startingPath: string) { 145 | const astroConfigPath = await searchProjectRootPattern( 146 | exitNodeModules(startingPath), 147 | astroConfigPattern.regexp, 148 | ) 149 | if (!astroConfigPath) return null 150 | 151 | const { readdirSync } = await AsyncNode.fs 152 | 153 | const dir = await popPath(astroConfigPath) 154 | const contents = readdirSync(dir) 155 | 156 | const packageJson = contents.find((name) => PACKAGE_JSON_PATTERN.test(name)) 157 | // checking if we are a dependency of package.json 158 | if (packageJson) { 159 | const json = await importJson(`${dir}/${packageJson}`) 160 | if ( 161 | isRecord(json) && 162 | isRecord(json["dependencies"]) && 163 | json["dependencies"][PACKAGE_NAME] 164 | ) { 165 | return dir 166 | } 167 | } 168 | 169 | const denoJson = contents.find((name) => DENO_JSON_PATTERN.test(name)) 170 | // not checking deno import map because npm pkgs may not be mentionned 171 | if (denoJson) return dir 172 | 173 | const depsTs = contents.find((name) => DEPS_TS_PATTERN.test(name)) 174 | if (depsTs) return dir 175 | 176 | return null 177 | } 178 | 179 | export async function hasAstroConfig(directory: string) { 180 | const { readdirSync } = await AsyncNode.fs 181 | return ( 182 | typeof readdirSync(directory).find((content) => 183 | astroConfigPattern.test(content), 184 | ) === "string" 185 | ) 186 | } 187 | 188 | /** 189 | * Removes all the path segments inside node_modules (including node_modules). 190 | * For example, `"/my/project/root/node_modules/nested/library"` will return 191 | * `"/my/project/root`. 192 | */ 193 | function exitNodeModules(path: string) { 194 | const { match } = NODE_MODULES_PATH_PATTERN.match(path) || {} 195 | return match 196 | ? match[0]?.replace(NODE_MODULES_SEGMENT_PATTERN.regexp, "") || "/" 197 | : path 198 | } 199 | 200 | /** 201 | * Crawls every directory and parent directory containing a `package.json` or 202 | * `deno.json` looking for the given pattern. 203 | * If the directory is not a project root (contains `package.json`...), it will 204 | * search in the parent directory. 205 | */ 206 | async function searchProjectRootPattern( 207 | path: string, 208 | pattern: RegExp, 209 | crawlDirection = 1, 210 | isFirstIteration = true, 211 | ): Promise { 212 | const { readdirSync } = await AsyncNode.fs 213 | 214 | if (await isFile(path)) { 215 | if (pattern.test(path)) return path 216 | if (isFirstIteration) { 217 | return searchProjectRootPattern( 218 | await popPath(path), 219 | pattern, 220 | 1, 221 | false, 222 | ) 223 | } 224 | return null 225 | } 226 | 227 | if (!(await canRead(path))) return null 228 | 229 | if (isRootPath(path)) return null 230 | 231 | const dirContent = readdirSync(path) 232 | 233 | // check for config 234 | const config = dirContent.find((file) => pattern.test(file)) 235 | if (config) return `${path}/${config}` 236 | 237 | if (crawlDirection > 0) { 238 | const isProjectRoot = 239 | typeof dirContent.find((name) => 240 | PACKAGE_DENO_JSON_PATTERN.test(name), 241 | ) === "string" 242 | // dir is not project root check next level up 243 | if (!isProjectRoot) { 244 | return searchProjectRootPattern( 245 | await popPath(path), 246 | pattern, 247 | 1, 248 | false, 249 | ) 250 | } 251 | } 252 | 253 | // filter sibling folders we don't want to crawl 254 | const filtered = dirContent.filter((name) => { 255 | switch (name) { 256 | case "node_modules": { 257 | return false 258 | } 259 | case "src": { 260 | return false 261 | } 262 | case "public": { 263 | return false 264 | } 265 | case "dist": { 266 | return false 267 | } 268 | case "build": { 269 | return false 270 | } 271 | default: { 272 | return !name.startsWith(".") 273 | } 274 | } 275 | }) 276 | 277 | // crawl filtered sibling folders 278 | for (const name of filtered) { 279 | const fullpath = `${path}/${name}` 280 | if (await isFile(path)) continue 281 | 282 | const result = await searchProjectRootPattern( 283 | fullpath, 284 | pattern, 285 | -1, 286 | false, 287 | ) 288 | if (typeof result === "string") return result 289 | } 290 | 291 | // continue search or return 292 | return crawlDirection > 0 293 | ? searchProjectRootPattern(await popPath(path), pattern, 1, false) 294 | : null 295 | } 296 | -------------------------------------------------------------------------------- /astro-i18n/src/core/translation/classes/translation-bank.class.ts: -------------------------------------------------------------------------------- 1 | import { never } from "@lib/error" 2 | import { Regex } from "@lib/regex" 3 | import { setObjectProperty } from "@lib/object" 4 | import { categorizeConfigTranslationsGroups } from "@src/core/config/functions/config.functions" 5 | import { 6 | computeDeepStringRecord, 7 | interpolate, 8 | } from "@src/core/translation/functions/translation.functions" 9 | import { COMMON_TRANSLATIONS_GROUP } from "@src/core/translation/constants/translation.constants" 10 | import { INTERPOLATION_PATTERN } from "@src/core/translation/constants/translation-patterns.constants" 11 | import { matchInterpolationVariables } from "@src/core/translation/functions/interpolation/interpolation-matching.functions" 12 | import { ROUTE_PARAM_PATTERN } from "@src/core/routing/constants/routing-patterns.constants" 13 | import type { 14 | ComputedTranslations, 15 | Formatters, 16 | LoadDirectives, 17 | Primitive, 18 | SerializedTranslationMap, 19 | TranslationMap, 20 | TranslationProperties, 21 | TranslationVariables, 22 | } from "@src/core/translation/types" 23 | import type { 24 | ConfigTranslationLoadingRules, 25 | ConfigTranslations, 26 | } from "@src/core/config/types" 27 | import type Config from "@src/core/config/classes/config.class" 28 | 29 | class TranslationBank { 30 | #loadDirectives: LoadDirectives = {} 31 | 32 | #translations: TranslationMap 33 | 34 | constructor( 35 | translations: TranslationMap = {}, 36 | loadDirectives: LoadDirectives = {}, 37 | ) { 38 | this.#translations = translations 39 | this.#loadDirectives = loadDirectives 40 | } 41 | 42 | /** 43 | * Create a TranslationBank from a config's translations. 44 | */ 45 | static fromConfig({ translations, translationLoadingRules }: Config) { 46 | return new TranslationBank() 47 | .addTranslations(translations) 48 | .addTranslationLoadingRules(translationLoadingRules) 49 | } 50 | 51 | /** 52 | * Get the appropriate translation for the given key, route, locale and 53 | * properties. 54 | * If no translation is found the key will be returned. 55 | */ 56 | get( 57 | key: string, 58 | page: string, 59 | locale: string, 60 | fallbackLocale = "", 61 | properties: TranslationProperties = {}, 62 | formatters: Formatters = {}, 63 | ignorePageIsolation = false, 64 | ) { 65 | let translation = this.#getValue(key, page, locale, ignorePageIsolation) 66 | 67 | if (!translation && fallbackLocale && fallbackLocale !== locale) { 68 | translation = this.#getValue( 69 | key, 70 | page, 71 | fallbackLocale, 72 | ignorePageIsolation, 73 | ) 74 | } 75 | 76 | // find the best variant, defaults to the default value or key param if none 77 | const bestVariant = { 78 | score: 0, 79 | value: translation?.default || key, 80 | } 81 | for (const variant of translation?.variants || []) { 82 | const score = variant.calculateMatchingScore(properties) 83 | if (score > bestVariant.score) { 84 | bestVariant.score = score 85 | bestVariant.value = variant.value 86 | } 87 | } 88 | 89 | return interpolate(bestVariant.value, properties, formatters) 90 | } 91 | 92 | getRouteGroups() { 93 | return Object.keys(this.#translations).filter((group) => 94 | group.startsWith("/"), 95 | ) 96 | } 97 | 98 | getParamRouteGroups() { 99 | return Object.keys(this.#translations).filter( 100 | (group) => group.startsWith("/") && ROUTE_PARAM_PATTERN.test(group), 101 | ) 102 | } 103 | 104 | /** 105 | * For every translation of the given locale, returns all the interpolation 106 | * and variant variables. 107 | */ 108 | getLocaleTranslationVariables(locale: string) { 109 | const translationProperties: Record = {} 110 | 111 | for (const group of Object.values(this.#translations)) { 112 | if (!group[locale]) continue 113 | 114 | const entries = Object.entries(group[locale] || never()) 115 | for (const [key, { default: defaultValue, variants }] of entries) { 116 | const props: TranslationVariables = { 117 | interpolationVars: [], 118 | variantVars: [], 119 | isVariantRequired: false, 120 | } 121 | const translationValues = [] // all the possible values for this key 122 | 123 | if (defaultValue === undefined) props.isVariantRequired = true 124 | else translationValues.push(defaultValue) 125 | 126 | // getting variant values and variables 127 | if (variants.length > 0) { 128 | const propertyValues: Record = {} 129 | 130 | for (const { value, properties } of variants) { 131 | translationValues.push(value) // add variant value 132 | // variant property values 133 | for (const { name, values } of properties) { 134 | propertyValues[name] = [ 135 | ...(propertyValues[name] || []), 136 | ...values, 137 | ] 138 | } 139 | } 140 | 141 | props.variantVars = Object.entries(propertyValues).map( 142 | ([name, values]) => ({ name, values }), 143 | ) 144 | } 145 | 146 | // getting interpolation variables from values 147 | for (const translation of translationValues) { 148 | INTERPOLATION_PATTERN.exec(translation, ({ match }) => { 149 | if (!match[1]) return 150 | props.interpolationVars = [ 151 | ...new Set([ 152 | ...props.interpolationVars, 153 | ...matchInterpolationVariables(match[1]), 154 | ]), 155 | ] 156 | }) 157 | } 158 | 159 | // adding new property 160 | if (!translationProperties[key]) { 161 | translationProperties[key] = props 162 | continue 163 | } 164 | 165 | // merging properties 166 | const { interpolationVars, variantVars, isVariantRequired } = 167 | translationProperties[key] || {} 168 | 169 | const mergedInterpolations = [ 170 | ...new Set([ 171 | ...(interpolationVars || []), 172 | ...props.interpolationVars, 173 | ]), 174 | ] 175 | 176 | const mergedVariants: TranslationVariables["variantVars"] = [] 177 | for (const variantVar of props.variantVars) { 178 | const existingVariantVar = variantVars?.find( 179 | (item) => item.name === variantVar.name, 180 | ) 181 | mergedVariants.push( 182 | existingVariantVar 183 | ? { 184 | name: variantVar.name, 185 | values: [ 186 | ...new Set([ 187 | ...(existingVariantVar?.values || 188 | []), 189 | ...variantVar.values, 190 | ]), 191 | ], 192 | } 193 | : variantVar, 194 | ) 195 | } 196 | 197 | // adding merged properties 198 | translationProperties[key] = { 199 | interpolationVars: mergedInterpolations, 200 | variantVars: mergedVariants, 201 | isVariantRequired: 202 | (isVariantRequired || false) && props.isVariantRequired, 203 | } 204 | } 205 | } 206 | 207 | return translationProperties 208 | } 209 | 210 | addTranslations(translations: ConfigTranslations) { 211 | for (const [group, locales] of Object.entries(translations)) { 212 | const localeEntries = Object.entries(locales) 213 | 214 | // empty record for no translations to be able to save route translation-less groups 215 | if (localeEntries.length === 0 && !this.#translations[group]) { 216 | this.#translations[group] = {} 217 | continue 218 | } 219 | 220 | for (const [locale, deepStringRecord] of localeEntries) { 221 | setObjectProperty( 222 | this.#translations, 223 | [group, locale], 224 | computeDeepStringRecord(deepStringRecord), 225 | ) 226 | } 227 | } 228 | 229 | return this 230 | } 231 | 232 | addTranslationLoadingRules( 233 | translationLoadingRules: ConfigTranslationLoadingRules, 234 | ) { 235 | if (translationLoadingRules.length === 0) return this 236 | 237 | const { routes } = categorizeConfigTranslationsGroups( 238 | this.#translations, 239 | ) 240 | 241 | for (const rule of translationLoadingRules) { 242 | // find which groups need to be loaded 243 | const matchedGroups: string[] = [] 244 | for (const groupRegex of rule.groups) { 245 | const pattern = Regex.fromString(groupRegex) 246 | // matched against every group including routes & common 247 | matchedGroups.push( 248 | ...Object.keys(this.#translations).filter((group) => 249 | pattern.test(group), 250 | ), 251 | ) 252 | } 253 | // find the routes where the matched groups will be loaded 254 | for (const routeSource of rule.routes) { 255 | const pattern = Regex.fromString(routeSource) 256 | const matchedRoutes = routes.filter((route) => 257 | pattern.test(route), 258 | ) 259 | for (const route of matchedRoutes) { 260 | if (!this.#loadDirectives[route]) { 261 | this.#loadDirectives[route] = [ 262 | ...new Set(matchedGroups), 263 | ] 264 | continue 265 | } 266 | 267 | this.#loadDirectives[route] = [ 268 | ...new Set([ 269 | ...(this.#loadDirectives[route] || never()), 270 | ...matchedGroups, 271 | ]), 272 | ] 273 | } 274 | } 275 | } 276 | 277 | return this 278 | } 279 | 280 | clear() { 281 | this.#loadDirectives = {} 282 | this.#translations = {} 283 | } 284 | 285 | toClientSideObject(route: string) { 286 | const translations: SerializedTranslationMap = {} 287 | // adding groups for this route 288 | if (this.#loadDirectives[route]) { 289 | for (const group of this.#loadDirectives[route] || never()) { 290 | translations[group] = this.#translations[group] || {} 291 | } 292 | } 293 | // adding route translations 294 | if (this.#translations[route]) { 295 | translations[route] = this.#translations[route] || {} 296 | } 297 | // adding common translations 298 | if (this.#translations[COMMON_TRANSLATIONS_GROUP]) { 299 | translations[COMMON_TRANSLATIONS_GROUP] = 300 | this.#translations[COMMON_TRANSLATIONS_GROUP] || {} 301 | } 302 | return translations 303 | } 304 | 305 | toObject() { 306 | return { 307 | loadDirectives: this.#loadDirectives, 308 | translations: this.#translations, 309 | } 310 | } 311 | 312 | toString() { 313 | return JSON.stringify(this.toObject(), null, "\t") 314 | } 315 | 316 | /** 317 | * @param ignorePageIsolation If true it will ignore local page rules and 318 | * search in every page and every group loaded in any page. 319 | */ 320 | #getValue( 321 | key: string, 322 | page: string, 323 | locale: string, 324 | ignorePageIsolation = false, 325 | ) { 326 | let translation: ComputedTranslations[string] | null = null 327 | 328 | if (ignorePageIsolation) { 329 | const pages = this.getRouteGroups() 330 | // search key in all the groups to load for each page 331 | for (const page of pages) { 332 | for (const group of this.#loadDirectives[page] || []) { 333 | const value = this.#translations[group]?.[locale]?.[key] 334 | if (!value) continue 335 | translation = value 336 | break 337 | } 338 | } 339 | 340 | // search key in every page group 341 | if (!translation) { 342 | for (const page of pages) { 343 | if (this.#translations[page]?.[locale]?.[key]) { 344 | translation = 345 | this.#translations[page]?.[locale]?.[key] || never() 346 | } 347 | } 348 | } 349 | } else { 350 | // search key inthe groups to load for the given page 351 | for (const group of this.#loadDirectives[page] || []) { 352 | const value = this.#translations[group]?.[locale]?.[key] 353 | if (!value) continue 354 | translation = value 355 | break 356 | } 357 | 358 | // search key in corresponding page group 359 | if (!translation && this.#translations[page]?.[locale]?.[key]) { 360 | translation = 361 | this.#translations[page]?.[locale]?.[key] || never() 362 | } 363 | } 364 | 365 | // search key in the common group 366 | if ( 367 | !translation && 368 | this.#translations[COMMON_TRANSLATIONS_GROUP]?.[locale]?.[key] 369 | ) { 370 | translation = 371 | this.#translations[COMMON_TRANSLATIONS_GROUP]?.[locale]?.[ 372 | key 373 | ] || never() 374 | } 375 | 376 | return translation 377 | } 378 | } 379 | 380 | export default TranslationBank 381 | --------------------------------------------------------------------------------