├── .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 |
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 |
--------------------------------------------------------------------------------