@jxsuite/create 0.34.0 → 0.36.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/cli-args.ts +36 -0
- package/generate.ts +272 -37
- package/index.ts +37 -3
- package/package.json +11 -3
- package/templates/mobile-app/layouts/base.json +96 -0
- package/templates/mobile-app/pages/explore.md +7 -0
- package/templates/mobile-app/pages/index.md +8 -0
- package/templates/mobile-app/pages/profile.md +7 -0
- package/templates.ts +74 -0
package/cli-args.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Argument parsing for the create-jxsuite CLI, split out so it can be unit-tested in isolation. */
|
|
2
|
+
|
|
3
|
+
export interface CliArgs {
|
|
4
|
+
/** Destination directory (first non-flag argument). */
|
|
5
|
+
dest?: string;
|
|
6
|
+
/** Starter template id from `--template <id>` / `--template=<id>`. */
|
|
7
|
+
template?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Parse the create-jxsuite argument list: the first non-flag token is the destination directory;
|
|
12
|
+
* `--template <id>` or `--template=<id>` selects a starter template.
|
|
13
|
+
*
|
|
14
|
+
* @param {string[]} argv — arguments after the node/script path (i.e. process.argv.slice(2))
|
|
15
|
+
* @returns {CliArgs}
|
|
16
|
+
*/
|
|
17
|
+
export function parseCliArgs(argv: string[]): CliArgs {
|
|
18
|
+
const positional: string[] = [];
|
|
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;
|
|
23
|
+
if (arg === "--template") {
|
|
24
|
+
template = argv[i + 1];
|
|
25
|
+
i += 1;
|
|
26
|
+
} else if (arg.startsWith("--template=")) {
|
|
27
|
+
template = arg.slice("--template=".length);
|
|
28
|
+
} else {
|
|
29
|
+
positional.push(arg);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
...(positional[0] !== undefined ? { dest: positional[0] } : {}),
|
|
34
|
+
...(template !== undefined ? { template } : {}),
|
|
35
|
+
};
|
|
36
|
+
}
|
package/generate.ts
CHANGED
|
@@ -1,21 +1,65 @@
|
|
|
1
1
|
/** Shared project generation logic. Used by both the CLI scaffolder and the Studio server endpoint. */
|
|
2
2
|
|
|
3
|
-
import { cp, mkdir, writeFile } from "node:fs/promises";
|
|
4
|
-
import {
|
|
3
|
+
import { cp, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import { adapterNeedsWrangler, buildWranglerJsonc } from "./scaffold";
|
|
5
|
+
import { basename, join, relative, sep } from "node:path";
|
|
6
|
+
|
|
7
|
+
import { Buffer } from "node:buffer";
|
|
5
8
|
|
|
6
9
|
import { existsSync } from "node:fs";
|
|
10
|
+
import { mediaForTemplate } from "./templates";
|
|
11
|
+
import type { TemplateId } from "./templates";
|
|
7
12
|
|
|
8
13
|
const __dirname = import.meta.dirname;
|
|
9
14
|
const TEMPLATE_DIR = join(__dirname, "template");
|
|
15
|
+
const TEMPLATE_OVERLAYS_DIR = join(__dirname, "templates");
|
|
16
|
+
|
|
17
|
+
export interface DesignOptions {
|
|
18
|
+
/** Accent / primary brand color (hex). */
|
|
19
|
+
accent?: string;
|
|
20
|
+
/** Page background color (hex). */
|
|
21
|
+
background?: string;
|
|
22
|
+
/** Body text color (hex). */
|
|
23
|
+
text?: string;
|
|
24
|
+
/** Body font stack. */
|
|
25
|
+
bodyFont?: string;
|
|
26
|
+
/** Heading font stack. */
|
|
27
|
+
headingFont?: string;
|
|
28
|
+
/** Replaces project.json $media entirely when provided (name → query/width map). */
|
|
29
|
+
media?: Record<string, string>;
|
|
30
|
+
/** Logo image written to public/<name>. base64 is the raw file content, no data: prefix. */
|
|
31
|
+
logo?: { name: string; base64: string };
|
|
32
|
+
}
|
|
10
33
|
|
|
11
34
|
export interface ProjectOptions {
|
|
12
35
|
name: string;
|
|
13
36
|
description?: string;
|
|
14
37
|
url?: string;
|
|
15
38
|
adapter?: "static" | "cloudflare-pages" | "cloudflare-workers" | "node" | "bun";
|
|
39
|
+
/**
|
|
40
|
+
* Id of a starter template to clone (from @jxsuite/starters), or "blank"/undefined for the
|
|
41
|
+
* built-in minimal template.
|
|
42
|
+
*/
|
|
43
|
+
starter?: string;
|
|
44
|
+
/**
|
|
45
|
+
* Id of a built-in template variant (from ./templates). Ignored when a non-blank starter is
|
|
46
|
+
* given; undefined means "blank".
|
|
47
|
+
*/
|
|
48
|
+
template?: TemplateId;
|
|
49
|
+
/** Design quickstart (colors, fonts, logo, breakpoints) applied on top of the scaffold. */
|
|
50
|
+
design?: DesignOptions;
|
|
16
51
|
}
|
|
17
52
|
|
|
18
|
-
|
|
53
|
+
// Paths never copied out of a starter tree into a fresh project: build artifacts and the
|
|
54
|
+
// Authoring-only Pexels fetch manifest.
|
|
55
|
+
const STARTER_EXCLUDE = new Set([
|
|
56
|
+
"node_modules",
|
|
57
|
+
"dist",
|
|
58
|
+
".cache",
|
|
59
|
+
".jx-cache",
|
|
60
|
+
".git",
|
|
61
|
+
"images.json",
|
|
62
|
+
]);
|
|
19
63
|
|
|
20
64
|
/**
|
|
21
65
|
* Generate a new Jx project at the given path.
|
|
@@ -24,7 +68,15 @@ const CF_ADAPTERS = new Set(["cloudflare-pages", "cloudflare-workers"]);
|
|
|
24
68
|
* @param {ProjectOptions} opts
|
|
25
69
|
*/
|
|
26
70
|
export async function generateProject(destPath: string, opts: ProjectOptions) {
|
|
27
|
-
const {
|
|
71
|
+
const {
|
|
72
|
+
name,
|
|
73
|
+
description = "",
|
|
74
|
+
url = "",
|
|
75
|
+
adapter = "static",
|
|
76
|
+
starter,
|
|
77
|
+
template = "blank",
|
|
78
|
+
design,
|
|
79
|
+
} = opts;
|
|
28
80
|
|
|
29
81
|
if (existsSync(destPath)) {
|
|
30
82
|
const { readdirSync } = await import("node:fs");
|
|
@@ -34,11 +86,26 @@ export async function generateProject(destPath: string, opts: ProjectOptions) {
|
|
|
34
86
|
}
|
|
35
87
|
|
|
36
88
|
await mkdir(destPath, { recursive: true });
|
|
89
|
+
|
|
90
|
+
if (starter && starter !== "blank") {
|
|
91
|
+
await scaffoldFromStarter(destPath, starter, {
|
|
92
|
+
adapter,
|
|
93
|
+
description,
|
|
94
|
+
name,
|
|
95
|
+
url,
|
|
96
|
+
...(design !== undefined ? { design } : {}),
|
|
97
|
+
});
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
37
101
|
await mkdir(join(destPath, "components"), { recursive: true });
|
|
38
102
|
await mkdir(join(destPath, "public"), { recursive: true });
|
|
39
103
|
await mkdir(join(destPath, "content"), { recursive: true });
|
|
40
104
|
|
|
41
|
-
const projectJson = buildProjectJson({ adapter, description, name, url });
|
|
105
|
+
const projectJson = buildProjectJson({ adapter, description, name, template, url });
|
|
106
|
+
if (design) {
|
|
107
|
+
applyDesign(projectJson as unknown as Record<string, unknown>, design, { starter: false });
|
|
108
|
+
}
|
|
42
109
|
const packageJson = buildPackageJson({ adapter, description, name });
|
|
43
110
|
|
|
44
111
|
const writes = [
|
|
@@ -49,12 +116,193 @@ export async function generateProject(destPath: string, opts: ProjectOptions) {
|
|
|
49
116
|
cp(join(TEMPLATE_DIR, "pages"), join(destPath, "pages"), { recursive: true }),
|
|
50
117
|
];
|
|
51
118
|
|
|
52
|
-
if (
|
|
119
|
+
if (adapterNeedsWrangler(adapter)) {
|
|
53
120
|
const wranglerJsonc = buildWranglerJsonc({ adapter, slug: packageJson.name });
|
|
54
121
|
writes.push(writeFile(join(destPath, "wrangler.jsonc"), wranglerJsonc));
|
|
55
122
|
}
|
|
56
123
|
|
|
57
124
|
await Promise.all(writes);
|
|
125
|
+
|
|
126
|
+
// The mobile-app variant overlays the shared skeleton with its app-shell layout and home page.
|
|
127
|
+
// Sequenced after the base copies so the overlay files win.
|
|
128
|
+
if (template === "mobile-app") {
|
|
129
|
+
await cp(join(TEMPLATE_OVERLAYS_DIR, "mobile-app"), destPath, {
|
|
130
|
+
force: true,
|
|
131
|
+
recursive: true,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (design?.logo) {
|
|
136
|
+
await writeLogo(destPath, design.logo);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Clone a starter template from @jxsuite/starters, then re-stamp the files that carry the new
|
|
142
|
+
* project's identity: project.json (name/url/description/adapter) and a freshly-built package.json
|
|
143
|
+
* (current dependency ranges, user's name). Content, components, layouts, and public assets are
|
|
144
|
+
* copied verbatim.
|
|
145
|
+
*
|
|
146
|
+
* @param {string} destPath
|
|
147
|
+
* @param {string} starter
|
|
148
|
+
* @param {Required<Pick<ProjectOptions, "name">> &
|
|
149
|
+
* Pick<ProjectOptions, "description" | "url" | "adapter">} opts
|
|
150
|
+
*/
|
|
151
|
+
async function scaffoldFromStarter(
|
|
152
|
+
destPath: string,
|
|
153
|
+
starter: string,
|
|
154
|
+
opts: {
|
|
155
|
+
name: string;
|
|
156
|
+
description?: string;
|
|
157
|
+
url?: string;
|
|
158
|
+
adapter?: ProjectOptions["adapter"];
|
|
159
|
+
design?: DesignOptions;
|
|
160
|
+
},
|
|
161
|
+
) {
|
|
162
|
+
const { name, description = "", url = "", adapter = "static", design } = opts;
|
|
163
|
+
|
|
164
|
+
// Resolved lazily so blank projects never load the (large) starters package.
|
|
165
|
+
const { getStarterDir } = await import("@jxsuite/starters");
|
|
166
|
+
const srcDir = getStarterDir(starter);
|
|
167
|
+
|
|
168
|
+
await cp(srcDir, destPath, {
|
|
169
|
+
recursive: true,
|
|
170
|
+
filter: (src) => {
|
|
171
|
+
const rel = relative(srcDir, src);
|
|
172
|
+
return rel === "" || !rel.split(sep).some((seg) => STARTER_EXCLUDE.has(seg));
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// Re-stamp project.json with the new project's identity, keeping the starter's design tokens,
|
|
177
|
+
// Content types, image pipeline, and head.
|
|
178
|
+
const projectPath = join(destPath, "project.json");
|
|
179
|
+
const project = JSON.parse(await readFile(projectPath, "utf8")) as Record<string, unknown>;
|
|
180
|
+
project.name = name;
|
|
181
|
+
if (url) {
|
|
182
|
+
project.url = url;
|
|
183
|
+
}
|
|
184
|
+
applyDescription(project, description);
|
|
185
|
+
if (adapter && adapter !== "static") {
|
|
186
|
+
const build = (project.build as Record<string, unknown> | undefined) ?? {};
|
|
187
|
+
build.adapter = adapter;
|
|
188
|
+
project.build = build;
|
|
189
|
+
}
|
|
190
|
+
if (design) {
|
|
191
|
+
applyDesign(project, design, { starter: true });
|
|
192
|
+
}
|
|
193
|
+
await writeFile(projectPath, `${JSON.stringify(project, null, "\t")}\n`);
|
|
194
|
+
|
|
195
|
+
// Rebuild package.json so scaffolded projects get current dep ranges and scripts regardless of
|
|
196
|
+
// What the in-repo starter pinned.
|
|
197
|
+
const packageJson = buildPackageJson({ adapter, description, name });
|
|
198
|
+
await writeFile(join(destPath, "package.json"), `${JSON.stringify(packageJson, null, " ")}\n`);
|
|
199
|
+
|
|
200
|
+
if (adapterNeedsWrangler(adapter)) {
|
|
201
|
+
const wranglerJsonc = buildWranglerJsonc({ adapter, slug: packageJson.name });
|
|
202
|
+
await writeFile(join(destPath, "wrangler.jsonc"), wranglerJsonc);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (design?.logo) {
|
|
206
|
+
await writeLogo(destPath, design.logo);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Apply the design quickstart (colors, fonts, breakpoints) to a project.json object, in place.
|
|
212
|
+
*
|
|
213
|
+
* Blank projects own their style block, so values are written directly. Starters are re-themed
|
|
214
|
+
* best-effort against the conventions the in-repo starters share (`--color-primary`,
|
|
215
|
+
* `--color-text-primary`, `--font-body`, `--font-heading`, top-level `fontFamily` /
|
|
216
|
+
* `backgroundColor`): an override only lands on a token key the starter's style already declares
|
|
217
|
+
* (falling back to the plain CSS property where noted), and is otherwise skipped silently rather
|
|
218
|
+
* than fighting the starter's layered styles. `--color-primary-hover` is intentionally left alone.
|
|
219
|
+
*
|
|
220
|
+
* @param {Record<string, unknown>} project — parsed project.json, mutated in place
|
|
221
|
+
* @param {DesignOptions} design
|
|
222
|
+
* @param {{ starter: boolean }} mode — starter clone vs. blank/template scaffold
|
|
223
|
+
*/
|
|
224
|
+
function applyDesign(
|
|
225
|
+
project: Record<string, unknown>,
|
|
226
|
+
design: DesignOptions,
|
|
227
|
+
{ starter }: { starter: boolean },
|
|
228
|
+
) {
|
|
229
|
+
const { accent, background, text, bodyFont, headingFont, media } = design;
|
|
230
|
+
const style = project.style as Record<string, unknown> | undefined;
|
|
231
|
+
|
|
232
|
+
if (style) {
|
|
233
|
+
if (accent && (!starter || "--color-primary" in style)) {
|
|
234
|
+
style["--color-primary"] = accent;
|
|
235
|
+
}
|
|
236
|
+
if (text) {
|
|
237
|
+
if (!starter) {
|
|
238
|
+
style.color = text;
|
|
239
|
+
} else if ("--color-text-primary" in style) {
|
|
240
|
+
style["--color-text-primary"] = text;
|
|
241
|
+
} else if ("color" in style) {
|
|
242
|
+
style.color = text;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (background && (!starter || "backgroundColor" in style)) {
|
|
246
|
+
style.backgroundColor = background;
|
|
247
|
+
}
|
|
248
|
+
if (bodyFont) {
|
|
249
|
+
if (!starter) {
|
|
250
|
+
style.fontFamily = bodyFont;
|
|
251
|
+
} else if ("--font-body" in style) {
|
|
252
|
+
style["--font-body"] = bodyFont;
|
|
253
|
+
} else if ("fontFamily" in style) {
|
|
254
|
+
style.fontFamily = bodyFont;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (headingFont && (!starter || "--font-heading" in style)) {
|
|
258
|
+
style["--font-heading"] = headingFont;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (media && Object.keys(media).length > 0) {
|
|
263
|
+
project.$media = media;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** File types accepted for the quickstart logo. */
|
|
268
|
+
const LOGO_EXTENSION = /\.(svg|png|jpe?g|webp|gif|ico)$/i;
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Write the design-quickstart logo into the project's public/ directory. The provided name is
|
|
272
|
+
* flattened to its basename (no path segments can escape public/) and must carry an image extension
|
|
273
|
+
* — this is the only guard between UI input and the filesystem.
|
|
274
|
+
*
|
|
275
|
+
* @param {string} destPath — absolute path to the project directory
|
|
276
|
+
* @param {{ name: string; base64: string }} logo
|
|
277
|
+
*/
|
|
278
|
+
async function writeLogo(destPath: string, logo: { name: string; base64: string }) {
|
|
279
|
+
const fileName = basename(logo.name);
|
|
280
|
+
if (!LOGO_EXTENSION.test(fileName)) {
|
|
281
|
+
throw new Error(
|
|
282
|
+
`Logo file type not allowed: "${logo.name}" (expected .svg, .png, .jpg, .jpeg, .webp, .gif, or .ico)`,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
const publicDir = join(destPath, "public");
|
|
286
|
+
await mkdir(publicDir, { recursive: true });
|
|
287
|
+
await writeFile(join(publicDir, fileName), Buffer.from(logo.base64, "base64"));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Set the `<meta name="description">` content in a project's `$head` (or leave it untouched when
|
|
292
|
+
* the starter has no such tag or no description was supplied).
|
|
293
|
+
*
|
|
294
|
+
* @param {Record<string, unknown>} project
|
|
295
|
+
* @param {string} description
|
|
296
|
+
*/
|
|
297
|
+
function applyDescription(project: Record<string, unknown>, description: string) {
|
|
298
|
+
if (!description || !Array.isArray(project.$head)) {
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
for (const tag of project.$head as { tagName?: string; attributes?: Record<string, unknown> }[]) {
|
|
302
|
+
if (tag.tagName === "meta" && tag.attributes?.name === "description") {
|
|
303
|
+
tag.attributes.content = description;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
58
306
|
}
|
|
59
307
|
|
|
60
308
|
/**
|
|
@@ -62,38 +310,30 @@ export async function generateProject(destPath: string, opts: ProjectOptions) {
|
|
|
62
310
|
*
|
|
63
311
|
* @param {{ slug: string; adapter: string }} opts
|
|
64
312
|
*/
|
|
65
|
-
function buildWranglerJsonc({ slug, adapter }: { slug: string; adapter: string }) {
|
|
66
|
-
const compatibilityDate = new Date().toISOString().slice(0, 10);
|
|
67
|
-
|
|
68
|
-
// Nodejs_compat: server functions routinely pull in Node-flavored npm packages
|
|
69
|
-
const config =
|
|
70
|
-
adapter === "cloudflare-workers"
|
|
71
|
-
? {
|
|
72
|
-
assets: { binding: "ASSETS", directory: "./dist" },
|
|
73
|
-
compatibility_date: compatibilityDate,
|
|
74
|
-
compatibility_flags: ["nodejs_compat"],
|
|
75
|
-
main: "./dist/worker.js",
|
|
76
|
-
name: slug,
|
|
77
|
-
}
|
|
78
|
-
: {
|
|
79
|
-
compatibility_date: compatibilityDate,
|
|
80
|
-
compatibility_flags: ["nodejs_compat"],
|
|
81
|
-
name: slug,
|
|
82
|
-
pages_build_output_dir: "./dist",
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
return `${JSON.stringify(config, null, "\t")}\n`;
|
|
86
|
-
}
|
|
87
313
|
|
|
88
314
|
/** @param {ProjectOptions} opts */
|
|
89
|
-
function buildProjectJson({ name, description, url, adapter }: ProjectOptions) {
|
|
315
|
+
function buildProjectJson({ name, description, url, adapter, template = "blank" }: ProjectOptions) {
|
|
316
|
+
// Mobile-app shells draw under device notches/home bars; viewport-fit=cover makes the safe-area
|
|
317
|
+
// Insets used by the app-shell layout take effect.
|
|
318
|
+
const viewport =
|
|
319
|
+
template === "mobile-app"
|
|
320
|
+
? "width=device-width, initial-scale=1, viewport-fit=cover"
|
|
321
|
+
: "width=device-width, initial-scale=1";
|
|
322
|
+
|
|
90
323
|
const $head = [
|
|
91
324
|
{
|
|
92
|
-
attributes: { content:
|
|
325
|
+
attributes: { content: viewport, name: "viewport" },
|
|
93
326
|
tagName: "meta",
|
|
94
327
|
},
|
|
95
328
|
];
|
|
96
329
|
|
|
330
|
+
if (template === "mobile-app") {
|
|
331
|
+
$head.push({
|
|
332
|
+
attributes: { content: "#ffffff", name: "theme-color" },
|
|
333
|
+
tagName: "meta",
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
97
337
|
if (description) {
|
|
98
338
|
$head.push({
|
|
99
339
|
attributes: { content: description, name: "description" },
|
|
@@ -113,12 +353,7 @@ function buildProjectJson({ name, description, url, adapter }: ProjectOptions) {
|
|
|
113
353
|
|
|
114
354
|
return {
|
|
115
355
|
$head,
|
|
116
|
-
$media:
|
|
117
|
-
"--": "1280px",
|
|
118
|
-
"--lg": "(max-width: 1024px)",
|
|
119
|
-
"--md": "(max-width: 768px)",
|
|
120
|
-
"--sm": "(max-width: 640px)",
|
|
121
|
-
},
|
|
356
|
+
$media: mediaForTemplate(template),
|
|
122
357
|
build,
|
|
123
358
|
defaults: {
|
|
124
359
|
lang: "en",
|
|
@@ -167,7 +402,7 @@ function buildPackageJson({
|
|
|
167
402
|
dependencies["hono"] = "^4";
|
|
168
403
|
}
|
|
169
404
|
|
|
170
|
-
if (
|
|
405
|
+
if (adapterNeedsWrangler(adapter)) {
|
|
171
406
|
devDependencies["wrangler"] = "^4";
|
|
172
407
|
scripts.deploy =
|
|
173
408
|
adapter === "cloudflare-workers" ? "wrangler deploy" : "wrangler pages deploy dist";
|
package/index.ts
CHANGED
|
@@ -8,12 +8,17 @@
|
|
|
8
8
|
import { createInterface } from "node:readline/promises";
|
|
9
9
|
import { stdin, stdout } from "node:process";
|
|
10
10
|
import { basename, resolve } from "node:path";
|
|
11
|
+
import { listStarters } from "@jxsuite/starters";
|
|
12
|
+
import { parseCliArgs } from "./cli-args";
|
|
11
13
|
import { generateProject } from "./generate";
|
|
14
|
+
import { isTemplateId, listTemplates } from "./templates";
|
|
12
15
|
import type { ProjectOptions } from "./generate";
|
|
16
|
+
import type { TemplateId } from "./templates";
|
|
13
17
|
|
|
14
|
-
|
|
18
|
+
// The first non-flag arg is the destination; --template <id> selects a starter (skips the prompt).
|
|
19
|
+
const { dest, template: templateFlag } = parseCliArgs(process.argv.slice(2));
|
|
15
20
|
if (!dest) {
|
|
16
|
-
console.error("Usage: bun create @jxsuite <directory>");
|
|
21
|
+
console.error("Usage: bun create @jxsuite <directory> [--template <id>]");
|
|
17
22
|
process.exit(1);
|
|
18
23
|
}
|
|
19
24
|
|
|
@@ -32,6 +37,35 @@ async function main() {
|
|
|
32
37
|
const description = await rl.question("Description: ");
|
|
33
38
|
const url = await rl.question("Production URL (https://example.com): ");
|
|
34
39
|
|
|
40
|
+
const starters = listStarters();
|
|
41
|
+
const templates = listTemplates();
|
|
42
|
+
let starter = "blank";
|
|
43
|
+
let template: TemplateId = "blank";
|
|
44
|
+
if (templateFlag !== undefined) {
|
|
45
|
+
// Non-interactive: honor --template — built-in template ids win over starter ids, and an
|
|
46
|
+
// Unknown id falls back to blank.
|
|
47
|
+
if (isTemplateId(templateFlag)) {
|
|
48
|
+
template = templateFlag;
|
|
49
|
+
} else if (starters.some((s) => s.id === templateFlag)) {
|
|
50
|
+
starter = templateFlag;
|
|
51
|
+
}
|
|
52
|
+
} else {
|
|
53
|
+
console.log("\nStart from a template:");
|
|
54
|
+
for (const [i, t] of templates.entries()) {
|
|
55
|
+
console.log(` ${i + 1}) ${t.name}${i === 0 ? " (default)" : ""}`);
|
|
56
|
+
}
|
|
57
|
+
for (const [i, s] of starters.entries()) {
|
|
58
|
+
console.log(` ${i + templates.length + 1}) ${s.name} — ${s.tagline}`);
|
|
59
|
+
}
|
|
60
|
+
const templateChoice = await rl.question("Template [1]: ");
|
|
61
|
+
const idx = Math.trunc(Number(templateChoice));
|
|
62
|
+
if (idx >= 2 && idx <= templates.length) {
|
|
63
|
+
template = templates[idx - 1]?.id ?? "blank";
|
|
64
|
+
} else if (idx > templates.length && idx <= templates.length + starters.length) {
|
|
65
|
+
starter = starters[idx - templates.length - 1]?.id ?? "blank";
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
35
69
|
console.log(`
|
|
36
70
|
Deployment adapter:
|
|
37
71
|
1) static (default)
|
|
@@ -52,7 +86,7 @@ Deployment adapter:
|
|
|
52
86
|
};
|
|
53
87
|
const adapter = adapterMap[adapterChoice] || "static";
|
|
54
88
|
|
|
55
|
-
await generateProject(destPath, { adapter, description, name, url });
|
|
89
|
+
await generateProject(destPath, { adapter, description, name, starter, template, url });
|
|
56
90
|
|
|
57
91
|
console.log(`
|
|
58
92
|
Project created at ${destPath}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/create",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.36.0",
|
|
4
4
|
"description": "Scaffold a new Jx project",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -14,12 +14,17 @@
|
|
|
14
14
|
"files": [
|
|
15
15
|
"index.ts",
|
|
16
16
|
"generate.ts",
|
|
17
|
-
"
|
|
17
|
+
"cli-args.ts",
|
|
18
|
+
"templates.ts",
|
|
19
|
+
"template/",
|
|
20
|
+
"templates/"
|
|
18
21
|
],
|
|
19
22
|
"type": "module",
|
|
20
23
|
"exports": {
|
|
21
24
|
".": "./index.ts",
|
|
22
|
-
"./generate": "./generate.ts"
|
|
25
|
+
"./generate": "./generate.ts",
|
|
26
|
+
"./templates": "./templates.ts",
|
|
27
|
+
"./scaffold": "./scaffold.ts"
|
|
23
28
|
},
|
|
24
29
|
"publishConfig": {
|
|
25
30
|
"provenance": true
|
|
@@ -27,5 +32,8 @@
|
|
|
27
32
|
"scripts": {
|
|
28
33
|
"test": "bun test --isolate",
|
|
29
34
|
"test:coverage": "bun test --isolate --coverage"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@jxsuite/starters": "^0.35.1"
|
|
30
38
|
}
|
|
31
39
|
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
{
|
|
2
|
+
"tagName": "div",
|
|
3
|
+
"style": {
|
|
4
|
+
"margin": "0",
|
|
5
|
+
"padding": "0",
|
|
6
|
+
"height": "100dvh",
|
|
7
|
+
"display": "flex",
|
|
8
|
+
"flexDirection": "column",
|
|
9
|
+
"fontFamily": "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif",
|
|
10
|
+
"lineHeight": "1.6",
|
|
11
|
+
"color": "#1a1a1a",
|
|
12
|
+
"backgroundColor": "#ffffff"
|
|
13
|
+
},
|
|
14
|
+
"children": [
|
|
15
|
+
{
|
|
16
|
+
"tagName": "header",
|
|
17
|
+
"style": {
|
|
18
|
+
"padding": "0.75rem 1rem",
|
|
19
|
+
"paddingTop": "calc(0.75rem + env(safe-area-inset-top))",
|
|
20
|
+
"borderBottom": "1px solid #e5e5e5",
|
|
21
|
+
"textAlign": "center"
|
|
22
|
+
},
|
|
23
|
+
"children": [
|
|
24
|
+
{
|
|
25
|
+
"tagName": "span",
|
|
26
|
+
"style": {
|
|
27
|
+
"fontSize": "1.05rem",
|
|
28
|
+
"fontWeight": "700"
|
|
29
|
+
},
|
|
30
|
+
"children": ["My App"]
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"tagName": "main",
|
|
36
|
+
"style": {
|
|
37
|
+
"flex": "1",
|
|
38
|
+
"overflowY": "auto",
|
|
39
|
+
"padding": "1rem",
|
|
40
|
+
"width": "100%"
|
|
41
|
+
},
|
|
42
|
+
"children": [{ "tagName": "slot" }]
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"tagName": "nav",
|
|
46
|
+
"style": {
|
|
47
|
+
"display": "flex",
|
|
48
|
+
"borderTop": "1px solid #e5e5e5",
|
|
49
|
+
"paddingBottom": "env(safe-area-inset-bottom)",
|
|
50
|
+
"backgroundColor": "#ffffff"
|
|
51
|
+
},
|
|
52
|
+
"children": [
|
|
53
|
+
{
|
|
54
|
+
"tagName": "a",
|
|
55
|
+
"attributes": { "href": "/" },
|
|
56
|
+
"style": {
|
|
57
|
+
"flex": "1",
|
|
58
|
+
"padding": "0.75rem 0",
|
|
59
|
+
"textAlign": "center",
|
|
60
|
+
"textDecoration": "none",
|
|
61
|
+
"fontSize": "0.8rem",
|
|
62
|
+
"fontWeight": "600",
|
|
63
|
+
"color": "#1a1a1a"
|
|
64
|
+
},
|
|
65
|
+
"children": ["Home"]
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"tagName": "a",
|
|
69
|
+
"attributes": { "href": "/explore/" },
|
|
70
|
+
"style": {
|
|
71
|
+
"flex": "1",
|
|
72
|
+
"padding": "0.75rem 0",
|
|
73
|
+
"textAlign": "center",
|
|
74
|
+
"textDecoration": "none",
|
|
75
|
+
"fontSize": "0.8rem",
|
|
76
|
+
"color": "#666"
|
|
77
|
+
},
|
|
78
|
+
"children": ["Explore"]
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
"tagName": "a",
|
|
82
|
+
"attributes": { "href": "/profile/" },
|
|
83
|
+
"style": {
|
|
84
|
+
"flex": "1",
|
|
85
|
+
"padding": "0.75rem 0",
|
|
86
|
+
"textAlign": "center",
|
|
87
|
+
"textDecoration": "none",
|
|
88
|
+
"fontSize": "0.8rem",
|
|
89
|
+
"color": "#666"
|
|
90
|
+
},
|
|
91
|
+
"children": ["Profile"]
|
|
92
|
+
}
|
|
93
|
+
]
|
|
94
|
+
}
|
|
95
|
+
]
|
|
96
|
+
}
|
package/templates.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in project templates. All are thin variants of the blank skeleton: they share the files in
|
|
3
|
+
* `template/` and differ in the generated project.json ($media direction, viewport meta) plus, for
|
|
4
|
+
* mobile-app, an overlay of app-shell files from `templates/mobile-app/`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type TemplateId = "blank" | "desktop-first" | "mobile-first" | "mobile-app";
|
|
8
|
+
|
|
9
|
+
export interface TemplateMeta {
|
|
10
|
+
id: TemplateId;
|
|
11
|
+
name: string;
|
|
12
|
+
description: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const TEMPLATES: readonly TemplateMeta[] = [
|
|
16
|
+
{
|
|
17
|
+
description: "A minimal desktop-first site with a header, footer, and one page.",
|
|
18
|
+
id: "blank",
|
|
19
|
+
name: "Blank",
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
description: "The blank skeleton with desktop-first breakpoints (max-width media queries).",
|
|
23
|
+
id: "desktop-first",
|
|
24
|
+
name: "Desktop First",
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
description: "The blank skeleton with mobile-first breakpoints (min-width media queries).",
|
|
28
|
+
id: "mobile-first",
|
|
29
|
+
name: "Mobile First",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
description: "A mobile-first app shell with a fixed bottom navigation and scrollable content.",
|
|
33
|
+
id: "mobile-app",
|
|
34
|
+
name: "Mobile App",
|
|
35
|
+
},
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
/** @returns {TemplateMeta[]} The built-in template list, for pickers and menus */
|
|
39
|
+
export function listTemplates(): TemplateMeta[] {
|
|
40
|
+
return [...TEMPLATES];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** @param {string} id */
|
|
44
|
+
export function isTemplateId(id: string): id is TemplateId {
|
|
45
|
+
return TEMPLATES.some((t) => t.id === id);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Desktop-first: base canvas is the widest; narrower breakpoints cascade via max-width.
|
|
49
|
+
const DESKTOP_FIRST_MEDIA = {
|
|
50
|
+
"--": "1280px",
|
|
51
|
+
"--lg": "(max-width: 1024px)",
|
|
52
|
+
"--md": "(max-width: 768px)",
|
|
53
|
+
"--sm": "(max-width: 640px)",
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// Mobile-first: base canvas is the narrowest; wider breakpoints cascade via min-width.
|
|
57
|
+
const MOBILE_FIRST_MEDIA = {
|
|
58
|
+
"--": "375px",
|
|
59
|
+
"--sm": "(min-width: 640px)",
|
|
60
|
+
"--md": "(min-width: 768px)",
|
|
61
|
+
"--lg": "(min-width: 1024px)",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The project.json `$media` map for a template: desktop-first templates use max-width queries with
|
|
66
|
+
* the widest base; mobile-first templates use min-width queries with the narrowest base.
|
|
67
|
+
*
|
|
68
|
+
* @param {TemplateId} id
|
|
69
|
+
*/
|
|
70
|
+
export function mediaForTemplate(id: TemplateId): Record<string, string> {
|
|
71
|
+
return id === "mobile-first" || id === "mobile-app"
|
|
72
|
+
? { ...MOBILE_FIRST_MEDIA }
|
|
73
|
+
: { ...DESKTOP_FIRST_MEDIA };
|
|
74
|
+
}
|