@riebeckite/plugin-diagnostics 0.0.1 → 0.0.5
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.md +7 -1
- package/README_ja.md +5 -0
- package/dist/bin.js +156 -68
- package/dist/index.d.ts +5 -0
- package/dist/index.js +156 -68
- package/dist/src/checks/analytics.d.ts +13 -0
- package/dist/src/checks/content_identity.d.ts +14 -0
- package/dist/src/checks/shared.d.ts +1 -0
- package/dist/src/types.d.ts +1 -0
- package/package.json +6 -4
package/README.md
CHANGED
|
@@ -48,8 +48,11 @@ export default defineConfig({
|
|
|
48
48
|
| `publish-conflict` | `warning` | `publish: true` combined with `draft: true` / `private: true` |
|
|
49
49
|
| `duplicate-title` | `warning` | Multiple published notes share a title |
|
|
50
50
|
| `slug-collision` | `error` | Slugs collide case-insensitively |
|
|
51
|
+
| `duplicate-content-id` | `error` | Published notes share a stable content ID (`id`/`uid`) |
|
|
52
|
+
| `invalid-content-id` | `error` | `id`/`uid` frontmatter violates the stable content ID contract |
|
|
51
53
|
| `excluded-public` | `warning` | Excluded note is marked `publish: true` |
|
|
52
54
|
| `publish-boundary` | `warning` | Published content links to or embeds non-published content |
|
|
55
|
+
| `analytics-untracked` | `info` | Published note has no stable `id` and the analytics plugin will not track it (enabled with `reportAnalyticsCoverage`, or automatically when the config enables the analytics plugin) |
|
|
53
56
|
| `internal-error` | `error` | Content analysis failed |
|
|
54
57
|
|
|
55
58
|
## Options
|
|
@@ -59,6 +62,7 @@ export default defineConfig({
|
|
|
59
62
|
| `failOnError` | `boolean` | `false` | Fail the build on error-level diagnostics |
|
|
60
63
|
| `reportUnusedAssets` | `boolean` | `false` | Report unreferenced images |
|
|
61
64
|
| `reportOrphans` | `boolean` | `false` | Report published notes without incoming links |
|
|
65
|
+
| `reportAnalyticsCoverage` | `boolean` | `false` | Report published notes the analytics plugin will not track (auto-enabled when the resolved config enables the analytics plugin) |
|
|
62
66
|
| `requiredFrontmatter` | `string[]` | `[]` | Required frontmatter fields |
|
|
63
67
|
| `severity` | `Partial<Record<DiagnosticCode, DiagnosticSeverity>>` | table above | Override severity per code |
|
|
64
68
|
| `exclude` | `string[]` | `[]` | Extra exclude globs |
|
|
@@ -79,6 +83,7 @@ riebeckite-diagnostics --content ./content --report-orphans
|
|
|
79
83
|
| `--publish-strategy <mode>` | `explicit` \| `selective` (default: `selective`) |
|
|
80
84
|
| `--report-unused-assets` | Report images never referenced by any note |
|
|
81
85
|
| `--report-orphans` | Report published notes with no incoming links |
|
|
86
|
+
| `--report-analytics-coverage` | Report published notes without a stable content ID (auto-enabled for `--config` when the config enables the analytics plugin) |
|
|
82
87
|
| `--required-frontmatter <f>` | Comma-separated required frontmatter fields |
|
|
83
88
|
| `--fail-on-error` | Exit with code 1 when errors are found (default) |
|
|
84
89
|
| `--exit-on <severity>` | Exit with code 1 at/above severity (`info` \| `warning` \| `error`) |
|
|
@@ -115,6 +120,7 @@ assertNoErrors(report);
|
|
|
115
120
|
- `diagnostics(options?)` / `diagnosticsPlugin` — plugin factory
|
|
116
121
|
- `runDiagnostics(target, options?)` — run the analysis directly
|
|
117
122
|
- `analyzeContent(config, options?)` — raw analysis returning `Diagnostic[]`
|
|
123
|
+
- `hasEnabledAnalyticsPlugin(config?)` — whether the resolved config enables the analytics plugin
|
|
118
124
|
- Report helpers: `buildReport`, `formatDiagnostics`, `groupByCode`,
|
|
119
125
|
`summarize`, `assertNoErrors`, `DiagnosticsFailure`
|
|
120
126
|
- Types: `DiagnosticsOptions`, `DiagnosticsReport`, `DiagnosticsSummary`,
|
|
@@ -123,4 +129,4 @@ assertNoErrors(report);
|
|
|
123
129
|
|
|
124
130
|
## See also
|
|
125
131
|
|
|
126
|
-
- [Plugin guide](
|
|
132
|
+
- [Plugin guide](../../../docs/en/plugin-system.md)
|
package/README_ja.md
CHANGED
|
@@ -37,8 +37,11 @@ export default defineConfig({
|
|
|
37
37
|
| `publish-conflict` | warning | `publish: true` と `draft: true` または `private: true` が同居している |
|
|
38
38
|
| `duplicate-title` | warning | 公開ノート同士でタイトルが重複している |
|
|
39
39
|
| `slug-collision` | error | 大文字・小文字を区別しない slug が衝突している |
|
|
40
|
+
| `duplicate-content-id` | error | 公開ノート同士で安定コンテンツ ID(`id`/`uid`)が重複している |
|
|
41
|
+
| `invalid-content-id` | error | `id`/`uid` の frontmatter が安定コンテンツ ID の契約を満たしていない |
|
|
40
42
|
| `excluded-public` | warning | 除外されたノートに `publish: true` が指定されている |
|
|
41
43
|
| `publish-boundary` | warning | 公開コンテンツから非公開コンテンツへリンク・埋め込みしている |
|
|
44
|
+
| `analytics-untracked` | info | 安定 `id` のない公開ノートは analytics プラグインで計測されない(`reportAnalyticsCoverage` で有効化。設定で analytics プラグインが有効な場合は自動で有効化) |
|
|
42
45
|
| `internal-error` | error | コンテンツ解析中に処理できないエラーが発生した |
|
|
43
46
|
|
|
44
47
|
未使用アセットと孤立ノートは、明示的に有効化した場合だけ確認します。意図的に孤立させるトップページなどがあるなら、`reportOrphans` の結果を公開方針と照らして判断してください。
|
|
@@ -50,6 +53,7 @@ export default defineConfig({
|
|
|
50
53
|
| `failOnError` | `false` | error レベルの診断があればビルドを失敗させる |
|
|
51
54
|
| `reportUnusedAssets` | `false` | 未参照画像を報告する |
|
|
52
55
|
| `reportOrphans` | `false` | 孤立した公開ノートを報告する |
|
|
56
|
+
| `reportAnalyticsCoverage` | `false` | analytics プラグインで計測されない公開ノートを報告する(解決済み設定で analytics プラグインが有効なら自動で有効化) |
|
|
53
57
|
| `requiredFrontmatter` | `[]` | 必須にする frontmatter フィールド |
|
|
54
58
|
| `severity` | コードごとの既定値 | 診断コード別の重要度を上書きする |
|
|
55
59
|
| `exclude` | `[]` | 解析対象から追加で除外する glob |
|
|
@@ -89,6 +93,7 @@ assertNoErrors(report);
|
|
|
89
93
|
- `diagnostics(options?)` / `diagnosticsPlugin`: プラグインを作成する
|
|
90
94
|
- `runDiagnostics(target, options?)`: 診断と集計を実行する
|
|
91
95
|
- `analyzeContent(config, options?)`: 生の `Diagnostic[]` を取得する
|
|
96
|
+
- `hasEnabledAnalyticsPlugin(config?)`: 解決済み設定で analytics プラグインが有効かを判定する
|
|
92
97
|
- `formatDiagnostics`、`summarize`、`groupByCode`、`assertNoErrors`: レポート処理用の補助関数
|
|
93
98
|
- `DiagnosticsFailure`: `failOnError` または `assertNoErrors` が送出するエラー
|
|
94
99
|
- `riebeckite-diagnostics`: CLI
|
package/dist/bin.js
CHANGED
|
@@ -5,6 +5,73 @@ import { resolveConfigModule } from "@riebeckite/core";
|
|
|
5
5
|
// index.ts
|
|
6
6
|
import { definePlugin, FileSystemContentSource as FileSystemContentSource2 } from "@riebeckite/core";
|
|
7
7
|
|
|
8
|
+
// src/frontmatter.ts
|
|
9
|
+
function parseFrontmatter(markdown) {
|
|
10
|
+
const match = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
11
|
+
if (!match?.[1]) return { hasFrontmatter: false, values: {} };
|
|
12
|
+
return { hasFrontmatter: true, values: parseFrontmatterBlock(match[1]) };
|
|
13
|
+
}
|
|
14
|
+
function hasField(values, field) {
|
|
15
|
+
if (!(field in values)) return false;
|
|
16
|
+
const value = values[field];
|
|
17
|
+
if (value === null || value === void 0) return false;
|
|
18
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
19
|
+
if (typeof value === "string") return value.trim() !== "";
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
function getNoteTitle(values, slug) {
|
|
23
|
+
const title = values.title;
|
|
24
|
+
return typeof title === "string" && title.trim() ? title : slug;
|
|
25
|
+
}
|
|
26
|
+
function parseFrontmatterBlock(body) {
|
|
27
|
+
const values = {};
|
|
28
|
+
const lines = body.split(/\r?\n/);
|
|
29
|
+
let openKey = null;
|
|
30
|
+
const pending = [];
|
|
31
|
+
const closeList = () => {
|
|
32
|
+
if (openKey !== null) {
|
|
33
|
+
values[openKey] = pending.length > 0 ? [...pending] : [];
|
|
34
|
+
}
|
|
35
|
+
openKey = null;
|
|
36
|
+
pending.length = 0;
|
|
37
|
+
};
|
|
38
|
+
for (const line of lines) {
|
|
39
|
+
const keyMatch = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
40
|
+
if (keyMatch) {
|
|
41
|
+
closeList();
|
|
42
|
+
const [, key, rest] = keyMatch;
|
|
43
|
+
const trimmed = rest.trim();
|
|
44
|
+
if (trimmed === "") {
|
|
45
|
+
openKey = key;
|
|
46
|
+
} else if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
|
47
|
+
values[key] = trimmed.slice(1, -1).split(",").map((item) => stripQuotes(item.trim())).filter(Boolean);
|
|
48
|
+
} else {
|
|
49
|
+
values[key] = parseScalar(trimmed);
|
|
50
|
+
}
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const itemMatch = line.match(/^\s*-\s+(.+)$/);
|
|
54
|
+
if (itemMatch) {
|
|
55
|
+
if (openKey !== null) {
|
|
56
|
+
pending.push(stripQuotes(itemMatch[1].trim()));
|
|
57
|
+
}
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (line.trim() === "") continue;
|
|
61
|
+
closeList();
|
|
62
|
+
}
|
|
63
|
+
closeList();
|
|
64
|
+
return values;
|
|
65
|
+
}
|
|
66
|
+
function parseScalar(value) {
|
|
67
|
+
if (/^true$/i.test(value)) return true;
|
|
68
|
+
if (/^false$/i.test(value)) return false;
|
|
69
|
+
return stripQuotes(value);
|
|
70
|
+
}
|
|
71
|
+
function stripQuotes(value) {
|
|
72
|
+
return value.trim().replace(/^['"]|['"]$/g, "");
|
|
73
|
+
}
|
|
74
|
+
|
|
8
75
|
// src/checks/shared.ts
|
|
9
76
|
var PLUGIN_NAME = "diagnostics";
|
|
10
77
|
var DEFAULT_SEVERITY = {
|
|
@@ -17,8 +84,11 @@ var DEFAULT_SEVERITY = {
|
|
|
17
84
|
"publish-conflict": "warning",
|
|
18
85
|
"duplicate-title": "warning",
|
|
19
86
|
"slug-collision": "error",
|
|
87
|
+
"duplicate-content-id": "error",
|
|
88
|
+
"invalid-content-id": "error",
|
|
20
89
|
"excluded-public": "warning",
|
|
21
90
|
"publish-boundary": "warning",
|
|
91
|
+
"analytics-untracked": "info",
|
|
22
92
|
"internal-error": "error"
|
|
23
93
|
};
|
|
24
94
|
function createAnalysisState() {
|
|
@@ -31,6 +101,7 @@ function normalizeOptions(options) {
|
|
|
31
101
|
return {
|
|
32
102
|
reportUnusedAssets: options.reportUnusedAssets ?? false,
|
|
33
103
|
reportOrphans: options.reportOrphans ?? false,
|
|
104
|
+
reportAnalyticsCoverage: options.reportAnalyticsCoverage ?? false,
|
|
34
105
|
requiredFrontmatter: options.requiredFrontmatter ?? [],
|
|
35
106
|
severity: options.severity ?? {}
|
|
36
107
|
};
|
|
@@ -55,6 +126,26 @@ function pushDiagnostic(diagnostics, options, location, code, message, target, s
|
|
|
55
126
|
});
|
|
56
127
|
}
|
|
57
128
|
|
|
129
|
+
// src/checks/analytics.ts
|
|
130
|
+
function checkAnalyticsCoverage(source, options, diagnostics) {
|
|
131
|
+
if (!options.reportAnalyticsCoverage) return;
|
|
132
|
+
for (const note of source.includedNotes) {
|
|
133
|
+
if (!note.published || hasAnalyticsId(note)) continue;
|
|
134
|
+
pushDiagnostic(
|
|
135
|
+
diagnostics,
|
|
136
|
+
options,
|
|
137
|
+
{ slug: note.slug, filePath: note.relativePath },
|
|
138
|
+
"analytics-untracked",
|
|
139
|
+
"published content has no stable content ID and is not tracked by the analytics plugin",
|
|
140
|
+
note.slug,
|
|
141
|
+
'add "id" to the frontmatter'
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function hasAnalyticsId(note) {
|
|
146
|
+
return hasField(note.fm.values, "id") || hasField(note.fm.values, "uid");
|
|
147
|
+
}
|
|
148
|
+
|
|
58
149
|
// src/checks/assets.ts
|
|
59
150
|
function checkUnusedAssets(source, state, options, diagnostics) {
|
|
60
151
|
if (!options.reportUnusedAssets) return;
|
|
@@ -72,6 +163,52 @@ function checkUnusedAssets(source, state, options, diagnostics) {
|
|
|
72
163
|
}
|
|
73
164
|
}
|
|
74
165
|
|
|
166
|
+
// src/checks/content_identity.ts
|
|
167
|
+
import { resolveContentStableId } from "@riebeckite/core";
|
|
168
|
+
function checkContentIdIntegrity(source, options, diagnostics) {
|
|
169
|
+
const publishedById = /* @__PURE__ */ new Map();
|
|
170
|
+
for (const note of source.includedNotes) {
|
|
171
|
+
if (!note.published) continue;
|
|
172
|
+
let id;
|
|
173
|
+
try {
|
|
174
|
+
id = resolveContentStableId(note.fm.values);
|
|
175
|
+
} catch (error) {
|
|
176
|
+
pushDiagnostic(
|
|
177
|
+
diagnostics,
|
|
178
|
+
options,
|
|
179
|
+
{ slug: note.slug, filePath: note.relativePath },
|
|
180
|
+
"invalid-content-id",
|
|
181
|
+
`invalid content id frontmatter: ${toErrorMessage(error)}`,
|
|
182
|
+
void 0,
|
|
183
|
+
'set a unique, trimmed "id" value and remove a conflicting "uid" value'
|
|
184
|
+
);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (!id) continue;
|
|
188
|
+
const group = publishedById.get(id) ?? [];
|
|
189
|
+
group.push(note);
|
|
190
|
+
publishedById.set(id, group);
|
|
191
|
+
}
|
|
192
|
+
for (const [id, notes] of publishedById) {
|
|
193
|
+
if (notes.length < 2) continue;
|
|
194
|
+
const slugs = notes.map((note) => note.slug).join(", ");
|
|
195
|
+
for (const note of notes) {
|
|
196
|
+
pushDiagnostic(
|
|
197
|
+
diagnostics,
|
|
198
|
+
options,
|
|
199
|
+
{ slug: note.slug, filePath: note.relativePath },
|
|
200
|
+
"duplicate-content-id",
|
|
201
|
+
`content id "${id}" is used by multiple published notes: ${slugs}`,
|
|
202
|
+
id,
|
|
203
|
+
'give each note a unique "id" (or "uid") frontmatter value'
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
function toErrorMessage(error) {
|
|
209
|
+
return error instanceof Error ? error.message : String(error);
|
|
210
|
+
}
|
|
211
|
+
|
|
75
212
|
// src/checks/excluded_public.ts
|
|
76
213
|
function checkExcludedPublic(source, options, diagnostics) {
|
|
77
214
|
for (const note of source.excludedNotes) {
|
|
@@ -88,73 +225,6 @@ function checkExcludedPublic(source, options, diagnostics) {
|
|
|
88
225
|
}
|
|
89
226
|
}
|
|
90
227
|
|
|
91
|
-
// src/frontmatter.ts
|
|
92
|
-
function parseFrontmatter(markdown) {
|
|
93
|
-
const match = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
94
|
-
if (!match?.[1]) return { hasFrontmatter: false, values: {} };
|
|
95
|
-
return { hasFrontmatter: true, values: parseFrontmatterBlock(match[1]) };
|
|
96
|
-
}
|
|
97
|
-
function hasField(values, field) {
|
|
98
|
-
if (!(field in values)) return false;
|
|
99
|
-
const value = values[field];
|
|
100
|
-
if (value === null || value === void 0) return false;
|
|
101
|
-
if (Array.isArray(value)) return value.length > 0;
|
|
102
|
-
if (typeof value === "string") return value.trim() !== "";
|
|
103
|
-
return true;
|
|
104
|
-
}
|
|
105
|
-
function getNoteTitle(values, slug) {
|
|
106
|
-
const title = values.title;
|
|
107
|
-
return typeof title === "string" && title.trim() ? title : slug;
|
|
108
|
-
}
|
|
109
|
-
function parseFrontmatterBlock(body) {
|
|
110
|
-
const values = {};
|
|
111
|
-
const lines = body.split(/\r?\n/);
|
|
112
|
-
let openKey = null;
|
|
113
|
-
const pending = [];
|
|
114
|
-
const closeList = () => {
|
|
115
|
-
if (openKey !== null) {
|
|
116
|
-
values[openKey] = pending.length > 0 ? [...pending] : [];
|
|
117
|
-
}
|
|
118
|
-
openKey = null;
|
|
119
|
-
pending.length = 0;
|
|
120
|
-
};
|
|
121
|
-
for (const line of lines) {
|
|
122
|
-
const keyMatch = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
123
|
-
if (keyMatch) {
|
|
124
|
-
closeList();
|
|
125
|
-
const [, key, rest] = keyMatch;
|
|
126
|
-
const trimmed = rest.trim();
|
|
127
|
-
if (trimmed === "") {
|
|
128
|
-
openKey = key;
|
|
129
|
-
} else if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
|
130
|
-
values[key] = trimmed.slice(1, -1).split(",").map((item) => stripQuotes(item.trim())).filter(Boolean);
|
|
131
|
-
} else {
|
|
132
|
-
values[key] = parseScalar(trimmed);
|
|
133
|
-
}
|
|
134
|
-
continue;
|
|
135
|
-
}
|
|
136
|
-
const itemMatch = line.match(/^\s*-\s+(.+)$/);
|
|
137
|
-
if (itemMatch) {
|
|
138
|
-
if (openKey !== null) {
|
|
139
|
-
pending.push(stripQuotes(itemMatch[1].trim()));
|
|
140
|
-
}
|
|
141
|
-
continue;
|
|
142
|
-
}
|
|
143
|
-
if (line.trim() === "") continue;
|
|
144
|
-
closeList();
|
|
145
|
-
}
|
|
146
|
-
closeList();
|
|
147
|
-
return values;
|
|
148
|
-
}
|
|
149
|
-
function parseScalar(value) {
|
|
150
|
-
if (/^true$/i.test(value)) return true;
|
|
151
|
-
if (/^false$/i.test(value)) return false;
|
|
152
|
-
return stripQuotes(value);
|
|
153
|
-
}
|
|
154
|
-
function stripQuotes(value) {
|
|
155
|
-
return value.trim().replace(/^['"]|['"]$/g, "");
|
|
156
|
-
}
|
|
157
|
-
|
|
158
228
|
// src/vault.ts
|
|
159
229
|
import {
|
|
160
230
|
FileSystemContentSource,
|
|
@@ -760,10 +830,12 @@ async function analyzeContent(config, options = {}) {
|
|
|
760
830
|
}
|
|
761
831
|
checkSlugCollisions(source, normalizedOptions, diagnostics);
|
|
762
832
|
checkDuplicateTitles(source, normalizedOptions, diagnostics);
|
|
833
|
+
checkContentIdIntegrity(source, normalizedOptions, diagnostics);
|
|
763
834
|
checkOrphans(source, state, normalizedOptions, diagnostics);
|
|
764
835
|
checkUnusedAssets(source, state, normalizedOptions, diagnostics);
|
|
765
836
|
checkExcludedPublic(source, normalizedOptions, diagnostics);
|
|
766
837
|
checkPublishBoundary(source, normalizedOptions, diagnostics);
|
|
838
|
+
checkAnalyticsCoverage(source, normalizedOptions, diagnostics);
|
|
767
839
|
return diagnostics;
|
|
768
840
|
}
|
|
769
841
|
|
|
@@ -902,6 +974,12 @@ async function runDiagnostics(target, options = {}) {
|
|
|
902
974
|
return buildReport([createInternalError(error)]);
|
|
903
975
|
}
|
|
904
976
|
}
|
|
977
|
+
var ANALYTICS_PLUGIN_NAME = "analytics";
|
|
978
|
+
function hasEnabledAnalyticsPlugin(config) {
|
|
979
|
+
return config?.plugins.some(
|
|
980
|
+
(plugin) => plugin.name === ANALYTICS_PLUGIN_NAME && plugin.enabled !== false
|
|
981
|
+
) ?? false;
|
|
982
|
+
}
|
|
905
983
|
function createInternalError(error) {
|
|
906
984
|
const message = error instanceof Error ? error.message : String(error);
|
|
907
985
|
return {
|
|
@@ -936,7 +1014,8 @@ var HELP = `riebeckite-diagnostics
|
|
|
936
1014
|
Check an Obsidian vault / Riebeckite content directory for content problems:
|
|
937
1015
|
broken wikilinks, broken images, broken markdown links, unused assets,
|
|
938
1016
|
orphan notes, missing frontmatter, publish conflicts, duplicate titles,
|
|
939
|
-
slug collisions, and excluded-but-public
|
|
1017
|
+
slug collisions, duplicate or invalid content IDs, and excluded-but-public
|
|
1018
|
+
notes.
|
|
940
1019
|
|
|
941
1020
|
Usage:
|
|
942
1021
|
riebeckite-diagnostics [options]
|
|
@@ -948,6 +1027,9 @@ Options:
|
|
|
948
1027
|
--publish-strategy <mode> explicit | selective (default: selective)
|
|
949
1028
|
--report-unused-assets Report images never referenced by any note
|
|
950
1029
|
--report-orphans Report published notes with no incoming links
|
|
1030
|
+
--report-analytics-coverage Report published notes without a stable content ID
|
|
1031
|
+
(auto-enabled when riebeckite.config.ts enables the
|
|
1032
|
+
analytics plugin)
|
|
951
1033
|
--required-frontmatter <f> Comma-separated required frontmatter fields
|
|
952
1034
|
--fail-on-error Exit with code 1 when errors are found (default)
|
|
953
1035
|
--exit-on <severity> Exit with code 1 at/above severity (info|warning|error)
|
|
@@ -976,6 +1058,7 @@ async function main(argv) {
|
|
|
976
1058
|
...config ? diagnosticsOptionsFromConfig(config) : {},
|
|
977
1059
|
...args.explicit.has("reportUnusedAssets") ? { reportUnusedAssets: args.reportUnusedAssets } : {},
|
|
978
1060
|
...args.explicit.has("reportOrphans") ? { reportOrphans: args.reportOrphans } : {},
|
|
1061
|
+
...args.explicit.has("reportAnalyticsCoverage") ? { reportAnalyticsCoverage: args.reportAnalyticsCoverage } : config ? { reportAnalyticsCoverage: hasEnabledAnalyticsPlugin(config) } : {},
|
|
979
1062
|
...args.explicit.has("requiredFrontmatter") ? { requiredFrontmatter: args.requiredFrontmatter } : {},
|
|
980
1063
|
...args.explicit.has("publishStrategy") ? { publishStrategy: args.publishStrategy ?? void 0 } : {},
|
|
981
1064
|
exclude: [
|
|
@@ -1081,6 +1164,7 @@ function parseArgs(argv) {
|
|
|
1081
1164
|
requiredFrontmatter: [],
|
|
1082
1165
|
reportUnusedAssets: false,
|
|
1083
1166
|
reportOrphans: false,
|
|
1167
|
+
reportAnalyticsCoverage: false,
|
|
1084
1168
|
publishStrategy: null,
|
|
1085
1169
|
explicit: /* @__PURE__ */ new Set()
|
|
1086
1170
|
};
|
|
@@ -1105,6 +1189,10 @@ function parseArgs(argv) {
|
|
|
1105
1189
|
args.reportOrphans = true;
|
|
1106
1190
|
args.explicit.add("reportOrphans");
|
|
1107
1191
|
break;
|
|
1192
|
+
case "--report-analytics-coverage":
|
|
1193
|
+
args.reportAnalyticsCoverage = true;
|
|
1194
|
+
args.explicit.add("reportAnalyticsCoverage");
|
|
1195
|
+
break;
|
|
1108
1196
|
case "--format":
|
|
1109
1197
|
args.format = (argv[++i] ?? "text") === "json" ? "json" : "text";
|
|
1110
1198
|
break;
|
package/dist/index.d.ts
CHANGED
|
@@ -6,3 +6,8 @@ export type { AnalyzerContentConfig, DiagnosticsOptions, DiagnosticsReport, Diag
|
|
|
6
6
|
export declare function runDiagnostics(target: ResolvedRiebeckiteConfig | string, options?: DiagnosticsOptions): Promise<DiagnosticsReport>;
|
|
7
7
|
export declare function diagnostics(options?: DiagnosticsOptions): import("@riebeckite/core").RiebeckitePlugin<DiagnosticsOptions>;
|
|
8
8
|
export declare const diagnosticsPlugin: typeof diagnostics;
|
|
9
|
+
/**
|
|
10
|
+
* Reports whether the resolved configuration enables the analytics plugin, so
|
|
11
|
+
* the analytics coverage check turns itself on without coupling to the plugin.
|
|
12
|
+
*/
|
|
13
|
+
export declare function hasEnabledAnalyticsPlugin(config?: ResolvedRiebeckiteConfig): boolean;
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,73 @@
|
|
|
1
1
|
// index.ts
|
|
2
2
|
import { definePlugin, FileSystemContentSource as FileSystemContentSource2 } from "@riebeckite/core";
|
|
3
3
|
|
|
4
|
+
// src/frontmatter.ts
|
|
5
|
+
function parseFrontmatter(markdown) {
|
|
6
|
+
const match = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
7
|
+
if (!match?.[1]) return { hasFrontmatter: false, values: {} };
|
|
8
|
+
return { hasFrontmatter: true, values: parseFrontmatterBlock(match[1]) };
|
|
9
|
+
}
|
|
10
|
+
function hasField(values, field) {
|
|
11
|
+
if (!(field in values)) return false;
|
|
12
|
+
const value = values[field];
|
|
13
|
+
if (value === null || value === void 0) return false;
|
|
14
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
15
|
+
if (typeof value === "string") return value.trim() !== "";
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
function getNoteTitle(values, slug) {
|
|
19
|
+
const title = values.title;
|
|
20
|
+
return typeof title === "string" && title.trim() ? title : slug;
|
|
21
|
+
}
|
|
22
|
+
function parseFrontmatterBlock(body) {
|
|
23
|
+
const values = {};
|
|
24
|
+
const lines = body.split(/\r?\n/);
|
|
25
|
+
let openKey = null;
|
|
26
|
+
const pending = [];
|
|
27
|
+
const closeList = () => {
|
|
28
|
+
if (openKey !== null) {
|
|
29
|
+
values[openKey] = pending.length > 0 ? [...pending] : [];
|
|
30
|
+
}
|
|
31
|
+
openKey = null;
|
|
32
|
+
pending.length = 0;
|
|
33
|
+
};
|
|
34
|
+
for (const line of lines) {
|
|
35
|
+
const keyMatch = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
36
|
+
if (keyMatch) {
|
|
37
|
+
closeList();
|
|
38
|
+
const [, key, rest] = keyMatch;
|
|
39
|
+
const trimmed = rest.trim();
|
|
40
|
+
if (trimmed === "") {
|
|
41
|
+
openKey = key;
|
|
42
|
+
} else if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
|
43
|
+
values[key] = trimmed.slice(1, -1).split(",").map((item) => stripQuotes(item.trim())).filter(Boolean);
|
|
44
|
+
} else {
|
|
45
|
+
values[key] = parseScalar(trimmed);
|
|
46
|
+
}
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const itemMatch = line.match(/^\s*-\s+(.+)$/);
|
|
50
|
+
if (itemMatch) {
|
|
51
|
+
if (openKey !== null) {
|
|
52
|
+
pending.push(stripQuotes(itemMatch[1].trim()));
|
|
53
|
+
}
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (line.trim() === "") continue;
|
|
57
|
+
closeList();
|
|
58
|
+
}
|
|
59
|
+
closeList();
|
|
60
|
+
return values;
|
|
61
|
+
}
|
|
62
|
+
function parseScalar(value) {
|
|
63
|
+
if (/^true$/i.test(value)) return true;
|
|
64
|
+
if (/^false$/i.test(value)) return false;
|
|
65
|
+
return stripQuotes(value);
|
|
66
|
+
}
|
|
67
|
+
function stripQuotes(value) {
|
|
68
|
+
return value.trim().replace(/^['"]|['"]$/g, "");
|
|
69
|
+
}
|
|
70
|
+
|
|
4
71
|
// src/checks/shared.ts
|
|
5
72
|
var PLUGIN_NAME = "diagnostics";
|
|
6
73
|
var DEFAULT_SEVERITY = {
|
|
@@ -13,8 +80,11 @@ var DEFAULT_SEVERITY = {
|
|
|
13
80
|
"publish-conflict": "warning",
|
|
14
81
|
"duplicate-title": "warning",
|
|
15
82
|
"slug-collision": "error",
|
|
83
|
+
"duplicate-content-id": "error",
|
|
84
|
+
"invalid-content-id": "error",
|
|
16
85
|
"excluded-public": "warning",
|
|
17
86
|
"publish-boundary": "warning",
|
|
87
|
+
"analytics-untracked": "info",
|
|
18
88
|
"internal-error": "error"
|
|
19
89
|
};
|
|
20
90
|
function createAnalysisState() {
|
|
@@ -27,6 +97,7 @@ function normalizeOptions(options) {
|
|
|
27
97
|
return {
|
|
28
98
|
reportUnusedAssets: options.reportUnusedAssets ?? false,
|
|
29
99
|
reportOrphans: options.reportOrphans ?? false,
|
|
100
|
+
reportAnalyticsCoverage: options.reportAnalyticsCoverage ?? false,
|
|
30
101
|
requiredFrontmatter: options.requiredFrontmatter ?? [],
|
|
31
102
|
severity: options.severity ?? {}
|
|
32
103
|
};
|
|
@@ -51,6 +122,26 @@ function pushDiagnostic(diagnostics2, options, location, code, message, target,
|
|
|
51
122
|
});
|
|
52
123
|
}
|
|
53
124
|
|
|
125
|
+
// src/checks/analytics.ts
|
|
126
|
+
function checkAnalyticsCoverage(source, options, diagnostics2) {
|
|
127
|
+
if (!options.reportAnalyticsCoverage) return;
|
|
128
|
+
for (const note of source.includedNotes) {
|
|
129
|
+
if (!note.published || hasAnalyticsId(note)) continue;
|
|
130
|
+
pushDiagnostic(
|
|
131
|
+
diagnostics2,
|
|
132
|
+
options,
|
|
133
|
+
{ slug: note.slug, filePath: note.relativePath },
|
|
134
|
+
"analytics-untracked",
|
|
135
|
+
"published content has no stable content ID and is not tracked by the analytics plugin",
|
|
136
|
+
note.slug,
|
|
137
|
+
'add "id" to the frontmatter'
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function hasAnalyticsId(note) {
|
|
142
|
+
return hasField(note.fm.values, "id") || hasField(note.fm.values, "uid");
|
|
143
|
+
}
|
|
144
|
+
|
|
54
145
|
// src/checks/assets.ts
|
|
55
146
|
function checkUnusedAssets(source, state, options, diagnostics2) {
|
|
56
147
|
if (!options.reportUnusedAssets) return;
|
|
@@ -68,6 +159,52 @@ function checkUnusedAssets(source, state, options, diagnostics2) {
|
|
|
68
159
|
}
|
|
69
160
|
}
|
|
70
161
|
|
|
162
|
+
// src/checks/content_identity.ts
|
|
163
|
+
import { resolveContentStableId } from "@riebeckite/core";
|
|
164
|
+
function checkContentIdIntegrity(source, options, diagnostics2) {
|
|
165
|
+
const publishedById = /* @__PURE__ */ new Map();
|
|
166
|
+
for (const note of source.includedNotes) {
|
|
167
|
+
if (!note.published) continue;
|
|
168
|
+
let id;
|
|
169
|
+
try {
|
|
170
|
+
id = resolveContentStableId(note.fm.values);
|
|
171
|
+
} catch (error) {
|
|
172
|
+
pushDiagnostic(
|
|
173
|
+
diagnostics2,
|
|
174
|
+
options,
|
|
175
|
+
{ slug: note.slug, filePath: note.relativePath },
|
|
176
|
+
"invalid-content-id",
|
|
177
|
+
`invalid content id frontmatter: ${toErrorMessage(error)}`,
|
|
178
|
+
void 0,
|
|
179
|
+
'set a unique, trimmed "id" value and remove a conflicting "uid" value'
|
|
180
|
+
);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (!id) continue;
|
|
184
|
+
const group = publishedById.get(id) ?? [];
|
|
185
|
+
group.push(note);
|
|
186
|
+
publishedById.set(id, group);
|
|
187
|
+
}
|
|
188
|
+
for (const [id, notes] of publishedById) {
|
|
189
|
+
if (notes.length < 2) continue;
|
|
190
|
+
const slugs = notes.map((note) => note.slug).join(", ");
|
|
191
|
+
for (const note of notes) {
|
|
192
|
+
pushDiagnostic(
|
|
193
|
+
diagnostics2,
|
|
194
|
+
options,
|
|
195
|
+
{ slug: note.slug, filePath: note.relativePath },
|
|
196
|
+
"duplicate-content-id",
|
|
197
|
+
`content id "${id}" is used by multiple published notes: ${slugs}`,
|
|
198
|
+
id,
|
|
199
|
+
'give each note a unique "id" (or "uid") frontmatter value'
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
function toErrorMessage(error) {
|
|
205
|
+
return error instanceof Error ? error.message : String(error);
|
|
206
|
+
}
|
|
207
|
+
|
|
71
208
|
// src/checks/excluded_public.ts
|
|
72
209
|
function checkExcludedPublic(source, options, diagnostics2) {
|
|
73
210
|
for (const note of source.excludedNotes) {
|
|
@@ -84,73 +221,6 @@ function checkExcludedPublic(source, options, diagnostics2) {
|
|
|
84
221
|
}
|
|
85
222
|
}
|
|
86
223
|
|
|
87
|
-
// src/frontmatter.ts
|
|
88
|
-
function parseFrontmatter(markdown) {
|
|
89
|
-
const match = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
90
|
-
if (!match?.[1]) return { hasFrontmatter: false, values: {} };
|
|
91
|
-
return { hasFrontmatter: true, values: parseFrontmatterBlock(match[1]) };
|
|
92
|
-
}
|
|
93
|
-
function hasField(values, field) {
|
|
94
|
-
if (!(field in values)) return false;
|
|
95
|
-
const value = values[field];
|
|
96
|
-
if (value === null || value === void 0) return false;
|
|
97
|
-
if (Array.isArray(value)) return value.length > 0;
|
|
98
|
-
if (typeof value === "string") return value.trim() !== "";
|
|
99
|
-
return true;
|
|
100
|
-
}
|
|
101
|
-
function getNoteTitle(values, slug) {
|
|
102
|
-
const title = values.title;
|
|
103
|
-
return typeof title === "string" && title.trim() ? title : slug;
|
|
104
|
-
}
|
|
105
|
-
function parseFrontmatterBlock(body) {
|
|
106
|
-
const values = {};
|
|
107
|
-
const lines = body.split(/\r?\n/);
|
|
108
|
-
let openKey = null;
|
|
109
|
-
const pending = [];
|
|
110
|
-
const closeList = () => {
|
|
111
|
-
if (openKey !== null) {
|
|
112
|
-
values[openKey] = pending.length > 0 ? [...pending] : [];
|
|
113
|
-
}
|
|
114
|
-
openKey = null;
|
|
115
|
-
pending.length = 0;
|
|
116
|
-
};
|
|
117
|
-
for (const line of lines) {
|
|
118
|
-
const keyMatch = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
119
|
-
if (keyMatch) {
|
|
120
|
-
closeList();
|
|
121
|
-
const [, key, rest] = keyMatch;
|
|
122
|
-
const trimmed = rest.trim();
|
|
123
|
-
if (trimmed === "") {
|
|
124
|
-
openKey = key;
|
|
125
|
-
} else if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
|
126
|
-
values[key] = trimmed.slice(1, -1).split(",").map((item) => stripQuotes(item.trim())).filter(Boolean);
|
|
127
|
-
} else {
|
|
128
|
-
values[key] = parseScalar(trimmed);
|
|
129
|
-
}
|
|
130
|
-
continue;
|
|
131
|
-
}
|
|
132
|
-
const itemMatch = line.match(/^\s*-\s+(.+)$/);
|
|
133
|
-
if (itemMatch) {
|
|
134
|
-
if (openKey !== null) {
|
|
135
|
-
pending.push(stripQuotes(itemMatch[1].trim()));
|
|
136
|
-
}
|
|
137
|
-
continue;
|
|
138
|
-
}
|
|
139
|
-
if (line.trim() === "") continue;
|
|
140
|
-
closeList();
|
|
141
|
-
}
|
|
142
|
-
closeList();
|
|
143
|
-
return values;
|
|
144
|
-
}
|
|
145
|
-
function parseScalar(value) {
|
|
146
|
-
if (/^true$/i.test(value)) return true;
|
|
147
|
-
if (/^false$/i.test(value)) return false;
|
|
148
|
-
return stripQuotes(value);
|
|
149
|
-
}
|
|
150
|
-
function stripQuotes(value) {
|
|
151
|
-
return value.trim().replace(/^['"]|['"]$/g, "");
|
|
152
|
-
}
|
|
153
|
-
|
|
154
224
|
// src/vault.ts
|
|
155
225
|
import {
|
|
156
226
|
FileSystemContentSource,
|
|
@@ -756,10 +826,12 @@ async function analyzeContent(config, options = {}) {
|
|
|
756
826
|
}
|
|
757
827
|
checkSlugCollisions(source, normalizedOptions, diagnostics2);
|
|
758
828
|
checkDuplicateTitles(source, normalizedOptions, diagnostics2);
|
|
829
|
+
checkContentIdIntegrity(source, normalizedOptions, diagnostics2);
|
|
759
830
|
checkOrphans(source, state, normalizedOptions, diagnostics2);
|
|
760
831
|
checkUnusedAssets(source, state, normalizedOptions, diagnostics2);
|
|
761
832
|
checkExcludedPublic(source, normalizedOptions, diagnostics2);
|
|
762
833
|
checkPublishBoundary(source, normalizedOptions, diagnostics2);
|
|
834
|
+
checkAnalyticsCoverage(source, normalizedOptions, diagnostics2);
|
|
763
835
|
return diagnostics2;
|
|
764
836
|
}
|
|
765
837
|
|
|
@@ -919,7 +991,10 @@ function diagnostics(options = {}) {
|
|
|
919
991
|
const config = context.config;
|
|
920
992
|
if (!config) return [];
|
|
921
993
|
try {
|
|
922
|
-
return await analyzeContent(
|
|
994
|
+
return await analyzeContent(
|
|
995
|
+
toAnalyzerConfig(config, options),
|
|
996
|
+
analyticsCoverageOptions(config, options)
|
|
997
|
+
);
|
|
923
998
|
} catch (error) {
|
|
924
999
|
return [createInternalError(error)];
|
|
925
1000
|
}
|
|
@@ -937,6 +1012,18 @@ function diagnostics(options = {}) {
|
|
|
937
1012
|
});
|
|
938
1013
|
}
|
|
939
1014
|
var diagnosticsPlugin = diagnostics;
|
|
1015
|
+
var ANALYTICS_PLUGIN_NAME = "analytics";
|
|
1016
|
+
function hasEnabledAnalyticsPlugin(config) {
|
|
1017
|
+
return config?.plugins.some(
|
|
1018
|
+
(plugin) => plugin.name === ANALYTICS_PLUGIN_NAME && plugin.enabled !== false
|
|
1019
|
+
) ?? false;
|
|
1020
|
+
}
|
|
1021
|
+
function analyticsCoverageOptions(config, options) {
|
|
1022
|
+
return {
|
|
1023
|
+
...options,
|
|
1024
|
+
reportAnalyticsCoverage: options.reportAnalyticsCoverage ?? hasEnabledAnalyticsPlugin(config)
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
940
1027
|
function createInternalError(error) {
|
|
941
1028
|
const message = error instanceof Error ? error.message : String(error);
|
|
942
1029
|
return {
|
|
@@ -973,6 +1060,7 @@ export {
|
|
|
973
1060
|
diagnosticsPlugin,
|
|
974
1061
|
formatDiagnostics,
|
|
975
1062
|
groupByCode,
|
|
1063
|
+
hasEnabledAnalyticsPlugin,
|
|
976
1064
|
runDiagnostics,
|
|
977
1065
|
summarize
|
|
978
1066
|
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Diagnostic } from "@riebeckite/core";
|
|
2
|
+
import type { ScanResult } from "../vault.js";
|
|
3
|
+
import { type NormalizedOptions } from "./shared.js";
|
|
4
|
+
/**
|
|
5
|
+
* Reports published content that the analytics plugin will not track.
|
|
6
|
+
*
|
|
7
|
+
* The analytics browser initializer emits a page view only for entries that
|
|
8
|
+
* carry Core's source-authored stable content ID (`id`, or the compatible
|
|
9
|
+
* `uid`). Content without that ID builds and publishes normally but is silently
|
|
10
|
+
* absent from analytics. This check surfaces that gap as factual, read-only
|
|
11
|
+
* `info` findings; the analytics plugin itself never autofixes content.
|
|
12
|
+
*/
|
|
13
|
+
export declare function checkAnalyticsCoverage(source: ScanResult, options: NormalizedOptions, diagnostics: Diagnostic[]): void;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Diagnostic } from "@riebeckite/core";
|
|
2
|
+
import type { ScanResult } from "../vault.js";
|
|
3
|
+
import { type NormalizedOptions } from "./shared.js";
|
|
4
|
+
/**
|
|
5
|
+
* Checks stable content ID integrity.
|
|
6
|
+
*
|
|
7
|
+
* Stable content IDs come from the optional source-authored `id` frontmatter
|
|
8
|
+
* field (`uid` is accepted for legacy content). They are the identity key for
|
|
9
|
+
* per-content consumers such as analytics page views, so duplicates silently
|
|
10
|
+
* merge per-content metrics and conflicting declarations break the build.
|
|
11
|
+
* This check is intentionally generic: it validates the content-model invariant
|
|
12
|
+
* without knowing which plugin consumes the ID.
|
|
13
|
+
*/
|
|
14
|
+
export declare function checkContentIdIntegrity(source: ScanResult, options: NormalizedOptions, diagnostics: Diagnostic[]): void;
|
package/dist/src/types.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export type DiagnosticsOptions = {
|
|
|
3
3
|
failOnError?: boolean;
|
|
4
4
|
reportUnusedAssets?: boolean;
|
|
5
5
|
reportOrphans?: boolean;
|
|
6
|
+
reportAnalyticsCoverage?: boolean;
|
|
6
7
|
requiredFrontmatter?: string[];
|
|
7
8
|
severity?: Partial<Record<DiagnosticCode, DiagnosticSeverity>>;
|
|
8
9
|
exclude?: string[];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@riebeckite/plugin-diagnostics",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.5",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -20,11 +20,12 @@
|
|
|
20
20
|
"remark-parse": "^11.0.0",
|
|
21
21
|
"unified": "^11.0.5",
|
|
22
22
|
"unist-util-visit": "^5.1.0",
|
|
23
|
-
"@riebeckite/core": "0.0.
|
|
23
|
+
"@riebeckite/core": "0.0.5"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@types/mdast": "^4.0.4",
|
|
27
|
-
"@types/node": "^24.5.2"
|
|
27
|
+
"@types/node": "^24.5.2",
|
|
28
|
+
"tsx": "^4.22.4"
|
|
28
29
|
},
|
|
29
30
|
"publishConfig": {
|
|
30
31
|
"access": "public"
|
|
@@ -56,6 +57,7 @@
|
|
|
56
57
|
},
|
|
57
58
|
"sideEffects": false,
|
|
58
59
|
"scripts": {
|
|
59
|
-
"build": "node ../../../scripts/build_package.mjs"
|
|
60
|
+
"build": "node ../../../scripts/build_package.mjs",
|
|
61
|
+
"test": "node --import tsx --test \"test/*.test.ts\""
|
|
60
62
|
}
|
|
61
63
|
}
|