@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 CHANGED
@@ -1,8 +1,8 @@
1
1
  # @riebeckite/plugin-diagnostics
2
2
 
3
- Content diagnostics for Obsidian vaults / Riebeckite content: broken links,
4
- frontmatter issues, orphan notes, unused assets, and more. Usable as a build
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` | No frontmatter, or required fields missing |
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
- コンテンツのリンク切れ、公開設定の矛盾、frontmatter の不足などを検出するプラグインです。ビルド時の診断だけでなく、CLI とプログラムからの実行にも対応します。
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 | frontmatter がない、または必須フィールドが欠けている |
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 (!note.fm.hasFrontmatter) {
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 [title, notes] of byTitle) {
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
- checkWikilinks(note, source, state, normalizedOptions, diagnostics);
822
- checkMarkdownReferences(
823
- note,
824
- source,
825
- state,
826
- normalizedOptions,
827
- diagnostics
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
- checkOrphans(source, state, normalizedOptions, diagnostics);
835
- checkUnusedAssets(source, state, normalizedOptions, diagnostics);
844
+ if (!options.skipReferenceIntegrity) {
845
+ checkOrphans(source, state, normalizedOptions, diagnostics);
846
+ checkUnusedAssets(source, state, normalizedOptions, diagnostics);
847
+ }
836
848
  checkExcludedPublic(source, normalizedOptions, diagnostics);
837
- checkPublishBoundary(source, normalizedOptions, diagnostics);
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 (!note.fm.hasFrontmatter) {
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 [title, notes] of byTitle) {
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
- checkWikilinks(note, source, state, normalizedOptions, diagnostics2);
818
- checkMarkdownReferences(
819
- note,
820
- source,
821
- state,
822
- normalizedOptions,
823
- diagnostics2
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
- checkOrphans(source, state, normalizedOptions, diagnostics2);
831
- checkUnusedAssets(source, state, normalizedOptions, diagnostics2);
840
+ if (!options.skipReferenceIntegrity) {
841
+ checkOrphans(source, state, normalizedOptions, diagnostics2);
842
+ checkUnusedAssets(source, state, normalizedOptions, diagnostics2);
843
+ }
832
844
  checkExcludedPublic(source, normalizedOptions, diagnostics2);
833
- checkPublishBoundary(source, normalizedOptions, diagnostics2);
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 diagnostic of diagnostics2) {
859
- switch (diagnostic.severity) {
871
+ for (const diagnostic2 of diagnostics2) {
872
+ switch (diagnostic2.severity) {
860
873
  case "error":
861
- errors.push(diagnostic);
874
+ errors.push(diagnostic2);
862
875
  break;
863
876
  case "warning":
864
- warnings.push(diagnostic);
877
+ warnings.push(diagnostic2);
865
878
  break;
866
879
  default:
867
- infos.push(diagnostic);
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 diagnostic of diagnostics2) {
887
- switch (diagnostic.severity) {
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 diagnostic of diagnostics2) {
904
- const group = byCode.get(diagnostic.code) ?? [];
905
- group.push(diagnostic);
906
- byCode.set(diagnostic.code, group);
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 diagnostic of ordered) {
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
- }[diagnostic.severity];
943
+ }[diagnostic2.severity];
931
944
  const location = [
932
- diagnostic.filePath ?? "",
933
- diagnostic.line != null ? `:${diagnostic.line}${diagnostic.column != null ? `:${diagnostic.column}` : ""}` : ""
945
+ diagnostic2.filePath ?? "",
946
+ diagnostic2.line != null ? `:${diagnostic2.line}${diagnostic2.column != null ? `:${diagnostic2.column}` : ""}` : ""
934
947
  ].join("");
935
- lines.push(`${icon} ${color.reset}[${diagnostic.code}] ${location}`);
936
- lines.push(` ${diagnostic.message}`);
937
- if (diagnostic.target) {
938
- lines.push(` ${color.dim}target: ${diagnostic.target}${color.reset}`);
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 (diagnostic.suggestion) {
941
- lines.push(` ${color.cyan}→ ${diagnostic.suggestion}${color.reset}`);
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
- // index.ts
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: PLUGIN_NAME2,
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
- toAnalyzerConfig(config, options),
996
- analyticsCoverageOptions(config, options)
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
- (diagnostic) => diagnostic.pluginName === PLUGIN_NAME2 && diagnostic.severity === "error"
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: PLUGIN_NAME2,
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[];
@@ -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
  };
@@ -3,6 +3,7 @@ import type { AnalyzerContentConfig } from "./types.js";
3
3
  export type ScannedNote = {
4
4
  relativePath: string;
5
5
  slug: string;
6
+ language?: string;
6
7
  markdown: string;
7
8
  fm: ParsedFrontmatter;
8
9
  headings: Set<string>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@riebeckite/plugin-diagnostics",
3
- "version": "0.0.13",
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.13"
23
+ "@riebeckite/core": "0.0.15"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/mdast": "^4.0.4",