@usegraft/compiler 0.1.1 → 1.0.0-beta.0
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/README.md +44 -0
- package/dist/index.d.ts +82 -3
- package/dist/index.js +169 -25
- package/package.json +21 -6
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)
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { AnyCollection } from '@usegraft/core';
|
|
2
|
+
import { MdxTrust } from '@usegraft/mdx-safety';
|
|
2
3
|
import { Database, ChangeSet } from '@usegraft/db';
|
|
3
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;
|
|
4
8
|
/** One asset reference found in a document's frontmatter. */
|
|
5
9
|
interface DocAsset {
|
|
6
10
|
/** The field the reference came from. */
|
|
@@ -21,6 +25,51 @@ interface ProjectedDoc {
|
|
|
21
25
|
}
|
|
22
26
|
declare function parseDocument(raw: string, collection: AnyCollection, sourcePath: string): ProjectedDoc;
|
|
23
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
|
+
|
|
24
73
|
interface CompileOptions {
|
|
25
74
|
contentDir: string;
|
|
26
75
|
collections: Record<string, AnyCollection>;
|
|
@@ -38,6 +87,12 @@ interface CompileOptions {
|
|
|
38
87
|
* unless this is set (legitimate after a collection rename/delete).
|
|
39
88
|
*/
|
|
40
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;
|
|
41
96
|
}
|
|
42
97
|
interface CompileResult {
|
|
43
98
|
count: number;
|
|
@@ -47,8 +102,16 @@ interface CompileResult {
|
|
|
47
102
|
/** The commit the content tree was compiled from, when resolvable. */
|
|
48
103
|
gitSha: string | null;
|
|
49
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
|
+
}
|
|
50
113
|
/** Read + validate the content tree into normalized docs. Pure of the database. */
|
|
51
|
-
declare function readDocs(contentDir: string, collections: Record<string, AnyCollection
|
|
114
|
+
declare function readDocs(contentDir: string, collections: Record<string, AnyCollection>, options?: ReadDocsOptions): ProjectedDoc[];
|
|
52
115
|
declare function compile(options: CompileOptions): Promise<CompileResult>;
|
|
53
116
|
interface CompileStaticOptions {
|
|
54
117
|
contentDir: string;
|
|
@@ -57,6 +120,12 @@ interface CompileStaticOptions {
|
|
|
57
120
|
indexPath: string;
|
|
58
121
|
/** As in CompileOptions: omit to auto-resolve, null to skip. */
|
|
59
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;
|
|
60
129
|
}
|
|
61
130
|
/**
|
|
62
131
|
* The static-mode sibling of `compile`: same validate step (readDocs), but the
|
|
@@ -73,8 +142,18 @@ declare function resolveGitSha(cwd: string): string | null;
|
|
|
73
142
|
* a Studio or MCP write served from one fails deep inside fs with an opaque
|
|
74
143
|
* errno; authored content being files is the whole model, so the fix is to run
|
|
75
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.
|
|
76
155
|
*/
|
|
77
|
-
declare function writeDocumentFile(
|
|
156
|
+
declare function writeDocumentFile(root: string, sourcePath: string, raw: string): string;
|
|
78
157
|
/**
|
|
79
158
|
* The bytes to write for a document.
|
|
80
159
|
*
|
|
@@ -85,4 +164,4 @@ declare function writeDocumentFile(fullPath: string, raw: string): void;
|
|
|
85
164
|
*/
|
|
86
165
|
declare function composeDocument(existingRaw: string | undefined, data: Record<string, unknown>, body: string): string;
|
|
87
166
|
|
|
88
|
-
export { type CompileOptions, type CompileResult, type CompileStaticOptions, type DocAsset, type ProjectedDoc, compile, compileStatic, composeDocument, parseDocument, readDocs, resolveGitSha, writeDocumentFile };
|
|
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
CHANGED
|
@@ -49,18 +49,121 @@ function collectAssets(collection, data) {
|
|
|
49
49
|
return assets;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
// src/
|
|
53
|
-
import { execFileSync } from "child_process";
|
|
52
|
+
// src/content-files.ts
|
|
54
53
|
import { existsSync, readdirSync, readFileSync, statSync } from "fs";
|
|
55
54
|
import { join, relative, sep } from "path";
|
|
56
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";
|
|
57
160
|
import {
|
|
58
161
|
projectBranchContent,
|
|
59
162
|
projectStaticContent
|
|
60
163
|
} from "@usegraft/db";
|
|
61
|
-
function readDocs(contentDir, collections) {
|
|
62
|
-
if (!
|
|
63
|
-
throw new
|
|
164
|
+
function readDocs(contentDir, collections, options = {}) {
|
|
165
|
+
if (!existsSync3(contentDir) || !statSync2(contentDir).isDirectory()) {
|
|
166
|
+
throw new GraftError4({
|
|
64
167
|
code: "CONTENT_DIR_NOT_FOUND",
|
|
65
168
|
message: `Content directory not found: ${contentDir}`,
|
|
66
169
|
fix: `Create it (documents live at <contentDir>/<collection>/<slug>.mdx, e.g. content/pages/home.mdx) or pass the correct \`contentDir\` to compile().`,
|
|
@@ -69,14 +172,17 @@ function readDocs(contentDir, collections) {
|
|
|
69
172
|
}
|
|
70
173
|
const docs = [];
|
|
71
174
|
const seen = /* @__PURE__ */ new Map();
|
|
72
|
-
|
|
73
|
-
|
|
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("/");
|
|
74
180
|
const segments = sourcePath.split("/");
|
|
75
181
|
const collectionName = segments.length > 1 ? segments[0] ?? "" : "";
|
|
76
182
|
const collection = collections[collectionName];
|
|
77
183
|
if (!collection) {
|
|
78
184
|
const known = Object.keys(collections).join(", ") || "(none registered)";
|
|
79
|
-
throw new
|
|
185
|
+
throw new GraftError4({
|
|
80
186
|
code: "COLLECTION_NOT_FOUND",
|
|
81
187
|
message: segments.length > 1 ? `No collection registered for "${collectionName}" (${sourcePath})` : `${sourcePath} sits at the content root; documents must live in a collection folder`,
|
|
82
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}.`,
|
|
@@ -84,18 +190,34 @@ function readDocs(contentDir, collections) {
|
|
|
84
190
|
});
|
|
85
191
|
}
|
|
86
192
|
if (collection.authority === "db-authoritative") {
|
|
87
|
-
throw new
|
|
193
|
+
throw new GraftError4({
|
|
88
194
|
code: "AUTHORITY_MISMATCH",
|
|
89
195
|
message: `${sourcePath} is a file, but collection "${collectionName}" is db-authoritative \u2014 its records live in Postgres, not in the content tree.`,
|
|
90
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.`,
|
|
91
197
|
details: { sourcePath, collection: collectionName, authority: collection.authority }
|
|
92
198
|
});
|
|
93
199
|
}
|
|
94
|
-
const doc = parseDocument(
|
|
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
|
+
}
|
|
95
217
|
const key = `${doc.collection}/${doc.slug}`;
|
|
96
218
|
const existing = seen.get(key);
|
|
97
219
|
if (existing) {
|
|
98
|
-
throw new
|
|
220
|
+
throw new GraftError4({
|
|
99
221
|
code: "SLUG_NOT_UNIQUE",
|
|
100
222
|
message: `Slug "${doc.slug}" in collection "${doc.collection}" is used by both ${existing} and ${sourcePath}`,
|
|
101
223
|
fix: `Give one a unique \`slug\` in frontmatter, or rename the file.`,
|
|
@@ -105,12 +227,33 @@ function readDocs(contentDir, collections) {
|
|
|
105
227
|
seen.set(key, sourcePath);
|
|
106
228
|
docs.push(doc);
|
|
107
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
|
+
}
|
|
108
249
|
return docs.sort(
|
|
109
250
|
(a, b) => a.collection === b.collection ? a.slug.localeCompare(b.slug) : a.collection.localeCompare(b.collection)
|
|
110
251
|
);
|
|
111
252
|
}
|
|
112
253
|
async function compile(options) {
|
|
113
|
-
const docs = readDocs(options.contentDir, options.collections
|
|
254
|
+
const docs = readDocs(options.contentDir, options.collections, {
|
|
255
|
+
mdxTrust: options.mdxTrust
|
|
256
|
+
});
|
|
114
257
|
const gitSha = options.gitSha === void 0 ? resolveGitSha(options.contentDir) : options.gitSha;
|
|
115
258
|
const changes = await projectBranchContent(
|
|
116
259
|
options.db,
|
|
@@ -132,7 +275,9 @@ async function compile(options) {
|
|
|
132
275
|
return { count: docs.length, docs, changes, gitSha };
|
|
133
276
|
}
|
|
134
277
|
async function compileStatic(options) {
|
|
135
|
-
const docs = readDocs(options.contentDir, options.collections
|
|
278
|
+
const docs = readDocs(options.contentDir, options.collections, {
|
|
279
|
+
mdxTrust: options.mdxTrust
|
|
280
|
+
});
|
|
136
281
|
const gitSha = options.gitSha === void 0 ? resolveGitSha(options.contentDir) : options.gitSha;
|
|
137
282
|
const changes = await projectStaticContent(
|
|
138
283
|
docs.map((doc) => ({
|
|
@@ -158,30 +303,23 @@ function resolveGitSha(cwd) {
|
|
|
158
303
|
return null;
|
|
159
304
|
}
|
|
160
305
|
}
|
|
161
|
-
function walk(dir) {
|
|
162
|
-
const out = [];
|
|
163
|
-
for (const name of readdirSync(dir)) {
|
|
164
|
-
const full = join(dir, name);
|
|
165
|
-
if (statSync(full).isDirectory()) out.push(...walk(full));
|
|
166
|
-
else if (/\.mdx?$/.test(name)) out.push(full);
|
|
167
|
-
}
|
|
168
|
-
return out.sort();
|
|
169
|
-
}
|
|
170
306
|
|
|
171
307
|
// src/serialize.ts
|
|
172
308
|
import { mkdirSync, writeFileSync } from "fs";
|
|
173
309
|
import { dirname } from "path";
|
|
174
|
-
import { GraftError as
|
|
310
|
+
import { GraftError as GraftError5 } from "@usegraft/contracts";
|
|
175
311
|
import matter2 from "gray-matter";
|
|
176
312
|
var READ_ONLY_CODES = /* @__PURE__ */ new Set(["EROFS", "EACCES", "EPERM"]);
|
|
177
|
-
function writeDocumentFile(
|
|
313
|
+
function writeDocumentFile(root, sourcePath, raw) {
|
|
314
|
+
const fullPath = resolveContained(root, sourcePath, { label: "document" });
|
|
178
315
|
try {
|
|
179
316
|
mkdirSync(dirname(fullPath), { recursive: true });
|
|
180
317
|
writeFileSync(fullPath, raw);
|
|
318
|
+
return fullPath;
|
|
181
319
|
} catch (error) {
|
|
182
320
|
const code = error.code ?? "";
|
|
183
321
|
if (!READ_ONLY_CODES.has(code)) throw error;
|
|
184
|
-
throw new
|
|
322
|
+
throw new GraftError5({
|
|
185
323
|
code: "CONTENT_TREE_READ_ONLY",
|
|
186
324
|
message: `Cannot write ${fullPath}: the filesystem refused it (${code}).`,
|
|
187
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.",
|
|
@@ -280,11 +418,17 @@ function composeDocument(existingRaw, data, body) {
|
|
|
280
418
|
return frontmatter + separator + body + (needsNewline ? newline : "");
|
|
281
419
|
}
|
|
282
420
|
export {
|
|
421
|
+
SLUG_RE,
|
|
283
422
|
compile,
|
|
284
423
|
compileStatic,
|
|
285
424
|
composeDocument,
|
|
425
|
+
findDoc,
|
|
286
426
|
parseDocument,
|
|
427
|
+
readCollectionDocs,
|
|
287
428
|
readDocs,
|
|
429
|
+
requireCollection,
|
|
430
|
+
resolveContained,
|
|
288
431
|
resolveGitSha,
|
|
432
|
+
walkContentFiles,
|
|
289
433
|
writeDocumentFile
|
|
290
434
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@usegraft/compiler",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0-beta.0",
|
|
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",
|
|
4
19
|
"license": "MIT",
|
|
5
20
|
"repository": {
|
|
6
21
|
"type": "git",
|
|
@@ -24,9 +39,10 @@
|
|
|
24
39
|
"access": "public"
|
|
25
40
|
},
|
|
26
41
|
"dependencies": {
|
|
27
|
-
"@usegraft/contracts": "0.
|
|
28
|
-
"@usegraft/core": "0.
|
|
29
|
-
"@usegraft/db": "0.
|
|
42
|
+
"@usegraft/contracts": "1.0.0-beta.0",
|
|
43
|
+
"@usegraft/core": "1.0.0-beta.0",
|
|
44
|
+
"@usegraft/db": "1.0.0-beta.0",
|
|
45
|
+
"@usegraft/mdx-safety": "1.0.0-beta.0",
|
|
30
46
|
"gray-matter": "^4.0.3"
|
|
31
47
|
},
|
|
32
48
|
"engines": {
|
|
@@ -36,7 +52,6 @@
|
|
|
36
52
|
"build": "tsup src/index.ts --format esm --dts --clean",
|
|
37
53
|
"dev": "tsup src/index.ts --format esm --watch",
|
|
38
54
|
"typecheck": "tsc --noEmit",
|
|
39
|
-
"test": "vitest run --passWithNoTests"
|
|
40
|
-
"lint": "oxlint ."
|
|
55
|
+
"test": "vitest run --passWithNoTests"
|
|
41
56
|
}
|
|
42
57
|
}
|