@modularcloud/cspec 0.1.0 → 0.2.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 +226 -0
- package/dist/bin/cspec.d.ts +3 -0
- package/dist/bin/cspec.d.ts.map +1 -0
- package/dist/bin/cspec.js +6 -0
- package/dist/cli.d.ts +6 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +60 -0
- package/dist/config.d.ts +8 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +47 -0
- package/dist/errors.d.ts +10 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +20 -0
- package/dist/generate.d.ts +11 -0
- package/dist/generate.d.ts.map +1 -0
- package/dist/generate.js +57 -0
- package/dist/glob.d.ts +3 -0
- package/dist/glob.d.ts.map +1 -0
- package/dist/glob.js +49 -0
- package/dist/ids.d.ts +6 -0
- package/dist/ids.d.ts.map +1 -0
- package/dist/ids.js +38 -0
- package/dist/parser.d.ts +55 -0
- package/dist/parser.d.ts.map +1 -0
- package/dist/parser.js +210 -0
- package/dist/render.d.ts +9 -0
- package/dist/render.d.ts.map +1 -0
- package/dist/render.js +91 -0
- package/dist/runtime.d.ts +12 -0
- package/dist/runtime.d.ts.map +1 -0
- package/{src → dist}/runtime.js +3 -6
- package/dist/workspace.d.ts +13 -0
- package/dist/workspace.d.ts.map +1 -0
- package/dist/workspace.js +31 -0
- package/package.json +24 -9
- package/bin/cspec.js +0 -7
- package/src/cli.js +0 -62
- package/src/config.js +0 -47
- package/src/errors.js +0 -15
- package/src/generate.js +0 -53
- package/src/glob.js +0 -42
- package/src/ids.js +0 -45
- package/src/parser.js +0 -221
- package/src/render.js +0 -89
- package/src/workspace.js +0 -37
package/dist/parser.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { CspecError } from "./errors.js";
|
|
3
|
+
import { assertStructuralId, idToSegments } from "./ids.js";
|
|
4
|
+
const TAG_RE = /<\/?(S|Spec)\b[^>]*>/g;
|
|
5
|
+
const IMPORT_RE = /^\s*import\s+([^'";]+?)\s+from\s+["']([^"']+)["']\s*;?\s*$/gm;
|
|
6
|
+
const EXTERNAL_REF_RE = /\{([A-Za-z_$][\w$]*(?:(?:\.[A-Za-z_$][\w$]*)|(?:\["(?:[^"\\]|\\.)*"\])|(?:\['(?:[^'\\]|\\.)*'\]))*)\.\$\}/g;
|
|
7
|
+
const LOCAL_REF_RE = /\{\s*ref\s*\(([^)]*)\)\s*\.\$\s*\}/g;
|
|
8
|
+
export function parseSource(file, source) {
|
|
9
|
+
const imports = parseImports(source);
|
|
10
|
+
const root = makeBlock("", null, 0, source.length, 0, source.length, "root");
|
|
11
|
+
const stack = [root];
|
|
12
|
+
const blocks = new Map();
|
|
13
|
+
const rangesToRemove = imports.map((item) => expandStandaloneRange(source, item.start, item.end));
|
|
14
|
+
for (const match of source.matchAll(TAG_RE)) {
|
|
15
|
+
const raw = match[0];
|
|
16
|
+
const index = match.index ?? 0;
|
|
17
|
+
const closing = raw.startsWith("</");
|
|
18
|
+
if (closing) {
|
|
19
|
+
if (stack.length === 1) {
|
|
20
|
+
throw new CspecError(`Unexpected closing ${raw}.`, { file, line: lineAt(source, index) });
|
|
21
|
+
}
|
|
22
|
+
const block = stack.pop();
|
|
23
|
+
if (!block)
|
|
24
|
+
throw new CspecError(`Unexpected closing ${raw}.`, { file, line: lineAt(source, index) });
|
|
25
|
+
const expected = `</${block.tag}>`;
|
|
26
|
+
if (raw !== expected) {
|
|
27
|
+
throw new CspecError(`Mismatched block tag. Expected ${expected}, received ${raw}.`, {
|
|
28
|
+
file,
|
|
29
|
+
line: lineAt(source, index)
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
block.closeStart = index;
|
|
33
|
+
block.closeEnd = index + raw.length;
|
|
34
|
+
block.contentEnd = index;
|
|
35
|
+
rangesToRemove.push(expandStandaloneRange(source, block.openStart, block.openEnd), expandStandaloneRange(source, block.closeStart, block.closeEnd));
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const tag = match[1];
|
|
39
|
+
const parent = stack[stack.length - 1];
|
|
40
|
+
if (!parent)
|
|
41
|
+
throw new CspecError(`Unexpected <${tag}> block.`, { file, line: lineAt(source, index) });
|
|
42
|
+
const id = parseIdAttribute(raw);
|
|
43
|
+
if (!id) {
|
|
44
|
+
throw new CspecError(`Non-root <${tag}> block is missing required id.`, { file, line: lineAt(source, index) });
|
|
45
|
+
}
|
|
46
|
+
assertStructuralId(id, parent.id, { file, line: lineAt(source, index) });
|
|
47
|
+
if (blocks.has(id)) {
|
|
48
|
+
throw new CspecError(`Duplicate block id "${id}".`, { file, line: lineAt(source, index) });
|
|
49
|
+
}
|
|
50
|
+
const block = makeBlock(id, parent.id || null, index, index + raw.length, index + raw.length, index + raw.length, tag);
|
|
51
|
+
block.segments = idToSegments(id);
|
|
52
|
+
block.line = lineAt(source, index);
|
|
53
|
+
parent.children.push(block);
|
|
54
|
+
blocks.set(id, block);
|
|
55
|
+
stack.push(block);
|
|
56
|
+
}
|
|
57
|
+
if (stack.length > 1) {
|
|
58
|
+
const block = stack[stack.length - 1];
|
|
59
|
+
throw new CspecError(`Unclosed <${block.tag}> block "${block.id}".`, { file, line: block.line });
|
|
60
|
+
}
|
|
61
|
+
root.children = root.children.sort((a, b) => a.openStart - b.openStart);
|
|
62
|
+
return {
|
|
63
|
+
file,
|
|
64
|
+
source,
|
|
65
|
+
root,
|
|
66
|
+
blocks,
|
|
67
|
+
imports,
|
|
68
|
+
rangesToRemove,
|
|
69
|
+
references: parseReferences(source, imports)
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function makeBlock(id, parentId, openStart, openEnd, contentStart, contentEnd, tag) {
|
|
73
|
+
return {
|
|
74
|
+
id,
|
|
75
|
+
parentId,
|
|
76
|
+
openStart,
|
|
77
|
+
openEnd,
|
|
78
|
+
contentStart,
|
|
79
|
+
contentEnd,
|
|
80
|
+
closeStart: null,
|
|
81
|
+
closeEnd: null,
|
|
82
|
+
tag,
|
|
83
|
+
children: [],
|
|
84
|
+
segments: [],
|
|
85
|
+
line: 1,
|
|
86
|
+
compiled: null
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function parseIdAttribute(raw) {
|
|
90
|
+
const doubleMatch = raw.match(/\bid\s*=\s*"([^"]*)"/);
|
|
91
|
+
if (doubleMatch)
|
|
92
|
+
return doubleMatch[1];
|
|
93
|
+
const singleMatch = raw.match(/\bid\s*=\s*'([^']*)'/);
|
|
94
|
+
if (singleMatch)
|
|
95
|
+
return singleMatch[1];
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
function expandStandaloneRange(source, start, end) {
|
|
99
|
+
const lineStart = source.lastIndexOf("\n", start - 1) + 1;
|
|
100
|
+
const nextNewline = source.indexOf("\n", end);
|
|
101
|
+
const lineEnd = nextNewline === -1 ? source.length : nextNewline + 1;
|
|
102
|
+
const before = source.slice(lineStart, start);
|
|
103
|
+
const after = source.slice(end, nextNewline === -1 ? source.length : nextNewline);
|
|
104
|
+
if (/^[ \t]*$/.test(before) && /^[ \t]*$/.test(after)) {
|
|
105
|
+
return [lineStart, lineEnd];
|
|
106
|
+
}
|
|
107
|
+
return [start, end];
|
|
108
|
+
}
|
|
109
|
+
function parseImports(source) {
|
|
110
|
+
const imports = [];
|
|
111
|
+
for (const match of source.matchAll(IMPORT_RE)) {
|
|
112
|
+
const specifier = match[2] ?? "";
|
|
113
|
+
const clause = (match[1] ?? "").trim();
|
|
114
|
+
const start = match.index ?? 0;
|
|
115
|
+
const end = start + match[0].length;
|
|
116
|
+
if (specifier === "cspec") {
|
|
117
|
+
imports.push({ kind: "cspec", clause, specifier, start, end });
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (specifier.endsWith(".cspec")) {
|
|
121
|
+
const local = clause.match(/^([A-Za-z_$][\w$]*)$/)?.[1];
|
|
122
|
+
if (local)
|
|
123
|
+
imports.push({ kind: "module", clause, local, specifier, start, end });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return imports;
|
|
127
|
+
}
|
|
128
|
+
function parseReferences(source, imports) {
|
|
129
|
+
const importedNames = new Set(imports.filter((item) => item.kind === "module").map((item) => item.local));
|
|
130
|
+
const references = [];
|
|
131
|
+
for (const match of source.matchAll(LOCAL_REF_RE)) {
|
|
132
|
+
const arg = (match[1] ?? "").trim();
|
|
133
|
+
const literal = parseStringLiteral(arg);
|
|
134
|
+
references.push({
|
|
135
|
+
kind: "local",
|
|
136
|
+
raw: match[0],
|
|
137
|
+
start: match.index ?? 0,
|
|
138
|
+
end: (match.index ?? 0) + match[0].length,
|
|
139
|
+
id: literal,
|
|
140
|
+
validStatic: literal !== null,
|
|
141
|
+
expression: arg
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
for (const match of source.matchAll(EXTERNAL_REF_RE)) {
|
|
145
|
+
const expression = match[1] ?? "";
|
|
146
|
+
const [name] = expression.split(/[.\[]/, 1);
|
|
147
|
+
if (!importedNames.has(name))
|
|
148
|
+
continue;
|
|
149
|
+
references.push({
|
|
150
|
+
kind: "external",
|
|
151
|
+
raw: match[0],
|
|
152
|
+
start: match.index ?? 0,
|
|
153
|
+
end: (match.index ?? 0) + match[0].length,
|
|
154
|
+
local: name,
|
|
155
|
+
path: memberExpressionToPath(expression.slice(name.length)),
|
|
156
|
+
expression
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return references.sort((a, b) => a.start - b.start);
|
|
160
|
+
}
|
|
161
|
+
function parseStringLiteral(value) {
|
|
162
|
+
if (!/^(['"])(?:\\.|(?!\1).)*\1$/s.test(value))
|
|
163
|
+
return null;
|
|
164
|
+
try {
|
|
165
|
+
return JSON.parse(value[0] === "'" ? `"${value.slice(1, -1).replace(/"/g, '\\"')}"` : value);
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return value.slice(1, -1);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
function memberExpressionToPath(expression) {
|
|
172
|
+
const segments = [];
|
|
173
|
+
let rest = expression;
|
|
174
|
+
while (rest) {
|
|
175
|
+
const dot = rest.match(/^\.(?!\$)([A-Za-z_$][\w$]*)/);
|
|
176
|
+
if (dot) {
|
|
177
|
+
segments.push(dot[1]);
|
|
178
|
+
rest = rest.slice(dot[0].length);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const bracket = rest.match(/^\[(["'])((?:\\.|(?!\1).)*)\]/s);
|
|
182
|
+
if (bracket) {
|
|
183
|
+
segments.push(unescapeQuoted(bracket[2]));
|
|
184
|
+
rest = rest.slice(bracket[0].length);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
return segments.join(".");
|
|
190
|
+
}
|
|
191
|
+
function unescapeQuoted(value) {
|
|
192
|
+
try {
|
|
193
|
+
return JSON.parse(`"${value.replace(/"/g, '\\"')}"`);
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return value.replace(/\\(['"\\])/g, "$1");
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
export function resolveImportPath(fromFile, specifier) {
|
|
200
|
+
const base = path.resolve(path.dirname(fromFile), specifier.replace(/\.cspec$/, ".mdx"));
|
|
201
|
+
return base;
|
|
202
|
+
}
|
|
203
|
+
export function lineAt(source, index) {
|
|
204
|
+
let line = 1;
|
|
205
|
+
for (let i = 0; i < index; i += 1) {
|
|
206
|
+
if (source.charCodeAt(i) === 10)
|
|
207
|
+
line += 1;
|
|
208
|
+
}
|
|
209
|
+
return line;
|
|
210
|
+
}
|
package/dist/render.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { CspecBlock, CspecDocument } from "./parser.js";
|
|
2
|
+
interface CompileContext {
|
|
3
|
+
byFile: Map<string, CspecDocument>;
|
|
4
|
+
stack: string[];
|
|
5
|
+
}
|
|
6
|
+
export declare function compileWorkspace(documents: CspecDocument[]): void;
|
|
7
|
+
export declare function compileBlock(doc: CspecDocument, block: CspecBlock, context: CompileContext): string;
|
|
8
|
+
export {};
|
|
9
|
+
//# sourceMappingURL=render.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"render.d.ts","sourceRoot":"","sources":["../src/render.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,aAAa,EAAkB,MAAM,aAAa,CAAC;AAE7E,UAAU,cAAc;IACtB,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,aAAa,CAAC,CAAC;IACnC,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AAID,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,aAAa,EAAE,GAAG,IAAI,CAOjE;AAED,wBAAgB,YAAY,CAAC,GAAG,EAAE,aAAa,EAAE,KAAK,EAAE,UAAU,EAAE,OAAO,EAAE,cAAc,GAAG,MAAM,CAcnG"}
|
package/dist/render.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { CspecError } from "./errors.js";
|
|
2
|
+
import { lineAt, resolveImportPath } from "./parser.js";
|
|
3
|
+
export function compileWorkspace(documents) {
|
|
4
|
+
const byFile = new Map(documents.map((doc) => [doc.file, doc]));
|
|
5
|
+
const context = { byFile, stack: [] };
|
|
6
|
+
for (const doc of documents) {
|
|
7
|
+
compileBlock(doc, doc.root, context);
|
|
8
|
+
for (const block of doc.blocks.values())
|
|
9
|
+
compileBlock(doc, block, context);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function compileBlock(doc, block, context) {
|
|
13
|
+
const key = `${doc.file}#${block.id || "$"}`;
|
|
14
|
+
if (block.compiled !== null)
|
|
15
|
+
return block.compiled;
|
|
16
|
+
const cycleAt = context.stack.indexOf(key);
|
|
17
|
+
if (cycleAt !== -1) {
|
|
18
|
+
const cycle = context.stack.slice(cycleAt).concat(key).map((item) => item.split("#")[1]).join(" -> ");
|
|
19
|
+
throw new CspecError(`Embedding cycle detected:\n${cycle}`, { file: doc.file, line: block.line });
|
|
20
|
+
}
|
|
21
|
+
context.stack.push(key);
|
|
22
|
+
const start = block.id ? block.contentStart : 0;
|
|
23
|
+
const end = block.id ? block.contentEnd : doc.source.length;
|
|
24
|
+
block.compiled = renderRange(doc, start, end, context);
|
|
25
|
+
context.stack.pop();
|
|
26
|
+
return block.compiled;
|
|
27
|
+
}
|
|
28
|
+
function renderRange(doc, start, end, context) {
|
|
29
|
+
const removals = doc.rangesToRemove
|
|
30
|
+
.filter(([a, b]) => b > start && a < end)
|
|
31
|
+
.map(([a, b]) => [Math.max(a, start), Math.min(b, end), ""]);
|
|
32
|
+
const replacements = doc.references
|
|
33
|
+
.filter((ref) => ref.start >= start && ref.end <= end)
|
|
34
|
+
.map((ref) => [ref.start, ref.end, resolveReference(doc, ref, context)]);
|
|
35
|
+
const edits = removals.concat(replacements).sort((a, b) => a[0] - b[0] || b[1] - a[1]);
|
|
36
|
+
let out = "";
|
|
37
|
+
let cursor = start;
|
|
38
|
+
for (const [a, b, value] of edits) {
|
|
39
|
+
if (a < cursor)
|
|
40
|
+
continue;
|
|
41
|
+
out += doc.source.slice(cursor, a);
|
|
42
|
+
out += value;
|
|
43
|
+
cursor = b;
|
|
44
|
+
}
|
|
45
|
+
out += doc.source.slice(cursor, end);
|
|
46
|
+
return trimOuterBlankLines(out);
|
|
47
|
+
}
|
|
48
|
+
function resolveReference(doc, ref, context) {
|
|
49
|
+
if (ref.kind === "local") {
|
|
50
|
+
if (!ref.validStatic) {
|
|
51
|
+
throw new CspecError("ref(...) requires a static string literal.", {
|
|
52
|
+
file: doc.file,
|
|
53
|
+
line: lineAt(doc.source, ref.start)
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
const target = ref.id ? doc.blocks.get(ref.id) : undefined;
|
|
57
|
+
if (!target) {
|
|
58
|
+
throw new CspecError(`Unknown local block reference "${ref.id}".`, {
|
|
59
|
+
file: doc.file,
|
|
60
|
+
line: lineAt(doc.source, ref.start)
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return compileBlock(doc, target, context);
|
|
64
|
+
}
|
|
65
|
+
const imported = doc.imports.find((item) => item.kind === "module" && item.local === ref.local);
|
|
66
|
+
if (!imported) {
|
|
67
|
+
throw new CspecError(`Unresolved import for ${ref.local}.`, {
|
|
68
|
+
file: doc.file,
|
|
69
|
+
line: lineAt(doc.source, ref.start)
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
const targetFile = resolveImportPath(doc.file, imported.specifier);
|
|
73
|
+
const targetDoc = context.byFile.get(targetFile);
|
|
74
|
+
if (!targetDoc) {
|
|
75
|
+
throw new CspecError(`Unresolved import ${imported.specifier}.`, {
|
|
76
|
+
file: doc.file,
|
|
77
|
+
line: lineAt(doc.source, ref.start)
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
const target = ref.path ? targetDoc.blocks.get(ref.path) : targetDoc.root;
|
|
81
|
+
if (!target) {
|
|
82
|
+
throw new CspecError(`Unknown block reference ${ref.expression}.`, {
|
|
83
|
+
file: doc.file,
|
|
84
|
+
line: lineAt(doc.source, ref.start)
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
return compileBlock(targetDoc, target, context);
|
|
88
|
+
}
|
|
89
|
+
function trimOuterBlankLines(value) {
|
|
90
|
+
return value.replace(/^\s*\n/, "").replace(/\n\s*$/, "");
|
|
91
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface ComponentProps {
|
|
2
|
+
children?: unknown;
|
|
3
|
+
}
|
|
4
|
+
export declare function S(props?: ComponentProps): unknown;
|
|
5
|
+
export declare const Spec: typeof S;
|
|
6
|
+
export interface RefValue {
|
|
7
|
+
id: string;
|
|
8
|
+
$: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function ref(id: string): RefValue;
|
|
11
|
+
export declare function defineConfig<T>(config: T): T;
|
|
12
|
+
//# sourceMappingURL=runtime.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../src/runtime.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,wBAAgB,CAAC,CAAC,KAAK,CAAC,EAAE,cAAc,GAAG,OAAO,CAEjD;AAED,eAAO,MAAM,IAAI,UAAI,CAAC;AAEtB,MAAM,WAAW,QAAQ;IACvB,EAAE,EAAE,MAAM,CAAC;IACX,CAAC,EAAE,MAAM,CAAC;CACX;AAED,wBAAgB,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,QAAQ,CAExC;AAED,wBAAgB,YAAY,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,GAAG,CAAC,CAE5C"}
|
package/{src → dist}/runtime.js
RENAMED
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
export function S(props) {
|
|
2
|
-
|
|
2
|
+
return props?.children ?? null;
|
|
3
3
|
}
|
|
4
|
-
|
|
5
4
|
export const Spec = S;
|
|
6
|
-
|
|
7
5
|
export function ref(id) {
|
|
8
|
-
|
|
6
|
+
return { id, $: "" };
|
|
9
7
|
}
|
|
10
|
-
|
|
11
8
|
export function defineConfig(config) {
|
|
12
|
-
|
|
9
|
+
return config;
|
|
13
10
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { CspecConfig } from "./config.js";
|
|
2
|
+
import type { CspecDocument } from "./parser.js";
|
|
3
|
+
import type { GeneratedFile } from "./generate.js";
|
|
4
|
+
export interface Workspace {
|
|
5
|
+
cwd: string;
|
|
6
|
+
config: CspecConfig;
|
|
7
|
+
documents: CspecDocument[];
|
|
8
|
+
}
|
|
9
|
+
export declare function loadWorkspace(cwd: string): Promise<Workspace>;
|
|
10
|
+
export declare function expectedOutputs(workspace: Workspace): GeneratedFile[];
|
|
11
|
+
export declare function writeOutputs(workspace: Workspace): void;
|
|
12
|
+
export declare function assertFresh(workspace: Workspace): void;
|
|
13
|
+
//# sourceMappingURL=workspace.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"workspace.d.ts","sourceRoot":"","sources":["../src/workspace.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC/C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AACjD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAEnD,MAAM,WAAW,SAAS;IACxB,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,WAAW,CAAC;IACpB,SAAS,EAAE,aAAa,EAAE,CAAC;CAC5B;AAED,wBAAsB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,CAAC,CAMnE;AAED,wBAAgB,eAAe,CAAC,SAAS,EAAE,SAAS,GAAG,aAAa,EAAE,CAIrE;AAED,wBAAgB,YAAY,CAAC,SAAS,EAAE,SAAS,GAAG,IAAI,CAIvD;AAED,wBAAgB,WAAW,CAAC,SAAS,EAAE,SAAS,GAAG,IAAI,CAOtD"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { loadConfig } from "./config.js";
|
|
4
|
+
import { discoverFiles } from "./glob.js";
|
|
5
|
+
import { parseSource } from "./parser.js";
|
|
6
|
+
import { compileWorkspace } from "./render.js";
|
|
7
|
+
import { generatedFilesFor } from "./generate.js";
|
|
8
|
+
import { CspecError } from "./errors.js";
|
|
9
|
+
export async function loadWorkspace(cwd) {
|
|
10
|
+
const config = await loadConfig(cwd);
|
|
11
|
+
const files = discoverFiles(cwd, config.specs);
|
|
12
|
+
const documents = files.map((file) => parseSource(file, fs.readFileSync(file, "utf8")));
|
|
13
|
+
compileWorkspace(documents);
|
|
14
|
+
return { cwd, config, documents };
|
|
15
|
+
}
|
|
16
|
+
export function expectedOutputs(workspace) {
|
|
17
|
+
return workspace.documents.flatMap((doc) => generatedFilesFor(doc, { markdownEmit: workspace.config.markdown.emit }));
|
|
18
|
+
}
|
|
19
|
+
export function writeOutputs(workspace) {
|
|
20
|
+
for (const output of expectedOutputs(workspace)) {
|
|
21
|
+
fs.writeFileSync(output.file, output.content);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function assertFresh(workspace) {
|
|
25
|
+
for (const output of expectedOutputs(workspace)) {
|
|
26
|
+
const rel = path.relative(workspace.cwd, output.file);
|
|
27
|
+
if (!fs.existsSync(output.file) || fs.readFileSync(output.file, "utf8") !== output.content) {
|
|
28
|
+
throw new CspecError(`Generated file ${rel} is stale.\nRun cspec build.`, { file: output.file });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
package/package.json
CHANGED
|
@@ -1,22 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@modularcloud/cspec",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Minimal MDX content block compiler implementing the cspec reusable-block subset.",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/modularcloud/xspec"
|
|
9
|
+
},
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/modularcloud/xspec/issues"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/modularcloud/xspec#readme",
|
|
6
14
|
"bin": {
|
|
7
|
-
"cspec": "bin/cspec.js"
|
|
15
|
+
"cspec": "dist/bin/cspec.js"
|
|
8
16
|
},
|
|
17
|
+
"types": "./dist/runtime.d.ts",
|
|
9
18
|
"exports": {
|
|
10
|
-
".":
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./dist/runtime.d.ts",
|
|
21
|
+
"default": "./dist/runtime.js"
|
|
22
|
+
}
|
|
11
23
|
},
|
|
12
24
|
"scripts": {
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
"prepack": "
|
|
25
|
+
"build": "tsc && node -e \"require('node:fs').chmodSync('dist/bin/cspec.js', 0o755)\"",
|
|
26
|
+
"test": "npm run build && node --test",
|
|
27
|
+
"prepack": "npm test"
|
|
16
28
|
},
|
|
17
29
|
"files": [
|
|
18
|
-
"
|
|
19
|
-
"
|
|
30
|
+
"dist",
|
|
31
|
+
"README.md"
|
|
20
32
|
],
|
|
21
33
|
"keywords": [
|
|
22
34
|
"cspec",
|
|
@@ -33,5 +45,8 @@
|
|
|
33
45
|
"engines": {
|
|
34
46
|
"node": ">=20"
|
|
35
47
|
},
|
|
36
|
-
"devDependencies": {
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@types/node": "^25.9.1",
|
|
50
|
+
"typescript": "^6.0.3"
|
|
51
|
+
}
|
|
37
52
|
}
|
package/bin/cspec.js
DELETED
package/src/cli.js
DELETED
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
import path from "node:path";
|
|
2
|
-
import { formatError } from "./errors.js";
|
|
3
|
-
import { assertFresh, loadWorkspace, writeOutputs } from "./workspace.js";
|
|
4
|
-
|
|
5
|
-
export async function runCli(args, io = { stdout: process.stdout, stderr: process.stderr }) {
|
|
6
|
-
const [command = "build", ...rest] = args;
|
|
7
|
-
try {
|
|
8
|
-
if (command === "build") {
|
|
9
|
-
const workspace = await loadWorkspace(process.cwd());
|
|
10
|
-
writeOutputs(workspace);
|
|
11
|
-
io.stdout.write(`Built ${workspace.documents.length} file(s).\n`);
|
|
12
|
-
return;
|
|
13
|
-
}
|
|
14
|
-
if (command === "check") {
|
|
15
|
-
const workspace = await loadWorkspace(process.cwd());
|
|
16
|
-
assertFresh(workspace);
|
|
17
|
-
io.stdout.write("cspec check passed.\n");
|
|
18
|
-
return;
|
|
19
|
-
}
|
|
20
|
-
if (command === "ids") {
|
|
21
|
-
const workspace = await loadWorkspace(process.cwd());
|
|
22
|
-
io.stdout.write(formatIds(workspace, rest));
|
|
23
|
-
return;
|
|
24
|
-
}
|
|
25
|
-
if (command === "--help" || command === "-h") {
|
|
26
|
-
io.stdout.write("Usage: cspec <build|check|ids> [--tree|--json]\n");
|
|
27
|
-
return;
|
|
28
|
-
}
|
|
29
|
-
throw new Error(`Unknown command "${command}".`);
|
|
30
|
-
} catch (error) {
|
|
31
|
-
io.stderr.write(`${formatError(error)}\n`);
|
|
32
|
-
process.exitCode = 1;
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function formatIds(workspace, args) {
|
|
37
|
-
if (args.includes("--json")) {
|
|
38
|
-
return `${JSON.stringify({
|
|
39
|
-
files: workspace.documents.map((doc) => ({
|
|
40
|
-
file: path.relative(workspace.cwd, doc.file),
|
|
41
|
-
ids: [...doc.blocks.values()].map((block) => ({ id: block.id, segments: block.segments }))
|
|
42
|
-
}))
|
|
43
|
-
}, null, 2)}\n`;
|
|
44
|
-
}
|
|
45
|
-
if (args.includes("--tree")) {
|
|
46
|
-
return workspace.documents.map((doc) => `${path.relative(workspace.cwd, doc.file)}\n${treeLines(doc.root.children, "")}`).join("\n");
|
|
47
|
-
}
|
|
48
|
-
return workspace.documents.map((doc) => {
|
|
49
|
-
const ids = [...doc.blocks.keys()].map((id) => ` ${id}`).join("\n");
|
|
50
|
-
return `${path.relative(workspace.cwd, doc.file)}${ids ? `\n${ids}` : ""}\n`;
|
|
51
|
-
}).join("\n");
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function treeLines(blocks, prefix) {
|
|
55
|
-
return blocks.map((block, index) => {
|
|
56
|
-
const last = index === blocks.length - 1;
|
|
57
|
-
const branch = last ? "└─ " : "├─ ";
|
|
58
|
-
const nextPrefix = `${prefix}${last ? " " : "│ "}`;
|
|
59
|
-
const children = block.children.length ? `\n${treeLines(block.children, nextPrefix)}` : "";
|
|
60
|
-
return `${prefix}${branch}${block.id}${children}`;
|
|
61
|
-
}).join("\n") + "\n";
|
|
62
|
-
}
|
package/src/config.js
DELETED
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { CspecError } from "./errors.js";
|
|
4
|
-
|
|
5
|
-
export async function loadConfig(cwd) {
|
|
6
|
-
const candidates = ["cspec.config.js", "cspec.config.mjs", "cspec.config.cjs", "cspec.config.ts"];
|
|
7
|
-
for (const name of candidates) {
|
|
8
|
-
const file = path.join(cwd, name);
|
|
9
|
-
if (!fs.existsSync(file)) continue;
|
|
10
|
-
const config = name.endsWith(".ts") ? loadTsConfig(file) : await importConfig(file);
|
|
11
|
-
return normalizeConfig(config);
|
|
12
|
-
}
|
|
13
|
-
return normalizeConfig({
|
|
14
|
-
specs: { default: ["**/*.mdx"] },
|
|
15
|
-
markdown: { emit: true }
|
|
16
|
-
});
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
async function importConfig(file) {
|
|
20
|
-
const module = await import(`${pathToFileUrl(file)}?t=${Date.now()}`);
|
|
21
|
-
return module.default ?? module;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function loadTsConfig(file) {
|
|
25
|
-
const source = fs.readFileSync(file, "utf8")
|
|
26
|
-
.replace(/^\s*import\s+.*?(?:;\s*)?$/gm, "")
|
|
27
|
-
.replace(/export\s+default\s+defineConfig\s*\(/, "return (")
|
|
28
|
-
.replace(/export\s+default\s+/, "return ");
|
|
29
|
-
try {
|
|
30
|
-
return Function("defineConfig", source)((value) => value);
|
|
31
|
-
} catch (error) {
|
|
32
|
-
throw new CspecError(`Unable to load ${path.basename(file)}: ${error.message}`, { file });
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function normalizeConfig(config) {
|
|
37
|
-
return {
|
|
38
|
-
specs: config?.specs && Object.keys(config.specs).length ? config.specs : { default: ["**/*.mdx"] },
|
|
39
|
-
markdown: {
|
|
40
|
-
emit: config?.markdown?.emit !== false
|
|
41
|
-
}
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function pathToFileUrl(file) {
|
|
46
|
-
return `file://${file.split(path.sep).map(encodeURIComponent).join("/")}`;
|
|
47
|
-
}
|
package/src/errors.js
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
export class CspecError extends Error {
|
|
2
|
-
constructor(message, details = {}) {
|
|
3
|
-
super(message);
|
|
4
|
-
this.name = "CspecError";
|
|
5
|
-
this.details = details;
|
|
6
|
-
}
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export function formatError(error) {
|
|
10
|
-
if (!error?.details?.file) return error.message;
|
|
11
|
-
const location = error.details.line
|
|
12
|
-
? `${error.details.file}:${error.details.line}`
|
|
13
|
-
: error.details.file;
|
|
14
|
-
return `${location}: ${error.message}`;
|
|
15
|
-
}
|
package/src/generate.js
DELETED
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
import path from "node:path";
|
|
2
|
-
|
|
3
|
-
export function generatedFilesFor(doc, options) {
|
|
4
|
-
const base = doc.file.replace(/\.mdx$/i, "");
|
|
5
|
-
const files = [
|
|
6
|
-
{
|
|
7
|
-
file: `${base}.cspec.ts`,
|
|
8
|
-
content: generateTypeScript(doc)
|
|
9
|
-
}
|
|
10
|
-
];
|
|
11
|
-
if (options.markdownEmit) {
|
|
12
|
-
files.push({
|
|
13
|
-
file: `${base}.md`,
|
|
14
|
-
content: `${doc.root.compiled}\n`
|
|
15
|
-
});
|
|
16
|
-
}
|
|
17
|
-
return files;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function generateTypeScript(doc) {
|
|
21
|
-
const name = safeConstName(path.basename(doc.file, ".mdx"));
|
|
22
|
-
const tree = { $: doc.root.compiled };
|
|
23
|
-
for (const block of doc.blocks.values()) {
|
|
24
|
-
let cursor = tree;
|
|
25
|
-
for (const segment of block.segments) {
|
|
26
|
-
cursor[segment] ??= {};
|
|
27
|
-
cursor = cursor[segment];
|
|
28
|
-
}
|
|
29
|
-
cursor.$ = block.compiled;
|
|
30
|
-
}
|
|
31
|
-
return `const ${name} = ${printObject(tree, 0)} as const;\n\nexport default ${name};\n`;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function safeConstName(name) {
|
|
35
|
-
const cleaned = name.replace(/\W+/g, "_").replace(/^(\d)/, "_$1");
|
|
36
|
-
return cleaned || "SPEC";
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function printObject(value, depth) {
|
|
40
|
-
if (typeof value === "string") return JSON.stringify(value);
|
|
41
|
-
const indent = " ".repeat(depth);
|
|
42
|
-
const childIndent = " ".repeat(depth + 1);
|
|
43
|
-
const entries = Object.entries(value);
|
|
44
|
-
if (entries.length === 0) return "{}";
|
|
45
|
-
const body = entries
|
|
46
|
-
.map(([key, child]) => `${childIndent}${printKey(key)}: ${printObject(child, depth + 1)}`)
|
|
47
|
-
.join(",\n");
|
|
48
|
-
return `{\n${body}\n${indent}}`;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function printKey(key) {
|
|
52
|
-
return /^[A-Za-z_$][\w$]*$/.test(key) ? key : JSON.stringify(key);
|
|
53
|
-
}
|