@dittowords/spec-cli 0.0.1-alpha.2 → 0.0.1-alpha.3

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
@@ -4,7 +4,7 @@
4
4
 
5
5
  CLI for syncing `.ditto.md` content specs with the Ditto platform.
6
6
 
7
- A `.ditto.md` file lives next to a component and declares its **text surfaces** — the props (and `children`) that hold user-facing copy. The file is pure metadata; nothing imports it at runtime. It exists for three consumers:
7
+ A `.ditto.md` file lives next to a component and declares its **text surfaces** — every piece of user-facing copy the component renders, whether passed as props, `children`, or hardcoded in the component itself. The file is pure metadata; nothing imports it at runtime. It exists for three consumers:
8
8
 
9
9
  - **Agents** read it as fast-path context when writing or editing copy for the component.
10
10
  - **The CLI** (`ditto-spec pull`) syncs style guide rules from the platform whose tags match the file's surface tags.
@@ -52,7 +52,7 @@ examples: []
52
52
 
53
53
  ### Workspace spec (`workspace.ditto.md`)
54
54
 
55
- A repo may have a single `workspace.ditto.md` somewhere under the CLI's configured `roots`. It holds universal rules from the workspace style guide that carry no tags — these apply to every surface in every component.
55
+ A repo may have a single `workspace.ditto.md` somewhere under the CLI's configured `roots`. It holds universal rules from the workspace style guide that carry no tags — these apply to every surface in every component. It also carries an inventory of all tags available on the platform, populated by `ditto-spec pull`.
56
56
 
57
57
  ```yaml
58
58
  ---
@@ -60,6 +60,7 @@ workspace: true
60
60
  description: >
61
61
  Workspace-wide content rules. Read alongside any component's index.ditto.md.
62
62
  # Managed by Ditto — do not edit below
63
+ tags: [body, button, call-to-action, dialog-title, heading, nav]
63
64
  rules: []
64
65
  ---
65
66
  ```
@@ -68,9 +69,9 @@ rules: []
68
69
 
69
70
  **Developer-owned keys**: `component`, `description`, `tags`, `surfaces`. Edit these freely.
70
71
 
71
- **CLI-managed keys**: `rules`, `examples`. Overwritten by `ditto-spec pull`. Do not edit by hand.
72
+ **CLI-managed keys**: `rules`, `examples`, and workspace `tags`. Overwritten by `ditto-spec pull`. Do not edit by hand.
72
73
 
73
- **Surface keys** are prop paths on the component. Dot-notation works for nested props (e.g., `primaryAction.label`). Use `$children` for components whose text comes through the `children` prop.
74
+ **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`).
74
75
 
75
76
  **Component-level `tags`** cause matching rules to apply to every surface in the component (emitted in `rules` with no `surface` field). Per-surface tags cause rules to emit with `surface: "<key>"`. If a rule matches both levels, it emits once at component level (broader scope wins).
76
77
 
@@ -100,7 +101,7 @@ Creates a new `index.ditto.md` for a component with the correct YAML structure a
100
101
  ditto-spec scaffold DialogueModal --path src/components/DialogueModal
101
102
  ```
102
103
 
103
- Use `--path <dir>` to specify where the file is created (defaults to the current directory). After scaffolding, add surfaces for the component's text-bearing props and run `ditto-spec pull` to populate rules.
104
+ Use `--path <dir>` to specify where the file is created (defaults to the current directory). After scaffolding, add a surface for each piece of user-facing text the component renders and run `ditto-spec pull` to populate rules.
104
105
 
105
106
  ### `ditto-spec pull`
106
107
 
@@ -143,23 +144,23 @@ Set `DITTO_API_KEY` in your environment or in a `.env` file at the repo root.
143
144
 
144
145
  ## Agent contract
145
146
 
146
- When writing or editing text props for a component:
147
+ When writing or editing user-facing text for a component:
147
148
 
148
149
  1. Read `workspace.ditto.md` (if it exists) for universal rules.
