@django-stack/cli 0.0.1 → 0.1.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/README.md +27 -3
- package/dist/bin.js +1302 -2
- package/dist/index.d.ts +250 -1
- package/dist/index.js +1 -4
- package/dist/stack-OTrWHXrO.js +712 -0
- package/package.json +14 -7
- package/templates/backend/.dockerignore +11 -0
- package/templates/backend/Dockerfile +45 -0
- package/templates/backend/config/__init__.py +0 -0
- package/templates/backend/config/asgi.py +7 -0
- package/templates/backend/config/settings.py +87 -0
- package/templates/backend/config/studio.py +20 -0
- package/templates/backend/config/urls.py +9 -0
- package/templates/backend/config/wsgi.py +7 -0
- package/templates/backend/conftest.py +7 -0
- package/templates/backend/content/__init__.py +0 -0
- package/templates/backend/content/admin.py +107 -0
- package/templates/backend/content/apps.py +6 -0
- package/templates/backend/content/blocks.py +51 -0
- package/templates/backend/content/migrations/0001_initial.py +418 -0
- package/templates/backend/content/migrations/__init__.py +0 -0
- package/templates/backend/content/models.py +234 -0
- package/templates/backend/content/seed.py +153 -0
- package/templates/backend/content/tests/__init__.py +0 -0
- package/templates/backend/content/tests/test_api.py +73 -0
- package/templates/backend/entrypoint.sh +27 -0
- package/templates/backend/manage.py +16 -0
- package/templates/backend/pyproject.toml +33 -0
- package/templates/backend/vendor/.gitkeep +0 -0
- package/templates/base/.github/workflows/deploy.yml +70 -0
- package/templates/base/AGENTS.md +78 -0
- package/templates/base/CLAUDE.md +1 -0
- package/templates/base/Caddyfile +51 -0
- package/templates/base/README.md +87 -0
- package/templates/base/SECURITY.md +12 -0
- package/templates/base/compose.yaml +303 -0
- package/templates/base/env.example +80 -0
- package/templates/base/gitignore +15 -0
- package/templates/base/package.json +21 -0
- package/templates/frontend-astro/.dockerignore +4 -0
- package/templates/frontend-astro/Dockerfile +32 -0
- package/templates/frontend-astro/astro.config.mjs +38 -0
- package/templates/frontend-astro/package.json +31 -0
- package/templates/frontend-astro/public/favicon.svg +1 -0
- package/templates/frontend-astro/src/components/Blocks.astro +32 -0
- package/templates/frontend-astro/src/components/ContactForm.astro +68 -0
- package/templates/frontend-astro/src/components/Image.astro +34 -0
- package/templates/frontend-astro/src/components/PostCard.astro +41 -0
- package/templates/frontend-astro/src/components/PostList.astro +39 -0
- package/templates/frontend-astro/src/components/RichText.astro +14 -0
- package/templates/frontend-astro/src/components/Seo.astro +20 -0
- package/templates/frontend-astro/src/components/blocks/CallToAction.astro +23 -0
- package/templates/frontend-astro/src/components/blocks/Image.astro +24 -0
- package/templates/frontend-astro/src/components/blocks/Quote.astro +10 -0
- package/templates/frontend-astro/src/components/blocks/Text.astro +8 -0
- package/templates/frontend-astro/src/layouts/Base.astro +122 -0
- package/templates/frontend-astro/src/lib/api.ts +24 -0
- package/templates/frontend-astro/src/lib/cache.ts +58 -0
- package/templates/frontend-astro/src/lib/headless.ts +344 -0
- package/templates/frontend-astro/src/middleware.ts +69 -0
- package/templates/frontend-astro/src/pages/404.astro +13 -0
- package/templates/frontend-astro/src/pages/[...slug].astro +21 -0
- package/templates/frontend-astro/src/pages/blog/[slug].astro +50 -0
- package/templates/frontend-astro/src/pages/blog/index.astro +15 -0
- package/templates/frontend-astro/src/pages/category/[slug].astro +25 -0
- package/templates/frontend-astro/src/pages/index.astro +28 -0
- package/templates/frontend-astro/src/pages/preview/exit.ts +7 -0
- package/templates/frontend-astro/src/pages/preview/index.ts +26 -0
- package/templates/frontend-astro/src/pages/revalidate.ts +19 -0
- package/templates/frontend-astro/src/pages/robots.txt.ts +7 -0
- package/templates/frontend-astro/src/pages/rss.xml.ts +19 -0
- package/templates/frontend-astro/src/pages/sitemap.xml.ts +29 -0
- package/templates/frontend-astro/src/pages/tag/[slug].astro +24 -0
- package/templates/frontend-astro/src/styles/global.css +101 -0
- package/templates/frontend-astro/test/blocks.test.ts +42 -0
- package/templates/frontend-astro/test/cache.test.ts +22 -0
- package/templates/frontend-astro/tsconfig.json +5 -0
- package/templates/frontend-astro/vendor/.gitkeep +0 -0
- package/templates/frontend-astro/vitest.config.ts +8 -0
|
@@ -0,0 +1,303 @@
|
|
|
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 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
|
+
environment:
|
|
160
|
+
DJANGO_DEBUG: "0"
|
|
161
|
+
NUM_PROXIES: ${NUM_PROXIES:-1}
|
|
162
|
+
volumes:
|
|
163
|
+
- media:/app/media
|
|
164
|
+
depends_on:
|
|
165
|
+
backend-prod:
|
|
166
|
+
condition: service_healthy
|
|
167
|
+
healthcheck:
|
|
168
|
+
disable: true
|
|
169
|
+
deploy:
|
|
170
|
+
resources:
|
|
171
|
+
limits:
|
|
172
|
+
memory: 512M
|
|
173
|
+
restart: unless-stopped
|
|
174
|
+
|
|
175
|
+
frontend-prod:
|
|
176
|
+
profiles: [prod]
|
|
177
|
+
image: ${FRONTEND_IMAGE:-{{project_name}}-frontend}
|
|
178
|
+
build:
|
|
179
|
+
context: ./frontend
|
|
180
|
+
target: prod
|
|
181
|
+
<<: *hardened
|
|
182
|
+
environment: *frontend-env
|
|
183
|
+
depends_on:
|
|
184
|
+
backend-prod:
|
|
185
|
+
condition: service_healthy
|
|
186
|
+
networks:
|
|
187
|
+
default:
|
|
188
|
+
aliases: [frontend]
|
|
189
|
+
deploy:
|
|
190
|
+
resources:
|
|
191
|
+
limits:
|
|
192
|
+
memory: 512M
|
|
193
|
+
restart: unless-stopped
|
|
194
|
+
|
|
195
|
+
# The only service with public ports: HTTPS for SITE_URL with a
|
|
196
|
+
# certificate from Let's Encrypt, routing to Django and the frontend.
|
|
197
|
+
# Behind a platform's proxy, see CADDY_ADDRESS in .env.
|
|
198
|
+
proxy:
|
|
199
|
+
profiles: [prod]
|
|
200
|
+
image: caddy:2.11-alpine
|
|
201
|
+
<<: *hardened
|
|
202
|
+
cap_add:
|
|
203
|
+
- NET_BIND_SERVICE
|
|
204
|
+
environment:
|
|
205
|
+
CADDY_ADDRESS: ${CADDY_ADDRESS:-${SITE_URL}}
|
|
206
|
+
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1/32}
|
|
207
|
+
volumes:
|
|
208
|
+
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
|
209
|
+
- media:/srv/media:ro
|
|
210
|
+
- caddy_data:/data
|
|
211
|
+
- caddy_config:/config
|
|
212
|
+
ports:
|
|
213
|
+
- "${HTTP_PORT:-80}:80"
|
|
214
|
+
- "${HTTPS_PORT:-443}:443"
|
|
215
|
+
- "${HTTPS_PORT:-443}:443/udp"
|
|
216
|
+
depends_on:
|
|
217
|
+
backend-prod:
|
|
218
|
+
condition: service_healthy
|
|
219
|
+
frontend-prod:
|
|
220
|
+
condition: service_healthy
|
|
221
|
+
deploy:
|
|
222
|
+
resources:
|
|
223
|
+
limits:
|
|
224
|
+
memory: 256M
|
|
225
|
+
restart: unless-stopped
|
|
226
|
+
|
|
227
|
+
# Backs up the database every day at BACKUP_TIME (UTC) into the backups
|
|
228
|
+
# volume, and into BACKUP_S3_BUCKET when it is set. See README.md.
|
|
229
|
+
backup:
|
|
230
|
+
profiles: [prod]
|
|
231
|
+
image: ${BACKEND_IMAGE:-{{project_name}}-backend}
|
|
232
|
+
build:
|
|
233
|
+
context: ./backend
|
|
234
|
+
target: prod
|
|
235
|
+
<<: *hardened
|
|
236
|
+
env_file: ${ENV_FILE:-.env}
|
|
237
|
+
command: ["python", "manage.py", "run_backups"]
|
|
238
|
+
environment:
|
|
239
|
+
DJANGO_DEBUG: "0"
|
|
240
|
+
BACKUP_DIR: /app/backups
|
|
241
|
+
volumes:
|
|
242
|
+
- backups:/app/backups
|
|
243
|
+
depends_on:
|
|
244
|
+
backend-prod:
|
|
245
|
+
condition: service_healthy
|
|
246
|
+
healthcheck:
|
|
247
|
+
disable: true
|
|
248
|
+
deploy:
|
|
249
|
+
resources:
|
|
250
|
+
limits:
|
|
251
|
+
memory: 256M
|
|
252
|
+
restart: unless-stopped
|
|
253
|
+
|
|
254
|
+
# Shared -----------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
db:
|
|
257
|
+
profiles: [db]
|
|
258
|
+
image: postgres:17-alpine
|
|
259
|
+
environment:
|
|
260
|
+
POSTGRES_DB: ${POSTGRES_DB:-app}
|
|
261
|
+
POSTGRES_USER: ${POSTGRES_USER:-app}
|
|
262
|
+
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
|
263
|
+
volumes:
|
|
264
|
+
- pgdata:/var/lib/postgresql/data
|
|
265
|
+
healthcheck:
|
|
266
|
+
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
|
267
|
+
interval: 3s
|
|
268
|
+
timeout: 3s
|
|
269
|
+
retries: 20
|
|
270
|
+
security_opt:
|
|
271
|
+
- no-new-privileges:true
|
|
272
|
+
deploy:
|
|
273
|
+
resources:
|
|
274
|
+
limits:
|
|
275
|
+
memory: 1G
|
|
276
|
+
restart: unless-stopped
|
|
277
|
+
|
|
278
|
+
# Cache, sessions and the task queue. Only keys with an expiry are
|
|
279
|
+
# evicted when memory runs out, so queued tasks are never dropped.
|
|
280
|
+
cache:
|
|
281
|
+
image: valkey/valkey:8-alpine
|
|
282
|
+
user: valkey
|
|
283
|
+
command: ["valkey-server", "--save", "", "--appendonly", "no", "--maxmemory", "200mb", "--maxmemory-policy", "volatile-lru"]
|
|
284
|
+
healthcheck:
|
|
285
|
+
test: ["CMD", "valkey-cli", "ping"]
|
|
286
|
+
interval: 3s
|
|
287
|
+
timeout: 3s
|
|
288
|
+
retries: 20
|
|
289
|
+
<<: *hardened
|
|
290
|
+
deploy:
|
|
291
|
+
resources:
|
|
292
|
+
limits:
|
|
293
|
+
memory: 256M
|
|
294
|
+
restart: unless-stopped
|
|
295
|
+
|
|
296
|
+
volumes:
|
|
297
|
+
pgdata:
|
|
298
|
+
media:
|
|
299
|
+
backups:
|
|
300
|
+
s3data:
|
|
301
|
+
frontend_node_modules:
|
|
302
|
+
caddy_data:
|
|
303
|
+
caddy_config:
|
|
@@ -0,0 +1,80 @@
|
|
|
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), the newest
|
|
75
|
+
# BACKUP_KEEP kept in the backups volume. Set BACKUP_S3_BUCKET to a private
|
|
76
|
+
# bucket (never the public media bucket) to keep a copy off the server; it
|
|
77
|
+
# uses the S3_* credentials unless BACKUP_S3_* ones are set.
|
|
78
|
+
BACKUP_TIME=03:00
|
|
79
|
+
BACKUP_KEEP=14
|
|
80
|
+
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,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.1.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
|
+
}
|