@django-stack/cli 0.0.1 → 0.2.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 (79) hide show
  1. package/README.md +27 -3
  2. package/dist/bin.js +2282 -2
  3. package/dist/index.d.ts +252 -1
  4. package/dist/index.js +1 -4
  5. package/dist/stack-CTyqYWCV.js +800 -0
  6. package/package.json +15 -7
  7. package/templates/backend/.dockerignore +11 -0
  8. package/templates/backend/Dockerfile +45 -0
  9. package/templates/backend/config/__init__.py +0 -0
  10. package/templates/backend/config/asgi.py +7 -0
  11. package/templates/backend/config/settings.py +87 -0
  12. package/templates/backend/config/studio.py +20 -0
  13. package/templates/backend/config/urls.py +9 -0
  14. package/templates/backend/config/wsgi.py +7 -0
  15. package/templates/backend/conftest.py +7 -0
  16. package/templates/backend/content/__init__.py +0 -0
  17. package/templates/backend/content/admin.py +107 -0
  18. package/templates/backend/content/apps.py +6 -0
  19. package/templates/backend/content/blocks.py +51 -0
  20. package/templates/backend/content/migrations/0001_initial.py +418 -0
  21. package/templates/backend/content/migrations/__init__.py +0 -0
  22. package/templates/backend/content/models.py +234 -0
  23. package/templates/backend/content/seed.py +153 -0
  24. package/templates/backend/content/tests/__init__.py +0 -0
  25. package/templates/backend/content/tests/test_api.py +73 -0
  26. package/templates/backend/entrypoint.sh +27 -0
  27. package/templates/backend/manage.py +16 -0
  28. package/templates/backend/pyproject.toml +33 -0
  29. package/templates/backend/vendor/.gitkeep +0 -0
  30. package/templates/base/.github/workflows/deploy.yml +70 -0
  31. package/templates/base/AGENTS.md +79 -0
  32. package/templates/base/CLAUDE.md +1 -0
  33. package/templates/base/Caddyfile +51 -0
  34. package/templates/base/README.md +87 -0
  35. package/templates/base/SECURITY.md +12 -0
  36. package/templates/base/compose.yaml +282 -0
  37. package/templates/base/env.example +83 -0
  38. package/templates/base/gitignore +15 -0
  39. package/templates/base/package.json +21 -0
  40. package/templates/frontend-astro/.dockerignore +4 -0
  41. package/templates/frontend-astro/Dockerfile +32 -0
  42. package/templates/frontend-astro/astro.config.mjs +38 -0
  43. package/templates/frontend-astro/package.json +31 -0
  44. package/templates/frontend-astro/public/favicon.svg +1 -0
  45. package/templates/frontend-astro/src/components/Blocks.astro +32 -0
  46. package/templates/frontend-astro/src/components/ContactForm.astro +68 -0
  47. package/templates/frontend-astro/src/components/Image.astro +34 -0
  48. package/templates/frontend-astro/src/components/PostCard.astro +41 -0
  49. package/templates/frontend-astro/src/components/PostList.astro +39 -0
  50. package/templates/frontend-astro/src/components/RichText.astro +14 -0
  51. package/templates/frontend-astro/src/components/Seo.astro +20 -0
  52. package/templates/frontend-astro/src/components/blocks/CallToAction.astro +23 -0
  53. package/templates/frontend-astro/src/components/blocks/Image.astro +24 -0
  54. package/templates/frontend-astro/src/components/blocks/Quote.astro +10 -0
  55. package/templates/frontend-astro/src/components/blocks/Text.astro +8 -0
  56. package/templates/frontend-astro/src/layouts/Base.astro +122 -0
  57. package/templates/frontend-astro/src/lib/api.ts +24 -0
  58. package/templates/frontend-astro/src/lib/cache.ts +58 -0
  59. package/templates/frontend-astro/src/lib/headless.ts +344 -0
  60. package/templates/frontend-astro/src/middleware.ts +69 -0
  61. package/templates/frontend-astro/src/pages/404.astro +13 -0
  62. package/templates/frontend-astro/src/pages/[...slug].astro +21 -0
  63. package/templates/frontend-astro/src/pages/blog/[slug].astro +50 -0
  64. package/templates/frontend-astro/src/pages/blog/index.astro +15 -0
  65. package/templates/frontend-astro/src/pages/category/[slug].astro +25 -0
  66. package/templates/frontend-astro/src/pages/index.astro +28 -0
  67. package/templates/frontend-astro/src/pages/preview/exit.ts +7 -0
  68. package/templates/frontend-astro/src/pages/preview/index.ts +26 -0
  69. package/templates/frontend-astro/src/pages/revalidate.ts +19 -0
  70. package/templates/frontend-astro/src/pages/robots.txt.ts +7 -0
  71. package/templates/frontend-astro/src/pages/rss.xml.ts +19 -0
  72. package/templates/frontend-astro/src/pages/sitemap.xml.ts +29 -0
  73. package/templates/frontend-astro/src/pages/tag/[slug].astro +24 -0
  74. package/templates/frontend-astro/src/styles/global.css +101 -0
  75. package/templates/frontend-astro/test/blocks.test.ts +42 -0
  76. package/templates/frontend-astro/test/cache.test.ts +22 -0
  77. package/templates/frontend-astro/tsconfig.json +5 -0
  78. package/templates/frontend-astro/vendor/.gitkeep +0 -0
  79. package/templates/frontend-astro/vitest.config.ts +8 -0
