@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 +25 -9
- package/dist/api.d.ts +2 -0
- package/dist/api.js +3 -1
- package/dist/commands/init.js +2 -1
- package/dist/commands/pull.js +62 -29
- package/dist/config.d.ts +1 -0
- package/dist/config.js +7 -0
- package/dist/serialize.d.ts +1 -0
- package/dist/serialize.js +18 -1
- package/package.json +1 -1
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
|
-
###
|
|
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.
|
|
119
|
-
5.
|
|
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
|
|
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,
|
package/dist/commands/init.js
CHANGED
|
@@ -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("
|
|
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
|
|
package/dist/commands/pull.js
CHANGED
|
@@ -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
|
-
|
|
50
|
-
const
|
|
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
|
|
56
|
-
|
|
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 ${
|
|
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:
|
|
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)} — ${
|
|
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
|
|
73
|
-
const
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
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 (
|
|
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 ${
|
|
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:
|
|
103
|
+
(0, serialize_1.rewriteManagedKeys)(file.abs, { rules: matchedBaseRules, locales: matchedLocales });
|
|
93
104
|
written++;
|
|
94
|
-
console.log(`✓ ${path_1.default.relative(root, file.abs)} — ${
|
|
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
|
-
|
|
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
|
|
131
|
-
return JSON.stringify(existing ??
|
|
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
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 {
|
package/dist/serialize.d.ts
CHANGED
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,
|