@backmatter/core 0.0.6
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/bin/backmatter.js +2 -0
- package/package.json +36 -0
- package/src/api-loader.ts +72 -0
- package/src/block.ts +103 -0
- package/src/coerce.ts +74 -0
- package/src/config.ts +125 -0
- package/src/content-committed.ts +57 -0
- package/src/doc-format.ts +32 -0
- package/src/doc-ref.ts +29 -0
- package/src/doc.ts +18 -0
- package/src/fields.ts +92 -0
- package/src/index.ts +132 -0
- package/src/mdx.ts +44 -0
- package/src/model.ts +258 -0
- package/src/node/cli.ts +505 -0
- package/src/node/create-backmatter.ts +130 -0
- package/src/node/discover.ts +191 -0
- package/src/node/eval-block.ts +202 -0
- package/src/node/eval-model.ts +143 -0
- package/src/node/fs-loader.ts +96 -0
- package/src/node/generate.ts +265 -0
- package/src/node/index.ts +50 -0
- package/src/path.ts +196 -0
- package/src/preview-hotkeys.ts +71 -0
- package/src/preview-session.ts +131 -0
- package/src/preview.ts +205 -0
- package/src/query.ts +152 -0
- package/src/routes.ts +110 -0
- package/src/signed-token.ts +63 -0
- package/src/sync-schema.ts +52 -0
- package/src/validate.ts +149 -0
- package/src/zod.ts +111 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// packages/core/src/sync-schema.ts
|
|
2
|
+
import {
|
|
3
|
+
serializeSchema,
|
|
4
|
+
type ModelDef,
|
|
5
|
+
type ModelSnapshot,
|
|
6
|
+
} from "./model";
|
|
7
|
+
import type { BlockDef } from "./block";
|
|
8
|
+
|
|
9
|
+
export function detectGitBranch(
|
|
10
|
+
env: Record<string, string | undefined> = process.env,
|
|
11
|
+
): string | null {
|
|
12
|
+
const override = env.BACKMATTER_GIT_BRANCH?.trim();
|
|
13
|
+
if (override) return override;
|
|
14
|
+
const vercel = env.VERCEL_GIT_COMMIT_REF?.trim();
|
|
15
|
+
if (vercel) return vercel;
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type SyncSchemaOptions = {
|
|
20
|
+
apiUrl: string;
|
|
21
|
+
workspaceId: string;
|
|
22
|
+
hostToken: string;
|
|
23
|
+
models: Array<ModelDef | ModelSnapshot>;
|
|
24
|
+
blocks?: BlockDef[];
|
|
25
|
+
branch?: string;
|
|
26
|
+
siteUrl?: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export async function syncSchema(options: SyncSchemaOptions): Promise<void> {
|
|
30
|
+
const branch = options.branch ?? detectGitBranch() ?? "main";
|
|
31
|
+
const payload = serializeSchema(options.models as ModelDef[], options.blocks);
|
|
32
|
+
const body: { payload: ReturnType<typeof serializeSchema>; branch: string; siteUrl?: string } = {
|
|
33
|
+
payload,
|
|
34
|
+
branch,
|
|
35
|
+
};
|
|
36
|
+
if (options.siteUrl) body.siteUrl = options.siteUrl;
|
|
37
|
+
const res = await fetch(
|
|
38
|
+
`${options.apiUrl.replace(/\/$/, "")}/workspaces/${options.workspaceId}/schema`,
|
|
39
|
+
{
|
|
40
|
+
method: "PUT",
|
|
41
|
+
headers: {
|
|
42
|
+
Authorization: `Bearer ${options.hostToken}`,
|
|
43
|
+
"Content-Type": "application/json",
|
|
44
|
+
},
|
|
45
|
+
body: JSON.stringify(body),
|
|
46
|
+
},
|
|
47
|
+
);
|
|
48
|
+
if (!res.ok) {
|
|
49
|
+
const text = await res.text().catch(() => "");
|
|
50
|
+
throw new Error(`schema sync failed: ${res.status} ${text}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/validate.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// packages/core/src/validate.ts
|
|
2
|
+
import { modelFormat, parseDoc } from "./doc-format";
|
|
3
|
+
import type { Frontmatter } from "./mdx";
|
|
4
|
+
import { parseMdx } from "./mdx";
|
|
5
|
+
import type { ModelSnapshot } from "./model";
|
|
6
|
+
import type { BlockInstance, BlockSnapshot } from "./block";
|
|
7
|
+
import { matchAnyModelPath } from "./path";
|
|
8
|
+
import { expectedTypeFor, frontmatterZod, normalizeFrontmatter } from "./zod";
|
|
9
|
+
|
|
10
|
+
export type ValidationResult =
|
|
11
|
+
| { ok: true }
|
|
12
|
+
| { ok: false; errors: string[] };
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Frontmatter must satisfy the model: required fields present and non-blank,
|
|
16
|
+
* every declared field of the right primitive type. Unknown keys are ignored.
|
|
17
|
+
*/
|
|
18
|
+
export function validateFrontmatter(
|
|
19
|
+
model: Pick<ModelSnapshot, "fields">,
|
|
20
|
+
frontmatter: Frontmatter,
|
|
21
|
+
): ValidationResult {
|
|
22
|
+
const data = normalizeFrontmatter(frontmatter);
|
|
23
|
+
const result = frontmatterZod(model).safeParse(data);
|
|
24
|
+
if (result.success) return { ok: true };
|
|
25
|
+
|
|
26
|
+
const errors: string[] = [];
|
|
27
|
+
const seen = new Set<string>();
|
|
28
|
+
for (const issue of result.error.issues) {
|
|
29
|
+
const name = String(issue.path[0] ?? "");
|
|
30
|
+
const field = model.fields[name];
|
|
31
|
+
if (!field || seen.has(name)) continue;
|
|
32
|
+
seen.add(name);
|
|
33
|
+
const missing = data[name] === undefined;
|
|
34
|
+
if (issue.code === "too_small" || (issue.code === "invalid_type" && missing)) {
|
|
35
|
+
errors.push(`${name} is required`);
|
|
36
|
+
} else {
|
|
37
|
+
errors.push(`${name} must be a ${expectedTypeFor(field.kind)}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// Never fail open: if every issue was filtered above, still report invalid.
|
|
41
|
+
// Unreachable through the public API today (normalizeFrontmatter always yields
|
|
42
|
+
// a plain object and looseObject only raises issues on declared fields), so
|
|
43
|
+
// there is no test exercising this branch.
|
|
44
|
+
if (errors.length === 0) return { ok: false, errors: ["frontmatter is invalid"] };
|
|
45
|
+
return { ok: false, errors };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function asBlockList(value: unknown): BlockInstance[] {
|
|
49
|
+
if (!Array.isArray(value)) return [];
|
|
50
|
+
return value.filter((item): item is BlockInstance => {
|
|
51
|
+
if (!item || typeof item !== "object") return false;
|
|
52
|
+
const row = item as Record<string, unknown>;
|
|
53
|
+
return typeof row.id === "string" && typeof row.type === "string";
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Validate each block instance against the blocks catalog. */
|
|
58
|
+
export function validateBlocks(
|
|
59
|
+
blocks: unknown,
|
|
60
|
+
catalog: BlockSnapshot[],
|
|
61
|
+
prefix = "blocks",
|
|
62
|
+
): ValidationResult {
|
|
63
|
+
if (!Array.isArray(blocks)) return { ok: true };
|
|
64
|
+
const byName = new Map(catalog.map((b) => [b.name, b]));
|
|
65
|
+
const errors: string[] = [];
|
|
66
|
+
asBlockList(blocks).forEach((block, i) => {
|
|
67
|
+
const def = byName.get(block.type);
|
|
68
|
+
if (!def) {
|
|
69
|
+
errors.push(`${prefix}[${i}]: unknown type "${block.type}"`);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const props = block.props && typeof block.props === "object" ? block.props : {};
|
|
73
|
+
const result = validateFrontmatter({ fields: def.fields }, props);
|
|
74
|
+
if (!result.ok) {
|
|
75
|
+
for (const err of result.errors) {
|
|
76
|
+
errors.push(`${prefix}[${i}].${err}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
if (errors.length > 0) return { ok: false, errors };
|
|
81
|
+
return { ok: true };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Parse a raw MDX string and validate its frontmatter. Never throws on
|
|
86
|
+
* malformed frontmatter (an unknown field kind in `model` still throws).
|
|
87
|
+
*/
|
|
88
|
+
export function validateMdxFile(
|
|
89
|
+
model: Pick<ModelSnapshot, "fields">,
|
|
90
|
+
raw: string,
|
|
91
|
+
): ValidationResult {
|
|
92
|
+
let frontmatter: Frontmatter;
|
|
93
|
+
try {
|
|
94
|
+
frontmatter = parseMdx(raw).frontmatter;
|
|
95
|
+
} catch (err) {
|
|
96
|
+
const msg = err instanceof Error ? err.message : "invalid";
|
|
97
|
+
return { ok: false, errors: [`frontmatter: ${msg}`] };
|
|
98
|
+
}
|
|
99
|
+
return validateFrontmatter(model, frontmatter);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function validateDocFile(
|
|
103
|
+
model: Pick<ModelSnapshot, "fields" | "format">,
|
|
104
|
+
raw: string,
|
|
105
|
+
catalog: BlockSnapshot[] = [],
|
|
106
|
+
): ValidationResult {
|
|
107
|
+
const format = modelFormat(model);
|
|
108
|
+
let frontmatter: Frontmatter;
|
|
109
|
+
try {
|
|
110
|
+
frontmatter = parseDoc(raw, format).frontmatter;
|
|
111
|
+
} catch (err) {
|
|
112
|
+
const msg = err instanceof Error ? err.message : "invalid";
|
|
113
|
+
const label = format === "page" ? "JSON" : "frontmatter";
|
|
114
|
+
return { ok: false, errors: [msg.startsWith("JSON:") ? msg : `${label}: ${msg}`] };
|
|
115
|
+
}
|
|
116
|
+
const base = validateFrontmatter(model, frontmatter);
|
|
117
|
+
if (format !== "page") return base;
|
|
118
|
+
const blockField = Object.entries(model.fields).find(([, f]) => f.kind === "blocks");
|
|
119
|
+
if (!blockField) return base;
|
|
120
|
+
const blockResult = validateBlocks(frontmatter[blockField[0]], catalog, blockField[0]);
|
|
121
|
+
if (base.ok && blockResult.ok) return { ok: true };
|
|
122
|
+
return {
|
|
123
|
+
ok: false,
|
|
124
|
+
errors: [...(base.ok ? [] : base.errors), ...(blockResult.ok ? [] : blockResult.errors)],
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export type ContentFileResult =
|
|
129
|
+
| { path: string; type: string; ok: true }
|
|
130
|
+
| { path: string; type: string; ok: false; errors: string[] }
|
|
131
|
+
| { path: string; type: null; ok: true; skipped: true };
|
|
132
|
+
|
|
133
|
+
/** Validate many repo-relative files against a model set. Order is preserved. */
|
|
134
|
+
export function validateContentTree(input: {
|
|
135
|
+
models: ModelSnapshot[];
|
|
136
|
+
files: Array<{ path: string; raw: string }>;
|
|
137
|
+
catalog?: BlockSnapshot[];
|
|
138
|
+
}): ContentFileResult[] {
|
|
139
|
+
const catalog = input.catalog ?? [];
|
|
140
|
+
return input.files.map((file) => {
|
|
141
|
+
const hit = matchAnyModelPath(input.models, file.path);
|
|
142
|
+
if (!hit) return { path: file.path, type: null, ok: true, skipped: true };
|
|
143
|
+
const model = input.models.find((m) => m.type === hit.type);
|
|
144
|
+
if (!model) return { path: file.path, type: null, ok: true, skipped: true };
|
|
145
|
+
const result = validateDocFile(model, file.raw, catalog);
|
|
146
|
+
if (result.ok) return { path: file.path, type: model.type, ok: true };
|
|
147
|
+
return { path: file.path, type: model.type, ok: false, errors: result.errors };
|
|
148
|
+
});
|
|
149
|
+
}
|
package/src/zod.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
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" | "array" {
|
|
9
|
+
switch (kind) {
|
|
10
|
+
case "number":
|
|
11
|
+
return "number";
|
|
12
|
+
case "boolean":
|
|
13
|
+
return "boolean";
|
|
14
|
+
case "blocks":
|
|
15
|
+
return "array";
|
|
16
|
+
case "text":
|
|
17
|
+
case "slug":
|
|
18
|
+
case "locale":
|
|
19
|
+
case "image":
|
|
20
|
+
case "relation":
|
|
21
|
+
return "string";
|
|
22
|
+
default: {
|
|
23
|
+
const _exhaustive: never = kind;
|
|
24
|
+
throw new Error(`unknown field kind: ${String(_exhaustive)}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Example JSON value for prompts and docs; image is always a URL string. */
|
|
30
|
+
export function exampleValueFor(kind: FieldKind): unknown {
|
|
31
|
+
switch (kind) {
|
|
32
|
+
case "number":
|
|
33
|
+
return 0;
|
|
34
|
+
case "boolean":
|
|
35
|
+
return false;
|
|
36
|
+
case "blocks":
|
|
37
|
+
return [];
|
|
38
|
+
case "image":
|
|
39
|
+
return "https://example.com/image.jpg";
|
|
40
|
+
case "slug":
|
|
41
|
+
return "example-slug";
|
|
42
|
+
case "locale":
|
|
43
|
+
return "en";
|
|
44
|
+
case "relation":
|
|
45
|
+
return "related-slug";
|
|
46
|
+
case "text":
|
|
47
|
+
return "";
|
|
48
|
+
default: {
|
|
49
|
+
const _exhaustive: never = kind;
|
|
50
|
+
throw new Error(`unknown field kind: ${String(_exhaustive)}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function fieldZod(field: FieldSnapshot): z.ZodType {
|
|
56
|
+
if (field.kind === "blocks") {
|
|
57
|
+
const item = z.object({
|
|
58
|
+
id: z.string().min(1),
|
|
59
|
+
type: z.string().min(1),
|
|
60
|
+
props: z.record(z.string(), z.unknown()).optional().default({}),
|
|
61
|
+
});
|
|
62
|
+
const arr = field.required ? z.array(item).min(1) : z.array(item).optional();
|
|
63
|
+
return arr;
|
|
64
|
+
}
|
|
65
|
+
let base: z.ZodType;
|
|
66
|
+
switch (expectedTypeFor(field.kind)) {
|
|
67
|
+
case "number":
|
|
68
|
+
base = z.number();
|
|
69
|
+
break;
|
|
70
|
+
case "boolean":
|
|
71
|
+
base = z.boolean();
|
|
72
|
+
break;
|
|
73
|
+
default:
|
|
74
|
+
// Required strings must be non-blank; trim happens before min(1).
|
|
75
|
+
base = field.required ? z.string().trim().min(1) : z.string();
|
|
76
|
+
}
|
|
77
|
+
return field.required ? base : base.optional();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Zod object for a model's frontmatter.
|
|
82
|
+
* Loose: keys not in the model pass through untouched (drift is not an error).
|
|
83
|
+
*/
|
|
84
|
+
export function frontmatterZod(model: Pick<ModelSnapshot, "fields">) {
|
|
85
|
+
const shape: Record<string, z.ZodType> = {};
|
|
86
|
+
for (const [name, field] of Object.entries(model.fields)) {
|
|
87
|
+
shape[name] = fieldZod(field);
|
|
88
|
+
}
|
|
89
|
+
return z.looseObject(shape);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** YAML `key:` with no value parses to null; treat null as "not set". */
|
|
93
|
+
export function normalizeFrontmatter(frontmatter: Frontmatter): Frontmatter {
|
|
94
|
+
const out: Frontmatter = {};
|
|
95
|
+
for (const [key, value] of Object.entries(frontmatter)) {
|
|
96
|
+
if (value !== null) out[key] = value;
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export type FrontmatterJsonSchema = z.core.JSONSchema.BaseSchema & {
|
|
102
|
+
title: string;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** JSON Schema (draft 2020-12) for editor tooling. */
|
|
106
|
+
export function frontmatterJsonSchema(model: ModelSnapshot): FrontmatterJsonSchema {
|
|
107
|
+
const schema = z.toJSONSchema(frontmatterZod(model), {
|
|
108
|
+
target: "draft-2020-12",
|
|
109
|
+
});
|
|
110
|
+
return { ...schema, title: model.type };
|
|
111
|
+
}
|