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();
+ submit.prop("disabled", !hasText);
+ };
+ input.on("input", toggleSubmit);
+ toggleSubmit();
+
+ // drag & drop file
+ input.on({
+ 'dragover dragenter': function (e) {
+ e.preventDefault();
+ e.stopPropagation();
+ },
+ 'drop': function (e) {
+ var dataTransfer = e.originalEvent.dataTransfer;
+ if (dataTransfer && dataTransfer.files.length) {
+ e.preventDefault();
+ e.stopPropagation();
+ var file = dataTransfer.files[0];
+ var reader = new FileReader();
+ reader.onload = $.proxy(function (file, $input, event) {
+ $input.val(event.target.result);
+ toggleSubmit();
+ }, this, file, $("#input"));
+ reader.readAsText(file);
+ }
+ }
+ });
+ }
+ };
+ init();
+};
diff --git a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js
index e87a1a5c..e6c7591c 100644
--- a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js
+++ b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js
@@ -23,7 +23,7 @@ smapi.logParser = function (data, sectionUrl) {
}
// set local time started
- if(data)
+ if (data)
data.localTimeStarted = ("0" + data.logStarted.getHours()).slice(-2) + ":" + ("0" + data.logStarted.getMinutes()).slice(-2);
// init app
@@ -100,7 +100,7 @@ smapi.logParser = function (data, sectionUrl) {
updateModFilters();
},
- filtersAllow: function(modId, level) {
+ filtersAllow: function (modId, level) {
return this.showMods[modId] !== false && this.showLevels[level] !== false;
},
@@ -121,16 +121,15 @@ smapi.logParser = function (data, sectionUrl) {
var submit = $("#submit");
// instruction OS chooser
- var chooseSystem = function() {
+ var chooseSystem = function () {
systemInstructions.hide();
systemInstructions.filter("[data-os='" + $("input[name='os']:checked").val() + "']").show();
- }
+ };
systemOptions.on("click", chooseSystem);
chooseSystem();
// disable submit if it's empty
- var toggleSubmit = function()
- {
+ var toggleSubmit = function () {
var hasText = !!input.val().trim();
submit.prop("disabled", !hasText);
}
@@ -139,18 +138,18 @@ smapi.logParser = function (data, sectionUrl) {
// drag & drop file
input.on({
- 'dragover dragenter': function(e) {
+ 'dragover dragenter': function (e) {
e.preventDefault();
e.stopPropagation();
},
- 'drop': function(e) {
+ 'drop': function (e) {
var dataTransfer = e.originalEvent.dataTransfer;
if (dataTransfer && dataTransfer.files.length) {
e.preventDefault();
e.stopPropagation();
var file = dataTransfer.files[0];
var reader = new FileReader();
- reader.onload = $.proxy(function(file, $input, event) {
+ reader.onload = $.proxy(function (file, $input, event) {
$input.val(event.target.result);
toggleSubmit();
}, this, file, $("#input"));
diff --git a/src/SMAPI.Web/wwwroot/schemas/manifest.json b/src/SMAPI.Web/wwwroot/schemas/manifest.json
new file mode 100644
index 00000000..06173333
--- /dev/null
+++ b/src/SMAPI.Web/wwwroot/schemas/manifest.json
@@ -0,0 +1,120 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "$id": "https://smapi.io/schemas/manifest.json",
+ "title": "SMAPI manifest",
+ "description": "Manifest file for a SMAPI mod or content pack",
+ "type": "object",
+ "properties": {
+ "Name": {
+ "title": "Mod name",
+ "description": "The mod's display name. SMAPI uses this in player messages, logs, and errors.",
+ "type": "string",
+ "examples": [ "Lookup Anything" ]
+ },
+ "Author": {
+ "title": "Mod author",
+ "description": "The name of the person who created the mod. Ideally this should include the username used to publish mods.",
+ "type": "string",
+ "examples": [ "Pathoschild" ]
+ },
+ "Version": {
+ "title": "Mod version",
+ "description": "The mod's semantic version. Make sure you update this for each release! SMAPI uses this for update checks, mod dependencies, and compatibility blacklists (if the mod breaks in a future version of the game).",
+ "$ref": "#/definitions/SemanticVersion"
+ },
+ "Description": {
+ "title": "Mod description",
+ "description": "A short explanation of what your mod does (one or two sentences), shown in the SMAPI log.",
+ "type": "string",
+ "examples": [ "View metadata about anything by pressing a button." ]
+ },
+ "UniqueID": {
+ "title": "Mod unique ID",
+ "description": "A unique identifier for your mod. The recommended format is \"Username.ModName\", with no spaces or special characters. SMAPI uses this for update checks, mod dependencies, and compatibility blacklists (if the mod breaks in a future version of the game). When another mod needs to reference this mod, it uses the unique ID.",
+ "$ref": "#/definitions/ModID"
+ },
+ "EntryDll": {
+ "title": "Mod entry DLL",
+ "description": "The DLL filename SMAPI should load for this mod. Mutually exclusive with ContentPackFor.",
+ "type": "string",
+ "pattern": "^[a-zA-Z0-9_.-]+\\.dll$",
+ "examples": "LookupAnything.dll"
+ },
+ "ContentPackFor": {
+ "title": "Content pack for",
+ "description": "Specifies the mod which can read this content pack.",
+ "type": "object",
+ "properties": {
+ "UniqueID": {
+ "title": "Required unique ID",
+ "description": "The unique ID of the mod which can read this content pack.",
+ "$ref": "#/definitions/ModID"
+ },
+ "MinimumVersion": {
+ "title": "Required minimum version",
+ "description": "The minimum semantic version of the mod which can read this content pack, if applicable.",
+ "$ref": "#/definitions/SemanticVersion"
+ }
+ },
+
+ "required": [ "UniqueID" ]
+ },
+ "Dependencies": {
+ "title": "Mod dependencies",
+ "description": "Specifies other mods to load before this mod. If a dependency is required and a player tries to use the mod without the dependency installed, the mod won't be loaded and they'll see a friendly message saying they need to install those.",
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "UniqueID": {
+ "title": "Dependency unique ID",
+ "description": "The unique ID of the mod to load first.",
+ "$ref": "#/definitions/ModID"
+ },
+ "MinimumVersion": {
+ "title": "Dependency minimum version",
+ "description": "The minimum semantic version of the mod to load first, if applicable.",
+ "$ref": "#/definitions/SemanticVersion"
+ },
+ "IsRequired": {
+ "title": "Dependency is required",
+ "description": "Whether the dependency is required. Default true if not specified."
+ }
+ },
+ "required": [ "UniqueID" ]
+ }
+ },
+ "UpdateKeys": {
+ "title": "Mod update keys",
+ "description": "Specifies where SMAPI should check for mod updates, so it can alert the user with a link to your mod page. See https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Manifest#Update_checks for more info.",
+ "type": "array",
+ "items": {
+ "type": "string",
+ "pattern": "^(Chucklefish:\\d+|Nexus:\\d+|GitHub:[A-Za-z0-9_]+/[A-Za-z0-9_]+|ModDrop:\\d+)$"
+ }
+ }
+ },
+
+ "required": [ "Name", "Author", "Version", "Description", "UniqueID" ],
+ "oneOf": [
+ {
+ "required": [ "EntryDll" ]
+ },
+ {
+ "required": [ "ContentPackFor" ]
+ }
+ ],
+
+ "definitions": {
+ "SemanticVersion": {
+ "type": "string",
+ "pattern": "(?>(?<major>0|[1-9]\\d*))\\.(?>(?<minor>0|[1-9]\\d*))(?>(?:\\.(?<patch>0|[1-9]\\d*))?)(?:-(?<prerelease>(?>[a-zA-Z0-9]+[\\-\\.]?)+))?", // derived from SMAPI.Toolkit.SemanticVersion
+ "examples": [ "1.0.0", "1.0.1-beta.2" ]
+ },
+ "ModID": {
+ "type": "string",
+ "pattern": "^[a-zA-Z0-9_.-]+$", // derived from SMAPI.Toolkit.Utilities.PathUtilities.IsSlug
+ "examples": [ "Pathoschild.LookupAnything" ]
+ }
+ }
+}