frakment 0.1.0 → 0.2.0

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/bin/index.js CHANGED
@@ -25,6 +25,7 @@ program
25
25
  Examples:
26
26
  ${chalk.dim("$ frakment init")}
27
27
  ${chalk.dim("$ frakment add button")}
28
+ ${chalk.dim("$ frakment add button bubble")}
28
29
  ${chalk.dim("$ frakment list")}
29
30
  `
30
31
  );
@@ -35,8 +36,8 @@ program
35
36
  .action(init);
36
37
 
37
38
  program
38
- .command("add <component>")
39
- .description("Add a component from the frakment registry")
39
+ .command("add <components...>")
40
+ .description("Add one or more components from the frakment registry")
40
41
  .option(
41
42
  "--dry-run",
42
43
  "Show planned file and dependency changes without writing"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "frakment",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "CLI and design system for the frakment component registry",
5
5
  "keywords": [
6
6
  "components",
@@ -16,6 +16,29 @@ const DEPENDENCY_PATTERN =
16
16
  /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*(?:@[0-9A-Za-z*~^<>=|.+_-]+)?$/u;
17
17
  const MAX_REGISTRY_PAYLOAD_BYTES = 10 * 1024 * 1024;
18
18
 
19
+ const DEFAULT_ALIASES = {
20
+ components: "@/components",
21
+ ui: "@/components/ui",
22
+ utils: "@/lib/utils",
23
+ };
24
+
25
+ const normalizeAliases = (input) => {
26
+ const aliases = input ?? {};
27
+ const components = aliases.components ?? DEFAULT_ALIASES.components;
28
+ const ui = aliases.ui ?? `${components}/ui`;
29
+ const utils = aliases.utils ?? DEFAULT_ALIASES.utils;
30
+ return { components, ui, utils };
31
+ };
32
+
33
+ export const rewriteImports = (content, aliases) => {
34
+ const a = normalizeAliases(aliases);
35
+ let out = content;
36
+ out = out.replaceAll("@/components/ui/", `${a.ui}/`);
37
+ out = out.replaceAll("@/components/", `${a.components}/`);
38
+ out = out.replace(/@\/lib\/utils\b/g, a.utils);
39
+ return out;
40
+ };
41
+
19
42
  const isRecord = (value) =>
20
43
  typeof value === "object" && value !== null && !Array.isArray(value);
21
44
 
@@ -194,7 +217,7 @@ export async function assertSafePath(cwd, relativePath) {
194
217
  return target;
195
218
  }
196
219
 
197
- async function writeFileSafely(file, cwd, options) {
220
+ async function writeFileSafely(file, cwd, options, aliases) {
198
221
  const targetPath = await assertSafePath(cwd, file.path);
199
222
  if (existsSync(targetPath) && !options.overwrite) {
200
223
  const { overwrite } = await prompts({
@@ -212,12 +235,21 @@ async function writeFileSafely(file, cwd, options) {
212
235
  const output =
213
236
  file.encoding === "base64"
214
237
  ? Buffer.from(file.content, "base64")
215
- : file.content.replaceAll("\r\n", "\n");
238
+ : rewriteImports(file.content, aliases).replaceAll("\r\n", "\n");
216
239
  await writeFile(targetPath, output);
217
240
  return file.path;
218
241
  }
219
242
 
220
- export async function add(componentName, options) {
243
+ export async function add(componentNamesInput, options) {
244
+ const componentNames = Array.isArray(componentNamesInput)
245
+ ? [...new Set(componentNamesInput)]
246
+ : [componentNamesInput];
247
+ if (componentNames.length === 0) {
248
+ logger.error(
249
+ `${chalk.red("Error:")} At least one component name is required.`
250
+ );
251
+ process.exit(1);
252
+ }
221
253
  let cwd;
222
254
  const preflight = ora("Preflight checks").start();
223
255
  try {
@@ -237,7 +269,9 @@ export async function add(componentName, options) {
237
269
  let config;
238
270
  let registryUrl;
239
271
  try {
240
- assertComponentName(componentName);
272
+ for (const name of componentNames) {
273
+ assertComponentName(name);
274
+ }
241
275
  const configRaw = await readFile(configPath, "utf-8");
242
276
  config = JSON.parse(configRaw.replace(/^\uFEFF/u, ""));
243
277
  registryUrl = getRegistryUrl(options.registry ?? config.registry);
@@ -249,7 +283,8 @@ export async function add(componentName, options) {
249
283
  }
250
284
 
251
285
  const registrySpinner = ora("Checking registry").start();
252
- const queue = [componentName];
286
+ const roots = new Set(componentNames);
287
+ const queue = [...roots];
253
288
  const fetched = new Map();
254
289
  const npmDeps = new Map();
255
290
 
@@ -277,6 +312,7 @@ export async function add(componentName, options) {
277
312
  }
278
313
  }
279
314
  registrySpinner.succeed("Checking registry.");
315
+ const aliases = normalizeAliases(config.aliases);
280
316
  const isSrcLayout = config.tailwind?.css?.startsWith("src/") ?? false;
281
317
  const resolveTargetPath = (p) => {
282
318
  if (!isSrcLayout) {
@@ -288,8 +324,8 @@ export async function add(componentName, options) {
288
324
  return `src/${p}`;
289
325
  };
290
326
 
291
- const order = [...fetched.keys()].filter((n) => n !== componentName);
292
- order.push(componentName);
327
+ const order = [...fetched.keys()].filter((n) => !roots.has(n));
328
+ order.push(...roots);
293
329
  const written = [];
294
330
 
295
331
  const getMissingDependencies = async () => {
@@ -320,7 +356,7 @@ export async function add(componentName, options) {
320
356
  for (const name of order) {
321
357
  for (const file of fetched.get(name).files ?? []) {
322
358
  const targetFile = { ...file, path: resolveTargetPath(file.path) };
323
- const result = await writeFileSafely(targetFile, cwd, options);
359
+ const result = await writeFileSafely(targetFile, cwd, options, aliases);
324
360
  if (result) {
325
361
  written.push(result);
326
362
  }
@@ -1,6 +1,10 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
 
3
- import { assertSafePath, validateRegistryPayload } from "./add.js";
3
+ import {
4
+ assertSafePath,
5
+ rewriteImports,
6
+ validateRegistryPayload,
7
+ } from "./add.js";
4
8
 
5
9
  const unsignedPayload = {
6
10
  dependencies: ["@frakment/react@^0.0.3", "motion@^14.10.0"],
@@ -87,3 +91,41 @@ describe("registry target paths", () => {
87
91
  ).rejects.toThrow("Absolute path rejected");
88
92
  });
89
93
  });
94
+
95
+ describe("rewriteImports", () => {
96
+ const src = `import { Button } from "@/components/ui/button";
97
+ import { cn } from "@/lib/utils";
98
+ import { Card } from "@/components/card";
99
+ export { cn, Button, Card };
100
+ `;
101
+
102
+ it("is a no-op with default aliases", () => {
103
+ expect(rewriteImports(src, undefined)).toBe(src);
104
+ expect(rewriteImports(src, {})).toBe(src);
105
+ });
106
+
107
+ it("rewrites ui, components, and utils prefixes", () => {
108
+ const out = rewriteImports(src, {
109
+ components: "~/components",
110
+ ui: "~/ui",
111
+ utils: "~/lib/cn",
112
+ });
113
+ expect(out).toContain('from "~/ui/button"');
114
+ expect(out).toContain('from "~/lib/cn"');
115
+ expect(out).toContain('from "~/components/card"');
116
+ expect(out).not.toContain("@/components/ui/");
117
+ expect(out).not.toContain("@/lib/utils");
118
+ });
119
+
120
+ it("derives ui from components when ui is omitted", () => {
121
+ const out = rewriteImports(src, { components: "~/components" });
122
+ expect(out).toContain('from "~/components/ui/button"');
123
+ expect(out).toContain('from "~/components/card"');
124
+ });
125
+
126
+ it("does not rewrite unrelated @/lib paths", () => {
127
+ const input = 'import { springSoft } from "@/lib/animation";\n';
128
+ const out = rewriteImports(input, { utils: "~/lib/cn" });
129
+ expect(out).toBe(input);
130
+ });
131
+ });
@@ -135,28 +135,28 @@ export function cn(...inputs: ClassValue[]) {
135
135
  logger.log(" - lib/utils.ts");
136
136
  }
137
137
 
138
- if (!existsSync(path.join(cwd, "lib/animation.ts"))) {
138
+ if (existsSync(path.join(cwd, "lib/animation.ts"))) {
139
+ const existing = await readFile(
140
+ path.join(cwd, "lib/animation.ts"),
141
+ "utf-8"
142
+ );
143
+ if (!existing.includes("parseAnimation")) {
144
+ logger.warn(
145
+ "lib/animation.ts is outdated (missing animation DSL parser). Delete it and re-run `frakment init`."
146
+ );
147
+ }
148
+ } else {
139
149
  const s = ora("Creating lib/animation.ts").start();
140
150
  await mkdir(path.join(cwd, "lib"), { recursive: true });
141
- await writeFile(
142
- path.join(cwd, "lib/animation.ts"),
143
- `export const springSoft = { type: "spring", stiffness: 200, damping: 30 } as const
144
-
145
- export const easeInOut = [0.4, 0, 0.2, 1] as const
146
- export const easeInOutQuad = [0.45, 0, 0.55, 1] as const
147
- export const easeOutExpo = [0.16, 1, 0.3, 1] as const
148
- export const easeSmoothOut = [0.32, 0.72, 0, 1] as const
149
-
150
- export const durationFast = 0.2
151
- export const durationNormal = 0.3
152
- export const durationSlow = 0.45
153
-
154
- export const delayTight = 0.02
155
- `
151
+ const animation = await readFile(
152
+ path.join(TEMPLATES_DIR, "lib/animation.ts"),
153
+ "utf-8"
156
154
  );
155
+ await writeFile(path.join(cwd, "lib/animation.ts"), animation);
157
156
  s.succeed("Created 1 file:");
158
157
  logger.log(" - lib/animation.ts");
159
158
  }
159
+
160
160
  const pkgPath = path.join(cwd, "package.json");
161
161
  const pkgRaw = await readFile(pkgPath, "utf-8");
162
162
  const pkg = JSON.parse(pkgRaw.replace(/^\uFEFF/u, ""));
@@ -1,12 +1,243 @@
1
- export const SPRING_SMOOTH = {
1
+ // Design tokens
2
+ export const springSoft = {
2
3
  damping: 30,
3
4
  stiffness: 200,
4
5
  type: "spring",
5
6
  } as const;
6
7
 
7
- export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
8
- export const EASE_SMOOTH = [0.32, 0.72, 0, 1] as const;
9
- export const EASE_IN_OUT = [0.4, 0, 0.2, 1] as const;
8
+ export const easeInOut = [0.4, 0, 0.2, 1] as const;
9
+ export const easeInOutQuad = [0.45, 0, 0.55, 1] as const;
10
+ export const easeOutExpo = [0.16, 1, 0.3, 1] as const;
11
+ export const easeSmoothOut = [0.32, 0.72, 0, 1] as const;
10
12
 
11
- export const DURATION_FAST = 0.2;
12
- export const DURATION_SMOOTH = 0.45;
13
+ export const durationFast = 0.2;
14
+ export const durationNormal = 0.3;
15
+ export const durationSlow = 0.45;
16
+
17
+ export const delayTight = 0.02;
18
+
19
+ // Animation DSL parser
20
+ export interface AnimationKeyframes {
21
+ times?: number[];
22
+ values: number[];
23
+ }
24
+
25
+ export interface AnimationConfig {
26
+ keyframes?: AnimationKeyframes;
27
+ transition: Record<string, unknown>;
28
+ }
29
+
30
+ const NAMED_EASINGS: Record<string, readonly number[]> = {
31
+ easeInOut,
32
+ easeInOutQuad,
33
+ easeOutExpo,
34
+ easeSmoothOut,
35
+ };
36
+
37
+ const MOTION_NATIVE_EASINGS = new Set([
38
+ "linear",
39
+ "circIn",
40
+ "circOut",
41
+ "circInOut",
42
+ "backIn",
43
+ "backOut",
44
+ "backInOut",
45
+ "anticipate",
46
+ ]);
47
+
48
+ const DURATION_RE = /^(\d+(?:\.\d+)?)(ms|s)?$/;
49
+ const DELAY_RE = /^delay\(([^)]+)\)$/;
50
+ const REPEAT_RE = /^repeat\(([^)]+)\)$/;
51
+
52
+ function warn(message: string): void {
53
+ if (typeof console !== "undefined" && typeof console.warn === "function") {
54
+ console.warn(`[motion-dsl] ${message}`);
55
+ }
56
+ }
57
+
58
+ function toSeconds(raw: string): number | null {
59
+ const m = raw.trim().match(DURATION_RE);
60
+ if (!m) {
61
+ return null;
62
+ }
63
+ const n = Number.parseFloat(m[1]);
64
+ if (Number.isNaN(n)) {
65
+ return null;
66
+ }
67
+ return m[2] === "ms" ? n / 1000 : n;
68
+ }
69
+
70
+ function parseNumbers(raw: string): number[] {
71
+ return raw.split(",").map((s) => Number.parseFloat(s.trim()));
72
+ }
73
+
74
+ function tokenize(input: string): string[] {
75
+ const out: string[] = [];
76
+ let depth = 0;
77
+ let cur = "";
78
+ for (const ch of input) {
79
+ if (ch === "(" || ch === "[") {
80
+ depth++;
81
+ }
82
+ if (ch === ")" || ch === "]") {
83
+ depth--;
84
+ }
85
+ if (/\s/.test(ch) && depth === 0) {
86
+ if (cur) {
87
+ out.push(cur);
88
+ }
89
+ cur = "";
90
+ } else {
91
+ cur += ch;
92
+ }
93
+ }
94
+ if (cur) {
95
+ out.push(cur);
96
+ }
97
+ return out;
98
+ }
99
+
100
+ export function parseAnimation(input?: false | string): AnimationConfig | null {
101
+ if (input === undefined || input === false) {
102
+ return null;
103
+ }
104
+ if (typeof input !== "string") {
105
+ return null;
106
+ }
107
+
108
+ const trimmed = input.trim();
109
+ if (!trimmed) {
110
+ return null;
111
+ }
112
+
113
+ const tokens = tokenize(trimmed);
114
+ if (tokens.length === 0) {
115
+ return null;
116
+ }
117
+
118
+ const head = tokens[0];
119
+ let config: AnimationConfig;
120
+
121
+ if (head.startsWith("spring(")) {
122
+ const args = parseNumbers(head.slice(7, -1));
123
+ if (args.length !== 2 || args.some(Number.isNaN)) {
124
+ warn(`invalid spring: "${head}" - expected spring(stiffness, damping)`);
125
+ return null;
126
+ }
127
+ config = {
128
+ transition: { damping: args[1], stiffness: args[0], type: "spring" },
129
+ };
130
+ } else if (head.startsWith("inertia(")) {
131
+ const args = parseNumbers(head.slice(8, -1));
132
+ if (args.length !== 2 || args.some(Number.isNaN)) {
133
+ warn(
134
+ `invalid inertia: "${head}" - expected inertia(power, timeConstant)`
135
+ );
136
+ return null;
137
+ }
138
+ config = {
139
+ transition: { power: args[0], timeConstant: args[1], type: "inertia" },
140
+ };
141
+ } else if (head.startsWith("cubic-bezier(")) {
142
+ const args = parseNumbers(head.slice(13, -1));
143
+ if (args.length !== 4 || args.some(Number.isNaN)) {
144
+ warn(`invalid cubic-bezier: "${head}" - expected 4 numbers`);
145
+ return null;
146
+ }
147
+ config = {
148
+ transition: {
149
+ ease: args as [number, number, number, number],
150
+ type: "tween",
151
+ },
152
+ };
153
+ } else if (head.startsWith("steps(")) {
154
+ const args = parseNumbers(head.slice(6, -1));
155
+ if (args.length !== 1 || Number.isNaN(args[0])) {
156
+ warn(`invalid steps: "${head}" - expected steps(count)`);
157
+ return null;
158
+ }
159
+ config = { transition: { ease: "linear", type: "tween" } };
160
+ } else if (head.startsWith("keyframes(")) {
161
+ const inner = head.slice(10, -1);
162
+ const arrays = inner.match(/\[[^\]]*\]/g);
163
+ if (!arrays || arrays.length === 0) {
164
+ warn(
165
+ `invalid keyframes: "${head}" - expected keyframes([values], [times]?)`
166
+ );
167
+ return null;
168
+ }
169
+ const values = arrays[0]
170
+ .slice(1, -1)
171
+ .split(",")
172
+ .map((s) => Number.parseFloat(s.trim()));
173
+
174
+ if (values.some(Number.isNaN)) {
175
+ warn(`invalid keyframes values: "${arrays[0]}"`);
176
+ return null;
177
+ }
178
+ const times = arrays[1]
179
+ ? arrays[1]
180
+ .slice(1, -1)
181
+ .split(",")
182
+ .map((s) => Number.parseFloat(s.trim()))
183
+ : undefined;
184
+ if (times?.some(Number.isNaN)) {
185
+ warn(`invalid keyframes times: "${arrays[1]}"`);
186
+ return null;
187
+ }
188
+ config = { keyframes: { times, values }, transition: { type: "tween" } };
189
+ } else if (head in NAMED_EASINGS) {
190
+ config = {
191
+ transition: {
192
+ ease: [...NAMED_EASINGS[head]] as [number, number, number, number],
193
+ type: "tween",
194
+ },
195
+ };
196
+ } else if (MOTION_NATIVE_EASINGS.has(head)) {
197
+ config = { transition: { ease: head, type: "tween" } };
198
+ } else {
199
+ warn(`unknown easing or syntax: "${head}"`);
200
+ return null;
201
+ }
202
+
203
+ const isTween = config.transition.type === "tween";
204
+
205
+ for (const token of tokens.slice(1)) {
206
+ const dur = toSeconds(token);
207
+ if (dur !== null) {
208
+ if (isTween) {
209
+ (config.transition as { duration?: number }).duration = dur;
210
+ }
211
+ continue;
212
+ }
213
+ const delayMatch = token.match(DELAY_RE);
214
+ if (delayMatch) {
215
+ const d = toSeconds(delayMatch[1]);
216
+ if (d === null) {
217
+ warn(`invalid delay: "${token}"`);
218
+ continue;
219
+ }
220
+ (config.transition as { delay?: number }).delay = d;
221
+ continue;
222
+ }
223
+ const repeatMatch = token.match(REPEAT_RE);
224
+ if (repeatMatch) {
225
+ const v = repeatMatch[1].trim();
226
+ if (v === "infinite") {
227
+ (config.transition as { repeat?: number }).repeat =
228
+ Number.POSITIVE_INFINITY;
229
+ } else {
230
+ const n = Number.parseInt(v, 10);
231
+ if (Number.isNaN(n) || n < 0) {
232
+ warn(`invalid repeat: "${token}"`);
233
+ continue;
234
+ }
235
+ (config.transition as { repeat?: number }).repeat = n;
236
+ }
237
+ continue;
238
+ }
239
+ warn(`unexpected token: "${token}"`);
240
+ }
241
+
242
+ return config;
243
+ }