@dittowords/spec-cli 0.0.1-alpha.6 → 0.0.1-alpha.8

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 CHANGED
@@ -37,12 +37,29 @@ surfaces:
37
37
  rules:
38
38
  - name: Confirmation dialogs should be direct
39
39
  description: Keep confirmation copy terse and unambiguous
40
+ styleguide: Acme Inc.
41
+ section: Voice & Tone
40
42
  - surface: actionText
41
43
  name: Calls to action should use active voice
42
44
  description: Always lead with a verb
43
45
  examples:
44
46
  - from: "Your settings"
45
47
  to: "Open settings"
48
+ styleguide: Acme Inc.
49
+ section: Voice & Tone
50
+ - term: sign up
51
+ disallowed:
52
+ - signup
53
+ - sign-up
54
+ description: Always use as two words (verb form)
55
+ styleguide: Acme Inc.
56
+ section: Terminology
57
+ locales:
58
+ de-DE:
59
+ - name: Use informal address
60
+ description: Use "Du" instead of "Sie" for all user-facing copy
61
+ styleguide: German
62
+ section: Formality
46
63
  ---
47
64
  ```
48
65
 
@@ -55,7 +72,17 @@ A repo may have a single `workspace.ditto.md` somewhere under the CLI's configur
55
72
  workspace: true
56
73
  # Managed by Ditto — do not edit below
57
74
  tags: [body, button, call-to-action, dialog-title, heading, nav]
58
- rules: []
75
+ rules:
76
+ - name: Write in active voice
77
+ description: Lead with verbs, avoid passive constructions
78
+ styleguide: Acme Inc.
79
+ section: Voice & Tone
80
+ locales:
81
+ de-DE:
82
+ - name: Use informal address
83
+ description: Use "Du" instead of "Sie" for all user-facing copy
84
+ styleguide: German
85
+ section: Formality
59
86
  ---
60
87
  ```
61
88
 
@@ -63,7 +90,7 @@ rules: []
63
90
 
64
91
  **Developer-owned keys**: `component`, `tags`, `surfaces`. Edit these freely.
65
92
 
66
- **CLI-managed keys**: `rules` and workspace `tags`. Overwritten by `ditto-spec pull`. Do not edit by hand.
93
+ **CLI-managed keys**: `rules`, `locales`, and workspace `tags`. Overwritten by `ditto-spec pull`. Do not edit by hand. Rules come in two shapes: style rules (`name`/`description`/`examples`) and terminology entries (`term`/`disallowed`/`description`).
67
94
 
68
95
  **Surface keys** identify each distinct piece of user-facing text the component renders. For text passed as props, use the prop path as the key — dot-notation works for nested props (e.g., `primaryAction.label`). Use `$children` for text via the `children` prop. For hardcoded or internal strings, use a descriptive role name (e.g., `headline`, `bodyText`, `submitLabel`).
69
96
 
@@ -71,13 +98,16 @@ rules: []
71
98
 
72
99
  **`maxLength` / `minLength`** are hard layout constraints, not stylistic preferences. Stylistic guidance belongs on the platform as rules.
73
100
 
74
- ### Three-level rule hierarchy
101
+ ### Rule hierarchy
75
102
 
76
103
  | Scope | Where | Applies to |
77
104
  |---|---|---|
78
105
  | **Workspace** | `workspace.ditto.md` `rules[]` | Every surface in every component |
79
106
  | **Component-level** | Component's `rules[]`, no `surface` field | Every surface in this component |
80
107
  | **Per-surface** | Component's `rules[]`, with `surface: "<key>"` | That one surface |
108
+ | **Locale-scoped** | `locales.<code>[]` (workspace or component) | Same hierarchy as above, but only when writing copy for that locale |
109
+
110
+ Base rules in `rules` always apply. Locale-scoped rules in `locales` apply only when writing copy for the matching locale — they never conflict with each other because each locale is a separate scope.
81
111
 
82
112
  ## CLI commands
83
113
 
