Skip to content

Commit

Permalink
Add support for custom parameters in patterns
Browse files Browse the repository at this point in the history
  • Loading branch information
yanthomasdev committed Nov 7, 2024
1 parent 0aebd79 commit 0bf48b2
Show file tree
Hide file tree
Showing 10 changed files with 249 additions and 43 deletions.
35 changes: 32 additions & 3 deletions packages/core/src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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,
Expand All @@ -102,6 +108,29 @@ export const LunariaConfigSchema = BaseLunariaConfigSchema.superRefine((config,
locales.add(locale);
}

let params: Array<string> | 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,
Expand Down
14 changes: 10 additions & 4 deletions packages/core/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,21 @@ export type File =

type GitHostingOptions = 'github' | 'gitlab';

export type Locale = {
label: string;
lang: string;
parameters?: Record<string, string>;
};

export interface LunariaConfig {
repository: {
name: string;
branch: string;
rootDir: string;
hosting: GitHostingOptions;
};
sourceLocale: string;
locales: [string, ...string[]];
sourceLocale: Locale;
locales: [Locale, ...Locale[]];
files: [File, ...File[]];
tracking: {
ignoredKeywords: string[];
Expand All @@ -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[];
Expand Down
61 changes: 51 additions & 10 deletions packages/core/src/files/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> => {
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<string, string> => {
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<string, string>,
);

return {
...basePlaceholders,
...customPlaceholders,
};
}

return basePlaceholders;
};

// We accept either a single string pattern or one for souce and localized content
Expand All @@ -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
Expand All @@ -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));
}

Expand All @@ -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 }>;
Expand All @@ -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,
Expand Down
8 changes: 4 additions & 4 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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<StatusLocalizationEntry> => {
this.#config.locales.map(async ({ lang }): Promise<StatusLocalizationEntry> => {
const localizedPath = toPath(path, lang);

if (!(await exists(resolve(externalSafePath(external, this.#cwd, localizedPath))))) {
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/integrations/schema.ts
Original file line number Diff line number Diff line change
@@ -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(),
});
6 changes: 3 additions & 3 deletions packages/core/src/integrations/types.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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[]];
}
56 changes: 55 additions & 1 deletion packages/core/tests/unit/config-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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',
},
},
],
}),
);
});
});
25 changes: 21 additions & 4 deletions packages/core/tests/unit/integrations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down Expand Up @@ -76,15 +83,25 @@ describe('Integration setup hook', async () => {
setup: async ({ updateConfig }) =>
new Promise<void>((resolve) => {
setTimeout(() => {
resolve(updateConfig({ locales: ['es', 'pt'] }));
resolve(
updateConfig({
locales: [
{ label: 'Spanish', lang: 'es' },
{ label: 'Português', lang: 'pt' },
],
}),
);
}, 50);
}),
},
};

const { integrations, ...expectedConfig } = validateFinalConfig({
...sampleValidConfig,
locales: ['es', 'pt'],
locales: [
{ label: 'Spanish', lang: 'es' },
{ label: 'Português', lang: 'pt' },
],
});

const { integrations: _, ...resultingConfig } = await runSetupHook(
Expand Down
Loading

0 comments on commit 0bf48b2

Please sign in to comment.