@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/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
+ }