@gukhanmun/napi 0.1.0-dev.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/LICENSE +674 -0
- package/dist/index.d.ts +574 -0
- package/dist/index.js +272 -0
- package/package.json +60 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
4
|
+
//#region index.ts
|
|
5
|
+
/**
|
|
6
|
+
* @module
|
|
7
|
+
*
|
|
8
|
+
* Node.js native addon (napi-rs) implementation of the Gukhanmun
|
|
9
|
+
* hanja-to-hangul converter.
|
|
10
|
+
*
|
|
11
|
+
* Provides the same `{@link load}` / `{@link Gukhanmun}` contract as
|
|
12
|
+
* `@gukhanmun/wasm` but uses a precompiled native addon for maximum
|
|
13
|
+
* throughput. Node.js 20+ is required. The native addon binary
|
|
14
|
+
* (`gukhanmun_napi.node`) must be present in the package directory; build it
|
|
15
|
+
* locally with `mise run napi-build`.
|
|
16
|
+
*
|
|
17
|
+
* The `load()` factory is asynchronous for API uniformity with the WASM
|
|
18
|
+
* backend, but the native addon is synchronously ready — dictionary data is
|
|
19
|
+
* the only async part.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```ts
|
|
23
|
+
* import { load } from "@gukhanmun/napi";
|
|
24
|
+
* import { stdictFst } from "@gukhanmun/stdict-fst";
|
|
25
|
+
*
|
|
26
|
+
* const g = await load({ dictionaries: [await stdictFst()] });
|
|
27
|
+
* console.log(g.convert("漢字를 한글로")); // "한자를 한글로"
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
/** Returns the platform-specific optional-dependency package name. */
|
|
31
|
+
function detectPlatformPackage() {
|
|
32
|
+
const map = {
|
|
33
|
+
"darwin-arm64": "aarch64-apple-darwin",
|
|
34
|
+
"darwin-x64": "x86_64-apple-darwin",
|
|
35
|
+
"win32-arm64": "aarch64-pc-windows-msvc",
|
|
36
|
+
"win32-x64": "x86_64-pc-windows-msvc",
|
|
37
|
+
"linux-arm64": "aarch64-unknown-linux-musl",
|
|
38
|
+
"linux-x64": "x86_64-unknown-linux-musl"
|
|
39
|
+
};
|
|
40
|
+
const key = `${process.platform}-${process.arch}`;
|
|
41
|
+
const target = map[key];
|
|
42
|
+
if (!target) throw new Error(`No prebuilt @gukhanmun/napi binary for ${key}`);
|
|
43
|
+
return `@gukhanmun/napi-${target}`;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Loads the native addon at module initialisation time.
|
|
47
|
+
*
|
|
48
|
+
* When installed from npm the platform-specific optional dependency is tried
|
|
49
|
+
* first. Falls back to a locally-built binary (produced by
|
|
50
|
+
* `mise run napi-build`) for development.
|
|
51
|
+
*/
|
|
52
|
+
const nativeAddon = (() => {
|
|
53
|
+
const req = createRequire(import.meta.url);
|
|
54
|
+
try {
|
|
55
|
+
return req(detectPlatformPackage());
|
|
56
|
+
} catch {
|
|
57
|
+
try {
|
|
58
|
+
return req("./gukhanmun_napi.node");
|
|
59
|
+
} catch {
|
|
60
|
+
return req("../gukhanmun_napi.node");
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
})();
|
|
64
|
+
/**
|
|
65
|
+
* Error thrown by `{@link load}`, `{@link Gukhanmun.convert}`, and
|
|
66
|
+
* `{@link Gukhanmun.stream}` when the Rust engine reports a failure.
|
|
67
|
+
*
|
|
68
|
+
* `code` identifies the failure class; `chain` carries the full causal chain
|
|
69
|
+
* materialised at the FFI boundary so callers do not need additional round
|
|
70
|
+
* trips.
|
|
71
|
+
*/
|
|
72
|
+
var GukhanmunError = class extends Error {
|
|
73
|
+
/**
|
|
74
|
+
* Machine-readable error code.
|
|
75
|
+
*
|
|
76
|
+
* @see {@link ErrorCode}
|
|
77
|
+
*/
|
|
78
|
+
code;
|
|
79
|
+
/**
|
|
80
|
+
* Full causal chain from the Rust `Error::source()` traversal, materialised
|
|
81
|
+
* at the FFI boundary. The first element is the root cause; the last is
|
|
82
|
+
* the immediate error.
|
|
83
|
+
*/
|
|
84
|
+
chain;
|
|
85
|
+
/**
|
|
86
|
+
* Creates a new `GukhanmunError`.
|
|
87
|
+
*
|
|
88
|
+
* @param code - Machine-readable error code.
|
|
89
|
+
* @param message - Human-readable description.
|
|
90
|
+
* @param chain - Optional causal chain.
|
|
91
|
+
*/
|
|
92
|
+
constructor(code, message, chain = []) {
|
|
93
|
+
super(message);
|
|
94
|
+
this.name = "GukhanmunError";
|
|
95
|
+
this.code = code;
|
|
96
|
+
this.chain = chain;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
/**
|
|
100
|
+
* Converts a raw error thrown at the NAPI boundary into a `GukhanmunError`.
|
|
101
|
+
*
|
|
102
|
+
* napi-rs encodes structured errors as JSON in the `message` field:
|
|
103
|
+
* `{"code":"…","message":"…","chain":[…]}`.
|
|
104
|
+
*/
|
|
105
|
+
function liftNapiError(raw) {
|
|
106
|
+
if (raw instanceof GukhanmunError) return raw;
|
|
107
|
+
if (raw instanceof Error) try {
|
|
108
|
+
const parsed = JSON.parse(raw.message);
|
|
109
|
+
if (typeof parsed.code === "string") return new GukhanmunError(parsed.code, typeof parsed.message === "string" ? parsed.message : raw.message, Array.isArray(parsed.chain) ? parsed.chain : []);
|
|
110
|
+
} catch {}
|
|
111
|
+
return new GukhanmunError("internal", String(raw));
|
|
112
|
+
}
|
|
113
|
+
async function resolveDictionary(source) {
|
|
114
|
+
let bytes;
|
|
115
|
+
if (source.data instanceof ArrayBuffer) bytes = Buffer.from(source.data);
|
|
116
|
+
else if (ArrayBuffer.isView(source.data)) {
|
|
117
|
+
const view = source.data;
|
|
118
|
+
bytes = Buffer.from(view.buffer, view.byteOffset, view.byteLength);
|
|
119
|
+
} else {
|
|
120
|
+
let url;
|
|
121
|
+
if (source.data instanceof URL) url = source.data;
|
|
122
|
+
else {
|
|
123
|
+
const str = String(source.data);
|
|
124
|
+
url = str.includes("://") ? new URL(str) : pathToFileURL(str);
|
|
125
|
+
}
|
|
126
|
+
if (url.protocol === "file:") try {
|
|
127
|
+
bytes = await readFile(fileURLToPath(url));
|
|
128
|
+
} catch (e) {
|
|
129
|
+
throw new GukhanmunError("dictionary-load", `Failed to read dictionary: ${e instanceof Error ? e.message : String(e)}`);
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
let response;
|
|
133
|
+
try {
|
|
134
|
+
response = await fetch(url);
|
|
135
|
+
} catch (e) {
|
|
136
|
+
throw new GukhanmunError("dictionary-load", `Failed to fetch dictionary: ${e instanceof Error ? e.message : String(e)}`);
|
|
137
|
+
}
|
|
138
|
+
if (!response.ok) throw new GukhanmunError("dictionary-load", `Failed to fetch dictionary: HTTP ${response.status}`);
|
|
139
|
+
bytes = Buffer.from(await response.arrayBuffer());
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
format: source.format,
|
|
144
|
+
bytes
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
function resolveOptions(opts = {}) {
|
|
148
|
+
const preset = opts.preset ?? "ko-kr";
|
|
149
|
+
const koKp = preset === "ko-kp";
|
|
150
|
+
return {
|
|
151
|
+
preset,
|
|
152
|
+
rendering: opts.rendering ?? "hangul-only",
|
|
153
|
+
segmentation: opts.segmentation ?? "lattice",
|
|
154
|
+
numerals: opts.numerals ?? "hangul-phonetic",
|
|
155
|
+
initialSoundLaw: opts.initialSoundLaw ?? (koKp ? false : true),
|
|
156
|
+
homophoneWindow: opts.homophoneWindow ?? (koKp ? "off" : "per-block"),
|
|
157
|
+
firstOccurrenceWindow: opts.firstOccurrenceWindow ?? "off",
|
|
158
|
+
recovery: opts.recovery ?? "strict"
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
function buildRawOptions(opts, resolved) {
|
|
162
|
+
const raw = {
|
|
163
|
+
preset: resolved.preset,
|
|
164
|
+
rendering: resolved.rendering,
|
|
165
|
+
segmentation: resolved.segmentation,
|
|
166
|
+
numerals: resolved.numerals,
|
|
167
|
+
initialSoundLaw: resolved.initialSoundLaw,
|
|
168
|
+
homophoneWindow: resolved.homophoneWindow,
|
|
169
|
+
firstOccurrenceWindow: resolved.firstOccurrenceWindow,
|
|
170
|
+
recovery: resolved.recovery
|
|
171
|
+
};
|
|
172
|
+
if (resolved.rendering === "original" && opts.originalGloss != null) raw["originalGloss"] = opts.originalGloss;
|
|
173
|
+
if (opts.directives != null) raw["directives"] = {
|
|
174
|
+
requireHanja: opts.directives.requireHanja ?? [],
|
|
175
|
+
requireHangul: opts.directives.requireHangul ?? [],
|
|
176
|
+
skipAnnotation: opts.directives.skipAnnotation ?? []
|
|
177
|
+
};
|
|
178
|
+
if (opts.html != null) raw["html"] = {
|
|
179
|
+
preserveClasses: opts.html.preserveClasses ?? [],
|
|
180
|
+
preserveAttributes: opts.html.preserveAttributes ?? []
|
|
181
|
+
};
|
|
182
|
+
return raw;
|
|
183
|
+
}
|
|
184
|
+
/** Internal implementation of the `Gukhanmun` contract backed by the native addon. */
|
|
185
|
+
var GukhanmunImpl = class {
|
|
186
|
+
#handle;
|
|
187
|
+
options;
|
|
188
|
+
constructor(handle, resolvedOpts) {
|
|
189
|
+
this.#handle = handle;
|
|
190
|
+
this.options = resolvedOpts;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Converts `source` in one shot.
|
|
194
|
+
*
|
|
195
|
+
* @param source - Input string.
|
|
196
|
+
* @param format - `"text"` (default), `"html"`, `"markdown"`, or
|
|
197
|
+
* `{ format: "markdown"; gfm?: boolean }`.
|
|
198
|
+
* @returns Converted string.
|
|
199
|
+
* @throws {@link GukhanmunError} on conversion failure.
|
|
200
|
+
*/
|
|
201
|
+
convert(source, format) {
|
|
202
|
+
try {
|
|
203
|
+
return this.#handle.convert(source, format != null ? JSON.stringify(format) : null);
|
|
204
|
+
} catch (e) {
|
|
205
|
+
throw liftNapiError(e);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Returns a `TransformStream` that converts string chunks.
|
|
210
|
+
*
|
|
211
|
+
* Chunks are buffered internally; all output is produced on `flush`.
|
|
212
|
+
* This satisfies batch-equivalence: the output of any chunk partition equals
|
|
213
|
+
* the output of `{@link convert}` on the concatenated input.
|
|
214
|
+
*
|
|
215
|
+
* @param format - Same as {@link convert}.
|
|
216
|
+
* @returns A `TransformStream<string, string>`.
|
|
217
|
+
*/
|
|
218
|
+
stream(format) {
|
|
219
|
+
const formatJson = format != null ? JSON.stringify(format) : null;
|
|
220
|
+
let streamHandle;
|
|
221
|
+
try {
|
|
222
|
+
streamHandle = this.#handle.openStream(formatJson);
|
|
223
|
+
} catch (e) {
|
|
224
|
+
throw liftNapiError(e);
|
|
225
|
+
}
|
|
226
|
+
const handle = this.#handle;
|
|
227
|
+
return new TransformStream({
|
|
228
|
+
transform(chunk, controller) {
|
|
229
|
+
const out = handle.streamPush(streamHandle, chunk);
|
|
230
|
+
if (out) controller.enqueue(out);
|
|
231
|
+
},
|
|
232
|
+
flush(controller) {
|
|
233
|
+
try {
|
|
234
|
+
const out = handle.streamFinish(streamHandle);
|
|
235
|
+
if (out) controller.enqueue(out);
|
|
236
|
+
} catch (e) {
|
|
237
|
+
throw liftNapiError(e);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
/**
|
|
244
|
+
* Creates a Gukhanmun converter with the given options.
|
|
245
|
+
*
|
|
246
|
+
* The native addon is synchronously ready; dictionaries supplied via
|
|
247
|
+
* `{@link GukhanmunOptions.dictionaries}` are fetched or read from disk and
|
|
248
|
+
* passed to the Rust engine as `FileDictionarySource` values.
|
|
249
|
+
*
|
|
250
|
+
* Note: unlike the Rust `ko-kr` preset, the JavaScript preset never includes a
|
|
251
|
+
* bundled dictionary. Pass `dictionaries: [await stdictFst()]` to include the
|
|
252
|
+
* Standard Korean Language Dictionary.
|
|
253
|
+
*
|
|
254
|
+
* @param options - Conversion options. All fields are optional; defaults match
|
|
255
|
+
* the `ko-kr` preset.
|
|
256
|
+
* @returns A `{@link Gukhanmun}` instance.
|
|
257
|
+
* @throws {@link GukhanmunError} on invalid options or dictionary load failure.
|
|
258
|
+
*/
|
|
259
|
+
async function load(options = {}) {
|
|
260
|
+
const resolved = resolveOptions(options);
|
|
261
|
+
const dicts = await Promise.all((options.dictionaries ?? []).map(resolveDictionary));
|
|
262
|
+
const optionsJson = JSON.stringify(buildRawOptions(options, resolved));
|
|
263
|
+
let handle;
|
|
264
|
+
try {
|
|
265
|
+
handle = nativeAddon.NapiGukhanmun.load(optionsJson, dicts);
|
|
266
|
+
} catch (e) {
|
|
267
|
+
throw liftNapiError(e);
|
|
268
|
+
}
|
|
269
|
+
return new GukhanmunImpl(handle, resolved);
|
|
270
|
+
}
|
|
271
|
+
//#endregion
|
|
272
|
+
export { GukhanmunError, load };
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gukhanmun/napi",
|
|
3
|
+
"version": "0.1.0-dev.0",
|
|
4
|
+
"description": "Node.js native addon (napi-rs) for the Gukhanmun hanja-to-hangul converter.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"import": "./dist/index.js",
|
|
10
|
+
"default": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist"
|
|
15
|
+
],
|
|
16
|
+
"optionalDependencies": {
|
|
17
|
+
"@gukhanmun/napi-aarch64-apple-darwin": "0.1.0-dev.0",
|
|
18
|
+
"@gukhanmun/napi-x86_64-apple-darwin": "0.1.0-dev.0",
|
|
19
|
+
"@gukhanmun/napi-aarch64-pc-windows-msvc": "0.1.0-dev.0",
|
|
20
|
+
"@gukhanmun/napi-x86_64-pc-windows-msvc": "0.1.0-dev.0",
|
|
21
|
+
"@gukhanmun/napi-aarch64-unknown-linux-musl": "0.1.0-dev.0",
|
|
22
|
+
"@gukhanmun/napi-x86_64-unknown-linux-musl": "0.1.0-dev.0"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://github.com/dahlia/gukhanmun",
|
|
25
|
+
"bugs": "https://github.com/dahlia/gukhanmun/issues",
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/dahlia/gukhanmun.git",
|
|
29
|
+
"directory": "packages/napi"
|
|
30
|
+
},
|
|
31
|
+
"license": "GPL-3.0-only",
|
|
32
|
+
"author": {
|
|
33
|
+
"name": "Hong Minhe (洪 民憙)",
|
|
34
|
+
"email": "hong@minhee.org",
|
|
35
|
+
"url": "https://hongminhee.org/"
|
|
36
|
+
},
|
|
37
|
+
"funding": "https://github.com/sponsors/dahlia",
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=20",
|
|
40
|
+
"bun": ">=1.0",
|
|
41
|
+
"deno": ">=2.0"
|
|
42
|
+
},
|
|
43
|
+
"sideEffects": false,
|
|
44
|
+
"keywords": [
|
|
45
|
+
"korean",
|
|
46
|
+
"hanja",
|
|
47
|
+
"hangul",
|
|
48
|
+
"typography",
|
|
49
|
+
"napi"
|
|
50
|
+
],
|
|
51
|
+
"peerDependencies": {
|
|
52
|
+
"@gukhanmun/types": "*"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@gukhanmun/types": "0.1.0-dev.0"
|
|
56
|
+
},
|
|
57
|
+
"scripts": {
|
|
58
|
+
"build": "tsdown"
|
|
59
|
+
}
|
|
60
|
+
}
|