From 0bf48b2a02651e9709ad3d9d6feef925dc72f44d Mon Sep 17 00:00:00 2001 From: Yan <61414485+yanthomasdev@users.noreply.github.com> Date: Thu, 7 Nov 2024 20:15:45 -0300 Subject: [PATCH] Add support for custom parameters in patterns --- packages/core/src/config/schema.ts | 35 +++++++++- packages/core/src/config/types.ts | 14 ++-- packages/core/src/files/paths.ts | 61 +++++++++++++--- packages/core/src/index.ts | 8 +-- packages/core/src/integrations/schema.ts | 6 +- packages/core/src/integrations/types.ts | 6 +- .../core/tests/unit/config-validation.test.ts | 56 ++++++++++++++- packages/core/tests/unit/integrations.test.ts | 25 +++++-- .../core/tests/unit/path-resolver.test.ts | 69 ++++++++++++++++--- packages/core/tests/utils.ts | 12 +++- 10 files changed, 249 insertions(+), 43 deletions(-) diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts index 36755f1..1604feb 100644 --- a/packages/core/src/config/schema.ts +++ b/packages/core/src/config/schema.ts @@ -70,12 +70,18 @@ const LunariaIntegrationSchema = z.object({ }), }); +export const LocaleSchema = z.object({ + label: z.string(), + lang: z.string(), + parameters: z.record(z.string(), z.string()).optional(), +}); + // We need both of these schemas so that we can extend the Lunaria config // e.g. to validate integrations export const BaseLunariaConfigSchema = z.object({ repository: RepositorySchema, - sourceLocale: z.string(), - locales: z.array(z.string()).nonempty(), + sourceLocale: LocaleSchema, + locales: z.array(LocaleSchema).nonempty(), files: z.array(FileSchema).nonempty(), tracking: z .object({ @@ -92,7 +98,7 @@ export const BaseLunariaConfigSchema = z.object({ export const LunariaConfigSchema = BaseLunariaConfigSchema.superRefine((config, ctx) => { // Adds an validation issue if any locales share the same value. const locales = new Set(); - for (const locale of [config.sourceLocale, ...config.locales]) { + for (const locale of [config.sourceLocale.lang, ...config.locales.map((locale) => locale.lang)]) { if (locales.has(locale)) { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -102,6 +108,29 @@ export const LunariaConfigSchema = BaseLunariaConfigSchema.superRefine((config, locales.add(locale); } + let params: Array | undefined = undefined; + for (const { parameters, lang } of [config.sourceLocale, ...config.locales]) { + // Since the sourceLocale is evaluated first in the array, we can use it + // to ensure whe are properly checking no locales has the `parameters` field. + if (!params && parameters && config.sourceLocale.parameters) { + params = Object.keys(parameters); + } + + if (parameters && Object.keys(parameters).join(',') !== params?.join(',')) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'All locales must have the same `parameters` keys', + }); + } + + if (params && !parameters) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `All locales must have the same \`parameters\` keys. Locale ${lang} does not have \`parameters\`.`, + }); + } + } + if (config.cacheDir === config.cloneDir) { ctx.addIssue({ code: z.ZodIssueCode.custom, diff --git a/packages/core/src/config/types.ts b/packages/core/src/config/types.ts index 2b6251c..b163465 100644 --- a/packages/core/src/config/types.ts +++ b/packages/core/src/config/types.ts @@ -20,6 +20,12 @@ export type File = type GitHostingOptions = 'github' | 'gitlab'; +export type Locale = { + label: string; + lang: string; + parameters?: Record; +}; + export interface LunariaConfig { repository: { name: string; @@ -27,8 +33,8 @@ export interface LunariaConfig { rootDir: string; hosting: GitHostingOptions; }; - sourceLocale: string; - locales: [string, ...string[]]; + sourceLocale: Locale; + locales: [Locale, ...Locale[]]; files: [File, ...File[]]; tracking: { ignoredKeywords: string[]; @@ -47,8 +53,8 @@ export interface LunariaUserConfig { rootDir?: string; hosting?: 'github' | 'gitlab'; }; - sourceLocale?: string; - locales?: [string, ...string[]]; + sourceLocale?: Locale; + locales?: [Locale, ...Locale[]]; files?: [File, ...File[]]; tracking?: { ignoredKeywords?: string[]; diff --git a/packages/core/src/files/paths.ts b/packages/core/src/files/paths.ts index 90566b1..31f5d12 100644 --- a/packages/core/src/files/paths.ts +++ b/packages/core/src/files/paths.ts @@ -22,18 +22,47 @@ export function createPathResolver( * We have to change the accepted locales for each pattern, since the source pattern * should only accept the source locale, and the locales pattern should accept all the other locales. */ - const joinedLocales = locales.join('|'); + const joinedLocales = locales.map((locale) => locale.lang).join('|'); // @lang - Matches the locale part of the path. const langPattern = (locales: string) => `:lang(${locales})`; // @path - Matches the rest of the path. const pathPattern = ':path(.*)'; - const placeholders = (locales: string): Record => { - return { - '@lang': langPattern(locales), + // All locales are assumed to have the same parameters, so for simplicity + // we use the source pattern's parameters as a reference of all shared parameters. + const customParameters = sourceLocale.parameters; + + const placeholders = (source: boolean): Record => { + const basePlaceholders = { + '@lang': langPattern(source ? sourceLocale.lang : joinedLocales), '@path': pathPattern, }; + + if (customParameters) { + const placeholderValue = (key: string) => { + // biome-ignore lint/style/noNonNullAssertion: These are ensured to exist by the validation schema. + const sourceParameterValue = sourceLocale.parameters![key]; + // biome-ignore lint/style/noNonNullAssertion: see above + const localeParameterValue = locales.map((locale) => locale.parameters![key]).join('|'); + return source ? sourceParameterValue : localeParameterValue; + }; + + const customPlaceholders = Object.keys(customParameters).reduce( + (acc, param) => { + acc[`@${param}`] = `:${param}(${placeholderValue(param)})`; + return acc; + }, + {} as Record, + ); + + return { + ...basePlaceholders, + ...customPlaceholders, + }; + } + + return basePlaceholders; }; // We accept either a single string pattern or one for souce and localized content @@ -42,8 +71,8 @@ export function createPathResolver( const baseSourcePattern = typeof pattern === 'string' ? pattern : pattern.source; const baseLocalesPattern = typeof pattern === 'string' ? pattern : pattern.locales; - const sourcePattern = stringFromFormat(baseSourcePattern, placeholders(sourceLocale)); - const localesPattern = stringFromFormat(baseLocalesPattern, placeholders(joinedLocales)); + const sourcePattern = stringFromFormat(baseSourcePattern, placeholders(true)); + const localesPattern = stringFromFormat(baseLocalesPattern, placeholders(false)); /* * Originally, this was a strict check to see if the source pattern had the `@path` parameter @@ -54,13 +83,17 @@ export function createPathResolver( * - Locales path: `src/i18n/pt-BR.yml` * - Pattern: `src/i18n/@tag.yml` */ - const parameters = [':lang', ':path']; + const validParameters = [ + ':lang', + ':path', + ...(customParameters ? Object.keys(customParameters).map((param) => `:${param}`) : []), + ]; - if (!hasParameters(sourcePattern, parameters)) { + if (!hasParameters(sourcePattern, validParameters)) { throw new Error(InvalidFilesPattern.message(baseSourcePattern)); } - if (!hasParameters(localesPattern, parameters)) { + if (!hasParameters(localesPattern, validParameters)) { throw new Error(InvalidFilesPattern.message(baseLocalesPattern)); } @@ -82,10 +115,17 @@ export function createPathResolver( // Since the path for the same source and localized content can have different patterns, // we have to check if the `toLang` is from the sourceLocale (i.e. source content) or // from the localized content, meaning we get the correct path always. - const selectedPattern = locales.includes(toLang) ? localesPattern : sourcePattern; + const selectedPattern = locales.map((locale) => locale.lang).includes(toLang) + ? localesPattern + : sourcePattern; const inverseSelectedPattern = selectedPattern === sourcePattern ? localesPattern : sourcePattern; + // We inject the custom parameters as-is for the target locale. + const localeParameters = [sourceLocale, ...locales].find( + (locale) => locale.lang === toLang, + )?.parameters; + const matcher = match(inverseSelectedPattern) as ( path: string, ) => MatchResult<{ lang?: string; path: string }>; @@ -94,6 +134,7 @@ export function createPathResolver( lang: toLang, // We extract the common path from `fromPath` to build the resulting path. path: matcher(fromPath).params.path, + ...localeParameters, }); }, sourcePattern: sourcePattern, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5db70f2..04ca5aa 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -126,7 +126,7 @@ class Lunaria { const { isSourcePath, toPath } = this.getPathResolver(fileConfig.pattern); /** The given path can be of another locale, therefore we always convert it to the source path */ - const sourcePath = isSourcePath(path) ? path : toPath(path, this.#config.sourceLocale); + const sourcePath = isSourcePath(path) ? path : toPath(path, this.#config.sourceLocale.lang); const isLocalizable = await isFileLocalizable( externalSafePath(external, this.#cwd, path), @@ -157,12 +157,12 @@ class Lunaria { return { ...fileConfig, source: { - lang: this.#config.sourceLocale, + lang: this.#config.sourceLocale.lang, path: sourcePath, git: latestSourceChanges, }, localizations: await Promise.all( - this.#config.locales.map(async (lang): Promise => { + this.#config.locales.map(async ({ lang }): Promise => { const localizedPath = toPath(path, lang); if (!(await exists(resolve(externalSafePath(external, this.#cwd, localizedPath))))) { @@ -228,7 +228,7 @@ class Lunaria { const { isSourcePath, toPath } = this.getPathResolver(file.pattern); try { - const sourcePath = isSourcePath(path) ? path : toPath(path, this.#config.sourceLocale); + const sourcePath = isSourcePath(path) ? path : toPath(path, this.#config.sourceLocale.lang); // There's a few cases in which the pattern might match, but the include/exclude filters don't, // therefore we need to test both to find the correct `files` config. diff --git a/packages/core/src/integrations/schema.ts b/packages/core/src/integrations/schema.ts index 214d742..1eba309 100644 --- a/packages/core/src/integrations/schema.ts +++ b/packages/core/src/integrations/schema.ts @@ -1,8 +1,8 @@ import { z } from 'zod'; -import { BaseLunariaConfigSchema, FileSchema } from '../config/schema.js'; +import { BaseLunariaConfigSchema, FileSchema, LocaleSchema } from '../config/schema.js'; export const LunariaPreSetupSchema = BaseLunariaConfigSchema.extend({ - sourceLocale: z.string().optional(), - locales: z.array(z.string()).nonempty().optional(), + sourceLocale: LocaleSchema.optional(), + locales: z.array(LocaleSchema).nonempty().optional(), files: z.array(FileSchema).nonempty().optional(), }); diff --git a/packages/core/src/integrations/types.ts b/packages/core/src/integrations/types.ts index bc95bd7..23ead71 100644 --- a/packages/core/src/integrations/types.ts +++ b/packages/core/src/integrations/types.ts @@ -1,5 +1,5 @@ import type { ConsolaInstance } from 'consola'; -import type { File, LunariaUserConfig } from '../config/types.js'; +import type { File, Locale, LunariaUserConfig } from '../config/types.js'; export interface LunariaIntegration { name: string; @@ -15,7 +15,7 @@ export interface LunariaIntegration { // This type exists to ensure it's a Lunaria user config that has all necessary fields. // We use this to improve types for the Lunaria config during the setup hook. export interface CompleteLunariaUserConfig extends LunariaUserConfig { - sourceLocale: string; - locales: [string, ...string[]]; + sourceLocale: Locale; + locales: [Locale, ...Locale[]]; files: [File, ...File[]]; } diff --git a/packages/core/tests/unit/config-validation.test.ts b/packages/core/tests/unit/config-validation.test.ts index 52bfecb..78c7eaf 100644 --- a/packages/core/tests/unit/config-validation.test.ts +++ b/packages/core/tests/unit/config-validation.test.ts @@ -10,7 +10,17 @@ describe('Configuration validation', () => { }); it('should throw when there are repeated locales', () => { - assert.throws(() => validateFinalConfig({ ...sampleValidConfig, locales: ['en'] })); + assert.throws(() => + validateFinalConfig({ + ...sampleValidConfig, + locales: [ + { + label: 'English', + lang: 'en', + }, + ], + }), + ); }); it('should accept unset `sourceLocale`, `locales`, and `files` before setup hook', () => { @@ -56,4 +66,48 @@ describe('Configuration validation', () => { assert.equal(resultingConfig.repository.name, 'yanthomasdev/lunaria'); assert.equal(resultingConfig.repository.rootDir, 'examples/starlight'); }); + + it('should throw when not all locales share the same parameters keys', () => { + assert.throws(() => + validateFinalConfig({ + ...sampleValidConfig, + sourceLocale: { + lang: 'en', + label: 'English', + parameters: { + tag: 'en', + }, + }, + locales: [ + { + label: 'Simplified Chinese', + lang: 'zh-cn', + parameters: { + tag: 'zh-CN', + some: 'value', + }, + }, + ], + }), + ); + assert.throws(() => + validateFinalConfig({ + ...sampleValidConfig, + sourceLocale: { + lang: 'en', + label: 'English', + }, + locales: [ + { + label: 'Simplified Chinese', + lang: 'zh-cn', + parameters: { + tag: 'zh-CN', + some: 'value', + }, + }, + ], + }), + ); + }); }); diff --git a/packages/core/tests/unit/integrations.test.ts b/packages/core/tests/unit/integrations.test.ts index e94bb06..bc1f393 100644 --- a/packages/core/tests/unit/integrations.test.ts +++ b/packages/core/tests/unit/integrations.test.ts @@ -32,8 +32,15 @@ describe('Integration setup hook', async () => { it('should successfully update the configuration', async () => { const addedConfigFields = { - sourceLocale: 'en', - locales: ['es', 'fr', 'ja'], + sourceLocale: { + label: 'English', + lang: 'en', + }, + locales: [ + { label: 'Spanish', lang: 'es' }, + { label: 'French', lang: 'fr' }, + { label: 'Japanese', lang: 'ja' }, + ], files: [ { include: ['src/content/**/*.mdx'], @@ -76,7 +83,14 @@ describe('Integration setup hook', async () => { setup: async ({ updateConfig }) => new Promise((resolve) => { setTimeout(() => { - resolve(updateConfig({ locales: ['es', 'pt'] })); + resolve( + updateConfig({ + locales: [ + { label: 'Spanish', lang: 'es' }, + { label: 'Português', lang: 'pt' }, + ], + }), + ); }, 50); }), }, @@ -84,7 +98,10 @@ describe('Integration setup hook', async () => { const { integrations, ...expectedConfig } = validateFinalConfig({ ...sampleValidConfig, - locales: ['es', 'pt'], + locales: [ + { label: 'Spanish', lang: 'es' }, + { label: 'Português', lang: 'pt' }, + ], }); const { integrations: _, ...resultingConfig } = await runSetupHook( diff --git a/packages/core/tests/unit/path-resolver.test.ts b/packages/core/tests/unit/path-resolver.test.ts index 361a102..3defc67 100644 --- a/packages/core/tests/unit/path-resolver.test.ts +++ b/packages/core/tests/unit/path-resolver.test.ts @@ -12,7 +12,10 @@ describe('Path resolver', () => { locales: 'src/content/i18n/@lang/@path', }; - const firstResolver = createPathResolver(firstPattern, 'en', ['es', 'pt']); + const firstResolver = createPathResolver(firstPattern, { label: 'English', lang: 'en' }, [ + { label: 'Spanish', lang: 'es' }, + { label: 'Portuguese', lang: 'pt' }, + ]); // Checks if the patterns are correctly converted in a double-string pattern. assert.equal(firstResolver.sourcePattern, 'src/content/docs/:path(.*)'); @@ -24,7 +27,10 @@ describe('Path resolver', () => { */ const secondPattern = 'pages/:path+.@lang.mdx'; - const secondResolver = createPathResolver(secondPattern, 'en', ['es', 'pt']); + const secondResolver = createPathResolver(secondPattern, { label: 'English', lang: 'en' }, [ + { label: 'Spanish', lang: 'es' }, + { label: 'Portuguese', lang: 'pt' }, + ]); // Checks if the pattern is correctly converted in a single-string pattern. // Also checks if the pattern for when `@lang` is correctly converted. @@ -35,7 +41,10 @@ describe('Path resolver', () => { it('should make valid paths from single-string pattern', () => { const pattern = 'src/content/docs/@lang/@path'; - const { toPath } = createPathResolver(pattern, 'en', ['es', 'pt']); + const { toPath } = createPathResolver(pattern, { label: 'English', lang: 'en' }, [ + { label: 'Spanish', lang: 'es' }, + { label: 'Portuguese', lang: 'pt' }, + ]); assert.equal(toPath('src/content/docs/en/test.mdx', 'es'), 'src/content/docs/es/test.mdx'); assert.equal( @@ -54,7 +63,10 @@ describe('Path resolver', () => { locales: 'translations/@lang/@path', }; - const { toPath } = createPathResolver(pattern, 'en', ['es', 'pt']); + const { toPath } = createPathResolver(pattern, { label: 'English', lang: 'en' }, [ + { label: 'Spanish', lang: 'es' }, + { label: 'Portuguese', lang: 'pt' }, + ]); assert.equal(toPath('docs/test.mdx', 'es'), 'translations/es/test.mdx'); assert.equal(toPath('docs/examples/theory.mdx', 'pt'), 'translations/pt/examples/theory.mdx'); @@ -64,7 +76,14 @@ describe('Path resolver', () => { it('should correctly match source and locale paths from single-string pattern', () => { const pattern = 'docs/@lang/@path'; - const { isSourcePath, isLocalesPath } = createPathResolver(pattern, 'pt', ['es', 'en']); + const { isSourcePath, isLocalesPath } = createPathResolver( + pattern, + { label: 'Portuguese', lang: 'pt' }, + [ + { label: 'Spanish', lang: 'es' }, + { label: 'English', lang: 'en' }, + ], + ); assert.equal(isSourcePath('docs/pt/test.mdx'), true); assert.equal(isSourcePath('docs/es/reference/api-reference.mdx'), false); @@ -81,7 +100,14 @@ describe('Path resolver', () => { locales: 'docs/@lang/@path', }; - const { isSourcePath, isLocalesPath } = createPathResolver(pattern, 'pt', ['es', 'en']); + const { isSourcePath, isLocalesPath } = createPathResolver( + pattern, + { label: 'Portuguese', lang: 'pt' }, + [ + { label: 'Spanish', lang: 'es' }, + { label: 'English', lang: 'en' }, + ], + ); assert.equal(isSourcePath('docs/test.mdx'), true); assert.equal(isSourcePath('docs/es/reference/api-reference.mdx'), false); @@ -93,10 +119,35 @@ describe('Path resolver', () => { }); it('should accept any pattern with at least one valid parameter', () => { - assert.throws(() => createPathResolver('docs/path/lang.md', 'en', ['es'])); - assert.doesNotThrow(() => createPathResolver('docs/:path.md', 'en', ['es'])); + assert.throws(() => + createPathResolver('docs/path/lang.md', { label: 'English', lang: 'en' }, [ + { label: 'Spanish', lang: 'es' }, + ]), + ); + assert.doesNotThrow(() => + createPathResolver('docs/:path.md', { label: 'English', lang: 'en' }, [ + { label: 'Spanish', lang: 'es' }, + ]), + ); assert.doesNotThrow(() => - createPathResolver({ source: 'src/ui/@lang.ts', locales: 'src/i18n/@lang.ts' }, 'en', ['es']), + createPathResolver( + { source: 'src/ui/@lang.ts', locales: 'src/i18n/@lang.ts' }, + { label: 'English', lang: 'en' }, + [{ label: 'Spanish', lang: 'es' }], + ), ); }); + + it('should correctly evaluate custom parameters', () => { + const pattern = 'src/i18n/@tag.yml'; + + const { toPath } = createPathResolver( + pattern, + { label: 'English', lang: 'en', parameters: { tag: 'en' } }, + [{ label: 'Simplified Chinese', lang: 'zh-cn', parameters: { tag: 'zh-CN' } }], + ); + + assert.equal(toPath('src/i18n/en.yml', 'zh-cn'), 'src/i18n/zh-CN.yml'); + assert.equal(toPath('src/i18n/zh-CN.yml', 'en'), 'src/i18n/en.yml'); + }); }); diff --git a/packages/core/tests/utils.ts b/packages/core/tests/utils.ts index b5781f0..bdf7e53 100644 --- a/packages/core/tests/utils.ts +++ b/packages/core/tests/utils.ts @@ -4,8 +4,16 @@ export const sampleValidConfig: CompleteLunariaUserConfig = { repository: { name: 'yanthomasdev/lunaria', }, - sourceLocale: 'en', - locales: ['es'], + sourceLocale: { + label: 'English', + lang: 'en', + }, + locales: [ + { + label: 'Spanish', + lang: 'es', + }, + ], files: [ { include: ['src/content/**/*.mdx'],