@dittowords/spec-cli 0.0.1-alpha.1 → 0.0.1-alpha.10
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 +94 -26
- package/dist/api.d.ts +81 -0
- package/dist/api.js +101 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +4 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +86 -0
- package/dist/commands/check.d.ts +1 -0
- package/dist/commands/check.js +57 -0
- package/dist/commands/create-rule.d.ts +8 -0
- package/dist/commands/create-rule.js +33 -0
- package/dist/commands/init.d.ts +6 -0
- package/dist/commands/init.js +237 -0
- package/dist/commands/list.d.ts +1 -0
- package/dist/commands/list.js +51 -0
- package/dist/commands/pull.d.ts +5 -0
- package/dist/commands/pull.js +168 -0
- package/dist/commands/scaffold.d.ts +6 -0
- package/dist/commands/scaffold.js +51 -0
- package/dist/config.d.ts +16 -0
- package/dist/config.js +90 -0
- package/dist/discover.d.ts +5 -0
- package/dist/discover.js +24 -0
- package/dist/parse.d.ts +24 -0
- package/dist/parse.js +42 -0
- package/dist/serialize.d.ts +5 -0
- package/dist/serialize.js +60 -0
- package/dist/skill-content.d.ts +1 -0
- package/dist/skill-content.js +156 -0
- package/dist/skills.d.ts +4 -0
- package/dist/skills.js +278 -0
- package/package.json +19 -5
- package/src/api.ts +0 -45
- package/src/bin.js +0 -3
- package/src/cli.ts +0 -70
- package/src/commands/check.ts +0 -48
- package/src/commands/init.ts +0 -219
- package/src/commands/list.ts +0 -59
- package/src/commands/pull.ts +0 -140
- package/src/commands/scaffold.ts +0 -40
- package/src/config.ts +0 -55
- package/src/discover.ts +0 -23
- package/src/parse.ts +0 -45
- package/src/serialize.ts +0 -38
package/src/api.ts
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
import type { Config } from "./config";
|
|
2
|
-
|
|
3
|
-
export interface RuleResponse {
|
|
4
|
-
name: string;
|
|
5
|
-
type: "style" | "wordlist";
|
|
6
|
-
description: string;
|
|
7
|
-
examples: { from: string; to: string }[];
|
|
8
|
-
tags: string[];
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export interface GetRulesMCPResponse {
|
|
12
|
-
workspaceRules: RuleResponse[];
|
|
13
|
-
projectRules: Record<string, RuleResponse[]>;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export class DittoApi {
|
|
17
|
-
constructor(private readonly config: Config, private readonly apiKey: string) {}
|
|
18
|
-
|
|
19
|
-
async getRules(): Promise<RuleResponse[]> {
|
|
20
|
-
const url = new URL("/v2/rules/mcp", this.config.apiBase);
|
|
21
|
-
const res = await this.fetch(url, { method: "GET" });
|
|
22
|
-
const data = (await res.json()) as GetRulesMCPResponse;
|
|
23
|
-
return data.workspaceRules;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
private async fetch(url: URL, init: RequestInit): Promise<Response> {
|
|
27
|
-
const res = await fetch(url.toString(), {
|
|
28
|
-
...init,
|
|
29
|
-
headers: { ...this.headers(), ...(init.headers ?? {}) },
|
|
30
|
-
});
|
|
31
|
-
if (!res.ok) {
|
|
32
|
-
const body = await res.text();
|
|
33
|
-
throw new Error(`${init.method} ${url} → ${res.status}: ${body}`);
|
|
34
|
-
}
|
|
35
|
-
return res;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
private headers(): Record<string, string> {
|
|
39
|
-
return {
|
|
40
|
-
authorization: this.apiKey,
|
|
41
|
-
workspace_id: this.config.workspaceId,
|
|
42
|
-
"content-type": "application/json",
|
|
43
|
-
};
|
|
44
|
-
}
|
|
45
|
-
}
|
package/src/bin.js
DELETED
package/src/cli.ts
DELETED
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
import { check } from "./commands/check";
|
|
2
|
-
import { init } from "./commands/init";
|
|
3
|
-
import { list } from "./commands/list";
|
|
4
|
-
import { pull } from "./commands/pull";
|
|
5
|
-
import { scaffold } from "./commands/scaffold";
|
|
6
|
-
|
|
7
|
-
const COMMANDS: Record<string, (args: string[]) => Promise<void>> = {
|
|
8
|
-
init: async (args) => init({ writeAgent: args.includes("--agent") }),
|
|
9
|
-
pull: async (args) => pull({ dryRun: args.includes("--dry-run") }),
|
|
10
|
-
check: async () => check(),
|
|
11
|
-
list: async () => list(),
|
|
12
|
-
scaffold: async (args) => {
|
|
13
|
-
const name = args.find((a) => !a.startsWith("--"));
|
|
14
|
-
if (!name) {
|
|
15
|
-
process.stderr.write("Usage: ditto-spec scaffold <ComponentName> [--path <dir>]\n");
|
|
16
|
-
process.exit(2);
|
|
17
|
-
}
|
|
18
|
-
const pathIdx = args.indexOf("--path");
|
|
19
|
-
const targetDir = pathIdx !== -1 && args[pathIdx + 1] ? args[pathIdx + 1] : process.cwd();
|
|
20
|
-
return scaffold({ componentName: name, targetDir });
|
|
21
|
-
},
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
const HELP = `ditto-spec — sync .ditto.md content specs with the Ditto platform.
|
|
25
|
-
|
|
26
|
-
Usage:
|
|
27
|
-
ditto-spec <command> [options]
|
|
28
|
-
|
|
29
|
-
Commands:
|
|
30
|
-
init Set up ditto specs: creates config, workspace spec, and prints agent setup.
|
|
31
|
-
init --agent Also writes agent configuration (CLAUDE.md, .cursorrules, etc.).
|
|
32
|
-
scaffold <Name> Create a new index.ditto.md for a component.
|
|
33
|
-
scaffold <Name> --path <dir> Create the spec in a specific directory.
|
|
34
|
-
pull Pull rules from the platform into the managed keys of each spec.
|
|
35
|
-
pull --dry-run Show which files would change without writing.
|
|
36
|
-
check Parse every spec file; exit non-zero on any malformed file.
|
|
37
|
-
list Print every component spec with its surfaces and tags.
|
|
38
|
-
|
|
39
|
-
Environment:
|
|
40
|
-
DITTO_API_KEY Workspace API key (required for pull).
|
|
41
|
-
|
|
42
|
-
Config:
|
|
43
|
-
Reads dittospec.config.json from the nearest ancestor directory:
|
|
44
|
-
{ "apiBase": "https://...", "workspaceId": "...", "roots": ["design-system"] }
|
|
45
|
-
`;
|
|
46
|
-
|
|
47
|
-
async function main() {
|
|
48
|
-
const args = process.argv.slice(2);
|
|
49
|
-
const cmd = args[0];
|
|
50
|
-
|
|
51
|
-
if (!cmd || cmd === "--help" || cmd === "-h") {
|
|
52
|
-
process.stdout.write(HELP);
|
|
53
|
-
return;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const handler = COMMANDS[cmd];
|
|
57
|
-
if (!handler) {
|
|
58
|
-
process.stderr.write(`Unknown command: ${cmd}\n\n${HELP}`);
|
|
59
|
-
process.exit(2);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
try {
|
|
63
|
-
await handler(args.slice(1));
|
|
64
|
-
} catch (err) {
|
|
65
|
-
console.error(err instanceof Error ? err.message : err);
|
|
66
|
-
process.exit(1);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
main();
|
package/src/commands/check.ts
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
import path from "path";
|
|
2
|
-
import { loadConfig } from "../config";
|
|
3
|
-
import { discover } from "../discover";
|
|
4
|
-
import { parseSpecFile } from "../parse";
|
|
5
|
-
|
|
6
|
-
export async function check(): Promise<void> {
|
|
7
|
-
const { config, root } = loadConfig();
|
|
8
|
-
const files = discover(root, config.roots);
|
|
9
|
-
|
|
10
|
-
if (files.length === 0) {
|
|
11
|
-
console.log("No .ditto.md files found.");
|
|
12
|
-
return;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
let failed = 0;
|
|
16
|
-
for (const f of files) {
|
|
17
|
-
try {
|
|
18
|
-
const parsed = parseSpecFile(f.abs);
|
|
19
|
-
const specLike = parsed.spec as Record<string, unknown>;
|
|
20
|
-
|
|
21
|
-
if (parsed.kind === "component") {
|
|
22
|
-
const surfaces = specLike.surfaces;
|
|
23
|
-
if (!surfaces || typeof surfaces !== "object") {
|
|
24
|
-
throw new Error("component spec missing 'surfaces' object");
|
|
25
|
-
}
|
|
26
|
-
for (const [key, val] of Object.entries(surfaces as Record<string, unknown>)) {
|
|
27
|
-
if (!val || typeof val !== "object") {
|
|
28
|
-
throw new Error(`surface '${key}' must be an object`);
|
|
29
|
-
}
|
|
30
|
-
const surface = val as Record<string, unknown>;
|
|
31
|
-
if (!Array.isArray(surface.tags)) {
|
|
32
|
-
throw new Error(`surface '${key}' missing 'tags' array`);
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
console.log(`✓ ${path.relative(root, f.abs)}`);
|
|
38
|
-
} catch (err) {
|
|
39
|
-
failed++;
|
|
40
|
-
console.error(`× ${path.relative(root, f.abs)}: ${err instanceof Error ? err.message : err}`);
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
if (failed > 0) {
|
|
45
|
-
console.error(`\n${failed} file(s) failed validation.`);
|
|
46
|
-
process.exit(1);
|
|
47
|
-
}
|
|
48
|
-
}
|
package/src/commands/init.ts
DELETED
|
@@ -1,219 +0,0 @@
|
|
|
1
|
-
import fs from "fs";
|
|
2
|
-
import path from "path";
|
|
3
|
-
|
|
4
|
-
const CONFIG_NAME = "dittospec.config.json";
|
|
5
|
-
const WORKSPACE_SPEC_NAME = "workspace.ditto.md";
|
|
6
|
-
|
|
7
|
-
const DEFAULT_CONFIG = JSON.stringify(
|
|
8
|
-
{
|
|
9
|
-
apiBase: "https://api.dittowords.com",
|
|
10
|
-
workspaceId: "TODO: your Ditto workspace ID",
|
|
11
|
-
roots: ["."],
|
|
12
|
-
},
|
|
13
|
-
null,
|
|
14
|
-
2
|
|
15
|
-
);
|
|
16
|
-
|
|
17
|
-
const WORKSPACE_SPEC = `---
|
|
18
|
-
workspace: true
|
|
19
|
-
description: >
|
|
20
|
-
Workspace-wide content rules. Read alongside any component's index.ditto.md.
|
|
21
|
-
# Managed by Ditto — do not edit below
|
|
22
|
-
rules: []
|
|
23
|
-
---
|
|
24
|
-
`;
|
|
25
|
-
|
|
26
|
-
const CLAUDE_MD_BLOCK = `
|
|
27
|
-
## Ditto Content Specs
|
|
28
|
-
|
|
29
|
-
This project uses \`.ditto.md\` files to declare text surfaces on components. Rules are synced from the Ditto platform — never written by hand.
|
|
30
|
-
|
|
31
|
-
### Reading specs
|
|
32
|
-
|
|
33
|
-
When writing or editing text props for a component:
|
|
34
|
-
|
|
35
|
-
1. Check for \`workspace.ditto.md\` at the project root for universal content rules.
|
|
36
|
-
2. Check for \`index.ditto.md\` next to the component for surface-specific rules and constraints.
|
|
37
|
-
3. Respect \`maxLength\` — it's a layout constraint, not a suggestion.
|
|
38
|
-
4. Follow all rules in the \`rules\` key. Rules without a \`surface\` field apply to every surface; rules with \`surface\` apply only to that surface.
|
|
39
|
-
|
|
40
|
-
### Creating specs
|
|
41
|
-
|
|
42
|
-
When creating a new component with text-bearing props, scaffold a spec file:
|
|
43
|
-
|
|
44
|
-
npx ditto-spec scaffold <ComponentName> --path <dir>
|
|
45
|
-
|
|
46
|
-
Then edit the generated \`index.ditto.md\` to add surfaces — one entry per text-bearing prop:
|
|
47
|
-
|
|
48
|
-
surfaces:
|
|
49
|
-
title:
|
|
50
|
-
tags: [heading]
|
|
51
|
-
maxLength: 60
|
|
52
|
-
$children:
|
|
53
|
-
tags: [button, cta]
|
|
54
|
-
maxLength: 30
|
|
55
|
-
|
|
56
|
-
- Use \`$children\` for text via children. Use dot notation for nested props (\`primaryAction.label\`).
|
|
57
|
-
- Choose \`tags\` from content categories like \`heading\`, \`body\`, \`button\`, \`cta\`, \`dialog-title\`, \`call-to-action\`.
|
|
58
|
-
- **Never write \`rules\` or \`examples\` by hand.** Run \`npx ditto-spec pull\` after adding surfaces to populate rules from the platform.
|
|
59
|
-
|
|
60
|
-
Run \`npx ditto-spec list\` to see all existing specs.
|
|
61
|
-
`;
|
|
62
|
-
|
|
63
|
-
const CURSOR_RULES_BLOCK = `
|
|
64
|
-
# Ditto Content Specs
|
|
65
|
-
|
|
66
|
-
This project uses .ditto.md files to declare text surfaces on components. Rules are synced from the Ditto platform — never written by hand.
|
|
67
|
-
|
|
68
|
-
## Reading specs
|
|
69
|
-
|
|
70
|
-
When writing or editing text props for a component:
|
|
71
|
-
|
|
72
|
-
1. Check for workspace.ditto.md at the project root for universal content rules.
|
|
73
|
-
2. Check for index.ditto.md next to the component for surface-specific rules and constraints.
|
|
74
|
-
3. Respect maxLength — it's a layout constraint, not a suggestion.
|
|
75
|
-
4. Follow all rules in the rules key. Rules without a surface field apply to every surface; rules with surface apply only to that surface.
|
|
76
|
-
|
|
77
|
-
## Creating specs
|
|
78
|
-
|
|
79
|
-
When creating a new component with text-bearing props, scaffold a spec file:
|
|
80
|
-
|
|
81
|
-
npx ditto-spec scaffold <ComponentName> --path <dir>
|
|
82
|
-
|
|
83
|
-
Then edit the generated index.ditto.md to add surfaces — one entry per text-bearing prop:
|
|
84
|
-
|
|
85
|
-
surfaces:
|
|
86
|
-
title:
|
|
87
|
-
tags: [heading]
|
|
88
|
-
maxLength: 60
|
|
89
|
-
$children:
|
|
90
|
-
tags: [button, cta]
|
|
91
|
-
maxLength: 30
|
|
92
|
-
|
|
93
|
-
- Use $children for text via children. Use dot notation for nested props (primaryAction.label).
|
|
94
|
-
- Choose tags from content categories like heading, body, button, cta, dialog-title, call-to-action.
|
|
95
|
-
- NEVER write rules or examples by hand. Run npx ditto-spec pull after adding surfaces to populate rules from the platform.
|
|
96
|
-
|
|
97
|
-
Run npx ditto-spec list to see all existing specs.
|
|
98
|
-
`;
|
|
99
|
-
|
|
100
|
-
const AGENTS_MD_ROW =
|
|
101
|
-
"| [Ditto component specs](./packages/ditto-spec-cli/README.md) | `.ditto.md` content specs — read before writing component copy |";
|
|
102
|
-
|
|
103
|
-
interface InitOptions {
|
|
104
|
-
writeAgent?: boolean;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
type AgentEnv = "claude" | "cursor" | "none";
|
|
108
|
-
|
|
109
|
-
export async function init(opts: InitOptions = {}): Promise<void> {
|
|
110
|
-
const cwd = process.cwd();
|
|
111
|
-
|
|
112
|
-
if (fs.existsSync(path.join(cwd, CONFIG_NAME))) {
|
|
113
|
-
console.log(`${CONFIG_NAME} already exists. Nothing to do.`);
|
|
114
|
-
return;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
fs.writeFileSync(path.join(cwd, CONFIG_NAME), DEFAULT_CONFIG + "\n");
|
|
118
|
-
console.log(`✓ Created ${CONFIG_NAME}`);
|
|
119
|
-
|
|
120
|
-
fs.writeFileSync(path.join(cwd, WORKSPACE_SPEC_NAME), WORKSPACE_SPEC);
|
|
121
|
-
console.log(`✓ Created ${WORKSPACE_SPEC_NAME}`);
|
|
122
|
-
|
|
123
|
-
const env = detectAgentEnv(cwd);
|
|
124
|
-
|
|
125
|
-
if (opts.writeAgent) {
|
|
126
|
-
writeAgentConfig(cwd, env);
|
|
127
|
-
} else {
|
|
128
|
-
printAgentSuggestions(env);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
printNextSteps();
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function detectAgentEnv(cwd: string): AgentEnv {
|
|
135
|
-
if (fs.existsSync(path.join(cwd, ".claude")) || fs.existsSync(path.join(cwd, "CLAUDE.md"))) {
|
|
136
|
-
return "claude";
|
|
137
|
-
}
|
|
138
|
-
if (fs.existsSync(path.join(cwd, ".cursor")) || fs.existsSync(path.join(cwd, ".cursorrules"))) {
|
|
139
|
-
return "cursor";
|
|
140
|
-
}
|
|
141
|
-
return "none";
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function writeAgentConfig(cwd: string, env: AgentEnv): void {
|
|
145
|
-
if (env === "claude") {
|
|
146
|
-
const claudeMd = path.join(cwd, "CLAUDE.md");
|
|
147
|
-
if (fs.existsSync(claudeMd)) {
|
|
148
|
-
const existing = fs.readFileSync(claudeMd, "utf8");
|
|
149
|
-
if (existing.includes("## Ditto Content Specs")) {
|
|
150
|
-
console.log(" CLAUDE.md already has Ditto Content Specs section — skipped.");
|
|
151
|
-
} else {
|
|
152
|
-
fs.appendFileSync(claudeMd, CLAUDE_MD_BLOCK);
|
|
153
|
-
console.log("✓ Appended Ditto Content Specs section to CLAUDE.md");
|
|
154
|
-
}
|
|
155
|
-
} else {
|
|
156
|
-
fs.writeFileSync(claudeMd, `# CLAUDE.md${CLAUDE_MD_BLOCK}`);
|
|
157
|
-
console.log("✓ Created CLAUDE.md with Ditto Content Specs section");
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
const agentsMd = path.join(cwd, "AGENTS.md");
|
|
161
|
-
if (fs.existsSync(agentsMd)) {
|
|
162
|
-
const existing = fs.readFileSync(agentsMd, "utf8");
|
|
163
|
-
if (existing.includes("Ditto component specs")) {
|
|
164
|
-
console.log(" AGENTS.md already has Ditto row — skipped.");
|
|
165
|
-
} else {
|
|
166
|
-
fs.appendFileSync(agentsMd, "\n" + AGENTS_MD_ROW + "\n");
|
|
167
|
-
console.log("✓ Appended Ditto row to AGENTS.md");
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
return;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
if (env === "cursor") {
|
|
174
|
-
const cursorrules = path.join(cwd, ".cursorrules");
|
|
175
|
-
if (fs.existsSync(cursorrules)) {
|
|
176
|
-
const existing = fs.readFileSync(cursorrules, "utf8");
|
|
177
|
-
if (existing.includes("Ditto Content Specs")) {
|
|
178
|
-
console.log(" .cursorrules already has Ditto section — skipped.");
|
|
179
|
-
} else {
|
|
180
|
-
fs.appendFileSync(cursorrules, CURSOR_RULES_BLOCK);
|
|
181
|
-
console.log("✓ Appended Ditto section to .cursorrules");
|
|
182
|
-
}
|
|
183
|
-
} else {
|
|
184
|
-
fs.writeFileSync(cursorrules, CURSOR_RULES_BLOCK.trimStart());
|
|
185
|
-
console.log("✓ Created .cursorrules with Ditto section");
|
|
186
|
-
}
|
|
187
|
-
return;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
console.log("\nNo agent environment detected. See next steps for manual setup.");
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function printAgentSuggestions(env: AgentEnv): void {
|
|
194
|
-
if (env === "claude") {
|
|
195
|
-
console.log("\nDetected Claude Code environment.");
|
|
196
|
-
console.log("Add this to your CLAUDE.md (or run `ditto-spec init --agent` to do it automatically):\n");
|
|
197
|
-
console.log(CLAUDE_MD_BLOCK.trim());
|
|
198
|
-
return;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
if (env === "cursor") {
|
|
202
|
-
console.log("\nDetected Cursor environment.");
|
|
203
|
-
console.log("Add this to your .cursorrules (or run `ditto-spec init --agent` to do it automatically):\n");
|
|
204
|
-
console.log(CURSOR_RULES_BLOCK.trim());
|
|
205
|
-
return;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
console.log("\nTo help your AI agent use ditto specs, add the agent contract from the README");
|
|
209
|
-
console.log("to your project's agent configuration file (CLAUDE.md, .cursorrules, etc.).");
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
function printNextSteps(): void {
|
|
213
|
-
console.log(`
|
|
214
|
-
Next steps:
|
|
215
|
-
1. Fill in your workspaceId in ${CONFIG_NAME}
|
|
216
|
-
2. Set DITTO_API_KEY in .env or your shell
|
|
217
|
-
3. Create your first component spec (index.ditto.md next to a component)
|
|
218
|
-
4. Run \`ditto-spec pull\` to sync rules from the platform`);
|
|
219
|
-
}
|
package/src/commands/list.ts
DELETED
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
import path from "path";
|
|
2
|
-
import { loadConfig } from "../config";
|
|
3
|
-
import { discover } from "../discover";
|
|
4
|
-
import { ParsedSpec, parseSpecFile } from "../parse";
|
|
5
|
-
|
|
6
|
-
interface SurfaceLike {
|
|
7
|
-
tags?: string[];
|
|
8
|
-
maxLength?: number;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
interface SpecLike {
|
|
12
|
-
tags?: string[];
|
|
13
|
-
surfaces?: Record<string, SurfaceLike>;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export async function list(): Promise<void> {
|
|
17
|
-
const { config, root } = loadConfig();
|
|
18
|
-
const files = discover(root, config.roots);
|
|
19
|
-
|
|
20
|
-
if (files.length === 0) {
|
|
21
|
-
console.log("No .ditto.md files found.");
|
|
22
|
-
return;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
for (const f of files) {
|
|
26
|
-
let parsed: ParsedSpec;
|
|
27
|
-
try {
|
|
28
|
-
parsed = parseSpecFile(f.abs);
|
|
29
|
-
} catch (err) {
|
|
30
|
-
console.error(`× ${path.relative(root, f.abs)}: ${err instanceof Error ? err.message : err}`);
|
|
31
|
-
continue;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
if (parsed.kind === "workspace") {
|
|
35
|
-
const rules = Array.isArray(parsed.spec.rules) ? parsed.spec.rules : [];
|
|
36
|
-
console.log(`\n[workspace] (${path.relative(root, f.abs)})`);
|
|
37
|
-
console.log(` ${rules.length} rule${rules.length === 1 ? "" : "s"}`);
|
|
38
|
-
continue;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
console.log(`\n${parsed.name} (${path.relative(root, f.abs)})`);
|
|
42
|
-
const specLike = parsed.spec as SpecLike;
|
|
43
|
-
const componentTags = specLike.tags ?? [];
|
|
44
|
-
if (componentTags.length > 0) {
|
|
45
|
-
console.log(` component tags: [${componentTags.join(", ")}]`);
|
|
46
|
-
}
|
|
47
|
-
const surfaces = specLike.surfaces ?? {};
|
|
48
|
-
const entries = Object.entries(surfaces);
|
|
49
|
-
if (entries.length === 0) {
|
|
50
|
-
console.log(" (no surfaces)");
|
|
51
|
-
continue;
|
|
52
|
-
}
|
|
53
|
-
for (const [key, surface] of entries) {
|
|
54
|
-
const tags = (surface.tags ?? []).join(", ");
|
|
55
|
-
const maxLen = surface.maxLength != null ? ` max ${surface.maxLength}` : "";
|
|
56
|
-
console.log(` ${key.padEnd(28)} [${tags}]${maxLen}`);
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
}
|
package/src/commands/pull.ts
DELETED
|
@@ -1,140 +0,0 @@
|
|
|
1
|
-
import path from "path";
|
|
2
|
-
import { DittoApi, RuleResponse } from "../api";
|
|
3
|
-
import { getApiKey, loadConfig } from "../config";
|
|
4
|
-
import { discover, DiscoveredFile } from "../discover";
|
|
5
|
-
import { ParsedSpec, parseSpecFile } from "../parse";
|
|
6
|
-
import { rewriteManagedKeys } from "../serialize";
|
|
7
|
-
|
|
8
|
-
interface SurfaceLike {
|
|
9
|
-
tags?: string[];
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
interface SpecLike {
|
|
13
|
-
tags?: string[];
|
|
14
|
-
surfaces?: Record<string, SurfaceLike>;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
interface PullOptions {
|
|
18
|
-
dryRun?: boolean;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export async function pull(opts: PullOptions = {}): Promise<void> {
|
|
22
|
-
const { config, root } = loadConfig();
|
|
23
|
-
const apiKey = getApiKey();
|
|
24
|
-
const api = new DittoApi(config, apiKey);
|
|
25
|
-
|
|
26
|
-
const files = discover(root, config.roots);
|
|
27
|
-
if (files.length === 0) {
|
|
28
|
-
console.log("No .ditto.md files found.");
|
|
29
|
-
return;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
console.log(`Found ${files.length} spec file${files.length === 1 ? "" : "s"}.`);
|
|
33
|
-
|
|
34
|
-
type ParsedFile = { file: DiscoveredFile; parsed: ParsedSpec };
|
|
35
|
-
const componentFiles: ParsedFile[] = [];
|
|
36
|
-
const workspaceFiles: ParsedFile[] = [];
|
|
37
|
-
|
|
38
|
-
for (const f of files) {
|
|
39
|
-
let parsed: ParsedSpec;
|
|
40
|
-
try {
|
|
41
|
-
parsed = parseSpecFile(f.abs);
|
|
42
|
-
} catch (err) {
|
|
43
|
-
console.error(`× ${f.rel}: ${err instanceof Error ? err.message : err}`);
|
|
44
|
-
continue;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
if (parsed.kind === "workspace") {
|
|
48
|
-
workspaceFiles.push({ file: f, parsed });
|
|
49
|
-
} else {
|
|
50
|
-
componentFiles.push({ file: f, parsed });
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
if (workspaceFiles.length > 1) {
|
|
55
|
-
const paths = workspaceFiles.map((w) => path.relative(root, w.file.abs)).join(", ");
|
|
56
|
-
throw new Error(`Found multiple workspace specs (${paths}). Expected at most one.`);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const allRules = await api.getRules();
|
|
60
|
-
console.log(`Fetched ${allRules.length} rule${allRules.length === 1 ? "" : "s"} from workspace.`);
|
|
61
|
-
|
|
62
|
-
let written = 0;
|
|
63
|
-
let unchanged = 0;
|
|
64
|
-
|
|
65
|
-
if (workspaceFiles.length === 1) {
|
|
66
|
-
const { file, parsed } = workspaceFiles[0];
|
|
67
|
-
const universalRules = allRules.filter((r) => r.tags.length === 0).map(toRuleObj);
|
|
68
|
-
|
|
69
|
-
if (rulesMatch(parsed.spec.rules, universalRules)) {
|
|
70
|
-
unchanged++;
|
|
71
|
-
} else if (opts.dryRun) {
|
|
72
|
-
console.log(`~ ${path.relative(root, file.abs)} (would write ${universalRules.length} workspace rule(s))`);
|
|
73
|
-
} else {
|
|
74
|
-
rewriteManagedKeys(file.abs, { rules: universalRules });
|
|
75
|
-
written++;
|
|
76
|
-
console.log(`✓ ${path.relative(root, file.abs)} — ${universalRules.length} workspace rule(s)`);
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
for (const { file, parsed } of componentFiles) {
|
|
81
|
-
const specLike = parsed.spec as SpecLike;
|
|
82
|
-
const componentTags = specLike.tags ?? [];
|
|
83
|
-
const surfaces = specLike.surfaces ?? {};
|
|
84
|
-
|
|
85
|
-
const componentMatches = rulesForTags(allRules, componentTags);
|
|
86
|
-
const consumed = new Set<RuleResponse>(componentMatches);
|
|
87
|
-
|
|
88
|
-
const matchedRules: Array<Record<string, unknown>> = [];
|
|
89
|
-
for (const rule of componentMatches) {
|
|
90
|
-
matchedRules.push(toRuleObj(rule));
|
|
91
|
-
}
|
|
92
|
-
for (const [surfaceKey, surface] of Object.entries(surfaces)) {
|
|
93
|
-
const matches = rulesForTags(allRules, surface.tags ?? []).filter((r) => !consumed.has(r));
|
|
94
|
-
for (const rule of matches) {
|
|
95
|
-
matchedRules.push(toSurfaceRuleObj(surfaceKey, rule));
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
if (rulesMatch(parsed.spec.rules, matchedRules)) {
|
|
100
|
-
unchanged++;
|
|
101
|
-
continue;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
if (opts.dryRun) {
|
|
105
|
-
console.log(`~ ${path.relative(root, file.abs)} (would write ${matchedRules.length} rule(s))`);
|
|
106
|
-
continue;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
rewriteManagedKeys(file.abs, { rules: matchedRules });
|
|
110
|
-
written++;
|
|
111
|
-
console.log(`✓ ${path.relative(root, file.abs)} — ${matchedRules.length} rule(s)`);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
console.log(`\nDone. ${written} updated, ${unchanged} unchanged.`);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function rulesForTags(rules: RuleResponse[], tags: string[]): RuleResponse[] {
|
|
118
|
-
if (tags.length === 0) return [];
|
|
119
|
-
const tagSet = new Set(tags);
|
|
120
|
-
return rules.filter((r) => r.tags.length > 0 && r.tags.some((t) => tagSet.has(t)));
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function toRuleObj(rule: RuleResponse): Record<string, unknown> {
|
|
124
|
-
const out: Record<string, unknown> = {
|
|
125
|
-
name: rule.name,
|
|
126
|
-
description: rule.description,
|
|
127
|
-
};
|
|
128
|
-
if (rule.examples.length > 0) {
|
|
129
|
-
out.examples = rule.examples.map((ex) => ({ from: ex.from, to: ex.to }));
|
|
130
|
-
}
|
|
131
|
-
return out;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function toSurfaceRuleObj(surface: string, rule: RuleResponse): Record<string, unknown> {
|
|
135
|
-
return { surface, ...toRuleObj(rule) };
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function rulesMatch(existing: unknown, incoming: unknown): boolean {
|
|
139
|
-
return JSON.stringify(existing ?? []) === JSON.stringify(incoming);
|
|
140
|
-
}
|
package/src/commands/scaffold.ts
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
import fs from "fs";
|
|
2
|
-
import path from "path";
|
|
3
|
-
|
|
4
|
-
const SPEC_FILENAME = "index.ditto.md";
|
|
5
|
-
|
|
6
|
-
interface ScaffoldOptions {
|
|
7
|
-
componentName: string;
|
|
8
|
-
targetDir: string;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export async function scaffold(opts: ScaffoldOptions): Promise<void> {
|
|
12
|
-
const dest = path.join(opts.targetDir, SPEC_FILENAME);
|
|
13
|
-
|
|
14
|
-
if (fs.existsSync(dest)) {
|
|
15
|
-
console.log(`${SPEC_FILENAME} already exists at ${opts.targetDir}. Nothing to do.`);
|
|
16
|
-
return;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const content = `---
|
|
20
|
-
component: ${opts.componentName}
|
|
21
|
-
description: >
|
|
22
|
-
TODO: describe this component.
|
|
23
|
-
tags: []
|
|
24
|
-
surfaces: {}
|
|
25
|
-
# Managed by Ditto — do not edit below
|
|
26
|
-
rules: []
|
|
27
|
-
examples: []
|
|
28
|
-
---
|
|
29
|
-
`;
|
|
30
|
-
|
|
31
|
-
fs.mkdirSync(opts.targetDir, { recursive: true });
|
|
32
|
-
fs.writeFileSync(dest, content);
|
|
33
|
-
console.log(`✓ Created ${path.relative(process.cwd(), dest)}`);
|
|
34
|
-
|
|
35
|
-
console.log(`
|
|
36
|
-
Next steps:
|
|
37
|
-
1. Add text-bearing props as surface keys under 'surfaces'
|
|
38
|
-
2. Tag each surface with content categories (heading, body, button, cta, etc.)
|
|
39
|
-
3. Run \`ditto-spec pull\` to populate rules from the platform`);
|
|
40
|
-
}
|
package/src/config.ts
DELETED
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
import dotenv from "dotenv";
|
|
2
|
-
import fs from "fs";
|
|
3
|
-
import path from "path";
|
|
4
|
-
|
|
5
|
-
export interface Config {
|
|
6
|
-
apiBase: string;
|
|
7
|
-
workspaceId: string;
|
|
8
|
-
roots?: string[];
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
const DEFAULT_CONFIG_NAME = "dittospec.config.json";
|
|
12
|
-
|
|
13
|
-
export function loadConfig(cwd: string = process.cwd()): { config: Config; root: string } {
|
|
14
|
-
let dir = path.resolve(cwd);
|
|
15
|
-
while (true) {
|
|
16
|
-
const candidate = path.join(dir, DEFAULT_CONFIG_NAME);
|
|
17
|
-
if (fs.existsSync(candidate)) {
|
|
18
|
-
const raw = fs.readFileSync(candidate, "utf8");
|
|
19
|
-
const parsed = JSON.parse(raw);
|
|
20
|
-
validate(parsed, candidate);
|
|
21
|
-
return { config: parsed as Config, root: dir };
|
|
22
|
-
}
|
|
23
|
-
const parent = path.dirname(dir);
|
|
24
|
-
if (parent === dir) break;
|
|
25
|
-
dir = parent;
|
|
26
|
-
}
|
|
27
|
-
throw new Error(`No ${DEFAULT_CONFIG_NAME} found from ${cwd} up to filesystem root.`);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function validate(c: unknown, source: string): asserts c is Config {
|
|
31
|
-
if (!c || typeof c !== "object") throw new Error(`${source}: must be a JSON object.`);
|
|
32
|
-
const obj = c as Record<string, unknown>;
|
|
33
|
-
if (typeof obj.apiBase !== "string") throw new Error(`${source}: missing string "apiBase".`);
|
|
34
|
-
if (typeof obj.workspaceId !== "string") throw new Error(`${source}: missing string "workspaceId".`);
|
|
35
|
-
if (obj.roots !== undefined && !Array.isArray(obj.roots)) {
|
|
36
|
-
throw new Error(`${source}: "roots" must be an array of strings if present.`);
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export function getApiKey(): string {
|
|
41
|
-
try {
|
|
42
|
-
const { root } = loadConfig();
|
|
43
|
-
dotenv.config({ path: path.join(root, ".env") });
|
|
44
|
-
} catch {
|
|
45
|
-
// No config yet — let getApiKey still work for direct env exports.
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const key = process.env.DITTO_API_KEY;
|
|
49
|
-
if (!key) {
|
|
50
|
-
throw new Error(
|
|
51
|
-
"DITTO_API_KEY is not set. Add it to .env at the repo root, or `export DITTO_API_KEY=...` in your shell."
|
|
52
|
-
);
|
|
53
|
-
}
|
|
54
|
-
return key;
|
|
55
|
-
}
|