@oh-my-pi/pi-natives 9.4.0 → 9.6.1

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/src/html/types.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Types for HTML to Markdown worker communication.
2
+ * Types for HTML to Markdown conversion.
3
3
  */
4
4
 
5
5
  export interface HtmlToMarkdownOptions {
@@ -8,18 +8,3 @@ export interface HtmlToMarkdownOptions {
8
8
  /** Skip images during conversion */
9
9
  skipImages?: boolean;
10
10
  }
11
-
12
- export type HtmlRequest =
13
- | { type: "init"; id: number }
14
- | { type: "destroy" }
15
- | {
16
- type: "convert";
17
- id: number;
18
- html: string;
19
- options?: HtmlToMarkdownOptions;
20
- };
21
-
22
- export type HtmlResponse =
23
- | { type: "ready"; id: number }
24
- | { type: "error"; id: number; error: string }
25
- | { type: "converted"; id: number; markdown: string };
@@ -1,48 +1,55 @@
1
1
  /**
2
- * Async image processing via worker.
3
- *
4
- * All heavy lifting happens in a worker thread to avoid blocking the main thread.
5
- * Uses transferable ArrayBuffers to avoid copying image data.
2
+ * Image processing via native bindings.
6
3
  */
7
4
 
8
- import { WorkerPool } from "../pool";
9
- import { resolveWorkerSpecifier } from "../worker-resolver";
10
- import type { ImageRequest, ImageResponse } from "./types";
11
-
12
- // Re-export the enum for filter selection
13
- export { SamplingFilter } from "../../wasm/pi_natives";
14
-
15
- const pool = new WorkerPool<ImageRequest, ImageResponse>({
16
- createWorker: () =>
17
- new Worker(
18
- resolveWorkerSpecifier({
19
- compiled: "./packages/natives/src/image/worker.ts",
20
- dev: new URL("./worker.ts", import.meta.url),
21
- }),
22
- ),
23
- maxWorkers: 1,
24
- idleTimeoutMs: 0, // Keep alive - stateful (image handles)
25
- });
5
+ import { type NativePhotonImage, native } from "../native";
6
+
7
+ const images = new Map<number, NativePhotonImage>();
8
+ let nextHandle = 1;
9
+
10
+ function registerImage(image: NativePhotonImage): number {
11
+ const handle = nextHandle++;
12
+ images.set(handle, image);
13
+ return handle;
14
+ }
15
+
16
+ function getImage(handle: number): NativePhotonImage {
17
+ const image = images.get(handle);
18
+ if (!image) {
19
+ throw new Error("Image already freed");
20
+ }
21
+ return image;
22
+ }
23
+
24
+ export const SamplingFilter = native.SamplingFilter;
25
+ export type SamplingFilter = (typeof SamplingFilter)[keyof typeof SamplingFilter];
26
26
 
27
27
  /**
28
28
  * Image handle for async operations.
29
- * Must call free() when done to release WASM memory.
30
29
  */
31
30
  export class PhotonImage {
32
31
  #handle: number;
33
- #width: number;
34
- #height: number;
35
32
  #freed = false;
36
33
 
37
- private constructor(handle: number, width: number, height: number) {
34
+ private constructor(handle: number) {
38
35
  this.#handle = handle;
39
- this.#width = width;
40
- this.#height = height;
41
36
  }
42
37
 
43
38
  /** @internal */
44
- static _create(handle: number, width: number, height: number): PhotonImage {
45
- return new PhotonImage(handle, width, height);
39
+ static _create(handle: number): PhotonImage {
40
+ if (!images.has(handle)) {
41
+ throw new Error("Invalid image handle");
42
+ }
43
+ return new PhotonImage(handle);
44
+ }
45
+
46
+ /**
47
+ * Load an image from encoded bytes (PNG, JPEG, WebP, GIF).
48
+ */
49
+ static async new_from_byteslice(bytes: Uint8Array): Promise<PhotonImage> {
50
+ const image = await native.PhotonImage.newFromByteslice(bytes);
51
+ const handle = registerImage(image);
52
+ return new PhotonImage(handle);
46
53
  }
47
54
 
48
55
  /** @internal */
@@ -51,56 +58,36 @@ export class PhotonImage {
51
58
  return this.#handle;
52
59
  }
53
60
 
54
- /**
55
- * Load an image from encoded bytes (PNG, JPEG, WebP, GIF).
56
- * The bytes are transferred to the worker (zero-copy).
57
- */
58
- static async new_from_byteslice(bytes: Uint8Array): Promise<PhotonImage> {
59
- const response = await pool.request<Extract<ImageResponse, { type: "loaded" }>>(
60
- { type: "load", bytes },
61
- {
62
- transfer: [bytes.buffer],
63
- },
64
- );
65
- return new PhotonImage(response.handle, response.width, response.height);
61
+ #native(): NativePhotonImage {
62
+ if (this.#freed) throw new Error("Image already freed");
63
+ return getImage(this.#handle);
66
64
  }
67
65
 
68
66
  /** Get image width in pixels. */
69
67
  get_width(): number {
70
- return this.#width;
68
+ return this.#native().getWidth();
71
69
  }
72
70
 
73
71
  /** Get image height in pixels. */
74
72
  get_height(): number {
75
- return this.#height;
73
+ return this.#native().getHeight();
76
74
  }
77
75
 
78
76
  /** Export as PNG bytes. */
79
77
  async get_bytes(): Promise<Uint8Array> {
80
- if (this.#freed) throw new Error("Image already freed");
81
- const response = await pool.request<Extract<ImageResponse, { type: "bytes" }>>({
82
- type: "get_png",
83
- handle: this.#handle,
84
- });
85
- return response.bytes;
78
+ return this.#native().getBytes();
86
79
  }
87
80
 
88
81
  /** Export as JPEG bytes with specified quality (0-100). */
89
82
  async get_bytes_jpeg(quality: number): Promise<Uint8Array> {
90
- if (this.#freed) throw new Error("Image already freed");
91
- const response = await pool.request<Extract<ImageResponse, { type: "bytes" }>>({
92
- type: "get_jpeg",
93
- handle: this.#handle,
94
- quality,
95
- });
96
- return response.bytes;
83
+ return this.#native().getBytesJpeg(quality);
97
84
  }
98
85
 
99
- /** Release WASM memory. Must be called when done with the image. */
86
+ /** Release native resources. */
100
87
  free() {
101
88
  if (this.#freed) return;
102
89
  this.#freed = true;
103
- pool.request({ type: "free", handle: this.#handle }).catch(() => {});
90
+ images.delete(this.#handle);
104
91
  }
105
92
 
106
93
  /** Alias for free() to support using-declarations. */
@@ -114,21 +101,13 @@ export class PhotonImage {
114
101
  * Returns a new PhotonImage (original is not modified).
115
102
  */
116
103
  export async function resize(image: PhotonImage, width: number, height: number, filter: number): Promise<PhotonImage> {
117
- const handle = image._getHandle();
118
- const response = await pool.request<Extract<ImageResponse, { type: "resized" }>>({
119
- type: "resize",
120
- handle,
121
- width,
122
- height,
123
- filter,
124
- });
125
- return PhotonImage._create(response.handle, response.width, response.height);
104
+ const nativeImage = getImage(image._getHandle());
105
+ const resized = await nativeImage.resize(width, height, filter);
106
+ const handle = registerImage(resized);
107
+ return PhotonImage._create(handle);
126
108
  }
127
109
 
128
110
  /**
129
- * Terminate the image worker.
130
- * Call this when shutting down to clean up resources.
111
+ * Terminate image resources (no-op for native bindings).
131
112
  */
132
- export function terminate(): void {
133
- pool.terminate();
134
- }
113
+ export function terminate(): void {}
package/src/index.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  /**
2
- * Native utilities powered by WASM.
2
+ * Native utilities powered by N-API.
3
3
  */
4
4
 
5
- import * as fs from "node:fs/promises";
6
5
  import * as path from "node:path";
7
- import { globPaths } from "@oh-my-pi/pi-utils";
6
+ import type { FindMatch, FindOptions, FindResult } from "./find/types";
7
+ import { native } from "./native";
8
8
 
9
9
  // =============================================================================
10
10
  // Grep (ripgrep-based regex search)
@@ -12,52 +12,24 @@ import { globPaths } from "@oh-my-pi/pi-utils";
12
12
 
13
13
  export {
14
14
  type ContextLine,
15
+ type FuzzyFindMatch,
16
+ type FuzzyFindOptions,
17
+ type FuzzyFindResult,
18
+ fuzzyFind,
15
19
  type GrepMatch,
16
20
  type GrepOptions,
17
21
  type GrepResult,
18
22
  type GrepSummary,
19
23
  grep,
20
- grepDirect,
21
- grepPool,
22
24
  hasMatch,
23
25
  searchContent,
24
- terminate,
25
26
  } from "./grep/index";
26
27
 
27
- // =============================================================================
28
- // WASI implementation
29
- // =============================================================================
30
-
31
- export { WASI1, WASIError, WASIExitError, type WASIOptions } from "./wasix";
32
-
33
28
  // =============================================================================
34
29
  // Find (file discovery)
35
30
  // =============================================================================
36
31
 
37
- export interface FindOptions {
38
- /** Glob pattern to match (e.g., `*.ts`) */
39
- pattern: string;
40
- /** Directory to search */
41
- path: string;
42
- /** Filter by file type: "file", "dir", or "symlink" */
43
- fileType?: "file" | "dir" | "symlink";
44
- /** Include hidden files (default: false) */
45
- hidden?: boolean;
46
- /** Maximum number of results */
47
- maxResults?: number;
48
- /** Respect .gitignore files (default: true) */
49
- gitignore?: boolean;
50
- }
51
-
52
- export interface FindMatch {
53
- path: string;
54
- fileType: "file" | "dir" | "symlink";
55
- }
56
-
57
- export interface FindResult {
58
- matches: FindMatch[];
59
- totalMatches: number;
60
- }
32
+ export type { FindMatch, FindOptions, FindResult } from "./find/types";
61
33
 
62
34
  /**
63
35
  * Find files matching a glob pattern.
@@ -70,59 +42,19 @@ export async function find(options: FindOptions, onMatch?: (match: FindMatch) =>
70
42
  // Convert simple patterns to recursive globs if needed
71
43
  const globPattern = pattern.includes("/") || pattern.startsWith("**") ? pattern : `**/${pattern}`;
72
44
 
73
- const paths = await globPaths(globPattern, {
74
- cwd: searchPath,
75
- dot: options.hidden ?? false,
76
- onlyFiles: options.fileType === "file",
77
- gitignore: options.gitignore ?? true,
78
- });
79
-
80
- const matches: FindMatch[] = [];
81
- const maxResults = options.maxResults ?? Number.MAX_SAFE_INTEGER;
82
-
83
- for (const p of paths) {
84
- if (matches.length >= maxResults) {
85
- break;
86
- }
87
-
88
- const normalizedPath = p.replace(/\\/g, "/");
89
- if (!normalizedPath) {
90
- continue;
91
- }
92
-
93
- let stats: Awaited<ReturnType<typeof fs.lstat>>;
94
- try {
95
- stats = await fs.lstat(path.join(searchPath, normalizedPath));
96
- } catch {
97
- continue;
98
- }
99
-
100
- const fileType: "file" | "dir" | "symlink" = stats.isSymbolicLink()
101
- ? "symlink"
102
- : stats.isDirectory()
103
- ? "dir"
104
- : "file";
105
-
106
- if (options.fileType && options.fileType !== fileType) {
107
- continue;
108
- }
109
-
110
- const match: FindMatch = {
111
- path: normalizedPath,
112
- fileType,
113
- };
114
-
115
- matches.push(match);
116
- onMatch?.(match);
117
- }
118
-
119
- return {
120
- matches,
121
- totalMatches: matches.length,
122
- };
45
+ return native.find(
46
+ {
47
+ ...options,
48
+ path: searchPath,
49
+ pattern: globPattern,
50
+ hidden: options.hidden ?? false,
51
+ gitignore: options.gitignore ?? true,
52
+ },
53
+ onMatch,
54
+ );
123
55
  }
124
56
 
125
- // =============================================================================
57
+ // ===================================================== ========================
126
58
  // Image processing (photon-compatible API)
127
59
  // =============================================================================
128
60
 
@@ -143,7 +75,6 @@ export {
143
75
  type SliceWithWidthResult,
144
76
  sliceWithWidth,
145
77
  truncateToWidth,
146
- visibleWidth,
147
78
  } from "./text/index";
148
79
 
149
80
  // =============================================================================
@@ -157,6 +88,12 @@ export {
157
88
  supportsLanguage,
158
89
  } from "./highlight/index";
159
90
 
91
+ // =============================================================================
92
+ // Keyboard sequence helpers
93
+ // =============================================================================
94
+
95
+ export { matchesKittySequence } from "./keys/index";
96
+
160
97
  // =============================================================================
161
98
  // HTML to Markdown
162
99
  // =============================================================================
@@ -164,11 +101,6 @@ export {
164
101
  export {
165
102
  type HtmlToMarkdownOptions,
166
103
  htmlToMarkdown,
167
- terminate as terminateHtmlWorker,
168
104
  } from "./html/index";
169
105
 
170
- // =============================================================================
171
- // Worker Pool (shared infrastructure)
172
- // =============================================================================
173
-
174
- export { type BaseRequest, type BaseResponse, type RequestOptions, WorkerPool, type WorkerPoolOptions } from "./pool";
106
+ export type { RequestOptions } from "./request-options";
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Keyboard sequence utilities powered by native bindings.
3
+ */
4
+
5
+ import { native } from "../native";
6
+
7
+ /** Match Kitty protocol sequences for codepoint and modifier. */
8
+ export function matchesKittySequence(data: string, expectedCodepoint: number, expectedModifier: number): boolean {
9
+ return native.matchesKittySequence(data, expectedCodepoint, expectedModifier);
10
+ }
package/src/native.ts ADDED
@@ -0,0 +1,159 @@
1
+ import { createRequire } from "node:module";
2
+ import * as path from "node:path";
3
+ import type { FindMatch, FindOptions, FindResult } from "./find/types";
4
+ import type {
5
+ FuzzyFindOptions,
6
+ FuzzyFindResult,
7
+ GrepOptions,
8
+ GrepResult,
9
+ SearchOptions,
10
+ SearchResult,
11
+ } from "./grep/types";
12
+ import type { HighlightColors } from "./highlight/index";
13
+ import type { HtmlToMarkdownOptions } from "./html/types";
14
+ import type { ExtractSegmentsResult, SliceWithWidthResult, TextInput } from "./text/index";
15
+
16
+ export interface NativePhotonImage {
17
+ getWidth(): number;
18
+ getHeight(): number;
19
+ getBytes(): Promise<Uint8Array>;
20
+ getBytesJpeg(quality: number): Promise<Uint8Array>;
21
+ getBytesWebp(): Promise<Uint8Array>;
22
+ getBytesGif(): Promise<Uint8Array>;
23
+ resize(width: number, height: number, filter: number): Promise<NativePhotonImage>;
24
+ }
25
+
26
+ export interface NativePhotonImageConstructor {
27
+ newFromByteslice(bytes: Uint8Array): Promise<NativePhotonImage>;
28
+ prototype: NativePhotonImage;
29
+ }
30
+
31
+ export interface NativeSamplingFilter {
32
+ Nearest: 1;
33
+ Triangle: 2;
34
+ CatmullRom: 3;
35
+ Gaussian: 4;
36
+ Lanczos3: 5;
37
+ }
38
+
39
+ import type { GrepMatch } from "./grep/types";
40
+
41
+ export interface NativeBindings {
42
+ find(options: FindOptions, onMatch?: (match: FindMatch) => void): Promise<FindResult>;
43
+ fuzzyFind(options: FuzzyFindOptions): Promise<FuzzyFindResult>;
44
+ grep(options: GrepOptions, onMatch?: (match: GrepMatch) => void): Promise<GrepResult>;
45
+ search(content: string | Uint8Array, options: SearchOptions): SearchResult;
46
+ hasMatch(
47
+ content: string | Uint8Array,
48
+ pattern: string | Uint8Array,
49
+ ignoreCase: boolean,
50
+ multiline: boolean,
51
+ ): boolean;
52
+ htmlToMarkdown(html: string, options?: HtmlToMarkdownOptions | null): Promise<string>;
53
+ highlightCode(code: string, lang: string | null | undefined, colors: HighlightColors): string;
54
+ supportsLanguage(lang: string): boolean;
55
+ getSupportedLanguages(): string[];
56
+ SamplingFilter: NativeSamplingFilter;
57
+ PhotonImage: NativePhotonImageConstructor;
58
+ truncateToWidth(text: TextInput, maxWidth: number, ellipsis: TextInput, pad: boolean): string;
59
+ sliceWithWidth(line: TextInput, startCol: number, length: number, strict: boolean): SliceWithWidthResult;
60
+ extractSegments(
61
+ line: TextInput,
62
+ beforeEnd: number,
63
+ afterStart: number,
64
+ afterLen: number,
65
+ strictAfter: boolean,
66
+ ): ExtractSegmentsResult;
67
+ matchesKittySequence(data: string, expectedCodepoint: number, expectedModifier: number): boolean;
68
+ }
69
+
70
+ const require = createRequire(import.meta.url);
71
+ const platformTag = `${process.platform}-${process.arch}`;
72
+ const nativeDir = path.join(import.meta.dir, "..", "native");
73
+ const repoRoot = path.join(import.meta.dir, "..", "..", "..");
74
+ const execDir = path.dirname(process.execPath);
75
+
76
+ const SUPPORTED_PLATFORMS = ["linux-x64", "linux-arm64", "darwin-x64", "darwin-arm64", "win32-x64"];
77
+
78
+ const candidates = [
79
+ path.join(nativeDir, `pi_natives.${platformTag}.node`),
80
+ path.join(nativeDir, "pi_natives.node"),
81
+ path.join(execDir, `pi_natives.${platformTag}.node`),
82
+ path.join(execDir, "pi_natives.node"),
83
+ path.join(repoRoot, "target", "release", "pi_natives.node"),
84
+ path.join(repoRoot, "crates", "pi-natives", "target", "release", "pi_natives.node"),
85
+ ];
86
+
87
+ function loadNative(): NativeBindings {
88
+ const errors: string[] = [];
89
+
90
+ for (const candidate of candidates) {
91
+ try {
92
+ const bindings = require(candidate) as NativeBindings;
93
+ validateNative(bindings, candidate);
94
+ return bindings;
95
+ } catch (err) {
96
+ const message = err instanceof Error ? err.message : String(err);
97
+ errors.push(`${candidate}: ${message}`);
98
+ }
99
+ }
100
+
101
+ // Check if this is an unsupported platform
102
+ if (!SUPPORTED_PLATFORMS.includes(platformTag)) {
103
+ throw new Error(
104
+ `Unsupported platform: ${platformTag}\n` +
105
+ `Supported platforms: ${SUPPORTED_PLATFORMS.join(", ")}\n` +
106
+ "If you need support for this platform, please open an issue.",
107
+ );
108
+ }
109
+
110
+ const details = errors.map(error => `- ${error}`).join("\n");
111
+ throw new Error(
112
+ `Failed to load pi_natives native addon for ${platformTag}.\n\n` +
113
+ `Tried:\n${details}\n\n` +
114
+ "If installed via npm/bun, try reinstalling: bun install @oh-my-pi/pi-natives\n" +
115
+ "If developing locally, build with: bun --cwd=packages/natives run build:native",
116
+ );
117
+ }
118
+
119
+ function validateNative(bindings: NativeBindings, source: string): void {
120
+ const missing: string[] = [];
121
+ const checkFn = (name: keyof NativeBindings) => {
122
+ if (typeof bindings[name] !== "function") {
123
+ missing.push(name);
124
+ }
125
+ };
126
+
127
+ checkFn("find");
128
+ checkFn("fuzzyFind");
129
+ checkFn("grep");
130
+ checkFn("search");
131
+ checkFn("hasMatch");
132
+ checkFn("htmlToMarkdown");
133
+ checkFn("highlightCode");
134
+ checkFn("supportsLanguage");
135
+ checkFn("getSupportedLanguages");
136
+ checkFn("truncateToWidth");
137
+ checkFn("sliceWithWidth");
138
+ checkFn("extractSegments");
139
+ checkFn("matchesKittySequence");
140
+
141
+ if (!bindings.PhotonImage?.newFromByteslice) {
142
+ missing.push("PhotonImage.newFromByteslice");
143
+ }
144
+ if (!bindings.PhotonImage?.prototype?.resize) {
145
+ missing.push("PhotonImage.resize");
146
+ }
147
+ if (!bindings.SamplingFilter || typeof bindings.SamplingFilter.Lanczos3 !== "number") {
148
+ missing.push("SamplingFilter");
149
+ }
150
+
151
+ if (missing.length) {
152
+ throw new Error(
153
+ `Native addon missing exports (${source}). Missing: ${missing.join(", ")}. ` +
154
+ "Rebuild with `bun --cwd=packages/natives run build:native`.",
155
+ );
156
+ }
157
+ }
158
+
159
+ export const native = loadNative();
@@ -0,0 +1,94 @@
1
+ export interface RequestOptions {
2
+ timeoutMs?: number;
3
+ signal?: AbortSignal;
4
+ }
5
+
6
+ function abortReason(signal?: AbortSignal): Error {
7
+ if (!signal || !signal.reason) return new Error("Request aborted");
8
+ if (signal.reason instanceof Error) return signal.reason;
9
+ return new Error("Request aborted", { cause: signal.reason });
10
+ }
11
+ export async function wrapRequestOptions<T>(fn: () => Promise<T>, options?: RequestOptions): Promise<T> {
12
+ const timeoutMs = options?.timeoutMs ?? 0;
13
+ const signal = options?.signal;
14
+
15
+ // Fast path: no timeout + no signal
16
+ if (!signal && timeoutMs <= 0) return fn();
17
+
18
+ // If already aborted, fail immediately
19
+ if (signal?.aborted) throw abortReason(signal);
20
+
21
+ // If we only have an abort signal and no timeout, keep it simple.
22
+ if (signal && timeoutMs <= 0) {
23
+ return withAbortSignal(fn, signal);
24
+ }
25
+
26
+ return withTimeoutAndOptionalAbort(fn, timeoutMs, signal);
27
+ }
28
+
29
+ function withAbortSignal<T>(fn: () => Promise<T>, signal: AbortSignal): Promise<T> {
30
+ return new Promise<T>((resolve, reject) => {
31
+ const onAbort = () => {
32
+ signal.removeEventListener("abort", onAbort);
33
+ reject(abortReason(signal));
34
+ };
35
+
36
+ signal.addEventListener("abort", onAbort, { once: true });
37
+
38
+ // If it races and aborts right after addEventListener, `once` handles it,
39
+ // but we still want to short-circuit.
40
+ if (signal.aborted) return onAbort();
41
+
42
+ fn().then(
43
+ v => {
44
+ signal.removeEventListener("abort", onAbort);
45
+ resolve(v);
46
+ },
47
+ err => {
48
+ signal.removeEventListener("abort", onAbort);
49
+ reject(err);
50
+ },
51
+ );
52
+ });
53
+ }
54
+
55
+ function withTimeoutAndOptionalAbort<T>(fn: () => Promise<T>, timeoutMs: number, signal?: AbortSignal): Promise<T> {
56
+ return new Promise<T>((resolve, reject) => {
57
+ let settled = false;
58
+
59
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
60
+ const cleanup = () => {
61
+ if (timeoutId) {
62
+ clearTimeout(timeoutId);
63
+ timeoutId = undefined;
64
+ }
65
+ if (signal) signal.removeEventListener("abort", onAbort);
66
+ };
67
+
68
+ const settle = (ok: boolean, value: any) => {
69
+ if (settled) return;
70
+ settled = true;
71
+ cleanup();
72
+ ok ? resolve(value as T) : reject(value);
73
+ };
74
+
75
+ const onAbort = () => settle(false, abortReason(signal!));
76
+
77
+ // timeout
78
+ timeoutId = setTimeout(() => {
79
+ settle(false, new Error(`Request timed out after ${timeoutMs}ms`));
80
+ }, timeoutMs);
81
+
82
+ // abort (optional)
83
+ if (signal) {
84
+ signal.addEventListener("abort", onAbort, { once: true });
85
+ if (signal.aborted) return onAbort();
86
+ }
87
+
88
+ // run
89
+ fn().then(
90
+ v => settle(true, v),
91
+ err => settle(false, err),
92
+ );
93
+ });
94
+ }