summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/SMAPI.Web/Controllers/JsonValidatorController.cs217
-rw-r--r--src/SMAPI.Web/Controllers/LogParserController.cs4
-rw-r--r--src/SMAPI.Web/Framework/Clients/Pastebin/IPastebinClient.cs3
-rw-r--r--src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs5
-rw-r--r--src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs3
-rw-r--r--src/SMAPI.Web/SMAPI.Web.csproj1
-rw-r--r--src/SMAPI.Web/Startup.cs2
-rw-r--r--src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorErrorModel.cs36
-rw-r--r--src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorModel.cs92
-rw-r--r--src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorRequestModel.cs15
-rw-r--r--src/SMAPI.Web/Views/JsonValidator/Index.cshtml126
-rw-r--r--src/SMAPI.Web/Views/Shared/_Layout.cshtml7
-rw-r--r--src/SMAPI.Web/appsettings.Development.json1
-rw-r--r--src/SMAPI.Web/appsettings.json1
-rw-r--r--src/SMAPI.Web/wwwroot/Content/css/json-validator.css98
-rw-r--r--src/SMAPI.Web/wwwroot/Content/css/main.css2
-rw-r--r--src/SMAPI.Web/wwwroot/Content/js/json-validator.js60
-rw-r--r--src/SMAPI.Web/wwwroot/Content/js/log-parser.js17
-rw-r--r--src/SMAPI.Web/wwwroot/schemas/manifest.json120
19 files changed, 792 insertions, 18 deletions
diff --git a/src/SMAPI.Web/Controllers/JsonValidatorController.cs b/src/SMAPI.Web/Controllers/JsonValidatorController.cs
new file mode 100644
index 00000000..9d1685ac
--- /dev/null
+++ b/src/SMAPI.Web/Controllers/JsonValidatorController.cs
@@ -0,0 +1,217 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Options;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using Newtonsoft.Json.Schema;
+using StardewModdingAPI.Web.Framework;
+using StardewModdingAPI.Web.Framework.Clients.Pastebin;
+using StardewModdingAPI.Web.Framework.Compression;
+using StardewModdingAPI.Web.Framework.ConfigModels;
+using StardewModdingAPI.Web.ViewModels.JsonValidator;
+
+namespace StardewModdingAPI.Web.Controllers
+{
+ /// <summary>Provides a web UI for validating JSON schemas.</summary>
+ internal class JsonValidatorController : Controller
+ {
+ /*********
+ ** Fields
+ *********/
+ /// <summary>The site config settings.</summary>
+ private readonly SiteConfig Config;
+
+ /// <summary>The underlying Pastebin client.</summary>
+ private readonly IPastebinClient Pastebin;
+
+ /// <summary>The underlying text compression helper.</summary>
+ private readonly IGzipHelper GzipHelper;
+
+ /// <summary>The section URL for the schema validator.</summary>
+ private string SectionUrl => this.Config.JsonValidatorUrl;
+
+ /// <summary>The supported JSON schemas (names indexed by ID).</summary>
+ private readonly IDictionary<string, string> SchemaFormats = new Dictionary<string, string>
+ {
+ ["none"] = "None",
+ ["manifest"] = "Manifest"
+ };
+
+ /// <summary>The schema ID to use if none was specified.</summary>
+ private string DefaultSchemaID = "manifest";
+
+
+ /*********
+ ** Public methods
+ *********/
+ /***
+ ** Constructor
+ ***/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="siteConfig">The context config settings.</param>
+ /// <param name="pastebin">The Pastebin API client.</param>
+ /// <param name="gzipHelper">The underlying text compression helper.</param>
+ public JsonValidatorController(IOptions<SiteConfig> siteConfig, IPastebinClient pastebin, IGzipHelper gzipHelper)
+ {
+ this.Config = siteConfig.Value;
+ this.Pastebin = pastebin;
+ this.GzipHelper = gzipHelper;
+ }
+
+ /***
+ ** Web UI
+ ***/
+ /// <summary>Render the schema validator UI.</summary>
+ /// <param name="schemaName">The schema name with which to validate the JSON.</param>
+ /// <param name="id">The paste ID.</param>
+ [HttpGet]
+ [Route("json")]
+ [Route("json/{schemaName}")]
+ [Route("json/{schemaName}/{id}")]
+ public async Task<ViewResult> Index(string schemaName = null, string id = null)
+ {
+ schemaName = this.NormaliseSchemaName(schemaName);
+
+ var result = new JsonValidatorModel(this.SectionUrl, id, schemaName, this.SchemaFormats);
+ if (string.IsNullOrWhiteSpace(id))
+ return this.View("Index", result);
+
+ // fetch raw JSON
+ PasteInfo paste = await this.GetAsync(id);
+ if (string.IsNullOrWhiteSpace(paste.Content))
+ return this.View("Index", result.SetUploadError("The JSON file seems to be empty."));
+ result.SetContent(paste.Content);
+
+ // parse JSON
+ JToken parsed;
+ try
+ {
+ parsed = JToken.Parse(paste.Content);
+ }
+ catch (JsonReaderException ex)
+ {
+ return this.View("Index", result.AddErrors(new JsonValidatorErrorModel(ex.LineNumber, ex.Path, ex.Message)));
+ }
+
+ // skip if no schema selected
+ if (schemaName == "none")
+ return this.View("Index", result);
+
+ // load schema
+ JSchema schema;
+ {
+ FileInfo schemaFile = this.FindSchemaFile(schemaName);
+ if (schemaFile == null)
+ return this.View("Index", result.SetParseError($"Invalid schema '{schemaName}'."));
+ schema = JSchema.Parse(System.IO.File.ReadAllText(schemaFile.FullName));
+ }
+
+ // validate JSON
+ parsed.IsValid(schema, out IList<ValidationError> rawErrors);
+ var errors = rawErrors
+ .Select(error => new JsonValidatorErrorModel(error.LineNumber, error.Path, this.GetFlattenedError(error)))
+ .ToArray();
+ return this.View("Index", result.AddErrors(errors));
+ }
+
+ /***
+ ** JSON
+ ***/
+ /// <summary>Save raw JSON data.</summary>
+ [HttpPost, AllowLargePosts]
+ [Route("json")]
+ public async Task<ActionResult> PostAsync(JsonValidatorRequestModel request)
+ {
+ if (request == null)
+ return this.View("Index", new JsonValidatorModel(this.SectionUrl, null, null, this.SchemaFormats).SetUploadError("The request seems to be invalid."));
+
+ // normalise schema name
+ string schemaName = this.NormaliseSchemaName(request.SchemaName);
+
+ // get raw log text
+ string input = request.Content;
+ if (string.IsNullOrWhiteSpace(input))
+ return this.View("Index", new JsonValidatorModel(this.SectionUrl, null, schemaName, this.SchemaFormats).SetUploadError("The JSON file seems to be empty."));
+
+ // upload log
+ input = this.GzipHelper.CompressString(input);
+ SavePasteResult result = await this.Pastebin.PostAsync($"JSON validator {DateTime.UtcNow:s}", input);
+
+ // handle errors
+ if (!result.Success)
+ return this.View("Index", new JsonValidatorModel(this.SectionUrl, result.ID, schemaName, this.SchemaFormats).SetUploadError($"Pastebin error: {result.Error ?? "unknown error"}"));
+
+ // redirect to view
+ UriBuilder uri = new UriBuilder(new Uri(this.SectionUrl));
+ uri.Path = $"{uri.Path.TrimEnd('/')}/{schemaName}/{result.ID}";
+ return this.Redirect(uri.Uri.ToString());
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Fetch raw text from Pastebin.</summary>
+ /// <param name="id">The Pastebin paste ID.</param>
+ private async Task<PasteInfo> GetAsync(string id)
+ {
+ PasteInfo response = await this.Pastebin.GetAsync(id);
+ response.Content = this.GzipHelper.DecompressString(response.Content);
+ return response;
+ }
+
+ /// <summary>Get a flattened, human-readable message representing a schema validation error.</summary>
+ /// <param name="error">The error to represent.</param>
+ /// <param name="indent">The indentation level to apply for inner errors.</param>
+ private string GetFlattenedError(ValidationError error, int indent = 0)
+ {
+ // get friendly representation of main error
+ string message = error.Message;
+ switch (error.ErrorType)
+ {
+ case ErrorType.Enum:
+ message = $"Invalid value. Found '{error.Value}', but expected one of '{string.Join("', '", error.Schema.Enum)}'.";
+ break;
+ }
+
+ // add inner errors
+ foreach (ValidationError childError in error.ChildErrors)
+ message += "\n" + "".PadLeft(indent * 2, ' ') + $"==> {childError.Path}: " + this.GetFlattenedError(childError, indent + 1);
+ return message;
+ }
+
+ /// <summary>Get a normalised schema name, or the <see cref="DefaultSchemaID"/> if blank.</summary>
+ /// <param name="schemaName">The raw schema name to normalise.</param>
+ private string NormaliseSchemaName(string schemaName)
+ {
+ schemaName = schemaName?.Trim().ToLower();
+ return !string.IsNullOrWhiteSpace(schemaName)
+ ? schemaName
+ : this.DefaultSchemaID;
+ }
+
+ /// <summary>Get the schema file given its unique ID.</summary>
+ /// <param name="id">The schema ID.</param>
+ private FileInfo FindSchemaFile(string id)
+ {
+ // normalise ID
+ id = id?.Trim().ToLower();
+ if (string.IsNullOrWhiteSpace(id))
+ return null;
+
+ // get matching file
+ DirectoryInfo schemaDir = new DirectoryInfo(Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "schemas"));
+ foreach (FileInfo file in schemaDir.EnumerateFiles("*.json"))
+ {
+ if (file.Name.Equals($"{id}.json"))
+ return file;
+ }
+
+ return null;
+ }
+ }
+}
diff --git a/src/SMAPI.Web/Controllers/LogParserController.cs b/src/SMAPI.Web/Controllers/LogParserController.cs
index dc5895b0..0556a81e 100644
--- a/src/SMAPI.Web/Controllers/LogParserController.cs
+++ b/src/SMAPI.Web/Controllers/LogParserController.cs
@@ -84,7 +84,7 @@ namespace StardewModdingAPI.Web.Controllers
// upload log
input = this.GzipHelper.CompressString(input);
- SavePasteResult result = await this.Pastebin.PostAsync(input);
+ SavePasteResult result = await this.Pastebin.PostAsync($"SMAPI log {DateTime.UtcNow:s}", input);
// handle errors
if (!result.Success)
@@ -108,7 +108,5 @@ namespace StardewModdingAPI.Web.Controllers
response.Content = this.GzipHelper.DecompressString(response.Content);
return response;
}
-
-
}
}
diff --git a/src/SMAPI.Web/Framework/Clients/Pastebin/IPastebinClient.cs b/src/SMAPI.Web/Framework/Clients/Pastebin/IPastebinClient.cs
index 630dfb76..a635abe3 100644
--- a/src/SMAPI.Web/Framework/Clients/Pastebin/IPastebinClient.cs
+++ b/src/SMAPI.Web/Framework/Clients/Pastebin/IPastebinClient.cs
@@ -11,7 +11,8 @@ namespace StardewModdingAPI.Web.Framework.Clients.Pastebin
Task<PasteInfo> GetAsync(string id);
/// <summary>Save a paste to Pastebin.</summary>
+ /// <param name="name">The paste name.</param>
/// <param name="content">The paste content.</param>
- Task<SavePasteResult> PostAsync(string content);
+ Task<SavePasteResult> PostAsync(string name, string content);
}
}
diff --git a/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs b/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs
index 1e46f2dc..2e8a8c68 100644
--- a/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs
+++ b/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs
@@ -67,8 +67,9 @@ namespace StardewModdingAPI.Web.Framework.Clients.Pastebin
}
/// <summary>Save a paste to Pastebin.</summary>
+ /// <param name="name">The paste name.</param>
/// <param name="content">The paste content.</param>
- public async Task<SavePasteResult> PostAsync(string content)
+ public async Task<SavePasteResult> PostAsync(string name, string content)
{
try
{
@@ -85,7 +86,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.Pastebin
api_user_key = this.UserKey,
api_dev_key = this.DevKey,
api_paste_private = 1, // unlisted
- api_paste_name = $"SMAPI log {DateTime.UtcNow:s}",
+ api_paste_name = name,
api_paste_expire_date = "N", // never expire
api_paste_code = content
}))
diff --git a/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs
index d89a4260..bc6e868a 100644
--- a/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs
+++ b/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs
@@ -12,6 +12,9 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels
/// <summary>The root URL for the log parser.</summary>
public string LogParserUrl { get; set; }
+ /// <summary>The root URL for the JSON validator.</summary>
+ public string JsonValidatorUrl { get; set; }
+
/// <summary>The root URL for the mod list.</summary>
public string ModListUrl { get; set; }
diff --git a/src/SMAPI.Web/SMAPI.Web.csproj b/src/SMAPI.Web/SMAPI.Web.csproj
index 26b29fce..98517818 100644
--- a/src/SMAPI.Web/SMAPI.Web.csproj
+++ b/src/SMAPI.Web/SMAPI.Web.csproj
@@ -26,6 +26,7 @@
<PackageReference Include="Microsoft.AspNetCore.StaticFiles" Version="2.2.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="2.2.0" />
<PackageReference Include="MongoDB.Driver" Version="2.8.1" />
+ <PackageReference Include="Newtonsoft.Json.Schema" Version="3.0.11" />
<PackageReference Include="Pathoschild.FluentNexus" Version="0.7.1" />
<PackageReference Include="Pathoschild.Http.FluentClient" Version="3.3.1" />
</ItemGroup>
diff --git a/src/SMAPI.Web/Startup.cs b/src/SMAPI.Web/Startup.cs
index bb43f5a5..de45b8a4 100644
--- a/src/SMAPI.Web/Startup.cs
+++ b/src/SMAPI.Web/Startup.cs
@@ -203,7 +203,7 @@ namespace StardewModdingAPI.Web
redirects.Add(new ConditionalRewriteSubdomainRule(
shouldRewrite: req =>
req.Host.Host != "localhost"
- && (req.Host.Host.StartsWith("api.") || req.Host.Host.StartsWith("log.") || req.Host.Host.StartsWith("mods."))
+ && (req.Host.Host.StartsWith("api.") || req.Host.Host.StartsWith("json.") || req.Host.Host.StartsWith("log.") || req.Host.Host.StartsWith("mods."))
&& !req.Path.StartsWithSegments("/content")
&& !req.Path.StartsWithSegments("/favicon.ico")
));
diff --git a/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorErrorModel.cs b/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorErrorModel.cs
new file mode 100644
index 00000000..f9497a38
--- /dev/null
+++ b/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorErrorModel.cs
@@ -0,0 +1,36 @@
+namespace StardewModdingAPI.Web.ViewModels.JsonValidator
+{
+ /// <summary>The view model for a JSON validator error.</summary>
+ public class JsonValidatorErrorModel
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The line number on which the error occurred.</summary>
+ public int Line { get; set; }
+
+ /// <summary>The field path in the JSON file where the error occurred.</summary>
+ public string Path { get; set; }
+
+ /// <summary>A human-readable description of the error.</summary>
+ public string Message { get; set; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ public JsonValidatorErrorModel() { }
+
+ /// <summary>Construct an instance.</summary>
+ /// <param name="line">The line number on which the error occurred.</param>
+ /// <param name="path">The field path in the JSON file where the error occurred.</param>
+ /// <param name="message">A human-readable description of the error.</param>
+ public JsonValidatorErrorModel(int line, string path, string message)
+ {
+ this.Line = line;
+ this.Path = path;
+ this.Message = message;
+ }
+ }
+}
diff --git a/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorModel.cs b/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorModel.cs
new file mode 100644
index 00000000..4c122d4f
--- /dev/null
+++ b/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorModel.cs
@@ -0,0 +1,92 @@
+using System.Collections.Generic;
+using System.Linq;
+
+namespace StardewModdingAPI.Web.ViewModels.JsonValidator
+{
+ /// <summary>The view model for the JSON validator page.</summary>
+ public class JsonValidatorModel
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The root URL for the log parser controller.</summary>
+ public string SectionUrl { get; set; }
+
+ /// <summary>The paste ID.</summary>
+ public string PasteID { get; set; }
+
+ /// <summary>The schema name with which the JSON was validated.</summary>
+ public string SchemaName { get; set; }
+
+ /// <summary>The supported JSON schemas (names indexed by ID).</summary>
+ public readonly IDictionary<string, string> SchemaFormats;
+
+ /// <summary>The validated content.</summary>
+ public string Content { get; set; }
+
+ /// <summary>The schema validation errors, if any.</summary>
+ public JsonValidatorErrorModel[] Errors { get; set; } = new JsonValidatorErrorModel[0];
+
+ /// <summary>An error which occurred while uploading the JSON to Pastebin.</summary>
+ public string UploadError { get; set; }
+
+ /// <summary>An error which occurred while parsing the JSON.</summary>
+ public string ParseError { get; set; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ public JsonValidatorModel() { }
+
+ /// <summary>Construct an instance.</summary>
+ /// <param name="sectionUrl">The root URL for the log parser controller.</param>
+ /// <param name="pasteID">The paste ID.</param>
+ /// <param name="schemaName">The schema name with which the JSON was validated.</param>
+ /// <param name="schemaFormats">The supported JSON schemas (names indexed by ID).</param>
+ public JsonValidatorModel(string sectionUrl, string pasteID, string schemaName, IDictionary<string, string> schemaFormats)
+ {
+ this.SectionUrl = sectionUrl;
+ this.PasteID = pasteID;
+ this.SchemaName = schemaName;
+ this.SchemaFormats = schemaFormats;
+ }
+
+ /// <summary>Set the validated content.</summary>
+ /// <param name="content">The validated content.</param>
+ public JsonValidatorModel SetContent(string content)
+ {
+ this.Content = content;
+
+ return this;
+ }
+
+ /// <summary>Set the error which occurred while uploading the log to Pastebin.</summary>
+ /// <param name="error">The error message.</param>
+ public JsonValidatorModel SetUploadError(string error)
+ {
+ this.UploadError = error;
+
+ return this;
+ }
+
+ /// <summary>Set the error which occurred while parsing the JSON.</summary>
+ /// <param name="error">The error message.</param>
+ public JsonValidatorModel SetParseError(string error)
+ {
+ this.ParseError = error;
+
+ return this;
+ }
+
+ /// <summary>Add validation errors to the response.</summary>
+ /// <param name="errors">The schema validation errors.</param>
+ public JsonValidatorModel AddErrors(params JsonValidatorErrorModel[] errors)
+ {
+ this.Errors = this.Errors.Concat(errors).ToArray();
+
+ return this;
+ }
+ }
+}
diff --git a/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorRequestModel.cs b/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorRequestModel.cs
new file mode 100644
index 00000000..c8e851bf
--- /dev/null
+++ b/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorRequestModel.cs
@@ -0,0 +1,15 @@
+namespace StardewModdingAPI.Web.ViewModels.JsonValidator
+{
+ /// <summary>The view model for a JSON validation request.</summary>
+ public class JsonValidatorRequestModel
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The schema name with which to validate the JSON.</summary>
+ public string SchemaName { get; set; }
+
+ /// <summary>The raw content to validate.</summary>
+ public string Content { get; set; }
+ }
+}
diff --git a/src/SMAPI.Web/Views/JsonValidator/Index.cshtml b/src/SMAPI.Web/Views/JsonValidator/Index.cshtml
new file mode 100644
index 00000000..cd7ca912
--- /dev/null
+++ b/src/SMAPI.Web/Views/JsonValidator/Index.cshtml
@@ -0,0 +1,126 @@
+@using StardewModdingAPI.Web.ViewModels.JsonValidator
+@model JsonValidatorModel
+
+@{
+ ViewData["Title"] = "JSON validator";
+}
+
+@section Head {
+ @if (Model.PasteID != null)
+ {
+ <meta name="robots" content="noindex" />
+ }
+ <link rel="stylesheet" href="~/Content/css/json-validator.css" />
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/tmont/sunlight@1.22.0/src/themes/sunlight.default.min.css" />
+
+ <script src="https://cdn.jsdelivr.net/npm/jquery@3.3.1/dist/jquery.min.js" crossorigin="anonymous"></script>
+ <script src="https://cdn.jsdelivr.net/gh/tmont/sunlight@1.22.0/src/sunlight.min.js" crossorigin="anonymous"></script>
+ <script src="https://cdn.jsdelivr.net/gh/tmont/sunlight@1.22.0/src/plugins/sunlight-plugin.linenumbers.min.js" crossorigin="anonymous"></script>
+ <script src="https://cdn.jsdelivr.net/gh/tmont/sunlight@1.22.0/src/lang/sunlight.javascript.min.js" crossorigin="anonymous"></script>
+ <script src="~/Content/js/json-validator.js"></script>
+ <script>
+ $(function () {
+ smapi.jsonValidator(@Json.Serialize(Model.SectionUrl), @Json.Serialize(Model.PasteID));
+ });
+ </script>
+}
+
+@* upload result banner *@
+@if (Model.UploadError != null)
+{
+ <div class="banner error">
+ <strong>Oops, the server ran into trouble saving that file.</strong><br />
+ <small>Error details: @Model.UploadError</small>
+ </div>
+}
+else if (Model.ParseError != null)
+{
+ <div class="banner error">
+ <strong>Oops, couldn't parse that JSON.</strong><br />
+ Share this link to let someone see this page: <code>@(new Uri(new Uri(Model.SectionUrl), Model.PasteID))</code><br />
+ (Or <a href="@Model.SectionUrl">validate a new file</a>.)<br />
+ <br />
+ <small v-pre>Error details: @Model.ParseError</small>
+ </div>
+}
+else if (Model.PasteID != null)
+{
+ <div class="banner success">
+ <strong>Share this link to let someone else see this page:</strong> <code>@(new Uri(new Uri(Model.SectionUrl), $"{Model.SchemaName}/{Model.PasteID}"))</code><br />
+ (Or <a href="@Model.SectionUrl">validate a new file</a>.)
+ </div>
+}
+
+@* upload new file *@
+@if (Model.Content == null)
+{
+ <h2>Upload a JSON file</h2>
+ <form action="@Model.SectionUrl" method="post">
+ <ol>
+ <li>
+ Choose the JSON format:<br />
+ <select id="format" name="SchemaName">
+ @foreach (var pair in Model.SchemaFormats)
+ {
+ <option value="@pair.Key" selected="@(Model.SchemaName == pair.Key)">@pair.Value</option>
+ }
+ </select>
+ </li>
+ <li>
+ Drag the file onto this textbox (or paste the text in):<br />
+ <textarea id="input" name="Content" placeholder="paste file here"></textarea>
+ </li>
+ <li>
+ Click this button:<br />
+ <input type="submit" id="submit" value="save file" />
+ </li>
+ </ol>
+ </form>
+}
+
+@* validation results *@
+@if (Model.Content != null)
+{
+ <div id="output">
+ @if (Model.UploadError == null)
+ {
+ <div>
+ Change JSON format:
+ <select id="format" name="format">
+ @foreach (var pair in Model.SchemaFormats)
+ {
+ <option value="@pair.Key" selected="@(Model.SchemaName == pair.Key)">@pair.Value</option>
+ }
+ </select>
+ </div>
+
+ <h2>Validation errors</h2>
+ @if (Model.Errors.Any())
+ {
+ <table id="metadata" class="table">
+ <tr>
+ <th>Line</th>
+ <th>Field</th>
+ <th>Error</th>
+ </tr>
+
+ @foreach (JsonValidatorErrorModel error in Model.Errors)
+ {
+ <tr>
+ <td>@error.Line</td>
+ <td>@error.Path</td>
+ <td>@error.Message</td>
+ </tr>
+ }
+ </table>
+ }
+ else
+ {
+ <p>No errors found.</p>
+ }
+ }
+
+ <h2>Raw content</h2>
+ <pre class="sunlight-highlight-javascript">@Model.Content</pre>
+ </div>
+}
diff --git a/src/SMAPI.Web/Views/Shared/_Layout.cshtml b/src/SMAPI.Web/Views/Shared/_Layout.cshtml
index 4c602b29..9911ef0e 100644
--- a/src/SMAPI.Web/Views/Shared/_Layout.cshtml
+++ b/src/SMAPI.Web/Views/Shared/_Layout.cshtml
@@ -16,9 +16,14 @@
<h4>SMAPI</h4>
<ul>
<li><a href="@SiteConfig.Value.RootUrl">About SMAPI</a></li>
+ <li><a href="https://stardewvalleywiki.com/Modding:Index">Modding docs</a></li>
+ </ul>
+
+ <h4>Tools</h4>
+ <ul>
<li><a href="@SiteConfig.Value.ModListUrl">Mod compatibility</a></li>
<li><a href="@SiteConfig.Value.LogParserUrl">Log parser</a></li>
- <li><a href="https://stardewvalleywiki.com/Modding:Index">Docs</a></li>
+ <li><a href="@SiteConfig.Value.JsonValidatorUrl">JSON validator</a></li>
</ul>
</div>
<div id="content-column">
diff --git a/src/SMAPI.Web/appsettings.Development.json b/src/SMAPI.Web/appsettings.Development.json
index e6b4a1b1..baf7efb7 100644
--- a/src/SMAPI.Web/appsettings.Development.json
+++ b/src/SMAPI.Web/appsettings.Development.json
@@ -12,6 +12,7 @@
"RootUrl": "http://localhost:59482/",
"ModListUrl": "http://localhost:59482/mods/",
"LogParserUrl": "http://localhost:59482/log/",
+ "JsonValidatorUrl": "http://localhost:59482/json/",
"BetaEnabled": false,
"BetaBlurb": null
},
diff --git a/src/SMAPI.Web/appsettings.json b/src/SMAPI.Web/appsettings.json
index f9777f87..a440cf42 100644
--- a/src/SMAPI.Web/appsettings.json
+++ b/src/SMAPI.Web/appsettings.json
@@ -19,6 +19,7 @@
"RootUrl": null, // see top note
"ModListUrl": null, // see top note
"LogParserUrl": null, // see top note
+ "JsonValidatorUrl": null, // see top note
"BetaEnabled": null, // see top note
"BetaBlurb": null // see top note
},
diff --git a/src/SMAPI.Web/wwwroot/Content/css/json-validator.css b/src/SMAPI.Web/wwwroot/Content/css/json-validator.css
new file mode 100644
index 00000000..f9aeb18b
--- /dev/null
+++ b/src/SMAPI.Web/wwwroot/Content/css/json-validator.css
@@ -0,0 +1,98 @@
+/*********
+** Main layout
+*********/
+#content {
+ max-width: 100%;
+}
+
+#output {
+ padding: 10px;
+ overflow: auto;
+}
+
+#output table td {
+ font-family: monospace;
+}
+
+#output table tr th,
+#output table tr td {
+ padding: 0 0.75rem;
+ white-space: pre-wrap;
+}
+
+
+/*********
+** Result banner
+*********/
+.banner {
+ border: 2px solid gray;
+ border-radius: 5px;
+ margin-top: 1em;
+ padding: 1em;
+}
+
+.banner.success {
+ border-color: green;
+ background: #CFC;
+}
+
+.banner.error {
+ border-color: red;
+ background: #FCC;
+}
+
+/*********
+** Validation results
+*********/
+.table {
+ border-bottom: 1px dashed #888888;
+ margin-bottom: 5px;
+}
+
+#metadata th, #metadata td {
+ text-align: left;
+ padding-right: 0.7em;
+}
+
+.table {
+ border: 1px solid #000000;
+ background: #ffffff;
+ border-radius: 5px;
+ border-spacing: 1px;
+ overflow: hidden;
+ cursor: default;
+ box-shadow: 1px 1px 1px 1px #dddddd;
+}
+
+.table tr {
+ background: #eee;
+}
+
+.table tr:nth-child(even) {
+ background: #fff;
+}
+
+/*********
+** Upload form
+*********/
+#input {
+ width: 100%;
+ height: 20em;
+ max-height: 70%;
+ margin: auto;
+ box-sizing: border-box;
+ border-radius: 5px;
+ border: 1px solid #000088;
+ outline: none;
+ box-shadow: inset 0px 0px 1px 1px rgba(0, 0, 192, .2);
+}
+
+#submit {
+ font-size: 1.5em;
+ border-radius: 5px;
+ outline: none;
+ box-shadow: inset 0 0 1px 1px rgba(0, 0, 0, .2);
+ cursor: pointer;
+ border: 1px solid #008800;
+ background-color: #cfc;
+}
diff --git a/src/SMAPI.Web/wwwroot/Content/css/main.css b/src/SMAPI.Web/wwwroot/Content/css/main.css
index 57eeee88..dcc7a798 100644
--- a/src/SMAPI.Web/wwwroot/Content/css/main.css
+++ b/src/SMAPI.Web/wwwroot/Content/css/main.css
@@ -73,7 +73,7 @@ a {
}
#sidebar h4 {
- margin: 0 0 0.2em 0;
+ margin: 1.5em 0 0.2em 0;
width: 10em;
border-bottom: 1px solid #CCC;
font-size: 0.8em;
diff --git a/src/SMAPI.Web/wwwroot/Content/js/json-validator.js b/src/SMAPI.Web/wwwroot/Content/js/json-validator.js
new file mode 100644
index 00000000..3f7a1775
--- /dev/null
+++ b/src/SMAPI.Web/wwwroot/Content/js/json-validator.js
@@ -0,0 +1,60 @@
+/* globals $ */
+
+var smapi = smapi || {};
+smapi.jsonValidator = function (sectionUrl, pasteID) {
+ /**
+ * Rebuild the syntax-highlighted element.
+ */
+ var formatCode = function () {
+ Sunlight.highlightAll();
+ };
+
+ /**
+ * Initialise the JSON validator page.
+ */
+ var init = function () {
+ // code formatting
+ formatCode();
+
+ // change format
+ $("#output #format").on("change", function() {
+ var schemaName = $(this).val();
+ location.href = new URL(schemaName + "/" + pasteID, sectionUrl).toString();
+ });
+
+ // upload form
+ var input = $("#input");
+ if (input.length) {
+ // disable submit if it's empty
+ var toggleSubmit = function () {
+ var hasText = !!input.val().trim();<