rewrite
Some checks failed
Deploy / build-deploy (push) Failing after 4m56s

This commit is contained in:
Jorijn van der Graaf 2026-08-05 04:18:37 +02:00
commit 934c94cb5c
50 changed files with 10464 additions and 758 deletions

View 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