@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,34 @@
1
+ {
2
+ "name": "{{project_name}}-frontend",
3
+ "private": true,
4
+ "type": "module",
5
+ "version": "0.1.0",
6
+ "scripts": {
7
+ "dev": "nuxt dev",
8
+ "build": "nuxt build",
9
+ "start": "node .output/server/index.mjs",
10
+ "preview": "nuxt preview",
11
+ "postinstall": "nuxt prepare",
12
+ "check": "nuxt typecheck",
13
+ "test": "vitest run",
14
+ "generate:types": "headless generate --url \"${API_URL:-http://localhost:8000/api}/schema\" --out src/lib/headless.ts"
15
+ },
16
+ "dependencies": {
17
+ "@django-headless/client": "^1.1.1",
18
+ "@django-stack/utils": "^0.5.0",
19
+ "@django-stack/vue": "^0.5.0",
20
+ "nuxt": "^4.6.3",
21
+ "nuxt-security": "^2.7.0",
22
+ "vue": "^3.5.44",
23
+ "vue-router": "^5.3.1"
24
+ },
25
+ "devDependencies": {
26
+ "@types/node": "^22.20.5",
27
+ "typescript": "~5.9.3",
28
+ "vitest": "^5.1.0",
29
+ "vue-tsc": "^3.3.13"
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,27 @@
1
+ /**
2
+ * Images and video may come from the media origins (S3 or a CDN), which are
3
+ * only known when the server starts: add them to the Content-Security-Policy
4
+ * that nuxt.config.ts sets.
5
+ */
6
+ export default defineNitroPlugin((nitroApp) => {
7
+ const origins = (process.env.MEDIA_ORIGINS ?? "")
8
+ .split(",")
9
+ .map((value) => {
10
+ try {
11
+ return new URL(value.trim()).origin;
12
+ } catch {
13
+ return "";
14
+ }
15
+ })
16
+ .filter(Boolean);
17
+ if (!origins.length) return;
18
+ nitroApp.hooks.hook("nuxt-security:routeRules", (routeRules) => {
19
+ const headers = routeRules["/**"]?.headers;
20
+ const csp = headers ? headers.contentSecurityPolicy : undefined;
21
+ if (!csp || typeof csp === "string") return;
22
+ const sources = (value: unknown): string[] =>
23
+ Array.isArray(value) ? value : typeof value === "string" ? [value] : [];
24
+ csp["img-src"] = [...sources(csp["img-src"]), ...origins];
25
+ csp["media-src"] = [...sources(csp["media-src"]), ...origins];
26
+ });
27
+ });
@@ -0,0 +1,13 @@
1
+ /** A category and a page of its posts. */
2
+ export default defineEventHandler(async (event) => {
3
+ const slug = getRouterParam(event, "slug") ?? "";
4
+ const data = await cachedContent(event, async () => {
5
+ const content = getContent(event);
6
+ const category = await content.getCategory(slug);
7
+ if (!category) return null;
8
+ const result = await content.getPosts({ category: slug, page: pageNumber(event), limit: 9 });
9
+ return { category, result };
10
+ });
11
+ if (!data) throw createError({ statusCode: 404, statusMessage: "Page not found" });
12
+ return data;
13
+ });
@@ -0,0 +1,10 @@
1
+ /** The home page set in the site settings, or else the latest posts. */
2
+ export default defineEventHandler((event) =>
3
+ cachedContent(event, async () => {
4
+ const content = getContent(event);
5
+ const settings = await content.getSiteSettings();
6
+ const home = await content.getHomePage(settings);
7
+ const posts = home ? null : await content.getPosts({ limit: 9 });
8
+ return { settings, home, posts };
9
+ }),
10
+ );
@@ -0,0 +1,13 @@
1
+ /** What every page shows: the site settings and the menus. */
2
+ export default defineEventHandler(async (event) => {
3
+ const data = await cachedContent(event, async () => {
4
+ const content = getContent(event);
5
+ const [settings, mainMenu, footerMenu] = await Promise.all([
6
+ content.getSiteSettings(),
7
+ content.getMenu("main"),
8
+ content.getMenu("footer"),
9
+ ]);
10
+ return { settings, mainMenu, footerMenu, siteUrl: SITE_URL };
11
+ });
12
+ return { ...data, preview: previewToken(event) !== null };
13
+ });
@@ -0,0 +1,7 @@
1
+ /** A page by its path, such as about/team. */
2
+ export default defineEventHandler(async (event) => {
3
+ const path = String(getQuery(event).path ?? "");
4
+ const page = await cachedContent(event, () => getContent(event).getPage(path));
5
+ if (!page) throw createError({ statusCode: 404, statusMessage: "Page not found" });
6
+ return page;
7
+ });
@@ -0,0 +1,7 @@
1
+ /** A post by its slug. */
2
+ export default defineEventHandler(async (event) => {
3
+ const slug = getRouterParam(event, "slug") ?? "";
4
+ const post = await cachedContent(event, () => getContent(event).getPost(slug));
5
+ if (!post) throw createError({ statusCode: 404, statusMessage: "Page not found" });
6
+ return post;
7
+ });
@@ -0,0 +1,4 @@
1
+ /** A page of posts, newest first. */
2
+ export default defineEventHandler((event) =>
3
+ cachedContent(event, () => getContent(event).getPosts({ page: pageNumber(event), limit: 9 })),
4
+ );
@@ -0,0 +1,7 @@
1
+ /** The redirect an editor set up in the admin for a path, if any. */
2
+ export default defineEventHandler(async (event) => {
3
+ const path = String(getQuery(event).path ?? "/");
4
+ const redirect = await cachedContent(event, () => getContent(event).resolveRedirect(path));
5
+ if (!redirect) throw createError({ statusCode: 404, statusMessage: "No redirect" });
6
+ return { new_path: redirect.new_path, permanent: redirect.permanent };
7
+ });
@@ -0,0 +1,13 @@
1
+ /** A tag and a page of its posts. */
2
+ export default defineEventHandler(async (event) => {
3
+ const slug = getRouterParam(event, "slug") ?? "";
4
+ const data = await cachedContent(event, async () => {
5
+ const content = getContent(event);
6
+ const tag = await content.getTag(slug);
7
+ if (!tag) return null;
8
+ const result = await content.getPosts({ tag: slug, page: pageNumber(event), limit: 9 });
9
+ return { tag, result };
10
+ });
11
+ if (!data) throw createError({ statusCode: 404, statusMessage: "Page not found" });
12
+ return data;
13
+ });
@@ -0,0 +1,4 @@
1
+ export default defineEventHandler((event) => {
2
+ deleteCookie(event, PREVIEW_COOKIE, { path: "/" });
3
+ return sendRedirect(event, "/", 302);
4
+ });
@@ -0,0 +1,26 @@
1
+ import { verifyPreviewToken } from "@django-stack/utils";
2
+
3
+ /**
4
+ * Opens a page with drafts shown. The admin's Preview button links here with
5
+ * a token Django signed with PREVIEW_SECRET; the token is kept in a cookie
6
+ * and sent to the API with every request until it expires.
7
+ */
8
+ export default defineEventHandler(async (event) => {
9
+ const query = getQuery(event);
10
+ const token = typeof query.token === "string" ? query.token : null;
11
+ if (!(await verifyPreviewToken(process.env.PREVIEW_SECRET, token))) {
12
+ setResponseStatus(event, 401);
13
+ return "The preview link is invalid or has expired.";
14
+ }
15
+ const expires = Number(token?.split(".")[0]);
16
+ setCookie(event, PREVIEW_COOKIE, token ?? "", {
17
+ path: "/",
18
+ httpOnly: true,
19
+ sameSite: "lax",
20
+ secure: getRequestProtocol(event) === "https",
21
+ expires: new Date(expires * 1000),
22
+ });
23
+ const path = typeof query.path === "string" ? query.path : "/";
24
+ // Only paths on this site, never another origin.
25
+ return sendRedirect(event, path.startsWith("/") && !path.startsWith("//") ? path : "/", 302);
26
+ });
@@ -0,0 +1,15 @@
1
+ import { verifyWebhook } from "@django-stack/utils";
2
+
3
+ /**
4
+ * Django calls this after editors change content (REVALIDATE_URL), signed
5
+ * with REVALIDATE_SECRET. It empties the content cache, so the next visit
6
+ * renders the new content.
7
+ */
8
+ export default defineEventHandler(async (event) => {
9
+ const { valid, payload } = await verifyWebhook(toWebRequest(event), process.env.REVALIDATE_SECRET);
10
+ if (!valid) {
11
+ setResponseStatus(event, 401);
12
+ return { detail: "Invalid signature." };
13
+ }
14
+ return { purged: purgeAll(), changes: payload?.changes.length ?? 0 };
15
+ });
@@ -0,0 +1,4 @@
1
+ export default defineEventHandler((event) => {
2
+ setResponseHeader(event, "Content-Type", "text/plain; charset=utf-8");
3
+ return `User-agent: *\nDisallow: /admin/\nDisallow: /preview\n\nSitemap: ${SITE_URL}/sitemap.xml\n`;
4
+ });
@@ -0,0 +1,19 @@
1
+ export default defineEventHandler(async (event) => {
2
+ const body = await cachedContent(event, async () => {
3
+ const content = getContent(event);
4
+ const [settings, posts] = await Promise.all([content.getSiteSettings(), content.getPosts({ limit: 20 })]);
5
+ return rssFeed({
6
+ title: settings?.site_name ?? "Blog",
7
+ description: settings?.seo_description || settings?.tagline || "",
8
+ site: SITE_URL,
9
+ items: posts.data.map((post) => ({
10
+ title: post.title,
11
+ link: `/blog/${post.slug}`,
12
+ description: post.excerpt,
13
+ pubDate: post.published_at ? new Date(post.published_at) : null,
14
+ })),
15
+ });
16
+ });
17
+ setResponseHeader(event, "Content-Type", "application/rss+xml; charset=utf-8");
18
+ return body;
19
+ });
@@ -0,0 +1,29 @@
1
+ export default defineEventHandler(async (event) => {
2
+ const body = await cachedContent(event, async () => {
3
+ const content = getContent(event);
4
+ const [pages, posts, settings] = await Promise.all([
5
+ content.getAllPages(),
6
+ content.getAllPosts(),
7
+ content.getSiteSettings(),
8
+ ]);
9
+ const urls = [
10
+ { loc: "/", lastmod: null },
11
+ { loc: "/blog", lastmod: null },
12
+ ...pages
13
+ .filter((page) => page.id !== settings?.home_page)
14
+ .map((page) => ({ loc: `/${page.path}`, lastmod: page.published_at })),
15
+ ...posts.map((post) => ({ loc: `/blog/${post.slug}`, lastmod: post.published_at })),
16
+ ];
17
+ return `<?xml version="1.0" encoding="UTF-8"?>
18
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
19
+ ${urls
20
+ .map(
21
+ ({ loc, lastmod }) =>
22
+ ` <url><loc>${SITE_URL}${loc}</loc>${lastmod ? `<lastmod>${lastmod.slice(0, 10)}</lastmod>` : ""}</url>`,
23
+ )
24
+ .join("\n")}
25
+ </urlset>`;
26
+ });
27
+ setResponseHeader(event, "Content-Type", "application/xml; charset=utf-8");
28
+ return body;
29
+ });
@@ -0,0 +1,48 @@
1
+ /**
2
+ * A small in-memory cache of the content the pages load. Django calls
3
+ * /revalidate after editors change content, which empties it; entries also
4
+ * expire after CACHE_TTL seconds (default 300). One frontend process, one
5
+ * cache: with several replicas, put a shared cache or CDN in front instead.
6
+ */
7
+
8
+ const MAX_ENTRIES = Number(process.env.CACHE_MAX_ENTRIES ?? 500);
9
+ const TTL = Number(process.env.CACHE_TTL ?? 300) * 1000;
10
+ const entries = new Map<string, { value: unknown; expires: number }>();
11
+
12
+ export function getCached<T>(key: string): T | undefined {
13
+ const entry = entries.get(key);
14
+ if (!entry) return undefined;
15
+ if (entry.expires < Date.now()) {
16
+ entries.delete(key);
17
+ return undefined;
18
+ }
19
+ // Move to the end: the oldest entries are evicted first.
20
+ entries.delete(key);
21
+ entries.set(key, entry);
22
+ return entry.value as T;
23
+ }
24
+
25
+ export function store<T>(key: string, value: T): T {
26
+ if (TTL <= 0) return value;
27
+ entries.set(key, { value, expires: Date.now() + TTL });
28
+ while (entries.size > MAX_ENTRIES) {
29
+ const oldest = entries.keys().next().value;
30
+ if (oldest === undefined) break;
31
+ entries.delete(oldest);
32
+ }
33
+ return value;
34
+ }
35
+
36
+ /** The cached value for key, or the result of load, which is then cached. */
37
+ export async function cached<T>(key: string, load: () => Promise<T>): Promise<T> {
38
+ const hit = getCached<T>(key);
39
+ if (hit !== undefined) return hit;
40
+ return store(key, await load());
41
+ }
42
+
43
+ /** Empties the cache; returns the number of entries removed. */
44
+ export function purgeAll(): number {
45
+ const size = entries.size;
46
+ entries.clear();
47
+ return size;
48
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Content access for the server routes. The API key is read from
3
+ * API_READ_KEY on the server; pages load content through the routes in
4
+ * server/routes/_content, so the key never reaches the browser.
5
+ */
6
+
7
+ import { createClient, createContent } from "@django-stack/utils";
8
+ import type { H3Event } from "h3";
9
+ import type { Models } from "../../src/lib/headless";
10
+
11
+ export const PREVIEW_COOKIE = "stack_preview";
12
+
13
+ export const SITE_URL = (process.env.SITE_URL ?? "http://localhost:3000").replace(/\/$/, "");
14
+
15
+ export function previewToken(event: H3Event): string | null {
16
+ return getCookie(event, PREVIEW_COOKIE) ?? null;
17
+ }
18
+
19
+ /** The typed Django Headless client, with drafts when the request carries a preview cookie. */
20
+ export function getClient(event: H3Event) {
21
+ return createClient<Models>({ previewToken: previewToken(event) });
22
+ }
23
+
24
+ /** The content helpers, with drafts when the request carries a preview cookie. */
25
+ export function getContent(event: H3Event) {
26
+ return createContent(getClient(event));
27
+ }
28
+
29
+ /**
30
+ * Content for a request, from the cache unless the request is a preview.
31
+ * The key is the request's path and query.
32
+ */
33
+ export function cachedContent<T>(event: H3Event, load: () => Promise<T>): Promise<T> {
34
+ if (previewToken(event) || import.meta.dev) return load();
35
+ return cached(event.path, load);
36
+ }
37
+
38
+ /** The page number of a list, from ?page=. */
39
+ export function pageNumber(event: H3Event): number {
40
+ return Math.max(1, Number(getQuery(event).page) || 1);
41
+ }
@@ -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,5 @@
1
+ <template>
2
+ <NuxtLayout>
3
+ <NuxtPage />
4
+ </NuxtLayout>
5
+ </template>
@@ -0,0 +1,126 @@
1
+ /* A small, plain stylesheet: replace it with your own design. */
2
+ :root {
3
+ --color-bg: #ffffff;
4
+ --color-text: #1d2420;
5
+ --color-muted: #5b6862;
6
+ --color-line: #e3e8e4;
7
+ --color-accent: #0c4b33;
8
+ --color-accent-soft: #e8f3ee;
9
+ --font-body: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
10
+ --width: 72rem;
11
+ --width-text: 44rem;
12
+ color-scheme: light dark;
13
+ }
14
+
15
+ @media (prefers-color-scheme: dark) {
16
+ :root {
17
+ --color-bg: #111614;
18
+ --color-text: #e6ebe8;
19
+ --color-muted: #a1aba6;
20
+ --color-line: #2a322e;
21
+ --color-accent: #6fcf9f;
22
+ --color-accent-soft: #1b2a23;
23
+ }
24
+ }
25
+
26
+ *,
27
+ *::before,
28
+ *::after {
29
+ box-sizing: border-box;
30
+ }
31
+
32
+ body {
33
+ margin: 0;
34
+ background: var(--color-bg);
35
+ color: var(--color-text);
36
+ font-family: var(--font-body);
37
+ line-height: 1.6;
38
+ }
39
+
40
+ a {
41
+ color: var(--color-accent);
42
+ }
43
+
44
+ img {
45
+ max-width: 100%;
46
+ height: auto;
47
+ }
48
+
49
+ .container {
50
+ width: min(100% - 2rem, var(--width));
51
+ margin-inline: auto;
52
+ }
53
+
54
+ .narrow {
55
+ width: min(100% - 2rem, var(--width-text));
56
+ margin-inline: auto;
57
+ }
58
+
59
+ .muted {
60
+ color: var(--color-muted);
61
+ }
62
+
63
+ .prose h2,
64
+ .prose h3 {
65
+ line-height: 1.25;
66
+ margin-top: 2em;
67
+ }
68
+
69
+ .prose blockquote {
70
+ margin: 2rem 0;
71
+ padding-left: 1.25rem;
72
+ border-left: 4px solid var(--color-accent);
73
+ }
74
+
75
+ .button {
76
+ display: inline-block;
77
+ padding: 0.6rem 1.2rem;
78
+ border-radius: 0.5rem;
79
+ background: var(--color-accent);
80
+ color: var(--color-bg);
81
+ font-weight: 600;
82
+ text-decoration: none;
83
+ border: 0;
84
+ cursor: pointer;
85
+ font-family: inherit;
86
+ font-size: inherit;
87
+ }
88
+
89
+ .grid {
90
+ display: grid;
91
+ gap: 2rem;
92
+ grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
93
+ }
94
+
95
+ .preview-banner {
96
+ background: #fde68a;
97
+ color: #422006;
98
+ padding: 0.5rem 1rem;
99
+ text-align: center;
100
+ font-size: 0.9rem;
101
+ }
102
+
103
+ /* Blocks, from @django-stack/vue. */
104
+ .block-image {
105
+ margin: 2rem 0;
106
+ }
107
+
108
+ .block-image img {
109
+ border-radius: 0.5rem;
110
+ }
111
+
112
+ .block-image figcaption {
113
+ font-size: 0.9rem;
114
+ margin-top: 0.5rem;
115
+ }
116
+
117
+ .cta {
118
+ margin: 2.5rem 0;
119
+ padding: 2rem;
120
+ border-radius: 0.75rem;
121
+ background: var(--color-accent-soft);
122
+ }
123
+
124
+ .cta h2 {
125
+ margin-top: 0;
126
+ }
@@ -0,0 +1,66 @@
1
+ <!--
2
+ The contact form. It posts to Django's public create route for form
3
+ submissions (PUBLIC_CREATE in backend/config/settings.py), which is rate
4
+ limited per IP address. Submissions appear in the admin.
5
+ -->
6
+ <script setup lang="ts">
7
+ const status = ref("");
8
+ const sending = ref(false);
9
+
10
+ async function submit(event: Event) {
11
+ const form = event.currentTarget as HTMLFormElement;
12
+ sending.value = true;
13
+ status.value = "Sending…";
14
+ try {
15
+ const response = await fetch("/api/content.formsubmission", {
16
+ method: "POST",
17
+ headers: { "Content-Type": "application/json" },
18
+ body: JSON.stringify(Object.fromEntries(new FormData(form))),
19
+ });
20
+ if (response.status === 429) throw new Error("Too many messages; try again in a minute.");
21
+ if (!response.ok) throw new Error("The message could not be sent. Check the fields and try again.");
22
+ form.reset();
23
+ status.value = "Thank you, your message was sent.";
24
+ } catch (error) {
25
+ status.value = error instanceof Error ? error.message : String(error);
26
+ } finally {
27
+ sending.value = false;
28
+ }
29
+ }
30
+ </script>
31
+
32
+ <template>
33
+ <form class="contact-form" @submit.prevent="submit">
34
+ <label>Name <input name="name" required maxlength="200" autocomplete="name" /></label>
35
+ <label>Email <input name="email" type="email" required autocomplete="email" /></label>
36
+ <label>Message <textarea name="message" required maxlength="5000" rows="6"></textarea></label>
37
+ <button class="button" type="submit" :disabled="sending">Send</button>
38
+ <p class="status" role="status" aria-live="polite">{{ status }}</p>
39
+ </form>
40
+ </template>
41
+
42
+ <style scoped>
43
+ .contact-form {
44
+ display: grid;
45
+ gap: 1rem;
46
+ max-width: 32rem;
47
+ margin-top: 2rem;
48
+ }
49
+ label {
50
+ display: grid;
51
+ gap: 0.25rem;
52
+ font-weight: 600;
53
+ }
54
+ input,
55
+ textarea {
56
+ font: inherit;
57
+ padding: 0.5rem 0.75rem;
58
+ border: 1px solid var(--color-line);
59
+ border-radius: 0.375rem;
60
+ background: transparent;
61
+ color: inherit;
62
+ }
63
+ button {
64
+ justify-self: start;
65
+ }
66
+ </style>
@@ -0,0 +1,43 @@
1
+ <script setup lang="ts">
2
+ import { Image } from "@django-stack/vue";
3
+ import type { Author, Post } from "@django-stack/utils";
4
+
5
+ const props = defineProps<{ post: Post }>();
6
+ const author = computed(() => (typeof props.post.author === "object" ? (props.post.author as Author | null) : null));
7
+ const date = computed(() => (props.post.published_at ? new Date(props.post.published_at) : null));
8
+ </script>
9
+
10
+ <template>
11
+ <article class="card">
12
+ <NuxtLink :to="`/blog/${post.slug}`">
13
+ <Image :media="post.cover" size="card" sizes="(min-width: 40rem) 22rem, 100vw" class="cover" />
14
+ </NuxtLink>
15
+ <h3><NuxtLink :to="`/blog/${post.slug}`">{{ post.title }}</NuxtLink></h3>
16
+ <p class="muted meta">
17
+ <time v-if="date" :datetime="date.toISOString()">{{ date.toLocaleDateString("en", { dateStyle: "medium" }) }}</time>
18
+ <span v-if="author"> · {{ author.name }}</span>
19
+ </p>
20
+ <p v-if="post.excerpt">{{ post.excerpt }}</p>
21
+ </article>
22
+ </template>
23
+
24
+ <style scoped>
25
+ .card h3 {
26
+ margin: 0.75rem 0 0.25rem;
27
+ line-height: 1.3;
28
+ }
29
+ .card h3 a {
30
+ color: inherit;
31
+ text-decoration: none;
32
+ }
33
+ .card :deep(.cover) {
34
+ width: 100%;
35
+ aspect-ratio: 16 / 10;
36
+ object-fit: cover;
37
+ border-radius: 0.5rem;
38
+ }
39
+ .meta {
40
+ font-size: 0.9rem;
41
+ margin: 0;
42
+ }
43
+ </style>
@@ -0,0 +1,33 @@
1
+ <script setup lang="ts">
2
+ import type { Paginated, Post } from "@django-stack/utils";
3
+
4
+ const props = defineProps<{
5
+ result: Paginated<Post>;
6
+ /** The URL of the list, without the page parameter. */
7
+ base: string;
8
+ }>();
9
+ const current = computed(() => props.result.pagination.current);
10
+ const pages = computed(() => props.result.pagination.pages);
11
+ const pageUrl = (page: number) => (page === 1 ? props.base : `${props.base}?page=${page}`);
12
+ </script>
13
+
14
+ <template>
15
+ <p v-if="result.data.length === 0" class="muted">No posts yet.</p>
16
+ <div class="grid">
17
+ <PostCard v-for="post in result.data" :key="post.id" :post="post" />
18
+ </div>
19
+ <nav v-if="pages > 1" class="pagination" aria-label="Pages">
20
+ <NuxtLink v-if="current > 1" :to="pageUrl(current - 1)">← Newer</NuxtLink>
21
+ <span class="muted">Page {{ current }} of {{ pages }}</span>
22
+ <NuxtLink v-if="current < pages" :to="pageUrl(current + 1)">Older →</NuxtLink>
23
+ </nav>
24
+ </template>
25
+
26
+ <style scoped>
27
+ .pagination {
28
+ display: flex;
29
+ gap: 1.5rem;
30
+ justify-content: center;
31
+ margin: 3rem 0;
32
+ }
33
+ </style>