@django-stack/cli 0.5.0 → 0.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@django-stack/cli",
3
- "version": "0.5.0",
3
+ "version": "0.7.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",
@@ -5,9 +5,11 @@ https://djangocontentstudio.org/docs/extensions for the hooks and extensions.
5
5
  """
6
6
 
7
7
  from content_studio.admin import ContentStudioSite
8
+ from content_studio.analytics import AnalyticsExtension, AnalyticsLink, AnalyticsPage
8
9
  from content_studio.contrib.huey import HueyAdapter
9
10
  from content_studio.dashboard.tasks import TaskDashboard
10
11
  from content_studio.extensions import UserMenuLink
12
+ from django_stack.analytics import UmamiProvider
11
13
 
12
14
 
13
15
  class SiteStudio(ContentStudioSite):
@@ -16,5 +18,12 @@ class SiteStudio(ContentStudioSite):
16
18
  UserMenuLink(url="/tasks/", label="Tasks", icon="ph-bold ph-queue"),
17
19
  ]
18
20
 
21
+ # Page analytics from the bundled Umami when UMAMI_URL is set (see
22
+ # .env): an Analytics page in the main menu. content/admin.py shows the
23
+ # numbers of each page and post. See
24
+ # https://djangocontentstudio.org/docs/analytics.
25
+ if UmamiProvider.is_configured():
26
+ extensions += [AnalyticsExtension(provider=UmamiProvider()), AnalyticsPage(), AnalyticsLink()]
27
+
19
28
 
20
29
  admin_site = SiteStudio()
@@ -5,6 +5,7 @@ The studios of the content models: how they look in Content Studio at
5
5
 
6
6
  from content_studio import register
7
7
  from content_studio.admin import ModelStudio
8
+ from content_studio.analytics import Analytics
8
9
  from content_studio.form import ActivityLog, Field, FormSet
9
10
  from django_stack.studio import PreviewButton
10
11
 
@@ -15,6 +16,10 @@ PAGE_PREVIEW = PreviewButton(component_id="8d0f6a52-3c51-4a8e-9a2b-6f1f0c8f2a01"
15
16
  POST_PREVIEW = PreviewButton(component_id="8d0f6a52-3c51-4a8e-9a2b-6f1f0c8f2a02")
16
17
  PAGE_ACTIVITY = ActivityLog(component_id="8d0f6a52-3c51-4a8e-9a2b-6f1f0c8f2a03")
17
18
  POST_ACTIVITY = ActivityLog(component_id="8d0f6a52-3c51-4a8e-9a2b-6f1f0c8f2a04")
19
+ # 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")
18
23
  SEO = FormSet(
19
24
  title="Search and sharing",
20
25
  description="Leave empty to use the title and the site settings.",
@@ -37,7 +42,7 @@ class PageStudio(ModelStudio):
37
42
  FormSet(title="Body", fields=["body"]),
38
43
  SEO,
39
44
  ]
40
- edit_sidebar = ["status", "published_at", PAGE_PREVIEW, "order", PAGE_ACTIVITY]
45
+ edit_sidebar = ["status", "published_at", PAGE_PREVIEW, "order", PAGE_ANALYTICS, PAGE_ACTIVITY]
41
46
 
42
47
 
43
48
  @register(Post)
@@ -55,7 +60,16 @@ class PostStudio(ModelStudio):
55
60
  FormSet(title="Body", fields=["body"]),
56
61
  SEO,
57
62
  ]
58
- edit_sidebar = ["status", "published_at", POST_PREVIEW, "author", "categories", "tags", POST_ACTIVITY]
63
+ edit_sidebar = [
64
+ "status",
65
+ "published_at",
66
+ POST_PREVIEW,
67
+ "author",
68
+ "categories",
69
+ "tags",
70
+ POST_ANALYTICS,
71
+ POST_ACTIVITY,
72
+ ]
59
73
 
60
74
 
61
75
  @register(Category, Tag)
@@ -5,7 +5,8 @@
5
5
  # first admin user and, with SEED_DEMO_CONTENT=1, add demo content to an
6
6
  # empty site. DJANGO_CHECK_DEPLOY=1 (production): stop when Django's deploy
7
7
  # checks find an error. S3_CREATE_BUCKET=1 (the development S3 service):
8
- # create the media bucket.
8
+ # create the media bucket. UMAMI_URL set (the analytics profile): set up
9
+ # Umami; when that fails the site still starts, without analytics.
9
10
  set -eu
10
11
 
11
12
  python manage.py wait_for_db
@@ -22,6 +23,9 @@ if [ "${DJANGO_MIGRATE:-0}" = "1" ]; then
22
23
  if [ "${SEED_DEMO_CONTENT:-0}" = "1" ]; then
23
24
  python manage.py seed_content --if-empty
24
25
  fi
26
+ if [ -n "${UMAMI_URL:-}" ]; then
27
+ python manage.py setup_umami || echo "Umami is not set up, so the admin shows no analytics. See the error above." >&2
28
+ fi
25
29
  fi
26
30
 
27
31
  exec "$@"
@@ -5,9 +5,9 @@ description = "The Django backend of {{project_name}}."
5
5
  requires-python = ">=3.12"
6
6
  dependencies = [
7
7
  "Django>=6.1,<6.2",
8
- "django-stack[server,s3]>=0.5.0",
8
+ "django-stack[server,s3]>=0.7.0",
9
9
  "django-headless[schema]>=1.1.1",
10
- "django-content-studio[images,huey]>=1.8.0",
10
+ "django-content-studio[images,huey]>=1.11.0",
11
11
  ]
12
12
 
13
13
  [dependency-groups]
@@ -41,7 +41,8 @@ Run them at the project root. They need Docker; Node is only needed for the `npm
41
41
 
42
42
  In production (`COMPOSE_PROFILES=prod,db`) the services are `backend-prod`,
43
43
  `worker-prod` (which also runs the daily backup), `frontend-prod` and
44
- `proxy`; their containers are
44
+ `proxy`, with `umami` for page analytics when the `analytics` profile is on;
45
+ their containers are
45
46
  read-only, so a change goes through the code and a new image, never an edit
46
47
  in a running container.
47
48
 
@@ -1,7 +1,8 @@
1
1
  # {{project_name}}: the reverse proxy of the prod profile.
2
2
  #
3
3
  # One domain: Django serves /admin, /api and /static; uploads on the server's
4
- # disk are served from /media; the frontend serves everything else. Caddy
4
+ # disk are served from /media; Umami's tracker from /_a/; the frontend
5
+ # serves everything else. Caddy
5
6
  # gets and renews the HTTPS certificate for SITE_URL by itself.
6
7
  #
7
8
  # Behind a platform's proxy (Coolify, Dokploy), CADDY_ADDRESS=:80 serves
@@ -35,6 +36,18 @@
35
36
  file_server
36
37
  }
37
38
 
39
+ # Page analytics: only Umami's tracker and its page views, never its
40
+ # interface or API. Without the analytics profile these answer 502.
41
+ handle_path /_a/* {
42
+ @tracker path /script.js /api/send
43
+ handle @tracker {
44
+ reverse_proxy umami:3000
45
+ }
46
+ handle {
47
+ respond 404
48
+ }
49
+ }
50
+
38
51
  @django path /admin /admin/* /api /api/* /static/*
39
52
  handle @django {
40
53
  reverse_proxy backend:8000
@@ -6,7 +6,9 @@ A website with a Django backend and a frontend built with {{frontend_label}}, cr
6
6
  - [Django Headless](https://djangoheadless.org) serves the content API.
7
7
  - [Django Content Studio](https://djangocontentstudio.org) is the admin.
8
8
  - [{{frontend_label}}]({{frontend_url}}) renders the site.
9
- - Postgres stores the content; Valkey is the cache and task queue.
9
+ - Postgres stores the content, and by default also the cache and the task
10
+ queue (`CACHE_URL` and `TASKS_URL` in `.env`); Valkey can take those over
11
+ with the `valkey` profile.
10
12
 
11
13
  ## Start
12
14
 
@@ -16,9 +18,12 @@ You need Docker.
16
18
  docker compose up --build
17
19
  ```
18
20
 
19
- - Site: http://localhost:3000
20
- - Admin: http://localhost:3000/admin/ (user `admin`, password `ADMIN_PASSWORD` in `.env`)
21
- - Outgoing mail: http://localhost:8025
21
+ - Site: http://localhost:{{site_port}}
22
+ - Admin: http://localhost:{{site_port}}/admin/ (user `admin`, password `ADMIN_PASSWORD` in `.env`)
23
+ - Outgoing mail: http://localhost:{{mail_port}}
24
+
25
+ The ports are `SITE_PORT` and `MAIL_PORT` in `.env`. When you change
26
+ `SITE_PORT`, change `SITE_URL` and `BACKEND_URL` with it.
22
27
 
23
28
  The first start migrates the database, creates the admin user and adds demo
24
29
  content. Edit a page in the admin, publish, and reload the site.
@@ -51,6 +56,15 @@ site with drafts shown, for an hour.
51
56
  When an editor saves, Django tells the frontend (`REVALIDATE_URL`), which
52
57
  empties its page cache, so the change shows on the next visit.
53
58
 
59
+ ## Analytics
60
+
61
+ When `COMPOSE_PROFILES` in `.env` has `analytics`, the `umami` service
62
+ counts page views without cookies, and the admin shows them under
63
+ **Analytics**. Umami's own interface is not served: only its tracker and the
64
+ page views pass through the site at `/_a/`. To turn it off, remove
65
+ `analytics` from `COMPOSE_PROFILES` and empty `UMAMI_URL`. See
66
+ [Page analytics](https://dwarsbit.github.io/django-stack/docs/analytics).
67
+
54
68
  ## Deploy
55
69
 
56
70
  On a server with Docker and a domain pointing at it:
@@ -2,11 +2,14 @@
2
2
  #
3
3
  # COMPOSE_PROFILES in .env picks the services:
4
4
  # dev,db `docker compose up` on a laptop: dev servers with hot reload,
5
- # http://localhost:3000, Mailpit at http://localhost:8025.
5
+ # http://localhost:3000, Mailpit at http://localhost:8025 (the
6
+ # *_PORT variables in .env change the ports).
6
7
  # prod,db `docker compose up -d` on a server: production images behind
7
8
  # Caddy on ports 80 and 443, with automatic HTTPS and daily backups.
8
9
  # Leave out `db` to use an external Postgres through DATABASE_URL, and add
9
- # `s3` in development for a local S3 service (see .env).
10
+ # `s3` in development for a local S3 service (see .env). `analytics` runs
11
+ # Umami for page analytics, `valkey` the Valkey service for CACHE_URL or
12
+ # TASKS_URL.
10
13
  #
11
14
  # The frontend only receives the variables it needs: never the full-access
12
15
  # API key or the database password.
@@ -31,6 +34,9 @@ x-frontend-env: &frontend-env
31
34
  PREVIEW_SECRET: ${PREVIEW_SECRET}
32
35
  REVALIDATE_SECRET: ${REVALIDATE_SECRET}
33
36
  MEDIA_ORIGINS: ${S3_PUBLIC_URL:-${S3_ENDPOINT_URL:-}}
37
+ # Page analytics: the tracker loads from /_a/ on the site when set.
38
+ UMAMI_WEBSITE_ID: ${UMAMI_WEBSITE_ID:-}
39
+ UMAMI_URL: ${UMAMI_URL:-}
34
40
 
35
41
  x-backend-depends: &backend-depends
36
42
  db:
@@ -38,9 +44,14 @@ x-backend-depends: &backend-depends
38
44
  required: false
39
45
  cache:
40
46
  condition: service_healthy
47
+ required: false
41
48
  s3:
42
49
  condition: service_started
43
50
  required: false
51
+ # The backend sets Umami up on start; it waits for it by itself.
52
+ umami:
53
+ condition: service_started
54
+ required: false
44
55
 
45
56
  x-backend-health: &backend-health
46
57
  test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/readyz', timeout=4)"]
@@ -64,7 +75,7 @@ services:
64
75
  - ./backend:/app
65
76
  - media:/app/media
66
77
  ports:
67
- - "127.0.0.1:8000:8000"
78
+ - "127.0.0.1:${BACKEND_PORT:-8000}:8000"
68
79
  depends_on: *backend-depends
69
80
  healthcheck: *backend-health
70
81
  restart: unless-stopped
@@ -79,6 +90,9 @@ services:
79
90
  volumes:
80
91
  - ./backend:/app
81
92
  - media:/app/media
93
+ # 0 with TASKS_URL=immediate://: tasks run in the request.
94
+ deploy:
95
+ replicas: ${TASK_WORKERS:-1}
82
96
  depends_on:
83
97
  backend:
84
98
  condition: service_healthy
@@ -94,7 +108,7 @@ services:
94
108
  - ./frontend:/app
95
109
  - frontend_node_modules:/app/node_modules
96
110
  ports:
97
- - "127.0.0.1:3000:3000"
111
+ - "127.0.0.1:${SITE_PORT:-3000}:3000"
98
112
  depends_on:
99
113
  backend:
100
114
  condition: service_healthy
@@ -104,11 +118,12 @@ services:
104
118
  profiles: [dev]
105
119
  image: axllent/mailpit:v1.27
106
120
  ports:
107
- - "127.0.0.1:8025:8025"
121
+ - "127.0.0.1:${MAIL_PORT:-8025}:8025"
108
122
  restart: unless-stopped
109
123
 
110
124
  # A local S3 service, so development runs the same storage code as
111
- # production with S3. Files are public at http://localhost:8333/<bucket>/.
125
+ # production with S3. Files are public at http://localhost:8333/<bucket>/
126
+ # (S3_PORT).
112
127
  s3:
113
128
  profiles: [s3]
114
129
  image: chrislusf/seaweedfs:4.52
@@ -116,7 +131,7 @@ services:
116
131
  volumes:
117
132
  - s3data:/data
118
133
  ports:
119
- - "127.0.0.1:8333:8333"
134
+ - "127.0.0.1:${S3_PORT:-8333}:8333"
120
135
  restart: unless-stopped
121
136
 
122
137
  # Production -------------------------------------------------------------
@@ -173,6 +188,7 @@ services:
173
188
  healthcheck:
174
189
  disable: true
175
190
  deploy:
191
+ replicas: ${TASK_WORKERS:-1}
176
192
  resources:
177
193
  limits:
178
194
  memory: 512M
@@ -232,6 +248,36 @@ services:
232
248
 
233
249
  # Shared -----------------------------------------------------------------
234
250
 
251
+ # Page analytics (the analytics profile). Umami's interface and API stay on
252
+ # the internal network: Caddy, and the frontend's dev server, pass on only
253
+ # the tracker and the page views at /_a/, and the admin reads the numbers
254
+ # with UMAMI_ADMIN_PASSWORD. Its tables are in the umami schema of the
255
+ # database.
256
+ umami:
257
+ profiles: [analytics]
258
+ image: umamisoftware/umami:3.4
259
+ <<: *hardened
260
+ environment:
261
+ DATABASE_URL: ${UMAMI_DATABASE_URL:-}
262
+ APP_SECRET: ${UMAMI_APP_SECRET:-}
263
+ DISABLE_TELEMETRY: "1"
264
+ DISABLE_UPDATES: "1"
265
+ depends_on:
266
+ db:
267
+ condition: service_healthy
268
+ required: false
269
+ healthcheck:
270
+ test: ["CMD", "node", "-e", "fetch('http://localhost:3000/api/heartbeat').then((r) => process.exit(r.ok ? 0 : 1), () => process.exit(1))"]
271
+ interval: 5s
272
+ timeout: 5s
273
+ start_period: 60s
274
+ retries: 30
275
+ deploy:
276
+ resources:
277
+ limits:
278
+ memory: 512M
279
+ restart: unless-stopped
280
+
235
281
  db:
236
282
  profiles: [db]
237
283
  image: postgres:17-alpine
@@ -254,9 +300,11 @@ services:
254
300
  memory: 1G
255
301
  restart: unless-stopped
256
302
 
257
- # Cache, sessions and the task queue. Only keys with an expiry are
258
- # evicted when memory runs out, so queued tasks are never dropped.
303
+ # Valkey (the valkey profile), for CACHE_URL or TASKS_URL redis://cache.
304
+ # Only keys with an expiry are evicted when memory runs out, so queued
305
+ # tasks are never dropped.
259
306
  cache:
307
+ profiles: [valkey]
260
308
  image: valkey/valkey:8-alpine
261
309
  user: valkey
262
310
  command: ["valkey-server", "--save", "", "--appendonly", "no", "--maxmemory", "200mb", "--maxmemory-policy", "volatile-lru"]
@@ -8,7 +8,8 @@ PROJECT_NAME={{project_name}}
8
8
  # dev,db development on this machine
9
9
  # prod,db production on a server
10
10
  # Leave out `db` when DATABASE_URL points to an external Postgres; add `s3`
11
- # in development for the local S3 service.
11
+ # in development for the local S3 service. `analytics` runs Umami, `valkey`
12
+ # the Valkey service.
12
13
  COMPOSE_PROFILES=dev,db
13
14
 
14
15
  # The public URL of the site. In development the frontend's dev server also
@@ -17,6 +18,15 @@ COMPOSE_PROFILES=dev,db
17
18
  SITE_URL=http://localhost:3000
18
19
  BACKEND_URL=http://localhost:3000
19
20
 
21
+ # The ports of the development services on this machine: the site with the
22
+ # admin, the backend, Mailpit and the local S3 service. `init` and
23
+ # `dstack dev` move to free ones when these are in use, and change the
24
+ # URLs above with SITE_PORT.
25
+ SITE_PORT=3000
26
+ BACKEND_PORT=8000
27
+ MAIL_PORT=8025
28
+ S3_PORT=8333
29
+
20
30
  # Behind the proxy of a platform such as Coolify or Dokploy, which gets the
21
31
  # certificate, Caddy serves plain HTTP on port 80 to it instead:
22
32
  # CADDY_ADDRESS=:80 TRUSTED_PROXIES=private_ranges NUM_PROXIES=2
@@ -33,9 +43,18 @@ POSTGRES_USER=app
33
43
  POSTGRES_PASSWORD={{secret:postgres}}
34
44
  DATABASE_URL=postgres://app:{{secret:postgres}}@db:5432/app
35
45
 
36
- # Cache, task queue and mail. In production, EMAIL_URL is your SMTP server,
37
- # such as smtp+tls://user:password@smtp.example.com:587.
38
- CACHE_URL=redis://cache:6379/0
46
+ # Cache and task queue: db:// keeps them in tables in the database, so
47
+ # nothing else runs. For the Valkey service add `valkey` to COMPOSE_PROFILES
48
+ # and use redis://cache:6379/0 (the cache) or redis://cache:6379/1 (the
49
+ # tasks). CACHE_URL=locmem:// is a cache per process. TASKS_URL=immediate://
50
+ # runs tasks in the request, with TASK_WORKERS=0 to stop the worker; there
51
+ # are no daily backups then.
52
+ CACHE_URL=db://
53
+ TASKS_URL=db://
54
+ TASK_WORKERS=1
55
+
56
+ # Mail. In production, EMAIL_URL is your SMTP server, such as
57
+ # smtp+tls://user:password@smtp.example.com:587.
39
58
  EMAIL_URL=smtp://mailpit:1025
40
59
  DEFAULT_FROM_EMAIL={{project_name}} <noreply@localhost>
41
60
 
@@ -71,6 +90,20 @@ S3_ACCESS_KEY_ID=
71
90
  S3_SECRET_ACCESS_KEY=
72
91
  S3_PUBLIC_URL=
73
92
 
93
+ # Page analytics: Umami, in the `analytics` profile. It runs on the internal
94
+ # network only: its tracker and the page views pass through /_a/ on the
95
+ # site, and the admin shows the numbers under Analytics. Umami keeps its
96
+ # tables in the `umami` schema of the database (UMAMI_DATABASE_URL is
97
+ # DATABASE_URL with ?schema=umami), so backups include them. The backend
98
+ # replaces Umami's default admin password with UMAMI_ADMIN_PASSWORD and
99
+ # creates the website UMAMI_WEBSITE_ID on start. To turn analytics off,
100
+ # remove `analytics` from COMPOSE_PROFILES and empty UMAMI_URL.
101
+ UMAMI_URL=
102
+ UMAMI_WEBSITE_ID=
103
+ UMAMI_ADMIN_PASSWORD=
104
+ UMAMI_APP_SECRET=
105
+ UMAMI_DATABASE_URL=
106
+
74
107
  # Backups (prod profile): a daily pg_dump at BACKUP_TIME (UTC) and, when
75
108
  # uploads are on disk (no S3_BUCKET) and BACKUP_MEDIA=1, an archive of the
76
109
  # media volume; the newest BACKUP_KEEP of each kept in the backups volume.
@@ -4,6 +4,8 @@ import { defineConfig } from "astro/config";
4
4
 
5
5
  // The Django backend: the frontend container reaches it on the Compose network.
6
6
  const backend = process.env.BACKEND_INTERNAL_URL ?? "http://localhost:8000";
7
+ // Umami, for page analytics: only its tracker and page views, under /_a/.
8
+ const umami = process.env.UMAMI_URL || "http://umami:3000";
7
9
 
8
10
  export default defineConfig({
9
11
  site: process.env.SITE_URL ?? "http://localhost:3000",
@@ -17,6 +19,8 @@ export default defineConfig({
17
19
  security: {
18
20
  csp: {
19
21
  directives: ["default-src 'self'", "base-uri 'self'", "form-action 'self'", "object-src 'none'"],
22
+ // Scripts from this site, such as the analytics tracker at /_a/script.js.
23
+ scriptDirective: { resources: ["'self'"] },
20
24
  // Inline style attributes, such as the focal point of an image.
21
25
  styleDirective: { resources: ["'self'", { resource: "'unsafe-inline'", kind: "attribute" }] },
22
26
  },
@@ -27,12 +31,15 @@ export default defineConfig({
27
31
  allowedHosts: ["frontend"],
28
32
  // In development, serve Django's paths from the same origin as the site,
29
33
  // like the reverse proxy does in production.
30
- proxy: Object.fromEntries(
31
- ["/admin", "/api", "/media", "/static"].map((path) => [
32
- path,
33
- { target: backend, changeOrigin: false },
34
- ]),
35
- ),
34
+ proxy: {
35
+ ...Object.fromEntries(
36
+ ["/admin", "/api", "/media", "/static"].map((path) => [
37
+ path,
38
+ { target: backend, changeOrigin: false },
39
+ ]),
40
+ ),
41
+ "^/_a/(script\\.js|api/send)$": { target: umami, rewrite: (path) => path.slice("/_a".length) },
42
+ },
36
43
  },
37
44
  },
38
45
  });
@@ -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.5.0",
19
+ "@django-stack/utils": "^0.7.0",
20
20
  "astro": "^7.4.5"
21
21
  },
22
22
  "devDependencies": {
@@ -20,6 +20,8 @@ const [settings, mainMenu, footerMenu] = await Promise.all([
20
20
  ]);
21
21
  const preview = Astro.cookies.has(PREVIEW_COOKIE);
22
22
  const path = Astro.url.pathname;
23
+ // Page analytics: Umami's tracker, served from this site at /_a/.
24
+ const analytics = process.env.UMAMI_WEBSITE_ID;
23
25
  ---
24
26
 
25
27
  <!doctype html>
@@ -30,6 +32,7 @@ const path = Astro.url.pathname;
30
32
  <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
31
33
  <link rel="alternate" type="application/rss+xml" title={settings?.site_name ?? "RSS"} href="/rss.xml" />
32
34
  <Seo page={page} settings={settings} type={type} />
35
+ {analytics && <script is:inline defer src="/_a/script.js" data-website-id={analytics} />}
33
36
  </head>
34
37
  <body>
35
38
  {
@@ -13,9 +13,9 @@
13
13
  },
14
14
  "dependencies": {
15
15
  "@django-headless/client": "^1.1.1",
16
- "@django-stack/next": "^0.5.0",
17
- "@django-stack/react": "^0.5.0",
18
- "@django-stack/utils": "^0.5.0",
16
+ "@django-stack/next": "^0.7.0",
17
+ "@django-stack/react": "^0.7.0",
18
+ "@django-stack/utils": "^0.7.0",
19
19
  "next": "^16.4.4",
20
20
  "react": "^19.3.1",
21
21
  "react-dom": "^19.3.1",
@@ -2,6 +2,7 @@ import "./globals.css";
2
2
  import { Image, RichText } from "@django-stack/react";
3
3
  import { linkHref } from "@django-stack/utils";
4
4
  import type { Metadata } from "next";
5
+ import Script from "next/script";
5
6
  import { NavLink } from "@/components/NavLink";
6
7
  import { getContent, previewToken, SITE_URL } from "@/lib/api";
7
8
 
@@ -64,6 +65,14 @@ export default async function RootLayout({ children }: { children: React.ReactNo
64
65
  </nav>
65
66
  </div>
66
67
  </footer>
68
+ {/* Page analytics: Umami's tracker, served from this site at /_a/. */}
69
+ {process.env.UMAMI_WEBSITE_ID ? (
70
+ <Script
71
+ src="/_a/script.js"
72
+ data-website-id={process.env.UMAMI_WEBSITE_ID}
73
+ strategy="afterInteractive"
74
+ />
75
+ ) : null}
67
76
  </body>
68
77
  </html>
69
78
  );
@@ -7,6 +7,9 @@ const dev = process.env.NODE_ENV === "development";
7
7
  // development, this proxy does, so the site and the admin share an origin.
8
8
  const BACKEND_PATHS = /^\/(admin|api|media|static)(\/|$)/;
9
9
  const backend = process.env.BACKEND_INTERNAL_URL ?? "http://localhost:8000";
10
+ // Umami's tracker and page views, for page analytics; Caddy passes them on in production.
11
+ const TRACKER_PATHS = /^\/_a\/(script\.js|api\/send)$/;
12
+ const umami = process.env.UMAMI_URL || "http://umami:3000";
10
13
  const media = mediaOrigins();
11
14
 
12
15
  export function proxy(request: NextRequest) {
@@ -14,6 +17,9 @@ export function proxy(request: NextRequest) {
14
17
  if (dev && BACKEND_PATHS.test(pathname)) {
15
18
  return NextResponse.rewrite(new URL(pathname + search, backend));
16
19
  }
20
+ if (dev && TRACKER_PATHS.test(pathname)) {
21
+ return NextResponse.rewrite(new URL(pathname.slice("/_a".length) + search, umami));
22
+ }
17
23
 
18
24
  // A fresh nonce for every page: Next adds it to the scripts it renders.
19
25
  const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
@@ -1,5 +1,7 @@
1
1
  // The Django backend: the frontend container reaches it on the Compose network.
2
2
  const backend = process.env.BACKEND_INTERNAL_URL ?? "http://localhost:8000";
3
+ // Umami, for page analytics: only its tracker and page views, under /_a/.
4
+ const umami = process.env.UMAMI_URL || "http://umami:3000";
3
5
 
4
6
  export default defineNuxtConfig({
5
7
  compatibilityDate: "2026-11-01",
@@ -26,12 +28,16 @@ export default defineNuxtConfig({
26
28
  // In development, serve Django's paths from the same origin as the site,
27
29
  // like the reverse proxy does in production.
28
30
  $development: {
29
- routeRules: Object.fromEntries(
30
- ["/admin", "/api", "/media", "/static"].map((path) => [
31
- `${path}/**`,
32
- { proxy: `${backend}${path}/**` },
33
- ]),
34
- ),
31
+ routeRules: {
32
+ ...Object.fromEntries(
33
+ ["/admin", "/api", "/media", "/static"].map((path) => [
34
+ `${path}/**`,
35
+ { proxy: `${backend}${path}/**` },
36
+ ]),
37
+ ),
38
+ "/_a/script.js": { proxy: `${umami}/script.js` },
39
+ "/_a/api/send": { proxy: `${umami}/api/send` },
40
+ },
35
41
  },
36
42
  // Security headers, with a Content-Security-Policy on every page in
37
43
  // production: scripts and styles only from this site, with a nonce for
@@ -15,8 +15,8 @@
15
15
  },
16
16
  "dependencies": {
17
17
  "@django-headless/client": "^1.1.1",
18
- "@django-stack/utils": "^0.5.0",
19
- "@django-stack/vue": "^0.5.0",
18
+ "@django-stack/utils": "^0.7.0",
19
+ "@django-stack/vue": "^0.7.0",
20
20
  "nuxt": "^4.6.3",
21
21
  "nuxt-security": "^2.7.0",
22
22
  "vue": "^3.5.44",
@@ -9,5 +9,10 @@ export default defineEventHandler(async (event) => {
9
9
  ]);
10
10
  return { settings, mainMenu, footerMenu, siteUrl: SITE_URL };
11
11
  });
12
- return { ...data, preview: previewToken(event) !== null };
12
+ return {
13
+ ...data,
14
+ preview: previewToken(event) !== null,
15
+ // Page analytics: Umami's tracker, served from this site at /_a/.
16
+ analytics: process.env.UMAMI_WEBSITE_ID || null,
17
+ };
13
18
  });
@@ -10,6 +10,9 @@ useHead(() => ({
10
10
  link: [
11
11
  { rel: "alternate", type: "application/rss+xml", title: data.value?.settings?.site_name ?? "RSS", href: "/rss.xml" },
12
12
  ],
13
+ script: data.value?.analytics
14
+ ? [{ src: "/_a/script.js", defer: true, "data-website-id": data.value.analytics }]
15
+ : [],
13
16
  }));
14
17
  </script>
15
18
 
@@ -19,8 +19,8 @@
19
19
  },
20
20
  "dependencies": {
21
21
  "@django-headless/client": "^1.1.1",
22
- "@django-stack/svelte": "^0.5.0",
23
- "@django-stack/utils": "^0.5.0"
22
+ "@django-stack/svelte": "^0.7.0",
23
+ "@django-stack/utils": "^0.7.0"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@sveltejs/adapter-node": "^6.0.1",
@@ -14,5 +14,7 @@ export const load: LayoutServerLoad = async ({ cookies }) => {
14
14
  footerMenu,
15
15
  preview: cookies.get(PREVIEW_COOKIE) !== undefined,
16
16
  siteUrl: SITE_URL,
17
+ // Page analytics: Umami's tracker, served from this site at /_a/.
18
+ analytics: process.env.UMAMI_WEBSITE_ID || null,
17
19
  };
18
20
  };
@@ -13,6 +13,9 @@
13
13
 
14
14
  <svelte:head>
15
15
  <link rel="alternate" type="application/rss+xml" title={data.settings?.site_name ?? "RSS"} href="/rss.xml" />
16
+ {#if data.analytics}
17
+ <script defer src="/_a/script.js" data-website-id={data.analytics}></script>
18
+ {/if}
16
19
  </svelte:head>
17
20
 
18
21
  <Seo