@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
@@ -9,4 +9,4 @@ Problems in the packages it is built on go to their projects:
9
9
  - Django Headless: https://github.com/dwarsbit/django-headless/security
10
10
  - Django Content Studio: https://github.com/dwarsbit/django-content-studio/security
11
11
  - Django: https://www.djangoproject.com/security/
12
- - Astro: https://github.com/withastro/astro/security
12
+ - {{frontend_label}}: {{frontend_security}}
@@ -10,7 +10,13 @@ backend/db.sqlite3
10
10
  __pycache__/
11
11
  *.pyc
12
12
  frontend/dist/
13
+ frontend/build/
13
14
  frontend/.astro/
15
+ frontend/.svelte-kit/
16
+ frontend/.nuxt/
17
+ frontend/.output/
18
+ frontend/.next/
19
+ frontend/next-env.d.ts
14
20
  .DS_Store
15
21
  backend/backups/
16
22
  backend/huey-stats.db*
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "{{project_name}}",
3
3
  "private": true,
4
- "description": "{{project_name}}: Django, Django Headless, Content Studio and Astro, created with DjangoStack.",
4
+ "description": "{{project_name}}: Django, Django Headless, Content Studio and {{frontend_label}}, created with DjangoStack.",
5
5
  "scripts": {
6
6
  "dev": "docker compose up --build",
7
7
  "start": "docker compose up --build --detach",
@@ -0,0 +1,43 @@
1
+ # The Astro frontend
2
+
3
+ Astro renders the site on the server (`output: "server"`, Node adapter).
4
+ The project's `AGENTS.md` describes the whole project; this file describes
5
+ the frontend.
6
+
7
+ ## Layout
8
+
9
+ ```
10
+ src/pages/ routes: /, /[...slug], /blog, /blog/[slug], /category/[slug], /tag/[slug], sitemap.xml, rss.xml, robots.txt, /preview, /revalidate
11
+ src/layouts/Base.astro the page shell: head, menus, footer, preview banner
12
+ src/components/ Blocks.astro maps block types to components in components/blocks/
13
+ src/lib/api.ts getContent(): content helpers with the read-only key, drafts in preview
14
+ src/lib/cache.ts the in-memory page cache that /revalidate empties
15
+ src/lib/headless.ts generated API types; do not edit, run `npm run generate:types`
16
+ src/middleware.ts page cache, editor-managed redirects, security headers
17
+ astro.config.mjs server output, Content-Security-Policy, the dev proxy to Django
18
+ ```
19
+
20
+ ## Read a content model
21
+
22
+ ```astro
23
+ ---
24
+ // src/pages/events.astro
25
+ import { getClient } from "../lib/api";
26
+
27
+ const events = await getClient(Astro.cookies).model("content.event").list();
28
+ ---
29
+ ```
30
+
31
+ ## Render a block type
32
+
33
+ Add a component in `src/components/blocks/` that takes a `block` prop, and
34
+ map the block's type to it in `src/components/Blocks.astro`.
35
+
36
+ ## Commands
37
+
38
+ Run them in the `frontend` container (`docker compose exec frontend <command>`):
39
+
40
+ | Task | Command |
41
+ |------|---------|
42
+ | Type-check | `npm run check` |
43
+ | Test | `npm test` |
@@ -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.3.0",
19
+ "@django-stack/utils": "^0.5.0",
20
20
  "astro": "^7.4.5"
21
21
  },
