@usegraft/compiler 0.0.0-canary-20260831153011

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Anderson Joseph
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,44 @@
1
+ # @usegraft/compiler
2
+
3
+ > Validate authored MDX against your collections and project it into the content index. The bridge that makes file-authored content queryable.
4
+
5
+ Part of [Graft](https://github.com/AndersonDesign1/graft), a CMS built so an AI agent is the primary operator.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm i @usegraft/compiler
11
+ ```
12
+
13
+ Most projects use `graft compile` from [`@usegraft/cli`](https://www.npmjs.com/package/@usegraft/cli) rather than calling this directly.
14
+
15
+ ## Compile
16
+
17
+ ```ts
18
+ import { compile, compileStatic } from "@usegraft/compiler";
19
+
20
+ // into Postgres
21
+ const result = await compile({ contentDir, collections, db, branchId: "main" });
22
+
23
+ // or into a SQLite artifact, with no database at all
24
+ const result = await compileStatic({ contentDir, collections, indexPath: ".graft/index.db" });
25
+ ```
26
+
27
+ Both return a `ChangeSet`: what was added, changed and removed, plus the git SHA the tree was compiled from. Projection is a content-hash diff, not a delete-and-rebuild, so unchanged rows are untouched and removals are a soft delete.
28
+
29
+ ## What it refuses, and why
30
+
31
+ `readDocs` validates every authored file before anything reaches the index:
32
+
33
+ - Frontmatter against the collection schema.
34
+ - Slug uniqueness within a collection.
35
+ - Files sitting outside a registered collection folder.
36
+ - **Executable MDX.** `{…}` expressions, `import`, `export` and spread attributes are refused, because rendering evaluates them as JavaScript on the server.
37
+
38
+ Every offending document is reported at once rather than the first found, because an author fixing them one at a time learns the rule slowly.
39
+
40
+ Set `export const mdxTrust = "full"` in `graft.config.ts` to allow full MDX. That is correct only where every author of the repository has commit access, since code review then really is the control. It defaults to `"restricted"`, matching `MdxBody`, so a document that compiles is a document that renders.
41
+
42
+ ---
43
+
44
+ MIT. [Repository](https://github.com/AndersonDesign1/graft) · [Changelog](https://github.com/AndersonDesign1/graft/blob/main/packages/compiler/CHANGELOG.md)
@@ -0,0 +1,167 @@
1
+ import { AnyCollection } from '@usegraft/core';
2
+ import { MdxTrust } from '@usegraft/mdx-safety';
3
+ import { Database, ChangeSet } from '@usegraft/db';
4
+
5
+ /** Kebab-case: URL-safe, unambiguous in paths, and stable as a primary-key part. */
6
+ /** URL-safe kebab-case: the only shape a slug may take. */
7
+ declare const SLUG_RE: RegExp;
8
+ /** One asset reference found in a document's frontmatter. */
9
+ interface DocAsset {
10
+ /** The field the reference came from. */
11
+ field: string;
12
+ /** Key of the binary in the asset store. */
13
+ key: string;
14
+ alt?: string;
15
+ }
16
+ interface ProjectedDoc {
17
+ collection: string;
18
+ slug: string;
19
+ data: Record<string, unknown>;
20
+ body: string;
21
+ contentHash: string;
22
+ sourcePath: string;
23
+ /** Asset references indexed from `asset` fields (validated by the schema). */
24
+ assets: DocAsset[];
25
+ }
26
+ declare function parseDocument(raw: string, collection: AnyCollection, sourcePath: string): ProjectedDoc;
27
+
28
+ /** How THIS surface tells a caller to do the thing the error is about. */
29
+ interface ContentHints {
30
+ /** How to see what collections exist, e.g. "see list_collections". */
31
+ listCollections?: string;
32
+ /** How to create a document, e.g. "author it with write_content". */
33
+ authorDocument?: string;
34
+ }
35
+ /** Every `.md`/`.mdx` file under `dir`, recursively, in stable order. */
36
+ declare function walkContentFiles(dir: string): string[];
37
+ /** Resolve a registered collection, or throw a miss the caller can act on. */
38
+ declare function requireCollection(collections: Record<string, AnyCollection>, name: string, hints?: ContentHints): AnyCollection;
39
+ /** Parse every document in one collection's folder. Empty if it does not exist. */
40
+ declare function readCollectionDocs(contentDir: string, collectionName: string, collection: AnyCollection): ProjectedDoc[];
41
+ /** Find one document by slug, or throw DOCUMENT_NOT_FOUND listing the real ones. */
42
+ declare function findDoc(contentDir: string, collectionName: string, collection: AnyCollection, slug: string, hints?: ContentHints): ProjectedDoc;
43
+
44
+ interface ContainOptions {
45
+ /** What the path names, for the error message (default: "path"). */
46
+ label?: string;
47
+ /**
48
+ * Follow symlinks instead of refusing them. Only for paths the operator
49
+ * typed, never for anything a request or a manifest supplied.
50
+ */
51
+ allowSymlinks?: boolean;
52
+ /**
53
+ * Accept an absolute path, provided it still resolves inside the root.
54
+ *
55
+ * For a local agent naming a file on its own machine an absolute path is the
56
+ * natural form, and containment is what matters, not the spelling. Leave this
57
+ * off for anything arriving in a request field, where "relative to the root"
58
+ * is the contract and an absolute path means the caller misunderstood — or is
59
+ * probing.
60
+ */
61
+ allowAbsolute?: boolean;
62
+ }
63
+ /**
64
+ * Resolve `path` against `root` and return it only if it genuinely lives there.
65
+ *
66
+ * Refuses absolute paths, NUL bytes, anything that escapes the root once
67
+ * normalised, and — unless `allowSymlinks` — any segment that is a symbolic
68
+ * link, whether or not its target escapes. A symlink inside the tree is a
69
+ * redirection the caller did not get to choose, so the honest answer is no.
70
+ */
71
+ declare function resolveContained(root: string, path: string, options?: ContainOptions): string;
72
+
73
+ interface CompileOptions {
74
+ contentDir: string;
75
+ collections: Record<string, AnyCollection>;
76
+ db: Database;
77
+ branchId?: string;
78
+ /**
79
+ * Git commit recorded with this compilation. Omit to auto-resolve from
80
+ * `contentDir` (tolerant: records null outside a git repo); pass null to skip.
81
+ */
82
+ gitSha?: string | null;
83
+ /**
84
+ * Also remove index rows in collections this schema doesn't know. Off by
85
+ * default: purging unknown collections is the signature of two projects
86
+ * sharing one DATABASE_URL, so projection aborts with INDEX_OWNERSHIP
87
+ * unless this is set (legitimate after a collection rename/delete).
88
+ */
89
+ pruneUnknown?: boolean;
90
+ /**
91
+ * How much of MDX authored bodies may be. Defaults to "restricted", matching
92
+ * MdxBody, so a document that compiles is a document that renders. From
93
+ * `export const mdxTrust` in graft.config.ts.
94
+ */
95
+ mdxTrust?: MdxTrust;
96
+ }
97
+ interface CompileResult {
98
+ count: number;
99
+ docs: ProjectedDoc[];
100
+ /** What this run did to the index: added / changed / removed keys + unchanged count. */
101
+ changes: ChangeSet;
102
+ /** The commit the content tree was compiled from, when resolvable. */
103
+ gitSha: string | null;
104
+ }
105
+ interface ReadDocsOptions {
106
+ /**
107
+ * How much of MDX authored bodies may be. Defaults to "restricted", which is
108
+ * also MdxBody's default, so a document that compiles is a document that
109
+ * renders. Set "full" only where every author has commit access.
110
+ */
111
+ mdxTrust?: MdxTrust;
112
+ }
113
+ /** Read + validate the content tree into normalized docs. Pure of the database. */
114
+ declare function readDocs(contentDir: string, collections: Record<string, AnyCollection>, options?: ReadDocsOptions): ProjectedDoc[];
115
+ declare function compile(options: CompileOptions): Promise<CompileResult>;
116
+ interface CompileStaticOptions {
117
+ contentDir: string;
118
+ collections: Record<string, AnyCollection>;
119
+ /** Artifact path, e.g. <project>/.graft/index.db. */
120
+ indexPath: string;
121
+ /** As in CompileOptions: omit to auto-resolve, null to skip. */
122
+ gitSha?: string | null;
123
+ /**
124
+ * How much of MDX authored bodies may be. Defaults to "restricted", matching
125
+ * MdxBody, so a document that compiles is a document that renders. From
126
+ * `export const mdxTrust` in graft.config.ts.
127
+ */
128
+ mdxTrust?: MdxTrust;
129
+ }
130
+ /**
131
+ * The static-mode sibling of `compile`: same validate step (readDocs), but the
132
+ * projection target is the SQLite artifact — no database, no env. Branching is
133
+ * git's job here, so there is no branchId; the artifact records "main".
134
+ */
135
+ declare function compileStatic(options: CompileStaticOptions): Promise<CompileResult>;
136
+ /** HEAD commit of the repo containing `cwd`, or null (no git, no repo — never throws). */
137
+ declare function resolveGitSha(cwd: string): string | null;
138
+
139
+ /**
140
+ * Write a document's bytes, translating a read-only filesystem into an error
141
+ * that explains itself. Serverless platforms deploy an immutable filesystem, so
142
+ * a Studio or MCP write served from one fails deep inside fs with an opaque
143
+ * errno; authored content being files is the whole model, so the fix is to run
144
+ * the writing surface where the checkout is writable.
145
+ *
146
+ * Takes a root and a relative path rather than a resolved one, and contains it
147
+ * here, because this sink previously trusted whatever path it was handed and
148
+ * `.greptile/rules.md` told reviewers that every sink in this package did its
149
+ * own containment. Studio resolved carefully; MCP's `write_content` did not,
150
+ * and a slug of `"../../escaped"` wrote outside the content tree — the exact
151
+ * bug the rule claimed was impossible. A guarantee that lives in each caller is
152
+ * one a new caller does not inherit.
153
+ *
154
+ * Returns the resolved path, so a caller that needs it does not re-derive it.
155
+ */
156
+ declare function writeDocumentFile(root: string, sourcePath: string, raw: string): string;
157
+ /**
158
+ * The bytes to write for a document.
159
+ *
160
+ * @param existingRaw The file's current contents, or undefined for a new file.
161
+ * @returns the existing frontmatter with only genuinely changed keys rewritten,
162
+ * plus the new body. Falls back to a full `matter.stringify` when the block
163
+ * cannot be patched safely.
164
+ */
165
+ declare function composeDocument(existingRaw: string | undefined, data: Record<string, unknown>, body: string): string;
166
+
167
+ export { type CompileOptions, type CompileResult, type CompileStaticOptions, type ContainOptions, type ContentHints, type DocAsset, type ProjectedDoc, type ReadDocsOptions, SLUG_RE, compile, compileStatic, composeDocument, findDoc, parseDocument, readCollectionDocs, readDocs, requireCollection, resolveContained, resolveGitSha, walkContentFiles, writeDocumentFile };
package/dist/index.js ADDED
@@ -0,0 +1,434 @@
1
+ // src/parse.ts
2
+ import { createHash } from "crypto";
3
+ import { basename } from "path";
4
+ import { GraftError } from "@usegraft/contracts";
5
+ import matter from "gray-matter";
6
+ var SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
7
+ function parseDocument(raw, collection, sourcePath) {
8
+ const { data: frontmatter, content } = matter(raw);
9
+ const result = collection.schema.safeParse(frontmatter);
10
+ if (!result.success) {
11
+ const issues = result.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
12
+ throw new GraftError({
13
+ code: "SCHEMA_VALIDATION_FAILED",
14
+ message: `${sourcePath} does not match collection "${collection.name}"`,
15
+ fix: `Update the frontmatter to satisfy the "${collection.name}" schema \u2014 ${issues}`,
16
+ details: { sourcePath, issues: result.error.issues }
17
+ });
18
+ }
19
+ const slugValue = frontmatter.slug;
20
+ const slug = typeof slugValue === "string" && slugValue.length > 0 ? slugValue : basename(sourcePath).replace(/\.mdx?$/, "");
21
+ if (!SLUG_RE.test(slug)) {
22
+ throw new GraftError({
23
+ code: "INVALID_SLUG",
24
+ message: `Slug "${slug}" (${sourcePath}) is not URL-safe`,
25
+ fix: `Slugs must be kebab-case: lowercase letters, digits, and single hyphens (e.g. "getting-started"). Set a valid \`slug\` in frontmatter or rename the file.`,
26
+ details: { slug, sourcePath, pattern: SLUG_RE.source }
27
+ });
28
+ }
29
+ const data = result.data;
30
+ return {
31
+ collection: collection.name,
32
+ slug,
33
+ data,
34
+ body: content.trim(),
35
+ contentHash: createHash("sha256").update(raw).digest("hex"),
36
+ sourcePath,
37
+ assets: collectAssets(collection, data)
38
+ };
39
+ }
40
+ function collectAssets(collection, data) {
41
+ const assets = [];
42
+ for (const [name, definition] of Object.entries(collection.fields)) {
43
+ if (definition.type !== "asset") continue;
44
+ const value = data[name];
45
+ if (value === void 0 || value === null) continue;
46
+ const ref = value;
47
+ assets.push({ field: name, key: ref.key, ...ref.alt !== void 0 && { alt: ref.alt } });
48
+ }
49
+ return assets;
50
+ }
51
+
52
+ // src/content-files.ts
53
+ import { existsSync, readdirSync, readFileSync, statSync } from "fs";
54
+ import { join, relative, sep } from "path";
55
+ import { GraftError as GraftError2 } from "@usegraft/contracts";
56
+ function walkContentFiles(dir) {
57
+ const out = [];
58
+ for (const name of readdirSync(dir)) {
59
+ const full = join(dir, name);
60
+ if (statSync(full).isDirectory()) out.push(...walkContentFiles(full));
61
+ else if (/\.mdx?$/.test(name)) out.push(full);
62
+ }
63
+ return out.sort();
64
+ }
65
+ function requireCollection(collections, name, hints = {}) {
66
+ const collection = collections[name];
67
+ if (collection) return collection;
68
+ const registered = Object.keys(collections);
69
+ const known = registered.join(", ") || "(none registered)";
70
+ const where = hints.listCollections ? ` (${hints.listCollections})` : "";
71
+ throw new GraftError2({
72
+ code: "COLLECTION_NOT_FOUND",
73
+ message: `No collection named "${name}" is registered`,
74
+ fix: `Use one of the registered collections: ${known}${where}, or add defineCollection({ name: "${name}", \u2026 }) to the schema.`,
75
+ details: { collection: name, registered }
76
+ });
77
+ }
78
+ function readCollectionDocs(contentDir, collectionName, collection) {
79
+ const dir = join(contentDir, collectionName);
80
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) return [];
81
+ const docs = [];
82
+ for (const file of walkContentFiles(dir)) {
83
+ const sourcePath = relative(contentDir, file).split(sep).join("/");
84
+ docs.push(parseDocument(readFileSync(file, "utf8"), collection, sourcePath));
85
+ }
86
+ return docs.sort((a, b) => a.slug.localeCompare(b.slug));
87
+ }
88
+ function findDoc(contentDir, collectionName, collection, slug, hints = {}) {
89
+ const docs = readCollectionDocs(contentDir, collectionName, collection);
90
+ const doc = docs.find((candidate) => candidate.slug === slug);
91
+ if (doc) return doc;
92
+ const slugs = docs.map((candidate) => candidate.slug);
93
+ const author = hints.authorDocument ? ` ${hints.authorDocument}` : "";
94
+ throw new GraftError2({
95
+ code: "DOCUMENT_NOT_FOUND",
96
+ message: `No document "${slug}" in collection "${collectionName}"`,
97
+ fix: slugs.length > 0 ? `Existing slugs: ${slugs.join(", ")}. Use one of those,${author || " or create it."}` : `Collection "${collectionName}" has no documents yet \u2014${author || " create the first one."}`,
98
+ details: { collection: collectionName, slug, existing: slugs }
99
+ });
100
+ }
101
+
102
+ // src/paths.ts
103
+ import { existsSync as existsSync2, lstatSync } from "fs";
104
+ import { isAbsolute, normalize, resolve, sep as sep2 } from "path";
105
+ import { GraftError as GraftError3 } from "@usegraft/contracts";
106
+ function refuse(label, path, why, fix) {
107
+ throw new GraftError3({
108
+ code: "INPUT_VALIDATION_FAILED",
109
+ message: `${label} "${path}" ${why}.`,
110
+ fix,
111
+ details: { path }
112
+ });
113
+ }
114
+ function resolveContained(root, path, options = {}) {
115
+ const label = options.label ?? "path";
116
+ const rootAbs = resolve(root);
117
+ if (!path || path.includes("\0")) {
118
+ refuse(
119
+ label,
120
+ path,
121
+ "is empty or contains a NUL byte",
122
+ "Pass a relative path with no NUL bytes."
123
+ );
124
+ }
125
+ if (isAbsolute(path) && options.allowAbsolute !== true) {
126
+ refuse(label, path, "is absolute", `Pass a path relative to ${rootAbs}.`);
127
+ }
128
+ const candidate = isAbsolute(path) ? resolve(normalize(path)) : resolve(rootAbs, normalize(path));
129
+ if (candidate !== rootAbs && !candidate.startsWith(rootAbs + sep2)) {
130
+ refuse(
131
+ label,
132
+ path,
133
+ "resolves outside the permitted directory",
134
+ `Pass a path inside ${rootAbs}.`
135
+ );
136
+ }
137
+ if (options.allowSymlinks !== true) {
138
+ let cursor = rootAbs;
139
+ for (const segment of candidate.slice(rootAbs.length).split(sep2).filter(Boolean)) {
140
+ cursor = resolve(cursor, segment);
141
+ if (existsSync2(cursor) && lstatSync(cursor).isSymbolicLink()) {
142
+ refuse(
143
+ label,
144
+ path,
145
+ "passes through a symbolic link",
146
+ "Symbolic links are refused because they redirect a path somewhere the caller did not name. Replace the link with a real file, or read the target directly."
147
+ );
148
+ }
149
+ }
150
+ }
151
+ return candidate;
152
+ }
153
+
154
+ // src/compile.ts
155
+ import { execFileSync } from "child_process";
156
+ import { existsSync as existsSync3, readFileSync as readFileSync2, statSync as statSync2 } from "fs";
157
+ import { relative as relative2, sep as sep3 } from "path";
158
+ import { GraftError as GraftError4 } from "@usegraft/contracts";
159
+ import { findExecutableMdx, UncheckableMdxError } from "@usegraft/mdx-safety";
160
+ import {
161
+ projectBranchContent,
162
+ projectStaticContent
163
+ } from "@usegraft/db";
164
+ function readDocs(contentDir, collections, options = {}) {
165
+ if (!existsSync3(contentDir) || !statSync2(contentDir).isDirectory()) {
166
+ throw new GraftError4({
167
+ code: "CONTENT_DIR_NOT_FOUND",
168
+ message: `Content directory not found: ${contentDir}`,
169
+ fix: `Create it (documents live at <contentDir>/<collection>/<slug>.mdx, e.g. content/pages/home.mdx) or pass the correct \`contentDir\` to compile().`,
170
+ details: { contentDir, collections: Object.keys(collections) }
171
+ });
172
+ }
173
+ const docs = [];
174
+ const seen = /* @__PURE__ */ new Map();
175
+ const executable = [];
176
+ const unreadable = [];
177
+ const mdxTrust = options.mdxTrust ?? "restricted";
178
+ for (const file of walkContentFiles(contentDir)) {
179
+ const sourcePath = relative2(contentDir, file).split(sep3).join("/");
180
+ const segments = sourcePath.split("/");
181
+ const collectionName = segments.length > 1 ? segments[0] ?? "" : "";
182
+ const collection = collections[collectionName];
183
+ if (!collection) {
184
+ const known = Object.keys(collections).join(", ") || "(none registered)";
185
+ throw new GraftError4({
186
+ code: "COLLECTION_NOT_FOUND",
187
+ message: segments.length > 1 ? `No collection registered for "${collectionName}" (${sourcePath})` : `${sourcePath} sits at the content root; documents must live in a collection folder`,
188
+ fix: segments.length > 1 ? `Register it \u2014 defineCollection({ name: "${collectionName}", fields: { \u2026 } }) and pass it to compile() \u2014 or move the file into one of: ${known}.` : `Move it to <contentDir>/<collection>/${sourcePath}. Registered collections: ${known}.`,
189
+ details: { sourcePath, collection: collectionName, registered: Object.keys(collections) }
190
+ });
191
+ }
192
+ if (collection.authority === "db-authoritative") {
193
+ throw new GraftError4({
194
+ code: "AUTHORITY_MISMATCH",
195
+ message: `${sourcePath} is a file, but collection "${collectionName}" is db-authoritative \u2014 its records live in Postgres, not in the content tree.`,
196
+ fix: `Remove the file and write the data through the collection's functions instead (insertRecord via a defineFunction mutation). Files are only for file-authoritative collections.`,
197
+ details: { sourcePath, collection: collectionName, authority: collection.authority }
198
+ });
199
+ }
200
+ const doc = parseDocument(readFileSync2(file, "utf8"), collection, sourcePath);
201
+ if (mdxTrust !== "full") {
202
+ try {
203
+ const found = findExecutableMdx(doc.body);
204
+ if (found.length > 0) {
205
+ const first = found[0];
206
+ executable.push({
207
+ sourcePath,
208
+ found: found.length,
209
+ first: first?.line === void 0 ? "" : `line ${first.line}`
210
+ });
211
+ }
212
+ } catch (error) {
213
+ if (!(error instanceof UncheckableMdxError)) throw error;
214
+ unreadable.push({ sourcePath, reason: error.message });
215
+ }
216
+ }
217
+ const key = `${doc.collection}/${doc.slug}`;
218
+ const existing = seen.get(key);
219
+ if (existing) {
220
+ throw new GraftError4({
221
+ code: "SLUG_NOT_UNIQUE",
222
+ message: `Slug "${doc.slug}" in collection "${doc.collection}" is used by both ${existing} and ${sourcePath}`,
223
+ fix: `Give one a unique \`slug\` in frontmatter, or rename the file.`,
224
+ details: { slug: doc.slug, collection: doc.collection, files: [existing, sourcePath] }
225
+ });
226
+ }
227
+ seen.set(key, sourcePath);
228
+ docs.push(doc);
229
+ }
230
+ if (unreadable.length > 0) {
231
+ const listed = unreadable.map((u) => `${u.sourcePath} (${u.reason})`).join(", ");
232
+ throw new GraftError4({
233
+ code: "INPUT_VALIDATION_FAILED",
234
+ message: `${unreadable.length} document(s) could not be checked for executable MDX: ${listed}.`,
235
+ fix: "Fix the MDX syntax so it parses. Unparseable source is refused rather than indexed, because the renderer's parser is not this one and the difference between them is where executable source would hide.",
236
+ details: { unreadable }
237
+ });
238
+ }
239
+ if (executable.length > 0) {
240
+ const listed = executable.slice(0, 10).map((e) => e.first === "" ? e.sourcePath : `${e.sourcePath} (${e.first})`).join(", ");
241
+ const more = executable.length > 10 ? ` \u2026and ${executable.length - 10} more` : "";
242
+ throw new GraftError4({
243
+ code: "INPUT_VALIDATION_FAILED",
244
+ message: `Executable MDX in ${executable.length} document(s): ${listed}${more}.`,
245
+ fix: 'Rendering evaluates `{\u2026}` and `import` as JavaScript on the server, and MdxBody refuses them by default, so these documents would fail per-request rather than here. Write prose, Markdown and components with literal attributes. If every author of this repository has commit access, set `export const mdxTrust = "full"` in graft.config.ts and pass trust="full" to MdxBody. Code review is the control in that case, which is what ADR 0004 assumes.',
246
+ details: { documents: executable.length, offenders: executable.slice(0, 20) }
247
+ });
248
+ }
249
+ return docs.sort(
250
+ (a, b) => a.collection === b.collection ? a.slug.localeCompare(b.slug) : a.collection.localeCompare(b.collection)
251
+ );
252
+ }
253
+ async function compile(options) {
254
+ const docs = readDocs(options.contentDir, options.collections, {
255
+ mdxTrust: options.mdxTrust
256
+ });
257
+ const gitSha = options.gitSha === void 0 ? resolveGitSha(options.contentDir) : options.gitSha;
258
+ const changes = await projectBranchContent(
259
+ options.db,
260
+ docs.map((doc) => ({
261
+ collection: doc.collection,
262
+ slug: doc.slug,
263
+ data: doc.data,
264
+ body: doc.body,
265
+ contentHash: doc.contentHash,
266
+ sourcePath: doc.sourcePath
267
+ })),
268
+ {
269
+ branchId: options.branchId ?? "main",
270
+ gitSha,
271
+ knownCollections: Object.keys(options.collections),
272
+ pruneUnknown: options.pruneUnknown
273
+ }
274
+ );
275
+ return { count: docs.length, docs, changes, gitSha };
276
+ }
277
+ async function compileStatic(options) {
278
+ const docs = readDocs(options.contentDir, options.collections, {
279
+ mdxTrust: options.mdxTrust
280
+ });
281
+ const gitSha = options.gitSha === void 0 ? resolveGitSha(options.contentDir) : options.gitSha;
282
+ const changes = await projectStaticContent(
283
+ docs.map((doc) => ({
284
+ collection: doc.collection,
285
+ slug: doc.slug,
286
+ data: doc.data,
287
+ body: doc.body,
288
+ contentHash: doc.contentHash,
289
+ sourcePath: doc.sourcePath
290
+ })),
291
+ { path: options.indexPath, gitSha }
292
+ );
293
+ return { count: docs.length, docs, changes, gitSha };
294
+ }
295
+ function resolveGitSha(cwd) {
296
+ try {
297
+ return execFileSync("git", ["rev-parse", "HEAD"], {
298
+ cwd,
299
+ encoding: "utf8",
300
+ stdio: ["ignore", "pipe", "ignore"]
301
+ }).trim();
302
+ } catch {
303
+ return null;
304
+ }
305
+ }
306
+
307
+ // src/serialize.ts
308
+ import { mkdirSync, writeFileSync } from "fs";
309
+ import { dirname } from "path";
310
+ import { GraftError as GraftError5 } from "@usegraft/contracts";
311
+ import matter2 from "gray-matter";
312
+ var READ_ONLY_CODES = /* @__PURE__ */ new Set(["EROFS", "EACCES", "EPERM"]);
313
+ function writeDocumentFile(root, sourcePath, raw) {
314
+ const fullPath = resolveContained(root, sourcePath, { label: "document" });
315
+ try {
316
+ mkdirSync(dirname(fullPath), { recursive: true });
317
+ writeFileSync(fullPath, raw);
318
+ return fullPath;
319
+ } catch (error) {
320
+ const code = error.code ?? "";
321
+ if (!READ_ONLY_CODES.has(code)) throw error;
322
+ throw new GraftError5({
323
+ code: "CONTENT_TREE_READ_ONLY",
324
+ message: `Cannot write ${fullPath}: the filesystem refused it (${code}).`,
325
+ fix: "Authored content lives in files, so writing needs a writable checkout. Run this surface locally (`graft studio` / `graft mcp`) or in a container with the project mounted read-write; a serverless deployment's filesystem is read-only and should serve reads only.",
326
+ details: { path: fullPath, errno: code }
327
+ });
328
+ }
329
+ }
330
+ var FRONTMATTER_BLOCK = /^(?---\r?\n[\s\S]*?\r?\n---[ \t]*\r?\n)([\s\S]*)$/;
331
+ function sameData(a, b) {
332
+ if (a === b) return true;
333
+ if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
334
+ if (Array.isArray(a) && Array.isArray(b)) {
335
+ return a.length === b.length && a.every((item, i) => sameData(item, b[i]));
336
+ }
337
+ if (typeof a === "object" && typeof b === "object" && a !== null && b !== null) {
338
+ const aKeys = Object.keys(a);
339
+ const bKeys = Object.keys(b);
340
+ if (aKeys.length !== bKeys.length) return false;
341
+ return aKeys.every(
342
+ (key) => Object.hasOwn(b, key) && sameData(a[key], b[key])
343
+ );
344
+ }
345
+ return false;
346
+ }
347
+ var BLOCK_PARTS = /^(?---[ \t]*\r?\n)([\s\S]*?)(\r?\n---[ \t]*\r?\n)$/;
348
+ var TOP_LEVEL_KEY = /^([A-Za-z0-9_$-]+|"(?:[^"\\]|\\.)*"|'(?:[^']|'')*'):(?:\s|$)/;
349
+ function unquoteKey(key) {
350
+ if (key.startsWith('"') && key.endsWith('"')) return JSON.parse(key);
351
+ if (key.startsWith("'") && key.endsWith("'")) return key.slice(1, -1).replace(/''/g, "'");
352
+ return key;
353
+ }
354
+ function scanKeys(yaml) {
355
+ const lines = yaml.split(/\r?\n/);
356
+ const out = [];
357
+ for (const line of lines) {
358
+ const match = TOP_LEVEL_KEY.exec(line);
359
+ if (match?.[1]) {
360
+ out.push({ key: unquoteKey(match[1]), lines: [line] });
361
+ continue;
362
+ }
363
+ if (out.length === 0) {
364
+ if (line.trim() === "") continue;
365
+ return null;
366
+ }
367
+ out[out.length - 1]?.lines.push(line);
368
+ }
369
+ return out.length > 0 ? out : null;
370
+ }
371
+ function renderKey(key, value) {
372
+ const dumped = matter2.stringify("", { [key]: value });
373
+ const block = FRONTMATTER_BLOCK.exec(dumped)?.[1];
374
+ if (!block) return null;
375
+ const inner = BLOCK_PARTS.exec(block)?.[2];
376
+ if (inner === void 0) return null;
377
+ return inner.split(/\r?\n/);
378
+ }
379
+ function patchFrontmatter(block, original, data, newline) {
380
+ const parts = BLOCK_PARTS.exec(block);
381
+ if (!parts) return null;
382
+ const [, open = "", yaml = "", close = ""] = parts;
383
+ const scanned = scanKeys(yaml);
384
+ if (!scanned) return null;
385
+ const out = [];
386
+ const kept = /* @__PURE__ */ new Set();
387
+ for (const entry of scanned) {
388
+ if (!Object.hasOwn(data, entry.key)) continue;
389
+ kept.add(entry.key);
390
+ if (Object.hasOwn(original, entry.key) && sameData(original[entry.key], data[entry.key])) {
391
+ out.push(...entry.lines);
392
+ continue;
393
+ }
394
+ const rendered = renderKey(entry.key, data[entry.key]);
395
+ if (!rendered) return null;
396
+ out.push(...rendered);
397
+ }
398
+ for (const key of Object.keys(data)) {
399
+ if (kept.has(key)) continue;
400
+ const rendered = renderKey(key, data[key]);
401
+ if (!rendered) return null;
402
+ out.push(...rendered);
403
+ }
404
+ if (out.length === 0) return null;
405
+ return open + out.join(newline) + close;
406
+ }
407
+ function composeDocument(existingRaw, data, body) {
408
+ if (existingRaw === void 0) return matter2.stringify(body, data);
409
+ const match = FRONTMATTER_BLOCK.exec(existingRaw);
410
+ if (!match) return matter2.stringify(body, data);
411
+ const [, block = "", rest = ""] = match;
412
+ const newline = /\r\n/.test(block) ? "\r\n" : "\n";
413
+ const original = matter2(existingRaw).data;
414
+ const frontmatter = sameData(original, data) ? block : patchFrontmatter(block, original, data, newline);
415
+ if (frontmatter === null) return matter2.stringify(body, data);
416
+ const separator = /^\r?\n/.exec(rest)?.[0] ?? "";
417
+ const needsNewline = !body.endsWith("\n") && existingRaw.endsWith("\n");
418
+ return frontmatter + separator + body + (needsNewline ? newline : "");
419
+ }
420
+ export {
421
+ SLUG_RE,
422
+ compile,
423
+ compileStatic,
424
+ composeDocument,
425
+ findDoc,
426
+ parseDocument,
427
+ readCollectionDocs,
428
+ readDocs,
429
+ requireCollection,
430
+ resolveContained,
431
+ resolveGitSha,
432
+ walkContentFiles,
433
+ writeDocumentFile
434
+ };
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@usegraft/compiler",
3
+ "version": "0.0.0-canary-20260831153011",
4
+ "description": "Validate authored MDX against your collections and project it into the content index.",
5
+ "keywords": [
6
+ "agent",
7
+ "ai",
8
+ "cms",
9
+ "compiler",
10
+ "content-index",
11
+ "graft",
12
+ "headless-cms",
13
+ "mcp",
14
+ "mdx",
15
+ "typescript",
16
+ "validation"
17
+ ],
18
+ "homepage": "https://github.com/AndersonDesign1/graft#readme",
19
+ "license": "MIT",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/AndersonDesign1/graft.git",
23
+ "directory": "packages/compiler"
24
+ },
25
+ "files": [
26
+ "dist"
27
+ ],
28
+ "type": "module",
29
+ "main": "./dist/index.js",
30
+ "module": "./dist/index.js",
31
+ "types": "./dist/index.d.ts",
32
+ "exports": {
33
+ ".": {
34
+ "types": "./dist/index.d.ts",
35
+ "import": "./dist/index.js"
36
+ }
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "dependencies": {
42
+ "@usegraft/contracts": "0.0.0-canary-20260831153011",
43
+ "@usegraft/core": "0.0.0-canary-20260831153011",
44
+ "@usegraft/db": "0.0.0-canary-20260831153011",
45
+ "@usegraft/mdx-safety": "0.0.0-canary-20260831153011",
46
+ "gray-matter": "^4.0.3"
47
+ },
48
+ "engines": {
49
+ "node": ">=22.16"
50
+ },
51
+ "scripts": {
52
+ "build": "tsup src/index.ts --format esm --dts --clean",
53
+ "dev": "tsup src/index.ts --format esm --watch",
54
+ "typecheck": "tsc --noEmit",
55
+ "test": "vitest run --passWithNoTests"
56
+ }
57
+ }