@askewly/design 0.4.0 → 0.4.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/dist/kickstart.js +212 -21
- package/dist/verify.js +16 -1
- package/package.json +1 -1
package/dist/kickstart.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, 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";
|
|
@@ -196,7 +198,11 @@ export function renderBrandCss(answers) {
|
|
|
196
198
|
const a = ACCENTS[answers.color];
|
|
197
199
|
const c = CANVASES[answers.tone];
|
|
198
200
|
const font = FONT_STACKS[answers.type];
|
|
199
|
-
|
|
201
|
+
// `color-scheme` is what the *browser* reads. Tokens only repaint what we
|
|
202
|
+
// draw; scrollbars, native selects and checkboxes, and the overscroll gutter
|
|
203
|
+
// behind the page are the browser's, and they stay light without this.
|
|
204
|
+
const vars = (m, primary, onPrimary, ring, scheme) => ` color-scheme: ${scheme};
|
|
205
|
+
--background: ${m.bg};
|
|
200
206
|
--foreground: ${m.fg};
|
|
201
207
|
--card: ${m.card};
|
|
202
208
|
--card-foreground: ${m.fg};
|
|
@@ -227,13 +233,22 @@ export function renderBrandCss(answers) {
|
|
|
227
233
|
return `/* Generated by \`askewly-design init --block\` from DESIGN.md (tone=${answers.tone}, color=${answers.color}).
|
|
228
234
|
* This file is the project's token definition layer — the block's required
|
|
229
235
|
* CSS variables (block-contract §4/§6) are defined here, nowhere else. */
|
|
236
|
+
|
|
237
|
+
/* One switch for dark mode. Without this line Tailwind v4 keys \`dark:\`
|
|
238
|
+
* utilities off \`prefers-color-scheme\` while the token values below key off
|
|
239
|
+
* the \`.dark\` class — two independent switches, so a project that toggles the
|
|
240
|
+
* class gets dark tokens with light-mode utilities (and an OS-dark visitor gets
|
|
241
|
+
* the reverse). Transplanted shadcn primitives carry \`dark:\` utilities, so this
|
|
242
|
+
* is load-bearing, not cosmetic. Toggle by putting \`dark\` on <html>. */
|
|
243
|
+
@custom-variant dark (&:where(.dark, .dark *));
|
|
244
|
+
|
|
230
245
|
:root {
|
|
231
246
|
font-family: ${font};
|
|
232
|
-
${vars(c.light, a.light, a.onLight, a.ring)}
|
|
247
|
+
${vars(c.light, a.light, a.onLight, a.ring, "light")}
|
|
233
248
|
}
|
|
234
249
|
|
|
235
250
|
.dark {
|
|
236
|
-
${vars(c.dark, a.dark, a.onDark, a.darkRing)}
|
|
251
|
+
${vars(c.dark, a.dark, a.onDark, a.darkRing, "dark")}
|
|
237
252
|
}
|
|
238
253
|
|
|
239
254
|
@theme inline {
|
|
@@ -309,6 +324,167 @@ function destFor(file, srcRoot) {
|
|
|
309
324
|
return path.join(srcRoot, "lib", libMatch[1]);
|
|
310
325
|
return path.join(srcRoot, "components", path.basename(p));
|
|
311
326
|
}
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
// What the transplanted code actually needs installed.
|
|
329
|
+
//
|
|
330
|
+
// A registry item's declared `dependencies` is a lower bound, not the answer:
|
|
331
|
+
// shadcn's own button.json declares only `radix-ui` while button.tsx imports
|
|
332
|
+
// `class-variance-authority`. Following the printed `npm i` therefore left cva
|
|
333
|
+
// missing and the first fresh build failed (M27). The authority is the source
|
|
334
|
+
// we just wrote to disk, so read the imports out of it.
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
const SPECIFIER = /(?:\bfrom\s*|\brequire\s*\(\s*|\bimport\s*\(\s*|^\s*import\s+)(['"])([^'"]+)\1/gm;
|
|
337
|
+
/** Bare npm package names imported by a source file, folded to their install name. */
|
|
338
|
+
export function importedPackages(content) {
|
|
339
|
+
const found = new Set();
|
|
340
|
+
for (const match of content.matchAll(SPECIFIER)) {
|
|
341
|
+
const spec = match[2];
|
|
342
|
+
// Relative paths and the project's own `@/` alias are not packages.
|
|
343
|
+
if (spec.startsWith(".") || spec.startsWith("/") || spec.startsWith("@/"))
|
|
344
|
+
continue;
|
|
345
|
+
if (spec.startsWith("node:"))
|
|
346
|
+
continue;
|
|
347
|
+
if (BUILTIN_MODULES.has(spec.split("/")[0]))
|
|
348
|
+
continue;
|
|
349
|
+
const parts = spec.split("/");
|
|
350
|
+
// `radix-ui/react-slot` installs as `radix-ui`; `@scope/pkg/sub` as `@scope/pkg`.
|
|
351
|
+
found.add(spec.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0]);
|
|
352
|
+
}
|
|
353
|
+
return [...found];
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* The symbol a consumer imports to render the block.
|
|
357
|
+
*
|
|
358
|
+
* Derived from the transplanted page rather than declared in the registry:
|
|
359
|
+
* a meta field would mean regenerating and redeploying all 56 assets for a
|
|
360
|
+
* defect the CLI can close on its own. `…Demo` exports are the block's own
|
|
361
|
+
* showcase wrapper, never the entry point.
|
|
362
|
+
*/
|
|
363
|
+
export function blockExportName(pageSource) {
|
|
364
|
+
for (const match of pageSource.matchAll(/^\s*export\s+function\s+([A-Za-z_$][\w$]*)/gm)) {
|
|
365
|
+
if (!match[1].endsWith("Demo"))
|
|
366
|
+
return match[1];
|
|
367
|
+
}
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
// ---------------------------------------------------------------------------
|
|
371
|
+
// The `@/` alias the transplanted code needs.
|
|
372
|
+
//
|
|
373
|
+
// Every registry source imports through `@/…`, so a project without the alias
|
|
374
|
+
// configured fails to typecheck the moment the block lands — six `tsc -b`
|
|
375
|
+
// errors on a stock vite react-ts app, patched by hand in both M28 E2E runs.
|
|
376
|
+
// The printed handoff said nothing about it.
|
|
377
|
+
//
|
|
378
|
+
// Two traps this has to survive:
|
|
379
|
+
// 1. tsconfig files are JSONC. Parsing them would mean shipping a parser, so
|
|
380
|
+
// the check is a raw scan with comments removed — a commented-out `"@/*"`
|
|
381
|
+
// must not read as configured.
|
|
382
|
+
// 2. vite's react-ts template makes the root tsconfig.json a *solution* file:
|
|
383
|
+
// references only, no compilerOptions. `paths` written there is ignored by
|
|
384
|
+
// `tsc -b`. The step has to name the file that actually owns the options.
|
|
385
|
+
// ---------------------------------------------------------------------------
|
|
386
|
+
/** Blank out comments without touching string contents (JSONC + TS configs). */
|
|
387
|
+
function stripComments(source) {
|
|
388
|
+
let out = "";
|
|
389
|
+
let quote = null;
|
|
390
|
+
for (let i = 0; i < source.length; i++) {
|
|
391
|
+
const ch = source[i];
|
|
392
|
+
if (quote) {
|
|
393
|
+
out += ch;
|
|
394
|
+
if (ch === "\\") {
|
|
395
|
+
out += source[++i] ?? "";
|
|
396
|
+
}
|
|
397
|
+
else if (ch === quote) {
|
|
398
|
+
quote = null;
|
|
399
|
+
}
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
if (ch === '"' || ch === "'" || ch === "`") {
|
|
403
|
+
quote = ch;
|
|
404
|
+
out += ch;
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
if (ch === "/" && source[i + 1] === "/") {
|
|
408
|
+
while (i < source.length && source[i] !== "\n")
|
|
409
|
+
i++;
|
|
410
|
+
out += "\n";
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
if (ch === "/" && source[i + 1] === "*") {
|
|
414
|
+
i += 2;
|
|
415
|
+
while (i < source.length && !(source[i] === "*" && source[i + 1] === "/"))
|
|
416
|
+
i++;
|
|
417
|
+
i++;
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
out += ch;
|
|
421
|
+
}
|
|
422
|
+
return out;
|
|
423
|
+
}
|
|
424
|
+
const TSCONFIG_ALIAS = /"@\/\*"\s*:/;
|
|
425
|
+
const VITE_ALIAS = /vite-tsconfig-paths|resolve\s*:[\s\S]{0,400}?alias/;
|
|
426
|
+
/**
|
|
427
|
+
* Which half of the `@/` alias is missing.
|
|
428
|
+
*
|
|
429
|
+
* Uncertainty resolves toward printing: an extra step in an already-configured
|
|
430
|
+
* project costs a glance, a missing one costs the build.
|
|
431
|
+
*/
|
|
432
|
+
export function detectPathAlias(targetDir) {
|
|
433
|
+
const names = existsSync(targetDir)
|
|
434
|
+
? readdirSync(targetDir).filter((n) => /^tsconfig.*\.json$/.test(n))
|
|
435
|
+
: [];
|
|
436
|
+
const read = (name) => {
|
|
437
|
+
try {
|
|
438
|
+
return stripComments(readFileSync(path.join(targetDir, name), "utf8"));
|
|
439
|
+
}
|
|
440
|
+
catch {
|
|
441
|
+
return "";
|
|
442
|
+
}
|
|
443
|
+
};
|
|
444
|
+
const sources = new Map(names.map((n) => [n, read(n)]));
|
|
445
|
+
const declared = names.some((n) => TSCONFIG_ALIAS.test(sources.get(n) ?? ""));
|
|
446
|
+
// Name the file that owns compilerOptions — writing paths anywhere else is a
|
|
447
|
+
// step the reader can follow and still fail.
|
|
448
|
+
const owner = names.find((n) => n !== "tsconfig.json" && /"compilerOptions"\s*:/.test(sources.get(n) ?? "")) ??
|
|
449
|
+
names.find((n) => /"compilerOptions"\s*:/.test(sources.get(n) ?? "")) ??
|
|
450
|
+
names[0] ??
|
|
451
|
+
"tsconfig.json";
|
|
452
|
+
const viteConfig = existsSync(targetDir)
|
|
453
|
+
? readdirSync(targetDir).find((n) => /^vite\.config\.[cm]?[jt]s$/.test(n))
|
|
454
|
+
: undefined;
|
|
455
|
+
const vite = viteConfig ? VITE_ALIAS.test(stripComments(readFileSync(path.join(targetDir, viteConfig), "utf8"))) : false;
|
|
456
|
+
return { tsconfig: declared ? null : owner, vite };
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* The copy-paste step, or null when both halves are already in place.
|
|
460
|
+
*
|
|
461
|
+
* Deliberately free of new npm dependencies: the printed `npm i` list is
|
|
462
|
+
* contracted to be what the transplanted files import (M28), and recommending
|
|
463
|
+
* a build-tool plugin there would blur that. `srcRel` keeps the snippet true to
|
|
464
|
+
* where the block was actually written.
|
|
465
|
+
*/
|
|
466
|
+
export function aliasStep(status, srcRel) {
|
|
467
|
+
if (!status.tsconfig && status.vite)
|
|
468
|
+
return null;
|
|
469
|
+
const parts = [
|
|
470
|
+
`configure the \`@/\` alias — the transplanted files import through it, and \`tsc\` fails without it:`,
|
|
471
|
+
];
|
|
472
|
+
if (status.tsconfig) {
|
|
473
|
+
// No `baseUrl`: paths resolve relative to the tsconfig since TS 4.4, and on
|
|
474
|
+
// TS 6 the option is deprecated hard enough to fail the build (TS5101,
|
|
475
|
+
// measured on a stock vite react-ts project — the first snippet shipped it
|
|
476
|
+
// and traded six missing-module errors for one deprecation error).
|
|
477
|
+
parts.push(` in ${status.tsconfig}, inside "compilerOptions":`);
|
|
478
|
+
parts.push(` "paths": { "@/*": ["${srcRel}/*"] }`);
|
|
479
|
+
}
|
|
480
|
+
if (!status.vite) {
|
|
481
|
+
parts.push(` in vite.config.ts:`);
|
|
482
|
+
parts.push(` import { fileURLToPath } from "node:url"`);
|
|
483
|
+
parts.push(` // inside defineConfig({ … })`);
|
|
484
|
+
parts.push(` resolve: { alias: { "@": fileURLToPath(new URL("${srcRel}", import.meta.url)) } }`);
|
|
485
|
+
}
|
|
486
|
+
return parts.join("\n");
|
|
487
|
+
}
|
|
312
488
|
export async function fetchBlock(blockName, registryBase) {
|
|
313
489
|
const block = await fetchJson(`${registryBase}/r/${blockName}.json`);
|
|
314
490
|
if (block.meta?.tier !== "block") {
|
|
@@ -321,6 +497,16 @@ export async function transplantBlock(block, targetDir) {
|
|
|
321
497
|
const written = [];
|
|
322
498
|
const npmDeps = new Set(["clsx", "tailwind-merge"]);
|
|
323
499
|
const visited = new Set();
|
|
500
|
+
function write(file) {
|
|
501
|
+
const dest = destFor(file, srcRoot);
|
|
502
|
+
const content = rewriteImports(file.content);
|
|
503
|
+
mkdirSync(path.dirname(dest), { recursive: true });
|
|
504
|
+
writeFileSync(dest, content);
|
|
505
|
+
written.push(dest);
|
|
506
|
+
// The file we just wrote is the authority on what must be installed.
|
|
507
|
+
for (const pkg of importedPackages(content))
|
|
508
|
+
npmDeps.add(pkg);
|
|
509
|
+
}
|
|
324
510
|
async function resolve(item) {
|
|
325
511
|
for (const dep of item.dependencies ?? [])
|
|
326
512
|
npmDeps.add(dep.split("@").slice(0, dep.startsWith("@") ? 2 : 1).join("@"));
|
|
@@ -332,21 +518,13 @@ export async function transplantBlock(block, targetDir) {
|
|
|
332
518
|
visited.add(key);
|
|
333
519
|
const child = await fetchJson(url);
|
|
334
520
|
await resolve(child);
|
|
335
|
-
for (const file of child.files ?? [])
|
|
336
|
-
|
|
337
|
-
mkdirSync(path.dirname(dest), { recursive: true });
|
|
338
|
-
writeFileSync(dest, rewriteImports(file.content));
|
|
339
|
-
written.push(dest);
|
|
340
|
-
}
|
|
521
|
+
for (const file of child.files ?? [])
|
|
522
|
+
write(file);
|
|
341
523
|
}
|
|
342
524
|
}
|
|
343
525
|
await resolve(block);
|
|
344
|
-
for (const file of block.files ?? [])
|
|
345
|
-
|
|
346
|
-
mkdirSync(path.dirname(dest), { recursive: true });
|
|
347
|
-
writeFileSync(dest, rewriteImports(file.content));
|
|
348
|
-
written.push(dest);
|
|
349
|
-
}
|
|
526
|
+
for (const file of block.files ?? [])
|
|
527
|
+
write(file);
|
|
350
528
|
// cn() — shadcn init would have created it; transplant guarantees it (M18 step-5 friction).
|
|
351
529
|
const utils = path.join(srcRoot, "lib", "utils.ts");
|
|
352
530
|
if (!existsSync(utils)) {
|
|
@@ -401,10 +579,23 @@ export async function runKickstart(dir, opts) {
|
|
|
401
579
|
throw new KickstartError("transplanted block bypasses the token system — fix before continuing");
|
|
402
580
|
}
|
|
403
581
|
console.log(`verify PASS — ${files} block file(s), no color literals`);
|
|
404
|
-
// 7. handoff
|
|
582
|
+
// 7. handoff — every line here must be true of the project that now exists on
|
|
583
|
+
// disk. A printed step the reader cannot follow is worse than no step.
|
|
584
|
+
const pagePath = path.join(blockDir, "page.tsx");
|
|
585
|
+
const exportName = existsSync(pagePath) ? blockExportName(readFileSync(pagePath, "utf8")) : null;
|
|
586
|
+
const srcRel = srcRoot === targetDir ? "." : "./src";
|
|
587
|
+
const steps = [];
|
|
588
|
+
const alias = aliasStep(detectPathAlias(targetDir), srcRel);
|
|
589
|
+
if (alias)
|
|
590
|
+
steps.push(alias);
|
|
591
|
+
steps.push(`npm i ${[...result.npmDeps].sort().join(" ")}`);
|
|
592
|
+
steps.push(`import "./askewly-brand.css" after \`@import "tailwindcss";\` in your global stylesheet (order matters — it registers the dark variant)`);
|
|
593
|
+
steps.push(`toggle dark mode by putting the \`dark\` class on <html> — tokens and \`dark:\` utilities both key off it`);
|
|
594
|
+
steps.push(exportName
|
|
595
|
+
? `render the block: import { ${exportName} } from "@/components/blocks/${opts.block}/page"`
|
|
596
|
+
: `render the block from "@/components/blocks/${opts.block}/page" — open it to see which component it exports`);
|
|
597
|
+
steps.push(`wire routing yourself — the block deliberately ships none (block-contract §3)`);
|
|
405
598
|
console.log(`\nNext steps:`);
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
console.log(` 3. render the block: import { SaasAppShell } from "@/components/blocks/${opts.block}/page"`);
|
|
409
|
-
console.log(` 4. wire routing yourself — the block deliberately ships none (block-contract §3)`);
|
|
599
|
+
for (const [index, step] of steps.entries())
|
|
600
|
+
console.log(` ${index + 1}. ${step}`);
|
|
410
601
|
}
|
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.
|
|
3
|
+
"version": "0.4.2",
|
|
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",
|