@riebeckite/plugin-diagnostics 0.0.3 → 0.0.6

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 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](../../docs/plugins_en.md)
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,
@@ -642,7 +712,7 @@ function checkPublishBoundary(source, options, diagnostics) {
642
712
  const position = new LineMapper(note.markdown);
643
713
  for (const match of getWikilinkMatches(note.markdown)) {
644
714
  const resolved = resolveWikilinkTarget(match.target, source.targetIndex);
645
- if (!resolved || resolved.kind !== "note") continue;
715
+ if (resolved?.kind !== "note") continue;
646
716
  const { line, column } = position.positionAt(match.index);
647
717
  reportBoundaryReference(
648
718
  source,
@@ -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 notes.
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,
@@ -638,7 +708,7 @@ function checkPublishBoundary(source, options, diagnostics2) {
638
708
  const position = new LineMapper(note.markdown);
639
709
  for (const match of getWikilinkMatches(note.markdown)) {
640
710
  const resolved = resolveWikilinkTarget(match.target, source.targetIndex);
641
- if (!resolved || resolved.kind !== "note") continue;
711
+ if (resolved?.kind !== "note") continue;
642
712
  const { line, column } = position.positionAt(match.index);
643
713
  reportBoundaryReference(
644
714
  source,
@@ -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(toAnalyzerConfig(config, options), options);
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;
@@ -11,6 +11,7 @@ export type AnalysisState = {
11
11
  export type NormalizedOptions = {
12
12
  reportUnusedAssets: boolean;
13
13
  reportOrphans: boolean;
14
+ reportAnalyticsCoverage: boolean;
14
15
  requiredFrontmatter: string[];
15
16
  severity: Partial<Record<string, DiagnosticSeverity>>;
16
17
  };
@@ -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",
3
+ "version": "0.0.6",
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.3"
23
+ "@riebeckite/core": "0.0.6"
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
  }