/* 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 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> 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(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; namespace { struct Parser { std::string_view s; std::size_t i = 0; std::unexpected 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(cp); } else if (cp <= 0x7FF) { out += static_cast(0xC0 | (cp >> 6)); out += static_cast(0x80 | (cp & 0x3F)); } else if (cp <= 0xFFFF) { out += static_cast(0xE0 | (cp >> 12)); out += static_cast(0x80 | ((cp >> 6) & 0x3F)); out += static_cast(0x80 | (cp & 0x3F)); } else { out += static_cast(0xF0 | (cp >> 18)); out += static_cast(0x80 | ((cp >> 12) & 0x3F)); out += static_cast(0x80 | ((cp >> 6) & 0x3F)); out += static_cast(0x80 | (cp & 0x3F)); } } std::optional 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(d); } i += 4; return v; } std::expected 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(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 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