Compare commits
1 Commits
master
...
renovate/r
Author | SHA1 | Date | |
---|---|---|---|
a856545345 |
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"]
|
||||
}
|
||||
|
|
21
Dockerfile
21
Dockerfile
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
2
etc/s6-rc.d/nginx/run → etc/entrypoints/nginx
Normal file → Executable file
|
@ -1,4 +1,4 @@
|
|||
#!/command/with-contenv bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
7
etc/entrypoints/web
Executable file
7
etc/entrypoints/web
Executable 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
4
etc/s6-rc.d/rq/run → etc/entrypoints/worker
Normal file → Executable file
|
@ -1,7 +1,5 @@
|
|||
#!/command/with-contenv bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
cd /app
|
||||
|
||||
exec python manage.py rqworker --with-scheduler
|
|
@ -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"
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +0,0 @@
|
|||
#!/command/with-contenv bash
|
||||
|
||||
set -e
|
||||
|
||||
cd /app
|
||||
|
||||
exec supercronic etc/crontab
|
|
@ -1 +0,0 @@
|
|||
longrun
|
|
@ -1,7 +0,0 @@
|
|||
#!/command/with-contenv bash
|
||||
|
||||
set -e
|
||||
|
||||
cd /app
|
||||
|
||||
exec gunicorn -c etc/gunicorn.conf.py
|
|
@ -1 +0,0 @@
|
|||
longrun
|
|
@ -1 +0,0 @@
|
|||
oneshot
|
|
@ -1 +0,0 @@
|
|||
with-contenv bash -c "cd /app && python manage.py migrate --noinput"
|
|
@ -1 +0,0 @@
|
|||
longrun
|
|
@ -1 +0,0 @@
|
|||
longrun
|
6
justfile
6
justfile
|
@ -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
1110
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
16
package.json
16
package.json
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
|
|
@ -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({});
|
||||
});
|
5
static/src/js/lightbox.js
Normal file
5
static/src/js/lightbox.js
Normal file
|
@ -0,0 +1,5 @@
|
|||
const GLightbox = require("glightbox");
|
||||
|
||||
window.addEventListener("load", () => {
|
||||
GLightbox({});
|
||||
});
|
1
static/src/js/lite-youtube-embed.js
Normal file
1
static/src/js/lite-youtube-embed.js
Normal file
|
@ -0,0 +1 @@
|
|||
require("lite-youtube-embed");
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
.shareon-container {
|
||||
scroll-margin-top: var(--hero-height); // hero height (ish)
|
||||
margin-top: 1rem;
|
||||
|
||||
.shareon {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
@import "lite-youtube-embed/src/lite-yt-embed";
|
||||
@import "shareon/dist/shareon.min";
|
||||
@import "glightbox/dist/css/glightbox";
|
1
static/src/scss/lite-youtube-embed.scss
Normal file
1
static/src/scss/lite-youtube-embed.scss
Normal file
|
@ -0,0 +1 @@
|
|||
@import "lite-youtube-embed/src/lite-yt-embed";
|
|
@ -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
|
||||
|
|
|
@ -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"])
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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")
|
||||
)
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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
|
||||
|
|
|
@ -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" %}
|
||||
|
|
|
@ -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 →</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 →</a>
|
||||
</p>
|
||||
</section>
|
||||
{% endcache %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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
10
website/blog/views.py
Normal 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
38
website/common/embed.py
Normal 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
|
|
@ -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")
|
|
@ -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,
|
||||
}
|
|
@ -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):
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<section class="container" id="comments">
|
||||
<section class="container">
|
||||
<div id="commento"></div>
|
||||
</section>
|
||||
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 }}—{{ listing_pages.end_index }} of {{ listing_pages.paginator.count }}.</p>{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block post_content %}
|
||||
|
|
|
@ -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 %}">←</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">←</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 %}">→</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">→</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">…</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">…</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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
{% load wagtailcore_tags %}
|
||||
|
||||
{% spaceless %}
|
||||
{{ obj.content_html | truncatewords_html:100 | safe }}
|
||||
|
||||
<p>
|
||||
<a href="{% fullpageurl obj %}">Continue Reading…</a>
|
||||
</p>
|
||||
{% endspaceless %}
|
|
@ -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 }}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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",
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
"""
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
→
|
||||
</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>
|
||||
→
|
||||
</div>
|
||||
|
||||
{% if blog_post_list_url %}
|
||||
<div class="box">
|
||||
<a href="{{ blog_post_list_url }}">View more →</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
|
|
@ -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 %}
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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"))
|
||||
|
|
|
@ -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()),
|
||||
]
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
Loading…
Reference in New Issue
Block a user