@django-stack/cli 0.2.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.
Files changed (140) hide show
  1. package/README.md +1 -1
  2. package/dist/bin.js +861 -86
  3. package/dist/index.d.ts +52 -40
  4. package/dist/index.js +1 -1
  5. package/dist/{stack-CTyqYWCV.js → scaffold-DbgGYEsc.js} +216 -183
  6. package/package.json +1 -1
  7. package/templates/backend/pyproject.toml +1 -1
  8. package/templates/base/AGENTS.md +7 -11
  9. package/templates/base/README.md +4 -4
  10. package/templates/base/SECURITY.md +1 -1
  11. package/templates/base/gitignore +7 -0
  12. package/templates/base/package.json +1 -1
  13. package/templates/frontend-astro/AGENTS.md +43 -0
  14. package/templates/frontend-astro/package.json +1 -1
  15. package/templates/frontend-astro/template.json +5 -0
  16. package/templates/frontend-next/.dockerignore +3 -0
  17. package/templates/frontend-next/AGENTS.md +59 -0
  18. package/templates/frontend-next/Dockerfile +32 -0
  19. package/templates/frontend-next/next.config.ts +19 -0
  20. package/templates/frontend-next/package.json +34 -0
  21. package/templates/frontend-next/public/favicon.svg +1 -0
  22. package/templates/frontend-next/src/app/[...path]/page.tsx +28 -0
  23. package/templates/frontend-next/src/app/blog/[slug]/page.tsx +60 -0
  24. package/templates/frontend-next/src/app/blog/page.tsx +20 -0
  25. package/templates/frontend-next/src/app/category/[slug]/page.tsx +34 -0
  26. package/templates/frontend-next/src/app/error.tsx +13 -0
  27. package/templates/frontend-next/src/app/globals.css +250 -0
  28. package/templates/frontend-next/src/app/layout.tsx +70 -0
  29. package/templates/frontend-next/src/app/not-found.tsx +15 -0
  30. package/templates/frontend-next/src/app/page.tsx +35 -0
  31. package/templates/frontend-next/src/app/preview/exit/route.ts +3 -0
  32. package/templates/frontend-next/src/app/preview/route.ts +4 -0
  33. package/templates/frontend-next/src/app/revalidate/route.ts +8 -0
  34. package/templates/frontend-next/src/app/robots.txt/route.ts +13 -0
  35. package/templates/frontend-next/src/app/rss.xml/route.ts +19 -0
  36. package/templates/frontend-next/src/app/sitemap.xml/route.ts +28 -0
  37. package/templates/frontend-next/src/app/tag/[slug]/page.tsx +29 -0
  38. package/templates/frontend-next/src/components/ContactForm.tsx +55 -0
  39. package/templates/frontend-next/src/components/NavLink.tsx +15 -0
  40. package/templates/frontend-next/src/components/PostCard.tsx +25 -0
  41. package/templates/frontend-next/src/components/PostList.tsx +28 -0
  42. package/templates/frontend-next/src/lib/api.ts +71 -0
  43. package/templates/frontend-next/src/lib/headless.ts +344 -0
  44. package/templates/frontend-next/src/lib/rss.ts +44 -0
  45. package/templates/frontend-next/src/proxy.ts +34 -0
  46. package/templates/frontend-next/template.json +5 -0
  47. package/templates/frontend-next/test/rss.test.ts +17 -0
  48. package/templates/frontend-next/tsconfig.json +42 -0
  49. package/templates/frontend-next/vendor/.gitkeep +0 -0
  50. package/templates/frontend-next/vitest.config.ts +7 -0
  51. package/templates/frontend-nuxt/.dockerignore +4 -0
  52. package/templates/frontend-nuxt/AGENTS.md +64 -0
  53. package/templates/frontend-nuxt/Dockerfile +32 -0
  54. package/templates/frontend-nuxt/nuxt.config.ts +69 -0
  55. package/templates/frontend-nuxt/package.json +34 -0
  56. package/templates/frontend-nuxt/public/favicon.svg +1 -0
  57. package/templates/frontend-nuxt/server/plugins/media-origins.ts +27 -0
  58. package/templates/frontend-nuxt/server/routes/_content/category/[slug].get.ts +13 -0
  59. package/templates/frontend-nuxt/server/routes/_content/home.get.ts +10 -0
  60. package/templates/frontend-nuxt/server/routes/_content/layout.get.ts +13 -0
  61. package/templates/frontend-nuxt/server/routes/_content/page.get.ts +7 -0
  62. package/templates/frontend-nuxt/server/routes/_content/post/[slug].get.ts +7 -0
  63. package/templates/frontend-nuxt/server/routes/_content/posts.get.ts +4 -0
  64. package/templates/frontend-nuxt/server/routes/_content/redirect.get.ts +7 -0
  65. package/templates/frontend-nuxt/server/routes/_content/tag/[slug].get.ts +13 -0
  66. package/templates/frontend-nuxt/server/routes/preview/exit.get.ts +4 -0
  67. package/templates/frontend-nuxt/server/routes/preview/index.get.ts +26 -0
  68. package/templates/frontend-nuxt/server/routes/revalidate.post.ts +15 -0
  69. package/templates/frontend-nuxt/server/routes/robots.txt.get.ts +4 -0
  70. package/templates/frontend-nuxt/server/routes/rss.xml.get.ts +19 -0
  71. package/templates/frontend-nuxt/server/routes/sitemap.xml.get.ts +29 -0
  72. package/templates/frontend-nuxt/server/utils/cache.ts +48 -0
  73. package/templates/frontend-nuxt/server/utils/content.ts +41 -0
  74. package/templates/frontend-nuxt/server/utils/rss.ts +44 -0
  75. package/templates/frontend-nuxt/src/app.vue +5 -0
  76. package/templates/frontend-nuxt/src/assets/main.css +126 -0
  77. package/templates/frontend-nuxt/src/components/ContactForm.vue +66 -0
  78. package/templates/frontend-nuxt/src/components/PostCard.vue +43 -0
  79. package/templates/frontend-nuxt/src/components/PostList.vue +33 -0
  80. package/templates/frontend-nuxt/src/composables/useContent.ts +27 -0
  81. package/templates/frontend-nuxt/src/composables/useLayoutContent.ts +4 -0
  82. package/templates/frontend-nuxt/src/composables/usePageSeo.ts +33 -0
  83. package/templates/frontend-nuxt/src/error.vue +23 -0
  84. package/templates/frontend-nuxt/src/layouts/default.vue +99 -0
  85. package/templates/frontend-nuxt/src/lib/headless.ts +344 -0
  86. package/templates/frontend-nuxt/src/pages/[...path].vue +16 -0
  87. package/templates/frontend-nuxt/src/pages/blog/[slug].vue +43 -0
  88. package/templates/frontend-nuxt/src/pages/blog/index.vue +12 -0
  89. package/templates/frontend-nuxt/src/pages/category/[slug].vue +14 -0
  90. package/templates/frontend-nuxt/src/pages/index.vue +18 -0
  91. package/templates/frontend-nuxt/src/pages/tag/[slug].vue +13 -0
  92. package/templates/frontend-nuxt/template.json +5 -0
  93. package/templates/frontend-nuxt/test/cache.test.ts +20 -0
  94. package/templates/frontend-nuxt/test/rss.test.ts +17 -0
  95. package/templates/frontend-nuxt/tsconfig.json +9 -0
  96. package/templates/frontend-nuxt/vendor/.gitkeep +0 -0
  97. package/templates/frontend-nuxt/vitest.config.ts +7 -0
  98. package/templates/frontend-sveltekit/.dockerignore +4 -0
  99. package/templates/frontend-sveltekit/AGENTS.md +53 -0
  100. package/templates/frontend-sveltekit/Dockerfile +32 -0
  101. package/templates/frontend-sveltekit/package.json +39 -0
  102. package/templates/frontend-sveltekit/src/app.css +126 -0
  103. package/templates/frontend-sveltekit/src/app.d.ts +12 -0
  104. package/templates/frontend-sveltekit/src/app.html +12 -0
  105. package/templates/frontend-sveltekit/src/hooks.server.ts +70 -0
  106. package/templates/frontend-sveltekit/src/lib/components/ContactForm.svelte +65 -0
  107. package/templates/frontend-sveltekit/src/lib/components/PostCard.svelte +41 -0
  108. package/templates/frontend-sveltekit/src/lib/components/PostList.svelte +39 -0
  109. package/templates/frontend-sveltekit/src/lib/headless.ts +344 -0
  110. package/templates/frontend-sveltekit/src/lib/server/api.ts +28 -0
  111. package/templates/frontend-sveltekit/src/lib/server/cache.ts +58 -0
  112. package/templates/frontend-sveltekit/src/lib/server/rss.ts +44 -0
  113. package/templates/frontend-sveltekit/src/routes/+error.svelte +14 -0
  114. package/templates/frontend-sveltekit/src/routes/+layout.server.ts +18 -0
  115. package/templates/frontend-sveltekit/src/routes/+layout.svelte +112 -0
  116. package/templates/frontend-sveltekit/src/routes/+page.server.ts +10 -0
  117. package/templates/frontend-sveltekit/src/routes/+page.svelte +19 -0
  118. package/templates/frontend-sveltekit/src/routes/[...path]/+page.server.ts +9 -0
  119. package/templates/frontend-sveltekit/src/routes/[...path]/+page.svelte +12 -0
  120. package/templates/frontend-sveltekit/src/routes/blog/+page.server.ts +7 -0
  121. package/templates/frontend-sveltekit/src/routes/blog/+page.svelte +10 -0
  122. package/templates/frontend-sveltekit/src/routes/blog/[slug]/+page.server.ts +9 -0
  123. package/templates/frontend-sveltekit/src/routes/blog/[slug]/+page.svelte +41 -0
  124. package/templates/frontend-sveltekit/src/routes/category/[slug]/+page.server.ts +11 -0
  125. package/templates/frontend-sveltekit/src/routes/category/[slug]/+page.svelte +12 -0
  126. package/templates/frontend-sveltekit/src/routes/preview/+server.ts +27 -0
  127. package/templates/frontend-sveltekit/src/routes/preview/exit/+server.ts +8 -0
  128. package/templates/frontend-sveltekit/src/routes/revalidate/+server.ts +16 -0
  129. package/templates/frontend-sveltekit/src/routes/robots.txt/+server.ts +7 -0
  130. package/templates/frontend-sveltekit/src/routes/rss.xml/+server.ts +20 -0
  131. package/templates/frontend-sveltekit/src/routes/sitemap.xml/+server.ts +29 -0
  132. package/templates/frontend-sveltekit/src/routes/tag/[slug]/+page.server.ts +11 -0
  133. package/templates/frontend-sveltekit/src/routes/tag/[slug]/+page.svelte +11 -0
  134. package/templates/frontend-sveltekit/static/favicon.svg +1 -0
  135. package/templates/frontend-sveltekit/template.json +5 -0
  136. package/templates/frontend-sveltekit/test/cache.test.ts +22 -0
  137. package/templates/frontend-sveltekit/test/rss.test.ts +17 -0
  138. package/templates/frontend-sveltekit/tsconfig.json +5 -0
  139. package/templates/frontend-sveltekit/vendor/.gitkeep +0 -0
  140. package/templates/frontend-sveltekit/vite.config.ts +47 -0
