This commit is contained in:
parent
fb2f6079cc
commit
934c94cb5c
50 changed files with 10464 additions and 758 deletions
334
shared/interfaces/Catcrafts.Shared-Json.cppm
Normal file
334
shared/interfaces/Catcrafts.Shared-Json.cppm
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
/*
|
||||
catcrafts.net
|
||||
Copyright (C) 2026 Catcrafts
|
||||
|
||||
The source code of this website is made available for viewing purposes only.
|
||||
No permission is granted to copy, modify, distribute, or create derivative works.
|
||||
*/
|
||||
|
||||
// A small, strict JSON reader.
|
||||
//
|
||||
// Why not vendor nlohmann/json: Catcrafts.Shared may import `std` and nothing
|
||||
// else (see Catcrafts.Shared.cppm for why that boundary is absolute), and
|
||||
// json.hpp wants a global module fragment plus exceptions — the wasm build is
|
||||
// -fno-exceptions. This reads the one document shape the site actually needs
|
||||
// (content/posts.json, generated by CI from the Lemmy API) and refuses
|
||||
// everything else loudly.
|
||||
//
|
||||
// Deliberately NOT a general-purpose parser. No streaming, no comments, no
|
||||
// trailing commas, no big-number handling beyond int64. Errors are values,
|
||||
// never exceptions, and a malformed document yields an error rather than a
|
||||
// partial parse — a half-read post list rendering as a broken page is worse
|
||||
// than an empty one.
|
||||
|
||||
export module Catcrafts.Shared:Json;
|
||||
import std;
|
||||
|
||||
namespace Catcrafts::Json {
|
||||
|
||||
export enum class Type { Null, Bool, Number, String, Array, Object };
|
||||
|
||||
export class Value {
|
||||
public:
|
||||
Type type = Type::Null;
|
||||
bool boolean = false;
|
||||
double number = 0;
|
||||
std::string string;
|
||||
std::vector<Value> array;
|
||||
// A vector rather than a map: object key order is preserved (useful when
|
||||
// re-emitting) and these documents have a handful of keys, so linear
|
||||
// lookup beats hashing.
|
||||
std::vector<std::pair<std::string, Value>> object;
|
||||
|
||||
bool IsNull() const { return type == Type::Null; }
|
||||
bool IsArray() const { return type == Type::Array; }
|
||||
bool IsObject() const { return type == Type::Object; }
|
||||
|
||||
// Object lookup. Returns nullptr when absent, so callers distinguish
|
||||
// "missing" from "present but null" without a second query.
|
||||
const Value* Find(std::string_view key) const {
|
||||
if (type != Type::Object) return nullptr;
|
||||
for (const auto& [k, v] : object) {
|
||||
if (k == key) return &v;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Typed accessors with a fallback. CI generates the input, so a missing
|
||||
// or wrong-typed field is a bug in the generator rather than something a
|
||||
// page render should abort over — default and carry on, and let the
|
||||
// generator's own validation catch it.
|
||||
std::string_view Str(std::string_view key, std::string_view fallback = {}) const {
|
||||
const Value* v = Find(key);
|
||||
return (v && v->type == Type::String) ? std::string_view(v->string) : fallback;
|
||||
}
|
||||
std::int64_t Int(std::string_view key, std::int64_t fallback = 0) const {
|
||||
const Value* v = Find(key);
|
||||
return (v && v->type == Type::Number) ? static_cast<std::int64_t>(v->number) : fallback;
|
||||
}
|
||||
bool Bool(std::string_view key, bool fallback = false) const {
|
||||
const Value* v = Find(key);
|
||||
return (v && v->type == Type::Bool) ? v->boolean : fallback;
|
||||
}
|
||||
};
|
||||
|
||||
export struct ParseError {
|
||||
std::string message;
|
||||
std::size_t offset = 0;
|
||||
};
|
||||
|
||||
export using ParseResult = std::expected<Value, ParseError>;
|
||||
|
||||
namespace {
|
||||
|
||||
struct Parser {
|
||||
std::string_view s;
|
||||
std::size_t i = 0;
|
||||
|
||||
std::unexpected<ParseError> Fail(std::string msg) {
|
||||
return std::unexpected(ParseError{ std::move(msg), i });
|
||||
}
|
||||
|
||||
void SkipWhitespace() {
|
||||
while (i < s.size()) {
|
||||
const char c = s[i];
|
||||
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') ++i;
|
||||
else break;
|
||||
}
|
||||
}
|
||||
|
||||
bool Literal(std::string_view lit) {
|
||||
if (s.size() - i < lit.size()) return false;
|
||||
if (s.compare(i, lit.size(), lit) != 0) return false;
|
||||
i += lit.size();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Appends the UTF-8 encoding of a code point. JSON escapes are UTF-16,
|
||||
// so astral characters arrive as a surrogate pair and must be combined
|
||||
// before encoding — emitting each half separately produces invalid UTF-8
|
||||
// that will render as replacement characters (emoji in post titles are
|
||||
// exactly this case).
|
||||
static void AppendUtf8(std::string& out, char32_t cp) {
|
||||
if (cp <= 0x7F) {
|
||||
out += static_cast<char>(cp);
|
||||
} else if (cp <= 0x7FF) {
|
||||
out += static_cast<char>(0xC0 | (cp >> 6));
|
||||
out += static_cast<char>(0x80 | (cp & 0x3F));
|
||||
} else if (cp <= 0xFFFF) {
|
||||
out += static_cast<char>(0xE0 | (cp >> 12));
|
||||
out += static_cast<char>(0x80 | ((cp >> 6) & 0x3F));
|
||||
out += static_cast<char>(0x80 | (cp & 0x3F));
|
||||
} else {
|
||||
out += static_cast<char>(0xF0 | (cp >> 18));
|
||||
out += static_cast<char>(0x80 | ((cp >> 12) & 0x3F));
|
||||
out += static_cast<char>(0x80 | ((cp >> 6) & 0x3F));
|
||||
out += static_cast<char>(0x80 | (cp & 0x3F));
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<char32_t> Hex4() {
|
||||
if (s.size() - i < 4) return std::nullopt;
|
||||
char32_t v = 0;
|
||||
for (int k = 0; k < 4; ++k) {
|
||||
const char c = s[i + k];
|
||||
int d;
|
||||
if (c >= '0' && c <= '9') d = c - '0';
|
||||
else if (c >= 'a' && c <= 'f') d = c - 'a' + 10;
|
||||
else if (c >= 'A' && c <= 'F') d = c - 'A' + 10;
|
||||
else return std::nullopt;
|
||||
v = v * 16 + static_cast<char32_t>(d);
|
||||
}
|
||||
i += 4;
|
||||
return v;
|
||||
}
|
||||
|
||||
std::expected<std::string, ParseError> ParseString() {
|
||||
if (i >= s.size() || s[i] != '"') return Fail("expected string");
|
||||
++i;
|
||||
std::string out;
|
||||
while (true) {
|
||||
if (i >= s.size()) return Fail("unterminated string");
|
||||
const char c = s[i];
|
||||
if (c == '"') { ++i; return out; }
|
||||
if (c == '\\') {
|
||||
++i;
|
||||
if (i >= s.size()) return Fail("unterminated escape");
|
||||
const char e = s[i++];
|
||||
switch (e) {
|
||||
case '"': out += '"'; break;
|
||||
case '\\': out += '\\'; break;
|
||||
case '/': out += '/'; break;
|
||||
case 'b': out += '\b'; break;
|
||||
case 'f': out += '\f'; break;
|
||||
case 'n': out += '\n'; break;
|
||||
case 'r': out += '\r'; break;
|
||||
case 't': out += '\t'; break;
|
||||
case 'u': {
|
||||
auto hi = Hex4();
|
||||
if (!hi) return Fail("bad \\u escape");
|
||||
char32_t cp = *hi;
|
||||
if (cp >= 0xD800 && cp <= 0xDBFF) {
|
||||
// High surrogate: a low surrogate must follow.
|
||||
if (i + 1 < s.size() && s[i] == '\\' && s[i + 1] == 'u') {
|
||||
const std::size_t save = i;
|
||||
i += 2;
|
||||
auto lo = Hex4();
|
||||
if (lo && *lo >= 0xDC00 && *lo <= 0xDFFF) {
|
||||
cp = 0x10000 + ((cp - 0xD800) << 10) + (*lo - 0xDC00);
|
||||
} else {
|
||||
i = save;
|
||||
cp = 0xFFFD; // lone high surrogate
|
||||
}
|
||||
} else {
|
||||
cp = 0xFFFD;
|
||||
}
|
||||
} else if (cp >= 0xDC00 && cp <= 0xDFFF) {
|
||||
cp = 0xFFFD; // stray low surrogate
|
||||
}
|
||||
AppendUtf8(out, cp);
|
||||
break;
|
||||
}
|
||||
default: return Fail("unknown escape");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Unescaped control characters are invalid JSON; rejecting them
|
||||
// keeps a truncated/corrupted file from parsing as valid.
|
||||
if (static_cast<unsigned char>(c) < 0x20) return Fail("control character in string");
|
||||
out += c;
|
||||
++i;
|
||||
}
|
||||
}
|
||||
|
||||
// RFC 8259 number grammar, validated explicitly:
|
||||
//
|
||||
// number = [ "-" ] int [ frac ] [ exp ]
|
||||
// int = "0" / ( digit1-9 *DIGIT )
|
||||
// frac = "." 1*DIGIT
|
||||
// exp = ("e"/"E") [ "-" / "+" ] 1*DIGIT
|
||||
//
|
||||
// Scanning the character set and handing the span to from_chars is NOT
|
||||
// equivalent: from_chars accepts "01" and "+1", both of which are invalid
|
||||
// JSON. Since the point of this parser is to reject corrupted input rather
|
||||
// than guess at it, the grammar is checked before conversion.
|
||||
std::expected<double, ParseError> ParseNumber() {
|
||||
const std::size_t start = i;
|
||||
auto digit = [&] { return i < s.size() && s[i] >= '0' && s[i] <= '9'; };
|
||||
|
||||
if (i < s.size() && s[i] == '-') ++i; // leading '+' is not JSON
|
||||
|
||||
if (!digit()) return Fail("expected digit");
|
||||
if (s[i] == '0') {
|
||||
++i;
|
||||
// "0" may not be followed by another digit — "01" is invalid.
|
||||
if (digit()) return Fail("leading zero");
|
||||
} else {
|
||||
while (digit()) ++i;
|
||||
}
|
||||
|
||||
if (i < s.size() && s[i] == '.') {
|
||||
++i;
|
||||
if (!digit()) return Fail("expected digit after '.'");
|
||||
while (digit()) ++i;
|
||||
}
|
||||
|
||||
if (i < s.size() && (s[i] == 'e' || s[i] == 'E')) {
|
||||
++i;
|
||||
if (i < s.size() && (s[i] == '+' || s[i] == '-')) ++i;
|
||||
if (!digit()) return Fail("expected digit in exponent");
|
||||
while (digit()) ++i;
|
||||
}
|
||||
|
||||
double out = 0;
|
||||
const char* b = s.data() + start;
|
||||
const char* e = s.data() + i;
|
||||
const auto [ptr, ec] = std::from_chars(b, e, out);
|
||||
// Out-of-range is the one case the grammar allows but the type can't
|
||||
// hold (1e400). Treat it as malformed rather than silently infinite.
|
||||
if (ec != std::errc{} || ptr != e) return Fail("number out of range");
|
||||
return out;
|
||||
}
|
||||
|
||||
// Recursion is bounded so a hostile or corrupted document can't blow the
|
||||
// stack. Real input here nests two levels (array of flat objects).
|
||||
ParseResult ParseValue(int depth) {
|
||||
if (depth > 32) return Fail("nesting too deep");
|
||||
SkipWhitespace();
|
||||
if (i >= s.size()) return Fail("unexpected end of input");
|
||||
|
||||
Value v;
|
||||
const char c = s[i];
|
||||
|
||||
if (c == '"') {
|
||||
auto str = ParseString();
|
||||
if (!str) return std::unexpected(str.error());
|
||||
v.type = Type::String;
|
||||
v.string = std::move(*str);
|
||||
return v;
|
||||
}
|
||||
if (c == '{') {
|
||||
++i;
|
||||
v.type = Type::Object;
|
||||
SkipWhitespace();
|
||||
if (i < s.size() && s[i] == '}') { ++i; return v; }
|
||||
while (true) {
|
||||
SkipWhitespace();
|
||||
auto key = ParseString();
|
||||
if (!key) return std::unexpected(key.error());
|
||||
SkipWhitespace();
|
||||
if (i >= s.size() || s[i] != ':') return Fail("expected ':'");
|
||||
++i;
|
||||
auto val = ParseValue(depth + 1);
|
||||
if (!val) return std::unexpected(val.error());
|
||||
v.object.emplace_back(std::move(*key), std::move(*val));
|
||||
SkipWhitespace();
|
||||
if (i < s.size() && s[i] == ',') { ++i; continue; }
|
||||
if (i < s.size() && s[i] == '}') { ++i; return v; }
|
||||
return Fail("expected ',' or '}'");
|
||||
}
|
||||
}
|
||||
if (c == '[') {
|
||||
++i;
|
||||
v.type = Type::Array;
|
||||
SkipWhitespace();
|
||||
if (i < s.size() && s[i] == ']') { ++i; return v; }
|
||||
while (true) {
|
||||
auto item = ParseValue(depth + 1);
|
||||
if (!item) return std::unexpected(item.error());
|
||||
v.array.push_back(std::move(*item));
|
||||
SkipWhitespace();
|
||||
if (i < s.size() && s[i] == ',') { ++i; continue; }
|
||||
if (i < s.size() && s[i] == ']') { ++i; return v; }
|
||||
return Fail("expected ',' or ']'");
|
||||
}
|
||||
}
|
||||
if (Literal("true")) { v.type = Type::Bool; v.boolean = true; return v; }
|
||||
if (Literal("false")) { v.type = Type::Bool; v.boolean = false; return v; }
|
||||
if (Literal("null")) { v.type = Type::Null; return v; }
|
||||
|
||||
auto num = ParseNumber();
|
||||
if (!num) return std::unexpected(num.error());
|
||||
v.type = Type::Number;
|
||||
v.number = *num;
|
||||
return v;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
// Parse a complete JSON document. Trailing content after the top-level value
|
||||
// is an error rather than ignored — it usually means a truncated or
|
||||
// concatenated file, and silently accepting the prefix hides that.
|
||||
export ParseResult Parse(std::string_view text) {
|
||||
Parser p{ text, 0 };
|
||||
auto v = p.ParseValue(0);
|
||||
if (!v) return v;
|
||||
p.SkipWhitespace();
|
||||
if (p.i != text.size()) {
|
||||
return std::unexpected(ParseError{ "trailing content after JSON value", p.i });
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
} // namespace Catcrafts::Json
|
||||
Loading…
Reference in a new issue