Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/plain-oranges-wonder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@saykit/config": patch
---

Make formatter a required option for config bucket
1 change: 1 addition & 0 deletions examples/carbon-tsdown/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"devDependencies": {
"@cloudflare/workers-types": "^4.20260210.0",
"@saykit/config": "workspace:^",
"@saykit/format-po": "workspace:^",
"typescript": "^5.9.3",
"unplugin-saykit": "workspace:^",
"wrangler": "^4.64.0"
Expand Down
11 changes: 0 additions & 11 deletions examples/carbon-tsdown/saykit.config.json

This file was deleted.

14 changes: 14 additions & 0 deletions examples/carbon-tsdown/saykit.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { defineConfig } from '@saykit/config';
import createFormatter from '@saykit/format-po';

export default defineConfig({
sourceLocale: 'en',
locales: ['en', 'fr'],
buckets: [
{
include: ['src/**/*.{ts,tsx}'],
output: 'src/locales/{locale}/messages.{extension}',
formatter: createFormatter(),
},
],
});
1 change: 1 addition & 0 deletions examples/nextjs-babel/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"devDependencies": {
"@saykit/babel-plugin": "workspace:^",
"@saykit/config": "workspace:^",
"@saykit/format-po": "workspace:^",
"@types/react": "^19.2.13"
}
}
2 changes: 2 additions & 0 deletions examples/nextjs-babel/saykit.config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { defineConfig } from '@saykit/config';
import createFormatter from '@saykit/format-po';

export default defineConfig({
sourceLocale: 'en',
Expand All @@ -7,6 +8,7 @@ export default defineConfig({
{
include: ['src/**/*.{ts,tsx}'],
output: 'src/locales/{locale}/messages.{extension}',
formatter: createFormatter(),
},
],
});
1 change: 0 additions & 1 deletion packages/config/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@
"@commander-js/extra-typings": "^14.0.0",
"@saykit/babel-plugin": "workspace:*",
"commander": "^14.0.3",
"neverthrow": "^8.2.0",
"picomatch": "^4.0.3",
"zod": "^4.3.6"
},
Expand Down
2 changes: 1 addition & 1 deletion packages/config/src/features/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export async function writeTranslationsToDisk(
bucket: Bucket,
locale: string,
translations: Record<string, string>,
path = expandOutputPath(bucket, locale, 'json'),
path = expandOutputPath(bucket, locale, '.json'),
) {
const content = JSON.stringify(translations, null, 2);
await mkdir(dirname(path), { recursive: true });
Expand Down
2 changes: 1 addition & 1 deletion packages/config/src/features/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ export function expandOutputPath(
) {
const outputMessageTemplate = bucket.output
.replaceAll('{locale}', locale)
.replaceAll('{extension}', extension);
.replaceAll('{extension}', extension.slice(1));
return resolve(outputMessageTemplate);
}

Expand Down
14 changes: 0 additions & 14 deletions packages/config/src/features/loader/explorer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,12 @@ import { join } from 'node:path';

function getFilesToTry<Name extends string>(name: Name) {
return [
`.${name}rc`,
`.${name}rc.json`,
`.${name}rc.yaml`,
`.${name}rc.yml`,
`.${name}rc.js`,
`.${name}rc.cjs`,
`.${name}rc.mjs`,
`.${name}rc.ts`,
`.${name}rc.mts`,
`.${name}rc.cts`,

`${name}.config.json`,
`${name}.config.js`,
`${name}.config.cjs`,
`${name}.config.mjs`,
`${name}.config.ts`,
`${name}.config.mts`,
`${name}.config.cts`,

'package.json',
] as const;
}

Expand Down
122 changes: 0 additions & 122 deletions packages/config/src/features/loader/loaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,112 +24,6 @@ export const js: Loader = async (path, _content) => {
}
};

export const json: Loader = async (_path, content) => {
try {
return JSON.parse(content);
} catch (error) {
throw new Error('Failed to parse JSON', {
cause: error,
});
}
};

namespace YAML {
type YAMLValue = string | number | boolean | null | YAMLObject | YAMLArray;
type YAMLObject = { [key: string]: YAMLValue };
type YAMLArray = YAMLValue[];

type StackFrame = { indent: number; container: YAMLObject | YAMLArray };

export function parse(text: string) {
const lines = text
.split(/\r?\n/)
.filter((l) => l.trim() !== '')
.map((l) => [l.match(/^\s*/)![0].length, l] as const);
const root: YAMLObject = {};
const stack: [StackFrame, ...StackFrame[]] = [{ indent: -1, container: root }];

function parseValue(value: string, fallback?: YAMLValue): YAMLValue {
if (value === 'true') return true;
if (value === 'false') return false;
if (!Number.isNaN(Number(value)) && value !== '') return Number(value);
if (value.startsWith('[') && value.endsWith(']')) {
return value
.slice(1, -1)
.split(',')
.map((e) => parseValue(e.trim()));
}
return value || fallback || value;
}

for (let [indent, line] of lines) {
const parent = stack.at(-1)!;
while (stack.length > 1 && indent <= parent.indent) stack.pop();

if (line.startsWith('- ')) {
line = line.slice(2).trim();

let value: YAMLValue;
if (line === '') {
value = {};
} else if (line.includes(':')) {
const [k, ...r] = line.split(':');
value = { [k!.trim()]: parseValue(r.join(':').trim()) };
} else {
value = parseValue(line);
}

if (!Array.isArray(parent.container)) {
const upper = stack.at(-2)!;

if (!Array.isArray(upper.container)) {
for (const key in upper.container) {
if (upper.container[key] === parent.container) {
upper.container[key] = [value];
stack.push({ indent, container: upper.container[key] });
break;
}
}
} else {
throw new Error('Invalid YAML: expected parent object for array conversion');
}
} else {
parent.container.push(value);
}

if (typeof value === 'object' && value && !Array.isArray(value)) {
stack.push({ indent, container: value });
}
} else {
const [key, ...rest] = line.split(':');
const value = parseValue(rest.join(':').trim(), {});

if (!Array.isArray(parent.container)) {
parent.container[key!.trim()] = value;
} else {
throw new Error('Invalid YAML: cannot assign key to non-object');
}

if (typeof value === 'object' && value && !Array.isArray(value)) {
stack.push({ indent, container: value });
}
}
}

return root;
}
}

