com.wallstop-studios.unity-helpers 2.0.0-rc81.9 → 2.0.0

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.
Files changed (168) hide show
  1. package/.editorconfig +1 -1
  2. package/.gitattributes +1 -1
  3. package/.githooks/pre-commit +31 -5
  4. package/.githooks/pre-push +50 -0
  5. package/.github/dependabot.yml +24 -2
  6. package/.github/scripts/check-markdown-links.ps1 +77 -0
  7. package/.github/scripts/check_markdown_links.py +89 -0
  8. package/.github/scripts/check_markdown_url_encoding.py +74 -0
  9. package/.github/scripts/validate_markdown_links.py +194 -0
  10. package/.github/workflows/csharpier-autofix.yml +152 -0
  11. package/.github/workflows/format-on-demand.yml +305 -0
  12. package/.github/workflows/lint-doc-links.yml +8 -5
  13. package/.github/workflows/markdown-json.yml +6 -2
  14. package/.github/workflows/prettier-autofix.yml +195 -0
  15. package/.github/workflows/update-dotnet-tools.yml +80 -0
  16. package/.github/workflows/yaml-format-lint.yml +41 -0
  17. package/.lychee.toml +4 -4
  18. package/.markdownlint.jsonc +21 -0
  19. package/.pre-commit-config.yaml +11 -3
  20. package/.yamllint.yaml +31 -0
  21. package/AGENTS.md +5 -1
  22. package/CHANGELOG.md +11 -0
  23. package/CONTRIBUTING.md +49 -0
  24. package/CONTRIBUTING.md.meta +7 -0
  25. package/EDITOR_TOOLS_GUIDE.md +4 -0
  26. package/Editor/AnimationEventEditor.cs +337 -160
  27. package/Editor/Core/Helper/AnimationEventHelpers.cs +178 -152
  28. package/Editor/CustomEditors/PersistentDirectoryGUI.cs +20 -11
  29. package/Editor/CustomEditors/TexturePlatformOverrideEntryDrawer.cs +11 -2
  30. package/Editor/FitTextureSizeWindow.cs +43 -19
  31. package/Editor/PersistentDirectorySettings.cs +64 -12
  32. package/Editor/PrefabChecker.cs +72 -5
  33. package/Editor/Sprites/AnimationCopier.cs +132 -56
  34. package/Editor/Sprites/AnimationCreator.cs +63 -22
  35. package/Editor/Sprites/AnimationViewerWindow.cs +42 -6
  36. package/Editor/Sprites/TexturePlatformNameHelper.cs +50 -39
  37. package/Editor/Sprites/TextureResizerWizard.cs +23 -1
  38. package/Editor/Sprites/TextureSettingsApplierWindow.cs +148 -85
  39. package/Editor/Tools/ImageBlurTool.cs +81 -10
  40. package/Editor/Utils/EditorUi.cs +1 -1
  41. package/Editor/Utils/ScriptableObjectSingletonCreator.cs +1 -1
  42. package/GETTING_STARTED.md +40 -56
  43. package/RANDOM_PERFORMANCE.md +12 -12
  44. package/README.md +395 -2407
  45. package/RELATIONAL_COMPONENTS.md +92 -83
  46. package/Runtime/AssemblyInfo.cs +2 -0
  47. package/Runtime/Core/Attributes/NotNullAttribute.cs +1 -3
  48. package/Runtime/Core/Attributes/RelationalComponentAssigner.cs +50 -5
  49. package/Runtime/Core/DataStructure/CyclicBuffer.cs +0 -1
  50. package/Runtime/Core/Extension/RandomExtensions.cs +68 -0
  51. package/Runtime/Core/Extension/WallstopStudiosLogger.cs +16 -0
  52. package/Runtime/Core/Helper/Partials/ObjectHelpers.cs +4 -1
  53. package/Runtime/Core/Helper/ReflectionHelpers.cs +21 -10
  54. package/Runtime/Core/Helper/SpriteHelpers.cs +3 -1
  55. package/Runtime/Core/Helper/UnityMainThreadDispatcher.cs +45 -1
  56. package/Runtime/Core/Serialization/JsonConverters/GameObjectConverter.cs +13 -5
  57. package/Runtime/Core/Serialization/JsonConverters/ResolutionConverter.cs +1 -1
  58. package/Runtime/Core/Serialization/JsonConverters/TypeConverter.cs +1 -1
  59. package/Runtime/Core/Serialization/Serializer.cs +101 -0
  60. package/Runtime/Integrations/VContainer/AssemblyInfo.cs +9 -0
  61. package/Runtime/Integrations/VContainer/AssemblyInfo.cs.meta +3 -0
  62. package/Runtime/Integrations/VContainer/ObjectResolverRelationalExtensions.cs +96 -0
  63. package/Runtime/Integrations/VContainer/RelationalComponentEntryPoint.cs +90 -10
  64. package/Runtime/Integrations/VContainer/RelationalComponentsBuilderExtensions.cs +13 -1
  65. package/Runtime/Integrations/VContainer/RelationalObjectPools.cs +114 -0
  66. package/Runtime/Integrations/VContainer/RelationalObjectPools.cs.meta +11 -0
  67. package/Runtime/Integrations/VContainer/RelationalSceneAssignmentOptions.cs +16 -4
  68. package/Runtime/Integrations/VContainer/RelationalSceneLoadListener.cs +241 -0
  69. package/Runtime/Integrations/VContainer/RelationalSceneLoadListener.cs.meta +11 -0
  70. package/Runtime/Integrations/Zenject/AssemblyInfo.cs +9 -0
  71. package/Runtime/Integrations/Zenject/AssemblyInfo.cs.meta +3 -0
  72. package/Runtime/Integrations/Zenject/DiContainerRelationalExtensions.cs +69 -2
  73. package/Runtime/Integrations/Zenject/RelationalComponentSceneInitializer.cs +89 -12
  74. package/Runtime/Integrations/Zenject/RelationalComponentsInstaller.cs +23 -1
  75. package/Runtime/Integrations/Zenject/RelationalMemoryPools.cs +44 -0
  76. package/Runtime/Integrations/Zenject/RelationalMemoryPools.cs.meta +11 -0
  77. package/Runtime/Integrations/Zenject/RelationalSceneAssignmentOptions.cs +16 -10
  78. package/Runtime/Integrations/Zenject/RelationalSceneLoadListener.cs +243 -0
  79. package/Runtime/Integrations/Zenject/RelationalSceneLoadListener.cs.meta +11 -0
  80. package/Runtime/Tags/AttributeMetadataCache.cs +1 -4
  81. package/Runtime/Utils/Buffers.cs +4 -4
  82. package/Runtime/Utils/ScriptableObjectSingleton.cs +0 -1
  83. package/Runtime/Utils/SetTextureImportData.cs +3 -1
  84. package/Runtime/Utils/TextureScale.cs +10 -2
  85. package/Runtime/Visuals/UGUI/EnhancedImage.cs +6 -0
  86. package/Runtime/Visuals/UIToolkit/LayeredImage.cs +4 -1
  87. package/SERIALIZATION.md +15 -0
  88. package/SPATIAL_TREE_2D_PERFORMANCE.md +85 -82
  89. package/SPATIAL_TREE_3D_PERFORMANCE.md +94 -91
  90. package/Samples~/DI - VContainer/README.md +232 -51
  91. package/Samples~/DI - VContainer/Scripts/GameLifetimeScope.cs +22 -4
  92. package/Samples~/DI - VContainer/Scripts/RelationalConsumer.cs +5 -2
  93. package/Samples~/DI - VContainer/Scripts/Spawner.cs +113 -4
  94. package/Samples~/DI - Zenject/README.md +217 -53
  95. package/Samples~/DI - Zenject/Scripts/RelationalConsumer.cs +3 -0
  96. package/Samples~/DI - Zenject/Scripts/RelationalConsumerPool.cs +37 -0
  97. package/Samples~/DI - Zenject/Scripts/RelationalConsumerPool.cs.meta +12 -0
  98. package/Samples~/DI - Zenject/Scripts/SpawnerZenject.cs +74 -3
  99. package/Samples~/Random - PRNG/README.md +2 -1
  100. package/Samples~/Relational Components - Basic/README.md +3 -1
  101. package/Samples~/Serialization - JSON/README.md +2 -1
  102. package/Samples~/Spatial Structures - 2D and 3D/README.md +2 -1
  103. package/Samples~/UGUI - EnhancedImage/README.md +2 -1
  104. package/Samples~/UI Toolkit - MultiFile Selector (Editor)/README.md +2 -1
  105. package/THIRD_PARTY_NOTICES.md +1 -1
  106. package/Tests/Editor/Attributes/AnimationEventHelpersTests.cs +16 -0
  107. package/Tests/Editor/Core/Attributes/RelationalComponentAssignerTests.cs +3 -3
  108. package/Tests/Editor/Integrations/VContainer/VContainerRelationalEntryPointTests.cs +6 -2
  109. package/Tests/Editor/Integrations/VContainer/VContainerRelationalHelpersTests.cs +170 -0
  110. package/Tests/Editor/Integrations/VContainer/VContainerRelationalHelpersTests.cs.meta +11 -0
  111. package/Tests/Editor/Integrations/VContainer/WallstopStudios.UnityHelpers.Tests.Editor.VContainer.asmdef +2 -1
  112. package/Tests/Editor/Integrations/Zenject/WallstopStudios.UnityHelpers.Tests.Editor.Zenject.asmdef +3 -2
  113. package/Tests/Editor/Integrations/Zenject/ZenjectRelationalHelpersTests.cs +131 -0
  114. package/Tests/Editor/Integrations/Zenject/ZenjectRelationalHelpersTests.cs.meta +11 -0
  115. package/Tests/Editor/Integrations/Zenject/ZenjectRelationalInitializerTests.cs +6 -2
  116. package/Tests/Editor/PersistentDirectorySettingsTests.cs +59 -0
  117. package/Tests/Editor/PersistentDirectorySettingsTests.cs.meta +11 -0
  118. package/Tests/Editor/PrefabCheckerReportTests.cs +32 -0
  119. package/Tests/Editor/PrefabCheckerReportTests.cs.meta +11 -0
  120. package/Tests/Editor/Sprites/AnimationCopierFilterTests.cs +64 -0
  121. package/Tests/Editor/Sprites/AnimationCopierFilterTests.cs.meta +11 -0
  122. package/Tests/Editor/Sprites/AnimationCopierWindowTests.cs +1 -1
  123. package/Tests/Editor/Sprites/AnimationViewerWindowTests.cs +38 -0
  124. package/Tests/Editor/Sprites/AnimationViewerWindowTests.cs.meta +11 -0
  125. package/Tests/Editor/Sprites/ScriptableSpriteAtlasEditorTests.cs +1 -1
  126. package/Tests/Editor/Sprites/SpriteCropperAdditionalTests.cs +12 -12
  127. package/Tests/Editor/Sprites/SpriteCropperTests.cs +9 -9
  128. package/Tests/Editor/Sprites/SpritePivotAdjusterTests.cs +3 -3
  129. package/Tests/Editor/Sprites/TexturePlatformNameHelperTests.cs +18 -0
  130. package/Tests/Editor/Sprites/TextureResizerWizardTests.cs +5 -5
  131. package/Tests/Editor/Sprites/TextureSettingsApplierAPITests.cs +3 -3
  132. package/Tests/Editor/Sprites/TextureSettingsApplierWizardAdditionalTests.cs +4 -4
  133. package/Tests/Editor/Sprites/TextureSettingsApplierWizardTests.cs +4 -4
  134. package/Tests/Editor/Tools/ImageBlurToolTests.cs +22 -110
  135. package/Tests/Editor/Utils/CommonTestBase.cs +43 -1
  136. package/Tests/Editor/Utils/ScriptableObjectSingletonCreatorTests.cs +5 -5
  137. package/Tests/Editor/Windows/FitTextureSizeWindowTests.cs +66 -74
  138. package/Tests/Runtime/Attributes/RelationalComponentInitializerTests.cs +4 -15
  139. package/Tests/Runtime/DataStructures/SpatialTree3DBoundsConsistencyTests.cs +29 -29
  140. package/Tests/Runtime/Integrations/VContainer/RelationalComponentsVContainerTests.cs +259 -218
  141. package/Tests/Runtime/Integrations/VContainer/RelationalObjectPoolsVContainerTests.cs +86 -0
  142. package/Tests/Runtime/Integrations/VContainer/RelationalObjectPoolsVContainerTests.cs.meta +11 -0
  143. package/Tests/Runtime/Integrations/Zenject/RelationalComponentsZenjectTests.cs +255 -227
  144. package/Tests/Runtime/Performance/SpatialTree2DPerformanceTests.cs +5 -0
  145. package/Tests/Runtime/Performance/SpatialTree3DPerformanceTests.cs +3 -0
  146. package/Tests/Runtime/Serialization/JsonConverterAdditionalTests.cs +30 -0
  147. package/Tests/Runtime/Serialization/JsonConverterAdditionalTests.cs.meta +11 -0
  148. package/Tests/Runtime/Serialization/JsonConverterTests.cs +8 -12
  149. package/Tests/Runtime/Serialization/JsonSerializationTest.cs +16 -5
  150. package/Tests/Runtime/Serialization/SerializerAdditionalTests.cs +12 -0
  151. package/Tests/Runtime/Serialization/SerializerFileIoTests.cs +105 -0
  152. package/Tests/Runtime/Serialization/SerializerFileIoTests.cs.meta +11 -0
  153. package/Tests/Runtime/Serialization/UnityEngineObjectJsonTests.cs +247 -0
  154. package/Tests/Runtime/Serialization/UnityEngineObjectJsonTests.cs.meta +11 -0
  155. package/Tests/Runtime/TestUtils/CommonTestBase.cs +88 -0
  156. package/Tests/Runtime/Utils/CoroutineHandlerTests.cs +1 -1
  157. package/Tests/Runtime/Utils/LZMAComprehensiveTests.cs +1 -1
  158. package/Tests/Runtime/Utils/LZMATests.cs +1 -1
  159. package/Tests/Runtime/Utils/MatchColliderToSpriteTests.cs +1 -1
  160. package/Tests/Runtime/Visuals/EnhancedImageTests.cs +25 -56
  161. package/Tests/Runtime/Visuals/VisualsTestHelpers.cs +1 -8
  162. package/package-lock.json.meta +7 -0
  163. package/package.json +8 -4
  164. package/scripts/check-eol.ps1 +4 -5
  165. package/scripts/lint-tests.ps1 +156 -0
  166. package/scripts/lint-tests.ps1.meta +7 -0
  167. package/scripts/normalize-eol.ps1 +6 -9
  168. package/.github/workflows/csharpier.yml +0 -135
