@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/src/index.ts ADDED
@@ -0,0 +1,132 @@
1
+ // packages/core/src/index.ts
2
+ export type { DocRef } from "./doc-ref";
3
+ export { parseDocRef, docRefPath } from "./doc-ref";
4
+
5
+ export type {
6
+ FieldDef,
7
+ FieldKind,
8
+ FieldSnapshot,
9
+ } from "./fields";
10
+ export { field, serializeField, parseFieldSnapshot } from "./fields";
11
+
12
+ export type {
13
+ ModelDef,
14
+ ModelInput,
15
+ ModelSnapshot,
16
+ SchemaSnapshot,
17
+ InferFrontmatter,
18
+ } from "./model";
19
+ export {
20
+ defineModel,
21
+ defaultModelPath,
22
+ DEFAULT_MODEL_PATH,
23
+ DEFAULT_PAGE_PATH,
24
+ serializeModel,
25
+ serializeSchema,
26
+ parseSchemaSnapshot,
27
+ } from "./model";
28
+
29
+ export type {
30
+ BlockInstance,
31
+ BlockDef,
32
+ BlockSnapshot,
33
+ InferBlockProps,
34
+ } from "./block";
35
+ export {
36
+ defineBlock,
37
+ serializeBlock,
38
+ parseBlockSnapshot,
39
+ } from "./block";
40
+
41
+ export type { DocFormat } from "./doc-format";
42
+ export { modelFormat, parseDoc, serializeDoc } from "./doc-format";
43
+
44
+ export type { BackmatterConfig, BackmatterDirs, ResolvedConfig } from "./config";
45
+ export { defineConfig, resolveConfig, detectSiteUrl, DEFAULT_DIRS, DEFAULT_LOCALE } from "./config";
46
+
47
+ export type { RouteMatch, RouteModel, MatchRouteOptions } from "./routes";
48
+ export {
49
+ DEFAULT_ROUTE,
50
+ compileRoutePattern,
51
+ matchRoute,
52
+ normalizePathname,
53
+ routeFor,
54
+ routeSegments,
55
+ routeTemplate,
56
+ sortByRouteSpecificity,
57
+ } from "./routes";
58
+
59
+ export type { PathMatch, ContentSource, ParsedContentKey } from "./path";
60
+ export {
61
+ compilePathPattern,
62
+ matchModelPath,
63
+ matchAnyModelPath,
64
+ repoPathFromModel,
65
+ contentExtFromPath,
66
+ contentExtForType,
67
+ encodeGitBranch,
68
+ decodeGitBranch,
69
+ s3HeadKey,
70
+ s3TreeKey,
71
+ s3HeadPrefix,
72
+ s3TreePrefix,
73
+ s3ShaMetaKey,
74
+ parseS3ContentKey,
75
+ } from "./path";
76
+
77
+ export type { Frontmatter, ParsedMdx } from "./mdx";
78
+ export { parseMdx, serializeMdx } from "./mdx";
79
+
80
+ export type { ValidationResult, ContentFileResult } from "./validate";
81
+ export {
82
+ validateFrontmatter,
83
+ validateMdxFile,
84
+ validateDocFile,
85
+ validateBlocks,
86
+ validateContentTree,
87
+ } from "./validate";
88
+ export { coerceFieldValue, coerceDocValues, imageUrlFromUnknown } from "./coerce";
89
+
90
+ export type { FrontmatterJsonSchema } from "./zod";
91
+ export {
92
+ expectedTypeFor,
93
+ exampleValueFor,
94
+ frontmatterJsonSchema,
95
+ frontmatterZod,
96
+ normalizeFrontmatter,
97
+ } from "./zod";
98
+
99
+ // Runtime-agnostic content access (no Node builtins). Filesystem loading,
100
+ // createBackmatter and the CLI live in `@backmatter/core/node`.
101
+ export type { BackmatterDoc, BackmatterDocSummary } from "./doc";
102
+ export { createQuery } from "./query";
103
+ export type { Query, ModelQuery, TypedDoc, Where, Include, DocLoader } from "./query";
104
+ export type { PreviewCheckout, PreviewTicket } from "./preview-session";
105
+ export {
106
+ PREVIEW_COOKIE,
107
+ PREVIEW_ENABLE_PATH,
108
+ PREVIEW_QUERY,
109
+ isSafeRedirectPath,
110
+ mintPreviewTicket,
111
+ previewMiddlewareDecision,
112
+ readPreviewCookie,
113
+ readPreviewTicket,
114
+ writePreviewCookie,
115
+ PREVIEW_TICKET_TTL_MS,
116
+ PREVIEW_COOKIE_TTL_MS,
117
+ } from "./preview-session";
118
+ export { signPayload, verifyPayload } from "./signed-token";
119
+ export { getDocFromApi, listDocsFromApi } from "./api-loader";
120
+ export type { PublicDocsQuery } from "./api-loader";
121
+ export { syncSchema, detectGitBranch } from "./sync-schema";
122
+ export type { SyncSchemaOptions } from "./sync-schema";
123
+ export type { ContentCommittedDoc, ContentCommittedEvent } from "./content-committed";
124
+ export {
125
+ CONTENT_COMMITTED_EVENT,
126
+ BACKMATTER_CACHE_TAG,
127
+ BACKMATTER_REVALIDATE_PATH,
128
+ contentCacheTags,
129
+ listCacheTags,
130
+ pathsForCommittedDocs,
131
+ revalidateUrl,
132
+ } from "./content-committed";
package/src/mdx.ts ADDED
@@ -0,0 +1,44 @@
1
+ // packages/core/src/mdx.ts
2
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
3
+
4
+ export type Frontmatter = Record<string, unknown>;
5
+
6
+ export type ParsedMdx = {
7
+ frontmatter: Frontmatter;
8
+ body: string;
9
+ };
10
+
11
+ const FM_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
12
+
13
+ export function parseMdx(raw: string): ParsedMdx {
14
+ const match = FM_RE.exec(raw);
15
+ if (!match) {
16
+ return { frontmatter: {}, body: raw };
17
+ }
18
+ const yamlText = match[1] ?? "";
19
+ const body = match[2] ?? "";
20
+ let frontmatter: Frontmatter = {};
21
+ if (yamlText.trim()) {
22
+ const parsed = parseYaml(yamlText);
23
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
24
+ frontmatter = parsed as Frontmatter;
25
+ } else {
26
+ throw new Error("frontmatter must be a YAML mapping");
27
+ }
28
+ }
29
+ return { frontmatter, body };
30
+ }
31
+
32
+ export function serializeMdx(frontmatter: Frontmatter, body: string): string {
33
+ const keys = Object.keys(frontmatter);
34
+ if (keys.length === 0) {
35
+ return body.startsWith("---") ? `\n${body}` : body;
36
+ }
37
+ const yaml = stringifyYaml(frontmatter, {
38
+ lineWidth: 0,
39
+ defaultStringType: "PLAIN",
40
+ defaultKeyType: "PLAIN",
41
+ }).trimEnd();
42
+ const normalizedBody = body.replace(/^\r?\n/, "");
43
+ return `---\n${yaml}\n---\n${normalizedBody}`;
44
+ }
package/src/model.ts ADDED
@@ -0,0 +1,258 @@
1
+ // packages/core/src/model.ts
2
+ import {
3
+ type FieldDef,
4
+ type FieldKind,
5
+ type FieldSnapshot,
6
+ parseFieldSnapshot,
7
+ serializeField,
8
+ } from "./fields";
9
+ import { parseBlockSnapshot, serializeBlock, type BlockDef, type BlockSnapshot } from "./block";
10
+ import type { BlockInstance } from "./block";
11
+ import type { DocFormat } from "./doc-format";
12
+
13
+ export type ModelDef = {
14
+ type: string;
15
+ /** Repo-relative path template, e.g. content/{locale}/blog/{slug}.mdx */
16
+ path: string;
17
+ /**
18
+ * URL template for the host catch-all route, e.g. "/blog/{slug}".
19
+ * Defaults to "/{locale}/{type}/{slug}" when omitted (see routes.ts).
20
+ */
21
+ route?: string;
22
+ /** File format. Default `"mdx"`. `"page"` stores JSON with a blocks array. */
23
+ format?: DocFormat;
24
+ /**
25
+ * Document slug served at `/` by the host catch-all. At most one model
26
+ * in a schema may set this.
27
+ */
28
+ indexSlug?: string;
29
+ fields: Record<string, FieldDef>;
30
+ };
31
+
32
+ /** What `defineModel` accepts: `path` falls back to the folder convention. */
33
+ export type ModelInput = Omit<ModelDef, "path"> & { path?: string };
34
+
35
+ /** Default repo path when a model does not declare one. */
36
+ export const DEFAULT_MODEL_PATH = "content/{locale}/{type}/{slug}.mdx";
37
+ export const DEFAULT_PAGE_PATH = "content/{locale}/{type}/{slug}.json";
38
+
39
+ export function defaultModelPath(type: string, format: DocFormat = "mdx"): string {
40
+ const template = format === "page" ? DEFAULT_PAGE_PATH : DEFAULT_MODEL_PATH;
41
+ return template.replaceAll("{type}", type);
42
+ }
43
+
44
+ export type ModelSnapshot = {
45
+ type: string;
46
+ path: string;
47
+ route?: string;
48
+ format?: DocFormat;
49
+ indexSlug?: string;
50
+ fields: Record<string, FieldSnapshot>;
51
+ };
52
+
53
+ export type SchemaSnapshot = {
54
+ version: number;
55
+ models: ModelSnapshot[];
56
+ blocks?: BlockSnapshot[];
57
+ };
58
+
59
+ type WithPath<D extends ModelInput> = D extends { path: string } ? D : D & { path: string };
60
+
61
+ /**
62
+ * Validates the definition at runtime and preserves its literal types.
63
+ * `path` defaults to `content/{locale}/{type}/{slug}.mdx`.
64
+ */
65
+ export function defineModel<const D extends ModelInput>(def: D): WithPath<D> {
66
+ if (!def.type || typeof def.type !== "string") {
67
+ throw new Error("model.type is required");
68
+ }
69
+ if (def.format !== undefined && def.format !== "mdx" && def.format !== "page") {
70
+ throw new Error('model.format must be "mdx" or "page"');
71
+ }
72
+ const path = def.path ?? defaultModelPath(def.type, def.format ?? "mdx");
73
+ if (typeof path !== "string" || !path) {
74
+ throw new Error("model.path must be a non-empty string");
75
+ }
76
+ if (!path.includes("{locale}") || !path.includes("{slug}")) {
77
+ throw new Error("model.path must include {locale} and {slug}");
78
+ }
79
+ if (def.route !== undefined) {
80
+ if (typeof def.route !== "string" || !def.route.startsWith("/")) {
81
+ throw new Error('model.route must start with "/"');
82
+ }
83
+ if (!def.route.includes("{slug}")) {
84
+ throw new Error("model.route must include {slug}");
85
+ }
86
+ }
87
+ if (def.indexSlug !== undefined) {
88
+ if (typeof def.indexSlug !== "string" || !def.indexSlug.trim()) {
89
+ throw new Error("model.indexSlug must be a non-empty string");
90
+ }
91
+ }
92
+ if (!def.fields || typeof def.fields !== "object") {
93
+ throw new Error("model.fields is required");
94
+ }
95
+ return (def.path === undefined ? { ...def, path } : def) as WithPath<D>;
96
+ }
97
+
98
+ type Simplify<T> = { -readonly [K in keyof T]: T[K] } & {};
99
+
100
+ type ScalarOf<K extends FieldKind> = K extends "number"
101
+ ? number
102
+ : K extends "boolean"
103
+ ? boolean
104
+ : K extends "blocks"
105
+ ? BlockInstance[]
106
+ : string;
107
+
108
+ type IsRequired<F> = F extends { required?: infer R }
109
+ ? [Exclude<R, undefined>] extends [true]
110
+ ? true
111
+ : false
112
+ : false;
113
+
114
+ /** TypeScript shape of a model's frontmatter, derived from `field.*` calls. */
115
+ export type InferFrontmatter<M extends ModelDef> = Simplify<
116
+ {
117
+ [N in keyof M["fields"] as IsRequired<M["fields"][N]> extends true
118
+ ? N
119
+ : never]: ScalarOf<M["fields"][N]["kind"]>;
120
+ } & {
121
+ [N in keyof M["fields"] as IsRequired<M["fields"][N]> extends true
122
+ ? never
123
+ : N]?: ScalarOf<M["fields"][N]["kind"]>;
124
+ }
125
+ >;
126
+
127
+ export function serializeModel(model: ModelDef): ModelSnapshot {
128
+ const fields: Record<string, FieldSnapshot> = {};
129
+ for (const [name, def] of Object.entries(model.fields)) {
130
+ fields[name] = serializeField(def);
131
+ }
132
+ const snapshot: ModelSnapshot = {
133
+ type: model.type,
134
+ path: model.path,
135
+ fields,
136
+ };
137
+ if (model.route !== undefined) snapshot.route = model.route;
138
+ if (model.format === "page") snapshot.format = "page";
139
+ if (model.indexSlug !== undefined) snapshot.indexSlug = model.indexSlug.trim();
140
+ return snapshot;
141
+ }
142
+
143
+ export function serializeSchema(
144
+ models: ModelDef[],
145
+ blocks?: BlockDef[],
146
+ ): SchemaSnapshot {
147
+ const snapshot: SchemaSnapshot = {
148
+ version: 1,
149
+ models: models.map(serializeModel),
150
+ };
151
+ if (blocks && blocks.length > 0) {
152
+ snapshot.blocks = blocks.map(serializeBlock);
153
+ }
154
+ return snapshot;
155
+ }
156
+
157
+ export function parseSchemaSnapshot(input: unknown): SchemaSnapshot {
158
+ if (!input || typeof input !== "object") {
159
+ throw new Error("schema must be an object");
160
+ }
161
+ const value = input as Record<string, unknown>;
162
+ const version = value.version;
163
+ if (typeof version !== "number" || !Number.isInteger(version) || version < 1) {
164
+ throw new Error("schema.version must be a positive integer");
165
+ }
166
+ if (!Array.isArray(value.models)) {
167
+ throw new Error("schema.models must be an array");
168
+ }
169
+ const models: ModelSnapshot[] = value.models.map((raw, i) => {
170
+ if (!raw || typeof raw !== "object") {
171
+ throw new Error(`schema.models[${i}] must be an object`);
172
+ }
173
+ const m = raw as Record<string, unknown>;
174
+ if (typeof m.type !== "string" || !m.type) {
175
+ throw new Error(`schema.models[${i}].type is required`);
176
+ }
177
+ if (typeof m.path !== "string" || !m.path) {
178
+ throw new Error(`schema.models[${i}].path is required`);
179
+ }
180
+ if (!m.path.includes("{locale}") || !m.path.includes("{slug}")) {
181
+ throw new Error(
182
+ `schema.models[${i}].path must include {locale} and {slug}`,
183
+ );
184
+ }
185
+ if (m.route !== undefined && (typeof m.route !== "string" || !m.route.startsWith("/"))) {
186
+ throw new Error(`schema.models[${i}].route must be a string starting with "/"`);
187
+ }
188
+ if (!m.fields || typeof m.fields !== "object" || Array.isArray(m.fields)) {
189
+ throw new Error(`schema.models[${i}].fields must be an object`);
190
+ }
191
+ const fields: Record<string, FieldSnapshot> = {};
192
+ for (const [name, fieldRaw] of Object.entries(
193
+ m.fields as Record<string, unknown>,
194
+ )) {
195
+ try {
196
+ fields[name] = parseFieldSnapshot(fieldRaw);
197
+ } catch (err) {
198
+ const msg = err instanceof Error ? err.message : "invalid field";
199
+ throw new Error(`schema.models[${i}].fields.${name}: ${msg}`);
200
+ }
201
+ }
202
+ const snapshot: ModelSnapshot = { type: m.type, path: m.path, fields };
203
+ if (typeof m.route === "string") snapshot.route = m.route;
204
+ if (m.format === "page") snapshot.format = "page";
205
+ else if (m.format !== undefined && m.format !== "mdx") {
206
+ throw new Error(`schema.models[${i}].format must be "mdx" or "page"`);
207
+ }
208
+ if (m.indexSlug !== undefined) {
209
+ if (typeof m.indexSlug !== "string" || !m.indexSlug.trim()) {
210
+ throw new Error(`schema.models[${i}].indexSlug must be a non-empty string`);
211
+ }
212
+ snapshot.indexSlug = m.indexSlug.trim();
213
+ }
214
+ return snapshot;
215
+ });
216
+
217
+ const types = new Set<string>();
218
+ let indexOwner: string | undefined;
219
+ for (const m of models) {
220
+ if (types.has(m.type)) {
221
+ throw new Error(`duplicate model type: ${m.type}`);
222
+ }
223
+ types.add(m.type);
224
+ if (m.indexSlug) {
225
+ if (indexOwner) {
226
+ throw new Error(
227
+ `duplicate indexSlug: models "${indexOwner}" and "${m.type}" both claim /`,
228
+ );
229
+ }
230
+ indexOwner = m.type;
231
+ }
232
+ }
233
+
234
+ let blocks: BlockSnapshot[] = [];
235
+ const catalogRaw = value.blocks ?? value.pageblocks;
236
+ if (catalogRaw !== undefined) {
237
+ if (!Array.isArray(catalogRaw)) {
238
+ throw new Error("schema.blocks must be an array");
239
+ }
240
+ blocks = catalogRaw.map((raw, i) => {
241
+ try {
242
+ return parseBlockSnapshot(raw);
243
+ } catch (err) {
244
+ const msg = err instanceof Error ? err.message : "invalid block";
245
+ throw new Error(`schema.blocks[${i}]: ${msg}`);
246
+ }
247
+ });
248
+ const names = new Set<string>();
249
+ for (const block of blocks) {
250
+ if (names.has(block.name)) {
251
+ throw new Error(`duplicate block name: ${block.name}`);
252
+ }
253
+ names.add(block.name);
254
+ }
255
+ }
256
+
257
+ return { version, models, blocks };
258
+ }