From 487d1c89744989354670b590b564fcdea404d405 Mon Sep 17 00:00:00 2001 From: Rasmus Gustafsson Date: Fri, 26 Jul 2024 04:02:40 +0300 Subject: [PATCH] feat: simpler and more intuitive api --- package.json | 7 +- pnpm-lock.yaml | 26 ++++- src/formatters.ts | 21 ++++ src/index.ts | 290 ++++++++++++++-------------------------------- src/types.ts | 55 ++++++--- test/setup.ts | 73 +++++++----- 6 files changed, 214 insertions(+), 258 deletions(-) create mode 100644 src/formatters.ts diff --git a/package.json b/package.json index 91c74b6..bf7e81c 100644 --- a/package.json +++ b/package.json @@ -52,12 +52,11 @@ "eslint-plugin-prettier": "^4.2.1", "prettier": "^2.8.8", "turbo": "^1.10.6", + "type-fest": "^4.23.0", + "typescript": "5", "unbuild": "^1.2.1", "vite": "^4.3.9", "vitest": "^0.32.2" }, - "packageManager": "pnpm@8.6.0", - "dependencies": { - "typescript": "5" - } + "packageManager": "pnpm@8.6.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1f5ec0b..fca0caf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,4 +1,4 @@ -lockfileVersion: '6.1' +lockfileVersion: '6.0' settings: autoInstallPeers: true @@ -7,10 +7,6 @@ settings: importers: .: - dependencies: - typescript: - specifier: '5' - version: 5.0.2 devDependencies: '@typescript-eslint/eslint-plugin': specifier: ^5.60.1 @@ -30,6 +26,12 @@ importers: turbo: specifier: ^1.10.6 version: 1.10.6 + type-fest: + specifier: ^4.23.0 + version: 4.23.0 + typescript: + specifier: '5' + version: 5.0.2 unbuild: specifier: ^1.2.1 version: 1.2.1 @@ -246,6 +248,7 @@ packages: /@babel/highlight@7.22.5: resolution: {integrity: sha512-BSKlD1hgnedS5XRnGOljZawtag7H1yPfQp0tdNJCHoH6AZ+Pcm9VvkrK59/Yy593Ypg0zMxH2BxD1VPYUQ7UIw==} engines: {node: '>=6.9.0'} + requiresBuild: true dependencies: '@babel/helper-validator-identifier': 7.22.5 chalk: 2.4.2 @@ -313,6 +316,7 @@ packages: /@emotion/memoize@0.7.4: resolution: {integrity: sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==} + requiresBuild: true dev: false optional: true @@ -1077,6 +1081,7 @@ packages: /ansi-styles@3.2.1: resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} engines: {node: '>=4'} + requiresBuild: true dependencies: color-convert: 1.9.3 @@ -1319,6 +1324,7 @@ packages: /chalk@2.4.2: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} engines: {node: '>=4'} + requiresBuild: true dependencies: ansi-styles: 3.2.1 escape-string-regexp: 1.0.5 @@ -1366,6 +1372,7 @@ packages: /color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + requiresBuild: true dependencies: color-name: 1.1.3 @@ -1377,6 +1384,7 @@ packages: /color-name@1.1.3: resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + requiresBuild: true /color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} @@ -1678,6 +1686,7 @@ packages: /escape-string-regexp@1.0.5: resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} engines: {node: '>=0.8.0'} + requiresBuild: true /escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} @@ -2299,6 +2308,7 @@ packages: /has-flag@3.0.0: resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} engines: {node: '>=4'} + requiresBuild: true /has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} @@ -3527,6 +3537,7 @@ packages: /supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} + requiresBuild: true dependencies: has-flag: 3.0.0 @@ -3748,6 +3759,11 @@ packages: resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} engines: {node: '>=10'} + /type-fest@4.23.0: + resolution: {integrity: sha512-ZiBujro2ohr5+Z/hZWHESLz3g08BBdrdLMieYFULJO+tWc437sn8kQsWLJoZErY8alNhxre9K4p3GURAG11n+w==} + engines: {node: '>=16'} + dev: true + /typed-array-length@1.0.4: resolution: {integrity: sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng==} dependencies: diff --git a/src/formatters.ts b/src/formatters.ts new file mode 100644 index 0000000..356689e --- /dev/null +++ b/src/formatters.ts @@ -0,0 +1,21 @@ +import { Formatter } from './types' + +export const textFormatter: Formatter = (logParts) => { + const events = logParts.filter(({ type }) => type === 'event') + const explanations = logParts.filter(({ type }) => type === 'explanation') + const solutions = logParts.filter(({ type }) => type === 'solution') + + const capitalize = (input: string) => `${input.charAt(0).toUpperCase()}${input.slice(1)}` + + const logMessageParts = [ + capitalize(events.map(({ message }) => message).join(' and ')), + ' because ', + explanations.map(({ message }) => message).join(' and '), + '.', + '\n', + '\n', + 'Possible solutions:\n', + solutions.map(({ message }, solutionIndex) => `${solutionIndex + 1}) ${message}`) + ] + return logMessageParts.join('') +} diff --git a/src/index.ts b/src/index.ts index 07e7bc7..6d946a9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,225 +11,109 @@ * */ -import { ActionType, HumanLogsObject, SolutionType, TemplatedMessage } from './types' - -function replaceTemplateParams(input: TemplatedMessage, params: HumanLogsObject['params']): string { - let result = input.template - for (const key in params) { - const value = params[key] as string - const placeholder = `{${key}}` - result = result.split(placeholder).join(value) +import { textFormatter } from './formatters' +import { ActionType, CombineParams, CreateHumanLogsOptions, LogObject } from './types' + +function log( + name: Name & {}, + message: Message & {}, + options: { + params: Params + type: LogObject['type'] + actions?: ActionType[] + } = { + params: {} as Params, + type: 'event' } - return result +) { + return { + name: name, + message, + type: options.type, + params: options.params + } as const } -function isTemplatedMessage( - solution: string | Record -): solution is TemplatedMessage { - if (typeof solution === 'string') return false - if (solution.template) return true - return false +export function event( + name: Name & {}, + message: Message & {}, + options: { + params: Params + actions?: ActionType[] + } = { + params: {} as Params + } +) { + return log(name, message, { ...options, type: 'event' }) } -function isSolutionType(solution: string | Record): solution is SolutionType { - if (typeof solution === 'string') return false - if (solution.actions) return true - return false +export function explanation( + name: Name & {}, + message: Message & {}, + options: { + params: Params + actions?: ActionType[] + } = { + params: {} as Params + } +) { + return log(name, message, { ...options, type: 'explanation' }) } -function getMessagePartsFor( - what: keyof HumanLogsObject, - options: HumanLogs, - eventKey: string, - params: HumanLogs['params'] -) { - const messageParts: string[] = [] - const eventTypeOrString = options[what]![eventKey] as - | string - | TemplatedMessage - | SolutionType - | undefined - if (!eventTypeOrString) { - return messageParts +export function solution( + name: Name & {}, + message: Message & {}, + options: { + params: Params + actions?: ActionType[] + } = { + params: {} as Params } +) { + return log(name, message, { ...options, type: 'solution' }) +} - if (isTemplatedMessage(eventTypeOrString)) { - messageParts.push(replaceTemplateParams(eventTypeOrString, params)) - } else { - messageParts.push(eventTypeOrString) +function replaceTemplateParams( + input: string, + params: Params +): string { + let result = String(input) + for (const key in params) { + const value = params[key] as string + const placeholder = `{${key}}` + result = result.split(placeholder).join(value) } - - return messageParts + return result } -export function createHumanLogs(options: HumanLogs) { - type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ( - k: infer I - ) => void - ? I - : never - type Explanations = HumanLogs['explanations'] - type Events = HumanLogs['events'] - type Solutions = HumanLogs['solutions'] - type ExplanationParams> = UnionToIntersection< - { - [I in keyof K]: Explanations[K[I]] extends { params: infer P } ? P : never - }[number] - > - type EventParams> = UnionToIntersection< - { - [I in keyof K]: Events[K[I]] extends { params: infer P } ? P : never - }[number] - > - type SolutionParams> = UnionToIntersection< - { - [I in keyof K]: Solutions[K[I]] extends { params: infer P } ? P : never - }[number] - > - - return function < - Events extends keyof HumanLogs['events'], - Explanations extends keyof HumanLogs['explanations'], - Solutions extends keyof HumanLogs['solutions'] - >({ - events, - explanations, - solutions, - params - }: { - events?: Events[] - explanations?: Explanations[] - solutions?: Solutions[] - params?: ExplanationParams & EventParams & SolutionParams - }) { - const eventParts: string[] = [] - const explanationParts: string[] = [] - const solutionParts: string[] = [] - const actionParts: ActionType[] = [] - - const addEventParts = (events: Events[], overwrittenParams?: HumanLogs['params']) => { - events.forEach((event) => { - eventParts.push( - ...getMessagePartsFor( - 'events', - options, - event as string, - overwrittenParams ?? (params as HumanLogs['params']) - ) - ) - }) - } - - const addExplanationParts = ( - explanations: Explanations[], - overwrittenParams?: HumanLogs['params'] - ) => { - explanations?.forEach((explanation) => { - explanationParts.push( - ...getMessagePartsFor( - 'explanations', - options, - explanation as string, - overwrittenParams ?? (params as HumanLogs['params']) - ) - ) - }) - } - - const addSolutionParts = (solutions: Solutions[], overwrittenParams?: HumanLogs['params']) => { - solutions?.forEach((solution) => { - solutionParts.push( - ...getMessagePartsFor( - 'solutions', - options, - solution as string, - overwrittenParams ?? (params as HumanLogs['params']) - ) - ) - - const solutionOrString = options.solutions[solution as string] - if (!solutionOrString) { - return - } - if (isSolutionType(solutionOrString) && solutionOrString.actions) { - actionParts.push(...solutionOrString.actions) - } - }) - } - - // Events - if (events) addEventParts(events) - - // Explanations - if (explanations) addExplanationParts(explanations) - - // Solutions - if (solutions) addSolutionParts(solutions) +export function createHumanLogs( + logs: LogParts, + options: CreateHumanLogsOptions = {} +) { + return function >( + logParts: LogNames, + // ...[params]: CombineParams extends never ? [] : [CombineParams] + params: CombineParams + ) { + const matchingParts = logs.filter(({ name }) => logParts.includes(name)) + + // Then template all of the parts + const templatedParts = matchingParts.map((part) => { + return { + ...part, + params: params as LogObject['params'], + message: replaceTemplateParams(part.message, params as LogObject['params']) + } + }) return { - actions: actionParts, - get message() { - return [...eventParts, ...explanationParts, ...solutionParts].join(' ') - }, - // Adds - addEvents( - events: InnerEvents[], - params?: EventParams - ) { - addEventParts(events as unknown as Events[], params as HumanLogs['params']) - return this - }, - addExplanations( - explanations: InnerExplanations[], - params?: ExplanationParams - ) { - addExplanationParts(explanations as unknown as Explanations[], params as any) - return this - }, - addSolutions( - solutions: InnerSolutions[], - params?: SolutionParams - ) { - addSolutionParts(solutions as unknown as Solutions[], params as HumanLogs['params']) - return this - }, - // Overrides - overrideEvents( - events: InnerEvents[], - params?: EventParams - ) { - // Clear event parts - eventParts.length = 0 - addEventParts(events as unknown as Events[], params as HumanLogs['params']) - return this - }, - overrideExplanations( - explanations: InnerExplanations[], - params?: ExplanationParams - ) { - // Clear explanation parts - explanationParts.length = 0 - addExplanationParts( - explanations as unknown as Explanations[], - params as HumanLogs['params'] - ) - return this - }, - - overrideSolutions( - solutions: InnerSolutions[], - params?: SolutionParams - ) { - // Clear solution parts - solutionParts.length = 0 - addSolutionParts(solutions as unknown as Solutions[], params as HumanLogs['params']) - return this + get parts() { + return templatedParts }, toString() { - return `${[...eventParts, ...explanationParts, ...solutionParts].join(' ')}${ - actionParts ? actionParts.map((a) => ` ${a.text} (${a.href})`).join(' or') : '' - }` + const formatter = options.formatter ?? textFormatter + return formatter(templatedParts) } - } as const + } } } diff --git a/src/types.ts b/src/types.ts index c014892..2be5270 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,27 +1,48 @@ -export type TemplatedMessage = { - template: string - params: Record -} - -export type EventType = TemplatedMessage - export type ActionType = { text: string - href: string + href?: string } -export type SolutionType = TemplatedMessage & { - actions?: ActionType[] -} +import type { UnionToIntersection } from 'type-fest' -export type HumanLogsObject = { - events: Record - explanations: Record - solutions: Record - params?: Record +// Utility type to broaden literal types to their primitive types +export type Broaden = { + [K in keyof T]: T[K] extends string + ? string + : T[K] extends number + ? number + : T[K] extends boolean + ? boolean + : T[K] extends undefined + ? undefined + : T[K] extends object + ? Broaden + : T[K] } -export type HumanLogResponse = { +// Helper to extract params from a single solution and broaden the types +export type ExtractParams = T extends { params: infer P } ? Broaden