149
- 2. Read the component's `index.ditto.md`. Match each prop you're filling to a surface key.
150
+ 2. Read the component's `index.ditto.md`. Match each piece of text you're writing to a surface key.
150
151
  3. Respect `maxLength` — it's a layout invariant, not a suggestion.
151
152
  4. Follow all rules in `rules[]`. Entries without `surface` apply to every surface; entries with `surface` apply only to that surface.
152
153
  5. Reference `examples[]` entries with `status: "approved"` as concrete tone/shape guidance.
153
154
 
154
155
  ### Creating specs
155
156
 
156
- When creating a new component with text-bearing props, scaffold a spec file:
157
+ When creating a new component that renders any user-facing text, scaffold a spec file:
157
158
 
158
159
  ```
159
160
  npx ditto-spec scaffold <ComponentName> --path <dir>
160
161
  ```
161
162
 
162
- Then edit the generated `index.ditto.md` to add surfaces — one entry per text-bearing prop:
163
+ Then edit the generated `index.ditto.md` to add surfaces — one entry per piece of user-facing text the component renders:
163
164
 
164
165
  ```yaml
165
166
  surfaces:
@@ -171,8 +172,10 @@ surfaces:
171
172
  maxLength: 30
172
173
  ```
173
174
 
174
- - Use `$children` for text via children. Use dot notation for nested props (`primaryAction.label`).
175
- - Choose `tags` from content categories like `heading`, `body`, `button`, `cta`, `dialog-title`, `call-to-action`.
175
+ - Use `$children` for text via children. Use dot notation for nested props (`primaryAction.label`). For hardcoded or internal strings, use a descriptive role name (`headline`, `bodyText`, `submitLabel`).
176
+ - Check the `tags` key in `workspace.ditto.md` for tags available on the platform. Prefer reusing an existing tag over creating a new one — only a tag that exists on the platform will match rules. If no existing tag fits, create a new one following the convention of existing tags (lowercase, hyphenated).
176
177
  - **Never write `rules` or `examples` by hand.** Run `ditto-spec pull` after adding surfaces to populate rules from the platform.
177
178
 
179
+ Parent and child specs both contribute rules. If your component passes a label to a child Button, add a surface in the parent's spec — 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.
180
+
178
181
  If a component lacks a spec and you'd have found one useful, propose creating one. If a spec lacks a rule you'd have wanted, propose adding the rule to the platform style guide with appropriate tags.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dittowords/spec-cli",
3
- "version": "0.0.1-alpha.2",
3
+ "version": "0.0.1-alpha.3",
4
4
  "description": "CLI for syncing .ditto.md content specs with the Ditto platform.",
5
5
  "main": "src/cli.ts",