package/.editorconfig CHANGED
@@ -1,6 +1,6 @@
1
1
 
2
2
  [*]
3
- charset = utf-8-bom
3
+ charset = utf-8
4
4
  end_of_line = crlf
5
5
  trim_trailing_whitespace = false
6
6
  insert_final_newline = false
package/.gitattributes CHANGED
@@ -2,7 +2,7 @@
2
2
  # Line endings
3
3
  #
4
4
  # Enforce CRLF in the working tree for all text files to align with
5
- # .editorconfig (end_of_line = crlf, charset = utf-8-bom) and avoid
5
+ # .editorconfig (end_of_line = crlf, charset = utf-8 without BOM) and avoid
6
6
  # formatter diffs on Linux runners.
7
7
  ###############################################################################
8
8
  * text=auto eol=crlf
@@ -1,7 +1,12 @@
1
1
  #!/usr/bin/env sh
2
2
  set -e
3
3
 
4
- # 1) Lint Markdown links via PowerShell script (existing behavior)
4
+ # 0) Ensure .NET tools available (for CSharpier)
5
+ if command -v dotnet >/dev/null 2>&1; then
6
+ dotnet tool restore >/dev/null 2>&1 || true
7
+ fi
8
+
9
+ # 1) Lint Markdown link text style (PowerShell script)
5
10
  run_pwsh() {
6
11
  pwsh -NoProfile -File scripts/lint-doc-links.ps1
7
12
  }