@@ -0,0 +1,282 @@
1
+ # {{project_name}}: one Compose file for development and production.
2
+ #
3
+ # COMPOSE_PROFILES in .env picks the services:
4
+ # dev,db `docker compose up` on a laptop: dev servers with hot reload,
5
+ # http://localhost:3000, Mailpit at http://localhost:8025.
6
+ # prod,db `docker compose up -d` on a server: production images behind
7
+ # Caddy on ports 80 and 443, with automatic HTTPS and daily backups.
8
+ # 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
+ #
11
+ # The frontend only receives the variables it needs: never the full-access
12
+ # API key or the database password.
13
+
14
+ name: {{project_name}}
15
+
16
+ # Production containers: read-only file system, no privileges.
17
+ x-hardened: &hardened
18
+ read_only: true
19
+ tmpfs:
20
+ - /tmp
21
+ security_opt:
22
+ - no-new-privileges:true
23
+ cap_drop:
24
+ - ALL
25
+
26
+ x-frontend-env: &frontend-env
27
+ SITE_URL: ${SITE_URL}
28
+ API_URL: http://backend:8000/api
29
+ BACKEND_INTERNAL_URL: http://backend:8000
30
+ API_READ_KEY: ${API_READ_KEY}
31
+ PREVIEW_SECRET: ${PREVIEW_SECRET}
32
+ REVALIDATE_SECRET: ${REVALIDATE_SECRET}
33
+ MEDIA_ORIGINS: ${S3_PUBLIC_URL:-${S3_ENDPOINT_URL:-}}
34
+
35
+ x-backend-depends: &backend-depends
36
+ db:
37
+ condition: service_healthy
38
+ required: false
39
+ cache:
40
+ condition: service_healthy
41
+ s3:
42
+ condition: service_started
43
+ required: false
44
+
45
+ x-backend-health: &backend-health
46
+ test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/readyz', timeout=4)"]
47
+ interval: 5s
48
+ timeout: 5s
49
+ start_period: 60s
50
+ retries: 30
51
+
52
+ services:
53
+ # Development ------------------------------------------------------------
54
+
55
+ backend:
56
+ profiles: [dev]
57
+ build:
58
+ context: ./backend
59
+ target: dev
60
+ env_file: ${ENV_FILE:-.env}
61
+ environment:
62
+ DJANGO_MIGRATE: "1"
63
+ volumes:
64
+ - ./backend:/app
65
+ - media:/app/media
66
+ ports:
67
+ - "127.0.0.1:8000:8000"
68
+ depends_on: *backend-depends
69
+ healthcheck: *backend-health
70
+ restart: unless-stopped
71
+
72
+ worker:
73
+ profiles: [dev]
74
+ build:
75
+ context: ./backend
76
+ target: dev
77
+ env_file: ${ENV_FILE:-.env}
78
+ command: ["python", "manage.py", "run_huey"]
79
+ volumes:
80
+ - ./backend:/app
81
+ - media:/app/media
82
+ depends_on:
83
+ backend:
84
+ condition: service_healthy
85
+ restart: unless-stopped
86
+
87
+ frontend:
88
+ profiles: [dev]
89
+ build:
90
+ context: ./frontend
91
+ target: dev
92
+ environment: *frontend-env
93
+ volumes:
94
+ - ./frontend:/app
95
+ - frontend_node_modules:/app/node_modules
96
+ ports:
97
+ - "127.0.0.1:3000:3000"
98
+ depends_on:
99
+ backend:
100
+ condition: service_healthy
101
+ restart: unless-stopped
102
+
103
+ mailpit:
104
+ profiles: [dev]
105
+ image: axllent/mailpit:v1.27
106
+ ports:
107
+ - "127.0.0.1:8025:8025"
108
+ restart: unless-stopped
109
+
110
+ # A local S3 service, so development runs the same storage code as
111
+ # production with S3. Files are public at http://localhost:8333/<bucket>/.
112
+ s3:
113
+ profiles: [s3]
114
+ image: chrislusf/seaweedfs:4.52
115
+ command: ["server", "-s3", "-dir=/data", "-ip.bind=0.0.0.0"]
116
+ volumes:
117
+ - s3data:/data
118
+ ports:
119
+ - "127.0.0.1:8333:8333"
120
+ restart: unless-stopped
121
+
122
+ # Production -------------------------------------------------------------
123
+
124
+ backend-prod:
125
+ profiles: [prod]
126
+ image: ${BACKEND_IMAGE:-{{project_name}}-backend}
127
+ build:
128
+ context: ./backend
129
+ target: prod
130
+ <<: *hardened
131
+ env_file: ${ENV_FILE:-.env}
132
+ environment:
133
+ DJANGO_DEBUG: "0"
134
+ DJANGO_MIGRATE: "1"
135
+ DJANGO_CHECK_DEPLOY: "1"
136
+ NUM_PROXIES: ${NUM_PROXIES:-1}
137
+ volumes:
138
+ - media:/app/media
139
+ depends_on: *backend-depends
140
+ healthcheck: *backend-health
141
+ networks:
142
+ default:
143
+ aliases: [backend]
144
+ deploy:
145
+ resources:
146
+ limits:
147
+ memory: 768M
148
+ restart: unless-stopped
149
+
150
+ worker-prod:
151
+ profiles: [prod]
152
+ image: ${BACKEND_IMAGE:-{{project_name}}-backend}
153
+ build:
154
+ context: ./backend
155
+ target: prod
156
+ <<: *hardened
157
+ env_file: ${ENV_FILE:-.env}
158
+ command: ["python", "manage.py", "run_huey"]
159
+ # Also runs the daily backup of the database, and of the media volume
160
+ # when uploads are not in S3, at BACKUP_TIME (UTC) into the backups
161
+ # volume and BACKUP_S3_BUCKET. See README.md.
162
+ environment:
163
+ DJANGO_DEBUG: "0"
164
+ NUM_PROXIES: ${NUM_PROXIES:-1}
165
+ BACKUP_DAILY: "1"
166
+ BACKUP_DIR: /app/backups
167
+ volumes:
168
+ - media:/app/media
169
+ - backups:/app/backups
170
+ depends_on:
171
+ backend-prod:
172
+ condition: service_healthy
173
+ healthcheck:
174
+ disable: true
175
+ deploy:
176
+ resources:
177
+ limits:
178
+ memory: 512M
179
+ restart: unless-stopped
180
+
181
+ frontend-prod:
182
+ profiles: [prod]
183
+ image: ${FRONTEND_IMAGE:-{{project_name}}-frontend}
184
+ build:
185
+ context: ./frontend
186
+ target: prod
187
+ <<: *hardened
188
+ environment: *frontend-env
189
+ depends_on:
190
+ backend-prod:
191
+ condition: service_healthy
192
+ networks:
193
+ default:
194
+ aliases: [frontend]
195
+ deploy:
196
+ resources:
197
+ limits:
198
+ memory: 512M
199
+ restart: unless-stopped
200
+
201
+ # The only service with public ports: HTTPS for SITE_URL with a
202
+ # certificate from Let's Encrypt, routing to Django and the frontend.
203
+ # Behind a platform's proxy, see CADDY_ADDRESS in .env.
204
+ proxy:
205
+ profiles: [prod]
206
+ image: caddy:2.11-alpine
207
+ <<: *hardened
208
+ cap_add:
209
+ - NET_BIND_SERVICE
210
+ environment:
211
+ CADDY_ADDRESS: ${CADDY_ADDRESS:-${SITE_URL}}
212
+ TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1/32}
213
+ volumes:
214
+ - ./Caddyfile:/etc/caddy/Caddyfile:ro
215
+ - media:/srv/media:ro
216
+ - caddy_data:/data
217
+ - caddy_config:/config
218
+ ports:
219
+ - "${HTTP_PORT:-80}:80"
220
+ - "${HTTPS_PORT:-443}:443"
221
+ - "${HTTPS_PORT:-443}:443/udp"
222
+ depends_on:
223
+ backend-prod:
224
+ condition: service_healthy
225
+ frontend-prod:
226
+ condition: service_healthy
227
+ deploy:
228
+ resources:
229
+ limits:
230
+ memory: 256M
231
+ restart: unless-stopped
232
+
233
+ # Shared -----------------------------------------------------------------
234
+
235
+ db:
236
+ profiles: [db]
237
+ image: postgres:17-alpine
238
+ environment:
239
+ POSTGRES_DB: ${POSTGRES_DB:-app}
240
+ POSTGRES_USER: ${POSTGRES_USER:-app}
241
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
242
+ volumes:
243
+ - pgdata:/var/lib/postgresql/data
244
+ healthcheck:
245
+ test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
246
+ interval: 3s
247
+ timeout: 3s
248
+ retries: 20
249
+ security_opt:
250
+ - no-new-privileges:true
251
+ deploy:
252
+ resources:
253
+ limits:
254
+ memory: 1G
255
+ restart: unless-stopped
256
+
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.
259
+ cache:
260
+ image: valkey/valkey:8-alpine
261
+ user: valkey
262
+ command: ["valkey-server", "--save", "", "--appendonly", "no", "--maxmemory", "200mb", "--maxmemory-policy", "volatile-lru"]
263
+ healthcheck:
264
+ test: ["CMD", "valkey-cli", "ping"]
265
+ interval: 3s
266
+ timeout: 3s
267
+ retries: 20
268
+ <<: *hardened
269
+ deploy:
270
+ resources:
271
+ limits:
272
+ memory: 256M
273
+ restart: unless-stopped
274
+
275
+ volumes:
276
+ pgdata:
277
+ media:
278
+ backups:
279
+ s3data:
280
+ frontend_node_modules:
281
+ caddy_data:
282
+ caddy_config:
@@ -0,0 +1,83 @@
1
+ # {{project_name}} environment. `npx @django-stack/cli init` generates .env
2
+ # from this file with fresh secrets; .env.example keeps the names without
3
+ # values. Never commit .env.
4
+
5
+ PROJECT_NAME={{project_name}}
6
+
7
+ # Which services run (see compose.yaml):
8
+ # dev,db development on this machine
9
+ # prod,db production on a server
10
+ # Leave out `db` when DATABASE_URL points to an external Postgres; add `s3`
11
+ # in development for the local S3 service.
12
+ COMPOSE_PROFILES=dev,db
13
+
14
+ # The public URL of the site. In development the frontend's dev server also
15
+ # serves /admin, /api and /media, so both URLs are the frontend's. In
16
+ # production set both to https://your-domain: Caddy gets the certificate.
17
+ SITE_URL=http://localhost:3000
18
+ BACKEND_URL=http://localhost:3000
19
+
20
+ # Behind the proxy of a platform such as Coolify or Dokploy, which gets the
21
+ # certificate, Caddy serves plain HTTP on port 80 to it instead:
22
+ # CADDY_ADDRESS=:80 TRUSTED_PROXIES=private_ranges NUM_PROXIES=2
23
+ # HTTP_PORT=127.0.0.1:8080 HTTPS_PORT=127.0.0.1:8443
24
+
25
+ # Django. The prod profile always runs with debug off.
26
+ DJANGO_SECRET_KEY={{secret:django}}
27
+ DJANGO_DEBUG=1
28
+
29
+ # Database: the bundled Postgres, or your own URL for an external one
30
+ # (add ?sslmode=require for a server on another host).
31
+ POSTGRES_DB=app
32
+ POSTGRES_USER=app
33
+ POSTGRES_PASSWORD={{secret:postgres}}
34
+ DATABASE_URL=postgres://app:{{secret:postgres}}@db:5432/app
35
+
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
39
+ EMAIL_URL=smtp://mailpit:1025
40
+ DEFAULT_FROM_EMAIL={{project_name}} <noreply@localhost>
41
+
42
+ # Content API keys. The full-access key is for scripts only; the frontend
43
+ # gets the read-only key. They must differ.
44
+ API_SECRET_KEY={{secret:api}}
45
+ API_READ_KEY={{secret:api_read}}
46
+
47
+ # Shared between Django and the frontend: preview links and revalidation.
48
+ PREVIEW_SECRET={{secret:preview}}
49
+ REVALIDATE_SECRET={{secret:revalidate}}
50
+ REVALIDATE_URL=http://frontend:3000/revalidate
51
+
52
+ # The first admin user, created on the first start.
53
+ ADMIN_USERNAME=admin
54
+ ADMIN_EMAIL=admin@example.com
55
+ ADMIN_PASSWORD={{secret:admin}}
56
+
57
+ # Demo content for an empty site (pages, posts, menus); 0 to start empty.
58
+ SEED_DEMO_CONTENT=1
59
+
60
+ # Media in S3 instead of on this server: any S3-compatible service (AWS,
61
+ # Cloudflare R2, Hetzner, Scaleway). Uploads must be publicly readable, at
62
+ # S3_PUBLIC_URL when that is not the endpoint (a CDN or a custom domain).
63
+ # For the local S3 service: add `s3` to COMPOSE_PROFILES and use
64
+ # S3_BUCKET=media S3_ENDPOINT_URL=http://s3:8333
65
+ # S3_PUBLIC_URL=http://localhost:8333/media S3_CREATE_BUCKET=1
66
+ # S3_ACCESS_KEY_ID=local S3_SECRET_ACCESS_KEY=local
67
+ S3_BUCKET=
68
+ S3_ENDPOINT_URL=
69
+ S3_REGION=
70
+ S3_ACCESS_KEY_ID=
71
+ S3_SECRET_ACCESS_KEY=
72
+ S3_PUBLIC_URL=
73
+
74
+ # Backups (prod profile): a daily pg_dump at BACKUP_TIME (UTC) and, when
75
+ # uploads are on disk (no S3_BUCKET) and BACKUP_MEDIA=1, an archive of the
76
+ # media volume; the newest BACKUP_KEEP of each kept in the backups volume.
77
+ # Set BACKUP_S3_BUCKET to a private bucket (never the public media bucket)
78
+ # to keep a copy off the server; it uses the S3_* credentials unless
79
+ # BACKUP_S3_* ones are set.
80
+ BACKUP_TIME=03:00
81
+ BACKUP_KEEP=14
82
+ BACKUP_MEDIA=1
83
+ BACKUP_S3_BUCKET=
@@ -0,0 +1,15 @@
1
+ .env
2
+ .env.run
3
+ node_modules/
4
+ backend/.venv/
5
+ backend/media/
6
+ backend/staticfiles/
7
+ backend/openapi.json
8
+ backend/db.sqlite3
9
+ __pycache__/
10
+ *.pyc
11
+ frontend/dist/
12
+ frontend/.astro/
13
+ .DS_Store
14
+ backend/backups/
15
+ backend/huey-stats.db*
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "{{project_name}}",
3
+ "private": true,
4
+ "description": "{{project_name}}: Django, Django Headless, Content Studio and Astro, created with DjangoStack.",
5
+ "scripts": {
6
+ "dev": "docker compose up --build",
7
+ "start": "docker compose up --build --detach",
8
+ "stop": "docker compose down",
9
+ "logs": "docker compose logs --follow",
10
+ "backend": "docker compose exec backend python manage.py",
11
+ "test": "docker compose exec backend pytest && docker compose exec frontend npm test",
12
+ "lint": "docker compose exec backend ruff check . && docker compose exec frontend npm run check",
13
+ "generate:types": "docker compose exec backend python manage.py generateschema --generator_class headless.rest.schema.SchemaGenerator --format openapi-json --file openapi.json && docker compose run --rm --no-deps -v ./backend/openapi.json:/schema.json:ro frontend npx headless generate --file /schema.json --out src/lib/headless.ts",
14
+ "doctor": "npx @django-stack/cli@{{cli_version}} doctor"
15
+ },
16
+ "django-stack": {
17
+ "version": "{{cli_version}}",
18
+ "frontend": "{{frontend}}",
19
+ "preset": "{{preset}}"
20
+ }
21
+ }
@@ -0,0 +1,4 @@
1
+ node_modules
2
+ dist
3
+ .astro
4
+ .env
@@ -0,0 +1,32 @@
1
+ # syntax=docker/dockerfile:1
2
+
3
+ # The frontend image. The `dev` target runs the Astro dev server with the
4
+ # source mounted; `prod` builds the site and runs the Node server.
5
+ FROM node:22-alpine AS base
6
+ WORKDIR /app
7
+ ENV ASTRO_TELEMETRY_DISABLED=1
8
+
9
+ FROM base AS deps
10
+ COPY package.json package-lock.json* ./
11
+ COPY vendor/ vendor/
12
+ RUN --mount=type=cache,target=/root/.npm \
13
+ if [ -f package-lock.json ]; then npm ci; else npm install; fi
14
+
15
+ FROM deps AS dev
16
+ EXPOSE 3000
17
+ CMD ["npm", "run", "dev"]
18
+
19
+ FROM deps AS build
20
+ COPY . .
21
+ RUN npm run build && npm prune --omit=dev
22
+
23
+ FROM base AS prod
24
+ ENV NODE_ENV=production HOST=0.0.0.0 PORT=3000
25
+ COPY --from=build --chown=node:node /app/node_modules ./node_modules
26
+ COPY --from=build --chown=node:node /app/dist ./dist
27
+ COPY --from=build --chown=node:node /app/package.json ./package.json
28
+ USER node
29
+ EXPOSE 3000
30
+ HEALTHCHECK --interval=10s --timeout=5s --start-period=20s --retries=5 \
31
+ CMD wget -qO- http://127.0.0.1:3000/robots.txt > /dev/null || exit 1
32
+ CMD ["node", "./dist/server/entry.mjs"]
@@ -0,0 +1,38 @@
1
+ // @ts-check
2
+ import node from "@astrojs/node";
3
+ import { defineConfig } from "astro/config";
4
+
5
+ // The Django backend: the frontend container reaches it on the Compose network.
6
+ const backend = process.env.BACKEND_INTERNAL_URL ?? "http://localhost:8000";
7
+
8
+ export default defineConfig({
9
+ site: process.env.SITE_URL ?? "http://localhost:3000",
10
+ // Server output: pages render on request, so a publish shows within seconds.
11
+ output: "server",
12
+ adapter: node({ mode: "standalone" }),
13
+ server: { host: true, port: 3000 },
14
+ // A Content-Security-Policy on every page: scripts and styles only from
15
+ // this site, with hashes for the ones Astro inlines. src/middleware.ts adds
16
+ // the origin of the media files.
17
+ security: {
18
+ csp: {
19
+ directives: ["default-src 'self'", "base-uri 'self'", "form-action 'self'", "object-src 'none'"],
20
+ // Inline style attributes, such as the focal point of an image.
21
+ styleDirective: { resources: ["'self'", { resource: "'unsafe-inline'", kind: "attribute" }] },
22
+ },
23
+ },
24
+ vite: {
25
+ server: {
26
+ // Django calls /revalidate at http://frontend:3000 on the Compose network.
27
+ allowedHosts: ["frontend"],
28
+ // In development, serve Django's paths from the same origin as the site,
29
+ // 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
+ ),
36
+ },
37
+ },
38
+ });
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "{{project_name}}-frontend",
3
+ "private": true,
4
+ "type": "module",
5
+ "version": "0.1.0",
6
+ "scripts": {
7
+ "dev": "astro dev",
8
+ "build": "astro build",
9
+ "start": "node ./dist/server/entry.mjs",
10
+ "preview": "astro preview",
11
+ "check": "astro check",
12
+ "test": "vitest run",
13
+ "generate:types": "headless generate --url \"${API_URL:-http://localhost:8000/api}/schema\" --out src/lib/headless.ts"
14
+ },
15
+ "dependencies": {
16
+ "@astrojs/node": "^11.1.9",
17
+ "@astrojs/rss": "^4.0.19",
18
+ "@django-headless/client": "^1.1.1",
19
+ "@django-stack/utils": "^0.2.0",
20
+ "astro": "^7.4.5"
21
+ },
22
+ "devDependencies": {
23
+ "@astrojs/check": "^0.9.12",
24
+ "@types/node": "^22.20.5",
25
+ "typescript": "~5.9.3",
26
+ "vitest": "^5.1.0"
27
+ },
28
+ "engines": {
29
+ "node": ">=22"
30
+ }
31
+ }
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#0c4b33"/><path d="M8 9h10a6 6 0 0 1 0 12h-4v3H8z" fill="#44b78b"/><path d="M14 13h4a2 2 0 0 1 0 4h-4z" fill="#0c4b33"/></svg>
@@ -0,0 +1,32 @@
1
+ ---
2
+ /**
3
+ * Renders a Content Studio block field. To add a block type: define it in
4
+ * backend/content/blocks.py, add a component in ./blocks and map its type
5
+ * here. Unknown types render nothing.
6
+ */
7
+ import type { Block } from "@django-stack/utils";
8
+ import CallToAction from "./blocks/CallToAction.astro";
9
+ import Image from "./blocks/Image.astro";
10
+ import Quote from "./blocks/Quote.astro";
11
+ import Text from "./blocks/Text.astro";
12
+
13
+ const registry: Record<string, (props: { block: Block }) => unknown> = {
14
+ text: Text,
15
+ image: Image,
16
+ quote: Quote,
17
+ call_to_action: CallToAction,
18
+ };
19
+
20
+ interface Props {
21
+ blocks: Block[] | null | undefined;
22
+ }
23
+
24
+ const { blocks = [] } = Astro.props;
25
+ ---
26
+
27
+ {
28
+ (blocks ?? []).map((block) => {
29
+ const Component = registry[block.type];
30
+ return Component ? <Component block={block} /> : null;
31
+ })
32
+ }
@@ -0,0 +1,68 @@
1
+ ---
2
+ /**
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.
6
+ */
7
+ ---
8
+
9
+ <form class="contact-form" data-endpoint="/api/content.formsubmission">
10
+ <label>Name <input name="name" required maxlength="200" autocomplete="name" /></label>
11
+ <label>Email <input name="email" type="email" required autocomplete="email" /></label>
12
+ <label>Message <textarea name="message" required maxlength="5000" rows="6"></textarea></label>
13
+ <button class="button" type="submit">Send</button>
14
+ <p class="status" role="status" aria-live="polite"></p>
15
+ </form>
16
+
17
+ <script>
18
+ for (const form of document.querySelectorAll<HTMLFormElement>("form.contact-form")) {
19
+ form.addEventListener("submit", async (event) => {
20
+ event.preventDefault();
21
+ const status = form.querySelector<HTMLElement>(".status")!;
22
+ const button = form.querySelector<HTMLButtonElement>("button")!;
23
+ button.disabled = true;
24
+ status.textContent = "Sending…";
25
+ try {
26
+ const response = await fetch(form.dataset.endpoint!, {
27
+ method: "POST",
28
+ headers: { "Content-Type": "application/json" },
29
+ body: JSON.stringify(Object.fromEntries(new FormData(form))),
30
+ });
31
+ 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.");
33
+ form.reset();
34
+ status.textContent = "Thank you, your message was sent.";
35
+ } catch (error) {
36
+ status.textContent = error instanceof Error ? error.message : String(error);
37
+ } finally {
38
+ button.disabled = false;
39
+ }
40
+ });
41
+ }
42
+ </script>
43
+
44
+ <style>
45
+ .contact-form {
46
+ display: grid;
47
+ gap: 1rem;
48
+ max-width: 32rem;
49
+ margin-top: 2rem;
50
+ }
51
+ label {
52
+ display: grid;
53
+ gap: 0.25rem;
54
+ font-weight: 600;
55
+ }
56
+ input,
57
+ textarea {
58
+ font: inherit;
59
+ padding: 0.5rem 0.75rem;
60
+ border: 1px solid var(--color-line);
61
+ border-radius: 0.375rem;
62
+ background: transparent;
63
+ color: inherit;
64
+ }
65
+ button {
66
+ justify-self: start;
67
+ }
68
+ </style>
@@ -0,0 +1,34 @@
1
+ ---
2
+ import { imageAttributes, type Media } from "@django-stack/utils";
3
+
4
+ interface Props {
5
+ media: Media | string | null | undefined;
6
+ /** An image size from IMAGE_SIZES, such as card or hero. */
7
+ size?: string;
8
+ /** The sizes attribute, for the browser to pick from the srcset. */
9
+ sizes?: string;
10
+ alt?: string;
11
+ class?: string;
12
+ eager?: boolean;
13
+ }
14
+
15
+ const { media, size, sizes = "100vw", alt, eager = false, class: className } = Astro.props;
16
+ const attributes = imageAttributes(media, { size, alt });
17
+ ---
18
+
19
+ {
20
+ attributes && (
21
+ <img
22
+ src={attributes.src}
23
+ srcset={attributes.srcset}
24
+ sizes={attributes.srcset ? sizes : undefined}
25
+ width={attributes.width}
26
+ height={attributes.height}
27
+ alt={attributes.alt}
28
+ class={className}
29
+ style={attributes.objectPosition ? `object-position: ${attributes.objectPosition}` : undefined}
30
+ loading={eager ? "eager" : "lazy"}
31
+ decoding="async"
32
+ />
33
+ )
34
+ }