diff --git a/README.md b/README.md index 38a9e5e..99dd132 100644 --- a/README.md +++ b/README.md @@ -149,15 +149,83 @@ This function accepts file contents and parses it to a JavaScript value that wil An object whose keys are message IDs and whose values are either parsing options for those messages or a resolver function that generates parsing options based on contextual information (such as module ID, message ID, and all messages). +#### **`onParseError`** + +- **Type**: [`ParseErrorHandlingOption`](#parseerrorhandlingoption) +- **Default**: `undefined` + +A method to handle any errors that may arise during the parsing of one of the messages. + #### **`pluginsWrapping`** -- **Type**: `boolean | WrappingOptions` +- **Type**: `boolean` `|` [`WrappingOptions`](#wrappingoptions) - **Default**: `false` Plugins wrapping enables additional hooks in compatible bundlers to prevent other plugins from transforming files that would be transformed by this plugin. --- +### `ParseErrorHandlingOption` + +- **Type**: `(context: `[`ParseErrorContext`](#parseerrorcontext)`) => MessageFormatElement[] | void` + +Either a name of the built-in handler, or a custom method that will accept context and may return the fallback result, throw another error, or return nothing (`undefined`) to ignore the error. + +Custom methods can access the built-in handlers using the context's `useBuiltinStrategy` method and are used solely for logging. + +The following built-in handlers exist: + +| Name | Description | +| ------------------------ | ------------------------------------------------ | +| `use-message-as-literal` | Uses the unparsed message contents as a literal. | +| `use-id-as-literal` | Uses the the message ID as a literal. | +| `use-empty-literal` | Uses a literal with an empty string. | +| `skip` | Ignore the error and skip the message. | + +--- + +### `ParseErrorContext` + +A read-only object containing information relevant to the parsing error, including the error itself. + +#### **`moduleId`** + +- **Type**: `string` + +ID of the module that is being parsed. + +#### **`messageId`** + +- **Type**: `string` + +ID of the message that cannot be parsed. + +#### **`message`** + +- **Type**: `string` + +Message that cannot be parsed. + +#### **`error`** + +- **Type**: `unknown` + +Error that occurred during the parsing. + +#### **`parserOptions`** + +- **Type**: `ParserOptions | undefined` + +Parser options that were used to parse the message. + +#### **`useBuiltinStrategy`** + +- **Type**: `(name: ParseErrorHandlingStrategy) => MessageFormatElement[] | void` + +Method used to call one of the built-in error handling strategies and return its result. + +--- + ### `WrappingOptions` #### **`use`** diff --git a/src/index.ts b/src/index.ts index cc31531..9832e5d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,6 @@ export * from './secondary-exports.ts' -export { plugin as icuMessages } from './plugin/index.ts' + +export { + plugin as icuMessages, + type Options as PluginOptions, +} from './plugin/index.ts' diff --git a/src/parser/error-handling.ts b/src/parser/error-handling.ts new file mode 100644 index 0000000..6970150 --- /dev/null +++ b/src/parser/error-handling.ts @@ -0,0 +1,109 @@ +import { + type MessageFormatElement, + createLiteralElement, + type ParserOptions, +} from '@formatjs/icu-messageformat-parser' + +type ParseErrorHandlerResult = MessageFormatElement[] | undefined | void + +/** + * A function that handles parsing error and either throws another error, or + * returns a fallback string or parse result. + * + * @param context Context containing the error, message ID and other relevant + * properties. + * @returns The fallback value or nothing. + */ +export type ParseErrorHandler = ( + context: ParseErrorContext, +) => ParseErrorHandlerResult + +export const builtinStrategies = { + /** @returns The original message as a literal. */ + 'use-message-as-literal'(ctx) { + return [createLiteralElement(ctx.message)] + }, + /** @returns The message ID as a literal. */ + 'use-id-as-literal'(ctx) { + return [createLiteralElement(ctx.messageId)] + }, + /** @returns Empty literal. */ + 'use-empty-literal'() { + return [createLiteralElement('')] + }, + /** @returns `undefined`, which skips the string. */ + skip() { + return undefined + }, +} satisfies Record + +Object.setPrototypeOf(builtinStrategies, null) + +type ParseErrorHandlingStrategy = keyof typeof builtinStrategies + +export type ParseErrorHandlingOption = + | ParseErrorHandler + | ParseErrorHandlingStrategy + +/** + * Resolve error handler function. + * + * @param option Either an error handler to return back or the name of the + * built-in handling strategy. + * @returns Resolved error handler. + * @throws {Error} If called with unknown built-in handling strategy name. + */ +export function resolveParseErrorHandler(option: ParseErrorHandlingOption) { + if (typeof option === 'function') return option + + if (Object.hasOwn(builtinStrategies, option)) return builtinStrategies[option] + + throw new Error(`Cannot resolve built-in strategy with name "${option}"`) +} + +interface ParseErrorContext { + /** ID of the module that is being parsed. */ + get moduleId(): string + + /** ID of the message that cannot be parsed. */ + get messageId(): string + + /** Message that cannot be parsed. */ + get message(): string + + /** Error that occurred during the parsing. */ + get error(): unknown + + /** Parser options that were used to parse the message. */ + get parserOptions(): ParserOptions | undefined + + /** + * Call one of the built-in error handling strategies. + * + * @param name Name of the error handling strategy. + * @returns Result for the strategy. + */ + useBuiltinStrategy(name: ParseErrorHandlingStrategy): ParseErrorHandlerResult +} + +/** + * Creates a new context. + * + * @param info Information required to create a context. + * @returns Newly created context object. + */ +export function createParseErrorContext( + info: Pick< + ParseErrorContext, + 'error' | 'moduleId' | 'message' | 'messageId' | 'parserOptions' + >, +) { + const ctx = { + ...info, + useBuiltinStrategy(name: ParseErrorHandlingStrategy) { + return resolveParseErrorHandler(name)(ctx) + }, + } + + return Object.freeze(ctx) satisfies ParseErrorContext +} diff --git a/src/parser/index.ts b/src/parser/index.ts index d6e0004..5ac76ab 100644 --- a/src/parser/index.ts +++ b/src/parser/index.ts @@ -1,3 +1,13 @@ -export { createOptionsResolver, AnyMessage } from './options.ts' -export type { MessagesParserOptionsValue } from './options.ts' +export { + createOptionsResolver, + AnyMessage, + type MessagesParserOptionsValue, + type ParserOptionsResolver, +} from './options.ts' export { defaultOptionsResolver } from './default-options-resolver.ts' +export { + createParseErrorContext, + resolveParseErrorHandler, + type ParseErrorHandler, + type ParseErrorHandlingOption, +} from './error-handling.ts' diff --git a/src/platform-bindings/rollup.ts b/src/platform-bindings/rollup.ts index e27d005..636e9a4 100644 --- a/src/platform-bindings/rollup.ts +++ b/src/platform-bindings/rollup.ts @@ -2,6 +2,9 @@ import type { UnpluginInstance, RollupPlugin } from 'unplugin' import { plugin as basePlugin, type Options } from '../plugin/index.ts' export * from '../secondary-exports.ts' + +export type PluginOptions = Options + export const icuMessages = ( - basePlugin as UnpluginInstance, false> + basePlugin as UnpluginInstance ).rollup diff --git a/src/platform-bindings/vite.ts b/src/platform-bindings/vite.ts index fa4057e..0a0030f 100644 --- a/src/platform-bindings/vite.ts +++ b/src/platform-bindings/vite.ts @@ -2,6 +2,9 @@ import type { UnpluginInstance, VitePlugin } from 'unplugin' import { plugin as basePlugin, type Options } from '../plugin/index.ts' export * from '../secondary-exports.ts' + +export type PluginOptions = Options + export const icuMessages = ( - basePlugin as UnpluginInstance, false> + basePlugin as UnpluginInstance ).vite diff --git a/src/platform-bindings/webpack.ts b/src/platform-bindings/webpack.ts index 5ae3003..c46f364 100644 --- a/src/platform-bindings/webpack.ts +++ b/src/platform-bindings/webpack.ts @@ -1,4 +1,7 @@ -import { plugin as basePlugin } from '../plugin/index.ts' +import { plugin as basePlugin, type Options } from '../plugin/index.ts' export * from '../secondary-exports.ts' + +export type PluginOptions = Options + export const icuMessages = basePlugin.webpack diff --git a/src/plugin/message-parsing.ts b/src/plugin/message-parsing.ts index fc38a5d..2aed1e4 100644 --- a/src/plugin/message-parsing.ts +++ b/src/plugin/message-parsing.ts @@ -2,8 +2,12 @@ import { type MessageFormatElement, parse as parseMessage, } from '@formatjs/icu-messageformat-parser' -import type { ParserOptionsResolver } from '../parser/options.ts' -import { BaseError } from '../shared/error-proto.ts' +import { + type ParserOptionsResolver, + createParseErrorContext, + type ParseErrorHandler, +} from '../parser/index.ts' +import { BaseError, type BaseErrorOptions } from '../shared/error-proto.ts' import type { MessagesMap } from './types.ts' /** @@ -14,6 +18,43 @@ class ParseError extends BaseError { public readonly code = 'UNPLUGIN_ICU_PARSING_ERROR' } +class ParseOptionsResolutionError extends BaseError { + public readonly code = 'UNPLUGIN_ICU_PARSE_OPTS_RES_ERROR' +} + +class ErrorHandlerError extends BaseError { + public readonly code = 'UNPLUGIN_ICU_ERROR_HANDLER_ERROR' + + /** The original parsing error which has been suppressed by this error. */ + public readonly suppressed: unknown + + public constructor( + message?: string, + options?: BaseErrorOptions & { suppressed?: unknown }, + ) { + super( + message, + (() => { + if (options == null) return options + + return Object.fromEntries( + Object.entries(options).filter( + ([propertyName]) => propertyName !== 'suppressed', + ), + ) + })(), + ) + + this.suppressed = options?.suppressed + } + + public get stack() { + return this.suppressed == null + ? super.stack + : `${super.stack}\n\n Suppressed: ${String(this.suppressed)}` + } +} + /** * Takes in a record of raw messages, parses each of the keys, querying the * parsing options using the provided resolver, and returns a record where each @@ -28,23 +69,60 @@ class ParseError extends BaseError { */ export function parseMessages( messages: MessagesMap, - getParserOptions: ParserOptionsResolver, moduleId: string, + getParserOptions: ParserOptionsResolver, + onError?: ParseErrorHandler, ) { const out: Record = Object.create(null) - for (const [key, message] of Object.entries(messages)) { + for (const [messageId, message] of Object.entries(messages)) { if (typeof message !== 'string') { - throw new ParseError(`Value under key "${key}" is not a string`) + throw new ParseError(`Value under key "${messageId}" is not a string`) + } + + function throwWithCause(cause: unknown): never { + throw new ParseError(`Cannot parse message under key "${messageId}"`, { + cause, + }) } + let parserOptions try { - out[key] = parseMessage( - message, - getParserOptions(moduleId, key, messages), - ) + parserOptions = getParserOptions(moduleId, messageId, messages) } catch (cause) { - throw new ParseError(`Cannot parse message under key "${key}"`, { cause }) + throwWithCause( + new ParseOptionsResolutionError( + 'Failed to resolve options for the message', + { cause }, + ), + ) + } + + try { + out[messageId] = parseMessage(message, parserOptions) + } catch (error) { + if (onError == null) throwWithCause(error) + + try { + const fallback = onError( + createParseErrorContext({ + error, + moduleId, + message, + messageId, + parserOptions, + }), + ) + + if (fallback != null) out[messageId] = fallback + } catch (cause) { + throwWithCause( + new ErrorHandlerError('Calling error handler thrown an error', { + cause, + suppressed: error, + }), + ) + } } } diff --git a/src/plugin/options.ts b/src/plugin/options.ts index 14ea671..6fb9f19 100644 --- a/src/plugin/options.ts +++ b/src/plugin/options.ts @@ -1,6 +1,10 @@ import type { CompileFn } from '@formatjs/cli-lib' import type { FilterPattern } from '@rollup/pluginutils' -import type { MessagesParserOptionsValue } from '../parser/options.ts' +import { + resolveParseErrorHandler, + type MessagesParserOptionsValue, + type ParseErrorHandlingOption, +} from '../parser/index.ts' import { normalizeWrappingOptions, type WrappablePlugin, @@ -126,6 +130,37 @@ export interface Options { */ parserOptions?: MessagesParserOptionsValue + /** + * A method to handle any errors that may arise during the parsing of one of + * the messages. + * + * This can be either a name of the built-in handler, or a custom method that + * will accept context and may return the fallback result, throw another error + * or return nothing (`undefined`) to ignore the error. + * + * Custom methods can access the built-in handlers using the context's + * `useBuiltinStrategy` method and used solely for logging. + * + * The following built-in handlers exist: + * + * | Name | Description | + * | :----------------------: | ------------------------------------------------ | + * | `use-message-as-literal` | Uses the unparsed message contents as a literal. | + * | `use-id-as-literal` | Uses the message ID as a literal. | + * | `use-empty-literal` | Uses literal with empty string. | + * | `skip` | Ignore the error and skip the message. | + * + * @example + * const opts: PluginOptions = { + * // ... + * onParseError({ message, moduleId, useBuiltinStrategy }) { + * console.warn(`[i18n] Cannot parse "${message}" in "${moduleId}"`) + * return useBuiltinStrategy('use-message-as-literal') + * }, + * } + */ + onParseError?: ParseErrorHandlingOption + /** * Plugins wrapping enables additional hooks in compatible bundlers to prevent * other plugins from transforming files that would be transformed by this @@ -185,7 +220,11 @@ export function normalizeOptions( options?.pluginsWrapping ?? false, ), output: normalizeOutputOptions(options?.output), - } satisfies Options + onParseError: + options?.onParseError == null + ? undefined + : resolveParseErrorHandler(options?.onParseError), + } } /** Represents the options after normalization using {@link normalizeOptions}. */ diff --git a/src/plugin/plugin.ts b/src/plugin/plugin.ts index f14ac76..58baac6 100644 --- a/src/plugin/plugin.ts +++ b/src/plugin/plugin.ts @@ -40,6 +40,7 @@ export const plugin = createUnplugin((options_, meta) => { parse, pluginsWrapping, output: outputOpts, + onParseError, ...options } = normalizeOptions(options_ ?? {}) @@ -190,7 +191,7 @@ export const plugin = createUnplugin((options_, meta) => { let data: MessagesASTMap | MessagesMap if (outputOpts.type === 'ast') { try { - data = parseMessages(messages, getParserOptions, id) + data = parseMessages(messages, id, getParserOptions, onParseError) } catch (cause) { this.error( new TransformError('Cannot generate messages AST', { cause }), diff --git a/src/secondary-exports.ts b/src/secondary-exports.ts index 99ecdb9..1ddf2af 100644 --- a/src/secondary-exports.ts +++ b/src/secondary-exports.ts @@ -1,2 +1 @@ -export type { Options as PluginOptions } from './plugin/options.ts' export { AnyMessage } from './parser/index.ts' diff --git a/src/shared/error-proto.ts b/src/shared/error-proto.ts index a6bc2d0..21cd83a 100644 --- a/src/shared/error-proto.ts +++ b/src/shared/error-proto.ts @@ -4,7 +4,7 @@ export interface BaseErrorOptions { } export class BaseError extends Error { - public constructor(message: string, options: BaseErrorOptions = {}) { + public constructor(message?: string, options: BaseErrorOptions = {}) { super(message) if ('cause' in options && !('cause' in this)) { diff --git a/test/__snapshots__/rollup.test.ts.snap b/test/__snapshots__/rollup.test.ts.snap index 494cf30..648162e 100644 --- a/test/__snapshots__/rollup.test.ts.snap +++ b/test/__snapshots__/rollup.test.ts.snap @@ -70,6 +70,25 @@ export { example as default }; " `; +exports[`rollup > should handle errors as defined 1`] = ` +"const greeting = [ + { + type: 0, + value: \\"Hello, {name} should parse TOML when specified 1`] = ` "const greeting = [ { diff --git a/test/__snapshots__/webpack.test.ts.snap b/test/__snapshots__/webpack.test.ts.snap index 299a63c..c66eab7 100644 --- a/test/__snapshots__/webpack.test.ts.snap +++ b/test/__snapshots__/webpack.test.ts.snap @@ -199,6 +199,59 @@ const greeting = [ ;// CONCATENATED MODULE: ./test/fixtures/normal/input.mjs +function example() { + return en_messages +} + +var __webpack_exports__default = __webpack_exports__.Z; +export { __webpack_exports__default as default }; +" +`; + +exports[`webpack > should handle errors as defined 1`] = ` +"/******/ // The require scope +/******/ var __webpack_require__ = {}; +/******/ +/************************************************************************/ +/******/ /* webpack/runtime/define property getters */ +/******/ (() => { +/******/ // define getter functions for harmony exports +/******/ __webpack_require__.d = (exports, definition) => { +/******/ for(var key in definition) { +/******/ if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) { +/******/ Object.defineProperty(exports, key, { enumerable: true, get: definition[key] }); +/******/ } +/******/ } +/******/ }; +/******/ })(); +/******/ +/******/ /* webpack/runtime/hasOwnProperty shorthand */ +/******/ (() => { +/******/ __webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop)) +/******/ })(); +/******/ +/************************************************************************/ +var __webpack_exports__ = {}; + +// EXPORTS +__webpack_require__.d(__webpack_exports__, { + Z: () => (/* binding */ example) +}); + +;// CONCATENATED MODULE: ./test/fixtures/errored/en.messages.json +const greeting = [ + { + type: 0, + value: \\"Hello, {name}{name} { it('should generate bundle', async () => { @@ -296,6 +296,74 @@ describe('rollup', () => { expect(output[0]?.code).toMatchSnapshot() }) + it('should handle errors as defined', async () => { + const onParseError = vi.fn(function ({ useBuiltinStrategy }) { + return useBuiltinStrategy('use-message-as-literal') + } satisfies PluginOptions['onParseError']) + + const { generate } = await rollup({ + input: [ + resolve( + dirname(fileURLToPath(import.meta.url)), + 'fixtures/errored/input.mjs', + ), + ], + plugins: [ + icuMessages({ + format: 'crowdin', + parserOptions() { + return { + ...this.getDefaultOptions(), + } + }, + onParseError, + }), + ], + }) + + const { output } = await generate({ format: 'esm' }) + + expect(output).toHaveLength(1) + + expect(output[0]?.code).toMatchSnapshot() + + expect(onParseError.mock.calls).toHaveLength(1) + + const context = onParseError.mock.calls[0][0] + + expect(context).toBeDefined() + + const { message, messageId, error } = context + + expect({ + message, + messageId, + error, + moduleId: basename(context.moduleId), + locale: context.parserOptions?.locale?.baseName, + }).toMatchInlineSnapshot(` + { + "error": [SyntaxError: INVALID_TAG], + "locale": "en", + "message": "Hello, {name}{name} { expect(icuMessages({}).api).toHaveProperty('filter') }) diff --git a/test/webpack.test.ts b/test/webpack.test.ts index 3f82366..46edc59 100644 --- a/test/webpack.test.ts +++ b/test/webpack.test.ts @@ -4,10 +4,11 @@ import { default as webpack, type StatsCompilation, } from 'webpack' -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi } from 'vitest' import { createFsFromVolume, Volume } from 'memfs' import type * as _distWebpack from '../dist/webpack' import { createResolver } from './utils/resolver' +import { basename } from 'pathe' Error.stackTraceLimit = 1000 @@ -188,6 +189,59 @@ describe( expect(out).toMatchSnapshot() }) + + it('should handle errors as defined', async () => { + const onParseError = vi.fn(function ({ useBuiltinStrategy }) { + return useBuiltinStrategy('use-message-as-literal') + } satisfies _distWebpack.PluginOptions['onParseError']) + + const out = await buildFile('fixtures/errored/input.mjs', (config) => { + ;(config.plugins ??= []).push( + icuMessages({ + format: 'crowdin', + onParseError, + }), + ) + }) + + expect(out).toMatchSnapshot() + + expect(onParseError.mock.calls).toHaveLength(1) + + const context = onParseError.mock.calls[0][0] + + expect(context).toBeDefined() + + const { message, messageId, error } = context + + expect({ + message, + messageId, + error, + moduleId: basename(context.moduleId), + locale: context.parserOptions?.locale?.baseName, + }).toMatchInlineSnapshot(` + { + "error": [SyntaxError: INVALID_TAG], + "locale": "en", + "message": "Hello, {name}{name}