@yukyu30/fluorite 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 +209 -0
- package/dist/chunk-2QW4LSG5.js +484 -0
- package/dist/chunk-2QW4LSG5.js.map +1 -0
- package/dist/cli.cjs +573 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.cts +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +93 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.cjs +529 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +226 -0
- package/dist/index.d.ts +226 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/package.json +60 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 yukyu30
|
|
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,209 @@
|
|
|
1
|
+
# fluorite
|
|
2
|
+
|
|
3
|
+
Inspect and validate Markdown **frontmatter** with a readable, chainable DSL —
|
|
4
|
+
usable as a **library** and a **CLI**.
|
|
5
|
+
|
|
6
|
+
```md
|
|
7
|
+
---
|
|
8
|
+
tags: ["ok", "ng"]
|
|
9
|
+
title: "これはタイトルです"
|
|
10
|
+
---
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { check } from "@yukyu30/fluorite";
|
|
15
|
+
|
|
16
|
+
const result = check(markdown, (fm) => {
|
|
17
|
+
fm.key("title").required().type("string").lengthMin(10);
|
|
18
|
+
fm.key("tags").not.has("ng"); // ← fails (red) because "ng" is present
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
result.ok; // false
|
|
22
|
+
result.failures; // [{ key: "tags", rule: "has", negated: true, ok: false, ... }]
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Checks never throw — every rule produces a **result object** (pass/fail with a
|
|
26
|
+
reason) so you can collect and report them as red/green.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
npm install @yukyu30/fluorite
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Library API
|
|
35
|
+
|
|
36
|
+
### `check(source, rules) => CheckResult`
|
|
37
|
+
|
|
38
|
+
Parses the frontmatter out of a Markdown `source` string and runs the `rules`
|
|
39
|
+
callback against it. The `fm` argument is a recorder: each matcher you call
|
|
40
|
+
records one result.
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
const result = check(source, (fm) => {
|
|
44
|
+
fm.key("title").required().lengthMin(10);
|
|
45
|
+
});
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`CheckResult`:
|
|
49
|
+
|
|
50
|
+
| field | description |
|
|
51
|
+
| ---------- | -------------------------------------------- |
|
|
52
|
+
| `ok` | `true` when every rule passed |
|
|
53
|
+
| `results` | every recorded `RuleResult`, in order |
|
|
54
|
+
| `failures` | only the failing `RuleResult`s |
|
|
55
|
+
| `data` | the parsed frontmatter object |
|
|
56
|
+
|
|
57
|
+
A missing frontmatter block or malformed YAML is recorded as a failing
|
|
58
|
+
`parse` rule rather than throwing.
|
|
59
|
+
|
|
60
|
+
### `checkData(data, rules) => CheckResult`
|
|
61
|
+
|
|
62
|
+
Same as `check`, but runs against an already-parsed frontmatter object.
|
|
63
|
+
|
|
64
|
+
### Matchers
|
|
65
|
+
|
|
66
|
+
Begin a chain with `fm.key("name")`. The `.not` modifier negates **only the
|
|
67
|
+
next** matcher, then resets.
|
|
68
|
+
|
|
69
|
+
**Existence / type**
|
|
70
|
+
|
|
71
|
+
- `required()` / `exists()` — the key is present
|
|
72
|
+
- `type(t)` — `"string" | "number" | "boolean" | "array" | "object" | "null"`
|
|
73
|
+
|
|
74
|
+
**Value**
|
|
75
|
+
|
|
76
|
+
- `eq(value)` — deep-equality
|
|
77
|
+
- `oneOf([...])` — value is one of the allowed set (enum)
|
|
78
|
+
- `matches(regexp)` — string matches the pattern
|
|
79
|
+
|
|
80
|
+
**Containment (arrays & strings)**
|
|
81
|
+
|
|
82
|
+
- `has(value)` — array contains the element, or string contains the substring
|
|
83
|
+
- `hasAll([...])` — contains every item
|
|
84
|
+
- `hasAny([...])` — contains at least one item
|
|
85
|
+
|
|
86
|
+
**Enum / array contents** — catch tag notation drift
|
|
87
|
+
|
|
88
|
+
- `subsetOf([...])` / `only([...])` — value is an array whose every element is
|
|
89
|
+
in the allowed set; failures list the offending (typo'd) values
|
|
90
|
+
- `each.oneOf([...])` — same as `subsetOf`, via the per-element accessor
|
|
91
|
+
- `each.type(t)` — every element is of type `t`
|
|
92
|
+
- `each.matches(regexp)` — every (string) element matches the pattern
|
|
93
|
+
|
|
94
|
+
**Length (arrays & strings)**
|
|
95
|
+
|
|
96
|
+
- `length(n)` — length equals `n`
|
|
97
|
+
- `lengthMin(n)` — length `>= n`
|
|
98
|
+
- `lengthMax(n)` — length `<= n`
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
check(source, (fm) => {
|
|
102
|
+
fm.key("status").oneOf(["draft", "published"]);
|
|
103
|
+
fm.key("slug").matches(/^[a-z0-9-]+$/);
|
|
104
|
+
fm.key("tags").type("array").hasAll(["blog"]).not.has("ng");
|
|
105
|
+
fm.key("summary").lengthMin(20).lengthMax(160);
|
|
106
|
+
});
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Defining a tag enum
|
|
110
|
+
|
|
111
|
+
Tags drift easily (`Blog` vs `blog`, stray `ng`). Define the canonical
|
|
112
|
+
vocabulary once and `subsetOf` flags anything outside it — the failure names
|
|
113
|
+
the exact offending values:
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
const TAGS = ["ok", "release", "blog", "news"];
|
|
117
|
+
|
|
118
|
+
check(source, (fm) => {
|
|
119
|
+
fm.key("tags").type("array").subsetOf(TAGS);
|
|
120
|
+
});
|
|
121
|
+
// tags: ["ok", "Blog", "ng"]
|
|
122
|
+
// → all items should be one of [...] (invalid: ["Blog","ng"])
|
|
123
|
+
|
|
124
|
+
// Or enforce a notation rule instead of a fixed list:
|
|
125
|
+
check(source, (fm) => {
|
|
126
|
+
fm.key("tags").each.matches(/^[a-z0-9-]+$/); // lowercase kebab-case only
|
|
127
|
+
});
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## CLI
|
|
131
|
+
|
|
132
|
+
```sh
|
|
133
|
+
fluorite check "docs/**/*.md" [--config <path>] [--quiet]
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
- Collects files via glob, checks each file's frontmatter, prints a red/green
|
|
137
|
+
report, and exits with code `1` if any file fails (great for CI).
|
|
138
|
+
- Rules come from a config file (`fluorite.config.{js,mjs,cjs}`, or `--config`).
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
✘ docs/bad.md
|
|
142
|
+
✘ title: length should be >= 10 (was 2) (value: "短い")
|
|
143
|
+
✘ tags: should not have "ng" (value: ["ok","ng"])
|
|
144
|
+
✔ docs/good.md
|
|
145
|
+
|
|
146
|
+
2 files, 1 passed, 1 failed, 2 rule failures
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Options:
|
|
150
|
+
|
|
151
|
+
- `-c, --config <path>` — path to a config file
|
|
152
|
+
- `-q, --quiet` — only print files that have failures
|
|
153
|
+
- `-h, --help` — show help
|
|
154
|
+
|
|
155
|
+
### Config file
|
|
156
|
+
|
|
157
|
+
```js
|
|
158
|
+
// fluorite.config.mjs
|
|
159
|
+
import { defineConfig } from "@yukyu30/fluorite";
|
|
160
|
+
|
|
161
|
+
export default defineConfig({
|
|
162
|
+
include: ["docs/**/*.md"],
|
|
163
|
+
exclude: ["**/node_modules/**"],
|
|
164
|
+
rules: (fm) => {
|
|
165
|
+
fm.key("title").required().type("string").lengthMin(10);
|
|
166
|
+
fm.key("tags").required().type("array").not.has("ng");
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Positional patterns on the CLI override `include`. If neither is given,
|
|
172
|
+
`**/*.md` is used.
|
|
173
|
+
|
|
174
|
+
## Releasing
|
|
175
|
+
|
|
176
|
+
Releases are automated with [tagpr](https://github.com/Songmu/tagpr) +
|
|
177
|
+
`npm publish` (`.github/workflows/tagpr.yml`):
|
|
178
|
+
|
|
179
|
+
1. Push commits to `main`. tagpr opens/updates a **Release PR** that bumps the
|
|
180
|
+
version in `package.json` and updates `CHANGELOG.md`.
|
|
181
|
+
2. Label the Release PR `minor` / `major` to control the bump (default: patch).
|
|
182
|
+
3. Merge the Release PR. tagpr creates the `vX.Y.Z` tag + GitHub Release, and
|
|
183
|
+
the same workflow run publishes the package to npm.
|
|
184
|
+
|
|
185
|
+
Publishing uses npm **trusted publishing (OIDC)** — no `NPM_TOKEN` secret, and
|
|
186
|
+
provenance is attached automatically.
|
|
187
|
+
|
|
188
|
+
One-time setup:
|
|
189
|
+
|
|
190
|
+
1. **First publish** (the package must exist before a trusted publisher can be
|
|
191
|
+
attached). From your machine:
|
|
192
|
+
```sh
|
|
193
|
+
npm login
|
|
194
|
+
npm publish --access public
|
|
195
|
+
```
|
|
196
|
+
2. On npmjs.com → the package → **Settings → Trusted Publisher → GitHub
|
|
197
|
+
Actions**, set:
|
|
198
|
+
- Organization or user: `yukyu30`
|
|
199
|
+
- Repository: `fluorite`
|
|
200
|
+
- Workflow filename: `tagpr.yml`
|
|
201
|
+
- Environment: *(leave empty)*
|
|
202
|
+
3. GitHub → Settings → Actions → General → enable
|
|
203
|
+
"Allow GitHub Actions to create and approve pull requests" (for tagpr).
|
|
204
|
+
|
|
205
|
+
After that, every merged Release PR publishes automatically with no token.
|
|
206
|
+
|
|
207
|
+
## License
|
|
208
|
+
|
|
209
|
+
MIT
|
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
// src/parse.ts
|
|
2
|
+
import matter from "gray-matter";
|
|
3
|
+
function parseFrontmatter(source) {
|
|
4
|
+
const hasFrontmatter = /^?\s*---\r?\n/.test(source);
|
|
5
|
+
try {
|
|
6
|
+
const parsed = matter(source);
|
|
7
|
+
const data = parsed.data ?? {};
|
|
8
|
+
return {
|
|
9
|
+
data,
|
|
10
|
+
content: parsed.content,
|
|
11
|
+
hasFrontmatter
|
|
12
|
+
};
|
|
13
|
+
} catch (err) {
|
|
14
|
+
return {
|
|
15
|
+
data: {},
|
|
16
|
+
content: source,
|
|
17
|
+
hasFrontmatter,
|
|
18
|
+
error: err instanceof Error ? err.message : String(err)
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// src/assertion.ts
|
|
24
|
+
var MISSING = /* @__PURE__ */ Symbol("missing");
|
|
25
|
+
function valueType(value) {
|
|
26
|
+
if (value === MISSING) return "undefined";
|
|
27
|
+
if (value === null) return "null";
|
|
28
|
+
if (Array.isArray(value)) return "array";
|
|
29
|
+
const t = typeof value;
|
|
30
|
+
if (t === "string" || t === "number" || t === "boolean" || t === "object") {
|
|
31
|
+
return t;
|
|
32
|
+
}
|
|
33
|
+
return "undefined";
|
|
34
|
+
}
|
|
35
|
+
function lengthOf(value) {
|
|
36
|
+
if (typeof value === "string" || Array.isArray(value)) return value.length;
|
|
37
|
+
return void 0;
|
|
38
|
+
}
|
|
39
|
+
function display(value) {
|
|
40
|
+
if (value === MISSING) return "undefined";
|
|
41
|
+
try {
|
|
42
|
+
return JSON.stringify(value) ?? String(value);
|
|
43
|
+
} catch {
|
|
44
|
+
return String(value);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
var KeyAssertion = class {
|
|
48
|
+
constructor(recorder, key, value, present) {
|
|
49
|
+
this.recorder = recorder;
|
|
50
|
+
this.key = key;
|
|
51
|
+
this.value = value;
|
|
52
|
+
this.present = present;
|
|
53
|
+
}
|
|
54
|
+
recorder;
|
|
55
|
+
key;
|
|
56
|
+
value;
|
|
57
|
+
present;
|
|
58
|
+
#negated = false;
|
|
59
|
+
/** Negate the next matcher in the chain. */
|
|
60
|
+
get not() {
|
|
61
|
+
this.#negated = true;
|
|
62
|
+
return this;
|
|
63
|
+
}
|
|
64
|
+
/** The raw value (resolved against the sentinel) used by matchers. */
|
|
65
|
+
get resolved() {
|
|
66
|
+
return this.present ? this.value : MISSING;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Record a result, applying the pending `.not` negation, then reset it.
|
|
70
|
+
*/
|
|
71
|
+
record(rule, rawPass, describe, expected) {
|
|
72
|
+
const negated = this.#negated;
|
|
73
|
+
this.#negated = false;
|
|
74
|
+
const ok = negated ? !rawPass : rawPass;
|
|
75
|
+
const result = {
|
|
76
|
+
key: this.key,
|
|
77
|
+
rule,
|
|
78
|
+
ok,
|
|
79
|
+
negated,
|
|
80
|
+
message: describe(negated),
|
|
81
|
+
value: this.present ? this.value : void 0,
|
|
82
|
+
expected
|
|
83
|
+
};
|
|
84
|
+
this.recorder.push(result);
|
|
85
|
+
return this;
|
|
86
|
+
}
|
|
87
|
+
/** Assert the key exists in the frontmatter. */
|
|
88
|
+
required() {
|
|
89
|
+
return this.record(
|
|
90
|
+
"required",
|
|
91
|
+
this.present,
|
|
92
|
+
(neg) => neg ? `should not exist` : `is required`
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
/** Alias of {@link required}. */
|
|
96
|
+
exists() {
|
|
97
|
+
return this.record(
|
|
98
|
+
"exists",
|
|
99
|
+
this.present,
|
|
100
|
+
(neg) => neg ? `should not exist` : `should exist`
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
/** Assert the value is of the given type. */
|
|
104
|
+
type(expected) {
|
|
105
|
+
const actual = valueType(this.resolved);
|
|
106
|
+
return this.record(
|
|
107
|
+
"type",
|
|
108
|
+
actual === expected,
|
|
109
|
+
(neg) => neg ? `should not be of type ${expected}` : `should be of type ${expected} (was ${actual})`,
|
|
110
|
+
expected
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
/** Assert the value strictly equals `expected` (deep for arrays/objects). */
|
|
114
|
+
eq(expected) {
|
|
115
|
+
return this.record(
|
|
116
|
+
"eq",
|
|
117
|
+
deepEqual(this.resolved, expected),
|
|
118
|
+
(neg) => neg ? `should not equal ${display(expected)}` : `should equal ${display(expected)}`,
|
|
119
|
+
expected
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Assert the value is an array whose every element is in `allowed` (enum).
|
|
124
|
+
*
|
|
125
|
+
* Designed for catching tag notation drift: define the canonical set once
|
|
126
|
+
* and any stray / mistyped value is reported.
|
|
127
|
+
*
|
|
128
|
+
* ```ts
|
|
129
|
+
* fm.key("tags").subsetOf(["ok", "release", "blog"]);
|
|
130
|
+
* ```
|
|
131
|
+
*/
|
|
132
|
+
subsetOf(allowed) {
|
|
133
|
+
const v = this.resolved;
|
|
134
|
+
const isArray = Array.isArray(v);
|
|
135
|
+
const invalid = isArray ? v.filter((el) => !allowed.some((a) => deepEqual(el, a))) : [];
|
|
136
|
+
return this.record(
|
|
137
|
+
"subsetOf",
|
|
138
|
+
isArray && invalid.length === 0,
|
|
139
|
+
(neg) => {
|
|
140
|
+
if (!isArray) return `should be an array of values from ${display(allowed)}`;
|
|
141
|
+
if (neg) return `should contain values outside ${display(allowed)}`;
|
|
142
|
+
return invalid.length ? `all items should be one of ${display(allowed)} (invalid: ${display(invalid)})` : `all items should be one of ${display(allowed)}`;
|
|
143
|
+
},
|
|
144
|
+
allowed
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
/** Alias of {@link subsetOf}. */
|
|
148
|
+
only(allowed) {
|
|
149
|
+
return this.subsetOf(allowed);
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Apply matchers to every element of an array value.
|
|
153
|
+
*
|
|
154
|
+
* ```ts
|
|
155
|
+
* fm.key("tags").each.oneOf(["ok", "release"]);
|
|
156
|
+
* fm.key("tags").each.matches(/^[a-z0-9-]+$/);
|
|
157
|
+
* ```
|
|
158
|
+
*/
|
|
159
|
+
get each() {
|
|
160
|
+
const negated = this.#negated;
|
|
161
|
+
this.#negated = false;
|
|
162
|
+
return new EachAssertion(this.recorder, this.key, this.resolved, negated);
|
|
163
|
+
}
|
|
164
|
+
/** Assert the value is one of `allowed` (enum). */
|
|
165
|
+
oneOf(allowed) {
|
|
166
|
+
return this.record(
|
|
167
|
+
"oneOf",
|
|
168
|
+
allowed.some((a) => deepEqual(this.resolved, a)),
|
|
169
|
+
(neg) => neg ? `should not be one of ${display(allowed)}` : `should be one of ${display(allowed)}`,
|
|
170
|
+
allowed
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
/** Assert a string value matches the given regular expression. */
|
|
174
|
+
matches(pattern) {
|
|
175
|
+
const v = this.resolved;
|
|
176
|
+
const pass = typeof v === "string" && pattern.test(v);
|
|
177
|
+
return this.record(
|
|
178
|
+
"matches",
|
|
179
|
+
pass,
|
|
180
|
+
(neg) => neg ? `should not match ${pattern}` : `should match ${pattern}`,
|
|
181
|
+
pattern.source
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
/** Assert an array contains `item`, or a string contains the substring. */
|
|
185
|
+
has(item) {
|
|
186
|
+
const v = this.resolved;
|
|
187
|
+
let pass = false;
|
|
188
|
+
if (Array.isArray(v)) pass = v.some((el) => deepEqual(el, item));
|
|
189
|
+
else if (typeof v === "string" && typeof item === "string")
|
|
190
|
+
pass = v.includes(item);
|
|
191
|
+
return this.record(
|
|
192
|
+
"has",
|
|
193
|
+
pass,
|
|
194
|
+
(neg) => neg ? `should not have ${display(item)}` : `should have ${display(item)}`,
|
|
195
|
+
item
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
/** Assert an array/string contains all of `items`. */
|
|
199
|
+
hasAll(items) {
|
|
200
|
+
const v = this.resolved;
|
|
201
|
+
const pass = items.every((item) => contains(v, item));
|
|
202
|
+
return this.record(
|
|
203
|
+
"hasAll",
|
|
204
|
+
pass,
|
|
205
|
+
(neg) => neg ? `should not have all of ${display(items)}` : `should have all of ${display(items)}`,
|
|
206
|
+
items
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
/** Assert an array/string contains at least one of `items`. */
|
|
210
|
+
hasAny(items) {
|
|
211
|
+
const v = this.resolved;
|
|
212
|
+
const pass = items.some((item) => contains(v, item));
|
|
213
|
+
return this.record(
|
|
214
|
+
"hasAny",
|
|
215
|
+
pass,
|
|
216
|
+
(neg) => neg ? `should not have any of ${display(items)}` : `should have any of ${display(items)}`,
|
|
217
|
+
items
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
/** Assert the string/array length equals `n`. */
|
|
221
|
+
length(n) {
|
|
222
|
+
const len = lengthOf(this.resolved);
|
|
223
|
+
return this.record(
|
|
224
|
+
"length",
|
|
225
|
+
len === n,
|
|
226
|
+
(neg) => neg ? `length should not be ${n} (was ${len ?? "n/a"})` : `length should be ${n} (was ${len ?? "n/a"})`,
|
|
227
|
+
n
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
/** Assert the string/array length is at least `n`. */
|
|
231
|
+
lengthMin(n) {
|
|
232
|
+
const len = lengthOf(this.resolved);
|
|
233
|
+
return this.record(
|
|
234
|
+
"lengthMin",
|
|
235
|
+
len !== void 0 && len >= n,
|
|
236
|
+
(neg) => neg ? `length should be < ${n} (was ${len ?? "n/a"})` : `length should be >= ${n} (was ${len ?? "n/a"})`,
|
|
237
|
+
n
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
/** Assert the string/array length is at most `n`. */
|
|
241
|
+
lengthMax(n) {
|
|
242
|
+
const len = lengthOf(this.resolved);
|
|
243
|
+
return this.record(
|
|
244
|
+
"lengthMax",
|
|
245
|
+
len !== void 0 && len <= n,
|
|
246
|
+
(neg) => neg ? `length should be > ${n} (was ${len ?? "n/a"})` : `length should be <= ${n} (was ${len ?? "n/a"})`,
|
|
247
|
+
n
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
var EachAssertion = class {
|
|
252
|
+
constructor(recorder, key, value, negated) {
|
|
253
|
+
this.recorder = recorder;
|
|
254
|
+
this.key = key;
|
|
255
|
+
this.value = value;
|
|
256
|
+
this.#negated = negated;
|
|
257
|
+
}
|
|
258
|
+
recorder;
|
|
259
|
+
key;
|
|
260
|
+
value;
|
|
261
|
+
#negated;
|
|
262
|
+
/** Negate the next matcher in the chain. */
|
|
263
|
+
get not() {
|
|
264
|
+
this.#negated = true;
|
|
265
|
+
return this;
|
|
266
|
+
}
|
|
267
|
+
record(rule, perElement, describe, expected) {
|
|
268
|
+
const negated = this.#negated;
|
|
269
|
+
this.#negated = false;
|
|
270
|
+
const isArray = Array.isArray(this.value);
|
|
271
|
+
const invalid = isArray ? this.value.filter((el) => !perElement(el)) : [];
|
|
272
|
+
const rawPass = isArray && invalid.length === 0;
|
|
273
|
+
const ok = isArray ? negated ? !rawPass : rawPass : false;
|
|
274
|
+
this.recorder.push({
|
|
275
|
+
key: this.key,
|
|
276
|
+
rule: `each.${rule}`,
|
|
277
|
+
ok,
|
|
278
|
+
negated,
|
|
279
|
+
message: describe(negated, invalid, isArray),
|
|
280
|
+
value: this.value === MISSING ? void 0 : this.value,
|
|
281
|
+
expected
|
|
282
|
+
});
|
|
283
|
+
return this;
|
|
284
|
+
}
|
|
285
|
+
/** Every element must be one of `allowed` (enum over array contents). */
|
|
286
|
+
oneOf(allowed) {
|
|
287
|
+
return this.record(
|
|
288
|
+
"oneOf",
|
|
289
|
+
(el) => allowed.some((a) => deepEqual(el, a)),
|
|
290
|
+
(neg, invalid, isArray) => !isArray ? `should be an array of values from ${display(allowed)}` : neg ? `every item should be outside ${display(allowed)}` : `every item should be one of ${display(allowed)}${invalid.length ? ` (invalid: ${display(invalid)})` : ""}`,
|
|
291
|
+
allowed
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
/** Every element must be of the given type. */
|
|
295
|
+
type(expected) {
|
|
296
|
+
return this.record(
|
|
297
|
+
"type",
|
|
298
|
+
(el) => valueType(el) === expected,
|
|
299
|
+
(neg, invalid, isArray) => !isArray ? `should be an array of ${expected}` : neg ? `every item should not be of type ${expected}` : `every item should be of type ${expected}${invalid.length ? ` (invalid: ${display(invalid)})` : ""}`,
|
|
300
|
+
expected
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
/** Every (string) element must match the pattern. */
|
|
304
|
+
matches(pattern) {
|
|
305
|
+
return this.record(
|
|
306
|
+
"matches",
|
|
307
|
+
(el) => typeof el === "string" && pattern.test(el),
|
|
308
|
+
(neg, invalid, isArray) => !isArray ? `should be an array of strings matching ${pattern}` : neg ? `every item should not match ${pattern}` : `every item should match ${pattern}${invalid.length ? ` (invalid: ${display(invalid)})` : ""}`,
|
|
309
|
+
pattern.source
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
function contains(container, item) {
|
|
314
|
+
if (Array.isArray(container)) return container.some((el) => deepEqual(el, item));
|
|
315
|
+
if (typeof container === "string" && typeof item === "string")
|
|
316
|
+
return container.includes(item);
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
function deepEqual(a, b) {
|
|
320
|
+
if (a === b) return true;
|
|
321
|
+
if (a === null || b === null) return false;
|
|
322
|
+
if (typeof a !== "object" || typeof b !== "object") return false;
|
|
323
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
324
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
325
|
+
if (a.length !== b.length) return false;
|
|
326
|
+
return a.every((el, i) => deepEqual(el, b[i]));
|
|
327
|
+
}
|
|
328
|
+
const ao = a;
|
|
329
|
+
const bo = b;
|
|
330
|
+
const ak = Object.keys(ao);
|
|
331
|
+
const bk = Object.keys(bo);
|
|
332
|
+
if (ak.length !== bk.length) return false;
|
|
333
|
+
return ak.every((k) => deepEqual(ao[k], bo[k]));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// src/recorder.ts
|
|
337
|
+
var Recorder = class {
|
|
338
|
+
constructor(data) {
|
|
339
|
+
this.data = data;
|
|
340
|
+
}
|
|
341
|
+
data;
|
|
342
|
+
results = [];
|
|
343
|
+
/** Begin a chain of assertions against the given frontmatter key. */
|
|
344
|
+
key(name) {
|
|
345
|
+
const present = Object.prototype.hasOwnProperty.call(this.data, name);
|
|
346
|
+
return new KeyAssertion(this, name, this.data[name], present);
|
|
347
|
+
}
|
|
348
|
+
/** Internal: append a recorded rule result. */
|
|
349
|
+
push(result) {
|
|
350
|
+
this.results.push(result);
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
// src/check.ts
|
|
355
|
+
function check(source, rules) {
|
|
356
|
+
const parsed = parseFrontmatter(source);
|
|
357
|
+
const recorder = new Recorder(parsed.data);
|
|
358
|
+
if (parsed.error) {
|
|
359
|
+
recorder.push(parseErrorResult(`invalid frontmatter: ${parsed.error}`));
|
|
360
|
+
} else if (!parsed.hasFrontmatter) {
|
|
361
|
+
recorder.push(parseErrorResult("no frontmatter block found"));
|
|
362
|
+
} else {
|
|
363
|
+
rules(recorder);
|
|
364
|
+
}
|
|
365
|
+
const results = recorder.results;
|
|
366
|
+
const failures = results.filter((r) => !r.ok);
|
|
367
|
+
return {
|
|
368
|
+
ok: failures.length === 0,
|
|
369
|
+
results,
|
|
370
|
+
failures,
|
|
371
|
+
data: parsed.data
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
function checkData(data, rules) {
|
|
375
|
+
const recorder = new Recorder(data);
|
|
376
|
+
rules(recorder);
|
|
377
|
+
const results = recorder.results;
|
|
378
|
+
const failures = results.filter((r) => !r.ok);
|
|
379
|
+
return { ok: failures.length === 0, results, failures, data };
|
|
380
|
+
}
|
|
381
|
+
function parseErrorResult(message) {
|
|
382
|
+
return {
|
|
383
|
+
key: "(frontmatter)",
|
|
384
|
+
rule: "parse",
|
|
385
|
+
ok: false,
|
|
386
|
+
negated: false,
|
|
387
|
+
message,
|
|
388
|
+
value: void 0
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// src/config.ts
|
|
393
|
+
import { pathToFileURL } from "url";
|
|
394
|
+
import { access } from "fs/promises";
|
|
395
|
+
import { resolve } from "path";
|
|
396
|
+
function defineConfig(config) {
|
|
397
|
+
return config;
|
|
398
|
+
}
|
|
399
|
+
var CONFIG_NAMES = [
|
|
400
|
+
"fluorite.config.js",
|
|
401
|
+
"fluorite.config.mjs",
|
|
402
|
+
"fluorite.config.cjs"
|
|
403
|
+
];
|
|
404
|
+
async function resolveConfigPath(explicit, cwd = process.cwd()) {
|
|
405
|
+
if (explicit) return resolve(cwd, explicit);
|
|
406
|
+
for (const name of CONFIG_NAMES) {
|
|
407
|
+
const candidate = resolve(cwd, name);
|
|
408
|
+
if (await fileExists(candidate)) return candidate;
|
|
409
|
+
}
|
|
410
|
+
return void 0;
|
|
411
|
+
}
|
|
412
|
+
async function loadConfig(path) {
|
|
413
|
+
const mod = await import(pathToFileURL(path).href);
|
|
414
|
+
const config = mod.default ?? mod;
|
|
415
|
+
if (!config || typeof config.rules !== "function") {
|
|
416
|
+
throw new Error(
|
|
417
|
+
`Config at ${path} must export a default object with a "rules" function.`
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
return config;
|
|
421
|
+
}
|
|
422
|
+
async function fileExists(path) {
|
|
423
|
+
try {
|
|
424
|
+
await access(path);
|
|
425
|
+
return true;
|
|
426
|
+
} catch {
|
|
427
|
+
return false;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// src/report.ts
|
|
432
|
+
import pc from "picocolors";
|
|
433
|
+
function displayValue(value) {
|
|
434
|
+
try {
|
|
435
|
+
return JSON.stringify(value) ?? String(value);
|
|
436
|
+
} catch {
|
|
437
|
+
return String(value);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
function formatReports(reports, options = {}) {
|
|
441
|
+
const lines = [];
|
|
442
|
+
let passed = 0;
|
|
443
|
+
let failed = 0;
|
|
444
|
+
let ruleFailures = 0;
|
|
445
|
+
for (const { file, result } of reports) {
|
|
446
|
+
if (result.ok) {
|
|
447
|
+
passed++;
|
|
448
|
+
if (!options.quiet) lines.push(`${pc.green("\u2714")} ${file}`);
|
|
449
|
+
} else {
|
|
450
|
+
failed++;
|
|
451
|
+
lines.push(`${pc.red("\u2718")} ${file}`);
|
|
452
|
+
for (const failure of result.failures) {
|
|
453
|
+
ruleFailures++;
|
|
454
|
+
const where = pc.dim(`(value: ${displayValue(failure.value)})`);
|
|
455
|
+
lines.push(
|
|
456
|
+
` ${pc.red("\u2718")} ${pc.bold(failure.key)}: ${failure.message} ${where}`
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
const summary = [
|
|
462
|
+
`${reports.length} files`,
|
|
463
|
+
pc.green(`${passed} passed`),
|
|
464
|
+
failed > 0 ? pc.red(`${failed} failed`) : `${failed} failed`,
|
|
465
|
+
`${ruleFailures} rule failures`
|
|
466
|
+
].join(", ");
|
|
467
|
+
lines.push("");
|
|
468
|
+
lines.push(summary);
|
|
469
|
+
return lines.join("\n");
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export {
|
|
473
|
+
parseFrontmatter,
|
|
474
|
+
KeyAssertion,
|
|
475
|
+
EachAssertion,
|
|
476
|
+
Recorder,
|
|
477
|
+
check,
|
|
478
|
+
checkData,
|
|
479
|
+
defineConfig,
|
|
480
|
+
resolveConfigPath,
|
|
481
|
+
loadConfig,
|
|
482
|
+
formatReports
|
|
483
|
+
};
|
|
484
|
+
//# sourceMappingURL=chunk-2QW4LSG5.js.map
|