com.wallstop-studios.unity-helpers 2.0.3 → 2.1.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.
- package/Docs/DATA_STRUCTURES.md +7 -7
- package/Docs/EFFECTS_SYSTEM.md +836 -8
- package/Docs/EFFECTS_SYSTEM_TUTORIAL.md +77 -18
- package/Docs/HULLS.md +2 -2
- package/Docs/RANDOM_PERFORMANCE.md +1 -1
- package/Docs/REFLECTION_HELPERS.md +1 -1
- package/Docs/RELATIONAL_COMPONENTS.md +51 -6
- package/Docs/SERIALIZATION.md +1 -1
- package/Docs/SINGLETONS.md +2 -2
- package/Docs/SPATIAL_TREES_2D_GUIDE.md +3 -3
- package/Docs/SPATIAL_TREES_3D_GUIDE.md +3 -3
- package/Docs/SPATIAL_TREE_SEMANTICS.md +7 -7
- package/Editor/CustomDrawers/WShowIfPropertyDrawer.cs +131 -41
- package/Editor/Utils/ScriptableObjectSingletonCreator.cs +175 -18
- package/README.md +17 -3
- package/Runtime/Core/Helper/UnityMainThreadDispatcher.cs +4 -2
- package/Runtime/Tags/Attribute.cs +144 -24
- package/Runtime/Tags/AttributeEffect.cs +278 -11
- package/Runtime/Tags/AttributeModification.cs +59 -29
- package/Runtime/Tags/AttributeUtilities.cs +465 -0
- package/Runtime/Tags/AttributesComponent.cs +20 -0
- package/Runtime/Tags/EffectBehavior.cs +171 -0
- package/Runtime/Tags/EffectBehavior.cs.meta +4 -0
- package/Runtime/Tags/EffectHandle.cs +5 -0
- package/Runtime/Tags/EffectHandler.cs +564 -39
- package/Runtime/Tags/EffectStackKey.cs +79 -0
- package/Runtime/Tags/EffectStackKey.cs.meta +4 -0
- package/Runtime/Tags/PeriodicEffectDefinition.cs +102 -0
- package/Runtime/Tags/PeriodicEffectDefinition.cs.meta +4 -0
- package/Runtime/Tags/PeriodicEffectRuntimeState.cs +40 -0
- package/Runtime/Tags/PeriodicEffectRuntimeState.cs.meta +4 -0
- package/Runtime/Tags/TagHandler.cs +375 -21
- package/Samples~/DI - Zenject/README.md +0 -2
- package/Tests/Editor/Attributes/WShowIfPropertyDrawerTests.cs +285 -0
- package/Tests/Editor/Attributes/WShowIfPropertyDrawerTests.cs.meta +11 -0
- package/Tests/Editor/Core/Attributes/RelationalComponentAssignerTests.cs +2 -2
- package/Tests/Editor/Utils/ScriptableObjectSingletonTests.cs +41 -0
- package/Tests/Runtime/Serialization/JsonSerializationTest.cs +4 -3
- package/Tests/Runtime/Tags/AttributeEffectTests.cs +135 -0
- package/Tests/Runtime/Tags/AttributeEffectTests.cs.meta +3 -0
- package/Tests/Runtime/Tags/AttributeModificationTests.cs +137 -0
- package/Tests/Runtime/Tags/AttributeTests.cs +192 -0
- package/Tests/Runtime/Tags/AttributeTests.cs.meta +3 -0
- package/Tests/Runtime/Tags/AttributeUtilitiesTests.cs +245 -0
- package/Tests/Runtime/Tags/CosmeticAndCollisionTests.cs +1 -1
- package/Tests/Runtime/Tags/EffectBehaviorTests.cs +184 -0
- package/Tests/Runtime/Tags/EffectBehaviorTests.cs.meta +3 -0
- package/Tests/Runtime/Tags/EffectHandlerTests.cs +809 -0
- package/Tests/Runtime/Tags/Helpers/RecordingEffectBehavior.cs +89 -0
- package/Tests/Runtime/Tags/Helpers/RecordingEffectBehavior.cs.meta +4 -0
- package/Tests/Runtime/Tags/PeriodicEffectDefinitionSerializationTests.cs +92 -0
- package/Tests/Runtime/Tags/PeriodicEffectDefinitionSerializationTests.cs.meta +3 -0
- package/Tests/Runtime/Tags/TagHandlerTests.cs +130 -6
- package/package.json +1 -1
- package/scripts/lint-doc-links.ps1 +156 -11
- package/Tests/Runtime/Tags/AttributeDataTests.cs +0 -312
- package/node_modules.meta +0 -8
- /package/Tests/Runtime/Tags/{AttributeDataTests.cs.meta → AttributeModificationTests.cs.meta} +0 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
namespace WallstopStudios.UnityHelpers.Tests.Tags.Helpers
|
|
2
|
+
{
|
|
3
|
+
using System.Collections.Generic;
|
|
4
|
+
using WallstopStudios.UnityHelpers.Tags;
|
|
5
|
+
|
|
6
|
+
public sealed class RecordingEffectBehavior : EffectBehavior
|
|
7
|
+
{
|
|
8
|
+
private static readonly HashSet<int> InstanceIds = new();
|
|
9
|
+
|
|
10
|
+
public static List<EffectBehaviorContext> ApplyContexts { get; } = new();
|
|
11
|
+
|
|
12
|
+
public static List<EffectBehaviorContext> TickContexts { get; } = new();
|
|
13
|
+
|
|
14
|
+
public static List<PeriodicInvocation> PeriodicInvocations { get; } = new();
|
|
15
|
+
|
|
16
|
+
public static List<EffectBehaviorContext> RemoveContexts { get; } = new();
|
|
17
|
+
|
|
18
|
+
public static int ApplyCount { get; private set; }
|
|
19
|
+
|
|
20
|
+
public static int TickCount { get; private set; }
|
|
21
|
+
|
|
22
|
+
public static int PeriodicTickCount { get; private set; }
|
|
23
|
+
|
|
24
|
+
public static int RemoveCount { get; private set; }
|
|
25
|
+
|
|
26
|
+
public static int InstanceCount => InstanceIds.Count;
|
|
27
|
+
|
|
28
|
+
public static void Reset()
|
|
29
|
+
{
|
|
30
|
+
ApplyCount = 0;
|
|
31
|
+
TickCount = 0;
|
|
32
|
+
PeriodicTickCount = 0;
|
|
33
|
+
RemoveCount = 0;
|
|
34
|
+
InstanceIds.Clear();
|
|
35
|
+
ApplyContexts.Clear();
|
|
36
|
+
TickContexts.Clear();
|
|
37
|
+
PeriodicInvocations.Clear();
|
|
38
|
+
RemoveContexts.Clear();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private void OnEnable()
|
|
42
|
+
{
|
|
43
|
+
_ = InstanceIds.Add(GetInstanceID());
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
public override void OnApply(EffectBehaviorContext context)
|
|
47
|
+
{
|
|
48
|
+
++ApplyCount;
|
|
49
|
+
ApplyContexts.Add(context);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
public override void OnTick(EffectBehaviorContext context)
|
|
53
|
+
{
|
|
54
|
+
++TickCount;
|
|
55
|
+
TickContexts.Add(context);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
public override void OnPeriodicTick(
|
|
59
|
+
EffectBehaviorContext context,
|
|
60
|
+
PeriodicEffectTickContext tickContext
|
|
61
|
+
)
|
|
62
|
+
{
|
|
63
|
+
++PeriodicTickCount;
|
|
64
|
+
PeriodicInvocations.Add(new PeriodicInvocation(context, tickContext));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
public override void OnRemove(EffectBehaviorContext context)
|
|
68
|
+
{
|
|
69
|
+
++RemoveCount;
|
|
70
|
+
RemoveContexts.Add(context);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
public readonly struct PeriodicInvocation
|
|
74
|
+
{
|
|
75
|
+
public PeriodicInvocation(
|
|
76
|
+
EffectBehaviorContext context,
|
|
77
|
+
PeriodicEffectTickContext tickContext
|
|
78
|
+
)
|
|
79
|
+
{
|
|
80
|
+
Context = context;
|
|
81
|
+
TickContext = tickContext;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
public EffectBehaviorContext Context { get; }
|
|
85
|
+
|
|
86
|
+
public PeriodicEffectTickContext TickContext { get; }
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
namespace WallstopStudios.UnityHelpers.Tests.Tags
|
|
2
|
+
{
|
|
3
|
+
using System.Collections.Generic;
|
|
4
|
+
using NUnit.Framework;
|
|
5
|
+
using WallstopStudios.UnityHelpers.Tags;
|
|
6
|
+
using Serializer = WallstopStudios.UnityHelpers.Core.Serialization.Serializer;
|
|
7
|
+
|
|
8
|
+
[TestFixture]
|
|
9
|
+
public sealed class PeriodicEffectDefinitionSerializationTests
|
|
10
|
+
{
|
|
11
|
+
[Test]
|
|
12
|
+
public void JsonRoundtripPreservesFieldValues()
|
|
13
|
+
{
|
|
14
|
+
PeriodicEffectDefinition definition = CreateDefinition();
|
|
15
|
+
|
|
16
|
+
string json = Serializer.JsonStringify(definition);
|
|
17
|
+
PeriodicEffectDefinition deserialized =
|
|
18
|
+
Serializer.JsonDeserialize<PeriodicEffectDefinition>(json);
|
|
19
|
+
|
|
20
|
+
AssertEquivalent(definition, deserialized);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
[Test]
|
|
24
|
+
public void ProtoRoundtripPreservesFieldValues()
|
|
25
|
+
{
|
|
26
|
+
PeriodicEffectDefinition definition = CreateDefinition();
|
|
27
|
+
|
|
28
|
+
byte[] serialized = Serializer.ProtoSerialize(definition);
|
|
29
|
+
Assert.IsNotNull(serialized);
|
|
30
|
+
Assert.Greater(serialized.Length, 0);
|
|
31
|
+
|
|
32
|
+
PeriodicEffectDefinition deserialized =
|
|
33
|
+
Serializer.ProtoDeserialize<PeriodicEffectDefinition>(serialized);
|
|
34
|
+
|
|
35
|
+
AssertEquivalent(definition, deserialized);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private static PeriodicEffectDefinition CreateDefinition()
|
|
39
|
+
{
|
|
40
|
+
PeriodicEffectDefinition definition = new()
|
|
41
|
+
{
|
|
42
|
+
name = "Damage Pulse",
|
|
43
|
+
initialDelay = 0.35f,
|
|
44
|
+
interval = 0.8f,
|
|
45
|
+
maxTicks = 6,
|
|
46
|
+
modifications = new List<AttributeModification>
|
|
47
|
+
{
|
|
48
|
+
new AttributeModification
|
|
49
|
+
{
|
|
50
|
+
attribute = "health",
|
|
51
|
+
action = ModificationAction.Addition,
|
|
52
|
+
value = -7.5f,
|
|
53
|
+
},
|
|
54
|
+
new AttributeModification
|
|
55
|
+
{
|
|
56
|
+
attribute = "armor",
|
|
57
|
+
action = ModificationAction.Multiplication,
|
|
58
|
+
value = 0.85f,
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return definition;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private static void AssertEquivalent(
|
|
67
|
+
PeriodicEffectDefinition expected,
|
|
68
|
+
PeriodicEffectDefinition actual
|
|
69
|
+
)
|
|
70
|
+
{
|
|
71
|
+
Assert.IsNotNull(actual);
|
|
72
|
+
Assert.AreNotSame(expected, actual);
|
|
73
|
+
Assert.AreEqual(expected.name, actual.name);
|
|
74
|
+
Assert.AreEqual(expected.initialDelay, actual.initialDelay);
|
|
75
|
+
Assert.AreEqual(expected.interval, actual.interval);
|
|
76
|
+
Assert.AreEqual(expected.maxTicks, actual.maxTicks);
|
|
77
|
+
|
|
78
|
+
Assert.IsNotNull(actual.modifications);
|
|
79
|
+
Assert.AreNotSame(expected.modifications, actual.modifications);
|
|
80
|
+
Assert.AreEqual(expected.modifications.Count, actual.modifications.Count);
|
|
81
|
+
|
|
82
|
+
for (int i = 0; i < expected.modifications.Count; ++i)
|
|
83
|
+
{
|
|
84
|
+
AttributeModification expectedModification = expected.modifications[i];
|
|
85
|
+
AttributeModification actualModification = actual.modifications[i];
|
|
86
|
+
Assert.AreEqual(expectedModification.attribute, actualModification.attribute);
|
|
87
|
+
Assert.AreEqual(expectedModification.action, actualModification.action);
|
|
88
|
+
Assert.AreEqual(expectedModification.value, actualModification.value);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -33,18 +33,19 @@ namespace WallstopStudios.UnityHelpers.Tests.Tags
|
|
|
33
33
|
|
|
34
34
|
handler.ApplyTag("Buff");
|
|
35
35
|
handler.ApplyTag("Buff");
|
|
36
|
-
handler.
|
|
37
|
-
handler.RemoveTag("Buff"
|
|
36
|
+
handler.ApplyTag("Buff");
|
|
37
|
+
handler.RemoveTag("Buff");
|
|
38
38
|
|
|
39
39
|
Assert.AreEqual(new[] { "Buff" }, added);
|
|
40
|
+
Assert.AreEqual(2, changed.Count);
|
|
40
41
|
Assert.AreEqual(("Buff", 2U), changed[0]);
|
|
41
|
-
Assert.AreEqual(("Buff",
|
|
42
|
+
Assert.AreEqual(("Buff", 3U), changed[1]);
|
|
42
43
|
Assert.AreEqual(new[] { "Buff" }, removed);
|
|
43
44
|
yield return null;
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
[UnityTest]
|
|
47
|
-
public IEnumerator
|
|
48
|
+
public IEnumerator RemoveTagAllInstancesClearsWithEvents()
|
|
48
49
|
{
|
|
49
50
|
GameObject entity = CreateTrackedGameObject("Tags", typeof(TagHandler));
|
|
50
51
|
TagHandler handler = entity.GetComponent<TagHandler>();
|
|
@@ -53,10 +54,10 @@ namespace WallstopStudios.UnityHelpers.Tests.Tags
|
|
|
53
54
|
handler.OnTagRemoved += _ => ++removedCount;
|
|
54
55
|
|
|
55
56
|
handler.ApplyTag("Buff");
|
|
56
|
-
handler.RemoveTag("Buff"
|
|
57
|
+
handler.RemoveTag("Buff");
|
|
57
58
|
|
|
58
59
|
Assert.IsFalse(handler.HasTag("Buff"));
|
|
59
|
-
Assert.AreEqual(
|
|
60
|
+
Assert.AreEqual(1, removedCount);
|
|
60
61
|
yield return null;
|
|
61
62
|
}
|
|
62
63
|
|
|
@@ -104,5 +105,128 @@ namespace WallstopStudios.UnityHelpers.Tests.Tags
|
|
|
104
105
|
Assert.IsFalse(handler.ForceRemoveTags(handle));
|
|
105
106
|
yield return null;
|
|
106
107
|
}
|
|
108
|
+
|
|
109
|
+
[UnityTest]
|
|
110
|
+
public IEnumerator HasAllTagsAndHasNoneTagSupport()
|
|
111
|
+
{
|
|
112
|
+
GameObject entity = CreateTrackedGameObject("Tags", typeof(TagHandler));
|
|
113
|
+
TagHandler handler = entity.GetComponent<TagHandler>();
|
|
114
|
+
|
|
115
|
+
handler.ApplyTag("Buff");
|
|
116
|
+
handler.ApplyTag("Shield");
|
|
117
|
+
|
|
118
|
+
Assert.IsTrue(handler.HasAllTags(new[] { "Buff" }));
|
|
119
|
+
List<string> required = new() { "Buff", "Shield" };
|
|
120
|
+
Assert.IsTrue(handler.HasAllTags(required));
|
|
121
|
+
Assert.IsFalse(handler.HasAllTags(new[] { "Buff", "Missing" }));
|
|
122
|
+
|
|
123
|
+
List<string> none = new() { "Missing", "Other" };
|
|
124
|
+
Assert.IsTrue(handler.HasNoneOfTags(none));
|
|
125
|
+
none[1] = "Buff";
|
|
126
|
+
Assert.IsFalse(handler.HasNoneOfTags(none));
|
|
127
|
+
yield return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
[UnityTest]
|
|
131
|
+
public IEnumerator TryGetTagCountAndActiveTagsPopulateBuffer()
|
|
132
|
+
{
|
|
133
|
+
GameObject entity = CreateTrackedGameObject("Tags", typeof(TagHandler));
|
|
134
|
+
TagHandler handler = entity.GetComponent<TagHandler>();
|
|
135
|
+
|
|
136
|
+
handler.ApplyTag("Buff");
|
|
137
|
+
handler.ApplyTag("Buff");
|
|
138
|
+
handler.ApplyTag("Shield");
|
|
139
|
+
|
|
140
|
+
Assert.IsTrue(handler.TryGetTagCount("Buff", out int buffCount));
|
|
141
|
+
Assert.AreEqual(2, buffCount);
|
|
142
|
+
Assert.IsFalse(handler.TryGetTagCount("Missing", out int missingCount));
|
|
143
|
+
Assert.AreEqual(0, missingCount);
|
|
144
|
+
|
|
145
|
+
List<string> active = new();
|
|
146
|
+
handler.GetActiveTags(active);
|
|
147
|
+
CollectionAssert.AreEquivalent(new[] { "Buff", "Shield" }, active);
|
|
148
|
+
yield return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
[UnityTest]
|
|
152
|
+
public IEnumerator GetHandlesWithTagTracksHandleLifecycle()
|
|
153
|
+
{
|
|
154
|
+
GameObject entity = CreateTrackedGameObject("Entity", typeof(TestAttributesComponent));
|
|
155
|
+
TagHandler handler = entity.GetComponent<TagHandler>();
|
|
156
|
+
EffectHandler effectHandler = entity.GetComponent<EffectHandler>();
|
|
157
|
+
|
|
158
|
+
AttributeEffect effect = CreateEffect(
|
|
159
|
+
"Buff",
|
|
160
|
+
e =>
|
|
161
|
+
{
|
|
162
|
+
e.effectTags.Add("Buff");
|
|
163
|
+
}
|
|
164
|
+
);
|
|
165
|
+
EffectHandle handle = effectHandler.ApplyEffect(effect).Value;
|
|
166
|
+
|
|
167
|
+
List<EffectHandle> handles = new();
|
|
168
|
+
Assert.AreNotEqual(0, handler.GetHandlesWithTag("Buff", handles).Count);
|
|
169
|
+
Assert.AreEqual(1, handles.Count);
|
|
170
|
+
Assert.AreEqual(handle, handles[0]);
|
|
171
|
+
|
|
172
|
+
handles.Clear();
|
|
173
|
+
handler.RemoveTag("Buff");
|
|
174
|
+
Assert.AreEqual(0, handler.GetHandlesWithTag("Buff", handles).Count);
|
|
175
|
+
|
|
176
|
+
handles.Clear();
|
|
177
|
+
effectHandler.RemoveEffect(handle);
|
|
178
|
+
Assert.AreEqual(0, handler.GetHandlesWithTag("Buff", handles).Count);
|
|
179
|
+
yield return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
[UnityTest]
|
|
183
|
+
public IEnumerator GetActiveTagsReturnsNewBufferWhenNeeded()
|
|
184
|
+
{
|
|
185
|
+
GameObject entity = CreateTrackedGameObject("Tags", typeof(TagHandler));
|
|
186
|
+
TagHandler handler = entity.GetComponent<TagHandler>();
|
|
187
|
+
|
|
188
|
+
handler.ApplyTag("Buff");
|
|
189
|
+
handler.ApplyTag("Shield");
|
|
190
|
+
|
|
191
|
+
List<string> firstCall = handler.GetActiveTags();
|
|
192
|
+
Assert.IsNotNull(firstCall);
|
|
193
|
+
CollectionAssert.AreEquivalent(new[] { "Buff", "Shield" }, firstCall);
|
|
194
|
+
|
|
195
|
+
List<string> reusable = new() { "Sentinel" };
|
|
196
|
+
List<string> secondCall = handler.GetActiveTags(reusable);
|
|
197
|
+
Assert.AreSame(reusable, secondCall);
|
|
198
|
+
CollectionAssert.AreEquivalent(new[] { "Buff", "Shield" }, secondCall);
|
|
199
|
+
Assert.IsFalse(secondCall.Contains("Sentinel"));
|
|
200
|
+
yield return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
[UnityTest]
|
|
204
|
+
public IEnumerator RemoveTagReturnsContributingHandles()
|
|
205
|
+
{
|
|
206
|
+
GameObject entity = CreateTrackedGameObject("Entity", typeof(TestAttributesComponent));
|
|
207
|
+
yield return null;
|
|
208
|
+
TagHandler handler = entity.GetComponent<TagHandler>();
|
|
209
|
+
EffectHandler effectHandler = entity.GetComponent<EffectHandler>();
|
|
210
|
+
|
|
211
|
+
AttributeEffect effect = CreateEffect(
|
|
212
|
+
"Buff",
|
|
213
|
+
e =>
|
|
214
|
+
{
|
|
215
|
+
e.effectTags.Add("Buff");
|
|
216
|
+
}
|
|
217
|
+
);
|
|
218
|
+
EffectHandle handle = effectHandler.ApplyEffect(effect).Value;
|
|
219
|
+
|
|
220
|
+
List<EffectHandle> removed = handler.RemoveTag("Buff");
|
|
221
|
+
Assert.AreEqual(1, removed.Count);
|
|
222
|
+
Assert.AreEqual(handle, removed[0]);
|
|
223
|
+
Assert.IsFalse(handler.HasTag("Buff"));
|
|
224
|
+
Assert.AreEqual(0, handler.GetHandlesWithTag("Buff").Count);
|
|
225
|
+
|
|
226
|
+
List<EffectHandle> empty = handler.RemoveTag("Missing");
|
|
227
|
+
Assert.IsNotNull(empty);
|
|
228
|
+
Assert.AreEqual(0, empty.Count);
|
|
229
|
+
yield return null;
|
|
230
|
+
}
|
|
107
231
|
}
|
|
108
232
|
}
|
package/package.json
CHANGED
|
@@ -3,6 +3,7 @@ Param(
|
|
|
3
3
|
)
|
|
4
4
|
|
|
5
5
|
$ErrorActionPreference = 'Stop'
|
|
6
|
+
$repoRoot = (Resolve-Path -LiteralPath '.').Path
|
|
6
7
|
|
|
7
8
|
function Write-Violation {
|
|
8
9
|
param(
|
|
@@ -19,11 +20,110 @@ function Write-Violation {
|
|
|
19
20
|
}
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
$schemePattern = [regex]'^[a-zA-Z][a-zA-Z0-9+\.-]*:'
|
|
24
|
+
|
|
25
|
+
function Get-MarkdownLinkTarget {
|
|
26
|
+
param([string]$RawTarget)
|
|
27
|
+
|
|
28
|
+
if ([string]::IsNullOrWhiteSpace($RawTarget)) {
|
|
29
|
+
return $null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
$trimmed = $RawTarget.Trim()
|
|
33
|
+
|
|
34
|
+
if ($trimmed.StartsWith('<') -and $trimmed.Contains('>')) {
|
|
35
|
+
$closingIndex = $trimmed.IndexOf('>')
|
|
36
|
+
if ($closingIndex -gt 1) {
|
|
37
|
+
$trimmed = $trimmed.Substring(1, $closingIndex - 1)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
$spaceIndex = $trimmed.IndexOfAny(@(' ', "`t"))
|
|
42
|
+
if ($spaceIndex -ge 0) {
|
|
43
|
+
$trimmed = $trimmed.Substring(0, $spaceIndex)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if ([string]::IsNullOrWhiteSpace($trimmed)) {
|
|
47
|
+
return $null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return $trimmed
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function Is-LocalImageTarget {
|
|
54
|
+
param([string]$Target)
|
|
55
|
+
|
|
56
|
+
if ([string]::IsNullOrWhiteSpace($Target)) {
|
|
57
|
+
return $false
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
$trimmed = $Target.Trim()
|
|
61
|
+
if ($schemePattern.IsMatch($trimmed)) {
|
|
62
|
+
return $false
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if ($trimmed.StartsWith('#') -or $trimmed.StartsWith('//')) {
|
|
66
|
+
return $false
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return $true
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function Resolve-ImagePath {
|
|
73
|
+
param(
|
|
74
|
+
[string]$SourceFile,
|
|
75
|
+
[string]$Target,
|
|
76
|
+
[string]$RepoRoot
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
if ([string]::IsNullOrWhiteSpace($Target)) {
|
|
80
|
+
return $null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
$normalized = $Target.Trim() -replace '\\', '/'
|
|
84
|
+
$cutIndex = $normalized.IndexOfAny(@('#', '?'))
|
|
85
|
+
if ($cutIndex -ge 0) {
|
|
86
|
+
$normalized = $normalized.Substring(0, $cutIndex)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
while ($normalized.StartsWith('./')) {
|
|
90
|
+
$normalized = $normalized.Substring(2)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
$normalized = $normalized.Trim()
|
|
94
|
+
if ([string]::IsNullOrWhiteSpace($normalized)) {
|
|
95
|
+
return $null
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
$separator = [System.IO.Path]::DirectorySeparatorChar
|
|
99
|
+
$normalized = $normalized.Replace('/', $separator).Replace('\', $separator)
|
|
100
|
+
|
|
101
|
+
$sourceDirectory = Split-Path -Path $SourceFile -Parent
|
|
102
|
+
$candidate = [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($sourceDirectory, $normalized))
|
|
103
|
+
|
|
104
|
+
if (Test-Path -LiteralPath $candidate) {
|
|
105
|
+
return $candidate
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if ($normalized.Length -gt 0 -and $normalized[0] -ne '.') {
|
|
109
|
+
$rootRelative = $normalized.TrimStart($separator)
|
|
110
|
+
$candidateFromRoot = [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($RepoRoot, $rootRelative))
|
|
111
|
+
if (Test-Path -LiteralPath $candidateFromRoot) {
|
|
112
|
+
return $candidateFromRoot
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return $null
|
|
117
|
+
}
|
|
118
|
+
|
|
22
119
|
$mdPattern = [regex]'[A-Za-z0-9._/\-]+\.md(?:#[A-Za-z0-9_\-]+)?'
|
|
23
120
|
$linkPattern = [regex]'\[[^\]]+\]\([^)]+\)'
|
|
24
121
|
$anglePattern = [regex]'<[^>]+>'
|
|
25
122
|
$filenameTextLinkPattern = [regex]'\[(?<text>[^\]]+?\.md(?:#[^\]]+)?)\]\((?<target>[^)]+?\.md(?:#[^)]+)?)\)'
|
|
26
123
|
$inlineCodeMdPattern = [regex]'`[^`\n]*?\.md[^`\n]*?`'
|
|
124
|
+
$imagePattern = [regex]'!\[[^\]]*\]\((?<target>[^)]+)\)'
|
|
125
|
+
$imageReferencePattern = [regex]'!\[[^\]]*\]\[(?<label>[^\]]+)\]'
|
|
126
|
+
$definitionPattern = [regex]'^\s*\[(?<label>[^\]]+)\]:\s*(?<rest>.+)$'
|
|
27
127
|
|
|
28
128
|
$violationCount = 0
|
|
29
129
|
|
|
@@ -31,44 +131,88 @@ Get-ChildItem -Path . -Recurse -Include *.md -File |
|
|
|
31
131
|
Where-Object { $_.FullName -notmatch '(?i)[\\/](node_modules)[\\/]' } |
|
|
32
132
|
ForEach-Object {
|
|
33
133
|
$file = $_.FullName
|
|
134
|
+
$lines = Get-Content -LiteralPath $file
|
|
135
|
+
$lineCount = $lines.Length
|
|
136
|
+
$linkDefinitions = New-Object 'System.Collections.Generic.Dictionary[string,string]' ([System.StringComparer]::OrdinalIgnoreCase)
|
|
137
|
+
$inFence = $false
|
|
138
|
+
|
|
139
|
+
for ($index = 0; $index -lt $lineCount; $index++) {
|
|
140
|
+
$line = $lines[$index]
|
|
141
|
+
if ($line -match '^\s*```' -or $line -match '^\s*~~~') {
|
|
142
|
+
$inFence = -not $inFence
|
|
143
|
+
continue
|
|
144
|
+
}
|
|
145
|
+
if ($inFence) { continue }
|
|
146
|
+
|
|
147
|
+
$definitionMatch = $definitionPattern.Match($line)
|
|
148
|
+
if ($definitionMatch.Success) {
|
|
149
|
+
$label = $definitionMatch.Groups['label'].Value.Trim()
|
|
150
|
+
$rest = $definitionMatch.Groups['rest'].Value
|
|
151
|
+
$target = Get-MarkdownLinkTarget $rest
|
|
152
|
+
if ($label -and $target) {
|
|
153
|
+
$linkDefinitions[$label] = $target
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
34
158
|
$inFence = $false
|
|
35
|
-
$
|
|
36
|
-
|
|
37
|
-
$
|
|
38
|
-
$lineNo++
|
|
159
|
+
for ($index = 0; $index -lt $lineCount; $index++) {
|
|
160
|
+
$line = $lines[$index]
|
|
161
|
+
$lineNo = $index + 1
|
|
39
162
|
|
|
40
|
-
# Track fenced code blocks (``` or ~~~)
|
|
41
163
|
if ($line -match '^\s*```' -or $line -match '^\s*~~~') {
|
|
42
164
|
$inFence = -not $inFence
|
|
165
|
+
continue
|
|
43
166
|
}
|
|
44
|
-
if ($inFence) {
|
|
167
|
+
if ($inFence) { continue }
|
|
45
168
|
|
|
46
|
-
# 1) Flag inline code mentions of .md
|
|
47
169
|
if ($inlineCodeMdPattern.IsMatch($line)) {
|
|
48
170
|
$violationCount++
|
|
49
171
|
Write-Violation -File $file -LineNumber $lineNo -Message "Inline code mentions .md; use a human-readable link instead" -Line $line
|
|
50
172
|
}
|
|
51
173
|
|
|
52
|
-
# Strip markdown links and angle-bracket autolinks before scanning for bare mentions
|
|
53
174
|
$stripped = $linkPattern.Replace($line, '')
|
|
54
175
|
$stripped = $anglePattern.Replace($stripped, '')
|
|
55
176
|
|
|
56
|
-
# 2) Bare .md mention outside links
|
|
57
177
|
if ($mdPattern.IsMatch($stripped)) {
|
|
58
178
|
$violationCount++
|
|
59
179
|
Write-Violation -File $file -LineNumber $lineNo -Message "Bare .md mention; convert to [Readable Text](file.md)" -Line $line
|
|
60
180
|
}
|
|
61
181
|
|
|
62
|
-
# 3) Links whose visible text is still a filename
|
|
63
182
|
foreach ($m in $filenameTextLinkPattern.Matches($line)) {
|
|
64
183
|
$text = $m.Groups['text'].Value
|
|
65
184
|
$target = $m.Groups['target'].Value
|
|
66
|
-
# Ignore if the text already contains spaces (unlikely for filename.md); otherwise flag
|
|
67
185
|
if ($text -match '\.md') {
|
|
68
186
|
$violationCount++
|
|
69
187
|
Write-Violation -File $file -LineNumber $lineNo -Message "Link text is a filename; use human-readable text for $target" -Line $line
|
|
70
188
|
}
|
|
71
189
|
}
|
|
190
|
+
|
|
191
|
+
foreach ($match in $imagePattern.Matches($line)) {
|
|
192
|
+
$rawTarget = $match.Groups['target'].Value
|
|
193
|
+
$resolvedTarget = Get-MarkdownLinkTarget $rawTarget
|
|
194
|
+
if ($resolvedTarget -and (Is-LocalImageTarget $resolvedTarget)) {
|
|
195
|
+
$imagePath = Resolve-ImagePath -SourceFile $file -Target $resolvedTarget -RepoRoot $repoRoot
|
|
196
|
+
if (-not $imagePath) {
|
|
197
|
+
$violationCount++
|
|
198
|
+
Write-Violation -File $file -LineNumber $lineNo -Message "Image target '$resolvedTarget' does not resolve to a file" -Line $line
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
foreach ($match in $imageReferencePattern.Matches($line)) {
|
|
204
|
+
$label = $match.Groups['label'].Value.Trim()
|
|
205
|
+
if ($linkDefinitions.ContainsKey($label)) {
|
|
206
|
+
$resolvedTarget = $linkDefinitions[$label]
|
|
207
|
+
if ($resolvedTarget -and (Is-LocalImageTarget $resolvedTarget)) {
|
|
208
|
+
$imagePath = Resolve-ImagePath -SourceFile $file -Target $resolvedTarget -RepoRoot $repoRoot
|
|
209
|
+
if (-not $imagePath) {
|
|
210
|
+
$violationCount++
|
|
211
|
+
Write-Violation -File $file -LineNumber $lineNo -Message "Image reference '$label' points to '$resolvedTarget' which does not resolve" -Line $line
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
72
216
|
}
|
|
73
217
|
}
|
|
74
218
|
|
|
@@ -78,3 +222,4 @@ if ($violationCount -gt 0) {
|
|
|
78
222
|
} else {
|
|
79
223
|
Write-Host "Markdown link lint passed: no issues found." -ForegroundColor Green
|
|
80
224
|
}
|
|
225
|
+
|