@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 +411 -0
- package/README.md +203 -4
- package/dist/{chunk-2QW4LSG5.js → chunk-PTAYLHQM.js} +43 -2
- package/dist/chunk-PTAYLHQM.js.map +1 -0
- package/dist/cli.cjs +46 -3
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +4 -2
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +42 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +16 -1
- package/dist/index.d.ts +16 -1
- package/dist/index.js +1 -1
- package/package.json +6 -2
- package/dist/chunk-2QW4LSG5.js.map +0 -1
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.
|
|
388
|
+
2. GitHub → repo **Settings → Environments → New 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: *(
|
|
202
|
-
|
|
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
|
|