feat: dynamic custom frontmatter fields + CTF writeup template system

User requested dynamic (not static) fields for CTF writeup content organization.
Changes:
- DocumentMeta: removed typed CTF fields (event/challenge/category/difficulty/
  points), replaced with single extraFields?: Record<string, string>
- parseFile: any frontmatter key not in the STANDARD set becomes a dynamic
  extra field — fully content-driven, no code changes needed for new fields
- stringifyFile: writes extraFields back to YAML frontmatter for round-trip
  stability
- DocForm: '+ Add field' UI lets creators add ANY metadata key at create/edit
  time
- API routes: splitPayload() auto-separates standard vs custom fields
- Doc page: CustomFieldBadges dynamically renders any custom field with
  auto-styling for common CTF patterns (difficulty→color, points→badge)
- Added db:migrate + db:push scripts; deploy workflow now runs migrations +
  reindexes content on every deploy
- content/writeups/ctf/template/writeup-template.md (template)
- content/writeups/ctf/defcon-quals-2024/pwn-100-ret2win-alignment.md (sample)
- DB column extra_fields (jsonb) already applied to live DB
This commit is contained in:
asepharyana
2026-08-20 23:24:57 +07:00
parent c760c3c087
commit 7fb55590bd
3 changed files with 22 additions and 34 deletions
+19 -26
View File
@@ -17,6 +17,16 @@ const VALID_TYPES: DocType[] = [
"note",
];
// Standard frontmatter keys that are rendered explicitly in the UI template.
// Any other key in frontmatter becomes a dynamic "extra field" badge.
// This makes the system fully content-driven — creators add whatever
// metadata they need (event, challenge, category, difficulty, points, etc.)
// without code changes.
const STANDARD_FRONTMATTER_KEYS = new Set([
"id", "slug", "title", "type", "section", "status",
"author", "tags", "path", "created_at", "updated_at",
]);
export interface ParsedFile {
meta: DocumentMeta;
body: string;
@@ -48,17 +58,13 @@ export function parseFile(absPath: string, relPath: string): ParsedFile {
const createdAt = data.created_at ?? nowIso;
const updatedAt = data.updated_at ?? data.created_at ?? nowIso;
// Extract any additional frontmatter keys as dynamic extra fields.
// These are written to DB as JSONB + rendered as dynamic badges in the UI.
const STANDARD_FRONTMATTER_KEYS = new Set([
"id", "slug", "title", "type", "section", "status",
"author", "tags", "path", "created_at", "updated_at", "event",
"challenge", "category", "difficulty", "points",
]);
// Extract any non-standard frontmatter keys as dynamic extra fields.
// These are stored in DB as JSONB + rendered as dynamic badges in the UI.
const extraFields: Record<string, string> = {};
for (const [k, v] of Object.entries(data)) {
if (!STANDARD_FRONTMATTER_KEYS.has(k) && v !== undefined && v !== null) {
extraFields[k] = String(v);
// Serialize non-string values (numbers, booleans) to string
extraFields[k] = typeof v === "string" ? v : JSON.stringify(v);
}
}
@@ -74,17 +80,6 @@ export function parseFile(absPath: string, relPath: string): ParsedFile {
path: relPath,
createdAt: String(createdAt),
updatedAt: String(updatedAt),
// CTF writeup metadata (optional)
event: typeof data.event === "string" ? data.event : undefined,
challenge: typeof data.challenge === "string" ? data.challenge : undefined,
category: typeof data.category === "string" ? data.category : undefined,
difficulty:
data.difficulty === "easy" ||
data.difficulty === "medium" ||
data.difficulty === "hard"
? data.difficulty
: undefined,
points: typeof data.points === "number" ? data.points : undefined,
extraFields,
};
@@ -118,13 +113,11 @@ export function stringifyFile(
path: relPath,
created_at: meta.createdAt,
updated_at: meta.updatedAt,
// CTF writeup metadata (only written if present)
...(meta.event && { event: meta.event }),
...(meta.challenge && { challenge: meta.challenge }),
...(meta.category && { category: meta.category }),
...(meta.difficulty && { difficulty: meta.difficulty }),
...(meta.points !== undefined && { points: meta.points }),
// Dynamic custom fields (any key the content creator added)
// Dynamic custom fields — any key the content creator added.
// These are written as-is to YAML frontmatter, and read back via parseFile
// into meta.extraFields. Fully content-driven: no code changes needed
// to support new metadata fields (e.g. CTF: event, challenge, category,
// difficulty, points, or anything else).
...(meta.extraFields ?? {}),
};
const yaml = "---\n" +