picata 0.0.1__py3-none-any.whl
Sign up to get free protection for your applications and to get access to all the features.
- LICENSE.md +24 -0
- README.md +59 -0
- components/HelloWorld.tsx +11 -0
- entrypoint.tsx +268 -0
- manage.py +15 -0
- picata/__init__.py +1 -0
- picata/apps.py +33 -0
- picata/blocks.py +175 -0
- picata/helpers/__init__.py +70 -0
- picata/helpers/wagtail.py +61 -0
- picata/log_utils.py +47 -0
- picata/middleware.py +54 -0
- picata/migrations/0001_initial.py +264 -0
- picata/migrations/0002_alter_article_content_alter_basicpage_content.py +112 -0
- picata/migrations/0003_alter_article_content_alter_basicpage_content.py +104 -0
- picata/migrations/0004_alter_article_content_alter_basicpage_content.py +105 -0
- picata/migrations/0005_socialsettings.py +48 -0
- picata/migrations/0006_alter_article_content.py +71 -0
- picata/migrations/0007_splitviewpage.py +69 -0
- picata/migrations/0008_alter_splitviewpage_content.py +96 -0
- picata/migrations/0009_alter_splitviewpage_content.py +111 -0
- picata/migrations/0010_alter_splitviewpage_content.py +105 -0
- picata/migrations/0011_alter_splitviewpage_options_and_more.py +113 -0
- picata/migrations/0012_alter_splitviewpage_content.py +109 -0
- picata/migrations/0013_alter_article_content.py +43 -0
- picata/migrations/0014_alter_article_content_alter_article_summary.py +24 -0
- picata/migrations/0015_alter_article_options_article_tagline_and_more.py +28 -0
- picata/migrations/0016_alter_article_options_alter_articletag_options_and_more.py +33 -0
- picata/migrations/0017_articletagrelation_alter_article_tags_and_more.py +35 -0
- picata/migrations/0018_rename_articletag_pagetag_and_more.py +21 -0
- picata/migrations/0019_rename_name_plural_articletype__name_plural.py +18 -0
- picata/migrations/0020_rename__name_plural_articletype__pluralised_name.py +18 -0
- picata/migrations/0021_rename_article_type_article_page_type.py +18 -0
- picata/migrations/0022_homepage.py +28 -0
- picata/migrations/__init__.py +0 -0
- picata/models.py +486 -0
- picata/settings/__init__.py +1 -0
- picata/settings/base.py +345 -0
- picata/settings/dev.py +94 -0
- picata/settings/mypy.py +7 -0
- picata/settings/prod.py +12 -0
- picata/settings/test.py +6 -0
- picata/static/picata/ada-profile.jpg +0 -0
- picata/static/picata/ada-social-bear.jpg +0 -0
- picata/static/picata/favicon.ico +0 -0
- picata/static/picata/fonts/Bitter-Light.ttf +0 -0
- picata/static/picata/fonts/Bitter-LightItalic.ttf +0 -0
- picata/static/picata/fonts/FiraCode-Light.ttf +0 -0
- picata/static/picata/fonts/FiraCode-SemiBold.ttf +0 -0
- picata/static/picata/fonts/Sacramento-Regular.ttf +0 -0
- picata/static/picata/fonts/ZillaSlab-Bold.ttf +0 -0
- picata/static/picata/fonts/ZillaSlab-BoldItalic.ttf +0 -0
- picata/static/picata/fonts/ZillaSlab-Light.ttf +0 -0
- picata/static/picata/fonts/ZillaSlab-LightItalic.ttf +0 -0
- picata/static/picata/fonts/ZillaSlabHighlight-Bold.ttf +0 -0
- picata/static/picata/icons.svg +56 -0
- picata/templates/picata/3_column.html +28 -0
- picata/templates/picata/404.html +11 -0
- picata/templates/picata/500.html +13 -0
- picata/templates/picata/_post_list.html +24 -0
- picata/templates/picata/article.html +20 -0
- picata/templates/picata/base.html +135 -0
- picata/templates/picata/basic_page.html +10 -0
- picata/templates/picata/blocks/icon_link_item.html +7 -0
- picata/templates/picata/blocks/icon_link_list.html +4 -0
- picata/templates/picata/blocks/icon_link_list_stream.html +3 -0
- picata/templates/picata/dl_view.html +18 -0
- picata/templates/picata/home_page.html +21 -0
- picata/templates/picata/post_listing.html +17 -0
- picata/templates/picata/previews/3col.html +73 -0
- picata/templates/picata/previews/dl.html +10 -0
- picata/templates/picata/previews/split.html +10 -0
- picata/templates/picata/previews/theme_gallery.html +158 -0
- picata/templates/picata/search_results.html +28 -0
- picata/templates/picata/split_view.html +15 -0
- picata/templates/picata/tags/site_menu.html +8 -0
- picata/templatetags/__init__.py +1 -0
- picata/templatetags/absolute_static.py +15 -0
- picata/templatetags/menu_tags.py +42 -0
- picata/templatetags/stringify.py +23 -0
- picata/transformers.py +60 -0
- picata/typing/__init__.py +19 -0
- picata/typing/wagtail.py +31 -0
- picata/urls.py +48 -0
- picata/validators.py +36 -0
- picata/views.py +80 -0
- picata/wagtail_hooks.py +43 -0
- picata/wsgi.py +15 -0
- picata-0.0.1.dist-info/METADATA +87 -0
- picata-0.0.1.dist-info/RECORD +94 -0
- picata-0.0.1.dist-info/WHEEL +4 -0
- picata-0.0.1.dist-info/licenses/LICENSE.md +24 -0
- pygments.sass +382 -0
- styles.sass +300 -0
LICENSE.md
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
# The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright © `2024` `Ada Wright <ada@hpk.io>`
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person
|
6
|
+
obtaining a copy of this software and associated documentation
|
7
|
+
files (the “Software”), to deal in the Software without
|
8
|
+
restriction, including without limitation the rights to use,
|
9
|
+
copy, modify, merge, publish, distribute, sublicense, and/or sell
|
10
|
+
copies of the Software, and to permit persons to whom the
|
11
|
+
Software is furnished to do so, subject to the following
|
12
|
+
conditions:
|
13
|
+
|
14
|
+
The above copyright notice and this permission notice shall be
|
15
|
+
included in all copies or substantial portions of the Software.
|
16
|
+
|
17
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
|
18
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
19
|
+
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
20
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
21
|
+
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
22
|
+
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
23
|
+
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
24
|
+
OTHER DEALINGS IN THE SOFTWARE.
|
README.md
ADDED
@@ -0,0 +1,59 @@
|
|
1
|
+
# Ada's website
|
2
|
+
|
3
|
+
Wherein I set up a little website, and learn a bunch of stuff as I go.
|
4
|
+
|
5
|
+
## What it's made of
|
6
|
+
|
7
|
+
### Inside the box
|
8
|
+
|
9
|
+
- [Wagtail](https://wagtail.org) (on [Django](https://www.djangoproject.com)) is the web framework
|
10
|
+
<!-- - [Tailwind CSS](https://tailwindcss.com) for styling -->
|
11
|
+
|
12
|
+
### Holding things together
|
13
|
+
|
14
|
+
- [UV](https://github.com/astral-sh/uv) for all Python project management
|
15
|
+
- [Just](https://just.systems) as a command runner
|
16
|
+
- [OpenTofu](https://opentofu.org) for DevOps
|
17
|
+
- [Postgres](https://www.postgresql.org) for the database
|
18
|
+
- [Docker](https://www.docker.com) for local development
|
19
|
+
|
20
|
+
## Quickstart
|
21
|
+
|
22
|
+
### Requirements
|
23
|
+
|
24
|
+
- On a Mac:
|
25
|
+
|
26
|
+
```shell
|
27
|
+
brew install colima docker
|
28
|
+
```
|
29
|
+
|
30
|
+
### Run a development server
|
31
|
+
|
32
|
+
```shell
|
33
|
+
just tofu workspace select dev
|
34
|
+
just tofu apply
|
35
|
+
```
|
36
|
+
|
37
|
+
This will spin up a box on DigitalOcean using the settings defined in
|
38
|
+
[infra/variables.tf](infra/variables.tf), and create a DNS A record at
|
39
|
+
(workspace).for.(tld), (i.e. dev.for.hpk.io) pointing to the box. The variables
|
40
|
+
`do_token` and `ssh_fingerprint` should be defined in
|
41
|
+
[infra/secrets.tfvars](infra/secrets.tfvars). Workspace-specific variables are
|
42
|
+
defined in infra/envs/(workspace).tfvars; e.g.
|
43
|
+
[infra/envs/dev.tfvars](infra/envs/dev.tfvars) defines the 'tags' list for the
|
44
|
+
box as `[development]` and sets `cloud_init_config` to point to the
|
45
|
+
[cloud-init](https://cloud-init.io) script
|
46
|
+
[config/cloud-init-dev.yml](config/cloud-init-dev.yml).
|
47
|
+
|
48
|
+
The development cloud-init script will:
|
49
|
+
|
50
|
+
- Install the system packages [`just`](https://just.systems), [`zsh`](https://www.zsh.org),
|
51
|
+
[`gunicorn`](https://gunicorn.org), and `tree`
|
52
|
+
- Create a 'wagtail' user, with UID 1500
|
53
|
+
- Create the 'ada' user, and:
|
54
|
+
- install their SSH public keys,
|
55
|
+
- install their dotfiles,
|
56
|
+
- add them to the 'sudo' and 'wagtail' groups
|
57
|
+
- Install [Node](http://nodejs.org) on the system, from the `TF_VAR_NODE_VERSION`
|
58
|
+
defined in [.env](.env)
|
59
|
+
- Checkout this repository into `/app`, setting the owner and group to 'wagtail'.
|
entrypoint.tsx
ADDED
@@ -0,0 +1,268 @@
|
|
1
|
+
import "./styles.sass";
|
2
|
+
|
3
|
+
const THEMES = {
|
4
|
+
light: "fl",
|
5
|
+
dark: "ad",
|
6
|
+
};
|
7
|
+
|
8
|
+
// Set listeners on data-set-theme attributes to change the theme
|
9
|
+
import { themeChange } from "theme-change";
|
10
|
+
themeChange();
|
11
|
+
|
12
|
+
//
|
13
|
+
// Theme "reset to system defaults", and "light"/"dark" data-theme-mode logic
|
14
|
+
//
|
15
|
+
function initializeThemeReset() {
|
16
|
+
const themeReset = document.querySelector<HTMLSpanElement>("#theme-reset");
|
17
|
+
const themeButtons = document.querySelectorAll<HTMLButtonElement>("[data-set-theme]");
|
18
|
+
|
19
|
+
const updateThemeMode = () => {
|
20
|
+
const theme = document.documentElement.getAttribute("data-theme");
|
21
|
+
if (theme === THEMES.dark || theme === THEMES.light) {
|
22
|
+
document.documentElement.setAttribute(
|
23
|
+
"data-theme-mode",
|
24
|
+
theme === THEMES.dark ? "dark" : "light",
|
25
|
+
);
|
26
|
+
} else {
|
27
|
+
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
28
|
+
document.documentElement.setAttribute("data-theme-mode", prefersDark ? "dark" : "light");
|
29
|
+
}
|
30
|
+
};
|
31
|
+
|
32
|
+
const updateThemeResetButtonVisibility = () => {
|
33
|
+
if (themeReset) {
|
34
|
+
const isThemeSet = document.documentElement.hasAttribute("data-theme");
|
35
|
+
if (isThemeSet) {
|
36
|
+
themeReset.classList.remove("hidden", "pointer-events-none");
|
37
|
+
} else {
|
38
|
+
themeReset.classList.add("hidden", "pointer-events-none");
|
39
|
+
}
|
40
|
+
}
|
41
|
+
};
|
42
|
+
|
43
|
+
// Initialize on page load
|
44
|
+
updateThemeMode();
|
45
|
+
updateThemeResetButtonVisibility();
|
46
|
+
|
47
|
+
// Add a listener for system preference changes
|
48
|
+
const prefersDarkQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
49
|
+
prefersDarkQuery.addEventListener("change", () => {
|
50
|
+
if (!document.documentElement.getAttribute("data-theme")) {
|
51
|
+
updateThemeMode();
|
52
|
+
}
|
53
|
+
});
|
54
|
+
|
55
|
+
// Monitor changes to the data-theme attribute
|
56
|
+
const themeChangeObserver = new MutationObserver(() => {
|
57
|
+
updateThemeMode();
|
58
|
+
updateThemeResetButtonVisibility();
|
59
|
+
});
|
60
|
+
themeChangeObserver.observe(document.documentElement, {
|
61
|
+
attributes: true,
|
62
|
+
attributeFilter: ["data-theme"],
|
63
|
+
});
|
64
|
+
|
65
|
+
// Add click listener for the reset button
|
66
|
+
if (themeReset) {
|
67
|
+
themeReset.addEventListener("click", () => {
|
68
|
+
document.documentElement.removeAttribute("data-theme");
|
69
|
+
localStorage.removeItem("theme");
|
70
|
+
|
71
|
+
themeButtons.forEach((button) => {
|
72
|
+
button.classList.remove("btn-active");
|
73
|
+
});
|
74
|
+
|
75
|
+
updateThemeMode();
|
76
|
+
updateThemeResetButtonVisibility();
|
77
|
+
});
|
78
|
+
} else {
|
79
|
+
console.error("Could not find #theme-reset element.");
|
80
|
+
}
|
81
|
+
|
82
|
+
// Add listeners to theme buttons to toggle "btn-active" class
|
83
|
+
themeButtons.forEach((button) => {
|
84
|
+
button.addEventListener("click", () => {
|
85
|
+
const newTheme = button.getAttribute("data-set-theme");
|
86
|
+
if (newTheme) {
|
87
|
+
document.documentElement.setAttribute("data-theme", newTheme);
|
88
|
+
localStorage.setItem("theme", newTheme);
|
89
|
+
}
|
90
|
+
|
91
|
+
themeButtons.forEach((btn) => btn.classList.remove("btn-active"));
|
92
|
+
button.classList.add("btn-active");
|
93
|
+
|
94
|
+
updateThemeMode();
|
95
|
+
});
|
96
|
+
});
|
97
|
+
}
|
98
|
+
|
99
|
+
//
|
100
|
+
// Search field toggling logic
|
101
|
+
//
|
102
|
+
function initializeSearchFieldToggle() {
|
103
|
+
const searchToggleButton = document.getElementById("search-toggle") as HTMLButtonElement | null;
|
104
|
+
const searchField = document.getElementById("search-field") as HTMLElement | null;
|
105
|
+
|
106
|
+
if (!searchToggleButton || !searchField) {
|
107
|
+
console.error("Search toggle or search field elements not found.");
|
108
|
+
return;
|
109
|
+
}
|
110
|
+
|
111
|
+
searchToggleButton.addEventListener("click", () => {
|
112
|
+
const isVisible = searchField.classList.toggle("search-visible");
|
113
|
+
searchField.classList.toggle("search-hidden", !isVisible);
|
114
|
+
searchField.setAttribute("tabindex", isVisible ? "0" : "-1");
|
115
|
+
searchToggleButton.classList.toggle("!rounded-r-none", isVisible);
|
116
|
+
searchToggleButton.classList.toggle("!rounded-r-full", !isVisible);
|
117
|
+
searchToggleButton.setAttribute("aria-expanded", isVisible.toString());
|
118
|
+
if (isVisible) {
|
119
|
+
searchField.focus();
|
120
|
+
}
|
121
|
+
});
|
122
|
+
}
|
123
|
+
|
124
|
+
//
|
125
|
+
// Apply shadows to the right of code blocks when they overflow their container
|
126
|
+
//
|
127
|
+
function initializeCodeBlockOverflowWatchers(): void {
|
128
|
+
const pygmentsDivs: NodeListOf<HTMLDivElement> = document.querySelectorAll(".pygments");
|
129
|
+
|
130
|
+
const applyOverflowClass = (div: HTMLDivElement) => {
|
131
|
+
const pre = div.querySelector("pre");
|
132
|
+
if (!pre) return;
|
133
|
+
|
134
|
+
if (pre.scrollWidth > pre.clientWidth) {
|
135
|
+
div.classList.add("shadow-fade-right");
|
136
|
+
} else {
|
137
|
+
div.classList.remove("shadow-fade-right");
|
138
|
+
}
|
139
|
+
};
|
140
|
+
|
141
|
+
// Apply initial shadows
|
142
|
+
pygmentsDivs.forEach(applyOverflowClass);
|
143
|
+
|
144
|
+
// Add resize listener to recheck on window resize
|
145
|
+
window.addEventListener("resize", () => {
|
146
|
+
pygmentsDivs.forEach(applyOverflowClass);
|
147
|
+
});
|
148
|
+
}
|
149
|
+
|
150
|
+
//
|
151
|
+
// Create the nested list of internal page links for a 'Page Contents' container
|
152
|
+
//
|
153
|
+
function renderPageContents(): void {
|
154
|
+
const tocContainer = document.querySelector("main nav .toc");
|
155
|
+
if (!tocContainer) return;
|
156
|
+
|
157
|
+
// Create the header for the navigation
|
158
|
+
const tocHeader = document.createElement("h2");
|
159
|
+
tocHeader.textContent = "In this page";
|
160
|
+
tocContainer.appendChild(tocHeader);
|
161
|
+
|
162
|
+
// Create the root list
|
163
|
+
const tocList = document.createElement("ul");
|
164
|
+
tocContainer.appendChild(tocList);
|
165
|
+
|
166
|
+
// Stack to track the current list level
|
167
|
+
const listStack: HTMLUListElement[] = [tocList];
|
168
|
+
let currentLevel = 1;
|
169
|
+
|
170
|
+
// Find all anchor-linked headings
|
171
|
+
const headings = document.querySelectorAll<HTMLElement>(
|
172
|
+
"h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]",
|
173
|
+
);
|
174
|
+
headings.forEach((heading) => {
|
175
|
+
const headingLevel = parseInt(heading.tagName.substring(1)); // Extract the heading level (e.g., "1" for "H1")
|
176
|
+
|
177
|
+
// Adjust the stack to match the heading level
|
178
|
+
while (headingLevel > currentLevel) {
|
179
|
+
// Create intermediate sub-lists for skipped levels
|
180
|
+
const newList = document.createElement("ul");
|
181
|
+
const lastItem = listStack[listStack.length - 1].lastElementChild;
|
182
|
+
|
183
|
+
if (lastItem) {
|
184
|
+
lastItem.appendChild(newList);
|
185
|
+
listStack.push(newList);
|
186
|
+
} else {
|
187
|
+
// If no previous item exists, append directly to the current list
|
188
|
+
listStack[listStack.length - 1].appendChild(newList);
|
189
|
+
listStack.push(newList);
|
190
|
+
}
|
191
|
+
currentLevel++;
|
192
|
+
}
|
193
|
+
|
194
|
+
while (headingLevel < currentLevel) {
|
195
|
+
// Pop back to the parent list
|
196
|
+
listStack.pop();
|
197
|
+
currentLevel--;
|
198
|
+
}
|
199
|
+
|
200
|
+
// Add the heading to the current list
|
201
|
+
const listItem = document.createElement("li");
|
202
|
+
const link = document.createElement("a");
|
203
|
+
|
204
|
+
// Get heading text without pilcrow
|
205
|
+
link.href = `#${heading.id}`;
|
206
|
+
link.textContent = heading.textContent?.replace("¶", "").trim() || "Untitled"; // Remove pilcrow
|
207
|
+
listItem.appendChild(link);
|
208
|
+
|
209
|
+
listStack[listStack.length - 1].appendChild(listItem);
|
210
|
+
});
|
211
|
+
}
|
212
|
+
|
213
|
+
// function enableStickyTOC(): void {
|
214
|
+
// const tocContainer = document.querySelector<HTMLElement>(".toc > div");
|
215
|
+
// if (!tocContainer) return;
|
216
|
+
|
217
|
+
// const parentContainer = tocContainer.parentElement;
|
218
|
+
// if (!parentContainer) return;
|
219
|
+
|
220
|
+
// const offsetTop = 16; // Equivalent to Tailwind's `top-4`
|
221
|
+
// const marginRight = 16; // Equivalent to Tailwind's `-mr-4`
|
222
|
+
|
223
|
+
// const initialTop = parentContainer.getBoundingClientRect().top + window.scrollY;
|
224
|
+
// const parentStyles = getComputedStyle(parentContainer);
|
225
|
+
|
226
|
+
// window.addEventListener("scroll", () => {
|
227
|
+
// const currentScroll = window.scrollY;
|
228
|
+
// const stickyStart = initialTop - offsetTop;
|
229
|
+
|
230
|
+
// if (currentScroll >= stickyStart) {
|
231
|
+
// tocContainer.classList.add("is-fixed");
|
232
|
+
|
233
|
+
// tocContainer.style.position = "fixed";
|
234
|
+
// tocContainer.style.top = `${offsetTop}px`;
|
235
|
+
// tocContainer.style.maxHeight = `calc(100vh - ${offsetTop}px)`;
|
236
|
+
// tocContainer.style.overflowY = "auto";
|
237
|
+
|
238
|
+
// // Dynamically calculate width and right offset
|
239
|
+
// const parentWidth = parentContainer.getBoundingClientRect().width;
|
240
|
+
// tocContainer.style.width = `${parentWidth}px`;
|
241
|
+
// tocContainer.style.padding = parentStyles.padding; // Preserve padding
|
242
|
+
// tocContainer.style.right = `${marginRight}px`; // Apply negative right margin
|
243
|
+
// } else {
|
244
|
+
// tocContainer.classList.remove("is-fixed");
|
245
|
+
|
246
|
+
// tocContainer.style.position = "relative";
|
247
|
+
// tocContainer.style.top = "initial";
|
248
|
+
// tocContainer.style.maxHeight = "initial";
|
249
|
+
// tocContainer.style.overflowY = "initial";
|
250
|
+
|
251
|
+
// // Reset dynamically applied styles
|
252
|
+
// tocContainer.style.width = "initial";
|
253
|
+
// tocContainer.style.padding = "initial";
|
254
|
+
// tocContainer.style.right = "initial"; // Reset right offset
|
255
|
+
// }
|
256
|
+
// });
|
257
|
+
// }
|
258
|
+
|
259
|
+
//
|
260
|
+
// Main DOMContentLoaded Listener
|
261
|
+
//
|
262
|
+
document.addEventListener("DOMContentLoaded", () => {
|
263
|
+
initializeThemeReset();
|
264
|
+
initializeSearchFieldToggle();
|
265
|
+
initializeCodeBlockOverflowWatchers();
|
266
|
+
renderPageContents();
|
267
|
+
// enableStickyTOC();
|
268
|
+
});
|
manage.py
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
#!/usr/bin/env python
|
2
|
+
"""Entry-point for Django management commands."""
|
3
|
+
|
4
|
+
from os import environ
|
5
|
+
from sys import argv
|
6
|
+
|
7
|
+
if __name__ == "__main__":
|
8
|
+
environ.setdefault("DJANGO_SETTINGS_MODULE", "hpk.settings.dev")
|
9
|
+
|
10
|
+
if len(argv) >= 2: # noqa: PLR2004
|
11
|
+
environ.setdefault("DJANGO_MANAGEMENT_COMMAND", argv[1])
|
12
|
+
|
13
|
+
from django.core.management import execute_from_command_line
|
14
|
+
|
15
|
+
execute_from_command_line(argv)
|
picata/__init__.py
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
"""Main "umbrella" package for custom code running hpk.io."""
|
picata/apps.py
ADDED
@@ -0,0 +1,33 @@
|
|
1
|
+
"""Application configuration for the hpk Django app."""
|
2
|
+
|
3
|
+
from django.apps import AppConfig
|
4
|
+
|
5
|
+
|
6
|
+
class Config(AppConfig):
|
7
|
+
"""Configuration class for the hpk Django application."""
|
8
|
+
|
9
|
+
default_auto_field = "django.db.models.BigAutoField"
|
10
|
+
name = "hpk"
|
11
|
+
|
12
|
+
def ready(self) -> None:
|
13
|
+
"""Configure Wagtail admin with custom models, and register document transformers."""
|
14
|
+
#
|
15
|
+
# Register the 'custom article type' model with the Wagtail admin
|
16
|
+
from wagtail_modeladmin.options import modeladmin_register
|
17
|
+
|
18
|
+
from hpk.models import ArticleTypeAdmin
|
19
|
+
|
20
|
+
modeladmin_register(ArticleTypeAdmin)
|
21
|
+
|
22
|
+
# Add document transformers to the HTMLProcessingMiddleware
|
23
|
+
from hpk.middleware import HTMLProcessingMiddleware
|
24
|
+
from hpk.transformers import AnchorInserter, add_heading_ids
|
25
|
+
|
26
|
+
## Add ids to all headings missing them within html > body > main
|
27
|
+
HTMLProcessingMiddleware.add_transformer(add_heading_ids)
|
28
|
+
|
29
|
+
## Add anchored pillcrows to headings in designated pages
|
30
|
+
anchor_inserter = AnchorInserter(
|
31
|
+
root="//main/article", targets=".//h1 | .//h2 | .//h3 | .//h4 | .//h5 | .//h6"
|
32
|
+
)
|
33
|
+
HTMLProcessingMiddleware.add_transformer(anchor_inserter)
|
picata/blocks.py
ADDED
@@ -0,0 +1,175 @@
|
|
1
|
+
"""Wagtail "blocks"."""
|
2
|
+
|
3
|
+
import pygments
|
4
|
+
from django.forms import CharField
|
5
|
+
from django.utils.html import format_html
|
6
|
+
from hpk.typing.wagtail import BlockRenderContext, BlockRenderValue
|
7
|
+
from hpk.validators import HREFValidator
|
8
|
+
from pygments import formatters, lexers
|
9
|
+
from pygments.util import ClassNotFound
|
10
|
+
from wagtail.blocks import (
|
11
|
+
CharBlock,
|
12
|
+
ChoiceBlock,
|
13
|
+
IntegerBlock,
|
14
|
+
ListBlock,
|
15
|
+
RichTextBlock,
|
16
|
+
StreamBlock,
|
17
|
+
StructBlock,
|
18
|
+
TextBlock,
|
19
|
+
URLBlock,
|
20
|
+
)
|
21
|
+
from wagtail.images.blocks import ImageChooserBlock
|
22
|
+
|
23
|
+
|
24
|
+
class HREFField(CharField):
|
25
|
+
"""Custom field for href attributes (i.e. URLs but also schemes like 'mailto:')."""
|
26
|
+
|
27
|
+
def __init__(self, *args, **kwargs) -> None: # noqa: ANN002, ANN003
|
28
|
+
"""Initialise a CharField with `HREFValidator` as the only validator."""
|
29
|
+
kwargs["validators"] = [HREFValidator()]
|
30
|
+
super().__init__(*args, **kwargs)
|
31
|
+
|
32
|
+
|
33
|
+
class HREFBlock(URLBlock):
|
34
|
+
"""Custom block for href attributes (i.e. a `URLBlock` using the extended `HREFField`)."""
|
35
|
+
|
36
|
+
def __init__(self, *args, **kwargs) -> None: # noqa: ANN002, ANN003
|
37
|
+
"""Initialise the `URLBlock` superclass and replace our field with a `HREFField`."""
|
38
|
+
super().__init__(*args, **kwargs)
|
39
|
+
self.field = HREFField()
|
40
|
+
|
41
|
+
|
42
|
+
class StaticIconLinkItemBlock(StructBlock):
|
43
|
+
"""A single list item with an optional icon and surrounding anchor."""
|
44
|
+
|
45
|
+
href = HREFBlock(
|
46
|
+
required=False,
|
47
|
+
help_text="An optional link field",
|
48
|
+
max_length=255,
|
49
|
+
)
|
50
|
+
label = CharBlock(required=True, max_length=50, help_text="The title for the list item.")
|
51
|
+
icon = CharBlock(
|
52
|
+
required=False,
|
53
|
+
max_length=255,
|
54
|
+
help_text="Id of the icon in the static/icons.svg file.",
|
55
|
+
)
|
56
|
+
|
57
|
+
class Meta:
|
58
|
+
"""Meta information."""
|
59
|
+
|
60
|
+
template = "picata/blocks/icon_link_item.html"
|
61
|
+
|
62
|
+
|
63
|
+
class StaticIconLinkListBlock(StructBlock):
|
64
|
+
"""A list of optionally-linked list items with an optional heading."""
|
65
|
+
|
66
|
+
heading = CharBlock(
|
67
|
+
required=False,
|
68
|
+
help_text="Optional heading for this list (e.g., Social Links).",
|
69
|
+
)
|
70
|
+
heading_level = IntegerBlock(
|
71
|
+
required=False,
|
72
|
+
min_value=1,
|
73
|
+
max_value=6,
|
74
|
+
default=2,
|
75
|
+
help_text="Heading level for the list (1-6).",
|
76
|
+
)
|
77
|
+
items = ListBlock(
|
78
|
+
StaticIconLinkItemBlock(),
|
79
|
+
help_text="The list of items.",
|
80
|
+
)
|
81
|
+
|
82
|
+
class Meta:
|
83
|
+
"""Meta information."""
|
84
|
+
|
85
|
+
template = "picata/blocks/icon_link_list.html"
|
86
|
+
|
87
|
+
|
88
|
+
class StaticIconLinkListsBlock(StructBlock):
|
89
|
+
"""A wrapper for multiple heading-and-link-list blocks."""
|
90
|
+
|
91
|
+
lists = StreamBlock(
|
92
|
+
[
|
93
|
+
("link_list", StaticIconLinkListBlock()),
|
94
|
+
],
|
95
|
+
required=False,
|
96
|
+
help_text="Add one or more heading-and-link-list blocks.",
|
97
|
+
)
|
98
|
+
|
99
|
+
class Meta:
|
100
|
+
"""Meta information."""
|
101
|
+
|
102
|
+
template = "picata/blocks/icon_link_list_stream.html"
|
103
|
+
|
104
|
+
|
105
|
+
class CodeBlock(StructBlock):
|
106
|
+
"""A block for displaying code with optional syntax highlighting."""
|
107
|
+
|
108
|
+
code = TextBlock(required=True, help_text=None)
|
109
|
+
language = ChoiceBlock(
|
110
|
+
required=False,
|
111
|
+
choices=[
|
112
|
+
("python", "Python"),
|
113
|
+
("javascript", "JavaScript"),
|
114
|
+
("html", "HTML"),
|
115
|
+
("css", "CSS"),
|
116
|
+
("bash", "Bash"),
|
117
|
+
("plaintext", "Plain Text"),
|
118
|
+
],
|
119
|
+
help_text=None,
|
120
|
+
)
|
121
|
+
|
122
|
+
def render_basic(self, value: BlockRenderValue, context: BlockRenderContext = None) -> str:
|
123
|
+
"""Render the code block with syntax highlighting."""
|
124
|
+
code = value.get("code", "")
|
125
|
+
language = value.get("language", "plaintext")
|
126
|
+
try:
|
127
|
+
lexer = lexers.get_lexer_by_name(language)
|
128
|
+
formatter = formatters.HtmlFormatter(cssclass="pygments")
|
129
|
+
highlighted_code = pygments.highlight(code, lexer, formatter)
|
130
|
+
except ClassNotFound:
|
131
|
+
highlighted_code = f"<pre><code>{code}</code></pre>"
|
132
|
+
|
133
|
+
return format_html(highlighted_code)
|
134
|
+
|
135
|
+
class Meta:
|
136
|
+
"""Meta information."""
|
137
|
+
|
138
|
+
icon = "code"
|
139
|
+
label = "Code Block"
|
140
|
+
|
141
|
+
|
142
|
+
class SectionBlock(StructBlock):
|
143
|
+
"""A page section, with a heading rendered at a defined level."""
|
144
|
+
|
145
|
+
heading = CharBlock(
|
146
|
+
required=True, help_text='Heading for this section, included in "page contents".'
|
147
|
+
)
|
148
|
+
level = IntegerBlock(required=True, min_value=1, max_value=6, help_text="Heading level")
|
149
|
+
content = StreamBlock(
|
150
|
+
[
|
151
|
+
("rich_text", RichTextBlock()),
|
152
|
+
("image", ImageChooserBlock()),
|
153
|
+
],
|
154
|
+
required=False,
|
155
|
+
help_text=None,
|
156
|
+
)
|
157
|
+
|
158
|
+
class Meta:
|
159
|
+
"""Meta-info for the block."""
|
160
|
+
|
161
|
+
icon = "folder"
|
162
|
+
label = "Section"
|
163
|
+
|
164
|
+
|
165
|
+
class WrappedImageChooserBlock(ImageChooserBlock):
|
166
|
+
"""An ImageChooserBlock that wraps the output in a div."""
|
167
|
+
|
168
|
+
def render_basic(self, value: BlockRenderValue, context: BlockRenderContext = None) -> str:
|
169
|
+
"""Render the image wrapped in a div with a custom class."""
|
170
|
+
if not value: # If no image is selected, return an empty string
|
171
|
+
return ""
|
172
|
+
|
173
|
+
# Use Wagtail's default rendering for the image, wrapped in a <div>
|
174
|
+
image_tag = super().render_basic(value, context)
|
175
|
+
return f'<div class="image-wrapper">{image_tag}</div>'
|
@@ -0,0 +1,70 @@
|
|
1
|
+
"""Generic helper-functions."""
|
2
|
+
# NB: Django's meta-class shenanigans over-complicate type hinting when QuerySets get involved.
|
3
|
+
# pyright: reportAttributeAccessIssue=false
|
4
|
+
|
5
|
+
import re
|
6
|
+
from ipaddress import AddressValueError, IPv4Address
|
7
|
+
|
8
|
+
from django.apps import apps
|
9
|
+
from django.db.models import Model
|
10
|
+
from django.http import HttpResponse, StreamingHttpResponse
|
11
|
+
from lxml.etree import _Element
|
12
|
+
|
13
|
+
# Pre-compile commonly used regular expressions
|
14
|
+
ALPHANUMERIC_REGEX = re.compile(r"[^a-zA-Z0-9]")
|
15
|
+
|
16
|
+
|
17
|
+
def get_models_of_type(base_type: type[Model]) -> list[type[Model]]:
|
18
|
+
"""Retrieve all concrete subclasses of the given base Model type."""
|
19
|
+
all_models = apps.get_models()
|
20
|
+
|
21
|
+
return [
|
22
|
+
model
|
23
|
+
for model in all_models
|
24
|
+
if issubclass(model, base_type) and not model._meta.abstract # noqa: SLF001
|
25
|
+
]
|
26
|
+
|
27
|
+
|
28
|
+
def get_public_ip() -> IPv4Address | None:
|
29
|
+
"""Fetch the public-facing IP of the current host."""
|
30
|
+
import socket
|
31
|
+
|
32
|
+
import psutil
|
33
|
+
|
34
|
+
for addrs in psutil.net_if_addrs().values():
|
35
|
+
for addr in addrs:
|
36
|
+
if addr.family == socket.AF_INET:
|
37
|
+
ip = addr.address
|
38
|
+
if not ip.startswith(("10.", "192.168.", "172.", "127.")):
|
39
|
+
try:
|
40
|
+
return IPv4Address(ip)
|
41
|
+
except AddressValueError:
|
42
|
+
pass
|
43
|
+
return None
|
44
|
+
|
45
|
+
|
46
|
+
def get_full_text(element: _Element) -> str:
|
47
|
+
"""Extract text from an element and its descendants, concatenate it, and trim whitespace."""
|
48
|
+
return "".join(element.xpath(".//text()")).strip()
|
49
|
+
|
50
|
+
|
51
|
+
def make_response(
|
52
|
+
original_response: HttpResponse,
|
53
|
+
new_content: str | bytes,
|
54
|
+
) -> HttpResponse:
|
55
|
+
"""Create a new HttpResponse while preserving attributes from the original response."""
|
56
|
+
if isinstance(original_response, StreamingHttpResponse):
|
57
|
+
raise TypeError("StreamingHttpResponse objects are not supported.")
|
58
|
+
new_response = HttpResponse(
|
59
|
+
content=new_content,
|
60
|
+
content_type=original_response.get("Content-Type", None),
|
61
|
+
status=original_response.status_code,
|
62
|
+
)
|
63
|
+
for key, value in original_response.headers.items():
|
64
|
+
new_response[key] = value
|
65
|
+
new_response.cookies = original_response.cookies
|
66
|
+
for attr in dir(original_response):
|
67
|
+
if not attr.startswith("_") and not hasattr(HttpResponse, attr):
|
68
|
+
setattr(new_response, attr, getattr(original_response, attr))
|
69
|
+
|
70
|
+
return new_response
|