@django-stack/cli 0.0.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -3
- package/dist/bin.js +2282 -2
- package/dist/index.d.ts +252 -1
- package/dist/index.js +1 -4
- package/dist/stack-CTyqYWCV.js +800 -0
- package/package.json +15 -7
- package/templates/backend/.dockerignore +11 -0
- package/templates/backend/Dockerfile +45 -0
- package/templates/backend/config/__init__.py +0 -0
- package/templates/backend/config/asgi.py +7 -0
- package/templates/backend/config/settings.py +87 -0
- package/templates/backend/config/studio.py +20 -0
- package/templates/backend/config/urls.py +9 -0
- package/templates/backend/config/wsgi.py +7 -0
- package/templates/backend/conftest.py +7 -0
- package/templates/backend/content/__init__.py +0 -0
- package/templates/backend/content/admin.py +107 -0
- package/templates/backend/content/apps.py +6 -0
- package/templates/backend/content/blocks.py +51 -0
- package/templates/backend/content/migrations/0001_initial.py +418 -0
- package/templates/backend/content/migrations/__init__.py +0 -0
- package/templates/backend/content/models.py +234 -0
- package/templates/backend/content/seed.py +153 -0
- package/templates/backend/content/tests/__init__.py +0 -0
- package/templates/backend/content/tests/test_api.py +73 -0
- package/templates/backend/entrypoint.sh +27 -0
- package/templates/backend/manage.py +16 -0
- package/templates/backend/pyproject.toml +33 -0
- package/templates/backend/vendor/.gitkeep +0 -0
- package/templates/base/.github/workflows/deploy.yml +70 -0
- package/templates/base/AGENTS.md +79 -0
- package/templates/base/CLAUDE.md +1 -0
- package/templates/base/Caddyfile +51 -0
- package/templates/base/README.md +87 -0
- package/templates/base/SECURITY.md +12 -0
- package/templates/base/compose.yaml +282 -0
- package/templates/base/env.example +83 -0
- package/templates/base/gitignore +15 -0
- package/templates/base/package.json +21 -0
- package/templates/frontend-astro/.dockerignore +4 -0
- package/templates/frontend-astro/Dockerfile +32 -0
- package/templates/frontend-astro/astro.config.mjs +38 -0
- package/templates/frontend-astro/package.json +31 -0
- package/templates/frontend-astro/public/favicon.svg +1 -0
- package/templates/frontend-astro/src/components/Blocks.astro +32 -0
- package/templates/frontend-astro/src/components/ContactForm.astro +68 -0
- package/templates/frontend-astro/src/components/Image.astro +34 -0
- package/templates/frontend-astro/src/components/PostCard.astro +41 -0
- package/templates/frontend-astro/src/components/PostList.astro +39 -0
- package/templates/frontend-astro/src/components/RichText.astro +14 -0
- package/templates/frontend-astro/src/components/Seo.astro +20 -0
- package/templates/frontend-astro/src/components/blocks/CallToAction.astro +23 -0
- package/templates/frontend-astro/src/components/blocks/Image.astro +24 -0
- package/templates/frontend-astro/src/components/blocks/Quote.astro +10 -0
- package/templates/frontend-astro/src/components/blocks/Text.astro +8 -0
- package/templates/frontend-astro/src/layouts/Base.astro +122 -0
- package/templates/frontend-astro/src/lib/api.ts +24 -0
- package/templates/frontend-astro/src/lib/cache.ts +58 -0
- package/templates/frontend-astro/src/lib/headless.ts +344 -0
- package/templates/frontend-astro/src/middleware.ts +69 -0
- package/templates/frontend-astro/src/pages/404.astro +13 -0
- package/templates/frontend-astro/src/pages/[...slug].astro +21 -0
- package/templates/frontend-astro/src/pages/blog/[slug].astro +50 -0
- package/templates/frontend-astro/src/pages/blog/index.astro +15 -0
- package/templates/frontend-astro/src/pages/category/[slug].astro +25 -0
- package/templates/frontend-astro/src/pages/index.astro +28 -0
- package/templates/frontend-astro/src/pages/preview/exit.ts +7 -0
- package/templates/frontend-astro/src/pages/preview/index.ts +26 -0
- package/templates/frontend-astro/src/pages/revalidate.ts +19 -0
- package/templates/frontend-astro/src/pages/robots.txt.ts +7 -0
- package/templates/frontend-astro/src/pages/rss.xml.ts +19 -0
- package/templates/frontend-astro/src/pages/sitemap.xml.ts +29 -0
- package/templates/frontend-astro/src/pages/tag/[slug].astro +24 -0
- package/templates/frontend-astro/src/styles/global.css +101 -0
- package/templates/frontend-astro/test/blocks.test.ts +42 -0
- package/templates/frontend-astro/test/cache.test.ts +22 -0
- package/templates/frontend-astro/tsconfig.json +5 -0
- package/templates/frontend-astro/vendor/.gitkeep +0 -0
- package/templates/frontend-astro/vitest.config.ts +8 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { Post, Author } from "@django-stack/utils";
|
|
3
|
+
import Image from "./Image.astro";
|
|
4
|
+
|
|
5
|
+
const { post } = Astro.props as { post: Post };
|
|
6
|
+
const author = typeof post.author === "object" ? (post.author as Author | null) : null;
|
|
7
|
+
const date = post.published_at ? new Date(post.published_at) : null;
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
<article class="card">
|
|
11
|
+
<a href={`/blog/${post.slug}`}>
|
|
12
|
+
<Image media={post.cover} size="card" sizes="(min-width: 40rem) 22rem, 100vw" class="cover" />
|
|
13
|
+
</a>
|
|
14
|
+
<h3><a href={`/blog/${post.slug}`}>{post.title}</a></h3>
|
|
15
|
+
<p class="muted meta">
|
|
16
|
+
{date && <time datetime={date.toISOString()}>{date.toLocaleDateString("en", { dateStyle: "medium" })}</time>}
|
|
17
|
+
{author && <span> · {author.name}</span>}
|
|
18
|
+
</p>
|
|
19
|
+
{post.excerpt && <p>{post.excerpt}</p>}
|
|
20
|
+
</article>
|
|
21
|
+
|
|
22
|
+
<style>
|
|
23
|
+
.card h3 {
|
|
24
|
+
margin: 0.75rem 0 0.25rem;
|
|
25
|
+
line-height: 1.3;
|
|
26
|
+
}
|
|
27
|
+
.card h3 a {
|
|
28
|
+
color: inherit;
|
|
29
|
+
text-decoration: none;
|
|
30
|
+
}
|
|
31
|
+
.card :global(.cover) {
|
|
32
|
+
width: 100%;
|
|
33
|
+
aspect-ratio: 16 / 10;
|
|
34
|
+
object-fit: cover;
|
|
35
|
+
border-radius: 0.5rem;
|
|
36
|
+
}
|
|
37
|
+
.meta {
|
|
38
|
+
font-size: 0.9rem;
|
|
39
|
+
margin: 0;
|
|
40
|
+
}
|
|
41
|
+
</style>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { Paginated, Post } from "@django-stack/utils";
|
|
3
|
+
import PostCard from "./PostCard.astro";
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
result: Paginated<Post>;
|
|
7
|
+
/** The URL of the list, without the page parameter. */
|
|
8
|
+
base: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const { result, base } = Astro.props;
|
|
12
|
+
const { current, pages } = result.pagination;
|
|
13
|
+
const pageUrl = (page: number) => (page === 1 ? base : `${base}?page=${page}`);
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
{result.data.length === 0 && <p class="muted">No posts yet.</p>}
|
|
17
|
+
<div class="grid">
|
|
18
|
+
{result.data.map((post) => <PostCard post={post} />)}
|
|
19
|
+
</div>
|
|
20
|
+
{
|
|
21
|
+
pages > 1 && (
|
|
22
|
+
<nav class="pagination" aria-label="Pages">
|
|
23
|
+
{current > 1 && <a href={pageUrl(current - 1)}>← Newer</a>}
|
|
24
|
+
<span class="muted">
|
|
25
|
+
Page {current} of {pages}
|
|
26
|
+
</span>
|
|
27
|
+
{current < pages && <a href={pageUrl(current + 1)}>Older →</a>}
|
|
28
|
+
</nav>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
<style>
|
|
33
|
+
.pagination {
|
|
34
|
+
display: flex;
|
|
35
|
+
gap: 1.5rem;
|
|
36
|
+
justify-content: center;
|
|
37
|
+
margin: 3rem 0;
|
|
38
|
+
}
|
|
39
|
+
</style>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* HTML from a Content Studio HTMLField. Django sanitizes it with an
|
|
4
|
+
* allowlist when it is saved, so it is safe to render as is.
|
|
5
|
+
*/
|
|
6
|
+
interface Props {
|
|
7
|
+
html: string | null | undefined;
|
|
8
|
+
class?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const { html, class: className = "prose" } = Astro.props;
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
{html && <div class={className} set:html={html} />}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { seo, type SeoInput } from "@django-stack/utils";
|
|
3
|
+
import type { SiteSettings } from "@django-stack/utils";
|
|
4
|
+
import { SITE_URL } from "../lib/api";
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
page?: SeoInput | null;
|
|
8
|
+
settings: SiteSettings | null;
|
|
9
|
+
type?: "website" | "article";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const { page, settings, type } = Astro.props;
|
|
13
|
+
const meta = seo({ page, settings, siteUrl: SITE_URL, path: Astro.url.pathname, type });
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
<title>{meta.title}</title>
|
|
17
|
+
{meta.description && <meta name="description" content={meta.description} />}
|
|
18
|
+
<link rel="canonical" href={meta.canonical} />
|
|
19
|
+
{Object.entries(meta.openGraph).map(([property, content]) => <meta property={property} content={content} />)}
|
|
20
|
+
{Object.entries(meta.twitter).map(([name, content]) => <meta name={name} content={content} />)}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { CallToActionBlock } from "@django-stack/utils";
|
|
3
|
+
|
|
4
|
+
const { block } = Astro.props as { block: CallToActionBlock };
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
<aside class="cta">
|
|
8
|
+
<h2>{block.value.title}</h2>
|
|
9
|
+
{block.value.text && <p>{block.value.text}</p>}
|
|
10
|
+
<a class="button" href={block.value.button_url}>{block.value.button_label}</a>
|
|
11
|
+
</aside>
|
|
12
|
+
|
|
13
|
+
<style>
|
|
14
|
+
.cta {
|
|
15
|
+
margin: 2.5rem 0;
|
|
16
|
+
padding: 2rem;
|
|
17
|
+
border-radius: 0.75rem;
|
|
18
|
+
background: var(--color-accent-soft);
|
|
19
|
+
}
|
|
20
|
+
.cta h2 {
|
|
21
|
+
margin-top: 0;
|
|
22
|
+
}
|
|
23
|
+
</style>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { ImageBlock } from "@django-stack/utils";
|
|
3
|
+
import Image from "../Image.astro";
|
|
4
|
+
|
|
5
|
+
const { block } = Astro.props as { block: ImageBlock };
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
<figure class="block-image">
|
|
9
|
+
<Image media={block.value.image} size="content" sizes="(min-width: 48rem) 44rem, 100vw" />
|
|
10
|
+
{block.value.caption && <figcaption class="muted">{block.value.caption}</figcaption>}
|
|
11
|
+
</figure>
|
|
12
|
+
|
|
13
|
+
<style>
|
|
14
|
+
figure {
|
|
15
|
+
margin: 2rem 0;
|
|
16
|
+
}
|
|
17
|
+
figure :global(img) {
|
|
18
|
+
border-radius: 0.5rem;
|
|
19
|
+
}
|
|
20
|
+
figcaption {
|
|
21
|
+
font-size: 0.9rem;
|
|
22
|
+
margin-top: 0.5rem;
|
|
23
|
+
}
|
|
24
|
+
</style>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { QuoteBlock } from "@django-stack/utils";
|
|
3
|
+
|
|
4
|
+
const { block } = Astro.props as { block: QuoteBlock };
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
<blockquote class="prose">
|
|
8
|
+
<p>{block.value.text}</p>
|
|
9
|
+
{block.value.attribution && <footer class="muted">{block.value.attribution}</footer>}
|
|
10
|
+
</blockquote>
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { linkHref, type SeoInput } from "@django-stack/utils";
|
|
3
|
+
import Image from "../components/Image.astro";
|
|
4
|
+
import RichText from "../components/RichText.astro";
|
|
5
|
+
import Seo from "../components/Seo.astro";
|
|
6
|
+
import { getContent, PREVIEW_COOKIE } from "../lib/api";
|
|
7
|
+
import "../styles/global.css";
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
page?: SeoInput | null;
|
|
11
|
+
type?: "website" | "article";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const { page, type } = Astro.props;
|
|
15
|
+
const content = getContent(Astro.cookies);
|
|
16
|
+
const [settings, mainMenu, footerMenu] = await Promise.all([
|
|
17
|
+
content.getSiteSettings(),
|
|
18
|
+
content.getMenu("main"),
|
|
19
|
+
content.getMenu("footer"),
|
|
20
|
+
]);
|
|
21
|
+
const preview = Astro.cookies.has(PREVIEW_COOKIE);
|
|
22
|
+
const path = Astro.url.pathname;
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
<!doctype html>
|
|
26
|
+
<html lang="en">
|
|
27
|
+
<head>
|
|
28
|
+
<meta charset="utf-8" />
|
|
29
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
30
|
+
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
|
31
|
+
<link rel="alternate" type="application/rss+xml" title={settings?.site_name ?? "RSS"} href="/rss.xml" />
|
|
32
|
+
<Seo page={page} settings={settings} type={type} />
|
|
33
|
+
</head>
|
|
34
|
+
<body>
|
|
35
|
+
{
|
|
36
|
+
preview && (
|
|
37
|
+
<div class="preview-banner">
|
|
38
|
+
Preview: drafts are shown. <a href="/preview/exit">Leave preview</a>
|
|
39
|
+
</div>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
<header class="site-header">
|
|
43
|
+
<div class="container bar">
|
|
44
|
+
<a class="brand" href="/">
|
|
45
|
+
{settings?.logo ? <Image media={settings.logo} size="thumbnail" alt={settings.site_name} eager /> : settings?.site_name}
|
|
46
|
+
</a>
|
|
47
|
+
<nav aria-label="Main">
|
|
48
|
+
{
|
|
49
|
+
mainMenu?.items.map((item) => {
|
|
50
|
+
const href = linkHref(item);
|
|
51
|
+
const current = href === path || (href !== "/" && path.startsWith(`${href}/`));
|
|
52
|
+
return (
|
|
53
|
+
<a href={href} aria-current={current ? "page" : undefined}>
|
|
54
|
+
{item.value.label}
|
|
55
|
+
</a>
|
|
56
|
+
);
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
</nav>
|
|
60
|
+
</div>
|
|
61
|
+
</header>
|
|
62
|
+
<main>
|
|
63
|
+
<slot />
|
|
64
|
+
</main>
|
|
65
|
+
<footer class="site-footer">
|
|
66
|
+
<div class="container bar muted">
|
|
67
|
+
<RichText html={settings?.footer_text} class="footer-text" />
|
|
68
|
+
<nav aria-label="Footer">
|
|
69
|
+
{footerMenu?.items.map((item) => <a href={linkHref(item)}>{item.value.label}</a>)}
|
|
70
|
+
{Object.entries(settings?.social_links ?? {}).map(([name, url]) => <a href={url} rel="me">{name}</a>)}
|
|
71
|
+
</nav>
|
|
72
|
+
</div>
|
|
73
|
+
</footer>
|
|
74
|
+
</body>
|
|
75
|
+
</html>
|
|
76
|
+
|
|
77
|
+
<style>
|
|
78
|
+
.site-header {
|
|
79
|
+
border-bottom: 1px solid var(--color-line);
|
|
80
|
+
}
|
|
81
|
+
.bar {
|
|
82
|
+
display: flex;
|
|
83
|
+
flex-wrap: wrap;
|
|
84
|
+
gap: 1rem;
|
|
85
|
+
align-items: center;
|
|
86
|
+
justify-content: space-between;
|
|
87
|
+
padding-block: 1rem;
|
|
88
|
+
}
|
|
89
|
+
.brand {
|
|
90
|
+
font-weight: 700;
|
|
91
|
+
font-size: 1.2rem;
|
|
92
|
+
color: inherit;
|
|
93
|
+
text-decoration: none;
|
|
94
|
+
}
|
|
95
|
+
.brand :global(img) {
|
|
96
|
+
height: 2.5rem;
|
|
97
|
+
width: auto;
|
|
98
|
+
}
|
|
99
|
+
nav {
|
|
100
|
+
display: flex;
|
|
101
|
+
flex-wrap: wrap;
|
|
102
|
+
gap: 1.25rem;
|
|
103
|
+
}
|
|
104
|
+
nav a {
|
|
105
|
+
text-decoration: none;
|
|
106
|
+
}
|
|
107
|
+
nav a[aria-current="page"] {
|
|
108
|
+
font-weight: 600;
|
|
109
|
+
text-decoration: underline;
|
|
110
|
+
}
|
|
111
|
+
main {
|
|
112
|
+
min-height: 70vh;
|
|
113
|
+
padding-block: 2rem 4rem;
|
|
114
|
+
}
|
|
115
|
+
.site-footer {
|
|
116
|
+
border-top: 1px solid var(--color-line);
|
|
117
|
+
font-size: 0.9rem;
|
|
118
|
+
}
|
|
119
|
+
.site-footer :global(.footer-text p) {
|
|
120
|
+
margin: 0;
|
|
121
|
+
}
|
|
122
|
+
</style>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content access for pages and endpoints. Server-side only: the API key is
|
|
3
|
+
* read from API_READ_KEY and never reaches the browser.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createClient, createContent } from "@django-stack/utils";
|
|
7
|
+
import type { AstroCookies } from "astro";
|
|
8
|
+
import type { Models } from "./headless";
|
|
9
|
+
|
|
10
|
+
export const PREVIEW_COOKIE = "stack_preview";
|
|
11
|
+
|
|
12
|
+
/** The content helpers, with drafts when the request carries a preview cookie. */
|
|
13
|
+
export function getContent(cookies: AstroCookies) {
|
|
14
|
+
const previewToken = cookies.get(PREVIEW_COOKIE)?.value ?? null;
|
|
15
|
+
const client = createClient<Models>({ previewToken });
|
|
16
|
+
return createContent(client);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The typed Django Headless client, for queries the content helpers do not cover. */
|
|
20
|
+
export function getClient(cookies?: AstroCookies) {
|
|
21
|
+
return createClient<Models>({ previewToken: cookies?.get(PREVIEW_COOKIE)?.value ?? null });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const SITE_URL = (process.env.SITE_URL ?? "http://localhost:3000").replace(/\/$/, "");
|
|
@@ -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
|
+
}
|