@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.
- package/dist/bin.js +65 -20
- package/dist/index.d.ts +24 -9
- package/dist/index.js +1 -1
- package/dist/{production-M2SsuuBb.js → production-WThC1aS2.js} +267 -30
- package/package.json +1 -1
- package/templates/backend/Dockerfile +7 -2
- package/templates/backend/config/settings.py +13 -0
- package/templates/backend/config/urls.py +11 -0
- package/templates/backend/content/admin.py +14 -5
- package/templates/backend/content/models.py +2 -2
- package/templates/backend/content/tests/test_api.py +15 -3
- package/templates/backend/pyproject.toml +1 -1
- package/templates/base/.github/workflows/deploy.yml +10 -1
- package/templates/base/AGENTS.md +86 -8
- package/templates/base/Caddyfile +24 -0
- package/templates/base/README.md +74 -6
- package/templates/base/SECURITY.md +2 -0
- package/templates/base/compose.yaml +28 -2
- package/templates/base/env.example +37 -0
- package/templates/frontend-astro/AGENTS.md +3 -3
- package/templates/frontend-astro/Dockerfile +6 -3
- package/templates/frontend-astro/astro.config.mjs +16 -5
- package/templates/frontend-astro/package.json +1 -1
- package/templates/frontend-astro/pnpm-workspace.yaml +17 -0
- package/templates/frontend-astro/src/components/ContactForm.astro +19 -3
- package/templates/frontend-astro/src/middleware.ts +9 -1
- package/templates/frontend-next/AGENTS.md +3 -3
- package/templates/frontend-next/Dockerfile +5 -2
- package/templates/frontend-next/package.json +3 -3
- package/templates/frontend-next/pnpm-workspace.yaml +11 -0
- package/templates/frontend-next/src/app/[...path]/page.tsx +2 -1
- package/templates/frontend-next/src/components/ContactForm.tsx +14 -3
- package/templates/frontend-next/src/instrumentation.ts +12 -0
- package/templates/frontend-next/src/proxy.ts +4 -1
- package/templates/frontend-nuxt/AGENTS.md +3 -3
- package/templates/frontend-nuxt/Dockerfile +5 -2
- package/templates/frontend-nuxt/nuxt.config.ts +7 -3
- package/templates/frontend-nuxt/package.json +2 -2
- package/templates/frontend-nuxt/pnpm-workspace.yaml +13 -0
- package/templates/frontend-nuxt/server/plugins/errors.ts +14 -0
- package/templates/frontend-nuxt/server/routes/_content/layout.get.ts +4 -0
- package/templates/frontend-nuxt/src/components/ContactForm.vue +15 -2
- package/templates/frontend-sveltekit/AGENTS.md +3 -3
- package/templates/frontend-sveltekit/Dockerfile +6 -3
- package/templates/frontend-sveltekit/package.json +2 -2
- package/templates/frontend-sveltekit/pnpm-workspace.yaml +11 -0
- package/templates/frontend-sveltekit/src/hooks.server.ts +13 -1
- package/templates/frontend-sveltekit/src/lib/components/ContactForm.svelte +15 -2
- package/templates/frontend-sveltekit/src/routes/+layout.server.ts +3 -0
- package/templates/frontend-sveltekit/src/routes/[...path]/+page.svelte +1 -1
- package/templates/frontend-sveltekit/vite.config.ts +10 -3
- package/templates/frontend-templates/template.json +5 -0
- package/templates/frontend-templates/website/__init__.py +0 -0
- package/templates/frontend-templates/website/apps.py +6 -0
- package/templates/frontend-templates/website/context_processors.py +16 -0
- package/templates/frontend-templates/website/forms.py +20 -0
- package/templates/frontend-templates/website/static/website/favicon.svg +1 -0
- package/templates/frontend-templates/website/static/website/global.css +232 -0
- package/templates/frontend-templates/website/templates/website/404.html +10 -0
- package/templates/frontend-templates/website/templates/website/base.html +44 -0
- package/templates/frontend-templates/website/templates/website/blocks/call_to_action.html +5 -0
- package/templates/frontend-templates/website/templates/website/blocks/image.html +4 -0
- package/templates/frontend-templates/website/templates/website/blocks/quote.html +4 -0
- package/templates/frontend-templates/website/templates/website/blocks/text.html +2 -0
- package/templates/frontend-templates/website/templates/website/home.html +8 -0
- package/templates/frontend-templates/website/templates/website/includes/contact_form.html +21 -0
- package/templates/frontend-templates/website/templates/website/includes/post_card.html +9 -0
- package/templates/frontend-templates/website/templates/website/includes/post_list.html +11 -0
- package/templates/frontend-templates/website/templates/website/page.html +10 -0
- package/templates/frontend-templates/website/templates/website/post.html +20 -0
- package/templates/frontend-templates/website/templates/website/post_list.html +11 -0
- package/templates/frontend-templates/website/templates/website/robots.txt +5 -0
- package/templates/frontend-templates/website/templates/website/rss.xml +16 -0
- package/templates/frontend-templates/website/templates/website/sitemap.xml +8 -0
- package/templates/frontend-templates/website/tests/__init__.py +0 -0
- package/templates/frontend-templates/website/tests/test_site.py +102 -0
- package/templates/frontend-templates/website/urls.py +17 -0
- 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
|
|
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:
|
|
285
|
-
detail:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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: "
|
|
686
|
-
generated:
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
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,
|
|
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, {
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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.
|
|
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-
|
|
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
|
-
#
|
|
21
|
-
|
|
22
|
-
|
|
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 ``
|
|
9
|
-
and ``
|
|
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
|