@smallpen/local-package 0.1.0-alpha.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,533 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import {
3
+ lstat,
4
+ mkdir,
5
+ mkdtemp,
6
+ rename,
7
+ rm,
8
+ writeFile,
9
+ } from "node:fs/promises";
10
+ import { basename, dirname, join, resolve } from "node:path";
11
+
12
+ import { canonicalJSON, SmallPenError } from "@smallpen/core";
13
+
14
+ import { openPackage } from "./local-package.mjs";
15
+ import { openWorkspace } from "./workspace.mjs";
16
+
17
+ function slug(value) {
18
+ return (
19
+ value
20
+ .normalize("NFKD")
21
+ .toLowerCase()
22
+ .replace(/[^a-z0-9]+/g, "_")
23
+ .replace(/^_+|_+$/g, "") || "item"
24
+ );
25
+ }
26
+
27
+ function contextAxis(axis) {
28
+ return {
29
+ defaultValue: slug(axis.defaultValue),
30
+ id: axis.id,
31
+ kind: axis.kind,
32
+ name: axis.name,
33
+ values: axis.values.map((value) => ({ id: slug(value), name: value })),
34
+ };
35
+ }
36
+
37
+ function tokenDescriptor(name) {
38
+ const path = name
39
+ .split(".")
40
+ .map((part) => slug(part))
41
+ .join(".");
42
+ const prefix = path.split(".")[0];
43
+ if (prefix === "color") return { path, type: "color", value: "#6750a4" };
44
+ if (prefix === "opacity") return { path, type: "opacity", value: 1 };
45
+ if (prefix === "radius") return { path, type: "border-radius", value: 8 };
46
+ if (prefix === "font") return { path, type: "font-family", value: "Inter" };
47
+ return {
48
+ path,
49
+ type: prefix === "spacing" ? "spacing" : "number",
50
+ value: prefix === "spacing" ? 8 : 0,
51
+ };
52
+ }
53
+
54
+ function setToken(root, descriptor, usedIds) {
55
+ const segments = descriptor.path.split(".");
56
+ let parent = root;
57
+ for (const segment of segments.slice(0, -1)) {
58
+ parent[segment] ??= {};
59
+ parent = parent[segment];
60
+ }
61
+ let id = `tok_${slug(descriptor.path)}`;
62
+ let suffix = 2;
63
+ while (usedIds.has(id)) {
64
+ id = `tok_${slug(descriptor.path)}_${suffix}`;
65
+ suffix += 1;
66
+ }
67
+ usedIds.add(id);
68
+ parent[segments.at(-1)] = {
69
+ $extensions: { smallpen: { id, visibility: "public" } },
70
+ $type: descriptor.type,
71
+ $value: descriptor.value,
72
+ };
73
+ }
74
+
75
+ function foundationFiles(proposal) {
76
+ const brief = proposal.brief;
77
+ const platformValues = brief.platforms.map((value) => ({
78
+ id: slug(value),
79
+ name: value,
80
+ }));
81
+ const axes = [
82
+ {
83
+ defaultValue: platformValues[0].id,
84
+ id: "axis_platform",
85
+ kind: "viewport",
86
+ name: "Platform",
87
+ values: platformValues,
88
+ },
89
+ ...brief.contextAxes
90
+ .filter(({ id }) => id !== "axis_platform")
91
+ .map(contextAxis),
92
+ ];
93
+ const defaults = Object.fromEntries(
94
+ axes.map((axis) => [axis.id, axis.defaultValue]),
95
+ );
96
+ const tokens = {};
97
+ const usedTokenIds = new Set();
98
+ for (const token of brief.initialTokens) {
99
+ setToken(tokens, tokenDescriptor(token), usedTokenIds);
100
+ }
101
+ const componentSets = brief.initialComponents.map((name) => {
102
+ const id = slug(name);
103
+ const nodeId = `node_${id}_root`;
104
+ return {
105
+ axes: [],
106
+ id: `cmp_${id}`,
107
+ name,
108
+ variants: [
109
+ {
110
+ id: `var_${id}_default`,
111
+ nodes: {
112
+ [nodeId]: {
113
+ children: [],
114
+ fills: [{ color: "#6750a4", type: "solid" }],
115
+ height: 40,
116
+ id: nodeId,
117
+ name,
118
+ type: "COMPONENT",
119
+ width: 120,
120
+ x: 0,
121
+ y: 0,
122
+ },
123
+ },
124
+ rootId: nodeId,
125
+ selection: {},
126
+ },
127
+ ],
128
+ visibility: "public",
129
+ };
130
+ });
131
+ return new Map([
132
+ [
133
+ "manifest.json",
134
+ {
135
+ entries: {
136
+ assets: [],
137
+ components: ["components/foundation.json"],
138
+ contexts: ["contexts/foundation.json"],
139
+ requirements: [],
140
+ scenarios: [],
141
+ screens: [],
142
+ tokens: ["tokens/foundation.json"],
143
+ },
144
+ formatVersion: 1,
145
+ name: proposal.packages.foundation.name,
146
+ packageId: proposal.packages.foundation.packageId,
147
+ role: "foundation",
148
+ },
149
+ ],
150
+ ["components/foundation.json", { componentSets }],
151
+ [
152
+ "contexts/foundation.json",
153
+ {
154
+ axes,
155
+ profiles: [
156
+ {
157
+ default: true,
158
+ id: `ctx_${slug(brief.platforms[0])}`,
159
+ name: brief.platforms[0],
160
+ values: defaults,
161
+ },
162
+ ],
163
+ },
164
+ ],
165
+ ["tokens/foundation.json", tokens],
166
+ ]);
167
+ }
168
+
169
+ function productFiles(proposal) {
170
+ const { brief, firstDesign } = proposal;
171
+ const rootId = `node_${slug(brief.firstScreen)}_root`;
172
+ const width = brief.platforms[0].toLowerCase().includes("mobile") ? 390 : 1024;
173
+ const height = brief.platforms[0].toLowerCase().includes("mobile") ? 844 : 768;
174
+ const defaults = {
175
+ axis_platform: slug(brief.platforms[0]),
176
+ ...Object.fromEntries(
177
+ brief.contextAxes
178
+ .filter(({ id }) => id !== "axis_platform")
179
+ .map((axis) => [axis.id, slug(axis.defaultValue)]),
180
+ ),
181
+ };
182
+ return new Map([
183
+ [
184
+ "manifest.json",
185
+ {
186
+ defaultScreenId: firstDesign.screenId,
187
+ dependencies: [
188
+ {
189
+ packageId: proposal.packages.foundation.packageId,
190
+ path: proposal.packages.foundation.directoryName,
191
+ },
192
+ ],
193
+ entries: {
194
+ assets: [],
195
+ components: [],
196
+ contexts: [],
197
+ requirements: ["requirements/product.json"],
198
+ scenarios: ["scenarios/product.json"],
199
+ screens: ["screens/first-design.json"],
200
+ tokens: ["tokens/product.json"],
201
+ },
202
+ formatVersion: 1,
203
+ name: proposal.packages.product.name,
204
+ packageId: proposal.packages.product.packageId,
205
+ role: "product",
206
+ },
207
+ ],
208
+ [
209
+ "requirements/product.json",
210
+ {
211
+ annotations: [],
212
+ flows: [
213
+ {
214
+ id: firstDesign.flowId,
215
+ interactionIds: [],
216
+ name: brief.firstJourney,
217
+ },
218
+ ],
219
+ requirements: [
220
+ {
221
+ id: firstDesign.requirementId,
222
+ links: [{ flowId: firstDesign.flowId, kind: "flow" }],
223
+ markdown: `${brief.purpose}\n\nFirst output: ${brief.firstOutput}`,
224
+ title: brief.firstJourney,
225
+ },
226
+ ],
227
+ },
228
+ ],
229
+ [
230
+ "scenarios/product.json",
231
+ {
232
+ scenarios: [
233
+ {
234
+ actions: [],
235
+ context: defaults,
236
+ expectedVisibleNodeIds: [rootId],
237
+ fixture: {},
238
+ id: firstDesign.scenarioId,
239
+ name: brief.firstScenario,
240
+ target: {
241
+ kind: "screen",
242
+ presentationId: firstDesign.presentationId,
243
+ screen: {
244
+ assetId: firstDesign.screenId,
245
+ packageId: proposal.packages.product.packageId,
246
+ },
247
+ },
248
+ viewport: { height, scale: 1, width },
249
+ },
250
+ ],
251
+ },
252
+ ],
253
+ [
254
+ "screens/first-design.json",
255
+ {
256
+ basePresentationId: firstDesign.presentationId,
257
+ counterparts: [],
258
+ id: firstDesign.screenId,
259
+ name: firstDesign.name,
260
+ presentations: [
261
+ {
262
+ id: firstDesign.presentationId,
263
+ interactions: [],
264
+ name: brief.platforms[0],
265
+ nodes: {
266
+ [rootId]: {
267
+ children: [],
268
+ fills: [{ color: "#ffffff", type: "solid" }],
269
+ height,
270
+ id: rootId,
271
+ name: firstDesign.name,
272
+ type: "FRAME",
273
+ width,
274
+ x: 0,
275
+ y: 0,
276
+ },
277
+ },
278
+ platform: brief.platforms[0],
279
+ rootId,
280
+ viewport: { height, width },
281
+ },
282
+ ],
283
+ },
284
+ ],
285
+ [
286
+ "tokens/product.json",
287
+ {
288
+ activeSetIds: [],
289
+ activeThemeIds: ["theme_default"],
290
+ id: "tlib_product",
291
+ sets: [
292
+ {
293
+ description: "",
294
+ id: "tset_theme_default",
295
+ name: "Theme/Default",
296
+ tokens: [],
297
+ },
298
+ ],
299
+ themes: [
300
+ {
301
+ description: "",
302
+ externalId: "",
303
+ group: "Theme",
304
+ id: "theme_default",
305
+ isSource: false,
306
+ name: "Default",
307
+ setIds: ["tset_theme_default"],
308
+ },
309
+ ],
310
+ },
311
+ ],
312
+ ]);
313
+ }
314
+
315
+ async function writeFiles(packagePath, files) {
316
+ for (const [entry, value] of files) {
317
+ const output = join(packagePath, entry);
318
+ await mkdir(dirname(output), { recursive: true });
319
+ await writeFile(output, canonicalJSON(value), "utf8");
320
+ }
321
+ }
322
+
323
+ async function pathExists(path) {
324
+ try {
325
+ await lstat(path);
326
+ return true;
327
+ } catch (error) {
328
+ if (error?.code === "ENOENT") return false;
329
+ throw error;
330
+ }
331
+ }
332
+
333
+ function blankPackageFiles(name) {
334
+ const suffix = randomUUID().replaceAll("-", "");
335
+ const screenId = `scr_${suffix}`;
336
+ const presentationId = `pres_${suffix}_desktop`;
337
+ const rootId = `node_${suffix}_root`;
338
+ return new Map([
339
+ [
340
+ "manifest.json",
341
+ {
342
+ defaultScreenId: screenId,
343
+ entries: {
344
+ assets: [],
345
+ components: [],
346
+ contexts: [],
347
+ requirements: [],
348
+ scenarios: [],
349
+ screens: ["screens/page-1.json"],
350
+ tokens: ["tokens/tokens.json"],
351
+ },
352
+ formatVersion: 1,
353
+ name,
354
+ packageId: `pkg_${randomUUID().replaceAll("-", "")}`,
355
+ role: "foundation",
356
+ },
357
+ ],
358
+ [
359
+ "screens/page-1.json",
360
+ {
361
+ basePresentationId: presentationId,
362
+ counterparts: [],
363
+ id: screenId,
364
+ name: "Page 1",
365
+ presentations: [
366
+ {
367
+ id: presentationId,
368
+ interactions: [],
369
+ name: "Desktop",
370
+ nodes: {
371
+ [rootId]: {
372
+ children: [],
373
+ fills: [{ color: "#ffffff", type: "solid" }],
374
+ height: 768,
375
+ id: rootId,
376
+ name: "Board",
377
+ type: "FRAME",
378
+ width: 1024,
379
+ x: 0,
380
+ y: 0,
381
+ },
382
+ },
383
+ platform: "desktop",
384
+ rootId,
385
+ viewport: { height: 768, width: 1024 },
386
+ },
387
+ ],
388
+ },
389
+ ],
390
+ [
391
+ "tokens/tokens.json",
392
+ {
393
+ activeSetIds: [],
394
+ activeThemeIds: ["theme_default"],
395
+ id: "tlib_default",
396
+ sets: [
397
+ {
398
+ description: "",
399
+ id: "tset_theme_default",
400
+ name: "Theme/Default",
401
+ tokens: [],
402
+ },
403
+ ],
404
+ themes: [
405
+ {
406
+ description: "",
407
+ externalId: "",
408
+ group: "Theme",
409
+ id: "theme_default",
410
+ isSource: false,
411
+ name: "Default",
412
+ setIds: ["tset_theme_default"],
413
+ },
414
+ ],
415
+ },
416
+ ],
417
+ ]);
418
+ }
419
+
420
+ export async function createBlankPackage(packageLocator, options = {}) {
421
+ const packagePath = resolve(packageLocator);
422
+ if (!packagePath.toLowerCase().endsWith(".smallpen")) {
423
+ throw new SmallPenError(
424
+ "invalid_package_path",
425
+ "SmallPen Package must use the .smallpen extension",
426
+ { packagePath },
427
+ );
428
+ }
429
+ if (await pathExists(packagePath)) {
430
+ throw new SmallPenError(
431
+ "package_already_exists",
432
+ `Package target already exists: ${packagePath}`,
433
+ { packagePath },
434
+ );
435
+ }
436
+ const defaultName = basename(packagePath).replace(/\.smallpen$/i, "");
437
+ const name = String(options.name ?? defaultName).trim() || "Untitled";
438
+ if (name.length > 255) {
439
+ throw new SmallPenError(
440
+ "invalid_package_name",
441
+ "Package name must contain at most 255 characters",
442
+ );
443
+ }
444
+ const parent = dirname(packagePath);
445
+ await mkdir(parent, { recursive: true });
446
+ const transactionPath = await mkdtemp(join(parent, ".smallpen-create-"));
447
+ const candidatePath = join(transactionPath, basename(packagePath));
448
+ try {
449
+ await writeFiles(candidatePath, blankPackageFiles(name));
450
+ await openPackage(candidatePath);
451
+ await rename(candidatePath, packagePath);
452
+ await rm(transactionPath, { force: true, recursive: true });
453
+ return { packagePath, status: "created" };
454
+ } catch (error) {
455
+ await rm(transactionPath, { force: true, recursive: true });
456
+ throw error;
457
+ }
458
+ }
459
+
460
+ export async function initializeWorkspace(workspaceLocator, proposal, options = {}) {
461
+ if (options.confirmed !== true) {
462
+ throw new SmallPenError(
463
+ "initialization_confirmation_required",
464
+ "Initialization Proposal must be explicitly confirmed",
465
+ {
466
+ nextOperations: [
467
+ {
468
+ args: { confirm: true, proposal },
469
+ operation: "smallpen.init.confirm",
470
+ },
471
+ ],
472
+ },
473
+ );
474
+ }
475
+ const workspacePath = resolve(workspaceLocator);
476
+ if (await pathExists(workspacePath)) {
477
+ throw new SmallPenError(
478
+ "workspace_already_exists",
479
+ `Initialization target already exists: ${workspacePath}`,
480
+ { workspacePath },
481
+ );
482
+ }
483
+ const parent = dirname(workspacePath);
484
+ await mkdir(parent, { recursive: true });
485
+ const transactionPath = await mkdtemp(join(parent, ".smallpen-init-"));
486
+ const candidatePath = join(transactionPath, basename(workspacePath));
487
+ const foundationPath = join(
488
+ candidatePath,
489
+ proposal.packages.foundation.directoryName,
490
+ );
491
+ const productPath = join(candidatePath, proposal.packages.product.directoryName);
492
+ try {
493
+ await writeFiles(foundationPath, foundationFiles(proposal));
494
+ await writeFiles(productPath, productFiles(proposal));
495
+ await mkdir(join(candidatePath, "initialization"), { recursive: true });
496
+ await writeFile(
497
+ join(candidatePath, "initialization", "brief.json"),
498
+ canonicalJSON(proposal.brief),
499
+ "utf8",
500
+ );
501
+ await writeFile(
502
+ join(candidatePath, "initialization", "proposal.json"),
503
+ canonicalJSON(proposal),
504
+ "utf8",
505
+ );
506
+ await openPackage(foundationPath);
507
+ await openWorkspace(productPath);
508
+ await rename(candidatePath, workspacePath);
509
+ await rm(transactionPath, { force: true, recursive: true });
510
+ return {
511
+ foundationPath: join(
512
+ workspacePath,
513
+ proposal.packages.foundation.directoryName,
514
+ ),
515
+ initializationBriefPath: join(
516
+ workspacePath,
517
+ "initialization",
518
+ "brief.json",
519
+ ),
520
+ initializationProposalPath: join(
521
+ workspacePath,
522
+ "initialization",
523
+ "proposal.json",
524
+ ),
525
+ productPath: join(workspacePath, proposal.packages.product.directoryName),
526
+ status: "initialized",
527
+ workspacePath,
528
+ };
529
+ } catch (error) {
530
+ await rm(transactionPath, { force: true, recursive: true });
531
+ throw error;
532
+ }
533
+ }