@django-stack/cli 0.3.0 → 0.4.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/dist/bin.js +3 -3
- package/dist/index.d.ts +52 -40
- package/dist/index.js +1 -1
- package/dist/{stack-CTyqYWCV.js → scaffold-DbgGYEsc.js} +216 -183
- package/package.json +1 -1
- package/templates/backend/pyproject.toml +1 -1
- package/templates/base/AGENTS.md +7 -11
- package/templates/base/README.md +3 -3
- package/templates/base/SECURITY.md +1 -1
- package/templates/base/gitignore +6 -0
- package/templates/base/package.json +1 -1
- package/templates/frontend-astro/AGENTS.md +43 -0
- package/templates/frontend-astro/package.json +1 -1
- package/templates/frontend-astro/template.json +5 -0
- package/templates/frontend-next/.dockerignore +3 -0
- package/templates/frontend-next/AGENTS.md +59 -0
- package/templates/frontend-next/Dockerfile +32 -0
- package/templates/frontend-next/next.config.ts +19 -0
- package/templates/frontend-next/package.json +34 -0
- package/templates/frontend-next/public/favicon.svg +1 -0
- package/templates/frontend-next/src/app/[...path]/page.tsx +28 -0
- package/templates/frontend-next/src/app/blog/[slug]/page.tsx +60 -0
- package/templates/frontend-next/src/app/blog/page.tsx +20 -0
- package/templates/frontend-next/src/app/category/[slug]/page.tsx +34 -0
- package/templates/frontend-next/src/app/error.tsx +13 -0
- package/templates/frontend-next/src/app/globals.css +250 -0
- package/templates/frontend-next/src/app/layout.tsx +70 -0
- package/templates/frontend-next/src/app/not-found.tsx +15 -0
- package/templates/frontend-next/src/app/page.tsx +35 -0
- package/templates/frontend-next/src/app/preview/exit/route.ts +3 -0
- package/templates/frontend-next/src/app/preview/route.ts +4 -0
- package/templates/frontend-next/src/app/revalidate/route.ts +8 -0
- package/templates/frontend-next/src/app/robots.txt/route.ts +13 -0
- package/templates/frontend-next/src/app/rss.xml/route.ts +19 -0
- package/templates/frontend-next/src/app/sitemap.xml/route.ts +28 -0
- package/templates/frontend-next/src/app/tag/[slug]/page.tsx +29 -0
- package/templates/frontend-next/src/components/ContactForm.tsx +55 -0
- package/templates/frontend-next/src/components/NavLink.tsx +15 -0
- package/templates/frontend-next/src/components/PostCard.tsx +25 -0
- package/templates/frontend-next/src/components/PostList.tsx +28 -0
- package/templates/frontend-next/src/lib/api.ts +71 -0
- package/templates/frontend-next/src/lib/headless.ts +344 -0
- package/templates/frontend-next/src/lib/rss.ts +44 -0
- package/templates/frontend-next/src/proxy.ts +34 -0
- package/templates/frontend-next/template.json +5 -0
- package/templates/frontend-next/test/rss.test.ts +17 -0
- package/templates/frontend-next/tsconfig.json +42 -0
- package/templates/frontend-next/vendor/.gitkeep +0 -0
- package/templates/frontend-next/vitest.config.ts +7 -0
- package/templates/frontend-nuxt/.dockerignore +4 -0
- package/templates/frontend-nuxt/AGENTS.md +64 -0
- package/templates/frontend-nuxt/Dockerfile +32 -0
- package/templates/frontend-nuxt/nuxt.config.ts +69 -0
- package/templates/frontend-nuxt/package.json +34 -0
- package/templates/frontend-nuxt/public/favicon.svg +1 -0
- package/templates/frontend-nuxt/server/plugins/media-origins.ts +27 -0
- package/templates/frontend-nuxt/server/routes/_content/category/[slug].get.ts +13 -0
- package/templates/frontend-nuxt/server/routes/_content/home.get.ts +10 -0
- package/templates/frontend-nuxt/server/routes/_content/layout.get.ts +13 -0
- package/templates/frontend-nuxt/server/routes/_content/page.get.ts +7 -0
- package/templates/frontend-nuxt/server/routes/_content/post/[slug].get.ts +7 -0
- package/templates/frontend-nuxt/server/routes/_content/posts.get.ts +4 -0
- package/templates/frontend-nuxt/server/routes/_content/redirect.get.ts +7 -0
- package/templates/frontend-nuxt/server/routes/_content/tag/[slug].get.ts +13 -0
- package/templates/frontend-nuxt/server/routes/preview/exit.get.ts +4 -0
- package/templates/frontend-nuxt/server/routes/preview/index.get.ts +26 -0
- package/templates/frontend-nuxt/server/routes/revalidate.post.ts +15 -0
- package/templates/frontend-nuxt/server/routes/robots.txt.get.ts +4 -0
- package/templates/frontend-nuxt/server/routes/rss.xml.get.ts +19 -0
- package/templates/frontend-nuxt/server/routes/sitemap.xml.get.ts +29 -0
- package/templates/frontend-nuxt/server/utils/cache.ts +48 -0
- package/templates/frontend-nuxt/server/utils/content.ts +41 -0
- package/templates/frontend-nuxt/server/utils/rss.ts +44 -0
- package/templates/frontend-nuxt/src/app.vue +5 -0
- package/templates/frontend-nuxt/src/assets/main.css +126 -0
- package/templates/frontend-nuxt/src/components/ContactForm.vue +66 -0
- package/templates/frontend-nuxt/src/components/PostCard.vue +43 -0
- package/templates/frontend-nuxt/src/components/PostList.vue +33 -0
- package/templates/frontend-nuxt/src/composables/useContent.ts +27 -0
- package/templates/frontend-nuxt/src/composables/useLayoutContent.ts +4 -0
- package/templates/frontend-nuxt/src/composables/usePageSeo.ts +33 -0
- package/templates/frontend-nuxt/src/error.vue +23 -0
- package/templates/frontend-nuxt/src/layouts/default.vue +99 -0
- package/templates/frontend-nuxt/src/lib/headless.ts +344 -0
- package/templates/frontend-nuxt/src/pages/[...path].vue +16 -0
- package/templates/frontend-nuxt/src/pages/blog/[slug].vue +43 -0
- package/templates/frontend-nuxt/src/pages/blog/index.vue +12 -0
- package/templates/frontend-nuxt/src/pages/category/[slug].vue +14 -0
- package/templates/frontend-nuxt/src/pages/index.vue +18 -0
- package/templates/frontend-nuxt/src/pages/tag/[slug].vue +13 -0
- package/templates/frontend-nuxt/template.json +5 -0
- package/templates/frontend-nuxt/test/cache.test.ts +20 -0
- package/templates/frontend-nuxt/test/rss.test.ts +17 -0
- package/templates/frontend-nuxt/tsconfig.json +9 -0
- package/templates/frontend-nuxt/vendor/.gitkeep +0 -0
- package/templates/frontend-nuxt/vitest.config.ts +7 -0
- package/templates/frontend-sveltekit/.dockerignore +4 -0
- package/templates/frontend-sveltekit/AGENTS.md +53 -0
- package/templates/frontend-sveltekit/Dockerfile +32 -0
- package/templates/frontend-sveltekit/package.json +39 -0
- package/templates/frontend-sveltekit/src/app.css +126 -0
- package/templates/frontend-sveltekit/src/app.d.ts +12 -0
- package/templates/frontend-sveltekit/src/app.html +12 -0
- package/templates/frontend-sveltekit/src/hooks.server.ts +70 -0
- package/templates/frontend-sveltekit/src/lib/components/ContactForm.svelte +65 -0
- package/templates/frontend-sveltekit/src/lib/components/PostCard.svelte +41 -0
- package/templates/frontend-sveltekit/src/lib/components/PostList.svelte +39 -0
- package/templates/frontend-sveltekit/src/lib/headless.ts +344 -0
- package/templates/frontend-sveltekit/src/lib/server/api.ts +28 -0
- package/templates/frontend-sveltekit/src/lib/server/cache.ts +58 -0
- package/templates/frontend-sveltekit/src/lib/server/rss.ts +44 -0
- package/templates/frontend-sveltekit/src/routes/+error.svelte +14 -0
- package/templates/frontend-sveltekit/src/routes/+layout.server.ts +18 -0
- package/templates/frontend-sveltekit/src/routes/+layout.svelte +112 -0
- package/templates/frontend-sveltekit/src/routes/+page.server.ts +10 -0
- package/templates/frontend-sveltekit/src/routes/+page.svelte +19 -0
- package/templates/frontend-sveltekit/src/routes/[...path]/+page.server.ts +9 -0
- package/templates/frontend-sveltekit/src/routes/[...path]/+page.svelte +12 -0
- package/templates/frontend-sveltekit/src/routes/blog/+page.server.ts +7 -0
- package/templates/frontend-sveltekit/src/routes/blog/+page.svelte +10 -0
- package/templates/frontend-sveltekit/src/routes/blog/[slug]/+page.server.ts +9 -0
- package/templates/frontend-sveltekit/src/routes/blog/[slug]/+page.svelte +41 -0
- package/templates/frontend-sveltekit/src/routes/category/[slug]/+page.server.ts +11 -0
- package/templates/frontend-sveltekit/src/routes/category/[slug]/+page.svelte +12 -0
- package/templates/frontend-sveltekit/src/routes/preview/+server.ts +27 -0
- package/templates/frontend-sveltekit/src/routes/preview/exit/+server.ts +8 -0
- package/templates/frontend-sveltekit/src/routes/revalidate/+server.ts +16 -0
- package/templates/frontend-sveltekit/src/routes/robots.txt/+server.ts +7 -0
- package/templates/frontend-sveltekit/src/routes/rss.xml/+server.ts +20 -0
- package/templates/frontend-sveltekit/src/routes/sitemap.xml/+server.ts +29 -0
- package/templates/frontend-sveltekit/src/routes/tag/[slug]/+page.server.ts +11 -0
- package/templates/frontend-sveltekit/src/routes/tag/[slug]/+page.svelte +11 -0
- package/templates/frontend-sveltekit/static/favicon.svg +1 -0
- package/templates/frontend-sveltekit/template.json +5 -0
- package/templates/frontend-sveltekit/test/cache.test.ts +22 -0
- package/templates/frontend-sveltekit/test/rss.test.ts +17 -0
- package/templates/frontend-sveltekit/tsconfig.json +5 -0
- package/templates/frontend-sveltekit/vendor/.gitkeep +0 -0
- package/templates/frontend-sveltekit/vite.config.ts +47 -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,44 @@
|
|
|
1
|
+
/** An RSS 2.0 feed. */
|
|
2
|
+
|
|
3
|
+
export interface FeedItem {
|
|
4
|
+
title: string;
|
|
5
|
+
/** A path on the site, such as /blog/hello-world. */
|
|
6
|
+
link: string;
|
|
7
|
+
description?: string | null;
|
|
8
|
+
pubDate?: Date | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const escapeXml = (text: string) =>
|
|
12
|
+
text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
13
|
+
|
|
14
|
+
export function rssFeed(feed: {
|
|
15
|
+
title: string;
|
|
16
|
+
description: string;
|
|
17
|
+
site: string;
|
|
18
|
+
items: FeedItem[];
|
|
19
|
+
}): string {
|
|
20
|
+
const items = feed.items.map((item) => {
|
|
21
|
+
const url = escapeXml(new URL(item.link, `${feed.site}/`).href);
|
|
22
|
+
return [
|
|
23
|
+
" <item>",
|
|
24
|
+
` <title>${escapeXml(item.title)}</title>`,
|
|
25
|
+
` <link>${url}</link>`,
|
|
26
|
+
` <guid isPermaLink="true">${url}</guid>`,
|
|
27
|
+
item.description ? ` <description>${escapeXml(item.description)}</description>` : "",
|
|
28
|
+
item.pubDate ? ` <pubDate>${item.pubDate.toUTCString()}</pubDate>` : "",
|
|
29
|
+
" </item>",
|
|
30
|
+
]
|
|
31
|
+
.filter(Boolean)
|
|
32
|
+
.join("\n");
|
|
33
|
+
});
|
|
34
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
35
|
+
<rss version="2.0">
|
|
36
|
+
<channel>
|
|
37
|
+
<title>${escapeXml(feed.title)}</title>
|
|
38
|
+
<description>${escapeXml(feed.description)}</description>
|
|
39
|
+
<link>${escapeXml(`${feed.site}/`)}</link>
|
|
40
|
+
${items.join("\n")}
|
|
41
|
+
</channel>
|
|
42
|
+
</rss>
|
|
43
|
+
`;
|
|
44
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { contentSecurityPolicy, mediaOrigins, PREVIEW_COOKIE, SECURITY_HEADERS } from "@django-stack/next";
|
|
2
|
+
import { type NextRequest, NextResponse } from "next/server";
|
|
3
|
+
|
|
4
|
+
const dev = process.env.NODE_ENV === "development";
|
|
5
|
+
|
|
6
|
+
// Django's paths. The reverse proxy sends them to Django in production; in
|
|
7
|
+
// development, this proxy does, so the site and the admin share an origin.
|
|
8
|
+
const BACKEND_PATHS = /^\/(admin|api|media|static)(\/|$)/;
|
|
9
|
+
const backend = process.env.BACKEND_INTERNAL_URL ?? "http://localhost:8000";
|
|
10
|
+
const media = mediaOrigins();
|
|
11
|
+
|
|
12
|
+
export function proxy(request: NextRequest) {
|
|
13
|
+
const { pathname, search } = request.nextUrl;
|
|
14
|
+
if (dev && BACKEND_PATHS.test(pathname)) {
|
|
15
|
+
return NextResponse.rewrite(new URL(pathname + search, backend));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// A fresh nonce for every page: Next adds it to the scripts it renders.
|
|
19
|
+
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
|
|
20
|
+
const policy = contentSecurityPolicy({ nonce, mediaOrigins: media, dev });
|
|
21
|
+
const headers = new Headers(request.headers);
|
|
22
|
+
headers.set("Content-Security-Policy", policy);
|
|
23
|
+
|
|
24
|
+
const response = NextResponse.next({ request: { headers } });
|
|
25
|
+
response.headers.set("Content-Security-Policy", policy);
|
|
26
|
+
for (const [name, value] of Object.entries(SECURITY_HEADERS)) response.headers.set(name, value);
|
|
27
|
+
if (request.cookies.has(PREVIEW_COOKIE)) response.headers.set("Cache-Control", "private, no-store");
|
|
28
|
+
return response;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const config = {
|
|
32
|
+
// Every path but Next's own files and the favicon.
|
|
33
|
+
matcher: ["/((?!_next/static|_next/image|favicon.svg).*)"],
|
|
34
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { rssFeed } from "../src/lib/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 & notes</title>");
|
|
13
|
+
expect(xml).toContain("<title><Hello></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,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": [
|
|
5
|
+
"dom",
|
|
6
|
+
"dom.iterable",
|
|
7
|
+
"esnext"
|
|
8
|
+
],
|
|
9
|
+
"allowJs": false,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"strict": true,
|
|
12
|
+
"noEmit": true,
|
|
13
|
+
"module": "esnext",
|
|
14
|
+
"moduleResolution": "bundler",
|
|
15
|
+
"resolveJsonModule": true,
|
|
16
|
+
"isolatedModules": true,
|
|
17
|
+
"jsx": "react-jsx",
|
|
18
|
+
"incremental": true,
|
|
19
|
+
"plugins": [
|
|
20
|
+
{
|
|
21
|
+
"name": "next"
|
|
22
|
+
}
|
|
23
|
+
],
|
|
24
|
+
"paths": {
|
|
25
|
+
"@/*": [
|
|
26
|
+
"./src/*"
|
|
27
|
+
]
|
|
28
|
+
},
|
|
29
|
+
"esModuleInterop": true
|
|
30
|
+
},
|
|
31
|
+
"include": [
|
|
32
|
+
"next-env.d.ts",
|
|
33
|
+
"src",
|
|
34
|
+
"test",
|
|
35
|
+
"*.ts",
|
|
36
|
+
".next/types/**/*.ts",
|
|
37
|
+
".next/dev/types/**/*.ts"
|
|
38
|
+
],
|
|
39
|
+
"exclude": [
|
|
40
|
+
"node_modules"
|
|
41
|
+
]
|
|
42
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# The Nuxt frontend
|
|
2
|
+
|
|
3
|
+
Nuxt renders the site on the server (Nitro's `node-server` preset). Pages
|
|
4
|
+
never call Django: they load content with `useFetch` from the server routes
|
|
5
|
+
in `server/routes/_content/`, which hold the read-only API key, so the key
|
|
6
|
+
never reaches the browser. The project's `AGENTS.md` describes the whole
|
|
7
|
+
project; this file describes the frontend.
|
|
8
|
+
|
|
9
|
+
## Layout
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
src/pages/ routes: /, /[...path], /blog, /blog/[slug], /category/[slug], /tag/[slug]
|
|
13
|
+
src/layouts/default.vue the page shell: menus, footer, preview banner
|
|
14
|
+
src/components/ the post list and the contact form
|
|
15
|
+
src/composables/ useContent() loads from server/routes/_content and follows redirects; usePageSeo() sets the SEO tags
|
|
16
|
+
src/lib/headless.ts generated API types; do not edit, run `npm run generate:types`
|
|
17
|
+
server/routes/_content/ the content the pages load, cached in memory
|
|
18
|
+
server/routes/ sitemap.xml, rss.xml, robots.txt, /preview, /revalidate
|
|
19
|
+
server/utils/content.ts getContent(): content helpers with the read-only key, drafts in preview
|
|
20
|
+
server/utils/cache.ts the in-memory cache that /revalidate empties
|
|
21
|
+
server/plugins/ the media origins in the Content-Security-Policy
|
|
22
|
+
nuxt.config.ts security headers (nuxt-security), the dev proxy to Django
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Blocks, rich text and images come from `@django-stack/vue`.
|
|
26
|
+
|
|
27
|
+
## Read a content model
|
|
28
|
+
|
|
29
|
+
Add a server route, then load it in a page:
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
// server/routes/_content/events.get.ts
|
|
33
|
+
export default defineEventHandler((event) =>
|
|
34
|
+
cachedContent(event, () => getClient(event).model("content.event").list()),
|
|
35
|
+
);
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
```vue
|
|
39
|
+
<!-- src/pages/events.vue -->
|
|
40
|
+
<script setup lang="ts">
|
|
41
|
+
const { data: events } = await useContent("/_content/events");
|
|
42
|
+
</script>
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Never call `getClient()` or `getContent()` outside `server/`, and never
|
|
46
|
+
put a route in `server/api/`: Django owns `/api`.
|
|
47
|
+
|
|
48
|
+
## Render a block type
|
|
49
|
+
|
|
50
|
+
Add a component in `src/components/blocks/` that takes a `block` prop, and
|
|
51
|
+
pass it to `Blocks` by the block's type:
|
|
52
|
+
|
|
53
|
+
```vue
|
|
54
|
+
<Blocks :blocks="page.body" :components="{ gallery: Gallery }" />
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Commands
|
|
58
|
+
|
|
59
|
+
Run them in the `frontend` container (`docker compose exec frontend <command>`):
|
|
60
|
+
|
|
61
|
+
| Task | Command |
|
|
62
|
+
|------|---------|
|
|
63
|
+
| Type-check | `npm run check` |
|
|
64
|
+
| Test | `npm test` |
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# syntax=docker/dockerfile:1
|
|
2
|
+
|
|
3
|
+
# The frontend image. The `dev` target runs the Nuxt 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 NUXT_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 --ignore-scripts; else npm install --ignore-scripts; 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
|
+
# The build output holds the server and the packages it needs: no
|
|
24
|
+
# node_modules in the image.
|
|
25
|
+
FROM base AS prod
|
|
26
|
+
ENV NODE_ENV=production HOST=0.0.0.0 PORT=3000
|
|
27
|
+
COPY --from=build --chown=node:node /app/.output ./.output
|
|
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", ".output/server/index.mjs"]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// The Django backend: the frontend container reaches it on the Compose network.
|
|
2
|
+
const backend = process.env.BACKEND_INTERNAL_URL ?? "http://localhost:8000";
|
|
3
|
+
|
|
4
|
+
export default defineNuxtConfig({
|
|
5
|
+
compatibilityDate: "2026-11-01",
|
|
6
|
+
srcDir: "src/",
|
|
7
|
+
modules: ["nuxt-security"],
|
|
8
|
+
css: ["~/assets/main.css"],
|
|
9
|
+
telemetry: false,
|
|
10
|
+
devtools: { enabled: false },
|
|
11
|
+
app: {
|
|
12
|
+
head: {
|
|
13
|
+
htmlAttrs: { lang: "en" },
|
|
14
|
+
link: [{ rel: "icon", href: "/favicon.svg", type: "image/svg+xml" }],
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
devServer: { host: "0.0.0.0", port: 3000 },
|
|
18
|
+
vite: {
|
|
19
|
+
// Django calls /revalidate at http://frontend:3000 on the Compose network.
|
|
20
|
+
server: { allowedHosts: ["frontend"] },
|
|
21
|
+
},
|
|
22
|
+
nitro: {
|
|
23
|
+
// A Node server: pages render on request, so a publish shows within seconds.
|
|
24
|
+
preset: "node-server",
|
|
25
|
+
},
|
|
26
|
+
// In development, serve Django's paths from the same origin as the site,
|
|
27
|
+
// like the reverse proxy does in production.
|
|
28
|
+
$development: {
|
|
29
|
+
routeRules: Object.fromEntries(
|
|
30
|
+
["/admin", "/api", "/media", "/static"].map((path) => [
|
|
31
|
+
`${path}/**`,
|
|
32
|
+
{ proxy: `${backend}${path}/**` },
|
|
33
|
+
]),
|
|
34
|
+
),
|
|
35
|
+
},
|
|
36
|
+
// Security headers, with a Content-Security-Policy on every page in
|
|
37
|
+
// production: scripts and styles only from this site, with a nonce for
|
|
38
|
+
// the ones Nuxt inlines. server/plugins/media-origins.ts adds the origin
|
|
39
|
+
// of the media files.
|
|
40
|
+
security: {
|
|
41
|
+
nonce: true,
|
|
42
|
+
rateLimiter: false,
|
|
43
|
+
xssValidator: false,
|
|
44
|
+
headers: {
|
|
45
|
+
contentSecurityPolicy:
|
|
46
|
+
process.env.NODE_ENV === "production"
|
|
47
|
+
? {
|
|
48
|
+
"default-src": ["'self'"],
|
|
49
|
+
"script-src": ["'self'", "'nonce-{{nonce}}'", "'strict-dynamic'"],
|
|
50
|
+
"style-src": ["'self'", "'nonce-{{nonce}}'"],
|
|
51
|
+
// Inline style attributes, such as the focal point of an image.
|
|
52
|
+
"style-src-attr": ["'unsafe-inline'"],
|
|
53
|
+
"img-src": ["'self'", "data:"],
|
|
54
|
+
"media-src": ["'self'"],
|
|
55
|
+
"font-src": ["'self'"],
|
|
56
|
+
"connect-src": ["'self'"],
|
|
57
|
+
"base-uri": ["'self'"],
|
|
58
|
+
"form-action": ["'self'"],
|
|
59
|
+
"frame-ancestors": ["'none'"],
|
|
60
|
+
"object-src": ["'none'"],
|
|
61
|
+
"upgrade-insecure-requests": false,
|
|
62
|
+
}
|
|
63
|
+
: false,
|
|
64
|
+
// Media may come from another origin (S3 or a CDN) without CORP headers.
|
|
65
|
+
crossOriginEmbedderPolicy: false,
|
|
66
|
+
permissionsPolicy: { camera: [], microphone: [], geolocation: [] },
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
});
|