6
6
  "bin": {
@@ -19,6 +19,7 @@ workspace: true
19
19
  description: >
20
20
  Workspace-wide content rules. Read alongside any component's index.ditto.md.
21
21
  # Managed by Ditto — do not edit below
22
+ tags: []
22
23
  rules: []
23
24
  ---
24
25
  `;
@@ -39,11 +40,11 @@ When writing or editing text props for a component:
39
40
 
40
41
  ### Creating specs
41
42
 
42
- When creating a new component with text-bearing props, scaffold a spec file:
43
+ When creating a new component that renders any user-facing text, scaffold a spec file:
43
44
 
44
45
  npx ditto-spec scaffold <ComponentName> --path <dir>
45
46
 
46
- Then edit the generated \`index.ditto.md\` to add surfaces — one entry per text-bearing prop:
47
+ Then edit the generated \`index.ditto.md\` to add surfaces — one entry per piece of user-facing text the component renders:
47
48
 
48
49
  surfaces:
49
50
  title:
@@ -53,10 +54,12 @@ Then edit the generated \`index.ditto.md\` to add surfaces — one entry per tex
53
54
  tags: [button, cta]
54
55
  maxLength: 30
55
56
 
56
- - Use \`$children\` for text via children. Use dot notation for nested props (\`primaryAction.label\`).
57
- - Choose \`tags\` from content categories like \`heading\`, \`body\`, \`button\`, \`cta\`, \`dialog-title\`, \`call-to-action\`.
57
+ - Use \`$children\` for text via children. Use dot notation for nested props (\`primaryAction.label\`). For hardcoded or internal strings, use a descriptive role name (\`headline\`, \`bodyText\`, \`submitLabel\`).
58
+ - Check the \`tags\` key in \`workspace.ditto.md\` for tags available on the platform. Prefer reusing an existing tag over creating a new one — only a tag that exists on the platform will match rules. If no existing tag fits, create a new one following the convention of existing tags (lowercase, hyphenated).
58
59
  - **Never write \`rules\` or \`examples\` by hand.** Run \`npx ditto-spec pull\` after adding surfaces to populate rules from the platform.
59
60
 
61
+ Parent and child specs both contribute rules. If your component passes a label to a child Button, add a surface in the parent's spec — 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.
62
+
60
63
  Run \`npx ditto-spec list\` to see all existing specs.
61
64
  `;
62
65
 
@@ -76,11 +79,11 @@ When writing or editing text props for a component:
76
79
 
77
80
  ## Creating specs
78
81
 
79
- When creating a new component with text-bearing props, scaffold a spec file:
82
+ When creating a new component that renders any user-facing text, scaffold a spec file:
80
83
 
81
84
  npx ditto-spec scaffold <ComponentName> --path <dir>
82
85
 
83
- Then edit the generated index.ditto.md to add surfaces — one entry per text-bearing prop:
86
+ Then edit the generated index.ditto.md to add surfaces — one entry per piece of user-facing text the component renders:
84
87
 
85
88
  surfaces:
86
89
  title:
@@ -90,10 +93,12 @@ Then edit the generated index.ditto.md to add surfaces — one entry per text-be
90
93
  tags: [button, cta]
91
94
  maxLength: 30
92
95
 
93
- - Use $children for text via children. Use dot notation for nested props (primaryAction.label).
94
- - Choose tags from content categories like heading, body, button, cta, dialog-title, call-to-action.
96
+ - Use $children for text via children. Use dot notation for nested props (primaryAction.label). For hardcoded or internal strings, use a descriptive role name (headline, bodyText, submitLabel).
97
+ - Check the tags key in workspace.ditto.md for tags available on the platform. Prefer reusing an existing tag over creating a new one — only a tag that exists on the platform will match rules. If no existing tag fits, create a new one following the convention of existing tags (lowercase, hyphenated).
95
98
  - NEVER write rules or examples by hand. Run npx ditto-spec pull after adding surfaces to populate rules from the platform.
96
99
 
100
+ Parent and child specs both contribute rules. If your component passes a label to a child Button, add a surface in the parent's spec — 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.
101
+
97
102
  Run npx ditto-spec list to see all existing specs.
98
103
  `;
99
104
 
@@ -59,6 +59,8 @@ export async function pull(opts: PullOptions = {}): Promise<void> {
59
59
  const allRules = await api.getRules();
60
60
  console.log(`Fetched ${allRules.length} rule${allRules.length === 1 ? "" : "s"} from workspace.`);
61
61
 
62
+ const platformTags = [...new Set(allRules.flatMap((r) => r.tags))].filter(Boolean).sort();
63
+
62
64
  let written = 0;
63
65
  let unchanged = 0;
64
66
 
@@ -66,14 +68,20 @@ export async function pull(opts: PullOptions = {}): Promise<void> {
66
68
  const { file, parsed } = workspaceFiles[0];
67
69
  const universalRules = allRules.filter((r) => r.tags.length === 0).map(toRuleObj);
68
70
 
69
- if (rulesMatch(parsed.spec.rules, universalRules)) {
71
+ if (rulesMatch(parsed.spec.rules, universalRules) && tagsMatch(parsed.spec.tags, platformTags)) {
70
72
  unchanged++;
71
73
  } else if (opts.dryRun) {
72
- console.log(`~ ${path.relative(root, file.abs)} (would write ${universalRules.length} workspace rule(s))`);
74
+ console.log(
75
+ `~ ${path.relative(root, file.abs)} (would write ${universalRules.length} workspace rule(s), ${
76
+ platformTags.length
77
+ } tag(s))`
78
+ );
73
79
  } else {
74
- rewriteManagedKeys(file.abs, { rules: universalRules });
80
+ rewriteManagedKeys(file.abs, { tags: platformTags, rules: universalRules });
75
81
  written++;
76
- console.log(`✓ ${path.relative(root, file.abs)} — ${universalRules.length} workspace rule(s)`);
82
+ console.log(
83
+ `✓ ${path.relative(root, file.abs)} — ${universalRules.length} workspace rule(s), ${platformTags.length} tag(s)`
84
+ );
77
85
  }
78
86
  }
79
87
 
@@ -138,3 +146,7 @@ function toSurfaceRuleObj(surface: string, rule: RuleResponse): Record<string, u
138
146
  function rulesMatch(existing: unknown, incoming: unknown): boolean {
139
147
  return JSON.stringify(existing ?? []) === JSON.stringify(incoming);
140
148
  }
149
+
150
+ function tagsMatch(existing: unknown, incoming: string[]): boolean {
151
+ return JSON.stringify(existing ?? []) === JSON.stringify(incoming);
152
+ }
@@ -34,7 +34,7 @@ examples: []
34
34
 
35
35
  console.log(`
36
36
  Next steps:
37
- 1. Add text-bearing props as surface keys under 'surfaces'
37
+ 1. Add a surface key for each piece of user-facing text the component renders
38
38
  2. Tag each surface with content categories (heading, body, button, cta, etc.)
39
39
  3. Run \`ditto-spec pull\` to populate rules from the platform`);
