feat(core): dynamic custom frontmatter fields for CTF writeup metadata
CI / typecheck + build (turbo) (push) Canceled after 0s

- Add extra_fields JSONB column to documents table (migration 0003)
- CreateDocInput/UpdateDocInput: extraFields?: Record<string, string>
- parseFile: extracts non-standard frontmatter keys as extraFields
- stringifyFile: writes extraFields dynamically to YAML frontmatter
- toMeta: spreads extraFields from DB row into DocumentMeta
- DocForm: '+ Add field' UI for content creators to add any metadata
- API routes: splitPayload() separates standard vs custom fields
- Doc page: CustomFieldBadges dynamically renders any custom field
- Add db:migrate / db:push scripts + deploy workflow migration step
- Add CTF writeup template (content/writeups/ctf/template/)
- Add sample DEF CON writeup with event/challenge/category/difficulty/points
- Add @tailwindcss/typography for table rendering (prose classes)
- Add remark-gfm for GitHub Flavored Markdown table parsing
This commit is contained in:
asepharyana committed 2026-08-20 23:18:57 +07:00
1 parent 8906ed55a0
commit c760c3c087
16 files changed
+580 -25

No files matched your search

+10
View File
@@ -128,6 +128,7 @@ export interface CreateDocInput {
status?: DocStatus;
author?: string;
tags?: string[];
extraFields?: Record<string, string>;
}
export interface UpdateDocInput {
@@ -137,6 +138,7 @@ export interface UpdateDocInput {
status?: DocStatus;
tags?: string[];
author?: string;
extraFields?: Record<string, string>;
}
/** Validate that a slug is safe (no path traversal, only [a-z0-9/_-]). */
@@ -186,6 +188,7 @@ export async function createDocument(input: CreateDocInput): Promise<DocumentMet
path: relPath,
createdAt: nowIso,
updatedAt: nowIso,
extraFields: input.extraFields ?? {},
};
// Write file to disk first (source of truth).
@@ -204,6 +207,7 @@ export async function createDocument(input: CreateDocInput): Promise<DocumentMet
tags: meta.tags,
path: meta.path,
body: input.body,
extraFields: input.extraFields ?? {},
createdAt: new Date(meta.createdAt),
updatedAt: new Date(meta.updatedAt),
});
@@ -241,6 +245,11 @@ export async function updateDocument(
tags: input.tags ?? doc.tags,
author: input.author ?? doc.author,
updatedAt,
// Merge: new extraFields override old ones; merge with existing
extraFields:
input.extraFields !== undefined
? { ...doc.extraFields, ...input.extraFields }
: doc.extraFields,
};
const body = input.body ?? doc.body;
@@ -259,6 +268,7 @@ export async function updateDocument(
status: updated.status,
author: updated.author,
tags: updated.tags,
extraFields: input.extraFields ?? doc.extraFields ?? {},
body,
updatedAt: new Date(updatedAt),
})
+2
View File
@@ -48,6 +48,7 @@ export async function indexContentFile(
tags: meta.tags,
path: meta.path,
body,
extraFields: meta.extraFields ?? {},
createdAt: new Date(meta.createdAt || nowIso),
updatedAt: new Date(nowIso),
})
@@ -62,6 +63,7 @@ export async function indexContentFile(
tags: meta.tags,
path: meta.path,
body,
extraFields: meta.extraFields ?? {},
updatedAt: new Date(nowIso),
},
});
@@ -0,0 +1,3 @@
-- Add extra_fields JSONB column to documents table for dynamic metadata
-- (CTF writeup fields: event, challenge, category, difficulty, points, etc.)
ALTER TABLE "documents" ADD COLUMN "extra_fields" jsonb DEFAULT '{}'::jsonb NOT NULL;
+6
View File
@@ -15,5 +15,11 @@
"devDependencies": {
"drizzle-kit": "^0.30.0",
"@types/node": "^20"
},
"scripts": {
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio"
}
}
+4
View File
@@ -43,6 +43,10 @@ export const documents = pgTable(
),
createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(),
// Dynamic custom metadata fields (JSONB) — e.g. CTF writeup fields
// (event, challenge, category, difficulty, points). Content creators
// add arbitrary key-value pairs that are rendered as dynamic badges.
extraFields: jsonb("extra_fields").notNull().default(sql`'{}'::jsonb`),
},
(t) => ({
searchIdx: index("documents_search_idx").using("gin", t.searchVector),
+34
View File
@@ -48,6 +48,20 @@ 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",
]);
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);
}
}
const meta: DocumentMeta = {
id: slug,
slug,
@@ -60,6 +74,18 @@ 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,
};
return { meta, body: content };
@@ -92,6 +118,14 @@ 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)
...(meta.extraFields ?? {}),
};
const yaml = "---\n" +
Object.entries(data)
+3
View File
@@ -32,6 +32,7 @@ const VALID_TYPES: DocType[] = ["documentation", "writeup", "research", "note"];
/** Map a Drizzle row (text columns, Date timestamps) into the strict types. */
function toMeta(row: DocumentRow): DocumentMeta {
const extra = row.extraFields as Record<string, unknown> | null;
return {
id: row.id,
slug: row.slug,
@@ -46,6 +47,8 @@ function toMeta(row: DocumentRow): DocumentMeta {
path: row.path,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
// Spread dynamic extra fields (CTF: event, challenge, category, difficulty, points, etc.)
...(extra ?? {}),
};
}
+9
View File
@@ -14,6 +14,15 @@ export interface DocumentMeta {
path: string; // relative path under content/
createdAt: string; // ISO
updatedAt: string; // ISO
// CTF writeup metadata (optional — only set for writeups)
event?: string;
challenge?: string;
category?: string;
difficulty?: "easy" | "medium" | "hard";
points?: number;
// Dynamic custom frontmatter fields (serialized as-is to YAML frontmatter).
// Allows content creators to add arbitrary metadata keys without code changes.
extraFields?: Record<string, string>;
}
export interface Document extends DocumentMeta {