summaryrefslogtreecommitdiffstats
path: root/include/frontmatter.h
diff options
context:
space:
mode:
authorEthan Werner <mail@ethanwerner.com>2026-09-19 06:09:54 +0000
committerEthan Werner <mail@ethanwerner.com>2026-09-19 06:09:54 +0000
commitbf19b9938508f70011395f4dc283ca758ec6a8d3 (patch)
tree46dc4c442ed8d8d43040433c6a4794e76f8e618a /include/frontmatter.h
parent96ecf29e8be977f51f351308ef2c7c492bb829aa (diff)
llm toolingHEADmaster
Diffstat (limited to 'include/frontmatter.h')
-rw-r--r--include/frontmatter.h1058
1 files changed, 1058 insertions, 0 deletions
diff --git a/include/frontmatter.h b/include/frontmatter.h
new file mode 100644
index 0000000..56c6935
--- /dev/null
+++ b/include/frontmatter.h
@@ -0,0 +1,1058 @@
+/* frontmatter.h — portable, header-only markdown frontmatter library.
+ *
+ * Parses a markdown document with an optional `---` fenced frontmatter block
+ * into typed values. Zero dependencies: libc only. Not full YAML: simple
+ * `key: value` lines. Values may be quoted (`"..."` or `'...'`). List fields
+ * accept CSV (`tags: a, b, c`) or JSON arrays (`tags: ["a", "b"]`).
+ *
+ * Format:
+ * - Frontmatter is delimited by a leading `---` line and a closing `---`
+ * line. An unterminated fence (no closing `---`) means the whole text is
+ * body — no metadata.
+ * - Lines are `key: value`. Blank lines, `#` comments, and lines without a
+ * colon are skipped. Keys and values are trimmed.
+ * - Value types: `true`/`false` → bool; bare integer → int; `[...]` → list
+ * (JSON array; non-string elements are serialized); anything else →
+ * string (quotes stripped if present).
+ * - The body is everything after the closing fence, trailing whitespace
+ * trimmed. Documents without frontmatter: whole text, trailing
+ * whitespace trimmed.
+ *
+ * Usage: #include "frontmatter.h" in your code. The header is complete on
+ * its own; all functions are static inline, safe to include in any number
+ * of translation units.
+ *
+ * API:
+ * parse_frontmatter(text, &doc) split frontmatter + body
+ * doc_get(&doc, "key") value or NULL
+ * doc_free(&doc) free the document
+ * fm_value_free(&val) free one value
+ * fm_list_items(&val, &items) list-valued field as string items
+ * fm_json_parse(text) embedded JSON value or NULL (invalid)
+ * fm_json_free(v) / fm_json_get(obj, key) / fm_json_serialize(v)
+ * fm_json_merge_owned(base, over) shallow object merge; owns both inputs
+ *
+ * Concurrency: no shared state. Each call is self-contained.
+ */
+#ifndef FRONTMATTER_H
+#define FRONTMATTER_H
+
+#include <ctype.h>
+#include <errno.h>
+#include <stdarg.h>
+#include <stdbool.h>
+#include <stddef.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/* ------------------------------------------------------------------ */
+/* Allocation */
+/* ------------------------------------------------------------------ */
+
+static inline void *fm_xmalloc(size_t n) {
+ void *p = malloc(n ? n : 1);
+ if (!p) {
+ fprintf(stderr, "frontmatter: out of memory\n");
+ abort();
+ }
+ return p;
+}
+
+static inline void *fm_xrealloc(void *p, size_t n) {
+ void *q = realloc(p, n ? n : 1);
+ if (!q) {
+ fprintf(stderr, "frontmatter: out of memory\n");
+ abort();
+ }
+ return q;
+}
+
+static inline char *fm_strdup(const char *s) {
+ size_t n = s ? strlen(s) : 0;
+ char *p = (char *)fm_xmalloc(n + 1);
+ if (s)
+ memcpy(p, s, n);
+ p[n] = '\0';
+ return p;
+}
+
+static inline char *fm_strndup(const char *s, size_t n) {
+ char *p = (char *)fm_xmalloc(n + 1);
+ memcpy(p, s, n);
+ p[n] = '\0';
+ return p;
+}
+
+static inline char *fm_vsnprintf(const char *fmt, va_list ap) {
+ va_list ap2;
+ va_copy(ap2, ap);
+ int n = vsnprintf(NULL, 0, fmt, ap2);
+ va_end(ap2);
+ if (n < 0)
+ return fm_strdup("");
+ char *p = (char *)fm_xmalloc((size_t)n + 1);
+ vsnprintf(p, (size_t)n + 1, fmt, ap);
+ return p;
+}
+
+static inline char *fm_strfmt(const char *fmt, ...) {
+ va_list ap;
+ va_start(ap, fmt);
+ char *s = fm_vsnprintf(fmt, ap);
+ va_end(ap);
+ return s;
+}
+
+/* ------------------------------------------------------------------ */
+/* Growable byte buffer (NUL-terminated on steal) */
+/* ------------------------------------------------------------------ */
+
+typedef struct {
+ char *data;
+ size_t len;
+ size_t cap;
+} fm_buf;
+
+static inline void fm_buf_init(fm_buf *b) {
+ b->data = NULL;
+ b->len = 0;
+ b->cap = 0;
+}
+
+static inline void fm_buf_free(fm_buf *b) {
+ free(b->data);
+ b->data = NULL;
+ b->len = 0;
+ b->cap = 0;
+}
+
+static inline void fm_buf_reset(fm_buf *b) {
+ b->len = 0;
+}
+
+static inline void fm_buf_reserve(fm_buf *b, size_t extra) {
+ if (b->len + extra + 1 > b->cap) {
+ size_t cap = b->cap ? b->cap : 64;
+ while (cap < b->len + extra + 1)
+ cap *= 2;
+ b->data = (char *)fm_xrealloc(b->data, cap);
+ b->cap = cap;
+ }
+}
+
+static inline void fm_buf_putn(fm_buf *b, const char *s, size_t n) {
+ fm_buf_reserve(b, n);
+ memcpy(b->data + b->len, s, n);
+ b->len += n;
+ b->data[b->len] = '\0';
+}
+
+static inline void fm_buf_put(fm_buf *b, const char *s) {
+ if (s)
+ fm_buf_putn(b, s, strlen(s));
+}
+
+static inline void fm_buf_push(fm_buf *b, char c) {
+ fm_buf_reserve(b, 1);
+ b->data[b->len++] = c;
+ b->data[b->len] = '\0';
+}
+
+static inline int fm_buf_fmt(fm_buf *b, const char *fmt, ...) {
+ va_list ap, ap2;
+ if (!fmt)
+ return 0;
+ va_start(ap, fmt);
+ va_copy(ap2, ap);
+ int n = vsnprintf(NULL, 0, fmt, ap2);
+ va_end(ap2);
+ if (n < 0) {
+ va_end(ap);
+ return -1;
+ }
+ fm_buf_reserve(b, (size_t)n);
+ vsnprintf(b->data + b->len, (size_t)n + 1, fmt, ap);
+ va_end(ap);
+ b->len += (size_t)n;
+ return n;
+}
+
+static inline char *fm_buf_steal(fm_buf *b) {
+ if (!b->data)
+ b->data = (char *)fm_xmalloc(1);
+ b->data[b->len] = '\0';
+ char *s = b->data;
+ b->data = NULL;
+ b->len = 0;
+ b->cap = 0;
+ return s;
+}
+
+/* ------------------------------------------------------------------ */
+/* String list */
+/* ------------------------------------------------------------------ */
+
+typedef struct {
+ char **items;
+ size_t len;
+ size_t cap;
+} fm_strlist;
+
+static inline void fm_sl_init(fm_strlist *l) {
+ l->items = NULL;
+ l->len = 0;
+ l->cap = 0;
+}
+
+static inline void fm_sl_free(fm_strlist *l) {
+ for (size_t i = 0; i < l->len; i++)
+ free(l->items[i]);
+ free(l->items);
+ fm_sl_init(l);
+}
+
+static inline void fm_sl_push(fm_strlist *l, char *owned) {
+ if (l->len + 1 > l->cap) {
+ l->cap = l->cap ? l->cap * 2 : 8;
+ l->items = (char **)fm_xrealloc(l->items, l->cap * sizeof(char *));
+ }
+ l->items[l->len++] = owned;
+}
+
+static inline void fm_sl_push_copy(fm_strlist *l, const char *s) {
+ fm_sl_push(l, fm_strdup(s));
+}
+
+static inline void fm_sl_pop(fm_strlist *l) {
+ if (!l->len)
+ return;
+ free(l->items[--l->len]);
+}
+
+/* ------------------------------------------------------------------ */
+/* Embedded JSON values */
+/* Object, array, string, number, bool, null. Parse, serialize, */
+/* lookup, shallow-merge. */
+/* ------------------------------------------------------------------ */
+
+enum {
+ FMJ_OBJECT,
+ FMJ_ARRAY,
+ FMJ_STRING,
+ FMJ_NUMBER,
+ FMJ_BOOL,
+ FMJ_NULL,
+};
+
+typedef struct fm_json {
+ int type;
+ char *key; /* OBJECT member key (owned), else NULL */
+ char *str; /* FMJ_STRING value (owned), else NULL */
+ double num; /* FMJ_NUMBER */
+ int is_int; /* FMJ_NUMBER was an integer literal in range */
+ long long i; /* FMJ_NUMBER integral value */
+ int b; /* FMJ_BOOL */
+ struct fm_json **items; /* OBJECT/ARRAY members (owned) */
+ size_t count;
+} fm_json;
+
+static inline fm_json *fm_json_new(int type) {
+ fm_json *v = (fm_json *)fm_xmalloc(sizeof *v);
+ memset(v, 0, sizeof *v);
+ v->type = type;
+ return v;
+}
+
+static inline void fm_json_free(fm_json *v) {
+ if (!v)
+ return;
+ for (size_t i = 0; i < v->count; i++)
+ fm_json_free(v->items[i]);
+ free(v->items);
+ free(v->key);
+ free(v->str);
+ free(v);
+}
+
+static inline const fm_json *fm_json_get(const fm_json *o, const char *key) {
+ if (!o || o->type != FMJ_OBJECT || !key)
+ return NULL;
+ for (size_t i = 0; i < o->count; i++)
+ if (o->items[i] && o->items[i]->key &&
+ strcmp(o->items[i]->key, key) == 0)
+ return o->items[i];
+ return NULL;
+}
+
+/* Max nesting depth for the recursive-descent JSON parser; bounds stack
+ * growth so a pathologically deep input (e.g. 100k nested arrays) is
+ * rejected with an error instead of overflowing the C stack. */
+#define FM_JSON_MAX_DEPTH 256
+
+typedef struct {
+ const char *p;
+ const char *end;
+ int err;
+ size_t depth;
+} fm_jp;
+
+static inline void fm_jskip(fm_jp *s) {
+ while (s->p < s->end && (*s->p == ' ' || *s->p == '\t' || *s->p == '\n' ||
+ *s->p == '\r'))
+ s->p++;
+}
+
+/* String at s->p (opening quote). Returns owned unescaped text. */
+static inline char *fm_jstring_raw(fm_jp *s) {
+ if (s->p >= s->end || *s->p != '"') {
+ s->err = 1;
+ return NULL;
+ }
+ s->p++;
+ fm_buf b;
+ fm_buf_init(&b);
+ while (s->p < s->end && *s->p != '"') {
+ char c = *s->p++;
+ if (c == '\\' && s->p < s->end) {
+ char e = *s->p++;
+ switch (e) {
+ case '"':
+ fm_buf_push(&b, '"');
+ break;
+ case '\\':
+ fm_buf_push(&b, '\\');
+ break;
+ case '/':
+ fm_buf_push(&b, '/');
+ break;
+ case 'b':
+ fm_buf_push(&b, '\b');
+ break;
+ case 'f':
+ fm_buf_push(&b, '\f');
+ break;
+ case 'n':
+ fm_buf_push(&b, '\n');
+ break;
+ case 'r':
+ fm_buf_push(&b, '\r');
+ break;
+ case 't':
+ fm_buf_push(&b, '\t');
+ break;
+ case 'u': {
+ if (s->end - s->p < 4)
+ goto fail;
+ unsigned h = 0;
+ bool ok = true;
+ for (int k = 0; k < 4; k++) {
+ char hc = s->p[k];
+ int d;
+ if (hc >= '0' && hc <= '9')
+ d = hc - '0';
+ else if (hc >= 'a' && hc <= 'f')
+ d = hc - 'a' + 10;
+ else if (hc >= 'A' && hc <= 'F')
+ d = hc - 'A' + 10;
+ else {
+ ok = false;
+ break;
+ }
+ h = h * 16 + (unsigned)d;
+ }
+ if (!ok)
+ goto fail;
+ s->p += 4;
+ /* Join a UTF-16 surrogate pair (high \uD800-\uDBFF followed by
+ * low \uDC00-\uDFFF) into a single codepoint so astral chars
+ * (emoji, CJK ext-B, …) emit proper 4-byte UTF-8 rather than
+ * two CESU-8 3-byte sequences. Lone surrogates fall through to
+ * the 3-byte (lenient) encoding below. */
+ unsigned cp = h;
+ if (h >= 0xD800u && h <= 0xDBFFu &&
+ s->end - s->p >= 6 && s->p[0] == '\\' && s->p[1] == 'u') {
+ unsigned lo = 0;
+ bool lok = true;
+ for (int k = 0; k < 4; k++) {
+ char hc = s->p[2 + k];
+ int d;
+ if (hc >= '0' && hc <= '9')
+ d = hc - '0';
+ else if (hc >= 'a' && hc <= 'f')
+ d = hc - 'a' + 10;
+ else if (hc >= 'A' && hc <= 'F')
+ d = hc - 'A' + 10;
+ else {
+ lok = false;
+ break;
+ }
+ lo = lo * 16 + (unsigned)d;
+ }
+ if (lok && lo >= 0xDC00u && lo <= 0xDFFFu) {
+ cp = 0x10000u + ((h - 0xD800u) << 10) + (lo - 0xDC00u);
+ s->p += 6;
+ }
+ }
+ if (cp < 0x80) {
+ fm_buf_push(&b, (char)cp);
+ } else if (cp < 0x800) {
+ fm_buf_push(&b, (char)(0xC0 | (cp >> 6)));
+ fm_buf_push(&b, (char)(0x80 | (cp & 0x3F)));
+ } else if (cp < 0x10000) {
+ fm_buf_push(&b, (char)(0xE0 | (cp >> 12)));
+ fm_buf_push(&b, (char)(0x80 | ((cp >> 6) & 0x3F)));
+ fm_buf_push(&b, (char)(0x80 | (cp & 0x3F)));
+ } else {
+ fm_buf_push(&b, (char)(0xF0 | (cp >> 18)));
+ fm_buf_push(&b, (char)(0x80 | ((cp >> 12) & 0x3F)));
+ fm_buf_push(&b, (char)(0x80 | ((cp >> 6) & 0x3F)));
+ fm_buf_push(&b, (char)(0x80 | (cp & 0x3F)));
+ }
+ break;
+ }
+ default:
+ goto fail;
+ }
+ } else if (c < 0x20) {
+ goto fail; /* raw control char: invalid JSON */
+ } else {
+ fm_buf_push(&b, c);
+ }
+ }
+ if (s->p >= s->end || *s->p != '"')
+ goto fail;
+ s->p++;
+ return fm_buf_steal(&b);
+fail:
+ s->err = 1;
+ fm_buf_free(&b);
+ return NULL;
+}
+
+static inline fm_json *fm_jvalue(fm_jp *s);
+
+static inline fm_json *fm_jobject(fm_jp *s) {
+ if (s->depth >= FM_JSON_MAX_DEPTH) {
+ s->err = 1;
+ return NULL;
+ }
+ s->depth++;
+ s->p++; /* { */
+ fm_json *o = fm_json_new(FMJ_OBJECT);
+ for (;;) {
+ fm_jskip(s);
+ if (s->p < s->end && *s->p == '}') {
+ s->p++;
+ s->depth--;
+ return o;
+ }
+ char *key = fm_jstring_raw(s);
+ if (!key) {
+ fm_json_free(o);
+ return NULL;
+ }
+ fm_jskip(s);
+ if (s->p >= s->end || *s->p != ':') {
+ free(key);
+ fm_json_free(o);
+ s->err = 1;
+ return NULL;
+ }
+ s->p++;
+ fm_json *v = fm_jvalue(s);
+ if (!v) {
+ free(key);
+ fm_json_free(o);
+ return NULL;
+ }
+ v->key = key;
+ o->items =
+ (fm_json **)fm_xrealloc(o->items, (o->count + 1) * sizeof(fm_json *));
+ o->items[o->count++] = v;
+ fm_jskip(s);
+ if (s->p < s->end && *s->p == ',') {
+ s->p++;
+ continue;
+ }
+ if (s->p < s->end && *s->p == '}') {
+ s->p++;
+ s->depth--;
+ return o;
+ }
+ fm_json_free(o);
+ s->err = 1;
+ return NULL;
+ }
+}
+
+static inline fm_json *fm_jarray(fm_jp *s) {
+ if (s->depth >= FM_JSON_MAX_DEPTH) {
+ s->err = 1;
+ return NULL;
+ }
+ s->depth++;
+ s->p++; /* [ */
+ fm_json *a = fm_json_new(FMJ_ARRAY);
+ for (;;) {
+ fm_jskip(s);
+ if (s->p < s->end && *s->p == ']') {
+ s->p++;
+ s->depth--;
+ return a;
+ }
+ fm_json *v = fm_jvalue(s);
+ if (!v) {
+ fm_json_free(a);
+ return NULL;
+ }
+ a->items =
+ (fm_json **)fm_xrealloc(a->items, (a->count + 1) * sizeof(fm_json *));
+ a->items[a->count++] = v;
+ fm_jskip(s);
+ if (s->p < s->end && *s->p == ',') {
+ s->p++;
+ continue;
+ }
+ if (s->p < s->end && *s->p == ']') {
+ s->p++;
+ s->depth--;
+ return a;
+ }
+ fm_json_free(a);
+ s->err = 1;
+ return NULL;
+ }
+}
+
+static inline fm_json *fm_jvalue(fm_jp *s) {
+ fm_jskip(s);
+ if (s->p >= s->end) {
+ s->err = 1;
+ return NULL;
+ }
+ char c = *s->p;
+ if (c == '{')
+ return fm_jobject(s);
+ if (c == '[')
+ return fm_jarray(s);
+ if (c == '"') {
+ char *t = fm_jstring_raw(s);
+ if (!t)
+ return NULL;
+ fm_json *v = fm_json_new(FMJ_STRING);
+ v->str = t;
+ return v;
+ }
+ if (s->end - s->p >= 4 && strncmp(s->p, "true", 4) == 0) {
+ s->p += 4;
+ fm_json *v = fm_json_new(FMJ_BOOL);
+ v->b = 1;
+ return v;
+ }
+ if (s->end - s->p >= 5 && strncmp(s->p, "false", 5) == 0) {
+ s->p += 5;
+ fm_json *v = fm_json_new(FMJ_BOOL);
+ v->b = 0;
+ return v;
+ }
+ if (s->end - s->p >= 4 && strncmp(s->p, "null", 4) == 0) {
+ s->p += 4;
+ return fm_json_new(FMJ_NULL);
+ }
+ if (c == '-' || (c >= '0' && c <= '9')) {
+ const char *start = s->p;
+ if (c == '-')
+ s->p++;
+ if (s->p >= s->end || *s->p < '0' || *s->p > '9') {
+ s->err = 1;
+ return NULL;
+ }
+ if (*s->p == '0' && s->p + 1 < s->end && s->p[1] >= '0' &&
+ s->p[1] <= '9') {
+ s->err = 1; /* leading zero */
+ return NULL;
+ }
+ while (s->p < s->end && *s->p >= '0' && *s->p <= '9')
+ s->p++;
+ int is_int = 1;
+ if (s->p < s->end && *s->p == '.') {
+ is_int = 0;
+ s->p++;
+ if (s->p >= s->end || *s->p < '0' || *s->p > '9') {
+ s->err = 1;
+ return NULL;
+ }
+ while (s->p < s->end && *s->p >= '0' && *s->p <= '9')
+ s->p++;
+ }
+ if (s->p < s->end && (*s->p == 'e' || *s->p == 'E')) {
+ is_int = 0;
+ s->p++;
+ if (s->p < s->end && (*s->p == '+' || *s->p == '-'))
+ s->p++;
+ if (s->p >= s->end || *s->p < '0' || *s->p > '9') {
+ s->err = 1;
+ return NULL;
+ }
+ while (s->p < s->end && *s->p >= '0' && *s->p <= '9')
+ s->p++;
+ }
+ char *endp = NULL;
+ errno = 0;
+ double d = strtod(start, &endp);
+ if (errno != 0 || endp == start) {
+ s->err = 1;
+ return NULL;
+ }
+ fm_json *v = fm_json_new(FMJ_NUMBER);
+ v->num = d;
+ if (is_int && d >= -9.0e18 && d <= 9.0e18) {
+ v->is_int = 1;
+ v->i = (long long)d;
+ }
+ return v;
+ }
+ s->err = 1;
+ return NULL;
+}
+
+static inline fm_json *fm_json_parse(const char *text) {
+ if (!text)
+ return NULL;
+ fm_jp s = {text, text + strlen(text), 0, 0};
+ fm_json *v = fm_jvalue(&s);
+ if (!v)
+ return NULL;
+ fm_jskip(&s);
+ if (s.p != s.end) { /* trailing garbage */
+ fm_json_free(v);
+ return NULL;
+ }
+ return v;
+}
+
+static inline void fm_jstring_out(fm_buf *b, const char *s) {
+ fm_buf_push(b, '"');
+ for (const unsigned char *p = (const unsigned char *)s; *p; p++) {
+ unsigned char c = *p;
+ switch (c) {
+ case '"':
+ fm_buf_put(b, "\\\"");
+ break;
+ case '\\':
+ fm_buf_put(b, "\\\\");
+ break;
+ case '\b':
+ fm_buf_put(b, "\\b");
+ break;
+ case '\f':
+ fm_buf_put(b, "\\f");
+ break;
+ case '\n':
+ fm_buf_put(b, "\\n");
+ break;
+ case '\r':
+ fm_buf_put(b, "\\r");
+ break;
+ case '\t':
+ fm_buf_put(b, "\\t");
+ break;
+ default:
+ if (c < 0x20)
+ fm_buf_fmt(b, "\\u%04x", c);
+ else
+ fm_buf_push(b, (char)c);
+ }
+ }
+ fm_buf_push(b, '"');
+}
+
+static inline void fm_json_out(const fm_json *v, fm_buf *b) {
+ switch (v->type) {
+ case FMJ_NULL:
+ fm_buf_put(b, "null");
+ break;
+ case FMJ_BOOL:
+ fm_buf_put(b, v->b ? "true" : "false");
+ break;
+ case FMJ_STRING:
+ fm_jstring_out(b, v->str ? v->str : "");
+ break;
+ case FMJ_NUMBER:
+ if (v->is_int)
+ fm_buf_fmt(b, "%lld", v->i);
+ else
+ fm_buf_fmt(b, "%.15g", v->num);
+ break;
+ case FMJ_ARRAY:
+ fm_buf_push(b, '[');
+ for (size_t i = 0; i < v->count; i++) {
+ if (i)
+ fm_buf_put(b, ",");
+ fm_json_out(v->items[i], b);
+ }
+ fm_buf_push(b, ']');
+ break;
+ case FMJ_OBJECT:
+ fm_buf_push(b, '{');
+ for (size_t i = 0; i < v->count; i++) {
+ if (i)
+ fm_buf_put(b, ",");
+ fm_jstring_out(b, v->items[i]->key ? v->items[i]->key : "");
+ fm_buf_put(b, ":");
+ fm_json_out(v->items[i], b);
+ }
+ fm_buf_push(b, '}');
+ break;
+ }
+}
+
+static inline char *fm_json_serialize(const fm_json *v) {
+ fm_buf b;
+ fm_buf_init(&b);
+ if (v)
+ fm_json_out(v, &b);
+ return fm_buf_steal(&b);
+}
+
+/* Shallow merge of two objects: union of keys, `over` wins per key.
+ * Takes ownership of both inputs (either may be NULL or non-object, in
+ * which case it is dropped). Returns a new object; free with fm_json_free.
+ * Member order: over's keys first, then base-only keys. */
+static inline fm_json *fm_json_merge_owned(const fm_json *base,
+ const fm_json *over) {
+ fm_json *B = (base && base->type == FMJ_OBJECT) ? (fm_json *)base : NULL;
+ fm_json *O = (over && over->type == FMJ_OBJECT) ? (fm_json *)over : NULL;
+ if (base && base->type != FMJ_OBJECT)
+ fm_json_free((fm_json *)base);
+ if (over && over->type != FMJ_OBJECT)
+ fm_json_free((fm_json *)over);
+ fm_json *r = fm_json_new(FMJ_OBJECT);
+ if (O) {
+ for (size_t i = 0; i < O->count; i++)
+ if (O->items[i]) {
+ r->items =
+ (fm_json **)fm_xrealloc(r->items,
+ (r->count + 1) * sizeof(fm_json *));
+ r->items[r->count++] = O->items[i];
+ }
+ }
+ if (B) {
+ for (size_t i = 0; i < B->count; i++) {
+ fm_json *m = B->items[i];
+ if (!m)
+ continue;
+ if (O && fm_json_get(O, m->key))
+ fm_json_free(m); /* superseded */
+ else {
+ r->items =
+ (fm_json **)fm_xrealloc(r->items,
+ (r->count + 1) * sizeof(fm_json *));
+ r->items[r->count++] = m;
+ }
+ }
+ }
+ if (B) {
+ free(B->items);
+ free(B->key);
+ free(B->str);
+ free(B);
+ }
+ if (O) {
+ free(O->items);
+ free(O->key);
+ free(O->str);
+ free(O);
+ }
+ return r;
+}
+
+/* ------------------------------------------------------------------ */
+/* Frontmatter */
+/* ------------------------------------------------------------------ */
+
+/* Frontmatter value */
+typedef enum {
+ FM_STR,
+ FM_LIST,
+ FM_BOOL,
+ FM_INT,
+} fm_kind;
+
+typedef struct {
+ fm_kind kind;
+ char *s;
+ char **items;
+ size_t item_count;
+ bool b;
+ long long i;
+} fm_value;
+
+static inline void fm_value_free(fm_value *v) {
+ free(v->s);
+ for (size_t i = 0; i < v->item_count; i++)
+ free(v->items[i]);
+ free(v->items);
+ memset(v, 0, sizeof *v);
+}
+
+/* List-valued field as items: JSON array as-is, CSV split on commas,
+ * single string as one item. */
+static inline void fm_list_items(const fm_value *v, fm_strlist *out) {
+ fm_sl_init(out);
+ if (!v)
+ return;
+ if (v->kind == FM_LIST) {
+ for (size_t i = 0; i < v->item_count; i++)
+ fm_sl_push_copy(out, v->items[i]);
+ return;
+ }
+ if (v->kind != FM_STR || !v->s || !strchr(v->s, ',')) {
+ if (v->s) {
+ size_t a = 0, n = strlen(v->s);
+ while (a < n && isspace((unsigned char)v->s[a]))
+ a++;
+ while (n > a && isspace((unsigned char)v->s[n - 1]))
+ n--;
+ if (n > a)
+ fm_sl_push(out, fm_strndup(v->s + a, n - a));
+ }
+ return;
+ }
+ const char *s = v->s;
+ size_t n = strlen(s);
+ size_t start = 0;
+ for (;;) {
+ while (start < n && isspace((unsigned char)s[start]))
+ start++;
+ if (start >= n)
+ break;
+ const char *comma = (const char *)memchr(s + start, ',', n - start);
+ size_t seg_end = comma ? (size_t)(comma - s) : n;
+ size_t a = start, b = seg_end;
+ while (b > a && isspace((unsigned char)s[b - 1]))
+ b--;
+ if (b > a)
+ fm_sl_push(out, fm_strndup(s + a, b - a));
+ if (!comma)
+ break;
+ start = seg_end + 1;
+ }
+}
+
+/* A frontmatter entry */
+typedef struct {
+ char *key;
+ fm_value val;
+} fm_entry;
+
+/* A markdown document with optional `---` frontmatter */
+typedef struct {
+ fm_entry *entries;
+ size_t count;
+ size_t cap;
+ char *body;
+} doc;
+
+static inline const fm_value *doc_get(const doc *d, const char *key) {
+ for (size_t i = 0; i < d->count; i++)
+ if (strcmp(d->entries[i].key, key) == 0)
+ return &d->entries[i].val;
+ return NULL;
+}
+
+static inline void doc_free(doc *d) {
+ for (size_t i = 0; i < d->count; i++) {
+ free(d->entries[i].key);
+ fm_value_free(&d->entries[i].val);
+ }
+ free(d->entries);
+ free(d->body);
+ memset(d, 0, sizeof *d);
+}
+
+static inline void fm_parse_value(const char *raw, fm_value *v) {
+ memset(v, 0, sizeof *v);
+ if (raw[0] == '[') {
+ fm_json *arr = fm_json_parse(raw);
+ if (arr && arr->type == FMJ_ARRAY) {
+ v->kind = FM_LIST;
+ v->items = (char **)fm_xmalloc(arr->count * sizeof(char *));
+ size_t i = 0;
+ for (size_t k = 0; k < arr->count; k++) {
+ const fm_json *it = arr->items[k];
+ if (it && it->type == FMJ_STRING)
+ v->items[i++] = fm_strdup(it->str);
+ else
+ v->items[i++] = it ? fm_json_serialize(it)
+ : fm_strdup("");
+ }
+ v->item_count = arr->count;
+ fm_json_free(arr);
+ return;
+ }
+ fm_json_free(arr);
+ v->kind = FM_STR;
+ v->s = fm_strdup(raw);
+ return;
+ }
+ if (strcmp(raw, "true") == 0) {
+ v->kind = FM_BOOL;
+ v->b = true;
+ return;
+ }
+ if (strcmp(raw, "false") == 0) {
+ v->kind = FM_BOOL;
+ v->b = false;
+ return;
+ }
+ size_t len = strlen(raw);
+ if (len >= 2 &&
+ ((raw[0] == '"' && raw[len - 1] == '"') ||
+ (raw[0] == '\'' && raw[len - 1] == '\''))) {
+ v->kind = FM_STR;
+ v->s = fm_strndup(raw + 1, len - 2);
+ return;
+ }
+ /* integer? */
+ char *end = NULL;
+ errno = 0;
+ long long n = strtoll(raw, &end, 10);
+ if (errno == 0 && end && *end == '\0' && raw[0] != '\0') {
+ v->kind = FM_INT;
+ v->i = n;
+ return;
+ }
+ v->kind = FM_STR;
+ v->s = fm_strdup(raw);
+}
+
+/* Split text into lines (no trailing empty). Returns line count. */
+static inline char **fm_split_lines(const char *text, size_t *out_count) {
+ size_t cap = 16, n = 0;
+ char **lines = (char **)fm_xmalloc(cap * sizeof(char *));
+ const char *p = text;
+ while (*p) {
+ const char *nl = (const char *)strchr(p, '\n');
+ size_t len = nl ? (size_t)(nl - p) : strlen(p);
+ if (n + 1 > cap) {
+ cap *= 2;
+ lines = (char **)fm_xrealloc(lines, cap * sizeof(char *));
+ }
+ lines[n++] = fm_strndup(p, len);
+ if (!nl)
+ break;
+ p = nl + 1;
+ }
+ *out_count = n;
+ return lines;
+}
+
+static inline void parse_frontmatter(const char *text, doc *out) {
+ memset(out, 0, sizeof *out);
+ size_t nlines = 0;
+ char **lines = fm_split_lines(text, &nlines);
+
+ char *body = fm_strdup(text); /* default: whole text */
+ if (nlines == 0) {
+ /* keep as-is */
+ } else if (strcmp(lines[0], "---") != 0) {
+ /* trim end */
+ size_t blen = strlen(body);
+ while (blen && (body[blen - 1] == '\n' || body[blen - 1] == '\r' ||
+ body[blen - 1] == ' '))
+ body[--blen] = '\0';
+ } else {
+ size_t close = 0;
+ bool found = false;
+ for (size_t i = 1; i < nlines; i++) {
+ if (strcmp(lines[i], "---") == 0) {
+ close = i - 1; /* index within lines[1..] */
+ found = true;
+ break;
+ }
+ }
+ if (!found) {
+ /* whole text is body, trimmed */
+ size_t blen = strlen(body);
+ while (blen && (body[blen - 1] == '\n' || body[blen - 1] == '\r' ||
+ body[blen - 1] == ' '))
+ body[--blen] = '\0';
+ } else {
+ for (size_t i = 1; i <= close; i++) {
+ const char *line = lines[i];
+ /* trim */
+ size_t start = 0, len = strlen(line);
+ while (start < len && isspace((unsigned char)line[start]))
+ start++;
+ while (len > start &&
+ isspace((unsigned char)line[len - 1]))
+ len--;
+ if (len == 0 || line[start] == '#')
+ continue;
+ const char *colon =
+ (const char *)memchr(line + start, ':', len - start);
+ if (!colon)
+ continue;
+ size_t klen = (size_t)(colon - (line + start));
+ if (klen == 0)
+ continue;
+ char *key = fm_strndup(line + start, klen);
+ const char *vraw = colon + 1;
+ /* trim value */
+ size_t vlen = strlen(vraw);
+ size_t vstart = 0;
+ while (vstart < vlen &&
+ isspace((unsigned char)vraw[vstart]))
+ vstart++;
+ while (vlen > vstart &&
+ isspace((unsigned char)vraw[vlen - 1]))
+ vlen--;
+ fm_value val;
+ char *vtmp = fm_strndup(vraw + vstart, vlen - vstart);
+ fm_parse_value(vtmp, &val);
+ free(vtmp);
+ if (out->count + 1 > out->cap) {
+ out->cap = out->cap ? out->cap * 2 : 8;
+ out->entries =
+ (fm_entry *)fm_xrealloc(out->entries,
+ out->cap *
+ sizeof(out->entries[0]));
+ }
+ out->entries[out->count].key = key;
+ out->entries[out->count].val = val;
+ out->count++;
+ }
+ /* body = lines[2+close..] joined by \n, trimmed end */
+ fm_buf b;
+ fm_buf_init(&b);
+ for (size_t i = 2 + close; i < nlines; i++) {
+ if (i > 2 + close)
+ fm_buf_push(&b, '\n');
+ fm_buf_put(&b, lines[i]);
+ }
+ char *nb = fm_buf_steal(&b);
+ size_t blen = strlen(nb);
+ while (blen && (nb[blen - 1] == '\n' || nb[blen - 1] == '\r' ||
+ nb[blen - 1] == ' '))
+ nb[--blen] = '\0';
+ free(body);
+ body = nb;
+ }
+ }
+ for (size_t i = 0; i < nlines; i++)
+ free(lines[i]);
+ free(lines);
+ out->body = body;
+}
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* FRONTMATTER_H */