@@ -0,0 +1,27 @@
1
+ import type { NitroFetchRequest } from "nitropack";
2
+
3
+ /**
4
+ * Content from a route in server/routes/_content. When it is not found, a
5
+ * redirect that editors manage in the admin is followed; otherwise the error
6
+ * page shows with the status, such as 404.
7
+ */
8
+ export async function useContent<R extends NitroFetchRequest>(url: R, query?: () => Record<string, unknown>) {
9
+ const nuxtApp = useNuxtApp();
10
+ const route = useRoute();
11
+ const result = await useFetch(url, { query: computed(() => query?.() ?? {}) });
12
+ const error = result.error.value;
13
+ if (error) {
14
+ const statusCode = error.statusCode ?? 500;
15
+ if (statusCode === 404) {
16
+ const target = await $fetch("/_content/redirect", { query: { path: route.path } }).catch(() => null);
17
+ if (target) {
18
+ await nuxtApp.runWithContext(() =>
19
+ navigateTo(target.new_path, { redirectCode: target.permanent ? 301 : 302 }),
20
+ );
21
+ return result;
22
+ }
23
+ }
24
+ throw createError({ statusCode, statusMessage: error.statusMessage, fatal: true });
25
+ }
26
+ return result;
27
+ }
@@ -0,0 +1,4 @@
1
+ /** The site settings, the menus and whether drafts are shown, for every page. */
2
+ export function useLayoutContent() {
3
+ return useFetch("/_content/layout", { key: "layout", dedupe: "defer" });
4
+ }
@@ -0,0 +1,33 @@
1
+ import { type SeoInput, seo } from "@django-stack/utils";
2
+ import type { MaybeRefOrGetter } from "vue";
3
+
4
+ /**
5
+ * The title, description, canonical URL, Open Graph and Twitter tags of a
6
+ * page. It awaits nothing, so call it anywhere in `<script setup>`.
7
+ */
8
+ export function usePageSeo(
9
+ page: MaybeRefOrGetter<SeoInput | null | undefined>,
10
+ type?: "website" | "article",
11
+ ) {
12
+ const route = useRoute();
13
+ // The layout loads the same data under the same key: this does not fetch again.
14
+ const { data: layout } = useLayoutContent();
15
+ const meta = computed(() =>
16
+ seo({
17
+ page: toValue(page),
18
+ settings: layout.value?.settings ?? null,
19
+ siteUrl: layout.value?.siteUrl ?? "",
20
+ path: route.path,
21
+ type,
22
+ }),
23
+ );
24
+ useHead(() => ({
25
+ title: meta.value.title,
26
+ link: [{ rel: "canonical", href: meta.value.canonical }],
27
+ meta: [
28
+ ...(meta.value.description ? [{ name: "description", content: meta.value.description }] : []),
29
+ ...Object.entries(meta.value.openGraph).map(([property, content]) => ({ property, content })),
30
+ ...Object.entries(meta.value.twitter).map(([name, content]) => ({ name, content })),
31
+ ],
32
+ }));
33
+ }
@@ -0,0 +1,23 @@
1
+ <script setup lang="ts">
2
+ import type { NuxtError } from "#app";
3
+
4
+ const props = defineProps<{ error: NuxtError }>();
5
+ const notFound = computed(() => props.error.statusCode === 404);
6
+ useHead({ title: notFound.value ? "Page not found" : "Something went wrong" });
7
+ </script>
8
+
9
+ <template>
10
+ <NuxtLayout>
11
+ <section class="narrow">
12
+ <template v-if="notFound">
13
+ <h1>Page not found</h1>
14
+ <p>The page you are looking for does not exist or has moved.</p>
15
+ </template>
16
+ <template v-else>
17
+ <h1>Something went wrong</h1>
18
+ <p>The page could not be shown. Try again in a moment.</p>
19
+ </template>
20
+ <p><a href="/">Go to the home page</a></p>
21
+ </section>
22
+ </NuxtLayout>
23
+ </template>
@@ -0,0 +1,99 @@
1
+ <script setup lang="ts">
2
+ import { Image, RichText } from "@django-stack/vue";
3
+ import { linkHref } from "@django-stack/utils";
4
+
5
+ const { data } = await useLayoutContent();
6
+ const route = useRoute();
7
+ const isCurrent = (href: string) => href === route.path || (href !== "/" && route.path.startsWith(`${href}/`));
8
+
9
+ useHead(() => ({
10
+ link: [
11
+ { rel: "alternate", type: "application/rss+xml", title: data.value?.settings?.site_name ?? "RSS", href: "/rss.xml" },
12
+ ],
13
+ }));
14
+ </script>
15
+
16
+ <template>
17
+ <div v-if="data?.preview" class="preview-banner">
18
+ Preview: drafts are shown. <a href="/preview/exit">Leave preview</a>
19
+ </div>
20
+ <header class="site-header">
21
+ <div class="container bar">
22
+ <NuxtLink class="brand" to="/">
23
+ <Image v-if="data?.settings?.logo" :media="data.settings.logo" size="thumbnail" :alt="data.settings.site_name" eager />
24
+ <template v-else>{{ data?.settings?.site_name }}</template>
25
+ </NuxtLink>
26
+ <nav aria-label="Main">
27
+ <NuxtLink
28
+ v-for="item in data?.mainMenu?.items ?? []"
29
+ :key="item.id"
30
+ :to="linkHref(item)"
31
+ :aria-current="isCurrent(linkHref(item)) ? 'page' : undefined"
32
+ >
33
+ {{ item.value.label }}
34
+ </NuxtLink>
35
+ </nav>
36
+ </div>
37
+ </header>
38
+ <main>
39
+ <slot />
40
+ </main>
41
+ <footer class="site-footer">
42
+ <div class="container bar muted">
43
+ <RichText :html="data?.settings?.footer_text" class="footer-text" />
44
+ <nav aria-label="Footer">
45
+ <NuxtLink v-for="item in data?.footerMenu?.items ?? []" :key="item.id" :to="linkHref(item)">
46
+ {{ item.value.label }}
47
+ </NuxtLink>
48
+ <a v-for="(url, name) in data?.settings?.social_links ?? {}" :key="name" :href="url" rel="me">{{ name }}</a>
49
+ </nav>
50
+ </div>
51
+ </footer>
52
+ </template>
53
+
54
+ <style scoped>
55
+ .site-header {
56
+ border-bottom: 1px solid var(--color-line);
57
+ }
58
+ .bar {
59
+ display: flex;
60
+ flex-wrap: wrap;
61
+ gap: 1rem;
62
+ align-items: center;
63
+ justify-content: space-between;
64
+ padding-block: 1rem;
65
+ }
66
+ .brand {
67
+ font-weight: 700;
68
+ font-size: 1.2rem;
69
+ color: inherit;
70
+ text-decoration: none;
71
+ }
72
+ .brand :deep(img) {
73
+ height: 2.5rem;
74
+ width: auto;
75
+ }
76
+ nav {
77
+ display: flex;
78
+ flex-wrap: wrap;
79
+ gap: 1.25rem;
80
+ }
81
+ nav a {
82
+ text-decoration: none;
83
+ }
84
+ nav a[aria-current="page"] {
85
+ font-weight: 600;
86
+ text-decoration: underline;
87
+ }
88
+ main {
89
+ min-height: 70vh;
90
+ padding-block: 2rem 4rem;
91
+ }
92
+ .site-footer {
93
+ border-top: 1px solid var(--color-line);
94
+ font-size: 0.9rem;
95
+ }
96
+ .site-footer :deep(.footer-text p) {
97
+ margin: 0;
98
+ }
99
+ </style>
@@ -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,16 @@
1
+ <script setup lang="ts">
2
+ import { Blocks } from "@django-stack/vue";
3
+
4
+ const route = useRoute();
5
+ const path = computed(() => ([] as string[]).concat(route.params.path ?? []).join("/"));
6
+ const { data: page } = await useContent("/_content/page", () => ({ path: path.value }));
7
+ usePageSeo(page);
8
+ </script>
9
+
10
+ <template>
11
+ <article v-if="page" class="narrow">
12
+ <h1>{{ page.title }}</h1>
13
+ <Blocks :blocks="page.body" />
14
+ <ContactForm v-if="page.path === 'contact'" />
15
+ </article>
16
+ </template>
@@ -0,0 +1,43 @@
1
+ <script setup lang="ts">
2
+ import { Blocks, Image } from "@django-stack/vue";
3
+ import type { Author, Taxonomy } from "@django-stack/utils";
4
+
5
+ const route = useRoute();
6
+ const { data: post } = await useContent(`/_content/post/${route.params.slug}`);
7
+ usePageSeo(post, "article");
8
+
9
+ const author = computed(() => (typeof post.value?.author === "object" ? (post.value.author as Author | null) : null));
10
+ const categories = computed(() => (post.value?.categories ?? []).filter((item): item is Taxonomy => typeof item === "object"));
11
+ const tags = computed(() => (post.value?.tags ?? []).filter((item): item is Taxonomy => typeof item === "object"));
12
+ const date = computed(() => (post.value?.published_at ? new Date(post.value.published_at) : null));
13
+ </script>
14
+
15
+ <template>
16
+ <article v-if="post" class="narrow">
17
+ <p class="muted">
18
+ <time v-if="date" :datetime="date.toISOString()">{{ date.toLocaleDateString("en", { dateStyle: "long" }) }}</time>
19
+ <span v-if="author"> · {{ author.name }}</span>
20
+ <span v-for="category in categories" :key="category.slug">
21
+ · <NuxtLink :to="`/category/${category.slug}`">{{ category.name }}</NuxtLink>
22
+ </span>
23
+ </p>
24
+ <h1>{{ post.title }}</h1>
25
+ <Image :media="post.cover" size="content" sizes="(min-width: 48rem) 44rem, 100vw" class="cover" eager />
26
+ <Blocks :blocks="post.body" />
27
+ <p v-if="tags.length > 0" class="tags">
28
+ <NuxtLink v-for="tag in tags" :key="tag.slug" :to="`/tag/${tag.slug}`">#{{ tag.name }}</NuxtLink>
29
+ </p>
30
+ </article>
31
+ </template>
32
+
33
+ <style scoped>
34
+ article :deep(.cover) {
35
+ width: 100%;
36
+ border-radius: 0.75rem;
37
+ }
38
+ .tags {
39
+ display: flex;
40
+ gap: 1rem;
41
+ margin-top: 3rem;
42
+ }
43
+ </style>
@@ -0,0 +1,12 @@
1
+ <script setup lang="ts">
2
+ const route = useRoute();
3
+ const { data: result } = await useContent("/_content/posts", () => ({ page: route.query.page }));
4
+ usePageSeo({ title: "Blog" });
5
+ </script>
6
+
7
+ <template>
8
+ <section class="container">
9
+ <h1>Blog</h1>
10
+ <PostList v-if="result" :result="result" base="/blog" />
11
+ </section>
12
+ </template>
@@ -0,0 +1,14 @@
1
+ <script setup lang="ts">
2
+ const route = useRoute();
3
+ const { data } = await useContent(`/_content/category/${route.params.slug}`, () => ({ page: route.query.page }));
4
+ usePageSeo(() => ({ title: data.value?.category.name, seo_description: data.value?.category.description ?? "" }));
5
+ </script>
6
+
7
+ <template>
8
+ <section v-if="data" class="container">
9
+ <p class="muted">Category</p>
10
+ <h1>{{ data.category.name }}</h1>
11
+ <p v-if="data.category.description">{{ data.category.description }}</p>
12
+ <PostList :result="data.result" :base="`/category/${data.category.slug}`" />
13
+ </section>
14
+ </template>
@@ -0,0 +1,18 @@
1
+ <script setup lang="ts">
2
+ import { Blocks } from "@django-stack/vue";
3
+
4
+ const { data } = await useContent("/_content/home");
5
+ usePageSeo(() => data.value?.home);
6
+ </script>
7
+
8
+ <template>
9
+ <article v-if="data?.home" class="narrow">
10
+ <h1>{{ data.home.title }}</h1>
11
+ <Blocks :blocks="data.home.body" />
12
+ </article>
13
+ <section v-else class="container">
14
+ <h1>{{ data?.settings?.site_name ?? "Latest posts" }}</h1>
15
+ <p v-if="data?.settings?.tagline" class="muted">{{ data.settings.tagline }}</p>
16
+ <PostList v-if="data?.posts" :result="data.posts" base="/" />
17
+ </section>
18
+ </template>
@@ -0,0 +1,13 @@
1
+ <script setup lang="ts">
2
+ const route = useRoute();
3
+ const { data } = await useContent(`/_content/tag/${route.params.slug}`, () => ({ page: route.query.page }));
4
+ usePageSeo(() => ({ title: `#${data.value?.tag.name}` }));
5
+ </script>
6
+
7
+ <template>
8
+ <section v-if="data" class="container">
9
+ <p class="muted">Tag</p>
10
+ <h1>#{{ data.tag.name }}</h1>
11
+ <PostList :result="data.result" :base="`/tag/${data.tag.slug}`" />
12
+ </section>
13
+ </template>
@@ -0,0 +1,5 @@
1
+ {
2
+ "label": "Nuxt",
3
+ "url": "https://nuxt.com",
4
+ "security": "https://github.com/nuxt/nuxt/security"
5
+ }
@@ -0,0 +1,20 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { cached, getCached, purgeAll } from "../server/utils/cache";
3
+
4
+ describe("content cache", () => {
5
+ it("stores, serves and purges content", async () => {
6
+ purgeAll();
7
+ expect(getCached("/_content/page?path=about")).toBeUndefined();
8
+ let loads = 0;
9
+ const load = async () => {
10
+ loads += 1;
11
+ return { title: "About" };
12
+ };
13
+ expect(await cached("/_content/page?path=about", load)).toEqual({ title: "About" });
14
+ expect(await cached("/_content/page?path=about", load)).toEqual({ title: "About" });
15
+ expect(loads).toBe(1);
16
+
17
+ expect(purgeAll()).toBe(1);
18
+ expect(getCached("/_content/page?path=about")).toBeUndefined();
19
+ });
20
+ });