hanzio 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +510 -0
- package/dist/api-wrapper/index.d.ts +84 -0
- package/dist/api-wrapper/index.js +250 -0
- package/dist/array/index.d.ts +22 -0
- package/dist/cache/index.d.ts +19 -0
- package/dist/date/index.d.ts +11 -0
- package/dist/gzip/index.d.ts +2 -0
- package/dist/gzip/index.js +16 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +1565 -0
- package/dist/jwt/index.d.ts +3 -0
- package/dist/jwt/index.js +19 -0
- package/dist/math/index.d.ts +2 -0
- package/dist/math/linearRegressionTrend.d.ts +5 -0
- package/dist/math/metricsSeriesColors.d.ts +42 -0
- package/dist/p-queue/index.d.ts +95 -0
- package/dist/p-queue/index.js +439 -0
- package/dist/promise/index.d.ts +21 -0
- package/dist/sitemap/index.d.ts +14 -0
- package/dist/sitemap/index.js +552 -0
- package/dist/state/index.d.ts +101 -0
- package/dist/string/index.d.ts +8 -0
- package/dist/typedswitch/index.d.ts +124 -0
- package/dist/url/index.d.ts +1 -0
- package/dist/zod/index.d.ts +8 -0
- package/dist/zod/index.js +210 -0
- package/dist/zod/jsonToZodSchemaMapper.d.ts +6 -0
- package/dist/zod/zodMinMaxFilter.d.ts +14 -0
- package/dist/zod/zodToTypeString.d.ts +9 -0
- package/package.json +102 -0
- package/src/api-wrapper/index.ts +492 -0
- package/src/array/index.ts +187 -0
- package/src/cache/index.ts +154 -0
- package/src/date/index.ts +54 -0
- package/src/gzip/index.ts +29 -0
- package/src/index.ts +12 -0
- package/src/jwt/index.ts +25 -0
- package/src/math/index.ts +11 -0
- package/src/math/linearRegressionTrend.ts +36 -0
- package/src/math/metricsSeriesColors.ts +208 -0
- package/src/p-queue/index.ts +690 -0
- package/src/promise/index.ts +73 -0
- package/src/sitemap/index.ts +195 -0
- package/src/state/index.ts +331 -0
- package/src/string/index.ts +86 -0
- package/src/typedswitch/index.ts +309 -0
- package/src/url/index.ts +5 -0
- package/src/zod/index.ts +29 -0
- package/src/zod/jsonToZodSchemaMapper.ts +44 -0
- package/src/zod/zodMinMaxFilter.ts +28 -0
- package/src/zod/zodToTypeString.ts +222 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export type BackgroundPromiseOptions = {
|
|
2
|
+
onError?: (error: unknown) => void
|
|
3
|
+
onSuccess?: () => void
|
|
4
|
+
scheduler?: (callback: () => void) => void
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export type Success<T> = {
|
|
8
|
+
data: T
|
|
9
|
+
error: null
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type Failure<E> = {
|
|
13
|
+
data: null
|
|
14
|
+
error: E
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type Result<T, E = Error> = Success<T> | Failure<E>
|
|
18
|
+
|
|
19
|
+
export function isSuccess<T, E>(result: Result<T, E>): result is Success<T> {
|
|
20
|
+
return result.error === null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isFailure<T, E>(result: Result<T, E>): result is Failure<E> {
|
|
24
|
+
return result.error !== null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function unwrapResult<T, E>(result: Result<T, E>): T {
|
|
28
|
+
if (isSuccess(result)) return result.data
|
|
29
|
+
throw result.error
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function backgroundPromise<T>(
|
|
33
|
+
promiseFactory: () => Promise<T>,
|
|
34
|
+
options: BackgroundPromiseOptions = {}
|
|
35
|
+
): void {
|
|
36
|
+
const run = () => {
|
|
37
|
+
promiseFactory()
|
|
38
|
+
.then(() => options.onSuccess?.())
|
|
39
|
+
.catch((error: unknown) => options.onError?.(error))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (options.scheduler) {
|
|
43
|
+
options.scheduler(run)
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
run()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function backgroundPromiseSync<T>(
|
|
51
|
+
promiseFactory: () => Promise<T>,
|
|
52
|
+
options: BackgroundPromiseOptions = {}
|
|
53
|
+
): void {
|
|
54
|
+
backgroundPromise(promiseFactory, {
|
|
55
|
+
...options,
|
|
56
|
+
scheduler: options.scheduler ?? queueMicrotask
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function promiseTimeout(ms: number): Promise<void> {
|
|
61
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function tryCatch<T, E = Error>(
|
|
65
|
+
promise: Promise<T>
|
|
66
|
+
): Promise<Result<T, E>> {
|
|
67
|
+
try {
|
|
68
|
+
const data = await promise
|
|
69
|
+
return { data, error: null }
|
|
70
|
+
} catch (error) {
|
|
71
|
+
return { data: null, error: error as E }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import PQueue from '../p-queue'
|
|
2
|
+
|
|
3
|
+
interface SitemapOptions {
|
|
4
|
+
filterIndexes?: string
|
|
5
|
+
concurrency?: number
|
|
6
|
+
maxDepth?: number
|
|
7
|
+
timeout?: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface ProcessedResult {
|
|
11
|
+
links: Set<string>
|
|
12
|
+
visited: Set<string>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Creates an AbortController with a timeout
|
|
17
|
+
*/
|
|
18
|
+
const createTimeoutController = (timeout: number): AbortController => {
|
|
19
|
+
const controller = new AbortController()
|
|
20
|
+
setTimeout(() => controller.abort(), timeout)
|
|
21
|
+
return controller
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Discovers sitemap URL(s) from a domain by checking common locations and robots.txt
|
|
26
|
+
*/
|
|
27
|
+
const discoverSitemaps = async (domain: string): Promise<string[]> => {
|
|
28
|
+
const normalizedDomain = domain.replace(/\/$/, '')
|
|
29
|
+
const commonLocations = [
|
|
30
|
+
'/sitemap.xml',
|
|
31
|
+
'/sitemap_index.xml',
|
|
32
|
+
'/sitemap/',
|
|
33
|
+
'/sitemaps.xml',
|
|
34
|
+
'/sitemap/sitemap.xml'
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
const sitemaps: Set<string> = new Set()
|
|
38
|
+
const requestOptions: RequestInit = {
|
|
39
|
+
headers: {
|
|
40
|
+
'User-Agent': 'SitemapCrawler/1.0'
|
|
41
|
+
},
|
|
42
|
+
// biome-ignore lint/suspicious/noExplicitAny: AbortSignal types differ between Bun and React Native
|
|
43
|
+
signal: createTimeoutController(5000).signal as any
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Check robots.txt first
|
|
47
|
+
try {
|
|
48
|
+
const robotsTxt = await fetch(`${normalizedDomain}/robots.txt`, {
|
|
49
|
+
...requestOptions
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
if (robotsTxt.ok) {
|
|
53
|
+
const text = await robotsTxt.text()
|
|
54
|
+
const sitemapMatches = text.match(/Sitemap: (.*)/gi)
|
|
55
|
+
if (sitemapMatches) {
|
|
56
|
+
sitemapMatches.forEach((match: string) => {
|
|
57
|
+
const url = match.replace('Sitemap:', '').trim()
|
|
58
|
+
sitemaps.add(url)
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// If we found sitemaps in robots.txt, return them immediately
|
|
63
|
+
if (sitemaps.size > 0) {
|
|
64
|
+
return [...sitemaps]
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
} catch (error) {
|
|
68
|
+
console.warn(
|
|
69
|
+
'Could not fetch robots.txt:',
|
|
70
|
+
error instanceof Error ? error.message : 'Unknown error'
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Check common locations one by one
|
|
75
|
+
for (const location of commonLocations) {
|
|
76
|
+
try {
|
|
77
|
+
const response = await fetch(`${normalizedDomain}${location}`, {
|
|
78
|
+
...requestOptions,
|
|
79
|
+
method: 'HEAD'
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
if (response.ok) {
|
|
83
|
+
return [`${normalizedDomain}${location}`]
|
|
84
|
+
}
|
|
85
|
+
} catch (_error) {
|
|
86
|
+
// Ignore 404s and other errors
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return []
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Gets all URLs from a domain by discovering and crawling its sitemaps
|
|
95
|
+
* @param domain - The root domain (e.g., 'https://example.com')
|
|
96
|
+
* @param options - Configuration options for the crawler
|
|
97
|
+
* @returns Promise<string[]> - Array of unique URLs found in the sitemap
|
|
98
|
+
*/
|
|
99
|
+
export const getDomainSitemap = async (
|
|
100
|
+
domain: string,
|
|
101
|
+
options: SitemapOptions = {}
|
|
102
|
+
): Promise<string[]> => {
|
|
103
|
+
const {
|
|
104
|
+
filterIndexes,
|
|
105
|
+
concurrency = 10,
|
|
106
|
+
maxDepth = 5,
|
|
107
|
+
timeout = 10000
|
|
108
|
+
} = options
|
|
109
|
+
|
|
110
|
+
// Normalize domain
|
|
111
|
+
let normalizedDomain = domain.replace(/\/$/, '')
|
|
112
|
+
if (!normalizedDomain.startsWith('http')) {
|
|
113
|
+
normalizedDomain = `https://${normalizedDomain}`
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Discover sitemaps
|
|
117
|
+
const sitemapUrls = await discoverSitemaps(normalizedDomain)
|
|
118
|
+
if (sitemapUrls.length === 0) {
|
|
119
|
+
throw new Error('No sitemaps found for the domain')
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const queue = new PQueue({ concurrency })
|
|
123
|
+
const result: ProcessedResult = {
|
|
124
|
+
links: new Set<string>(),
|
|
125
|
+
visited: new Set<string>()
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const locRegex = /<loc>([^<]+)<\/loc>/g
|
|
129
|
+
const isXmlSitemap = (url: string) =>
|
|
130
|
+
url.endsWith('.xml') || url.toLowerCase().includes('sitemap')
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Process a single sitemap URL and extract all links
|
|
134
|
+
*/
|
|
135
|
+
const processSitemap = async (
|
|
136
|
+
sitemapUrl: string,
|
|
137
|
+
depth = 0
|
|
138
|
+
): Promise<void> => {
|
|
139
|
+
if (depth > maxDepth || result.visited.has(sitemapUrl)) {
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// console.log('Processing sitemap:', sitemapUrl)
|
|
144
|
+
|
|
145
|
+
result.visited.add(sitemapUrl)
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
const controller = createTimeoutController(timeout)
|
|
149
|
+
const response = await fetch(sitemapUrl, {
|
|
150
|
+
headers: {
|
|
151
|
+
'User-Agent': 'SitemapCrawler/1.0'
|
|
152
|
+
},
|
|
153
|
+
// biome-ignore lint/suspicious/noExplicitAny: AbortSignal types differ between Bun and React Native
|
|
154
|
+
signal: controller.signal as any
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
if (!response.ok)
|
|
158
|
+
throw new Error(`HTTP error! status: ${response.status}`)
|
|
159
|
+
|
|
160
|
+
const data = await response.text()
|
|
161
|
+
const matches = [...data.matchAll(locRegex)]
|
|
162
|
+
.map((match) => match[1]!.trim())
|
|
163
|
+
.filter((link) => {
|
|
164
|
+
if (filterIndexes && isXmlSitemap(link)) {
|
|
165
|
+
return link.includes(filterIndexes)
|
|
166
|
+
}
|
|
167
|
+
return true
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
for (const link of matches) {
|
|
171
|
+
if (isXmlSitemap(link)) {
|
|
172
|
+
// Queue nested sitemap for processing
|
|
173
|
+
queue.add(() => processSitemap(link, depth + 1))
|
|
174
|
+
} else {
|
|
175
|
+
result.links.add(link)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
} catch (error) {
|
|
179
|
+
console.warn(
|
|
180
|
+
`Failed to process sitemap ${sitemapUrl}:`,
|
|
181
|
+
error instanceof Error ? error.message : 'Unknown error'
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Process all discovered sitemaps
|
|
187
|
+
await Promise.all(
|
|
188
|
+
sitemapUrls.map((sitemapUrl) => queue.add(() => processSitemap(sitemapUrl)))
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
// Wait for all queued tasks to complete
|
|
192
|
+
await queue.onIdle()
|
|
193
|
+
|
|
194
|
+
return [...result.links]
|
|
195
|
+
}
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple State Machine - A lightweight state machine implementation
|
|
3
|
+
*
|
|
4
|
+
* This provides a generic state machine abstraction that can be used
|
|
5
|
+
* for various workflows and business processes.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Type for validation result
|
|
10
|
+
* - true: transition is valid
|
|
11
|
+
* - string: transition is invalid with reason
|
|
12
|
+
*/
|
|
13
|
+
export type ValidationResult = true | string
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Type for validation function
|
|
17
|
+
* @param context The context object containing data needed for validation
|
|
18
|
+
* @param fromState The state transitioning from
|
|
19
|
+
* @param toState The state transitioning to
|
|
20
|
+
* @param action When validating a specific action, the action key (for per-action rules)
|
|
21
|
+
*/
|
|
22
|
+
export type ValidatorFn<
|
|
23
|
+
TState extends string,
|
|
24
|
+
TContext,
|
|
25
|
+
TAction extends string = string
|
|
26
|
+
> = (
|
|
27
|
+
context: TContext,
|
|
28
|
+
fromState: TState,
|
|
29
|
+
toState: TState,
|
|
30
|
+
action?: TAction
|
|
31
|
+
) => Promise<ValidationResult> | ValidationResult
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Type for action handler function
|
|
35
|
+
* @param context The context object containing data needed for validation
|
|
36
|
+
* @param data Additional data passed to the action
|
|
37
|
+
* @returns Result of executing the action
|
|
38
|
+
*/
|
|
39
|
+
export type ActionHandlerFn<TContext, TData = unknown, TResult = unknown> = (
|
|
40
|
+
context: TContext,
|
|
41
|
+
data: TData
|
|
42
|
+
) => Promise<TResult> | TResult
|
|
43
|
+
|
|
44
|
+
export type ActionDataMap<TAction extends string> = Record<TAction, unknown>
|
|
45
|
+
export type ActionResultMap<TAction extends string> = Record<TAction, unknown>
|
|
46
|
+
export type StateKey<TContext, TState extends string> = {
|
|
47
|
+
[K in keyof TContext]: TContext[K] extends TState ? K : never
|
|
48
|
+
}[keyof TContext]
|
|
49
|
+
|
|
50
|
+
export type ActionExecutionResult<TState extends string, TResult = unknown> =
|
|
51
|
+
| {
|
|
52
|
+
success: true
|
|
53
|
+
newState: TState
|
|
54
|
+
result?: TResult
|
|
55
|
+
}
|
|
56
|
+
| {
|
|
57
|
+
success: false
|
|
58
|
+
error: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Interface for state machine configuration
|
|
63
|
+
*/
|
|
64
|
+
export interface StateMachineConfig<
|
|
65
|
+
TState extends string,
|
|
66
|
+
TAction extends string,
|
|
67
|
+
TContext,
|
|
68
|
+
TActionDataMap extends ActionDataMap<TAction> = ActionDataMap<TAction>,
|
|
69
|
+
TActionResultMap extends ActionResultMap<TAction> = ActionResultMap<TAction>
|
|
70
|
+
> {
|
|
71
|
+
// Maps states to possible actions and their target states
|
|
72
|
+
stateActionMap: Record<TState, Partial<Record<TAction, TState>>>
|
|
73
|
+
|
|
74
|
+
// Maps states to allowed direct transitions
|
|
75
|
+
stateTransitions: Record<TState, readonly TState[]>
|
|
76
|
+
|
|
77
|
+
// Validation functions for specific states
|
|
78
|
+
validators?: Partial<Record<TState, ValidatorFn<TState, TContext, TAction>>>
|
|
79
|
+
|
|
80
|
+
// Global validator that runs for all transitions
|
|
81
|
+
globalValidator?: ValidatorFn<TState, TContext, TAction>
|
|
82
|
+
|
|
83
|
+
// Action handlers for each action
|
|
84
|
+
actions?: {
|
|
85
|
+
[K in TAction]?: ActionHandlerFn<
|
|
86
|
+
TContext,
|
|
87
|
+
TActionDataMap[K],
|
|
88
|
+
TActionResultMap[K]
|
|
89
|
+
>
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type ValidateTransitionOptions<TAction extends string> = {
|
|
94
|
+
action?: TAction
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Creates a simple state machine with validation capabilities
|
|
99
|
+
*/
|
|
100
|
+
export class SimpleStateMachine<
|
|
101
|
+
TState extends string,
|
|
102
|
+
TAction extends string,
|
|
103
|
+
TContext,
|
|
104
|
+
TActionDataMap extends ActionDataMap<TAction> = ActionDataMap<TAction>,
|
|
105
|
+
TActionResultMap extends ActionResultMap<TAction> = ActionResultMap<TAction>
|
|
106
|
+
> {
|
|
107
|
+
private config: StateMachineConfig<
|
|
108
|
+
TState,
|
|
109
|
+
TAction,
|
|
110
|
+
TContext,
|
|
111
|
+
TActionDataMap,
|
|
112
|
+
TActionResultMap
|
|
113
|
+
>
|
|
114
|
+
private item: TContext
|
|
115
|
+
private stateKey: StateKey<TContext, TState>
|
|
116
|
+
|
|
117
|
+
constructor(
|
|
118
|
+
config: StateMachineConfig<
|
|
119
|
+
TState,
|
|
120
|
+
TAction,
|
|
121
|
+
TContext,
|
|
122
|
+
TActionDataMap,
|
|
123
|
+
TActionResultMap
|
|
124
|
+
>,
|
|
125
|
+
item: TContext,
|
|
126
|
+
stateKey: StateKey<TContext, TState>
|
|
127
|
+
) {
|
|
128
|
+
this.config = config
|
|
129
|
+
this.item = item
|
|
130
|
+
this.stateKey = stateKey
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
getCurrentStatus(): TState {
|
|
134
|
+
return this.item[this.stateKey] as TState
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Get available actions for the current state with validation results
|
|
139
|
+
*
|
|
140
|
+
* @returns Map of actions to validation results
|
|
141
|
+
*/
|
|
142
|
+
async getAvailableActions(): Promise<
|
|
143
|
+
Partial<Record<TAction, ValidationResult>>
|
|
144
|
+
> {
|
|
145
|
+
const actionsMap = this.config.stateActionMap[this.getCurrentStatus()] || {}
|
|
146
|
+
const result: Partial<Record<TAction, ValidationResult>> = {}
|
|
147
|
+
|
|
148
|
+
for (const [action, targetState] of Object.entries(actionsMap)) {
|
|
149
|
+
if (!targetState) continue
|
|
150
|
+
|
|
151
|
+
const validationResult = await this.validateTransition(
|
|
152
|
+
targetState as TState,
|
|
153
|
+
{ action: action as TAction }
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
result[action as TAction] = validationResult
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return result
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Get all possible state transitions from the current state
|
|
164
|
+
*
|
|
165
|
+
* @returns Map of target states to validation results (true if any action to that state validates)
|
|
166
|
+
*/
|
|
167
|
+
async getPossibleTransitions(): Promise<
|
|
168
|
+
Partial<Record<TState, ValidationResult>>
|
|
169
|
+
> {
|
|
170
|
+
const possibleTransitions =
|
|
171
|
+
this.config.stateTransitions[this.getCurrentStatus()] || []
|
|
172
|
+
const actionsMap = this.config.stateActionMap[this.getCurrentStatus()] || {}
|
|
173
|
+
const result: Partial<Record<TState, ValidationResult>> = {}
|
|
174
|
+
|
|
175
|
+
for (const targetState of possibleTransitions) {
|
|
176
|
+
const actionsToTarget = (
|
|
177
|
+
Object.entries(actionsMap) as [TAction, TState | undefined][]
|
|
178
|
+
).filter(([, t]) => t === targetState)
|
|
179
|
+
|
|
180
|
+
if (actionsToTarget.length === 0) {
|
|
181
|
+
result[targetState] = await this.validateTransition(targetState)
|
|
182
|
+
continue
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
let lastError: ValidationResult = true
|
|
186
|
+
let anyOk = false
|
|
187
|
+
for (const [actionKey] of actionsToTarget) {
|
|
188
|
+
const r = await this.validateTransition(targetState, {
|
|
189
|
+
action: actionKey
|
|
190
|
+
})
|
|
191
|
+
if (r === true) {
|
|
192
|
+
anyOk = true
|
|
193
|
+
break
|
|
194
|
+
}
|
|
195
|
+
lastError = r
|
|
196
|
+
}
|
|
197
|
+
result[targetState] = anyOk ? true : lastError
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return result
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Executes an action with additional data
|
|
205
|
+
*
|
|
206
|
+
* @param action The action to execute
|
|
207
|
+
* @param data The data for the action
|
|
208
|
+
* @returns Promise with the execution result
|
|
209
|
+
*/
|
|
210
|
+
async executeAction<A extends TAction>(
|
|
211
|
+
action: A,
|
|
212
|
+
data: TActionDataMap[A]
|
|
213
|
+
): Promise<ActionExecutionResult<TState, TActionResultMap[A]>> {
|
|
214
|
+
const actionsMap = this.config.stateActionMap[this.getCurrentStatus()]
|
|
215
|
+
if (!actionsMap) {
|
|
216
|
+
return {
|
|
217
|
+
success: false,
|
|
218
|
+
error: `No actions defined for state '${this.getCurrentStatus()}'`
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const targetState = actionsMap[action]
|
|
223
|
+
if (!targetState) {
|
|
224
|
+
return {
|
|
225
|
+
success: false,
|
|
226
|
+
error: `Action '${action}' not allowed in state '${this.getCurrentStatus()}'`
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const validationResult = await this.validateTransition(targetState, {
|
|
231
|
+
action
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
if (validationResult === true) {
|
|
235
|
+
// If there's a custom action handler, call it with the provided data
|
|
236
|
+
if (this.config.actions?.[action]) {
|
|
237
|
+
try {
|
|
238
|
+
const result = await this.config.actions[action]!(this.item, data)
|
|
239
|
+
return {
|
|
240
|
+
success: true,
|
|
241
|
+
newState: targetState,
|
|
242
|
+
result
|
|
243
|
+
}
|
|
244
|
+
} catch (error) {
|
|
245
|
+
return {
|
|
246
|
+
success: false,
|
|
247
|
+
error: error instanceof Error ? error.message : String(error)
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Default behavior if no action handler
|
|
253
|
+
return {
|
|
254
|
+
success: true,
|
|
255
|
+
newState: targetState
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
success: false,
|
|
261
|
+
error: validationResult
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Validates a transition between states
|
|
267
|
+
*
|
|
268
|
+
* @param toState The state transitioning to
|
|
269
|
+
* @param options When validating a specific action, pass `action` for per-action rules
|
|
270
|
+
*/
|
|
271
|
+
async validateTransition(
|
|
272
|
+
toState: TState,
|
|
273
|
+
options?: ValidateTransitionOptions<TAction>
|
|
274
|
+
): Promise<ValidationResult> {
|
|
275
|
+
const action = options?.action
|
|
276
|
+
|
|
277
|
+
// If states are the same, no transition is needed
|
|
278
|
+
if (this.getCurrentStatus() === toState) return true
|
|
279
|
+
|
|
280
|
+
// Check if transition is allowed in the state machine
|
|
281
|
+
const allowedTransitions =
|
|
282
|
+
this.config.stateTransitions[this.getCurrentStatus()] || []
|
|
283
|
+
|
|
284
|
+
if (!allowedTransitions.includes(toState)) {
|
|
285
|
+
return `Invalid transition from '${this.getCurrentStatus()}' to '${toState}'`
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Run global validator if provided
|
|
289
|
+
if (this.config.globalValidator) {
|
|
290
|
+
const globalResult = await this.config.globalValidator(
|
|
291
|
+
this.item,
|
|
292
|
+
this.getCurrentStatus(),
|
|
293
|
+
toState,
|
|
294
|
+
action
|
|
295
|
+
)
|
|
296
|
+
if (globalResult !== true) {
|
|
297
|
+
return globalResult
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Run state-specific validator if provided
|
|
302
|
+
const stateValidator = this.config.validators?.[toState]
|
|
303
|
+
if (stateValidator) {
|
|
304
|
+
return await stateValidator(
|
|
305
|
+
this.item,
|
|
306
|
+
this.getCurrentStatus(),
|
|
307
|
+
toState,
|
|
308
|
+
action
|
|
309
|
+
)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return true
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Get all states in the state machine
|
|
317
|
+
*/
|
|
318
|
+
getStates(): TState[] {
|
|
319
|
+
return Object.keys(this.config.stateTransitions) as TState[]
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Get initial state of the state machine
|
|
324
|
+
* @returns The initial state
|
|
325
|
+
*/
|
|
326
|
+
getInitialState(): TState | undefined {
|
|
327
|
+
// This implementation just returns the first state
|
|
328
|
+
// You might want to customize this based on your needs
|
|
329
|
+
return this.getStates()[0]
|
|
330
|
+
}
|
|
331
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
export type JsonValue =
|
|
2
|
+
| string
|
|
3
|
+
| number
|
|
4
|
+
| boolean
|
|
5
|
+
| null
|
|
6
|
+
| JsonObject
|
|
7
|
+
| JsonArray
|
|
8
|
+
export type JsonObject = { [key: string]: JsonValue | undefined }
|
|
9
|
+
export type JsonArray = Array<JsonValue | undefined>
|
|
10
|
+
|
|
11
|
+
export function extractNumber(
|
|
12
|
+
value: string | null | undefined
|
|
13
|
+
): number | undefined {
|
|
14
|
+
if (!value?.trim()) return undefined
|
|
15
|
+
|
|
16
|
+
const cleanedValue = value.replace(/[^\d.,-]/g, '')
|
|
17
|
+
if (!cleanedValue) return undefined
|
|
18
|
+
|
|
19
|
+
const parsedNumber = Number.parseFloat(cleanedValue)
|
|
20
|
+
return Number.isNaN(parsedNumber) ? undefined : parsedNumber
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function generateId(input: JsonValue | undefined): string {
|
|
24
|
+
const data = normalizeIdInput(input)
|
|
25
|
+
let hash = 0
|
|
26
|
+
|
|
27
|
+
for (let index = 0; index < data.length; index++) {
|
|
28
|
+
const char = data.charCodeAt(index)
|
|
29
|
+
hash = (hash << 5) - hash + char
|
|
30
|
+
hash |= 0
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return Math.abs(hash).toString(16).padStart(8, '0')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function slugify(value: string): string {
|
|
37
|
+
const source =
|
|
38
|
+
'àáâäæãåāăąçćčđďèéêëēėęěğǵḧîïíīįìłḿñńǹňôöòóœøōõṕŕřßśšşșťțûüùúūǘůűųẃẍÿýžźż·/_,:;'
|
|
39
|
+
const replacement =
|
|
40
|
+
'aaaaaaaaaacccddeeeeeeeegghiiiiiilmnnnnooooooooprrsssssttuuuuuuuuuwxyyzzz------'
|
|
41
|
+
const specialCharacterPattern = new RegExp(source.split('').join('|'), 'g')
|
|
42
|
+
|
|
43
|
+
return value
|
|
44
|
+
.toString()
|
|
45
|
+
.toLowerCase()
|
|
46
|
+
.replace(/\s+/g, '-')
|
|
47
|
+
.replace(specialCharacterPattern, (character) =>
|
|
48
|
+
replacement.charAt(source.indexOf(character))
|
|
49
|
+
)
|
|
50
|
+
.replace(/&/g, '-and-')
|
|
51
|
+
.replace(/[^\w-]+/g, '')
|
|
52
|
+
.replace(/--+/g, '-')
|
|
53
|
+
.replace(/^-+/, '')
|
|
54
|
+
.replace(/-+$/, '')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function normalizeIdInput(input: JsonValue | undefined): string {
|
|
58
|
+
if (input === undefined) return 'undefined'
|
|
59
|
+
if (input === null) return 'null'
|
|
60
|
+
if (typeof input === 'string') {
|
|
61
|
+
return decodeURIComponent(input).split('&').sort().join('&')
|
|
62
|
+
}
|
|
63
|
+
if (typeof input === 'number' || typeof input === 'boolean') {
|
|
64
|
+
return input.toString()
|
|
65
|
+
}
|
|
66
|
+
return JSON.stringify(sortJsonValue(input))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function sortJsonValue(value: JsonValue | undefined): JsonValue | null {
|
|
70
|
+
if (value === undefined) return null
|
|
71
|
+
|
|
72
|
+
if (Array.isArray(value)) {
|
|
73
|
+
return value.map((item) => sortJsonValue(item))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (typeof value === 'object' && value !== null) {
|
|
77
|
+
return Object.fromEntries(
|
|
78
|
+
Object.entries(value)
|
|
79
|
+
.filter(([, itemValue]) => itemValue !== undefined)
|
|
80
|
+
.sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))
|
|
81
|
+
.map(([key, itemValue]) => [key, sortJsonValue(itemValue)])
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return value
|
|
86
|
+
}
|