picata 0.0.1__py3-none-any.whl
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.
- 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
|