@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,344 @@
1
+ // Generated by django-headless from the OpenAPI schema of the API. Do not edit this file:
2
+ // run `headless generate` again when the exposed models change.
3
+
4
+ export interface ContentAuthor {
5
+ id: number;
6
+ name: string;
7
+ slug: string;
8
+ bio: string;
9
+ avatar: string | null;
10
+ }
11
+
12
+ export interface ContentCategory {
13
+ id: number;
14
+ name: string;
15
+ slug: string;
16
+ description: string;
17
+ }
18
+
19
+ export interface ContentFormSubmission {
20
+ id: number;
21
+ name: string;
22
+ /** Format: email. */
23
+ email: string;
24
+ message: string;
25
+ /** Format: date-time. */
26
+ created_at: string;
27
+ }
28
+
29
+ export interface ContentFormSubmissionInput {
30
+ name: string;
31
+ /** Format: email. */
32
+ email: string;
33
+ message: string;
34
+ }
35
+
36
+ export interface ContentMenu {
37
+ id: number;
38
+ name: string;
39
+ /** The frontend asks for a menu by this name, such as main. */
40
+ slug: string;
41
+ items: { [key: string]: unknown };
42
+ }
43
+
44
+ export interface ContentPage {
45
+ id: number;
46
+ status: "draft" | "published";
47
+ /** Leave empty to publish right away. A future date schedules the publication. Format: date-time. */
48
+ published_at: string | null;
49
+ seo_title: string;
50
+ seo_description: string;
51
+ title: string;
52
+ slug: string;
53
+ path: string;
54
+ body: { [key: string]: unknown };
55
+ order: number;
56
+ /** Shown when the page is shared; 1200 × 630 works best. */
57
+ share_image: string | null;
58
+ parent: number | null;
59
+ is_published: boolean;
60
+ }
61
+
62
+ export interface ContentPost {
63
+ id: number;
64
+ status: "draft" | "published";
65
+ /** Leave empty to publish right away. A future date schedules the publication. Format: date-time. */
66
+ published_at: string | null;
67
+ seo_title: string;
68
+ seo_description: string;
69
+ title: string;
70
+ slug: string;
71
+ /** A short summary for lists and search results. */
72
+ excerpt: string;
73
+ body: { [key: string]: unknown };
74
+ /** Shown when the page is shared; 1200 × 630 works best. */
75
+ share_image: string | null;
76
+ cover: string | null;
77
+ author: number | null;
78
+ categories: number[];
79
+ tags: number[];
80
+ is_published: boolean;
81
+ }
82
+
83
+ export interface ContentRedirect {
84
+ id: number;
85
+ /** Such as /old-page, without the domain. */
86
+ old_path: string;
87
+ /** A path on the site or a full URL. */
88
+ new_path: string;
89
+ /** 301 when on, 302 when off. */
90
+ permanent: boolean;
91
+ }
92
+
93
+ export interface ContentSiteSettings {
94
+ id: number;
95
+ site_name: string;
96
+ tagline: string;
97
+ seo_description: string;
98
+ /** Such as {"Mastodon": "https://..."}. */
99
+ social_links: { [key: string]: unknown };
100
+ footer_text: string;
101
+ logo: string | null;
102
+ /** The page shown at /. Without one, the home page lists the latest posts. */
103
+ home_page: number | null;
104
+ /** The default image for social sharing. */
105
+ share_image: string | null;
106
+ }
107
+
108
+ export interface ContentStudioMediaLibraryMedia {
109
+ /** Format: uuid. */
110
+ id: string;
111
+ name: string;
112
+ /** Describes a picture for screen readers */
113
+ alt_text: string;
114
+ type: "image" | "video" | "audio" | "file";
115
+ /** Format: binary. */
116
+ file: string;
117
+ /** Read from the file, such as an image's width and height. */
118
+ metadata: { [key: string]: unknown };
119
+ /** Set by editors for some types of media, such as a focal point. */
120
+ properties: { [key: string]: unknown };
121
+ }
122
+
123
+ export interface ContentTag {
124
+ id: number;
125
+ name: string;
126
+ slug: string;
127
+ }
128
+
129
+ export type Models = {
130
+ "content.author": {
131
+ item: ContentAuthor;
132
+ input: never;
133
+ id: number;
134
+ filters: {
135
+ posts: number;
136
+ id: number;
137
+ name: string;
138
+ slug: string;
139
+ bio: string;
140
+ avatar: string;
141
+ };
142
+ lookups: {
143
+ slug: string;
144
+ };
145
+ expandable: {
146
+ posts: { model: "content.post"; many: true; nullable: false };
147
+ avatar: { model: "content_studio_media_library.media"; many: false; nullable: true };
148
+ };
149
+ readOnly: true;
150
+ singleton: false;
151
+ paginated: true;
152
+ };
153
+ "content.category": {
154
+ item: ContentCategory;
155
+ input: never;
156
+ id: number;
157
+ filters: {
158
+ posts: number;
159
+ id: number;
160
+ name: string;
161
+ slug: string;
162
+ description: string;
163
+ };
164
+ lookups: {
165
+ slug: string;
166
+ };
167
+ expandable: {
168
+ posts: { model: "content.post"; many: true; nullable: false };
169
+ };
170
+ readOnly: true;
171
+ singleton: false;
172
+ paginated: true;
173
+ };
174
+ "content.formsubmission": {
175
+ item: ContentFormSubmission;
176
+ input: ContentFormSubmissionInput;
177
+ id: number;
178
+ filters: {
179
+ id: number;
180
+ name: string;
181
+ email: string;
182
+ message: string;
183
+ created_at: string | Date;
184
+ };
185
+ lookups: {};
186
+ expandable: {};
187
+ readOnly: false;
188
+ singleton: false;
189
+ paginated: true;
190
+ };
191
+ "content.menu": {
192
+ item: ContentMenu;
193
+ input: never;
194
+ id: number;
195
+ filters: {
196
+ id: number;
197
+ name: string;
198
+ slug: string;
199
+ items: string;
200
+ };
201
+ lookups: {
202
+ slug: string;
203
+ };
204
+ expandable: {};
205
+ readOnly: true;
206
+ singleton: false;
207
+ paginated: true;
208
+ };
209
+ "content.page": {
210
+ item: ContentPage;
211
+ input: never;
212
+ id: number;
213
+ filters: {
214
+ children: number;
215
+ id: number;
216
+ status: "draft" | "published";
217
+ published_at: string | Date;
218
+ seo_title: string;
219
+ seo_description: string;
220
+ share_image: string;
221
+ title: string;
222
+ slug: string;
223
+ parent: number;
224
+ path: string;
225
+ body: string;
226
+ order: number;
227
+ };
228
+ lookups: {};
229
+ expandable: {
230
+ children: { model: "content.page"; many: true; nullable: false };
231
+ share_image: { model: "content_studio_media_library.media"; many: false; nullable: true };
232
+ parent: { model: "content.page"; many: false; nullable: true };
233
+ };
234
+ readOnly: true;
235
+ singleton: false;
236
+ paginated: true;
237
+ };
238
+ "content.post": {
239
+ item: ContentPost;
240
+ input: never;
241
+ id: number;
242
+ filters: {
243
+ id: number;
244
+ status: "draft" | "published";
245
+ published_at: string | Date;
246
+ seo_title: string;
247
+ seo_description: string;
248
+ share_image: string;
249
+ title: string;
250
+ slug: string;
251
+ excerpt: string;
252
+ cover: string;
253
+ body: string;
254
+ author: number;
255
+ categories: number;
256
+ tags: number;
257
+ };
258
+ lookups: {
259
+ slug: string;
260
+ };
261
+ expandable: {
262
+ share_image: { model: "content_studio_media_library.media"; many: false; nullable: true };
263
+ cover: { model: "content_studio_media_library.media"; many: false; nullable: true };
264
+ author: { model: "content.author"; many: false; nullable: true };
265
+ categories: { model: "content.category"; many: true; nullable: false };
266
+ tags: { model: "content.tag"; many: true; nullable: false };
267
+ };
268
+ readOnly: true;
269
+ singleton: false;
270
+ paginated: true;
271
+ };
272
+ "content.redirect": {
273
+ item: ContentRedirect;
274
+ input: never;
275
+ id: number;
276
+ filters: {
277
+ id: number;
278
+ old_path: string;
279
+ new_path: string;
280
+ permanent: boolean;
281
+ };
282
+ lookups: {
283
+ old_path: string;
284
+ };
285
+ expandable: {};
286
+ readOnly: true;
287
+ singleton: false;
288
+ paginated: true;
289
+ };
290
+ "content.sitesettings": {
291
+ item: ContentSiteSettings;
292
+ input: never;
293
+ id: string | number;
294
+ filters: {};
295
+ lookups: {};
296
+ expandable: {
297
+ logo: { model: "content_studio_media_library.media"; many: false; nullable: true };
298
+ home_page: { model: "content.page"; many: false; nullable: true };
299
+ share_image: { model: "content_studio_media_library.media"; many: false; nullable: true };
300
+ };
301
+ readOnly: true;
302
+ singleton: true;
303
+ paginated: true;
304
+ };
305
+ "content.tag": {
306
+ item: ContentTag;
307
+ input: never;
308
+ id: number;
309
+ filters: {
310
+ posts: number;
311
+ id: number;
312
+ name: string;
313
+ slug: string;
314
+ };
315
+ lookups: {
316
+ slug: string;
317
+ };
318
+ expandable: {
319
+ posts: { model: "content.post"; many: true; nullable: false };
320
+ };
321
+ readOnly: true;
322
+ singleton: false;
323
+ paginated: true;
324
+ };
325
+ "content_studio_media_library.media": {
326
+ item: ContentStudioMediaLibraryMedia;
327
+ input: never;
328
+ id: string;
329
+ filters: {
330
+ id: string;
331
+ file: string;
332
+ name: string;
333
+ alt_text: string;
334
+ metadata: string;
335
+ properties: string;
336
+ type: "image" | "video" | "audio" | "file";
337
+ };
338
+ lookups: {};
339
+ expandable: {};
340
+ readOnly: true;
341
+ singleton: false;
342
+ paginated: true;
343
+ };
344
+ };
@@ -0,0 +1,69 @@
1
+ import { defineMiddleware } from "astro:middleware";
2
+ import { getContent, PREVIEW_COOKIE } from "./lib/api";
3
+ import { getCached, store } from "./lib/cache";
4
+
5
+ const SECURITY_HEADERS: Record<string, string> = {
6
+ "X-Content-Type-Options": "nosniff",
7
+ "Referrer-Policy": "strict-origin-when-cross-origin",
8
+ "X-Frame-Options": "DENY",
9
+ "Permissions-Policy": "camera=(), microphone=(), geolocation=()",
10
+ };
11
+
12
+ const CACHEABLE = /^(text\/html|application\/xml|application\/rss\+xml|text\/plain)/;
13
+
14
+ // Where media files come from when they are not on this site: S3 or a CDN.
15
+ const MEDIA_ORIGINS = (process.env.MEDIA_ORIGINS ?? "")
16
+ .split(",")
17
+ .map((value) => {
18
+ try {
19
+ return new URL(value.trim()).origin;
20
+ } catch {
21
+ return "";
22
+ }
23
+ })
24
+ .filter(Boolean)
25
+ .map((origin) => ` ${origin}`)
26
+ .join("");
27
+ const MEDIA_DIRECTIVES = `img-src 'self' data:${MEDIA_ORIGINS}; media-src 'self'${MEDIA_ORIGINS}`;
28
+
29
+ export const onRequest = defineMiddleware(async (context, next) => {
30
+ const { request, url, cookies } = context;
31
+ const preview = cookies.has(PREVIEW_COOKIE);
32
+ const cacheable =
33
+ request.method === "GET" && !preview && !import.meta.env.DEV && !url.pathname.startsWith("/_");
34
+ const key = url.pathname + url.search;
35
+
36
+ if (cacheable) {
37
+ const hit = getCached(key);
38
+ if (hit) return hit;
39
+ }
40
+
41
+ let response = await next();
42
+
43
+ // Redirects that editors manage in the admin, for paths that do not exist.
44
+ if (response.status === 404 && request.method === "GET") {
45
+ const redirect = await getContent(cookies)
46
+ .resolveRedirect(url.pathname)
47
+ .catch(() => null);
48
+ if (redirect) {
49
+ return context.redirect(redirect.new_path, redirect.permanent ? 301 : 302);
50
+ }
51
+ }
52
+
53
+ for (const [name, value] of Object.entries(SECURITY_HEADERS)) {
54
+ if (!response.headers.has(name)) response.headers.set(name, value);
55
+ }
56
+ // Images and video may come from the media origins. Astro builds the
57
+ // policy while it renders, so the directives are added to its header.
58
+ const csp = response.headers.get("Content-Security-Policy");
59
+ if (csp && !csp.includes("img-src")) {
60
+ response.headers.set("Content-Security-Policy", `${csp.trim().replace(/;$/, "")}; ${MEDIA_DIRECTIVES};`);
61
+ }
62
+ if (preview) response.headers.set("Cache-Control", "private, no-store");
63
+
64
+ const type = response.headers.get("Content-Type") ?? "";
65
+ if (cacheable && response.status === 200 && CACHEABLE.test(type)) {
66
+ response = await store(key, response);
67
+ }
68
+ return response;
69
+ });
@@ -0,0 +1,13 @@
1
+ ---
2
+ import Base from "../layouts/Base.astro";
3
+
4
+ Astro.response.status = 404;
5
+ ---
6
+
7
+ <Base page={{ title: "Page not found" }}>
8
+ <section class="narrow">
9
+ <h1>Page not found</h1>
10
+ <p>The page you are looking for does not exist or has moved.</p>
11
+ <p><a href="/">Go to the home page</a></p>
12
+ </section>
13
+ </Base>
@@ -0,0 +1,21 @@
1
+ ---
2
+ import Blocks from "../components/Blocks.astro";
3
+ import ContactForm from "../components/ContactForm.astro";
4
+ import Base from "../layouts/Base.astro";
5
+ import { getContent } from "../lib/api";
6
+
7
+ const path = Astro.params.slug ?? "";
8
+ const page = await getContent(Astro.cookies).getPage(path);
9
+
10
+ if (!page) {
11
+ return Astro.rewrite("/404");
12
+ }
13
+ ---
14
+
15
+ <Base page={page}>
16
+ <article class="narrow">
17
+ <h1>{page.title}</h1>
18
+ <Blocks blocks={page.body} />
19
+ {page.path === "contact" && <ContactForm />}
20
+ </article>
21
+ </Base>
@@ -0,0 +1,50 @@
1
+ ---
2
+ import type { Author, Taxonomy } from "@django-stack/utils";
3
+ import Blocks from "../../components/Blocks.astro";
4
+ import Image from "../../components/Image.astro";
5
+ import Base from "../../layouts/Base.astro";
6
+ import { getContent } from "../../lib/api";
7
+
8
+ const post = await getContent(Astro.cookies).getPost(Astro.params.slug ?? "");
9
+
10
+ if (!post) {
11
+ return Astro.rewrite("/404");
12
+ }
13
+
14
+ const author = typeof post.author === "object" ? (post.author as Author | null) : null;
15
+ const categories = post.categories.filter((item): item is Taxonomy => typeof item === "object");
16
+ const tags = post.tags.filter((item): item is Taxonomy => typeof item === "object");
17
+ const date = post.published_at ? new Date(post.published_at) : null;
18
+ ---
19
+
20
+ <Base page={post} type="article">
21
+ <article class="narrow">
22
+ <p class="muted">
23
+ {date && <time datetime={date.toISOString()}>{date.toLocaleDateString("en", { dateStyle: "long" })}</time>}
24
+ {author && <span> · {author.name}</span>}
25
+ {categories.map((category) => <span> · <a href={`/category/${category.slug}`}>{category.name}</a></span>)}
26
+ </p>
27
+ <h1>{post.title}</h1>
28
+ <Image media={post.cover} size="content" sizes="(min-width: 48rem) 44rem, 100vw" class="cover" eager />
29
+ <Blocks blocks={post.body} />
30
+ {
31
+ tags.length > 0 && (
32
+ <p class="tags">
33
+ {tags.map((tag) => <a href={`/tag/${tag.slug}`}>#{tag.name}</a>)}
34
+ </p>
35
+ )
36
+ }
37
+ </article>
38
+ </Base>
39
+
40
+ <style>
41
+ article :global(.cover) {
42
+ width: 100%;
43
+ border-radius: 0.75rem;
44
+ }
45
+ .tags {
46
+ display: flex;
47
+ gap: 1rem;
48
+ margin-top: 3rem;
49
+ }
50
+ </style>
@@ -0,0 +1,15 @@
1
+ ---
2
+ import PostList from "../../components/PostList.astro";
3
+ import Base from "../../layouts/Base.astro";
4
+ import { getContent } from "../../lib/api";
5
+
6
+ const page = Math.max(1, Number(Astro.url.searchParams.get("page")) || 1);
7
+ const result = await getContent(Astro.cookies).getPosts({ page, limit: 9 });
8
+ ---
9
+
10
+ <Base page={{ title: "Blog" }}>
11
+ <section class="container">
12
+ <h1>Blog</h1>
13
+ <PostList result={result} base="/blog" />
14
+ </section>
15
+ </Base>
@@ -0,0 +1,25 @@
1
+ ---
2
+ import PostList from "../../components/PostList.astro";
3
+ import Base from "../../layouts/Base.astro";
4
+ import { getContent } from "../../lib/api";
5
+
6
+ const content = getContent(Astro.cookies);
7
+ const slug = Astro.params.slug ?? "";
8
+ const category = await content.getCategory(slug);
9
+
10
+ if (!category) {
11
+ return Astro.rewrite("/404");
12
+ }
13
+
14
+ const page = Math.max(1, Number(Astro.url.searchParams.get("page")) || 1);
15
+ const result = await content.getPosts({ category: slug, page, limit: 9 });
16
+ ---
17
+
18
+ <Base page={{ title: category.name, seo_description: category.description ?? "" }}>
19
+ <section class="container">
20
+ <p class="muted">Category</p>
21
+ <h1>{category.name}</h1>
22
+ {category.description && <p>{category.description}</p>}
23
+ <PostList result={result} base={`/category/${slug}`} />
24
+ </section>
25
+ </Base>
@@ -0,0 +1,28 @@
1
+ ---
2
+ import Blocks from "../components/Blocks.astro";
3
+ import PostList from "../components/PostList.astro";
4
+ import Base from "../layouts/Base.astro";
5
+ import { getContent } from "../lib/api";
6
+
7
+ const content = getContent(Astro.cookies);
8
+ const settings = await content.getSiteSettings();
9
+ const home = await content.getHomePage(settings);
10
+ const posts = home ? null : await content.getPosts({ limit: 9 });
11
+ ---
12
+
13
+ <Base page={home}>
14
+ {
15
+ home ? (
16
+ <article class="narrow">
17
+ <h1>{home.title}</h1>
18
+ <Blocks blocks={home.body} />
19
+ </article>
20
+ ) : (
21
+ <section class="container">
22
+ <h1>{settings?.site_name ?? "Latest posts"}</h1>
23
+ {settings?.tagline && <p class="muted">{settings.tagline}</p>}
24
+ {posts && <PostList result={posts} base="/" />}
25
+ </section>
26
+ )
27
+ }
28
+ </Base>
@@ -0,0 +1,7 @@
1
+ import type { APIRoute } from "astro";
2
+ import { PREVIEW_COOKIE } from "../../lib/api";
3
+
4
+ export const GET: APIRoute = ({ cookies, redirect }) => {
5
+ cookies.delete(PREVIEW_COOKIE, { path: "/" });
6
+ return redirect("/", 302);
7
+ };
@@ -0,0 +1,26 @@
1
+ import { verifyPreviewToken } from "@django-stack/utils";
2
+ import type { APIRoute } from "astro";
3
+ import { PREVIEW_COOKIE } from "../../lib/api";
4
+
5
+ /**
6
+ * Opens a page with drafts shown. The admin's Preview button links here with
7
+ * a token Django signed with PREVIEW_SECRET; the token is kept in a cookie
8
+ * and sent to the API with every request until it expires.
9
+ */
10
+ export const GET: APIRoute = async ({ url, cookies, redirect }) => {
11
+ const token = url.searchParams.get("token");
12
+ if (!(await verifyPreviewToken(process.env.PREVIEW_SECRET, token))) {
13
+ return new Response("The preview link is invalid or has expired.", { status: 401 });
14
+ }
15
+ const expires = Number(token?.split(".")[0]);
16
+ cookies.set(PREVIEW_COOKIE, token ?? "", {
17
+ path: "/",
18
+ httpOnly: true,
19
+ sameSite: "lax",
20
+ secure: url.protocol === "https:",
21
+ expires: new Date(expires * 1000),
22
+ });
23
+ const path = url.searchParams.get("path") ?? "/";
24
+ // Only paths on this site, never another origin.
25
+ return redirect(path.startsWith("/") && !path.startsWith("//") ? path : "/", 302);
26
+ };
@@ -0,0 +1,19 @@
1
+ import { verifyWebhook } from "@django-stack/utils";
2
+ import type { APIRoute } from "astro";
3
+ import { purgeAll } from "../lib/cache";
4
+
5
+ /**
6
+ * Django calls this after editors change content (REVALIDATE_URL), signed
7
+ * with REVALIDATE_SECRET. It empties the page cache, so the next visit
8
+ * renders the new content.
9
+ */
10
+ export const POST: APIRoute = async ({ request }) => {
11
+ const { valid, payload } = await verifyWebhook(request, process.env.REVALIDATE_SECRET);
12
+ if (!valid) {
13
+ return new Response(JSON.stringify({ detail: "Invalid signature." }), { status: 401 });
14
+ }
15
+ const purged = purgeAll();
16
+ return new Response(JSON.stringify({ purged, changes: payload?.changes.length ?? 0 }), {
17
+ headers: { "Content-Type": "application/json" },
18
+ });
19
+ };
@@ -0,0 +1,7 @@
1
+ import type { APIRoute } from "astro";
2
+ import { SITE_URL } from "../lib/api";
3
+
4
+ export const GET: APIRoute = () =>
5
+ new Response(`User-agent: *\nDisallow: /admin/\nDisallow: /preview\n\nSitemap: ${SITE_URL}/sitemap.xml\n`, {
6
+ headers: { "Content-Type": "text/plain; charset=utf-8" },
7
+ });
@@ -0,0 +1,19 @@
1
+ import rss from "@astrojs/rss";
2
+ import type { APIRoute } from "astro";
3
+ import { getContent, SITE_URL } from "../lib/api";
4
+
5
+ export const GET: APIRoute = async ({ cookies }) => {
6
+ const content = getContent(cookies);
7
+ const [settings, posts] = await Promise.all([content.getSiteSettings(), content.getPosts({ limit: 20 })]);
8
+ return rss({
9
+ title: settings?.site_name ?? "Blog",
10
+ description: settings?.seo_description || settings?.tagline || "",
11
+ site: SITE_URL,
12
+ items: posts.data.map((post) => ({
13
+ title: post.title,
14
+ link: `/blog/${post.slug}`,
15
+ description: post.excerpt,
16
+ ...(post.published_at ? { pubDate: new Date(post.published_at) } : {}),
17
+ })),
18
+ });
19
+ };
@@ -0,0 +1,29 @@
1
+ import type { APIRoute } from "astro";
2
+ import { getContent, SITE_URL } from "../lib/api";
3
+
4
+ export const GET: APIRoute = async ({ cookies }) => {
5
+ const content = getContent(cookies);
6
+ const [pages, posts, settings] = await Promise.all([
7
+ content.getAllPages(),
8
+ content.getAllPosts(),
9
+ content.getSiteSettings(),
10
+ ]);
11
+ const urls = [
12
+ { loc: "/", lastmod: null },
13
+ { loc: "/blog", lastmod: null },
14
+ ...pages
15
+ .filter((page) => page.id !== settings?.home_page)
16
+ .map((page) => ({ loc: `/${page.path}`, lastmod: page.published_at })),
17
+ ...posts.map((post) => ({ loc: `/blog/${post.slug}`, lastmod: post.published_at })),
18
+ ];
19
+ const body = `<?xml version="1.0" encoding="UTF-8"?>
20
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
21
+ ${urls
22
+ .map(
23
+ ({ loc, lastmod }) =>
24
+ ` <url><loc>${SITE_URL}${loc}</loc>${lastmod ? `<lastmod>${lastmod.slice(0, 10)}</lastmod>` : ""}</url>`,
25
+ )
26
+ .join("\n")}
27
+ </urlset>`;
28
+ return new Response(body, { headers: { "Content-Type": "application/xml; charset=utf-8" } });
29
+ };