From 3de528d9d67f48920e3b8fc1af039257d1c23eb1 Mon Sep 17 00:00:00 2001 From: k0d13 <40654585+k0d13@users.noreply.github.com> Date: Fri, 24 Apr 2026 11:26:16 +1200 Subject: [PATCH 01/14] Add support for importing translation files directly, get rid of compile step --- packages/config/src/commands/build.ts | 24 ------- packages/config/src/commands/compile.ts | 21 ------ packages/config/src/commands/extract.ts | 21 +++--- packages/config/src/commands/index.ts | 4 -- .../config/src/features/catalogue/path.ts | 5 ++ .../config/src/features/catalogue/storage.ts | 25 +++++-- .../config/src/features/loader/resolve.ts | 13 +--- .../src/features/runtime/translations.ts | 68 ------------------- .../src/features/workers/build-worker.ts | 41 ----------- .../src/features/workers/compile-worker.ts | 33 --------- .../src/features/workers/extract-worker.ts | 40 +++++++---- packages/config/src/features/workers/index.ts | 3 - .../config/src/features/workers/shared.ts | 6 +- packages/config/src/index.ts | 6 +- packages/config/src/shapes.ts | 18 ++--- packages/format-po/src/formatter.ts | 8 +-- packages/plugin-babel/src/index.ts | 44 +++++++++++- packages/plugin-unplugin/src/index.ts | 21 ++++++ 18 files changed, 140 insertions(+), 261 deletions(-) delete mode 100644 packages/config/src/commands/build.ts delete mode 100644 packages/config/src/commands/compile.ts delete mode 100644 packages/config/src/features/runtime/translations.ts delete mode 100644 packages/config/src/features/workers/build-worker.ts delete mode 100644 packages/config/src/features/workers/compile-worker.ts delete mode 100644 packages/config/src/features/workers/index.ts diff --git a/packages/config/src/commands/build.ts b/packages/config/src/commands/build.ts deleted file mode 100644 index 7a17587..0000000 --- a/packages/config/src/commands/build.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Command } from '@commander-js/extra-typings'; -import { resolveConfig } from '~/features/loader/resolve.js'; -import Logger from '~/features/logger.js'; -import { BucketBuildWorker } from '~/features/workers/build-worker.js'; - -export default new Command() - .name('build') - .description('') - .option('-v, --verbose', 'enable verbose logging', false) - .option('-q, --quiet', 'suppress all logging', false) - // .option('-w, --watch', 'watch source files for changes', false) - .action(async (options) => { - const config = await resolveConfig('saykit'); - const logger = new Logger(options); - logger.header('🏗 Building Messages'); - - const tasks = config.buckets.map(async (bucket) => { - const worker = new BucketBuildWorker(config, bucket, logger); - await worker.buildAll(); - // if (options.watch) await worker.watch(); - }); - - await Promise.all(tasks); - }); diff --git a/packages/config/src/commands/compile.ts b/packages/config/src/commands/compile.ts deleted file mode 100644 index 97568f0..0000000 --- a/packages/config/src/commands/compile.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Command } from '@commander-js/extra-typings'; -import { resolveConfig } from '~/features/loader/resolve.js'; -import Logger from '~/features/logger.js'; -import { BucketCompileWorker } from '~/features/workers/compile-worker.js'; - -export default new Command('compile') - .description('Compile translations into runtime-ready locale files') - .option('-v, --verbose', 'enable verbose logging', false) - .option('-q, --quiet', 'suppress all logging', false) - .action(async (options) => { - const config = await resolveConfig('saykit'); - const logger = new Logger(options); - logger.header('🛠 Compiling Translations'); - - const tasks = config.buckets.map(async (bucket) => { - const worker = new BucketCompileWorker(config, bucket, logger); - await worker.compileAll(); - }); - - await Promise.all(tasks); - }); diff --git a/packages/config/src/commands/extract.ts b/packages/config/src/commands/extract.ts index b2c1c2c..fe8c109 100644 --- a/packages/config/src/commands/extract.ts +++ b/packages/config/src/commands/extract.ts @@ -1,5 +1,5 @@ import { Command } from '@commander-js/extra-typings'; -import { resolveConfig } from '~/features/loader/resolve.js'; +import { resolveConfig } from '~/features/loader/index.js'; import Logger from '~/features/logger.js'; import { BucketExtractWorker } from '~/features/workers/extract-worker.js'; @@ -7,21 +7,18 @@ export default new Command('extract') .description('Extract messages from source files') .option('-v, --verbose', 'enable verbose logging', false) .option('-q, --quiet', 'suppress all logging', false) + .option('-w, --watch', 'watch source files for changes', false) .action(async (options) => { - const config = await resolveConfig('saykit'); + const config = resolveConfig(); const logger = new Logger(options); logger.header('🛠 Extracting Messages'); - const tasks = config.buckets.map(async (bucket) => { - const worker = new BucketExtractWorker(config, bucket, logger); - await worker.scanAll(); - await worker.writeAll(); + const tasks = config.buckets.map(async (b) => { + const worker = new BucketExtractWorker(config, b, logger); + await worker.scan(); + await worker.write(); + if (options.watch) await worker.watch(); }); - const results = await Promise.allSettled(tasks); - const rejections = results.filter((r): r is PromiseRejectedResult => r.status === 'rejected'); - if (rejections.length > 0) { - const errors = rejections.map((r) => r.reason).join('\n'); - throw new Error(`Bucket extraction failed:\n${errors}`); - } + await Promise.all(tasks); }); diff --git a/packages/config/src/commands/index.ts b/packages/config/src/commands/index.ts index 190bc53..d0ad69d 100644 --- a/packages/config/src/commands/index.ts +++ b/packages/config/src/commands/index.ts @@ -1,8 +1,6 @@ #!/usr/bin/env node import { program } from '@commander-js/extra-typings'; -import build from './build.js'; -import compile from './compile.js'; import extract from './extract.js'; program @@ -10,6 +8,4 @@ program .helpOption('-h, --help', 'Display help for command') .helpCommand('help [command]', 'Display help for command') .addCommand(extract) - .addCommand(compile) - .addCommand(build) .parse(); diff --git a/packages/config/src/features/catalogue/path.ts b/packages/config/src/features/catalogue/path.ts index 3c11021..51ee3dd 100644 --- a/packages/config/src/features/catalogue/path.ts +++ b/packages/config/src/features/catalogue/path.ts @@ -11,3 +11,8 @@ export function expandBucketOutputPath( .replaceAll('{extension}', extension.slice(1)); return resolve(outputMessageTemplate); } + +export function expandBucketOutputIgnoreDirectory(bucket: Bucket) { + const [prefix] = bucket.output.split('{locale}'); + return resolve(prefix || '.'); +} diff --git a/packages/config/src/features/catalogue/storage.ts b/packages/config/src/features/catalogue/storage.ts index f6bb175..bfd0523 100644 --- a/packages/config/src/features/catalogue/storage.ts +++ b/packages/config/src/features/catalogue/storage.ts @@ -1,7 +1,12 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; -import { dirname } from 'node:path'; +import { dirname, join } from 'node:path'; import type { Bucket, Message } from '~/shapes.js'; -import { expandBucketOutputPath } from './path.js'; +import { expandBucketOutputIgnoreDirectory, expandBucketOutputPath } from './path.js'; + +const DECLARATION_CONTENT = ` +declare const translations: Record; +export default translations; +`.trim(); export async function readCatalogueMessages( bucket: Bucket, @@ -10,7 +15,7 @@ export async function readCatalogueMessages( ) { const content = await readFile(path, 'utf8').catch(() => ''); if (!content) return []; - return bucket.formatter.parse(content, { locale }); + return bucket.formatter.parse(content); } export async function writeCatalogueMessages( @@ -19,7 +24,17 @@ export async function writeCatalogueMessages( messages: Message[], path = expandBucketOutputPath(bucket, locale), ) { - const content = bucket.formatter.stringify(messages, { locale }); + const catalogueContent = bucket.formatter.stringify(messages); + const declarationPath = `${path}.d.ts`; + const ignoreDirectory = expandBucketOutputIgnoreDirectory(bucket); + const ignorePath = join(ignoreDirectory, '.gitignore'); + const ignoreContent = `.gitignore\n*.d.ts`; + await mkdir(dirname(path), { recursive: true }); - await writeFile(path, content); + await mkdir(ignoreDirectory, { recursive: true }); + await Promise.all([ + writeFile(path, catalogueContent), + writeFile(declarationPath, DECLARATION_CONTENT), + writeFile(ignorePath, ignoreContent), + ]); } diff --git a/packages/config/src/features/loader/resolve.ts b/packages/config/src/features/loader/resolve.ts index d43d678..095cab8 100644 --- a/packages/config/src/features/loader/resolve.ts +++ b/packages/config/src/features/loader/resolve.ts @@ -1,13 +1,8 @@ import { extname } from 'node:path'; -import { Configuration } from '~/shapes.js'; +import type { Config } from '~/shapes.js'; import { findConfigFile } from './files.js'; import { configLoaders } from './module.js'; -function unwrapNamedConfig(config: object, name: string) { - if (!(name in config) || !config[name as keyof object]) return config; - return config[name as keyof object]; -} - export function resolveConfig(name = 'saykit') { const file = findConfigFile(name, process.cwd()); if (!file) throw new Error(`Could not find config file for "${name}"`); @@ -18,10 +13,6 @@ export function resolveConfig(name = 'saykit') { let config = load(file.id, file.content); if (!config || typeof config !== 'object') throw new Error(`Invalid config file for "${name}"`); - config = unwrapNamedConfig(config, name); - - const result = Configuration.safeParse(config); - if (result.error) throw new Error(`Invalid config file for "${name}"`, { cause: result.error }); - return result.data; + return config as Config; } diff --git a/packages/config/src/features/runtime/translations.ts b/packages/config/src/features/runtime/translations.ts deleted file mode 100644 index e13b1a5..0000000 --- a/packages/config/src/features/runtime/translations.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { mkdir, writeFile } from 'node:fs/promises'; -import { dirname } from 'node:path'; -import { expandBucketOutputPath } from '~/features/catalogue/path.js'; -import { generateHash } from '~/features/messages/hash.js'; -import type { Bucket, Configuration, Message } from '~/shapes.js'; -import { readCatalogueMessages } from '../catalogue/storage.js'; - -function getFallbackLocaleChain(config: Configuration, locale: string) { - return [...(config.fallbackLocales?.[locale] ?? []), config.sourceLocale]; -} - -export async function hydrateTranslations( - cache: Map>, - config: Configuration, - bucket: Bucket, - locale: string, - messages: Message[], -) { - if (cache.has(locale)) return cache.get(locale)!; - const translations = await applyFallbackTranslations(cache, config, bucket, locale, messages); - cache.set(locale, translations); - return translations; -} - -export async function applyFallbackTranslations( - cache: Map>, - config: Configuration, - bucket: Bucket, - locale: string, - messages: Message[], -) { - const fallbackLocales = getFallbackLocaleChain(config, locale); - const translations: Record = {}; - - for (const message of messages) { - const key = message.id ?? generateHash(message.message, message.context); - - if (message.translation) { - translations[key] = message.translation; - continue; - } - - for (const fallbackLocale of fallbackLocales) { - const fallbackMessages = // - await readCatalogueMessages(bucket, fallbackLocale); - const fallbackTranslations = // - await hydrateTranslations(cache, config, bucket, fallbackLocale, fallbackMessages); - - if (fallbackTranslations[key]) { - translations[key] = fallbackTranslations[key]; - break; - } - } - } - - return translations; -} - -export async function writeRuntimeTranslations( - bucket: Bucket, - locale: string, - translations: Record, - path = expandBucketOutputPath(bucket, locale, '.json'), -) { - const content = JSON.stringify(translations, null, 2); - await mkdir(dirname(path), { recursive: true }); - await writeFile(path, content); -} diff --git a/packages/config/src/features/workers/build-worker.ts b/packages/config/src/features/workers/build-worker.ts deleted file mode 100644 index 2ccf442..0000000 --- a/packages/config/src/features/workers/build-worker.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { join } from 'node:path'; -import type Logger from '~/features/logger.js'; -import { watchDebounced } from '~/features/watch.js'; -import type { Bucket, Configuration } from '~/shapes.js'; -import { BucketCompileWorker } from './compile-worker.js'; -import { BucketExtractWorker } from './extract-worker.js'; -import { BucketWorker, normalisePathForLogs } from './shared.js'; - -export class BucketBuildWorker extends BucketWorker { - extract: BucketExtractWorker; - compile: BucketCompileWorker; - - constructor(config: Configuration, bucket: Bucket, logger: Logger) { - const parameters = [config, bucket, logger] as const; - super(...parameters); - this.extract = new BucketExtractWorker(...parameters); - this.compile = new BucketCompileWorker(...parameters); - } - - async buildAll() { - await this.extract.scanAll(); - await this.extract.writeAll(); - await this.compile.compileAll(); - } - - async watch() { - this.logger.header(`👀 Watching bucket for changes: ${this.bucket.include}`); - - for await (const event of watchDebounced('.', { recursive: true })) { - if (!event.filename || !this.bucket.match(event.filename)) continue; - - const filePath = join(process.cwd(), event.filename); - const changed = await this.extract.updatePath(filePath); - - if (changed) { - this.logger.info(`Recompiling due to changes in ${normalisePathForLogs(filePath)}`); - await this.compile.compileAll(); - } - } - } -} diff --git a/packages/config/src/features/workers/compile-worker.ts b/packages/config/src/features/workers/compile-worker.ts deleted file mode 100644 index 18f2b73..0000000 --- a/packages/config/src/features/workers/compile-worker.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { readCatalogueMessages } from '~/features/catalogue/storage.js'; -import { hydrateTranslations, writeRuntimeTranslations } from '~/features/runtime/translations.js'; -import { BucketWorker } from './shared.js'; - -export class BucketCompileWorker extends BucketWorker { - #translationsByLocale = new Map>(); - - async compileAll() { - this.logger.info(`Compiling bucket: ${this.bucket.include}`); - - for (const locale of this.config.locales) { - await this.compileLocale(locale); - } - - this.logger.success(`Compilation complete for bucket: ${this.bucket.include}`); - } - - async compileLocale(locale: string) { - this.logger.step(`Compiling locale: ${locale}`); - - const messages = await readCatalogueMessages(this.bucket, locale); - const translations = await hydrateTranslations( - this.#translationsByLocale, - this.config, - this.bucket, - locale, - messages, - ); - - this.logger.step(`Writing runtime file for ${locale}`); - await writeRuntimeTranslations(this.bucket, locale, translations); - } -} diff --git a/packages/config/src/features/workers/extract-worker.ts b/packages/config/src/features/workers/extract-worker.ts index 48a542b..0c97922 100644 --- a/packages/config/src/features/workers/extract-worker.ts +++ b/packages/config/src/features/workers/extract-worker.ts @@ -1,14 +1,14 @@ -import { extractMessagesFromFile } from '~/features/catalogue/extractor.js'; -import { mergeExtractedMessages, reconcileLocaleMessages } from '~/features/catalogue/merge.js'; -import { readCatalogueMessages, writeCatalogueMessages } from '~/features/catalogue/storage.js'; -import { globBucket } from '~/features/watch.js'; -import type { Message } from '~/shapes.js'; -import { BucketWorker, normalisePathForLogs } from './shared.js'; +import { join } from 'node:path'; +import type { Message } from '~/shapes'; +import { extractMessagesFromFile } from '../catalogue/extractor'; +import { mergeExtractedMessages, reconcileLocaleMessages } from '../catalogue/merge'; +import { readCatalogueMessages, writeCatalogueMessages } from '../catalogue/storage'; +import { globBucket, watchDebounced } from '../watch'; +import { BucketWorker, normalisePathForLogs } from './shared'; export class BucketExtractWorker extends BucketWorker { #indexedMessagesByPath = new Map(); - - get messages(): Message[] { + get #indexedMessages() { return Array.from(this.#indexedMessagesByPath.values()).flat(); } @@ -31,18 +31,18 @@ export class BucketExtractWorker extends BucketWorker { return true; } - async scanAll() { + async scan() { this.logger.info(`Scanning bucket: ${this.bucket.include}`); const paths = await globBucket(this.bucket); this.logger.step(`Found ${paths.length} file(s)`); - await Promise.all(paths.map((path) => this.#indexPath(path))); + await Promise.all(paths.map((p) => this.#indexPath(p))); - this.logger.info(`Total extracted messages: ${this.messages.length}`); + this.logger.info(`Total extracted messages: ${this.#indexedMessages.length}`); } - async writeAll() { - const mergedMessages = mergeExtractedMessages(this.messages); + async write() { + const mergedMessages = mergeExtractedMessages(this.#indexedMessages); this.logger.info(`Writing ${mergedMessages.length} messages to locales`); for (const locale of this.config.locales) { @@ -60,9 +60,19 @@ export class BucketExtractWorker extends BucketWorker { this.logger.success(`Extraction complete for bucket: ${this.bucket.include}`); } - async updatePath(path: string) { + async update(path: string) { const changed = await this.#indexPath(path); - if (changed) await this.writeAll(); + if (changed) await this.write(); return changed; } + + async watch() { + this.logger.header(`👀 Watching bucket for changes: ${this.bucket.include}`); + + for await (const event of watchDebounced('.', { recursive: true })) { + if (!event.filename || !this.bucket.match(event.filename)) continue; + const path = join(process.cwd(), event.filename); + await this.update(path); + } + } } diff --git a/packages/config/src/features/workers/index.ts b/packages/config/src/features/workers/index.ts deleted file mode 100644 index 6c472ee..0000000 --- a/packages/config/src/features/workers/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { BucketBuildWorker } from './build-worker.js'; -export { BucketCompileWorker } from './compile-worker.js'; -export { BucketExtractWorker } from './extract-worker.js'; diff --git a/packages/config/src/features/workers/shared.ts b/packages/config/src/features/workers/shared.ts index 50d2ae2..f9c90a2 100644 --- a/packages/config/src/features/workers/shared.ts +++ b/packages/config/src/features/workers/shared.ts @@ -1,17 +1,17 @@ import { relative } from 'node:path'; import type Logger from '~/features/logger.js'; -import type { Bucket, Configuration } from '~/shapes.js'; +import type { Bucket, Config } from '~/shapes.js'; export function normalisePathForLogs(path: string) { return relative(process.cwd(), path).replaceAll('\\', '/'); } export abstract class BucketWorker { - protected config: Configuration; + protected config: Config; protected bucket: Bucket; protected logger: Logger; - constructor(config: Configuration, bucket: Bucket, logger: Logger) { + constructor(config: Config, bucket: Bucket, logger: Logger) { this.config = config; this.bucket = bucket; this.logger = logger; diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 4e9e856..444ebc9 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -1,8 +1,8 @@ import type { input } from 'zod'; -import { Configuration } from './shapes.js'; +import { Config } from './shapes.js'; -export function defineConfig>(config: C) { - return Configuration.parse(config); +export function defineConfig>(config: C) { + return Config.parse(config); } export type * from './shapes.js'; diff --git a/packages/config/src/shapes.ts b/packages/config/src/shapes.ts index c08a550..268cb99 100644 --- a/packages/config/src/shapes.ts +++ b/packages/config/src/shapes.ts @@ -13,12 +13,8 @@ export type Message = z.infer; export const Formatter = z.object({ extension: z.templateLiteral(['.', z.string()]), - parse: z.custom<(content: string, context: { locale: string }) => Message[]>( - (v) => typeof v === 'function', - ), - stringify: z.custom<(messages: Message[], context: { locale: string }) => string>( - (v) => typeof v === 'function', - ), + parse: z.custom<(content: string) => Message[]>((v) => typeof v === 'function'), + stringify: z.custom<(messages: Message[]) => string>((v) => typeof v === 'function'), }); export type Formatter = z.infer; @@ -51,17 +47,21 @@ export const Bucket = z .transform((v) => ({ ...v, match: picomatch(v.include, { ignore: v.exclude }) as (id: string) => boolean, + output: Object.assign(v.output, { + match: picomatch( + v.output.replace('{locale}', '*').replace('{extension}', v.formatter.extension.slice(1)), + ), + }), })); export type Bucket = z.infer; -export const Configuration = z +export const Config = z .object({ sourceLocale: z.string(), locales: z.tuple([z.string()], z.string()), - fallbackLocales: z.record(z.string(), z.string().array()).optional(), buckets: Bucket.array(), }) .refine((config) => config.sourceLocale === config.locales[0], { error: 'sourceLocale must be the same as locales[0]', }); -export type Configuration = z.infer; +export type Config = z.infer; diff --git a/packages/format-po/src/formatter.ts b/packages/format-po/src/formatter.ts index 316a312..ebaf4d0 100644 --- a/packages/format-po/src/formatter.ts +++ b/packages/format-po/src/formatter.ts @@ -19,12 +19,9 @@ function createPoFormatter(options: FormatterOptions = {}): Formatter { return { extension: '.po', - parse(content, context) { + parse(content) { const po = PO.parse(content); - if (po.headers.Language !== context.locale) - throw new Error('PO file locale does not match the expected locale'); - return po.items.map((item) => { const id = item.extractedComments.find((c) => c.startsWith('id:'))?.slice(3); const comments = item.extractedComments // @@ -42,12 +39,11 @@ function createPoFormatter(options: FormatterOptions = {}): Formatter { }); }, - stringify(messages, context) { + stringify(messages) { const po = new PO(); po.headers['Content-Type'] = 'text/plain; charset=UTF-8'; po.headers['Content-Transfer-Encoding'] = '8bit'; - po.headers.Language = context.locale; po.headers['X-Generator'] = 'saykit'; for (const message of messages.sort((a, b) => a.message.localeCompare(b.message))) { diff --git a/packages/plugin-babel/src/index.ts b/packages/plugin-babel/src/index.ts index 53da19d..49f2152 100644 --- a/packages/plugin-babel/src/index.ts +++ b/packages/plugin-babel/src/index.ts @@ -1,6 +1,8 @@ -import { relative } from 'node:path'; -import { type PluginObj, type parse as Parse } from '@babel/core'; +import { readFileSync } from 'node:fs'; +import { dirname, relative, resolve } from 'node:path'; +import { types as t, type PluginObj, type parse as Parse } from '@babel/core'; import { resolveConfig } from '@saykit/config/features/loader'; +import { generateHash } from '@saykit/config/features/messages'; declare module '@babel/core' { interface PluginObj { @@ -13,7 +15,7 @@ export default (): PluginObj => { return { name: 'saykit', - visitor: {}, + parserOverride(code, opts, parse) { const id_ = opts.sourceFileName; if (!id_ || id_.includes('node_modules')) return parse(code, opts); @@ -24,5 +26,41 @@ export default (): PluginObj => { return parse(transformed, opts); }, + + visitor: { + // TODO: This is fragile, it does not work with dynamic imports, document this + ImportDeclaration(path, state) { + const importer = state.filename ?? state.file.opts.filename; + if (!importer) return; + const id_ = resolve(dirname(importer), path.node.source.value); + + const id = relative(process.cwd(), id_).replaceAll('\\', '/').split('?')[0]!; + const bucket = config.buckets.find((b) => b.output.match(id)); + if (!bucket) return; + + const specifier = path.node.specifiers.find((s) => s.type === 'ImportDefaultSpecifier'); + if (!specifier) + throw path.buildCodeFrameError('SayKit inline imports require a default import'); + + const content = readFileSync(id, 'utf8'); + const messages = bucket.formatter.parse(content); + const entries = messages.map( + (m) => [m.id || generateHash(m.message, m.context), m.translation || m.message] as const, + ); + + path.replaceWith( + t.variableDeclaration('const', [ + t.variableDeclarator( + t.identifier(specifier.local.name), + t.objectExpression( + entries.map(([key, value]) => + t.objectProperty(t.stringLiteral(key), t.stringLiteral(value)), + ), + ), + ), + ]), + ); + }, + }, }; }; diff --git a/packages/plugin-unplugin/src/index.ts b/packages/plugin-unplugin/src/index.ts index 9733ec6..9b79813 100644 --- a/packages/plugin-unplugin/src/index.ts +++ b/packages/plugin-unplugin/src/index.ts @@ -1,5 +1,7 @@ +import { readFile } from 'node:fs/promises'; import { relative } from 'node:path'; import { resolveConfig } from '@saykit/config/features/loader'; +import { generateHash } from '@saykit/config/features/messages'; import { createUnplugin } from 'unplugin'; export default createUnplugin((_options?: never) => { @@ -8,6 +10,7 @@ export default createUnplugin((_options?: never) => { return { name: 'saykit', enforce: 'pre', + transform: { filter: { id: { exclude: /node_modules/ } }, handler: (code, id_) => { @@ -16,5 +19,23 @@ export default createUnplugin((_options?: never) => { return bucket?.transformer.transform(code, id) ?? code; }, }, + + load: { + // TODO: Can bucket output be used in this filter? + filter: { id: { exclude: /node_modules/ } }, + handler: async (id_) => { + const id = relative(process.cwd(), id_).replaceAll('\\', '/').split('?')[0]!; + const bucket = config.buckets.find((b) => b.output.match(id)); + if (!bucket) return; + + const content = await readFile(id, 'utf8'); + const messages = bucket.formatter.parse(content); + const entries = messages.map( + (m) => [m.id || generateHash(m.message, m.context), m.translation || m.message] as const, + ); + + return `export default ${JSON.stringify(Object.fromEntries(entries))}`; + }, + }, }; }); From 4c18d59cb09fed6d46967a4fdef3bf1d9274ca73 Mon Sep 17 00:00:00 2001 From: k0d13 <40654585+k0d13@users.noreply.github.com> Date: Fri, 24 Apr 2026 11:28:20 +1200 Subject: [PATCH 02/14] Update examples to use new loader support --- examples/carbon/package.json | 1 - examples/carbon/saykit.config.ts | 10 +++++----- examples/carbon/src/i18n.ts | 4 ++-- .../carbon/src/locales/{en/messages.po => en.po} | 1 - .../carbon/src/locales/{fr/messages.po => fr.po} | 1 - examples/nextjs/next-env.d.ts | 2 +- examples/nextjs/package.json | 1 - examples/nextjs/saykit.config.ts | 12 ++++++------ examples/nextjs/src/i18n.ts | 7 +++---- .../nextjs/src/locales/{en/messages.po => en.po} | 1 - .../nextjs/src/locales/{fr/messages.po => fr.po} | 1 - examples/tanstack-start/package.json | 1 - examples/tanstack-start/saykit.config.ts | 12 ++++++------ examples/tanstack-start/src/i18n.ts | 4 ++-- .../src/locales/{en/messages.po => en.po} | 1 - .../src/locales/{fr/messages.po => fr.po} | 1 - 16 files changed, 25 insertions(+), 35 deletions(-) rename examples/carbon/src/locales/{en/messages.po => en.po} (99%) rename examples/carbon/src/locales/{fr/messages.po => fr.po} (99%) rename examples/nextjs/src/locales/{en/messages.po => en.po} (93%) rename examples/nextjs/src/locales/{fr/messages.po => fr.po} (93%) rename examples/tanstack-start/src/locales/{en/messages.po => en.po} (93%) rename examples/tanstack-start/src/locales/{fr/messages.po => fr.po} (93%) diff --git a/examples/carbon/package.json b/examples/carbon/package.json index 14657b2..c1da054 100644 --- a/examples/carbon/package.json +++ b/examples/carbon/package.json @@ -5,7 +5,6 @@ "scripts": { "check": "tsc --noEmit", "extract": "saykit extract", - "compile": "saykit compile", "build": "tsdown", "dev": "wrangler dev" }, diff --git a/examples/carbon/saykit.config.ts b/examples/carbon/saykit.config.ts index 71fa780..5ebac81 100644 --- a/examples/carbon/saykit.config.ts +++ b/examples/carbon/saykit.config.ts @@ -1,6 +1,6 @@ import { defineConfig } from '@saykit/config'; -import createPoFormatter from '@saykit/format-po'; -import createJsTransformer from '@saykit/transform-js'; +import po from '@saykit/format-po'; +import js from '@saykit/transform-js'; export default defineConfig({ sourceLocale: 'en', @@ -8,9 +8,9 @@ export default defineConfig({ buckets: [ { include: ['src/**/*.ts'], - output: 'src/locales/{locale}/messages.{extension}', - formatter: createPoFormatter(), - transformer: createJsTransformer(), + output: 'src/locales/{locale}.{extension}', + formatter: po(), + transformer: js(), }, ], }); diff --git a/examples/carbon/src/i18n.ts b/examples/carbon/src/i18n.ts index ee345cd..f1d7b0a 100644 --- a/examples/carbon/src/i18n.ts +++ b/examples/carbon/src/i18n.ts @@ -3,8 +3,8 @@ import { Say } from 'saykit'; const say = new Say({ locales: ['en', 'fr'], messages: { - en: await import('./locales/en/messages.json').then((m) => m.default), - fr: await import('./locales/fr/messages.json').then((m) => m.default), + en: await import('./locales/en.po').then((m) => m.default), + fr: await import('./locales/fr.po').then((m) => m.default), }, }); diff --git a/examples/carbon/src/locales/en/messages.po b/examples/carbon/src/locales/en.po similarity index 99% rename from examples/carbon/src/locales/en/messages.po rename to examples/carbon/src/locales/en.po index 2cf2c6e..3a2ff91 100644 --- a/examples/carbon/src/locales/en/messages.po +++ b/examples/carbon/src/locales/en.po @@ -2,7 +2,6 @@ msgid "" msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"Language: en\n" "X-Generator: saykit\n" #: src/commands/maths.ts:17 diff --git a/examples/carbon/src/locales/fr/messages.po b/examples/carbon/src/locales/fr.po similarity index 99% rename from examples/carbon/src/locales/fr/messages.po rename to examples/carbon/src/locales/fr.po index 0305343..ed25cb6 100644 --- a/examples/carbon/src/locales/fr/messages.po +++ b/examples/carbon/src/locales/fr.po @@ -2,7 +2,6 @@ msgid "" msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"Language: fr\n" "X-Generator: saykit\n" #: src/commands/maths.ts:17 diff --git a/examples/nextjs/next-env.d.ts b/examples/nextjs/next-env.d.ts index 1511519..20e7bcf 100644 --- a/examples/nextjs/next-env.d.ts +++ b/examples/nextjs/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import './.next/types/routes.d.ts'; +import './.next/dev/types/routes.d.ts'; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/examples/nextjs/package.json b/examples/nextjs/package.json index 5c092b0..e0af977 100644 --- a/examples/nextjs/package.json +++ b/examples/nextjs/package.json @@ -5,7 +5,6 @@ "scripts": { "check": "tsc --noEmit", "extract": "saykit extract", - "compile": "saykit compile", "dev": "next dev", "build": "next build", "start": "next start" diff --git a/examples/nextjs/saykit.config.ts b/examples/nextjs/saykit.config.ts index b8404f8..554f08b 100644 --- a/examples/nextjs/saykit.config.ts +++ b/examples/nextjs/saykit.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from '@saykit/config'; -import createPoFormatter from '@saykit/format-po'; -import createJsTransformer from '@saykit/transform-js'; -import createJsxTransformer from '@saykit/transform-jsx'; +import po from '@saykit/format-po'; +import js from '@saykit/transform-js'; +import jsx from '@saykit/transform-jsx'; export default defineConfig({ sourceLocale: 'en', @@ -9,9 +9,9 @@ export default defineConfig({ buckets: [ { include: ['src/**/*.{ts,tsx}'], - output: 'src/locales/{locale}/messages.{extension}', - formatter: createPoFormatter(), - transformer: [createJsTransformer(), createJsxTransformer()], + output: 'src/locales/{locale}.{extension}', + formatter: po(), + transformer: [js(), jsx()], }, ], }); diff --git a/examples/nextjs/src/i18n.ts b/examples/nextjs/src/i18n.ts index 5f42da8..92d6870 100644 --- a/examples/nextjs/src/i18n.ts +++ b/examples/nextjs/src/i18n.ts @@ -1,13 +1,12 @@ import 'server-only'; import { unstable_createWithSay } from '@saykit/react/server'; import { Say } from 'saykit'; +import en from './locales/en.po'; +import fr from './locales/fr.po'; const say = new Say({ locales: ['en', 'fr'], - messages: { - en: await import('./locales/en/messages.json').then((m) => m.default), - fr: await import('./locales/fr/messages.json').then((m) => m.default), - }, + messages: { en, fr }, }); export const withSay = unstable_createWithSay(say); diff --git a/examples/nextjs/src/locales/en/messages.po b/examples/nextjs/src/locales/en.po similarity index 93% rename from examples/nextjs/src/locales/en/messages.po rename to examples/nextjs/src/locales/en.po index d3e3491..34fbb1e 100644 --- a/examples/nextjs/src/locales/en/messages.po +++ b/examples/nextjs/src/locales/en.po @@ -2,7 +2,6 @@ msgid "" msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"Language: en\n" "X-Generator: saykit\n" msgid "Hello from the <0>{region}" diff --git a/examples/nextjs/src/locales/fr/messages.po b/examples/nextjs/src/locales/fr.po similarity index 93% rename from examples/nextjs/src/locales/fr/messages.po rename to examples/nextjs/src/locales/fr.po index f0e90df..cc3f33a 100644 --- a/examples/nextjs/src/locales/fr/messages.po +++ b/examples/nextjs/src/locales/fr.po @@ -2,7 +2,6 @@ msgid "" msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"Language: fr\n" "X-Generator: saykit\n" msgid "Hello from the <0>{region}" diff --git a/examples/tanstack-start/package.json b/examples/tanstack-start/package.json index 5bfc775..a63b023 100644 --- a/examples/tanstack-start/package.json +++ b/examples/tanstack-start/package.json @@ -6,7 +6,6 @@ "scripts": { "check": "tsc --noEmit", "extract": "saykit extract", - "compile": "saykit compile", "build": "vite build && tsc --noEmit", "dev": "vite dev" }, diff --git a/examples/tanstack-start/saykit.config.ts b/examples/tanstack-start/saykit.config.ts index b8404f8..554f08b 100644 --- a/examples/tanstack-start/saykit.config.ts +++ b/examples/tanstack-start/saykit.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from '@saykit/config'; -import createPoFormatter from '@saykit/format-po'; -import createJsTransformer from '@saykit/transform-js'; -import createJsxTransformer from '@saykit/transform-jsx'; +import po from '@saykit/format-po'; +import js from '@saykit/transform-js'; +import jsx from '@saykit/transform-jsx'; export default defineConfig({ sourceLocale: 'en', @@ -9,9 +9,9 @@ export default defineConfig({ buckets: [ { include: ['src/**/*.{ts,tsx}'], - output: 'src/locales/{locale}/messages.{extension}', - formatter: createPoFormatter(), - transformer: [createJsTransformer(), createJsxTransformer()], + output: 'src/locales/{locale}.{extension}', + formatter: po(), + transformer: [js(), jsx()], }, ], }); diff --git a/examples/tanstack-start/src/i18n.ts b/examples/tanstack-start/src/i18n.ts index 18ff8ba..010801f 100644 --- a/examples/tanstack-start/src/i18n.ts +++ b/examples/tanstack-start/src/i18n.ts @@ -3,8 +3,8 @@ import { Say } from 'saykit'; const say = new Say({ locales: ['en', 'fr'], messages: { - en: await import('./locales/en/messages.json').then((m) => m.default), - fr: await import('./locales/fr/messages.json').then((m) => m.default), + en: await import('./locales/en.po').then((m) => m.default), + fr: await import('./locales/fr.po').then((m) => m.default), }, }); diff --git a/examples/tanstack-start/src/locales/en/messages.po b/examples/tanstack-start/src/locales/en.po similarity index 93% rename from examples/tanstack-start/src/locales/en/messages.po rename to examples/tanstack-start/src/locales/en.po index 645d445..a58d778 100644 --- a/examples/tanstack-start/src/locales/en/messages.po +++ b/examples/tanstack-start/src/locales/en.po @@ -2,7 +2,6 @@ msgid "" msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"Language: en\n" "X-Generator: saykit\n" msgid "Count: {count}" diff --git a/examples/tanstack-start/src/locales/fr/messages.po b/examples/tanstack-start/src/locales/fr.po similarity index 93% rename from examples/tanstack-start/src/locales/fr/messages.po rename to examples/tanstack-start/src/locales/fr.po index b325550..9b72e52 100644 --- a/examples/tanstack-start/src/locales/fr/messages.po +++ b/examples/tanstack-start/src/locales/fr.po @@ -2,7 +2,6 @@ msgid "" msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"Language: fr\n" "X-Generator: saykit\n" msgid "Count: {count}" From 347a9dad330f7b4d9dee1015c318f3deabc91e91 Mon Sep 17 00:00:00 2001 From: k0d13 <40654585+k0d13@users.noreply.github.com> Date: Fri, 24 Apr 2026 11:38:09 +1200 Subject: [PATCH 03/14] Update website home page to reflect loader and compile changes --- website/app/(home)/layout.tsx | 14 +- website/app/(home)/page.tsx | 544 +++++++++++++++++----------------- 2 files changed, 273 insertions(+), 285 deletions(-) diff --git a/website/app/(home)/layout.tsx b/website/app/(home)/layout.tsx index 851cd91..89a06f2 100644 --- a/website/app/(home)/layout.tsx +++ b/website/app/(home)/layout.tsx @@ -1,7 +1,7 @@ -import { HomeLayout } from 'fumadocs-ui/layouts/home'; -import type { ReactNode } from 'react'; -import { baseOptions } from '@/lib/layout.shared'; - -export default function Layout({ children }: { children: ReactNode }) { - return {children}; -} +import { HomeLayout } from 'fumadocs-ui/layouts/home'; +import type { ReactNode } from 'react'; +import { baseOptions } from '@/lib/layout.shared'; + +export default function Layout({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/website/app/(home)/page.tsx b/website/app/(home)/page.tsx index 8022d47..64bf875 100644 --- a/website/app/(home)/page.tsx +++ b/website/app/(home)/page.tsx @@ -1,278 +1,266 @@ -import { CodeBlock, Pre } from 'fumadocs-ui/components/codeblock'; -import { - ArrowRight, - CheckCircle2, - Code2, - Languages, - PackageCheck, - Rocket, - ScanSearch, - SquareTerminal, -} from 'lucide-react'; -import type { Metadata } from 'next'; -import Link from 'next/link'; -import type { ReactNode } from 'react'; -import { createHighlighter } from 'shiki'; - -const frameworks = ['React', 'Next.js', 'Expo', 'Carbon']; - -const workflow = [ - { - name: 'Define', - icon: Code2, - description: 'Author messages inline with tagged templates or React components.', - }, - { - name: 'Extract', - icon: ScanSearch, - description: 'Scan source files and collect ICU-ready messages into translation files.', - }, - { - name: 'Translate', - icon: Languages, - description: 'Hand translators clean PO files with comments, context, and stable identifiers.', - }, - { - name: 'Compile', - icon: PackageCheck, - description: 'Build locale catalogs into runtime-ready message bundles for your app.', - }, - { - name: 'Deploy', - icon: Rocket, - description: - 'Ship small runtime helpers while keeping extraction and transforms in build time.', - }, -] satisfies { name: string; icon: React.ElementType; description: string }[]; - -const features = [ - 'Compile-time extraction from JS, TS, JSX, and TSX', - 'ICU MessageFormat support for plurals, ordinals, and select', - 'Framework-agnostic core runtime with adapters where needed', - 'Typed config and CLI for extract, compile, and build', -]; - -const CODE_EXAMPLE = `import { Say } from '@saykit/react'; - -export function Inbox({ count }: { count: number }) { - return ( -
-

