@modularcloud/cspec 0.1.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/bin/cspec.js +7 -0
- package/package.json +37 -0
- package/src/cli.js +62 -0
- package/src/config.js +47 -0
- package/src/errors.js +15 -0
- package/src/generate.js +53 -0
- package/src/glob.js +42 -0
- package/src/ids.js +45 -0
- package/src/parser.js +221 -0
- package/src/render.js +89 -0
- package/src/runtime.js +13 -0
- package/src/workspace.js +37 -0
package/bin/cspec.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@modularcloud/cspec",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Minimal MDX content block compiler implementing the cspec reusable-block subset.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"cspec": "bin/cspec.js"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/runtime.js"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"test": "node --test",
|
|
14
|
+
"build": "node ./bin/cspec.js build",
|
|
15
|
+
"prepack": "node --test"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"bin",
|
|
19
|
+
"src"
|
|
20
|
+
],
|
|
21
|
+
"keywords": [
|
|
22
|
+
"cspec",
|
|
23
|
+
"xspec",
|
|
24
|
+
"mdx",
|
|
25
|
+
"markdown",
|
|
26
|
+
"compiler",
|
|
27
|
+
"content"
|
|
28
|
+
],
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=20"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {}
|
|
37
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
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
|
+
}
|
package/src/glob.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export function discoverFiles(cwd, specs) {
|
|
5
|
+
const patterns = Object.values(specs).flat();
|
|
6
|
+
const files = new Set();
|
|
7
|
+
for (const pattern of patterns) {
|
|
8
|
+
for (const file of walk(cwd)) {
|
|
9
|
+
const rel = path.relative(cwd, file).split(path.sep).join("/");
|
|
10
|
+
if (isGenerated(rel)) continue;
|
|
11
|
+
if (matchGlob(pattern, rel)) files.add(file);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return [...files].sort();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function walk(dir) {
|
|
18
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
19
|
+
const files = [];
|
|
20
|
+
for (const entry of entries) {
|
|
21
|
+
if (entry.name === "node_modules" || entry.name === ".git") continue;
|
|
22
|
+
const full = path.join(dir, entry.name);
|
|
23
|
+
if (entry.isDirectory()) files.push(...walk(full));
|
|
24
|
+
else if (entry.isFile()) files.push(full);
|
|
25
|
+
}
|
|
26
|
+
return files;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isGenerated(rel) {
|
|
30
|
+
return rel.endsWith(".cspec.ts") || rel.endsWith(".md");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function matchGlob(pattern, rel) {
|
|
34
|
+
if (pattern.startsWith("**/") && matchGlob(pattern.slice(3), rel)) return true;
|
|
35
|
+
if (pattern.includes("/**/") && matchGlob(pattern.replace("/**/", "/"), rel)) return true;
|
|
36
|
+
const escaped = pattern.split(/(\*\*|\*)/g).map((part) => {
|
|
37
|
+
if (part === "**") return ".*";
|
|
38
|
+
if (part === "*") return "[^/]*";
|
|
39
|
+
return part.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
|
40
|
+
}).join("");
|
|
41
|
+
return new RegExp(`^${escaped}$`).test(rel);
|
|
42
|
+
}
|
package/src/ids.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { CspecError } from "./errors.js";
|
|
2
|
+
|
|
3
|
+
const RESERVED = new Set(["$", "__proto__", "prototype", "constructor"]);
|
|
4
|
+
|
|
5
|
+
export function splitId(id, details = {}) {
|
|
6
|
+
if (typeof id !== "string") {
|
|
7
|
+
throw new CspecError("Block id must be a string.", details);
|
|
8
|
+
}
|
|
9
|
+
if (id.trim() !== id) {
|
|
10
|
+
throw new CspecError(`Invalid block id "${id}"; ids must not have leading or trailing whitespace.`, details);
|
|
11
|
+
}
|
|
12
|
+
const segments = id.split(".");
|
|
13
|
+
for (const segment of segments) validateSegment(segment, details);
|
|
14
|
+
return segments;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function validateSegment(segment, details = {}) {
|
|
18
|
+
if (!segment) {
|
|
19
|
+
throw new CspecError("Invalid empty id segment.", details);
|
|
20
|
+
}
|
|
21
|
+
if (segment.trim() !== segment) {
|
|
22
|
+
throw new CspecError(`Invalid id segment "${segment}"; segments must not have leading or trailing whitespace.`, details);
|
|
23
|
+
}
|
|
24
|
+
if (/[\u0000-\u001f\u007f]/u.test(segment)) {
|
|
25
|
+
throw new CspecError(`Invalid id segment "${segment}"; control characters are not allowed.`, details);
|
|
26
|
+
}
|
|
27
|
+
if (RESERVED.has(segment)) {
|
|
28
|
+
const reason = segment === "$" ? '"$" is reserved for block values.' : `"${segment}" is reserved.`;
|
|
29
|
+
throw new CspecError(`Invalid id segment "${segment}"; ${reason}`, details);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function assertStructuralId(id, parentId, details = {}) {
|
|
34
|
+
splitId(id, details);
|
|
35
|
+
if (parentId && !id.startsWith(`${parentId}.`)) {
|
|
36
|
+
throw new CspecError(
|
|
37
|
+
`Child block id must begin with parent id plus ".".\nExpected id matching "${parentId}.<segment>".\nReceived "${id}".`,
|
|
38
|
+
details
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function idToSegments(id) {
|
|
44
|
+
return id ? id.split(".") : [];
|
|
45
|
+
}
|
package/src/parser.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { CspecError } from "./errors.js";
|
|
3
|
+
import { assertStructuralId, idToSegments } from "./ids.js";
|
|
4
|
+
|
|
5
|
+
const TAG_RE = /<\/?(S|Spec)\b[^>]*>/g;
|
|
6
|
+
const IMPORT_RE = /^\s*import\s+([^'";]+?)\s+from\s+["']([^"']+)["']\s*;?\s*$/gm;
|
|
7
|
+
const EXTERNAL_REF_RE = /\{([A-Za-z_$][\w$]*(?:(?:\.[A-Za-z_$][\w$]*)|(?:\["(?:[^"\\]|\\.)*"\])|(?:\['(?:[^'\\]|\\.)*'\]))*)\.\$\}/g;
|
|
8
|
+
const LOCAL_REF_RE = /\{\s*ref\s*\(([^)]*)\)\s*\.\$\s*\}/g;
|
|
9
|
+
|
|
10
|
+
export function parseSource(file, source) {
|
|
11
|
+
const imports = parseImports(source);
|
|
12
|
+
const root = makeBlock("", null, 0, source.length, 0, source.length, "root");
|
|
13
|
+
const stack = [root];
|
|
14
|
+
const blocks = new Map();
|
|
15
|
+
const rangesToRemove = imports.map((item) => expandStandaloneRange(source, item.start, item.end));
|
|
16
|
+
|
|
17
|
+
for (const match of source.matchAll(TAG_RE)) {
|
|
18
|
+
const raw = match[0];
|
|
19
|
+
const index = match.index;
|
|
20
|
+
const closing = raw.startsWith("</");
|
|
21
|
+
if (closing) {
|
|
22
|
+
if (stack.length === 1) {
|
|
23
|
+
throw new CspecError(`Unexpected closing ${raw}.`, { file, line: lineAt(source, index) });
|
|
24
|
+
}
|
|
25
|
+
const block = stack.pop();
|
|
26
|
+
const expected = `</${block.tag}>`;
|
|
27
|
+
if (raw !== expected) {
|
|
28
|
+
throw new CspecError(`Mismatched block tag. Expected ${expected}, received ${raw}.`, {
|
|
29
|
+
file,
|
|
30
|
+
line: lineAt(source, index)
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
block.closeStart = index;
|
|
34
|
+
block.closeEnd = index + raw.length;
|
|
35
|
+
block.contentEnd = index;
|
|
36
|
+
rangesToRemove.push(
|
|
37
|
+
expandStandaloneRange(source, block.openStart, block.openEnd),
|
|
38
|
+
expandStandaloneRange(source, block.closeStart, block.closeEnd)
|
|
39
|
+
);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const tag = match[1];
|
|
44
|
+
const parent = stack[stack.length - 1];
|
|
45
|
+
const id = parseIdAttribute(raw);
|
|
46
|
+
if (!id) {
|
|
47
|
+
throw new CspecError(`Non-root <${tag}> block is missing required id.`, { file, line: lineAt(source, index) });
|
|
48
|
+
}
|
|
49
|
+
assertStructuralId(id, parent.id, { file, line: lineAt(source, index) });
|
|
50
|
+
if (blocks.has(id)) {
|
|
51
|
+
throw new CspecError(`Duplicate block id "${id}".`, { file, line: lineAt(source, index) });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const block = makeBlock(id, parent.id || null, index, index + raw.length, index + raw.length, null, tag);
|
|
55
|
+
block.segments = idToSegments(id);
|
|
56
|
+
block.line = lineAt(source, index);
|
|
57
|
+
parent.children.push(block);
|
|
58
|
+
blocks.set(id, block);
|
|
59
|
+
stack.push(block);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (stack.length > 1) {
|
|
63
|
+
const block = stack[stack.length - 1];
|
|
64
|
+
throw new CspecError(`Unclosed <${block.tag}> block "${block.id}".`, { file, line: block.line });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
root.children = root.children.sort((a, b) => a.openStart - b.openStart);
|
|
68
|
+
return {
|
|
69
|
+
file,
|
|
70
|
+
source,
|
|
71
|
+
root,
|
|
72
|
+
blocks,
|
|
73
|
+
imports,
|
|
74
|
+
rangesToRemove,
|
|
75
|
+
references: parseReferences(source, imports)
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function makeBlock(id, parentId, openStart, openEnd, contentStart, contentEnd, tag) {
|
|
80
|
+
return {
|
|
81
|
+
id,
|
|
82
|
+
parentId,
|
|
83
|
+
openStart,
|
|
84
|
+
openEnd,
|
|
85
|
+
contentStart,
|
|
86
|
+
contentEnd,
|
|
87
|
+
closeStart: null,
|
|
88
|
+
closeEnd: null,
|
|
89
|
+
tag,
|
|
90
|
+
children: [],
|
|
91
|
+
segments: [],
|
|
92
|
+
line: 1,
|
|
93
|
+
compiled: null
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function parseIdAttribute(raw) {
|
|
98
|
+
const doubleMatch = raw.match(/\bid\s*=\s*"([^"]*)"/);
|
|
99
|
+
if (doubleMatch) return doubleMatch[1];
|
|
100
|
+
const singleMatch = raw.match(/\bid\s*=\s*'([^']*)'/);
|
|
101
|
+
if (singleMatch) return singleMatch[1];
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function expandStandaloneRange(source, start, end) {
|
|
106
|
+
const lineStart = source.lastIndexOf("\n", start - 1) + 1;
|
|
107
|
+
const nextNewline = source.indexOf("\n", end);
|
|
108
|
+
const lineEnd = nextNewline === -1 ? source.length : nextNewline + 1;
|
|
109
|
+
const before = source.slice(lineStart, start);
|
|
110
|
+
const after = source.slice(end, nextNewline === -1 ? source.length : nextNewline);
|
|
111
|
+
if (/^[ \t]*$/.test(before) && /^[ \t]*$/.test(after)) {
|
|
112
|
+
return [lineStart, lineEnd];
|
|
113
|
+
}
|
|
114
|
+
return [start, end];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function parseImports(source) {
|
|
118
|
+
const imports = [];
|
|
119
|
+
for (const match of source.matchAll(IMPORT_RE)) {
|
|
120
|
+
const specifier = match[2];
|
|
121
|
+
const clause = match[1].trim();
|
|
122
|
+
const start = match.index;
|
|
123
|
+
const end = start + match[0].length;
|
|
124
|
+
if (specifier === "cspec") {
|
|
125
|
+
imports.push({ kind: "cspec", clause, specifier, start, end });
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (specifier.endsWith(".cspec")) {
|
|
129
|
+
const local = clause.match(/^([A-Za-z_$][\w$]*)$/)?.[1];
|
|
130
|
+
if (local) imports.push({ kind: "module", local, specifier, start, end });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return imports;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function parseReferences(source, imports) {
|
|
137
|
+
const importedNames = new Set(imports.filter((item) => item.kind === "module").map((item) => item.local));
|
|
138
|
+
const references = [];
|
|
139
|
+
|
|
140
|
+
for (const match of source.matchAll(LOCAL_REF_RE)) {
|
|
141
|
+
const arg = match[1].trim();
|
|
142
|
+
const literal = parseStringLiteral(arg);
|
|
143
|
+
references.push({
|
|
144
|
+
kind: "local",
|
|
145
|
+
raw: match[0],
|
|
146
|
+
start: match.index,
|
|
147
|
+
end: match.index + match[0].length,
|
|
148
|
+
id: literal,
|
|
149
|
+
validStatic: literal !== null,
|
|
150
|
+
expression: arg
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
for (const match of source.matchAll(EXTERNAL_REF_RE)) {
|
|
155
|
+
const expression = match[1];
|
|
156
|
+
const [name] = expression.split(/[.\[]/, 1);
|
|
157
|
+
if (!importedNames.has(name)) continue;
|
|
158
|
+
references.push({
|
|
159
|
+
kind: "external",
|
|
160
|
+
raw: match[0],
|
|
161
|
+
start: match.index,
|
|
162
|
+
end: match.index + match[0].length,
|
|
163
|
+
local: name,
|
|
164
|
+
path: memberExpressionToPath(expression.slice(name.length)),
|
|
165
|
+
expression
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return references.sort((a, b) => a.start - b.start);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function parseStringLiteral(value) {
|
|
173
|
+
if (!/^(['"])(?:\\.|(?!\1).)*\1$/s.test(value)) return null;
|
|
174
|
+
try {
|
|
175
|
+
return JSON.parse(value[0] === "'" ? `"${value.slice(1, -1).replace(/"/g, '\\"')}"` : value);
|
|
176
|
+
} catch {
|
|
177
|
+
return value.slice(1, -1);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function memberExpressionToPath(expression) {
|
|
182
|
+
const segments = [];
|
|
183
|
+
let rest = expression;
|
|
184
|
+
while (rest) {
|
|
185
|
+
const dot = rest.match(/^\.(?!\$)([A-Za-z_$][\w$]*)/);
|
|
186
|
+
if (dot) {
|
|
187
|
+
segments.push(dot[1]);
|
|
188
|
+
rest = rest.slice(dot[0].length);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
const bracket = rest.match(/^\[(["'])((?:\\.|(?!\1).)*)\]/s);
|
|
192
|
+
if (bracket) {
|
|
193
|
+
segments.push(unescapeQuoted(bracket[2]));
|
|
194
|
+
rest = rest.slice(bracket[0].length);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
return segments.join(".");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function unescapeQuoted(value) {
|
|
203
|
+
try {
|
|
204
|
+
return JSON.parse(`"${value.replace(/"/g, '\\"')}"`);
|
|
205
|
+
} catch {
|
|
206
|
+
return value.replace(/\\(['"\\])/g, "$1");
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function resolveImportPath(fromFile, specifier) {
|
|
211
|
+
const base = path.resolve(path.dirname(fromFile), specifier.replace(/\.cspec$/, ".mdx"));
|
|
212
|
+
return base;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function lineAt(source, index) {
|
|
216
|
+
let line = 1;
|
|
217
|
+
for (let i = 0; i < index; i += 1) {
|
|
218
|
+
if (source.charCodeAt(i) === 10) line += 1;
|
|
219
|
+
}
|
|
220
|
+
return line;
|
|
221
|
+
}
|
package/src/render.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { CspecError } from "./errors.js";
|
|
2
|
+
import { lineAt, resolveImportPath } from "./parser.js";
|
|
3
|
+
|
|
4
|
+
export function compileWorkspace(documents) {
|
|
5
|
+
const byFile = new Map(documents.map((doc) => [doc.file, doc]));
|
|
6
|
+
const context = { byFile, stack: [] };
|
|
7
|
+
for (const doc of documents) {
|
|
8
|
+
compileBlock(doc, doc.root, context);
|
|
9
|
+
for (const block of doc.blocks.values()) compileBlock(doc, block, context);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function compileBlock(doc, block, context) {
|
|
14
|
+
const key = `${doc.file}#${block.id || "$"}`;
|
|
15
|
+
if (block.compiled !== null) 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
|
+
|
|
29
|
+
function renderRange(doc, start, end, context) {
|
|
30
|
+
const removals = doc.rangesToRemove
|
|
31
|
+
.filter(([a, b]) => b > start && a < end)
|
|
32
|
+
.map(([a, b]) => [Math.max(a, start), Math.min(b, end), ""]);
|
|
33
|
+
const replacements = doc.references
|
|
34
|
+
.filter((ref) => ref.start >= start && ref.end <= end)
|
|
35
|
+
.map((ref) => [ref.start, ref.end, resolveReference(doc, ref, context)]);
|
|
36
|
+
const edits = removals.concat(replacements).sort((a, b) => a[0] - b[0] || b[1] - a[1]);
|
|
37
|
+
|
|
38
|
+
let out = "";
|
|
39
|
+
let cursor = start;
|
|
40
|
+
for (const [a, b, value] of edits) {
|
|
41
|
+
if (a < cursor) continue;
|
|
42
|
+
out += doc.source.slice(cursor, a);
|
|
43
|
+
out += value;
|
|
44
|
+
cursor = b;
|
|
45
|
+
}
|
|
46
|
+
out += doc.source.slice(cursor, end);
|
|
47
|
+
return trimOuterBlankLines(out);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function resolveReference(doc, ref, context) {
|
|
51
|
+
if (ref.kind === "local") {
|
|
52
|
+
if (!ref.validStatic) {
|
|
53
|
+
throw new CspecError("ref(...) requires a static string literal.", {
|
|
54
|
+
file: doc.file,
|
|
55
|
+
line: lineAt(doc.source, ref.start)
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
const target = doc.blocks.get(ref.id);
|
|
59
|
+
if (!target) {
|
|
60
|
+
throw new CspecError(`Unknown local block reference "${ref.id}".`, {
|
|
61
|
+
file: doc.file,
|
|
62
|
+
line: lineAt(doc.source, ref.start)
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return compileBlock(doc, target, context);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const imported = doc.imports.find((item) => item.kind === "module" && item.local === ref.local);
|
|
69
|
+
const targetFile = resolveImportPath(doc.file, imported.specifier);
|
|
70
|
+
const targetDoc = context.byFile.get(targetFile);
|
|
71
|
+
if (!targetDoc) {
|
|
72
|
+
throw new CspecError(`Unresolved import ${imported.specifier}.`, {
|
|
73
|
+
file: doc.file,
|
|
74
|
+
line: lineAt(doc.source, ref.start)
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
const target = ref.path ? targetDoc.blocks.get(ref.path) : targetDoc.root;
|
|
78
|
+
if (!target) {
|
|
79
|
+
throw new CspecError(`Unknown block reference ${ref.expression}.`, {
|
|
80
|
+
file: doc.file,
|
|
81
|
+
line: lineAt(doc.source, ref.start)
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return compileBlock(targetDoc, target, context);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function trimOuterBlankLines(value) {
|
|
88
|
+
return value.replace(/^\s*\n/, "").replace(/\n\s*$/, "");
|
|
89
|
+
}
|
package/src/runtime.js
ADDED
package/src/workspace.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
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
|
+
|
|
10
|
+
export async function loadWorkspace(cwd) {
|
|
11
|
+
const config = await loadConfig(cwd);
|
|
12
|
+
const files = discoverFiles(cwd, config.specs);
|
|
13
|
+
const documents = files.map((file) => parseSource(file, fs.readFileSync(file, "utf8")));
|
|
14
|
+
compileWorkspace(documents);
|
|
15
|
+
return { cwd, config, documents };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function expectedOutputs(workspace) {
|
|
19
|
+
return workspace.documents.flatMap((doc) =>
|
|
20
|
+
generatedFilesFor(doc, { markdownEmit: workspace.config.markdown.emit })
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function writeOutputs(workspace) {
|
|
25
|
+
for (const output of expectedOutputs(workspace)) {
|
|
26
|
+
fs.writeFileSync(output.file, output.content);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function assertFresh(workspace) {
|
|
31
|
+
for (const output of expectedOutputs(workspace)) {
|
|
32
|
+
const rel = path.relative(workspace.cwd, output.file);
|
|
33
|
+
if (!fs.existsSync(output.file) || fs.readFileSync(output.file, "utf8") !== output.content) {
|
|
34
|
+
throw new CspecError(`Generated file ${rel} is stale.\nRun cspec build.`, { file: output.file });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|