From ee744d10b03ea1a710164e682e6a9c9b5b6d47b1 Mon Sep 17 00:00:00 2001 From: k0d13 Date: Mon, 30 Mar 2026 20:22:07 +1300 Subject: [PATCH 1/3] Make formatter a required option for config bucket, remove support for non js/ts config files --- examples/carbon-tsdown/package.json | 1 + examples/carbon-tsdown/saykit.config.json | 11 -- examples/carbon-tsdown/saykit.config.ts | 14 ++ examples/nextjs-babel/package.json | 1 + examples/nextjs-babel/saykit.config.ts | 2 + packages/config/package.json | 1 - packages/config/src/features/compile.ts | 2 +- packages/config/src/features/extract.ts | 2 +- .../config/src/features/loader/explorer.ts | 14 -- .../config/src/features/loader/loaders.ts | 122 ------------------ .../config/src/features/loader/resolve.ts | 3 +- packages/config/src/shapes.ts | 40 +----- packages/config/tsdown.config.ts | 13 -- pnpm-lock.yaml | 17 +-- 14 files changed, 31 insertions(+), 212 deletions(-) delete mode 100644 examples/carbon-tsdown/saykit.config.json create mode 100644 examples/carbon-tsdown/saykit.config.ts diff --git a/examples/carbon-tsdown/package.json b/examples/carbon-tsdown/package.json index 2779850..b841092 100644 --- a/examples/carbon-tsdown/package.json +++ b/examples/carbon-tsdown/package.json @@ -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" diff --git a/examples/carbon-tsdown/saykit.config.json b/examples/carbon-tsdown/saykit.config.json deleted file mode 100644 index ab85ad0..0000000 --- a/examples/carbon-tsdown/saykit.config.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "$schema": "./node_modules/@saykit/config/dist/schema.json", - "sourceLocale": "en", - "locales": ["en", "fr"], - "buckets": [ - { - "include": ["src/**/*.ts"], - "output": "src/locales/{locale}/messages.{extension}" - } - ] -} diff --git a/examples/carbon-tsdown/saykit.config.ts b/examples/carbon-tsdown/saykit.config.ts new file mode 100644 index 0000000..98c9287 --- /dev/null +++ b/examples/carbon-tsdown/saykit.config.ts @@ -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}.{extension}', + formatter: createFormatter(), + }, + ], +}); diff --git a/examples/nextjs-babel/package.json b/examples/nextjs-babel/package.json index c71dc67..b8be10f 100644 --- a/examples/nextjs-babel/package.json +++ b/examples/nextjs-babel/package.json @@ -20,6 +20,7 @@ "devDependencies": { "@saykit/babel-plugin": "workspace:^", "@saykit/config": "workspace:^", + "@saykit/format-po": "workspace:^", "@types/react": "^19.2.13" } } diff --git a/examples/nextjs-babel/saykit.config.ts b/examples/nextjs-babel/saykit.config.ts index 97568d2..eda11a4 100644 --- a/examples/nextjs-babel/saykit.config.ts +++ b/examples/nextjs-babel/saykit.config.ts @@ -1,4 +1,5 @@ import { defineConfig } from '@saykit/config'; +import createFormatter from '@saykit/format-po'; export default defineConfig({ sourceLocale: 'en', @@ -7,6 +8,7 @@ export default defineConfig({ { include: ['src/**/*.{ts,tsx}'], output: 'src/locales/{locale}/messages.{extension}', + formatter: createFormatter(), }, ], }); diff --git a/packages/config/package.json b/packages/config/package.json index 3bd3dd3..7ac3017 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -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" }, diff --git a/packages/config/src/features/compile.ts b/packages/config/src/features/compile.ts index 49091bb..c5ae42a 100644 --- a/packages/config/src/features/compile.ts +++ b/packages/config/src/features/compile.ts @@ -63,7 +63,7 @@ export async function writeTranslationsToDisk( bucket: Bucket, locale: string, translations: Record, - path = expandOutputPath(bucket, locale, 'json'), + path = expandOutputPath(bucket, locale, '.json'), ) { const content = JSON.stringify(translations, null, 2); await mkdir(dirname(path), { recursive: true }); diff --git a/packages/config/src/features/extract.ts b/packages/config/src/features/extract.ts index ff5efcc..b90dba9 100644 --- a/packages/config/src/features/extract.ts +++ b/packages/config/src/features/extract.ts @@ -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); } diff --git a/packages/config/src/features/loader/explorer.ts b/packages/config/src/features/loader/explorer.ts index a63fc16..4e7fcf9 100644 --- a/packages/config/src/features/loader/explorer.ts +++ b/packages/config/src/features/loader/explorer.ts @@ -3,26 +3,12 @@ import { join } from 'node:path'; function getFilesToTry(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; } diff --git a/packages/config/src/features/loader/loaders.ts b/packages/config/src/features/loader/loaders.ts index 73b351c..df8518a 100644 --- a/packages/config/src/features/loader/loaders.ts +++ b/packages/config/src/features/loader/loaders.ts @@ -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`; @@ -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, diff --git a/packages/config/src/features/loader/resolve.ts b/packages/config/src/features/loader/resolve.ts index 0e97eb6..96d5bff 100644 --- a/packages/config/src/features/loader/resolve.ts +++ b/packages/config/src/features/loader/resolve.ts @@ -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}"`); diff --git a/packages/config/src/shapes.ts b/packages/config/src/shapes.ts index f6fa897..628d946 100644 --- a/packages/config/src/shapes.ts +++ b/packages/config/src/shapes.ts @@ -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'; @@ -16,7 +12,7 @@ export const Message = z.object({ export type Message = z.infer; 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>( (v) => typeof v === 'function', ), @@ -26,39 +22,12 @@ export const Formatter = z.object({ }); export type Formatter = z.infer; -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, @@ -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; diff --git a/packages/config/tsdown.config.ts b/packages/config/tsdown.config.ts index 31aabf6..f9d6529 100644 --- a/packages/config/tsdown.config.ts +++ b/packages/config/tsdown.config.ts @@ -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)); - }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f97c505..847d71b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,6 +72,9 @@ importers: '@saykit/config': specifier: workspace:^ version: link:../../packages/config + '@saykit/format-po': + specifier: workspace:^ + version: link:../../packages/format-po typescript: specifier: ^5.9.3 version: 5.9.3 @@ -106,6 +109,9 @@ importers: '@saykit/config': specifier: workspace:^ version: link:../../packages/config + '@saykit/format-po': + specifier: workspace:^ + version: link:../../packages/format-po '@types/react': specifier: ^19.2.13 version: 19.2.13 @@ -121,9 +127,6 @@ importers: commander: specifier: ^14.0.3 version: 14.0.3 - neverthrow: - specifier: ^8.2.0 - version: 8.2.0 picomatch: specifier: ^4.0.3 version: 4.0.3 @@ -2047,10 +2050,6 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - neverthrow@8.2.0: - resolution: {integrity: sha512-kOCT/1MCPAxY5iUV3wytNFUMUolzuwd/VF/1KCx7kf6CutrOsTie+84zTGTpgQycjvfLdBBdvBvFLqFD2c0wkQ==} - engines: {node: '>=18'} - next@16.1.6: resolution: {integrity: sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==} engines: {node: '>=20.9.0'} @@ -4161,10 +4160,6 @@ snapshots: nanoid@3.3.11: {} - neverthrow@8.2.0: - optionalDependencies: - '@rollup/rollup-linux-x64-gnu': 4.54.0 - next@16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@next/env': 16.1.6 From 9cb028ef2e7da821dd69f5db93ca058e79fe1298 Mon Sep 17 00:00:00 2001 From: k0d13 <40654585+k0d13@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:45:20 +1300 Subject: [PATCH 2/3] Add missing changeset --- .changeset/plain-oranges-wonder.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/plain-oranges-wonder.md diff --git a/.changeset/plain-oranges-wonder.md b/.changeset/plain-oranges-wonder.md new file mode 100644 index 0000000..826fad0 --- /dev/null +++ b/.changeset/plain-oranges-wonder.md @@ -0,0 +1,5 @@ +--- +"@saykit/config": patch +--- + +Make formatter a required option for config bucket From 615afcfe8a36c9053a2a2f39d899b8f2b325a18c Mon Sep 17 00:00:00 2001 From: k0d13 <40654585+k0d13@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:59:17 +1300 Subject: [PATCH 3/3] Update carbon example output path --- examples/carbon-tsdown/saykit.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/carbon-tsdown/saykit.config.ts b/examples/carbon-tsdown/saykit.config.ts index 98c9287..eda11a4 100644 --- a/examples/carbon-tsdown/saykit.config.ts +++ b/examples/carbon-tsdown/saykit.config.ts @@ -7,7 +7,7 @@ export default defineConfig({ buckets: [ { include: ['src/**/*.{ts,tsx}'], - output: 'src/locales/{locale}.{extension}', + output: 'src/locales/{locale}/messages.{extension}', formatter: createFormatter(), }, ],