Compare commits

..

1 Commits

Author SHA1 Message Date
a856545345 Update redis Docker tag to v7 2023-09-30 10:00:59 +01:00
118 changed files with 1011 additions and 2711 deletions

View File

@ -1,7 +1,6 @@
{
"extends": [
"eslint:recommended",
"plugin:unicorn/recommended"
"eslint:recommended"
],
"parserOptions": {
"ecmaVersion": 6
@ -9,10 +8,5 @@
"env": {
"browser": true,
"node": true
},
"rules": {
"unicorn/prefer-module": 0,
"unicorn/prefer-query-selector": 0,
"unicorn/prefer-optional-catch-binding": 0
}
}

View File

@ -23,7 +23,7 @@ static:
expire_in: 2 hours
pip:
image: python:3.12-slim
image: python:3.11-slim
stage: build
variables:
PIP_CACHE_DIR: $CI_PROJECT_DIR/.pip-cache
@ -45,7 +45,7 @@ pip:
expire_in: 2 hours
.python_test_template:
image: python:3.12-slim
image: python:3.11-slim
stage: test
dependencies:
- pip

View File

@ -1,6 +1,3 @@
{
"extends": ["stylelint-config-standard-scss", "stylelint-config-prettier-scss"],
"rules": {
"scss/at-extend-no-missing-placeholder": null
}
"extends": ["stylelint-config-standard-scss", "stylelint-config-prettier-scss"]
}

View File

@ -11,13 +11,10 @@ COPY ./static/src ./static/src
RUN npm run build
# The actual container
FROM python:3.12-slim as production
FROM python:3.11-slim as production
ENV VIRTUAL_ENV=/venv
# renovate: datasource=github-tags depName=just-containers/s6-overlay
ENV S6_OVERLAY_VERSION=3.1.6.2
RUN useradd website --create-home -u 1000 && mkdir /app $VIRTUAL_ENV && chown -R website /app $VIRTUAL_ENV
WORKDIR /app
@ -28,22 +25,18 @@ RUN apt-get update --yes --quiet && apt-get install --yes --quiet --no-install-r
curl \
git \
nginx \
libnginx-mod-http-headers-more-filter \
# wand dependencies
libmagickwand-6.q16-6 libmagickwand-6.q16hdri-6 \
&& apt-get autoremove && rm -rf /var/lib/apt/lists/*
RUN curl -fsSL https://github.com/aptible/supercronic/releases/download/v0.2.1/supercronic-linux-amd64 -o /usr/local/bin/supercronic && chmod +x /usr/local/bin/supercronic
ADD https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-noarch.tar.xz /tmp
ADD https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-x86_64.tar.xz /tmp
RUN tar -C / -Jxpf /tmp/s6-overlay-noarch.tar.xz && tar -C / -Jxpf /tmp/s6-overlay-x86_64.tar.xz
ENV PATH=$VIRTUAL_ENV/bin:$PATH \
PYTHONUNBUFFERED=1
EXPOSE 8000
RUN ln -fs /app/etc/nginx.conf /etc/nginx/sites-available/default && chown -R website /var/log/nginx
RUN ln -fs /app/etc/nginx.conf /etc/nginx/sites-available/default
USER website
@ -58,22 +51,17 @@ COPY --chown=website ./etc ./etc
COPY --chown=website ./manage.py ./manage.py
COPY --chown=website ./website ./website
RUN cat ./etc/bashrc.sh >> ~/.bashrc
RUN SECRET_KEY=none python manage.py collectstatic --noinput --clear
COPY ./etc/s6-rc.d /etc/s6-overlay/s6-rc.d
ENTRYPOINT [ "/init" ]
CMD ["/app/etc/entrypoints/web"]
# Just dev stuff
FROM production as dev
# Swap user, so the following tasks can be run as root
USER root
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && apt-get install -y nodejs
RUN apt-get update --yes --quiet && apt-get install -y postgresql-client inotify-tools
RUN apt-get install -y postgresql-client inotify-tools
RUN curl -sSf https://just.systems/install.sh | bash -s -- --to /usr/bin
# Restore user
@ -82,5 +70,4 @@ USER website
COPY --chown=website dev-requirements.txt ./
RUN pip install --no-cache -r dev-requirements.txt
ENTRYPOINT []
CMD sleep infinity

View File

@ -1,14 +1,13 @@
-r requirements.txt
honcho==1.1.0
black==23.12.1
django-browser-reload==1.12.1
black==23.7.0
django-browser-reload==1.11.0
django-debug-toolbar
types-requests
mypy==1.8.0
wagtail-factories==4.1.0
coverage==7.4.0
djlint==1.34.1
types-pyyaml
ruff==0.1.11
setuptools # required for Honcho to work on Python 3.12+
types-requests==2.31.0.1
mypy==1.5.1
wagtail-factories==4.0.0
coverage==7.3.0
djlint==1.31.0
types-pyyaml==6.0.12.9
ruff==0.0.278

View File

@ -4,6 +4,7 @@ services:
build:
context: ../../
target: dev
init: true
environment:
- QUEUE_STORE_URL=redis://redis/0
- DEBUG=true
@ -11,8 +12,6 @@ services:
- DATABASE_URL=postgres://website:website@db/website
volumes:
- ../../:/app
tmpfs:
- /tmp
depends_on:
- redis
- db
@ -21,7 +20,7 @@ services:
- 127.0.0.1:8080:8080
redis:
image: redis:6-alpine
image: redis:7-alpine
db:
image: postgres:14-alpine

View File

@ -1,5 +0,0 @@
# nvm
if [ -a "$HOME/.nvm/nvm.sh" ]; then
export NVM_DIR="$HOME/.nvm"
source "$NVM_DIR/nvm.sh"
fi

2
etc/s6-rc.d/nginx/run → etc/entrypoints/nginx Normal file → Executable file
View File

@ -1,4 +1,4 @@
#!/command/with-contenv bash
#!/usr/bin/env bash
set -e

7
etc/entrypoints/web Executable file
View File

@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -e
python manage.py migrate --noinput
exec gunicorn -c etc/gunicorn.conf.py

4
etc/s6-rc.d/rq/run → etc/entrypoints/worker Normal file → Executable file
View File

@ -1,7 +1,5 @@
#!/command/with-contenv bash
#!/usr/bin/env bash
set -e
cd /app
exec python manage.py rqworker --with-scheduler

View File

@ -1,10 +1,16 @@
import gunicorn
wsgi_app = "website.wsgi:application"
accesslog = "-"
disable_redirect_access_to_syslog = True
preload_app = True
bind = "127.0.0.1:8080"
bind = "0.0.0.0:8080"
max_requests = 1200
max_requests_jitter = 50
forwarded_allow_ips = "*"
# Run additional threads so the GIL isn't sitting completely idle
threads = 4
# Run an additional thread so the GIL isn't sitting completely idle
threads = 2
# Replace gunicorn's 'Server' HTTP header
gunicorn.SERVER_SOFTWARE = gunicorn.SERVER = "Wouldn't you like to know"

View File

@ -1,59 +1,39 @@
proxy_cache_path /tmp/nginx_cache levels=1:2 keys_zone=nginxcache:10m max_size=150m inactive=24h;
client_body_temp_path /tmp/client_temp;
proxy_temp_path /tmp/proxy_temp_path;
fastcgi_temp_path /tmp/fastcgi_temp;
uwsgi_temp_path /tmp/uwsgi_temp;
scgi_temp_path /tmp/scgi_temp;
server {
listen 8000;
access_log /dev/stdout;
error_log /dev/stderr;
gzip_static on;
gzip on;
gzip_vary on;
server_tokens off;
set_real_ip_from 0.0.0.0/0;
real_ip_header X-Forwarded-For;
# Override nginx's server header
more_set_headers "Server: Wouldn't you like to know";
server_tokens off;
proxy_buffers 32 4k;
proxy_connect_timeout 240;
proxy_headers_hash_bucket_size 128;
proxy_headers_hash_max_size 1024;
proxy_http_version 1.1;
proxy_read_timeout 240;
proxy_send_timeout 240;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Proxy "";
proxy_cache_use_stale error timeout http_500 http_502 http_503 http_504;
proxy_cache_valid 404 1m;
location / {
proxy_pass http://localhost:8080;
gzip_types *;
proxy_buffers 32 4k;
proxy_connect_timeout 240;
proxy_headers_hash_bucket_size 128;
proxy_headers_hash_max_size 1024;
proxy_http_version 1.1;
proxy_read_timeout 240;
proxy_send_timeout 240;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $host;
proxy_pass http://django:8080;
}
location /static {
proxy_cache nginxcache;
add_header X-Cache-Status $upstream_cache_status;
proxy_pass http://localhost:8080;
expires 1y;
add_header Cache-Control "public, immutable";
alias /app/collected-static;
}
location /media {
proxy_cache nginxcache;
add_header X-Cache-Status $upstream_cache_status;
proxy_pass http://localhost:8080;
expires 1h;
alias /app/media;
}
}

View File

@ -1,7 +0,0 @@
#!/command/with-contenv bash
set -e
cd /app
exec supercronic etc/crontab

View File

@ -1 +0,0 @@
longrun

View File

@ -1,7 +0,0 @@
#!/command/with-contenv bash
set -e
cd /app
exec gunicorn -c etc/gunicorn.conf.py

View File

@ -1 +0,0 @@
longrun

View File

@ -1 +0,0 @@
oneshot

View File

@ -1 +0,0 @@
with-contenv bash -c "cd /app && python manage.py migrate --noinput"

View File

@ -1 +0,0 @@
longrun

View File

@ -1 +0,0 @@
longrun

View File

@ -9,7 +9,7 @@ DEV_COMPOSE := justfile_directory() + "/docker/dev/docker-compose.yml"
build:
docker-compose -f {{ DEV_COMPOSE }} pull
docker-compose -f {{ DEV_COMPOSE }} build
docker-compose -f {{ DEV_COMPOSE }} run --entrypoint=bash --rm --no-deps web -c "npm ci"
docker-compose -f {{ DEV_COMPOSE }} run --rm --no-deps web npm ci
@compose +ARGS:
docker-compose -f {{ DEV_COMPOSE }} {{ ARGS }}
@ -52,9 +52,5 @@ lint_python:
docker-compose -f {{ DEV_COMPOSE }} up -d
docker-compose -f {{ DEV_COMPOSE }} exec web bash
@sh-root:
docker-compose -f {{ DEV_COMPOSE }} up -d
docker-compose -f {{ DEV_COMPOSE }} exec --user=root web bash
@down:
docker-compose -f {{ DEV_COMPOSE }} down

1110
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -20,8 +20,7 @@
},
"author": "Jake Howard",
"devDependencies": {
"eslint": "8.55.0",
"eslint-plugin-unicorn": "49.0.0",
"eslint": "8.33.0",
"prettier": "2.7.1",
"stylelint": "14.16.1",
"stylelint-config-prettier-scss": "0.0.1",
@ -29,19 +28,18 @@
},
"dependencies": {
"@fontsource/fira-code": "5.0.2",
"@fortawesome/fontawesome-free": "6.5.2",
"@fortawesome/fontawesome-free": "6.4.0",
"@ledge/is-ie-11": "7.0.0",
"bulma": "0.9.4",
"elevator.js": "1.0.1",
"esbuild": "0.20.2",
"glightbox": "3.3.0",
"esbuild": "0.19.2",
"glightbox": "3.2.0",
"htmx.org": "1.9.2",
"lite-youtube-embed": "0.3.0",
"lodash.clamp": "4.0.3",
"lite-youtube-embed": "0.2.0",
"lodash.debounce": "4.0.8",
"lodash.throttle": "4.1.1",
"npm-run-all": "4.1.5",
"sass": "1.75.0",
"shareon": "2.4.0"
"sass": "1.67.0",
"shareon": "2.3.0"
}
}

View File

@ -10,13 +10,5 @@
"schedule": ["every weekend"],
"enabled": false
}
],
"regexManagers": [
{
"fileMatch": ["^Dockerfile$"],
"matchStrings": ["ENV S6_OVERLAY_VERSION=(?<currentValue>.*?)\\n"],
"depNameTemplate": "just-containers/s6-overlay",
"datasourceTemplate": "github-releases"
}
]
}

View File

@ -1,43 +1,39 @@
Django==5.0.4
wagtail==5.2.5
Django==4.1.10
wagtail==4.1.5
django-environ==0.11.2
whitenoise[brotli]==6.6.0
Pygments==2.17.2
beautifulsoup4
lxml==5.2.1
requests
wagtail-generic-chooser==0.6
django-rq==2.10.1
django-redis==5.4.0
gunicorn==22.0.0
psycopg==3.1.18
whitenoise[brotli]==6.5.0
Pygments==2.16.1
beautifulsoup4==4.11.2
lxml==4.9.1
requests==2.31.0
wagtail-generic-chooser==0.5.1
django-rq==2.8.0
django-redis==5.3.0
gunicorn==21.2.0
psycopg2==2.9.6
djangorestframework
django-htmx==1.17.2
wagtail-metadata==5.0.0
django-htmx==1.16.0
wagtail-metadata==4.0.3
django-plausible==0.5.0
sentry-sdk
sentry-sdk==1.29.2
django-sri==0.7.0
wagtail-2fa==1.6.9
wagtail-2fa==1.6.5
django-health-check==3.17.0
wagtail-autocomplete==0.11.0
Wand==0.6.13
wagtail-autocomplete==0.10.0
Wand==0.6.11
django3-cache-decorator==0.5.2
django-cors-headers==4.3.1
django-cors-headers==4.2.0
django-csp==3.7
django-permissions-policy==4.18.0
django-permissions-policy==4.17.0
django-enforce-host==1.1.0
django-proxy==1.2.2
wagtail-lite-youtube-embed==0.1.0
django-minify-html==1.7.1
metadata-parser==0.12.1
# DRF OpenAPI dependencies
uritemplate
PyYAML
inflection
# Use custom `wagtail-favicon` with performance improvements
git+https://github.com/RealOrangeOne/wagtail-favicon@b892165e047b35c46d7244109b9ad9226d32a213
git+https://github.com/RealOrangeOne/wagtail-favicon@4586efaac746085338fc7d61713006d9adc62d2e
# Use custom `wagtail-draftail-snippet` with support for Wagtail 5.x
git+https://github.com/RealOrangeOne/wagtail-draftail-snippet@74ed858bd958a066d5aee295c9848257107b1546
# Use custom `wagtail-draftail-snippet` with support for Wagtail 4.1
git+https://github.com/RealOrangeOne/wagtail-draftail-snippet@0924ab12b1ca205b94ccd9a34ecc446d7ac422e5

View File

@ -20,6 +20,8 @@ mkcontrib elevator-js node_modules/elevator.js/demo/music/*
mkcontrib shareon node_modules/shareon/dist/{shareon.iife.*,shareon.min.css*}
mkcontrib fira-code node_modules/@fontsource/fira-code/latin.css
mkcontrib fira-code/files node_modules/@fontsource/fira-code/files/fira-code-latin-*
mkcontrib htmx node_modules/htmx.org/dist/{htmx.min.js,ext}
mkcontrib glightbox node_modules/glightbox/dist/css/glightbox.min.css
curl -sf -L https://raw.githubusercontent.com/genmon/aboutfeeds/main/tools/pretty-feed-v3.xsl -o $CONTRIB_DIR/pretty-feed-v3.xsl

View File

@ -2,8 +2,6 @@ const Elevator = require("elevator.js");
const debounce = require("lodash.debounce");
const throttle = require("lodash.throttle");
require("htmx.org");
const HERO = document.querySelector("section.hero");
const ROOT = document.querySelector(":root");
@ -25,10 +23,7 @@ function scrollToElement(element, behavior = "smooth") {
}
function handleHeroStuck() {
if (
HERO.getBoundingClientRect().top <= 0 &&
window.getComputedStyle(HERO).position === "sticky"
) {
if (HERO.getBoundingClientRect().top <= 0) {
HERO.classList.add("stuck");
} else {
HERO.classList.remove("stuck");
@ -44,18 +39,18 @@ window.addEventListener("load", () => {
navbar.classList.toggle("is-active");
});
for (const element of document.querySelectorAll(".scroll-top")) {
document.querySelectorAll(".scroll-top").forEach((element) => {
element.addEventListener("click", () => {
window.scrollTo({ top: 0, behavior: "smooth" });
});
}
});
for (const element of document.querySelectorAll("#table-of-contents li a")) {
document.querySelectorAll("#table-of-contents li a").forEach((element) => {
element.addEventListener("click", (event) => {
event.preventDefault();
scrollToElement(document.querySelector(event.target.hash));
});
}
});
const elevatorButton = document.getElementById("to-top-elevator");
new Elevator({
@ -65,12 +60,12 @@ window.addEventListener("load", () => {
preloadAudio: false,
});
for (const codeBlock of document.querySelectorAll(".block-code")) {
document.querySelectorAll(".block-code").forEach((codeBlock) => {
const clipboardIcon = codeBlock.querySelector(".code-copy");
// There may not be an icon
if (!clipboardIcon) {
continue;
return;
}
clipboardIcon.addEventListener("click", (event) => {
@ -87,26 +82,20 @@ window.addEventListener("load", () => {
}, 3000);
});
});
}
});
});
window.addEventListener("DOMContentLoaded", () => {
if (HERO) {
setHeroHeight();
handleHeroStuck();
window.addEventListener("resize", debounce(setHeroHeight, 2000));
window.addEventListener("scroll", throttle(handleHeroStuck, 100));
}
setHeroHeight();
if (window.location.hash <= 1) {
return;
}
let scrollTarget;
let scrollTarget = null;
try {
scrollTarget = document.getElementById(window.location.hash.slice(1));
} catch (error) {
} catch (e) {
// Probably an invalid selector - just ignore it
}
@ -116,3 +105,8 @@ window.addEventListener("DOMContentLoaded", () => {
scrollToElement(scrollTarget, "auto");
});
if (HERO) {
window.addEventListener("resize", debounce(setHeroHeight, 2000));
window.addEventListener("scroll", throttle(handleHeroStuck, 100));
}

View File

@ -1,35 +0,0 @@
require("lite-youtube-embed");
const GLightbox = require("glightbox");
const clamp = require("lodash.clamp");
const SCROLL_INDICATOR = document.getElementById("scroll-indicator");
const CONTENT = document.querySelector(".container.content");
function handleScrollIndicator() {
// How far down the page does the content start?
const initialScroll = CONTENT.getBoundingClientRect().top + window.scrollY;
const contentHeight = CONTENT.getBoundingClientRect().height;
// How far down the page do we consider the content "read"?
const scrollTarget = window.innerHeight * 0.75;
const scrolled =
(window.scrollY - initialScroll + scrollTarget) / contentHeight;
const scrolledPercentage = clamp(scrolled * 100, 0, 100);
SCROLL_INDICATOR.style.width = `${scrolledPercentage.toFixed(2)}%`;
}
window.addEventListener("load", () => {
if (CONTENT && SCROLL_INDICATOR) {
window.addEventListener("resize", handleScrollIndicator);
window.addEventListener("scroll", handleScrollIndicator);
// Initialize the indicator
handleScrollIndicator();
}
GLightbox({});
});

View File

@ -0,0 +1,5 @@
const GLightbox = require("glightbox");
window.addEventListener("load", () => {
GLightbox({});
});

View File

@ -0,0 +1 @@
require("lite-youtube-embed");

View File

@ -8,7 +8,7 @@ section.content {
h5,
h6 {
.heading-anchor {
scroll-margin-top: var(--hero-height); // hero height (ish)
scroll-margin-top: var(--hero-height); // navbar height (ish)
margin-right: 0.5rem;
font-weight: 100;
}
@ -56,16 +56,9 @@ section.content {
.gslide-image img {
object-fit: contain !important;
// Manually set sizes, as mermaid images are very small
width: 80vw !important;
&[src*="mermaid.ink"] {
@include dark-mode {
filter: invert(100%);
}
}
}
#comments {
scroll-margin-top: var(--hero-height); // hero height (ish)
}

View File

@ -95,10 +95,6 @@ section.hero {
padding-bottom: unset;
}
.columns {
margin-bottom: unset !important;
}
nav.breadcrumb {
margin-bottom: 0 !important;
}
@ -125,21 +121,6 @@ section.hero {
box-shadow: $shadow;
}
}
.scroll-indicator-container {
height: 5px;
#scroll-indicator {
transition-duration: 0.3s;
transition-property: background-color;
height: 100%;
width: 0%;
}
}
&.stuck #scroll-indicator {
background-color: $primary;
}
}
#view-restriction-banner {

View File

@ -1,27 +1,17 @@
body.page-homepage {
min-height: 100vh;
height: 100vh;
main {
background-repeat: no-repeat;
background-size: cover;
background-position: center;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
color: $white;
margin-bottom: 0;
padding-bottom: 2rem;
display: flex;
flex-direction: column;
justify-content: space-evenly;
}
.top-section {
min-width: 90%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin: 2rem;
margin-bottom: 1rem;
}
h1 {
@ -33,7 +23,7 @@ body.page-homepage {
min-width: 45%;
}
.box {
.latest {
padding: 0.25rem 0.5rem;
margin-top: 2rem;
background-color: color.adjust($dark, $alpha: -0.2);
@ -69,60 +59,4 @@ body.page-homepage {
#to-top {
display: none;
}
.content-list {
width: 90%;
.card-image {
overflow: hidden;
}
img {
width: 100%;
height: 100%;
object-fit: cover;
position: absolute;
transition: filter 0.25s;
filter: brightness(0.85);
}
p {
position: absolute;
bottom: 0;
color: $white;
padding: 0.5rem;
transition: background-color 0.25s;
width: 100%;
background-color: rgb(0 0 0 / 55%);
}
figure {
margin: 0;
text-align: initial;
}
a:hover {
img {
filter: unset;
}
p {
background-color: rgb(0 0 0 / 75%);
}
}
}
.recent-posts {
display: flex;
align-items: center;
flex-direction: column;
width: 100%;
flex-grow: unset;
margin-top: 2rem;
.box {
margin: 0 auto;
display: inline-block;
}
}
}

View File

@ -18,17 +18,6 @@
.title {
margin-bottom: 0;
a {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
i {
font-size: 50%;
}
}
}
.content-details {
@ -53,13 +42,9 @@
.columns {
width: 100%;
}
nav.breadcrumb {
margin-bottom: 0;
}
}
.container.listing {
.page-blogpostlistpage {
.date-header {
font-size: $size-2;
font-weight: $weight-bold;

View File

@ -14,6 +14,10 @@ body.page-searchpage {
max-width: 80%;
}
.htmx-request i {
animation: search-loading 1.5s linear infinite;
}
#search-results > p {
font-size: 1.5rem;
text-align: center;
@ -43,19 +47,9 @@ body.page-searchpage {
#search-page-indicator {
text-align: center;
margin-top: 1.5rem;
font-size: $size-3;
&:not(.htmx-request) {
display: none;
}
}
// The search icon is hidden during requests
#search-icon {
opacity: 1 !important;
&.htmx-request {
opacity: 0 !important;
}
}
}

View File

@ -1,5 +1,4 @@
.shareon-container {
scroll-margin-top: var(--hero-height); // hero height (ish)
margin-top: 1rem;
.shareon {

View File

@ -4,14 +4,12 @@ section#similar-content {
align-items: center;
margin-top: 2rem;
h2 {
.subtitle {
color: inherit;
margin-bottom: 0;
}
.view-all {
margin-top: 0.25rem;
margin-bottom: 0.5rem;
margin: $block-spacing 0;
}
.media {

View File

@ -1,4 +1,4 @@
$support-pill-size: 60px;
$support-pill-size: 50px;
$support-pill-position: 1.5rem;
.tag.support-pill {
@ -13,11 +13,6 @@ $support-pill-position: 1.5rem;
height: $support-pill-size;
font-size: 100%;
z-index: $dropdown-content-z;
padding: none;
&:hover {
background-color: $primary-dark;
}
@include mobile {
display: none;

View File

@ -1,3 +0,0 @@
@import "lite-youtube-embed/src/lite-yt-embed";
@import "shareon/dist/shareon.min";
@import "glightbox/dist/css/glightbox";

View File

@ -0,0 +1 @@
@import "lite-youtube-embed/src/lite-yt-embed";

View File

@ -3,5 +3,4 @@ from rest_framework.pagination import PageNumberPagination
class CustomPageNumberPagination(PageNumberPagination):
page_size = 10
page_size_query_param = "page_size"
max_page_size = 25
max_page_size = 30

View File

@ -36,14 +36,3 @@ class LMOTFYSerializer(serializers.ModelSerializer):
return self.context["request"].build_absolute_uri(image_url)
return image_url
class LatestPostSerializer(serializers.ModelSerializer):
full_url = serializers.SerializerMethodField()
class Meta:
model = BlogPostPage
fields = read_only_fields = ["full_url", "title", "date"]
def get_full_url(self, page: Page) -> str:
return page.get_full_url(request=self.context["request"])

View File

@ -86,20 +86,3 @@ class SchemaTestCase(APISimpleTestCase):
def test_schema(self) -> None:
response = self.client.get(reverse("api:schema"))
self.assertEqual(response.status_code, 200)
class LatestPostsAPIViewTestCase(APITestCase):
url = reverse("api:latest-posts")
@classmethod
def setUpTestData(cls) -> None:
cls.home_page = HomePage.objects.get()
for i in range(4):
BlogPostPageFactory(parent=cls.home_page, title=f"Post {i}")
def test_accessible(self) -> None:
with self.assertNumQueries(5):
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["count"], 4)

View File

@ -10,7 +10,6 @@ api_urlpatterns = [
path("ping/", views.PingAPIView.as_view(), name="ping"),
path("page-links/", views.PageLinksAPIView.as_view(), name="page-links"),
path("lmotfy/", views.LMOTFYAPIView.as_view(), name="lmotfy"),
path("latest-posts/", views.LatestPostsAPIView.as_view(), name="latest-posts"),
]
schema_view = get_schema_view(

View File

@ -60,20 +60,3 @@ class SwaggerRedirectView(RedirectView):
return HttpResponseRedirect(
self.SWAGGER_EDITOR_URL + request.build_absolute_uri(reverse("api:schema"))
)
class LatestPostsAPIView(ListAPIView):
"""
List my latest blog posts
"""
serializer_class = serializers.LatestPostSerializer
pagination_class = CustomPageNumberPagination
def get_queryset(self) -> PageQuerySet:
return (
BlogPostPage.objects.live()
.public()
.only("id", "url_path", "title", "date")
.order_by("-date")
)

View File

@ -1,5 +1,3 @@
import factory
from website.common.factories import BaseContentFactory, BaseListingFactory
from . import models
@ -13,11 +11,3 @@ class BlogPostListPageFactory(BaseListingFactory):
class BlogPostPageFactory(BaseContentFactory):
class Meta:
model = models.BlogPostPage
class ExternalBlogPostPageFactory(BaseContentFactory):
external_url = factory.Faker("url")
class Meta:
model = models.ExternalBlogPostPage
exclude = ["subtitle"]

View File

@ -1,45 +0,0 @@
# Generated by Django 5.0.4 on 2024-05-29 21:10
import django.db.models.deletion
import django.utils.timezone
import modelcluster.fields
import wagtailmetadata.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("blog", "0005_auto_20230602_1236"),
("wagtailcore", "0089_log_entry_data_json_null_to_object"),
]
operations = [
migrations.CreateModel(
name="ExternalBlogPostPage",
fields=[
(
"page_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="wagtailcore.page",
),
),
("external_url", models.URLField()),
("date", models.DateField(default=django.utils.timezone.now)),
(
"tags",
modelcluster.fields.ParentalManyToManyField(
blank=True, to="blog.blogposttagpage"
),
),
],
options={
"abstract": False,
},
bases=("wagtailcore.page", wagtailmetadata.models.MetadataMixin),
),
]

View File

@ -1,26 +1,18 @@
from typing import Any, Optional
from urllib.parse import urlsplit
from typing import Any, Optional, Type
from django.contrib.postgres.search import TrigramSimilarity
from django.db import models
from django.db.models.functions import Cast, Coalesce
from django.http import HttpRequest, HttpResponse, HttpResponsePermanentRedirect
from django.db.models.functions import Cast
from django.utils import timezone
from django.utils.functional import cached_property
from metadata_parser import MetadataParser
from modelcluster.fields import ParentalManyToManyField
from wagtail.admin.panels import FieldPanel
from wagtail.models import Page, PageQuerySet, Site
from wagtail.search import index
from wagtailautocomplete.edit_handlers import AutocompletePanel
from website.common.models import BaseContentPage, BaseListingPage, BasePage
from website.common.utils import (
TocEntry,
extend_query_params,
get_page_metadata,
get_url_mime_type,
)
from website.common.models import BaseContentPage, BaseListingPage
from website.common.utils import TocEntry
from website.common.views import ContentPageFeed
from website.contrib.singleton_page.utils import SingletonPageCache
@ -31,8 +23,6 @@ class BlogPostListPage(BaseListingPage):
"blog.BlogPostTagListPage",
"blog.BlogPostCollectionListPage",
"blog.BlogPostCollectionPage",
"blog.BlogPostCollectionPage",
"blog.ExternalBlogPostPage",
]
@cached_property
@ -41,15 +31,18 @@ class BlogPostListPage(BaseListingPage):
def get_listing_pages(self) -> models.QuerySet:
return (
Page.objects.live()
BlogPostPage.objects.descendant_of(self)
.live()
.public()
.annotate(date=Coalesce("blogpostpage__date", "externalblogpostpage__date"))
.descendant_of(self)
.type(BlogPostPage, ExternalBlogPostPage)
.specific()
.order_by("-date", "title")
)
@property
def feed_class(self) -> Type[ContentPageFeed]:
from .views import BlogPostPageFeed
return BlogPostPageFeed
@cached_property
def tag_list_page_url(self) -> Optional[str]:
return SingletonPageCache.get_url(BlogPostTagListPage)
@ -75,58 +68,41 @@ class BlogPostPage(BaseContentPage):
def tag_list_page_url(self) -> Optional[str]:
return SingletonPageCache.get_url(BlogPostTagListPage)
@cached_property
def tags_list(self) -> models.QuerySet:
"""
Use this to get a page's tags.
"""
tags = self.tags.order_by("slug")
# In drafts, `django-modelcluster` doesn't support these filters
if isinstance(tags, PageQuerySet):
return tags.public().live()
return tags
@cached_property
def blog_post_list_page_url(self) -> Optional[str]:
return SingletonPageCache.get_url(BlogPostListPage)
def get_similar_posts(self) -> models.QuerySet:
listing_pages = BlogPostListPage.objects.get().get_listing_pages()
try:
listing_pages = BlogPostListPage.objects.get().get_listing_pages()
except BlogPostListPage.DoesNotExist:
return BlogPostPage.objects.none()
similar_posts = listing_pages.exclude(id=self.id).alias(
title_similarity=TrigramSimilarity("title", self.title),
# If this page has no subtitle, ignore it as part of similarity
subtitle_similarity=TrigramSimilarity("subtitle", self.subtitle)
if self.subtitle
else models.Value(1),
)
page_tags = list(self.tags.public().live().values_list("id", flat=True))
# If this page has no tags, ignore it as part of similarity
divisor = len(page_tags) if page_tags else models.Value(1)
page_tags = list(self.tags.values_list("id", flat=True))
similar_posts = similar_posts.alias(
# If this page has no tags, ignore it as part of similarity
# NB: Cast to a float, because `COUNT` returns a `bigint`.
_blog_tag_similarity=Cast(
models.Count(
"blogpostpage__tags",
filter=models.Q(blogpostpage__tags__in=page_tags),
),
tag_similarity=Cast(
models.Count("tags", filter=models.Q(tags__in=page_tags)),
output_field=models.FloatField(),
)
/ divisor,
_external_tag_similarity=Cast(
models.Count(
"externalblogpostpage__tags",
filter=models.Q(externalblogpostpage__tags__in=page_tags),
),
output_field=models.FloatField(),
)
/ divisor,
tag_similarity=models.F("_blog_tag_similarity")
+ models.F("_external_tag_similarity"),
/ len(page_tags)
if page_tags
else models.Value(1)
)
similar_posts = similar_posts.annotate(
similarity=(models.F("tag_similarity") * 2)
+ (models.F("title_similarity") * 10)
+ (models.F("subtitle_similarity"))
).order_by("-similarity")[:3]
return similar_posts
@ -154,12 +130,13 @@ class BlogPostTagPage(BaseListingPage):
def get_listing_pages(self) -> models.QuerySet:
blog_list_page = BlogPostListPage.objects.get()
listing_pages = blog_list_page.get_listing_pages()
return blog_list_page.get_listing_pages().filter(tags=self)
return listing_pages.filter(
models.Q(blogpostpage__tags=self)
| models.Q(externalblogpostpage__tags=self)
).distinct()
@property
def feed_class(self) -> Type[ContentPageFeed]:
from .views import BlogPostPageFeed
return BlogPostPageFeed
class BlogPostCollectionListPage(BaseListingPage):
@ -190,100 +167,8 @@ class BlogPostCollectionPage(BaseListingPage):
.order_by("-date", "title")
)
@property
def feed_class(self) -> Type[ContentPageFeed]:
from .views import BlogPostPageFeed
class ExternalBlogPostPage(BaseContentPage):
subpage_types: list[Any] = []
parent_page_types = [BlogPostListPage]
preview_modes: list[Any] = []
is_external = True
# Some `BaseContentPage` fields aren't relevant
body = None
subtitle = None
hero_image = None
hero_unsplash_photo = None
external_url = models.URLField()
tags = ParentalManyToManyField("blog.BlogPostTagPage", blank=True)
date = models.DateField(default=timezone.now)
content_panels = BasePage.content_panels + [FieldPanel("external_url")]
promote_panels = BaseContentPage.promote_panels + [
FieldPanel("date"),
AutocompletePanel("tags"),
]
search_fields = BaseContentPage.search_fields + [
index.RelatedFields("tags", [index.SearchField("title", boost=1)]),
index.SearchField("external_url"),
]
@cached_property
def tag_list_page_url(self) -> Optional[str]:
return SingletonPageCache.get_url(BlogPostTagListPage)
@cached_property
def tags_list(self) -> models.QuerySet:
"""
Use this to get a page's tags.
"""
tags = self.tags.order_by("slug")
# In drafts, `django-modelcluster` doesn't support these filters
if isinstance(tags, PageQuerySet):
return tags.public().live()
return tags
@cached_property
def metadata(self) -> MetadataParser:
return get_page_metadata(self.external_url)
@cached_property
def _body_html(self) -> str:
try:
return self.metadata.get_metadatas("description")[0]
except (KeyError, IndexError, TypeError):
return ""
@cached_property
def plain_text(self) -> str:
# The metadata is already just text
return self._body_html
def hero_url(
self, image_size: str, wagtail_image_spec_extra: Optional[str] = None
) -> Optional[str]:
try:
return self.metadata.get_metadatas("image")[0]
except (KeyError, IndexError, TypeError):
return None
@cached_property
def hero_image_url(self) -> str:
return ""
@cached_property
def hero_image_alt(self) -> str:
return ""
def get_meta_image_mime(self) -> Optional[str]:
return get_url_mime_type(self.hero_url(""))
def get_url(
self, request: HttpRequest | None = None, current_site: Site | None = None
) -> str:
return self.get_full_url(request)
def get_full_url(self, request: HttpRequest | None = None) -> str:
full_url = urlsplit(super().get_full_url(request))
return extend_query_params(self.external_url, {"utm_source": full_url.netloc})
def serve(self, request: HttpRequest, *args: tuple, **kwargs: dict) -> HttpResponse:
"""
Send the user directly to the external page
"""
return HttpResponsePermanentRedirect(self.get_full_url(request))
return BlogPostPageFeed

View File

@ -1,19 +1,21 @@
{% extends "common/listing_page.html" %}
{% load wagtailroutablepage_tags %}
{% block hero_buttons %}
<a class="button is-radiusless" href="{{ page.tag_list_page_url }}" title="View tags"><i class="fas fa-tags" aria-hidden="true"></i></a>
{{ block.super }}
{% endblock %}
{% block post_content %}
<section class="container listing">
<section class="container">
{% for page in listing_pages %}
{% ifchanged %}
<h2 id="date-{{ page.date|date:'Y-m' }}" class="date-header">
<h3 id="date-{{ page.date|date:'Y-m' }}" class="date-header">
<time datetime="{{ page.date|date:'Y-m' }}" title="{{ page.date|date:'F Y' }}">
{{ page.date|date:"Y-m" }}
</time>
</h2>
</h3>
{% endifchanged %}
{% include "common/listing-item.html" %}

View File

@ -1,32 +1,25 @@
{% extends "common/content_page.html" %}
{% load wagtail_cache navbar_tags %}
{% load cache util_tags %}
{% block post_content %}
{{ block.super }}
{% if not request.is_preview %}
{% include "common/shareon.html" %}
{% cache FRAGMENT_CACHE_TTL|jitter:FRAGMENT_CACHE_TTL_JITTER "similar-content" page.id request.is_preview %}
<section class="container similar-content" id="similar-content">
<h2 class="subtitle is-size-2">Similar content</h2>
{% wagtailpagecache FRAGMENT_CACHE_TTL "similar-content" %}
<section class="container similar-content" id="similar-content">
<h2 class="subtitle is-size-2">Similar content</h2>
{% for page in page.get_similar_posts %}
{% block listing_item %}
{% include "common/listing-item.html" %}
{% endblock %}
{% endfor %}
<p class="view-all">
<a href="{{ page.blog_post_list_page_url }}">View all &rarr;</a>
</p>
{% for page in page.get_similar_posts %}
{% block listing_item %}
{% include "common/listing-item.html" with show_listing_images=True %}
{% endblock %}
{% endfor %}
</section>
{% endwagtailpagecache %}
{% include "common/comments.html" %}
{% if not request.user.is_authenticated %}
{% support_pill %}
<p class="subtitle view-all">
<a href="{{ page.blog_post_list_page_url }}">View all &rarr;</a>
</p>
</section>
{% endcache %}
{% endif %}
{% endif %}
{% endblock %}

View File

@ -7,14 +7,11 @@
<div class="card-content">
<div class="media">
<div class="media-content">
<h2 class="title is-4">
<p class="title is-4">
<a href="{% pageurl page %}">{{ page.title }}</a>
</h2>
</p>
<p class="subtitle is-6">{{ page.summary }}</p>
</div>
<div class="media-right">
{% with posts_count=page.get_listing_pages.count %}<p class="subtitle is-6 has-text-weight-light">{{ posts_count }} post{{ posts_count|pluralize }}</p>{% endwith %}
</div>
</div>
</div>
</div>

View File

@ -1,16 +0,0 @@
{% comment %}
This template is never used, but exists just in case.
{% endcomment %}
<!DOCTYPE html>
<html lang="en-GB">
<head>
<title>Redirecting...</title>
<link rel="canonical" href="{{ page.external_url }}" />
<meta charset="utf-8" />
<meta http-equiv="refresh" content="0; url={{ page.external_url }}" />
</head>
<body>
<p>Redirecting...</p>
</body>
</html>

View File

@ -1,13 +1,8 @@
from django.test import TestCase
from django.urls import reverse
from website.home.models import HomePage
from .factories import (
BlogPostListPageFactory,
BlogPostPageFactory,
ExternalBlogPostPageFactory,
)
from .factories import BlogPostListPageFactory, BlogPostPageFactory
class BlogPostPageTestCase(TestCase):
@ -22,7 +17,7 @@ class BlogPostPageTestCase(TestCase):
self.assertEqual(response.status_code, 200)
def test_queries(self) -> None:
with self.assertNumQueries(43):
with self.assertNumQueries(48):
self.client.get(self.page.url)
@ -73,37 +68,22 @@ class BlogPostListPageTestCase(TestCase):
BlogPostPageFactory(parent=cls.page)
BlogPostPageFactory(parent=cls.page)
ExternalBlogPostPageFactory(parent=cls.page, external_url="https://example.com")
def test_accessible(self) -> None:
response = self.client.get(self.page.url)
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.context["listing_pages"]), 3)
self.assertEqual(len(response.context["listing_pages"]), 2)
self.assertContains(response, self.page.reverse_subpage("feed"))
def test_queries(self) -> None:
with self.assertNumQueries(43):
with self.assertNumQueries(44):
self.client.get(self.page.url)
def test_feed_accessible(self) -> None:
response = self.client.get(self.page.url + self.page.reverse_subpage("feed"))
self.assertRedirects(
response, reverse("feed"), status_code=301, fetch_redirect_response=True
)
class ExternalBlogPostPageTestCase(TestCase):
@classmethod
def setUpTestData(cls) -> None:
cls.home_page = HomePage.objects.get()
cls.blog_post_list_page = BlogPostListPageFactory(parent=cls.home_page)
cls.page = ExternalBlogPostPageFactory(parent=cls.blog_post_list_page)
def test_redirects(self) -> None:
with self.assertNumQueries(10):
response = self.client.get(self.page.url)
self.assertRedirects(
response,
self.page.external_url + "?utm_source=localhost",
status_code=301,
fetch_redirect_response=False,
)
with self.assertNumQueries(12):
response = self.client.get(
self.page.url + self.page.reverse_subpage("feed")
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response["Content-Type"], "application/xml")
self.assertContains(response, "xml-stylesheet")

10
website/blog/views.py Normal file
View File

@ -0,0 +1,10 @@
from datetime import datetime, time
from website.common.views import ContentPageFeed
from .models import BlogPostPage
class BlogPostPageFeed(ContentPageFeed):
def item_pubdate(self, item: BlogPostPage) -> datetime:
return datetime.combine(item.date, time())

38
website/common/embed.py Normal file
View File

@ -0,0 +1,38 @@
import re
from django.utils.html import format_html
from wagtail.embeds.finders.oembed import OEmbedFinder
from wagtail.embeds.oembed_providers import youtube
class YouTubeLiteEmbedFinder(OEmbedFinder):
"""
A modified OEmbed finder uses lite-youtube-embed instead
https://github.com/paulirish/lite-youtube-embed
"""
EMBED_ID_RE = re.compile(r"\/embed\/(.*?)\?")
def __init__(
self, providers: list[dict] | None = None, options: dict | None = None
):
super().__init__(providers=[youtube], options=options)
@classmethod
def _get_video_id(cls, html: str) -> str:
matched = cls.EMBED_ID_RE.search(html)
if matched is None:
raise ValueError(f"Unable to find video id in {html}")
return matched.group(1)
def find_embed(self, *args: list, **kwargs: dict) -> dict:
result = super().find_embed(*args, **kwargs)
video_id = self._get_video_id(result["html"])
result["html"] = format_html(
"<lite-youtube videoid='{}' playlabel='{}' backgroundImage='{}'></lite-youtube>",
video_id,
result["title"],
result["thumbnail_url"],
)
return result

View File

@ -1,23 +0,0 @@
from typing import Any
from django.http import HttpRequest
from django.utils.feedgenerator import DefaultFeed
from django.utils.xmlutils import SimplerXMLGenerator
class CustomFeed(DefaultFeed):
"""
A custom feed generator with additional features.
"""
def __init__(self, request: HttpRequest, **kwargs: Any) -> None:
super().__init__(**kwargs)
self.request = request
def add_root_elements(self, handler: SimplerXMLGenerator) -> None:
super().add_root_elements(handler)
handler.startElement("image", {})
handler.addQuickElement("url", self.request.build_absolute_uri("/favicon.ico"))
handler.addQuickElement("title", self.feed["title"])
handler.addQuickElement("link", self.feed["link"])
handler.endElement("image")

View File

@ -1,9 +0,0 @@
from django_minify_html.middleware import MinifyHtmlMiddleware
class CustomMinifyHtmlMiddleware(MinifyHtmlMiddleware):
minify_args = {
"do_not_minify_doctype": True,
"ensure_spec_compliant_unquoted_attribute_values": True,
"keep_spaces_between_attributes": True,
}

View File

@ -1,16 +1,18 @@
from datetime import timedelta
from math import ceil
from typing import Any, Optional
from typing import Any, Optional, Type
from django.contrib.humanize.templatetags.humanize import NaturalTimeFormatter
from django.contrib.syndication.views import Feed
from django.core.paginator import EmptyPage, Paginator
from django.core.paginator import Page as PaginatorPage
from django.db import models
from django.http.request import HttpRequest
from django.http.response import Http404, HttpResponse, HttpResponseBadRequest
from django.shortcuts import redirect
from django.template.defaultfilters import pluralize
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.utils.functional import cached_property, classproperty
from django.utils.text import Truncator, slugify
from django.utils.text import slugify
from django.views.decorators.cache import cache_page
from wagtail.admin.panels import FieldPanel, MultiFieldPanel
from wagtail.contrib.routable_page.models import RoutablePageMixin, route
from wagtail.contrib.settings.models import BaseGenericSetting, register_setting
@ -30,11 +32,12 @@ from .serializers import PaginationSerializer
from .streamfield import add_heading_anchors, get_blocks, get_content_html
from .utils import (
TocEntry,
extend_query_params,
count_words,
extract_text,
get_site_title,
get_table_of_contents,
get_url_mime_type,
truncate_string,
)
@ -114,36 +117,29 @@ class BaseContentPage(BasePage, MetadataMixin):
@cached_property
def reading_time_display(self) -> str:
reading_time_seconds = ceil(self.reading_time.total_seconds())
# Show nothing if under a minute. Probably won't be shown anyway
if reading_time_seconds < 60:
return ""
# If under an hour, show minutes
if reading_time_seconds < 3600:
minutes = ceil(reading_time_seconds / 60)
return f"{minutes} minute{pluralize(minutes)}"
# After that, show hours
hours = ceil(reading_time_seconds / 60 / 60)
return f"{hours} hour{pluralize(hours)}"
return NaturalTimeFormatter.string_for(
timezone.now() - self.reading_time
).removesuffix(" ago")
@cached_property
def show_reading_time(self) -> bool:
"""
Only show reading time if it's longer than 2 minutes (rounded)
Only show reading time if it's longer than 2 minutes
"""
return ceil(self.reading_time.total_seconds() / 60) >= 2
return self.reading_time.total_seconds() >= 120
@cached_property
def word_count(self) -> int:
return len(self.plain_text.split())
return count_words(self.plain_text)
@cached_property
def summary(self) -> str:
return Truncator(self.plain_text).words(50)
summary = truncate_string(self.plain_text, 50)
if summary and summary != self.plain_text and not summary.endswith("."):
summary += ""
return summary
@cached_property
def body_html(self) -> str:
@ -181,7 +177,6 @@ class BaseContentPage(BasePage, MetadataMixin):
for size, width in UNSPLASH_SIZES.items()
}
@cached_property
def hero_image_url(self) -> Optional[str]:
return self.hero_url("regular")
@ -189,13 +184,6 @@ class BaseContentPage(BasePage, MetadataMixin):
def list_image_url(self) -> Optional[str]:
return self.hero_url("small")
@cached_property
def hero_image_alt(self) -> str:
if self.hero_unsplash_photo_id is None:
return ""
return self.hero_unsplash_photo.data.get("description", "")
def get_meta_url(self) -> str:
return self.full_url
@ -216,7 +204,7 @@ class BaseContentPage(BasePage, MetadataMixin):
return self.html_title
def get_meta_description(self) -> str:
return self.summary or self.get_meta_title()
return self.summary
def get_object_title(self) -> str:
return ""
@ -227,7 +215,7 @@ class ContentPage(BaseContentPage):
class BaseListingPage(RoutablePageMixin, BaseContentPage):
PAGE_SIZE = 30
PAGE_SIZE = 20
subtitle = None
content_panels = [
@ -257,12 +245,7 @@ class BaseListingPage(RoutablePageMixin, BaseContentPage):
def get_context(self, request: HttpRequest) -> dict:
context = super().get_context(request)
listing_pages = self.get_paginator_page()
context["listing_pages"] = listing_pages
# Show listing images if at least 1 page has an image
context["show_listing_images"] = any(p.list_image_url for p in listing_pages)
context["listing_pages"] = self.get_paginator_page()
return context
@cached_property
@ -273,6 +256,12 @@ class BaseListingPage(RoutablePageMixin, BaseContentPage):
def show_reading_time(self) -> bool:
return False
@property
def feed_class(self) -> Type[Feed]:
from .views import ContentPageFeed
return ContentPageFeed
@route(r"^$")
def index_route(self, request: HttpRequest) -> HttpResponse:
self.serializer = PaginationSerializer(data=request.GET)
@ -280,28 +269,14 @@ class BaseListingPage(RoutablePageMixin, BaseContentPage):
return HttpResponseBadRequest()
return super().index_route(request)
def get_meta_url(self) -> str:
query_data = self.serializer.validated_data.copy()
if query_data["page"] == 1:
del query_data["page"]
url = super().get_meta_url()
return extend_query_params(url, query_data)
@route(r"^feed/$")
@method_decorator(cache_page(60 * 30))
def feed(self, request: HttpRequest) -> HttpResponse:
return redirect("feed", permanent=True)
@route(r"^random/$")
def random(self, request: HttpRequest) -> HttpResponse:
page = self.get_listing_pages().order_by("?").first()
if page is None:
response = redirect(self.get_url(request=request), permanent=False)
else:
response = redirect(page.get_url(request=request), permanent=False)
response.headers["X-Robots-Tag"] = "noindex"
return response
return self.feed_class(
self.get_listing_pages(),
self.get_full_url(request),
self.html_title_tag,
)(request)
class ListingPage(BaseListingPage):

View File

@ -1,4 +1,4 @@
{% load static wagtailcore_tags wagtailuserbar navbar_tags footer_tags plausible_wagtail favicon_tags sri wagtail_cache %}
{% load static wagtailcore_tags wagtailuserbar navbar_tags footer_tags plausible_wagtail favicon_tags sri cache %}
<!DOCTYPE html>
<html lang="en-GB">
@ -14,8 +14,6 @@
{% block extra_head %}{% endblock %}
<link rel="search" type="application/opensearchdescription+xml" href="{% url 'opensearch' %}" title="Orange search" />
<link rel="alternate" type="application/rss+xml" href="{% url 'feed' %}" />
<link rel="me" href="https://{{ ACTIVITYPUB_HOST }}/@jake" />
@ -32,31 +30,32 @@
<body class="{% block body_class %}{% endblock %}">
{% wagtailuserbar %}
{% wagtailcache 1800 "navbar" %}
{% navbar %}
{% endwagtailcache %}
{% cache 1800 "navbar" request.is_preview %}
{% navbar %}
{% endcache %}
{% block main %}
<main>
{% block main_content %}{% endblock %}
</main>
{% endblock %}
{% block main %}
<main>
{% block main_content %}{% endblock %}
</main>
{% endblock %}
{% wagtailcache 1800 "footer" %}
{% footer %}
{% endwagtailcache %}
{% cache 1800 "footer" request.is_preview %}
{% footer %}
{% endcache %}
{# Not async to avoid bright flashes #}
{% sri_static "js/dark-mode.js" %}
<script async defer type="text/javascript" src="{% static 'js/base.js' %}" integrity="{% sri_integrity_static 'js/base.js' %}"></script>
<script async defer type="text/javascript" src="{% static 'contrib/htmx/htmx.min.js' %}" integrity="{% sri_integrity_static 'contrib/htmx/htmx.min.js' %}"></script>
<script async defer type="text/javascript" src="{% static 'js/base.js' %}" integrity="{% sri_integrity_static 'js/base.js' %}"></script>
{# Not async to avoid bright flashes #}
{% sri_static "js/dark-mode.js" %}
{% block extra_js %}{% endblock %}
{% block extra_js %}{% endblock %}
{% block plausible %}
{% if not request.user.is_authenticated or not request.is_preview %}
{% plausible %}
{% endif %}
{% endblock %}
</body>
{% block plausible %}
{% if not request.user.is_authenticated or not request.is_preview %}
{% plausible %}
{% endif %}
{% endblock %}
</body>
</html>

View File

@ -2,7 +2,7 @@
<figure>
<div class="image">
<a href="{% image_url value.image 'original' %}" class="glightbox" data-gallery="content" data-height="70vh" data-alt="{{ value.caption|richtext|extract_text }}" data-title="{{ value.caption|richtext|extract_text }}">
<a href="{% image_url value.image 'original' %}" class="glightbox" data-gallery="content" data-height="70vh" data-width="95vw" data-alt="{{ value.caption|richtext|extract_text }}">
<img src="{% image_url value.image 'width-1500' %}" alt="{{ value.caption|richtext|extract_text }}" loading="lazy" decoding="async" />
</a>
</div>

View File

@ -1,4 +1,4 @@
<section class="container" id="comments">
<section class="container">
<div id="commento"></div>
</section>

View File

@ -1,69 +1,36 @@
{% load wagtailcore_tags wagtail_cache %}
{% load wagtailcore_tags cache util_tags %}
{% wagtailpagecache FRAGMENT_CACHE_TTL "content-details" %}
<div class="content-details field is-grouped">
{% if page.date %}
<span class="icon-text">
<span class="icon">
<i class="far fa-lg fa-calendar-alt"></i>
</span>
<span>{{ page.date|date:"Y-m-d" }}</span>
</span>
{% endif %}
{% if page.show_reading_time %}
<div class="icon-text" {% if page.word_count %}title="{{ page.word_count }} words"{% endif %}>
<span class="icon">
<i class="far fa-lg fa-clock"></i>
</span>
<span>{{ page.reading_time_display }}</span>
</div>
{% endif %}
{% if page.tags_list %}
<div class="icon-text is-family-code">
<span class="icon">
<a href="{{ page.tag_list_page_url }}" title="View all tags">
<i class="fas fa-lg fa-tags"></i>
</a>
</span>
{% for tag in page.tags_list %}
<span><a title="{{ tag.name }}" href="{% pageurl tag %}">#{{ tag.slug }}</a></span>
{% endfor %}
</div>
{% endif %}
{% if page.slides_url %}
<span class="icon-text">
<a href="{{ page.slides_url }}">
{% cache FRAGMENT_CACHE_TTL|jitter:FRAGMENT_CACHE_TTL_JITTER "content-details" page.id request.is_preview %}
<div class="content-details field is-grouped">
{% if page.date %}
<span class="icon-text">
<span class="icon">
<i class="fas fa-lg fa-images"></i>
<i class="far fa-lg fa-calendar-alt"></i>
</span>
<span>Slides</span>
</a>
</span>
{% endif %}
<span>{{ page.date|date:"Y-m-d" }}</span>
</span>
{% endif %}
{% if page.video_url %}
<span class="icon-text">
<a href="{{ page.video_url }}">
{% if page.show_reading_time %}
<div class="icon-text" {% if page.word_count %}title="{{ page.word_count }} words"{% endif %}>
<span class="icon">
<i class="fas fa-lg fa-film"></i>
<i class="far fa-lg fa-clock"></i>
</span>
<span>Video</span>
</a>
</span>
{% endif %}
<span>{{ page.reading_time_display }}</span>
</div>
{% endif %}
{% if page.location_name and page.location_url %}
<span class="icon-text">
<a href="{{ page.location_url }}">
{% if page.tags.all %}
<div class="icon-text is-family-code">
<span class="icon">
<i class="fas fa-lg fa-map-marker-alt"></i>
<a href="{{ page.tag_list_page_url }}" title="View all tags">
<i class="fas fa-lg fa-tags"></i>
</a>
</span>
<span>{{ page.location_name }}</span>
</a>
</span>
{% endif %}
</div>
{% endwagtailpagecache %}
{% for tag in page.tags.all|dictsort:"slug" %}
<span><a title="{{ tag.name }}" href="{% pageurl tag %}">#{{ tag.slug }}</a></span>
{% endfor %}
</div>
{% endif %}
</div>
{% endcache %}

View File

@ -23,13 +23,15 @@
{% endblock %}
{% block extra_css %}
{% sri_static "css/content-contrib.css" %}
{% sri_static "css/lite-youtube-embed.css" %}
{% sri_static "contrib/shareon/shareon.min.css" %}
{% sri_static "contrib/glightbox/glightbox.min.css" %}
<link rel="stylesheet" type="text/css" href="{% url 'code-block:styles' %}" />
{% endblock %}
{% block extra_js %}
<script async defer type="text/javascript" src="{% static 'js/content.js' %}" integrity="{% sri_integrity_static 'js/content.js' %}"></script>
<script async defer type="text/javascript" src="{% static 'js/lite-youtube-embed.js' %}" integrity="{% sri_integrity_static 'js/lite-youtube-embed.js' %}"></script>
<script async defer type="text/javascript" src="{% static 'contrib/shareon/shareon.iife.js' %}" integrity="{% sri_integrity_static 'contrib/shareon/shareon.iife.js' %}" init></script>
<script async defer type="text/javascript" src="{% static 'js/lightbox.js' %}" integrity="{% sri_integrity_static 'js/lightbox.js' %}"></script>
{% endblock %}

View File

@ -1,30 +1,24 @@
{% load wagtailcore_tags wagtail_cache util_tags %}
{% load wagtailcore_tags cache util_tags %}
{% wagtailpagecache FRAGMENT_CACHE_TTL "listing-item" breadcrumbs show_listing_images %}
<article class="media listing-item">
<div class="columns">
<figure class="media-left column is-{{ show_listing_images|yesno:'3,1' }} image-column">
{% if page.list_image_url %}
<a href="{% pageurl page %}" class="image" title="{{ page.title }}">
<img src="{{ page.list_image_url }}" alt="{{ page.hero_image_alt }}" loading="lazy" decoding="async" referrerpolicy="no-referrer" />
</a>
{% endif %}
</figure>
<div class="media-content column">
<div>
{% if breadcrumbs %}
{% include "common/breadcrumbs.html" with parents=page.get_parent_pages %}
{% endif %}
<h2 class="title is-3">
<a href="{% pageurl page %}">
{{ page.title }}
{% if page.is_external %}<i class="fa-solid fa-arrow-up-right-from-square" title="This page is from a external source"></i>{% endif %}
{% cache FRAGMENT_CACHE_TTL|jitter:FRAGMENT_CACHE_TTL_JITTER "listing-item" page.id request.is_preview %}
<article class="media listing-item">
<div class="columns">
<figure class="media-left column is-3 image-column">
{% if page.list_image_url %}
<a href="{% pageurl page %}" class="image" title="{{ page.title }}">
<img src="{{ page.list_image_url }}" alt="" loading="lazy" decoding="async" />
</a>
</h2>
{% include "common/content-details.html" %}
<p>{{ page.summary }}</p>
{% endif %}
</figure>
<div class="media-content column">
<div>
<h2 class="title is-3">
<a href="{% pageurl page %}">{{ page.title }}</a>
</h2>
{% include "common/content-details.html" %}
<p>{{ page.summary }}</p>
</div>
</div>
</div>
</div>
</article>
{% endwagtailpagecache %}
</article>
{% endcache %}

View File

@ -1,25 +1,15 @@
{% extends "common/content_page.html" %}
{% load wagtailadmin_tags wagtailroutablepage_tags %}
{% block hero_buttons %}
<a class="button is-radiusless" href="{% routablepageurl page 'random' %}" title="View random"><i class="fas fa-dice" aria-hidden="true"></i></a>
{% if listing_pages.has_previous %}
<a class="button is-radiusless" href="{% querystring page=listing_pages.previous_page_number %}" title="Previous page"><i class="fas fa-arrow-left" aria-hidden="true"></i></a>
{% endif %}
{% if listing_pages.has_next %}
<a class="button is-radiusless" href="{% querystring page=listing_pages.next_page_number %}" title="Next page"><i class="fas fa-arrow-right" aria-hidden="true"></i></a>
{% endif %}
{% load wagtailroutablepage_tags %}
{% block extra_head %}
{{ block.super }}
<link rel="alternate" type="application/rss+xml" href="{% routablepageurl page 'feed' %}" />
{% endblock %}
{% block content_details %}
{% block hero_buttons %}
<a class="button is-radiusless" href="{% routablepageurl page 'feed' %}" title="View feed"><i class="fas fa-rss" aria-hidden="true"></i></a>
{{ block.super }}
{% if listing_pages.has_other_pages %}<p>Showing {{ listing_pages.start_index }}&mdash;{{ listing_pages.end_index }} of {{ listing_pages.paginator.count }}.</p>{% endif %}
{% endblock %}
{% block post_content %}

View File

@ -1,22 +1,22 @@
{% load wagtailadmin_tags %}
<nav class="pagination is-centered" role="navigation" title="pagination">
<nav class="pagination is-centered" role="navigation" aria-label="pagination">
{% if page.has_previous %}
<a class="pagination-previous" title="Go to page {{ page.previous_page_number }}" href="{% querystring page=page.previous_page_number %}"><i class="fas fa-arrow-left" aria-hidden="true"></i></a>
<a class="pagination-previous" href="{% querystring page=page.previous_page_number %}">&larr;</a>
{% else %}
<span class="pagination-previous is-disabled"><i class="fas fa-arrow-left" aria-hidden="true"></i></span>
<span class="pagination-previous is-disabled">&larr;</span>
{% endif %}
{% if page.has_next %}
<a class="pagination-next" title="Go to page {{ page.next_page_number }}" href="{% querystring page=page.next_page_number %}"><i class="fas fa-arrow-right" aria-hidden="true"></i></a>
<a class="pagination-next" href="{% querystring page=page.next_page_number %}">&rarr;</a>
{% else %}
<span class="pagination-next is-disabled"><i class="fas fa-arrow-right" aria-hidden="true"></i></span>
<span class="pagination-next is-disabled">&rarr;</span>
{% endif %}
<ul class="pagination-list">
{% if page.has_previous and page.previous_page_number != 1 %}
<li>
<a class="pagination-link" aria-label="Go to page 1" href="{% querystring page=1 %}">1</a>
<a class="pagination-link" aria-label="Goto page 1" href="{% querystring page=1 %}">1</a>
</li>
<li>
<span class="pagination-ellipsis">&hellip;</span>
@ -25,17 +25,17 @@
{% if page.has_previous %}
<li>
<a class="pagination-link" title="Go to page {{ page.previous_page_number }}" href="{% querystring page=page.previous_page_number %}">{{ page.previous_page_number }}</a>
<a class="pagination-link" aria-label="Goto page {{ page.previous_page_number }}" href="{% querystring page=page.previous_page_number %}">{{ page.previous_page_number }}</a>
</li>
{% endif %}
<li>
<a class="pagination-link is-current" title="Page {{ page.number }}" aria-current="page" href="{% querystring page=page.number %}">{{ page.number }}</a>
<a class="pagination-link is-current" aria-label="Page {{ page.number }}" aria-current="page" href="{% querystring page=page.number %}">{{ page.number }}</a>
</li>
{% if page.has_next %}
<li>
<a class="pagination-link" title="Go to page {{ page.next_page_number }}" href="{% querystring page=page.next_page_number %}">{{ page.next_page_number }}</a>
<a class="pagination-link" aria-label="Goto page {{ page.next_page_number }}" href="{% querystring page=page.next_page_number %}">{{ page.next_page_number }}</a>
</li>
{% endif %}
@ -44,7 +44,7 @@
<span class="pagination-ellipsis">&hellip;</span>
</li>
<li>
<a class="pagination-link" title="Go to page {{ page.paginator.num_pages }}" href="{% querystring page=page.paginator.num_pages %}">{{ page.paginator.num_pages }}</a>
<a class="pagination-link" aria-label="Goto page {{ page.paginator.num_pages }}" href="{% querystring page=page.paginator.num_pages %}">{{ page.paginator.num_pages }}</a>
</li>
{% endif %}
</ul>

View File

@ -1,8 +1,8 @@
{% load wagtailcore_tags %}
{% load util_tags %}
<section class="container has-text-centered shareon-container" id="shareon">
<section class="container has-text-centered shareon-container">
<p>Share this page</p>
<div class="shareon" data-title="{{ page.title }}" data-url="{% fullpageurl page %}">
<div class="shareon" data-title="{{ page.title }}" data-url="{% pagefullurl page %}">
<a class="facebook" title="Share on Facebook"></a>
<a class="linkedin" title="Share on LinkedIn"></a>
<a class="mastodon" title="Share on Mastodon"></a>
@ -11,9 +11,8 @@
<a class="reddit" title="Share on Reddit"></a>
<a class="teams" title="Share on Teams"></a>
<a class="telegram" title="Share on Telegram"></a>
<a class="twitter" title="Share on Twitter"></a>
<a class="twitter" title="Twitter"></a>
<a class="whatsapp" title="Share on WhatsApp"></a>
<a class="email" title="Share by email"></a>
<a role="button" class="copy-url" title="Copy URL"></a>
<a role="button" class="web-share" title="Share"></a>
</div>

View File

@ -1,7 +1,7 @@
{% load wagtailcore_tags %}
{% if support_page and page.id != support_page.id %}
<a href="{% pageurl support_page %}" class="tag is-primary support-pill" title="If you like what I do, please consider supporting my work!">
<a href="{% pageurl support_page %}" class="tag is-primary support-pill" title="Support me">
<i class="fas fa-praying-hands"></i>
</a>
{% endif %}

View File

@ -1,9 +0,0 @@
{% load wagtailcore_tags %}
{% spaceless %}
{{ obj.content_html | truncatewords_html:100 | safe }}
<p>
<a href="{% fullpageurl obj %}">Continue Reading&hellip;</a>
</p>
{% endspaceless %}

View File

@ -1,7 +1,11 @@
{% if SEO_INDEX %}
User-agent: *
{% if SEO_INDEX %}Allow: /{% else %}Disallow: /{% endif %}
# https://github.com/ai-robots-txt/ai.robots.txt
{{ ai_robots_txt }}
Allow: /
{% else %}
User-agent: *
Disallow: /
{% endif %}
Disallow: {% url "wagtailadmin_home" %}
Disallow: {% url "api:index" %}
Sitemap: {{ sitemap }}

View File

@ -15,7 +15,7 @@
<picture>
{% for width, image_url in page.hero_image_urls.items reversed %}<source srcset="{{ image_url }}" media="(max-width: {{ width }}px)" />{% endfor %}
<img class="hero" src="{{ page.hero_image_url }}" referrerpolicy="no-referrer" decoding="async" alt="{{ page.hero_image_alt }}" />
<img class="hero" src="{{ page.hero_image_url }}" decoding="async" alt="" />
</picture>
{% endif %}
@ -34,9 +34,7 @@
<h1 class="title is-spaced">{{ page.hero_title }}</h1>
{% if page.subtitle %}<h2 class="subtitle is-size-4">{{ page.subtitle|richtext }}</h2>{% endif %}
{% block content_details %}
{% include "common/content-details.html" %}
{% endblock %}
{% include "common/content-details.html" %}
</div>
<div class="column is-narrow dropdown-wrapper hero-buttons is-grouped">
{% block hero_buttons %}
@ -70,10 +68,6 @@
</div>
</div>
</div>
<div class="scroll-indicator-container">
<div id="scroll-indicator"></div>
</div>
</section>
{% block pre_content %}{% endblock %}

View File

@ -1,5 +1,8 @@
import random
from django.template import Library
from django.utils.encoding import force_str
from wagtail.models import Page
from wagtail.rich_text import RichText
from website.common import utils
@ -12,6 +15,16 @@ def do_range(stop: int) -> range:
return range(stop)
@register.simple_tag(takes_context=True)
def pagefullurl(context: dict, page: Page) -> str:
return page.get_full_url(context["request"])
@register.filter()
def jitter(original: float, jitter: float) -> float:
return random.uniform(original + jitter, original - jitter)
@register.filter()
def extract_text(html: str | RichText) -> str:
return utils.extract_text(force_str(html))

View File

@ -1,9 +1,8 @@
from django.template.loader import get_template
from django.test import SimpleTestCase, TestCase
from django.urls import reverse
from website.common.factories import ContentPageFactory, ListingPageFactory
from website.common.models import BaseListingPage, BasePage
from website.common.models import BasePage
from website.common.utils import get_page_models
from website.home.models import HomePage
@ -36,7 +35,7 @@ class ContentPageTestCase(TestCase):
self.assertEqual(response.status_code, 200)
def test_queries(self) -> None:
with self.assertNumQueries(32):
with self.assertNumQueries(39):
self.client.get(self.page.url)
@ -53,36 +52,17 @@ class ListingPageTestCase(TestCase):
ContentPageFactory(parent=cls.page)
def test_accessible(self) -> None:
with self.assertNumQueries(35):
with self.assertNumQueries(42):
response = self.client.get(self.page.url)
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.context["listing_pages"]), 2)
self.assertContains(response, self.page.reverse_subpage("feed"))
def test_feed_redirects(self) -> None:
response = self.client.get(self.page.url + self.page.reverse_subpage("feed"))
self.assertRedirects(
response, reverse("feed"), status_code=301, fetch_redirect_response=True
)
def test_meta_url(self) -> None:
response = self.client.get(self.page.url)
def test_feed_accessible(self) -> None:
with self.assertNumQueries(13):
response = self.client.get(
self.page.url + self.page.reverse_subpage("feed")
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context["page"].get_meta_url(), self.page.full_url)
def test_meta_url_with_page(self) -> None:
ContentPageFactory.create_batch(BaseListingPage.PAGE_SIZE, parent=self.page)
response = self.client.get(self.page.url, {"page": 2})
self.assertEqual(response.status_code, 200)
self.assertEqual(
response.context["page"].get_meta_url(), self.page.full_url + "?page=2"
)
def test_random(self) -> None:
url = self.page.url + self.page.reverse_subpage("random")
with self.assertNumQueries(10):
response = self.client.get(url)
self.assertEqual(response.status_code, 302)
self.assertIn(
response.url, [page.get_url() for page in self.page.get_listing_pages()]
)
self.assertEqual(response["Content-Type"], "application/xml")
self.assertContains(response, "xml-stylesheet")

View File

@ -2,14 +2,27 @@ from django.conf import settings
from django.test import SimpleTestCase
from wagtail.rich_text import features as richtext_feature_registry
from website.common.embed import YouTubeLiteEmbedFinder
from website.common.utils import (
extend_query_params,
count_words,
extract_text,
get_table_of_contents,
heading_id,
)
class YouTubeLiteEmbedFinderTestCase(SimpleTestCase):
def test_finds_video_id(self) -> None:
self.assertEqual(
YouTubeLiteEmbedFinder._get_video_id(
'<iframe width="200" height="113" src="https://www.youtube.com/embed/dQw4w9WgXcQ?feature=oembed" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen title=""></iframe>'
),
"dQw4w9WgXcQ",
)
with self.assertRaises(ValueError):
YouTubeLiteEmbedFinder._get_video_id("something-else")
class TableOfContentsTestCase(SimpleTestCase):
def test_creates_table_of_contents(self) -> None:
toc = get_table_of_contents(
@ -97,6 +110,13 @@ class ExtractTextTestCase(SimpleTestCase):
self.assertEqual(extract_text("Hello there!"), "Hello there!")
class CountWordsTestCase(SimpleTestCase):
def test_counts_words(self) -> None:
self.assertEqual(count_words("a b c"), 3)
self.assertEqual(count_words("Correct Horse Battery Staple"), 4)
self.assertEqual(count_words("Hello there! How are you?"), 5)
class RichTextFeaturesTestCase(SimpleTestCase):
def test_features_exist(self) -> None:
for editor, editor_config in settings.WAGTAILADMIN_RICH_TEXT_EDITORS.items():
@ -112,25 +132,3 @@ class HeadingIDTestCase(SimpleTestCase):
self.assertEqual(heading_id("123"), "ref-123")
self.assertEqual(heading_id("test"), "test")
self.assertEqual(heading_id("Look, a title!"), "look-a-title")
class ExtendQueryParamsTestCase(SimpleTestCase):
def test_params(self) -> None:
self.assertEqual(
extend_query_params("https://example.com", {"foo": "bar"}),
"https://example.com?foo=bar",
)
self.assertEqual(
extend_query_params("https://example.com?foo=bar", {"bar": "foo"}),
"https://example.com?foo=bar&bar=foo",
)
self.assertEqual(
extend_query_params("https://example.com?foo=baz", {"foo": "baz"}),
"https://example.com?foo=baz",
)
def test_removes_param(self) -> None:
self.assertEqual(
extend_query_params("https://example.com?foo=bar", {"foo": None}),
"https://example.com",
)

View File

@ -22,7 +22,7 @@ class Error404PageTestCase(TestCase):
)
def test_queries(self) -> None:
with self.assertNumQueries(16):
with self.assertNumQueries(22):
self.client.get(self.url)

View File

@ -1,17 +1,13 @@
from dataclasses import dataclass
from itertools import pairwise
from typing import Any, Optional, Type
from urllib.parse import urlsplit, urlunsplit
from itertools import islice, pairwise
from typing import Iterable, Optional, Type
import requests
from bs4 import BeautifulSoup, SoupStrainer
from django.conf import settings
from django.db import models
from django.http import QueryDict
from django.http.request import HttpRequest
from django.utils.text import slugify
from django.utils.text import re_words, slugify
from django_cache_decorator import django_cache_decorator
from metadata_parser import MetadataParser
from wagtail.models import Page, Site
from wagtail.models import get_page_models as get_wagtail_page_models
@ -72,6 +68,19 @@ def show_toolbar_callback(request: HttpRequest) -> bool:
return settings.DEBUG
def split_words(text: str) -> Iterable[str]:
for word in re_words.split(text):
if word and word.strip():
yield word.strip()
def count_words(text: str) -> int:
"""
Count the number of words in the text, without duplicating the item in memory
"""
return len(list(split_words(text)))
def extract_text(html: str) -> str:
"""
Get the plain text of some HTML.
@ -81,6 +90,10 @@ def extract_text(html: str) -> str:
)
def truncate_string(text: str, words: int) -> str:
return " ".join(islice(split_words(text), words))
def heading_id(heading: str) -> str:
"""
Convert a heading into an identifier which is valid for a HTML id attribute
@ -105,41 +118,3 @@ def get_url_mime_type(url: str) -> Optional[str]:
return requests_session.head(url).headers.get("Content-Type")
except requests.exceptions.RequestException:
return None
def get_or_none(queryset: models.QuerySet) -> models.Model:
"""
Helper method to get a single instance, or None if there is not exactly 1 matches
"""
try:
return queryset.get()
except (queryset.model.DoesNotExist, queryset.model.MultipleObjectsReturned):
return None
@django_cache_decorator(time=21600)
def get_ai_robots_txt() -> str:
"""
https://github.com/ai-robots-txt/ai.robots.txt
"""
return requests_session.get(
"https://raw.githubusercontent.com/ai-robots-txt/ai.robots.txt/main/robots.txt"
).content.decode()
@django_cache_decorator(time=21600)
def get_page_metadata(url: str) -> MetadataParser:
return MetadataParser(url=url, search_head_only=True)
def extend_query_params(url: str, params: dict[str, Any]) -> str:
scheme, netloc, path, query, fragment = urlsplit(url)
query_dict = QueryDict(query, mutable=True)
for k, v in params.items():
if v is None:
del query_dict[k]
else:
query_dict[k] = v
return urlunsplit((scheme, netloc, path, query_dict.urlencode(), fragment))

View File

@ -1,4 +1,4 @@
from datetime import datetime, time
from datetime import datetime
from typing import Any, Optional
from django.contrib.syndication.views import Feed
@ -15,15 +15,12 @@ from wagtail.query import PageQuerySet
from wagtail_favicon.models import FaviconSettings
from wagtail_favicon.utils import get_rendition_url
from website.blog.models import BlogPostPage
from website.common.utils import get_site_title
from website.contrib.singleton_page.utils import SingletonPageCache
from website.home.models import HomePage
from website.search.models import SearchPage
from .feed_generators import CustomFeed
from .models import BaseListingPage, BasePage
from .utils import extend_query_params, get_ai_robots_txt
from .models import BasePage
class Error404View(TemplateView):
@ -53,7 +50,6 @@ class RobotsView(TemplateView):
def get_context_data(self, **kwargs: dict) -> dict:
context = super().get_context_data(**kwargs)
context["sitemap"] = self.request.build_absolute_uri(reverse("sitemap"))
context["ai_robots_txt"] = get_ai_robots_txt()
return context
@ -64,9 +60,7 @@ class KeybaseView(TemplateView):
class AllPagesFeed(Feed):
feed_type = CustomFeed
link = "/"
description_template = "feed-description.html"
def __init__(self) -> None:
self.style_tag = f'<?xml-stylesheet href="{static("contrib/pretty-feed-v3.xsl")}" type="text/xsl"?>'.encode()
@ -93,18 +87,14 @@ class AllPagesFeed(Feed):
return response
def feed_extra_kwargs(self, obj: None) -> dict:
return {**super().feed_extra_kwargs(obj), "request": self.request}
def title(self) -> str:
return f"Feed :: {get_site_title()}"
return f"All Pages Feed :: {get_site_title()}"
def items(self) -> PageQuerySet:
return (
Page.objects.live()
.public()
.exclude(depth__lte=2)
.not_type(BaseListingPage)
.specific()
.order_by("-last_published_at")
)
@ -116,22 +106,16 @@ class AllPagesFeed(Feed):
return item.title
def item_link(self, item: BasePage) -> str:
return extend_query_params(
item.get_full_url(request=self.request), {"utm_medium": "rss"}
)
return item.get_full_url(request=self.request) + "?utm_medium=rss"
def item_pubdate(self, item: BasePage) -> datetime:
if item_date := getattr(item, "date", None):
return datetime.combine(item_date, time())
return item.first_published_at
def item_updateddate(self, item: BasePage) -> datetime:
return item.last_published_at
def item_categories(self, item: BasePage) -> Optional[list[str]]:
if isinstance(item, BlogPostPage):
return item.tags_list.values_list("slug", flat=True)
return None
def item_description(self, item: BasePage) -> str:
return getattr(item, "summary", None) or item.title
def item_enclosure_url(self, item: BasePage) -> Optional[str]:
if not hasattr(item, "get_meta_image_url"):
@ -153,6 +137,20 @@ class AllPagesFeed(Feed):
item_enclosure_length = 0
class ContentPageFeed(AllPagesFeed):
def __init__(self, posts: PageQuerySet, link: str, title: str):
self.posts = posts
self.link = link
self._title = title
super().__init__()
def title(self) -> str:
return self._title
def items(self) -> PageQuerySet:
return self.posts
@method_decorator(cache_control(max_age=60 * 60), name="dispatch")
class FaviconView(RedirectView):
def get_redirect_url(self) -> str:

View File

@ -7,12 +7,12 @@
<div class="card-content">
<div class="media">
<div class="media-content">
<h3 class="title is-4">
<p class="title is-4">
<a href="{{ account.url }}">
{% if account.icon %}<i class="{{ account.icon }}"></i>{% endif %}
{{ account.name }}
</a>
</h3>
</p>
<p class="subtitle is-6">{{ account.username }}</p>
</div>
</div>

View File

@ -9,7 +9,6 @@ class GitHubLinguistHealthCheckBackend(BaseHealthCheckBackend):
colours = _get_linguist_colours()
except Exception as e:
self.add_error(str(e))
return
if colours is None:
self.add_error("No colours provided")

View File

@ -2,7 +2,7 @@
<figure>
<div class="image">
<a href="https://mermaid.ink/svg/{{ value.pako }}" data-gallery="content" class="glightbox" data-type="image" data-height="70vh" data-alt="{{ value.caption|richtext|extract_text }}" data-title="{{ value.caption|richtext|extract_text }}">
<a href="https://mermaid.ink/svg/{{ value.pako }}" data-gallery="content" class="glightbox" data-type="image" data-height="60vh" data-width="95vw" data-alt="{{ value.caption|richtext|extract_text }}">
<img src="https://mermaid.ink/svg/{{ value.pako }}" referrerpolicy="no-referrer" alt="{{ value.caption|richtext|extract_text }}" loading="lazy" decoding="async" />
</a>
</div>

View File

@ -1,6 +1,5 @@
from django.core.cache import cache
from wagtail import hooks
from wagtail.models import Page
from website.common.utils import get_page_models
@ -8,7 +7,7 @@ from .utils import SingletonPageCache
@hooks.register("after_move_page")
def clear_singleton_url_cache(page_to_move: Page) -> None:
def clear_singleton_url_cache(**kwargs: dict) -> None:
"""
Clear all page caches, in case a parent has moved
"""

View File

@ -3,11 +3,11 @@ from typing import Type
from django.core.exceptions import ValidationError
from django.http.response import Http404
from django.utils.html import format_html
from wagtail import hooks
from wagtail.admin.forms.models import WagtailAdminModelForm
from wagtail.contrib.modeladmin.helpers import WagtailBackendSearchHandler
from wagtail.contrib.modeladmin.options import ModelAdmin, modeladmin_register
from wagtail.contrib.modeladmin.views import CreateView, EditView, IndexView
from wagtail.core import hooks
from .models import UnsplashPhoto
from .utils import get_unsplash_photo

View File

@ -14,6 +14,6 @@ class UnsplashPhotoChooser(AdminChooser):
def get_title(self, instance: UnsplashPhoto) -> str:
return format_html(
"<img src='{}' width=165 loading='lazy' decoding='async' referrerpolicy='no-referrer'>",
"<img src='{}' width=165 loading='lazy' decoding='async'>",
instance.get_thumbnail_url(),
)

View File

@ -1,5 +1,8 @@
from typing import Optional, Tuple
from django.db import models
from django.http.request import HttpRequest
from django_cache_decorator import django_cache_decorator
from wagtail.admin.panels import FieldPanel
from wagtail.images import get_image_model_string
from wagtail.images.models import Image
@ -9,6 +12,20 @@ from website.common.models import BasePage
from website.contrib.singleton_page.utils import SingletonPageCache
@django_cache_decorator(time=600)
def get_latest_blog_post() -> Optional[Tuple[str, str]]:
from website.blog.models import BlogPostPage
try:
latest_blog_post = (
BlogPostPage.objects.live().public().defer_streamfields().latest("date")
)
except BlogPostPage.DoesNotExist:
return None
return latest_blog_post.title, latest_blog_post.get_url()
class HomePage(BasePage, WagtailImageMetadataMixin):
max_count = 1
@ -38,22 +55,9 @@ class HomePage(BasePage, WagtailImageMetadataMixin):
return self.html_title
def get_context(self, request: HttpRequest) -> dict:
from website.blog.models import BlogPostListPage, BlogPostPage
from website.search.models import SearchPage
context = super().get_context(request)
context["recent_posts"] = list(
BlogPostPage.objects.live()
.public()
.defer_streamfields()
.order_by("-date")[:7]
)
context["latest_blog_post"] = (
context["recent_posts"].pop(0) if context["recent_posts"] else None
)
context["latest_blog_post"] = get_latest_blog_post()
context["search_page_url"] = SingletonPageCache.get_url(SearchPage, request)
context["blog_post_list_url"] = SingletonPageCache.get_url(
BlogPostListPage, request
)
return context

View File

@ -4,38 +4,21 @@
{% block main %}
<main {% if page.image %}style="background-image: url({% image_url page.image 'width-1200' %})"{% endif %}>
<div class="top-section">
<div class="heading-wrapper">
<h1>{{ page.heading }}</h1>
{% if search_page_url %}
<form action="{{ search_page_url }}">
<input id="search-input" class="input" type="text" placeholder="Search" name="q" />
</form>
{% endif %}
</div>
{% if latest_blog_post %}
<div class="box latest is-size-5">
<strong>Latest Post</strong>:
<a href="{% pageurl latest_blog_post %}">{{ latest_blog_post.title }}</a>
&rarr;
</div>
<div class="heading-wrapper">
<h1>{{ page.heading }}</h1>
{% if search_page_url %}
<form action="{{ search_page_url }}">
<input id="search-input" class="input" type="text" placeholder="Search" name="q" />
</form>
{% endif %}
</div>
<section class="container content recent-posts">
<h2 class="has-text-centered has-text-white is-size-3">Recent Posts</h2>
<div class="columns content-list is-multiline">
{% for page in recent_posts %}
{% include "home/home_page_card.html" %}
{% endfor %}
{% if latest_blog_post %}
<div class="box latest">
<strong>Latest Post</strong>:
<a href="{{ latest_blog_post.1 }}">{{ latest_blog_post.0 }}</a>
&rarr;
</div>
{% if blog_post_list_url %}
<div class="box">
<a href="{{ blog_post_list_url }}">View more &rarr;</a>
</div>
{% endif %}
</section>
{% endif %}
</main>
{% endblock %}

View File

@ -1,16 +0,0 @@
{% load wagtail_cache wagtailcore_tags %}
{% wagtailpagecache FRAGMENT_CACHE_TTL "homepage-card" %}
<div class="column is-one-third-widescreen is-half">
<div class="card">
<div class="card-image">
<a href="{% pageurl page %}">
<figure class="image is-16by9">
<img src="{{ page.list_image_url }}" alt="{{ page.hero_image_alt }}" loading="lazy" decoding="async" referrerpolicy="no-referrer" />
<p>{{ page.title }}</p>
</figure>
</a>
</div>
</div>
</div>
{% endwagtailpagecache %}

View File

@ -1,33 +0,0 @@
# Generated by Django 5.0.1 on 2024-01-05 17:28
import wagtail.images.models
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("images", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="customimage",
name="file",
field=wagtail.images.models.WagtailImageField(
height_field="height",
upload_to=wagtail.images.models.get_upload_to,
verbose_name="file",
width_field="width",
),
),
migrations.AlterField(
model_name="customrendition",
name="file",
field=wagtail.images.models.WagtailImageField(
height_field="height",
storage=wagtail.images.models.get_rendition_storage,
upload_to=wagtail.images.models.get_rendition_upload_to,
width_field="width",
),
),
]

View File

@ -1,16 +1,24 @@
from django.test import TestCase
from django.urls import reverse
from website.blog.factories import BlogPostListPageFactory
from website.home.models import HomePage
class PostsFeedViewTestCase(TestCase):
@classmethod
def setUpTestData(cls) -> None:
cls.home_page = HomePage.objects.get()
cls.page = BlogPostListPageFactory(parent=cls.home_page)
def test_redirects(self) -> None:
response = self.client.get("/posts/index.xml")
self.assertRedirects(
response, self.page.url + self.page.reverse_subpage("feed")
)
class AllPagesFeedViewTestCase(TestCase):
def test_redirects(self) -> None:
response = self.client.get("/index.xml")
self.assertRedirects(
response, reverse("feed"), status_code=301, fetch_redirect_response=True
)
def test_redirects_posts(self) -> None:
response = self.client.get("/posts/index.xml")
self.assertRedirects(
response, reverse("feed"), status_code=301, fetch_redirect_response=True
)
self.assertRedirects(response, reverse("feed"))

View File

@ -5,10 +5,6 @@ from . import views
app_name = "legacy"
urlpatterns = [
path("posts/index.xml", views.AllPagesFeedView.as_view()),
path("posts/index.xml", views.PostsFeedView.as_view()),
path("index.xml", views.AllPagesFeedView.as_view()),
path("tags/<slug:slug>/", views.TagView.as_view()),
path("tags/", views.TagsView.as_view()),
path("categories/", views.TagsView.as_view()),
path("index.json", views.PageLinksView.as_view()),
]

View File

@ -3,32 +3,16 @@ from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_control
from django.views.generic import RedirectView
from website.blog.models import BlogPostTagListPage, BlogPostTagPage
from website.blog.models import BlogPostListPage
@method_decorator(cache_control(max_age=60 * 60), name="dispatch")
class PostsFeedView(RedirectView):
def get_redirect_url(self) -> str:
post_list = get_object_or_404(BlogPostListPage)
return post_list.url + post_list.reverse_subpage("feed")
@method_decorator(cache_control(max_age=60 * 60), name="dispatch")
class AllPagesFeedView(RedirectView):
pattern_name = "feed"
permanent = True
@method_decorator(cache_control(max_age=60 * 60), name="dispatch")
class PageLinksView(RedirectView):
pattern_name = "api:page-links"
permanent = True
@method_decorator(cache_control(max_age=60 * 60), name="dispatch")
class TagView(RedirectView):
permanent = True
def get_redirect_url(self, slug: str) -> str:
tag = get_object_or_404(BlogPostTagPage.objects.public().live(), slug=slug)
return tag.get_url(request=self.request)
@method_decorator(cache_control(max_age=60 * 60), name="dispatch")
class TagsView(RedirectView):
def get_redirect_url(self) -> str:
tag_list = get_object_or_404(BlogPostTagListPage)
return tag_list.get_url(request=self.request)

View File

@ -1,5 +1,4 @@
from django.core.paginator import EmptyPage, Paginator
from django.db import models
from django.http.request import HttpRequest
from django.http.response import Http404, HttpResponse, HttpResponseBadRequest
from django.template.response import TemplateResponse
@ -10,23 +9,20 @@ from wagtail.contrib.routable_page.models import RoutablePageMixin, route
from wagtail.models import Page
from wagtail.search.utils import parse_query_string
from website.common.models import BaseContentPage, BaseListingPage
from website.common.models import BaseContentPage
from website.common.utils import get_page_models
from .serializers import MIN_SEARCH_LENGTH, SearchPageParamsSerializer
from .serializers import MIN_SEARCH_LENGTH, SearchParamsSerializer
class SearchPage(RoutablePageMixin, BaseContentPage):
max_count = 1
subpage_types: list = []
parent_page_types = ["home.HomePage"]
PAGE_SIZE = 12
PAGE_SIZE = 10
# Exclude singleton pages from search results
EXCLUDED_PAGE_TYPES = {
*(page for page in get_page_models() if page.max_count == 1),
BaseListingPage,
}
EXCLUDED_PAGE_TYPES = {page for page in get_page_models() if page.max_count == 1}
@cached_property
def show_reading_time(self) -> bool:
@ -41,24 +37,15 @@ class SearchPage(RoutablePageMixin, BaseContentPage):
context["search_query"] = request.GET.get("q", "")
context["search_url"] = self.reverse_subpage("results")
context["MIN_SEARCH_LENGTH"] = MIN_SEARCH_LENGTH
context["SEO_INDEX"] = False
return context
@classmethod
def get_listing_pages(cls) -> models.QuerySet:
return (
Page.objects.live()
.public()
.not_type(cls.__class__, *cls.EXCLUDED_PAGE_TYPES)
)
@route(r"^results/$")
@method_decorator(require_GET)
def results(self, request: HttpRequest) -> HttpResponse:
if not request.htmx:
return HttpResponseBadRequest()
serializer = SearchPageParamsSerializer(data=request.GET)
serializer = SearchParamsSerializer(data=request.GET)
if not serializer.is_valid():
return TemplateResponse(
@ -77,7 +64,12 @@ class SearchPage(RoutablePageMixin, BaseContentPage):
}
filters, query = parse_query_string(search_query)
pages = self.get_listing_pages().search(query, order_by_relevance=True)
pages = (
Page.objects.live()
.public()
.not_type(self.__class__, *self.EXCLUDED_PAGE_TYPES)
.search(query, order_by_relevance=True)
)
paginator = Paginator(pages, self.PAGE_SIZE)
context["paginator"] = paginator

View File

@ -5,9 +5,5 @@ from website.common.serializers import PaginationSerializer
MIN_SEARCH_LENGTH = 3
class SearchParamSerializer(serializers.Serializer):
class SearchParamsSerializer(PaginationSerializer):
q = serializers.CharField(min_length=MIN_SEARCH_LENGTH)
class SearchPageParamsSerializer(SearchParamSerializer, PaginationSerializer):
pass

Some files were not shown because too many files have changed in this diff Show More