@django-stack/cli 0.3.0 → 0.5.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 (139) hide show
  1. package/dist/bin.js +3 -3
  2. package/dist/index.d.ts +52 -40
  3. package/dist/index.js +1 -1
  4. package/dist/{stack-CTyqYWCV.js → scaffold-DbgGYEsc.js} +216 -183
  5. package/package.json +1 -1
  6. package/templates/backend/pyproject.toml +1 -1
  7. package/templates/base/AGENTS.md +7 -11
  8. package/templates/base/README.md +3 -3
  9. package/templates/base/SECURITY.md +1 -1
  10. package/templates/base/gitignore +6 -0
  11. package/templates/base/package.json +1 -1
  12. package/templates/frontend-astro/AGENTS.md +43 -0
  13. package/templates/frontend-astro/package.json +1 -1
  14. package/templates/frontend-astro/template.json +5 -0
  15. package/templates/frontend-next/.dockerignore +3 -0
  16. package/templates/frontend-next/AGENTS.md +59 -0
  17. package/templates/frontend-next/Dockerfile +32 -0
  18. package/templates/frontend-next/next.config.ts +19 -0
  19. package/templates/frontend-next/package.json +34 -0
  20. package/templates/frontend-next/public/favicon.svg +1 -0
  21. package/templates/frontend-next/src/app/[...path]/page.tsx +28 -0
  22. package/templates/frontend-next/src/app/blog/[slug]/page.tsx +60 -0
  23. package/templates/frontend-next/src/app/blog/page.tsx +20 -0
  24. package/templates/frontend-next/src/app/category/[slug]/page.tsx +34 -0
  25. package/templates/frontend-next/src/app/error.tsx +13 -0
  26. package/templates/frontend-next/src/app/globals.css +250 -0
  27. package/templates/frontend-next/src/app/layout.tsx +70 -0
  28. package/templates/frontend-next/src/app/not-found.tsx +15 -0
  29. package/templates/frontend-next/src/app/page.tsx +35 -0
  30. package/templates/frontend-next/src/app/preview/exit/route.ts +3 -0
  31. package/templates/frontend-next/src/app/preview/route.ts +4 -0
  32. package/templates/frontend-next/src/app/revalidate/route.ts +8 -0
  33. package/templates/frontend-next/src/app/robots.txt/route.ts +13 -0
  34. package/templates/frontend-next/src/app/rss.xml/route.ts +19 -0
  35. package/templates/frontend-next/src/app/sitemap.xml/route.ts +28 -0
  36. package/templates/frontend-next/src/app/tag/[slug]/page.tsx +29 -0
  37. package/templates/frontend-next/src/components/ContactForm.tsx +55 -0
  38. package/templates/frontend-next/src/components/NavLink.tsx +15 -0
  39. package/templates/frontend-next/src/components/PostCard.tsx +25 -0
  40. package/templates/frontend-next/src/components/PostList.tsx +28 -0
  41. package/templates/frontend-next/src/lib/api.ts +71 -0
  42. package/templates/frontend-next/src/lib/headless.ts +344 -0
  43. package/templates/frontend-next/src/lib/rss.ts +44 -0
  44. package/templates/frontend-next/src/proxy.ts +34 -0
  45. package/templates/frontend-next/template.json +5 -0
  46. package/templates/frontend-next/test/rss.test.ts +17 -0
  47. package/templates/frontend-next/tsconfig.json +42 -0
  48. package/templates/frontend-next/vendor/.gitkeep +0 -0
  49. package/templates/frontend-next/vitest.config.ts +7 -0
  50. package/templates/frontend-nuxt/.dockerignore +4 -0
  51. package/templates/frontend-nuxt/AGENTS.md +64 -0
  52. package/templates/frontend-nuxt/Dockerfile +32 -0
  53. package/templates/frontend-nuxt/nuxt.config.ts +69 -0
  54. package/templates/frontend-nuxt/package.json +34 -0
  55. package/templates/frontend-nuxt/public/favicon.svg +1 -0
  56. package/templates/frontend-nuxt/server/plugins/media-origins.ts +27 -0
  57. package/templates/frontend-nuxt/server/routes/_content/category/[slug].get.ts +13 -0
  58. package/templates/frontend-nuxt/server/routes/_content/home.get.ts +10 -0
  59. package/templates/frontend-nuxt/server/routes/_content/layout.get.ts +13 -0
  60. package/templates/frontend-nuxt/server/routes/_content/page.get.ts +7 -0
  61. package/templates/frontend-nuxt/server/routes/_content/post/[slug].get.ts +7 -0
  62. package/templates/frontend-nuxt/server/routes/_content/posts.get.ts +4 -0
  63. package/templates/frontend-nuxt/server/routes/_content/redirect.get.ts +7 -0
  64. package/templates/frontend-nuxt/server/routes/_content/tag/[slug].get.ts +13 -0
  65. package/templates/frontend-nuxt/server/routes/preview/exit.get.ts +4 -0
  66. package/templates/frontend-nuxt/server/routes/preview/index.get.ts +26 -0
  67. package/templates/frontend-nuxt/server/routes/revalidate.post.ts +15 -0
  68. package/templates/frontend-nuxt/server/routes/robots.txt.get.ts +4 -0
  69. package/templates/frontend-nuxt/server/routes/rss.xml.get.ts +19 -0
  70. package/templates/frontend-nuxt/server/routes/sitemap.xml.get.ts +29 -0
  71. package/templates/frontend-nuxt/server/utils/cache.ts +48 -0
  72. package/templates/frontend-nuxt/server/utils/content.ts +41 -0
  73. package/templates/frontend-nuxt/server/utils/rss.ts +44 -0
  74. package/templates/frontend-nuxt/src/app.vue +5 -0
  75. package/templates/frontend-nuxt/src/assets/main.css +126 -0
  76. package/templates/frontend-nuxt/src/components/ContactForm.vue +66 -0
  77. package/templates/frontend-nuxt/src/components/PostCard.vue +43 -0
  78. package/templates/frontend-nuxt/src/components/PostList.vue +33 -0
  79. package/templates/frontend-nuxt/src/composables/useContent.ts +27 -0
  80. package/templates/frontend-nuxt/src/composables/useLayoutContent.ts +4 -0
  81. package/templates/frontend-nuxt/src/composables/usePageSeo.ts +33 -0
  82. package/templates/frontend-nuxt/src/error.vue +23 -0
  83. package/templates/frontend-nuxt/src/layouts/default.vue +99 -0
  84. package/templates/frontend-nuxt/src/lib/headless.ts +344 -0
  85. package/templates/frontend-nuxt/src/pages/[...path].vue +16 -0
  86. package/templates/frontend-nuxt/src/pages/blog/[slug].vue +43 -0
  87. package/templates/frontend-nuxt/src/pages/blog/index.vue +12 -0
  88. package/templates/frontend-nuxt/src/pages/category/[slug].vue +14 -0
  89. package/templates/frontend-nuxt/src/pages/index.vue +18 -0
  90. package/templates/frontend-nuxt/src/pages/tag/[slug].vue +13 -0
  91. package/templates/frontend-nuxt/template.json +5 -0
  92. package/templates/frontend-nuxt/test/cache.test.ts +20 -0
  93. package/templates/frontend-nuxt/test/rss.test.ts +17 -0
  94. package/templates/frontend-nuxt/tsconfig.json +9 -0
  95. package/templates/frontend-nuxt/vendor/.gitkeep +0 -0
  96. package/templates/frontend-nuxt/vitest.config.ts +7 -0
  97. package/templates/frontend-sveltekit/.dockerignore +4 -0
  98. package/templates/frontend-sveltekit/AGENTS.md +53 -0
  99. package/templates/frontend-sveltekit/Dockerfile +32 -0
  100. package/templates/frontend-sveltekit/package.json +39 -0
  101. package/templates/frontend-sveltekit/src/app.css +126 -0
  102. package/templates/frontend-sveltekit/src/app.d.ts +12 -0
  103. package/templates/frontend-sveltekit/src/app.html +12 -0
  104. package/templates/frontend-sveltekit/src/hooks.server.ts +70 -0
  105. package/templates/frontend-sveltekit/src/lib/components/ContactForm.svelte +65 -0
  106. package/templates/frontend-sveltekit/src/lib/components/PostCard.svelte +41 -0
  107. package/templates/frontend-sveltekit/src/lib/components/PostList.svelte +39 -0
  108. package/templates/frontend-sveltekit/src/lib/headless.ts +344 -0
  109. package/templates/frontend-sveltekit/src/lib/server/api.ts +28 -0
  110. package/templates/frontend-sveltekit/src/lib/server/cache.ts +58 -0
  111. package/templates/frontend-sveltekit/src/lib/server/rss.ts +44 -0
  112. package/templates/frontend-sveltekit/src/routes/+error.svelte +14 -0
  113. package/templates/frontend-sveltekit/src/routes/+layout.server.ts +18 -0
  114. package/templates/frontend-sveltekit/src/routes/+layout.svelte +112 -0
  115. package/templates/frontend-sveltekit/src/routes/+page.server.ts +10 -0
  116. package/templates/frontend-sveltekit/src/routes/+page.svelte +19 -0
  117. package/templates/frontend-sveltekit/src/routes/[...path]/+page.server.ts +9 -0
  118. package/templates/frontend-sveltekit/src/routes/[...path]/+page.svelte +12 -0
  119. package/templates/frontend-sveltekit/src/routes/blog/+page.server.ts +7 -0
  120. package/templates/frontend-sveltekit/src/routes/blog/+page.svelte +10 -0
  121. package/templates/frontend-sveltekit/src/routes/blog/[slug]/+page.server.ts +9 -0
  122. package/templates/frontend-sveltekit/src/routes/blog/[slug]/+page.svelte +41 -0
  123. package/templates/frontend-sveltekit/src/routes/category/[slug]/+page.server.ts +11 -0
  124. package/templates/frontend-sveltekit/src/routes/category/[slug]/+page.svelte +12 -0
  125. package/templates/frontend-sveltekit/src/routes/preview/+server.ts +27 -0
  126. package/templates/frontend-sveltekit/src/routes/preview/exit/+server.ts +8 -0
  127. package/templates/frontend-sveltekit/src/routes/revalidate/+server.ts +16 -0
  128. package/templates/frontend-sveltekit/src/routes/robots.txt/+server.ts +7 -0
  129. package/templates/frontend-sveltekit/src/routes/rss.xml/+server.ts +20 -0
  130. package/templates/frontend-sveltekit/src/routes/sitemap.xml/+server.ts +29 -0
  131. package/templates/frontend-sveltekit/src/routes/tag/[slug]/+page.server.ts +11 -0
  132. package/templates/frontend-sveltekit/src/routes/tag/[slug]/+page.svelte +11 -0
  133. package/templates/frontend-sveltekit/static/favicon.svg +1 -0
  134. package/templates/frontend-sveltekit/template.json +5 -0
  135. package/templates/frontend-sveltekit/test/cache.test.ts +22 -0
  136. package/templates/frontend-sveltekit/test/rss.test.ts +17 -0
  137. package/templates/frontend-sveltekit/tsconfig.json +5 -0
  138. package/templates/frontend-sveltekit/vendor/.gitkeep +0 -0
  139. package/templates/frontend-sveltekit/vite.config.ts +47 -0
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { rssFeed } from "../server/utils/rss";
3
+
4
+ describe("RSS feed", () => {
5
+ it("lists the posts with absolute links, escaped", () => {
6
+ const xml = rssFeed({
7
+ title: "News & notes",
8
+ description: "",
9
+ site: "https://example.com",
10
+ items: [{ title: "<Hello>", link: "/blog/hello", pubDate: new Date("2026-01-02T03:04:05Z") }],
11
+ });
12
+ expect(xml).toContain("<title>News &amp; notes</title>");
13
+ expect(xml).toContain("<title>&lt;Hello&gt;</title>");
14
+ expect(xml).toContain("<link>https://example.com/blog/hello</link>");
15
+ expect(xml).toContain("<pubDate>Fri, 02 Jan 2026 03:04:05 GMT</pubDate>");
16
+ });
17
+ });
@@ -0,0 +1,9 @@
1
+ {
2
+ "files": [],
3
+ "references": [
4
+ { "path": "./.nuxt/tsconfig.app.json" },
5
+ { "path": "./.nuxt/tsconfig.server.json" },
6
+ { "path": "./.nuxt/tsconfig.shared.json" },
7
+ { "path": "./.nuxt/tsconfig.node.json" }
8
+ ]
9
+ }
File without changes
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ include: ["test/**/*.test.ts"],
6
+ },
7
+ });
@@ -0,0 +1,4 @@
1
+ node_modules
2
+ build
3
+ .svelte-kit
4
+ .env
@@ -0,0 +1,53 @@
1
+ # The SvelteKit frontend
2
+
3
+ SvelteKit renders the site on the server (`@sveltejs/adapter-node`). Content
4
+ is loaded in `+page.server.ts` and `+layout.server.ts` only, so the API key
5
+ stays on the server. The project's `AGENTS.md` describes the whole project;
6
+ this file describes the frontend.
7
+
8
+ ## Layout
9
+
10
+ ```
11
+ src/routes/ routes: /, /[...path], /blog, /blog/[slug], /category/[slug], /tag/[slug], sitemap.xml, rss.xml, robots.txt, /preview, /revalidate
12
+ src/routes/+layout.svelte the page shell: SEO tags, menus, footer, preview banner
13
+ src/lib/components/ the post list and the contact form
14
+ src/lib/server/api.ts getContent(): content helpers with the read-only key, drafts in preview
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`
17
+ src/hooks.server.ts page cache, editor-managed redirects, security headers
18
+ vite.config.ts SvelteKit's options (Node adapter, Content-Security-Policy) and the dev proxy to Django
19
+ ```
20
+
21
+ Blocks, rich text, images and SEO tags come from `@django-stack/svelte`.
22
+
23
+ ## Read a content model
24
+
25
+ ```ts
26
+ // src/routes/events/+page.server.ts
27
+ import { getClient } from "#lib/server/api.ts";
28
+ import type { PageServerLoad } from "./$types";
29
+
30
+ export const load: PageServerLoad = async ({ cookies }) => ({
31
+ events: await getClient(cookies).model("content.event").list(),
32
+ });
33
+ ```
34
+
35
+ Never import `#lib/server` from a component or a universal `+page.ts`.
36
+
37
+ ## Render a block type
38
+
39
+ Add a component in `src/lib/components/blocks/` that takes a `block` prop,
40
+ and pass it to `Blocks` by the block's type:
41
+
42
+ ```svelte
43
+ <Blocks blocks={data.page.body} components={{ gallery: Gallery }} />
44
+ ```
45
+
46
+ ## Commands
47
+
48
+ Run them in the `frontend` container (`docker compose exec frontend <command>`):
49
+
50
+ | Task | Command |
51
+ |------|---------|
52
+ | Type-check | `npm run check` |
53
+ | Test | `npm test` |
@@ -0,0 +1,32 @@
1
+ # syntax=docker/dockerfile:1
2
+
3
+ # The frontend image. The `dev` target runs the Vite 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
+
8
+ FROM base AS deps
9
+ COPY package.json package-lock.json* ./
10
+ COPY vendor/ vendor/
11
+ RUN --mount=type=cache,target=/root/.npm \
12
+ if [ -f package-lock.json ]; then npm ci; else npm install; fi
13
+
14
+ FROM deps AS dev
15
+ EXPOSE 3000
16
+ CMD ["npm", "run", "dev"]
17
+
18
+ FROM deps AS build
19
+ COPY . .
20
+ RUN npm run build && npm prune --omit=dev
21
+
22
+ FROM base AS prod
23
+ # The proxy in front sets X-Forwarded-Proto, so links and cookies use https.
24
+ ENV NODE_ENV=production HOST=0.0.0.0 PORT=3000 PROTOCOL_HEADER=x-forwarded-proto
25
+ COPY --from=build --chown=node:node /app/node_modules ./node_modules
26
+ COPY --from=build --chown=node:node /app/build ./build
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", "build"]
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "{{project_name}}-frontend",
3
+ "private": true,
4
+ "type": "module",
5
+ "version": "0.1.0",
6
+ "imports": {
7
+ "#lib": "./src/lib",
8
+ "#lib/*": "./src/lib/*"
9
+ },
10
+ "scripts": {
11
+ "dev": "vite dev",
12
+ "build": "vite build",
13
+ "start": "node build",
14
+ "preview": "vite preview",
15
+ "prepare": "svelte-kit sync || echo ''",
16
+ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
17
+ "test": "vitest run",
18
+ "generate:types": "headless generate --url \"${API_URL:-http://localhost:8000/api}/schema\" --out src/lib/headless.ts"
19
+ },
20
+ "dependencies": {
21
+ "@django-headless/client": "^1.1.1",
22
+ "@django-stack/svelte": "^0.5.0",
23
+ "@django-stack/utils": "^0.5.0"
24
+ },
25
+ "devDependencies": {
26
+ "@sveltejs/adapter-node": "^6.0.1",
27
+ "@sveltejs/kit": "^3.1.1",
28
+ "@sveltejs/vite-plugin-svelte": "^7.3.1",
29
+ "@types/node": "^22.20.5",
30
+ "svelte": "^5.57.3",
31
+ "svelte-check": "^4.7.8",
32
+ "typescript": "~6.0.3",
33
+ "vite": "^8.3.5",
34
+ "vitest": "^5.1.0"
35
+ },
36
+ "engines": {
37
+ "node": ">=22"
38
+ }
39
+ }
@@ -0,0 +1,126 @@
1
+ /* A small, plain stylesheet: replace it with your own design. */
2
+ :root {
3
+ --color-bg: #ffffff;
4
+ --color-text: #1d2420;
5
+ --color-muted: #5b6862;
6
+ --color-line: #e3e8e4;
7
+ --color-accent: #0c4b33;
8
+ --color-accent-soft: #e8f3ee;
9
+ --font-body: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
10
+ --width: 72rem;
11
+ --width-text: 44rem;
12
+ color-scheme: light dark;
13
+ }
14
+
15
+ @media (prefers-color-scheme: dark) {
16
+ :root {
17
+ --color-bg: #111614;
18
+ --color-text: #e6ebe8;
19
+ --color-muted: #a1aba6;
20
+ --color-line: #2a322e;
21
+ --color-accent: #6fcf9f;
22
+ --color-accent-soft: #1b2a23;
23
+ }
24
+ }
25
+
26
+ *,
27
+ *::before,
28
+ *::after {
29
+ box-sizing: border-box;
30
+ }
31
+
32
+ body {
33
+ margin: 0;
34
+ background: var(--color-bg);
35
+ color: var(--color-text);
36
+ font-family: var(--font-body);
37
+ line-height: 1.6;
38
+ }
39
+
40
+ a {
41
+ color: var(--color-accent);
42
+ }
43
+
44
+ img {
45
+ max-width: 100%;
46
+ height: auto;
47
+ }
48
+
49
+ .container {
50
+ width: min(100% - 2rem, var(--width));
51
+ margin-inline: auto;
52
+ }
53
+
54
+ .narrow {
55
+ width: min(100% - 2rem, var(--width-text));
56
+ margin-inline: auto;
57
+ }
58
+
59
+ .muted {
60
+ color: var(--color-muted);
61
+ }
62
+
63
+ .prose h2,
64
+ .prose h3 {
65
+ line-height: 1.25;
66
+ margin-top: 2em;
67
+ }
68
+
69
+ .prose blockquote {
70
+ margin: 2rem 0;
71
+ padding-left: 1.25rem;
72
+ border-left: 4px solid var(--color-accent);
73
+ }
74
+
75
+ .button {
76
+ display: inline-block;
77
+ padding: 0.6rem 1.2rem;
78
+ border-radius: 0.5rem;
79
+ background: var(--color-accent);
80
+ color: var(--color-bg);
81
+ font-weight: 600;
82
+ text-decoration: none;
83
+ border: 0;
84
+ cursor: pointer;
85
+ font-family: inherit;
86
+ font-size: inherit;
87
+ }
88
+
89
+ .grid {
90
+ display: grid;
91
+ gap: 2rem;
92
+ grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
93
+ }
94
+
95
+ .preview-banner {
96
+ background: #fde68a;
97
+ color: #422006;
98
+ padding: 0.5rem 1rem;
99
+ text-align: center;
100
+ font-size: 0.9rem;
101
+ }
102
+
103
+ /* Blocks, from @django-stack/svelte. */
104
+ .block-image {
105
+ margin: 2rem 0;
106
+ }
107
+
108
+ .block-image img {
109
+ border-radius: 0.5rem;
110
+ }
111
+
112
+ .block-image figcaption {
113
+ font-size: 0.9rem;
114
+ margin-top: 0.5rem;
115
+ }
116
+
117
+ .cta {
118
+ margin: 2.5rem 0;
119
+ padding: 2rem;
120
+ border-radius: 0.75rem;
121
+ background: var(--color-accent-soft);
122
+ }
123
+
124
+ .cta h2 {
125
+ margin-top: 0;
126
+ }
@@ -0,0 +1,12 @@
1
+ // See https://svelte.dev/docs/kit/types#app.d.ts
2
+ import type { SeoInput } from "@django-stack/utils";
3
+
4
+ declare global {
5
+ namespace App {
6
+ interface PageData {
7
+ /** The page's title, description and share image, for the SEO tags in the layout. */
8
+ seo?: SeoInput | null;
9
+ seoType?: "website" | "article";
10
+ }
11
+ }
12
+ }
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <link rel="icon" href="%sveltekit.assets%/favicon.svg" type="image/svg+xml" />
7
+ %sveltekit.head%
8
+ </head>
9
+ <body data-sveltekit-preload-data="hover">
10
+ <div style="display: contents">%sveltekit.body%</div>
11
+ </body>
12
+ </html>
@@ -0,0 +1,70 @@
1
+ import { redirect } from "@sveltejs/kit";
2
+ import type { Handle } from "@sveltejs/kit/hooks";
3
+ import { getContent, PREVIEW_COOKIE } from "#lib/server/api.ts";
4
+ import { getCached, store } from "#lib/server/cache.ts";
5
+ import { dev } from "$app/env";
6
+
7
+ const SECURITY_HEADERS: Record<string, string> = {
8
+ "X-Content-Type-Options": "nosniff",
9
+ "Referrer-Policy": "strict-origin-when-cross-origin",
10
+ "X-Frame-Options": "DENY",
11
+ "Permissions-Policy": "camera=(), microphone=(), geolocation=()",
12
+ };
13
+
14
+ const CACHEABLE = /^(text\/html|application\/json|application\/xml|application\/rss\+xml|text\/plain)/;
15
+
16
+ // Where media files come from when they are not on this site: S3 or a CDN.
17
+ const MEDIA_ORIGINS = (process.env.MEDIA_ORIGINS ?? "")
18
+ .split(",")
19
+ .map((value) => {
20
+ try {
21
+ return new URL(value.trim()).origin;
22
+ } catch {
23
+ return "";
24
+ }
25
+ })
26
+ .filter(Boolean)
27
+ .map((origin) => ` ${origin}`)
28
+ .join("");
29
+ const MEDIA_DIRECTIVES = `img-src 'self' data:${MEDIA_ORIGINS}; media-src 'self'${MEDIA_ORIGINS}`;
30
+
31
+ export const handle: Handle = async ({ event, resolve }) => {
32
+ const { request, url, cookies } = event;
33
+ const preview = cookies.get(PREVIEW_COOKIE) !== undefined;
34
+ const cacheable = request.method === "GET" && !preview && !dev && !url.pathname.startsWith("/_app/");
35
+ const key = url.pathname + url.search;
36
+
37
+ if (cacheable) {
38
+ const hit = getCached(key);
39
+ if (hit) return hit;
40
+ }
41
+
42
+ let response = await resolve(event);
43
+
44
+ // Redirects that editors manage in the admin, for paths that do not exist.
45
+ if (response.status === 404 && request.method === "GET") {
46
+ const target = await getContent(cookies)
47
+ .resolveRedirect(url.pathname)
48
+ .catch(() => null);
49
+ if (target) redirect(target.permanent ? 301 : 302, target.new_path);
50
+ }
51
+
52
+ // A copy with headers that can be changed.
53
+ response = new Response(response.body, response);
54
+ for (const [name, value] of Object.entries(SECURITY_HEADERS)) {
55
+ if (!response.headers.has(name)) response.headers.set(name, value);
56
+ }
57
+ // Images and video may come from the media origins. SvelteKit builds the
58
+ // policy while it renders, so the directives are added to its header.
59
+ const csp = response.headers.get("Content-Security-Policy");
60
+ if (csp && !csp.includes("img-src")) {
61
+ response.headers.set("Content-Security-Policy", `${csp.trim().replace(/;$/, "")}; ${MEDIA_DIRECTIVES};`);
62
+ }
63
+ if (preview) response.headers.set("Cache-Control", "private, no-store");
64
+
65
+ const type = response.headers.get("Content-Type") ?? "";
66
+ if (cacheable && response.status === 200 && CACHEABLE.test(type)) {
67
+ response = await store(key, response);
68
+ }
69
+ return response;
70
+ };
@@ -0,0 +1,65 @@
1
+ <!--
2
+ The contact form. It posts to Django's public create route for form
3
+ submissions (PUBLIC_CREATE in backend/config/settings.py), which is rate
4
+ limited per IP address. Submissions appear in the admin.
5
+ -->
6
+ <script lang="ts">
7
+ let status = $state("");
8
+ let sending = $state(false);
9
+
10
+ async function submit(event: SubmitEvent) {
11
+ event.preventDefault();
12
+ const form = event.currentTarget as HTMLFormElement;
13
+ sending = true;
14
+ status = "Sending…";
15
+ try {
16
+ const response = await fetch("/api/content.formsubmission", {
17
+ method: "POST",
18
+ headers: { "Content-Type": "application/json" },
19
+ body: JSON.stringify(Object.fromEntries(new FormData(form))),
20
+ });
21
+ 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.");
23
+ form.reset();
24
+ status = "Thank you, your message was sent.";
25
+ } catch (error) {
26
+ status = error instanceof Error ? error.message : String(error);
27
+ } finally {
28
+ sending = false;
29
+ }
30
+ }
31
+ </script>
32
+
33
+ <form class="contact-form" onsubmit={submit}>
34
+ <label>Name <input name="name" required maxlength="200" autocomplete="name" /></label>
35
+ <label>Email <input name="email" type="email" required autocomplete="email" /></label>
36
+ <label>Message <textarea name="message" required maxlength="5000" rows="6"></textarea></label>
37
+ <button class="button" type="submit" disabled={sending}>Send</button>
38
+ <p class="status" role="status" aria-live="polite">{status}</p>
39
+ </form>
40
+
41
+ <style>
42
+ .contact-form {
43
+ display: grid;
44
+ gap: 1rem;
45
+ max-width: 32rem;
46
+ margin-top: 2rem;
47
+ }
48
+ label {
49
+ display: grid;
50
+ gap: 0.25rem;
51
+ font-weight: 600;
52
+ }
53
+ input,
54
+ textarea {
55
+ font: inherit;
56
+ padding: 0.5rem 0.75rem;
57
+ border: 1px solid var(--color-line);
58
+ border-radius: 0.375rem;
59
+ background: transparent;
60
+ color: inherit;
61
+ }
62
+ button {
63
+ justify-self: start;
64
+ }
65
+ </style>
@@ -0,0 +1,41 @@
1
+ <script lang="ts">
2
+ import { Image } from "@django-stack/svelte";
3
+ import type { Author, Post } from "@django-stack/utils";
4
+
5
+ let { post }: { post: Post } = $props();
6
+ const author = $derived(typeof post.author === "object" ? (post.author as Author | null) : null);
7
+ const date = $derived(post.published_at ? new Date(post.published_at) : null);
8
+ </script>
9
+
10
+ <article class="card">
11
+ <a href="/blog/{post.slug}">
12
+ <Image media={post.cover} size="card" sizes="(min-width: 40rem) 22rem, 100vw" class="cover" />
13
+ </a>
14
+ <h3><a href="/blog/{post.slug}">{post.title}</a></h3>
15
+ <p class="muted meta">
16
+ {#if date}<time datetime={date.toISOString()}>{date.toLocaleDateString("en", { dateStyle: "medium" })}</time>{/if}
17
+ {#if author}<span> · {author.name}</span>{/if}
18
+ </p>
19
+ {#if post.excerpt}<p>{post.excerpt}</p>{/if}
20
+ </article>
21
+
22
+ <style>
23
+ .card h3 {
24
+ margin: 0.75rem 0 0.25rem;
25
+ line-height: 1.3;
26
+ }
27
+ .card h3 a {
28
+ color: inherit;
29
+ text-decoration: none;
30
+ }
31
+ .card :global(.cover) {
32
+ width: 100%;
33
+ aspect-ratio: 16 / 10;
34
+ object-fit: cover;
35
+ border-radius: 0.5rem;
36
+ }
37
+ .meta {
38
+ font-size: 0.9rem;
39
+ margin: 0;
40
+ }
41
+ </style>
@@ -0,0 +1,39 @@
1
+ <script lang="ts">
2
+ import type { Paginated, Post } from "@django-stack/utils";
3
+ import PostCard from "./PostCard.svelte";
4
+
5
+ interface Props {
6
+ result: Paginated<Post>;
7
+ /** The URL of the list, without the page parameter. */
8
+ base: string;
9
+ }
10
+
11
+ let { result, base }: Props = $props();
12
+ const { current, pages } = $derived(result.pagination);
13
+ const pageUrl = (page: number) => (page === 1 ? base : `${base}?page=${page}`);
14
+ </script>
15
+
16
+ {#if result.data.length === 0}
17
+ <p class="muted">No posts yet.</p>
18
+ {/if}
19
+ <div class="grid">
20
+ {#each result.data as post (post.id)}
21
+ <PostCard {post} />
22
+ {/each}
23
+ </div>
24
+ {#if pages > 1}
25
+ <nav class="pagination" aria-label="Pages">
26
+ {#if current > 1}<a href={pageUrl(current - 1)}>← Newer</a>{/if}
27
+ <span class="muted">Page {current} of {pages}</span>
28
+ {#if current < pages}<a href={pageUrl(current + 1)}>Older →</a>{/if}
29
+ </nav>
30
+ {/if}
31
+
32
+ <style>
33
+ .pagination {
34
+ display: flex;
35
+ gap: 1.5rem;
36
+ justify-content: center;
37
+ margin: 3rem 0;
38
+ }
39
+ </style>