@modularcloud/cspec 0.1.0 → 0.3.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.
@@ -0,0 +1,14 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <link rel="icon" href="data:," />
7
+ <title>cspec Editor</title>
8
+ <script type="module" crossorigin src="/assets/index-DL-RbHCV.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-BV-lxU2y.css">
10
+ </head>
11
+ <body>
12
+ <div id="root"></div>
13
+ </body>
14
+ </html>
@@ -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
@@ -1,13 +1,10 @@
1
1
  export function S(props) {
2
- return props?.children ?? null;
2
+ return props?.children ?? null;
3
3
  }
4
-
5
4
  export const Spec = S;
6
-
7
5
  export function ref(id) {
8
- return { id, $: "" };
6
+ return { id, $: "" };
9
7
  }
10
-
11
8
  export function defineConfig(config) {
12
- return config;
9
+ return config;
13
10
  }
package/package.json CHANGED
@@ -1,22 +1,40 @@
1
1
  {
2
2
  "name": "@modularcloud/cspec",
3
- "version": "0.1.0",
4
- "description": "Minimal MDX content block compiler implementing the cspec reusable-block subset.",
3
+ "version": "0.3.0",
4
+ "description": "CLI and runtime entrypoint for cspec reusable Markdown blocks.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/modularcloud/xspec"
8
+ },
5
9
  "type": "module",
6
10
  "bin": {
7
- "cspec": "bin/cspec.js"
11
+ "cspec": "dist/bin/cspec.js"
8
12
  },
13
+ "types": "./dist/runtime.d.ts",
9
14
  "exports": {
10
- ".": "./src/runtime.js"
15
+ ".": {
16
+ "types": "./dist/runtime.d.ts",
17
+ "default": "./dist/runtime.js"
18
+ },
19
+ "./cli": {
20
+ "types": "./dist/cli.d.ts",
21
+ "default": "./dist/cli.js"
22
+ }
11
23
  },
12
24
  "scripts": {
13
- "test": "node --test",
14
- "build": "node ./bin/cspec.js build",
15
- "prepack": "node --test"
25
+ "build": "tsc -p tsconfig.json && cp ../cspec-core/dist/runtime.js dist/runtime.js && cp ../cspec-core/dist/runtime.d.ts dist/runtime.d.ts && rm -rf dist/editor && mkdir -p dist/editor && cp -R ../cspec-app/dist/. dist/editor/ && node -e \"require('node:fs').chmodSync('dist/bin/cspec.js', 0o755)\"",
26
+ "check": "tsc -p tsconfig.json --noEmit",
27
+ "test": "node --test test.js"
28
+ },
29
+ "dependencies": {
30
+ "@modularcloud/cspec-core": "^0.3.0"
31
+ },
32
+ "devDependencies": {
33
+ "@modularcloud/cspec-app": "0.1.0"
16
34
  },
17
35
  "files": [
18
- "bin",
19
- "src"
36
+ "dist",
37
+ "README.md"
20
38
  ],
21
39
  "keywords": [
22
40
  "cspec",
@@ -32,6 +50,5 @@
32
50
  },
33
51
  "engines": {
34
52
  "node": ">=20"
35
- },
36
- "devDependencies": {}
53
+ }
37
54
  }
package/bin/cspec.js DELETED
@@ -1,7 +0,0 @@
1
- #!/usr/bin/env node
2
- import { runCli } from "../src/cli.js";
3
-
4
- runCli(process.argv.slice(2)).catch((error) => {
5
- console.error(error?.message || String(error));
6
- process.exitCode = 1;
7
- });
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
- }
package/src/glob.js DELETED
@@ -1,42 +0,0 @@
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 DELETED
@@ -1,45 +0,0 @@
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 DELETED
@@ -1,221 +0,0 @@
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
- }