@askewly/design 0.6.2 → 0.6.4

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/dist/index.js CHANGED
@@ -2,8 +2,8 @@
2
2
  import { readFileSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { Command } from "commander";
5
- import { addRecipe, initProject, InjectError } from "./inject.js";
6
- import { runKickstart, KickstartError } from "./kickstart.js";
5
+ import { addFromRegistry, addRecipe, defaultSrcRoot, initProject, InjectError } from "./inject.js";
6
+ import { aliasStep, detectPathAlias, REGISTRY_BASE, runKickstart, KickstartError } from "./kickstart.js";
7
7
  import { DEFAULT_TYPOGRAPHY_THRESHOLD } from "./typography.js";
8
8
  import { MASCOT_POSES, renderMascot } from "./mascot.js";
9
9
  import { verifyDir } from "./verify.js";
@@ -143,17 +143,71 @@ program
143
143
  program
144
144
  .command("add")
145
145
  .argument("<recipe>", "recipe id, e.g. button")
146
- .option("--out <dir>", "output directory for the component file", ".")
146
+ .option("--out <dir>", "directory for the component file (default: src/components, or . for --excerpt)")
147
147
  .option("--tokens <file>", "path to the project's tokens.css", "tokens.css")
148
- .option("--force", "overwrite an existing file")
149
- .description("inject a recipe's Code excerpt into the project")
150
- .action((id, options) => {
148
+ .option("--registry <baseUrl>", "registry base URL", REGISTRY_BASE)
149
+ .option("--excerpt", "write only the recipe's Code excerpt (offline; the excerpt is a usage example)")
150
+ .option("--force", "overwrite existing files")
151
+ .description("add the published component (registry item + its libs and dependencies) to the project")
152
+ .action(async (id, options) => {
151
153
  const recipe = loadRecipes().find((r) => r.id === id);
154
+ // 1. 완성본 — 사이트가 공개한 registry 항목 그대로 (F6, 0.6.4)
155
+ let fallbackReason = null;
156
+ if (!options.excerpt) {
157
+ try {
158
+ const full = await addFromRegistry(id, {
159
+ registryBase: options.registry,
160
+ projectDir: process.cwd(),
161
+ componentDir: options.out ? path.resolve(options.out) : undefined,
162
+ force: Boolean(options.force),
163
+ tokensCssPath: path.resolve(options.tokens),
164
+ });
165
+ if (full) {
166
+ console.log(`added the full component from ${full.url}`);
167
+ for (const file of full.written)
168
+ console.log(`wrote ${file}`);
169
+ for (const file of full.unchanged)
170
+ console.log(`unchanged ${file} (already identical)`);
171
+ for (const file of full.conflicts)
172
+ console.log(`⚠ kept ${file} - differs from the registry; not overwritten (use --force to replace)`);
173
+ const steps = [];
174
+ const srcRel = path.basename(defaultSrcRoot(process.cwd())) === "src" ? "./src" : ".";
175
+ const alias = aliasStep(detectPathAlias(process.cwd()), srcRel);
176
+ if (alias)
177
+ steps.push(alias);
178
+ steps.push(`npm i ${full.npmDeps.join(" ")}`);
179
+ if (full.missingCssVars.length > 0) {
180
+ steps.push(`define the CSS variables it needs (missing in ${options.tokens} — run \`askewly-design init\` first): ${full.missingCssVars.join(", ")}`);
181
+ }
182
+ console.log("\nNext steps:");
183
+ for (const [index, step] of steps.entries())
184
+ console.log(` ${index + 1}. ${step}`);
185
+ return;
186
+ }
187
+ fallbackReason = `no published component for "${id}" in ${options.registry}/r/`;
188
+ }
189
+ catch (error) {
190
+ if (!(error instanceof KickstartError))
191
+ throw error;
192
+ fallbackReason = error.message;
193
+ }
194
+ }
195
+ // 2. 폴백 — recipe 의 Code 발췌 (사용 예시라 그대로는 안 돌 수 있다)
152
196
  if (!recipe)
153
- fail(`unknown recipe "${id}" — try: askewly-design recipes list`);
197
+ fail(fallbackReason ? `unknown recipe "${id}" (${fallbackReason}) — try: askewly-design recipes list` : `unknown recipe "${id}" — try: askewly-design recipes list`);
198
+ if (fallbackReason)
199
+ console.log(`⚠ ${fallbackReason} — writing the recipe's Code excerpt instead. It is a usage example, not the finished component; wire it up yourself.`);
154
200
  try {
155
- const result = addRecipe(recipe, path.resolve(options.out), Boolean(options.force), path.resolve(options.tokens));
201
+ const result = addRecipe(recipe, path.resolve(options.out ?? "."), Boolean(options.force), path.resolve(options.tokens));
156
202
  console.log(`wrote ${result.file}`);
203
+ for (const lib of result.libs) {
204
+ if (lib.status === "written")
205
+ console.log(`wrote ${lib.file}`);
206
+ else if (lib.status === "unchanged")
207
+ console.log(`unchanged ${lib.file} (already identical)`);
208
+ else
209
+ console.log(`⚠ kept ${lib.file} - differs from the bundled lib; not overwritten. Diff it by hand if the component misbehaves.`);
210
+ }
157
211
  if (result.missingTokens.length > 0) {
158
212
  console.log(`\n⚠ tokens not found in ${options.tokens} — run \`askewly-design init\` first. Required:`);
159
213
  for (const token of result.missingTokens)
@@ -443,7 +497,7 @@ siteCmd
443
497
  try {
444
498
  const target = dir ?? slug;
445
499
  const n = await getSite(slug, target);
446
- console.log(`${n} files -> ${target}
500
+ console.log(`${n} files -> ${target}
447
501
  ${siteNextStep(target)}`);
448
502
  }
449
503
  catch (error) {
@@ -452,4 +506,4 @@ ${siteNextStep(target)}`);
452
506
  throw error;
453
507
  }
454
508
  });
455
- program.parse();
509
+ await program.parseAsync();
package/dist/inject.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import path from "node:path";
3
+ import { fetchRegistryItem, missingRequiredVars, transplantBlock } from "./kickstart.js";
3
4
  export class InjectError extends Error {
4
5
  }
5
6
  function readBundled(relative) {
@@ -71,12 +72,40 @@ export function extractSectionNotes(recipe, title) {
71
72
  .filter((line) => line.trim().startsWith("- "))
72
73
  .map((line) => line.trim().slice(2));
73
74
  }
75
+ /** kickstart 의 srcRoot 규칙과 같다: 프로젝트에 src/ 가 있으면 그 안, 없으면 프로젝트 루트. */
76
+ export function defaultSrcRoot(projectDir) {
77
+ return existsSync(path.join(projectDir, "src")) ? path.join(projectDir, "src") : projectDir;
78
+ }
79
+ /**
80
+ * Writes the published component — the same item `npx shadcn add <url>` would
81
+ * install: its files, its registry:lib files, and registryDependencies resolved
82
+ * recursively (bare names from shadcn, URLs from ui.askewly.com). Returns null
83
+ * when the registry has no item for this id, so the caller can fall back to the
84
+ * recipe excerpt and say so (F6 — the excerpt alone does not typecheck).
85
+ */
86
+ export async function addFromRegistry(id, opts) {
87
+ const registryBase = opts.registryBase.replace(/\/$/, "");
88
+ const item = await fetchRegistryItem(id, registryBase);
89
+ if (!item)
90
+ return null;
91
+ const result = await transplantBlock(item, opts.projectDir, { keepExisting: !opts.force, componentDir: opts.componentDir });
92
+ const css = opts.tokensCssPath && existsSync(opts.tokensCssPath) ? readFileSync(opts.tokensCssPath, "utf8") : "";
93
+ return {
94
+ url: `${registryBase}/r/${id}.json`,
95
+ written: result.written,
96
+ unchanged: result.unchanged,
97
+ conflicts: result.conflicts,
98
+ npmDeps: [...result.npmDeps].sort(),
99
+ requiredCssVars: result.requiredCssVars,
100
+ missingCssVars: missingRequiredVars(css, result.requiredCssVars),
101
+ };
102
+ }
74
103
  /**
75
104
  * Writes the recipe's Code excerpt to <outDir>/<id>.tsx and reports which
76
105
  * tokens_used entries are not covered by an existing tokens.css in outDir's
77
106
  * project root (heuristic: tokens.css next to outDir or in cwd).
78
107
  */
79
- export function addRecipe(recipe, outDir, force, tokensCssPath) {
108
+ export function addRecipe(recipe, outDir, force, tokensCssPath, srcRoot = defaultSrcRoot(process.cwd())) {
80
109
  const snippet = extractCodeSnippet(recipe);
81
110
  const file = path.join(outDir, `${recipe.id}.tsx`);
82
111
  if (existsSync(file) && !force) {
@@ -84,6 +113,20 @@ export function addRecipe(recipe, outDir, force, tokensCssPath) {
84
113
  }
85
114
  mkdirSync(outDir, { recursive: true });
86
115
  writeFileSync(file, snippet);
116
+ // 컴포넌트가 import 하는 로컬 lib(`@/lib/...`)도 함께 쓴다. 같은 내용이면 건너뛰고,
117
+ // 다르면 덮어쓰지 않고 알린다(사용자가 고친 lib 를 조용히 날리지 않는다).
118
+ const libs = [];
119
+ for (const lib of recipe.lib_files ?? []) {
120
+ const dest = path.join(srcRoot, lib.target);
121
+ if (existsSync(dest)) {
122
+ const same = readFileSync(dest, "utf8").replace(/\r\n/g, "\n") === lib.content;
123
+ libs.push({ file: dest, status: same ? "unchanged" : "conflict" });
124
+ continue;
125
+ }
126
+ mkdirSync(path.dirname(dest), { recursive: true });
127
+ writeFileSync(dest, lib.content);
128
+ libs.push({ file: dest, status: "written" });
129
+ }
87
130
  let missingTokens = [];
88
131
  const tokensUsed = recipe.tokens_used ?? [];
89
132
  if (tokensUsed.length > 0) {
@@ -101,6 +144,7 @@ export function addRecipe(recipe, outDir, force, tokensCssPath) {
101
144
  }
102
145
  return {
103
146
  file,
147
+ libs,
104
148
  missingTokens,
105
149
  checks: extractSectionNotes(recipe, "Checks"),
106
150
  antiPatterns: extractSectionNotes(recipe, "Anti-patterns"),
package/dist/kickstart.js CHANGED
@@ -7,7 +7,7 @@ import { verifyDir } from "./verify.js";
7
7
  const BUILTIN_MODULES = new Set(builtinModules);
8
8
  export class KickstartError extends Error {
9
9
  }
10
- const REGISTRY_BASE = "https://ui.askewly.com";
10
+ export const REGISTRY_BASE = "https://ui.askewly.com";
11
11
  const SHADCN_BASE = "https://ui.shadcn.com/r/styles/new-york-v4";
12
12
  const BRIEF_QUESTIONS = [
13
13
  {
@@ -516,21 +516,45 @@ export async function fetchBlock(blockName, registryBase) {
516
516
  }
517
517
  return block;
518
518
  }
519
- export async function transplantBlock(block, targetDir) {
519
+ /** `add` 용 — 티어를 가리지 않는다. 공개 항목이 없으면(404) null, 그 밖의 실패는 던진다. */
520
+ export async function fetchRegistryItem(name, registryBase) {
521
+ const url = `${registryBase}/r/${name}.json`;
522
+ let res;
523
+ try {
524
+ res = await fetch(url);
525
+ }
526
+ catch (error) {
527
+ throw new KickstartError(`fetch failed: ${url} — ${error.message}`);
528
+ }
529
+ if (res.status === 404)
530
+ return null;
531
+ if (!res.ok)
532
+ throw new KickstartError(`registry fetch ${res.status}: ${url}`);
533
+ return (await res.json());
534
+ }
535
+ export async function transplantBlock(block, targetDir, opts = {}) {
520
536
  const srcRoot = existsSync(path.join(targetDir, "src")) ? path.join(targetDir, "src") : targetDir;
521
537
  const written = [];
538
+ const unchanged = [];
539
+ const conflicts = [];
522
540
  const npmDeps = new Set(["clsx", "tailwind-merge"]);
523
541
  const visited = new Set();
524
542
  const requiredVars = new Set();
525
- function write(file) {
526
- const dest = destFor(file, srcRoot);
543
+ function write(file, top = false) {
544
+ const dest = top && opts.componentDir && !file.target ? path.join(opts.componentDir, path.basename(file.path)) : destFor(file, srcRoot);
527
545
  const content = rewriteImports(file.content);
546
+ // The file is the authority on what must be installed, written or not.
547
+ for (const pkg of importedPackages(content))
548
+ npmDeps.add(pkg);
549
+ if (opts.keepExisting && existsSync(dest)) {
550
+ // 사용자가 고친 파일을 조용히 날리지 않는다 — 같으면 건너뛰고 다르면 알린다.
551
+ const same = readFileSync(dest, "utf8").replace(/\r\n/g, "\n") === content.replace(/\r\n/g, "\n");
552
+ (same ? unchanged : conflicts).push(dest);
553
+ return;
554
+ }
528
555
  mkdirSync(path.dirname(dest), { recursive: true });
529
556
  writeFileSync(dest, content);
530
557
  written.push(dest);
531
- // The file we just wrote is the authority on what must be installed.
532
- for (const pkg of importedPackages(content))
533
- npmDeps.add(pkg);
534
558
  }
535
559
  async function resolve(item) {
536
560
  // Required CSS vars are collected transitively, not just off the top-level
@@ -556,7 +580,7 @@ export async function transplantBlock(block, targetDir) {
556
580
  }
557
581
  await resolve(block);
558
582
  for (const file of block.files ?? [])
559
- write(file);
583
+ write(file, true);
560
584
  // cn() — shadcn init would have created it; transplant guarantees it (M18 step-5 friction).
561
585
  const utils = path.join(srcRoot, "lib", "utils.ts");
562
586
  if (!existsSync(utils)) {
@@ -564,7 +588,7 @@ export async function transplantBlock(block, targetDir) {
564
588
  writeFileSync(utils, `import { clsx, type ClassValue } from "clsx"\nimport { twMerge } from "tailwind-merge"\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs))\n}\n`);
565
589
  written.push(utils);
566
590
  }
567
- return { written, npmDeps, requiredCssVars: [...requiredVars] };
591
+ return { written, unchanged, conflicts, npmDeps, requiredCssVars: [...requiredVars] };
568
592
  }
569
593
  // ---------------------------------------------------------------------------
570
594
  // Orchestrator — brief → DESIGN.md → tokens → transplant → required-var check → verify
package/package.json CHANGED
@@ -1,50 +1,50 @@
1
- {
2
- "name": "@askewly/design",
3
- "version": "0.6.2",
4
- "description": "Query, inject, and verify the Askewly Design system (tokens, UI vocabulary, recipes) from your terminal or coding agent.",
5
- "license": "MIT",
6
- "homepage": "https://ui.askewly.com",
7
- "repository": {
8
- "type": "git",
9
- "url": "git+https://github.com/lumatic2/ui-dictionary.git",
10
- "directory": "packages/cli"
11
- },
12
- "keywords": [
13
- "design-system",
14
- "design-tokens",
15
- "linter",
16
- "ui",
17
- "coding-agent"
18
- ],
19
- "type": "module",
20
- "bin": {
21
- "askewly-design": "./dist/index.js"
22
- },
23
- "files": [
24
- "dist",
25
- "data",
26
- "README.md",
27
- "LICENSE"
28
- ],
29
- "publishConfig": {
30
- "access": "public"
31
- },
32
- "engines": {
33
- "node": ">=18"
34
- },
35
- "scripts": {
36
- "build:data": "node scripts/build-data.mjs",
37
- "build": "npm run build:data && tsc",
38
- "test": "vitest run",
39
- "prepack": "npm run build"
40
- },
41
- "dependencies": {
42
- "commander": "^13.1.0"
43
- },
44
- "devDependencies": {
45
- "@types/node": "^22.10.0",
46
- "typescript": "^5.7.0",
47
- "vitest": "^3.0.0",
48
- "yaml": "^2.6.0"
49
- }
50
- }
1
+ {
2
+ "name": "@askewly/design",
3
+ "version": "0.6.4",
4
+ "description": "Query, inject, and verify the Askewly Design system (tokens, UI vocabulary, recipes) from your terminal or coding agent.",
5
+ "license": "MIT",
6
+ "homepage": "https://ui.askewly.com",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/lumatic2/ui-dictionary.git",
10
+ "directory": "packages/cli"
11
+ },
12
+ "keywords": [
13
+ "design-system",
14
+ "design-tokens",
15
+ "linter",
16
+ "ui",
17
+ "coding-agent"
18
+ ],
19
+ "type": "module",
20
+ "bin": {
21
+ "askewly-design": "./dist/index.js"
22
+ },
23
+ "files": [
24
+ "dist",
25
+ "data",
26
+ "README.md",
27
+ "LICENSE"
28
+ ],
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "engines": {
33
+ "node": ">=18"
34
+ },
35
+ "scripts": {
36
+ "build:data": "node scripts/build-data.mjs",
37
+ "build": "npm run build:data && tsc",
38
+ "test": "vitest run",
39
+ "prepack": "npm run build"
40
+ },
41
+ "dependencies": {
42
+ "commander": "^13.1.0"
43
+ },
44
+ "devDependencies": {
45
+ "@types/node": "^22.10.0",
46
+ "typescript": "^5.7.0",
47
+ "vitest": "^3.0.0",
48
+ "yaml": "^2.6.0"
49
+ }
50
+ }