export const yaml: Loader = async (_path, content) => {
try {
return YAML.parse(content);
} catch (error) {
throw new Error('Failed to parse YAML', {
cause: error,
});
}
};

export const ts: Loader = async (path, content) => {
const ts = await import('typescript');
const outputPath = `${path}.${Date.now()}.js`;
Expand Down Expand Up @@ -161,26 +55,10 @@ export const ts: Loader = async (path, content) => {
}
};

export const detect: Loader = async (path, content) => {
try {
return await yaml(path, content);
} catch {
try {
return await json(path, content);
} catch {
return undefined;
}
}
};

export default Object.freeze({
'.js': js,
'.mjs': js,
'.cjs': js,
'.json': json,
'.yaml': yaml,
'.yml': yaml,
'': detect,
'.ts': ts,
'.mts': ts,
'.cts': ts,
Expand Down
3 changes: 2 additions & 1 deletion packages/config/src/features/loader/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ export async function useConfig(name = 'saykit') {
if (!file) throw new Error(`Could not find config file for "${name}"`);

const ext = extname(file.id).toLowerCase();
const loader = ext in loaders ? loaders[ext as keyof typeof loaders] : loaders[''];
const loader = ext in loaders ? loaders[ext as keyof typeof loaders] : null;
if (!loader) throw new Error(`Unsupported config file type "${ext}" for "${name}"`);

let config = await loader(file.id, file.content);
if (!config || typeof config !== 'object') throw new Error(`Invalid config file for "${name}"`);
Expand Down
40 changes: 3 additions & 37 deletions packages/config/src/shapes.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
import { createRequire } from 'node:module';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { err, ok } from 'neverthrow';
import picomatch from 'picomatch';
import * as z from 'zod';

Expand All @@ -16,7 +12,7 @@ export const Message = z.object({
export type Message = z.infer<typeof Message>;

export const Formatter = z.object({
extension: z.templateLiteral(['.', z.string()]).transform((v) => v.slice(1)),
extension: z.templateLiteral(['.', z.string()]),
parse: z.custom<(content: string, context: { locale: string }) => Promise<Message[]>>(
(v) => typeof v === 'function',
),
Expand All @@ -26,39 +22,12 @@ export const Formatter = z.object({
});
export type Formatter = z.infer<typeof Formatter>;

async function tryImport(id: string) {
const require = createRequire(join(process.cwd(), 'noop.js'));
try {
const url = pathToFileURL(require.resolve(id));
return ok(await import(url.toString()));
} catch {
return err(`Cannot find package '${id}', required by saykit`);
}
}

export const Bucket = z
.object({
include: z.array(z.string()),
exclude: z.array(z.string()).optional(),
output: z.templateLiteral([z.string(), '{locale}', z.string(), '.{extension}']),

formatter: Formatter.optional().transform(async (formatter, context) => {
if (formatter) return formatter;

const module = await tryImport('@saykit/format-po');
if (module.isErr()) {
context.addIssue(module.error);
return z.NEVER;
}
formatter = module.value.default();

const result = Formatter.safeParse(formatter);
if (result.error) {
for (const issue of result.error.issues) context.addIssue({ ...issue });
return z.NEVER;
}
return result.data;
}),
formatter: Formatter,
})
.transform((v) => ({
...v,
Expand All @@ -73,8 +42,5 @@ export const Configuration = z
fallbackLocales: z.record(z.string(), z.array(z.string())).optional(),
buckets: z.array(Bucket),
})
.refine(
(c) => c.sourceLocale === c.locales[0],
'sourceLocale must be the same as the first locale',
);
.refine((c) => c.sourceLocale === c.locales[0], 'sourceLocale must be the same as locales[0]');
export type Configuration = z.infer<typeof Configuration>;
13 changes: 0 additions & 13 deletions packages/config/tsdown.config.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,5 @@
import { writeFile } from 'node:fs/promises';
import { defineConfig } from 'tsdown';

export default defineConfig({
entry: ['src/index.ts', 'src/commands/index.ts'],
async onSuccess() {
const { Configuration } = await import('./src/shapes.ts');
const schema = Configuration.toJSONSchema({
target: 'draft-7',
io: 'input',
unrepresentable: 'any',
override(ctx) {
if (ctx.path.includes('formatter')) ctx.jsonSchema.type = 'null';
},
});
await writeFile('dist/schema.json', JSON.stringify(schema, null, 2));
},
});
Loading
Loading