@dittowords/spec-cli 0.0.1-alpha.7 → 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
@@ -54,6 +54,12 @@ rules:
54
54
  description: Always use as two words (verb form)
55
55
  styleguide: Acme Inc.
56
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
57
63
  ---
58
64
  ```
59
65
 
@@ -66,7 +72,17 @@ A repo may have a single `workspace.ditto.md` somewhere under the CLI's configur
66
72
  workspace: true
67
73
  # Managed by Ditto — do not edit below
68
74
  tags: [body, button, call-to-action, dialog-title, heading, nav]
69
- 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
70
86
  ---
71
87
  ```
72
88
 
@@ -74,7 +90,7 @@ rules: []
74
90
 
75
91
  **Developer-owned keys**: `component`, `tags`, `surfaces`. Edit these freely.
76
92
 
77
- **CLI-managed keys**: `rules` 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`).
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`).
78
94
 
79
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`).
80
96
 
@@ -82,13 +98,16 @@ rules: []
82
98
 
83
99
  **`maxLength` / `minLength`** are hard layout constraints, not stylistic preferences. Stylistic guidance belongs on the platform as rules.
84
100
 
85
- ### Three-level rule hierarchy
101
+ ### Rule hierarchy
86
102
 
87
103
  | Scope | Where | Applies to |
88
104
  |---|---|---|
89
105
  | **Workspace** | `workspace.ditto.md` `rules[]` | Every surface in every component |
90
106
  | **Component-level** | Component's `rules[]`, no `surface` field | Every surface in this component |
91
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.
92
111
 
93
112
  ## CLI commands
94
113
 
@@ -115,8 +134,9 @@ Syncs rules from the platform into spec files.
115
134
  1. Discovers all `.ditto.md` files under configured roots
116
135
  2. Fetches style guides from `GET /v2/styleguides`
117
136
  3. Flattens rules and wordlist entries across all guides (or only those named in `styleguides` config)
118
- 4. Matches rules to specs by tag intersection (client-side)
119
- 5. Rewrites the `rules` key in each file's YAML frontmatter
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
120
140
 
121
141
  Use `--dry-run` to see what would change without writing.
122
142
 
@@ -146,6 +166,7 @@ Create `dittospec.config.json` at your repo root (or any ancestor directory):
146
166
  | `workspaceId` | Your workspace ID |
147
167
  | `roots` | Repo-relative directories to search for `.ditto.md` files. Defaults to `["."]`. |
148
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. |
149
170
 
150
171
  Set `DITTO_API_KEY` in your environment or in a `.env` file at the repo root.
151
172
 
@@ -161,6 +182,7 @@ When writing or editing user-facing text for a component:
161
182
  - **Style rules** have `name`, `description`, and optional `examples` (before/after pairs). Use `examples` as concrete tone/shape guidance.
162
183
  - **Terminology entries** have `term` and `disallowed`. Always use the `term` form; never use any of the `disallowed` alternatives.
163
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.
164
186
 
165
187
  ### Creating specs
166
188
 
package/dist/api.d.ts CHANGED
@@ -34,6 +34,7 @@ export type FlatRule = {
34
34
  kind: "style";
35
35
  styleguide: string;
36
36
  section: string;
37
+ localeCode: string | null;
37
38
  name: string;
38
39
  description: string;
39
40
  examples: {
@@ -45,6 +46,7 @@ export type FlatRule = {
45
46
  kind: "wordlist";
46
47
  styleguide: string;
47
48
  section: string;
49
+ localeCode: string | null;
48
50
  term: string;
49
51
  disallowed: string[];
50
52
  description: string;
package/dist/api.js CHANGED
@@ -3,7 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.DittoApi = void 0;
4
4
  exports.flattenStyleguides = flattenStyleguides;
5
5
  function flattenStyleguides(guides, filter) {
6
- const selected = filter ? guides.filter((g) => filter.includes(g.name)) : guides;
6
+ const selected = filter?.length ? guides.filter((g) => filter.includes(g.name)) : guides;
7
7
  const result = [];
8
8
  for (const guide of selected) {
9
9
  for (const section of guide.sections) {
@@ -13,6 +13,7 @@ function flattenStyleguides(guides, filter) {
13
13
  kind: "style",
14
14
  styleguide: guide.name,
15
15
  section: section.name,
16
+ localeCode: guide.variant?.localeCode ?? null,
16
17
  name: rule.name,
17
18
  description: rule.description,
18
19
  examples: rule.examples,
@@ -26,6 +27,7 @@ function flattenStyleguides(guides, filter) {
26
27
  kind: "wordlist",
27
28
  styleguide: guide.name,
28
29
  section: section.name,
30
+ localeCode: guide.variant?.localeCode ?? null,
29
31
  term: entry.term,
30
32
  disallowed: entry.disallowed,
31
33
  description: entry.description,
@@ -44,6 +44,7 @@ When writing or editing text props for a component:
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
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
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")}.
47
48
 
48
49
  ${h3} Creating specs
49
50
 
@@ -46,52 +46,63 @@ async function pull(opts = {}) {
46
46
  }
47
47
  const guides = await api.getStyleguides();
48
48
  const allRules = (0, api_1.flattenStyleguides)(guides, config.styleguides);
49
- console.log(`Fetched ${allRules.length} rule${allRules.length === 1 ? "" : "s"} from ${guides.length} style guide(s).`);
50
- const platformTags = [...new Set(allRules.flatMap((r) => r.tags))].filter(Boolean).sort();
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();
51
58
  let written = 0;
52
59
  let unchanged = 0;
53
60
  if (workspaceFiles.length === 1) {
54
61
  const { file, parsed } = workspaceFiles[0];
55
- const universalRules = allRules.filter((r) => r.tags.length === 0).map(toRuleObj);
56
- 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)) {
57
72
  unchanged++;
58
73
  }
59
74
  else if (opts.dryRun) {
60
- 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))`);
61
76
  }
