@django-stack/cli 0.9.0 → 0.11.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.
Files changed (78) hide show
  1. package/dist/bin.js +65 -20
  2. package/dist/index.d.ts +24 -9
  3. package/dist/index.js +1 -1
  4. package/dist/{production-M2SsuuBb.js → production-WThC1aS2.js} +267 -30
  5. package/package.json +1 -1
  6. package/templates/backend/Dockerfile +7 -2
  7. package/templates/backend/config/settings.py +13 -0
  8. package/templates/backend/config/urls.py +11 -0
  9. package/templates/backend/content/admin.py +14 -5
  10. package/templates/backend/content/models.py +2 -2
  11. package/templates/backend/content/tests/test_api.py +15 -3
  12. package/templates/backend/pyproject.toml +1 -1
  13. package/templates/base/.github/workflows/deploy.yml +10 -1
  14. package/templates/base/AGENTS.md +86 -8
  15. package/templates/base/Caddyfile +24 -0
  16. package/templates/base/README.md +74 -6
  17. package/templates/base/SECURITY.md +2 -0
  18. package/templates/base/compose.yaml +28 -2
  19. package/templates/base/env.example +37 -0
  20. package/templates/frontend-astro/AGENTS.md +3 -3
  21. package/templates/frontend-astro/Dockerfile +6 -3
  22. package/templates/frontend-astro/astro.config.mjs +16 -5
  23. package/templates/frontend-astro/package.json +1 -1
  24. package/templates/frontend-astro/pnpm-workspace.yaml +17 -0
  25. package/templates/frontend-astro/src/components/ContactForm.astro +19 -3
  26. package/templates/frontend-astro/src/middleware.ts +9 -1
  27. package/templates/frontend-next/AGENTS.md +3 -3
  28. package/templates/frontend-next/Dockerfile +5 -2
  29. package/templates/frontend-next/package.json +3 -3
  30. package/templates/frontend-next/pnpm-workspace.yaml +11 -0
  31. package/templates/frontend-next/src/app/[...path]/page.tsx +2 -1
  32. package/templates/frontend-next/src/components/ContactForm.tsx +14 -3
  33. package/templates/frontend-next/src/instrumentation.ts +12 -0
  34. package/templates/frontend-next/src/proxy.ts +4 -1
  35. package/templates/frontend-nuxt/AGENTS.md +3 -3
  36. package/templates/frontend-nuxt/Dockerfile +5 -2
  37. package/templates/frontend-nuxt/nuxt.config.ts +7 -3
  38. package/templates/frontend-nuxt/package.json +2 -2
  39. package/templates/frontend-nuxt/pnpm-workspace.yaml +13 -0
  40. package/templates/frontend-nuxt/server/plugins/errors.ts +14 -0
  41. package/templates/frontend-nuxt/server/routes/_content/layout.get.ts +4 -0
  42. package/templates/frontend-nuxt/src/components/ContactForm.vue +15 -2
  43. package/templates/frontend-sveltekit/AGENTS.md +3 -3
  44. package/templates/frontend-sveltekit/Dockerfile +6 -3
  45. package/templates/frontend-sveltekit/package.json +2 -2
  46. package/templates/frontend-sveltekit/pnpm-workspace.yaml +11 -0
  47. package/templates/frontend-sveltekit/src/hooks.server.ts +13 -1
  48. package/templates/frontend-sveltekit/src/lib/components/ContactForm.svelte +15 -2
  49. package/templates/frontend-sveltekit/src/routes/+layout.server.ts +3 -0
  50. package/templates/frontend-sveltekit/src/routes/[...path]/+page.svelte +1 -1
  51. package/templates/frontend-sveltekit/vite.config.ts +10 -3
  52. package/templates/frontend-templates/template.json +5 -0
  53. package/templates/frontend-templates/website/__init__.py +0 -0
  54. package/templates/frontend-templates/website/apps.py +6 -0
  55. package/templates/frontend-templates/website/context_processors.py +16 -0
  56. package/templates/frontend-templates/website/forms.py +20 -0
  57. package/templates/frontend-templates/website/static/website/favicon.svg +1 -0
  58. package/templates/frontend-templates/website/static/website/global.css +232 -0
  59. package/templates/frontend-templates/website/templates/website/404.html +10 -0
  60. package/templates/frontend-templates/website/templates/website/base.html +44 -0
  61. package/templates/frontend-templates/website/templates/website/blocks/call_to_action.html +5 -0
  62. package/templates/frontend-templates/website/templates/website/blocks/image.html +4 -0
  63. package/templates/frontend-templates/website/templates/website/blocks/quote.html +4 -0
  64. package/templates/frontend-templates/website/templates/website/blocks/text.html +2 -0
  65. package/templates/frontend-templates/website/templates/website/home.html +8 -0
  66. package/templates/frontend-templates/website/templates/website/includes/contact_form.html +21 -0
  67. package/templates/frontend-templates/website/templates/website/includes/post_card.html +9 -0
  68. package/templates/frontend-templates/website/templates/website/includes/post_list.html +11 -0
  69. package/templates/frontend-templates/website/templates/website/page.html +10 -0
  70. package/templates/frontend-templates/website/templates/website/post.html +20 -0
  71. package/templates/frontend-templates/website/templates/website/post_list.html +11 -0
  72. package/templates/frontend-templates/website/templates/website/robots.txt +5 -0
  73. package/templates/frontend-templates/website/templates/website/rss.xml +16 -0
  74. package/templates/frontend-templates/website/templates/website/sitemap.xml +8 -0
  75. package/templates/frontend-templates/website/tests/__init__.py +0 -0
  76. package/templates/frontend-templates/website/tests/test_site.py +102 -0
  77. package/templates/frontend-templates/website/urls.py +17 -0
  78. package/templates/frontend-templates/website/views.py +176 -0
