@nodii/dev 0.6.1 → 0.6.2
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 -0
- package/dist/api.js +10 -2
- package/dist/cli.js +9 -0
- package/dist/core.js +2 -2
- package/dist/devops-openapi.json +1 -1
- package/dist/guidelines.js +223 -0
- package/package.json +1 -1
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// `nodii-dev design guideline list|show|propose` and `design changeset
|
|
2
|
+
// list|show`: design-system guideline edits (A-032, DEV-DS-GUIDELINES-EDIT-1).
|
|
3
|
+
// A proposal is a guideline change set in Dev. Approving or rejecting one is a
|
|
4
|
+
// person's action in Dev, and nobody decides their own, so there is no command
|
|
5
|
+
// for it here.
|
|
6
|
+
//
|
|
7
|
+
// The Markdown form is what `guideline show` prints and `propose --file` reads:
|
|
8
|
+
//
|
|
9
|
+
// # Button (`button`)
|
|
10
|
+
// ## dos
|
|
11
|
+
// <!-- sourced; refs: … -->
|
|
12
|
+
// - Use one primary button per view.
|
|
13
|
+
// ## anatomy (drafted)
|
|
14
|
+
// - …
|
|
15
|
+
//
|
|
16
|
+
// One `## <list>` heading per statement list, `(drafted)` when the text still
|
|
17
|
+
// awaits a real source, one bullet per item (a line break inside an item reads
|
|
18
|
+
// back as a space). Lines that are HTML comments are ignored; the `#` title
|
|
19
|
+
// names the entry, and `propose` refuses a file titled for another one. Only
|
|
20
|
+
// the lists that differ from Dev's current guideline are sent.
|
|
21
|
+
import { readFile } from "node:fs/promises";
|
|
22
|
+
import { CliError, devRequest, encode, query } from "./core.js";
|
|
23
|
+
|
|
24
|
+
// Dev's editable statement lists (the request schema's `edits[].list` enum).
|
|
25
|
+
// A parked guideline has only the first four.
|
|
26
|
+
export const BASE_LISTS = ["whenToUse", "whenNotToUse", "dos", "donts"];
|
|
27
|
+
export const GUIDELINE_LISTS = [...BASE_LISTS, "anatomy", "states.states", "states.loading", "states.empty", "states.error", "copy", "composition", "data.notes"];
|
|
28
|
+
export const CHANGE_SET_STATES = ["in_review", "approved", "pr_open", "merged", "closed", "rejected", "conflict"];
|
|
29
|
+
|
|
30
|
+
const getList = (guideline, path) => path.split(".").reduce((node, key) => node?.[key], guideline);
|
|
31
|
+
/** The lists this guideline has that Dev lets a change set edit. */
|
|
32
|
+
export const editableLists = (guideline) =>
|
|
33
|
+
(guideline?.status === "active" ? GUIDELINE_LISTS : BASE_LISTS).filter((path) => getList(guideline, path) !== undefined);
|
|
34
|
+
|
|
35
|
+
/** A guideline as the Markdown `propose --file` reads back. */
|
|
36
|
+
export function guidelineMarkdown(entry) {
|
|
37
|
+
const lines = [`# ${entry.title} (\`${entry.key}\`)`, "", `<!-- guideline: ${entry.guideline.status}. Edit the items, then: nodii-dev design guideline propose ${entry.key} --file FILE -->`];
|
|
38
|
+
for (const path of editableLists(entry.guideline)) {
|
|
39
|
+
const list = getList(entry.guideline, path);
|
|
40
|
+
lines.push("", `## ${path}${list.provenance === "drafted" ? " (drafted)" : ""}`, `<!-- ${list.provenance}${list.refs.length ? `; refs: ${list.refs.join(", ")}` : ""} -->`, ...list.items.map((item) => `- ${item}`));
|
|
41
|
+
}
|
|
42
|
+
return lines.join("\n");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const problem = (line, message) => `line ${line}: ${message}`;
|
|
46
|
+
/** Parse the Markdown form into Dev's `edits` and the entry key its title names. Throws one error listing every problem. */
|
|
47
|
+
export function parseGuidelineMarkdown(text) {
|
|
48
|
+
const edits = [];
|
|
49
|
+
let entryKey = null;
|
|
50
|
+
const problems = [];
|
|
51
|
+
// undefined before the first heading; null under a heading that is not a list (its items are skipped).
|
|
52
|
+
let current;
|
|
53
|
+
let comment = false;
|
|
54
|
+
let last = null;
|
|
55
|
+
const lines = text.replace(/\r\n?/g, "\n").split("\n");
|
|
56
|
+
lines.forEach((raw, i) => {
|
|
57
|
+
const n = i + 1;
|
|
58
|
+
let line = raw;
|
|
59
|
+
if (comment) {
|
|
60
|
+
const end = line.indexOf("-->");
|
|
61
|
+
if (end === -1) return;
|
|
62
|
+
comment = false;
|
|
63
|
+
line = line.slice(end + 3);
|
|
64
|
+
}
|
|
65
|
+
// A comment is a line of its own; `<!--` inside an item is the item's text.
|
|
66
|
+
while (/^\s*<!--/.test(line)) {
|
|
67
|
+
const end = line.indexOf("-->");
|
|
68
|
+
if (end === -1) { comment = true; return; }
|
|
69
|
+
line = line.slice(end + 3);
|
|
70
|
+
}
|
|
71
|
+
if (!line.trim()) { last = null; return; }
|
|
72
|
+
const title = /^#\s+(.*)$/.exec(line);
|
|
73
|
+
if (title && current === undefined && entryKey === null) {
|
|
74
|
+
entryKey = /\(`([^`]+)`\)\s*$/.exec(title[1])?.[1] ?? "";
|
|
75
|
+
last = null;
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const heading = /^##\s+(\S+)(\s+\(drafted\))?\s*$/.exec(line);
|
|
79
|
+
if (heading) {
|
|
80
|
+
last = null;
|
|
81
|
+
const [, list, drafted] = heading;
|
|
82
|
+
if (!GUIDELINE_LISTS.includes(list)) {
|
|
83
|
+
problems.push(problem(n, `\`${list}\` is not a guideline list (${GUIDELINE_LISTS.join(", ")})`));
|
|
84
|
+
current = null;
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (edits.some((e) => e.list === list)) problems.push(problem(n, `${list} appears twice`));
|
|
88
|
+
current = { list, items: [], drafted: Boolean(drafted), line: n };
|
|
89
|
+
edits.push(current);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (/^#/.test(line)) { problems.push(problem(n, `expected \`## <list>\`, got: ${line.trim()}`)); return; }
|
|
93
|
+
const item = /^\s{0,3}(?:[-*+]|\d+\.)\s+(.*)$/.exec(line);
|
|
94
|
+
if (item) {
|
|
95
|
+
if (current === undefined) problems.push(problem(n, "a list item before any `## <list>` heading"));
|
|
96
|
+
if (!current) return;
|
|
97
|
+
last = { text: item[1].trim() };
|
|
98
|
+
current.items.push(last);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
// An indented line continues the item above it.
|
|
102
|
+
if (/^\s/.test(line) && last) { last.text = `${last.text} ${line.trim()}`; return; }
|
|
103
|
+
if (current !== null) problems.push(problem(n, `expected a \`- \` list item, got: ${line.trim()}`));
|
|
104
|
+
});
|
|
105
|
+
if (comment) problems.push("an HTML comment is not closed");
|
|
106
|
+
for (const edit of edits) {
|
|
107
|
+
edit.items = edit.items.map((x) => x.text);
|
|
108
|
+
// The checks Dev (and the design-system validator) apply to a list's items.
|
|
109
|
+
if (!edit.items.length) problems.push(problem(edit.line, `${edit.list} needs at least one item`));
|
|
110
|
+
if (edit.items.some((x) => x === "")) problems.push(problem(edit.line, `${edit.list} has an empty item`));
|
|
111
|
+
const seen = new Set();
|
|
112
|
+
for (const x of edit.items) if (seen.has(x)) problems.push(problem(edit.line, `${edit.list} repeats: ${x}`)); else seen.add(x);
|
|
113
|
+
}
|
|
114
|
+
if (!edits.length && !problems.length) problems.push("no `## <list>` sections");
|
|
115
|
+
if (problems.length) throw new CliError("GUIDELINE_FILE_INVALID", `The guideline file has problems:\n ${problems.join("\n ")}`, 2);
|
|
116
|
+
return { entryKey: entryKey || null, edits: edits.map(({ list, items, drafted }) => ({ list, items, drafted })) };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The edits that change Dev's current guideline; refuses lists it cannot edit. */
|
|
120
|
+
export function changedEdits(entry, edits) {
|
|
121
|
+
if (!entry.guideline) throw new CliError("GUIDELINE_MISSING", `${entry.key} has no synced guideline to edit`, 1);
|
|
122
|
+
const allowed = editableLists(entry.guideline);
|
|
123
|
+
const refused = edits.filter((e) => !allowed.includes(e.list)).map((e) => e.list);
|
|
124
|
+
if (refused.length)
|
|
125
|
+
throw new CliError("GUIDELINE_LIST_NOT_EDITABLE", `${refused.join(", ")}: not a list of ${entry.key}'s ${entry.guideline.status} guideline (it has ${allowed.join(", ")})`, 2);
|
|
126
|
+
return edits.filter((e) => {
|
|
127
|
+
const now = getList(entry.guideline, e.list);
|
|
128
|
+
return JSON.stringify(now.items) !== JSON.stringify(e.items) || (now.provenance === "drafted") !== e.drafted;
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const person = (p) => (p ? (p.displayName?.value ?? p.displayName?.hint ?? p.id) : "—");
|
|
133
|
+
const changeSetLine = (x) => `${x.id}\t${x.state}\t${x.entryKey}\t${x.edits.map((e) => e.list).join(",")}\t${x.summary ?? ""}`;
|
|
134
|
+
function changeSetText(x) {
|
|
135
|
+
const out = [
|
|
136
|
+
`${x.id} ${x.state} — ${x.entryTitle} (${x.entryKey})`,
|
|
137
|
+
...(x.summary ? [x.summary] : []),
|
|
138
|
+
`proposed by ${person(x.createdBy)} at ${x.createdAt}`,
|
|
139
|
+
...(x.decidedAt ? [`decided by ${person(x.decidedBy)} at ${x.decidedAt}${x.decisionComment ? `: ${x.decisionComment}` : ""}`] : []),
|
|
140
|
+
...(x.pr ? [`PR #${x.pr.number} ${x.pr.url}${x.mergedAt ? ` (merged ${x.mergedAt})` : ""}`] : []),
|
|
141
|
+
...(x.error ? [`error: ${x.error}`] : []),
|
|
142
|
+
];
|
|
143
|
+
for (const edit of x.edits) {
|
|
144
|
+
const before = x.before?.[edit.list];
|
|
145
|
+
out.push("", `## ${edit.list}${edit.drafted ? " (drafted)" : ""}`);
|
|
146
|
+
// Removed items first, then the new list: `-` removed, `+` added, blank kept.
|
|
147
|
+
for (const item of before?.items ?? []) if (!edit.items.includes(item)) out.push(`- ${item}`);
|
|
148
|
+
for (const item of edit.items) out.push(`${before?.items.includes(item) ? " " : "+"} ${item}`);
|
|
149
|
+
}
|
|
150
|
+
return out.join("\n");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const usage = (text) => new CliError("USAGE", text, 2);
|
|
154
|
+
const flagValue = (flags, name, example) => {
|
|
155
|
+
if (flags[name] === true) throw usage(`--${name} needs a value, e.g. --${name} ${example}`);
|
|
156
|
+
if (Array.isArray(flags[name])) throw usage(`--${name} is given more than once`);
|
|
157
|
+
return flags[name];
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
export async function guidelineCommand(action, args, flags, { request = devRequest, read = (path) => readFile(path, "utf8") } = {}) {
|
|
161
|
+
if (action === "list") {
|
|
162
|
+
const params = { kind: flagValue(flags, "kind", "component"), group: flagValue(flags, "group", "inputs"), q: flagValue(flags, "q", "button"), guidelineDrafted: flags.drafted ? "true" : undefined, limit: flagValue(flags, "limit", "100") ?? 100 };
|
|
163
|
+
let cursor = flagValue(flags, "cursor", "CURSOR");
|
|
164
|
+
const data = [];
|
|
165
|
+
let nextCursor;
|
|
166
|
+
do {
|
|
167
|
+
const page = await request("GET", `/api/v1/design/entries${query({ ...params, cursor })}`);
|
|
168
|
+
data.push(...page.data);
|
|
169
|
+
nextCursor = page.nextCursor ?? null;
|
|
170
|
+
cursor = nextCursor;
|
|
171
|
+
} while (flags.all && cursor);
|
|
172
|
+
return {
|
|
173
|
+
payload: { data, nextCursor },
|
|
174
|
+
// Dev lists root entries with their parts nested; a part is shown indented under its root.
|
|
175
|
+
human: (p) => [p.data.flatMap((x) => [x, ...(x.parts ?? []).map((part) => ({ ...part, key: ` ${part.key}` }))]).map((x) => `${x.key}\t${x.kind}\t${x.status}\tdrafted=${x.guidelineDraftedCount}\t${x.title}`).join("\n") || "No design entries.", ...(p.nextCursor ? [`more: --cursor ${p.nextCursor} (or --all)`] : [])].join("\n"),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
const key = args[0];
|
|
179
|
+
if (action === "show") {
|
|
180
|
+
if (!key || args.length > 1) throw usage("design guideline show ENTRY");
|
|
181
|
+
const payload = await request("GET", `/api/v1/design/entries/${encode(key)}`);
|
|
182
|
+
if (!payload.data.guideline && !flags.json) throw new CliError("GUIDELINE_MISSING", `${key} has no synced guideline`, 1);
|
|
183
|
+
return { payload, human: (p) => guidelineMarkdown(p.data) };
|
|
184
|
+
}
|
|
185
|
+
if (action === "propose") {
|
|
186
|
+
if (!key || args.length > 1) throw usage("design guideline propose ENTRY --file FILE.md [--summary TEXT] [--dry-run]");
|
|
187
|
+
const file = flagValue(flags, "file", "ENTRY.md");
|
|
188
|
+
if (!file) throw usage("Missing --file FILE.md (start from `nodii-dev design guideline show ENTRY > FILE.md`)");
|
|
189
|
+
const summary = flagValue(flags, "summary", "'Clarify when to use'")?.trim();
|
|
190
|
+
if (summary !== undefined && (summary === "" || summary.length > 500)) throw usage("--summary must be 1 to 500 characters");
|
|
191
|
+
let text;
|
|
192
|
+
try { text = await read(file); } catch (error) { throw new CliError("NOT_FOUND", `Cannot read ${file}: ${error.message}`, 2); }
|
|
193
|
+
const { entryKey, edits } = parseGuidelineMarkdown(text);
|
|
194
|
+
if (entryKey && entryKey !== key) throw usage(`${file} is titled for ${entryKey}, not ${key}`);
|
|
195
|
+
const entry = (await request("GET", `/api/v1/design/entries/${encode(key)}`)).data;
|
|
196
|
+
const changed = changedEdits(entry, edits);
|
|
197
|
+
if (!changed.length) throw new CliError("NO_CHANGES", `${file} matches ${key}'s current guideline; nothing to propose`, 1);
|
|
198
|
+
const body = { ...(summary ? { summary } : {}), edits: changed };
|
|
199
|
+
if (flags["dry-run"])
|
|
200
|
+
return { payload: { dryRun: true, entryKey: key, body }, human: () => `Would propose ${changed.map((e) => e.list).join(", ")} for ${key} (dry run; nothing sent).` };
|
|
201
|
+
const payload = await request("POST", `/api/v1/design/entries/${encode(key)}/guideline-change-sets`, body);
|
|
202
|
+
return { payload, human: (p) => `Proposed ${p.data.id} (${p.data.state}) for ${p.data.entryKey}: ${p.data.edits.map((e) => e.list).join(", ")}. A person approves or rejects it in Dev.` };
|
|
203
|
+
}
|
|
204
|
+
throw usage("design guideline list|show ENTRY|propose ENTRY --file FILE.md");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export async function changeSetCommand(action, args, flags, { request = devRequest } = {}) {
|
|
208
|
+
if (action === "list") {
|
|
209
|
+
const states = flags.state === undefined ? undefined : [flags.state].flat();
|
|
210
|
+
for (const state of states ?? [])
|
|
211
|
+
if (!CHANGE_SET_STATES.includes(state)) throw usage(`--state is one of ${CHANGE_SET_STATES.join(", ")}`);
|
|
212
|
+
const payload = await request("GET", `/api/v1/design/guideline-change-sets${query({ entryKey: flagValue(flags, "entry", "button"), state: states, limit: flagValue(flags, "limit", "100") })}`);
|
|
213
|
+
return { payload, human: (p) => [p.data.map(changeSetLine).join("\n") || "No guideline change sets.", ...(p.total > p.data.length ? [`showing ${p.data.length} of ${p.total}; raise --limit (up to 200) or narrow with --entry/--state`] : [])].join("\n") };
|
|
214
|
+
}
|
|
215
|
+
if (action === "show") {
|
|
216
|
+
if (!args[0] || args.length > 1) throw usage("design changeset show ID");
|
|
217
|
+
const payload = await request("GET", `/api/v1/design/guideline-change-sets/${encode(args[0])}`);
|
|
218
|
+
return { payload, human: (p) => changeSetText(p.data) };
|
|
219
|
+
}
|
|
220
|
+
if (["approve", "reject"].includes(action))
|
|
221
|
+
throw usage(`Approving or rejecting a guideline change set is a person's action in Dev; nodii-dev has no ${action}.`);
|
|
222
|
+
throw usage("design changeset list [--entry KEY] [--state STATE] | show ID");
|
|
223
|
+
}
|