diff --git a/neverthrow.md b/neverthrow.md new file mode 100644 index 000000000..a706592b9 --- /dev/null +++ b/neverthrow.md @@ -0,0 +1,106 @@ +# Try/Catch → neverthrow Feasibility Analysis + +## Context + +You asked how many of the repo's `try/catch` blocks could realistically be simplified by adopting +[`neverthrow`](https://github.com/supermacro/neverthrow). This is a scoping report, not an execution plan — it should +let you decide whether/where adopting neverthrow is worth the dependency and refactor cost before committing to any code +changes. + +## Headline numbers + +- **63 try/catch blocks** across **838 TS/JS source files** (~7.5% file prevalence) +- **`neverthrow` is not a current dependency** anywhere in the monorepo +- **No existing custom Result/Either utility** — each package defines its own ad-hoc shapes (`ApiResponse`, + `CommandResult`, …) +- TS posture is friendly to it: TS 5.9, `strict: true`, library-style packaging with declaration files + +## Categorization + +| Category | Description | Count | Good fit for neverthrow? | +| -------- | ----------------------------------------------------------------------------------- | ------: | ------------------------------------------------------------ | +| A | Single fallible call wrapped to transform/handle error | **~28** | ✅ Best candidates — `ResultAsync.fromPromise` one-liners | +| B | Multiple chained fallible operations in one `try` | **~8** | ✅ Idiomatic `andThen` chains | +| C | Boundary / framework integration (CLI entry, React Query `queryFn`, OAuth boundary) | **~15** | ⚠️ Usually must stay — these are where Results get unwrapped | +| D | Resource cleanup (`finally`) or intentional silent-swallow | **~8** | ⚠️ Convertible but loses ergonomics; case-by-case | +| E | Tests / ambiguous | **~4** | n/a | + +**Convertible without changing public API**: ~30–36 blocks (A + most of B). **Convertible only as part of a major +version bump**: most of category C if you also redesign CQRS client return types. + +## Per-package hotspots + +| Package | try/catch | Notes | +| ------------------------------------------------------ | --------: | --------------------------------------------------------------------------- | +| [packages/intl](packages/intl) | 20 | POEditor client + CLI commands — densest, most uniform Category A wrappers | +| [packages/kratos](packages/kratos) | 18 | Mostly Category C (React Query `queryFn`s) + 4 silent-swallow Passkey calls | +| [packages/mail-translation](packages/mail-translation) | 7 | Mostly Category A — error-message-rewriting wrappers | +| [packages/login-manager](packages/login-manager) | 4 | OAuth boundary — Category C | +| Other | 14 | Scattered single-use | + +## Highest-leverage targets (start here if you proceed) + +These are the cleanest Category A blocks — converting them gives the most "look at the diff" payoff for the least risk, +because they're internal to their packages and don't change cross-package surfaces: + +1. [packages/intl/src/poeditor/POEditorClient.ts:43-59](packages/intl/src/poeditor/POEditorClient.ts#L43) — 5 methods, + each wraps a single API call to rethrow with context. Textbook `ResultAsync.fromPromise(call, mapErr)`. +2. [packages/mail-translation/src/compileMjml.ts:18](packages/mail-translation/src/compileMjml.ts#L18) and + [packages/mail-translation/src/loadConfig.ts:36](packages/mail-translation/src/loadConfig.ts#L36) — single-call + wrappers that enhance error messages. +3. [packages/intl/src/formatjs.ts:17](packages/intl/src/formatjs.ts#L17) — two CLI command wrappers. +4. [packages/image-uploader/src/lib/\_utils/tryUploadWithUploadParams.ts:37-55](packages/image-uploader/src/lib/_utils/tryUploadWithUploadParams.ts#L37) + — currently swallows original error; Result preserves it without uglifying control flow. +5. [packages/intl/src/commands/download.ts:17-49](packages/intl/src/commands/download.ts#L17) and + [packages/intl/src/commands/local.ts:30-89](packages/intl/src/commands/local.ts#L30) — Category B chains that would + read well as `.andThen(...).andThen(...)`. + +## What should _not_ move + +- **CQRS client return types** ([fetch-client](packages/cqrs-clients/fetch-client), + [axios-cqrs-client](packages/cqrs-clients/axios-cqrs-client), [rx-cqrs-client](packages/cqrs-clients/rx-cqrs-client), + [react-query-cqrs-client](packages/cqrs-clients/react-query-cqrs-client)) — these are public APIs of 6 published + packages; changing them is a major version bump and overlaps the existing `ApiResponse` / `CommandResult` design + from [cqrs-client-base](packages/cqrs-clients/cqrs-client-base). +- **rx-cqrs-client** — RxJS has its own error channel; mixing `Result` inside Observables adds cognitive overhead with + little gain. +- **React Query `queryFn` handlers in kratos** + ([packages/kratos/src/lib/flows/login/hooks/useGetLoginFlow.ts:22-30](packages/kratos/src/lib/flows/login/hooks/useGetLoginFlow.ts#L22) + and ~9 siblings) — React Query _expects_ throws to drive its error state, so converting these gains nothing. +- **Silent-swallow passkey calls** + ([packages/kratos/src/lib/utils/passkeys/index.ts:22-37](packages/kratos/src/lib/utils/passkeys/index.ts#L22)) — these + are _intentionally_ returning `undefined` on error. Convertible to `Result`, but only if you actually want callers to + handle the absence explicitly. +- **CLI top-level entry points** in [packages/intl/src/commands/](packages/intl/src/commands/) — these are where Results + get unwrapped to exit codes; they should remain try/catch (or `.match()`). + +## Honest assessment + +**~36 of 63 blocks (≈57%) are technically convertible** without touching public APIs. Of those, **~10–12 would +meaningfully improve readability or correctness** (the Category A wrappers in `intl/poeditor` and `mail-translation`, +plus the swallow-bug in `image-uploader`). The rest are short single-use blocks where neverthrow adds a dependency for +marginal stylistic win. + +**Recommendation**: this isn't a "rip out all try/catch" situation. If you want to adopt neverthrow: + +- Pilot it in **`packages/intl`** only (densest cluster, no external API breakage), specifically + `poeditor/POEditorClient.ts` + the two CLI command files. ~15 blocks converted, 1 package gets the dep, you can + reverse course cheaply if the team dislikes it. +- Re-evaluate after that pilot before touching `mail-translation`, `image-uploader`, or any CQRS client. + +## What I would not recommend + +- Adding neverthrow as a peer dep of `cqrs-client-base` or any of the CQRS clients in this pass — that's a multi-package + coordinated breaking change masquerading as a refactor. +- Converting the kratos React Query `queryFn`s — net negative. + +## Verification of this report + +Findings are derived from a ripgrep + read sweep over the repo. To spot-check: + +``` +rg -n --type ts --type tsx -g '!**/node_modules' -g '!**/dist' -g '!**/coverage' '\bcatch\s*\(' packages | wc -l +rg -n --type ts --type tsx -g '!**/node_modules' '"neverthrow"' . +``` + +The first should land near 63; the second should return nothing (confirming it isn't currently a dep). diff --git a/package-lock.json b/package-lock.json index 27afe81b9..527dbd7bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26188,6 +26188,18 @@ "node": ">= 0.4.0" } }, + "node_modules/neverthrow": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/neverthrow/-/neverthrow-8.2.0.tgz", + "integrity": "sha512-kOCT/1MCPAxY5iUV3wytNFUMUolzuwd/VF/1KCx7kf6CutrOsTie+84zTGTpgQycjvfLdBBdvBvFLqFD2c0wkQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@rollup/rollup-linux-x64-gnu": "^4.24.0" + } + }, "node_modules/nise": { "version": "5.1.9", "dev": true, @@ -35155,6 +35167,7 @@ "license": "Apache-2.0", "dependencies": { "@leancodepl/utils": "10.3.1", + "neverthrow": "^8.2.0", "react-dropzone": "14.3.8", "react-easy-crop": "5.5.0", "uuid": "11.1.0" @@ -35180,6 +35193,7 @@ "commander": "^12.0.0", "lilconfig": "^3.1.3", "lodash": "^4.17.23", + "neverthrow": "^8.2.0", "tslib": "^2.8.1", "zod": "^4.0.5" }, @@ -35703,6 +35717,7 @@ "js-beautify": "^1.15.4", "lilconfig": "^3.1.3", "mjml": "^4.17.1", + "neverthrow": "^8.2.0", "tslib": "^2.8.1", "yaml": "^2.8.0", "yargs": "^18.0.0", diff --git a/packages/image-uploader/package.json b/packages/image-uploader/package.json index 01ad31170..51b0c3d5c 100644 --- a/packages/image-uploader/package.json +++ b/packages/image-uploader/package.json @@ -23,6 +23,7 @@ }, "dependencies": { "@leancodepl/utils": "10.3.1", + "neverthrow": "^8.2.0", "react-dropzone": "14.3.8", "react-easy-crop": "5.5.0", "uuid": "11.1.0" diff --git a/packages/image-uploader/src/index.ts b/packages/image-uploader/src/index.ts index e33c09aac..0f8178e8c 100644 --- a/packages/image-uploader/src/index.ts +++ b/packages/image-uploader/src/index.ts @@ -1,5 +1,6 @@ export { ErrorCode } from "./lib/_utils/errors" export * from "./lib/_utils/tryUploadWithUploadParams" +export { formatUploadError, type UploadError } from "./lib/_utils/UploadError" export * from "./lib/UploadImages" export * from "./lib/types" export { useUploadImages, type UseUploadImagesProps } from "./lib/_hooks/useUploadImages" diff --git a/packages/image-uploader/src/lib/_utils/UploadError.ts b/packages/image-uploader/src/lib/_utils/UploadError.ts new file mode 100644 index 000000000..f93a11385 --- /dev/null +++ b/packages/image-uploader/src/lib/_utils/UploadError.ts @@ -0,0 +1,28 @@ +export type UploadError = + | { kind: "getUploadParamsFailed"; cause: unknown } + | { kind: "imageNotDefined" } + | { kind: "noUploadParams" } + | { kind: "uploadRequestFailed"; cause: unknown } + | { kind: "uploadResponseNotOk"; status: number; statusText: string } + +export function formatUploadError(error: UploadError): string { + switch (error.kind) { + case "imageNotDefined": + return "Image is not defined" + case "getUploadParamsFailed": + return `Failed to obtain upload params: ${stringifyCause(error.cause)}` + case "noUploadParams": + return "Upload params are not defined" + case "uploadRequestFailed": + return `Upload request failed: ${stringifyCause(error.cause)}` + case "uploadResponseNotOk": + return `Upload server responded with ${error.status} ${error.statusText}` + } +} + +function stringifyCause(cause: unknown): string { + if (cause instanceof Error) { + return cause.message + } + return String(cause) +} diff --git a/packages/image-uploader/src/lib/_utils/tryUploadWithUploadParams.ts b/packages/image-uploader/src/lib/_utils/tryUploadWithUploadParams.ts index 3614d8d68..0b606c681 100644 --- a/packages/image-uploader/src/lib/_utils/tryUploadWithUploadParams.ts +++ b/packages/image-uploader/src/lib/_utils/tryUploadWithUploadParams.ts @@ -1,4 +1,6 @@ +import { errAsync, okAsync, ResultAsync } from "neverthrow" import { FileWithId, UploadedFileWithId, UploadParams } from "../types" +import { UploadError } from "./UploadError" /** * Uploads an image file using provided upload parameters. @@ -8,49 +10,60 @@ import { FileWithId, UploadedFileWithId, UploadParams } from "../types" * * @param image - Image file with ID or already uploaded image with URL * @param getUploadParams - Function that returns upload parameters (URI, method, headers) for the image - * @returns Promise resolving to uploaded image with URL - * @throws {Error} When upload params are not defined, image is not defined, or upload fails + * @returns ResultAsync resolving to the uploaded image with URL, or a typed `UploadError` on failure * * @example * ```typescript * import { tryUploadWithUploadParams } from "@leancodepl/image-uploader"; * - * const uploadedImage = await tryUploadWithUploadParams(image, async (img) => ({ + * const result = await tryUploadWithUploadParams(image, async (img) => ({ * uri: "https://api.example.com/upload", * method: "POST", * requiredHeaders: { "Content-Type": "image/jpeg" } * })); + * + * if (result.isErr()) { + * // result.error is a typed UploadError; switch on result.error.kind + * } else { + * const uploadedImage = result.value; + * } * ``` */ -export async function tryUploadWithUploadParams( +export function tryUploadWithUploadParams( image: FileWithId | UploadedFileWithId, getUploadParams: (image: FileWithId) => Promise, -): Promise { +): ResultAsync { if ("url" in image) { - return image + return okAsync(image) } if (!image.originalFile) { - throw new Error("Image is not defined") + return errAsync({ kind: "imageNotDefined" }) } - try { - const uploadParams = await getUploadParams(image) + const fileImage = image + return ResultAsync.fromPromise( + getUploadParams(fileImage), + (cause): UploadError => ({ kind: "getUploadParamsFailed", cause }), + ).andThen(uploadParams => { if (!uploadParams) { - throw new Error("Upload params are not defined") + return errAsync({ kind: "noUploadParams" }) } - const { uri, method, requiredHeaders } = uploadParams - const response = await fetch(uri, { method, headers: requiredHeaders, body: image.originalFile }) - - if (!response.ok) { - throw new Error("Failed to upload image") - } - - return { ...image, url: uri } - } catch { - throw new Error("Failed to upload image") - } + return ResultAsync.fromPromise( + fetch(uri, { method, headers: requiredHeaders, body: fileImage.originalFile }), + (cause): UploadError => ({ kind: "uploadRequestFailed", cause }), + ).andThen(response => { + if (!response.ok) { + return errAsync({ + kind: "uploadResponseNotOk", + status: response.status, + statusText: response.statusText, + }) + } + return okAsync({ ...fileImage, url: uri }) + }) + }) } diff --git a/packages/intl/package.json b/packages/intl/package.json index a1a9be2e6..5a309be8f 100644 --- a/packages/intl/package.json +++ b/packages/intl/package.json @@ -26,6 +26,7 @@ "commander": "^12.0.0", "lilconfig": "^3.1.3", "lodash": "^4.17.23", + "neverthrow": "^8.2.0", "tslib": "^2.8.1", "zod": "^4.0.5" }, diff --git a/packages/intl/src/TranslationsServiceClient.ts b/packages/intl/src/TranslationsServiceClient.ts index c89924008..45ed4443b 100644 --- a/packages/intl/src/TranslationsServiceClient.ts +++ b/packages/intl/src/TranslationsServiceClient.ts @@ -1,4 +1,13 @@ +import { ResultAsync } from "neverthrow" import type { ExtractedMessages } from "./formatjs" +import type { + DownloadTermsError, + DownloadTranslationsError, + GetTranslationsInDefaultLanguageError, + RemoveTermsError, + UploadTermsError, + UploadTranslationsError, +} from "./poeditor/POEditorError" export interface Term { term: string @@ -10,10 +19,12 @@ export interface Term { type TermToRemove = Pick export interface TranslationsServiceClient { - downloadTranslations(language: string): Promise> - uploadTerms(messages: ExtractedMessages): Promise - uploadTranslations(messages: ExtractedMessages, language: string): Promise - downloadTerms(): Promise - removeTerms(terms: TermToRemove[]): Promise - getTranslationsInDefaultLanguage(terms: Term[]): Promise<{ term: string; translation: string }[]> + downloadTranslations(language: string): ResultAsync, DownloadTranslationsError> + uploadTerms(messages: ExtractedMessages): ResultAsync + uploadTranslations(messages: ExtractedMessages, language: string): ResultAsync + downloadTerms(): ResultAsync + removeTerms(terms: TermToRemove[]): ResultAsync + getTranslationsInDefaultLanguage( + terms: Term[], + ): ResultAsync<{ term: string; translation: string }[], GetTranslationsInDefaultLanguageError> } diff --git a/packages/intl/src/commands/diff.ts b/packages/intl/src/commands/diff.ts index 0282072c9..71b2a9613 100644 --- a/packages/intl/src/commands/diff.ts +++ b/packages/intl/src/commands/diff.ts @@ -4,6 +4,7 @@ import { z } from "zod/v4" import type { TranslationsServiceClient } from "../TranslationsServiceClient" import { extractMessages } from "../formatjs" import { logger } from "../logger" +import { type GetTranslationsInDefaultLanguageError, stringifyCause } from "../poeditor/POEditorError" export const diffCommandOptionsSchema = z.object({ srcPattern: z.string(), @@ -14,51 +15,91 @@ export type DiffCommandOptions = z.infer & { } export async function diff({ srcPattern, translationsServiceClient }: DiffCommandOptions) { - try { - logger.info("Analyzing differences between local and remote translations...") + logger.info("Analyzing differences between local and remote translations...") - logger.info("Extracting local messages...") - const localMessages = extractMessages(srcPattern) - const localTerms = new Set(Object.keys(localMessages)) + logger.info("Extracting local messages...") + const localMessagesResult = extractMessages(srcPattern) + if (localMessagesResult.isErr()) { + logger.error(`Failed to extract messages: ${localMessagesResult.error.message}`) + process.exit(1) + } + const localTerms = new Set(Object.keys(localMessagesResult.value)) + + logger.info("Fetching remote terms...") + const remoteTermsResult = await translationsServiceClient.downloadTerms() + if (remoteTermsResult.isErr()) { + logger.error(`Failed to fetch remote terms from POEditor: ${stringifyCause(remoteTermsResult.error.cause)}`) + process.exit(1) + } + const remoteTerms = remoteTermsResult.value + const remoteTermSet = new Set(remoteTerms.map(term => term.term)) + + const unusedInLocal = [...remoteTermSet].filter(term => !localTerms.has(term)) + + const defaultLanguageResult = await translationsServiceClient.getTranslationsInDefaultLanguage(remoteTerms) + if (defaultLanguageResult.isErr()) { + exitOnDefaultLanguageFailure(defaultLanguageResult.error) + } + const translationsInDefaultLanguage = defaultLanguageResult.value - logger.info("Fetching remote terms...") - const remoteTerms = await translationsServiceClient.downloadTerms() - const remoteTermSet = new Set(remoteTerms.map(term => term.term)) + if (unusedInLocal.length === 0) { + logger.info("\nNo unused terms found in remote") + return + } - const unusedInLocal = [...remoteTermSet].filter(term => !localTerms.has(term)) - const translationsInDefaultLanguage = await translationsServiceClient.getTranslationsInDefaultLanguage(remoteTerms) - if (unusedInLocal.length > 0) { - logger.info(`\nTerms in remote but not used locally (${unusedInLocal.length}):`) - const termsToRemove = await checkbox({ - message: "Select terms to remove", - choices: unusedInLocal.map(term => { - const translation = translationsInDefaultLanguage.find(t => t.term === term)?.translation - return { name: `${term} $`, value: term } - }), - }) + logger.info(`\nTerms in remote but not used locally (${unusedInLocal.length}):`) + const termsToRemove = await checkbox({ + message: "Select terms to remove", + choices: unusedInLocal.map(term => { + const translation = translationsInDefaultLanguage.find(t => t.term === term)?.translation + return { name: `${term} $`, value: term } + }), + }) - if (termsToRemove.length === 0) { - return - } + if (termsToRemove.length === 0) { + return + } - const confirmation = await confirm({ - message: `Are you sure you want to remove these terms: ${termsToRemove.join(", ")}?`, - }) + const confirmation = await confirm({ + message: `Are you sure you want to remove these terms: ${termsToRemove.join(", ")}?`, + }) - if (!confirmation) { - return - } + if (!confirmation) { + return + } - const termsToRemoveWithContext = remoteTerms.filter(term => termsToRemove.includes(term.term)) - logger.info(`\nRemoving selected terms from remote...`) + const termsToRemoveWithContext = remoteTerms.filter(term => termsToRemove.includes(term.term)) + logger.info(`\nRemoving selected terms from remote...`) - await translationsServiceClient.removeTerms(termsToRemoveWithContext) - logger.success(`\nTerms removed successfully`) - } else { - logger.info("\nNo unused terms found in remote") - } - } catch (error) { - logger.error("Error in diff command:", error as Error) + const removeResult = await translationsServiceClient.removeTerms(termsToRemoveWithContext) + if (removeResult.isErr()) { + logger.error(`Failed to remove terms from POEditor: ${stringifyCause(removeResult.error.cause)}`) process.exit(1) } + logger.success(`\nTerms removed successfully`) +} + +function exitOnDefaultLanguageFailure(error: GetTranslationsInDefaultLanguageError): never { + switch (error.kind) { + case "viewProjectFailed": + logger.error(`Failed to read POEditor project metadata: ${stringifyCause(error.cause)}`) + break + case "noReferenceLanguage": + logger.error("POEditor project has no reference language configured") + break + case "downloadTranslationsFailed": + logger.error( + `POEditor API call failed while loading reference translations for ${error.language}: ${stringifyCause(error.cause)}`, + ) + break + case "noDownloadUrl": + logger.error(`POEditor returned no download URL for reference language ${error.language}`) + break + case "downloadTranslationsContentFailed": + logger.error( + `Failed to fetch reference translations for ${error.language} from ${error.url}: ${stringifyCause(error.cause)}`, + ) + break + } + process.exit(1) } diff --git a/packages/intl/src/commands/download.ts b/packages/intl/src/commands/download.ts index c704f56e2..84fdfdc44 100644 --- a/packages/intl/src/commands/download.ts +++ b/packages/intl/src/commands/download.ts @@ -3,6 +3,7 @@ import { z } from "zod/v4" import type { TranslationsServiceClient } from "../TranslationsServiceClient" import { compileTranslations, createTranslationsTempDir, writeTranslationsToTempDir } from "../formatjs" import { logger } from "../logger" +import { type DownloadTranslationsError, stringifyCause } from "../poeditor/POEditorError" export const downloadCommandOptionsSchema = z.object({ outputDir: z.string(), @@ -14,36 +15,57 @@ export type DownloadCommandOptions = z.infer { logger.info("Extracting messages from source files...") - const messages = extractMessages(srcPattern) + const messagesResult = extractMessages(srcPattern) + if (messagesResult.isErr()) { + logger.error(`Failed to extract messages: ${messagesResult.error.message}`) + process.exit(1) + } + const messages = messagesResult.value const messageCount = Object.keys(messages).length logger.info(`Extracted ${messageCount} messages`) if (messageCount === 0) { logger.error("No messages found. Make sure your source files contain formatjs messages.") - throw new Error("No messages found") + process.exit(1) } const tempDir = createTranslationsTempDir("local-") @@ -76,7 +77,11 @@ function extractAndCompile({ const tempOutputDir = createTranslationsTempDir("compiled-") try { - compileTranslations({ inputDir: tempDir, outputDir: tempOutputDir }) + const compileResult = compileTranslations({ inputDir: tempDir, outputDir: tempOutputDir }) + if (compileResult.isErr()) { + logger.error(`Failed to compile extracted translations: ${compileResult.error.message}`) + process.exit(1) + } const compiledFilePath = join(tempOutputDir, `${defaultLanguage}.json`) const compiledContent = readFileSync(compiledFilePath, "utf-8") @@ -96,46 +101,65 @@ async function downloadAndCompile({ defaultLanguage: string client: TranslationsServiceClient }): Promise | undefined> { - try { - logger.info(`Downloading ${defaultLanguage} translations...`) - - const downloadedTranslations = await client.downloadTranslations(defaultLanguage) - const downloadedCount = Object.keys(downloadedTranslations).length - logger.info(`Downloaded ${downloadedCount} translations`) + logger.info(`Downloading ${defaultLanguage} translations...`) - if (downloadedCount === 0) { - return undefined - } + const downloadResult = await client.downloadTranslations(defaultLanguage) + if (downloadResult.isErr()) { + warnDownloadFailure(downloadResult.error) + return undefined + } + const downloadedTranslations = downloadResult.value + const downloadedCount = Object.keys(downloadedTranslations).length + logger.info(`Downloaded ${downloadedCount} translations`) - const downloadTempDir = createTranslationsTempDir("download-") + if (downloadedCount === 0) { + return undefined + } + const downloadTempDir = createTranslationsTempDir("download-") + try { + writeTranslationsToTempDir({ + translations: downloadedTranslations, + language: defaultLanguage, + tempDir: downloadTempDir, + }) + + logger.info("Compiling downloaded translations...") + const downloadTempOutputDir = createTranslationsTempDir("download-compiled-") try { - writeTranslationsToTempDir({ - translations: downloadedTranslations, - language: defaultLanguage, - tempDir: downloadTempDir, - }) - - logger.info("Compiling downloaded translations...") - const downloadTempOutputDir = createTranslationsTempDir("download-compiled-") - - try { - compileTranslations({ inputDir: downloadTempDir, outputDir: downloadTempOutputDir }) - - const downloadedCompiledFilePath = join(downloadTempOutputDir, `${defaultLanguage}.json`) - const downloadedCompiledContent = readFileSync(downloadedCompiledFilePath, "utf-8") - return JSON.parse(downloadedCompiledContent) - } finally { - rmSync(downloadTempOutputDir, { recursive: true, force: true }) + const compileResult = compileTranslations({ inputDir: downloadTempDir, outputDir: downloadTempOutputDir }) + if (compileResult.isErr()) { + logger.warn(`Failed to compile downloaded translations: ${compileResult.error.message}`) + logger.info("Using extracted translations only") + return undefined } + + const downloadedCompiledFilePath = join(downloadTempOutputDir, `${defaultLanguage}.json`) + const downloadedCompiledContent = readFileSync(downloadedCompiledFilePath, "utf-8") + return JSON.parse(downloadedCompiledContent) } finally { - rmSync(downloadTempDir, { recursive: true, force: true }) + rmSync(downloadTempOutputDir, { recursive: true, force: true }) } - } catch (error) { - logger.warn(`Failed to download translations from translation service: ${error}`) - logger.info("Using extracted translations only") - return undefined + } finally { + rmSync(downloadTempDir, { recursive: true, force: true }) + } +} + +function warnDownloadFailure(error: DownloadTranslationsError): void { + switch (error.kind) { + case "downloadTranslationsFailed": + logger.warn(`POEditor API call failed for ${error.language}: ${stringifyCause(error.cause)}`) + break + case "noDownloadUrl": + logger.warn(`POEditor returned no download URL for ${error.language}`) + break + case "downloadTranslationsContentFailed": + logger.warn( + `Failed to fetch translation content for ${error.language} from ${error.url}: ${stringifyCause(error.cause)}`, + ) + break } + logger.info("Using extracted translations only") } function mergeTranslations({ diff --git a/packages/intl/src/commands/sync.ts b/packages/intl/src/commands/sync.ts index 9bcb0d447..58122ab0a 100644 --- a/packages/intl/src/commands/sync.ts +++ b/packages/intl/src/commands/sync.ts @@ -22,24 +22,19 @@ export async function sync({ translationsServiceClient, defaultLanguage, }: SyncCommandOptions) { - try { - logger.info("Starting sync operation...") + logger.info("Starting sync operation...") - await upload({ - srcPattern, - translationsServiceClient, - defaultLanguage, - }) + await upload({ + srcPattern, + translationsServiceClient, + defaultLanguage, + }) - await download({ - outputDir, - languages, - translationsServiceClient, - }) + await download({ + outputDir, + languages, + translationsServiceClient, + }) - logger.success("Sync completed successfully!") - } catch (error) { - logger.error("Error in sync command:", error as Error) - process.exit(1) - } + logger.success("Sync completed successfully!") } diff --git a/packages/intl/src/commands/upload.ts b/packages/intl/src/commands/upload.ts index 01e178bcd..c6858ab83 100644 --- a/packages/intl/src/commands/upload.ts +++ b/packages/intl/src/commands/upload.ts @@ -2,6 +2,7 @@ import { z } from "zod/v4" import type { TranslationsServiceClient } from "../TranslationsServiceClient" import { extractMessages } from "../formatjs" import { logger } from "../logger" +import { stringifyCause } from "../poeditor/POEditorError" export const uploadCommandOptionsSchema = z.object({ srcPattern: z.string(), @@ -13,30 +14,40 @@ export type UploadCommandOptions = z.infer & } export async function upload({ srcPattern, translationsServiceClient, defaultLanguage }: UploadCommandOptions) { - try { - logger.info("Extracting messages from source files...") + logger.info("Extracting messages from source files...") - const messages = extractMessages(srcPattern) - const messageCount = Object.keys(messages).length - - logger.info(`Extracted ${messageCount} messages`) + const messagesResult = extractMessages(srcPattern) + if (messagesResult.isErr()) { + logger.error(`Failed to extract messages: ${messagesResult.error.message}`) + process.exit(1) + } + const messages = messagesResult.value + const messageCount = Object.keys(messages).length - if (messageCount === 0) { - logger.warn("No messages found. Make sure your source files contain formatjs messages.") - return - } + logger.info(`Extracted ${messageCount} messages`) - logger.info("Uploading terms to translation service...") + if (messageCount === 0) { + logger.warn("No messages found. Make sure your source files contain formatjs messages.") + return + } - await translationsServiceClient.uploadTerms(messages) + logger.info("Uploading terms to translation service...") - logger.info(`Uploading default translations (${defaultLanguage}) to translation service...`) + const uploadTermsResult = await translationsServiceClient.uploadTerms(messages) + if (uploadTermsResult.isErr()) { + logger.error(`Failed to upload terms to POEditor: ${stringifyCause(uploadTermsResult.error.cause)}`) + process.exit(1) + } - await translationsServiceClient.uploadTranslations(messages, defaultLanguage) + logger.info(`Uploading default translations (${defaultLanguage}) to translation service...`) - logger.success("Upload completed successfully!") - } catch (error) { - logger.error("Error in upload command:", error as Error) + const uploadTranslationsResult = await translationsServiceClient.uploadTranslations(messages, defaultLanguage) + if (uploadTranslationsResult.isErr()) { + logger.error( + `Failed to upload ${defaultLanguage} translations to POEditor: ${stringifyCause(uploadTranslationsResult.error.cause)}`, + ) process.exit(1) } + + logger.success("Upload completed successfully!") } diff --git a/packages/intl/src/formatjs.ts b/packages/intl/src/formatjs.ts index 4ab5810de..8a638422e 100644 --- a/packages/intl/src/formatjs.ts +++ b/packages/intl/src/formatjs.ts @@ -1,3 +1,4 @@ +import { Result } from "neverthrow" import { execSync } from "node:child_process" import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" @@ -11,29 +12,30 @@ export interface ExtractedMessage { export type ExtractedMessages = Record -export function extractMessages(pattern = "src/**/*.{ts,tsx}"): ExtractedMessages { +export function extractMessages(pattern = "src/**/*.{ts,tsx}"): Result { const tempFile = join(tmpdir(), `messages-${Date.now()}.json`) - try { - const command = [ - "npx", - "@formatjs/cli", - "extract", - `"${pattern}"`, - "--out-file", - `"${tempFile}"`, - "--preserve-whitespace", - "--extract-source-location", - ].join(" ") + return Result.fromThrowable( + () => { + const command = [ + "npx", + "@formatjs/cli", + "extract", + `"${pattern}"`, + "--out-file", + `"${tempFile}"`, + "--preserve-whitespace", + "--extract-source-location", + ].join(" ") - execSync(command) + execSync(command) - const messagesText = readFileSync(tempFile, "utf-8") - rmSync(tempFile) - return JSON.parse(messagesText) - } catch (error) { - throw new Error(`Failed to extract messages. Error: ${error}`) - } + const messagesText = readFileSync(tempFile, "utf-8") + rmSync(tempFile) + return JSON.parse(messagesText) as ExtractedMessages + }, + error => new Error(`Failed to extract messages. Error: ${error}`), + )() } export function compileTranslations({ @@ -44,24 +46,25 @@ export function compileTranslations({ inputDir: string outputDir: string options?: { ast?: boolean; format?: string } -}) { +}): Result { const { ast = true, format = "simple" } = options - try { - const command = [ - "npx", - "@formatjs/cli", - "compile-folder", - ...(ast ? ["--ast"] : []), - "--format", - format, - inputDir, - outputDir, - ].join(" ") - execSync(command) - } catch (error) { - throw new Error(`Failed to compile translations. Error: ${error}`) - } + return Result.fromThrowable( + () => { + const command = [ + "npx", + "@formatjs/cli", + "compile-folder", + ...(ast ? ["--ast"] : []), + "--format", + format, + inputDir, + outputDir, + ].join(" ") + execSync(command) + }, + error => new Error(`Failed to compile translations. Error: ${error}`), + )() } export function createTranslationsTempDir(prefix = "intl-"): string { diff --git a/packages/intl/src/poeditor/POEditorClient.ts b/packages/intl/src/poeditor/POEditorClient.ts index 462e5de1e..b8182c4cd 100644 --- a/packages/intl/src/poeditor/POEditorClient.ts +++ b/packages/intl/src/poeditor/POEditorClient.ts @@ -1,4 +1,5 @@ import axios from "axios" +import { errAsync, okAsync, ResultAsync } from "neverthrow" import type { Term, TranslationsServiceClient } from "../TranslationsServiceClient" import { ExtractedMessages } from "../formatjs" import { @@ -8,6 +9,14 @@ import { TermsApi, TranslationsApi, } from "./api.generated" +import { + DownloadTermsError, + DownloadTranslationsError, + GetTranslationsInDefaultLanguageError, + RemoveTermsError, + UploadTermsError, + UploadTranslationsError, +} from "./POEditorError" export interface POEditorClientConfig { apiToken: string @@ -39,103 +48,98 @@ export class POEditorClient implements TranslationsServiceClient { this.translationsApi = new TranslationsApi(apiConfig, undefined, this.axiosInstance) } - async downloadTranslations(language: string): Promise> { - try { - const response = await this.projectsApi.projectsExport( + downloadTranslations(language: string): ResultAsync, DownloadTranslationsError> { + return ResultAsync.fromPromise( + this.projectsApi.projectsExport( this.projectId, language, ProjectsExportTypeEnum.KeyValueJson, this.apiToken, - ) - - if (response.data.result?.url) { - const translationsResponse = await this.axiosInstance.get(response.data.result.url) - return translationsResponse.data || {} + ), + (cause): DownloadTranslationsError => ({ kind: "downloadTranslationsFailed", language, cause }), + ).andThen(response => { + const url = response.data.result?.url + if (!url) { + return errAsync, DownloadTranslationsError>({ kind: "noDownloadUrl", language }) } - - throw new Error("No download URL received from POEditor") - } catch (error) { - throw new Error(`Failed to download translations for ${language}: ${error}`) - } + return ResultAsync.fromPromise( + this.axiosInstance.get>(url), + (cause): DownloadTranslationsError => ({ kind: "downloadTranslationsContentFailed", language, url, cause }), + ).map(translationsResponse => translationsResponse.data || {}) + }) } - async uploadTerms(messages: ExtractedMessages): Promise { - try { - const terms: Term[] = Object.entries(messages).map(([key, details]) => ({ - term: key, - context: details.description, - reference: details.file, - comment: `Default: ${details.defaultMessage}`, - })) - - await this.termsApi.termsAdd(this.projectId, JSON.stringify(terms), this.apiToken) - } catch (error) { - throw new Error(`Failed to upload terms: ${error}`) - } + uploadTerms(messages: ExtractedMessages): ResultAsync { + const terms: Term[] = Object.entries(messages).map(([key, details]) => ({ + term: key, + context: details.description, + reference: details.file, + comment: `Default: ${details.defaultMessage}`, + })) + + return ResultAsync.fromPromise( + this.termsApi.termsAdd(this.projectId, JSON.stringify(terms), this.apiToken), + (cause): UploadTermsError => ({ kind: "uploadTermsFailed", cause }), + ).map(() => {}) } - async uploadTranslations(messages: ExtractedMessages, language: string): Promise { - try { - const translations = Object.entries(messages).map(([term, details]) => ({ - term, - translation: { - content: details.defaultMessage, - fuzzy: 0, - }, - })) - - await this.translationsApi.translationsAdd(this.projectId, language, JSON.stringify(translations), this.apiToken) - } catch (error) { - throw new Error(`Failed to upload translations for ${language}: ${error}`) - } + uploadTranslations(messages: ExtractedMessages, language: string): ResultAsync { + const translations = Object.entries(messages).map(([term, details]) => ({ + term, + translation: { + content: details.defaultMessage, + fuzzy: 0, + }, + })) + + return ResultAsync.fromPromise( + this.translationsApi.translationsAdd(this.projectId, language, JSON.stringify(translations), this.apiToken), + (cause): UploadTranslationsError => ({ kind: "uploadTranslationsFailed", language, cause }), + ).map(() => {}) } - async downloadTerms(): Promise { - try { - const response = await this.termsApi.termsList(this.projectId, this.apiToken) - - if (response.data.result?.terms) { - return response.data.result.terms.map(term => ({ - term: term.term || "", - context: term.context || "", - reference: term.reference || "", - comment: term.comment || "", - })) - } - - return [] - } catch (error) { - throw new Error(`Failed to get terms: ${error}`) - } + downloadTerms(): ResultAsync { + return ResultAsync.fromPromise( + this.termsApi.termsList(this.projectId, this.apiToken), + (cause): DownloadTermsError => ({ kind: "downloadTermsFailed", cause }), + ).map(response => + (response.data.result?.terms ?? []).map(term => ({ + term: term.term || "", + context: term.context || "", + reference: term.reference || "", + comment: term.comment || "", + })), + ) } - async removeTerms(terms: Pick[]): Promise { - try { - await this.termsApi.termsDelete(this.projectId, JSON.stringify(terms), this.apiToken) - } catch (error) { - throw new Error(`Failed to remove terms: ${error}`) - } + removeTerms(terms: Pick[]): ResultAsync { + return ResultAsync.fromPromise( + this.termsApi.termsDelete(this.projectId, JSON.stringify(terms), this.apiToken), + (cause): RemoveTermsError => ({ kind: "removeTermsFailed", cause }), + ).map(() => {}) } - async getTranslationsInDefaultLanguage(terms: Term[]): Promise<{ term: string; translation: string }[]> { - try { - let referenceLanguage: string | undefined - const response = await this.projectsApi.projectsView(this.projectId, this.apiToken) - if (response.data.result?.project) { - referenceLanguage = response.data.result.project.reference_language - } - + getTranslationsInDefaultLanguage( + terms: Term[], + ): ResultAsync<{ term: string; translation: string }[], GetTranslationsInDefaultLanguageError> { + return ResultAsync.fromPromise( + this.projectsApi.projectsView(this.projectId, this.apiToken), + (cause): GetTranslationsInDefaultLanguageError => ({ kind: "viewProjectFailed", cause }), + ).andThen(response => { + const referenceLanguage = response.data.result?.project?.reference_language if (!referenceLanguage) { - throw new Error("No reference language found") + return errAsync<{ term: string; translation: string }[], GetTranslationsInDefaultLanguageError>({ + kind: "noReferenceLanguage", + }) } - - const translations = await this.downloadTranslations(referenceLanguage) - return terms.map(term => ({ - term: term.term, - translation: translations[term.term] || "", - })) - } catch (error) { - throw new Error(`Failed to get translations in default language: ${error}`) - } + return this.downloadTranslations(referenceLanguage).andThen(translations => + okAsync( + terms.map(term => ({ + term: term.term, + translation: translations[term.term] || "", + })), + ), + ) + }) } } diff --git a/packages/intl/src/poeditor/POEditorError.ts b/packages/intl/src/poeditor/POEditorError.ts new file mode 100644 index 000000000..350851798 --- /dev/null +++ b/packages/intl/src/poeditor/POEditorError.ts @@ -0,0 +1,72 @@ +export type POEditorError = + | { kind: "downloadTermsFailed"; cause: unknown } + | { kind: "downloadTranslationsContentFailed"; language: string; url: string; cause: unknown } + | { kind: "downloadTranslationsFailed"; language: string; cause: unknown } + | { kind: "noDownloadUrl"; language: string } + | { kind: "noReferenceLanguage" } + | { kind: "removeTermsFailed"; cause: unknown } + | { kind: "uploadTermsFailed"; cause: unknown } + | { kind: "uploadTranslationsFailed"; language: string; cause: unknown } + | { kind: "viewProjectFailed"; cause: unknown } + +export type DownloadTranslationsError = Extract< + POEditorError, + { kind: "downloadTranslationsContentFailed" | "downloadTranslationsFailed" | "noDownloadUrl" } +> +export type UploadTermsError = Extract +export type UploadTranslationsError = Extract +export type DownloadTermsError = Extract +export type RemoveTermsError = Extract +export type GetTranslationsInDefaultLanguageError = + | DownloadTranslationsError + | Extract + +const allKinds: readonly POEditorError["kind"][] = [ + "downloadTranslationsFailed", + "downloadTranslationsContentFailed", + "noDownloadUrl", + "uploadTermsFailed", + "uploadTranslationsFailed", + "downloadTermsFailed", + "removeTermsFailed", + "viewProjectFailed", + "noReferenceLanguage", +] + +export function isPOEditorError(value: unknown): value is POEditorError { + if (typeof value !== "object" || value === null || !("kind" in value)) { + return false + } + const { kind } = value as { kind: unknown } + return typeof kind === "string" && (allKinds as readonly string[]).includes(kind) +} + +export function formatPOEditorError(error: POEditorError): string { + switch (error.kind) { + case "downloadTranslationsFailed": + return `Failed to download translations for ${error.language}: ${stringifyCause(error.cause)}` + case "downloadTranslationsContentFailed": + return `Failed to download translation content for ${error.language} from ${error.url}: ${stringifyCause(error.cause)}` + case "noDownloadUrl": + return `No download URL received from POEditor for language ${error.language}` + case "uploadTermsFailed": + return `Failed to upload terms: ${stringifyCause(error.cause)}` + case "uploadTranslationsFailed": + return `Failed to upload translations for ${error.language}: ${stringifyCause(error.cause)}` + case "downloadTermsFailed": + return `Failed to get terms: ${stringifyCause(error.cause)}` + case "removeTermsFailed": + return `Failed to remove terms: ${stringifyCause(error.cause)}` + case "viewProjectFailed": + return `Failed to view project: ${stringifyCause(error.cause)}` + case "noReferenceLanguage": + return "No reference language configured in POEditor project" + } +} + +export function stringifyCause(cause: unknown): string { + if (cause instanceof Error) { + return cause.message + } + return String(cause) +} diff --git a/packages/intl/src/poeditor/generatePoeditorClient.ts b/packages/intl/src/poeditor/generatePoeditorClient.ts index 9490869b8..89f862daa 100644 --- a/packages/intl/src/poeditor/generatePoeditorClient.ts +++ b/packages/intl/src/poeditor/generatePoeditorClient.ts @@ -8,7 +8,7 @@ const swaggerUrl = "https://poeditor.com/public/api/swagger.yaml" const schemaDir = "./src/poeditor/openapi-schema" try { - generatePOEditorClient() + await generatePOEditorClient() } catch (error) { logger.error("Error:", error as Error) process.exit(1) diff --git a/packages/mail-translation/package.json b/packages/mail-translation/package.json index f3d04ed53..36245e574 100644 --- a/packages/mail-translation/package.json +++ b/packages/mail-translation/package.json @@ -23,6 +23,7 @@ "js-beautify": "^1.15.4", "lilconfig": "^3.1.3", "mjml": "^4.17.1", + "neverthrow": "^8.2.0", "tslib": "^2.8.1", "yaml": "^2.8.0", "yargs": "^18.0.0", diff --git a/packages/mail-translation/src/MailTranslationError.ts b/packages/mail-translation/src/MailTranslationError.ts new file mode 100644 index 000000000..37debcc02 --- /dev/null +++ b/packages/mail-translation/src/MailTranslationError.ts @@ -0,0 +1,33 @@ +export type MailTranslationError = + | { kind: "configFileNotFound" } + | { kind: "configValidationFailed"; cause: unknown } + | { kind: "loadMjmlTemplatesFailed"; mailsPath: string; cause: unknown } + | { kind: "loadPlaintextTemplatesFailed"; plaintextMailsPath: string; cause: unknown } + | { kind: "mjmlCompilationFailed"; filePath: string; cause: unknown } + +export type LoadConfigError = Extract +export type LoadMjmlTemplatesError = Extract +export type LoadPlaintextTemplatesError = Extract +export type CompileMjmlError = Extract + +export function formatMailTranslationError(error: MailTranslationError): string { + switch (error.kind) { + case "configFileNotFound": + return "No mail-translation configuration file found" + case "configValidationFailed": + return `Failed to validate mail-translation configuration: ${stringifyCause(error.cause)}` + case "loadMjmlTemplatesFailed": + return `Failed to load MJML templates from ${error.mailsPath}: ${stringifyCause(error.cause)}` + case "loadPlaintextTemplatesFailed": + return `Failed to load plaintext templates from ${error.plaintextMailsPath}: ${stringifyCause(error.cause)}` + case "mjmlCompilationFailed": + return `MJML compilation failed for ${error.filePath}: ${stringifyCause(error.cause)}` + } +} + +export function stringifyCause(cause: unknown): string { + if (cause instanceof Error) { + return cause.message + } + return String(cause) +} diff --git a/packages/mail-translation/src/bin.ts b/packages/mail-translation/src/bin.ts index a0148a39e..7508ff252 100644 --- a/packages/mail-translation/src/bin.ts +++ b/packages/mail-translation/src/bin.ts @@ -4,6 +4,8 @@ import { hideBin } from "yargs/helpers" import yargs from "yargs/yargs" import { generate } from "./generate" import { loadConfig } from "./loadConfig" +import { logger } from "./logger" +import { formatMailTranslationError } from "./MailTranslationError" import { saveOutputs } from "./saveOutputs" const argv = yargs(hideBin(process.argv)) @@ -14,6 +16,24 @@ const argv = yargs(hideBin(process.argv)) }) .parseSync() -const config = loadConfig(argv.config) +main().catch(error => { + logger.error("Unexpected error:", error as Error) + process.exit(1) +}) -generate(config).then(processedTemplates => saveOutputs({ processedTemplates, outputPath: config.outputPath })) +async function main() { + const configResult = loadConfig(argv.config) + if (configResult.isErr()) { + logger.error(formatMailTranslationError(configResult.error)) + process.exit(1) + } + const config = configResult.value + + const generateResult = await generate(config) + if (generateResult.isErr()) { + logger.error(formatMailTranslationError(generateResult.error)) + process.exit(1) + } + + await saveOutputs({ processedTemplates: generateResult.value, outputPath: config.outputPath }) +} diff --git a/packages/mail-translation/src/compileMjml.ts b/packages/mail-translation/src/compileMjml.ts index 7882e7ab2..9a6a2e819 100644 --- a/packages/mail-translation/src/compileMjml.ts +++ b/packages/mail-translation/src/compileMjml.ts @@ -1,5 +1,7 @@ import jsBeautify from "js-beautify" import mjml2html from "mjml" +import { Result } from "neverthrow" +import type { CompileMjmlError } from "./MailTranslationError" const { html: beautifyHtml } = jsBeautify @@ -14,26 +16,33 @@ export interface MjmlCompileResult { mjmlParseErrors: MjmlParseError[] } -export function compileMjml({ mjmlContent, filePath }: { mjmlContent: string; filePath: string }): MjmlCompileResult { - try { - const result = mjml2html(mjmlContent, { - keepComments: false, - validationLevel: "soft", - filePath, - }) +export function compileMjml({ + mjmlContent, + filePath, +}: { + mjmlContent: string + filePath: string +}): Result { + return Result.fromThrowable( + () => { + const result = mjml2html(mjmlContent, { + keepComments: false, + validationLevel: "soft", + filePath, + }) - // js-beautify is used to format the HTML as beautify option is deprecated in mjml-core - const html = beautifyHtml(result.html, { - indent_size: 2, - preserve_newlines: true, - max_preserve_newlines: 1, - }) + // js-beautify is used to format the HTML as beautify option is deprecated in mjml-core + const html = beautifyHtml(result.html, { + indent_size: 2, + preserve_newlines: true, + max_preserve_newlines: 1, + }) - return { - html, - mjmlParseErrors: result.errors || [], - } - } catch (error) { - throw new Error(`MJML compilation failed: ${error}`) - } + return { + html, + mjmlParseErrors: result.errors || [], + } + }, + (cause): CompileMjmlError => ({ kind: "mjmlCompilationFailed", filePath, cause }), + )() } diff --git a/packages/mail-translation/src/generate.ts b/packages/mail-translation/src/generate.ts index a1f601dfd..95ac594bc 100644 --- a/packages/mail-translation/src/generate.ts +++ b/packages/mail-translation/src/generate.ts @@ -1,25 +1,33 @@ +import { Result, ResultAsync } from "neverthrow" +import type { MailTranslationError } from "./MailTranslationError" import { MailTranslationConfig } from "./config" import { loadMjmlTemplates, loadPlaintextTemplates } from "./loadTemplates" import { loadTranslations } from "./loadTranslations" -import { processTemplate } from "./processTemplate" +import { ProcessedTemplate, processTemplate } from "./processTemplate" -export async function generate(config: Omit) { - const translationData = await loadTranslations(config.translationsPath) - - const mjmlTemplates = await loadMjmlTemplates(config.mailsPath) - const plaintextTemplates = await loadPlaintextTemplates({ - plaintextMailsPath: config.plaintextMailsPath ?? config.mailsPath, - outputMode: config.outputMode, - }) - - return [...mjmlTemplates, ...plaintextTemplates].map(template => - processTemplate({ - template, - translationData, - outputMode: config.outputMode, - defaultLanguage: config.defaultLanguage, - kratosLanguageVariable: config.kratosLanguageVariable, - mailsPath: config.mailsPath, - }), +export function generate( + config: Omit, +): ResultAsync { + return ResultAsync.fromSafePromise(loadTranslations(config.translationsPath)).andThen(translationData => + ResultAsync.combine([ + loadMjmlTemplates(config.mailsPath), + loadPlaintextTemplates({ + plaintextMailsPath: config.plaintextMailsPath ?? config.mailsPath, + outputMode: config.outputMode, + }), + ]).andThen(([mjmlTemplates, plaintextTemplates]) => + Result.combine( + [...mjmlTemplates, ...plaintextTemplates].map(template => + processTemplate({ + template, + translationData, + outputMode: config.outputMode, + defaultLanguage: config.defaultLanguage, + kratosLanguageVariable: config.kratosLanguageVariable, + mailsPath: config.mailsPath, + }), + ), + ), + ), ) } diff --git a/packages/mail-translation/src/loadConfig.ts b/packages/mail-translation/src/loadConfig.ts index cd76d7b4d..25dd11644 100644 --- a/packages/mail-translation/src/loadConfig.ts +++ b/packages/mail-translation/src/loadConfig.ts @@ -1,5 +1,7 @@ import { OptionsSync as LilconfigOptionsSync, lilconfigSync } from "lilconfig" +import { err, ok, Result } from "neverthrow" import * as yaml from "yaml" +import type { LoadConfigError } from "./MailTranslationError" import { MailTranslationConfig, mailTranslationConfigSchema } from "./config" function loadYaml(filepath: string, content: string) { @@ -25,17 +27,17 @@ const options: LilconfigOptionsSync = { }, } -export function loadConfig(configPath?: string): MailTranslationConfig { +export function loadConfig(configPath?: string): Result { const searcher = lilconfigSync("mail-translation", options) const result = configPath ? searcher.load(configPath) : searcher.search() if (!result) { - throw new Error("No configuration file found") + return err({ kind: "configFileNotFound" }) } - try { - return mailTranslationConfigSchema.parse(result.config) - } catch (error) { - throw new Error(`Failed to load configuration: ${error}`) + const parsed = mailTranslationConfigSchema.safeParse(result.config) + if (!parsed.success) { + return err({ kind: "configValidationFailed", cause: parsed.error }) } + return ok(parsed.data) } diff --git a/packages/mail-translation/src/loadTemplates.ts b/packages/mail-translation/src/loadTemplates.ts index 3ad920620..47ff4ccba 100644 --- a/packages/mail-translation/src/loadTemplates.ts +++ b/packages/mail-translation/src/loadTemplates.ts @@ -1,61 +1,80 @@ +import { ResultAsync } from "neverthrow" import { readdir, readFile } from "node:fs/promises" import { basename, extname, join } from "node:path" +import type { LoadMjmlTemplatesError, LoadPlaintextTemplatesError } from "./MailTranslationError" import { OutputMode } from "./config" import { Template } from "./processTemplate" -export async function loadMjmlTemplates(mailsPath: string): Promise { - try { - const files = await readdir(mailsPath) - const mjmlFiles = files.filter(file => extname(file) === ".mjml") - - return Promise.all( - mjmlFiles.map(async file => { - const templateName = basename(file, ".mjml") - const filePath = join(mailsPath, file) - const content = await readFile(filePath, "utf8") - - return { - name: templateName, - content, - isPlaintext: false, - } - }), - ) - } catch (error) { - throw new Error(`Failed to load templates: ${error}`) - } +export function loadMjmlTemplates(mailsPath: string): ResultAsync { + return ResultAsync.fromPromise(readMjmlTemplates(mailsPath), (cause): LoadMjmlTemplatesError => ({ + kind: "loadMjmlTemplatesFailed", + mailsPath, + cause, + })) } -export async function loadPlaintextTemplates({ +async function readMjmlTemplates(mailsPath: string): Promise { + const files = await readdir(mailsPath) + const mjmlFiles = files.filter(file => extname(file) === ".mjml") + + return Promise.all( + mjmlFiles.map(async file => { + const templateName = basename(file, ".mjml") + const filePath = join(mailsPath, file) + const content = await readFile(filePath, "utf8") + + return { + name: templateName, + content, + isPlaintext: false, + } + }), + ) +} + +export function loadPlaintextTemplates({ + plaintextMailsPath, + outputMode, +}: { + plaintextMailsPath: string + outputMode: OutputMode +}): ResultAsync { + return ResultAsync.fromPromise( + readPlaintextTemplates({ plaintextMailsPath, outputMode }), + (cause): LoadPlaintextTemplatesError => ({ + kind: "loadPlaintextTemplatesFailed", + plaintextMailsPath, + cause, + }), + ) +} + +async function readPlaintextTemplates({ plaintextMailsPath, outputMode, }: { plaintextMailsPath: string outputMode: OutputMode }): Promise { - try { - const files = await readdir(plaintextMailsPath) - - const plaintextFiles = - outputMode === "kratos" - ? files.filter(file => file.endsWith(".plaintext.gotmpl")) - : files.filter(file => file.endsWith(".txt.cshtml")) - - return Promise.all( - plaintextFiles.map(async file => { - const templateName = - outputMode === "kratos" ? file.replace(/\.plaintext\.gotmpl$/, "") : file.replace(/\.txt\.cshtml$/, "") - const filePath = join(plaintextMailsPath, file) - const content = await readFile(filePath, "utf8") - - return { - name: templateName, - content, - isPlaintext: true, - } - }), - ) - } catch (error) { - throw new Error(`Failed to load plaintext templates: ${error}`) - } + const files = await readdir(plaintextMailsPath) + + const plaintextFiles = + outputMode === "kratos" + ? files.filter(file => file.endsWith(".plaintext.gotmpl")) + : files.filter(file => file.endsWith(".txt.cshtml")) + + return Promise.all( + plaintextFiles.map(async file => { + const templateName = + outputMode === "kratos" ? file.replace(/\.plaintext\.gotmpl$/, "") : file.replace(/\.txt\.cshtml$/, "") + const filePath = join(plaintextMailsPath, file) + const content = await readFile(filePath, "utf8") + + return { + name: templateName, + content, + isPlaintext: true, + } + }), + ) } diff --git a/packages/mail-translation/src/logger.ts b/packages/mail-translation/src/logger.ts index 5bc7ef29f..755f91da6 100644 --- a/packages/mail-translation/src/logger.ts +++ b/packages/mail-translation/src/logger.ts @@ -1,3 +1,3 @@ -import { createCliLogger } from "@leancodepl/logger/cli" +import { type CliLogger, createCliLogger } from "@leancodepl/logger/cli" -export const logger = createCliLogger() +export const logger: CliLogger = createCliLogger() diff --git a/packages/mail-translation/src/processTemplate.ts b/packages/mail-translation/src/processTemplate.ts index 0493c2c1c..98a67183a 100644 --- a/packages/mail-translation/src/processTemplate.ts +++ b/packages/mail-translation/src/processTemplate.ts @@ -1,3 +1,5 @@ +import { ok, Result } from "neverthrow" +import type { CompileMjmlError } from "./MailTranslationError" import { compileMjml, MjmlParseError } from "./compileMjml" import { OutputMode } from "./config" import { generateOutputTemplates, OutputTemplate } from "./generateOutputTemplates" @@ -37,39 +39,41 @@ export function processTemplate({ defaultLanguage?: string kratosLanguageVariable?: string mailsPath: string -}): ProcessedTemplate { +}): Result { const availableLanguages = Object.keys(translationData) const languagesToProcess = availableLanguages.length > 0 ? availableLanguages : [defaultLanguage] const mjmlCompileResult = template.isPlaintext - ? undefined + ? ok(undefined) : compileMjml({ mjmlContent: template.content, filePath: mailsPath }) - const content = template.isPlaintext ? template.content : (mjmlCompileResult?.html ?? "") + return mjmlCompileResult.map(compiled => { + const content = template.isPlaintext ? template.content : (compiled?.html ?? "") - const translatedTemplates = languagesToProcess.map(language => { - const translations = language ? (translationData[language] ?? {}) : {} + const translatedTemplates = languagesToProcess.map(language => { + const translations = language ? (translationData[language] ?? {}) : {} - const translatedContent = processTranslations({ template: content, translations, language }) + const translatedContent = processTranslations({ template: content, translations, language }) + + return { + name: template.name, + content: translatedContent, + isPlaintext: template.isPlaintext, + language, + } + }) + + const outputTemplates = generateOutputTemplates({ + translatedTemplates, + outputMode, + defaultLanguage, + kratosLanguageVariable, + }) return { name: template.name, - content: translatedContent, - isPlaintext: template.isPlaintext, - language, + mjmlParseErrors: compiled?.mjmlParseErrors ?? [], + outputTemplates, } }) - - const outputTemplates = generateOutputTemplates({ - translatedTemplates, - outputMode, - defaultLanguage, - kratosLanguageVariable, - }) - - return { - name: template.name, - mjmlParseErrors: mjmlCompileResult?.mjmlParseErrors ?? [], - outputTemplates, - } }