@@ -19,7 +24,7 @@ else
19
24
  exit 1
20
25
  fi
21
26
 
22
- # 2) Format staged Markdown/JSON (incl. asmdef/asmref) with Prettier and re-stage
27
+ # 2) Format staged Markdown/JSON/YAML with Prettier and re-stage
23
28
  if ! command -v npx >/dev/null 2>&1; then
24
29
  echo "npx is required for formatting. Please install Node.js." >&2
25
30
  exit 1
@@ -28,8 +33,9 @@ fi
28
33
  STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)
29
34
 
30
35
  # Collect file lists
31
- MD_FILES=$(echo "$STAGED_FILES" | grep -E '\.(md|markdown)$' || true)
32
- JSON_FILES=$(echo "$STAGED_FILES" | grep -E '\.(json|asmdef|asmref)$' || true)
36
+ MD_FILES=$(echo "$STAGED_FILES" | grep -E '\\.(md|markdown)$' || true)
37
+ JSON_FILES=$(echo "$STAGED_FILES" | grep -E '\\.(json|asmdef|asmref)$' || true)
38
+ YAML_FILES=$(echo "$STAGED_FILES" | grep -E '\\.(yml|yaml)$' || true)
33
39
 
34
40
  # Prettier format and re-add
35
41
  if [ -n "$MD_FILES" ]; then
