@django-stack/cli 0.9.0 → 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 (45) hide show
  1. package/dist/bin.js +28 -3
  2. package/dist/index.d.ts +13 -5
  3. package/dist/index.js +1 -1
  4. package/dist/{production-M2SsuuBb.js → production-Ct_qoeXk.js} +154 -9
  5. package/package.json +1 -1
  6. package/templates/backend/config/settings.py +1 -0
  7. package/templates/backend/content/models.py +2 -2
  8. package/templates/backend/content/tests/test_api.py +15 -3
  9. package/templates/backend/pyproject.toml +1 -1
  10. package/templates/base/AGENTS.md +8 -8
  11. package/templates/base/README.md +3 -3
  12. package/templates/base/compose.yaml +7 -0
  13. package/templates/base/env.example +12 -0
  14. package/templates/frontend-astro/AGENTS.md +3 -3
  15. package/templates/frontend-astro/Dockerfile +6 -3
  16. package/templates/frontend-astro/astro.config.mjs +16 -5
  17. package/templates/frontend-astro/package.json +1 -1
  18. package/templates/frontend-astro/pnpm-workspace.yaml +17 -0
  19. package/templates/frontend-astro/src/components/ContactForm.astro +19 -3
  20. package/templates/frontend-astro/src/middleware.ts +9 -1
  21. package/templates/frontend-next/AGENTS.md +3 -3
  22. package/templates/frontend-next/Dockerfile +5 -2
  23. package/templates/frontend-next/package.json +3 -3
  24. package/templates/frontend-next/pnpm-workspace.yaml +11 -0
  25. package/templates/frontend-next/src/app/[...path]/page.tsx +2 -1
  26. package/templates/frontend-next/src/components/ContactForm.tsx +14 -3
  27. package/templates/frontend-next/src/instrumentation.ts +12 -0
  28. package/templates/frontend-next/src/proxy.ts +4 -1
  29. package/templates/frontend-nuxt/AGENTS.md +3 -3
  30. package/templates/frontend-nuxt/Dockerfile +5 -2
  31. package/templates/frontend-nuxt/nuxt.config.ts +7 -3
  32. package/templates/frontend-nuxt/package.json +2 -2
  33. package/templates/frontend-nuxt/pnpm-workspace.yaml +13 -0
  34. package/templates/frontend-nuxt/server/plugins/errors.ts +14 -0
  35. package/templates/frontend-nuxt/server/routes/_content/layout.get.ts +4 -0
  36. package/templates/frontend-nuxt/src/components/ContactForm.vue +15 -2
  37. package/templates/frontend-sveltekit/AGENTS.md +3 -3
  38. package/templates/frontend-sveltekit/Dockerfile +6 -3
  39. package/templates/frontend-sveltekit/package.json +2 -2
  40. package/templates/frontend-sveltekit/pnpm-workspace.yaml +11 -0
  41. package/templates/frontend-sveltekit/src/hooks.server.ts +13 -1
  42. package/templates/frontend-sveltekit/src/lib/components/ContactForm.svelte +15 -2
  43. package/templates/frontend-sveltekit/src/routes/+layout.server.ts +3 -0
  44. package/templates/frontend-sveltekit/src/routes/[...path]/+page.svelte +1 -1
  45. package/templates/frontend-sveltekit/vite.config.ts +10 -3
@@ -1,5 +1,6 @@
1
1
  // @ts-check
2
2
  import node from "@astrojs/node";
3
+ import { CAPTCHA_CSP, CAPTCHA_STYLE_HASH } from "@django-stack/utils";
3
4
  import { defineConfig } from "astro/config";
4
5
 
5
6
  // The Django backend: the frontend container reaches it on the Compose network.