@@ -102,9 +132,11 @@ Use `--path <dir>` to specify where the file is created (defaults to the current
102
132
  Syncs rules from the platform into spec files.
103
133
 
104
134
  1. Discovers all `.ditto.md` files under configured roots
105
- 2. Fetches all workspace rules from `GET /v2/rules/mcp`
106
- 3. Matches rules to specs by tag intersection (client-side)
107
- 4. Rewrites the `rules` key in each file's YAML frontmatter
135
+ 2. Fetches style guides from `GET /v2/styleguides`
136
+ 3. Flattens rules and wordlist entries across all guides (or only those named in `styleguides` config)
137
+ 4. Separates base rules from locale-scoped rules (included when `locales` is configured)
138
+ 5. Matches rules to specs by tag intersection (client-side)
139
+ 6. Rewrites the `rules` and `locales` keys in each file's YAML frontmatter
108
140
 
109
141
  Use `--dry-run` to see what would change without writing.
110
142
 
@@ -133,6 +165,8 @@ Create `dittospec.config.json` at your repo root (or any ancestor directory):
133
165
  | `apiBase` | Ditto API base URL |
134
166
  | `workspaceId` | Your workspace ID |
135
167
  | `roots` | Repo-relative directories to search for `.ditto.md` files. Defaults to `["."]`. |
168
+ | `styleguides` | Optional list of style guide names to pull. Defaults to all. |
169
+ | `locales` | Optional list of locale codes (e.g. `["de-DE", "fr-FR"]`). Includes locale-scoped style guides matching these codes. Base (no-variant) guides are always included. |
136
170
 
137
171
  Set `DITTO_API_KEY` in your environment or in a `.env` file at the repo root.
138
172
 
@@ -144,7 +178,11 @@ When writing or editing user-facing text for a component:
144
178
  2. Read the component's `index.ditto.md`. Match each piece of text you're writing to a surface key.
145
179
  3. Respect `maxLength` — it's a layout invariant, not a suggestion.
146
180
  4. Follow all rules in `rules[]`. Entries without `surface` apply to every surface; entries with `surface` apply only to that surface.
147
- 5. When a rule includes `examples`, reference them as concrete tone/shape guidance.
181
+ 5. Rules come in two shapes:
182
+ - **Style rules** have `name`, `description`, and optional `examples` (before/after pairs). Use `examples` as concrete tone/shape guidance.
183
+ - **Terminology entries** have `term` and `disallowed`. Always use the `term` form; never use any of the `disallowed` alternatives.
184
+ 6. Each rule carries `styleguide` and `section` metadata indicating its source on the platform.
185
+ 7. If a `locales` key is present, it contains locale-scoped rules keyed by locale code (e.g. `de-DE`). When writing copy for a specific locale, follow the matching `locales.<code>` rules **in addition to** the base `rules`. When writing for the default/base locale, only follow `rules`. Locale-scoped rules never conflict — each locale is a separate boundary.
148
186
 
149
187
  ### Creating specs
150
188
 
package/dist/api.d.ts CHANGED
@@ -1,7 +1,11 @@
1
1
  import type { Config } from "./config";
2
- export interface RuleResponse {
2
+ export interface StyleguideVariant {
3
+ id: string;
4
+ name: string;
5
+ localeCode: string | null;
6
+ }
7
+ export interface StyleRule {
3
8
  name: string;
4
- type: "style" | "wordlist";
5
9
  description: string;
6
10
  examples: {
7
11
  from: string;
@@ -9,11 +13,51 @@ export interface RuleResponse {
9
13
  }[];
10
14
  tags: string[];
11
15
  }
16
+ export interface WordlistEntry {
17
+ term: string;
18
+ disallowed: string[];
19
+ description: string;
20
+ tags: string[];
21
+ }
22
+ export interface StyleguideSection {
23
+ name: string;
24
+ kind: "rules" | "wordlist";
25
+ rules: StyleRule[] | WordlistEntry[];
26
+ }
27
+ export interface Styleguide {
28
+ name: string;
29
+ description: string;
30
+ variant: StyleguideVariant | null;
31
+ sections: StyleguideSection[];
32
+ }
33
+ export type FlatRule = {
34
+ kind: "style";
35
+ styleguide: string;
36
+ section: string;
37
+ localeCode: string | null;
38
+ name: string;
39
+ description: string;
40
+ examples: {
41
+ from: string;
42
+ to: string;
43
+ }[];
44
+ tags: string[];
45
+ } | {
46
+ kind: "wordlist";
47
+ styleguide: string;
48
+ section: string;
49
+ localeCode: string | null;
50
+ term: string;
51
+ disallowed: string[];
52
+ description: string;
53
+ tags: string[];
54
+ };
55
+ export declare function flattenStyleguides(guides: Styleguide[], filter?: string[]): FlatRule[];
12
56
  export declare class DittoApi {
13
57
  private readonly config;
14
58
  private readonly apiKey;
15
59
  constructor(config: Config, apiKey: string);
16
- getRules(): Promise<RuleResponse[]>;
60
+ getStyleguides(): Promise<Styleguide[]>;
17
61
  private fetch;
18
62
  private headers;
19
63
  }
package/dist/api.js CHANGED
@@ -1,16 +1,54 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.DittoApi = void 0;
4
+ exports.flattenStyleguides = flattenStyleguides;
5
+ function flattenStyleguides(guides, filter) {
6
+ const selected = filter?.length ? guides.filter((g) => filter.includes(g.name)) : guides;
7
+ const result = [];
8
+ for (const guide of selected) {
9
+ for (const section of guide.sections) {
10
+ if (section.kind === "rules") {
11
+ for (const rule of section.rules) {
12
+ result.push({
13
+ kind: "style",
14
+ styleguide: guide.name,
15
+ section: section.name,
16
+ localeCode: guide.variant?.localeCode ?? null,
17
+ name: rule.name,
18
+ description: rule.description,
19
+ examples: rule.examples,
20
+ tags: rule.tags,
21
+ });
22
+ }
23
+ }
24
+ else {
25
+ for (const entry of section.rules) {
26
+ result.push({
27
+ kind: "wordlist",
28
+ styleguide: guide.name,
29
+ section: section.name,
30
+ localeCode: guide.variant?.localeCode ?? null,
31
+ term: entry.term,
32
+ disallowed: entry.disallowed,
33
+ description: entry.description,
34
+ tags: entry.tags,
35
+ });
36
+ }
37
+ }
38
+ }
39
+ }
40
+ return result;
41
+ }
4
42
  class DittoApi {
5
43
  constructor(config, apiKey) {
6
44
  this.config = config;
7
45
  this.apiKey = apiKey;
8
46
  }
9
- async getRules() {
10
- const url = new URL("/v2/rules/mcp", this.config.apiBase);
47
+ async getStyleguides() {
48
+ const url = new URL("/v2/styleguides", this.config.apiBase);
11
49
  const res = await this.fetch(url, { method: "GET" });
12
50
  const data = (await res.json());
13
- return data.workspaceRules;
51
+ return data.styleguides;
14
52
  }
15
53
  async fetch(url, init) {
16
54
  const res = await fetch(url.toString(), {
@@ -0,0 +1,8 @@
1
+ interface CreateRuleOptions {
2
+ name: string;
3
+ description: string;
4
+ tags?: string;
5
+ examples?: string;
6
+ }
7
+ export declare function createRule(opts: CreateRuleOptions): Promise<void>;
8
+ export {};
@@ -0,0 +1,37 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createRule = createRule;
4
+ const api_1 = require("../api");
5
+ const config_1 = require("../config");
6
+ async function createRule(opts) {
7
+ const { config } = (0, config_1.loadConfig)();
8
+ const apiKey = (0, config_1.getApiKey)();
9
+ const api = new api_1.DittoApi(config, apiKey);
10
+ if (!config.styleguideId) {
11
+ throw new Error('No "styleguideId" in dittospec.config.json. Add the ID of the style guide you want rules created in.');
12
+ }
13
+ const info = await api.getStyleguideInfo();
14
+ if (!info) {
15
+ throw new Error("Could not discover style guide sections. Check that your workspace has at least one style guide with rules.");
16
+ }
17
+ const sectionId = info.sectionId;
18
+ let examples;
19
+ if (opts.examples) {
20
+ try {
21
+ examples = JSON.parse(opts.examples);
22
+ }
23
+ catch {
24
+ throw new Error('--examples must be valid JSON, e.g. \'[{"from":"bad","to":"good"}]\'');
25
+ }
26
+ }
27
+ const tags = opts.tags ? opts.tags.split(",").map((t) => t.trim()).filter(Boolean) : undefined;
28
+ const result = await api.createRule({
29
+ styleguideId: config.styleguideId,
30
+ sectionId,
31
+ name: opts.name,
32
+ description: opts.description,
33
+ examples,
34
+ tags,
35
+ });
36
+ console.log(`✓ Created rule "${result.name}" (${result.id})`);
37
+ }
@@ -42,6 +42,9 @@ When writing or editing text props for a component:
42
42
  2. Check for ${c("index.ditto.md")} next to the component for surface-specific rules and constraints.
43
43
  3. Respect ${c("maxLength")} — it's a layout constraint, not a suggestion.
44
44
  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.
45
+ 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.
46
+ 6. Each rule carries ${c("styleguide")} and ${c("section")} metadata indicating its source.
47
+ 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")}.
45
48
 
46
49
  ${h3} Creating specs
47
50
 
@@ -44,53 +44,65 @@ async function pull(opts = {}) {
44
44
  const paths = workspaceFiles.map((w) => path_1.default.relative(root, w.file.abs)).join(", ");
45
45
  throw new Error(`Found multiple workspace specs (${paths}). Expected at most one.`);
46
46
  }
47
- const allRules = await api.getRules();
48
- console.log(`Fetched ${allRules.length} rule${allRules.length === 1 ? "" : "s"} from workspace.`);
49
- const platformTags = [...new Set(allRules.flatMap((r) => r.tags))].filter(Boolean).sort();
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();
50
58
  let written = 0;
51
59
  let unchanged = 0;
52
60
  if (workspaceFiles.length === 1) {
53
61
  const { file, parsed } = workspaceFiles[0];
54
- const universalRules = allRules.filter((r) => r.tags.length === 0).map(toRuleObj);
55
- if (rulesMatch(parsed.spec.rules, universalRules) && tagsMatch(parsed.spec.tags, platformTags)) {
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)) {
56
72
  unchanged++;
57
73
  }
58
74
  else if (opts.dryRun) {
59
- console.log(`~ ${path_1.default.relative(root, file.abs)} (would write ${universalRules.length} workspace rule(s), ${platformTags.length} tag(s))`);
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))`);
60
76
  }
61
77
  else {
62
- (0, serialize_1.rewriteManagedKeys)(file.abs, { tags: platformTags, rules: universalRules });
78
+ (0, serialize_1.rewriteManagedKeys)(file.abs, { tags: platformTags, rules: universalBaseRules, locales: universalLocales });
63
79
  written++;
64
- console.log(`✓ ${path_1.default.relative(root, file.abs)} — ${universalRules.length} workspace rule(s), ${platformTags.length} tag(s)`);
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)`);
65
81
  }
66
82
  }
67
83
  for (const { file, parsed } of componentFiles) {
68
84
  const specLike = parsed.spec;
69
85
  const componentTags = specLike.tags ?? [];
70
86
  const surfaces = specLike.surfaces ?? {};
71
- const componentMatches = rulesForTags(allRules, componentTags);
72
- const consumed = new Set(componentMatches);
73
- const matchedRules = [];
74
- for (const rule of componentMatches) {
75
- matchedRules.push(toRuleObj(rule));
76
- }
77
- for (const [surfaceKey, surface] of Object.entries(surfaces)) {
78
- const matches = rulesForTags(allRules, surface.tags ?? []).filter((r) => !consumed.has(r));
79
- for (const rule of matches) {
80
- matchedRules.push(toSurfaceRuleObj(surfaceKey, rule));
81
- }
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;
82
93
  }
83
- if (rulesMatch(parsed.spec.rules, matchedRules)) {
94
+ if (jsonMatch(parsed.spec.rules, matchedBaseRules) &&
95
+ jsonMatch(parsed.spec.locales, Object.keys(matchedLocales).length > 0 ? matchedLocales : undefined)) {
84
96
  unchanged++;
85
97
  continue;
86
98
  }
87
99
  if (opts.dryRun) {
88
- console.log(`~ ${path_1.default.relative(root, file.abs)} (would write ${matchedRules.length} rule(s))`);
100
+ console.log(`~ ${path_1.default.relative(root, file.abs)} (would write ${matchedBaseRules.length} rule(s), ${Object.keys(matchedLocales).length} locale(s))`);
89
101
  continue;
90
102
  }
91
- (0, serialize_1.rewriteManagedKeys)(file.abs, { rules: matchedRules });
103
+ (0, serialize_1.rewriteManagedKeys)(file.abs, { rules: matchedBaseRules, locales: matchedLocales });
92
104
  written++;
93
- console.log(`✓ ${path_1.default.relative(root, file.abs)} — ${matchedRules.length} rule(s)`);
105
+ console.log(`✓ ${path_1.default.relative(root, file.abs)} — ${matchedBaseRules.length} rule(s), ${Object.keys(matchedLocales).length} locale(s)`);
94
106
  }
95
107
  console.log(`\nDone. ${written} updated, ${unchanged} unchanged.`);
96
108
  if (failed > 0) {
@@ -98,6 +110,31 @@ async function pull(opts = {}) {
98
110
  process.exit(1);
99
111
  }
100
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
+ }
101
138
  function rulesForTags(rules, tags) {
102
139
  if (tags.length === 0)
103
140
  return [];
@@ -105,21 +142,28 @@ function rulesForTags(rules, tags) {
105
142
  return rules.filter((r) => r.tags.length > 0 && r.tags.some((t) => tagSet.has(t)));
106
143
  }
107
144
  function toRuleObj(rule) {
108
- const out = {
109
- name: rule.name,
110
- description: rule.description,
111
- };
112
- if (rule.examples.length > 0) {
113
- out.examples = rule.examples.map((ex) => ({ from: ex.from, to: ex.to }));
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;
114
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.styleguide = rule.styleguide;
161
+ out.section = rule.section;
115
162
  return out;
116
163
  }
117
164
  function toSurfaceRuleObj(surface, rule) {
118
165
  return { surface, ...toRuleObj(rule) };
119
166
  }
120
- function rulesMatch(existing, incoming) {
121
- return JSON.stringify(existing ?? []) === JSON.stringify(incoming);
122
- }
123
- function tagsMatch(existing, incoming) {
124
- return JSON.stringify(existing ?? []) === JSON.stringify(incoming);
167
+ function jsonMatch(existing, incoming) {
168
+ return JSON.stringify(existing ?? undefined) === JSON.stringify(incoming ?? undefined);
125
169
  }
package/dist/config.d.ts CHANGED
@@ -2,6 +2,8 @@ export interface Config {
2
2
  apiBase: string;
3
3
  workspaceId: string;
4
4
  roots?: string[];
5
+ styleguides?: string[];
6
+ locales?: string[];
5
7
  }
6
8
  export declare class ConfigNotFoundError extends Error {
7
9
  constructor(cwd: string);
package/dist/config.js CHANGED
@@ -54,6 +54,20 @@ function validate(c, source) {
54
54
  throw new Error(`${source}: every entry in "roots" must be a string.`);
55
55
  }
56
56
  }
57
+ if (obj.styleguides !== undefined) {
58
+ if (!Array.isArray(obj.styleguides))
59
+ throw new Error(`${source}: "styleguides" must be an array of strings if present.`);
60
+ if (obj.styleguides.some((s) => typeof s !== "string")) {
61
+ throw new Error(`${source}: every entry in "styleguides" must be a string.`);
62
+ }
63
+ }
64
+ if (obj.locales !== undefined) {
65
+ if (!Array.isArray(obj.locales))
66
+ throw new Error(`${source}: "locales" must be an array of strings if present.`);
67
+ if (obj.locales.some((l) => typeof l !== "string")) {
68
+ throw new Error(`${source}: every entry in "locales" must be a string.`);
69
+ }
70
+ }
57
71
  }
58
72
  function getApiKey() {
59
73
  try {
@@ -1,4 +1,5 @@
1
1
  export declare function rewriteManagedKeys(filePath: string, updates: {
2
2
  tags?: string[];
3
3
  rules?: unknown[];
4
+ locales?: Record<string, unknown[]>;
4
5
  }): void;
package/dist/serialize.js CHANGED
@@ -14,12 +14,29 @@ function rewriteManagedKeys(filePath, updates) {
14
14
  const spec = js_yaml_1.default.load(frontmatterYaml);
15
15
  if (updates.tags !== undefined) {
16
16
  const savedRules = spec.rules;
17
+ const savedLocales = spec.locales;
17
18
  delete spec.rules;
19
+ delete spec.locales;
18
20
  spec.tags = updates.tags;
19
21
  spec.rules = savedRules;
22
+ if (savedLocales !== undefined)
23
+ spec.locales = savedLocales;
20
24
  }
21
- if (updates.rules !== undefined)
25
+ if (updates.rules !== undefined) {
26
+ const savedLocales = spec.locales;
27
+ delete spec.locales;
22
28
  spec.rules = updates.rules;
29
+ if (savedLocales !== undefined)
30
+ spec.locales = savedLocales;
31
+ }
32
+ if (updates.locales !== undefined) {
33
+ if (Object.keys(updates.locales).length > 0) {
34
+ spec.locales = updates.locales;
35
+ }
36
+ else {
37
+ delete spec.locales;
38
+ }
39
+ }
23
40
  let dumped = js_yaml_1.default
24
41
  .dump(spec, {
25
42
  lineWidth: -1,
@@ -0,0 +1 @@
1
+ export declare const SPEC_COMPONENT_SKILL = "---\ndescription: Analyze a component, create ditto specs for it and its dependencies, identify rule gaps, and audit copy\nargument-hint: <ComponentName or path/to/component>\nallowed-tools: [Read, Bash, Edit, Write, Grep, Glob, Agent]\n---\n\n# Spec Component\n\nAnalyze a design system component, scaffold `.ditto.md` content specs (including child components that lack specs), auto-fill surfaces and tags, identify style guide rule gaps from real code usage, create new rules on the platform, and audit copy compliance.\n\n## Input\n\n$ARGUMENTS\n\nAccept either a component name (e.g. `Button`) or a file path (e.g. `src/components/Button/index.tsx`). If a name is given, search the codebase to find the component. If multiple matches exist, ask the user to clarify.\n\n---\n\n## Phase 1: Discovery\n\nFind the target component and map its dependencies.\n\n1. Locate the component source file.\n2. Read the component code. Walk imports to find **child components that render user-facing text** (components that accept string props, children rendered as text, or contain hardcoded strings). Skip utility imports, style imports, icon imports, and non-text components.\n3. Check which components already have an `index.ditto.md` spec by looking for the file in each component's directory.\n4. Read `workspace.ditto.md` at the project root (if it exists) for existing platform tags and universal rules.\n5. Report:\n - Target component and its location\n - Child components needing specs (no existing `index.ditto.md`)\n - Existing specs found (already covered)\n\n---\n\n## Phase 2: Surface Analysis\n\nFor each component that needs a new spec (target + unspecced children):\n\n1. Read the TypeScript interface / props type definition.\n2. Identify every **text surface** \u2014 each piece of user-facing copy the component renders:\n - **String props** rendered in JSX \u2192 use the prop name as the surface key\n - **`children`** when used as text \u2192 use `$children` as the surface key\n - **Nested props** \u2192 use dot notation (e.g. `primaryAction.label`)\n - **Hardcoded strings** in JSX \u2192 use a descriptive role name (e.g. `headline`, `submitLabel`, `bodyText`)\n3. For each surface, suggest **tags** from the `workspace.ditto.md` tag inventory, matched by semantic role (e.g. `heading`, `body`, `button`, `call-to-action`). Prefer reusing existing tags. Only propose a new tag if nothing fits \u2014 and note it will be new.\n4. Estimate **`maxLength`** where layout context provides constraints (e.g. single-line headings, button widths). Omit if no clear constraint.\n\nPresent the proposed spec for each component in exact YAML format:\n\n```yaml\n---\ncomponent: ComponentName\ntags: [relevant-tags]\nsurfaces:\n surfaceKey:\n tags: [semantic-tags]\n maxLength: 60\n# Managed by Ditto \u2014 do not edit below\nrules: []\n---\n```\n\n**STOP HERE. Ask the user to review and approve the proposed surfaces before creating any files.** Let them add, remove, or modify surfaces and tags.\n\n---\n\n## Phase 3: Create Specs\n\nAfter user approval:\n\n1. For each component, run:\n ```\n npx ditto-spec scaffold <ComponentName> --path <component-directory>\n ```\n2. Edit each created `index.ditto.md` to replace the empty `surfaces: {}` and `tags: []` with the approved values. Only edit developer-owned keys (`component`, `tags`, `surfaces`). Never write `rules` by hand.\n3. Run `npx ditto-spec pull` to populate the `rules` section from the platform.\n4. Run `npx ditto-spec check` to validate all spec files.\n\nReport which specs were created and how many rules were pulled.\n\n---\n\n## Phase 4: Rule Gap Analysis\n\nIdentify copy patterns that should be rules but aren't.\n\n1. Read all rules now in the spec files (workspace-level from `workspace.ditto.md` + component-level and per-surface from each `index.ditto.md`).\n2. Search the codebase for **instances where the target component is used** \u2014 find all files that import and render it.\n3. For each usage instance, read the **actual copy** being passed to the component's text surfaces (string literals, template strings, variables with obvious values).\n4. Analyze the copy across instances for patterns no existing rule covers:\n - Inconsistent terminology across instances\n - Tone mismatches (e.g. some formal, some casual)\n - Anti-patterns: passive voice in CTAs, overly long descriptions, redundant words\n - Surface-specific conventions that should be formalized (e.g. \"all action buttons start with a verb\")\n5. If **no meaningful gaps** are found, say so and skip to Phase 6.\n6. If gaps are found, propose new rules. For each:\n - **name**: short rule name\n - **description**: what the rule enforces\n - **tags**: which tags it should apply to (determines which surfaces it matches)\n - **examples**: `{from, to}` pairs drawn from actual code showing the pattern\n\nIf any proposed tags don't exist in the workspace yet, warn the user. Currently, only existing workspace tags can be assigned to rules. New tags may need to be created on the platform first.\n\n**STOP HERE. Present proposals and ask the user which rules (if any) to create.**\n\n---\n\n## Phase 5: Create Rules and Re-Pull\n\nFor each approved rule:\n\n1. Run:\n ```\n npx ditto-spec create-rule --name \"<rule name>\" --description \"<description>\" --tags \"<tag1,tag2>\" --examples '<json array of {from,to}>'\n ```\n If a rule uses tags that don't exist yet, warn the user and omit those tags from the command (the API rejects unknown tags).\n\n2. After all rules are created, run `npx ditto-spec pull` to sync the new rules into spec files.\n\n3. Verify the specs now contain the expected rules by reading the updated `index.ditto.md` files.\n\n---\n\n## Phase 6: Copy Audit\n\nEvaluate all component instances against the full rule set.\n\n1. Re-read the fully-populated spec files (workspace + component + surface rules).\n2. Find all instances of the component in the codebase.\n3. For each instance, evaluate the actual copy against every applicable rule:\n - Workspace rules apply to all surfaces\n - Component-level rules (no `surface` field) apply to all surfaces\n - Per-surface rules (with `surface` field) apply only to that surface\n - Respect `maxLength` constraints \u2014 flag any text that exceeds the limit\n4. Report violations:\n - File and line where the instance appears\n - The text that violates the rule\n - Which rule is violated\n - Suggested corrected copy\n\n---\n\n## Reference: Ditto Spec Conventions\n\nThese conventions are essential for creating correct specs:\n\n- **Surface keys**: prop name for props, `$children` for children text, dot-notation for nested props (`primaryAction.label`), descriptive role for hardcoded strings (`headline`, `bodyText`).\n- **Tags**: lowercase, hyphenated. Match semantic role (e.g. `heading`, `body`, `button`, `call-to-action`, `dialog-title`). Check `workspace.ditto.md` `tags` key for the full inventory.\n- **Developer-owned keys**: `component`, `tags`, `surfaces` \u2014 edit freely.\n- **CLI-managed keys**: `rules` (and workspace `tags`) \u2014 **never write by hand**. Populated by `ditto-spec pull`.\n- **Three-level rule hierarchy**: workspace rules (apply everywhere) \u2192 component-level rules (no `surface` field, apply to all surfaces in that component) \u2192 per-surface rules (with `surface: \"<key>\"`, apply only to that surface).\n- **Parent-child layering**: if a parent component passes text to a child (e.g. a label to a Button), the parent declares a surface in its own spec. Both parent and child rules apply \u2014 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.\n- **Tag matching**: rules match specs by tag intersection. Any shared tag triggers the match. If a rule matches both component-level and surface-level tags, it emits at component level (broader scope wins).\n";
@@ -0,0 +1,156 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SPEC_COMPONENT_SKILL = void 0;
4
+ exports.SPEC_COMPONENT_SKILL = `---
5
+ description: Analyze a component, create ditto specs for it and its dependencies, identify rule gaps, and audit copy
6
+ argument-hint: <ComponentName or path/to/component>
7
+ allowed-tools: [Read, Bash, Edit, Write, Grep, Glob, Agent]
8
+ ---
9
+
10
+ # Spec Component
11
+
12
+ Analyze a design system component, scaffold \`.ditto.md\` content specs (including child components that lack specs), auto-fill surfaces and tags, identify style guide rule gaps from real code usage, create new rules on the platform, and audit copy compliance.
13
+
14
+ ## Input
15
+
16
+ $ARGUMENTS
17
+
18
+ Accept either a component name (e.g. \`Button\`) or a file path (e.g. \`src/components/Button/index.tsx\`). If a name is given, search the codebase to find the component. If multiple matches exist, ask the user to clarify.
19
+
20
+ ---
21
+
22
+ ## Phase 1: Discovery
23
+
24
+ Find the target component and map its dependencies.
25
+
26
+ 1. Locate the component source file.
27
+ 2. Read the component code. Walk imports to find **child components that render user-facing text** (components that accept string props, children rendered as text, or contain hardcoded strings). Skip utility imports, style imports, icon imports, and non-text components.
28
+ 3. Check which components already have an \`index.ditto.md\` spec by looking for the file in each component's directory.
29
+ 4. Read \`workspace.ditto.md\` at the project root (if it exists) for existing platform tags and universal rules.
30
+ 5. Report:
31
+ - Target component and its location
32
+ - Child components needing specs (no existing \`index.ditto.md\`)
33
+ - Existing specs found (already covered)
34
+
35
+ ---
36
+
37
+ ## Phase 2: Surface Analysis
38
+
39
+ For each component that needs a new spec (target + unspecced children):
40
+
41
+ 1. Read the TypeScript interface / props type definition.
42
+ 2. Identify every **text surface** — each piece of user-facing copy the component renders:
43
+ - **String props** rendered in JSX → use the prop name as the surface key
44
+ - **\`children\`** when used as text → use \`$children\` as the surface key
45
+ - **Nested props** → use dot notation (e.g. \`primaryAction.label\`)
46
+ - **Hardcoded strings** in JSX → use a descriptive role name (e.g. \`headline\`, \`submitLabel\`, \`bodyText\`)
47
+ 3. For each surface, suggest **tags** from the \`workspace.ditto.md\` tag inventory, matched by semantic role (e.g. \`heading\`, \`body\`, \`button\`, \`call-to-action\`). Prefer reusing existing tags. Only propose a new tag if nothing fits — and note it will be new.
48
+ 4. Estimate **\`maxLength\`** where layout context provides constraints (e.g. single-line headings, button widths). Omit if no clear constraint.
49
+
50
+ Present the proposed spec for each component in exact YAML format:
51
+
52
+ \`\`\`yaml
53
+ ---
54
+ component: ComponentName
55
+ tags: [relevant-tags]
56
+ surfaces:
57
+ surfaceKey:
58
+ tags: [semantic-tags]
59
+ maxLength: 60
60
+ # Managed by Ditto — do not edit below
61
+ rules: []
62
+ ---
63
+ \`\`\`
64
+
65
+ **STOP HERE. Ask the user to review and approve the proposed surfaces before creating any files.** Let them add, remove, or modify surfaces and tags.
66
+
67
+ ---
68
+
69
+ ## Phase 3: Create Specs
70
+
71
+ After user approval:
72
+
73
+ 1. For each component, run:
74
+ \`\`\`
75
+ npx ditto-spec scaffold <ComponentName> --path <component-directory>
76
+ \`\`\`
77
+ 2. Edit each created \`index.ditto.md\` to replace the empty \`surfaces: {}\` and \`tags: []\` with the approved values. Only edit developer-owned keys (\`component\`, \`tags\`, \`surfaces\`). Never write \`rules\` by hand.
78
+ 3. Run \`npx ditto-spec pull\` to populate the \`rules\` section from the platform.
79
+ 4. Run \`npx ditto-spec check\` to validate all spec files.
80
+
81
+ Report which specs were created and how many rules were pulled.
82
+
83
+ ---
84
+
85
+ ## Phase 4: Rule Gap Analysis
86
+
87
+ Identify copy patterns that should be rules but aren't.
88
+
89
+ 1. Read all rules now in the spec files (workspace-level from \`workspace.ditto.md\` + component-level and per-surface from each \`index.ditto.md\`).
90
+ 2. Search the codebase for **instances where the target component is used** — find all files that import and render it.
91
+ 3. For each usage instance, read the **actual copy** being passed to the component's text surfaces (string literals, template strings, variables with obvious values).
92
+ 4. Analyze the copy across instances for patterns no existing rule covers:
93
+ - Inconsistent terminology across instances
94
+ - Tone mismatches (e.g. some formal, some casual)
95
+ - Anti-patterns: passive voice in CTAs, overly long descriptions, redundant words
96
+ - Surface-specific conventions that should be formalized (e.g. "all action buttons start with a verb")
97
+ 5. If **no meaningful gaps** are found, say so and skip to Phase 6.
98
+ 6. If gaps are found, propose new rules. For each:
99
+ - **name**: short rule name
100
+ - **description**: what the rule enforces
101
+ - **tags**: which tags it should apply to (determines which surfaces it matches)
102
+ - **examples**: \`{from, to}\` pairs drawn from actual code showing the pattern
103
+
104
+ If any proposed tags don't exist in the workspace yet, warn the user. Currently, only existing workspace tags can be assigned to rules. New tags may need to be created on the platform first.
105
+
106
+ **STOP HERE. Present proposals and ask the user which rules (if any) to create.**
107
+
108
+ ---
109
+
110
+ ## Phase 5: Create Rules and Re-Pull
111
+
112
+ For each approved rule:
113
+
114
+ 1. Run:
115
+ \`\`\`
116
+ npx ditto-spec create-rule --name "<rule name>" --description "<description>" --tags "<tag1,tag2>" --examples '<json array of {from,to}>'
117
+ \`\`\`
118
+ If a rule uses tags that don't exist yet, warn the user and omit those tags from the command (the API rejects unknown tags).
119
+
120
+ 2. After all rules are created, run \`npx ditto-spec pull\` to sync the new rules into spec files.
121
+
122
+ 3. Verify the specs now contain the expected rules by reading the updated \`index.ditto.md\` files.
123
+
124
+ ---
125
+
126
+ ## Phase 6: Copy Audit
127
+
128
+ Evaluate all component instances against the full rule set.
129
+
130
+ 1. Re-read the fully-populated spec files (workspace + component + surface rules).
131
+ 2. Find all instances of the component in the codebase.
132
+ 3. For each instance, evaluate the actual copy against every applicable rule:
133
+ - Workspace rules apply to all surfaces
134
+ - Component-level rules (no \`surface\` field) apply to all surfaces
135
+ - Per-surface rules (with \`surface\` field) apply only to that surface
136
+ - Respect \`maxLength\` constraints — flag any text that exceeds the limit
137
+ 4. Report violations:
138
+ - File and line where the instance appears
139
+ - The text that violates the rule
140
+ - Which rule is violated
141
+ - Suggested corrected copy
142
+
143
+ ---
144
+
145
+ ## Reference: Ditto Spec Conventions
146
+
147
+ These conventions are essential for creating correct specs:
148
+
149
+ - **Surface keys**: prop name for props, \`$children\` for children text, dot-notation for nested props (\`primaryAction.label\`), descriptive role for hardcoded strings (\`headline\`, \`bodyText\`).
150
+ - **Tags**: lowercase, hyphenated. Match semantic role (e.g. \`heading\`, \`body\`, \`button\`, \`call-to-action\`, \`dialog-title\`). Check \`workspace.ditto.md\` \`tags\` key for the full inventory.
151
+ - **Developer-owned keys**: \`component\`, \`tags\`, \`surfaces\` — edit freely.
152
+ - **CLI-managed keys**: \`rules\` (and workspace \`tags\`) — **never write by hand**. Populated by \`ditto-spec pull\`.
153
+ - **Three-level rule hierarchy**: workspace rules (apply everywhere) → component-level rules (no \`surface\` field, apply to all surfaces in that component) → per-surface rules (with \`surface: "<key>"\`, apply only to that surface).
154
+ - **Parent-child layering**: if a parent component passes text to a child (e.g. a label to a Button), the parent declares a surface in its own spec. Both parent and child rules apply — 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.
155
+ - **Tag matching**: rules match specs by tag intersection. Any shared tag triggers the match. If a rule matches both component-level and surface-level tags, it emits at component level (broader scope wins).
156
+ `;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dittowords/spec-cli",
3
- "version": "0.0.1-alpha.6",
3
+ "version": "0.0.1-alpha.8",
4
4
  "description": "CLI for syncing .ditto.md content specs with the Ditto platform.",
5
5
  "main": "dist/cli.js",
6
6
  "bin": {