forked from retoor/devplacepy
feat: add seo diagnostics tool with cli commands, config paths, and static versioning
Add SEO_REPORTS_DIR to config, STATIC_VERSION for cache-busting, seo prune/clear CLI subcommands, tools router with seo job endpoints, and boot-versioned static URLs in Dockerfile and Makefile
This commit is contained in:
@@ -284,6 +284,46 @@ def cmd_forks_clear(args):
|
||||
print(f"Cleared {len(jobs)} fork job(s)")
|
||||
|
||||
|
||||
def _remove_seo_artifacts(job):
|
||||
import shutil
|
||||
from devplacepy.config import SEO_REPORTS_DIR
|
||||
|
||||
shutil.rmtree(SEO_REPORTS_DIR / job["uid"], ignore_errors=True)
|
||||
|
||||
|
||||
def cmd_seo_prune(args):
|
||||
from datetime import datetime, timezone
|
||||
from devplacepy.services.jobs import queue
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
removed = 0
|
||||
for job in queue.list_jobs(kind="seo", status=queue.DONE):
|
||||
expires_at = job.get("expires_at")
|
||||
if not expires_at:
|
||||
continue
|
||||
try:
|
||||
expiry = datetime.fromisoformat(expires_at)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if expiry < now:
|
||||
_remove_seo_artifacts(job)
|
||||
get_table("jobs").delete(uid=job["uid"])
|
||||
removed += 1
|
||||
_audit_cli("cli.seo.prune", f"CLI pruned {removed} expired SEO jobs", metadata={"count": removed})
|
||||
print(f"Pruned {removed} expired SEO audit(s)")
|
||||
|
||||
|
||||
def cmd_seo_clear(args):
|
||||
from devplacepy.services.jobs import queue
|
||||
|
||||
jobs = queue.list_jobs(kind="seo")
|
||||
for job in jobs:
|
||||
_remove_seo_artifacts(job)
|
||||
get_table("jobs").delete(uid=job["uid"])
|
||||
_audit_cli("cli.seo.clear", f"CLI cleared all SEO jobs ({len(jobs)})", metadata={"count": len(jobs)})
|
||||
print(f"Cleared {len(jobs)} SEO audit(s) and their reports")
|
||||
|
||||
|
||||
def cmd_containers_list(args):
|
||||
from devplacepy.services.containers import store
|
||||
|
||||
@@ -661,6 +701,17 @@ def main():
|
||||
)
|
||||
forks_clear.set_defaults(func=cmd_forks_clear)
|
||||
|
||||
seo = sub.add_parser("seo", help="SEO Diagnostics job management")
|
||||
seo_sub = seo.add_subparsers(title="action", dest="action")
|
||||
seo_prune = seo_sub.add_parser(
|
||||
"prune", help="Delete expired SEO audit reports and their job rows"
|
||||
)
|
||||
seo_prune.set_defaults(func=cmd_seo_prune)
|
||||
seo_clear = seo_sub.add_parser(
|
||||
"clear", help="Delete every SEO audit report and job row"
|
||||
)
|
||||
seo_clear.set_defaults(func=cmd_seo_clear)
|
||||
|
||||
containers = sub.add_parser("containers", help="Container manager")
|
||||
containers_sub = containers.add_subparsers(title="action", dest="action")
|
||||
containers_sub.add_parser("list", help="List container instances").set_defaults(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
from os import environ
|
||||
@@ -23,6 +24,7 @@ CONTAINER_WORKSPACES_DIR = DATA_DIR / "container_workspaces"
|
||||
ZIPS_DIR = DATA_DIR / "zips"
|
||||
ZIP_STAGING_DIR = DATA_DIR / "zip_staging"
|
||||
FORK_STAGING_DIR = DATA_DIR / "fork_staging"
|
||||
SEO_REPORTS_DIR = DATA_DIR / "seo_reports"
|
||||
KEYS_DIR = DATA_DIR / "keys"
|
||||
BOT_DIR = DATA_DIR / "bot"
|
||||
LOCKS_DIR = DATA_DIR / "locks"
|
||||
@@ -40,6 +42,12 @@ SESSION_MAX_AGE_REMEMBER = SECONDS_PER_DAY * 30
|
||||
PORT = 10500
|
||||
SITE_URL = environ.get("DEVPLACE_SITE_URL", "").rstrip("/")
|
||||
|
||||
# Cache-busting version stamped onto every app-owned static URL. Computed once at
|
||||
# process start, so a restart/deploy changes it and busts every browser cache while
|
||||
# assets stay heavily cached (immutable, 1 year) between deploys. Set
|
||||
# DEVPLACE_STATIC_VERSION at launch so multiple prod workers share one value.
|
||||
STATIC_VERSION = environ.get("DEVPLACE_STATIC_VERSION") or str(int(time.time()))
|
||||
|
||||
INTERNAL_BASE_URL = environ.get(
|
||||
"DEVPLACE_INTERNAL_BASE_URL", f"http://localhost:{PORT}"
|
||||
).rstrip("/")
|
||||
@@ -76,6 +84,7 @@ DATA_PATHS: dict[str, Path] = {
|
||||
"zips": ZIPS_DIR,
|
||||
"zip_staging": ZIP_STAGING_DIR,
|
||||
"fork_staging": FORK_STAGING_DIR,
|
||||
"seo_reports": SEO_REPORTS_DIR,
|
||||
"keys": KEYS_DIR,
|
||||
"bot": BOT_DIR,
|
||||
"locks": LOCKS_DIR,
|
||||
|
||||
@@ -2277,6 +2277,7 @@ def get_daily_topic():
|
||||
"summary": desc,
|
||||
"slug": article.get("slug", ""),
|
||||
"url": article.get("url", ""),
|
||||
"image_url": article.get("image_url", ""),
|
||||
}
|
||||
return {
|
||||
"title": "Welcome to DevPlace",
|
||||
|
||||
@@ -3232,6 +3232,104 @@ Mutations flip desired state; a single reconciler converges containers to it.
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
"slug": "tools",
|
||||
"title": "Tools (SEO Diagnostics)",
|
||||
"intro": """
|
||||
# Tools: SEO Diagnostics
|
||||
|
||||
A public auditor that crawls a URL or sitemap with a headless browser and runs a broad battery of
|
||||
technical, on-page, structured-data, Core Web Vitals, accessibility and AI-readiness checks. It runs
|
||||
as a background job; poll the status URL (or watch the websocket) until `status` is `done`, then read
|
||||
the full report.
|
||||
|
||||
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html). These are
|
||||
**capability URLs**: the job `uid` is an unguessable identifier, so anyone holding it can read the
|
||||
status and report.
|
||||
""",
|
||||
"endpoints": [
|
||||
endpoint(
|
||||
id="tools-seo-run",
|
||||
method="POST",
|
||||
path="/tools/seo/run",
|
||||
title="Queue an SEO audit",
|
||||
summary="Start a background SEO audit of a URL or sitemap. Returns the job uid plus status and websocket URLs.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("url", "body", "string", True, "https://example.com", "Page URL or sitemap.xml URL to audit."),
|
||||
field("mode", "body", "string", False, "url", "'url' (single page) or 'sitemap' (crawl)."),
|
||||
field("max_pages", "body", "integer", False, "10", "Max pages to crawl in sitemap mode (1-50)."),
|
||||
],
|
||||
sample_response={
|
||||
"uid": "SEO_JOB_UID",
|
||||
"status_url": "/tools/seo/SEO_JOB_UID",
|
||||
"ws_url": "/tools/seo/SEO_JOB_UID/ws",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="tools-seo-status",
|
||||
method="GET",
|
||||
path="/tools/seo/{uid}",
|
||||
title="SEO audit status",
|
||||
summary="Poll an SEO audit. Once done, score, grade and report_url are populated.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "SEO_JOB_UID", "SEO job uid returned when the audit was queued."),
|
||||
],
|
||||
sample_response={
|
||||
"uid": "SEO_JOB_UID",
|
||||
"kind": "seo",
|
||||
"status": "done",
|
||||
"target": "https://example.com",
|
||||
"mode": "url",
|
||||
"ws_url": "/tools/seo/SEO_JOB_UID/ws",
|
||||
"report_url": "/tools/seo/SEO_JOB_UID/report",
|
||||
"score": 82,
|
||||
"grade": "B",
|
||||
"page_count": 1,
|
||||
"error": None,
|
||||
"created_at": "2026-06-14T10:00:00+00:00",
|
||||
"completed_at": "2026-06-14T10:00:18+00:00",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="tools-seo-report",
|
||||
method="GET",
|
||||
path="/tools/seo/{uid}/report",
|
||||
title="SEO audit report",
|
||||
summary="Full categorised report: overall score, per-category subscores, and every check with its recommendation. Negotiates HTML or JSON.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "SEO_JOB_UID", "SEO job uid of a finished audit."),
|
||||
],
|
||||
sample_response={
|
||||
"uid": "SEO_JOB_UID",
|
||||
"status": "done",
|
||||
"target": "https://example.com",
|
||||
"score": 82,
|
||||
"grade": "B",
|
||||
"page_count": 1,
|
||||
"counts": {"pass": 40, "warn": 8, "fail": 3, "info": 5, "skip": 0},
|
||||
"categories": {"crawlability": {"score": 90, "pass": 9, "warn": 1, "fail": 0}},
|
||||
"pages": [{"url": "https://example.com", "status": 200, "score": 82, "grade": "B"}],
|
||||
"checks": [
|
||||
{
|
||||
"id": "meta.title_present",
|
||||
"category": "meta",
|
||||
"title": "Title tag",
|
||||
"status": "pass",
|
||||
"severity": "high",
|
||||
"value": "Example Domain",
|
||||
"recommendation": "",
|
||||
"url": "https://example.com",
|
||||
}
|
||||
],
|
||||
"site": {"robots": {"status": 200}, "sitemap": {"status": 200, "url_count": 12}},
|
||||
"generated_at": "2026-06-14T10:00:18+00:00",
|
||||
},
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
"slug": "push",
|
||||
"title": "Web Push",
|
||||
|
||||
@@ -13,6 +13,7 @@ from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from devplacepy.config import (
|
||||
STATIC_DIR,
|
||||
STATIC_VERSION,
|
||||
UPLOADS_DIR,
|
||||
PORT,
|
||||
SERVICE_LOCK_FILE,
|
||||
@@ -69,6 +70,7 @@ from devplacepy.routers import (
|
||||
zips,
|
||||
forks,
|
||||
proxy,
|
||||
tools,
|
||||
)
|
||||
from devplacepy.services.manager import service_manager
|
||||
from devplacepy.services.news import NewsService
|
||||
@@ -78,6 +80,7 @@ from devplacepy.services.devii import DeviiService
|
||||
from devplacepy.services.jobs.zip_service import ZipService
|
||||
from devplacepy.services.jobs.fork_service import ForkService
|
||||
from devplacepy.services.jobs.bug_create_service import BugCreateService
|
||||
from devplacepy.services.jobs.seo.service import SeoService
|
||||
from devplacepy.services.gitea.service import BugTrackerService
|
||||
from devplacepy.services.containers.service import ContainerService
|
||||
from devplacepy.services.audit import AuditService
|
||||
@@ -155,6 +158,16 @@ class UploadStaticFiles(StaticFiles):
|
||||
return response
|
||||
|
||||
|
||||
class CachedStaticFiles(StaticFiles):
|
||||
async def get_response(self, path, scope):
|
||||
response = await super().get_response(path, scope)
|
||||
if Path(path).name == "service-worker.js":
|
||||
response.headers["Cache-Control"] = "no-cache"
|
||||
else:
|
||||
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
||||
return response
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="DevPlace",
|
||||
docs_url="/swagger",
|
||||
@@ -166,6 +179,11 @@ app.mount(
|
||||
UploadStaticFiles(directory=str(UPLOADS_DIR), check_dir=False),
|
||||
name="uploads",
|
||||
)
|
||||
app.mount(
|
||||
f"/static/v{STATIC_VERSION}",
|
||||
CachedStaticFiles(directory=str(STATIC_DIR)),
|
||||
name="static_versioned",
|
||||
)
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
|
||||
|
||||
@@ -305,6 +323,7 @@ app.include_router(media.router, prefix="/media")
|
||||
app.include_router(zips.router, prefix="/zips")
|
||||
app.include_router(forks.router, prefix="/forks")
|
||||
app.include_router(proxy.router, prefix="/p")
|
||||
app.include_router(tools.router, prefix="/tools")
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
@@ -418,6 +437,7 @@ async def startup():
|
||||
service_manager.register(DeviiService())
|
||||
service_manager.register(ZipService())
|
||||
service_manager.register(ForkService())
|
||||
service_manager.register(SeoService())
|
||||
service_manager.register(BugCreateService())
|
||||
service_manager.register(BugTrackerService())
|
||||
service_manager.register(ContainerService())
|
||||
|
||||
@@ -370,6 +370,20 @@ class PollVoteForm(BaseModel):
|
||||
option_uid: str = Field(min_length=1, max_length=36)
|
||||
|
||||
|
||||
class SeoRunForm(BaseModel):
|
||||
url: str = Field(min_length=3, max_length=2000)
|
||||
mode: Literal["url", "sitemap"] = "url"
|
||||
max_pages: int = Field(default=10, ge=1, le=50)
|
||||
|
||||
@field_validator("url")
|
||||
@classmethod
|
||||
def url_scheme(cls, value):
|
||||
text = value.strip()
|
||||
if not text:
|
||||
raise ValueError("A URL is required")
|
||||
return text
|
||||
|
||||
|
||||
class AdminRoleForm(BaseModel):
|
||||
role: Literal["member", "admin"]
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import socket
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
NAT64_PREFIXES = (
|
||||
ipaddress.ip_network("64:ff9b::/96"),
|
||||
ipaddress.ip_network("64:ff9b:1::/48"),
|
||||
)
|
||||
|
||||
|
||||
class BlockedAddressError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def effective_address(address: Any) -> Any:
|
||||
if isinstance(address, ipaddress.IPv6Address):
|
||||
if address.ipv4_mapped is not None:
|
||||
return address.ipv4_mapped
|
||||
for prefix in NAT64_PREFIXES:
|
||||
if address in prefix:
|
||||
return ipaddress.IPv4Address(int(address) & 0xFFFFFFFF)
|
||||
return address
|
||||
|
||||
|
||||
def is_blocked_address(address: Any) -> bool:
|
||||
resolved = effective_address(address)
|
||||
return (
|
||||
resolved.is_private
|
||||
or resolved.is_loopback
|
||||
or resolved.is_link_local
|
||||
or resolved.is_reserved
|
||||
or resolved.is_multicast
|
||||
or resolved.is_unspecified
|
||||
)
|
||||
|
||||
|
||||
async def guard_public_url(url: str, *, allow_private: bool = False) -> str:
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise BlockedAddressError("Only http and https URLs are allowed.")
|
||||
host = parsed.hostname
|
||||
if not host:
|
||||
raise BlockedAddressError("URL has no host.")
|
||||
if allow_private:
|
||||
return host
|
||||
try:
|
||||
infos = await asyncio.to_thread(socket.getaddrinfo, host, None)
|
||||
except socket.gaierror as exc:
|
||||
raise BlockedAddressError(f"Could not resolve host: {host}") from exc
|
||||
for info in infos:
|
||||
address = ipaddress.ip_address(info[4][0])
|
||||
if is_blocked_address(address):
|
||||
raise BlockedAddressError(
|
||||
f"Refusing to reach a private or local address ({effective_address(address)})."
|
||||
)
|
||||
return host
|
||||
@@ -3,6 +3,7 @@
|
||||
from devplacepy.docs_api import api_doc_pages
|
||||
|
||||
SECTION_GENERAL = "General"
|
||||
SECTION_TOOLS = "Tools"
|
||||
SECTION_COMPONENTS = "Components"
|
||||
SECTION_STYLES = "Styles"
|
||||
SECTION_API = "API"
|
||||
@@ -22,7 +23,7 @@ AUDIENCE_CONTRIBUTE = "Contribute and internals"
|
||||
AUDIENCE_OPERATE = "Operate"
|
||||
|
||||
AUDIENCES = [
|
||||
(AUDIENCE_START, [SECTION_GENERAL]),
|
||||
(AUDIENCE_START, [SECTION_GENERAL, SECTION_TOOLS]),
|
||||
(AUDIENCE_BUILD, [SECTION_API, SECTION_COMPONENTS, SECTION_STYLES]),
|
||||
(
|
||||
AUDIENCE_CONTRIBUTE,
|
||||
@@ -72,6 +73,13 @@ DOCS_PAGES = [
|
||||
"kind": "prose",
|
||||
"section": SECTION_GENERAL,
|
||||
},
|
||||
# Tools - public developer tools (everyone)
|
||||
{
|
||||
"slug": "tools-seo",
|
||||
"title": "SEO Diagnostics",
|
||||
"kind": "prose",
|
||||
"section": SECTION_TOOLS,
|
||||
},
|
||||
# Maintenance agents - the self-maintaining quality fleet (public)
|
||||
{
|
||||
"slug": "maintenance-agents",
|
||||
@@ -525,6 +533,13 @@ DOCS_PAGES = [
|
||||
"admin": True,
|
||||
"section": SECTION_PROD,
|
||||
},
|
||||
{
|
||||
"slug": "static-caching",
|
||||
"title": "Static asset caching and versioning",
|
||||
"kind": "prose",
|
||||
"admin": True,
|
||||
"section": SECTION_PROD,
|
||||
},
|
||||
]
|
||||
|
||||
PAGES_BY_SLUG = {page["slug"]: page for page in DOCS_PAGES}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from . import index, seo
|
||||
|
||||
router = index.router
|
||||
router.include_router(seo.router, prefix="/seo")
|
||||
@@ -0,0 +1,45 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from devplacepy.templating import templates
|
||||
from devplacepy.seo import list_page_seo
|
||||
from devplacepy.utils import get_current_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
"slug": "seo",
|
||||
"name": "SEO Diagnostics",
|
||||
"icon": "🔍",
|
||||
"url": "/tools/seo",
|
||||
"description": (
|
||||
"Audit any URL or sitemap against a broad battery of technical, on-page, "
|
||||
"structured-data, performance, accessibility and AI-readiness checks."
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def tools_index(request: Request):
|
||||
user = get_current_user(request)
|
||||
seo_ctx = list_page_seo(
|
||||
request,
|
||||
title="Tools",
|
||||
description="Developer tools on DevPlace, including the SEO Diagnostics auditor.",
|
||||
breadcrumbs=[
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Tools", "url": "/tools"},
|
||||
],
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"tools/index.html",
|
||||
{**seo_ctx, "request": request, "user": user, "tools": TOOLS},
|
||||
)
|
||||
@@ -0,0 +1,213 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Form, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
||||
|
||||
from devplacepy.config import SEO_REPORTS_DIR
|
||||
from devplacepy.models import SeoRunForm
|
||||
from devplacepy.responses import respond
|
||||
from devplacepy.schemas import SeoJobOut, SeoReportOut
|
||||
from devplacepy.seo import list_page_seo
|
||||
from devplacepy.services.jobs import queue
|
||||
from devplacepy.services.jobs.seo.progress import hub
|
||||
from devplacepy.services.manager import service_manager
|
||||
from devplacepy.templating import templates
|
||||
from devplacepy.utils import get_current_user, not_found
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
ACTIVE_STATES = (queue.PENDING, queue.RUNNING)
|
||||
TERMINAL_STATES = (queue.DONE, queue.FAILED)
|
||||
|
||||
|
||||
def _owner(request: Request) -> tuple[str, str]:
|
||||
user = get_current_user(request)
|
||||
if user:
|
||||
return "user", user["uid"]
|
||||
ip = request.headers.get("X-Real-IP") or (
|
||||
request.client.host if request.client else "anon"
|
||||
)
|
||||
return "guest", ip
|
||||
|
||||
|
||||
def _status_payload(job: dict) -> dict:
|
||||
result = job.get("result", {})
|
||||
done = job.get("status") == queue.DONE
|
||||
uid = job.get("uid", "")
|
||||
payload = job.get("payload", {})
|
||||
return {
|
||||
"uid": uid,
|
||||
"kind": job.get("kind", ""),
|
||||
"status": job.get("status", ""),
|
||||
"target": result.get("target") or payload.get("url"),
|
||||
"mode": result.get("mode") or payload.get("mode"),
|
||||
"ws_url": f"/tools/seo/{uid}/ws",
|
||||
"report_url": f"/tools/seo/{uid}/report" if done else None,
|
||||
"score": result.get("score") if done else None,
|
||||
"grade": result.get("grade") if done else None,
|
||||
"page_count": int(result.get("page_count") or 0),
|
||||
"error": job.get("error") or None,
|
||||
"created_at": job.get("created_at"),
|
||||
"completed_at": job.get("completed_at") or None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def seo_page(request: Request):
|
||||
user = get_current_user(request)
|
||||
seo_ctx = list_page_seo(
|
||||
request,
|
||||
title="SEO Diagnostics",
|
||||
description=(
|
||||
"Run a deep SEO audit on any URL or sitemap: technical, on-page, structured "
|
||||
"data, Core Web Vitals, accessibility and AI-readiness checks with live progress."
|
||||
),
|
||||
breadcrumbs=[
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Tools", "url": "/tools"},
|
||||
{"name": "SEO Diagnostics", "url": "/tools/seo"},
|
||||
],
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"tools/seo.html",
|
||||
{**seo_ctx, "request": request, "user": user},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/run")
|
||||
async def seo_run(request: Request, data: Annotated[SeoRunForm, Form()]):
|
||||
owner_kind, owner_id = _owner(request)
|
||||
active = [
|
||||
job
|
||||
for job in queue.list_jobs(kind="seo", owner=(owner_kind, owner_id))
|
||||
if job.get("status") in ACTIVE_STATES
|
||||
]
|
||||
if active:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": {
|
||||
"status": 429,
|
||||
"message": "You already have an SEO audit running. Wait for it to finish.",
|
||||
"uid": active[0]["uid"],
|
||||
}
|
||||
},
|
||||
status_code=429,
|
||||
)
|
||||
uid = queue.enqueue(
|
||||
"seo",
|
||||
{"url": data.url, "mode": data.mode, "max_pages": data.max_pages, "allow_private": False},
|
||||
owner_kind,
|
||||
owner_id,
|
||||
f"SEO: {data.url}"[:64],
|
||||
)
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
audit.record(
|
||||
request,
|
||||
"seo.run.request",
|
||||
summary=f"requested SEO diagnostics for {data.url}",
|
||||
metadata={"target": data.url, "mode": data.mode, "max_pages": data.max_pages},
|
||||
links=[audit.job(uid)],
|
||||
)
|
||||
return JSONResponse(
|
||||
{
|
||||
"uid": uid,
|
||||
"status_url": f"/tools/seo/{uid}",
|
||||
"ws_url": f"/tools/seo/{uid}/ws",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{uid}")
|
||||
async def seo_status(request: Request, uid: str):
|
||||
job = queue.get_job(uid)
|
||||
if not job or job.get("kind") != "seo":
|
||||
raise not_found("Audit not found")
|
||||
return JSONResponse(
|
||||
SeoJobOut.model_validate(_status_payload(job)).model_dump(mode="json")
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{uid}/report")
|
||||
async def seo_report(request: Request, uid: str):
|
||||
job = queue.get_job(uid)
|
||||
if not job or job.get("kind") != "seo":
|
||||
raise not_found("Audit not found")
|
||||
result = job.get("result", {})
|
||||
report = result.get("report", {}) if job.get("status") == queue.DONE else {}
|
||||
context = {
|
||||
"uid": uid,
|
||||
"status": job.get("status", ""),
|
||||
"target": report.get("target") or job.get("payload", {}).get("url"),
|
||||
"mode": report.get("mode") or job.get("payload", {}).get("mode"),
|
||||
"score": report.get("score"),
|
||||
"grade": report.get("grade"),
|
||||
"page_count": report.get("page_count", 0),
|
||||
"counts": report.get("counts", {}),
|
||||
"categories": report.get("categories", {}),
|
||||
"pages": report.get("pages", []),
|
||||
"checks": report.get("checks", []),
|
||||
"site": report.get("site", {}),
|
||||
"generated_at": report.get("generated_at"),
|
||||
"request": request,
|
||||
"user": get_current_user(request),
|
||||
}
|
||||
return respond(request, "tools/seo_report.html", context, model=SeoReportOut)
|
||||
|
||||
|
||||
@router.get("/{uid}/screenshot/{index}")
|
||||
async def seo_screenshot(request: Request, uid: str, index: int):
|
||||
job = queue.get_job(uid)
|
||||
if not job or job.get("kind") != "seo":
|
||||
raise not_found("Screenshot not available")
|
||||
path = (SEO_REPORTS_DIR / uid / f"page-{index}.png").resolve()
|
||||
if not path.is_relative_to(SEO_REPORTS_DIR.resolve()) or not path.is_file():
|
||||
raise not_found("Screenshot not available")
|
||||
return FileResponse(path, media_type="image/png")
|
||||
|
||||
|
||||
@router.websocket("/{uid}/ws")
|
||||
async def seo_ws(websocket: WebSocket, uid: str):
|
||||
await websocket.accept()
|
||||
if not service_manager.owns_lock():
|
||||
await websocket.close(code=4013)
|
||||
return
|
||||
job = queue.get_job(uid)
|
||||
if not job or job.get("kind") != "seo":
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
listener = hub.subscribe(uid)
|
||||
try:
|
||||
for frame in hub.snapshot(uid):
|
||||
await websocket.send_json(frame)
|
||||
current = queue.get_job(uid)
|
||||
if current and current.get("status") in TERMINAL_STATES:
|
||||
done = current.get("status") == queue.DONE
|
||||
report = current.get("result", {}).get("report", {}) if done else {}
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "done" if done else "failed",
|
||||
"status": current.get("status"),
|
||||
"report_url": f"/tools/seo/{uid}/report",
|
||||
"error": current.get("error") or None,
|
||||
"report": report,
|
||||
}
|
||||
)
|
||||
return
|
||||
while True:
|
||||
frame = await listener.get()
|
||||
await websocket.send_json(frame)
|
||||
if frame.get("type") in ("done", "failed"):
|
||||
break
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception: # noqa: BLE001 - never crash the worker on a socket error
|
||||
logger.exception("SEO websocket loop failed for %s", uid)
|
||||
finally:
|
||||
hub.unsubscribe(uid, listener)
|
||||
@@ -95,6 +95,38 @@ class ForkJobOut(_Out):
|
||||
completed_at: Optional[str] = None
|
||||
|
||||
|
||||
class SeoJobOut(_Out):
|
||||
uid: str = ""
|
||||
kind: str = ""
|
||||
status: str = ""
|
||||
target: Optional[str] = None
|
||||
mode: Optional[str] = None
|
||||
ws_url: Optional[str] = None
|
||||
report_url: Optional[str] = None
|
||||
score: Optional[int] = None
|
||||
grade: Optional[str] = None
|
||||
page_count: int = 0
|
||||
error: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
completed_at: Optional[str] = None
|
||||
|
||||
|
||||
class SeoReportOut(_Out):
|
||||
uid: str = ""
|
||||
status: str = ""
|
||||
target: Optional[str] = None
|
||||
mode: Optional[str] = None
|
||||
score: Optional[int] = None
|
||||
grade: Optional[str] = None
|
||||
page_count: int = 0
|
||||
counts: dict = {}
|
||||
categories: dict = {}
|
||||
pages: list = []
|
||||
checks: list = []
|
||||
site: dict = {}
|
||||
generated_at: Optional[str] = None
|
||||
|
||||
|
||||
# ---------- shared resource projections ----------
|
||||
|
||||
|
||||
|
||||
@@ -333,6 +333,8 @@ def _build_sitemap(base_url):
|
||||
url_element(f"{base_url}/leaderboard", changefreq="daily", priority="0.7")
|
||||
)
|
||||
urlset.append(url_element(f"{base_url}/bugs", changefreq="daily", priority="0.6"))
|
||||
urlset.append(url_element(f"{base_url}/tools", changefreq="monthly", priority="0.5"))
|
||||
urlset.append(url_element(f"{base_url}/tools/seo", changefreq="monthly", priority="0.5"))
|
||||
|
||||
if "posts" in db.tables:
|
||||
posts = _collect(
|
||||
@@ -440,6 +442,7 @@ def _build_sitemap(base_url):
|
||||
_public_docs_pages = [
|
||||
"index",
|
||||
"devii",
|
||||
"tools-seo",
|
||||
"media-gallery",
|
||||
"maintenance-agents",
|
||||
"maintenance-usage",
|
||||
|
||||
@@ -26,6 +26,7 @@ CATEGORY_BY_PREFIX: dict[str, str] = {
|
||||
"service": "service",
|
||||
"container": "container",
|
||||
"proxy": "ingress",
|
||||
"seo": "tools",
|
||||
"ai": "ai",
|
||||
"devii": "devii",
|
||||
"cli": "cli",
|
||||
|
||||
@@ -566,6 +566,35 @@ ACTIONS: tuple[Action, ...] = (
|
||||
params=(path("uid", "Fork job uid returned by fork_project."),),
|
||||
requires_auth=False,
|
||||
),
|
||||
Action(
|
||||
name="seo_diagnostics",
|
||||
method="POST",
|
||||
path="/tools/seo/run",
|
||||
summary="Run an SEO audit on a URL or sitemap",
|
||||
description=(
|
||||
"Queues a background SEO diagnostics job and returns {uid, status_url}. Poll the "
|
||||
"status_url with seo_status until status is 'done', then share the score, grade and "
|
||||
"report_url. mode='url' audits a single page; mode='sitemap' crawls up to max_pages."
|
||||
),
|
||||
params=(
|
||||
body("url", "The page URL or sitemap.xml URL to audit.", required=True),
|
||||
body("mode", "'url' for a single page or 'sitemap' to crawl a sitemap."),
|
||||
body("max_pages", "Maximum pages to crawl in sitemap mode (1-50)."),
|
||||
),
|
||||
requires_auth=False,
|
||||
),
|
||||
Action(
|
||||
name="seo_status",
|
||||
method="GET",
|
||||
path="/tools/seo/{uid}",
|
||||
summary="Check an SEO audit and obtain its score once finished",
|
||||
description=(
|
||||
"Returns the audit status. When status is 'done', score, grade and report_url are "
|
||||
"populated; while 'pending' or 'running', poll again shortly."
|
||||
),
|
||||
params=(path("uid", "SEO job uid returned by seo_diagnostics."),),
|
||||
requires_auth=False,
|
||||
),
|
||||
Action(
|
||||
name="search_users",
|
||||
method="GET",
|
||||
|
||||
@@ -13,6 +13,7 @@ from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from devplacepy.net_guard import effective_address, is_blocked_address
|
||||
from ..config import Settings
|
||||
from ..errors import NetworkError, ToolInputError, UpstreamError
|
||||
from ..text import html_to_text
|
||||
@@ -49,21 +50,6 @@ TITLE = re.compile(r"<title[^>]*>(.*?)</title>", re.IGNORECASE | re.DOTALL)
|
||||
MIN_FETCH_CHARS = 1000
|
||||
ALLOWED_METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS")
|
||||
|
||||
NAT64_PREFIXES = (
|
||||
ipaddress.ip_network("64:ff9b::/96"),
|
||||
ipaddress.ip_network("64:ff9b:1::/48"),
|
||||
)
|
||||
|
||||
|
||||
def _effective_address(address: Any) -> Any:
|
||||
if isinstance(address, ipaddress.IPv6Address):
|
||||
if address.ipv4_mapped is not None:
|
||||
return address.ipv4_mapped
|
||||
for prefix in NAT64_PREFIXES:
|
||||
if address in prefix:
|
||||
return ipaddress.IPv4Address(int(address) & 0xFFFFFFFF)
|
||||
return address
|
||||
|
||||
|
||||
class FetchController:
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
@@ -218,17 +204,10 @@ class FetchController:
|
||||
except socket.gaierror as exc:
|
||||
raise NetworkError(f"Could not resolve host: {host}", host=host) from exc
|
||||
for info in infos:
|
||||
address = _effective_address(ipaddress.ip_address(info[4][0]))
|
||||
if (
|
||||
address.is_private
|
||||
or address.is_loopback
|
||||
or address.is_link_local
|
||||
or address.is_reserved
|
||||
or address.is_multicast
|
||||
or address.is_unspecified
|
||||
):
|
||||
address = ipaddress.ip_address(info[4][0])
|
||||
if is_blocked_address(address):
|
||||
raise ToolInputError(
|
||||
f"Refusing to fetch a private or local address ({address}). "
|
||||
f"Refusing to fetch a private or local address ({effective_address(address)}). "
|
||||
"Set DEVII_FETCH_ALLOW_PRIVATE=1 to override."
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
@@ -0,0 +1 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
@@ -0,0 +1,90 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from .base import (
|
||||
HIGH,
|
||||
MEDIUM,
|
||||
LOW,
|
||||
INFO,
|
||||
Check,
|
||||
PageContext,
|
||||
SiteContext,
|
||||
ok_check,
|
||||
page_check,
|
||||
site_check,
|
||||
)
|
||||
|
||||
CATEGORY = "ai_readiness"
|
||||
|
||||
_TAG = re.compile(r"<[^>]+>")
|
||||
_SCRIPT_STYLE = re.compile(r"<(script|style)[^>]*>.*?</\1>", re.IGNORECASE | re.DOTALL)
|
||||
|
||||
|
||||
def _text_words(html: str) -> int:
|
||||
if not html:
|
||||
return 0
|
||||
stripped = _SCRIPT_STYLE.sub(" ", html)
|
||||
stripped = _TAG.sub(" ", stripped)
|
||||
return len([w for w in stripped.split() if w])
|
||||
|
||||
|
||||
@page_check
|
||||
def ssr_parity(page: PageContext, site: SiteContext) -> Check:
|
||||
rendered = int(page.dom.get("wordCount", 0) or 0)
|
||||
raw = _text_words(page.raw_html)
|
||||
if rendered <= 0:
|
||||
return None
|
||||
ratio = raw / rendered if rendered else 0
|
||||
passed = ratio >= 0.5
|
||||
return ok_check(
|
||||
"ai.ssr_parity",
|
||||
CATEGORY,
|
||||
"Content in initial HTML",
|
||||
passed,
|
||||
severity=HIGH,
|
||||
value=f"{int(ratio * 100)}% server-rendered",
|
||||
recommendation="Serve primary content in the initial HTML (SSR); JS-only content is invisible to many crawlers and AI agents.",
|
||||
warn=ratio >= 0.25,
|
||||
details={"raw_words": raw, "rendered_words": rendered},
|
||||
url=page.url,
|
||||
)
|
||||
|
||||
|
||||
@page_check
|
||||
def semantic_html(page: PageContext, site: SiteContext) -> Check:
|
||||
semantic = page.dom.get("semantic", {}) or {}
|
||||
has_main = int(semantic.get("main", 0) or 0) >= 1
|
||||
has_landmarks = any(
|
||||
int(semantic.get(tag, 0) or 0) >= 1 for tag in ("article", "nav", "header", "footer")
|
||||
)
|
||||
passed = has_main and has_landmarks
|
||||
return ok_check(
|
||||
"ai.semantic_html",
|
||||
CATEGORY,
|
||||
"Semantic HTML landmarks",
|
||||
passed,
|
||||
severity=LOW,
|
||||
value="ok" if passed else "missing landmarks",
|
||||
recommendation="Use <main>, <article>, <nav>, <header> and <footer> so machines can extract the main content.",
|
||||
warn=True,
|
||||
details=semantic,
|
||||
url=page.url,
|
||||
)
|
||||
|
||||
|
||||
@site_check
|
||||
def llms_txt(site: SiteContext) -> Check:
|
||||
found = bool((site.llms_txt or {}).get("found"))
|
||||
return ok_check(
|
||||
"ai.llms_txt",
|
||||
CATEGORY,
|
||||
"llms.txt present",
|
||||
found,
|
||||
severity=LOW,
|
||||
value="present" if found else "missing",
|
||||
recommendation="Publish an /llms.txt manifest to guide AI crawlers to your key content (emerging standard).",
|
||||
warn=True,
|
||||
)
|
||||
@@ -0,0 +1,128 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable, Optional
|
||||
|
||||
PASS = "pass"
|
||||
WARN = "warn"
|
||||
FAIL = "fail"
|
||||
INFO = "info"
|
||||
SKIP = "skip"
|
||||
|
||||
CRITICAL = "critical"
|
||||
HIGH = "high"
|
||||
MEDIUM = "medium"
|
||||
LOW = "low"
|
||||
INFORMATIONAL = "info"
|
||||
|
||||
SEVERITY_WEIGHT = {
|
||||
CRITICAL: 5.0,
|
||||
HIGH: 3.0,
|
||||
MEDIUM: 2.0,
|
||||
LOW: 1.0,
|
||||
INFORMATIONAL: 0.0,
|
||||
}
|
||||
|
||||
STATUS_CREDIT = {PASS: 1.0, WARN: 0.5, FAIL: 0.0, INFO: 1.0, SKIP: 1.0}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Check:
|
||||
id: str
|
||||
category: str
|
||||
title: str
|
||||
status: str
|
||||
severity: str = MEDIUM
|
||||
value: str = ""
|
||||
recommendation: str = ""
|
||||
details: dict = field(default_factory=dict)
|
||||
url: str = ""
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"category": self.category,
|
||||
"title": self.title,
|
||||
"status": self.status,
|
||||
"severity": self.severity,
|
||||
"value": self.value,
|
||||
"recommendation": self.recommendation,
|
||||
"details": self.details,
|
||||
"url": self.url,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PageContext:
|
||||
requested_url: str = ""
|
||||
url: str = ""
|
||||
status: int = 0
|
||||
ok: bool = False
|
||||
error: str = ""
|
||||
elapsed_ms: int = 0
|
||||
redirect_chain: list = field(default_factory=list)
|
||||
headers: dict = field(default_factory=dict)
|
||||
raw_html: str = ""
|
||||
rendered_html: str = ""
|
||||
dom: dict = field(default_factory=dict)
|
||||
metrics: dict = field(default_factory=dict)
|
||||
mobile: dict = field(default_factory=dict)
|
||||
screenshot: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class SiteContext:
|
||||
target_url: str = ""
|
||||
mode: str = "url"
|
||||
base_host: str = ""
|
||||
base_scheme: str = "https"
|
||||
robots: dict = field(default_factory=dict)
|
||||
sitemap: dict = field(default_factory=dict)
|
||||
llms_txt: dict = field(default_factory=dict)
|
||||
pages: list = field(default_factory=list)
|
||||
|
||||
|
||||
PAGE_CHECKS: list[Callable] = []
|
||||
SITE_CHECKS: list[Callable] = []
|
||||
|
||||
|
||||
def page_check(func: Callable) -> Callable:
|
||||
PAGE_CHECKS.append(func)
|
||||
return func
|
||||
|
||||
|
||||
def site_check(func: Callable) -> Callable:
|
||||
SITE_CHECKS.append(func)
|
||||
return func
|
||||
|
||||
|
||||
def ok_check(
|
||||
cid: str,
|
||||
category: str,
|
||||
title: str,
|
||||
passed: bool,
|
||||
*,
|
||||
severity: str = MEDIUM,
|
||||
value: str = "",
|
||||
recommendation: str = "",
|
||||
details: Optional[dict] = None,
|
||||
url: str = "",
|
||||
warn: bool = False,
|
||||
) -> Check:
|
||||
if passed:
|
||||
status = PASS
|
||||
else:
|
||||
status = WARN if warn else FAIL
|
||||
return Check(
|
||||
id=cid,
|
||||
category=category,
|
||||
title=title,
|
||||
status=status,
|
||||
severity=severity,
|
||||
value=value,
|
||||
recommendation=recommendation if not passed else "",
|
||||
details=details or {},
|
||||
url=url,
|
||||
)
|
||||
@@ -0,0 +1,302 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .base import (
|
||||
CRITICAL,
|
||||
HIGH,
|
||||
MEDIUM,
|
||||
LOW,
|
||||
PASS,
|
||||
WARN,
|
||||
FAIL,
|
||||
INFO,
|
||||
Check,
|
||||
PageContext,
|
||||
SiteContext,
|
||||
ok_check,
|
||||
page_check,
|
||||
site_check,
|
||||
)
|
||||
|
||||
CATEGORY = "crawlability"
|
||||
|
||||
|
||||
def _normalize(url: str) -> str:
|
||||
return url.rstrip("/").split("#")[0]
|
||||
|
||||
|
||||
@page_check
|
||||
def http_status(page: PageContext, site: SiteContext) -> Check:
|
||||
passed = 200 <= page.status < 300
|
||||
return ok_check(
|
||||
"crawl.http_status",
|
||||
CATEGORY,
|
||||
"HTTP status",
|
||||
passed,
|
||||
severity=CRITICAL,
|
||||
value=str(page.status),
|
||||
recommendation="Return a 200 OK status for indexable pages.",
|
||||
url=page.url,
|
||||
)
|
||||
|
||||
|
||||
@page_check
|
||||
def redirects(page: PageContext, site: SiteContext) -> Check:
|
||||
hops = len(page.redirect_chain)
|
||||
if hops == 0:
|
||||
return Check(
|
||||
"crawl.redirects",
|
||||
CATEGORY,
|
||||
"Redirect chain",
|
||||
PASS,
|
||||
LOW,
|
||||
value="no redirect",
|
||||
url=page.url,
|
||||
)
|
||||
status = WARN if hops <= 2 else FAIL
|
||||
return Check(
|
||||
"crawl.redirects",
|
||||
CATEGORY,
|
||||
"Redirect chain",
|
||||
status,
|
||||
MEDIUM,
|
||||
value=f"{hops} hop(s)",
|
||||
recommendation="Link directly to the final URL to avoid redirect latency and link-equity loss.",
|
||||
details={"chain": page.redirect_chain},
|
||||
url=page.url,
|
||||
)
|
||||
|
||||
|
||||
@page_check
|
||||
def https(page: PageContext, site: SiteContext) -> Check:
|
||||
passed = page.url.startswith("https://")
|
||||
return ok_check(
|
||||
"crawl.https",
|
||||
CATEGORY,
|
||||
"Served over HTTPS",
|
||||
passed,
|
||||
severity=HIGH,
|
||||
value="https" if passed else "http",
|
||||
recommendation="Serve every page over HTTPS.",
|
||||
url=page.url,
|
||||
)
|
||||
|
||||
|
||||
@page_check
|
||||
def hsts(page: PageContext, site: SiteContext) -> Check:
|
||||
if not page.url.startswith("https://"):
|
||||
return None
|
||||
present = bool(page.headers.get("strict-transport-security"))
|
||||
return ok_check(
|
||||
"crawl.hsts",
|
||||
CATEGORY,
|
||||
"HSTS header",
|
||||
present,
|
||||
severity=LOW,
|
||||
value="present" if present else "missing",
|
||||
recommendation="Add a Strict-Transport-Security header to enforce HTTPS.",
|
||||
warn=True,
|
||||
url=page.url,
|
||||
)
|
||||
|
||||
|
||||
@page_check
|
||||
def canonical(page: PageContext, site: SiteContext) -> list:
|
||||
href = page.dom.get("canonical", "")
|
||||
count = int(page.dom.get("canonicalCount", 0) or 0)
|
||||
results = [
|
||||
ok_check(
|
||||
"crawl.canonical_present",
|
||||
CATEGORY,
|
||||
"Canonical tag",
|
||||
bool(href),
|
||||
severity=MEDIUM,
|
||||
value=href or "missing",
|
||||
recommendation="Add a self-referential <link rel=canonical> to consolidate duplicates.",
|
||||
warn=True,
|
||||
url=page.url,
|
||||
)
|
||||
]
|
||||
if count > 1:
|
||||
results.append(
|
||||
Check(
|
||||
"crawl.canonical_single",
|
||||
CATEGORY,
|
||||
"Single canonical",
|
||||
FAIL,
|
||||
MEDIUM,
|
||||
value=f"{count} canonicals",
|
||||
recommendation="Declare exactly one canonical URL per page.",
|
||||
url=page.url,
|
||||
)
|
||||
)
|
||||
if href:
|
||||
same = _normalize(href) == _normalize(page.url)
|
||||
results.append(
|
||||
ok_check(
|
||||
"crawl.canonical_self",
|
||||
CATEGORY,
|
||||
"Self-referential canonical",
|
||||
same,
|
||||
severity=LOW,
|
||||
value="self" if same else href,
|
||||
recommendation="Point the canonical at this page's own URL unless intentionally consolidating.",
|
||||
warn=True,
|
||||
url=page.url,
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
@page_check
|
||||
def indexability(page: PageContext, site: SiteContext) -> Check:
|
||||
robots = str(page.dom.get("metaRobots", "")).lower()
|
||||
header = str(page.headers.get("x-robots-tag", "")).lower()
|
||||
blocked = "noindex" in robots or "noindex" in header
|
||||
return ok_check(
|
||||
"crawl.indexable",
|
||||
CATEGORY,
|
||||
"Indexable (no noindex)",
|
||||
not blocked,
|
||||
severity=HIGH,
|
||||
value="noindex" if blocked else "indexable",
|
||||
recommendation="Remove noindex from meta robots / X-Robots-Tag if this page should rank.",
|
||||
warn=True,
|
||||
url=page.url,
|
||||
)
|
||||
|
||||
|
||||
@page_check
|
||||
def url_hygiene(page: PageContext, site: SiteContext) -> Check:
|
||||
parsed = urlparse(page.url)
|
||||
path = parsed.path
|
||||
issues = []
|
||||
if len(page.url) > 100:
|
||||
issues.append("long")
|
||||
if any(c.isupper() for c in path):
|
||||
issues.append("uppercase")
|
||||
if "_" in path:
|
||||
issues.append("underscores")
|
||||
if parsed.query.count("&") >= 3:
|
||||
issues.append("many params")
|
||||
passed = not issues
|
||||
return ok_check(
|
||||
"crawl.url_hygiene",
|
||||
CATEGORY,
|
||||
"Clean URL",
|
||||
passed,
|
||||
severity=LOW,
|
||||
value="clean" if passed else ", ".join(issues),
|
||||
recommendation="Use short lowercase hyphenated paths with few query parameters.",
|
||||
warn=True,
|
||||
url=page.url,
|
||||
)
|
||||
|
||||
|
||||
@page_check
|
||||
def mixed_content(page: PageContext, site: SiteContext) -> Check:
|
||||
if not page.url.startswith("https://"):
|
||||
return None
|
||||
items = page.dom.get("mixedContent", []) or []
|
||||
passed = not items
|
||||
return ok_check(
|
||||
"crawl.mixed_content",
|
||||
CATEGORY,
|
||||
"No mixed content",
|
||||
passed,
|
||||
severity=HIGH,
|
||||
value="clean" if passed else f"{len(items)} insecure resource(s)",
|
||||
recommendation="Load every sub-resource over HTTPS to avoid mixed-content blocking.",
|
||||
details={"resources": items[:20]},
|
||||
url=page.url,
|
||||
)
|
||||
|
||||
|
||||
@site_check
|
||||
def robots_txt(site: SiteContext) -> list:
|
||||
robots = site.robots or {}
|
||||
fetched = robots.get("status") == 200
|
||||
results = [
|
||||
ok_check(
|
||||
"crawl.robots_txt",
|
||||
CATEGORY,
|
||||
"robots.txt present",
|
||||
fetched,
|
||||
severity=MEDIUM,
|
||||
value=f"{robots.get('status', 'n/a')}",
|
||||
recommendation="Publish a robots.txt at the site root.",
|
||||
warn=True,
|
||||
)
|
||||
]
|
||||
if fetched:
|
||||
results.append(
|
||||
ok_check(
|
||||
"crawl.robots_sitemap",
|
||||
CATEGORY,
|
||||
"Sitemap declared in robots.txt",
|
||||
bool(robots.get("sitemap_urls")),
|
||||
severity=LOW,
|
||||
value=", ".join(robots.get("sitemap_urls", [])[:3]) or "none",
|
||||
recommendation="Reference your sitemap with a Sitemap: directive in robots.txt.",
|
||||
warn=True,
|
||||
)
|
||||
)
|
||||
if robots.get("blocks_target"):
|
||||
results.append(
|
||||
Check(
|
||||
"crawl.robots_blocks_target",
|
||||
CATEGORY,
|
||||
"robots.txt blocks the audited URL",
|
||||
FAIL,
|
||||
HIGH,
|
||||
value="disallowed",
|
||||
recommendation="The audited URL is disallowed in robots.txt; allow it if it should be crawled.",
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
@site_check
|
||||
def sitemap_xml(site: SiteContext) -> list:
|
||||
sm = site.sitemap or {}
|
||||
fetched = sm.get("status") == 200
|
||||
results = [
|
||||
ok_check(
|
||||
"crawl.sitemap_present",
|
||||
CATEGORY,
|
||||
"XML sitemap reachable",
|
||||
fetched,
|
||||
severity=MEDIUM,
|
||||
value=f"{sm.get('url_count', 0)} url(s)" if fetched else f"{sm.get('status', 'n/a')}",
|
||||
recommendation="Publish an XML sitemap and submit it to search engines.",
|
||||
warn=True,
|
||||
)
|
||||
]
|
||||
if fetched:
|
||||
results.append(
|
||||
ok_check(
|
||||
"crawl.sitemap_valid",
|
||||
CATEGORY,
|
||||
"Sitemap is valid XML",
|
||||
bool(sm.get("valid_xml")),
|
||||
severity=MEDIUM,
|
||||
value="valid" if sm.get("valid_xml") else "invalid",
|
||||
recommendation="Fix the sitemap XML so crawlers can parse it.",
|
||||
)
|
||||
)
|
||||
results.append(
|
||||
ok_check(
|
||||
"crawl.sitemap_lastmod",
|
||||
CATEGORY,
|
||||
"Sitemap uses lastmod",
|
||||
int(sm.get("lastmod_count", 0) or 0) > 0,
|
||||
severity=LOW,
|
||||
value=f"{sm.get('lastmod_count', 0)} entries",
|
||||
recommendation="Add <lastmod> dates so crawlers prioritise fresh pages.",
|
||||
warn=True,
|
||||
)
|
||||
)
|
||||
return results
|
||||
@@ -0,0 +1,79 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
from .base import (
|
||||
MEDIUM,
|
||||
LOW,
|
||||
INFO,
|
||||
Check,
|
||||
SiteContext,
|
||||
site_check,
|
||||
)
|
||||
|
||||
CATEGORY = "crosspage"
|
||||
|
||||
|
||||
def _duplicates(site: SiteContext, dom_key: str) -> dict:
|
||||
groups: dict[str, list] = defaultdict(list)
|
||||
for page in site.pages:
|
||||
value = (page.dom.get(dom_key, "") or "").strip().lower()
|
||||
if value:
|
||||
groups[value].append(page.url)
|
||||
return {value: urls for value, urls in groups.items() if len(urls) > 1}
|
||||
|
||||
|
||||
@site_check
|
||||
def duplicate_titles(site: SiteContext) -> Check:
|
||||
if len(site.pages) < 2:
|
||||
return None
|
||||
dupes = _duplicates(site, "title")
|
||||
passed = not dupes
|
||||
return Check(
|
||||
"crosspage.duplicate_titles",
|
||||
CATEGORY,
|
||||
"Unique page titles",
|
||||
"pass" if passed else "warn",
|
||||
MEDIUM,
|
||||
value="unique" if passed else f"{len(dupes)} duplicated title(s)",
|
||||
recommendation="" if passed else "Give every page a distinct <title>.",
|
||||
details={"duplicates": {k: v for k, v in list(dupes.items())[:10]}},
|
||||
)
|
||||
|
||||
|
||||
@site_check
|
||||
def duplicate_descriptions(site: SiteContext) -> Check:
|
||||
if len(site.pages) < 2:
|
||||
return None
|
||||
dupes = _duplicates(site, "metaDescription")
|
||||
passed = not dupes
|
||||
return Check(
|
||||
"crosspage.duplicate_descriptions",
|
||||
CATEGORY,
|
||||
"Unique meta descriptions",
|
||||
"pass" if passed else "warn",
|
||||
LOW,
|
||||
value="unique" if passed else f"{len(dupes)} duplicated description(s)",
|
||||
recommendation="" if passed else "Write a distinct meta description for every page.",
|
||||
details={"duplicates": {k: v for k, v in list(dupes.items())[:10]}},
|
||||
)
|
||||
|
||||
|
||||
@site_check
|
||||
def hreflang_usage(site: SiteContext) -> Check:
|
||||
annotated = [p for p in site.pages if p.dom.get("hreflang")]
|
||||
if not annotated:
|
||||
return None
|
||||
return Check(
|
||||
"crosspage.hreflang",
|
||||
CATEGORY,
|
||||
"Hreflang annotations",
|
||||
INFO,
|
||||
INFO,
|
||||
value=f"{len(annotated)} page(s) declare hreflang",
|
||||
details={
|
||||
"pages": {p.url: p.dom.get("hreflang") for p in annotated[:5]},
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,90 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import (
|
||||
MEDIUM,
|
||||
LOW,
|
||||
WARN,
|
||||
Check,
|
||||
PageContext,
|
||||
SiteContext,
|
||||
ok_check,
|
||||
page_check,
|
||||
)
|
||||
|
||||
CATEGORY = "content"
|
||||
|
||||
THIN_CONTENT_WORDS = 250
|
||||
|
||||
|
||||
@page_check
|
||||
def single_h1(page: PageContext, site: SiteContext) -> list:
|
||||
h1s = page.dom.get("h1", []) or []
|
||||
results = [
|
||||
ok_check(
|
||||
"content.h1_present",
|
||||
CATEGORY,
|
||||
"H1 heading",
|
||||
len(h1s) >= 1,
|
||||
severity=MEDIUM,
|
||||
value=(h1s[0][:80] if h1s else "missing"),
|
||||
recommendation="Add a single descriptive H1 heading.",
|
||||
url=page.url,
|
||||
)
|
||||
]
|
||||
if len(h1s) > 1:
|
||||
results.append(
|
||||
Check(
|
||||
"content.h1_single",
|
||||
CATEGORY,
|
||||
"Single H1",
|
||||
WARN,
|
||||
LOW,
|
||||
value=f"{len(h1s)} H1s",
|
||||
recommendation="Use exactly one H1 per page.",
|
||||
url=page.url,
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
@page_check
|
||||
def heading_order(page: PageContext, site: SiteContext) -> Check:
|
||||
headings = page.dom.get("headings", []) or []
|
||||
levels = [int(h.get("level", 0)) for h in headings if h.get("level")]
|
||||
skipped = False
|
||||
prev = 0
|
||||
for level in levels:
|
||||
if prev and level > prev + 1:
|
||||
skipped = True
|
||||
break
|
||||
prev = level
|
||||
return ok_check(
|
||||
"content.heading_order",
|
||||
CATEGORY,
|
||||
"Heading hierarchy",
|
||||
not skipped,
|
||||
severity=LOW,
|
||||
value="ordered" if not skipped else "skips a level",
|
||||
recommendation="Do not skip heading levels (e.g. H2 to H4).",
|
||||
warn=True,
|
||||
url=page.url,
|
||||
)
|
||||
|
||||
|
||||
@page_check
|
||||
def word_count(page: PageContext, site: SiteContext) -> Check:
|
||||
words = int(page.dom.get("wordCount", 0) or 0)
|
||||
passed = words >= THIN_CONTENT_WORDS
|
||||
return ok_check(
|
||||
"content.word_count",
|
||||
CATEGORY,
|
||||
"Content depth",
|
||||
passed,
|
||||
severity=MEDIUM,
|
||||
value=f"{words} words",
|
||||
recommendation=f"Thin content; aim for more than {THIN_CONTENT_WORDS} meaningful words.",
|
||||
warn=True,
|
||||
url=page.url,
|
||||
)
|
||||
@@ -0,0 +1,99 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .base import (
|
||||
MEDIUM,
|
||||
LOW,
|
||||
INFO,
|
||||
Check,
|
||||
PageContext,
|
||||
SiteContext,
|
||||
ok_check,
|
||||
page_check,
|
||||
)
|
||||
|
||||
CATEGORY = "links"
|
||||
|
||||
GENERIC_ANCHORS = {
|
||||
"click here",
|
||||
"here",
|
||||
"read more",
|
||||
"more",
|
||||
"link",
|
||||
"this",
|
||||
"this page",
|
||||
}
|
||||
|
||||
|
||||
@page_check
|
||||
def link_counts(page: PageContext, site: SiteContext) -> Check:
|
||||
links = page.dom.get("links", []) or []
|
||||
host = site.base_host
|
||||
internal = 0
|
||||
external = 0
|
||||
for link in links:
|
||||
href = link.get("href", "")
|
||||
netloc = urlparse(href).netloc
|
||||
if not netloc or netloc == host:
|
||||
internal += 1
|
||||
else:
|
||||
external += 1
|
||||
return Check(
|
||||
"links.counts",
|
||||
CATEGORY,
|
||||
"Link profile",
|
||||
INFO,
|
||||
INFO,
|
||||
value=f"{internal} internal, {external} external",
|
||||
details={"internal": internal, "external": external},
|
||||
url=page.url,
|
||||
)
|
||||
|
||||
|
||||
@page_check
|
||||
def internal_links_present(page: PageContext, site: SiteContext) -> Check:
|
||||
links = page.dom.get("links", []) or []
|
||||
host = site.base_host
|
||||
internal = [
|
||||
link
|
||||
for link in links
|
||||
if not urlparse(link.get("href", "")).netloc
|
||||
or urlparse(link.get("href", "")).netloc == host
|
||||
]
|
||||
return ok_check(
|
||||
"links.internal_present",
|
||||
CATEGORY,
|
||||
"Internal links",
|
||||
len(internal) >= 1,
|
||||
severity=LOW,
|
||||
value=f"{len(internal)} internal link(s)",
|
||||
recommendation="Add internal links so crawlers can discover related pages.",
|
||||
warn=True,
|
||||
url=page.url,
|
||||
)
|
||||
|
||||
|
||||
@page_check
|
||||
def generic_anchors(page: PageContext, site: SiteContext) -> Check:
|
||||
links = page.dom.get("links", []) or []
|
||||
generic = [
|
||||
link
|
||||
for link in links
|
||||
if (link.get("text", "") or "").strip().lower() in GENERIC_ANCHORS
|
||||
]
|
||||
passed = not generic
|
||||
return ok_check(
|
||||
"links.anchor_text",
|
||||
CATEGORY,
|
||||
"Descriptive anchor text",
|
||||
passed,
|
||||
severity=LOW,
|
||||
value="descriptive" if passed else f"{len(generic)} generic anchor(s)",
|
||||
recommendation="Replace generic anchors like 'click here' with descriptive text.",
|
||||
warn=True,
|
||||
details={"samples": [link.get("href", "") for link in generic[:10]]},
|
||||
url=page.url,
|
||||
)
|
||||
@@ -0,0 +1,169 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import (
|
||||
HIGH,
|
||||
MEDIUM,
|
||||
LOW,
|
||||
PASS,
|
||||
WARN,
|
||||
Check,
|
||||
PageContext,
|
||||
SiteContext,
|
||||
ok_check,
|
||||
page_check,
|
||||
)
|
||||
|
||||
CATEGORY = "meta"
|
||||
|
||||
TITLE_MIN = 30
|
||||
TITLE_MAX = 60
|
||||
DESC_MIN = 70
|
||||
DESC_MAX = 160
|
||||
|
||||
|
||||
@page_check
|
||||
def title(page: PageContext, site: SiteContext) -> list:
|
||||
text = (page.dom.get("title", "") or "").strip()
|
||||
count = int(page.dom.get("titleCount", 0) or 0)
|
||||
results = [
|
||||
ok_check(
|
||||
"meta.title_present",
|
||||
CATEGORY,
|
||||
"Title tag",
|
||||
bool(text),
|
||||
severity=HIGH,
|
||||
value=text or "missing",
|
||||
recommendation="Add a unique, descriptive <title>.",
|
||||
url=page.url,
|
||||
)
|
||||
]
|
||||
if text:
|
||||
length = len(text)
|
||||
good = TITLE_MIN <= length <= TITLE_MAX
|
||||
results.append(
|
||||
ok_check(
|
||||
"meta.title_length",
|
||||
CATEGORY,
|
||||
"Title length",
|
||||
good,
|
||||
severity=LOW,
|
||||
value=f"{length} chars",
|
||||
recommendation=f"Keep titles between {TITLE_MIN} and {TITLE_MAX} characters.",
|
||||
warn=True,
|
||||
url=page.url,
|
||||
)
|
||||
)
|
||||
if count > 1:
|
||||
results.append(
|
||||
Check(
|
||||
"meta.title_single",
|
||||
CATEGORY,
|
||||
"Single title tag",
|
||||
WARN,
|
||||
LOW,
|
||||
value=f"{count} titles",
|
||||
recommendation="Use exactly one <title> element.",
|
||||
url=page.url,
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
@page_check
|
||||
def description(page: PageContext, site: SiteContext) -> list:
|
||||
text = (page.dom.get("metaDescription", "") or "").strip()
|
||||
results = [
|
||||
ok_check(
|
||||
"meta.description_present",
|
||||
CATEGORY,
|
||||
"Meta description",
|
||||
bool(text),
|
||||
severity=MEDIUM,
|
||||
value=text[:120] or "missing",
|
||||
recommendation="Write a compelling 70-160 character meta description.",
|
||||
warn=True,
|
||||
url=page.url,
|
||||
)
|
||||
]
|
||||
if text:
|
||||
length = len(text)
|
||||
good = DESC_MIN <= length <= DESC_MAX
|
||||
results.append(
|
||||
ok_check(
|
||||
"meta.description_length",
|
||||
CATEGORY,
|
||||
"Description length",
|
||||
good,
|
||||
severity=LOW,
|
||||
value=f"{length} chars",
|
||||
recommendation=f"Keep meta descriptions between {DESC_MIN} and {DESC_MAX} characters.",
|
||||
warn=True,
|
||||
url=page.url,
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
@page_check
|
||||
def lang(page: PageContext, site: SiteContext) -> Check:
|
||||
value = (page.dom.get("htmlLang", "") or "").strip()
|
||||
return ok_check(
|
||||
"meta.html_lang",
|
||||
CATEGORY,
|
||||
"Document language",
|
||||
bool(value),
|
||||
severity=LOW,
|
||||
value=value or "missing",
|
||||
recommendation="Declare the page language with <html lang=...>.",
|
||||
warn=True,
|
||||
url=page.url,
|
||||
)
|
||||
|
||||
|
||||
@page_check
|
||||
def charset(page: PageContext, site: SiteContext) -> Check:
|
||||
value = (page.dom.get("charset", "") or "").strip()
|
||||
return ok_check(
|
||||
"meta.charset",
|
||||
CATEGORY,
|
||||
"Character encoding",
|
||||
bool(value),
|
||||
severity=LOW,
|
||||
value=value or "missing",
|
||||
recommendation="Declare a <meta charset> (UTF-8 recommended).",
|
||||
warn=True,
|
||||
url=page.url,
|
||||
)
|
||||
|
||||
|
||||
@page_check
|
||||
def viewport(page: PageContext, site: SiteContext) -> Check:
|
||||
present = bool(page.dom.get("hasViewport"))
|
||||
return ok_check(
|
||||
"meta.viewport",
|
||||
CATEGORY,
|
||||
"Mobile viewport",
|
||||
present,
|
||||
severity=HIGH,
|
||||
value=page.dom.get("viewportContent", "") or ("present" if present else "missing"),
|
||||
recommendation="Add <meta name=viewport content='width=device-width, initial-scale=1'>.",
|
||||
url=page.url,
|
||||
)
|
||||
|
||||
|
||||
@page_check
|
||||
def favicon(page: PageContext, site: SiteContext) -> Check:
|
||||
present = bool(page.dom.get("favicon"))
|
||||
return ok_check(
|
||||
"meta.favicon",
|
||||
CATEGORY,
|
||||
"Favicon",
|
||||
present,
|
||||
severity=LOW,
|
||||
value="present" if present else "missing",
|
||||
recommendation="Provide a favicon for brand recognition in results and tabs.",
|
||||
warn=True,
|
||||
url=page.url,
|
||||
)
|
||||
@@ -0,0 +1,103 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import (
|
||||
HIGH,
|
||||
MEDIUM,
|
||||
LOW,
|
||||
PageContext,
|
||||
SiteContext,
|
||||
ok_check,
|
||||
page_check,
|
||||
)
|
||||
|
||||
CATEGORY = "mobile_accessibility"
|
||||
|
||||
|
||||
@page_check
|
||||
def responsive(page: PageContext, site: SiteContext) -> PageContext:
|
||||
mobile = page.mobile or {}
|
||||
overflow = bool(mobile.get("hasHorizontalOverflow"))
|
||||
return ok_check(
|
||||
"mobile.no_overflow",
|
||||
CATEGORY,
|
||||
"No horizontal overflow (mobile)",
|
||||
not overflow,
|
||||
severity=MEDIUM,
|
||||
value="ok" if not overflow else f"scrollWidth {mobile.get('scrollWidth', '?')}px",
|
||||
recommendation="Eliminate horizontal scrolling at a 390px mobile viewport.",
|
||||
warn=True,
|
||||
url=page.url,
|
||||
)
|
||||
|
||||
|
||||
@page_check
|
||||
def tap_targets(page: PageContext, site: SiteContext):
|
||||
mobile = page.mobile or {}
|
||||
small = int(mobile.get("smallTapTargets", 0) or 0)
|
||||
if "smallTapTargets" not in mobile:
|
||||
return None
|
||||
return ok_check(
|
||||
"mobile.tap_targets",
|
||||
CATEGORY,
|
||||
"Tap target sizing",
|
||||
small == 0,
|
||||
severity=LOW,
|
||||
value="ok" if small == 0 else f"{small} small target(s)",
|
||||
recommendation="Make interactive targets at least 24x24 px (48 px recommended).",
|
||||
warn=True,
|
||||
url=page.url,
|
||||
)
|
||||
|
||||
|
||||
@page_check
|
||||
def image_alt(page: PageContext, site: SiteContext):
|
||||
images = page.dom.get("images", []) or []
|
||||
if not images:
|
||||
return None
|
||||
missing = [img for img in images if not (img.get("alt", "") or "").strip()]
|
||||
return ok_check(
|
||||
"a11y.image_alt",
|
||||
CATEGORY,
|
||||
"Image alt coverage",
|
||||
not missing,
|
||||
severity=MEDIUM,
|
||||
value="complete" if not missing else f"{len(missing)}/{len(images)} missing alt",
|
||||
recommendation="Add descriptive alt text to every meaningful image.",
|
||||
warn=True,
|
||||
url=page.url,
|
||||
)
|
||||
|
||||
|
||||
@page_check
|
||||
def form_labels(page: PageContext, site: SiteContext):
|
||||
missing = int(page.dom.get("formsMissingLabels", 0) or 0)
|
||||
if not page.dom.get("formFieldCount"):
|
||||
return None
|
||||
return ok_check(
|
||||
"a11y.form_labels",
|
||||
CATEGORY,
|
||||
"Form fields labelled",
|
||||
missing == 0,
|
||||
severity=LOW,
|
||||
value="ok" if missing == 0 else f"{missing} unlabelled field(s)",
|
||||
recommendation="Associate a <label> or aria-label with every form control.",
|
||||
warn=True,
|
||||
url=page.url,
|
||||
)
|
||||
|
||||
|
||||
@page_check
|
||||
def document_title(page: PageContext, site: SiteContext):
|
||||
return ok_check(
|
||||
"a11y.document_title",
|
||||
CATEGORY,
|
||||
"Accessible document title",
|
||||
bool((page.dom.get("title", "") or "").strip()),
|
||||
severity=LOW,
|
||||
value="present" if (page.dom.get("title", "") or "").strip() else "missing",
|
||||
recommendation="Every page needs a non-empty <title> for assistive tech.",
|
||||
warn=True,
|
||||
url=page.url,
|
||||
)
|
||||
@@ -0,0 +1,283 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import (
|
||||
HIGH,
|
||||
MEDIUM,
|
||||
LOW,
|
||||
INFO,
|
||||
Check,
|
||||
PageContext,
|
||||
SiteContext,
|
||||
ok_check,
|
||||
page_check,
|
||||
)
|
||||
|
||||
CATEGORY = "performance"
|
||||
|
||||
LCP_GOOD_MS = 2500
|
||||
LCP_POOR_MS = 4000
|
||||
CLS_GOOD = 0.1
|
||||
CLS_POOR = 0.25
|
||||
FCP_GOOD_MS = 1800
|
||||
TTFB_GOOD_MS = 800
|
||||
PAGE_WEIGHT_BUDGET = 3_000_000
|
||||
REQUEST_BUDGET = 80
|
||||
DOM_NODE_BUDGET = 1500
|
||||
MODERN_FORMATS = (".webp", ".avif")
|
||||
|
||||
|
||||
def _ms(metrics: dict, key: str) -> float:
|
||||
try:
|
||||
return float(metrics.get(key) or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
@page_check
|
||||
def lcp(page: PageContext, site: SiteContext) -> Check:
|
||||
value = _ms(page.metrics, "lcp")
|
||||
if value <= 0:
|
||||
return None
|
||||
good = value <= LCP_GOOD_MS
|
||||
return ok_check(
|
||||
"perf.lcp",
|
||||
CATEGORY,
|
||||
"Largest Contentful Paint",
|
||||
good,
|
||||
severity=HIGH,
|
||||
value=f"{value / 1000:.2f}s",
|
||||
recommendation=f"Reduce LCP below {LCP_GOOD_MS / 1000:.1f}s (optimise hero image/render path).",
|
||||
warn=value <= LCP_POOR_MS,
|
||||
url=page.url,
|
||||
)
|
||||
|
||||
|
||||
@page_check
|
||||
def cls(page: PageContext, site: SiteContext) -> Check:
|
||||
value = _ms(page.metrics, "cls")
|
||||
good = value <= CLS_GOOD
|
||||
return ok_check(
|
||||
"perf.cls",
|
||||
CATEGORY,
|
||||
"Cumulative Layout Shift",
|
||||
good,
|
||||
severity=HIGH,
|
||||
value=f"{value:.3f}",
|
||||
recommendation=f"Keep CLS under {CLS_GOOD} by reserving space for media and ads.",
|
||||
warn=value <= CLS_POOR,
|
||||
url=page.url,
|
||||
)
|
||||
|
||||
|
||||
@page_check
|
||||
def fcp(page: PageContext, site: SiteContext) -> Check:
|
||||
value = _ms(page.metrics, "fcp")
|
||||
if value <= 0:
|
||||
return None
|
||||
return ok_check(
|
||||
"perf.fcp",
|
||||
CATEGORY,
|
||||
"First Contentful Paint",
|
||||
value <= FCP_GOOD_MS,
|
||||
severity=LOW,
|
||||
value=f"{value / 1000:.2f}s",
|
||||
recommendation=f"Reduce FCP below {FCP_GOOD_MS / 1000:.1f}s.",
|
||||
warn=True,
|
||||
url=page.url,
|
||||
)
|
||||
|
||||
|
||||
@page_check
|
||||
def ttfb(page: PageContext, site: SiteContext) -> Check:
|
||||
value = _ms(page.metrics, "ttfb")
|
||||
if value <= 0:
|
||||
return None
|
||||
return ok_check(
|
||||
"perf.ttfb",
|
||||
CATEGORY,
|
||||
"Time To First Byte",
|
||||
value <= TTFB_GOOD_MS,
|
||||
severity=MEDIUM,
|
||||
value=f"{value:.0f}ms",
|
||||
recommendation=f"Reduce server response time below {TTFB_GOOD_MS}ms.",
|
||||
warn=True,
|
||||
url=page.url,
|
||||
)
|
||||
|
||||
|
||||
@page_check
|
||||
def page_weight(page: PageContext, site: SiteContext) -> Check:
|
||||
total = int(page.metrics.get("transferSize") or 0)
|
||||
if total <= 0:
|
||||
return None
|
||||
return ok_check(
|
||||
"perf.page_weight",
|
||||
CATEGORY,
|
||||
"Total page weight",
|
||||
total <= PAGE_WEIGHT_BUDGET,
|
||||
severity=MEDIUM,
|
||||
value=f"{total / 1_000_000:.2f} MB",
|
||||
recommendation=f"Trim total transfer below {PAGE_WEIGHT_BUDGET / 1_000_000:.0f} MB.",
|
||||
warn=True,
|
||||
url=page.url,
|
||||
)
|
||||
|
||||
|
||||
@page_check
|
||||
def request_count(page: PageContext, site: SiteContext) -> Check:
|
||||
count = int(page.metrics.get("resourceCount") or 0)
|
||||
if count <= 0:
|
||||
return None
|
||||
return ok_check(
|
||||
"perf.requests",
|
||||
CATEGORY,
|
||||
"Request count",
|
||||
count <= REQUEST_BUDGET,
|
||||
severity=LOW,
|
||||
value=str(count),
|
||||
recommendation=f"Reduce HTTP requests below {REQUEST_BUDGET} (bundle, sprite, lazy-load).",
|
||||
warn=True,
|
||||
url=page.url,
|
||||
)
|
||||
|
||||
|
||||
@page_check
|
||||
def dom_size(page: PageContext, site: SiteContext) -> Check:
|
||||
nodes = int(page.metrics.get("domNodes") or 0)
|
||||
if nodes <= 0:
|
||||
return None
|
||||
return ok_check(
|
||||
"perf.dom_size",
|
||||
CATEGORY,
|
||||
"DOM size",
|
||||
nodes <= DOM_NODE_BUDGET,
|
||||
severity=LOW,
|
||||
value=f"{nodes} nodes",
|
||||
recommendation=f"Keep the DOM under {DOM_NODE_BUDGET} nodes for faster rendering.",
|
||||
warn=True,
|
||||
url=page.url,
|
||||
)
|
||||
|
||||
|
||||
@page_check
|
||||
def compression(page: PageContext, site: SiteContext) -> Check:
|
||||
encoding = str(page.headers.get("content-encoding", "")).lower()
|
||||
passed = any(token in encoding for token in ("gzip", "br", "zstd", "deflate"))
|
||||
return ok_check(
|
||||
"perf.compression",
|
||||
CATEGORY,
|
||||
"Text compression",
|
||||
passed,
|
||||
severity=MEDIUM,
|
||||
value=encoding or "none",
|
||||
recommendation="Enable gzip or brotli compression on text responses.",
|
||||
warn=True,
|
||||
url=page.url,
|
||||
)
|
||||
|
||||
|
||||
@page_check
|
||||
def caching(page: PageContext, site: SiteContext) -> Check:
|
||||
cache = str(page.headers.get("cache-control", "")).lower()
|
||||
passed = bool(cache) and "no-store" not in cache
|
||||
return ok_check(
|
||||
"perf.caching",
|
||||
CATEGORY,
|
||||
"Cache headers",
|
||||
passed,
|
||||
severity=LOW,
|
||||
value=cache or "none",
|
||||
recommendation="Set Cache-Control with sensible max-age for static assets.",
|
||||
warn=True,
|
||||
url=page.url,
|
||||
)
|
||||
|
||||
|
||||
@page_check
|
||||
def http_version(page: PageContext, site: SiteContext) -> Check:
|
||||
version = str(page.metrics.get("protocol", "") or "")
|
||||
if not version:
|
||||
return None
|
||||
modern = any(token in version.lower() for token in ("h2", "h3", "http/2", "http/3"))
|
||||
return ok_check(
|
||||
"perf.http_version",
|
||||
CATEGORY,
|
||||
"Modern HTTP protocol",
|
||||
modern,
|
||||
severity=LOW,
|
||||
value=version,
|
||||
recommendation="Serve over HTTP/2 or HTTP/3 for multiplexed delivery.",
|
||||
warn=True,
|
||||
url=page.url,
|
||||
)
|
||||
|
||||
|
||||
@page_check
|
||||
def console_errors(page: PageContext, site: SiteContext) -> Check:
|
||||
errors = int(page.metrics.get("consoleErrors") or 0)
|
||||
return ok_check(
|
||||
"perf.console_errors",
|
||||
CATEGORY,
|
||||
"No console errors",
|
||||
errors == 0,
|
||||
severity=LOW,
|
||||
value="clean" if errors == 0 else f"{errors} error(s)",
|
||||
recommendation="Resolve JavaScript console errors emitted on load.",
|
||||
warn=True,
|
||||
details={"samples": page.metrics.get("consoleErrorSamples", [])[:5]},
|
||||
url=page.url,
|
||||
)
|
||||
|
||||
|
||||
@page_check
|
||||
def image_optimization(page: PageContext, site: SiteContext) -> list:
|
||||
images = page.dom.get("images", []) or []
|
||||
if not images:
|
||||
return []
|
||||
no_dimensions = [
|
||||
img for img in images if not img.get("width") or not img.get("height")
|
||||
]
|
||||
legacy_format = [
|
||||
img
|
||||
for img in images
|
||||
if (img.get("src", "") or "").lower().rsplit("?", 1)[0].endswith((".jpg", ".jpeg", ".png"))
|
||||
]
|
||||
not_lazy = [img for img in images if (img.get("loading", "") or "") != "lazy"]
|
||||
results = [
|
||||
ok_check(
|
||||
"perf.img_dimensions",
|
||||
CATEGORY,
|
||||
"Images have dimensions",
|
||||
not no_dimensions,
|
||||
severity=MEDIUM,
|
||||
value="ok" if not no_dimensions else f"{len(no_dimensions)} without width/height",
|
||||
recommendation="Set width and height on images to prevent layout shift (CLS).",
|
||||
warn=True,
|
||||
url=page.url,
|
||||
),
|
||||
ok_check(
|
||||
"perf.img_modern_format",
|
||||
CATEGORY,
|
||||
"Modern image formats",
|
||||
not legacy_format,
|
||||
severity=LOW,
|
||||
value="ok" if not legacy_format else f"{len(legacy_format)} legacy image(s)",
|
||||
recommendation="Serve WebP or AVIF instead of JPEG/PNG where possible.",
|
||||
warn=True,
|
||||
url=page.url,
|
||||
),
|
||||
ok_check(
|
||||
"perf.img_lazy",
|
||||
CATEGORY,
|
||||
"Off-screen images lazy-loaded",
|
||||
len(not_lazy) <= 1,
|
||||
severity=LOW,
|
||||
value="ok" if len(not_lazy) <= 1 else f"{len(not_lazy)} eager image(s)",
|
||||
recommendation="Add loading=lazy to below-the-fold images.",
|
||||
warn=True,
|
||||
url=page.url,
|
||||
),
|
||||
]
|
||||
return results
|
||||
@@ -0,0 +1,131 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import (
|
||||
PAGE_CHECKS,
|
||||
SITE_CHECKS,
|
||||
SEVERITY_WEIGHT,
|
||||
STATUS_CREDIT,
|
||||
INFO,
|
||||
SKIP,
|
||||
Check,
|
||||
PageContext,
|
||||
SiteContext,
|
||||
)
|
||||
from . import crawl # noqa: F401
|
||||
from . import meta # noqa: F401
|
||||
from . import headings # noqa: F401
|
||||
from . import links # noqa: F401
|
||||
from . import structured_data # noqa: F401
|
||||
from . import social # noqa: F401
|
||||
from . import performance # noqa: F401
|
||||
from . import mobile_a11y # noqa: F401
|
||||
from . import security # noqa: F401
|
||||
from . import ai_readiness # noqa: F401
|
||||
from . import crosspage # noqa: F401
|
||||
|
||||
|
||||
def _collect(result) -> list[Check]:
|
||||
if result is None:
|
||||
return []
|
||||
if isinstance(result, Check):
|
||||
return [result]
|
||||
return [c for c in result if isinstance(c, Check)]
|
||||
|
||||
|
||||
def run_page_checks(page: PageContext, site: SiteContext) -> list[Check]:
|
||||
checks: list[Check] = []
|
||||
for func in PAGE_CHECKS:
|
||||
try:
|
||||
checks.extend(_collect(func(page, site)))
|
||||
except Exception as exc: # noqa: BLE001 - one bad check never aborts the page
|
||||
checks.append(
|
||||
Check(
|
||||
id=f"{func.__name__}.error",
|
||||
category="internal",
|
||||
title=f"Check {func.__name__} failed",
|
||||
status=INFO,
|
||||
severity="info",
|
||||
value=str(exc)[:200],
|
||||
url=page.url,
|
||||
)
|
||||
)
|
||||
return checks
|
||||
|
||||
|
||||
def run_site_checks(site: SiteContext) -> list[Check]:
|
||||
checks: list[Check] = []
|
||||
for func in SITE_CHECKS:
|
||||
try:
|
||||
checks.extend(_collect(func(site)))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
checks.append(
|
||||
Check(
|
||||
id=f"{func.__name__}.error",
|
||||
category="internal",
|
||||
title=f"Site check {func.__name__} failed",
|
||||
status=INFO,
|
||||
severity="info",
|
||||
value=str(exc)[:200],
|
||||
)
|
||||
)
|
||||
return checks
|
||||
|
||||
|
||||
def compute_score(checks: list[Check]) -> dict:
|
||||
by_category: dict[str, dict] = {}
|
||||
earned = 0.0
|
||||
weight = 0.0
|
||||
counts = {"pass": 0, "warn": 0, "fail": 0, "info": 0, "skip": 0}
|
||||
for check in checks:
|
||||
counts[check.status] = counts.get(check.status, 0) + 1
|
||||
bucket = by_category.setdefault(
|
||||
check.category,
|
||||
{"earned": 0.0, "weight": 0.0, "pass": 0, "warn": 0, "fail": 0, "info": 0, "skip": 0},
|
||||
)
|
||||
bucket[check.status] = bucket.get(check.status, 0) + 1
|
||||
if check.status in (INFO, SKIP):
|
||||
continue
|
||||
w = SEVERITY_WEIGHT.get(check.severity, 1.0)
|
||||
if w <= 0:
|
||||
continue
|
||||
credit = STATUS_CREDIT.get(check.status, 0.0)
|
||||
earned += w * credit
|
||||
weight += w
|
||||
bucket["earned"] += w * credit
|
||||
bucket["weight"] += w
|
||||
categories = {}
|
||||
for name, bucket in by_category.items():
|
||||
cat_score = (
|
||||
round(100 * bucket["earned"] / bucket["weight"])
|
||||
if bucket["weight"] > 0
|
||||
else None
|
||||
)
|
||||
categories[name] = {
|
||||
"score": cat_score,
|
||||
"pass": bucket["pass"],
|
||||
"warn": bucket["warn"],
|
||||
"fail": bucket["fail"],
|
||||
"info": bucket["info"],
|
||||
"skip": bucket["skip"],
|
||||
}
|
||||
score = round(100 * earned / weight) if weight > 0 else 0
|
||||
return {
|
||||
"score": score,
|
||||
"grade": _grade(score),
|
||||
"counts": counts,
|
||||
"categories": categories,
|
||||
}
|
||||
|
||||
|
||||
def _grade(score: int) -> str:
|
||||
if score >= 90:
|
||||
return "A"
|
||||
if score >= 80:
|
||||
return "B"
|
||||
if score >= 70:
|
||||
return "C"
|
||||
if score >= 55:
|
||||
return "D"
|
||||
return "F"
|
||||
@@ -0,0 +1,59 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import (
|
||||
MEDIUM,
|
||||
LOW,
|
||||
PageContext,
|
||||
SiteContext,
|
||||
ok_check,
|
||||
page_check,
|
||||
)
|
||||
|
||||
CATEGORY = "security"
|
||||
|
||||
SECURITY_HEADERS = {
|
||||
"content-security-policy": ("Content-Security-Policy", LOW),
|
||||
"x-content-type-options": ("X-Content-Type-Options", LOW),
|
||||
"x-frame-options": ("X-Frame-Options", LOW),
|
||||
"referrer-policy": ("Referrer-Policy", LOW),
|
||||
}
|
||||
|
||||
|
||||
@page_check
|
||||
def security_headers(page: PageContext, site: SiteContext) -> list:
|
||||
results = []
|
||||
for key, (label, severity) in SECURITY_HEADERS.items():
|
||||
present = bool(page.headers.get(key))
|
||||
results.append(
|
||||
ok_check(
|
||||
f"security.{key}",
|
||||
CATEGORY,
|
||||
label,
|
||||
present,
|
||||
severity=severity,
|
||||
value="present" if present else "missing",
|
||||
recommendation=f"Add the {label} response header.",
|
||||
warn=True,
|
||||
url=page.url,
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
@page_check
|
||||
def tls_valid(page: PageContext, site: SiteContext):
|
||||
if "tlsValid" not in page.metrics:
|
||||
return None
|
||||
valid = bool(page.metrics.get("tlsValid"))
|
||||
return ok_check(
|
||||
"security.tls",
|
||||
CATEGORY,
|
||||
"Valid TLS certificate",
|
||||
valid,
|
||||
severity=MEDIUM,
|
||||
value="valid" if valid else "invalid",
|
||||
recommendation="Serve a valid, unexpired TLS certificate.",
|
||||
url=page.url,
|
||||
)
|
||||
@@ -0,0 +1,59 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import (
|
||||
MEDIUM,
|
||||
LOW,
|
||||
PageContext,
|
||||
SiteContext,
|
||||
ok_check,
|
||||
page_check,
|
||||
)
|
||||
|
||||
CATEGORY = "social"
|
||||
|
||||
OG_REQUIRED = ("og:title", "og:description", "og:image", "og:url")
|
||||
TWITTER_REQUIRED = ("twitter:card",)
|
||||
|
||||
|
||||
@page_check
|
||||
def open_graph(page: PageContext, site: SiteContext) -> list:
|
||||
og = page.dom.get("og", {}) or {}
|
||||
missing = [tag for tag in OG_REQUIRED if not og.get(tag)]
|
||||
results = [
|
||||
ok_check(
|
||||
"social.open_graph",
|
||||
CATEGORY,
|
||||
"Open Graph tags",
|
||||
not missing,
|
||||
severity=MEDIUM,
|
||||
value="complete" if not missing else f"missing {', '.join(missing)}",
|
||||
recommendation="Add og:title, og:description, og:image and og:url for rich social previews.",
|
||||
warn=True,
|
||||
details={"present": sorted(og.keys())},
|
||||
url=page.url,
|
||||
)
|
||||
]
|
||||
return results
|
||||
|
||||
|
||||
@page_check
|
||||
def twitter_card(page: PageContext, site: SiteContext) -> list:
|
||||
twitter = page.dom.get("twitter", {}) or {}
|
||||
og = page.dom.get("og", {}) or {}
|
||||
has_card = bool(twitter.get("twitter:card"))
|
||||
return [
|
||||
ok_check(
|
||||
"social.twitter_card",
|
||||
CATEGORY,
|
||||
"Twitter Card",
|
||||
has_card or bool(og),
|
||||
severity=LOW,
|
||||
value=twitter.get("twitter:card", "") or ("falls back to OG" if og else "missing"),
|
||||
recommendation="Add a twitter:card meta tag (summary_large_image) for X/Twitter previews.",
|
||||
warn=True,
|
||||
details={"present": sorted(twitter.keys())},
|
||||
url=page.url,
|
||||
)
|
||||
]
|
||||
@@ -0,0 +1,165 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from .base import (
|
||||
MEDIUM,
|
||||
LOW,
|
||||
INFO,
|
||||
PASS,
|
||||
WARN,
|
||||
FAIL,
|
||||
Check,
|
||||
PageContext,
|
||||
SiteContext,
|
||||
ok_check,
|
||||
page_check,
|
||||
)
|
||||
|
||||
CATEGORY = "structured_data"
|
||||
|
||||
REQUIRED_PROPS = {
|
||||
"Article": ("headline",),
|
||||
"BlogPosting": ("headline",),
|
||||
"NewsArticle": ("headline",),
|
||||
"Product": ("name",),
|
||||
"Offer": ("price", "priceCurrency"),
|
||||
"Organization": ("name", "url"),
|
||||
"LocalBusiness": ("name", "address"),
|
||||
"BreadcrumbList": ("itemListElement",),
|
||||
"FAQPage": ("mainEntity",),
|
||||
"WebSite": ("name", "url"),
|
||||
"Person": ("name",),
|
||||
"Recipe": ("name", "recipeIngredient"),
|
||||
"Event": ("name", "startDate"),
|
||||
"VideoObject": ("name", "thumbnailUrl", "uploadDate"),
|
||||
"SoftwareApplication": ("name",),
|
||||
}
|
||||
|
||||
|
||||
def _iter_objects(parsed):
|
||||
if isinstance(parsed, list):
|
||||
for item in parsed:
|
||||
yield from _iter_objects(item)
|
||||
return
|
||||
if not isinstance(parsed, dict):
|
||||
return
|
||||
graph = parsed.get("@graph")
|
||||
if isinstance(graph, list):
|
||||
for item in graph:
|
||||
yield from _iter_objects(item)
|
||||
if parsed.get("@type"):
|
||||
yield parsed
|
||||
|
||||
|
||||
def _types(node) -> list:
|
||||
raw = node.get("@type")
|
||||
if isinstance(raw, list):
|
||||
return [str(item) for item in raw]
|
||||
return [str(raw)] if raw else []
|
||||
|
||||
|
||||
@page_check
|
||||
def jsonld(page: PageContext, site: SiteContext) -> list:
|
||||
blocks = page.dom.get("jsonld", []) or []
|
||||
present = bool(blocks)
|
||||
results = [
|
||||
ok_check(
|
||||
"structured.jsonld_present",
|
||||
CATEGORY,
|
||||
"Structured data (JSON-LD)",
|
||||
present,
|
||||
severity=MEDIUM,
|
||||
value=f"{len(blocks)} block(s)" if present else "none",
|
||||
recommendation="Add JSON-LD structured data so search engines can render rich results.",
|
||||
warn=True,
|
||||
url=page.url,
|
||||
)
|
||||
]
|
||||
if not present:
|
||||
return results
|
||||
|
||||
invalid = 0
|
||||
nodes = []
|
||||
for block in blocks:
|
||||
try:
|
||||
parsed = json.loads(block)
|
||||
except (ValueError, TypeError):
|
||||
invalid += 1
|
||||
continue
|
||||
nodes.extend(_iter_objects(parsed))
|
||||
|
||||
results.append(
|
||||
ok_check(
|
||||
"structured.jsonld_valid",
|
||||
CATEGORY,
|
||||
"JSON-LD parses",
|
||||
invalid == 0,
|
||||
severity=MEDIUM,
|
||||
value="valid" if invalid == 0 else f"{invalid} invalid block(s)",
|
||||
recommendation="Fix JSON syntax errors in structured-data blocks.",
|
||||
url=page.url,
|
||||
)
|
||||
)
|
||||
|
||||
found_types = sorted({t for node in nodes for t in _types(node)})
|
||||
results.append(
|
||||
Check(
|
||||
"structured.types",
|
||||
CATEGORY,
|
||||
"Schema types",
|
||||
INFO,
|
||||
INFO,
|
||||
value=", ".join(found_types) or "none",
|
||||
details={"types": found_types},
|
||||
url=page.url,
|
||||
)
|
||||
)
|
||||
|
||||
missing = []
|
||||
for node in nodes:
|
||||
for type_name in _types(node):
|
||||
required = REQUIRED_PROPS.get(type_name)
|
||||
if not required:
|
||||
continue
|
||||
absent = [prop for prop in required if not node.get(prop)]
|
||||
if absent:
|
||||
missing.append(f"{type_name}: {', '.join(absent)}")
|
||||
if missing:
|
||||
results.append(
|
||||
Check(
|
||||
"structured.required_props",
|
||||
CATEGORY,
|
||||
"Required schema properties",
|
||||
WARN,
|
||||
LOW,
|
||||
value=f"{len(missing)} type(s) missing props",
|
||||
recommendation="Add the required properties for each declared schema type.",
|
||||
details={"missing": missing[:20]},
|
||||
url=page.url,
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
@page_check
|
||||
def other_markup(page: PageContext, site: SiteContext) -> Check:
|
||||
microdata = bool(page.dom.get("microdata"))
|
||||
rdfa = bool(page.dom.get("rdfa"))
|
||||
formats = []
|
||||
if microdata:
|
||||
formats.append("microdata")
|
||||
if rdfa:
|
||||
formats.append("RDFa")
|
||||
return Check(
|
||||
"structured.other_markup",
|
||||
CATEGORY,
|
||||
"Other structured-data formats",
|
||||
INFO,
|
||||
INFO,
|
||||
value=", ".join(formats) or "none",
|
||||
details={"microdata": microdata, "rdfa": rdfa},
|
||||
url=page.url,
|
||||
)
|
||||
@@ -0,0 +1,422 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from urllib.parse import urljoin, urlparse
|
||||
from xml.etree import ElementTree
|
||||
|
||||
import httpx
|
||||
|
||||
from devplacepy.net_guard import BlockedAddressError, guard_public_url
|
||||
from .checks.base import PageContext, SiteContext
|
||||
|
||||
USER_AGENT = (
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/131.0.0.0 Safari/537.36 DevPlaceSEOBot/1.0"
|
||||
)
|
||||
NAV_TIMEOUT_MS = 30000
|
||||
RAW_FETCH_TIMEOUT = 15.0
|
||||
MAX_RAW_BYTES = 3_000_000
|
||||
MOBILE_VIEWPORT = {"width": 390, "height": 844}
|
||||
DESKTOP_VIEWPORT = {"width": 1366, "height": 900}
|
||||
|
||||
INIT_SCRIPT = """
|
||||
window.__seo = { lcp: 0, cls: 0, consoleErrors: 0, errorSamples: [] };
|
||||
try {
|
||||
new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
window.__seo.lcp = Math.max(window.__seo.lcp, entry.startTime || entry.renderTime || 0);
|
||||
}
|
||||
}).observe({ type: 'largest-contentful-paint', buffered: true });
|
||||
} catch (e) {}
|
||||
try {
|
||||
new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
if (!entry.hadRecentInput) window.__seo.cls += entry.value;
|
||||
}
|
||||
}).observe({ type: 'layout-shift', buffered: true });
|
||||
} catch (e) {}
|
||||
"""
|
||||
|
||||
EXTRACT_SCRIPT = r"""
|
||||
() => {
|
||||
const abs = (href) => { try { return new URL(href, document.baseURI).href; } catch (e) { return href || ''; } };
|
||||
const metaByName = (name) => {
|
||||
const el = document.querySelector(`meta[name="${name}"]`);
|
||||
return el ? (el.getAttribute('content') || '') : '';
|
||||
};
|
||||
const og = {};
|
||||
document.querySelectorAll('meta[property^="og:"]').forEach((m) => {
|
||||
og[m.getAttribute('property')] = m.getAttribute('content') || '';
|
||||
});
|
||||
const twitter = {};
|
||||
document.querySelectorAll('meta[name^="twitter:"]').forEach((m) => {
|
||||
twitter[m.getAttribute('name')] = m.getAttribute('content') || '';
|
||||
});
|
||||
const headings = [];
|
||||
document.querySelectorAll('h1,h2,h3,h4,h5,h6').forEach((h) => {
|
||||
headings.push({ level: parseInt(h.tagName.substring(1), 10), text: (h.textContent || '').trim().slice(0, 160) });
|
||||
});
|
||||
const h1 = Array.from(document.querySelectorAll('h1')).map((h) => (h.textContent || '').trim().slice(0, 160));
|
||||
const images = Array.from(document.querySelectorAll('img')).slice(0, 200).map((img) => ({
|
||||
src: abs(img.getAttribute('src') || ''),
|
||||
alt: img.getAttribute('alt'),
|
||||
width: img.getAttribute('width') || (img.naturalWidth ? String(img.naturalWidth) : ''),
|
||||
height: img.getAttribute('height') || (img.naturalHeight ? String(img.naturalHeight) : ''),
|
||||
loading: img.getAttribute('loading') || ''
|
||||
}));
|
||||
const links = Array.from(document.querySelectorAll('a[href]')).slice(0, 500).map((a) => ({
|
||||
href: abs(a.getAttribute('href') || ''),
|
||||
rel: a.getAttribute('rel') || '',
|
||||
text: (a.textContent || '').trim().slice(0, 120)
|
||||
}));
|
||||
const jsonld = Array.from(document.querySelectorAll('script[type="application/ld+json"]')).map((s) => s.textContent || '');
|
||||
const hreflang = Array.from(document.querySelectorAll('link[rel="alternate"][hreflang]')).map((l) => ({
|
||||
hreflang: l.getAttribute('hreflang'), href: abs(l.getAttribute('href') || '')
|
||||
}));
|
||||
const canonicalEls = document.querySelectorAll('link[rel="canonical"]');
|
||||
const isHttps = location.protocol === 'https:';
|
||||
const mixed = [];
|
||||
if (isHttps) {
|
||||
document.querySelectorAll('[src],[href]').forEach((el) => {
|
||||
const v = el.getAttribute('src') || el.getAttribute('href') || '';
|
||||
if (v.startsWith('http://')) mixed.push(v);
|
||||
});
|
||||
}
|
||||
const viewportEl = document.querySelector('meta[name="viewport"]');
|
||||
const charsetEl = document.querySelector('meta[charset]');
|
||||
let formFieldCount = 0;
|
||||
let formsMissingLabels = 0;
|
||||
document.querySelectorAll('input,select,textarea').forEach((field) => {
|
||||
const type = (field.getAttribute('type') || '').toLowerCase();
|
||||
if (type === 'hidden' || type === 'submit' || type === 'button') return;
|
||||
formFieldCount += 1;
|
||||
const id = field.getAttribute('id');
|
||||
const labelled = (id && document.querySelector(`label[for="${id}"]`)) ||
|
||||
field.getAttribute('aria-label') || field.getAttribute('aria-labelledby') ||
|
||||
field.closest('label');
|
||||
if (!labelled) formsMissingLabels += 1;
|
||||
});
|
||||
const nav = performance.getEntriesByType('navigation')[0] || {};
|
||||
const resources = performance.getEntriesByType('resource') || [];
|
||||
let transfer = nav.transferSize || 0;
|
||||
resources.forEach((r) => { transfer += (r.transferSize || 0); });
|
||||
const bodyText = (document.body ? document.body.innerText || '' : '');
|
||||
const wordCount = bodyText.split(/\s+/).filter(Boolean).length;
|
||||
return {
|
||||
title: (document.title || '').trim(),
|
||||
titleCount: document.querySelectorAll('title').length,
|
||||
metaDescription: metaByName('description'),
|
||||
metaRobots: metaByName('robots'),
|
||||
canonical: canonicalEls.length ? abs(canonicalEls[0].getAttribute('href') || '') : '',
|
||||
canonicalCount: canonicalEls.length,
|
||||
htmlLang: document.documentElement.getAttribute('lang') || '',
|
||||
charset: charsetEl ? (charsetEl.getAttribute('charset') || '') : (document.characterSet || ''),
|
||||
hasViewport: !!viewportEl,
|
||||
viewportContent: viewportEl ? (viewportEl.getAttribute('content') || '') : '',
|
||||
favicon: !!document.querySelector('link[rel~="icon"]'),
|
||||
h1: h1,
|
||||
headings: headings,
|
||||
images: images,
|
||||
links: links,
|
||||
jsonld: jsonld,
|
||||
hreflang: hreflang,
|
||||
microdata: !!document.querySelector('[itemscope]'),
|
||||
rdfa: !!document.querySelector('[vocab],[typeof],[property]'),
|
||||
og: og,
|
||||
twitter: twitter,
|
||||
iframeCount: document.querySelectorAll('iframe').length,
|
||||
semantic: {
|
||||
main: document.querySelectorAll('main').length,
|
||||
article: document.querySelectorAll('article').length,
|
||||
nav: document.querySelectorAll('nav').length,
|
||||
header: document.querySelectorAll('header').length,
|
||||
footer: document.querySelectorAll('footer').length
|
||||
},
|
||||
mixedContent: mixed,
|
||||
formFieldCount: formFieldCount,
|
||||
formsMissingLabels: formsMissingLabels,
|
||||
wordCount: wordCount,
|
||||
metrics: {
|
||||
ttfb: Math.max(0, (nav.responseStart || 0) - (nav.requestStart || 0)),
|
||||
fcp: (performance.getEntriesByName('first-contentful-paint')[0] || {}).startTime || 0,
|
||||
domContentLoaded: nav.domContentLoadedEventEnd || 0,
|
||||
load: nav.loadEventEnd || 0,
|
||||
transferSize: transfer,
|
||||
resourceCount: resources.length,
|
||||
domNodes: document.getElementsByTagName('*').length,
|
||||
protocol: nav.nextHopProtocol || '',
|
||||
lcp: window.__seo ? window.__seo.lcp : 0,
|
||||
cls: window.__seo ? window.__seo.cls : 0
|
||||
}
|
||||
};
|
||||
}
|
||||
"""
|
||||
|
||||
MOBILE_SCRIPT = r"""
|
||||
() => {
|
||||
let small = 0;
|
||||
document.querySelectorAll('a,button,[role="button"],input[type="submit"]').forEach((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width > 0 && r.height > 0 && (r.width < 24 || r.height < 24)) small += 1;
|
||||
});
|
||||
return {
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
hasHorizontalOverflow: document.documentElement.scrollWidth > document.documentElement.clientWidth + 4,
|
||||
smallTapTargets: small
|
||||
};
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def _normalize_url(url: str) -> str:
|
||||
url = (url or "").strip()
|
||||
if not url:
|
||||
return ""
|
||||
if "://" not in url:
|
||||
url = "https://" + url
|
||||
return url
|
||||
|
||||
|
||||
async def _fetch_text(client: httpx.AsyncClient, url: str) -> dict:
|
||||
try:
|
||||
response = await client.get(url)
|
||||
body = response.text[:MAX_RAW_BYTES]
|
||||
return {"status": response.status_code, "text": body, "url": str(response.url)}
|
||||
except httpx.HTTPError as exc:
|
||||
return {"status": 0, "text": "", "url": url, "error": str(exc)[:200]}
|
||||
|
||||
|
||||
def _parse_robots(text: str, target_path: str) -> dict:
|
||||
disallows = []
|
||||
sitemap_urls = []
|
||||
active = False
|
||||
for raw in text.splitlines():
|
||||
line = raw.split("#", 1)[0].strip()
|
||||
if not line:
|
||||
continue
|
||||
key, _, value = line.partition(":")
|
||||
key = key.strip().lower()
|
||||
value = value.strip()
|
||||
if key == "user-agent":
|
||||
active = value == "*"
|
||||
elif key == "sitemap":
|
||||
sitemap_urls.append(value)
|
||||
elif key == "disallow" and active and value:
|
||||
disallows.append(value)
|
||||
blocks_target = any(
|
||||
target_path.startswith(rule) for rule in disallows if rule and rule != "/"
|
||||
) or "/" in [r for r in disallows]
|
||||
return {
|
||||
"disallows": disallows,
|
||||
"sitemap_urls": sitemap_urls,
|
||||
"blocks_target": blocks_target,
|
||||
}
|
||||
|
||||
|
||||
def _parse_sitemap(text: str) -> dict:
|
||||
result = {"valid_xml": False, "url_count": 0, "lastmod_count": 0, "urls": []}
|
||||
try:
|
||||
root = ElementTree.fromstring(text)
|
||||
except ElementTree.ParseError:
|
||||
return result
|
||||
result["valid_xml"] = True
|
||||
urls = []
|
||||
lastmods = 0
|
||||
for url_el in root.iter():
|
||||
tag = url_el.tag.rsplit("}", 1)[-1]
|
||||
if tag == "loc" and url_el.text:
|
||||
urls.append(url_el.text.strip())
|
||||
elif tag == "lastmod" and url_el.text:
|
||||
lastmods += 1
|
||||
result["url_count"] = len(urls)
|
||||
result["lastmod_count"] = lastmods
|
||||
result["urls"] = urls
|
||||
return result
|
||||
|
||||
|
||||
async def _site_resources(
|
||||
client: httpx.AsyncClient, base_url: str, sitemap_url: str, target_path: str
|
||||
) -> tuple[dict, dict, dict]:
|
||||
parsed = urlparse(base_url)
|
||||
root = f"{parsed.scheme}://{parsed.netloc}"
|
||||
robots_raw = await _fetch_text(client, urljoin(root + "/", "robots.txt"))
|
||||
robots = dict(robots_raw)
|
||||
if robots_raw.get("status") == 200:
|
||||
robots.update(_parse_robots(robots_raw.get("text", ""), target_path))
|
||||
sm_target = sitemap_url or urljoin(root + "/", "sitemap.xml")
|
||||
sitemap_raw = await _fetch_text(client, sm_target)
|
||||
sitemap = dict(sitemap_raw)
|
||||
if sitemap_raw.get("status") == 200:
|
||||
sitemap.update(_parse_sitemap(sitemap_raw.get("text", "")))
|
||||
llms_raw = await _fetch_text(client, urljoin(root + "/", "llms.txt"))
|
||||
llms = {"found": llms_raw.get("status") == 200, "status": llms_raw.get("status")}
|
||||
return robots, sitemap, llms
|
||||
|
||||
|
||||
async def _build_page(context, client, url: str, output_dir, index: int) -> PageContext:
|
||||
page_ctx = PageContext(requested_url=url, url=url)
|
||||
page = await context.new_page()
|
||||
console_errors = []
|
||||
|
||||
def _on_console(message):
|
||||
if message.type == "error":
|
||||
console_errors.append(message.text[:200])
|
||||
|
||||
page.on("console", _on_console)
|
||||
page.on("pageerror", lambda exc: console_errors.append(str(exc)[:200]))
|
||||
await page.add_init_script(INIT_SCRIPT)
|
||||
try:
|
||||
response = await page.goto(url, wait_until="load", timeout=NAV_TIMEOUT_MS)
|
||||
try:
|
||||
await page.wait_for_load_state("networkidle", timeout=8000)
|
||||
except Exception:
|
||||
pass
|
||||
page_ctx.status = response.status if response else 0
|
||||
page_ctx.url = page.url
|
||||
page_ctx.ok = bool(response and response.ok)
|
||||
if response is not None:
|
||||
page_ctx.headers = {k.lower(): v for k, v in response.headers.items()}
|
||||
chain = []
|
||||
req = response.request.redirected_from
|
||||
while req is not None:
|
||||
resp = await req.response()
|
||||
chain.append({"url": req.url, "status": resp.status if resp else 0})
|
||||
req = req.redirected_from
|
||||
page_ctx.redirect_chain = list(reversed(chain))
|
||||
dom = await page.evaluate(EXTRACT_SCRIPT)
|
||||
page_ctx.metrics = dom.pop("metrics", {})
|
||||
page_ctx.metrics["consoleErrors"] = len(console_errors)
|
||||
page_ctx.metrics["consoleErrorSamples"] = console_errors[:5]
|
||||
page_ctx.dom = dom
|
||||
page_ctx.rendered_html = (await page.content())[:MAX_RAW_BYTES]
|
||||
try:
|
||||
await page.set_viewport_size(MOBILE_VIEWPORT)
|
||||
page_ctx.mobile = await page.evaluate(MOBILE_SCRIPT)
|
||||
await page.set_viewport_size(DESKTOP_VIEWPORT)
|
||||
except Exception:
|
||||
page_ctx.mobile = {}
|
||||
if output_dir is not None:
|
||||
shot = output_dir / f"page-{index}.png"
|
||||
try:
|
||||
await page.screenshot(path=str(shot), full_page=False)
|
||||
page_ctx.screenshot = shot.name
|
||||
except Exception:
|
||||
page_ctx.screenshot = ""
|
||||
raw = await _fetch_text(client, page_ctx.url)
|
||||
page_ctx.raw_html = raw.get("text", "")
|
||||
except Exception as exc: # noqa: BLE001 - record the failure as page state
|
||||
page_ctx.error = str(exc)[:300]
|
||||
page_ctx.ok = False
|
||||
finally:
|
||||
await page.close()
|
||||
return page_ctx
|
||||
|
||||
|
||||
async def crawl_target(payload: dict, emit, output_dir) -> SiteContext:
|
||||
target = _normalize_url(payload.get("url", ""))
|
||||
mode = payload.get("mode", "url")
|
||||
allow_private = bool(payload.get("allow_private"))
|
||||
max_pages = max(1, min(int(payload.get("max_pages", 1) or 1), 50))
|
||||
|
||||
await guard_public_url(target, allow_private=allow_private)
|
||||
parsed = urlparse(target)
|
||||
site = SiteContext(
|
||||
target_url=target,
|
||||
mode=mode,
|
||||
base_host=parsed.netloc,
|
||||
base_scheme=parsed.scheme,
|
||||
)
|
||||
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
follow_redirects=True,
|
||||
timeout=RAW_FETCH_TIMEOUT,
|
||||
headers={"User-Agent": USER_AGENT},
|
||||
) as client:
|
||||
emit({"type": "stage", "stage": "resources", "message": "Reading robots.txt and sitemap"})
|
||||
sitemap_hint = target if mode == "sitemap" else ""
|
||||
robots, sitemap, llms = await _site_resources(
|
||||
client, target, sitemap_hint, parsed.path or "/"
|
||||
)
|
||||
site.robots = robots
|
||||
site.sitemap = sitemap
|
||||
site.llms_txt = llms
|
||||
|
||||
if mode == "sitemap":
|
||||
candidates = sitemap.get("urls", [])[:max_pages]
|
||||
else:
|
||||
candidates = [target]
|
||||
|
||||
safe_urls = []
|
||||
for url in candidates:
|
||||
host = urlparse(url).netloc
|
||||
if host and host == site.base_host:
|
||||
# Same host as the audited target, already validated by the
|
||||
# top-level guard above; skip the redundant per-URL DNS lookup.
|
||||
safe_urls.append(url)
|
||||
continue
|
||||
try:
|
||||
await guard_public_url(url, allow_private=allow_private)
|
||||
safe_urls.append(url)
|
||||
except BlockedAddressError:
|
||||
continue
|
||||
|
||||
if not safe_urls:
|
||||
if mode == "sitemap":
|
||||
raise ValueError(
|
||||
"No crawlable page URLs found in the sitemap "
|
||||
f"({sitemap.get('url_count', 0)} entries; none reachable or all blocked)."
|
||||
)
|
||||
safe_urls = [target]
|
||||
|
||||
emit(
|
||||
{
|
||||
"type": "target",
|
||||
"url": target,
|
||||
"mode": mode,
|
||||
"urls": safe_urls,
|
||||
"sitemap_total": sitemap.get("url_count", 0) if mode == "sitemap" else 0,
|
||||
}
|
||||
)
|
||||
|
||||
async with async_playwright() as pw:
|
||||
browser = await pw.chromium.launch(
|
||||
headless=True, args=["--no-sandbox", "--disable-dev-shm-usage"]
|
||||
)
|
||||
context = await browser.new_context(
|
||||
viewport=DESKTOP_VIEWPORT,
|
||||
user_agent=USER_AGENT,
|
||||
ignore_https_errors=False,
|
||||
)
|
||||
try:
|
||||
total = len(safe_urls)
|
||||
for index, url in enumerate(safe_urls):
|
||||
emit(
|
||||
{
|
||||
"type": "progress",
|
||||
"done": index,
|
||||
"total": total,
|
||||
"url": url,
|
||||
"message": f"Auditing {url}",
|
||||
}
|
||||
)
|
||||
page_ctx = await _build_page(
|
||||
context, client, url, output_dir, index
|
||||
)
|
||||
site.pages.append(page_ctx)
|
||||
yield_frame = {
|
||||
"type": "page_loaded",
|
||||
"url": page_ctx.url,
|
||||
"status": page_ctx.status,
|
||||
"done": index + 1,
|
||||
"total": total,
|
||||
}
|
||||
emit(yield_frame)
|
||||
finally:
|
||||
await context.close()
|
||||
await browser.close()
|
||||
return site
|
||||
@@ -0,0 +1,43 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
MAX_BUFFER = 2000
|
||||
|
||||
|
||||
class ProgressHub:
|
||||
def __init__(self) -> None:
|
||||
self._subscribers: dict[str, set[asyncio.Queue]] = {}
|
||||
self._buffers: dict[str, list[dict]] = {}
|
||||
|
||||
def subscribe(self, uid: str) -> asyncio.Queue:
|
||||
queue: asyncio.Queue = asyncio.Queue()
|
||||
self._subscribers.setdefault(uid, set()).add(queue)
|
||||
return queue
|
||||
|
||||
def unsubscribe(self, uid: str, queue: asyncio.Queue) -> None:
|
||||
listeners = self._subscribers.get(uid)
|
||||
if not listeners:
|
||||
return
|
||||
listeners.discard(queue)
|
||||
if not listeners:
|
||||
self._subscribers.pop(uid, None)
|
||||
|
||||
def publish(self, uid: str, frame: dict) -> None:
|
||||
buffer = self._buffers.setdefault(uid, [])
|
||||
buffer.append(frame)
|
||||
if len(buffer) > MAX_BUFFER:
|
||||
del buffer[: len(buffer) - MAX_BUFFER]
|
||||
for queue in self._subscribers.get(uid, set()):
|
||||
queue.put_nowait(frame)
|
||||
|
||||
def snapshot(self, uid: str) -> list[dict]:
|
||||
return list(self._buffers.get(uid, []))
|
||||
|
||||
def clear(self, uid: str) -> None:
|
||||
self._buffers.pop(uid, None)
|
||||
|
||||
|
||||
hub = ProgressHub()
|
||||
@@ -0,0 +1,155 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from devplacepy.config import BASE_DIR, SEO_REPORTS_DIR
|
||||
from devplacepy.services.jobs.base import JobService
|
||||
from .progress import hub
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
WORKER_MODULE = "devplacepy.services.jobs.seo.worker"
|
||||
STREAM_LIMIT = 16 * 1024 * 1024
|
||||
|
||||
|
||||
class SeoService(JobService):
|
||||
kind = "seo"
|
||||
title = "SEO Diagnostics"
|
||||
description = (
|
||||
"Audits any URL or sitemap with a broad battery of technical, on-page, structured-data, "
|
||||
"performance, accessibility and AI-readiness checks using a headless browser, streaming "
|
||||
"live progress over a websocket and storing a categorised report."
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(name="seo", interval_seconds=2)
|
||||
|
||||
def report_dir(self, uid: str) -> Path:
|
||||
return SEO_REPORTS_DIR / uid
|
||||
|
||||
async def process(self, job: dict) -> dict:
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
uid = job["uid"]
|
||||
payload = job.get("payload", {})
|
||||
target = payload.get("url", "")
|
||||
output_dir = self.report_dir(uid)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
payload_path = output_dir / "payload.json"
|
||||
payload_path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
|
||||
actor_kind = "user" if job.get("owner_kind") == "user" else (
|
||||
job.get("owner_kind") or "system"
|
||||
)
|
||||
actor_uid = job.get("owner_id") if job.get("owner_kind") == "user" else None
|
||||
|
||||
try:
|
||||
summary = await self._run_worker(uid, payload_path, output_dir)
|
||||
except Exception as exc:
|
||||
hub.publish(uid, {"type": "failed", "message": str(exc)[:300]})
|
||||
audit.record_system(
|
||||
"seo.run.failed",
|
||||
actor_kind=actor_kind,
|
||||
actor_uid=actor_uid,
|
||||
result="failure",
|
||||
summary=f"SEO diagnostics for {target} failed",
|
||||
metadata={"target": target, "error": str(exc)[:200]},
|
||||
links=[audit.job(uid)],
|
||||
)
|
||||
raise
|
||||
|
||||
report = self._load_report(output_dir)
|
||||
hub.publish(
|
||||
uid,
|
||||
{
|
||||
"type": "done",
|
||||
"score": summary.get("score"),
|
||||
"grade": summary.get("grade"),
|
||||
"page_count": summary.get("page_count"),
|
||||
"report_url": f"/tools/seo/{uid}/report",
|
||||
"report": report,
|
||||
},
|
||||
)
|
||||
hub.clear(uid)
|
||||
audit.record_system(
|
||||
"seo.run.complete",
|
||||
actor_kind=actor_kind,
|
||||
actor_uid=actor_uid,
|
||||
summary=f"SEO diagnostics for {target} scored {summary.get('score')}",
|
||||
metadata={
|
||||
"target": target,
|
||||
"score": summary.get("score"),
|
||||
"grade": summary.get("grade"),
|
||||
"page_count": summary.get("page_count"),
|
||||
},
|
||||
links=[audit.job(uid)],
|
||||
)
|
||||
return {
|
||||
"target": target,
|
||||
"mode": payload.get("mode", "url"),
|
||||
"score": summary.get("score", 0),
|
||||
"grade": summary.get("grade", ""),
|
||||
"page_count": summary.get("page_count", 0),
|
||||
"counts": summary.get("counts", {}),
|
||||
"categories": summary.get("categories", {}),
|
||||
"report": report,
|
||||
"report_url": f"/tools/seo/{uid}/report",
|
||||
"bytes_in": 0,
|
||||
"bytes_out": len(json.dumps(report)) if report else 0,
|
||||
"item_count": summary.get("page_count", 0),
|
||||
}
|
||||
|
||||
async def _run_worker(self, uid: str, payload_path: Path, output_dir: Path) -> dict:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
sys.executable,
|
||||
"-m",
|
||||
WORKER_MODULE,
|
||||
str(payload_path),
|
||||
str(output_dir),
|
||||
cwd=str(BASE_DIR),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
limit=STREAM_LIMIT,
|
||||
)
|
||||
summary: dict = {}
|
||||
worker_error = ""
|
||||
while True:
|
||||
line = await proc.stdout.readline()
|
||||
if not line:
|
||||
break
|
||||
try:
|
||||
frame = json.loads(line.decode("utf-8", "replace"))
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
hub.publish(uid, frame)
|
||||
if frame.get("type") == "report_ready":
|
||||
summary = frame
|
||||
elif frame.get("type") == "error":
|
||||
worker_error = frame.get("message", "worker error")
|
||||
err = (await proc.stderr.read()).decode("utf-8", "replace")
|
||||
await proc.wait()
|
||||
if proc.returncode != 0 or not summary:
|
||||
raise RuntimeError(
|
||||
worker_error or err[:500] or f"seo worker exited {proc.returncode}"
|
||||
)
|
||||
return summary
|
||||
|
||||
def _load_report(self, output_dir: Path) -> dict:
|
||||
report_path = output_dir / "report.json"
|
||||
if not report_path.is_file():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(report_path.read_text(encoding="utf-8"))
|
||||
except (ValueError, OSError):
|
||||
return {}
|
||||
|
||||
def cleanup(self, job: dict) -> None:
|
||||
hub.clear(job["uid"])
|
||||
shutil.rmtree(self.report_dir(job["uid"]), ignore_errors=True)
|
||||
@@ -0,0 +1,132 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from .checks.registry import compute_score, run_page_checks, run_site_checks
|
||||
from .crawler import crawl_target
|
||||
|
||||
|
||||
def _emit(frame: dict) -> None:
|
||||
sys.stdout.write(json.dumps(frame, ensure_ascii=False) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def _trim_site(site) -> dict:
|
||||
robots = site.robots or {}
|
||||
sitemap = site.sitemap or {}
|
||||
return {
|
||||
"robots": {
|
||||
"status": robots.get("status"),
|
||||
"disallows": robots.get("disallows", [])[:50],
|
||||
"sitemap_urls": robots.get("sitemap_urls", [])[:10],
|
||||
"blocks_target": robots.get("blocks_target", False),
|
||||
},
|
||||
"sitemap": {
|
||||
"status": sitemap.get("status"),
|
||||
"valid_xml": sitemap.get("valid_xml", False),
|
||||
"url_count": sitemap.get("url_count", 0),
|
||||
"lastmod_count": sitemap.get("lastmod_count", 0),
|
||||
},
|
||||
"llms_txt": site.llms_txt or {},
|
||||
}
|
||||
|
||||
|
||||
async def _run(payload: dict, output_dir: Path) -> dict:
|
||||
site = await crawl_target(payload, _emit, output_dir)
|
||||
|
||||
all_checks = []
|
||||
pages_summary = []
|
||||
_emit({"type": "stage", "stage": "checks", "message": "Running SEO checks"})
|
||||
for page in site.pages:
|
||||
page_checks = run_page_checks(page, site)
|
||||
all_checks.extend(page_checks)
|
||||
page_score = compute_score(page_checks)
|
||||
page_dicts = [c.to_dict() for c in page_checks]
|
||||
pages_summary.append(
|
||||
{
|
||||
"url": page.url,
|
||||
"requested_url": page.requested_url,
|
||||
"status": page.status,
|
||||
"ok": page.ok,
|
||||
"error": page.error,
|
||||
"screenshot": page.screenshot,
|
||||
"score": page_score["score"],
|
||||
"grade": page_score["grade"],
|
||||
"counts": page_score["counts"],
|
||||
}
|
||||
)
|
||||
_emit(
|
||||
{
|
||||
"type": "page",
|
||||
"url": page.url,
|
||||
"status": page.status,
|
||||
"score": page_score["score"],
|
||||
"grade": page_score["grade"],
|
||||
"counts": page_score["counts"],
|
||||
"checks": page_dicts,
|
||||
}
|
||||
)
|
||||
|
||||
site_checks = run_site_checks(site)
|
||||
all_checks.extend(site_checks)
|
||||
_emit(
|
||||
{
|
||||
"type": "site_checks",
|
||||
"checks": [c.to_dict() for c in site_checks],
|
||||
}
|
||||
)
|
||||
|
||||
overall = compute_score(all_checks)
|
||||
report = {
|
||||
"target": site.target_url,
|
||||
"mode": site.mode,
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"page_count": len(site.pages),
|
||||
"score": overall["score"],
|
||||
"grade": overall["grade"],
|
||||
"counts": overall["counts"],
|
||||
"categories": overall["categories"],
|
||||
"pages": pages_summary,
|
||||
"checks": [c.to_dict() for c in all_checks],
|
||||
"site": _trim_site(site),
|
||||
}
|
||||
if output_dir is not None:
|
||||
(output_dir / "report.json").write_text(
|
||||
json.dumps(report, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
_emit(
|
||||
{
|
||||
"type": "report_ready",
|
||||
"score": report["score"],
|
||||
"grade": report["grade"],
|
||||
"counts": report["counts"],
|
||||
"categories": report["categories"],
|
||||
"page_count": report["page_count"],
|
||||
}
|
||||
)
|
||||
return report
|
||||
|
||||
|
||||
def main(argv: list) -> int:
|
||||
if len(argv) != 3:
|
||||
sys.stderr.write("usage: seo.worker <payload_json> <output_dir>\n")
|
||||
return 2
|
||||
payload = json.loads(Path(argv[1]).read_text(encoding="utf-8"))
|
||||
output_dir = Path(argv[2])
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
asyncio.run(_run(payload, output_dir))
|
||||
except Exception as exc: # noqa: BLE001 - surface as a worker error line
|
||||
_emit({"type": "error", "message": str(exc)[:500]})
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv))
|
||||
@@ -36,6 +36,8 @@
|
||||
margin: 1rem;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.modal-card-wide {
|
||||
max-width: 700px;
|
||||
@@ -361,7 +363,7 @@ input, textarea, select {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
border-radius: var(--radius-input);
|
||||
padding: 0.625rem 0.875rem;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
@@ -411,13 +413,15 @@ img {
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
background: var(--accent-gradient);
|
||||
color: var(--white);
|
||||
box-shadow: 0 2px 12px rgba(255, 95, 70, 0.3);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-hover);
|
||||
color: var(--white);
|
||||
box-shadow: var(--glow-accent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@@ -533,6 +537,9 @@ img {
|
||||
}
|
||||
.topnav-link:hover { color: var(--text-primary); background: var(--bg-card); }
|
||||
.topnav-link.active { color: var(--accent); background: var(--accent-light); }
|
||||
.topnav-link.topnav-link-feature { color: var(--accent); font-weight: 600; background: var(--accent-light); }
|
||||
.topnav-link.topnav-link-feature:hover { color: var(--accent); background: var(--accent-light); filter: brightness(1.2); }
|
||||
.topnav-link.topnav-link-feature.active { color: var(--white); background: var(--accent-gradient); box-shadow: var(--glow-accent); }
|
||||
.topnav-right { margin-left: auto; display: flex; align-items: center; gap: 1rem; flex-shrink: 0; }
|
||||
.topnav-icon { position: relative; padding: 0.375rem; color: var(--text-secondary); font-size: 1.25rem; background: none; border: none; cursor: pointer; font-family: inherit; line-height: 1; }
|
||||
.topnav-icon:hover { color: var(--text-primary); }
|
||||
@@ -568,6 +575,26 @@ img {
|
||||
}
|
||||
.topnav-user-dropdown:hover .dropdown-menu,
|
||||
.topnav-user-dropdown.open .dropdown-menu { display: block; }
|
||||
|
||||
.topnav-tools-dropdown { position: relative; }
|
||||
.topnav-tools-toggle { display: inline-flex; align-items: center; gap: 0.25rem; background: none; border: none; cursor: pointer; font-family: inherit; }
|
||||
.topnav-tools-dropdown.active .topnav-tools-toggle { color: var(--accent); background: var(--accent-light); }
|
||||
.topnav-caret { font-size: 0.625rem; }
|
||||
.topnav-tools-dropdown .dropdown-menu {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
min-width: 180px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
padding: 0.25rem 0;
|
||||
z-index: 1001;
|
||||
}
|
||||
.topnav-tools-dropdown:hover .dropdown-menu,
|
||||
.topnav-tools-dropdown.open .dropdown-menu { display: block; }
|
||||
.dropdown-item {
|
||||
display: block;
|
||||
padding: 0.5rem 0.875rem;
|
||||
@@ -779,6 +806,7 @@ button.comment-form-submit:hover {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -35,8 +35,9 @@
|
||||
}
|
||||
|
||||
.feed-nav-btn.active {
|
||||
background: var(--accent);
|
||||
background: var(--accent-gradient);
|
||||
color: var(--white);
|
||||
box-shadow: var(--glow-accent);
|
||||
}
|
||||
|
||||
.feed-nav-actions {
|
||||
@@ -67,12 +68,13 @@
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: var(--space-xl);
|
||||
}
|
||||
|
||||
.post-card:hover {
|
||||
border-color: var(--border-light);
|
||||
box-shadow: var(--shadow-sm);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.post-header {
|
||||
@@ -124,15 +126,16 @@
|
||||
}
|
||||
|
||||
.post-title {
|
||||
font-size: 1.125rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.3;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.post-content {
|
||||
font-size: 0.875rem;
|
||||
font-size: 1rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.65;
|
||||
margin-bottom: 1rem;
|
||||
@@ -225,6 +228,7 @@
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
@@ -234,12 +238,24 @@
|
||||
color: var(--accent);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
margin-bottom: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.daily-topic-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.daily-topic-card h4 {
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.375rem;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@@ -264,6 +280,7 @@
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
@@ -315,25 +332,26 @@
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
right: 2rem;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: var(--danger);
|
||||
background: var(--accent-gradient);
|
||||
color: var(--white);
|
||||
font-size: 1.5rem;
|
||||
font-size: 2rem;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 16px rgba(229, 57, 53, 0.4);
|
||||
box-shadow: var(--glow-accent);
|
||||
z-index: 900;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.feed-fab:hover {
|
||||
background: var(--danger);
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 6px 20px rgba(229, 57, 53, 0.5);
|
||||
color: var(--white);
|
||||
transform: scale(1.06);
|
||||
box-shadow: 0 10px 32px rgba(255, 95, 70, 0.6);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
@@ -453,6 +471,7 @@
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -40,13 +40,15 @@
|
||||
}
|
||||
|
||||
.post-detail-title {
|
||||
font-size: 1.5rem;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.post-detail-content {
|
||||
font-size: 0.9375rem;
|
||||
font-size: 1rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.7;
|
||||
margin-top: 0.625rem;
|
||||
|
||||
@@ -23,8 +23,9 @@
|
||||
}
|
||||
|
||||
.projects-tab.active {
|
||||
background: var(--accent);
|
||||
background: var(--accent-gradient);
|
||||
color: var(--white);
|
||||
box-shadow: var(--glow-accent);
|
||||
}
|
||||
|
||||
.projects-header {
|
||||
@@ -56,27 +57,46 @@
|
||||
}
|
||||
|
||||
.project-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.25rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 1.5rem 1.25rem 1.25rem;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.project-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 4px;
|
||||
background: var(--accent-gradient);
|
||||
}
|
||||
|
||||
.project-card:hover {
|
||||
border-color: var(--border-light);
|
||||
box-shadow: var(--shadow);
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
.project-card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.project-card-title {
|
||||
font-size: 1.125rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.project-card-star {
|
||||
@@ -123,28 +143,33 @@
|
||||
|
||||
.platform-tag {
|
||||
display: inline-flex;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
padding: 0.1875rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
background: var(--border);
|
||||
color: var(--text-muted);
|
||||
background: var(--overlay-light);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.project-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.project-status.released {
|
||||
color: var(--success);
|
||||
background: rgba(76, 175, 80, 0.14);
|
||||
}
|
||||
|
||||
.project-status.dev {
|
||||
color: var(--warning);
|
||||
background: rgba(255, 152, 0, 0.14);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
/* retoor <retoor@molodetz.nl> */
|
||||
|
||||
.tools-page,
|
||||
.seo-tool {
|
||||
max-width: 920px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 1rem 3rem;
|
||||
}
|
||||
|
||||
.tools-header h1,
|
||||
.seo-header h1 {
|
||||
font-size: 1.75rem;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.tools-header p,
|
||||
.seo-header p {
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 1.5rem;
|
||||
max-width: 60ch;
|
||||
}
|
||||
|
||||
.tools-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.tool-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.tool-card:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.tool-card-icon { font-size: 1.5rem; }
|
||||
.tool-card-name { font-weight: 600; color: var(--text-primary); }
|
||||
.tool-card-desc { font-size: 0.8125rem; color: var(--text-secondary); }
|
||||
|
||||
.seo-form {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.seo-form-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.seo-input,
|
||||
.seo-select {
|
||||
padding: 0.625rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.seo-input[type="url"] { flex: 1 1 280px; }
|
||||
.seo-input-num { width: 5rem; }
|
||||
|
||||
.seo-pages {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.seo-run-btn { flex-shrink: 0; }
|
||||
|
||||
.seo-form-error {
|
||||
margin: 0.625rem 0 0;
|
||||
color: var(--danger, #e5484d);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.seo-live {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-card);
|
||||
padding: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.seo-live-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.625rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.seo-live-status { font-weight: 600; color: var(--text-primary); }
|
||||
.seo-live-count { color: var(--text-muted); }
|
||||
|
||||
.seo-progress {
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--bg-secondary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.seo-progress-bar {
|
||||
height: 100%;
|
||||
width: 0;
|
||||
background: var(--accent);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.seo-log {
|
||||
list-style: none;
|
||||
margin: 0.75rem 0 0;
|
||||
padding: 0;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.seo-log li {
|
||||
padding: 0.125rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.seo-score-card {
|
||||
display: flex;
|
||||
gap: 1.25rem;
|
||||
align-items: center;
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-card);
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.seo-gauge {
|
||||
flex-shrink: 0;
|
||||
width: 92px;
|
||||
height: 92px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-secondary);
|
||||
border: 3px solid var(--accent);
|
||||
}
|
||||
|
||||
.seo-gauge-score { font-size: 1.75rem; font-weight: 700; color: var(--text-primary); line-height: 1; }
|
||||
.seo-gauge-grade { font-size: 0.875rem; font-weight: 600; color: var(--text-muted); }
|
||||
|
||||
.seo-grade-a { border-color: #30a46c; }
|
||||
.seo-grade-b { border-color: #5dbb7a; }
|
||||
.seo-grade-c { border-color: #e2a336; }
|
||||
.seo-grade-d { border-color: #e08c3b; }
|
||||
.seo-grade-f { border-color: #e5484d; }
|
||||
|
||||
.seo-score-meta h2 { margin: 0 0 0.5rem; font-size: 1.0625rem; word-break: break-all; }
|
||||
.seo-report-sub { color: var(--text-muted); font-size: 0.8125rem; margin: 0.375rem 0 0; }
|
||||
|
||||
.seo-counts { display: flex; flex-wrap: wrap; gap: 0.375rem; }
|
||||
|
||||
.seo-chip {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.seo-chip-pass { color: #30a46c; }
|
||||
.seo-chip-warn { color: #e2a336; }
|
||||
.seo-chip-fail { color: #e5484d; }
|
||||
.seo-chip-info { color: var(--text-muted); }
|
||||
|
||||
.seo-report-link {
|
||||
display: inline-block;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.seo-categories {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.seo-cat-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--bg-card);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.seo-cat-name { color: var(--text-secondary); }
|
||||
.seo-cat-score { font-weight: 700; color: var(--text-primary); }
|
||||
|
||||
.seo-check-group { margin-bottom: 1.5rem; }
|
||||
.seo-check-group h2 { font-size: 1rem; margin: 0 0 0.625rem; text-transform: capitalize; }
|
||||
|
||||
.seo-check {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: flex-start;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-left-width: 3px;
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 0.5rem;
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.seo-check-pass { border-left-color: #30a46c; }
|
||||
.seo-check-warn { border-left-color: #e2a336; }
|
||||
.seo-check-fail { border-left-color: #e5484d; }
|
||||
.seo-check-info { border-left-color: var(--text-muted); }
|
||||
.seo-check-skip { border-left-color: var(--border); }
|
||||
|
||||
.seo-check-status {
|
||||
flex-shrink: 0;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.1875rem 0.375rem;
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.seo-check-body { display: flex; flex-direction: column; gap: 0.1875rem; flex: 1; }
|
||||
.seo-check-title { font-weight: 600; color: var(--text-primary); font-size: 0.875rem; }
|
||||
.seo-check-url { font-weight: 400; color: var(--text-muted); font-size: 0.75rem; word-break: break-all; }
|
||||
.seo-check-value { font-size: 0.8125rem; color: var(--text-secondary); }
|
||||
.seo-check-rec { font-size: 0.8125rem; color: var(--accent); }
|
||||
|
||||
.seo-check-severity {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.seo-sev-critical { color: #e5484d; }
|
||||
.seo-sev-high { color: #e08c3b; }
|
||||
|
||||
.seo-empty {
|
||||
padding: 1.5rem;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.seo-report-target { color: var(--text-muted); word-break: break-all; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.seo-score-card { flex-direction: column; align-items: flex-start; }
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
@@ -75,9 +76,38 @@
|
||||
|
||||
.sidebar-link.active,
|
||||
.sidebar-link.active:hover {
|
||||
background: var(--accent-light);
|
||||
color: var(--accent);
|
||||
background: var(--accent-gradient);
|
||||
color: var(--white);
|
||||
font-weight: 600;
|
||||
box-shadow: var(--glow-accent);
|
||||
}
|
||||
|
||||
.sidebar-more {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.sidebar-more-toggle {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sidebar-more-toggle::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-more-chevron {
|
||||
margin-left: auto;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
color: var(--text-muted);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.sidebar-more[open] > .sidebar-more-toggle .sidebar-more-chevron {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.sidebar-dot {
|
||||
|
||||
@@ -1,49 +1,53 @@
|
||||
/* retoor <retoor@molodetz.nl> */
|
||||
|
||||
:root {
|
||||
--bg-primary: #0f0a1a;
|
||||
--bg-secondary: #1a1028;
|
||||
--bg-card: #221436;
|
||||
--bg-card-hover: #2a1a42;
|
||||
--bg-input: #1a1028;
|
||||
--bg-modal: #1a1028;
|
||||
--bg-primary: #080413;
|
||||
--bg-secondary: #120821;
|
||||
--bg-card: #1a1030;
|
||||
--bg-card-hover: #241640;
|
||||
--bg-input: #140b26;
|
||||
--bg-modal: #1a1030;
|
||||
|
||||
--accent: #ff6b35;
|
||||
--accent-hover: #e55a2b;
|
||||
--accent-light: rgba(255, 107, 53, 0.1);
|
||||
--accent-hover: #ff7d4d;
|
||||
--accent-light: rgba(255, 107, 53, 0.12);
|
||||
--accent-2: #ff4f8b;
|
||||
--accent-gradient: linear-gradient(135deg, #ff6b3d, #ff4f6d);
|
||||
|
||||
--text-primary: #f0e8f8;
|
||||
--text-primary: #f4eefb;
|
||||
--text-secondary: #b8a8d0;
|
||||
--text-muted: #7a6a90;
|
||||
|
||||
--border: #2e2040;
|
||||
--border-light: #3a2a50;
|
||||
--border: rgba(255, 255, 255, 0.08);
|
||||
--border-light: rgba(255, 255, 255, 0.14);
|
||||
|
||||
--success: #4caf50;
|
||||
--warning: #ff9800;
|
||||
--danger: #e53935;
|
||||
--info: #42a5f5;
|
||||
|
||||
--radius: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 16px;
|
||||
--radius: 12px;
|
||||
--radius-input: 14px;
|
||||
--radius-lg: 18px;
|
||||
--radius-xl: 24px;
|
||||
|
||||
--shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.25);
|
||||
--shadow: 0 4px 20px rgba(0, 0, 0, 0.28);
|
||||
--shadow-md: 0 4px 20px rgba(0, 0, 0, 0.28);
|
||||
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.45);
|
||||
--glow-accent: 0 6px 24px rgba(255, 95, 70, 0.45);
|
||||
|
||||
--white: #fff;
|
||||
--overlay-dark: rgba(0, 0, 0, 0.7);
|
||||
--overlay-light: rgba(255, 255, 255, 0.05);
|
||||
|
||||
--space-xs: 0.25rem;
|
||||
--space-sm: 0.375rem;
|
||||
--space-sm: 0.5rem;
|
||||
--space-base: 0.5rem;
|
||||
--space-md: 0.75rem;
|
||||
--space-lg: 1rem;
|
||||
--space-xl: 1.25rem;
|
||||
--space-2xl: 1.5rem;
|
||||
--space-xl: 1.5rem;
|
||||
--space-2xl: 2rem;
|
||||
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
--font-mono: "SF Mono", Monaco, "Cascadia Code", monospace;
|
||||
@@ -59,5 +63,5 @@
|
||||
--topic-fun: #ffab00;
|
||||
--topic-signals: #00bcd4;
|
||||
|
||||
--bg-gradient: linear-gradient(135deg, #0f0a1a 0%, #1a0a2e 50%, #0f0a1a 100%);
|
||||
--bg-gradient: linear-gradient(135deg, #080413 0%, #160a28 50%, #080413 100%);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export class MobileNav {
|
||||
this.initMessagesResponsive();
|
||||
this.initMessageThread();
|
||||
this.initProfileDropdown();
|
||||
this.initToolsDropdown();
|
||||
}
|
||||
|
||||
initMobileNav() {
|
||||
@@ -119,4 +120,31 @@ export class MobileNav {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
initToolsDropdown() {
|
||||
const dropdown = document.querySelector(".topnav-tools-dropdown");
|
||||
if (!dropdown) return;
|
||||
const btn = dropdown.querySelector(".topnav-tools-toggle");
|
||||
if (!btn) return;
|
||||
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const open = dropdown.classList.toggle("open");
|
||||
btn.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
});
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
if (!dropdown.contains(e.target)) {
|
||||
dropdown.classList.remove("open");
|
||||
btn.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") {
|
||||
dropdown.classList.remove("open");
|
||||
btn.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { Http } from "./Http.js";
|
||||
import { SeoProgressSocket } from "./SeoProgressSocket.js";
|
||||
|
||||
const STATUS_LABEL = {
|
||||
pass: "pass",
|
||||
warn: "warn",
|
||||
fail: "fail",
|
||||
info: "info",
|
||||
skip: "skip",
|
||||
};
|
||||
|
||||
export class SeoDiagnostics {
|
||||
constructor() {
|
||||
this.root = document.querySelector("[data-seo-tool]");
|
||||
if (!this.root) return;
|
||||
this.form = this.root.querySelector("[data-seo-form]");
|
||||
this.modeSelect = this.root.querySelector("[data-seo-mode]");
|
||||
this.pagesField = this.root.querySelector("[data-seo-pages]");
|
||||
this.errorBox = this.root.querySelector("[data-seo-error]");
|
||||
this.live = this.root.querySelector("[data-seo-live]");
|
||||
this.statusText = this.root.querySelector("[data-seo-status]");
|
||||
this.countText = this.root.querySelector("[data-seo-count]");
|
||||
this.bar = this.root.querySelector("[data-seo-bar]");
|
||||
this.log = this.root.querySelector("[data-seo-log]");
|
||||
this.report = this.root.querySelector("[data-seo-report]");
|
||||
this.socket = null;
|
||||
this.uid = null;
|
||||
this._bind();
|
||||
}
|
||||
|
||||
_bind() {
|
||||
this.modeSelect.addEventListener("change", () => this._syncMode());
|
||||
this._syncMode();
|
||||
this.form.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
this._start();
|
||||
});
|
||||
}
|
||||
|
||||
_syncMode() {
|
||||
const sitemap = this.modeSelect.value === "sitemap";
|
||||
this.pagesField.hidden = !sitemap;
|
||||
}
|
||||
|
||||
async _start() {
|
||||
this._setError("");
|
||||
const url = this.form.url.value.trim();
|
||||
if (!url) return;
|
||||
const params = {
|
||||
url,
|
||||
mode: this.modeSelect.value,
|
||||
max_pages: this.form.max_pages ? this.form.max_pages.value : 10,
|
||||
};
|
||||
this._resetLive();
|
||||
try {
|
||||
const response = await fetch("/tools/seo/run", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Requested-With": "fetch",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams(params),
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
this._setError((data.error && data.error.message) || "Could not start the audit.");
|
||||
this.live.hidden = true;
|
||||
return;
|
||||
}
|
||||
this.uid = data.uid;
|
||||
this._watch(data.uid);
|
||||
} catch (error) {
|
||||
this._setError("Could not start the audit.");
|
||||
this.live.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
_resetLive() {
|
||||
if (this.socket) this.socket.close();
|
||||
this.report.hidden = true;
|
||||
this.report.querySelector("[data-seo-categories]").innerHTML = "";
|
||||
this.report.querySelector("[data-seo-checks]").innerHTML = "";
|
||||
this.live.hidden = false;
|
||||
this.log.innerHTML = "";
|
||||
this.bar.style.width = "0%";
|
||||
this.countText.textContent = "";
|
||||
this.statusText.textContent = "Starting…";
|
||||
this.form.querySelector("[data-seo-run]").disabled = true;
|
||||
}
|
||||
|
||||
_watch(uid) {
|
||||
this.socket = new SeoProgressSocket(uid, {
|
||||
onMessage: (frame) => this._frame(frame),
|
||||
onClose: () => {},
|
||||
});
|
||||
this.socket.connect();
|
||||
}
|
||||
|
||||
_frame(frame) {
|
||||
const type = frame.type;
|
||||
if (type === "stage") {
|
||||
this.statusText.textContent = frame.message || "Working…";
|
||||
} else if (type === "target") {
|
||||
const total = (frame.urls || []).length;
|
||||
this.countText.textContent = total ? `0 / ${total} pages` : "";
|
||||
this.statusText.textContent = `Auditing ${frame.url}`;
|
||||
} else if (type === "progress") {
|
||||
const total = frame.total || 1;
|
||||
const done = frame.done || 0;
|
||||
this.bar.style.width = `${Math.round((done / total) * 100)}%`;
|
||||
this.countText.textContent = `${done} / ${total} pages`;
|
||||
if (frame.message) this.statusText.textContent = frame.message;
|
||||
} else if (type === "page_loaded") {
|
||||
this.bar.style.width = `${Math.round((frame.done / frame.total) * 100)}%`;
|
||||
this.countText.textContent = `${frame.done} / ${frame.total} pages`;
|
||||
this._appendLog(`${frame.status} ${frame.url}`);
|
||||
} else if (type === "report_ready") {
|
||||
this.statusText.textContent = "Compiling report…";
|
||||
} else if (type === "done") {
|
||||
this.bar.style.width = "100%";
|
||||
this.statusText.textContent = "Done";
|
||||
this.form.querySelector("[data-seo-run]").disabled = false;
|
||||
const reportUrl = frame.report_url || `/tools/seo/${this.uid}/report`;
|
||||
if (frame.report && Object.keys(frame.report).length) {
|
||||
this._renderReport(frame.report, reportUrl);
|
||||
} else {
|
||||
this._loadReport(reportUrl);
|
||||
}
|
||||
} else if (type === "failed") {
|
||||
this.statusText.textContent = "Failed";
|
||||
this._setError(frame.message || frame.error || "The audit failed.");
|
||||
this.form.querySelector("[data-seo-run]").disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
_appendLog(text) {
|
||||
const item = document.createElement("li");
|
||||
item.textContent = text;
|
||||
this.log.prepend(item);
|
||||
}
|
||||
|
||||
async _loadReport(url) {
|
||||
this.form.querySelector("[data-seo-run]").disabled = false;
|
||||
let report;
|
||||
try {
|
||||
report = await Http.getJson(url);
|
||||
} catch (error) {
|
||||
this._setError("Could not load the report.");
|
||||
return;
|
||||
}
|
||||
this._renderReport(report, url);
|
||||
}
|
||||
|
||||
_renderReport(report, url) {
|
||||
this.report.hidden = false;
|
||||
this.report.querySelector("[data-seo-score]").textContent = report.score ?? 0;
|
||||
const gauge = this.report.querySelector("[data-seo-gauge]");
|
||||
gauge.className = `seo-gauge seo-grade-${(report.grade || "f").toLowerCase()}`;
|
||||
this.report.querySelector("[data-seo-grade]").textContent = report.grade || "";
|
||||
this.report.querySelector("[data-seo-target]").textContent = report.target || "";
|
||||
this.report.querySelector("[data-seo-report-link]").href = url.replace(/\?.*$/, "");
|
||||
const counts = report.counts || {};
|
||||
this.report.querySelector("[data-seo-counts]").innerHTML = ["pass", "warn", "fail", "info"]
|
||||
.map((key) => `<span class="seo-chip seo-chip-${key}">${counts[key] || 0} ${key}</span>`)
|
||||
.join("");
|
||||
this._renderCategories(report.categories || {});
|
||||
this._renderChecks(report.categories || {}, report.checks || [], report.page_count || 1);
|
||||
}
|
||||
|
||||
_renderCategories(categories) {
|
||||
const host = this.report.querySelector("[data-seo-categories]");
|
||||
host.innerHTML = Object.entries(categories)
|
||||
.map(([name, meta]) => {
|
||||
const score = meta.score === null || meta.score === undefined ? "" : `<span class="seo-cat-score">${meta.score}</span>`;
|
||||
return `<div class="seo-cat-pill"><span class="seo-cat-name">${this._title(name)}</span>${score}</div>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
_renderChecks(categories, checks, pageCount) {
|
||||
const host = this.report.querySelector("[data-seo-checks]");
|
||||
const groups = Object.keys(categories).map((cat) => {
|
||||
const items = checks.filter((c) => c.category === cat);
|
||||
const rows = items.map((check) => this._checkRow(check, pageCount)).join("");
|
||||
return `<section class="seo-check-group"><h2>${this._title(cat)}</h2>${rows}</section>`;
|
||||
});
|
||||
host.innerHTML = groups.join("");
|
||||
}
|
||||
|
||||
_checkRow(check, pageCount) {
|
||||
const status = STATUS_LABEL[check.status] || check.status;
|
||||
const url = check.url && pageCount > 1 ? `<span class="seo-check-url">${this._escape(check.url)}</span>` : "";
|
||||
const value = check.value ? `<span class="seo-check-value">${this._escape(check.value)}</span>` : "";
|
||||
const rec = check.recommendation ? `<span class="seo-check-rec">${this._escape(check.recommendation)}</span>` : "";
|
||||
return `<div class="seo-check seo-check-${check.status}">
|
||||
<span class="seo-check-status">${status}</span>
|
||||
<div class="seo-check-body">
|
||||
<span class="seo-check-title">${this._escape(check.title)} ${url}</span>
|
||||
${value}${rec}
|
||||
</div>
|
||||
<span class="seo-check-severity seo-sev-${check.severity}">${check.severity}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_title(text) {
|
||||
return String(text).replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
_escape(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text == null ? "" : String(text);
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
_setError(message) {
|
||||
if (!this.errorBox) return;
|
||||
this.errorBox.textContent = message;
|
||||
this.errorBox.hidden = !message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
const WRONG_WORKER_CODE = 4013;
|
||||
const WRONG_WORKER_RETRY_MS = 200;
|
||||
const RECONNECT_DELAY_MS = 1500;
|
||||
|
||||
export class SeoProgressSocket {
|
||||
constructor(uid, handlers) {
|
||||
this.handlers = handlers || {};
|
||||
this.ws = null;
|
||||
this._shouldRun = true;
|
||||
const scheme = location.protocol === "https:" ? "wss://" : "ws://";
|
||||
this.url = `${scheme}${location.host}/tools/seo/${encodeURIComponent(uid)}/ws`;
|
||||
}
|
||||
|
||||
connect() {
|
||||
this._shouldRun = true;
|
||||
this._open();
|
||||
}
|
||||
|
||||
_open() {
|
||||
this.ws = new WebSocket(this.url);
|
||||
this.ws.addEventListener("message", (event) => {
|
||||
let frame;
|
||||
try {
|
||||
frame = JSON.parse(event.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
this._emit("onMessage", frame);
|
||||
});
|
||||
this.ws.addEventListener("close", (event) => {
|
||||
if (event.code === WRONG_WORKER_CODE && this._shouldRun) {
|
||||
window.setTimeout(() => this._open(), WRONG_WORKER_RETRY_MS);
|
||||
return;
|
||||
}
|
||||
this._emit("onClose");
|
||||
});
|
||||
this.ws.addEventListener("error", () => this._emit("onError"));
|
||||
}
|
||||
|
||||
close() {
|
||||
this._shouldRun = false;
|
||||
if (this.ws) this.ws.close();
|
||||
}
|
||||
|
||||
_emit(name, payload) {
|
||||
const handler = this.handlers[name];
|
||||
if (typeof handler === "function") handler(payload);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
const META = document.querySelector('meta[name="asset-version"]');
|
||||
const VERSION = META ? META.content : "";
|
||||
|
||||
export function assetUrl(path) {
|
||||
if (!VERSION || !path.startsWith("/static/") || path.startsWith("/static/uploads/")) {
|
||||
return path;
|
||||
}
|
||||
return `/static/v${VERSION}${path.slice("/static".length)}`;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { Component } from "./Component.js";
|
||||
import { assetUrl } from "../assetVersion.js";
|
||||
import DeviiSocket from "../devii/DeviiSocket.js";
|
||||
import Markdown from "../devii/markdown.js";
|
||||
|
||||
@@ -23,7 +24,7 @@ export class AppDocsChat extends Component {
|
||||
const link = document.createElement("link");
|
||||
link.id = DOCS_CHAT_CSS_ID;
|
||||
link.rel = "stylesheet";
|
||||
link.href = "/static/css/docs-chat.css";
|
||||
link.href = assetUrl("/static/css/docs-chat.css");
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { Component } from "./Component.js";
|
||||
import { assetUrl } from "../assetVersion.js";
|
||||
|
||||
const LB_CSS_ID = "lightbox-css";
|
||||
|
||||
@@ -22,7 +23,7 @@ export class AppLightbox extends Component {
|
||||
const link = document.createElement("link");
|
||||
link.id = LB_CSS_ID;
|
||||
link.rel = "stylesheet";
|
||||
link.href = "/static/css/lightbox.css";
|
||||
link.href = assetUrl("/static/css/lightbox.css");
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { assetUrl } from "../assetVersion.js";
|
||||
|
||||
const COMP_CSS_ID = "dp-components-css";
|
||||
|
||||
export class Component extends HTMLElement {
|
||||
@@ -13,7 +15,7 @@ export class Component extends HTMLElement {
|
||||
const link = document.createElement("link");
|
||||
link.id = COMP_CSS_ID;
|
||||
link.rel = "stylesheet";
|
||||
link.href = "/static/css/components.css";
|
||||
link.href = assetUrl("/static/css/components.css");
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
import FloatingWindow from "./FloatingWindow.js";
|
||||
import { Http } from "../Http.js";
|
||||
import { UserScope } from "../userScope.js";
|
||||
import { assetUrl } from "../assetVersion.js";
|
||||
|
||||
const XTERM_JS = "/static/vendor/xterm/xterm.js";
|
||||
const XTERM_CSS = "/static/vendor/xterm/xterm.css";
|
||||
const XTERM_FIT = "/static/vendor/xterm/addon-fit.js";
|
||||
const XTERM_JS = assetUrl("/static/vendor/xterm/xterm.js");
|
||||
const XTERM_CSS = assetUrl("/static/vendor/xterm/xterm.css");
|
||||
const XTERM_FIT = assetUrl("/static/vendor/xterm/addon-fit.js");
|
||||
|
||||
const FONT_MIN = 8;
|
||||
const FONT_MAX = 30;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { Component } from "./Component.js";
|
||||
import { assetUrl } from "../assetVersion.js";
|
||||
|
||||
const FW_CSS_ID = "floating-window-css";
|
||||
const MOBILE_QUERY = "(max-width: 640px)";
|
||||
@@ -75,7 +76,7 @@ export default class FloatingWindow extends Component {
|
||||
const link = document.createElement("link");
|
||||
link.id = FW_CSS_ID;
|
||||
link.rel = "stylesheet";
|
||||
link.href = "/static/css/floating-window.css";
|
||||
link.href = assetUrl("/static/css/floating-window.css");
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { assetUrl } from "../assetVersion.js";
|
||||
|
||||
const CSS_ID = "devii-avatar-css";
|
||||
const DEFAULT_CHARACTER = "Clippy";
|
||||
const CORNER_MARGIN = 16;
|
||||
@@ -36,13 +38,13 @@ export default class DeviiAvatarElement extends HTMLElement {
|
||||
const link = document.createElement("link");
|
||||
link.id = CSS_ID;
|
||||
link.rel = "stylesheet";
|
||||
link.href = "/static/css/devii-avatar.css";
|
||||
link.href = assetUrl("/static/css/devii-avatar.css");
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
async _app() {
|
||||
if (!this._clippy) {
|
||||
const module = await import("/static/vendor/md-clippy/index.js");
|
||||
const module = await import(assetUrl("/static/vendor/md-clippy/index.js"));
|
||||
this._clippy = module.app;
|
||||
}
|
||||
return this._clippy;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import FloatingWindow from "../components/FloatingWindow.js";
|
||||
import { Http } from "../Http.js";
|
||||
import { assetUrl } from "../assetVersion.js";
|
||||
import Markdown from "./markdown.js";
|
||||
import DeviiSocket from "./DeviiSocket.js";
|
||||
import DeviiClient from "./DeviiClient.js";
|
||||
@@ -230,7 +231,7 @@ export default class DeviiTerminalElement extends FloatingWindow {
|
||||
const link = document.createElement("link");
|
||||
link.id = CSS_ID;
|
||||
link.rel = "stylesheet";
|
||||
link.href = "/static/css/devii.css";
|
||||
link.href = assetUrl("/static/css/devii.css");
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "admin_base.html" %}
|
||||
{% block extra_head %}
|
||||
{{ super() }}
|
||||
<link rel="stylesheet" href="/static/css/audit.css">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/audit.css') }}">
|
||||
{% endblock %}
|
||||
{% block admin_content %}
|
||||
<a href="/admin/audit-log" class="audit-back-link"><span class="icon">←</span> Back to audit log</a>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "admin_base.html" %}
|
||||
{% block extra_head %}
|
||||
{{ super() }}
|
||||
<link rel="stylesheet" href="/static/css/audit.css">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/audit.css') }}">
|
||||
{% endblock %}
|
||||
{% block admin_content %}
|
||||
<div class="admin-toolbar">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/admin.css">
|
||||
<link rel="stylesheet" href="/static/css/sidebar.css">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/admin.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/sidebar.css') }}">
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="admin-layout">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "admin_base.html" %}
|
||||
{% block extra_head %}{{ super() }}
|
||||
<link rel="stylesheet" href="/static/css/profile.css">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/profile.css') }}">
|
||||
{% endblock %}
|
||||
{% block admin_content %}
|
||||
<div class="admin-toolbar">
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{% extends "admin_base.html" %}
|
||||
{% block extra_head %}
|
||||
{{ super() }}
|
||||
<link rel="stylesheet" href="/static/css/services.css">
|
||||
<link rel="stylesheet" href="/static/css/ai_usage.css">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/services.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/ai_usage.css') }}">
|
||||
{% endblock %}
|
||||
{% block admin_content %}
|
||||
<div class="admin-toolbar">
|
||||
@@ -31,7 +31,7 @@
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block extra_js %}
|
||||
<script type="module" src="/static/js/AiUsageMonitor.js"></script>
|
||||
<script type="module" src="{{ static_url('/static/js/AiUsageMonitor.js') }}"></script>
|
||||
<script type="module">
|
||||
new window.AiUsageMonitor().start();
|
||||
</script>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="theme-color" content="#0f0a1a">
|
||||
<meta name="asset-version" content="{{ static_version }}">
|
||||
<title>{% if page_title %}{{ page_title }}{% else %}DevPlace - The Developer Social Network{% endif %}</title>
|
||||
<meta name="description" content="{% if meta_description %}{{ meta_description }}{% else %}Track industry shifts. Discover bold releases. Share what you're building in an open, uncensored environment.{% endif %}">
|
||||
<link rel="canonical" href="{{ canonical_url or request.url }}">
|
||||
@@ -15,33 +16,33 @@
|
||||
<meta property="og:description" content="{{ og_description or meta_description or 'The Developer Social Network' }}">
|
||||
<meta property="og:type" content="{{ og_type or 'website' }}">
|
||||
<meta property="og:url" content="{{ canonical_url or request.url }}">
|
||||
<meta property="og:image" content="{{ og_image or '/static/og-default.png' }}">
|
||||
<meta property="og:image" content="{{ static_url(og_image or '/static/og-default.png') }}">
|
||||
<meta property="og:site_name" content="DevPlace">
|
||||
<meta property="og:locale" content="en_US">
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="{{ og_title or page_title or 'DevPlace' }}">
|
||||
<meta name="twitter:description" content="{{ og_description or meta_description or 'The Developer Social Network' }}">
|
||||
<meta name="twitter:image" content="{{ og_image or '/static/og-default.png' }}">
|
||||
<meta name="twitter:image" content="{{ static_url(og_image or '/static/og-default.png') }}">
|
||||
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>💻</text></svg>">
|
||||
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png">
|
||||
<link rel="apple-touch-icon" href="{{ static_url('/static/apple-touch-icon.png') }}">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="DevPlace">
|
||||
|
||||
<link rel="stylesheet" href="/static/css/variables.css">
|
||||
<link rel="stylesheet" href="/static/css/base.css">
|
||||
<link rel="stylesheet" href="/static/css/components.css">
|
||||
<link rel="stylesheet" href="/static/vendor/atom-one-dark.min.css">
|
||||
<link rel="stylesheet" href="/static/css/markdown.css">
|
||||
<link rel="stylesheet" href="/static/css/mention.css">
|
||||
<link rel="stylesheet" href="/static/css/attachments.css">
|
||||
<link rel="stylesheet" href="/static/css/lightbox.css">
|
||||
<link rel="stylesheet" href="/static/css/media.css">
|
||||
<link rel="stylesheet" href="/static/css/engagement.css">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/variables.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/base.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/components.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/vendor/atom-one-dark.min.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/markdown.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/mention.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/attachments.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/lightbox.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/media.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/engagement.css') }}">
|
||||
{% block extra_head %}{% endblock %}
|
||||
{{ custom_css_tag(request) }}
|
||||
|
||||
@@ -60,8 +61,14 @@
|
||||
<a href="/feed" class="topnav-link {% if request.url.path == '/feed' %}active{% endif %}"><span class="icon">📝</span> Posts</a>
|
||||
<a href="/news" class="topnav-link {% if 'news' in request.url.path %}active{% endif %}"><span class="icon">📰</span> News</a>
|
||||
<a href="/gists" class="topnav-link {% if 'gists' in request.url.path %}active{% endif %}"><span class="icon">📄</span> Gists</a>
|
||||
<a href="/projects" class="topnav-link {% if 'projects' in request.url.path %}active{% endif %}"><span class="icon">🚀</span> Projects</a>
|
||||
<a href="/projects" class="topnav-link topnav-link-feature {% if 'projects' in request.url.path %}active{% endif %}"><span class="icon">🚀</span> Projects</a>
|
||||
<a href="/leaderboard" class="topnav-link {% if 'leaderboard' in request.url.path %}active{% endif %}"><span class="icon">🏆</span> Leaderboard</a>
|
||||
<div class="topnav-tools-dropdown {% if request.url.path.startswith('/tools') %}active{% endif %}">
|
||||
<button type="button" class="topnav-link topnav-tools-toggle" aria-haspopup="true" aria-expanded="false"><span class="icon">🧰</span> Tools <span class="topnav-caret">▾</span></button>
|
||||
<div class="dropdown-menu">
|
||||
<a href="/tools/seo" class="dropdown-item"><span class="icon">🔍</span> SEO Diagnostics</a>
|
||||
</div>
|
||||
</div>
|
||||
{% if user %}
|
||||
<a href="/messages" class="topnav-link {% if 'messages' in request.url.path %}active{% endif %}" data-counter="messages"><span class="icon">✉️</span> Messages{% set msg_unread = get_unread_messages(user["uid"]) %}<span class="nav-badge nav-badge-inline" data-counter-badge {% if msg_unread == 0 %}hidden{% endif %}>{{ msg_unread }}</span></a>
|
||||
{% if is_admin(user) %}
|
||||
@@ -112,6 +119,9 @@
|
||||
<a href="/gists" class="topnav-mobile-link {% if 'gists' in request.url.path %}active{% endif %}"><span class="icon">📄</span> Gists</a>
|
||||
<a href="/projects" class="topnav-mobile-link {% if 'projects' in request.url.path %}active{% endif %}"><span class="icon">🚀</span> Projects</a>
|
||||
<a href="/leaderboard" class="topnav-mobile-link {% if 'leaderboard' in request.url.path %}active{% endif %}"><span class="icon">🏆</span> Leaderboard</a>
|
||||
<div class="topnav-mobile-divider"></div>
|
||||
<div class="topnav-mobile-section-label">Tools</div>
|
||||
<a href="/tools/seo" class="topnav-mobile-link {% if 'tools/seo' in request.url.path %}active{% endif %}"><span class="icon">🔍</span> SEO Diagnostics</a>
|
||||
{% if user %}
|
||||
<a href="/messages" class="topnav-mobile-link {% if 'messages' in request.url.path %}active{% endif %}" data-counter="messages"><span class="icon">✉️</span> Messages<span class="nav-badge nav-badge-inline" data-counter-badge {% if get_unread_messages(user["uid"]) == 0 %}hidden{% endif %}>{{ get_unread_messages(user["uid"]) }}</span></a>
|
||||
<a href="/notifications" class="topnav-mobile-link {% if 'notifications' in request.url.path %}active{% endif %}" data-counter="notifications"><span class="icon">🔔</span> Notifications<span class="nav-badge nav-badge-inline" data-counter-badge {% if get_unread_count(user["uid"]) == 0 %}hidden{% endif %}>{{ get_unread_count(user["uid"]) }}</span></a>
|
||||
@@ -177,11 +187,11 @@
|
||||
</template>
|
||||
{% endif %}
|
||||
|
||||
<script defer src="/static/vendor/marked.umd.js"></script>
|
||||
<script defer src="/static/vendor/highlight.min.js"></script>
|
||||
<script defer src="/static/vendor/purify.min.js"></script>
|
||||
<script type="module" src="/static/vendor/emoji-picker-element/index.js"></script>
|
||||
<script type="module" src="/static/js/Application.js"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/marked.umd.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/highlight.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/purify.min.js') }}"></script>
|
||||
<script type="module" src="{{ static_url('/static/vendor/emoji-picker-element/index.js') }}"></script>
|
||||
<script type="module" src="{{ static_url('/static/js/Application.js') }}"></script>
|
||||
{% block extra_js %}{% endblock %}
|
||||
{{ custom_js_tag(request) }}
|
||||
</body>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/bugs.css">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/bugs.css') }}">
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="bugs-layout bug-detail">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import modal %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/bugs.css">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/bugs.css') }}">
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="bugs-layout">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import modal %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/containers.css">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/containers.css') }}">
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<h1 class="sr-only">Container manager</h1>
|
||||
@@ -76,7 +76,7 @@
|
||||
{% endblock %}
|
||||
{% block extra_js %}
|
||||
<script type="module">
|
||||
import { ContainerManager } from "/static/js/ContainerManager.js";
|
||||
import { ContainerManager } from "{{ static_url('/static/js/ContainerManager.js') }}";
|
||||
const root = document.getElementById("container-manager");
|
||||
if (root) {
|
||||
const manager = new ContainerManager(root);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "admin_base.html" %}
|
||||
{% block extra_head %}
|
||||
{{ super() }}
|
||||
<link rel="stylesheet" href="/static/css/containers.css">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/containers.css') }}">
|
||||
{% endblock %}
|
||||
{% block admin_content %}
|
||||
<div class="admin-toolbar">
|
||||
@@ -48,7 +48,7 @@
|
||||
{% endblock %}
|
||||
{% block extra_js %}
|
||||
<script type="module">
|
||||
import { ContainerList } from "/static/js/ContainerList.js";
|
||||
import { ContainerList } from "{{ static_url('/static/js/ContainerList.js') }}";
|
||||
const list = new ContainerList(document.getElementById("cm-admin-list"));
|
||||
list.init();
|
||||
window.app.containerList = list;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import modal %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/containers.css">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/containers.css') }}">
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="ci-page" id="container-instance"
|
||||
@@ -128,7 +128,7 @@
|
||||
{% endblock %}
|
||||
{% block extra_js %}
|
||||
<script type="module">
|
||||
import { ContainerInstance } from "/static/js/ContainerInstance.js";
|
||||
import { ContainerInstance } from "{{ static_url('/static/js/ContainerInstance.js') }}";
|
||||
const root = document.getElementById("container-instance");
|
||||
const instance = new ContainerInstance(root);
|
||||
instance.init();
|
||||
|
||||
@@ -60,7 +60,7 @@ There is no separate front-end application or client-side router: every page is
|
||||
|
||||
**Production** (Docker Compose): an nginx front door sits in front of the app and serves the static layer directly from the bind-mounted `devplacepy/static` directory on disk, never touching the application:
|
||||
|
||||
- `/static/` (CSS and JavaScript) is served from disk with an immutable 7-day cache.
|
||||
- `/static/v<version>/` (CSS and JavaScript) is served from disk with a one-year immutable cache; the `<version>` path segment is the server boot timestamp, so a deploy busts every asset URL while keeping returning visitors fully cached between deploys. The unversioned `/static/` fallback keeps a short one-hour cache. See [Static asset caching](/docs/static-caching.html).
|
||||
- `/static/uploads/` is served from disk, but nginx **re-applies** `Content-Disposition: attachment` and `X-Content-Type-Options: nosniff`, because the app's own `UploadStaticFiles` sets that download header and a direct nginx handler would otherwise bypass it (a stored-XSS defense for uploaded SVG/HTML).
|
||||
- `/avatar/` is proxied to the app, because avatars are generated SVGs, not files on disk.
|
||||
- Everything else (`/`, the rendered pages and the JSON API) is proxied to the app, with an optional micro-cache.
|
||||
|
||||
@@ -33,5 +33,5 @@ No JavaScript is required - the element renders itself on connection.
|
||||
</div>
|
||||
</div>
|
||||
<script type="module">
|
||||
import "/static/js/components/AppAvatar.js";
|
||||
import "{{ static_url('/static/js/components/AppAvatar.js') }}";
|
||||
</script>
|
||||
|
||||
@@ -37,5 +37,5 @@ On connection the element enhances its content once: highlighting, a copy button
|
||||
}</code></pre></dp-code>
|
||||
</div>
|
||||
<script type="module">
|
||||
import "/static/js/components/AppCode.js";
|
||||
import "{{ static_url('/static/js/components/AppCode.js') }}";
|
||||
</script>
|
||||
|
||||
@@ -46,5 +46,5 @@ const answer = 42;
|
||||
</dp-content>
|
||||
</div>
|
||||
<script type="module">
|
||||
import "/static/js/components/AppContent.js";
|
||||
import "{{ static_url('/static/js/components/AppContent.js') }}";
|
||||
</script>
|
||||
|
||||
@@ -44,7 +44,7 @@ app.contextMenu.attach(myElement, (event) => [
|
||||
<dp-context-menu id="ctx-menu"></dp-context-menu>
|
||||
</div>
|
||||
<script type="module">
|
||||
import "/static/js/components/AppContextMenu.js";
|
||||
import "{{ static_url('/static/js/components/AppContextMenu.js') }}";
|
||||
const menu = document.getElementById("ctx-menu");
|
||||
const target = document.getElementById("ctx-target");
|
||||
const result = document.getElementById("ctx-result");
|
||||
|
||||
@@ -48,7 +48,7 @@ if (ok) {
|
||||
<dp-dialog id="dialog-demo"></dp-dialog>
|
||||
</div>
|
||||
<script type="module">
|
||||
import "/static/js/components/AppDialog.js";
|
||||
import "{{ static_url('/static/js/components/AppDialog.js') }}";
|
||||
const dialog = document.getElementById("dialog-demo");
|
||||
const result = document.getElementById("dlg-result");
|
||||
document.getElementById("dlg-confirm").addEventListener("click", async () => {
|
||||
|
||||
@@ -36,7 +36,7 @@ The element renders nothing until `show` is called.
|
||||
<dp-toast id="toast-demo"></dp-toast>
|
||||
</div>
|
||||
<script type="module">
|
||||
import "/static/js/components/AppToast.js";
|
||||
import "{{ static_url('/static/js/components/AppToast.js') }}";
|
||||
const toast = document.getElementById("toast-demo");
|
||||
const messages = { info: "Heads up", success: "Saved", error: "Something went wrong" };
|
||||
document.querySelectorAll("[data-toast]").forEach((btn) => {
|
||||
|
||||
@@ -60,5 +60,5 @@ Set with the `mode` attribute:
|
||||
<dp-upload mode="field" field-name="demo" multiple label="Choose files"></dp-upload>
|
||||
</div>
|
||||
<script type="module">
|
||||
import "/static/js/components/AppUpload.js";
|
||||
import "{{ static_url('/static/js/components/AppUpload.js') }}";
|
||||
</script>
|
||||
|
||||
@@ -43,7 +43,7 @@ picker.addEventListener("emoji-click", (event) => {
|
||||
</div>
|
||||
</div>
|
||||
<script type="module">
|
||||
import "/static/vendor/emoji-picker-element/index.js";
|
||||
import "{{ static_url('/static/vendor/emoji-picker-element/index.js') }}";
|
||||
const result = document.getElementById("emoji-demo-result");
|
||||
document.querySelector(".component-demo emoji-picker").addEventListener("emoji-click", (event) => {
|
||||
result.textContent = `Selected: ${event.detail.unicode}`;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div class="docs-content" data-render>
|
||||
# nginx and networking
|
||||
|
||||
The nginx front door, how it serves each route, and the production-specific rules it enforces. See also [Production overview](/docs/production.html) and [Deploy and update](/docs/production-deploy.html).
|
||||
The nginx front door, how it serves each route, and the production-specific rules it enforces. See also [Production overview](/docs/production.html), [Deploy and update](/docs/production-deploy.html), and [Static asset caching and versioning](/docs/static-caching.html).
|
||||
|
||||
## Build and configuration
|
||||
|
||||
@@ -12,8 +12,10 @@ The nginx image (`nginx/Dockerfile`) renders `nginx/nginx.conf.template` at star
|
||||
| Location | Behavior |
|
||||
|----------|----------|
|
||||
| `/static/uploads/` | Served from disk with `Content-Disposition: attachment` and `X-Content-Type-Options: nosniff`. |
|
||||
| `/static/` | Served from disk, immutable, 7-day cache. |
|
||||
| `/static/v<version>/` | Versioned assets served from disk, `public, immutable, max-age=31536000` (1 year). The `<version>` is the server boot timestamp, so a deploy changes every asset URL. See [Static asset caching](/docs/static-caching.html). |
|
||||
| `/static/` | Unversioned fallback served from disk with a short `max-age=3600` (1 hour), not immutable. |
|
||||
| `/devii/ws` | Proxied as a WebSocket (`Upgrade`/`Connection` headers, 1h read/send timeout). |
|
||||
| `/tools/seo/<uid>/ws` | SEO Diagnostics live-progress WebSocket; matched by `location ~ ^/tools/seo/[^/]+/ws$` with the same `Upgrade`/`Connection` headers and 1h timeout. |
|
||||
| `/avatar/` | Proxied to the app (generated SVG avatars). |
|
||||
| `/` | Proxied to the app; optional micro-cache. |
|
||||
|
||||
@@ -34,6 +36,8 @@ map $http_upgrade $connection_upgrade {
|
||||
|
||||
and the `/devii/ws` location forwards `Upgrade $http_upgrade` and `Connection $connection_upgrade` with a long read timeout. The app serves `/devii/ws` only from the worker holding the service lock; other workers close with code 1013 and the auto-reconnecting client lands on the owner, so a connection may take a brief reconnect to settle. See [Multi-worker and concurrency](/docs/production-concurrency.html) for the lock-owner model.
|
||||
|
||||
**Every WebSocket path needs its own location block.** The catch-all `location /` deliberately sets `Connection ""` (no upgrade) and a short 60s read timeout, so any route that falls through to it cannot complete a WebSocket handshake - the browser reports `WebSocket connection failed` with no close code (the upgrade never happens). When you add a new WebSocket endpoint, add a dedicated `location` (exact path or a `~` regex) that forwards `Upgrade $http_upgrade` and `Connection $connection_upgrade` with long timeouts, **above** `location /`. The existing examples are `/devii/ws`, the SEO Diagnostics `location ~ ^/tools/seo/[^/]+/ws$`, and the container ingress `/p/` block. Like Devii, the SEO progress socket is served only by the service-lock owner; a non-owner closes with code 4013 and the client fast-retries until it lands on the owner.
|
||||
|
||||
## Upload size ceiling
|
||||
|
||||
`client_max_body_size` is set from `NGINX_MAX_BODY_SIZE` (default `50m`). It must be **>= the admin-configurable `max_upload_size_mb`** (Admin -> Settings). If the app limit exceeds the nginx limit, nginx rejects the upload with **HTTP 413** before it ever reaches the app. After raising `max_upload_size_mb`, raise `NGINX_MAX_BODY_SIZE` to match and restart nginx.
|
||||
@@ -45,7 +49,8 @@ The server block sets `X-Content-Type-Options`, `X-Frame-Options: DENY`, `X-XSS-
|
||||
## Troubleshooting
|
||||
|
||||
- **Devii terminal will not connect** - confirm the `map` block and the `/devii/ws` location are present in the rendered config (`docker compose exec nginx cat /etc/nginx/conf.d/default.conf`); a connection that closes immediately with 1013 is the lock-owner reconnect, not a failure.
|
||||
- **A WebSocket reports `connection failed` with no close code** - the route has no dedicated upgrade `location` and fell through to `location /`, which strips the upgrade headers. Add a matching `location` block (see WebSockets above) and reload nginx. This is what breaks the SEO Diagnostics live progress if the `location ~ ^/tools/seo/[^/]+/ws$` block is missing.
|
||||
- **Uploads fail with 413** - `NGINX_MAX_BODY_SIZE` is below `max_upload_size_mb`; raise it and restart nginx.
|
||||
- **Uploaded file renders inline instead of downloading** - the `/static/uploads/` location lost its `Content-Disposition` rule.
|
||||
- **Static assets stale after deploy** - the static bind mount is read-only and live; a hard refresh clears the 7-day browser cache.
|
||||
- **Static assets stale after deploy** - every app-owned asset URL carries the boot-version path segment (`/static/v<timestamp>/...`), so a restart busts the cache automatically; if assets still look stale, confirm the app actually restarted (the `<meta name="asset-version">` value in page source changed). See [Static asset caching](/docs/static-caching.html).
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
<div class="docs-content" data-render>
|
||||
{% raw %}
|
||||
# Static asset caching and versioning
|
||||
|
||||
DevPlace serves its CSS and JavaScript with a one-year immutable cache for the best
|
||||
possible "Serve static assets with an efficient cache policy" score, while still letting a
|
||||
deploy take effect immediately. The two goals are reconciled by stamping a **boot-time
|
||||
version** into every app-owned static URL.
|
||||
|
||||
See also [nginx and networking](/docs/production-nginx.html) and
|
||||
[Deploy and update](/docs/production-deploy.html).
|
||||
|
||||
## How it works
|
||||
|
||||
Every static URL the app emits carries a version path segment:
|
||||
|
||||
```
|
||||
/static/css/base.css -> /static/v1718040000/css/base.css
|
||||
/static/js/Application.js -> /static/v1718040000/js/Application.js
|
||||
```
|
||||
|
||||
`v1718040000` is the unix timestamp captured once when the server process starts
|
||||
(`config.STATIC_VERSION`). Because the value is fixed for the life of the process:
|
||||
|
||||
- **Heavy caching is safe.** Each versioned URL is unique per deploy, so the browser may
|
||||
cache it for a full year (`Cache-Control: public, immutable, max-age=31536000`).
|
||||
- **Deploys are instant.** Restarting the server changes the boot timestamp, so every
|
||||
asset URL changes, so every returning browser refetches on its next page load. No manual
|
||||
cache purge, no hashing build step, no waiting for a TTL to expire.
|
||||
|
||||
## Why a path segment, not a query string
|
||||
|
||||
DevPlace ships unbundled ES6 modules wired together with **relative imports**
|
||||
(`import { Http } from "./Http.js"`). A relative import resolves against the URL of the
|
||||
module that contains it. If only the entry `<script>` carried a version (for example a
|
||||
`?v=` query string), its transitively imported modules would resolve to **unversioned**
|
||||
URLs and stay frozen under the immutable cache - a deploy would not propagate JavaScript
|
||||
changes for up to a year.
|
||||
|
||||
Putting the version in the path solves this for free: a module loaded from
|
||||
`/static/v1718040000/js/Application.js` resolves `./Http.js` to
|
||||
`/static/v1718040000/js/Http.js`, so the **entire module graph and every relative CSS
|
||||
`url()`** inherits the version automatically with no per-file rewriting.
|
||||
|
||||
## The helpers
|
||||
|
||||
- **Templates** use the `static_url` Jinja global (registered in `templating.py`):
|
||||
|
||||
```
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/base.css') }}">
|
||||
<script type="module" src="{{ static_url('/static/js/Application.js') }}"></script>
|
||||
```
|
||||
|
||||
It returns the path unchanged for anything outside `/static/` and for `/static/uploads/`
|
||||
(user content, addressed by stable database paths), so it is safe to wrap around dynamic
|
||||
values such as `{{ static_url(og_image or '/static/og-default.png') }}`.
|
||||
|
||||
- **JavaScript** that builds an absolute static URL at runtime (component CSS link
|
||||
injection, lazily loaded xterm/md-clippy modules) uses the `assetUrl` helper
|
||||
(`static/js/assetVersion.js`), which reads the version from the
|
||||
`<meta name="asset-version">` tag rendered in `base.html`:
|
||||
|
||||
```
|
||||
import { assetUrl } from "../assetVersion.js";
|
||||
link.href = assetUrl("/static/css/components.css");
|
||||
```
|
||||
|
||||
Relative module imports never need either helper - they inherit the version from the
|
||||
importing module's URL.
|
||||
|
||||
## Serving and cache headers
|
||||
|
||||
| Layer | Versioned `/static/v<n>/...` | Unversioned `/static/...` |
|
||||
|-------|------------------------------|---------------------------|
|
||||
| App (dev / `make dev`) | `CachedStaticFiles` mount sets `public, immutable, max-age=31536000` | plain mount, validator-based revalidation |
|
||||
| nginx (prod) | regex `location ~ ^/static/v\d+/` sets `public, immutable, max-age=31536000` | `public, max-age=3600` (one hour, not immutable) |
|
||||
|
||||
The unversioned `/static/` location stays a short, non-immutable cache because a few
|
||||
unversioned URLs still exist (the service worker's precache icons, direct hits); they must
|
||||
not be frozen for a year.
|
||||
|
||||
**Service worker exception.** `service-worker.js` is served with `Cache-Control: no-cache`
|
||||
(by its `/service-worker.js` route and, defensively, by `CachedStaticFiles`) so a new
|
||||
worker always deploys. It keeps a stable, unversioned URL so its registration scope does
|
||||
not change on every deploy.
|
||||
|
||||
**Uploads exclusion.** `/static/uploads/` is user-generated content with its own public
|
||||
cache and stable stored paths; it is never boot-versioned.
|
||||
|
||||
## Multi-worker consistency
|
||||
|
||||
In production the app runs multiple uvicorn workers. Each worker is a separate process, so
|
||||
if every worker computed its own timestamp they could disagree by a second and emit
|
||||
mismatched asset URLs. The version is therefore fixed **once at launch** and shared through
|
||||
the `DEVPLACE_STATIC_VERSION` environment variable:
|
||||
|
||||
- `make prod` prefixes the command with `DEVPLACE_STATIC_VERSION=$(date +%s)`.
|
||||
- The Docker image launches uvicorn through `sh -c` so a single `date +%s` is captured and
|
||||
exported before the workers fork.
|
||||
|
||||
When `DEVPLACE_STATIC_VERSION` is unset (dev with `--reload`), each process boot computes a
|
||||
fresh timestamp, so a reload after editing a file naturally produces a new version. You can
|
||||
also pin the variable to a build id or git sha in CI for reproducible URLs.
|
||||
|
||||
## Verify
|
||||
|
||||
```
|
||||
curl -sI http://localhost:10500/static/v$(date +%s)/css/base.css | grep -i cache-control
|
||||
# -> Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
curl -sI http://localhost:10500/service-worker.js | grep -i cache-control
|
||||
# -> Cache-Control: no-cache
|
||||
```
|
||||
|
||||
In a rendered page, the `<meta name="asset-version">` value and every `/static/v.../`
|
||||
URL change after a server restart.
|
||||
{% endraw %}
|
||||
</div>
|
||||
@@ -14,7 +14,7 @@ This is the posts page reduced to its structure. A content page looks like this
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/feed.css">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/feed.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<div class="docs-content" data-render>
|
||||
# SEO Diagnostics
|
||||
|
||||
SEO Diagnostics audits a web page or an entire sitemap against a broad battery of checks, far wider
|
||||
than a single Core Web Vitals or rich-results test. It loads each page in a real headless browser,
|
||||
inspects the rendered DOM, measures performance, and grades the result. Progress streams live while
|
||||
the audit runs, and a full categorised report is produced at the end.
|
||||
|
||||
Open it from the **Tools** menu, or go straight to `/tools/seo`. It is public: you do not need an
|
||||
account.
|
||||
|
||||
## Running an audit
|
||||
|
||||
1. Paste a URL (for a single page) or a `sitemap.xml` URL.
|
||||
2. Choose **Single URL** or **Sitemap**. In sitemap mode, set how many pages to crawl (up to 50).
|
||||
3. Press **Run audit**. Progress appears immediately: each page is loaded, then every check runs.
|
||||
4. When the audit finishes, the score, grade, per-category breakdown and every individual check
|
||||
with its recommendation are shown. A link opens the full standalone report.
|
||||
|
||||
You can run one audit at a time. Targets that resolve to private or local addresses are refused.
|
||||
|
||||
## What gets checked
|
||||
|
||||
The auditor groups its findings into categories:
|
||||
|
||||
- **Crawlability and indexing** - HTTP status, redirect chains, HTTPS and HSTS, canonical tags,
|
||||
meta-robots and `X-Robots-Tag`, `robots.txt`, XML sitemap, URL hygiene, and mixed content.
|
||||
- **On-page meta and content** - title and meta description (presence and length), single H1 and
|
||||
heading hierarchy, content depth, language, character encoding, viewport, and favicon.
|
||||
- **Links** - internal and external link profile, descriptive anchor text.
|
||||
- **Structured data** - JSON-LD validity, recognised schema types and their required properties,
|
||||
microdata and RDFa detection.
|
||||
- **Social cards** - Open Graph and Twitter Card tags for rich link previews.
|
||||
- **Performance and Core Web Vitals** - Largest Contentful Paint, Cumulative Layout Shift, First
|
||||
Contentful Paint, Time To First Byte, page weight, request count, DOM size, compression, caching,
|
||||
image optimisation, and console errors.
|
||||
- **Mobile and accessibility** - responsive layout (no horizontal overflow), tap-target sizing,
|
||||
image alt coverage, and form labels.
|
||||
- **Security** - the common security response headers and TLS.
|
||||
- **AI and LLM-search readiness** - whether your primary content is in the initial HTML (server
|
||||
rendered) rather than JavaScript-only, presence of an `llms.txt`, and semantic HTML landmarks.
|
||||
|
||||
Each check reports a status (pass, warn, fail or info), its severity, the observed value, and a
|
||||
recommendation when something can be improved. The overall score is a severity-weighted pass rate,
|
||||
shown as a 0-100 number and an A to F grade, with a subscore per category.
|
||||
|
||||
## Scoring
|
||||
|
||||
Failing a critical check (for example a non-200 status or HTTPS) costs far more than a low-severity
|
||||
warning. Informational checks never affect the score. A score of 90 or above is an A.
|
||||
|
||||
## Ask Devii
|
||||
|
||||
You can also run an audit in plain language through the Devii assistant:
|
||||
|
||||
> Run SEO diagnostics on https://example.com and tell me the score.
|
||||
|
||||
Devii queues the audit, polls it, and reports the score, grade and a link to the full report.
|
||||
|
||||
## Programmatic access
|
||||
|
||||
The same audit is available over the API: `POST /tools/seo/run` to queue, `GET /tools/seo/{uid}`
|
||||
to poll, and `GET /tools/seo/{uid}/report` for the full report (HTML or JSON). See the
|
||||
[Tools (SEO Diagnostics)](/docs/tools.html) API group for request and response shapes.
|
||||
</div>
|
||||
|
||||
<div class="devii-doc-cta">
|
||||
<a href="/tools/seo" class="sidebar-link">Open SEO Diagnostics</a>
|
||||
</div>
|
||||
@@ -1,8 +1,8 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/sidebar.css">
|
||||
<link rel="stylesheet" href="/static/css/docs.css">
|
||||
<link rel="stylesheet" href="/static/css/components-docs.css">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/sidebar.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/docs.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/components-docs.css') }}">
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<script>window.DEVPLACE_DOCS = {{ {
|
||||
@@ -60,5 +60,5 @@
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block extra_js %}
|
||||
<script type="module" src="/static/js/ApiDocs.js"></script>
|
||||
<script type="module" src="{{ static_url('/static/js/ApiDocs.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import modal %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/feed.css">
|
||||
<link rel="stylesheet" href="/static/css/sidebar.css">
|
||||
<link rel="stylesheet" href="/static/css/post.css">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/feed.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/sidebar.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/post.css') }}">
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<h1 class="sr-only">Feed</h1>
|
||||
@@ -29,18 +29,25 @@
|
||||
<span class="icon">❓</span>
|
||||
Question
|
||||
</a>
|
||||
<a href="/feed?topic=rant" class="sidebar-link {% if current_topic == 'rant' %}active{% endif %}" data-topic="rant">
|
||||
<span class="icon">💢</span>
|
||||
Rant
|
||||
</a>
|
||||
<a href="/feed?topic=fun" class="sidebar-link {% if current_topic == 'fun' %}active{% endif %}" data-topic="fun">
|
||||
<span class="icon">🎮</span>
|
||||
Fun
|
||||
</a>
|
||||
<a href="/feed?topic=signals" class="sidebar-link {% if current_topic == 'signals' %}active{% endif %}" data-topic="signals">
|
||||
<span class="icon">📡</span>
|
||||
Signals
|
||||
</a>
|
||||
<details class="sidebar-more" {% if current_topic in ['rant', 'fun', 'signals'] %}open{% endif %}>
|
||||
<summary class="sidebar-link sidebar-more-toggle">
|
||||
<span class="icon">…</span>
|
||||
More
|
||||
<span class="sidebar-more-chevron">›</span>
|
||||
</summary>
|
||||
<a href="/feed?topic=rant" class="sidebar-link {% if current_topic == 'rant' %}active{% endif %}" data-topic="rant">
|
||||
<span class="icon">💢</span>
|
||||
Rant
|
||||
</a>
|
||||
<a href="/feed?topic=fun" class="sidebar-link {% if current_topic == 'fun' %}active{% endif %}" data-topic="fun">
|
||||
<span class="icon">🎮</span>
|
||||
Fun
|
||||
</a>
|
||||
<a href="/feed?topic=signals" class="sidebar-link {% if current_topic == 'signals' %}active{% endif %}" data-topic="signals">
|
||||
<span class="icon">📡</span>
|
||||
Signals
|
||||
</a>
|
||||
</details>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -70,6 +77,9 @@
|
||||
<aside class="feed-right">
|
||||
<div class="daily-topic-card">
|
||||
<div class="daily-topic-label">Daily Topic</div>
|
||||
{% if daily_topic.get('image_url', '') %}
|
||||
<img src="{{ daily_topic['image_url'] }}" alt="{{ daily_topic.get('title', '') }}" loading="lazy" class="daily-topic-image">
|
||||
{% endif %}
|
||||
<h4>{{ daily_topic.get('title', '') }}</h4>
|
||||
<p>{{ daily_topic.get('summary', '') }}</p>
|
||||
<div class="topic-links">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/auth.css">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/auth.css') }}">
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="auth-page">
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import modal %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/gists.css">
|
||||
<link rel="stylesheet" href="/static/css/post.css">
|
||||
<link rel="stylesheet" href="/static/vendor/codemirror/codemirror.min.css">
|
||||
<link rel="stylesheet" href="/static/vendor/codemirror/theme/monokai.min.css">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/gists.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/post.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/vendor/codemirror/codemirror.min.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/vendor/codemirror/theme/monokai.min.css') }}">
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="gist-detail-page">
|
||||
@@ -98,28 +98,28 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script defer src="/static/vendor/codemirror/codemirror.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/python/python.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/javascript/javascript.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/clike/clike.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/htmlmixed/htmlmixed.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/xml/xml.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/css/css.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/go/go.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/rust/rust.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/sql/sql.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/shell/shell.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/yaml/yaml.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/markdown/markdown.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/swift/swift.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/php/php.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/ruby/ruby.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/haskell/haskell.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/lua/lua.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/perl/perl.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/r/r.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/dart/dart.min.js"></script>
|
||||
<script type="module" src="/static/js/GistEditor.js"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/codemirror.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/python/python.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/javascript/javascript.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/clike/clike.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/htmlmixed/htmlmixed.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/xml/xml.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/css/css.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/go/go.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/rust/rust.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/sql/sql.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/shell/shell.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/yaml/yaml.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/markdown/markdown.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/swift/swift.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/php/php.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/ruby/ruby.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/haskell/haskell.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/lua/lua.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/perl/perl.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/r/r.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/dart/dart.min.js') }}"></script>
|
||||
<script type="module" src="{{ static_url('/static/js/GistEditor.js') }}"></script>
|
||||
<script type="module">
|
||||
const editModal = document.getElementById("edit-gist-modal");
|
||||
if (editModal) {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import modal %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/feed.css">
|
||||
<link rel="stylesheet" href="/static/css/gists.css">
|
||||
<link rel="stylesheet" href="/static/css/sidebar.css">
|
||||
<link rel="stylesheet" href="/static/vendor/codemirror/codemirror.min.css">
|
||||
<link rel="stylesheet" href="/static/vendor/codemirror/theme/monokai.min.css">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/feed.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/gists.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/sidebar.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/vendor/codemirror/codemirror.min.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/vendor/codemirror/theme/monokai.min.css') }}">
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="gists-layout">
|
||||
@@ -129,27 +129,27 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script defer src="/static/vendor/codemirror/codemirror.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/python/python.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/javascript/javascript.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/clike/clike.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/htmlmixed/htmlmixed.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/xml/xml.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/css/css.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/go/go.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/sql/sql.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/shell/shell.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/yaml/yaml.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/markdown/markdown.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/swift/swift.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/php/php.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/ruby/ruby.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/haskell/haskell.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/lua/lua.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/perl/perl.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/r/r.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/dart/dart.min.js"></script>
|
||||
<script type="module" src="/static/js/GistEditor.js"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/codemirror.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/python/python.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/javascript/javascript.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/clike/clike.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/htmlmixed/htmlmixed.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/xml/xml.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/css/css.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/go/go.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/sql/sql.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/shell/shell.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/yaml/yaml.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/markdown/markdown.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/swift/swift.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/php/php.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/ruby/ruby.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/haskell/haskell.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/lua/lua.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/perl/perl.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/r/r.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/dart/dart.min.js') }}"></script>
|
||||
<script type="module" src="{{ static_url('/static/js/GistEditor.js') }}"></script>
|
||||
<script type="module">
|
||||
const gistEditor = new window.GistEditor();
|
||||
const gistModal = document.getElementById("create-gist-modal");
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{% extends "base.html" %}
|
||||
{% block footer %}{% endblock %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/landing.css">
|
||||
<link rel="stylesheet" href="/static/css/news.css">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/landing.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/news.css') }}">
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="landing">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/feed.css">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/feed.css') }}">
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="leaderboard-page">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/auth.css">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/auth.css') }}">
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="auth-page">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/messages.css">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/messages.css') }}">
|
||||
{% endblock %}
|
||||
{% block footer %}{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/news.css">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/news.css') }}">
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="news-page">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/news.css">
|
||||
<link rel="stylesheet" href="/static/css/post.css">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/news.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/post.css') }}">
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="news-detail">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/notifications.css">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/notifications.css') }}">
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="notifications-page">
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import modal %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/feed.css">
|
||||
<link rel="stylesheet" href="/static/css/post.css">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/feed.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/post.css') }}">
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="post-page">
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/feed.css">
|
||||
<link rel="stylesheet" href="/static/css/profile.css">
|
||||
<link rel="stylesheet" href="/static/css/projects.css">
|
||||
<link rel="stylesheet" href="/static/css/gists.css">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/feed.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/profile.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/projects.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/gists.css') }}">
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="profile-layout">
|
||||
@@ -424,7 +424,7 @@
|
||||
{% endblock %}
|
||||
{% block extra_js %}
|
||||
<script type="module">
|
||||
import { ProfileTabs } from "/static/js/ProfileTabs.js";
|
||||
import { ProfileTabs } from "{{ static_url('/static/js/ProfileTabs.js') }}";
|
||||
const tabs = document.querySelector("[data-profile-tabs]");
|
||||
if (tabs) {
|
||||
new ProfileTabs(tabs);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import modal %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/projects.css">
|
||||
<link rel="stylesheet" href="/static/css/post.css">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/projects.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/post.css') }}">
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="project-detail-page">
|
||||
@@ -177,7 +177,7 @@
|
||||
{% endblock %}
|
||||
{% block extra_js %}
|
||||
<script type="module">
|
||||
import { ProjectActionsMenu } from "/static/js/ProjectActionsMenu.js";
|
||||
import { ProjectActionsMenu } from "{{ static_url('/static/js/ProjectActionsMenu.js') }}";
|
||||
const actions = document.querySelector(".project-detail-actions");
|
||||
if (actions) {
|
||||
new ProjectActionsMenu(actions);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/vendor/codemirror/codemirror.min.css">
|
||||
<link rel="stylesheet" href="/static/vendor/codemirror/theme/monokai.min.css">
|
||||
<link rel="stylesheet" href="/static/css/project_files.css">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/vendor/codemirror/codemirror.min.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/vendor/codemirror/theme/monokai.min.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/project_files.css') }}">
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="pf-page" id="project-files" data-slug="{{ project['slug'] or project['uid'] }}" data-owner="{{ 1 if is_owner and not read_only else 0 }}">
|
||||
@@ -50,28 +50,28 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script defer src="/static/vendor/codemirror/codemirror.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/python/python.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/javascript/javascript.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/clike/clike.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/htmlmixed/htmlmixed.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/xml/xml.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/css/css.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/go/go.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/sql/sql.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/shell/shell.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/yaml/yaml.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/markdown/markdown.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/swift/swift.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/php/php.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/ruby/ruby.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/haskell/haskell.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/lua/lua.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/perl/perl.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/r/r.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/dart/dart.min.js"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/codemirror.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/python/python.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/javascript/javascript.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/clike/clike.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/htmlmixed/htmlmixed.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/xml/xml.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/css/css.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/go/go.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/sql/sql.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/shell/shell.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/yaml/yaml.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/markdown/markdown.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/swift/swift.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/php/php.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/ruby/ruby.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/haskell/haskell.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/lua/lua.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/perl/perl.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/r/r.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/codemirror/mode/dart/dart.min.js') }}"></script>
|
||||
<script type="module">
|
||||
import { ProjectFiles } from "/static/js/ProjectFiles.js";
|
||||
import { ProjectFiles } from "{{ static_url('/static/js/ProjectFiles.js') }}";
|
||||
const root = document.getElementById("project-files");
|
||||
if (root) {
|
||||
const files = new ProjectFiles(root);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import modal %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/feed.css">
|
||||
<link rel="stylesheet" href="/static/css/projects.css">
|
||||
<link rel="stylesheet" href="/static/css/sidebar.css">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/feed.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/projects.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/sidebar.css') }}">
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="projects-layout">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/auth.css">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/auth.css') }}">
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="auth-page">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/feed.css">
|
||||
<link rel="stylesheet" href="/static/css/post.css">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/feed.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/post.css') }}">
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="saved-page">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "admin_base.html" %}
|
||||
{% block extra_head %}
|
||||
{{ super() }}
|
||||
<link rel="stylesheet" href="/static/css/services.css">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/services.css') }}">
|
||||
{% endblock %}
|
||||
{% block admin_content %}
|
||||
{% set svc = service %}
|
||||
@@ -97,7 +97,7 @@
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block extra_js %}
|
||||
<script type="module" src="/static/js/ServiceMonitor.js"></script>
|
||||
<script type="module" src="{{ static_url('/static/js/ServiceMonitor.js') }}"></script>
|
||||
<script type="module">
|
||||
new window.ServiceMonitor().start();
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "admin_base.html" %}
|
||||
{% block extra_head %}
|
||||
{{ super() }}
|
||||
<link rel="stylesheet" href="/static/css/services.css">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/services.css') }}">
|
||||
{% endblock %}
|
||||
{% block admin_content %}
|
||||
<div class="admin-toolbar">
|
||||
@@ -44,7 +44,7 @@
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block extra_js %}
|
||||
<script type="module" src="/static/js/ServiceMonitor.js"></script>
|
||||
<script type="module" src="{{ static_url('/static/js/ServiceMonitor.js') }}"></script>
|
||||
<script type="module">
|
||||
new window.ServiceMonitor().start();
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/auth.css">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/auth.css') }}">
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="auth-page">
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/seo-tool.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="tools-page">
|
||||
<header class="tools-header">
|
||||
<h1>Tools</h1>
|
||||
<p>Practical developer tools, available to everyone.</p>
|
||||
</header>
|
||||
<div class="tools-grid">
|
||||
{% for tool in tools %}
|
||||
<a href="{{ tool.url }}" class="tool-card">
|
||||
<span class="tool-card-icon">{{ tool.icon }}</span>
|
||||
<span class="tool-card-name">{{ tool.name }}</span>
|
||||
<span class="tool-card-desc">{{ tool.description }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user