@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 +2 -0
- package/package.json +35 -0
- package/src/api-loader.ts +72 -0
- package/src/config.ts +111 -0
- package/src/content-committed.ts +57 -0
- package/src/doc-ref.ts +29 -0
- package/src/doc.ts +18 -0
- package/src/fields.ts +89 -0
- package/src/index.ts +95 -0
- package/src/mdx.ts +44 -0
- package/src/model.ts +182 -0
- package/src/node/cli.ts +387 -0
- package/src/node/create-llmcms.ts +125 -0
- package/src/node/discover.ts +116 -0
- package/src/node/fs-loader.ts +89 -0
- package/src/node/generate.ts +158 -0
- package/src/node/index.ts +36 -0
- package/src/path.ts +179 -0
- package/src/preview-hotkeys.ts +71 -0
- package/src/preview.ts +127 -0
- package/src/query.ts +152 -0
- package/src/routes.ts +102 -0
- package/src/sync-schema.ts +50 -0
- package/src/validate.ts +83 -0
- package/src/zod.ts +74 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// packages/core/src/node/discover.ts
|
|
2
|
+
// Folder conventions: `models/{type}.ts` (default export = defineModel) and
|
|
3
|
+
// `blocks/{Name}.tsx` (default export = React component). Listing is pure
|
|
4
|
+
// filesystem so it can run inside next.config; importing needs Bun/TS.
|
|
5
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { pathToFileURL } from "node:url";
|
|
8
|
+
import type { ModelDef } from "../model";
|
|
9
|
+
|
|
10
|
+
export type ModelFile = {
|
|
11
|
+
/** Absolute path. */
|
|
12
|
+
file: string;
|
|
13
|
+
/** File stem, also the expected `model.type`. */
|
|
14
|
+
stem: string;
|
|
15
|
+
/** Valid JS identifier derived from the stem, used in generated imports. */
|
|
16
|
+
importName: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type BlockFile = {
|
|
20
|
+
file: string;
|
|
21
|
+
/** File stem, also the MDX tag name. */
|
|
22
|
+
name: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const SOURCE_EXT = /\.(ts|tsx|js|jsx|mjs)$/;
|
|
26
|
+
const IGNORED = /(\.test\.|\.spec\.|\.d\.ts$|\.stories\.)/;
|
|
27
|
+
|
|
28
|
+
export function toIdentifier(stem: string): string {
|
|
29
|
+
const camel = stem
|
|
30
|
+
.split(/[^A-Za-z0-9]+/)
|
|
31
|
+
.filter(Boolean)
|
|
32
|
+
.map((part, i) => (i === 0 ? part : part[0]!.toUpperCase() + part.slice(1)))
|
|
33
|
+
.join("");
|
|
34
|
+
const safe = camel.replace(/^[^A-Za-z_$]/, (c) => `_${c}`);
|
|
35
|
+
return safe || "_";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function listSourceFiles(dir: string): string[] {
|
|
39
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) return [];
|
|
40
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
41
|
+
.filter((e) => e.isFile() && SOURCE_EXT.test(e.name) && !IGNORED.test(e.name))
|
|
42
|
+
.filter((e) => !e.name.startsWith("_") && !e.name.startsWith("."))
|
|
43
|
+
.map((e) => path.join(dir, e.name))
|
|
44
|
+
.sort();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function listModelFiles(root: string, modelsDir = "models"): ModelFile[] {
|
|
48
|
+
const files = listSourceFiles(path.resolve(root, modelsDir));
|
|
49
|
+
const seen = new Map<string, string>();
|
|
50
|
+
return files.map((file) => {
|
|
51
|
+
const stem = path.basename(file).replace(SOURCE_EXT, "");
|
|
52
|
+
const importName = toIdentifier(stem);
|
|
53
|
+
const clash = seen.get(importName);
|
|
54
|
+
if (clash) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`models/${path.basename(clash)} and models/${path.basename(file)} both map to "${importName}"`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
seen.set(importName, file);
|
|
60
|
+
return { file, stem, importName };
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const JSX_TAG = /^[A-Z][A-Za-z0-9]*$/;
|
|
65
|
+
|
|
66
|
+
export function listBlockFiles(
|
|
67
|
+
root: string,
|
|
68
|
+
blocksDir = "blocks",
|
|
69
|
+
): { blocks: BlockFile[]; skipped: string[] } {
|
|
70
|
+
const blocks: BlockFile[] = [];
|
|
71
|
+
const skipped: string[] = [];
|
|
72
|
+
for (const file of listSourceFiles(path.resolve(root, blocksDir))) {
|
|
73
|
+
const name = path.basename(file).replace(SOURCE_EXT, "");
|
|
74
|
+
if (JSX_TAG.test(name)) blocks.push({ file, name });
|
|
75
|
+
else skipped.push(path.relative(root, file));
|
|
76
|
+
}
|
|
77
|
+
return { blocks, skipped };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isModelDef(value: unknown): value is ModelDef {
|
|
81
|
+
if (!value || typeof value !== "object") return false;
|
|
82
|
+
const m = value as Record<string, unknown>;
|
|
83
|
+
return typeof m.type === "string" && typeof m.path === "string" && typeof m.fields === "object";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Import every model file and return its default export. The file stem must
|
|
88
|
+
* equal `model.type` so `content/{locale}/{type}/` and `models/{type}.ts` line up.
|
|
89
|
+
*/
|
|
90
|
+
export async function discoverModels(root: string, modelsDir = "models"): Promise<ModelDef[]> {
|
|
91
|
+
const models: ModelDef[] = [];
|
|
92
|
+
const types = new Set<string>();
|
|
93
|
+
for (const { file, stem } of listModelFiles(root, modelsDir)) {
|
|
94
|
+
const rel = path.relative(root, file);
|
|
95
|
+
// CLI-only runtime import. The ignore hints stop webpack/Turbopack from
|
|
96
|
+
// trying to bundle an expression request when this module ends up in a
|
|
97
|
+
// host app graph via `@llm-cms/core/node`.
|
|
98
|
+
const mod = (await import(
|
|
99
|
+
/* webpackIgnore: true */ /* turbopackIgnore: true */ pathToFileURL(file).href
|
|
100
|
+
)) as { default?: unknown };
|
|
101
|
+
if (!isModelDef(mod.default)) {
|
|
102
|
+
throw new Error(`${rel} must \`export default defineModel({ ... })\``);
|
|
103
|
+
}
|
|
104
|
+
if (mod.default.type !== stem) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`${rel} defines type "${mod.default.type}" but the file is named "${stem}"; rename one so they match`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
if (types.has(mod.default.type)) {
|
|
110
|
+
throw new Error(`duplicate model type "${mod.default.type}" (${rel})`);
|
|
111
|
+
}
|
|
112
|
+
types.add(mod.default.type);
|
|
113
|
+
models.push(mod.default);
|
|
114
|
+
}
|
|
115
|
+
return models;
|
|
116
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// packages/core/src/node/fs-loader.ts
|
|
2
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
matchAnyModelPath,
|
|
6
|
+
parseMdx,
|
|
7
|
+
repoPathFromModel,
|
|
8
|
+
type ModelSnapshot,
|
|
9
|
+
} from "../index";
|
|
10
|
+
import type { LlmcmsDoc, LlmcmsDocSummary } from "../doc";
|
|
11
|
+
|
|
12
|
+
const SKIP_DIRS = new Set(["node_modules", ".next", ".git"]);
|
|
13
|
+
|
|
14
|
+
export async function walkMdxFiles(rootDir: string): Promise<string[]> {
|
|
15
|
+
const out: string[] = [];
|
|
16
|
+
|
|
17
|
+
async function walk(dir: string) {
|
|
18
|
+
let entries;
|
|
19
|
+
try {
|
|
20
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
21
|
+
} catch {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
for (const entry of entries) {
|
|
25
|
+
if (entry.isDirectory() && SKIP_DIRS.has(entry.name)) continue;
|
|
26
|
+
const full = path.join(dir, entry.name);
|
|
27
|
+
if (entry.isDirectory()) {
|
|
28
|
+
await walk(full);
|
|
29
|
+
} else if (entry.isFile() && entry.name.endsWith(".mdx")) {
|
|
30
|
+
out.push(full);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
await walk(rootDir);
|
|
36
|
+
return out.sort();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function listDocsFromFs(
|
|
40
|
+
siteRoot: string,
|
|
41
|
+
models: ModelSnapshot[],
|
|
42
|
+
): Promise<LlmcmsDocSummary[]> {
|
|
43
|
+
const files = await walkMdxFiles(siteRoot);
|
|
44
|
+
const docs: LlmcmsDocSummary[] = [];
|
|
45
|
+
for (const full of files) {
|
|
46
|
+
const rel = path.relative(siteRoot, full).split(path.sep).join("/");
|
|
47
|
+
const hit = matchAnyModelPath(models, rel);
|
|
48
|
+
if (!hit) continue;
|
|
49
|
+
docs.push({
|
|
50
|
+
locale: hit.locale,
|
|
51
|
+
type: hit.type,
|
|
52
|
+
slug: hit.slug,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return docs.sort((a, b) => {
|
|
56
|
+
const ak = `${a.locale}/${a.type}/${a.slug}`;
|
|
57
|
+
const bk = `${b.locale}/${b.type}/${b.slug}`;
|
|
58
|
+
return ak.localeCompare(bk);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function getDocFromFs(
|
|
63
|
+
siteRoot: string,
|
|
64
|
+
models: ModelSnapshot[],
|
|
65
|
+
locale: string,
|
|
66
|
+
type: string,
|
|
67
|
+
slug: string,
|
|
68
|
+
): Promise<LlmcmsDoc | null> {
|
|
69
|
+
const model = models.find((m) => m.type === type);
|
|
70
|
+
if (!model) return null;
|
|
71
|
+
const rel = repoPathFromModel(model, locale, slug);
|
|
72
|
+
const full = path.join(siteRoot, rel);
|
|
73
|
+
let raw: string;
|
|
74
|
+
try {
|
|
75
|
+
raw = await readFile(full, "utf-8");
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
const parsed = parseMdx(raw);
|
|
80
|
+
return {
|
|
81
|
+
locale,
|
|
82
|
+
type,
|
|
83
|
+
slug,
|
|
84
|
+
source: "head",
|
|
85
|
+
frontmatter: parsed.frontmatter,
|
|
86
|
+
body: parsed.body,
|
|
87
|
+
raw,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// packages/core/src/node/generate.ts
|
|
2
|
+
// Writes the `.llmcms/` registry: a models barrel + typed `cms` client, and a
|
|
3
|
+
// blocks barrel. Pure filesystem (no TS import) so it can run inside
|
|
4
|
+
// next.config and on every file change in dev.
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { DEFAULT_DIRS } from "../config";
|
|
8
|
+
import { listBlockFiles, listModelFiles, type BlockFile, type ModelFile } from "./discover";
|
|
9
|
+
|
|
10
|
+
// Re-exported so `@llm-cms/core/generate` is a self-sufficient, dependency-light
|
|
11
|
+
// entry for bundler-side code (no zod / yaml pulled in).
|
|
12
|
+
export { DEFAULT_DIRS };
|
|
13
|
+
|
|
14
|
+
export const GENERATED_HEADER = "// Generated by llmcms — do not edit. Re-run `llmcms generate`.";
|
|
15
|
+
export const DEFAULT_OUT_DIR = ".llmcms";
|
|
16
|
+
export const CONFIG_FILES = ["llmcms.config.ts", "llmcms.config.mts", "llmcms.config.js", "llmcms.config.mjs"];
|
|
17
|
+
|
|
18
|
+
export type GenerateOptions = {
|
|
19
|
+
/** Customer repo root. */
|
|
20
|
+
root: string;
|
|
21
|
+
/** Where to write. Default: ".llmcms". */
|
|
22
|
+
outDir?: string;
|
|
23
|
+
modelsDir?: string;
|
|
24
|
+
blocksDir?: string;
|
|
25
|
+
/** Module that exports `createLlmcms`. Auto-detected from package.json when omitted. */
|
|
26
|
+
sdk?: string;
|
|
27
|
+
/** Write to disk. Default: true. `false` only renders (used by the bundler loader). */
|
|
28
|
+
write?: boolean;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type GeneratedFile = {
|
|
32
|
+
/** Absolute path. */
|
|
33
|
+
file: string;
|
|
34
|
+
/** Root-relative path with `/` separators. */
|
|
35
|
+
rel: string;
|
|
36
|
+
content: string;
|
|
37
|
+
/** True when the on-disk content differed (or the file was missing). */
|
|
38
|
+
changed: boolean;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type GenerateResult = {
|
|
42
|
+
/** Every registry file, rendered. */
|
|
43
|
+
outputs: GeneratedFile[];
|
|
44
|
+
/** Root-relative paths of all registry files. */
|
|
45
|
+
files: string[];
|
|
46
|
+
/** Root-relative paths whose content differed from disk. */
|
|
47
|
+
changed: string[];
|
|
48
|
+
models: ModelFile[];
|
|
49
|
+
blocks: BlockFile[];
|
|
50
|
+
skippedBlocks: string[];
|
|
51
|
+
/** Absolute path of `llmcms.config.*` when present. */
|
|
52
|
+
configFile: string | null;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
function importSpecifier(fromDir: string, file: string): string {
|
|
56
|
+
const rel = path.relative(fromDir, file).split(path.sep).join("/").replace(/\.(ts|tsx|mts|js|jsx|mjs)$/, "");
|
|
57
|
+
return rel.startsWith(".") ? rel : `./${rel}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function findConfigFile(root: string): string | null {
|
|
61
|
+
for (const name of CONFIG_FILES) {
|
|
62
|
+
const full = path.join(root, name);
|
|
63
|
+
if (existsSync(full)) return full;
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** `@llm-cms/next` when the host depends on it, otherwise `@llm-cms/core/node`. */
|
|
69
|
+
export function detectSdk(root: string): string {
|
|
70
|
+
try {
|
|
71
|
+
const pkg = JSON.parse(readFileSync(path.join(root, "package.json"), "utf-8")) as {
|
|
72
|
+
dependencies?: Record<string, string>;
|
|
73
|
+
devDependencies?: Record<string, string>;
|
|
74
|
+
};
|
|
75
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
76
|
+
if (deps["@llm-cms/next"]) return "@llm-cms/next";
|
|
77
|
+
} catch {
|
|
78
|
+
// no package.json → core
|
|
79
|
+
}
|
|
80
|
+
return "@llm-cms/core/node";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function renderModelsBarrel(input: {
|
|
84
|
+
outDir: string;
|
|
85
|
+
models: ModelFile[];
|
|
86
|
+
configFile: string | null;
|
|
87
|
+
sdk: string;
|
|
88
|
+
}): string {
|
|
89
|
+
const lines = [GENERATED_HEADER, `import { createLlmcms } from "${input.sdk}";`];
|
|
90
|
+
if (input.configFile) {
|
|
91
|
+
lines.push(`import config from "${importSpecifier(input.outDir, input.configFile)}";`);
|
|
92
|
+
} else {
|
|
93
|
+
lines.push("const config = {};");
|
|
94
|
+
}
|
|
95
|
+
for (const m of input.models) {
|
|
96
|
+
lines.push(`import ${m.importName} from "${importSpecifier(input.outDir, m.file)}";`);
|
|
97
|
+
}
|
|
98
|
+
lines.push("");
|
|
99
|
+
lines.push(`export const models = [${input.models.map((m) => m.importName).join(", ")}] as const;`);
|
|
100
|
+
lines.push("export type Models = typeof models;");
|
|
101
|
+
lines.push("");
|
|
102
|
+
lines.push("/** Typed content client: cms.query.{type}, cms.getDoc, cms.listDocs. */");
|
|
103
|
+
lines.push("export const cms = createLlmcms({ ...config, models });");
|
|
104
|
+
return `${lines.join("\n")}\n`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function renderBlocksBarrel(input: { outDir: string; blocks: BlockFile[] }): string {
|
|
108
|
+
const lines = [GENERATED_HEADER];
|
|
109
|
+
for (const b of input.blocks) {
|
|
110
|
+
lines.push(`import ${b.name} from "${importSpecifier(input.outDir, b.file)}";`);
|
|
111
|
+
}
|
|
112
|
+
lines.push("");
|
|
113
|
+
lines.push("/** MDX component map: <Hero /> in content resolves to blocks/Hero.tsx. */");
|
|
114
|
+
const names = input.blocks.map((b) => b.name).join(", ");
|
|
115
|
+
lines.push(`export const blocks = ${names ? `{ ${names} }` : "{}"};`);
|
|
116
|
+
return `${lines.join("\n")}\n`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function differsOnDisk(file: string, content: string): boolean {
|
|
120
|
+
return !existsSync(file) || readFileSync(file, "utf-8") !== content;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Registry file names inside the out dir. */
|
|
124
|
+
export const REGISTRY_FILES = ["index.ts", "blocks.ts"] as const;
|
|
125
|
+
|
|
126
|
+
export function generate(options: GenerateOptions): GenerateResult {
|
|
127
|
+
const root = path.resolve(options.root);
|
|
128
|
+
const outDir = path.resolve(root, options.outDir ?? DEFAULT_OUT_DIR);
|
|
129
|
+
const models = listModelFiles(root, options.modelsDir ?? DEFAULT_DIRS.models);
|
|
130
|
+
const { blocks, skipped } = listBlockFiles(root, options.blocksDir ?? DEFAULT_DIRS.blocks);
|
|
131
|
+
const sdk = options.sdk ?? detectSdk(root);
|
|
132
|
+
const configFile = findConfigFile(root);
|
|
133
|
+
const write = options.write ?? true;
|
|
134
|
+
|
|
135
|
+
const rendered: Record<(typeof REGISTRY_FILES)[number], string> = {
|
|
136
|
+
"index.ts": renderModelsBarrel({ outDir, models, configFile, sdk }),
|
|
137
|
+
"blocks.ts": renderBlocksBarrel({ outDir, blocks }),
|
|
138
|
+
};
|
|
139
|
+
const outputs: GeneratedFile[] = REGISTRY_FILES.map((name) => {
|
|
140
|
+
const file = path.join(outDir, name);
|
|
141
|
+
const content = rendered[name];
|
|
142
|
+
const changed = differsOnDisk(file, content);
|
|
143
|
+
if (changed && write) {
|
|
144
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
145
|
+
writeFileSync(file, content);
|
|
146
|
+
}
|
|
147
|
+
return { file, rel: path.relative(root, file).split(path.sep).join("/"), content, changed };
|
|
148
|
+
});
|
|
149
|
+
return {
|
|
150
|
+
outputs,
|
|
151
|
+
files: outputs.map((o) => o.rel),
|
|
152
|
+
changed: outputs.filter((o) => o.changed).map((o) => o.rel),
|
|
153
|
+
models,
|
|
154
|
+
blocks,
|
|
155
|
+
skippedBlocks: skipped,
|
|
156
|
+
configFile,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// packages/core/src/node/index.ts
|
|
2
|
+
// Node/Bun-only entry: filesystem loaders, createLlmcms, folder discovery,
|
|
3
|
+
// codegen and the CLI helpers. Browser code must import `@llm-cms/core` or
|
|
4
|
+
// `@llm-cms/core/preview` instead.
|
|
5
|
+
export { createLlmcms } from "./create-llmcms";
|
|
6
|
+
export type { CreateLlmcmsOptions, LlmcmsClient } from "./create-llmcms";
|
|
7
|
+
export { getDocFromFs, listDocsFromFs, walkMdxFiles } from "./fs-loader";
|
|
8
|
+
export {
|
|
9
|
+
discoverModels,
|
|
10
|
+
listBlockFiles,
|
|
11
|
+
listModelFiles,
|
|
12
|
+
toIdentifier,
|
|
13
|
+
} from "./discover";
|
|
14
|
+
export type { BlockFile, ModelFile } from "./discover";
|
|
15
|
+
export {
|
|
16
|
+
detectSdk,
|
|
17
|
+
findConfigFile,
|
|
18
|
+
generate,
|
|
19
|
+
renderBlocksBarrel,
|
|
20
|
+
renderModelsBarrel,
|
|
21
|
+
DEFAULT_OUT_DIR,
|
|
22
|
+
REGISTRY_FILES,
|
|
23
|
+
} from "./generate";
|
|
24
|
+
export type { GenerateOptions, GenerateResult, GeneratedFile } from "./generate";
|
|
25
|
+
export {
|
|
26
|
+
contentRoots,
|
|
27
|
+
formatResults,
|
|
28
|
+
loadConfig,
|
|
29
|
+
loadModels,
|
|
30
|
+
parseArgs,
|
|
31
|
+
runGenerate,
|
|
32
|
+
runInit,
|
|
33
|
+
runSchema,
|
|
34
|
+
runValidate,
|
|
35
|
+
} from "./cli";
|
|
36
|
+
export type { CliArgs } from "./cli";
|
package/src/path.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// packages/core/src/path.ts
|
|
2
|
+
import type { ModelSnapshot } from "./model";
|
|
3
|
+
|
|
4
|
+
export type PathMatch = {
|
|
5
|
+
locale: string;
|
|
6
|
+
slug: string;
|
|
7
|
+
type: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
function escapeRegex(s: string): string {
|
|
11
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Compile a model path template into a regex with named locale/slug captures. */
|
|
15
|
+
export function compilePathPattern(template: string): RegExp {
|
|
16
|
+
const parts = template.split(/(\{locale\}|\{slug\})/g);
|
|
17
|
+
let pattern = "^";
|
|
18
|
+
for (const part of parts) {
|
|
19
|
+
if (part === "{locale}") pattern += "(?<locale>[^/]+)";
|
|
20
|
+
else if (part === "{slug}") pattern += "(?<slug>[^/]+?)";
|
|
21
|
+
else pattern += escapeRegex(part);
|
|
22
|
+
}
|
|
23
|
+
pattern += "$";
|
|
24
|
+
return new RegExp(pattern);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function matchModelPath(
|
|
28
|
+
model: Pick<ModelSnapshot, "type" | "path">,
|
|
29
|
+
repoPath: string,
|
|
30
|
+
): PathMatch | null {
|
|
31
|
+
const normalized = repoPath.replace(/^\/+/, "");
|
|
32
|
+
const re = compilePathPattern(model.path);
|
|
33
|
+
const m = re.exec(normalized);
|
|
34
|
+
if (!m?.groups?.locale || !m.groups.slug) return null;
|
|
35
|
+
// Strip trailing .mdx from slug if the template baked the extension into the capture.
|
|
36
|
+
let slug = m.groups.slug;
|
|
37
|
+
if (model.path.endsWith(".mdx") && slug.endsWith(".mdx")) {
|
|
38
|
+
slug = slug.slice(0, -".mdx".length);
|
|
39
|
+
}
|
|
40
|
+
if (!slug) return null;
|
|
41
|
+
return { locale: m.groups.locale, slug, type: model.type };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function matchAnyModelPath(
|
|
45
|
+
models: Array<Pick<ModelSnapshot, "type" | "path">>,
|
|
46
|
+
repoPath: string,
|
|
47
|
+
): PathMatch | null {
|
|
48
|
+
for (const model of models) {
|
|
49
|
+
const hit = matchModelPath(model, repoPath);
|
|
50
|
+
if (hit) return hit;
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function repoPathFromModel(
|
|
56
|
+
model: Pick<ModelSnapshot, "path">,
|
|
57
|
+
locale: string,
|
|
58
|
+
slug: string,
|
|
59
|
+
): string {
|
|
60
|
+
return model.path
|
|
61
|
+
.replaceAll("{locale}", locale)
|
|
62
|
+
.replaceAll("{slug}", slug);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// S3 layout (git working tree model)
|
|
67
|
+
//
|
|
68
|
+
// workspaces/{ws}/branches/{branch}/head/{locale}/{type}/{slug}.mdx
|
|
69
|
+
// mirror of the git branch HEAD — what the public site reads
|
|
70
|
+
// workspaces/{ws}/branches/{branch}/trees/{userId}/{locale}/{type}/{slug}.mdx
|
|
71
|
+
// one user's uncommitted file on that branch
|
|
72
|
+
// workspaces/{ws}/branches/{branch}/meta/shas/{locale}/{type}/{slug}.json
|
|
73
|
+
// HEAD blob sha per file (conflict base)
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
/** Where a doc's bytes came from: the branch HEAD mirror or the caller's working tree. */
|
|
77
|
+
export type ContentSource = "head" | "tree";
|
|
78
|
+
|
|
79
|
+
export function encodeGitBranch(branch: string): string {
|
|
80
|
+
return encodeURIComponent(branch);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function decodeGitBranch(encoded: string): string {
|
|
84
|
+
return decodeURIComponent(encoded);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function branchRoot(workspaceId: string, branch: string): string {
|
|
88
|
+
return `workspaces/${workspaceId}/branches/${encodeGitBranch(branch)}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function s3HeadPrefix(workspaceId: string, branch: string): string {
|
|
92
|
+
return `${branchRoot(workspaceId, branch)}/head/`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function s3TreePrefix(
|
|
96
|
+
workspaceId: string,
|
|
97
|
+
branch: string,
|
|
98
|
+
userId: string,
|
|
99
|
+
): string {
|
|
100
|
+
return `${branchRoot(workspaceId, branch)}/trees/${encodeURIComponent(userId)}/`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function s3HeadKey(
|
|
104
|
+
workspaceId: string,
|
|
105
|
+
branch: string,
|
|
106
|
+
locale: string,
|
|
107
|
+
type: string,
|
|
108
|
+
slug: string,
|
|
109
|
+
): string {
|
|
110
|
+
return `${s3HeadPrefix(workspaceId, branch)}${locale}/${type}/${slug}.mdx`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function s3TreeKey(
|
|
114
|
+
workspaceId: string,
|
|
115
|
+
branch: string,
|
|
116
|
+
userId: string,
|
|
117
|
+
locale: string,
|
|
118
|
+
type: string,
|
|
119
|
+
slug: string,
|
|
120
|
+
): string {
|
|
121
|
+
return `${s3TreePrefix(workspaceId, branch, userId)}${locale}/${type}/${slug}.mdx`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** HEAD blob sha for conflict checks. */
|
|
125
|
+
export function s3ShaMetaKey(
|
|
126
|
+
workspaceId: string,
|
|
127
|
+
branch: string,
|
|
128
|
+
locale: string,
|
|
129
|
+
type: string,
|
|
130
|
+
slug: string,
|
|
131
|
+
): string {
|
|
132
|
+
return `${branchRoot(workspaceId, branch)}/meta/shas/${locale}/${type}/${slug}.json`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export type ParsedContentKey = {
|
|
136
|
+
branch: string;
|
|
137
|
+
source: ContentSource;
|
|
138
|
+
/** Owner of the working tree; null for head keys. */
|
|
139
|
+
userId: string | null;
|
|
140
|
+
locale: string;
|
|
141
|
+
type: string;
|
|
142
|
+
slug: string;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/** Parse a head/ or trees/{userId}/ key into identity parts. */
|
|
146
|
+
export function parseS3ContentKey(
|
|
147
|
+
workspaceId: string,
|
|
148
|
+
key: string,
|
|
149
|
+
): ParsedContentKey | null {
|
|
150
|
+
const prefix = `workspaces/${workspaceId}/branches/`;
|
|
151
|
+
if (!key.startsWith(prefix)) return null;
|
|
152
|
+
const rest = key.slice(prefix.length);
|
|
153
|
+
|
|
154
|
+
const head = /^([^/]+)\/head\/([^/]+)\/([^/]+)\/(.+)\.mdx$/.exec(rest);
|
|
155
|
+
if (head) {
|
|
156
|
+
return {
|
|
157
|
+
branch: decodeGitBranch(head[1]!),
|
|
158
|
+
source: "head",
|
|
159
|
+
userId: null,
|
|
160
|
+
locale: head[2]!,
|
|
161
|
+
type: head[3]!,
|
|
162
|
+
slug: head[4]!,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const tree =
|
|
167
|
+
/^([^/]+)\/trees\/([^/]+)\/([^/]+)\/([^/]+)\/(.+)\.mdx$/.exec(rest);
|
|
168
|
+
if (tree) {
|
|
169
|
+
return {
|
|
170
|
+
branch: decodeGitBranch(tree[1]!),
|
|
171
|
+
source: "tree",
|
|
172
|
+
userId: decodeURIComponent(tree[2]!),
|
|
173
|
+
locale: tree[3]!,
|
|
174
|
+
type: tree[4]!,
|
|
175
|
+
slug: tree[5]!,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// packages/core/src/preview-hotkeys.ts
|
|
2
|
+
export const PREVIEW_HOTKEY_TYPE = "llmcms:hotkey";
|
|
3
|
+
|
|
4
|
+
export type PreviewHotkeyMessage = {
|
|
5
|
+
type: typeof PREVIEW_HOTKEY_TYPE;
|
|
6
|
+
key: string;
|
|
7
|
+
metaKey: boolean;
|
|
8
|
+
ctrlKey: boolean;
|
|
9
|
+
shiftKey: boolean;
|
|
10
|
+
altKey: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type KeyboardHotkeyLike = {
|
|
14
|
+
key: string;
|
|
15
|
+
metaKey: boolean;
|
|
16
|
+
ctrlKey: boolean;
|
|
17
|
+
shiftKey: boolean;
|
|
18
|
+
altKey: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const FORWARDED = new Set(["s", "k"]);
|
|
22
|
+
|
|
23
|
+
export function previewHotkeyFromKeyboardEvent(
|
|
24
|
+
event: KeyboardHotkeyLike,
|
|
25
|
+
): PreviewHotkeyMessage | null {
|
|
26
|
+
const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
|
|
27
|
+
if (!(event.metaKey || event.ctrlKey)) return null;
|
|
28
|
+
if (!FORWARDED.has(key)) return null;
|
|
29
|
+
return {
|
|
30
|
+
type: PREVIEW_HOTKEY_TYPE,
|
|
31
|
+
key,
|
|
32
|
+
metaKey: event.metaKey,
|
|
33
|
+
ctrlKey: event.ctrlKey,
|
|
34
|
+
shiftKey: event.shiftKey,
|
|
35
|
+
altKey: event.altKey,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function parsePreviewHotkey(data: unknown): PreviewHotkeyMessage | null {
|
|
40
|
+
if (!data || typeof data !== "object") return null;
|
|
41
|
+
const record = data as Record<string, unknown>;
|
|
42
|
+
if (record.type !== PREVIEW_HOTKEY_TYPE) return null;
|
|
43
|
+
if (typeof record.key !== "string") return null;
|
|
44
|
+
return {
|
|
45
|
+
type: PREVIEW_HOTKEY_TYPE,
|
|
46
|
+
key: record.key,
|
|
47
|
+
metaKey: Boolean(record.metaKey),
|
|
48
|
+
ctrlKey: Boolean(record.ctrlKey),
|
|
49
|
+
shiftKey: Boolean(record.shiftKey),
|
|
50
|
+
altKey: Boolean(record.altKey),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Forward cmd/ctrl+s and cmd/ctrl+k to the parent CMS when this page is iframed. */
|
|
55
|
+
export function installPreviewHotkeys(): () => void {
|
|
56
|
+
function onKey(event: KeyboardEvent) {
|
|
57
|
+
if (window.parent === window) return;
|
|
58
|
+
const message = previewHotkeyFromKeyboardEvent(event);
|
|
59
|
+
if (!message) return;
|
|
60
|
+
event.preventDefault();
|
|
61
|
+
let target = "*";
|
|
62
|
+
try {
|
|
63
|
+
if (document.referrer) target = new URL(document.referrer).origin;
|
|
64
|
+
} catch {
|
|
65
|
+
target = "*";
|
|
66
|
+
}
|
|
67
|
+
window.parent.postMessage(message, target);
|
|
68
|
+
}
|
|
69
|
+
window.addEventListener("keydown", onKey);
|
|
70
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
71
|
+
}
|