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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Quill
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# Quill Matter
|
|
2
|
+
|
|
3
|
+
A TypeScript library for parsing YAML, JSON, and TOML front matter from Markdown files — powered by a Rust WASM core.
|
|
4
|
+
|
|
5
|
+
Supports **Bun** ≥ 1.0 and **Deno** ≥ 2.0.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **YAML / JSON / TOML** — Auto-detects format from delimiters and content
|
|
10
|
+
- **Rust WASM core** — High-performance parsing via WebAssembly
|
|
11
|
+
- **File Helpers** — `readFrontMatter()` reads local files (Bun/Deno) or fetches URLs
|
|
12
|
+
- **Type-safe** — Discriminated unions force correct error handling
|
|
13
|
+
- **Schema validation** — Optional [Valibot](https://valibot.dev) integration
|
|
14
|
+
- **Sync + Async** — `parseFrontMatter()` and `parseFrontMatterSync()` APIs
|
|
15
|
+
- **Stringify** — Convert data back to front matter Markdown
|
|
16
|
+
- **CLI** — Parse, extract, detect, and validate from the command line
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
# Bun
|
|
22
|
+
bun add quill-matter
|
|
23
|
+
|
|
24
|
+
# Deno
|
|
25
|
+
deno add jsr:@quill/quill-matter
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Quick Start
|
|
29
|
+
|
|
30
|
+
```typescript
|
|
31
|
+
// Bun
|
|
32
|
+
import { readFrontMatter } from "quill-matter";
|
|
33
|
+
|
|
34
|
+
// Deno
|
|
35
|
+
import { readFrontMatter } from "@quill/quill-matter";
|
|
36
|
+
|
|
37
|
+
// Works in Bun and Deno
|
|
38
|
+
const result = await readFrontMatter("post.md");
|
|
39
|
+
|
|
40
|
+
if (result.isEmpty) {
|
|
41
|
+
console.log("No front matter found");
|
|
42
|
+
} else if (result.error) {
|
|
43
|
+
console.error("Parse failed:", result.error.message);
|
|
44
|
+
} else {
|
|
45
|
+
// Safe to access data after checks
|
|
46
|
+
console.log(result.data.title);
|
|
47
|
+
console.log(result.content);
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Usage
|
|
52
|
+
|
|
53
|
+
### Reading Files & URLs
|
|
54
|
+
|
|
55
|
+
The `readFrontMatter` helper abstracts away runtime differences.
|
|
56
|
+
|
|
57
|
+
```typescript
|
|
58
|
+
import { readFrontMatter } from "quill-matter";
|
|
59
|
+
|
|
60
|
+
// Local file (Bun / Deno)
|
|
61
|
+
const post = await readFrontMatter("./content/post.md");
|
|
62
|
+
|
|
63
|
+
// Remote URL (Fetch)
|
|
64
|
+
const remote = await readFrontMatter("https://example.com/post.md");
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Manual Parsing
|
|
68
|
+
|
|
69
|
+
If you already have the string content:
|
|
70
|
+
|
|
71
|
+
```typescript
|
|
72
|
+
import { parseFrontMatter } from "quill-matter";
|
|
73
|
+
|
|
74
|
+
const result = await parseFrontMatter(`---
|
|
75
|
+
title: Hello World
|
|
76
|
+
tags:
|
|
77
|
+
- typescript
|
|
78
|
+
- rust
|
|
79
|
+
---
|
|
80
|
+
# My Blog Post`);
|
|
81
|
+
|
|
82
|
+
if (!result.isEmpty && !result.error) {
|
|
83
|
+
console.log(result.data.title); // "Hello World"
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Type Safety (`ParseResult` Union)
|
|
88
|
+
|
|
89
|
+
The return type `ParseResult<T>` is a discriminated union. You **must** narrow the type by checking `isEmpty` and `error` before accessing `data`.
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
const result = await parseFrontMatter<PostMeta>(source);
|
|
93
|
+
|
|
94
|
+
// ❌ Error: Property 'title' does not exist (could be empty or error)
|
|
95
|
+
console.log(result.data.title);
|
|
96
|
+
|
|
97
|
+
// ✅ Correct usage
|
|
98
|
+
if (!result.isEmpty && !result.error) {
|
|
99
|
+
console.log(result.data.title); // typed as PostMeta
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Schema Validation
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
import * as v from "valibot";
|
|
107
|
+
|
|
108
|
+
const result = await parseFrontMatter(source, {
|
|
109
|
+
schema: v.object({
|
|
110
|
+
title: v.string(),
|
|
111
|
+
draft: v.optional(v.boolean(), false),
|
|
112
|
+
}),
|
|
113
|
+
});
|
|
114
|
+
// result.data is typed & validated
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Synchronous API
|
|
118
|
+
|
|
119
|
+
```typescript
|
|
120
|
+
import { initWasm, parseFrontMatterSync } from "quill-matter";
|
|
121
|
+
|
|
122
|
+
await initWasm(); // call once at startup
|
|
123
|
+
const result = parseFrontMatterSync(source);
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### CLI
|
|
127
|
+
|
|
128
|
+
The CLI is runtime-agnostic and works with `bun` or `deno`.
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
# Parse to JSON
|
|
132
|
+
quill-matter parse post.md --pretty
|
|
133
|
+
|
|
134
|
+
# Detect format
|
|
135
|
+
quill-matter detect post.md
|
|
136
|
+
|
|
137
|
+
# Validate against schema (requires custom script, CLI only validates syntax)
|
|
138
|
+
quill-matter validate post.md
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## API Reference
|
|
142
|
+
|
|
143
|
+
| Function | Description |
|
|
144
|
+
|----------|-------------|
|
|
145
|
+
| `readFrontMatter(path, options?)` | Read file/URL and parse front matter. |
|
|
146
|
+
| `readFrontMatterMany(paths, options?)` | Read multiple files/URLs in parallel. |
|
|
147
|
+
| `parseFrontMatter(source, options?)` | Async parse string. Lazily loads WASM. |
|
|
148
|
+
| `parseFrontMatterSync(source, options?)` | Sync parse string. Requires `initWasm()`. |
|
|
149
|
+
| `hasFrontMatter(source, delimiters?)` | Zero-cost check (no WASM). |
|
|
150
|
+
| `stringifyFrontMatter(data, content)` | Async stringify to Markdown. |
|
|
151
|
+
| `extractFrontMatter(source)` | Extract raw front matter block. |
|
|
152
|
+
|
|
153
|
+
### `ParseOptions`
|
|
154
|
+
|
|
155
|
+
| Option | Type | Default | Description |
|
|
156
|
+
|--------|------|---------|-------------|
|
|
157
|
+
| `schema` | `GenericSchema` | — | Valibot schema for validation |
|
|
158
|
+
| `format` | `"yaml" \| "json" \| "toml"` | auto | Override auto-detection |
|
|
159
|
+
| `strict` | `boolean` | `true` | Set `false` to return errors instead of throwing |
|
|
160
|
+
| `excerpt` | `boolean \| ExcerptOptions` | `false` | Extract excerpt from content |
|
|
161
|
+
|
|
162
|
+
## Security
|
|
163
|
+
|
|
164
|
+
- **Prototype Pollution** — `__proto__`, `prototype` keys are stripped.
|
|
165
|
+
- **Input Size** — Max 1MB input enforced.
|
|
166
|
+
- **Error Leakage** — Internal paths/stack traces are sanitized in non-strict mode.
|
|
167
|
+
|
|
168
|
+
## Development
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
bun install
|
|
172
|
+
bun run build:wasm
|
|
173
|
+
bun run test # Run tests (Vitest)
|
|
174
|
+
bun run test:deno # Run Deno tests
|
|
175
|
+
```
|
package/cli/index.ts
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { detectFormat, extractFrontMatter, initWasm, parseFrontMatterSync } from "../src/index.js";
|
|
4
|
+
import type { DelimiterPair, FrontMatterFormat } from "../src/types.js";
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Runtime detection & Polyfills
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
declare const Bun: { argv: string[]; file(path: string): { text(): Promise<string> } } | undefined;
|
|
11
|
+
declare const Deno:
|
|
12
|
+
| { args: string[]; readTextFile(path: string): Promise<string>; exit(code?: number): never }
|
|
13
|
+
| undefined;
|
|
14
|
+
|
|
15
|
+
function getArgs(): string[] {
|
|
16
|
+
if (typeof Bun !== "undefined") return Bun.argv.slice(2);
|
|
17
|
+
if (typeof Deno !== "undefined") return Deno.args;
|
|
18
|
+
throw new Error("Unsupported runtime — use Bun or Deno.");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function exit(code = 0): never {
|
|
22
|
+
if (typeof Deno !== "undefined") Deno.exit(code);
|
|
23
|
+
process.exit(code); // Bun supports process.exit
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Args Parser (Minimal — no external dependencies)
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
interface ParsedArgs {
|
|
31
|
+
values: {
|
|
32
|
+
help?: boolean;
|
|
33
|
+
format?: string;
|
|
34
|
+
json?: boolean;
|
|
35
|
+
pretty?: boolean;
|
|
36
|
+
delimiter?: string;
|
|
37
|
+
};
|
|
38
|
+
positionals: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parseCliArgs(args: string[]): ParsedArgs {
|
|
42
|
+
const values: ParsedArgs["values"] = {};
|
|
43
|
+
const positionals: string[] = [];
|
|
44
|
+
|
|
45
|
+
for (let i = 0; i < args.length; i++) {
|
|
46
|
+
const arg = args[i];
|
|
47
|
+
|
|
48
|
+
if (arg === "-h" || arg === "--help") {
|
|
49
|
+
values.help = true;
|
|
50
|
+
} else if (arg === "-f" || arg === "--format") {
|
|
51
|
+
values.format = args[++i];
|
|
52
|
+
} else if (arg === "-j" || arg === "--json") {
|
|
53
|
+
values.json = true;
|
|
54
|
+
} else if (arg === "-p" || arg === "--pretty") {
|
|
55
|
+
values.pretty = true;
|
|
56
|
+
} else if (arg === "-d" || arg === "--delimiter") {
|
|
57
|
+
values.delimiter = args[++i];
|
|
58
|
+
} else if (arg.startsWith("-")) {
|
|
59
|
+
die(`unknown option: ${arg}`);
|
|
60
|
+
} else {
|
|
61
|
+
positionals.push(arg);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return { values, positionals };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Help
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
const HELP = `
|
|
72
|
+
quill-matter — Parse YAML, JSON, and TOML front matter from Markdown files
|
|
73
|
+
|
|
74
|
+
Usage:
|
|
75
|
+
quill-matter <command> <file> [options]
|
|
76
|
+
|
|
77
|
+
Commands:
|
|
78
|
+
parse Parse front matter and output as JSON
|
|
79
|
+
extract Output raw extracted front matter (unparsed)
|
|
80
|
+
detect Detect and print the front matter format
|
|
81
|
+
validate Parse and validate front matter (exits 1 on error)
|
|
82
|
+
|
|
83
|
+
Options:
|
|
84
|
+
-f, --format <fmt> Force format: yaml | json | toml
|
|
85
|
+
-d, --delimiter <delim> Custom delimiter (e.g. "~~~" or "<!--,-->")
|
|
86
|
+
-j, --json Output as compact JSON (default)
|
|
87
|
+
-p, --pretty Pretty-print JSON output
|
|
88
|
+
-h, --help Show this help message
|
|
89
|
+
|
|
90
|
+
Examples:
|
|
91
|
+
quill-matter parse README.md
|
|
92
|
+
quill-matter parse README.md --pretty
|
|
93
|
+
quill-matter detect post.md
|
|
94
|
+
quill-matter extract post.mdx -d "~~~"
|
|
95
|
+
quill-matter parse post.md -f toml
|
|
96
|
+
`.trim();
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Helpers
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
function die(message: string, code = 1): never {
|
|
103
|
+
console.error(`error: ${message}`);
|
|
104
|
+
exit(code);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function readInput(path?: string): Promise<string> {
|
|
108
|
+
if (!path) die("no file specified — run with --help for usage");
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
if (typeof Bun !== "undefined") {
|
|
112
|
+
// Bun.file() handles absolute/relative paths automatically
|
|
113
|
+
const file = Bun.file(path);
|
|
114
|
+
try {
|
|
115
|
+
return await file.text();
|
|
116
|
+
} catch {
|
|
117
|
+
throw new Error("File not found");
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (typeof Deno !== "undefined") {
|
|
121
|
+
return await Deno.readTextFile(path);
|
|
122
|
+
}
|
|
123
|
+
} catch {
|
|
124
|
+
die(`could not read file: ${path}`);
|
|
125
|
+
}
|
|
126
|
+
throw new Error("Unsupported runtime");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function parseDelimiter(raw?: string): DelimiterPair[] | undefined {
|
|
130
|
+
if (!raw) return undefined;
|
|
131
|
+
if (raw.includes(",")) {
|
|
132
|
+
const [open, close] = raw.split(",", 2);
|
|
133
|
+
return [{ open: open.trim(), close: close.trim() }];
|
|
134
|
+
}
|
|
135
|
+
return [{ open: raw, close: raw }];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function output(data: unknown, pretty?: boolean): void {
|
|
139
|
+
console.log(pretty ? JSON.stringify(data, null, 2) : JSON.stringify(data));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// Main
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
async function main() {
|
|
147
|
+
const args = getArgs();
|
|
148
|
+
const { values, positionals } = parseCliArgs(args);
|
|
149
|
+
|
|
150
|
+
if (values.help || positionals.length === 0) {
|
|
151
|
+
console.log(HELP);
|
|
152
|
+
exit(0);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const [command, filePath] = positionals;
|
|
156
|
+
const source = await readInput(filePath);
|
|
157
|
+
const delimiters = parseDelimiter(values.delimiter);
|
|
158
|
+
const VALID_FORMATS = ["yaml", "json", "toml"];
|
|
159
|
+
const format: FrontMatterFormat | undefined = values.format
|
|
160
|
+
? VALID_FORMATS.includes(values.format)
|
|
161
|
+
? (values.format as FrontMatterFormat)
|
|
162
|
+
: die(`invalid format: "${values.format}"`)
|
|
163
|
+
: undefined;
|
|
164
|
+
|
|
165
|
+
switch (command) {
|
|
166
|
+
case "parse": {
|
|
167
|
+
await initWasm();
|
|
168
|
+
const result = parseFrontMatterSync(source, { format, delimiters });
|
|
169
|
+
output(
|
|
170
|
+
{
|
|
171
|
+
data: result.data,
|
|
172
|
+
format: result.format,
|
|
173
|
+
isEmpty: result.isEmpty,
|
|
174
|
+
},
|
|
175
|
+
values.pretty,
|
|
176
|
+
);
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
case "extract": {
|
|
181
|
+
const extraction = extractFrontMatter(source, delimiters);
|
|
182
|
+
if (!extraction) die("no front matter found");
|
|
183
|
+
console.log(extraction.rawData);
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
case "detect": {
|
|
188
|
+
const extraction = extractFrontMatter(source, delimiters);
|
|
189
|
+
if (!extraction) die("no front matter found");
|
|
190
|
+
const detected = format ?? detectFormat(extraction.rawData, extraction.delimiter);
|
|
191
|
+
console.log(detected);
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
case "validate": {
|
|
196
|
+
await initWasm();
|
|
197
|
+
try {
|
|
198
|
+
const result = parseFrontMatterSync(source, { format, delimiters });
|
|
199
|
+
output({ valid: true, format: result.format, data: result.data }, values.pretty);
|
|
200
|
+
} catch (err) {
|
|
201
|
+
output({ valid: false, error: String(err) }, values.pretty);
|
|
202
|
+
exit(1);
|
|
203
|
+
}
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
default:
|
|
208
|
+
die(`unknown command: ${command}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
main().catch((err) => {
|
|
213
|
+
die(String(err));
|
|
214
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "quill-matter",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Parse YAML, JSON, and TOML front matter from Markdown files — powered by Rust WASM",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"quill-matter": "./cli/index.ts"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"bun": "./src/index.ts",
|
|
12
|
+
"deno": "./src/index.ts",
|
|
13
|
+
"default": "./src/index.ts"
|
|
14
|
+
},
|
|
15
|
+
"./extractor": {
|
|
16
|
+
"bun": "./src/extractor.ts",
|
|
17
|
+
"deno": "./src/extractor.ts",
|
|
18
|
+
"default": "./src/extractor.ts"
|
|
19
|
+
},
|
|
20
|
+
"./detector": {
|
|
21
|
+
"bun": "./src/detector.ts",
|
|
22
|
+
"deno": "./src/detector.ts",
|
|
23
|
+
"default": "./src/detector.ts"
|
|
24
|
+
},
|
|
25
|
+
"./validator": {
|
|
26
|
+
"bun": "./src/validator.ts",
|
|
27
|
+
"deno": "./src/validator.ts",
|
|
28
|
+
"default": "./src/validator.ts"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"src/",
|
|
33
|
+
"pkg/",
|
|
34
|
+
"cli/",
|
|
35
|
+
"README.md",
|
|
36
|
+
"LICENSE"
|
|
37
|
+
],
|
|
38
|
+
"engines": {
|
|
39
|
+
"bun": ">=1.0",
|
|
40
|
+
"deno": ">=2.0"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build:wasm": "wasm-pack build crates/parser-wasm --target bundler --out-dir ../../pkg --release",
|
|
44
|
+
"test": "vitest run",
|
|
45
|
+
"test:deno": "deno test --allow-read --allow-write tests/deno.test.ts",
|
|
46
|
+
"test:watch": "vitest",
|
|
47
|
+
"bench": "vitest bench",
|
|
48
|
+
"lint": "biome check .",
|
|
49
|
+
"format": "biome format --write .",
|
|
50
|
+
"clean": "rimraf crates/parser-wasm/target pkg",
|
|
51
|
+
"clean:all": "rimraf crates/parser-wasm/target pkg node_modules"
|
|
52
|
+
},
|
|
53
|
+
"keywords": [
|
|
54
|
+
"markdown",
|
|
55
|
+
"frontmatter",
|
|
56
|
+
"front-matter",
|
|
57
|
+
"yaml",
|
|
58
|
+
"json",
|
|
59
|
+
"toml",
|
|
60
|
+
"wasm",
|
|
61
|
+
"parser"
|
|
62
|
+
],
|
|
63
|
+
"license": "MIT",
|
|
64
|
+
"repository": {
|
|
65
|
+
"type": "git",
|
|
66
|
+
"url": "https://github.com/murelux/quill-matter.git"
|
|
67
|
+
},
|
|
68
|
+
"homepage": "https://github.com/murelux/quill-matter#readme",
|
|
69
|
+
"bugs": {
|
|
70
|
+
"url": "https://github.com/murelux/quill-matter/issues"
|
|
71
|
+
},
|
|
72
|
+
"devDependencies": {
|
|
73
|
+
"@biomejs/biome": "^2.3.0",
|
|
74
|
+
"@types/bun": "^1.3.9",
|
|
75
|
+
"rimraf": "^6.1.2",
|
|
76
|
+
"tinybench": "^3.0.0",
|
|
77
|
+
"valibot": "^1.0.0",
|
|
78
|
+
"vite-plugin-wasm": "^3.5.0",
|
|
79
|
+
"vitest": "^4.0.0",
|
|
80
|
+
"wasm-pack": "^0.14.0"
|
|
81
|
+
},
|
|
82
|
+
"peerDependencies": {
|
|
83
|
+
"valibot": ">=1.0.0"
|
|
84
|
+
},
|
|
85
|
+
"peerDependenciesMeta": {
|
|
86
|
+
"valibot": {
|
|
87
|
+
"optional": true
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
package/src/detector.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { DelimiterPair, FrontMatterFormat } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/** @internal Result of format detection that may include pre-parsed JSON data. */
|
|
4
|
+
export interface DetectionResult {
|
|
5
|
+
format: FrontMatterFormat;
|
|
6
|
+
/** Pre-parsed JSON data, present only when format is "json". Avoids double parsing. */
|
|
7
|
+
preparsedData?: unknown;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Detect the front matter format from the raw content and delimiter pair.
|
|
12
|
+
*
|
|
13
|
+
* - `+++` delimiters → TOML
|
|
14
|
+
* - Content starts with `{` → JSON
|
|
15
|
+
* - Anything else → YAML (default)
|
|
16
|
+
*/
|
|
17
|
+
export function detectFormat(rawData: string, delimiter: DelimiterPair): FrontMatterFormat {
|
|
18
|
+
return detectFormatWithPreparsed(rawData, delimiter).format;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Detect the format and optionally return pre-parsed JSON data.
|
|
23
|
+
*
|
|
24
|
+
* Used internally by the main pipeline so JSON front matter is only
|
|
25
|
+
* parsed once (by the native `JSON.parse`) rather than being parsed
|
|
26
|
+
* again through the WASM layer.
|
|
27
|
+
*
|
|
28
|
+
* @internal
|
|
29
|
+
*/
|
|
30
|
+
export function detectFormatWithPreparsed(
|
|
31
|
+
rawData: string,
|
|
32
|
+
delimiter: DelimiterPair,
|
|
33
|
+
): DetectionResult {
|
|
34
|
+
// TOML uses +++ delimiters by convention.
|
|
35
|
+
if (delimiter.open === "+++") {
|
|
36
|
+
return { format: "toml" };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// JSON detection: content starts with `{` or `[` and is valid JSON.
|
|
40
|
+
// A simple `startsWith("{")` heuristic would misidentify YAML flow
|
|
41
|
+
// mappings (e.g. `{key: value}`) as JSON, so we probe with JSON.parse.
|
|
42
|
+
const trimmed = rawData.trimStart();
|
|
43
|
+
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
44
|
+
try {
|
|
45
|
+
const preparsedData = JSON.parse(trimmed);
|
|
46
|
+
return { format: "json", preparsedData };
|
|
47
|
+
} catch {
|
|
48
|
+
// Not valid JSON — fall through to YAML default.
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return { format: "yaml" };
|
|
53
|
+
}
|
package/src/excerpt.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { ExcerptOptions } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/** Default excerpt separator. */
|
|
4
|
+
const DEFAULT_SEPARATOR = "<!-- more -->";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Extract an excerpt from markdown content.
|
|
8
|
+
*
|
|
9
|
+
* If the separator is found, returns everything before it.
|
|
10
|
+
* Otherwise, returns the first paragraph.
|
|
11
|
+
*
|
|
12
|
+
* @param content - The markdown content (after front matter).
|
|
13
|
+
* @param options - Excerpt extraction options.
|
|
14
|
+
* @returns The extracted excerpt, or undefined if content is empty.
|
|
15
|
+
*/
|
|
16
|
+
export function extractExcerpt(
|
|
17
|
+
content: string,
|
|
18
|
+
options?: ExcerptOptions | boolean,
|
|
19
|
+
): string | undefined {
|
|
20
|
+
if (!content || content.trim().length === 0) {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const separator =
|
|
25
|
+
typeof options === "object" && options.separator ? options.separator : DEFAULT_SEPARATOR;
|
|
26
|
+
|
|
27
|
+
const trimmed = content.trim();
|
|
28
|
+
|
|
29
|
+
// Try to find the separator
|
|
30
|
+
const separatorIndex = trimmed.indexOf(separator);
|
|
31
|
+
if (separatorIndex !== -1) {
|
|
32
|
+
const excerpt = trimmed.slice(0, separatorIndex).trim();
|
|
33
|
+
return excerpt.length > 0 ? excerpt : undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Fallback: extract first paragraph
|
|
37
|
+
// A paragraph is text separated by blank lines
|
|
38
|
+
const paragraphs = trimmed.split(/\n\s*\n/);
|
|
39
|
+
const firstParagraph = paragraphs[0]?.trim();
|
|
40
|
+
|
|
41
|
+
// Skip if first "paragraph" is a heading alone
|
|
42
|
+
if (firstParagraph && !isHeadingOnly(firstParagraph)) {
|
|
43
|
+
return firstParagraph;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// If first is heading, try second paragraph
|
|
47
|
+
if (paragraphs.length > 1) {
|
|
48
|
+
const secondParagraph = paragraphs[1]?.trim();
|
|
49
|
+
if (secondParagraph && !isHeadingOnly(secondParagraph)) {
|
|
50
|
+
return secondParagraph;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Check if text is only a markdown heading.
|
|
59
|
+
*/
|
|
60
|
+
function isHeadingOnly(text: string): boolean {
|
|
61
|
+
const lines = text.split("\n").filter((line) => line.trim().length > 0);
|
|
62
|
+
return lines.length === 1 && /^#{1,6}\s+/.test(lines[0]);
|
|
63
|
+
}
|