Skip to content

Commit b1a3eed

Browse files
authored
feat: guide agents through webstudio cli (#5847)
1 parent 8136386 commit b1a3eed

24 files changed

Lines changed: 862 additions & 148 deletions

packages/cli/package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
],
2424
"scripts": {
2525
"typecheck": "tsgo --noEmit",
26-
"generate-docs": "node scripts/generate-docs.mjs",
26+
"generate-docs": "tsx scripts/generate-docs.ts",
2727
"build": "pnpm generate-docs && rm -rf lib && vite build",
2828
"test": "vitest run"
2929
},
@@ -68,6 +68,7 @@
6868
"@remix-run/node": "^2.16.5",
6969
"@remix-run/react": "^2.16.5",
7070
"@remix-run/server-runtime": "^2.16.5",
71+
"@types/mdast": "^4.0.4",
7172
"@types/react": "^18.2.70",
7273
"@types/react-dom": "^18.2.25",
7374
"@types/yargs": "^17.0.33",
@@ -88,6 +89,10 @@
8889
"h3": "^1.15.1",
8990
"ipx": "^3.0.3",
9091
"isbot": "^5.1.25",
92+
"mdast-util-directive": "^3.1.0",
93+
"mdast-util-from-markdown": "^2.0.3",
94+
"mdast-util-to-string": "^4.0.0",
95+
"micromark-extension-directive": "^4.0.0",
9196
"prettier": "3.5.3",
9297
"react": "18.3.0-canary-14898b6a9-20240318",
9398
"react-dom": "18.3.0-canary-14898b6a9-20240318",
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
2+
import { basename, join } from "node:path";
3+
import { format } from "prettier";
4+
import {
5+
buildDocSections,
6+
buildDocTitles,
7+
stripDocMeta,
8+
} from "./markdown-docs";
9+
10+
const docsDir = new URL("../src/docs", import.meta.url);
11+
const outputFile = new URL("../src/docs.generated.ts", import.meta.url);
12+
13+
const sourceDocs = Object.fromEntries(
14+
readdirSync(docsDir)
15+
.filter((fileName) => fileName.endsWith(".md"))
16+
.sort()
17+
.map((fileName) => [
18+
basename(fileName, ".md"),
19+
readFileSync(join(docsDir.pathname, fileName), "utf8"),
20+
])
21+
);
22+
23+
const docs = Object.fromEntries(
24+
Object.entries(sourceDocs).map(([docName, markdown]) => [
25+
docName,
26+
stripDocMeta(markdown),
27+
])
28+
);
29+
const docTitles = buildDocTitles(sourceDocs);
30+
const docSections = buildDocSections(sourceDocs);
31+
32+
const output = await format(
33+
[
34+
"// This file is generated by scripts/generate-docs.ts.",
35+
"// Edit markdown files in src/docs instead.",
36+
`export const cliDocs = ${JSON.stringify(docs, null, 2)} as const;`,
37+
"",
38+
`export const cliDocTitles = ${JSON.stringify(docTitles, null, 2)} as const;`,
39+
"",
40+
`export const cliDocSections = ${JSON.stringify(docSections, null, 2)} as const;`,
41+
"",
42+
"export type CliDocName = keyof typeof cliDocs;",
43+
"",
44+
].join("\n"),
45+
{
46+
filepath: outputFile.pathname,
47+
}
48+
);
49+
50+
writeFileSync(outputFile, output, "utf8");
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { describe, expect, test } from "vitest";
2+
import {
3+
buildDocTitles,
4+
buildDocSections,
5+
stripDocMeta,
6+
} from "./markdown-docs";
7+
8+
describe("markdown docs metadata extraction", () => {
9+
test("builds directive-marked doc sections", () => {
10+
const docs = {
11+
"manual-api": `# API
12+
13+
::doc-section{field="safetyRules"}
14+
15+
Rules:
16+
17+
- API rule.
18+
19+
::doc-section{field="safetyRules"}
20+
21+
## Safety Rules
22+
23+
- Safety rule.`,
24+
"manual-llm": `# LLM
25+
26+
::doc-section{field="implementationProcess"}
27+
28+
## LLM Implementation Process
29+
30+
1. Discover.
31+
32+
::doc-section{field="visualDesignWorkflow"}
33+
34+
## Visual Design Workflow
35+
36+
- Verify visually.
37+
38+
::doc-section{field="generatedFileGuardrails"}
39+
40+
## Generated Files Guardrails
41+
42+
- Avoid generated files.
43+
44+
::doc-section{field="rules"}
45+
46+
## Rules
47+
48+
- LLM rule.`,
49+
"manual-mcp": `# MCP
50+
51+
::doc-section{field="rules"}
52+
53+
## Core Rules
54+
55+
- MCP rule.`,
56+
};
57+
58+
expect(buildDocSections(docs)).toEqual({
59+
"manual-api": {
60+
safetyRules: ["API rule.", "Safety rule."],
61+
},
62+
"manual-llm": {
63+
implementationProcess: ["Discover."],
64+
visualDesignWorkflow: ["Verify visually."],
65+
generatedFileGuardrails: ["Avoid generated files."],
66+
rules: ["LLM rule."],
67+
},
68+
"manual-mcp": {
69+
rules: ["MCP rule."],
70+
},
71+
});
72+
});
73+
74+
test("builds titles and strips generation-only directives", () => {
75+
const docs = {
76+
"manual-api": `# API
77+
78+
::doc-section{field="safetyRules"}
79+
80+
## Safety Rules
81+
82+
- Safety rule.`,
83+
};
84+
85+
expect(buildDocTitles(docs)).toEqual({
86+
"manual-api": "API",
87+
});
88+
expect(stripDocMeta(docs["manual-api"])).not.toContain("doc-section");
89+
expect(stripDocMeta(docs["manual-api"])).toContain(
90+
"# API\n\n## Safety Rules"
91+
);
92+
});
93+
94+
test("fails on empty directive-marked lists", () => {
95+
expect(() =>
96+
buildDocSections({
97+
"manual-mcp":
98+
'# MCP\n\n::doc-section{field="rules"}\n\n## Core Rules\n\nOnly prose.',
99+
})
100+
).toThrow("Missing generated doc section manual-mcp:rules");
101+
102+
expect(() =>
103+
buildDocSections({
104+
"manual-mcp": "# MCP\n\n## Core Rules\n\n- Rule.",
105+
})
106+
).toThrow("Missing generated doc sections manual-mcp");
107+
});
108+
});
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { fromMarkdown } from "mdast-util-from-markdown";
2+
import { toString } from "mdast-util-to-string";
3+
import { directive } from "micromark-extension-directive";
4+
import {
5+
directiveFromMarkdown,
6+
type LeafDirective,
7+
} from "mdast-util-directive";
8+
import type { ListItem, RootContent } from "mdast";
9+
10+
const docSectionDirectiveName = "doc-section";
11+
12+
const parseMarkdown = (markdown: string) =>
13+
fromMarkdown(markdown, {
14+
extensions: [directive()],
15+
mdastExtensions: [directiveFromMarkdown()],
16+
});
17+
18+
const getStartOffset = (node: RootContent) => node.position?.start.offset ?? 0;
19+
20+
const getNodeText = (node: RootContent | ListItem) => toString(node);
21+
22+
const getSectionEndIndex = (
23+
children: readonly RootContent[],
24+
startIndex: number,
25+
maxHeadingDepth: number | undefined
26+
) => {
27+
for (let index = startIndex; index < children.length; index += 1) {
28+
const node = children[index];
29+
if (node?.type !== "heading") {
30+
continue;
31+
}
32+
if (maxHeadingDepth === undefined || (node.depth ?? 0) <= maxHeadingDepth) {
33+
return index;
34+
}
35+
}
36+
return children.length;
37+
};
38+
39+
const getMarkedSectionNodes = (
40+
children: readonly RootContent[],
41+
directiveIndex: number
42+
) => {
43+
const firstNode = children[directiveIndex + 1];
44+
if (firstNode === undefined) {
45+
return [];
46+
}
47+
const startsWithHeading = firstNode.type === "heading";
48+
const contentStartIndex = startsWithHeading
49+
? directiveIndex + 2
50+
: directiveIndex + 1;
51+
const endIndex = getSectionEndIndex(
52+
children,
53+
contentStartIndex,
54+
startsWithHeading ? (firstNode.depth ?? 0) : undefined
55+
);
56+
return children.slice(contentStartIndex, endIndex);
57+
};
58+
59+
const isDocSectionDirective = (node: RootContent): node is LeafDirective =>
60+
node.type === "leafDirective" && node.name === docSectionDirectiveName;
61+
62+
const getDirectiveRemovalEnd = (markdown: string, node: LeafDirective) => {
63+
let end = node.position?.end.offset ?? 0;
64+
while (markdown[end] === "\n") {
65+
end += 1;
66+
}
67+
return end;
68+
};
69+
70+
export const stripDocMeta = (markdown: string) => {
71+
const tree = parseMarkdown(markdown);
72+
const ranges = tree.children
73+
.filter(isDocSectionDirective)
74+
.map((node) => ({
75+
start: getStartOffset(node),
76+
end: getDirectiveRemovalEnd(markdown, node),
77+
}))
78+
.sort((left, right) => right.start - left.start);
79+
let strippedMarkdown = markdown;
80+
for (const range of ranges) {
81+
strippedMarkdown =
82+
strippedMarkdown.slice(0, range.start) +
83+
strippedMarkdown.slice(range.end);
84+
}
85+
return strippedMarkdown;
86+
};
87+
88+
const getListItemText = (node: ListItem) =>
89+
getNodeText(node).replace(/\s+/g, " ").trim();
90+
91+
const getListItems = (nodes: readonly RootContent[]) =>
92+
nodes.flatMap((node) =>
93+
node.type === "list" ? node.children.map(getListItemText) : []
94+
);
95+
96+
export const getTitle = (markdown: string) => {
97+
const heading = parseMarkdown(markdown).children.find(
98+
(node) => node.type === "heading" && node.depth === 1
99+
);
100+
return heading === undefined ? "" : getNodeText(heading).trim();
101+
};
102+
103+
export const buildDocSections = (docs: Record<string, string>) =>
104+
Object.fromEntries(
105+
Object.entries(docs).map(([docName, markdown]) => {
106+
const tree = parseMarkdown(markdown);
107+
const docSections: Record<string, string[]> = {};
108+
tree.children.forEach((node, index) => {
109+
if (isDocSectionDirective(node) === false) {
110+
return;
111+
}
112+
const fieldName = node.attributes?.field;
113+
if (typeof fieldName !== "string" || fieldName.length === 0) {
114+
throw new Error(`Missing doc-section field ${docName}`);
115+
}
116+
const items = getListItems(getMarkedSectionNodes(tree.children, index));
117+
if (items.length === 0) {
118+
throw new Error(
119+
`Missing generated doc section ${docName}:${fieldName}`
120+
);
121+
}
122+
docSections[fieldName] = [...(docSections[fieldName] ?? []), ...items];
123+
});
124+
if (
125+
docName.startsWith("manual-") &&
126+
Object.keys(docSections).length === 0
127+
) {
128+
throw new Error(`Missing generated doc sections ${docName}`);
129+
}
130+
return [docName, docSections];
131+
})
132+
);
133+
134+
export const buildDocTitles = (docs: Record<string, string>) =>
135+
Object.fromEntries(
136+
Object.entries(docs).map(([docName, markdown]) => {
137+
const title = getTitle(markdown);
138+
if (title === "" && docName.startsWith("manual-")) {
139+
throw new Error(`Missing generated doc title ${docName}`);
140+
}
141+
return [docName, title === "" ? docName : title];
142+
})
143+
);

0 commit comments

Comments
 (0)