quill-matter 0.1.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 +21 -0
- package/README.md +175 -0
- package/cli/index.ts +214 -0
- package/package.json +90 -0
- package/src/detector.ts +53 -0
- package/src/excerpt.ts +63 -0
- package/src/extractor.ts +120 -0
- package/src/index.ts +510 -0
- package/src/parsers.ts +73 -0
- package/src/sanitizer.ts +50 -0
- package/src/stringify.ts +151 -0
- package/src/types.ts +166 -0
- package/src/validator.ts +28 -0
- package/src/wasm-loader.ts +166 -0
package/src/sanitizer.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sanitize parsed objects by stripping keys that could trigger
|
|
3
|
+
* **prototype pollution** when the consumer merges or spreads the data
|
|
4
|
+
* into other objects.
|
|
5
|
+
*
|
|
6
|
+
* Targets: `__proto__`, `prototype`.
|
|
7
|
+
*
|
|
8
|
+
* Note: `constructor` is intentionally NOT stripped because it is a
|
|
9
|
+
* legitimate data key (e.g. `constructor: "Builder Pattern"`). The
|
|
10
|
+
* `constructor.prototype.x` attack chain is already neutralised by
|
|
11
|
+
* stripping `prototype`.
|
|
12
|
+
*
|
|
13
|
+
* @param obj - The parsed value (object, array, or primitive).
|
|
14
|
+
* @returns A deep clone with dangerous keys removed.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const DANGEROUS_KEYS = new Set(["__proto__", "prototype"]);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Check if an object tree contains any dangerous keys.
|
|
21
|
+
* Returns `true` if no sanitization is needed (fast path).
|
|
22
|
+
*/
|
|
23
|
+
function isSafe(obj: unknown): boolean {
|
|
24
|
+
if (obj === null || typeof obj !== "object") return true;
|
|
25
|
+
if (Array.isArray(obj)) return obj.every(isSafe);
|
|
26
|
+
for (const key of Object.keys(obj as Record<string, unknown>)) {
|
|
27
|
+
if (DANGEROUS_KEYS.has(key)) return false;
|
|
28
|
+
}
|
|
29
|
+
return Object.values(obj as Record<string, unknown>).every(isSafe);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function sanitizeKeys(obj: unknown): unknown {
|
|
33
|
+
if (obj === null || typeof obj !== "object") return obj;
|
|
34
|
+
// Fast path: skip deep clone if no dangerous keys exist.
|
|
35
|
+
if (isSafe(obj)) return obj;
|
|
36
|
+
return sanitizeDeep(obj);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function sanitizeDeep(obj: unknown): unknown {
|
|
40
|
+
if (obj === null || typeof obj !== "object") return obj;
|
|
41
|
+
if (Array.isArray(obj)) return obj.map(sanitizeDeep);
|
|
42
|
+
|
|
43
|
+
const clean: Record<string, unknown> = {};
|
|
44
|
+
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
|
|
45
|
+
if (!DANGEROUS_KEYS.has(key)) {
|
|
46
|
+
clean[key] = sanitizeDeep(value);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return clean;
|
|
50
|
+
}
|
package/src/stringify.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type { DelimiterPair, FrontMatterFormat, StringifyOptions } from "./types.js";
|
|
2
|
+
import { FrontMatterError } from "./types.js";
|
|
3
|
+
import type { WasmParsers } from "./wasm-loader.js";
|
|
4
|
+
import { getWasmParsers, getWasmParsersSync } from "./wasm-loader.js";
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Constants
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
const FORMAT_DELIMITERS: Record<FrontMatterFormat, DelimiterPair> = {
|
|
11
|
+
yaml: { open: "---", close: "---" },
|
|
12
|
+
json: { open: "---", close: "---" },
|
|
13
|
+
toml: { open: "+++", close: "+++" },
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Public API
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Stringify data and content into a Markdown string with front matter.
|
|
22
|
+
*
|
|
23
|
+
* @param data - The front matter data object.
|
|
24
|
+
* @param content - The Markdown content (without front matter).
|
|
25
|
+
* @param options - Stringify options (format, delimiter).
|
|
26
|
+
* @returns A complete Markdown string with front matter.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```ts
|
|
30
|
+
* import { stringifyFrontMatter } from "quill-matter";
|
|
31
|
+
*
|
|
32
|
+
* const markdown = await stringifyFrontMatter(
|
|
33
|
+
* { title: "Hello", tags: ["a", "b"] },
|
|
34
|
+
* "# Content here"
|
|
35
|
+
* );
|
|
36
|
+
* // ---
|
|
37
|
+
* // title: Hello
|
|
38
|
+
* // tags:
|
|
39
|
+
* // - a
|
|
40
|
+
* // - b
|
|
41
|
+
* // ---
|
|
42
|
+
* // # Content here
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export async function stringifyFrontMatter(
|
|
46
|
+
data: Record<string, unknown>,
|
|
47
|
+
content: string,
|
|
48
|
+
options?: StringifyOptions,
|
|
49
|
+
): Promise<string> {
|
|
50
|
+
// Skip front matter block entirely when data is empty.
|
|
51
|
+
if (data === null || typeof data !== "object" || Array.isArray(data)) {
|
|
52
|
+
throw new FrontMatterError(
|
|
53
|
+
`Expected a plain object for front matter data, got ${Array.isArray(data) ? "array" : typeof data}`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
if (Object.keys(data).length === 0) {
|
|
57
|
+
return content;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const format = options?.format ?? "yaml";
|
|
61
|
+
const delimiter = options?.delimiter ?? FORMAT_DELIMITERS[format];
|
|
62
|
+
|
|
63
|
+
const wasm = await getWasmParsers();
|
|
64
|
+
const serialized = stringifyData(wasm, data, format);
|
|
65
|
+
|
|
66
|
+
return assembleMarkdown(serialized, content, delimiter);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Stringify data and content synchronously.
|
|
71
|
+
*
|
|
72
|
+
* **Requires** `await initWasm()` to have been called first.
|
|
73
|
+
*
|
|
74
|
+
* @throws {Error} if WASM module is not initialized.
|
|
75
|
+
*/
|
|
76
|
+
export function stringifyFrontMatterSync(
|
|
77
|
+
data: Record<string, unknown>,
|
|
78
|
+
content: string,
|
|
79
|
+
options?: StringifyOptions,
|
|
80
|
+
): string {
|
|
81
|
+
// Skip front matter block entirely when data is empty.
|
|
82
|
+
if (data === null || typeof data !== "object" || Array.isArray(data)) {
|
|
83
|
+
throw new FrontMatterError(
|
|
84
|
+
`Expected a plain object for front matter data, got ${Array.isArray(data) ? "array" : typeof data}`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
if (Object.keys(data).length === 0) {
|
|
88
|
+
return content;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const format = options?.format ?? "yaml";
|
|
92
|
+
const delimiter = options?.delimiter ?? FORMAT_DELIMITERS[format];
|
|
93
|
+
|
|
94
|
+
const wasm = getWasmParsersSync();
|
|
95
|
+
const serialized = stringifyData(wasm, data, format);
|
|
96
|
+
|
|
97
|
+
return assembleMarkdown(serialized, content, delimiter);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Internal
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
/** Maximum allowed output size in bytes (1 MB), consistent with parse limits. */
|
|
105
|
+
const MAX_OUTPUT_SIZE = 1_048_576;
|
|
106
|
+
|
|
107
|
+
const encoder = new TextEncoder();
|
|
108
|
+
|
|
109
|
+
function stringifyData(
|
|
110
|
+
wasm: WasmParsers,
|
|
111
|
+
data: Record<string, unknown>,
|
|
112
|
+
format: FrontMatterFormat,
|
|
113
|
+
): string {
|
|
114
|
+
let result: string;
|
|
115
|
+
switch (format) {
|
|
116
|
+
case "yaml":
|
|
117
|
+
result = wasm.stringify_yaml(data);
|
|
118
|
+
break;
|
|
119
|
+
case "json":
|
|
120
|
+
result = wasm.stringify_json(data);
|
|
121
|
+
break;
|
|
122
|
+
case "toml":
|
|
123
|
+
result = wasm.stringify_toml(data);
|
|
124
|
+
break;
|
|
125
|
+
default:
|
|
126
|
+
throw new FrontMatterError(`Unknown format: ${format}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Guard against unbounded output — mirrors the parse-side 1 MB byte limit.
|
|
130
|
+
const size = encoder.encode(result).byteLength;
|
|
131
|
+
if (size > MAX_OUTPUT_SIZE) {
|
|
132
|
+
throw new FrontMatterError(
|
|
133
|
+
`Serialized front matter too large: ${size} bytes (max: ${MAX_OUTPUT_SIZE})`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return result;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function assembleMarkdown(frontMatter: string, content: string, delimiter: DelimiterPair): string {
|
|
141
|
+
const trimmedFM = frontMatter.trim();
|
|
142
|
+
|
|
143
|
+
// Build the final markdown — preserve original content whitespace.
|
|
144
|
+
const parts = [delimiter.open, trimmedFM, delimiter.close];
|
|
145
|
+
|
|
146
|
+
if (content.length > 0) {
|
|
147
|
+
parts.push(content);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return `${parts.join("\n")}\n`;
|
|
151
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/** Supported front matter formats. */
|
|
2
|
+
export type FrontMatterFormat = "yaml" | "json" | "toml";
|
|
3
|
+
|
|
4
|
+
/** A pair of opening/closing delimiters. */
|
|
5
|
+
export interface DelimiterPair {
|
|
6
|
+
open: string;
|
|
7
|
+
close: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Built-in delimiters. */
|
|
11
|
+
export const DEFAULT_DELIMITERS: readonly DelimiterPair[] = [
|
|
12
|
+
{ open: "---", close: "---" },
|
|
13
|
+
{ open: "---", close: "..." },
|
|
14
|
+
{ open: "+++", close: "+++" },
|
|
15
|
+
] as const;
|
|
16
|
+
|
|
17
|
+
/** Options for excerpt extraction. */
|
|
18
|
+
export interface ExcerptOptions {
|
|
19
|
+
/** Separator string to look for. @default "<!-- more -->" */
|
|
20
|
+
separator?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Result returned by the main `parseFrontMatter` function.
|
|
25
|
+
*
|
|
26
|
+
* This is a **discriminated union** — narrow on `isEmpty` or `error`
|
|
27
|
+
* before accessing typed `data` to avoid runtime errors.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```ts
|
|
31
|
+
* const result = await parseFrontMatter<PostMeta>(source);
|
|
32
|
+
* if (!result.isEmpty && !result.error) {
|
|
33
|
+
* console.log(result.data.title); // ✅ safely typed as PostMeta
|
|
34
|
+
* }
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export type ParseResult<T = Record<string, unknown>> =
|
|
38
|
+
| ParseResultSuccess<T>
|
|
39
|
+
| ParseResultEmpty
|
|
40
|
+
| ParseResultError;
|
|
41
|
+
|
|
42
|
+
/** Successful parse — `data` is fully typed as `T`. */
|
|
43
|
+
export interface ParseResultSuccess<T = Record<string, unknown>> {
|
|
44
|
+
/** Parsed front matter data. */
|
|
45
|
+
data: T;
|
|
46
|
+
/** Markdown content after the front matter block. */
|
|
47
|
+
content: string;
|
|
48
|
+
/** Detected (or overridden) format of the front matter. */
|
|
49
|
+
format: FrontMatterFormat;
|
|
50
|
+
/** Whether the front matter block was empty / absent. */
|
|
51
|
+
isEmpty: false;
|
|
52
|
+
/** Never present on success. */
|
|
53
|
+
error?: undefined;
|
|
54
|
+
/** Extracted excerpt (when `excerpt` option is enabled). */
|
|
55
|
+
excerpt?: string;
|
|
56
|
+
/** Raw (unparsed) front matter text between delimiters. */
|
|
57
|
+
rawData: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Empty or absent front matter — `data` is `{}`. */
|
|
61
|
+
export interface ParseResultEmpty {
|
|
62
|
+
/** Empty object — no front matter data was found. */
|
|
63
|
+
data: Record<string, never>;
|
|
64
|
+
/** The full source content (no front matter to strip). */
|
|
65
|
+
content: string;
|
|
66
|
+
/** Default or overridden format. */
|
|
67
|
+
format: FrontMatterFormat;
|
|
68
|
+
/** Always `true` when front matter is empty or absent. */
|
|
69
|
+
isEmpty: true;
|
|
70
|
+
/** Never present on empty result. */
|
|
71
|
+
error?: undefined;
|
|
72
|
+
/** Extracted excerpt (when `excerpt` option is enabled). */
|
|
73
|
+
excerpt?: string;
|
|
74
|
+
/** Absent when isEmpty is true. */
|
|
75
|
+
rawData?: undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Parse error (when `strict: false`) — `data` is `{}`. */
|
|
79
|
+
export interface ParseResultError {
|
|
80
|
+
/** Empty object — parse failed. */
|
|
81
|
+
data: Record<string, never>;
|
|
82
|
+
/** Markdown content after the front matter block. */
|
|
83
|
+
content: string;
|
|
84
|
+
/** Detected (or overridden) format. */
|
|
85
|
+
format: FrontMatterFormat;
|
|
86
|
+
/** Always `false` — front matter was present but parsing failed. */
|
|
87
|
+
isEmpty: false;
|
|
88
|
+
/** The parse error. */
|
|
89
|
+
error: FrontMatterError;
|
|
90
|
+
/** Extracted excerpt (when `excerpt` option is enabled). */
|
|
91
|
+
excerpt?: string;
|
|
92
|
+
/** Raw (unparsed) front matter text between delimiters. */
|
|
93
|
+
rawData: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Intermediate result from the extraction step — before parsing. */
|
|
97
|
+
export interface ExtractionResult {
|
|
98
|
+
/** Raw text between the delimiters (unparsed). */
|
|
99
|
+
rawData: string;
|
|
100
|
+
/** Markdown body after the closing delimiter. */
|
|
101
|
+
content: string;
|
|
102
|
+
/** Delimiter pair used. */
|
|
103
|
+
delimiter: DelimiterPair;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Adapter interface every parser must implement. */
|
|
107
|
+
export interface ParserAdapter {
|
|
108
|
+
/** Parse a raw front matter string into a JS object. */
|
|
109
|
+
parse(input: string): unknown;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Options for `stringifyFrontMatter`. */
|
|
113
|
+
export interface StringifyOptions {
|
|
114
|
+
/** Output format. @default "yaml" */
|
|
115
|
+
format?: FrontMatterFormat;
|
|
116
|
+
/** Custom delimiter pair. Defaults based on format: `---` for yaml/json, `+++` for toml. */
|
|
117
|
+
delimiter?: DelimiterPair;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Error classes
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
/** Base error for all quill-matter errors. */
|
|
125
|
+
export class FrontMatterError extends Error {
|
|
126
|
+
constructor(message: string) {
|
|
127
|
+
super(message);
|
|
128
|
+
this.name = "FrontMatterError";
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Thrown when the front matter block cannot be extracted (e.g. unclosed delimiter). */
|
|
133
|
+
export class ExtractionError extends FrontMatterError {
|
|
134
|
+
constructor(message: string) {
|
|
135
|
+
super(message);
|
|
136
|
+
this.name = "ExtractionError";
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Thrown when the raw data fails to parse in the detected format. */
|
|
141
|
+
export class ParseError extends FrontMatterError {
|
|
142
|
+
public readonly format: FrontMatterFormat;
|
|
143
|
+
/** Line number where the error occurred (1-based), if available. */
|
|
144
|
+
public readonly line?: number;
|
|
145
|
+
/** Column number where the error occurred (1-based), if available. */
|
|
146
|
+
public readonly column?: number;
|
|
147
|
+
|
|
148
|
+
constructor(message: string, format: FrontMatterFormat, line?: number, column?: number) {
|
|
149
|
+
super(message);
|
|
150
|
+
this.name = "ParseError";
|
|
151
|
+
this.format = format;
|
|
152
|
+
this.line = line;
|
|
153
|
+
this.column = column;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Thrown when Valibot schema validation fails. */
|
|
158
|
+
export class ValidationError extends FrontMatterError {
|
|
159
|
+
public readonly issues: unknown[];
|
|
160
|
+
|
|
161
|
+
constructor(message: string, issues: unknown[]) {
|
|
162
|
+
super(message);
|
|
163
|
+
this.name = "ValidationError";
|
|
164
|
+
this.issues = issues;
|
|
165
|
+
}
|
|
166
|
+
}
|
package/src/validator.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import * as v from "valibot";
|
|
2
|
+
import { ValidationError } from "./types.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Validate `data` against a Valibot schema.
|
|
6
|
+
*
|
|
7
|
+
* @param data - The parsed front matter object.
|
|
8
|
+
* @param schema - A Valibot `GenericSchema` to validate against.
|
|
9
|
+
* @returns The validated (and potentially transformed) data.
|
|
10
|
+
* @throws {ValidationError} if validation fails.
|
|
11
|
+
*/
|
|
12
|
+
export function validate<T>(data: unknown, schema: v.GenericSchema<unknown, T>): T {
|
|
13
|
+
const result = v.safeParse(schema, data);
|
|
14
|
+
|
|
15
|
+
if (result.success) {
|
|
16
|
+
return result.output;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const issues = result.issues.map((issue) => ({
|
|
20
|
+
message: issue.message,
|
|
21
|
+
path: issue.path?.map((p) => p.key).join("."),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
throw new ValidationError(
|
|
25
|
+
`Front matter validation failed: ${issues.map((i) => i.message).join("; ")}`,
|
|
26
|
+
issues,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lazy WASM module loader.
|
|
3
|
+
*
|
|
4
|
+
* Supports two loading modes:
|
|
5
|
+
* 1. **Bundler** (Vitest, Vite, Cloudflare Workers) — `import("../pkg/quill_matter_wasm.js")`
|
|
6
|
+
* 2. **Direct** (Bun, Deno) — manually instantiate the WASM binary
|
|
7
|
+
*
|
|
8
|
+
* The module is loaded once and cached for subsequent calls.
|
|
9
|
+
*
|
|
10
|
+
* No `node:*` imports — uses only web-standard APIs (`URL`, `fetch`)
|
|
11
|
+
* and runtime-specific file APIs (`Deno.readFile`, `Bun.file`).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// Type declarations for runtime-specific globals
|
|
15
|
+
declare const Deno:
|
|
16
|
+
| {
|
|
17
|
+
readFile(path: string | URL): Promise<Uint8Array>;
|
|
18
|
+
}
|
|
19
|
+
| undefined;
|
|
20
|
+
|
|
21
|
+
declare const Bun:
|
|
22
|
+
| {
|
|
23
|
+
file(path: string | URL): { arrayBuffer(): Promise<ArrayBuffer> };
|
|
24
|
+
}
|
|
25
|
+
| undefined;
|
|
26
|
+
|
|
27
|
+
// biome-ignore lint/suspicious/noExplicitAny: WASM module shape is dynamic
|
|
28
|
+
let wasmModule: any | null = null;
|
|
29
|
+
let initPromise: Promise<WasmParsers> | null = null;
|
|
30
|
+
|
|
31
|
+
export interface WasmParsers {
|
|
32
|
+
parse_yaml(input: string): unknown;
|
|
33
|
+
parse_json(input: string): unknown;
|
|
34
|
+
parse_toml(input: string): unknown;
|
|
35
|
+
stringify_yaml(value: unknown): string;
|
|
36
|
+
stringify_json(value: unknown): string;
|
|
37
|
+
stringify_toml(value: unknown): string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Initialise and return the WASM parser module (async).
|
|
42
|
+
*
|
|
43
|
+
* The module is loaded lazily on first call and cached thereafter.
|
|
44
|
+
* Concurrent calls are deduplicated via a shared promise.
|
|
45
|
+
*/
|
|
46
|
+
export async function getWasmParsers(): Promise<WasmParsers> {
|
|
47
|
+
if (wasmModule) {
|
|
48
|
+
return wasmModule as WasmParsers;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (initPromise) {
|
|
52
|
+
return initPromise;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
initPromise = loadWasm().catch((err) => {
|
|
56
|
+
initPromise = null; // allow retry on next call
|
|
57
|
+
throw new Error("Failed to load WASM module", { cause: err });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return initPromise;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Return the cached WASM module synchronously.
|
|
65
|
+
*
|
|
66
|
+
* @throws {Error} if `initWasm()` has not been called yet.
|
|
67
|
+
*/
|
|
68
|
+
export function getWasmParsersSync(): WasmParsers {
|
|
69
|
+
if (!wasmModule) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
"WASM not initialized — call `await initWasm()` before using synchronous APIs.",
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
return wasmModule as WasmParsers;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Eagerly pre-initialise the WASM module so the first `parseFrontMatter()`
|
|
79
|
+
* call does not pay the loading cost.
|
|
80
|
+
*
|
|
81
|
+
* Calling this is **optional** for the async API but **required** before
|
|
82
|
+
* using `parseFrontMatterSync()`.
|
|
83
|
+
*
|
|
84
|
+
* ```ts
|
|
85
|
+
* import { initWasm } from "quill-matter";
|
|
86
|
+
* await initWasm();
|
|
87
|
+
* ```
|
|
88
|
+
*/
|
|
89
|
+
export async function initWasm(): Promise<void> {
|
|
90
|
+
await getWasmParsers();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Reset the cached module — useful for testing.
|
|
95
|
+
* @internal
|
|
96
|
+
*/
|
|
97
|
+
export function _resetWasmCache(): void {
|
|
98
|
+
wasmModule = null;
|
|
99
|
+
initPromise = null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// Internal
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Read WASM file with runtime detection (Deno, Bun).
|
|
108
|
+
* Uses web-standard `URL` objects — no `node:path` or `node:url` needed.
|
|
109
|
+
*/
|
|
110
|
+
async function readWasmFile(url: URL): Promise<ArrayBuffer> {
|
|
111
|
+
// Deno
|
|
112
|
+
if (typeof Deno !== "undefined") {
|
|
113
|
+
const bytes = await Deno.readFile(url);
|
|
114
|
+
return (bytes.buffer as ArrayBuffer).slice(
|
|
115
|
+
bytes.byteOffset,
|
|
116
|
+
bytes.byteOffset + bytes.byteLength,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Bun
|
|
121
|
+
if (typeof Bun !== "undefined") {
|
|
122
|
+
return Bun.file(url).arrayBuffer();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Fallback: fetch (works in environments with file:// fetch support)
|
|
126
|
+
const response = await fetch(url);
|
|
127
|
+
return response.arrayBuffer();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function loadWasm(): Promise<WasmParsers> {
|
|
131
|
+
try {
|
|
132
|
+
// Bundler-friendly import (works in Vitest, Vite, Cloudflare Workers, etc.)
|
|
133
|
+
// @ts-ignore — wasm-bindgen generated, typed via WasmParsers interface
|
|
134
|
+
const mod = await import("../pkg/quill_matter_wasm.js");
|
|
135
|
+
wasmModule = mod;
|
|
136
|
+
return mod as WasmParsers;
|
|
137
|
+
} catch {
|
|
138
|
+
// Fallback: manually load the WASM binary (Bun, Deno)
|
|
139
|
+
return loadWasmDirect();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function loadWasmDirect(): Promise<WasmParsers> {
|
|
144
|
+
// @ts-ignore — wasm-bindgen generated, typed via WasmParsers interface
|
|
145
|
+
const bgModule = await import("../pkg/quill_matter_wasm_bg.js");
|
|
146
|
+
|
|
147
|
+
// Resolve the .wasm path using web-standard `URL` constructor.
|
|
148
|
+
// Works in Bun, Deno, and any environment with `import.meta.url`.
|
|
149
|
+
const wasmUrl = new URL("../pkg/quill_matter_wasm_bg.wasm", import.meta.url);
|
|
150
|
+
const wasmBytes = await readWasmFile(wasmUrl);
|
|
151
|
+
|
|
152
|
+
const wasmImports = {
|
|
153
|
+
"./quill_matter_wasm_bg.js": bgModule,
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const { instance } = await WebAssembly.instantiate(wasmBytes, wasmImports);
|
|
157
|
+
bgModule.__wbg_set_wasm(instance.exports);
|
|
158
|
+
|
|
159
|
+
// Call the init function for externref table
|
|
160
|
+
if (typeof instance.exports.__wbindgen_start === "function") {
|
|
161
|
+
(instance.exports.__wbindgen_start as () => void)();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
wasmModule = bgModule;
|
|
165
|
+
return bgModule as WasmParsers;
|
|
166
|
+
}
|