@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,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,28 @@
1
+ /**
2
+ * Content access for load functions and endpoints. Server-side only: the API
3
+ * key is read from API_READ_KEY, and SvelteKit never sends modules in
4
+ * src/lib/server to the browser.
5
+ */
6
+
7
+ import { createClient, createContent } from "@django-stack/utils";
8
+ import type { Cookies } from "@sveltejs/kit";
9
+ import type { Models } from "../headless";
10
+
11
+ export const PREVIEW_COOKIE = "stack_preview";
12
+
13
+ /** The content helpers, with drafts when the request carries a preview cookie. */
14
+ export function getContent(cookies: Cookies) {
15
+ return createContent(getClient(cookies));
16
+ }
17
+
18
+ /** The typed Django Headless client, for queries the content helpers do not cover. */
19
+ export function getClient(cookies?: Cookies) {
20
+ return createClient<Models>({ previewToken: cookies?.get(PREVIEW_COOKIE) ?? null });
21
+ }
22
+
23
+ export const SITE_URL = (process.env.SITE_URL ?? "http://localhost:3000").replace(/\/$/, "");
24
+
25
+ /** The page number of a list, from ?page=. */
26
+ export function pageNumber(url: URL): number {
27
+ return Math.max(1, Number(url.searchParams.get("page")) || 1);
28
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * A small in-memory cache of rendered pages. Django calls /revalidate after
3
+ * editors change content, which empties it; entries also expire after
4
+ * CACHE_TTL seconds (default 300). One frontend process, one cache: with
5
+ * several replicas, put a shared cache or CDN in front instead.
6
+ */
7
+
8
+ interface Entry {
9
+ body: string;
10
+ status: number;
11
+ headers: [string, string][];
12
+ expires: number;
13
+ }
14
+
15
+ const MAX_ENTRIES = Number(process.env.CACHE_MAX_ENTRIES ?? 500);
16
+ const TTL = Number(process.env.CACHE_TTL ?? 300) * 1000;
17
+ const entries = new Map<string, Entry>();
18
+
19
+ export function getCached(key: string): Response | null {
20
+ const entry = entries.get(key);
21
+ if (!entry) return null;
22
+ if (entry.expires < Date.now()) {
23
+ entries.delete(key);
24
+ return null;
25
+ }
26
+ // Move to the end: the oldest entries are evicted first.
27
+ entries.delete(key);
28
+ entries.set(key, entry);
29
+ const headers = new Headers(entry.headers);
30
+ headers.set("X-Cache", "HIT");
31
+ return new Response(entry.body, { status: entry.status, headers });
32
+ }
33
+
34
+ export async function store(key: string, response: Response): Promise<Response> {
35
+ if (TTL <= 0) return response;
36
+ const body = await response.text();
37
+ entries.set(key, {
38
+ body,
39
+ status: response.status,
40
+ headers: [...response.headers.entries()].filter(([name]) => name.toLowerCase() !== "set-cookie"),
41
+ expires: Date.now() + TTL,
42
+ });
43
+ while (entries.size > MAX_ENTRIES) {
44
+ const oldest = entries.keys().next().value;
45
+ if (oldest === undefined) break;
46
+ entries.delete(oldest);
47
+ }
48
+ const headers = new Headers(response.headers);
49
+ headers.set("X-Cache", "MISS");
50
+ return new Response(body, { status: response.status, headers });
51
+ }
52
+
53
+ /** Empties the cache; returns the number of entries removed. */
54
+ export function purgeAll(): number {
55
+ const size = entries.size;
56
+ entries.clear();
57
+ return size;
58
+ }
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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,14 @@
1
+ <script lang="ts">
2
+ import { page } from "$app/state";
3
+ </script>
4
+
5
+ <section class="narrow">
6
+ {#if page.status === 404}
7
+ <h1>Page not found</h1>
8
+ <p>The page you are looking for does not exist or has moved.</p>
9
+ {:else}
10
+ <h1>Something went wrong</h1>
11
+ <p>The page could not be shown. Try again in a moment.</p>
12
+ {/if}
13
+ <p><a href="/">Go to the home page</a></p>
14
+ </section>
@@ -0,0 +1,18 @@
1
+ import { getContent, PREVIEW_COOKIE, SITE_URL } from "#lib/server/api.ts";
2
+ import type { LayoutServerLoad } from "./$types";
3
+
4
+ export const load: LayoutServerLoad = async ({ cookies }) => {
5
+ const content = getContent(cookies);
6
+ const [settings, mainMenu, footerMenu] = await Promise.all([
7
+ content.getSiteSettings(),
8
+ content.getMenu("main"),
9
+ content.getMenu("footer"),
10
+ ]);
11
+ return {
12
+ settings,
13
+ mainMenu,
14
+ footerMenu,
15
+ preview: cookies.get(PREVIEW_COOKIE) !== undefined,
16
+ siteUrl: SITE_URL,
17
+ };
18
+ };
@@ -0,0 +1,112 @@
1
+ <script lang="ts">
2
+ import "../app.css";
3
+ import { page } from "$app/state";
4
+ import { Image, RichText, Seo } from "@django-stack/svelte";
5
+ import { linkHref } from "@django-stack/utils";
6
+
7
+ let { data, children } = $props();
8
+ const path = $derived(page.url.pathname);
9
+ const seo = $derived(
10
+ page.error ? { title: page.status === 404 ? "Page not found" : "Something went wrong" } : page.data.seo,
11
+ );
12
+ </script>
13
+
14
+ <svelte:head>
15
+ <link rel="alternate" type="application/rss+xml" title={data.settings?.site_name ?? "RSS"} href="/rss.xml" />
16
+ </svelte:head>
17
+
18
+ <Seo
19
+ page={seo ?? null}
20
+ type={page.data.seoType}
21
+ settings={data.settings}
22
+ siteUrl={data.siteUrl}
23
+ path={path}
24
+ />
25
+
26
+ {#if data.preview}
27
+ <div class="preview-banner">
28
+ Preview: drafts are shown. <a href="/preview/exit" data-sveltekit-reload>Leave preview</a>
29
+ </div>
30
+ {/if}
31
+ <header class="site-header">
32
+ <div class="container bar">
33
+ <a class="brand" href="/">
34
+ {#if data.settings?.logo}
35
+ <Image media={data.settings.logo} size="thumbnail" alt={data.settings.site_name} eager />
36
+ {:else}
37
+ {data.settings?.site_name}
38
+ {/if}
39
+ </a>
40
+ <nav aria-label="Main">
41
+ {#each data.mainMenu?.items ?? [] as item (item.id)}
42
+ {@const href = linkHref(item)}
43
+ <a {href} aria-current={href === path || (href !== "/" && path.startsWith(`${href}/`)) ? "page" : undefined}>
44
+ {item.value.label}
45
+ </a>
46
+ {/each}
47
+ </nav>
48
+ </div>
49
+ </header>
50
+ <main>
51
+ {@render children()}
52
+ </main>
53
+ <footer class="site-footer">
54
+ <div class="container bar muted">
55
+ <RichText html={data.settings?.footer_text} class="footer-text" />
56
+ <nav aria-label="Footer">
57
+ {#each data.footerMenu?.items ?? [] as item (item.id)}
58
+ <a href={linkHref(item)}>{item.value.label}</a>
59
+ {/each}
60
+ {#each Object.entries(data.settings?.social_links ?? {}) as [name, url] (name)}
61
+ <a href={url} rel="me">{name}</a>
62
+ {/each}
63
+ </nav>
64
+ </div>
65
+ </footer>
66
+
67
+ <style>
68
+ .site-header {
69
+ border-bottom: 1px solid var(--color-line);
70
+ }
71
+ .bar {
72
+ display: flex;
73
+ flex-wrap: wrap;
74
+ gap: 1rem;
75
+ align-items: center;
76
+ justify-content: space-between;
77
+ padding-block: 1rem;
78
+ }
79
+ .brand {
80
+ font-weight: 700;
81
+ font-size: 1.2rem;
82
+ color: inherit;
83
+ text-decoration: none;
84
+ }
85
+ .brand :global(img) {
86
+ height: 2.5rem;
87
+ width: auto;
88
+ }
89
+ nav {
90
+ display: flex;
91
+ flex-wrap: wrap;
92
+ gap: 1.25rem;
93
+ }
94
+ nav a {
95
+ text-decoration: none;
96
+ }
97
+ nav a[aria-current="page"] {
98
+ font-weight: 600;
99
+ text-decoration: underline;
100
+ }
101
+ main {
102
+ min-height: 70vh;
103
+ padding-block: 2rem 4rem;
104
+ }
105
+ .site-footer {
106
+ border-top: 1px solid var(--color-line);
107
+ font-size: 0.9rem;
108
+ }
109
+ .site-footer :global(.footer-text p) {
110
+ margin: 0;
111
+ }
112
+ </style>
@@ -0,0 +1,10 @@
1
+ import { getContent } from "#lib/server/api.ts";
2
+ import type { PageServerLoad } from "./$types";
3
+
4
+ export const load: PageServerLoad = async ({ cookies }) => {
5
+ const content = getContent(cookies);
6
+ const settings = await content.getSiteSettings();
7
+ const home = await content.getHomePage(settings);
8
+ const posts = home ? null : await content.getPosts({ limit: 9 });
9
+ return { home, posts, seo: home };
10
+ };
@@ -0,0 +1,19 @@
1
+ <script lang="ts">
2
+ import PostList from "#lib/components/PostList.svelte";
3
+ import { Blocks } from "@django-stack/svelte";
4
+
5
+ let { data } = $props();
6
+ </script>
7
+
8
+ {#if data.home}
9
+ <article class="narrow">
10
+ <h1>{data.home.title}</h1>
11
+ <Blocks blocks={data.home.body} />
12
+ </article>
13
+ {:else}
14
+ <section class="container">
15
+ <h1>{data.settings?.site_name ?? "Latest posts"}</h1>
16
+ {#if data.settings?.tagline}<p class="muted">{data.settings.tagline}</p>{/if}
17
+ {#if data.posts}<PostList result={data.posts} base="/" />{/if}
18
+ </section>
19
+ {/if}
@@ -0,0 +1,9 @@
1
+ import { error } from "@sveltejs/kit";
2
+ import { getContent } from "#lib/server/api.ts";
3
+ import type { PageServerLoad } from "./$types";
4
+
5
+ export const load: PageServerLoad = async ({ params, cookies }) => {
6
+ const page = await getContent(cookies).getPage(params.path);
7
+ if (!page) error(404, "Page not found");
8
+ return { page, seo: page };
9
+ };
@@ -0,0 +1,12 @@
1
+ <script lang="ts">
2
+ import ContactForm from "#lib/components/ContactForm.svelte";
3
+ import { Blocks } from "@django-stack/svelte";
4
+
5
+ let { data } = $props();
6
+ </script>
7
+
8
+ <article class="narrow">
9
+ <h1>{data.page.title}</h1>
10
+ <Blocks blocks={data.page.body} />
11
+ {#if data.page.path === "contact"}<ContactForm />{/if}
12
+ </article>
@@ -0,0 +1,7 @@
1
+ import { getContent, pageNumber } from "#lib/server/api.ts";
2
+ import type { PageServerLoad } from "./$types";
3
+
4
+ export const load: PageServerLoad = async ({ cookies, url }) => {
5
+ const result = await getContent(cookies).getPosts({ page: pageNumber(url), limit: 9 });
6
+ return { result, seo: { title: "Blog" } };
7
+ };
@@ -0,0 +1,10 @@
1
+ <script lang="ts">
2
+ import PostList from "#lib/components/PostList.svelte";
3
+
4
+ let { data } = $props();
5
+ </script>
6
+
7
+ <section class="container">
8
+ <h1>Blog</h1>
9
+ <PostList result={data.result} base="/blog" />
10
+ </section>