@riebeckite/plugin-diagnostics 0.0.16 → 0.0.18
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 +4 -5
- package/README_ja.md +5 -4
- package/dist/bin.js +24 -8
- package/dist/index.js +85 -21
- package/dist/src/checks/analytics.d.ts +2 -2
- package/dist/src/checks/content_identity.d.ts +2 -3
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -32,7 +32,7 @@ export default defineConfig({
|
|
|
32
32
|
|
|
33
33
|
- `addDiagnostics` — runs the analysis at build time; results go to
|
|
34
34
|
`manifest.diagnostics`
|
|
35
|
-
- `
|
|
35
|
+
- `buildEnd` — with `failOnError: true`, throws `DiagnosticsFailure` when
|
|
36
36
|
error-level diagnostics exist
|
|
37
37
|
|
|
38
38
|
## Checks
|
|
@@ -48,8 +48,8 @@ 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 within the same language |
|
|
50
50
|
| `slug-collision` | `error` | Slugs collide case-insensitively |
|
|
51
|
-
| `duplicate-content-id` | `error` | Published notes share a stable content ID (`id
|
|
52
|
-
| `invalid-content-id` | `error` | `id
|
|
51
|
+
| `duplicate-content-id` | `error` | Published notes share a stable content ID (`id`) |
|
|
52
|
+
| `invalid-content-id` | `error` | `id` frontmatter violates the stable content ID contract |
|
|
53
53
|
| `excluded-public` | `warning` | Excluded note is marked `publish: true` |
|
|
54
54
|
| `publish-boundary` | `warning` | Published content links to or embeds non-published content |
|
|
55
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) |
|
|
@@ -159,5 +159,4 @@ assertNoErrors(report);
|
|
|
159
159
|
|
|
160
160
|
## See also
|
|
161
161
|
|
|
162
|
-
- [Plugin guide](../../../docs/
|
|
163
|
-
|
|
162
|
+
- [Plugin guide](../../../docs/docs/reference/plugin-api.en.md)
|
package/README_ja.md
CHANGED
|
@@ -24,6 +24,8 @@ export default defineConfig({
|
|
|
24
24
|
});
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
+
`failOnError: true` を指定すると、`buildEnd` で error レベルの Diagnostics が残っている場合に `DiagnosticsFailure` を送出します。
|
|
28
|
+
|
|
27
29
|
## 検出する問題
|
|
28
30
|
|
|
29
31
|
| コード | 既定の重要度 | 内容 |
|
|
@@ -37,8 +39,8 @@ export default defineConfig({
|
|
|
37
39
|
| `publish-conflict` | warning | `publish: true` と `draft: true` または `private: true` が同居している |
|
|
38
40
|
| `duplicate-title` | warning | 同じ言語の公開ノート同士でタイトルが重複している |
|
|
39
41
|
| `slug-collision` | error | 大文字・小文字を区別しない slug が衝突している |
|
|
40
|
-
| `duplicate-content-id` | error | 公開ノート同士で安定コンテンツ ID(`id
|
|
41
|
-
| `invalid-content-id` | error | `id
|
|
42
|
+
| `duplicate-content-id` | error | 公開ノート同士で安定コンテンツ ID(`id`)が重複している |
|
|
43
|
+
| `invalid-content-id` | error | `id` の frontmatter が安定コンテンツ ID の要件を満たしていない |
|
|
42
44
|
| `excluded-public` | warning | 除外されたノートに `publish: true` が指定されている |
|
|
43
45
|
| `publish-boundary` | warning | 公開コンテンツから非公開コンテンツへリンク・埋め込みしている |
|
|
44
46
|
| `analytics-untracked` | info | 安定 `id` のない公開ノートは analytics プラグインで計測されない(`reportAnalyticsCoverage` で有効化。設定で analytics プラグインが有効な場合は自動で有効化) |
|
|
@@ -115,5 +117,4 @@ assertNoErrors(report);
|
|
|
115
117
|
|
|
116
118
|
## 関連資料
|
|
117
119
|
|
|
118
|
-
- [プラグインシステム](../../../docs/
|
|
119
|
-
|
|
120
|
+
- [プラグインシステム](../../../docs/docs/reference/plugin-api.md)
|
package/dist/bin.js
CHANGED
|
@@ -143,7 +143,7 @@ function checkAnalyticsCoverage(source, options, diagnostics) {
|
|
|
143
143
|
}
|
|
144
144
|
}
|
|
145
145
|
function hasAnalyticsId(note) {
|
|
146
|
-
return hasField(note.fm.values, "id")
|
|
146
|
+
return hasField(note.fm.values, "id");
|
|
147
147
|
}
|
|
148
148
|
|
|
149
149
|
// src/checks/assets.ts
|
|
@@ -180,7 +180,7 @@ function checkContentIdIntegrity(source, options, diagnostics) {
|
|
|
180
180
|
"invalid-content-id",
|
|
181
181
|
`invalid content id frontmatter: ${toErrorMessage(error)}`,
|
|
182
182
|
void 0,
|
|
183
|
-
'set a unique, trimmed "id" value
|
|
183
|
+
'set a unique, trimmed "id" value'
|
|
184
184
|
);
|
|
185
185
|
continue;
|
|
186
186
|
}
|
|
@@ -200,7 +200,7 @@ function checkContentIdIntegrity(source, options, diagnostics) {
|
|
|
200
200
|
"duplicate-content-id",
|
|
201
201
|
`content id "${id}" is used by multiple published notes: ${slugs}`,
|
|
202
202
|
id,
|
|
203
|
-
'give each note a unique "id"
|
|
203
|
+
'give each note a unique "id" frontmatter value'
|
|
204
204
|
);
|
|
205
205
|
}
|
|
206
206
|
}
|
|
@@ -258,7 +258,11 @@ async function scanVault(config) {
|
|
|
258
258
|
const note = {
|
|
259
259
|
relativePath: relative,
|
|
260
260
|
slug,
|
|
261
|
-
language: resolveNoteLanguage(
|
|
261
|
+
language: resolveNoteLanguage(
|
|
262
|
+
relative,
|
|
263
|
+
fm.values.lang,
|
|
264
|
+
config.languages
|
|
265
|
+
),
|
|
262
266
|
markdown,
|
|
263
267
|
fm,
|
|
264
268
|
headings: extractHeadings(markdown),
|
|
@@ -417,11 +421,23 @@ function fragmentExists(note, fragment) {
|
|
|
417
421
|
function normalizeMarkdown(markdown) {
|
|
418
422
|
return markdown.replace(/\r\n/g, "\n");
|
|
419
423
|
}
|
|
420
|
-
function resolveNoteLanguage(relativePath, languages) {
|
|
424
|
+
function resolveNoteLanguage(relativePath, frontmatterLang, languages) {
|
|
421
425
|
if (!languages || languages.length === 0) return void 0;
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
426
|
+
if (typeof frontmatterLang === "string" && frontmatterLang.trim()) {
|
|
427
|
+
const value = frontmatterLang.trim().toLowerCase();
|
|
428
|
+
const match = languages.find(
|
|
429
|
+
(language) => language.toLowerCase() === value
|
|
430
|
+
);
|
|
431
|
+
if (match) return match;
|
|
432
|
+
}
|
|
433
|
+
const filename = relativePath.split("/").at(-1)?.replace(/\.md$/i, "") ?? "";
|
|
434
|
+
const lower = filename.toLowerCase();
|
|
435
|
+
for (const language of languages) {
|
|
436
|
+
const tag = language.toLowerCase();
|
|
437
|
+
if (lower.endsWith(`.${tag}`) || lower.endsWith(`-${tag}`) || lower.endsWith(`_${tag}`))
|
|
438
|
+
return language;
|
|
439
|
+
}
|
|
440
|
+
return void 0;
|
|
425
441
|
}
|
|
426
442
|
function getExtension(filePath) {
|
|
427
443
|
const lastSegment = filePath.split("/").pop() ?? filePath;
|
package/dist/index.js
CHANGED
|
@@ -139,7 +139,7 @@ function checkAnalyticsCoverage(source, options, diagnostics2) {
|
|
|
139
139
|
}
|
|
140
140
|
}
|
|
141
141
|
function hasAnalyticsId(note) {
|
|
142
|
-
return hasField(note.fm.values, "id")
|
|
142
|
+
return hasField(note.fm.values, "id");
|
|
143
143
|
}
|
|
144
144
|
|
|
145
145
|
// src/checks/assets.ts
|
|
@@ -176,7 +176,7 @@ function checkContentIdIntegrity(source, options, diagnostics2) {
|
|
|
176
176
|
"invalid-content-id",
|
|
177
177
|
`invalid content id frontmatter: ${toErrorMessage(error)}`,
|
|
178
178
|
void 0,
|
|
179
|
-
'set a unique, trimmed "id" value
|
|
179
|
+
'set a unique, trimmed "id" value'
|
|
180
180
|
);
|
|
181
181
|
continue;
|
|
182
182
|
}
|
|
@@ -196,7 +196,7 @@ function checkContentIdIntegrity(source, options, diagnostics2) {
|
|
|
196
196
|
"duplicate-content-id",
|
|
197
197
|
`content id "${id}" is used by multiple published notes: ${slugs}`,
|
|
198
198
|
id,
|
|
199
|
-
'give each note a unique "id"
|
|
199
|
+
'give each note a unique "id" frontmatter value'
|
|
200
200
|
);
|
|
201
201
|
}
|
|
202
202
|
}
|
|
@@ -254,7 +254,11 @@ async function scanVault(config) {
|
|
|
254
254
|
const note = {
|
|
255
255
|
relativePath: relative,
|
|
256
256
|
slug,
|
|
257
|
-
language: resolveNoteLanguage(
|
|
257
|
+
language: resolveNoteLanguage(
|
|
258
|
+
relative,
|
|
259
|
+
fm.values.lang,
|
|
260
|
+
config.languages
|
|
261
|
+
),
|
|
258
262
|
markdown,
|
|
259
263
|
fm,
|
|
260
264
|
headings: extractHeadings(markdown),
|
|
@@ -413,11 +417,23 @@ function fragmentExists(note, fragment) {
|
|
|
413
417
|
function normalizeMarkdown(markdown) {
|
|
414
418
|
return markdown.replace(/\r\n/g, "\n");
|
|
415
419
|
}
|
|
416
|
-
function resolveNoteLanguage(relativePath, languages) {
|
|
420
|
+
function resolveNoteLanguage(relativePath, frontmatterLang, languages) {
|
|
417
421
|
if (!languages || languages.length === 0) return void 0;
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
422
|
+
if (typeof frontmatterLang === "string" && frontmatterLang.trim()) {
|
|
423
|
+
const value = frontmatterLang.trim().toLowerCase();
|
|
424
|
+
const match = languages.find(
|
|
425
|
+
(language) => language.toLowerCase() === value
|
|
426
|
+
);
|
|
427
|
+
if (match) return match;
|
|
428
|
+
}
|
|
429
|
+
const filename = relativePath.split("/").at(-1)?.replace(/\.md$/i, "") ?? "";
|
|
430
|
+
const lower = filename.toLowerCase();
|
|
431
|
+
for (const language of languages) {
|
|
432
|
+
const tag = language.toLowerCase();
|
|
433
|
+
if (lower.endsWith(`.${tag}`) || lower.endsWith(`-${tag}`) || lower.endsWith(`_${tag}`))
|
|
434
|
+
return language;
|
|
435
|
+
}
|
|
436
|
+
return void 0;
|
|
421
437
|
}
|
|
422
438
|
function getExtension(filePath) {
|
|
423
439
|
const lastSegment = filePath.split("/").pop() ?? filePath;
|
|
@@ -991,6 +1007,7 @@ var PLUGIN_NAME2 = "diagnostics";
|
|
|
991
1007
|
var DEFAULT_SEVERITY2 = {
|
|
992
1008
|
"content-integrity:broken-link": "warning",
|
|
993
1009
|
"content-integrity:unresolved-wikilink": "warning",
|
|
1010
|
+
"content-integrity:ambiguous-wikilink": "warning",
|
|
994
1011
|
"content-integrity:broken-asset": "warning",
|
|
995
1012
|
"content-integrity:duplicate-public-location": "error",
|
|
996
1013
|
"content-integrity:redirect-target-missing": "warning",
|
|
@@ -1006,8 +1023,18 @@ function checkSiteIntegrity(manifest, options = {}) {
|
|
|
1006
1023
|
const routeOwners = buildRouteOwners(manifest, diagnostics2, options);
|
|
1007
1024
|
const publicRoutes = new Set(routeOwners.keys());
|
|
1008
1025
|
const assetPaths = buildAssetPaths(manifest);
|
|
1026
|
+
const noteSlugs = new Set(manifest.entries.map((entry) => entry.slug));
|
|
1027
|
+
const ambiguities = manifest.contentIndexAmbiguities ?? /* @__PURE__ */ new Map();
|
|
1009
1028
|
for (const entry of manifest.publicEntries) {
|
|
1010
|
-
checkResolvedLinks(
|
|
1029
|
+
checkResolvedLinks(
|
|
1030
|
+
entry,
|
|
1031
|
+
publicSlugs,
|
|
1032
|
+
assetPaths,
|
|
1033
|
+
noteSlugs,
|
|
1034
|
+
ambiguities,
|
|
1035
|
+
diagnostics2,
|
|
1036
|
+
options
|
|
1037
|
+
);
|
|
1011
1038
|
checkHtmlReferences(entry, publicRoutes, assetPaths, diagnostics2, options);
|
|
1012
1039
|
}
|
|
1013
1040
|
checkRedirectTargets(manifest, diagnostics2, options);
|
|
@@ -1049,13 +1076,6 @@ function buildRouteOwners(manifest, diagnostics2, options) {
|
|
|
1049
1076
|
});
|
|
1050
1077
|
}
|
|
1051
1078
|
}
|
|
1052
|
-
for (const output of manifest.generatedOutputs ?? []) {
|
|
1053
|
-
reserveRoute(owners, diagnostics2, options, {
|
|
1054
|
-
path: normalizeRoutePath(output.path),
|
|
1055
|
-
slug: "(generated-output)",
|
|
1056
|
-
kind: "output"
|
|
1057
|
-
});
|
|
1058
|
-
}
|
|
1059
1079
|
return owners;
|
|
1060
1080
|
}
|
|
1061
1081
|
function reserveRoute(owners, diagnostics2, options, next) {
|
|
@@ -1072,14 +1092,29 @@ function reserveRoute(owners, diagnostics2, options, next) {
|
|
|
1072
1092
|
slug: next.slug.startsWith("(") ? void 0 : next.slug,
|
|
1073
1093
|
target: next.path,
|
|
1074
1094
|
message: `Public path "${next.path}" is claimed by both ${describeOwner(previous)} and ${describeOwner(next)}.`,
|
|
1075
|
-
suggestion: "Change one permalink, alias, redirect,
|
|
1095
|
+
suggestion: "Change one permalink, alias, redirect, or page path.",
|
|
1076
1096
|
meta: { previous, next }
|
|
1077
1097
|
})
|
|
1078
1098
|
);
|
|
1079
1099
|
}
|
|
1080
|
-
function checkResolvedLinks(entry, publicSlugs, assetPaths, diagnostics2, options) {
|
|
1100
|
+
function checkResolvedLinks(entry, publicSlugs, assetPaths, noteSlugs, ambiguities, diagnostics2, options) {
|
|
1081
1101
|
for (const link of entry.links) {
|
|
1082
1102
|
if (link.kind === "unresolved") {
|
|
1103
|
+
const ambiguity = ambiguities.get(link.raw.toLowerCase());
|
|
1104
|
+
if (ambiguity && ambiguity.length > 0) {
|
|
1105
|
+
diagnostics2.push(
|
|
1106
|
+
createAmbiguousWikilinkDiagnostic(
|
|
1107
|
+
entry,
|
|
1108
|
+
link.raw,
|
|
1109
|
+
link.embed,
|
|
1110
|
+
ambiguity,
|
|
1111
|
+
noteSlugs,
|
|
1112
|
+
publicSlugs,
|
|
1113
|
+
options
|
|
1114
|
+
)
|
|
1115
|
+
);
|
|
1116
|
+
continue;
|
|
1117
|
+
}
|
|
1083
1118
|
diagnostics2.push(
|
|
1084
1119
|
diagnostic(options, {
|
|
1085
1120
|
code: "content-integrity:unresolved-wikilink",
|
|
@@ -1125,6 +1160,37 @@ function checkResolvedLinks(entry, publicSlugs, assetPaths, diagnostics2, option
|
|
|
1125
1160
|
}
|
|
1126
1161
|
}
|
|
1127
1162
|
}
|
|
1163
|
+
function createAmbiguousWikilinkDiagnostic(entry, raw, embed, candidates, noteSlugs, publicSlugs, options) {
|
|
1164
|
+
const visible = [];
|
|
1165
|
+
let unpublishedCount = 0;
|
|
1166
|
+
for (const candidate of candidates) {
|
|
1167
|
+
if (!noteSlugs.has(candidate)) {
|
|
1168
|
+
visible.push(candidate);
|
|
1169
|
+
continue;
|
|
1170
|
+
}
|
|
1171
|
+
if (publicSlugs.has(candidate)) visible.push(candidate);
|
|
1172
|
+
else unpublishedCount += 1;
|
|
1173
|
+
}
|
|
1174
|
+
const described = [...visible];
|
|
1175
|
+
if (unpublishedCount > 0)
|
|
1176
|
+
described.push(`${unpublishedCount} unpublished candidate(s)`);
|
|
1177
|
+
const first = visible[0];
|
|
1178
|
+
const suggestion = first ? `Use an explicit path such as [[${first}]].` : "Rename one of the duplicate targets or reference it by a unique path.";
|
|
1179
|
+
return diagnostic(options, {
|
|
1180
|
+
code: "content-integrity:ambiguous-wikilink",
|
|
1181
|
+
slug: entry.slug,
|
|
1182
|
+
filePath: `${entry.slug}.md`,
|
|
1183
|
+
target: raw,
|
|
1184
|
+
message: `WikiLink target "${raw}" matches multiple candidates: ${described.join(", ")}.`,
|
|
1185
|
+
suggestion,
|
|
1186
|
+
meta: {
|
|
1187
|
+
raw,
|
|
1188
|
+
embed,
|
|
1189
|
+
candidates: visible,
|
|
1190
|
+
...unpublishedCount > 0 ? { unpublishedCandidateCount: unpublishedCount } : {}
|
|
1191
|
+
}
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1128
1194
|
function checkHtmlReferences(entry, publicRoutes, assetPaths, diagnostics2, options) {
|
|
1129
1195
|
for (const href of extractAttributes(entry.html, "href")) {
|
|
1130
1196
|
if (!isSiteLocalReference(href)) continue;
|
|
@@ -1225,8 +1291,6 @@ function buildAssetPaths(manifest) {
|
|
|
1225
1291
|
}
|
|
1226
1292
|
for (const asset of manifest.assets ?? [])
|
|
1227
1293
|
paths.add(asset.path.replace(/^\/+/, ""));
|
|
1228
|
-
for (const output of manifest.generatedOutputs ?? [])
|
|
1229
|
-
paths.add(output.path.replace(/^\/+/, ""));
|
|
1230
1294
|
return paths;
|
|
1231
1295
|
}
|
|
1232
1296
|
function extractAttributes(html, attribute) {
|
|
@@ -1359,7 +1423,7 @@ function diagnostics(options = {}) {
|
|
|
1359
1423
|
onManifestCreated: ({ manifest, diagnostics: diagnostics2 }) => {
|
|
1360
1424
|
diagnostics2.push(...checkSiteIntegrity(manifest, options));
|
|
1361
1425
|
},
|
|
1362
|
-
|
|
1426
|
+
buildEnd: ({ manifest }) => {
|
|
1363
1427
|
if (!options.failOnError) return void 0;
|
|
1364
1428
|
const errors = manifest.diagnostics.filter(
|
|
1365
1429
|
(diagnostic2) => diagnostic2.pluginName === PLUGIN_NAME3 && diagnostic2.severity === "error"
|
|
@@ -5,8 +5,8 @@ import { type NormalizedOptions } from "./shared.js";
|
|
|
5
5
|
* Reports published content that the analytics plugin will not track.
|
|
6
6
|
*
|
|
7
7
|
* The analytics browser initializer emits a page view only for entries that
|
|
8
|
-
* carry Core's source-authored stable content ID (`id
|
|
9
|
-
*
|
|
8
|
+
* carry Core's source-authored stable content ID (`id`). Content without that
|
|
9
|
+
* ID builds and publishes normally but is silently
|
|
10
10
|
* absent from analytics. This check surfaces that gap as factual, read-only
|
|
11
11
|
* `info` findings; the analytics plugin itself never autofixes content.
|
|
12
12
|
*/
|
|
@@ -5,9 +5,8 @@ import { type NormalizedOptions } from "./shared.js";
|
|
|
5
5
|
* Checks stable content ID integrity.
|
|
6
6
|
*
|
|
7
7
|
* Stable content IDs come from the optional source-authored `id` frontmatter
|
|
8
|
-
* field
|
|
9
|
-
*
|
|
10
|
-
* merge per-content metrics and conflicting declarations break the build.
|
|
8
|
+
* field. They are the identity key for per-content consumers such as analytics
|
|
9
|
+
* page views, so duplicates silently merge per-content metrics.
|
|
11
10
|
* This check is intentionally generic: it validates the content-model invariant
|
|
12
11
|
* without knowing which plugin consumes the ID.
|
|
13
12
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@riebeckite/plugin-diagnostics",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.18",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -20,7 +20,7 @@
|
|
|
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.18"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@types/mdast": "^4.0.4",
|