Start the rebuild: note schema, vault kit and data migration

Phase 1 of the functional design plan.

- Move the old site into legacy/, unchanged, for reference. It still
  builds from there until cutover.
- Add packages/schema: the one definition of all 20 note types, with
  field titles and descriptions, note and vault checks, and JSON Schema
  export so later editing tools and the MCP server share it.
- Add vault-kit: Obsidian templates generated from the schema, and the
  Site guide for 24.01_PersonalWebsite.
- Add tools/migrate: turns the legacy JSON, collections, store data and
  page text into 545 Site folder notes and 84 images, and checks the
  result with the schema.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
This commit is contained in:
Claude
2026-10-11 18:40:55 +00:00
parent 4ded4b277b
commit a2f723ca2d
324 changed files with 11538 additions and 7556 deletions
+18
View File
@@ -0,0 +1,18 @@
{
"name": "@gk/schema",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "The one definition of every GregKilmer.com note type, shared by the migration, the content service, the MCP server, the site and any future editor.",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"json": "tsx scripts/emit-json-schema.ts",
"templates": "tsx scripts/emit-templates.ts"
},
"dependencies": {
"yaml": "^2.6.0",
"zod": "^4.1.0"
}
}
@@ -0,0 +1,24 @@
/**
* Writes one JSON Schema per note type to json/, with every field's title and
* description. The MCP server and future editors can read these without
* importing TypeScript.
*/
import { mkdirSync, writeFileSync } from "node:fs";
import { z } from "zod";
import { noteTypes } from "../src/types.ts";
const out = new URL("../json/", import.meta.url);
mkdirSync(out, { recursive: true });
for (const def of Object.values(noteTypes)) {
const schema = z.toJSONSchema(def.schema, { io: "input", unrepresentable: "any" });
const doc = {
...schema,
title: def.label,
description: def.description,
"x-folder": typeof def.folder === "string" ? def.folder : "depends on the note",
"x-body": def.body,
"x-singleton": def.singleton?.fileName ?? null,
};
writeFileSync(new URL(`${def.type}.schema.json`, out), `${JSON.stringify(doc, null, 2)}\n`);
}
console.log(`Wrote ${Object.keys(noteTypes).length} schemas to json/`);
@@ -0,0 +1,9 @@
/** Writes the Obsidian templates into vault-kit/. */
import { mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { allTemplates } from "../src/templates.ts";
const dir = new URL("../../../vault-kit/24.01_PersonalWebsite/Site/_templates/", import.meta.url);
mkdirSync(dir, { recursive: true });
for (const f of readdirSync(dir)) rmSync(new URL(f, dir));
for (const t of allTemplates()) writeFileSync(new URL(`${t.name}.md`, dir), t.text);
console.log(`Wrote ${allTemplates().length} templates`);
+97
View File
@@ -0,0 +1,97 @@
/**
* Field builders shared by every note type.
*
* Each field carries `title` and `description` metadata. The JSON Schema
* export keeps them, so the MCP server, the admin page and any later editor
* can render a form or explain a field without a second definition.
*/
import { z } from "zod";
import { normalizeWikilinkInput, wikilinkTarget } from "./names.ts";
type Meta = { title: string; description?: string };
/** A calendar date written `YYYY-MM-DD`, as Obsidian writes dates. */
export const isoDate = (meta: Meta) =>
z
.preprocess(
(v) => (v instanceof Date ? v.toISOString().slice(0, 10) : v),
z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Use a date like 2026-10-11"),
)
.meta(meta);
/** A month written `YYYY-MM`, for resume dates. */
export const isoMonth = (meta: Meta) =>
z.string().regex(/^\d{4}-\d{2}$/, "Use a month like 2019-03").meta(meta);
/** A link to another note, written `[[Note name]]`. */
export const wikilink = (meta: Meta) =>
z
.preprocess(
normalizeWikilinkInput,
z.string().refine((v) => wikilinkTarget(v) !== null, "Write a link as [[Note name]]"),
)
.meta(meta);
/** A list of links to other notes. */
export const wikilinks = (meta: Meta) =>
z
.preprocess(
(v) => (Array.isArray(v) ? v.map(normalizeWikilinkInput) : v),
z.array(z.string().refine((v) => wikilinkTarget(v) !== null, "Write each link as [[Note name]]")),
)
.meta(meta);
/** An image: a vault attachment as `[[file.jpg]]`, or a full https URL. */
export const image = (meta: Meta) =>
z
.preprocess(
normalizeWikilinkInput,
z
.string()
.refine(
(v) => wikilinkTarget(v) !== null || /^https:\/\//.test(v),
"Use a vault image as [[file.jpg]] or a full https URL",
),
)
.meta(meta);
/** One short line of text. */
export const line = (meta: Meta) => z.string().trim().min(1).meta(meta);
/** A list of short tags or names. */
export const strings = (meta: Meta) => z.array(z.string().trim().min(1)).meta(meta);
/**
* Fields every note shares. `type` is added per note type.
*
* `publish` defaults to false: a note goes live only when it says so.
*/
export const baseFields = {
title: line({ title: "Title" }),
slug: z
.string()
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Lowercase words joined by hyphens")
.meta({ title: "Slug", description: "The last part of the page address. Made from the title when left out." })
.optional(),
publish: z
.boolean()
.default(false)
.meta({ title: "Publish", description: "Only notes set to true appear on the site." }),
favorite: z.boolean().meta({ title: "Favorite", description: "Starred on the site." }).optional(),
tags: strings({ title: "Tags" }).optional(),
links: wikilinks({
title: "Links",
description: "Other notes this one is about. The site shows the link on both notes.",
}).optional(),
cover: image({ title: "Cover image" }).optional(),
created: isoDate({ title: "Created" }).optional(),
updated: isoDate({ title: "Updated" }).optional(),
updatedBy: z
.enum(["greg", "agent", "admin"])
.meta({ title: "Last edited by", description: "Set by the content service; you never need to write it." })
.optional(),
agentEdits: z
.boolean()
.meta({ title: "Agents may edit", description: "Let agents change this note without your review." })
.optional(),
};
+6
View File
@@ -0,0 +1,6 @@
export * from "./names.ts";
export * from "./fields.ts";
export * from "./types.ts";
export * from "./notes.ts";
export * from "./vault.ts";
export * from "./templates.ts";
+64
View File
@@ -0,0 +1,64 @@
/**
* Names, slugs and wikilinks.
*
* Every note is addressed three ways: its file name in the vault (what
* Obsidian wikilinks resolve), its slug (what the URL uses), and its type.
* These helpers are the only place those rules live.
*/
/** Characters Obsidian or common file systems refuse in a file name. */
const UNSAFE_FILE_CHARS = /[\\/:*?"<>|#^[\]]/g;
/** A file name (without `.md`) safe for Obsidian on every platform. */
export function safeFileName(title: string): string {
return title
.replace(UNSAFE_FILE_CHARS, " ")
.replace(/\s+/g, " ")
.trim()
.replace(/[. ]+$/, "");
}
/** URL slug from a title: lowercase ASCII words joined by hyphens. */
export function slugify(text: string): string {
return text
.normalize("NFKD")
.replace(/[̀-ͯ]/g, "")
.toLowerCase()
.replace(/&/g, " and ")
.replace(/['’]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
const WIKILINK = /^\[\[([^[\]|#]+)(?:#[^[\]|]*)?(?:\|[^[\]]*)?\]\]$/;
const WIKILINK_GLOBAL = /\[\[([^[\]|#]+)(?:#[^[\]|]*)?(?:\|[^[\]]*)?\]\]/g;
/** `Name` → `[[Name]]`. */
export function toWikilink(target: string): string {
return `[[${target}]]`;
}
/** `[[Name|alias]]` → `Name`, or null when the value is not a wikilink. */
export function wikilinkTarget(value: string): string | null {
const m = WIKILINK.exec(value.trim());
return m ? m[1]!.trim() : null;
}
/** Every wikilink target in a block of Markdown, in order, without repeats. */
export function wikilinksIn(markdown: string): string[] {
const seen = new Set<string>();
for (const m of markdown.matchAll(WIKILINK_GLOBAL)) seen.add(m[1]!.trim());
return [...seen];
}
/**
* An unquoted `[[Name]]` in YAML parses as a nested list (`[["Name"]]`).
* Obsidian quotes them, but hand edits may not, so accept both shapes.
*/
export function normalizeWikilinkInput(value: unknown): unknown {
if (Array.isArray(value) && value.length === 1 && Array.isArray(value[0]) && value[0].length === 1) {
const inner = value[0][0];
if (typeof inner === "string") return toWikilink(inner);
}
return value;
}
+112
View File
@@ -0,0 +1,112 @@
/**
* Reading, checking and writing one note.
*
* A note is Markdown with YAML frontmatter. These functions never touch the
* file system, so the migration, the content service and tests share them.
*/
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
import type { z } from "zod";
import { baseFields } from "./fields.ts";
import { safeFileName } from "./names.ts";
import { noteTypes, type NoteType, type NoteTypeDef } from "./types.ts";
export interface RawNote {
/** Parsed frontmatter, unchecked. */
frontmatter: Record<string, unknown>;
/** The Markdown under the frontmatter. */
body: string;
}
export interface NoteProblem {
/** Dotted path to the field, or "" for the whole note. */
field: string;
message: string;
}
export type CheckedNote =
| { ok: true; type: NoteType; data: Record<string, unknown>; body: string }
| { ok: false; type: NoteType | null; problems: NoteProblem[] };
const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
/** Split a note's text into frontmatter and body. Throws on broken YAML. */
export function splitNote(text: string): RawNote {
const m = FRONTMATTER.exec(text);
if (!m) return { frontmatter: {}, body: text };
const parsed = parseYaml(m[1]!) ?? {};
if (typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("Frontmatter must be a set of fields");
}
return { frontmatter: parsed as Record<string, unknown>, body: text.slice(m[0].length) };
}
export function typeDef(type: string): NoteTypeDef | undefined {
return (noteTypes as Record<string, NoteTypeDef>)[type];
}
/** Check a note's text against its type. Never throws. */
export function checkNote(text: string): CheckedNote {
let raw: RawNote;
try {
raw = splitNote(text);
} catch (e) {
return { ok: false, type: null, problems: [{ field: "", message: `Frontmatter is not valid YAML: ${(e as Error).message}` }] };
}
const type = raw.frontmatter.type;
if (typeof type !== "string") {
return { ok: false, type: null, problems: [{ field: "type", message: "Missing type" }] };
}
const def = typeDef(type);
if (!def) {
return { ok: false, type: null, problems: [{ field: "type", message: `Unknown type "${type}"` }] };
}
const result = (def.schema as z.ZodType).safeParse(raw.frontmatter);
if (!result.success) {
return {
ok: false,
type: type as NoteType,
problems: result.error.issues.map((i) => ({ field: i.path.join("."), message: i.message })),
};
}
return { ok: true, type: type as NoteType, data: result.data as Record<string, unknown>, body: raw.body };
}
/**
* Frontmatter key order: type, title, slug and publish, then the type's own
* fields in schema order, then the other shared fields, then anything else.
*/
function orderKeys(def: NoteTypeDef, data: Record<string, unknown>): Record<string, unknown> {
const shape = (def.schema as unknown as { shape: Record<string, unknown> }).shape;
const first = ["type", "title", "slug", "publish"];
const shared = Object.keys(baseFields).filter((k) => !first.includes(k));
const own = Object.keys(shape).filter((k) => !first.includes(k) && !shared.includes(k));
const keys = [...first, ...own, ...shared];
const out: Record<string, unknown> = {};
for (const k of keys) if (data[k] !== undefined) out[k] = data[k];
for (const k of Object.keys(data)) if (!(k in out) && data[k] !== undefined) out[k] = data[k];
return out;
}
/** Write a note's text. The data is checked first; this throws when it fails. */
export function writeNote(data: Record<string, unknown>, body = ""): string {
const def = typeDef(String(data.type));
if (!def) throw new Error(`Unknown type "${String(data.type)}"`);
const result = (def.schema as z.ZodType).safeParse(data);
if (!result.success) {
const problems = result.error.issues.map((i) => `${i.path.join(".") || "note"}: ${i.message}`).join("; ");
throw new Error(`"${String(data.title)}" is not a valid ${def.label}: ${problems}`);
}
// Write what was given (not schema defaults), so notes stay short.
const yaml = stringifyYaml(orderKeys(def, data), { lineWidth: 0 }).trimEnd();
const text = body.trim() ? `\n${body.trim()}\n` : "";
return `---\n${yaml}\n---\n${text}`;
}
/** Where a note of this type lives, relative to the Site folder, with `.md`. */
export function notePath(data: Record<string, unknown>): string {
const def = typeDef(String(data.type));
if (!def) throw new Error(`Unknown type "${String(data.type)}"`);
const folder = typeof def.folder === "function" ? def.folder(data) : def.folder;
const name = def.singleton?.fileName ?? (def.fileName ? def.fileName(data) : safeFileName(String(data.title)));
return `${folder ? `${folder}/` : ""}${name}.md`;
}
+60
View File
@@ -0,0 +1,60 @@
/**
* Obsidian templates, one per note type, made from the schema so they can
* never drift from what the content service accepts.
*/
import { z } from "zod";
import { noteTypes } from "./types.ts";
import type { NoteTypeDef } from "./types.ts";
type Prop = { type?: string; enum?: unknown[]; const?: unknown; description?: string; title?: string; default?: unknown; items?: Prop };
/** Shared fields that stay out of templates: the service or the title fills them. */
const SKIP = new Set(["slug", "updatedBy", "agentEdits", "updated", "cover", "links", "tags", "favorite"]);
function placeholder(key: string, p: Prop): string {
if (key === "created" || key === "date") return '"{{date:YYYY-MM-DD}}"';
if (p.const !== undefined) return JSON.stringify(p.const);
if (p.default !== undefined) return JSON.stringify(p.default);
if (p.type === "array") return "[]";
if (p.type === "boolean") return "false";
return "";
}
function hint(p: Prop): string {
const parts: string[] = [];
if (p.enum) parts.push(`one of: ${p.enum.join(", ")}`);
else if (p.items?.enum) parts.push(`any of: ${p.items.enum.join(", ")}`);
if (p.description) parts.push(p.description);
return parts.join(" ");
}
export function templateFor(def: NoteTypeDef): string {
const schema = z.toJSONSchema(def.schema, { io: "input", unrepresentable: "any" }) as {
properties: Record<string, Prop>;
required?: string[];
};
const required = new Set(schema.required ?? []);
const lines: string[] = [];
for (const [key, p] of Object.entries(schema.properties)) {
if (SKIP.has(key)) continue;
if (key === "title") {
lines.push(`title: "{{title}}"`);
continue;
}
// Optional object lists (specs, prices...) are easier to add when needed.
if (!required.has(key) && p.type === "array" && p.items?.type === "object") continue;
const value = placeholder(key, p);
const optional = !required.has(key) && key !== "publish" && key !== "type";
const h = [optional ? "optional" : "", hint(p)].filter(Boolean).join(". ");
lines.push(`${key}: ${value}`.trimEnd() + (h ? ` # ${h}` : ""));
}
const body = def.body ? `\n<!-- ${def.body} -->\n` : "";
return `---\n${lines.join("\n")}\n---\n${body}`;
}
/** File name (without `.md`) and text for every template. */
export function allTemplates(): { name: string; text: string }[] {
return Object.values(noteTypes)
.filter((d) => !d.singleton)
.map((d) => ({ name: d.label, text: templateFor(d as NoteTypeDef) }));
}
+508
View File
@@ -0,0 +1,508 @@
/**
* Every note type the site understands.
*
* A note type is one Markdown note shape: its frontmatter fields, the folder
* it lives in under the Site folder, and how its file is named. This file is
* the single definition; the migration, the content service, the MCP tools,
* the admin page and the templates all read it.
*/
import { z } from "zod";
import { baseFields, image, isoDate, isoMonth, line, strings, wikilink, wikilinks } from "./fields.ts";
import { safeFileName } from "./names.ts";
export interface NoteTypeDef<S extends z.ZodType = z.ZodType> {
/** The value of the note's `type` field. */
type: string;
/** What people call it. */
label: string;
/** One sentence for editors and agents. */
description: string;
/** Folder under the Site folder. A function when the folder depends on the note. */
folder: string | ((data: any) => string);
/** A single note at a fixed path, such as site settings. */
singleton?: { fileName: string };
/** File name (without `.md`) for a new note of this type. Defaults to the title. */
fileName?: (data: any) => string;
/** What the Markdown body under the frontmatter is for, or null when unused. */
body: string | null;
schema: S;
}
const note = <T extends string, F extends z.ZodRawShape>(type: T, fields: F) =>
z.looseObject({ type: z.literal(type), ...baseFields, ...fields });
const def = <S extends z.ZodType>(d: NoteTypeDef<S>) => d;
// ---------------------------------------------------------------- Blog
export const topics = ["Making", "Tabletop", "Library", "Work and tech", "Life"] as const;
export const post = def({
type: "post",
label: "Post",
description: "A blog post. Case studies are posts with postType: case study and show on the Work page too.",
folder: "Posts",
body: "The post itself.",
schema: note("post", {
date: isoDate({ title: "Publish date" }),
topic: z.enum(topics).meta({ title: "Topic" }),
postType: z
.enum(["article", "build log", "case study"])
.default("article")
.meta({ title: "Post type" }),
summary: line({ title: "Summary", description: "One or two sentences for lists and link previews." }),
pinned: z.boolean().meta({ title: "Featured", description: "Takes the top slot on the Blog." }).optional(),
// Case study fields
employer: line({ title: "Employer", description: "Case studies only. Leave out for personal projects." }).optional(),
role: line({ title: "Role", description: "Case studies only." }).optional(),
period: line({ title: "Period", description: "Case studies only, like 2021 to now." }).optional(),
tech: strings({ title: "Tech" }).optional(),
facts: z
.array(z.object({ label: line({ title: "Label" }), value: line({ title: "Value" }) }))
.meta({ title: "Key facts", description: "Case studies only, like Users: 1,000+." })
.optional(),
}).superRefine((v, ctx) => {
if (v.postType === "case study" && !v.role) {
ctx.addIssue({ code: "custom", path: ["role"], message: "A case study needs a role" });
}
}),
});
// ---------------------------------------------------------------- Library
export const media = [
"book",
"light novel",
"webnovel",
"manga",
"manhwa",
"video game",
"anime",
"TV",
"movie",
] as const;
const mediaFolder: Record<(typeof media)[number], string> = {
book: "Books",
"light novel": "Light novels",
webnovel: "Webnovels",
manga: "Manga",
manhwa: "Manhwa",
"video game": "Video games",
anime: "Anime",
TV: "TV",
movie: "Movies",
};
export const libraryStates = ["now", "up next", "played", "finished", "caught up", "dropped"] as const;
export const libraryItem = def({
type: "library-item",
label: "Library item",
description: "A book, series, show, film or video game, with its state and a short verdict.",
folder: (d: { medium: (typeof media)[number] }) => `Library/${mediaFolder[d.medium]}`,
body: "Optional longer thoughts.",
schema: note("library-item", {
medium: z.enum(media).meta({ title: "Medium" }),
state: z.enum(libraryStates).meta({ title: "State" }),
creator: line({ title: "Author or creator" }).optional(),
group: line({
title: "Group",
description: "Where it sits in the list, like Cosmere, Marvel Cinematic Universe or Prestige Drama.",
}).optional(),
year: z.number().int().meta({ title: "Year" }).optional(),
platform: line({ title: "Platform", description: "Video games only, like Steam or PlayStation 5." }).optional(),
verdict: line({ title: "Verdict", description: "A short review. No score." }).optional(),
note: line({ title: "Note", description: "Context, like where you are or how you came to it." }).optional(),
progress: line({ title: "Progress", description: "Like 16/18 volumes. Synced from PersonalReader when linked." }).optional(),
anilistId: z.number().int().meta({ title: "AniList id", description: "Links the item to PersonalReader's progress." }).optional(),
}),
});
// ---------------------------------------------------------------- The Shelf
export const boardGame = def({
type: "board-game",
label: "Board game",
description: "A board game on The Shelf. Players, time and art sync from BoardGameGeek.",
folder: "Shelf",
body: "Optional longer thoughts.",
schema: note("board-game", {
bggId: z.number().int().meta({ title: "BoardGameGeek id" }),
blurb: line({ title: "Blurb" }),
howItPlays: strings({ title: "How it plays", description: "Like engine building, social deduction, co-op." }).optional(),
owned: z.boolean().default(true).meta({ title: "Owned" }),
wantToPlay: z.boolean().meta({ title: "Want to play" }).optional(),
lastPlayed: isoDate({ title: "Last played" }).optional(),
}),
});
// ---------------------------------------------------------------- Workshop
export const craftStatuses = ["Practicing", "Learning", "Getting into", "Paused"] as const;
export const craft = def({
type: "craft",
label: "Craft",
description: "A craft in the Workshop, with its status, station and tools.",
folder: "Workshop/Crafts",
body: "What you make with it, for the craft page.",
schema: note("craft", {
status: z.enum(craftStatuses).meta({ title: "Status" }),
blurb: line({ title: "Blurb" }).optional(),
station: wikilink({ title: "Station" }).optional(),
tools: wikilinks({ title: "Tools and materials" }).optional(),
order: z.number().int().meta({ title: "Order" }).optional(),
}),
});
export const station = def({
type: "station",
label: "Station",
description: "A place in the workspace where tools live.",
folder: "Workshop/Stations",
body: null,
schema: note("station", {
where: line({ title: "Where", description: "Like the work counter and filament wall." }),
goodFor: line({ title: "Good for" }).optional(),
order: z.number().int().meta({ title: "Order" }),
}),
});
export const tool = def({
type: "tool",
label: "Tool or material",
description: "A tool, machine or material, placed at a station.",
folder: "Workshop/Tools",
body: null,
schema: note("tool", {
kind: z.enum(["printer", "cutter", "casting", "finishing", "material", "other"]).meta({ title: "Kind" }),
station: wikilink({ title: "Station" }).optional(),
blurb: line({ title: "Blurb" }).optional(),
}),
});
export const make = def({
type: "make",
label: "Make",
description: "Something finished in the Workshop.",
folder: "Workshop/Makes",
body: "Optional story of the make.",
schema: note("make", {
craft: wikilink({ title: "Craft" }),
date: isoDate({ title: "Finished" }).optional(),
photos: z.array(image({ title: "Photo" })).meta({ title: "Photos" }).optional(),
blurb: line({ title: "Blurb" }).optional(),
}),
});
// ---------------------------------------------------------------- Tabletop
export const tabletopSystem = def({
type: "tabletop-system",
label: "Tabletop system",
description: "A tabletop RPG system you run, have played or want to try.",
folder: "Tabletop/Systems",
body: null,
schema: note("tabletop-system", {
state: z.enum(["running", "played", "want to run", "want to play", "want to try"]).meta({ title: "State" }),
blurb: line({ title: "Blurb" }),
order: z.number().int().meta({ title: "Order" }).optional(),
}),
});
export const campaign = def({
type: "campaign",
label: "Campaign",
description: "A campaign or playtest at the table now or in the past.",
folder: "Tabletop/Campaigns",
body: "Optional campaign notes for the page.",
schema: note("campaign", {
system: wikilink({ title: "System", description: "Leave out for a playtest of an unreleased game." }).optional(),
status: z.enum(["active", "playtest", "paused", "finished"]).meta({ title: "Status" }),
schedule: line({ title: "Schedule", description: "Like Thursdays, every other week." }).optional(),
platform: line({ title: "Platform", description: "Like Foundry VTT." }).optional(),
blurb: line({ title: "Blurb" }).optional(),
}),
});
export const cardGame = def({
type: "card-game",
label: "Card game",
description: "A trading card game you play or follow.",
folder: "Tabletop/Card games",
body: null,
schema: note("card-game", {
stance: line({ title: "Where you are with it", description: "Like Main event, Collecting, Getting into." }),
blurb: line({ title: "Blurb" }).optional(),
order: z.number().int().meta({ title: "Order" }).optional(),
}),
});
export const deck = def({
type: "deck",
label: "Deck",
description: "A deck for any card game. Magic decks pull their details from Archidekt.",
folder: "Tabletop/Decks",
body: null,
schema: note("deck", {
game: wikilink({ title: "Card game" }),
format: line({ title: "Format", description: "Like Commander." }).optional(),
archidektId: z.number().int().meta({ title: "Archidekt deck id" }).optional(),
listUrl: z.url().meta({ title: "Deck list link", description: "For games not on Archidekt." }).optional(),
take: line({ title: "Your take", description: "One line on the deck." }).optional(),
}),
});
// ---------------------------------------------------------------- Resume
export const experience = def({
type: "experience",
label: "Experience",
description: "One role on the resume. The page and the PDF both come from these.",
folder: "Resume/Experience",
fileName: (d: { company: string; start: string }) => safeFileName(`${d.company} ${d.start.slice(0, 4)}`),
body: "Optional longer notes; the resume uses the bullets.",
schema: note("experience", {
company: line({ title: "Company" }),
role: line({ title: "Role" }),
start: isoMonth({ title: "Start" }),
end: isoMonth({ title: "End", description: "Leave out while it is your current role." }).optional(),
location: line({ title: "Location" }).optional(),
focus: line({ title: "Focus", description: "One line under the role." }).optional(),
bullets: strings({ title: "Bullets" }),
tech: strings({ title: "Tech" }).optional(),
}),
});
export const skills = def({
type: "skills",
label: "Skills",
description: "The resume's skills sidebar.",
folder: "Resume",
singleton: { fileName: "Skills" },
body: null,
schema: note("skills", {
core: strings({ title: "Core strengths" }),
learning: strings({ title: "Learning now" }).optional(),
stack: z
.array(z.object({ label: line({ title: "Group" }), items: strings({ title: "Items" }) }))
.meta({ title: "Technical stack" }),
}),
});
export const education = def({
type: "education",
label: "Education",
description: "Degrees and certifications on the resume.",
folder: "Resume",
singleton: { fileName: "Education" },
body: null,
schema: note("education", {
degrees: z
.array(
z.object({
credential: line({ title: "Credential" }),
institution: line({ title: "Institution" }),
period: line({ title: "Period" }),
detail: line({ title: "Detail" }).optional(),
}),
)
.meta({ title: "Degrees" }),
certifications: z
.array(
z.object({
name: line({ title: "Name" }),
issuer: line({ title: "Issuer" }),
year: line({ title: "Year" }),
status: z.enum(["Active", "Expired", "In progress"]).meta({ title: "Status" }).optional(),
}),
)
.meta({ title: "Certifications" })
.optional(),
}),
});
// ---------------------------------------------------------------- Now
export const status = def({
type: "status",
label: "Status",
description: "One line in the status log: what you are reading, playing, making or learning.",
folder: "Now",
fileName: (d: { date: string; kind: string; title: string }) => safeFileName(`${d.date} ${d.kind} ${d.title}`),
body: null,
schema: note("status", {
kind: line({ title: "Kind", description: "One of the kinds listed in Now/_kinds." }),
date: isoDate({ title: "Date" }),
subject: wikilink({ title: "About", description: "The note this status is about, if there is one." }).optional(),
note: line({ title: "Note" }).optional(),
pinned: z.boolean().meta({ title: "Pinned" }).optional(),
}),
});
export const statusKinds = def({
type: "status-kinds",
label: "Status kinds",
description: "The kinds a status can have. Adding a kind is one line here.",
folder: "Now",
singleton: { fileName: "_kinds" },
body: null,
schema: note("status-kinds", {
kinds: z
.array(
z.object({
label: line({ title: "Label" }),
icon: line({ title: "Icon" }),
page: line({ title: "Page", description: "The page this kind belongs to, like /library." }).optional(),
}),
)
.meta({ title: "Kinds" }),
}),
});
// ---------------------------------------------------------------- Store
export const productLine = def({
type: "product-line",
label: "Product line",
description: "One line in the Store, made to order or in stock.",
folder: "Store/Lines",
body: "Optional longer description for the product page.",
schema: note("product-line", {
category: z.enum(["Tabletop", "Paper", "Leather"]).meta({ title: "Category" }),
mode: z.enum(["made to order", "in stock"]).meta({ title: "Mode" }),
channel: z.enum(["here", "Etsy", "Patiun.com", "coming soon"]).meta({ title: "Where to buy" }),
channelUrl: z.url().meta({ title: "Shop link", description: "For Etsy or Patiun.com lines." }).optional(),
summary: line({ title: "Summary" }),
icon: line({ title: "Icon" }).optional(),
order: z.number().int().meta({ title: "Order" }).optional(),
slots: z
.object({ open: z.number().int().min(0), total: z.number().int().min(1) })
.meta({ title: "Order slots", description: "Made to order lines only." })
.optional(),
quantity: z.number().int().min(0).meta({ title: "In stock", description: "In stock lines only." }).optional(),
images: z
.array(z.object({ file: image({ title: "Image" }), alt: line({ title: "Alt text" }), label: line({ title: "Caption" }) }))
.meta({ title: "Images" })
.optional(),
specs: z.array(z.object({ label: line({ title: "Label" }), value: line({ title: "Value" }) })).meta({ title: "Specs" }).optional(),
priceTiers: z
.array(z.object({ band: line({ title: "Band" }), price: line({ title: "Price" }), note: line({ title: "Note" }).optional() }))
.meta({ title: "Prices" }),
options: z
.array(
z.object({
group: line({ title: "Option group", description: "Like Filament, Leather or Thread." }),
name: line({ title: "Name" }),
hex: z.string().regex(/^#[0-9a-fA-F]{6}$/).meta({ title: "Color" }).optional(),
inStock: z.boolean().meta({ title: "In stock" }),
}),
)
.meta({ title: "Options" })
.optional(),
terms: z
.array(
z.object({
kind: z.enum(["info", "warning", "rule"]).meta({ title: "Kind" }),
title: line({ title: "Title" }),
body: line({ title: "Text" }),
}),
)
.meta({ title: "Order terms" })
.optional(),
}).superRefine((v, ctx) => {
if (v.mode === "made to order" && v.channel === "here" && !v.slots) {
ctx.addIssue({ code: "custom", path: ["slots"], message: "A made to order line sold here needs slots" });
}
}),
});
export const storeSettings = def({
type: "store-settings",
label: "Store settings",
description: "Whether each category takes orders, and the terms for every order.",
folder: "Store",
singleton: { fileName: "_settings" },
body: null,
schema: note("store-settings", {
categories: z
.array(
z.object({
name: z.enum(["Tabletop", "Paper", "Leather"]).meta({ title: "Category" }),
ordersOpen: z.boolean().meta({ title: "Orders open" }),
reopens: isoDate({ title: "Reopens" }).optional(),
turnaround: line({ title: "Turnaround" }).optional(),
}),
)
.meta({ title: "Categories" }),
capacityNote: line({ title: "Capacity note" }).optional(),
terms: productLine.schema.shape.terms,
}),
});
// ---------------------------------------------------------------- Site
export const siteSettings = def({
type: "site-settings",
label: "Site settings",
description: "Name, tagline, availability and links used across the site.",
folder: "",
singleton: { fileName: "_site" },
body: null,
schema: note("site-settings", {
name: line({ title: "Name" }),
tagline: line({ title: "Tagline" }),
lockup: line({ title: "Lockup line" }),
location: line({ title: "Location" }).optional(),
availability: line({ title: "Availability", description: "Shown on the resume and Inquiries, like Open to freelance web work." }).optional(),
socials: z
.array(z.object({ label: line({ title: "Label" }), handle: line({ title: "Handle" }), url: z.url().meta({ title: "Link" }) }))
.meta({ title: "Social links" }),
footer: line({ title: "Footer line" }).optional(),
}),
});
export const page = def({
type: "page",
label: "Page text",
description: "The written introduction for one page of the site.",
folder: "Pages",
body: "The page's introduction.",
schema: note("page", {
page: line({ title: "Page", description: "The page address this text belongs to, like /about." }),
lede: line({ title: "Lede", description: "The one line under the page title." }).optional(),
quickFacts: z
.array(z.object({ icon: line({ title: "Icon" }), text: line({ title: "Text" }) }))
.meta({ title: "Quick facts" })
.optional(),
}),
});
/** Every note type, keyed by the `type` value. */
export const noteTypes = {
post,
"library-item": libraryItem,
"board-game": boardGame,
craft,
station,
tool,
make,
"tabletop-system": tabletopSystem,
campaign,
"card-game": cardGame,
deck,
experience,
skills,
education,
status,
"status-kinds": statusKinds,
"product-line": productLine,
"store-settings": storeSettings,
"site-settings": siteSettings,
page,
} as const;
export type NoteType = keyof typeof noteTypes;
export type NoteData<T extends NoteType> = z.output<(typeof noteTypes)[T]["schema"]>;
export const noteTypeNames = Object.keys(noteTypes) as NoteType[];
+75
View File
@@ -0,0 +1,75 @@
/**
* Checks that need every note at once: unique names, unique slugs and links
* that resolve. The content service runs these on each change; the migration
* runs them on its output.
*/
import { slugify, wikilinkTarget, wikilinksIn } from "./names.ts";
import { typeDef } from "./notes.ts";
export interface VaultNote {
/** Path under the Site folder, like `Library/Books/Cosmere.md`. */
path: string;
type: string;
data: Record<string, unknown>;
body: string;
}
export interface VaultProblem {
path: string;
message: string;
}
/** The name Obsidian resolves a wikilink against: the file name without `.md`. */
export const linkName = (path: string) => path.replace(/^.*\//, "").replace(/\.md$/, "");
/** Every wikilink a note makes, from frontmatter and body. */
export function outgoingLinks(note: VaultNote): string[] {
const found = new Set<string>();
const visit = (v: unknown) => {
if (typeof v === "string") {
const t = wikilinkTarget(v);
if (t) found.add(t);
} else if (Array.isArray(v)) v.forEach(visit);
else if (v && typeof v === "object") Object.values(v).forEach(visit);
};
visit(note.data);
for (const t of wikilinksIn(note.body)) found.add(t);
return [...found];
}
const IMAGE = /\.(jpe?g|png|gif|webp|svg|avif)$/i;
/**
* Vault-wide problems. `attachments` lists attachment file names so image
* links can be checked too.
*/
export function checkVault(notes: VaultNote[], attachments: string[] = []): VaultProblem[] {
const problems: VaultProblem[] = [];
const byName = new Map<string, VaultNote>();
for (const n of notes) {
const name = linkName(n.path).toLowerCase();
const other = byName.get(name);
if (other) problems.push({ path: n.path, message: `Same file name as ${other.path}, so links to it are ambiguous` });
else byName.set(name, n);
}
const slugs = new Map<string, string>();
for (const n of notes) {
const def = typeDef(n.type);
if (!def || def.singleton) continue;
const slug = String(n.data.slug ?? slugify(String(n.data.title)));
const key = `${n.type}:${slug}`;
const other = slugs.get(key);
if (other) problems.push({ path: n.path, message: `Slug "${slug}" is already used by ${other}` });
else slugs.set(key, n.path);
}
const files = new Set(attachments.map((a) => a.toLowerCase()));
for (const n of notes) {
for (const target of outgoingLinks(n)) {
const known = IMAGE.test(target) ? files.has(target.toLowerCase()) : byName.has(target.toLowerCase());
if (!known) problems.push({ path: n.path, message: `Links to [[${target}]], which does not exist` });
}
}
return problems;
}
+89
View File
@@ -0,0 +1,89 @@
import { readFileSync, readdirSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { allTemplates, checkNote, checkVault, notePath, slugify, wikilinkTarget, writeNote } from "../src/index.ts";
describe("names", () => {
it("slugs titles", () => {
expect(slugify("Pokémon TCG")).toBe("pokemon-tcg");
expect(slugify("The Queen's Gambit")).toBe("the-queens-gambit");
expect(slugify("Dungeons & Dragons")).toBe("dungeons-and-dragons");
});
it("reads wikilinks with aliases and headings", () => {
expect(wikilinkTarget("[[Galaxy dice|the galaxy set]]")).toBe("Galaxy dice");
expect(wikilinkTarget("[[Resin#Tools]]")).toBe("Resin");
expect(wikilinkTarget("Resin")).toBeNull();
});
});
describe("checkNote", () => {
const status = `---\ntype: status\ntitle: Throne of Glass\npublish: true\nkind: Reading\ndate: 2026-10-11\nsubject: [[Throne of Glass]]\n---\n`;
it("accepts an unquoted wikilink, as a hand edit might write it", () => {
const r = checkNote(status);
expect(r.ok).toBe(true);
if (r.ok) expect(r.data.subject).toBe("[[Throne of Glass]]");
});
it("names the field that is wrong", () => {
const r = checkNote(status.replace("date: 2026-10-11", "date: last tuesday"));
expect(r.ok).toBe(false);
if (!r.ok) expect(r.problems.map((p) => p.field)).toContain("date");
});
it("rejects unknown types and broken YAML without throwing", () => {
expect(checkNote("---\ntype: recipe\ntitle: x\n---\n").ok).toBe(false);
expect(checkNote("---\ntitle: [unclosed\n---\n").ok).toBe(false);
});
it("defaults publish to false", () => {
const r = checkNote("---\ntype: card-game\ntitle: Riftbound\nstance: Exploring\n---\n");
expect(r.ok && r.data.publish).toBe(false);
});
it("requires a role on case studies", () => {
const post = "---\ntype: post\ntitle: X\ndate: 2026-10-11\ntopic: Work and tech\npostType: case study\nsummary: Y\n---\n";
expect(checkNote(post).ok).toBe(false);
expect(checkNote(post.replace("summary: Y", "summary: Y\nrole: Lead")).ok).toBe(true);
});
});
describe("writeNote", () => {
it("round-trips through checkNote", () => {
const data = { type: "deck", title: "Kynaios and Tiro", publish: true, game: "[[Magic: The Gathering]]", format: "Commander" };
const r = checkNote(writeNote(data));
expect(r.ok && r.data).toMatchObject(data);
});
it("refuses invalid data", () => {
expect(() => writeNote({ type: "deck", title: "No game" })).toThrow(/game/);
});
it("places notes by type", () => {
expect(notePath({ type: "library-item", title: "Re:Zero", medium: "light novel" })).toBe("Library/Light novels/Re Zero.md");
expect(notePath({ type: "site-settings", title: "Site settings" })).toBe("_site.md");
});
});
describe("checkVault", () => {
const n = (path: string, type: string, data: Record<string, unknown>, body = "") => ({ path, type, data: { title: path, ...data }, body });
it("finds duplicate names, duplicate slugs and dangling links", () => {
const problems = checkVault([
n("Shelf/Coup.md", "board-game", { slug: "coup" }),
n("Library/Movies/Coup.md", "library-item", {}),
n("Shelf/Other.md", "board-game", { slug: "coup" }),
n("Posts/A.md", "post", { links: ["[[Missing]]"] }, "See [[Coup]] and ![[photo.jpg]]"),
], ["photo.jpg"]);
const messages = problems.map((p) => p.message).join("\n");
expect(messages).toMatch(/Same file name/);
expect(messages).toMatch(/Slug "coup"/);
expect(messages).toMatch(/\[\[Missing\]\]/);
expect(messages).not.toMatch(/photo\.jpg/);
});
});
describe("templates", () => {
it("match the committed vault kit", () => {
const dir = new URL("../../../vault-kit/24.01_PersonalWebsite/Site/_templates/", import.meta.url);
const committed = Object.fromEntries(readdirSync(dir).map((f) => [f.replace(/\.md$/, ""), readFileSync(new URL(f, dir), "utf8")]));
for (const t of allTemplates()) expect(committed[t.name], `Run npm run templates -w @gk/schema`).toBe(t.text);
expect(Object.keys(committed).length).toBe(allTemplates().length);
});
});
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "rootDir": "." },
"include": ["src", "scripts", "test"]
}