@jxsuite/create 1.3.14 → 1.3.17

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/cli-args.ts CHANGED
@@ -17,20 +17,24 @@ export interface CliArgs {
17
17
  export function parseCliArgs(argv: string[]): CliArgs {
18
18
  const positional: string[] = [];
19
19
  let template: string | undefined;
20
- for (let i = 0; i < argv.length; i += 1) {
21
- // Safe: i < argv.length. The cast sheds `undefined` from noUncheckedIndexedAccess.
22
- const arg = argv[i] as string;
20
+ // One iterator drives the loop, so `--template` can consume its value token with next().
21
+ const tokens = argv.values();
22
+ for (const arg of tokens) {
23
23
  if (arg === "--template") {
24
- template = argv[i + 1];
25
- i += 1;
24
+ template = tokens.next().value;
26
25
  } else if (arg.startsWith("--template=")) {
27
26
  template = arg.slice("--template=".length);
28
27
  } else {
29
28
  positional.push(arg);
30
29
  }
31
30
  }
32
- return {
33
- ...(positional[0] !== undefined ? { dest: positional[0] } : {}),
34
- ...(template !== undefined ? { template } : {}),
35
- };
31
+ const args: CliArgs = {};
32
+ const [dest] = positional;
33
+ if (dest !== undefined) {
34
+ args.dest = dest;
35
+ }
36
+ if (template !== undefined) {
37
+ args.template = template;
38
+ }
39
+ return args;
36
40
  }
package/generate.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /** Shared project generation logic. Used by both the CLI scaffolder and the Studio server endpoint. */
2
2
 
3
3
  import { chmod, cp, lstat, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
4
- import { adapterNeedsWrangler, buildWranglerJsonc } from "./scaffold";
4
+ import { adapterNeedsWrangler, buildWranglerJsonc, isJsonObject, parseJson } from "./scaffold";
5
5
  import { basename, join, relative, sep } from "node:path";
6
6
 
7
7
  import { Buffer } from "node:buffer";
@@ -9,6 +9,7 @@ import { Buffer } from "node:buffer";
9
9
  import { existsSync } from "node:fs";
10
10
  import { mediaForTemplate } from "./templates";
11
11
  import type { TemplateId } from "./templates";
12
+ import type { JsonObject } from "./scaffold";
12
13
 
13
14
  import TEMPLATE_VERSIONS from "./template-versions.json";
14
15
 
@@ -183,13 +184,7 @@ async function populateProject(destPath: string, opts: ProjectOptions) {
183
184
  } = opts;
184
185
 
185
186
  if (starter && starter !== "blank") {
186
- await scaffoldFromStarter(destPath, starter, {
187
- adapter,
188
- description,
189
- name,
190
- url,
191
- ...(design !== undefined ? { design } : {}),
192
- });
187
+ await scaffoldFromStarter(destPath, starter, { adapter, description, design, name, url });
193
188
  return;
194
189
  }
195
190
 
@@ -199,7 +194,7 @@ async function populateProject(destPath: string, opts: ProjectOptions) {
199
194
 
200
195
  const projectJson = buildProjectJson({ adapter, description, name, template, url });
201
196
  if (design) {
202
- applyDesign(projectJson as unknown as Record<string, unknown>, design, { starter: false });
197
+ applyDesign(projectJson, design, { starter: false });
203
198
  }
204
199
  const packageJson = buildPackageJson({ adapter, description, name });
205
200
 
@@ -256,7 +251,7 @@ async function scaffoldFromStarter(
256
251
  description?: string;
257
252
  url?: string;
258
253
  adapter?: ProjectOptions["adapter"];
259
- design?: DesignOptions;
254
+ design?: DesignOptions | undefined;
260
255
  },
261
256
  ) {
262
257
  const { name, description = "", url = "", adapter = "static", design } = opts;
@@ -279,14 +274,17 @@ async function scaffoldFromStarter(
279
274
  // Re-stamp project.json with the new project's identity, keeping the starter's design tokens,
280
275
  // Content types, image pipeline, and head.
281
276
  const projectPath = join(destPath, "project.json");
282
- const project = JSON.parse(await readFile(projectPath, "utf8")) as Record<string, unknown>;
277
+ const project = parseJson(await readFile(projectPath, "utf8"));
278
+ if (!isJsonObject(project)) {
279
+ throw new TypeError(`Starter "${starter}" has a project.json that is not a JSON object`);
280
+ }
283
281
  project.name = name;
284
282
  if (url) {
285
283
  project.url = url;
286
284
  }
287
285
  applyDescription(project, description);
288
- if (adapter && adapter !== "static") {
289
- const build = (project.build as Record<string, unknown> | undefined) ?? {};
286
+ if (adapter !== "static") {
287
+ const build = isJsonObject(project.build) ? project.build : {};
290
288
  build.adapter = adapter;
291
289
  project.build = build;
292
290
  }
@@ -320,19 +318,19 @@ async function scaffoldFromStarter(
320
318
  * (falling back to the plain CSS property where noted), and is otherwise skipped silently rather
321
319
  * than fighting the starter's layered styles. `--color-primary-hover` is intentionally left alone.
322
320
  *
323
- * @param {Record<string, unknown>} project — parsed project.json, mutated in place
321
+ * @param {JsonObject} project — parsed project.json, mutated in place
324
322
  * @param {DesignOptions} design
325
323
  * @param {{ starter: boolean }} mode — starter clone vs. blank/template scaffold
326
324
  */
327
325
  function applyDesign(
328
- project: Record<string, unknown>,
326
+ project: JsonObject,
329
327
  design: DesignOptions,
330
328
  { starter }: { starter: boolean },
331
329
  ) {
332
330
  const { accent, background, text, bodyFont, headingFont, media } = design;
333
- const style = project.style as Record<string, unknown> | undefined;
331
+ const { style } = project;
334
332
 
335
- if (style) {
333
+ if (isJsonObject(style)) {
336
334
  if (accent && (!starter || "--color-primary" in style)) {
337
335
  style["--color-primary"] = accent;
338
336
  }
@@ -394,25 +392,56 @@ async function writeLogo(destPath: string, logo: { name: string; base64: string
394
392
  * Set the `<meta name="description">` content in a project's `$head` (or leave it untouched when
395
393
  * the starter has no such tag or no description was supplied).
396
394
  *
397
- * @param {Record<string, unknown>} project
395
+ * @param {JsonObject} project
398
396
  * @param {string} description
399
397
  */
400
- function applyDescription(project: Record<string, unknown>, description: string) {
398
+ function applyDescription(project: JsonObject, description: string) {
401
399
  if (!description || !Array.isArray(project.$head)) {
402
400
  return;
403
401
  }
404
- for (const tag of project.$head as { tagName?: string; attributes?: Record<string, unknown> }[]) {
405
- if (tag.tagName === "meta" && tag.attributes?.name === "description") {
402
+ for (const tag of project.$head) {
403
+ if (
404
+ isJsonObject(tag) &&
405
+ tag.tagName === "meta" &&
406
+ isJsonObject(tag.attributes) &&
407
+ tag.attributes.name === "description"
408
+ ) {
406
409
  tag.attributes.content = description;
407
410
  }
408
411
  }
409
412
  }
410
413
 
411
414
  /**
412
- * Build a wrangler.jsonc for Cloudflare adapters.
413
- *
414
- * @param {{ slug: string; adapter: string }} opts
415
+ * The scaffolded project.json `build` section. Non-static adapters add `adapter` through the
416
+ * JsonObject index signature; an optional `adapter?: string` member would conflict with that index
417
+ * under configs without `exactOptionalPropertyTypes` (packages/desktop's typecheck).
415
418
  */
419
+ interface BuildSection extends JsonObject {
420
+ outDir: string;
421
+ trailingSlash: string;
422
+ }
423
+
424
+ /** Scaffolded package.json `dependencies`; `hono` joins for adapters with a server worker. */
425
+ interface Dependencies {
426
+ "@jxsuite/parser": string;
427
+ hono?: string;
428
+ }
429
+
430
+ /** Scaffolded package.json `devDependencies`; `wrangler` joins for Cloudflare adapters. */
431
+ interface DevDependencies {
432
+ "@jxsuite/compiler": string;
433
+ "@jxsuite/runtime": string;
434
+ "@jxsuite/server": string;
435
+ wrangler?: string;
436
+ }
437
+
438
+ /** Scaffolded package.json `scripts`; `deploy` joins for Cloudflare adapters. */
439
+ interface PackageScripts {
440
+ build: string;
441
+ dev: string;
442
+ preview: string;
443
+ deploy?: string;
444
+ }
416
445
 
417
446
  /** @param {ProjectOptions} opts */
418
447
  function buildProjectJson({ name, description, url, adapter, template = "blank" }: ProjectOptions) {
@@ -444,7 +473,7 @@ function buildProjectJson({ name, description, url, adapter, template = "blank"
444
473
  });
445
474
  }
446
475
 
447
- const build: { outDir: string; trailingSlash: string; adapter?: string } = {
476
+ const build: BuildSection = {
448
477
  outDir: "./dist",
449
478
  trailingSlash: "always",
450
479
  };
@@ -502,17 +531,17 @@ function buildPackageJson({
502
531
  // (release-please-config.json extra-files). Hardcoding them here is what left every scaffolded
503
532
  // Project asking for a compiler version that was never published. Never hand-edit the
504
533
  // Map; `bun run templates:sync` is the fixer.
505
- const dependencies: Record<string, string> = {
534
+ const dependencies: Dependencies = {
506
535
  "@jxsuite/parser": TEMPLATE_VERSIONS.parser,
507
536
  };
508
- const devDependencies: Record<string, string> = {
537
+ const devDependencies: DevDependencies = {
509
538
  "@jxsuite/compiler": TEMPLATE_VERSIONS.compiler,
510
539
  "@jxsuite/runtime": TEMPLATE_VERSIONS.runtime,
511
540
  // The `dev` script spawns @jxsuite/server's dev entry (see jx dev in the compiler CLI)
512
541
  "@jxsuite/server": TEMPLATE_VERSIONS.server,
513
542
  };
514
543
 
515
- const scripts: Record<string, string> = {
544
+ const scripts: PackageScripts = {
516
545
  build: "jx build",
517
546
  dev: "jx dev",
518
547
  preview: "jx preview",
package/index.ts CHANGED
@@ -77,14 +77,14 @@ Deployment adapter:
77
77
  const adapterChoice = await rl.question("Adapter [1]: ");
78
78
  rl.close();
79
79
 
80
- const adapterMap: Record<string, ProjectOptions["adapter"]> = {
81
- 1: "static",
82
- 2: "cloudflare-pages",
83
- 3: "node",
84
- 4: "bun",
85
- 5: "cloudflare-workers",
86
- };
87
- const adapter = adapterMap[adapterChoice] || "static";
80
+ const adapterMap = new Map<string, NonNullable<ProjectOptions["adapter"]>>([
81
+ ["1", "static"],
82
+ ["2", "cloudflare-pages"],
83
+ ["3", "node"],
84
+ ["4", "bun"],
85
+ ["5", "cloudflare-workers"],
86
+ ]);
87
+ const adapter = adapterMap.get(adapterChoice) ?? "static";
88
88
 
89
89
  await generateProject(destPath, { adapter, description, name, starter, template, url });
90
90
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/create",
3
- "version": "1.3.14",
3
+ "version": "1.3.17",
4
4
  "description": "Scaffold a new Jx project",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -37,6 +37,6 @@
37
37
  "test:coverage": "bun test --isolate --coverage"
38
38
  },
39
39
  "dependencies": {
40
- "@jxsuite/starters": "^2.0.1"
40
+ "@jxsuite/starters": "^3.0.1"
41
41
  }
42
42
  }
package/scaffold.ts CHANGED
@@ -6,6 +6,55 @@
6
6
 
7
7
  const CF_ADAPTERS = new Set(["cloudflare-pages", "cloudflare-workers"]);
8
8
 
9
+ /** A JSON primitive. */
10
+ export type JsonPrimitive = string | number | boolean | null;
11
+ /** Any value `JSON.parse` can produce, and `JSON.stringify` can write back unchanged. */
12
+ export type JsonValue = JsonPrimitive | JsonValue[] | JsonObject;
13
+ /** A JSON object: wrangler.jsonc, project.json and connector binding fragments are all one. */
14
+ export interface JsonObject {
15
+ [key: string]: JsonValue;
16
+ }
17
+
18
+ /** The result of patching a wrangler.jsonc: the new text, and whether it was rewritten at all. */
19
+ export interface WranglerPatch {
20
+ content: string;
21
+ patched: boolean;
22
+ }
23
+
24
+ /** A fragment array entry keyed by its `binding` name (`{ binding: "DB", database_id }`). */
25
+ interface BindingEntry extends JsonObject {
26
+ binding: string;
27
+ }
28
+
29
+ /** Parse JSON text. Throws the `SyntaxError` `JSON.parse` raises for text that is not JSON. */
30
+ export function parseJson(text: string): JsonValue {
31
+ // SAFETY: JSON.parse without a reviver can only build objects, arrays, strings, finite numbers,
32
+ // Booleans and null out of JSON text, which is exactly the JsonValue union.
33
+ return JSON.parse(text) as JsonValue;
34
+ }
35
+
36
+ /** True for a JSON object; false for arrays, primitives, null and a missing value. */
37
+ export function isJsonObject(value: JsonValue | undefined): value is JsonObject {
38
+ return value !== null && typeof value === "object" && !Array.isArray(value);
39
+ }
40
+
41
+ function isBindingEntry(value: JsonValue): value is BindingEntry {
42
+ return isJsonObject(value) && typeof value.binding === "string";
43
+ }
44
+
45
+ /**
46
+ * Parse a wrangler.jsonc that can be patched: undefined when it is comment-bearing JSONC (which
47
+ * `JSON.parse` rejects) or its top level is not an object, so callers leave the file alone.
48
+ */
49
+ function parseWranglerObject(text: string): JsonObject | undefined {
50
+ try {
51
+ const parsed = parseJson(text);
52
+ return isJsonObject(parsed) ? parsed : undefined;
53
+ } catch {
54
+ return undefined;
55
+ }
56
+ }
57
+
9
58
  /** True when the adapter deploys through wrangler (needs a wrangler.jsonc). */
10
59
  export function adapterNeedsWrangler(adapter: string | undefined): boolean {
11
60
  return Boolean(adapter && CF_ADAPTERS.has(adapter));
@@ -51,24 +100,24 @@ export function buildWranglerJsonc({ slug, adapter }: { slug: string; adapter: s
51
100
  * loss.
52
101
  *
53
102
  * @param {string | null} existing - Current wrangler.jsonc content, or null when absent
54
- * @param {Record<string, unknown>[]} fragments - Binding fragments, applied in order
55
- * @returns {{ content: string; patched: boolean }}
103
+ * @param {JsonObject[]} fragments - Binding fragments, applied in order
104
+ * @returns {WranglerPatch}
56
105
  */
57
106
  export function applyBindingFragments(
58
107
  existing: string | null,
59
- fragments: Record<string, unknown>[],
60
- ): { content: string; patched: boolean } {
108
+ fragments: JsonObject[],
109
+ ): WranglerPatch {
61
110
  if (fragments.length === 0) {
62
111
  return { content: existing ?? "", patched: false };
63
112
  }
64
- let config: Record<string, unknown> = {};
113
+ let config: JsonObject = {};
65
114
  if (existing) {
66
- try {
67
- config = JSON.parse(existing) as Record<string, unknown>;
68
- } catch {
69
- // Comment-bearing JSONC — leave the user's file alone.
115
+ const parsed = parseWranglerObject(existing);
116
+ if (parsed === undefined) {
117
+ // Comment-bearing JSONC (or a non-object top level) — leave the user's file alone.
70
118
  return { content: existing, patched: false };
71
119
  }
120
+ config = parsed;
72
121
  }
73
122
  for (const fragment of fragments) {
74
123
  mergeBindingValue(config, fragment);
@@ -77,15 +126,18 @@ export function applyBindingFragments(
77
126
  }
78
127
 
79
128
  /** Recursively merge one fragment object into the target, per the applyBindingFragments rules. */
80
- function mergeBindingValue(target: Record<string, unknown>, fragment: Record<string, unknown>) {
129
+ function mergeBindingValue(target: JsonObject, fragment: JsonObject) {
81
130
  for (const [key, incoming] of Object.entries(fragment)) {
82
- const current = target[key];
131
+ /* A key the target lacks reads as undefined. Spelled out rather than left to
132
+ noUncheckedIndexedAccess, which the inferred program this file is linted in does not set
133
+ (packages/create/tsconfig.json does not include it). */
134
+ const current = key in target ? target[key] : undefined;
83
135
  if (Array.isArray(incoming)) {
84
136
  target[key] = mergeBindingArrays(Array.isArray(current) ? current : [], incoming);
85
137
  continue;
86
138
  }
87
- if (isPlainObject(incoming)) {
88
- if (isPlainObject(current)) {
139
+ if (isJsonObject(incoming)) {
140
+ if (isJsonObject(current)) {
89
141
  mergeBindingValue(current, incoming);
90
142
  } else if (current === undefined) {
91
143
  target[key] = structuredClone(incoming);
@@ -99,10 +151,10 @@ function mergeBindingValue(target: Record<string, unknown>, fragment: Record<str
99
151
  }
100
152
 
101
153
  /** Merge fragment array entries into an existing array, keyed by each entry's `binding`. */
102
- function mergeBindingArrays(current: unknown[], incoming: unknown[]): unknown[] {
154
+ function mergeBindingArrays(current: JsonValue[], incoming: JsonValue[]): JsonValue[] {
103
155
  const merged = [...current];
104
156
  for (const entry of incoming) {
105
- if (!isPlainObject(entry) || typeof entry.binding !== "string") {
157
+ if (!isBindingEntry(entry)) {
106
158
  // Non-binding-keyed values: append when missing (dedupe by deep equality).
107
159
  const exists = merged.some((m) => JSON.stringify(m) === JSON.stringify(entry));
108
160
  if (!exists) {
@@ -110,23 +162,19 @@ function mergeBindingArrays(current: unknown[], incoming: unknown[]): unknown[]
110
162
  }
111
163
  continue;
112
164
  }
113
- const index = merged.findIndex(
114
- (m) => isPlainObject(m) && (m as Record<string, unknown>).binding === entry.binding,
115
- );
116
- if (index === -1) {
117
- merged.push(structuredClone(entry));
118
- } else {
165
+ const index = merged.findIndex((m) => isJsonObject(m) && m.binding === entry.binding);
166
+ // Undefined when no entry matched (index -1), so a miss appends.
167
+ const match = merged[index];
168
+ if (isJsonObject(match)) {
119
169
  // Fragment values win for connector-owned keys; user-added extra keys survive.
120
- merged[index] = { ...(merged[index] as Record<string, unknown>), ...entry };
170
+ merged[index] = { ...match, ...entry };
171
+ } else {
172
+ merged.push(structuredClone(entry));
121
173
  }
122
174
  }
123
175
  return merged;
124
176
  }
125
177
 
126
- function isPlainObject(value: unknown): value is Record<string, unknown> {
127
- return value !== null && typeof value === "object" && !Array.isArray(value);
128
- }
129
-
130
178
  /**
131
179
  * Patch an existing wrangler.jsonc for an adapter change (or a Pages project rename), touching only
132
180
  * the adapter-shape keys and preserving everything else the user added. Regenerates from scratch
@@ -137,15 +185,13 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
137
185
  export function updateWranglerConfig(
138
186
  existing: string | null,
139
187
  { slug, adapter }: { slug: string; adapter: string },
140
- ): { content: string; patched: boolean } {
188
+ ): WranglerPatch {
141
189
  if (!existing) {
142
190
  return { content: buildWranglerJsonc({ adapter, slug }), patched: true };
143
191
  }
144
- let config: Record<string, unknown>;
145
- try {
146
- config = JSON.parse(existing) as Record<string, unknown>;
147
- } catch {
148
- // Comment-bearing JSONC — leave the user's file alone.
192
+ const config = parseWranglerObject(existing);
193
+ if (config === undefined) {
194
+ // Comment-bearing JSONC (or a non-object top level) — leave the user's file alone.
149
195
  return { content: existing, patched: false };
150
196
  }
151
197
  config["name"] = slug;
@@ -1,6 +1,6 @@
1
1
  {
2
- "parser": "^2.0.1",
3
- "compiler": "^5.1.2",
4
- "runtime": "^4.1.0",
5
- "server": "^4.5.0"
2
+ "parser": "^4.0.1",
3
+ "compiler": "^6.0.2",
4
+ "runtime": "^5.1.0",
5
+ "server": "^6.0.1"
6
6
  }