@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/bin/llmcms.js ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bun
2
+ import "../src/node/cli.ts";
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@llm-cms/core",
3
+ "version": "0.0.1",
4
+ "description": "Experimental LLMCMS 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
+ "./preview": "./src/preview.ts",
13
+ "./generate": "./src/node/generate.ts"
14
+ },
15
+ "files": [
16
+ "bin",
17
+ "src",
18
+ "!src/**/*.test.ts",
19
+ "!src/**/*.test.tsx",
20
+ "!**/test-fixtures"
21
+ ],
22
+ "bin": {
23
+ "llmcms": "./bin/llmcms.js"
24
+ },
25
+ "scripts": {
26
+ "test": "bun test"
27
+ },
28
+ "dependencies": {
29
+ "yaml": "^2.9.0",
30
+ "zod": "^4.6.1"
31
+ },
32
+ "devDependencies": {
33
+ "@types/bun": "^1.3.14"
34
+ }
35
+ }
@@ -0,0 +1,72 @@
1
+ // packages/core/src/api-loader.ts
2
+ import type { LlmcmsDoc, LlmcmsDocSummary } 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<LlmcmsDocSummary[]> {
43
+ const res = await fetch(publicDocsUrl(apiUrl, workspaceId, "", query), {
44
+ ...fetchInit(contentToken, query.tags),
45
+ });
46
+ if (!res.ok) {
47
+ throw new Error(`llmcms listDocs failed: ${res.status}`);
48
+ }
49
+ const data = (await res.json()) as { docs: LlmcmsDocSummary[] };
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<LlmcmsDoc | 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(`llmcms getDoc failed: ${res.status}`);
69
+ }
70
+ const data = (await res.json()) as { doc: LlmcmsDoc };
71
+ return data.doc;
72
+ }
package/src/config.ts ADDED
@@ -0,0 +1,111 @@
1
+ // packages/core/src/config.ts
2
+ // General host configuration (`llmcms.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 LlmcmsDirs = {
7
+ /** Folder with one `defineModel` default export per file. Default: "models". */
8
+ models?: string;
9
+ /** Folder with one React component default export per file. Default: "blocks". */
10
+ blocks?: string;
11
+ };
12
+
13
+ export type LlmcmsConfig = {
14
+ /** Required only when talking to the LLMCMS API. Env: LLMCMS_WORKSPACE_ID. */
15
+ workspaceId?: string;
16
+ /** Env: LLMCMS_API_URL. When set with workspaceId, published docs load from the API. */
17
+ apiUrl?: string;
18
+ /** Env: LLMCMS_CONTENT_TOKEN. Required only to read non-default branches. */
19
+ contentToken?: string;
20
+ /** Env: LLMCMS_HOST_TOKEN. When set with apiUrl, the schema snapshot syncs on boot. */
21
+ hostToken?: string;
22
+ /** Git branch for the schema sync. Env: LLMCMS_GIT_BRANCH / VERCEL_GIT_COMMIT_REF. */
23
+ branch?: string;
24
+ /**
25
+ * Public origin of this host (no trailing slash). Used to register the
26
+ * revalidate webhook. Env: LLMCMS_SITE_URL / VERCEL_* .
27
+ */
28
+ siteUrl?: string;
29
+ /** Site root for filesystem mode. Default: process.cwd(). */
30
+ siteRoot?: string;
31
+ /** Locales the catch-all route accepts in `{locale}`. Unset → any segment. */
32
+ locales?: readonly string[];
33
+ /** Locale used by routes without a `{locale}` placeholder. Default: "en". */
34
+ defaultLocale?: string;
35
+ dirs?: LlmcmsDirs;
36
+ };
37
+
38
+ export type ResolvedConfig = Omit<LlmcmsConfig, "dirs" | "defaultLocale"> & {
39
+ defaultLocale: string;
40
+ dirs: Required<LlmcmsDirs>;
41
+ };
42
+
43
+ export const DEFAULT_DIRS: Required<LlmcmsDirs> = { models: "models", blocks: "blocks" };
44
+ export const DEFAULT_LOCALE = "en";
45
+
46
+ function withHttps(url: string): string {
47
+ if (url.startsWith("http://") || url.startsWith("https://")) return url;
48
+ return `https://${url}`;
49
+ }
50
+
51
+ function stripTrailingSlash(url: string): string {
52
+ return url.endsWith("/") ? url.slice(0, -1) : url;
53
+ }
54
+
55
+ /** Public origin of the host that should receive `content.committed`. */
56
+ export function detectSiteUrl(
57
+ config: { siteUrl?: string } = {},
58
+ env: Record<string, string | undefined> = {},
59
+ ): string | undefined {
60
+ const explicit = config.siteUrl?.trim() || env.LLMCMS_SITE_URL?.trim();
61
+ if (explicit) return stripTrailingSlash(withHttps(explicit));
62
+ const production = env.VERCEL_PROJECT_PRODUCTION_URL?.trim();
63
+ if (env.VERCEL_ENV === "production" && production) {
64
+ return stripTrailingSlash(withHttps(production));
65
+ }
66
+ const vercel = env.VERCEL_URL?.trim();
67
+ if (vercel) return stripTrailingSlash(withHttps(vercel));
68
+ return undefined;
69
+ }
70
+
71
+ /** Identity helper that gives `llmcms.config.ts` a typed default export. */
72
+ export function defineConfig(config: LlmcmsConfig): LlmcmsConfig {
73
+ if (config.defaultLocale !== undefined && !config.defaultLocale) {
74
+ throw new Error("config.defaultLocale must be a non-empty string");
75
+ }
76
+ if (config.locales && config.defaultLocale && !config.locales.includes(config.defaultLocale)) {
77
+ throw new Error(`config.defaultLocale "${config.defaultLocale}" is not in config.locales`);
78
+ }
79
+ return config;
80
+ }
81
+
82
+ function envOf(): Record<string, string | undefined> {
83
+ return typeof process === "undefined" ? {} : process.env;
84
+ }
85
+
86
+ /** Explicit config wins; env vars fill the gaps; folder defaults last. */
87
+ export function resolveConfig(
88
+ config: LlmcmsConfig = {},
89
+ env: Record<string, string | undefined> = envOf(),
90
+ ): ResolvedConfig {
91
+ const pick = (value: string | undefined, key: string) => {
92
+ if (value !== undefined) return value;
93
+ const fromEnv = env[key]?.trim();
94
+ return fromEnv ? fromEnv : undefined;
95
+ };
96
+ return {
97
+ workspaceId: pick(config.workspaceId, "LLMCMS_WORKSPACE_ID"),
98
+ apiUrl: pick(config.apiUrl, "LLMCMS_API_URL"),
99
+ contentToken: pick(config.contentToken, "LLMCMS_CONTENT_TOKEN"),
100
+ hostToken: pick(config.hostToken, "LLMCMS_HOST_TOKEN"),
101
+ branch: config.branch?.trim() || detectGitBranch(env) || undefined,
102
+ siteUrl: detectSiteUrl(config, env),
103
+ siteRoot: config.siteRoot,
104
+ locales: config.locales,
105
+ defaultLocale: config.defaultLocale ?? DEFAULT_LOCALE,
106
+ dirs: {
107
+ models: config.dirs?.models ?? DEFAULT_DIRS.models,
108
+ blocks: config.dirs?.blocks ?? DEFAULT_DIRS.blocks,
109
+ },
110
+ };
111
+ }
@@ -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 LLMCMS_REVALIDATE_PATH = "/api/llmcms/revalidate";
9
+
10
+ export const LLMCMS_CACHE_TAG = "llmcms";
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(/\/$/, "")}${LLMCMS_REVALIDATE_PATH}`;
30
+ }
31
+
32
+ export function contentCacheTags(locale: string, type: string, slug: string): string[] {
33
+ return [LLMCMS_CACHE_TAG, `llmcms:${type}`, `llmcms:${locale}:${type}:${slug}`];
34
+ }
35
+
36
+ export function listCacheTags(type?: string): string[] {
37
+ return type ? [LLMCMS_CACHE_TAG, `llmcms:${type}`] : [LLMCMS_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
+ }
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 LlmcmsDocSummary = {
4
+ locale: string;
5
+ type: string;
6
+ slug: string;
7
+ };
8
+
9
+ export type LlmcmsDoc = {
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,89 @@
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
+
12
+ /**
13
+ * Field definition. Generic parameters only exist for type inference
14
+ * (`InferFrontmatter`); the runtime object is `{ kind, required?, relationTo? }`.
15
+ */
16
+ export type FieldDef<
17
+ K extends FieldKind = FieldKind,
18
+ R extends boolean = boolean,
19
+ To extends string = string,
20
+ > = {
21
+ kind: K;
22
+ required?: R;
23
+ /** For relation fields: target model type. */
24
+ relationTo?: To;
25
+ };
26
+
27
+ export type FieldSnapshot = {
28
+ kind: FieldKind;
29
+ required?: boolean;
30
+ relationTo?: string;
31
+ };
32
+
33
+ type Opts<R extends boolean> = { required?: R };
34
+
35
+ function base<K extends FieldKind, R extends boolean, To extends string>(
36
+ kind: K,
37
+ opts?: { required?: R; relationTo?: To },
38
+ ): FieldDef<K, R, To> {
39
+ return {
40
+ kind,
41
+ ...(opts?.required ? { required: opts.required } : {}),
42
+ ...(opts?.relationTo ? { relationTo: opts.relationTo } : {}),
43
+ } as FieldDef<K, R, To>;
44
+ }
45
+
46
+ export const field = {
47
+ text: <const R extends boolean = false>(opts?: Opts<R>) => base("text", opts),
48
+ slug: <const R extends boolean = false>(opts?: Opts<R>) => base("slug", opts),
49
+ locale: <const R extends boolean = false>(opts?: Opts<R>) => base("locale", opts),
50
+ image: <const R extends boolean = false>(opts?: Opts<R>) => base("image", opts),
51
+ number: <const R extends boolean = false>(opts?: Opts<R>) => base("number", opts),
52
+ boolean: <const R extends boolean = false>(opts?: Opts<R>) => base("boolean", opts),
53
+ relation: <To extends string, const R extends boolean = false>(to: To, opts?: Opts<R>) =>
54
+ base("relation", { ...opts, relationTo: to }),
55
+ };
56
+
57
+ export function serializeField(def: FieldDef): FieldSnapshot {
58
+ return {
59
+ kind: def.kind,
60
+ ...(def.required ? { required: true } : {}),
61
+ ...(def.relationTo ? { relationTo: def.relationTo } : {}),
62
+ };
63
+ }
64
+
65
+ export function parseFieldSnapshot(input: unknown): FieldSnapshot {
66
+ if (!input || typeof input !== "object") {
67
+ throw new Error("field must be an object");
68
+ }
69
+ const value = input as Record<string, unknown>;
70
+ const kind = value.kind;
71
+ const allowed: FieldKind[] = [
72
+ "text",
73
+ "slug",
74
+ "locale",
75
+ "image",
76
+ "relation",
77
+ "number",
78
+ "boolean",
79
+ ];
80
+ if (typeof kind !== "string" || !allowed.includes(kind as FieldKind)) {
81
+ throw new Error(`invalid field.kind: ${String(kind)}`);
82
+ }
83
+ const snap: FieldSnapshot = { kind: kind as FieldKind };
84
+ if (value.required === true) snap.required = true;
85
+ if (typeof value.relationTo === "string" && value.relationTo.length > 0) {
86
+ snap.relationTo = value.relationTo;
87
+ }
88
+ return snap;
89
+ }
package/src/index.ts ADDED
@@ -0,0 +1,95 @@
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
+ serializeModel,
24
+ serializeSchema,
25
+ parseSchemaSnapshot,
26
+ } from "./model";
27
+
28
+ export type { LlmcmsConfig, LlmcmsDirs, ResolvedConfig } from "./config";
29
+ export { defineConfig, resolveConfig, detectSiteUrl, DEFAULT_DIRS, DEFAULT_LOCALE } from "./config";
30
+
31
+ export type { RouteMatch, RouteModel, MatchRouteOptions } from "./routes";
32
+ export {
33
+ DEFAULT_ROUTE,
34
+ compileRoutePattern,
35
+ matchRoute,
36
+ normalizePathname,
37
+ routeFor,
38
+ routeSegments,
39
+ routeTemplate,
40
+ sortByRouteSpecificity,
41
+ } from "./routes";
42
+
43
+ export type { PathMatch, ContentSource, ParsedContentKey } from "./path";
44
+ export {
45
+ compilePathPattern,
46
+ matchModelPath,
47
+ matchAnyModelPath,
48
+ repoPathFromModel,
49
+ encodeGitBranch,
50
+ decodeGitBranch,
51
+ s3HeadKey,
52
+ s3TreeKey,
53
+ s3HeadPrefix,
54
+ s3TreePrefix,
55
+ s3ShaMetaKey,
56
+ parseS3ContentKey,
57
+ } from "./path";
58
+
59
+ export type { Frontmatter, ParsedMdx } from "./mdx";
60
+ export { parseMdx, serializeMdx } from "./mdx";
61
+
62
+ export type { ValidationResult, ContentFileResult } from "./validate";
63
+ export {
64
+ validateFrontmatter,
65
+ validateMdxFile,
66
+ validateContentTree,
67
+ } from "./validate";
68
+
69
+ export type { FrontmatterJsonSchema } from "./zod";
70
+ export {
71
+ expectedTypeFor,
72
+ frontmatterJsonSchema,
73
+ frontmatterZod,
74
+ normalizeFrontmatter,
75
+ } from "./zod";
76
+
77
+ // Runtime-agnostic content access (no Node builtins). Filesystem loading,
78
+ // createLlmcms and the CLI live in `@llm-cms/core/node`.
79
+ export type { LlmcmsDoc, LlmcmsDocSummary } from "./doc";
80
+ export { createQuery } from "./query";
81
+ export type { Query, ModelQuery, TypedDoc, Where, Include, DocLoader } from "./query";
82
+ export { getDocFromApi, listDocsFromApi } from "./api-loader";
83
+ export type { PublicDocsQuery } from "./api-loader";
84
+ export { syncSchema, detectGitBranch } from "./sync-schema";
85
+ export type { SyncSchemaOptions } from "./sync-schema";
86
+ export type { ContentCommittedDoc, ContentCommittedEvent } from "./content-committed";
87
+ export {
88
+ CONTENT_COMMITTED_EVENT,
89
+ LLMCMS_CACHE_TAG,
90
+ LLMCMS_REVALIDATE_PATH,
91
+ contentCacheTags,
92
+ listCacheTags,
93
+ pathsForCommittedDocs,
94
+ revalidateUrl,
95
+ } 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
+ }