@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
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@backmatter/core",
|
|
3
|
+
"version": "0.0.6",
|
|
4
|
+
"description": "Experimental Backmatter SDK. APIs will break. Not ready for production.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.ts",
|
|
11
|
+
"./node": "./src/node/index.ts",
|
|
12
|
+
"./node/create-backmatter": "./src/node/create-backmatter.ts",
|
|
13
|
+
"./preview": "./src/preview.ts",
|
|
14
|
+
"./generate": "./src/node/generate.ts"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"bin",
|
|
18
|
+
"src",
|
|
19
|
+
"!src/**/*.test.ts",
|
|
20
|
+
"!src/**/*.test.tsx",
|
|
21
|
+
"!**/test-fixtures"
|
|
22
|
+
],
|
|
23
|
+
"bin": {
|
|
24
|
+
"backmatter": "./bin/backmatter.js"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test": "bun test"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"yaml": "^2.9.0",
|
|
31
|
+
"zod": "^4.6.1"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/bun": "^1.3.14"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// packages/core/src/api-loader.ts
|
|
2
|
+
import type { BackmatterDoc, BackmatterDocSummary } from "./doc";
|
|
3
|
+
|
|
4
|
+
export type PublicDocsQuery = {
|
|
5
|
+
branch?: string;
|
|
6
|
+
type?: string;
|
|
7
|
+
locale?: string;
|
|
8
|
+
/** When set, Next Data Cache uses these tags instead of `cache: "no-store"`. */
|
|
9
|
+
tags?: string[];
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function fetchInit(
|
|
13
|
+
contentToken: string | undefined,
|
|
14
|
+
tags?: string[],
|
|
15
|
+
): RequestInit & { next?: { tags: string[] } } {
|
|
16
|
+
const headers: Record<string, string> = {};
|
|
17
|
+
if (contentToken) headers.Authorization = `Bearer ${contentToken}`;
|
|
18
|
+
if (tags?.length) return { headers, next: { tags } };
|
|
19
|
+
return { headers, cache: "no-store" };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function publicDocsUrl(
|
|
23
|
+
apiUrl: string,
|
|
24
|
+
workspaceId: string,
|
|
25
|
+
path: string,
|
|
26
|
+
query: PublicDocsQuery = {},
|
|
27
|
+
): string {
|
|
28
|
+
const url = new URL(
|
|
29
|
+
`${apiUrl.replace(/\/$/, "")}/public/workspaces/${workspaceId}/docs${path}`,
|
|
30
|
+
);
|
|
31
|
+
if (query.branch) url.searchParams.set("branch", query.branch);
|
|
32
|
+
if (query.type) url.searchParams.set("type", query.type);
|
|
33
|
+
if (query.locale) url.searchParams.set("locale", query.locale);
|
|
34
|
+
return url.toString();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function listDocsFromApi(
|
|
38
|
+
apiUrl: string,
|
|
39
|
+
workspaceId: string,
|
|
40
|
+
contentToken: string | undefined,
|
|
41
|
+
query: PublicDocsQuery = {},
|
|
42
|
+
): Promise<BackmatterDocSummary[]> {
|
|
43
|
+
const res = await fetch(publicDocsUrl(apiUrl, workspaceId, "", query), {
|
|
44
|
+
...fetchInit(contentToken, query.tags),
|
|
45
|
+
});
|
|
46
|
+
if (!res.ok) {
|
|
47
|
+
throw new Error(`backmatter listDocs failed: ${res.status}`);
|
|
48
|
+
}
|
|
49
|
+
const data = (await res.json()) as { docs: BackmatterDocSummary[] };
|
|
50
|
+
return data.docs;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function getDocFromApi(
|
|
54
|
+
apiUrl: string,
|
|
55
|
+
workspaceId: string,
|
|
56
|
+
contentToken: string | undefined,
|
|
57
|
+
locale: string,
|
|
58
|
+
type: string,
|
|
59
|
+
slug: string,
|
|
60
|
+
query: Pick<PublicDocsQuery, "branch" | "tags"> = {},
|
|
61
|
+
): Promise<BackmatterDoc | null> {
|
|
62
|
+
const res = await fetch(
|
|
63
|
+
publicDocsUrl(apiUrl, workspaceId, `/${locale}/${type}/${slug}`, query),
|
|
64
|
+
fetchInit(contentToken, query.tags),
|
|
65
|
+
);
|
|
66
|
+
if (res.status === 404) return null;
|
|
67
|
+
if (!res.ok) {
|
|
68
|
+
throw new Error(`backmatter getDoc failed: ${res.status}`);
|
|
69
|
+
}
|
|
70
|
+
const data = (await res.json()) as { doc: BackmatterDoc };
|
|
71
|
+
return data.doc;
|
|
72
|
+
}
|
package/src/block.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// packages/core/src/block.ts
|
|
2
|
+
import {
|
|
3
|
+
type FieldDef,
|
|
4
|
+
type FieldKind,
|
|
5
|
+
type FieldSnapshot,
|
|
6
|
+
parseFieldSnapshot,
|
|
7
|
+
serializeField,
|
|
8
|
+
} from "./fields";
|
|
9
|
+
|
|
10
|
+
export type BlockInstance = {
|
|
11
|
+
id: string;
|
|
12
|
+
type: string;
|
|
13
|
+
props: Record<string, unknown>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type BlockDef<C = unknown> = {
|
|
17
|
+
name: string;
|
|
18
|
+
fields: Record<string, FieldDef>;
|
|
19
|
+
component: C;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type BlockSnapshot = {
|
|
23
|
+
name: string;
|
|
24
|
+
fields: Record<string, FieldSnapshot>;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const PASCAL = /^[A-Z][A-Za-z0-9]*$/;
|
|
28
|
+
|
|
29
|
+
export function defineBlock<const D extends BlockDef>(def: D): D {
|
|
30
|
+
if (!def.name || typeof def.name !== "string") {
|
|
31
|
+
throw new Error("block.name is required");
|
|
32
|
+
}
|
|
33
|
+
if (!PASCAL.test(def.name)) {
|
|
34
|
+
throw new Error('block.name must be PascalCase (e.g. "Hero")');
|
|
35
|
+
}
|
|
36
|
+
if (!def.fields || typeof def.fields !== "object") {
|
|
37
|
+
throw new Error("block.fields is required");
|
|
38
|
+
}
|
|
39
|
+
if (def.component === undefined) {
|
|
40
|
+
throw new Error("block.component is required");
|
|
41
|
+
}
|
|
42
|
+
return def;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type Simplify<T> = { -readonly [K in keyof T]: T[K] } & {};
|
|
46
|
+
|
|
47
|
+
type ScalarOf<K extends FieldKind> = K extends "number"
|
|
48
|
+
? number
|
|
49
|
+
: K extends "boolean"
|
|
50
|
+
? boolean
|
|
51
|
+
: K extends "blocks"
|
|
52
|
+
? BlockInstance[]
|
|
53
|
+
: string;
|
|
54
|
+
|
|
55
|
+
type IsRequired<F> = F extends { required?: infer R }
|
|
56
|
+
? [Exclude<R, undefined>] extends [true]
|
|
57
|
+
? true
|
|
58
|
+
: false
|
|
59
|
+
: false;
|
|
60
|
+
|
|
61
|
+
/** TypeScript shape of a block's props, derived from `field.*` calls. */
|
|
62
|
+
export type InferBlockProps<Fields extends Record<string, FieldDef>> = Simplify<
|
|
63
|
+
{
|
|
64
|
+
[N in keyof Fields as IsRequired<Fields[N]> extends true ? N : never]: ScalarOf<
|
|
65
|
+
Fields[N]["kind"]
|
|
66
|
+
>;
|
|
67
|
+
} & {
|
|
68
|
+
[N in keyof Fields as IsRequired<Fields[N]> extends true ? never : N]?: ScalarOf<
|
|
69
|
+
Fields[N]["kind"]
|
|
70
|
+
>;
|
|
71
|
+
}
|
|
72
|
+
>;
|
|
73
|
+
|
|
74
|
+
export function serializeBlock(block: BlockDef): BlockSnapshot {
|
|
75
|
+
const fields: Record<string, FieldSnapshot> = {};
|
|
76
|
+
for (const [name, def] of Object.entries(block.fields)) {
|
|
77
|
+
fields[name] = serializeField(def);
|
|
78
|
+
}
|
|
79
|
+
return { name: block.name, fields };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function parseBlockSnapshot(input: unknown): BlockSnapshot {
|
|
83
|
+
if (!input || typeof input !== "object") {
|
|
84
|
+
throw new Error("block must be an object");
|
|
85
|
+
}
|
|
86
|
+
const value = input as Record<string, unknown>;
|
|
87
|
+
if (typeof value.name !== "string" || !value.name) {
|
|
88
|
+
throw new Error("block.name is required");
|
|
89
|
+
}
|
|
90
|
+
if (!value.fields || typeof value.fields !== "object" || Array.isArray(value.fields)) {
|
|
91
|
+
throw new Error("block.fields must be an object");
|
|
92
|
+
}
|
|
93
|
+
const fields: Record<string, FieldSnapshot> = {};
|
|
94
|
+
for (const [name, fieldRaw] of Object.entries(value.fields as Record<string, unknown>)) {
|
|
95
|
+
try {
|
|
96
|
+
fields[name] = parseFieldSnapshot(fieldRaw);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
const msg = err instanceof Error ? err.message : "invalid field";
|
|
99
|
+
throw new Error(`block.fields.${name}: ${msg}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return { name: value.name, fields };
|
|
103
|
+
}
|
package/src/coerce.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// packages/core/src/coerce.ts
|
|
2
|
+
import type { BlockSnapshot } from "./block";
|
|
3
|
+
import type { FieldKind, FieldSnapshot } from "./fields";
|
|
4
|
+
import type { Frontmatter } from "./mdx";
|
|
5
|
+
import type { ModelSnapshot } from "./model";
|
|
6
|
+
|
|
7
|
+
const IMAGE_URL_KEYS = ["src", "url", "href"] as const;
|
|
8
|
+
|
|
9
|
+
/** Models often write `{ src, alt }` for image fields; storage is a URL string. */
|
|
10
|
+
export function coerceFieldValue(kind: FieldKind, value: unknown): unknown {
|
|
11
|
+
if (kind !== "image") return value;
|
|
12
|
+
return imageUrlFromUnknown(value);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function imageUrlFromUnknown(value: unknown): unknown {
|
|
16
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
|
17
|
+
const rec = value as Record<string, unknown>;
|
|
18
|
+
for (const key of IMAGE_URL_KEYS) {
|
|
19
|
+
const candidate = rec[key];
|
|
20
|
+
if (typeof candidate === "string") return candidate;
|
|
21
|
+
}
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function coerceRecord(
|
|
26
|
+
fields: Record<string, FieldSnapshot>,
|
|
27
|
+
data: Record<string, unknown>,
|
|
28
|
+
): Record<string, unknown> {
|
|
29
|
+
const out: Record<string, unknown> = { ...data };
|
|
30
|
+
for (const [name, field] of Object.entries(fields)) {
|
|
31
|
+
if (!(name in out)) continue;
|
|
32
|
+
out[name] = coerceFieldValue(field.kind, out[name]);
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function coerceBlocks(value: unknown, catalog: BlockSnapshot[]): unknown {
|
|
38
|
+
if (!Array.isArray(value)) return value;
|
|
39
|
+
const byName = new Map(catalog.map((b) => [b.name, b]));
|
|
40
|
+
return value.map((item) => {
|
|
41
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) return item;
|
|
42
|
+
const block = item as Record<string, unknown>;
|
|
43
|
+
const propsRaw = block.props;
|
|
44
|
+
const props =
|
|
45
|
+
propsRaw && typeof propsRaw === "object" && !Array.isArray(propsRaw)
|
|
46
|
+
? { ...(propsRaw as Record<string, unknown>) }
|
|
47
|
+
: {};
|
|
48
|
+
const def = typeof block.type === "string" ? byName.get(block.type) : undefined;
|
|
49
|
+
const nextProps = def
|
|
50
|
+
? coerceRecord(def.fields, props)
|
|
51
|
+
: Object.fromEntries(
|
|
52
|
+
Object.entries(props).map(([k, v]) => [k, imageUrlFromUnknown(v)]),
|
|
53
|
+
);
|
|
54
|
+
return { ...block, props: nextProps };
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Flatten image-like objects in model fields and nested block props. */
|
|
59
|
+
export function coerceDocValues(
|
|
60
|
+
model: Pick<ModelSnapshot, "fields">,
|
|
61
|
+
frontmatter: Frontmatter,
|
|
62
|
+
catalog: BlockSnapshot[] = [],
|
|
63
|
+
): Frontmatter {
|
|
64
|
+
const out: Frontmatter = { ...frontmatter };
|
|
65
|
+
for (const [name, field] of Object.entries(model.fields)) {
|
|
66
|
+
if (!(name in out)) continue;
|
|
67
|
+
if (field.kind === "blocks") {
|
|
68
|
+
out[name] = coerceBlocks(out[name], catalog);
|
|
69
|
+
} else {
|
|
70
|
+
out[name] = coerceFieldValue(field.kind, out[name]);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// packages/core/src/config.ts
|
|
2
|
+
// General host configuration (`backmatter.config.ts`). Models are never listed
|
|
3
|
+
// here; they are discovered from the `models/` folder.
|
|
4
|
+
import { detectGitBranch } from "./sync-schema";
|
|
5
|
+
|
|
6
|
+
export type BackmatterDirs = {
|
|
7
|
+
/** Folder with one `defineModel` default export per file. Default: "models". */
|
|
8
|
+
models?: string;
|
|
9
|
+
/** Folder with one `defineBlock` default export per file. Default: "blocks". */
|
|
10
|
+
blocks?: string;
|
|
11
|
+
/** Folder with one MDX tag component default export per file. Default: "mdx". */
|
|
12
|
+
mdx?: string;
|
|
13
|
+
/** Folder with one layout default export per model type. Default: "layouts". */
|
|
14
|
+
layouts?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type BackmatterConfig = {
|
|
18
|
+
/** Required only when talking to the BACKMATTER API. Env: BACKMATTER_WORKSPACE_ID. */
|
|
19
|
+
workspaceId?: string;
|
|
20
|
+
/** Env: BACKMATTER_API_URL. When set with workspaceId, published docs load from the API. */
|
|
21
|
+
apiUrl?: string;
|
|
22
|
+
/** Env: BACKMATTER_CONTENT_TOKEN. Required only to read non-default branches. */
|
|
23
|
+
contentToken?: string;
|
|
24
|
+
/** Env: BACKMATTER_PREVIEW_TOKEN. Working-tree reads and signed preview cookies. */
|
|
25
|
+
previewToken?: string;
|
|
26
|
+
/** Env: BACKMATTER_HOST_TOKEN. When set with apiUrl, the schema snapshot syncs on boot. */
|
|
27
|
+
hostToken?: string;
|
|
28
|
+
/** Git branch for the schema sync. Env: BACKMATTER_GIT_BRANCH / VERCEL_GIT_COMMIT_REF. */
|
|
29
|
+
branch?: string;
|
|
30
|
+
/**
|
|
31
|
+
* Public origin of this host (no trailing slash). Used to register the
|
|
32
|
+
* revalidate webhook. Env: BACKMATTER_SITE_URL / VERCEL_* .
|
|
33
|
+
*/
|
|
34
|
+
siteUrl?: string;
|
|
35
|
+
/** Site root for filesystem mode. Default: process.cwd(). */
|
|
36
|
+
siteRoot?: string;
|
|
37
|
+
/** Locales the catch-all route accepts in `{locale}`. Unset → any segment. */
|
|
38
|
+
locales?: readonly string[];
|
|
39
|
+
/** Locale used by routes without a `{locale}` placeholder. Default: "en". */
|
|
40
|
+
defaultLocale?: string;
|
|
41
|
+
dirs?: BackmatterDirs;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type ResolvedConfig = Omit<BackmatterConfig, "dirs" | "defaultLocale"> & {
|
|
45
|
+
defaultLocale: string;
|
|
46
|
+
dirs: Required<BackmatterDirs>;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const DEFAULT_DIRS: Required<BackmatterDirs> = {
|
|
50
|
+
models: "models",
|
|
51
|
+
blocks: "blocks",
|
|
52
|
+
mdx: "mdx",
|
|
53
|
+
layouts: "layouts",
|
|
54
|
+
};
|
|
55
|
+
export const DEFAULT_LOCALE = "en";
|
|
56
|
+
|
|
57
|
+
function withHttps(url: string): string {
|
|
58
|
+
if (url.startsWith("http://") || url.startsWith("https://")) return url;
|
|
59
|
+
return `https://${url}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function stripTrailingSlash(url: string): string {
|
|
63
|
+
return url.endsWith("/") ? url.slice(0, -1) : url;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Public origin of the host that should receive `content.committed`. */
|
|
67
|
+
export function detectSiteUrl(
|
|
68
|
+
config: { siteUrl?: string } = {},
|
|
69
|
+
env: Record<string, string | undefined> = {},
|
|
70
|
+
): string | undefined {
|
|
71
|
+
const explicit = config.siteUrl?.trim() || env.BACKMATTER_SITE_URL?.trim();
|
|
72
|
+
if (explicit) return stripTrailingSlash(withHttps(explicit));
|
|
73
|
+
const production = env.VERCEL_PROJECT_PRODUCTION_URL?.trim();
|
|
74
|
+
if (env.VERCEL_ENV === "production" && production) {
|
|
75
|
+
return stripTrailingSlash(withHttps(production));
|
|
76
|
+
}
|
|
77
|
+
const vercel = env.VERCEL_URL?.trim();
|
|
78
|
+
if (vercel) return stripTrailingSlash(withHttps(vercel));
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Identity helper that gives `backmatter.config.ts` a typed default export. */
|
|
83
|
+
export function defineConfig(config: BackmatterConfig): BackmatterConfig {
|
|
84
|
+
if (config.defaultLocale !== undefined && !config.defaultLocale) {
|
|
85
|
+
throw new Error("config.defaultLocale must be a non-empty string");
|
|
86
|
+
}
|
|
87
|
+
if (config.locales && config.defaultLocale && !config.locales.includes(config.defaultLocale)) {
|
|
88
|
+
throw new Error(`config.defaultLocale "${config.defaultLocale}" is not in config.locales`);
|
|
89
|
+
}
|
|
90
|
+
return config;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function envOf(): Record<string, string | undefined> {
|
|
94
|
+
return typeof process === "undefined" ? {} : process.env;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Explicit config wins; env vars fill the gaps; folder defaults last. */
|
|
98
|
+
export function resolveConfig(
|
|
99
|
+
config: BackmatterConfig = {},
|
|
100
|
+
env: Record<string, string | undefined> = envOf(),
|
|
101
|
+
): ResolvedConfig {
|
|
102
|
+
const pick = (value: string | undefined, key: string) => {
|
|
103
|
+
if (value !== undefined) return value;
|
|
104
|
+
const fromEnv = env[key]?.trim();
|
|
105
|
+
return fromEnv ? fromEnv : undefined;
|
|
106
|
+
};
|
|
107
|
+
return {
|
|
108
|
+
workspaceId: pick(config.workspaceId, "BACKMATTER_WORKSPACE_ID"),
|
|
109
|
+
apiUrl: pick(config.apiUrl, "BACKMATTER_API_URL"),
|
|
110
|
+
contentToken: pick(config.contentToken, "BACKMATTER_CONTENT_TOKEN"),
|
|
111
|
+
previewToken: pick(config.previewToken, "BACKMATTER_PREVIEW_TOKEN"),
|
|
112
|
+
hostToken: pick(config.hostToken, "BACKMATTER_HOST_TOKEN"),
|
|
113
|
+
branch: config.branch?.trim() || detectGitBranch(env) || undefined,
|
|
114
|
+
siteUrl: detectSiteUrl(config, env),
|
|
115
|
+
siteRoot: config.siteRoot,
|
|
116
|
+
locales: config.locales,
|
|
117
|
+
defaultLocale: config.defaultLocale ?? DEFAULT_LOCALE,
|
|
118
|
+
dirs: {
|
|
119
|
+
models: config.dirs?.models ?? DEFAULT_DIRS.models,
|
|
120
|
+
blocks: config.dirs?.blocks ?? DEFAULT_DIRS.blocks,
|
|
121
|
+
mdx: config.dirs?.mdx ?? DEFAULT_DIRS.mdx,
|
|
122
|
+
layouts: config.dirs?.layouts ?? DEFAULT_DIRS.layouts,
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// packages/core/src/content-committed.ts
|
|
2
|
+
// Wire format for "HEAD changed on this branch" — the host revalidate route
|
|
3
|
+
// and the central server share this. No GitHub, no Next imports.
|
|
4
|
+
import { routeFor, type RouteModel } from "./routes";
|
|
5
|
+
|
|
6
|
+
export const CONTENT_COMMITTED_EVENT = "content.committed" as const;
|
|
7
|
+
|
|
8
|
+
export const BACKMATTER_REVALIDATE_PATH = "/api/backmatter/revalidate";
|
|
9
|
+
|
|
10
|
+
export const BACKMATTER_CACHE_TAG = "backmatter";
|
|
11
|
+
|
|
12
|
+
export type ContentCommittedDoc = {
|
|
13
|
+
locale: string;
|
|
14
|
+
type: string;
|
|
15
|
+
slug: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type ContentCommittedEvent = {
|
|
19
|
+
event: typeof CONTENT_COMMITTED_EVENT;
|
|
20
|
+
workspaceId: string;
|
|
21
|
+
branch: string;
|
|
22
|
+
headSha: string | null;
|
|
23
|
+
docs: ContentCommittedDoc[];
|
|
24
|
+
/** True when the whole branch mirror was rebuilt (force push / truncated). */
|
|
25
|
+
full?: boolean;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function revalidateUrl(siteUrl: string): string {
|
|
29
|
+
return `${siteUrl.replace(/\/$/, "")}${BACKMATTER_REVALIDATE_PATH}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function contentCacheTags(locale: string, type: string, slug: string): string[] {
|
|
33
|
+
return [BACKMATTER_CACHE_TAG, `backmatter:${type}`, `backmatter:${locale}:${type}:${slug}`];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function listCacheTags(type?: string): string[] {
|
|
37
|
+
return type ? [BACKMATTER_CACHE_TAG, `backmatter:${type}`] : [BACKMATTER_CACHE_TAG];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Site paths the host should `revalidatePath` for these docs. */
|
|
41
|
+
export function pathsForCommittedDocs(
|
|
42
|
+
models: readonly RouteModel[],
|
|
43
|
+
docs: readonly ContentCommittedDoc[],
|
|
44
|
+
): string[] {
|
|
45
|
+
const byType = new Map(models.map((m) => [m.type, m]));
|
|
46
|
+
const paths: string[] = [];
|
|
47
|
+
const seen = new Set<string>();
|
|
48
|
+
for (const doc of docs) {
|
|
49
|
+
const model = byType.get(doc.type);
|
|
50
|
+
if (!model) continue;
|
|
51
|
+
const path = routeFor(model, doc.locale, doc.slug);
|
|
52
|
+
if (seen.has(path)) continue;
|
|
53
|
+
seen.add(path);
|
|
54
|
+
paths.push(path);
|
|
55
|
+
}
|
|
56
|
+
return paths;
|
|
57
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// packages/core/src/doc-format.ts
|
|
2
|
+
import { parseMdx, serializeMdx, type Frontmatter, type ParsedMdx } from "./mdx";
|
|
3
|
+
|
|
4
|
+
export type DocFormat = "mdx" | "page";
|
|
5
|
+
|
|
6
|
+
export function modelFormat(model: { format?: string } | null | undefined): DocFormat {
|
|
7
|
+
return model?.format === "page" ? "page" : "mdx";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function parseDoc(raw: string, format: DocFormat = "mdx"): ParsedMdx {
|
|
11
|
+
if (format !== "page") return parseMdx(raw);
|
|
12
|
+
let value: unknown;
|
|
13
|
+
try {
|
|
14
|
+
value = JSON.parse(raw);
|
|
15
|
+
} catch (err) {
|
|
16
|
+
const msg = err instanceof Error ? err.message : "invalid JSON";
|
|
17
|
+
throw new Error(`JSON: ${msg}`);
|
|
18
|
+
}
|
|
19
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
20
|
+
throw new Error("page document must be a JSON object");
|
|
21
|
+
}
|
|
22
|
+
return { frontmatter: value as Frontmatter, body: "" };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function serializeDoc(
|
|
26
|
+
format: DocFormat,
|
|
27
|
+
frontmatter: Frontmatter,
|
|
28
|
+
body: string,
|
|
29
|
+
): string {
|
|
30
|
+
if (format !== "page") return serializeMdx(frontmatter, body);
|
|
31
|
+
return `${JSON.stringify(frontmatter, null, 2)}\n`;
|
|
32
|
+
}
|
package/src/doc-ref.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export type DocRef = {
|
|
2
|
+
workspaceId: string;
|
|
3
|
+
locale: string;
|
|
4
|
+
type: string;
|
|
5
|
+
slug: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export function parseDocRef(input: unknown): DocRef {
|
|
9
|
+
if (!input || typeof input !== "object") {
|
|
10
|
+
throw new Error("docRef must be an object");
|
|
11
|
+
}
|
|
12
|
+
const value = input as Record<string, unknown>;
|
|
13
|
+
for (const key of ["workspaceId", "locale", "type", "slug"] as const) {
|
|
14
|
+
if (typeof value[key] !== "string" || value[key].length === 0) {
|
|
15
|
+
throw new Error(`docRef.${key} is required`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
workspaceId: value.workspaceId as string,
|
|
20
|
+
locale: value.locale as string,
|
|
21
|
+
type: value.type as string,
|
|
22
|
+
slug: value.slug as string,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Relative content path (no workspace prefix) used under head/ and trees/{userId}/. */
|
|
27
|
+
export function docRefPath(ref: DocRef): string {
|
|
28
|
+
return `${ref.locale}/${ref.type}/${ref.slug}`;
|
|
29
|
+
}
|
package/src/doc.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// packages/core/src/doc.ts
|
|
2
|
+
|
|
3
|
+
export type BackmatterDocSummary = {
|
|
4
|
+
locale: string;
|
|
5
|
+
type: string;
|
|
6
|
+
slug: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type BackmatterDoc = {
|
|
10
|
+
locale: string;
|
|
11
|
+
type: string;
|
|
12
|
+
slug: string;
|
|
13
|
+
/** `head` = committed on the branch; `tree` = the caller's uncommitted working copy. */
|
|
14
|
+
source: "head" | "tree";
|
|
15
|
+
frontmatter: Record<string, unknown>;
|
|
16
|
+
body: string;
|
|
17
|
+
raw: string;
|
|
18
|
+
};
|
package/src/fields.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// packages/core/src/fields.ts
|
|
2
|
+
|
|
3
|
+
export type FieldKind =
|
|
4
|
+
| "text"
|
|
5
|
+
| "slug"
|
|
6
|
+
| "locale"
|
|
7
|
+
| "image"
|
|
8
|
+
| "relation"
|
|
9
|
+
| "number"
|
|
10
|
+
| "boolean"
|
|
11
|
+
| "blocks";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Field definition. Generic parameters only exist for type inference
|
|
15
|
+
* (`InferFrontmatter`); the runtime object is `{ kind, required?, relationTo? }`.
|
|
16
|
+
*/
|
|
17
|
+
export type FieldDef<
|
|
18
|
+
K extends FieldKind = FieldKind,
|
|
19
|
+
R extends boolean = boolean,
|
|
20
|
+
To extends string = string,
|
|
21
|
+
> = {
|
|
22
|
+
kind: K;
|
|
23
|
+
required?: R;
|
|
24
|
+
/** For relation fields: target model type. */
|
|
25
|
+
relationTo?: To;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type FieldSnapshot = {
|
|
29
|
+
kind: FieldKind;
|
|
30
|
+
required?: boolean;
|
|
31
|
+
relationTo?: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type Opts<R extends boolean> = { required?: R };
|
|
35
|
+
|
|
36
|
+
function base<K extends FieldKind, R extends boolean, To extends string>(
|
|
37
|
+
kind: K,
|
|
38
|
+
opts?: { required?: R; relationTo?: To },
|
|
39
|
+
): FieldDef<K, R, To> {
|
|
40
|
+
return {
|
|
41
|
+
kind,
|
|
42
|
+
...(opts?.required ? { required: opts.required } : {}),
|
|
43
|
+
...(opts?.relationTo ? { relationTo: opts.relationTo } : {}),
|
|
44
|
+
} as FieldDef<K, R, To>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const field = {
|
|
48
|
+
text: <const R extends boolean = false>(opts?: Opts<R>) => base("text", opts),
|
|
49
|
+
slug: <const R extends boolean = false>(opts?: Opts<R>) => base("slug", opts),
|
|
50
|
+
locale: <const R extends boolean = false>(opts?: Opts<R>) => base("locale", opts),
|
|
51
|
+
image: <const R extends boolean = false>(opts?: Opts<R>) => base("image", opts),
|
|
52
|
+
number: <const R extends boolean = false>(opts?: Opts<R>) => base("number", opts),
|
|
53
|
+
boolean: <const R extends boolean = false>(opts?: Opts<R>) => base("boolean", opts),
|
|
54
|
+
relation: <To extends string, const R extends boolean = false>(to: To, opts?: Opts<R>) =>
|
|
55
|
+
base("relation", { ...opts, relationTo: to }),
|
|
56
|
+
blocks: <const R extends boolean = false>(opts?: Opts<R>) => base("blocks", opts),
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export function serializeField(def: FieldDef): FieldSnapshot {
|
|
60
|
+
return {
|
|
61
|
+
kind: def.kind,
|
|
62
|
+
...(def.required ? { required: true } : {}),
|
|
63
|
+
...(def.relationTo ? { relationTo: def.relationTo } : {}),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function parseFieldSnapshot(input: unknown): FieldSnapshot {
|
|
68
|
+
if (!input || typeof input !== "object") {
|
|
69
|
+
throw new Error("field must be an object");
|
|
70
|
+
}
|
|
71
|
+
const value = input as Record<string, unknown>;
|
|
72
|
+
const kind = value.kind;
|
|
73
|
+
const allowed: FieldKind[] = [
|
|
74
|
+
"text",
|
|
75
|
+
"slug",
|
|
76
|
+
"locale",
|
|
77
|
+
"image",
|
|
78
|
+
"relation",
|
|
79
|
+
"number",
|
|
80
|
+
"boolean",
|
|
81
|
+
"blocks",
|
|
82
|
+
];
|
|
83
|
+
if (typeof kind !== "string" || !allowed.includes(kind as FieldKind)) {
|
|
84
|
+
throw new Error(`invalid field.kind: ${String(kind)}`);
|
|
85
|
+
}
|
|
86
|
+
const snap: FieldSnapshot = { kind: kind as FieldKind };
|
|
87
|
+
if (value.required === true) snap.required = true;
|
|
88
|
+
if (typeof value.relationTo === "string" && value.relationTo.length > 0) {
|
|
89
|
+
snap.relationTo = value.relationTo;
|
|
90
|
+
}
|
|
91
|
+
return snap;
|
|
92
|
+
}
|