@dittowords/spec-cli 0.0.1-alpha.1 → 0.0.1-alpha.11

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.
Files changed (44) hide show
  1. package/README.md +95 -26
  2. package/dist/api.d.ts +82 -0
  3. package/dist/api.js +103 -0
  4. package/dist/bin.d.ts +2 -0
  5. package/dist/bin.js +4 -0
  6. package/dist/cli.d.ts +1 -0
  7. package/dist/cli.js +87 -0
  8. package/dist/commands/check.d.ts +1 -0
  9. package/dist/commands/check.js +57 -0
  10. package/dist/commands/create-rule.d.ts +9 -0
  11. package/dist/commands/create-rule.js +33 -0
  12. package/dist/commands/init.d.ts +6 -0
  13. package/dist/commands/init.js +237 -0
  14. package/dist/commands/list.d.ts +1 -0
  15. package/dist/commands/list.js +51 -0
  16. package/dist/commands/pull.d.ts +5 -0
  17. package/dist/commands/pull.js +168 -0
  18. package/dist/commands/scaffold.d.ts +6 -0
  19. package/dist/commands/scaffold.js +51 -0
  20. package/dist/config.d.ts +18 -0
  21. package/dist/config.js +101 -0
  22. package/dist/discover.d.ts +5 -0
  23. package/dist/discover.js +24 -0
  24. package/dist/parse.d.ts +24 -0
  25. package/dist/parse.js +42 -0
  26. package/dist/serialize.d.ts +5 -0
  27. package/dist/serialize.js +60 -0
  28. package/dist/skill-content.d.ts +1 -0
  29. package/dist/skill-content.js +156 -0
  30. package/dist/skills.d.ts +4 -0
  31. package/dist/skills.js +284 -0
  32. package/package.json +19 -5
  33. package/src/api.ts +0 -45
  34. package/src/bin.js +0 -3
  35. package/src/cli.ts +0 -70
  36. package/src/commands/check.ts +0 -48
  37. package/src/commands/init.ts +0 -219
  38. package/src/commands/list.ts +0 -59
  39. package/src/commands/pull.ts +0 -140
  40. package/src/commands/scaffold.ts +0 -40
  41. package/src/config.ts +0 -55
  42. package/src/discover.ts +0 -23
  43. package/src/parse.ts +0 -45
  44. package/src/serialize.ts +0 -38
