@dittowords/spec-cli 0.0.1-alpha.0 → 0.0.1-alpha.10

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/src/api.ts DELETED
@@ -1,45 +0,0 @@
1
- import type { Config } from "./config";
2
-
3
- export interface RuleResponse {
4
- name: string;
5
- type: "style" | "wordlist";
6
- description: string;
7
- examples: { from: string; to: string }[];
8
- tags: string[];
9
- }
10
-
11
- export interface GetRulesMCPResponse {
12
- workspaceRules: RuleResponse[];
13
- projectRules: Record<string, RuleResponse[]>;
14
- }
15
-
16
- export class DittoApi {
17
- constructor(private readonly config: Config, private readonly apiKey: string) {}
18
-
19
- async getRules(): Promise<RuleResponse[]> {
20
- const url = new URL("/v2/rules/mcp", this.config.apiBase);
21
- const res = await this.fetch(url, { method: "GET" });
22
- const data = (await res.json()) as GetRulesMCPResponse;
23
- return data.workspaceRules;
24
- }
25
-
26
- private async fetch(url: URL, init: RequestInit): Promise<Response> {
27
- const res = await fetch(url.toString(), {
28
- ...init,
29
- headers: { ...this.headers(), ...(init.headers ?? {}) },
30
- });
31
- if (!res.ok) {
32
- const body = await res.text();
33
- throw new Error(`${init.method} ${url} → ${res.status}: ${body}`);
34
- }
35
- return res;
36
- }
37
-
38
- private headers(): Record<string, string> {
39
- return {
40
- authorization: this.apiKey,
41
- workspace_id: this.config.workspaceId,
42
- "content-type": "application/json",
43
- };
44
- }
45
- }
package/src/bin.js DELETED
@@ -1,3 +0,0 @@
1
- #!/usr/bin/env node
2
- require("tsx/cjs");
3
- require("./cli.ts");
package/src/cli.ts DELETED
@@ -1,57 +0,0 @@
1
- import { check } from "./commands/check";
2
- import { init } from "./commands/init";
3
- import { list } from "./commands/list";
4
- import { pull } from "./commands/pull";
5
-
6
- const COMMANDS: Record<string, (args: string[]) => Promise<void>> = {
7
- init: async (args) => init({ writeAgent: args.includes("--agent") }),
8
- pull: async (args) => pull({ dryRun: args.includes("--dry-run") }),
9
- check: async () => check(),
10
- list: async () => list(),
11
- };
12
-
13
- const HELP = `ditto-spec — sync .ditto.md content specs with the Ditto platform.
14
-
15
- Usage:
16
- ditto-spec <command> [options]
17
-
18
- Commands:
19
- init Set up ditto specs: creates config, workspace spec, and prints agent setup.
20
- init --agent Also writes agent configuration (CLAUDE.md, .cursorrules, etc.).
21
- pull Pull rules from the platform into the managed keys of each spec.
22
- pull --dry-run Show which files would change without writing.
23
- check Parse every spec file; exit non-zero on any malformed file.
24
- list Print every component spec with its surfaces and tags.
25
-
26
- Environment:
27
- DITTO_API_KEY Workspace API key (required for pull).
28
-
29
- Config:
30
- Reads dittospec.config.json from the nearest ancestor directory:
31
- { "apiBase": "https://...", "workspaceId": "...", "roots": ["design-system"] }
32
- `;
33
-
34
- async function main() {
35
- const args = process.argv.slice(2);
36
- const cmd = args[0];
37
-
38
- if (!cmd || cmd === "--help" || cmd === "-h") {
39
- process.stdout.write(HELP);
40
- return;
41
- }
42
-
43
- const handler = COMMANDS[cmd];
44
- if (!handler) {
45
- process.stderr.write(`Unknown command: ${cmd}\n\n${HELP}`);
46
- process.exit(2);
47
- }
48
-
49
- try {
50
- await handler(args.slice(1));
51
- } catch (err) {
52
- console.error(err instanceof Error ? err.message : err);
53
- process.exit(1);
54
- }
55
- }
56
-
57
- main();
@@ -1,48 +0,0 @@
1
- import path from "path";
2
- import { loadConfig } from "../config";
3
- import { discover } from "../discover";
4
- import { parseSpecFile } from "../parse";
5
-
6
- export async function check(): Promise<void> {
7
- const { config, root } = loadConfig();
8
- const files = discover(root, config.roots);
9
-
10
- if (files.length === 0) {
11
- console.log("No .ditto.md files found.");
12
- return;
13
- }
14
-
15
- let failed = 0;
16
- for (const f of files) {
17
- try {
18
- const parsed = parseSpecFile(f.abs);
19
- const specLike = parsed.spec as Record<string, unknown>;
20
-
21
- if (parsed.kind === "component") {
22
- const surfaces = specLike.surfaces;
23
- if (!surfaces || typeof surfaces !== "object") {
24
- throw new Error("component spec missing 'surfaces' object");
25
- }
26
- for (const [key, val] of Object.entries(surfaces as Record<string, unknown>)) {
27
- if (!val || typeof val !== "object") {
28
- throw new Error(`surface '${key}' must be an object`);
29
- }
30
- const surface = val as Record<string, unknown>;
31
- if (!Array.isArray(surface.tags)) {
32
- throw new Error(`surface '${key}' missing 'tags' array`);
33
- }
34
- }
35
- }
36
-
37
- console.log(`✓ ${path.relative(root, f.abs)}`);
38
- } catch (err) {
39
- failed++;
40
- console.error(`× ${path.relative(root, f.abs)}: ${err instanceof Error ? err.message : err}`);
41
- }
42
- }
43
-
44
- if (failed > 0) {
45
- console.error(`\n${failed} file(s) failed validation.`);
46
- process.exit(1);
47
- }
48
- }
@@ -1,175 +0,0 @@
1
- import fs from "fs";
2
- import path from "path";
3
-
4
- const CONFIG_NAME = "dittospec.config.json";
5
- const WORKSPACE_SPEC_NAME = "workspace.ditto.md";
6
-
7
- const DEFAULT_CONFIG = JSON.stringify(
8
- {
9
- apiBase: "https://api.dittowords.com",
10
- workspaceId: "TODO: your Ditto workspace ID",
11
- roots: ["."],
12
- },
13
- null,
14
- 2
15
- );
16
-
17
- const WORKSPACE_SPEC = `---
18
- workspace: true
19
- description: >
20
- Workspace-wide content rules. Read alongside any component's index.ditto.md.
21
- # Managed by Ditto — do not edit below
22
- rules: []
23
- ---
24
- `;
25
-
26
- const CLAUDE_MD_BLOCK = `
27
- ## Ditto Content Specs
28
-
29
- This project uses \`.ditto.md\` files for component content governance.
30
-
31
- When writing or editing text props for a component:
32
-
33
- 1. Check for \`workspace.ditto.md\` at the project root for universal content rules.
34
- 2. Check for \`index.ditto.md\` next to the component for surface-specific rules and constraints.
35
- 3. Respect \`maxLength\` — it's a layout constraint, not a suggestion.
36
- 4. Follow all rules in the \`rules\` key. Rules without a \`surface\` field apply to every surface; rules with \`surface\` apply only to that surface.
37
- 5. When creating a new component with text-bearing props, scaffold an \`index.ditto.md\` alongside it.
38
-
39
- Run \`ditto-spec list\` to see all specs, or read the [spec format docs](./packages/ditto-spec-cli/README.md).
40
- `;
41
-
42
- const CURSOR_RULES_BLOCK = `
43
- # Ditto Content Specs
44
-
45
- This project uses .ditto.md files for component content governance.
46
-
47
- When writing or editing text props for a component:
48
-
49
- 1. Check for workspace.ditto.md at the project root for universal content rules.
50
- 2. Check for index.ditto.md next to the component for surface-specific rules and constraints.
51
- 3. Respect maxLength — it's a layout constraint, not a suggestion.
52
- 4. Follow all rules in the rules key. Rules without a surface field apply to every surface; rules with surface apply only to that surface.
53
- 5. When creating a new component with text-bearing props, scaffold an index.ditto.md alongside it.
54
- `;
55
-
56
- const AGENTS_MD_ROW =
57
- "| [Ditto component specs](./packages/ditto-spec-cli/README.md) | `.ditto.md` content specs — read before writing component copy |";
58
-
59
- interface InitOptions {
60
- writeAgent?: boolean;
61
- }
62
-
63
- type AgentEnv = "claude" | "cursor" | "none";
64
-
65
- export async function init(opts: InitOptions = {}): Promise<void> {
66
- const cwd = process.cwd();
67
-
68
- if (fs.existsSync(path.join(cwd, CONFIG_NAME))) {
69
- console.log(`${CONFIG_NAME} already exists. Nothing to do.`);
70
- return;
71
- }
72
-
73
- fs.writeFileSync(path.join(cwd, CONFIG_NAME), DEFAULT_CONFIG + "\n");
74
- console.log(`✓ Created ${CONFIG_NAME}`);
75
-
76
- fs.writeFileSync(path.join(cwd, WORKSPACE_SPEC_NAME), WORKSPACE_SPEC);
77
- console.log(`✓ Created ${WORKSPACE_SPEC_NAME}`);
78
-
79
- const env = detectAgentEnv(cwd);
80
-
81
- if (opts.writeAgent) {
82
- writeAgentConfig(cwd, env);
83
- } else {
84
- printAgentSuggestions(env);
85
- }
86
-
87
- printNextSteps();
88
- }
89
-
90
- function detectAgentEnv(cwd: string): AgentEnv {
91
- if (fs.existsSync(path.join(cwd, ".claude")) || fs.existsSync(path.join(cwd, "CLAUDE.md"))) {
92
- return "claude";
93
- }
94
- if (fs.existsSync(path.join(cwd, ".cursor")) || fs.existsSync(path.join(cwd, ".cursorrules"))) {
95
- return "cursor";
96
- }
97
- return "none";
98
- }
99
-
100
- function writeAgentConfig(cwd: string, env: AgentEnv): void {
101
- if (env === "claude") {
102
- const claudeMd = path.join(cwd, "CLAUDE.md");
103
- if (fs.existsSync(claudeMd)) {
104
- const existing = fs.readFileSync(claudeMd, "utf8");
105
- if (existing.includes("## Ditto Content Specs")) {
106
- console.log(" CLAUDE.md already has Ditto Content Specs section — skipped.");
107
- } else {
108
- fs.appendFileSync(claudeMd, CLAUDE_MD_BLOCK);
109
- console.log("✓ Appended Ditto Content Specs section to CLAUDE.md");
110
- }
111
- } else {
112
- fs.writeFileSync(claudeMd, `# CLAUDE.md${CLAUDE_MD_BLOCK}`);
113
- console.log("✓ Created CLAUDE.md with Ditto Content Specs section");
114
- }
115
-
116
- const agentsMd = path.join(cwd, "AGENTS.md");
117
- if (fs.existsSync(agentsMd)) {
118
- const existing = fs.readFileSync(agentsMd, "utf8");
119
- if (existing.includes("Ditto component specs")) {
120
- console.log(" AGENTS.md already has Ditto row — skipped.");
121
- } else {
122
- fs.appendFileSync(agentsMd, "\n" + AGENTS_MD_ROW + "\n");
123
- console.log("✓ Appended Ditto row to AGENTS.md");
124
- }
125
- }
126
- return;
127
- }
128
-
129
- if (env === "cursor") {
130
- const cursorrules = path.join(cwd, ".cursorrules");
131
- if (fs.existsSync(cursorrules)) {
132
- const existing = fs.readFileSync(cursorrules, "utf8");
133
- if (existing.includes("Ditto Content Specs")) {
134
- console.log(" .cursorrules already has Ditto section — skipped.");
135
- } else {
136
- fs.appendFileSync(cursorrules, CURSOR_RULES_BLOCK);
137
- console.log("✓ Appended Ditto section to .cursorrules");
138
- }
139
- } else {
140
- fs.writeFileSync(cursorrules, CURSOR_RULES_BLOCK.trimStart());
141
- console.log("✓ Created .cursorrules with Ditto section");
142
- }
143
- return;
144
- }
145
-
146
- console.log("\nNo agent environment detected. See next steps for manual setup.");
147
- }
148
-
149
- function printAgentSuggestions(env: AgentEnv): void {
150
- if (env === "claude") {
151
- console.log("\nDetected Claude Code environment.");
152
- console.log("Add this to your CLAUDE.md (or run `ditto-spec init --agent` to do it automatically):\n");
153
- console.log(CLAUDE_MD_BLOCK.trim());
154
- return;
155
- }
156
-
157
- if (env === "cursor") {
158
- console.log("\nDetected Cursor environment.");
159
- console.log("Add this to your .cursorrules (or run `ditto-spec init --agent` to do it automatically):\n");
160
- console.log(CURSOR_RULES_BLOCK.trim());
161
- return;
162
- }
163
-
164
- console.log("\nTo help your AI agent use ditto specs, add the agent contract from the README");
165
- console.log("to your project's agent configuration file (CLAUDE.md, .cursorrules, etc.).");
166
- }
167
-
168
- function printNextSteps(): void {
169
- console.log(`
170
- Next steps:
171
- 1. Fill in your workspaceId in ${CONFIG_NAME}
172
- 2. Set DITTO_API_KEY in .env or your shell
173
- 3. Create your first component spec (index.ditto.md next to a component)
174
- 4. Run \`ditto-spec pull\` to sync rules from the platform`);
175
- }
@@ -1,59 +0,0 @@
1
- import path from "path";
2
- import { loadConfig } from "../config";
3
- import { discover } from "../discover";
4
- import { ParsedSpec, parseSpecFile } from "../parse";
5
-
6
- interface SurfaceLike {
7
- tags?: string[];
8
- maxLength?: number;
9
- }
10
-
11
- interface SpecLike {
12
- tags?: string[];
13
- surfaces?: Record<string, SurfaceLike>;
14
- }
15
-
16
- export async function list(): Promise<void> {
17
- const { config, root } = loadConfig();
18
- const files = discover(root, config.roots);
19
-
20
- if (files.length === 0) {
21
- console.log("No .ditto.md files found.");
22
- return;
23
- }
24
-
25
- for (const f of files) {
26
- let parsed: ParsedSpec;
27
- try {
28
- parsed = parseSpecFile(f.abs);
29
- } catch (err) {
30
- console.error(`× ${path.relative(root, f.abs)}: ${err instanceof Error ? err.message : err}`);
31
- continue;
32
- }
33
-
34
- if (parsed.kind === "workspace") {
35
- const rules = Array.isArray(parsed.spec.rules) ? parsed.spec.rules : [];
36
- console.log(`\n[workspace] (${path.relative(root, f.abs)})`);
37
- console.log(` ${rules.length} rule${rules.length === 1 ? "" : "s"}`);
38
- continue;
39
- }
40
-
41
- console.log(`\n${parsed.name} (${path.relative(root, f.abs)})`);
42
- const specLike = parsed.spec as SpecLike;
43
- const componentTags = specLike.tags ?? [];
44
- if (componentTags.length > 0) {
45
- console.log(` component tags: [${componentTags.join(", ")}]`);
46
- }
47
- const surfaces = specLike.surfaces ?? {};
48
- const entries = Object.entries(surfaces);
49
- if (entries.length === 0) {
50
- console.log(" (no surfaces)");
51
- continue;
52
- }
53
- for (const [key, surface] of entries) {
54
- const tags = (surface.tags ?? []).join(", ");
55
- const maxLen = surface.maxLength != null ? ` max ${surface.maxLength}` : "";
56
- console.log(` ${key.padEnd(28)} [${tags}]${maxLen}`);
57
- }
58
- }
59
- }
@@ -1,140 +0,0 @@
1
- import path from "path";
2
- import { DittoApi, RuleResponse } from "../api";
3
- import { getApiKey, loadConfig } from "../config";
4
- import { discover, DiscoveredFile } from "../discover";
5
- import { ParsedSpec, parseSpecFile } from "../parse";
6
- import { rewriteManagedKeys } from "../serialize";
7
-
8
- interface SurfaceLike {
9
- tags?: string[];
10
- }
11
-
12
- interface SpecLike {
13
- tags?: string[];
14
- surfaces?: Record<string, SurfaceLike>;
15
- }
16
-
17
- interface PullOptions {
18
- dryRun?: boolean;
19
- }
20
-
21
- export async function pull(opts: PullOptions = {}): Promise<void> {
22
- const { config, root } = loadConfig();
23
- const apiKey = getApiKey();
24
- const api = new DittoApi(config, apiKey);
25
-
26
- const files = discover(root, config.roots);
27
- if (files.length === 0) {
28
- console.log("No .ditto.md files found.");
29
- return;
30
- }
31
-
32
- console.log(`Found ${files.length} spec file${files.length === 1 ? "" : "s"}.`);
33
-
34
- type ParsedFile = { file: DiscoveredFile; parsed: ParsedSpec };
35
- const componentFiles: ParsedFile[] = [];
36
- const workspaceFiles: ParsedFile[] = [];
37
-
38
- for (const f of files) {
39
- let parsed: ParsedSpec;
40
- try {
41
- parsed = parseSpecFile(f.abs);
42
- } catch (err) {
43
- console.error(`× ${f.rel}: ${err instanceof Error ? err.message : err}`);
44
- continue;
45
- }
46
-
47
- if (parsed.kind === "workspace") {
48
- workspaceFiles.push({ file: f, parsed });
49
- } else {
50
- componentFiles.push({ file: f, parsed });
51
- }
52
- }
53
-
54
- if (workspaceFiles.length > 1) {
55
- const paths = workspaceFiles.map((w) => path.relative(root, w.file.abs)).join(", ");
56
- throw new Error(`Found multiple workspace specs (${paths}). Expected at most one.`);
57
- }
58
-
59
- const allRules = await api.getRules();
60
- console.log(`Fetched ${allRules.length} rule${allRules.length === 1 ? "" : "s"} from workspace.`);
61
-
62
- let written = 0;
63
- let unchanged = 0;
64
-
65
- if (workspaceFiles.length === 1) {
66
- const { file, parsed } = workspaceFiles[0];
67
- const universalRules = allRules.filter((r) => r.tags.length === 0).map(toRuleObj);
68
-
69
- if (rulesMatch(parsed.spec.rules, universalRules)) {
70
- unchanged++;
71
- } else if (opts.dryRun) {
72
- console.log(`~ ${path.relative(root, file.abs)} (would write ${universalRules.length} workspace rule(s))`);
73
- } else {
74
- rewriteManagedKeys(file.abs, { rules: universalRules });
75
- written++;
76
- console.log(`✓ ${path.relative(root, file.abs)} — ${universalRules.length} workspace rule(s)`);
77
- }
78
- }
79
-
80
- for (const { file, parsed } of componentFiles) {
81
- const specLike = parsed.spec as SpecLike;
82
- const componentTags = specLike.tags ?? [];
83
- const surfaces = specLike.surfaces ?? {};
84
-
85
- const componentMatches = rulesForTags(allRules, componentTags);
86
- const consumed = new Set<RuleResponse>(componentMatches);
87
-
88
- const matchedRules: Array<Record<string, unknown>> = [];
89
- for (const rule of componentMatches) {
90
- matchedRules.push(toRuleObj(rule));
91
- }
92
- for (const [surfaceKey, surface] of Object.entries(surfaces)) {
93
- const matches = rulesForTags(allRules, surface.tags ?? []).filter((r) => !consumed.has(r));
94
- for (const rule of matches) {
95
- matchedRules.push(toSurfaceRuleObj(surfaceKey, rule));
96
- }
97
- }
98
-
99
- if (rulesMatch(parsed.spec.rules, matchedRules)) {
100
- unchanged++;
101
- continue;
102
- }
103
-
104
- if (opts.dryRun) {
105
- console.log(`~ ${path.relative(root, file.abs)} (would write ${matchedRules.length} rule(s))`);
106
- continue;
107
- }
108
-
109
- rewriteManagedKeys(file.abs, { rules: matchedRules });
110
- written++;
111
- console.log(`✓ ${path.relative(root, file.abs)} — ${matchedRules.length} rule(s)`);
112
- }
113
-
114
- console.log(`\nDone. ${written} updated, ${unchanged} unchanged.`);
115
- }
116
-
117
- function rulesForTags(rules: RuleResponse[], tags: string[]): RuleResponse[] {
118
- if (tags.length === 0) return [];
119
- const tagSet = new Set(tags);
120
- return rules.filter((r) => r.tags.length > 0 && r.tags.some((t) => tagSet.has(t)));
121
- }
122
-
123
- function toRuleObj(rule: RuleResponse): Record<string, unknown> {
124
- const out: Record<string, unknown> = {
125
- name: rule.name,
126
- description: rule.description,
127
- };
128
- if (rule.examples.length > 0) {
129
- out.examples = rule.examples.map((ex) => ({ from: ex.from, to: ex.to }));
130
- }
131
- return out;
132
- }
133
-
134
- function toSurfaceRuleObj(surface: string, rule: RuleResponse): Record<string, unknown> {
135
- return { surface, ...toRuleObj(rule) };
136
- }
137
-
138
- function rulesMatch(existing: unknown, incoming: unknown): boolean {
139
- return JSON.stringify(existing ?? []) === JSON.stringify(incoming);
140
- }
package/src/config.ts DELETED
@@ -1,55 +0,0 @@
1
- import dotenv from "dotenv";
2
- import fs from "fs";
3
- import path from "path";
4
-
5
- export interface Config {
6
- apiBase: string;
7
- workspaceId: string;
8
- roots?: string[];
9
- }
10
-
11
- const DEFAULT_CONFIG_NAME = "dittospec.config.json";
12
-
13
- export function loadConfig(cwd: string = process.cwd()): { config: Config; root: string } {
14
- let dir = path.resolve(cwd);
15
- while (true) {
16
- const candidate = path.join(dir, DEFAULT_CONFIG_NAME);
17
- if (fs.existsSync(candidate)) {
18
- const raw = fs.readFileSync(candidate, "utf8");
19
- const parsed = JSON.parse(raw);
20
- validate(parsed, candidate);
21
- return { config: parsed as Config, root: dir };
22
- }
23
- const parent = path.dirname(dir);
24
- if (parent === dir) break;
25
- dir = parent;
26
- }
27
- throw new Error(`No ${DEFAULT_CONFIG_NAME} found from ${cwd} up to filesystem root.`);
28
- }
29
-
30
- function validate(c: unknown, source: string): asserts c is Config {
31
- if (!c || typeof c !== "object") throw new Error(`${source}: must be a JSON object.`);
32
- const obj = c as Record<string, unknown>;
33
- if (typeof obj.apiBase !== "string") throw new Error(`${source}: missing string "apiBase".`);
34
- if (typeof obj.workspaceId !== "string") throw new Error(`${source}: missing string "workspaceId".`);
35
- if (obj.roots !== undefined && !Array.isArray(obj.roots)) {
36
- throw new Error(`${source}: "roots" must be an array of strings if present.`);
37
- }
38
- }
39
-
40
- export function getApiKey(): string {
41
- try {
42
- const { root } = loadConfig();
43
- dotenv.config({ path: path.join(root, ".env") });
44
- } catch {
45
- // No config yet — let getApiKey still work for direct env exports.
46
- }
47
-
48
- const key = process.env.DITTO_API_KEY;
49
- if (!key) {
50
- throw new Error(
51
- "DITTO_API_KEY is not set. Add it to .env at the repo root, or `export DITTO_API_KEY=...` in your shell."
52
- );
53
- }
54
- return key;
55
- }
package/src/discover.ts DELETED
@@ -1,23 +0,0 @@
1
- import { globSync } from "glob";
2
- import path from "path";
3
-
4
- const SPEC_GLOB = "**/*.ditto.md";
5
-
6
- const IGNORE = ["**/node_modules/**", "**/dist*/**", "**/.git/**", "**/.next/**", "**/coverage/**"];
7
-
8
- export interface DiscoveredFile {
9
- abs: string;
10
- rel: string;
11
- }
12
-
13
- export function discover(repoRoot: string, roots: string[] = ["."]): DiscoveredFile[] {
14
- const patterns = roots.map((r) => path.posix.join(r, SPEC_GLOB));
15
-
16
- const results = globSync(patterns, {
17
- cwd: repoRoot,
18
- ignore: IGNORE,
19
- absolute: true,
20
- });
21
-
22
- return results.map((abs) => ({ abs, rel: path.relative(repoRoot, abs) })).sort((a, b) => a.rel.localeCompare(b.rel));
23
- }
package/src/parse.ts DELETED
@@ -1,45 +0,0 @@
1
- import fs from "fs";
2
- import yaml from "js-yaml";
3
-
4
- export interface ParsedSpec {
5
- kind: "component" | "workspace";
6
- name?: string;
7
- spec: Record<string, unknown>;
8
- filePath: string;
9
- }
10
-
11
- export class ParseError extends Error {
12
- constructor(public filePath: string, public reason: string) {
13
- super(`${filePath}: ${reason}`);
14
- }
15
- }
16
-
17
- export function parseSpecFile(filePath: string): ParsedSpec {
18
- const source = fs.readFileSync(filePath, "utf8");
19
- const frontmatter = extractFrontmatter(source, filePath);
20
- const raw = yaml.load(frontmatter);
21
-
22
- if (!raw || typeof raw !== "object") {
23
- throw new ParseError(filePath, "frontmatter must be a YAML object");
24
- }
25
-
26
- const spec = raw as Record<string, unknown>;
27
-
28
- if (spec.workspace === true) {
29
- return { kind: "workspace", spec, filePath };
30
- }
31
-
32
- if (typeof spec.component !== "string" || !spec.component) {
33
- throw new ParseError(filePath, "missing required 'component' key (string)");
34
- }
35
-
36
- return { kind: "component", name: spec.component, spec, filePath };
37
- }
38
-
39
- function extractFrontmatter(source: string, filePath: string): string {
40
- const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---/);
41
- if (!match) {
42
- throw new ParseError(filePath, "no YAML frontmatter found (expected --- delimiters)");
43
- }
44
- return match[1];
45
- }
package/src/serialize.ts DELETED
@@ -1,38 +0,0 @@
1
- import fs from "fs";
2
- import yaml from "js-yaml";
3
-
4
- const MANAGED_COMMENT = "# Managed by Ditto — do not edit below";
5
-
6
- export function rewriteManagedKeys(filePath: string, updates: { rules?: unknown[] }): void {
7
- const source = fs.readFileSync(filePath, "utf8");
8
- const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---/);
9
- if (!match) throw new Error(`${filePath}: no YAML frontmatter found`);
10
-
11
- const spec = yaml.load(match[1]) as Record<string, unknown>;
12
-
13
- if (updates.rules !== undefined) spec.rules = updates.rules;
14
-
15
- let dumped = yaml
16
- .dump(spec, {
17
- lineWidth: -1,
18
- noRefs: true,
19
- sortKeys: false,
20
- quotingType: '"',
21
- })
22
- .trimEnd();
23
-
24
- dumped = insertManagedComment(dumped);
25
-
26
- const afterFrontmatter = source.slice(match[0].length);
27
- fs.writeFileSync(filePath, `---\n${dumped}\n---${afterFrontmatter}`);
28
- }
29
-
30
- function insertManagedComment(dumped: string): string {
31
- const rulesIdx = dumped.search(/^rules:/m);
32
- if (rulesIdx === -1) return dumped;
33
-
34
- const before = dumped.slice(0, rulesIdx);
35
- if (before.includes(MANAGED_COMMENT)) return dumped;
36
-
37
- return before + MANAGED_COMMENT + "\n" + dumped.slice(rulesIdx);
38
- }