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.
Files changed (94) hide show
  1. LICENSE.md +24 -0
  2. README.md +59 -0
  3. components/HelloWorld.tsx +11 -0
  4. entrypoint.tsx +268 -0
  5. manage.py +15 -0
  6. picata/__init__.py +1 -0
  7. picata/apps.py +33 -0
  8. picata/blocks.py +175 -0
  9. picata/helpers/__init__.py +70 -0
  10. picata/helpers/wagtail.py +61 -0
  11. picata/log_utils.py +47 -0
  12. picata/middleware.py +54 -0
  13. picata/migrations/0001_initial.py +264 -0
  14. picata/migrations/0002_alter_article_content_alter_basicpage_content.py +112 -0
  15. picata/migrations/0003_alter_article_content_alter_basicpage_content.py +104 -0
  16. picata/migrations/0004_alter_article_content_alter_basicpage_content.py +105 -0
  17. picata/migrations/0005_socialsettings.py +48 -0
  18. picata/migrations/0006_alter_article_content.py +71 -0
  19. picata/migrations/0007_splitviewpage.py +69 -0
  20. picata/migrations/0008_alter_splitviewpage_content.py +96 -0
  21. picata/migrations/0009_alter_splitviewpage_content.py +111 -0
  22. picata/migrations/0010_alter_splitviewpage_content.py +105 -0
  23. picata/migrations/0011_alter_splitviewpage_options_and_more.py +113 -0
  24. picata/migrations/0012_alter_splitviewpage_content.py +109 -0
  25. picata/migrations/0013_alter_article_content.py +43 -0
  26. picata/migrations/0014_alter_article_content_alter_article_summary.py +24 -0
  27. picata/migrations/0015_alter_article_options_article_tagline_and_more.py +28 -0
  28. picata/migrations/0016_alter_article_options_alter_articletag_options_and_more.py +33 -0
  29. picata/migrations/0017_articletagrelation_alter_article_tags_and_more.py +35 -0
  30. picata/migrations/0018_rename_articletag_pagetag_and_more.py +21 -0
  31. picata/migrations/0019_rename_name_plural_articletype__name_plural.py +18 -0
  32. picata/migrations/0020_rename__name_plural_articletype__pluralised_name.py +18 -0
  33. picata/migrations/0021_rename_article_type_article_page_type.py +18 -0
  34. picata/migrations/0022_homepage.py +28 -0
  35. picata/migrations/__init__.py +0 -0
  36. picata/models.py +486 -0
  37. picata/settings/__init__.py +1 -0
  38. picata/settings/base.py +345 -0
  39. picata/settings/dev.py +94 -0
  40. picata/settings/mypy.py +7 -0
  41. picata/settings/prod.py +12 -0
  42. picata/settings/test.py +6 -0
  43. picata/static/picata/ada-profile.jpg +0 -0
  44. picata/static/picata/ada-social-bear.jpg +0 -0
  45. picata/static/picata/favicon.ico +0 -0
  46. picata/static/picata/fonts/Bitter-Light.ttf +0 -0
  47. picata/static/picata/fonts/Bitter-LightItalic.ttf +0 -0
  48. picata/static/picata/fonts/FiraCode-Light.ttf +0 -0
  49. picata/static/picata/fonts/FiraCode-SemiBold.ttf +0 -0
  50. picata/static/picata/fonts/Sacramento-Regular.ttf +0 -0
  51. picata/static/picata/fonts/ZillaSlab-Bold.ttf +0 -0
  52. picata/static/picata/fonts/ZillaSlab-BoldItalic.ttf +0 -0
  53. picata/static/picata/fonts/ZillaSlab-Light.ttf +0 -0
  54. picata/static/picata/fonts/ZillaSlab-LightItalic.ttf +0 -0
  55. picata/static/picata/fonts/ZillaSlabHighlight-Bold.ttf +0 -0
  56. picata/static/picata/icons.svg +56 -0
  57. picata/templates/picata/3_column.html +28 -0
  58. picata/templates/picata/404.html +11 -0
  59. picata/templates/picata/500.html +13 -0
  60. picata/templates/picata/_post_list.html +24 -0
  61. picata/templates/picata/article.html +20 -0
  62. picata/templates/picata/base.html +135 -0
  63. picata/templates/picata/basic_page.html +10 -0
  64. picata/templates/picata/blocks/icon_link_item.html +7 -0
  65. picata/templates/picata/blocks/icon_link_list.html +4 -0
  66. picata/templates/picata/blocks/icon_link_list_stream.html +3 -0
  67. picata/templates/picata/dl_view.html +18 -0
  68. picata/templates/picata/home_page.html +21 -0
  69. picata/templates/picata/post_listing.html +17 -0
  70. picata/templates/picata/previews/3col.html +73 -0
  71. picata/templates/picata/previews/dl.html +10 -0
  72. picata/templates/picata/previews/split.html +10 -0
  73. picata/templates/picata/previews/theme_gallery.html +158 -0
  74. picata/templates/picata/search_results.html +28 -0
  75. picata/templates/picata/split_view.html +15 -0
  76. picata/templates/picata/tags/site_menu.html +8 -0
  77. picata/templatetags/__init__.py +1 -0
  78. picata/templatetags/absolute_static.py +15 -0
  79. picata/templatetags/menu_tags.py +42 -0
  80. picata/templatetags/stringify.py +23 -0
  81. picata/transformers.py +60 -0
  82. picata/typing/__init__.py +19 -0
  83. picata/typing/wagtail.py +31 -0
  84. picata/urls.py +48 -0
  85. picata/validators.py +36 -0
  86. picata/views.py +80 -0
  87. picata/wagtail_hooks.py +43 -0
  88. picata/wsgi.py +15 -0
  89. picata-0.0.1.dist-info/METADATA +87 -0
  90. picata-0.0.1.dist-info/RECORD +94 -0
  91. picata-0.0.1.dist-info/WHEEL +4 -0
  92. picata-0.0.1.dist-info/licenses/LICENSE.md +24 -0
  93. pygments.sass +382 -0
  94. 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'.
@@ -0,0 +1,11 @@
1
+ import React from "react";
2
+
3
+ type Props = {
4
+ name: string;
5
+ };
6
+
7
+ const HelloWorld: React.FC<Props> = ({ name }) => {
8
+ return <h1>Hello, {name}!</h1>;
9
+ };
10
+
11
+ export default HelloWorld;
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