@@ -42,8 +48,28 @@ if [ -n "$JSON_FILES" ]; then
42
48
  echo "$JSON_FILES" | xargs git add
43
49
  fi
44
50
 
45
- # 3) Markdown lint for staged Markdown files
51
+ if [ -n "$YAML_FILES" ]; then
52
+ echo "$YAML_FILES" | xargs npx --no-install prettier --write --log-level warn
53
+ echo "$YAML_FILES" | xargs git add
54
+ fi
55
+
56
+ # 3) Format staged C# files with CSharpier (if available) and re-stage
57
+ CS_FILES=$(echo "$STAGED_FILES" | grep -E '\\.cs$' || true)
58
+ if [ -n "$CS_FILES" ] && command -v dotnet >/dev/null 2>&1; then
59
+ # Use local tool version to format paths; fall back to repo-wide if necessary
60
+ dotnet tool run csharpier $CS_FILES >/dev/null 2>&1 || dotnet tool run csharpier . >/dev/null 2>&1 || true
61
+ echo "$CS_FILES" | xargs git add
62
+ fi
63
+
64
+ # 4) Markdown lint for staged Markdown files
46
65
  if [ -n "$MD_FILES" ]; then
47
66
  npx --no-install markdownlint $MD_FILES --config .markdownlint.json --ignore-path .markdownlintignore
48
67
  fi
49
68
 
69
+ # 5) Optional YAML lint on staged YAML (if yamllint present)
70
+ if [ -n "$YAML_FILES" ]; then
71
+ if command -v yamllint >/dev/null 2>&1; then
72
+ echo "$YAML_FILES" | xargs yamllint -c .yamllint.yaml
73
+ fi
74
+ fi
75
+
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env sh
2
+ set -e
3
+
4
+ # Mirror key CI checks before pushing
5
+
6
+ # Ensure Node is available
7
+ if ! command -v npx >/dev/null 2>&1; then
8
+ echo "npx is required. Please install Node.js." >&2
9
+ exit 1
10
+ fi
11
+
12
+ # 1) Prettier checks (Markdown, JSON, asmdef/asmref, YAML)
13
+ npx --no-install prettier --check "**/*.{md,markdown}" || exit 1
14
+ npx --no-install prettier --check "**/*.{json,asmdef,asmref}" || exit 1
15
+ npx --no-install prettier --check "**/*.{yml,yaml}" || exit 1
16
+
17
+ # 2) Markdownlint (uses repo config and ignore list)
18
+ npx --no-install markdownlint "**/*.md" "**/*.markdown" --config .markdownlint.json --ignore-path .markdownlintignore || exit 1
19
+
20
+ # 3) EOL policy (CRLF, no UTF-8 BOM)
21
+ if command -v pwsh >/dev/null 2>&1; then
22
+ pwsh -NoProfile -File scripts/check-eol.ps1 -VerboseOutput
23
+ elif command -v powershell >/dev/null 2>&1; then
24
+ powershell -NoProfile -ExecutionPolicy Bypass -File scripts/check-eol.ps1 -VerboseOutput
25
+ else
26
+ echo "PowerShell not found; skipping EOL validation." >&2
27
+ fi
28
+
29
+ # 4) Optional YAML lint (if yamllint installed)
30
+ if command -v yamllint >/dev/null 2>&1; then
31
+ yamllint -c .yamllint.yaml .
32
+ else
33
+ echo "yamllint not found; skipping YAML lint. CI will catch issues." >&2
34
+ fi
35
+
36
+ # 5) Optional external link check (if lychee installed)
37
+ if command -v lychee >/dev/null 2>&1; then
38
+ lychee -c .lychee.toml --no-progress --verbose "./**/*.md"
39
+ else
40
+ echo "lychee not found; skipping external link check. CI will run lychee." >&2
41
+ fi
42
+
43
+ # 6) Test lifecycle lint
44
+ if command -v pwsh >/dev/null 2>&1; then
45
+ pwsh -NoProfile -File scripts/lint-tests.ps1 -VerboseOutput
46
+ elif command -v powershell >/dev/null 2>&1; then
47
+ powershell -NoProfile -ExecutionPolicy Bypass -File scripts/lint-tests.ps1 -VerboseOutput
48
+ else
49
+ echo 'PowerShell not found; skipping test lints.' >&2
50
+ fi
@@ -1,10 +1,32 @@
1
1
  version: 2