@@ -0,0 +1,237 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.init = init;
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const path_1 = __importDefault(require("path"));
9
+ const config_1 = require("../config");
10
+ const skills_1 = require("../skills");
11
+ const CONFIG_NAME = "dittospec.config.json";
12
+ const WORKSPACE_SPEC_NAME = "workspace.ditto.md";
13
+ const DEFAULT_CONFIG = JSON.stringify({
14
+ apiBase: "https://api.dittowords.com",
15
+ workspaceId: "TODO: your Ditto workspace ID",
16
+ roots: ["."],
17
+ }, null, 2);
18
+ const WORKSPACE_SPEC = `---
19
+ workspace: true
20
+ # Managed by Ditto — do not edit below
21
+ tags: []
22
+ rules: []
23
+ ---
24
+ `;
25
+ function agentDocs(format) {
26
+ const md = format === "claude";
27
+ const h2 = md ? "##" : "#";
28
+ const h3 = md ? "###" : "##";
29
+ const c = (s) => (md ? `\`${s}\`` : s);
30
+ const neverRules = md
31
+ ? `**Never write \`rules\` by hand.**`
32
+ : "NEVER write rules by hand.";
33
+ return `
34
+ ${h2} Ditto Content Specs
35
+
36
+ This project uses ${c(".ditto.md")} files to declare text surfaces on components. Rules are synced from the Ditto platform — never written by hand.
37
+
38
+ ${h3} Reading specs
39
+
40
+ When writing or editing text props for a component:
41
+
42
+ 1. Check for ${c("workspace.ditto.md")} at the project root for universal content rules.
43
+ 2. Check for ${c("index.ditto.md")} next to the component for surface-specific rules and constraints.
44
+ 3. Respect ${c("maxLength")} — it's a layout constraint, not a suggestion.
45
+ 4. Follow all rules in the ${c("rules")} key. Rules without a ${c("surface")} field apply to every surface; rules with ${c("surface")} apply only to that surface.
46
+ 5. Rules come in two shapes: style rules (${c("name")}, ${c("description")}, ${c("examples")}) and terminology entries (${c("term")}, ${c("disallowed")}). For terminology entries, always use the ${c("term")} form and never use the ${c("disallowed")} alternatives.
47
+ 6. Each rule carries a ${c("section")} field (e.g. "Voice & Tone", "Terminology") providing context for how to interpret it.
48
+ 7. If a ${c("locales")} key is present, it contains locale-scoped rules keyed by locale code. When writing copy for a specific locale, follow the matching locale's rules in addition to the base ${c("rules")}. When writing for the default locale, only follow ${c("rules")}.
49
+
50
+ ${h3} Creating specs
51
+
52
+ When creating a new component that renders any user-facing text, scaffold a spec file:
53
+
54
+ npx ditto-spec scaffold <ComponentName> --path <dir>
55
+
56
+ Then edit the generated ${c("index.ditto.md")} to add surfaces — one entry per piece of user-facing text the component renders:
57
+
58
+ surfaces:
59
+ title:
60
+ tags: [heading]
61
+ maxLength: 60
62
+ $children:
63
+ tags: [button, cta]
64
+ maxLength: 30
65
+
66
+ - Use ${c("$children")} for text via children. Use dot notation for nested props (${c("primaryAction.label")}). For hardcoded or internal strings, use a descriptive role name (${c("headline")}, ${c("bodyText")}, ${c("submitLabel")}).
67
+ - Check the ${c("tags")} key in ${c("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).
68
+ - ${neverRules} Run ${c("npx ditto-spec pull")} after adding surfaces to populate rules from the platform.
69
+
70
+ 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.
71
+
72
+ Run ${c("npx ditto-spec list")} to see all existing specs.
73
+ `;
74
+ }
75
+ const AGENTS_MD_ROW = "| Ditto component specs | `.ditto.md` content specs — read `workspace.ditto.md` and component `index.ditto.md` before writing copy |";
76
+ async function init(opts = {}) {
77
+ const cwd = process.cwd();
78
+ const configExistsHere = fs_1.default.existsSync(path_1.default.join(cwd, CONFIG_NAME));
79
+ if (configExistsHere && !opts.writeAgent) {
80
+ console.log(`${CONFIG_NAME} already exists. Nothing to do.`);
81
+ console.log("Run with --agent to add agent configuration (CLAUDE.md, .cursorrules, etc.).");
82
+ return;
83
+ }
84
+ if (!configExistsHere) {
85
+ const ancestorConfig = findAncestorConfig(cwd);
86
+ if (ancestorConfig) {
87
+ console.log(`⚠ Found existing ${CONFIG_NAME} at ${ancestorConfig}`);
88
+ console.log(" Creating a new config here will shadow it for commands run in this directory.");
89
+ }
90
+ fs_1.default.writeFileSync(path_1.default.join(cwd, CONFIG_NAME), DEFAULT_CONFIG + "\n");
91
+ console.log(`✓ Created ${CONFIG_NAME}`);
92
+ fs_1.default.writeFileSync(path_1.default.join(cwd, WORKSPACE_SPEC_NAME), WORKSPACE_SPEC);
93
+ console.log(`✓ Created ${WORKSPACE_SPEC_NAME}`);
94
+ }
95
+ const env = detectAgentEnv(cwd);
96
+ if (opts.writeAgent) {
97
+ writeAgentConfig(cwd, env, opts.force);
98
+ }
99
+ else {
100
+ printAgentSuggestions(env);
101
+ }
102
+ if (!configExistsHere) {
103
+ printNextSteps();
104
+ }
105
+ }
106
+ function findAncestorConfig(cwd) {
107
+ try {
108
+ const { root } = (0, config_1.loadConfig)(cwd);
109
+ if (path_1.default.resolve(root) !== path_1.default.resolve(cwd)) {
110
+ return path_1.default.join(root, CONFIG_NAME);
111
+ }
112
+ }
113
+ catch (err) {
114
+ if (!(err instanceof config_1.ConfigNotFoundError))
115
+ throw err;
116
+ }
117
+ return null;
118
+ }
119
+ function detectAgentEnv(cwd) {
120
+ if (fs_1.default.existsSync(path_1.default.join(cwd, ".claude")) || fs_1.default.existsSync(path_1.default.join(cwd, "CLAUDE.md"))) {
121
+ return "claude";
122
+ }
123
+ if (fs_1.default.existsSync(path_1.default.join(cwd, ".cursor")) || fs_1.default.existsSync(path_1.default.join(cwd, ".cursorrules"))) {
124
+ return "cursor";
125
+ }
126
+ return "none";
127
+ }
128
+ function writeAgentConfig(cwd, env, force) {
129
+ if (env === "claude") {
130
+ const claudeMd = path_1.default.join(cwd, "CLAUDE.md");
131
+ const block = agentDocs("claude");
132
+ if (fs_1.default.existsSync(claudeMd)) {
133
+ const existing = fs_1.default.readFileSync(claudeMd, "utf8");
134
+ if (existing.includes("Ditto Content Specs")) {
135
+ console.log(" CLAUDE.md already has Ditto Content Specs section — skipped.");
136
+ }
137
+ else {
138
+ fs_1.default.appendFileSync(claudeMd, block);
139
+ console.log("✓ Appended Ditto Content Specs section to CLAUDE.md");
140
+ }
141
+ }
142
+ else {
143
+ fs_1.default.writeFileSync(claudeMd, `# CLAUDE.md${block}`);
144
+ console.log("✓ Created CLAUDE.md with Ditto Content Specs section");
145
+ }
146
+ writeSkillFiles(cwd, force);
147
+ const agentsMd = path_1.default.join(cwd, "AGENTS.md");
148
+ if (fs_1.default.existsSync(agentsMd)) {
149
+ const existing = fs_1.default.readFileSync(agentsMd, "utf8");
150
+ if (existing.includes("Ditto component specs")) {
151
+ console.log(" AGENTS.md already has Ditto row — skipped.");
152
+ }
153
+ else {
154
+ fs_1.default.appendFileSync(agentsMd, "\n" + AGENTS_MD_ROW + "\n");
155
+ console.log("✓ Appended Ditto row to AGENTS.md");
156
+ }
157
+ }
158
+ return;
159
+ }
160
+ if (env === "cursor") {
161
+ const cursorrules = path_1.default.join(cwd, ".cursorrules");
162
+ const block = agentDocs("cursor");
163
+ if (fs_1.default.existsSync(cursorrules)) {
164
+ const existing = fs_1.default.readFileSync(cursorrules, "utf8");
165
+ if (existing.includes("Ditto Content Specs")) {
166
+ console.log(" .cursorrules already has Ditto section — skipped.");
167
+ }
168
+ else {
169
+ fs_1.default.appendFileSync(cursorrules, block);
170
+ console.log("✓ Appended Ditto section to .cursorrules");
171
+ }
172
+ }
173
+ else {
174
+ fs_1.default.writeFileSync(cursorrules, block.trimStart());
175
+ console.log("✓ Created .cursorrules with Ditto section");
176
+ }
177
+ return;
178
+ }
179
+ console.log("\nNo agent environment detected. See next steps for manual setup.");
180
+ }
181
+ function writeSkillFiles(cwd, force) {
182
+ const commandsDir = path_1.default.join(cwd, ".claude", "commands");
183
+ fs_1.default.mkdirSync(commandsDir, { recursive: true });
184
+ let written = 0;
185
+ let unchanged = 0;
186
+ const skipped = [];
187
+ for (const [filename, content] of Object.entries(skills_1.SKILLS)) {
188
+ const filePath = path_1.default.join(commandsDir, filename);
189
+ if (fs_1.default.existsSync(filePath)) {
190
+ const existing = fs_1.default.readFileSync(filePath, "utf8");
191
+ if (existing === content) {
192
+ unchanged++;
193
+ continue;
194
+ }
195
+ if (!force) {
196
+ skipped.push(filename);
197
+ continue;
198
+ }
199
+ }
200
+ fs_1.default.writeFileSync(filePath, content);
201
+ written++;
202
+ }
203
+ if (written > 0) {
204
+ console.log(`✓ Wrote ${written} skill file(s) to .claude/commands/`);
205
+ }
206
+ if (unchanged > 0) {
207
+ console.log(` ${unchanged} skill file(s) already up to date.`);
208
+ }
209
+ if (skipped.length > 0) {
210
+ console.log(`⚠ Skipped ${skipped.length} locally modified skill file(s): ${skipped.join(", ")}`);
211
+ console.log(" Re-run with --force to overwrite.");
212
+ }
213
+ }
214
+ function printAgentSuggestions(env) {
215
+ if (env === "claude") {
216
+ console.log("\nDetected Claude Code environment.");
217
+ console.log("Add this to your CLAUDE.md (or run `ditto-spec init --agent` to do it automatically):\n");
218
+ console.log(agentDocs("claude").trim());
219
+ return;
220
+ }
221
+ if (env === "cursor") {
222
+ console.log("\nDetected Cursor environment.");
223
+ console.log("Add this to your .cursorrules (or run `ditto-spec init --agent` to do it automatically):\n");
224
+ console.log(agentDocs("cursor").trim());
225
+ return;
226
+ }
227
+ console.log("\nTo help your AI agent use ditto specs, add the agent contract from the README");
228
+ console.log("to your project's agent configuration file (CLAUDE.md, .cursorrules, etc.).");
229
+ }
230
+ function printNextSteps() {
231
+ console.log(`
232
+ Next steps:
233
+ 1. Fill in your workspaceId in ${CONFIG_NAME}
234
+ 2. Set DITTO_API_KEY in .env or your shell
235
+ 3. Create your first component spec (index.ditto.md next to a component)
236
+ 4. Run \`ditto-spec pull\` to sync rules from the platform`);
237
+ }
@@ -0,0 +1 @@
1
+ export declare function list(): Promise<void>;
@@ -0,0 +1,51 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.list = list;
7
+ const path_1 = __importDefault(require("path"));
8
+ const config_1 = require("../config");
9
+ const discover_1 = require("../discover");
10
+ const parse_1 = require("../parse");
11
+ async function list() {
12
+ const { config, root } = (0, config_1.loadConfig)();
13
+ const files = (0, discover_1.discover)(root, config.roots);
14
+ if (files.length === 0) {
15
+ console.log("No .ditto.md files found.");
16
+ return;
17
+ }
18
+ for (const f of files) {
19
+ let parsed;
20
+ try {
21
+ parsed = (0, parse_1.parseSpecFile)(f.abs);
22
+ }
23
+ catch (err) {
24
+ console.error(`× ${path_1.default.relative(root, f.abs)}: ${err instanceof Error ? err.message : err}`);
25
+ continue;
26
+ }
27
+ if (parsed.kind === "workspace") {
28
+ const rules = Array.isArray(parsed.spec.rules) ? parsed.spec.rules : [];
29
+ console.log(`\n[workspace] (${path_1.default.relative(root, f.abs)})`);
30
+ console.log(` ${rules.length} rule${rules.length === 1 ? "" : "s"}`);
31
+ continue;
32
+ }
33
+ console.log(`\n${parsed.name} (${path_1.default.relative(root, f.abs)})`);
34
+ const specLike = parsed.spec;
35
+ const componentTags = specLike.tags ?? [];
36
+ if (componentTags.length > 0) {
37
+ console.log(` component tags: [${componentTags.join(", ")}]`);
38
+ }
39
+ const surfaces = specLike.surfaces ?? {};
40
+ const entries = Object.entries(surfaces);
41
+ if (entries.length === 0) {
42
+ console.log(" (no surfaces)");
43
+ continue;
44
+ }
45
+ for (const [key, surface] of entries) {
46
+ const tags = (surface.tags ?? []).join(", ");
47
+ const maxLen = surface.maxLength != null ? ` max ${surface.maxLength}` : "";
48
+ console.log(` ${key.padEnd(28)} [${tags}]${maxLen}`);
49
+ }
50
+ }
51
+ }
@@ -0,0 +1,5 @@
1
+ interface PullOptions {
2
+ dryRun?: boolean;
3
+ }
4
+ export declare function pull(opts?: PullOptions): Promise<void>;
5
+ export {};
@@ -0,0 +1,168 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.pull = pull;
7
+ const path_1 = __importDefault(require("path"));
8
+ const api_1 = require("../api");
9
+ const config_1 = require("../config");
10
+ const discover_1 = require("../discover");
11
+ const parse_1 = require("../parse");
12
+ const serialize_1 = require("../serialize");
13
+ async function pull(opts = {}) {
14
+ const { config, root } = (0, config_1.loadConfig)();
15
+ const apiKey = (0, config_1.getApiKey)();
16
+ const api = new api_1.DittoApi(config, apiKey);
17
+ const files = (0, discover_1.discover)(root, config.roots);
18
+ if (files.length === 0) {
19
+ console.log("No .ditto.md files found.");
20
+ return;
21
+ }
22
+ console.log(`Found ${files.length} spec file${files.length === 1 ? "" : "s"}.`);
23
+ const componentFiles = [];
24
+ const workspaceFiles = [];
25
+ let failed = 0;
26
+ for (const f of files) {
27
+ let parsed;
28
+ try {
29
+ parsed = (0, parse_1.parseSpecFile)(f.abs);
30
+ }
31
+ catch (err) {
32
+ failed++;
33
+ console.error(`× ${f.rel}: ${err instanceof Error ? err.message : err}`);
34
+ continue;
35
+ }
36
+ if (parsed.kind === "workspace") {
37
+ workspaceFiles.push({ file: f, parsed });
38
+ }
39
+ else {
40
+ componentFiles.push({ file: f, parsed });
41
+ }
42
+ }
43
+ if (workspaceFiles.length > 1) {
44
+ const paths = workspaceFiles.map((w) => path_1.default.relative(root, w.file.abs)).join(", ");
45
+ throw new Error(`Found multiple workspace specs (${paths}). Expected at most one.`);
46
+ }
47
+ const guides = await api.getStyleguides();
48
+ const allRules = (0, api_1.flattenStyleguides)(guides, config.styleguides);
49
+ const baseRules = allRules.filter((r) => r.localeCode === null);
50
+ const configuredLocales = config.locales ?? [];
51
+ const localeRules = configuredLocales.length > 0
52
+ ? allRules.filter((r) => r.localeCode !== null && configuredLocales.includes(r.localeCode))
53
+ : [];
54
+ const localeGroups = groupByLocale(localeRules);
55
+ const activeRules = [...baseRules, ...localeRules];
56
+ console.log(`Fetched ${activeRules.length} rule${activeRules.length === 1 ? "" : "s"} from ${guides.length} style guide(s).`);
57
+ const platformTags = [...new Set(activeRules.flatMap((r) => r.tags))].filter(Boolean).sort();
58
+ let written = 0;
59
+ let unchanged = 0;
60
+ if (workspaceFiles.length === 1) {
61
+ const { file, parsed } = workspaceFiles[0];
62
+ const universalBaseRules = baseRules.filter((r) => r.tags.length === 0).map(toRuleObj);
63
+ const universalLocales = {};
64
+ for (const [code, rules] of localeGroups) {
65
+ const universal = rules.filter((r) => r.tags.length === 0).map(toRuleObj);
66
+ if (universal.length > 0)
67
+ universalLocales[code] = universal;
68
+ }
69
+ if (jsonMatch(parsed.spec.rules, universalBaseRules) &&
70
+ jsonMatch(parsed.spec.tags, platformTags) &&
71
+ jsonMatch(parsed.spec.locales, Object.keys(universalLocales).length > 0 ? universalLocales : undefined)) {
72
+ unchanged++;
73
+ }
74
+ else if (opts.dryRun) {
75
+ console.log(`~ ${path_1.default.relative(root, file.abs)} (would write ${universalBaseRules.length} workspace rule(s), ${platformTags.length} tag(s), ${Object.keys(universalLocales).length} locale(s))`);
76
+ }
77
+ else {
78
+ (0, serialize_1.rewriteManagedKeys)(file.abs, { tags: platformTags, rules: universalBaseRules, locales: universalLocales });
79
+ written++;
80
+ console.log(`✓ ${path_1.default.relative(root, file.abs)} — ${universalBaseRules.length} workspace rule(s), ${platformTags.length} tag(s), ${Object.keys(universalLocales).length} locale(s)`);
81
+ }
82
+ }
83
+ for (const { file, parsed } of componentFiles) {
84
+ const specLike = parsed.spec;
85
+ const componentTags = specLike.tags ?? [];
86
+ const surfaces = specLike.surfaces ?? {};
87
+ const matchedBaseRules = matchRulesForSpec(baseRules, componentTags, surfaces);
88
+ const matchedLocales = {};
89
+ for (const [code, rules] of localeGroups) {
90
+ const matched = matchRulesForSpec(rules, componentTags, surfaces);
91
+ if (matched.length > 0)
92
+ matchedLocales[code] = matched;
93
+ }
94
+ if (jsonMatch(parsed.spec.rules, matchedBaseRules) &&
95
+ jsonMatch(parsed.spec.locales, Object.keys(matchedLocales).length > 0 ? matchedLocales : undefined)) {
96
+ unchanged++;
97
+ continue;
98
+ }
99
+ if (opts.dryRun) {
100
+ console.log(`~ ${path_1.default.relative(root, file.abs)} (would write ${matchedBaseRules.length} rule(s), ${Object.keys(matchedLocales).length} locale(s))`);
101
+ continue;
102
+ }
103
+ (0, serialize_1.rewriteManagedKeys)(file.abs, { rules: matchedBaseRules, locales: matchedLocales });
104
+ written++;
105
+ console.log(`✓ ${path_1.default.relative(root, file.abs)} — ${matchedBaseRules.length} rule(s), ${Object.keys(matchedLocales).length} locale(s)`);
106
+ }
107
+ console.log(`\nDone. ${written} updated, ${unchanged} unchanged.`);
108
+ if (failed > 0) {
109
+ console.error(`${failed} file(s) failed to parse.`);
110
+ process.exit(1);
111
+ }
112
+ }
113
+ function matchRulesForSpec(rules, componentTags, surfaces) {
114
+ const componentMatches = rulesForTags(rules, componentTags);
115
+ const consumed = new Set(componentMatches);
116
+ const matched = [];
117
+ for (const rule of componentMatches) {
118
+ matched.push(toRuleObj(rule));
119
+ }
120
+ for (const [surfaceKey, surface] of Object.entries(surfaces)) {
121
+ const surfaceMatches = rulesForTags(rules, surface.tags ?? []).filter((r) => !consumed.has(r));
122
+ for (const rule of surfaceMatches) {
123
+ matched.push(toSurfaceRuleObj(surfaceKey, rule));
124
+ }
125
+ }
126
+ return matched;
127
+ }
128
+ function groupByLocale(rules) {
129
+ const groups = new Map();
130
+ for (const rule of rules) {
131
+ const code = rule.localeCode;
132
+ if (!groups.has(code))
133
+ groups.set(code, []);
134
+ groups.get(code).push(rule);
135
+ }
136
+ return groups;
137
+ }
138
+ function rulesForTags(rules, tags) {
139
+ if (tags.length === 0)
140
+ return [];
141
+ const tagSet = new Set(tags);
142
+ return rules.filter((r) => r.tags.length > 0 && r.tags.some((t) => tagSet.has(t)));
143
+ }
144
+ function toRuleObj(rule) {
145
+ const out = {};
146
+ if (rule.kind === "wordlist") {
147
+ out.term = rule.term;
148
+ out.disallowed = rule.disallowed;
149
+ if (rule.description)
150
+ out.description = rule.description;
151
+ }
152
+ else {
153
+ out.name = rule.name;
154
+ if (rule.description)
155
+ out.description = rule.description;
156
+ if (rule.examples.length > 0) {
157
+ out.examples = rule.examples.map((ex) => ({ from: ex.from, to: ex.to }));
158
+ }
159
+ }
160
+ out.section = rule.section;
161
+ return out;
162
+ }
163
+ function toSurfaceRuleObj(surface, rule) {
164
+ return { surface, ...toRuleObj(rule) };
165
+ }
166
+ function jsonMatch(existing, incoming) {
167
+ return JSON.stringify(existing ?? undefined) === JSON.stringify(incoming ?? undefined);
168
+ }
@@ -0,0 +1,6 @@
1
+ interface ScaffoldOptions {
2
+ componentName: string;
3
+ targetDir: string;
4
+ }
5
+ export declare function scaffold(opts: ScaffoldOptions): Promise<void>;
6
+ export {};
@@ -0,0 +1,51 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.scaffold = scaffold;
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const path_1 = __importDefault(require("path"));
9
+ const js_yaml_1 = __importDefault(require("js-yaml"));
10
+ const config_1 = require("../config");
11
+ const SPEC_FILENAME = "index.ditto.md";
12
+ async function scaffold(opts) {
13
+ const dest = path_1.default.join(opts.targetDir, SPEC_FILENAME);
14
+ if (fs_1.default.existsSync(dest)) {
15
+ console.log(`${SPEC_FILENAME} already exists at ${opts.targetDir}. Nothing to do.`);
16
+ return;
17
+ }
18
+ const nameYaml = js_yaml_1.default.dump({ component: opts.componentName }, { lineWidth: -1 }).trimEnd();
19
+ const content = `---
20
+ ${nameYaml}
21
+ tags: []
22
+ surfaces: {}
23
+ # Managed by Ditto — do not edit below
24
+ rules: []
25
+ ---
26
+ `;
27
+ fs_1.default.mkdirSync(opts.targetDir, { recursive: true });
28
+ fs_1.default.writeFileSync(dest, content);
29
+ console.log(`✓ Created ${path_1.default.relative(process.cwd(), dest)}`);
30
+ warnIfOutsideRoots(opts.targetDir);
31
+ console.log(`
32
+ Next steps:
33
+ 1. Add a surface key for each piece of user-facing text the component renders
34
+ 2. Tag each surface with content categories (heading, body, button, cta, etc.)
35
+ 3. Run \`ditto-spec pull\` to populate rules from the platform`);
36
+ }
37
+ function warnIfOutsideRoots(targetDir) {
38
+ try {
39
+ const { config, root } = (0, config_1.loadConfig)();
40
+ const roots = config.roots ?? ["."];
41
+ const absTarget = path_1.default.resolve(targetDir);
42
+ const inside = roots.some((r) => absTarget.startsWith(path_1.default.resolve(root, r)));
43
+ if (!inside) {
44
+ console.log(`\n⚠ Target directory is outside configured roots (${roots.join(", ")}). This file won't be found by pull, check, or list.`);
45
+ }
46
+ }
47
+ catch (err) {
48
+ if (!(err instanceof config_1.ConfigNotFoundError))
49
+ throw err;
50
+ }
51
+ }
@@ -0,0 +1,18 @@
1
+ export interface Config {
2
+ apiBase: string;
3
+ workspaceId: string;
4
+ roots?: string[];
5
+ styleguides?: string[];
6
+ locales?: string[];
7
+ defaultStyleguide?: string;
8
+ /** @deprecated Use defaultStyleguide instead. */
9
+ styleguideId?: string;
10
+ }
11
+ export declare class ConfigNotFoundError extends Error {
12
+ constructor(cwd: string);
13
+ }
14
+ export declare function loadConfig(cwd?: string): {
15
+ config: Config;
16
+ root: string;
17
+ };
18
+ export declare function getApiKey(): string;
package/dist/config.js ADDED
@@ -0,0 +1,101 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.ConfigNotFoundError = void 0;
7
+ exports.loadConfig = loadConfig;
8
+ exports.getApiKey = getApiKey;
9
+ const dotenv_1 = __importDefault(require("dotenv"));
10
+ const fs_1 = __importDefault(require("fs"));
11
+ const path_1 = __importDefault(require("path"));
12
+ const DEFAULT_CONFIG_NAME = "dittospec.config.json";
13
+ class ConfigNotFoundError extends Error {
14
+ constructor(cwd) {
15
+ super(`No ${DEFAULT_CONFIG_NAME} found from ${cwd} up to filesystem root.`);
16
+ }
17
+ }
18
+ exports.ConfigNotFoundError = ConfigNotFoundError;
19
+ function loadConfig(cwd = process.cwd()) {
20
+ let dir = path_1.default.resolve(cwd);
21
+ while (true) {
22
+ const candidate = path_1.default.join(dir, DEFAULT_CONFIG_NAME);
23
+ if (fs_1.default.existsSync(candidate)) {
24
+ const raw = fs_1.default.readFileSync(candidate, "utf8");
25
+ let parsed;
26
+ try {
27
+ parsed = JSON.parse(raw);
28
+ }
29
+ catch {
30
+ throw new Error(`${candidate}: invalid JSON. Check for syntax errors.`);
31
+ }
32
+ validate(parsed, candidate);
33
+ const config = parsed;
34
+ if (config.styleguideId && !config.defaultStyleguide) {
35
+ config.defaultStyleguide = config.styleguideId;
36
+ }
37
+ return { config, root: dir };
38
+ }
39
+ const parent = path_1.default.dirname(dir);
40
+ if (parent === dir)
41
+ break;
42
+ dir = parent;
43
+ }
44
+ throw new ConfigNotFoundError(cwd);
45
+ }
46
+ function validate(c, source) {
47
+ if (!c || typeof c !== "object")
48
+ throw new Error(`${source}: must be a JSON object.`);
49
+ const obj = c;
50
+ if (typeof obj.apiBase !== "string")
51
+ throw new Error(`${source}: missing string "apiBase".`);
52
+ if (typeof obj.workspaceId !== "string")
53
+ throw new Error(`${source}: missing string "workspaceId".`);
54
+ if (obj.roots !== undefined) {
55
+ if (!Array.isArray(obj.roots))
56
+ throw new Error(`${source}: "roots" must be an array of strings if present.`);
57
+ if (obj.roots.some((r) => typeof r !== "string")) {
58
+ throw new Error(`${source}: every entry in "roots" must be a string.`);
59
+ }
60
+ }
61
+ if (obj.styleguides !== undefined) {
62
+ if (!Array.isArray(obj.styleguides))
63
+ throw new Error(`${source}: "styleguides" must be an array of strings if present.`);
64
+ if (obj.styleguides.some((s) => typeof s !== "string")) {
65
+ throw new Error(`${source}: every entry in "styleguides" must be a string.`);
66
+ }
67
+ }
68
+ if (obj.locales !== undefined) {
69
+ if (!Array.isArray(obj.locales))
70
+ throw new Error(`${source}: "locales" must be an array of strings if present.`);
71
+ if (obj.locales.some((l) => typeof l !== "string")) {
72
+ throw new Error(`${source}: every entry in "locales" must be a string.`);
73
+ }
74
+ }
75
+ if (obj.defaultStyleguide !== undefined) {
76
+ if (typeof obj.defaultStyleguide !== "string")
77
+ throw new Error(`${source}: "defaultStyleguide" must be a string if present.`);
78
+ }
79
+ if (obj.styleguideId !== undefined) {
80
+ if (typeof obj.styleguideId !== "string")
81
+ throw new Error(`${source}: "styleguideId" must be a string if present.`);
82
+ }
83
+ if (obj.defaultStyleguide !== undefined && obj.styleguideId !== undefined) {
84
+ throw new Error(`${source}: specify "defaultStyleguide" or "styleguideId", not both.`);
85
+ }
86
+ }
87
+ function getApiKey() {
88
+ try {
89
+ const { root } = loadConfig();
90
+ dotenv_1.default.config({ path: path_1.default.join(root, ".env") });
91
+ }
92
+ catch (err) {
93
+ if (!(err instanceof ConfigNotFoundError))
94
+ throw err;
95
+ }
96
+ const key = process.env.DITTO_API_KEY;
97
+ if (!key) {
98
+ throw new Error("DITTO_API_KEY is not set. Add it to .env at the repo root, or `export DITTO_API_KEY=...` in your shell.");
99
+ }
100
+ return key;
101
+ }
@@ -0,0 +1,5 @@
1
+ export interface DiscoveredFile {
2
+ abs: string;
3
+ rel: string;
4
+ }
5
+ export declare function discover(repoRoot: string, roots?: string[]): DiscoveredFile[];
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.discover = discover;
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const glob_1 = require("glob");
9
+ const path_1 = __importDefault(require("path"));
10
+ const SPEC_GLOB = "**/*.ditto.md";
11
+ const IGNORE = ["**/node_modules/**", "**/dist*/**", "**/.git/**", "**/.next/**", "**/coverage/**"];
12
+ function discover(repoRoot, roots = ["."]) {
13
+ const patterns = roots.map((r) => path_1.default.posix.join(r, SPEC_GLOB));
14
+ const results = (0, glob_1.globSync)(patterns, {
15
+ cwd: repoRoot,
16
+ ignore: IGNORE,
17
+ absolute: true,
18
+ });
19
+ const workspacePath = path_1.default.resolve(repoRoot, "workspace.ditto.md");
20
+ if (fs_1.default.existsSync(workspacePath) && !results.includes(workspacePath)) {
21
+ results.push(workspacePath);
22
+ }
23
+ return results.map((abs) => ({ abs, rel: path_1.default.relative(repoRoot, abs) })).sort((a, b) => a.rel.localeCompare(b.rel));
24
+ }