@riebeckite/plugin-diagnostics 0.0.13 → 0.0.14
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 +30 -3
- package/README_ja.md +16 -1
- package/dist/bin.js +12 -9
- package/dist/index.js +339 -38
- package/dist/src/site_integrity.d.ts +5 -0
- package/dist/src/types.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
|
|
|
@@ -53,8 +53,35 @@ export default defineConfig({
|
|
|
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.
|
|
84
|
+
|
|
58
85
|
## Options
|
|
59
86
|
|
|
60
87
|
| Option | Type | Default | Description |
|
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
|
|
|
@@ -42,10 +42,25 @@ export default defineConfig({
|
|
|
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 解析を使います。
|
|
63
|
+
|
|
49
64
|
## 設定項目
|
|
50
65
|
|
|
51
66
|
| 項目 | 既定値 | 説明 |
|
package/dist/bin.js
CHANGED
|
@@ -818,14 +818,16 @@ async function analyzeContent(config, options = {}) {
|
|
|
818
818
|
const state = createAnalysisState();
|
|
819
819
|
const diagnostics = [];
|
|
820
820
|
for (const note of source.includedNotes) {
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
821
|
+
if (!options.skipReferenceIntegrity) {
|
|
822
|
+
checkWikilinks(note, source, state, normalizedOptions, diagnostics);
|
|
823
|
+
checkMarkdownReferences(
|
|
824
|
+
note,
|
|
825
|
+
source,
|
|
826
|
+
state,
|
|
827
|
+
normalizedOptions,
|
|
828
|
+
diagnostics
|
|
829
|
+
);
|
|
830
|
+
}
|
|
829
831
|
checkFrontmatter(note, source, state, normalizedOptions, diagnostics);
|
|
830
832
|
}
|
|
831
833
|
checkSlugCollisions(source, normalizedOptions, diagnostics);
|
|
@@ -834,7 +836,8 @@ async function analyzeContent(config, options = {}) {
|
|
|
834
836
|
checkOrphans(source, state, normalizedOptions, diagnostics);
|
|
835
837
|
checkUnusedAssets(source, state, normalizedOptions, diagnostics);
|
|
836
838
|
checkExcludedPublic(source, normalizedOptions, diagnostics);
|
|
837
|
-
|
|
839
|
+
if (!options.skipReferenceIntegrity)
|
|
840
|
+
checkPublishBoundary(source, normalizedOptions, diagnostics);
|
|
838
841
|
checkAnalyticsCoverage(source, normalizedOptions, diagnostics);
|
|
839
842
|
return diagnostics;
|
|
840
843
|
}
|
package/dist/index.js
CHANGED
|
@@ -814,14 +814,16 @@ async function analyzeContent(config, options = {}) {
|
|
|
814
814
|
const state = createAnalysisState();
|
|
815
815
|
const diagnostics2 = [];
|
|
816
816
|
for (const note of source.includedNotes) {
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
817
|
+
if (!options.skipReferenceIntegrity) {
|
|
818
|
+
checkWikilinks(note, source, state, normalizedOptions, diagnostics2);
|
|
819
|
+
checkMarkdownReferences(
|
|
820
|
+
note,
|
|
821
|
+
source,
|
|
822
|
+
state,
|
|
823
|
+
normalizedOptions,
|
|
824
|
+
diagnostics2
|
|
825
|
+
);
|
|
826
|
+
}
|
|
825
827
|
checkFrontmatter(note, source, state, normalizedOptions, diagnostics2);
|
|
826
828
|
}
|
|
827
829
|
checkSlugCollisions(source, normalizedOptions, diagnostics2);
|
|
@@ -830,7 +832,8 @@ async function analyzeContent(config, options = {}) {
|
|
|
830
832
|
checkOrphans(source, state, normalizedOptions, diagnostics2);
|
|
831
833
|
checkUnusedAssets(source, state, normalizedOptions, diagnostics2);
|
|
832
834
|
checkExcludedPublic(source, normalizedOptions, diagnostics2);
|
|
833
|
-
|
|
835
|
+
if (!options.skipReferenceIntegrity)
|
|
836
|
+
checkPublishBoundary(source, normalizedOptions, diagnostics2);
|
|
834
837
|
checkAnalyticsCoverage(source, normalizedOptions, diagnostics2);
|
|
835
838
|
return diagnostics2;
|
|
836
839
|
}
|
|
@@ -855,16 +858,16 @@ function buildReport(diagnostics2) {
|
|
|
855
858
|
const errors = [];
|
|
856
859
|
const warnings = [];
|
|
857
860
|
const infos = [];
|
|
858
|
-
for (const
|
|
859
|
-
switch (
|
|
861
|
+
for (const diagnostic2 of diagnostics2) {
|
|
862
|
+
switch (diagnostic2.severity) {
|
|
860
863
|
case "error":
|
|
861
|
-
errors.push(
|
|
864
|
+
errors.push(diagnostic2);
|
|
862
865
|
break;
|
|
863
866
|
case "warning":
|
|
864
|
-
warnings.push(
|
|
867
|
+
warnings.push(diagnostic2);
|
|
865
868
|
break;
|
|
866
869
|
default:
|
|
867
|
-
infos.push(
|
|
870
|
+
infos.push(diagnostic2);
|
|
868
871
|
break;
|
|
869
872
|
}
|
|
870
873
|
}
|
|
@@ -883,8 +886,8 @@ function summarize(diagnostics2) {
|
|
|
883
886
|
let error = 0;
|
|
884
887
|
let warning = 0;
|
|
885
888
|
let info = 0;
|
|
886
|
-
for (const
|
|
887
|
-
switch (
|
|
889
|
+
for (const diagnostic2 of diagnostics2) {
|
|
890
|
+
switch (diagnostic2.severity) {
|
|
888
891
|
case "error":
|
|
889
892
|
error++;
|
|
890
893
|
break;
|
|
@@ -900,10 +903,10 @@ function summarize(diagnostics2) {
|
|
|
900
903
|
}
|
|
901
904
|
function groupByCode(diagnostics2) {
|
|
902
905
|
const byCode = /* @__PURE__ */ new Map();
|
|
903
|
-
for (const
|
|
904
|
-
const group = byCode.get(
|
|
905
|
-
group.push(
|
|
906
|
-
byCode.set(
|
|
906
|
+
for (const diagnostic2 of diagnostics2) {
|
|
907
|
+
const group = byCode.get(diagnostic2.code) ?? [];
|
|
908
|
+
group.push(diagnostic2);
|
|
909
|
+
byCode.set(diagnostic2.code, group);
|
|
907
910
|
}
|
|
908
911
|
return byCode;
|
|
909
912
|
}
|
|
@@ -922,23 +925,23 @@ function formatDiagnostics(input, options = {}) {
|
|
|
922
925
|
);
|
|
923
926
|
lines.push("");
|
|
924
927
|
const ordered = [...report.diagnostics].sort(compareDiagnostics);
|
|
925
|
-
for (const
|
|
928
|
+
for (const diagnostic2 of ordered) {
|
|
926
929
|
const icon = {
|
|
927
930
|
error: `${color.error}✖`,
|
|
928
931
|
warning: `${color.yellow}⚠`,
|
|
929
932
|
info: `${color.cyan}ℹ`
|
|
930
|
-
}[
|
|
933
|
+
}[diagnostic2.severity];
|
|
931
934
|
const location = [
|
|
932
|
-
|
|
933
|
-
|
|
935
|
+
diagnostic2.filePath ?? "",
|
|
936
|
+
diagnostic2.line != null ? `:${diagnostic2.line}${diagnostic2.column != null ? `:${diagnostic2.column}` : ""}` : ""
|
|
934
937
|
].join("");
|
|
935
|
-
lines.push(`${icon} ${color.reset}[${
|
|
936
|
-
lines.push(` ${
|
|
937
|
-
if (
|
|
938
|
-
lines.push(` ${color.dim}target: ${
|
|
938
|
+
lines.push(`${icon} ${color.reset}[${diagnostic2.code}] ${location}`);
|
|
939
|
+
lines.push(` ${diagnostic2.message}`);
|
|
940
|
+
if (diagnostic2.target) {
|
|
941
|
+
lines.push(` ${color.dim}target: ${diagnostic2.target}${color.reset}`);
|
|
939
942
|
}
|
|
940
|
-
if (
|
|
941
|
-
lines.push(` ${color.cyan}→ ${
|
|
943
|
+
if (diagnostic2.suggestion) {
|
|
944
|
+
lines.push(` ${color.cyan}→ ${diagnostic2.suggestion}${color.reset}`);
|
|
942
945
|
}
|
|
943
946
|
}
|
|
944
947
|
return lines.join("\n");
|
|
@@ -973,8 +976,303 @@ var noColors = {
|
|
|
973
976
|
cyan: ""
|
|
974
977
|
};
|
|
975
978
|
|
|
976
|
-
//
|
|
979
|
+
// src/site_integrity.ts
|
|
977
980
|
var PLUGIN_NAME2 = "diagnostics";
|
|
981
|
+
var DEFAULT_SEVERITY2 = {
|
|
982
|
+
"content-integrity:broken-link": "warning",
|
|
983
|
+
"content-integrity:unresolved-wikilink": "warning",
|
|
984
|
+
"content-integrity:broken-asset": "warning",
|
|
985
|
+
"content-integrity:duplicate-public-location": "error",
|
|
986
|
+
"content-integrity:redirect-target-missing": "warning",
|
|
987
|
+
"content-integrity:redirect-cycle": "error",
|
|
988
|
+
"content-integrity:redirect-public-location-conflict": "error"
|
|
989
|
+
};
|
|
990
|
+
function checkSiteIntegrity(manifest, options = {}) {
|
|
991
|
+
const diagnostics2 = [];
|
|
992
|
+
const publicSlugs = new Set(
|
|
993
|
+
manifest.publicEntries.map((entry) => entry.slug)
|
|
994
|
+
);
|
|
995
|
+
const routeOwners = buildRouteOwners(manifest, diagnostics2, options);
|
|
996
|
+
const publicRoutes = new Set(routeOwners.keys());
|
|
997
|
+
const assetPaths = buildAssetPaths(manifest);
|
|
998
|
+
for (const entry of manifest.publicEntries) {
|
|
999
|
+
checkResolvedLinks(entry, publicSlugs, assetPaths, diagnostics2, options);
|
|
1000
|
+
checkHtmlReferences(entry, publicRoutes, assetPaths, diagnostics2, options);
|
|
1001
|
+
}
|
|
1002
|
+
checkRedirectTargets(manifest, publicRoutes, diagnostics2, options);
|
|
1003
|
+
checkRedirectCycles(manifest, diagnostics2, options);
|
|
1004
|
+
return diagnostics2;
|
|
1005
|
+
}
|
|
1006
|
+
function buildRouteOwners(manifest, diagnostics2, options) {
|
|
1007
|
+
const owners = /* @__PURE__ */ new Map();
|
|
1008
|
+
for (const entry of manifest.publicEntries) {
|
|
1009
|
+
reserveRoute(owners, diagnostics2, options, {
|
|
1010
|
+
path: normalizeRoutePath(entry.publicLocation.permalink),
|
|
1011
|
+
slug: entry.slug,
|
|
1012
|
+
kind: "content"
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
for (const [path, redirect] of manifest.publicRedirects) {
|
|
1016
|
+
reserveRoute(owners, diagnostics2, options, {
|
|
1017
|
+
path: normalizeRoutePath(path),
|
|
1018
|
+
slug: redirect.slug,
|
|
1019
|
+
kind: "redirect"
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
const pagePaths = manifest.pagePaths ?? [];
|
|
1023
|
+
for (const path of pagePaths) {
|
|
1024
|
+
reserveRoute(owners, diagnostics2, options, {
|
|
1025
|
+
path: normalizeRoutePath(path),
|
|
1026
|
+
slug: "(plugin-page)",
|
|
1027
|
+
kind: "page"
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
for (const output of manifest.generatedOutputs ?? []) {
|
|
1031
|
+
reserveRoute(owners, diagnostics2, options, {
|
|
1032
|
+
path: normalizeRoutePath(output.path),
|
|
1033
|
+
slug: "(generated-output)",
|
|
1034
|
+
kind: "output"
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
return owners;
|
|
1038
|
+
}
|
|
1039
|
+
function reserveRoute(owners, diagnostics2, options, next) {
|
|
1040
|
+
const previous = owners.get(next.path);
|
|
1041
|
+
if (!previous) {
|
|
1042
|
+
owners.set(next.path, next);
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
if (previous.slug === next.slug && previous.kind === next.kind) return;
|
|
1046
|
+
const code = previous.kind === "redirect" || next.kind === "redirect" ? "content-integrity:redirect-public-location-conflict" : "content-integrity:duplicate-public-location";
|
|
1047
|
+
diagnostics2.push(
|
|
1048
|
+
diagnostic(options, {
|
|
1049
|
+
code,
|
|
1050
|
+
slug: next.slug.startsWith("(") ? void 0 : next.slug,
|
|
1051
|
+
target: next.path,
|
|
1052
|
+
message: `Public path "${next.path}" is claimed by both ${describeOwner(previous)} and ${describeOwner(next)}.`,
|
|
1053
|
+
suggestion: "Change one permalink, alias, redirect, page path, or generated output path.",
|
|
1054
|
+
meta: { previous, next }
|
|
1055
|
+
})
|
|
1056
|
+
);
|
|
1057
|
+
}
|
|
1058
|
+
function checkResolvedLinks(entry, publicSlugs, assetPaths, diagnostics2, options) {
|
|
1059
|
+
for (const link of entry.links) {
|
|
1060
|
+
if (link.kind === "unresolved") {
|
|
1061
|
+
diagnostics2.push(
|
|
1062
|
+
diagnostic(options, {
|
|
1063
|
+
code: "content-integrity:unresolved-wikilink",
|
|
1064
|
+
slug: entry.slug,
|
|
1065
|
+
filePath: `${entry.slug}.md`,
|
|
1066
|
+
target: link.raw,
|
|
1067
|
+
message: `WikiLink target "${link.raw}" could not be resolved.`,
|
|
1068
|
+
suggestion: "Create the target note/asset or update the WikiLink target.",
|
|
1069
|
+
meta: { raw: link.raw, embed: link.embed }
|
|
1070
|
+
})
|
|
1071
|
+
);
|
|
1072
|
+
continue;
|
|
1073
|
+
}
|
|
1074
|
+
if (link.kind === "note" && link.slug && !publicSlugs.has(link.slug)) {
|
|
1075
|
+
diagnostics2.push(
|
|
1076
|
+
diagnostic(options, {
|
|
1077
|
+
code: "content-integrity:broken-link",
|
|
1078
|
+
slug: entry.slug,
|
|
1079
|
+
filePath: `${entry.slug}.md`,
|
|
1080
|
+
target: link.slug,
|
|
1081
|
+
message: `Content link "${link.raw}" resolves to unpublished content "${link.slug}".`,
|
|
1082
|
+
suggestion: "Publish the target content or remove the reference from published content.",
|
|
1083
|
+
meta: {
|
|
1084
|
+
raw: link.raw,
|
|
1085
|
+
resolvedSlug: link.slug,
|
|
1086
|
+
reason: "unpublished-target"
|
|
1087
|
+
}
|
|
1088
|
+
})
|
|
1089
|
+
);
|
|
1090
|
+
}
|
|
1091
|
+
if ((link.kind === "image" || link.kind === "attachment") && link.slug && !assetPaths.has(link.slug)) {
|
|
1092
|
+
diagnostics2.push(
|
|
1093
|
+
diagnostic(options, {
|
|
1094
|
+
code: "content-integrity:broken-asset",
|
|
1095
|
+
slug: entry.slug,
|
|
1096
|
+
filePath: `${entry.slug}.md`,
|
|
1097
|
+
target: link.slug,
|
|
1098
|
+
message: `Asset reference "${link.raw}" resolves to "${link.slug}", but no asset is published for that path.`,
|
|
1099
|
+
suggestion: "Add the asset, fix the reference, or register the generated asset from a plugin.",
|
|
1100
|
+
meta: { raw: link.raw, resolvedPath: link.slug }
|
|
1101
|
+
})
|
|
1102
|
+
);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
function checkHtmlReferences(entry, publicRoutes, assetPaths, diagnostics2, options) {
|
|
1107
|
+
for (const href of extractAttributes(entry.html, "href")) {
|
|
1108
|
+
if (!isSiteLocalReference(href)) continue;
|
|
1109
|
+
const path = normalizeReferencePath(href, entry.publicLocation.permalink);
|
|
1110
|
+
if (!path || publicRoutes.has(path)) continue;
|
|
1111
|
+
diagnostics2.push(
|
|
1112
|
+
diagnostic(options, {
|
|
1113
|
+
code: "content-integrity:broken-link",
|
|
1114
|
+
slug: entry.slug,
|
|
1115
|
+
filePath: entry.publicLocation.permalink,
|
|
1116
|
+
target: href,
|
|
1117
|
+
message: `Internal link target "${href}" does not match a public route.`,
|
|
1118
|
+
suggestion: "Update the link, create the target content, or register the generated route from a plugin.",
|
|
1119
|
+
meta: { normalizedTarget: path }
|
|
1120
|
+
})
|
|
1121
|
+
);
|
|
1122
|
+
}
|
|
1123
|
+
for (const src of extractAttributes(entry.html, "src")) {
|
|
1124
|
+
if (!isSiteLocalReference(src)) continue;
|
|
1125
|
+
const path = normalizeReferencePath(src, entry.publicLocation.permalink);
|
|
1126
|
+
const assetPath = path?.replace(/^\/+/, "");
|
|
1127
|
+
if (!path || publicRoutes.has(path) || assetPath && assetPaths.has(assetPath))
|
|
1128
|
+
continue;
|
|
1129
|
+
diagnostics2.push(
|
|
1130
|
+
diagnostic(options, {
|
|
1131
|
+
code: "content-integrity:broken-asset",
|
|
1132
|
+
slug: entry.slug,
|
|
1133
|
+
filePath: entry.publicLocation.permalink,
|
|
1134
|
+
target: src,
|
|
1135
|
+
message: `Local asset target "${src}" does not match a published asset or route.`,
|
|
1136
|
+
suggestion: "Add the asset, fix the reference, or register the generated asset from a plugin.",
|
|
1137
|
+
meta: { normalizedTarget: path }
|
|
1138
|
+
})
|
|
1139
|
+
);
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
function checkRedirectTargets(manifest, publicRoutes, diagnostics2, options) {
|
|
1143
|
+
for (const [path, redirect] of manifest.publicRedirects) {
|
|
1144
|
+
const owner = manifest.bySlug.get(redirect.slug);
|
|
1145
|
+
const target = owner ? normalizeRoutePath(owner.publicLocation.permalink) : null;
|
|
1146
|
+
if (target && publicRoutes.has(target)) continue;
|
|
1147
|
+
diagnostics2.push(
|
|
1148
|
+
diagnostic(options, {
|
|
1149
|
+
code: "content-integrity:redirect-target-missing",
|
|
1150
|
+
slug: redirect.slug,
|
|
1151
|
+
target: path,
|
|
1152
|
+
message: `Redirect "${path}" points to content "${redirect.slug}", but its public target is not available.`,
|
|
1153
|
+
suggestion: "Publish the target content or remove the redirect.",
|
|
1154
|
+
meta: { redirect: path, targetSlug: redirect.slug, target }
|
|
1155
|
+
})
|
|
1156
|
+
);
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
function checkRedirectCycles(manifest, diagnostics2, options) {
|
|
1160
|
+
const redirectTargets = /* @__PURE__ */ new Map();
|
|
1161
|
+
const reportedCycles = /* @__PURE__ */ new Set();
|
|
1162
|
+
for (const [path, redirect] of manifest.publicRedirects) {
|
|
1163
|
+
const owner = manifest.bySlug.get(redirect.slug);
|
|
1164
|
+
if (owner)
|
|
1165
|
+
redirectTargets.set(
|
|
1166
|
+
normalizeRoutePath(path),
|
|
1167
|
+
normalizeRoutePath(owner.publicLocation.permalink)
|
|
1168
|
+
);
|
|
1169
|
+
}
|
|
1170
|
+
for (const start of redirectTargets.keys()) {
|
|
1171
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1172
|
+
const chain = [];
|
|
1173
|
+
let current = start;
|
|
1174
|
+
while (current) {
|
|
1175
|
+
const previousIndex = seen.get(current);
|
|
1176
|
+
if (previousIndex !== void 0) {
|
|
1177
|
+
const cycle = [...chain.slice(previousIndex), current];
|
|
1178
|
+
const key = canonicalCycleKey(cycle);
|
|
1179
|
+
if (reportedCycles.has(key)) break;
|
|
1180
|
+
reportedCycles.add(key);
|
|
1181
|
+
diagnostics2.push(
|
|
1182
|
+
diagnostic(options, {
|
|
1183
|
+
code: "content-integrity:redirect-cycle",
|
|
1184
|
+
target: cycle.join(" -> "),
|
|
1185
|
+
message: `Redirect cycle detected: ${cycle.join(" -> ")}.`,
|
|
1186
|
+
suggestion: "Remove or retarget one redirect in the cycle.",
|
|
1187
|
+
meta: { cycle }
|
|
1188
|
+
})
|
|
1189
|
+
);
|
|
1190
|
+
break;
|
|
1191
|
+
}
|
|
1192
|
+
seen.set(current, chain.length);
|
|
1193
|
+
chain.push(current);
|
|
1194
|
+
current = redirectTargets.get(current);
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
function buildAssetPaths(manifest) {
|
|
1199
|
+
const paths = /* @__PURE__ */ new Set();
|
|
1200
|
+
for (const entry of manifest.entries) {
|
|
1201
|
+
for (const asset of entry.assets) paths.add(asset.path);
|
|
1202
|
+
}
|
|
1203
|
+
for (const asset of manifest.assets ?? [])
|
|
1204
|
+
paths.add(asset.path.replace(/^\/+/, ""));
|
|
1205
|
+
for (const output of manifest.generatedOutputs ?? [])
|
|
1206
|
+
paths.add(output.path.replace(/^\/+/, ""));
|
|
1207
|
+
return paths;
|
|
1208
|
+
}
|
|
1209
|
+
function extractAttributes(html, attribute) {
|
|
1210
|
+
const pattern = new RegExp(
|
|
1211
|
+
`${attribute}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`,
|
|
1212
|
+
"gi"
|
|
1213
|
+
);
|
|
1214
|
+
const values = [];
|
|
1215
|
+
for (let match = pattern.exec(html); match; match = pattern.exec(html)) {
|
|
1216
|
+
const value = match[1] ?? match[2] ?? match[3];
|
|
1217
|
+
if (value) values.push(value);
|
|
1218
|
+
}
|
|
1219
|
+
return values;
|
|
1220
|
+
}
|
|
1221
|
+
function isSiteLocalReference(value) {
|
|
1222
|
+
if (!value || value.startsWith("#") || value.startsWith("//")) return false;
|
|
1223
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(value)) return false;
|
|
1224
|
+
return true;
|
|
1225
|
+
}
|
|
1226
|
+
function normalizeReferencePath(value, basePath) {
|
|
1227
|
+
const path = value.split(/[?#]/)[0] ?? "";
|
|
1228
|
+
if (!path || path === "/") return path || null;
|
|
1229
|
+
try {
|
|
1230
|
+
return normalizeRoutePath(resolveReferencePath(decodeURI(path), basePath));
|
|
1231
|
+
} catch {
|
|
1232
|
+
return null;
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
function resolveReferencePath(path, basePath) {
|
|
1236
|
+
if (path.startsWith("/")) return path;
|
|
1237
|
+
const baseSegments = basePath.split("/").filter(Boolean);
|
|
1238
|
+
if (!basePath.endsWith("/")) baseSegments.pop();
|
|
1239
|
+
const segments = [...baseSegments, ...path.split("/")];
|
|
1240
|
+
const normalized = [];
|
|
1241
|
+
for (const segment of segments) {
|
|
1242
|
+
if (!segment || segment === ".") continue;
|
|
1243
|
+
if (segment === "..") {
|
|
1244
|
+
normalized.pop();
|
|
1245
|
+
continue;
|
|
1246
|
+
}
|
|
1247
|
+
normalized.push(segment);
|
|
1248
|
+
}
|
|
1249
|
+
return `/${normalized.join("/")}`;
|
|
1250
|
+
}
|
|
1251
|
+
function normalizeRoutePath(value) {
|
|
1252
|
+
const path = `/${value.split(/[?#]/)[0]?.split("/").filter(Boolean).join("/") ?? ""}`;
|
|
1253
|
+
return path === "/" ? path : path.replace(/\/$/, "");
|
|
1254
|
+
}
|
|
1255
|
+
function describeOwner(owner) {
|
|
1256
|
+
return `${owner.kind} "${owner.slug}"`;
|
|
1257
|
+
}
|
|
1258
|
+
function canonicalCycleKey(cycle) {
|
|
1259
|
+
const closed = cycle.at(0) === cycle.at(-1) ? cycle.slice(0, -1) : [...cycle];
|
|
1260
|
+
const rotations = closed.map((_, index) => [
|
|
1261
|
+
...closed.slice(index),
|
|
1262
|
+
...closed.slice(0, index)
|
|
1263
|
+
]);
|
|
1264
|
+
return rotations.map((rotation) => rotation.join("\0")).sort()[0] ?? "";
|
|
1265
|
+
}
|
|
1266
|
+
function diagnostic(options, input) {
|
|
1267
|
+
return {
|
|
1268
|
+
pluginName: PLUGIN_NAME2,
|
|
1269
|
+
severity: options.severity?.[input.code] ?? input.severity ?? DEFAULT_SEVERITY2[input.code] ?? "warning",
|
|
1270
|
+
...input
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// index.ts
|
|
1275
|
+
var PLUGIN_NAME3 = "diagnostics";
|
|
978
1276
|
async function runDiagnostics(target, options = {}) {
|
|
979
1277
|
const config = toAnalyzerConfig(target, options);
|
|
980
1278
|
try {
|
|
@@ -985,24 +1283,27 @@ async function runDiagnostics(target, options = {}) {
|
|
|
985
1283
|
}
|
|
986
1284
|
function diagnostics(options = {}) {
|
|
987
1285
|
return definePlugin({
|
|
988
|
-
name:
|
|
1286
|
+
name: PLUGIN_NAME3,
|
|
989
1287
|
options,
|
|
990
1288
|
addDiagnostics: async (context) => {
|
|
991
1289
|
const config = context.config;
|
|
992
1290
|
if (!config) return [];
|
|
993
1291
|
try {
|
|
994
|
-
return await analyzeContent(
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
);
|
|
1292
|
+
return await analyzeContent(toAnalyzerConfig(config, options), {
|
|
1293
|
+
...analyticsCoverageOptions(config, options),
|
|
1294
|
+
skipReferenceIntegrity: true
|
|
1295
|
+
});
|
|
998
1296
|
} catch (error) {
|
|
999
1297
|
return [createInternalError(error)];
|
|
1000
1298
|
}
|
|
1001
1299
|
},
|
|
1300
|
+
onManifestCreated: ({ manifest, diagnostics: diagnostics2 }) => {
|
|
1301
|
+
diagnostics2.push(...checkSiteIntegrity(manifest, options));
|
|
1302
|
+
},
|
|
1002
1303
|
onBuildEnd: ({ manifest }) => {
|
|
1003
1304
|
if (!options.failOnError) return void 0;
|
|
1004
1305
|
const errors = manifest.diagnostics.filter(
|
|
1005
|
-
(
|
|
1306
|
+
(diagnostic2) => diagnostic2.pluginName === PLUGIN_NAME3 && diagnostic2.severity === "error"
|
|
1006
1307
|
);
|
|
1007
1308
|
if (errors.length > 0) {
|
|
1008
1309
|
throw new DiagnosticsFailure(buildReport(errors));
|
|
@@ -1027,7 +1328,7 @@ function analyticsCoverageOptions(config, options) {
|
|
|
1027
1328
|
function createInternalError(error) {
|
|
1028
1329
|
const message = error instanceof Error ? error.message : String(error);
|
|
1029
1330
|
return {
|
|
1030
|
-
pluginName:
|
|
1331
|
+
pluginName: PLUGIN_NAME3,
|
|
1031
1332
|
code: "internal-error",
|
|
1032
1333
|
severity: "error",
|
|
1033
1334
|
message: `content analysis failed: ${message}`
|
|
@@ -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;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@riebeckite/plugin-diagnostics",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.14",
|
|
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.14"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@types/mdast": "^4.0.4",
|