40
40
  }
package/src/serialize.ts CHANGED
@@ -3,13 +3,19 @@ import yaml from "js-yaml";
3
3
 
4
4
  const MANAGED_COMMENT = "# Managed by Ditto — do not edit below";
5
5
 
6
- export function rewriteManagedKeys(filePath: string, updates: { rules?: unknown[] }): void {
6
+ export function rewriteManagedKeys(filePath: string, updates: { tags?: string[]; rules?: unknown[] }): void {
7
7
  const source = fs.readFileSync(filePath, "utf8");
8
8
  const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---/);
9
9
  if (!match) throw new Error(`${filePath}: no YAML frontmatter found`);
10
10
 
11
11
  const spec = yaml.load(match[1]) as Record<string, unknown>;
12
12
 
13
+ if (updates.tags !== undefined) {
14
+ const savedRules = spec.rules;
15
+ delete spec.rules;
16
+ spec.tags = updates.tags;
17
+ spec.rules = savedRules;
18
+ }
13
19
  if (updates.rules !== undefined) spec.rules = updates.rules;
14
20
 
15
21
  let dumped = yaml
@@ -21,18 +27,19 @@ export function rewriteManagedKeys(filePath: string, updates: { rules?: unknown[
21
27
  })
22
28
  .trimEnd();
23
29
 
24
- dumped = insertManagedComment(dumped);
30
+ dumped = insertManagedComment(dumped, updates.tags !== undefined);
25
31
 
26
32
  const afterFrontmatter = source.slice(match[0].length);
27
33
  fs.writeFileSync(filePath, `---\n${dumped}\n---${afterFrontmatter}`);
28
34
  }
29
35
 
30
- function insertManagedComment(dumped: string): string {
31
- const rulesIdx = dumped.search(/^rules:/m);
32
- if (rulesIdx === -1) return dumped;
36
+ function insertManagedComment(dumped: string, managedTagsPresent: boolean): string {
37
+ const targetKey = managedTagsPresent ? "tags" : "rules";
38
+ const idx = dumped.search(new RegExp(`^${targetKey}:`, "m"));
39
+ if (idx === -1) return dumped;
33
40
 
34
- const before = dumped.slice(0, rulesIdx);
41
+ const before = dumped.slice(0, idx);
35
42
  if (before.includes(MANAGED_COMMENT)) return dumped;
36
43
 
37
- return before + MANAGED_COMMENT + "\n" + dumped.slice(rulesIdx);
44
+ return before + MANAGED_COMMENT + "\n" + dumped.slice(idx);
38
45
  }