22
22
  "devDependencies": {
@@ -0,0 +1,5 @@
1
+ {
2
+ "label": "Astro",
3
+ "url": "https://astro.build",
4
+ "security": "https://github.com/withastro/astro/security"
5
+ }
@@ -0,0 +1,3 @@
1
+ node_modules
2
+ .next
3
+ .env
@@ -0,0 +1,59 @@
1
+ # The Next.js frontend
2
+
3
+ Next.js renders the site on the server with the App Router. Content is
4
+ loaded in server components and route handlers only, through `src/lib/api.ts`,
5
+ so the API key stays on the server. The project's `AGENTS.md` describes the
6
+ whole project; this file describes the frontend.
7
+
8
+ ## Layout
9
+
10
+ ```
11
+ src/app/ routes: /, /[...path], /blog, /blog/[slug], /category/[slug], /tag/[slug], sitemap.xml, rss.xml, robots.txt, /preview, /revalidate
12
+ src/app/layout.tsx the page shell: menus, footer, preview banner
13
+ src/components/ the post list, the menu links and the contact form
14
+ src/lib/api.ts getContent(): content helpers with the read-only key, drafts in preview; pageMetadata()
15
+ src/lib/headless.ts generated API types; do not edit, run `npm run generate:types`
16
+ src/proxy.ts Content-Security-Policy with a nonce, security headers, the dev proxy to Django
17
+ next.config.ts standalone output, the in-memory data cache
18
+ ```
19
+
20
+ Blocks, rich text and images come from `@django-stack/react`; the
21
+ `/preview` and `/revalidate` handlers, the metadata and the
22
+ Content-Security-Policy from `@django-stack/next`.
23
+
24
+ API responses are kept in Next's data cache with the tag `content` for
25
+ `CACHE_TTL` seconds (default 300). `/revalidate` expires the tag when
26
+ editors publish. Previews and the dev server skip the cache.
27
+
28
+ ## Read a content model
29
+
30
+ ```tsx
31
+ // src/app/events/page.tsx
32
+ import { getClient } from "@/lib/api";
33
+
34
+ export default async function Events() {
35
+ const events = await (await getClient()).model("content.event").list();
36
+ return <ul>{events.data.map((event) => <li key={event.id}>{event.title}</li>)}</ul>;
37
+ }
38
+ ```
39
+
40
+ `src/lib/api.ts` imports `server-only`: a client component (`"use client"`)
41
+ that imports it fails the build.
42
+
43
+ ## Render a block type
44
+
45
+ Add a component that takes a `block` prop, and pass it to `Blocks` by the
46
+ block's type:
47
+
48
+ ```tsx
49
+ <Blocks blocks={page.body} components={{ gallery: Gallery }} />
50
+ ```
51
+
52
+ ## Commands
53
+
54
+ Run them in the `frontend` container (`docker compose exec frontend <command>`):
55
+
56
+ | Task | Command |
57
+ |------|---------|
58
+ | Type-check | `npm run check` |
59
+ | Test | `npm test` |
@@ -0,0 +1,32 @@
1
+ # syntax=docker/dockerfile:1
2
+
3
+ # The frontend image. The `dev` target runs the Next dev server with the
4
+ # source mounted; `prod` builds the site and runs Next's standalone server.
5
+ FROM node:22-alpine AS base
6
+ WORKDIR /app
7
+ ENV NEXT_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
22
+
23
+ FROM base AS prod
24
+ ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000
25
+ COPY --from=build --chown=node:node /app/.next/standalone ./
26
+ COPY --from=build --chown=node:node /app/.next/static ./.next/static
27
+ COPY --from=build --chown=node:node /app/public ./public
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", "server.js"]
@@ -0,0 +1,19 @@
1
+ import type { NextConfig } from "next";
2
+
3
+ const config: NextConfig = {
4
+ // A self-contained server in .next/standalone, for a small production image.
5
+ output: "standalone",
6
+ // Django's paths end in a slash; the dev proxy in src/proxy.ts passes them on as they are.
7
+ skipTrailingSlashRedirect: true,
8
+ // Django calls /revalidate at http://frontend:3000 on the Compose network.
9
+ allowedDevOrigins: ["frontend"],
10
+ poweredByHeader: false,
11
+ experimental: {
12
+ // The data cache stays in memory: the production container's file system is read-only.
13
+ isrFlushToDisk: false,
14
+ // Media uploads in the admin pass through the dev proxy.
15
+ proxyClientMaxBodySize: "100mb",
16
+ },
17
+ };
18
+
19
+ export default config;
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "{{project_name}}-frontend",
3
+ "private": true,
4
+ "type": "module",
5
+ "version": "0.1.0",
6
+ "scripts": {
7
+ "dev": "next dev --hostname 0.0.0.0 --port 3000",
8
+ "build": "next build",
9
+ "start": "next start --hostname 0.0.0.0 --port 3000",
10
+ "check": "next typegen && tsc --noEmit",
11
+ "test": "vitest run",
12
+ "generate:types": "headless generate --url \"${API_URL:-http://localhost:8000/api}/schema\" --out src/lib/headless.ts"
13
+ },
14
+ "dependencies": {
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",
19
+ "next": "^16.4.4",
20
+ "react": "^19.3.1",
21
+ "react-dom": "^19.3.1",
22
+ "server-only": "^0.0.1"
23
+ },
24
+ "devDependencies": {
25
+ "@types/node": "^22.20.5",
26
+ "@types/react": "^19.2.18",
27
+ "@types/react-dom": "^19.2.7",
28
+ "typescript": "~6.0.3",
29
+ "vitest": "^5.1.0"
30
+ },
31
+ "engines": {
32
+ "node": ">=22"
33
+ }
34
+ }
@@ -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,28 @@
1
+ import { Blocks } from "@django-stack/react";
2
+ import type { Metadata } from "next";
3
+ import { ContactForm } from "@/components/ContactForm";
4
+ import { getContent, notFoundOrRedirect, pageMetadata } from "@/lib/api";
5
+
6
+ type Props = { params: Promise<{ path: string[] }> };
7
+
8
+ async function load(params: Props["params"]) {
9
+ const path = (await params).path.join("/");
10
+ return { path, page: await (await getContent()).getPage(path) };
11
+ }
12
+
13
+ export async function generateMetadata({ params }: Props): Promise<Metadata> {
14
+ const { path, page } = await load(params);
15
+ return page ? pageMetadata(page, `/${path}`) : {};
16
+ }
17
+
18
+ export default async function ContentPage({ params }: Props) {
19
+ const { path, page } = await load(params);
20
+ if (!page) return notFoundOrRedirect(`/${path}`);
21
+ return (
22
+ <article className="narrow">
23
+ <h1>{page.title}</h1>
24
+ <Blocks blocks={page.body} />
25
+ {page.path === "contact" ? <ContactForm /> : null}
26
+ </article>
27
+ );
28
+ }
@@ -0,0 +1,60 @@
1
+ import { Blocks, Image } from "@django-stack/react";
2
+ import type { Author, Taxonomy } from "@django-stack/utils";
3
+ import type { Metadata } from "next";
4
+ import Link from "next/link";
5
+ import { getContent, notFoundOrRedirect, pageMetadata } from "@/lib/api";
6
+
7
+ type Props = { params: Promise<{ slug: string }> };
8
+
9
+ async function load(params: Props["params"]) {
10
+ const { slug } = await params;
11
+ return { slug, post: await (await getContent()).getPost(slug) };
12
+ }
13
+
14
+ export async function generateMetadata({ params }: Props): Promise<Metadata> {
15
+ const { slug, post } = await load(params);
16
+ return post ? pageMetadata(post, `/blog/${slug}`, "article") : {};
17
+ }
18
+
19
+ export default async function BlogPost({ params }: Props) {
20
+ const { slug, post } = await load(params);
21
+ if (!post) return notFoundOrRedirect(`/blog/${slug}`);
22
+ const author = typeof post.author === "object" ? (post.author as Author | null) : null;
23
+ const categories = post.categories.filter((item): item is Taxonomy => typeof item === "object");
24
+ const tags = post.tags.filter((item): item is Taxonomy => typeof item === "object");
25
+ const date = post.published_at ? new Date(post.published_at) : null;
26
+ return (
27
+ <article className="narrow">
28
+ <p className="muted">
29
+ {date ? (
30
+ <time dateTime={date.toISOString()}>{date.toLocaleDateString("en", { dateStyle: "long" })}</time>
31
+ ) : null}
32
+ {author ? <span> · {author.name}</span> : null}
33
+ {categories.map((category) => (
34
+ <span key={category.slug}>
35
+ {" · "}
36
+ <Link href={`/category/${category.slug}`}>{category.name}</Link>
37
+ </span>
38
+ ))}
39
+ </p>
40
+ <h1>{post.title}</h1>
41
+ <Image
42
+ media={post.cover}
43
+ size="content"
44
+ sizes="(min-width: 48rem) 44rem, 100vw"
45
+ className="post-cover"
46
+ eager
47
+ />
48
+ <Blocks blocks={post.body} />
49
+ {tags.length > 0 ? (
50
+ <p className="tags">
51
+ {tags.map((tag) => (
52
+ <Link key={tag.slug} href={`/tag/${tag.slug}`}>
53
+ #{tag.name}
54
+ </Link>
55
+ ))}
56
+ </p>
57
+ ) : null}
58
+ </article>
59
+ );
60
+ }
@@ -0,0 +1,20 @@
1
+ import type { Metadata } from "next";
2
+ import { PostList } from "@/components/PostList";
3
+ import { getContent, pageMetadata, pageNumber } from "@/lib/api";
4
+
5
+ type Props = { searchParams: Promise<{ page?: string }> };
6
+
7
+ export function generateMetadata(): Promise<Metadata> {
8
+ return pageMetadata({ title: "Blog" }, "/blog");
9
+ }
10
+
11
+ export default async function Blog({ searchParams }: Props) {
12
+ const page = pageNumber((await searchParams).page);
13
+ const result = await (await getContent()).getPosts({ page, limit: 9 });
14
+ return (
15
+ <section className="container">
16
+ <h1>Blog</h1>
17
+ <PostList result={result} base="/blog" />
18
+ </section>
19
+ );
20
+ }
@@ -0,0 +1,34 @@
1
+ import type { Metadata } from "next";
2
+ import { PostList } from "@/components/PostList";
3
+ import { getContent, notFoundOrRedirect, pageMetadata, pageNumber } from "@/lib/api";
4
+
5
+ type Props = { params: Promise<{ slug: string }>; searchParams: Promise<{ page?: string }> };
6
+
7
+ async function load(params: Props["params"]) {
8
+ const { slug } = await params;
9
+ return { slug, category: await (await getContent()).getCategory(slug) };
10
+ }
11
+
12
+ export async function generateMetadata({ params }: Props): Promise<Metadata> {
13
+ const { slug, category } = await load(params);
14
+ if (!category) return {};
15
+ return pageMetadata(
16
+ { title: category.name, seo_description: category.description ?? "" },
17
+ `/category/${slug}`,
18
+ );
19
+ }
20
+
21
+ export default async function Category({ params, searchParams }: Props) {
22
+ const { slug, category } = await load(params);
23
+ if (!category) return notFoundOrRedirect(`/category/${slug}`);
24
+ const page = pageNumber((await searchParams).page);
25
+ const result = await (await getContent()).getPosts({ category: slug, page, limit: 9 });
26
+ return (
27
+ <section className="container">
28
+ <p className="muted">Category</p>
29
+ <h1>{category.name}</h1>
30
+ {category.description ? <p>{category.description}</p> : null}
31
+ <PostList result={result} base={`/category/${slug}`} />
32
+ </section>
33
+ );
34
+ }
@@ -0,0 +1,13 @@
1
+ "use client";
2
+
3
+ export default function ErrorPage() {
4
+ return (
5
+ <section className="narrow">
6
+ <h1>Something went wrong</h1>
7
+ <p>The page could not be shown. Try again in a moment.</p>
8
+ <p>
9
+ <a href="/">Go to the home page</a>
10
+ </p>
11
+ </section>
12
+ );
13
+ }
@@ -0,0 +1,250 @@
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/react. */
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
+ }
127
+
128
+ /* The page shell. */
129
+ .site-header {
130
+ border-bottom: 1px solid var(--color-line);
131
+ }
132
+
133
+ .bar {
134
+ display: flex;
135
+ flex-wrap: wrap;
136
+ gap: 1rem;
137
+ align-items: center;
138
+ justify-content: space-between;
139
+ padding-block: 1rem;
140
+ }
141
+
142
+ .brand {
143
+ font-weight: 700;
144
+ font-size: 1.2rem;
145
+ color: inherit;
146
+ text-decoration: none;
147
+ }
148
+
149
+ .brand img {
150
+ height: 2.5rem;
151
+ width: auto;
152
+ }
153
+
154
+ .bar nav {
155
+ display: flex;
156
+ flex-wrap: wrap;
157
+ gap: 1.25rem;
158
+ }
159
+
160
+ .bar nav a {
161
+ text-decoration: none;
162
+ }
163
+
164
+ .bar nav a[aria-current="page"] {
165
+ font-weight: 600;
166
+ text-decoration: underline;
167
+ }
168
+
169
+ main {
170
+ min-height: 70vh;
171
+ padding-block: 2rem 4rem;
172
+ }
173
+
174
+ .site-footer {
175
+ border-top: 1px solid var(--color-line);
176
+ font-size: 0.9rem;
177
+ }
178
+
179
+ .site-footer .footer-text p {
180
+ margin: 0;
181
+ }
182
+
183
+ /* Posts. */
184
+ .card h3 {
185
+ margin: 0.75rem 0 0.25rem;
186
+ line-height: 1.3;
187
+ }
188
+
189
+ .card h3 a {
190
+ color: inherit;
191
+ text-decoration: none;
192
+ }
193
+
194
+ .card .cover {
195
+ width: 100%;
196
+ aspect-ratio: 16 / 10;
197
+ object-fit: cover;
198
+ border-radius: 0.5rem;
199
+ }
200
+
201
+ .card .meta {
202
+ font-size: 0.9rem;
203
+ margin: 0;
204
+ }
205
+
206
+ .pagination {
207
+ display: flex;
208
+ gap: 1.5rem;
209
+ justify-content: center;
210
+ margin: 3rem 0;
211
+ }
212
+
213
+ .post-cover {
214
+ width: 100%;
215
+ border-radius: 0.75rem;
216
+ }
217
+
218
+ .tags {
219
+ display: flex;
220
+ gap: 1rem;
221
+ margin-top: 3rem;
222
+ }
223
+
224
+ /* The contact form. */
225
+ .contact-form {
226
+ display: grid;
227
+ gap: 1rem;
228
+ max-width: 32rem;
229
+ margin-top: 2rem;
230
+ }
231
+
232
+ .contact-form label {
233
+ display: grid;
234
+ gap: 0.25rem;
235
+ font-weight: 600;
236
+ }
237
+
238
+ .contact-form input,
239
+ .contact-form textarea {
240
+ font: inherit;
241
+ padding: 0.5rem 0.75rem;
242
+ border: 1px solid var(--color-line);
243
+ border-radius: 0.375rem;
244
+ background: transparent;
245
+ color: inherit;
246
+ }
247
+
248
+ .contact-form button {
249
+ justify-self: start;
250
+ }