62
77
  else {
63
- (0, serialize_1.rewriteManagedKeys)(file.abs, { tags: platformTags, rules: universalRules });
78
+ (0, serialize_1.rewriteManagedKeys)(file.abs, { tags: platformTags, rules: universalBaseRules, locales: universalLocales });
64
79
  written++;
65
- 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)`);
66
81
  }
67
82
  }
68
83
  for (const { file, parsed } of componentFiles) {
69
84
  const specLike = parsed.spec;
70
85
  const componentTags = specLike.tags ?? [];
71
86
  const surfaces = specLike.surfaces ?? {};
72
- const componentMatches = rulesForTags(allRules, componentTags);
73
- const consumed = new Set(componentMatches);
74
- const matchedRules = [];
75
- for (const rule of componentMatches) {
76
- matchedRules.push(toRuleObj(rule));
77
- }
78
- for (const [surfaceKey, surface] of Object.entries(surfaces)) {
79
- const matches = rulesForTags(allRules, surface.tags ?? []).filter((r) => !consumed.has(r));
80
- for (const rule of matches) {
81
- matchedRules.push(toSurfaceRuleObj(surfaceKey, rule));
82
- }
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;
83
93
  }
84
- 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)) {
85
96
  unchanged++;
86
97
  continue;
87
98
  }
88
99
  if (opts.dryRun) {
89
- 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))`);
90
101
  continue;
91
102
  }
92
- (0, serialize_1.rewriteManagedKeys)(file.abs, { rules: matchedRules });
103
+ (0, serialize_1.rewriteManagedKeys)(file.abs, { rules: matchedBaseRules, locales: matchedLocales });
93
104
  written++;
94
- 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)`);
95
106
  }
96
107
  console.log(`\nDone. ${written} updated, ${unchanged} unchanged.`);
97
108
  if (failed > 0) {
@@ -99,6 +110,31 @@ async function pull(opts = {}) {
99
110
  process.exit(1);
100
111
  }
101
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
+ }
102
138
  function rulesForTags(rules, tags) {
103
139
  if (tags.length === 0)
104
140
  return [];
@@ -115,7 +151,8 @@ function toRuleObj(rule) {
115
151
  }
116
152
  else {
117
153
  out.name = rule.name;
118
- out.description = rule.description;
154
+ if (rule.description)
155
+ out.description = rule.description;
119
156
  if (rule.examples.length > 0) {
120
157
  out.examples = rule.examples.map((ex) => ({ from: ex.from, to: ex.to }));
121
158
  }
@@ -127,9 +164,6 @@ function toRuleObj(rule) {
127
164
  function toSurfaceRuleObj(surface, rule) {
128
165
  return { surface, ...toRuleObj(rule) };
129
166
  }
130
- function rulesMatch(existing, incoming) {
131
- return JSON.stringify(existing ?? []) === JSON.stringify(incoming);
132
- }
133
- function tagsMatch(existing, incoming) {
134
- return JSON.stringify(existing ?? []) === JSON.stringify(incoming);
167
+ function jsonMatch(existing, incoming) {
168
+ return JSON.stringify(existing ?? undefined) === JSON.stringify(incoming ?? undefined);
135
169
  }
package/dist/config.d.ts CHANGED
@@ -3,6 +3,7 @@ export interface Config {
3
3
  workspaceId: string;
4
4
  roots?: string[];
5
5
  styleguides?: string[];
6
+ locales?: string[];
6
7
  }
7
8
  export declare class ConfigNotFoundError extends Error {
8
9
  constructor(cwd: string);
package/dist/config.js CHANGED
@@ -61,6 +61,13 @@ function validate(c, source) {
61
61
  throw new Error(`${source}: every entry in "styleguides" must be a string.`);
62
62
  }
63
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
+ }
64
71
  }
65
72
  function getApiKey() {
66
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dittowords/spec-cli",
3
- "version": "0.0.1-alpha.7",
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": {