@llm-cms/core 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/preview.ts ADDED
@@ -0,0 +1,127 @@
1
+ // packages/core/src/preview.ts
2
+ import type { LlmcmsDoc } from "./doc";
3
+
4
+ export type PreviewDocRef = {
5
+ locale: string;
6
+ type: string;
7
+ slug: string;
8
+ /** Editor checkout branch. Omit to read the workspace default branch. */
9
+ branch?: string;
10
+ /**
11
+ * Working tree owner (the editing user's id). With it the preview shows that
12
+ * user's uncommitted copy and streams their saves; without it, branch HEAD.
13
+ */
14
+ tree?: string;
15
+ };
16
+
17
+ export type PreviewChangeEvent = PreviewDocRef & {
18
+ updatedAt: string;
19
+ frontmatter?: Record<string, unknown>;
20
+ body?: string;
21
+ raw?: string;
22
+ };
23
+
24
+ export type PreviewClientOptions = {
25
+ apiUrl: string;
26
+ workspaceId: string;
27
+ previewToken: string;
28
+ };
29
+
30
+ export {
31
+ installPreviewHotkeys,
32
+ parsePreviewHotkey,
33
+ previewHotkeyFromKeyboardEvent,
34
+ } from "./preview-hotkeys";
35
+ export type { PreviewHotkeyMessage } from "./preview-hotkeys";
36
+
37
+ function baseUrl(apiUrl: string) {
38
+ return apiUrl.replace(/\/$/, "");
39
+ }
40
+
41
+ /** First non-empty value from a Next.js `searchParams` field. */
42
+ function queryValue(value: string | string[] | undefined): string | undefined {
43
+ const raw = Array.isArray(value) ? value[0] : value;
44
+ const trimmed = raw?.trim();
45
+ return trimmed ? trimmed : undefined;
46
+ }
47
+
48
+ /**
49
+ * Read `branch` and `tree` off the host preview URL so the snapshot and SSE
50
+ * match the editor checkout. Empty values mean branch HEAD (no live stream).
51
+ */
52
+ export function previewCheckoutFromQuery(query: {
53
+ branch?: string | string[];
54
+ tree?: string | string[];
55
+ }): Pick<PreviewDocRef, "branch" | "tree"> {
56
+ const branch = queryValue(query.branch);
57
+ const tree = queryValue(query.tree);
58
+ return {
59
+ ...(branch ? { branch } : {}),
60
+ ...(tree ? { tree } : {}),
61
+ };
62
+ }
63
+
64
+ function previewParams(options: PreviewClientOptions, ref: PreviewDocRef) {
65
+ const params = new URLSearchParams({ token: options.previewToken });
66
+ if (ref.branch) params.set("branch", ref.branch);
67
+ if (ref.tree) params.set("tree", ref.tree);
68
+ return params;
69
+ }
70
+
71
+ function docPath(options: PreviewClientOptions, ref: PreviewDocRef) {
72
+ return `${baseUrl(options.apiUrl)}/preview/workspaces/${options.workspaceId}/docs/${ref.locale}/${ref.type}/${ref.slug}`;
73
+ }
74
+
75
+ export function previewDocRequestUrl(
76
+ options: PreviewClientOptions,
77
+ ref: PreviewDocRef,
78
+ ): string {
79
+ return `${docPath(options, ref)}?${previewParams(options, ref)}`;
80
+ }
81
+
82
+ export function previewStreamUrl(
83
+ options: PreviewClientOptions,
84
+ ref: PreviewDocRef,
85
+ ): string {
86
+ return `${docPath(options, ref)}/stream?${previewParams(options, ref)}`;
87
+ }
88
+
89
+ export async function fetchPreviewDoc(
90
+ options: PreviewClientOptions,
91
+ ref: PreviewDocRef,
92
+ ): Promise<LlmcmsDoc | null> {
93
+ const url = previewDocRequestUrl(options, ref);
94
+ const res = await fetch(url, { cache: "no-store" });
95
+ if (res.status === 404) return null;
96
+ if (!res.ok) throw new Error(`preview getDoc failed: ${res.status}`);
97
+ const data = (await res.json()) as { doc: LlmcmsDoc };
98
+ return data.doc;
99
+ }
100
+
101
+ /** Browser EventSource subscribe to one working tree's saves; returns unsubscribe. */
102
+ export function subscribePreview(
103
+ options: PreviewClientOptions,
104
+ ref: PreviewDocRef,
105
+ onEvent: (event: PreviewChangeEvent) => void,
106
+ ): () => void {
107
+ if (typeof EventSource === "undefined") {
108
+ throw new Error("subscribePreview requires a browser EventSource");
109
+ }
110
+ const source = new EventSource(previewStreamUrl(options, ref));
111
+
112
+ const onChange = (ev: MessageEvent) => {
113
+ try {
114
+ const data = JSON.parse(String(ev.data)) as PreviewChangeEvent;
115
+ onEvent(data);
116
+ } catch {
117
+ // ignore malformed
118
+ }
119
+ };
120
+
121
+ source.addEventListener("change", onChange as EventListener);
122
+
123
+ return () => {
124
+ source.removeEventListener("change", onChange as EventListener);
125
+ source.close();
126
+ };
127
+ }
package/src/query.ts ADDED
@@ -0,0 +1,152 @@
1
+ // packages/core/src/query.ts
2
+ import type { LlmcmsDoc, LlmcmsDocSummary } from "./doc";
3
+ import type { FieldKind } from "./fields";
4
+ import type { InferFrontmatter, ModelDef } from "./model";
5
+ import { normalizeFrontmatter } from "./zod";
6
+
7
+ /** Loader the query layer sits on: the FS or API loaders behind createLlmcms. */
8
+ export type DocLoader = {
9
+ listDocs(filter?: { type?: string; locale?: string }): Promise<LlmcmsDocSummary[]>;
10
+ getDoc(locale: string, type: string, slug: string): Promise<LlmcmsDoc | null>;
11
+ };
12
+
13
+ export type TypedDoc<M extends ModelDef> = Omit<LlmcmsDoc, "type" | "frontmatter"> & {
14
+ type: M["type"];
15
+ frontmatter: InferFrontmatter<M>;
16
+ };
17
+
18
+ type KeysOfKind<M extends ModelDef, K extends FieldKind> = {
19
+ [N in keyof M["fields"]]: M["fields"][N]["kind"] extends K ? N : never;
20
+ }[keyof M["fields"]];
21
+
22
+ type ScalarKind = "text" | "slug" | "locale" | "number" | "boolean";
23
+
24
+ /** Equality filters on slug and scalar frontmatter fields. */
25
+ export type Where<M extends ModelDef> = Partial<
26
+ { slug: string } & Pick<
27
+ InferFrontmatter<M>,
28
+ Extract<KeysOfKind<M, ScalarKind>, keyof InferFrontmatter<M>>
29
+ >
30
+ >;
31
+
32
+ /** Relation fields that may be resolved (depth 1, same locale). */
33
+ export type Include<M extends ModelDef> = Partial<Record<KeysOfKind<M, "relation">, true>>;
34
+
35
+ type RelationTarget<
36
+ M extends ModelDef,
37
+ N extends keyof M["fields"],
38
+ Models extends readonly ModelDef[],
39
+ > = M["fields"][N] extends { relationTo?: infer To }
40
+ ? Extract<Models[number], { type: Exclude<To, undefined> }>
41
+ : never;
42
+
43
+ export type Resolved<
44
+ M extends ModelDef,
45
+ Models extends readonly ModelDef[],
46
+ I extends Include<M>,
47
+ > = {
48
+ [N in keyof I as I[N] extends true ? N : never]: N extends keyof M["fields"]
49
+ ? TypedDoc<RelationTarget<M, N, Models>> | null
50
+ : never;
51
+ };
52
+
53
+ export type ModelQuery<M extends ModelDef, Models extends readonly ModelDef[]> = {
54
+ get(locale: string, slug: string): Promise<TypedDoc<M> | null>;
55
+ findMany<I extends Include<M> = {}>(opts?: {
56
+ locale?: string;
57
+ where?: Where<M>;
58
+ include?: I;
59
+ }): Promise<Array<TypedDoc<M> & Resolved<M, Models, I>>>;
60
+ };
61
+
62
+ export type Query<Models extends readonly ModelDef[]> = {
63
+ [M in Models[number] as M["type"]]: ModelQuery<M, Models>;
64
+ };
65
+
66
+ type RawOpts = {
67
+ locale?: string;
68
+ where?: Record<string, unknown>;
69
+ include?: Record<string, boolean | undefined>;
70
+ };
71
+
72
+ export function createQuery<const Models extends readonly ModelDef[]>(
73
+ models: Models,
74
+ loader: DocLoader,
75
+ ): Query<Models> {
76
+ const byType = new Map<string, ModelDef>(models.map((m) => [m.type, m]));
77
+
78
+ async function get(type: string, locale: string, slug: string): Promise<LlmcmsDoc | null> {
79
+ const doc = await loader.getDoc(locale, type, slug);
80
+ if (!doc) return null;
81
+ return { ...doc, frontmatter: normalizeFrontmatter(doc.frontmatter) };
82
+ }
83
+
84
+ function matchesWhere(doc: LlmcmsDoc, where: Record<string, unknown>): boolean {
85
+ for (const [key, expected] of Object.entries(where)) {
86
+ if (expected === undefined) continue;
87
+ const actual = key === "slug" ? doc.slug : doc.frontmatter[key];
88
+ if (actual !== expected) return false;
89
+ }
90
+ return true;
91
+ }
92
+
93
+ async function findMany(type: string, opts?: RawOpts): Promise<LlmcmsDoc[]> {
94
+ const model = byType.get(type);
95
+ const whereSlug = opts?.where?.slug;
96
+
97
+ let docs: LlmcmsDoc[];
98
+ // Truthy gate: the loaders' locale filter is truthiness-based, so a falsy
99
+ // locale ("") must fall back to the list path like an omitted one.
100
+ if (typeof whereSlug === "string" && opts?.locale) {
101
+ // Slug fast-path: identity lookup instead of list + load fan-out.
102
+ const doc = await get(type, opts.locale, whereSlug);
103
+ docs = doc ? [doc] : [];
104
+ } else {
105
+ const summaries = await loader.listDocs({ type, locale: opts?.locale });
106
+ const loaded = await Promise.all(summaries.map((s) => get(type, s.locale, s.slug)));
107
+ docs = loaded.filter((d): d is LlmcmsDoc => d !== null);
108
+ }
109
+
110
+ const filtered = opts?.where ? docs.filter((d) => matchesWhere(d, opts.where!)) : docs;
111
+ const include = opts?.include;
112
+ if (!include || !model) return filtered;
113
+
114
+ // Memoize relation loads per call: N posts sharing one author load it once.
115
+ const relationCache = new Map<string, Promise<LlmcmsDoc | null>>();
116
+ const getCached = (target: string, locale: string, slug: string) => {
117
+ const key = `${target}:${locale}:${slug}`;
118
+ let promise = relationCache.get(key);
119
+ if (!promise) {
120
+ promise = get(target, locale, slug);
121
+ relationCache.set(key, promise);
122
+ }
123
+ return promise;
124
+ };
125
+
126
+ return Promise.all(
127
+ filtered.map(async (doc) => {
128
+ const extra: Record<string, LlmcmsDoc | null> = {};
129
+ for (const [name, on] of Object.entries(include)) {
130
+ if (!on) continue;
131
+ const field = model.fields[name];
132
+ const target = field?.kind === "relation" ? field.relationTo : undefined;
133
+ const slug = doc.frontmatter[name];
134
+ extra[name] =
135
+ target && typeof slug === "string" && slug.length > 0
136
+ ? await getCached(target, doc.locale, slug)
137
+ : null;
138
+ }
139
+ return { ...doc, ...extra };
140
+ }),
141
+ );
142
+ }
143
+
144
+ const query: Record<string, unknown> = {};
145
+ for (const m of models) {
146
+ query[m.type] = {
147
+ get: (locale: string, slug: string) => get(m.type, locale, slug),
148
+ findMany: (opts?: RawOpts) => findMany(m.type, opts),
149
+ };
150
+ }
151
+ return query as Query<Models>;
152
+ }
package/src/routes.ts ADDED
@@ -0,0 +1,102 @@
1
+ // packages/core/src/routes.ts
2
+ // Pure URL ↔ document mapping for the host catch-all route. No framework code.
3
+ import type { ModelSnapshot } from "./model";
4
+
5
+ export const DEFAULT_ROUTE = "/{locale}/{type}/{slug}";
6
+
7
+ export type RouteModel = Pick<ModelSnapshot, "type" | "route">;
8
+
9
+ export type RouteMatch = {
10
+ type: string;
11
+ locale: string;
12
+ slug: string;
13
+ };
14
+
15
+ export type MatchRouteOptions = {
16
+ /** Locale for routes without a `{locale}` placeholder. */
17
+ defaultLocale: string;
18
+ /** When set, `{locale}` only matches one of these values. */
19
+ locales?: readonly string[];
20
+ };
21
+
22
+ /** The model's route template with `{type}` already substituted. */
23
+ export function routeTemplate(model: RouteModel): string {
24
+ return (model.route ?? DEFAULT_ROUTE).replaceAll("{type}", model.type);
25
+ }
26
+
27
+ /** Build the public URL path for a document, e.g. "/en/blog/hello". */
28
+ export function routeFor(model: RouteModel, locale: string, slug: string): string {
29
+ return routeTemplate(model)
30
+ .replaceAll("{locale}", encodeURIComponent(locale))
31
+ .replaceAll("{slug}", slug.split("/").map(encodeURIComponent).join("/"));
32
+ }
33
+
34
+ /** URL segments for Next `generateStaticParams` (["en", "blog", "hello"]). */
35
+ export function routeSegments(model: RouteModel, locale: string, slug: string): string[] {
36
+ return routeFor(model, locale, slug)
37
+ .split("/")
38
+ .filter(Boolean)
39
+ .map(decodeURIComponent);
40
+ }
41
+
42
+ function escapeRegex(s: string): string {
43
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
44
+ }
45
+
46
+ export function compileRoutePattern(template: string): RegExp {
47
+ const parts = template.split(/(\{locale\}|\{slug\})/g);
48
+ let pattern = "^";
49
+ for (const part of parts) {
50
+ if (part === "{locale}") pattern += "(?<locale>[^/]+)";
51
+ else if (part === "{slug}") pattern += "(?<slug>[^/]+)";
52
+ else pattern += escapeRegex(part);
53
+ }
54
+ return new RegExp(`${pattern}/?$`);
55
+ }
56
+
57
+ function specificity(template: string): [staticSegments: number, placeholders: number] {
58
+ const segments = template.split("/").filter(Boolean);
59
+ const placeholders = segments.filter((s) => s.includes("{")).length;
60
+ return [segments.length - placeholders, placeholders];
61
+ }
62
+
63
+ /** Most static segments first, then fewer placeholders, then declaration order. */
64
+ export function sortByRouteSpecificity<M extends RouteModel>(models: readonly M[]): M[] {
65
+ return models
66
+ .map((model, index) => ({ model, index, spec: specificity(routeTemplate(model)) }))
67
+ .sort((a, b) => {
68
+ if (a.spec[0] !== b.spec[0]) return b.spec[0] - a.spec[0];
69
+ if (a.spec[1] !== b.spec[1]) return a.spec[1] - b.spec[1];
70
+ return a.index - b.index;
71
+ })
72
+ .map((entry) => entry.model);
73
+ }
74
+
75
+ export function normalizePathname(pathname: string): string {
76
+ let out = pathname.startsWith("/") ? pathname : `/${pathname}`;
77
+ if (out.length > 1) out = out.replace(/\/+$/, "");
78
+ return out;
79
+ }
80
+
81
+ /** Resolve a URL path to a document identity, or null when no model claims it. */
82
+ export function matchRoute(
83
+ models: readonly RouteModel[],
84
+ pathname: string,
85
+ options: MatchRouteOptions,
86
+ ): RouteMatch | null {
87
+ const target = normalizePathname(pathname);
88
+ for (const model of sortByRouteSpecificity(models)) {
89
+ const template = routeTemplate(model);
90
+ const m = compileRoutePattern(template).exec(target);
91
+ if (!m?.groups?.slug) continue;
92
+ let locale = options.defaultLocale;
93
+ if (template.includes("{locale}")) {
94
+ const raw = m.groups.locale;
95
+ if (!raw) continue;
96
+ locale = decodeURIComponent(raw);
97
+ if (options.locales && !options.locales.includes(locale)) continue;
98
+ }
99
+ return { type: model.type, locale, slug: decodeURIComponent(m.groups.slug) };
100
+ }
101
+ return null;
102
+ }
@@ -0,0 +1,50 @@
1
+ // packages/core/src/sync-schema.ts
2
+ import {
3
+ serializeSchema,
4
+ type ModelDef,
5
+ type ModelSnapshot,
6
+ } from "./model";
7
+
8
+ export function detectGitBranch(
9
+ env: Record<string, string | undefined> = process.env,
10
+ ): string | null {
11
+ const override = env.LLMCMS_GIT_BRANCH?.trim();
12
+ if (override) return override;
13
+ const vercel = env.VERCEL_GIT_COMMIT_REF?.trim();
14
+ if (vercel) return vercel;
15
+ return null;
16
+ }
17
+
18
+ export type SyncSchemaOptions = {
19
+ apiUrl: string;
20
+ workspaceId: string;
21
+ hostToken: string;
22
+ models: Array<ModelDef | ModelSnapshot>;
23
+ branch?: string;
24
+ siteUrl?: string;
25
+ };
26
+
27
+ export async function syncSchema(options: SyncSchemaOptions): Promise<void> {
28
+ const branch = options.branch ?? detectGitBranch() ?? "main";
29
+ const payload = serializeSchema(options.models as ModelDef[]);
30
+ const body: { payload: ReturnType<typeof serializeSchema>; branch: string; siteUrl?: string } = {
31
+ payload,
32
+ branch,
33
+ };
34
+ if (options.siteUrl) body.siteUrl = options.siteUrl;
35
+ const res = await fetch(
36
+ `${options.apiUrl.replace(/\/$/, "")}/workspaces/${options.workspaceId}/schema`,
37
+ {
38
+ method: "PUT",
39
+ headers: {
40
+ Authorization: `Bearer ${options.hostToken}`,
41
+ "Content-Type": "application/json",
42
+ },
43
+ body: JSON.stringify(body),
44
+ },
45
+ );
46
+ if (!res.ok) {
47
+ const text = await res.text().catch(() => "");
48
+ throw new Error(`schema sync failed: ${res.status} ${text}`);
49
+ }
50
+ }
@@ -0,0 +1,83 @@
1
+ // packages/core/src/validate.ts
2
+ import type { Frontmatter } from "./mdx";
3
+ import { parseMdx } from "./mdx";
4
+ import type { ModelSnapshot } from "./model";
5
+ import { matchAnyModelPath } from "./path";
6
+ import { expectedTypeFor, frontmatterZod, normalizeFrontmatter } from "./zod";
7
+
8
+ export type ValidationResult =
9
+ | { ok: true }
10
+ | { ok: false; errors: string[] };
11
+
12
+ /**
13
+ * Frontmatter must satisfy the model: required fields present and non-blank,
14
+ * every declared field of the right primitive type. Unknown keys are ignored.
15
+ */
16
+ export function validateFrontmatter(
17
+ model: Pick<ModelSnapshot, "fields">,
18
+ frontmatter: Frontmatter,
19
+ ): ValidationResult {
20
+ const data = normalizeFrontmatter(frontmatter);
21
+ const result = frontmatterZod(model).safeParse(data);
22
+ if (result.success) return { ok: true };
23
+
24
+ const errors: string[] = [];
25
+ const seen = new Set<string>();
26
+ for (const issue of result.error.issues) {
27
+ const name = String(issue.path[0] ?? "");
28
+ const field = model.fields[name];
29
+ if (!field || seen.has(name)) continue;
30
+ seen.add(name);
31
+ const missing = data[name] === undefined;
32
+ if (issue.code === "too_small" || (issue.code === "invalid_type" && missing)) {
33
+ errors.push(`${name} is required`);
34
+ } else {
35
+ errors.push(`${name} must be a ${expectedTypeFor(field.kind)}`);
36
+ }
37
+ }
38
+ // Never fail open: if every issue was filtered above, still report invalid.
39
+ // Unreachable through the public API today (normalizeFrontmatter always yields
40
+ // a plain object and looseObject only raises issues on declared fields), so
41
+ // there is no test exercising this branch.
42
+ if (errors.length === 0) return { ok: false, errors: ["frontmatter is invalid"] };
43
+ return { ok: false, errors };
44
+ }
45
+
46
+ /**
47
+ * Parse a raw MDX string and validate its frontmatter. Never throws on
48
+ * malformed frontmatter (an unknown field kind in `model` still throws).
49
+ */
50
+ export function validateMdxFile(
51
+ model: Pick<ModelSnapshot, "fields">,
52
+ raw: string,
53
+ ): ValidationResult {
54
+ let frontmatter: Frontmatter;
55
+ try {
56
+ frontmatter = parseMdx(raw).frontmatter;
57
+ } catch (err) {
58
+ const msg = err instanceof Error ? err.message : "invalid";
59
+ return { ok: false, errors: [`frontmatter: ${msg}`] };
60
+ }
61
+ return validateFrontmatter(model, frontmatter);
62
+ }
63
+
64
+ export type ContentFileResult =
65
+ | { path: string; type: string; ok: true }
66
+ | { path: string; type: string; ok: false; errors: string[] }
67
+ | { path: string; type: null; ok: true; skipped: true };
68
+
69
+ /** Validate many repo-relative files against a model set. Order is preserved. */
70
+ export function validateContentTree(input: {
71
+ models: ModelSnapshot[];
72
+ files: Array<{ path: string; raw: string }>;
73
+ }): ContentFileResult[] {
74
+ return input.files.map((file) => {
75
+ const hit = matchAnyModelPath(input.models, file.path);
76
+ if (!hit) return { path: file.path, type: null, ok: true, skipped: true };
77
+ const model = input.models.find((m) => m.type === hit.type);
78
+ if (!model) return { path: file.path, type: null, ok: true, skipped: true };
79
+ const result = validateMdxFile(model, file.raw);
80
+ if (result.ok) return { path: file.path, type: model.type, ok: true };
81
+ return { path: file.path, type: model.type, ok: false, errors: result.errors };
82
+ });
83
+ }
package/src/zod.ts ADDED
@@ -0,0 +1,74 @@
1
+ // packages/core/src/zod.ts
2
+ import { z } from "zod";
3
+ import type { FieldKind, FieldSnapshot } from "./fields";
4
+ import type { Frontmatter } from "./mdx";
5
+ import type { ModelSnapshot } from "./model";
6
+
7
+ /** Primitive a field kind is stored as in frontmatter; used in error messages. */
8
+ export function expectedTypeFor(kind: FieldKind): "string" | "number" | "boolean" {
9
+ switch (kind) {
10
+ case "number":
11
+ return "number";
12
+ case "boolean":
13
+ return "boolean";
14
+ case "text":
15
+ case "slug":
16
+ case "locale":
17
+ case "image":
18
+ case "relation":
19
+ return "string";
20
+ default: {
21
+ const _exhaustive: never = kind;
22
+ throw new Error(`unknown field kind: ${String(_exhaustive)}`);
23
+ }
24
+ }
25
+ }
26
+
27
+ function fieldZod(field: FieldSnapshot): z.ZodType {
28
+ let base: z.ZodType;
29
+ switch (expectedTypeFor(field.kind)) {
30
+ case "number":
31
+ base = z.number();
32
+ break;
33
+ case "boolean":
34
+ base = z.boolean();
35
+ break;
36
+ default:
37
+ // Required strings must be non-blank; trim happens before min(1).
38
+ base = field.required ? z.string().trim().min(1) : z.string();
39
+ }
40
+ return field.required ? base : base.optional();
41
+ }
42
+
43
+ /**
44
+ * Zod object for a model's frontmatter.
45
+ * Loose: keys not in the model pass through untouched (drift is not an error).
46
+ */
47
+ export function frontmatterZod(model: Pick<ModelSnapshot, "fields">) {
48
+ const shape: Record<string, z.ZodType> = {};
49
+ for (const [name, field] of Object.entries(model.fields)) {
50
+ shape[name] = fieldZod(field);
51
+ }
52
+ return z.looseObject(shape);
53
+ }
54
+
55
+ /** YAML `key:` with no value parses to null; treat null as "not set". */
56
+ export function normalizeFrontmatter(frontmatter: Frontmatter): Frontmatter {
57
+ const out: Frontmatter = {};
58
+ for (const [key, value] of Object.entries(frontmatter)) {
59
+ if (value !== null) out[key] = value;
60
+ }
61
+ return out;
62
+ }
63
+
64
+ export type FrontmatterJsonSchema = z.core.JSONSchema.BaseSchema & {
65
+ title: string;
66
+ };
67
+
68
+ /** JSON Schema (draft 2020-12) for editor tooling. */
69
+ export function frontmatterJsonSchema(model: ModelSnapshot): FrontmatterJsonSchema {
70
+ const schema = z.toJSONSchema(frontmatterZod(model), {
71
+ target: "draft-2020-12",
72
+ });
73
+ return { ...schema, title: model.type };
74
+ }