@seed-design/cli 1.2.2 → 1.3.1

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.
@@ -0,0 +1,450 @@
1
+ import { fetchDocsIndex } from "@/src/utils/fetch";
2
+ import * as p from "@clack/prompts";
3
+ import type { CAC } from "cac";
4
+ import { z } from "zod";
5
+ import { BASE_URL } from "../constants";
6
+ import { analytics } from "../utils/analytics";
7
+ import { highlight } from "../utils/color";
8
+ import {
9
+ CliCancelError,
10
+ CliError,
11
+ handleCliError,
12
+ isCliCancelError,
13
+ isVerboseMode,
14
+ } from "../utils/error";
15
+ import type { DocsCategory, DocsItem, DocsSection } from "../schema";
16
+
17
+ const GITHUB_SNIPPET_BASE =
18
+ "https://raw.githubusercontent.com/daangn/seed-design/refs/heads/dev/docs/registry";
19
+
20
+ const docsOptionsSchema = z.object({
21
+ query: z.string().optional(),
22
+ baseUrl: z.string().optional(),
23
+ });
24
+
25
+ function buildSnippetUrl(registryId: string, snippetPath: string): string {
26
+ return `${GITHUB_SNIPPET_BASE}/${registryId}/${snippetPath}`;
27
+ }
28
+
29
+ function printDocsResult(item: DocsItem, baseUrl: string) {
30
+ const docLink = `${baseUrl}${item.docUrl}`;
31
+ const llmsLink = `${baseUrl}/llms${item.docUrl}.txt`;
32
+
33
+ const lines = [item.id, `- docs: ${docLink}`, `- llms.txt: ${llmsLink}`];
34
+
35
+ if (item.snippetKey && item.snippets && item.snippets.length > 0) {
36
+ const [registryId] = item.snippetKey.split(":");
37
+ if (registryId === "ui" || registryId === "breeze") {
38
+ if (item.snippets.length === 1) {
39
+ lines.push(`- snippet: ${buildSnippetUrl(registryId, item.snippets[0].path)}`);
40
+ } else {
41
+ lines.push("- snippet:");
42
+ for (const snippet of item.snippets) {
43
+ lines.push(` - ${snippet.label}: ${buildSnippetUrl(registryId, snippet.path)}`);
44
+ }
45
+ }
46
+ }
47
+ }
48
+
49
+ p.log.message(lines.join("\n"));
50
+ }
51
+
52
+ /**
53
+ * Compute the Levenshtein (edit) distance between two strings.
54
+ * Used to suggest similar valid paths when users make typos.
55
+ */
56
+ function levenshtein(a: string, b: string): number {
57
+ const m = a.length;
58
+ const n = b.length;
59
+ const dp: number[][] = Array.from({ length: m + 1 }, (_, i) =>
60
+ Array.from({ length: n + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)),
61
+ );
62
+ for (let i = 1; i <= m; i++) {
63
+ for (let j = 1; j <= n; j++) {
64
+ dp[i][j] = Math.min(
65
+ dp[i - 1][j] + 1,
66
+ dp[i][j - 1] + 1,
67
+ dp[i - 1][j - 1] + (a[i - 1] !== b[j - 1] ? 1 : 0),
68
+ );
69
+ }
70
+ }
71
+ return dp[m][n];
72
+ }
73
+
74
+ /**
75
+ * Find candidates similar to `input` within `maxDistance` edits, sorted by distance.
76
+ */
77
+ function findSimilar(input: string, candidates: string[], maxDistance = 3): string[] {
78
+ const q = input.toLowerCase();
79
+ return candidates
80
+ .map((c) => ({ value: c, dist: levenshtein(q, c.toLowerCase()) }))
81
+ .filter(({ dist }) => dist > 0 && dist <= maxDistance)
82
+ .sort((a, b) => a.dist - b.dist)
83
+ .map(({ value }) => value);
84
+ }
85
+
86
+ /**
87
+ * Build a suggestion hint from a path query by fuzzy-matching each segment
88
+ * against the docs index hierarchy.
89
+ */
90
+ function buildSuggestionHint(segments: string[], categories: DocsCategory[]): string | undefined {
91
+ if (segments.length === 0) return undefined;
92
+
93
+ const suggestions: string[] = [];
94
+
95
+ // Try to fuzzy-match the first segment against category IDs
96
+ const categoryIds = categories.map((c) => c.id);
97
+ const similarCategories = findSimilar(segments[0], categoryIds);
98
+
99
+ if (similarCategories.length === 0) {
100
+ // No similar category — try to find similar full paths across everything
101
+ const allPaths = categories.flatMap((cat) =>
102
+ cat.sections.flatMap((sec) => sec.items.map((item) => `${cat.id}/${sec.id}/${item.id}`)),
103
+ );
104
+ const fullQuery = segments.join("/");
105
+ const similarPaths = findSimilar(fullQuery, allPaths, 5);
106
+ if (similarPaths.length > 0) {
107
+ suggestions.push(...similarPaths.slice(0, 3));
108
+ }
109
+ } else {
110
+ const bestCat = categories.find((c) => c.id === similarCategories[0]);
111
+ if (bestCat && segments.length >= 2) {
112
+ const sectionIds = bestCat.sections.map((s) => s.id);
113
+ const similarSections = findSimilar(segments[1], sectionIds);
114
+
115
+ if (similarSections.length > 0) {
116
+ const bestSec = bestCat.sections.find((s) => s.id === similarSections[0]);
117
+ if (bestSec && segments.length >= 3) {
118
+ const itemIds = bestSec.items.map((i) => i.id);
119
+ const similarItems = findSimilar(segments[2], itemIds);
120
+ for (const item of similarItems.slice(0, 3)) {
121
+ suggestions.push(`${bestCat.id}/${bestSec.id}/${item}`);
122
+ }
123
+ } else {
124
+ for (const sec of similarSections.slice(0, 3)) {
125
+ suggestions.push(`${bestCat.id}/${sec}`);
126
+ }
127
+ }
128
+ } else {
129
+ // Section not found, search items within category
130
+ const allItemIds = bestCat.sections.flatMap((s) =>
131
+ s.items.map((i) => ({ path: `${bestCat.id}/${s.id}/${i.id}`, id: i.id })),
132
+ );
133
+ const similarItems = findSimilar(
134
+ segments[1],
135
+ allItemIds.map((x) => x.id),
136
+ );
137
+ for (const itemId of similarItems.slice(0, 3)) {
138
+ const found = allItemIds.find((x) => x.id === itemId);
139
+ if (found) suggestions.push(found.path);
140
+ }
141
+ }
142
+ } else {
143
+ for (const cat of similarCategories.slice(0, 3)) {
144
+ suggestions.push(cat);
145
+ }
146
+ }
147
+ }
148
+
149
+ if (suggestions.length === 0) return undefined;
150
+
151
+ const lines = ["", "💡 이것을 의미했나요?"];
152
+ for (const s of suggestions) {
153
+ lines.push(` - ${s}`);
154
+ }
155
+ return lines.join("\n");
156
+ }
157
+
158
+ /**
159
+ * Parse a path-style query into segments.
160
+ * e.g. "react/components/action-button" → ["react", "components", "action-button"]
161
+ */
162
+ function parseQueryPath(query: string): string[] {
163
+ return query
164
+ .split("/")
165
+ .map((s) => s.trim())
166
+ .filter(Boolean);
167
+ }
168
+
169
+ /**
170
+ * Search all items across all categories/sections.
171
+ */
172
+ function searchAllItems(
173
+ categories: DocsCategory[],
174
+ query: string,
175
+ ): { item: DocsItem; categoryLabel: string; sectionLabel: string }[] {
176
+ const q = query.toLowerCase();
177
+ return categories.flatMap((cat) =>
178
+ cat.sections.flatMap((sec) =>
179
+ sec.items
180
+ .filter((item) => item.id.toLowerCase().includes(q) || item.title.toLowerCase().includes(q))
181
+ .map((item) => ({
182
+ item,
183
+ categoryLabel: cat.label,
184
+ sectionLabel: sec.label,
185
+ })),
186
+ ),
187
+ );
188
+ }
189
+
190
+ async function selectItem(items: DocsItem[]): Promise<DocsItem> {
191
+ const selected = await p.select({
192
+ message: "항목을 선택해주세요",
193
+ options: items.map((item) => ({
194
+ label: `${item.deprecated ? "(deprecated) " : ""}${item.title}`,
195
+ value: item,
196
+ hint: item.description,
197
+ })),
198
+ });
199
+ if (p.isCancel(selected)) throw new CliCancelError();
200
+ return selected;
201
+ }
202
+
203
+ async function selectSection(sections: DocsSection[]): Promise<DocsSection> {
204
+ const selected = await p.select({
205
+ message: "섹션을 선택해주세요",
206
+ options: sections.map((sec) => ({
207
+ label: sec.label,
208
+ value: sec,
209
+ hint: `${sec.items.length}개 항목`,
210
+ })),
211
+ });
212
+ if (p.isCancel(selected)) throw new CliCancelError();
213
+ return selected;
214
+ }
215
+
216
+ async function selectCategory(categories: DocsCategory[]): Promise<DocsCategory> {
217
+ const selected = await p.select({
218
+ message: "카테고리를 선택해주세요",
219
+ options: categories.map((cat) => ({
220
+ label: cat.label,
221
+ value: cat,
222
+ hint: `${cat.sections.reduce((sum, s) => sum + s.items.length, 0)}개 항목`,
223
+ })),
224
+ });
225
+ if (p.isCancel(selected)) throw new CliCancelError();
226
+ return selected;
227
+ }
228
+
229
+ export const docsCommand = (cli: CAC) => {
230
+ cli
231
+ .command("docs [query]", "문서 링크, llms.txt 링크, 스니펫 링크를 조회합니다")
232
+ .option("-u, --baseUrl <baseUrl>", `레지스트리의 기본 URL (기본값: ${BASE_URL})`, {
233
+ default: BASE_URL,
234
+ })
235
+ .example("seed-design docs")
236
+ .example("seed-design docs action-button")
237
+ .example("seed-design docs react")
238
+ .example("seed-design docs react/components")
239
+ .example("seed-design docs react/components/action-button")
240
+ .action(async (query, opts) => {
241
+ const startTime = Date.now();
242
+ const verbose = isVerboseMode(opts);
243
+ p.intro("seed-design docs");
244
+
245
+ try {
246
+ const parsed = docsOptionsSchema.safeParse({ query, ...opts });
247
+ if (!parsed.success) {
248
+ throw parsed.error;
249
+ }
250
+
251
+ const { data: options } = parsed;
252
+ const baseUrl = options.baseUrl ?? BASE_URL;
253
+
254
+ const { start, stop } = p.spinner();
255
+ start("문서 목록을 가져오고 있어요...");
256
+
257
+ const docsIndex = await (async () => {
258
+ try {
259
+ const index = await fetchDocsIndex({ baseUrl });
260
+ stop("문서 목록을 가져왔어요.");
261
+ return index;
262
+ } catch (error) {
263
+ stop("문서 목록을 가져오지 못했어요.");
264
+ throw error;
265
+ }
266
+ })();
267
+
268
+ const { categories } = docsIndex;
269
+ let selectedItem: DocsItem;
270
+
271
+ if (options.query) {
272
+ const segments = parseQueryPath(options.query);
273
+
274
+ // Try to resolve as path: category / section / item
275
+ const matchedCategory = categories.find((c) => c.id === segments[0]);
276
+
277
+ if (matchedCategory && segments.length >= 2) {
278
+ const matchedSection = matchedCategory.sections.find((s) => s.id === segments[1]);
279
+
280
+ if (matchedSection && segments.length >= 3) {
281
+ // Full path: category/section/item
282
+ const matchedItem = matchedSection.items.find((i) => i.id === segments[2]);
283
+ if (matchedItem) {
284
+ selectedItem = matchedItem;
285
+ } else {
286
+ // Item not found in section — search within the section
287
+ const q = segments[2].toLowerCase();
288
+ const matched = matchedSection.items.filter(
289
+ (i) => i.id.toLowerCase().includes(q) || i.title.toLowerCase().includes(q),
290
+ );
291
+ if (matched.length === 0) {
292
+ const similarItems = findSimilar(
293
+ segments[2],
294
+ matchedSection.items.map((i) => i.id),
295
+ );
296
+ const suggestion =
297
+ similarItems.length > 0
298
+ ? `\n\n💡 이것을 의미했나요?\n${similarItems
299
+ .slice(0, 3)
300
+ .map((s) => ` - ${matchedCategory.id}/${matchedSection.id}/${s}`)
301
+ .join("\n")}`
302
+ : "";
303
+ throw new CliError({
304
+ message: `${highlight(options.query)}: 문서를 찾을 수 없어요.${suggestion}`,
305
+ hint: `\`seed-design docs ${matchedCategory.id}/${matchedSection.id}\`로 목록을 확인해보세요.`,
306
+ });
307
+ }
308
+ if (matched.length === 1) {
309
+ selectedItem = matched[0];
310
+ } else {
311
+ selectedItem = await selectItem(matched);
312
+ }
313
+ }
314
+ } else if (matchedSection) {
315
+ // category/section — select item within section
316
+ selectedItem = await selectItem(matchedSection.items);
317
+ } else {
318
+ // category/??? — search within category
319
+ const q = segments[1].toLowerCase();
320
+ const matched = matchedCategory.sections.flatMap((s) =>
321
+ s.items
322
+ .filter(
323
+ (i) => i.id.toLowerCase().includes(q) || i.title.toLowerCase().includes(q),
324
+ )
325
+ .map((item) => ({ item, sectionLabel: s.label })),
326
+ );
327
+
328
+ if (matched.length === 0) {
329
+ const sectionIds = matchedCategory.sections.map((s) => s.id);
330
+ const similarSections = findSimilar(segments[1], sectionIds);
331
+ const allItemIds = matchedCategory.sections.flatMap((s) =>
332
+ s.items.map((i) => ({ path: `${matchedCategory.id}/${s.id}/${i.id}`, id: i.id })),
333
+ );
334
+ const similarItems = findSimilar(
335
+ segments[1],
336
+ allItemIds.map((x) => x.id),
337
+ );
338
+ const suggestions: string[] = [
339
+ ...similarSections.slice(0, 2).map((s) => `${matchedCategory.id}/${s}`),
340
+ ...similarItems
341
+ .slice(0, 2)
342
+ .map((id) => allItemIds.find((x) => x.id === id)?.path)
343
+ .filter((p): p is string => p != null),
344
+ ];
345
+ const suggestion =
346
+ suggestions.length > 0
347
+ ? `\n\n💡 이것을 의미했나요?\n${suggestions.map((s) => ` - ${s}`).join("\n")}`
348
+ : "";
349
+ throw new CliError({
350
+ message: `${highlight(options.query)}: 문서를 찾을 수 없어요.${suggestion}`,
351
+ hint: `\`seed-design docs ${matchedCategory.id}\`로 목록을 확인해보세요.`,
352
+ });
353
+ }
354
+ if (matched.length === 1) {
355
+ selectedItem = matched[0].item;
356
+ } else {
357
+ const selected = await p.select({
358
+ message: `${highlight(segments[1])}에 해당하는 항목을 선택해주세요`,
359
+ options: matched.map(({ item, sectionLabel }) => ({
360
+ label: `[${sectionLabel}] ${item.title}`,
361
+ value: item,
362
+ hint: item.description,
363
+ })),
364
+ });
365
+ if (p.isCancel(selected)) throw new CliCancelError();
366
+ selectedItem = selected;
367
+ }
368
+ }
369
+ } else if (matchedCategory) {
370
+ // Single segment matching a category — drill into it
371
+ if (matchedCategory.sections.length === 1) {
372
+ selectedItem = await selectItem(matchedCategory.sections[0].items);
373
+ } else {
374
+ const section = await selectSection(matchedCategory.sections);
375
+ selectedItem = await selectItem(section.items);
376
+ }
377
+ } else {
378
+ // No category match — global search
379
+ const matched = searchAllItems(categories, options.query);
380
+
381
+ if (matched.length === 0) {
382
+ const suggestion = buildSuggestionHint(segments, categories);
383
+ throw new CliError({
384
+ message: `${highlight(options.query)}: 문서를 찾을 수 없어요.${suggestion ?? ""}`,
385
+ hint: "`seed-design docs`로 전체 목록을 확인해보세요.",
386
+ });
387
+ }
388
+ if (matched.length === 1) {
389
+ selectedItem = matched[0].item;
390
+ } else {
391
+ const selected = await p.select({
392
+ message: `${highlight(options.query)}에 해당하는 항목을 선택해주세요`,
393
+ options: matched.map(({ item, categoryLabel, sectionLabel }) => ({
394
+ label: `[${categoryLabel} > ${sectionLabel}] ${item.title}`,
395
+ value: item,
396
+ hint: item.description,
397
+ })),
398
+ });
399
+ if (p.isCancel(selected)) throw new CliCancelError();
400
+ selectedItem = selected;
401
+ }
402
+ }
403
+ } else {
404
+ // Full interactive flow: category → section → item
405
+ const category = await selectCategory(categories);
406
+
407
+ let section: DocsSection;
408
+ if (category.sections.length === 1) {
409
+ section = category.sections[0];
410
+ } else {
411
+ section = await selectSection(category.sections);
412
+ }
413
+
414
+ selectedItem = await selectItem(section.items);
415
+ }
416
+
417
+ printDocsResult(selectedItem, baseUrl);
418
+ p.outro("완료했어요.");
419
+
420
+ const duration = Date.now() - startTime;
421
+ try {
422
+ await analytics.track(process.cwd(), {
423
+ event: "docs",
424
+ properties: {
425
+ query: options.query ?? null,
426
+ item_id: selectedItem.id,
427
+ has_snippet: !!(selectedItem.snippets && selectedItem.snippets.length > 0),
428
+ duration_ms: duration,
429
+ },
430
+ });
431
+ } catch (telemetryError) {
432
+ if (verbose) {
433
+ console.error("[Telemetry] docs tracking failed:", telemetryError);
434
+ }
435
+ }
436
+ } catch (error) {
437
+ if (isCliCancelError(error)) {
438
+ p.outro(highlight(error.message));
439
+ process.exit(0);
440
+ }
441
+
442
+ handleCliError(error, {
443
+ defaultMessage: "문서 조회에 실패했어요.",
444
+ defaultHint: "`--verbose` 옵션으로 상세 오류를 확인해보세요.",
445
+ verbose,
446
+ });
447
+ process.exit(1);
448
+ }
449
+ });
450
+ };