Inbox

-

- -

-
- ); -}`; - -const SHIKI_THEMES = { dark: 'github-dark', light: 'github-light' } as const; - -export const metadata: Metadata = { - title: 'SayKit', - description: 'Compile-time i18n for JavaScript, TypeScript, React, Next.js, Expo, and Carbon.', -}; - -function Pill({ children }: { children: ReactNode }) { - return ( - - {children} - - ); -} - -export default async function HomePage() { - const highlighter = await createHighlighter({ - themes: Object.values(SHIKI_THEMES), - langs: ['tsx'], - }); - - const highlight = (code: string, lang: 'tsx') => - highlighter.codeToHtml(code, { lang, themes: SHIKI_THEMES, defaultColor: false }); - - const codeHtml = highlight(CODE_EXAMPLE, 'tsx'); - - return ( -
-
- - {/* Hero */} -
-
- Compile-time i18n for modern TypeScript apps - -
-

- Write messages in code. Ship translations with almost no runtime baggage. -

-

- SayKit is a framework-agnostic i18n toolkit built around compile-time extraction, - typed configuration, and small runtime primitives. It keeps authoring ergonomic for - developers and translation flows clean for everyone else. -

-
- -
- - Read the docs - - - View on GitHub - -
- -
- {frameworks.map((f) => ( - {f} - ))} -
-
- - {/* Code cards */} -
- } - title="Code example" - allowCopy={false} - className="rounded-3xl m-0" - > -
-          
-
-          
-
-

- Why it feels good -

-
    - {features.map((f) => ( -
  • - - {f} -
  • - ))} -
-
- -
-

- Terminal -

-
-

- $ - saykit extract -

-

✓ 2 messages extracted

-

→ locales/en.po

-

- $ - saykit compile -

-

✓ 3 locales compiled

-

→ locales/en.json, fr.json, ja.json

-
-
-
-
-
- - {/* Workflow */} -
-
-
-

- End to end workflow -

-

- A simple translation pipeline that stays close to your codebase -

-

- SayKit is designed for the whole path from authoring to deployment, without pushing - app teams into a heavyweight platform. -

-
- -
- {workflow.map(({ name, icon: Icon, description }, i) => ( -
-
-
- -
- - {String(i + 1).padStart(2, '0')} - -
-
-

{name}

-

{description}

-
-
- ))} -
-
-
- - {/* CTA */} -
-
-

- Ready to try it? -

-
-

- Start with the docs, then adapt the example closest to your stack. -

-

- The repo already includes examples for Next.js, TanStack Start, and Carbon, and the - core package is intended to stay useful even outside framework-specific adapters. -

-
-
- - Installation - - - View integrations - -
-
-
-
- ); -} +import { CodeBlock, Pre } from 'fumadocs-ui/components/codeblock'; +import { + ArrowRight, + CheckCircle2, + Code2, + Languages, + Rocket, + ScanSearch, + SquareTerminal, +} from 'lucide-react'; +import type { Metadata } from 'next'; +import Link from 'next/link'; +import type { ReactNode } from 'react'; +import { createHighlighter } from 'shiki'; + +const frameworks = ['React', 'Next.js', 'TanStack Start', 'React Native', 'Expo', 'Carbon']; + +const workflow = [ + { + name: 'Define', + icon: Code2, + description: 'Author messages inline with tagged templates or React components.', + }, + { + name: 'Extract', + icon: ScanSearch, + description: 'Scan source files and collect ICU-ready messages into translation files.', + }, + { + name: 'Translate', + icon: Languages, + description: + 'Hand translators clean, structured translation files with comments, context, and stable identifiers.', + }, + { + name: 'Deploy', + icon: Rocket, + description: + 'Import translation files directly into your app, no compile step, no extra build-time transform. Ship small runtime helpers and keep extraction at dev time.', + }, +] satisfies { name: string; icon: React.ElementType; description: string }[]; + +const features = [ + 'Compile-time extraction from JS, TS, JSX, and TSX', + 'ICU MessageFormat support for plurals, ordinals, and select', + 'Framework-agnostic core runtime with adapters where needed', + 'Import translation files directly, no compile step, no build-time transform', + 'Typed config and CLI with watch mode for instant feedback', +]; + +const CODE_EXAMPLE = `import { Say } from '@saykit/react'; + +export function Inbox({ count }: { count: number }) { + return ( +
+

Inbox

+

+ +

+
+ ); +}`; + +const SHIKI_THEMES = { dark: 'github-dark', light: 'github-light' } as const; + +export const metadata: Metadata = { + title: 'SayKit', + description: 'Compile-time i18n for JavaScript, TypeScript, React, Next.js, Expo, and Carbon.', +}; + +function Pill({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +const highlighter = await createHighlighter({ + themes: Object.values(SHIKI_THEMES), + langs: ['tsx'], +}); +const highlight = (code: string, lang: 'tsx') => + highlighter.codeToHtml(code, { lang, themes: SHIKI_THEMES, defaultColor: false }); +const codeHtml = highlight(CODE_EXAMPLE, 'tsx'); + +export default async function HomePage() { + return ( +
+
+ + {/* Hero */} +
+
+ Compile-time i18n for modern TypeScript apps + +
+

+ Write messages in code. Ship translations with almost no runtime baggage. +

+

+ SayKit is a framework-agnostic i18n toolkit built around compile-time extraction, + typed configuration, and small runtime primitives. It keeps authoring ergonomic for + developers and translation flows clean for everyone else. +

+
+ +
+ + Read the docs + + + View on GitHub + +
+ +
+ {frameworks.map((f) => ( + {f} + ))} +
+
+ + {/* Code cards */} +
+ } + title="Code example" + allowCopy={false} + className="rounded-3xl m-0" + > +
+          
+
+          
+
+

+ Why it feels good +

+
    + {features.map((f) => ( +
  • + + {f} +
  • + ))} +
+
+ +
+

+ Terminal +

+
+

+ $ + saykit extract +

+

✓ 2 messages extracted

+

→ locales/en, fr, ja

+
+
+
+
+
+ + {/* Workflow */} +
+
+
+

+ End to end workflow +

+

+ A simple translation pipeline that stays close to your codebase +

+

+ SayKit is designed for the whole path from authoring to deployment, without pushing + app teams into a heavyweight platform. +

+
+ +
+ {workflow.map(({ name, icon: Icon, description }, i) => ( +
+
+
+ +
+ + {String(i + 1).padStart(2, '0')} + +
+
+

{name}

+

{description}

+
+
+ ))} +
+
+
+ + {/* CTA */} +
+
+

+ Ready to try it? +

+
+

+ Start with the docs, then adapt the example closest to your stack. +

+

+ The repo already includes examples for Next.js, TanStack Start, and Carbon, and the + core package is intended to stay useful even outside framework-specific adapters. +

+
+
+ + Installation + + + View integrations + +
+
+
+
+ ); +} From f96c9acbbfdc11d8d555e90336d1428ac24b9402 Mon Sep 17 00:00:00 2001 From: k0d13 <40654585+k0d13@users.noreply.github.com> Date: Fri, 24 Apr 2026 11:47:02 +1200 Subject: [PATCH 04/14] Update documentation to reflect loader and compile changes Co-authored-by: Copilot --- .../content/core-concepts/configuration.mdx | 23 +++++-------------- website/content/core-concepts/runtime.mdx | 17 +++++--------- .../content/getting-started/installation.mdx | 2 +- .../content/getting-started/introduction.mdx | 10 ++++---- 4 files changed, 18 insertions(+), 34 deletions(-) diff --git a/website/content/core-concepts/configuration.mdx b/website/content/core-concepts/configuration.mdx index 109ad01..64bed30 100644 --- a/website/content/core-concepts/configuration.mdx +++ b/website/content/core-concepts/configuration.mdx @@ -11,8 +11,8 @@ Create a `saykit.config.ts` in your project root: ```ts title='saykit.config.ts' import { defineConfig } from '@saykit/config'; -import createPoFormatter from '@saykit/format-po'; -import createJsTransformer from '@saykit/transform-js'; +import po from '@saykit/format-po'; +import js from '@saykit/transform-js'; export default defineConfig({ sourceLocale: 'en', @@ -21,8 +21,8 @@ export default defineConfig({ { include: ['src/**/*.{ts,tsx}'], output: 'src/locales/{locale}.{extension}', - transformer: createJsTransformer(), - formatter: createPoFormatter(), + transformer: js(), + formatter: po(), }, ], }); @@ -49,11 +49,6 @@ The configuration is type-checked using Zod schemas at runtime: type: '[string, ...string[]]', required: true, }, - fallbackLocales: { - description: - "Define fallback chains for locales. If a message isn't found in a locale, SayKit will check the fallback locales in order.", - type: 'Record', - }, buckets: { description: 'Array of bucket configurations that define which files to extract from and where to output translations.', @@ -152,12 +147,6 @@ Transformers control how messages are extracted and transformed: ), required: true, }, - transform: { - description: - 'Function to transform source code. Must be synchronous - cannot return Promise.', - type: '(code: string, id: string) => string', - required: true, - }, }} /> @@ -177,7 +166,7 @@ Formatters control how messages are serialized to disk: 'Function to parse file content into messages. Must be synchronous - cannot return Promise.', type: ( <> - (content: string, context: {'{ locale: string }'}) {'=>'}{' '} + (content: string) {'=>'}{' '} Message @@ -195,7 +184,7 @@ Formatters control how messages are serialized to disk: Message - [], context: {'{ locale: string }'}) {'=>'} string + []) {'=>'} string ), required: true, diff --git a/website/content/core-concepts/runtime.mdx b/website/content/core-concepts/runtime.mdx index 13b0e66..ed286f1 100644 --- a/website/content/core-concepts/runtime.mdx +++ b/website/content/core-concepts/runtime.mdx @@ -19,17 +19,12 @@ You create a `Say` instance with a list of supported locales, plus either preloa ```ts import { Say } from 'saykit'; +import en from './locales/en.po'; +import fr from './locales/fr.po'; const say = new Say({ locales: ['en', 'fr'], - messages: { - en: await import('./locales/en/messages.json').then((m) => m.default), - fr: await import('./locales/fr/messages.json').then((m) => m.default), - }, - // or with a loader: - async loader(locale) { - return import(`./locales/${locale}/messages.json`).then((m) => m.default); - }, + messages: { en, fr }, }); ``` @@ -164,8 +159,8 @@ In normal application code you usually do not write this by hand. Instead, the c There are two different fallback ideas in SayKit: - runtime locale matching, handled by `say.match()` -- translation fallback chains, configured with `fallbackLocales` during compilation +- translation fallback chains, configured with `fallbackLocales` in your config -Today, the `Say` instance itself does not walk a per-message fallback chain at runtime. Instead, fallback translations are applied when translations are compiled, based on [`fallbackLocales`](/core-concepts/configuration). +Today, the `Say` instance itself does not walk a per-message fallback chain at runtime. Instead, fallback translations are applied during extraction, based on [`fallbackLocales`](/core-concepts/configuration). -That means the runtime instance is responsible for selecting and using one locale, while the compile step is responsible for building the final translation catalogue for that locale. +That means the runtime instance is responsible for selecting and using one locale, while extraction is responsible for building the final translation catalogue for that locale. diff --git a/website/content/getting-started/installation.mdx b/website/content/getting-started/installation.mdx index eb411b4..8d2c903 100644 --- a/website/content/getting-started/installation.mdx +++ b/website/content/getting-started/installation.mdx @@ -8,7 +8,7 @@ description: Install SayKit and set up internationalisation in your project ### Core Packages - `saykit` - Core runtime library with the Say class and message formatting -- `@saykit/config` - Configuration schema and CLI tools for extracting, compiling, and building translations +- `@saykit/config` - Configuration schema and CLI tools for extracting translations ### Framework Integrations diff --git a/website/content/getting-started/introduction.mdx b/website/content/getting-started/introduction.mdx index f42f852..65ebc93 100644 --- a/website/content/getting-started/introduction.mdx +++ b/website/content/getting-started/introduction.mdx @@ -26,11 +26,11 @@ import { Braces, WandSparkles, Puzzle, Languages } from 'lucide-react'; title="Compile-Time Extraction" description="Automatically extract translatable messages from your source code during build" /> - {/* } title="Framework Agnostic" - description="Works with React, Next.js, and any framework that supports plugins" - /> */} + description="Works with React, Carbon, and any framework that supports plugins" + /> } title="ICU MessageFormat" @@ -62,9 +62,9 @@ Install the React, Babel, or unplugin integration for your build setup -### Extract and Compile Messages +### Extract Messages -Use the CLI tools to extract messages and compile translations +Use the CLI to extract messages from your source files. From d65ab89d0d0ea3a1cd338424daa5d950d6f51653 Mon Sep 17 00:00:00 2001 From: k0d13 <40654585+k0d13@users.noreply.github.com> Date: Fri, 24 Apr 2026 12:01:13 +1200 Subject: [PATCH 05/14] Earlier return if importee is not relative Co-authored-by: Copilot --- packages/plugin-babel/src/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/plugin-babel/src/index.ts b/packages/plugin-babel/src/index.ts index 49f2152..e49236c 100644 --- a/packages/plugin-babel/src/index.ts +++ b/packages/plugin-babel/src/index.ts @@ -30,9 +30,11 @@ export default (): PluginObj => { visitor: { // TODO: This is fragile, it does not work with dynamic imports, document this ImportDeclaration(path, state) { + const importee = path.node.source.value; + if (!importee.startsWith('.')) return; const importer = state.filename ?? state.file.opts.filename; if (!importer) return; - const id_ = resolve(dirname(importer), path.node.source.value); + const id_ = resolve(dirname(importer), importee); const id = relative(process.cwd(), id_).replaceAll('\\', '/').split('?')[0]!; const bucket = config.buckets.find((b) => b.output.match(id)); From bb9a8012a67b7df0198917b3d0098348cf9d44f7 Mon Sep 17 00:00:00 2001 From: k0d13 <40654585+k0d13@users.noreply.github.com> Date: Fri, 24 Apr 2026 12:05:28 +1200 Subject: [PATCH 06/14] Make generated gitignore stricter --- packages/config/src/features/catalogue/storage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/config/src/features/catalogue/storage.ts b/packages/config/src/features/catalogue/storage.ts index bfd0523..820335f 100644 --- a/packages/config/src/features/catalogue/storage.ts +++ b/packages/config/src/features/catalogue/storage.ts @@ -28,7 +28,7 @@ export async function writeCatalogueMessages( const declarationPath = `${path}.d.ts`; const ignoreDirectory = expandBucketOutputIgnoreDirectory(bucket); const ignorePath = join(ignoreDirectory, '.gitignore'); - const ignoreContent = `.gitignore\n*.d.ts`; + const ignoreContent = `.gitignore\n*.${bucket.formatter.extension.slice(1)}.d.ts`; await mkdir(dirname(path), { recursive: true }); await mkdir(ignoreDirectory, { recursive: true }); From 292a0dee703a4dc9c3e34777172a2b1705c642b5 Mon Sep 17 00:00:00 2001 From: Kodie <40654585+k0d13@users.noreply.github.com> Date: Fri, 24 Apr 2026 12:16:37 +1200 Subject: [PATCH 07/14] Create twelve-eggs-nail.md --- .changeset/twelve-eggs-nail.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/twelve-eggs-nail.md diff --git a/.changeset/twelve-eggs-nail.md b/.changeset/twelve-eggs-nail.md new file mode 100644 index 0000000..2164fd8 --- /dev/null +++ b/.changeset/twelve-eggs-nail.md @@ -0,0 +1,8 @@ +--- +"unplugin-saykit": patch +"babel-plugin-saykit": patch +"@saykit/format-po": patch +"@saykit/config": patch +--- + +Add support for importing translation files directly From e6713b4256cbfb6ff549ff365d3e4ee60c15faec Mon Sep 17 00:00:00 2001 From: k0d13 <40654585+k0d13@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:29:20 +1200 Subject: [PATCH 08/14] Fix labeller --- .github/labeller.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/labeller.yml b/.github/labeller.yml index 9999593..d14c620 100644 --- a/.github/labeller.yml +++ b/.github/labeller.yml @@ -40,7 +40,7 @@ 'package: unplugin': - changed-files: - - any-glob-to-any-file: ['packages/unplugin/**'] + - any-glob-to-any-file: ['packages/plugin-unplugin/**'] 'package: transform-js': - changed-files: From 86cc099a52d80cf76e6ff4945aa09b248c63fe4c Mon Sep 17 00:00:00 2001 From: k0d13 <40654585+k0d13@users.noreply.github.com> Date: Sun, 26 Apr 2026 09:15:57 +1200 Subject: [PATCH 09/14] Add context field back to formatter stringify --- packages/config/src/features/catalogue/storage.ts | 3 ++- packages/config/src/shapes.ts | 4 +++- packages/format-po/src/formatter.ts | 13 +++++++++---- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/config/src/features/catalogue/storage.ts b/packages/config/src/features/catalogue/storage.ts index 820335f..63b3e4b 100644 --- a/packages/config/src/features/catalogue/storage.ts +++ b/packages/config/src/features/catalogue/storage.ts @@ -24,7 +24,8 @@ export async function writeCatalogueMessages( messages: Message[], path = expandBucketOutputPath(bucket, locale), ) { - const catalogueContent = bucket.formatter.stringify(messages); + const existingContent = await readFile(path, 'utf8').catch(() => undefined); + const catalogueContent = bucket.formatter.stringify(messages, { locale, existingContent }); const declarationPath = `${path}.d.ts`; const ignoreDirectory = expandBucketOutputIgnoreDirectory(bucket); const ignorePath = join(ignoreDirectory, '.gitignore'); diff --git a/packages/config/src/shapes.ts b/packages/config/src/shapes.ts index 268cb99..f45a81d 100644 --- a/packages/config/src/shapes.ts +++ b/packages/config/src/shapes.ts @@ -14,7 +14,9 @@ export type Message = z.infer; export const Formatter = z.object({ extension: z.templateLiteral(['.', z.string()]), parse: z.custom<(content: string) => Message[]>((v) => typeof v === 'function'), - stringify: z.custom<(messages: Message[]) => string>((v) => typeof v === 'function'), + stringify: z.custom< + (messages: Message[], context: { locale: string; existingContent?: string }) => string + >((v) => typeof v === 'function'), }); export type Formatter = z.infer; diff --git a/packages/format-po/src/formatter.ts b/packages/format-po/src/formatter.ts index ebaf4d0..a50106f 100644 --- a/packages/format-po/src/formatter.ts +++ b/packages/format-po/src/formatter.ts @@ -39,12 +39,17 @@ function createPoFormatter(options: FormatterOptions = {}): Formatter { }); }, - stringify(messages) { + stringify(messages, { locale, existingContent }) { const po = new PO(); + if (existingContent) { + const existing = PO.parse(existingContent); + Object.assign(po.headers, existing.headers); + } - po.headers['Content-Type'] = 'text/plain; charset=UTF-8'; - po.headers['Content-Transfer-Encoding'] = '8bit'; - po.headers['X-Generator'] = 'saykit'; + po.headers['Content-Type'] ||= 'text/plain; charset=UTF-8'; + po.headers['Content-Transfer-Encoding'] ||= '8bit'; + po.headers['Language'] ||= locale; + po.headers['X-Generator'] ||= 'saykit'; for (const message of messages.sort((a, b) => a.message.localeCompare(b.message))) { const item = new PO.Item(); From 48bb1cd989002cc1c686c89503d40becc9486e26 Mon Sep 17 00:00:00 2001 From: k0d13 <40654585+k0d13@users.noreply.github.com> Date: Sun, 26 Apr 2026 09:16:09 +1200 Subject: [PATCH 10/14] Make the generated hash ids url safe --- packages/config/src/features/messages/hash.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/config/src/features/messages/hash.ts b/packages/config/src/features/messages/hash.ts index 75c7ada..fcb132c 100644 --- a/packages/config/src/features/messages/hash.ts +++ b/packages/config/src/features/messages/hash.ts @@ -7,5 +7,9 @@ export function generateHash(input: string, context?: string) { const elements = result.match(/.{1,2}/g)?.map((b) => parseInt(b, 16)) || []; const bytes = Uint8Array.from(elements); - return btoa(String.fromCharCode(...bytes)).slice(0, 6); + return btoa(String.fromCharCode(...bytes)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + .slice(0, 6); } From 4ac818e7551233de8363407ce4b4b1ebca317548 Mon Sep 17 00:00:00 2001 From: k0d13 <40654585+k0d13@users.noreply.github.com> Date: Sun, 26 Apr 2026 09:17:09 +1200 Subject: [PATCH 11/14] Rerun extract in examples --- examples/carbon/src/locales/en.po | 8 ++++++++ examples/carbon/src/locales/fr.po | 8 ++++++++ examples/nextjs/src/locales/en.po | 8 ++++++++ examples/nextjs/src/locales/fr.po | 8 ++++++++ examples/tanstack-start/src/locales/en.po | 8 ++++++++ examples/tanstack-start/src/locales/fr.po | 8 ++++++++ 6 files changed, 48 insertions(+) diff --git a/examples/carbon/src/locales/en.po b/examples/carbon/src/locales/en.po index 3a2ff91..88594ab 100644 --- a/examples/carbon/src/locales/en.po +++ b/examples/carbon/src/locales/en.po @@ -1,7 +1,15 @@ msgid "" msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: \n" +"PO-Revision-Date: \n" +"Last-Translator: \n" +"Language: en\n" +"Language-Team: \n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: \n" "X-Generator: saykit\n" #: src/commands/maths.ts:17 diff --git a/examples/carbon/src/locales/fr.po b/examples/carbon/src/locales/fr.po index ed25cb6..fb589de 100644 --- a/examples/carbon/src/locales/fr.po +++ b/examples/carbon/src/locales/fr.po @@ -1,7 +1,15 @@ msgid "" msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: \n" +"PO-Revision-Date: \n" +"Last-Translator: \n" +"Language: fr\n" +"Language-Team: \n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: \n" "X-Generator: saykit\n" #: src/commands/maths.ts:17 diff --git a/examples/nextjs/src/locales/en.po b/examples/nextjs/src/locales/en.po index 34fbb1e..895fd56 100644 --- a/examples/nextjs/src/locales/en.po +++ b/examples/nextjs/src/locales/en.po @@ -1,7 +1,15 @@ msgid "" msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: \n" +"PO-Revision-Date: \n" +"Last-Translator: \n" +"Language: en\n" +"Language-Team: \n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: \n" "X-Generator: saykit\n" msgid "Hello from the <0>{region}" diff --git a/examples/nextjs/src/locales/fr.po b/examples/nextjs/src/locales/fr.po index cc3f33a..410089b 100644 --- a/examples/nextjs/src/locales/fr.po +++ b/examples/nextjs/src/locales/fr.po @@ -1,7 +1,15 @@ msgid "" msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: \n" +"PO-Revision-Date: \n" +"Last-Translator: \n" +"Language: fr\n" +"Language-Team: \n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: \n" "X-Generator: saykit\n" msgid "Hello from the <0>{region}" diff --git a/examples/tanstack-start/src/locales/en.po b/examples/tanstack-start/src/locales/en.po index a58d778..2097d78 100644 --- a/examples/tanstack-start/src/locales/en.po +++ b/examples/tanstack-start/src/locales/en.po @@ -1,7 +1,15 @@ msgid "" msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: \n" +"PO-Revision-Date: \n" +"Last-Translator: \n" +"Language: en\n" +"Language-Team: \n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: \n" "X-Generator: saykit\n" msgid "Count: {count}" diff --git a/examples/tanstack-start/src/locales/fr.po b/examples/tanstack-start/src/locales/fr.po index 9b72e52..bae7c8d 100644 --- a/examples/tanstack-start/src/locales/fr.po +++ b/examples/tanstack-start/src/locales/fr.po @@ -1,7 +1,15 @@ msgid "" msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: \n" +"PO-Revision-Date: \n" +"Last-Translator: \n" +"Language: fr\n" +"Language-Team: \n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: \n" "X-Generator: saykit\n" msgid "Count: {count}" From e97339e00e456be35d0cb0302cd83db13accbe02 Mon Sep 17 00:00:00 2001 From: k0d13 <40654585+k0d13@users.noreply.github.com> Date: Sun, 26 Apr 2026 12:55:21 +1200 Subject: [PATCH 12/14] Refactor to handle extract errors --- packages/config/src/commands/extract.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/config/src/commands/extract.ts b/packages/config/src/commands/extract.ts index fe8c109..0875992 100644 --- a/packages/config/src/commands/extract.ts +++ b/packages/config/src/commands/extract.ts @@ -20,5 +20,11 @@ export default new Command('extract') if (options.watch) await worker.watch(); }); - await Promise.all(tasks); + const results = await Promise.allSettled(tasks); + const failures = results.filter((r): r is PromiseRejectedResult => r.status === 'rejected'); + if (failures.length > 0) + throw new AggregateError( + failures.map((f) => f.reason), + 'One or more buckets failed to extract', + ); }); From d4ee64ddf96fcb9bc748a53c655dc65aed141738 Mon Sep 17 00:00:00 2001 From: k0d13 <40654585+k0d13@users.noreply.github.com> Date: Sun, 26 Apr 2026 12:56:07 +1200 Subject: [PATCH 13/14] Remove fallback references from docs --- website/content/core-concepts/runtime.mdx | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/website/content/core-concepts/runtime.mdx b/website/content/core-concepts/runtime.mdx index ed286f1..ce66b42 100644 --- a/website/content/core-concepts/runtime.mdx +++ b/website/content/core-concepts/runtime.mdx @@ -153,14 +153,3 @@ say.call({ ``` In normal application code you usually do not write this by hand. Instead, the compiler transform rewrites say\`Hello, \$\{name}!\` into the appropriate runtime call for you. - -## Fallbacks - -There are two different fallback ideas in SayKit: - -- runtime locale matching, handled by `say.match()` -- translation fallback chains, configured with `fallbackLocales` in your config - -Today, the `Say` instance itself does not walk a per-message fallback chain at runtime. Instead, fallback translations are applied during extraction, based on [`fallbackLocales`](/core-concepts/configuration). - -That means the runtime instance is responsible for selecting and using one locale, while extraction is responsible for building the final translation catalogue for that locale. From 228d887f274cb7d8208332957ade2fece73ce0b8 Mon Sep 17 00:00:00 2001 From: k0d13 <40654585+k0d13@users.noreply.github.com> Date: Sun, 26 Apr 2026 15:05:42 +1200 Subject: [PATCH 14/14] Refactor extract command to simplify worker task handling and remove error aggregation --- packages/config/src/commands/extract.ts | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/packages/config/src/commands/extract.ts b/packages/config/src/commands/extract.ts index 0875992..084df8a 100644 --- a/packages/config/src/commands/extract.ts +++ b/packages/config/src/commands/extract.ts @@ -13,18 +13,7 @@ export default new Command('extract') const logger = new Logger(options); logger.header('🛠 Extracting Messages'); - const tasks = config.buckets.map(async (b) => { - const worker = new BucketExtractWorker(config, b, logger); - await worker.scan(); - await worker.write(); - if (options.watch) await worker.watch(); - }); - - const results = await Promise.allSettled(tasks); - const failures = results.filter((r): r is PromiseRejectedResult => r.status === 'rejected'); - if (failures.length > 0) - throw new AggregateError( - failures.map((f) => f.reason), - 'One or more buckets failed to extract', - ); + const workers = config.buckets.map((b) => new BucketExtractWorker(config, b, logger)); + await Promise.all(workers.map((w) => w.scan().then(() => w.write()))); + if (options.watch) await Promise.all(workers.map((w) => w.watch())); });