@django-stack/cli 0.8.1 → 0.10.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 (50) hide show
  1. package/dist/bin.js +840 -54
  2. package/dist/index.d.ts +43 -4
  3. package/dist/index.js +1 -1
  4. package/dist/{production-CGkI7oNe.js → production-Ct_qoeXk.js} +210 -7
  5. package/package.json +1 -1
  6. package/templates/backend/Dockerfile +4 -5
  7. package/templates/backend/config/settings.py +3 -0
  8. package/templates/backend/config/studio.py +4 -0
  9. package/templates/backend/content/apps.py +5 -1
  10. package/templates/backend/content/models.py +2 -2
  11. package/templates/backend/content/notifications.py +36 -0
  12. package/templates/backend/content/tests/test_api.py +32 -2
  13. package/templates/backend/pyproject.toml +1 -1
  14. package/templates/base/AGENTS.md +8 -8
  15. package/templates/base/Caddyfile +7 -1
  16. package/templates/base/README.md +3 -3
  17. package/templates/base/compose.yaml +37 -2
  18. package/templates/base/env.example +29 -0
  19. package/templates/frontend-astro/AGENTS.md +3 -3
  20. package/templates/frontend-astro/Dockerfile +7 -4
  21. package/templates/frontend-astro/astro.config.mjs +16 -5
  22. package/templates/frontend-astro/package.json +1 -1
  23. package/templates/frontend-astro/pnpm-workspace.yaml +17 -0
  24. package/templates/frontend-astro/src/components/ContactForm.astro +19 -3
  25. package/templates/frontend-astro/src/middleware.ts +9 -1
  26. package/templates/frontend-next/AGENTS.md +3 -3
  27. package/templates/frontend-next/Dockerfile +6 -3
  28. package/templates/frontend-next/package.json +3 -3
  29. package/templates/frontend-next/pnpm-workspace.yaml +11 -0
  30. package/templates/frontend-next/src/app/[...path]/page.tsx +2 -1
  31. package/templates/frontend-next/src/components/ContactForm.tsx +14 -3
  32. package/templates/frontend-next/src/instrumentation.ts +12 -0
  33. package/templates/frontend-next/src/proxy.ts +4 -1
  34. package/templates/frontend-nuxt/AGENTS.md +3 -3
  35. package/templates/frontend-nuxt/Dockerfile +6 -3
  36. package/templates/frontend-nuxt/nuxt.config.ts +7 -3
  37. package/templates/frontend-nuxt/package.json +2 -2
  38. package/templates/frontend-nuxt/pnpm-workspace.yaml +13 -0
  39. package/templates/frontend-nuxt/server/plugins/errors.ts +14 -0
  40. package/templates/frontend-nuxt/server/routes/_content/layout.get.ts +4 -0
  41. package/templates/frontend-nuxt/src/components/ContactForm.vue +15 -2
  42. package/templates/frontend-sveltekit/AGENTS.md +3 -3
  43. package/templates/frontend-sveltekit/Dockerfile +7 -4
  44. package/templates/frontend-sveltekit/package.json +2 -2
  45. package/templates/frontend-sveltekit/pnpm-workspace.yaml +11 -0
  46. package/templates/frontend-sveltekit/src/hooks.server.ts +13 -1
  47. package/templates/frontend-sveltekit/src/lib/components/ContactForm.svelte +15 -2
  48. package/templates/frontend-sveltekit/src/routes/+layout.server.ts +3 -0
  49. package/templates/frontend-sveltekit/src/routes/[...path]/+page.svelte +1 -1
  50. package/templates/frontend-sveltekit/vite.config.ts +10 -3
@@ -0,0 +1,14 @@
1
+ import { reportError } from "@django-stack/utils";
2
+
3
+ /** Server errors go to Sentry with ERRORS=sentry; Nuxt logs them as well. */
4
+ export default defineNitroPlugin((nitroApp) => {
5
+ nitroApp.hooks.hook("error", async (error, { event }) => {
6
+ const status = (error as { statusCode?: number }).statusCode ?? 500;
7
+ if (status < 500) return;
8
+ await reportError(error, {
9
+ method: event?.method,
10
+ path: event?.path,
11
+ requestId: event?.headers.get("x-request-id"),
12
+ });
13
+ });
14
+ });
@@ -1,3 +1,5 @@
1
+ import { captchaEnabled } from "@django-stack/utils";
2
+
1
3
  /** What every page shows: the site settings and the menus. */
