feat(core): dynamic custom frontmatter fields for CTF writeup metadata
CI / typecheck + build (turbo) (push) Canceled after 0s
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:
1 parent
8906ed55a0
commit
c760c3c087
16 files changed
+580
-25
No files matched your search
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ?? {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user