Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@
<ItemGroup>
<!-- Catalog tools -->
<PackageVersion Include="Microsoft.Playwright" Version="1.61.0" />
<PackageVersion Include="ReverseMarkdown" Version="5.4.0" />
<PackageVersion Include="ReverseMarkdown" Version="6.1.0" />
<PackageVersion Include="System.CommandLine" Version="2.0.9" />
<PackageVersion Include="YamlDotNet" Version="18.1.0" />
<!-- Publisher: JSON Schema authoring + validation of generated artifacts -->
<PackageVersion Include="JsonSchema.Net" Version="7.3.4" />
<PackageVersion Include="JsonSchema.Net" Version="9.2.2" />
<!-- Tests -->
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
Expand Down
67 changes: 67 additions & 0 deletions tools/WarHub.Catalog.Publish.Tests/SchemaValidatorTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
namespace WarHub.Catalog.Publish.Tests;

/// <summary>
/// The happy path (every generated document validating) is covered by <see cref="PublishTests"/>
/// via the fixture's full publish run. These cover the rejection path: a schema violation must
/// fail the build with a message that actually names the offending location.
/// </summary>
public sealed class SchemaValidatorTests
{
private static SchemaValidator Validator()
=> SchemaValidator.LoadFrom(Path.Combine(AppContext.BaseDirectory, "schema"));

[Fact]
public void Valid_document_passes()
{
Validator().Validate("manifest", ValidManifest, "manifest.json");
}

[Fact]
public void Missing_required_property_is_reported()
{
// "files" is required by the manifest schema.
const string json = """
{"schemaVersion":"1","kind":"manifest","version":"1.0.0","generatedAt":"2026-07-04T00:00:00Z",
"source":{"repo":"WarHub/warhub-catalog"},"counts":{}}
""";

var ex = Assert.Throws<InvalidOperationException>(
() => Validator().Validate("manifest", json, "manifest.json"));

Assert.Contains("manifest.json", ex.Message, StringComparison.Ordinal);
Assert.Contains("files", ex.Message, StringComparison.Ordinal);
}

[Fact]
public void Nested_constraint_violation_reports_instance_location()
{
// sha256 must match ^[0-9a-f]{64}$ — this one is far too short.
const string json = """
{"schemaVersion":"1","kind":"manifest","version":"1.0.0","generatedAt":"2026-07-04T00:00:00Z",
"source":{"repo":"WarHub/warhub-catalog"},"counts":{},
"files":[{"path":"products.json","kind":"products","bytes":10,"sha256":"abc"}]}
""";

var ex = Assert.Throws<InvalidOperationException>(
() => Validator().Validate("manifest", json, "manifest.json"));

// The error detail must pinpoint where it failed, not just say "invalid".
Assert.Contains("/files/0/sha256", ex.Message, StringComparison.Ordinal);
}

[Fact]
public void Unknown_schema_name_throws()
{
var ex = Assert.Throws<InvalidOperationException>(
() => Validator().Validate("no-such-schema", ValidManifest, "whatever.json"));

Assert.Contains("no-such-schema", ex.Message, StringComparison.Ordinal);
}

private const string ValidManifest = """
{"schemaVersion":"1","kind":"manifest","version":"1.0.0","generatedAt":"2026-07-04T00:00:00Z",
"source":{"repo":"WarHub/warhub-catalog"},"counts":{"products":2},
"files":[{"path":"products.json","kind":"products","bytes":10,
"sha256":"0000000000000000000000000000000000000000000000000000000000000000"}]}
""";
}
17 changes: 11 additions & 6 deletions tools/WarHub.Catalog.Publish/SchemaValidator.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System.Text.Json.Nodes;
using System.Text.Json;
using Json.Schema;

namespace WarHub.Catalog.Publish;
Expand All @@ -15,10 +15,15 @@ private SchemaValidator() { }

public static SchemaValidator LoadFrom(string schemaDir)
{
// Build into a registry owned by this instance. The default is a process-wide global
// registry that refuses to re-register an $id, which would make a second LoadFrom in
// the same process throw.
var options = new BuildOptions { SchemaRegistry = new SchemaRegistry() };

var v = new SchemaValidator();
foreach (string file in Directory.EnumerateFiles(schemaDir, "*.json"))
{
v._schemas[Path.GetFileNameWithoutExtension(file)] = JsonSchema.FromText(File.ReadAllText(file));
v._schemas[Path.GetFileNameWithoutExtension(file)] = JsonSchema.FromText(File.ReadAllText(file), options);
}

return v;
Expand All @@ -31,12 +36,12 @@ public void Validate(string schemaName, string json, string relPath)
throw new InvalidOperationException($"No schema '{schemaName}' loaded (validating {relPath}).");
}

JsonNode? node = JsonNode.Parse(json);
EvaluationResults results = schema.Evaluate(node, new EvaluationOptions { OutputFormat = OutputFormat.List });
using JsonDocument doc = JsonDocument.Parse(json);
EvaluationResults results = schema.Evaluate(doc.RootElement, new EvaluationOptions { OutputFormat = OutputFormat.List });
if (!results.IsValid)
{
IEnumerable<string> errors = results.Details
.Where(d => d.HasErrors)
IEnumerable<string> errors = (results.Details ?? [])
.Where(d => d.Errors is { Count: > 0 })
.SelectMany(d => d.Errors!.Select(e => $"{d.InstanceLocation}: {e.Value}"))
.Distinct()
.Take(10);
Expand Down
9 changes: 6 additions & 3 deletions tools/WarHub.ProductCatalog.Tool/Scraping/HtmlCleaner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,15 @@ internal static partial class HtmlCleaner
{
private const int DefaultMaxLength = 10000;

// GithubFlavored enables GFM extras (pipe tables, ~~strikethrough~~). It is NOT the same
// as Flavor = MarkdownFlavor.GitHub, which selects the round-trip-faithful GitHub writer
// that preserves raw HTML — that would emit <strong>x</strong> instead of **x**.
private static readonly Converter MarkdownConverter = new(new Config
{
UnknownTags = Config.UnknownTagsOption.Drop,
GithubFlavored = true,
RemoveComments = true,
SmartHrefHandling = true,
Tags = { Unknown = Config.UnknownTagsOption.Drop },
Formatting = { RemoveComments = true },
Links = { SmartHref = true },
});

/// <summary>
Expand Down