@jxsuite/create 0.34.0 → 0.35.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,22 +1,67 @@
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 { basename, join, relative, sep } from "node:path";
5
+
6
+ import { Buffer } from "node:buffer";
5
7
 
6
8
  import { existsSync } from "node:fs";
9
+ import { mediaForTemplate } from "./templates";
10
+ import type { TemplateId } from "./templates";
7
11
 
8
12
  const __dirname = import.meta.dirname;
9
13
  const TEMPLATE_DIR = join(__dirname, "template");
14
+ const TEMPLATE_OVERLAYS_DIR = join(__dirname, "templates");
15
+
16
+ export interface DesignOptions {
17
+ /** Accent / primary brand color (hex). */
18
+ accent?: string;
19
+ /** Page background color (hex). */
20
+ background?: string;
21
+ /** Body text color (hex). */
22
+ text?: string;
23
+ /** Body font stack. */
24
+ bodyFont?: string;
25
+ /** Heading font stack. */
26
+ headingFont?: string;
27
+ /** Replaces project.json $media entirely when provided (name → query/width map). */
28
+ media?: Record<string, string>;
29
+ /** Logo image written to public/<name>. base64 is the raw file content, no data: prefix. */
30
+ logo?: { name: string; base64: string };
31
+ }
10
32
 
11
33
  export interface ProjectOptions {
12
34
  name: string;
13
35
  description?: string;
14
36
  url?: string;
15
37
  adapter?: "static" | "cloudflare-pages" | "cloudflare-workers" | "node" | "bun";
38
+ /**
39
+ * Id of a starter template to clone (from @jxsuite/starters), or "blank"/undefined for the
40
+ * built-in minimal template.
41
+ */
42
+ starter?: string;
43
+ /**
44
+ * Id of a built-in template variant (from ./templates). Ignored when a non-blank starter is
45
+ * given; undefined means "blank".
46
+ */
47
+ template?: TemplateId;
48
+ /** Design quickstart (colors, fonts, logo, breakpoints) applied on top of the scaffold. */
49
+ design?: DesignOptions;
16
50
  }
17
51
 
18
52
  const CF_ADAPTERS = new Set(["cloudflare-pages", "cloudflare-workers"]);
19
53
 
54
+ // Paths never copied out of a starter tree into a fresh project: build artifacts and the
55
+ // Authoring-only Pexels fetch manifest.
56
+ const STARTER_EXCLUDE = new Set([
57
+ "node_modules",
58
+ "dist",
59
+ ".cache",
60
+ ".jx-cache",
61
+ ".git",
62
+ "images.json",
63
+ ]);
64
+
20
65
  /**
21
66
  * Generate a new Jx project at the given path.
22
67
  *
@@ -24,7 +69,15 @@ const CF_ADAPTERS = new Set(["cloudflare-pages", "cloudflare-workers"]);
24
69
  * @param {ProjectOptions} opts
25
70
  */