2
2
  updates:
3
+ # GitHub Actions workflow updates
3
4
  - package-ecosystem: "github-actions"
4
5
  directory: "/"
5
6
  schedule:
6
- interval: "weekly"
7
+ interval: "daily"
7
8
  assignees:
8
9
  - wallstop
9
10
  reviewers:
10
- - wallstop
11
+ - wallstop
12
+
13
+ # NuGet: .csproj/props/targets and .config/dotnet-tools.json (local tools)
14
+ - package-ecosystem: "nuget"
15
+ directory: "/"
16
+ schedule:
17
+ interval: "daily"
18
+ assignees:
19
+ - wallstop
20
+ reviewers:
21
+ - wallstop
22
+
23
+ # npm/UPM: package.json at repo root (Unity package manifest)
24
+ - package-ecosystem: "npm"
25
+ directory: "/"
26
+ schedule:
27
+ interval: "daily"
28
+ versioning-strategy: increase
29
+ assignees:
30
+ - wallstop
31
+ reviewers:
32
+ - wallstop
@@ -0,0 +1,77 @@
1
+ Param(
2
+ [string]$Root = "."
3
+ )
4
+
5
+ $ErrorActionPreference = 'Stop'
6
+
7
+ function Normalize-Name {
8
+ param([string]$s)
9
+ if ([string]::IsNullOrWhiteSpace($s)) { return "" }
10
+ # Remove extension (like .md), collapse non-alphanumerics, lowercase
11
+ $noExt = $s -replace '\.[^\.]+$',''
12
+ $normalized = ($noExt -replace '[^A-Za-z0-9]', '')
13
+ return $normalized.ToLowerInvariant()
14
+ }
15
+
16
+ $issueCount = 0
17
+
18
+ # Exclude typical directories that shouldn't be scanned
19
+ $excludeDirs = @('.git', 'node_modules', '.vs')
20
+
21
+ $mdFiles = Get-ChildItem -Path $Root -Recurse -File -Filter *.md |
22
+ Where-Object { $excludeDirs -notcontains $_.Directory.Name }
23
+
24
+ # Regex for inline markdown links (exclude images), capture optional title
25
+ $pattern = '(?<!\!)\[(?<text>[^\]]+)\]\((?<target>[^)\s]+)(?:\s+"[^"]*")?\)'
26
+
27
+ foreach ($file in $mdFiles) {
28
+ $lines = Get-Content -LiteralPath $file.FullName -Encoding UTF8
29
+ for ($i = 0; $i -lt $lines.Count; $i++) {
30
+ $line = $lines[$i]
31
+ $matches = [System.Text.RegularExpressions.Regex]::Matches($line, $pattern)
32
+ foreach ($m in $matches) {
33
+ $text = $m.Groups['text'].Value.Trim()
34
+ $targetRaw = $m.Groups['target'].Value.Trim()
35
+
36
+ # Skip anchors, external links, and mailto
37
+ if ($targetRaw -match '^(#|https?://|mailto:|tel:|data:)') { continue }
38
+
39
+ # Remove query/anchor for file checks
40
+ $targetCore = $targetRaw -replace '[?#].*$',''
41
+
42
+ # Decode URL-encoded chars
43
+ try { $targetCore = [uri]::UnescapeDataString($targetCore) } catch { }
44
+
45
+ # Only care about links to markdown files
46
+ if (-not ($targetCore -match '\.md$')) { continue }
47
+
48
+ $fileName = [System.IO.Path]::GetFileName($targetCore)
49
+ $baseName = [System.IO.Path]::GetFileNameWithoutExtension($targetCore)
50
+
51
+ # Fail when the visible link text is the raw file name
52
+ $isExactFileName = $text.Equals($fileName, [System.StringComparison]::OrdinalIgnoreCase)
53
+
54
+ # Also fail when the visible text looks like a path or ends with .md
55
+ # contains path separators and no whitespace (heuristic for raw paths)
56
+ $looksLikePath = ($text -match '[\\/]' -and -not ($text -match '\\s'))
57
+ $looksLikeMarkdownFileName = $text.Trim().ToLowerInvariant().EndsWith('.md')
58
+
59
+ if ($isExactFileName -or $looksLikePath -or $looksLikeMarkdownFileName) {
60
+ $issueCount++
61
+ $lineNo = $i + 1
62
+ $msg = "Link text '$text' should be human-readable, not a raw file name or path"
63
+ # GitHub Actions annotation
64
+ Write-Output "::error file=$($file.FullName),line=$lineNo::$msg (target: $targetRaw)"
65
+ }
66
+ }
67
+ }
68
+ }
69
+
70
+ if ($issueCount -gt 0) {
71
+ Write-Host "Found $issueCount documentation link(s) with non-human-readable text." -ForegroundColor Red
72
+ Write-Host "Use a descriptive phrase instead of the raw file name."
73
+ exit 1
74
+ }
75
+ else {
76
+ Write-Host "All markdown links have human-readable text."
77
+ }
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env python3
2
+ import os
3
+ import re
4
+ import sys
5
+ import urllib.parse
6
+
7
+
8
+ EXCLUDE_DIRS = {".git", "node_modules", ".vs"}
9
+
10
+
11
+ def normalize_name(s: str) -> str:
12
+ if not s:
13
+ return ""
14
+ # remove extension, strip non-alphanumerics, lowercase
15
+ base = re.sub(r"\.[^.]+$", "", s)
16
+ return re.sub(r"[^A-Za-z0-9]", "", base).lower()
17
+
18
+
19
+ LINK_RE = re.compile(r"(?<!\!)\[(?P<text>[^\]]+)\]\((?P<target>[^)\s]+)(?:\s+\"[^\"]*\")?\)")
20
+
21
+
22
+ def should_check_target(target: str) -> bool:
23
+ if re.match(r"^(#|https?://|mailto:|tel:|data:)", target):
24
+ return False
25
+ # only check links that end in .md (ignoring anchors/query)
26
+ core = re.sub(r"[?#].*$", "", target)
27
+ try:
28
+ core = urllib.parse.unquote(core)
29
+ except Exception:
30
+ pass
31
+ return core.lower().endswith(".md")
32
+
33
+
34
+ def main(root: str) -> int:
35
+ issues = 0
36
+ for dirpath, dirnames, filenames in os.walk(root):
37
+ # prune excluded directories
38
+ dirnames[:] = [d for d in dirnames if d not in EXCLUDE_DIRS]
39
+ for filename in filenames:
40
+ if not filename.lower().endswith(".md"):
41
+ continue
42
+ path = os.path.join(dirpath, filename)
43
+ try:
44
+ with open(path, "r", encoding="utf-8") as f:
45
+ lines = f.readlines()
46
+ except Exception:
47
+ continue
48
+ for idx, line in enumerate(lines, start=1):
49
+ for m in LINK_RE.finditer(line):
50
+ text = m.group("text").strip()
51
+ target_raw = m.group("target").strip()
52
+ if not should_check_target(target_raw):
53
+ continue
54
+ target_core = re.sub(r"[?#].*$", "", target_raw)
55
+ try:
56
+ target_core = urllib.parse.unquote(target_core)
57
+ except Exception:
58
+ pass
59
+ file_name = os.path.basename(target_core)
60
+ base_name, _ = os.path.splitext(file_name)
61
+
62
+ is_exact_file_name = text.lower() == file_name.lower()
63
+ looks_like_path = (("/" in text) or ("\\" in text)) and not re.search(r"\s", text)
64
+ looks_like_markdown = text.strip().lower().endswith(".md")
65
+
66
+ if (
67
+ is_exact_file_name
68
+ or looks_like_path
69
+ or looks_like_markdown
70
+ ):
71
+ issues += 1
72
+ msg = f"{path}:{idx}: Link text '{text}' should be human-readable, not a raw file name or path (target: {target_raw})"
73
+ print(msg)
74
+
75
+ if issues:
76
+ print(
77
+ f"Found {issues} documentation link(s) with non-human-readable text.",
78
+ file=sys.stderr,
79
+ )
80
+ print(
81
+ "Use a descriptive phrase instead of the raw file name.", file=sys.stderr
82
+ )
83
+ return 1
84
+ return 0
85
+
86
+
87
+ if __name__ == "__main__":
88
+ root = sys.argv[1] if len(sys.argv) > 1 else "."
89
+ sys.exit(main(root))
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env python3
2
+ import os
3
+ import re
4
+ import sys
5
+
6
+
7
+ EXCLUDE_DIRS = {".git", "node_modules", ".vs", ".vscode", "Library", "Temp"}
8
+
9
+
10
+ # Inline markdown link or image: ![alt](target "title") or [text](target "title")
11
+ INLINE_LINK_RE = re.compile(
12
+ r"!?(?P<all>\[(?P<text>[^\]]+)\]\((?P<target>[^)\s]+)(?:\s+\"[^\"]*\")?\))"
13
+ )
14
+
15
+ # Reference-style link definitions: [id]: target "title"
16
+ REF_DEF_RE = re.compile(r"^\s*\[[^\]]+\]:\s*(?P<target>\S+)(?:\s+\"[^\"]*\")?\s*$")
17
+
18
+
19
+ def is_external(target: str) -> bool:
20
+ return target.startswith("http://") or target.startswith("https://") or target.startswith("mailto:") or target.startswith("tel:") or target.startswith("data:")
21
+
22
+
23
+ def has_unencoded_chars(target: str) -> bool:
24
+ # Only flag raw spaces or plus signs in the path/query/fragment
25
+ return (" " in target) or ("+" in target)
26
+
27
+
28
+ def scan_file(path: str) -> int:
29
+ issues = 0
30
+ try:
31
+ with open(path, "r", encoding="utf-8") as f:
32
+ lines = f.readlines()
33
+ except Exception:
34
+ return 0
35
+
36
+ for idx, line in enumerate(lines, start=1):
37
+ # Inline links/images
38
+ for m in INLINE_LINK_RE.finditer(line):
39
+ target = m.group("target").strip()
40
+ if is_external(target):
41
+ continue
42
+ if has_unencoded_chars(target):
43
+ issues += 1
44
+ print(f"{path}:{idx}: Unencoded character(s) in link target: '{target}'. Encode spaces as %20 and '+' as %2B.")
45
+
46
+ # Reference-style link definitions
47
+ m = REF_DEF_RE.match(line)
48
+ if m:
49
+ target = m.group("target").strip()
50
+ if not is_external(target) and has_unencoded_chars(target):
51
+ issues += 1
52
+ print(f"{path}:{idx}: Unencoded character(s) in link definition: '{target}'. Encode spaces as %20 and '+' as %2B.")
53
+
54
+ return issues
55
+
56
+
57
+ def main(root: str) -> int:
58
+ issues = 0
59
+ for dirpath, dirnames, filenames in os.walk(root):
60
+ dirnames[:] = [d for d in dirnames if d not in EXCLUDE_DIRS]
61
+ for filename in filenames:
62
+ if filename.lower().endswith(".md"):
63
+ issues += scan_file(os.path.join(dirpath, filename))
64
+ if issues:
65
+ print(f"Found {issues} markdown link(s) with unencoded spaces or plus signs.", file=sys.stderr)
66
+ print("Please URL-encode spaces as %20 and '+' as %2B in relative links.", file=sys.stderr)
67
+ return 1
68
+ return 0
69
+
70
+
71
+ if __name__ == "__main__":
72
+ root = sys.argv[1] if len(sys.argv) > 1 else "."
73
+ sys.exit(main(root))
74
+
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env python3
2
+ import os
3
+ import re
4
+ import sys
5
+ import urllib.parse
6
+ from typing import Dict, List, Set, Tuple
7
+
8
+
9
+ EXCLUDE_DIRS = {".git", "node_modules", ".vs"}
10
+
11
+
12
+ LINK_RE = re.compile(r"(?<!\!)\[(?P<text>[^\]]+)\]\((?P<target>[^)\s]+)(?:\s+\"[^\"]*\")?\)")
13
+
14
+
15
+ def unescape_uri(s: str) -> str:
16
+ try:
17
+ return urllib.parse.unquote(s)
18
+ except Exception:
19
+ return s
20
+
21
+
22
+ def normalize_heading_to_id(text: str) -> str:
23
+ """
24
+ Approximate GitHub-style anchor slug generation (GFM):
25
+ - Lowercase
26
+ - Strip Markdown formatting and code ticks
27
+ - Remove most punctuation
28
+ - Replace whitespace with hyphens
29
+ - Collapse multiple hyphens
30
+ - Trim leading/trailing hyphens
31
+ """
32
+ if not text:
33
+ return ""
34
+ t = text
35
+ # Remove inline code ticks
36
+ t = t.replace("`", "")
37
+ # Remove Markdown emphasis markers
38
+ t = t.replace("*", "").replace("_", "").replace("~", "")
39
+ # Remove link/image markup inside headings e.g. [text](url)
40
+ t = re.sub(r"!?\[[^\]]*\]\([^)]*\)", "", t)
41
+ # Remove HTML tags
42
+ t = re.sub(r"<[^>]+>", "", t)
43
+ # Lowercase
44
+ t = t.lower()
45
+ # Replace whitespace with hyphens
46
+ t = re.sub(r"\s+", "-", t)
47
+ # Remove punctuation except hyphens and alphanumerics
48
+ t = re.sub(r"[^a-z0-9-]", "", t)
49
+ # Collapse multiple hyphens
50
+ t = re.sub(r"-+", "-", t)
51
+ # Trim hyphens
52
+ t = t.strip("-")
53
+ return t
54
+
55
+
56
+ def collect_heading_ids(file_path: str) -> Set[str]:
57
+ """
58
+ Build the set of anchor IDs generated by headings within a markdown file.
59
+ Handles ATX (# ...) and Setext (underlined) headings. Accounts for duplicate
60
+ slugs by adding -1, -2, ... suffixes (GitHub behavior).
61
+ """
62
+ try:
63
+ with open(file_path, "r", encoding="utf-8") as f:
64
+ lines = f.readlines()
65
+ except Exception:
66
+ return set()
67
+
68
+ ids: Set[str] = set()
69
+ slug_counts: Dict[str, int] = {}
70
+
71
+ def add_slug_from_text(text: str):
72
+ slug = normalize_heading_to_id(text)
73
+ if slug == "":
74
+ return
75
+ count = slug_counts.get(slug, 0)
76
+ if count == 0:
77
+ final = slug
78
+ else:
79
+ final = f"{slug}-{count}"
80
+ slug_counts[slug] = count + 1
81
+ ids.add(final)
82
+
83
+ # ATX headings
84
+ atx_re = re.compile(r"^\s{0,3}#{1,6}\s+(.*)$")
85
+
86
+ # Walk lines, handle setext headings by looking ahead
87
+ i = 0
88
+ while i < len(lines):
89
+ line = lines[i].rstrip("\n")
90
+ m = atx_re.match(line)
91
+ if m:
92
+ add_slug_from_text(m.group(1).strip())
93
+ i += 1
94
+ continue
95
+ # Setext H1/H2
96
+ if i + 1 < len(lines):
97
+ underline = lines[i + 1].rstrip("\n")
98
+ if re.match(r"^\s{0,3}=+\s*$", underline) or re.match(r"^\s{0,3}-+\s*$", underline):
99
+ add_slug_from_text(line.strip())
100
+ i += 2
101
+ continue
102
+ i += 1
103
+
104
+ return ids
105
+
106
+
107
+ def is_external(target: str) -> bool:
108
+ return bool(re.match(r"^(https?://|mailto:|tel:|data:)", target))
109
+
110
+
111
+ def resolve_path(base_dir: str, target_path: str) -> str:
112
+ return os.path.normpath(os.path.join(base_dir, target_path))
113
+
114
+
115
+ def check_internal_link(src_file: str, target_raw: str) -> Tuple[bool, str]:
116
+ # Separate fragment
117
+ if target_raw.startswith("#"):
118
+ # Anchor within same file
119
+ frag = target_raw[1:]
120
+ anchor = unescape_uri(frag)
121
+ anchor = anchor.strip()
122
+ anchor = anchor.lower()
123
+ ids = collect_heading_ids(src_file)
124
+ if anchor in ids:
125
+ return True, ""
126
+ return False, f"dangling anchor '#{frag}' (no matching heading)"
127
+
128
+ # Split off query/fragment
129
+ core = re.sub(r"[?#].*$", "", target_raw)
130
+ core = unescape_uri(core)
131
+ base_dir = os.path.dirname(src_file)
132
+ target_fs = resolve_path(base_dir, core)
133
+ if not os.path.exists(target_fs):
134
+ return False, f"target file not found: {core}"
135
+
136
+ # Fragment check if present
137
+ m = re.search(r"#(.+)$", target_raw)
138
+ if m:
139
+ frag = m.group(1)
140
+ anchor = unescape_uri(frag).strip().lower()
141
+ ids = collect_heading_ids(target_fs)
142
+ if anchor not in ids:
143
+ return False, f"dangling anchor '#{frag}' in {core}"
144
+
145
+ return True, ""
146
+
147
+
148
+ def main(paths: List[str]) -> int:
149
+ issues = 0
150
+
151
+ def iter_markdown_files() -> List[str]:
152
+ files: List[str] = []
153
+ for p in paths:
154
+ if os.path.isdir(p):
155
+ for dirpath, dirnames, filenames in os.walk(p):
156
+ dirnames[:] = [d for d in dirnames if d not in EXCLUDE_DIRS]
157
+ for filename in filenames:
158
+ if filename.lower().endswith(".md"):
159
+ files.append(os.path.join(dirpath, filename))
160
+ else:
161
+ if p.lower().endswith(".md") and os.path.exists(p):
162
+ files.append(p)
163
+ return files
164
+
165
+ files = iter_markdown_files()
166
+ for path in files:
167
+ try:
168
+ with open(path, "r", encoding="utf-8") as f:
169
+ lines = f.readlines()
170
+ except Exception:
171
+ continue
172
+ for idx, line in enumerate(lines, start=1):
173
+ for m in LINK_RE.finditer(line):
174
+ target = m.group("target").strip()
175
+ # Skip images, external links, anchors we can't resolve externally here
176
+ if is_external(target):
177
+ continue
178
+ ok, reason = check_internal_link(path, target)
179
+ if not ok:
180
+ issues += 1
181
+ print(f"{path}:{idx}: Broken link '{target}': {reason}")
182
+
183
+ if issues:
184
+ print(f"Found {issues} broken internal markdown link(s).", file=sys.stderr)
185
+ print("Fix the paths or anchors so links resolve.", file=sys.stderr)
186
+ return 1
187
+ return 0
188
+
189
+
190
+ if __name__ == "__main__":
191
+ args = sys.argv[1:]
192
+ if not args:
193
+ args = ["."]
194
+ sys.exit(main(args))