create-lacspace-app 2.7.0 → 2.9.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/README.md CHANGED
@@ -20,6 +20,18 @@ npx create-lacspace-app my-app --template saas --fullstack
20
20
 
21
21
  You choose the *kind* of site you're building. It writes a **real Next.js 15 + React 19 + Tailwind v4 app** — not a hello-world, but a genuinely **polished, modern site**: a fluid `clamp()` type scale, tight display headings, a refined light **and** dark palette, glass chrome, soft layered shadows, a smooth logo marquee, animated counters, scroll reveals and a shimmering primary CTA — every page filled in, an SEO stack wired end-to-end, and a **26-component UI kit** you can drop in anywhere.
22
22
 
23
+ > **New in v2.9 — guided & discoverable.** Made for newcomers:
24
+ > - **Guided start** — the interactive prompt now offers a **recipe** first ("what are you building?"), so you can ship a whole product without knowing the flags.
25
+ > - **`list`** — `npx create-lacspace-app list` shows every template, add-on and recipe. **`explain <name>`** describes what any add-on or recipe gives you (packages, next steps, docs link).
26
+ > - **`--dry-run`** prints the exact files a command would create without writing anything. `--brand "#hex"` is an alias for `--theme`.
27
+
28
+ > **New in v2.8 — recipes.** Scaffold a whole *kind of product* in one command with `--recipe <key>` (a curated template + full-stack mode + add-on stack):
29
+ > - **`ai-saas`** → SaaS + accounts + payments + AI chat + analytics
30
+ > - **`store`** → e-commerce + eSewa/Khalti checkout + email + analytics
31
+ > - **`blog`** → blog + content + search · **`docs-ai`** → docs + RAG + search · **`internal-tool`** → dashboard + auth + analytics + email
32
+ >
33
+ > `npx create-lacspace-app my-app --recipe ai-saas`. Explicit `--template`/`--with`/`--fullstack` still merge on top. Also on the lib API: `listRecipes()` / `getRecipe()`.
34
+
23
35
  > **New in v2.7 — payments & email add-ons.**
24
36
  > - **`payments`** — a checkout wired to **eSewa & Khalti**: orders, integer-safe money (`@lacspace/money`), and the signed eSewa flow that **works end-to-end in test mode with NO credentials**. Khalti activates when you add `KHALTI_SECRET`.
25
37
  > - **`email`** — transactional email: a ready mail service (`@lacspace/mailer`) with beautiful templates (`@lacspace/email-templates`) + address validation. **Logs emails to the console until you add SMTP** — so it runs out-of-the-box, then delivers for real with one env change.
package/dist/index.js CHANGED
@@ -29,14 +29,15 @@ var TEMPLATES = [
29
29
  { key: "marketplace", label: "Marketplace / commerce", description: "A real storefront wired to the Lacspace commerce packages \u2014 cart, checkout, tax, shipping, orders, invoices and Nepal payments.", accent: ["#0d9488", "#6366f1"], siteName: "LSBazaar", siteDescription: "A modern storefront \u2014 cart to checkout, wired end to end." }
30
30
  ];
31
31
  function resolveContext(options = {}) {
32
- const base = TEMPLATES.find((t) => t.key === options.template) ?? TEMPLATES[0];
32
+ const recipe = options.recipe ? RECIPES.find((r) => r.key === String(options.recipe).toLowerCase()) : void 0;
33
+ const base = TEMPLATES.find((t) => t.key === (options.template ?? recipe?.template)) ?? TEMPLATES[0];
33
34
  const accent = resolveAccent(options.theme);
34
35
  const template = accent ? { ...base, accent } : base;
35
36
  const raw = options.name ?? "my-app";
36
37
  const seg = raw.split(/[\\/]/).filter(Boolean).pop() ?? "my-app";
37
38
  const name = seg.toLowerCase().replace(/[^a-z0-9-_]/g, "-").replace(/^-+|-+$/g, "") || "my-app";
38
- const features = normalizeFeatures(options.features);
39
- let mode = options.mode === "dynamic" ? "dynamic" : "static";
39
+ const features = normalizeFeatures([...recipe?.features ?? [], ...options.features ?? []]);
40
+ let mode = options.mode === "dynamic" ? "dynamic" : options.mode === "static" ? "static" : recipe?.mode ?? "static";
40
41
  if (mode === "static" && features.some((f) => f.requiresBackend)) mode = "dynamic";
41
42
  return { name, template, features, mode };
42
43
  }
