Skip to content
This repository has been archived by the owner on Feb 16, 2024. It is now read-only.

Commit

Permalink
feat: add error handling option
Browse files Browse the repository at this point in the history
  • Loading branch information
brawaru committed Oct 5, 2023
1 parent 50838f1 commit 6c6c282
Show file tree
Hide file tree
Showing 18 changed files with 550 additions and 29 deletions.
70 changes: 69 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<PluginType>`
- **Type**: `boolean` `|` [`WrappingOptions<PluginType>`](#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`**
Expand Down
6 changes: 5 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -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'
109 changes: 109 additions & 0 deletions src/parser/error-handling.ts
Original file line number Diff line number Diff line change
@@ -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<string, ParseErrorHandler>

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
}
14 changes: 12 additions & 2 deletions src/parser/index.ts
Original file line number Diff line number Diff line change
@@ -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'
5 changes: 4 additions & 1 deletion src/platform-bindings/rollup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RollupPlugin>

export const icuMessages = (
basePlugin as UnpluginInstance<Options<RollupPlugin>, false>
basePlugin as UnpluginInstance<PluginOptions, false>
).rollup
5 changes: 4 additions & 1 deletion src/platform-bindings/vite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<VitePlugin>

export const icuMessages = (
basePlugin as UnpluginInstance<Options<VitePlugin>, false>
basePlugin as UnpluginInstance<PluginOptions, false>
).vite
5 changes: 4 additions & 1 deletion src/platform-bindings/webpack.ts
Original file line number Diff line number Diff line change
@@ -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<any>

export const icuMessages = basePlugin.webpack
98 changes: 88 additions & 10 deletions src/plugin/message-parsing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

/**
Expand All @@ -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
Expand All @@ -28,23 +69,60 @@ class ParseError extends BaseError {
*/
export function parseMessages(
messages: MessagesMap,
getParserOptions: ParserOptionsResolver,
moduleId: string,
getParserOptions: ParserOptionsResolver,
onError?: ParseErrorHandler,
) {
const out: Record<string, MessageFormatElement[]> = 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,
}),
)
}
}
}

Expand Down

0 comments on commit 6c6c282

Please sign in to comment.