@@ -258,9 +258,10 @@ async function runDoctor(directory) {
258
258
  const services = running ? running.split("\n").filter(Boolean) : [];
259
259
  const backendService = prod ? "backend-prod" : "backend";
260
260
  const frontendService = prod ? "frontend-prod" : "frontend";
261
+ const frontend = existsSync(join(directory, "frontend", "package.json"));
261
262
  checks.push({
262
263
  name: "Services are running",
263
- ok: services.includes(backendService) && services.includes(frontendService),
264
+ ok: services.includes(backendService) && (!frontend || services.includes(frontendService)),
264
265
  warning: services.length === 0,
265
266
  detail: services.length ? services.join(", ") : `Nothing is running. Start the project with \`docker compose up${prod ? " -d" : ""}\`.`
266
267
  });
@@ -278,11 +279,12 @@ async function runDoctor(directory) {
278
279
  detail: health ? `The ${backendService} container is ${health}` : `See \`docker compose logs ${backendService}\`.`
279
280
  });
280
281
  const site = (env.SITE_URL ?? "http://localhost:3000").replace(/\/$/, "");
281
- const frontend = await fetchStatus(site);
282
+ const status = await fetchStatus(site);
283
+ const logs = prod ? "proxy" : frontend ? frontendService : backendService;
282
284
  checks.push({
283
285
  name: "The site answers",
284
- ok: frontend !== void 0 && frontend < 500,
285
- detail: frontend ? `${site} answered ${frontend}` : `No answer on ${site}. See \`docker compose logs ${prod ? "proxy" : frontendService}\`.`
286
+ ok: status !== void 0 && status < 500,
287
+ detail: status ? `${site} answered ${status}` : `No answer on ${site}. See \`docker compose logs ${logs}\`.`
286
288
  });
287
289
  const api = await fetchStatus(`${site}/api/content.sitesettings`, { "X-Secret-Key": env.API_READ_KEY ?? "" });