@@ -5252,6 +5253,13 @@ var faqSection = (ctx) => {
5252
5253
  </div>
5253
5254
  </section>`;
5254
5255
  };
5256
+ var RECIPES = [
5257
+ { key: "ai-saas", label: "AI SaaS", description: "SaaS landing + accounts + payments + a streaming AI chat + analytics.", template: "saas", mode: "dynamic", features: ["auth-pages", "payments", "ai-chat", "analytics"] },
5258
+ { key: "store", label: "Online store", description: "E-commerce storefront + eSewa/Khalti checkout + transactional email + analytics.", template: "ecommerce", mode: "dynamic", features: ["payments", "email", "analytics"] },
5259
+ { key: "blog", label: "Blog", description: "A blog with a Markdown content section, RSS/llms.txt and instant search.", template: "blog", features: ["content", "search"] },
5260
+ { key: "internal-tool", label: "Internal tool", description: "An admin dashboard with accounts (2FA), analytics and email \u2014 full-stack.", template: "dashboard", mode: "dynamic", features: ["auth-pages", "analytics", "email"] },
5261
+ { key: "docs-ai", label: "AI docs", description: "A documentation site with chat-with-your-docs RAG and instant search.", template: "docs", features: ["rag", "search"] }
5262
+ ];
5255
5263
  var contentLib = () => `import fs from "node:fs";
5256
5264
  import path from "node:path";
5257
5265
  import { parseFrontmatter, markdownToHtml, excerpt } from "@lacspace/markdown";
@@ -7209,7 +7217,7 @@ function backendFiles(ctx) {
7209
7217
  }
7210
7218
  var splitList = (s) => s.split(",").map((x) => x.trim()).filter(Boolean);
7211
7219
  function parseArgs(list) {
7212
- const a = { features: [], yes: false, install: true, git: true, pm: "npm", help: false };
7220
+ const a = { features: [], yes: false, install: true, git: true, pm: "npm", help: false, dryRun: false };
7213
7221
  for (let i = 0; i < list.length; i++) {
7214
7222
  const arg = list[i];
7215
7223
  const next = () => list[++i] ?? "";
@@ -7217,8 +7225,9 @@ function parseArgs(list) {
7217
7225
  else if (arg === "-y" || arg === "--yes") a.yes = true;
7218
7226
  else if (arg === "--no-install") a.install = false;
7219
7227
  else if (arg === "--no-git") a.git = false;
7228
+ else if (arg === "--dry-run" || arg === "--dry") a.dryRun = true;
7220
7229
  else if (arg === "--pm") a.pm = next();
7221
- else if (arg === "--theme" || arg === "--accent") a.theme = next();
7230
+ else if (arg === "--theme" || arg === "--accent" || arg === "--brand") a.theme = next();
7222
7231
  else if (arg === "--with" || arg === "--features") a.features.push(...splitList(next()));
7223
7232
  else if (arg === "--fullstack" || arg === "--full-stack" || arg === "--dynamic") a.mode = "dynamic";
7224
7233
  else if (arg === "--static") a.mode = "static";
@@ -7228,10 +7237,13 @@ function parseArgs(list) {
7228
7237
  } else if (arg.startsWith("--mode=")) {
7229
7238
  const m = arg.slice(7).toLowerCase();
7230
7239
  a.mode = m === "dynamic" ? "dynamic" : "static";
7231
- } else if (arg === "-h" || arg === "--help") a.help = true;
7240
+ } else if (arg === "--recipe") a.recipe = next();
7241
+ else if (arg.startsWith("--recipe=")) a.recipe = arg.slice(9);
7242
+ else if (arg === "-h" || arg === "--help") a.help = true;
7232
7243
  else if (arg.startsWith("--template=")) a.template = arg.slice(11);
7233
7244
  else if (arg.startsWith("--theme=")) a.theme = arg.slice(8);
7234
7245
  else if (arg.startsWith("--accent=")) a.theme = arg.slice(9);
7246
+ else if (arg.startsWith("--brand=")) a.theme = arg.slice(8);
7235
7247
  else if (arg.startsWith("--with=")) a.features.push(...splitList(arg.slice(7)));
7236
7248
  else if (arg.startsWith("--features=")) a.features.push(...splitList(arg.slice(11)));
7237
7249
  else if (!arg.startsWith("-") && !a.name) a.name = arg;
@@ -7322,6 +7334,8 @@ ${c("bold", "Usage")}
7322
7334
  npm create lacspace-app@latest <name> [options]
7323
7335
  npx create-lacspace-app <name> --template <key>
7324
7336
  npx create-lacspace-app add <section...> ${c("dim", "# grow an existing app")}
7337
+ npx create-lacspace-app list ${c("dim", "# templates, add-ons & recipes")}
7338
+ npx create-lacspace-app explain <name> ${c("dim", "# what an add-on/recipe gives you")}
7325
7339
 
7326
7340
  ${c("bold", "Templates")}
7327
7341
  ${TEMPLATES.map((t) => ` ${t.key.padEnd(10)} ${t.description}`).join("\n")}
@@ -7332,17 +7346,23 @@ ${c("bold", "Options")}
7332
7346
  MongoDB/Redis backend (JWT auth + CRUD) + shared types
7333
7347
  ${c("dim", "(alias --dynamic; default is --static, a single Next.js app)")}
7334
7348
  --with <a,b> Feature add-ons, comma-separated (alias --features)
7335
- --theme <name|hex> Accent: a preset, a "#hex", or "from,to" (e.g. --theme lacspace)
7349
+ --recipe <key> Start from a curated stack (${RECIPES.map((r) => r.key).join(" | ")})
7350
+ --theme <name|hex> Accent: a preset, a "#hex", or "from,to" (alias --brand)
7336
7351
  --pm <npm|pnpm|yarn|bun> Package manager (default npm)
7352
+ --dry-run Print the files that would be created; write nothing
7337
7353
  --no-install Skip installing dependencies
7338
7354
  --no-git Skip git init
7339
7355
  -y, --yes Accept defaults (needs <name>)
7340
7356
  -h, --help Show this help
7341
7357
 
7342
7358
  ${c("bold", "Feature add-ons")} ${c("dim", "(--with) \u2014 free & keyless, local by default")}
7343
- ${FEATURES.map((f) => ` ${f.key.padEnd(10)} ${f.description}`).join("\n")}
7359
+ ${FEATURES.map((f) => ` ${f.key.padEnd(11)} ${f.description}`).join("\n")}
7344
7360
  ${c("dim", "e.g. npx create-lacspace-app my-app --template saas --with ai-chat,rag")}
7345
7361
 
7362
+ ${c("bold", "Recipes")} ${c("dim", "(--recipe) \u2014 a whole product in one command")}
7363
+ ${RECIPES.map((r) => ` ${r.key.padEnd(14)} ${r.description}`).join("\n")}
7364
+ ${c("dim", "e.g. npx create-lacspace-app my-app --recipe ai-saas")}
7365
+
7346
7366
  ${c("bold", "Themes")} ${c("dim", "(--theme)")}
7347
7367
  ${Object.keys(THEMES).join(" \xB7 ")}
7348
7368
  ${c("dim", 'or a custom colour: --theme "#ff6a00" \xB7 --theme "#0bb9d9,#7c3aed"')}
@@ -7705,12 +7725,103 @@ ${c("bold", "Use them")} \u2014 import into any page:
7705
7725
  return;
7706
7726
  }
7707
7727
  }
7728
+ function runList() {
7729
+ stdout.write(`
7730
+ ${c("bold", c("magenta", "\u25C6 create-lacspace-app"))} ${c("dim", "\u2014 what you can build")}
7731
+ `);
7732
+ stdout.write(`
7733
+ ${c("bold", "Templates")} ${c("dim", "(-t / --template)")}
7734
+ `);
7735
+ for (const t of TEMPLATES) stdout.write(` ${c("cyan", t.key.padEnd(12))} ${c("dim", t.description)}
7736
+ `);
7737
+ stdout.write(`
7738
+ ${c("bold", "Add-ons")} ${c("dim", "(--with) \u2014 free & keyless")}
7739
+ `);
7740
+ for (const f of FEATURES) stdout.write(` ${c("cyan", f.key.padEnd(12))} ${c("dim", f.description)}${f.requiresBackend ? c("green", " [full-stack]") : ""}
7741
+ `);
7742
+ stdout.write(`
7743
+ ${c("bold", "Recipes")} ${c("dim", "(--recipe) \u2014 a whole product in one command")}
7744
+ `);
7745
+ for (const r of RECIPES) stdout.write(` ${c("cyan", r.key.padEnd(14))} ${c("dim", r.description)}
7746
+ `);
7747
+ stdout.write(`
7748
+ ${c("dim", "Learn one:")} ${c("cyan", "npx create-lacspace-app explain <name>")}
7749
+
7750
+ `);
7751
+ }
7752
+ function runExplain(rawNames) {
7753
+ const name = rawNames.find((n) => !n.startsWith("-"))?.toLowerCase();
7754
+ if (!name) {
7755
+ stdout.write(`Usage: ${c("cyan", "npx create-lacspace-app explain <template|add-on|recipe>")}
7756
+
7757
+ `);
7758
+ runList();
7759
+ return;
7760
+ }
7761
+ const feat = FEATURES.find((f) => f.key === name);
7762
+ const recipe = RECIPES.find((r) => r.key === name);
7763
+ const tmpl = TEMPLATES.find((t) => t.key === name);
7764
+ stdout.write("\n");
7765
+ if (feat) {
7766
+ stdout.write(`${c("bold", c("magenta", "add-on: " + feat.key))} ${feat.requiresBackend ? c("green", "[full-stack]") : c("dim", "[frontend]")}
7767
+
7768
+ ${feat.description}
7769
+ `);
7770
+ const deps = { ...feat.deps, ...feat.backend?.({ name: "app", template: TEMPLATES[0], features: [], mode: "dynamic" })?.deps ?? {} };
7771
+ if (Object.keys(deps).length) stdout.write(`
7772
+ ${c("bold", "Packages")}: ${Object.keys(deps).map((d) => c("cyan", d)).join(", ")}
7773
+ `);
7774
+ stdout.write(`
7775
+ ${c("bold", "Next steps")}:
7776
+ `);
7777
+ for (const s of feat.nextSteps) stdout.write(` ${c("dim", "\u2022")} ${s}
7778
+ `);
7779
+ if (feat.learn) stdout.write(`
7780
+ Learn more \u2192 ${c("cyan", feat.learn)}
7781
+ `);
7782
+ stdout.write(`
7783
+ Add it: ${c("cyan", "npx create-lacspace-app my-app --with " + feat.key)}
7784
+
7785
+ `);
7786
+ } else if (recipe) {
7787
+ stdout.write(`${c("bold", c("magenta", "recipe: " + recipe.key))}
7788
+
7789
+ ${recipe.description}
7790
+
7791
+ ${c("bold", "Template")}: ${c("cyan", recipe.template)}${recipe.mode ? c("dim", " \xB7 " + recipe.mode) : ""}
7792
+ ${c("bold", "Add-ons")}: ${recipe.features.map((f) => c("cyan", f)).join(", ")}
7793
+
7794
+ Use it: ${c("cyan", "npx create-lacspace-app my-app --recipe " + recipe.key)}
7795
+
7796
+ `);
7797
+ } else if (tmpl) {
7798
+ stdout.write(`${c("bold", c("magenta", "template: " + tmpl.key))}
7799
+
7800
+ ${tmpl.description}
7801
+
7802
+ Use it: ${c("cyan", "npx create-lacspace-app my-app --template " + tmpl.key)}
7803
+
7804
+ `);
7805
+ } else {
7806
+ stdout.write(c("yellow", `Unknown "${name}".
7807
+ `));
7808
+ runList();
7809
+ }
7810
+ }
7708
7811
  async function main() {
7709
7812
  const raw = argv.slice(2);
7710
7813
  if (raw[0] === "add") {
7711
7814
  runAdd(raw.slice(1));
7712
7815
  return;
7713
7816
  }
7817
+ if (raw[0] === "list" || raw[0] === "ls") {
7818
+ runList();
7819
+ return;
7820
+ }
7821
+ if (raw[0] === "explain" || raw[0] === "info") {
7822
+ runExplain(raw.slice(1));
7823
+ return;
7824
+ }
7714
7825
  const args = parseArgs(raw);
7715
7826
  if (args.help) {
7716
7827
  stdout.write(HELP + "\n");
@@ -7719,15 +7830,42 @@ async function main() {
7719
7830
  stdout.write(`
7720
7831
  ${c("bold", c("magenta", "\u25C6 create-lacspace-app"))} ${c("dim", "\u2014 a gorgeous Next.js starter, batteries wired")}
7721
7832
 
7833
+ `);
7834
+ const recipe = args.recipe ? RECIPES.find((r) => r.key === args.recipe.toLowerCase()) : void 0;
7835
+ if (args.recipe && !recipe) stdout.write(c("yellow", ` ! Unknown recipe "${args.recipe}" \u2014 ignoring. Try: ${RECIPES.map((r) => r.key).join(", ")}
7836
+ `));
7837
+ if (recipe) stdout.write(` ${c("green", "\u2714")} Recipe ${c("cyan", recipe.key)} ${c("dim", "\u2014 " + recipe.description)}
7722
7838
  `);
7723
7839
  let name = args.name;
7724
- let templateKey = args.template;
7725
- let mode = args.mode ?? "static";
7726
- const featureKeys = [...args.features];
7840
+ let templateKey = args.template ?? recipe?.template;
7841
+ let mode = args.mode ?? recipe?.mode ?? "static";
7842
+ const featureKeys = [...recipe?.features ?? [], ...args.features];
7843
+ let usedRecipe = Boolean(recipe);
7727
7844
  if (!args.yes && stdin.isTTY) {
7728
7845
  const rl = createInterface({ input: stdin, output: stdout });
7729
7846
  try {
7730
7847
  if (!name) name = (await rl.question(`${c("green", "?")} Project name ${c("dim", "(my-app)")}: `)).trim() || "my-app";
7848
+ if (!usedRecipe && !templateKey && featureKeys.length === 0) {
7849
+ stdout.write(`
7850
+ Start from a recipe? ${c("dim", "(a template + add-ons, ready to go)")}
7851
+ `);
7852
+ RECIPES.forEach((r, i) => stdout.write(` ${c("cyan", String(i + 1))}. ${c("bold", r.label)} ${c("dim", "\u2014 " + r.description)}
7853
+ `));
7854
+ stdout.write(` ${c("cyan", "0")}. ${c("dim", "No recipe \u2014 I'll choose a template myself")}
7855
+ `);
7856
+ const ans = (await rl.question(`
7857
+ ${c("green", "?")} Recipe ${c("dim", "(0)")}: `)).trim() || "0";
7858
+ const idx = /^\d+$/.test(ans) ? parseInt(ans, 10) - 1 : RECIPES.findIndex((r) => r.key === ans);
7859
+ const chosen = ans === "0" ? void 0 : RECIPES[idx];
7860
+ if (chosen) {
7861
+ usedRecipe = true;
7862
+ templateKey = chosen.template;
7863
+ mode = chosen.mode ?? mode;
7864
+ for (const k of chosen.features) if (!featureKeys.includes(k)) featureKeys.push(k);
7865
+ stdout.write(` ${c("green", "\u2714")} Recipe ${c("cyan", chosen.key)}
7866
+ `);
7867
+ }
7868
+ }
7731
7869
  if (!templateKey) {
7732
7870
  stdout.write(`
7733
7871
  Choose a template:
@@ -7739,7 +7877,7 @@ ${c("green", "?")} Template ${c("dim", "(1)")}: `)).trim() || "1";
7739
7877
  const idx = /^\d+$/.test(ans) ? parseInt(ans, 10) - 1 : TEMPLATES.findIndex((t) => t.key === ans);
7740
7878
  templateKey = TEMPLATES[idx]?.key ?? "personal";
7741
7879
  }
7742
- if (args.mode === void 0) {
7880
+ if (args.mode === void 0 && !usedRecipe) {
7743
7881
  stdout.write(`
7744
7882
  What kind of app?
7745
7883
  `);
@@ -7792,7 +7930,25 @@ ${c("green", "?")} Add features? ${c("dim", "(comma-separated numbers, or Enter
7792
7930
  return;
7793
7931
  }
7794
7932
  const features = normalizeFeatures(featureKeys);
7933
+ if (mode === "static" && features.some((f) => f.requiresBackend)) {
7934
+ mode = "dynamic";
7935
+ stdout.write(` ${c("dim", "\u2191 a selected add-on needs a backend \u2014 building full-stack")}
7936
+ `);
7937
+ }
7795
7938
  const files = buildFiles({ name: projectName, template, features, mode });
7939
+ if (args.dryRun) {
7940
+ const keys = Object.keys(files).sort();
7941
+ stdout.write(`
7942
+ ${c("bold", "Dry run")} ${c("dim", `\u2014 ${keys.length} files (${mode}${features.length ? ", +" + features.map((f) => f.key).join(",") : ""}), nothing written`)}
7943
+ `);
7944
+ for (const rel of keys) stdout.write(` ${c("dim", "+ " + rel)}
7945
+ `);
7946
+ stdout.write(`
7947
+ ${c("dim", "Remove --dry-run to create the project.")}
7948
+
7949
+ `);
7950
+ return;
7951
+ }
7796
7952
  for (const [rel, content] of Object.entries(files)) {
7797
7953
  const full = join(dir, rel);
7798
7954
  mkdirSync(dirname(full), { recursive: true });
@@ -7880,4 +8036,4 @@ if (invokedAsCli) {
7880
8036
  });
7881
8037
  }
7882
8038
 
7883
- export { FEATURES, SECTIONS, TEMPLATES, applyFeatures, buildFiles, normalizeFeatures, resolveContext };
8039
+ export { FEATURES, RECIPES, SECTIONS, TEMPLATES, applyFeatures, buildFiles, normalizeFeatures, resolveContext };
package/dist/lib.cjs CHANGED
@@ -33,14 +33,15 @@ var TEMPLATES = [
33
33
  { key: "marketplace", label: "Marketplace / commerce", description: "A real storefront wired to the Lacspace commerce packages \u2014 cart, checkout, tax, shipping, orders, invoices and Nepal payments.", accent: ["#0d9488", "#6366f1"], siteName: "LSBazaar", siteDescription: "A modern storefront \u2014 cart to checkout, wired end to end." }
34
34
  ];
35
35
  function resolveContext(options = {}) {
36
- const base = TEMPLATES.find((t) => t.key === options.template) ?? TEMPLATES[0];
36
+ const recipe = options.recipe ? RECIPES.find((r) => r.key === String(options.recipe).toLowerCase()) : void 0;
37
+ const base = TEMPLATES.find((t) => t.key === (options.template ?? recipe?.template)) ?? TEMPLATES[0];
37
38
  const accent = resolveAccent(options.theme);
38
39
  const template = accent ? { ...base, accent } : base;
39
40
  const raw = options.name ?? "my-app";
40
41
  const seg = raw.split(/[\\/]/).filter(Boolean).pop() ?? "my-app";
41
42
  const name = seg.toLowerCase().replace(/[^a-z0-9-_]/g, "-").replace(/^-+|-+$/g, "") || "my-app";
42
- const features = normalizeFeatures(options.features);
43
- let mode = options.mode === "dynamic" ? "dynamic" : "static";
43
+ const features = normalizeFeatures([...recipe?.features ?? [], ...options.features ?? []]);
44
+ let mode = options.mode === "dynamic" ? "dynamic" : options.mode === "static" ? "static" : recipe?.mode ?? "static";
44
45
  if (mode === "static" && features.some((f) => f.requiresBackend)) mode = "dynamic";
45
46
  return { name, template, features, mode };
46
47
  }
@@ -5256,6 +5257,13 @@ var faqSection = (ctx) => {
5256
5257
  </div>
5257
5258
  </section>`;
5258
5259
  };
5260
+ var RECIPES = [
5261
+ { key: "ai-saas", label: "AI SaaS", description: "SaaS landing + accounts + payments + a streaming AI chat + analytics.", template: "saas", mode: "dynamic", features: ["auth-pages", "payments", "ai-chat", "analytics"] },
5262
+ { key: "store", label: "Online store", description: "E-commerce storefront + eSewa/Khalti checkout + transactional email + analytics.", template: "ecommerce", mode: "dynamic", features: ["payments", "email", "analytics"] },
5263
+ { key: "blog", label: "Blog", description: "A blog with a Markdown content section, RSS/llms.txt and instant search.", template: "blog", features: ["content", "search"] },
5264
+ { key: "internal-tool", label: "Internal tool", description: "An admin dashboard with accounts (2FA), analytics and email \u2014 full-stack.", template: "dashboard", mode: "dynamic", features: ["auth-pages", "analytics", "email"] },
5265
+ { key: "docs-ai", label: "AI docs", description: "A documentation site with chat-with-your-docs RAG and instant search.", template: "docs", features: ["rag", "search"] }
5266
+ ];
5259
5267
  var contentLib = () => `import fs from "node:fs";
5260
5268
  import path from "node:path";
5261
5269
  import { parseFrontmatter, markdownToHtml, excerpt } from "@lacspace/markdown";
@@ -7213,7 +7221,7 @@ function backendFiles(ctx) {
7213
7221
  }
7214
7222
  var splitList = (s) => s.split(",").map((x) => x.trim()).filter(Boolean);
7215
7223
  function parseArgs(list) {
7216
- const a = { features: [], yes: false, install: true, git: true, pm: "npm", help: false };
7224
+ const a = { features: [], yes: false, install: true, git: true, pm: "npm", help: false, dryRun: false };
7217
7225
  for (let i = 0; i < list.length; i++) {
7218
7226
  const arg = list[i];
7219
7227
  const next = () => list[++i] ?? "";
@@ -7221,8 +7229,9 @@ function parseArgs(list) {
7221
7229
  else if (arg === "-y" || arg === "--yes") a.yes = true;
7222
7230
  else if (arg === "--no-install") a.install = false;
7223
7231
  else if (arg === "--no-git") a.git = false;
7232
+ else if (arg === "--dry-run" || arg === "--dry") a.dryRun = true;
7224
7233
  else if (arg === "--pm") a.pm = next();
7225
- else if (arg === "--theme" || arg === "--accent") a.theme = next();
7234
+ else if (arg === "--theme" || arg === "--accent" || arg === "--brand") a.theme = next();
7226
7235
  else if (arg === "--with" || arg === "--features") a.features.push(...splitList(next()));
7227
7236
  else if (arg === "--fullstack" || arg === "--full-stack" || arg === "--dynamic") a.mode = "dynamic";
7228
7237
  else if (arg === "--static") a.mode = "static";
@@ -7232,10 +7241,13 @@ function parseArgs(list) {
7232
7241
  } else if (arg.startsWith("--mode=")) {
7233
7242
  const m = arg.slice(7).toLowerCase();
7234
7243
  a.mode = m === "dynamic" ? "dynamic" : "static";
7235
- } else if (arg === "-h" || arg === "--help") a.help = true;
7244
+ } else if (arg === "--recipe") a.recipe = next();
7245
+ else if (arg.startsWith("--recipe=")) a.recipe = arg.slice(9);
7246
+ else if (arg === "-h" || arg === "--help") a.help = true;
7236
7247
  else if (arg.startsWith("--template=")) a.template = arg.slice(11);
7237
7248
  else if (arg.startsWith("--theme=")) a.theme = arg.slice(8);
7238
7249
  else if (arg.startsWith("--accent=")) a.theme = arg.slice(9);
7250
+ else if (arg.startsWith("--brand=")) a.theme = arg.slice(8);
7239
7251
  else if (arg.startsWith("--with=")) a.features.push(...splitList(arg.slice(7)));
7240
7252
  else if (arg.startsWith("--features=")) a.features.push(...splitList(arg.slice(11)));
7241
7253
  else if (!arg.startsWith("-") && !a.name) a.name = arg;
@@ -7326,6 +7338,8 @@ ${c("bold", "Usage")}
7326
7338
  npm create lacspace-app@latest <name> [options]
7327
7339
  npx create-lacspace-app <name> --template <key>
7328
7340
  npx create-lacspace-app add <section...> ${c("dim", "# grow an existing app")}
7341
+ npx create-lacspace-app list ${c("dim", "# templates, add-ons & recipes")}
7342
+ npx create-lacspace-app explain <name> ${c("dim", "# what an add-on/recipe gives you")}
7329
7343
 
7330
7344
  ${c("bold", "Templates")}
7331
7345
  ${TEMPLATES.map((t) => ` ${t.key.padEnd(10)} ${t.description}`).join("\n")}
@@ -7336,17 +7350,23 @@ ${c("bold", "Options")}
7336
7350
  MongoDB/Redis backend (JWT auth + CRUD) + shared types
7337
7351
  ${c("dim", "(alias --dynamic; default is --static, a single Next.js app)")}
7338
7352
  --with <a,b> Feature add-ons, comma-separated (alias --features)
7339
- --theme <name|hex> Accent: a preset, a "#hex", or "from,to" (e.g. --theme lacspace)
7353
+ --recipe <key> Start from a curated stack (${RECIPES.map((r) => r.key).join(" | ")})
7354
+ --theme <name|hex> Accent: a preset, a "#hex", or "from,to" (alias --brand)
7340
7355
  --pm <npm|pnpm|yarn|bun> Package manager (default npm)
7356
+ --dry-run Print the files that would be created; write nothing
7341
7357
  --no-install Skip installing dependencies
7342
7358
  --no-git Skip git init
7343
7359
  -y, --yes Accept defaults (needs <name>)
7344
7360
  -h, --help Show this help
7345
7361
 
7346
7362
  ${c("bold", "Feature add-ons")} ${c("dim", "(--with) \u2014 free & keyless, local by default")}
7347
- ${FEATURES.map((f) => ` ${f.key.padEnd(10)} ${f.description}`).join("\n")}
7363
+ ${FEATURES.map((f) => ` ${f.key.padEnd(11)} ${f.description}`).join("\n")}
7348
7364
  ${c("dim", "e.g. npx create-lacspace-app my-app --template saas --with ai-chat,rag")}
7349
7365
 
7366
+ ${c("bold", "Recipes")} ${c("dim", "(--recipe) \u2014 a whole product in one command")}
7367
+ ${RECIPES.map((r) => ` ${r.key.padEnd(14)} ${r.description}`).join("\n")}
7368
+ ${c("dim", "e.g. npx create-lacspace-app my-app --recipe ai-saas")}
7369
+
7350
7370
  ${c("bold", "Themes")} ${c("dim", "(--theme)")}
7351
7371
  ${Object.keys(THEMES).join(" \xB7 ")}
7352
7372
  ${c("dim", 'or a custom colour: --theme "#ff6a00" \xB7 --theme "#0bb9d9,#7c3aed"')}
@@ -7709,12 +7729,103 @@ ${c("bold", "Use them")} \u2014 import into any page:
7709
7729
  return;
7710
7730
  }
7711
7731
  }
7732
+ function runList() {
7733
+ process$1.stdout.write(`
7734
+ ${c("bold", c("magenta", "\u25C6 create-lacspace-app"))} ${c("dim", "\u2014 what you can build")}
7735
+ `);
7736
+ process$1.stdout.write(`
7737
+ ${c("bold", "Templates")} ${c("dim", "(-t / --template)")}
7738
+ `);
7739
+ for (const t of TEMPLATES) process$1.stdout.write(` ${c("cyan", t.key.padEnd(12))} ${c("dim", t.description)}
7740
+ `);
7741
+ process$1.stdout.write(`
7742
+ ${c("bold", "Add-ons")} ${c("dim", "(--with) \u2014 free & keyless")}
7743
+ `);
7744
+ for (const f of FEATURES) process$1.stdout.write(` ${c("cyan", f.key.padEnd(12))} ${c("dim", f.description)}${f.requiresBackend ? c("green", " [full-stack]") : ""}
7745
+ `);
7746
+ process$1.stdout.write(`
7747
+ ${c("bold", "Recipes")} ${c("dim", "(--recipe) \u2014 a whole product in one command")}
7748
+ `);
7749
+ for (const r of RECIPES) process$1.stdout.write(` ${c("cyan", r.key.padEnd(14))} ${c("dim", r.description)}
7750
+ `);
7751
+ process$1.stdout.write(`
7752
+ ${c("dim", "Learn one:")} ${c("cyan", "npx create-lacspace-app explain <name>")}
7753
+
7754
+ `);
7755
+ }
7756
+ function runExplain(rawNames) {
7757
+ const name = rawNames.find((n) => !n.startsWith("-"))?.toLowerCase();
7758
+ if (!name) {
7759
+ process$1.stdout.write(`Usage: ${c("cyan", "npx create-lacspace-app explain <template|add-on|recipe>")}
7760
+
7761
+ `);
7762
+ runList();
7763
+ return;
7764
+ }
7765
+ const feat = FEATURES.find((f) => f.key === name);
7766
+ const recipe = RECIPES.find((r) => r.key === name);
7767
+ const tmpl = TEMPLATES.find((t) => t.key === name);
7768
+ process$1.stdout.write("\n");
7769
+ if (feat) {
7770
+ process$1.stdout.write(`${c("bold", c("magenta", "add-on: " + feat.key))} ${feat.requiresBackend ? c("green", "[full-stack]") : c("dim", "[frontend]")}
7771
+
7772
+ ${feat.description}
7773
+ `);
7774
+ const deps = { ...feat.deps, ...feat.backend?.({ name: "app", template: TEMPLATES[0], features: [], mode: "dynamic" })?.deps ?? {} };
7775
+ if (Object.keys(deps).length) process$1.stdout.write(`
7776
+ ${c("bold", "Packages")}: ${Object.keys(deps).map((d) => c("cyan", d)).join(", ")}
7777
+ `);
7778
+ process$1.stdout.write(`
7779
+ ${c("bold", "Next steps")}:
7780
+ `);
7781
+ for (const s of feat.nextSteps) process$1.stdout.write(` ${c("dim", "\u2022")} ${s}
7782
+ `);
7783
+ if (feat.learn) process$1.stdout.write(`
7784
+ Learn more \u2192 ${c("cyan", feat.learn)}
7785
+ `);
7786
+ process$1.stdout.write(`
7787
+ Add it: ${c("cyan", "npx create-lacspace-app my-app --with " + feat.key)}
7788
+
7789
+ `);
7790
+ } else if (recipe) {
7791
+ process$1.stdout.write(`${c("bold", c("magenta", "recipe: " + recipe.key))}
7792
+
7793
+ ${recipe.description}
7794
+
7795
+ ${c("bold", "Template")}: ${c("cyan", recipe.template)}${recipe.mode ? c("dim", " \xB7 " + recipe.mode) : ""}
7796
+ ${c("bold", "Add-ons")}: ${recipe.features.map((f) => c("cyan", f)).join(", ")}
7797
+
7798
+ Use it: ${c("cyan", "npx create-lacspace-app my-app --recipe " + recipe.key)}
7799
+
7800
+ `);
7801
+ } else if (tmpl) {
7802
+ process$1.stdout.write(`${c("bold", c("magenta", "template: " + tmpl.key))}
7803
+
7804
+ ${tmpl.description}
7805
+
7806
+ Use it: ${c("cyan", "npx create-lacspace-app my-app --template " + tmpl.key)}
7807
+
7808
+ `);
7809
+ } else {
7810
+ process$1.stdout.write(c("yellow", `Unknown "${name}".
7811
+ `));
7812
+ runList();
7813
+ }
7814
+ }
7712
7815
  async function main() {
7713
7816
  const raw = process$1.argv.slice(2);
7714
7817
  if (raw[0] === "add") {
7715
7818
  runAdd(raw.slice(1));
7716
7819
  return;
7717
7820
  }
7821
+ if (raw[0] === "list" || raw[0] === "ls") {
7822
+ runList();
7823
+ return;
7824
+ }
7825
+ if (raw[0] === "explain" || raw[0] === "info") {
7826
+ runExplain(raw.slice(1));
7827
+ return;
7828
+ }
7718
7829
  const args = parseArgs(raw);
7719
7830
  if (args.help) {
7720
7831
  process$1.stdout.write(HELP + "\n");
@@ -7723,15 +7834,42 @@ async function main() {
7723
7834
  process$1.stdout.write(`
7724
7835
  ${c("bold", c("magenta", "\u25C6 create-lacspace-app"))} ${c("dim", "\u2014 a gorgeous Next.js starter, batteries wired")}
7725
7836
 
7837
+ `);
7838
+ const recipe = args.recipe ? RECIPES.find((r) => r.key === args.recipe.toLowerCase()) : void 0;
7839
+ if (args.recipe && !recipe) process$1.stdout.write(c("yellow", ` ! Unknown recipe "${args.recipe}" \u2014 ignoring. Try: ${RECIPES.map((r) => r.key).join(", ")}
7840
+ `));
7841
+ if (recipe) process$1.stdout.write(` ${c("green", "\u2714")} Recipe ${c("cyan", recipe.key)} ${c("dim", "\u2014 " + recipe.description)}
7726
7842
  `);
7727
7843
  let name = args.name;
7728
- let templateKey = args.template;
7729
- let mode = args.mode ?? "static";
7730
- const featureKeys = [...args.features];
7844
+ let templateKey = args.template ?? recipe?.template;
7845
+ let mode = args.mode ?? recipe?.mode ?? "static";
7846
+ const featureKeys = [...recipe?.features ?? [], ...args.features];
7847
+ let usedRecipe = Boolean(recipe);
7731
7848
  if (!args.yes && process$1.stdin.isTTY) {
7732
7849
  const rl = promises.createInterface({ input: process$1.stdin, output: process$1.stdout });
7733
7850
  try {
7734
7851
  if (!name) name = (await rl.question(`${c("green", "?")} Project name ${c("dim", "(my-app)")}: `)).trim() || "my-app";
7852
+ if (!usedRecipe && !templateKey && featureKeys.length === 0) {
7853
+ process$1.stdout.write(`
7854
+ Start from a recipe? ${c("dim", "(a template + add-ons, ready to go)")}
7855
+ `);
7856
+ RECIPES.forEach((r, i) => process$1.stdout.write(` ${c("cyan", String(i + 1))}. ${c("bold", r.label)} ${c("dim", "\u2014 " + r.description)}
7857
+ `));
7858
+ process$1.stdout.write(` ${c("cyan", "0")}. ${c("dim", "No recipe \u2014 I'll choose a template myself")}
7859
+ `);
7860
+ const ans = (await rl.question(`
7861
+ ${c("green", "?")} Recipe ${c("dim", "(0)")}: `)).trim() || "0";
7862
+ const idx = /^\d+$/.test(ans) ? parseInt(ans, 10) - 1 : RECIPES.findIndex((r) => r.key === ans);
7863
+ const chosen = ans === "0" ? void 0 : RECIPES[idx];
7864
+ if (chosen) {
7865
+ usedRecipe = true;
7866
+ templateKey = chosen.template;
7867
+ mode = chosen.mode ?? mode;
7868
+ for (const k of chosen.features) if (!featureKeys.includes(k)) featureKeys.push(k);
7869
+ process$1.stdout.write(` ${c("green", "\u2714")} Recipe ${c("cyan", chosen.key)}
7870
+ `);
7871
+ }
7872
+ }
7735
7873
  if (!templateKey) {
7736
7874
  process$1.stdout.write(`
7737
7875
  Choose a template:
@@ -7743,7 +7881,7 @@ ${c("green", "?")} Template ${c("dim", "(1)")}: `)).trim() || "1";
7743
7881
  const idx = /^\d+$/.test(ans) ? parseInt(ans, 10) - 1 : TEMPLATES.findIndex((t) => t.key === ans);
7744
7882
  templateKey = TEMPLATES[idx]?.key ?? "personal";
7745
7883
  }
7746
- if (args.mode === void 0) {
7884
+ if (args.mode === void 0 && !usedRecipe) {
7747
7885
  process$1.stdout.write(`
7748
7886
  What kind of app?
7749
7887
  `);
@@ -7796,7 +7934,25 @@ ${c("green", "?")} Add features? ${c("dim", "(comma-separated numbers, or Enter
7796
7934
  return;
7797
7935
  }
7798
7936
  const features = normalizeFeatures(featureKeys);
7937
+ if (mode === "static" && features.some((f) => f.requiresBackend)) {
7938
+ mode = "dynamic";
7939
+ process$1.stdout.write(` ${c("dim", "\u2191 a selected add-on needs a backend \u2014 building full-stack")}
7940
+ `);
7941
+ }
7799
7942
  const files = buildFiles({ name: projectName, template, features, mode });
7943
+ if (args.dryRun) {
7944
+ const keys = Object.keys(files).sort();
7945
+ process$1.stdout.write(`
7946
+ ${c("bold", "Dry run")} ${c("dim", `\u2014 ${keys.length} files (${mode}${features.length ? ", +" + features.map((f) => f.key).join(",") : ""}), nothing written`)}
7947
+ `);
7948
+ for (const rel of keys) process$1.stdout.write(` ${c("dim", "+ " + rel)}
7949
+ `);
7950
+ process$1.stdout.write(`
7951
+ ${c("dim", "Remove --dry-run to create the project.")}
7952
+
7953
+ `);
7954
+ return;
7955
+ }
7800
7956
  for (const [rel, content] of Object.entries(files)) {
7801
7957
  const full = path.join(dir, rel);
7802
7958
  fs.mkdirSync(path.dirname(full), { recursive: true });
@@ -7907,6 +8063,13 @@ function getFeature(key) {
7907
8063
  const f = FEATURES.find((x) => x.key === key);
7908
8064
  return f ? { ...f } : void 0;
7909
8065
  }
8066
+ function listRecipes() {
8067
+ return RECIPES.map((r) => ({ ...r, features: [...r.features] }));
8068
+ }
8069
+ function getRecipe(key) {
8070
+ const r = RECIPES.find((x) => x.key === key);
8071
+ return r ? { ...r, features: [...r.features] } : void 0;
8072
+ }
7910
8073
  function generateProject(options = {}) {
7911
8074
  return buildFiles(resolveContext(options));
7912
8075
  }
@@ -7924,9 +8087,11 @@ async function scaffold(options = {}) {
7924
8087
 
7925
8088
  exports.generateProject = generateProject;
7926
8089
  exports.getFeature = getFeature;
8090
+ exports.getRecipe = getRecipe;
7927
8091
  exports.getSection = getSection;
7928
8092
  exports.getTemplate = getTemplate;
7929
8093
  exports.listFeatures = listFeatures;
8094
+ exports.listRecipes = listRecipes;
7930
8095
  exports.listSections = listSections;
7931
8096
  exports.listTemplates = listTemplates;
7932
8097
  exports.scaffold = scaffold;
package/dist/lib.d.cts CHANGED
@@ -104,6 +104,28 @@ interface GenerateOptions {
104
104
  * changes.
105
105
  */
106
106
  mode?: "static" | "dynamic";
107
+ /**
108
+ * A one-shot **recipe** (see {@link RECIPES}) — a curated bundle of template +
109
+ * mode + feature add-ons for a whole kind of product, e.g. `"ai-saas"` or
110
+ * `"store"`. Explicit `template`/`features`/`mode` still win and are merged on
111
+ * top. Omit for no recipe.
112
+ */
113
+ recipe?: string;
114
+ }
115
+ /**
116
+ * A one-command **recipe**: a curated template + mode + add-on stack for a whole
117
+ * kind of product. `npx create-lacspace-app my-app --recipe ai-saas`.
118
+ */
119
+ interface RecipeDef {
120
+ key: string;
121
+ label: string;
122
+ description: string;
123
+ /** The template this recipe starts from. */
124
+ template: string;
125
+ /** Force full-stack when the stack needs a backend (add-ons can also force it). */
126
+ mode?: "static" | "dynamic";
127
+ /** The add-ons this recipe layers on. */
128
+ features: string[];
107
129
  }
108
130
 
109
131
  /** A project file map: relative path → file contents. */
@@ -131,6 +153,13 @@ declare function getSection(name: string): string | undefined;
131
153
  declare function listFeatures(): FeatureDef[];
132
154
  /** Look up one feature add-on's definition by key, or `undefined`. */
133
155
  declare function getFeature(key: string): FeatureDef | undefined;
156
+ /**
157
+ * The built-in one-shot recipes (see {@link RecipeDef}) — curated template + mode +
158
+ * add-on bundles you can pass as `options.recipe` or request with `--recipe <key>`.
159
+ */
160
+ declare function listRecipes(): RecipeDef[];
161
+ /** Look up one recipe's definition by key, or `undefined`. */
162
+ declare function getRecipe(key: string): RecipeDef | undefined;
134
163
  /**
135
164
  * Generate a full project as an in-memory file map (relative path → contents).
136
165
  *
@@ -162,4 +191,4 @@ interface ScaffoldResult {
162
191
  */
163
192
  declare function scaffold(options?: ScaffoldOptions): Promise<ScaffoldResult>;
164
193
 
165
- export { type FeatureBackend, type FeatureDef, type FeatureRoute, type GenerateOptions, type ProjectFiles, type ScaffoldOptions, type ScaffoldResult, type TemplateDef, generateProject, getFeature, getSection, getTemplate, listFeatures, listSections, listTemplates, scaffold, templateKeys, templates };
194
+ export { type FeatureBackend, type FeatureDef, type FeatureRoute, type GenerateOptions, type ProjectFiles, type RecipeDef, type ScaffoldOptions, type ScaffoldResult, type TemplateDef, generateProject, getFeature, getRecipe, getSection, getTemplate, listFeatures, listRecipes, listSections, listTemplates, scaffold, templateKeys, templates };
package/dist/lib.d.ts CHANGED
@@ -104,6 +104,28 @@ interface GenerateOptions {
104
104
  * changes.
105
105
  */
106
106
  mode?: "static" | "dynamic";
107
+ /**
108
+ * A one-shot **recipe** (see {@link RECIPES}) — a curated bundle of template +
109
+ * mode + feature add-ons for a whole kind of product, e.g. `"ai-saas"` or
110
+ * `"store"`. Explicit `template`/`features`/`mode` still win and are merged on
111
+ * top. Omit for no recipe.
112
+ */
113
+ recipe?: string;
114
+ }
115
+ /**
116
+ * A one-command **recipe**: a curated template + mode + add-on stack for a whole
117
+ * kind of product. `npx create-lacspace-app my-app --recipe ai-saas`.
118
+ */
119
+ interface RecipeDef {
120
+ key: string;
121
+ label: string;
122
+ description: string;
123
+ /** The template this recipe starts from. */
124
+ template: string;
125
+ /** Force full-stack when the stack needs a backend (add-ons can also force it). */
126
+ mode?: "static" | "dynamic";
127
+ /** The add-ons this recipe layers on. */
128
+ features: string[];
107
129
  }
108
130
 
109
131
  /** A project file map: relative path → file contents. */
@@ -131,6 +153,13 @@ declare function getSection(name: string): string | undefined;
131
153
  declare function listFeatures(): FeatureDef[];
132
154
  /** Look up one feature add-on's definition by key, or `undefined`. */
133
155
  declare function getFeature(key: string): FeatureDef | undefined;
156
+ /**
157
+ * The built-in one-shot recipes (see {@link RecipeDef}) — curated template + mode +
158
+ * add-on bundles you can pass as `options.recipe` or request with `--recipe <key>`.
159
+ */
160
+ declare function listRecipes(): RecipeDef[];
161
+ /** Look up one recipe's definition by key, or `undefined`. */
162
+ declare function getRecipe(key: string): RecipeDef | undefined;
134
163
  /**
135
164
  * Generate a full project as an in-memory file map (relative path → contents).
136
165
  *
@@ -162,4 +191,4 @@ interface ScaffoldResult {
162
191
  */
163
192
  declare function scaffold(options?: ScaffoldOptions): Promise<ScaffoldResult>;
164
193
 
165
- export { type FeatureBackend, type FeatureDef, type FeatureRoute, type GenerateOptions, type ProjectFiles, type ScaffoldOptions, type ScaffoldResult, type TemplateDef, generateProject, getFeature, getSection, getTemplate, listFeatures, listSections, listTemplates, scaffold, templateKeys, templates };
194
+ export { type FeatureBackend, type FeatureDef, type FeatureRoute, type GenerateOptions, type ProjectFiles, type RecipeDef, type ScaffoldOptions, type ScaffoldResult, type TemplateDef, generateProject, getFeature, getRecipe, getSection, getTemplate, listFeatures, listRecipes, listSections, listTemplates, scaffold, templateKeys, templates };
package/dist/lib.js CHANGED
@@ -30,14 +30,15 @@ var TEMPLATES = [
30
30
  { key: "marketplace", label: "Marketplace / commerce", description: "A real storefront wired to the Lacspace commerce packages \u2014 cart, checkout, tax, shipping, orders, invoices and Nepal payments.", accent: ["#0d9488", "#6366f1"], siteName: "LSBazaar", siteDescription: "A modern storefront \u2014 cart to checkout, wired end to end." }
31
31
  ];
32
32
  function resolveContext(options = {}) {
33
- const base = TEMPLATES.find((t) => t.key === options.template) ?? TEMPLATES[0];
33
+ const recipe = options.recipe ? RECIPES.find((r) => r.key === String(options.recipe).toLowerCase()) : void 0;
34
+ const base = TEMPLATES.find((t) => t.key === (options.template ?? recipe?.template)) ?? TEMPLATES[0];
34
35
  const accent = resolveAccent(options.theme);
35
36
  const template = accent ? { ...base, accent } : base;
36
37
  const raw = options.name ?? "my-app";
37
38
  const seg = raw.split(/[\\/]/).filter(Boolean).pop() ?? "my-app";
38
39
  const name = seg.toLowerCase().replace(/[^a-z0-9-_]/g, "-").replace(/^-+|-+$/g, "") || "my-app";
39
- const features = normalizeFeatures(options.features);
40
- let mode = options.mode === "dynamic" ? "dynamic" : "static";
40
+ const features = normalizeFeatures([...recipe?.features ?? [], ...options.features ?? []]);
41
+ let mode = options.mode === "dynamic" ? "dynamic" : options.mode === "static" ? "static" : recipe?.mode ?? "static";
41
42
  if (mode === "static" && features.some((f) => f.requiresBackend)) mode = "dynamic";
42
43
  return { name, template, features, mode };
43
44
  }
@@ -5253,6 +5254,13 @@ var faqSection = (ctx) => {
5253
5254
  </div>
5254
5255
  </section>`;
5255
5256
  };
5257
+ var RECIPES = [
5258
+ { key: "ai-saas", label: "AI SaaS", description: "SaaS landing + accounts + payments + a streaming AI chat + analytics.", template: "saas", mode: "dynamic", features: ["auth-pages", "payments", "ai-chat", "analytics"] },
5259
+ { key: "store", label: "Online store", description: "E-commerce storefront + eSewa/Khalti checkout + transactional email + analytics.", template: "ecommerce", mode: "dynamic", features: ["payments", "email", "analytics"] },
5260
+ { key: "blog", label: "Blog", description: "A blog with a Markdown content section, RSS/llms.txt and instant search.", template: "blog", features: ["content", "search"] },
5261
+ { key: "internal-tool", label: "Internal tool", description: "An admin dashboard with accounts (2FA), analytics and email \u2014 full-stack.", template: "dashboard", mode: "dynamic", features: ["auth-pages", "analytics", "email"] },
5262
+ { key: "docs-ai", label: "AI docs", description: "A documentation site with chat-with-your-docs RAG and instant search.", template: "docs", features: ["rag", "search"] }
5263
+ ];
5256
5264
  var contentLib = () => `import fs from "node:fs";
5257
5265
  import path from "node:path";
5258
5266
  import { parseFrontmatter, markdownToHtml, excerpt } from "@lacspace/markdown";
@@ -7210,7 +7218,7 @@ function backendFiles(ctx) {
7210
7218
  }
7211
7219
  var splitList = (s) => s.split(",").map((x) => x.trim()).filter(Boolean);
7212
7220
  function parseArgs(list) {
7213
- const a = { features: [], yes: false, install: true, git: true, pm: "npm", help: false };
7221
+ const a = { features: [], yes: false, install: true, git: true, pm: "npm", help: false, dryRun: false };
7214
7222
  for (let i = 0; i < list.length; i++) {
7215
7223
  const arg = list[i];
7216
7224
  const next = () => list[++i] ?? "";
@@ -7218,8 +7226,9 @@ function parseArgs(list) {
7218
7226
  else if (arg === "-y" || arg === "--yes") a.yes = true;
7219
7227
  else if (arg === "--no-install") a.install = false;
7220
7228
  else if (arg === "--no-git") a.git = false;
7229
+ else if (arg === "--dry-run" || arg === "--dry") a.dryRun = true;
7221
7230
  else if (arg === "--pm") a.pm = next();
7222
- else if (arg === "--theme" || arg === "--accent") a.theme = next();
7231
+ else if (arg === "--theme" || arg === "--accent" || arg === "--brand") a.theme = next();
7223
7232
  else if (arg === "--with" || arg === "--features") a.features.push(...splitList(next()));
7224
7233
  else if (arg === "--fullstack" || arg === "--full-stack" || arg === "--dynamic") a.mode = "dynamic";
7225
7234
  else if (arg === "--static") a.mode = "static";
@@ -7229,10 +7238,13 @@ function parseArgs(list) {
7229
7238
  } else if (arg.startsWith("--mode=")) {
7230
7239
  const m = arg.slice(7).toLowerCase();
7231
7240
  a.mode = m === "dynamic" ? "dynamic" : "static";
7232
- } else if (arg === "-h" || arg === "--help") a.help = true;
7241
+ } else if (arg === "--recipe") a.recipe = next();
7242
+ else if (arg.startsWith("--recipe=")) a.recipe = arg.slice(9);
7243
+ else if (arg === "-h" || arg === "--help") a.help = true;
7233
7244
  else if (arg.startsWith("--template=")) a.template = arg.slice(11);
7234
7245
  else if (arg.startsWith("--theme=")) a.theme = arg.slice(8);
7235
7246
  else if (arg.startsWith("--accent=")) a.theme = arg.slice(9);
7247
+ else if (arg.startsWith("--brand=")) a.theme = arg.slice(8);
7236
7248
  else if (arg.startsWith("--with=")) a.features.push(...splitList(arg.slice(7)));
7237
7249
  else if (arg.startsWith("--features=")) a.features.push(...splitList(arg.slice(11)));
7238
7250
  else if (!arg.startsWith("-") && !a.name) a.name = arg;
@@ -7323,6 +7335,8 @@ ${c("bold", "Usage")}
7323
7335
  npm create lacspace-app@latest <name> [options]
7324
7336
  npx create-lacspace-app <name> --template <key>
7325
7337
  npx create-lacspace-app add <section...> ${c("dim", "# grow an existing app")}
7338
+ npx create-lacspace-app list ${c("dim", "# templates, add-ons & recipes")}
7339
+ npx create-lacspace-app explain <name> ${c("dim", "# what an add-on/recipe gives you")}
7326
7340
 
7327
7341
  ${c("bold", "Templates")}
7328
7342
  ${TEMPLATES.map((t) => ` ${t.key.padEnd(10)} ${t.description}`).join("\n")}
@@ -7333,17 +7347,23 @@ ${c("bold", "Options")}
7333
7347
  MongoDB/Redis backend (JWT auth + CRUD) + shared types
7334
7348
  ${c("dim", "(alias --dynamic; default is --static, a single Next.js app)")}
7335
7349
  --with <a,b> Feature add-ons, comma-separated (alias --features)
7336
- --theme <name|hex> Accent: a preset, a "#hex", or "from,to" (e.g. --theme lacspace)
7350
+ --recipe <key> Start from a curated stack (${RECIPES.map((r) => r.key).join(" | ")})
7351
+ --theme <name|hex> Accent: a preset, a "#hex", or "from,to" (alias --brand)
7337
7352
  --pm <npm|pnpm|yarn|bun> Package manager (default npm)
7353
+ --dry-run Print the files that would be created; write nothing
7338
7354
  --no-install Skip installing dependencies
7339
7355
  --no-git Skip git init
7340
7356
  -y, --yes Accept defaults (needs <name>)
7341
7357
  -h, --help Show this help
7342
7358
 
7343
7359
  ${c("bold", "Feature add-ons")} ${c("dim", "(--with) \u2014 free & keyless, local by default")}
7344
- ${FEATURES.map((f) => ` ${f.key.padEnd(10)} ${f.description}`).join("\n")}
7360
+ ${FEATURES.map((f) => ` ${f.key.padEnd(11)} ${f.description}`).join("\n")}
7345
7361
  ${c("dim", "e.g. npx create-lacspace-app my-app --template saas --with ai-chat,rag")}
7346
7362
 
7363
+ ${c("bold", "Recipes")} ${c("dim", "(--recipe) \u2014 a whole product in one command")}
7364
+ ${RECIPES.map((r) => ` ${r.key.padEnd(14)} ${r.description}`).join("\n")}
7365
+ ${c("dim", "e.g. npx create-lacspace-app my-app --recipe ai-saas")}
7366
+
7347
7367
  ${c("bold", "Themes")} ${c("dim", "(--theme)")}
7348
7368
  ${Object.keys(THEMES).join(" \xB7 ")}
7349
7369
  ${c("dim", 'or a custom colour: --theme "#ff6a00" \xB7 --theme "#0bb9d9,#7c3aed"')}
@@ -7706,12 +7726,103 @@ ${c("bold", "Use them")} \u2014 import into any page:
7706
7726
  return;
7707
7727
  }
7708
7728
  }
7729
+ function runList() {
7730
+ stdout.write(`
7731
+ ${c("bold", c("magenta", "\u25C6 create-lacspace-app"))} ${c("dim", "\u2014 what you can build")}
7732
+ `);
7733
+ stdout.write(`
7734
+ ${c("bold", "Templates")} ${c("dim", "(-t / --template)")}
7735
+ `);
7736
+ for (const t of TEMPLATES) stdout.write(` ${c("cyan", t.key.padEnd(12))} ${c("dim", t.description)}
7737
+ `);
7738
+ stdout.write(`
7739
+ ${c("bold", "Add-ons")} ${c("dim", "(--with) \u2014 free & keyless")}
7740
+ `);
7741
+ for (const f of FEATURES) stdout.write(` ${c("cyan", f.key.padEnd(12))} ${c("dim", f.description)}${f.requiresBackend ? c("green", " [full-stack]") : ""}
7742
+ `);
7743
+ stdout.write(`
7744
+ ${c("bold", "Recipes")} ${c("dim", "(--recipe) \u2014 a whole product in one command")}
7745
+ `);
7746
+ for (const r of RECIPES) stdout.write(` ${c("cyan", r.key.padEnd(14))} ${c("dim", r.description)}
7747
+ `);
7748
+ stdout.write(`
7749
+ ${c("dim", "Learn one:")} ${c("cyan", "npx create-lacspace-app explain <name>")}
7750
+
7751
+ `);
7752
+ }
7753
+ function runExplain(rawNames) {
7754
+ const name = rawNames.find((n) => !n.startsWith("-"))?.toLowerCase();
7755
+ if (!name) {
7756
+ stdout.write(`Usage: ${c("cyan", "npx create-lacspace-app explain <template|add-on|recipe>")}
7757
+
7758
+ `);
7759
+ runList();
7760
+ return;
7761
+ }
7762
+ const feat = FEATURES.find((f) => f.key === name);
7763
+ const recipe = RECIPES.find((r) => r.key === name);
7764
+ const tmpl = TEMPLATES.find((t) => t.key === name);
7765
+ stdout.write("\n");
7766
+ if (feat) {
7767
+ stdout.write(`${c("bold", c("magenta", "add-on: " + feat.key))} ${feat.requiresBackend ? c("green", "[full-stack]") : c("dim", "[frontend]")}
7768
+
7769
+ ${feat.description}
7770
+ `);
7771
+ const deps = { ...feat.deps, ...feat.backend?.({ name: "app", template: TEMPLATES[0], features: [], mode: "dynamic" })?.deps ?? {} };
7772
+ if (Object.keys(deps).length) stdout.write(`
7773
+ ${c("bold", "Packages")}: ${Object.keys(deps).map((d) => c("cyan", d)).join(", ")}
7774
+ `);
7775
+ stdout.write(`
7776
+ ${c("bold", "Next steps")}:
7777
+ `);
7778
+ for (const s of feat.nextSteps) stdout.write(` ${c("dim", "\u2022")} ${s}
7779
+ `);
7780
+ if (feat.learn) stdout.write(`
7781
+ Learn more \u2192 ${c("cyan", feat.learn)}
7782
+ `);
7783
+ stdout.write(`
7784
+ Add it: ${c("cyan", "npx create-lacspace-app my-app --with " + feat.key)}
7785
+
7786
+ `);
7787
+ } else if (recipe) {
7788
+ stdout.write(`${c("bold", c("magenta", "recipe: " + recipe.key))}
7789
+
7790
+ ${recipe.description}
7791
+
7792
+ ${c("bold", "Template")}: ${c("cyan", recipe.template)}${recipe.mode ? c("dim", " \xB7 " + recipe.mode) : ""}
7793
+ ${c("bold", "Add-ons")}: ${recipe.features.map((f) => c("cyan", f)).join(", ")}
7794
+
7795
+ Use it: ${c("cyan", "npx create-lacspace-app my-app --recipe " + recipe.key)}
7796
+
7797
+ `);
7798
+ } else if (tmpl) {
7799
+ stdout.write(`${c("bold", c("magenta", "template: " + tmpl.key))}
7800
+
7801
+ ${tmpl.description}
7802
+
7803
+ Use it: ${c("cyan", "npx create-lacspace-app my-app --template " + tmpl.key)}
7804
+
7805
+ `);
7806
+ } else {
7807
+ stdout.write(c("yellow", `Unknown "${name}".
7808
+ `));
7809
+ runList();
7810
+ }
7811
+ }
7709
7812
  async function main() {
7710
7813
  const raw = argv.slice(2);
7711
7814
  if (raw[0] === "add") {
7712
7815
  runAdd(raw.slice(1));
7713
7816
  return;
7714
7817
  }
7818
+ if (raw[0] === "list" || raw[0] === "ls") {
7819
+ runList();
7820
+ return;
7821
+ }
7822
+ if (raw[0] === "explain" || raw[0] === "info") {
7823
+ runExplain(raw.slice(1));
7824
+ return;
7825
+ }
7715
7826
  const args = parseArgs(raw);
7716
7827
  if (args.help) {
7717
7828
  stdout.write(HELP + "\n");
@@ -7720,15 +7831,42 @@ async function main() {
7720
7831
  stdout.write(`
7721
7832
  ${c("bold", c("magenta", "\u25C6 create-lacspace-app"))} ${c("dim", "\u2014 a gorgeous Next.js starter, batteries wired")}
7722
7833
 
7834
+ `);
7835
+ const recipe = args.recipe ? RECIPES.find((r) => r.key === args.recipe.toLowerCase()) : void 0;
7836
+ if (args.recipe && !recipe) stdout.write(c("yellow", ` ! Unknown recipe "${args.recipe}" \u2014 ignoring. Try: ${RECIPES.map((r) => r.key).join(", ")}
7837
+ `));
7838
+ if (recipe) stdout.write(` ${c("green", "\u2714")} Recipe ${c("cyan", recipe.key)} ${c("dim", "\u2014 " + recipe.description)}
7723
7839
  `);
7724
7840
  let name = args.name;
7725
- let templateKey = args.template;
7726
- let mode = args.mode ?? "static";
7727
- const featureKeys = [...args.features];
7841
+ let templateKey = args.template ?? recipe?.template;
7842
+ let mode = args.mode ?? recipe?.mode ?? "static";
7843
+ const featureKeys = [...recipe?.features ?? [], ...args.features];
7844
+ let usedRecipe = Boolean(recipe);
7728
7845
  if (!args.yes && stdin.isTTY) {
7729
7846
  const rl = createInterface({ input: stdin, output: stdout });
7730
7847
  try {
7731
7848
  if (!name) name = (await rl.question(`${c("green", "?")} Project name ${c("dim", "(my-app)")}: `)).trim() || "my-app";
7849
+ if (!usedRecipe && !templateKey && featureKeys.length === 0) {
7850
+ stdout.write(`
7851
+ Start from a recipe? ${c("dim", "(a template + add-ons, ready to go)")}
7852
+ `);
7853
+ RECIPES.forEach((r, i) => stdout.write(` ${c("cyan", String(i + 1))}. ${c("bold", r.label)} ${c("dim", "\u2014 " + r.description)}
7854
+ `));
7855
+ stdout.write(` ${c("cyan", "0")}. ${c("dim", "No recipe \u2014 I'll choose a template myself")}
7856
+ `);
7857
+ const ans = (await rl.question(`
7858
+ ${c("green", "?")} Recipe ${c("dim", "(0)")}: `)).trim() || "0";
7859
+ const idx = /^\d+$/.test(ans) ? parseInt(ans, 10) - 1 : RECIPES.findIndex((r) => r.key === ans);
7860
+ const chosen = ans === "0" ? void 0 : RECIPES[idx];
7861
+ if (chosen) {
7862
+ usedRecipe = true;
7863
+ templateKey = chosen.template;
7864
+ mode = chosen.mode ?? mode;
7865
+ for (const k of chosen.features) if (!featureKeys.includes(k)) featureKeys.push(k);
7866
+ stdout.write(` ${c("green", "\u2714")} Recipe ${c("cyan", chosen.key)}
7867
+ `);
7868
+ }
7869
+ }
7732
7870
  if (!templateKey) {
7733
7871
  stdout.write(`
7734
7872
  Choose a template:
@@ -7740,7 +7878,7 @@ ${c("green", "?")} Template ${c("dim", "(1)")}: `)).trim() || "1";
7740
7878
  const idx = /^\d+$/.test(ans) ? parseInt(ans, 10) - 1 : TEMPLATES.findIndex((t) => t.key === ans);
7741
7879
  templateKey = TEMPLATES[idx]?.key ?? "personal";
7742
7880
  }
7743
- if (args.mode === void 0) {
7881
+ if (args.mode === void 0 && !usedRecipe) {
7744
7882
  stdout.write(`
7745
7883
  What kind of app?
7746
7884
  `);
@@ -7793,7 +7931,25 @@ ${c("green", "?")} Add features? ${c("dim", "(comma-separated numbers, or Enter
7793
7931
  return;
7794
7932
  }
7795
7933
  const features = normalizeFeatures(featureKeys);
7934
+ if (mode === "static" && features.some((f) => f.requiresBackend)) {
7935
+ mode = "dynamic";
7936
+ stdout.write(` ${c("dim", "\u2191 a selected add-on needs a backend \u2014 building full-stack")}
7937
+ `);
7938
+ }
7796
7939
  const files = buildFiles({ name: projectName, template, features, mode });
7940
+ if (args.dryRun) {
7941
+ const keys = Object.keys(files).sort();
7942
+ stdout.write(`
7943
+ ${c("bold", "Dry run")} ${c("dim", `\u2014 ${keys.length} files (${mode}${features.length ? ", +" + features.map((f) => f.key).join(",") : ""}), nothing written`)}
7944
+ `);
7945
+ for (const rel of keys) stdout.write(` ${c("dim", "+ " + rel)}
7946
+ `);
7947
+ stdout.write(`
7948
+ ${c("dim", "Remove --dry-run to create the project.")}
7949
+
7950
+ `);
7951
+ return;
7952
+ }
7797
7953
  for (const [rel, content] of Object.entries(files)) {
7798
7954
  const full = join(dir, rel);
7799
7955
  mkdirSync(dirname(full), { recursive: true });
@@ -7904,6 +8060,13 @@ function getFeature(key) {
7904
8060
  const f = FEATURES.find((x) => x.key === key);
7905
8061
  return f ? { ...f } : void 0;
7906
8062
  }
8063
+ function listRecipes() {
8064
+ return RECIPES.map((r) => ({ ...r, features: [...r.features] }));
8065
+ }
8066
+ function getRecipe(key) {
8067
+ const r = RECIPES.find((x) => x.key === key);
8068
+ return r ? { ...r, features: [...r.features] } : void 0;
8069
+ }
7907
8070
  function generateProject(options = {}) {
7908
8071
  return buildFiles(resolveContext(options));
7909
8072
  }
@@ -7919,4 +8082,4 @@ async function scaffold(options = {}) {
7919
8082
  return { dir: target, files: Object.keys(files) };
7920
8083
  }
7921
8084
 
7922
- export { generateProject, getFeature, getSection, getTemplate, listFeatures, listSections, listTemplates, scaffold, templateKeys, templates };
8085
+ export { generateProject, getFeature, getRecipe, getSection, getTemplate, listFeatures, listRecipes, listSections, listTemplates, scaffold, templateKeys, templates };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-lacspace-app",
3
- "version": "2.7.0",
3
+ "version": "2.9.0",
4
4
  "description": "Scaffold a beautiful, production-ready Next.js app from a Lacspace template — portfolio, business, e-commerce, SaaS, blog, docs, dashboard, restaurant or marketplace — pre-wired with SEO, security headers, sitemap and robots. Use it as a CLI (like create-next-app, but you start gorgeous) or import it as a library to generate projects programmatically.",
5
5
  "type": "module",
6
6
  "bin": {