@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 +27 -5
- package/dist/api.d.ts +2 -0
- package/dist/api.js +3 -1
- package/dist/commands/init.js +1 -0
- package/dist/commands/pull.js +62 -28
- 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
|
@@ -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
|
-
###
|
|
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.
|
|
119
|
-
5.
|
|
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,
|
package/dist/commands/init.js
CHANGED
|
@@ -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
|
|
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,7 +151,8 @@ 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
|
}
|
|
@@ -127,9 +164,6 @@ function toRuleObj(rule) {
|
|
|
127
164
|
function toSurfaceRuleObj(surface, rule) {
|
|
128
165
|
return { surface, ...toRuleObj(rule) };
|
|
129
166
|
}
|
|
130
|
-
function
|
|
131
|
-
return JSON.stringify(existing ??
|
|
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
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,
|