26
71
  export async function generateProject(destPath: string, opts: ProjectOptions) {
27
- const { name, description = "", url = "", adapter = "static" } = opts;
72
+ const {
73
+ name,
74
+ description = "",
75
+ url = "",
76
+ adapter = "static",
77
+ starter,
78
+ template = "blank",
79
+ design,
80
+ } = opts;
28
81
 
29
82
  if (existsSync(destPath)) {
30
83
  const { readdirSync } = await import("node:fs");
@@ -34,11 +87,26 @@ export async function generateProject(destPath: string, opts: ProjectOptions) {
34
87
  }
35
88
 
36
89
  await mkdir(destPath, { recursive: true });
90
+
91
+ if (starter && starter !== "blank") {
92
+ await scaffoldFromStarter(destPath, starter, {
93
+ adapter,
94
+ description,
95
+ name,
96
+ url,
97
+ ...(design !== undefined ? { design } : {}),
98
+ });
99
+ return;
100
+ }
101
+
37
102
  await mkdir(join(destPath, "components"), { recursive: true });
38
103
  await mkdir(join(destPath, "public"), { recursive: true });
39
104
  await mkdir(join(destPath, "content"), { recursive: true });
40
105
 
41
- const projectJson = buildProjectJson({ adapter, description, name, url });
106
+ const projectJson = buildProjectJson({ adapter, description, name, template, url });
107
+ if (design) {
108
+ applyDesign(projectJson as unknown as Record<string, unknown>, design, { starter: false });
109
+ }
42
110
  const packageJson = buildPackageJson({ adapter, description, name });
43
111
 
44
112
  const writes = [
@@ -55,6 +123,187 @@ export async function generateProject(destPath: string, opts: ProjectOptions) {
55
123
  }
56
124
 
57
125
  await Promise.all(writes);
126
+
127
+ // The mobile-app variant overlays the shared skeleton with its app-shell layout and home page.
128
+ // Sequenced after the base copies so the overlay files win.
129
+ if (template === "mobile-app") {
130
+ await cp(join(TEMPLATE_OVERLAYS_DIR, "mobile-app"), destPath, {
131
+ force: true,
132
+ recursive: true,
133
+ });
134
+ }
135
+
136
+ if (design?.logo) {
137
+ await writeLogo(destPath, design.logo);
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Clone a starter template from @jxsuite/starters, then re-stamp the files that carry the new
143
+ * project's identity: project.json (name/url/description/adapter) and a freshly-built package.json
144
+ * (current dependency ranges, user's name). Content, components, layouts, and public assets are
145
+ * copied verbatim.
146
+ *
147
+ * @param {string} destPath
148
+ * @param {string} starter
149
+ * @param {Required<Pick<ProjectOptions, "name">> &
150
+ * Pick<ProjectOptions, "description" | "url" | "adapter">} opts
151
+ */
152
+ async function scaffoldFromStarter(
153
+ destPath: string,
154
+ starter: string,
155
+ opts: {
156
+ name: string;
157
+ description?: string;
158
+ url?: string;
159
+ adapter?: ProjectOptions["adapter"];
160
+ design?: DesignOptions;
161
+ },
162
+ ) {
163
+ const { name, description = "", url = "", adapter = "static", design } = opts;
164
+
165
+ // Resolved lazily so blank projects never load the (large) starters package.
166
+ const { getStarterDir } = await import("@jxsuite/starters");
167
+ const srcDir = getStarterDir(starter);
168
+
169
+ await cp(srcDir, destPath, {
170
+ recursive: true,
171
+ filter: (src) => {
172
+ const rel = relative(srcDir, src);
173
+ return rel === "" || !rel.split(sep).some((seg) => STARTER_EXCLUDE.has(seg));
174
+ },
175
+ });
176
+
177
+ // Re-stamp project.json with the new project's identity, keeping the starter's design tokens,
178
+ // Content types, image pipeline, and head.
179
+ const projectPath = join(destPath, "project.json");
180
+ const project = JSON.parse(await readFile(projectPath, "utf8")) as Record<string, unknown>;
181
+ project.name = name;
182
+ if (url) {
183
+ project.url = url;
184
+ }
185
+ applyDescription(project, description);
186
+ if (adapter && adapter !== "static") {
187
+ const build = (project.build as Record<string, unknown> | undefined) ?? {};
188
+ build.adapter = adapter;
189
+ project.build = build;
190
+ }
191
+ if (design) {
192
+ applyDesign(project, design, { starter: true });
193
+ }
194
+ await writeFile(projectPath, `${JSON.stringify(project, null, "\t")}\n`);
195
+
196
+ // Rebuild package.json so scaffolded projects get current dep ranges and scripts regardless of
197
+ // What the in-repo starter pinned.
198
+ const packageJson = buildPackageJson({ adapter, description, name });
199
+ await writeFile(join(destPath, "package.json"), `${JSON.stringify(packageJson, null, " ")}\n`);
200
+
201
+ if (adapter && CF_ADAPTERS.has(adapter)) {
202
+ const wranglerJsonc = buildWranglerJsonc({ adapter, slug: packageJson.name });
203
+ await writeFile(join(destPath, "wrangler.jsonc"), wranglerJsonc);
204
+ }
205
+
206
+ if (design?.logo) {
207
+ await writeLogo(destPath, design.logo);
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Apply the design quickstart (colors, fonts, breakpoints) to a project.json object, in place.
213
+ *
214
+ * Blank projects own their style block, so values are written directly. Starters are re-themed
215
+ * best-effort against the conventions the in-repo starters share (`--color-primary`,
216
+ * `--color-text-primary`, `--font-body`, `--font-heading`, top-level `fontFamily` /
217
+ * `backgroundColor`): an override only lands on a token key the starter's style already declares
218
+ * (falling back to the plain CSS property where noted), and is otherwise skipped silently rather
219
+ * than fighting the starter's layered styles. `--color-primary-hover` is intentionally left alone.
220
+ *
221
+ * @param {Record<string, unknown>} project — parsed project.json, mutated in place
222
+ * @param {DesignOptions} design
223
+ * @param {{ starter: boolean }} mode — starter clone vs. blank/template scaffold
224
+ */
225
+ function applyDesign(
226
+ project: Record<string, unknown>,
227
+ design: DesignOptions,
228
+ { starter }: { starter: boolean },
229
+ ) {
230
+ const { accent, background, text, bodyFont, headingFont, media } = design;
231
+ const style = project.style as Record<string, unknown> | undefined;
232
+
233
+ if (style) {
234
+ if (accent && (!starter || "--color-primary" in style)) {
235
+ style["--color-primary"] = accent;
236
+ }
237
+ if (text) {
238
+ if (!starter) {
239
+ style.color = text;
240
+ } else if ("--color-text-primary" in style) {
241
+ style["--color-text-primary"] = text;
242
+ } else if ("color" in style) {
243
+ style.color = text;
244
+ }
245
+ }
246
+ if (background && (!starter || "backgroundColor" in style)) {
247
+ style.backgroundColor = background;
248
+ }
249
+ if (bodyFont) {
250
+ if (!starter) {
251
+ style.fontFamily = bodyFont;
252
+ } else if ("--font-body" in style) {
253
+ style["--font-body"] = bodyFont;
254
+ } else if ("fontFamily" in style) {
255
+ style.fontFamily = bodyFont;
256
+ }
257
+ }
258
+ if (headingFont && (!starter || "--font-heading" in style)) {
259
+ style["--font-heading"] = headingFont;
260
+ }
261
+ }
262
+
263
+ if (media && Object.keys(media).length > 0) {
264
+ project.$media = media;
265
+ }
266
+ }
267
+
268
+ /** File types accepted for the quickstart logo. */
269
+ const LOGO_EXTENSION = /\.(svg|png|jpe?g|webp|gif|ico)$/i;
270
+
271
+ /**
272
+ * Write the design-quickstart logo into the project's public/ directory. The provided name is
273
+ * flattened to its basename (no path segments can escape public/) and must carry an image extension
274
+ * — this is the only guard between UI input and the filesystem.
275
+ *
276
+ * @param {string} destPath — absolute path to the project directory
277
+ * @param {{ name: string; base64: string }} logo
278
+ */
279
+ async function writeLogo(destPath: string, logo: { name: string; base64: string }) {
280
+ const fileName = basename(logo.name);
281
+ if (!LOGO_EXTENSION.test(fileName)) {
282
+ throw new Error(
283
+ `Logo file type not allowed: "${logo.name}" (expected .svg, .png, .jpg, .jpeg, .webp, .gif, or .ico)`,
284
+ );
285
+ }
286
+ const publicDir = join(destPath, "public");
287
+ await mkdir(publicDir, { recursive: true });
288
+ await writeFile(join(publicDir, fileName), Buffer.from(logo.base64, "base64"));
289
+ }
290
+
291
+ /**
292
+ * Set the `<meta name="description">` content in a project's `$head` (or leave it untouched when
293
+ * the starter has no such tag or no description was supplied).
294
+ *
295
+ * @param {Record<string, unknown>} project
296
+ * @param {string} description
297
+ */
298
+ function applyDescription(project: Record<string, unknown>, description: string) {
299
+ if (!description || !Array.isArray(project.$head)) {
300
+ return;
301
+ }
302
+ for (const tag of project.$head as { tagName?: string; attributes?: Record<string, unknown> }[]) {
303
+ if (tag.tagName === "meta" && tag.attributes?.name === "description") {
304
+ tag.attributes.content = description;
305
+ }
306
+ }
58
307
  }
59
308
 
60
309
  /**
@@ -86,14 +335,28 @@ function buildWranglerJsonc({ slug, adapter }: { slug: string; adapter: string }
86
335
  }
87
336
 
88
337
  /** @param {ProjectOptions} opts */
89
- function buildProjectJson({ name, description, url, adapter }: ProjectOptions) {
338
+ function buildProjectJson({ name, description, url, adapter, template = "blank" }: ProjectOptions) {
339
+ // Mobile-app shells draw under device notches/home bars; viewport-fit=cover makes the safe-area
340
+ // Insets used by the app-shell layout take effect.
341
+ const viewport =
342
+ template === "mobile-app"
343
+ ? "width=device-width, initial-scale=1, viewport-fit=cover"
344
+ : "width=device-width, initial-scale=1";
345
+
90
346
  const $head = [
91
347
  {
92
- attributes: { content: "width=device-width, initial-scale=1", name: "viewport" },
348
+ attributes: { content: viewport, name: "viewport" },
93
349
  tagName: "meta",
94
350
  },
95
351
  ];
96
352
 
353
+ if (template === "mobile-app") {
354
+ $head.push({
355
+ attributes: { content: "#ffffff", name: "theme-color" },
356
+ tagName: "meta",
357
+ });
358
+ }
359
+
97
360
  if (description) {
98
361
  $head.push({
99
362
  attributes: { content: description, name: "description" },
@@ -113,12 +376,7 @@ function buildProjectJson({ name, description, url, adapter }: ProjectOptions) {
113
376
 
114
377
  return {
115
378
  $head,
116
- $media: {
117
- "--": "1280px",
118
- "--lg": "(max-width: 1024px)",
119
- "--md": "(max-width: 768px)",
120
- "--sm": "(max-width: 640px)",
121
- },
379
+ $media: mediaForTemplate(template),
122
380
  build,
123
381
  defaults: {
124
382
  lang: "en",
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.35.0",
4
4
  "description": "Scaffold a new Jx project",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -14,12 +14,16 @@
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"
23
27
  },
24
28
  "publishConfig": {
25
29
  "provenance": true
@@ -27,5 +31,8 @@
27
31
  "scripts": {
28
32
  "test": "bun test --isolate",
29
33
  "test:coverage": "bun test --isolate --coverage"
34
+ },
35
+ "dependencies": {
36
+ "@jxsuite/starters": "^0.35.0"
30
37
  }
31
38
  }
@@ -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
+ }