Files
com.gregkilmer.www/packages/schema/src/fields.ts
T
Claude 0f78505e99 Edit any note and its images from the admin page
Every note gets an Edit form built from its note type's schema: image
fields show a thumbnail with a pick from the vault or an upload, link
fields offer note names, and lists of rows can be added to, removed and
reordered. A new Site pages view lists each page of the site with its
text and images and the notes that fill it. Image and link fields now
carry a JSON Schema format so a form knows to offer a picker.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-18 14:19:38 +00:00

100 lines
3.6 KiB
TypeScript

/**
* 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. Image
* and link fields also carry a `format` (`vault-image`, `vault-link`,
* `vault-links`) so a form knows to offer a picker.
*/
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({ format: "vault-link", ...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({ format: "vault-links", ...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({ format: "vault-image", ...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", "sync"])
.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(),
};