@jxsuite/create 0.35.0 → 1.0.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/generate.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /** Shared project generation logic. Used by both the CLI scaffolder and the Studio server endpoint. */
2
2
 
3
3
  import { cp, mkdir, readFile, writeFile } from "node:fs/promises";
4
+ import { adapterNeedsWrangler, buildWranglerJsonc } from "./scaffold";
4
5
  import { basename, join, relative, sep } from "node:path";
5
6
 
6
7
  import { Buffer } from "node:buffer";
@@ -49,8 +50,6 @@ export interface ProjectOptions {
49
50
  design?: DesignOptions;
50
51
  }
51
52
 
52
- const CF_ADAPTERS = new Set(["cloudflare-pages", "cloudflare-workers"]);
53
-
54
53
  // Paths never copied out of a starter tree into a fresh project: build artifacts and the
55
54
  // Authoring-only Pexels fetch manifest.
56
55
  const STARTER_EXCLUDE = new Set([
@@ -117,7 +116,7 @@ export async function generateProject(destPath: string, opts: ProjectOptions) {
117
116
  cp(join(TEMPLATE_DIR, "pages"), join(destPath, "pages"), { recursive: true }),
118
117
  ];
119
118
 
120
- if (adapter && CF_ADAPTERS.has(adapter)) {
119
+ if (adapterNeedsWrangler(adapter)) {
121
120
  const wranglerJsonc = buildWranglerJsonc({ adapter, slug: packageJson.name });
122
121
  writes.push(writeFile(join(destPath, "wrangler.jsonc"), wranglerJsonc));
123
122
  }
@@ -198,7 +197,7 @@ async function scaffoldFromStarter(
198
197
  const packageJson = buildPackageJson({ adapter, description, name });
199
198
  await writeFile(join(destPath, "package.json"), `${JSON.stringify(packageJson, null, " ")}\n`);
200
199
 
201
- if (adapter && CF_ADAPTERS.has(adapter)) {
200
+ if (adapterNeedsWrangler(adapter)) {
202
201
  const wranglerJsonc = buildWranglerJsonc({ adapter, slug: packageJson.name });
203
202
  await writeFile(join(destPath, "wrangler.jsonc"), wranglerJsonc);
204
203
  }
@@ -311,28 +310,6 @@ function applyDescription(project: Record<string, unknown>, description: string)
311
310
  *
312
311
  * @param {{ slug: string; adapter: string }} opts
313
312
  */
314
- function buildWranglerJsonc({ slug, adapter }: { slug: string; adapter: string }) {
315
- const compatibilityDate = new Date().toISOString().slice(0, 10);
316
-
317
- // Nodejs_compat: server functions routinely pull in Node-flavored npm packages
318
- const config =
319
- adapter === "cloudflare-workers"
320
- ? {
321
- assets: { binding: "ASSETS", directory: "./dist" },
322
- compatibility_date: compatibilityDate,
323
- compatibility_flags: ["nodejs_compat"],
324
- main: "./dist/worker.js",
325
- name: slug,
326
- }
327
- : {
328
- compatibility_date: compatibilityDate,
329
- compatibility_flags: ["nodejs_compat"],
330
- name: slug,
331
- pages_build_output_dir: "./dist",
332
- };
333
-
334
- return `${JSON.stringify(config, null, "\t")}\n`;
335
- }
336
313
 
337
314
  /** @param {ProjectOptions} opts */
338
315
  function buildProjectJson({ name, description, url, adapter, template = "blank" }: ProjectOptions) {
@@ -375,13 +352,19 @@ function buildProjectJson({ name, description, url, adapter, template = "blank"
375
352
  }
376
353
 
377
354
  return {
355
+ // The generated per-project schema binding; written by `jx schema` (specs/extensions.md §5.2).
356
+ $schema: "./project.schema.json",
378
357
  $head,
379
358
  $media: mediaForTemplate(template),
380
359
  build,
360
+ // The scaffolded pages/index.md needs the parser extension's Markdown format; `content` is the
361
+ // Parser-owned section ready for the first collection.
362
+ content: {},
381
363
  defaults: {
382
364
  lang: "en",
383
365
  layout: "./layouts/base.json",
384
366
  },
367
+ extensions: ["@jxsuite/parser"],
385
368
  name,
386
369
  style: {
387
370
  color: "#1a1a1a",
@@ -409,7 +392,11 @@ function buildPackageJson({
409
392
  .replaceAll(/[^a-z0-9]+/g, "-")
410
393
  .replaceAll(/^-|-$/g, "");
411
394
 
412
- const dependencies: Record<string, string> = {};
395
+ // Projects own their extension dependencies (specs/extensions.md §3) — the parser extension is
396
+ // A runtime dependency, resolved project-first by every host.
397
+ const dependencies: Record<string, string> = {
398
+ "@jxsuite/parser": "^0.35.1",
399
+ };
413
400
  const devDependencies: Record<string, string> = {
414
401
  "@jxsuite/compiler": "^0.19.0",
415
402
  "@jxsuite/runtime": "^0.19.0",
@@ -425,7 +412,7 @@ function buildPackageJson({
425
412
  dependencies["hono"] = "^4";
426
413
  }
427
414
 
428
- if (adapter && CF_ADAPTERS.has(adapter)) {
415
+ if (adapterNeedsWrangler(adapter)) {
429
416
  devDependencies["wrangler"] = "^4";
430
417
  scripts.deploy =
431
418
  adapter === "cloudflare-workers" ? "wrangler deploy" : "wrangler pages deploy dist";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/create",
3
- "version": "0.35.0",
3
+ "version": "1.0.0",
4
4
  "description": "Scaffold a new Jx project",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -16,6 +16,7 @@
16
16
  "generate.ts",
17
17
  "cli-args.ts",
18
18
  "templates.ts",
19
+ "scaffold.ts",
19
20
  "template/",
20
21
  "templates/"
21
22
  ],
@@ -23,7 +24,8 @@
23
24
  "exports": {
24
25
  ".": "./index.ts",
25
26
  "./generate": "./generate.ts",
26
- "./templates": "./templates.ts"
27
+ "./templates": "./templates.ts",
28
+ "./scaffold": "./scaffold.ts"
27
29
  },
28
30
  "publishConfig": {
29
31
  "provenance": true
@@ -33,6 +35,6 @@
33
35
  "test:coverage": "bun test --isolate --coverage"
34
36
  },
35
37
  "dependencies": {
36
- "@jxsuite/starters": "^0.35.0"
38
+ "@jxsuite/starters": "^1.0.0"
37
39
  }
38
40
  }
package/scaffold.ts ADDED
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Pure scaffolding helpers — zero node imports so Studio can run them in the browser (the publish
3
+ * flow writes wrangler.jsonc through the PAL) and cloud platforms can seed repos through the Git
4
+ * Data API. generate.ts wraps these with filesystem I/O.
5
+ */
6
+
7
+ const CF_ADAPTERS = new Set(["cloudflare-pages", "cloudflare-workers"]);
8
+
9
+ /** True when the adapter deploys through wrangler (needs a wrangler.jsonc). */
10
+ export function adapterNeedsWrangler(adapter: string | undefined): boolean {
11
+ return Boolean(adapter && CF_ADAPTERS.has(adapter));
12
+ }
13
+
14
+ /**
15
+ * A fresh wrangler.jsonc for the adapter. `name` becomes the Cloudflare project name — when the
16
+ * project has a `build.deploy` block, pass its projectName so the config and the connected Pages
17
+ * project agree.
18
+ */
19
+ export function buildWranglerJsonc({ slug, adapter }: { slug: string; adapter: string }): string {
20
+ const compatibilityDate = new Date().toISOString().slice(0, 10);
21
+
22
+ // Nodejs_compat: server functions routinely pull in Node-flavored npm packages
23
+ const config =
24
+ adapter === "cloudflare-workers"
25
+ ? {
26
+ assets: { binding: "ASSETS", directory: "./dist" },
27
+ compatibility_date: compatibilityDate,
28
+ compatibility_flags: ["nodejs_compat"],
29
+ main: "./dist/worker.js",
30
+ name: slug,
31
+ }
32
+ : {
33
+ compatibility_date: compatibilityDate,
34
+ compatibility_flags: ["nodejs_compat"],
35
+ name: slug,
36
+ pages_build_output_dir: "./dist",
37
+ };
38
+
39
+ return `${JSON.stringify(config, null, "\t")}\n`;
40
+ }
41
+
42
+ /**
43
+ * Deep-merge connector binding fragments into a wrangler.jsonc (plan Part 4a; consumed by `jx db
44
+ * push`). Fragments are the `bindings` connector-capability results — e.g. `{ d1_databases: [{
45
+ * binding, database_id }] }` or `{ hyperdrive: [{ binding, id }] }`.
46
+ *
47
+ * Merge rules: objects merge recursively; arrays whose elements carry a `binding` key are keyed by
48
+ * it (same-binding entries merge, new ones append — user-added keys on an entry survive); other
49
+ * values only land when the user has not set them (user keys are preserved). Unparseable
50
+ * (comment-bearing) JSONC is returned unchanged with `patched: false` rather than risking data
51
+ * loss.
52
+ *
53
+ * @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 }}
56
+ */
57
+ export function applyBindingFragments(
58
+ existing: string | null,
59
+ fragments: Record<string, unknown>[],
60
+ ): { content: string; patched: boolean } {
61
+ if (fragments.length === 0) {
62
+ return { content: existing ?? "", patched: false };
63
+ }
64
+ let config: Record<string, unknown> = {};
65
+ 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.
70
+ return { content: existing, patched: false };
71
+ }
72
+ }
73
+ for (const fragment of fragments) {
74
+ mergeBindingValue(config, fragment);
75
+ }
76
+ return { content: `${JSON.stringify(config, null, "\t")}\n`, patched: true };
77
+ }
78
+
79
+ /** Recursively merge one fragment object into the target, per the applyBindingFragments rules. */
80
+ function mergeBindingValue(target: Record<string, unknown>, fragment: Record<string, unknown>) {
81
+ for (const [key, incoming] of Object.entries(fragment)) {
82
+ const current = target[key];
83
+ if (Array.isArray(incoming)) {
84
+ target[key] = mergeBindingArrays(Array.isArray(current) ? current : [], incoming);
85
+ continue;
86
+ }
87
+ if (isPlainObject(incoming)) {
88
+ if (isPlainObject(current)) {
89
+ mergeBindingValue(current, incoming);
90
+ } else if (current === undefined) {
91
+ target[key] = structuredClone(incoming);
92
+ }
93
+ continue;
94
+ }
95
+ if (current === undefined) {
96
+ target[key] = incoming;
97
+ }
98
+ }
99
+ }
100
+
101
+ /** Merge fragment array entries into an existing array, keyed by each entry's `binding`. */
102
+ function mergeBindingArrays(current: unknown[], incoming: unknown[]): unknown[] {
103
+ const merged = [...current];
104
+ for (const entry of incoming) {
105
+ if (!isPlainObject(entry) || typeof entry.binding !== "string") {
106
+ // Non-binding-keyed values: append when missing (dedupe by deep equality).
107
+ const exists = merged.some((m) => JSON.stringify(m) === JSON.stringify(entry));
108
+ if (!exists) {
109
+ merged.push(structuredClone(entry));
110
+ }
111
+ continue;
112
+ }
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 {
119
+ // Fragment values win for connector-owned keys; user-added extra keys survive.
120
+ merged[index] = { ...(merged[index] as Record<string, unknown>), ...entry };
121
+ }
122
+ }
123
+ return merged;
124
+ }
125
+
126
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
127
+ return value !== null && typeof value === "object" && !Array.isArray(value);
128
+ }
129
+
130
+ /**
131
+ * Patch an existing wrangler.jsonc for an adapter change (or a Pages project rename), touching only
132
+ * the adapter-shape keys and preserving everything else the user added. Regenerates from scratch
133
+ * when the input is missing or unparseable (wrangler.jsonc supports comments, which JSON.parse does
134
+ * not — a commented config falls back to regeneration rather than data loss... it is returned
135
+ * unchanged with `patched: false` so callers can warn).
136
+ */
137
+ export function updateWranglerConfig(
138
+ existing: string | null,
139
+ { slug, adapter }: { slug: string; adapter: string },
140
+ ): { content: string; patched: boolean } {
141
+ if (!existing) {
142
+ return { content: buildWranglerJsonc({ adapter, slug }), patched: true };
143
+ }
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.
149
+ return { content: existing, patched: false };
150
+ }
151
+ config["name"] = slug;
152
+ if (adapter === "cloudflare-workers") {
153
+ delete config["pages_build_output_dir"];
154
+ config["main"] = "./dist/worker.js";
155
+ config["assets"] = { binding: "ASSETS", directory: "./dist" };
156
+ } else {
157
+ delete config["main"];
158
+ delete config["assets"];
159
+ config["pages_build_output_dir"] = "./dist";
160
+ }
161
+ return { content: `${JSON.stringify(config, null, "\t")}\n`, patched: true };
162
+ }
@@ -1,3 +1,5 @@
1
1
  .cache
2
2
  dist
3
3
  node_modules
4
+ .dev.vars
5
+ .jx