288
290
  checks.push({
@@ -461,13 +463,13 @@ const STACK_OPTIONS = [
461
463
  value: "headless",
462
464
  label: "Headless",
463
465
  hint: "just the API and the admin",
464
- generated: false
466
+ generated: true
465
467
  },
466
468
  {
467
469
  value: "templates",
468
470
  label: "Django templates",
469
471
  hint: "no Node, Django renders the site",
470
- generated: false
472
+ generated: true
471
473
  },
472
474
  {
473
475
  value: "nuxt",
@@ -498,7 +500,7 @@ const STACK_OPTIONS = [
498
500
  value: "latest",
499
501
  label: "Latest",
500
502
  hint: "Postgres 18",
501
- generated: false
503
+ generated: true
502
504
  }]
503
505
  },
504
506
  {
@@ -599,7 +601,7 @@ const STACK_OPTIONS = [
599
601
  value: "on",
600
602
  label: "On",
601
603
  hint: "proof of work, no tracking",
602
- generated: false
604
+ generated: true
603
605
  }, {
604
606
  value: "off",
605
607
  label: "Off",
@@ -634,7 +636,8 @@ const STACK_OPTIONS = [
634
636
  {
635
637
  value: "pnpm",
636
638
  label: "pnpm",
637
- generated: false
639
+ hint: "with a day's delay on new releases",
640
+ generated: true
638
641
  },
639
642
  {
640
643
  value: "npm",
@@ -644,7 +647,8 @@ const STACK_OPTIONS = [
644
647
  {
645
648
  value: "bun",
646
649
  label: "Bun",
647
- generated: false
650
+ hint: "for installs; the frontend runs on Node.js",
651
+ generated: true
648
652
  }
649
653
  ]
650
654
  },
@@ -682,8 +686,8 @@ const STACK_OPTIONS = [
682
686
  {
683
687
  value: "sentry",
684
688
  label: "Sentry",
685
- hint: "or GlitchTip, with SENTRY_DSN",
686
- generated: false
689
+ hint: "sentry.io, with SENTRY_DSN",
690
+ generated: true
687
691
  },
688
692
  {
689
693
  value: "off",
@@ -724,9 +728,13 @@ const NODE_FRONTENDS = [
724
728
  "nuxt",
725
729
  "sveltekit"
726
730
  ];
727
- /** Whether an option applies to a stack: the package manager only with a Node frontend. */
731
+ /**
732
+ * Whether an option applies to a stack: the package manager only with a Node
733
+ * frontend, page analytics only with a frontend for its tracker.
734
+ */
728
735
  function applies(key, stack) {
729
736
  if (key === "packageManager") return stack.frontend === void 0 || NODE_FRONTENDS.includes(stack.frontend);
737
+ if (key === "analytics") return stack.frontend !== "headless";
730
738
  return true;
731
739
  }
732
740
  const DIGITS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
@@ -914,6 +922,10 @@ function projectName(directory) {
914
922
  } catch {}
915
923
  return basename(directory).toLowerCase();
916
924
  }
925
+ /** Whether the project has a frontend: a headless one has no `frontend/`. */
926
+ function hasFrontend(directory) {
927
+ return existsSync(join(directory, "frontend", "package.json"));
928
+ }
917
929
  function fromEnv(directory, name, envFile, localRun) {
918
930
  const env = parseEnv(readFileSync(join(directory, envFile), "utf8"));
919
931
  const profiles = parseProfiles(env.COMPOSE_PROFILES);
@@ -928,7 +940,7 @@ function fromEnv(directory, name, envFile, localRun) {
928
940
  localRun,
929
941
  services: {
930
942
  backend: `backend${suffix}`,
931
- frontend: `frontend${suffix}`,
943
+ ...hasFrontend(directory) ? { frontend: `frontend${suffix}` } : {},
932
944
  worker: `worker${suffix}`
933
945
  },
934
946
  composeArgs: localRun ? [
@@ -973,7 +985,8 @@ function projectStack(project) {
973
985
  const fromEnv = {
974
986
  analytics: project.profiles.includes("analytics") ? "umami" : "off",
975
987
  ...project.env.CACHE_URL === void 0 ? {} : cacheStack(project.env),
976
- ...notificationsStack(project)
988
+ ...notificationsStack(project),
989
+ ...captchaStack(project)
977
990
  };
978
991
  const { preset } = projectManifest(project);
979
992
  if (preset) try {
@@ -994,6 +1007,17 @@ function notificationsStack(project) {
994
1007
  if (!existsSync(settings)) return {};
995
1008
  return { notifications: /^ *["']content_studio\.notifications["'],/m.test(readFileSync(settings, "utf8")) ? "on" : "off" };
996
1009
  }
1010
+ /** The line of DjangoStack's captcha app in the backend's INSTALLED_APPS. */
1011
+ const CAPTCHA_APP = /^ *["']django_stack\.contrib\.captcha["'],/m;
1012
+ /**
1013
+ * The captcha is on when the backend has DjangoStack's captcha app and
1014
+ * CAPTCHA is not off. Projects from before the captcha have no app.
1015
+ */
1016
+ function captchaStack(project) {
1017
+ const settings = join(project.directory, "backend", "config", "settings.py");
1018
+ if (!existsSync(settings)) return {};
1019
+ return { captcha: CAPTCHA_APP.test(readFileSync(settings, "utf8")) && project.env.CAPTCHA !== "off" ? "on" : "off" };
1020
+ }
997
1021
  const REDIS = /^(redis|rediss|valkey|valkeys):/;
998
1022
  /**
999
1023
  * The cache and the tasks a project's `.env` runs. Projects from before 0.7
@@ -1147,6 +1171,73 @@ const PACKAGE_NAME = "@django-stack/cli";
1147
1171
  // src/scaffold.ts
1148
1172
  /** The frontends this version of the CLI generates. */
1149
1173
  const FRONTENDS = stackOption("frontend").values.filter((value) => value.generated).map((value) => value.value);
1174
+ /** The Postgres major version of each `--postgres` value. */
1175
+ const POSTGRES_VERSIONS = {
1176
+ stable: "17",
1177
+ latest: "18"
1178
+ };
1179
+ /**
1180
+ * The template values of a Postgres version. From 18 the image keeps its data
1181
+ * in a versioned directory under /var/lib/postgresql, so the volume is
1182
+ * mounted there; before 18 at /var/lib/postgresql/data.
1183
+ */
1184
+ function postgresValues(postgres) {
1185
+ const version = POSTGRES_VERSIONS[postgres];
1186
+ return {
1187
+ postgres_version: version,
1188
+ postgres_data: Number(version) >= 18 ? "/var/lib/postgresql" : "/var/lib/postgresql/data"
1189
+ };
1190
+ }
1191
+ /** The pnpm and Bun versions the frontend image installs with. */
1192
+ const PNPM_VERSION = "12.18.0";
1193
+ const BUN_VERSION = "1.4.4";
1194
+ const LOCKFILES = {
1195
+ pnpm: "pnpm-lock.yaml",
1196
+ npm: "package-lock.json",
1197
+ bun: "bun.lock"
1198
+ };
1199
+ /** The install command of a package manager: frozen to the lockfile when there is one. */
1200
+ function installCommand(manager, flags = "") {
1201
+ const frozen = manager === "npm" ? "npm ci" : `${manager} install --frozen-lockfile`;
1202
+ return `if [ -f ${LOCKFILES[manager]} ]; then ${frozen}${flags}; else ${manager} install${flags}; fi`;
1203
+ }
1204
+ /**
1205
+ * The template values for a package manager: the commands in the README and
1206
+ * AGENTS.md, and the install steps of the frontend's Dockerfile. Scripts run
1207
+ * with npm in the image whatever installed them; it comes with Node.js.
1208
+ */
1209
+ function packageManagerValues(manager) {
1210
+ return {
1211
+ package_manager: manager,
1212
+ pm_run: {
1213
+ pnpm: "pnpm",
1214
+ npm: "npm run",
1215
+ bun: "bun run"
1216
+ }[manager],
1217
+ pm_backend: {
1218
+ pnpm: "pnpm backend",
1219
+ npm: "npm run backend --",
1220
+ bun: "bun run backend"
1221
+ }[manager],
1222
+ pm_setup: {
1223
+ pnpm: `RUN npm install --global pnpm@${PNPM_VERSION}`,
1224
+ npm: "# npm comes with Node.js.",
1225
+ bun: `COPY --from=oven/bun:${BUN_VERSION}-alpine /usr/local/bin/bun /usr/local/bin/bun`
1226
+ }[manager],
1227
+ pm_manifests: {
1228
+ pnpm: "package.json pnpm-lock.yaml* pnpm-workspace.yaml",
1229
+ npm: "package.json package-lock.json*",
1230
+ bun: "package.json bun.lock*"
1231
+ }[manager],
1232
+ pm_install: installCommand(manager),
1233
+ pm_install_no_scripts: installCommand(manager, " --ignore-scripts"),
1234
+ pm_prune: {
1235
+ pnpm: "pnpm prune --prod",
1236
+ npm: "npm prune --omit=dev",
1237
+ bun: "rm -rf node_modules && bun install --production"
1238
+ }[manager]
1239
+ };
1240
+ }
1150
1241
  /** Umami's database URL: the project's database, with Umami's tables in the `umami` schema. */
1151
1242
  function umamiDatabaseUrl(databaseUrl) {
1152
1243
  const url = new URL(databaseUrl);
@@ -1225,7 +1316,7 @@ function localS3(port = 8333) {
1225
1316
  };
1226
1317
  }
1227
1318
  const LOCAL_S3 = localS3();
1228
- const TEXT_EXTENSIONS = /\.(py|ts|tsx|mjs|js|jsx|json|astro|vue|svelte|css|md|yaml|yml|toml|txt|html|sh|svg)$|Dockerfile$|gitignore$|\.dockerignore$|env\.example$/;
1319
+ const TEXT_EXTENSIONS = /\.(py|ts|tsx|mjs|js|jsx|json|astro|vue|svelte|css|md|yaml|yml|toml|txt|html|sh|svg)$|Dockerfile$|Caddyfile$|gitignore$|\.dockerignore$|env\.example$/;
1229
1320
  function defaultTemplatesDir() {
1230
1321
  return fileURLToPath(new URL("../templates", import.meta.url));
1231
1322
  }
@@ -1259,6 +1350,35 @@ function walk(directory) {
1259
1350
  return statSync(path).isDirectory() ? walk(path) : [path];
1260
1351
  });
1261
1352
  }
1353
+ const BLOCK_START = /^\s*(?:#|<!--)\s*@if\s+(!?)([a-z]+)\s*(?:-->)?\s*$/;
1354
+ const BLOCK_END = /^\s*(?:#|<!--)\s*@end\s*(?:-->)?\s*$/;
1355
+ /**
1356
+ * Keeps or drops the template's conditional blocks: the lines between a
1357
+ * comment line `# @if frontend` (or `<!-- @if !frontend -->`) and `# @end`
1358
+ * stay when the flag is on (off with `!`). The marker lines always go.
1359
+ */
1360
+ function selectBlocks(text, flags) {
1361
+ if (!/@(if|end)\b/.test(text)) return text;
1362
+ const kept = [];
1363
+ const open = [];
1364
+ for (const line of text.split("\n")) {
1365
+ const start = line.match(BLOCK_START);
1366
+ if (start) {
1367
+ const flag = flags[start[2]];
1368
+ if (flag === void 0) throw new Error(`Unknown template flag ${start[2]}.`);
1369
+ open.push(start[1] ? !flag : flag);
1370
+ continue;
1371
+ }
1372
+ if (BLOCK_END.test(line)) {
1373
+ if (!open.length) throw new Error("@end without @if.");
1374
+ open.pop();
1375
+ continue;
1376
+ }
1377
+ if (open.every(Boolean)) kept.push(line);
1378
+ }
1379
+ if (open.length) throw new Error("@if without @end.");
1380
+ return kept.join("\n");
1381
+ }
1262
1382
  function render(text, values) {
1263
1383
  return text.replace(/\{\{([a-z_]+)\}\}/g, (match, key) => values[key] ?? match);
1264
1384
  }
@@ -1270,6 +1390,16 @@ function validateTimeZone(value) {
1270
1390
  if (timeZones().includes(value)) return void 0;
1271
1391
  return `${JSON.stringify(value)} is not a time zone. Use a name such as Europe/Amsterdam or UTC.`;
1272
1392
  }
1393
+ /** The root package.json of a headless project: the scripts without the frontend's steps. */
1394
+ function headlessScripts(text) {
1395
+ const manifest = JSON.parse(text);
1396
+ const scripts = manifest.scripts;
1397
+ scripts.test = "docker compose exec backend pytest";
1398
+ scripts.lint = "docker compose exec backend ruff check .";
1399
+ scripts["generate:types"] = (scripts["generate:types"] ?? "").split(" && ")[0] ?? "";
1400
+ manifest.description = manifest.description.replace(", Content Studio and Headless,", " and Content Studio,");
1401
+ return `${JSON.stringify(manifest, null, 2)}\n`;
1402
+ }
1273
1403
  function scaffold(options) {
1274
1404
  const templates = options.templatesDir ?? defaultTemplatesDir();
1275
1405
  const target = options.directory;
@@ -1280,17 +1410,29 @@ function scaffold(options) {
1280
1410
  const zoneError = validateTimeZone(options.timeZone);
1281
1411
  if (zoneError) throw new Error(zoneError);
1282
1412
  }
1413
+ const headless = options.frontend === "headless";
1414
+ const djangoTemplates = options.frontend === "templates";
1415
+ const jsFrontend = !headless && !djangoTemplates;
1283
1416
  const frontendTemplate = join(templates, `frontend-${options.frontend}`);
1284
- if (!existsSync(frontendTemplate)) throw new Error(`This version of the CLI has no ${options.frontend} frontend.`);
1417
+ if (!headless && !existsSync(frontendTemplate)) throw new Error(`This version of the CLI has no ${options.frontend} frontend.`);
1285
1418
  mkdirSync(target, { recursive: true });
1286
1419
  cpSync(join(templates, "base"), target, { recursive: true });
1287
1420
  cpSync(join(templates, "backend"), join(target, "backend"), { recursive: true });
1288
- cpSync(frontendTemplate, join(target, "frontend"), { recursive: true });
1421
+ const frontendDir = join(target, djangoTemplates ? "backend" : "frontend");
1422
+ if (!headless) cpSync(frontendTemplate, frontendDir, { recursive: true });
1289
1423
  renameSync(join(target, "gitignore"), join(target, ".gitignore"));
1290
1424
  makeExecutable(target);
1291
- const about = join(target, "frontend", "template.json");
1292
- const frontend = JSON.parse(readFileSync(about, "utf8"));
1293
- rmSync(about);
1425
+ let frontend = {
1426
+ label: "Headless",
1427
+ url: "",
1428
+ security: ""
1429
+ };
1430
+ if (!headless) {
1431
+ const about = join(frontendDir, "template.json");
1432
+ frontend = JSON.parse(readFileSync(about, "utf8"));
1433
+ rmSync(about);
1434
+ }
1435
+ const packageManager = options.packageManager ?? "pnpm";
1294
1436
  const values = {
1295
1437
  project_name: options.name,
1296
1438
  frontend: options.frontend,
@@ -1300,15 +1442,33 @@ function scaffold(options) {
1300
1442
  cli_version: CLI_VERSION,
1301
1443
  preset: options.preset ?? encodePreset(DEFAULT_STACK),
1302
1444
  site_port: String(options.ports?.SITE_PORT ?? 3e3),
1303
- mail_port: String(options.ports?.MAIL_PORT ?? 8025)
1445
+ mail_port: String(options.ports?.MAIL_PORT ?? 8025),
1446
+ ...packageManagerValues(packageManager),
1447
+ ...postgresValues(options.postgres ?? DEFAULT_STACK.postgres)
1304
1448
  };
1305
1449
  const files = walk(target);
1306
1450
  for (const file of files) {
1307
1451
  if (!TEXT_EXTENSIONS.test(file)) continue;
1308
1452
  const text = readFileSync(file, "utf8");
1309
- const rendered = render(text, values);
1453
+ const rendered = render(selectBlocks(text, {
1454
+ frontend: jsFrontend,
1455
+ headless,
1456
+ templates: djangoTemplates,
1457
+ site: !headless
1458
+ }), values);
1310
1459
  if (rendered !== text) writeFileSync(file, rendered);
1311
1460
  }
1461
+ if (!jsFrontend) writeFileSync(join(target, "package.json"), headlessScripts(readFileSync(join(target, "package.json"), "utf8")));
1462
+ if (packageManager !== "pnpm") rmSync(join(target, "frontend", "pnpm-workspace.yaml"), { force: true });
1463
+ if (jsFrontend && packageManager !== "npm") {
1464
+ const packageJson = join(target, "frontend", "package.json");
1465
+ const manifest = JSON.parse(readFileSync(packageJson, "utf8"));
1466
+ const version = packageManager === "pnpm" ? PNPM_VERSION : BUN_VERSION;
1467
+ writeFileSync(packageJson, `${JSON.stringify({
1468
+ ...manifest,
1469
+ packageManager: `${packageManager}@${version}`
1470
+ }, null, 2)}\n`);
1471
+ }
1312
1472
  if (options.notifications === false) {
1313
1473
  const settings = join(target, "backend", "config", "settings.py");
1314
1474
  writeFileSync(settings, setNotificationsApp(readFileSync(settings, "utf8"), false));
@@ -1322,14 +1482,13 @@ function scaffold(options) {
1322
1482
  const secrets = generateProjectSecrets();
1323
1483
  let env = template.replace(/\{\{secret:([a-z_]+)\}\}/g, (match, key) => secrets[key] ?? match);
1324
1484
  const envExample = template.replace(/\{\{secret:[a-z_]+\}\}/g, "");
1325
- const profiles = ["dev"];
1326
- if (options.databaseUrl) env = setEnvValues(env, { DATABASE_URL: normalizeDatabaseUrl(options.databaseUrl) });
1327
- else profiles.push("db");
1485
+ const profiles = ["dev", "db"];
1486
+ if (options.databaseUrl) env = setEnvValues(env, { PRODUCTION_DATABASE_URL: normalizeDatabaseUrl(options.databaseUrl) });
1328
1487
  if (options.localS3) {
1329
1488
  profiles.push("s3");
1330
1489
  env = setEnvValues(env, LOCAL_S3);
1331
1490
  }
1332
- if (options.analytics) {
1491
+ if (options.analytics && !headless) {
1333
1492
  profiles.push("analytics");
1334
1493
  env = setEnvValues(env, umamiEnv(parseEnv(env).DATABASE_URL ?? "", secrets));
1335
1494
  }
@@ -1342,7 +1501,8 @@ function scaffold(options) {
1342
1501
  env = setEnvValues(env, { COMPOSE_PROFILES: profiles.join(",") });
1343
1502
  if (options.ports) env = setEnvValues(env, portValues(parseEnv(env), options.ports));
1344
1503
  if (!options.demoContent) env = setEnvValues(env, { SEED_DEMO_CONTENT: "0" });
1345
- if (options.errors === "off") env = setEnvValues(env, { ERRORS: "off" });
1504
+ if (options.errors && options.errors !== "email") env = setEnvValues(env, { ERRORS: options.errors });
1505
+ if (options.captcha === false) env = setEnvValues(env, { CAPTCHA: "off" });
1346
1506
  writeFileSync(join(target, ".env"), env, { mode: 384 });
1347
1507
  writeFileSync(join(target, ".env.example"), envExample);
1348
1508
  rmSync(join(target, "env.example"));
@@ -1379,6 +1539,10 @@ function linkPackages(target, monorepo) {
1379
1539
  const pyproject = join(target, "backend", "pyproject.toml");
1380
1540
  writeFileSync(pyproject, `${readFileSync(pyproject, "utf8").trimEnd()}\n\n[tool.uv.sources]\ndjango-stack = { path = "vendor/${wheel}" }\n`);
1381
1541
  const packageJson = join(target, "frontend", "package.json");
1542
+ if (!existsSync(packageJson)) {
1543
+ rmSync(frontendVendor, { recursive: true });
1544
+ return;
1545
+ }
1382
1546
  const manifest = JSON.parse(readFileSync(packageJson, "utf8"));
1383
1547
  const packages = Object.keys(manifest.dependencies ?? {}).filter((name) => name.startsWith("@django-stack/"));
1384
1548
  for (const name of packages) {
@@ -1407,6 +1571,74 @@ function linkPackages(target, monorepo) {
1407
1571
  writeFileSync(packageJson, `${JSON.stringify(manifest, null, 2)}\n`);
1408
1572
  }
1409
1573
  /** Locks the backend's dependencies with uv, when uv is installed. */
1574
+ /** Runs a package manager in the frontend directory: pnpm and Bun through npx, at the image's version. */
1575
+ function runPackageManager(directory, manager, args) {
1576
+ const [file, commandArgs] = manager === "npm" ? ["npm", args] : ["npx", [
1577
+ "--yes",
1578
+ `${manager}@${manager === "pnpm" ? PNPM_VERSION : BUN_VERSION}`,
1579
+ ...args
1580
+ ]];
1581
+ try {
1582
+ execFileSync(file, commandArgs, {
1583
+ cwd: directory,
1584
+ stdio: "ignore",
1585
+ shell: process.platform === "win32"
1586
+ });
1587
+ return true;
1588
+ } catch {
1589
+ return false;
1590
+ }
1591
+ }
1592
+ /** Writes the frontend's lockfile with the chosen package manager, without installing anything. */
1593
+ function lockFrontend(target, manager) {
1594
+ const directory = join(target, "frontend");
1595
+ return runPackageManager(directory, manager, manager === "npm" ? [
1596
+ "install",
1597
+ "--package-lock-only",
1598
+ "--ignore-scripts",
1599
+ "--no-audit",
1600
+ "--no-fund"
1601
+ ] : ["install", "--lockfile-only"]) && existsSync(join(directory, LOCKFILES[manager]));
1602
+ }
1603
+ /** The package manager of a project's frontend, from its lockfile. */
1604
+ function frontendPackageManager(directory) {
1605
+ return Object.keys(LOCKFILES).find((manager) => existsSync(join(directory, "frontend", LOCKFILES[manager])));
1606
+ }
1607
+ /**
1608
+ * Updates the frontend's lockfile, without installing: the @django-stack
1609
+ * packages, or every package with `all`, within the ranges of package.json.
1610
+ * Returns the lockfile, or undefined without one or on failure.
1611
+ */
1612
+ function updateFrontendLock(target, all = false) {
1613
+ const manager = frontendPackageManager(target);
1614
+ if (!manager) return void 0;
1615
+ const directory = join(target, "frontend");
1616
+ const manifest = JSON.parse(readFileSync(join(directory, "package.json"), "utf8"));
1617
+ const names = Object.keys(manifest.dependencies ?? {}).filter((name) => name.startsWith("@django-stack/"));
1618
+ const packages = all ? [] : names;
1619
+ const args = {
1620
+ pnpm: [
1621
+ "update",
1622
+ ...packages,
1623
+ "--lockfile-only"
1624
+ ],
1625
+ npm: [
1626
+ "update",
1627
+ ...packages,
1628
+ "--package-lock-only",
1629
+ "--ignore-scripts",
1630
+ "--no-audit",
1631
+ "--no-fund"
1632
+ ],
1633
+ bun: [
1634
+ "update",
1635
+ ...packages,
1636
+ "--lockfile-only"
1637
+ ]
1638
+ }[manager];
1639
+ if (!all && !names.length) return LOCKFILES[manager];
1640
+ return runPackageManager(directory, manager, args) ? LOCKFILES[manager] : void 0;
1641
+ }
1410
1642
  function lockBackend(target) {
1411
1643
  try {
1412
1644
  execFileSync("uv", ["lock", "--quiet"], {
@@ -1462,7 +1694,12 @@ function productionEnv(devEnv, options) {
1462
1694
  ADMIN_PASSWORD: secrets.admin ?? "",
1463
1695
  DEFAULT_FROM_EMAIL: `${current.PROJECT_NAME ?? "site"} <noreply@${options.domain.toLowerCase()}>`
1464
1696
  };
1465
- if (profiles.includes("db")) {
1697
+ const external = options.bundledDatabase ? "" : current.PRODUCTION_DATABASE_URL ?? "";
1698
+ if (external) {
1699
+ values.DATABASE_URL = external;
1700
+ values.POSTGRES_PASSWORD = "";
1701
+ values.COMPOSE_PROFILES = [...new Set(profiles.filter((profile) => profile !== "db"))].join(",");
1702
+ } else if (profiles.includes("db")) {
1466
1703
  const user = current.POSTGRES_USER || "app";
1467
1704
  const name = current.POSTGRES_DB || "app";
1468
1705
  values.POSTGRES_PASSWORD = secrets.postgres ?? "";
@@ -1499,4 +1736,4 @@ function productionEnv(devEnv, options) {
1499
1736
  todo
1500
1737
  };
1501
1738
  }
1502
- export { dockerRunning as $, projectStack as A, decodePreset as B, RUN_DOMAIN as C, composeOutput as D, compose as E, DEFAULT_STACK as F, DEV_PORTS as G, pendingChoices as H, NAMED_PRESETS as I, envPorts as J, chooseFreePorts as K, PROMPT_ORDER as L, readLocalRun as M, readProject as N, findProjectDirectory as O, runningServices as P, dockerNotRunning as Q, STACK_OPTIONS as R, generateSecret as S, activeProject as T, resolvePreset as U, encodePreset as V, stackOption as W, runDoctor as X, portValues as Y, checkTools as Z, validateProjectName as _, LOCAL_S3 as a, PACKAGE_NAME as b, lockBackend as c, scaffold as d, isDockerDown as et, setNotificationsApp as f, usesValkey as g, umamiEnv as h, FRONTENDS as i, projectUrls as j, parseProfiles as k, normalizeDatabaseUrl as l, toProjectName as m, productionEnv as n, quoteEnvValue as nt, cacheEnv as o, timeZones as p, describeMove as q, validateDomain as r, setEnvValues as rt, localS3 as s, missingS3 as t, parseEnv as tt, relativeDirectory as u, validateTimeZone as v, RUN_ENV as w, generateProjectSecrets as x, CLI_VERSION as y, applies as z };
1739
+ export { envPorts as $, compose as A, NAMED_PRESETS as B, PACKAGE_NAME as C, RUN_DOMAIN as D, CAPTCHA_APP as E, projectUrls as F, decodePreset as G, PROMPT_ORDER as H, readLocalRun as I, resolvePreset as J, encodePreset as K, readProject as L, findProjectDirectory as M, parseProfiles as N, RUN_ENV as O, projectStack as P, describeMove as Q, runningServices as R, CLI_VERSION as S, generateSecret as T, STACK_OPTIONS as U, NODE_FRONTENDS as V, applies as W, DEV_PORTS as X, stackOption as Y, chooseFreePorts as Z, umamiEnv as _, LOCAL_S3 as a, isDockerDown as at, validateProjectName as b, localS3 as c, setEnvValues as ct, normalizeDatabaseUrl as d, portValues as et, relativeDirectory as f, toProjectName as g, timeZones as h, FRONTENDS as i, dockerRunning as it, composeOutput as j, activeProject as k, lockBackend as l, setNotificationsApp as m, productionEnv as n, checkTools as nt, cacheEnv as o, parseEnv as ot, scaffold as p, pendingChoices as q, validateDomain as r, dockerNotRunning as rt, frontendPackageManager as s, quoteEnvValue as st, missingS3 as t, runDoctor as tt, lockFrontend as u, updateFrontendLock as v, generateProjectSecrets as w, validateTimeZone as x, usesValkey as y, DEFAULT_STACK as z };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@django-stack/cli",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "The production-ready Django, batteries included stack. Create and manage DjangoStack projects: a Django backend, a frontend, Postgres and Docker Compose in one command.",
5
5
  "license": "MIT",
6
6
  "author": "Leon van der Grient",
@@ -10,9 +10,14 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
10
10
  UV_PROJECT_ENVIRONMENT=/venv \
11
11
  PATH="/venv/bin:$PATH"
12
12
  COPY --from=ghcr.io/astral-sh/uv:0.11 /uv /bin/uv
13
- # The Postgres client, for the backups (pg_dump and pg_restore).
13
+ # The Postgres client, for the backups (pg_dump and pg_restore), from the
14
+ # PostgreSQL apt repository: its major version must match the server's, as
15
+ # pg_dump refuses a newer server. Change it with the database.
16
+ ARG POSTGRES_VERSION={{postgres_version}}
14
17
  RUN apt-get update \
15
- && apt-get install --yes --no-install-recommends postgresql-client \
18
+ && apt-get install --yes --no-install-recommends postgresql-common \
19
+ && /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y \
20
+ && apt-get install --yes --no-install-recommends "postgresql-client-${POSTGRES_VERSION}" \
16
21
  && rm -rf /var/lib/apt/lists/*
17
22
  RUN groupadd --system app && useradd --system --gid app --home-dir /app app
18
23
  WORKDIR /app
@@ -40,7 +40,12 @@ INSTALLED_APPS = [
40
40
  "content_studio.contrib.password_reset",
41
41
  "content_studio.notifications", # notifications in the admin and by email
42
42
  "django_stack",
43
+ "django_stack.contrib.captcha", # the captcha of the contact form (CAPTCHA=off turns it off)
43
44
  "content",
45
+ # @if templates
46
+ "django_stack.contrib.site", # preview, the page cache, redirects and the template tags of the site
47
+ "website", # the site's pages
48
+ # @end
44
49
  # Last: builds the API routes once every model is exposed.
45
50
  "headless.rest",
46
51
  ]
@@ -55,6 +60,10 @@ MIDDLEWARE = [
55
60
  "django.contrib.auth.middleware.AuthenticationMiddleware",
56
61
  "django.contrib.messages.middleware.MessageMiddleware",
57
62
  "django.middleware.clickjacking.XFrameOptionsMiddleware",
63
+ # @if templates
64
+ # The redirects editors manage in the admin, for paths that do not exist.
65
+ "django_stack.contrib.site.middleware.RedirectMiddleware",
66
+ # @end
58
67
  ]
59
68
 
60
69
  ROOT_URLCONF = "config.urls"
@@ -71,6 +80,10 @@ TEMPLATES = [
71
80
  "django.template.context_processors.request",
72
81
  "django.contrib.auth.context_processors.auth",
73
82
  "django.contrib.messages.context_processors.messages",
83
+ # @if templates
84
+ "django_stack.contrib.site.context_processors.site", # preview, analytics_website_id
85
+ "website.context_processors.site", # site, main_menu, footer_menu
86
+ # @end
74
87
  ],
75
88
  },
76
89
  },
@@ -6,4 +6,15 @@ urlpatterns = [
6
6
  path("api/", include("headless.rest.urls")),
7
7
  path("", include("django_stack.urls")), # /healthz and /readyz
8
8
  *media_urlpatterns(), # uploaded files, in development only
9
+ # @if headless
10
+ path("", include("django_stack.start")), # a start page with links to the admin and the API schema
11
+ # @end
12
+ # @if templates
13
+ path("", include("website.urls")), # the site's pages, last: they include a catch-all for page paths
14
+ # @end
9
15
  ]
16
+ # @if templates
17
+
18
+ # The site's 404 page, with its layout (when DJANGO_DEBUG is off).
19
+ handler404 = "website.views.not_found"
20
+ # @end
@@ -7,6 +7,7 @@ from content_studio import register
7
7
  from content_studio.admin import ModelStudio
8
8
  from content_studio.analytics import Analytics
9
9
  from content_studio.form import ActivityLog, Field, FormSet
10
+ from django_stack.analytics import UmamiProvider
10
11
  from django_stack.studio import PreviewButton
11
12
 
12
13
  from .models import Author, Category, FormSubmission, Menu, Page, Post, Redirect, SiteSettings, Tag
@@ -17,9 +18,17 @@ POST_PREVIEW = PreviewButton(component_id="8d0f6a52-3c51-4a8e-9a2b-6f1f0c8f2a02"
17
18
  PAGE_ACTIVITY = ActivityLog(component_id="8d0f6a52-3c51-4a8e-9a2b-6f1f0c8f2a03")
18
19
  POST_ACTIVITY = ActivityLog(component_id="8d0f6a52-3c51-4a8e-9a2b-6f1f0c8f2a04")
19
20
  # The visitors of the page or post, from the analytics (see config/studio.py).
20
- # Hidden while analytics are off.
21
- PAGE_ANALYTICS = Analytics(path_field="path", component_id="8d0f6a52-3c51-4a8e-9a2b-6f1f0c8f2a05")
22
- POST_ANALYTICS = Analytics(path_field="slug", path_prefix="/blog/", component_id="8d0f6a52-3c51-4a8e-9a2b-6f1f0c8f2a06")
21
+ # Only with analytics on: Content Studio refuses them without its
22
+ # AnalyticsExtension.
23
+ ANALYTICS = UmamiProvider.is_configured()
24
+ PAGE_ANALYTICS = (
25
+ [Analytics(path_field="path", component_id="8d0f6a52-3c51-4a8e-9a2b-6f1f0c8f2a05")] if ANALYTICS else []
26
+ )
27
+ POST_ANALYTICS = (
28
+ [Analytics(path_field="slug", path_prefix="/blog/", component_id="8d0f6a52-3c51-4a8e-9a2b-6f1f0c8f2a06")]
29
+ if ANALYTICS
30
+ else []
31
+ )
23
32
  SEO = FormSet(
24
33
  title="Search and sharing",
25
34
  description="Leave empty to use the title and the site settings.",
@@ -42,7 +51,7 @@ class PageStudio(ModelStudio):
42
51
  FormSet(title="Body", fields=["body"]),
43
52
  SEO,
44
53
  ]
45
- edit_sidebar = ["status", "published_at", PAGE_PREVIEW, "order", PAGE_ANALYTICS, PAGE_ACTIVITY]
54
+ edit_sidebar = ["status", "published_at", PAGE_PREVIEW, "order", *PAGE_ANALYTICS, PAGE_ACTIVITY]
46
55
 
47
56
 
48
57
  @register(Post)
@@ -67,7 +76,7 @@ class PostStudio(ModelStudio):
67
76
  "author",
68
77
  "categories",
69
78
  "tags",
70
- POST_ANALYTICS,
79
+ *POST_ANALYTICS,
71
80
  POST_ACTIVITY,
72
81
  ]
73
82
 
@@ -5,8 +5,8 @@ site settings, menus, redirects and form submissions.
5
5
  These models are yours: add fields, remove models, create new ones. Every
6
6
  model exposed with ``@expose()`` gets routes under /api/ (see
7
7
  https://djangoheadless.org/docs/models); register it in ``admin.py`` to edit
8
- it in Content Studio. After a change, run ``pnpm backend makemigrations``
9
- and ``pnpm generate:types``.
8
+ it in Content Studio. After a change, run ``{{pm_run}} backend makemigrations``
9
+ and ``{{pm_run}} generate:types``.
10
10
  """
11
11
 
12
12
  from content_studio.blocks import Block, BlockField