@riebeckite/plugin-diagnostics 0.0.13 → 0.0.15
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 +37 -6
- package/README_ja.md +22 -6
- package/dist/bin.js +49 -25
- package/dist/index.js +424 -54
- package/dist/src/site_integrity.d.ts +5 -0
- package/dist/src/types.d.ts +2 -0
- package/dist/src/vault.d.ts +1 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# @riebeckite/plugin-diagnostics
|
|
2
2
|
|
|
3
|
-
Content diagnostics for Obsidian vaults / Riebeckite content:
|
|
4
|
-
frontmatter issues, orphan notes, unused assets, and more.
|
|
5
|
-
plugin, a programmatic API, and a CLI.
|
|
3
|
+
Content diagnostics for Obsidian vaults / Riebeckite content: site-wide
|
|
4
|
+
reference integrity, frontmatter issues, orphan notes, unused assets, and more.
|
|
5
|
+
Usable as a build plugin, a programmatic API, and a CLI.
|
|
6
6
|
|
|
7
7
|
[日本語](./README_ja.md)
|
|
8
8
|
|
|
@@ -44,17 +44,47 @@ export default defineConfig({
|
|
|
44
44
|
| `broken-link` | `error` | Markdown link points to a missing or excluded note/file |
|
|
45
45
|
| `unused-asset` | `warning` | Image never referenced by any note (`reportUnusedAssets`) |
|
|
46
46
|
| `orphan-note` | `info` | Published note has no incoming links (`reportOrphans`) |
|
|
47
|
-
| `missing-frontmatter` | `warning` |
|
|
47
|
+
| `missing-frontmatter` | `warning` | A required frontmatter field is missing |
|
|
48
48
|
| `publish-conflict` | `warning` | `publish: true` combined with `draft: true` / `private: true` |
|
|
49
|
-
| `duplicate-title` | `warning` | Multiple published notes share a title |
|
|
49
|
+
| `duplicate-title` | `warning` | Multiple published notes share a title within the same language |
|
|
50
50
|
| `slug-collision` | `error` | Slugs collide case-insensitively |
|
|
51
51
|
| `duplicate-content-id` | `error` | Published notes share a stable content ID (`id`/`uid`) |
|
|
52
52
|
| `invalid-content-id` | `error` | `id`/`uid` 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) |
|
|
56
|
+
| `content-integrity:broken-link` | `warning` | A published page links to a site-local route that is not published or registered |
|
|
57
|
+
| `content-integrity:unresolved-wikilink` | `warning` | A published entry contains a WikiLink that the configured WikiLink/content index resolution did not resolve |
|
|
58
|
+
| `content-integrity:broken-asset` | `warning` | A published page references a local asset that is not in resolved content assets, plugin assets, generated outputs, or public routes |
|
|
59
|
+
| `content-integrity:duplicate-public-location` | `error` | Two published entries or generated routes claim the same final public path |
|
|
60
|
+
| `content-integrity:redirect-target-missing` | `warning` | A public redirect points at content whose public target is unavailable |
|
|
61
|
+
| `content-integrity:redirect-cycle` | `error` | Public redirects form a cycle |
|
|
62
|
+
| `content-integrity:redirect-public-location-conflict` | `error` | A redirect path conflicts with another public route |
|
|
56
63
|
| `internal-error` | `error` | Content analysis failed |
|
|
57
64
|
|
|
65
|
+
## Site-wide content integrity
|
|
66
|
+
|
|
67
|
+
When used as a Riebeckite plugin, diagnostics runs site-wide integrity checks
|
|
68
|
+
from the resolved manifest instead of rescanning content for link rules. It uses
|
|
69
|
+
`publicEntries`, resolved `ContentLink` metadata, `ContentPublicLocation`,
|
|
70
|
+
`publicRedirects`, plugin page paths, plugin assets, and generated outputs.
|
|
71
|
+
This keeps permalink, alias, rename redirect, l10n, publish/exclude, and Page
|
|
72
|
+
System behavior aligned with the build pipeline.
|
|
73
|
+
|
|
74
|
+
It does not check external HTTP reachability, SEO, spelling, Lighthouse, or
|
|
75
|
+
automatic repairs. External URLs (`http:`, `https:`, `mailto:`, `tel:`, `data:`),
|
|
76
|
+
protocol-relative URLs, and fragment-only links are ignored. Query strings and
|
|
77
|
+
fragments are stripped before route existence checks. Plugin authors should
|
|
78
|
+
register generated routes through Page Types and generated files/assets through
|
|
79
|
+
the existing plugin output/asset contracts so integrity checks can treat them as
|
|
80
|
+
valid public targets.
|
|
81
|
+
|
|
82
|
+
`runDiagnostics()` and the standalone CLI still use the filesystem/content-source
|
|
83
|
+
analyzer because no full manifest is available in that mode. As a result,
|
|
84
|
+
`orphan-note` and `unused-asset` are reported only by `runDiagnostics()` and the
|
|
85
|
+
standalone CLI; during a build they are omitted because link relationships come
|
|
86
|
+
from the manifest.
|
|
87
|
+
|
|
58
88
|
## Options
|
|
59
89
|
|
|
60
90
|
| Option | Type | Default | Description |
|
|
@@ -129,4 +159,5 @@ assertNoErrors(report);
|
|
|
129
159
|
|
|
130
160
|
## See also
|
|
131
161
|
|
|
132
|
-
- [Plugin guide](../../../docs/en/reference/plugin-api.md)
|
|
162
|
+
- [Plugin guide](../../../docs/en/docs/reference/plugin-api.md)
|
|
163
|
+
|
package/README_ja.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @riebeckite/plugin-diagnostics
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
サイト全体の参照整合性、公開設定の矛盾、frontmatter の不足などを検出するプラグインです。ビルド時の診断だけでなく、CLI とプログラムからの実行にも対応します。
|
|
4
4
|
|
|
5
5
|
[English](./README.md)
|
|
6
6
|
|
|
@@ -28,24 +28,39 @@ export default defineConfig({
|
|
|
28
28
|
|
|
29
29
|
| コード | 既定の重要度 | 内容 |
|
|
30
30
|
| --- | --- | --- |
|
|
31
|
-
| `broken-wikilink` | error | Wikiリンクの参照先またはフラグメントを解決できない |
|
|
31
|
+
| `broken-wikilink` | error | Wiki リンクの参照先またはフラグメントを解決できない |
|
|
32
32
|
| `broken-image` | error | 埋め込み画像または画像リンクの参照先がない |
|
|
33
33
|
| `broken-link` | error | Markdown リンクが存在しない、または除外されたノートを指す |
|
|
34
34
|
| `unused-asset` | warning | どのノートからも参照されない画像 |
|
|
35
35
|
| `orphan-note` | info | 公開ノートのうち、他のノートからリンクされていないもの |
|
|
36
|
-
| `missing-frontmatter` | warning |
|
|
36
|
+
| `missing-frontmatter` | warning | 必須フィールドが欠けている |
|
|
37
37
|
| `publish-conflict` | warning | `publish: true` と `draft: true` または `private: true` が同居している |
|
|
38
|
-
| `duplicate-title` | warning |
|
|
38
|
+
| `duplicate-title` | warning | 同じ言語の公開ノート同士でタイトルが重複している |
|
|
39
39
|
| `slug-collision` | error | 大文字・小文字を区別しない slug が衝突している |
|
|
40
40
|
| `duplicate-content-id` | error | 公開ノート同士で安定コンテンツ ID(`id`/`uid`)が重複している |
|
|
41
|
-
| `invalid-content-id` | error | `id`/`uid` の frontmatter が安定コンテンツ ID
|
|
41
|
+
| `invalid-content-id` | error | `id`/`uid` の frontmatter が安定コンテンツ ID の要件を満たしていない |
|
|
42
42
|
| `excluded-public` | warning | 除外されたノートに `publish: true` が指定されている |
|
|
43
43
|
| `publish-boundary` | warning | 公開コンテンツから非公開コンテンツへリンク・埋め込みしている |
|
|
44
44
|
| `analytics-untracked` | info | 安定 `id` のない公開ノートは analytics プラグインで計測されない(`reportAnalyticsCoverage` で有効化。設定で analytics プラグインが有効な場合は自動で有効化) |
|
|
45
|
+
| `content-integrity:broken-link` | warning | 公開ページが、公開または登録されていないサイト内経路へリンクしている |
|
|
46
|
+
| `content-integrity:unresolved-wikilink` | warning | 公開ページ内の Wiki リンクを、設定済みの解決規則で解決できない |
|
|
47
|
+
| `content-integrity:broken-asset` | warning | 公開ページが、解決済みアセット・プラグインアセット・生成出力・公開経路のいずれにもないローカルアセットを参照している |
|
|
48
|
+
| `content-integrity:duplicate-public-location` | error | 複数の公開コンテンツまたは生成経路が、同じ最終公開パスを所有している |
|
|
49
|
+
| `content-integrity:redirect-target-missing` | warning | 公開リダイレクトの転送先コンテンツが公開されない |
|
|
50
|
+
| `content-integrity:redirect-cycle` | error | 公開リダイレクトが循環している |
|
|
51
|
+
| `content-integrity:redirect-public-location-conflict` | error | リダイレクト元のパスが別の公開経路と衝突している |
|
|
45
52
|
| `internal-error` | error | コンテンツ解析中に処理できないエラーが発生した |
|
|
46
53
|
|
|
47
54
|
未使用アセットと孤立ノートは、明示的に有効化した場合だけ確認します。意図的に孤立させるトップページなどがあるなら、`reportOrphans` の結果を公開方針と照らして判断してください。
|
|
48
55
|
|
|
56
|
+
## サイト全体の参照整合性
|
|
57
|
+
|
|
58
|
+
Riebeckite のプラグインとして使う場合、リンク関係の検査はコンテンツを再走査せず、解決済み manifest から行います。`publicEntries`、解決済み `ContentLink`、`ContentPublicLocation`、`publicRedirects`、プラグインページのパス、プラグインアセット、生成出力を参照します。これにより permalink、alias、rename によるリダイレクト、l10n、公開・除外設定、Page System の扱いがビルド処理と揃います。
|
|
59
|
+
|
|
60
|
+
外部 URL の疎通確認、SEO、スペルチェック、Lighthouse、自動修復は行いません。`http:`、`https:`、`mailto:`、`tel:`、`data:`、プロトコル相対 URL、ページ内フラグメントだけのリンクは対象外です。経路の存在確認では、クエリ文字列とフラグメントを除いて判定します。生成ページや生成アセットを提供するプラグインは、Page Type、生成出力、アセット登録の既存の仕組みを使ってください。登録されていれば、整合性検査でも有効な公開先として扱われます。
|
|
61
|
+
|
|
62
|
+
`runDiagnostics()` と単体 CLI は、完全な manifest を持たないため、従来の content source 解析を使います。そのため `orphan-note` と `unused-asset` は `runDiagnostics()` と単体 CLI でのみ報告されます。ビルド時はリンク関係を manifest から解決するため、これらは出力しません。
|
|
63
|
+
|
|
49
64
|
## 設定項目
|
|
50
65
|
|
|
51
66
|
| 項目 | 既定値 | 説明 |
|
|
@@ -100,4 +115,5 @@ assertNoErrors(report);
|
|
|
100
115
|
|
|
101
116
|
## 関連資料
|
|
102
117
|
|
|
103
|
-
- [プラグインシステム](../../../docs/ja/reference/plugin-api.md)
|
|
118
|
+
- [プラグインシステム](../../../docs/ja/docs/reference/plugin-api.md)
|
|
119
|
+
|
package/dist/bin.js
CHANGED
|
@@ -258,6 +258,7 @@ async function scanVault(config) {
|
|
|
258
258
|
const note = {
|
|
259
259
|
relativePath: relative,
|
|
260
260
|
slug,
|
|
261
|
+
language: resolveNoteLanguage(relative, config.languages),
|
|
261
262
|
markdown,
|
|
262
263
|
fm,
|
|
263
264
|
headings: extractHeadings(markdown),
|
|
@@ -416,6 +417,12 @@ function fragmentExists(note, fragment) {
|
|
|
416
417
|
function normalizeMarkdown(markdown) {
|
|
417
418
|
return markdown.replace(/\r\n/g, "\n");
|
|
418
419
|
}
|
|
420
|
+
function resolveNoteLanguage(relativePath, languages) {
|
|
421
|
+
if (!languages || languages.length === 0) return void 0;
|
|
422
|
+
const segment = relativePath.split("/")[0]?.toLowerCase();
|
|
423
|
+
if (!segment) return void 0;
|
|
424
|
+
return languages.find((language) => language.toLowerCase() === segment);
|
|
425
|
+
}
|
|
419
426
|
function getExtension(filePath) {
|
|
420
427
|
const lastSegment = filePath.split("/").pop() ?? filePath;
|
|
421
428
|
const dotIndex = lastSegment.lastIndexOf(".");
|
|
@@ -444,17 +451,7 @@ function normalizeRelativePath(parts) {
|
|
|
444
451
|
function checkFrontmatter(note, source, state, options, diagnostics) {
|
|
445
452
|
const location = { slug: note.slug, filePath: note.relativePath };
|
|
446
453
|
const values = note.fm.values;
|
|
447
|
-
if (
|
|
448
|
-
pushDiagnostic(
|
|
449
|
-
diagnostics,
|
|
450
|
-
options,
|
|
451
|
-
location,
|
|
452
|
-
"missing-frontmatter",
|
|
453
|
-
"note has no frontmatter",
|
|
454
|
-
note.slug,
|
|
455
|
-
"add frontmatter with the required fields"
|
|
456
|
-
);
|
|
457
|
-
} else {
|
|
454
|
+
if (note.fm.hasFrontmatter) {
|
|
458
455
|
for (const field of options.requiredFrontmatter) {
|
|
459
456
|
if (!hasField(values, field))
|
|
460
457
|
pushDiagnostic(
|
|
@@ -467,6 +464,16 @@ function checkFrontmatter(note, source, state, options, diagnostics) {
|
|
|
467
464
|
`add "${field}" to the frontmatter`
|
|
468
465
|
);
|
|
469
466
|
}
|
|
467
|
+
} else if (options.requiredFrontmatter.length > 0) {
|
|
468
|
+
pushDiagnostic(
|
|
469
|
+
diagnostics,
|
|
470
|
+
options,
|
|
471
|
+
location,
|
|
472
|
+
"missing-frontmatter",
|
|
473
|
+
"note has no frontmatter",
|
|
474
|
+
note.slug,
|
|
475
|
+
"add frontmatter with the required fields"
|
|
476
|
+
);
|
|
470
477
|
}
|
|
471
478
|
if (values.publish === true && values.draft === true)
|
|
472
479
|
pushDiagnostic(
|
|
@@ -629,10 +636,11 @@ function checkSlugCollisions(source, options, diagnostics) {
|
|
|
629
636
|
function checkDuplicateTitles(source, options, diagnostics) {
|
|
630
637
|
const byTitle = groupNotes(
|
|
631
638
|
source.includedNotes.filter((note) => note.published),
|
|
632
|
-
(note) => getNoteTitle(note.fm.values, note.slug).toLowerCase()
|
|
639
|
+
(note) => `${note.language ?? ""}\0${getNoteTitle(note.fm.values, note.slug).toLowerCase()}`
|
|
633
640
|
);
|
|
634
|
-
for (const [
|
|
641
|
+
for (const [groupKey, notes] of byTitle) {
|
|
635
642
|
if (notes.length < 2) continue;
|
|
643
|
+
const title = groupKey.slice(groupKey.indexOf("\0") + 1);
|
|
636
644
|
const slugs = notes.map((note) => note.slug).join(", ");
|
|
637
645
|
for (const note of notes)
|
|
638
646
|
pushDiagnostic(
|
|
@@ -818,23 +826,28 @@ async function analyzeContent(config, options = {}) {
|
|
|
818
826
|
const state = createAnalysisState();
|
|
819
827
|
const diagnostics = [];
|
|
820
828
|
for (const note of source.includedNotes) {
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
+
if (!options.skipReferenceIntegrity) {
|
|
830
|
+
checkWikilinks(note, source, state, normalizedOptions, diagnostics);
|
|
831
|
+
checkMarkdownReferences(
|
|
832
|
+
note,
|
|
833
|
+
source,
|
|
834
|
+
state,
|
|
835
|
+
normalizedOptions,
|
|
836
|
+
diagnostics
|
|
837
|
+
);
|
|
838
|
+
}
|
|
829
839
|
checkFrontmatter(note, source, state, normalizedOptions, diagnostics);
|
|
830
840
|
}
|
|
831
841
|
checkSlugCollisions(source, normalizedOptions, diagnostics);
|
|
832
842
|
checkDuplicateTitles(source, normalizedOptions, diagnostics);
|
|
833
843
|
checkContentIdIntegrity(source, normalizedOptions, diagnostics);
|
|
834
|
-
|
|
835
|
-
|
|
844
|
+
if (!options.skipReferenceIntegrity) {
|
|
845
|
+
checkOrphans(source, state, normalizedOptions, diagnostics);
|
|
846
|
+
checkUnusedAssets(source, state, normalizedOptions, diagnostics);
|
|
847
|
+
}
|
|
836
848
|
checkExcludedPublic(source, normalizedOptions, diagnostics);
|
|
837
|
-
|
|
849
|
+
if (!options.skipReferenceIntegrity)
|
|
850
|
+
checkPublishBoundary(source, normalizedOptions, diagnostics);
|
|
838
851
|
checkAnalyticsCoverage(source, normalizedOptions, diagnostics);
|
|
839
852
|
return diagnostics;
|
|
840
853
|
}
|
|
@@ -975,6 +988,7 @@ async function runDiagnostics(target, options = {}) {
|
|
|
975
988
|
}
|
|
976
989
|
}
|
|
977
990
|
var ANALYTICS_PLUGIN_NAME = "analytics";
|
|
991
|
+
var L10N_PLUGIN_NAME = "l10n";
|
|
978
992
|
function hasEnabledAnalyticsPlugin(config) {
|
|
979
993
|
return config?.plugins.some(
|
|
980
994
|
(plugin) => plugin.name === ANALYTICS_PLUGIN_NAME && plugin.enabled !== false
|
|
@@ -1004,9 +1018,19 @@ function toAnalyzerConfig(target, options) {
|
|
|
1004
1018
|
target.content.exclude
|
|
1005
1019
|
),
|
|
1006
1020
|
exclude: [...target.content.exclude, ...options.exclude ?? []],
|
|
1007
|
-
publishStrategy: target.content.filters.publishStrategy
|
|
1021
|
+
publishStrategy: target.content.filters.publishStrategy,
|
|
1022
|
+
languages: resolveLanguages(target)
|
|
1008
1023
|
};
|
|
1009
1024
|
}
|
|
1025
|
+
function resolveLanguages(config) {
|
|
1026
|
+
const plugin = config.plugins.find(
|
|
1027
|
+
(entry) => entry.name === L10N_PLUGIN_NAME && entry.enabled !== false
|
|
1028
|
+
);
|
|
1029
|
+
const languages = plugin?.options?.languages;
|
|
1030
|
+
return Array.isArray(languages) ? languages.filter(
|
|
1031
|
+
(language) => typeof language === "string"
|
|
1032
|
+
) : [];
|
|
1033
|
+
}
|
|
1010
1034
|
|
|
1011
1035
|
// bin.ts
|
|
1012
1036
|
var HELP = `riebeckite-diagnostics
|
package/dist/index.js
CHANGED
|
@@ -254,6 +254,7 @@ async function scanVault(config) {
|
|
|
254
254
|
const note = {
|
|
255
255
|
relativePath: relative,
|
|
256
256
|
slug,
|
|
257
|
+
language: resolveNoteLanguage(relative, config.languages),
|
|
257
258
|
markdown,
|
|
258
259
|
fm,
|
|
259
260
|
headings: extractHeadings(markdown),
|
|
@@ -412,6 +413,12 @@ function fragmentExists(note, fragment) {
|
|
|
412
413
|
function normalizeMarkdown(markdown) {
|
|
413
414
|
return markdown.replace(/\r\n/g, "\n");
|
|
414
415
|
}
|
|
416
|
+
function resolveNoteLanguage(relativePath, languages) {
|
|
417
|
+
if (!languages || languages.length === 0) return void 0;
|
|
418
|
+
const segment = relativePath.split("/")[0]?.toLowerCase();
|
|
419
|
+
if (!segment) return void 0;
|
|
420
|
+
return languages.find((language) => language.toLowerCase() === segment);
|
|
421
|
+
}
|
|
415
422
|
function getExtension(filePath) {
|
|
416
423
|
const lastSegment = filePath.split("/").pop() ?? filePath;
|
|
417
424
|
const dotIndex = lastSegment.lastIndexOf(".");
|
|
@@ -440,17 +447,7 @@ function normalizeRelativePath(parts) {
|
|
|
440
447
|
function checkFrontmatter(note, source, state, options, diagnostics2) {
|
|
441
448
|
const location = { slug: note.slug, filePath: note.relativePath };
|
|
442
449
|
const values = note.fm.values;
|
|
443
|
-
if (
|
|
444
|
-
pushDiagnostic(
|
|
445
|
-
diagnostics2,
|
|
446
|
-
options,
|
|
447
|
-
location,
|
|
448
|
-
"missing-frontmatter",
|
|
449
|
-
"note has no frontmatter",
|
|
450
|
-
note.slug,
|
|
451
|
-
"add frontmatter with the required fields"
|
|
452
|
-
);
|
|
453
|
-
} else {
|
|
450
|
+
if (note.fm.hasFrontmatter) {
|
|
454
451
|
for (const field of options.requiredFrontmatter) {
|
|
455
452
|
if (!hasField(values, field))
|
|
456
453
|
pushDiagnostic(
|
|
@@ -463,6 +460,16 @@ function checkFrontmatter(note, source, state, options, diagnostics2) {
|
|
|
463
460
|
`add "${field}" to the frontmatter`
|
|
464
461
|
);
|
|
465
462
|
}
|
|
463
|
+
} else if (options.requiredFrontmatter.length > 0) {
|
|
464
|
+
pushDiagnostic(
|
|
465
|
+
diagnostics2,
|
|
466
|
+
options,
|
|
467
|
+
location,
|
|
468
|
+
"missing-frontmatter",
|
|
469
|
+
"note has no frontmatter",
|
|
470
|
+
note.slug,
|
|
471
|
+
"add frontmatter with the required fields"
|
|
472
|
+
);
|
|
466
473
|
}
|
|
467
474
|
if (values.publish === true && values.draft === true)
|
|
468
475
|
pushDiagnostic(
|
|
@@ -625,10 +632,11 @@ function checkSlugCollisions(source, options, diagnostics2) {
|
|
|
625
632
|
function checkDuplicateTitles(source, options, diagnostics2) {
|
|
626
633
|
const byTitle = groupNotes(
|
|
627
634
|
source.includedNotes.filter((note) => note.published),
|
|
628
|
-
(note) => getNoteTitle(note.fm.values, note.slug).toLowerCase()
|
|
635
|
+
(note) => `${note.language ?? ""}\0${getNoteTitle(note.fm.values, note.slug).toLowerCase()}`
|
|
629
636
|
);
|
|
630
|
-
for (const [
|
|
637
|
+
for (const [groupKey, notes] of byTitle) {
|
|
631
638
|
if (notes.length < 2) continue;
|
|
639
|
+
const title = groupKey.slice(groupKey.indexOf("\0") + 1);
|
|
632
640
|
const slugs = notes.map((note) => note.slug).join(", ");
|
|
633
641
|
for (const note of notes)
|
|
634
642
|
pushDiagnostic(
|
|
@@ -814,23 +822,28 @@ async function analyzeContent(config, options = {}) {
|
|
|
814
822
|
const state = createAnalysisState();
|
|
815
823
|
const diagnostics2 = [];
|
|
816
824
|
for (const note of source.includedNotes) {
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
+
if (!options.skipReferenceIntegrity) {
|
|
826
|
+
checkWikilinks(note, source, state, normalizedOptions, diagnostics2);
|
|
827
|
+
checkMarkdownReferences(
|
|
828
|
+
note,
|
|
829
|
+
source,
|
|
830
|
+
state,
|
|
831
|
+
normalizedOptions,
|
|
832
|
+
diagnostics2
|
|
833
|
+
);
|
|
834
|
+
}
|
|
825
835
|
checkFrontmatter(note, source, state, normalizedOptions, diagnostics2);
|
|
826
836
|
}
|
|
827
837
|
checkSlugCollisions(source, normalizedOptions, diagnostics2);
|
|
828
838
|
checkDuplicateTitles(source, normalizedOptions, diagnostics2);
|
|
829
839
|
checkContentIdIntegrity(source, normalizedOptions, diagnostics2);
|
|
830
|
-
|
|
831
|
-
|
|
840
|
+
if (!options.skipReferenceIntegrity) {
|
|
841
|
+
checkOrphans(source, state, normalizedOptions, diagnostics2);
|
|
842
|
+
checkUnusedAssets(source, state, normalizedOptions, diagnostics2);
|
|
843
|
+
}
|
|
832
844
|
checkExcludedPublic(source, normalizedOptions, diagnostics2);
|
|
833
|
-
|
|
845
|
+
if (!options.skipReferenceIntegrity)
|
|
846
|
+
checkPublishBoundary(source, normalizedOptions, diagnostics2);
|
|
834
847
|
checkAnalyticsCoverage(source, normalizedOptions, diagnostics2);
|
|
835
848
|
return diagnostics2;
|
|
836
849
|
}
|
|
@@ -855,16 +868,16 @@ function buildReport(diagnostics2) {
|
|
|
855
868
|
const errors = [];
|
|
856
869
|
const warnings = [];
|
|
857
870
|
const infos = [];
|
|
858
|
-
for (const
|
|
859
|
-
switch (
|
|
871
|
+
for (const diagnostic2 of diagnostics2) {
|
|
872
|
+
switch (diagnostic2.severity) {
|
|
860
873
|
case "error":
|
|
861
|
-
errors.push(
|
|
874
|
+
errors.push(diagnostic2);
|
|
862
875
|
break;
|
|
863
876
|
case "warning":
|
|
864
|
-
warnings.push(
|
|
877
|
+
warnings.push(diagnostic2);
|
|
865
878
|
break;
|
|
866
879
|
default:
|
|
867
|
-
infos.push(
|
|
880
|
+
infos.push(diagnostic2);
|
|
868
881
|
break;
|
|
869
882
|
}
|
|
870
883
|
}
|
|
@@ -883,8 +896,8 @@ function summarize(diagnostics2) {
|
|
|
883
896
|
let error = 0;
|
|
884
897
|
let warning = 0;
|
|
885
898
|
let info = 0;
|
|
886
|
-
for (const
|
|
887
|
-
switch (
|
|
899
|
+
for (const diagnostic2 of diagnostics2) {
|
|
900
|
+
switch (diagnostic2.severity) {
|
|
888
901
|
case "error":
|
|
889
902
|
error++;
|
|
890
903
|
break;
|
|
@@ -900,10 +913,10 @@ function summarize(diagnostics2) {
|
|
|
900
913
|
}
|
|
901
914
|
function groupByCode(diagnostics2) {
|
|
902
915
|
const byCode = /* @__PURE__ */ new Map();
|
|
903
|
-
for (const
|
|
904
|
-
const group = byCode.get(
|
|
905
|
-
group.push(
|
|
906
|
-
byCode.set(
|
|
916
|
+
for (const diagnostic2 of diagnostics2) {
|
|
917
|
+
const group = byCode.get(diagnostic2.code) ?? [];
|
|
918
|
+
group.push(diagnostic2);
|
|
919
|
+
byCode.set(diagnostic2.code, group);
|
|
907
920
|
}
|
|
908
921
|
return byCode;
|
|
909
922
|
}
|
|
@@ -922,23 +935,23 @@ function formatDiagnostics(input, options = {}) {
|
|
|
922
935
|
);
|
|
923
936
|
lines.push("");
|
|
924
937
|
const ordered = [...report.diagnostics].sort(compareDiagnostics);
|
|
925
|
-
for (const
|
|
938
|
+
for (const diagnostic2 of ordered) {
|
|
926
939
|
const icon = {
|
|
927
940
|
error: `${color.error}✖`,
|
|
928
941
|
warning: `${color.yellow}⚠`,
|
|
929
942
|
info: `${color.cyan}ℹ`
|
|
930
|
-
}[
|
|
943
|
+
}[diagnostic2.severity];
|
|
931
944
|
const location = [
|
|
932
|
-
|
|
933
|
-
|
|
945
|
+
diagnostic2.filePath ?? "",
|
|
946
|
+
diagnostic2.line != null ? `:${diagnostic2.line}${diagnostic2.column != null ? `:${diagnostic2.column}` : ""}` : ""
|
|
934
947
|
].join("");
|
|
935
|
-
lines.push(`${icon} ${color.reset}[${
|
|
936
|
-
lines.push(` ${
|
|
937
|
-
if (
|
|
938
|
-
lines.push(` ${color.dim}target: ${
|
|
948
|
+
lines.push(`${icon} ${color.reset}[${diagnostic2.code}] ${location}`);
|
|
949
|
+
lines.push(` ${diagnostic2.message}`);
|
|
950
|
+
if (diagnostic2.target) {
|
|
951
|
+
lines.push(` ${color.dim}target: ${diagnostic2.target}${color.reset}`);
|
|
939
952
|
}
|
|
940
|
-
if (
|
|
941
|
-
lines.push(` ${color.cyan}→ ${
|
|
953
|
+
if (diagnostic2.suggestion) {
|
|
954
|
+
lines.push(` ${color.cyan}→ ${diagnostic2.suggestion}${color.reset}`);
|
|
942
955
|
}
|
|
943
956
|
}
|
|
944
957
|
return lines.join("\n");
|
|
@@ -973,8 +986,351 @@ var noColors = {
|
|
|
973
986
|
cyan: ""
|
|
974
987
|
};
|
|
975
988
|
|
|
976
|
-
//
|
|
989
|
+
// src/site_integrity.ts
|
|
977
990
|
var PLUGIN_NAME2 = "diagnostics";
|
|
991
|
+
var DEFAULT_SEVERITY2 = {
|
|
992
|
+
"content-integrity:broken-link": "warning",
|
|
993
|
+
"content-integrity:unresolved-wikilink": "warning",
|
|
994
|
+
"content-integrity:broken-asset": "warning",
|
|
995
|
+
"content-integrity:duplicate-public-location": "error",
|
|
996
|
+
"content-integrity:redirect-target-missing": "warning",
|
|
997
|
+
"content-integrity:redirect-cycle": "error",
|
|
998
|
+
"content-integrity:redirect-public-location-conflict": "error",
|
|
999
|
+
"content-integrity:ambiguous-folder-page-owner": "warning"
|
|
1000
|
+
};
|
|
1001
|
+
function checkSiteIntegrity(manifest, options = {}) {
|
|
1002
|
+
const diagnostics2 = [];
|
|
1003
|
+
const publicSlugs = new Set(
|
|
1004
|
+
manifest.publicEntries.map((entry) => entry.slug)
|
|
1005
|
+
);
|
|
1006
|
+
const routeOwners = buildRouteOwners(manifest, diagnostics2, options);
|
|
1007
|
+
const publicRoutes = new Set(routeOwners.keys());
|
|
1008
|
+
const assetPaths = buildAssetPaths(manifest);
|
|
1009
|
+
for (const entry of manifest.publicEntries) {
|
|
1010
|
+
checkResolvedLinks(entry, publicSlugs, assetPaths, diagnostics2, options);
|
|
1011
|
+
checkHtmlReferences(entry, publicRoutes, assetPaths, diagnostics2, options);
|
|
1012
|
+
}
|
|
1013
|
+
checkRedirectTargets(manifest, publicRoutes, diagnostics2, options);
|
|
1014
|
+
checkRedirectCycles(manifest, diagnostics2, options);
|
|
1015
|
+
checkAmbiguousFolderPageOwners(manifest, diagnostics2, options);
|
|
1016
|
+
return diagnostics2;
|
|
1017
|
+
}
|
|
1018
|
+
function buildRouteOwners(manifest, diagnostics2, options) {
|
|
1019
|
+
const owners = /* @__PURE__ */ new Map();
|
|
1020
|
+
for (const entry of manifest.publicEntries) {
|
|
1021
|
+
reserveRoute(owners, diagnostics2, options, {
|
|
1022
|
+
path: normalizeRoutePath(entry.publicLocation.permalink),
|
|
1023
|
+
slug: entry.slug,
|
|
1024
|
+
kind: "content"
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
for (const [path, redirect] of manifest.publicRedirects) {
|
|
1028
|
+
reserveRoute(owners, diagnostics2, options, {
|
|
1029
|
+
path: normalizeRoutePath(path),
|
|
1030
|
+
slug: redirect.slug,
|
|
1031
|
+
kind: "redirect"
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
const pageRoutes = manifest.pageRoutes ?? [];
|
|
1035
|
+
for (const route of pageRoutes) {
|
|
1036
|
+
reserveRoute(owners, diagnostics2, options, {
|
|
1037
|
+
path: normalizeRoutePath(route.pathname),
|
|
1038
|
+
slug: `(plugin-page:${route.pluginName}:${route.pageType})`,
|
|
1039
|
+
kind: "page",
|
|
1040
|
+
producer: `${route.pluginName}:${route.pageType}`
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
if (pageRoutes.length === 0) {
|
|
1044
|
+
for (const path of manifest.pagePaths ?? []) {
|
|
1045
|
+
reserveRoute(owners, diagnostics2, options, {
|
|
1046
|
+
path: normalizeRoutePath(path),
|
|
1047
|
+
slug: "(plugin-page)",
|
|
1048
|
+
kind: "page"
|
|
1049
|
+
});
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
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
|
+
return owners;
|
|
1060
|
+
}
|
|
1061
|
+
function reserveRoute(owners, diagnostics2, options, next) {
|
|
1062
|
+
const previous = owners.get(next.path);
|
|
1063
|
+
if (!previous) {
|
|
1064
|
+
owners.set(next.path, next);
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
if (previous.slug === next.slug && previous.kind === next.kind) return;
|
|
1068
|
+
const code = previous.kind === "redirect" || next.kind === "redirect" ? "content-integrity:redirect-public-location-conflict" : "content-integrity:duplicate-public-location";
|
|
1069
|
+
diagnostics2.push(
|
|
1070
|
+
diagnostic(options, {
|
|
1071
|
+
code,
|
|
1072
|
+
slug: next.slug.startsWith("(") ? void 0 : next.slug,
|
|
1073
|
+
target: next.path,
|
|
1074
|
+
message: `Public path "${next.path}" is claimed by both ${describeOwner(previous)} and ${describeOwner(next)}.`,
|
|
1075
|
+
suggestion: "Change one permalink, alias, redirect, page path, or generated output path.",
|
|
1076
|
+
meta: { previous, next }
|
|
1077
|
+
})
|
|
1078
|
+
);
|
|
1079
|
+
}
|
|
1080
|
+
function checkResolvedLinks(entry, publicSlugs, assetPaths, diagnostics2, options) {
|
|
1081
|
+
for (const link of entry.links) {
|
|
1082
|
+
if (link.kind === "unresolved") {
|
|
1083
|
+
diagnostics2.push(
|
|
1084
|
+
diagnostic(options, {
|
|
1085
|
+
code: "content-integrity:unresolved-wikilink",
|
|
1086
|
+
slug: entry.slug,
|
|
1087
|
+
filePath: `${entry.slug}.md`,
|
|
1088
|
+
target: link.raw,
|
|
1089
|
+
message: `WikiLink target "${link.raw}" could not be resolved.`,
|
|
1090
|
+
suggestion: "Create the target note/asset or update the WikiLink target.",
|
|
1091
|
+
meta: { raw: link.raw, embed: link.embed }
|
|
1092
|
+
})
|
|
1093
|
+
);
|
|
1094
|
+
continue;
|
|
1095
|
+
}
|
|
1096
|
+
if (link.kind === "note" && link.slug && !publicSlugs.has(link.slug)) {
|
|
1097
|
+
diagnostics2.push(
|
|
1098
|
+
diagnostic(options, {
|
|
1099
|
+
code: "content-integrity:broken-link",
|
|
1100
|
+
slug: entry.slug,
|
|
1101
|
+
filePath: `${entry.slug}.md`,
|
|
1102
|
+
target: link.slug,
|
|
1103
|
+
message: `Content link "${link.raw}" resolves to unpublished content "${link.slug}".`,
|
|
1104
|
+
suggestion: "Publish the target content or remove the reference from published content.",
|
|
1105
|
+
meta: {
|
|
1106
|
+
raw: link.raw,
|
|
1107
|
+
resolvedSlug: link.slug,
|
|
1108
|
+
reason: "unpublished-target"
|
|
1109
|
+
}
|
|
1110
|
+
})
|
|
1111
|
+
);
|
|
1112
|
+
}
|
|
1113
|
+
if ((link.kind === "image" || link.kind === "attachment") && link.slug && !assetPaths.has(link.slug)) {
|
|
1114
|
+
diagnostics2.push(
|
|
1115
|
+
diagnostic(options, {
|
|
1116
|
+
code: "content-integrity:broken-asset",
|
|
1117
|
+
slug: entry.slug,
|
|
1118
|
+
filePath: `${entry.slug}.md`,
|
|
1119
|
+
target: link.slug,
|
|
1120
|
+
message: `Asset reference "${link.raw}" resolves to "${link.slug}", but no asset is published for that path.`,
|
|
1121
|
+
suggestion: "Add the asset, fix the reference, or register the generated asset from a plugin.",
|
|
1122
|
+
meta: { raw: link.raw, resolvedPath: link.slug }
|
|
1123
|
+
})
|
|
1124
|
+
);
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
function checkHtmlReferences(entry, publicRoutes, assetPaths, diagnostics2, options) {
|
|
1129
|
+
for (const href of extractAttributes(entry.html, "href")) {
|
|
1130
|
+
if (!isSiteLocalReference(href)) continue;
|
|
1131
|
+
const path = normalizeReferencePath(href, entry.publicLocation.permalink);
|
|
1132
|
+
if (!path || publicRoutes.has(path)) continue;
|
|
1133
|
+
diagnostics2.push(
|
|
1134
|
+
diagnostic(options, {
|
|
1135
|
+
code: "content-integrity:broken-link",
|
|
1136
|
+
slug: entry.slug,
|
|
1137
|
+
filePath: entry.publicLocation.permalink,
|
|
1138
|
+
target: href,
|
|
1139
|
+
message: `Internal link target "${href}" does not match a public route.`,
|
|
1140
|
+
suggestion: "Update the link, create the target content, or register the generated route from a plugin.",
|
|
1141
|
+
meta: { normalizedTarget: path }
|
|
1142
|
+
})
|
|
1143
|
+
);
|
|
1144
|
+
}
|
|
1145
|
+
for (const src of extractAttributes(entry.html, "src")) {
|
|
1146
|
+
if (!isSiteLocalReference(src)) continue;
|
|
1147
|
+
const path = normalizeReferencePath(src, entry.publicLocation.permalink);
|
|
1148
|
+
const assetPath = path?.replace(/^\/+/, "");
|
|
1149
|
+
if (!path || publicRoutes.has(path) || assetPath && assetPaths.has(assetPath))
|
|
1150
|
+
continue;
|
|
1151
|
+
diagnostics2.push(
|
|
1152
|
+
diagnostic(options, {
|
|
1153
|
+
code: "content-integrity:broken-asset",
|
|
1154
|
+
slug: entry.slug,
|
|
1155
|
+
filePath: entry.publicLocation.permalink,
|
|
1156
|
+
target: src,
|
|
1157
|
+
message: `Local asset target "${src}" does not match a published asset or route.`,
|
|
1158
|
+
suggestion: "Add the asset, fix the reference, or register the generated asset from a plugin.",
|
|
1159
|
+
meta: { normalizedTarget: path }
|
|
1160
|
+
})
|
|
1161
|
+
);
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
function checkRedirectTargets(manifest, publicRoutes, diagnostics2, options) {
|
|
1165
|
+
for (const [path, redirect] of manifest.publicRedirects) {
|
|
1166
|
+
const owner = manifest.bySlug.get(redirect.slug);
|
|
1167
|
+
const target = owner ? normalizeRoutePath(owner.publicLocation.permalink) : null;
|
|
1168
|
+
if (target && publicRoutes.has(target)) continue;
|
|
1169
|
+
diagnostics2.push(
|
|
1170
|
+
diagnostic(options, {
|
|
1171
|
+
code: "content-integrity:redirect-target-missing",
|
|
1172
|
+
slug: redirect.slug,
|
|
1173
|
+
target: path,
|
|
1174
|
+
message: `Redirect "${path}" points to content "${redirect.slug}", but its public target is not available.`,
|
|
1175
|
+
suggestion: "Publish the target content or remove the redirect.",
|
|
1176
|
+
meta: { redirect: path, targetSlug: redirect.slug, target }
|
|
1177
|
+
})
|
|
1178
|
+
);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
function checkRedirectCycles(manifest, diagnostics2, options) {
|
|
1182
|
+
const redirectTargets = /* @__PURE__ */ new Map();
|
|
1183
|
+
const reportedCycles = /* @__PURE__ */ new Set();
|
|
1184
|
+
for (const [path, redirect] of manifest.publicRedirects) {
|
|
1185
|
+
const owner = manifest.bySlug.get(redirect.slug);
|
|
1186
|
+
if (owner)
|
|
1187
|
+
redirectTargets.set(
|
|
1188
|
+
normalizeRoutePath(path),
|
|
1189
|
+
normalizeRoutePath(owner.publicLocation.permalink)
|
|
1190
|
+
);
|
|
1191
|
+
}
|
|
1192
|
+
for (const start of redirectTargets.keys()) {
|
|
1193
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1194
|
+
const chain = [];
|
|
1195
|
+
let current = start;
|
|
1196
|
+
while (current) {
|
|
1197
|
+
const previousIndex = seen.get(current);
|
|
1198
|
+
if (previousIndex !== void 0) {
|
|
1199
|
+
const cycle = [...chain.slice(previousIndex), current];
|
|
1200
|
+
const key = canonicalCycleKey(cycle);
|
|
1201
|
+
if (reportedCycles.has(key)) break;
|
|
1202
|
+
reportedCycles.add(key);
|
|
1203
|
+
diagnostics2.push(
|
|
1204
|
+
diagnostic(options, {
|
|
1205
|
+
code: "content-integrity:redirect-cycle",
|
|
1206
|
+
target: cycle.join(" -> "),
|
|
1207
|
+
message: `Redirect cycle detected: ${cycle.join(" -> ")}.`,
|
|
1208
|
+
suggestion: "Remove or retarget one redirect in the cycle.",
|
|
1209
|
+
meta: { cycle }
|
|
1210
|
+
})
|
|
1211
|
+
);
|
|
1212
|
+
break;
|
|
1213
|
+
}
|
|
1214
|
+
seen.set(current, chain.length);
|
|
1215
|
+
chain.push(current);
|
|
1216
|
+
current = redirectTargets.get(current);
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
function buildAssetPaths(manifest) {
|
|
1221
|
+
const paths = /* @__PURE__ */ new Set();
|
|
1222
|
+
for (const entry of manifest.entries) {
|
|
1223
|
+
for (const asset of entry.assets) paths.add(asset.path);
|
|
1224
|
+
}
|
|
1225
|
+
for (const asset of manifest.assets ?? [])
|
|
1226
|
+
paths.add(asset.path.replace(/^\/+/, ""));
|
|
1227
|
+
for (const output of manifest.generatedOutputs ?? [])
|
|
1228
|
+
paths.add(output.path.replace(/^\/+/, ""));
|
|
1229
|
+
return paths;
|
|
1230
|
+
}
|
|
1231
|
+
function extractAttributes(html, attribute) {
|
|
1232
|
+
const pattern = new RegExp(
|
|
1233
|
+
`${attribute}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`,
|
|
1234
|
+
"gi"
|
|
1235
|
+
);
|
|
1236
|
+
const values = [];
|
|
1237
|
+
for (let match = pattern.exec(html); match; match = pattern.exec(html)) {
|
|
1238
|
+
const value = match[1] ?? match[2] ?? match[3];
|
|
1239
|
+
if (value) values.push(value);
|
|
1240
|
+
}
|
|
1241
|
+
return values;
|
|
1242
|
+
}
|
|
1243
|
+
function isSiteLocalReference(value) {
|
|
1244
|
+
if (!value || value.startsWith("#") || value.startsWith("//")) return false;
|
|
1245
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(value)) return false;
|
|
1246
|
+
return true;
|
|
1247
|
+
}
|
|
1248
|
+
function normalizeReferencePath(value, basePath) {
|
|
1249
|
+
const path = value.split(/[?#]/)[0] ?? "";
|
|
1250
|
+
if (!path || path === "/") return path || null;
|
|
1251
|
+
try {
|
|
1252
|
+
return normalizeRoutePath(resolveReferencePath(decodeURI(path), basePath));
|
|
1253
|
+
} catch {
|
|
1254
|
+
return null;
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
function resolveReferencePath(path, basePath) {
|
|
1258
|
+
if (path.startsWith("/")) return path;
|
|
1259
|
+
const baseSegments = basePath.split("/").filter(Boolean);
|
|
1260
|
+
if (!basePath.endsWith("/")) baseSegments.pop();
|
|
1261
|
+
const segments = [...baseSegments, ...path.split("/")];
|
|
1262
|
+
const normalized = [];
|
|
1263
|
+
for (const segment of segments) {
|
|
1264
|
+
if (!segment || segment === ".") continue;
|
|
1265
|
+
if (segment === "..") {
|
|
1266
|
+
normalized.pop();
|
|
1267
|
+
continue;
|
|
1268
|
+
}
|
|
1269
|
+
normalized.push(segment);
|
|
1270
|
+
}
|
|
1271
|
+
return `/${normalized.join("/")}`;
|
|
1272
|
+
}
|
|
1273
|
+
function normalizeRoutePath(value) {
|
|
1274
|
+
const path = `/${value.split(/[?#]/)[0]?.split("/").filter(Boolean).join("/") ?? ""}`;
|
|
1275
|
+
return path === "/" ? path : path.replace(/\/$/, "");
|
|
1276
|
+
}
|
|
1277
|
+
function describeOwner(owner) {
|
|
1278
|
+
return `${owner.kind} "${owner.producer ?? owner.slug}"`;
|
|
1279
|
+
}
|
|
1280
|
+
function checkAmbiguousFolderPageOwners(manifest, diagnostics2, options) {
|
|
1281
|
+
const candidates = /* @__PURE__ */ new Map();
|
|
1282
|
+
for (const entry of manifest.entries) {
|
|
1283
|
+
const basename = entry.slug.split("/").at(-1);
|
|
1284
|
+
if (basename !== "README" && basename !== "index") continue;
|
|
1285
|
+
const folder = entry.slug.split("/").slice(0, -1).join("/");
|
|
1286
|
+
if (!folder) continue;
|
|
1287
|
+
const folderCandidates = candidates.get(folder) ?? {};
|
|
1288
|
+
folderCandidates[basename] = entry;
|
|
1289
|
+
candidates.set(folder, folderCandidates);
|
|
1290
|
+
}
|
|
1291
|
+
for (const [folder, entries] of candidates) {
|
|
1292
|
+
const readme = entries.README;
|
|
1293
|
+
const index = entries.index;
|
|
1294
|
+
if (!readme || !index) continue;
|
|
1295
|
+
diagnostics2.push(
|
|
1296
|
+
diagnostic(options, {
|
|
1297
|
+
code: "content-integrity:ambiguous-folder-page-owner",
|
|
1298
|
+
target: folder,
|
|
1299
|
+
message: `Folder "${folder}" has both README and index Folder Page candidates.`,
|
|
1300
|
+
suggestion: "Keep either README or index so the Folder Page owner is unambiguous.",
|
|
1301
|
+
meta: {
|
|
1302
|
+
folder,
|
|
1303
|
+
readme: {
|
|
1304
|
+
slug: readme.slug,
|
|
1305
|
+
permalink: readme.publicLocation.permalink
|
|
1306
|
+
},
|
|
1307
|
+
index: {
|
|
1308
|
+
slug: index.slug,
|
|
1309
|
+
permalink: index.publicLocation.permalink
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
})
|
|
1313
|
+
);
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
function canonicalCycleKey(cycle) {
|
|
1317
|
+
const closed = cycle.at(0) === cycle.at(-1) ? cycle.slice(0, -1) : [...cycle];
|
|
1318
|
+
const rotations = closed.map((_, index) => [
|
|
1319
|
+
...closed.slice(index),
|
|
1320
|
+
...closed.slice(0, index)
|
|
1321
|
+
]);
|
|
1322
|
+
return rotations.map((rotation) => rotation.join("\0")).sort()[0] ?? "";
|
|
1323
|
+
}
|
|
1324
|
+
function diagnostic(options, input) {
|
|
1325
|
+
return {
|
|
1326
|
+
pluginName: PLUGIN_NAME2,
|
|
1327
|
+
severity: options.severity?.[input.code] ?? input.severity ?? DEFAULT_SEVERITY2[input.code] ?? "warning",
|
|
1328
|
+
...input
|
|
1329
|
+
};
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
// index.ts
|
|
1333
|
+
var PLUGIN_NAME3 = "diagnostics";
|
|
978
1334
|
async function runDiagnostics(target, options = {}) {
|
|
979
1335
|
const config = toAnalyzerConfig(target, options);
|
|
980
1336
|
try {
|
|
@@ -985,24 +1341,27 @@ async function runDiagnostics(target, options = {}) {
|
|
|
985
1341
|
}
|
|
986
1342
|
function diagnostics(options = {}) {
|
|
987
1343
|
return definePlugin({
|
|
988
|
-
name:
|
|
1344
|
+
name: PLUGIN_NAME3,
|
|
989
1345
|
options,
|
|
990
1346
|
addDiagnostics: async (context) => {
|
|
991
1347
|
const config = context.config;
|
|
992
1348
|
if (!config) return [];
|
|
993
1349
|
try {
|
|
994
|
-
return await analyzeContent(
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
);
|
|
1350
|
+
return await analyzeContent(toAnalyzerConfig(config, options), {
|
|
1351
|
+
...analyticsCoverageOptions(config, options),
|
|
1352
|
+
skipReferenceIntegrity: true
|
|
1353
|
+
});
|
|
998
1354
|
} catch (error) {
|
|
999
1355
|
return [createInternalError(error)];
|
|
1000
1356
|
}
|
|
1001
1357
|
},
|
|
1358
|
+
onManifestCreated: ({ manifest, diagnostics: diagnostics2 }) => {
|
|
1359
|
+
diagnostics2.push(...checkSiteIntegrity(manifest, options));
|
|
1360
|
+
},
|
|
1002
1361
|
onBuildEnd: ({ manifest }) => {
|
|
1003
1362
|
if (!options.failOnError) return void 0;
|
|
1004
1363
|
const errors = manifest.diagnostics.filter(
|
|
1005
|
-
(
|
|
1364
|
+
(diagnostic2) => diagnostic2.pluginName === PLUGIN_NAME3 && diagnostic2.severity === "error"
|
|
1006
1365
|
);
|
|
1007
1366
|
if (errors.length > 0) {
|
|
1008
1367
|
throw new DiagnosticsFailure(buildReport(errors));
|
|
@@ -1013,6 +1372,7 @@ function diagnostics(options = {}) {
|
|
|
1013
1372
|
}
|
|
1014
1373
|
var diagnosticsPlugin = diagnostics;
|
|
1015
1374
|
var ANALYTICS_PLUGIN_NAME = "analytics";
|
|
1375
|
+
var L10N_PLUGIN_NAME = "l10n";
|
|
1016
1376
|
function hasEnabledAnalyticsPlugin(config) {
|
|
1017
1377
|
return config?.plugins.some(
|
|
1018
1378
|
(plugin) => plugin.name === ANALYTICS_PLUGIN_NAME && plugin.enabled !== false
|
|
@@ -1027,7 +1387,7 @@ function analyticsCoverageOptions(config, options) {
|
|
|
1027
1387
|
function createInternalError(error) {
|
|
1028
1388
|
const message = error instanceof Error ? error.message : String(error);
|
|
1029
1389
|
return {
|
|
1030
|
-
pluginName:
|
|
1390
|
+
pluginName: PLUGIN_NAME3,
|
|
1031
1391
|
code: "internal-error",
|
|
1032
1392
|
severity: "error",
|
|
1033
1393
|
message: `content analysis failed: ${message}`
|
|
@@ -1048,9 +1408,19 @@ function toAnalyzerConfig(target, options) {
|
|
|
1048
1408
|
target.content.exclude
|
|
1049
1409
|
),
|
|
1050
1410
|
exclude: [...target.content.exclude, ...options.exclude ?? []],
|
|
1051
|
-
publishStrategy: target.content.filters.publishStrategy
|
|
1411
|
+
publishStrategy: target.content.filters.publishStrategy,
|
|
1412
|
+
languages: resolveLanguages(target)
|
|
1052
1413
|
};
|
|
1053
1414
|
}
|
|
1415
|
+
function resolveLanguages(config) {
|
|
1416
|
+
const plugin = config.plugins.find(
|
|
1417
|
+
(entry) => entry.name === L10N_PLUGIN_NAME && entry.enabled !== false
|
|
1418
|
+
);
|
|
1419
|
+
const languages = plugin?.options?.languages;
|
|
1420
|
+
return Array.isArray(languages) ? languages.filter(
|
|
1421
|
+
(language) => typeof language === "string"
|
|
1422
|
+
) : [];
|
|
1423
|
+
}
|
|
1054
1424
|
export {
|
|
1055
1425
|
DiagnosticsFailure,
|
|
1056
1426
|
analyzeContent,
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { ContentManifest, Diagnostic, DiagnosticCode, DiagnosticSeverity } from "@riebeckite/core";
|
|
2
|
+
export type SiteIntegrityOptions = {
|
|
3
|
+
readonly severity?: Partial<Record<DiagnosticCode, DiagnosticSeverity>>;
|
|
4
|
+
};
|
|
5
|
+
export declare function checkSiteIntegrity(manifest: ContentManifest, options?: SiteIntegrityOptions): Diagnostic[];
|
package/dist/src/types.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ export type DiagnosticsOptions = {
|
|
|
8
8
|
severity?: Partial<Record<DiagnosticCode, DiagnosticSeverity>>;
|
|
9
9
|
exclude?: string[];
|
|
10
10
|
publishStrategy?: "explicit" | "selective";
|
|
11
|
+
skipReferenceIntegrity?: boolean;
|
|
11
12
|
};
|
|
12
13
|
export type DiagnosticsSummary = {
|
|
13
14
|
total: number;
|
|
@@ -30,4 +31,5 @@ export type AnalyzerContentConfig = {
|
|
|
30
31
|
source?: ContentSource;
|
|
31
32
|
exclude: string[];
|
|
32
33
|
publishStrategy: "explicit" | "selective";
|
|
34
|
+
languages?: readonly string[];
|
|
33
35
|
};
|
package/dist/src/vault.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@riebeckite/plugin-diagnostics",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.15",
|
|
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.15"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@types/mdast": "^4.0.4",
|