@dittowords/spec-cli 0.0.1-alpha.3 → 0.0.1-alpha.4
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 +4 -10
- package/dist/api.d.ts +19 -0
- package/dist/api.js +36 -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 +66 -0
- package/dist/commands/check.d.ts +1 -0
- package/dist/commands/check.js +57 -0
- package/dist/commands/init.d.ts +5 -0
- package/dist/commands/init.js +199 -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 +125 -0
- package/dist/commands/scaffold.d.ts +6 -0
- package/dist/commands/scaffold.js +51 -0
- package/dist/config.d.ts +13 -0
- package/dist/config.js +72 -0
- package/dist/discover.d.ts +5 -0
- package/dist/discover.js +19 -0
- package/dist/parse.d.ts +24 -0
- package/dist/parse.js +42 -0
- package/dist/serialize.d.ts +4 -0
- package/dist/serialize.js +43 -0
- package/package.json +18 -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 -231
- package/src/commands/list.ts +0 -59
- package/src/commands/pull.ts +0 -152
- 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 -45
package/src/commands/init.ts
DELETED
|
@@ -1,231 +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
|
-
tags: []
|
|
23
|
-
rules: []
|
|
24
|
-
---
|
|
25
|
-
`;
|
|
26
|
-
|
|
27
|
-
const CLAUDE_MD_BLOCK = `
|
|
28
|
-
## Ditto Content Specs
|
|
29
|
-
|
|
30
|
-
This project uses \`.ditto.md\` files to declare text surfaces on components. Rules are synced from the Ditto platform — never written by hand.
|
|
31
|
-
|
|
32
|
-
### Reading specs
|
|
33
|
-
|
|
34
|
-
When writing or editing text props for a component:
|
|
35
|
-
|
|
36
|
-
1. Check for \`workspace.ditto.md\` at the project root for universal content rules.
|
|
37
|
-
2. Check for \`index.ditto.md\` next to the component for surface-specific rules and constraints.
|
|
38
|
-
3. Respect \`maxLength\` — it's a layout constraint, not a suggestion.
|
|
39
|
-
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.
|
|
40
|
-
|
|
41
|
-
### Creating specs
|
|
42
|
-
|
|
43
|
-
When creating a new component that renders any user-facing text, scaffold a spec file:
|
|
44
|
-
|
|
45
|
-
npx ditto-spec scaffold <ComponentName> --path <dir>
|
|
46
|
-
|
|
47
|
-
Then edit the generated \`index.ditto.md\` to add surfaces — one entry per piece of user-facing text the component renders:
|
|
48
|
-
|
|
49
|
-
surfaces:
|
|
50
|
-
title:
|
|
51
|
-
tags: [heading]
|
|
52
|
-
maxLength: 60
|
|
53
|
-
$children:
|
|
54
|
-
tags: [button, cta]
|
|
55
|
-
maxLength: 30
|
|
56
|
-
|
|
57
|
-
- Use \`$children\` for text via children. Use dot notation for nested props (\`primaryAction.label\`). For hardcoded or internal strings, use a descriptive role name (\`headline\`, \`bodyText\`, \`submitLabel\`).
|
|
58
|
-
- Check the \`tags\` key in \`workspace.ditto.md\` for tags available on the platform. Prefer reusing an existing tag over creating a new one — only a tag that exists on the platform will match rules. If no existing tag fits, create a new one following the convention of existing tags (lowercase, hyphenated).
|
|
59
|
-
- **Never write \`rules\` or \`examples\` by hand.** Run \`npx ditto-spec pull\` after adding surfaces to populate rules from the platform.
|
|
60
|
-
|
|
61
|
-
Parent and child specs both contribute rules. If your component passes a label to a child Button, add a surface in the parent's spec — the parent's rules (e.g., dialog-level tone) layer with the child's rules (e.g., button-level constraints). A child having its own spec does not exempt the parent from declaring surfaces for text it provides.
|
|
62
|
-
|
|
63
|
-
Run \`npx ditto-spec list\` to see all existing specs.
|
|
64
|
-
`;
|
|
65
|
-
|
|
66
|
-
const CURSOR_RULES_BLOCK = `
|
|
67
|
-
# Ditto Content Specs
|
|
68
|
-
|
|
69
|
-
This project uses .ditto.md files to declare text surfaces on components. Rules are synced from the Ditto platform — never written by hand.
|
|
70
|
-
|
|
71
|
-
## Reading specs
|
|
72
|
-
|
|
73
|
-
When writing or editing text props for a component:
|
|
74
|
-
|
|
75
|
-
1. Check for workspace.ditto.md at the project root for universal content rules.
|
|
76
|
-
2. Check for index.ditto.md next to the component for surface-specific rules and constraints.
|
|
77
|
-
3. Respect maxLength — it's a layout constraint, not a suggestion.
|
|
78
|
-
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.
|
|
79
|
-
|
|
80
|
-
## Creating specs
|
|
81
|
-
|
|
82
|
-
When creating a new component that renders any user-facing text, scaffold a spec file:
|
|
83
|
-
|
|
84
|
-
npx ditto-spec scaffold <ComponentName> --path <dir>
|
|
85
|
-
|
|
86
|
-
Then edit the generated index.ditto.md to add surfaces — one entry per piece of user-facing text the component renders:
|
|
87
|
-
|
|
88
|
-
surfaces:
|
|
89
|
-
title:
|
|
90
|
-
tags: [heading]
|
|
91
|
-
maxLength: 60
|
|
92
|
-
$children:
|
|
93
|
-
tags: [button, cta]
|
|
94
|
-
maxLength: 30
|
|
95
|
-
|
|
96
|
-
- Use $children for text via children. Use dot notation for nested props (primaryAction.label). For hardcoded or internal strings, use a descriptive role name (headline, bodyText, submitLabel).
|
|
97
|
-
- Check the tags key in workspace.ditto.md for tags available on the platform. Prefer reusing an existing tag over creating a new one — only a tag that exists on the platform will match rules. If no existing tag fits, create a new one following the convention of existing tags (lowercase, hyphenated).
|
|
98
|
-
- NEVER write rules or examples by hand. Run npx ditto-spec pull after adding surfaces to populate rules from the platform.
|
|
99
|
-
|
|
100
|
-
Parent and child specs both contribute rules. If your component passes a label to a child Button, add a surface in the parent's spec — the parent's rules (e.g., dialog-level tone) layer with the child's rules (e.g., button-level constraints). A child having its own spec does not exempt the parent from declaring surfaces for text it provides.
|
|
101
|
-
|
|
102
|
-
Run npx ditto-spec list to see all existing specs.
|
|
103
|
-
`;
|
|
104
|
-
|
|
105
|
-
const AGENTS_MD_ROW =
|
|
106
|
-
"| [Ditto component specs](./packages/ditto-spec-cli/README.md) | `.ditto.md` content specs — read before writing component copy |";
|
|
107
|
-
|
|
108
|
-
interface InitOptions {
|
|
109
|
-
writeAgent?: boolean;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
type AgentEnv = "claude" | "cursor" | "none";
|
|
113
|
-
|
|
114
|
-
export async function init(opts: InitOptions = {}): Promise<void> {
|
|
115
|
-
const cwd = process.cwd();
|
|
116
|
-
|
|
117
|
-
const configExists = fs.existsSync(path.join(cwd, CONFIG_NAME));
|
|
118
|
-
|
|
119
|
-
if (configExists && !opts.writeAgent) {
|
|
120
|
-
console.log(`${CONFIG_NAME} already exists. Nothing to do.`);
|
|
121
|
-
console.log("Run with --agent to add agent configuration (CLAUDE.md, .cursorrules, etc.).");
|
|
122
|
-
return;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
if (!configExists) {
|
|
126
|
-
fs.writeFileSync(path.join(cwd, CONFIG_NAME), DEFAULT_CONFIG + "\n");
|
|
127
|
-
console.log(`✓ Created ${CONFIG_NAME}`);
|
|
128
|
-
|
|
129
|
-
fs.writeFileSync(path.join(cwd, WORKSPACE_SPEC_NAME), WORKSPACE_SPEC);
|
|
130
|
-
console.log(`✓ Created ${WORKSPACE_SPEC_NAME}`);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const env = detectAgentEnv(cwd);
|
|
134
|
-
|
|
135
|
-
if (opts.writeAgent) {
|
|
136
|
-
writeAgentConfig(cwd, env);
|
|
137
|
-
} else {
|
|
138
|
-
printAgentSuggestions(env);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
if (!configExists) {
|
|
142
|
-
printNextSteps();
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function detectAgentEnv(cwd: string): AgentEnv {
|
|
147
|
-
if (fs.existsSync(path.join(cwd, ".claude")) || fs.existsSync(path.join(cwd, "CLAUDE.md"))) {
|
|
148
|
-
return "claude";
|
|
149
|
-
}
|
|
150
|
-
if (fs.existsSync(path.join(cwd, ".cursor")) || fs.existsSync(path.join(cwd, ".cursorrules"))) {
|
|
151
|
-
return "cursor";
|
|
152
|
-
}
|
|
153
|
-
return "none";
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
function writeAgentConfig(cwd: string, env: AgentEnv): void {
|
|
157
|
-
if (env === "claude") {
|
|
158
|
-
const claudeMd = path.join(cwd, "CLAUDE.md");
|
|
159
|
-
if (fs.existsSync(claudeMd)) {
|
|
160
|
-
const existing = fs.readFileSync(claudeMd, "utf8");
|
|
161
|
-
if (existing.includes("## Ditto Content Specs")) {
|
|
162
|
-
console.log(" CLAUDE.md already has Ditto Content Specs section — skipped.");
|
|
163
|
-
} else {
|
|
164
|
-
fs.appendFileSync(claudeMd, CLAUDE_MD_BLOCK);
|
|
165
|
-
console.log("✓ Appended Ditto Content Specs section to CLAUDE.md");
|
|
166
|
-
}
|
|
167
|
-
} else {
|
|
168
|
-
fs.writeFileSync(claudeMd, `# CLAUDE.md${CLAUDE_MD_BLOCK}`);
|
|
169
|
-
console.log("✓ Created CLAUDE.md with Ditto Content Specs section");
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
const agentsMd = path.join(cwd, "AGENTS.md");
|
|
173
|
-
if (fs.existsSync(agentsMd)) {
|
|
174
|
-
const existing = fs.readFileSync(agentsMd, "utf8");
|
|
175
|
-
if (existing.includes("Ditto component specs")) {
|
|
176
|
-
console.log(" AGENTS.md already has Ditto row — skipped.");
|
|
177
|
-
} else {
|
|
178
|
-
fs.appendFileSync(agentsMd, "\n" + AGENTS_MD_ROW + "\n");
|
|
179
|
-
console.log("✓ Appended Ditto row to AGENTS.md");
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
return;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
if (env === "cursor") {
|
|
186
|
-
const cursorrules = path.join(cwd, ".cursorrules");
|
|
187
|
-
if (fs.existsSync(cursorrules)) {
|
|
188
|
-
const existing = fs.readFileSync(cursorrules, "utf8");
|
|
189
|
-
if (existing.includes("Ditto Content Specs")) {
|
|
190
|
-
console.log(" .cursorrules already has Ditto section — skipped.");
|
|
191
|
-
} else {
|
|
192
|
-
fs.appendFileSync(cursorrules, CURSOR_RULES_BLOCK);
|
|
193
|
-
console.log("✓ Appended Ditto section to .cursorrules");
|
|
194
|
-
}
|
|
195
|
-
} else {
|
|
196
|
-
fs.writeFileSync(cursorrules, CURSOR_RULES_BLOCK.trimStart());
|
|
197
|
-
console.log("✓ Created .cursorrules with Ditto section");
|
|
198
|
-
}
|
|
199
|
-
return;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
console.log("\nNo agent environment detected. See next steps for manual setup.");
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function printAgentSuggestions(env: AgentEnv): void {
|
|
206
|
-
if (env === "claude") {
|
|
207
|
-
console.log("\nDetected Claude Code environment.");
|
|
208
|
-
console.log("Add this to your CLAUDE.md (or run `ditto-spec init --agent` to do it automatically):\n");
|
|
209
|
-
console.log(CLAUDE_MD_BLOCK.trim());
|
|
210
|
-
return;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
if (env === "cursor") {
|
|
214
|
-
console.log("\nDetected Cursor environment.");
|
|
215
|
-
console.log("Add this to your .cursorrules (or run `ditto-spec init --agent` to do it automatically):\n");
|
|
216
|
-
console.log(CURSOR_RULES_BLOCK.trim());
|
|
217
|
-
return;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
console.log("\nTo help your AI agent use ditto specs, add the agent contract from the README");
|
|
221
|
-
console.log("to your project's agent configuration file (CLAUDE.md, .cursorrules, etc.).");
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
function printNextSteps(): void {
|
|
225
|
-
console.log(`
|
|
226
|
-
Next steps:
|
|
227
|
-
1. Fill in your workspaceId in ${CONFIG_NAME}
|
|
228
|
-
2. Set DITTO_API_KEY in .env or your shell
|
|
229
|
-
3. Create your first component spec (index.ditto.md next to a component)
|
|
230
|
-
4. Run \`ditto-spec pull\` to sync rules from the platform`);
|
|
231
|
-
}
|
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,152 +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
|
-
const platformTags = [...new Set(allRules.flatMap((r) => r.tags))].filter(Boolean).sort();
|
|
63
|
-
|
|
64
|
-
let written = 0;
|
|
65
|
-
let unchanged = 0;
|
|
66
|
-
|
|
67
|
-
if (workspaceFiles.length === 1) {
|
|
68
|
-
const { file, parsed } = workspaceFiles[0];
|
|
69
|
-
const universalRules = allRules.filter((r) => r.tags.length === 0).map(toRuleObj);
|
|
70
|
-
|
|
71
|
-
if (rulesMatch(parsed.spec.rules, universalRules) && tagsMatch(parsed.spec.tags, platformTags)) {
|
|
72
|
-
unchanged++;
|
|
73
|
-
} else if (opts.dryRun) {
|
|
74
|
-
console.log(
|
|
75
|
-
`~ ${path.relative(root, file.abs)} (would write ${universalRules.length} workspace rule(s), ${
|
|
76
|
-
platformTags.length
|
|
77
|
-
} tag(s))`
|
|
78
|
-
);
|
|
79
|
-
} else {
|
|
80
|
-
rewriteManagedKeys(file.abs, { tags: platformTags, rules: universalRules });
|
|
81
|
-
written++;
|
|
82
|
-
console.log(
|
|
83
|
-
`✓ ${path.relative(root, file.abs)} — ${universalRules.length} workspace rule(s), ${platformTags.length} tag(s)`
|
|
84
|
-
);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
for (const { file, parsed } of componentFiles) {
|
|
89
|
-
const specLike = parsed.spec as SpecLike;
|
|
90
|
-
const componentTags = specLike.tags ?? [];
|
|
91
|
-
const surfaces = specLike.surfaces ?? {};
|
|
92
|
-
|
|
93
|
-
const componentMatches = rulesForTags(allRules, componentTags);
|
|
94
|
-
const consumed = new Set<RuleResponse>(componentMatches);
|
|
95
|
-
|
|
96
|
-
const matchedRules: Array<Record<string, unknown>> = [];
|
|
97
|
-
for (const rule of componentMatches) {
|
|
98
|
-
matchedRules.push(toRuleObj(rule));
|
|
99
|
-
}
|
|
100
|
-
for (const [surfaceKey, surface] of Object.entries(surfaces)) {
|
|
101
|
-
const matches = rulesForTags(allRules, surface.tags ?? []).filter((r) => !consumed.has(r));
|
|
102
|
-
for (const rule of matches) {
|
|
103
|
-
matchedRules.push(toSurfaceRuleObj(surfaceKey, rule));
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
if (rulesMatch(parsed.spec.rules, matchedRules)) {
|
|
108
|
-
unchanged++;
|
|
109
|
-
continue;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
if (opts.dryRun) {
|
|
113
|
-
console.log(`~ ${path.relative(root, file.abs)} (would write ${matchedRules.length} rule(s))`);
|
|
114
|
-
continue;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
rewriteManagedKeys(file.abs, { rules: matchedRules });
|
|
118
|
-
written++;
|
|
119
|
-
console.log(`✓ ${path.relative(root, file.abs)} — ${matchedRules.length} rule(s)`);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
console.log(`\nDone. ${written} updated, ${unchanged} unchanged.`);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function rulesForTags(rules: RuleResponse[], tags: string[]): RuleResponse[] {
|
|
126
|
-
if (tags.length === 0) return [];
|
|
127
|
-
const tagSet = new Set(tags);
|
|
128
|
-
return rules.filter((r) => r.tags.length > 0 && r.tags.some((t) => tagSet.has(t)));
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
function toRuleObj(rule: RuleResponse): Record<string, unknown> {
|
|
132
|
-
const out: Record<string, unknown> = {
|
|
133
|
-
name: rule.name,
|
|
134
|
-
description: rule.description,
|
|
135
|
-
};
|
|
136
|
-
if (rule.examples.length > 0) {
|
|
137
|
-
out.examples = rule.examples.map((ex) => ({ from: ex.from, to: ex.to }));
|
|
138
|
-
}
|
|
139
|
-
return out;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function toSurfaceRuleObj(surface: string, rule: RuleResponse): Record<string, unknown> {
|
|
143
|
-
return { surface, ...toRuleObj(rule) };
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function rulesMatch(existing: unknown, incoming: unknown): boolean {
|
|
147
|
-
return JSON.stringify(existing ?? []) === JSON.stringify(incoming);
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
function tagsMatch(existing: unknown, incoming: string[]): boolean {
|
|
151
|
-
return JSON.stringify(existing ?? []) === JSON.stringify(incoming);
|
|
152
|
-
}
|
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 a surface key for each piece of user-facing text the component renders
|
|
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
|
-
}
|
package/src/discover.ts
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
import { globSync } from "glob";
|
|
2
|
-
import path from "path";
|
|
3
|
-
|
|
4
|
-
const SPEC_GLOB = "**/*.ditto.md";
|
|
5
|
-
|
|
6
|
-
const IGNORE = ["**/node_modules/**", "**/dist*/**", "**/.git/**", "**/.next/**", "**/coverage/**"];
|
|
7
|
-
|
|
8
|
-
export interface DiscoveredFile {
|
|
9
|
-
abs: string;
|
|
10
|
-
rel: string;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export function discover(repoRoot: string, roots: string[] = ["."]): DiscoveredFile[] {
|
|
14
|
-
const patterns = roots.map((r) => path.posix.join(r, SPEC_GLOB));
|
|
15
|
-
|
|
16
|
-
const results = globSync(patterns, {
|
|
17
|
-
cwd: repoRoot,
|
|
18
|
-
ignore: IGNORE,
|
|
19
|
-
absolute: true,
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
return results.map((abs) => ({ abs, rel: path.relative(repoRoot, abs) })).sort((a, b) => a.rel.localeCompare(b.rel));
|
|
23
|
-
}
|
package/src/parse.ts
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
import fs from "fs";
|
|
2
|
-
import yaml from "js-yaml";
|
|
3
|
-
|
|
4
|
-
export interface ParsedSpec {
|
|
5
|
-
kind: "component" | "workspace";
|
|
6
|
-
name?: string;
|
|
7
|
-
spec: Record<string, unknown>;
|
|
8
|
-
filePath: string;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export class ParseError extends Error {
|
|
12
|
-
constructor(public filePath: string, public reason: string) {
|
|
13
|
-
super(`${filePath}: ${reason}`);
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function parseSpecFile(filePath: string): ParsedSpec {
|
|
18
|
-
const source = fs.readFileSync(filePath, "utf8");
|
|
19
|
-
const frontmatter = extractFrontmatter(source, filePath);
|
|
20
|
-
const raw = yaml.load(frontmatter);
|
|
21
|
-
|
|
22
|
-
if (!raw || typeof raw !== "object") {
|
|
23
|
-
throw new ParseError(filePath, "frontmatter must be a YAML object");
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const spec = raw as Record<string, unknown>;
|
|
27
|
-
|
|
28
|
-
if (spec.workspace === true) {
|
|
29
|
-
return { kind: "workspace", spec, filePath };
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
if (typeof spec.component !== "string" || !spec.component) {
|
|
33
|
-
throw new ParseError(filePath, "missing required 'component' key (string)");
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
return { kind: "component", name: spec.component, spec, filePath };
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function extractFrontmatter(source: string, filePath: string): string {
|
|
40
|
-
const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
41
|
-
if (!match) {
|
|
42
|
-
throw new ParseError(filePath, "no YAML frontmatter found (expected --- delimiters)");
|
|
43
|
-
}
|
|
44
|
-
return match[1];
|
|
45
|
-
}
|
package/src/serialize.ts
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
import fs from "fs";
|
|
2
|
-
import yaml from "js-yaml";
|
|
3
|
-
|
|
4
|
-
const MANAGED_COMMENT = "# Managed by Ditto — do not edit below";
|
|
5
|
-
|
|
6
|
-
export function rewriteManagedKeys(filePath: string, updates: { tags?: string[]; rules?: unknown[] }): void {
|
|
7
|
-
const source = fs.readFileSync(filePath, "utf8");
|
|
8
|
-
const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
9
|
-
if (!match) throw new Error(`${filePath}: no YAML frontmatter found`);
|
|
10
|
-
|
|
11
|
-
const spec = yaml.load(match[1]) as Record<string, unknown>;
|
|
12
|
-
|
|
13
|
-
if (updates.tags !== undefined) {
|
|
14
|
-
const savedRules = spec.rules;
|
|
15
|
-
delete spec.rules;
|
|
16
|
-
spec.tags = updates.tags;
|
|
17
|
-
spec.rules = savedRules;
|
|
18
|
-
}
|
|
19
|
-
if (updates.rules !== undefined) spec.rules = updates.rules;
|
|
20
|
-
|
|
21
|
-
let dumped = yaml
|
|
22
|
-
.dump(spec, {
|
|
23
|
-
lineWidth: -1,
|
|
24
|
-
noRefs: true,
|
|
25
|
-
sortKeys: false,
|
|
26
|
-
quotingType: '"',
|
|
27
|
-
})
|
|
28
|
-
.trimEnd();
|
|
29
|
-
|
|
30
|
-
dumped = insertManagedComment(dumped, updates.tags !== undefined);
|
|
31
|
-
|
|
32
|
-
const afterFrontmatter = source.slice(match[0].length);
|
|
33
|
-
fs.writeFileSync(filePath, `---\n${dumped}\n---${afterFrontmatter}`);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function insertManagedComment(dumped: string, managedTagsPresent: boolean): string {
|
|
37
|
-
const targetKey = managedTagsPresent ? "tags" : "rules";
|
|
38
|
-
const idx = dumped.search(new RegExp(`^${targetKey}:`, "m"));
|
|
39
|
-
if (idx === -1) return dumped;
|
|
40
|
-
|
|
41
|
-
const before = dumped.slice(0, idx);
|
|
42
|
-
if (before.includes(MANAGED_COMMENT)) return dumped;
|
|
43
|
-
|
|
44
|
-
return before + MANAGED_COMMENT + "\n" + dumped.slice(idx);
|
|
45
|
-
}
|