@@ -15,14 +16,24 @@ export default defineConfig({
15
16
  server: { host: true, port: 3000 },
16
17
  // A Content-Security-Policy on every page: scripts and styles only from
17
18
  // this site, with hashes for the ones Astro inlines. src/middleware.ts adds
18
- // the origin of the media files.
19
+ // the origin of the media files. CAPTCHA_CSP allows the captcha widget of
20
+ // the contact form: its solver, its style and its workers.
19
21
  security: {
20
22
  csp: {
21
- directives: ["default-src 'self'", "base-uri 'self'", "form-action 'self'", "object-src 'none'"],
23
+ directives: [
24
+ "default-src 'self'",
25
+ "base-uri 'self'",
26
+ "form-action 'self'",
27
+ "object-src 'none'",
28
+ `worker-src ${CAPTCHA_CSP["worker-src"].join(" ")}`,
29
+ ],
22
30
  // Scripts from this site, such as the analytics tracker at /_a/script.js.
23
- scriptDirective: { resources: ["'self'"] },
24
- // Inline style attributes, such as the focal point of an image.
25
- styleDirective: { resources: ["'self'", { resource: "'unsafe-inline'", kind: "attribute" }] },
31
+ scriptDirective: { resources: ["'self'", ...CAPTCHA_CSP["script-src"]] },
32
+ styleDirective: {
33
+ // Inline style attributes, such as the focal point of an image.
34
+ resources: ["'self'", { resource: "'unsafe-inline'", kind: "attribute" }],
35
+ hashes: [/** @type {`sha256-${string}`} */ (CAPTCHA_STYLE_HASH.replaceAll("'", ""))],
36
+ },
26
37
  },
27
38
  },
28
39
  vite: {
@@ -16,7 +16,7 @@
16
16
  "@astrojs/node": "^11.1.9",
17
17
  "@astrojs/rss": "^4.0.19",
18
18
  "@django-headless/client": "^1.1.1",
19
- "@django-stack/utils": "^0.9.0",
19
+ "@django-stack/utils": "^0.10.0",
20
20
  "astro": "^7.4.5"
21
21
  },
22
22
  "devDependencies": {
@@ -0,0 +1,17 @@
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: {}
12
+
13
+ # The peer ranges of @astrojs/check allow a 2.0 beta of @emnapi, which pnpm
14
+ # picks and which crashes `astro check`. Keep @emnapi on 1.x, as npm does.
15
+ overrides:
16
+ "@emnapi/core": ^1.11.3
17
+ "@emnapi/runtime": ^1.11.3
@@ -1,20 +1,29 @@
1
1
  ---
2
2
  /**
3
3
  * The contact form. It posts to Django's public create route for form
4
- * submissions (PUBLIC_CREATE in backend/config/settings.py), which is
5
- * rate limited per IP address. Submissions appear in the admin.
4
+ * submissions (PUBLIC_CREATE in backend/config/settings.py), which is rate
5
+ * limited per IP address and, unless CAPTCHA=off, needs the token of the
6
+ * captcha widget. Submissions appear in the admin.
6
7
  */
8
+ import { CAPTCHA_ATTRIBUTES, captchaEnabled } from "@django-stack/utils";
9
+
10
+ const captcha = captchaEnabled(process.env.CAPTCHA);
7
11
  ---
8
12
 
9
13
  <form class="contact-form" data-endpoint="/api/content.formsubmission">
10
14
  <label>Name <input name="name" required maxlength="200" autocomplete="name" /></label>
11
15
  <label>Email <input name="email" type="email" required autocomplete="email" /></label>
12
16
  <label>Message <textarea name="message" required maxlength="5000" rows="6"></textarea></label>
17
+ {captcha && <cap-widget {...CAPTCHA_ATTRIBUTES}></cap-widget>}
13
18
  <button class="button" type="submit">Send</button>
14
19
  <p class="status" role="status" aria-live="polite"></p>
15
20
  </form>
16
21
 
17
22
  <script>
23
+ import { loadCaptcha, resetCaptcha } from "@django-stack/utils";
24
+
25
+ if (document.querySelector("form.contact-form cap-widget")) void loadCaptcha();
26
+
18
27
  for (const form of document.querySelectorAll<HTMLFormElement>("form.contact-form")) {
19
28
  form.addEventListener("submit", async (event) => {
20
29
  event.preventDefault();
@@ -29,8 +38,15 @@
29
38
  body: JSON.stringify(Object.fromEntries(new FormData(form))),
30
39
  });
31
40
  if (response.status === 429) throw new Error("Too many messages; try again in a minute.");
32
- if (!response.ok) throw new Error("The message could not be sent. Check the fields and try again.");
41
+ if (!response.ok) {
42
+ const errors = await response.json().catch(() => ({}));
43
+ // A token is valid once, so the widget verifies again.
44
+ resetCaptcha(form);
45
+ if (errors.captcha) throw new Error("Verify that you are human, then send the message again.");
46
+ throw new Error("The message could not be sent. Check the fields and try again.");
47
+ }
33
48
  form.reset();
49
+ resetCaptcha(form);
34
50
  status.textContent = "Thank you, your message was sent.";
35
51
  } catch (error) {
36
52
  status.textContent = error instanceof Error ? error.message : String(error);
@@ -1,4 +1,5 @@
1
1
  import { defineMiddleware } from "astro:middleware";
2
+ import { reportError } from "@django-stack/utils";
2
3
  import { getContent, PREVIEW_COOKIE } from "./lib/api";
3
4
  import { getCached, store } from "./lib/cache";
4
5
 
@@ -38,7 +39,14 @@ export const onRequest = defineMiddleware(async (context, next) => {
38
39
  if (hit) return hit;
39
40
  }
40
41
 
41
- let response = await next();
42
+ let response: Response;
43
+ try {
44
+ response = await next();
45
+ } catch (error) {
46
+ // Sent to Sentry with ERRORS=sentry; Astro logs it and renders the error page.
47
+ await reportError(error, { request });
48
+ throw error;
49
+ }
42
50
 
43
51
  // Redirects that editors manage in the admin, for paths that do not exist.
44
52
  if (response.status === 404 && request.method === "GET") {
@@ -12,7 +12,7 @@ src/app/ routes: /, /[...path], /blog, /blog/[slug], /category/[
12
12
  src/app/layout.tsx the page shell: menus, footer, preview banner
13
13
  src/components/ the post list, the menu links and the contact form
14
14
  src/lib/api.ts getContent(): content helpers with the read-only key, drafts in preview; pageMetadata()
15
- src/lib/headless.ts generated API types; do not edit, run `npm run generate:types`
15
+ src/lib/headless.ts generated API types; do not edit, run `{{pm_run}} generate:types`
16
16
  src/proxy.ts Content-Security-Policy with a nonce, security headers, the dev proxy to Django
17
17
  next.config.ts standalone output, the in-memory data cache
18
18
  ```
@@ -55,5 +55,5 @@ Run them in the `frontend` container (`docker compose exec frontend <command>`):
55
55
 
56
56
  | Task | Command |
57
57
  |------|---------|
58
- | Type-check | `npm run check` |
59
- | Test | `npm test` |
58
+ | Type-check | `{{pm_run}} check` |
59
+ | Test | `{{pm_run}} test` |
@@ -7,10 +7,13 @@ WORKDIR /app
7
7
  ENV NEXT_TELEMETRY_DISABLED=1
8
8
 
9
9
  FROM base AS deps
10
- COPY package.json package-lock.json* ./
10
+ {{pm_setup}}
11
+ COPY {{pm_manifests}} ./
11
12
  COPY vendor/ vendor/
12
13
  # No BuildKit cache mounts: Railway rejects them, and the layer is cached anyway.
13
- RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
14
+ RUN {{pm_install}}
15
+ # The package manager only installs: npm, which comes with Node.js, runs
16
+ # the scripts of package.json below.
14
17
 
15
18
  FROM deps AS dev
16
19
  EXPOSE 3000
@@ -13,9 +13,9 @@
13
13
  },
14
14
  "dependencies": {
15
15
  "@django-headless/client": "^1.1.1",
16
- "@django-stack/next": "^0.9.0",
17
- "@django-stack/react": "^0.9.0",
18
- "@django-stack/utils": "^0.9.0",
16
+ "@django-stack/next": "^0.10.0",
17
+ "@django-stack/react": "^0.10.0",
18
+ "@django-stack/utils": "^0.10.0",
19
19
  "next": "^16.4.4",
20
20
  "react": "^19.3.1",
21
21
  "react-dom": "^19.3.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,4 +1,5 @@
1
1
  import { Blocks } from "@django-stack/react";
2
+ import { captchaEnabled } from "@django-stack/utils";
2
3
  import type { Metadata } from "next";
3
4
  import { ContactForm } from "@/components/ContactForm";
4
5
  import { getContent, notFoundOrRedirect, pageMetadata } from "@/lib/api";
@@ -22,7 +23,7 @@ export default async function ContentPage({ params }: Props) {
22
23
  <article className="narrow">
23
24
  <h1>{page.title}</h1>
24
25
  <Blocks blocks={page.body} />
25
- {page.path === "contact" ? <ContactForm /> : null}
26
+ {page.path === "contact" ? <ContactForm captcha={captchaEnabled(process.env.CAPTCHA)} /> : null}
26
27
  </article>
27
28
  );
28
29
  }
@@ -3,11 +3,14 @@
3
3
  /**
4
4
  * The contact form. It posts to Django's public create route for form
5
5
  * submissions (PUBLIC_CREATE in backend/config/settings.py), which is rate
6
- * limited per IP address. Submissions appear in the admin.
6
+ * limited per IP address and, unless CAPTCHA=off, needs the token of the
7
+ * captcha widget. Submissions appear in the admin.
7
8
  */
9
+ import { Captcha } from "@django-stack/react";
10
+ import { resetCaptcha } from "@django-stack/utils";
8
11
  import { type FormEvent, useState } from "react";
9
12
 
10
- export function ContactForm() {
13
+ export function ContactForm({ captcha = true }: { captcha?: boolean }) {
11
14
  const [status, setStatus] = useState("");
12
15
  const [sending, setSending] = useState(false);
13
16
 
@@ -23,8 +26,15 @@ export function ContactForm() {
23
26
  body: JSON.stringify(Object.fromEntries(new FormData(form))),
24
27
  });
25
28
  if (response.status === 429) throw new Error("Too many messages; try again in a minute.");
26
- if (!response.ok) throw new Error("The message could not be sent. Check the fields and try again.");
29
+ if (!response.ok) {
30
+ const errors = await response.json().catch(() => ({}));
31
+ // A token is valid once, so the widget verifies again.
32
+ resetCaptcha(form);
33
+ if (errors.captcha) throw new Error("Verify that you are human, then send the message again.");
34
+ throw new Error("The message could not be sent. Check the fields and try again.");
35
+ }
27
36
  form.reset();
37
+ resetCaptcha(form);
28
38
  setStatus("Thank you, your message was sent.");
29
39
  } catch (error) {
30
40
  setStatus(error instanceof Error ? error.message : String(error));
@@ -44,6 +54,7 @@ export function ContactForm() {
44
54
  <label>
45
55
  Message <textarea name="message" required maxLength={5000} rows={6} />
46
56
  </label>
57
+ {captcha ? <Captcha /> : null}
47
58
  <button className="button" type="submit" disabled={sending}>
48
59
  Send
49
60
  </button>
@@ -0,0 +1,12 @@
1
+ import { reportError } from "@django-stack/utils";
2
+ import type { Instrumentation } from "next";
3
+
4
+ /** Server errors go to Sentry with ERRORS=sentry; Next.js logs them as well. */
5
+ export const onRequestError: Instrumentation.onRequestError = async (error, request) => {
6
+ const id = request.headers["x-request-id"];
7
+ await reportError(error, {
8
+ method: request.method,
9
+ path: request.path,
10
+ requestId: Array.isArray(id) ? id[0] : id,
11
+ });
12
+ };
@@ -1,4 +1,5 @@
1
1
  import { contentSecurityPolicy, mediaOrigins, PREVIEW_COOKIE, SECURITY_HEADERS } from "@django-stack/next";
2
+ import { captchaEnabled } from "@django-stack/utils";
2
3
  import { type NextRequest, NextResponse } from "next/server";
3
4
 
4
5
  const dev = process.env.NODE_ENV === "development";
@@ -11,6 +12,8 @@ const backend = process.env.BACKEND_INTERNAL_URL ?? "http://localhost:8000";
11
12
  const TRACKER_PATHS = /^\/_a\/(script\.js|api\/send)$/;
12
13
  const umami = process.env.UMAMI_URL || "http://umami:3000";
13
14
  const media = mediaOrigins();
15
+ // The captcha widget of the contact form, unless CAPTCHA=off.
16
+ const captcha = captchaEnabled(process.env.CAPTCHA);
14
17
 
15
18
  export function proxy(request: NextRequest) {
16
19
  const { pathname, search } = request.nextUrl;
@@ -23,7 +26,7 @@ export function proxy(request: NextRequest) {
23
26
 
24
27
  // A fresh nonce for every page: Next adds it to the scripts it renders.
25
28
  const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
26
- const policy = contentSecurityPolicy({ nonce, mediaOrigins: media, dev });
29
+ const policy = contentSecurityPolicy({ nonce, mediaOrigins: media, dev, captcha });
27
30
  const headers = new Headers(request.headers);
28
31
  headers.set("Content-Security-Policy", policy);
29
32
 
@@ -13,7 +13,7 @@ src/pages/ routes: /, /[...path], /blog, /blog/[slug], /catego
13
13
  src/layouts/default.vue the page shell: menus, footer, preview banner
14
14
  src/components/ the post list and the contact form
15
15
  src/composables/ useContent() loads from server/routes/_content and follows redirects; usePageSeo() sets the SEO tags
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
  server/routes/_content/ the content the pages load, cached in memory
18
18
  server/routes/ sitemap.xml, rss.xml, robots.txt, /preview, /revalidate
19
19
  server/utils/content.ts getContent(): content helpers with the read-only key, drafts in preview
@@ -60,5 +60,5 @@ Run them in the `frontend` container (`docker compose exec frontend <command>`):
60
60
 
61
61
  | Task | Command |
62
62
  |------|---------|
63
- | Type-check | `npm run check` |
64
- | Test | `npm test` |
63
+ | Type-check | `{{pm_run}} check` |
64
+ | Test | `{{pm_run}} test` |
@@ -7,10 +7,13 @@ WORKDIR /app
7
7
  ENV NUXT_TELEMETRY_DISABLED=1
8
8
 
9
9
  FROM base AS deps
10
- COPY package.json package-lock.json* ./
10
+ {{pm_setup}}
11
+ COPY {{pm_manifests}} ./
11
12
  COPY vendor/ vendor/
12
13
  # No BuildKit cache mounts: Railway rejects them, and the layer is cached anyway.
13
- RUN if [ -f package-lock.json ]; then npm ci --ignore-scripts; else npm install --ignore-scripts; fi
14
+ RUN {{pm_install_no_scripts}}
15
+ # The package manager only installs: npm, which comes with Node.js, runs
16
+ # the scripts of package.json below.
14
17
 
15
18
  FROM deps AS dev
16
19
  EXPOSE 3000
@@ -1,3 +1,5 @@
1
+ import { CAPTCHA_CSP } from "@django-stack/utils";
2
+
1
3
  // The Django backend: the frontend container reaches it on the Compose network.
2
4
  const backend = process.env.BACKEND_INTERNAL_URL ?? "http://localhost:8000";
3
5
  // Umami, for page analytics: only its tracker and page views, under /_a/.
@@ -42,7 +44,8 @@ export default defineNuxtConfig({
42
44
  // Security headers, with a Content-Security-Policy on every page in
43
45
  // production: scripts and styles only from this site, with a nonce for
44
46
  // the ones Nuxt inlines. server/plugins/media-origins.ts adds the origin
45
- // of the media files.
47
+ // of the media files. CAPTCHA_CSP allows the captcha widget of the contact
48
+ // form: its solver, its style and its workers.
46
49
  security: {
47
50
  nonce: true,
48
51
  rateLimiter: false,
@@ -52,8 +55,9 @@ export default defineNuxtConfig({
52
55
  process.env.NODE_ENV === "production"
53
56
  ? {
54
57
  "default-src": ["'self'"],
55
- "script-src": ["'self'", "'nonce-{{nonce}}'", "'strict-dynamic'"],
56
- "style-src": ["'self'", "'nonce-{{nonce}}'"],
58
+ "script-src": ["'self'", "'nonce-{{nonce}}'", "'strict-dynamic'", ...CAPTCHA_CSP["script-src"]],
59
+ "style-src": ["'self'", "'nonce-{{nonce}}'", ...CAPTCHA_CSP["style-src"]],
60
+ "worker-src": [...CAPTCHA_CSP["worker-src"]],
57
61
  // Inline style attributes, such as the focal point of an image.
58
62
  "style-src-attr": ["'unsafe-inline'"],
59
63
  "img-src": ["'self'", "data:"],
@@ -15,8 +15,8 @@
15
15
  },
16
16
  "dependencies": {
17
17
  "@django-headless/client": "^1.1.1",
18
- "@django-stack/utils": "^0.9.0",
19
- "@django-stack/vue": "^0.9.0",
18
+ "@django-stack/utils": "^0.10.0",
19
+ "@django-stack/vue": "^0.10.0",
20
20
  "nuxt": "^4.6.3",
21
21
  "nuxt-security": "^2.7.0",
22
22
  "vue": "^3.5.44",
@@ -0,0 +1,13 @@
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:
12
+ # Its install script checks its binary.
13
+ esbuild: true
@@ -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
12
  # No BuildKit cache mounts: Railway rejects them, and the layer is cached anyway.
12
- RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
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.9.0",
23
- "@django-stack/utils": "^0.9.0"
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>