2
4
  export default defineEventHandler(async (event) => {
3
5
  const data = await cachedContent(event, async () => {
@@ -14,5 +16,7 @@ export default defineEventHandler(async (event) => {
14
16
  preview: previewToken(event) !== null,
15
17
  // Page analytics: Umami's tracker, served from this site at /_a/.
16
18
  analytics: process.env.UMAMI_WEBSITE_ID || null,
19
+ // The captcha widget of the contact form, unless CAPTCHA=off.
20
+ captcha: captchaEnabled(process.env.CAPTCHA),
17
21
  };
18
22
  });
@@ -1,9 +1,14 @@
1
1
  <!--
2
2
  The contact form. It posts to Django's public create route for form
3
3
  submissions (PUBLIC_CREATE in backend/config/settings.py), which is rate
4
- limited per IP address. Submissions appear in the admin.
4
+ limited per IP address and, unless CAPTCHA=off, needs the token of the
5
+ captcha widget. Submissions appear in the admin.
5
6
  -->
6
7
  <script setup lang="ts">
8
+ import { resetCaptcha } from "@django-stack/utils";
9
+ import { Captcha } from "@django-stack/vue";
10
+
11
+ const { data: layout } = useLayoutContent();
7
12
  const status = ref("");
8
13
  const sending = ref(false);
9
14
 
@@ -18,8 +23,15 @@ async function submit(event: Event) {
18
23
  body: JSON.stringify(Object.fromEntries(new FormData(form))),
19
24
  });
20
25
  if (response.status === 429) throw new Error("Too many messages; try again in a minute.");
21
- if (!response.ok) throw new Error("The message could not be sent. Check the fields and try again.");
26
+ if (!response.ok) {
27
+ const errors = await response.json().catch(() => ({}));
28
+ // A token is valid once, so the widget verifies again.
29
+ resetCaptcha(form);
30
+ if (errors.captcha) throw new Error("Verify that you are human, then send the message again.");
31
+ throw new Error("The message could not be sent. Check the fields and try again.");
32
+ }
22
33
  form.reset();
34
+ resetCaptcha(form);
23
35
  status.value = "Thank you, your message was sent.";
24
36
  } catch (error) {
25
37
  status.value = error instanceof Error ? error.message : String(error);
@@ -34,6 +46,7 @@ async function submit(event: Event) {
34
46
  <label>Name <input name="name" required maxlength="200" autocomplete="name" /></label>
35
47
  <label>Email <input name="email" type="email" required autocomplete="email" /></label>
36
48
  <label>Message <textarea name="message" required maxlength="5000" rows="6"></textarea></label>
49
+ <Captcha v-if="layout?.captcha !== false" />
37
50
  <button class="button" type="submit" :disabled="sending">Send</button>
38
51
  <p class="status" role="status" aria-live="polite">{{ status }}</p>
39
52
  </form>
@@ -13,7 +13,7 @@ src/routes/+layout.svelte the page shell: SEO tags, menus, footer, preview ba
13
13
  src/lib/components/ the post list and the contact form
14
14
  src/lib/server/api.ts getContent(): content helpers with the read-only key, drafts in preview
15
15
  src/lib/server/cache.ts the in-memory page cache that /revalidate empties
16
- src/lib/headless.ts generated API types; do not edit, run `npm run generate:types`
16
+ src/lib/headless.ts generated API types; do not edit, run `{{pm_run}} generate:types`
17
17
  src/hooks.server.ts page cache, editor-managed redirects, security headers
18
18
  vite.config.ts SvelteKit's options (Node adapter, Content-Security-Policy) and the dev proxy to Django
19
19
  ```
@@ -49,5 +49,5 @@ Run them in the `frontend` container (`docker compose exec frontend <command>`):
49
49
 
50
50
  | Task | Command |
51
51
  |------|---------|
52
- | Type-check | `npm run check` |
53
- | Test | `npm test` |
52
+ | Type-check | `{{pm_run}} check` |
53
+ | Test | `{{pm_run}} test` |
@@ -6,10 +6,13 @@ FROM node:22-alpine AS base
6
6
  WORKDIR /app
7
7
 
8
8
  FROM base AS deps
9
- COPY package.json package-lock.json* ./
9
+ {{pm_setup}}
10
+ COPY {{pm_manifests}} ./
10
11
  COPY vendor/ vendor/
11
- RUN --mount=type=cache,target=/root/.npm \
12
- if [ -f package-lock.json ]; then npm ci; else npm install; fi
12
+ # No BuildKit cache mounts: Railway rejects them, and the layer is cached anyway.
13
+ RUN {{pm_install}}
14
+ # The package manager only installs: npm, which comes with Node.js, runs
15
+ # the scripts of package.json below.
13
16
 
14
17
  FROM deps AS dev
15
18
  EXPOSE 3000
@@ -17,7 +20,7 @@ CMD ["npm", "run", "dev"]
17
20
 
18
21
  FROM deps AS build
19
22
  COPY . .
20
- RUN npm run build && npm prune --omit=dev
23
+ RUN npm run build && {{pm_prune}}
21
24
 
22
25
  FROM base AS prod
23
26
  # The proxy in front sets X-Forwarded-Proto, so links and cookies use https.
@@ -19,8 +19,8 @@
19
19
  },
20
20
  "dependencies": {
21
21
  "@django-headless/client": "^1.1.1",
22
- "@django-stack/svelte": "^0.8.1",
23
- "@django-stack/utils": "^0.8.1"
22
+ "@django-stack/svelte": "^0.10.0",
23
+ "@django-stack/utils": "^0.10.0"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@sveltejs/adapter-node": "^6.0.1",
@@ -0,0 +1,11 @@
1
+ # pnpm's settings for the frontend (pnpm 11 and later).
2
+
3
+ # Resolve only versions published at least a day ago, so a compromised
4
+ # release is usually caught before it reaches you. DjangoStack's own
5
+ # packages are exempt, so a project made on a release day installs.
6
+ minimumReleaseAge: 1440
7
+ minimumReleaseAgeExclude:
8
+ - "@django-stack/*"
9
+
10
+ # Which dependencies may run install scripts; pnpm refuses others.
11
+ allowBuilds: {}
@@ -1,5 +1,6 @@
1
+ import { reportError } from "@django-stack/utils";
1
2
  import { redirect } from "@sveltejs/kit";
2
- import type { Handle } from "@sveltejs/kit/hooks";
3
+ import type { Handle, HandleServerError } from "@sveltejs/kit/hooks";
3
4
  import { getContent, PREVIEW_COOKIE } from "#lib/server/api.ts";
4
5
  import { getCached, store } from "#lib/server/cache.ts";
5
6
  import { dev } from "$app/env";
@@ -68,3 +69,14 @@ export const handle: Handle = async ({ event, resolve }) => {
68
69
  }
69
70
  return response;
70
71
  };
72
+
73
+ /**
74
+ * Errors of the site's code are logged, as SvelteKit does by default, and
75
+ * go to Sentry with ERRORS=sentry. Errors such as a 404 are not.
76
+ */
77
+ export const handleError: HandleServerError = async ({ kind, error, issues, event }) => {
78
+ if (kind === "validation") console.error("Remote function schema validation failed:", issues);
79
+ if (kind !== "unknown") return;
80
+ console.error(error instanceof Error ? error.stack : String(error));
81
+ await reportError(error, { request: event.request });
82
+ };
@@ -1,9 +1,14 @@
1
1
  <!--
2
2
  The contact form. It posts to Django's public create route for form
3
3
  submissions (PUBLIC_CREATE in backend/config/settings.py), which is rate
4
- limited per IP address. Submissions appear in the admin.
4
+ limited per IP address and, unless CAPTCHA=off, needs the token of the
5
+ captcha widget. Submissions appear in the admin.
5
6
  -->
6
7
  <script lang="ts">
8
+ import { Captcha } from "@django-stack/svelte";
9
+ import { resetCaptcha } from "@django-stack/utils";
10
+
11
+ let { captcha = true }: { captcha?: boolean } = $props();
7
12
  let status = $state("");
8
13
  let sending = $state(false);
9
14
 
@@ -19,8 +24,15 @@
19
24
  body: JSON.stringify(Object.fromEntries(new FormData(form))),
20
25
  });
21
26
  if (response.status === 429) throw new Error("Too many messages; try again in a minute.");
22
- if (!response.ok) throw new Error("The message could not be sent. Check the fields and try again.");
27
+ if (!response.ok) {
28
+ const errors = await response.json().catch(() => ({}));
29
+ // A token is valid once, so the widget verifies again.
30
+ resetCaptcha(form);
31
+ if (errors.captcha) throw new Error("Verify that you are human, then send the message again.");
32
+ throw new Error("The message could not be sent. Check the fields and try again.");
33
+ }
23
34
  form.reset();
35
+ resetCaptcha(form);
24
36
  status = "Thank you, your message was sent.";
25
37
  } catch (error) {
26
38
  status = error instanceof Error ? error.message : String(error);
@@ -34,6 +46,7 @@
34
46
  <label>Name <input name="name" required maxlength="200" autocomplete="name" /></label>
35
47
  <label>Email <input name="email" type="email" required autocomplete="email" /></label>
36
48
  <label>Message <textarea name="message" required maxlength="5000" rows="6"></textarea></label>
49
+ {#if captcha}<Captcha />{/if}
37
50
  <button class="button" type="submit" disabled={sending}>Send</button>
38
51
  <p class="status" role="status" aria-live="polite">{status}</p>
39
52
  </form>
@@ -1,3 +1,4 @@
1
+ import { captchaEnabled } from "@django-stack/utils";
1
2
  import { getContent, PREVIEW_COOKIE, SITE_URL } from "#lib/server/api.ts";
2
3
  import type { LayoutServerLoad } from "./$types";
3
4
 
@@ -16,5 +17,7 @@ export const load: LayoutServerLoad = async ({ cookies }) => {
16
17
  siteUrl: SITE_URL,
17
18
  // Page analytics: Umami's tracker, served from this site at /_a/.
18
19
  analytics: process.env.UMAMI_WEBSITE_ID || null,
20
+ // The captcha widget of the contact form, unless CAPTCHA=off.
21
+ captcha: captchaEnabled(process.env.CAPTCHA),
19
22
  };
20
23
  };
@@ -8,5 +8,5 @@
8
8
  <article class="narrow">
9
9
  <h1>{data.page.title}</h1>
10
10
  <Blocks blocks={data.page.body} />
11
- {#if data.page.path === "contact"}<ContactForm />{/if}
11
+ {#if data.page.path === "contact"}<ContactForm captcha={data.captcha} />{/if}
12
12
  </article>
@@ -1,3 +1,4 @@
1
+ import { CAPTCHA_CSP } from "@django-stack/utils";
1
2
  import adapter from "@sveltejs/adapter-node";
2
3
  import { sveltekit } from "@sveltejs/kit/vite";
3
4
  import { defineConfig } from "vitest/config";
@@ -7,6 +8,10 @@ const backend = process.env.BACKEND_INTERNAL_URL ?? "http://localhost:8000";
7
8
  // Umami, for page analytics: only its tracker and page views, under /_a/.
8
9
  const umami = process.env.UMAMI_URL || "http://umami:3000";
9
10
 
11
+ // SvelteKit takes keyword sources without quotes, such as self.
12
+ const unquoted = (sources: readonly string[]) =>
13
+ sources.map((source) => source.replaceAll("'", "")) as never[];
14
+
10
15
  export default defineConfig({
11
16
  plugins: [
12
17
  sveltekit({
@@ -15,13 +20,15 @@ export default defineConfig({
15
20
  // A Content-Security-Policy on every page: scripts and styles only from
16
21
  // this site, with hashes for the ones SvelteKit inlines (hashes, not
17
22
  // nonces, so cached pages stay valid). src/hooks.server.ts adds the
18
- // origin of the media files.
23
+ // origin of the media files. CAPTCHA_CSP allows the captcha widget of
24
+ // the contact form: its solver, its style and its workers.
19
25
  csp: {
20
26
  mode: "hash",
21
27
  directives: {
22
28
  "default-src": ["self"],
23
- "script-src": ["self"],
24
- "style-src": ["self"],
29
+ "script-src": ["self", ...unquoted(CAPTCHA_CSP["script-src"])],
30
+ "style-src": ["self", ...unquoted(CAPTCHA_CSP["style-src"])],
31
+ "worker-src": unquoted(CAPTCHA_CSP["worker-src"]),
25
32
  // Inline style attributes, such as the focal point of an image.
26
33
  "style-src-attr": ["unsafe-inline"],
27
34
  "base-uri": ["self"],