@askewly/design 0.4.0 → 0.4.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.
package/dist/kickstart.js CHANGED
@@ -1,8 +1,10 @@
1
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { builtinModules } from "node:module";
2
3
  import path from "node:path";
3
4
  import { createInterface } from "node:readline/promises";
4
5
  import { stdin, stdout } from "node:process";
5
6
  import { verifyDir } from "./verify.js";
7
+ const BUILTIN_MODULES = new Set(builtinModules);
6
8
  export class KickstartError extends Error {
7
9
  }
8
10
  const REGISTRY_BASE = "https://ui.askewly.com";
@@ -309,6 +311,49 @@ function destFor(file, srcRoot) {
309
311
  return path.join(srcRoot, "lib", libMatch[1]);
310
312
  return path.join(srcRoot, "components", path.basename(p));
311
313
  }
314
+ // ---------------------------------------------------------------------------
315
+ // What the transplanted code actually needs installed.
316
+ //
317
+ // A registry item's declared `dependencies` is a lower bound, not the answer:
318
+ // shadcn's own button.json declares only `radix-ui` while button.tsx imports
319
+ // `class-variance-authority`. Following the printed `npm i` therefore left cva
320
+ // missing and the first fresh build failed (M27). The authority is the source
321
+ // we just wrote to disk, so read the imports out of it.
322
+ // ---------------------------------------------------------------------------
323
+ const SPECIFIER = /(?:\bfrom\s*|\brequire\s*\(\s*|\bimport\s*\(\s*|^\s*import\s+)(['"])([^'"]+)\1/gm;
324
+ /** Bare npm package names imported by a source file, folded to their install name. */
325
+ export function importedPackages(content) {
326
+ const found = new Set();
327
+ for (const match of content.matchAll(SPECIFIER)) {
328
+ const spec = match[2];
329
+ // Relative paths and the project's own `@/` alias are not packages.
330
+ if (spec.startsWith(".") || spec.startsWith("/") || spec.startsWith("@/"))
331
+ continue;
332
+ if (spec.startsWith("node:"))
333
+ continue;
334
+ if (BUILTIN_MODULES.has(spec.split("/")[0]))
335
+ continue;
336
+ const parts = spec.split("/");
337
+ // `radix-ui/react-slot` installs as `radix-ui`; `@scope/pkg/sub` as `@scope/pkg`.
338
+ found.add(spec.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0]);
339
+ }
340
+ return [...found];
341
+ }
342
+ /**
343
+ * The symbol a consumer imports to render the block.
344
+ *
345
+ * Derived from the transplanted page rather than declared in the registry:
346
+ * a meta field would mean regenerating and redeploying all 56 assets for a
347
+ * defect the CLI can close on its own. `…Demo` exports are the block's own
348
+ * showcase wrapper, never the entry point.
349
+ */
350
+ export function blockExportName(pageSource) {
351
+ for (const match of pageSource.matchAll(/^\s*export\s+function\s+([A-Za-z_$][\w$]*)/gm)) {
352
+ if (!match[1].endsWith("Demo"))
353
+ return match[1];
354
+ }
355
+ return null;
356
+ }
312
357
  export async function fetchBlock(blockName, registryBase) {
313
358
  const block = await fetchJson(`${registryBase}/r/${blockName}.json`);
314
359
  if (block.meta?.tier !== "block") {
@@ -321,6 +366,16 @@ export async function transplantBlock(block, targetDir) {
321
366
  const written = [];
322
367
  const npmDeps = new Set(["clsx", "tailwind-merge"]);
323
368
  const visited = new Set();
369
+ function write(file) {
370
+ const dest = destFor(file, srcRoot);
371
+ const content = rewriteImports(file.content);
372
+ mkdirSync(path.dirname(dest), { recursive: true });
373
+ writeFileSync(dest, content);
374
+ written.push(dest);
375
+ // The file we just wrote is the authority on what must be installed.
376
+ for (const pkg of importedPackages(content))
377
+ npmDeps.add(pkg);
378
+ }
324
379
  async function resolve(item) {
325
380
  for (const dep of item.dependencies ?? [])
326
381
  npmDeps.add(dep.split("@").slice(0, dep.startsWith("@") ? 2 : 1).join("@"));
@@ -332,21 +387,13 @@ export async function transplantBlock(block, targetDir) {
332
387
  visited.add(key);
333
388
  const child = await fetchJson(url);
334
389
  await resolve(child);
335
- for (const file of child.files ?? []) {
336
- const dest = destFor(file, srcRoot);
337
- mkdirSync(path.dirname(dest), { recursive: true });
338
- writeFileSync(dest, rewriteImports(file.content));
339
- written.push(dest);
340
- }
390
+ for (const file of child.files ?? [])
391
+ write(file);
341
392
  }
342
393
  }
343
394
  await resolve(block);
344
- for (const file of block.files ?? []) {
345
- const dest = destFor(file, srcRoot);
346
- mkdirSync(path.dirname(dest), { recursive: true });
347
- writeFileSync(dest, rewriteImports(file.content));
348
- written.push(dest);
349
- }
395
+ for (const file of block.files ?? [])
396
+ write(file);
350
397
  // cn() — shadcn init would have created it; transplant guarantees it (M18 step-5 friction).
351
398
  const utils = path.join(srcRoot, "lib", "utils.ts");
352
399
  if (!existsSync(utils)) {
@@ -401,10 +448,15 @@ export async function runKickstart(dir, opts) {
401
448
  throw new KickstartError("transplanted block bypasses the token system — fix before continuing");
402
449
  }
403
450
  console.log(`verify PASS — ${files} block file(s), no color literals`);
404
- // 7. handoff
451
+ // 7. handoff — every line here must be true of the project that now exists on
452
+ // disk. A printed step the reader cannot follow is worse than no step.
453
+ const pagePath = path.join(blockDir, "page.tsx");
454
+ const exportName = existsSync(pagePath) ? blockExportName(readFileSync(pagePath, "utf8")) : null;
405
455
  console.log(`\nNext steps:`);
406
456
  console.log(` 1. npm i ${[...result.npmDeps].sort().join(" ")}`);
407
457
  console.log(` 2. import "./askewly-brand.css" after \`@import "tailwindcss";\` in your global stylesheet`);
408
- console.log(` 3. render the block: import { SaasAppShell } from "@/components/blocks/${opts.block}/page"`);
458
+ console.log(exportName
459
+ ? ` 3. render the block: import { ${exportName} } from "@/components/blocks/${opts.block}/page"`
460
+ : ` 3. render the block from "@/components/blocks/${opts.block}/page" — open it to see which component it exports`);
409
461
  console.log(` 4. wire routing yourself — the block deliberately ships none (block-contract §3)`);
410
462
  }
package/dist/verify.js CHANGED
@@ -30,6 +30,21 @@ const LINE_COMMENT_EXTENSIONS = new Set(["ts", "tsx", "js", "jsx"]);
30
30
  // Two SVG shapes: paired <svg>…</svg> and self-closing <svg … />.
31
31
  const SVG_PAIRED = /<svg\b[\s\S]*?<\/svg\s*>/gi;
32
32
  const SVG_SELF_CLOSING = /<svg\b[^>]*\/>/gi;
33
+ /**
34
+ * CSS attribute selectors whose value is a hex color, e.g. the Tailwind
35
+ * arbitrary variant `[&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border`.
36
+ *
37
+ * The hex there is a *match target*, not a color the file paints with: it names
38
+ * a value some third-party library already emitted so the rule can override it
39
+ * with a token. Flagging it inverts the gate — the line doing the token work is
40
+ * the line getting reported.
41
+ *
42
+ * Deliberately narrow: an attribute name, an `=` (optionally prefixed by a CSS
43
+ * match operator), and a *quoted* hex. Tailwind arbitrary values like
44
+ * `bg-[#ccc]` have no `=` and no quotes, so they stay violations. The optional
45
+ * backslashes cover selectors written inside a same-quoted JS string.
46
+ */
47
+ const ATTR_SELECTOR_HEX = /\[[A-Za-z_-][\w:.-]*\s*[~^|*$]?=\s*\\?(['"])#[0-9a-fA-F]{3,8}\\?\1\s*[iIsS]?\s*\]/g;
33
48
  /**
34
49
  * Blanks regions whose color literals are not rendered output — comments and
35
50
  * SVG markup — while preserving every character position and newline, so
@@ -109,7 +124,7 @@ export function maskIgnoredRegions(source, ext) {
109
124
  // Pass 2 — SVG markup, matched against the comment-masked text so a commented
110
125
  // out `</svg>` cannot terminate a block early.
111
126
  let masked = chars.join("");
112
- for (const pattern of [SVG_PAIRED, SVG_SELF_CLOSING]) {
127
+ for (const pattern of [SVG_PAIRED, SVG_SELF_CLOSING, ATTR_SELECTOR_HEX]) {
113
128
  masked = masked.replace(pattern, (match) => match.replace(/[^\n]/g, " "));
114
129
  }
115
130
  return masked;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askewly/design",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Query, inject, and verify the Askewly Design system (tokens, UI vocabulary, recipes) from your terminal or coding agent.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://ui.askewly.com",