@yukyu30/fluorite 0.1.0 → 0.1.2

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/README.ja.md ADDED
@@ -0,0 +1,411 @@
1
+ # fluorite
2
+
3
+ [English](./README.md) · **日本語**
4
+
5
+ Markdown の **フロントマター** を、読みやすくチェーンできる DSL で検査・検証
6
+ します。**ライブラリ** としても **CLI** としても使えます。
7
+
8
+ ```md
9
+ ---
10
+ date: 2026-06-07
11
+ tags: ["ok", "ng"]
12
+ title: "これはタイトルです"
13
+ ---
14
+ ```
15
+
16
+ ```ts
17
+ import { check } from "@yukyu30/fluorite";
18
+
19
+ const result = check(markdown, (fm) => {
20
+ fm.key("title").required().type("string").lengthMin(10);
21
+ fm.key("date").required().isoDate(); // クォート無しの日付も文字列のまま → YYYY-MM-DD を検証
22
+ fm.key("tags").not.has("ng"); // ← "ng" があるので失敗(赤)
23
+ });
24
+
25
+ result.ok; // false
26
+ result.failures; // [{ key: "tags", rule: "has", negated: true, ok: false, ... }]
27
+ ```
28
+
29
+ チェックは例外を投げません。各ルールは**結果オブジェクト**(成否とその理由)を
30
+ 返すので、まとめて赤/緑でレポートできます。
31
+
32
+ ## インストール
33
+
34
+ **Node.js 18 以上** が必要です。パッケージは ESM と CommonJS の両ビルドと
35
+ TypeScript 型定義を同梱しています。
36
+
37
+ ```sh
38
+ # 開発依存として — CI / pre-commit での lint 用途で一般的
39
+ npm install -D @yukyu30/fluorite
40
+
41
+ # 実行時依存として — ライブラリを実行時に呼び出す場合
42
+ npm install @yukyu30/fluorite
43
+ ```
44
+
45
+ 他のパッケージマネージャ:
46
+
47
+ ```sh
48
+ pnpm add -D @yukyu30/fluorite
49
+ yarn add -D @yukyu30/fluorite
50
+ ```
51
+
52
+ インストールせずに CLI を実行:
53
+
54
+ ```sh
55
+ npx @yukyu30/fluorite check "docs/**/*.md"
56
+ ```
57
+
58
+ ## ユースケース
59
+
60
+ fluorite が実際に使われる場面ごとのレシピです。ここで使うマッチャの詳細は
61
+ [ライブラリ API](#ライブラリ-api) を参照してください。
62
+
63
+ ### 1. CI でブログ / ドキュメントのフロントマターを lint する
64
+
65
+ すべての記事のフロントマターを一貫させ、ズレたらビルドを失敗させます。ルールを
66
+ 設定ファイルに置き、`fluorite check` をパイプラインに組み込みます。
67
+
68
+ ```js
69
+ // fluorite.config.mjs
70
+ import { defineConfig } from "@yukyu30/fluorite";
71
+
72
+ export default defineConfig({
73
+ include: ["content/**/*.md"],
74
+ exclude: ["**/drafts/**"],
75
+ rules: (fm) => {
76
+ fm.key("title").required().type("string").lengthMin(10).lengthMax(70);
77
+ fm.key("date").required().isoDate(); // YYYY-MM-DD(書かれた形式を検証)
78
+ fm.key("description").required().lengthMin(50).lengthMax(160); // SEO に有効
79
+ fm.key("draft").type("boolean");
80
+ },
81
+ });
82
+ ```
83
+
84
+ ```jsonc
85
+ // package.json
86
+ {
87
+ "scripts": {
88
+ "lint:content": "fluorite check"
89
+ }
90
+ }
91
+ ```
92
+
93
+ ```yaml
94
+ # .github/workflows/content.yml
95
+ - run: npm ci
96
+ - run: npm run lint:content # 失敗があれば exit 1 → CI が赤に
97
+ ```
98
+
99
+ ### 2. タグの誤記を検出し、語彙を固定する
100
+
101
+ タグはすぐにブレます(`Blog` と `blog`、紛れ込んだ `ng`)。正規の集合を固定
102
+ すれば、fluorite が問題の値を正確に名指しします。あるいは表記ルールだけを
103
+ 強制することもできます。
104
+
105
+ ```js
106
+ const TAGS = ["release", "blog", "news", "guide"];
107
+
108
+ export default defineConfig({
109
+ rules: (fm) => {
110
+ fm.key("tags").required().type("array").subsetOf(TAGS);
111
+ // tags: ["blog", "New", "ng"]
112
+ // → all items should be one of [...] (invalid: ["New","ng"])
113
+ },
114
+ });
115
+
116
+ // …リストを保守したくなければ、小文字のケバブケースだけを要求してもよい:
117
+ fm.key("tags").each.matches(/^[a-z0-9-]+$/);
118
+ ```
119
+
120
+ ### 3. 条件付きルールで公開ワークフローを強制する
121
+
122
+ ルールセットはただの関数で、`fm.data` はパース済みのフロントマターです。記事が
123
+ 公開状態になったときだけ、より厳しいルールを適用できます。
124
+
125
+ ```js
126
+ export default defineConfig({
127
+ rules: (fm) => {
128
+ fm.key("status").required().oneOf(["draft", "review", "published"]);
129
+
130
+ if (fm.data.status === "published") {
131
+ fm.key("date").required().isoDate();
132
+ fm.key("author").required().type("string");
133
+ fm.key("description").required().lengthMin(50);
134
+ fm.key("tags").not.has("wip"); // 作業中タグのまま公開させない
135
+ }
136
+ },
137
+ });
138
+ ```
139
+
140
+ ### 4. 識別子・スラッグ・日付をパターンで検証する
141
+
142
+ ```js
143
+ fm.key("slug").required().matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/); // ケバブケース
144
+ fm.key("date").required().isoDate(); // YYYY-MM-DD のカレンダー日付
145
+ fm.key("version").matches(/^\d+\.\d+\.\d+$/); // semver
146
+ ```
147
+
148
+ > **クォート無しの日付も文字列のまま保持されます。** YAML は通常
149
+ > `date: 2026-06-07` を `Date` オブジェクトへ変換してしまい、どう書かれていたか
150
+ > が失われて形式を検証できません。fluorite は `!!timestamp` を含まないスキーマで
151
+ > フロントマターをパースし、日付を文字どおり文字列として保持します。そのため
152
+ > `isoDate()`(あるいは単なる `matches(/^\d{4}-\d{2}-\d{2}$/)`)で、書かれた
153
+ > `YYYY-MM-DD` 形式を、すべての値をクォートすることなく検証できます。時刻付き
154
+ > (`2026-06-07 10:30:00`)や、あり得ない日付(`2026-02-30`)は失敗として
155
+ > 報告されます。
156
+
157
+ ### 5. プログラムからフロントマターをチェックする(SSG / ビルドスクリプト)
158
+
159
+ サイトを生成するときや独自のレポートが欲しいときは、ライブラリを直接使います。
160
+ `check` は例外を投げず、収集できる結果を返します。
161
+
162
+ ```ts
163
+ import { readFile } from "node:fs/promises";
164
+ import { glob } from "tinyglobby";
165
+ import { check } from "@yukyu30/fluorite";
166
+
167
+ const failed: string[] = [];
168
+ for (const file of await glob("content/**/*.md")) {
169
+ const result = check(await readFile(file, "utf8"), (fm) => {
170
+ fm.key("title").required().lengthMin(10);
171
+ fm.key("tags").type("array").subsetOf(["blog", "news"]);
172
+ });
173
+
174
+ if (!result.ok) {
175
+ failed.push(file);
176
+ for (const f of result.failures) {
177
+ console.error(`${file} → ${f.key}: ${f.message}`);
178
+ }
179
+ }
180
+ }
181
+ if (failed.length) process.exit(1);
182
+ ```
183
+
184
+ ### 6. Markdown 以外のデータを検証する(CMS / API のペイロード)
185
+
186
+ すでにオブジェクトをパース済みなら、Markdown のステップを飛ばして `checkData`
187
+ を使えます。ヘッドレス CMS や API、YAML/JSON ローダー由来のコンテンツに便利です。
188
+
189
+ ```ts
190
+ import { checkData } from "@yukyu30/fluorite";
191
+
192
+ const entry = await cms.getEntry("home"); // { title, tags, ... }
193
+ const result = checkData(entry, (fm) => {
194
+ fm.key("title").required().type("string");
195
+ fm.key("tags").subsetOf(["featured", "evergreen"]);
196
+ });
197
+ ```
198
+
199
+ ### 7. 取り込み前に不正なフロントマターをブロックする(pre-commit)
200
+
201
+ ```jsonc
202
+ // package.json — lint-staged + husky の場合
203
+ {
204
+ "lint-staged": {
205
+ "content/**/*.md": "fluorite check"
206
+ }
207
+ }
208
+ ```
209
+
210
+ ## ライブラリ API
211
+
212
+ ### `check(source, rules) => CheckResult`
213
+
214
+ Markdown 文字列 `source` からフロントマターを取り出し、`rules` コールバックを
215
+ 実行します。引数 `fm` はレコーダーで、呼んだマッチャごとに結果が 1 件記録されます。
216
+
217
+ ```ts
218
+ const result = check(source, (fm) => {
219
+ fm.key("title").required().lengthMin(10);
220
+ });
221
+ ```
222
+
223
+ `CheckResult`:
224
+
225
+ | フィールド | 説明 |
226
+ | ---------- | ------------------------------------------ |
227
+ | `ok` | すべてのルールが通れば `true` |
228
+ | `results` | 記録されたすべての `RuleResult`(順序どおり)|
229
+ | `failures` | 失敗した `RuleResult` のみ |
230
+ | `data` | パース済みのフロントマターオブジェクト |
231
+
232
+ フロントマターブロックが無い、または YAML が壊れている場合は、例外ではなく失敗
233
+ 扱いの `parse` ルールとして記録されます。
234
+
235
+ ### `checkData(data, rules) => CheckResult`
236
+
237
+ `check` と同じですが、すでにパース済みのフロントマターオブジェクトに対して
238
+ 実行します。
239
+
240
+ ### マッチャ
241
+
242
+ `fm.key("name")` でチェーンを始めます。`.not` 修飾子は**次のマッチャだけ**を
243
+ 否定し、その後リセットされます。
244
+
245
+ **存在 / 型**
246
+
247
+ - `required()` / `exists()` — キーが存在する
248
+ - `type(t)` — `"string" | "number" | "boolean" | "array" | "object" | "null" | "date"`
249
+
250
+ **値**
251
+
252
+ - `eq(value)` — 深い等価比較
253
+ - `oneOf([...])` — 許可された集合のいずれか(enum)
254
+ - `matches(regexp)` — 文字列がパターンに一致
255
+ - `isoDate()` — 文字列が `YYYY-MM-DD` のカレンダー日付(時刻付きや
256
+ `2026-02-30` のようなあり得ない日付は弾く)
257
+
258
+ **包含(配列・文字列)**
259
+
260
+ - `has(value)` — 配列が要素を含む、または文字列が部分文字列を含む
261
+ - `hasAll([...])` — すべての項目を含む
262
+ - `hasAny([...])` — 少なくとも 1 つを含む
263
+
264
+ **enum / 配列の中身** — タグ表記のブレを検出
265
+
266
+ - `subsetOf([...])` / `only([...])` — 値が配列で、その全要素が許可集合に含まれる。
267
+ 失敗時は問題の(誤記の)値を列挙
268
+ - `each.oneOf([...])` — `subsetOf` と同じことを、要素ごとのアクセサ経由で
269
+ - `each.type(t)` — すべての要素が型 `t`
270
+ - `each.matches(regexp)` — すべての(文字列)要素がパターンに一致
271
+ - `each.isoDate()` — すべての要素が `YYYY-MM-DD` の日付
272
+
273
+ **長さ(配列・文字列)**
274
+
275
+ - `length(n)` — 長さが `n` と等しい
276
+ - `lengthMin(n)` — 長さ `>= n`
277
+ - `lengthMax(n)` — 長さ `<= n`
278
+
279
+ ```ts
280
+ check(source, (fm) => {
281
+ fm.key("status").oneOf(["draft", "published"]);
282
+ fm.key("slug").matches(/^[a-z0-9-]+$/);
283
+ fm.key("date").isoDate();
284
+ fm.key("tags").type("array").hasAll(["blog"]).not.has("ng");
285
+ fm.key("summary").lengthMin(20).lengthMax(160);
286
+ });
287
+ ```
288
+
289
+ ### タグの enum を定義する
290
+
291
+ タグはすぐにブレます(`Blog` と `blog`、紛れ込んだ `ng`)。正規の語彙を一度
292
+ 定義すれば、`subsetOf` がその外にあるものを検出し、失敗メッセージが問題の値を
293
+ 正確に名指しします。
294
+
295
+ ```ts
296
+ const TAGS = ["ok", "release", "blog", "news"];
297
+
298
+ check(source, (fm) => {
299
+ fm.key("tags").type("array").subsetOf(TAGS);
300
+ });
301
+ // tags: ["ok", "Blog", "ng"]
302
+ // → all items should be one of [...] (invalid: ["Blog","ng"])
303
+
304
+ // 固定リストの代わりに表記ルールを強制することもできる:
305
+ check(source, (fm) => {
306
+ fm.key("tags").each.matches(/^[a-z0-9-]+$/); // 小文字のケバブケースのみ
307
+ });
308
+ ```
309
+
310
+ ## CLI
311
+
312
+ ```sh
313
+ fluorite check "docs/**/*.md" [--config <path>] [--quiet]
314
+ ```
315
+
316
+ - glob でファイルを集め、各ファイルのフロントマターをチェックし、赤/緑のレポート
317
+ を表示します。いずれかのファイルが失敗すると終了コード `1` で終わります(CI に最適)。
318
+ - ルールは設定ファイル(`fluorite.config.{js,mjs,cjs}` または `--config`)から
319
+ 読み込みます。
320
+
321
+ ```
322
+ ✘ docs/bad.md
323
+ ✘ title: length should be >= 10 (was 2) (value: "短い")
324
+ ✘ tags: should not have "ng" (value: ["ok","ng"])
325
+ ✔ docs/good.md
326
+
327
+ 2 files, 1 passed, 1 failed, 2 rule failures
328
+ ```
329
+
330
+ オプション:
331
+
332
+ - `-c, --config <path>` — 設定ファイルのパス
333
+ - `-q, --quiet` — 失敗のあるファイルのみ表示
334
+ - `-h, --help` — ヘルプを表示
335
+
336
+ ### 設定ファイル
337
+
338
+ ```js
339
+ // fluorite.config.mjs
340
+ import { defineConfig } from "@yukyu30/fluorite";
341
+
342
+ export default defineConfig({
343
+ include: ["docs/**/*.md"],
344
+ exclude: ["**/node_modules/**"],
345
+ rules: (fm) => {
346
+ fm.key("title").required().type("string").lengthMin(10);
347
+ fm.key("tags").required().type("array").not.has("ng");
348
+ },
349
+ });
350
+ ```
351
+
352
+ CLI の位置引数パターンは `include` を上書きします。どちらも指定しない場合は
353
+ `**/*.md` が使われます。
354
+
355
+ ## 開発
356
+
357
+ ```sh
358
+ npm test # テストスイートを実行(vitest)
359
+ npm run coverage # カバレッジ付きで実行(v8)
360
+ npm run typecheck # tsc --noEmit
361
+ npm run build # tsup で dist/ にバンドル
362
+ ```
363
+
364
+ テストは、マッチャ DSL(すべてのマッチャ、`.not` 形、`each.*` アクセサ)、
365
+ フロントマターのパース、`check` / `checkData` の集計結果、設定の読み込み、
366
+ レポート整形、そして CLI(プロセス内および、ビルド済みの `dist/cli.js` を実際に
367
+ 起動する end-to-end)をカバーしています。
368
+
369
+ ## リリース
370
+
371
+ リリースは [tagpr](https://github.com/Songmu/tagpr) と `npm publish`
372
+ (`.github/workflows/tagpr.yml`)で自動化されています。
373
+
374
+ 1. `main` にコミットを push します。tagpr が **Release PR** を作成/更新し、
375
+ `package.json` のバージョンと `CHANGELOG.md` を更新します。
376
+ 2. Release PR に `minor` / `major` ラベルを付けてバンプを制御します(既定: patch)。
377
+ 3. Release PR をマージすると、tagpr が `vX.Y.Z` タグと GitHub Release を作成し、
378
+ 同じワークフロー実行が npm へパッケージを公開します。
379
+
380
+ 公開には npm の **trusted publishing (OIDC)** を使用します。`NPM_TOKEN`
381
+ シークレットは不要で、provenance(来歴)が自動的に添付されます。
382
+
383
+ 初回のみのセットアップ:
384
+
385
+ 1. **最初の公開**(trusted publisher を紐付ける前にパッケージが存在している
386
+ 必要があります)。手元のマシンから:
387
+ ```sh
388
+ npm login
389
+ npm publish --access public
390
+ ```
391
+ 2. GitHub → リポジトリの **Settings → Environments → New environment** で
392
+ `release` という名前の環境を作成します。必要なら保護ルール(例: 必須
393
+ レビュアー)を追加し、公開を手動承認待ちにできます。`publish` ジョブは
394
+ この環境で実行されます。
395
+ 3. npmjs.com → 対象パッケージ → **Settings → Trusted Publisher → GitHub
396
+ Actions** で次を設定します:
397
+ - Organization or user: `yukyu30`
398
+ - Repository: `fluorite`
399
+ - Workflow filename: `tagpr.yml`
400
+ - Environment: `release` *(手順 2 と一致させる)*
401
+ 4. GitHub → Settings → Actions → General で "Allow GitHub Actions to create
402
+ and approve pull requests" を有効化します(tagpr 用)。
403
+
404
+ これ以降、マージされた Release PR ごとにトークン無しで自動公開されます。
405
+ `tagpr` ジョブは `main` への push ごとに Release PR を維持します。`release`
406
+ 環境でゲートされるのは別の `publish` ジョブだけなので、保護ルールは公開だけに
407
+ 適用されます。
408
+
409
+ ## ライセンス
410
+
411
+ MIT
package/README.md CHANGED
@@ -1,10 +1,13 @@
1
1
  # fluorite
2
2
 
3
+ **English** · [日本語](./README.ja.md)
4
+
3
5
  Inspect and validate Markdown **frontmatter** with a readable, chainable DSL —
4
6
  usable as a **library** and a **CLI**.
5
7
 
6
8
  ```md
7
9
  ---
10
+ date: 2026-06-07
8
11
  tags: ["ok", "ng"]
9
12
  title: "これはタイトルです"
10
13
  ---
@@ -15,6 +18,7 @@ import { check } from "@yukyu30/fluorite";
15
18
 
16
19
  const result = check(markdown, (fm) => {
17
20
  fm.key("title").required().type("string").lengthMin(10);
21
+ fm.key("date").required().isoDate(); // unquoted YAML dates stay strings → validated as YYYY-MM-DD
18
22
  fm.key("tags").not.has("ng"); // ← fails (red) because "ng" is present
19
23
  });
20
24
 
@@ -27,10 +31,180 @@ reason) so you can collect and report them as red/green.
27
31
 
28
32
  ## Install
29
33
 
34
+ Requires **Node.js 18+**. The package ships both ESM and CommonJS builds with
35
+ TypeScript types.
36
+
30
37
  ```sh
38
+ # As a dev dependency — typical for linting in CI / pre-commit
39
+ npm install -D @yukyu30/fluorite
40
+
41
+ # As a runtime dependency — if you call the library at runtime
31
42
  npm install @yukyu30/fluorite
32
43
  ```
33
44
 
45
+ Other package managers:
46
+
47
+ ```sh
48
+ pnpm add -D @yukyu30/fluorite
49
+ yarn add -D @yukyu30/fluorite
50
+ ```
51
+
52
+ Or run the CLI without installing:
53
+
54
+ ```sh
55
+ npx @yukyu30/fluorite check "docs/**/*.md"
56
+ ```
57
+
58
+ ## Use cases
59
+
60
+ Concrete recipes for the things people reach for fluorite to do. The matcher
61
+ vocabulary used here is documented in full under [Library API](#library-api).
62
+
63
+ ### 1. Lint blog / docs frontmatter in CI
64
+
65
+ Keep every post's frontmatter consistent and fail the build when it drifts.
66
+ Put the rules in a config file and wire `fluorite check` into your pipeline.
67
+
68
+ ```js
69
+ // fluorite.config.mjs
70
+ import { defineConfig } from "@yukyu30/fluorite";
71
+
72
+ export default defineConfig({
73
+ include: ["content/**/*.md"],
74
+ exclude: ["**/drafts/**"],
75
+ rules: (fm) => {
76
+ fm.key("title").required().type("string").lengthMin(10).lengthMax(70);
77
+ fm.key("date").required().isoDate(); // YYYY-MM-DD, written form validated
78
+ fm.key("description").required().lengthMin(50).lengthMax(160); // good for SEO
79
+ fm.key("draft").type("boolean");
80
+ },
81
+ });
82
+ ```
83
+
84
+ ```jsonc
85
+ // package.json
86
+ {
87
+ "scripts": {
88
+ "lint:content": "fluorite check"
89
+ }
90
+ }
91
+ ```
92
+
93
+ ```yaml
94
+ # .github/workflows/content.yml
95
+ - run: npm ci
96
+ - run: npm run lint:content # exits 1 on any failure → red CI
97
+ ```
98
+
99
+ ### 2. Catch tag typos & enforce a tag vocabulary
100
+
101
+ Tags drift fast (`Blog` vs `blog`, a stray `ng`). Pin the canonical set and
102
+ fluorite names the exact offending values, or enforce a notation rule instead.
103
+
104
+ ```js
105
+ const TAGS = ["release", "blog", "news", "guide"];
106
+
107
+ export default defineConfig({
108
+ rules: (fm) => {
109
+ fm.key("tags").required().type("array").subsetOf(TAGS);
110
+ // tags: ["blog", "New", "ng"]
111
+ // → all items should be one of [...] (invalid: ["New","ng"])
112
+ },
113
+ });
114
+
115
+ // …or don't maintain a list — just require lowercase kebab-case:
116
+ fm.key("tags").each.matches(/^[a-z0-9-]+$/);
117
+ ```
118
+
119
+ ### 3. Enforce a publish workflow with conditional rules
120
+
121
+ The rule set is just a function, and `fm.data` is the parsed frontmatter — so
122
+ you can apply stricter rules only once a post is marked published.
123
+
124
+ ```js
125
+ export default defineConfig({
126
+ rules: (fm) => {
127
+ fm.key("status").required().oneOf(["draft", "review", "published"]);
128
+
129
+ if (fm.data.status === "published") {
130
+ fm.key("date").required().isoDate();
131
+ fm.key("author").required().type("string");
132
+ fm.key("description").required().lengthMin(50);
133
+ fm.key("tags").not.has("wip"); // can't ship a work-in-progress tag
134
+ }
135
+ },
136
+ });
137
+ ```
138
+
139
+ ### 4. Validate identifiers, slugs & dates with patterns
140
+
141
+ ```js
142
+ fm.key("slug").required().matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/); // kebab-case
143
+ fm.key("date").required().isoDate(); // YYYY-MM-DD calendar date
144
+ fm.key("version").matches(/^\d+\.\d+\.\d+$/); // semver
145
+ ```
146
+
147
+ > **Unquoted dates stay strings.** YAML normally coerces `date: 2026-06-07`
148
+ > into a `Date` object, which erases how it was written and makes the format
149
+ > impossible to lint. fluorite parses frontmatter with a schema that keeps
150
+ > dates verbatim, so `isoDate()` — or a plain `matches(/^\d{4}-\d{2}-\d{2}$/)`
151
+ > — validates the written `YYYY-MM-DD` form with no need to quote every value.
152
+ > A full timestamp (`2026-06-07 10:30:00`) or an impossible date
153
+ > (`2026-02-30`) is reported as a failure.
154
+
155
+ ### 5. Check frontmatter programmatically (SSG / build script)
156
+
157
+ Use the library directly when you generate a site or want a custom report.
158
+ `check` never throws — it returns a result you can collect.
159
+
160
+ ```ts
161
+ import { readFile } from "node:fs/promises";
162
+ import { glob } from "tinyglobby";
163
+ import { check } from "@yukyu30/fluorite";
164
+
165
+ const failed: string[] = [];
166
+ for (const file of await glob("content/**/*.md")) {
167
+ const result = check(await readFile(file, "utf8"), (fm) => {
168
+ fm.key("title").required().lengthMin(10);
169
+ fm.key("tags").type("array").subsetOf(["blog", "news"]);
170
+ });
171
+
172
+ if (!result.ok) {
173
+ failed.push(file);
174
+ for (const f of result.failures) {
175
+ console.error(`${file} → ${f.key}: ${f.message}`);
176
+ }
177
+ }
178
+ }
179
+ if (failed.length) process.exit(1);
180
+ ```
181
+
182
+ ### 6. Validate data that isn't Markdown (CMS / API payloads)
183
+
184
+ Already have the object parsed? Skip the Markdown step with `checkData` — handy
185
+ for content coming from a headless CMS, an API, or a YAML/JSON loader.
186
+
187
+ ```ts
188
+ import { checkData } from "@yukyu30/fluorite";
189
+
190
+ const entry = await cms.getEntry("home"); // { title, tags, ... }
191
+ const result = checkData(entry, (fm) => {
192
+ fm.key("title").required().type("string");
193
+ fm.key("tags").subsetOf(["featured", "evergreen"]);
194
+ });
195
+ ```
196
+
197
+ ### 7. Block bad frontmatter before it lands (pre-commit)
198
+
199
+ ```jsonc
200
+ // package.json — with lint-staged + husky
201
+ {
202
+ "lint-staged": {
203
+ "content/**/*.md": "fluorite check"
204
+ }
205
+ }
206
+ ```
207
+
34
208
  ## Library API
35
209
 
36
210
  ### `check(source, rules) => CheckResult`
@@ -69,13 +243,15 @@ next** matcher, then resets.
69
243
  **Existence / type**
70
244
 
71
245
  - `required()` / `exists()` — the key is present
72
- - `type(t)` — `"string" | "number" | "boolean" | "array" | "object" | "null"`
246
+ - `type(t)` — `"string" | "number" | "boolean" | "array" | "object" | "null" | "date"`
73
247
 
74
248
  **Value**
75
249
 
76
250
  - `eq(value)` — deep-equality
77
251
  - `oneOf([...])` — value is one of the allowed set (enum)
78
252
  - `matches(regexp)` — string matches the pattern
253
+ - `isoDate()` — string is a `YYYY-MM-DD` calendar date (rejects timestamps &
254
+ impossible dates like `2026-02-30`)
79
255
 
80
256
  **Containment (arrays & strings)**
81
257
 
@@ -90,6 +266,7 @@ next** matcher, then resets.
90
266
  - `each.oneOf([...])` — same as `subsetOf`, via the per-element accessor
91
267
  - `each.type(t)` — every element is of type `t`
92
268
  - `each.matches(regexp)` — every (string) element matches the pattern
269
+ - `each.isoDate()` — every element is a `YYYY-MM-DD` date
93
270
 
94
271
  **Length (arrays & strings)**
95
272
 
@@ -101,6 +278,7 @@ next** matcher, then resets.
101
278
  check(source, (fm) => {
102
279
  fm.key("status").oneOf(["draft", "published"]);
103
280
  fm.key("slug").matches(/^[a-z0-9-]+$/);
281
+ fm.key("date").isoDate();
104
282
  fm.key("tags").type("array").hasAll(["blog"]).not.has("ng");
105
283
  fm.key("summary").lengthMin(20).lengthMax(160);
106
284
  });
@@ -171,6 +349,20 @@ export default defineConfig({
171
349
  Positional patterns on the CLI override `include`. If neither is given,
172
350
  `**/*.md` is used.
173
351
 
352
+ ## Development
353
+
354
+ ```sh
355
+ npm test # run the test suite (vitest)
356
+ npm run coverage # run with a coverage report (v8)
357
+ npm run typecheck # tsc --noEmit
358
+ npm run build # bundle to dist/ with tsup
359
+ ```
360
+
361
+ The suite covers the matcher DSL (every matcher, its `.not` form, and the
362
+ `each.*` accessor), frontmatter parsing, the aggregate `check` / `checkData`
363
+ results, config loading, the report formatter, and the CLI — both in-process
364
+ and by running the built `dist/cli.js` end-to-end.
365
+
174
366
  ## Releasing
175
367
 
176
368
  Releases are automated with [tagpr](https://github.com/Songmu/tagpr) +
@@ -193,16 +385,23 @@ One-time setup:
193
385
  npm login
194
386
  npm publish --access public
195
387
  ```
196
- 2. On npmjs.com the package**SettingsTrusted Publisher → GitHub
388
+ 2. GitHubrepo **SettingsEnvironmentsNew environment** named
389
+ `release`. Optionally add protection rules (e.g. required reviewers) so a
390
+ publish waits for manual approval. The `publish` job runs in this
391
+ environment.
392
+ 3. On npmjs.com → the package → **Settings → Trusted Publisher → GitHub
197
393
  Actions**, set:
198
394
  - Organization or user: `yukyu30`
199
395
  - Repository: `fluorite`
200
396
  - Workflow filename: `tagpr.yml`
201
- - Environment: *(leave empty)*
202
- 3. GitHub → Settings → Actions → General → enable
397
+ - Environment: `release` *(must match step 2)*
398
+ 4. GitHub → Settings → Actions → General → enable
203
399
  "Allow GitHub Actions to create and approve pull requests" (for tagpr).
204
400
 
205
401
  After that, every merged Release PR publishes automatically with no token.
402
+ The `tagpr` job maintains the Release PR on every push to `main`; only the
403
+ separate `publish` job is gated by the `release` environment, so protection
404
+ rules apply to publishing alone.
206
405
 
207
406
  ## License
208
407