From ed16de09e85cd2eceab18db4944c31e30bb8b028 Mon Sep 17 00:00:00 2001 From: lzrs Date: Thu, 28 May 2026 14:17:50 -0700 Subject: [PATCH 1/2] Use scoped cspec package import --- README.md | 6 +++--- packages/cspec-app/src/main.tsx | 4 ++-- packages/cspec-core/src/parser.ts | 4 +++- packages/cspec-core/test.js | 25 +++++++++++++++---------- packages/cspec-editor/test.js | 2 +- 5 files changed, 24 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 8abeddf..e984c86 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ npx cspec build Create a source document: ```mdx -import { S } from "cspec" +import { S } from "@modularcloud/cspec" Writing requirements. @@ -105,7 +105,7 @@ Prints IDs as a tree or JSON. Add `cspec.config.ts`: ```ts -import { defineConfig } from "cspec" +import { defineConfig } from "@modularcloud/cspec" export default defineConfig({ specs: { @@ -168,7 +168,7 @@ import RULES from "./RULES.cspec" Embed a local block with `ref("id").$`: ```mdx -import { ref } from "cspec" +import { ref } from "@modularcloud/cspec" {ref("writing.beSpecific").$} ``` diff --git a/packages/cspec-app/src/main.tsx b/packages/cspec-app/src/main.tsx index 95583f1..50360d9 100644 --- a/packages/cspec-app/src/main.tsx +++ b/packages/cspec-app/src/main.tsx @@ -361,7 +361,7 @@ const demoFiles: AppFile[] = [ { file: "docs/RULES.mdx", dirty: false, - source: `import { S } from "cspec" + source: `import { S } from "@modularcloud/cspec" Writing rules. @@ -379,7 +379,7 @@ Ask for clarification when behavior is ambiguous. { file: "prompts/PROMPT.mdx", dirty: false, - source: `import { S, ref } from "cspec" + source: `import { S, ref } from "@modularcloud/cspec" import RULES from "../docs/RULES.cspec" # Instructions diff --git a/packages/cspec-core/src/parser.ts b/packages/cspec-core/src/parser.ts index 04a7ba1..b2b18ad 100644 --- a/packages/cspec-core/src/parser.ts +++ b/packages/cspec-core/src/parser.ts @@ -179,6 +179,8 @@ function expandStandaloneRange(source: string, start: number, end: number): [num return [start, end]; } +const CSPEC_RUNTIME_IMPORT = "@modularcloud/cspec"; + function parseImports(source: string): CspecImport[] { const imports: CspecImport[] = []; for (const match of source.matchAll(IMPORT_RE)) { @@ -186,7 +188,7 @@ function parseImports(source: string): CspecImport[] { const clause = (match[1] ?? "").trim(); const start = match.index ?? 0; const end = start + match[0].length; - if (specifier === "cspec") { + if (specifier === CSPEC_RUNTIME_IMPORT) { imports.push({ kind: "cspec", clause, specifier, start, end }); continue; } diff --git a/packages/cspec-core/test.js b/packages/cspec-core/test.js index cf9f5d0..91e4400 100644 --- a/packages/cspec-core/test.js +++ b/packages/cspec-core/test.js @@ -5,11 +5,11 @@ import path from "node:path"; import { test } from "node:test"; import { loadWorkspace, writeOutputs, assertFresh } from "./dist/workspace.js"; import { matchGlob } from "./dist/glob.js"; -import { resolveImportPath } from "./dist/parser.js"; +import { parseSource, resolveImportPath } from "./dist/parser.js"; test("builds generated modules and markdown with external references", async () => { const dir = tmp(); - fs.writeFileSync(path.join(dir, "RULES.mdx"), `import { S } from "cspec" + fs.writeFileSync(path.join(dir, "RULES.mdx"), `import { S } from "@modularcloud/cspec" Writing rules. @@ -51,7 +51,7 @@ Ask for clarification when behavior is ambiguous. test("supports local refs and non-identifier-safe segments", async () => { const dir = tmp(); - fs.writeFileSync(path.join(dir, "GUIDE.mdx"), `import { S, ref } from "cspec" + fs.writeFileSync(path.join(dir, "GUIDE.mdx"), `import { S, ref } from "@modularcloud/cspec" Rules: @@ -99,13 +99,13 @@ test("reports validation errors", async () => { test("reports unknown references and cycles", async () => { const unknown = tmp(); - fs.writeFileSync(path.join(unknown, "BAD.mdx"), `import { ref } from "cspec" + fs.writeFileSync(path.join(unknown, "BAD.mdx"), `import { ref } from "@modularcloud/cspec" {ref("missing").$} `); await assert.rejects(() => loadWorkspace(unknown), /Unknown local block reference "missing"/); const cycle = tmp(); - fs.writeFileSync(path.join(cycle, "BAD.mdx"), `import { S, ref } from "cspec" + fs.writeFileSync(path.join(cycle, "BAD.mdx"), `import { S, ref } from "@modularcloud/cspec" {ref("b").$} {ref("a").$} `); @@ -133,17 +133,17 @@ Text. test("validates dynamic refs while preserving static strings containing parens", async () => { const dynamic = tmp(); - fs.writeFileSync(path.join(dynamic, "BAD.mdx"), `import { ref } from "cspec" + fs.writeFileSync(path.join(dynamic, "BAD.mdx"), `import { ref } from "@modularcloud/cspec" {ref(getId()).$} `); await assert.rejects(() => loadWorkspace(dynamic), /ref\(\.\.\.\) requires a static string literal/); const template = tmp(); - fs.writeFileSync(path.join(template, "BAD.mdx"), "import { ref } from \"cspec\"\n{ref(`a)`).$}\n"); + fs.writeFileSync(path.join(template, "BAD.mdx"), "import { ref } from \"@modularcloud/cspec\"\n{ref(`a)`).$}\n"); await assert.rejects(() => loadWorkspace(template), /ref\(\.\.\.\) requires a static string literal/); const paren = tmp(); - fs.writeFileSync(path.join(paren, "OK.mdx"), `import { S, ref } from "cspec" + fs.writeFileSync(path.join(paren, "OK.mdx"), `import { S, ref } from "@modularcloud/cspec" Paren id. {ref("a)").$} `); @@ -151,7 +151,7 @@ test("validates dynamic refs while preserving static strings containing parens", assert.equal(workspace.documents[0].root.compiled, "Paren id.\nParen id."); const singleQuoted = tmp(); - fs.writeFileSync(path.join(singleQuoted, "OK.mdx"), `import { S, ref } from "cspec" + fs.writeFileSync(path.join(singleQuoted, "OK.mdx"), `import { S, ref } from "@modularcloud/cspec" Quoted id. {ref('a\\'b').$} `); @@ -178,7 +178,7 @@ test("loads cspec.config.ts and detects stale outputs", async () => { const dir = tmp(); fs.mkdirSync(path.join(dir, "docs")); fs.mkdirSync(path.join(dir, "ignored")); - fs.writeFileSync(path.join(dir, "cspec.config.ts"), `import { defineConfig } from "cspec" + fs.writeFileSync(path.join(dir, "cspec.config.ts"), `import { defineConfig } from "@modularcloud/cspec" export default defineConfig({ specs: { docs: ["docs/**/*.mdx"] }, @@ -205,6 +205,11 @@ test("matches common cspec globs", () => { assert.equal(matchGlob("docs/*.mdx", "docs/nested/A.mdx"), false); }); +test("recognizes only the scoped runtime package import", () => { + assert.equal(parseSource("A.mdx", 'import { S } from "@modularcloud/cspec"\n').imports.length, 1); + assert.equal(parseSource("A.mdx", 'import { S } from "cspec"\n').imports.length, 0); +}); + test("resolves imports across posix and windows-style paths", () => { assert.equal(resolveImportPath("/repo/prompts/PROMPT.mdx", "../docs/RULES.cspec"), "/repo/docs/RULES.mdx"); assert.equal(resolveImportPath("prompts/PROMPT.mdx", "../docs/RULES.cspec"), "docs/RULES.mdx"); diff --git a/packages/cspec-editor/test.js b/packages/cspec-editor/test.js index 9bbc2de..23794b3 100644 --- a/packages/cspec-editor/test.js +++ b/packages/cspec-editor/test.js @@ -16,7 +16,7 @@ import { setSourceMode } from "./dist/index.js"; -const source = `import { S, ref } from "cspec" +const source = `import { S, ref } from "@modularcloud/cspec" import RULES from "./RULES.cspec" # Prompt From e0e357431414be7df187c8d860b7633461b138c7 Mon Sep 17 00:00:00 2001 From: lzrs Date: Fri, 29 May 2026 10:25:09 -0700 Subject: [PATCH 2/2] Add collapsible hierarchy guides to hybrid editor --- packages/cspec-editor/src/index.ts | 270 ++++++++++++++++++++++++++++- 1 file changed, 263 insertions(+), 7 deletions(-) diff --git a/packages/cspec-editor/src/index.ts b/packages/cspec-editor/src/index.ts index 2e21847..955b2a6 100644 --- a/packages/cspec-editor/src/index.ts +++ b/packages/cspec-editor/src/index.ts @@ -67,6 +67,7 @@ export interface CspecJumpTarget { export const setSourceMode = StateEffect.define(); export const setWorkspace = StateEffect.define(); +const toggleCollapsedBlock = StateEffect.define(); const sourceModeField = StateField.define({ create: () => false, @@ -88,10 +89,30 @@ const workspaceField = StateField.define({ } }); +const collapsedBlocksField = StateField.define>({ + create: () => new Set(), + update(value, tr) { + let next = value; + for (const effect of tr.effects) { + if (!effect.is(toggleCollapsedBlock)) continue; + next = new Set(next); + if (next.has(effect.value)) { + next.delete(effect.value); + } else { + next.add(effect.value); + } + } + if (tr.docChanged && next.size) return new Set(); + return next; + } +}); + export function createCspecEditorExtensions(options: CspecEditorOptions): Extension[] { return [ sourceModeField.init(() => options.sourceMode ?? false), workspaceField.init(() => options.workspace), + collapsedBlocksField, + collapsedContentDecorations(options.file), history(), markdown(), syntaxHighlighting(cspecHighlightStyle), @@ -250,19 +271,49 @@ function buildDecorations(view: EditorView, file: string, onJumpToDefinition?: ( return Decoration.none; } const workspace = view.state.field(workspaceField); + const collapsedBlocks = view.state.field(collapsedBlocksField); const ranges = []; - for (const block of doc.blocks.values()) { + const blocks = [...doc.blocks.values()].sort((a, b) => a.openStart - b.openStart); + const hiddenRanges: Array<{ from: number; to: number }> = []; + const lineDepths = new Map(); + for (const block of blocks) { + if (rangeIsHidden(block.openStart, hiddenRanges)) continue; + const depth = blockDepth(block); + const collapsed = collapsedBlocks.has(block.id); + const hasChildren = block.children.length > 0; const hasCursor = selectionOverlaps(view.state.selection, block.openStart, block.openEnd); const hasDiagnostic = workspace.diagnostics.some((diag) => diag.file === file && rangesOverlap(diag.from, diag.to, block.openStart, block.openEnd)); if (!hasCursor && !hasDiagnostic) { - ranges.push(Decoration.replace({ widget: new BlockHeaderWidget(block) }).range(block.openStart, block.openEnd)); + ranges.push(Decoration.replace({ + widget: new BlockHeaderWidget(block, { + collapsed, + depth, + hasChildren, + onToggle: () => view.dispatch({ effects: toggleCollapsedBlock.of(block.id) }) + }) + }).range(block.openStart, block.openEnd)); + } + addLineDepths(view.state.doc, lineDepths, firstLineAfter(view.state.doc, block.contentStart), block.contentEnd, depth + 1); + if (collapsed) { + addCollapsedRange(block, hiddenRanges); + continue; } if (block.closeStart !== null && !selectionOverlaps(view.state.selection, block.closeStart, block.closeEnd ?? block.closeStart)) { ranges.push(Decoration.replace({}).range(block.closeStart, block.closeEnd ?? block.closeStart)); } } + for (const [lineFrom, depth] of lineDepths) { + if (rangeIsHidden(lineFrom, hiddenRanges)) continue; + ranges.push(Decoration.line({ + attributes: { + class: "cm-cspec-indentedLine", + style: `--cspec-depth: ${depth}` + } + }).range(lineFrom)); + } for (const ref of doc.references) { if (selectionOverlaps(view.state.selection, ref.start, ref.end)) continue; + if (rangeIsHidden(ref.start, hiddenRanges)) continue; ranges.push(Decoration.replace({ widget: new EmbedWidget(ref, previewForReference(doc, ref, workspace), () => { const target = findDefinition(file, view.state.doc.toString(), ref.start, view.state.field(workspaceField)); @@ -273,14 +324,127 @@ function buildDecorations(view: EditorView, file: string, onJumpToDefinition?: ( return Decoration.set(ranges, true); } +function collapsedContentDecorations(file: string): StateField { + return StateField.define({ + create(state) { + return buildCollapsedContentDecorations(state, file); + }, + update(value, tr) { + if ( + tr.docChanged || + tr.effects.some((effect) => effect.is(toggleCollapsedBlock) || effect.is(setSourceMode)) + ) { + return buildCollapsedContentDecorations(tr.state, file); + } + return value; + }, + provide: (field) => EditorView.decorations.from(field) + }); +} + +function buildCollapsedContentDecorations(state: EditorState, file: string): DecorationSet { + if (state.field(sourceModeField)) return Decoration.none; + const collapsedBlocks = state.field(collapsedBlocksField); + if (!collapsedBlocks.size) return Decoration.none; + let doc: CspecDocument; + try { + doc = parseSource(file, state.doc.toString()); + } catch { + return Decoration.none; + } + const ranges = []; + const hiddenRanges: Array<{ from: number; to: number }> = []; + for (const block of [...doc.blocks.values()].sort((a, b) => a.openStart - b.openStart)) { + if (!collapsedBlocks.has(block.id) || rangeIsHidden(block.openStart, hiddenRanges)) continue; + addCollapsedRange(block, hiddenRanges); + const collapseEnd = block.closeEnd ?? block.contentEnd; + if (block.contentStart < collapseEnd) { + ranges.push(Decoration.replace({ widget: new CollapsedBlockWidget(block, blockDepth(block)) }).range(block.contentStart, collapseEnd)); + } + } + return Decoration.set(ranges, true); +} + class BlockHeaderWidget extends WidgetType { - constructor(private readonly block: CspecBlock) { + constructor( + private readonly block: CspecBlock, + private readonly options: { + collapsed: boolean; + depth: number; + hasChildren: boolean; + onToggle: () => void; + } + ) { super(); } toDOM(): HTMLElement { const element = document.createElement("div"); element.className = "cm-cspec-blockHeader"; - element.textContent = `▣ ${this.block.id}`; + element.style.setProperty("--cspec-depth", String(this.options.depth)); + + const toggle = document.createElement("button"); + toggle.className = "cm-cspec-collapseButton"; + toggle.type = "button"; + toggle.title = this.options.collapsed ? "Expand block" : "Collapse block"; + toggle.ariaLabel = toggle.title; + toggle.dataset.cspecBlockToggle = this.block.id; + toggle.disabled = !this.options.hasChildren && this.block.contentStart === this.block.contentEnd; + toggle.append(makeChevronIcon(this.options.collapsed ? "right" : "down")); + toggle.addEventListener("mousedown", (event) => { + event.preventDefault(); + event.stopPropagation(); + }); + toggle.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + this.options.onToggle(); + }); + + const marker = document.createElement("span"); + marker.className = "cm-cspec-blockMarker"; + marker.textContent = "S"; + + const label = document.createElement("span"); + label.className = "cm-cspec-blockTitle"; + label.textContent = this.block.id; + + const meta = document.createElement("span"); + meta.className = "cm-cspec-blockMeta"; + meta.textContent = this.block.children.length ? `${this.block.children.length} ${this.block.children.length === 1 ? "child" : "children"}` : "leaf"; + + element.append(toggle, marker, label, meta); + return element; + } + ignoreEvent(): boolean { + return true; + } +} + +function makeChevronIcon(direction: "down" | "right"): SVGElement { + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("aria-hidden", "true"); + svg.setAttribute("focusable", "false"); + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute("d", direction === "down" ? "m6 9 6 6 6-6" : "m9 18 6-6-6-6"); + path.setAttribute("fill", "none"); + path.setAttribute("stroke", "currentColor"); + path.setAttribute("stroke-linecap", "round"); + path.setAttribute("stroke-linejoin", "round"); + path.setAttribute("stroke-width", "2"); + svg.append(path); + return svg; +} + +class CollapsedBlockWidget extends WidgetType { + constructor(private readonly block: CspecBlock, private readonly depth: number) { + super(); + } + toDOM(): HTMLElement { + const element = document.createElement("div"); + element.className = "cm-cspec-collapsedBlock"; + element.style.setProperty("--cspec-depth", String(this.depth)); + element.textContent = `${this.block.children.length} nested block${this.block.children.length === 1 ? "" : "s"} hidden`; return element; } } @@ -403,6 +567,35 @@ function previewForReference(doc: CspecDocument, ref: CspecReference, workspace: return workspace.blocks.find((block) => block.file === target.file && block.id === target.id)?.compiled ?? null; } +function blockDepth(block: CspecBlock): number { + return Math.max(0, block.segments.length - 1); +} + +function addLineDepths(doc: EditorState["doc"], lineDepths: Map, from: number, to: number, depth: number): void { + if (from >= to) return; + let line = doc.lineAt(Math.min(from, doc.length)); + const lastLine = doc.lineAt(Math.min(to, doc.length)).number; + while (line.number <= lastLine) { + lineDepths.set(line.from, Math.max(lineDepths.get(line.from) ?? 0, depth)); + if (line.number === lastLine) break; + line = doc.line(line.number + 1); + } +} + +function firstLineAfter(doc: EditorState["doc"], position: number): number { + const line = doc.lineAt(Math.min(position, doc.length)); + return line.to < doc.length ? line.to + 1 : line.to; +} + +function addCollapsedRange(block: CspecBlock, hiddenRanges: Array<{ from: number; to: number }>): void { + const collapseEnd = block.closeEnd ?? block.contentEnd; + if (block.contentStart < collapseEnd) hiddenRanges.push({ from: block.contentStart, to: collapseEnd }); +} + +function rangeIsHidden(position: number, hiddenRanges: Array<{ from: number; to: number }>): boolean { + return hiddenRanges.some((range) => position >= range.from && position < range.to); +} + function errorToDiagnostic(file: string, source: string, error: unknown): CspecDiagnostic { const message = error instanceof Error ? error.message : String(error); const err = error as CspecError; @@ -575,13 +768,76 @@ const cspecTheme = EditorView.theme({ }, ".cm-cspec-blockHeader": { boxSizing: "border-box", - borderLeft: "3px solid #4f8cff", - background: "#eef5ff", + display: "grid", + gridTemplateColumns: "22px 22px minmax(0, 1fr) auto", + alignItems: "center", + gap: "7px", + borderLeft: "2px solid #4f8cff", + background: "linear-gradient(90deg, #eef5ff 0%, #f7fbff 100%)", color: "#17345f", fontFamily: "Inter, ui-sans-serif, system-ui, sans-serif", fontWeight: "700", padding: "6px 8px", - margin: "6px 0 4px" + margin: "0 0 4px", + width: "100%" + }, + ".cm-cspec-collapseButton": { + display: "grid", + placeItems: "center", + width: "20px", + height: "20px", + border: "1px solid #b7c9dc", + borderRadius: "4px", + background: "#ffffff", + color: "#31506f", + cursor: "pointer", + font: "700 11px/1 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", + padding: "0" + }, + ".cm-cspec-collapseButton svg": { + width: "14px", + height: "14px" + }, + ".cm-cspec-collapseButton:disabled": { + color: "#9aaabd", + cursor: "default", + opacity: "0.75" + }, + ".cm-cspec-blockMarker": { + display: "grid", + placeItems: "center", + width: "20px", + height: "20px", + borderRadius: "50%", + background: "#d8e8ff", + color: "#17345f", + fontSize: "11px" + }, + ".cm-cspec-blockTitle": { + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap" + }, + ".cm-cspec-blockMeta": { + color: "#536b84", + fontSize: "11px", + fontWeight: "600" + }, + ".cm-cspec-collapsedBlock": { + boxSizing: "border-box", + borderLeft: "2px solid #b7c9dc", + color: "#536b84", + fontFamily: "Inter, ui-sans-serif, system-ui, sans-serif", + fontSize: "12px", + fontWeight: "700", + margin: "0 0 4px calc((var(--cspec-depth, 0) + 1) * 18px)", + padding: "4px 8px" + }, + ".cm-cspec-indentedLine": { + paddingLeft: "calc(var(--cspec-depth, 0) * 18px + 8px)", + backgroundImage: "repeating-linear-gradient(to right, transparent 0 17px, #d7e1ec 17px 18px)", + backgroundRepeat: "no-repeat", + backgroundSize: "calc(var(--cspec-depth, 0) * 18px) 100%" }, ".cm-cspec-embed": { boxSizing: "border-box",