@dittowords/spec-cli 0.0.1-alpha.7 → 0.0.1-alpha.9

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,7 +37,6 @@ 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
40
  section: Voice & Tone
42
41
  - surface: actionText
43
42
  name: Calls to action should use active voice
@@ -45,15 +44,18 @@ rules:
45
44
  examples:
46
45
  - from: "Your settings"
47
46
  to: "Open settings"
48
- styleguide: Acme Inc.
49
47
  section: Voice & Tone
50
48
  - term: sign up
51
49
  disallowed:
52
50
  - signup
53
51
  - sign-up
54
52
  description: Always use as two words (verb form)
55
- styleguide: Acme Inc.
56
53
  section: Terminology
54
+ locales:
55
+ de-DE:
56
+ - name: Use informal address
57
+ description: Use "Du" instead of "Sie" for all user-facing copy
58
+ section: Formality
57
59
  ---
58
60
  ```
59
61
 
@@ -66,7 +68,15 @@ A repo may have a single `workspace.ditto.md` somewhere under the CLI's configur
66
68
  workspace: true
67
69
  # Managed by Ditto — do not edit below
68
70
  tags: [body, button, call-to-action, dialog-title, heading, nav]
69
- rules: []
71
+ rules:
72
+ - name: Write in active voice
73
+ description: Lead with verbs, avoid passive constructions
74
+ section: Voice & Tone
75
+ locales:
76
+ de-DE:
77
+ - name: Use informal address
78
+ description: Use "Du" instead of "Sie" for all user-facing copy
79
+ section: Formality
70
80
  ---
71
81
  ```
72
82
 
@@ -74,7 +84,7 @@ rules: []
74
84
 
75
85
  **Developer-owned keys**: `component`, `tags`, `surfaces`. Edit these freely.
76
86
 
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`).
87
+ **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
88
 
79
89
  **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
90
 
@@ -82,13 +92,16 @@ rules: []
82
92
 
83
93
  **`maxLength` / `minLength`** are hard layout constraints, not stylistic preferences. Stylistic guidance belongs on the platform as rules.
84
94
 
85
- ### Three-level rule hierarchy
95
+ ### Rule hierarchy
86
96
 
87
97
  | Scope | Where | Applies to |
88
98
  |---|---|---|
89
99
  | **Workspace** | `workspace.ditto.md` `rules[]` | Every surface in every component |
90
100
  | **Component-level** | Component's `rules[]`, no `surface` field | Every surface in this component |
91
101
  | **Per-surface** | Component's `rules[]`, with `surface: "<key>"` | That one surface |
102
+ | **Locale-scoped** | `locales.<code>[]` (workspace or component) | Same hierarchy as above, but only when writing copy for that locale |
103
+
104
+ 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
105
 
93
106
  ## CLI commands
94
107
 
@@ -115,8 +128,9 @@ Syncs rules from the platform into spec files.
115
128
  1. Discovers all `.ditto.md` files under configured roots
116
129
  2. Fetches style guides from `GET /v2/styleguides`
117
130
  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
131
+ 4. Separates base rules from locale-scoped rules (included when `locales` is configured)
132
+ 5. Matches rules to specs by tag intersection (client-side)
133
+ 6. Rewrites the `rules` and `locales` keys in each file's YAML frontmatter
120
134
 
121
135
  Use `--dry-run` to see what would change without writing.
122
136
 
@@ -146,6 +160,7 @@ Create `dittospec.config.json` at your repo root (or any ancestor directory):
146
160
  | `workspaceId` | Your workspace ID |
147
161
  | `roots` | Repo-relative directories to search for `.ditto.md` files. Defaults to `["."]`. |
148
162
  | `styleguides` | Optional list of style guide names to pull. Defaults to all. |
163
+ | `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
164
 
150
165
  Set `DITTO_API_KEY` in your environment or in a `.env` file at the repo root.
151
166
 
@@ -160,7 +175,8 @@ When writing or editing user-facing text for a component:
160
175
  5. Rules come in two shapes:
161
176
  - **Style rules** have `name`, `description`, and optional `examples` (before/after pairs). Use `examples` as concrete tone/shape guidance.
162
177
  - **Terminology entries** have `term` and `disallowed`. Always use the `term` form; never use any of the `disallowed` alternatives.
163
- 6. Each rule carries `styleguide` and `section` metadata indicating its source on the platform.
178
+ 6. Each rule carries a `section` field (e.g. "Voice & Tone", "Terminology") providing context for how to interpret it.
179
+ 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
180
 
165
181
  ### Creating specs
166
182
 
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,
@@ -43,7 +43,8 @@ When writing or editing text props for a component:
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
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.
46
+ 6. Each rule carries a ${c("section")} field (e.g. "Voice & Tone", "Terminology") providing context for how to interpret it.
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,21 +151,18 @@ 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
  }
122
159
  }
123
- out.styleguide = rule.styleguide;
124
160
  out.section = rule.section;
125
161
  return out;
126
162
  }
127
163
  function toSurfaceRuleObj(surface, rule) {
128
164
  return { surface, ...toRuleObj(rule) };
129
165
  }
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);
166
+ function jsonMatch(existing, incoming) {
167
+ return JSON.stringify(existing ?? undefined) === JSON.stringify(incoming ?? undefined);
135
168
  }
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.9",
4
4
  "description": "CLI for syncing .ditto.md content specs with the Ditto platform.",
5
5
  "main": "dist/cli.js",
6
6
  "bin": {