@django-stack/cli 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.js +38 -18
- package/dist/index.d.ts +11 -4
- package/dist/index.js +1 -1
- package/dist/{production-Ct_qoeXk.js → production-WThC1aS2.js} +115 -23
- package/package.json +1 -1
- package/templates/backend/Dockerfile +7 -2
- package/templates/backend/config/settings.py +12 -0
- package/templates/backend/config/urls.py +11 -0
- package/templates/backend/content/admin.py +14 -5
- package/templates/backend/pyproject.toml +1 -1
- package/templates/base/.github/workflows/deploy.yml +10 -1
- package/templates/base/AGENTS.md +78 -0
- package/templates/base/Caddyfile +24 -0
- package/templates/base/README.md +71 -3
- package/templates/base/SECURITY.md +2 -0
- package/templates/base/compose.yaml +21 -2
- package/templates/base/env.example +25 -0
- package/templates/frontend-astro/package.json +1 -1
- package/templates/frontend-next/package.json +3 -3
- package/templates/frontend-nuxt/package.json +2 -2
- package/templates/frontend-sveltekit/package.json +2 -2
- package/templates/frontend-templates/template.json +5 -0
- package/templates/frontend-templates/website/__init__.py +0 -0
- package/templates/frontend-templates/website/apps.py +6 -0
- package/templates/frontend-templates/website/context_processors.py +16 -0
- package/templates/frontend-templates/website/forms.py +20 -0
- package/templates/frontend-templates/website/static/website/favicon.svg +1 -0
- package/templates/frontend-templates/website/static/website/global.css +232 -0
- package/templates/frontend-templates/website/templates/website/404.html +10 -0
- package/templates/frontend-templates/website/templates/website/base.html +44 -0
- package/templates/frontend-templates/website/templates/website/blocks/call_to_action.html +5 -0
- package/templates/frontend-templates/website/templates/website/blocks/image.html +4 -0
- package/templates/frontend-templates/website/templates/website/blocks/quote.html +4 -0
- package/templates/frontend-templates/website/templates/website/blocks/text.html +2 -0
- package/templates/frontend-templates/website/templates/website/home.html +8 -0
- package/templates/frontend-templates/website/templates/website/includes/contact_form.html +21 -0
- package/templates/frontend-templates/website/templates/website/includes/post_card.html +9 -0
- package/templates/frontend-templates/website/templates/website/includes/post_list.html +11 -0
- package/templates/frontend-templates/website/templates/website/page.html +10 -0
- package/templates/frontend-templates/website/templates/website/post.html +20 -0
- package/templates/frontend-templates/website/templates/website/post_list.html +11 -0
- package/templates/frontend-templates/website/templates/website/robots.txt +5 -0
- package/templates/frontend-templates/website/templates/website/rss.xml +16 -0
- package/templates/frontend-templates/website/templates/website/sitemap.xml +8 -0
- package/templates/frontend-templates/website/tests/__init__.py +0 -0
- package/templates/frontend-templates/website/tests/test_site.py +102 -0
- package/templates/frontend-templates/website/urls.py +17 -0
- package/templates/frontend-templates/website/views.py +176 -0
|
@@ -0,0 +1,232 @@
|
|
|
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
|
+
/* The layout: website/templates/website/base.html */
|
|
104
|
+
.site-header {
|
|
105
|
+
border-bottom: 1px solid var(--color-line);
|
|
106
|
+
}
|
|
107
|
+
.bar {
|
|
108
|
+
display: flex;
|
|
109
|
+
flex-wrap: wrap;
|
|
110
|
+
gap: 1rem;
|
|
111
|
+
align-items: center;
|
|
112
|
+
justify-content: space-between;
|
|
113
|
+
padding-block: 1rem;
|
|
114
|
+
}
|
|
115
|
+
.brand {
|
|
116
|
+
font-weight: 700;
|
|
117
|
+
font-size: 1.2rem;
|
|
118
|
+
color: inherit;
|
|
119
|
+
text-decoration: none;
|
|
120
|
+
}
|
|
121
|
+
.brand img {
|
|
122
|
+
height: 2.5rem;
|
|
123
|
+
width: auto;
|
|
124
|
+
}
|
|
125
|
+
nav {
|
|
126
|
+
display: flex;
|
|
127
|
+
flex-wrap: wrap;
|
|
128
|
+
gap: 1.25rem;
|
|
129
|
+
}
|
|
130
|
+
nav a {
|
|
131
|
+
text-decoration: none;
|
|
132
|
+
}
|
|
133
|
+
nav a[aria-current="page"] {
|
|
134
|
+
font-weight: 600;
|
|
135
|
+
text-decoration: underline;
|
|
136
|
+
}
|
|
137
|
+
main {
|
|
138
|
+
min-height: 70vh;
|
|
139
|
+
padding-block: 2rem 4rem;
|
|
140
|
+
}
|
|
141
|
+
.site-footer {
|
|
142
|
+
border-top: 1px solid var(--color-line);
|
|
143
|
+
font-size: 0.9rem;
|
|
144
|
+
}
|
|
145
|
+
.footer-text p {
|
|
146
|
+
margin: 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/* Posts: includes/post_list.html, includes/post_card.html and post.html */
|
|
150
|
+
.card h3 {
|
|
151
|
+
margin: 0.75rem 0 0.25rem;
|
|
152
|
+
line-height: 1.3;
|
|
153
|
+
}
|
|
154
|
+
.card h3 a {
|
|
155
|
+
color: inherit;
|
|
156
|
+
text-decoration: none;
|
|
157
|
+
}
|
|
158
|
+
.card .cover {
|
|
159
|
+
width: 100%;
|
|
160
|
+
aspect-ratio: 16 / 10;
|
|
161
|
+
object-fit: cover;
|
|
162
|
+
border-radius: 0.5rem;
|
|
163
|
+
}
|
|
164
|
+
.meta {
|
|
165
|
+
font-size: 0.9rem;
|
|
166
|
+
margin: 0;
|
|
167
|
+
}
|
|
168
|
+
.pagination {
|
|
169
|
+
display: flex;
|
|
170
|
+
gap: 1.5rem;
|
|
171
|
+
justify-content: center;
|
|
172
|
+
margin: 3rem 0;
|
|
173
|
+
}
|
|
174
|
+
article .cover {
|
|
175
|
+
width: 100%;
|
|
176
|
+
border-radius: 0.75rem;
|
|
177
|
+
}
|
|
178
|
+
.tags {
|
|
179
|
+
display: flex;
|
|
180
|
+
gap: 1rem;
|
|
181
|
+
margin-top: 3rem;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/* Blocks: templates/website/blocks/ */
|
|
185
|
+
.block-image {
|
|
186
|
+
margin: 2rem 0;
|
|
187
|
+
}
|
|
188
|
+
.block-image img {
|
|
189
|
+
border-radius: 0.5rem;
|
|
190
|
+
}
|
|
191
|
+
.block-image figcaption {
|
|
192
|
+
font-size: 0.9rem;
|
|
193
|
+
margin-top: 0.5rem;
|
|
194
|
+
}
|
|
195
|
+
.cta {
|
|
196
|
+
margin: 2.5rem 0;
|
|
197
|
+
padding: 2rem;
|
|
198
|
+
border-radius: 0.75rem;
|
|
199
|
+
background: var(--color-accent-soft);
|
|
200
|
+
}
|
|
201
|
+
.cta h2 {
|
|
202
|
+
margin-top: 0;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/* The contact form: includes/contact_form.html */
|
|
206
|
+
.contact-form {
|
|
207
|
+
display: grid;
|
|
208
|
+
gap: 1rem;
|
|
209
|
+
max-width: 32rem;
|
|
210
|
+
margin-top: 2rem;
|
|
211
|
+
}
|
|
212
|
+
.contact-form label {
|
|
213
|
+
display: grid;
|
|
214
|
+
gap: 0.25rem;
|
|
215
|
+
font-weight: 600;
|
|
216
|
+
}
|
|
217
|
+
.contact-form input,
|
|
218
|
+
.contact-form textarea {
|
|
219
|
+
font: inherit;
|
|
220
|
+
padding: 0.5rem 0.75rem;
|
|
221
|
+
border: 1px solid var(--color-line);
|
|
222
|
+
border-radius: 0.375rem;
|
|
223
|
+
background: transparent;
|
|
224
|
+
color: inherit;
|
|
225
|
+
}
|
|
226
|
+
.contact-form button {
|
|
227
|
+
justify-self: start;
|
|
228
|
+
}
|
|
229
|
+
.contact-form .error {
|
|
230
|
+
color: #b42318;
|
|
231
|
+
font-weight: 400;
|
|
232
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{% extends "website/base.html" %}
|
|
2
|
+
{% load stack %}
|
|
3
|
+
{% block seo %}{% seo not_found site %}{% endblock %}
|
|
4
|
+
{% block content %}
|
|
5
|
+
<section class="narrow">
|
|
6
|
+
<h1>Page not found</h1>
|
|
7
|
+
<p>The page you are looking for does not exist or has moved.</p>
|
|
8
|
+
<p><a href="/">Go to the home page</a></p>
|
|
9
|
+
</section>
|
|
10
|
+
{% endblock %}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{% load static stack %}<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<link rel="icon" href="{% static 'website/favicon.svg' %}" type="image/svg+xml">
|
|
7
|
+
<link rel="alternate" type="application/rss+xml" title="{{ site.site_name|default:'RSS' }}" href="/rss.xml">
|
|
8
|
+
<link rel="stylesheet" href="{% static 'website/global.css' %}">
|
|
9
|
+
{% block seo %}{% seo page site %}{% endblock %}
|
|
10
|
+
{% if analytics_website_id %}<script defer src="/_a/script.js" data-website-id="{{ analytics_website_id }}"></script>{% endif %}
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
{% if preview %}
|
|
14
|
+
<div class="preview-banner">Preview: drafts are shown. <a href="/preview/exit">Leave preview</a></div>
|
|
15
|
+
{% endif %}
|
|
16
|
+
<header class="site-header">
|
|
17
|
+
<div class="container bar">
|
|
18
|
+
<a class="brand" href="/">
|
|
19
|
+
{% if site.logo %}{% image site.logo size="thumbnail" alt=site.site_name eager=True %}{% else %}{{ site.site_name }}{% endif %}
|
|
20
|
+
</a>
|
|
21
|
+
<nav aria-label="Main">
|
|
22
|
+
{% for item in main_menu.items.blocks %}
|
|
23
|
+
{% with href=item|link_href %}
|
|
24
|
+
<a href="{{ href }}"{% if href|is_current:request.path %} aria-current="page"{% endif %}>{{ item.value.label }}</a>
|
|
25
|
+
{% endwith %}
|
|
26
|
+
{% endfor %}
|
|
27
|
+
</nav>
|
|
28
|
+
</div>
|
|
29
|
+
</header>
|
|
30
|
+
<main>
|
|
31
|
+
{% block content %}{% endblock %}
|
|
32
|
+
</main>
|
|
33
|
+
<footer class="site-footer">
|
|
34
|
+
<div class="container bar muted">
|
|
35
|
+
{% if site.footer_text %}<div class="footer-text">{{ site.footer_text|safe }}</div>{% endif %}
|
|
36
|
+
<nav aria-label="Footer">
|
|
37
|
+
{% for item in footer_menu.items.blocks %}<a href="{{ item|link_href }}">{{ item.value.label }}</a>{% endfor %}
|
|
38
|
+
{% for name, url in site.social_links.items %}<a href="{{ url }}" rel="me">{{ name }}</a>{% endfor %}
|
|
39
|
+
</nav>
|
|
40
|
+
</div>
|
|
41
|
+
</footer>
|
|
42
|
+
{% block scripts %}{% endblock %}
|
|
43
|
+
</body>
|
|
44
|
+
</html>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{% extends "website/base.html" %}
|
|
2
|
+
{% block content %}
|
|
3
|
+
<section class="container">
|
|
4
|
+
<h1>{{ site.site_name|default:"Latest posts" }}</h1>
|
|
5
|
+
{% if site.tagline %}<p class="muted">{{ site.tagline }}</p>{% endif %}
|
|
6
|
+
{% include "website/includes/post_list.html" with base="/" %}
|
|
7
|
+
</section>
|
|
8
|
+
{% endblock %}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{% comment %}
|
|
2
|
+
The contact form posts to its own page. The view saves a FormSubmission,
|
|
3
|
+
which the admin lists, after the captcha (unless CAPTCHA=off) and the
|
|
4
|
+
rate limit per IP address (PUBLIC_WRITE_THROTTLE_RATE).
|
|
5
|
+
{% endcomment %}
|
|
6
|
+
<form class="contact-form" method="post" action="{{ request.path }}">
|
|
7
|
+
{% csrf_token %}
|
|
8
|
+
{% if sent %}<p class="status" role="status">Thank you, your message was sent.</p>{% endif %}
|
|
9
|
+
{% for error in form.non_field_errors %}<p class="status error" role="alert">{{ error }}</p>{% endfor %}
|
|
10
|
+
{% for field in form %}
|
|
11
|
+
{% if field.name == "captcha" %}
|
|
12
|
+
{{ field }}
|
|
13
|
+
{% for error in field.errors %}<p class="error">{{ error }}</p>{% endfor %}
|
|
14
|
+
{% else %}
|
|
15
|
+
<label>{{ field.label }} {{ field }}
|
|
16
|
+
{% for error in field.errors %}<span class="error">{{ error }}</span>{% endfor %}
|
|
17
|
+
</label>
|
|
18
|
+
{% endif %}
|
|
19
|
+
{% endfor %}
|
|
20
|
+
<button class="button" type="submit">Send</button>
|
|
21
|
+
</form>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{% load stack %}<article class="card">
|
|
2
|
+
<a href="{{ post.get_absolute_url }}">{% image post.cover size="card" sizes="(min-width: 40rem) 22rem, 100vw" class_name="cover" %}</a>
|
|
3
|
+
<h3><a href="{{ post.get_absolute_url }}">{{ post.title }}</a></h3>
|
|
4
|
+
<p class="muted meta">
|
|
5
|
+
{% if post.published_at %}<time datetime="{{ post.published_at|date:'c' }}">{{ post.published_at|date:"M j, Y" }}</time>{% endif %}
|
|
6
|
+
{% if post.author %}<span> · {{ post.author.name }}</span>{% endif %}
|
|
7
|
+
</p>
|
|
8
|
+
{% if post.excerpt %}<p>{{ post.excerpt }}</p>{% endif %}
|
|
9
|
+
</article>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{% if not posts %}<p class="muted">No posts yet.</p>{% endif %}
|
|
2
|
+
<div class="grid">
|
|
3
|
+
{% for post in posts %}{% include "website/includes/post_card.html" %}{% endfor %}
|
|
4
|
+
</div>
|
|
5
|
+
{% if posts.paginator.num_pages > 1 %}
|
|
6
|
+
<nav class="pagination" aria-label="Pages">
|
|
7
|
+
{% if posts.has_previous %}<a href="{% if posts.previous_page_number == 1 %}{{ base }}{% else %}{{ base }}?page={{ posts.previous_page_number }}{% endif %}">← Newer</a>{% endif %}
|
|
8
|
+
<span class="muted">Page {{ posts.number }} of {{ posts.paginator.num_pages }}</span>
|
|
9
|
+
{% if posts.has_next %}<a href="{{ base }}?page={{ posts.next_page_number }}">Older →</a>{% endif %}
|
|
10
|
+
</nav>
|
|
11
|
+
{% endif %}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{% extends "website/base.html" %}
|
|
2
|
+
{% load stack %}
|
|
3
|
+
{% block content %}
|
|
4
|
+
<article class="narrow">
|
|
5
|
+
<h1>{{ page.title }}</h1>
|
|
6
|
+
{% blocks page.body %}
|
|
7
|
+
{% if form %}{% include "website/includes/contact_form.html" %}{% endif %}
|
|
8
|
+
</article>
|
|
9
|
+
{% endblock %}
|
|
10
|
+
{% block scripts %}{% if form %}{{ form.media }}{% endif %}{% endblock %}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{% extends "website/base.html" %}
|
|
2
|
+
{% load stack %}
|
|
3
|
+
{% block seo %}{% seo post site type="article" %}{% endblock %}
|
|
4
|
+
{% block content %}
|
|
5
|
+
<article class="narrow">
|
|
6
|
+
<p class="muted">
|
|
7
|
+
{% if post.published_at %}<time datetime="{{ post.published_at|date:'c' }}">{{ post.published_at|date:"F j, Y" }}</time>{% endif %}
|
|
8
|
+
{% if post.author %}<span> · {{ post.author.name }}</span>{% endif %}
|
|
9
|
+
{% for category in post.categories.all %}<span> · <a href="{{ category.get_absolute_url }}">{{ category.name }}</a></span>{% endfor %}
|
|
10
|
+
</p>
|
|
11
|
+
<h1>{{ post.title }}</h1>
|
|
12
|
+
{% image post.cover size="content" sizes="(min-width: 48rem) 44rem, 100vw" class_name="cover" eager=True %}
|
|
13
|
+
{% blocks post.body %}
|
|
14
|
+
{% with tags=post.tags.all %}
|
|
15
|
+
{% if tags %}
|
|
16
|
+
<p class="tags">{% for tag in tags %}<a href="{{ tag.get_absolute_url }}">#{{ tag.name }}</a>{% endfor %}</p>
|
|
17
|
+
{% endif %}
|
|
18
|
+
{% endwith %}
|
|
19
|
+
</article>
|
|
20
|
+
{% endblock %}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{% extends "website/base.html" %}
|
|
2
|
+
{% load stack %}
|
|
3
|
+
{% block seo %}{% seo seo_page site %}{% endblock %}
|
|
4
|
+
{% block content %}
|
|
5
|
+
<section class="container">
|
|
6
|
+
{% if label %}<p class="muted">{{ label }}</p>{% endif %}
|
|
7
|
+
<h1>{{ title }}</h1>
|
|
8
|
+
{% if description %}<p>{{ description }}</p>{% endif %}
|
|
9
|
+
{% include "website/includes/post_list.html" with base=request.path %}
|
|
10
|
+
</section>
|
|
11
|
+
{% endblock %}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<rss version="2.0">
|
|
3
|
+
<channel>
|
|
4
|
+
<title>{{ site.site_name|default:"Blog" }}</title>
|
|
5
|
+
<description>{% firstof site.seo_description site.tagline "" %}</description>
|
|
6
|
+
<link>{{ site_url }}/</link>
|
|
7
|
+
{% for post in posts %}<item>
|
|
8
|
+
<title>{{ post.title }}</title>
|
|
9
|
+
<link>{{ site_url }}{{ post.get_absolute_url }}</link>
|
|
10
|
+
<guid isPermaLink="true">{{ site_url }}{{ post.get_absolute_url }}</guid>
|
|
11
|
+
{% if post.excerpt %}<description>{{ post.excerpt }}</description>{% endif %}
|
|
12
|
+
{% if post.published_at %}<pubDate>{{ post.published_at|date:"r" }}</pubDate>{% endif %}
|
|
13
|
+
</item>
|
|
14
|
+
{% endfor %}
|
|
15
|
+
</channel>
|
|
16
|
+
</rss>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
3
|
+
<url><loc>{{ site_url }}/</loc></url>
|
|
4
|
+
<url><loc>{{ site_url }}/blog</loc></url>
|
|
5
|
+
{% for page in pages %}<url><loc>{{ site_url }}{{ page.get_absolute_url }}</loc>{% if page.published_at %}<lastmod>{{ page.published_at|date:"Y-m-d" }}</lastmod>{% endif %}</url>
|
|
6
|
+
{% endfor %}{% for post in posts %}<url><loc>{{ site_url }}{{ post.get_absolute_url }}</loc>{% if post.published_at %}<lastmod>{{ post.published_at|date:"Y-m-d" }}</lastmod>{% endif %}</url>
|
|
7
|
+
{% endfor %}
|
|
8
|
+
</urlset>
|
|
File without changes
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests of the site's pages: every route renders, drafts show only in
|
|
3
|
+
preview, a save empties the page cache, redirects work and the contact form
|
|
4
|
+
needs a captcha token. Run them with `{{pm_run}} test` at the project root.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from django.conf import settings
|
|
9
|
+
from django.core.cache import cache
|
|
10
|
+
from django.test import Client
|
|
11
|
+
from django_stack.contrib.captcha.testing import captcha_token
|
|
12
|
+
from django_stack.contrib.site.preview import PREVIEW_COOKIE
|
|
13
|
+
from django_stack.preview import make_preview_token
|
|
14
|
+
|
|
15
|
+
from content.models import FormSubmission, Page, Post
|
|
16
|
+
from content.seed import seed
|
|
17
|
+
|
|
18
|
+
pytestmark = pytest.mark.django_db
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.fixture
|
|
22
|
+
def seeded(settings, tmp_path):
|
|
23
|
+
settings.MEDIA_ROOT = tmp_path
|
|
24
|
+
cache.clear()
|
|
25
|
+
seed()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@pytest.fixture
|
|
29
|
+
def client():
|
|
30
|
+
return Client()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.mark.parametrize(
|
|
34
|
+
"path", ["/", "/about", "/about/team", "/blog", "/blog?page=2", "/category/news", "/tag/django", "/contact"]
|
|
35
|
+
)
|
|
36
|
+
def test_pages_render(client, seeded, path):
|
|
37
|
+
response = client.get(path)
|
|
38
|
+
assert response.status_code == 200
|
|
39
|
+
assert "<title>" in response.content.decode()
|
|
40
|
+
assert "script-src 'self'" in response["Content-Security-Policy"]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_post(client, seeded):
|
|
44
|
+
post = Post.objects.published().first()
|
|
45
|
+
html = client.get(f"/blog/{post.slug}").content.decode()
|
|
46
|
+
assert f"<h1>{post.title}</h1>" in html
|
|
47
|
+
assert '<meta property="og:type" content="article">' in html
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@pytest.mark.parametrize(
|
|
51
|
+
("path", "content_type"),
|
|
52
|
+
[("/rss.xml", "application/rss+xml"), ("/sitemap.xml", "application/xml"), ("/robots.txt", "text/plain")],
|
|
53
|
+
)
|
|
54
|
+
def test_feeds(client, seeded, path, content_type):
|
|
55
|
+
response = client.get(path)
|
|
56
|
+
assert response.status_code == 200
|
|
57
|
+
assert response["Content-Type"].startswith(content_type)
|
|
58
|
+
assert settings.SITE_URL in response.content.decode()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_not_found_and_redirects(client, seeded):
|
|
62
|
+
response = client.get("/does-not-exist")
|
|
63
|
+
assert response.status_code == 404
|
|
64
|
+
assert "Page not found" in response.content.decode()
|
|
65
|
+
redirect = client.get("/old-about")
|
|
66
|
+
assert redirect.status_code == 301
|
|
67
|
+
assert redirect["Location"] == "/about"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_drafts_only_in_preview(client, seeded):
|
|
71
|
+
assert Page.objects.filter(path="draft").exists()
|
|
72
|
+
assert client.get("/draft").status_code == 404
|
|
73
|
+
client.cookies[PREVIEW_COOKIE] = make_preview_token()
|
|
74
|
+
response = client.get("/draft")
|
|
75
|
+
assert response.status_code == 200
|
|
76
|
+
assert "preview-banner" in response.content.decode()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_a_save_empties_the_page_cache(client, seeded):
|
|
80
|
+
assert client.get("/about")["X-Cache"] == "MISS"
|
|
81
|
+
assert client.get("/about")["X-Cache"] == "HIT"
|
|
82
|
+
about = Page.objects.get(path="about")
|
|
83
|
+
about.title = "Changed"
|
|
84
|
+
about.save()
|
|
85
|
+
response = client.get("/about")
|
|
86
|
+
assert response["X-Cache"] == "MISS"
|
|
87
|
+
assert "<h1>Changed</h1>" in response.content.decode()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_contact_form(client, seeded):
|
|
91
|
+
data = {"name": "Ada", "email": "ada@example.com", "message": "Hello", "captcha": captcha_token()}
|
|
92
|
+
response = client.post("/contact", data)
|
|
93
|
+
assert response.status_code == 302
|
|
94
|
+
assert FormSubmission.objects.filter(email="ada@example.com").exists()
|
|
95
|
+
assert "Thank you" in client.get(response["Location"]).content.decode()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@pytest.mark.skipif(not settings.DJANGO_STACK["CAPTCHA"], reason="the captcha is off")
|
|
99
|
+
def test_contact_form_needs_a_captcha(client, seeded):
|
|
100
|
+
response = client.post("/contact", {"name": "Ada", "email": "ada@example.com", "message": "Hello"})
|
|
101
|
+
assert response.status_code == 400
|
|
102
|
+
assert not FormSubmission.objects.exists()
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from django.urls import include, path, re_path
|
|
2
|
+
|
|
3
|
+
from . import views
|
|
4
|
+
|
|
5
|
+
urlpatterns = [
|
|
6
|
+
path("", include("django_stack.contrib.site.urls")), # /preview and /preview/exit
|
|
7
|
+
path("", views.home, name="home"),
|
|
8
|
+
path("blog", views.blog, name="blog"),
|
|
9
|
+
path("blog/<slug:slug>", views.post, name="post"),
|
|
10
|
+
path("category/<slug:slug>", views.category, name="category"),
|
|
11
|
+
path("tag/<slug:slug>", views.tag, name="tag"),
|
|
12
|
+
path("rss.xml", views.rss, name="rss"),
|
|
13
|
+
path("sitemap.xml", views.sitemap, name="sitemap"),
|
|
14
|
+
path("robots.txt", views.robots, name="robots"),
|
|
15
|
+
# Pages at their path, such as /about or /about/team.
|
|
16
|
+
re_path(r"^(?P<path>[\w-]+(?:/[\w-]+)*)/?$", views.page, name="page"),
|
|
17
|
+
]
|