: never + +// Revised CombineParams type +export type CombineParams< + T extends ReadonlyArray<{ name: string; params?: any }>, + Names extends ReadonlyArray +> = UnionToIntersection< + { + [K in Names[number]]: ExtractParams> + }[Names[number]] +> + +export type LogObject = { + type: 'event' | 'explanation' | 'solution' + name: string message: string + params?: Record actions?: ActionType[] } + +export type CreateHumanLogsOptions = { + formatter?: () => {} +} + +export type Formatter = (logParts: LogParts) => string diff --git a/test/setup.ts b/test/setup.ts index 756287b..d7ef7b5 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -1,32 +1,47 @@ -import { createHumanLogs } from '../src' +import { createHumanLogs, event, explanation, solution } from '../src' -export const mockHumanLogs = createHumanLogs({ - events: { - project_create_failed: 'Cannot create your project', - team_create_failed: 'Creating your team failed' - }, - explanations: { - api_unreachable: 'because the API cannot be reached.', - database_unreachable: 'because database API cannot be reached.', - team_exists: { - template: 'because a team with ID "{teamId}" already exists.', - params: { - teamId: '' - } +export const mockErrors = createHumanLogs([ + event('fetch_posts_failed', 'fetching posts failed', { + params: {} + }), + event('saving_cache_failed', 'saving content to cache failed', { + params: {} + }), + event('fetching_page_contents_failed', 'fetching content for page with ID `{pageId}` failed', { + params: { pageId: '' } + }), + explanation( + 'package_json_not_found', + 'a package.json file could not be found while traversing up the filetree' + ), + explanation( + 'missing_params', + 'the {paramType} `{paramName}` is missing for post with ID `{postId}`, and no fallback was provided', + { + params: { paramType: '', paramName: '', postId: '' } + } + ), + explanation( + 'unsupported_blocktype', + 'unsupported block type `{blockType} is included on this page.`', + { + params: { blockType: '' } } - }, - solutions: { - try_again: 'Please try again.', - contact_us: 'If the problem persists, contact us.', - check_status_page: { - params: {}, - template: 'You can check the status of our services on our status page.', - actions: [ - { - text: 'Go to status page', - href: 'https://status.foobar.inc' - } - ] + ), + solution( + 'provide_fallback', + 'add a fallback to your parameter definition like this: \n\nurl(`{paramName}`, { fallback: `https://useflytrap.com` })', + { + params: { paramName: '' } } - } -}) + ), + solution('check_statuspage', 'you can check the status of our services on our status page', { + params: {}, + actions: [ + { + text: 'Go to status page', + href: 'https://status.foobar.inc' + } + ] + }) +])