@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 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 { join } from "node:path";
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
- const CF_ADAPTERS = new Set(["cloudflare-pages", "cloudflare-workers"]);
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 { name, description = "", url = "", adapter = "static" } = opts;
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 (adapter && CF_ADAPTERS.has(adapter)) {
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: "width=device-width, initial-scale=1", name: "viewport" },
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 (adapter && CF_ADAPTERS.has(adapter)) {
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
- const [dest] = process.argv.slice(2);
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.34.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
- "template/"
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
+ }
@@ -0,0 +1,7 @@
1
+ ---
2
+ title: Explore
3
+ ---
4
+
5
+ # Explore
6
+
7
+ Add your app's browse/discovery content here, at `pages/explore.md`.
@@ -0,0 +1,8 @@
1
+ ---
2
+ title: Home
3
+ ---
4
+
5
+ # Welcome
6
+
7
+ Your new Jx app is ready. Edit this page at `pages/index.md`, and the app shell (header and bottom
8
+ navigation) at `layouts/base.json`.
@@ -0,0 +1,7 @@
1
+ ---
2
+ title: Profile
3
+ ---
4
+
5
+ # Profile
6
+
7
+ Add your app's account/profile content here, at `pages/profile.md`.
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
+ }