commit f290a4c7668d484b9083dd232bffc9604fe521e2 Author: retoor Date: Mon Oct 5 09:36:20 2026 +0200 Molodetz blogrol volgens DPP-template diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..8355858 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,12 @@ +# retoor +[run] +source = molodetz +parallel = False +sigterm = True +omit = + tests/* + sitecustomize.py + +[report] +show_missing = True +skip_empty = True diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..401f4a8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +# retoor +.venv +data +out +.env +.git diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8bdc2a9 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,19 @@ +# retoor +root = true + +[*] +indent_style = space +indent_size = 4 +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{js,css,html}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..73f1398 --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# retoor +# Copy to .env (chmod 600). Never commit .env. +SECRET_KEY=change-me-to-a-long-random-string +MOLODETZ_ADMIN_USERNAME=retoor +MOLODETZ_ADMIN_EMAIL=retoor@molodetz.nl +MOLODETZ_ADMIN_PASSWORD= +MOLODETZ_PORT=8088 +MOLODETZ_TEST_PORT=8099 +MOLODETZ_SITE_URL= +MOLODETZ_DATA_DIR= +MOLODETZ_DISABLE_SERVICES= +MOLODETZ_DISABLE_RATE_LIMIT= +MOLODETZ_RATE_LIMIT=60 +MOLODETZ_TEMPLATE_AUTO_RELOAD=1 +MOLODETZ_STATIC_VERSION= +MOLODETZ_LOG_LEVEL=INFO +MOLODETZ_OUTBOUND_PROXY_URL= +MOLODETZ_BACKUP_OFFLOAD_REMOTE= +MOLODETZ_SITEMAP_TTL=3600 +MOLODETZ_LANDING_TTL=30 +MOLODETZ_UNREAD_TTL=10 +PLAYWRIGHT_HEADLESS=1 +PLAYWRIGHT_SLOW_MO=0 diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..2b3157a --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# retoor +set -euo pipefail +if git rev-parse -q --verify MERGE_HEAD >/dev/null; then + exit 0 +fi +if git diff --cached -U0 -- pyproject.toml | grep -q '^+version = '; then + exit 0 +fi +python3 - <<'PY' +import re +from pathlib import Path +path = Path("pyproject.toml") +text = path.read_text(encoding="utf-8") +match = re.search(r'^version = "(\d+)\.(\d+)\.(\d+)"', text, re.M) +if match: + major, minor, patch = map(int, match.groups()) + text = text.replace(match.group(0), f'version = "{major}.{minor}.{patch + 1}"', 1) + path.write_text(text, encoding="utf-8") +PY +git add pyproject.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7812384 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# retoor +.venv/ +data/ +out/ +.env +__pycache__/ +*.pyc +*.egg-info/ +.pytest_cache/ +.coverage +.coverage.* diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..4278f27 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,27 @@ + +# Architectuur + +## Lagen + +- `molodetz/main.py`: FastAPI-object (`/swagger`, `/openapi.json`), lifespan (init-lock, `init_db`, bootstrap, services met service-lock), statische mounts (uploads, versioned `/static/v/`, fallback), middleware in templatevolgorde, exception handlers, routers. +- `molodetz/config.py`: enige plek voor `load_dotenv`, paden, env-bindingen met prefix `MOLODETZ_`, `APP_VERSION` via `tomllib`, `BOOT_ID`, `STATIC_VERSION`. +- `molodetz/database/`: synchrone `dataset`-handle (SQLAlchemy NullPool, WAL-PRAGMA's), per-tabel modules, soft delete met registries, `init_db` met indexen en `ANALYZE`. Geen async driver; routes lopen in de threadpool waar nodig. +- `molodetz/routers/`: een module per oppervlak. Elke HTML-route geeft JSON bij `Accept: application/json` via `responses.respond` en een Pydantic-uitvoerschema uit `schemas/`. +- `molodetz/templating.py`: de enige `Jinja2Templates`. Globals `static_url`, `local_dt`, `avatar_url`, `render_content` en meer. +- `molodetz/rendering.py`: server-side markdown (mistune, escape aan), emoji, URL-allowlist, streepjesnormalisatie. Gepubliceerde inhoud wordt altijd server-side gerenderd. +- `molodetz/stealth.py`: enige fabriek voor uitgaande HTTP-clients (httpx met curl_cffi-transport, `net_guard` SSRF-bescherming). `link_check.py` gebruikt hem voor de repo-linkcontrole bij aanmeldingen. +- `molodetz/services/`: achtergrondservices (presence, housekeeping, backup) onder een `ServiceManager`, zichtbaar en bestuurbaar via `/admin/services`. +- `molodetz/gallery.py`: flyers en memes uit `static/media/`, Pillow-thumbnails (webp) als blobs onder `data/uploads`, perceptuele hash (imagehash) tegen duplicaten. +- `molodetz/cli/`: argparse-CLI `molodetz`. +- `molodetz/static/js/`: ES6-modules zonder bundler. `Application.js` laadt `components/index.js` (custom elements in light DOM) en zet singletons op `window.app`. Vendor: marked, DOMPurify, highlight.js als ES-modules in `static/vendor/`. +- `molodetz/static/css/`: handgeschreven, tokens in `variables.css`, breakpoints exact 360/480/768/1024. + +## Auth + +Sessiecookie (HttpOnly, SameSite=Lax, Secure achter TLS), rijen in `sessions`. Daarnaast `X-API-KEY`, `Authorization: Bearer ` en Basic voor scripts. Wachtwoorden met passlib PBKDF2-SHA256. Geen JWT, geen OAuth. De eerste gebruiker is de beheerder uit `.env`; er is geen publieke registratie. + +## Datastromen + +- Schrijven: beheerder -> `/admin/posts` -> `content.create_post` / `edit_post` -> SQLite -> server-render bij lezen (lru-cache op tekst). +- Aanmelden: bezoeker -> `POST /binnen` (honeypot, rate limit) -> `join_requests` -> melding aan beheerders -> `/admin/joins`. +- Galerij: start -> `sync_gallery` -> `media`-tabel + thumbnails -> `/flyers`, `/memes` met lightbox. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..42ca3eb --- /dev/null +++ b/Makefile @@ -0,0 +1,124 @@ +# retoor +SHELL := /bin/bash +export PYTHONDONTWRITEBYTECODE := 1 + +PY := .venv/bin/python +PIP := .venv/bin/pip +STAMP := .venv/.stamp +PKG := molodetz +HOST ?= 127.0.0.1 +PORT ?= 8088 +WORKERS ?= 2 +LOCUST_PORT ?= 8097 +LOCUST_DATA := /tmp/molodetz-locust-data +PYTEST := $(PY) -m pytest + +.PHONY: install dev prod test test-headed test-unit test-api test-e2e test-fast test-failed \ + test-first-failure test-slowest test-cache-clean check-ui preflight coverage coverage-headed \ + coverage-html locust locust-headless prune prune-dry-run tree tree-loc zip delete-pyc clean + +$(STAMP): pyproject.toml + python3 -m venv .venv + $(PIP) install --upgrade pip + $(PIP) install -e ".[dev]" + $(PY) -m playwright install chromium + touch $(STAMP) + +install: $(STAMP) + git config core.hooksPath .githooks || true + +dev: $(STAMP) + $(PY) -m uvicorn $(PKG).main:app --host $(HOST) --port $(PORT) --reload --reload-dir $(PKG) --backlog 4096 + +prod: $(STAMP) + MOLODETZ_STATIC_VERSION=$$(date +%s) MOLODETZ_TEMPLATE_AUTO_RELOAD=0 $(PY) -m uvicorn $(PKG).main:app --host $(HOST) --port $(PORT) \ + --workers $(WORKERS) --backlog 8192 --proxy-headers --forwarded-allow-ips='*' + +test: $(STAMP) + $(PYTEST) + +test-headed: $(STAMP) + PLAYWRIGHT_HEADLESS=0 PLAYWRIGHT_SLOW_MO=150 $(PYTEST) + +test-unit: $(STAMP) + $(PYTEST) tests/unit + +test-api: $(STAMP) + $(PYTEST) tests/api + +test-e2e: $(STAMP) + $(PYTEST) tests/e2e + +test-fast: $(STAMP) + $(PYTEST) tests/unit tests/api + +test-failed: $(STAMP) + $(PYTEST) --lf + +test-first-failure: $(STAMP) + $(PYTEST) -x + +test-slowest: $(STAMP) + $(PYTEST) --durations=40 + +test-cache-clean: + rm -rf .pytest_cache + +check-ui: $(STAMP) + $(PY) -m $(PKG).gates + +preflight: $(STAMP) + $(PY) -c "import $(PKG).main; print('import ok')" + $(PY) -m pyflakes $(PKG) tests + for f in $$(find $(PKG)/static/js -name '*.js'); do node --check "$$f" 2>/dev/null || node --input-type=module --check < "$$f" || exit 1; done + $(PY) -m $(PKG).cli system sql-lint + $(MAKE) check-ui + +coverage: $(STAMP) + rm -f .coverage .coverage.* + COVERAGE_PROCESS_START=$(CURDIR)/.coveragerc PYTHONPATH=$(CURDIR) $(PY) -m coverage run -m pytest + $(PY) -m coverage combine || true + $(PY) -m coverage report + +coverage-headed: $(STAMP) + PLAYWRIGHT_HEADLESS=0 $(MAKE) coverage + +coverage-html: coverage + $(PY) -m coverage html -d out/coverage + +locust: $(STAMP) + $(MAKE) locust-run LOCUST_ARGS="" + +locust-headless: $(STAMP) + $(MAKE) locust-run LOCUST_ARGS="--headless -u 20 -r 5 -t 120s --html out/locust.html" + +locust-run: + rm -rf $(LOCUST_DATA); mkdir -p $(LOCUST_DATA) out + -fuser -k $(LOCUST_PORT)/tcp 2>/dev/null + MOLODETZ_DATA_DIR=$(LOCUST_DATA) MOLODETZ_ADMIN_PASSWORD=locust-$$RANDOM$$RANDOM MOLODETZ_DISABLE_RATE_LIMIT=1 \ + $(PY) -m uvicorn $(PKG).main:app --host 127.0.0.1 --port $(LOCUST_PORT) --workers 2 & echo $$! > $(LOCUST_DATA)/server.pid + for i in $$(seq 1 60); do curl -fs http://127.0.0.1:$(LOCUST_PORT)/health >/dev/null && break; sleep 0.5; done + -$(PY) -m locust -f locustfile.py --host http://127.0.0.1:$(LOCUST_PORT) $(LOCUST_ARGS) + -kill $$(cat $(LOCUST_DATA)/server.pid) + rm -rf $(LOCUST_DATA) + +prune: $(STAMP) + bin/maintenance.sh + +prune-dry-run: $(STAMP) + bin/maintenance.sh --dry-run + +tree: + find . -path ./.venv -prune -o -path ./.git -prune -o -path ./data -prune -o -type f -print | sort + +tree-loc: + find $(PKG) tests -name '*.py' -o -name '*.js' -not -path '*/vendor/*' -not -path '*/generated/*' | xargs wc -l | sort -n + +zip: + mkdir -p out && git archive --format=zip -o out/$(PKG).zip HEAD + +delete-pyc: + find . -path ./.venv -prune -o \( -name '__pycache__' -o -name '*.pyc' \) -exec rm -rf {} + + +clean: delete-pyc + rm -rf .venv build dist *.egg-info .pytest_cache .coverage .coverage.* out/coverage diff --git a/README.md b/README.md new file mode 100644 index 0000000..ab4c675 --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ + +# Molodetz + +Blogrol van de Molodetz-community. Rustig, serif, licht provocerend. Berichten zijn markdown in SQLite, geschreven door retoor via een beheeromgeving. Flyers en memes zijn galerijen. Binnen is een echt aanmeldformulier ("Ik schrijf mee"). + +Gebouwd volgens de bevroren DPP-template (`/workspace/dpp/dptemplate.md`). Zie `SELECTION.md` voor de modulekeuze en `COMPLIANCE.md` voor de toetsing. + +## Starten + +``` +make install # venv in .venv, pip install -e ".[dev]", Chromium, git hooks +cp .env.example .env # alleen als er nog geen .env is; chmod 600 .env +make dev # http://127.0.0.1:8088, reload +make prod # workers, gepinde static-versie +``` + +Zonder make: + +``` +.venv/bin/python -m uvicorn molodetz.main:app --host 127.0.0.1 --port 8088 +``` + +De eerste start maakt de beheerder aan uit `MOLODETZ_ADMIN_USERNAME` / `MOLODETZ_ADMIN_EMAIL` / `MOLODETZ_ADMIN_PASSWORD` in `.env`, zaait de placeholderberichten eenmalig en synchroniseert de galerij. Inloggen via `/auth/login`, beheer via `/admin`. + +## Testen + +``` +make test # unit + api + e2e (Playwright, headless) +make test-headed # zichtbaar met slow motion +make check-ui # zes statische poorten + em-dash + json_error-volgorde +make preflight # import, pyflakes, JS-syntax, sqlglot-lint, check-ui +make coverage +make locust-headless +``` + +## CLI + +`molodetz --help`: rollen, API-sleutels, posts list/import (.md en .pdf via pypdf), aanmeldingen, back-ups, prunes, sql-lint, emoji regenerate. + +## Data + +Alles onder `data/` (database `molodetz.db`, uploads, back-ups, sleutels, locks). `.env` en `data/` staan in `.gitignore`. diff --git a/SELECTION.md b/SELECTION.md new file mode 100644 index 0000000..f55e7c0 --- /dev/null +++ b/SELECTION.md @@ -0,0 +1,42 @@ + +# Feature selection: Molodetz + +Package: `molodetz` +CLI: `molodetz` +Env prefix: `MOLODETZ` +Template: `/workspace/dpp/dptemplate.md` (frozen, section 0.1) + +Calm elite serif community blog roll for retoor. Dutch. Posts, +flyers, memes, people, join intent. Admin authoring via sessions. + +This file is the only place this project narrows the template +(section 0.3). Narrowing means omitting unselected modules, +never altering rules. + +| Section | Class | Selected | Date | Reason | +|---------|-------|----------|------|--------| +| 10 Search optimization | CONVENTION | yes | 2026-10-05 | Public blog; robots + sitemap + base SEO (section 10) | +| 12 Files and blobs | CONVENTION | yes | 2026-10-05 | Gallery images under static; uploads path ready (section 12) | +| 13 Outbound HTTP | CONVENTION | yes | 2026-10-05 | Stealth factory shipped; no live outbound at launch (section 13) | +| 14.3 AI text services | OPTIONAL | no | 2026-10-05 | OPTIONAL omitted (section 0.2 minimal-implementation) | +| 14.4 Async job services | CONVENTION | no | 2026-10-05 | No durable job kinds yet (section 0.2) | +| 15 AI gateway | OPTIONAL | no | 2026-10-05 | OPTIONAL omitted (section 0.2) | +| 16 Assistant | OPTIONAL | no | 2026-10-05 | OPTIONAL omitted (section 0.2) | +| 17 Realtime | MIXED | yes | 2026-10-05 | MIXED: presence CORE only; messaging/pubsub deferred | +| 18 Moderation and safety | OPTIONAL | no | 2026-10-05 | OPTIONAL omitted; join form is owner-private ledger | +| 20.2 Points, levels, badges | OPTIONAL | no | 2026-10-05 | OPTIONAL omitted (section 0.2) | +| 22 Push providers | OPTIONAL | no | 2026-10-05 | OPTIONAL omitted (section 0.2) | +| 24.1 Containers | OPTIONAL | no | 2026-10-05 | OPTIONAL omitted (section 0.2) | +| 24.2 Content import | OPTIONAL | no | 2026-10-05 | OPTIONAL omitted (section 0.2) | +| 24.3 Synthetic-user fleet | OPTIONAL | no | 2026-10-05 | OPTIONAL omitted (section 0.2) | +| 24.4 Issue tracker | OPTIONAL | no | 2026-10-05 | OPTIONAL omitted (section 0.2) | +| 24.5 Compatibility shim | OPTIONAL | no | 2026-10-05 | OPTIONAL omitted (section 0.2) | +| 24.6 Read-only data API | OPTIONAL | no | 2026-10-05 | OPTIONAL omitted (section 0.2) | +| 24.7 Legacy RPC bridge | OPTIONAL | no | 2026-10-05 | OPTIONAL omitted (section 0.2) | +| 24.8 Play modules | OPTIONAL | no | 2026-10-05 | OPTIONAL omitted (section 0.2) | +| 24.9 Diagnostics and research | OPTIONAL | no | 2026-10-05 | OPTIONAL omitted (section 0.2) | +| 24.10 Onboarding prompt | OPTIONAL | no | 2026-10-05 | OPTIONAL omitted (section 0.2) | +| 28.2 Docker | OPTIONAL | no | 2026-10-05 | Bare metal only for this deploy (section 28.1) | +| 31 Correctness verification | CONVENTION | no | 2026-10-05 | No spendable resource / multi-path race features yet | + +Optional libraries (chromadb, weasyprint, cairosvg, faker): not selected. diff --git a/bin/maintenance.sh b/bin/maintenance.sh new file mode 100755 index 0000000..04c6ccc --- /dev/null +++ b/bin/maintenance.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# retoor +set -euo pipefail +cd "$(dirname "$0")/.." +DRY="" +if [[ "${1:-}" == "--dry-run" ]]; then + DRY="--dry-run" +fi +PY=.venv/bin/python +DATA="${MOLODETZ_DATA_DIR:-data}" +echo "Schijfgebruik voor:"; du -sh "$DATA" 2>/dev/null || true +$PY -m molodetz.cli sessions prune $DRY +$PY -m molodetz.cli audit prune $DRY +$PY -m molodetz.cli backups prune $DRY +$PY -m molodetz.cli system prune $DRY +echo "Schijfgebruik na:"; du -sh "$DATA" 2>/dev/null || true diff --git a/events.md b/events.md new file mode 100644 index 0000000..f31fd8d --- /dev/null +++ b/events.md @@ -0,0 +1,40 @@ + +# Audit event catalogue + +Every audited action goes through `molodetz/utils/audit.py` (`record` for requests, `record_system` for services, CLI and bootstrap). Rows land in `audit_log` with typed links in `audit_links`. Category comes from the key prefix (`CATEGORY_BY_PREFIX`). + +| Key | Category | Emitted by | Result values | +|---|---|---|---| +| `auth.login` | auth | `routers/auth.py` login | ok | +| `auth.login.failed` | auth | `routers/auth.py` login | denied | +| `auth.logout` | auth | `routers/auth.py` logout | ok | +| `auth.terms.accept` | auth | `routers/voorwaarden.py` | ok | +| `admin.bootstrap` | admin | `bootstrap.py` first admin from env | ok | +| `admin.settings.save` | admin | `routers/admin/settings.py` | ok | +| `admin.user.role` | admin | `routers/admin/users.py`, CLI `role set` | ok, denied | +| `admin.trash.restore` | admin | `routers/admin/trash.py` | ok | +| `admin.trash.purge` | admin | `routers/admin/trash.py` | ok | +| `post.create` | content | `content.create_post` | ok | +| `post.edit` | content | `content.edit_post` | ok | +| `post.edit.denied` | content | `routers/admin/posts.py` | denied | +| `post.delete` | content | `content.delete_post` (soft delete) | ok | +| `post.delete.denied` | content | `routers/admin/posts.py` | denied | +| `post.seed` | content | `bootstrap.py` placeholder seed | ok | +| `join.request.create` | community | `routers/binnen.py` | ok | +| `join.request.honeypot` | community | `routers/binnen.py` honeypot hit | denied | +| `join.request.status` | community | `routers/admin/joins.py` | ok | +| `join.request.check` | community | `routers/admin/joins.py` repo-link check | ok, error | +| `security.authorization.denied` | security | `utils/auth.py` guards | denied | +| `security.ratelimit.exceeded` | security | `main.py` rate-limit middleware | denied | +| `security.crawler.blocked` | security | `main.py` crawler guard | denied | +| `security.maintenance.blocked` | security | `main.py` maintenance gate | denied | +| `security.suspension.refused` | security | `utils/auth.py refuse_suspended` | denied | +| `service.command.start` / `.stop` / `.run` / `.clear` | service | `routers/admin/services.py` | ok | +| `service.config.save` | service | `routers/admin/services.py` | ok | +| `backup.enqueue` | service | `routers/admin/backups.py` | ok | +| `backup.download` | service | `routers/admin/backups.py` (primary admin only) | ok, denied | +| `backup.finished` | service | `services/backup.py` | ok | +| `backup.failed` | service | `services/backup.py` | error | +| `profile.apikey.regenerate` | account | `routers/profile.py`, CLI `apikey reset` | ok | +| `profile.avatar.regenerate` | account | `routers/profile.py` | ok | +| `notification.create` | system | `utils/notifications.py` funnel | ok | diff --git a/hooks/prod_guard.py b/hooks/prod_guard.py new file mode 100644 index 0000000..a73d356 --- /dev/null +++ b/hooks/prod_guard.py @@ -0,0 +1,22 @@ +# retoor +import json +import re +import sys + +PROTECTED = re.compile(r"(\bdata/|molodetz\.db|\.env\b|rm\s+-rf\s+/|DROP\s+TABLE|git\s+push\s+--force)", re.IGNORECASE) + + +def main(): + try: + event = json.load(sys.stdin) + except json.JSONDecodeError: + return 0 + command = json.dumps(event.get("tool_input", event)) + if PROTECTED.search(command): + print("prod_guard: command touches production data or secrets; confirm manually.", file=sys.stderr) + return 2 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/locustfile.py b/locustfile.py new file mode 100644 index 0000000..eca3051 --- /dev/null +++ b/locustfile.py @@ -0,0 +1,39 @@ +# retoor +import random + +from locust import HttpUser, between, task + +PAGES = ["/", "/rol", "/standaard", "/flyers", "/memes", "/mensen", "/binnen", "/docs"] + + +class Reader(HttpUser): + wait_time = between(0.5, 2.0) + + def on_start(self): + response = self.client.get("/rol", headers={"Accept": "application/json"}, name="/rol [json]") + self.slugs = [post["slug"] for post in response.json().get("posts", [])] if response.ok else [] + + @task(6) + def home(self): + self.client.get("/") + + @task(4) + def listing(self): + self.client.get(random.choice(PAGES), name="[page]") + + @task(3) + def post(self): + if self.slugs: + self.client.get(f"/posts/{random.choice(self.slugs)}", name="/posts/[slug]") + + @task(1) + def json_face(self): + self.client.get("/standaard", headers={"Accept": "application/json"}, name="/standaard [json]") + + @task(1) + def join_form(self): + self.client.post( + "/binnen", + data={"name": f"Load {random.randint(1, 10**6)}", "contact": "load@example.invalid", "repo_url": "", "message": "", "website": ""}, + name="/binnen [post]", + ) diff --git a/molodetz/__init__.py b/molodetz/__init__.py new file mode 100644 index 0000000..95ee7c3 --- /dev/null +++ b/molodetz/__init__.py @@ -0,0 +1 @@ +# retoor diff --git a/molodetz/assets.py b/molodetz/assets.py new file mode 100644 index 0000000..13aaac7 --- /dev/null +++ b/molodetz/assets.py @@ -0,0 +1,8 @@ +# retoor +from molodetz import config + + +def static_url(path): + if not path.startswith("/static/") or path.startswith("/static/uploads/"): + return path + return f"/static/v{config.STATIC_VERSION}/{path[len('/static/'):]}" diff --git a/molodetz/attachments.py b/molodetz/attachments.py new file mode 100644 index 0000000..33068bb --- /dev/null +++ b/molodetz/attachments.py @@ -0,0 +1,92 @@ +# retoor +from pathlib import Path + +from molodetz import config +from molodetz.database import db, shard_path + +IMAGE_EXTENSIONS = frozenset({"jpg", "jpeg", "png", "gif", "webp", "avif"}) +DOCUMENT_EXTENSIONS = frozenset({"pdf", "md", "txt"}) +INLINE_EXTENSIONS = IMAGE_EXTENSIONS | frozenset({"mp4", "webm", "mp3", "ogg", "wav", "svg"}) + + +def allowed_extensions(): + return IMAGE_EXTENSIONS | DOCUMENT_EXTENSIONS + + +def extension_of(name): + return Path(name).suffix.lower().lstrip(".") + + +def is_image(name): + return extension_of(name) in IMAGE_EXTENSIONS + + +def format_size(size): + size = float(size or 0) + for unit in ("B", "KB", "MB", "GB"): + if size < 1024 or unit == "GB": + return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}" + size /= 1024 + return f"{size:.1f} GB" + + +def icon_for(name): + ext = extension_of(name) + if ext in IMAGE_EXTENSIONS: + return "image" + if ext == "pdf": + return "pdf" + return "file" + + +def blob_location(uid, extension): + directory = f"attachments/{shard_path(uid)}" + stored_name = f"{uid.replace('-', '')}.{extension}" + return directory, stored_name + + +def blob_path(directory, stored_name): + root = config.DATA_PATHS["uploads"].resolve() + path = (root / directory / stored_name).resolve() + if root not in path.parents: + raise ValueError("blob path escapes the uploads root") + return path + + +def blob_url(directory, stored_name): + return f"/static/uploads/{directory}/{stored_name}" + + +def write_blob(uid, extension, data): + if extension not in allowed_extensions(): + raise ValueError(f"extension {extension} not allowed") + directory, stored_name = blob_location(uid, extension) + path = blob_path(directory, stored_name) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + return directory, stored_name + + +def unlink_blob(directory, stored_name): + try: + blob_path(directory, stored_name).unlink(missing_ok=True) + return True + except (OSError, ValueError): + return False + + +def referenced_blob_paths(): + paths = set() + if "media_items" in db.tables: + for row in db["media_items"].find(deleted_at=None): + if row.get("thumb_directory") and row.get("thumb_name"): + paths.add(blob_path(row["thumb_directory"], row["thumb_name"])) + return paths + + +def orphan_blobs(): + root = config.DATA_PATHS["attachments"] + if not root.exists(): + return [] + referenced = referenced_blob_paths() + return [path for path in root.rglob("*") if path.is_file() and path.resolve() not in referenced] diff --git a/molodetz/avatar.py b/molodetz/avatar.py new file mode 100644 index 0000000..a45fa71 --- /dev/null +++ b/molodetz/avatar.py @@ -0,0 +1,42 @@ +# retoor +import hashlib +from functools import lru_cache +from html import escape + +PALETTE = ( + ("#1b1a17", "#e2a100"), + ("#141414", "#c9c2b2"), + ("#1d1b16", "#b98a2e"), + ("#121417", "#8fa3a8"), + ("#191512", "#d9b779"), +) + + +@lru_cache(maxsize=4096) +def render_avatar_svg(seed, size=64): + size = max(16, min(int(size), 512)) + try: + digest = hashlib.sha256(seed.encode("utf-8")).digest() + background, ink = PALETTE[digest[0] % len(PALETTE)] + cells = [] + unit = size / 5 + for row in range(5): + for col in range(3): + if digest[1 + row * 3 + col] % 2 == 0: + for mirrored in {col, 4 - col}: + cells.append( + f'' + ) + body = "".join(cells) + except Exception: + background, ink = PALETTE[0] + letter = escape((seed or "?")[:1].upper()) + body = f'{letter}' + return ( + f'' + f'{body}' + ) + + +def avatar_url(seed, size=64): + return f"/avatar/svg/{seed}?size={size}" diff --git a/molodetz/bootstrap.py b/molodetz/bootstrap.py new file mode 100644 index 0000000..fc3d38a --- /dev/null +++ b/molodetz/bootstrap.py @@ -0,0 +1,34 @@ +# retoor +import logging + +from molodetz import config +from molodetz.content import seed_placeholder_posts +from molodetz.database import count_users, create_user, get_setting, get_user_by_username, set_setting +from molodetz.gallery import sync_gallery +from molodetz.utils.audit import record_system +from molodetz.utils.auth import hash_password + +logger = logging.getLogger(__name__) + +ADMIN_BIO = "Begint de rol. Schrijft over het werk, het afmaken en het oordeel." + + +def bootstrap_admin(): + if count_users() > 0: + return get_user_by_username(config.ADMIN_USERNAME) + if not config.ADMIN_PASSWORD: + logger.warning("no users and MOLODETZ_ADMIN_PASSWORD unset; skipping admin bootstrap") + return None + user = create_user(config.ADMIN_USERNAME, config.ADMIN_EMAIL, hash_password(config.ADMIN_PASSWORD), bio=ADMIN_BIO) + record_system("admin.bootstrap", payload={"username": user["username"]}, targets=[("user", user["uid"])]) + return user + + +def bootstrap_content(): + admin = bootstrap_admin() + if admin and get_setting("seed_posts_done", "0") != "1": + created = seed_placeholder_posts(admin) + set_setting("seed_posts_done", "1") + record_system("post.seed", payload={"count": len(created)}) + synced = sync_gallery() + logger.info("gallery synced: %d items", synced) diff --git a/molodetz/cache.py b/molodetz/cache.py new file mode 100644 index 0000000..8e07c5b --- /dev/null +++ b/molodetz/cache.py @@ -0,0 +1,54 @@ +# retoor +import threading +import time + + +class TTLCache: + def __init__(self, ttl=60, max_size=1000): + self.ttl = ttl + self.max_size = max_size + self._data = {} + self._lock = threading.Lock() + + def get(self, key, default=None): + if self.ttl <= 0: + return default + with self._lock: + item = self._data.get(key) + if item is None: + return default + expires, value = item + if expires < time.monotonic(): + self._data.pop(key, None) + return default + return value + + def set(self, key, value): + if self.ttl <= 0: + return + with self._lock: + if len(self._data) >= self.max_size: + self._evict() + self._data[key] = (time.monotonic() + self.ttl, value) + + def pop(self, key): + with self._lock: + self._data.pop(key, None) + + def clear(self): + with self._lock: + self._data.clear() + + def __contains__(self, key): + return self.get(key, _MISSING) is not _MISSING + + def _evict(self): + now = time.monotonic() + expired = [k for k, (exp, _) in self._data.items() if exp < now] + for key in expired: + self._data.pop(key, None) + while len(self._data) >= self.max_size: + self._data.pop(next(iter(self._data))) + + +_MISSING = object() diff --git a/molodetz/cli/__init__.py b/molodetz/cli/__init__.py new file mode 100644 index 0000000..d9d906a --- /dev/null +++ b/molodetz/cli/__init__.py @@ -0,0 +1,86 @@ +# retoor +import argparse +import sys + +from molodetz.cli import commands + + +def build_parser(): + parser = argparse.ArgumentParser(prog="molodetz", description="Beheer-CLI voor Molodetz.") + groups = parser.add_subparsers(dest="group", required=True) + + role = groups.add_parser("role", help="Rollen lezen en zetten").add_subparsers(dest="action", required=True) + role_get = role.add_parser("get") + role_get.add_argument("username") + role_get.set_defaults(handler=commands.role_get) + role_set = role.add_parser("set") + role_set.add_argument("username") + role_set.add_argument("role", choices=["Member", "Admin"]) + role_set.set_defaults(handler=commands.role_set) + + apikey = groups.add_parser("apikey", help="API-sleutels").add_subparsers(dest="action", required=True) + key_get = apikey.add_parser("get") + key_get.add_argument("username") + key_get.set_defaults(handler=commands.apikey_get) + key_reset = apikey.add_parser("reset") + key_reset.add_argument("username") + key_reset.set_defaults(handler=commands.apikey_reset) + apikey.add_parser("backfill").set_defaults(handler=commands.apikey_backfill) + + posts = groups.add_parser("posts", help="Berichten").add_subparsers(dest="action", required=True) + posts.add_parser("list").set_defaults(handler=commands.posts_list) + posts_import = posts.add_parser("import", help="Importeer .md of .pdf als concept") + posts_import.add_argument("path") + posts_import.add_argument("--topic", default="rol", choices=["rol", "standaard"]) + posts_import.add_argument("--title") + posts_import.add_argument("--publish", action="store_true") + posts_import.set_defaults(handler=commands.posts_import) + + joins = groups.add_parser("joins", help="Aanmeldingen").add_subparsers(dest="action", required=True) + joins.add_parser("list").set_defaults(handler=commands.joins_list) + + backups = groups.add_parser("backups", help="Back-ups").add_subparsers(dest="action", required=True) + backups.add_parser("list").set_defaults(handler=commands.backups_list) + backup_run = backups.add_parser("run") + backup_run.add_argument("--target", default="database", choices=["database", "uploads", "keys", "full"]) + backup_run.add_argument("--codec", default="zstd", choices=["zstd", "brotli"]) + backup_run.set_defaults(handler=commands.backups_run) + backup_prune = backups.add_parser("prune") + backup_prune.add_argument("--keep", type=int, default=14) + backup_prune.add_argument("--dry-run", action="store_true") + backup_prune.set_defaults(handler=commands.backups_prune) + backup_clear = backups.add_parser("clear") + backup_clear.add_argument("--dry-run", action="store_true") + backup_clear.set_defaults(handler=commands.backups_clear) + + sessions = groups.add_parser("sessions", help="Sessies").add_subparsers(dest="action", required=True) + sessions_prune = sessions.add_parser("prune") + sessions_prune.add_argument("--dry-run", action="store_true") + sessions_prune.set_defaults(handler=commands.sessions_prune) + + audit = groups.add_parser("audit", help="Auditlog").add_subparsers(dest="action", required=True) + audit_prune = audit.add_parser("prune") + audit_prune.add_argument("--days", type=int, default=365) + audit_prune.add_argument("--dry-run", action="store_true") + audit_prune.set_defaults(handler=commands.audit_prune) + + system = groups.add_parser("system", help="Systeemonderhoud").add_subparsers(dest="action", required=True) + system_prune = system.add_parser("prune", help="Verwijder wees-blobs") + system_prune.add_argument("--dry-run", action="store_true") + system_prune.set_defaults(handler=commands.system_prune) + system.add_parser("sql-lint", help="Parse alle SQL-literals met sqlglot").set_defaults(handler=commands.system_sql_lint) + + emoji = groups.add_parser("emoji", help="Emoji-kaart").add_subparsers(dest="action", required=True) + emoji.add_parser("regenerate").set_defaults(handler=commands.emoji_regenerate) + return parser + + +def main(argv=None): + parser = build_parser() + args = parser.parse_args(argv) + commands.prepare() + return args.handler(args) or 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/molodetz/cli/__main__.py b/molodetz/cli/__main__.py new file mode 100644 index 0000000..6dc78c9 --- /dev/null +++ b/molodetz/cli/__main__.py @@ -0,0 +1,6 @@ +# retoor +import sys + +from molodetz.cli import main + +sys.exit(main()) diff --git a/molodetz/cli/commands.py b/molodetz/cli/commands.py new file mode 100644 index 0000000..84bafdc --- /dev/null +++ b/molodetz/cli/commands.py @@ -0,0 +1,154 @@ +# retoor +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from pypdf import PdfReader + +from molodetz import config +from molodetz.attachments import orphan_blobs +from molodetz.bootstrap import bootstrap_admin +from molodetz.content import create_post +from molodetz.database import ( + all_posts, + enqueue_backup, + generate_uid, + get_user_by_username, + init_db, + list_backups, + list_join_requests, + list_users, + prune_audit, + prune_expired_sessions, + set_role, + update_user, +) +from molodetz.database.sql_lint import lint +from molodetz.emoji_builder import write_js_module +from molodetz.services.backup import clear_backups, process_backup, prune_backups +from molodetz.utils.audit import record_system + + +def prepare(): + config.ensure_data_dirs() + init_db() + + +def _user(username): + user = get_user_by_username(username) + if user is None: + raise SystemExit(f"Onbekende gebruiker: {username}") + return user + + +def role_get(args): + print(_user(args.username).get("role")) + + +def role_set(args): + user = _user(args.username) + set_role(user["uid"], args.role) + record_system("admin.user.role", actor_kind="cli", origin="cli", payload={"role": args.role}, targets=[("user", user["uid"])]) + print(f"{user['username']} -> {args.role}") + + +def apikey_get(args): + print(_user(args.username).get("api_key")) + + +def apikey_reset(args): + user = _user(args.username) + key = generate_uid() + update_user(user["uid"], api_key=key) + record_system("profile.apikey.regenerate", actor_kind="cli", origin="cli", targets=[("user", user["uid"])]) + print(key) + + +def apikey_backfill(args): + changed = 0 + for user in list_users(limit=100000): + if not user.get("api_key"): + update_user(user["uid"], api_key=generate_uid()) + changed += 1 + print(f"{changed} sleutels aangevuld") + + +def posts_list(args): + for post in all_posts(): + flag = " [placeholder]" if post.get("is_placeholder") else "" + print(f"{post['uid']} {post['status']:<9} {post['topic']:<9} {post['title']}{flag}") + + +def read_source(path): + path = Path(path) + if path.suffix.lower() == ".pdf": + reader = PdfReader(str(path)) + return "\n\n".join((page.extract_text() or "").strip() for page in reader.pages).strip() + return path.read_text(encoding="utf-8") + + +def posts_import(args): + path = Path(args.path) + if not path.is_file(): + raise SystemExit(f"Bestand niet gevonden: {path}") + body = read_source(path) + title = args.title or path.stem.replace("-", " ").replace("_", " ").strip().capitalize() + author = bootstrap_admin() or _user(config.ADMIN_USERNAME) + post = create_post(author, title, body, args.topic, "published" if args.publish else "draft") + print(f"Geimporteerd: {post['uid']} {post['title']}") + + +def joins_list(args): + for row in list_join_requests(limit=1000): + print(f"{row['created_at']} {row['status']:<9} {row['name']} {row['contact']} {row.get('repo_url') or '-'}") + + +def backups_list(args): + for row in list_backups(limit=1000): + print(f"{row['uid']} {row['status']:<8} {row['target']:<9} {row.get('stored_name') or '-'} {row.get('size_bytes') or 0}") + + +def backups_run(args): + row = enqueue_backup(args.target, None) + ok = process_backup(row, args.codec) + print("klaar" if ok else "mislukt") + return 0 if ok else 1 + + +def backups_prune(args): + count = prune_backups(args.keep, dry_run=args.dry_run) + print(f"{count} archieven {'zouden worden verwijderd' if args.dry_run else 'verwijderd'}") + + +def backups_clear(args): + count = clear_backups(dry_run=args.dry_run) + print(f"{count} archieven {'zouden worden verwijderd' if args.dry_run else 'verwijderd'}") + + +def sessions_prune(args): + print(f"{prune_expired_sessions(dry_run=args.dry_run)} verlopen sessies") + + +def audit_prune(args): + cutoff = (datetime.now(timezone.utc) - timedelta(days=args.days)).isoformat() + print(f"{prune_audit(cutoff, dry_run=args.dry_run)} auditregels ouder dan {args.days} dagen") + + +def system_prune(args): + orphans = orphan_blobs() + for path in orphans: + print(("zou verwijderen: " if args.dry_run else "verwijderd: ") + str(path)) + if not args.dry_run: + path.unlink(missing_ok=True) + print(f"{len(orphans)} wees-blobs") + + +def system_sql_lint(args): + problems = lint() + for problem in problems: + print(problem) + print(f"{len(problems)} problemen") + return 1 if problems else 0 + + +def emoji_regenerate(args): + print(write_js_module()) diff --git a/molodetz/config.py b/molodetz/config.py new file mode 100644 index 0000000..fd230e6 --- /dev/null +++ b/molodetz/config.py @@ -0,0 +1,89 @@ +# retoor +import os +import time +import tomllib +from pathlib import Path + +from dotenv import load_dotenv + +ROOT = Path(__file__).resolve().parents[1] +PKG_ROOT = Path(__file__).resolve().parent + +load_dotenv(ROOT / ".env") + +with (ROOT / "pyproject.toml").open("rb") as handle: + APP_VERSION = tomllib.load(handle)["project"]["version"] + +APP_TITLE = "Molodetz" +APP_PORT = 8088 +DEFAULT_TEST_PORT = 8099 + + +def _env(name, default=""): + return os.environ.get(f"MOLODETZ_{name}", default) + + +def _env_int(name, default): + try: + return int(_env(name, str(default))) + except ValueError: + return default + + +DATA_DIR = Path(_env("DATA_DIR", str(ROOT / "data"))).resolve() +DATABASE_URL = _env("DATABASE_URL", f"sqlite:///{DATA_DIR / 'molodetz.db'}") +PORT = _env_int("PORT", APP_PORT) +TEST_PORT = _env_int("TEST_PORT", DEFAULT_TEST_PORT) +SECRET_KEY = os.environ.get("SECRET_KEY", "molodetz-development-secret-change-in-production") +SITE_URL = _env("SITE_URL", "").rstrip("/") +INTERNAL_BASE_URL = _env("INTERNAL_BASE_URL", f"http://localhost:{PORT}").rstrip("/") +DISABLE_SERVICES = _env("DISABLE_SERVICES") == "1" +DISABLE_RATE_LIMIT = _env("DISABLE_RATE_LIMIT") == "1" +RATE_LIMIT = _env_int("RATE_LIMIT", 60) +WEB_WORKERS = max(1, _env_int("WEB_WORKERS", os.cpu_count() or 1)) +TEMPLATE_AUTO_RELOAD = _env("TEMPLATE_AUTO_RELOAD", "1") == "1" +BOOT_ID = _env("STATIC_VERSION") or str(int(time.time())) +STATIC_VERSION = f"{APP_VERSION}-{BOOT_ID}" +LOG_LEVEL = _env("LOG_LEVEL", "INFO") +PRESENCE_TIMEOUT_SECONDS = _env_int("PRESENCE_TIMEOUT_SECONDS", 60) +PRESENCE_ONLINE_LIMIT = _env_int("PRESENCE_ONLINE_LIMIT", 30) +PRESENCE_TRACK_LIMIT = _env_int("PRESENCE_TRACK_LIMIT", 500) +PRESENCE_ONLINE_MARGIN_SECONDS = _env_int("PRESENCE_ONLINE_MARGIN_SECONDS", 20) +OUTBOUND_PROXY_URL = _env("OUTBOUND_PROXY_URL", "") +RCLONE_BIN = _env("RCLONE_BIN", "rclone") +RCLONE_CONFIG = _env("RCLONE_CONFIG", str(Path.home() / ".config/rclone/rclone.conf")) +BACKUP_OFFLOAD_REMOTE = _env("BACKUP_OFFLOAD_REMOTE", "") +SITEMAP_TTL = _env_int("SITEMAP_TTL", 3600) +LANDING_TTL = _env_int("LANDING_TTL", 30) +UNREAD_TTL = _env_int("UNREAD_TTL", 10) +PLAYWRIGHT_HEADLESS = os.environ.get("PLAYWRIGHT_HEADLESS", "1") == "1" +PLAYWRIGHT_SLOW_MO = int(os.environ.get("PLAYWRIGHT_SLOW_MO", "0") or 0) + +ADMIN_USERNAME = _env("ADMIN_USERNAME", "retoor") +ADMIN_EMAIL = _env("ADMIN_EMAIL", "retoor@molodetz.nl") +ADMIN_PASSWORD = _env("ADMIN_PASSWORD", "") + +STATIC_DIR = PKG_ROOT / "static" +TEMPLATES_DIR = PKG_ROOT / "templates" +MEDIA_SOURCE_DIR = STATIC_DIR / "media" + +DATA_PATHS: dict[str, Path] = { + "data": DATA_DIR, + "uploads": DATA_DIR / "uploads", + "attachments": DATA_DIR / "uploads" / "attachments", + "backups": DATA_DIR / "backups", + "backup_staging": DATA_DIR / "backups" / "staging", + "seo_reports": DATA_DIR / "seo_reports", + "keys": DATA_DIR / "keys", + "locks": DATA_DIR / "locks", +} + +INIT_LOCK_FILE = DATA_PATHS["locks"] / "init.lock" +SERVICE_LOCK_FILE = DATA_PATHS["locks"] / "service.lock" +WEB_LOCK_FILE = DATA_PATHS["locks"] / "web.lock" +NOTIFICATION_KEY_FILE = DATA_PATHS["keys"] / "notification.key" + + +def ensure_data_dirs(): + for path in DATA_PATHS.values(): + path.mkdir(parents=True, exist_ok=True) diff --git a/molodetz/constants.py b/molodetz/constants.py new file mode 100644 index 0000000..e9ca31c --- /dev/null +++ b/molodetz/constants.py @@ -0,0 +1,93 @@ +# retoor +ROLE_ADMIN = "Admin" +ROLE_MEMBER = "Member" +ROLES = (ROLE_ADMIN, ROLE_MEMBER) + +TOPICS = { + "rol": "Rol", + "standaard": "Standaard", +} + +POST_STATUSES = ("draft", "published") + +MEDIA_KINDS = { + "flyer": "Flyers", + "meme": "Memes", +} + +JOIN_STATUSES = ("open", "contacted", "accepted", "declined") + +SOFT_DELETE_TABLES = ( + "users", + "sessions", + "posts", + "join_requests", + "notifications", + "media_items", +) + +UNREPORTABLE_TABLES = { + "users": "Only the operator holds accounts; no public user content.", + "sessions": "Private credential rows.", + "posts": "Authored by administrators only; moderation module not selected.", + "join_requests": "Owner-private ledger visible to administrators only.", + "notifications": "Owner-private inbox rows.", + "media_items": "Operator-curated gallery assets.", +} + +REPORTABLE_TARGETS: frozenset[str] = frozenset() + +NOTIFICATION_TYPES = { + "join.request": "Nieuwe aanmelding via Binnen", + "post.published": "Bericht gepubliceerd", + "backup.finished": "Back-up afgerond", + "backup.failed": "Back-up mislukt", + "auth.apikey": "API-sleutel vernieuwd", + "system.maintenance": "Onderhoudsmodus gewijzigd", +} + +CRAWLER_PRIVATE_PREFIXES = ("/notifications", "/admin", "/profile") + +CRAWLER_MARKERS = ( + "bot", + "crawler", + "spider", + "slurp", + "crawl", + "facebookexternalhit", + "bingpreview", +) + +TERMS_EXEMPT_PREFIXES = ( + "/static", + "/avatar", + "/auth", + "/docs", + "/voorwaarden", + "/privacy", +) + +MAINTENANCE_ALLOWED_PREFIXES = ("/static", "/avatar", "/auth", "/admin") + +RATE_LIMIT_EXEMPT_PREFIXES: tuple[str, ...] = () + +GZIP_SKIP_PREFIXES: tuple[str, ...] = () + +SETTINGS_DEFAULTS = { + "site_title": "Molodetz", + "site_tagline": "De kring, niet de reclame.", + "maintenance_mode": "0", + "maintenance_message": "Molodetz is even dicht voor onderhoud.", + "rate_limit_per_minute": "60", + "rate_limit_window_seconds": "60", + "session_max_age_days": "7", + "session_remember_days": "30", + "terms_version": "1", + "friendly_404": "1", + "audit_retention_days": "365", + "outbound_proxy_url": "", +} + +CLIENT_RENDER_TEMPLATES = frozenset({"admin/post_form.html"}) + +HTTP_CLIENT_ALLOWLIST = frozenset({"molodetz/stealth.py"}) diff --git a/molodetz/content.py b/molodetz/content.py new file mode 100644 index 0000000..96970ee --- /dev/null +++ b/molodetz/content.py @@ -0,0 +1,72 @@ +# retoor +from molodetz.database import ( + insert_post, + soft_delete, + update_post_row, + now_iso, + db, +) +from molodetz.utils.audit import record +from molodetz.utils.notifications import create_notification + + +PLACEHOLDER_NOTE = ( + "\n\n> Placeholder. Dit is overgenomen uit de oude Molodetz-pagina. " + "retoor vervangt dit later door zijn eigen verhaal." +) + + +def create_post(user, title, body, topic, status="draft", is_placeholder=False, request=None): + if is_placeholder and PLACEHOLDER_NOTE.strip() not in body: + body = body.rstrip() + PLACEHOLDER_NOTE + row = insert_post(user["uid"], title, body, topic, status=status, is_placeholder=is_placeholder) + record(request, "post.create", payload={"uid": row["uid"], "status": status}, targets=[("post", row["uid"])], user=user) + if status == "published": + create_notification(user["uid"], "post.published", f"Gepubliceerd: {title}", f"/posts/{row['slug']}") + return row + + +def edit_post(post, user, title, body, topic, status, is_placeholder=False, request=None): + fields = { + "title": title, + "body": body, + "topic": topic, + "status": status, + "is_placeholder": bool(is_placeholder), + "updated_at": now_iso(), + } + if status == "published" and not post.get("published_at"): + fields["published_at"] = now_iso() + update_post_row(post["uid"], **fields) + record(request, "post.edit", payload={"uid": post["uid"]}, targets=[("post", post["uid"])], user=user) + return {**post, **fields} + + +def delete_post(post, actor, request=None): + stamp = now_iso() + soft_delete("posts", actor["uid"], stamp=stamp, uid=post["uid"]) + record( + request, + "post.delete", + payload={"uid": post["uid"], "stamp": stamp}, + targets=[("post", post["uid"])], + user=actor, + ) + return stamp + + +def seed_placeholder_posts(admin): + seeds = [ + ("standaard", "Eenendertig keuzes. Jij hebt er nul.", "Python 3.12. FastAPI. Jinja2. SQLite. Geen JWT, want dat is een hobby. Geen JavaScript-framework, want dat is een catalogus. Het document zegt 107 keer MUST. Wie daar \"het hangt ervan af\" van maakt, heeft geen smaak. Die heeft een roadmap van andermans tutorials."), + ("rol", "Een zes is geen ambitie.", "Wie tevreden is met voldoende, bouwt voldoende. Molodetz begint waar dat ophoudt."), + ("rol", "Af is een eigenschap.", "Een project dat open blijft, is een mening. Een project dat af is, is een feit."), + ("rol", "De assistent is geen senior.", "AI versnelt het werk. Het vervangt het oordeel niet. Wie dat door elkaar haalt, levert een chatlog in."), + ("rol", "Geen cursus.", "Kennis gaat hier van mensen naar mensen. Niet via een ebook dat belooft dat je in een weekend iemand anders wordt."), + ] + created = [] + for topic, title, body in seeds: + existing = db["posts"].find_one(title=title, deleted_at=None) if "posts" in db.tables else None + if existing: + continue + created.append(create_post(admin, title, body, topic, status="published", is_placeholder=topic == "rol")) + return created diff --git a/molodetz/curl_transport.py b/molodetz/curl_transport.py new file mode 100644 index 0000000..131757b --- /dev/null +++ b/molodetz/curl_transport.py @@ -0,0 +1,61 @@ +# retoor +import httpx + +try: + from curl_cffi import CurlHttpVersion + from curl_cffi.requests import AsyncSession + + CURL_AVAILABLE = True +except ImportError: + CURL_AVAILABLE = False + +IMPERSONATE_PROFILE = "chrome" + + +class CurlImpersonationTransport(httpx.AsyncBaseTransport): + def __init__(self, proxy=None, timeout=30.0, verify=True): + self.proxy = proxy or None + self.timeout = timeout + self.verify = verify + self._session = None + + def _session_for(self): + if self._session is None: + self._session = AsyncSession(impersonate=IMPERSONATE_PROFILE, verify=self.verify) + return self._session + + async def handle_async_request(self, request): + url = str(request.url) + http_version = CurlHttpVersion.V1_1 if request.url.scheme == "http" else None + headers = { + key.decode("latin-1"): value.decode("latin-1") + for key, value in request.headers.raw + if key.lower() not in (b"host", b"content-length", b"user-agent", b"accept-encoding", b"connection") + } + body = await request.aread() + kwargs = { + "headers": headers, + "data": body or None, + "timeout": self.timeout, + "allow_redirects": False, + "proxy": self.proxy, + } + if http_version is not None: + kwargs["http_version"] = http_version + response = await self._session_for().request(request.method, url, **kwargs) + response_headers = [ + (key, value) + for key, value in response.headers.multi_items() + if key.lower() not in ("content-encoding", "content-length", "transfer-encoding") + ] + return httpx.Response( + status_code=response.status_code, + headers=response_headers, + content=response.content, + request=request, + ) + + async def aclose(self): + if self._session is not None: + await self._session.close() + self._session = None diff --git a/molodetz/database/__init__.py b/molodetz/database/__init__.py new file mode 100644 index 0000000..d1747e1 --- /dev/null +++ b/molodetz/database/__init__.py @@ -0,0 +1,169 @@ +# retoor +from molodetz.database.audit import count_audit, insert_audit, list_audit, prune_audit +from molodetz.database.backups import ( + delete_backup_rows, + enqueue_backup, + get_backup, + list_backups, + pending_backups, + update_backup, +) +from molodetz.database.core import ( + db, + generate_uid, + make_combined_slug, + now_iso, + parse_iso, + refresh_snapshot, + resolve_by_slug, + shard_path, + table_exists, +) +from molodetz.database.init_db import init_db +from molodetz.database.joins import ( + count_open_join_requests, + get_join_request, + insert_join_request, + list_join_requests, + update_join_request, +) +from molodetz.database.media import count_media, media_by_kind, upsert_media +from molodetz.database.notifications import insert_notification, list_notifications, mark_all_read, unread_count +from molodetz.database.pagination import build_pagination, paginate +from molodetz.database.posts import ( + all_posts, + count_published, + get_post, + get_post_by_slug, + insert_post, + post_counts_by_author, + published_posts, + update_post_row, +) +from molodetz.database.services_state import ensure_service_state, get_service_state, write_service_state +from molodetz.database.sessions import ( + create_session, + end_all_sessions_for, + end_session, + get_session, + hard_delete_session, + prune_expired_sessions, +) +from molodetz.database.settings import ( + all_settings, + bump_cache_version, + get_int_setting, + get_setting, + set_setting, + sync_local_cache, +) +from molodetz.database.soft_delete import ( + count_deleted, + list_deleted, + purge, + purge_event, + restore, + restore_event, + soft_delete, + soft_delete_in, +) +from molodetz.database.users import ( + admin_uids_ordered, + count_users, + create_user, + get_user, + get_user_by_api_key, + get_user_by_email, + get_user_by_username, + invalidate_admins_cache, + list_users, + public_people, + set_role, + update_user, + users_by_uids, +) +from molodetz.database.visits import daily_totals, flush_visits, top_paths + +__all__ = [ + "all_posts", + "all_settings", + "admin_uids_ordered", + "build_pagination", + "bump_cache_version", + "count_audit", + "count_deleted", + "count_media", + "count_open_join_requests", + "count_published", + "count_users", + "create_session", + "create_user", + "daily_totals", + "db", + "delete_backup_rows", + "end_all_sessions_for", + "end_session", + "enqueue_backup", + "ensure_service_state", + "flush_visits", + "generate_uid", + "get_backup", + "get_int_setting", + "get_join_request", + "get_post", + "get_post_by_slug", + "get_service_state", + "get_session", + "get_setting", + "get_user", + "get_user_by_api_key", + "get_user_by_email", + "get_user_by_username", + "hard_delete_session", + "init_db", + "insert_audit", + "insert_join_request", + "insert_notification", + "insert_post", + "invalidate_admins_cache", + "list_audit", + "list_backups", + "list_deleted", + "list_join_requests", + "list_notifications", + "list_users", + "make_combined_slug", + "mark_all_read", + "media_by_kind", + "now_iso", + "paginate", + "parse_iso", + "pending_backups", + "post_counts_by_author", + "prune_audit", + "prune_expired_sessions", + "public_people", + "published_posts", + "purge", + "purge_event", + "refresh_snapshot", + "resolve_by_slug", + "restore", + "restore_event", + "set_role", + "set_setting", + "shard_path", + "soft_delete", + "soft_delete_in", + "sync_local_cache", + "table_exists", + "top_paths", + "unread_count", + "update_backup", + "update_join_request", + "update_post_row", + "update_user", + "upsert_media", + "users_by_uids", + "write_service_state", +] diff --git a/molodetz/database/audit.py b/molodetz/database/audit.py new file mode 100644 index 0000000..4f9c270 --- /dev/null +++ b/molodetz/database/audit.py @@ -0,0 +1,37 @@ +# retoor +from molodetz.database.core import db + + +def insert_audit(row, links): + db["audit_log"].insert(row) + for link in links: + db["audit_links"].insert(link) + + +def list_audit(limit=200, category=None): + if "audit_log" not in db.tables: + return [] + criteria = {} + if category: + criteria["category"] = category + return list(db["audit_log"].find(order_by=["-created_at"], _limit=limit, **criteria)) + + +def count_audit(): + if "audit_log" not in db.tables: + return 0 + return db["audit_log"].count() + + +def prune_audit(before_iso, dry_run=False): + if "audit_log" not in db.tables: + return 0 + rows = list(db.query("SELECT uid FROM audit_log WHERE created_at < :before", before=before_iso)) + if rows and not dry_run: + with db: + db.query( + "DELETE FROM audit_links WHERE audit_uid IN (SELECT uid FROM audit_log WHERE created_at < :before)", + before=before_iso, + ) + db.query("DELETE FROM audit_log WHERE created_at < :before", before=before_iso) + return len(rows) diff --git a/molodetz/database/backups.py b/molodetz/database/backups.py new file mode 100644 index 0000000..8485922 --- /dev/null +++ b/molodetz/database/backups.py @@ -0,0 +1,47 @@ +# retoor +from molodetz.database.core import db, generate_uid, now_iso + + +def enqueue_backup(target, requested_by): + row = { + "uid": generate_uid(), + "target": target, + "status": "pending", + "requested_by": requested_by, + "directory": None, + "stored_name": None, + "size_bytes": None, + "error": None, + "created_at": now_iso(), + "finished_at": None, + } + db["backups"].insert(row) + return row + + +def pending_backups(): + if "backups" not in db.tables: + return [] + return list(db["backups"].find(status="pending", order_by=["uid"])) + + +def list_backups(limit=100): + if "backups" not in db.tables: + return [] + return list(db["backups"].find(order_by=["-created_at"], _limit=limit)) + + +def get_backup(uid): + if "backups" not in db.tables: + return None + return db["backups"].find_one(uid=uid) + + +def update_backup(uid, **fields): + fields["uid"] = uid + db["backups"].update(fields, ["uid"]) + + +def delete_backup_rows(uids): + for uid in uids: + db["backups"].delete(uid=uid) diff --git a/molodetz/database/core.py b/molodetz/database/core.py new file mode 100644 index 0000000..be6ec27 --- /dev/null +++ b/molodetz/database/core.py @@ -0,0 +1,116 @@ +# retoor +import logging +import re +from datetime import datetime, timezone + +import dataset +import sqlalchemy +import uuid_utils +from sqlalchemy.pool import NullPool + +from molodetz import config + +logger = logging.getLogger(__name__) + +ON_CONNECT_STATEMENTS = [ + "PRAGMA journal_mode=WAL", + "PRAGMA synchronous=NORMAL", + "PRAGMA busy_timeout=30000", + "PRAGMA cache_size=-8000", + "PRAGMA temp_store=MEMORY", + "PRAGMA mmap_size=268435456", +] + +config.ensure_data_dirs() + +db = dataset.connect( + config.DATABASE_URL, + engine_kwargs={ + "connect_args": {"timeout": 30, "check_same_thread": False}, + "poolclass": NullPool, + }, + on_connect_statements=ON_CONNECT_STATEMENTS, +) + + +def generate_uid(): + return str(uuid_utils.uuid7()) + + +def now_iso(): + return datetime.now(timezone.utc).isoformat() + + +def parse_iso(value): + if not value: + return None + try: + parsed = datetime.fromisoformat(str(value)) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed + + +def refresh_snapshot(): + try: + if getattr(db.local, "tx", None): + return + conn = db.executable + if conn.in_transaction(): + conn.commit() + except Exception as exc: + logger.warning("refresh_snapshot failed: %s", exc) + + +def table_exists(name): + return name in db.tables + + +def in_clause(values, prefix="v"): + params = {f"{prefix}{index}": value for index, value in enumerate(values)} + placeholders = ", ".join(f":{key}" for key in params) + return placeholders, params + + +def atomic_update(sql, **params): + with db: + result = db.executable.execute(sqlalchemy.text(sql), params) + return result.rowcount + + +def run_write(sql, **params): + with db: + db.query(sql, **params) + + +def slugify(text): + lowered = (text or "").lower() + replaced = re.sub(r"[^a-z0-9-]", "-", lowered) + return re.sub(r"-+", "-", replaced).strip("-") + + +def make_combined_slug(text, uid): + tail = uid.replace("-", "")[-12:] + slug = slugify(text) + return f"{slug}-{tail}" if slug else tail + + +def shard_path(uid): + tail = uid.replace("-", "") + return f"{tail[-2:]}/{tail[-4:-2]}" + + +def resolve_by_slug(table_name, slug, include_deleted=False): + if table_name not in db.tables: + return None + table = db[table_name] + for key in ("slug", "uid"): + criteria = {key: slug} + if not include_deleted: + criteria["deleted_at"] = None + row = table.find_one(**criteria) + if row: + return row + return None diff --git a/molodetz/database/init_db.py b/molodetz/database/init_db.py new file mode 100644 index 0000000..79acef6 --- /dev/null +++ b/molodetz/database/init_db.py @@ -0,0 +1,233 @@ +# retoor +import logging + +from molodetz import config +from molodetz.constants import SETTINGS_DEFAULTS +from molodetz.database.core import db, generate_uid, now_iso + +logger = logging.getLogger(__name__) + +TABLE_EXAMPLES = { + "users": { + "uid": "x", + "username": "x", + "email": "x@x.nl", + "password_hash": "x", + "role": "Member", + "api_key": "x", + "bio": "", + "avatar_seed": None, + "is_active": True, + "last_seen_at": None, + "suspended_until": None, + "suspend_reason": None, + "terms_version": "1", + "created_at": now_iso(), + "updated_at": now_iso(), + "deleted_at": None, + "deleted_by": None, + }, + "sessions": { + "uid": "x", + "token": "x", + "user_uid": "x", + "created_at": now_iso(), + "expires_at": now_iso(), + "deleted_at": None, + "deleted_by": None, + }, + "posts": { + "uid": "x", + "slug": "x", + "title": "x", + "body": "x", + "topic": "rol", + "status": "draft", + "is_placeholder": False, + "user_uid": "x", + "published_at": None, + "created_at": now_iso(), + "updated_at": now_iso(), + "deleted_at": None, + "deleted_by": None, + }, + "join_requests": { + "uid": "x", + "name": "x", + "contact": "x", + "repo_url": None, + "message": "", + "status": "open", + "ip_hash": None, + "link_title": None, + "link_checked_at": None, + "link_status": None, + "created_at": now_iso(), + "updated_at": now_iso(), + "deleted_at": None, + "deleted_by": None, + }, + "media_items": { + "uid": "x", + "filename": "x.jpg", + "kind": "flyer", + "caption": "", + "position": 0, + "width": 0, + "height": 0, + "phash": None, + "thumb_directory": None, + "thumb_name": None, + "created_at": now_iso(), + "updated_at": now_iso(), + "deleted_at": None, + "deleted_by": None, + }, + "notifications": { + "uid": "x", + "user_uid": "x", + "kind": "system.maintenance", + "message": "x", + "url": "/", + "read_at": None, + "created_at": now_iso(), + "deleted_at": None, + "deleted_by": None, + }, + "site_settings": {"uid": "setting_x", "key": "x", "value": "x", "updated_at": now_iso()}, + "cache_state": {"name": "settings", "version": 0, "updated_at": now_iso()}, + "service_state": { + "name": "backup", + "status": "stopped", + "last_run": None, + "next_run": None, + "started_at": None, + "heartbeat": None, + "logs": "[]", + "metrics": "{}", + "updated_at": now_iso(), + }, + "audit_log": { + "uid": "x", + "event_key": "auth.login", + "category": "auth", + "actor_kind": "user", + "actor_uid": None, + "origin": "web", + "result": "ok", + "ip": None, + "user_agent": None, + "message": None, + "payload": "{}", + "created_at": now_iso(), + }, + "audit_links": {"audit_uid": "x", "target_type": "user", "target_uid": "x"}, + "visit_stats": {"day": "2026-01-01", "path": "/", "status": 200, "hits": 0}, + "backups": { + "uid": "x", + "target": "database", + "status": "pending", + "requested_by": "x", + "directory": None, + "stored_name": None, + "size_bytes": None, + "error": None, + "created_at": now_iso(), + "finished_at": None, + }, +} + + +def _ensure_table(name, example): + table = db.get_table(name) + for column, value in example.items(): + table.create_column_by_example(column, value) + return table + + +def _index(sql): + try: + with db: + db.query(sql) + except Exception as exc: + logger.warning("index ensure failed: %s (%s)", sql, exc) + + +def _seed_settings(): + if "site_settings" not in db.tables: + return + for key, value in SETTINGS_DEFAULTS.items(): + uid = f"default_{key}" + if db["site_settings"].find_one(uid=uid) is None: + db["site_settings"].insert({"uid": uid, "key": key, "value": value, "updated_at": now_iso()}) + + +def _seed_notification_key(): + path = config.NOTIFICATION_KEY_FILE + if not path.exists(): + path.write_text(generate_uid(), encoding="utf-8") + path.chmod(0o600) + + +def _backfill_api_keys(): + if "users" not in db.tables: + return + with db: + db.query("UPDATE users SET api_key = :key WHERE api_key IS NULL OR api_key = ''", key=generate_uid()) + + +def init_db(): + for name, example in TABLE_EXAMPLES.items(): + _ensure_table(name, example) + + _index("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_uid ON users(uid)") + _index("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username)") + _index("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email)") + _index("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_api_key ON users(api_key)") + _index("CREATE INDEX IF NOT EXISTS idx_users_created_at ON users(created_at)") + _index("CREATE INDEX IF NOT EXISTS idx_users_trash ON users(deleted_at) WHERE deleted_at IS NOT NULL") + + _index("CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_uid ON sessions(uid)") + _index("CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token)") + _index("CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_uid, created_at)") + _index("CREATE INDEX IF NOT EXISTS idx_sessions_trash ON sessions(deleted_at) WHERE deleted_at IS NOT NULL") + + _index("CREATE UNIQUE INDEX IF NOT EXISTS idx_posts_uid ON posts(uid)") + _index("CREATE UNIQUE INDEX IF NOT EXISTS idx_posts_slug ON posts(slug)") + _index( + "CREATE INDEX IF NOT EXISTS idx_posts_published ON posts(topic, published_at) " + "WHERE deleted_at IS NULL AND status = 'published'" + ) + _index("CREATE INDEX IF NOT EXISTS idx_posts_author ON posts(user_uid, created_at)") + _index("CREATE INDEX IF NOT EXISTS idx_posts_trash ON posts(deleted_at) WHERE deleted_at IS NOT NULL") + + _index("CREATE UNIQUE INDEX IF NOT EXISTS idx_join_requests_uid ON join_requests(uid)") + _index("CREATE INDEX IF NOT EXISTS idx_join_requests_status ON join_requests(status, created_at)") + _index("CREATE INDEX IF NOT EXISTS idx_join_requests_trash ON join_requests(deleted_at) WHERE deleted_at IS NOT NULL") + + _index("CREATE UNIQUE INDEX IF NOT EXISTS idx_media_items_uid ON media_items(uid)") + _index("CREATE UNIQUE INDEX IF NOT EXISTS idx_media_items_filename ON media_items(filename)") + _index("CREATE INDEX IF NOT EXISTS idx_media_items_kind ON media_items(kind, position)") + + _index("CREATE UNIQUE INDEX IF NOT EXISTS idx_notifications_uid ON notifications(uid)") + _index("CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(user_uid, created_at)") + + _index("CREATE UNIQUE INDEX IF NOT EXISTS idx_site_settings_uid ON site_settings(uid)") + _index("CREATE UNIQUE INDEX IF NOT EXISTS idx_site_settings_key ON site_settings(key)") + _index("CREATE UNIQUE INDEX IF NOT EXISTS idx_cache_state_name ON cache_state(name)") + _index("CREATE UNIQUE INDEX IF NOT EXISTS idx_service_state_name ON service_state(name)") + _index("CREATE UNIQUE INDEX IF NOT EXISTS idx_audit_log_uid ON audit_log(uid)") + _index("CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at)") + _index("CREATE INDEX IF NOT EXISTS idx_audit_links_audit ON audit_links(audit_uid)") + _index("CREATE UNIQUE INDEX IF NOT EXISTS idx_visit_stats_day_path ON visit_stats(day, path, status)") + _index("CREATE UNIQUE INDEX IF NOT EXISTS idx_backups_uid ON backups(uid)") + + _seed_settings() + _seed_notification_key() + _backfill_api_keys() + + with db: + has_stats = next(iter(db.query("SELECT name FROM sqlite_master WHERE type='table' AND name='sqlite_stat1'")), None) + if has_stats is None: + db.query("ANALYZE") + db.query("PRAGMA optimize") diff --git a/molodetz/database/joins.py b/molodetz/database/joins.py new file mode 100644 index 0000000..f367474 --- /dev/null +++ b/molodetz/database/joins.py @@ -0,0 +1,47 @@ +# retoor +from molodetz.database.core import db, generate_uid, now_iso + + +def insert_join_request(name, contact, repo_url, message, ip_hash): + row = { + "uid": generate_uid(), + "name": name, + "contact": contact, + "repo_url": repo_url or None, + "message": message or "", + "status": "open", + "ip_hash": ip_hash, + "link_title": None, + "link_checked_at": None, + "link_status": None, + "created_at": now_iso(), + "updated_at": now_iso(), + "deleted_at": None, + "deleted_by": None, + } + db["join_requests"].insert(row) + return row + + +def list_join_requests(limit=500): + if "join_requests" not in db.tables: + return [] + return list(db["join_requests"].find(deleted_at=None, order_by=["-created_at"], _limit=limit)) + + +def get_join_request(uid): + if "join_requests" not in db.tables: + return None + return db["join_requests"].find_one(uid=uid, deleted_at=None) + + +def update_join_request(uid, **fields): + fields["uid"] = uid + fields["updated_at"] = now_iso() + db["join_requests"].update(fields, ["uid"]) + + +def count_open_join_requests(): + if "join_requests" not in db.tables: + return 0 + return db["join_requests"].count(deleted_at=None, status="open") diff --git a/molodetz/database/media.py b/molodetz/database/media.py new file mode 100644 index 0000000..c32f7f8 --- /dev/null +++ b/molodetz/database/media.py @@ -0,0 +1,45 @@ +# retoor +from molodetz.database.core import db, generate_uid, now_iso + + +def media_by_kind(kind): + if "media_items" not in db.tables: + return [] + return list(db["media_items"].find(kind=kind, deleted_at=None, order_by=["position", "id"])) + + +def get_media_by_file(filename): + if "media_items" not in db.tables: + return None + return db["media_items"].find_one(filename=filename) + + +def upsert_media(filename, kind, caption, position, width, height, phash, thumb_directory, thumb_name): + existing = get_media_by_file(filename) + fields = { + "filename": filename, + "kind": kind, + "caption": caption, + "position": position, + "width": width, + "height": height, + "phash": phash, + "thumb_directory": thumb_directory, + "thumb_name": thumb_name, + "updated_at": now_iso(), + } + if existing: + fields["id"] = existing["id"] + db["media_items"].update(fields, ["id"]) + return existing["uid"] + fields.update({"uid": generate_uid(), "created_at": now_iso(), "deleted_at": None, "deleted_by": None}) + db["media_items"].insert(fields) + return fields["uid"] + + +def count_media(kind=None): + if "media_items" not in db.tables: + return 0 + if kind: + return db["media_items"].count(kind=kind, deleted_at=None) + return db["media_items"].count(deleted_at=None) diff --git a/molodetz/database/notifications.py b/molodetz/database/notifications.py new file mode 100644 index 0000000..ff2c92e --- /dev/null +++ b/molodetz/database/notifications.py @@ -0,0 +1,55 @@ +# retoor +from molodetz.cache import TTLCache +from molodetz import config +from molodetz.database.core import db, generate_uid, now_iso + +_unread_cache = TTLCache(ttl=config.UNREAD_TTL, max_size=1000) + + +def insert_notification(user_uid, kind, message, url): + row = { + "uid": generate_uid(), + "user_uid": user_uid, + "kind": kind, + "message": message, + "url": url, + "read_at": None, + "created_at": now_iso(), + "deleted_at": None, + "deleted_by": None, + } + db["notifications"].insert(row) + _unread_cache.pop(user_uid) + return row + + +def list_notifications(user_uid, limit=100): + if "notifications" not in db.tables: + return [] + return list(db["notifications"].find(user_uid=user_uid, deleted_at=None, order_by=["-created_at"], _limit=limit)) + + +def unread_count(user_uid): + if not user_uid: + return 0 + cached = _unread_cache.get(user_uid) + if cached is not None: + return cached + count = 0 + if "notifications" in db.tables: + count = db["notifications"].count(user_uid=user_uid, deleted_at=None, read_at=None) + _unread_cache.set(user_uid, count) + return count + + +def mark_all_read(user_uid): + if "notifications" not in db.tables: + return + with db: + db.query( + "UPDATE notifications SET read_at = :stamp WHERE user_uid = :user_uid " + "AND read_at IS NULL AND deleted_at IS NULL", + stamp=now_iso(), + user_uid=user_uid, + ) + _unread_cache.pop(user_uid) diff --git a/molodetz/database/pagination.py b/molodetz/database/pagination.py new file mode 100644 index 0000000..23f0b0c --- /dev/null +++ b/molodetz/database/pagination.py @@ -0,0 +1,36 @@ +# retoor +import math + +from molodetz.database.core import db + + +def paginate(table_name, before=None, order="desc", cursor_field="created_at", limit=25, **filters): + if table_name not in db.tables: + return [], None + table = db[table_name] + criteria = dict(filters) + if "deleted_at" not in criteria: + criteria["deleted_at"] = None + if before: + criteria[cursor_field] = {"<": before} if order == "desc" else {">": before} + order_by = [f"-{cursor_field}"] if order == "desc" else [cursor_field] + rows = list(table.find(_limit=limit + 1, order_by=order_by, **criteria)) + next_cursor = None + if len(rows) > limit: + rows = rows[:limit] + next_cursor = rows[-1][cursor_field] + return rows, next_cursor + + +def build_pagination(page, total, per_page): + pages = max(1, math.ceil(total / per_page)) if per_page else 1 + page = min(max(1, page), pages) + return { + "page": page, + "pages": pages, + "total": total, + "per_page": per_page, + "offset": (page - 1) * per_page, + "has_prev": page > 1, + "has_next": page < pages, + } diff --git a/molodetz/database/posts.py b/molodetz/database/posts.py new file mode 100644 index 0000000..5f75ca2 --- /dev/null +++ b/molodetz/database/posts.py @@ -0,0 +1,74 @@ +# retoor +from molodetz.database.core import db, generate_uid, in_clause, make_combined_slug, now_iso, resolve_by_slug +from molodetz.database.pagination import paginate + + +def insert_post(user_uid, title, body, topic, status="draft", is_placeholder=False, created_at=None): + uid = generate_uid() + stamp = created_at or now_iso() + row = { + "uid": uid, + "slug": make_combined_slug(title, uid), + "title": title, + "body": body, + "topic": topic, + "status": status, + "is_placeholder": bool(is_placeholder), + "user_uid": user_uid, + "published_at": stamp if status == "published" else None, + "created_at": stamp, + "updated_at": stamp, + "deleted_at": None, + "deleted_by": None, + } + db["posts"].insert(row) + return row + + +def update_post_row(uid, **fields): + fields["uid"] = uid + db["posts"].update(fields, ["uid"]) + + +def get_post(uid): + if "posts" not in db.tables: + return None + return db["posts"].find_one(uid=uid, deleted_at=None) + + +def get_post_by_slug(slug): + return resolve_by_slug("posts", slug) + + +def published_posts(topic=None, before=None, limit=25, user_uid=None): + filters = {"status": "published"} + if topic: + filters["topic"] = topic + if user_uid: + filters["user_uid"] = user_uid + return paginate("posts", before=before, cursor_field="published_at", limit=limit, **filters) + + +def all_posts(limit=200): + if "posts" not in db.tables: + return [] + return list(db["posts"].find(deleted_at=None, order_by=["-created_at"], _limit=limit)) + + +def count_published(): + if "posts" not in db.tables: + return 0 + return db["posts"].count(deleted_at=None, status="published") + + +def post_counts_by_author(user_uids): + user_uids = [uid for uid in set(user_uids) if uid] + if not user_uids or "posts" not in db.tables: + return {} + placeholders, params = in_clause(user_uids) + rows = db.query( + f"SELECT user_uid, COUNT(*) AS n FROM posts WHERE user_uid IN ({placeholders}) " + "AND deleted_at IS NULL AND status = 'published' GROUP BY user_uid", + **params, + ) + return {row["user_uid"]: int(row["n"]) for row in rows} diff --git a/molodetz/database/services_state.py b/molodetz/database/services_state.py new file mode 100644 index 0000000..93aa429 --- /dev/null +++ b/molodetz/database/services_state.py @@ -0,0 +1,38 @@ +# retoor +import json + +from molodetz.database.core import db, now_iso + + +def get_service_state(name): + if "service_state" not in db.tables: + return None + return db["service_state"].find_one(name=name) + + +def ensure_service_state(name): + row = get_service_state(name) + if row: + return row["id"] + return db["service_state"].insert( + { + "name": name, + "status": "stopped", + "last_run": None, + "next_run": None, + "started_at": None, + "heartbeat": None, + "logs": "[]", + "metrics": "{}", + "updated_at": now_iso(), + } + ) + + +def write_service_state(row_id, **fields): + for key in ("logs", "metrics"): + if key in fields and not isinstance(fields[key], str): + fields[key] = json.dumps(fields[key]) + fields["id"] = row_id + fields["updated_at"] = now_iso() + db["service_state"].update(fields, ["id"]) diff --git a/molodetz/database/sessions.py b/molodetz/database/sessions.py new file mode 100644 index 0000000..67946f0 --- /dev/null +++ b/molodetz/database/sessions.py @@ -0,0 +1,66 @@ +# retoor +import secrets +from datetime import datetime, timedelta, timezone + +from molodetz.database.core import db, generate_uid, now_iso + + +def create_session(user_uid, max_age_seconds): + token = secrets.token_hex(32) + expires = datetime.now(timezone.utc) + timedelta(seconds=max_age_seconds) + db["sessions"].insert( + { + "uid": generate_uid(), + "token": token, + "user_uid": user_uid, + "created_at": now_iso(), + "expires_at": expires.isoformat(), + "deleted_at": None, + "deleted_by": None, + } + ) + return token + + +def get_session(token): + if not token or "sessions" not in db.tables: + return None + return db["sessions"].find_one(token=token, deleted_at=None) + + +def hard_delete_session(session_id): + db["sessions"].delete(id=session_id) + + +def end_session(token, actor): + if "sessions" not in db.tables: + return + row = db["sessions"].find_one(token=token, deleted_at=None) + if row: + db["sessions"].update({"id": row["id"], "deleted_at": now_iso(), "deleted_by": actor}, ["id"]) + + +def prune_expired_sessions(dry_run=False): + if "sessions" not in db.tables: + return 0 + stamp = now_iso() + rows = list(db.query("SELECT id FROM sessions WHERE expires_at < :stamp", stamp=stamp)) + if not dry_run and rows: + with db: + db.query("DELETE FROM sessions WHERE expires_at < :stamp", stamp=stamp) + return len(rows) + + +def end_all_sessions_for(user_uid, actor): + if "sessions" not in db.tables: + return 0 + stamp = now_iso() + with db: + db.query( + "UPDATE sessions SET deleted_at = :stamp, deleted_by = :actor " + "WHERE user_uid = :user_uid AND deleted_at IS NULL", + stamp=stamp, + actor=actor, + user_uid=user_uid, + ) + return 1 diff --git a/molodetz/database/settings.py b/molodetz/database/settings.py new file mode 100644 index 0000000..0b84051 --- /dev/null +++ b/molodetz/database/settings.py @@ -0,0 +1,89 @@ +# retoor +import logging + +from molodetz.cache import TTLCache +from molodetz.constants import SETTINGS_DEFAULTS +from molodetz.database.core import db, now_iso + +logger = logging.getLogger(__name__) + +_settings_cache = TTLCache(ttl=60, max_size=100) +_version_cache = TTLCache(ttl=1, max_size=100) +_local_versions = {} + + +def read_cache_version(name): + cached = _version_cache.get(name) + if cached is not None: + return cached + try: + if "cache_state" not in db.tables: + return 0 + row = db["cache_state"].find_one(name=name) + version = int(row["version"]) if row else 0 + except Exception as exc: + logger.warning("cache version read failed for %s: %s", name, exc) + return 0 + _version_cache.set(name, version) + return version + + +def sync_local_cache(name, cache): + version = read_cache_version(name) + if _local_versions.get(name) != version: + cache.clear() + _local_versions[name] = version + + +def bump_cache_version(name, cache=None): + try: + with db: + db.query( + "INSERT INTO cache_state (name, version, updated_at) VALUES (:name, 1, :stamp) " + "ON CONFLICT(name) DO UPDATE SET version = version + 1, updated_at = excluded.updated_at", + name=name, + stamp=now_iso(), + ) + except Exception as exc: + logger.warning("cache version bump failed for %s: %s", name, exc) + _version_cache.pop(name) + if cache is not None: + cache.clear() + _local_versions.pop(name, None) + + +def get_setting(key, default=None): + if default is None: + default = SETTINGS_DEFAULTS.get(key, "") + sync_local_cache("settings", _settings_cache) + cached = _settings_cache.get(key) + if cached is not None: + return cached + value = default + if "site_settings" in db.tables: + row = db["site_settings"].find_one(uid=f"setting_{key}") + if row is None: + row = db["site_settings"].find_one(uid=f"default_{key}") + if row is not None and row.get("value") is not None: + value = str(row["value"]) + _settings_cache.set(key, value) + return value + + +def get_int_setting(key, default): + try: + return int(get_setting(key, str(default))) + except (TypeError, ValueError): + return default + + +def set_setting(key, value): + db["site_settings"].upsert( + {"uid": f"setting_{key}", "key": key, "value": str(value), "updated_at": now_iso()}, + ["uid"], + ) + bump_cache_version("settings", _settings_cache) + + +def all_settings(): + return {key: get_setting(key, default) for key, default in SETTINGS_DEFAULTS.items()} diff --git a/molodetz/database/soft_delete.py b/molodetz/database/soft_delete.py new file mode 100644 index 0000000..fd7ebfe --- /dev/null +++ b/molodetz/database/soft_delete.py @@ -0,0 +1,104 @@ +# retoor +from molodetz.constants import SOFT_DELETE_TABLES +from molodetz.database.core import db, in_clause, now_iso + + +def _check(table_name): + if table_name not in SOFT_DELETE_TABLES: + raise ValueError(f"{table_name} is not soft-deletable") + + +def soft_delete(table_name, actor, stamp=None, **criteria): + _check(table_name) + if table_name not in db.tables: + return 0 + stamp = stamp or now_iso() + table = db[table_name] + count = 0 + for row in list(table.find(deleted_at=None, **criteria)): + table.update({"id": row["id"], "deleted_at": stamp, "deleted_by": actor}, ["id"]) + count += 1 + return count + + +def soft_delete_in(table_name, column, values, actor, stamp=None): + _check(table_name) + if not values or table_name not in db.tables: + return 0 + stamp = stamp or now_iso() + placeholders, params = in_clause(list(values)) + with db: + db.query( + f"UPDATE {table_name} SET deleted_at = :stamp, deleted_by = :actor " + f"WHERE {column} IN ({placeholders}) AND deleted_at IS NULL", + stamp=stamp, + actor=actor, + **params, + ) + return len(values) + + +def restore(table_name, uid): + _check(table_name) + if table_name not in db.tables: + return False + row = db[table_name].find_one(uid=uid) + if not row or row.get("deleted_at") is None: + return False + db[table_name].update({"id": row["id"], "deleted_at": None, "deleted_by": None}, ["id"]) + return True + + +def purge(table_name, uid): + _check(table_name) + if table_name not in db.tables: + return False + return bool(db[table_name].delete(uid=uid)) + + +def list_deleted(table_name, limit=200): + _check(table_name) + if table_name not in db.tables: + return [] + return list( + db.query( + f"SELECT * FROM {table_name} WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC LIMIT :limit", + limit=limit, + ) + ) + + +def count_deleted(table_name): + _check(table_name) + if table_name not in db.tables: + return 0 + row = next(iter(db.query(f"SELECT COUNT(*) AS n FROM {table_name} WHERE deleted_at IS NOT NULL")), None) + return int(row["n"]) if row else 0 + + +def restore_event(stamp): + restored = [] + tables = set(db.tables) + for table_name in SOFT_DELETE_TABLES: + if table_name not in tables: + continue + with db: + db.query( + f"UPDATE {table_name} SET deleted_at = NULL, deleted_by = NULL WHERE deleted_at = :stamp", + stamp=stamp, + ) + restored.append(table_name) + return restored + + +def purge_event(stamp): + purged = [] + tables = set(db.tables) + for table_name in SOFT_DELETE_TABLES: + if table_name not in tables: + continue + rows = list(db[table_name].find(deleted_at=stamp)) + if rows: + db[table_name].delete(deleted_at=stamp) + purged.append((table_name, rows)) + return purged diff --git a/molodetz/database/sql_lint.py b/molodetz/database/sql_lint.py new file mode 100644 index 0000000..a340887 --- /dev/null +++ b/molodetz/database/sql_lint.py @@ -0,0 +1,58 @@ +# retoor +import ast +from pathlib import Path + +import sqlglot +from sqlglot.errors import ParseError + +from molodetz import config + +SQL_PREFIXES = ("SELECT ", "INSERT ", "UPDATE ", "DELETE ", "CREATE ", "PRAGMA ", "WITH ", "ANALYZE") + + +def _joined_text(node): + parts = [] + for value in node.values: + if isinstance(value, ast.Constant): + parts.append(str(value.value)) + else: + parts.append("lint_placeholder") + return "".join(parts) + + +def _literal_sql(node): + if isinstance(node, ast.JoinedStr): + raw = _joined_text(node) + elif isinstance(node, ast.Constant) and isinstance(node.value, str): + raw = node.value + else: + return None + text = " ".join(raw.split()) + return text if text.upper().startswith(SQL_PREFIXES) else None + + +def collect_sql(root=None): + root = Path(root or config.PKG_ROOT) + found = [] + for path in sorted(root.rglob("*.py")): + tree = ast.parse(path.read_text(encoding="utf-8")) + inner = {id(value) for node in ast.walk(tree) if isinstance(node, ast.JoinedStr) for value in node.values} + for node in ast.walk(tree): + if id(node) in inner: + continue + sql = _literal_sql(node) + if sql: + found.append((path, node.lineno, sql)) + return found + + +def lint(root=None): + problems = [] + for path, line, sql in collect_sql(root): + if sql.upper().startswith(("PRAGMA", "ANALYZE")): + continue + try: + sqlglot.parse_one(sql, read="sqlite") + except ParseError as exc: + problems.append(f"{path}:{line}: {str(exc).splitlines()[0]}") + return problems diff --git a/molodetz/database/users.py b/molodetz/database/users.py new file mode 100644 index 0000000..e06743c --- /dev/null +++ b/molodetz/database/users.py @@ -0,0 +1,120 @@ +# retoor +from molodetz.cache import TTLCache +from molodetz.constants import ROLE_ADMIN, ROLE_MEMBER +from molodetz.database.core import db, generate_uid, in_clause, now_iso +from molodetz.database.settings import bump_cache_version, get_setting, sync_local_cache + +_admins_cache = TTLCache(ttl=300, max_size=4) + + +def users_table(): + return db.get_table("users") + + +def get_user(uid): + if not uid or "users" not in db.tables: + return None + return db["users"].find_one(uid=uid, deleted_at=None) + + +def get_user_by_username(username): + if not username or "users" not in db.tables: + return None + return db["users"].find_one(username=username, deleted_at=None) + + +def get_user_by_email(email): + if not email or "users" not in db.tables: + return None + return db["users"].find_one(email=email.lower(), deleted_at=None) + + +def get_user_by_api_key(api_key): + if not api_key or "users" not in db.tables: + return None + return db["users"].find_one(api_key=api_key, deleted_at=None) + + +def users_by_uids(uids): + uids = [uid for uid in set(uids) if uid] + if not uids or "users" not in db.tables: + return {} + placeholders, params = in_clause(uids) + rows = db.query( + f"SELECT * FROM users WHERE uid IN ({placeholders}) AND deleted_at IS NULL", + **params, + ) + return {row["uid"]: dict(row) for row in rows} + + +def count_users(): + if "users" not in db.tables: + return 0 + return db["users"].count(deleted_at=None) + + +def create_user(username, email, password_hash, role=None, bio="", created_at=None): + role = role or (ROLE_ADMIN if count_users() == 0 else ROLE_MEMBER) + uid = generate_uid() + row = { + "uid": uid, + "username": username, + "email": (email or "").lower(), + "password_hash": password_hash, + "role": role, + "api_key": generate_uid(), + "bio": bio, + "avatar_seed": None, + "is_active": True, + "last_seen_at": None, + "suspended_until": None, + "suspend_reason": None, + "terms_version": get_setting("terms_version", "1"), + "created_at": created_at or now_iso(), + "updated_at": now_iso(), + "deleted_at": None, + "deleted_by": None, + } + db["users"].insert(row) + invalidate_admins_cache() + return row + + +def update_user(uid, **fields): + fields["uid"] = uid + fields["updated_at"] = now_iso() + db["users"].update(fields, ["uid"]) + + +def set_role(uid, role): + update_user(uid, role=role) + invalidate_admins_cache() + + +def invalidate_admins_cache(): + bump_cache_version("admins", _admins_cache) + + +def admin_uids_ordered(): + sync_local_cache("admins", _admins_cache) + cached = _admins_cache.get("ordered") + if cached is not None: + return cached + if "users" not in db.tables: + return [] + rows = db["users"].find(role=ROLE_ADMIN, deleted_at=None, order_by=["created_at", "id"]) + ordered = [row["uid"] for row in rows] + _admins_cache.set("ordered", ordered) + return ordered + + +def list_users(limit=200): + if "users" not in db.tables: + return [] + return list(db["users"].find(deleted_at=None, order_by=["created_at"], _limit=limit)) + + +def public_people(): + if "users" not in db.tables: + return [] + return list(db["users"].find(deleted_at=None, is_active=True, order_by=["created_at"])) diff --git a/molodetz/database/visits.py b/molodetz/database/visits.py new file mode 100644 index 0000000..6e441d2 --- /dev/null +++ b/molodetz/database/visits.py @@ -0,0 +1,39 @@ +# retoor +from molodetz.database.core import db + + +def flush_visits(day, counts): + if not counts: + return + with db: + for (path, status), hits in counts.items(): + db.query( + "INSERT INTO visit_stats (day, path, status, hits) VALUES (:day, :path, :status, :hits) " + "ON CONFLICT(day, path, status) DO UPDATE SET hits = hits + excluded.hits", + day=day, + path=path, + status=status, + hits=hits, + ) + + +def top_paths(limit=25): + if "visit_stats" not in db.tables: + return [] + return list( + db.query( + "SELECT path, SUM(hits) AS hits FROM visit_stats GROUP BY path ORDER BY hits DESC LIMIT :limit", + limit=limit, + ) + ) + + +def daily_totals(limit=30): + if "visit_stats" not in db.tables: + return [] + return list( + db.query( + "SELECT day, SUM(hits) AS hits FROM visit_stats GROUP BY day ORDER BY day DESC LIMIT :limit", + limit=limit, + ) + ) diff --git a/molodetz/dependencies.py b/molodetz/dependencies.py new file mode 100644 index 0000000..7316013 --- /dev/null +++ b/molodetz/dependencies.py @@ -0,0 +1,29 @@ +# retoor +import json + +from fastapi import Request +from fastapi.exceptions import RequestValidationError +from pydantic import ValidationError + + +async def json_or_form(request: Request, model): + content_type = request.headers.get("content-type", "") + if content_type.startswith("application/json"): + try: + data = await request.json() + except json.JSONDecodeError as exc: + raise RequestValidationError([{"loc": ("body",), "msg": "Invalid JSON", "type": "json_invalid"}]) from exc + if not isinstance(data, dict): + raise RequestValidationError([{"loc": ("body",), "msg": "Expected an object", "type": "type_error"}]) + else: + form = await request.form() + data = {} + for key in form.keys(): + values = [value for value in form.getlist(key) if value != ""] + if not values: + continue + data[key] = values if len(values) > 1 else values[0] + try: + return model.model_validate(data) + except ValidationError as exc: + raise RequestValidationError(exc.errors()) from exc diff --git a/molodetz/docs_api/__init__.py b/molodetz/docs_api/__init__.py new file mode 100644 index 0000000..7a69201 --- /dev/null +++ b/molodetz/docs_api/__init__.py @@ -0,0 +1,21 @@ +# retoor +from molodetz.docs_api import account, admin, community, content +from molodetz.docs_api.registry import endpoint, field, substitute + + +def ordered_groups(): + return [content.GROUP, community.GROUP, account.GROUP, admin.GROUP] + + +def all_endpoints(): + return [item for group in ordered_groups() for item in group["endpoints"]] + + +def group_by_slug(slug): + for group in ordered_groups(): + if group["slug"] == slug: + return group + return None + + +__all__ = ["all_endpoints", "endpoint", "field", "group_by_slug", "ordered_groups", "substitute"] diff --git a/molodetz/docs_api/account.py b/molodetz/docs_api/account.py new file mode 100644 index 0000000..ab65b70 --- /dev/null +++ b/molodetz/docs_api/account.py @@ -0,0 +1,29 @@ +# retoor +from molodetz.docs_api.registry import endpoint, field + +GROUP = { + "slug": "account", + "title": "Account", + "description": "Inloggen met sessies, API-sleutel, meldingen. Geen JWT, geen OAuth.", + "endpoints": [ + endpoint("GET", "/auth/login", "Inlogpagina", sample={"errors": [], "next": "/admin"}), + endpoint( + "POST", + "/auth/login", + "Inloggen", + "Zet een httponly sessiecookie (7 dagen, 30 met onthouden).", + fields=[field("username", required=True, example="{{username}}"), field("password", required=True, type="password"), field("remember", type="boolean")], + sample={"ok": True, "redirect": "/admin", "data": None}, + interactive=False, + ), + endpoint("POST", "/auth/logout", "Uitloggen", sample={"ok": True, "redirect": "/", "data": None}, interactive=False), + endpoint("GET", "/profile/api-key", "API-sleutel bekijken", auth="member", sample={"api_key": "{{api_key}}"}), + endpoint("POST", "/profile/api-key/regenerate", "API-sleutel vernieuwen", auth="member", destructive=True, sample={"ok": True, "redirect": "/profile/api-key", "data": {"api_key": "..."}}, interactive=False), + endpoint("POST", "/profile/avatar/regenerate", "Avatar vernieuwen", auth="member", destructive=True, sample={"ok": True}, interactive=False), + endpoint("GET", "/notifications", "Meldingen", auth="member", sample={"notifications": [], "unread": 0}), + endpoint("POST", "/notifications/read", "Alles gelezen", auth="member", sample={"ok": True, "redirect": "/notifications"}), + endpoint("GET", "/voorwaarden", "Voorwaarden", sample={"title": "Voorwaarden"}), + endpoint("POST", "/voorwaarden/accept", "Voorwaarden accepteren", auth="member", sample={"ok": True, "redirect": "/"}), + endpoint("GET", "/privacy", "Privacy", sample={"title": "Privacy"}), + ], +} diff --git a/molodetz/docs_api/admin.py b/molodetz/docs_api/admin.py new file mode 100644 index 0000000..44be905 --- /dev/null +++ b/molodetz/docs_api/admin.py @@ -0,0 +1,52 @@ +# retoor +from molodetz.docs_api.registry import endpoint, field + +GROUP = { + "slug": "beheer", + "title": "Beheer", + "description": "Alleen voor beheerders. Berichten schrijven en publiceren, aanmeldingen, services, prullenbak, instellingen.", + "endpoints": [ + endpoint("GET", "/admin", "Overzicht", auth="admin", sample={"post_count": 5, "open_joins": 0, "user_count": 1, "deleted_count": 0}), + endpoint("GET", "/admin/posts", "Berichten", auth="admin", sample={"posts": []}), + endpoint("GET", "/admin/posts/new", "Nieuw bericht", auth="admin", sample={}), + endpoint( + "POST", + "/admin/posts", + "Bericht aanmaken", + "Markdown-bericht aanmaken; status draft of published.", + auth="admin", + fields=[ + field("title", required=True, example="Een titel"), + field("body", required=True, example="Tekst in **markdown**."), + field("topic", enum=["rol", "standaard"], example="rol"), + field("status", enum=["draft", "published"], example="draft"), + field("is_placeholder", type="boolean"), + ], + sample={"ok": True, "redirect": "/admin/posts", "data": {"uid": "..."}}, + probe_body={"title": "x"}, + ), + endpoint("GET", "/admin/posts/{uid}/edit", "Bericht bewerken", auth="admin", fields=[field("uid", required=True, location="path")], probe_path="/admin/posts/onbekend/edit"), + endpoint("POST", "/admin/posts/{uid}", "Bericht opslaan", auth="admin", fields=[field("uid", required=True, location="path")], probe_path="/admin/posts/onbekend", probe_body={"title": "x"}), + endpoint("POST", "/admin/posts/{uid}/publish", "Publiceren", auth="admin", fields=[field("uid", required=True, location="path")], probe_path="/admin/posts/onbekend/publish"), + endpoint("POST", "/admin/posts/{uid}/delete", "Bericht verwijderen", "Zachte verwijdering; herstel via prullenbak.", auth="admin", destructive=True, fields=[field("uid", required=True, location="path")], probe_path="/admin/posts/onbekend/delete"), + endpoint("GET", "/admin/joins", "Aanmeldingen", auth="admin", sample={"requests": []}), + endpoint("POST", "/admin/joins/{uid}/status", "Status aanmelding", auth="admin", fields=[field("uid", required=True, location="path"), field("status", enum=["open", "contacted", "accepted", "declined"])], probe_path="/admin/joins/onbekend/status", probe_body={"status": "open"}), + endpoint("POST", "/admin/joins/{uid}/check", "Repo-link controleren", "Haalt de titel op via de beveiligde uitgaande client.", auth="admin", fields=[field("uid", required=True, location="path")], probe_path="/admin/joins/onbekend/check"), + endpoint("GET", "/admin/services", "Services", auth="admin", sample={"services": []}), + endpoint("GET", "/admin/services/{name}", "Service", auth="admin", fields=[field("name", required=True, location="path", example="backup")], probe_path="/admin/services/backup"), + endpoint("POST", "/admin/services/{name}/command", "Service-commando", auth="admin", fields=[field("name", required=True, location="path"), field("verb", enum=["start", "stop", "run", "clear"])], probe_path="/admin/services/backup/command", probe_body={"verb": "noop"}), + endpoint("POST", "/admin/services/{name}/config", "Service-configuratie", auth="admin", fields=[field("name", required=True, location="path")], probe_path="/admin/services/backup/config", probe_body={}), + endpoint("GET", "/admin/trash", "Prullenbak", auth="admin", sample={"items": []}), + endpoint("POST", "/admin/trash/restore", "Herstellen", auth="admin", fields=[field("stamp", required=True)], probe_body={"stamp": "none"}), + endpoint("POST", "/admin/trash/purge", "Definitief verwijderen", auth="admin", destructive=True, fields=[field("stamp", required=True)], probe_body={"stamp": "none"}), + endpoint("GET", "/admin/settings", "Instellingen", auth="admin", sample={"settings": {}}), + endpoint("POST", "/admin/settings", "Instellingen opslaan", auth="admin", probe_body={}), + endpoint("GET", "/admin/users", "Accounts", auth="admin", sample={"users": []}), + endpoint("POST", "/admin/users/{uid}/role", "Rol wijzigen", auth="admin", fields=[field("uid", required=True, location="path"), field("role", enum=["Admin", "Member"])], probe_path="/admin/users/onbekend/role", probe_body={"role": "Member"}), + endpoint("GET", "/admin/audit", "Auditlog", auth="admin", sample={"rows": []}), + endpoint("GET", "/admin/backups", "Back-ups", auth="admin", sample={"backups": []}), + endpoint("POST", "/admin/backups", "Back-up inplannen", auth="admin", fields=[field("target", enum=["database", "uploads", "keys", "full"])], probe_body={"target": "nope"}), + endpoint("GET", "/admin/backups/{uid}/download", "Back-up downloaden", "Alleen de primaire beheerder.", auth="admin", negotiation="none", interactive=False, probe_path="/admin/backups/onbekend/download"), + endpoint("GET", "/admin/stats", "Statistieken", auth="admin", sample={"top_paths": [], "daily": []}), + ], +} diff --git a/molodetz/docs_api/community.py b/molodetz/docs_api/community.py new file mode 100644 index 0000000..a3f4c5a --- /dev/null +++ b/molodetz/docs_api/community.py @@ -0,0 +1,25 @@ +# retoor +from molodetz.docs_api.registry import endpoint, field + +GROUP = { + "slug": "binnen", + "title": "Binnen", + "description": "Aanmelden om mee te schrijven. Opgeslagen voor beheerders, nergens publiek.", + "endpoints": [ + endpoint("GET", "/binnen", "Aanmeldformulier", sample={"submitted": False}), + endpoint( + "POST", + "/binnen", + "Ik schrijf mee", + "Slaat een aanmelding op: naam, contact en optioneel een repo-link.", + fields=[ + field("name", required=True, example="Ada"), + field("contact", required=True, example="ada@example.nl"), + field("repo_url", example="https://git.example.nl/ada"), + field("message", example="Ik schrijf over afmaken."), + ], + sample={"ok": True, "redirect": "/binnen?ok=1", "data": {"uid": "0190..."}}, + probe_body={"name": "Probe", "contact": "probe@example.nl"}, + ), + ], +} diff --git a/molodetz/docs_api/content.py b/molodetz/docs_api/content.py new file mode 100644 index 0000000..b855781 --- /dev/null +++ b/molodetz/docs_api/content.py @@ -0,0 +1,46 @@ +# retoor +from molodetz.docs_api.registry import endpoint, field + +GROUP = { + "slug": "inhoud", + "title": "Inhoud", + "description": "Publieke leesroutes: de rol, de standaard, berichten, galerijen en mensen.", + "endpoints": [ + endpoint("GET", "/", "Voorpagina", "Laatste berichten en uitgelichte flyer.", sample={"posts": [], "flyer_count": 4, "meme_count": 20}), + endpoint( + "GET", + "/rol", + "De rol", + "Gepubliceerde notities in het onderwerp rol, nieuwste eerst, cursor-gepagineerd.", + fields=[field("before", description="Cursor uit next_cursor", location="query")], + sample={"title": "Rol", "topic": "rol", "posts": [], "next_cursor": None}, + ), + endpoint("GET", "/standaard", "De standaard", "Gepubliceerde berichten in het onderwerp standaard.", sample={"title": "Standaard", "topic": "standaard", "posts": []}), + endpoint( + "GET", + "/posts/{slug}", + "Bericht", + "Een gepubliceerd bericht via slug of uid.", + fields=[field("slug", required=True, location="path", example="een-zes-is-geen-ambitie")], + sample={"post": {"title": "Een zes is geen ambitie.", "topic": "rol"}, "author": {"username": "retoor"}}, + probe_path="/posts/bestaat-niet", + ), + endpoint("GET", "/flyers", "Flyers", "Galerij met flyers.", sample={"kind": "flyer", "title": "Flyers", "items": []}), + endpoint("GET", "/memes", "Memes", "Galerij met memes.", sample={"kind": "meme", "title": "Memes", "items": []}), + endpoint("GET", "/mensen", "Mensen", "Mensen op de rol.", sample={"people": [{"username": "retoor", "post_count": 5}]}), + endpoint( + "GET", + "/mensen/{username}", + "Persoon", + "Profiel met berichten.", + fields=[field("username", required=True, location="path", example="retoor")], + sample={"person": {"username": "retoor"}, "posts": []}, + probe_path="/mensen/retoor", + ), + endpoint("GET", "/robots.txt", "robots.txt", negotiation="none", interactive=False), + endpoint("GET", "/sitemap.xml", "Sitemap", negotiation="none", interactive=False), + endpoint("GET", "/avatar/svg/{seed}", "Avatar", "Deterministische SVG-avatar.", negotiation="none", interactive=False, probe_path="/avatar/svg/retoor"), + endpoint("GET", "/health", "Health", "Liveness en versie.", negotiation="json", sample={"status": "ok", "version": "1.0.0"}), + endpoint("GET", "/presence/roster", "Presence", "Online-set (uids).", negotiation="json", sample={"online": [], "tracked": 0}), + ], +} diff --git a/molodetz/docs_api/registry.py b/molodetz/docs_api/registry.py new file mode 100644 index 0000000..183094f --- /dev/null +++ b/molodetz/docs_api/registry.py @@ -0,0 +1,56 @@ +# retoor +ROLE_BY_AUTH = {"public": "guest", "member": "Member", "admin": "Admin"} + +TARGET_TYPES = ("post", "join_request", "user") + + +def field(name, type="string", required=False, description="", enum=None, example=None, location="body"): + return { + "name": name, + "type": type, + "required": required, + "description": description, + "enum": list(enum or []), + "example": example, + "location": location, + } + + +def endpoint( + method, + path, + title, + description="", + auth="public", + fields=None, + sample=None, + negotiation="negotiable", + destructive=False, + interactive=True, + probe_path=None, + probe_body=None, +): + return { + "method": method, + "path": path, + "title": title, + "description": description, + "auth": auth, + "min_role": ROLE_BY_AUTH[auth], + "fields": list(fields or []), + "sample": sample, + "negotiation": negotiation, + "destructive": destructive, + "interactive": interactive, + "probe_path": probe_path or path, + "probe_body": probe_body, + } + + +def substitute(text, base="", username="", api_key="", app_reference="molodetz-docs-v-1-0-0"): + return ( + text.replace("{{base}}", base) + .replace("{{username}}", username) + .replace("{{api_key}}", api_key) + .replace("{{app_reference}}", app_reference) + ) diff --git a/molodetz/docs_export.py b/molodetz/docs_export.py new file mode 100644 index 0000000..b56347c --- /dev/null +++ b/molodetz/docs_export.py @@ -0,0 +1,37 @@ +# retoor +from html import escape + +from molodetz.docs_api import ordered_groups +from molodetz.docs_prose import page_source, render_page, visible_pages + + +def export_markdown(viewer_is_admin): + parts = ["# Molodetz documentatie", ""] + for page in visible_pages(viewer_is_admin): + parts += [page_source(page["slug"]).strip(), ""] + for group in ordered_groups(): + if group["slug"] == "beheer" and not viewer_is_admin: + continue + parts += [f"## API: {group['title']}", "", group["description"], ""] + for item in group["endpoints"]: + parts.append(f"- `{item['method']} {item['path']}` ({item['auth']}): {item['title']}") + parts.append("") + return "\n".join(parts) + + +def export_html(viewer_is_admin): + body = [] + for page in visible_pages(viewer_is_admin): + body.append(f"
{render_page(page['slug'])}
") + for group in ordered_groups(): + if group["slug"] == "beheer" and not viewer_is_admin: + continue + items = "".join( + f"
  • {escape(item['method'])} {escape(item['path'])} ({escape(item['auth'])}): {escape(item['title'])}
  • " + for item in group["endpoints"] + ) + body.append(f"

    API: {escape(group['title'])}

      {items}
    ") + return ( + "Molodetz documentatie" + f"

    Molodetz documentatie

    {''.join(body)}" + ) diff --git a/molodetz/docs_live.py b/molodetz/docs_live.py new file mode 100644 index 0000000..f726fa9 --- /dev/null +++ b/molodetz/docs_live.py @@ -0,0 +1,19 @@ +# retoor +from molodetz.services.base import manager + + +def live_services(): + rows = [] + for service in manager.services.values(): + rows.append( + { + "name": service.name, + "title": service.title, + "description": service.description, + "fields": [ + {"key": field.key, "type": field.type, "label": field.label, "group": field.group} + for field in service.fields() + ], + } + ) + return rows diff --git a/molodetz/docs_prose.py b/molodetz/docs_prose.py new file mode 100644 index 0000000..95cb838 --- /dev/null +++ b/molodetz/docs_prose.py @@ -0,0 +1,74 @@ +# retoor +import re +from functools import lru_cache + +import mistune +from mistune.renderers.html import HTMLRenderer + +from molodetz import config +from molodetz.rendering import normalize_dashes + +PROSE_DIR = config.TEMPLATES_DIR / "docs" / "prose" + +SECTIONS = ("Begin hier", "Bouwen met de API", "Bijdragen en internals", "Beheer") + +COMPONENTS = ("avatar", "code", "content", "title", "toast", "dialog", "context-menu", "upload", "lightbox", "chat") + +DOCS_PAGES = [ + {"slug": "welkom", "title": "Welkom", "section": "Begin hier", "file": "welkom.md"}, + {"slug": "schrijven", "title": "Schrijven op de rol", "section": "Begin hier", "file": "schrijven.md"}, + {"slug": "binnen", "title": "Binnenkomen", "section": "Begin hier", "file": "binnen.md"}, + {"slug": "juridisch", "title": "Voorwaarden en privacy", "section": "Begin hier", "file": "juridisch.md"}, + {"slug": "api", "title": "API en authenticatie", "section": "Bouwen met de API", "file": "api.md"}, + {"slug": "architectuur", "title": "Architectuur", "section": "Bijdragen en internals", "file": "architectuur.md"}, + {"slug": "frontend", "title": "Frontend en componenten", "section": "Bijdragen en internals", "file": "frontend.md"}, + {"slug": "beheer", "title": "Beheer en services", "section": "Beheer", "file": "beheer.md", "admin": True}, + {"slug": "backups", "title": "Back-ups en onderhoud", "section": "Beheer", "file": "backups.md", "admin": True}, +] + [ + { + "slug": f"component-{name}", + "title": f"dp-{name}", + "section": "Bijdragen en internals", + "file": f"component-{name}.md", + } + for name in COMPONENTS +] + + +class ProseRenderer(HTMLRenderer): + def heading(self, text, level, **attrs): + anchor = re.sub(r"[^a-z0-9-]", "-", re.sub(r"<[^>]+>", "", text).lower()) + anchor = re.sub(r"-+", "-", anchor).strip("-") + return f'\n' + + +_prose_markdown = mistune.create_markdown(escape=False, hard_wrap=True, renderer=ProseRenderer(escape=False), plugins=["table", "strikethrough", "url"]) + + +def visible_pages(viewer_is_admin): + return [page for page in DOCS_PAGES if viewer_is_admin or not page.get("admin")] + + +def page_by_slug(slug): + for page in DOCS_PAGES: + if page["slug"] == slug: + return page + return None + + +@lru_cache(maxsize=64) +def page_source(slug): + page = page_by_slug(slug) + if page is None: + return "" + return normalize_dashes((PROSE_DIR / page["file"]).read_text(encoding="utf-8")) + + +@lru_cache(maxsize=64) +def render_page(slug): + return _prose_markdown(page_source(slug)) + + +def sidebar(viewer_is_admin): + pages = visible_pages(viewer_is_admin) + return [(section, [page for page in pages if page["section"] == section]) for section in SECTIONS] diff --git a/molodetz/docs_search.py b/molodetz/docs_search.py new file mode 100644 index 0000000..add5aea --- /dev/null +++ b/molodetz/docs_search.py @@ -0,0 +1,73 @@ +# retoor +import math +import re +from collections import Counter +from functools import lru_cache + +from bs4 import BeautifulSoup + +from molodetz.docs_api import ordered_groups +from molodetz.docs_prose import DOCS_PAGES, render_page + +TOKEN = re.compile(r"[a-z0-9_]+") +K1 = 1.5 +B = 0.75 + + +def tokenize(text): + return TOKEN.findall((text or "").lower()) + + +@lru_cache(maxsize=1) +def build_index(): + documents = [] + for page in DOCS_PAGES: + text = BeautifulSoup(render_page(page["slug"]), "html.parser").get_text(" ") + documents.append({"title": page["title"], "url": f"/docs/{page['slug']}", "text": text, "admin": bool(page.get("admin"))}) + for group in ordered_groups(): + lines = [group["title"], group["description"]] + for item in group["endpoints"]: + lines.append(f"{item['method']} {item['path']} {item['title']} {item['description']}") + documents.append( + {"title": f"API: {group['title']}", "url": f"/docs/api/{group['slug']}", "text": " ".join(lines), "admin": group["slug"] == "beheer"} + ) + tokens = [Counter(tokenize(doc["title"] + " " + doc["text"])) for doc in documents] + lengths = [sum(counter.values()) for counter in tokens] + average = sum(lengths) / max(1, len(lengths)) + frequency = Counter() + for counter in tokens: + frequency.update(counter.keys()) + return documents, tokens, lengths, average, frequency + + +def _snippet(text, terms, width=160): + lowered = text.lower() + position = min((lowered.find(term) for term in terms if lowered.find(term) >= 0), default=0) + start = max(0, position - width // 3) + snippet = text[start : start + width].strip() + for term in terms: + snippet = re.sub(f"({re.escape(term)})", r"[[\1]]", snippet, flags=re.IGNORECASE) + return snippet + + +def search(query, viewer_is_admin=False, limit=20): + terms = tokenize(query) + if not terms: + return [] + documents, tokens, lengths, average, frequency = build_index() + total = len(documents) + results = [] + for index, doc in enumerate(documents): + if doc["admin"] and not viewer_is_admin: + continue + score = 0.0 + for term in terms: + tf = tokens[index].get(term, 0) + if not tf: + continue + idf = math.log(1 + (total - frequency[term] + 0.5) / (frequency[term] + 0.5)) + score += idf * tf * (K1 + 1) / (tf + K1 * (1 - B + B * lengths[index] / average)) + if score > 0: + results.append({"title": doc["title"], "url": doc["url"], "score": round(score, 3), "snippet": _snippet(doc["text"], terms)}) + results.sort(key=lambda item: item["score"], reverse=True) + return results[:limit] diff --git a/molodetz/emoji_builder.py b/molodetz/emoji_builder.py new file mode 100644 index 0000000..48f4650 --- /dev/null +++ b/molodetz/emoji_builder.py @@ -0,0 +1,45 @@ +# retoor +import json +from functools import lru_cache + +import emoji + +from molodetz import config + +CUSTOM_SHORTCODES = { + "molodetz": "\U0001F58B\uFE0F", + "af": "\u2705", +} + +GENERATED_JS = config.STATIC_DIR / "js" / "generated" / "EmojiMap.js" + + +@lru_cache(maxsize=1) +def shortcode_map(): + mapping = {} + for char, data in emoji.EMOJI_DATA.items(): + if data.get("status") != emoji.STATUS["fully_qualified"]: + continue + names = [data.get("en", "")] + list(data.get("alias", [])) + for name in names: + key = name.strip(":") + if key and key not in mapping: + mapping[key] = char + mapping.update(CUSTOM_SHORTCODES) + return mapping + + +def write_js_module(path=GENERATED_JS): + path.parent.mkdir(parents=True, exist_ok=True) + payload = json.dumps(dict(sorted(shortcode_map().items())), ensure_ascii=False, separators=(",", ":")) + path.write_text( + "// retoor \n" + f"export const EMOJI_MAP = Object.freeze({payload});\n", + encoding="utf-8", + ) + return path + + +def is_single_emoji(value): + value = (value or "").strip() + return emoji.emoji_count(value) == 1 and emoji.purely_emoji(value) diff --git a/molodetz/friendly404.py b/molodetz/friendly404.py new file mode 100644 index 0000000..a6a0fde --- /dev/null +++ b/molodetz/friendly404.py @@ -0,0 +1,13 @@ +# retoor +import random + +POOL = ( + "Deze pagina is nooit afgemaakt. Dat zegt genoeg.", + "Hier stond iets. Of het was bijna klaar.", + "Niet gevonden. Wel een goede reden om terug te gaan naar de rol.", + "Een link die nergens heen gaat is een mening, geen feit.", +) + + +def friendly_message(): + return random.choice(POOL) diff --git a/molodetz/gallery.py b/molodetz/gallery.py new file mode 100644 index 0000000..6b68114 --- /dev/null +++ b/molodetz/gallery.py @@ -0,0 +1,96 @@ +# retoor +import io +import logging + +import imagehash +from PIL import Image + +from molodetz import config +from molodetz.attachments import blob_path, blob_url, write_blob +from molodetz.database import generate_uid, media_by_kind, upsert_media +from molodetz.database.media import get_media_by_file + +logger = logging.getLogger(__name__) + +THUMB_WIDTH = 720 +DUPLICATE_DISTANCE = 2 + +GALLERY = ( + ("flyer-keuzes.jpg", "flyer", "Eenendertig keuzes. Jij hebt er nul."), + ("flyer-chat.jpg", "flyer", "Een chat is geen portfolio."), + ("flyer-bijna.jpg", "flyer", "Bijna is geen staat."), + ("flyer-assistent.jpg", "flyer", "De assistent oordeelt niet."), + ("meme-vloer.jpg", "meme", "Een zesje is prima. De vloer, bedoel je."), + ("meme-split.jpg", "meme", "Stuur ze naar huis."), + ("meme-cursus.jpg", "meme", "Geen ebook. Wel afmaken."), + ("take-chatlog.jpg", "meme", "Je portfolio is een chatlog."), + ("take-copilot.jpg", "meme", "Copilot is geen senior."), + ("take-coping.jpg", "meme", "Een zesje is coping."), + ("poster-thuis.jpg", "meme", "Vibe coders, ga naar huis."), + ("poster-vloer.jpg", "meme", "Een zesje? Dat is de vloer."), + ("poster-cursus.jpg", "meme", "Geen cursus. Wel afmaken."), + ("poster-luiheid.jpg", "meme", "AI pakt je baan niet. Luiheid wel."), + ("poster-spel.jpg", "meme", "Het spel is om. Jij bent het niet."), + ("story.jpg", "meme", "Geen ebook. Geen x10. Afwerken wel."), + ("meme-zes.jpg", "meme", "Een zesje. Gefeliciteerd, je bent gemiddeld."), + ("meme-gesprek.jpg", "meme", "Mijn portfolio is een gesprek."), + ("meme-product.jpg", "meme", "De cursus is het product."), + ("meme-bijna.jpg", "meme", "Iedereen staat bij bijna."), + ("meme-compiles.jpg", "meme", "It compiles. Ship it, king."), + ("meme-senior.jpg", "meme", "My copilot is the senior."), + ("meme-trophy.jpg", "meme", "Peak performance, 6 out of 10."), + ("meme-todo.jpg", "meme", "Side projects, a love story."), +) + + +def _thumbnail_bytes(image): + copy = image.convert("RGB") + copy.thumbnail((THUMB_WIDTH, THUMB_WIDTH * 2)) + buffer = io.BytesIO() + copy.save(buffer, format="WEBP", quality=82, method=4) + return buffer.getvalue() + + +def sync_gallery(): + seen_hashes = [] + synced = 0 + for position, (filename, kind, caption) in enumerate(GALLERY): + source = config.MEDIA_SOURCE_DIR / filename + if not source.exists(): + logger.warning("gallery source missing: %s", source) + continue + existing = get_media_by_file(filename) + with Image.open(source) as image: + width, height = image.size + phash = imagehash.phash(image) + if any(phash - other <= DUPLICATE_DISTANCE for other in seen_hashes): + logger.info("gallery duplicate skipped: %s", filename) + continue + seen_hashes.append(phash) + thumb_directory = existing.get("thumb_directory") if existing else None + thumb_name = existing.get("thumb_name") if existing else None + if not thumb_directory or not blob_path(thumb_directory, thumb_name).exists(): + thumb_directory, thumb_name = write_blob( + existing["uid"] if existing else generate_uid(), "webp", _thumbnail_bytes(image) + ) + upsert_media(filename, kind, caption, position, width, height, str(phash), thumb_directory, thumb_name) + synced += 1 + return synced + + +def gallery_items(kind): + items = [] + for row in media_by_kind(kind): + thumb = blob_url(row["thumb_directory"], row["thumb_name"]) if row.get("thumb_directory") else None + items.append( + { + "uid": row["uid"], + "filename": row["filename"], + "caption": row["caption"], + "width": row["width"], + "height": row["height"], + "source_path": f"/static/media/{row['filename']}", + "thumb_url": thumb, + } + ) + return items diff --git a/molodetz/gates.py b/molodetz/gates.py new file mode 100644 index 0000000..535fe20 --- /dev/null +++ b/molodetz/gates.py @@ -0,0 +1,129 @@ +# retoor +import ast +import re +import sys +from pathlib import Path + +PKG_ROOT = Path(__file__).resolve().parent +REPO_ROOT = PKG_ROOT.parent + +BREAKPOINTS = {360, 480, 768, 1024} +HTTP_CLIENT_ALLOWLIST = {"molodetz/stealth.py", "molodetz/curl_transport.py"} +CLIENT_RENDER_ALLOWLIST = {"molodetz/templates/admin/post_form.html"} +LOADING_BUTTON_MODULE = "molodetz/static/js/utils/LoadingButton.js" +_DASH_ENTITIES = ["&" + name + ";" for name in ("m" + "dash", "n" + "dash", "#8212", "#8211", "#x2014", "#x2013")] +DASH_PATTERN = re.compile("|".join(["\u2014", "\u2013", *_DASH_ENTITIES]), re.IGNORECASE) +DASH_SUFFIXES = {".py", ".html", ".md", ".js", ".css", ".toml", ".txt", ".sh", ".cfg", ".ini"} +DASH_EXEMPT_DIRS = ("molodetz/static/vendor", "molodetz/static/js/generated") + + +def _rel(path): + return path.resolve().relative_to(REPO_ROOT).as_posix() + + +def _files(base, pattern): + return sorted((REPO_ROOT / base).rglob(pattern)) + + +def gate_template_environment(): + offenders = set() + for path in _files("molodetz", "*.py"): + text = path.read_text(encoding="utf-8") + if re.search(r"\b(Jinja2Templates|Environment)\(", text) and _rel(path) != "molodetz/templating.py": + offenders.add(_rel(path)) + return offenders + + +def gate_breakpoints(): + found = set() + for path in _files("molodetz/static/css", "*.css"): + for match in re.finditer(r"@media[^{]*\((?:max|min)-width:\s*(\d+)px\)", path.read_text(encoding="utf-8")): + found.add(int(match.group(1))) + return found + + +def gate_http_clients(): + found = set() + pattern = re.compile(r"\b(httpx\.(Async)?Client|AsyncSession|aiohttp\.ClientSession|urllib\.request\.urlopen)\(") + for path in _files("molodetz", "*.py"): + if pattern.search(path.read_text(encoding="utf-8")): + found.add(_rel(path)) + return found + + +def gate_client_render_templates(): + found = set() + for path in _files("molodetz/templates", "*.html"): + if "/templates/docs/" in path.as_posix(): + continue + if re.search(r" +import re + +BLANK_LINES = re.compile(r"\n[ \t]*\n(?:[ \t]*\n)+") +TRAILING = re.compile(r"[ \t]+\n") + + +def reformat_html(text): + if " +import logging + +import defusedxml.ElementTree as SafeET +import httpx +from bs4 import BeautifulSoup + +from molodetz.database import now_iso, update_join_request +from molodetz.net_guard import UnsafeURL, guard_public_url +from molodetz.stealth import detect_consent_gate, guarded_async_client + +logger = logging.getLogger(__name__) +MAX_BYTES = 512 * 1024 + + +def extract_title(content_type, body): + if "xml" in content_type and "html" not in content_type: + root = SafeET.fromstring(body) + for element in root.iter(): + if element.tag.split("}")[-1] == "title" and element.text: + return element.text.strip()[:200] + return None + soup = BeautifulSoup(body, "lxml") + if soup.title and soup.title.string: + return soup.title.string.strip()[:200] + heading = soup.find("h1") + return heading.get_text(strip=True)[:200] if heading else None + + +async def check_repo_link(row): + url = row.get("repo_url") + if not url: + return {"status": "none"} + try: + await guard_public_url(url) + async with guarded_async_client(timeout=10.0) as client: + response = await client.get(url) + body = response.content[:MAX_BYTES] + redirect = detect_consent_gate(body.decode("utf-8", "ignore")) + if redirect: + response = await client.get(redirect) + body = response.content[:MAX_BYTES] + title = extract_title(response.headers.get("content-type", ""), body) if response.status_code < 400 else None + status = f"http {response.status_code}" + except UnsafeURL as exc: + title, status = None, f"geweigerd: {exc}" + except (httpx.HTTPError, OSError, SafeET.ParseError) as exc: + title, status = None, f"fout: {type(exc).__name__}" + except Exception as exc: + logger.warning("repo link check failed: %s", exc) + title, status = None, f"fout: {type(exc).__name__}" + update_join_request(row["uid"], link_title=title, link_status=status, link_checked_at=now_iso()) + return {"status": status, "title": title} diff --git a/molodetz/main.py b/molodetz/main.py new file mode 100644 index 0000000..99ae839 --- /dev/null +++ b/molodetz/main.py @@ -0,0 +1,440 @@ +# retoor +import fcntl +import logging +import math +import time +import traceback +from collections import defaultdict +from contextlib import asynccontextmanager +from datetime import datetime, timezone + +from fastapi import FastAPI, HTTPException, Request +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse, PlainTextResponse, RedirectResponse, Response +from fastapi.staticfiles import StaticFiles +from starlette.middleware.gzip import GZipMiddleware + +from molodetz import config +from molodetz.attachments import INLINE_EXTENSIONS, extension_of +from molodetz.constants import ( + CRAWLER_MARKERS, + CRAWLER_PRIVATE_PREFIXES, + GZIP_SKIP_PREFIXES, + MAINTENANCE_ALLOWED_PREFIXES, + RATE_LIMIT_EXEMPT_PREFIXES, + TERMS_EXEMPT_PREFIXES, +) +from molodetz.database import flush_visits, get_int_setting, get_setting, init_db, refresh_snapshot +from molodetz.emoji_builder import GENERATED_JS, write_js_module +from molodetz.friendly404 import friendly_message +from molodetz.html_reformat import reformat_html +from molodetz.responses import json_error, wants_json +from molodetz.routers import ( + auth, + avatar, + binnen, + docs, + flyers, + health, + home, + memes, + mensen, + notifications, + posts, + presence, + privacy, + profile, + rol, + seo, + standaard, + voorwaarden, +) +from molodetz.routers import admin +from molodetz.bootstrap import bootstrap_content +from molodetz.services.base import manager +from molodetz.services.queue import background +from molodetz.services.registry import build_services +from molodetz.templating import templates +from molodetz.utils.audit import record +from molodetz.utils.auth import GuardRedirect, get_current_user, is_admin +from molodetz.utils.presence import touch +from molodetz.utils.request_meta import client_ip, safe_next + +logging.basicConfig(level=config.LOG_LEVEL, format="%(asctime)s %(levelname)s %(name)s: %(message)s") +logger = logging.getLogger("molodetz") + +_visit_counts = defaultdict(int) +_visit_state = {"last_flush": time.monotonic()} +_lock_handles = {} + + +def _take_lock(path, blocking): + handle = open(path, "a+") + flags = fcntl.LOCK_EX if blocking else fcntl.LOCK_EX | fcntl.LOCK_NB + try: + fcntl.flock(handle, flags) + except BlockingIOError: + handle.close() + return None + return handle + + +def _flush_visit_counts(): + if not _visit_counts: + return + day = datetime.now(timezone.utc).date().isoformat() + snapshot = dict(_visit_counts) + _visit_counts.clear() + _visit_state["last_flush"] = time.monotonic() + background.submit(flush_visits, day, snapshot) + + +@asynccontextmanager +async def lifespan(app): + config.ensure_data_dirs() + init_handle = _take_lock(config.INIT_LOCK_FILE, blocking=True) + try: + init_db() + bootstrap_content() + if not GENERATED_JS.exists(): + write_js_module() + finally: + fcntl.flock(init_handle, fcntl.LOCK_UN) + init_handle.close() + for service in build_services(): + manager.register(service) + if not config.DISABLE_SERVICES: + background.start() + service_handle = _take_lock(config.SERVICE_LOCK_FILE, blocking=False) + if service_handle is not None: + _lock_handles["service"] = service_handle + manager.supervise() + logger.info("service lock acquired; supervising %d services", len(manager.services)) + else: + logger.info("service lock held elsewhere; skipping supervision") + yield + if _visit_counts: + _flush_visit_counts() + await manager.shutdown_all() + await background.stop() + for handle in _lock_handles.values(): + handle.close() + _lock_handles.clear() + + +app = FastAPI( + title=config.APP_TITLE, + docs_url="/swagger", + redoc_url=None, + swagger_ui_oauth2_redirect_url=None, + openapi_url="/openapi.json", + lifespan=lifespan, +) + +app.include_router(auth.router, prefix="/auth") +app.include_router(admin.router, prefix="/admin") +app.include_router(avatar.router, prefix="/avatar") +app.include_router(profile.router, prefix="/profile") +app.include_router(notifications.router, prefix="/notifications") +app.include_router(presence.router, prefix="/presence") +app.include_router(posts.router, prefix="/posts") +app.include_router(rol.router, prefix="/rol") +app.include_router(standaard.router, prefix="/standaard") +app.include_router(flyers.router, prefix="/flyers") +app.include_router(memes.router, prefix="/memes") +app.include_router(mensen.router, prefix="/mensen") +app.include_router(binnen.router, prefix="/binnen") +app.include_router(voorwaarden.router, prefix="/voorwaarden") +app.include_router(privacy.router, prefix="/privacy") +app.include_router(health.router, prefix="/health") +app.include_router(seo.router) +app.include_router(docs.router) +app.include_router(home.router) + + +class UploadsStatic(StaticFiles): + async def get_response(self, path, scope): + response = await super().get_response(path, scope) + disposition = "inline" if extension_of(path) in INLINE_EXTENSIONS else "attachment" + response.headers["Content-Disposition"] = disposition + response.headers["Cache-Control"] = "public, max-age=604800" + return response + + +class VersionedStatic(StaticFiles): + async def get_response(self, path, scope): + response = await super().get_response(path, scope) + if path.endswith("sw.js"): + response.headers["Cache-Control"] = "no-cache" + else: + response.headers["Cache-Control"] = "public, max-age=31536000, immutable" + return response + + +class FallbackStatic(StaticFiles): + async def get_response(self, path, scope): + response = await super().get_response(path, scope) + response.headers["Cache-Control"] = "no-cache" if path.endswith("sw.js") else "public, max-age=3600" + return response + + +app.mount("/static/uploads", UploadsStatic(directory=str(config.DATA_PATHS["uploads"]), check_dir=False), name="uploads") +app.mount(f"/static/v{config.STATIC_VERSION}", VersionedStatic(directory=str(config.STATIC_DIR)), name="static_versioned") +app.mount("/static", FallbackStatic(directory=str(config.STATIC_DIR)), name="static") + + +def _is_mutating(request): + return request.method in ("POST", "PUT", "PATCH", "DELETE") + + +@app.middleware("http") +async def snapshot_middleware(request: Request, call_next): + path = request.url.path + if not path.startswith(("/static", "/avatar")): + refresh_snapshot() + return await call_next(request) + + +@app.middleware("http") +async def security_headers_middleware(request: Request, call_next): + response = await call_next(request) + response.headers.setdefault("X-Robots-Tag", "index, follow") + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + response.headers["Content-Security-Policy"] = ( + "object-src 'none'; base-uri 'self'; frame-ancestors 'self'; form-action 'self'" + ) + if request.url.path.startswith("/admin"): + response.headers["Cache-Control"] = "no-store" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" + return response + + +_rate_buckets = defaultdict(list) +_rate_state = {"limit": None, "window": None, "loaded": 0.0, "swept": time.monotonic()} + + +def _rate_settings(): + now = time.monotonic() + if now - _rate_state["loaded"] > 2: + _rate_state["limit"] = max(1, get_int_setting("rate_limit_per_minute", config.RATE_LIMIT)) + _rate_state["window"] = max(1, get_int_setting("rate_limit_window_seconds", 60)) + _rate_state["loaded"] = now + per_worker = max(1, math.ceil(_rate_state["limit"] / max(1, config.WEB_WORKERS))) + return per_worker, _rate_state["window"] + + +@app.middleware("http") +async def rate_limit_middleware(request: Request, call_next): + if config.DISABLE_RATE_LIMIT or not _is_mutating(request): + return await call_next(request) + if RATE_LIMIT_EXEMPT_PREFIXES and request.url.path.startswith(RATE_LIMIT_EXEMPT_PREFIXES): + return await call_next(request) + limit, window = _rate_settings() + now = time.monotonic() + if now - _rate_state["swept"] > 60: + for key in list(_rate_buckets): + _rate_buckets[key] = [stamp for stamp in _rate_buckets[key] if now - stamp < window] + if not _rate_buckets[key]: + del _rate_buckets[key] + _rate_state["swept"] = now + key = client_ip(request) + bucket = [stamp for stamp in _rate_buckets[key] if now - stamp < window] + if len(bucket) >= max(1, limit): + _rate_buckets[key] = bucket + retry = max(1, int(window - (now - bucket[0]))) + record(request, "security.ratelimit.exceeded", result="denied", payload={"ip": key}) + if wants_json(request): + response = json_error(429, "Te veel verzoeken", retry_after=retry) + else: + response = PlainTextResponse("Te veel verzoeken. Probeer het zo opnieuw.", status_code=429) + response.headers["Retry-After"] = str(retry) + return response + bucket.append(now) + _rate_buckets[key] = bucket + return await call_next(request) + + +@app.middleware("http") +async def maintenance_middleware(request: Request, call_next): + if get_setting("maintenance_mode", "0") != "1": + return await call_next(request) + if request.url.path.startswith(MAINTENANCE_ALLOWED_PREFIXES) or is_admin(get_current_user(request)): + return await call_next(request) + message = get_setting("maintenance_message", "Onderhoud") + record(request, "security.maintenance.blocked", result="denied") + if wants_json(request): + return json_error(503, message) + return templates.TemplateResponse( + request, + "error.html", + {"status_code": 503, "message": message, "seo": {"title": "Onderhoud", "robots": "noindex,nofollow"}}, + status_code=503, + ) + + +@app.middleware("http") +async def terms_middleware(request: Request, call_next): + if not _is_mutating(request) or request.url.path.startswith(TERMS_EXEMPT_PREFIXES): + return await call_next(request) + user = get_current_user(request) + version = get_setting("terms_version", "1") + if user is not None and str(user.get("terms_version") or "") != str(version): + target = f"/voorwaarden?next={request.url.path}" + if wants_json(request): + return json_error(403, "Accepteer eerst de voorwaarden", code="terms_required", redirect=target, terms_version=version) + return RedirectResponse(target, status_code=303) + return await call_next(request) + + +@app.middleware("http") +async def crawler_block_middleware(request: Request, call_next): + if request.method == "GET" and request.url.path.startswith(CRAWLER_PRIVATE_PREFIXES): + agent = (request.headers.get("user-agent") or "").lower() + if any(marker in agent for marker in CRAWLER_MARKERS): + record(request, "security.crawler.blocked", result="denied", payload={"agent": agent[:120]}) + if wants_json(request): + return json_error(403, "Crawlers are not allowed here") + return PlainTextResponse("Forbidden", status_code=403) + return await call_next(request) + + +@app.middleware("http") +async def presence_middleware(request: Request, call_next): + if not request.url.path.startswith(("/static", "/avatar")): + user = get_current_user(request) + if user: + touch(user["uid"]) + return await call_next(request) + + +@app.middleware("http") +async def visits_middleware(request: Request, call_next): + response = await call_next(request) + path = request.url.path + if not path.startswith(("/static", "/avatar")): + _visit_counts[(path[:200], response.status_code)] += 1 + if time.monotonic() - _visit_state["last_flush"] > 30: + _flush_visit_counts() + return response + + +@app.middleware("http") +async def html_reformat_middleware(request: Request, call_next): + response = await call_next(request) + content_type = response.headers.get("content-type", "") + if not content_type.startswith("text/html") or response.headers.get("content-encoding"): + return response + body = b"" + async for chunk in response.body_iterator: + body += chunk + try: + body = reformat_html(body.decode("utf-8")).encode("utf-8") + except Exception: + logger.warning("html reformat failed; serving original body") + headers = dict(response.headers) + headers["content-length"] = str(len(body)) + return Response(content=body, status_code=response.status_code, headers=headers, media_type=None) + + +@app.middleware("http") +async def timing_middleware(request: Request, call_next): + request.state.request_start = time.perf_counter() + response = await call_next(request) + response.headers["X-Response-Time"] = f"{(time.perf_counter() - request.state.request_start) * 1000:.1f}ms" + return response + + +class SelectiveGZipMiddleware(GZipMiddleware): + async def __call__(self, scope, receive, send): + if scope["type"] == "http" and GZIP_SKIP_PREFIXES and scope["path"].startswith(GZIP_SKIP_PREFIXES): + await self.app(scope, receive, send) + return + await super().__call__(scope, receive, send) + + +app.add_middleware(SelectiveGZipMiddleware, minimum_size=512, compresslevel=5) + + +def _error_page(request, status_code, message, headers=None): + seo = {"title": f"{status_code}", "robots": "noindex,nofollow", "description": message} + return templates.TemplateResponse( + request, + "error.html", + {"status_code": status_code, "message": message, "seo": seo}, + status_code=status_code, + headers=headers, + ) + + +@app.exception_handler(GuardRedirect) +async def guard_redirect_handler(request: Request, exc: GuardRedirect): + return RedirectResponse(exc.url, status_code=exc.status_code) + + +@app.exception_handler(HTTPException) +async def http_exception_handler(request: Request, exc: HTTPException): + if wants_json(request) or request.headers.get("x-api-key") or request.headers.get("authorization"): + return json_error(exc.status_code, str(exc.detail)) + if exc.status_code == 404: + message = friendly_message() if get_setting("friendly_404", "1") == "1" else "Niet gevonden." + return _error_page(request, 404, message) + return _error_page(request, exc.status_code, str(exc.detail), headers=getattr(exc, "headers", None)) + + +@app.exception_handler(404) +async def not_found_handler(request: Request, exc): + return await http_exception_handler(request, HTTPException(404, "Niet gevonden")) + + +@app.exception_handler(500) +async def server_error_handler(request: Request, exc): + logger.error("unhandled error: %s", "".join(traceback.format_exception(exc))) + if wants_json(request): + return json_error(500, "Interne fout") + return _error_page(request, 500, "Er ging iets mis aan onze kant.") + + +AUTH_TEMPLATES = {"/auth/login": "auth/login.html"} + + +def _friendly_messages(errors): + fields, messages = [], [] + for error in errors: + loc = [str(part) for part in error.get("loc", ()) if part not in ("body", "query")] + field = loc[-1] if loc else "body" + message = str(error.get("msg", "Ongeldige invoer")).replace("Value error, ", "") + if field == "password" and "at least" in message: + message = "Wachtwoord moet 6 tot 128 tekens zijn." + fields.append(field) + messages.append(f"{field}: {message}" if field != "body" else message) + return fields, messages + + +@app.exception_handler(RequestValidationError) +async def validation_handler(request: Request, exc: RequestValidationError): + fields, messages = _friendly_messages(exc.errors()) + if wants_json(request): + return JSONResponse({"error": "validation", "fields": fields, "messages": messages}, status_code=422) + template = AUTH_TEMPLATES.get(request.url.path) + if template: + form = await request.form() + echoed = {key: value for key, value in form.items() if isinstance(value, str) and key != "password"} + return templates.TemplateResponse( + request, + template, + {"errors": messages, "form": echoed, "next": echoed.get("next"), "seo": {"title": "Inloggen", "robots": "noindex,nofollow"}}, + status_code=400, + ) + if request.url.path == "/binnen": + form = await request.form() + echoed = {key: value for key, value in form.items() if isinstance(value, str)} + return templates.TemplateResponse( + request, + "binnen.html", + {"errors": messages, "form": echoed, "submitted": False, "seo": {"title": "Binnen", "robots": "index,follow"}}, + status_code=400, + ) + return RedirectResponse(safe_next(request.headers.get("referer", "").replace(str(request.base_url).rstrip("/"), ""), "/"), status_code=303) diff --git a/molodetz/models.py b/molodetz/models.py new file mode 100644 index 0000000..5ba4693 --- /dev/null +++ b/molodetz/models.py @@ -0,0 +1,131 @@ +# retoor +import re +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from molodetz.constants import JOIN_STATUSES, POST_STATUSES, ROLES, TOPICS + +URL_HOST_PATTERN = re.compile(r"^https?://[^\s/]+\.[^\s/]+") +PROSE_CAP = 125000 + + +def normalize_website(value): + value = (value or "").strip() + if not value: + return None + if not value.lower().startswith(("http://", "https://")): + value = "https://" + value + if not URL_HOST_PATTERN.match(value): + raise ValueError("Geef een geldige link op, bijvoorbeeld https://git.example.nl/naam") + return value[:500] + + +def normalize_european_date(value): + value = (value or "").strip() + if not value: + return None + for fmt in ("%d/%m/%Y", "%Y-%m-%d"): + try: + return datetime.strptime(value, fmt).date().isoformat() + except ValueError: + continue + raise ValueError("Gebruik DD/MM/JJJJ") + + +class FormModel(BaseModel): + model_config = ConfigDict(str_strip_whitespace=True, extra="ignore") + + +class LoginForm(FormModel): + username: str = Field(min_length=3, max_length=255) + password: str = Field(min_length=6, max_length=128) + remember: bool = False + next: str | None = None + + +class PostForm(FormModel): + title: str = Field(min_length=3, max_length=200) + body: str = Field(min_length=10, max_length=PROSE_CAP) + topic: str = "rol" + status: str = "draft" + is_placeholder: bool = False + + @field_validator("topic") + @classmethod + def check_topic(cls, value): + if value not in TOPICS: + raise ValueError("Onbekend onderwerp") + return value + + @field_validator("status") + @classmethod + def check_status(cls, value): + if value not in POST_STATUSES: + raise ValueError("Onbekende status") + return value + + +class JoinForm(FormModel): + name: str = Field(min_length=2, max_length=80) + contact: str = Field(min_length=3, max_length=255) + repo_url: str | None = Field(default=None, max_length=500) + message: str | None = Field(default=None, max_length=2000) + website: str | None = Field(default=None, max_length=200) + + @field_validator("repo_url") + @classmethod + def check_repo(cls, value): + return normalize_website(value) + + +class JoinStatusForm(FormModel): + status: str + + @field_validator("status") + @classmethod + def check_status(cls, value): + if value not in JOIN_STATUSES: + raise ValueError("Onbekende status") + return value + + +class RoleForm(FormModel): + role: str + + @field_validator("role") + @classmethod + def check_role(cls, value): + if value not in ROLES: + raise ValueError("Onbekende rol") + return value + + +class SettingsForm(FormModel): + site_title: str | None = Field(default=None, max_length=80) + site_tagline: str | None = Field(default=None, max_length=200) + maintenance_mode: str | None = None + maintenance_message: str | None = Field(default=None, max_length=500) + rate_limit_per_minute: str | None = None + rate_limit_window_seconds: str | None = None + session_max_age_days: str | None = None + session_remember_days: str | None = None + terms_version: str | None = None + friendly_404: str | None = None + audit_retention_days: str | None = None + outbound_proxy_url: str | None = Field(default=None, max_length=500) + + +class ServiceConfigForm(FormModel): + model_config = ConfigDict(extra="allow", str_strip_whitespace=True) + + +class BackupForm(FormModel): + target: str = "database" + + @field_validator("target") + @classmethod + def check_target(cls, value): + if value not in ("database", "uploads", "keys", "full"): + raise ValueError("Onbekend doel") + return value diff --git a/molodetz/net_guard.py b/molodetz/net_guard.py new file mode 100644 index 0000000..a8935ac --- /dev/null +++ b/molodetz/net_guard.py @@ -0,0 +1,68 @@ +# retoor +import asyncio +import ipaddress +import socket +from urllib.parse import urlsplit + +import httpx + +NAT64_PREFIX = ipaddress.ip_network("64:ff9b::/96") + + +class UnsafeURL(ValueError): + pass + + +def _is_private(address): + ip = ipaddress.ip_address(address) + if isinstance(ip, ipaddress.IPv6Address): + if ip.ipv4_mapped is not None: + ip = ip.ipv4_mapped + elif ip in NAT64_PREFIX: + ip = ipaddress.IPv4Address(int(ip) & 0xFFFFFFFF) + return ( + ip.is_private + or ip.is_loopback + or ip.is_link_local + or ip.is_reserved + or ip.is_multicast + or ip.is_unspecified + ) + + +def _resolve(host, port): + return {info[4][0] for info in socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)} + + +async def guard_public_host(host, port=443): + if not host: + raise UnsafeURL("missing host") + try: + addresses = await asyncio.to_thread(_resolve, host, port) + except socket.gaierror as exc: + raise UnsafeURL(f"cannot resolve {host}") from exc + for address in addresses: + if _is_private(address): + raise UnsafeURL(f"{host} resolves to a non-public address") + return addresses + + +async def guard_public_url(url): + parts = urlsplit(url) + if parts.scheme not in ("http", "https"): + raise UnsafeURL("scheme must be http or https") + port = parts.port or (443 if parts.scheme == "https" else 80) + await guard_public_host(parts.hostname, port) + return url + + +class GuardedTransport(httpx.AsyncBaseTransport): + def __init__(self, inner): + self.inner = inner + + async def handle_async_request(self, request): + await guard_public_url(str(request.url)) + return await self.inner.handle_async_request(request) + + async def aclose(self): + await self.inner.aclose() diff --git a/molodetz/rendering.py b/molodetz/rendering.py new file mode 100644 index 0000000..90933ab --- /dev/null +++ b/molodetz/rendering.py @@ -0,0 +1,167 @@ +# retoor +import re +from functools import lru_cache + +import mistune +from bs4 import BeautifulSoup, NavigableString +from mistune.renderers.html import HTMLRenderer + +from molodetz.emoji_builder import shortcode_map + +_DASH_ENTITIES = ["&" + name + ";" for name in ("m" + "dash", "n" + "dash", "#8212", "#8211", "#x2014", "#x2013")] +DASH_PATTERN = re.compile("|".join(["\u2014", "\u2013", *_DASH_ENTITIES]), re.IGNORECASE) +SHORTCODE_PATTERN = re.compile(r":([a-z0-9_+\-]+):") +URL_PATTERN = re.compile(r"(?\"']+)") +MENTION_PATTERN = re.compile(r"(?{text}' + + def image(self, text, url, title=None): + src = self.safe_url(url) + alt = mistune.escape(text or "") + return f'{alt}' + + +_markdown = mistune.create_markdown( + escape=True, + hard_wrap=True, + renderer=ContentRenderer(escape=True), + plugins=["strikethrough", "table", "url"], +) + +_inline_markdown = mistune.create_markdown(escape=True, renderer=ContentRenderer(escape=True), plugins=["strikethrough"]) + + +def _embed_for(url): + path = url.lower().split("?", 1)[0] + if path.endswith(IMAGE_EXTENSIONS): + return f'' + if path.endswith(VIDEO_EXTENSIONS): + return f'' + if path.endswith(AUDIO_EXTENSIONS): + return f'' + return f'{url}' + + +def _media_pass(html): + soup = BeautifulSoup(html, "html.parser") + for anchor in list(soup.find_all("a")): + href = anchor.get("href", "") + if anchor.get_text() == href and any(parent.name in ("code", "pre") for parent in anchor.parents) is False: + embed = _embed_for(href) + if not embed.startswith("@{m.group(1)}', escaped) + fragment = BeautifulSoup(replaced, "html.parser") + node.replace_with(*list(fragment.contents)) + return str(soup) + + +@lru_cache(maxsize=2048) +def render_content(text): + prepared = replace_shortcodes(normalize_dashes(text)) + html = _markdown(prepared) + return _media_pass(html) + + +@lru_cache(maxsize=2048) +def render_title(text): + prepared = replace_shortcodes(normalize_dashes(text)) + html = _inline_markdown(prepared).strip() + if html.startswith("

    ") and html.endswith("

    "): + html = html[3:-4] + soup = BeautifulSoup(html, "html.parser") + for tag in soup.find_all(True): + if tag.name not in ("em", "strong", "code", "del"): + tag.unwrap() + return str(soup) + + +def plain_text(text): + html = render_content(text or "") + return BeautifulSoup(html, "lxml").get_text(" ", strip=True) + + +def safe_truncate(text, limit=180): + text = re.sub(r"\s+", " ", text or "").strip() + if len(text) <= limit: + return text + tokens = text[:limit].split(" ") + if len(tokens) > 1: + tokens = tokens[:-1] + return " ".join(tokens).rstrip(",.;:") + "..." + + +def content_preview(text, limit=180): + return safe_truncate(plain_text(text), limit) + + +def plain_title(text): + return BeautifulSoup(render_title(text or ""), "html.parser").get_text() + + +def markdown_structure_signature(text): + text = text or "" + return { + "code_fences": len(re.findall(r"^```", text, re.MULTILINE)) // 2, + "list_items": len(re.findall(r"^\s*(?:[-*+]|\d+\.)\s+", text, re.MULTILINE)), + "headers": len(re.findall(r"^#{1,6}\s", text, re.MULTILINE)), + "links": len(re.findall(r"\[[^\]]+\]\([^)]+\)", text)), + } + + +def extract_preview_urls(text, self_host=""): + urls = [] + for match in URL_PATTERN.finditer(text or ""): + url = match.group(1) + lowered = url.lower().split("?", 1)[0] + if lowered.endswith(IMAGE_EXTENSIONS + VIDEO_EXTENSIONS + AUDIO_EXTENSIONS): + continue + if self_host and self_host in url: + continue + if url not in urls: + urls.append(url) + if len(urls) == 4: + break + return urls diff --git a/molodetz/responses.py b/molodetz/responses.py new file mode 100644 index 0000000..1fd99d3 --- /dev/null +++ b/molodetz/responses.py @@ -0,0 +1,40 @@ +# retoor +import logging + +from fastapi.responses import JSONResponse, RedirectResponse + +logger = logging.getLogger(__name__) + + +def wants_json(request): + content_type = request.headers.get("content-type", "") + if content_type.startswith("application/json"): + return True + accept = request.headers.get("accept", "") + return "application/json" in accept and "text/html" not in accept + + +def json_error(status_code, message, **extra): + body = {"status": status_code, "message": message} + body.update(extra) + return JSONResponse({"error": body}, status_code=status_code) + + +def respond(request, template, ctx, *, model, status_code=200): + if wants_json(request): + try: + payload = model.model_validate(ctx).model_dump(mode="json") + except Exception: + logger.exception("schema validation failed for %s", model.__name__) + return json_error(500, "Response serialization failed") + return JSONResponse(payload, status_code=status_code) + from molodetz.templating import templates + + context = dict(ctx) + return templates.TemplateResponse(request, template, context, status_code=status_code) + + +def action_result(request, redirect_url, *, data=None, status_code=302): + if wants_json(request): + return JSONResponse({"ok": True, "redirect": redirect_url, "data": data}) + return RedirectResponse(redirect_url, status_code=status_code) diff --git a/molodetz/routers/__init__.py b/molodetz/routers/__init__.py new file mode 100644 index 0000000..95ee7c3 --- /dev/null +++ b/molodetz/routers/__init__.py @@ -0,0 +1 @@ +# retoor diff --git a/molodetz/routers/_gallery.py b/molodetz/routers/_gallery.py new file mode 100644 index 0000000..9f71c92 --- /dev/null +++ b/molodetz/routers/_gallery.py @@ -0,0 +1,24 @@ +# retoor +from molodetz.constants import MEDIA_KINDS +from molodetz.gallery import gallery_items +from molodetz.responses import respond +from molodetz.schemas.gallery import GalleryOut +from molodetz.seo import base_seo_context +from molodetz.seo_meta_text import description_for +from molodetz.templating import static_url + + +def gallery_page(request, kind, slug): + title = MEDIA_KINDS[kind] + items = gallery_items(kind) + for item in items: + item["url"] = static_url(item["source_path"]) + first = items[0]["url"] if items else None + seo = base_seo_context( + request, + title, + description_for(slug), + og_image=first, + breadcrumbs=[("Molodetz", "/"), (title, f"/{slug}")], + ) + return respond(request, "gallery.html", {"kind": kind, "title": title, "items": items, "seo": seo}, model=GalleryOut) diff --git a/molodetz/routers/_listing.py b/molodetz/routers/_listing.py new file mode 100644 index 0000000..5d8d50a --- /dev/null +++ b/molodetz/routers/_listing.py @@ -0,0 +1,25 @@ +# retoor +from molodetz.constants import TOPICS +from molodetz.database import published_posts +from molodetz.responses import respond +from molodetz.schemas.posts import PostListOut +from molodetz.seo import base_seo_context, website_schema +from molodetz.seo_meta_text import description_for + + +def topic_listing(request, topic): + before = request.query_params.get("before") + posts, next_cursor = published_posts(topic=topic, before=before, limit=25) + title = TOPICS[topic] + next_url = f"{request.url.path}?before={next_cursor}" if next_cursor else None + seo = base_seo_context( + request, + title, + description_for(topic), + breadcrumbs=[("Molodetz", "/"), (title, f"/{topic}")], + schemas=[website_schema(request)], + next_url=next_url, + robots="noindex,follow" if before else "index,follow", + ) + ctx = {"title": title, "topic": topic, "posts": posts, "next_cursor": next_cursor, "next_url": next_url, "seo": seo} + return respond(request, "listing.html", ctx, model=PostListOut) diff --git a/molodetz/routers/admin/__init__.py b/molodetz/routers/admin/__init__.py new file mode 100644 index 0000000..b5fcf17 --- /dev/null +++ b/molodetz/routers/admin/__init__.py @@ -0,0 +1,13 @@ +# retoor +from molodetz.routers.admin import audit, backups, joins, posts, services, settings, stats, trash, users +from molodetz.routers.admin.index import router + +router.include_router(posts.router) +router.include_router(joins.router) +router.include_router(services.router) +router.include_router(trash.router) +router.include_router(settings.router) +router.include_router(users.router) +router.include_router(audit.router) +router.include_router(backups.router) +router.include_router(stats.router) diff --git a/molodetz/routers/admin/_shared.py b/molodetz/routers/admin/_shared.py new file mode 100644 index 0000000..ac5c69c --- /dev/null +++ b/molodetz/routers/admin/_shared.py @@ -0,0 +1,6 @@ +# retoor +from molodetz.seo import base_seo_context + + +def admin_seo(request, title): + return base_seo_context(request, title, robots="noindex,nofollow") diff --git a/molodetz/routers/admin/audit.py b/molodetz/routers/admin/audit.py new file mode 100644 index 0000000..493dba5 --- /dev/null +++ b/molodetz/routers/admin/audit.py @@ -0,0 +1,17 @@ +# retoor +from fastapi import APIRouter, Request + +from molodetz.database import list_audit +from molodetz.responses import respond +from molodetz.routers.admin._shared import admin_seo +from molodetz.schemas.admin import AuditOut +from molodetz.utils.auth import require_admin + +router = APIRouter() + + +@router.get("/audit") +async def audit_index(request: Request): + require_admin(request) + category = request.query_params.get("category") or None + return respond(request, "admin/audit.html", {"rows": list_audit(200, category), "category": category, "seo": admin_seo(request, "Auditlog")}, model=AuditOut) diff --git a/molodetz/routers/admin/backups.py b/molodetz/routers/admin/backups.py new file mode 100644 index 0000000..4fa5671 --- /dev/null +++ b/molodetz/routers/admin/backups.py @@ -0,0 +1,60 @@ +# retoor +import aiofiles +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import StreamingResponse + +from molodetz.attachments import format_size +from molodetz.database import enqueue_backup, get_backup, list_backups +from molodetz.dependencies import json_or_form +from molodetz.models import BackupForm +from molodetz.responses import action_result, respond +from molodetz.routers.admin._shared import admin_seo +from molodetz.schemas.admin import BackupsOut +from molodetz.services.backup import backup_path +from molodetz.utils.audit import record +from molodetz.utils.auth import is_primary_admin, not_found, require_admin + +router = APIRouter() + + +@router.get("/backups") +async def backups_index(request: Request): + user = require_admin(request) + rows = [{**row, "size_label": format_size(row.get("size_bytes"))} for row in list_backups()] + ctx = {"backups": rows, "can_download": is_primary_admin(user), "seo": admin_seo(request, "Back-ups")} + return respond(request, "admin/backups.html", ctx, model=BackupsOut) + + +@router.post("/backups") +async def backups_enqueue(request: Request): + user = require_admin(request) + data = await json_or_form(request, BackupForm) + row = enqueue_backup(data.target, user["uid"]) + record(request, "backup.enqueue", payload={"target": data.target}, targets=[("backup", row["uid"])], user=user) + return action_result(request, "/admin/backups", data={"uid": row["uid"]}) + + +@router.get("/backups/{uid}/download") +async def backups_download(request: Request, uid: str): + user = require_admin(request) + if not is_primary_admin(user): + record(request, "backup.download", result="denied", targets=[("backup", uid)], user=user) + raise HTTPException(403, "Alleen de primaire beheerder") + row = get_backup(uid) + if row is None or row.get("status") != "done": + not_found("Back-up niet gevonden") + path = backup_path(row) + if not path.exists(): + not_found("Archief ontbreekt") + record(request, "backup.download", targets=[("backup", uid)], user=user) + return StreamingResponse( + _stream_file(path), + media_type="application/octet-stream", + headers={"Content-Disposition": f'attachment; filename="{row["stored_name"]}"', "Content-Length": str(path.stat().st_size)}, + ) + + +async def _stream_file(path, chunk_size=1024 * 256): + async with aiofiles.open(path, "rb") as handle: + while chunk := await handle.read(chunk_size): + yield chunk diff --git a/molodetz/routers/admin/index.py b/molodetz/routers/admin/index.py new file mode 100644 index 0000000..5a3354d --- /dev/null +++ b/molodetz/routers/admin/index.py @@ -0,0 +1,24 @@ +# retoor +from fastapi import APIRouter, Request + +from molodetz.constants import SOFT_DELETE_TABLES +from molodetz.database import all_posts, count_deleted, count_open_join_requests, count_users +from molodetz.responses import respond +from molodetz.routers.admin._shared import admin_seo +from molodetz.schemas.admin import AdminIndexOut +from molodetz.utils.auth import require_admin + +router = APIRouter() + + +@router.get("") +async def admin_index(request: Request): + require_admin(request) + ctx = { + "post_count": len(all_posts()), + "open_joins": count_open_join_requests(), + "user_count": count_users(), + "deleted_count": sum(count_deleted(name) for name in SOFT_DELETE_TABLES), + "seo": admin_seo(request, "Beheer"), + } + return respond(request, "admin/index.html", ctx, model=AdminIndexOut) diff --git a/molodetz/routers/admin/joins.py b/molodetz/routers/admin/joins.py new file mode 100644 index 0000000..510536b --- /dev/null +++ b/molodetz/routers/admin/joins.py @@ -0,0 +1,43 @@ +# retoor +from fastapi import APIRouter, Request + +from molodetz.database import get_join_request, list_join_requests, update_join_request +from molodetz.dependencies import json_or_form +from molodetz.link_check import check_repo_link +from molodetz.models import JoinStatusForm +from molodetz.responses import action_result, respond +from molodetz.routers.admin._shared import admin_seo +from molodetz.schemas.joins import JoinListOut +from molodetz.utils.audit import record +from molodetz.utils.auth import not_found, require_admin + +router = APIRouter() + + +@router.get("/joins") +async def joins_index(request: Request): + require_admin(request) + return respond(request, "admin/joins.html", {"requests": list_join_requests(), "seo": admin_seo(request, "Aanmeldingen")}, model=JoinListOut) + + +@router.post("/joins/{uid}/status") +async def joins_status(request: Request, uid: str): + user = require_admin(request) + row = get_join_request(uid) + if row is None: + not_found("Aanmelding niet gevonden") + data = await json_or_form(request, JoinStatusForm) + update_join_request(uid, status=data.status) + record(request, "join.request.status", payload={"status": data.status}, targets=[("join_request", uid)], user=user) + return action_result(request, "/admin/joins", data={"uid": uid, "status": data.status}) + + +@router.post("/joins/{uid}/check") +async def joins_check(request: Request, uid: str): + user = require_admin(request) + row = get_join_request(uid) + if row is None: + not_found("Aanmelding niet gevonden") + result = await check_repo_link(row) + record(request, "join.request.check", payload=result, targets=[("join_request", uid)], user=user) + return action_result(request, "/admin/joins", data=result) diff --git a/molodetz/routers/admin/posts.py b/molodetz/routers/admin/posts.py new file mode 100644 index 0000000..88e49b8 --- /dev/null +++ b/molodetz/routers/admin/posts.py @@ -0,0 +1,88 @@ +# retoor +from fastapi import APIRouter, HTTPException, Request + +from molodetz.constants import TOPICS +from molodetz.content import create_post, delete_post, edit_post +from molodetz.database import all_posts, get_post +from molodetz.dependencies import json_or_form +from molodetz.models import PostForm +from molodetz.responses import action_result, respond +from molodetz.routers.admin._shared import admin_seo +from molodetz.schemas.admin import AdminPostsOut +from molodetz.schemas.posts import PostDetailOut +from molodetz.utils.audit import record +from molodetz.utils.auth import is_admin, is_owner, not_found, require_admin + +router = APIRouter() + + +@router.get("/posts") +async def posts_index(request: Request): + require_admin(request) + return respond(request, "admin/posts.html", {"posts": all_posts(), "seo": admin_seo(request, "Berichten")}, model=AdminPostsOut) + + +@router.get("/posts/new") +async def posts_new(request: Request): + require_admin(request) + ctx = {"post": None, "topics": TOPICS, "errors": [], "seo": admin_seo(request, "Nieuw bericht")} + return respond(request, "admin/post_form.html", ctx, model=PostDetailOut) + + +@router.post("/posts") +async def posts_create(request: Request): + user = require_admin(request) + data = await json_or_form(request, PostForm) + row = create_post(user, data.title, data.body, data.topic, data.status, data.is_placeholder, request=request) + return action_result(request, "/admin/posts", data={"uid": row["uid"], "slug": row["slug"]}) + + +@router.get("/posts/{uid}/edit") +async def posts_edit(request: Request, uid: str): + require_admin(request) + post = get_post(uid) + if post is None: + not_found("Bericht niet gevonden") + ctx = {"post": post, "topics": TOPICS, "errors": [], "seo": admin_seo(request, "Bericht bewerken")} + return respond(request, "admin/post_form.html", ctx, model=PostDetailOut) + + +def _owned_post(request, user, uid): + post = get_post(uid) + if post is None: + not_found("Bericht niet gevonden") + if not is_owner(post, user): + record(request, "post.edit.denied", result="denied", targets=[("post", uid)], user=user) + raise HTTPException(403, "Alleen de auteur mag dit bericht bewerken") + return post + + +@router.post("/posts/{uid}") +async def posts_update(request: Request, uid: str): + user = require_admin(request) + post = _owned_post(request, user, uid) + data = await json_or_form(request, PostForm) + edit_post(post, user, data.title, data.body, data.topic, data.status, data.is_placeholder, request=request) + return action_result(request, "/admin/posts", data={"uid": uid}) + + +@router.post("/posts/{uid}/publish") +async def posts_publish(request: Request, uid: str): + user = require_admin(request) + post = _owned_post(request, user, uid) + status = "draft" if post["status"] == "published" else "published" + edit_post(post, user, post["title"], post["body"], post["topic"], status, post.get("is_placeholder"), request=request) + return action_result(request, "/admin/posts", data={"uid": uid, "status": status}) + + +@router.post("/posts/{uid}/delete") +async def posts_delete(request: Request, uid: str): + user = require_admin(request) + post = get_post(uid) + if post is None: + not_found("Bericht niet gevonden") + if not (is_owner(post, user) or is_admin(user)): + record(request, "post.delete.denied", result="denied", targets=[("post", uid)], user=user) + raise HTTPException(403, "Niet toegestaan") + stamp = delete_post(post, user, request=request) + return action_result(request, "/admin/posts", data={"uid": uid, "stamp": stamp}) diff --git a/molodetz/routers/admin/services.py b/molodetz/routers/admin/services.py new file mode 100644 index 0000000..5f82632 --- /dev/null +++ b/molodetz/routers/admin/services.py @@ -0,0 +1,69 @@ +# retoor +from fastapi import APIRouter, HTTPException, Request + +from molodetz.responses import action_result, respond +from molodetz.routers.admin._shared import admin_seo +from molodetz.schemas.admin import ServiceDetailOut, ServicesOut +from molodetz.services.base import manager +from molodetz.utils.audit import record +from molodetz.utils.auth import not_found, require_admin + +router = APIRouter() +VERBS = ("start", "stop", "run", "clear") + + +def _service(name): + service = manager.get(name) + if service is None: + not_found("Onbekende service") + return service + + +@router.get("/services") +async def services_index(request: Request): + require_admin(request) + return respond(request, "admin/services.html", {"services": manager.describe_all(), "seo": admin_seo(request, "Services")}, model=ServicesOut) + + +@router.get("/services/{name}") +async def services_detail(request: Request, name: str): + require_admin(request) + service = _service(name) + ctx = {"service": manager.describe(service), "seo": admin_seo(request, service.title)} + return respond(request, "admin/service.html", ctx, model=ServiceDetailOut) + + +async def _payload(request): + if request.headers.get("content-type", "").startswith("application/json"): + data = await request.json() + return data if isinstance(data, dict) else {} + form = await request.form() + return {key: value for key, value in form.items()} + + +@router.post("/services/{name}/command") +async def services_command(request: Request, name: str): + user = require_admin(request) + service = _service(name) + verb = (await _payload(request)).get("verb", "") + if verb not in VERBS: + raise HTTPException(400, "Onbekend commando") + if verb in ("start", "stop"): + manager.set_enabled(service.name, verb == "start") + else: + manager.send_command(service.name, verb) + record(request, f"service.command.{verb}", targets=[("service", service.name)], user=user) + return action_result(request, f"/admin/services/{service.name}", data={"verb": verb}) + + +@router.post("/services/{name}/config") +async def services_config(request: Request, name: str): + user = require_admin(request) + service = _service(name) + values = await _payload(request) + try: + updates = manager.save_config(service.name, values) + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + record(request, "service.config.save", payload={"keys": sorted(updates)}, targets=[("service", service.name)], user=user) + return action_result(request, f"/admin/services/{service.name}", data={"saved": sorted(updates)}) diff --git a/molodetz/routers/admin/settings.py b/molodetz/routers/admin/settings.py new file mode 100644 index 0000000..c593e22 --- /dev/null +++ b/molodetz/routers/admin/settings.py @@ -0,0 +1,43 @@ +# retoor +from fastapi import APIRouter, Request + +from molodetz.database import all_settings, get_setting, set_setting +from molodetz.dependencies import json_or_form +from molodetz.models import SettingsForm +from molodetz.responses import action_result, respond +from molodetz.routers.admin._shared import admin_seo +from molodetz.schemas.admin import SettingsOut +from molodetz.utils.audit import record +from molodetz.utils.auth import require_admin + +router = APIRouter() +NUMERIC = ("rate_limit_per_minute", "rate_limit_window_seconds", "session_max_age_days", "session_remember_days", "audit_retention_days") +BOOLEAN = ("maintenance_mode", "friendly_404") + + +@router.get("/settings") +async def settings_index(request: Request): + require_admin(request) + return respond(request, "admin/settings.html", {"settings": all_settings(), "seo": admin_seo(request, "Instellingen")}, model=SettingsOut) + + +@router.post("/settings") +async def settings_save(request: Request): + user = require_admin(request) + data = await json_or_form(request, SettingsForm) + changed = [] + for key, value in data.model_dump(exclude_none=True).items(): + if value == "": + continue + if key in NUMERIC: + try: + value = str(max(1, int(value))) + except ValueError: + continue + if key in BOOLEAN and value not in ("0", "1"): + continue + if get_setting(key) != value: + set_setting(key, value) + changed.append(key) + record(request, "admin.settings.save", payload={"keys": changed}, user=user) + return action_result(request, "/admin/settings", data={"changed": changed}) diff --git a/molodetz/routers/admin/stats.py b/molodetz/routers/admin/stats.py new file mode 100644 index 0000000..5d06a6b --- /dev/null +++ b/molodetz/routers/admin/stats.py @@ -0,0 +1,17 @@ +# retoor +from fastapi import APIRouter, Request + +from molodetz.database import daily_totals, top_paths +from molodetz.responses import respond +from molodetz.routers.admin._shared import admin_seo +from molodetz.schemas.admin import StatsOut +from molodetz.utils.auth import require_admin + +router = APIRouter() + + +@router.get("/stats") +async def stats_index(request: Request): + require_admin(request) + ctx = {"top_paths": [dict(row) for row in top_paths()], "daily": [dict(row) for row in daily_totals()], "seo": admin_seo(request, "Statistieken")} + return respond(request, "admin/stats.html", ctx, model=StatsOut) diff --git a/molodetz/routers/admin/trash.py b/molodetz/routers/admin/trash.py new file mode 100644 index 0000000..8f00707 --- /dev/null +++ b/molodetz/routers/admin/trash.py @@ -0,0 +1,62 @@ +# retoor +from fastapi import APIRouter, HTTPException, Request + +from molodetz.constants import SOFT_DELETE_TABLES +from molodetz.database import list_deleted, purge_event, restore_event +from molodetz.responses import action_result, respond +from molodetz.routers.admin._shared import admin_seo +from molodetz.schemas.admin import TrashOut +from molodetz.utils.audit import record +from molodetz.utils.auth import require_admin + +router = APIRouter() +LABEL_FIELDS = ("title", "name", "username", "message", "filename", "token") + + +def _label(row): + for key in LABEL_FIELDS: + if row.get(key): + return str(row[key])[:24] if key == "token" else str(row[key])[:120] + return row.get("uid", "") + + +@router.get("/trash") +async def trash_index(request: Request): + require_admin(request) + items = [] + for table in SOFT_DELETE_TABLES: + if table == "sessions": + continue + for row in list_deleted(table): + items.append({"table": table, "uid": row.get("uid"), "label": _label(row), "deleted_at": row.get("deleted_at"), "deleted_by": row.get("deleted_by")}) + items.sort(key=lambda item: item["deleted_at"] or "", reverse=True) + return respond(request, "admin/trash.html", {"items": items, "seo": admin_seo(request, "Prullenbak")}, model=TrashOut) + + +async def _stamp(request): + if request.headers.get("content-type", "").startswith("application/json"): + data = await request.json() + stamp = data.get("stamp") if isinstance(data, dict) else None + else: + stamp = (await request.form()).get("stamp") + if not stamp: + raise HTTPException(400, "stamp ontbreekt") + return str(stamp) + + +@router.post("/trash/restore") +async def trash_restore(request: Request): + user = require_admin(request) + stamp = await _stamp(request) + tables = restore_event(stamp) + record(request, "admin.trash.restore", payload={"stamp": stamp}, user=user) + return action_result(request, "/admin/trash", data={"tables": tables}) + + +@router.post("/trash/purge") +async def trash_purge(request: Request): + user = require_admin(request) + stamp = await _stamp(request) + purged = purge_event(stamp) + record(request, "admin.trash.purge", payload={"stamp": stamp, "tables": [name for name, _ in purged]}, user=user) + return action_result(request, "/admin/trash", data={"purged": [name for name, _ in purged]}) diff --git a/molodetz/routers/admin/users.py b/molodetz/routers/admin/users.py new file mode 100644 index 0000000..d84876e --- /dev/null +++ b/molodetz/routers/admin/users.py @@ -0,0 +1,35 @@ +# retoor +from fastapi import APIRouter, HTTPException, Request + +from molodetz.database import get_user, list_users, set_role +from molodetz.dependencies import json_or_form +from molodetz.models import RoleForm +from molodetz.responses import action_result, respond +from molodetz.routers.admin._shared import admin_seo +from molodetz.schemas.admin import AdminUsersOut +from molodetz.utils.audit import record +from molodetz.utils.auth import invalidate_user_cache, is_senior_admin, not_found, require_admin + +router = APIRouter() + + +@router.get("/users") +async def users_index(request: Request): + require_admin(request) + return respond(request, "admin/users.html", {"users": list_users(), "seo": admin_seo(request, "Accounts")}, model=AdminUsersOut) + + +@router.post("/users/{uid}/role") +async def users_role(request: Request, uid: str): + actor = require_admin(request) + target = get_user(uid) + if target is None: + not_found("Account niet gevonden") + data = await json_or_form(request, RoleForm) + if is_senior_admin(actor, target) or (target["uid"] == actor["uid"] and data.role != "Admin"): + record(request, "admin.user.role", result="denied", targets=[("user", uid)], user=actor) + raise HTTPException(403, "Je kunt een senior beheerder niet beheren") + set_role(uid, data.role) + invalidate_user_cache(propagate=True) + record(request, "admin.user.role", payload={"role": data.role}, targets=[("user", uid)], user=actor) + return action_result(request, "/admin/users", data={"uid": uid, "role": data.role}) diff --git a/molodetz/routers/auth.py b/molodetz/routers/auth.py new file mode 100644 index 0000000..cc8811b --- /dev/null +++ b/molodetz/routers/auth.py @@ -0,0 +1,70 @@ +# retoor +from fastapi import APIRouter, Request + +from molodetz.database import create_session, end_session, get_int_setting, get_user_by_email, get_user_by_username +from molodetz.dependencies import json_or_form +from molodetz.models import LoginForm +from molodetz.responses import action_result, json_error, respond, wants_json +from molodetz.schemas.account import LoginPageOut +from molodetz.seo import base_seo_context +from molodetz.templating import templates +from molodetz.utils.audit import record +from molodetz.utils.auth import get_current_user, invalidate_user_cache, is_account_active, verify_password_async +from molodetz.utils.request_meta import is_secure_request, safe_next + +router = APIRouter() + + +def _seo(request): + return base_seo_context(request, "Inloggen", robots="noindex,nofollow") + + +@router.get("/login") +async def login_page(request: Request): + next_url = safe_next(request.query_params.get("next"), "/admin") + return respond(request, "auth/login.html", {"errors": [], "form": {}, "next": next_url, "seo": _seo(request)}, model=LoginPageOut) + + +@router.post("/login") +async def login_submit(request: Request): + data = await json_or_form(request, LoginForm) + user = get_user_by_username(data.username) or get_user_by_email(data.username.lower()) + valid = user is not None and is_account_active(user) and await verify_password_async(data.password, user.get("password_hash")) + if not valid: + record(request, "auth.login.failed", result="denied", payload={"username": data.username[:64]}) + ctx = { + "errors": ["Onbekende combinatie van naam en wachtwoord."], + "form": {"username": data.username}, + "next": data.next, + "seo": _seo(request), + } + if wants_json(request): + return json_error(401, "Invalid credentials") + return templates.TemplateResponse(request, "auth/login.html", ctx, status_code=400) + days = get_int_setting("session_remember_days", 30) if data.remember else get_int_setting("session_max_age_days", 7) + max_age = max(1, days) * 86400 + token = create_session(user["uid"], max_age) + record(request, "auth.login", targets=[("user", user["uid"])], user=user) + response = action_result(request, safe_next(data.next, "/admin")) + response.set_cookie( + key="session", + value=token, + max_age=max_age, + httponly=True, + samesite="lax", + secure=is_secure_request(request), + ) + return response + + +@router.post("/logout") +async def logout(request: Request): + user = get_current_user(request) + token = request.cookies.get("session") + if token: + end_session(token, user["uid"] if user else "guest") + invalidate_user_cache(propagate=True) + record(request, "auth.logout", user=user) + response = action_result(request, "/") + response.delete_cookie("session") + return response diff --git a/molodetz/routers/avatar.py b/molodetz/routers/avatar.py new file mode 100644 index 0000000..e8c0dc8 --- /dev/null +++ b/molodetz/routers/avatar.py @@ -0,0 +1,13 @@ +# retoor +from fastapi import APIRouter +from fastapi.responses import Response + +from molodetz.avatar import render_avatar_svg + +router = APIRouter() + + +@router.get("/svg/{seed}") +async def avatar_svg(seed: str, size: int = 64): + svg = render_avatar_svg(seed[:128], size) + return Response(svg, media_type="image/svg+xml", headers={"Cache-Control": "public, max-age=86400"}) diff --git a/molodetz/routers/binnen.py b/molodetz/routers/binnen.py new file mode 100644 index 0000000..077d685 --- /dev/null +++ b/molodetz/routers/binnen.py @@ -0,0 +1,42 @@ +# retoor +import hashlib + +from fastapi import APIRouter, Request + +from molodetz import config +from molodetz.database import admin_uids_ordered, insert_join_request +from molodetz.dependencies import json_or_form +from molodetz.models import JoinForm +from molodetz.responses import action_result, respond +from molodetz.schemas.joins import JoinPageOut +from molodetz.seo import base_seo_context +from molodetz.seo_meta_text import description_for +from molodetz.utils.audit import record +from molodetz.utils.notifications import create_notification +from molodetz.utils.request_meta import client_ip + +router = APIRouter() + + +def _seo(request): + return base_seo_context(request, "Binnen", description_for("binnen"), breadcrumbs=[("Molodetz", "/"), ("Binnen", "/binnen")]) + + +@router.get("") +async def join_page(request: Request): + submitted = request.query_params.get("ok") == "1" + return respond(request, "binnen.html", {"submitted": submitted, "errors": [], "form": {}, "seo": _seo(request)}, model=JoinPageOut) + + +@router.post("") +async def join_submit(request: Request): + data = await json_or_form(request, JoinForm) + if data.website: + record(request, "join.request.honeypot", result="denied") + return action_result(request, "/binnen?ok=1") + ip_hash = hashlib.sha256(f"{config.SECRET_KEY}:{client_ip(request)}".encode()).hexdigest()[:24] + row = insert_join_request(data.name, data.contact, data.repo_url, data.message, ip_hash) + record(request, "join.request.create", payload={"uid": row["uid"]}, targets=[("join_request", row["uid"])]) + for uid in admin_uids_ordered(): + create_notification(uid, "join.request", f"{data.name} wil meeschrijven", "/admin/joins") + return action_result(request, "/binnen?ok=1", data={"uid": row["uid"]}) diff --git a/molodetz/routers/docs.py b/molodetz/routers/docs.py new file mode 100644 index 0000000..298c70b --- /dev/null +++ b/molodetz/routers/docs.py @@ -0,0 +1,76 @@ +# retoor +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse, PlainTextResponse + +from molodetz.docs_api import group_by_slug, ordered_groups, substitute +from molodetz.docs_export import export_html, export_markdown +from molodetz.docs_live import live_services +from molodetz.docs_prose import page_by_slug, render_page, sidebar +from molodetz.docs_search import search +from molodetz.seo import base_seo_context, base_url +from molodetz.templating import templates +from molodetz.utils.auth import get_current_user, is_admin, not_found + +router = APIRouter() + + +def _ctx(request, title, **extra): + viewer = get_current_user(request) + viewer_is_admin = is_admin(viewer) + groups = [group for group in ordered_groups() if viewer_is_admin or group["slug"] != "beheer"] + seo = base_seo_context(request, title, breadcrumbs=[("Molodetz", "/"), ("Docs", "/docs")]) + return {"sidebar": sidebar(viewer_is_admin), "api_groups": groups, "viewer_is_admin": viewer_is_admin, "seo": seo, "doc_title": title, **extra} + + +@router.get("/docs") +async def docs_index(request: Request): + return templates.TemplateResponse(request, "docs/index.html", _ctx(request, "Documentatie", body=render_page("welkom"))) + + +@router.get("/docs/search") +async def docs_search(request: Request): + query = request.query_params.get("q", "")[:200] + viewer_is_admin = is_admin(get_current_user(request)) + results = search(query, viewer_is_admin) + return templates.TemplateResponse(request, "docs/search.html", _ctx(request, "Zoeken", query=query, results=results)) + + +@router.get("/docs/download.md") +async def docs_download_md(request: Request): + viewer_is_admin = is_admin(get_current_user(request)) + return PlainTextResponse(export_markdown(viewer_is_admin), media_type="text/markdown; charset=utf-8") + + +@router.get("/docs/download.html") +async def docs_download_html(request: Request): + viewer_is_admin = is_admin(get_current_user(request)) + return HTMLResponse(export_html(viewer_is_admin)) + + +@router.get("/docs/api/{group_slug}") +async def docs_api_group(request: Request, group_slug: str): + group = group_by_slug(group_slug) + viewer = get_current_user(request) + if group is None or (group["slug"] == "beheer" and not is_admin(viewer)): + not_found("Onbekende API-groep") + base = base_url(request) + username = viewer["username"] if viewer else "retoor" + api_key = viewer["api_key"] if viewer else "JOUW_API_SLEUTEL" + endpoints = [] + for item in group["endpoints"]: + example = substitute(f"curl -H 'Accept: application/json' -H 'X-API-KEY: {{{{api_key}}}}' {{{{base}}}}{item['path']}", base, username, api_key) + endpoints.append({**item, "curl": example}) + return templates.TemplateResponse( + request, + "docs/api.html", + _ctx(request, f"API: {group['title']}", group=group, endpoints=endpoints, viewer=viewer, services=live_services() if group["slug"] == "beheer" else []), + ) + + +@router.get("/docs/{slug}") +async def docs_page(request: Request, slug: str): + page = page_by_slug(slug) + viewer_is_admin = is_admin(get_current_user(request)) + if page is None or (page.get("admin") and not viewer_is_admin): + not_found("Pagina niet gevonden") + return templates.TemplateResponse(request, "docs/page.html", _ctx(request, page["title"], body=render_page(slug), page=page)) diff --git a/molodetz/routers/flyers.py b/molodetz/routers/flyers.py new file mode 100644 index 0000000..5362990 --- /dev/null +++ b/molodetz/routers/flyers.py @@ -0,0 +1,11 @@ +# retoor +from fastapi import APIRouter, Request + +from molodetz.routers._gallery import gallery_page + +router = APIRouter() + + +@router.get("") +async def flyers(request: Request): + return gallery_page(request, "flyer", "flyers") diff --git a/molodetz/routers/health.py b/molodetz/routers/health.py new file mode 100644 index 0000000..11ef25d --- /dev/null +++ b/molodetz/routers/health.py @@ -0,0 +1,12 @@ +# retoor +from fastapi import APIRouter +from fastapi.responses import JSONResponse + +from molodetz import config + +router = APIRouter() + + +@router.get("") +async def health(): + return JSONResponse({"status": "ok", "version": config.APP_VERSION}) diff --git a/molodetz/routers/home.py b/molodetz/routers/home.py new file mode 100644 index 0000000..7cec5f9 --- /dev/null +++ b/molodetz/routers/home.py @@ -0,0 +1,42 @@ +# retoor +from fastapi import APIRouter, Request + +from molodetz import config +from molodetz.cache import TTLCache +from molodetz.database import count_media, published_posts +from molodetz.gallery import gallery_items +from molodetz.responses import respond +from molodetz.schemas.posts import HomeOut +from molodetz.seo import base_seo_context, organization_schema, website_schema +from molodetz.seo_meta_text import description_for + +router = APIRouter() +_landing_cache = TTLCache(ttl=config.LANDING_TTL, max_size=4) + + +def _landing_blocks(): + cached = _landing_cache.get("blocks") + if cached is not None: + return cached + posts, _ = published_posts(limit=8) + flyers = gallery_items("flyer") + blocks = { + "posts": posts, + "featured_flyer": flyers[0] if flyers else None, + "flyer_count": count_media("flyer"), + "meme_count": count_media("meme"), + } + _landing_cache.set("blocks", blocks) + return blocks + + +@router.get("/") +async def index(request: Request): + blocks = _landing_blocks() + seo = base_seo_context( + request, + config.APP_TITLE, + description_for("home"), + schemas=[website_schema(request), organization_schema(request)], + ) + return respond(request, "home.html", {**blocks, "seo": seo}, model=HomeOut) diff --git a/molodetz/routers/memes.py b/molodetz/routers/memes.py new file mode 100644 index 0000000..829afbb --- /dev/null +++ b/molodetz/routers/memes.py @@ -0,0 +1,11 @@ +# retoor +from fastapi import APIRouter, Request + +from molodetz.routers._gallery import gallery_page + +router = APIRouter() + + +@router.get("") +async def memes(request: Request): + return gallery_page(request, "meme", "memes") diff --git a/molodetz/routers/mensen.py b/molodetz/routers/mensen.py new file mode 100644 index 0000000..5da881c --- /dev/null +++ b/molodetz/routers/mensen.py @@ -0,0 +1,42 @@ +# retoor +from fastapi import APIRouter, Request + +from molodetz.database import get_user_by_username, post_counts_by_author, public_people, published_posts +from molodetz.responses import respond +from molodetz.schemas.people import PeopleOut, ProfileOut +from molodetz.seo import base_seo_context, profile_schema +from molodetz.seo_meta_text import description_for +from molodetz.utils.auth import not_found + +router = APIRouter() + + +def _with_counts(people): + counts = post_counts_by_author([person["uid"] for person in people]) + return [{**person, "post_count": counts.get(person["uid"], 0)} for person in people] + + +@router.get("") +async def people(request: Request): + persons = _with_counts(public_people()) + seo = base_seo_context(request, "Mensen", description_for("mensen"), breadcrumbs=[("Molodetz", "/"), ("Mensen", "/mensen")]) + return respond(request, "mensen.html", {"people": persons, "seo": seo}, model=PeopleOut) + + +@router.get("/{username}") +async def person(request: Request, username: str): + row = get_user_by_username(username) + if row is None: + not_found("Persoon niet gevonden") + person_row = _with_counts([row])[0] + own, _ = published_posts(limit=50, user_uid=row["uid"]) + seo = base_seo_context( + request, + row["username"], + row.get("bio") or description_for("mensen"), + og_type="profile", + robots="index,follow" if person_row["post_count"] > 0 else "noindex,follow", + breadcrumbs=[("Molodetz", "/"), ("Mensen", "/mensen"), (row["username"], f"/mensen/{row['username']}")], + schemas=[profile_schema(request, row)], + ) + return respond(request, "person.html", {"person": person_row, "posts": own, "seo": seo}, model=ProfileOut) diff --git a/molodetz/routers/notifications.py b/molodetz/routers/notifications.py new file mode 100644 index 0000000..ad602c5 --- /dev/null +++ b/molodetz/routers/notifications.py @@ -0,0 +1,26 @@ +# retoor +from fastapi import APIRouter, Request + +from molodetz.database import list_notifications, mark_all_read, unread_count +from molodetz.responses import action_result, respond +from molodetz.schemas.account import NotificationsOut +from molodetz.seo import base_seo_context +from molodetz.utils.auth import require_user + +router = APIRouter() + + +@router.get("") +async def notifications_page(request: Request): + user = require_user(request) + rows = list_notifications(user["uid"]) + seo = base_seo_context(request, "Meldingen", robots="noindex,nofollow") + ctx = {"notifications": rows, "unread": unread_count(user["uid"]), "seo": seo} + return respond(request, "notifications.html", ctx, model=NotificationsOut) + + +@router.post("/read") +async def notifications_read(request: Request): + user = require_user(request) + mark_all_read(user["uid"]) + return action_result(request, "/notifications") diff --git a/molodetz/routers/posts.py b/molodetz/routers/posts.py new file mode 100644 index 0000000..6358d9d --- /dev/null +++ b/molodetz/routers/posts.py @@ -0,0 +1,36 @@ +# retoor +from fastapi import APIRouter, Request +from fastapi.responses import RedirectResponse + +from molodetz.constants import TOPICS +from molodetz.database import get_post_by_slug, get_user +from molodetz.rendering import content_preview, plain_title +from molodetz.responses import respond +from molodetz.schemas.posts import PostDetailOut +from molodetz.seo import article_schema, base_seo_context +from molodetz.utils.auth import get_current_user, is_admin, not_found + +router = APIRouter() + + +@router.get("/{slug}") +async def post_detail(request: Request, slug: str): + post = get_post_by_slug(slug) + viewer = get_current_user(request) + if post is None or (post["status"] != "published" and not is_admin(viewer)): + not_found("Bericht niet gevonden") + if slug != post["slug"]: + return RedirectResponse(f"/posts/{post['slug']}", status_code=301) + author = get_user(post["user_uid"]) + topic_title = TOPICS.get(post["topic"], post["topic"]) + seo = base_seo_context( + request, + plain_title(post["title"]), + content_preview(post["body"], 160), + robots="index,follow" if post["status"] == "published" else "noindex,nofollow", + og_type="article", + breadcrumbs=[("Molodetz", "/"), (topic_title, f"/{post['topic']}"), (post["title"], f"/posts/{post['slug']}")], + schemas=[article_schema(request, post, author)], + ) + ctx = {"post": post, "author": author, "topic_title": topic_title, "seo": seo} + return respond(request, "post.html", ctx, model=PostDetailOut) diff --git a/molodetz/routers/presence.py b/molodetz/routers/presence.py new file mode 100644 index 0000000..3ab555d --- /dev/null +++ b/molodetz/routers/presence.py @@ -0,0 +1,14 @@ +# retoor +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse + +from molodetz import config +from molodetz.utils.presence import published_roster + +router = APIRouter() + + +@router.get("/roster") +async def roster(request: Request): + tracked = published_roster() + return JSONResponse({"online": tracked[: config.PRESENCE_ONLINE_LIMIT], "tracked": len(tracked)}) diff --git a/molodetz/routers/privacy.py b/molodetz/routers/privacy.py new file mode 100644 index 0000000..3233b3c --- /dev/null +++ b/molodetz/routers/privacy.py @@ -0,0 +1,14 @@ +# retoor +from fastapi import APIRouter, Request + +from molodetz.responses import respond +from molodetz.schemas.account import PageOut +from molodetz.seo import base_seo_context + +router = APIRouter() + + +@router.get("") +async def privacy(request: Request): + seo = base_seo_context(request, "Privacy", breadcrumbs=[("Molodetz", "/"), ("Privacy", "/privacy")]) + return respond(request, "privacy.html", {"title": "Privacy", "seo": seo}, model=PageOut) diff --git a/molodetz/routers/profile.py b/molodetz/routers/profile.py new file mode 100644 index 0000000..c1a08df --- /dev/null +++ b/molodetz/routers/profile.py @@ -0,0 +1,39 @@ +# retoor +from fastapi import APIRouter, Request + +from molodetz.database import generate_uid, update_user +from molodetz.responses import action_result, respond +from molodetz.schemas.account import ApiKeyOut +from molodetz.seo import base_seo_context +from molodetz.utils.audit import record +from molodetz.utils.auth import invalidate_user_cache, require_user +from molodetz.utils.notifications import create_notification + +router = APIRouter() + + +@router.get("/api-key") +async def api_key_page(request: Request): + user = require_user(request) + seo = base_seo_context(request, "API-sleutel", robots="noindex,nofollow") + return respond(request, "profile/api_key.html", {"api_key": user["api_key"], "seo": seo}, model=ApiKeyOut) + + +@router.post("/api-key/regenerate") +async def api_key_regenerate(request: Request): + user = require_user(request) + key = generate_uid() + update_user(user["uid"], api_key=key) + invalidate_user_cache(propagate=True) + record(request, "profile.apikey.regenerate", targets=[("user", user["uid"])], user=user) + create_notification(user["uid"], "auth.apikey", "Je API-sleutel is vernieuwd.", "/profile/api-key") + return action_result(request, "/profile/api-key", data={"api_key": key}) + + +@router.post("/avatar/regenerate") +async def avatar_regenerate(request: Request): + user = require_user(request) + update_user(user["uid"], avatar_seed=generate_uid()) + invalidate_user_cache(propagate=True) + record(request, "profile.avatar.regenerate", targets=[("user", user["uid"])], user=user) + return action_result(request, f"/mensen/{user['username']}") diff --git a/molodetz/routers/rol.py b/molodetz/routers/rol.py new file mode 100644 index 0000000..2913ed1 --- /dev/null +++ b/molodetz/routers/rol.py @@ -0,0 +1,11 @@ +# retoor +from fastapi import APIRouter, Request + +from molodetz.routers._listing import topic_listing + +router = APIRouter() + + +@router.get("") +async def rol(request: Request): + return topic_listing(request, "rol") diff --git a/molodetz/routers/seo.py b/molodetz/routers/seo.py new file mode 100644 index 0000000..07f24dc --- /dev/null +++ b/molodetz/routers/seo.py @@ -0,0 +1,71 @@ +# retoor +import logging +from xml.sax.saxutils import escape + +import defusedxml.ElementTree as SafeET +from fastapi import APIRouter, Request +from fastapi.responses import PlainTextResponse, Response + +from molodetz import config +from molodetz.cache import TTLCache +from molodetz.constants import TOPICS +from molodetz.database import db, public_people +from molodetz.docs_prose import DOCS_PAGES +from molodetz.seo import base_url + +router = APIRouter() +logger = logging.getLogger(__name__) +_sitemap_cache = TTLCache(ttl=config.SITEMAP_TTL, max_size=4) +PER_TABLE_CAP = 5000 +STATIC_PATHS = ("/", "/flyers", "/memes", "/mensen", "/binnen", "/voorwaarden", "/privacy", "/docs") + + +@router.get("/robots.txt") +async def robots(request: Request): + base = base_url(request) + body = "\n".join( + [ + "User-agent: *", + "Disallow: /admin", + "Disallow: /auth", + "Disallow: /notifications", + "Disallow: /profile", + "Allow: /", + f"Sitemap: {base}/sitemap.xml", + "", + ] + ) + return PlainTextResponse(body) + + +def build_sitemap(base): + urls = [(path, None) for path in STATIC_PATHS] + urls += [(f"/{topic}", None) for topic in TOPICS] + if "posts" in db.tables: + rows = list(db["posts"].find(deleted_at=None, status="published", order_by=["-published_at"], _limit=PER_TABLE_CAP + 1)) + if len(rows) > PER_TABLE_CAP: + logger.warning("sitemap truncated posts at %d", PER_TABLE_CAP) + rows = rows[:PER_TABLE_CAP] + urls += [(f"/posts/{row['slug']}", row.get("updated_at")) for row in rows] + urls += [(f"/mensen/{person['username']}", None) for person in public_people()[:PER_TABLE_CAP]] + urls += [(f"/docs/{page['slug']}", None) for page in DOCS_PAGES if not page.get("admin")] + parts = ['', ''] + for path, modified in urls: + entry = f"{escape(base + path)}" + if modified: + entry += f"{escape(modified[:10])}" + parts.append(entry + "") + parts.append("") + xml = "\n".join(parts) + SafeET.fromstring(xml.encode("utf-8")) + return xml + + +@router.get("/sitemap.xml") +async def sitemap(request: Request): + base = base_url(request) + xml = _sitemap_cache.get(base) + if xml is None: + xml = build_sitemap(base) + _sitemap_cache.set(base, xml) + return Response(xml, media_type="application/xml") diff --git a/molodetz/routers/standaard.py b/molodetz/routers/standaard.py new file mode 100644 index 0000000..1a2f541 --- /dev/null +++ b/molodetz/routers/standaard.py @@ -0,0 +1,11 @@ +# retoor +from fastapi import APIRouter, Request + +from molodetz.routers._listing import topic_listing + +router = APIRouter() + + +@router.get("") +async def standaard(request: Request): + return topic_listing(request, "standaard") diff --git a/molodetz/routers/voorwaarden.py b/molodetz/routers/voorwaarden.py new file mode 100644 index 0000000..e4f9745 --- /dev/null +++ b/molodetz/routers/voorwaarden.py @@ -0,0 +1,30 @@ +# retoor +from fastapi import APIRouter, Request + +from molodetz.database import get_setting, update_user +from molodetz.responses import action_result, respond +from molodetz.schemas.account import PageOut +from molodetz.seo import base_seo_context +from molodetz.utils.audit import record +from molodetz.utils.auth import invalidate_user_cache, require_user +from molodetz.utils.request_meta import safe_next + +router = APIRouter() + + +@router.get("") +async def terms(request: Request): + seo = base_seo_context(request, "Voorwaarden", breadcrumbs=[("Molodetz", "/"), ("Voorwaarden", "/voorwaarden")]) + ctx = {"title": "Voorwaarden", "terms_version": get_setting("terms_version", "1"), "next": safe_next(request.query_params.get("next"), "/"), "seo": seo} + return respond(request, "voorwaarden.html", ctx, model=PageOut) + + +@router.post("/accept") +async def accept_terms(request: Request): + user = require_user(request) + version = get_setting("terms_version", "1") + update_user(user["uid"], terms_version=version) + invalidate_user_cache(propagate=True) + record(request, "auth.terms.accept", payload={"version": version}, user=user) + form = await request.form() if not request.headers.get("content-type", "").startswith("application/json") else {} + return action_result(request, safe_next(form.get("next") if form else None, "/")) diff --git a/molodetz/schemas/__init__.py b/molodetz/schemas/__init__.py new file mode 100644 index 0000000..95ee7c3 --- /dev/null +++ b/molodetz/schemas/__init__.py @@ -0,0 +1 @@ +# retoor diff --git a/molodetz/schemas/account.py b/molodetz/schemas/account.py new file mode 100644 index 0000000..aca4925 --- /dev/null +++ b/molodetz/schemas/account.py @@ -0,0 +1,39 @@ +# retoor +from molodetz.schemas.base import OutModel + + +class NotificationOut(OutModel): + uid: str | None = None + kind: str | None = None + message: str | None = None + url: str | None = None + read_at: str | None = None + created_at: str | None = None + + +class NotificationsOut(OutModel): + notifications: list[NotificationOut] = [] + unread: int = 0 + + +class ApiKeyOut(OutModel): + api_key: str | None = None + + +class LoginPageOut(OutModel): + errors: list[str] = [] + next: str | None = None + + +class PresenceOut(OutModel): + online: list[str] = [] + tracked: int = 0 + + +class HealthOut(OutModel): + status: str = "ok" + version: str | None = None + + +class PageOut(OutModel): + title: str | None = None diff --git a/molodetz/schemas/admin.py b/molodetz/schemas/admin.py new file mode 100644 index 0000000..014bf7f --- /dev/null +++ b/molodetz/schemas/admin.py @@ -0,0 +1,92 @@ +# retoor +from typing import Any + +from molodetz.schemas.base import OutModel, PublicUserOut +from molodetz.schemas.posts import PostOut + + +class AdminIndexOut(OutModel): + post_count: int = 0 + open_joins: int = 0 + user_count: int = 0 + deleted_count: int = 0 + + +class AdminPostsOut(OutModel): + posts: list[PostOut] = [] + + +class ServiceOut(OutModel): + name: str | None = None + title: str | None = None + description: str | None = None + status: str | None = None + enabled: bool = False + last_run: str | None = None + next_run: str | None = None + heartbeat: str | None = None + metrics: dict[str, Any] = {} + logs: list[str] = [] + fields: list[dict[str, Any]] = [] + + +class ServicesOut(OutModel): + services: list[ServiceOut] = [] + + +class ServiceDetailOut(OutModel): + service: ServiceOut | None = None + + +class SettingsOut(OutModel): + settings: dict[str, str] = {} + + +class AdminUsersOut(OutModel): + users: list[PublicUserOut] = [] + + +class TrashItemOut(OutModel): + table: str | None = None + uid: str | None = None + label: str | None = None + deleted_at: str | None = None + deleted_by: str | None = None + + +class TrashOut(OutModel): + items: list[TrashItemOut] = [] + + +class AuditRowOut(OutModel): + uid: str | None = None + event_key: str | None = None + category: str | None = None + actor_kind: str | None = None + actor_uid: str | None = None + result: str | None = None + message: str | None = None + created_at: str | None = None + + +class AuditOut(OutModel): + rows: list[AuditRowOut] = [] + + +class BackupRowOut(OutModel): + uid: str | None = None + target: str | None = None + status: str | None = None + size_bytes: int | None = None + error: str | None = None + created_at: str | None = None + finished_at: str | None = None + + +class BackupsOut(OutModel): + backups: list[BackupRowOut] = [] + + +class StatsOut(OutModel): + top_paths: list[dict[str, Any]] = [] + daily: list[dict[str, Any]] = [] diff --git a/molodetz/schemas/base.py b/molodetz/schemas/base.py new file mode 100644 index 0000000..9ef50e1 --- /dev/null +++ b/molodetz/schemas/base.py @@ -0,0 +1,37 @@ +# retoor +from typing import Any + +from pydantic import BaseModel, ConfigDict + + +class OutModel(BaseModel): + model_config = ConfigDict(extra="ignore", from_attributes=True) + + +class ActionResult(OutModel): + ok: bool = True + redirect: str | None = None + data: Any = None + + +class ErrorBody(OutModel): + status: int = 500 + message: str = "" + + +class ErrorOut(OutModel): + error: ErrorBody = ErrorBody() + + +class ValidationErrorOut(OutModel): + error: str = "validation" + fields: list[str] = [] + messages: list[str] = [] + + +class PublicUserOut(OutModel): + uid: str | None = None + username: str | None = None + role: str | None = None + bio: str | None = None + created_at: str | None = None diff --git a/molodetz/schemas/gallery.py b/molodetz/schemas/gallery.py new file mode 100644 index 0000000..1ba16e9 --- /dev/null +++ b/molodetz/schemas/gallery.py @@ -0,0 +1,18 @@ +# retoor +from molodetz.schemas.base import OutModel + + +class MediaOut(OutModel): + uid: str | None = None + filename: str | None = None + caption: str | None = None + width: int | None = None + height: int | None = None + url: str | None = None + thumb_url: str | None = None + + +class GalleryOut(OutModel): + kind: str | None = None + title: str | None = None + items: list[MediaOut] = [] diff --git a/molodetz/schemas/joins.py b/molodetz/schemas/joins.py new file mode 100644 index 0000000..8922412 --- /dev/null +++ b/molodetz/schemas/joins.py @@ -0,0 +1,22 @@ +# retoor +from molodetz.schemas.base import OutModel + + +class JoinPageOut(OutModel): + submitted: bool = False + + +class JoinRequestOut(OutModel): + uid: str | None = None + name: str | None = None + contact: str | None = None + repo_url: str | None = None + message: str | None = None + status: str | None = None + link_title: str | None = None + link_status: str | None = None + created_at: str | None = None + + +class JoinListOut(OutModel): + requests: list[JoinRequestOut] = [] diff --git a/molodetz/schemas/people.py b/molodetz/schemas/people.py new file mode 100644 index 0000000..ed245a2 --- /dev/null +++ b/molodetz/schemas/people.py @@ -0,0 +1,16 @@ +# retoor +from molodetz.schemas.base import OutModel, PublicUserOut +from molodetz.schemas.posts import PostOut + + +class PersonOut(PublicUserOut): + post_count: int = 0 + + +class PeopleOut(OutModel): + people: list[PersonOut] = [] + + +class ProfileOut(OutModel): + person: PersonOut | None = None + posts: list[PostOut] = [] diff --git a/molodetz/schemas/posts.py b/molodetz/schemas/posts.py new file mode 100644 index 0000000..a6add57 --- /dev/null +++ b/molodetz/schemas/posts.py @@ -0,0 +1,33 @@ +# retoor +from molodetz.schemas.base import OutModel, PublicUserOut + + +class PostOut(OutModel): + uid: str | None = None + slug: str | None = None + title: str | None = None + body: str | None = None + topic: str | None = None + status: str | None = None + is_placeholder: bool = False + published_at: str | None = None + created_at: str | None = None + updated_at: str | None = None + + +class PostListOut(OutModel): + title: str | None = None + topic: str | None = None + posts: list[PostOut] = [] + next_cursor: str | None = None + + +class PostDetailOut(OutModel): + post: PostOut | None = None + author: PublicUserOut | None = None + + +class HomeOut(OutModel): + posts: list[PostOut] = [] + flyer_count: int = 0 + meme_count: int = 0 diff --git a/molodetz/seo.py b/molodetz/seo.py new file mode 100644 index 0000000..c3b1733 --- /dev/null +++ b/molodetz/seo.py @@ -0,0 +1,117 @@ +# retoor +import json +import re + +from molodetz import config +from molodetz.assets import static_url +from molodetz.seo_meta_text import DEFAULT_DESCRIPTION + +DEFAULT_OG_IMAGE_PATH = "/static/media/flyer-keuzes.jpg" + + +def base_url(request): + if config.SITE_URL: + return config.SITE_URL + return str(request.base_url).rstrip("/") + + +def _clean(text): + return re.sub(r"[*_`#\[\]]", "", text or "").strip() + + +def website_schema(request): + base = base_url(request) + return { + "@context": "https://schema.org", + "@type": "WebSite", + "name": config.APP_TITLE, + "url": base + "/", + "inLanguage": "nl", + "potentialAction": { + "@type": "SearchAction", + "target": base + "/docs/search?q={search_term_string}", + "query-input": "required name=search_term_string", + }, + } + + +def organization_schema(request): + base = base_url(request) + return {"@context": "https://schema.org", "@type": "Organization", "name": config.APP_TITLE, "url": base + "/"} + + +def breadcrumb_schema(request, breadcrumbs): + base = base_url(request) + return { + "@context": "https://schema.org", + "@type": "BreadcrumbList", + "itemListElement": [ + {"@type": "ListItem", "position": index + 1, "name": _clean(name), "item": base + path} + for index, (name, path) in enumerate(breadcrumbs) + ], + } + + +def article_schema(request, post, author): + base = base_url(request) + return { + "@context": "https://schema.org", + "@type": "BlogPosting", + "headline": _clean(post["title"]), + "datePublished": post.get("published_at") or post.get("created_at"), + "dateModified": post.get("updated_at"), + "inLanguage": "nl", + "mainEntityOfPage": f"{base}/posts/{post['slug']}", + "author": {"@type": "Person", "name": author["username"] if author else "retoor", "url": f"{base}/mensen/{author['username'] if author else 'retoor'}"}, + "publisher": {"@type": "Organization", "name": config.APP_TITLE}, + } + + +def profile_schema(request, person): + base = base_url(request) + return { + "@context": "https://schema.org", + "@type": "ProfilePage", + "mainEntity": {"@type": "Person", "name": person["username"], "url": f"{base}/mensen/{person['username']}"}, + } + + +def base_seo_context( + request, + title, + description=None, + robots="index,follow", + og_type="website", + og_image=None, + breadcrumbs=None, + schemas=None, + prev_url=None, + next_url=None, + keywords=None, +): + base = base_url(request) + canonical = base + request.url.path + page = request.query_params.get("page") + if page: + canonical += f"?page={page}" + image = og_image or static_url(DEFAULT_OG_IMAGE_PATH) + if image.startswith("/"): + image = base + image + crumbs = [(_clean(name), path) for name, path in (breadcrumbs or [])] + all_schemas = list(schemas or []) + if crumbs: + all_schemas.append(breadcrumb_schema(request, crumbs)) + full_title = f"{title} | {config.APP_TITLE}" if title and title != config.APP_TITLE else config.APP_TITLE + return { + "title": full_title, + "description": description or DEFAULT_DESCRIPTION, + "robots": robots, + "canonical": canonical, + "og_type": og_type, + "og_image": image, + "breadcrumbs": crumbs, + "json_ld": [json.dumps(schema, ensure_ascii=False).replace(" +DEFAULT_DESCRIPTION = ( + "Molodetz is een kleine kring die schrijft over het werk. " + "De standaard ligt boven wat nu gewoon lijkt." +) + +PAGE_DESCRIPTIONS = { + "home": DEFAULT_DESCRIPTION, + "rol": "De rol: korte notities over vakmanschap, afmaken en oordeel.", + "standaard": "De standaard: waar Molodetz op bouwt en waarom keuzes vastliggen.", + "flyers": "Flyers van Molodetz. Rustig gezet, scherp bedoeld.", + "memes": "Memes van Molodetz. Lichtelijk provocerend, nooit schreeuwerig.", + "mensen": "De mensen achter Molodetz.", + "binnen": "Schrijf mee met Molodetz. Laat weten wie je bent.", +} + + +def description_for(key): + return PAGE_DESCRIPTIONS.get(key, DEFAULT_DESCRIPTION) diff --git a/molodetz/services/__init__.py b/molodetz/services/__init__.py new file mode 100644 index 0000000..95ee7c3 --- /dev/null +++ b/molodetz/services/__init__.py @@ -0,0 +1 @@ +# retoor diff --git a/molodetz/services/backup.py b/molodetz/services/backup.py new file mode 100644 index 0000000..703778d --- /dev/null +++ b/molodetz/services/backup.py @@ -0,0 +1,204 @@ +# retoor +import asyncio +import hashlib +import io +import shutil +import sqlite3 +import subprocess +import tarfile +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import brotli +import zstandard + +from molodetz import config +from molodetz.database import ( + admin_uids_ordered, + delete_backup_rows, + enqueue_backup, + list_backups, + now_iso, + parse_iso, + pending_backups, + shard_path, + update_backup, +) +from molodetz.services.base import BaseService, ConfigField +from molodetz.utils.audit import record_system +from molodetz.utils.notifications import create_notification + +TARGETS = ("database", "uploads", "keys", "full") + + +def database_file(): + prefix = "sqlite:///" + if not config.DATABASE_URL.startswith(prefix): + raise RuntimeError("backups support SQLite databases only") + return Path(config.DATABASE_URL[len(prefix):]) + + +def _snapshot_database(staging): + target = staging / "molodetz.db" + source = sqlite3.connect(str(database_file())) + try: + destination = sqlite3.connect(str(target)) + try: + source.backup(destination) + finally: + destination.close() + finally: + source.close() + return target + + +def build_archive(uid, target, codec="zstd"): + if target not in TARGETS: + raise ValueError(f"unknown backup target {target}") + staging = config.DATA_PATHS["backup_staging"] / uid + staging.mkdir(parents=True, exist_ok=True) + try: + buffer = io.BytesIO() + with tarfile.open(fileobj=buffer, mode="w") as archive: + if target in ("database", "full"): + archive.add(_snapshot_database(staging), arcname="molodetz.db") + if target in ("uploads", "full") and config.DATA_PATHS["uploads"].exists(): + archive.add(config.DATA_PATHS["uploads"], arcname="uploads") + if target in ("keys", "full") and config.DATA_PATHS["keys"].exists(): + archive.add(config.DATA_PATHS["keys"], arcname="keys") + raw = buffer.getvalue() + if codec == "brotli": + data = brotli.compress(raw, quality=6) + suffix = "tar.br" + else: + data = zstandard.ZstdCompressor(level=10).compress(raw) + suffix = "tar.zst" + checksum = hashlib.sha256(data).hexdigest()[:16] + directory = f"{shard_path(uid)}" + stored_name = f"{target}-{uid.replace('-', '')}-{checksum}.{suffix}" + path = config.DATA_PATHS["backups"] / directory / stored_name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + return directory, stored_name, len(data) + finally: + shutil.rmtree(staging, ignore_errors=True) + + +def backup_path(row): + root = config.DATA_PATHS["backups"].resolve() + path = (root / row["directory"] / row["stored_name"]).resolve() + if root not in path.parents: + raise ValueError("backup path escapes the backup root") + return path + + +def prune_backups(keep_last, dry_run=False): + done = [row for row in list_backups(limit=10000) if row["status"] == "done"] + victims = done[keep_last:] + if not dry_run: + for row in victims: + try: + backup_path(row).unlink(missing_ok=True) + except ValueError: + pass + delete_backup_rows([row["uid"] for row in victims]) + return len(victims) + + +def clear_backups(dry_run=False): + rows = list_backups(limit=100000) + if not dry_run: + for row in rows: + if row.get("directory") and row.get("stored_name"): + try: + backup_path(row).unlink(missing_ok=True) + except ValueError: + pass + delete_backup_rows([row["uid"] for row in rows]) + return len(rows) + + +def offload(path): + remote = config.BACKUP_OFFLOAD_REMOTE + if not remote or shutil.which(config.RCLONE_BIN) is None: + return "skipped" + result = subprocess.run( + [config.RCLONE_BIN, "--config", config.RCLONE_CONFIG, "copy", str(path), remote], + capture_output=True, + text=True, + timeout=900, + ) + return "ok" if result.returncode == 0 else f"failed: {result.stderr.strip()[:200]}" + + +def process_backup(row, codec): + try: + directory, stored_name, size = build_archive(row["uid"], row["target"], codec) + except Exception as exc: + update_backup(row["uid"], status="failed", error=str(exc)[:500], finished_at=now_iso()) + record_system("backup.failed", actor_kind="service", origin="service", result="error", message=str(exc)[:200]) + return False + update_backup(row["uid"], status="done", directory=directory, stored_name=stored_name, size_bytes=size, finished_at=now_iso()) + record_system("backup.finished", actor_kind="service", origin="service", payload={"uid": row["uid"], "size": size}) + return True + + +class BackupService(BaseService): + name = "backup" + title = "Back-ups" + description = "Verwerkt de back-upwachtrij, plant automatische back-ups en snoeit oude archieven." + default_interval = 10 + min_interval = 5 + metrics_interval = 30 + config_fields = [ + ConfigField("schedule_hours", "int", 24, "Automatisch elke N uur (0 = uit)", minimum=0, maximum=720, group="Planning"), + ConfigField("schedule_target", "select", "database", "Automatisch doel", options=list(TARGETS), group="Planning"), + ConfigField("keep_last", "int", 14, "Bewaar laatste N", minimum=1, maximum=1000, group="Bewaren"), + ConfigField("codec", "select", "zstd", "Compressie", options=["zstd", "brotli"], group="Archief"), + ConfigField("offload", "bool", "0", "Offload via rclone", group="Archief"), + ] + + def __init__(self): + super().__init__() + self.processed = 0 + + def _schedule_due(self, hours): + if hours <= 0: + return False + rows = list_backups(limit=1) + if not rows: + return True + last = parse_iso(rows[0]["created_at"]) + return last is None or datetime.now(timezone.utc) - last >= timedelta(hours=hours) + + async def run_once(self): + cfg = self.get_config() + if self._schedule_due(int(cfg["schedule_hours"])): + enqueue_backup(cfg["schedule_target"], "system") + self.log(f"automatische back-up ingepland: {cfg['schedule_target']}") + for row in pending_backups(): + update_backup(row["uid"], status="running") + ok = await asyncio.to_thread(process_backup, row, cfg["codec"]) + self.processed += 1 + self.log(f"back-up {row['target']} {'klaar' if ok else 'mislukt'}") + primary = admin_uids_ordered()[:1] + for uid in primary: + create_notification(uid, "backup.finished" if ok else "backup.failed", f"Back-up {row['target']} {'klaar' if ok else 'mislukt'}", "/admin/backups") + if ok and cfg["offload"] == "1": + done = [item for item in list_backups(limit=5) if item["uid"] == row["uid"]] + if done: + self.log(f"offload: {offload(backup_path(done[0]))}") + pruned = prune_backups(int(cfg["keep_last"])) + if pruned: + self.log(f"{pruned} oude back-ups gesnoeid") + + def collect_metrics(self): + rows = list_backups(limit=200) + return { + "cards": [ + {"label": "Archieven", "value": sum(1 for row in rows if row["status"] == "done")}, + {"label": "Wachtrij", "value": sum(1 for row in rows if row["status"] == "pending")}, + {"label": "Verwerkt sinds start", "value": self.processed}, + ], + "table": None, + } diff --git a/molodetz/services/base.py b/molodetz/services/base.py new file mode 100644 index 0000000..4b3be66 --- /dev/null +++ b/molodetz/services/base.py @@ -0,0 +1,326 @@ +# retoor +import asyncio +import json +import logging +from collections import deque +from datetime import datetime, timedelta, timezone + +from molodetz.database import ( + ensure_service_state, + get_service_state, + get_setting, + now_iso, + parse_iso, + set_setting, + write_service_state, +) + +logger = logging.getLogger(__name__) + +HEARTBEAT_SECONDS = 8 +LIVENESS_SECONDS = 15 +METRICS_SECONDS = 15 +FIELD_TYPES = ("int", "str", "url", "text", "password", "bool", "select") + + +class ConfigField: + def __init__(self, key, type="str", default="", label="", help="", minimum=None, maximum=None, options=None, secret=False, group="Algemeen"): + if type not in FIELD_TYPES: + raise ValueError(f"unknown field type {type}") + self.key = key + self.type = type + self.default = default + self.label = label or key + self.help = help + self.minimum = minimum + self.maximum = maximum + self.options = options or [] + self.secret = secret or type == "password" + self.group = group + + def coerce(self, raw): + value = "" if raw is None else str(raw).strip() + if self.type == "int": + number = int(value) + if self.minimum is not None and number < self.minimum: + raise ValueError(f"{self.label} must be at least {self.minimum}") + if self.maximum is not None and number > self.maximum: + raise ValueError(f"{self.label} must be at most {self.maximum}") + return str(number) + if self.type == "bool": + if value not in ("0", "1"): + raise ValueError(f"{self.label} must be 0 or 1") + return value + if self.type == "select": + if value not in [str(option) for option in self.options]: + raise ValueError(f"{self.label} must be one of {self.options}") + return value + if self.type == "url" and value and not value.startswith(("http://", "https://")): + raise ValueError(f"{self.label} must be an http(s) URL") + return value + + def read(self, service_name): + raw = get_setting(f"service_{service_name}_{self.key}", str(self.default)) + try: + return self.coerce(raw) + except (TypeError, ValueError): + return str(self.default) + + def spec(self, service_name): + value = self.read(service_name) + return { + "key": self.key, + "type": self.type, + "label": self.label, + "help": self.help, + "group": self.group, + "options": self.options, + "minimum": self.minimum, + "maximum": self.maximum, + "secret": self.secret, + "value": "" if self.secret and value else value, + "has_value": bool(value), + } + + +class BaseService: + name = "base" + title = "Service" + description = "" + default_enabled = True + default_interval = 60 + min_interval = 1 + config_fields: list[ConfigField] = [] + metrics_interval = METRICS_SECONDS + + def __init__(self): + self.logs = deque(maxlen=self.log_size()) + self._task = None + self._row_id = None + self._last_heartbeat = 0.0 + self._last_metrics = 0.0 + self._last_command = None + self._was_enabled = None + self._next_run = None + + @property + def interval_key(self): + return f"service_{self.name}_interval" + + def fields(self): + framework = [ + ConfigField("enabled", "bool", "1" if self.default_enabled else "0", "Ingeschakeld", group="Algemeen"), + ConfigField("interval", "int", self.default_interval, "Interval (s)", minimum=self.min_interval, group="Algemeen"), + ] + tail = [ConfigField("log_size", "int", 200, "Logregels", minimum=10, maximum=5000, group="Logs")] + return framework + list(self.config_fields) + tail + + def field(self, key): + for field in self.fields(): + if field.key == key: + return field + raise KeyError(key) + + def get_config(self): + return {field.key: field.read(self.name) for field in self.fields()} + + def is_enabled(self): + return self.field("enabled").read(self.name) == "1" + + def current_interval(self): + return max(self.min_interval, int(self.field("interval").read(self.name))) + + def log_size(self): + try: + return max(10, int(get_setting(f"service_{self.name}_log_size", "200"))) + except ValueError: + return 200 + + def log(self, message): + line = f"{datetime.now(timezone.utc).strftime('%d/%m/%Y %H:%M:%S')} {message}" + self.logs.append(line) + logger.info("[%s] %s", self.name, message) + + async def run_once(self): + raise NotImplementedError + + async def on_enable(self): + return None + + async def on_disable(self): + return None + + def collect_metrics(self): + return {"cards": [], "table": None} + + def _command(self): + return get_setting(f"service_{self.name}_command", "") + + def _persist(self, status, force_metrics=False): + loop_time = asyncio.get_running_loop().time() + fields = {"status": status, "heartbeat": now_iso(), "logs": list(self.logs)} + if self._next_run: + fields["next_run"] = self._next_run.isoformat() + if force_metrics or loop_time - self._last_metrics >= self.metrics_interval: + fields["metrics"] = self.collect_metrics() + self._last_metrics = loop_time + write_service_state(self._row_id, **fields) + self._last_heartbeat = loop_time + + async def _tick(self): + enabled = self.is_enabled() + if enabled != self._was_enabled: + if enabled: + write_service_state(self._row_id, started_at=now_iso()) + await self.on_enable() + self.log("ingeschakeld") + elif self._was_enabled is not None: + await self.on_disable() + self.log("uitgeschakeld") + self._was_enabled = enabled + self._persist("running" if enabled else "stopped", force_metrics=True) + command = self._command() + run_now = False + if command and command != self._last_command: + if self._last_command is not None: + verb = command.split(":", 1)[0] + if verb == "run": + run_now = True + elif verb == "clear": + self.logs.clear() + self._last_command = command + if self.logs.maxlen != self.log_size(): + self.logs = deque(self.logs, maxlen=self.log_size()) + now = datetime.now(timezone.utc) + if enabled and (run_now or self._next_run is None or now >= self._next_run): + try: + await self.run_once() + except Exception as exc: + self.log(f"fout: {exc}") + logger.exception("service %s failed", self.name) + self._next_run = datetime.now(timezone.utc) + timedelta(seconds=self.current_interval()) + write_service_state(self._row_id, last_run=now_iso()) + self._persist("running", force_metrics=True) + elif asyncio.get_running_loop().time() - self._last_heartbeat >= HEARTBEAT_SECONDS: + self._persist("running" if enabled else "stopped") + + async def loop(self): + self._row_id = ensure_service_state(self.name) + self._last_command = None + while True: + try: + await self._tick() + except asyncio.CancelledError: + raise + except Exception: + logger.exception("service loop %s crashed a tick", self.name) + await asyncio.sleep(1) + + def start(self): + if self._task is None or self._task.done(): + self._task = asyncio.get_running_loop().create_task(self.loop()) + + async def stop(self): + if self._task is not None: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + if self._row_id is not None: + write_service_state(self._row_id, status="stopped") + + +def derived_status(service, row): + if not service.is_enabled(): + return "stopped" + if row is None: + return "stalled" + heartbeat = parse_iso(row.get("heartbeat")) + if heartbeat and (datetime.now(timezone.utc) - heartbeat).total_seconds() <= LIVENESS_SECONDS: + return "running" + return "stalled" + + +class ServiceManager: + def __init__(self): + self.services = {} + self.supervising = False + + def register(self, service): + self.services[service.name] = service + return service + + def get(self, name): + return self.services.get(name) + + def describe(self, service): + row = get_service_state(service.name) + metrics = {} + logs = [] + if row: + try: + metrics = json.loads(row.get("metrics") or "{}") + except ValueError: + metrics = {} + try: + logs = json.loads(row.get("logs") or "[]") + except ValueError: + logs = [] + return { + "name": service.name, + "title": service.title, + "description": service.description, + "status": derived_status(service, row), + "enabled": service.is_enabled(), + "last_run": row.get("last_run") if row else None, + "next_run": row.get("next_run") if row else None, + "heartbeat": row.get("heartbeat") if row else None, + "metrics": metrics, + "logs": logs, + "fields": [field.spec(service.name) for field in service.fields()], + } + + def describe_all(self): + return [self.describe(service) for service in self.services.values()] + + def set_enabled(self, name, enabled): + set_setting(f"service_{name}_enabled", "1" if enabled else "0") + + def send_command(self, name, verb): + current = get_setting(f"service_{name}_command", "") + counter = 0 + if ":" in current: + try: + counter = int(current.split(":", 1)[1]) + except ValueError: + counter = 0 + set_setting(f"service_{name}_command", f"{verb}:{counter + 1}") + + def save_config(self, name, values): + service = self.services[name] + updates = {} + for field in service.fields(): + if field.key not in values: + continue + raw = values[field.key] + if field.secret and (raw is None or str(raw).strip() == ""): + continue + updates[field.key] = field.coerce(raw) + for key, value in updates.items(): + set_setting(f"service_{name}_{key}", value) + return updates + + def supervise(self): + self.supervising = True + for service in self.services.values(): + service.start() + + async def shutdown_all(self): + for service in self.services.values(): + await service.stop() + self.supervising = False + + +manager = ServiceManager() diff --git a/molodetz/services/housekeeping.py b/molodetz/services/housekeeping.py new file mode 100644 index 0000000..06fcf53 --- /dev/null +++ b/molodetz/services/housekeeping.py @@ -0,0 +1,36 @@ +# retoor +from datetime import datetime, timedelta, timezone + +from molodetz.database import count_audit, get_int_setting, prune_audit, prune_expired_sessions +from molodetz.services.base import BaseService + + +class HousekeepingService(BaseService): + name = "housekeeping" + title = "Housekeeping" + description = "Ruimt verlopen sessies op en snoeit het auditlog volgens de bewaartermijn." + default_interval = 3600 + min_interval = 60 + metrics_interval = 300 + + def __init__(self): + super().__init__() + self.last_sessions = 0 + self.last_audit = 0 + + async def run_once(self): + self.last_sessions = prune_expired_sessions() + days = max(1, get_int_setting("audit_retention_days", 365)) + cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat() + self.last_audit = prune_audit(cutoff) + self.log(f"sessies verwijderd: {self.last_sessions}, auditregels gesnoeid: {self.last_audit}") + + def collect_metrics(self): + return { + "cards": [ + {"label": "Auditregels", "value": count_audit()}, + {"label": "Laatste sessie-opruiming", "value": self.last_sessions}, + {"label": "Laatste auditsnoei", "value": self.last_audit}, + ], + "table": None, + } diff --git a/molodetz/services/presence.py b/molodetz/services/presence.py new file mode 100644 index 0000000..c7b9dea --- /dev/null +++ b/molodetz/services/presence.py @@ -0,0 +1,30 @@ +# retoor +from molodetz import config +from molodetz.services.base import BaseService +from molodetz.utils.presence import compute_roster + + +class PresenceService(BaseService): + name = "presence" + title = "Presence" + description = "Publiceert de online-set uit de laatste activiteit van accounts." + default_interval = 5 + min_interval = 2 + metrics_interval = 5 + + def __init__(self): + super().__init__() + self.roster = [] + + async def run_once(self): + self.roster = compute_roster() + + def collect_metrics(self): + return { + "roster": self.roster, + "cards": [ + {"label": "Online", "value": len(self.roster[: config.PRESENCE_ONLINE_LIMIT])}, + {"label": "Gevolgd", "value": len(self.roster)}, + ], + "table": None, + } diff --git a/molodetz/services/queue.py b/molodetz/services/queue.py new file mode 100644 index 0000000..b4b8d6d --- /dev/null +++ b/molodetz/services/queue.py @@ -0,0 +1,65 @@ +# retoor +import asyncio +import logging + +logger = logging.getLogger(__name__) + + +class BackgroundQueue: + def __init__(self, max_size=10000): + self.max_size = max_size + self._queue = None + self._task = None + + @property + def running(self): + return self._task is not None and not self._task.done() + + def start(self): + if self.running: + return + self._queue = asyncio.Queue(maxsize=self.max_size) + self._task = asyncio.get_running_loop().create_task(self._consume()) + + def submit(self, fn, *args, **kwargs): + if not self.running: + self._run(fn, args, kwargs) + return + try: + self._queue.put_nowait((fn, args, kwargs)) + except asyncio.QueueFull: + self._run(fn, args, kwargs) + + async def stop(self): + if self._task is None: + return + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + self.drain() + + def drain(self): + if self._queue is None: + return + while not self._queue.empty(): + fn, args, kwargs = self._queue.get_nowait() + self._run(fn, args, kwargs) + + async def _consume(self): + while True: + fn, args, kwargs = await self._queue.get() + self._run(fn, args, kwargs) + self._queue.task_done() + + @staticmethod + def _run(fn, args, kwargs): + try: + fn(*args, **kwargs) + except Exception: + logger.exception("background task %s failed", getattr(fn, "__name__", fn)) + + +background = BackgroundQueue() diff --git a/molodetz/services/registry.py b/molodetz/services/registry.py new file mode 100644 index 0000000..60bcaa8 --- /dev/null +++ b/molodetz/services/registry.py @@ -0,0 +1,10 @@ +# retoor +from molodetz.services.backup import BackupService +from molodetz.services.housekeeping import HousekeepingService +from molodetz.services.presence import PresenceService + +SERVICE_CLASSES = (PresenceService, HousekeepingService, BackupService) + + +def build_services(): + return [cls() for cls in SERVICE_CLASSES] diff --git a/molodetz/static/css/admin.css b/molodetz/static/css/admin.css new file mode 100644 index 0000000..473ca17 --- /dev/null +++ b/molodetz/static/css/admin.css @@ -0,0 +1,43 @@ +/* retoor */ +.admin-layout { display: grid; grid-template-columns: 12rem minmax(0, 1fr); gap: var(--space-xl); } + +.admin-nav { display: grid; gap: var(--space-xs); align-content: start; font-family: var(--font-sans); font-size: var(--text-sm); } +.admin-nav a { text-decoration: none; color: var(--muted); padding: var(--space-xs) 0; border-bottom: var(--border); } +.admin-nav a.active, .admin-nav a:hover { color: var(--ink); } + +.admin-main { min-width: 0; } +.admin-main h1 { font-weight: 500; font-size: var(--text-xl); margin: 0 0 var(--space-lg); } + +.stat-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); gap: var(--space-md); margin-bottom: var(--space-xl); } +.stat-card { border: var(--border); padding: var(--space-md); display: grid; gap: var(--space-xs); } +.stat-card .value { font-size: var(--text-xl); } +.stat-card .name { font-family: var(--font-sans); font-size: var(--text-xs); color: var(--muted); letter-spacing: 0.08em; text-transform: uppercase; } + +.admin-table { width: 100%; border-collapse: collapse; font-family: var(--font-sans); font-size: var(--text-sm); } +.admin-table th { text-align: left; font-weight: 500; color: var(--muted); font-size: var(--text-xs); letter-spacing: 0.08em; text-transform: uppercase; border-bottom: var(--border-strong); padding: var(--space-sm); } +.admin-table td { border-bottom: var(--border); padding: var(--space-sm); vertical-align: top; overflow-wrap: anywhere; } +.table-wrap { overflow-x: auto; } + +.status { font-family: var(--font-sans); font-size: var(--text-xs); letter-spacing: 0.08em; text-transform: uppercase; } +.status.running, .status.done, .status.published, .status.accepted { color: var(--success); } +.status.stalled, .status.failed, .status.declined { color: var(--danger); } +.status.stopped, .status.draft, .status.pending, .status.open { color: var(--warning); } + +.log-tail { background: var(--bg-sunken); border: var(--border); padding: var(--space-md); font-family: var(--font-mono); font-size: var(--text-xs); max-height: 24rem; overflow: auto; white-space: pre-wrap; } + +.tabs { display: flex; gap: var(--space-lg); border-bottom: var(--border); margin-bottom: var(--space-lg); font-family: var(--font-sans); font-size: var(--text-sm); } +.tabs button { background: none; border: 0; color: var(--muted); font: inherit; padding: var(--space-sm) 0; cursor: pointer; border-bottom: 1px solid transparent; } +.tabs button.active { color: var(--ink); border-bottom-color: var(--accent); } + +.editor { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--space-lg); } +.editor textarea { min-height: 28rem; } +.editor .preview { border: var(--border); padding: var(--space-md) var(--space-lg); min-height: 28rem; overflow: auto; } + +@media (max-width: 1024px) { + .editor { grid-template-columns: minmax(0, 1fr); } +} + +@media (max-width: 768px) { + .admin-layout { grid-template-columns: minmax(0, 1fr); } + .admin-nav { grid-auto-flow: column; overflow-x: auto; } +} diff --git a/molodetz/static/css/base.css b/molodetz/static/css/base.css new file mode 100644 index 0000000..8fb460e --- /dev/null +++ b/molodetz/static/css/base.css @@ -0,0 +1,66 @@ +/* retoor */ +*, *::before, *::after { box-sizing: border-box; } + +html { background: var(--bg); color: var(--ink); -webkit-text-size-adjust: 100%; } + +body { + margin: 0; + font-family: var(--font-serif); + font-size: var(--text-base); + line-height: var(--leading-body); + background: var(--bg); + color: var(--ink); + min-height: 100vh; + display: flex; + flex-direction: column; +} + +a { color: var(--ink); text-decoration-color: rgba(var(--accent-rgb), 0.6); text-underline-offset: 0.2em; } +a:hover { color: var(--accent); } + +img, video { max-width: 100%; height: auto; display: block; } + +::selection { background: rgba(var(--accent-rgb), 0.35); color: var(--ink); } + +.hidden, [hidden] { display: none !important; } + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} + +.label { + font-family: var(--font-sans); + font-size: var(--text-xs); + letter-spacing: var(--tracking-label); + text-transform: uppercase; + color: var(--accent); +} + +.muted { color: var(--muted); } + +time { font-family: var(--font-sans); font-size: var(--text-xs); letter-spacing: 0.06em; color: var(--muted); } + +.response-time { + position: fixed; + right: var(--space-sm); + bottom: var(--space-sm); + font-family: var(--font-mono); + font-size: var(--text-xs); + color: var(--faint); + z-index: var(--z-floating-button); + will-change: transform; +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} diff --git a/molodetz/static/css/components.css b/molodetz/static/css/components.css new file mode 100644 index 0000000..19a3006 --- /dev/null +++ b/molodetz/static/css/components.css @@ -0,0 +1,94 @@ +/* retoor */ +dp-toast { + position: fixed; + left: 50%; + bottom: var(--space-xl); + transform: translateX(-50%); + z-index: var(--z-toast); + display: grid; + gap: var(--space-sm); + will-change: transform; +} + +.toast { + background: var(--bg-raised); + border: var(--border-strong); + color: var(--ink); + font-family: var(--font-sans); + font-size: var(--text-sm); + padding: var(--space-sm) var(--space-lg); + box-shadow: var(--shadow-soft); +} + +.modal-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(var(--shade-rgb), 0.7); + z-index: var(--z-modal); + align-items: center; + justify-content: center; + padding: var(--space-lg); + will-change: transform; +} + +.modal-overlay.visible { display: flex; } + +.modal { + background: var(--bg); + border: var(--border-strong); + max-width: 32rem; + width: 100%; + padding: var(--space-lg); + box-shadow: var(--shadow-soft); +} + +.modal.wide { max-width: 56rem; } +.modal header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: var(--space-md); } +.modal h2 { margin: 0; font-weight: 500; font-size: var(--text-lg); } +.modal-close { background: none; border: 0; color: var(--muted); font-size: var(--text-lg); cursor: pointer; } + +dp-dialog.modal-overlay { z-index: var(--z-dialog); } + +dp-code { display: block; position: relative; } + +.lightbox-close { position: absolute; top: var(--space-md); right: var(--space-md); color: var(--ink); } + +dp-lightbox { + display: none; + position: fixed; + inset: 0; + z-index: var(--z-lightbox); + background: rgba(var(--shade-rgb), 0.92); + align-items: center; + justify-content: center; + padding: var(--space-xl); + cursor: zoom-out; + will-change: transform; +} + +dp-lightbox.visible { display: flex; } +dp-lightbox img { max-height: 90vh; max-width: 100%; border: var(--border-strong); } +dp-lightbox .caption { position: absolute; bottom: var(--space-lg); left: 0; right: 0; text-align: center; color: var(--ink-soft); font-size: var(--text-base); } + +dp-context-menu { + position: fixed; + z-index: var(--z-menu); + background: var(--bg-raised); + border: var(--border-strong); + box-shadow: var(--shadow-soft); + font-family: var(--font-sans); + font-size: var(--text-sm); + min-width: 10rem; + will-change: transform; +} + +dp-context-menu button { display: block; width: 100%; text-align: left; background: none; border: 0; color: var(--ink); padding: var(--space-sm) var(--space-md); font: inherit; cursor: pointer; } +dp-context-menu button:hover { background: rgba(var(--accent-rgb), 0.12); } + +dp-upload { display: block; border: 1px dashed var(--rule-strong); padding: var(--space-lg); text-align: center; color: var(--muted); font-family: var(--font-sans); font-size: var(--text-sm); } +dp-upload.dragging { border-color: var(--accent); color: var(--ink); } + +dp-chat { display: grid; gap: var(--space-sm); border: var(--border); padding: var(--space-md); } +dp-chat .chat-log { display: grid; gap: var(--space-sm); max-height: 20rem; overflow-y: auto; } +dp-chat .chat-line { border-left: 2px solid var(--rule-strong); padding-left: var(--space-sm); } diff --git a/molodetz/static/css/content.css b/molodetz/static/css/content.css new file mode 100644 index 0000000..f4cd332 --- /dev/null +++ b/molodetz/static/css/content.css @@ -0,0 +1,128 @@ +/* retoor */ +.hero { padding: var(--space-xl) 0 var(--space-lg); border-bottom: var(--border); margin-bottom: var(--space-xl); } + +.hero h1 { + font-size: var(--text-hero); + font-weight: 500; + letter-spacing: var(--tracking-hero); + line-height: var(--leading-tight); + margin: var(--space-sm) 0 var(--space-md); +} + +.hero .lead { font-size: var(--text-lg); color: var(--ink-soft); max-width: 30em; margin: 0; } +.hero .lead em { color: var(--ink); font-style: italic; } + +.section-head { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: var(--space-md); + margin: var(--space-xl) 0 var(--space-sm); +} + +.section-head a { font-family: var(--font-sans); font-size: var(--text-xs); color: var(--muted); text-decoration: none; letter-spacing: 0.08em; } + +.post-card { border-top: var(--border); padding: var(--space-lg) 0; display: grid; gap: var(--space-xs); min-width: 0; } +.post-card:last-child { border-bottom: var(--border); } +.post-card h3, .post-card h2 { font-size: var(--text-xl); font-weight: 500; line-height: 1.2; margin: 0; letter-spacing: -0.01em; } +.post-card h3 a, .post-card h2 a { text-decoration: none; } +.post-card p { color: var(--ink-soft); margin: 0; } + +.post-meta { display: flex; gap: var(--space-md); align-items: baseline; flex-wrap: wrap; } + +.tag-placeholder { + font-family: var(--font-sans); + font-size: var(--text-xs); + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--muted); + border: var(--border-strong); + padding: 0 var(--space-sm); +} + +.tag-draft { color: var(--warning); border-color: rgba(var(--accent-rgb), 0.5); } + +.article-header { margin-bottom: var(--space-xl); } +.article-header h1 { font-size: var(--text-2xl); font-weight: 500; letter-spacing: -0.02em; line-height: 1.15; margin: var(--space-sm) 0; } + +.byline { display: flex; align-items: center; gap: var(--space-sm); font-family: var(--font-sans); font-size: var(--text-sm); color: var(--muted); } +.byline a { color: var(--ink-soft); text-decoration: none; } + +.prose { font-size: var(--text-lg); line-height: 1.65; overflow-wrap: anywhere; min-width: 0; } +.prose p { margin: 0 0 var(--space-md); } +.prose blockquote { margin: var(--space-lg) 0; padding: var(--space-sm) var(--space-lg); border-left: 2px solid var(--accent); color: var(--ink-soft); font-style: italic; } +.prose blockquote p:last-child { margin-bottom: 0; } +.prose h2, .prose h3 { font-weight: 500; margin: var(--space-xl) 0 var(--space-sm); } +.prose img { margin: var(--space-lg) 0; border: var(--border); cursor: zoom-in; } +.prose table { border-collapse: collapse; width: 100%; font-size: var(--text-base); } +.prose th, .prose td { border-bottom: var(--border); padding: var(--space-sm); text-align: left; } +.prose code { font-family: var(--font-mono); font-size: 0.88em; background: var(--code-bg); padding: 0 var(--space-xs); } +.prose pre { background: var(--code-bg); border: var(--border); padding: var(--space-md); overflow-x: auto; position: relative; font-size: var(--text-sm); } +.prose pre code { background: none; padding: 0; } +.prose hr { border: 0; border-top: var(--border); margin: var(--space-xl) 0; } +.prose a.mention { color: var(--accent); text-decoration: none; } + +.copy-button { + position: absolute; + top: var(--space-xs); + right: var(--space-xs); + background: var(--bg-raised); + color: var(--muted); + border: var(--border); + font-family: var(--font-sans); + font-size: var(--text-xs); + padding: 0 var(--space-sm); + cursor: pointer; +} + +.hljs-keyword, .hljs-selector-tag, .hljs-built_in { color: var(--code-keyword); } +.hljs-string, .hljs-attr, .hljs-template-variable { color: var(--code-string); } +.hljs-comment, .hljs-quote { color: var(--code-comment); font-style: italic; } +.hljs-number, .hljs-literal { color: var(--code-number); } +.hljs-title, .hljs-section, .hljs-name { color: var(--code-title); } + +.featured { margin: var(--space-xl) 0; border-top: var(--border); padding-top: var(--space-lg); } +.featured figure { margin: 0; } +.featured figcaption { font-family: var(--font-sans); font-size: var(--text-xs); color: var(--muted); margin-top: var(--space-sm); letter-spacing: 0.06em; } + +.closing { margin-top: var(--space-2xl); border-top: var(--border); padding-top: var(--space-lg); display: grid; gap: var(--space-md); } +.closing p { font-size: var(--text-lg); color: var(--ink-soft); margin: 0; max-width: 30em; } + +.person-row { display: flex; align-items: center; gap: var(--space-md); border-top: var(--border); padding: var(--space-md) 0; min-width: 0; } +.person-row:last-child { border-bottom: var(--border); } +.person-row .who { display: grid; min-width: 0; flex: 1; } +.person-row .who b { font-weight: 500; font-size: var(--text-lg); } +.person-row .who span { color: var(--muted); font-size: var(--text-sm); } +.person-row .count { font-family: var(--font-sans); font-size: var(--text-xs); color: var(--muted); } + +.avatar { border: var(--border-strong); background: var(--bg-raised); flex-shrink: 0; } + +.presence-host { position: relative; display: inline-block; } +.presence-dot { + position: absolute; + right: calc(var(--space-xs) * -1); + bottom: calc(var(--space-xs) * -1); + width: 0.6rem; + height: 0.6rem; + border-radius: var(--radius-pill); + background: var(--faint); + border: 2px solid var(--bg); +} +.presence-dot.online { background: var(--success); } + +.empty { color: var(--muted); font-style: italic; padding: var(--space-lg) 0; border-top: var(--border); } + +.load-more { display: flex; justify-content: center; margin-top: var(--space-lg); } + +.notice { border: var(--border-strong); padding: var(--space-md) var(--space-lg); margin: var(--space-lg) 0; background: var(--bg-raised); } +.notice.success { border-color: rgba(var(--accent-rgb), 0.6); } +.notice.error { border-color: var(--danger); } +.notice ul { margin: 0; padding-left: var(--space-lg); } + +.error-page { padding: var(--space-2xl) 0; } +.error-page .code { font-size: var(--text-hero); letter-spacing: var(--tracking-hero); line-height: 1; color: var(--faint); } + +@media (max-width: 480px) { + .article-header h1 { font-size: var(--text-xl); } + .prose { font-size: var(--text-base); } +} diff --git a/molodetz/static/css/docs.css b/molodetz/static/css/docs.css new file mode 100644 index 0000000..488d658 --- /dev/null +++ b/molodetz/static/css/docs.css @@ -0,0 +1,27 @@ +/* retoor */ +.docs-layout { display: grid; grid-template-columns: 14rem minmax(0, 1fr); gap: var(--space-xl); } + +.docs-sidebar { font-family: var(--font-sans); font-size: var(--text-sm); display: grid; gap: var(--space-lg); align-content: start; } +.docs-sidebar h4 { margin: 0 0 var(--space-xs); font-size: var(--text-xs); letter-spacing: var(--tracking-label); text-transform: uppercase; color: var(--accent); font-weight: 500; } +.docs-sidebar ul { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--space-xs); } +.docs-sidebar a { color: var(--muted); text-decoration: none; } +.docs-sidebar a.active, .docs-sidebar a:hover { color: var(--ink); } + +.docs-content { min-width: 0; } +.docs-content .permalink { text-decoration: none; color: inherit; } +.docs-content .permalink:hover::after { content: " #"; color: var(--faint); } + +.endpoint { border-top: var(--border); padding: var(--space-lg) 0; display: grid; gap: var(--space-sm); min-width: 0; } +.endpoint .signature { font-family: var(--font-mono); font-size: var(--text-sm); display: flex; gap: var(--space-sm); flex-wrap: wrap; } +.endpoint .method { color: var(--accent); } +.endpoint .auth { font-family: var(--font-sans); font-size: var(--text-xs); color: var(--muted); letter-spacing: 0.08em; text-transform: uppercase; } +.endpoint pre { background: var(--code-bg); border: var(--border); padding: var(--space-sm) var(--space-md); overflow-x: auto; font-size: var(--text-xs); margin: 0; } +.endpoint .tester { display: grid; gap: var(--space-sm); } +.endpoint .allowed { font-family: var(--font-sans); font-size: var(--text-xs); color: var(--faint); } + +.search-hit { border-top: var(--border); padding: var(--space-md) 0; } +.search-hit mark { background: rgba(var(--accent-rgb), 0.3); color: var(--ink); } + +@media (max-width: 768px) { + .docs-layout { grid-template-columns: minmax(0, 1fr); } +} diff --git a/molodetz/static/css/forms.css b/molodetz/static/css/forms.css new file mode 100644 index 0000000..0aa2dae --- /dev/null +++ b/molodetz/static/css/forms.css @@ -0,0 +1,64 @@ +/* retoor */ +.form { display: grid; gap: var(--space-lg); max-width: 34rem; } +.form.wide { max-width: none; } + +.field { display: grid; gap: var(--space-xs); min-width: 0; } +.field label { font-family: var(--font-sans); font-size: var(--text-xs); letter-spacing: 0.12em; text-transform: uppercase; color: var(--muted); } +.field .help { font-family: var(--font-sans); font-size: var(--text-xs); color: var(--faint); } + +input[type="text"], input[type="email"], input[type="password"], input[type="url"], input[type="number"], input[type="search"], select, textarea { + width: 100%; + background: var(--bg-sunken); + color: var(--ink); + border: var(--border-strong); + border-radius: var(--radius); + padding: var(--space-sm) var(--space-md); + font: inherit; + font-size: var(--text-base); +} + +textarea { min-height: 10rem; line-height: var(--leading-body); resize: vertical; font-family: var(--font-mono); font-size: var(--text-sm); } + +input:focus, select:focus, textarea:focus, button:focus-visible, a:focus-visible { outline: 1px solid var(--accent); outline-offset: 2px; } + +.check { display: flex; gap: var(--space-sm); align-items: center; font-family: var(--font-sans); font-size: var(--text-sm); color: var(--ink-soft); } + +.honeypot { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; } + +.button { + display: inline-flex; + align-items: center; + gap: var(--space-sm); + background: var(--accent); + color: var(--accent-ink); + border: 1px solid var(--accent); + border-radius: var(--radius); + padding: var(--space-sm) var(--space-lg); + font-family: var(--font-sans); + font-size: var(--text-sm); + font-weight: 700; + letter-spacing: 0.06em; + text-decoration: none; + cursor: pointer; +} + +.button:hover { color: var(--accent-ink); filter: brightness(1.08); } +.button.ghost { background: transparent; color: var(--ink); border-color: var(--rule-strong); font-weight: 500; } +.button.ghost:hover { border-color: var(--ink-soft); } +.button.danger { background: transparent; color: var(--danger); border-color: var(--danger); } +.button.small { padding: var(--space-xs) var(--space-sm); font-size: var(--text-xs); } + +.button.is-loading { opacity: 0.6; pointer-events: none; } +.button .spinner { + width: 0.8em; + height: 0.8em; + border: 2px solid currentColor; + border-right-color: transparent; + border-radius: var(--radius-pill); + animation: spin 700ms linear infinite; +} + +@keyframes spin { to { transform: rotate(360deg); } } + +.actions { display: flex; gap: var(--space-sm); flex-wrap: wrap; align-items: center; } +.actions form { margin: 0; } diff --git a/molodetz/static/css/gallery.css b/molodetz/static/css/gallery.css new file mode 100644 index 0000000..d292000 --- /dev/null +++ b/molodetz/static/css/gallery.css @@ -0,0 +1,31 @@ +/* retoor */ +.gallery-intro { color: var(--ink-soft); max-width: 34em; margin: 0 0 var(--space-xl); } + +.gallery { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-xl) var(--space-lg); +} + +.gallery figure { margin: 0; min-width: 0; } + +.gallery a.frame { display: block; border: var(--border); background: var(--bg-sunken); cursor: zoom-in; } +.gallery a.frame:hover { border-color: rgba(var(--accent-rgb), 0.7); } +.gallery img { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; } + +.gallery figcaption { + margin-top: var(--space-sm); + font-size: var(--text-base); + color: var(--ink-soft); + display: flex; + justify-content: space-between; + gap: var(--space-md); +} + +.gallery figcaption .index { font-family: var(--font-sans); font-size: var(--text-xs); color: var(--faint); letter-spacing: 0.08em; } + +.gallery.flyers { grid-template-columns: minmax(0, 1fr); } + +@media (max-width: 768px) { + .gallery { grid-template-columns: minmax(0, 1fr); } +} diff --git a/molodetz/static/css/layout.css b/molodetz/static/css/layout.css new file mode 100644 index 0000000..2ed567e --- /dev/null +++ b/molodetz/static/css/layout.css @@ -0,0 +1,118 @@ +/* retoor */ +.site-header { + border-bottom: var(--border); + position: sticky; + top: 0; + background: var(--bg); + z-index: var(--z-nav); + will-change: transform; +} + +.site-nav { + max-width: var(--wide); + margin: 0 auto; + display: flex; + align-items: baseline; + gap: var(--space-lg); + padding: var(--space-md) var(--space-lg); + font-family: var(--font-sans); + font-size: var(--text-sm); + letter-spacing: 0.08em; +} + +.site-nav a { text-decoration: none; color: var(--ink-soft); padding-bottom: var(--space-xs); border-bottom: 1px solid transparent; } +.site-nav a:hover { color: var(--ink); } +.site-nav a.active { color: var(--ink); border-bottom-color: var(--accent); } + +.brand { margin-right: auto; font-weight: 700; letter-spacing: var(--tracking-label); color: var(--accent); } +.site-nav .brand { color: var(--accent); border-bottom: 0; } + +.nav-links { display: flex; gap: var(--space-lg); align-items: baseline; min-width: 0; } + +.nav-toggle { + display: none; + background: transparent; + color: var(--ink); + border: var(--border-strong); + font: inherit; + padding: var(--space-xs) var(--space-sm); + cursor: pointer; +} + +.nav-account { display: flex; gap: var(--space-md); align-items: baseline; } +.nav-account form { margin: 0; } +.nav-account button { background: none; border: 0; color: var(--muted); font: inherit; cursor: pointer; padding: 0; letter-spacing: inherit; } +.nav-account button:hover { color: var(--ink); } + +.badge-count { + display: inline-block; + min-width: 1.4em; + padding: 0 var(--space-xs); + margin-left: var(--space-xs); + background: var(--accent); + color: var(--accent-ink); + font-size: var(--text-xs); + text-align: center; +} + +main.page { + flex: 1; + width: 100%; + max-width: var(--measure); + margin: 0 auto; + padding: var(--space-xl) var(--space-lg) var(--space-2xl); + min-width: 0; +} + +main.page.wide { max-width: var(--wide); } + +.site-footer { + border-top: var(--border); + padding: var(--space-lg); + font-family: var(--font-sans); + font-size: var(--text-xs); + color: var(--faint); +} + +.site-footer .inner { + max-width: var(--wide); + margin: 0 auto; + display: flex; + gap: var(--space-lg); + flex-wrap: wrap; + align-items: baseline; +} + +.site-footer a { color: var(--muted); text-decoration: none; } +.site-footer .tagline { margin-right: auto; } + +.breadcrumbs { font-family: var(--font-sans); font-size: var(--text-xs); color: var(--muted); margin-bottom: var(--space-lg); display: flex; gap: var(--space-sm); flex-wrap: wrap; } +.breadcrumbs a { color: var(--muted); text-decoration: none; } + +@media (max-width: 768px) { + .nav-toggle { display: inline-block; } + .nav-links { + display: none; + position: absolute; + top: 100%; + left: 0; + right: 0; + flex-direction: column; + gap: var(--space-md); + padding: var(--space-lg); + background: var(--bg); + border-bottom: var(--border); + z-index: var(--z-nav-panel); + } + .nav-links.open { display: flex; } + .site-nav { position: relative; } +} + +@media (max-width: 480px) { + main.page { padding: var(--space-lg) var(--space-md) var(--space-xl); } +} + +@media (max-width: 360px) { + main.page { padding: var(--space-md) var(--space-sm) var(--space-lg); } + .site-nav { padding-left: var(--space-sm); padding-right: var(--space-sm); } +} diff --git a/molodetz/static/css/variables.css b/molodetz/static/css/variables.css new file mode 100644 index 0000000..273abb2 --- /dev/null +++ b/molodetz/static/css/variables.css @@ -0,0 +1,74 @@ +/* retoor */ +:root { + --bg: #0d0c0a; + --bg-raised: #15130f; + --bg-sunken: #090807; + --ink: #ece6da; + --ink-soft: #cfc8ba; + --muted: #8c857a; + --faint: #5b564e; + --rule: #2a2722; + --rule-strong: #3d3932; + --accent: #d4a020; + --accent-ink: #120f08; + --accent-rgb: 212, 160, 32; + --ink-rgb: 236, 230, 218; + --shade-rgb: 0, 0, 0; + --success: #7fa36b; + --warning: #d4a020; + --danger: #c8644f; + --info: #7f9eb0; + --code-bg: #12110e; + --code-keyword: #d9a441; + --code-string: #a9b97c; + --code-comment: #6c675e; + --code-number: #c98f6a; + --code-title: #e8d9b5; + + --font-serif: "Iowan Old Style", "Palatino Linotype", Palatino, "Book Antiqua", Georgia, serif; + --font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + --font-mono: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; + + --text-xs: 0.75rem; + --text-sm: 0.875rem; + --text-base: 1.0625rem; + --text-lg: 1.25rem; + --text-xl: 1.75rem; + --text-2xl: 2.5rem; + --text-hero: clamp(3.25rem, 9vw, 6.5rem); + --leading-tight: 1.1; + --leading-body: 1.6; + --tracking-label: 0.18em; + --tracking-hero: -0.04em; + + --space-xs: 0.25rem; + --space-sm: 0.5rem; + --space-md: 1rem; + --space-lg: 1.5rem; + --space-xl: 2.5rem; + --space-2xl: 4.5rem; + + --radius: 0; + --radius-pill: 999px; + --border: 1px solid var(--rule); + --border-strong: 1px solid var(--rule-strong); + --shadow-soft: 0 12px 40px rgba(var(--shade-rgb), 0.45); + + --measure: 42rem; + --wide: 68rem; + --nav-height: 3.5rem; + --duration: 160ms; + + --z-floating-button: 900; + --z-nav-overlay: 998; + --z-nav-panel: 999; + --z-nav: 1000; + --z-nav-dropdown: 1001; + --z-modal: 2000; + --z-popover: 2100; + --z-lightbox: 10000; + --z-floating-windows: 2147480000; + --z-toast: 2147483400; + --z-menu: 2147483500; + --z-dialog: 2147483647; +} diff --git a/molodetz/static/js/Application.js b/molodetz/static/js/Application.js new file mode 100644 index 0000000..ef1fa25 --- /dev/null +++ b/molodetz/static/js/Application.js @@ -0,0 +1,55 @@ +// retoor +import "./components/index.js"; +import { ContentEnhancer } from "./utils/ContentEnhancer.js"; +import { FormManager } from "./utils/FormManager.js"; +import { Http } from "./utils/Http.js"; +import { LocalTime } from "./utils/LocalTime.js"; +import { MobileNav } from "./utils/MobileNav.js"; +import { ModalManager } from "./utils/ModalManager.js"; +import { PresenceManager } from "./utils/PresenceManager.js"; +import { ScrollMemory } from "./utils/ScrollMemory.js"; +import { Tabs } from "./utils/Tabs.js"; +import { TermsGate } from "./utils/TermsGate.js"; +import { Toast } from "./utils/Toast.js"; + +export class Application { + constructor() { + this.dialog = document.querySelector("dp-dialog") || document.body.appendChild(document.createElement("dp-dialog")); + this.toastHost = document.querySelector("dp-toast"); + this.http = Http; + this.termsGate = new TermsGate(this.dialog); + Http.termsGate = this.termsGate; + this.forms = new FormManager(this.dialog); + this.modals = new ModalManager(); + this.time = new LocalTime(); + this.nav = new MobileNav(); + this.tabs = new Tabs(); + this.scroll = new ScrollMemory(); + this.presence = new PresenceManager(); + this.enhancer = new ContentEnhancer(); + this.wireCopy(); + } + + toast(message, kind = "info") { + if (this.toastHost?.show) { + this.toastHost.show(message, kind); + } + } + + wireCopy() { + document.querySelectorAll("[data-copy]").forEach((node) => { + node.title = "Klik om te kopi\u00ebren"; + node.addEventListener("click", async () => { + try { + await navigator.clipboard.writeText(node.textContent.trim()); + this.toast("Gekopieerd"); + } catch { + Toast.flash(node, "Kopi\u00ebren mislukt", 1500); + } + }); + }); + } +} + +window.app = new Application(); +document.documentElement.dataset.app = "ready"; diff --git a/molodetz/static/js/components/Avatar.js b/molodetz/static/js/components/Avatar.js new file mode 100644 index 0000000..211c665 --- /dev/null +++ b/molodetz/static/js/components/Avatar.js @@ -0,0 +1,35 @@ +// retoor +import { Component } from "./Component.js"; + +export class Avatar extends Component { + static tag = "dp-avatar"; + static observedAttributes = ["seed", "uid", "size", "online"]; + + setup() { + this.render(); + } + + attributeChangedCallback() { + if (this.dataset.ready) { + this.render(); + } + } + + render() { + const uid = this.getAttribute("uid") || ""; + const size = Number(this.getAttribute("size") || 32); + this.replaceChildren(); + const img = document.createElement("img"); + img.className = "avatar"; + img.alt = ""; + img.width = size; + img.height = size; + const seed = this.getAttribute("seed") || uid; + img.src = `/avatar/svg/${encodeURIComponent(seed)}?size=${size}`; + const dot = document.createElement("span"); + dot.className = "presence-dot"; + dot.dataset.presenceUid = uid; + dot.classList.toggle("online", this.hasAttribute("online")); + this.append(img, dot); + } +} diff --git a/molodetz/static/js/components/Chat.js b/molodetz/static/js/components/Chat.js new file mode 100644 index 0000000..96de516 --- /dev/null +++ b/molodetz/static/js/components/Chat.js @@ -0,0 +1,43 @@ +// retoor +import { Component } from "./Component.js"; + +export class Chat extends Component { + static tag = "dp-chat"; + + setup() { + this.log = document.createElement("div"); + this.log.className = "chat-log"; + this.log.setAttribute("aria-live", "polite"); + const form = document.createElement("form"); + form.className = "chat-form"; + this.input = document.createElement("textarea"); + this.input.rows = 2; + this.input.setAttribute("aria-label", "Bericht"); + const send = document.createElement("button"); + send.type = "submit"; + send.className = "button small"; + send.textContent = "Verstuur"; + form.append(this.input, send); + form.addEventListener("submit", (event) => { + event.preventDefault(); + const text = this.input.value.trim(); + if (text) { + this.emit("dp-chat-send", { text }); + this.input.value = ""; + } + }); + this.append(this.log, form); + } + + addMessage(author, text) { + const item = document.createElement("div"); + item.className = "chat-line"; + const who = document.createElement("strong"); + who.textContent = author; + const body = document.createElement("span"); + body.textContent = ` ${text}`; + item.append(who, body); + this.log.append(item); + this.log.scrollTop = this.log.scrollHeight; + } +} diff --git a/molodetz/static/js/components/Code.js b/molodetz/static/js/components/Code.js new file mode 100644 index 0000000..29cc2e6 --- /dev/null +++ b/molodetz/static/js/components/Code.js @@ -0,0 +1,37 @@ +// retoor +import hljs from "../../vendor/highlight.es.min.js"; +import { Component } from "./Component.js"; +import { Toast } from "../utils/Toast.js"; + +export class Code extends Component { + static tag = "dp-code"; + + setup() { + requestAnimationFrame(() => this.enhance()); + } + + enhance() { + const code = this.querySelector("pre > code"); + if (!code) { + return; + } + try { + hljs.highlightElement(code); + } catch (error) { + console.debug("highlight failed", error); + } + const button = document.createElement("button"); + button.type = "button"; + button.className = "copy-button"; + button.textContent = "Kopieer"; + button.addEventListener("click", async () => { + try { + await navigator.clipboard.writeText(code.textContent); + Toast.flash(button, "Gekopieerd", 1500, "Kopieer"); + } catch { + Toast.flash(button, "Mislukt", 1500, "Kopieer"); + } + }); + this.prepend(button); + } +} diff --git a/molodetz/static/js/components/Component.js b/molodetz/static/js/components/Component.js new file mode 100644 index 0000000..202db6c --- /dev/null +++ b/molodetz/static/js/components/Component.js @@ -0,0 +1,23 @@ +// retoor +export class Component extends HTMLElement { + static tag = ""; + + static define() { + if (this.tag && !customElements.get(this.tag)) { + customElements.define(this.tag, this); + } + } + + emit(name, detail = {}) { + this.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true })); + } + + connectedCallback() { + if (!this.dataset.ready) { + this.dataset.ready = "1"; + this.setup(); + } + } + + setup() {} +} diff --git a/molodetz/static/js/components/Content.js b/molodetz/static/js/components/Content.js new file mode 100644 index 0000000..0387b3c --- /dev/null +++ b/molodetz/static/js/components/Content.js @@ -0,0 +1,23 @@ +// retoor +import { Component } from "./Component.js"; +import { ContentRenderer } from "../utils/ContentRenderer.js"; + +export class Content extends Component { + static tag = "dp-content"; + + setup() { + const source = this.querySelector('script[type="text/markdown"]'); + if (source) { + this.markdown = source.textContent; + } + } + + set markdown(text) { + this.innerHTML = ContentRenderer.render(text); + this.querySelectorAll("pre > code").forEach((code) => { + const host = document.createElement("dp-code"); + code.parentElement.replaceWith(host); + host.appendChild(code.parentElement); + }); + } +} diff --git a/molodetz/static/js/components/ContextMenu.js b/molodetz/static/js/components/ContextMenu.js new file mode 100644 index 0000000..384b687 --- /dev/null +++ b/molodetz/static/js/components/ContextMenu.js @@ -0,0 +1,41 @@ +// retoor +import { Component } from "./Component.js"; + +export class ContextMenu extends Component { + static tag = "dp-context-menu"; + + setup() { + this.hidden = true; + document.addEventListener("click", (event) => { + if (!this.contains(event.target)) { + this.close(); + } + }); + document.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + this.close(); + } + }); + } + + open(x, y, items) { + this.replaceChildren(); + items.forEach((item) => { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = item.label; + button.addEventListener("click", () => { + this.close(); + item.action(); + }); + this.append(button); + }); + this.style.left = `${Math.min(x, window.innerWidth - 220)}px`; + this.style.top = `${y}px`; + this.hidden = false; + } + + close() { + this.hidden = true; + } +} diff --git a/molodetz/static/js/components/Dialog.js b/molodetz/static/js/components/Dialog.js new file mode 100644 index 0000000..438b9fa --- /dev/null +++ b/molodetz/static/js/components/Dialog.js @@ -0,0 +1,50 @@ +// retoor +import { Component } from "./Component.js"; + +export class Dialog extends Component { + static tag = "dp-dialog"; + + setup() { + this.className = "modal-overlay"; + this.setAttribute("role", "alertdialog"); + this.setAttribute("aria-modal", "true"); + } + + confirm(message, { okLabel = "Ja", cancelLabel = "Annuleren" } = {}) { + return new Promise((resolve) => { + this.replaceChildren(); + const box = document.createElement("div"); + box.className = "modal"; + const text = document.createElement("p"); + text.textContent = message; + const actions = document.createElement("div"); + actions.className = "actions"; + const ok = document.createElement("button"); + ok.type = "button"; + ok.className = "button"; + ok.textContent = okLabel; + const cancel = document.createElement("button"); + cancel.type = "button"; + cancel.className = "button ghost"; + cancel.textContent = cancelLabel; + actions.append(cancel, ok); + box.append(text, actions); + this.append(box); + this.classList.add("visible"); + const finish = (value) => { + this.classList.remove("visible"); + document.removeEventListener("keydown", onKey); + resolve(value); + }; + const onKey = (event) => { + if (event.key === "Escape") { + finish(false); + } + }; + document.addEventListener("keydown", onKey); + ok.addEventListener("click", () => finish(true)); + cancel.addEventListener("click", () => finish(false)); + ok.focus(); + }); + } +} diff --git a/molodetz/static/js/components/Lightbox.js b/molodetz/static/js/components/Lightbox.js new file mode 100644 index 0000000..d4c11a5 --- /dev/null +++ b/molodetz/static/js/components/Lightbox.js @@ -0,0 +1,72 @@ +// retoor +import { Component } from "./Component.js"; + +export class Lightbox extends Component { + static tag = "dp-lightbox"; + + setup() { + this.setAttribute("role", "dialog"); + this.setAttribute("aria-modal", "true"); + this.setAttribute("aria-label", "Afbeelding"); + this.hidden = true; + this.image = document.createElement("img"); + this.image.alt = ""; + this.caption = document.createElement("p"); + this.caption.className = "caption"; + this.closeButton = document.createElement("button"); + this.closeButton.type = "button"; + this.closeButton.className = "modal-close lightbox-close"; + this.closeButton.setAttribute("aria-label", "Sluiten"); + this.closeButton.textContent = "\u00d7"; + this.append(this.closeButton, this.image, this.caption); + this.items = []; + this.index = 0; + document.addEventListener("click", (event) => this.onDocumentClick(event)); + this.addEventListener("click", (event) => { + if (event.target === this || event.target === this.closeButton) { + this.close(); + } + }); + document.addEventListener("keydown", (event) => this.onKey(event)); + } + + onDocumentClick(event) { + const link = event.target.closest("[data-lightbox]"); + if (!link || event.metaKey || event.ctrlKey) { + return; + } + event.preventDefault(); + this.items = [...document.querySelectorAll("[data-lightbox]")]; + this.show(this.items.indexOf(link)); + } + + show(index) { + this.index = (index + this.items.length) % this.items.length; + const link = this.items[this.index]; + this.image.src = link.getAttribute("href"); + this.image.alt = link.dataset.caption || ""; + this.caption.textContent = link.dataset.caption || ""; + this.hidden = false; + this.classList.add("visible"); + this.closeButton.focus(); + } + + close() { + this.hidden = true; + this.classList.remove("visible"); + this.image.removeAttribute("src"); + } + + onKey(event) { + if (this.hidden) { + return; + } + if (event.key === "Escape") { + this.close(); + } else if (event.key === "ArrowRight") { + this.show(this.index + 1); + } else if (event.key === "ArrowLeft") { + this.show(this.index - 1); + } + } +} diff --git a/molodetz/static/js/components/Title.js b/molodetz/static/js/components/Title.js new file mode 100644 index 0000000..8af2c07 --- /dev/null +++ b/molodetz/static/js/components/Title.js @@ -0,0 +1,12 @@ +// retoor +import { Component } from "./Component.js"; +import { ContentRenderer } from "../utils/ContentRenderer.js"; + +export class Title extends Component { + static tag = "dp-title"; + + setup() { + const text = this.textContent; + this.innerHTML = ContentRenderer.render(text, { inline: true }); + } +} diff --git a/molodetz/static/js/components/ToastHost.js b/molodetz/static/js/components/ToastHost.js new file mode 100644 index 0000000..32a1430 --- /dev/null +++ b/molodetz/static/js/components/ToastHost.js @@ -0,0 +1,19 @@ +// retoor +import { Component } from "./Component.js"; + +export class ToastHost extends Component { + static tag = "dp-toast"; + + setup() { + this.setAttribute("role", "status"); + this.setAttribute("aria-live", "polite"); + } + + show(message, kind = "info", ms = 3500) { + const item = document.createElement("div"); + item.className = `toast ${kind}`; + item.textContent = message; + this.append(item); + setTimeout(() => item.remove(), ms); + } +} diff --git a/molodetz/static/js/components/Upload.js b/molodetz/static/js/components/Upload.js new file mode 100644 index 0000000..35972fc --- /dev/null +++ b/molodetz/static/js/components/Upload.js @@ -0,0 +1,31 @@ +// retoor +import { Component } from "./Component.js"; + +export class Upload extends Component { + static tag = "dp-upload"; + + setup() { + this.input = document.createElement("input"); + this.input.type = "file"; + this.input.multiple = this.hasAttribute("multiple"); + this.input.accept = this.getAttribute("accept") || "image/*"; + this.input.className = "sr-only"; + const button = document.createElement("button"); + button.type = "button"; + button.className = "button ghost small"; + button.textContent = this.getAttribute("label") || "Bestand kiezen"; + button.addEventListener("click", () => this.input.click()); + this.input.addEventListener("change", () => this.emit("dp-files", { files: [...this.input.files] })); + this.addEventListener("dragover", (event) => { + event.preventDefault(); + this.classList.add("dragging"); + }); + this.addEventListener("dragleave", () => this.classList.remove("dragging")); + this.addEventListener("drop", (event) => { + event.preventDefault(); + this.classList.remove("dragging"); + this.emit("dp-files", { files: [...event.dataTransfer.files] }); + }); + this.append(this.input, button); + } +} diff --git a/molodetz/static/js/components/index.js b/molodetz/static/js/components/index.js new file mode 100644 index 0000000..fa6e03c --- /dev/null +++ b/molodetz/static/js/components/index.js @@ -0,0 +1,13 @@ +// retoor +import { Avatar } from "./Avatar.js"; +import { Chat } from "./Chat.js"; +import { Code } from "./Code.js"; +import { Content } from "./Content.js"; +import { ContextMenu } from "./ContextMenu.js"; +import { Dialog } from "./Dialog.js"; +import { Lightbox } from "./Lightbox.js"; +import { Title } from "./Title.js"; +import { ToastHost } from "./ToastHost.js"; +import { Upload } from "./Upload.js"; + +[Avatar, Chat, Code, Content, ContextMenu, Dialog, Lightbox, Title, ToastHost, Upload].forEach((component) => component.define()); diff --git a/molodetz/static/js/generated/EmojiMap.js b/molodetz/static/js/generated/EmojiMap.js new file mode 100644 index 0000000..d978c56 --- /dev/null +++ b/molodetz/static/js/generated/EmojiMap.js @@ -0,0 +1,2 @@ +// retoor +export const EMOJI_MAP = Object.freeze({"+1":"👍","-1":"👎","100":"💯","1234":"🔢","1st_place_medal":"🥇","2nd_place_medal":"🥈","3rd_place_medal":"🥉","8ball":"🎱","AB_button_(blood_type)":"🆎","ATM_sign":"🏧","A_button_(blood_type)":"🅰️","Afghanistan":"🇦🇫","Albania":"🇦🇱","Algeria":"🇩🇿","American_Samoa":"🇦🇸","Andorra":"🇦🇩","Angola":"🇦🇴","Anguilla":"🇦🇮","Antarctica":"🇦🇶","Antigua_&_Barbuda":"🇦🇬","Aquarius":"♒","Argentina":"🇦🇷","Aries":"♈","Armenia":"🇦🇲","Aruba":"🇦🇼","Ascension_Island":"🇦🇨","Australia":"🇦🇺","Austria":"🇦🇹","Azerbaijan":"🇦🇿","BACK_arrow":"🔙","B_button_(blood_type)":"🅱️","Bahamas":"🇧🇸","Bahrain":"🇧🇭","Bangladesh":"🇧🇩","Barbados":"🇧🇧","Belarus":"🇧🇾","Belgium":"🇧🇪","Belize":"🇧🇿","Benin":"🇧🇯","Bermuda":"🇧🇲","Bhutan":"🇧🇹","Bolivia":"🇧🇴","Bosnia_&_Herzegovina":"🇧🇦","Botswana":"🇧🇼","Bouvet_Island":"🇧🇻","Brazil":"🇧🇷","British_Indian_Ocean_Territory":"🇮🇴","British_Virgin_Islands":"🇻🇬","Brunei":"🇧🇳","Bulgaria":"🇧🇬","Burkina_Faso":"🇧🇫","Burundi":"🇧🇮","CL_button":"🆑","COOL_button":"🆒","Cambodia":"🇰🇭","Cameroon":"🇨🇲","Canada":"🇨🇦","Canary_Islands":"🇮🇨","Cancer":"♋","Cape_Verde":"🇨🇻","Capricorn":"♑","Caribbean_Netherlands":"🇧🇶","Cayman_Islands":"🇰🇾","Central_African_Republic":"🇨🇫","Ceuta_&_Melilla":"🇪🇦","Chad":"🇹🇩","Chile":"🇨🇱","China":"🇨🇳","Christmas_Island":"🇨🇽","Christmas_tree":"🎄","Clipperton_Island":"🇨🇵","Cocos_(Keeling)_Islands":"🇨🇨","Colombia":"🇨🇴","Comoros":"🇰🇲","Congo-Brazzaville":"🇨🇬","Congo-Kinshasa":"🇨🇩","Cook_Islands":"🇨🇰","Costa_Rica":"🇨🇷","Croatia":"🇭🇷","Cuba":"🇨🇺","Curaçao":"🇨🇼","Cyprus":"🇨🇾","Czechia":"🇨🇿","Côte_d’Ivoire":"🇨🇮","Denmark":"🇩🇰","Diego_Garcia":"🇩🇬","Djibouti":"🇩🇯","Dominica":"🇩🇲","Dominican_Republic":"🇩🇴","END_arrow":"🔚","Ecuador":"🇪🇨","Egypt":"🇪🇬","El_Salvador":"🇸🇻","England":"🏴󠁧󠁢󠁥󠁮󠁧󠁿","Equatorial_Guinea":"🇬🇶","Eritrea":"🇪🇷","Estonia":"🇪🇪","Eswatini":"🇸🇿","Ethiopia":"🇪🇹","European_Union":"🇪🇺","FREE_button":"🆓","Falkland_Islands":"🇫🇰","Faroe_Islands":"🇫🇴","Fiji":"🇫🇯","Finland":"🇫🇮","France":"🇫🇷","French_Guiana":"🇬🇫","French_Polynesia":"🇵🇫","French_Southern_and_Antarctic_Lands":"🇹🇫","Gabon":"🇬🇦","Gambia":"🇬🇲","Gemini":"♊","Georgia":"🇬🇪","Germany":"🇩🇪","Ghana":"🇬🇭","Gibraltar":"🇬🇮","Greece":"🇬🇷","Greenland":"🇬🇱","Grenada":"🇬🇩","Guadeloupe":"🇬🇵","Guam":"🇬🇺","Guatemala":"🇬🇹","Guernsey":"🇬🇬","Guinea":"🇬🇳","Guinea-Bissau":"🇬🇼","Guyana":"🇬🇾","Haiti":"🇭🇹","Heard_Island_&_McDonald_Islands":"🇭🇲","Honduras":"🇭🇳","Hong_Kong_SAR_China":"🇭🇰","Hungary":"🇭🇺","ID_button":"🆔","Iceland":"🇮🇸","India":"🇮🇳","Indonesia":"🇮🇩","Iran":"🇮🇷","Iraq":"🇮🇶","Ireland":"🇮🇪","Isle_of_Man":"🇮🇲","Israel":"🇮🇱","Italy":"🇮🇹","Jamaica":"🇯🇲","Japan":"🇯🇵","Japanese_acceptable_button":"🉑","Japanese_application_button":"🈸","Japanese_bargain_button":"🉐","Japanese_castle":"🏯","Japanese_congratulations_button":"㊗️","Japanese_discount_button":"🈹","Japanese_dolls":"🎎","Japanese_free_of_charge_button":"🈚","Japanese_here_button":"🈁","Japanese_monthly_amount_button":"🈷️","Japanese_no_vacancy_button":"🈵","Japanese_not_free_of_charge_button":"🈶","Japanese_open_for_business_button":"🈺","Japanese_passing_grade_button":"🈴","Japanese_post_office":"🏣","Japanese_prohibited_button":"🈲","Japanese_reserved_button":"🈯","Japanese_secret_button":"㊙️","Japanese_service_charge_button":"🈂️","Japanese_symbol_for_beginner":"🔰","Japanese_vacancy_button":"🈳","Jersey":"🇯🇪","Jordan":"🇯🇴","Kazakhstan":"🇰🇿","Kenya":"🇰🇪","Kiribati":"🇰🇮","Kosovo":"🇽🇰","Kuwait":"🇰🇼","Kyrgyzstan":"🇰🇬","Laos":"🇱🇦","Latvia":"🇱🇻","Lebanon":"🇱🇧","Leo":"♌","Lesotho":"🇱🇸","Liberia":"🇱🇷","Libra":"♎","Libya":"🇱🇾","Liechtenstein":"🇱🇮","Lithuania":"🇱🇹","Luxembourg":"🇱🇺","Macao_SAR_China":"🇲🇴","Madagascar":"🇲🇬","Malawi":"🇲🇼","Malaysia":"🇲🇾","Maldives":"🇲🇻","Mali":"🇲🇱","Malta":"🇲🇹","Marshall_Islands":"🇲🇭","Martinique":"🇲🇶","Mauritania":"🇲🇷","Mauritius":"🇲🇺","Mayotte":"🇾🇹","Mexico":"🇲🇽","Micronesia":"🇫🇲","Moldova":"🇲🇩","Monaco":"🇲🇨","Mongolia":"🇲🇳","Montenegro":"🇲🇪","Montserrat":"🇲🇸","Morocco":"🇲🇦","Mozambique":"🇲🇿","Mrs._Claus":"🤶","Mrs._Claus_dark_skin_tone":"🤶🏿","Mrs._Claus_light_skin_tone":"🤶🏻","Mrs._Claus_medium-dark_skin_tone":"🤶🏾","Mrs._Claus_medium-light_skin_tone":"🤶🏼","Mrs._Claus_medium_skin_tone":"🤶🏽","Mx_Claus":"🧑‍🎄","Mx_Claus_dark_skin_tone":"🧑🏿‍🎄","Mx_Claus_light_skin_tone":"🧑🏻‍🎄","Mx_Claus_medium-dark_skin_tone":"🧑🏾‍🎄","Mx_Claus_medium-light_skin_tone":"🧑🏼‍🎄","Mx_Claus_medium_skin_tone":"🧑🏽‍🎄","Myanmar_(Burma)":"🇲🇲","NEW_button":"🆕","NG_button":"🆖","Namibia":"🇳🇦","Nauru":"🇳🇷","Nepal":"🇳🇵","Netherlands":"🇳🇱","New_Caledonia":"🇳🇨","New_Zealand":"🇳🇿","Nicaragua":"🇳🇮","Niger":"🇳🇪","Nigeria":"🇳🇬","Niue":"🇳🇺","Norfolk_Island":"🇳🇫","North_Korea":"🇰🇵","North_Macedonia":"🇲🇰","Northern_Mariana_Islands":"🇲🇵","Norway":"🇳🇴","OK_button":"🆗","OK_hand":"👌","OK_hand_dark_skin_tone":"👌🏿","OK_hand_light_skin_tone":"👌🏻","OK_hand_medium-dark_skin_tone":"👌🏾","OK_hand_medium-light_skin_tone":"👌🏼","OK_hand_medium_skin_tone":"👌🏽","ON!_arrow":"🔛","O_button_(blood_type)":"🅾️","Oman":"🇴🇲","Ophiuchus":"⛎","P_button":"🅿️","Pakistan":"🇵🇰","Palau":"🇵🇼","Palestinian_Territories":"🇵🇸","Panama":"🇵🇦","Papua_New_Guinea":"🇵🇬","Paraguay":"🇵🇾","Peru":"🇵🇪","Philippines":"🇵🇭","Pisces":"♓","Pitcairn_Islands":"🇵🇳","Poland":"🇵🇱","Portugal":"🇵🇹","Puerto_Rico":"🇵🇷","Qatar":"🇶🇦","Romania":"🇷🇴","Russia":"🇷🇺","Rwanda":"🇷🇼","Réunion":"🇷🇪","SOON_arrow":"🔜","SOS_button":"🆘","Sagittarius":"♐","Samoa":"🇼🇸","San_Marino":"🇸🇲","Santa_Claus":"🎅","Santa_Claus_dark_skin_tone":"🎅🏿","Santa_Claus_light_skin_tone":"🎅🏻","Santa_Claus_medium-dark_skin_tone":"🎅🏾","Santa_Claus_medium-light_skin_tone":"🎅🏼","Santa_Claus_medium_skin_tone":"🎅🏽","Sark":"🇨🇶","Saudi_Arabia":"🇸🇦","Scorpio":"♏","Scotland":"🏴󠁧󠁢󠁳󠁣󠁴󠁿","Senegal":"🇸🇳","Serbia":"🇷🇸","Seychelles":"🇸🇨","Sierra_Leone":"🇸🇱","Singapore":"🇸🇬","Sint_Maarten":"🇸🇽","Slovakia":"🇸🇰","Slovenia":"🇸🇮","Solomon_Islands":"🇸🇧","Somalia":"🇸🇴","South_Africa":"🇿🇦","South_Georgia_&_South_Sandwich_Islands":"🇬🇸","South_Korea":"🇰🇷","South_Sudan":"🇸🇸","Spain":"🇪🇸","Sri_Lanka":"🇱🇰","St._Barthélemy":"🇧🇱","St._Helena_Ascension_&_Tristan_da_Cunha":"🇸🇭","St._Kitts_&_Nevis":"🇰🇳","St._Lucia":"🇱🇨","St._Martin":"🇲🇫","St._Pierre_&_Miquelon":"🇵🇲","St._Vincent_&_Grenadines":"🇻🇨","Statue_of_Liberty":"🗽","Sudan":"🇸🇩","Suriname":"🇸🇷","Svalbard_&_Jan_Mayen":"🇸🇯","Sweden":"🇸🇪","Switzerland":"🇨🇭","Syria":"🇸🇾","São_Tomé_&_Príncipe":"🇸🇹","T-Rex":"🦖","TOP_arrow":"🔝","Taiwan":"🇹🇼","Tajikistan":"🇹🇯","Tanzania":"🇹🇿","Taurus":"♉","Thailand":"🇹🇭","Timor-Leste":"🇹🇱","Togo":"🇹🇬","Tokelau":"🇹🇰","Tokyo_tower":"🗼","Tonga":"🇹🇴","Trinidad_&_Tobago":"🇹🇹","Tristan_da_Cunha":"🇹🇦","Tunisia":"🇹🇳","Turkey":"🇹🇷","Turkmenistan":"🇹🇲","Turks_&_Caicos_Islands":"🇹🇨","Tuvalu":"🇹🇻","Türkiye":"🇹🇷","U.S._Outlying_Islands":"🇺🇲","U.S._Virgin_Islands":"🇻🇮","UP!_button":"🆙","Uganda":"🇺🇬","Ukraine":"🇺🇦","United_Arab_Emirates":"🇦🇪","United_Kingdom":"🇬🇧","United_Nations":"🇺🇳","United_States":"🇺🇸","Uruguay":"🇺🇾","Uzbekistan":"🇺🇿","VS_button":"🆚","Vanuatu":"🇻🇺","Vatican_City":"🇻🇦","Venezuela":"🇻🇪","Vietnam":"🇻🇳","Virgo":"♍","Wales":"🏴󠁧󠁢󠁷󠁬󠁳󠁿","Wallis_&_Futuna":"🇼🇫","Western_Sahara":"🇪🇭","Yemen":"🇾🇪","ZZZ":"💤","Zambia":"🇿🇲","Zimbabwe":"🇿🇼","_1":"👎","a":"🅰️","a_button_blood_type":"🅰️","ab":"🆎","ab_button_blood_type":"🆎","abacus":"🧮","abc":"🔤","abcd":"🔡","accept":"🉑","accordion":"🪗","adhesive_bandage":"🩹","admission_tickets":"🎟️","adult":"🧑","aerial_tramway":"🚡","af":"✅","afghanistan":"🇦🇫","airplane":"✈️","airplane_arrival":"🛬","airplane_arriving":"🛬","airplane_departure":"🛫","aland_islands":"🇦🇽","alarm_clock":"⏰","albania":"🇦🇱","alembic":"⚗️","algeria":"🇩🇿","alien":"👽","alien_monster":"👾","ambulance":"🚑","american_football":"🏈","american_samoa":"🇦🇸","amphora":"🏺","anatomical_heart":"🫀","anchor":"⚓","andorra":"🇦🇩","angel":"👼","anger":"💢","anger_symbol":"💢","angola":"🇦🇴","angry":"😠","angry_face":"😠","angry_face_with_horns":"👿","anguilla":"🇦🇮","anguished":"😧","anguished_face":"😧","ant":"🐜","antarctica":"🇦🇶","antenna_bars":"📶","antigua_barbuda":"🇦🇬","anxious_face_with_sweat":"😰","apple":"🍎","aquarius":"♒","argentina":"🇦🇷","aries":"♈","armenia":"🇦🇲","arrow_backward":"◀️","arrow_double_down":"⏬","arrow_double_up":"⏫","arrow_down":"⬇️","arrow_down_small":"🔽","arrow_forward":"▶️","arrow_heading_down":"⤵️","arrow_heading_up":"⤴️","arrow_left":"⬅️","arrow_lower_left":"↙️","arrow_lower_right":"↘️","arrow_right":"➡️","arrow_right_hook":"↪️","arrow_up":"⬆️","arrow_up_down":"↕️","arrow_up_small":"🔼","arrow_upper_left":"↖️","arrow_upper_right":"↗️","arrows_clockwise":"🔃","arrows_counterclockwise":"🔄","art":"🎨","articulated_lorry":"🚛","artificial_satellite":"🛰️","artist":"🧑‍🎨","artist_dark_skin_tone":"🧑🏿‍🎨","artist_light_skin_tone":"🧑🏻‍🎨","artist_medium-dark_skin_tone":"🧑🏾‍🎨","artist_medium-light_skin_tone":"🧑🏼‍🎨","artist_medium_skin_tone":"🧑🏽‍🎨","artist_palette":"🎨","aruba":"🇦🇼","ascension_island":"🇦🇨","asterisk":"*️⃣","astonished":"😲","astonished_face":"😲","astronaut":"🧑‍🚀","astronaut_dark_skin_tone":"🧑🏿‍🚀","astronaut_light_skin_tone":"🧑🏻‍🚀","astronaut_medium-dark_skin_tone":"🧑🏾‍🚀","astronaut_medium-light_skin_tone":"🧑🏼‍🚀","astronaut_medium_skin_tone":"🧑🏽‍🚀","athletic_shoe":"👟","atm":"🏧","atm_sign":"🏧","atom_symbol":"⚛️","australia":"🇦🇺","austria":"🇦🇹","auto_rickshaw":"🛺","automobile":"🚗","avocado":"🥑","axe":"🪓","azerbaijan":"🇦🇿","b":"🅱️","b_button_blood_type":"🅱️","baby":"👶","baby_angel":"👼","baby_angel_dark_skin_tone":"👼🏿","baby_angel_light_skin_tone":"👼🏻","baby_angel_medium-dark_skin_tone":"👼🏾","baby_angel_medium-light_skin_tone":"👼🏼","baby_angel_medium_skin_tone":"👼🏽","baby_bottle":"🍼","baby_chick":"🐤","baby_dark_skin_tone":"👶🏿","baby_light_skin_tone":"👶🏻","baby_medium-dark_skin_tone":"👶🏾","baby_medium-light_skin_tone":"👶🏼","baby_medium_skin_tone":"👶🏽","baby_symbol":"🚼","back":"🔙","back_arrow":"🔙","backhand_index_pointing_down":"👇","backhand_index_pointing_down_dark_skin_tone":"👇🏿","backhand_index_pointing_down_light_skin_tone":"👇🏻","backhand_index_pointing_down_medium-dark_skin_tone":"👇🏾","backhand_index_pointing_down_medium-light_skin_tone":"👇🏼","backhand_index_pointing_down_medium_skin_tone":"👇🏽","backhand_index_pointing_left":"👈","backhand_index_pointing_left_dark_skin_tone":"👈🏿","backhand_index_pointing_left_light_skin_tone":"👈🏻","backhand_index_pointing_left_medium-dark_skin_tone":"👈🏾","backhand_index_pointing_left_medium-light_skin_tone":"👈🏼","backhand_index_pointing_left_medium_skin_tone":"👈🏽","backhand_index_pointing_right":"👉","backhand_index_pointing_right_dark_skin_tone":"👉🏿","backhand_index_pointing_right_light_skin_tone":"👉🏻","backhand_index_pointing_right_medium-dark_skin_tone":"👉🏾","backhand_index_pointing_right_medium-light_skin_tone":"👉🏼","backhand_index_pointing_right_medium_skin_tone":"👉🏽","backhand_index_pointing_up":"👆","backhand_index_pointing_up_dark_skin_tone":"👆🏿","backhand_index_pointing_up_light_skin_tone":"👆🏻","backhand_index_pointing_up_medium-dark_skin_tone":"👆🏾","backhand_index_pointing_up_medium-light_skin_tone":"👆🏼","backhand_index_pointing_up_medium_skin_tone":"👆🏽","backpack":"🎒","bacon":"🥓","badger":"🦡","badminton":"🏸","badminton_racquet_and_shuttlecock":"🏸","bagel":"🥯","baggage_claim":"🛄","baguette_bread":"🥖","bahamas":"🇧🇸","bahrain":"🇧🇭","balance_scale":"⚖️","bald_man":"👨‍🦲","bald_woman":"👩‍🦲","ballet_dancer":"🧑‍🩰","ballet_dancer_dark_skin_tone":"🧑🏿‍🩰","ballet_dancer_light_skin_tone":"🧑🏻‍🩰","ballet_dancer_medium-dark_skin_tone":"🧑🏾‍🩰","ballet_dancer_medium-light_skin_tone":"🧑🏼‍🩰","ballet_dancer_medium_skin_tone":"🧑🏽‍🩰","ballet_shoes":"🩰","balloon":"🎈","ballot_box":"🗳️","ballot_box_with_ballot":"🗳️","ballot_box_with_check":"☑️","bamboo":"🎍","banana":"🍌","bangbang":"‼️","bangladesh":"🇧🇩","banjo":"🪕","bank":"🏦","bar_chart":"📊","barbados":"🇧🇧","barber":"💈","barber_pole":"💈","baseball":"⚾","basket":"🧺","basketball":"🏀","basketball_man":"⛹️‍♂️","basketball_woman":"⛹️‍♀️","bat":"🦇","bath":"🛀","bathtub":"🛁","battery":"🔋","beach_umbrella":"🏖️","beach_with_umbrella":"🏖️","beaming_face_with_smiling_eyes":"😁","beans":"🫘","bear":"🐻","bearded_person":"🧔","beating_heart":"💓","beaver":"🦫","bed":"🛏️","bee":"🐝","beer":"🍺","beer_mug":"🍺","beers":"🍻","beetle":"🪲","beginner":"🔰","belarus":"🇧🇾","belgium":"🇧🇪","belize":"🇧🇿","bell":"🔔","bell_pepper":"🫑","bell_with_slash":"🔕","bellhop_bell":"🛎️","benin":"🇧🇯","bento":"🍱","bento_box":"🍱","bermuda":"🇧🇲","beverage_box":"🧃","bhutan":"🇧🇹","bicycle":"🚲","bicyclist":"🚴","bike":"🚲","biking_man":"🚴‍♂️","biking_woman":"🚴‍♀️","bikini":"👙","billed_cap":"🧢","biohazard":"☣️","biohazard_sign":"☣️","bird":"🐦","birthday":"🎂","birthday_cake":"🎂","bison":"🦬","biting_lip":"🫦","black_bird":"🐦‍⬛","black_cat":"🐈‍⬛","black_circle":"⚫","black_circle_for_record":"⏺️","black_flag":"🏴","black_heart":"🖤","black_joker":"🃏","black_large_square":"⬛","black_left_pointing_double_triangle_with_vertical_bar":"⏮️","black_medium-small_square":"◾","black_medium_small_square":"◾","black_medium_square":"◼️","black_nib":"✒️","black_right_pointing_double_triangle_with_vertical_bar":"⏭️","black_right_pointing_triangle_with_double_vertical_bar":"⏯️","black_small_square":"▪️","black_square_button":"🔲","black_square_for_stop":"⏹️","blond_haired_man":"👱‍♂️","blond_haired_person":"👱","blond_haired_woman":"👱‍♀️","blonde_woman":"👱‍♀️","blossom":"🌼","blowfish":"🐡","blue_book":"📘","blue_car":"🚙","blue_circle":"🔵","blue_heart":"💙","blue_square":"🟦","blueberries":"🫐","blush":"😊","boar":"🐗","boat":"⛵","bolivia":"🇧🇴","bomb":"💣","bone":"🦴","book":"📖","bookmark":"🔖","bookmark_tabs":"📑","books":"📚","boom":"💥","boomerang":"🪃","boot":"👢","bosnia_herzegovina":"🇧🇦","botswana":"🇧🇼","bottle_with_popping_cork":"🍾","bouncing_ball_man":"⛹️‍♂️","bouncing_ball_person":"⛹️","bouncing_ball_woman":"⛹️‍♀️","bouquet":"💐","bouvet_island":"🇧🇻","bow":"🙇","bow_and_arrow":"🏹","bowing_man":"🙇‍♂️","bowing_woman":"🙇‍♀️","bowl_with_spoon":"🥣","bowling":"🎳","boxing_glove":"🥊","boy":"👦","boy_dark_skin_tone":"👦🏿","boy_light_skin_tone":"👦🏻","boy_medium-dark_skin_tone":"👦🏾","boy_medium-light_skin_tone":"👦🏼","boy_medium_skin_tone":"👦🏽","brain":"🧠","brazil":"🇧🇷","bread":"🍞","breast-feeding":"🤱","breast-feeding_dark_skin_tone":"🤱🏿","breast-feeding_light_skin_tone":"🤱🏻","breast-feeding_medium-dark_skin_tone":"🤱🏾","breast-feeding_medium-light_skin_tone":"🤱🏼","breast-feeding_medium_skin_tone":"🤱🏽","breast_feeding":"🤱","brick":"🧱","bricks":"🧱","bride_with_veil":"👰‍♀️","bridge_at_night":"🌉","briefcase":"💼","briefs":"🩲","bright_button":"🔆","british_indian_ocean_territory":"🇮🇴","british_virgin_islands":"🇻🇬","broccoli":"🥦","broken_chain":"⛓️‍💥","broken_heart":"💔","broom":"🧹","brown_circle":"🟤","brown_heart":"🤎","brown_mushroom":"🍄‍🟫","brown_square":"🟫","brunei":"🇧🇳","bubble_tea":"🧋","bubbles":"🫧","bucket":"🪣","bug":"🐛","building_construction":"🏗️","bulb":"💡","bulgaria":"🇧🇬","bullet_train":"🚅","bullettrain_front":"🚅","bullettrain_side":"🚄","bullseye":"🎯","burkina_faso":"🇧🇫","burrito":"🌯","burundi":"🇧🇮","bus":"🚌","bus_stop":"🚏","business_suit_levitating":"🕴️","busstop":"🚏","bust_in_silhouette":"👤","busts_in_silhouette":"👥","butter":"🧈","butterfly":"🦋","cactus":"🌵","cake":"🍰","calendar":"📅","call_me_hand":"🤙","call_me_hand_dark_skin_tone":"🤙🏿","call_me_hand_light_skin_tone":"🤙🏻","call_me_hand_medium-dark_skin_tone":"🤙🏾","call_me_hand_medium-light_skin_tone":"🤙🏼","call_me_hand_medium_skin_tone":"🤙🏽","calling":"📲","cambodia":"🇰🇭","camel":"🐪","camera":"📷","camera_flash":"📸","camera_with_flash":"📸","cameroon":"🇨🇲","camping":"🏕️","canada":"🇨🇦","canary_islands":"🇮🇨","cancer":"♋","candle":"🕯️","candy":"🍬","canned_food":"🥫","canoe":"🛶","cape_verde":"🇨🇻","capital_abcd":"🔠","capricorn":"♑","car":"🚗","card_file_box":"🗃️","card_index":"📇","card_index_dividers":"🗂️","caribbean_netherlands":"🇧🇶","carousel_horse":"🎠","carp_streamer":"🎏","carpentry_saw":"🪚","carrot":"🥕","cartwheeling":"🤸","castle":"🏰","cat":"🐈","cat2":"🐈","cat_face":"🐱","cat_with_tears_of_joy":"😹","cat_with_wry_smile":"😼","cayman_islands":"🇰🇾","cd":"💿","central_african_republic":"🇨🇫","ceuta_melilla":"🇪🇦","chad":"🇹🇩","chains":"⛓️","chair":"🪑","champagne":"🍾","chart":"💹","chart_decreasing":"📉","chart_increasing":"📈","chart_increasing_with_yen":"💹","chart_with_downwards_trend":"📉","chart_with_upwards_trend":"📈","check_box_with_check":"☑️","check_mark":"✔️","check_mark_button":"✅","checkered_flag":"🏁","cheese":"🧀","cheese_wedge":"🧀","chequered_flag":"🏁","cherries":"🍒","cherry_blossom":"🌸","chess_pawn":"♟️","chestnut":"🌰","chicken":"🐔","child":"🧒","child_dark_skin_tone":"🧒🏿","child_light_skin_tone":"🧒🏻","child_medium-dark_skin_tone":"🧒🏾","child_medium-light_skin_tone":"🧒🏼","child_medium_skin_tone":"🧒🏽","children_crossing":"🚸","chile":"🇨🇱","chipmunk":"🐿️","chocolate_bar":"🍫","chopsticks":"🥢","christmas_island":"🇨🇽","christmas_tree":"🎄","church":"⛪","cigarette":"🚬","cinema":"🎦","circled_M":"Ⓜ️","circled_m":"Ⓜ️","circus_tent":"🎪","city_sunrise":"🌇","city_sunset":"🌆","cityscape":"🏙️","cityscape_at_dusk":"🌆","cl":"🆑","cl_button":"🆑","clamp":"🗜️","clap":"👏","clapper":"🎬","clapper_board":"🎬","clapping_hands":"👏","clapping_hands_dark_skin_tone":"👏🏿","clapping_hands_light_skin_tone":"👏🏻","clapping_hands_medium-dark_skin_tone":"👏🏾","clapping_hands_medium-light_skin_tone":"👏🏼","clapping_hands_medium_skin_tone":"👏🏽","classical_building":"🏛️","climbing":"🧗","climbing_man":"🧗‍♂️","climbing_woman":"🧗‍♀️","clinking_beer_mugs":"🍻","clinking_glasses":"🥂","clipboard":"📋","clipperton_island":"🇨🇵","clock1":"🕐","clock10":"🕙","clock1030":"🕥","clock11":"🕚","clock1130":"🕦","clock12":"🕛","clock1230":"🕧","clock130":"🕜","clock2":"🕑","clock230":"🕝","clock3":"🕒","clock330":"🕞","clock4":"🕓","clock430":"🕟","clock5":"🕔","clock530":"🕠","clock6":"🕕","clock630":"🕡","clock7":"🕖","clock730":"🕢","clock8":"🕗","clock830":"🕣","clock9":"🕘","clock930":"🕤","clockwise_vertical_arrows":"🔃","closed_book":"📕","closed_lock_with_key":"🔐","closed_mailbox_with_lowered_flag":"📪","closed_mailbox_with_raised_flag":"📫","closed_umbrella":"🌂","cloud":"☁️","cloud_with_lightning":"🌩️","cloud_with_lightning_and_rain":"⛈️","cloud_with_rain":"🌧️","cloud_with_snow":"🌨️","cloud_with_tornado":"🌪️","clown_face":"🤡","club_suit":"♣️","clubs":"♣️","clutch_bag":"👝","cn":"🇨🇳","coat":"🧥","cockroach":"🪳","cocktail":"🍸","cocktail_glass":"🍸","coconut":"🥥","cocos_islands":"🇨🇨","coffee":"☕","coffin":"⚰️","coin":"🪙","cold_face":"🥶","cold_sweat":"😰","collision":"💥","colombia":"🇨🇴","comet":"☄️","comoros":"🇰🇲","compass":"🧭","compression":"🗜️","computer":"💻","computer_disk":"💽","computer_mouse":"🖱️","confetti_ball":"🎊","confounded":"😖","confounded_face":"😖","confused":"😕","confused_face":"😕","congo_brazzaville":"🇨🇬","congo_kinshasa":"🇨🇩","congratulations":"㊗️","construction":"🚧","construction_worker":"👷","construction_worker_dark_skin_tone":"👷🏿","construction_worker_light_skin_tone":"👷🏻","construction_worker_man":"👷‍♂️","construction_worker_medium-dark_skin_tone":"👷🏾","construction_worker_medium-light_skin_tone":"👷🏼","construction_worker_medium_skin_tone":"👷🏽","construction_worker_woman":"👷‍♀️","control_knobs":"🎛️","convenience_store":"🏪","cook":"🧑‍🍳","cook_dark_skin_tone":"🧑🏿‍🍳","cook_islands":"🇨🇰","cook_light_skin_tone":"🧑🏻‍🍳","cook_medium-dark_skin_tone":"🧑🏾‍🍳","cook_medium-light_skin_tone":"🧑🏼‍🍳","cook_medium_skin_tone":"🧑🏽‍🍳","cooked_rice":"🍚","cookie":"🍪","cooking":"🍳","cool":"🆒","cool_button":"🆒","cop":"👮","copyright":"©️","coral":"🪸","corn":"🌽","costa_rica":"🇨🇷","cote_divoire":"🇨🇮","couch_and_lamp":"🛋️","counterclockwise_arrows_button":"🔄","couple":"👫","couple_with_heart":"💑","couple_with_heart_dark_skin_tone":"💑🏿","couple_with_heart_light_skin_tone":"💑🏻","couple_with_heart_man_man":"👨‍❤️‍👨","couple_with_heart_man_man_dark_skin_tone":"👨🏿‍❤️‍👨🏿","couple_with_heart_man_man_dark_skin_tone_light_skin_tone":"👨🏿‍❤️‍👨🏻","couple_with_heart_man_man_dark_skin_tone_medium-dark_skin_tone":"👨🏿‍❤️‍👨🏾","couple_with_heart_man_man_dark_skin_tone_medium-light_skin_tone":"👨🏿‍❤️‍👨🏼","couple_with_heart_man_man_dark_skin_tone_medium_skin_tone":"👨🏿‍❤️‍👨🏽","couple_with_heart_man_man_light_skin_tone":"👨🏻‍❤️‍👨🏻","couple_with_heart_man_man_light_skin_tone_dark_skin_tone":"👨🏻‍❤️‍👨🏿","couple_with_heart_man_man_light_skin_tone_medium-dark_skin_tone":"👨🏻‍❤️‍👨🏾","couple_with_heart_man_man_light_skin_tone_medium-light_skin_tone":"👨🏻‍❤️‍👨🏼","couple_with_heart_man_man_light_skin_tone_medium_skin_tone":"👨🏻‍❤️‍👨🏽","couple_with_heart_man_man_medium-dark_skin_tone":"👨🏾‍❤️‍👨🏾","couple_with_heart_man_man_medium-dark_skin_tone_dark_skin_tone":"👨🏾‍❤️‍👨🏿","couple_with_heart_man_man_medium-dark_skin_tone_light_skin_tone":"👨🏾‍❤️‍👨🏻","couple_with_heart_man_man_medium-dark_skin_tone_medium-light_skin_tone":"👨🏾‍❤️‍👨🏼","couple_with_heart_man_man_medium-dark_skin_tone_medium_skin_tone":"👨🏾‍❤️‍👨🏽","couple_with_heart_man_man_medium-light_skin_tone":"👨🏼‍❤️‍👨🏼","couple_with_heart_man_man_medium-light_skin_tone_dark_skin_tone":"👨🏼‍❤️‍👨🏿","couple_with_heart_man_man_medium-light_skin_tone_light_skin_tone":"👨🏼‍❤️‍👨🏻","couple_with_heart_man_man_medium-light_skin_tone_medium-dark_skin_tone":"👨🏼‍❤️‍👨🏾","couple_with_heart_man_man_medium-light_skin_tone_medium_skin_tone":"👨🏼‍❤️‍👨🏽","couple_with_heart_man_man_medium_skin_tone":"👨🏽‍❤️‍👨🏽","couple_with_heart_man_man_medium_skin_tone_dark_skin_tone":"👨🏽‍❤️‍👨🏿","couple_with_heart_man_man_medium_skin_tone_light_skin_tone":"👨🏽‍❤️‍👨🏻","couple_with_heart_man_man_medium_skin_tone_medium-dark_skin_tone":"👨🏽‍❤️‍👨🏾","couple_with_heart_man_man_medium_skin_tone_medium-light_skin_tone":"👨🏽‍❤️‍👨🏼","couple_with_heart_medium-dark_skin_tone":"💑🏾","couple_with_heart_medium-light_skin_tone":"💑🏼","couple_with_heart_medium_skin_tone":"💑🏽","couple_with_heart_person_person_dark_skin_tone_light_skin_tone":"🧑🏿‍❤️‍🧑🏻","couple_with_heart_person_person_dark_skin_tone_medium-dark_skin_tone":"🧑🏿‍❤️‍🧑🏾","couple_with_heart_person_person_dark_skin_tone_medium-light_skin_tone":"🧑🏿‍❤️‍🧑🏼","couple_with_heart_person_person_dark_skin_tone_medium_skin_tone":"🧑🏿‍❤️‍🧑🏽","couple_with_heart_person_person_light_skin_tone_dark_skin_tone":"🧑🏻‍❤️‍🧑🏿","couple_with_heart_person_person_light_skin_tone_medium-dark_skin_tone":"🧑🏻‍❤️‍🧑🏾","couple_with_heart_person_person_light_skin_tone_medium-light_skin_tone":"🧑🏻‍❤️‍🧑🏼","couple_with_heart_person_person_light_skin_tone_medium_skin_tone":"🧑🏻‍❤️‍🧑🏽","couple_with_heart_person_person_medium-dark_skin_tone_dark_skin_tone":"🧑🏾‍❤️‍🧑🏿","couple_with_heart_person_person_medium-dark_skin_tone_light_skin_tone":"🧑🏾‍❤️‍🧑🏻","couple_with_heart_person_person_medium-dark_skin_tone_medium-light_skin_tone":"🧑🏾‍❤️‍🧑🏼","couple_with_heart_person_person_medium-dark_skin_tone_medium_skin_tone":"🧑🏾‍❤️‍🧑🏽","couple_with_heart_person_person_medium-light_skin_tone_dark_skin_tone":"🧑🏼‍❤️‍🧑🏿","couple_with_heart_person_person_medium-light_skin_tone_light_skin_tone":"🧑🏼‍❤️‍🧑🏻","couple_with_heart_person_person_medium-light_skin_tone_medium-dark_skin_tone":"🧑🏼‍❤️‍🧑🏾","couple_with_heart_person_person_medium-light_skin_tone_medium_skin_tone":"🧑🏼‍❤️‍🧑🏽","couple_with_heart_person_person_medium_skin_tone_dark_skin_tone":"🧑🏽‍❤️‍🧑🏿","couple_with_heart_person_person_medium_skin_tone_light_skin_tone":"🧑🏽‍❤️‍🧑🏻","couple_with_heart_person_person_medium_skin_tone_medium-dark_skin_tone":"🧑🏽‍❤️‍🧑🏾","couple_with_heart_person_person_medium_skin_tone_medium-light_skin_tone":"🧑🏽‍❤️‍🧑🏼","couple_with_heart_woman_man":"👩‍❤️‍👨","couple_with_heart_woman_man_dark_skin_tone":"👩🏿‍❤️‍👨🏿","couple_with_heart_woman_man_dark_skin_tone_light_skin_tone":"👩🏿‍❤️‍👨🏻","couple_with_heart_woman_man_dark_skin_tone_medium-dark_skin_tone":"👩🏿‍❤️‍👨🏾","couple_with_heart_woman_man_dark_skin_tone_medium-light_skin_tone":"👩🏿‍❤️‍👨🏼","couple_with_heart_woman_man_dark_skin_tone_medium_skin_tone":"👩🏿‍❤️‍👨🏽","couple_with_heart_woman_man_light_skin_tone":"👩🏻‍❤️‍👨🏻","couple_with_heart_woman_man_light_skin_tone_dark_skin_tone":"👩🏻‍❤️‍👨🏿","couple_with_heart_woman_man_light_skin_tone_medium-dark_skin_tone":"👩🏻‍❤️‍👨🏾","couple_with_heart_woman_man_light_skin_tone_medium-light_skin_tone":"👩🏻‍❤️‍👨🏼","couple_with_heart_woman_man_light_skin_tone_medium_skin_tone":"👩🏻‍❤️‍👨🏽","couple_with_heart_woman_man_medium-dark_skin_tone":"👩🏾‍❤️‍👨🏾","couple_with_heart_woman_man_medium-dark_skin_tone_dark_skin_tone":"👩🏾‍❤️‍👨🏿","couple_with_heart_woman_man_medium-dark_skin_tone_light_skin_tone":"👩🏾‍❤️‍👨🏻","couple_with_heart_woman_man_medium-dark_skin_tone_medium-light_skin_tone":"👩🏾‍❤️‍👨🏼","couple_with_heart_woman_man_medium-dark_skin_tone_medium_skin_tone":"👩🏾‍❤️‍👨🏽","couple_with_heart_woman_man_medium-light_skin_tone":"👩🏼‍❤️‍👨🏼","couple_with_heart_woman_man_medium-light_skin_tone_dark_skin_tone":"👩🏼‍❤️‍👨🏿","couple_with_heart_woman_man_medium-light_skin_tone_light_skin_tone":"👩🏼‍❤️‍👨🏻","couple_with_heart_woman_man_medium-light_skin_tone_medium-dark_skin_tone":"👩🏼‍❤️‍👨🏾","couple_with_heart_woman_man_medium-light_skin_tone_medium_skin_tone":"👩🏼‍❤️‍👨🏽","couple_with_heart_woman_man_medium_skin_tone":"👩🏽‍❤️‍👨🏽","couple_with_heart_woman_man_medium_skin_tone_dark_skin_tone":"👩🏽‍❤️‍👨🏿","couple_with_heart_woman_man_medium_skin_tone_light_skin_tone":"👩🏽‍❤️‍👨🏻","couple_with_heart_woman_man_medium_skin_tone_medium-dark_skin_tone":"👩🏽‍❤️‍👨🏾","couple_with_heart_woman_man_medium_skin_tone_medium-light_skin_tone":"👩🏽‍❤️‍👨🏼","couple_with_heart_woman_woman":"👩‍❤️‍👩","couple_with_heart_woman_woman_dark_skin_tone":"👩🏿‍❤️‍👩🏿","couple_with_heart_woman_woman_dark_skin_tone_light_skin_tone":"👩🏿‍❤️‍👩🏻","couple_with_heart_woman_woman_dark_skin_tone_medium-dark_skin_tone":"👩🏿‍❤️‍👩🏾","couple_with_heart_woman_woman_dark_skin_tone_medium-light_skin_tone":"👩🏿‍❤️‍👩🏼","couple_with_heart_woman_woman_dark_skin_tone_medium_skin_tone":"👩🏿‍❤️‍👩🏽","couple_with_heart_woman_woman_light_skin_tone":"👩🏻‍❤️‍👩🏻","couple_with_heart_woman_woman_light_skin_tone_dark_skin_tone":"👩🏻‍❤️‍👩🏿","couple_with_heart_woman_woman_light_skin_tone_medium-dark_skin_tone":"👩🏻‍❤️‍👩🏾","couple_with_heart_woman_woman_light_skin_tone_medium-light_skin_tone":"👩🏻‍❤️‍👩🏼","couple_with_heart_woman_woman_light_skin_tone_medium_skin_tone":"👩🏻‍❤️‍👩🏽","couple_with_heart_woman_woman_medium-dark_skin_tone":"👩🏾‍❤️‍👩🏾","couple_with_heart_woman_woman_medium-dark_skin_tone_dark_skin_tone":"👩🏾‍❤️‍👩🏿","couple_with_heart_woman_woman_medium-dark_skin_tone_light_skin_tone":"👩🏾‍❤️‍👩🏻","couple_with_heart_woman_woman_medium-dark_skin_tone_medium-light_skin_tone":"👩🏾‍❤️‍👩🏼","couple_with_heart_woman_woman_medium-dark_skin_tone_medium_skin_tone":"👩🏾‍❤️‍👩🏽","couple_with_heart_woman_woman_medium-light_skin_tone":"👩🏼‍❤️‍👩🏼","couple_with_heart_woman_woman_medium-light_skin_tone_dark_skin_tone":"👩🏼‍❤️‍👩🏿","couple_with_heart_woman_woman_medium-light_skin_tone_light_skin_tone":"👩🏼‍❤️‍👩🏻","couple_with_heart_woman_woman_medium-light_skin_tone_medium-dark_skin_tone":"👩🏼‍❤️‍👩🏾","couple_with_heart_woman_woman_medium-light_skin_tone_medium_skin_tone":"👩🏼‍❤️‍👩🏽","couple_with_heart_woman_woman_medium_skin_tone":"👩🏽‍❤️‍👩🏽","couple_with_heart_woman_woman_medium_skin_tone_dark_skin_tone":"👩🏽‍❤️‍👩🏿","couple_with_heart_woman_woman_medium_skin_tone_light_skin_tone":"👩🏽‍❤️‍👩🏻","couple_with_heart_woman_woman_medium_skin_tone_medium-dark_skin_tone":"👩🏽‍❤️‍👩🏾","couple_with_heart_woman_woman_medium_skin_tone_medium-light_skin_tone":"👩🏽‍❤️‍👩🏼","couplekiss":"💏","couplekiss_man_man":"👨‍❤️‍💋‍👨","couplekiss_man_woman":"👩‍❤️‍💋‍👨","couplekiss_woman_woman":"👩‍❤️‍💋‍👩","cow":"🐄","cow2":"🐄","cow_face":"🐮","cowboy_hat_face":"🤠","crab":"🦀","cracking_face":"🫫","crayon":"🖍️","credit_card":"💳","crescent_moon":"🌙","cricket":"🦗","cricket_bat_and_ball":"🏏","cricket_game":"🏏","croatia":"🇭🇷","crocodile":"🐊","croissant":"🥐","cross_mark":"❌","cross_mark_button":"❎","crossed_fingers":"🤞","crossed_fingers_dark_skin_tone":"🤞🏿","crossed_fingers_light_skin_tone":"🤞🏻","crossed_fingers_medium-dark_skin_tone":"🤞🏾","crossed_fingers_medium-light_skin_tone":"🤞🏼","crossed_fingers_medium_skin_tone":"🤞🏽","crossed_flags":"🎌","crossed_swords":"⚔️","crow":"🐦‍⬛","crown":"👑","crutch":"🩼","cry":"😢","crying_cat":"😿","crying_cat_face":"😿","crying_face":"😢","crystal_ball":"🔮","cuba":"🇨🇺","cucumber":"🥒","cup_with_straw":"🥤","cupcake":"🧁","cupid":"💘","curacao":"🇨🇼","curling_stone":"🥌","curly_haired_man":"👨‍🦱","curly_haired_woman":"👩‍🦱","curly_loop":"➰","currency_exchange":"💱","curry":"🍛","curry_rice":"🍛","cursing_face":"🤬","custard":"🍮","customs":"🛃","cut_of_meat":"🥩","cyclone":"🌀","cyprus":"🇨🇾","czech_republic":"🇨🇿","dagger":"🗡️","dagger_knife":"🗡️","dancer":"💃","dancers":"👯","dancing_men":"👯‍♂️","dancing_women":"👯‍♀️","dango":"🍡","dark_sunglasses":"🕶️","dart":"🎯","dash":"💨","dashing_away":"💨","date":"📅","de":"🇩🇪","deaf_man":"🧏‍♂️","deaf_man_dark_skin_tone":"🧏🏿‍♂️","deaf_man_light_skin_tone":"🧏🏻‍♂️","deaf_man_medium-dark_skin_tone":"🧏🏾‍♂️","deaf_man_medium-light_skin_tone":"🧏🏼‍♂️","deaf_man_medium_skin_tone":"🧏🏽‍♂️","deaf_person":"🧏","deaf_person_dark_skin_tone":"🧏🏿","deaf_person_light_skin_tone":"🧏🏻","deaf_person_medium-dark_skin_tone":"🧏🏾","deaf_person_medium-light_skin_tone":"🧏🏼","deaf_person_medium_skin_tone":"🧏🏽","deaf_woman":"🧏‍♀️","deaf_woman_dark_skin_tone":"🧏🏿‍♀️","deaf_woman_light_skin_tone":"🧏🏻‍♀️","deaf_woman_medium-dark_skin_tone":"🧏🏾‍♀️","deaf_woman_medium-light_skin_tone":"🧏🏼‍♀️","deaf_woman_medium_skin_tone":"🧏🏽‍♀️","deciduous_tree":"🌳","deer":"🦌","delivery_truck":"🚚","denmark":"🇩🇰","department_store":"🏬","derelict_house":"🏚️","derelict_house_building":"🏚️","desert":"🏜️","desert_island":"🏝️","desktop_computer":"🖥️","detective":"🕵️","detective_dark_skin_tone":"🕵🏿","detective_light_skin_tone":"🕵🏻","detective_medium-dark_skin_tone":"🕵🏾","detective_medium-light_skin_tone":"🕵🏼","detective_medium_skin_tone":"🕵🏽","diamond_shape_with_a_dot_inside":"💠","diamond_suit":"♦️","diamond_with_a_dot":"💠","diamonds":"♦️","diego_garcia":"🇩🇬","dim_button":"🔅","disappointed":"😞","disappointed_face":"😞","disappointed_relieved":"😥","disguised_face":"🥸","distorted_face":"🫪","divide":"➗","diving_mask":"🤿","diya_lamp":"🪔","dizzy":"💫","dizzy_face":"😵","djibouti":"🇩🇯","dna":"🧬","do_not_litter":"🚯","dodo":"🦤","dog":"🐕","dog2":"🐕","dog_face":"🐶","dollar":"💵","dollar_banknote":"💵","dolls":"🎎","dolphin":"🐬","dominica":"🇩🇲","dominican_republic":"🇩🇴","donkey":"🫏","door":"🚪","dotted_line_face":"🫥","dotted_six-pointed_star":"🔯","dotted_six_pointed_star":"🔯","double_curly_loop":"➿","double_exclamation_mark":"‼️","double_vertical_bar":"⏸️","doughnut":"🍩","dove":"🕊️","dove_of_peace":"🕊️","down-left_arrow":"↙️","down-right_arrow":"↘️","down_arrow":"⬇️","down_left_arrow":"↙️","down_right_arrow":"↘️","downcast_face_with_sweat":"😓","downwards_button":"🔽","dragon":"🐉","dragon_face":"🐲","dress":"👗","dromedary_camel":"🐪","drooling_face":"🤤","drop_of_blood":"🩸","droplet":"💧","drum":"🥁","duck":"🦆","dumpling":"🥟","dvd":"📀","e-mail":"📧","e_mail":"📧","eagle":"🦅","ear":"👂","ear_dark_skin_tone":"👂🏿","ear_light_skin_tone":"👂🏻","ear_medium-dark_skin_tone":"👂🏾","ear_medium-light_skin_tone":"👂🏼","ear_medium_skin_tone":"👂🏽","ear_of_corn":"🌽","ear_of_rice":"🌾","ear_with_hearing_aid":"🦻","ear_with_hearing_aid_dark_skin_tone":"🦻🏿","ear_with_hearing_aid_light_skin_tone":"🦻🏻","ear_with_hearing_aid_medium-dark_skin_tone":"🦻🏾","ear_with_hearing_aid_medium-light_skin_tone":"🦻🏼","ear_with_hearing_aid_medium_skin_tone":"🦻🏽","earth_africa":"🌍","earth_americas":"🌎","earth_asia":"🌏","ecuador":"🇪🇨","egg":"🍳","egg2":"🥚","eggplant":"🍆","egypt":"🇪🇬","eight":"8️⃣","eight-pointed_star":"✴️","eight-spoked_asterisk":"✳️","eight-thirty":"🕣","eight_oclock":"🕗","eight_o’clock":"🕗","eight_pointed_black_star":"✴️","eight_pointed_star":"✴️","eight_spoked_asterisk":"✳️","eight_thirty":"🕣","eject_button":"⏏️","eject_symbol":"⏏️","el_salvador":"🇸🇻","electric_plug":"🔌","elephant":"🐘","elevator":"🛗","eleven-thirty":"🕦","eleven_oclock":"🕚","eleven_o’clock":"🕚","eleven_thirty":"🕦","elf":"🧝","elf_dark_skin_tone":"🧝🏿","elf_light_skin_tone":"🧝🏻","elf_man":"🧝‍♂️","elf_medium-dark_skin_tone":"🧝🏾","elf_medium-light_skin_tone":"🧝🏼","elf_medium_skin_tone":"🧝🏽","elf_woman":"🧝‍♀️","email":"📧","empty_nest":"🪹","end":"🔚","end_arrow":"🔚","england":"🏴󠁧󠁢󠁥󠁮󠁧󠁿","enraged_face":"😡","envelope":"✉️","envelope_with_arrow":"📩","equatorial_guinea":"🇬🇶","eraser":"🪌","eritrea":"🇪🇷","es":"🇪🇸","estonia":"🇪🇪","ethiopia":"🇪🇹","eu":"🇪🇺","euro":"💶","euro_banknote":"💶","european_castle":"🏰","european_post_office":"🏤","european_union":"🇪🇺","evergreen_tree":"🌲","ewe":"🐑","exclamation":"❗","exclamation_question_mark":"⁉️","exploding_head":"🤯","expressionless":"😑","expressionless_face":"😑","eye":"👁️","eye_in_speech_bubble":"👁️‍🗨️","eye_speech_bubble":"👁️‍🗨️","eyeglasses":"👓","eyes":"👀","face_blowing_a_kiss":"😘","face_exhaling":"😮‍💨","face_holding_back_tears":"🥹","face_in_clouds":"😶‍🌫️","face_savoring_food":"😋","face_screaming_in_fear":"😱","face_vomiting":"🤮","face_with_bags_under_eyes":"🫩","face_with_crossed-out_eyes":"😵","face_with_crossed_out_eyes":"😵","face_with_diagonal_mouth":"🫤","face_with_hand_over_mouth":"🤭","face_with_head-bandage":"🤕","face_with_head_bandage":"🤕","face_with_medical_mask":"😷","face_with_monocle":"🧐","face_with_open_eyes_and_hand_over_mouth":"🫢","face_with_open_mouth":"😮","face_with_peeking_eye":"🫣","face_with_raised_eyebrow":"🤨","face_with_rolling_eyes":"🙄","face_with_spiral_eyes":"😵‍💫","face_with_steam_from_nose":"😤","face_with_symbols_on_mouth":"🤬","face_with_tears_of_joy":"😂","face_with_thermometer":"🤒","face_with_tongue":"😛","face_without_mouth":"😶","facepalm":"🤦","facepunch":"👊","factory":"🏭","factory_worker":"🧑‍🏭","factory_worker_dark_skin_tone":"🧑🏿‍🏭","factory_worker_light_skin_tone":"🧑🏻‍🏭","factory_worker_medium-dark_skin_tone":"🧑🏾‍🏭","factory_worker_medium-light_skin_tone":"🧑🏼‍🏭","factory_worker_medium_skin_tone":"🧑🏽‍🏭","fairy":"🧚","fairy_dark_skin_tone":"🧚🏿","fairy_light_skin_tone":"🧚🏻","fairy_man":"🧚‍♂️","fairy_medium-dark_skin_tone":"🧚🏾","fairy_medium-light_skin_tone":"🧚🏼","fairy_medium_skin_tone":"🧚🏽","fairy_woman":"🧚‍♀️","falafel":"🧆","falkland_islands":"🇫🇰","fallen_leaf":"🍂","family":"👪","family_adult_adult_child":"🧑‍🧑‍🧒","family_adult_adult_child_child":"🧑‍🧑‍🧒‍🧒","family_adult_child":"🧑‍🧒","family_adult_child_child":"🧑‍🧒‍🧒","family_man_boy":"👨‍👦","family_man_boy_boy":"👨‍👦‍👦","family_man_girl":"👨‍👧","family_man_girl_boy":"👨‍👧‍👦","family_man_girl_girl":"👨‍👧‍👧","family_man_man_boy":"👨‍👨‍👦","family_man_man_boy_boy":"👨‍👨‍👦‍👦","family_man_man_girl":"👨‍👨‍👧","family_man_man_girl_boy":"👨‍👨‍👧‍👦","family_man_man_girl_girl":"👨‍👨‍👧‍👧","family_man_woman_boy":"👨‍👩‍👦","family_man_woman_boy_boy":"👨‍👩‍👦‍👦","family_man_woman_girl":"👨‍👩‍👧","family_man_woman_girl_boy":"👨‍👩‍👧‍👦","family_man_woman_girl_girl":"👨‍👩‍👧‍👧","family_woman_boy":"👩‍👦","family_woman_boy_boy":"👩‍👦‍👦","family_woman_girl":"👩‍👧","family_woman_girl_boy":"👩‍👧‍👦","family_woman_girl_girl":"👩‍👧‍👧","family_woman_woman_boy":"👩‍👩‍👦","family_woman_woman_boy_boy":"👩‍👩‍👦‍👦","family_woman_woman_girl":"👩‍👩‍👧","family_woman_woman_girl_boy":"👩‍👩‍👧‍👦","family_woman_woman_girl_girl":"👩‍👩‍👧‍👧","farmer":"🧑‍🌾","farmer_dark_skin_tone":"🧑🏿‍🌾","farmer_light_skin_tone":"🧑🏻‍🌾","farmer_medium-dark_skin_tone":"🧑🏾‍🌾","farmer_medium-light_skin_tone":"🧑🏼‍🌾","farmer_medium_skin_tone":"🧑🏽‍🌾","faroe_islands":"🇫🇴","fast-forward_button":"⏩","fast_down_button":"⏬","fast_forward":"⏩","fast_forward_button":"⏩","fast_reverse_button":"⏪","fast_up_button":"⏫","fax":"📠","fax_machine":"📠","fearful":"😨","fearful_face":"😨","feather":"🪶","feet":"🐾","female_detective":"🕵️‍♀️","female_sign":"♀️","ferris_wheel":"🎡","ferry":"⛴️","field_hockey":"🏑","field_hockey_stick_and_ball":"🏑","fight_cloud":"🫯","fiji":"🇫🇯","file_cabinet":"🗄️","file_folder":"📁","film_frames":"🎞️","film_projector":"📽️","film_strip":"🎞️","fingerprint":"🫆","finland":"🇫🇮","fire":"🔥","fire_engine":"🚒","fire_extinguisher":"🧯","firecracker":"🧨","firefighter":"🧑‍🚒","firefighter_dark_skin_tone":"🧑🏿‍🚒","firefighter_light_skin_tone":"🧑🏻‍🚒","firefighter_medium-dark_skin_tone":"🧑🏾‍🚒","firefighter_medium-light_skin_tone":"🧑🏼‍🚒","firefighter_medium_skin_tone":"🧑🏽‍🚒","fireworks":"🎆","first_quarter_moon":"🌓","first_quarter_moon_face":"🌛","first_quarter_moon_with_face":"🌛","fish":"🐟","fish_cake":"🍥","fish_cake_with_swirl":"🍥","fishing_pole":"🎣","fishing_pole_and_fish":"🎣","fist":"✊","fist_left":"🤛","fist_oncoming":"👊","fist_raised":"✊","fist_right":"🤜","five":"5️⃣","five-thirty":"🕠","five_oclock":"🕔","five_o’clock":"🕔","five_thirty":"🕠","flag_for_Afghanistan":"🇦🇫","flag_for_Albania":"🇦🇱","flag_for_Algeria":"🇩🇿","flag_for_American_Samoa":"🇦🇸","flag_for_Andorra":"🇦🇩","flag_for_Angola":"🇦🇴","flag_for_Anguilla":"🇦🇮","flag_for_Antarctica":"🇦🇶","flag_for_Antigua_&_Barbuda":"🇦🇬","flag_for_Argentina":"🇦🇷","flag_for_Armenia":"🇦🇲","flag_for_Aruba":"🇦🇼","flag_for_Ascension_Island":"🇦🇨","flag_for_Australia":"🇦🇺","flag_for_Austria":"🇦🇹","flag_for_Azerbaijan":"🇦🇿","flag_for_Bahamas":"🇧🇸","flag_for_Bahrain":"🇧🇭","flag_for_Bangladesh":"🇧🇩","flag_for_Barbados":"🇧🇧","flag_for_Belarus":"🇧🇾","flag_for_Belgium":"🇧🇪","flag_for_Belize":"🇧🇿","flag_for_Benin":"🇧🇯","flag_for_Bermuda":"🇧🇲","flag_for_Bhutan":"🇧🇹","flag_for_Bolivia":"🇧🇴","flag_for_Bosnia_&_Herzegovina":"🇧🇦","flag_for_Botswana":"🇧🇼","flag_for_Bouvet_Island":"🇧🇻","flag_for_Brazil":"🇧🇷","flag_for_British_Indian_Ocean_Territory":"🇮🇴","flag_for_British_Virgin_Islands":"🇻🇬","flag_for_Brunei":"🇧🇳","flag_for_Bulgaria":"🇧🇬","flag_for_Burkina_Faso":"🇧🇫","flag_for_Burundi":"🇧🇮","flag_for_Cambodia":"🇰🇭","flag_for_Cameroon":"🇨🇲","flag_for_Canada":"🇨🇦","flag_for_Canary_Islands":"🇮🇨","flag_for_Cape_Verde":"🇨🇻","flag_for_Caribbean_Netherlands":"🇧🇶","flag_for_Cayman_Islands":"🇰🇾","flag_for_Central_African_Republic":"🇨🇫","flag_for_Ceuta_&_Melilla":"🇪🇦","flag_for_Chad":"🇹🇩","flag_for_Chile":"🇨🇱","flag_for_China":"🇨🇳","flag_for_Christmas_Island":"🇨🇽","flag_for_Clipperton_Island":"🇨🇵","flag_for_Cocos_Islands":"🇨🇨","flag_for_Colombia":"🇨🇴","flag_for_Comoros":"🇰🇲","flag_for_Congo_Brazzaville":"🇨🇬","flag_for_Congo_Kinshasa":"🇨🇩","flag_for_Cook_Islands":"🇨🇰","flag_for_Costa_Rica":"🇨🇷","flag_for_Croatia":"🇭🇷","flag_for_Cuba":"🇨🇺","flag_for_Curaçao":"🇨🇼","flag_for_Cyprus":"🇨🇾","flag_for_Czech_Republic":"🇨🇿","flag_for_Côte_d’Ivoire":"🇨🇮","flag_for_Denmark":"🇩🇰","flag_for_Diego_Garcia":"🇩🇬","flag_for_Djibouti":"🇩🇯","flag_for_Dominica":"🇩🇲","flag_for_Dominican_Republic":"🇩🇴","flag_for_Ecuador":"🇪🇨","flag_for_Egypt":"🇪🇬","flag_for_El_Salvador":"🇸🇻","flag_for_Equatorial_Guinea":"🇬🇶","flag_for_Eritrea":"🇪🇷","flag_for_Estonia":"🇪🇪","flag_for_Ethiopia":"🇪🇹","flag_for_European_Union":"🇪🇺","flag_for_Falkland_Islands":"🇫🇰","flag_for_Faroe_Islands":"🇫🇴","flag_for_Fiji":"🇫🇯","flag_for_Finland":"🇫🇮","flag_for_France":"🇫🇷","flag_for_French_Guiana":"🇬🇫","flag_for_French_Polynesia":"🇵🇫","flag_for_French_Southern_Territories":"🇹🇫","flag_for_Gabon":"🇬🇦","flag_for_Gambia":"🇬🇲","flag_for_Georgia":"🇬🇪","flag_for_Germany":"🇩🇪","flag_for_Ghana":"🇬🇭","flag_for_Gibraltar":"🇬🇮","flag_for_Greece":"🇬🇷","flag_for_Greenland":"🇬🇱","flag_for_Grenada":"🇬🇩","flag_for_Guadeloupe":"🇬🇵","flag_for_Guam":"🇬🇺","flag_for_Guatemala":"🇬🇹","flag_for_Guernsey":"🇬🇬","flag_for_Guinea":"🇬🇳","flag_for_Guinea_Bissau":"🇬🇼","flag_for_Guyana":"🇬🇾","flag_for_Haiti":"🇭🇹","flag_for_Heard_&_McDonald_Islands":"🇭🇲","flag_for_Honduras":"🇭🇳","flag_for_Hong_Kong":"🇭🇰","flag_for_Hungary":"🇭🇺","flag_for_Iceland":"🇮🇸","flag_for_India":"🇮🇳","flag_for_Indonesia":"🇮🇩","flag_for_Iran":"🇮🇷","flag_for_Iraq":"🇮🇶","flag_for_Ireland":"🇮🇪","flag_for_Isle_of_Man":"🇮🇲","flag_for_Israel":"🇮🇱","flag_for_Italy":"🇮🇹","flag_for_Jamaica":"🇯🇲","flag_for_Japan":"🇯🇵","flag_for_Jersey":"🇯🇪","flag_for_Jordan":"🇯🇴","flag_for_Kazakhstan":"🇰🇿","flag_for_Kenya":"🇰🇪","flag_for_Kiribati":"🇰🇮","flag_for_Kosovo":"🇽🇰","flag_for_Kuwait":"🇰🇼","flag_for_Kyrgyzstan":"🇰🇬","flag_for_Laos":"🇱🇦","flag_for_Latvia":"🇱🇻","flag_for_Lebanon":"🇱🇧","flag_for_Lesotho":"🇱🇸","flag_for_Liberia":"🇱🇷","flag_for_Libya":"🇱🇾","flag_for_Liechtenstein":"🇱🇮","flag_for_Lithuania":"🇱🇹","flag_for_Luxembourg":"🇱🇺","flag_for_Macau":"🇲🇴","flag_for_Macedonia":"🇲🇰","flag_for_Madagascar":"🇲🇬","flag_for_Malawi":"🇲🇼","flag_for_Malaysia":"🇲🇾","flag_for_Maldives":"🇲🇻","flag_for_Mali":"🇲🇱","flag_for_Malta":"🇲🇹","flag_for_Marshall_Islands":"🇲🇭","flag_for_Martinique":"🇲🇶","flag_for_Mauritania":"🇲🇷","flag_for_Mauritius":"🇲🇺","flag_for_Mayotte":"🇾🇹","flag_for_Mexico":"🇲🇽","flag_for_Micronesia":"🇫🇲","flag_for_Moldova":"🇲🇩","flag_for_Monaco":"🇲🇨","flag_for_Mongolia":"🇲🇳","flag_for_Montenegro":"🇲🇪","flag_for_Montserrat":"🇲🇸","flag_for_Morocco":"🇲🇦","flag_for_Mozambique":"🇲🇿","flag_for_Myanmar":"🇲🇲","flag_for_Namibia":"🇳🇦","flag_for_Nauru":"🇳🇷","flag_for_Nepal":"🇳🇵","flag_for_Netherlands":"🇳🇱","flag_for_New_Caledonia":"🇳🇨","flag_for_New_Zealand":"🇳🇿","flag_for_Nicaragua":"🇳🇮","flag_for_Niger":"🇳🇪","flag_for_Nigeria":"🇳🇬","flag_for_Niue":"🇳🇺","flag_for_Norfolk_Island":"🇳🇫","flag_for_North_Korea":"🇰🇵","flag_for_Northern_Mariana_Islands":"🇲🇵","flag_for_Norway":"🇳🇴","flag_for_Oman":"🇴🇲","flag_for_Pakistan":"🇵🇰","flag_for_Palau":"🇵🇼","flag_for_Palestinian_Territories":"🇵🇸","flag_for_Panama":"🇵🇦","flag_for_Papua_New_Guinea":"🇵🇬","flag_for_Paraguay":"🇵🇾","flag_for_Peru":"🇵🇪","flag_for_Philippines":"🇵🇭","flag_for_Pitcairn_Islands":"🇵🇳","flag_for_Poland":"🇵🇱","flag_for_Portugal":"🇵🇹","flag_for_Puerto_Rico":"🇵🇷","flag_for_Qatar":"🇶🇦","flag_for_Romania":"🇷🇴","flag_for_Russia":"🇷🇺","flag_for_Rwanda":"🇷🇼","flag_for_Réunion":"🇷🇪","flag_for_Samoa":"🇼🇸","flag_for_San_Marino":"🇸🇲","flag_for_Sark":"🇨🇶","flag_for_Saudi_Arabia":"🇸🇦","flag_for_Senegal":"🇸🇳","flag_for_Serbia":"🇷🇸","flag_for_Seychelles":"🇸🇨","flag_for_Sierra_Leone":"🇸🇱","flag_for_Singapore":"🇸🇬","flag_for_Sint_Maarten":"🇸🇽","flag_for_Slovakia":"🇸🇰","flag_for_Slovenia":"🇸🇮","flag_for_Solomon_Islands":"🇸🇧","flag_for_Somalia":"🇸🇴","flag_for_South_Africa":"🇿🇦","flag_for_South_Georgia_&_South_Sandwich_Islands":"🇬🇸","flag_for_South_Korea":"🇰🇷","flag_for_South_Sudan":"🇸🇸","flag_for_Spain":"🇪🇸","flag_for_Sri_Lanka":"🇱🇰","flag_for_St._Barthélemy":"🇧🇱","flag_for_St._Helena":"🇸🇭","flag_for_St._Kitts_&_Nevis":"🇰🇳","flag_for_St._Lucia":"🇱🇨","flag_for_St._Martin":"🇲🇫","flag_for_St._Pierre_&_Miquelon":"🇵🇲","flag_for_St._Vincent_&_Grenadines":"🇻🇨","flag_for_Sudan":"🇸🇩","flag_for_Suriname":"🇸🇷","flag_for_Svalbard_&_Jan_Mayen":"🇸🇯","flag_for_Swaziland":"🇸🇿","flag_for_Sweden":"🇸🇪","flag_for_Switzerland":"🇨🇭","flag_for_Syria":"🇸🇾","flag_for_São_Tomé_&_Príncipe":"🇸🇹","flag_for_Taiwan":"🇹🇼","flag_for_Tajikistan":"🇹🇯","flag_for_Tanzania":"🇹🇿","flag_for_Thailand":"🇹🇭","flag_for_Timor_Leste":"🇹🇱","flag_for_Togo":"🇹🇬","flag_for_Tokelau":"🇹🇰","flag_for_Tonga":"🇹🇴","flag_for_Trinidad_&_Tobago":"🇹🇹","flag_for_Tristan_da_Cunha":"🇹🇦","flag_for_Tunisia":"🇹🇳","flag_for_Turkey":"🇹🇷","flag_for_Turkmenistan":"🇹🇲","flag_for_Turks_&_Caicos_Islands":"🇹🇨","flag_for_Tuvalu":"🇹🇻","flag_for_U.S._Outlying_Islands":"🇺🇲","flag_for_U.S._Virgin_Islands":"🇻🇮","flag_for_Uganda":"🇺🇬","flag_for_Ukraine":"🇺🇦","flag_for_United_Arab_Emirates":"🇦🇪","flag_for_United_Kingdom":"🇬🇧","flag_for_United_States":"🇺🇸","flag_for_Uruguay":"🇺🇾","flag_for_Uzbekistan":"🇺🇿","flag_for_Vanuatu":"🇻🇺","flag_for_Vatican_City":"🇻🇦","flag_for_Venezuela":"🇻🇪","flag_for_Vietnam":"🇻🇳","flag_for_Wallis_&_Futuna":"🇼🇫","flag_for_Western_Sahara":"🇪🇭","flag_for_Yemen":"🇾🇪","flag_for_Zambia":"🇿🇲","flag_for_Zimbabwe":"🇿🇼","flag_for_Åland_Islands":"🇦🇽","flag_in_hole":"⛳","flags":"🎏","flamingo":"🦩","flashlight":"🔦","flat_shoe":"🥿","flatbread":"🫓","fleur-de-lis":"⚜️","fleur_de_lis":"⚜️","flexed_biceps":"💪","flexed_biceps_dark_skin_tone":"💪🏿","flexed_biceps_light_skin_tone":"💪🏻","flexed_biceps_medium-dark_skin_tone":"💪🏾","flexed_biceps_medium-light_skin_tone":"💪🏼","flexed_biceps_medium_skin_tone":"💪🏽","flight_arrival":"🛬","flight_departure":"🛫","flipper":"🐬","floppy_disk":"💾","flower_playing_cards":"🎴","flushed":"😳","flushed_face":"😳","flute":"🪈","fly":"🪰","flying_disc":"🥏","flying_saucer":"🛸","fog":"🌫️","foggy":"🌁","folded_hands":"🙏","folded_hands_dark_skin_tone":"🙏🏿","folded_hands_light_skin_tone":"🙏🏻","folded_hands_medium-dark_skin_tone":"🙏🏾","folded_hands_medium-light_skin_tone":"🙏🏼","folded_hands_medium_skin_tone":"🙏🏽","folding_hand_fan":"🪭","fondue":"🫕","foot":"🦶","foot_dark_skin_tone":"🦶🏿","foot_light_skin_tone":"🦶🏻","foot_medium-dark_skin_tone":"🦶🏾","foot_medium-light_skin_tone":"🦶🏼","foot_medium_skin_tone":"🦶🏽","football":"🏈","footprints":"👣","fork_and_knife":"🍴","fork_and_knife_with_plate":"🍽️","fortune_cookie":"🥠","fountain":"⛲","fountain_pen":"🖋️","four":"4️⃣","four-thirty":"🕟","four_leaf_clover":"🍀","four_oclock":"🕓","four_o’clock":"🕓","four_thirty":"🕟","fox":"🦊","fox_face":"🦊","fr":"🇫🇷","frame_with_picture":"🖼️","framed_picture":"🖼️","free":"🆓","free_button":"🆓","french_fries":"🍟","french_guiana":"🇬🇫","french_polynesia":"🇵🇫","french_southern_territories":"🇹🇫","fried_egg":"🍳","fried_shrimp":"🍤","fries":"🍟","frog":"🐸","front-facing_baby_chick":"🐥","front_facing_baby_chick":"🐥","frowning":"😦","frowning_face":"☹️","frowning_face_with_open_mouth":"😦","frowning_man":"🙍‍♂️","frowning_person":"🙍","frowning_woman":"🙍‍♀️","fu":"🖕","fuel_pump":"⛽","fuelpump":"⛽","full_moon":"🌕","full_moon_face":"🌝","full_moon_with_face":"🌝","funeral_urn":"⚱️","gabon":"🇬🇦","gambia":"🇬🇲","game_die":"🎲","garlic":"🧄","gb":"🇬🇧","gear":"⚙️","gem":"💎","gem_stone":"💎","gemini":"♊","genie":"🧞","genie_man":"🧞‍♂️","genie_woman":"🧞‍♀️","georgia":"🇬🇪","ghana":"🇬🇭","ghost":"👻","gibraltar":"🇬🇮","gift":"🎁","gift_heart":"💝","ginger_root":"🫚","giraffe":"🦒","girl":"👧","girl_dark_skin_tone":"👧🏿","girl_light_skin_tone":"👧🏻","girl_medium-dark_skin_tone":"👧🏾","girl_medium-light_skin_tone":"👧🏼","girl_medium_skin_tone":"👧🏽","glass_of_milk":"🥛","glasses":"👓","globe_showing_Americas":"🌎","globe_showing_Asia-Australia":"🌏","globe_showing_Europe-Africa":"🌍","globe_showing_americas":"🌎","globe_showing_asia_australia":"🌏","globe_showing_europe_africa":"🌍","globe_with_meridians":"🌐","gloves":"🧤","glowing_star":"🌟","goal_net":"🥅","goat":"🐐","goblin":"👺","goggles":"🥽","golf":"⛳","golfer":"🏌️","golfing":"🏌️","golfing_man":"🏌️‍♂️","golfing_woman":"🏌️‍♀️","goose":"🪿","gorilla":"🦍","graduation_cap":"🎓","grapes":"🍇","greece":"🇬🇷","green_apple":"🍏","green_book":"📗","green_circle":"🟢","green_heart":"💚","green_salad":"🥗","green_square":"🟩","greenland":"🇬🇱","grenada":"🇬🇩","grey_exclamation":"❕","grey_heart":"🩶","grey_question":"❔","grimacing":"😬","grimacing_face":"😬","grin":"😁","grinning":"😀","grinning_cat":"😺","grinning_cat_with_smiling_eyes":"😸","grinning_face":"😀","grinning_face_with_big_eyes":"😃","grinning_face_with_smiling_eyes":"😄","grinning_face_with_sweat":"😅","grinning_squinting_face":"😆","growing_heart":"💗","guadeloupe":"🇬🇵","guam":"🇬🇺","guard":"💂","guard_dark_skin_tone":"💂🏿","guard_light_skin_tone":"💂🏻","guard_medium-dark_skin_tone":"💂🏾","guard_medium-light_skin_tone":"💂🏼","guard_medium_skin_tone":"💂🏽","guardsman":"💂‍♂️","guardswoman":"💂‍♀️","guatemala":"🇬🇹","guernsey":"🇬🇬","guide_dog":"🦮","guinea":"🇬🇳","guinea_bissau":"🇬🇼","guitar":"🎸","gun":"🔫","guyana":"🇬🇾","hair_pick":"🪮","haircut":"💇","haircut_man":"💇‍♂️","haircut_woman":"💇‍♀️","hairy_creature":"🫈","haiti":"🇭🇹","hamburger":"🍔","hammer":"🔨","hammer_and_pick":"⚒️","hammer_and_wrench":"🛠️","hamsa":"🪬","hamster":"🐹","hand":"✋","hand_over_mouth":"🤭","hand_with_fingers_splayed":"🖐️","hand_with_fingers_splayed_dark_skin_tone":"🖐🏿","hand_with_fingers_splayed_light_skin_tone":"🖐🏻","hand_with_fingers_splayed_medium-dark_skin_tone":"🖐🏾","hand_with_fingers_splayed_medium-light_skin_tone":"🖐🏼","hand_with_fingers_splayed_medium_skin_tone":"🖐🏽","hand_with_index_finger_and_thumb_crossed":"🫰","hand_with_index_finger_and_thumb_crossed_dark_skin_tone":"🫰🏿","hand_with_index_finger_and_thumb_crossed_light_skin_tone":"🫰🏻","hand_with_index_finger_and_thumb_crossed_medium-dark_skin_tone":"🫰🏾","hand_with_index_finger_and_thumb_crossed_medium-light_skin_tone":"🫰🏼","hand_with_index_finger_and_thumb_crossed_medium_skin_tone":"🫰🏽","handbag":"👜","handball_person":"🤾","handshake":"🤝","handshake_dark_skin_tone":"🤝🏿","handshake_dark_skin_tone_light_skin_tone":"🫱🏿‍🫲🏻","handshake_dark_skin_tone_medium-dark_skin_tone":"🫱🏿‍🫲🏾","handshake_dark_skin_tone_medium-light_skin_tone":"🫱🏿‍🫲🏼","handshake_dark_skin_tone_medium_skin_tone":"🫱🏿‍🫲🏽","handshake_light_skin_tone":"🤝🏻","handshake_light_skin_tone_dark_skin_tone":"🫱🏻‍🫲🏿","handshake_light_skin_tone_medium-dark_skin_tone":"🫱🏻‍🫲🏾","handshake_light_skin_tone_medium-light_skin_tone":"🫱🏻‍🫲🏼","handshake_light_skin_tone_medium_skin_tone":"🫱🏻‍🫲🏽","handshake_medium-dark_skin_tone":"🤝🏾","handshake_medium-dark_skin_tone_dark_skin_tone":"🫱🏾‍🫲🏿","handshake_medium-dark_skin_tone_light_skin_tone":"🫱🏾‍🫲🏻","handshake_medium-dark_skin_tone_medium-light_skin_tone":"🫱🏾‍🫲🏼","handshake_medium-dark_skin_tone_medium_skin_tone":"🫱🏾‍🫲🏽","handshake_medium-light_skin_tone":"🤝🏼","handshake_medium-light_skin_tone_dark_skin_tone":"🫱🏼‍🫲🏿","handshake_medium-light_skin_tone_light_skin_tone":"🫱🏼‍🫲🏻","handshake_medium-light_skin_tone_medium-dark_skin_tone":"🫱🏼‍🫲🏾","handshake_medium-light_skin_tone_medium_skin_tone":"🫱🏼‍🫲🏽","handshake_medium_skin_tone":"🤝🏽","handshake_medium_skin_tone_dark_skin_tone":"🫱🏽‍🫲🏿","handshake_medium_skin_tone_light_skin_tone":"🫱🏽‍🫲🏻","handshake_medium_skin_tone_medium-dark_skin_tone":"🫱🏽‍🫲🏾","handshake_medium_skin_tone_medium-light_skin_tone":"🫱🏽‍🫲🏼","hankey":"💩","harambe":"🦍","harp":"🪉","hash":"#️⃣","hatched_chick":"🐥","hatching_chick":"🐣","head_shaking_horizontally":"🙂‍↔️","head_shaking_vertically":"🙂‍↕️","headphone":"🎧","headphones":"🎧","headstone":"🪦","health_worker":"🧑‍⚕️","health_worker_dark_skin_tone":"🧑🏿‍⚕️","health_worker_light_skin_tone":"🧑🏻‍⚕️","health_worker_medium-dark_skin_tone":"🧑🏾‍⚕️","health_worker_medium-light_skin_tone":"🧑🏼‍⚕️","health_worker_medium_skin_tone":"🧑🏽‍⚕️","hear-no-evil_monkey":"🙉","hear_no_evil":"🙉","hear_no_evil_monkey":"🙉","heard_mcdonald_islands":"🇭🇲","heart":"❤️","heart_decoration":"💟","heart_exclamation":"❣️","heart_eyes":"😍","heart_eyes_cat":"😻","heart_hands":"🫶","heart_hands_dark_skin_tone":"🫶🏿","heart_hands_light_skin_tone":"🫶🏻","heart_hands_medium-dark_skin_tone":"🫶🏾","heart_hands_medium-light_skin_tone":"🫶🏼","heart_hands_medium_skin_tone":"🫶🏽","heart_on_fire":"❤️‍🔥","heart_suit":"♥️","heart_with_arrow":"💘","heart_with_ribbon":"💝","heartbeat":"💓","heartpulse":"💗","hearts":"♥️","heavy_check_mark":"✔️","heavy_division_sign":"➗","heavy_dollar_sign":"💲","heavy_equals_sign":"🟰","heavy_exclamation_mark":"❗","heavy_heart_exclamation":"❣️","heavy_heart_exclamation_mark_ornament":"❣️","heavy_minus_sign":"➖","heavy_multiplication_x":"✖️","heavy_plus_sign":"➕","hedgehog":"🦔","helicopter":"🚁","helmet_with_white_cross":"⛑️","herb":"🌿","hibiscus":"🌺","high-heeled_shoe":"👠","high-speed_train":"🚄","high_brightness":"🔆","high_heel":"👠","high_heeled_shoe":"👠","high_speed_train":"🚄","high_voltage":"⚡","hiking_boot":"🥾","hindu_temple":"🛕","hippopotamus":"🦛","hocho":"🔪","hole":"🕳️","hollow_red_circle":"⭕","honduras":"🇭🇳","honey_pot":"🍯","honeybee":"🐝","hong_kong":"🇭🇰","hook":"🪝","horizontal_traffic_light":"🚥","horse":"🐎","horse_face":"🐴","horse_racing":"🏇","horse_racing_dark_skin_tone":"🏇🏿","horse_racing_light_skin_tone":"🏇🏻","horse_racing_medium-dark_skin_tone":"🏇🏾","horse_racing_medium-light_skin_tone":"🏇🏼","horse_racing_medium_skin_tone":"🏇🏽","hospital":"🏥","hot_beverage":"☕","hot_dog":"🌭","hot_face":"🥵","hot_pepper":"🌶️","hot_springs":"♨️","hotdog":"🌭","hotel":"🏨","hotsprings":"♨️","hourglass":"⌛","hourglass_done":"⌛","hourglass_flowing_sand":"⏳","hourglass_not_done":"⏳","house":"🏠","house_buildings":"🏘️","house_with_garden":"🏡","houses":"🏘️","hugging_face":"🤗","hugs":"🤗","hundred_points":"💯","hungary":"🇭🇺","hushed":"😯","hushed_face":"😯","hut":"🛖","hyacinth":"🪻","ice":"🧊","ice_cream":"🍨","ice_cube":"🧊","ice_hockey":"🏒","ice_hockey_stick_and_puck":"🏒","ice_skate":"⛸️","icecream":"🍦","iceland":"🇮🇸","id":"🆔","id_button":"🆔","identification_card":"🪪","ideograph_advantage":"🉐","imp":"👿","inbox_tray":"📥","incoming_envelope":"📨","index_pointing_at_the_viewer":"🫵","index_pointing_at_the_viewer_dark_skin_tone":"🫵🏿","index_pointing_at_the_viewer_light_skin_tone":"🫵🏻","index_pointing_at_the_viewer_medium-dark_skin_tone":"🫵🏾","index_pointing_at_the_viewer_medium-light_skin_tone":"🫵🏼","index_pointing_at_the_viewer_medium_skin_tone":"🫵🏽","index_pointing_up":"☝️","index_pointing_up_dark_skin_tone":"☝🏿","index_pointing_up_light_skin_tone":"☝🏻","index_pointing_up_medium-dark_skin_tone":"☝🏾","index_pointing_up_medium-light_skin_tone":"☝🏼","index_pointing_up_medium_skin_tone":"☝🏽","india":"🇮🇳","indonesia":"🇮🇩","infinity":"♾️","information":"ℹ️","information_desk_person":"💁","information_source":"ℹ️","innocent":"😇","input_latin_letters":"🔤","input_latin_lowercase":"🔡","input_latin_uppercase":"🔠","input_numbers":"🔢","input_symbols":"🔣","interrobang":"⁉️","iphone":"📱","iran":"🇮🇷","iraq":"🇮🇶","ireland":"🇮🇪","isle_of_man":"🇮🇲","israel":"🇮🇱","it":"🇮🇹","izakaya_lantern":"🏮","jack-o-lantern":"🎃","jack_o_lantern":"🎃","jamaica":"🇯🇲","japan":"🗾","japanese_acceptable_button":"🉑","japanese_application_button":"🈸","japanese_bargain_button":"🉐","japanese_castle":"🏯","japanese_congratulations_button":"㊗️","japanese_discount_button":"🈹","japanese_dolls":"🎎","japanese_free_of_charge_button":"🈚","japanese_goblin":"👺","japanese_here_button":"🈁","japanese_monthly_amount_button":"🈷️","japanese_no_vacancy_button":"🈵","japanese_not_free_of_charge_button":"🈶","japanese_ogre":"👹","japanese_open_for_business_button":"🈺","japanese_passing_grade_button":"🈴","japanese_post_office":"🏣","japanese_prohibited_button":"🈲","japanese_reserved_button":"🈯","japanese_secret_button":"㊙️","japanese_service_charge_button":"🈂️","japanese_symbol_for_beginner":"🔰","japanese_vacancy_button":"🈳","jar":"🫙","jeans":"👖","jellyfish":"🪼","jersey":"🇯🇪","jigsaw":"🧩","joker":"🃏","jordan":"🇯🇴","joy":"😂","joy_cat":"😹","joystick":"🕹️","jp":"🇯🇵","judge":"🧑‍⚖️","judge_dark_skin_tone":"🧑🏿‍⚖️","judge_light_skin_tone":"🧑🏻‍⚖️","judge_medium-dark_skin_tone":"🧑🏾‍⚖️","judge_medium-light_skin_tone":"🧑🏼‍⚖️","judge_medium_skin_tone":"🧑🏽‍⚖️","juggling_person":"🤹","kaaba":"🕋","kangaroo":"🦘","kazakhstan":"🇰🇿","kenya":"🇰🇪","key":"🔑","keyboard":"⌨️","keycap_#":"#️⃣","keycap_*":"*️⃣","keycap_0":"0️⃣","keycap_1":"1️⃣","keycap_10":"🔟","keycap_2":"2️⃣","keycap_3":"3️⃣","keycap_4":"4️⃣","keycap_5":"5️⃣","keycap_6":"6️⃣","keycap_7":"7️⃣","keycap_8":"8️⃣","keycap_9":"9️⃣","keycap_ten":"🔟","khanda":"🪯","kick_scooter":"🛴","kimono":"👘","kiribati":"🇰🇮","kiss":"💏","kiss_dark_skin_tone":"💏🏿","kiss_light_skin_tone":"💏🏻","kiss_man_man":"👨‍❤️‍💋‍👨","kiss_man_man_dark_skin_tone":"👨🏿‍❤️‍💋‍👨🏿","kiss_man_man_dark_skin_tone_light_skin_tone":"👨🏿‍❤️‍💋‍👨🏻","kiss_man_man_dark_skin_tone_medium-dark_skin_tone":"👨🏿‍❤️‍💋‍👨🏾","kiss_man_man_dark_skin_tone_medium-light_skin_tone":"👨🏿‍❤️‍💋‍👨🏼","kiss_man_man_dark_skin_tone_medium_skin_tone":"👨🏿‍❤️‍💋‍👨🏽","kiss_man_man_light_skin_tone":"👨🏻‍❤️‍💋‍👨🏻","kiss_man_man_light_skin_tone_dark_skin_tone":"👨🏻‍❤️‍💋‍👨🏿","kiss_man_man_light_skin_tone_medium-dark_skin_tone":"👨🏻‍❤️‍💋‍👨🏾","kiss_man_man_light_skin_tone_medium-light_skin_tone":"👨🏻‍❤️‍💋‍👨🏼","kiss_man_man_light_skin_tone_medium_skin_tone":"👨🏻‍❤️‍💋‍👨🏽","kiss_man_man_medium-dark_skin_tone":"👨🏾‍❤️‍💋‍👨🏾","kiss_man_man_medium-dark_skin_tone_dark_skin_tone":"👨🏾‍❤️‍💋‍👨🏿","kiss_man_man_medium-dark_skin_tone_light_skin_tone":"👨🏾‍❤️‍💋‍👨🏻","kiss_man_man_medium-dark_skin_tone_medium-light_skin_tone":"👨🏾‍❤️‍💋‍👨🏼","kiss_man_man_medium-dark_skin_tone_medium_skin_tone":"👨🏾‍❤️‍💋‍👨🏽","kiss_man_man_medium-light_skin_tone":"👨🏼‍❤️‍💋‍👨🏼","kiss_man_man_medium-light_skin_tone_dark_skin_tone":"👨🏼‍❤️‍💋‍👨🏿","kiss_man_man_medium-light_skin_tone_light_skin_tone":"👨🏼‍❤️‍💋‍👨🏻","kiss_man_man_medium-light_skin_tone_medium-dark_skin_tone":"👨🏼‍❤️‍💋‍👨🏾","kiss_man_man_medium-light_skin_tone_medium_skin_tone":"👨🏼‍❤️‍💋‍👨🏽","kiss_man_man_medium_skin_tone":"👨🏽‍❤️‍💋‍👨🏽","kiss_man_man_medium_skin_tone_dark_skin_tone":"👨🏽‍❤️‍💋‍👨🏿","kiss_man_man_medium_skin_tone_light_skin_tone":"👨🏽‍❤️‍💋‍👨🏻","kiss_man_man_medium_skin_tone_medium-dark_skin_tone":"👨🏽‍❤️‍💋‍👨🏾","kiss_man_man_medium_skin_tone_medium-light_skin_tone":"👨🏽‍❤️‍💋‍👨🏼","kiss_mark":"💋","kiss_medium-dark_skin_tone":"💏🏾","kiss_medium-light_skin_tone":"💏🏼","kiss_medium_skin_tone":"💏🏽","kiss_person_person_dark_skin_tone_light_skin_tone":"🧑🏿‍❤️‍💋‍🧑🏻","kiss_person_person_dark_skin_tone_medium-dark_skin_tone":"🧑🏿‍❤️‍💋‍🧑🏾","kiss_person_person_dark_skin_tone_medium-light_skin_tone":"🧑🏿‍❤️‍💋‍🧑🏼","kiss_person_person_dark_skin_tone_medium_skin_tone":"🧑🏿‍❤️‍💋‍🧑🏽","kiss_person_person_light_skin_tone_dark_skin_tone":"🧑🏻‍❤️‍💋‍🧑🏿","kiss_person_person_light_skin_tone_medium-dark_skin_tone":"🧑🏻‍❤️‍💋‍🧑🏾","kiss_person_person_light_skin_tone_medium-light_skin_tone":"🧑🏻‍❤️‍💋‍🧑🏼","kiss_person_person_light_skin_tone_medium_skin_tone":"🧑🏻‍❤️‍💋‍🧑🏽","kiss_person_person_medium-dark_skin_tone_dark_skin_tone":"🧑🏾‍❤️‍💋‍🧑🏿","kiss_person_person_medium-dark_skin_tone_light_skin_tone":"🧑🏾‍❤️‍💋‍🧑🏻","kiss_person_person_medium-dark_skin_tone_medium-light_skin_tone":"🧑🏾‍❤️‍💋‍🧑🏼","kiss_person_person_medium-dark_skin_tone_medium_skin_tone":"🧑🏾‍❤️‍💋‍🧑🏽","kiss_person_person_medium-light_skin_tone_dark_skin_tone":"🧑🏼‍❤️‍💋‍🧑🏿","kiss_person_person_medium-light_skin_tone_light_skin_tone":"🧑🏼‍❤️‍💋‍🧑🏻","kiss_person_person_medium-light_skin_tone_medium-dark_skin_tone":"🧑🏼‍❤️‍💋‍🧑🏾","kiss_person_person_medium-light_skin_tone_medium_skin_tone":"🧑🏼‍❤️‍💋‍🧑🏽","kiss_person_person_medium_skin_tone_dark_skin_tone":"🧑🏽‍❤️‍💋‍🧑🏿","kiss_person_person_medium_skin_tone_light_skin_tone":"🧑🏽‍❤️‍💋‍🧑🏻","kiss_person_person_medium_skin_tone_medium-dark_skin_tone":"🧑🏽‍❤️‍💋‍🧑🏾","kiss_person_person_medium_skin_tone_medium-light_skin_tone":"🧑🏽‍❤️‍💋‍🧑🏼","kiss_woman_man":"👩‍❤️‍💋‍👨","kiss_woman_man_dark_skin_tone":"👩🏿‍❤️‍💋‍👨🏿","kiss_woman_man_dark_skin_tone_light_skin_tone":"👩🏿‍❤️‍💋‍👨🏻","kiss_woman_man_dark_skin_tone_medium-dark_skin_tone":"👩🏿‍❤️‍💋‍👨🏾","kiss_woman_man_dark_skin_tone_medium-light_skin_tone":"👩🏿‍❤️‍💋‍👨🏼","kiss_woman_man_dark_skin_tone_medium_skin_tone":"👩🏿‍❤️‍💋‍👨🏽","kiss_woman_man_light_skin_tone":"👩🏻‍❤️‍💋‍👨🏻","kiss_woman_man_light_skin_tone_dark_skin_tone":"👩🏻‍❤️‍💋‍👨🏿","kiss_woman_man_light_skin_tone_medium-dark_skin_tone":"👩🏻‍❤️‍💋‍👨🏾","kiss_woman_man_light_skin_tone_medium-light_skin_tone":"👩🏻‍❤️‍💋‍👨🏼","kiss_woman_man_light_skin_tone_medium_skin_tone":"👩🏻‍❤️‍💋‍👨🏽","kiss_woman_man_medium-dark_skin_tone":"👩🏾‍❤️‍💋‍👨🏾","kiss_woman_man_medium-dark_skin_tone_dark_skin_tone":"👩🏾‍❤️‍💋‍👨🏿","kiss_woman_man_medium-dark_skin_tone_light_skin_tone":"👩🏾‍❤️‍💋‍👨🏻","kiss_woman_man_medium-dark_skin_tone_medium-light_skin_tone":"👩🏾‍❤️‍💋‍👨🏼","kiss_woman_man_medium-dark_skin_tone_medium_skin_tone":"👩🏾‍❤️‍💋‍👨🏽","kiss_woman_man_medium-light_skin_tone":"👩🏼‍❤️‍💋‍👨🏼","kiss_woman_man_medium-light_skin_tone_dark_skin_tone":"👩🏼‍❤️‍💋‍👨🏿","kiss_woman_man_medium-light_skin_tone_light_skin_tone":"👩🏼‍❤️‍💋‍👨🏻","kiss_woman_man_medium-light_skin_tone_medium-dark_skin_tone":"👩🏼‍❤️‍💋‍👨🏾","kiss_woman_man_medium-light_skin_tone_medium_skin_tone":"👩🏼‍❤️‍💋‍👨🏽","kiss_woman_man_medium_skin_tone":"👩🏽‍❤️‍💋‍👨🏽","kiss_woman_man_medium_skin_tone_dark_skin_tone":"👩🏽‍❤️‍💋‍👨🏿","kiss_woman_man_medium_skin_tone_light_skin_tone":"👩🏽‍❤️‍💋‍👨🏻","kiss_woman_man_medium_skin_tone_medium-dark_skin_tone":"👩🏽‍❤️‍💋‍👨🏾","kiss_woman_man_medium_skin_tone_medium-light_skin_tone":"👩🏽‍❤️‍💋‍👨🏼","kiss_woman_woman":"👩‍❤️‍💋‍👩","kiss_woman_woman_dark_skin_tone":"👩🏿‍❤️‍💋‍👩🏿","kiss_woman_woman_dark_skin_tone_light_skin_tone":"👩🏿‍❤️‍💋‍👩🏻","kiss_woman_woman_dark_skin_tone_medium-dark_skin_tone":"👩🏿‍❤️‍💋‍👩🏾","kiss_woman_woman_dark_skin_tone_medium-light_skin_tone":"👩🏿‍❤️‍💋‍👩🏼","kiss_woman_woman_dark_skin_tone_medium_skin_tone":"👩🏿‍❤️‍💋‍👩🏽","kiss_woman_woman_light_skin_tone":"👩🏻‍❤️‍💋‍👩🏻","kiss_woman_woman_light_skin_tone_dark_skin_tone":"👩🏻‍❤️‍💋‍👩🏿","kiss_woman_woman_light_skin_tone_medium-dark_skin_tone":"👩🏻‍❤️‍💋‍👩🏾","kiss_woman_woman_light_skin_tone_medium-light_skin_tone":"👩🏻‍❤️‍💋‍👩🏼","kiss_woman_woman_light_skin_tone_medium_skin_tone":"👩🏻‍❤️‍💋‍👩🏽","kiss_woman_woman_medium-dark_skin_tone":"👩🏾‍❤️‍💋‍👩🏾","kiss_woman_woman_medium-dark_skin_tone_dark_skin_tone":"👩🏾‍❤️‍💋‍👩🏿","kiss_woman_woman_medium-dark_skin_tone_light_skin_tone":"👩🏾‍❤️‍💋‍👩🏻","kiss_woman_woman_medium-dark_skin_tone_medium-light_skin_tone":"👩🏾‍❤️‍💋‍👩🏼","kiss_woman_woman_medium-dark_skin_tone_medium_skin_tone":"👩🏾‍❤️‍💋‍👩🏽","kiss_woman_woman_medium-light_skin_tone":"👩🏼‍❤️‍💋‍👩🏼","kiss_woman_woman_medium-light_skin_tone_dark_skin_tone":"👩🏼‍❤️‍💋‍👩🏿","kiss_woman_woman_medium-light_skin_tone_light_skin_tone":"👩🏼‍❤️‍💋‍👩🏻","kiss_woman_woman_medium-light_skin_tone_medium-dark_skin_tone":"👩🏼‍❤️‍💋‍👩🏾","kiss_woman_woman_medium-light_skin_tone_medium_skin_tone":"👩🏼‍❤️‍💋‍👩🏽","kiss_woman_woman_medium_skin_tone":"👩🏽‍❤️‍💋‍👩🏽","kiss_woman_woman_medium_skin_tone_dark_skin_tone":"👩🏽‍❤️‍💋‍👩🏿","kiss_woman_woman_medium_skin_tone_light_skin_tone":"👩🏽‍❤️‍💋‍👩🏻","kiss_woman_woman_medium_skin_tone_medium-dark_skin_tone":"👩🏽‍❤️‍💋‍👩🏾","kiss_woman_woman_medium_skin_tone_medium-light_skin_tone":"👩🏽‍❤️‍💋‍👩🏼","kissing":"😗","kissing_cat":"😽","kissing_closed_eyes":"😚","kissing_face":"😗","kissing_face_with_closed_eyes":"😚","kissing_face_with_smiling_eyes":"😙","kissing_heart":"😘","kissing_smiling_eyes":"😙","kitchen_knife":"🔪","kite":"🪁","kiwi_fruit":"🥝","kneeling_man":"🧎‍♂️","kneeling_person":"🧎","kneeling_woman":"🧎‍♀️","knife":"🔪","knocked_out_face":"😵","knot":"🪢","koala":"🐨","koko":"🈁","kosovo":"🇽🇰","kr":"🇰🇷","kuwait":"🇰🇼","kyrgyzstan":"🇰🇬","lab_coat":"🥼","label":"🏷️","lacrosse":"🥍","ladder":"🪜","lady_beetle":"🐞","landslide":"🛘","lantern":"🏮","laos":"🇱🇦","laptop":"💻","large_blue_circle":"🔵","large_blue_diamond":"🔷","large_orange_diamond":"🔶","last_quarter_moon":"🌗","last_quarter_moon_face":"🌜","last_quarter_moon_with_face":"🌜","last_track_button":"⏮️","latin_cross":"✝️","latvia":"🇱🇻","laughing":"😆","leaf_fluttering_in_wind":"🍃","leafless_tree":"🪾","leafy_green":"🥬","leaves":"🍃","lebanon":"🇱🇧","ledger":"📒","left-facing_fist":"🤛","left-facing_fist_dark_skin_tone":"🤛🏿","left-facing_fist_light_skin_tone":"🤛🏻","left-facing_fist_medium-dark_skin_tone":"🤛🏾","left-facing_fist_medium-light_skin_tone":"🤛🏼","left-facing_fist_medium_skin_tone":"🤛🏽","left-right_arrow":"↔️","left_arrow":"⬅️","left_arrow_curving_right":"↪️","left_facing_fist":"🤛","left_luggage":"🛅","left_right_arrow":"↔️","left_speech_bubble":"🗨️","leftwards_arrow_with_hook":"↩️","leftwards_hand":"🫲","leftwards_hand_dark_skin_tone":"🫲🏿","leftwards_hand_light_skin_tone":"🫲🏻","leftwards_hand_medium-dark_skin_tone":"🫲🏾","leftwards_hand_medium-light_skin_tone":"🫲🏼","leftwards_hand_medium_skin_tone":"🫲🏽","leftwards_pushing_hand":"🫷","leftwards_pushing_hand_dark_skin_tone":"🫷🏿","leftwards_pushing_hand_light_skin_tone":"🫷🏻","leftwards_pushing_hand_medium-dark_skin_tone":"🫷🏾","leftwards_pushing_hand_medium-light_skin_tone":"🫷🏼","leftwards_pushing_hand_medium_skin_tone":"🫷🏽","leftwards_thumb_sign":"🫹","leftwards_thumb_sign_dark_skin_tone":"🫹🏿","leftwards_thumb_sign_light_skin_tone":"🫹🏻","leftwards_thumb_sign_medium-dark_skin_tone":"🫹🏾","leftwards_thumb_sign_medium-light_skin_tone":"🫹🏼","leftwards_thumb_sign_medium_skin_tone":"🫹🏽","leg":"🦵","leg_dark_skin_tone":"🦵🏿","leg_light_skin_tone":"🦵🏻","leg_medium-dark_skin_tone":"🦵🏾","leg_medium-light_skin_tone":"🦵🏼","leg_medium_skin_tone":"🦵🏽","lemon":"🍋","leo":"♌","leopard":"🐆","lesotho":"🇱🇸","level_slider":"🎚️","liberia":"🇱🇷","libra":"♎","libya":"🇱🇾","liechtenstein":"🇱🇮","light_blue_heart":"🩵","light_bulb":"💡","light_rail":"🚈","lighthouse":"🛙","lime":"🍋‍🟩","link":"🔗","linked_paperclips":"🖇️","lion":"🦁","lion_face":"🦁","lips":"👄","lipstick":"💄","lithuania":"🇱🇹","litter_in_bin_sign":"🚮","lizard":"🦎","llama":"🦙","lobster":"🦞","lock":"🔒","lock_with_ink_pen":"🔏","locked":"🔒","locked_with_key":"🔐","locked_with_pen":"🔏","locomotive":"🚂","lollipop":"🍭","long_drum":"🪘","loop":"➿","lotion_bottle":"🧴","lotus":"🪷","lotus_position":"🧘","lotus_position_man":"🧘‍♂️","lotus_position_woman":"🧘‍♀️","loud_sound":"🔊","loudly_crying_face":"😭","loudspeaker":"📢","love-you_gesture":"🤟","love-you_gesture_dark_skin_tone":"🤟🏿","love-you_gesture_light_skin_tone":"🤟🏻","love-you_gesture_medium-dark_skin_tone":"🤟🏾","love-you_gesture_medium-light_skin_tone":"🤟🏼","love-you_gesture_medium_skin_tone":"🤟🏽","love_hotel":"🏩","love_letter":"💌","love_you_gesture":"🤟","low_battery":"🪫","low_brightness":"🔅","lower_left_ballpoint_pen":"🖊️","lower_left_crayon":"🖍️","lower_left_fountain_pen":"🖋️","lower_left_paintbrush":"🖌️","luggage":"🧳","lungs":"🫁","luxembourg":"🇱🇺","lying_face":"🤥","m":"Ⓜ️","macau":"🇲🇴","macedonia":"🇲🇰","madagascar":"🇲🇬","mag":"🔍","mag_right":"🔎","mage":"🧙","mage_dark_skin_tone":"🧙🏿","mage_light_skin_tone":"🧙🏻","mage_man":"🧙‍♂️","mage_medium-dark_skin_tone":"🧙🏾","mage_medium-light_skin_tone":"🧙🏼","mage_medium_skin_tone":"🧙🏽","mage_woman":"🧙‍♀️","magic_wand":"🪄","magnet":"🧲","magnifying_glass_tilted_left":"🔍","magnifying_glass_tilted_right":"🔎","mahjong":"🀄","mahjong_red_dragon":"🀄","mailbox":"📫","mailbox_closed":"📪","mailbox_with_mail":"📬","mailbox_with_no_mail":"📭","malawi":"🇲🇼","malaysia":"🇲🇾","maldives":"🇲🇻","male_detective":"🕵️‍♂️","male_sign":"♂️","mali":"🇲🇱","malta":"🇲🇹","mammoth":"🦣","man":"👨","man_artist":"👨‍🎨","man_artist_dark_skin_tone":"👨🏿‍🎨","man_artist_light_skin_tone":"👨🏻‍🎨","man_artist_medium-dark_skin_tone":"👨🏾‍🎨","man_artist_medium-light_skin_tone":"👨🏼‍🎨","man_artist_medium_skin_tone":"👨🏽‍🎨","man_astronaut":"👨‍🚀","man_astronaut_dark_skin_tone":"👨🏿‍🚀","man_astronaut_light_skin_tone":"👨🏻‍🚀","man_astronaut_medium-dark_skin_tone":"👨🏾‍🚀","man_astronaut_medium-light_skin_tone":"👨🏼‍🚀","man_astronaut_medium_skin_tone":"👨🏽‍🚀","man_bald":"👨‍🦲","man_beard":"🧔‍♂️","man_biking":"🚴‍♂️","man_biking_dark_skin_tone":"🚴🏿‍♂️","man_biking_light_skin_tone":"🚴🏻‍♂️","man_biking_medium-dark_skin_tone":"🚴🏾‍♂️","man_biking_medium-light_skin_tone":"🚴🏼‍♂️","man_biking_medium_skin_tone":"🚴🏽‍♂️","man_blond_hair":"👱‍♂️","man_bouncing_ball":"⛹️‍♂️","man_bouncing_ball_dark_skin_tone":"⛹🏿‍♂️","man_bouncing_ball_light_skin_tone":"⛹🏻‍♂️","man_bouncing_ball_medium-dark_skin_tone":"⛹🏾‍♂️","man_bouncing_ball_medium-light_skin_tone":"⛹🏼‍♂️","man_bouncing_ball_medium_skin_tone":"⛹🏽‍♂️","man_bowing":"🙇‍♂️","man_bowing_dark_skin_tone":"🙇🏿‍♂️","man_bowing_light_skin_tone":"🙇🏻‍♂️","man_bowing_medium-dark_skin_tone":"🙇🏾‍♂️","man_bowing_medium-light_skin_tone":"🙇🏼‍♂️","man_bowing_medium_skin_tone":"🙇🏽‍♂️","man_cartwheeling":"🤸‍♂️","man_cartwheeling_dark_skin_tone":"🤸🏿‍♂️","man_cartwheeling_light_skin_tone":"🤸🏻‍♂️","man_cartwheeling_medium-dark_skin_tone":"🤸🏾‍♂️","man_cartwheeling_medium-light_skin_tone":"🤸🏼‍♂️","man_cartwheeling_medium_skin_tone":"🤸🏽‍♂️","man_climbing":"🧗‍♂️","man_climbing_dark_skin_tone":"🧗🏿‍♂️","man_climbing_light_skin_tone":"🧗🏻‍♂️","man_climbing_medium-dark_skin_tone":"🧗🏾‍♂️","man_climbing_medium-light_skin_tone":"🧗🏼‍♂️","man_climbing_medium_skin_tone":"🧗🏽‍♂️","man_construction_worker":"👷‍♂️","man_construction_worker_dark_skin_tone":"👷🏿‍♂️","man_construction_worker_light_skin_tone":"👷🏻‍♂️","man_construction_worker_medium-dark_skin_tone":"👷🏾‍♂️","man_construction_worker_medium-light_skin_tone":"👷🏼‍♂️","man_construction_worker_medium_skin_tone":"👷🏽‍♂️","man_cook":"👨‍🍳","man_cook_dark_skin_tone":"👨🏿‍🍳","man_cook_light_skin_tone":"👨🏻‍🍳","man_cook_medium-dark_skin_tone":"👨🏾‍🍳","man_cook_medium-light_skin_tone":"👨🏼‍🍳","man_cook_medium_skin_tone":"👨🏽‍🍳","man_curly_hair":"👨‍🦱","man_dancing":"🕺","man_dancing_dark_skin_tone":"🕺🏿","man_dancing_light_skin_tone":"🕺🏻","man_dancing_medium-dark_skin_tone":"🕺🏾","man_dancing_medium-light_skin_tone":"🕺🏼","man_dancing_medium_skin_tone":"🕺🏽","man_dark_skin_tone":"👨🏿","man_dark_skin_tone_bald":"👨🏿‍🦲","man_dark_skin_tone_beard":"🧔🏿‍♂️","man_dark_skin_tone_blond_hair":"👱🏿‍♂️","man_dark_skin_tone_curly_hair":"👨🏿‍🦱","man_dark_skin_tone_red_hair":"👨🏿‍🦰","man_dark_skin_tone_white_hair":"👨🏿‍🦳","man_detective":"🕵️‍♂️","man_detective_dark_skin_tone":"🕵🏿‍♂️","man_detective_light_skin_tone":"🕵🏻‍♂️","man_detective_medium-dark_skin_tone":"🕵🏾‍♂️","man_detective_medium-light_skin_tone":"🕵🏼‍♂️","man_detective_medium_skin_tone":"🕵🏽‍♂️","man_elf":"🧝‍♂️","man_elf_dark_skin_tone":"🧝🏿‍♂️","man_elf_light_skin_tone":"🧝🏻‍♂️","man_elf_medium-dark_skin_tone":"🧝🏾‍♂️","man_elf_medium-light_skin_tone":"🧝🏼‍♂️","man_elf_medium_skin_tone":"🧝🏽‍♂️","man_facepalming":"🤦‍♂️","man_facepalming_dark_skin_tone":"🤦🏿‍♂️","man_facepalming_light_skin_tone":"🤦🏻‍♂️","man_facepalming_medium-dark_skin_tone":"🤦🏾‍♂️","man_facepalming_medium-light_skin_tone":"🤦🏼‍♂️","man_facepalming_medium_skin_tone":"🤦🏽‍♂️","man_factory_worker":"👨‍🏭","man_factory_worker_dark_skin_tone":"👨🏿‍🏭","man_factory_worker_light_skin_tone":"👨🏻‍🏭","man_factory_worker_medium-dark_skin_tone":"👨🏾‍🏭","man_factory_worker_medium-light_skin_tone":"👨🏼‍🏭","man_factory_worker_medium_skin_tone":"👨🏽‍🏭","man_fairy":"🧚‍♂️","man_fairy_dark_skin_tone":"🧚🏿‍♂️","man_fairy_light_skin_tone":"🧚🏻‍♂️","man_fairy_medium-dark_skin_tone":"🧚🏾‍♂️","man_fairy_medium-light_skin_tone":"🧚🏼‍♂️","man_fairy_medium_skin_tone":"🧚🏽‍♂️","man_farmer":"👨‍🌾","man_farmer_dark_skin_tone":"👨🏿‍🌾","man_farmer_light_skin_tone":"👨🏻‍🌾","man_farmer_medium-dark_skin_tone":"👨🏾‍🌾","man_farmer_medium-light_skin_tone":"👨🏼‍🌾","man_farmer_medium_skin_tone":"👨🏽‍🌾","man_feeding_baby":"👨‍🍼","man_feeding_baby_dark_skin_tone":"👨🏿‍🍼","man_feeding_baby_light_skin_tone":"👨🏻‍🍼","man_feeding_baby_medium-dark_skin_tone":"👨🏾‍🍼","man_feeding_baby_medium-light_skin_tone":"👨🏼‍🍼","man_feeding_baby_medium_skin_tone":"👨🏽‍🍼","man_firefighter":"👨‍🚒","man_firefighter_dark_skin_tone":"👨🏿‍🚒","man_firefighter_light_skin_tone":"👨🏻‍🚒","man_firefighter_medium-dark_skin_tone":"👨🏾‍🚒","man_firefighter_medium-light_skin_tone":"👨🏼‍🚒","man_firefighter_medium_skin_tone":"👨🏽‍🚒","man_frowning":"🙍‍♂️","man_frowning_dark_skin_tone":"🙍🏿‍♂️","man_frowning_light_skin_tone":"🙍🏻‍♂️","man_frowning_medium-dark_skin_tone":"🙍🏾‍♂️","man_frowning_medium-light_skin_tone":"🙍🏼‍♂️","man_frowning_medium_skin_tone":"🙍🏽‍♂️","man_genie":"🧞‍♂️","man_gesturing_NO":"🙅‍♂️","man_gesturing_NO_dark_skin_tone":"🙅🏿‍♂️","man_gesturing_NO_light_skin_tone":"🙅🏻‍♂️","man_gesturing_NO_medium-dark_skin_tone":"🙅🏾‍♂️","man_gesturing_NO_medium-light_skin_tone":"🙅🏼‍♂️","man_gesturing_NO_medium_skin_tone":"🙅🏽‍♂️","man_gesturing_OK":"🙆‍♂️","man_gesturing_OK_dark_skin_tone":"🙆🏿‍♂️","man_gesturing_OK_light_skin_tone":"🙆🏻‍♂️","man_gesturing_OK_medium-dark_skin_tone":"🙆🏾‍♂️","man_gesturing_OK_medium-light_skin_tone":"🙆🏼‍♂️","man_gesturing_OK_medium_skin_tone":"🙆🏽‍♂️","man_gesturing_no":"🙅‍♂️","man_gesturing_ok":"🙆‍♂️","man_getting_haircut":"💇‍♂️","man_getting_haircut_dark_skin_tone":"💇🏿‍♂️","man_getting_haircut_light_skin_tone":"💇🏻‍♂️","man_getting_haircut_medium-dark_skin_tone":"💇🏾‍♂️","man_getting_haircut_medium-light_skin_tone":"💇🏼‍♂️","man_getting_haircut_medium_skin_tone":"💇🏽‍♂️","man_getting_massage":"💆‍♂️","man_getting_massage_dark_skin_tone":"💆🏿‍♂️","man_getting_massage_light_skin_tone":"💆🏻‍♂️","man_getting_massage_medium-dark_skin_tone":"💆🏾‍♂️","man_getting_massage_medium-light_skin_tone":"💆🏼‍♂️","man_getting_massage_medium_skin_tone":"💆🏽‍♂️","man_golfing":"🏌️‍♂️","man_golfing_dark_skin_tone":"🏌🏿‍♂️","man_golfing_light_skin_tone":"🏌🏻‍♂️","man_golfing_medium-dark_skin_tone":"🏌🏾‍♂️","man_golfing_medium-light_skin_tone":"🏌🏼‍♂️","man_golfing_medium_skin_tone":"🏌🏽‍♂️","man_guard":"💂‍♂️","man_guard_dark_skin_tone":"💂🏿‍♂️","man_guard_light_skin_tone":"💂🏻‍♂️","man_guard_medium-dark_skin_tone":"💂🏾‍♂️","man_guard_medium-light_skin_tone":"💂🏼‍♂️","man_guard_medium_skin_tone":"💂🏽‍♂️","man_health_worker":"👨‍⚕️","man_health_worker_dark_skin_tone":"👨🏿‍⚕️","man_health_worker_light_skin_tone":"👨🏻‍⚕️","man_health_worker_medium-dark_skin_tone":"👨🏾‍⚕️","man_health_worker_medium-light_skin_tone":"👨🏼‍⚕️","man_health_worker_medium_skin_tone":"👨🏽‍⚕️","man_in_business_suit_levitating":"🕴️","man_in_lotus_position":"🧘‍♂️","man_in_lotus_position_dark_skin_tone":"🧘🏿‍♂️","man_in_lotus_position_light_skin_tone":"🧘🏻‍♂️","man_in_lotus_position_medium-dark_skin_tone":"🧘🏾‍♂️","man_in_lotus_position_medium-light_skin_tone":"🧘🏼‍♂️","man_in_lotus_position_medium_skin_tone":"🧘🏽‍♂️","man_in_manual_wheelchair":"👨‍🦽","man_in_manual_wheelchair_dark_skin_tone":"👨🏿‍🦽","man_in_manual_wheelchair_facing_right":"👨‍🦽‍➡️","man_in_manual_wheelchair_facing_right_dark_skin_tone":"👨🏿‍🦽‍➡️","man_in_manual_wheelchair_facing_right_light_skin_tone":"👨🏻‍🦽‍➡️","man_in_manual_wheelchair_facing_right_medium-dark_skin_tone":"👨🏾‍🦽‍➡️","man_in_manual_wheelchair_facing_right_medium-light_skin_tone":"👨🏼‍🦽‍➡️","man_in_manual_wheelchair_facing_right_medium_skin_tone":"👨🏽‍🦽‍➡️","man_in_manual_wheelchair_light_skin_tone":"👨🏻‍🦽","man_in_manual_wheelchair_medium-dark_skin_tone":"👨🏾‍🦽","man_in_manual_wheelchair_medium-light_skin_tone":"👨🏼‍🦽","man_in_manual_wheelchair_medium_skin_tone":"👨🏽‍🦽","man_in_motorized_wheelchair":"👨‍🦼","man_in_motorized_wheelchair_dark_skin_tone":"👨🏿‍🦼","man_in_motorized_wheelchair_facing_right":"👨‍🦼‍➡️","man_in_motorized_wheelchair_facing_right_dark_skin_tone":"👨🏿‍🦼‍➡️","man_in_motorized_wheelchair_facing_right_light_skin_tone":"👨🏻‍🦼‍➡️","man_in_motorized_wheelchair_facing_right_medium-dark_skin_tone":"👨🏾‍🦼‍➡️","man_in_motorized_wheelchair_facing_right_medium-light_skin_tone":"👨🏼‍🦼‍➡️","man_in_motorized_wheelchair_facing_right_medium_skin_tone":"👨🏽‍🦼‍➡️","man_in_motorized_wheelchair_light_skin_tone":"👨🏻‍🦼","man_in_motorized_wheelchair_medium-dark_skin_tone":"👨🏾‍🦼","man_in_motorized_wheelchair_medium-light_skin_tone":"👨🏼‍🦼","man_in_motorized_wheelchair_medium_skin_tone":"👨🏽‍🦼","man_in_steamy_room":"🧖‍♂️","man_in_steamy_room_dark_skin_tone":"🧖🏿‍♂️","man_in_steamy_room_light_skin_tone":"🧖🏻‍♂️","man_in_steamy_room_medium-dark_skin_tone":"🧖🏾‍♂️","man_in_steamy_room_medium-light_skin_tone":"🧖🏼‍♂️","man_in_steamy_room_medium_skin_tone":"🧖🏽‍♂️","man_in_tuxedo":"🤵‍♂️","man_in_tuxedo_dark_skin_tone":"🤵🏿‍♂️","man_in_tuxedo_light_skin_tone":"🤵🏻‍♂️","man_in_tuxedo_medium-dark_skin_tone":"🤵🏾‍♂️","man_in_tuxedo_medium-light_skin_tone":"🤵🏼‍♂️","man_in_tuxedo_medium_skin_tone":"🤵🏽‍♂️","man_judge":"👨‍⚖️","man_judge_dark_skin_tone":"👨🏿‍⚖️","man_judge_light_skin_tone":"👨🏻‍⚖️","man_judge_medium-dark_skin_tone":"👨🏾‍⚖️","man_judge_medium-light_skin_tone":"👨🏼‍⚖️","man_judge_medium_skin_tone":"👨🏽‍⚖️","man_juggling":"🤹‍♂️","man_juggling_dark_skin_tone":"🤹🏿‍♂️","man_juggling_light_skin_tone":"🤹🏻‍♂️","man_juggling_medium-dark_skin_tone":"🤹🏾‍♂️","man_juggling_medium-light_skin_tone":"🤹🏼‍♂️","man_juggling_medium_skin_tone":"🤹🏽‍♂️","man_kneeling":"🧎‍♂️","man_kneeling_dark_skin_tone":"🧎🏿‍♂️","man_kneeling_facing_right":"🧎‍♂️‍➡️","man_kneeling_facing_right_dark_skin_tone":"🧎🏿‍♂️‍➡️","man_kneeling_facing_right_light_skin_tone":"🧎🏻‍♂️‍➡️","man_kneeling_facing_right_medium-dark_skin_tone":"🧎🏾‍♂️‍➡️","man_kneeling_facing_right_medium-light_skin_tone":"🧎🏼‍♂️‍➡️","man_kneeling_facing_right_medium_skin_tone":"🧎🏽‍♂️‍➡️","man_kneeling_light_skin_tone":"🧎🏻‍♂️","man_kneeling_medium-dark_skin_tone":"🧎🏾‍♂️","man_kneeling_medium-light_skin_tone":"🧎🏼‍♂️","man_kneeling_medium_skin_tone":"🧎🏽‍♂️","man_lifting_weights":"🏋️‍♂️","man_lifting_weights_dark_skin_tone":"🏋🏿‍♂️","man_lifting_weights_light_skin_tone":"🏋🏻‍♂️","man_lifting_weights_medium-dark_skin_tone":"🏋🏾‍♂️","man_lifting_weights_medium-light_skin_tone":"🏋🏼‍♂️","man_lifting_weights_medium_skin_tone":"🏋🏽‍♂️","man_light_skin_tone":"👨🏻","man_light_skin_tone_bald":"👨🏻‍🦲","man_light_skin_tone_beard":"🧔🏻‍♂️","man_light_skin_tone_blond_hair":"👱🏻‍♂️","man_light_skin_tone_curly_hair":"👨🏻‍🦱","man_light_skin_tone_red_hair":"👨🏻‍🦰","man_light_skin_tone_white_hair":"👨🏻‍🦳","man_mage":"🧙‍♂️","man_mage_dark_skin_tone":"🧙🏿‍♂️","man_mage_light_skin_tone":"🧙🏻‍♂️","man_mage_medium-dark_skin_tone":"🧙🏾‍♂️","man_mage_medium-light_skin_tone":"🧙🏼‍♂️","man_mage_medium_skin_tone":"🧙🏽‍♂️","man_mechanic":"👨‍🔧","man_mechanic_dark_skin_tone":"👨🏿‍🔧","man_mechanic_light_skin_tone":"👨🏻‍🔧","man_mechanic_medium-dark_skin_tone":"👨🏾‍🔧","man_mechanic_medium-light_skin_tone":"👨🏼‍🔧","man_mechanic_medium_skin_tone":"👨🏽‍🔧","man_medium-dark_skin_tone":"👨🏾","man_medium-dark_skin_tone_bald":"👨🏾‍🦲","man_medium-dark_skin_tone_beard":"🧔🏾‍♂️","man_medium-dark_skin_tone_blond_hair":"👱🏾‍♂️","man_medium-dark_skin_tone_curly_hair":"👨🏾‍🦱","man_medium-dark_skin_tone_red_hair":"👨🏾‍🦰","man_medium-dark_skin_tone_white_hair":"👨🏾‍🦳","man_medium-light_skin_tone":"👨🏼","man_medium-light_skin_tone_bald":"👨🏼‍🦲","man_medium-light_skin_tone_beard":"🧔🏼‍♂️","man_medium-light_skin_tone_blond_hair":"👱🏼‍♂️","man_medium-light_skin_tone_curly_hair":"👨🏼‍🦱","man_medium-light_skin_tone_red_hair":"👨🏼‍🦰","man_medium-light_skin_tone_white_hair":"👨🏼‍🦳","man_medium_skin_tone":"👨🏽","man_medium_skin_tone_bald":"👨🏽‍🦲","man_medium_skin_tone_beard":"🧔🏽‍♂️","man_medium_skin_tone_blond_hair":"👱🏽‍♂️","man_medium_skin_tone_curly_hair":"👨🏽‍🦱","man_medium_skin_tone_red_hair":"👨🏽‍🦰","man_medium_skin_tone_white_hair":"👨🏽‍🦳","man_mountain_biking":"🚵‍♂️","man_mountain_biking_dark_skin_tone":"🚵🏿‍♂️","man_mountain_biking_light_skin_tone":"🚵🏻‍♂️","man_mountain_biking_medium-dark_skin_tone":"🚵🏾‍♂️","man_mountain_biking_medium-light_skin_tone":"🚵🏼‍♂️","man_mountain_biking_medium_skin_tone":"🚵🏽‍♂️","man_office_worker":"👨‍💼","man_office_worker_dark_skin_tone":"👨🏿‍💼","man_office_worker_light_skin_tone":"👨🏻‍💼","man_office_worker_medium-dark_skin_tone":"👨🏾‍💼","man_office_worker_medium-light_skin_tone":"👨🏼‍💼","man_office_worker_medium_skin_tone":"👨🏽‍💼","man_pilot":"👨‍✈️","man_pilot_dark_skin_tone":"👨🏿‍✈️","man_pilot_light_skin_tone":"👨🏻‍✈️","man_pilot_medium-dark_skin_tone":"👨🏾‍✈️","man_pilot_medium-light_skin_tone":"👨🏼‍✈️","man_pilot_medium_skin_tone":"👨🏽‍✈️","man_playing_handball":"🤾‍♂️","man_playing_handball_dark_skin_tone":"🤾🏿‍♂️","man_playing_handball_light_skin_tone":"🤾🏻‍♂️","man_playing_handball_medium-dark_skin_tone":"🤾🏾‍♂️","man_playing_handball_medium-light_skin_tone":"🤾🏼‍♂️","man_playing_handball_medium_skin_tone":"🤾🏽‍♂️","man_playing_water_polo":"🤽‍♂️","man_playing_water_polo_dark_skin_tone":"🤽🏿‍♂️","man_playing_water_polo_light_skin_tone":"🤽🏻‍♂️","man_playing_water_polo_medium-dark_skin_tone":"🤽🏾‍♂️","man_playing_water_polo_medium-light_skin_tone":"🤽🏼‍♂️","man_playing_water_polo_medium_skin_tone":"🤽🏽‍♂️","man_police_officer":"👮‍♂️","man_police_officer_dark_skin_tone":"👮🏿‍♂️","man_police_officer_light_skin_tone":"👮🏻‍♂️","man_police_officer_medium-dark_skin_tone":"👮🏾‍♂️","man_police_officer_medium-light_skin_tone":"👮🏼‍♂️","man_police_officer_medium_skin_tone":"👮🏽‍♂️","man_pouting":"🙎‍♂️","man_pouting_dark_skin_tone":"🙎🏿‍♂️","man_pouting_light_skin_tone":"🙎🏻‍♂️","man_pouting_medium-dark_skin_tone":"🙎🏾‍♂️","man_pouting_medium-light_skin_tone":"🙎🏼‍♂️","man_pouting_medium_skin_tone":"🙎🏽‍♂️","man_raising_hand":"🙋‍♂️","man_raising_hand_dark_skin_tone":"🙋🏿‍♂️","man_raising_hand_light_skin_tone":"🙋🏻‍♂️","man_raising_hand_medium-dark_skin_tone":"🙋🏾‍♂️","man_raising_hand_medium-light_skin_tone":"🙋🏼‍♂️","man_raising_hand_medium_skin_tone":"🙋🏽‍♂️","man_red_hair":"👨‍🦰","man_rowing_boat":"🚣‍♂️","man_rowing_boat_dark_skin_tone":"🚣🏿‍♂️","man_rowing_boat_light_skin_tone":"🚣🏻‍♂️","man_rowing_boat_medium-dark_skin_tone":"🚣🏾‍♂️","man_rowing_boat_medium-light_skin_tone":"🚣🏼‍♂️","man_rowing_boat_medium_skin_tone":"🚣🏽‍♂️","man_running":"🏃‍♂️","man_running_dark_skin_tone":"🏃🏿‍♂️","man_running_facing_right":"🏃‍♂️‍➡️","man_running_facing_right_dark_skin_tone":"🏃🏿‍♂️‍➡️","man_running_facing_right_light_skin_tone":"🏃🏻‍♂️‍➡️","man_running_facing_right_medium-dark_skin_tone":"🏃🏾‍♂️‍➡️","man_running_facing_right_medium-light_skin_tone":"🏃🏼‍♂️‍➡️","man_running_facing_right_medium_skin_tone":"🏃🏽‍♂️‍➡️","man_running_light_skin_tone":"🏃🏻‍♂️","man_running_medium-dark_skin_tone":"🏃🏾‍♂️","man_running_medium-light_skin_tone":"🏃🏼‍♂️","man_running_medium_skin_tone":"🏃🏽‍♂️","man_scientist":"👨‍🔬","man_scientist_dark_skin_tone":"👨🏿‍🔬","man_scientist_light_skin_tone":"👨🏻‍🔬","man_scientist_medium-dark_skin_tone":"👨🏾‍🔬","man_scientist_medium-light_skin_tone":"👨🏼‍🔬","man_scientist_medium_skin_tone":"👨🏽‍🔬","man_shrugging":"🤷‍♂️","man_shrugging_dark_skin_tone":"🤷🏿‍♂️","man_shrugging_light_skin_tone":"🤷🏻‍♂️","man_shrugging_medium-dark_skin_tone":"🤷🏾‍♂️","man_shrugging_medium-light_skin_tone":"🤷🏼‍♂️","man_shrugging_medium_skin_tone":"🤷🏽‍♂️","man_singer":"👨‍🎤","man_singer_dark_skin_tone":"👨🏿‍🎤","man_singer_light_skin_tone":"👨🏻‍🎤","man_singer_medium-dark_skin_tone":"👨🏾‍🎤","man_singer_medium-light_skin_tone":"👨🏼‍🎤","man_singer_medium_skin_tone":"👨🏽‍🎤","man_standing":"🧍‍♂️","man_standing_dark_skin_tone":"🧍🏿‍♂️","man_standing_light_skin_tone":"🧍🏻‍♂️","man_standing_medium-dark_skin_tone":"🧍🏾‍♂️","man_standing_medium-light_skin_tone":"🧍🏼‍♂️","man_standing_medium_skin_tone":"🧍🏽‍♂️","man_student":"👨‍🎓","man_student_dark_skin_tone":"👨🏿‍🎓","man_student_light_skin_tone":"👨🏻‍🎓","man_student_medium-dark_skin_tone":"👨🏾‍🎓","man_student_medium-light_skin_tone":"👨🏼‍🎓","man_student_medium_skin_tone":"👨🏽‍🎓","man_superhero":"🦸‍♂️","man_superhero_dark_skin_tone":"🦸🏿‍♂️","man_superhero_light_skin_tone":"🦸🏻‍♂️","man_superhero_medium-dark_skin_tone":"🦸🏾‍♂️","man_superhero_medium-light_skin_tone":"🦸🏼‍♂️","man_superhero_medium_skin_tone":"🦸🏽‍♂️","man_supervillain":"🦹‍♂️","man_supervillain_dark_skin_tone":"🦹🏿‍♂️","man_supervillain_light_skin_tone":"🦹🏻‍♂️","man_supervillain_medium-dark_skin_tone":"🦹🏾‍♂️","man_supervillain_medium-light_skin_tone":"🦹🏼‍♂️","man_supervillain_medium_skin_tone":"🦹🏽‍♂️","man_surfing":"🏄‍♂️","man_surfing_dark_skin_tone":"🏄🏿‍♂️","man_surfing_light_skin_tone":"🏄🏻‍♂️","man_surfing_medium-dark_skin_tone":"🏄🏾‍♂️","man_surfing_medium-light_skin_tone":"🏄🏼‍♂️","man_surfing_medium_skin_tone":"🏄🏽‍♂️","man_swimming":"🏊‍♂️","man_swimming_dark_skin_tone":"🏊🏿‍♂️","man_swimming_light_skin_tone":"🏊🏻‍♂️","man_swimming_medium-dark_skin_tone":"🏊🏾‍♂️","man_swimming_medium-light_skin_tone":"🏊🏼‍♂️","man_swimming_medium_skin_tone":"🏊🏽‍♂️","man_teacher":"👨‍🏫","man_teacher_dark_skin_tone":"👨🏿‍🏫","man_teacher_light_skin_tone":"👨🏻‍🏫","man_teacher_medium-dark_skin_tone":"👨🏾‍🏫","man_teacher_medium-light_skin_tone":"👨🏼‍🏫","man_teacher_medium_skin_tone":"👨🏽‍🏫","man_technologist":"👨‍💻","man_technologist_dark_skin_tone":"👨🏿‍💻","man_technologist_light_skin_tone":"👨🏻‍💻","man_technologist_medium-dark_skin_tone":"👨🏾‍💻","man_technologist_medium-light_skin_tone":"👨🏼‍💻","man_technologist_medium_skin_tone":"👨🏽‍💻","man_tipping_hand":"💁‍♂️","man_tipping_hand_dark_skin_tone":"💁🏿‍♂️","man_tipping_hand_light_skin_tone":"💁🏻‍♂️","man_tipping_hand_medium-dark_skin_tone":"💁🏾‍♂️","man_tipping_hand_medium-light_skin_tone":"💁🏼‍♂️","man_tipping_hand_medium_skin_tone":"💁🏽‍♂️","man_vampire":"🧛‍♂️","man_vampire_dark_skin_tone":"🧛🏿‍♂️","man_vampire_light_skin_tone":"🧛🏻‍♂️","man_vampire_medium-dark_skin_tone":"🧛🏾‍♂️","man_vampire_medium-light_skin_tone":"🧛🏼‍♂️","man_vampire_medium_skin_tone":"🧛🏽‍♂️","man_walking":"🚶‍♂️","man_walking_dark_skin_tone":"🚶🏿‍♂️","man_walking_facing_right":"🚶‍♂️‍➡️","man_walking_facing_right_dark_skin_tone":"🚶🏿‍♂️‍➡️","man_walking_facing_right_light_skin_tone":"🚶🏻‍♂️‍➡️","man_walking_facing_right_medium-dark_skin_tone":"🚶🏾‍♂️‍➡️","man_walking_facing_right_medium-light_skin_tone":"🚶🏼‍♂️‍➡️","man_walking_facing_right_medium_skin_tone":"🚶🏽‍♂️‍➡️","man_walking_light_skin_tone":"🚶🏻‍♂️","man_walking_medium-dark_skin_tone":"🚶🏾‍♂️","man_walking_medium-light_skin_tone":"🚶🏼‍♂️","man_walking_medium_skin_tone":"🚶🏽‍♂️","man_wearing_turban":"👳‍♂️","man_wearing_turban_dark_skin_tone":"👳🏿‍♂️","man_wearing_turban_light_skin_tone":"👳🏻‍♂️","man_wearing_turban_medium-dark_skin_tone":"👳🏾‍♂️","man_wearing_turban_medium-light_skin_tone":"👳🏼‍♂️","man_wearing_turban_medium_skin_tone":"👳🏽‍♂️","man_white_hair":"👨‍🦳","man_with_gua_pi_mao":"👲","man_with_probing_cane":"👨‍🦯","man_with_turban":"👳‍♂️","man_with_veil":"👰‍♂️","man_with_veil_dark_skin_tone":"👰🏿‍♂️","man_with_veil_light_skin_tone":"👰🏻‍♂️","man_with_veil_medium-dark_skin_tone":"👰🏾‍♂️","man_with_veil_medium-light_skin_tone":"👰🏼‍♂️","man_with_veil_medium_skin_tone":"👰🏽‍♂️","man_with_white_cane":"👨‍🦯","man_with_white_cane_dark_skin_tone":"👨🏿‍🦯","man_with_white_cane_facing_right":"👨‍🦯‍➡️","man_with_white_cane_facing_right_dark_skin_tone":"👨🏿‍🦯‍➡️","man_with_white_cane_facing_right_light_skin_tone":"👨🏻‍🦯‍➡️","man_with_white_cane_facing_right_medium-dark_skin_tone":"👨🏾‍🦯‍➡️","man_with_white_cane_facing_right_medium-light_skin_tone":"👨🏼‍🦯‍➡️","man_with_white_cane_facing_right_medium_skin_tone":"👨🏽‍🦯‍➡️","man_with_white_cane_light_skin_tone":"👨🏻‍🦯","man_with_white_cane_medium-dark_skin_tone":"👨🏾‍🦯","man_with_white_cane_medium-light_skin_tone":"👨🏼‍🦯","man_with_white_cane_medium_skin_tone":"👨🏽‍🦯","man_zombie":"🧟‍♂️","mandarin":"🍊","mango":"🥭","mans_shoe":"👞","mantelpiece_clock":"🕰️","manual_wheelchair":"🦽","man’s_shoe":"👞","map_of_Japan":"🗾","map_of_japan":"🗾","maple_leaf":"🍁","maracas":"🪇","marshall_islands":"🇲🇭","martial_arts_uniform":"🥋","martinique":"🇲🇶","mask":"😷","massage":"💆","massage_man":"💆‍♂️","massage_woman":"💆‍♀️","mate":"🧉","mauritania":"🇲🇷","mauritius":"🇲🇺","mayotte":"🇾🇹","meat_on_bone":"🍖","mechanic":"🧑‍🔧","mechanic_dark_skin_tone":"🧑🏿‍🔧","mechanic_light_skin_tone":"🧑🏻‍🔧","mechanic_medium-dark_skin_tone":"🧑🏾‍🔧","mechanic_medium-light_skin_tone":"🧑🏼‍🔧","mechanic_medium_skin_tone":"🧑🏽‍🔧","mechanical_arm":"🦾","mechanical_leg":"🦿","medal_military":"🎖️","medal_sports":"🏅","medical_symbol":"⚕️","mega":"📣","megaphone":"📣","melon":"🍈","melting_face":"🫠","memo":"📝","men_holding_hands":"👬","men_holding_hands_dark_skin_tone":"👬🏿","men_holding_hands_dark_skin_tone_light_skin_tone":"👨🏿‍🤝‍👨🏻","men_holding_hands_dark_skin_tone_medium-dark_skin_tone":"👨🏿‍🤝‍👨🏾","men_holding_hands_dark_skin_tone_medium-light_skin_tone":"👨🏿‍🤝‍👨🏼","men_holding_hands_dark_skin_tone_medium_skin_tone":"👨🏿‍🤝‍👨🏽","men_holding_hands_light_skin_tone":"👬🏻","men_holding_hands_light_skin_tone_dark_skin_tone":"👨🏻‍🤝‍👨🏿","men_holding_hands_light_skin_tone_medium-dark_skin_tone":"👨🏻‍🤝‍👨🏾","men_holding_hands_light_skin_tone_medium-light_skin_tone":"👨🏻‍🤝‍👨🏼","men_holding_hands_light_skin_tone_medium_skin_tone":"👨🏻‍🤝‍👨🏽","men_holding_hands_medium-dark_skin_tone":"👬🏾","men_holding_hands_medium-dark_skin_tone_dark_skin_tone":"👨🏾‍🤝‍👨🏿","men_holding_hands_medium-dark_skin_tone_light_skin_tone":"👨🏾‍🤝‍👨🏻","men_holding_hands_medium-dark_skin_tone_medium-light_skin_tone":"👨🏾‍🤝‍👨🏼","men_holding_hands_medium-dark_skin_tone_medium_skin_tone":"👨🏾‍🤝‍👨🏽","men_holding_hands_medium-light_skin_tone":"👬🏼","men_holding_hands_medium-light_skin_tone_dark_skin_tone":"👨🏼‍🤝‍👨🏿","men_holding_hands_medium-light_skin_tone_light_skin_tone":"👨🏼‍🤝‍👨🏻","men_holding_hands_medium-light_skin_tone_medium-dark_skin_tone":"👨🏼‍🤝‍👨🏾","men_holding_hands_medium-light_skin_tone_medium_skin_tone":"👨🏼‍🤝‍👨🏽","men_holding_hands_medium_skin_tone":"👬🏽","men_holding_hands_medium_skin_tone_dark_skin_tone":"👨🏽‍🤝‍👨🏿","men_holding_hands_medium_skin_tone_light_skin_tone":"👨🏽‍🤝‍👨🏻","men_holding_hands_medium_skin_tone_medium-dark_skin_tone":"👨🏽‍🤝‍👨🏾","men_holding_hands_medium_skin_tone_medium-light_skin_tone":"👨🏽‍🤝‍👨🏼","men_with_bunny_ears":"👯‍♂️","men_with_bunny_ears_dark_skin_tone":"👯🏿‍♂️","men_with_bunny_ears_dark_skin_tone_light_skin_tone":"👨🏿‍🐰‍👨🏻","men_with_bunny_ears_dark_skin_tone_medium-dark_skin_tone":"👨🏿‍🐰‍👨🏾","men_with_bunny_ears_dark_skin_tone_medium-light_skin_tone":"👨🏿‍🐰‍👨🏼","men_with_bunny_ears_dark_skin_tone_medium_skin_tone":"👨🏿‍🐰‍👨🏽","men_with_bunny_ears_light_skin_tone":"👯🏻‍♂️","men_with_bunny_ears_light_skin_tone_dark_skin_tone":"👨🏻‍🐰‍👨🏿","men_with_bunny_ears_light_skin_tone_medium-dark_skin_tone":"👨🏻‍🐰‍👨🏾","men_with_bunny_ears_light_skin_tone_medium-light_skin_tone":"👨🏻‍🐰‍👨🏼","men_with_bunny_ears_light_skin_tone_medium_skin_tone":"👨🏻‍🐰‍👨🏽","men_with_bunny_ears_medium-dark_skin_tone":"👯🏾‍♂️","men_with_bunny_ears_medium-dark_skin_tone_dark_skin_tone":"👨🏾‍🐰‍👨🏿","men_with_bunny_ears_medium-dark_skin_tone_light_skin_tone":"👨🏾‍🐰‍👨🏻","men_with_bunny_ears_medium-dark_skin_tone_medium-light_skin_tone":"👨🏾‍🐰‍👨🏼","men_with_bunny_ears_medium-dark_skin_tone_medium_skin_tone":"👨🏾‍🐰‍👨🏽","men_with_bunny_ears_medium-light_skin_tone":"👯🏼‍♂️","men_with_bunny_ears_medium-light_skin_tone_dark_skin_tone":"👨🏼‍🐰‍👨🏿","men_with_bunny_ears_medium-light_skin_tone_light_skin_tone":"👨🏼‍🐰‍👨🏻","men_with_bunny_ears_medium-light_skin_tone_medium-dark_skin_tone":"👨🏼‍🐰‍👨🏾","men_with_bunny_ears_medium-light_skin_tone_medium_skin_tone":"👨🏼‍🐰‍👨🏽","men_with_bunny_ears_medium_skin_tone":"👯🏽‍♂️","men_with_bunny_ears_medium_skin_tone_dark_skin_tone":"👨🏽‍🐰‍👨🏿","men_with_bunny_ears_medium_skin_tone_light_skin_tone":"👨🏽‍🐰‍👨🏻","men_with_bunny_ears_medium_skin_tone_medium-dark_skin_tone":"👨🏽‍🐰‍👨🏾","men_with_bunny_ears_medium_skin_tone_medium-light_skin_tone":"👨🏽‍🐰‍👨🏼","men_wrestling":"🤼‍♂️","men_wrestling_dark_skin_tone":"🤼🏿‍♂️","men_wrestling_dark_skin_tone_light_skin_tone":"👨🏿‍🫯‍👨🏻","men_wrestling_dark_skin_tone_medium-dark_skin_tone":"👨🏿‍🫯‍👨🏾","men_wrestling_dark_skin_tone_medium-light_skin_tone":"👨🏿‍🫯‍👨🏼","men_wrestling_dark_skin_tone_medium_skin_tone":"👨🏿‍🫯‍👨🏽","men_wrestling_light_skin_tone":"🤼🏻‍♂️","men_wrestling_light_skin_tone_dark_skin_tone":"👨🏻‍🫯‍👨🏿","men_wrestling_light_skin_tone_medium-dark_skin_tone":"👨🏻‍🫯‍👨🏾","men_wrestling_light_skin_tone_medium-light_skin_tone":"👨🏻‍🫯‍👨🏼","men_wrestling_light_skin_tone_medium_skin_tone":"👨🏻‍🫯‍👨🏽","men_wrestling_medium-dark_skin_tone":"🤼🏾‍♂️","men_wrestling_medium-dark_skin_tone_dark_skin_tone":"👨🏾‍🫯‍👨🏿","men_wrestling_medium-dark_skin_tone_light_skin_tone":"👨🏾‍🫯‍👨🏻","men_wrestling_medium-dark_skin_tone_medium-light_skin_tone":"👨🏾‍🫯‍👨🏼","men_wrestling_medium-dark_skin_tone_medium_skin_tone":"👨🏾‍🫯‍👨🏽","men_wrestling_medium-light_skin_tone":"🤼🏼‍♂️","men_wrestling_medium-light_skin_tone_dark_skin_tone":"👨🏼‍🫯‍👨🏿","men_wrestling_medium-light_skin_tone_light_skin_tone":"👨🏼‍🫯‍👨🏻","men_wrestling_medium-light_skin_tone_medium-dark_skin_tone":"👨🏼‍🫯‍👨🏾","men_wrestling_medium-light_skin_tone_medium_skin_tone":"👨🏼‍🫯‍👨🏽","men_wrestling_medium_skin_tone":"🤼🏽‍♂️","men_wrestling_medium_skin_tone_dark_skin_tone":"👨🏽‍🫯‍👨🏿","men_wrestling_medium_skin_tone_light_skin_tone":"👨🏽‍🫯‍👨🏻","men_wrestling_medium_skin_tone_medium-dark_skin_tone":"👨🏽‍🫯‍👨🏾","men_wrestling_medium_skin_tone_medium-light_skin_tone":"👨🏽‍🫯‍👨🏼","mending_heart":"❤️‍🩹","menorah":"🕎","menorah_with_nine_branches":"🕎","mens":"🚹","mens_room":"🚹","men’s_room":"🚹","mermaid":"🧜‍♀️","mermaid_dark_skin_tone":"🧜🏿‍♀️","mermaid_light_skin_tone":"🧜🏻‍♀️","mermaid_medium-dark_skin_tone":"🧜🏾‍♀️","mermaid_medium-light_skin_tone":"🧜🏼‍♀️","mermaid_medium_skin_tone":"🧜🏽‍♀️","merman":"🧜‍♂️","merman_dark_skin_tone":"🧜🏿‍♂️","merman_light_skin_tone":"🧜🏻‍♂️","merman_medium-dark_skin_tone":"🧜🏾‍♂️","merman_medium-light_skin_tone":"🧜🏼‍♂️","merman_medium_skin_tone":"🧜🏽‍♂️","merperson":"🧜","merperson_dark_skin_tone":"🧜🏿","merperson_light_skin_tone":"🧜🏻","merperson_medium-dark_skin_tone":"🧜🏾","merperson_medium-light_skin_tone":"🧜🏼","merperson_medium_skin_tone":"🧜🏽","metal":"🤘","meteor":"🪋","metro":"🚇","mexico":"🇲🇽","microbe":"🦠","micronesia":"🇫🇲","microphone":"🎤","microscope":"🔬","middle_finger":"🖕","middle_finger_dark_skin_tone":"🖕🏿","middle_finger_light_skin_tone":"🖕🏻","middle_finger_medium-dark_skin_tone":"🖕🏾","middle_finger_medium-light_skin_tone":"🖕🏼","middle_finger_medium_skin_tone":"🖕🏽","military_helmet":"🪖","military_medal":"🎖️","milk_glass":"🥛","milky_way":"🌌","minibus":"🚐","minidisc":"💽","minus":"➖","mirror":"🪞","mirror_ball":"🪩","moai":"🗿","mobile_phone":"📱","mobile_phone_off":"📴","mobile_phone_with_arrow":"📲","moldova":"🇲🇩","molodetz":"🖋️","monaco":"🇲🇨","monarch_butterfly":"🫌","money-mouth_face":"🤑","money_bag":"💰","money_mouth_face":"🤑","money_with_wings":"💸","moneybag":"💰","mongolia":"🇲🇳","monkey":"🐒","monkey_face":"🐵","monocle_face":"🧐","monorail":"🚝","montenegro":"🇲🇪","montserrat":"🇲🇸","moon":"🌔","moon_cake":"🥮","moon_viewing_ceremony":"🎑","moose":"🫎","morocco":"🇲🇦","mortar_board":"🎓","mosque":"🕌","mosquito":"🦟","motor_boat":"🛥️","motor_scooter":"🛵","motorcycle":"🏍️","motorized_wheelchair":"🦼","motorway":"🛣️","mount_fuji":"🗻","mountain":"⛰️","mountain_bicyclist":"🚵","mountain_biking_man":"🚵‍♂️","mountain_biking_woman":"🚵‍♀️","mountain_cableway":"🚠","mountain_railway":"🚞","mountain_snow":"🏔️","mouse":"🐁","mouse2":"🐁","mouse_face":"🐭","mouse_trap":"🪤","mouth":"👄","movie_camera":"🎥","moyai":"🗿","mozambique":"🇲🇿","mrs_claus":"🤶","multiply":"✖️","muscle":"💪","mushroom":"🍄","musical_keyboard":"🎹","musical_note":"🎵","musical_notes":"🎶","musical_score":"🎼","mute":"🔇","muted_speaker":"🔇","mx_claus":"🧑‍🎄","myanmar":"🇲🇲","nail_care":"💅","nail_polish":"💅","nail_polish_dark_skin_tone":"💅🏿","nail_polish_light_skin_tone":"💅🏻","nail_polish_medium-dark_skin_tone":"💅🏾","nail_polish_medium-light_skin_tone":"💅🏼","nail_polish_medium_skin_tone":"💅🏽","name_badge":"📛","namibia":"🇳🇦","national_park":"🏞️","nauru":"🇳🇷","nauseated_face":"🤢","nazar_amulet":"🧿","necktie":"👔","negative_squared_cross_mark":"❎","nepal":"🇳🇵","nerd_face":"🤓","nest_with_eggs":"🪺","nesting_dolls":"🪆","net_with_handle":"🪍","netherlands":"🇳🇱","neutral_face":"😐","new":"🆕","new_button":"🆕","new_caledonia":"🇳🇨","new_moon":"🌑","new_moon_face":"🌚","new_moon_with_face":"🌚","new_zealand":"🇳🇿","newspaper":"📰","newspaper_roll":"🗞️","next_track_button":"⏭️","ng":"🆖","ng_button":"🆖","ng_man":"🙅‍♂️","ng_woman":"🙅‍♀️","nicaragua":"🇳🇮","niger":"🇳🇪","nigeria":"🇳🇬","night_with_stars":"🌃","nine":"9️⃣","nine-thirty":"🕤","nine_oclock":"🕘","nine_o’clock":"🕘","nine_thirty":"🕤","ninja":"🥷","ninja_dark_skin_tone":"🥷🏿","ninja_light_skin_tone":"🥷🏻","ninja_medium-dark_skin_tone":"🥷🏾","ninja_medium-light_skin_tone":"🥷🏼","ninja_medium_skin_tone":"🥷🏽","niue":"🇳🇺","no_bell":"🔕","no_bicycles":"🚳","no_entry":"⛔","no_entry_sign":"🚫","no_good":"🙅","no_good_man":"🙅‍♂️","no_good_woman":"🙅‍♀️","no_littering":"🚯","no_mobile_phones":"📵","no_mouth":"😶","no_one_under_eighteen":"🔞","no_pedestrians":"🚷","no_smoking":"🚭","non-potable_water":"🚱","non_potable_water":"🚱","norfolk_island":"🇳🇫","north_korea":"🇰🇵","northern_mariana_islands":"🇲🇵","norway":"🇳🇴","nose":"👃","nose_dark_skin_tone":"👃🏿","nose_light_skin_tone":"👃🏻","nose_medium-dark_skin_tone":"👃🏾","nose_medium-light_skin_tone":"👃🏼","nose_medium_skin_tone":"👃🏽","notebook":"📓","notebook_with_decorative_cover":"📔","notes":"🎶","nut_and_bolt":"🔩","o":"⭕","o2":"🅾️","o_button_blood_type":"🅾️","ocean":"🌊","octopus":"🐙","oden":"🍢","office":"🏢","office_building":"🏢","office_worker":"🧑‍💼","office_worker_dark_skin_tone":"🧑🏿‍💼","office_worker_light_skin_tone":"🧑🏻‍💼","office_worker_medium-dark_skin_tone":"🧑🏾‍💼","office_worker_medium-light_skin_tone":"🧑🏼‍💼","office_worker_medium_skin_tone":"🧑🏽‍💼","ogre":"👹","oil_drum":"🛢️","ok":"🆗","ok_button":"🆗","ok_hand":"👌","ok_man":"🙆‍♂️","ok_person":"🙆","ok_woman":"🙆‍♀️","old_key":"🗝️","old_man":"👴","old_man_dark_skin_tone":"👴🏿","old_man_light_skin_tone":"👴🏻","old_man_medium-dark_skin_tone":"👴🏾","old_man_medium-light_skin_tone":"👴🏼","old_man_medium_skin_tone":"👴🏽","old_woman":"👵","old_woman_dark_skin_tone":"👵🏿","old_woman_light_skin_tone":"👵🏻","old_woman_medium-dark_skin_tone":"👵🏾","old_woman_medium-light_skin_tone":"👵🏼","old_woman_medium_skin_tone":"👵🏽","older_adult":"🧓","older_man":"👴","older_person":"🧓","older_person_dark_skin_tone":"🧓🏿","older_person_light_skin_tone":"🧓🏻","older_person_medium-dark_skin_tone":"🧓🏾","older_person_medium-light_skin_tone":"🧓🏼","older_person_medium_skin_tone":"🧓🏽","older_woman":"👵","olive":"🫒","om":"🕉️","om_symbol":"🕉️","oman":"🇴🇲","on":"🔛","on!_arrow":"🔛","on_arrow":"🔛","oncoming_automobile":"🚘","oncoming_bus":"🚍","oncoming_fist":"👊","oncoming_fist_dark_skin_tone":"👊🏿","oncoming_fist_light_skin_tone":"👊🏻","oncoming_fist_medium-dark_skin_tone":"👊🏾","oncoming_fist_medium-light_skin_tone":"👊🏼","oncoming_fist_medium_skin_tone":"👊🏽","oncoming_police_car":"🚔","oncoming_taxi":"🚖","one":"1️⃣","one-piece_swimsuit":"🩱","one-thirty":"🕜","one_oclock":"🕐","one_o’clock":"🕐","one_piece_swimsuit":"🩱","one_thirty":"🕜","onion":"🧅","open_book":"📖","open_file_folder":"📂","open_hands":"👐","open_hands_dark_skin_tone":"👐🏿","open_hands_light_skin_tone":"👐🏻","open_hands_medium-dark_skin_tone":"👐🏾","open_hands_medium-light_skin_tone":"👐🏼","open_hands_medium_skin_tone":"👐🏽","open_mailbox_with_lowered_flag":"📭","open_mailbox_with_raised_flag":"📬","open_mouth":"😮","open_umbrella":"☂️","ophiuchus":"⛎","optical_disk":"💿","orange":"🍊","orange_book":"📙","orange_circle":"🟠","orange_heart":"🧡","orange_square":"🟧","orangutan":"🦧","orca":"🫍","orthodox_cross":"☦️","otter":"🦦","outbox_tray":"📤","owl":"🦉","ox":"🐂","oyster":"🦪","p_button":"🅿️","package":"📦","page_facing_up":"📄","page_with_curl":"📃","pager":"📟","paintbrush":"🖌️","pakistan":"🇵🇰","palau":"🇵🇼","palestinian_territories":"🇵🇸","palm_down_hand":"🫳","palm_down_hand_dark_skin_tone":"🫳🏿","palm_down_hand_light_skin_tone":"🫳🏻","palm_down_hand_medium-dark_skin_tone":"🫳🏾","palm_down_hand_medium-light_skin_tone":"🫳🏼","palm_down_hand_medium_skin_tone":"🫳🏽","palm_tree":"🌴","palm_up_hand":"🫴","palm_up_hand_dark_skin_tone":"🫴🏿","palm_up_hand_light_skin_tone":"🫴🏻","palm_up_hand_medium-dark_skin_tone":"🫴🏾","palm_up_hand_medium-light_skin_tone":"🫴🏼","palm_up_hand_medium_skin_tone":"🫴🏽","palms_up_together":"🤲","palms_up_together_dark_skin_tone":"🤲🏿","palms_up_together_light_skin_tone":"🤲🏻","palms_up_together_medium-dark_skin_tone":"🤲🏾","palms_up_together_medium-light_skin_tone":"🤲🏼","palms_up_together_medium_skin_tone":"🤲🏽","panama":"🇵🇦","pancakes":"🥞","panda":"🐼","panda_face":"🐼","paperclip":"📎","paperclips":"🖇️","papua_new_guinea":"🇵🇬","parachute":"🪂","paraguay":"🇵🇾","parasol_on_ground":"⛱️","parking":"🅿️","parrot":"🦜","part_alternation_mark":"〽️","partly_sunny":"⛅","party_popper":"🎉","partying_face":"🥳","passenger_ship":"🛳️","passport_control":"🛂","pause_button":"⏸️","paw_prints":"🐾","pea_pod":"🫛","peace_symbol":"☮️","peach":"🍑","peacock":"🦚","peanuts":"🥜","pear":"🍐","pen":"🖊️","pencil":"📝","pencil2":"✏️","penguin":"🐧","pensive":"😔","pensive_face":"😔","people_holding_hands":"🧑‍🤝‍🧑","people_holding_hands_dark_skin_tone":"🧑🏿‍🤝‍🧑🏿","people_holding_hands_dark_skin_tone_light_skin_tone":"🧑🏿‍🤝‍🧑🏻","people_holding_hands_dark_skin_tone_medium-dark_skin_tone":"🧑🏿‍🤝‍🧑🏾","people_holding_hands_dark_skin_tone_medium-light_skin_tone":"🧑🏿‍🤝‍🧑🏼","people_holding_hands_dark_skin_tone_medium_skin_tone":"🧑🏿‍🤝‍🧑🏽","people_holding_hands_light_skin_tone":"🧑🏻‍🤝‍🧑🏻","people_holding_hands_light_skin_tone_dark_skin_tone":"🧑🏻‍🤝‍🧑🏿","people_holding_hands_light_skin_tone_medium-dark_skin_tone":"🧑🏻‍🤝‍🧑🏾","people_holding_hands_light_skin_tone_medium-light_skin_tone":"🧑🏻‍🤝‍🧑🏼","people_holding_hands_light_skin_tone_medium_skin_tone":"🧑🏻‍🤝‍🧑🏽","people_holding_hands_medium-dark_skin_tone":"🧑🏾‍🤝‍🧑🏾","people_holding_hands_medium-dark_skin_tone_dark_skin_tone":"🧑🏾‍🤝‍🧑🏿","people_holding_hands_medium-dark_skin_tone_light_skin_tone":"🧑🏾‍🤝‍🧑🏻","people_holding_hands_medium-dark_skin_tone_medium-light_skin_tone":"🧑🏾‍🤝‍🧑🏼","people_holding_hands_medium-dark_skin_tone_medium_skin_tone":"🧑🏾‍🤝‍🧑🏽","people_holding_hands_medium-light_skin_tone":"🧑🏼‍🤝‍🧑🏼","people_holding_hands_medium-light_skin_tone_dark_skin_tone":"🧑🏼‍🤝‍🧑🏿","people_holding_hands_medium-light_skin_tone_light_skin_tone":"🧑🏼‍🤝‍🧑🏻","people_holding_hands_medium-light_skin_tone_medium-dark_skin_tone":"🧑🏼‍🤝‍🧑🏾","people_holding_hands_medium-light_skin_tone_medium_skin_tone":"🧑🏼‍🤝‍🧑🏽","people_holding_hands_medium_skin_tone":"🧑🏽‍🤝‍🧑🏽","people_holding_hands_medium_skin_tone_dark_skin_tone":"🧑🏽‍🤝‍🧑🏿","people_holding_hands_medium_skin_tone_light_skin_tone":"🧑🏽‍🤝‍🧑🏻","people_holding_hands_medium_skin_tone_medium-dark_skin_tone":"🧑🏽‍🤝‍🧑🏾","people_holding_hands_medium_skin_tone_medium-light_skin_tone":"🧑🏽‍🤝‍🧑🏼","people_hugging":"🫂","people_with_bunny_ears":"👯","people_with_bunny_ears_dark_skin_tone":"👯🏿","people_with_bunny_ears_dark_skin_tone_light_skin_tone":"🧑🏿‍🐰‍🧑🏻","people_with_bunny_ears_dark_skin_tone_medium-dark_skin_tone":"🧑🏿‍🐰‍🧑🏾","people_with_bunny_ears_dark_skin_tone_medium-light_skin_tone":"🧑🏿‍🐰‍🧑🏼","people_with_bunny_ears_dark_skin_tone_medium_skin_tone":"🧑🏿‍🐰‍🧑🏽","people_with_bunny_ears_light_skin_tone":"👯🏻","people_with_bunny_ears_light_skin_tone_dark_skin_tone":"🧑🏻‍🐰‍🧑🏿","people_with_bunny_ears_light_skin_tone_medium-dark_skin_tone":"🧑🏻‍🐰‍🧑🏾","people_with_bunny_ears_light_skin_tone_medium-light_skin_tone":"🧑🏻‍🐰‍🧑🏼","people_with_bunny_ears_light_skin_tone_medium_skin_tone":"🧑🏻‍🐰‍🧑🏽","people_with_bunny_ears_medium-dark_skin_tone":"👯🏾","people_with_bunny_ears_medium-dark_skin_tone_dark_skin_tone":"🧑🏾‍🐰‍🧑🏿","people_with_bunny_ears_medium-dark_skin_tone_light_skin_tone":"🧑🏾‍🐰‍🧑🏻","people_with_bunny_ears_medium-dark_skin_tone_medium-light_skin_tone":"🧑🏾‍🐰‍🧑🏼","people_with_bunny_ears_medium-dark_skin_tone_medium_skin_tone":"🧑🏾‍🐰‍🧑🏽","people_with_bunny_ears_medium-light_skin_tone":"👯🏼","people_with_bunny_ears_medium-light_skin_tone_dark_skin_tone":"🧑🏼‍🐰‍🧑🏿","people_with_bunny_ears_medium-light_skin_tone_light_skin_tone":"🧑🏼‍🐰‍🧑🏻","people_with_bunny_ears_medium-light_skin_tone_medium-dark_skin_tone":"🧑🏼‍🐰‍🧑🏾","people_with_bunny_ears_medium-light_skin_tone_medium_skin_tone":"🧑🏼‍🐰‍🧑🏽","people_with_bunny_ears_medium_skin_tone":"👯🏽","people_with_bunny_ears_medium_skin_tone_dark_skin_tone":"🧑🏽‍🐰‍🧑🏿","people_with_bunny_ears_medium_skin_tone_light_skin_tone":"🧑🏽‍🐰‍🧑🏻","people_with_bunny_ears_medium_skin_tone_medium-dark_skin_tone":"🧑🏽‍🐰‍🧑🏾","people_with_bunny_ears_medium_skin_tone_medium-light_skin_tone":"🧑🏽‍🐰‍🧑🏼","people_wrestling":"🤼","people_wrestling_dark_skin_tone":"🤼🏿","people_wrestling_dark_skin_tone_light_skin_tone":"🧑🏿‍🫯‍🧑🏻","people_wrestling_dark_skin_tone_medium-dark_skin_tone":"🧑🏿‍🫯‍🧑🏾","people_wrestling_dark_skin_tone_medium-light_skin_tone":"🧑🏿‍🫯‍🧑🏼","people_wrestling_dark_skin_tone_medium_skin_tone":"🧑🏿‍🫯‍🧑🏽","people_wrestling_light_skin_tone":"🤼🏻","people_wrestling_light_skin_tone_dark_skin_tone":"🧑🏻‍🫯‍🧑🏿","people_wrestling_light_skin_tone_medium-dark_skin_tone":"🧑🏻‍🫯‍🧑🏾","people_wrestling_light_skin_tone_medium-light_skin_tone":"🧑🏻‍🫯‍🧑🏼","people_wrestling_light_skin_tone_medium_skin_tone":"🧑🏻‍🫯‍🧑🏽","people_wrestling_medium-dark_skin_tone":"🤼🏾","people_wrestling_medium-dark_skin_tone_dark_skin_tone":"🧑🏾‍🫯‍🧑🏿","people_wrestling_medium-dark_skin_tone_light_skin_tone":"🧑🏾‍🫯‍🧑🏻","people_wrestling_medium-dark_skin_tone_medium-light_skin_tone":"🧑🏾‍🫯‍🧑🏼","people_wrestling_medium-dark_skin_tone_medium_skin_tone":"🧑🏾‍🫯‍🧑🏽","people_wrestling_medium-light_skin_tone":"🤼🏼","people_wrestling_medium-light_skin_tone_dark_skin_tone":"🧑🏼‍🫯‍🧑🏿","people_wrestling_medium-light_skin_tone_light_skin_tone":"🧑🏼‍🫯‍🧑🏻","people_wrestling_medium-light_skin_tone_medium-dark_skin_tone":"🧑🏼‍🫯‍🧑🏾","people_wrestling_medium-light_skin_tone_medium_skin_tone":"🧑🏼‍🫯‍🧑🏽","people_wrestling_medium_skin_tone":"🤼🏽","people_wrestling_medium_skin_tone_dark_skin_tone":"🧑🏽‍🫯‍🧑🏿","people_wrestling_medium_skin_tone_light_skin_tone":"🧑🏽‍🫯‍🧑🏻","people_wrestling_medium_skin_tone_medium-dark_skin_tone":"🧑🏽‍🫯‍🧑🏾","people_wrestling_medium_skin_tone_medium-light_skin_tone":"🧑🏽‍🫯‍🧑🏼","performing_arts":"🎭","persevere":"😣","persevering_face":"😣","person":"🧑","person_bald":"🧑‍🦲","person_beard":"🧔","person_biking":"🚴","person_biking_dark_skin_tone":"🚴🏿","person_biking_light_skin_tone":"🚴🏻","person_biking_medium-dark_skin_tone":"🚴🏾","person_biking_medium-light_skin_tone":"🚴🏼","person_biking_medium_skin_tone":"🚴🏽","person_blond_hair":"👱","person_bouncing_ball":"⛹️","person_bouncing_ball_dark_skin_tone":"⛹🏿","person_bouncing_ball_light_skin_tone":"⛹🏻","person_bouncing_ball_medium-dark_skin_tone":"⛹🏾","person_bouncing_ball_medium-light_skin_tone":"⛹🏼","person_bouncing_ball_medium_skin_tone":"⛹🏽","person_bowing":"🙇","person_bowing_dark_skin_tone":"🙇🏿","person_bowing_light_skin_tone":"🙇🏻","person_bowing_medium-dark_skin_tone":"🙇🏾","person_bowing_medium-light_skin_tone":"🙇🏼","person_bowing_medium_skin_tone":"🙇🏽","person_cartwheeling":"🤸","person_cartwheeling_dark_skin_tone":"🤸🏿","person_cartwheeling_light_skin_tone":"🤸🏻","person_cartwheeling_medium-dark_skin_tone":"🤸🏾","person_cartwheeling_medium-light_skin_tone":"🤸🏼","person_cartwheeling_medium_skin_tone":"🤸🏽","person_climbing":"🧗","person_climbing_dark_skin_tone":"🧗🏿","person_climbing_light_skin_tone":"🧗🏻","person_climbing_medium-dark_skin_tone":"🧗🏾","person_climbing_medium-light_skin_tone":"🧗🏼","person_climbing_medium_skin_tone":"🧗🏽","person_curly_hair":"🧑‍🦱","person_dark_skin_tone":"🧑🏿","person_dark_skin_tone_bald":"🧑🏿‍🦲","person_dark_skin_tone_beard":"🧔🏿","person_dark_skin_tone_blond_hair":"👱🏿","person_dark_skin_tone_curly_hair":"🧑🏿‍🦱","person_dark_skin_tone_red_hair":"🧑🏿‍🦰","person_dark_skin_tone_white_hair":"🧑🏿‍🦳","person_facepalming":"🤦","person_facepalming_dark_skin_tone":"🤦🏿","person_facepalming_light_skin_tone":"🤦🏻","person_facepalming_medium-dark_skin_tone":"🤦🏾","person_facepalming_medium-light_skin_tone":"🤦🏼","person_facepalming_medium_skin_tone":"🤦🏽","person_feeding_baby":"🧑‍🍼","person_feeding_baby_dark_skin_tone":"🧑🏿‍🍼","person_feeding_baby_light_skin_tone":"🧑🏻‍🍼","person_feeding_baby_medium-dark_skin_tone":"🧑🏾‍🍼","person_feeding_baby_medium-light_skin_tone":"🧑🏼‍🍼","person_feeding_baby_medium_skin_tone":"🧑🏽‍🍼","person_fencing":"🤺","person_frowning":"🙍","person_frowning_dark_skin_tone":"🙍🏿","person_frowning_light_skin_tone":"🙍🏻","person_frowning_medium-dark_skin_tone":"🙍🏾","person_frowning_medium-light_skin_tone":"🙍🏼","person_frowning_medium_skin_tone":"🙍🏽","person_gesturing_NO":"🙅","person_gesturing_NO_dark_skin_tone":"🙅🏿","person_gesturing_NO_light_skin_tone":"🙅🏻","person_gesturing_NO_medium-dark_skin_tone":"🙅🏾","person_gesturing_NO_medium-light_skin_tone":"🙅🏼","person_gesturing_NO_medium_skin_tone":"🙅🏽","person_gesturing_OK":"🙆","person_gesturing_OK_dark_skin_tone":"🙆🏿","person_gesturing_OK_light_skin_tone":"🙆🏻","person_gesturing_OK_medium-dark_skin_tone":"🙆🏾","person_gesturing_OK_medium-light_skin_tone":"🙆🏼","person_gesturing_OK_medium_skin_tone":"🙆🏽","person_gesturing_no":"🙅","person_gesturing_ok":"🙆","person_getting_haircut":"💇","person_getting_haircut_dark_skin_tone":"💇🏿","person_getting_haircut_light_skin_tone":"💇🏻","person_getting_haircut_medium-dark_skin_tone":"💇🏾","person_getting_haircut_medium-light_skin_tone":"💇🏼","person_getting_haircut_medium_skin_tone":"💇🏽","person_getting_massage":"💆","person_getting_massage_dark_skin_tone":"💆🏿","person_getting_massage_light_skin_tone":"💆🏻","person_getting_massage_medium-dark_skin_tone":"💆🏾","person_getting_massage_medium-light_skin_tone":"💆🏼","person_getting_massage_medium_skin_tone":"💆🏽","person_golfing":"🏌️","person_golfing_dark_skin_tone":"🏌🏿","person_golfing_light_skin_tone":"🏌🏻","person_golfing_medium-dark_skin_tone":"🏌🏾","person_golfing_medium-light_skin_tone":"🏌🏼","person_golfing_medium_skin_tone":"🏌🏽","person_in_bed":"🛌","person_in_bed_dark_skin_tone":"🛌🏿","person_in_bed_light_skin_tone":"🛌🏻","person_in_bed_medium-dark_skin_tone":"🛌🏾","person_in_bed_medium-light_skin_tone":"🛌🏼","person_in_bed_medium_skin_tone":"🛌🏽","person_in_lotus_position":"🧘","person_in_lotus_position_dark_skin_tone":"🧘🏿","person_in_lotus_position_light_skin_tone":"🧘🏻","person_in_lotus_position_medium-dark_skin_tone":"🧘🏾","person_in_lotus_position_medium-light_skin_tone":"🧘🏼","person_in_lotus_position_medium_skin_tone":"🧘🏽","person_in_manual_wheelchair":"🧑‍🦽","person_in_manual_wheelchair_dark_skin_tone":"🧑🏿‍🦽","person_in_manual_wheelchair_facing_right":"🧑‍🦽‍➡️","person_in_manual_wheelchair_facing_right_dark_skin_tone":"🧑🏿‍🦽‍➡️","person_in_manual_wheelchair_facing_right_light_skin_tone":"🧑🏻‍🦽‍➡️","person_in_manual_wheelchair_facing_right_medium-dark_skin_tone":"🧑🏾‍🦽‍➡️","person_in_manual_wheelchair_facing_right_medium-light_skin_tone":"🧑🏼‍🦽‍➡️","person_in_manual_wheelchair_facing_right_medium_skin_tone":"🧑🏽‍🦽‍➡️","person_in_manual_wheelchair_light_skin_tone":"🧑🏻‍🦽","person_in_manual_wheelchair_medium-dark_skin_tone":"🧑🏾‍🦽","person_in_manual_wheelchair_medium-light_skin_tone":"🧑🏼‍🦽","person_in_manual_wheelchair_medium_skin_tone":"🧑🏽‍🦽","person_in_motorized_wheelchair":"🧑‍🦼","person_in_motorized_wheelchair_dark_skin_tone":"🧑🏿‍🦼","person_in_motorized_wheelchair_facing_right":"🧑‍🦼‍➡️","person_in_motorized_wheelchair_facing_right_dark_skin_tone":"🧑🏿‍🦼‍➡️","person_in_motorized_wheelchair_facing_right_light_skin_tone":"🧑🏻‍🦼‍➡️","person_in_motorized_wheelchair_facing_right_medium-dark_skin_tone":"🧑🏾‍🦼‍➡️","person_in_motorized_wheelchair_facing_right_medium-light_skin_tone":"🧑🏼‍🦼‍➡️","person_in_motorized_wheelchair_facing_right_medium_skin_tone":"🧑🏽‍🦼‍➡️","person_in_motorized_wheelchair_light_skin_tone":"🧑🏻‍🦼","person_in_motorized_wheelchair_medium-dark_skin_tone":"🧑🏾‍🦼","person_in_motorized_wheelchair_medium-light_skin_tone":"🧑🏼‍🦼","person_in_motorized_wheelchair_medium_skin_tone":"🧑🏽‍🦼","person_in_steamy_room":"🧖","person_in_steamy_room_dark_skin_tone":"🧖🏿","person_in_steamy_room_light_skin_tone":"🧖🏻","person_in_steamy_room_medium-dark_skin_tone":"🧖🏾","person_in_steamy_room_medium-light_skin_tone":"🧖🏼","person_in_steamy_room_medium_skin_tone":"🧖🏽","person_in_suit_levitating":"🕴️","person_in_suit_levitating_dark_skin_tone":"🕴🏿","person_in_suit_levitating_light_skin_tone":"🕴🏻","person_in_suit_levitating_medium-dark_skin_tone":"🕴🏾","person_in_suit_levitating_medium-light_skin_tone":"🕴🏼","person_in_suit_levitating_medium_skin_tone":"🕴🏽","person_in_tuxedo":"🤵","person_in_tuxedo_dark_skin_tone":"🤵🏿","person_in_tuxedo_light_skin_tone":"🤵🏻","person_in_tuxedo_medium-dark_skin_tone":"🤵🏾","person_in_tuxedo_medium-light_skin_tone":"🤵🏼","person_in_tuxedo_medium_skin_tone":"🤵🏽","person_juggling":"🤹","person_juggling_dark_skin_tone":"🤹🏿","person_juggling_light_skin_tone":"🤹🏻","person_juggling_medium-dark_skin_tone":"🤹🏾","person_juggling_medium-light_skin_tone":"🤹🏼","person_juggling_medium_skin_tone":"🤹🏽","person_kneeling":"🧎","person_kneeling_dark_skin_tone":"🧎🏿","person_kneeling_facing_right":"🧎‍➡️","person_kneeling_facing_right_dark_skin_tone":"🧎🏿‍➡️","person_kneeling_facing_right_light_skin_tone":"🧎🏻‍➡️","person_kneeling_facing_right_medium-dark_skin_tone":"🧎🏾‍➡️","person_kneeling_facing_right_medium-light_skin_tone":"🧎🏼‍➡️","person_kneeling_facing_right_medium_skin_tone":"🧎🏽‍➡️","person_kneeling_light_skin_tone":"🧎🏻","person_kneeling_medium-dark_skin_tone":"🧎🏾","person_kneeling_medium-light_skin_tone":"🧎🏼","person_kneeling_medium_skin_tone":"🧎🏽","person_lifting_weights":"🏋️","person_lifting_weights_dark_skin_tone":"🏋🏿","person_lifting_weights_light_skin_tone":"🏋🏻","person_lifting_weights_medium-dark_skin_tone":"🏋🏾","person_lifting_weights_medium-light_skin_tone":"🏋🏼","person_lifting_weights_medium_skin_tone":"🏋🏽","person_light_skin_tone":"🧑🏻","person_light_skin_tone_bald":"🧑🏻‍🦲","person_light_skin_tone_beard":"🧔🏻","person_light_skin_tone_blond_hair":"👱🏻","person_light_skin_tone_curly_hair":"🧑🏻‍🦱","person_light_skin_tone_red_hair":"🧑🏻‍🦰","person_light_skin_tone_white_hair":"🧑🏻‍🦳","person_medium-dark_skin_tone":"🧑🏾","person_medium-dark_skin_tone_bald":"🧑🏾‍🦲","person_medium-dark_skin_tone_beard":"🧔🏾","person_medium-dark_skin_tone_blond_hair":"👱🏾","person_medium-dark_skin_tone_curly_hair":"🧑🏾‍🦱","person_medium-dark_skin_tone_red_hair":"🧑🏾‍🦰","person_medium-dark_skin_tone_white_hair":"🧑🏾‍🦳","person_medium-light_skin_tone":"🧑🏼","person_medium-light_skin_tone_bald":"🧑🏼‍🦲","person_medium-light_skin_tone_beard":"🧔🏼","person_medium-light_skin_tone_blond_hair":"👱🏼","person_medium-light_skin_tone_curly_hair":"🧑🏼‍🦱","person_medium-light_skin_tone_red_hair":"🧑🏼‍🦰","person_medium-light_skin_tone_white_hair":"🧑🏼‍🦳","person_medium_skin_tone":"🧑🏽","person_medium_skin_tone_bald":"🧑🏽‍🦲","person_medium_skin_tone_beard":"🧔🏽","person_medium_skin_tone_blond_hair":"👱🏽","person_medium_skin_tone_curly_hair":"🧑🏽‍🦱","person_medium_skin_tone_red_hair":"🧑🏽‍🦰","person_medium_skin_tone_white_hair":"🧑🏽‍🦳","person_mountain_biking":"🚵","person_mountain_biking_dark_skin_tone":"🚵🏿","person_mountain_biking_light_skin_tone":"🚵🏻","person_mountain_biking_medium-dark_skin_tone":"🚵🏾","person_mountain_biking_medium-light_skin_tone":"🚵🏼","person_mountain_biking_medium_skin_tone":"🚵🏽","person_playing_handball":"🤾","person_playing_handball_dark_skin_tone":"🤾🏿","person_playing_handball_light_skin_tone":"🤾🏻","person_playing_handball_medium-dark_skin_tone":"🤾🏾","person_playing_handball_medium-light_skin_tone":"🤾🏼","person_playing_handball_medium_skin_tone":"🤾🏽","person_playing_water_polo":"🤽","person_playing_water_polo_dark_skin_tone":"🤽🏿","person_playing_water_polo_light_skin_tone":"🤽🏻","person_playing_water_polo_medium-dark_skin_tone":"🤽🏾","person_playing_water_polo_medium-light_skin_tone":"🤽🏼","person_playing_water_polo_medium_skin_tone":"🤽🏽","person_pouting":"🙎","person_pouting_dark_skin_tone":"🙎🏿","person_pouting_light_skin_tone":"🙎🏻","person_pouting_medium-dark_skin_tone":"🙎🏾","person_pouting_medium-light_skin_tone":"🙎🏼","person_pouting_medium_skin_tone":"🙎🏽","person_raising_hand":"🙋","person_raising_hand_dark_skin_tone":"🙋🏿","person_raising_hand_light_skin_tone":"🙋🏻","person_raising_hand_medium-dark_skin_tone":"🙋🏾","person_raising_hand_medium-light_skin_tone":"🙋🏼","person_raising_hand_medium_skin_tone":"🙋🏽","person_red_hair":"🧑‍🦰","person_rowing_boat":"🚣","person_rowing_boat_dark_skin_tone":"🚣🏿","person_rowing_boat_light_skin_tone":"🚣🏻","person_rowing_boat_medium-dark_skin_tone":"🚣🏾","person_rowing_boat_medium-light_skin_tone":"🚣🏼","person_rowing_boat_medium_skin_tone":"🚣🏽","person_running":"🏃","person_running_dark_skin_tone":"🏃🏿","person_running_facing_right":"🏃‍➡️","person_running_facing_right_dark_skin_tone":"🏃🏿‍➡️","person_running_facing_right_light_skin_tone":"🏃🏻‍➡️","person_running_facing_right_medium-dark_skin_tone":"🏃🏾‍➡️","person_running_facing_right_medium-light_skin_tone":"🏃🏼‍➡️","person_running_facing_right_medium_skin_tone":"🏃🏽‍➡️","person_running_light_skin_tone":"🏃🏻","person_running_medium-dark_skin_tone":"🏃🏾","person_running_medium-light_skin_tone":"🏃🏼","person_running_medium_skin_tone":"🏃🏽","person_shrugging":"🤷","person_shrugging_dark_skin_tone":"🤷🏿","person_shrugging_light_skin_tone":"🤷🏻","person_shrugging_medium-dark_skin_tone":"🤷🏾","person_shrugging_medium-light_skin_tone":"🤷🏼","person_shrugging_medium_skin_tone":"🤷🏽","person_standing":"🧍","person_standing_dark_skin_tone":"🧍🏿","person_standing_light_skin_tone":"🧍🏻","person_standing_medium-dark_skin_tone":"🧍🏾","person_standing_medium-light_skin_tone":"🧍🏼","person_standing_medium_skin_tone":"🧍🏽","person_surfing":"🏄","person_surfing_dark_skin_tone":"🏄🏿","person_surfing_light_skin_tone":"🏄🏻","person_surfing_medium-dark_skin_tone":"🏄🏾","person_surfing_medium-light_skin_tone":"🏄🏼","person_surfing_medium_skin_tone":"🏄🏽","person_swimming":"🏊","person_swimming_dark_skin_tone":"🏊🏿","person_swimming_light_skin_tone":"🏊🏻","person_swimming_medium-dark_skin_tone":"🏊🏾","person_swimming_medium-light_skin_tone":"🏊🏼","person_swimming_medium_skin_tone":"🏊🏽","person_taking_bath":"🛀","person_taking_bath_dark_skin_tone":"🛀🏿","person_taking_bath_light_skin_tone":"🛀🏻","person_taking_bath_medium-dark_skin_tone":"🛀🏾","person_taking_bath_medium-light_skin_tone":"🛀🏼","person_taking_bath_medium_skin_tone":"🛀🏽","person_tipping_hand":"💁","person_tipping_hand_dark_skin_tone":"💁🏿","person_tipping_hand_light_skin_tone":"💁🏻","person_tipping_hand_medium-dark_skin_tone":"💁🏾","person_tipping_hand_medium-light_skin_tone":"💁🏼","person_tipping_hand_medium_skin_tone":"💁🏽","person_walking":"🚶","person_walking_dark_skin_tone":"🚶🏿","person_walking_facing_right":"🚶‍➡️","person_walking_facing_right_dark_skin_tone":"🚶🏿‍➡️","person_walking_facing_right_light_skin_tone":"🚶🏻‍➡️","person_walking_facing_right_medium-dark_skin_tone":"🚶🏾‍➡️","person_walking_facing_right_medium-light_skin_tone":"🚶🏼‍➡️","person_walking_facing_right_medium_skin_tone":"🚶🏽‍➡️","person_walking_light_skin_tone":"🚶🏻","person_walking_medium-dark_skin_tone":"🚶🏾","person_walking_medium-light_skin_tone":"🚶🏼","person_walking_medium_skin_tone":"🚶🏽","person_wearing_turban":"👳","person_wearing_turban_dark_skin_tone":"👳🏿","person_wearing_turban_light_skin_tone":"👳🏻","person_wearing_turban_medium-dark_skin_tone":"👳🏾","person_wearing_turban_medium-light_skin_tone":"👳🏼","person_wearing_turban_medium_skin_tone":"👳🏽","person_white_hair":"🧑‍🦳","person_with_ball":"⛹️","person_with_blond_hair":"👱","person_with_crown":"🫅","person_with_crown_dark_skin_tone":"🫅🏿","person_with_crown_light_skin_tone":"🫅🏻","person_with_crown_medium-dark_skin_tone":"🫅🏾","person_with_crown_medium-light_skin_tone":"🫅🏼","person_with_crown_medium_skin_tone":"🫅🏽","person_with_pouting_face":"🙎","person_with_probing_cane":"🧑‍🦯","person_with_skullcap":"👲","person_with_skullcap_dark_skin_tone":"👲🏿","person_with_skullcap_light_skin_tone":"👲🏻","person_with_skullcap_medium-dark_skin_tone":"👲🏾","person_with_skullcap_medium-light_skin_tone":"👲🏼","person_with_skullcap_medium_skin_tone":"👲🏽","person_with_turban":"👳","person_with_veil":"👰","person_with_veil_dark_skin_tone":"👰🏿","person_with_veil_light_skin_tone":"👰🏻","person_with_veil_medium-dark_skin_tone":"👰🏾","person_with_veil_medium-light_skin_tone":"👰🏼","person_with_veil_medium_skin_tone":"👰🏽","person_with_white_cane":"🧑‍🦯","person_with_white_cane_dark_skin_tone":"🧑🏿‍🦯","person_with_white_cane_facing_right":"🧑‍🦯‍➡️","person_with_white_cane_facing_right_dark_skin_tone":"🧑🏿‍🦯‍➡️","person_with_white_cane_facing_right_light_skin_tone":"🧑🏻‍🦯‍➡️","person_with_white_cane_facing_right_medium-dark_skin_tone":"🧑🏾‍🦯‍➡️","person_with_white_cane_facing_right_medium-light_skin_tone":"🧑🏼‍🦯‍➡️","person_with_white_cane_facing_right_medium_skin_tone":"🧑🏽‍🦯‍➡️","person_with_white_cane_light_skin_tone":"🧑🏻‍🦯","person_with_white_cane_medium-dark_skin_tone":"🧑🏾‍🦯","person_with_white_cane_medium-light_skin_tone":"🧑🏼‍🦯","person_with_white_cane_medium_skin_tone":"🧑🏽‍🦯","peru":"🇵🇪","petri_dish":"🧫","philippines":"🇵🇭","phoenix":"🐦‍🔥","phone":"☎️","pick":"⛏️","pickle":"🫝","pickup_truck":"🛻","pie":"🥧","pig":"🐖","pig2":"🐖","pig_face":"🐷","pig_nose":"🐽","pile_of_poo":"💩","pill":"💊","pilot":"🧑‍✈️","pilot_dark_skin_tone":"🧑🏿‍✈️","pilot_light_skin_tone":"🧑🏻‍✈️","pilot_medium-dark_skin_tone":"🧑🏾‍✈️","pilot_medium-light_skin_tone":"🧑🏼‍✈️","pilot_medium_skin_tone":"🧑🏽‍✈️","pinata":"🪅","pinched_fingers":"🤌","pinched_fingers_dark_skin_tone":"🤌🏿","pinched_fingers_light_skin_tone":"🤌🏻","pinched_fingers_medium-dark_skin_tone":"🤌🏾","pinched_fingers_medium-light_skin_tone":"🤌🏼","pinched_fingers_medium_skin_tone":"🤌🏽","pinching_hand":"🤏","pinching_hand_dark_skin_tone":"🤏🏿","pinching_hand_light_skin_tone":"🤏🏻","pinching_hand_medium-dark_skin_tone":"🤏🏾","pinching_hand_medium-light_skin_tone":"🤏🏼","pinching_hand_medium_skin_tone":"🤏🏽","pine_decoration":"🎍","pineapple":"🍍","ping_pong":"🏓","pink_heart":"🩷","pirate_flag":"🏴‍☠️","pisces":"♓","pitcairn_islands":"🇵🇳","pizza":"🍕","piñata":"🪅","placard":"🪧","place_of_worship":"🛐","plate_with_cutlery":"🍽️","play_button":"▶️","play_or_pause_button":"⏯️","playground_slide":"🛝","pleading_face":"🥺","plunger":"🪠","plus":"➕","point_down":"👇","point_left":"👈","point_right":"👉","point_up":"☝️","point_up_2":"👆","poland":"🇵🇱","polar_bear":"🐻‍❄️","police_car":"🚓","police_car_light":"🚨","police_officer":"👮","police_officer_dark_skin_tone":"👮🏿","police_officer_light_skin_tone":"👮🏻","police_officer_medium-dark_skin_tone":"👮🏾","police_officer_medium-light_skin_tone":"👮🏼","police_officer_medium_skin_tone":"👮🏽","policeman":"👮‍♂️","policewoman":"👮‍♀️","poodle":"🐩","pool_8_ball":"🎱","poop":"💩","popcorn":"🍿","portugal":"🇵🇹","post_office":"🏣","postal_horn":"📯","postbox":"📮","pot_of_food":"🍲","potable_water":"🚰","potato":"🥔","potted_plant":"🪴","pouch":"👝","poultry_leg":"🍗","pound":"💷","pound_banknote":"💷","pouring_liquid":"🫗","pout":"😡","pouting_cat":"😾","pouting_face":"🙎","pouting_man":"🙎‍♂️","pouting_woman":"🙎‍♀️","pray":"🙏","prayer_beads":"📿","pregnant_man":"🫃","pregnant_man_dark_skin_tone":"🫃🏿","pregnant_man_light_skin_tone":"🫃🏻","pregnant_man_medium-dark_skin_tone":"🫃🏾","pregnant_man_medium-light_skin_tone":"🫃🏼","pregnant_man_medium_skin_tone":"🫃🏽","pregnant_person":"🫄","pregnant_person_dark_skin_tone":"🫄🏿","pregnant_person_light_skin_tone":"🫄🏻","pregnant_person_medium-dark_skin_tone":"🫄🏾","pregnant_person_medium-light_skin_tone":"🫄🏼","pregnant_person_medium_skin_tone":"🫄🏽","pregnant_woman":"🤰","pregnant_woman_dark_skin_tone":"🤰🏿","pregnant_woman_light_skin_tone":"🤰🏻","pregnant_woman_medium-dark_skin_tone":"🤰🏾","pregnant_woman_medium-light_skin_tone":"🤰🏼","pregnant_woman_medium_skin_tone":"🤰🏽","pretzel":"🥨","previous_track_button":"⏮️","prince":"🤴","prince_dark_skin_tone":"🤴🏿","prince_light_skin_tone":"🤴🏻","prince_medium-dark_skin_tone":"🤴🏾","prince_medium-light_skin_tone":"🤴🏼","prince_medium_skin_tone":"🤴🏽","princess":"👸","princess_dark_skin_tone":"👸🏿","princess_light_skin_tone":"👸🏻","princess_medium-dark_skin_tone":"👸🏾","princess_medium-light_skin_tone":"👸🏼","princess_medium_skin_tone":"👸🏽","printer":"🖨️","probing_cane":"🦯","prohibited":"🚫","puerto_rico":"🇵🇷","punch":"👊","purple_circle":"🟣","purple_heart":"💜","purple_square":"🟪","purse":"👛","pushpin":"📌","put_litter_in_its_place":"🚮","puzzle_piece":"🧩","qatar":"🇶🇦","question":"❓","rabbit":"🐇","rabbit2":"🐇","rabbit_face":"🐰","raccoon":"🦝","racehorse":"🐎","racing_car":"🏎️","racing_motorcycle":"🏍️","radio":"📻","radio_button":"🔘","radioactive":"☢️","radioactive_sign":"☢️","rage":"😡","railway_car":"🚃","railway_track":"🛤️","rainbow":"🌈","rainbow_flag":"🏳️‍🌈","raised_back_of_hand":"🤚","raised_back_of_hand_dark_skin_tone":"🤚🏿","raised_back_of_hand_light_skin_tone":"🤚🏻","raised_back_of_hand_medium-dark_skin_tone":"🤚🏾","raised_back_of_hand_medium-light_skin_tone":"🤚🏼","raised_back_of_hand_medium_skin_tone":"🤚🏽","raised_eyebrow":"🤨","raised_fist":"✊","raised_fist_dark_skin_tone":"✊🏿","raised_fist_light_skin_tone":"✊🏻","raised_fist_medium-dark_skin_tone":"✊🏾","raised_fist_medium-light_skin_tone":"✊🏼","raised_fist_medium_skin_tone":"✊🏽","raised_hand":"✋","raised_hand_dark_skin_tone":"✋🏿","raised_hand_light_skin_tone":"✋🏻","raised_hand_medium-dark_skin_tone":"✋🏾","raised_hand_medium-light_skin_tone":"✋🏼","raised_hand_medium_skin_tone":"✋🏽","raised_hand_with_fingers_splayed":"🖐️","raised_hand_with_part_between_middle_and_ring_fingers":"🖖","raised_hands":"🙌","raising_hand":"🙋","raising_hand_man":"🙋‍♂️","raising_hand_woman":"🙋‍♀️","raising_hands":"🙌","raising_hands_dark_skin_tone":"🙌🏿","raising_hands_light_skin_tone":"🙌🏻","raising_hands_medium-dark_skin_tone":"🙌🏾","raising_hands_medium-light_skin_tone":"🙌🏼","raising_hands_medium_skin_tone":"🙌🏽","ram":"🐏","ramen":"🍜","rat":"🐀","raven":"🐦‍⬛","razor":"🪒","receipt":"🧾","record_button":"⏺️","recycle":"♻️","recycling_symbol":"♻️","red_apple":"🍎","red_car":"🚗","red_circle":"🔴","red_envelope":"🧧","red_exclamation_mark":"❗","red_haired_man":"👨‍🦰","red_haired_woman":"👩‍🦰","red_heart":"❤️","red_paper_lantern":"🏮","red_question_mark":"❓","red_square":"🟥","red_triangle_pointed_down":"🔻","red_triangle_pointed_up":"🔺","registered":"®️","relaxed":"☺️","relieved":"😌","relieved_face":"😌","reminder_ribbon":"🎗️","repeat":"🔁","repeat_button":"🔁","repeat_one":"🔂","repeat_single_button":"🔂","rescue_worker_helmet":"⛑️","rescue_workers_helmet":"⛑️","rescue_worker’s_helmet":"⛑️","restroom":"🚻","reunion":"🇷🇪","reverse_button":"◀️","reversed_hand_with_middle_finger_extended":"🖕","revolving_hearts":"💞","rewind":"⏪","rhinoceros":"🦏","ribbon":"🎀","rice":"🍚","rice_ball":"🍙","rice_cracker":"🍘","rice_scene":"🎑","right-facing_fist":"🤜","right-facing_fist_dark_skin_tone":"🤜🏿","right-facing_fist_light_skin_tone":"🤜🏻","right-facing_fist_medium-dark_skin_tone":"🤜🏾","right-facing_fist_medium-light_skin_tone":"🤜🏼","right-facing_fist_medium_skin_tone":"🤜🏽","right_anger_bubble":"🗯️","right_arrow":"➡️","right_arrow_curving_down":"⤵️","right_arrow_curving_left":"↩️","right_arrow_curving_up":"⤴️","right_facing_fist":"🤜","rightwards_hand":"🫱","rightwards_hand_dark_skin_tone":"🫱🏿","rightwards_hand_light_skin_tone":"🫱🏻","rightwards_hand_medium-dark_skin_tone":"🫱🏾","rightwards_hand_medium-light_skin_tone":"🫱🏼","rightwards_hand_medium_skin_tone":"🫱🏽","rightwards_pushing_hand":"🫸","rightwards_pushing_hand_dark_skin_tone":"🫸🏿","rightwards_pushing_hand_light_skin_tone":"🫸🏻","rightwards_pushing_hand_medium-dark_skin_tone":"🫸🏾","rightwards_pushing_hand_medium-light_skin_tone":"🫸🏼","rightwards_pushing_hand_medium_skin_tone":"🫸🏽","rightwards_thumb_sign":"🫺","rightwards_thumb_sign_dark_skin_tone":"🫺🏿","rightwards_thumb_sign_light_skin_tone":"🫺🏻","rightwards_thumb_sign_medium-dark_skin_tone":"🫺🏾","rightwards_thumb_sign_medium-light_skin_tone":"🫺🏼","rightwards_thumb_sign_medium_skin_tone":"🫺🏽","ring":"💍","ring_buoy":"🛟","ringed_planet":"🪐","roasted_sweet_potato":"🍠","robot":"🤖","robot_face":"🤖","rock":"🪨","rocket":"🚀","rofl":"🤣","roll_eyes":"🙄","roll_of_paper":"🧻","rolled-up_newspaper":"🗞️","rolled_up_newspaper":"🗞️","roller_coaster":"🎢","roller_skate":"🛼","rolling_on_the_floor_laughing":"🤣","romania":"🇷🇴","rook":"🐦‍⬛","rooster":"🐓","root_vegetable":"🫜","rose":"🌹","rosette":"🏵️","rotating_light":"🚨","round_pushpin":"📍","rowboat":"🚣","rowing_man":"🚣‍♂️","rowing_woman":"🚣‍♀️","ru":"🇷🇺","rugby_football":"🏉","runner":"🏃","running":"🏃","running_man":"🏃‍♂️","running_shirt":"🎽","running_shirt_with_sash":"🎽","running_shoe":"👟","running_woman":"🏃‍♀️","rwanda":"🇷🇼","sa":"🈂️","sad_but_relieved_face":"😥","safety_pin":"🧷","safety_vest":"🦺","sagittarius":"♐","sailboat":"⛵","sake":"🍶","salt":"🧂","saluting_face":"🫡","samoa":"🇼🇸","san_marino":"🇸🇲","sandal":"👡","sandwich":"🥪","santa":"🎅","santa_claus":"🎅","sao_tome_principe":"🇸🇹","sari":"🥻","sassy_man":"💁‍♂️","sassy_woman":"💁‍♀️","satellite":"🛰️","satellite_antenna":"📡","satisfied":"😆","saudi_arabia":"🇸🇦","sauna_man":"🧖‍♂️","sauna_person":"🧖","sauna_woman":"🧖‍♀️","sauropod":"🦕","saxophone":"🎷","scales":"⚖️","scarf":"🧣","school":"🏫","school_satchel":"🎒","scientist":"🧑‍🔬","scientist_dark_skin_tone":"🧑🏿‍🔬","scientist_light_skin_tone":"🧑🏻‍🔬","scientist_medium-dark_skin_tone":"🧑🏾‍🔬","scientist_medium-light_skin_tone":"🧑🏼‍🔬","scientist_medium_skin_tone":"🧑🏽‍🔬","scissors":"✂️","scorpio":"♏","scorpion":"🦂","scorpius":"♏","scotland":"🏴󠁧󠁢󠁳󠁣󠁴󠁿","scream":"😱","scream_cat":"🙀","screwdriver":"🪛","scroll":"📜","seal":"🦭","seat":"💺","secret":"㊙️","see-no-evil_monkey":"🙈","see_no_evil":"🙈","see_no_evil_monkey":"🙈","seedling":"🌱","selfie":"🤳","selfie_dark_skin_tone":"🤳🏿","selfie_light_skin_tone":"🤳🏻","selfie_medium-dark_skin_tone":"🤳🏾","selfie_medium-light_skin_tone":"🤳🏼","selfie_medium_skin_tone":"🤳🏽","senegal":"🇸🇳","serbia":"🇷🇸","service_dog":"🐕‍🦺","seven":"7️⃣","seven-thirty":"🕢","seven_oclock":"🕖","seven_o’clock":"🕖","seven_thirty":"🕢","sewing_needle":"🪡","seychelles":"🇸🇨","shaking_face":"🫨","shallow_pan_of_food":"🥘","shamrock":"☘️","shark":"🦈","shaved_ice":"🍧","sheaf_of_rice":"🌾","sheep":"🐑","shell":"🐚","shield":"🛡️","shinto_shrine":"⛩️","ship":"🚢","shirt":"👕","shit":"💩","shoe":"👞","shooting_star":"🌠","shopping":"🛍️","shopping_bags":"🛍️","shopping_cart":"🛒","shortcake":"🍰","shorts":"🩳","shovel":"🪏","shower":"🚿","shrimp":"🦐","shrug":"🤷","shuffle_tracks_button":"🔀","shushing_face":"🤫","sierra_leone":"🇸🇱","sign_of_the_horns":"🤘","sign_of_the_horns_dark_skin_tone":"🤘🏿","sign_of_the_horns_light_skin_tone":"🤘🏻","sign_of_the_horns_medium-dark_skin_tone":"🤘🏾","sign_of_the_horns_medium-light_skin_tone":"🤘🏼","sign_of_the_horns_medium_skin_tone":"🤘🏽","signal_strength":"📶","singapore":"🇸🇬","singer":"🧑‍🎤","singer_dark_skin_tone":"🧑🏿‍🎤","singer_light_skin_tone":"🧑🏻‍🎤","singer_medium-dark_skin_tone":"🧑🏾‍🎤","singer_medium-light_skin_tone":"🧑🏼‍🎤","singer_medium_skin_tone":"🧑🏽‍🎤","sint_maarten":"🇸🇽","six":"6️⃣","six-thirty":"🕡","six_oclock":"🕕","six_o’clock":"🕕","six_pointed_star":"🔯","six_thirty":"🕡","skateboard":"🛹","ski":"🎿","skier":"⛷️","skis":"🎿","skull":"💀","skull_and_crossbones":"☠️","skunk":"🦨","sled":"🛷","sleeping":"😴","sleeping_accommodation":"🛌","sleeping_bed":"🛌","sleeping_face":"😴","sleepy":"😪","sleepy_face":"😪","sleuth_or_spy":"🕵️","slightly_frowning_face":"🙁","slightly_smiling_face":"🙂","slot_machine":"🎰","sloth":"🦥","slovakia":"🇸🇰","slovenia":"🇸🇮","small_airplane":"🛩️","small_blue_diamond":"🔹","small_orange_diamond":"🔸","small_red_triangle":"🔺","small_red_triangle_down":"🔻","smile":"😄","smile_cat":"😸","smiley":"😃","smiley_cat":"😺","smiling_cat_with_heart-eyes":"😻","smiling_cat_with_heart_eyes":"😻","smiling_face":"☺️","smiling_face_with_halo":"😇","smiling_face_with_heart-eyes":"😍","smiling_face_with_heart_eyes":"😍","smiling_face_with_hearts":"🥰","smiling_face_with_horns":"😈","smiling_face_with_open_hands":"🤗","smiling_face_with_smiling_eyes":"😊","smiling_face_with_sunglasses":"😎","smiling_face_with_tear":"🥲","smiling_face_with_three_hearts":"🥰","smiling_imp":"😈","smirk":"😏","smirk_cat":"😼","smirking_face":"😏","smoking":"🚬","snail":"🐌","snake":"🐍","sneezing_face":"🤧","snow-capped_mountain":"🏔️","snow_capped_mountain":"🏔️","snowboarder":"🏂","snowboarder_dark_skin_tone":"🏂🏿","snowboarder_light_skin_tone":"🏂🏻","snowboarder_medium-dark_skin_tone":"🏂🏾","snowboarder_medium-light_skin_tone":"🏂🏼","snowboarder_medium_skin_tone":"🏂🏽","snowflake":"❄️","snowman":"☃️","snowman_with_snow":"☃️","snowman_without_snow":"⛄","soap":"🧼","sob":"😭","soccer":"⚽","soccer_ball":"⚽","socks":"🧦","soft_ice_cream":"🍦","softball":"🥎","solomon_islands":"🇸🇧","somalia":"🇸🇴","soon":"🔜","soon_arrow":"🔜","sos":"🆘","sos_button":"🆘","sound":"🔉","south_africa":"🇿🇦","south_georgia_south_sandwich_islands":"🇬🇸","south_sudan":"🇸🇸","space_invader":"👾","spade_suit":"♠️","spades":"♠️","spaghetti":"🍝","sparkle":"❇️","sparkler":"🎇","sparkles":"✨","sparkling_heart":"💖","speak-no-evil_monkey":"🙊","speak_no_evil":"🙊","speak_no_evil_monkey":"🙊","speaker":"🔈","speaker_high_volume":"🔊","speaker_low_volume":"🔈","speaker_medium_volume":"🔉","speaking_head":"🗣️","speaking_head_in_silhouette":"🗣️","speech_balloon":"💬","speedboat":"🚤","spider":"🕷️","spider_web":"🕸️","spiral_calendar":"🗓️","spiral_calendar_pad":"🗓️","spiral_note_pad":"🗒️","spiral_notepad":"🗒️","spiral_shell":"🐚","splatter":"🫟","sponge":"🧽","spoon":"🥄","sport_utility_vehicle":"🚙","sports_medal":"🏅","spouting_whale":"🐳","squid":"🦑","squinting_face_with_tongue":"😝","sri_lanka":"🇱🇰","st_barthelemy":"🇧🇱","st_helena":"🇸🇭","st_kitts_nevis":"🇰🇳","st_lucia":"🇱🇨","st_martin":"🇲🇫","st_pierre_miquelon":"🇵🇲","st_vincent_grenadines":"🇻🇨","stadium":"🏟️","standing_man":"🧍‍♂️","standing_person":"🧍","standing_woman":"🧍‍♀️","star":"⭐","star-struck":"🤩","star2":"🌟","star_and_crescent":"☪️","star_of_David":"✡️","star_of_david":"✡️","star_struck":"🤩","stars":"🌠","station":"🚉","statue_of_liberty":"🗽","steam_locomotive":"🚂","steaming_bowl":"🍜","stethoscope":"🩺","stew":"🍲","stop_button":"⏹️","stop_sign":"🛑","stopwatch":"⏱️","straight_ruler":"📏","strawberry":"🍓","stuck_out_tongue":"😛","stuck_out_tongue_closed_eyes":"😝","stuck_out_tongue_winking_eye":"😜","student":"🧑‍🎓","student_dark_skin_tone":"🧑🏿‍🎓","student_light_skin_tone":"🧑🏻‍🎓","student_medium-dark_skin_tone":"🧑🏾‍🎓","student_medium-light_skin_tone":"🧑🏼‍🎓","student_medium_skin_tone":"🧑🏽‍🎓","studio_microphone":"🎙️","stuffed_flatbread":"🥙","sudan":"🇸🇩","sun":"☀️","sun_behind_cloud":"⛅","sun_behind_large_cloud":"🌥️","sun_behind_rain_cloud":"🌦️","sun_behind_small_cloud":"🌤️","sun_with_face":"🌞","sunflower":"🌻","sunglasses":"😎","sunny":"☀️","sunrise":"🌅","sunrise_over_mountains":"🌄","sunset":"🌇","superhero":"🦸","superhero_dark_skin_tone":"🦸🏿","superhero_light_skin_tone":"🦸🏻","superhero_man":"🦸‍♂️","superhero_medium-dark_skin_tone":"🦸🏾","superhero_medium-light_skin_tone":"🦸🏼","superhero_medium_skin_tone":"🦸🏽","superhero_woman":"🦸‍♀️","supervillain":"🦹","supervillain_dark_skin_tone":"🦹🏿","supervillain_light_skin_tone":"🦹🏻","supervillain_man":"🦹‍♂️","supervillain_medium-dark_skin_tone":"🦹🏾","supervillain_medium-light_skin_tone":"🦹🏼","supervillain_medium_skin_tone":"🦹🏽","supervillain_woman":"🦹‍♀️","surfer":"🏄","surfing_man":"🏄‍♂️","surfing_woman":"🏄‍♀️","suriname":"🇸🇷","sushi":"🍣","suspension_railway":"🚟","svalbard_jan_mayen":"🇸🇯","swan":"🦢","swaziland":"🇸🇿","sweat":"😓","sweat_droplets":"💦","sweat_drops":"💦","sweat_smile":"😅","sweden":"🇸🇪","sweet_potato":"🍠","swim_brief":"🩲","swimmer":"🏊","swimming_man":"🏊‍♂️","swimming_woman":"🏊‍♀️","switzerland":"🇨🇭","symbols":"🔣","synagogue":"🕍","syria":"🇸🇾","syringe":"💉","t-rex":"🦖","t-shirt":"👕","t_rex":"🦖","t_shirt":"👕","table_tennis_paddle_and_ball":"🏓","taco":"🌮","tada":"🎉","taiwan":"🇹🇼","tajikistan":"🇹🇯","takeout_box":"🥡","tamale":"🫔","tanabata_tree":"🎋","tangerine":"🍊","tanzania":"🇹🇿","taurus":"♉","taxi":"🚕","tea":"🍵","teacher":"🧑‍🏫","teacher_dark_skin_tone":"🧑🏿‍🏫","teacher_light_skin_tone":"🧑🏻‍🏫","teacher_medium-dark_skin_tone":"🧑🏾‍🏫","teacher_medium-light_skin_tone":"🧑🏼‍🏫","teacher_medium_skin_tone":"🧑🏽‍🏫","teacup_without_handle":"🍵","teapot":"🫖","tear-off_calendar":"📆","tear_off_calendar":"📆","technologist":"🧑‍💻","technologist_dark_skin_tone":"🧑🏿‍💻","technologist_light_skin_tone":"🧑🏻‍💻","technologist_medium-dark_skin_tone":"🧑🏾‍💻","technologist_medium-light_skin_tone":"🧑🏼‍💻","technologist_medium_skin_tone":"🧑🏽‍💻","teddy_bear":"🧸","telephone":"☎️","telephone_receiver":"📞","telescope":"🔭","television":"📺","ten":"🔟","ten-thirty":"🕥","ten_oclock":"🕙","ten_o’clock":"🕙","ten_thirty":"🕥","tennis":"🎾","tent":"⛺","test_tube":"🧪","thailand":"🇹🇭","thermometer":"🌡️","thinking":"🤔","thinking_face":"🤔","thong_sandal":"🩴","thought_balloon":"💭","thread":"🧵","three":"3️⃣","three-thirty":"🕞","three_button_mouse":"🖱️","three_oclock":"🕒","three_o’clock":"🕒","three_thirty":"🕞","thumbs_down":"👎","thumbs_down_dark_skin_tone":"👎🏿","thumbs_down_light_skin_tone":"👎🏻","thumbs_down_medium-dark_skin_tone":"👎🏾","thumbs_down_medium-light_skin_tone":"👎🏼","thumbs_down_medium_skin_tone":"👎🏽","thumbs_up":"👍","thumbs_up_dark_skin_tone":"👍🏿","thumbs_up_light_skin_tone":"👍🏻","thumbs_up_medium-dark_skin_tone":"👍🏾","thumbs_up_medium-light_skin_tone":"👍🏼","thumbs_up_medium_skin_tone":"👍🏽","thumbsdown":"👎","thumbsup":"👍","thunder_cloud_and_rain":"⛈️","ticket":"🎫","tickets":"🎟️","tiger":"🐅","tiger2":"🐅","tiger_face":"🐯","timer_clock":"⏲️","timor_leste":"🇹🇱","tipping_hand_man":"💁‍♂️","tipping_hand_person":"💁","tipping_hand_woman":"💁‍♀️","tired_face":"😫","tm":"™️","togo":"🇹🇬","toilet":"🚽","tokelau":"🇹🇰","tokyo_tower":"🗼","tomato":"🍅","tonga":"🇹🇴","tongue":"👅","toolbox":"🧰","tooth":"🦷","toothbrush":"🪥","top":"🔝","top_arrow":"🔝","top_hat":"🎩","tophat":"🎩","tornado":"🌪️","tr":"🇹🇷","trackball":"🖲️","tractor":"🚜","trade_mark":"™️","traffic_light":"🚥","train":"🚆","train2":"🚆","tram":"🚊","tram_car":"🚋","transgender_flag":"🏳️‍⚧️","transgender_symbol":"⚧️","treasure_chest":"🪎","triangular_flag":"🚩","triangular_flag_on_post":"🚩","triangular_ruler":"📐","trident":"🔱","trident_emblem":"🔱","trinidad_tobago":"🇹🇹","tristan_da_cunha":"🇹🇦","triumph":"😤","troll":"🧌","trolleybus":"🚎","trombone":"🪊","trophy":"🏆","tropical_drink":"🍹","tropical_fish":"🐠","truck":"🚚","trumpet":"🎺","tshirt":"👕","tulip":"🌷","tumbler_glass":"🥃","tunisia":"🇹🇳","turkey":"🦃","turkmenistan":"🇹🇲","turks_caicos_islands":"🇹🇨","turtle":"🐢","tuvalu":"🇹🇻","tv":"📺","twelve-thirty":"🕧","twelve_oclock":"🕛","twelve_o’clock":"🕛","twelve_thirty":"🕧","twisted_rightwards_arrows":"🔀","two":"2️⃣","two-hump_camel":"🐫","two-thirty":"🕝","two_hearts":"💕","two_hump_camel":"🐫","two_men_holding_hands":"👬","two_oclock":"🕑","two_o’clock":"🕑","two_thirty":"🕝","two_women_holding_hands":"👭","u5272":"🈹","u5408":"🈴","u55b6":"🈺","u6307":"🈯","u6708":"🈷️","u6709":"🈶","u6e80":"🈵","u7121":"🈚","u7533":"🈸","u7981":"🈲","u7a7a":"🈳","uganda":"🇺🇬","uk":"🇬🇧","ukraine":"🇺🇦","umbrella":"☂️","umbrella_on_ground":"⛱️","umbrella_with_rain_drops":"☔","unamused":"😒","unamused_face":"😒","underage":"🔞","unicorn":"🦄","unicorn_face":"🦄","united_arab_emirates":"🇦🇪","united_nations":"🇺🇳","unlock":"🔓","unlocked":"🔓","up":"🆙","up-down_arrow":"↕️","up-left_arrow":"↖️","up-right_arrow":"↗️","up_arrow":"⬆️","up_button":"🆙","up_down_arrow":"↕️","up_left_arrow":"↖️","up_right_arrow":"↗️","upside-down_face":"🙃","upside_down_face":"🙃","upwards_button":"🔼","uruguay":"🇺🇾","us":"🇺🇸","us_outlying_islands":"🇺🇲","us_virgin_islands":"🇻🇮","uzbekistan":"🇺🇿","v":"✌️","vampire":"🧛","vampire_dark_skin_tone":"🧛🏿","vampire_light_skin_tone":"🧛🏻","vampire_man":"🧛‍♂️","vampire_medium-dark_skin_tone":"🧛🏾","vampire_medium-light_skin_tone":"🧛🏼","vampire_medium_skin_tone":"🧛🏽","vampire_woman":"🧛‍♀️","vanuatu":"🇻🇺","vatican_city":"🇻🇦","venezuela":"🇻🇪","vertical_traffic_light":"🚦","vhs":"📼","vibration_mode":"📳","victory_hand":"✌️","victory_hand_dark_skin_tone":"✌🏿","victory_hand_light_skin_tone":"✌🏻","victory_hand_medium-dark_skin_tone":"✌🏾","victory_hand_medium-light_skin_tone":"✌🏼","victory_hand_medium_skin_tone":"✌🏽","video_camera":"📹","video_game":"🎮","videocassette":"📼","vietnam":"🇻🇳","violin":"🎻","virgo":"♍","volcano":"🌋","volleyball":"🏐","vomiting_face":"🤮","vs":"🆚","vs_button":"🆚","vulcan_salute":"🖖","vulcan_salute_dark_skin_tone":"🖖🏿","vulcan_salute_light_skin_tone":"🖖🏻","vulcan_salute_medium-dark_skin_tone":"🖖🏾","vulcan_salute_medium-light_skin_tone":"🖖🏼","vulcan_salute_medium_skin_tone":"🖖🏽","waffle":"🧇","wales":"🏴󠁧󠁢󠁷󠁬󠁳󠁿","walking":"🚶","walking_man":"🚶‍♂️","walking_woman":"🚶‍♀️","wallis_futuna":"🇼🇫","waning_crescent_moon":"🌘","waning_gibbous_moon":"🌖","warning":"⚠️","wastebasket":"🗑️","watch":"⌚","water_buffalo":"🐃","water_closet":"🚾","water_pistol":"🔫","water_polo":"🤽","water_wave":"🌊","watermelon":"🍉","wave":"👋","waving_black_flag":"🏴","waving_hand":"👋","waving_hand_dark_skin_tone":"👋🏿","waving_hand_light_skin_tone":"👋🏻","waving_hand_medium-dark_skin_tone":"👋🏾","waving_hand_medium-light_skin_tone":"👋🏼","waving_hand_medium_skin_tone":"👋🏽","waving_white_flag":"🏳️","wavy_dash":"〰️","waxing_crescent_moon":"🌒","waxing_gibbous_moon":"🌔","wc":"🚾","weary":"😩","weary_cat":"🙀","weary_face":"😩","wedding":"💒","weight_lifter":"🏋️","weight_lifting":"🏋️","weight_lifting_man":"🏋️‍♂️","weight_lifting_woman":"🏋️‍♀️","western_sahara":"🇪🇭","whale":"🐳","whale2":"🐋","wheel":"🛞","wheel_of_dharma":"☸️","wheelchair":"♿","wheelchair_symbol":"♿","white_cane":"🦯","white_check_mark":"✅","white_circle":"⚪","white_exclamation_mark":"❕","white_flag":"🏳️","white_flower":"💮","white_frowning_face":"☹️","white_haired_man":"👨‍🦳","white_haired_woman":"👩‍🦳","white_heart":"🤍","white_large_square":"⬜","white_medium-small_square":"◽","white_medium_small_square":"◽","white_medium_square":"◻️","white_question_mark":"❔","white_small_square":"▫️","white_square_button":"🔳","white_sun_behind_cloud":"🌥️","white_sun_behind_cloud_with_rain":"🌦️","white_sun_with_small_cloud":"🌤️","wilted_flower":"🥀","wind_blowing_face":"🌬️","wind_chime":"🎐","wind_face":"🌬️","window":"🪟","wine_glass":"🍷","wing":"🪽","wink":"😉","winking_face":"😉","winking_face_with_tongue":"😜","wireless":"🛜","wolf":"🐺","woman":"👩","woman_and_man_holding_hands":"👫","woman_and_man_holding_hands_dark_skin_tone":"👫🏿","woman_and_man_holding_hands_dark_skin_tone_light_skin_tone":"👩🏿‍🤝‍👨🏻","woman_and_man_holding_hands_dark_skin_tone_medium-dark_skin_tone":"👩🏿‍🤝‍👨🏾","woman_and_man_holding_hands_dark_skin_tone_medium-light_skin_tone":"👩🏿‍🤝‍👨🏼","woman_and_man_holding_hands_dark_skin_tone_medium_skin_tone":"👩🏿‍🤝‍👨🏽","woman_and_man_holding_hands_light_skin_tone":"👫🏻","woman_and_man_holding_hands_light_skin_tone_dark_skin_tone":"👩🏻‍🤝‍👨🏿","woman_and_man_holding_hands_light_skin_tone_medium-dark_skin_tone":"👩🏻‍🤝‍👨🏾","woman_and_man_holding_hands_light_skin_tone_medium-light_skin_tone":"👩🏻‍🤝‍👨🏼","woman_and_man_holding_hands_light_skin_tone_medium_skin_tone":"👩🏻‍🤝‍👨🏽","woman_and_man_holding_hands_medium-dark_skin_tone":"👫🏾","woman_and_man_holding_hands_medium-dark_skin_tone_dark_skin_tone":"👩🏾‍🤝‍👨🏿","woman_and_man_holding_hands_medium-dark_skin_tone_light_skin_tone":"👩🏾‍🤝‍👨🏻","woman_and_man_holding_hands_medium-dark_skin_tone_medium-light_skin_tone":"👩🏾‍🤝‍👨🏼","woman_and_man_holding_hands_medium-dark_skin_tone_medium_skin_tone":"👩🏾‍🤝‍👨🏽","woman_and_man_holding_hands_medium-light_skin_tone":"👫🏼","woman_and_man_holding_hands_medium-light_skin_tone_dark_skin_tone":"👩🏼‍🤝‍👨🏿","woman_and_man_holding_hands_medium-light_skin_tone_light_skin_tone":"👩🏼‍🤝‍👨🏻","woman_and_man_holding_hands_medium-light_skin_tone_medium-dark_skin_tone":"👩🏼‍🤝‍👨🏾","woman_and_man_holding_hands_medium-light_skin_tone_medium_skin_tone":"👩🏼‍🤝‍👨🏽","woman_and_man_holding_hands_medium_skin_tone":"👫🏽","woman_and_man_holding_hands_medium_skin_tone_dark_skin_tone":"👩🏽‍🤝‍👨🏿","woman_and_man_holding_hands_medium_skin_tone_light_skin_tone":"👩🏽‍🤝‍👨🏻","woman_and_man_holding_hands_medium_skin_tone_medium-dark_skin_tone":"👩🏽‍🤝‍👨🏾","woman_and_man_holding_hands_medium_skin_tone_medium-light_skin_tone":"👩🏽‍🤝‍👨🏼","woman_artist":"👩‍🎨","woman_artist_dark_skin_tone":"👩🏿‍🎨","woman_artist_light_skin_tone":"👩🏻‍🎨","woman_artist_medium-dark_skin_tone":"👩🏾‍🎨","woman_artist_medium-light_skin_tone":"👩🏼‍🎨","woman_artist_medium_skin_tone":"👩🏽‍🎨","woman_astronaut":"👩‍🚀","woman_astronaut_dark_skin_tone":"👩🏿‍🚀","woman_astronaut_light_skin_tone":"👩🏻‍🚀","woman_astronaut_medium-dark_skin_tone":"👩🏾‍🚀","woman_astronaut_medium-light_skin_tone":"👩🏼‍🚀","woman_astronaut_medium_skin_tone":"👩🏽‍🚀","woman_bald":"👩‍🦲","woman_beard":"🧔‍♀️","woman_biking":"🚴‍♀️","woman_biking_dark_skin_tone":"🚴🏿‍♀️","woman_biking_light_skin_tone":"🚴🏻‍♀️","woman_biking_medium-dark_skin_tone":"🚴🏾‍♀️","woman_biking_medium-light_skin_tone":"🚴🏼‍♀️","woman_biking_medium_skin_tone":"🚴🏽‍♀️","woman_blond_hair":"👱‍♀️","woman_bouncing_ball":"⛹️‍♀️","woman_bouncing_ball_dark_skin_tone":"⛹🏿‍♀️","woman_bouncing_ball_light_skin_tone":"⛹🏻‍♀️","woman_bouncing_ball_medium-dark_skin_tone":"⛹🏾‍♀️","woman_bouncing_ball_medium-light_skin_tone":"⛹🏼‍♀️","woman_bouncing_ball_medium_skin_tone":"⛹🏽‍♀️","woman_bowing":"🙇‍♀️","woman_bowing_dark_skin_tone":"🙇🏿‍♀️","woman_bowing_light_skin_tone":"🙇🏻‍♀️","woman_bowing_medium-dark_skin_tone":"🙇🏾‍♀️","woman_bowing_medium-light_skin_tone":"🙇🏼‍♀️","woman_bowing_medium_skin_tone":"🙇🏽‍♀️","woman_cartwheeling":"🤸‍♀️","woman_cartwheeling_dark_skin_tone":"🤸🏿‍♀️","woman_cartwheeling_light_skin_tone":"🤸🏻‍♀️","woman_cartwheeling_medium-dark_skin_tone":"🤸🏾‍♀️","woman_cartwheeling_medium-light_skin_tone":"🤸🏼‍♀️","woman_cartwheeling_medium_skin_tone":"🤸🏽‍♀️","woman_climbing":"🧗‍♀️","woman_climbing_dark_skin_tone":"🧗🏿‍♀️","woman_climbing_light_skin_tone":"🧗🏻‍♀️","woman_climbing_medium-dark_skin_tone":"🧗🏾‍♀️","woman_climbing_medium-light_skin_tone":"🧗🏼‍♀️","woman_climbing_medium_skin_tone":"🧗🏽‍♀️","woman_construction_worker":"👷‍♀️","woman_construction_worker_dark_skin_tone":"👷🏿‍♀️","woman_construction_worker_light_skin_tone":"👷🏻‍♀️","woman_construction_worker_medium-dark_skin_tone":"👷🏾‍♀️","woman_construction_worker_medium-light_skin_tone":"👷🏼‍♀️","woman_construction_worker_medium_skin_tone":"👷🏽‍♀️","woman_cook":"👩‍🍳","woman_cook_dark_skin_tone":"👩🏿‍🍳","woman_cook_light_skin_tone":"👩🏻‍🍳","woman_cook_medium-dark_skin_tone":"👩🏾‍🍳","woman_cook_medium-light_skin_tone":"👩🏼‍🍳","woman_cook_medium_skin_tone":"👩🏽‍🍳","woman_curly_hair":"👩‍🦱","woman_dancing":"💃","woman_dancing_dark_skin_tone":"💃🏿","woman_dancing_light_skin_tone":"💃🏻","woman_dancing_medium-dark_skin_tone":"💃🏾","woman_dancing_medium-light_skin_tone":"💃🏼","woman_dancing_medium_skin_tone":"💃🏽","woman_dark_skin_tone":"👩🏿","woman_dark_skin_tone_bald":"👩🏿‍🦲","woman_dark_skin_tone_beard":"🧔🏿‍♀️","woman_dark_skin_tone_blond_hair":"👱🏿‍♀️","woman_dark_skin_tone_curly_hair":"👩🏿‍🦱","woman_dark_skin_tone_red_hair":"👩🏿‍🦰","woman_dark_skin_tone_white_hair":"👩🏿‍🦳","woman_detective":"🕵️‍♀️","woman_detective_dark_skin_tone":"🕵🏿‍♀️","woman_detective_light_skin_tone":"🕵🏻‍♀️","woman_detective_medium-dark_skin_tone":"🕵🏾‍♀️","woman_detective_medium-light_skin_tone":"🕵🏼‍♀️","woman_detective_medium_skin_tone":"🕵🏽‍♀️","woman_elf":"🧝‍♀️","woman_elf_dark_skin_tone":"🧝🏿‍♀️","woman_elf_light_skin_tone":"🧝🏻‍♀️","woman_elf_medium-dark_skin_tone":"🧝🏾‍♀️","woman_elf_medium-light_skin_tone":"🧝🏼‍♀️","woman_elf_medium_skin_tone":"🧝🏽‍♀️","woman_facepalming":"🤦‍♀️","woman_facepalming_dark_skin_tone":"🤦🏿‍♀️","woman_facepalming_light_skin_tone":"🤦🏻‍♀️","woman_facepalming_medium-dark_skin_tone":"🤦🏾‍♀️","woman_facepalming_medium-light_skin_tone":"🤦🏼‍♀️","woman_facepalming_medium_skin_tone":"🤦🏽‍♀️","woman_factory_worker":"👩‍🏭","woman_factory_worker_dark_skin_tone":"👩🏿‍🏭","woman_factory_worker_light_skin_tone":"👩🏻‍🏭","woman_factory_worker_medium-dark_skin_tone":"👩🏾‍🏭","woman_factory_worker_medium-light_skin_tone":"👩🏼‍🏭","woman_factory_worker_medium_skin_tone":"👩🏽‍🏭","woman_fairy":"🧚‍♀️","woman_fairy_dark_skin_tone":"🧚🏿‍♀️","woman_fairy_light_skin_tone":"🧚🏻‍♀️","woman_fairy_medium-dark_skin_tone":"🧚🏾‍♀️","woman_fairy_medium-light_skin_tone":"🧚🏼‍♀️","woman_fairy_medium_skin_tone":"🧚🏽‍♀️","woman_farmer":"👩‍🌾","woman_farmer_dark_skin_tone":"👩🏿‍🌾","woman_farmer_light_skin_tone":"👩🏻‍🌾","woman_farmer_medium-dark_skin_tone":"👩🏾‍🌾","woman_farmer_medium-light_skin_tone":"👩🏼‍🌾","woman_farmer_medium_skin_tone":"👩🏽‍🌾","woman_feeding_baby":"👩‍🍼","woman_feeding_baby_dark_skin_tone":"👩🏿‍🍼","woman_feeding_baby_light_skin_tone":"👩🏻‍🍼","woman_feeding_baby_medium-dark_skin_tone":"👩🏾‍🍼","woman_feeding_baby_medium-light_skin_tone":"👩🏼‍🍼","woman_feeding_baby_medium_skin_tone":"👩🏽‍🍼","woman_firefighter":"👩‍🚒","woman_firefighter_dark_skin_tone":"👩🏿‍🚒","woman_firefighter_light_skin_tone":"👩🏻‍🚒","woman_firefighter_medium-dark_skin_tone":"👩🏾‍🚒","woman_firefighter_medium-light_skin_tone":"👩🏼‍🚒","woman_firefighter_medium_skin_tone":"👩🏽‍🚒","woman_frowning":"🙍‍♀️","woman_frowning_dark_skin_tone":"🙍🏿‍♀️","woman_frowning_light_skin_tone":"🙍🏻‍♀️","woman_frowning_medium-dark_skin_tone":"🙍🏾‍♀️","woman_frowning_medium-light_skin_tone":"🙍🏼‍♀️","woman_frowning_medium_skin_tone":"🙍🏽‍♀️","woman_genie":"🧞‍♀️","woman_gesturing_NO":"🙅‍♀️","woman_gesturing_NO_dark_skin_tone":"🙅🏿‍♀️","woman_gesturing_NO_light_skin_tone":"🙅🏻‍♀️","woman_gesturing_NO_medium-dark_skin_tone":"🙅🏾‍♀️","woman_gesturing_NO_medium-light_skin_tone":"🙅🏼‍♀️","woman_gesturing_NO_medium_skin_tone":"🙅🏽‍♀️","woman_gesturing_OK":"🙆‍♀️","woman_gesturing_OK_dark_skin_tone":"🙆🏿‍♀️","woman_gesturing_OK_light_skin_tone":"🙆🏻‍♀️","woman_gesturing_OK_medium-dark_skin_tone":"🙆🏾‍♀️","woman_gesturing_OK_medium-light_skin_tone":"🙆🏼‍♀️","woman_gesturing_OK_medium_skin_tone":"🙆🏽‍♀️","woman_gesturing_no":"🙅‍♀️","woman_gesturing_ok":"🙆‍♀️","woman_getting_haircut":"💇‍♀️","woman_getting_haircut_dark_skin_tone":"💇🏿‍♀️","woman_getting_haircut_light_skin_tone":"💇🏻‍♀️","woman_getting_haircut_medium-dark_skin_tone":"💇🏾‍♀️","woman_getting_haircut_medium-light_skin_tone":"💇🏼‍♀️","woman_getting_haircut_medium_skin_tone":"💇🏽‍♀️","woman_getting_massage":"💆‍♀️","woman_getting_massage_dark_skin_tone":"💆🏿‍♀️","woman_getting_massage_light_skin_tone":"💆🏻‍♀️","woman_getting_massage_medium-dark_skin_tone":"💆🏾‍♀️","woman_getting_massage_medium-light_skin_tone":"💆🏼‍♀️","woman_getting_massage_medium_skin_tone":"💆🏽‍♀️","woman_golfing":"🏌️‍♀️","woman_golfing_dark_skin_tone":"🏌🏿‍♀️","woman_golfing_light_skin_tone":"🏌🏻‍♀️","woman_golfing_medium-dark_skin_tone":"🏌🏾‍♀️","woman_golfing_medium-light_skin_tone":"🏌🏼‍♀️","woman_golfing_medium_skin_tone":"🏌🏽‍♀️","woman_guard":"💂‍♀️","woman_guard_dark_skin_tone":"💂🏿‍♀️","woman_guard_light_skin_tone":"💂🏻‍♀️","woman_guard_medium-dark_skin_tone":"💂🏾‍♀️","woman_guard_medium-light_skin_tone":"💂🏼‍♀️","woman_guard_medium_skin_tone":"💂🏽‍♀️","woman_health_worker":"👩‍⚕️","woman_health_worker_dark_skin_tone":"👩🏿‍⚕️","woman_health_worker_light_skin_tone":"👩🏻‍⚕️","woman_health_worker_medium-dark_skin_tone":"👩🏾‍⚕️","woman_health_worker_medium-light_skin_tone":"👩🏼‍⚕️","woman_health_worker_medium_skin_tone":"👩🏽‍⚕️","woman_in_lotus_position":"🧘‍♀️","woman_in_lotus_position_dark_skin_tone":"🧘🏿‍♀️","woman_in_lotus_position_light_skin_tone":"🧘🏻‍♀️","woman_in_lotus_position_medium-dark_skin_tone":"🧘🏾‍♀️","woman_in_lotus_position_medium-light_skin_tone":"🧘🏼‍♀️","woman_in_lotus_position_medium_skin_tone":"🧘🏽‍♀️","woman_in_manual_wheelchair":"👩‍🦽","woman_in_manual_wheelchair_dark_skin_tone":"👩🏿‍🦽","woman_in_manual_wheelchair_facing_right":"👩‍🦽‍➡️","woman_in_manual_wheelchair_facing_right_dark_skin_tone":"👩🏿‍🦽‍➡️","woman_in_manual_wheelchair_facing_right_light_skin_tone":"👩🏻‍🦽‍➡️","woman_in_manual_wheelchair_facing_right_medium-dark_skin_tone":"👩🏾‍🦽‍➡️","woman_in_manual_wheelchair_facing_right_medium-light_skin_tone":"👩🏼‍🦽‍➡️","woman_in_manual_wheelchair_facing_right_medium_skin_tone":"👩🏽‍🦽‍➡️","woman_in_manual_wheelchair_light_skin_tone":"👩🏻‍🦽","woman_in_manual_wheelchair_medium-dark_skin_tone":"👩🏾‍🦽","woman_in_manual_wheelchair_medium-light_skin_tone":"👩🏼‍🦽","woman_in_manual_wheelchair_medium_skin_tone":"👩🏽‍🦽","woman_in_motorized_wheelchair":"👩‍🦼","woman_in_motorized_wheelchair_dark_skin_tone":"👩🏿‍🦼","woman_in_motorized_wheelchair_facing_right":"👩‍🦼‍➡️","woman_in_motorized_wheelchair_facing_right_dark_skin_tone":"👩🏿‍🦼‍➡️","woman_in_motorized_wheelchair_facing_right_light_skin_tone":"👩🏻‍🦼‍➡️","woman_in_motorized_wheelchair_facing_right_medium-dark_skin_tone":"👩🏾‍🦼‍➡️","woman_in_motorized_wheelchair_facing_right_medium-light_skin_tone":"👩🏼‍🦼‍➡️","woman_in_motorized_wheelchair_facing_right_medium_skin_tone":"👩🏽‍🦼‍➡️","woman_in_motorized_wheelchair_light_skin_tone":"👩🏻‍🦼","woman_in_motorized_wheelchair_medium-dark_skin_tone":"👩🏾‍🦼","woman_in_motorized_wheelchair_medium-light_skin_tone":"👩🏼‍🦼","woman_in_motorized_wheelchair_medium_skin_tone":"👩🏽‍🦼","woman_in_steamy_room":"🧖‍♀️","woman_in_steamy_room_dark_skin_tone":"🧖🏿‍♀️","woman_in_steamy_room_light_skin_tone":"🧖🏻‍♀️","woman_in_steamy_room_medium-dark_skin_tone":"🧖🏾‍♀️","woman_in_steamy_room_medium-light_skin_tone":"🧖🏼‍♀️","woman_in_steamy_room_medium_skin_tone":"🧖🏽‍♀️","woman_in_tuxedo":"🤵‍♀️","woman_in_tuxedo_dark_skin_tone":"🤵🏿‍♀️","woman_in_tuxedo_light_skin_tone":"🤵🏻‍♀️","woman_in_tuxedo_medium-dark_skin_tone":"🤵🏾‍♀️","woman_in_tuxedo_medium-light_skin_tone":"🤵🏼‍♀️","woman_in_tuxedo_medium_skin_tone":"🤵🏽‍♀️","woman_judge":"👩‍⚖️","woman_judge_dark_skin_tone":"👩🏿‍⚖️","woman_judge_light_skin_tone":"👩🏻‍⚖️","woman_judge_medium-dark_skin_tone":"👩🏾‍⚖️","woman_judge_medium-light_skin_tone":"👩🏼‍⚖️","woman_judge_medium_skin_tone":"👩🏽‍⚖️","woman_juggling":"🤹‍♀️","woman_juggling_dark_skin_tone":"🤹🏿‍♀️","woman_juggling_light_skin_tone":"🤹🏻‍♀️","woman_juggling_medium-dark_skin_tone":"🤹🏾‍♀️","woman_juggling_medium-light_skin_tone":"🤹🏼‍♀️","woman_juggling_medium_skin_tone":"🤹🏽‍♀️","woman_kneeling":"🧎‍♀️","woman_kneeling_dark_skin_tone":"🧎🏿‍♀️","woman_kneeling_facing_right":"🧎‍♀️‍➡️","woman_kneeling_facing_right_dark_skin_tone":"🧎🏿‍♀️‍➡️","woman_kneeling_facing_right_light_skin_tone":"🧎🏻‍♀️‍➡️","woman_kneeling_facing_right_medium-dark_skin_tone":"🧎🏾‍♀️‍➡️","woman_kneeling_facing_right_medium-light_skin_tone":"🧎🏼‍♀️‍➡️","woman_kneeling_facing_right_medium_skin_tone":"🧎🏽‍♀️‍➡️","woman_kneeling_light_skin_tone":"🧎🏻‍♀️","woman_kneeling_medium-dark_skin_tone":"🧎🏾‍♀️","woman_kneeling_medium-light_skin_tone":"🧎🏼‍♀️","woman_kneeling_medium_skin_tone":"🧎🏽‍♀️","woman_lifting_weights":"🏋️‍♀️","woman_lifting_weights_dark_skin_tone":"🏋🏿‍♀️","woman_lifting_weights_light_skin_tone":"🏋🏻‍♀️","woman_lifting_weights_medium-dark_skin_tone":"🏋🏾‍♀️","woman_lifting_weights_medium-light_skin_tone":"🏋🏼‍♀️","woman_lifting_weights_medium_skin_tone":"🏋🏽‍♀️","woman_light_skin_tone":"👩🏻","woman_light_skin_tone_bald":"👩🏻‍🦲","woman_light_skin_tone_beard":"🧔🏻‍♀️","woman_light_skin_tone_blond_hair":"👱🏻‍♀️","woman_light_skin_tone_curly_hair":"👩🏻‍🦱","woman_light_skin_tone_red_hair":"👩🏻‍🦰","woman_light_skin_tone_white_hair":"👩🏻‍🦳","woman_mage":"🧙‍♀️","woman_mage_dark_skin_tone":"🧙🏿‍♀️","woman_mage_light_skin_tone":"🧙🏻‍♀️","woman_mage_medium-dark_skin_tone":"🧙🏾‍♀️","woman_mage_medium-light_skin_tone":"🧙🏼‍♀️","woman_mage_medium_skin_tone":"🧙🏽‍♀️","woman_mechanic":"👩‍🔧","woman_mechanic_dark_skin_tone":"👩🏿‍🔧","woman_mechanic_light_skin_tone":"👩🏻‍🔧","woman_mechanic_medium-dark_skin_tone":"👩🏾‍🔧","woman_mechanic_medium-light_skin_tone":"👩🏼‍🔧","woman_mechanic_medium_skin_tone":"👩🏽‍🔧","woman_medium-dark_skin_tone":"👩🏾","woman_medium-dark_skin_tone_bald":"👩🏾‍🦲","woman_medium-dark_skin_tone_beard":"🧔🏾‍♀️","woman_medium-dark_skin_tone_blond_hair":"👱🏾‍♀️","woman_medium-dark_skin_tone_curly_hair":"👩🏾‍🦱","woman_medium-dark_skin_tone_red_hair":"👩🏾‍🦰","woman_medium-dark_skin_tone_white_hair":"👩🏾‍🦳","woman_medium-light_skin_tone":"👩🏼","woman_medium-light_skin_tone_bald":"👩🏼‍🦲","woman_medium-light_skin_tone_beard":"🧔🏼‍♀️","woman_medium-light_skin_tone_blond_hair":"👱🏼‍♀️","woman_medium-light_skin_tone_curly_hair":"👩🏼‍🦱","woman_medium-light_skin_tone_red_hair":"👩🏼‍🦰","woman_medium-light_skin_tone_white_hair":"👩🏼‍🦳","woman_medium_skin_tone":"👩🏽","woman_medium_skin_tone_bald":"👩🏽‍🦲","woman_medium_skin_tone_beard":"🧔🏽‍♀️","woman_medium_skin_tone_blond_hair":"👱🏽‍♀️","woman_medium_skin_tone_curly_hair":"👩🏽‍🦱","woman_medium_skin_tone_red_hair":"👩🏽‍🦰","woman_medium_skin_tone_white_hair":"👩🏽‍🦳","woman_mountain_biking":"🚵‍♀️","woman_mountain_biking_dark_skin_tone":"🚵🏿‍♀️","woman_mountain_biking_light_skin_tone":"🚵🏻‍♀️","woman_mountain_biking_medium-dark_skin_tone":"🚵🏾‍♀️","woman_mountain_biking_medium-light_skin_tone":"🚵🏼‍♀️","woman_mountain_biking_medium_skin_tone":"🚵🏽‍♀️","woman_office_worker":"👩‍💼","woman_office_worker_dark_skin_tone":"👩🏿‍💼","woman_office_worker_light_skin_tone":"👩🏻‍💼","woman_office_worker_medium-dark_skin_tone":"👩🏾‍💼","woman_office_worker_medium-light_skin_tone":"👩🏼‍💼","woman_office_worker_medium_skin_tone":"👩🏽‍💼","woman_pilot":"👩‍✈️","woman_pilot_dark_skin_tone":"👩🏿‍✈️","woman_pilot_light_skin_tone":"👩🏻‍✈️","woman_pilot_medium-dark_skin_tone":"👩🏾‍✈️","woman_pilot_medium-light_skin_tone":"👩🏼‍✈️","woman_pilot_medium_skin_tone":"👩🏽‍✈️","woman_playing_handball":"🤾‍♀️","woman_playing_handball_dark_skin_tone":"🤾🏿‍♀️","woman_playing_handball_light_skin_tone":"🤾🏻‍♀️","woman_playing_handball_medium-dark_skin_tone":"🤾🏾‍♀️","woman_playing_handball_medium-light_skin_tone":"🤾🏼‍♀️","woman_playing_handball_medium_skin_tone":"🤾🏽‍♀️","woman_playing_water_polo":"🤽‍♀️","woman_playing_water_polo_dark_skin_tone":"🤽🏿‍♀️","woman_playing_water_polo_light_skin_tone":"🤽🏻‍♀️","woman_playing_water_polo_medium-dark_skin_tone":"🤽🏾‍♀️","woman_playing_water_polo_medium-light_skin_tone":"🤽🏼‍♀️","woman_playing_water_polo_medium_skin_tone":"🤽🏽‍♀️","woman_police_officer":"👮‍♀️","woman_police_officer_dark_skin_tone":"👮🏿‍♀️","woman_police_officer_light_skin_tone":"👮🏻‍♀️","woman_police_officer_medium-dark_skin_tone":"👮🏾‍♀️","woman_police_officer_medium-light_skin_tone":"👮🏼‍♀️","woman_police_officer_medium_skin_tone":"👮🏽‍♀️","woman_pouting":"🙎‍♀️","woman_pouting_dark_skin_tone":"🙎🏿‍♀️","woman_pouting_light_skin_tone":"🙎🏻‍♀️","woman_pouting_medium-dark_skin_tone":"🙎🏾‍♀️","woman_pouting_medium-light_skin_tone":"🙎🏼‍♀️","woman_pouting_medium_skin_tone":"🙎🏽‍♀️","woman_raising_hand":"🙋‍♀️","woman_raising_hand_dark_skin_tone":"🙋🏿‍♀️","woman_raising_hand_light_skin_tone":"🙋🏻‍♀️","woman_raising_hand_medium-dark_skin_tone":"🙋🏾‍♀️","woman_raising_hand_medium-light_skin_tone":"🙋🏼‍♀️","woman_raising_hand_medium_skin_tone":"🙋🏽‍♀️","woman_red_hair":"👩‍🦰","woman_rowing_boat":"🚣‍♀️","woman_rowing_boat_dark_skin_tone":"🚣🏿‍♀️","woman_rowing_boat_light_skin_tone":"🚣🏻‍♀️","woman_rowing_boat_medium-dark_skin_tone":"🚣🏾‍♀️","woman_rowing_boat_medium-light_skin_tone":"🚣🏼‍♀️","woman_rowing_boat_medium_skin_tone":"🚣🏽‍♀️","woman_running":"🏃‍♀️","woman_running_dark_skin_tone":"🏃🏿‍♀️","woman_running_facing_right":"🏃‍♀️‍➡️","woman_running_facing_right_dark_skin_tone":"🏃🏿‍♀️‍➡️","woman_running_facing_right_light_skin_tone":"🏃🏻‍♀️‍➡️","woman_running_facing_right_medium-dark_skin_tone":"🏃🏾‍♀️‍➡️","woman_running_facing_right_medium-light_skin_tone":"🏃🏼‍♀️‍➡️","woman_running_facing_right_medium_skin_tone":"🏃🏽‍♀️‍➡️","woman_running_light_skin_tone":"🏃🏻‍♀️","woman_running_medium-dark_skin_tone":"🏃🏾‍♀️","woman_running_medium-light_skin_tone":"🏃🏼‍♀️","woman_running_medium_skin_tone":"🏃🏽‍♀️","woman_scientist":"👩‍🔬","woman_scientist_dark_skin_tone":"👩🏿‍🔬","woman_scientist_light_skin_tone":"👩🏻‍🔬","woman_scientist_medium-dark_skin_tone":"👩🏾‍🔬","woman_scientist_medium-light_skin_tone":"👩🏼‍🔬","woman_scientist_medium_skin_tone":"👩🏽‍🔬","woman_shrugging":"🤷‍♀️","woman_shrugging_dark_skin_tone":"🤷🏿‍♀️","woman_shrugging_light_skin_tone":"🤷🏻‍♀️","woman_shrugging_medium-dark_skin_tone":"🤷🏾‍♀️","woman_shrugging_medium-light_skin_tone":"🤷🏼‍♀️","woman_shrugging_medium_skin_tone":"🤷🏽‍♀️","woman_singer":"👩‍🎤","woman_singer_dark_skin_tone":"👩🏿‍🎤","woman_singer_light_skin_tone":"👩🏻‍🎤","woman_singer_medium-dark_skin_tone":"👩🏾‍🎤","woman_singer_medium-light_skin_tone":"👩🏼‍🎤","woman_singer_medium_skin_tone":"👩🏽‍🎤","woman_standing":"🧍‍♀️","woman_standing_dark_skin_tone":"🧍🏿‍♀️","woman_standing_light_skin_tone":"🧍🏻‍♀️","woman_standing_medium-dark_skin_tone":"🧍🏾‍♀️","woman_standing_medium-light_skin_tone":"🧍🏼‍♀️","woman_standing_medium_skin_tone":"🧍🏽‍♀️","woman_student":"👩‍🎓","woman_student_dark_skin_tone":"👩🏿‍🎓","woman_student_light_skin_tone":"👩🏻‍🎓","woman_student_medium-dark_skin_tone":"👩🏾‍🎓","woman_student_medium-light_skin_tone":"👩🏼‍🎓","woman_student_medium_skin_tone":"👩🏽‍🎓","woman_superhero":"🦸‍♀️","woman_superhero_dark_skin_tone":"🦸🏿‍♀️","woman_superhero_light_skin_tone":"🦸🏻‍♀️","woman_superhero_medium-dark_skin_tone":"🦸🏾‍♀️","woman_superhero_medium-light_skin_tone":"🦸🏼‍♀️","woman_superhero_medium_skin_tone":"🦸🏽‍♀️","woman_supervillain":"🦹‍♀️","woman_supervillain_dark_skin_tone":"🦹🏿‍♀️","woman_supervillain_light_skin_tone":"🦹🏻‍♀️","woman_supervillain_medium-dark_skin_tone":"🦹🏾‍♀️","woman_supervillain_medium-light_skin_tone":"🦹🏼‍♀️","woman_supervillain_medium_skin_tone":"🦹🏽‍♀️","woman_surfing":"🏄‍♀️","woman_surfing_dark_skin_tone":"🏄🏿‍♀️","woman_surfing_light_skin_tone":"🏄🏻‍♀️","woman_surfing_medium-dark_skin_tone":"🏄🏾‍♀️","woman_surfing_medium-light_skin_tone":"🏄🏼‍♀️","woman_surfing_medium_skin_tone":"🏄🏽‍♀️","woman_swimming":"🏊‍♀️","woman_swimming_dark_skin_tone":"🏊🏿‍♀️","woman_swimming_light_skin_tone":"🏊🏻‍♀️","woman_swimming_medium-dark_skin_tone":"🏊🏾‍♀️","woman_swimming_medium-light_skin_tone":"🏊🏼‍♀️","woman_swimming_medium_skin_tone":"🏊🏽‍♀️","woman_teacher":"👩‍🏫","woman_teacher_dark_skin_tone":"👩🏿‍🏫","woman_teacher_light_skin_tone":"👩🏻‍🏫","woman_teacher_medium-dark_skin_tone":"👩🏾‍🏫","woman_teacher_medium-light_skin_tone":"👩🏼‍🏫","woman_teacher_medium_skin_tone":"👩🏽‍🏫","woman_technologist":"👩‍💻","woman_technologist_dark_skin_tone":"👩🏿‍💻","woman_technologist_light_skin_tone":"👩🏻‍💻","woman_technologist_medium-dark_skin_tone":"👩🏾‍💻","woman_technologist_medium-light_skin_tone":"👩🏼‍💻","woman_technologist_medium_skin_tone":"👩🏽‍💻","woman_tipping_hand":"💁‍♀️","woman_tipping_hand_dark_skin_tone":"💁🏿‍♀️","woman_tipping_hand_light_skin_tone":"💁🏻‍♀️","woman_tipping_hand_medium-dark_skin_tone":"💁🏾‍♀️","woman_tipping_hand_medium-light_skin_tone":"💁🏼‍♀️","woman_tipping_hand_medium_skin_tone":"💁🏽‍♀️","woman_vampire":"🧛‍♀️","woman_vampire_dark_skin_tone":"🧛🏿‍♀️","woman_vampire_light_skin_tone":"🧛🏻‍♀️","woman_vampire_medium-dark_skin_tone":"🧛🏾‍♀️","woman_vampire_medium-light_skin_tone":"🧛🏼‍♀️","woman_vampire_medium_skin_tone":"🧛🏽‍♀️","woman_walking":"🚶‍♀️","woman_walking_dark_skin_tone":"🚶🏿‍♀️","woman_walking_facing_right":"🚶‍♀️‍➡️","woman_walking_facing_right_dark_skin_tone":"🚶🏿‍♀️‍➡️","woman_walking_facing_right_light_skin_tone":"🚶🏻‍♀️‍➡️","woman_walking_facing_right_medium-dark_skin_tone":"🚶🏾‍♀️‍➡️","woman_walking_facing_right_medium-light_skin_tone":"🚶🏼‍♀️‍➡️","woman_walking_facing_right_medium_skin_tone":"🚶🏽‍♀️‍➡️","woman_walking_light_skin_tone":"🚶🏻‍♀️","woman_walking_medium-dark_skin_tone":"🚶🏾‍♀️","woman_walking_medium-light_skin_tone":"🚶🏼‍♀️","woman_walking_medium_skin_tone":"🚶🏽‍♀️","woman_wearing_turban":"👳‍♀️","woman_wearing_turban_dark_skin_tone":"👳🏿‍♀️","woman_wearing_turban_light_skin_tone":"👳🏻‍♀️","woman_wearing_turban_medium-dark_skin_tone":"👳🏾‍♀️","woman_wearing_turban_medium-light_skin_tone":"👳🏼‍♀️","woman_wearing_turban_medium_skin_tone":"👳🏽‍♀️","woman_white_hair":"👩‍🦳","woman_with_headscarf":"🧕","woman_with_headscarf_dark_skin_tone":"🧕🏿","woman_with_headscarf_light_skin_tone":"🧕🏻","woman_with_headscarf_medium-dark_skin_tone":"🧕🏾","woman_with_headscarf_medium-light_skin_tone":"🧕🏼","woman_with_headscarf_medium_skin_tone":"🧕🏽","woman_with_probing_cane":"👩‍🦯","woman_with_turban":"👳‍♀️","woman_with_veil":"👰‍♀️","woman_with_veil_dark_skin_tone":"👰🏿‍♀️","woman_with_veil_light_skin_tone":"👰🏻‍♀️","woman_with_veil_medium-dark_skin_tone":"👰🏾‍♀️","woman_with_veil_medium-light_skin_tone":"👰🏼‍♀️","woman_with_veil_medium_skin_tone":"👰🏽‍♀️","woman_with_white_cane":"👩‍🦯","woman_with_white_cane_dark_skin_tone":"👩🏿‍🦯","woman_with_white_cane_facing_right":"👩‍🦯‍➡️","woman_with_white_cane_facing_right_dark_skin_tone":"👩🏿‍🦯‍➡️","woman_with_white_cane_facing_right_light_skin_tone":"👩🏻‍🦯‍➡️","woman_with_white_cane_facing_right_medium-dark_skin_tone":"👩🏾‍🦯‍➡️","woman_with_white_cane_facing_right_medium-light_skin_tone":"👩🏼‍🦯‍➡️","woman_with_white_cane_facing_right_medium_skin_tone":"👩🏽‍🦯‍➡️","woman_with_white_cane_light_skin_tone":"👩🏻‍🦯","woman_with_white_cane_medium-dark_skin_tone":"👩🏾‍🦯","woman_with_white_cane_medium-light_skin_tone":"👩🏼‍🦯","woman_with_white_cane_medium_skin_tone":"👩🏽‍🦯","woman_zombie":"🧟‍♀️","womans_boot":"👢","womans_clothes":"👚","womans_hat":"👒","womans_sandal":"👡","woman’s_boot":"👢","woman’s_clothes":"👚","woman’s_hat":"👒","woman’s_sandal":"👡","women_holding_hands":"👭","women_holding_hands_dark_skin_tone":"👭🏿","women_holding_hands_dark_skin_tone_light_skin_tone":"👩🏿‍🤝‍👩🏻","women_holding_hands_dark_skin_tone_medium-dark_skin_tone":"👩🏿‍🤝‍👩🏾","women_holding_hands_dark_skin_tone_medium-light_skin_tone":"👩🏿‍🤝‍👩🏼","women_holding_hands_dark_skin_tone_medium_skin_tone":"👩🏿‍🤝‍👩🏽","women_holding_hands_light_skin_tone":"👭🏻","women_holding_hands_light_skin_tone_dark_skin_tone":"👩🏻‍🤝‍👩🏿","women_holding_hands_light_skin_tone_medium-dark_skin_tone":"👩🏻‍🤝‍👩🏾","women_holding_hands_light_skin_tone_medium-light_skin_tone":"👩🏻‍🤝‍👩🏼","women_holding_hands_light_skin_tone_medium_skin_tone":"👩🏻‍🤝‍👩🏽","women_holding_hands_medium-dark_skin_tone":"👭🏾","women_holding_hands_medium-dark_skin_tone_dark_skin_tone":"👩🏾‍🤝‍👩🏿","women_holding_hands_medium-dark_skin_tone_light_skin_tone":"👩🏾‍🤝‍👩🏻","women_holding_hands_medium-dark_skin_tone_medium-light_skin_tone":"👩🏾‍🤝‍👩🏼","women_holding_hands_medium-dark_skin_tone_medium_skin_tone":"👩🏾‍🤝‍👩🏽","women_holding_hands_medium-light_skin_tone":"👭🏼","women_holding_hands_medium-light_skin_tone_dark_skin_tone":"👩🏼‍🤝‍👩🏿","women_holding_hands_medium-light_skin_tone_light_skin_tone":"👩🏼‍🤝‍👩🏻","women_holding_hands_medium-light_skin_tone_medium-dark_skin_tone":"👩🏼‍🤝‍👩🏾","women_holding_hands_medium-light_skin_tone_medium_skin_tone":"👩🏼‍🤝‍👩🏽","women_holding_hands_medium_skin_tone":"👭🏽","women_holding_hands_medium_skin_tone_dark_skin_tone":"👩🏽‍🤝‍👩🏿","women_holding_hands_medium_skin_tone_light_skin_tone":"👩🏽‍🤝‍👩🏻","women_holding_hands_medium_skin_tone_medium-dark_skin_tone":"👩🏽‍🤝‍👩🏾","women_holding_hands_medium_skin_tone_medium-light_skin_tone":"👩🏽‍🤝‍👩🏼","women_with_bunny_ears":"👯‍♀️","women_with_bunny_ears_dark_skin_tone":"👯🏿‍♀️","women_with_bunny_ears_dark_skin_tone_light_skin_tone":"👩🏿‍🐰‍👩🏻","women_with_bunny_ears_dark_skin_tone_medium-dark_skin_tone":"👩🏿‍🐰‍👩🏾","women_with_bunny_ears_dark_skin_tone_medium-light_skin_tone":"👩🏿‍🐰‍👩🏼","women_with_bunny_ears_dark_skin_tone_medium_skin_tone":"👩🏿‍🐰‍👩🏽","women_with_bunny_ears_light_skin_tone":"👯🏻‍♀️","women_with_bunny_ears_light_skin_tone_dark_skin_tone":"👩🏻‍🐰‍👩🏿","women_with_bunny_ears_light_skin_tone_medium-dark_skin_tone":"👩🏻‍🐰‍👩🏾","women_with_bunny_ears_light_skin_tone_medium-light_skin_tone":"👩🏻‍🐰‍👩🏼","women_with_bunny_ears_light_skin_tone_medium_skin_tone":"👩🏻‍🐰‍👩🏽","women_with_bunny_ears_medium-dark_skin_tone":"👯🏾‍♀️","women_with_bunny_ears_medium-dark_skin_tone_dark_skin_tone":"👩🏾‍🐰‍👩🏿","women_with_bunny_ears_medium-dark_skin_tone_light_skin_tone":"👩🏾‍🐰‍👩🏻","women_with_bunny_ears_medium-dark_skin_tone_medium-light_skin_tone":"👩🏾‍🐰‍👩🏼","women_with_bunny_ears_medium-dark_skin_tone_medium_skin_tone":"👩🏾‍🐰‍👩🏽","women_with_bunny_ears_medium-light_skin_tone":"👯🏼‍♀️","women_with_bunny_ears_medium-light_skin_tone_dark_skin_tone":"👩🏼‍🐰‍👩🏿","women_with_bunny_ears_medium-light_skin_tone_light_skin_tone":"👩🏼‍🐰‍👩🏻","women_with_bunny_ears_medium-light_skin_tone_medium-dark_skin_tone":"👩🏼‍🐰‍👩🏾","women_with_bunny_ears_medium-light_skin_tone_medium_skin_tone":"👩🏼‍🐰‍👩🏽","women_with_bunny_ears_medium_skin_tone":"👯🏽‍♀️","women_with_bunny_ears_medium_skin_tone_dark_skin_tone":"👩🏽‍🐰‍👩🏿","women_with_bunny_ears_medium_skin_tone_light_skin_tone":"👩🏽‍🐰‍👩🏻","women_with_bunny_ears_medium_skin_tone_medium-dark_skin_tone":"👩🏽‍🐰‍👩🏾","women_with_bunny_ears_medium_skin_tone_medium-light_skin_tone":"👩🏽‍🐰‍👩🏼","women_wrestling":"🤼‍♀️","women_wrestling_dark_skin_tone":"🤼🏿‍♀️","women_wrestling_dark_skin_tone_light_skin_tone":"👩🏿‍🫯‍👩🏻","women_wrestling_dark_skin_tone_medium-dark_skin_tone":"👩🏿‍🫯‍👩🏾","women_wrestling_dark_skin_tone_medium-light_skin_tone":"👩🏿‍🫯‍👩🏼","women_wrestling_dark_skin_tone_medium_skin_tone":"👩🏿‍🫯‍👩🏽","women_wrestling_light_skin_tone":"🤼🏻‍♀️","women_wrestling_light_skin_tone_dark_skin_tone":"👩🏻‍🫯‍👩🏿","women_wrestling_light_skin_tone_medium-dark_skin_tone":"👩🏻‍🫯‍👩🏾","women_wrestling_light_skin_tone_medium-light_skin_tone":"👩🏻‍🫯‍👩🏼","women_wrestling_light_skin_tone_medium_skin_tone":"👩🏻‍🫯‍👩🏽","women_wrestling_medium-dark_skin_tone":"🤼🏾‍♀️","women_wrestling_medium-dark_skin_tone_dark_skin_tone":"👩🏾‍🫯‍👩🏿","women_wrestling_medium-dark_skin_tone_light_skin_tone":"👩🏾‍🫯‍👩🏻","women_wrestling_medium-dark_skin_tone_medium-light_skin_tone":"👩🏾‍🫯‍👩🏼","women_wrestling_medium-dark_skin_tone_medium_skin_tone":"👩🏾‍🫯‍👩🏽","women_wrestling_medium-light_skin_tone":"🤼🏼‍♀️","women_wrestling_medium-light_skin_tone_dark_skin_tone":"👩🏼‍🫯‍👩🏿","women_wrestling_medium-light_skin_tone_light_skin_tone":"👩🏼‍🫯‍👩🏻","women_wrestling_medium-light_skin_tone_medium-dark_skin_tone":"👩🏼‍🫯‍👩🏾","women_wrestling_medium-light_skin_tone_medium_skin_tone":"👩🏼‍🫯‍👩🏽","women_wrestling_medium_skin_tone":"🤼🏽‍♀️","women_wrestling_medium_skin_tone_dark_skin_tone":"👩🏽‍🫯‍👩🏿","women_wrestling_medium_skin_tone_light_skin_tone":"👩🏽‍🫯‍👩🏻","women_wrestling_medium_skin_tone_medium-dark_skin_tone":"👩🏽‍🫯‍👩🏾","women_wrestling_medium_skin_tone_medium-light_skin_tone":"👩🏽‍🫯‍👩🏼","womens":"🚺","womens_room":"🚺","women’s_room":"🚺","wood":"🪵","woozy_face":"🥴","world_map":"🗺️","worm":"🪱","worried":"😟","worried_face":"😟","wrapped_gift":"🎁","wrench":"🔧","wrestling":"🤼","writing_hand":"✍️","writing_hand_dark_skin_tone":"✍🏿","writing_hand_light_skin_tone":"✍🏻","writing_hand_medium-dark_skin_tone":"✍🏾","writing_hand_medium-light_skin_tone":"✍🏼","writing_hand_medium_skin_tone":"✍🏽","x":"❌","x-ray":"🩻","x_ray":"🩻","yarn":"🧶","yawning_face":"🥱","yellow_circle":"🟡","yellow_heart":"💛","yellow_square":"🟨","yemen":"🇾🇪","yen":"💴","yen_banknote":"💴","yin_yang":"☯️","yo-yo":"🪀","yo_yo":"🪀","yum":"😋","zambia":"🇿🇲","zany_face":"🤪","zap":"⚡","zebra":"🦓","zero":"0️⃣","zimbabwe":"🇿🇼","zipper-mouth_face":"🤐","zipper_mouth_face":"🤐","zombie":"🧟","zombie_man":"🧟‍♂️","zombie_woman":"🧟‍♀️","zzz":"💤","Åland_Islands":"🇦🇽"}); diff --git a/molodetz/static/js/utils/ApiTester.js b/molodetz/static/js/utils/ApiTester.js new file mode 100644 index 0000000..e5833e5 --- /dev/null +++ b/molodetz/static/js/utils/ApiTester.js @@ -0,0 +1,63 @@ +// retoor +import { LoadingButton } from "./LoadingButton.js"; + +export class ApiTester { + constructor() { + document.querySelectorAll("[data-endpoint] [data-send]").forEach((button) => { + button.addEventListener("click", () => this.send(button)); + }); + } + + async send(button) { + const section = button.closest("[data-endpoint]"); + const output = section.querySelector("[data-response]"); + if (section.dataset.destructive === "1") { + const accepted = await window.app.dialog.confirm("Dit verzoek wijzigt echte data. Doorgaan?"); + if (!accepted) { + return; + } + } + let path = section.dataset.path; + const query = new URLSearchParams(); + const body = {}; + section.querySelectorAll("[data-param]").forEach((input) => { + const name = input.dataset.param; + const value = input.value; + if (input.dataset.location === "path") { + path = path.replace(`{${name}}`, encodeURIComponent(value)); + } else if (input.dataset.location === "query") { + if (value !== "") { + query.set(name, value); + } + } else { + body[name] = value; + } + }); + const method = section.dataset.method; + const url = query.toString() ? `${path}?${query}` : path; + const options = { method, credentials: "same-origin", headers: { Accept: "application/json" } }; + if (method !== "GET") { + options.headers["Content-Type"] = "application/json"; + options.body = JSON.stringify(body); + } + LoadingButton.setLoading(button, true); + const started = performance.now(); + try { + const response = await fetch(url, options); + const text = await response.text(); + let pretty = text; + try { + pretty = JSON.stringify(JSON.parse(text), null, 2); + } catch { + pretty = text.slice(0, 4000); + } + output.textContent = `${response.status} ${response.statusText} (${Math.round(performance.now() - started)} ms)\n\n${pretty}`; + } catch (error) { + output.textContent = `Fout: ${error.message}`; + } finally { + LoadingButton.setLoading(button, false); + } + } +} + +window.apiTester = new ApiTester(); diff --git a/molodetz/static/js/utils/AssetUrl.js b/molodetz/static/js/utils/AssetUrl.js new file mode 100644 index 0000000..93ed46f --- /dev/null +++ b/molodetz/static/js/utils/AssetUrl.js @@ -0,0 +1,16 @@ +// retoor +export class AssetUrl { + static version() { + const meta = document.querySelector('meta[name="asset-version"]'); + return meta ? meta.content : ""; + } + + static resolve(path) { + if (!path.startsWith("/static/") || path.startsWith("/static/uploads/")) { + return path; + } + return `/static/v${AssetUrl.version()}/${path.slice("/static/".length)}`; + } +} + +export const assetUrl = (path) => AssetUrl.resolve(path); diff --git a/molodetz/static/js/utils/ContentEnhancer.js b/molodetz/static/js/utils/ContentEnhancer.js new file mode 100644 index 0000000..f658e05 --- /dev/null +++ b/molodetz/static/js/utils/ContentEnhancer.js @@ -0,0 +1,10 @@ +// retoor +export class ContentEnhancer { + constructor() { + document.querySelectorAll("[data-enhance] pre > code, [data-docs-content] pre > code").forEach((code) => { + const host = document.createElement("dp-code"); + code.parentElement.replaceWith(host); + host.appendChild(code.parentElement); + }); + } +} diff --git a/molodetz/static/js/utils/ContentRenderer.js b/molodetz/static/js/utils/ContentRenderer.js new file mode 100644 index 0000000..c907e67 --- /dev/null +++ b/molodetz/static/js/utils/ContentRenderer.js @@ -0,0 +1,23 @@ +// retoor +import { marked } from "../../vendor/marked.esm.js"; +import DOMPurify from "../../vendor/purify.es.mjs"; +import { EMOJI_MAP } from "../generated/EmojiMap.js"; + +export class ContentRenderer { + static ALLOWED_URI = /^(?:(?:https?|mailto|tel):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i; + + static normalize(text) { + return (text || "").replace(/[\u2013\u2014]/g, "-").replace(/:([a-z0-9_+-]+):/g, (match, name) => EMOJI_MAP[name] || match); + } + + static render(text, { inline = false } = {}) { + try { + const source = ContentRenderer.normalize(text); + const html = inline ? marked.parseInline(source) : marked.parse(source, { gfm: true, breaks: true }); + return DOMPurify.sanitize(html, { ALLOWED_URI_REGEXP: ContentRenderer.ALLOWED_URI }); + } catch (error) { + console.error("render failed", error); + return ""; + } + } +} diff --git a/molodetz/static/js/utils/EditorPreview.js b/molodetz/static/js/utils/EditorPreview.js new file mode 100644 index 0000000..50caaae --- /dev/null +++ b/molodetz/static/js/utils/EditorPreview.js @@ -0,0 +1,24 @@ +// retoor +export class EditorPreview { + constructor() { + this.source = document.querySelector("[data-preview-source]"); + this.target = document.querySelector("[data-preview-target]"); + if (!this.source || !this.target) { + return; + } + this.timer = null; + this.source.addEventListener("input", () => this.schedule()); + customElements.whenDefined("dp-content").then(() => this.update()); + } + + schedule() { + clearTimeout(this.timer); + this.timer = setTimeout(() => this.update(), 150); + } + + update() { + this.target.markdown = this.source.value; + } +} + +window.editorPreview = new EditorPreview(); diff --git a/molodetz/static/js/utils/FormManager.js b/molodetz/static/js/utils/FormManager.js new file mode 100644 index 0000000..af2400f --- /dev/null +++ b/molodetz/static/js/utils/FormManager.js @@ -0,0 +1,30 @@ +// retoor +import { LoadingButton } from "./LoadingButton.js"; + +export class FormManager { + constructor(dialog) { + this.dialog = dialog; + document.addEventListener("submit", (event) => this.onSubmit(event), true); + } + + async onSubmit(event) { + const form = event.target; + if (!(form instanceof HTMLFormElement)) { + return; + } + if (form.dataset.confirm && form.dataset.confirmed !== "1") { + event.preventDefault(); + const accepted = await this.dialog.confirm(form.dataset.confirm); + if (accepted) { + form.dataset.confirmed = "1"; + form.requestSubmit(event.submitter || undefined); + } + return; + } + const button = event.submitter || form.querySelector("[data-loading]"); + if (button && button.hasAttribute("data-loading")) { + LoadingButton.setLoading(button, true); + window.addEventListener("pageshow", () => LoadingButton.setLoading(button, false), { once: true }); + } + } +} diff --git a/molodetz/static/js/utils/Http.js b/molodetz/static/js/utils/Http.js new file mode 100644 index 0000000..daa8f67 --- /dev/null +++ b/molodetz/static/js/utils/Http.js @@ -0,0 +1,72 @@ +// retoor +export class Http { + static termsGate = null; + + static suspend() { + return new Promise(() => {}); + } + + static async getJson(url) { + const response = await fetch(url, { headers: { Accept: "application/json" }, credentials: "same-origin" }); + if (!response.ok) { + throw new Error(`${response.status} ${response.statusText}`); + } + return response.json(); + } + + static async #post(url, body, headers) { + const response = await fetch(url, { + method: "POST", + body, + credentials: "same-origin", + headers: { Accept: "application/json", ...headers }, + }); + if (response.status === 403 && Http.termsGate) { + const handled = await Http.termsGate.intercept(response.clone()); + if (handled) { + return Http.#post(url, body, headers); + } + } + if (response.redirected && response.url.includes("/auth/login")) { + window.location.href = response.url; + return Http.suspend(); + } + return response; + } + + static async send(url, data = {}) { + const response = await Http.#post(url, JSON.stringify(data), { "Content-Type": "application/json" }); + const payload = await response.json().catch(() => ({})); + if (!response.ok || payload.ok === false) { + const message = payload.error?.message || (payload.messages || []).join(", ") || response.statusText; + throw new Error(message); + } + return payload; + } + + static async sendForm(url, form) { + const body = new URLSearchParams(new FormData(form)); + const response = await Http.#post(url, body, { "Content-Type": "application/x-www-form-urlencoded" }); + if (!response.ok) { + const payload = await response.json().catch(() => ({})); + throw new Error(`${response.status} ${payload.error?.message || (payload.messages || []).join(", ") || ""}`.trim()); + } + return response.json().catch(() => ({ ok: true })); + } + + static postJson(url, data) { + return Http.send(url, data); + } + + static postForm(url, form) { + return Http.sendForm(url, form); + } + + static async sendDelete(url) { + const response = await fetch(url, { method: "DELETE", credentials: "same-origin", headers: { Accept: "application/json" } }); + if (!response.ok) { + throw new Error(`${response.status}`); + } + return response.json().catch(() => ({ ok: true })); + } +} diff --git a/molodetz/static/js/utils/LoadingButton.js b/molodetz/static/js/utils/LoadingButton.js new file mode 100644 index 0000000..60ced77 --- /dev/null +++ b/molodetz/static/js/utils/LoadingButton.js @@ -0,0 +1,20 @@ +// retoor +export class LoadingButton { + static setLoading(button, on) { + if (!button) { + return; + } + button.classList.toggle("is-loading", on); + button.disabled = on; + button.setAttribute("aria-busy", on ? "true" : "false"); + const existing = button.querySelector(".spinner"); + if (on && !existing) { + const spinner = document.createElement("span"); + spinner.className = "spinner"; + spinner.setAttribute("aria-hidden", "true"); + button.prepend(spinner); + } else if (!on && existing) { + existing.remove(); + } + } +} diff --git a/molodetz/static/js/utils/LocalTime.js b/molodetz/static/js/utils/LocalTime.js new file mode 100644 index 0000000..ba3d636 --- /dev/null +++ b/molodetz/static/js/utils/LocalTime.js @@ -0,0 +1,55 @@ +// retoor +import { Poller } from "./Poller.js"; + +export class LocalTime { + constructor() { + this.dateFormat = new Intl.DateTimeFormat("nl-NL", { day: "2-digit", month: "2-digit", year: "numeric" }); + this.timeFormat = new Intl.DateTimeFormat("nl-NL", { hour: "2-digit", minute: "2-digit" }); + this.fullFormat = new Intl.DateTimeFormat("nl-NL", { dateStyle: "full", timeStyle: "long" }); + this.relative = new Intl.RelativeTimeFormat("nl-NL", { numeric: "auto" }); + this.renderAll(document); + this.observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => mutation.addedNodes.forEach((node) => node.nodeType === 1 && this.renderAll(node))); + }); + this.observer.observe(document.body, { childList: true, subtree: true }); + this.poller = new Poller(() => this.renderAll(document, "ago"), 60000, { pauseHidden: true }).start(); + } + + renderAll(root, onlyMode = null) { + const nodes = root.matches?.("time[data-dt]") ? [root] : root.querySelectorAll("time[data-dt]"); + nodes.forEach((node) => { + if (!onlyMode || node.dataset.mode === onlyMode) { + this.render(node); + } + }); + } + + render(node) { + const date = new Date(node.dataset.dt); + if (Number.isNaN(date.getTime())) { + return; + } + const mode = node.dataset.mode || "datetime"; + node.title = this.fullFormat.format(date); + if (mode === "date") { + node.textContent = this.dateFormat.format(date).replaceAll("-", "/"); + } else if (mode === "time") { + node.textContent = this.timeFormat.format(date); + } else if (mode === "ago") { + node.textContent = this.ago(date); + } else { + node.textContent = `${this.dateFormat.format(date).replaceAll("-", "/")} ${this.timeFormat.format(date)}`; + } + } + + ago(date) { + const seconds = Math.round((date.getTime() - Date.now()) / 1000); + const steps = [["year", 31536000], ["month", 2592000], ["day", 86400], ["hour", 3600], ["minute", 60]]; + for (const [unit, size] of steps) { + if (Math.abs(seconds) >= size) { + return this.relative.format(Math.round(seconds / size), unit); + } + } + return this.relative.format(seconds, "second"); + } +} diff --git a/molodetz/static/js/utils/MobileNav.js b/molodetz/static/js/utils/MobileNav.js new file mode 100644 index 0000000..3885c2c --- /dev/null +++ b/molodetz/static/js/utils/MobileNav.js @@ -0,0 +1,14 @@ +// retoor +export class MobileNav { + constructor() { + this.toggle = document.querySelector("[data-nav-toggle]"); + this.panel = document.getElementById("nav-links"); + if (!this.toggle || !this.panel) { + return; + } + this.toggle.addEventListener("click", () => { + const open = this.panel.classList.toggle("open"); + this.toggle.setAttribute("aria-expanded", open ? "true" : "false"); + }); + } +} diff --git a/molodetz/static/js/utils/ModalManager.js b/molodetz/static/js/utils/ModalManager.js new file mode 100644 index 0000000..be00aa7 --- /dev/null +++ b/molodetz/static/js/utils/ModalManager.js @@ -0,0 +1,84 @@ +// retoor +export class ModalManager { + constructor() { + this.stack = []; + document.addEventListener("click", (event) => this.onClick(event)); + document.addEventListener("keydown", (event) => { + if (event.key === "Escape" && this.stack.length) { + this.close(this.stack[this.stack.length - 1]); + } + }); + window.addEventListener("popstate", () => { + if (this.stack.length) { + this.close(this.stack[this.stack.length - 1], false); + } + }); + new MutationObserver(() => this.wire()).observe(document.body, { childList: true, subtree: true }); + this.wire(); + } + + wire() { + document.querySelectorAll(".modal-overlay:not([data-wired])").forEach((overlay) => { + overlay.dataset.wired = "1"; + overlay.setAttribute("role", overlay.getAttribute("role") || "dialog"); + overlay.setAttribute("aria-modal", "true"); + overlay.addEventListener("keydown", (event) => this.trap(event, overlay)); + }); + } + + onClick(event) { + const trigger = event.target.closest("[data-modal]"); + if (trigger) { + event.preventDefault(); + this.open(document.getElementById(trigger.dataset.modal)); + return; + } + if (event.target.closest(".modal-close")) { + this.close(event.target.closest(".modal-overlay")); + return; + } + if (event.target.classList?.contains("modal-overlay")) { + this.close(event.target); + } + } + + open(overlay) { + if (!overlay) { + return; + } + overlay.classList.add("visible"); + this.stack.push(overlay); + history.pushState({ modal: overlay.id }, ""); + overlay.querySelector("button, a, input, select, textarea")?.focus(); + } + + close(overlay, popHistory = true) { + if (!overlay) { + return; + } + overlay.classList.remove("visible"); + this.stack = this.stack.filter((item) => item !== overlay); + if (popHistory && history.state?.modal === overlay.id) { + history.back(); + } + } + + trap(event, overlay) { + if (event.key !== "Tab") { + return; + } + const focusable = [...overlay.querySelectorAll("button, a[href], input, select, textarea")].filter((el) => !el.disabled); + if (!focusable.length) { + return; + } + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + } +} diff --git a/molodetz/static/js/utils/Poller.js b/molodetz/static/js/utils/Poller.js new file mode 100644 index 0000000..d8a8299 --- /dev/null +++ b/molodetz/static/js/utils/Poller.js @@ -0,0 +1,41 @@ +// retoor +export class Poller { + constructor(fn, intervalMs, { immediate = false, pauseHidden = true } = {}) { + this.fn = fn; + this.intervalMs = intervalMs; + this.immediate = immediate; + this.pauseHidden = pauseHidden; + this.timer = null; + this.busy = false; + } + + start() { + if (this.timer) { + return this; + } + if (this.immediate) { + this.tick(); + } + this.timer = setInterval(() => this.tick(), this.intervalMs); + return this; + } + + stop() { + clearInterval(this.timer); + this.timer = null; + } + + async tick() { + if (this.busy || (this.pauseHidden && document.hidden)) { + return; + } + this.busy = true; + try { + await this.fn(); + } catch (error) { + console.debug("poller tick failed", error); + } finally { + this.busy = false; + } + } +} diff --git a/molodetz/static/js/utils/PresenceManager.js b/molodetz/static/js/utils/PresenceManager.js new file mode 100644 index 0000000..41c7144 --- /dev/null +++ b/molodetz/static/js/utils/PresenceManager.js @@ -0,0 +1,25 @@ +// retoor +import { Http } from "./Http.js"; +import { Poller } from "./Poller.js"; + +export class PresenceManager { + constructor() { + this.online = new Set(); + this.poller = null; + if (document.querySelector("[data-presence-uid]")) { + this.poller = new Poller(() => this.refresh(), 20000, { immediate: false }).start(); + } + } + + async refresh() { + const roster = await Http.getJson("/presence/roster"); + this.online = new Set(roster.online || []); + this.render(); + } + + render() { + document.querySelectorAll("[data-presence-uid]").forEach((dot) => { + dot.classList.toggle("online", this.online.has(dot.dataset.presenceUid)); + }); + } +} diff --git a/molodetz/static/js/utils/ScrollMemory.js b/molodetz/static/js/utils/ScrollMemory.js new file mode 100644 index 0000000..9a0a62a --- /dev/null +++ b/molodetz/static/js/utils/ScrollMemory.js @@ -0,0 +1,64 @@ +// retoor +export class ScrollMemory { + static KEY = "molodetz-scroll"; + static MAX_ENTRIES = 50; + static MAX_AGE_MS = 60 * 60 * 1000; + + constructor() { + history.scrollRestoration = "manual"; + this.key = location.pathname + location.search; + window.addEventListener("pagehide", () => this.save()); + const navigation = performance.getEntriesByType("navigation")[0]; + const type = navigation ? navigation.type : "navigate"; + if (type === "back_forward" || type === "reload" || sessionStorage.getItem("molodetz-restore") === this.key) { + sessionStorage.removeItem("molodetz-restore"); + this.restore(); + } + document.addEventListener("click", (event) => { + const link = event.target.closest(".breadcrumbs a, a[data-back]"); + if (link) { + sessionStorage.setItem("molodetz-restore", new URL(link.href).pathname); + } + }); + } + + read() { + try { + return JSON.parse(sessionStorage.getItem(ScrollMemory.KEY) || "{}"); + } catch { + return {}; + } + } + + save() { + const entries = this.read(); + entries[this.key] = { y: window.scrollY, at: Date.now() }; + const now = Date.now(); + const kept = Object.entries(entries) + .filter(([, value]) => now - value.at < ScrollMemory.MAX_AGE_MS) + .sort((a, b) => b[1].at - a[1].at) + .slice(0, ScrollMemory.MAX_ENTRIES); + sessionStorage.setItem(ScrollMemory.KEY, JSON.stringify(Object.fromEntries(kept))); + } + + restore() { + const entry = this.read()[this.key]; + if (!entry) { + return; + } + const deadline = performance.now() + 1500; + let aborted = false; + const abort = () => { aborted = true; }; + ["wheel", "touchstart", "keydown"].forEach((name) => window.addEventListener(name, abort, { once: true, passive: true })); + const apply = () => { + if (aborted) { + return; + } + window.scrollTo(0, entry.y); + if (Math.abs(window.scrollY - entry.y) > 2 && performance.now() < deadline) { + requestAnimationFrame(apply); + } + }; + requestAnimationFrame(apply); + } +} diff --git a/molodetz/static/js/utils/ServiceMonitor.js b/molodetz/static/js/utils/ServiceMonitor.js new file mode 100644 index 0000000..1a69680 --- /dev/null +++ b/molodetz/static/js/utils/ServiceMonitor.js @@ -0,0 +1,81 @@ +// retoor +import { Http } from "./Http.js"; +import { Poller } from "./Poller.js"; + +export class ServiceMonitor { + constructor() { + this.root = document.querySelector("[data-service-monitor]"); + if (!this.root) { + return; + } + this.mode = this.root.dataset.serviceMonitor; + this.poller = new Poller(() => this.refresh(), 3000).start(); + } + + async refresh() { + if (this.mode === "detail") { + const payload = await Http.getJson(`/admin/services/${encodeURIComponent(this.root.dataset.service)}`); + this.renderDetail(payload.service || {}); + } else { + const payload = await Http.getJson("/admin/services"); + (payload.services || []).forEach((service) => this.renderRow(service)); + } + } + + setStatus(node, status) { + if (node) { + node.className = `status ${status}`; + node.textContent = status; + } + } + + setTime(node, iso) { + if (!node) { + return; + } + node.replaceChildren(); + if (iso) { + const time = document.createElement("time"); + time.dataset.dt = iso; + time.dataset.mode = "datetime"; + time.dateTime = iso; + time.textContent = iso; + node.append(time); + } + } + + renderRow(service) { + const row = this.root.querySelector(`[data-service="${CSS.escape(service.name)}"]`); + if (!row) { + return; + } + this.setStatus(row.querySelector('[data-field="status"]'), service.status); + this.setTime(row.querySelector('[data-field="last_run"]'), service.last_run); + this.setTime(row.querySelector('[data-field="next_run"]'), service.next_run); + } + + renderDetail(service) { + this.setStatus(this.root.querySelector('[data-field="status"]'), service.status); + const logs = this.root.querySelector('[data-field="logs"]'); + if (logs) { + logs.textContent = (service.logs || []).join("\n"); + } + const cards = this.root.querySelector('[data-field="cards"]'); + if (cards && service.metrics?.cards) { + cards.replaceChildren(...service.metrics.cards.map((card) => { + const box = document.createElement("div"); + box.className = "stat-card"; + const value = document.createElement("span"); + value.className = "value"; + value.textContent = card.value; + const name = document.createElement("span"); + name.className = "name"; + name.textContent = card.label; + box.append(value, name); + return box; + })); + } + } +} + +window.serviceMonitor = new ServiceMonitor(); diff --git a/molodetz/static/js/utils/Tabs.js b/molodetz/static/js/utils/Tabs.js new file mode 100644 index 0000000..e447bbe --- /dev/null +++ b/molodetz/static/js/utils/Tabs.js @@ -0,0 +1,20 @@ +// retoor +export class Tabs { + constructor() { + document.addEventListener("click", (event) => { + const button = event.target.closest("[data-tabs] [data-tab]"); + if (button) { + this.select(button); + } + }); + } + + select(button) { + const bar = button.closest("[data-tabs]"); + const scope = bar.parentElement; + bar.querySelectorAll("[data-tab]").forEach((item) => item.classList.toggle("active", item === button)); + scope.querySelectorAll(":scope > [data-panel]").forEach((panel) => { + panel.hidden = panel.dataset.panel !== button.dataset.tab; + }); + } +} diff --git a/molodetz/static/js/utils/TermsGate.js b/molodetz/static/js/utils/TermsGate.js new file mode 100644 index 0000000..79418a5 --- /dev/null +++ b/molodetz/static/js/utils/TermsGate.js @@ -0,0 +1,34 @@ +// retoor +export class TermsGate { + constructor(dialog) { + this.dialog = dialog; + this.pending = null; + } + + async intercept(response) { + const payload = await response.json().catch(() => ({})); + if (payload.error?.code !== "terms_required") { + return false; + } + if (!this.pending) { + this.pending = this.dialog + .confirm(`De voorwaarden zijn gewijzigd (versie ${payload.error.terms_version}). Accepteren en doorgaan?`) + .then(async (accepted) => { + if (!accepted) { + return false; + } + const result = await fetch("/voorwaarden/accept", { + method: "POST", + credentials: "same-origin", + headers: { Accept: "application/json", "Content-Type": "application/json" }, + body: "{}", + }); + return result.ok; + }) + .finally(() => { + setTimeout(() => { this.pending = null; }, 0); + }); + } + return this.pending; + } +} diff --git a/molodetz/static/js/utils/Toast.js b/molodetz/static/js/utils/Toast.js new file mode 100644 index 0000000..74a00ec --- /dev/null +++ b/molodetz/static/js/utils/Toast.js @@ -0,0 +1,11 @@ +// retoor +export class Toast { + static flash(el, message, ms = 2000, revertTo = null) { + if (!el) { + return; + } + const original = revertTo ?? el.textContent; + el.textContent = message; + setTimeout(() => { el.textContent = original; }, ms); + } +} diff --git a/molodetz/static/media/favicon.svg b/molodetz/static/media/favicon.svg new file mode 100644 index 0000000..55f250e --- /dev/null +++ b/molodetz/static/media/favicon.svg @@ -0,0 +1 @@ +M diff --git a/molodetz/static/media/flyer-assistent.jpg b/molodetz/static/media/flyer-assistent.jpg new file mode 100644 index 0000000..04d89fc Binary files /dev/null and b/molodetz/static/media/flyer-assistent.jpg differ diff --git a/molodetz/static/media/flyer-bijna.jpg b/molodetz/static/media/flyer-bijna.jpg new file mode 100644 index 0000000..afcd1ce Binary files /dev/null and b/molodetz/static/media/flyer-bijna.jpg differ diff --git a/molodetz/static/media/flyer-chat.jpg b/molodetz/static/media/flyer-chat.jpg new file mode 100644 index 0000000..69616c8 Binary files /dev/null and b/molodetz/static/media/flyer-chat.jpg differ diff --git a/molodetz/static/media/flyer-keuzes.jpg b/molodetz/static/media/flyer-keuzes.jpg new file mode 100644 index 0000000..f9e8f77 Binary files /dev/null and b/molodetz/static/media/flyer-keuzes.jpg differ diff --git a/molodetz/static/media/meme-bijna.jpg b/molodetz/static/media/meme-bijna.jpg new file mode 100644 index 0000000..2f6a838 Binary files /dev/null and b/molodetz/static/media/meme-bijna.jpg differ diff --git a/molodetz/static/media/meme-compiles.jpg b/molodetz/static/media/meme-compiles.jpg new file mode 100644 index 0000000..25b2eea Binary files /dev/null and b/molodetz/static/media/meme-compiles.jpg differ diff --git a/molodetz/static/media/meme-cursus.jpg b/molodetz/static/media/meme-cursus.jpg new file mode 100644 index 0000000..919b104 Binary files /dev/null and b/molodetz/static/media/meme-cursus.jpg differ diff --git a/molodetz/static/media/meme-gesprek.jpg b/molodetz/static/media/meme-gesprek.jpg new file mode 100644 index 0000000..3f09dec Binary files /dev/null and b/molodetz/static/media/meme-gesprek.jpg differ diff --git a/molodetz/static/media/meme-product.jpg b/molodetz/static/media/meme-product.jpg new file mode 100644 index 0000000..c93627a Binary files /dev/null and b/molodetz/static/media/meme-product.jpg differ diff --git a/molodetz/static/media/meme-senior.jpg b/molodetz/static/media/meme-senior.jpg new file mode 100644 index 0000000..d898a7f Binary files /dev/null and b/molodetz/static/media/meme-senior.jpg differ diff --git a/molodetz/static/media/meme-split.jpg b/molodetz/static/media/meme-split.jpg new file mode 100644 index 0000000..0a5108a Binary files /dev/null and b/molodetz/static/media/meme-split.jpg differ diff --git a/molodetz/static/media/meme-todo.jpg b/molodetz/static/media/meme-todo.jpg new file mode 100644 index 0000000..9fc170a Binary files /dev/null and b/molodetz/static/media/meme-todo.jpg differ diff --git a/molodetz/static/media/meme-trophy.jpg b/molodetz/static/media/meme-trophy.jpg new file mode 100644 index 0000000..e0b51d5 Binary files /dev/null and b/molodetz/static/media/meme-trophy.jpg differ diff --git a/molodetz/static/media/meme-vloer.jpg b/molodetz/static/media/meme-vloer.jpg new file mode 100644 index 0000000..2c5003a Binary files /dev/null and b/molodetz/static/media/meme-vloer.jpg differ diff --git a/molodetz/static/media/meme-zes.jpg b/molodetz/static/media/meme-zes.jpg new file mode 100644 index 0000000..a2129e8 Binary files /dev/null and b/molodetz/static/media/meme-zes.jpg differ diff --git a/molodetz/static/media/poster-cursus.jpg b/molodetz/static/media/poster-cursus.jpg new file mode 100644 index 0000000..219c3fc Binary files /dev/null and b/molodetz/static/media/poster-cursus.jpg differ diff --git a/molodetz/static/media/poster-luiheid.jpg b/molodetz/static/media/poster-luiheid.jpg new file mode 100644 index 0000000..70aa23d Binary files /dev/null and b/molodetz/static/media/poster-luiheid.jpg differ diff --git a/molodetz/static/media/poster-spel.jpg b/molodetz/static/media/poster-spel.jpg new file mode 100644 index 0000000..211d311 Binary files /dev/null and b/molodetz/static/media/poster-spel.jpg differ diff --git a/molodetz/static/media/poster-thuis.jpg b/molodetz/static/media/poster-thuis.jpg new file mode 100644 index 0000000..27d4f5d Binary files /dev/null and b/molodetz/static/media/poster-thuis.jpg differ diff --git a/molodetz/static/media/poster-vloer.jpg b/molodetz/static/media/poster-vloer.jpg new file mode 100644 index 0000000..1e20e46 Binary files /dev/null and b/molodetz/static/media/poster-vloer.jpg differ diff --git a/molodetz/static/media/story.jpg b/molodetz/static/media/story.jpg new file mode 100644 index 0000000..31e90fa Binary files /dev/null and b/molodetz/static/media/story.jpg differ diff --git a/molodetz/static/media/take-chatlog.jpg b/molodetz/static/media/take-chatlog.jpg new file mode 100644 index 0000000..ecd1502 Binary files /dev/null and b/molodetz/static/media/take-chatlog.jpg differ diff --git a/molodetz/static/media/take-copilot.jpg b/molodetz/static/media/take-copilot.jpg new file mode 100644 index 0000000..ebd9431 Binary files /dev/null and b/molodetz/static/media/take-copilot.jpg differ diff --git a/molodetz/static/media/take-coping.jpg b/molodetz/static/media/take-coping.jpg new file mode 100644 index 0000000..9edbaa5 Binary files /dev/null and b/molodetz/static/media/take-coping.jpg differ diff --git a/molodetz/static/vendor/VERSIONS.txt b/molodetz/static/vendor/VERSIONS.txt new file mode 100644 index 0000000..1872c5a --- /dev/null +++ b/molodetz/static/vendor/VERSIONS.txt @@ -0,0 +1,3 @@ +marked 15.0.7 (lib/marked.esm.js) https://cdn.jsdelivr.net/npm/marked@15.0.7/lib/marked.esm.js +DOMPurify 3.2.4 (dist/purify.es.mjs) https://cdn.jsdelivr.net/npm/dompurify@3.2.4/dist/purify.es.mjs +highlight.js 11.11.1 (build/es/highlight.min.js) https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.11.1/build/es/highlight.min.js diff --git a/molodetz/static/vendor/highlight.es.min.js b/molodetz/static/vendor/highlight.es.min.js new file mode 100644 index 0000000..1f2f516 --- /dev/null +++ b/molodetz/static/vendor/highlight.es.min.js @@ -0,0 +1,1242 @@ +/*! + Highlight.js v11.11.1 (git: 08cb242e7d) + (c) 2006-2024 Josh Goebel and other contributors + License: BSD-3-Clause + */ +function e(n){return n instanceof Map?n.clear=n.delete=n.set=()=>{ +throw Error("map is read-only")}:n instanceof Set&&(n.add=n.clear=n.delete=()=>{ +throw Error("set is read-only") +}),Object.freeze(n),Object.getOwnPropertyNames(n).forEach((t=>{ +const a=n[t],i=typeof a;"object"!==i&&"function"!==i||Object.isFrozen(a)||e(a) +})),n}class n{constructor(e){ +void 0===e.data&&(e.data={}),this.data=e.data,this.isMatchIgnored=!1} +ignoreMatch(){this.isMatchIgnored=!0}}function t(e){ +return e.replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'") +}function a(e,...n){const t=Object.create(null);for(const n in e)t[n]=e[n] +;return n.forEach((e=>{for(const n in e)t[n]=e[n]})),t}const i=e=>!!e.scope +;class r{constructor(e,n){ +this.buffer="",this.classPrefix=n.classPrefix,e.walk(this)}addText(e){ +this.buffer+=t(e)}openNode(e){if(!i(e))return;const n=((e,{prefix:n})=>{ +if(e.startsWith("language:"))return e.replace("language:","language-") +;if(e.includes(".")){const t=e.split(".") +;return[`${n}${t.shift()}`,...t.map(((e,n)=>`${e}${"_".repeat(n+1)}`))].join(" ") +}return`${n}${e}`})(e.scope,{prefix:this.classPrefix});this.span(n)} +closeNode(e){i(e)&&(this.buffer+="")}value(){return this.buffer}span(e){ +this.buffer+=``}}const s=(e={})=>{const n={children:[]} +;return Object.assign(n,e),n};class o{constructor(){ +this.rootNode=s(),this.stack=[this.rootNode]}get top(){ +return this.stack[this.stack.length-1]}get root(){return this.rootNode}add(e){ +this.top.children.push(e)}openNode(e){const n=s({scope:e}) +;this.add(n),this.stack.push(n)}closeNode(){ +if(this.stack.length>1)return this.stack.pop()}closeAllNodes(){ +for(;this.closeNode(););}toJSON(){return JSON.stringify(this.rootNode,null,4)} +walk(e){return this.constructor._walk(e,this.rootNode)}static _walk(e,n){ +return"string"==typeof n?e.addText(n):n.children&&(e.openNode(n), +n.children.forEach((n=>this._walk(e,n))),e.closeNode(n)),e}static _collapse(e){ +"string"!=typeof e&&e.children&&(e.children.every((e=>"string"==typeof e))?e.children=[e.children.join("")]:e.children.forEach((e=>{ +o._collapse(e)})))}}class l extends o{constructor(e){super(),this.options=e} +addText(e){""!==e&&this.add(e)}startScope(e){this.openNode(e)}endScope(){ +this.closeNode()}__addSublanguage(e,n){const t=e.root +;n&&(t.scope="language:"+n),this.add(t)}toHTML(){ +return new r(this,this.options).value()}finalize(){ +return this.closeAllNodes(),!0}}function c(e){ +return e?"string"==typeof e?e:e.source:null}function d(e){return b("(?=",e,")")} +function g(e){return b("(?:",e,")*")}function u(e){return b("(?:",e,")?")} +function b(...e){return e.map((e=>c(e))).join("")}function m(...e){const n=(e=>{ +const n=e[e.length-1] +;return"object"==typeof n&&n.constructor===Object?(e.splice(e.length-1,1),n):{} +})(e);return"("+(n.capture?"":"?:")+e.map((e=>c(e))).join("|")+")"} +function p(e){return RegExp(e.toString()+"|").exec("").length-1} +const _=/\[(?:[^\\\]]|\\.)*\]|\(\??|\\([1-9][0-9]*)|\\./ +;function h(e,{joinWith:n}){let t=0;return e.map((e=>{t+=1;const n=t +;let a=c(e),i="";for(;a.length>0;){const e=_.exec(a);if(!e){i+=a;break} +i+=a.substring(0,e.index), +a=a.substring(e.index+e[0].length),"\\"===e[0][0]&&e[1]?i+="\\"+(Number(e[1])+n):(i+=e[0], +"("===e[0]&&t++)}return i})).map((e=>`(${e})`)).join(n)} +const f="[a-zA-Z]\\w*",E="[a-zA-Z_]\\w*",y="\\b\\d+(\\.\\d+)?",w="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",v="\\b(0b[01]+)",N={ +begin:"\\\\[\\s\\S]",relevance:0},k={scope:"string",begin:"'",end:"'", +illegal:"\\n",contains:[N]},x={scope:"string",begin:'"',end:'"',illegal:"\\n", +contains:[N]},O=(e,n,t={})=>{const i=a({scope:"comment",begin:e,end:n, +contains:[]},t);i.contains.push({scope:"doctag", +begin:"[ ]*(?=(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):)", +end:/(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):/,excludeBegin:!0,relevance:0}) +;const r=m("I","a","is","so","us","to","at","if","in","it","on",/[A-Za-z]+['](d|ve|re|ll|t|s|n)/,/[A-Za-z]+[-][a-z]+/,/[A-Za-z][a-z]{2,}/) +;return i.contains.push({begin:b(/[ ]+/,"(",r,/[.]?[:]?([.][ ]|[ ])/,"){3}")}),i +},M=O("//","$"),A=O("/\\*","\\*/"),S=O("#","$");var C=Object.freeze({ +__proto__:null,APOS_STRING_MODE:k,BACKSLASH_ESCAPE:N,BINARY_NUMBER_MODE:{ +scope:"number",begin:v,relevance:0},BINARY_NUMBER_RE:v,COMMENT:O, +C_BLOCK_COMMENT_MODE:A,C_LINE_COMMENT_MODE:M,C_NUMBER_MODE:{scope:"number", +begin:w,relevance:0},C_NUMBER_RE:w,END_SAME_AS_BEGIN:e=>Object.assign(e,{ +"on:begin":(e,n)=>{n.data._beginMatch=e[1]},"on:end":(e,n)=>{ +n.data._beginMatch!==e[1]&&n.ignoreMatch()}}),HASH_COMMENT_MODE:S,IDENT_RE:f, +MATCH_NOTHING_RE:/\b\B/,METHOD_GUARD:{begin:"\\.\\s*"+E,relevance:0}, +NUMBER_MODE:{scope:"number",begin:y,relevance:0},NUMBER_RE:y, +PHRASAL_WORDS_MODE:{ +begin:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|they|like|more)\b/ +},QUOTE_STRING_MODE:x,REGEXP_MODE:{scope:"regexp",begin:/\/(?=[^/\n]*\/)/, +end:/\/[gimuy]*/,contains:[N,{begin:/\[/,end:/\]/,relevance:0,contains:[N]}]}, +RE_STARTERS_RE:"!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~", +SHEBANG:(e={})=>{const n=/^#![ ]*\// +;return e.binary&&(e.begin=b(n,/.*\b/,e.binary,/\b.*/)),a({scope:"meta",begin:n, +end:/$/,relevance:0,"on:begin":(e,n)=>{0!==e.index&&n.ignoreMatch()}},e)}, +TITLE_MODE:{scope:"title",begin:f,relevance:0},UNDERSCORE_IDENT_RE:E, +UNDERSCORE_TITLE_MODE:{scope:"title",begin:E,relevance:0}});function T(e,n){ +"."===e.input[e.index-1]&&n.ignoreMatch()}function R(e,n){ +void 0!==e.className&&(e.scope=e.className,delete e.className)}function D(e,n){ +n&&e.beginKeywords&&(e.begin="\\b("+e.beginKeywords.split(" ").join("|")+")(?!\\.)(?=\\b|\\s)", +e.__beforeBegin=T,e.keywords=e.keywords||e.beginKeywords,delete e.beginKeywords, +void 0===e.relevance&&(e.relevance=0))}function I(e,n){ +Array.isArray(e.illegal)&&(e.illegal=m(...e.illegal))}function L(e,n){ +if(e.match){ +if(e.begin||e.end)throw Error("begin & end are not supported with match") +;e.begin=e.match,delete e.match}}function B(e,n){ +void 0===e.relevance&&(e.relevance=1)}const $=(e,n)=>{if(!e.beforeMatch)return +;if(e.starts)throw Error("beforeMatch cannot be used with starts") +;const t=Object.assign({},e);Object.keys(e).forEach((n=>{delete e[n] +})),e.keywords=t.keywords,e.begin=b(t.beforeMatch,d(t.begin)),e.starts={ +relevance:0,contains:[Object.assign(t,{endsParent:!0})] +},e.relevance=0,delete t.beforeMatch +},F=["of","and","for","in","not","or","if","then","parent","list","value"] +;function z(e,n,t="keyword"){const a=Object.create(null) +;return"string"==typeof e?i(t,e.split(" ")):Array.isArray(e)?i(t,e):Object.keys(e).forEach((t=>{ +Object.assign(a,z(e[t],n,t))})),a;function i(e,t){ +n&&(t=t.map((e=>e.toLowerCase()))),t.forEach((n=>{const t=n.split("|") +;a[t[0]]=[e,j(t[0],t[1])]}))}}function j(e,n){ +return n?Number(n):(e=>F.includes(e.toLowerCase()))(e)?0:1}const U={},P=e=>{ +console.error(e)},K=(e,...n)=>{console.log("WARN: "+e,...n)},q=(e,n)=>{ +U[`${e}/${n}`]||(console.log(`Deprecated as of ${e}. ${n}`),U[`${e}/${n}`]=!0) +},H=Error();function G(e,n,{key:t}){let a=0;const i=e[t],r={},s={} +;for(let e=1;e<=n.length;e++)s[e+a]=i[e],r[e+a]=!0,a+=p(n[e-1]) +;e[t]=s,e[t]._emit=r,e[t]._multi=!0}function Z(e){(e=>{ +e.scope&&"object"==typeof e.scope&&null!==e.scope&&(e.beginScope=e.scope, +delete e.scope)})(e),"string"==typeof e.beginScope&&(e.beginScope={ +_wrap:e.beginScope}),"string"==typeof e.endScope&&(e.endScope={_wrap:e.endScope +}),(e=>{if(Array.isArray(e.begin)){ +if(e.skip||e.excludeBegin||e.returnBegin)throw P("skip, excludeBegin, returnBegin not compatible with beginScope: {}"), +H +;if("object"!=typeof e.beginScope||null===e.beginScope)throw P("beginScope must be object"), +H;G(e,e.begin,{key:"beginScope"}),e.begin=h(e.begin,{joinWith:""})}})(e),(e=>{ +if(Array.isArray(e.end)){ +if(e.skip||e.excludeEnd||e.returnEnd)throw P("skip, excludeEnd, returnEnd not compatible with endScope: {}"), +H +;if("object"!=typeof e.endScope||null===e.endScope)throw P("endScope must be object"), +H;G(e,e.end,{key:"endScope"}),e.end=h(e.end,{joinWith:""})}})(e)}function W(e){ +function n(n,t){ +return RegExp(c(n),"m"+(e.case_insensitive?"i":"")+(e.unicodeRegex?"u":"")+(t?"g":"")) +}class t{constructor(){ +this.matchIndexes={},this.regexes=[],this.matchAt=1,this.position=0} +addRule(e,n){ +n.position=this.position++,this.matchIndexes[this.matchAt]=n,this.regexes.push([n,e]), +this.matchAt+=p(e)+1}compile(){0===this.regexes.length&&(this.exec=()=>null) +;const e=this.regexes.map((e=>e[1]));this.matcherRe=n(h(e,{joinWith:"|" +}),!0),this.lastIndex=0}exec(e){this.matcherRe.lastIndex=this.lastIndex +;const n=this.matcherRe.exec(e);if(!n)return null +;const t=n.findIndex(((e,n)=>n>0&&void 0!==e)),a=this.matchIndexes[t] +;return n.splice(0,t),Object.assign(n,a)}}class i{constructor(){ +this.rules=[],this.multiRegexes=[], +this.count=0,this.lastIndex=0,this.regexIndex=0}getMatcher(e){ +if(this.multiRegexes[e])return this.multiRegexes[e];const n=new t +;return this.rules.slice(e).forEach((([e,t])=>n.addRule(e,t))), +n.compile(),this.multiRegexes[e]=n,n}resumingScanAtSamePosition(){ +return 0!==this.regexIndex}considerAll(){this.regexIndex=0}addRule(e,n){ +this.rules.push([e,n]),"begin"===n.type&&this.count++}exec(e){ +const n=this.getMatcher(this.regexIndex);n.lastIndex=this.lastIndex +;let t=n.exec(e) +;if(this.resumingScanAtSamePosition())if(t&&t.index===this.lastIndex);else{ +const n=this.getMatcher(0);n.lastIndex=this.lastIndex+1,t=n.exec(e)} +return t&&(this.regexIndex+=t.position+1, +this.regexIndex===this.count&&this.considerAll()),t}} +if(e.compilerExtensions||(e.compilerExtensions=[]), +e.contains&&e.contains.includes("self"))throw Error("ERR: contains `self` is not supported at the top-level of a language. See documentation.") +;return e.classNameAliases=a(e.classNameAliases||{}),function t(r,s){const o=r +;if(r.isCompiled)return o +;[R,L,Z,$].forEach((e=>e(r,s))),e.compilerExtensions.forEach((e=>e(r,s))), +r.__beforeBegin=null,[D,I,B].forEach((e=>e(r,s))),r.isCompiled=!0;let l=null +;return"object"==typeof r.keywords&&r.keywords.$pattern&&(r.keywords=Object.assign({},r.keywords), +l=r.keywords.$pattern, +delete r.keywords.$pattern),l=l||/\w+/,r.keywords&&(r.keywords=z(r.keywords,e.case_insensitive)), +o.keywordPatternRe=n(l,!0), +s&&(r.begin||(r.begin=/\B|\b/),o.beginRe=n(o.begin),r.end||r.endsWithParent||(r.end=/\B|\b/), +r.end&&(o.endRe=n(o.end)), +o.terminatorEnd=c(o.end)||"",r.endsWithParent&&s.terminatorEnd&&(o.terminatorEnd+=(r.end?"|":"")+s.terminatorEnd)), +r.illegal&&(o.illegalRe=n(r.illegal)), +r.contains||(r.contains=[]),r.contains=[].concat(...r.contains.map((e=>(e=>(e.variants&&!e.cachedVariants&&(e.cachedVariants=e.variants.map((n=>a(e,{ +variants:null},n)))),e.cachedVariants?e.cachedVariants:Q(e)?a(e,{ +starts:e.starts?a(e.starts):null +}):Object.isFrozen(e)?a(e):e))("self"===e?r:e)))),r.contains.forEach((e=>{t(e,o) +})),r.starts&&t(r.starts,s),o.matcher=(e=>{const n=new i +;return e.contains.forEach((e=>n.addRule(e.begin,{rule:e,type:"begin" +}))),e.terminatorEnd&&n.addRule(e.terminatorEnd,{type:"end" +}),e.illegal&&n.addRule(e.illegal,{type:"illegal"}),n})(o),o}(e)}function Q(e){ +return!!e&&(e.endsWithParent||Q(e.starts))}class X extends Error{ +constructor(e,n){super(e),this.name="HTMLInjectionError",this.html=n}} +const V=t,J=a,Y=Symbol("nomatch"),ee=t=>{ +const a=Object.create(null),i=Object.create(null),r=[];let s=!0 +;const o="Could not find the language '{}', did you forget to load/include a language module?",c={ +disableAutodetect:!0,name:"Plain text",contains:[]};let p={ +ignoreUnescapedHTML:!1,throwUnescapedHTML:!1,noHighlightRe:/^(no-?highlight)$/i, +languageDetectRe:/\blang(?:uage)?-([\w-]+)\b/i,classPrefix:"hljs-", +cssSelector:"pre code",languages:null,__emitter:l};function _(e){ +return p.noHighlightRe.test(e)}function h(e,n,t){let a="",i="" +;"object"==typeof n?(a=e, +t=n.ignoreIllegals,i=n.language):(q("10.7.0","highlight(lang, code, ...args) has been deprecated."), +q("10.7.0","Please use highlight(code, options) instead.\nhttps://github.com/highlightjs/highlight.js/issues/2277"), +i=e,a=n),void 0===t&&(t=!0);const r={code:a,language:i};O("before:highlight",r) +;const s=r.result?r.result:f(r.language,r.code,t) +;return s.code=r.code,O("after:highlight",s),s}function f(e,t,i,r){ +const l=Object.create(null);function c(){if(!O.keywords)return void A.addText(S) +;let e=0;O.keywordPatternRe.lastIndex=0;let n=O.keywordPatternRe.exec(S),t="" +;for(;n;){t+=S.substring(e,n.index) +;const i=v.case_insensitive?n[0].toLowerCase():n[0],r=(a=i,O.keywords[a]);if(r){ +const[e,a]=r +;if(A.addText(t),t="",l[i]=(l[i]||0)+1,l[i]<=7&&(C+=a),e.startsWith("_"))t+=n[0];else{ +const t=v.classNameAliases[e]||e;g(n[0],t)}}else t+=n[0] +;e=O.keywordPatternRe.lastIndex,n=O.keywordPatternRe.exec(S)}var a +;t+=S.substring(e),A.addText(t)}function d(){null!=O.subLanguage?(()=>{ +if(""===S)return;let e=null;if("string"==typeof O.subLanguage){ +if(!a[O.subLanguage])return void A.addText(S) +;e=f(O.subLanguage,S,!0,M[O.subLanguage]),M[O.subLanguage]=e._top +}else e=E(S,O.subLanguage.length?O.subLanguage:null) +;O.relevance>0&&(C+=e.relevance),A.__addSublanguage(e._emitter,e.language) +})():c(),S=""}function g(e,n){ +""!==e&&(A.startScope(n),A.addText(e),A.endScope())}function u(e,n){let t=1 +;const a=n.length-1;for(;t<=a;){if(!e._emit[t]){t++;continue} +const a=v.classNameAliases[e[t]]||e[t],i=n[t];a?g(i,a):(S=i,c(),S=""),t++}} +function b(e,n){ +return e.scope&&"string"==typeof e.scope&&A.openNode(v.classNameAliases[e.scope]||e.scope), +e.beginScope&&(e.beginScope._wrap?(g(S,v.classNameAliases[e.beginScope._wrap]||e.beginScope._wrap), +S=""):e.beginScope._multi&&(u(e.beginScope,n),S="")),O=Object.create(e,{parent:{ +value:O}}),O}function m(e,t,a){let i=((e,n)=>{const t=e&&e.exec(n) +;return t&&0===t.index})(e.endRe,a);if(i){if(e["on:end"]){const a=new n(e) +;e["on:end"](t,a),a.isMatchIgnored&&(i=!1)}if(i){ +for(;e.endsParent&&e.parent;)e=e.parent;return e}} +if(e.endsWithParent)return m(e.parent,t,a)}function _(e){ +return 0===O.matcher.regexIndex?(S+=e[0],1):(D=!0,0)}function h(e){ +const n=e[0],a=t.substring(e.index),i=m(O,e,a);if(!i)return Y;const r=O +;O.endScope&&O.endScope._wrap?(d(), +g(n,O.endScope._wrap)):O.endScope&&O.endScope._multi?(d(), +u(O.endScope,e)):r.skip?S+=n:(r.returnEnd||r.excludeEnd||(S+=n), +d(),r.excludeEnd&&(S=n));do{ +O.scope&&A.closeNode(),O.skip||O.subLanguage||(C+=O.relevance),O=O.parent +}while(O!==i.parent);return i.starts&&b(i.starts,e),r.returnEnd?0:n.length} +let y={};function w(a,r){const o=r&&r[0];if(S+=a,null==o)return d(),0 +;if("begin"===y.type&&"end"===r.type&&y.index===r.index&&""===o){ +if(S+=t.slice(r.index,r.index+1),!s){const n=Error(`0 width match regex (${e})`) +;throw n.languageName=e,n.badRule=y.rule,n}return 1} +if(y=r,"begin"===r.type)return(e=>{ +const t=e[0],a=e.rule,i=new n(a),r=[a.__beforeBegin,a["on:begin"]] +;for(const n of r)if(n&&(n(e,i),i.isMatchIgnored))return _(t) +;return a.skip?S+=t:(a.excludeBegin&&(S+=t), +d(),a.returnBegin||a.excludeBegin||(S=t)),b(a,e),a.returnBegin?0:t.length})(r) +;if("illegal"===r.type&&!i){ +const e=Error('Illegal lexeme "'+o+'" for mode "'+(O.scope||"")+'"') +;throw e.mode=O,e}if("end"===r.type){const e=h(r);if(e!==Y)return e} +if("illegal"===r.type&&""===o)return S+="\n",1 +;if(R>1e5&&R>3*r.index)throw Error("potential infinite loop, way more iterations than matches") +;return S+=o,o.length}const v=N(e) +;if(!v)throw P(o.replace("{}",e)),Error('Unknown language: "'+e+'"') +;const k=W(v);let x="",O=r||k;const M={},A=new p.__emitter(p);(()=>{const e=[] +;for(let n=O;n!==v;n=n.parent)n.scope&&e.unshift(n.scope) +;e.forEach((e=>A.openNode(e)))})();let S="",C=0,T=0,R=0,D=!1;try{ +if(v.__emitTokens)v.__emitTokens(t,A);else{for(O.matcher.considerAll();;){ +R++,D?D=!1:O.matcher.considerAll(),O.matcher.lastIndex=T +;const e=O.matcher.exec(t);if(!e)break;const n=w(t.substring(T,e.index),e) +;T=e.index+n}w(t.substring(T))}return A.finalize(),x=A.toHTML(),{language:e, +value:x,relevance:C,illegal:!1,_emitter:A,_top:O}}catch(n){ +if(n.message&&n.message.includes("Illegal"))return{language:e,value:V(t), +illegal:!0,relevance:0,_illegalBy:{message:n.message,index:T, +context:t.slice(T-100,T+100),mode:n.mode,resultSoFar:x},_emitter:A};if(s)return{ +language:e,value:V(t),illegal:!1,relevance:0,errorRaised:n,_emitter:A,_top:O} +;throw n}}function E(e,n){n=n||p.languages||Object.keys(a);const t=(e=>{ +const n={value:V(e),illegal:!1,relevance:0,_top:c,_emitter:new p.__emitter(p)} +;return n._emitter.addText(e),n})(e),i=n.filter(N).filter(x).map((n=>f(n,e,!1))) +;i.unshift(t);const r=i.sort(((e,n)=>{ +if(e.relevance!==n.relevance)return n.relevance-e.relevance +;if(e.language&&n.language){if(N(e.language).supersetOf===n.language)return 1 +;if(N(n.language).supersetOf===e.language)return-1}return 0})),[s,o]=r,l=s +;return l.secondBest=o,l}function y(e){let n=null;const t=(e=>{ +let n=e.className+" ";n+=e.parentNode?e.parentNode.className:"" +;const t=p.languageDetectRe.exec(n);if(t){const n=N(t[1]) +;return n||(K(o.replace("{}",t[1])), +K("Falling back to no-highlight mode for this block.",e)),n?t[1]:"no-highlight"} +return n.split(/\s+/).find((e=>_(e)||N(e)))})(e);if(_(t))return +;if(O("before:highlightElement",{el:e,language:t +}),e.dataset.highlighted)return void console.log("Element previously highlighted. To highlight again, first unset `dataset.highlighted`.",e) +;if(e.children.length>0&&(p.ignoreUnescapedHTML||(console.warn("One of your code blocks includes unescaped HTML. This is a potentially serious security risk."), +console.warn("https://github.com/highlightjs/highlight.js/wiki/security"), +console.warn("The element with unescaped HTML:"), +console.warn(e)),p.throwUnescapedHTML))throw new X("One of your code blocks includes unescaped HTML.",e.innerHTML) +;n=e;const a=n.textContent,r=t?h(a,{language:t,ignoreIllegals:!0}):E(a) +;e.innerHTML=r.value,e.dataset.highlighted="yes",((e,n,t)=>{const a=n&&i[n]||t +;e.classList.add("hljs"),e.classList.add("language-"+a) +})(e,t,r.language),e.result={language:r.language,re:r.relevance, +relevance:r.relevance},r.secondBest&&(e.secondBest={ +language:r.secondBest.language,relevance:r.secondBest.relevance +}),O("after:highlightElement",{el:e,result:r,text:a})}let w=!1;function v(){ +if("loading"===document.readyState)return w||window.addEventListener("DOMContentLoaded",(()=>{ +v()}),!1),void(w=!0);document.querySelectorAll(p.cssSelector).forEach(y)} +function N(e){return e=(e||"").toLowerCase(),a[e]||a[i[e]]} +function k(e,{languageName:n}){"string"==typeof e&&(e=[e]),e.forEach((e=>{ +i[e.toLowerCase()]=n}))}function x(e){const n=N(e) +;return n&&!n.disableAutodetect}function O(e,n){const t=e;r.forEach((e=>{ +e[t]&&e[t](n)}))}Object.assign(t,{highlight:h,highlightAuto:E,highlightAll:v, +highlightElement:y, +highlightBlock:e=>(q("10.7.0","highlightBlock will be removed entirely in v12.0"), +q("10.7.0","Please use highlightElement now."),y(e)),configure:e=>{p=J(p,e)}, +initHighlighting:()=>{ +v(),q("10.6.0","initHighlighting() deprecated. Use highlightAll() now.")}, +initHighlightingOnLoad:()=>{ +v(),q("10.6.0","initHighlightingOnLoad() deprecated. Use highlightAll() now.") +},registerLanguage:(e,n)=>{let i=null;try{i=n(t)}catch(n){ +if(P("Language definition for '{}' could not be registered.".replace("{}",e)), +!s)throw n;P(n),i=c} +i.name||(i.name=e),a[e]=i,i.rawDefinition=n.bind(null,t),i.aliases&&k(i.aliases,{ +languageName:e})},unregisterLanguage:e=>{delete a[e] +;for(const n of Object.keys(i))i[n]===e&&delete i[n]}, +listLanguages:()=>Object.keys(a),getLanguage:N,registerAliases:k, +autoDetection:x,inherit:J,addPlugin:e=>{(e=>{ +e["before:highlightBlock"]&&!e["before:highlightElement"]&&(e["before:highlightElement"]=n=>{ +e["before:highlightBlock"](Object.assign({block:n.el},n)) +}),e["after:highlightBlock"]&&!e["after:highlightElement"]&&(e["after:highlightElement"]=n=>{ +e["after:highlightBlock"](Object.assign({block:n.el},n))})})(e),r.push(e)}, +removePlugin:e=>{const n=r.indexOf(e);-1!==n&&r.splice(n,1)}}),t.debugMode=()=>{ +s=!1},t.safeMode=()=>{s=!0},t.versionString="11.11.1",t.regex={concat:b, +lookahead:d,either:m,optional:u,anyNumberOfTimes:g} +;for(const n in C)"object"==typeof C[n]&&e(C[n]);return Object.assign(t,C),t +},ne=ee({});ne.newInstance=()=>ee({});const te=e=>({IMPORTANT:{scope:"meta", +begin:"!important"},BLOCK_COMMENT:e.C_BLOCK_COMMENT_MODE,HEXCOLOR:{ +scope:"number",begin:/#(([0-9a-fA-F]{3,4})|(([0-9a-fA-F]{2}){3,4}))\b/}, +FUNCTION_DISPATCH:{className:"built_in",begin:/[\w-]+(?=\()/}, +ATTRIBUTE_SELECTOR_MODE:{scope:"selector-attr",begin:/\[/,end:/\]/,illegal:"$", +contains:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]},CSS_NUMBER_MODE:{ +scope:"number", +begin:e.NUMBER_RE+"(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?", +relevance:0},CSS_VARIABLE:{className:"attr",begin:/--[A-Za-z_][A-Za-z0-9_-]*/} +}),ae=["a","abbr","address","article","aside","audio","b","blockquote","body","button","canvas","caption","cite","code","dd","del","details","dfn","div","dl","dt","em","fieldset","figcaption","figure","footer","form","h1","h2","h3","h4","h5","h6","header","hgroup","html","i","iframe","img","input","ins","kbd","label","legend","li","main","mark","menu","nav","object","ol","optgroup","option","p","picture","q","quote","samp","section","select","source","span","strong","summary","sup","table","tbody","td","textarea","tfoot","th","thead","time","tr","ul","var","video","defs","g","marker","mask","pattern","svg","switch","symbol","feBlend","feColorMatrix","feComponentTransfer","feComposite","feConvolveMatrix","feDiffuseLighting","feDisplacementMap","feFlood","feGaussianBlur","feImage","feMerge","feMorphology","feOffset","feSpecularLighting","feTile","feTurbulence","linearGradient","radialGradient","stop","circle","ellipse","image","line","path","polygon","polyline","rect","text","use","textPath","tspan","foreignObject","clipPath"],ie=["any-hover","any-pointer","aspect-ratio","color","color-gamut","color-index","device-aspect-ratio","device-height","device-width","display-mode","forced-colors","grid","height","hover","inverted-colors","monochrome","orientation","overflow-block","overflow-inline","pointer","prefers-color-scheme","prefers-contrast","prefers-reduced-motion","prefers-reduced-transparency","resolution","scan","scripting","update","width","min-width","max-width","min-height","max-height"].sort().reverse(),re=["active","any-link","blank","checked","current","default","defined","dir","disabled","drop","empty","enabled","first","first-child","first-of-type","fullscreen","future","focus","focus-visible","focus-within","has","host","host-context","hover","indeterminate","in-range","invalid","is","lang","last-child","last-of-type","left","link","local-link","not","nth-child","nth-col","nth-last-child","nth-last-col","nth-last-of-type","nth-of-type","only-child","only-of-type","optional","out-of-range","past","placeholder-shown","read-only","read-write","required","right","root","scope","target","target-within","user-invalid","valid","visited","where"].sort().reverse(),se=["after","backdrop","before","cue","cue-region","first-letter","first-line","grammar-error","marker","part","placeholder","selection","slotted","spelling-error"].sort().reverse(),oe=["accent-color","align-content","align-items","align-self","alignment-baseline","all","anchor-name","animation","animation-composition","animation-delay","animation-direction","animation-duration","animation-fill-mode","animation-iteration-count","animation-name","animation-play-state","animation-range","animation-range-end","animation-range-start","animation-timeline","animation-timing-function","appearance","aspect-ratio","backdrop-filter","backface-visibility","background","background-attachment","background-blend-mode","background-clip","background-color","background-image","background-origin","background-position","background-position-x","background-position-y","background-repeat","background-size","baseline-shift","block-size","border","border-block","border-block-color","border-block-end","border-block-end-color","border-block-end-style","border-block-end-width","border-block-start","border-block-start-color","border-block-start-style","border-block-start-width","border-block-style","border-block-width","border-bottom","border-bottom-color","border-bottom-left-radius","border-bottom-right-radius","border-bottom-style","border-bottom-width","border-collapse","border-color","border-end-end-radius","border-end-start-radius","border-image","border-image-outset","border-image-repeat","border-image-slice","border-image-source","border-image-width","border-inline","border-inline-color","border-inline-end","border-inline-end-color","border-inline-end-style","border-inline-end-width","border-inline-start","border-inline-start-color","border-inline-start-style","border-inline-start-width","border-inline-style","border-inline-width","border-left","border-left-color","border-left-style","border-left-width","border-radius","border-right","border-right-color","border-right-style","border-right-width","border-spacing","border-start-end-radius","border-start-start-radius","border-style","border-top","border-top-color","border-top-left-radius","border-top-right-radius","border-top-style","border-top-width","border-width","bottom","box-align","box-decoration-break","box-direction","box-flex","box-flex-group","box-lines","box-ordinal-group","box-orient","box-pack","box-shadow","box-sizing","break-after","break-before","break-inside","caption-side","caret-color","clear","clip","clip-path","clip-rule","color","color-interpolation","color-interpolation-filters","color-profile","color-rendering","color-scheme","column-count","column-fill","column-gap","column-rule","column-rule-color","column-rule-style","column-rule-width","column-span","column-width","columns","contain","contain-intrinsic-block-size","contain-intrinsic-height","contain-intrinsic-inline-size","contain-intrinsic-size","contain-intrinsic-width","container","container-name","container-type","content","content-visibility","counter-increment","counter-reset","counter-set","cue","cue-after","cue-before","cursor","cx","cy","direction","display","dominant-baseline","empty-cells","enable-background","field-sizing","fill","fill-opacity","fill-rule","filter","flex","flex-basis","flex-direction","flex-flow","flex-grow","flex-shrink","flex-wrap","float","flood-color","flood-opacity","flow","font","font-display","font-family","font-feature-settings","font-kerning","font-language-override","font-optical-sizing","font-palette","font-size","font-size-adjust","font-smooth","font-smoothing","font-stretch","font-style","font-synthesis","font-synthesis-position","font-synthesis-small-caps","font-synthesis-style","font-synthesis-weight","font-variant","font-variant-alternates","font-variant-caps","font-variant-east-asian","font-variant-emoji","font-variant-ligatures","font-variant-numeric","font-variant-position","font-variation-settings","font-weight","forced-color-adjust","gap","glyph-orientation-horizontal","glyph-orientation-vertical","grid","grid-area","grid-auto-columns","grid-auto-flow","grid-auto-rows","grid-column","grid-column-end","grid-column-start","grid-gap","grid-row","grid-row-end","grid-row-start","grid-template","grid-template-areas","grid-template-columns","grid-template-rows","hanging-punctuation","height","hyphenate-character","hyphenate-limit-chars","hyphens","icon","image-orientation","image-rendering","image-resolution","ime-mode","initial-letter","initial-letter-align","inline-size","inset","inset-area","inset-block","inset-block-end","inset-block-start","inset-inline","inset-inline-end","inset-inline-start","isolation","justify-content","justify-items","justify-self","kerning","left","letter-spacing","lighting-color","line-break","line-height","line-height-step","list-style","list-style-image","list-style-position","list-style-type","margin","margin-block","margin-block-end","margin-block-start","margin-bottom","margin-inline","margin-inline-end","margin-inline-start","margin-left","margin-right","margin-top","margin-trim","marker","marker-end","marker-mid","marker-start","marks","mask","mask-border","mask-border-mode","mask-border-outset","mask-border-repeat","mask-border-slice","mask-border-source","mask-border-width","mask-clip","mask-composite","mask-image","mask-mode","mask-origin","mask-position","mask-repeat","mask-size","mask-type","masonry-auto-flow","math-depth","math-shift","math-style","max-block-size","max-height","max-inline-size","max-width","min-block-size","min-height","min-inline-size","min-width","mix-blend-mode","nav-down","nav-index","nav-left","nav-right","nav-up","none","normal","object-fit","object-position","offset","offset-anchor","offset-distance","offset-path","offset-position","offset-rotate","opacity","order","orphans","outline","outline-color","outline-offset","outline-style","outline-width","overflow","overflow-anchor","overflow-block","overflow-clip-margin","overflow-inline","overflow-wrap","overflow-x","overflow-y","overlay","overscroll-behavior","overscroll-behavior-block","overscroll-behavior-inline","overscroll-behavior-x","overscroll-behavior-y","padding","padding-block","padding-block-end","padding-block-start","padding-bottom","padding-inline","padding-inline-end","padding-inline-start","padding-left","padding-right","padding-top","page","page-break-after","page-break-before","page-break-inside","paint-order","pause","pause-after","pause-before","perspective","perspective-origin","place-content","place-items","place-self","pointer-events","position","position-anchor","position-visibility","print-color-adjust","quotes","r","resize","rest","rest-after","rest-before","right","rotate","row-gap","ruby-align","ruby-position","scale","scroll-behavior","scroll-margin","scroll-margin-block","scroll-margin-block-end","scroll-margin-block-start","scroll-margin-bottom","scroll-margin-inline","scroll-margin-inline-end","scroll-margin-inline-start","scroll-margin-left","scroll-margin-right","scroll-margin-top","scroll-padding","scroll-padding-block","scroll-padding-block-end","scroll-padding-block-start","scroll-padding-bottom","scroll-padding-inline","scroll-padding-inline-end","scroll-padding-inline-start","scroll-padding-left","scroll-padding-right","scroll-padding-top","scroll-snap-align","scroll-snap-stop","scroll-snap-type","scroll-timeline","scroll-timeline-axis","scroll-timeline-name","scrollbar-color","scrollbar-gutter","scrollbar-width","shape-image-threshold","shape-margin","shape-outside","shape-rendering","speak","speak-as","src","stop-color","stop-opacity","stroke","stroke-dasharray","stroke-dashoffset","stroke-linecap","stroke-linejoin","stroke-miterlimit","stroke-opacity","stroke-width","tab-size","table-layout","text-align","text-align-all","text-align-last","text-anchor","text-combine-upright","text-decoration","text-decoration-color","text-decoration-line","text-decoration-skip","text-decoration-skip-ink","text-decoration-style","text-decoration-thickness","text-emphasis","text-emphasis-color","text-emphasis-position","text-emphasis-style","text-indent","text-justify","text-orientation","text-overflow","text-rendering","text-shadow","text-size-adjust","text-transform","text-underline-offset","text-underline-position","text-wrap","text-wrap-mode","text-wrap-style","timeline-scope","top","touch-action","transform","transform-box","transform-origin","transform-style","transition","transition-behavior","transition-delay","transition-duration","transition-property","transition-timing-function","translate","unicode-bidi","user-modify","user-select","vector-effect","vertical-align","view-timeline","view-timeline-axis","view-timeline-inset","view-timeline-name","view-transition-name","visibility","voice-balance","voice-duration","voice-family","voice-pitch","voice-range","voice-rate","voice-stress","voice-volume","white-space","white-space-collapse","widows","width","will-change","word-break","word-spacing","word-wrap","writing-mode","x","y","z-index","zoom"].sort().reverse(),le=re.concat(se).sort().reverse() +;var ce="[0-9](_*[0-9])*",de=`\\.(${ce})`,ge="[0-9a-fA-F](_*[0-9a-fA-F])*",ue={ +className:"number",variants:[{ +begin:`(\\b(${ce})((${de})|\\.)?|(${de}))[eE][+-]?(${ce})[fFdD]?\\b`},{ +begin:`\\b(${ce})((${de})[fFdD]?\\b|\\.([fFdD]\\b)?)`},{ +begin:`(${de})[fFdD]?\\b`},{begin:`\\b(${ce})[fFdD]\\b`},{ +begin:`\\b0[xX]((${ge})\\.?|(${ge})?\\.(${ge}))[pP][+-]?(${ce})[fFdD]?\\b`},{ +begin:"\\b(0|[1-9](_*[0-9])*)[lL]?\\b"},{begin:`\\b0[xX](${ge})[lL]?\\b`},{ +begin:"\\b0(_*[0-7])*[lL]?\\b"},{begin:"\\b0[bB][01](_*[01])*[lL]?\\b"}], +relevance:0};function be(e,n,t){return-1===t?"":e.replace(n,(a=>be(e,n,t-1)))} +const me="[A-Za-z$_][0-9A-Za-z$_]*",pe=["as","in","of","if","for","while","finally","var","new","function","do","return","void","else","break","catch","instanceof","with","throw","case","default","try","switch","continue","typeof","delete","let","yield","const","class","debugger","async","await","static","import","from","export","extends","using"],_e=["true","false","null","undefined","NaN","Infinity"],he=["Object","Function","Boolean","Symbol","Math","Date","Number","BigInt","String","RegExp","Array","Float32Array","Float64Array","Int8Array","Uint8Array","Uint8ClampedArray","Int16Array","Int32Array","Uint16Array","Uint32Array","BigInt64Array","BigUint64Array","Set","Map","WeakSet","WeakMap","ArrayBuffer","SharedArrayBuffer","Atomics","DataView","JSON","Promise","Generator","GeneratorFunction","AsyncFunction","Reflect","Proxy","Intl","WebAssembly"],fe=["Error","EvalError","InternalError","RangeError","ReferenceError","SyntaxError","TypeError","URIError"],Ee=["setInterval","setTimeout","clearInterval","clearTimeout","require","exports","eval","isFinite","isNaN","parseFloat","parseInt","decodeURI","decodeURIComponent","encodeURI","encodeURIComponent","escape","unescape"],ye=["arguments","this","super","console","window","document","localStorage","sessionStorage","module","global"],we=[].concat(Ee,he,fe) +;function ve(e){const n=e.regex,t=me,a={begin:/<[A-Za-z0-9\\._:-]+/, +end:/\/[A-Za-z0-9\\._:-]+>|\/>/,isTrulyOpeningTag:(e,n)=>{ +const t=e[0].length+e.index,a=e.input[t] +;if("<"===a||","===a)return void n.ignoreMatch();let i +;">"===a&&(((e,{after:n})=>{const t="e+"\\s*\\(")), +n.concat("(?!",N.join("|"),")")),t,n.lookahead(/\s*\(/)), +className:"title.function",relevance:0};var N;const k={ +begin:n.concat(/\./,n.lookahead(n.concat(t,/(?![0-9A-Za-z$_(])/))),end:t, +excludeBegin:!0,keywords:"prototype",className:"property",relevance:0},x={ +match:[/get|set/,/\s+/,t,/(?=\()/],className:{1:"keyword",3:"title.function"}, +contains:[{begin:/\(\)/},f] +},O="(\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)|"+e.UNDERSCORE_IDENT_RE+")\\s*=>",M={ +match:[/const|var|let/,/\s+/,t,/\s*/,/=\s*/,/(async\s*)?/,n.lookahead(O)], +keywords:"async",className:{1:"keyword",3:"title.function"},contains:[f]} +;return{name:"JavaScript",aliases:["js","jsx","mjs","cjs"],keywords:i,exports:{ +PARAMS_CONTAINS:h,CLASS_REFERENCE:y},illegal:/#(?![$_A-z])/, +contains:[e.SHEBANG({label:"shebang",binary:"node",relevance:5}),{ +label:"use_strict",className:"meta",relevance:10, +begin:/^\s*['"]use (strict|asm)['"]/ +},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,d,g,u,b,m,{match:/\$\d+/},l,y,{ +scope:"attr",match:t+n.lookahead(":"),relevance:0},M,{ +begin:"("+e.RE_STARTERS_RE+"|\\b(case|return|throw)\\b)\\s*", +keywords:"return throw case",relevance:0,contains:[m,e.REGEXP_MODE,{ +className:"function",begin:O,returnBegin:!0,end:"\\s*=>",contains:[{ +className:"params",variants:[{begin:e.UNDERSCORE_IDENT_RE,relevance:0},{ +className:null,begin:/\(\s*\)/,skip:!0},{begin:/(\s*)\(/,end:/\)/, +excludeBegin:!0,excludeEnd:!0,keywords:i,contains:h}]}]},{begin:/,/,relevance:0 +},{match:/\s+/,relevance:0},{variants:[{begin:"<>",end:""},{ +match:/<[A-Za-z0-9\\._:-]+\s*\/>/},{begin:a.begin, +"on:begin":a.isTrulyOpeningTag,end:a.end}],subLanguage:"xml",contains:[{ +begin:a.begin,end:a.end,skip:!0,contains:["self"]}]}]},w,{ +beginKeywords:"while if switch catch for"},{ +begin:"\\b(?!function)"+e.UNDERSCORE_IDENT_RE+"\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)\\s*\\{", +returnBegin:!0,label:"func.def",contains:[f,e.inherit(e.TITLE_MODE,{begin:t, +className:"title.function"})]},{match:/\.\.\./,relevance:0},k,{match:"\\$"+t, +relevance:0},{match:[/\bconstructor(?=\s*\()/],className:{1:"title.function"}, +contains:[f]},v,{relevance:0,match:/\b[A-Z][A-Z_0-9]+\b/, +className:"variable.constant"},E,x,{match:/\$[(.]/}]}} +const Ne=e=>b(/\b/,e,/\w$/.test(e)?/\b/:/\B/),ke=["Protocol","Type"].map(Ne),xe=["init","self"].map(Ne),Oe=["Any","Self"],Me=["actor","any","associatedtype","async","await",/as\?/,/as!/,"as","borrowing","break","case","catch","class","consume","consuming","continue","convenience","copy","default","defer","deinit","didSet","distributed","do","dynamic","each","else","enum","extension","fallthrough",/fileprivate\(set\)/,"fileprivate","final","for","func","get","guard","if","import","indirect","infix",/init\?/,/init!/,"inout",/internal\(set\)/,"internal","in","is","isolated","nonisolated","lazy","let","macro","mutating","nonmutating",/open\(set\)/,"open","operator","optional","override","package","postfix","precedencegroup","prefix",/private\(set\)/,"private","protocol",/public\(set\)/,"public","repeat","required","rethrows","return","set","some","static","struct","subscript","super","switch","throws","throw",/try\?/,/try!/,"try","typealias",/unowned\(safe\)/,/unowned\(unsafe\)/,"unowned","var","weak","where","while","willSet"],Ae=["false","nil","true"],Se=["assignment","associativity","higherThan","left","lowerThan","none","right"],Ce=["#colorLiteral","#column","#dsohandle","#else","#elseif","#endif","#error","#file","#fileID","#fileLiteral","#filePath","#function","#if","#imageLiteral","#keyPath","#line","#selector","#sourceLocation","#warning"],Te=["abs","all","any","assert","assertionFailure","debugPrint","dump","fatalError","getVaList","isKnownUniquelyReferenced","max","min","numericCast","pointwiseMax","pointwiseMin","precondition","preconditionFailure","print","readLine","repeatElement","sequence","stride","swap","swift_unboxFromSwiftValueWithType","transcode","type","unsafeBitCast","unsafeDowncast","withExtendedLifetime","withUnsafeMutablePointer","withUnsafePointer","withVaList","withoutActuallyEscaping","zip"],Re=m(/[/=\-+!*%<>&|^~?]/,/[\u00A1-\u00A7]/,/[\u00A9\u00AB]/,/[\u00AC\u00AE]/,/[\u00B0\u00B1]/,/[\u00B6\u00BB\u00BF\u00D7\u00F7]/,/[\u2016-\u2017]/,/[\u2020-\u2027]/,/[\u2030-\u203E]/,/[\u2041-\u2053]/,/[\u2055-\u205E]/,/[\u2190-\u23FF]/,/[\u2500-\u2775]/,/[\u2794-\u2BFF]/,/[\u2E00-\u2E7F]/,/[\u3001-\u3003]/,/[\u3008-\u3020]/,/[\u3030]/),De=m(Re,/[\u0300-\u036F]/,/[\u1DC0-\u1DFF]/,/[\u20D0-\u20FF]/,/[\uFE00-\uFE0F]/,/[\uFE20-\uFE2F]/),Ie=b(Re,De,"*"),Le=m(/[a-zA-Z_]/,/[\u00A8\u00AA\u00AD\u00AF\u00B2-\u00B5\u00B7-\u00BA]/,/[\u00BC-\u00BE\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u00FF]/,/[\u0100-\u02FF\u0370-\u167F\u1681-\u180D\u180F-\u1DBF]/,/[\u1E00-\u1FFF]/,/[\u200B-\u200D\u202A-\u202E\u203F-\u2040\u2054\u2060-\u206F]/,/[\u2070-\u20CF\u2100-\u218F\u2460-\u24FF\u2776-\u2793]/,/[\u2C00-\u2DFF\u2E80-\u2FFF]/,/[\u3004-\u3007\u3021-\u302F\u3031-\u303F\u3040-\uD7FF]/,/[\uF900-\uFD3D\uFD40-\uFDCF\uFDF0-\uFE1F\uFE30-\uFE44]/,/[\uFE47-\uFEFE\uFF00-\uFFFD]/),Be=m(Le,/\d/,/[\u0300-\u036F\u1DC0-\u1DFF\u20D0-\u20FF\uFE20-\uFE2F]/),$e=b(Le,Be,"*"),Fe=b(/[A-Z]/,Be,"*"),ze=["attached","autoclosure",b(/convention\(/,m("swift","block","c"),/\)/),"discardableResult","dynamicCallable","dynamicMemberLookup","escaping","freestanding","frozen","GKInspectable","IBAction","IBDesignable","IBInspectable","IBOutlet","IBSegueAction","inlinable","main","nonobjc","NSApplicationMain","NSCopying","NSManaged",b(/objc\(/,$e,/\)/),"objc","objcMembers","propertyWrapper","requires_stored_property_inits","resultBuilder","Sendable","testable","UIApplicationMain","unchecked","unknown","usableFromInline","warn_unqualified_access"],je=["iOS","iOSApplicationExtension","macOS","macOSApplicationExtension","macCatalyst","macCatalystApplicationExtension","watchOS","watchOSApplicationExtension","tvOS","tvOSApplicationExtension","swift"] +;var Ue=Object.freeze({__proto__:null,grmr_bash:e=>{const n=e.regex,t={},a={ +begin:/\$\{/,end:/\}/,contains:["self",{begin:/:-/,contains:[t]}]} +;Object.assign(t,{className:"variable",variants:[{ +begin:n.concat(/\$[\w\d#@][\w\d_]*/,"(?![\\w\\d])(?![$])")},a]});const i={ +className:"subst",begin:/\$\(/,end:/\)/,contains:[e.BACKSLASH_ESCAPE] +},r=e.inherit(e.COMMENT(),{match:[/(^|\s)/,/#.*$/],scope:{2:"comment"}}),s={ +begin:/<<-?\s*(?=\w+)/,starts:{contains:[e.END_SAME_AS_BEGIN({begin:/(\w+)/, +end:/(\w+)/,className:"string"})]}},o={className:"string",begin:/"/,end:/"/, +contains:[e.BACKSLASH_ESCAPE,t,i]};i.contains.push(o);const l={begin:/\$?\(\(/, +end:/\)\)/,contains:[{begin:/\d+#[0-9a-f]+/,className:"number"},e.NUMBER_MODE,t] +},c=e.SHEBANG({binary:"(fish|bash|zsh|sh|csh|ksh|tcsh|dash|scsh)",relevance:10 +}),d={className:"function",begin:/\w[\w\d_]*\s*\(\s*\)\s*\{/,returnBegin:!0, +contains:[e.inherit(e.TITLE_MODE,{begin:/\w[\w\d_]*/})],relevance:0};return{ +name:"Bash",aliases:["sh","zsh"],keywords:{$pattern:/\b[a-z][a-z0-9._-]+\b/, +keyword:["if","then","else","elif","fi","time","for","while","until","in","do","done","case","esac","coproc","function","select"], +literal:["true","false"], +built_in:["break","cd","continue","eval","exec","exit","export","getopts","hash","pwd","readonly","return","shift","test","times","trap","umask","unset","alias","bind","builtin","caller","command","declare","echo","enable","help","let","local","logout","mapfile","printf","read","readarray","source","sudo","type","typeset","ulimit","unalias","set","shopt","autoload","bg","bindkey","bye","cap","chdir","clone","comparguments","compcall","compctl","compdescribe","compfiles","compgroups","compquote","comptags","comptry","compvalues","dirs","disable","disown","echotc","echoti","emulate","fc","fg","float","functions","getcap","getln","history","integer","jobs","kill","limit","log","noglob","popd","print","pushd","pushln","rehash","sched","setcap","setopt","stat","suspend","ttyctl","unfunction","unhash","unlimit","unsetopt","vared","wait","whence","where","which","zcompile","zformat","zftp","zle","zmodload","zparseopts","zprof","zpty","zregexparse","zsocket","zstyle","ztcp","chcon","chgrp","chown","chmod","cp","dd","df","dir","dircolors","ln","ls","mkdir","mkfifo","mknod","mktemp","mv","realpath","rm","rmdir","shred","sync","touch","truncate","vdir","b2sum","base32","base64","cat","cksum","comm","csplit","cut","expand","fmt","fold","head","join","md5sum","nl","numfmt","od","paste","ptx","pr","sha1sum","sha224sum","sha256sum","sha384sum","sha512sum","shuf","sort","split","sum","tac","tail","tr","tsort","unexpand","uniq","wc","arch","basename","chroot","date","dirname","du","echo","env","expr","factor","groups","hostid","id","link","logname","nice","nohup","nproc","pathchk","pinky","printenv","printf","pwd","readlink","runcon","seq","sleep","stat","stdbuf","stty","tee","test","timeout","tty","uname","unlink","uptime","users","who","whoami","yes"] +},contains:[c,e.SHEBANG(),d,l,r,s,{match:/(\/[a-z._-]+)+/},o,{match:/\\"/},{ +className:"string",begin:/'/,end:/'/},{match:/\\'/},t]}},grmr_c:e=>{ +const n=e.regex,t=e.COMMENT("//","$",{contains:[{begin:/\\\n/}] +}),a="decltype\\(auto\\)",i="[a-zA-Z_]\\w*::",r="("+a+"|"+n.optional(i)+"[a-zA-Z_]\\w*"+n.optional("<[^<>]+>")+")",s={ +className:"type",variants:[{begin:"\\b[a-z\\d_]*_t\\b"},{ +match:/\batomic_[a-z]{3,6}\b/}]},o={className:"string",variants:[{ +begin:'(u8?|U|L)?"',end:'"',illegal:"\\n",contains:[e.BACKSLASH_ESCAPE]},{ +begin:"(u8?|U|L)?'(\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\S)|.)", +end:"'",illegal:"."},e.END_SAME_AS_BEGIN({ +begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},l={ +className:"number",variants:[{match:/\b(0b[01']+)/},{ +match:/(-?)\b([\d']+(\.[\d']*)?|\.[\d']+)((ll|LL|l|L)(u|U)?|(u|U)(ll|LL|l|L)?|f|F|b|B)/ +},{ +match:/(-?)\b(0[xX][a-fA-F0-9]+(?:'[a-fA-F0-9]+)*(?:\.[a-fA-F0-9]*(?:'[a-fA-F0-9]*)*)?(?:[pP][-+]?[0-9]+)?(l|L)?(u|U)?)/ +},{match:/(-?)\b\d+(?:'\d+)*(?:\.\d*(?:'\d*)*)?(?:[eE][-+]?\d+)?/}],relevance:0 +},c={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{ +keyword:"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef elifdef elifndef include" +},contains:[{begin:/\\\n/,relevance:0},e.inherit(o,{className:"string"}),{ +className:"string",begin:/<.*?>/},t,e.C_BLOCK_COMMENT_MODE]},d={ +className:"title",begin:n.optional(i)+e.IDENT_RE,relevance:0 +},g=n.optional(i)+e.IDENT_RE+"\\s*\\(",u={ +keyword:["asm","auto","break","case","continue","default","do","else","enum","extern","for","fortran","goto","if","inline","register","restrict","return","sizeof","typeof","typeof_unqual","struct","switch","typedef","union","volatile","while","_Alignas","_Alignof","_Atomic","_Generic","_Noreturn","_Static_assert","_Thread_local","alignas","alignof","noreturn","static_assert","thread_local","_Pragma"], +type:["float","double","signed","unsigned","int","short","long","char","void","_Bool","_BitInt","_Complex","_Imaginary","_Decimal32","_Decimal64","_Decimal96","_Decimal128","_Decimal64x","_Decimal128x","_Float16","_Float32","_Float64","_Float128","_Float32x","_Float64x","_Float128x","const","static","constexpr","complex","bool","imaginary"], +literal:"true false NULL", +built_in:"std string wstring cin cout cerr clog stdin stdout stderr stringstream istringstream ostringstream auto_ptr deque list queue stack vector map set pair bitset multiset multimap unordered_set unordered_map unordered_multiset unordered_multimap priority_queue make_pair array shared_ptr abort terminate abs acos asin atan2 atan calloc ceil cosh cos exit exp fabs floor fmod fprintf fputs free frexp fscanf future isalnum isalpha iscntrl isdigit isgraph islower isprint ispunct isspace isupper isxdigit tolower toupper labs ldexp log10 log malloc realloc memchr memcmp memcpy memset modf pow printf putchar puts scanf sinh sin snprintf sprintf sqrt sscanf strcat strchr strcmp strcpy strcspn strlen strncat strncmp strncpy strpbrk strrchr strspn strstr tanh tan vfprintf vprintf vsprintf endl initializer_list unique_ptr" +},b=[c,s,t,e.C_BLOCK_COMMENT_MODE,l,o],m={variants:[{begin:/=/,end:/;/},{ +begin:/\(/,end:/\)/},{beginKeywords:"new throw return else",end:/;/}], +keywords:u,contains:b.concat([{begin:/\(/,end:/\)/,keywords:u, +contains:b.concat(["self"]),relevance:0}]),relevance:0},p={ +begin:"("+r+"[\\*&\\s]+)+"+g,returnBegin:!0,end:/[{;=]/,excludeEnd:!0, +keywords:u,illegal:/[^\w\s\*&:<>.]/,contains:[{begin:a,keywords:u,relevance:0},{ +begin:g,returnBegin:!0,contains:[e.inherit(d,{className:"title.function"})], +relevance:0},{relevance:0,match:/,/},{className:"params",begin:/\(/,end:/\)/, +keywords:u,relevance:0,contains:[t,e.C_BLOCK_COMMENT_MODE,o,l,s,{begin:/\(/, +end:/\)/,keywords:u,relevance:0,contains:["self",t,e.C_BLOCK_COMMENT_MODE,o,l,s] +}]},s,t,e.C_BLOCK_COMMENT_MODE,c]};return{name:"C",aliases:["h"],keywords:u, +disableAutodetect:!0,illegal:"=]/,contains:[{ +beginKeywords:"final class struct"},e.TITLE_MODE]}]),exports:{preprocessor:c, +strings:o,keywords:u}}},grmr_cpp:e=>{const n=e.regex,t=e.COMMENT("//","$",{ +contains:[{begin:/\\\n/}] +}),a="decltype\\(auto\\)",i="[a-zA-Z_]\\w*::",r="(?!struct)("+a+"|"+n.optional(i)+"[a-zA-Z_]\\w*"+n.optional("<[^<>]+>")+")",s={ +className:"type",begin:"\\b[a-z\\d_]*_t\\b"},o={className:"string",variants:[{ +begin:'(u8?|U|L)?"',end:'"',illegal:"\\n",contains:[e.BACKSLASH_ESCAPE]},{ +begin:"(u8?|U|L)?'(\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\S)|.)", +end:"'",illegal:"."},e.END_SAME_AS_BEGIN({ +begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},l={ +className:"number",variants:[{ +begin:"[+-]?(?:(?:[0-9](?:'?[0-9])*\\.(?:[0-9](?:'?[0-9])*)?|\\.[0-9](?:'?[0-9])*)(?:[Ee][+-]?[0-9](?:'?[0-9])*)?|[0-9](?:'?[0-9])*[Ee][+-]?[0-9](?:'?[0-9])*|0[Xx](?:[0-9A-Fa-f](?:'?[0-9A-Fa-f])*(?:\\.(?:[0-9A-Fa-f](?:'?[0-9A-Fa-f])*)?)?|\\.[0-9A-Fa-f](?:'?[0-9A-Fa-f])*)[Pp][+-]?[0-9](?:'?[0-9])*)(?:[Ff](?:16|32|64|128)?|(BF|bf)16|[Ll]|)" +},{ +begin:"[+-]?\\b(?:0[Bb][01](?:'?[01])*|0[Xx][0-9A-Fa-f](?:'?[0-9A-Fa-f])*|0(?:'?[0-7])*|[1-9](?:'?[0-9])*)(?:[Uu](?:LL?|ll?)|[Uu][Zz]?|(?:LL?|ll?)[Uu]?|[Zz][Uu]|)" +}],relevance:0},c={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{ +keyword:"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef include" +},contains:[{begin:/\\\n/,relevance:0},e.inherit(o,{className:"string"}),{ +className:"string",begin:/<.*?>/},t,e.C_BLOCK_COMMENT_MODE]},d={ +className:"title",begin:n.optional(i)+e.IDENT_RE,relevance:0 +},g=n.optional(i)+e.IDENT_RE+"\\s*\\(",u={ +type:["bool","char","char16_t","char32_t","char8_t","double","float","int","long","short","void","wchar_t","unsigned","signed","const","static"], +keyword:["alignas","alignof","and","and_eq","asm","atomic_cancel","atomic_commit","atomic_noexcept","auto","bitand","bitor","break","case","catch","class","co_await","co_return","co_yield","compl","concept","const_cast|10","consteval","constexpr","constinit","continue","decltype","default","delete","do","dynamic_cast|10","else","enum","explicit","export","extern","false","final","for","friend","goto","if","import","inline","module","mutable","namespace","new","noexcept","not","not_eq","nullptr","operator","or","or_eq","override","private","protected","public","reflexpr","register","reinterpret_cast|10","requires","return","sizeof","static_assert","static_cast|10","struct","switch","synchronized","template","this","thread_local","throw","transaction_safe","transaction_safe_dynamic","true","try","typedef","typeid","typename","union","using","virtual","volatile","while","xor","xor_eq"], +literal:["NULL","false","nullopt","nullptr","true"],built_in:["_Pragma"], +_type_hints:["any","auto_ptr","barrier","binary_semaphore","bitset","complex","condition_variable","condition_variable_any","counting_semaphore","deque","false_type","flat_map","flat_set","future","imaginary","initializer_list","istringstream","jthread","latch","lock_guard","multimap","multiset","mutex","optional","ostringstream","packaged_task","pair","promise","priority_queue","queue","recursive_mutex","recursive_timed_mutex","scoped_lock","set","shared_future","shared_lock","shared_mutex","shared_timed_mutex","shared_ptr","stack","string_view","stringstream","timed_mutex","thread","true_type","tuple","unique_lock","unique_ptr","unordered_map","unordered_multimap","unordered_multiset","unordered_set","variant","vector","weak_ptr","wstring","wstring_view"] +},b={className:"function.dispatch",relevance:0,keywords:{ +_hint:["abort","abs","acos","apply","as_const","asin","atan","atan2","calloc","ceil","cerr","cin","clog","cos","cosh","cout","declval","endl","exchange","exit","exp","fabs","floor","fmod","forward","fprintf","fputs","free","frexp","fscanf","future","invoke","isalnum","isalpha","iscntrl","isdigit","isgraph","islower","isprint","ispunct","isspace","isupper","isxdigit","labs","launder","ldexp","log","log10","make_pair","make_shared","make_shared_for_overwrite","make_tuple","make_unique","malloc","memchr","memcmp","memcpy","memset","modf","move","pow","printf","putchar","puts","realloc","scanf","sin","sinh","snprintf","sprintf","sqrt","sscanf","std","stderr","stdin","stdout","strcat","strchr","strcmp","strcpy","strcspn","strlen","strncat","strncmp","strncpy","strpbrk","strrchr","strspn","strstr","swap","tan","tanh","terminate","to_underlying","tolower","toupper","vfprintf","visit","vprintf","vsprintf"] +}, +begin:n.concat(/\b/,/(?!decltype)/,/(?!if)/,/(?!for)/,/(?!switch)/,/(?!while)/,e.IDENT_RE,n.lookahead(/(<[^<>]+>|)\s*\(/)) +},m=[b,c,s,t,e.C_BLOCK_COMMENT_MODE,l,o],p={variants:[{begin:/=/,end:/;/},{ +begin:/\(/,end:/\)/},{beginKeywords:"new throw return else",end:/;/}], +keywords:u,contains:m.concat([{begin:/\(/,end:/\)/,keywords:u, +contains:m.concat(["self"]),relevance:0}]),relevance:0},_={className:"function", +begin:"("+r+"[\\*&\\s]+)+"+g,returnBegin:!0,end:/[{;=]/,excludeEnd:!0, +keywords:u,illegal:/[^\w\s\*&:<>.]/,contains:[{begin:a,keywords:u,relevance:0},{ +begin:g,returnBegin:!0,contains:[d],relevance:0},{begin:/::/,relevance:0},{ +begin:/:/,endsWithParent:!0,contains:[o,l]},{relevance:0,match:/,/},{ +className:"params",begin:/\(/,end:/\)/,keywords:u,relevance:0, +contains:[t,e.C_BLOCK_COMMENT_MODE,o,l,s,{begin:/\(/,end:/\)/,keywords:u, +relevance:0,contains:["self",t,e.C_BLOCK_COMMENT_MODE,o,l,s]}] +},s,t,e.C_BLOCK_COMMENT_MODE,c]};return{name:"C++", +aliases:["cc","c++","h++","hpp","hh","hxx","cxx"],keywords:u,illegal:"",keywords:u,contains:["self",s]},{begin:e.IDENT_RE+"::",keywords:u},{ +match:[/\b(?:enum(?:\s+(?:class|struct))?|class|struct|union)/,/\s+/,/\w+/], +className:{1:"keyword",3:"title.class"}}])}},grmr_csharp:e=>{const n={ +keyword:["abstract","as","base","break","case","catch","class","const","continue","do","else","event","explicit","extern","finally","fixed","for","foreach","goto","if","implicit","in","interface","internal","is","lock","namespace","new","operator","out","override","params","private","protected","public","readonly","record","ref","return","scoped","sealed","sizeof","stackalloc","static","struct","switch","this","throw","try","typeof","unchecked","unsafe","using","virtual","void","volatile","while"].concat(["add","alias","and","ascending","args","async","await","by","descending","dynamic","equals","file","from","get","global","group","init","into","join","let","nameof","not","notnull","on","or","orderby","partial","record","remove","required","scoped","select","set","unmanaged","value|0","var","when","where","with","yield"]), +built_in:["bool","byte","char","decimal","delegate","double","dynamic","enum","float","int","long","nint","nuint","object","sbyte","short","string","ulong","uint","ushort"], +literal:["default","false","null","true"]},t=e.inherit(e.TITLE_MODE,{ +begin:"[a-zA-Z](\\.?\\w)*"}),a={className:"number",variants:[{ +begin:"\\b(0b[01']+)"},{ +begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)(u|U|l|L|ul|UL|f|F|b|B)"},{ +begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)" +}],relevance:0},i={className:"string",begin:'@"',end:'"',contains:[{begin:'""'}] +},r=e.inherit(i,{illegal:/\n/}),s={className:"subst",begin:/\{/,end:/\}/, +keywords:n},o=e.inherit(s,{illegal:/\n/}),l={className:"string",begin:/\$"/, +end:'"',illegal:/\n/,contains:[{begin:/\{\{/},{begin:/\}\}/ +},e.BACKSLASH_ESCAPE,o]},c={className:"string",begin:/\$@"/,end:'"',contains:[{ +begin:/\{\{/},{begin:/\}\}/},{begin:'""'},s]},d=e.inherit(c,{illegal:/\n/, +contains:[{begin:/\{\{/},{begin:/\}\}/},{begin:'""'},o]}) +;s.contains=[c,l,i,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,a,e.C_BLOCK_COMMENT_MODE], +o.contains=[d,l,r,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,a,e.inherit(e.C_BLOCK_COMMENT_MODE,{ +illegal:/\n/})];const g={variants:[{className:"string", +begin:/"""("*)(?!")(.|\n)*?"""\1/,relevance:1 +},c,l,i,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]},u={begin:"<",end:">", +contains:[{beginKeywords:"in out"},t] +},b=e.IDENT_RE+"(<"+e.IDENT_RE+"(\\s*,\\s*"+e.IDENT_RE+")*>)?(\\[\\])?",m={ +begin:"@"+e.IDENT_RE,relevance:0};return{name:"C#",aliases:["cs","c#"], +keywords:n,illegal:/::/,contains:[e.COMMENT("///","$",{returnBegin:!0, +contains:[{className:"doctag",variants:[{begin:"///",relevance:0},{ +begin:"\x3c!--|--\x3e"},{begin:""}]}] +}),e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{className:"meta",begin:"#", +end:"$",keywords:{ +keyword:"if else elif endif define undef warning error line region endregion pragma checksum" +}},g,a,{beginKeywords:"class interface",relevance:0,end:/[{;=]/, +illegal:/[^\s:,]/,contains:[{beginKeywords:"where class" +},t,u,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{beginKeywords:"namespace", +relevance:0,end:/[{;=]/,illegal:/[^\s:]/, +contains:[t,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{ +beginKeywords:"record",relevance:0,end:/[{;=]/,illegal:/[^\s:]/, +contains:[t,u,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{className:"meta", +begin:"^\\s*\\[(?=[\\w])",excludeBegin:!0,end:"\\]",excludeEnd:!0,contains:[{ +className:"string",begin:/"/,end:/"/}]},{ +beginKeywords:"new return throw await else",relevance:0},{className:"function", +begin:"("+b+"\\s+)+"+e.IDENT_RE+"\\s*(<[^=]+>\\s*)?\\(",returnBegin:!0, +end:/\s*[{;=]/,excludeEnd:!0,keywords:n,contains:[{ +beginKeywords:"public private protected static internal protected abstract async extern override unsafe virtual new sealed partial", +relevance:0},{begin:e.IDENT_RE+"\\s*(<[^=]+>\\s*)?\\(",returnBegin:!0, +contains:[e.TITLE_MODE,u],relevance:0},{match:/\(\)/},{className:"params", +begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:n,relevance:0, +contains:[g,a,e.C_BLOCK_COMMENT_MODE] +},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},m]}},grmr_css:e=>{ +const n=e.regex,t=te(e),a=[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE];return{ +name:"CSS",case_insensitive:!0,illegal:/[=|'\$]/,keywords:{ +keyframePosition:"from to"},classNameAliases:{keyframePosition:"selector-tag"}, +contains:[t.BLOCK_COMMENT,{begin:/-(webkit|moz|ms|o)-(?=[a-z])/ +},t.CSS_NUMBER_MODE,{className:"selector-id",begin:/#[A-Za-z0-9_-]+/,relevance:0 +},{className:"selector-class",begin:"\\.[a-zA-Z-][a-zA-Z0-9_-]*",relevance:0 +},t.ATTRIBUTE_SELECTOR_MODE,{className:"selector-pseudo",variants:[{ +begin:":("+re.join("|")+")"},{begin:":(:)?("+se.join("|")+")"}] +},t.CSS_VARIABLE,{className:"attribute",begin:"\\b("+oe.join("|")+")\\b"},{ +begin:/:/,end:/[;}{]/, +contains:[t.BLOCK_COMMENT,t.HEXCOLOR,t.IMPORTANT,t.CSS_NUMBER_MODE,...a,{ +begin:/(url|data-uri)\(/,end:/\)/,relevance:0,keywords:{built_in:"url data-uri" +},contains:[...a,{className:"string",begin:/[^)]/,endsWithParent:!0, +excludeEnd:!0}]},t.FUNCTION_DISPATCH]},{begin:n.lookahead(/@/),end:"[{;]", +relevance:0,illegal:/:/,contains:[{className:"keyword",begin:/@-?\w[\w]*(-\w+)*/ +},{begin:/\s/,endsWithParent:!0,excludeEnd:!0,relevance:0,keywords:{ +$pattern:/[a-z-]+/,keyword:"and or not only",attribute:ie.join(" ")},contains:[{ +begin:/[a-z-]+(?=:)/,className:"attribute"},...a,t.CSS_NUMBER_MODE]}]},{ +className:"selector-tag",begin:"\\b("+ae.join("|")+")\\b"}]}},grmr_diff:e=>{ +const n=e.regex;return{name:"Diff",aliases:["patch"],contains:[{ +className:"meta",relevance:10, +match:n.either(/^@@ +-\d+,\d+ +\+\d+,\d+ +@@/,/^\*\*\* +\d+,\d+ +\*\*\*\*$/,/^--- +\d+,\d+ +----$/) +},{className:"comment",variants:[{ +begin:n.either(/Index: /,/^index/,/={3,}/,/^-{3}/,/^\*{3} /,/^\+{3}/,/^diff --git/), +end:/$/},{match:/^\*{15}$/}]},{className:"addition",begin:/^\+/,end:/$/},{ +className:"deletion",begin:/^-/,end:/$/},{className:"addition",begin:/^!/, +end:/$/}]}},grmr_go:e=>{const n={ +keyword:["break","case","chan","const","continue","default","defer","else","fallthrough","for","func","go","goto","if","import","interface","map","package","range","return","select","struct","switch","type","var"], +type:["bool","byte","complex64","complex128","error","float32","float64","int8","int16","int32","int64","string","uint8","uint16","uint32","uint64","int","uint","uintptr","rune"], +literal:["true","false","iota","nil"], +built_in:["append","cap","close","complex","copy","imag","len","make","new","panic","print","println","real","recover","delete"] +};return{name:"Go",aliases:["golang"],keywords:n,illegal:"{const n=e.regex +;return{name:"GraphQL",aliases:["gql"],case_insensitive:!0,disableAutodetect:!1, +keywords:{ +keyword:["query","mutation","subscription","type","input","schema","directive","interface","union","scalar","fragment","enum","on"], +literal:["true","false","null"]}, +contains:[e.HASH_COMMENT_MODE,e.QUOTE_STRING_MODE,e.NUMBER_MODE,{ +scope:"punctuation",match:/[.]{3}/,relevance:0},{scope:"punctuation", +begin:/[\!\(\)\:\=\[\]\{\|\}]{1}/,relevance:0},{scope:"variable",begin:/\$/, +end:/\W/,excludeEnd:!0,relevance:0},{scope:"meta",match:/@\w+/,excludeEnd:!0},{ +scope:"symbol",begin:n.concat(/[_A-Za-z][_0-9A-Za-z]*/,n.lookahead(/\s*:/)), +relevance:0}],illegal:[/[;<']/,/BEGIN/]}},grmr_ini:e=>{const n=e.regex,t={ +className:"number",relevance:0,variants:[{begin:/([+-]+)?[\d]+_[\d_]+/},{ +begin:e.NUMBER_RE}]},a=e.COMMENT();a.variants=[{begin:/;/,end:/$/},{begin:/#/, +end:/$/}];const i={className:"variable",variants:[{begin:/\$[\w\d"][\w\d_]*/},{ +begin:/\$\{(.*?)\}/}]},r={className:"literal", +begin:/\bon|off|true|false|yes|no\b/},s={className:"string", +contains:[e.BACKSLASH_ESCAPE],variants:[{begin:"'''",end:"'''",relevance:10},{ +begin:'"""',end:'"""',relevance:10},{begin:'"',end:'"'},{begin:"'",end:"'"}] +},o={begin:/\[/,end:/\]/,contains:[a,r,i,s,t,"self"],relevance:0 +},l=n.either(/[A-Za-z0-9_-]+/,/"(\\"|[^"])*"/,/'[^']*'/);return{ +name:"TOML, also INI",aliases:["toml"],case_insensitive:!0,illegal:/\S/, +contains:[a,{className:"section",begin:/\[+/,end:/\]+/},{ +begin:n.concat(l,"(\\s*\\.\\s*",l,")*",n.lookahead(/\s*=\s*[^#\s]/)), +className:"attr",starts:{end:/$/,contains:[a,o,r,i,s,t]}}]}},grmr_java:e=>{ +const n=e.regex,t="[\xc0-\u02b8a-zA-Z_$][\xc0-\u02b8a-zA-Z_$0-9]*",a=t+be("(?:<"+t+"~~~(?:\\s*,\\s*"+t+"~~~)*>)?",/~~~/g,2),i={ +keyword:["synchronized","abstract","private","var","static","if","const ","for","while","strictfp","finally","protected","import","native","final","void","enum","else","break","transient","catch","instanceof","volatile","case","assert","package","default","public","try","switch","continue","throws","protected","public","private","module","requires","exports","do","sealed","yield","permits","goto","when"], +literal:["false","true","null"], +type:["char","boolean","long","float","int","byte","short","double"], +built_in:["super","this"]},r={className:"meta",begin:"@"+t,contains:[{ +begin:/\(/,end:/\)/,contains:["self"]}]},s={className:"params",begin:/\(/, +end:/\)/,keywords:i,relevance:0,contains:[e.C_BLOCK_COMMENT_MODE],endsParent:!0} +;return{name:"Java",aliases:["jsp"],keywords:i,illegal:/<\/|#/, +contains:[e.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{begin:/\w+@/, +relevance:0},{className:"doctag",begin:"@[A-Za-z]+"}]}),{ +begin:/import java\.[a-z]+\./,keywords:"import",relevance:2 +},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{begin:/"""/,end:/"""/, +className:"string",contains:[e.BACKSLASH_ESCAPE] +},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{ +match:[/\b(?:class|interface|enum|extends|implements|new)/,/\s+/,t],className:{ +1:"keyword",3:"title.class"}},{match:/non-sealed/,scope:"keyword"},{ +begin:[n.concat(/(?!else)/,t),/\s+/,t,/\s+/,/=(?!=)/],className:{1:"type", +3:"variable",5:"operator"}},{begin:[/record/,/\s+/,t],className:{1:"keyword", +3:"title.class"},contains:[s,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{ +beginKeywords:"new throw return else",relevance:0},{ +begin:["(?:"+a+"\\s+)",e.UNDERSCORE_IDENT_RE,/\s*(?=\()/],className:{ +2:"title.function"},keywords:i,contains:[{className:"params",begin:/\(/, +end:/\)/,keywords:i,relevance:0, +contains:[r,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,ue,e.C_BLOCK_COMMENT_MODE] +},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},ue,r]}},grmr_javascript:ve, +grmr_json:e=>{const n=["true","false","null"],t={scope:"literal", +beginKeywords:n.join(" ")};return{name:"JSON",aliases:["jsonc"],keywords:{ +literal:n},contains:[{className:"attr",begin:/"(\\.|[^\\"\r\n])*"(?=\s*:)/, +relevance:1.01},{match:/[{}[\],:]/,className:"punctuation",relevance:0 +},e.QUOTE_STRING_MODE,t,e.C_NUMBER_MODE,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE], +illegal:"\\S"}},grmr_kotlin:e=>{const n={ +keyword:"abstract as val var vararg get set class object open private protected public noinline crossinline dynamic final enum if else do while for when throw try catch finally import package is in fun override companion reified inline lateinit init interface annotation data sealed internal infix operator out by constructor super tailrec where const inner suspend typealias external expect actual", +built_in:"Byte Short Char Int Long Boolean Float Double Void Unit Nothing", +literal:"true false null"},t={className:"symbol",begin:e.UNDERSCORE_IDENT_RE+"@" +},a={className:"subst",begin:/\$\{/,end:/\}/,contains:[e.C_NUMBER_MODE]},i={ +className:"variable",begin:"\\$"+e.UNDERSCORE_IDENT_RE},r={className:"string", +variants:[{begin:'"""',end:'"""(?=[^"])',contains:[i,a]},{begin:"'",end:"'", +illegal:/\n/,contains:[e.BACKSLASH_ESCAPE]},{begin:'"',end:'"',illegal:/\n/, +contains:[e.BACKSLASH_ESCAPE,i,a]}]};a.contains.push(r);const s={ +className:"meta", +begin:"@(?:file|property|field|get|set|receiver|param|setparam|delegate)\\s*:(?:\\s*"+e.UNDERSCORE_IDENT_RE+")?" +},o={className:"meta",begin:"@"+e.UNDERSCORE_IDENT_RE,contains:[{begin:/\(/, +end:/\)/,contains:[e.inherit(r,{className:"string"}),"self"]}] +},l=ue,c=e.COMMENT("/\\*","\\*/",{contains:[e.C_BLOCK_COMMENT_MODE]}),d={ +variants:[{className:"type",begin:e.UNDERSCORE_IDENT_RE},{begin:/\(/,end:/\)/, +contains:[]}]},g=d;return g.variants[1].contains=[d],d.variants[1].contains=[g], +{name:"Kotlin",aliases:["kt","kts"],keywords:n, +contains:[e.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{className:"doctag", +begin:"@[A-Za-z]+"}]}),e.C_LINE_COMMENT_MODE,c,{className:"keyword", +begin:/\b(break|continue|return|this)\b/,starts:{contains:[{className:"symbol", +begin:/@\w+/}]}},t,s,o,{className:"function",beginKeywords:"fun",end:"[(]|$", +returnBegin:!0,excludeEnd:!0,keywords:n,relevance:5,contains:[{ +begin:e.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,relevance:0, +contains:[e.UNDERSCORE_TITLE_MODE]},{className:"type",begin://, +keywords:"reified",relevance:0},{className:"params",begin:/\(/,end:/\)/, +endsParent:!0,keywords:n,relevance:0,contains:[{begin:/:/,end:/[=,\/]/, +endsWithParent:!0,contains:[d,e.C_LINE_COMMENT_MODE,c],relevance:0 +},e.C_LINE_COMMENT_MODE,c,s,o,r,e.C_NUMBER_MODE]},c]},{ +begin:[/class|interface|trait/,/\s+/,e.UNDERSCORE_IDENT_RE],beginScope:{ +3:"title.class"},keywords:"class interface trait",end:/[:\{(]|$/,excludeEnd:!0, +illegal:"extends implements",contains:[{ +beginKeywords:"public protected internal private constructor" +},e.UNDERSCORE_TITLE_MODE,{className:"type",begin://,excludeBegin:!0, +excludeEnd:!0,relevance:0},{className:"type",begin:/[,:]\s*/,end:/[<\(,){\s]|$/, +excludeBegin:!0,returnEnd:!0},s,o]},r,{className:"meta",begin:"^#!/usr/bin/env", +end:"$",illegal:"\n"},l]}},grmr_less:e=>{ +const n=te(e),t=le,a="[\\w-]+",i="("+a+"|@\\{"+a+"\\})",r=[],s=[],o=e=>({ +className:"string",begin:"~?"+e+".*?"+e}),l=(e,n,t)=>({className:e,begin:n, +relevance:t}),c={$pattern:/[a-z-]+/,keyword:"and or not only", +attribute:ie.join(" ")},d={begin:"\\(",end:"\\)",contains:s,keywords:c, +relevance:0} +;s.push(e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,o("'"),o('"'),n.CSS_NUMBER_MODE,{ +begin:"(url|data-uri)\\(",starts:{className:"string",end:"[\\)\\n]", +excludeEnd:!0} +},n.HEXCOLOR,d,l("variable","@@?"+a,10),l("variable","@\\{"+a+"\\}"),l("built_in","~?`[^`]*?`"),{ +className:"attribute",begin:a+"\\s*:",end:":",returnBegin:!0,excludeEnd:!0 +},n.IMPORTANT,{beginKeywords:"and not"},n.FUNCTION_DISPATCH);const g=s.concat({ +begin:/\{/,end:/\}/,contains:r}),u={beginKeywords:"when",endsWithParent:!0, +contains:[{beginKeywords:"and not"}].concat(s)},b={begin:i+"\\s*:", +returnBegin:!0,end:/[;}]/,relevance:0,contains:[{begin:/-(webkit|moz|ms|o)-/ +},n.CSS_VARIABLE,{className:"attribute",begin:"\\b("+oe.join("|")+")\\b", +end:/(?=:)/,starts:{endsWithParent:!0,illegal:"[<=$]",relevance:0,contains:s}}] +},m={className:"keyword", +begin:"@(import|media|charset|font-face|(-[a-z]+-)?keyframes|supports|document|namespace|page|viewport|host)\\b", +starts:{end:"[;{}]",keywords:c,returnEnd:!0,contains:s,relevance:0}},p={ +className:"variable",variants:[{begin:"@"+a+"\\s*:",relevance:15},{begin:"@"+a +}],starts:{end:"[;}]",returnEnd:!0,contains:g}},_={variants:[{ +begin:"[\\.#:&\\[>]",end:"[;{}]"},{begin:i,end:/\{/}],returnBegin:!0, +returnEnd:!0,illegal:"[<='$\"]",relevance:0, +contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,u,l("keyword","all\\b"),l("variable","@\\{"+a+"\\}"),{ +begin:"\\b("+ae.join("|")+")\\b",className:"selector-tag" +},n.CSS_NUMBER_MODE,l("selector-tag",i,0),l("selector-id","#"+i),l("selector-class","\\."+i,0),l("selector-tag","&",0),n.ATTRIBUTE_SELECTOR_MODE,{ +className:"selector-pseudo",begin:":("+re.join("|")+")"},{ +className:"selector-pseudo",begin:":(:)?("+se.join("|")+")"},{begin:/\(/, +end:/\)/,relevance:0,contains:g},{begin:"!important"},n.FUNCTION_DISPATCH]},h={ +begin:a+":(:)?"+`(${t.join("|")})`,returnBegin:!0,contains:[_]} +;return r.push(e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,m,p,h,b,_,u,n.FUNCTION_DISPATCH), +{name:"Less",case_insensitive:!0,illegal:"[=>'/<($\"]",contains:r}}, +grmr_lua:e=>{const n="\\[=*\\[",t="\\]=*\\]",a={begin:n,end:t,contains:["self"] +},i=[e.COMMENT("--(?!"+n+")","$"),e.COMMENT("--"+n,t,{contains:[a],relevance:10 +})];return{name:"Lua",aliases:["pluto"],keywords:{ +$pattern:e.UNDERSCORE_IDENT_RE,literal:"true false nil", +keyword:"and break do else elseif end for goto if in local not or repeat return then until while", +built_in:"_G _ENV _VERSION __index __newindex __mode __call __metatable __tostring __len __gc __add __sub __mul __div __mod __pow __concat __unm __eq __lt __le assert collectgarbage dofile error getfenv getmetatable ipairs load loadfile loadstring module next pairs pcall print rawequal rawget rawset require select setfenv setmetatable tonumber tostring type unpack xpcall arg self coroutine resume yield status wrap create running debug getupvalue debug sethook getmetatable gethook setmetatable setlocal traceback setfenv getinfo setupvalue getlocal getregistry getfenv io lines write close flush open output type read stderr stdin input stdout popen tmpfile math log max acos huge ldexp pi cos tanh pow deg tan cosh sinh random randomseed frexp ceil floor rad abs sqrt modf asin min mod fmod log10 atan2 exp sin atan os exit setlocale date getenv difftime remove time clock tmpname rename execute package preload loadlib loaded loaders cpath config path seeall string sub upper len gfind rep find match char dump gmatch reverse byte format gsub lower table setn insert getn foreachi maxn foreach concat sort remove" +},contains:i.concat([{className:"function",beginKeywords:"function",end:"\\)", +contains:[e.inherit(e.TITLE_MODE,{ +begin:"([_a-zA-Z]\\w*\\.)*([_a-zA-Z]\\w*:)?[_a-zA-Z]\\w*"}),{className:"params", +begin:"\\(",endsWithParent:!0,contains:i}].concat(i) +},e.C_NUMBER_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{className:"string", +begin:n,end:t,contains:[a],relevance:5}])}},grmr_makefile:e=>{const n={ +className:"variable",variants:[{begin:"\\$\\("+e.UNDERSCORE_IDENT_RE+"\\)", +contains:[e.BACKSLASH_ESCAPE]},{begin:/\$[@%{ +const n={begin:/<\/?[A-Za-z_]/,end:">",subLanguage:"xml",relevance:0},t={ +variants:[{begin:/\[.+?\]\[.*?\]/,relevance:0},{ +begin:/\[.+?\]\(((data|javascript|mailto):|(?:http|ftp)s?:\/\/).*?\)/, +relevance:2},{ +begin:e.regex.concat(/\[.+?\]\(/,/[A-Za-z][A-Za-z0-9+.-]*/,/:\/\/.*?\)/), +relevance:2},{begin:/\[.+?\]\([./?&#].*?\)/,relevance:1},{ +begin:/\[.*?\]\(.*?\)/,relevance:0}],returnBegin:!0,contains:[{match:/\[(?=\])/ +},{className:"string",relevance:0,begin:"\\[",end:"\\]",excludeBegin:!0, +returnEnd:!0},{className:"link",relevance:0,begin:"\\]\\(",end:"\\)", +excludeBegin:!0,excludeEnd:!0},{className:"symbol",relevance:0,begin:"\\]\\[", +end:"\\]",excludeBegin:!0,excludeEnd:!0}]},a={className:"strong",contains:[], +variants:[{begin:/_{2}(?!\s)/,end:/_{2}/},{begin:/\*{2}(?!\s)/,end:/\*{2}/}] +},i={className:"emphasis",contains:[],variants:[{begin:/\*(?![*\s])/,end:/\*/},{ +begin:/_(?![_\s])/,end:/_/,relevance:0}]},r=e.inherit(a,{contains:[] +}),s=e.inherit(i,{contains:[]});a.contains.push(s),i.contains.push(r) +;let o=[n,t];return[a,i,r,s].forEach((e=>{e.contains=e.contains.concat(o) +})),o=o.concat(a,i),{name:"Markdown",aliases:["md","mkdown","mkd"],contains:[{ +className:"section",variants:[{begin:"^#{1,6}",end:"$",contains:o},{ +begin:"(?=^.+?\\n[=-]{2,}$)",contains:[{begin:"^[=-]*$"},{begin:"^",end:"\\n", +contains:o}]}]},n,{className:"bullet",begin:"^[ \t]*([*+-]|(\\d+\\.))(?=\\s+)", +end:"\\s+",excludeEnd:!0},a,i,{className:"quote",begin:"^>\\s+",contains:o, +end:"$"},{className:"code",variants:[{begin:"(`{3,})[^`](.|\\n)*?\\1`*[ ]*"},{ +begin:"(~{3,})[^~](.|\\n)*?\\1~*[ ]*"},{begin:"```",end:"```+[ ]*$"},{ +begin:"~~~",end:"~~~+[ ]*$"},{begin:"`.+?`"},{begin:"(?=^( {4}|\\t))", +contains:[{begin:"^( {4}|\\t)",end:"(\\n)$"}],relevance:0}]},{ +begin:"^[-\\*]{3,}",end:"$"},t,{begin:/^\[[^\n]+\]:/,returnBegin:!0,contains:[{ +className:"symbol",begin:/\[/,end:/\]/,excludeBegin:!0,excludeEnd:!0},{ +className:"link",begin:/:\s*/,end:/$/,excludeBegin:!0}]},{scope:"literal", +match:/&([a-zA-Z0-9]+|#[0-9]{1,7}|#[Xx][0-9a-fA-F]{1,6});/}]}}, +grmr_objectivec:e=>{const n=/[a-zA-Z@][a-zA-Z0-9_]*/,t={$pattern:n, +keyword:["@interface","@class","@protocol","@implementation"]};return{ +name:"Objective-C",aliases:["mm","objc","obj-c","obj-c++","objective-c++"], +keywords:{"variable.language":["this","super"],$pattern:n, +keyword:["while","export","sizeof","typedef","const","struct","for","union","volatile","static","mutable","if","do","return","goto","enum","else","break","extern","asm","case","default","register","explicit","typename","switch","continue","inline","readonly","assign","readwrite","self","@synchronized","id","typeof","nonatomic","IBOutlet","IBAction","strong","weak","copy","in","out","inout","bycopy","byref","oneway","__strong","__weak","__block","__autoreleasing","@private","@protected","@public","@try","@property","@end","@throw","@catch","@finally","@autoreleasepool","@synthesize","@dynamic","@selector","@optional","@required","@encode","@package","@import","@defs","@compatibility_alias","__bridge","__bridge_transfer","__bridge_retained","__bridge_retain","__covariant","__contravariant","__kindof","_Nonnull","_Nullable","_Null_unspecified","__FUNCTION__","__PRETTY_FUNCTION__","__attribute__","getter","setter","retain","unsafe_unretained","nonnull","nullable","null_unspecified","null_resettable","class","instancetype","NS_DESIGNATED_INITIALIZER","NS_UNAVAILABLE","NS_REQUIRES_SUPER","NS_RETURNS_INNER_POINTER","NS_INLINE","NS_AVAILABLE","NS_DEPRECATED","NS_ENUM","NS_OPTIONS","NS_SWIFT_UNAVAILABLE","NS_ASSUME_NONNULL_BEGIN","NS_ASSUME_NONNULL_END","NS_REFINED_FOR_SWIFT","NS_SWIFT_NAME","NS_SWIFT_NOTHROW","NS_DURING","NS_HANDLER","NS_ENDHANDLER","NS_VALUERETURN","NS_VOIDRETURN"], +literal:["false","true","FALSE","TRUE","nil","YES","NO","NULL"], +built_in:["dispatch_once_t","dispatch_queue_t","dispatch_sync","dispatch_async","dispatch_once"], +type:["int","float","char","unsigned","signed","short","long","double","wchar_t","unichar","void","bool","BOOL","id|0","_Bool"] +},illegal:"/,end:/$/,illegal:"\\n" +},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{className:"class", +begin:"("+t.keyword.join("|")+")\\b",end:/(\{|$)/,excludeEnd:!0,keywords:t, +contains:[e.UNDERSCORE_TITLE_MODE]},{begin:"\\."+e.UNDERSCORE_IDENT_RE, +relevance:0}]}},grmr_perl:e=>{const n=e.regex,t=/[dualxmsipngr]{0,12}/,a={ +$pattern:/[\w.]+/, +keyword:"abs accept alarm and atan2 bind binmode bless break caller chdir chmod chomp chop chown chr chroot class close closedir connect continue cos crypt dbmclose dbmopen defined delete die do dump each else elsif endgrent endhostent endnetent endprotoent endpwent endservent eof eval exec exists exit exp fcntl field fileno flock for foreach fork format formline getc getgrent getgrgid getgrnam gethostbyaddr gethostbyname gethostent getlogin getnetbyaddr getnetbyname getnetent getpeername getpgrp getpriority getprotobyname getprotobynumber getprotoent getpwent getpwnam getpwuid getservbyname getservbyport getservent getsockname getsockopt given glob gmtime goto grep gt hex if index int ioctl join keys kill last lc lcfirst length link listen local localtime log lstat lt ma map method mkdir msgctl msgget msgrcv msgsnd my ne next no not oct open opendir or ord our pack package pipe pop pos print printf prototype push q|0 qq quotemeta qw qx rand read readdir readline readlink readpipe recv redo ref rename require reset return reverse rewinddir rindex rmdir say scalar seek seekdir select semctl semget semop send setgrent sethostent setnetent setpgrp setpriority setprotoent setpwent setservent setsockopt shift shmctl shmget shmread shmwrite shutdown sin sleep socket socketpair sort splice split sprintf sqrt srand stat state study sub substr symlink syscall sysopen sysread sysseek system syswrite tell telldir tie tied time times tr truncate uc ucfirst umask undef unless unlink unpack unshift untie until use utime values vec wait waitpid wantarray warn when while write x|0 xor y|0" +},i={className:"subst",begin:"[$@]\\{",end:"\\}",keywords:a},r={begin:/->\{/, +end:/\}/},s={scope:"attr",match:/\s+:\s*\w+(\s*\(.*?\))?/},o={scope:"variable", +variants:[{begin:/\$\d/},{ +begin:n.concat(/[$%@](?!")(\^\w\b|#\w+(::\w+)*|\{\w+\}|\w+(::\w*)*)/,"(?![A-Za-z])(?![@$%])") +},{begin:/[$%@](?!")[^\s\w{=]|\$=/,relevance:0}],contains:[s]},l={ +className:"number",variants:[{match:/0?\.[0-9][0-9_]+\b/},{ +match:/\bv?(0|[1-9][0-9_]*(\.[0-9_]+)?|[1-9][0-9_]*)\b/},{ +match:/\b0[0-7][0-7_]*\b/},{match:/\b0x[0-9a-fA-F][0-9a-fA-F_]*\b/},{ +match:/\b0b[0-1][0-1_]*\b/}],relevance:0 +},c=[e.BACKSLASH_ESCAPE,i,o],d=[/!/,/\//,/\|/,/\?/,/'/,/"/,/#/],g=(e,a,i="\\1")=>{ +const r="\\1"===i?i:n.concat(i,a) +;return n.concat(n.concat("(?:",e,")"),a,/(?:\\.|[^\\\/])*?/,r,/(?:\\.|[^\\\/])*?/,i,t) +},u=(e,a,i)=>n.concat(n.concat("(?:",e,")"),a,/(?:\\.|[^\\\/])*?/,i,t),b=[o,e.HASH_COMMENT_MODE,e.COMMENT(/^=\w/,/=cut/,{ +endsWithParent:!0}),r,{className:"string",contains:c,variants:[{ +begin:"q[qwxr]?\\s*\\(",end:"\\)",relevance:5},{begin:"q[qwxr]?\\s*\\[", +end:"\\]",relevance:5},{begin:"q[qwxr]?\\s*\\{",end:"\\}",relevance:5},{ +begin:"q[qwxr]?\\s*\\|",end:"\\|",relevance:5},{begin:"q[qwxr]?\\s*<",end:">", +relevance:5},{begin:"qw\\s+q",end:"q",relevance:5},{begin:"'",end:"'", +contains:[e.BACKSLASH_ESCAPE]},{begin:'"',end:'"'},{begin:"`",end:"`", +contains:[e.BACKSLASH_ESCAPE]},{begin:/\{\w+\}/,relevance:0},{ +begin:"-?\\w+\\s*=>",relevance:0}]},l,{ +begin:"(\\/\\/|"+e.RE_STARTERS_RE+"|\\b(split|return|print|reverse|grep)\\b)\\s*", +keywords:"split return print reverse grep",relevance:0, +contains:[e.HASH_COMMENT_MODE,{className:"regexp",variants:[{ +begin:g("s|tr|y",n.either(...d,{capture:!0}))},{begin:g("s|tr|y","\\(","\\)")},{ +begin:g("s|tr|y","\\[","\\]")},{begin:g("s|tr|y","\\{","\\}")}],relevance:2},{ +className:"regexp",variants:[{begin:/(m|qr)\/\//,relevance:0},{ +begin:u("(?:m|qr)?",/\//,/\//)},{begin:u("m|qr",n.either(...d,{capture:!0 +}),/\1/)},{begin:u("m|qr",/\(/,/\)/)},{begin:u("m|qr",/\[/,/\]/)},{ +begin:u("m|qr",/\{/,/\}/)}]}]},{className:"function",beginKeywords:"sub method", +end:"(\\s*\\(.*?\\))?[;{]",excludeEnd:!0,relevance:5,contains:[e.TITLE_MODE,s] +},{className:"class",beginKeywords:"class",end:"[;{]",excludeEnd:!0,relevance:5, +contains:[e.TITLE_MODE,s,l]},{begin:"-\\w\\b",relevance:0},{begin:"^__DATA__$", +end:"^__END__$",subLanguage:"mojolicious",contains:[{begin:"^@@.*",end:"$", +className:"comment"}]}];return i.contains=b,r.contains=b,{name:"Perl", +aliases:["pl","pm"],keywords:a,contains:b}},grmr_php:e=>{ +const n=e.regex,t=/(?![A-Za-z0-9])(?![$])/,a=n.concat(/[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*/,t),i=n.concat(/(\\?[A-Z][a-z0-9_\x7f-\xff]+|\\?[A-Z]+(?=[A-Z][a-z0-9_\x7f-\xff])){1,}/,t),r=n.concat(/[A-Z]+/,t),s={ +scope:"variable",match:"\\$+"+a},o={scope:"subst",variants:[{begin:/\$\w+/},{ +begin:/\{\$/,end:/\}/}]},l=e.inherit(e.APOS_STRING_MODE,{illegal:null +}),c="[ \t\n]",d={scope:"string",variants:[e.inherit(e.QUOTE_STRING_MODE,{ +illegal:null,contains:e.QUOTE_STRING_MODE.contains.concat(o)}),l,{ +begin:/<<<[ \t]*(?:(\w+)|"(\w+)")\n/,end:/[ \t]*(\w+)\b/, +contains:e.QUOTE_STRING_MODE.contains.concat(o),"on:begin":(e,n)=>{ +n.data._beginMatch=e[1]||e[2]},"on:end":(e,n)=>{ +n.data._beginMatch!==e[1]&&n.ignoreMatch()}},e.END_SAME_AS_BEGIN({ +begin:/<<<[ \t]*'(\w+)'\n/,end:/[ \t]*(\w+)\b/})]},g={scope:"number",variants:[{ +begin:"\\b0[bB][01]+(?:_[01]+)*\\b"},{begin:"\\b0[oO][0-7]+(?:_[0-7]+)*\\b"},{ +begin:"\\b0[xX][\\da-fA-F]+(?:_[\\da-fA-F]+)*\\b"},{ +begin:"(?:\\b\\d+(?:_\\d+)*(\\.(?:\\d+(?:_\\d+)*))?|\\B\\.\\d+)(?:[eE][+-]?\\d+)?" +}],relevance:0 +},u=["false","null","true"],b=["__CLASS__","__DIR__","__FILE__","__FUNCTION__","__COMPILER_HALT_OFFSET__","__LINE__","__METHOD__","__NAMESPACE__","__TRAIT__","die","echo","exit","include","include_once","print","require","require_once","array","abstract","and","as","binary","bool","boolean","break","callable","case","catch","class","clone","const","continue","declare","default","do","double","else","elseif","empty","enddeclare","endfor","endforeach","endif","endswitch","endwhile","enum","eval","extends","final","finally","float","for","foreach","from","global","goto","if","implements","instanceof","insteadof","int","integer","interface","isset","iterable","list","match|0","mixed","new","never","object","or","private","protected","public","readonly","real","return","string","switch","throw","trait","try","unset","use","var","void","while","xor","yield"],m=["Error|0","AppendIterator","ArgumentCountError","ArithmeticError","ArrayIterator","ArrayObject","AssertionError","BadFunctionCallException","BadMethodCallException","CachingIterator","CallbackFilterIterator","CompileError","Countable","DirectoryIterator","DivisionByZeroError","DomainException","EmptyIterator","ErrorException","Exception","FilesystemIterator","FilterIterator","GlobIterator","InfiniteIterator","InvalidArgumentException","IteratorIterator","LengthException","LimitIterator","LogicException","MultipleIterator","NoRewindIterator","OutOfBoundsException","OutOfRangeException","OuterIterator","OverflowException","ParentIterator","ParseError","RangeException","RecursiveArrayIterator","RecursiveCachingIterator","RecursiveCallbackFilterIterator","RecursiveDirectoryIterator","RecursiveFilterIterator","RecursiveIterator","RecursiveIteratorIterator","RecursiveRegexIterator","RecursiveTreeIterator","RegexIterator","RuntimeException","SeekableIterator","SplDoublyLinkedList","SplFileInfo","SplFileObject","SplFixedArray","SplHeap","SplMaxHeap","SplMinHeap","SplObjectStorage","SplObserver","SplPriorityQueue","SplQueue","SplStack","SplSubject","SplTempFileObject","TypeError","UnderflowException","UnexpectedValueException","UnhandledMatchError","ArrayAccess","BackedEnum","Closure","Fiber","Generator","Iterator","IteratorAggregate","Serializable","Stringable","Throwable","Traversable","UnitEnum","WeakReference","WeakMap","Directory","__PHP_Incomplete_Class","parent","php_user_filter","self","static","stdClass"],p={ +keyword:b,literal:(e=>{const n=[];return e.forEach((e=>{ +n.push(e),e.toLowerCase()===e?n.push(e.toUpperCase()):n.push(e.toLowerCase()) +})),n})(u),built_in:m},_=e=>e.map((e=>e.replace(/\|\d+$/,""))),h={variants:[{ +match:[/new/,n.concat(c,"+"),n.concat("(?!",_(m).join("\\b|"),"\\b)"),i],scope:{ +1:"keyword",4:"title.class"}}]},f=n.concat(a,"\\b(?!\\()"),E={variants:[{ +match:[n.concat(/::/,n.lookahead(/(?!class\b)/)),f],scope:{2:"variable.constant" +}},{match:[/::/,/class/],scope:{2:"variable.language"}},{ +match:[i,n.concat(/::/,n.lookahead(/(?!class\b)/)),f],scope:{1:"title.class", +3:"variable.constant"}},{match:[i,n.concat("::",n.lookahead(/(?!class\b)/))], +scope:{1:"title.class"}},{match:[i,/::/,/class/],scope:{1:"title.class", +3:"variable.language"}}]},y={scope:"attr", +match:n.concat(a,n.lookahead(":"),n.lookahead(/(?!::)/))},w={relevance:0, +begin:/\(/,end:/\)/,keywords:p,contains:[y,s,E,e.C_BLOCK_COMMENT_MODE,d,g,h] +},v={relevance:0, +match:[/\b/,n.concat("(?!fn\\b|function\\b|",_(b).join("\\b|"),"|",_(m).join("\\b|"),"\\b)"),a,n.concat(c,"*"),n.lookahead(/(?=\()/)], +scope:{3:"title.function.invoke"},contains:[w]};w.contains.push(v) +;const N=[y,E,e.C_BLOCK_COMMENT_MODE,d,g,h],k={ +begin:n.concat(/#\[\s*\\?/,n.either(i,r)),beginScope:"meta",end:/]/, +endScope:"meta",keywords:{literal:u,keyword:["new","array"]},contains:[{ +begin:/\[/,end:/]/,keywords:{literal:u,keyword:["new","array"]}, +contains:["self",...N]},...N,{scope:"meta",variants:[{match:i},{match:r}]}]} +;return{case_insensitive:!1,keywords:p, +contains:[k,e.HASH_COMMENT_MODE,e.COMMENT("//","$"),e.COMMENT("/\\*","\\*/",{ +contains:[{scope:"doctag",match:"@[A-Za-z]+"}]}),{match:/__halt_compiler\(\);/, +keywords:"__halt_compiler",starts:{scope:"comment",end:e.MATCH_NOTHING_RE, +contains:[{match:/\?>/,scope:"meta",endsParent:!0}]}},{scope:"meta",variants:[{ +begin:/<\?php/,relevance:10},{begin:/<\?=/},{begin:/<\?/,relevance:.1},{ +begin:/\?>/}]},{scope:"variable.language",match:/\$this\b/},s,v,E,{ +match:[/const/,/\s/,a],scope:{1:"keyword",3:"variable.constant"}},h,{ +scope:"function",relevance:0,beginKeywords:"fn function",end:/[;{]/, +excludeEnd:!0,illegal:"[$%\\[]",contains:[{beginKeywords:"use" +},e.UNDERSCORE_TITLE_MODE,{begin:"=>",endsParent:!0},{scope:"params", +begin:"\\(",end:"\\)",excludeBegin:!0,excludeEnd:!0,keywords:p, +contains:["self",k,s,E,e.C_BLOCK_COMMENT_MODE,d,g]}]},{scope:"class",variants:[{ +beginKeywords:"enum",illegal:/[($"]/},{beginKeywords:"class interface trait", +illegal:/[:($"]/}],relevance:0,end:/\{/,excludeEnd:!0,contains:[{ +beginKeywords:"extends implements"},e.UNDERSCORE_TITLE_MODE]},{ +beginKeywords:"namespace",relevance:0,end:";",illegal:/[.']/, +contains:[e.inherit(e.UNDERSCORE_TITLE_MODE,{scope:"title.class"})]},{ +beginKeywords:"use",relevance:0,end:";",contains:[{ +match:/\b(as|const|function)\b/,scope:"keyword"},e.UNDERSCORE_TITLE_MODE]},d,g]} +},grmr_php_template:e=>({name:"PHP template",subLanguage:"xml",contains:[{ +begin:/<\?(php|=)?/,end:/\?>/,subLanguage:"php",contains:[{begin:"/\\*", +end:"\\*/",skip:!0},{begin:'b"',end:'"',skip:!0},{begin:"b'",end:"'",skip:!0 +},e.inherit(e.APOS_STRING_MODE,{illegal:null,className:null,contains:null, +skip:!0}),e.inherit(e.QUOTE_STRING_MODE,{illegal:null,className:null, +contains:null,skip:!0})]}]}),grmr_plaintext:e=>({name:"Plain text", +aliases:["text","txt"],disableAutodetect:!0}),grmr_python:e=>{ +const n=e.regex,t=/[\p{XID_Start}_]\p{XID_Continue}*/u,a=["and","as","assert","async","await","break","case","class","continue","def","del","elif","else","except","finally","for","from","global","if","import","in","is","lambda","match","nonlocal|10","not","or","pass","raise","return","try","while","with","yield"],i={ +$pattern:/[A-Za-z]\w+|__\w+__/,keyword:a, +built_in:["__import__","abs","all","any","ascii","bin","bool","breakpoint","bytearray","bytes","callable","chr","classmethod","compile","complex","delattr","dict","dir","divmod","enumerate","eval","exec","filter","float","format","frozenset","getattr","globals","hasattr","hash","help","hex","id","input","int","isinstance","issubclass","iter","len","list","locals","map","max","memoryview","min","next","object","oct","open","ord","pow","print","property","range","repr","reversed","round","set","setattr","slice","sorted","staticmethod","str","sum","super","tuple","type","vars","zip"], +literal:["__debug__","Ellipsis","False","None","NotImplemented","True"], +type:["Any","Callable","Coroutine","Dict","List","Literal","Generic","Optional","Sequence","Set","Tuple","Type","Union"] +},r={className:"meta",begin:/^(>>>|\.\.\.) /},s={className:"subst",begin:/\{/, +end:/\}/,keywords:i,illegal:/#/},o={begin:/\{\{/,relevance:0},l={ +className:"string",contains:[e.BACKSLASH_ESCAPE],variants:[{ +begin:/([uU]|[bB]|[rR]|[bB][rR]|[rR][bB])?'''/,end:/'''/, +contains:[e.BACKSLASH_ESCAPE,r],relevance:10},{ +begin:/([uU]|[bB]|[rR]|[bB][rR]|[rR][bB])?"""/,end:/"""/, +contains:[e.BACKSLASH_ESCAPE,r],relevance:10},{ +begin:/([fF][rR]|[rR][fF]|[fF])'''/,end:/'''/, +contains:[e.BACKSLASH_ESCAPE,r,o,s]},{begin:/([fF][rR]|[rR][fF]|[fF])"""/, +end:/"""/,contains:[e.BACKSLASH_ESCAPE,r,o,s]},{begin:/([uU]|[rR])'/,end:/'/, +relevance:10},{begin:/([uU]|[rR])"/,end:/"/,relevance:10},{ +begin:/([bB]|[bB][rR]|[rR][bB])'/,end:/'/},{begin:/([bB]|[bB][rR]|[rR][bB])"/, +end:/"/},{begin:/([fF][rR]|[rR][fF]|[fF])'/,end:/'/, +contains:[e.BACKSLASH_ESCAPE,o,s]},{begin:/([fF][rR]|[rR][fF]|[fF])"/,end:/"/, +contains:[e.BACKSLASH_ESCAPE,o,s]},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE] +},c="[0-9](_?[0-9])*",d=`(\\b(${c}))?\\.(${c})|\\b(${c})\\.`,g="\\b|"+a.join("|"),u={ +className:"number",relevance:0,variants:[{ +begin:`(\\b(${c})|(${d}))[eE][+-]?(${c})[jJ]?(?=${g})`},{begin:`(${d})[jJ]?`},{ +begin:`\\b([1-9](_?[0-9])*|0+(_?0)*)[lLjJ]?(?=${g})`},{ +begin:`\\b0[bB](_?[01])+[lL]?(?=${g})`},{begin:`\\b0[oO](_?[0-7])+[lL]?(?=${g})` +},{begin:`\\b0[xX](_?[0-9a-fA-F])+[lL]?(?=${g})`},{begin:`\\b(${c})[jJ](?=${g})` +}]},b={className:"comment",begin:n.lookahead(/# type:/),end:/$/,keywords:i, +contains:[{begin:/# type:/},{begin:/#/,end:/\b\B/,endsWithParent:!0}]},m={ +className:"params",variants:[{className:"",begin:/\(\s*\)/,skip:!0},{begin:/\(/, +end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:i, +contains:["self",r,u,l,e.HASH_COMMENT_MODE]}]};return s.contains=[l,u,r],{ +name:"Python",aliases:["py","gyp","ipython"],unicodeRegex:!0,keywords:i, +illegal:/(<\/|\?)|=>/,contains:[r,u,{scope:"variable.language",match:/\bself\b/ +},{beginKeywords:"if",relevance:0},{match:/\bor\b/,scope:"keyword" +},l,b,e.HASH_COMMENT_MODE,{match:[/\bdef/,/\s+/,t],scope:{1:"keyword", +3:"title.function"},contains:[m]},{variants:[{ +match:[/\bclass/,/\s+/,t,/\s*/,/\(\s*/,t,/\s*\)/]},{match:[/\bclass/,/\s+/,t]}], +scope:{1:"keyword",3:"title.class",6:"title.class.inherited"}},{ +className:"meta",begin:/^[\t ]*@/,end:/(?=#)|$/,contains:[u,m,l]}]}}, +grmr_python_repl:e=>({aliases:["pycon"],contains:[{className:"meta.prompt", +starts:{end:/ |$/,starts:{end:"$",subLanguage:"python"}},variants:[{ +begin:/^>>>(?=[ ]|$)/},{begin:/^\.\.\.(?=[ ]|$)/}]}]}),grmr_r:e=>{ +const n=e.regex,t=/(?:(?:[a-zA-Z]|\.[._a-zA-Z])[._a-zA-Z0-9]*)|\.(?!\d)/,a=n.either(/0[xX][0-9a-fA-F]+\.[0-9a-fA-F]*[pP][+-]?\d+i?/,/0[xX][0-9a-fA-F]+(?:[pP][+-]?\d+)?[Li]?/,/(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?[Li]?/),i=/[=!<>:]=|\|\||&&|:::?|<-|<<-|->>|->|\|>|[-+*\/?!$&|:<=>@^~]|\*\*/,r=n.either(/[()]/,/[{}]/,/\[\[/,/[[\]]/,/\\/,/,/) +;return{name:"R",keywords:{$pattern:t, +keyword:"function if in break next repeat else for while", +literal:"NULL NA TRUE FALSE Inf NaN NA_integer_|10 NA_real_|10 NA_character_|10 NA_complex_|10", +built_in:"LETTERS letters month.abb month.name pi T F abs acos acosh all any anyNA Arg as.call as.character as.complex as.double as.environment as.integer as.logical as.null.default as.numeric as.raw asin asinh atan atanh attr attributes baseenv browser c call ceiling class Conj cos cosh cospi cummax cummin cumprod cumsum digamma dim dimnames emptyenv exp expression floor forceAndCall gamma gc.time globalenv Im interactive invisible is.array is.atomic is.call is.character is.complex is.double is.environment is.expression is.finite is.function is.infinite is.integer is.language is.list is.logical is.matrix is.na is.name is.nan is.null is.numeric is.object is.pairlist is.raw is.recursive is.single is.symbol lazyLoadDBfetch length lgamma list log max min missing Mod names nargs nzchar oldClass on.exit pos.to.env proc.time prod quote range Re rep retracemem return round seq_along seq_len seq.int sign signif sin sinh sinpi sqrt standardGeneric substitute sum switch tan tanh tanpi tracemem trigamma trunc unclass untracemem UseMethod xtfrm" +},contains:[e.COMMENT(/#'/,/$/,{contains:[{scope:"doctag",match:/@examples/, +starts:{end:n.lookahead(n.either(/\n^#'\s*(?=@[a-zA-Z]+)/,/\n^(?!#')/)), +endsParent:!0}},{scope:"doctag",begin:"@param",end:/$/,contains:[{ +scope:"variable",variants:[{match:t},{match:/`(?:\\.|[^`\\])+`/}],endsParent:!0 +}]},{scope:"doctag",match:/@[a-zA-Z]+/},{scope:"keyword",match:/\\[a-zA-Z]+/}] +}),e.HASH_COMMENT_MODE,{scope:"string",contains:[e.BACKSLASH_ESCAPE], +variants:[e.END_SAME_AS_BEGIN({begin:/[rR]"(-*)\(/,end:/\)(-*)"/ +}),e.END_SAME_AS_BEGIN({begin:/[rR]"(-*)\{/,end:/\}(-*)"/ +}),e.END_SAME_AS_BEGIN({begin:/[rR]"(-*)\[/,end:/\](-*)"/ +}),e.END_SAME_AS_BEGIN({begin:/[rR]'(-*)\(/,end:/\)(-*)'/ +}),e.END_SAME_AS_BEGIN({begin:/[rR]'(-*)\{/,end:/\}(-*)'/ +}),e.END_SAME_AS_BEGIN({begin:/[rR]'(-*)\[/,end:/\](-*)'/}),{begin:'"',end:'"', +relevance:0},{begin:"'",end:"'",relevance:0}]},{relevance:0,variants:[{scope:{ +1:"operator",2:"number"},match:[i,a]},{scope:{1:"operator",2:"number"}, +match:[/%[^%]*%/,a]},{scope:{1:"punctuation",2:"number"},match:[r,a]},{scope:{ +2:"number"},match:[/[^a-zA-Z0-9._]|^/,a]}]},{scope:{3:"operator"}, +match:[t,/\s+/,/<-/,/\s+/]},{scope:"operator",relevance:0,variants:[{match:i},{ +match:/%[^%]*%/}]},{scope:"punctuation",relevance:0,match:r},{begin:"`",end:"`", +contains:[{begin:/\\./}]}]}},grmr_ruby:e=>{ +const n=e.regex,t="([a-zA-Z_]\\w*[!?=]?|[-+~]@|<<|>>|=~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~`|]|\\[\\]=?)",a=n.either(/\b([A-Z]+[a-z0-9]+)+/,/\b([A-Z]+[a-z0-9]+)+[A-Z]+/),i=n.concat(a,/(::\w+)*/),r={ +"variable.constant":["__FILE__","__LINE__","__ENCODING__"], +"variable.language":["self","super"], +keyword:["alias","and","begin","BEGIN","break","case","class","defined","do","else","elsif","end","END","ensure","for","if","in","module","next","not","or","redo","require","rescue","retry","return","then","undef","unless","until","when","while","yield","include","extend","prepend","public","private","protected","raise","throw"], +built_in:["proc","lambda","attr_accessor","attr_reader","attr_writer","define_method","private_constant","module_function"], +literal:["true","false","nil"]},s={className:"doctag",begin:"@[A-Za-z]+"},o={ +begin:"#<",end:">"},l=[e.COMMENT("#","$",{contains:[s] +}),e.COMMENT("^=begin","^=end",{contains:[s],relevance:10 +}),e.COMMENT("^__END__",e.MATCH_NOTHING_RE)],c={className:"subst",begin:/#\{/, +end:/\}/,keywords:r},d={className:"string",contains:[e.BACKSLASH_ESCAPE,c], +variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/`/,end:/`/},{ +begin:/%[qQwWx]?\(/,end:/\)/},{begin:/%[qQwWx]?\[/,end:/\]/},{ +begin:/%[qQwWx]?\{/,end:/\}/},{begin:/%[qQwWx]?/},{begin:/%[qQwWx]?\//, +end:/\//},{begin:/%[qQwWx]?%/,end:/%/},{begin:/%[qQwWx]?-/,end:/-/},{ +begin:/%[qQwWx]?\|/,end:/\|/},{begin:/\B\?(\\\d{1,3})/},{ +begin:/\B\?(\\x[A-Fa-f0-9]{1,2})/},{begin:/\B\?(\\u\{?[A-Fa-f0-9]{1,6}\}?)/},{ +begin:/\B\?(\\M-\\C-|\\M-\\c|\\c\\M-|\\M-|\\C-\\M-)[\x20-\x7e]/},{ +begin:/\B\?\\(c|C-)[\x20-\x7e]/},{begin:/\B\?\\?\S/},{ +begin:n.concat(/<<[-~]?'?/,n.lookahead(/(\w+)(?=\W)[^\n]*\n(?:[^\n]*\n)*?\s*\1\b/)), +contains:[e.END_SAME_AS_BEGIN({begin:/(\w+)/,end:/(\w+)/, +contains:[e.BACKSLASH_ESCAPE,c]})]}]},g="[0-9](_?[0-9])*",u={className:"number", +relevance:0,variants:[{ +begin:`\\b([1-9](_?[0-9])*|0)(\\.(${g}))?([eE][+-]?(${g})|r)?i?\\b`},{ +begin:"\\b0[dD][0-9](_?[0-9])*r?i?\\b"},{begin:"\\b0[bB][0-1](_?[0-1])*r?i?\\b" +},{begin:"\\b0[oO][0-7](_?[0-7])*r?i?\\b"},{ +begin:"\\b0[xX][0-9a-fA-F](_?[0-9a-fA-F])*r?i?\\b"},{ +begin:"\\b0(_?[0-7])+r?i?\\b"}]},b={variants:[{match:/\(\)/},{ +className:"params",begin:/\(/,end:/(?=\))/,excludeBegin:!0,endsParent:!0, +keywords:r}]},m=[d,{variants:[{match:[/class\s+/,i,/\s+<\s+/,i]},{ +match:[/\b(class|module)\s+/,i]}],scope:{2:"title.class", +4:"title.class.inherited"},keywords:r},{match:[/(include|extend)\s+/,i],scope:{ +2:"title.class"},keywords:r},{relevance:0,match:[i,/\.new[. (]/],scope:{ +1:"title.class"}},{relevance:0,match:/\b[A-Z][A-Z_0-9]+\b/, +className:"variable.constant"},{relevance:0,match:a,scope:"title.class"},{ +match:[/def/,/\s+/,t],scope:{1:"keyword",3:"title.function"},contains:[b]},{ +begin:e.IDENT_RE+"::"},{className:"symbol", +begin:e.UNDERSCORE_IDENT_RE+"(!|\\?)?:",relevance:0},{className:"symbol", +begin:":(?!\\s)",contains:[d,{begin:t}],relevance:0},u,{className:"variable", +begin:"(\\$\\W)|((\\$|@@?)(\\w+))(?=[^@$?])(?![A-Za-z])(?![@$?'])"},{ +className:"params",begin:/\|(?!=)/,end:/\|/,excludeBegin:!0,excludeEnd:!0, +relevance:0,keywords:r},{begin:"("+e.RE_STARTERS_RE+"|unless)\\s*", +keywords:"unless",contains:[{className:"regexp",contains:[e.BACKSLASH_ESCAPE,c], +illegal:/\n/,variants:[{begin:"/",end:"/[a-z]*"},{begin:/%r\{/,end:/\}[a-z]*/},{ +begin:"%r\\(",end:"\\)[a-z]*"},{begin:"%r!",end:"![a-z]*"},{begin:"%r\\[", +end:"\\][a-z]*"}]}].concat(o,l),relevance:0}].concat(o,l) +;c.contains=m,b.contains=m;const p=[{begin:/^\s*=>/,starts:{end:"$",contains:m} +},{className:"meta.prompt", +begin:"^([>?]>|[\\w#]+\\(\\w+\\):\\d+:\\d+[>*]|(\\w+-)?\\d+\\.\\d+\\.\\d+(p\\d+)?[^\\d][^>]+>)(?=[ ])", +starts:{end:"$",keywords:r,contains:m}}];return l.unshift(o),{name:"Ruby", +aliases:["rb","gemspec","podspec","thor","irb"],keywords:r,illegal:/\/\*/, +contains:[e.SHEBANG({binary:"ruby"})].concat(p).concat(l).concat(m)}}, +grmr_rust:e=>{ +const n=e.regex,t=/(r#)?/,a=n.concat(t,e.UNDERSCORE_IDENT_RE),i=n.concat(t,e.IDENT_RE),r={ +className:"title.function.invoke",relevance:0, +begin:n.concat(/\b/,/(?!let|for|while|if|else|match\b)/,i,n.lookahead(/\s*\(/)) +},s="([ui](8|16|32|64|128|size)|f(32|64))?",o=["drop ","Copy","Send","Sized","Sync","Drop","Fn","FnMut","FnOnce","ToOwned","Clone","Debug","PartialEq","PartialOrd","Eq","Ord","AsRef","AsMut","Into","From","Default","Iterator","Extend","IntoIterator","DoubleEndedIterator","ExactSizeIterator","SliceConcatExt","ToString","assert!","assert_eq!","bitflags!","bytes!","cfg!","col!","concat!","concat_idents!","debug_assert!","debug_assert_eq!","env!","eprintln!","panic!","file!","format!","format_args!","include_bytes!","include_str!","line!","local_data_key!","module_path!","option_env!","print!","println!","select!","stringify!","try!","unimplemented!","unreachable!","vec!","write!","writeln!","macro_rules!","assert_ne!","debug_assert_ne!"],l=["i8","i16","i32","i64","i128","isize","u8","u16","u32","u64","u128","usize","f32","f64","str","char","bool","Box","Option","Result","String","Vec"] +;return{name:"Rust",aliases:["rs"],keywords:{$pattern:e.IDENT_RE+"!?",type:l, +keyword:["abstract","as","async","await","become","box","break","const","continue","crate","do","dyn","else","enum","extern","false","final","fn","for","if","impl","in","let","loop","macro","match","mod","move","mut","override","priv","pub","ref","return","self","Self","static","struct","super","trait","true","try","type","typeof","union","unsafe","unsized","use","virtual","where","while","yield"], +literal:["true","false","Some","None","Ok","Err"],built_in:o},illegal:""},r]}}, +grmr_scss:e=>{const n=te(e),t=se,a=re,i="@[a-z-]+",r={className:"variable", +begin:"(\\$[a-zA-Z-][a-zA-Z0-9_-]*)\\b",relevance:0};return{name:"SCSS", +case_insensitive:!0,illegal:"[=/|']", +contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,n.CSS_NUMBER_MODE,{ +className:"selector-id",begin:"#[A-Za-z0-9_-]+",relevance:0},{ +className:"selector-class",begin:"\\.[A-Za-z0-9_-]+",relevance:0 +},n.ATTRIBUTE_SELECTOR_MODE,{className:"selector-tag", +begin:"\\b("+ae.join("|")+")\\b",relevance:0},{className:"selector-pseudo", +begin:":("+a.join("|")+")"},{className:"selector-pseudo", +begin:":(:)?("+t.join("|")+")"},r,{begin:/\(/,end:/\)/, +contains:[n.CSS_NUMBER_MODE]},n.CSS_VARIABLE,{className:"attribute", +begin:"\\b("+oe.join("|")+")\\b"},{ +begin:"\\b(whitespace|wait|w-resize|visible|vertical-text|vertical-ideographic|uppercase|upper-roman|upper-alpha|underline|transparent|top|thin|thick|text|text-top|text-bottom|tb-rl|table-header-group|table-footer-group|sw-resize|super|strict|static|square|solid|small-caps|separate|se-resize|scroll|s-resize|rtl|row-resize|ridge|right|repeat|repeat-y|repeat-x|relative|progress|pointer|overline|outside|outset|oblique|nowrap|not-allowed|normal|none|nw-resize|no-repeat|no-drop|newspaper|ne-resize|n-resize|move|middle|medium|ltr|lr-tb|lowercase|lower-roman|lower-alpha|loose|list-item|line|line-through|line-edge|lighter|left|keep-all|justify|italic|inter-word|inter-ideograph|inside|inset|inline|inline-block|inherit|inactive|ideograph-space|ideograph-parenthesis|ideograph-numeric|ideograph-alpha|horizontal|hidden|help|hand|groove|fixed|ellipsis|e-resize|double|dotted|distribute|distribute-space|distribute-letter|distribute-all-lines|disc|disabled|default|decimal|dashed|crosshair|collapse|col-resize|circle|char|center|capitalize|break-word|break-all|bottom|both|bolder|bold|block|bidi-override|below|baseline|auto|always|all-scroll|absolute|table|table-cell)\\b" +},{begin:/:/,end:/[;}{]/,relevance:0, +contains:[n.BLOCK_COMMENT,r,n.HEXCOLOR,n.CSS_NUMBER_MODE,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,n.IMPORTANT,n.FUNCTION_DISPATCH] +},{begin:"@(page|font-face)",keywords:{$pattern:i,keyword:"@page @font-face"}},{ +begin:"@",end:"[{;]",returnBegin:!0,keywords:{$pattern:/[a-z-]+/, +keyword:"and or not only",attribute:ie.join(" ")},contains:[{begin:i, +className:"keyword"},{begin:/[a-z-]+(?=:)/,className:"attribute" +},r,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,n.HEXCOLOR,n.CSS_NUMBER_MODE] +},n.FUNCTION_DISPATCH]}},grmr_shell:e=>({name:"Shell Session", +aliases:["console","shellsession"],contains:[{className:"meta.prompt", +begin:/^\s{0,3}[/~\w\d[\]()@-]*[>%$#][ ]?/,starts:{end:/[^\\](?=\s*$)/, +subLanguage:"bash"}}]}),grmr_sql:e=>{ +const n=e.regex,t=e.COMMENT("--","$"),a=["abs","acos","array_agg","asin","atan","avg","cast","ceil","ceiling","coalesce","corr","cos","cosh","count","covar_pop","covar_samp","cume_dist","dense_rank","deref","element","exp","extract","first_value","floor","json_array","json_arrayagg","json_exists","json_object","json_objectagg","json_query","json_table","json_table_primitive","json_value","lag","last_value","lead","listagg","ln","log","log10","lower","max","min","mod","nth_value","ntile","nullif","percent_rank","percentile_cont","percentile_disc","position","position_regex","power","rank","regr_avgx","regr_avgy","regr_count","regr_intercept","regr_r2","regr_slope","regr_sxx","regr_sxy","regr_syy","row_number","sin","sinh","sqrt","stddev_pop","stddev_samp","substring","substring_regex","sum","tan","tanh","translate","translate_regex","treat","trim","trim_array","unnest","upper","value_of","var_pop","var_samp","width_bucket"],i=a,r=["abs","acos","all","allocate","alter","and","any","are","array","array_agg","array_max_cardinality","as","asensitive","asin","asymmetric","at","atan","atomic","authorization","avg","begin","begin_frame","begin_partition","between","bigint","binary","blob","boolean","both","by","call","called","cardinality","cascaded","case","cast","ceil","ceiling","char","char_length","character","character_length","check","classifier","clob","close","coalesce","collate","collect","column","commit","condition","connect","constraint","contains","convert","copy","corr","corresponding","cos","cosh","count","covar_pop","covar_samp","create","cross","cube","cume_dist","current","current_catalog","current_date","current_default_transform_group","current_path","current_role","current_row","current_schema","current_time","current_timestamp","current_path","current_role","current_transform_group_for_type","current_user","cursor","cycle","date","day","deallocate","dec","decimal","decfloat","declare","default","define","delete","dense_rank","deref","describe","deterministic","disconnect","distinct","double","drop","dynamic","each","element","else","empty","end","end_frame","end_partition","end-exec","equals","escape","every","except","exec","execute","exists","exp","external","extract","false","fetch","filter","first_value","float","floor","for","foreign","frame_row","free","from","full","function","fusion","get","global","grant","group","grouping","groups","having","hold","hour","identity","in","indicator","initial","inner","inout","insensitive","insert","int","integer","intersect","intersection","interval","into","is","join","json_array","json_arrayagg","json_exists","json_object","json_objectagg","json_query","json_table","json_table_primitive","json_value","lag","language","large","last_value","lateral","lead","leading","left","like","like_regex","listagg","ln","local","localtime","localtimestamp","log","log10","lower","match","match_number","match_recognize","matches","max","member","merge","method","min","minute","mod","modifies","module","month","multiset","national","natural","nchar","nclob","new","no","none","normalize","not","nth_value","ntile","null","nullif","numeric","octet_length","occurrences_regex","of","offset","old","omit","on","one","only","open","or","order","out","outer","over","overlaps","overlay","parameter","partition","pattern","per","percent","percent_rank","percentile_cont","percentile_disc","period","portion","position","position_regex","power","precedes","precision","prepare","primary","procedure","ptf","range","rank","reads","real","recursive","ref","references","referencing","regr_avgx","regr_avgy","regr_count","regr_intercept","regr_r2","regr_slope","regr_sxx","regr_sxy","regr_syy","release","result","return","returns","revoke","right","rollback","rollup","row","row_number","rows","running","savepoint","scope","scroll","search","second","seek","select","sensitive","session_user","set","show","similar","sin","sinh","skip","smallint","some","specific","specifictype","sql","sqlexception","sqlstate","sqlwarning","sqrt","start","static","stddev_pop","stddev_samp","submultiset","subset","substring","substring_regex","succeeds","sum","symmetric","system","system_time","system_user","table","tablesample","tan","tanh","then","time","timestamp","timezone_hour","timezone_minute","to","trailing","translate","translate_regex","translation","treat","trigger","trim","trim_array","true","truncate","uescape","union","unique","unknown","unnest","update","upper","user","using","value","values","value_of","var_pop","var_samp","varbinary","varchar","varying","versioning","when","whenever","where","width_bucket","window","with","within","without","year","add","asc","collation","desc","final","first","last","view"].filter((e=>!a.includes(e))),s={ +match:n.concat(/\b/,n.either(...i),/\s*\(/),relevance:0,keywords:{built_in:i}} +;function o(e){ +return n.concat(/\b/,n.either(...e.map((e=>e.replace(/\s+/,"\\s+")))),/\b/)} +const l={scope:"keyword", +match:o(["create table","insert into","primary key","foreign key","not null","alter table","add constraint","grouping sets","on overflow","character set","respect nulls","ignore nulls","nulls first","nulls last","depth first","breadth first"]), +relevance:0};return{name:"SQL",case_insensitive:!0,illegal:/[{}]|<\//,keywords:{ +$pattern:/\b[\w\.]+/,keyword:((e,{exceptions:n,when:t}={})=>{const a=t +;return n=n||[],e.map((e=>e.match(/\|\d+$/)||n.includes(e)?e:a(e)?e+"|0":e)) +})(r,{when:e=>e.length<3}),literal:["true","false","unknown"], +type:["bigint","binary","blob","boolean","char","character","clob","date","dec","decfloat","decimal","float","int","integer","interval","nchar","nclob","national","numeric","real","row","smallint","time","timestamp","varchar","varying","varbinary"], +built_in:["current_catalog","current_date","current_default_transform_group","current_path","current_role","current_schema","current_transform_group_for_type","current_user","session_user","system_time","system_user","current_time","localtime","current_timestamp","localtimestamp"] +},contains:[{scope:"type", +match:o(["double precision","large object","with timezone","without timezone"]) +},l,s,{scope:"variable",match:/@[a-z0-9][a-z0-9_]*/},{scope:"string",variants:[{ +begin:/'/,end:/'/,contains:[{match:/''/}]}]},{begin:/"/,end:/"/,contains:[{ +match:/""/}]},e.C_NUMBER_MODE,e.C_BLOCK_COMMENT_MODE,t,{scope:"operator", +match:/[-+*/=%^~]|&&?|\|\|?|!=?|<(?:=>?|<|>)?|>[>=]?/,relevance:0}]}}, +grmr_swift:e=>{const n={match:/\s+/,relevance:0},t=e.COMMENT("/\\*","\\*/",{ +contains:["self"]}),a=[e.C_LINE_COMMENT_MODE,t],i={match:[/\./,m(...ke,...xe)], +className:{2:"keyword"}},r={match:b(/\./,m(...Me)),relevance:0 +},s=Me.filter((e=>"string"==typeof e)).concat(["_|0"]),o={variants:[{ +className:"keyword", +match:m(...Me.filter((e=>"string"!=typeof e)).concat(Oe).map(Ne),...xe)}]},l={ +$pattern:m(/\b\w+/,/#\w+/),keyword:s.concat(Ce),literal:Ae},c=[i,r,o],g=[{ +match:b(/\./,m(...Te)),relevance:0},{className:"built_in", +match:b(/\b/,m(...Te),/(?=\()/)}],u={match:/->/,relevance:0},p=[u,{ +className:"operator",relevance:0,variants:[{match:Ie},{match:`\\.(\\.|${De})+`}] +}],_="([0-9]_*)+",h="([0-9a-fA-F]_*)+",f={className:"number",relevance:0, +variants:[{match:`\\b(${_})(\\.(${_}))?([eE][+-]?(${_}))?\\b`},{ +match:`\\b0x(${h})(\\.(${h}))?([pP][+-]?(${_}))?\\b`},{match:/\b0o([0-7]_*)+\b/ +},{match:/\b0b([01]_*)+\b/}]},E=(e="")=>({className:"subst",variants:[{ +match:b(/\\/,e,/[0\\tnr"']/)},{match:b(/\\/,e,/u\{[0-9a-fA-F]{1,8}\}/)}] +}),y=(e="")=>({className:"subst",match:b(/\\/,e,/[\t ]*(?:[\r\n]|\r\n)/) +}),w=(e="")=>({className:"subst",label:"interpol",begin:b(/\\/,e,/\(/),end:/\)/ +}),v=(e="")=>({begin:b(e,/"""/),end:b(/"""/,e),contains:[E(e),y(e),w(e)] +}),N=(e="")=>({begin:b(e,/"/),end:b(/"/,e),contains:[E(e),w(e)]}),k={ +className:"string", +variants:[v(),v("#"),v("##"),v("###"),N(),N("#"),N("##"),N("###")] +},x=[e.BACKSLASH_ESCAPE,{begin:/\[/,end:/\]/,relevance:0, +contains:[e.BACKSLASH_ESCAPE]}],O={begin:/\/[^\s](?=[^/\n]*\/)/,end:/\//, +contains:x},M=e=>{const n=b(e,/\//),t=b(/\//,e);return{begin:n,end:t, +contains:[...x,{scope:"comment",begin:`#(?!.*${t})`,end:/$/}]}},A={ +scope:"regexp",variants:[M("###"),M("##"),M("#"),O]},S={match:b(/`/,$e,/`/) +},C=[S,{className:"variable",match:/\$\d+/},{className:"variable", +match:`\\$${Be}+`}],T=[{match:/(@|#(un)?)available/,scope:"keyword",starts:{ +contains:[{begin:/\(/,end:/\)/,keywords:je,contains:[...p,f,k]}]}},{ +scope:"keyword",match:b(/@/,m(...ze),d(m(/\(/,/\s+/)))},{scope:"meta", +match:b(/@/,$e)}],R={match:d(/\b[A-Z]/),relevance:0,contains:[{className:"type", +match:b(/(AV|CA|CF|CG|CI|CL|CM|CN|CT|MK|MP|MTK|MTL|NS|SCN|SK|UI|WK|XC)/,Be,"+") +},{className:"type",match:Fe,relevance:0},{match:/[?!]+/,relevance:0},{ +match:/\.\.\./,relevance:0},{match:b(/\s+&\s+/,d(Fe)),relevance:0}]},D={ +begin://,keywords:l,contains:[...a,...c,...T,u,R]};R.contains.push(D) +;const I={begin:/\(/,end:/\)/,relevance:0,keywords:l,contains:["self",{ +match:b($e,/\s*:/),keywords:"_|0",relevance:0 +},...a,A,...c,...g,...p,f,k,...C,...T,R]},L={begin://, +keywords:"repeat each",contains:[...a,R]},B={begin:/\(/,end:/\)/,keywords:l, +contains:[{begin:m(d(b($e,/\s*:/)),d(b($e,/\s+/,$e,/\s*:/))),end:/:/, +relevance:0,contains:[{className:"keyword",match:/\b_\b/},{className:"params", +match:$e}]},...a,...c,...p,f,k,...T,R,I],endsParent:!0,illegal:/["']/},$={ +match:[/(func|macro)/,/\s+/,m(S.match,$e,Ie)],className:{1:"keyword", +3:"title.function"},contains:[L,B,n],illegal:[/\[/,/%/]},F={ +match:[/\b(?:subscript|init[?!]?)/,/\s*(?=[<(])/],className:{1:"keyword"}, +contains:[L,B,n],illegal:/\[|%/},z={match:[/operator/,/\s+/,Ie],className:{ +1:"keyword",3:"title"}},j={begin:[/precedencegroup/,/\s+/,Fe],className:{ +1:"keyword",3:"title"},contains:[R],keywords:[...Se,...Ae],end:/}/},U={ +begin:[/(struct|protocol|class|extension|enum|actor)/,/\s+/,$e,/\s*/], +beginScope:{1:"keyword",3:"title.class"},keywords:l,contains:[L,...c,{begin:/:/, +end:/\{/,keywords:l,contains:[{scope:"title.class.inherited",match:Fe},...c], +relevance:0}]};for(const e of k.variants){ +const n=e.contains.find((e=>"interpol"===e.label));n.keywords=l +;const t=[...c,...g,...p,f,k,...C];n.contains=[...t,{begin:/\(/,end:/\)/, +contains:["self",...t]}]}return{name:"Swift",keywords:l,contains:[...a,$,F,{ +match:[/class\b/,/\s+/,/func\b/,/\s+/,/\b[A-Za-z_][A-Za-z0-9_]*\b/],scope:{ +1:"keyword",3:"keyword",5:"title.function"}},{match:[/class\b/,/\s+/,/var\b/], +scope:{1:"keyword",3:"keyword"}},U,z,j,{beginKeywords:"import",end:/$/, +contains:[...a],relevance:0},A,...c,...g,...p,f,k,...C,...T,R,I]}}, +grmr_typescript:e=>{ +const n=e.regex,t=ve(e),a=me,i=["any","void","number","boolean","string","object","never","symbol","bigint","unknown"],r={ +begin:[/namespace/,/\s+/,e.IDENT_RE],beginScope:{1:"keyword",3:"title.class"} +},s={beginKeywords:"interface",end:/\{/,excludeEnd:!0,keywords:{ +keyword:"interface extends",built_in:i},contains:[t.exports.CLASS_REFERENCE] +},o={$pattern:me, +keyword:pe.concat(["type","interface","public","private","protected","implements","declare","abstract","readonly","enum","override","satisfies"]), +literal:_e,built_in:we.concat(i),"variable.language":ye},l={className:"meta", +begin:"@"+a},c=(e,n,t)=>{const a=e.contains.findIndex((e=>e.label===n)) +;if(-1===a)throw Error("can not find mode to replace");e.contains.splice(a,1,t)} +;Object.assign(t.keywords,o),t.exports.PARAMS_CONTAINS.push(l) +;const d=t.contains.find((e=>"attr"===e.scope)),g=Object.assign({},d,{ +match:n.concat(a,n.lookahead(/\s*\?:/))}) +;return t.exports.PARAMS_CONTAINS.push([t.exports.CLASS_REFERENCE,d,g]), +t.contains=t.contains.concat([l,r,s,g]), +c(t,"shebang",e.SHEBANG()),c(t,"use_strict",{className:"meta",relevance:10, +begin:/^\s*['"]use strict['"]/ +}),t.contains.find((e=>"func.def"===e.label)).relevance=0,Object.assign(t,{ +name:"TypeScript",aliases:["ts","tsx","mts","cts"]}),t},grmr_vbnet:e=>{ +const n=e.regex,t=/\d{1,2}\/\d{1,2}\/\d{4}/,a=/\d{4}-\d{1,2}-\d{1,2}/,i=/(\d|1[012])(:\d+){0,2} *(AM|PM)/,r=/\d{1,2}(:\d{1,2}){1,2}/,s={ +className:"literal",variants:[{begin:n.concat(/# */,n.either(a,t),/ *#/)},{ +begin:n.concat(/# */,r,/ *#/)},{begin:n.concat(/# */,i,/ *#/)},{ +begin:n.concat(/# */,n.either(a,t),/ +/,n.either(i,r),/ *#/)}] +},o=e.COMMENT(/'''/,/$/,{contains:[{className:"doctag",begin:/<\/?/,end:/>/}] +}),l=e.COMMENT(null,/$/,{variants:[{begin:/'/},{begin:/([\t ]|^)REM(?=\s)/}]}) +;return{name:"Visual Basic .NET",aliases:["vb"],case_insensitive:!0, +classNameAliases:{label:"symbol"},keywords:{ +keyword:"addhandler alias aggregate ansi as async assembly auto binary by byref byval call case catch class compare const continue custom declare default delegate dim distinct do each equals else elseif end enum erase error event exit explicit finally for friend from function get global goto group handles if implements imports in inherits interface into iterator join key let lib loop me mid module mustinherit mustoverride mybase myclass namespace narrowing new next notinheritable notoverridable of off on operator option optional order overloads overridable overrides paramarray partial preserve private property protected public raiseevent readonly redim removehandler resume return select set shadows shared skip static step stop structure strict sub synclock take text then throw to try unicode until using when where while widening with withevents writeonly yield", +built_in:"addressof and andalso await directcast gettype getxmlnamespace is isfalse isnot istrue like mod nameof new not or orelse trycast typeof xor cbool cbyte cchar cdate cdbl cdec cint clng cobj csbyte cshort csng cstr cuint culng cushort", +type:"boolean byte char date decimal double integer long object sbyte short single string uinteger ulong ushort", +literal:"true false nothing"}, +illegal:"//|\\{|\\}|endif|gosub|variant|wend|^\\$ ",contains:[{ +className:"string",begin:/"(""|[^/n])"C\b/},{className:"string",begin:/"/, +end:/"/,illegal:/\n/,contains:[{begin:/""/}]},s,{className:"number",relevance:0, +variants:[{begin:/\b\d[\d_]*((\.[\d_]+(E[+-]?[\d_]+)?)|(E[+-]?[\d_]+))[RFD@!#]?/ +},{begin:/\b\d[\d_]*((U?[SIL])|[%&])?/},{begin:/&H[\dA-F_]+((U?[SIL])|[%&])?/},{ +begin:/&O[0-7_]+((U?[SIL])|[%&])?/},{begin:/&B[01_]+((U?[SIL])|[%&])?/}]},{ +className:"label",begin:/^\w+:/},o,l,{className:"meta", +begin:/[\t ]*#(const|disable|else|elseif|enable|end|externalsource|if|region)\b/, +end:/$/,keywords:{ +keyword:"const disable else elseif enable end externalsource if region then"}, +contains:[l]}]}},grmr_wasm:e=>{e.regex;const n=e.COMMENT(/\(;/,/;\)/) +;return n.contains.push("self"),{name:"WebAssembly",keywords:{$pattern:/[\w.]+/, +keyword:["anyfunc","block","br","br_if","br_table","call","call_indirect","data","drop","elem","else","end","export","func","global.get","global.set","local.get","local.set","local.tee","get_global","get_local","global","if","import","local","loop","memory","memory.grow","memory.size","module","mut","nop","offset","param","result","return","select","set_global","set_local","start","table","tee_local","then","type","unreachable"] +},contains:[e.COMMENT(/;;/,/$/),n,{match:[/(?:offset|align)/,/\s*/,/=/], +className:{1:"keyword",3:"operator"}},{className:"variable",begin:/\$[\w_]+/},{ +match:/(\((?!;)|\))+/,className:"punctuation",relevance:0},{ +begin:[/(?:func|call|call_indirect)/,/\s+/,/\$[^\s)]+/],className:{1:"keyword", +3:"title.function"}},e.QUOTE_STRING_MODE,{match:/(i32|i64|f32|f64)(?!\.)/, +className:"type"},{className:"keyword", +match:/\b(f32|f64|i32|i64)(?:\.(?:abs|add|and|ceil|clz|const|convert_[su]\/i(?:32|64)|copysign|ctz|demote\/f64|div(?:_[su])?|eqz?|extend_[su]\/i32|floor|ge(?:_[su])?|gt(?:_[su])?|le(?:_[su])?|load(?:(?:8|16|32)_[su])?|lt(?:_[su])?|max|min|mul|nearest|neg?|or|popcnt|promote\/f32|reinterpret\/[fi](?:32|64)|rem_[su]|rot[lr]|shl|shr_[su]|store(?:8|16|32)?|sqrt|sub|trunc(?:_[su]\/f(?:32|64))?|wrap\/i64|xor))\b/ +},{className:"number",relevance:0, +match:/[+-]?\b(?:\d(?:_?\d)*(?:\.\d(?:_?\d)*)?(?:[eE][+-]?\d(?:_?\d)*)?|0x[\da-fA-F](?:_?[\da-fA-F])*(?:\.[\da-fA-F](?:_?[\da-fA-D])*)?(?:[pP][+-]?\d(?:_?\d)*)?)\b|\binf\b|\bnan(?::0x[\da-fA-F](?:_?[\da-fA-D])*)?\b/ +}]}},grmr_xml:e=>{ +const n=e.regex,t=n.concat(/[\p{L}_]/u,n.optional(/[\p{L}0-9_.-]*:/u),/[\p{L}0-9_.-]*/u),a={ +className:"symbol",begin:/&[a-z]+;|&#[0-9]+;|&#x[a-f0-9]+;/},i={begin:/\s/, +contains:[{className:"keyword",begin:/#?[a-z_][a-z1-9_-]+/,illegal:/\n/}] +},r=e.inherit(i,{begin:/\(/,end:/\)/}),s=e.inherit(e.APOS_STRING_MODE,{ +className:"string"}),o=e.inherit(e.QUOTE_STRING_MODE,{className:"string"}),l={ +endsWithParent:!0,illegal:/`]+/}]}]}]};return{ +name:"HTML, XML", +aliases:["html","xhtml","rss","atom","xjb","xsd","xsl","plist","wsf","svg"], +case_insensitive:!0,unicodeRegex:!0,contains:[{className:"meta",begin://,relevance:10,contains:[i,o,s,r,{begin:/\[/,end:/\]/,contains:[{ +className:"meta",begin://,contains:[i,r,o,s]}]}] +},e.COMMENT(//,{relevance:10}),{begin://, +relevance:10},a,{className:"meta",end:/\?>/,variants:[{begin:/<\?xml/, +relevance:10,contains:[o]},{begin:/<\?[a-z][a-z0-9]+/}]},{className:"tag", +begin:/)/,end:/>/,keywords:{name:"style"},contains:[l],starts:{ +end:/<\/style>/,returnEnd:!0,subLanguage:["css","xml"]}},{className:"tag", +begin:/)/,end:/>/,keywords:{name:"script"},contains:[l],starts:{ +end:/<\/script>/,returnEnd:!0,subLanguage:["javascript","handlebars","xml"]}},{ +className:"tag",begin:/<>|<\/>/},{className:"tag", +begin:n.concat(//,/>/,/\s/)))), +end:/\/?>/,contains:[{className:"name",begin:t,relevance:0,starts:l}]},{ +className:"tag",begin:n.concat(/<\//,n.lookahead(n.concat(t,/>/))),contains:[{ +className:"name",begin:t,relevance:0},{begin:/>/,relevance:0,endsParent:!0}]}]} +},grmr_yaml:e=>{ +const n="true false yes no null",t="[\\w#;/?:@&=+$,.~*'()[\\]]+",a={ +className:"string",relevance:0,variants:[{begin:/"/,end:/"/},{begin:/\S+/}], +contains:[e.BACKSLASH_ESCAPE,{className:"template-variable",variants:[{ +begin:/\{\{/,end:/\}\}/},{begin:/%\{/,end:/\}/}]}]},i=e.inherit(a,{variants:[{ +begin:/'/,end:/'/,contains:[{begin:/''/,relevance:0}]},{begin:/"/,end:/"/},{ +begin:/[^\s,{}[\]]+/}]}),r={end:",",endsWithParent:!0,excludeEnd:!0,keywords:n, +relevance:0},s={begin:/\{/,end:/\}/,contains:[r],illegal:"\\n",relevance:0},o={ +begin:"\\[",end:"\\]",contains:[r],illegal:"\\n",relevance:0},l=[{ +className:"attr",variants:[{begin:/[\w*@][\w*@ :()\./-]*:(?=[ \t]|$)/},{ +begin:/"[\w*@][\w*@ :()\./-]*":(?=[ \t]|$)/},{ +begin:/'[\w*@][\w*@ :()\./-]*':(?=[ \t]|$)/}]},{className:"meta", +begin:"^---\\s*$",relevance:10},{className:"string", +begin:"[\\|>]([1-9]?[+-])?[ ]*\\n( +)[^ ][^\\n]*\\n(\\2[^\\n]+\\n?)*"},{ +begin:"<%[%=-]?",end:"[%-]?%>",subLanguage:"ruby",excludeBegin:!0,excludeEnd:!0, +relevance:0},{className:"type",begin:"!\\w+!"+t},{className:"type", +begin:"!<"+t+">"},{className:"type",begin:"!"+t},{className:"type",begin:"!!"+t +},{className:"meta",begin:"&"+e.UNDERSCORE_IDENT_RE+"$"},{className:"meta", +begin:"\\*"+e.UNDERSCORE_IDENT_RE+"$"},{className:"bullet",begin:"-(?=[ ]|$)", +relevance:0},e.HASH_COMMENT_MODE,{beginKeywords:n,keywords:{literal:n}},{ +className:"number", +begin:"\\b[0-9]{4}(-[0-9][0-9]){0,2}([Tt \\t][0-9][0-9]?(:[0-9][0-9]){2})?(\\.[0-9]*)?([ \\t])*(Z|[-+][0-9][0-9]?(:[0-9][0-9])?)?\\b" +},{className:"number",begin:e.C_NUMBER_RE+"\\b",relevance:0},s,o,{ +className:"string",relevance:0,begin:/'/,end:/'/,contains:[{match:/''/, +scope:"char.escape",relevance:0}]},a],c=[...l] +;return c.pop(),c.push(i),r.contains=c,{name:"YAML",case_insensitive:!0, +aliases:["yml"],contains:l}}});const Pe=ne;for(const e of Object.keys(Ue)){ +const n=e.replace("grmr_","").replace("_","-");Pe.registerLanguage(n,Ue[e])} +export{Pe as default}; \ No newline at end of file diff --git a/molodetz/static/vendor/marked.esm.js b/molodetz/static/vendor/marked.esm.js new file mode 100644 index 0000000..b5f4066 --- /dev/null +++ b/molodetz/static/vendor/marked.esm.js @@ -0,0 +1,2579 @@ +/** + * marked v15.0.7 - a markdown parser + * Copyright (c) 2011-2025, Christopher Jeffrey. (MIT Licensed) + * https://github.com/markedjs/marked + */ + +/** + * DO NOT EDIT THIS FILE + * The code in this file is generated from files in ./src/ + */ + +/** + * Gets the original marked default options. + */ +function _getDefaults() { + return { + async: false, + breaks: false, + extensions: null, + gfm: true, + hooks: null, + pedantic: false, + renderer: null, + silent: false, + tokenizer: null, + walkTokens: null, + }; +} +let _defaults = _getDefaults(); +function changeDefaults(newDefaults) { + _defaults = newDefaults; +} + +const noopTest = { exec: () => null }; +function edit(regex, opt = '') { + let source = typeof regex === 'string' ? regex : regex.source; + const obj = { + replace: (name, val) => { + let valSource = typeof val === 'string' ? val : val.source; + valSource = valSource.replace(other.caret, '$1'); + source = source.replace(name, valSource); + return obj; + }, + getRegex: () => { + return new RegExp(source, opt); + }, + }; + return obj; +} +const other = { + codeRemoveIndent: /^(?: {1,4}| {0,3}\t)/gm, + outputLinkReplace: /\\([\[\]])/g, + indentCodeCompensation: /^(\s+)(?:```)/, + beginningSpace: /^\s+/, + endingHash: /#$/, + startingSpaceChar: /^ /, + endingSpaceChar: / $/, + nonSpaceChar: /[^ ]/, + newLineCharGlobal: /\n/g, + tabCharGlobal: /\t/g, + multipleSpaceGlobal: /\s+/g, + blankLine: /^[ \t]*$/, + doubleBlankLine: /\n[ \t]*\n[ \t]*$/, + blockquoteStart: /^ {0,3}>/, + blockquoteSetextReplace: /\n {0,3}((?:=+|-+) *)(?=\n|$)/g, + blockquoteSetextReplace2: /^ {0,3}>[ \t]?/gm, + listReplaceTabs: /^\t+/, + listReplaceNesting: /^ {1,4}(?=( {4})*[^ ])/g, + listIsTask: /^\[[ xX]\] /, + listReplaceTask: /^\[[ xX]\] +/, + anyLine: /\n.*\n/, + hrefBrackets: /^<(.*)>$/, + tableDelimiter: /[:|]/, + tableAlignChars: /^\||\| *$/g, + tableRowBlankLine: /\n[ \t]*$/, + tableAlignRight: /^ *-+: *$/, + tableAlignCenter: /^ *:-+: *$/, + tableAlignLeft: /^ *:-+ *$/, + startATag: /^/i, + startPreScriptTag: /^<(pre|code|kbd|script)(\s|>)/i, + endPreScriptTag: /^<\/(pre|code|kbd|script)(\s|>)/i, + startAngleBracket: /^$/, + pedanticHrefTitle: /^([^'"]*[^\s])\s+(['"])(.*)\2/, + unicodeAlphaNumeric: /[\p{L}\p{N}]/u, + escapeTest: /[&<>"']/, + escapeReplace: /[&<>"']/g, + escapeTestNoEncode: /[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/, + escapeReplaceNoEncode: /[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g, + unescapeTest: /&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/ig, + caret: /(^|[^\[])\^/g, + percentDecode: /%25/g, + findPipe: /\|/g, + splitPipe: / \|/, + slashPipe: /\\\|/g, + carriageReturn: /\r\n|\r/g, + spaceLine: /^ +$/gm, + notSpaceStart: /^\S*/, + endingNewline: /\n$/, + listItemRegex: (bull) => new RegExp(`^( {0,3}${bull})((?:[\t ][^\\n]*)?(?:\\n|$))`), + nextBulletRegex: (indent) => new RegExp(`^ {0,${Math.min(3, indent - 1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ \t][^\\n]*)?(?:\\n|$))`), + hrRegex: (indent) => new RegExp(`^ {0,${Math.min(3, indent - 1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`), + fencesBeginRegex: (indent) => new RegExp(`^ {0,${Math.min(3, indent - 1)}}(?:\`\`\`|~~~)`), + headingBeginRegex: (indent) => new RegExp(`^ {0,${Math.min(3, indent - 1)}}#`), + htmlBeginRegex: (indent) => new RegExp(`^ {0,${Math.min(3, indent - 1)}}<(?:[a-z].*>|!--)`, 'i'), +}; +/** + * Block-Level Grammar + */ +const newline = /^(?:[ \t]*(?:\n|$))+/; +const blockCode = /^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/; +const fences = /^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/; +const hr = /^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/; +const heading = /^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/; +const bullet = /(?:[*+-]|\d{1,9}[.)])/; +const lheadingCore = /^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/; +const lheading = edit(lheadingCore) + .replace(/bull/g, bullet) // lists can interrupt + .replace(/blockCode/g, /(?: {4}| {0,3}\t)/) // indented code blocks can interrupt + .replace(/fences/g, / {0,3}(?:`{3,}|~{3,})/) // fenced code blocks can interrupt + .replace(/blockquote/g, / {0,3}>/) // blockquote can interrupt + .replace(/heading/g, / {0,3}#{1,6}/) // ATX heading can interrupt + .replace(/html/g, / {0,3}<[^\n>]+>\n/) // block html can interrupt + .replace(/\|table/g, '') // table not in commonmark + .getRegex(); +const lheadingGfm = edit(lheadingCore) + .replace(/bull/g, bullet) // lists can interrupt + .replace(/blockCode/g, /(?: {4}| {0,3}\t)/) // indented code blocks can interrupt + .replace(/fences/g, / {0,3}(?:`{3,}|~{3,})/) // fenced code blocks can interrupt + .replace(/blockquote/g, / {0,3}>/) // blockquote can interrupt + .replace(/heading/g, / {0,3}#{1,6}/) // ATX heading can interrupt + .replace(/html/g, / {0,3}<[^\n>]+>\n/) // block html can interrupt + .replace(/table/g, / {0,3}\|?(?:[:\- ]*\|)+[\:\- ]*\n/) // table can interrupt + .getRegex(); +const _paragraph = /^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/; +const blockText = /^[^\n]+/; +const _blockLabel = /(?!\s*\])(?:\\.|[^\[\]\\])+/; +const def = edit(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/) + .replace('label', _blockLabel) + .replace('title', /(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/) + .getRegex(); +const list = edit(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/) + .replace(/bull/g, bullet) + .getRegex(); +const _tag = 'address|article|aside|base|basefont|blockquote|body|caption' + + '|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption' + + '|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe' + + '|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option' + + '|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title' + + '|tr|track|ul'; +const _comment = /|$))/; +const html = edit('^ {0,3}(?:' // optional indentation + + '<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)' // (1) + + '|comment[^\\n]*(\\n+|$)' // (2) + + '|<\\?[\\s\\S]*?(?:\\?>\\n*|$)' // (3) + + '|\\n*|$)' // (4) + + '|\\n*|$)' // (5) + + '|)[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)' // (6) + + '|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)' // (7) open tag + + '|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)' // (7) closing tag + + ')', 'i') + .replace('comment', _comment) + .replace('tag', _tag) + .replace('attribute', / +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/) + .getRegex(); +const paragraph = edit(_paragraph) + .replace('hr', hr) + .replace('heading', ' {0,3}#{1,6}(?:\\s|$)') + .replace('|lheading', '') // setext headings don't interrupt commonmark paragraphs + .replace('|table', '') + .replace('blockquote', ' {0,3}>') + .replace('fences', ' {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n') + .replace('list', ' {0,3}(?:[*+-]|1[.)]) ') // only lists starting from 1 can interrupt + .replace('html', ')|<(?:script|pre|style|textarea|!--)') + .replace('tag', _tag) // pars can be interrupted by type (6) html blocks + .getRegex(); +const blockquote = edit(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/) + .replace('paragraph', paragraph) + .getRegex(); +/** + * Normal Block Grammar + */ +const blockNormal = { + blockquote, + code: blockCode, + def, + fences, + heading, + hr, + html, + lheading, + list, + newline, + paragraph, + table: noopTest, + text: blockText, +}; +/** + * GFM Block Grammar + */ +const gfmTable = edit('^ *([^\\n ].*)\\n' // Header + + ' {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)' // Align + + '(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)') // Cells + .replace('hr', hr) + .replace('heading', ' {0,3}#{1,6}(?:\\s|$)') + .replace('blockquote', ' {0,3}>') + .replace('code', '(?: {4}| {0,3}\t)[^\\n]') + .replace('fences', ' {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n') + .replace('list', ' {0,3}(?:[*+-]|1[.)]) ') // only lists starting from 1 can interrupt + .replace('html', ')|<(?:script|pre|style|textarea|!--)') + .replace('tag', _tag) // tables can be interrupted by type (6) html blocks + .getRegex(); +const blockGfm = { + ...blockNormal, + lheading: lheadingGfm, + table: gfmTable, + paragraph: edit(_paragraph) + .replace('hr', hr) + .replace('heading', ' {0,3}#{1,6}(?:\\s|$)') + .replace('|lheading', '') // setext headings don't interrupt commonmark paragraphs + .replace('table', gfmTable) // interrupt paragraphs with table + .replace('blockquote', ' {0,3}>') + .replace('fences', ' {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n') + .replace('list', ' {0,3}(?:[*+-]|1[.)]) ') // only lists starting from 1 can interrupt + .replace('html', ')|<(?:script|pre|style|textarea|!--)') + .replace('tag', _tag) // pars can be interrupted by type (6) html blocks + .getRegex(), +}; +/** + * Pedantic grammar (original John Gruber's loose markdown specification) + */ +const blockPedantic = { + ...blockNormal, + html: edit('^ *(?:comment *(?:\\n|\\s*$)' + + '|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)' // closed tag + + '|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))') + .replace('comment', _comment) + .replace(/tag/g, '(?!(?:' + + 'a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub' + + '|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)' + + '\\b)\\w+(?!:|[^\\w\\s@]*@)\\b') + .getRegex(), + def: /^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/, + heading: /^(#{1,6})(.*)(?:\n+|$)/, + fences: noopTest, // fences not supported + lheading: /^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/, + paragraph: edit(_paragraph) + .replace('hr', hr) + .replace('heading', ' *#{1,6} *[^\n]') + .replace('lheading', lheading) + .replace('|table', '') + .replace('blockquote', ' {0,3}>') + .replace('|fences', '') + .replace('|list', '') + .replace('|html', '') + .replace('|tag', '') + .getRegex(), +}; +/** + * Inline-Level Grammar + */ +const escape$1 = /^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/; +const inlineCode = /^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/; +const br = /^( {2,}|\\)\n(?!\s*$)/; +const inlineText = /^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\ +const blockSkip = /\[[^[\]]*?\]\((?:\\.|[^\\\(\)]|\((?:\\.|[^\\\(\)])*\))*\)|`[^`]*?`|<[^<>]*?>/g; +const emStrongLDelimCore = /^(?:\*+(?:((?!\*)punct)|[^\s*]))|^_+(?:((?!_)punct)|([^\s_]))/; +const emStrongLDelim = edit(emStrongLDelimCore, 'u') + .replace(/punct/g, _punctuation) + .getRegex(); +const emStrongLDelimGfm = edit(emStrongLDelimCore, 'u') + .replace(/punct/g, _punctuationGfmStrongEm) + .getRegex(); +const emStrongRDelimAstCore = '^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)' // Skip orphan inside strong + + '|[^*]+(?=[^*])' // Consume to delim + + '|(?!\\*)punct(\\*+)(?=[\\s]|$)' // (1) #*** can only be a Right Delimiter + + '|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)' // (2) a***#, a*** can only be a Right Delimiter + + '|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)' // (3) #***a, ***a can only be Left Delimiter + + '|[\\s](\\*+)(?!\\*)(?=punct)' // (4) ***# can only be Left Delimiter + + '|(?!\\*)punct(\\*+)(?!\\*)(?=punct)' // (5) #***# can be either Left or Right Delimiter + + '|notPunctSpace(\\*+)(?=notPunctSpace)'; // (6) a***a can be either Left or Right Delimiter +const emStrongRDelimAst = edit(emStrongRDelimAstCore, 'gu') + .replace(/notPunctSpace/g, _notPunctuationOrSpace) + .replace(/punctSpace/g, _punctuationOrSpace) + .replace(/punct/g, _punctuation) + .getRegex(); +const emStrongRDelimAstGfm = edit(emStrongRDelimAstCore, 'gu') + .replace(/notPunctSpace/g, _notPunctuationOrSpaceGfmStrongEm) + .replace(/punctSpace/g, _punctuationOrSpaceGfmStrongEm) + .replace(/punct/g, _punctuationGfmStrongEm) + .getRegex(); +// (6) Not allowed for _ +const emStrongRDelimUnd = edit('^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)' // Skip orphan inside strong + + '|[^_]+(?=[^_])' // Consume to delim + + '|(?!_)punct(_+)(?=[\\s]|$)' // (1) #___ can only be a Right Delimiter + + '|notPunctSpace(_+)(?!_)(?=punctSpace|$)' // (2) a___#, a___ can only be a Right Delimiter + + '|(?!_)punctSpace(_+)(?=notPunctSpace)' // (3) #___a, ___a can only be Left Delimiter + + '|[\\s](_+)(?!_)(?=punct)' // (4) ___# can only be Left Delimiter + + '|(?!_)punct(_+)(?!_)(?=punct)', 'gu') // (5) #___# can be either Left or Right Delimiter + .replace(/notPunctSpace/g, _notPunctuationOrSpace) + .replace(/punctSpace/g, _punctuationOrSpace) + .replace(/punct/g, _punctuation) + .getRegex(); +const anyPunctuation = edit(/\\(punct)/, 'gu') + .replace(/punct/g, _punctuation) + .getRegex(); +const autolink = edit(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/) + .replace('scheme', /[a-zA-Z][a-zA-Z0-9+.-]{1,31}/) + .replace('email', /[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/) + .getRegex(); +const _inlineComment = edit(_comment).replace('(?:-->|$)', '-->').getRegex(); +const tag = edit('^comment' + + '|^' // self-closing tag + + '|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>' // open tag + + '|^<\\?[\\s\\S]*?\\?>' // processing instruction, e.g. + + '|^' // declaration, e.g. + + '|^') // CDATA section + .replace('comment', _inlineComment) + .replace('attribute', /\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/) + .getRegex(); +const _inlineLabel = /(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/; +const link = edit(/^!?\[(label)\]\(\s*(href)(?:\s+(title))?\s*\)/) + .replace('label', _inlineLabel) + .replace('href', /<(?:\\.|[^\n<>\\])+>|[^\s\x00-\x1f]*/) + .replace('title', /"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/) + .getRegex(); +const reflink = edit(/^!?\[(label)\]\[(ref)\]/) + .replace('label', _inlineLabel) + .replace('ref', _blockLabel) + .getRegex(); +const nolink = edit(/^!?\[(ref)\](?:\[\])?/) + .replace('ref', _blockLabel) + .getRegex(); +const reflinkSearch = edit('reflink|nolink(?!\\()', 'g') + .replace('reflink', reflink) + .replace('nolink', nolink) + .getRegex(); +/** + * Normal Inline Grammar + */ +const inlineNormal = { + _backpedal: noopTest, // only used for GFM url + anyPunctuation, + autolink, + blockSkip, + br, + code: inlineCode, + del: noopTest, + emStrongLDelim, + emStrongRDelimAst, + emStrongRDelimUnd, + escape: escape$1, + link, + nolink, + punctuation, + reflink, + reflinkSearch, + tag, + text: inlineText, + url: noopTest, +}; +/** + * Pedantic Inline Grammar + */ +const inlinePedantic = { + ...inlineNormal, + link: edit(/^!?\[(label)\]\((.*?)\)/) + .replace('label', _inlineLabel) + .getRegex(), + reflink: edit(/^!?\[(label)\]\s*\[([^\]]*)\]/) + .replace('label', _inlineLabel) + .getRegex(), +}; +/** + * GFM Inline Grammar + */ +const inlineGfm = { + ...inlineNormal, + emStrongRDelimAst: emStrongRDelimAstGfm, + emStrongLDelim: emStrongLDelimGfm, + url: edit(/^((?:ftp|https?):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/, 'i') + .replace('email', /[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/) + .getRegex(), + _backpedal: /(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/, + del: /^(~~?)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/, + text: /^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\': '>', + '"': '"', + "'": ''', +}; +const getEscapeReplacement = (ch) => escapeReplacements[ch]; +function escape(html, encode) { + if (encode) { + if (other.escapeTest.test(html)) { + return html.replace(other.escapeReplace, getEscapeReplacement); + } + } + else { + if (other.escapeTestNoEncode.test(html)) { + return html.replace(other.escapeReplaceNoEncode, getEscapeReplacement); + } + } + return html; +} +function cleanUrl(href) { + try { + href = encodeURI(href).replace(other.percentDecode, '%'); + } + catch { + return null; + } + return href; +} +function splitCells(tableRow, count) { + // ensure that every cell-delimiting pipe has a space + // before it to distinguish it from an escaped pipe + const row = tableRow.replace(other.findPipe, (match, offset, str) => { + let escaped = false; + let curr = offset; + while (--curr >= 0 && str[curr] === '\\') + escaped = !escaped; + if (escaped) { + // odd number of slashes means | is escaped + // so we leave it alone + return '|'; + } + else { + // add space before unescaped | + return ' |'; + } + }), cells = row.split(other.splitPipe); + let i = 0; + // First/last cell in a row cannot be empty if it has no leading/trailing pipe + if (!cells[0].trim()) { + cells.shift(); + } + if (cells.length > 0 && !cells.at(-1)?.trim()) { + cells.pop(); + } + if (count) { + if (cells.length > count) { + cells.splice(count); + } + else { + while (cells.length < count) + cells.push(''); + } + } + for (; i < cells.length; i++) { + // leading or trailing whitespace is ignored per the gfm spec + cells[i] = cells[i].trim().replace(other.slashPipe, '|'); + } + return cells; +} +/** + * Remove trailing 'c's. Equivalent to str.replace(/c*$/, ''). + * /c*$/ is vulnerable to REDOS. + * + * @param str + * @param c + * @param invert Remove suffix of non-c chars instead. Default falsey. + */ +function rtrim(str, c, invert) { + const l = str.length; + if (l === 0) { + return ''; + } + // Length of suffix matching the invert condition. + let suffLen = 0; + // Step left until we fail to match the invert condition. + while (suffLen < l) { + const currChar = str.charAt(l - suffLen - 1); + if (currChar === c && true) { + suffLen++; + } + else { + break; + } + } + return str.slice(0, l - suffLen); +} +function findClosingBracket(str, b) { + if (str.indexOf(b[1]) === -1) { + return -1; + } + let level = 0; + for (let i = 0; i < str.length; i++) { + if (str[i] === '\\') { + i++; + } + else if (str[i] === b[0]) { + level++; + } + else if (str[i] === b[1]) { + level--; + if (level < 0) { + return i; + } + } + } + return -1; +} + +function outputLink(cap, link, raw, lexer, rules) { + const href = link.href; + const title = link.title || null; + const text = cap[1].replace(rules.other.outputLinkReplace, '$1'); + if (cap[0].charAt(0) !== '!') { + lexer.state.inLink = true; + const token = { + type: 'link', + raw, + href, + title, + text, + tokens: lexer.inlineTokens(text), + }; + lexer.state.inLink = false; + return token; + } + return { + type: 'image', + raw, + href, + title, + text, + }; +} +function indentCodeCompensation(raw, text, rules) { + const matchIndentToCode = raw.match(rules.other.indentCodeCompensation); + if (matchIndentToCode === null) { + return text; + } + const indentToCode = matchIndentToCode[1]; + return text + .split('\n') + .map(node => { + const matchIndentInNode = node.match(rules.other.beginningSpace); + if (matchIndentInNode === null) { + return node; + } + const [indentInNode] = matchIndentInNode; + if (indentInNode.length >= indentToCode.length) { + return node.slice(indentToCode.length); + } + return node; + }) + .join('\n'); +} +/** + * Tokenizer + */ +class _Tokenizer { + options; + rules; // set by the lexer + lexer; // set by the lexer + constructor(options) { + this.options = options || _defaults; + } + space(src) { + const cap = this.rules.block.newline.exec(src); + if (cap && cap[0].length > 0) { + return { + type: 'space', + raw: cap[0], + }; + } + } + code(src) { + const cap = this.rules.block.code.exec(src); + if (cap) { + const text = cap[0].replace(this.rules.other.codeRemoveIndent, ''); + return { + type: 'code', + raw: cap[0], + codeBlockStyle: 'indented', + text: !this.options.pedantic + ? rtrim(text, '\n') + : text, + }; + } + } + fences(src) { + const cap = this.rules.block.fences.exec(src); + if (cap) { + const raw = cap[0]; + const text = indentCodeCompensation(raw, cap[3] || '', this.rules); + return { + type: 'code', + raw, + lang: cap[2] ? cap[2].trim().replace(this.rules.inline.anyPunctuation, '$1') : cap[2], + text, + }; + } + } + heading(src) { + const cap = this.rules.block.heading.exec(src); + if (cap) { + let text = cap[2].trim(); + // remove trailing #s + if (this.rules.other.endingHash.test(text)) { + const trimmed = rtrim(text, '#'); + if (this.options.pedantic) { + text = trimmed.trim(); + } + else if (!trimmed || this.rules.other.endingSpaceChar.test(trimmed)) { + // CommonMark requires space before trailing #s + text = trimmed.trim(); + } + } + return { + type: 'heading', + raw: cap[0], + depth: cap[1].length, + text, + tokens: this.lexer.inline(text), + }; + } + } + hr(src) { + const cap = this.rules.block.hr.exec(src); + if (cap) { + return { + type: 'hr', + raw: rtrim(cap[0], '\n'), + }; + } + } + blockquote(src) { + const cap = this.rules.block.blockquote.exec(src); + if (cap) { + let lines = rtrim(cap[0], '\n').split('\n'); + let raw = ''; + let text = ''; + const tokens = []; + while (lines.length > 0) { + let inBlockquote = false; + const currentLines = []; + let i; + for (i = 0; i < lines.length; i++) { + // get lines up to a continuation + if (this.rules.other.blockquoteStart.test(lines[i])) { + currentLines.push(lines[i]); + inBlockquote = true; + } + else if (!inBlockquote) { + currentLines.push(lines[i]); + } + else { + break; + } + } + lines = lines.slice(i); + const currentRaw = currentLines.join('\n'); + const currentText = currentRaw + // precede setext continuation with 4 spaces so it isn't a setext + .replace(this.rules.other.blockquoteSetextReplace, '\n $1') + .replace(this.rules.other.blockquoteSetextReplace2, ''); + raw = raw ? `${raw}\n${currentRaw}` : currentRaw; + text = text ? `${text}\n${currentText}` : currentText; + // parse blockquote lines as top level tokens + // merge paragraphs if this is a continuation + const top = this.lexer.state.top; + this.lexer.state.top = true; + this.lexer.blockTokens(currentText, tokens, true); + this.lexer.state.top = top; + // if there is no continuation then we are done + if (lines.length === 0) { + break; + } + const lastToken = tokens.at(-1); + if (lastToken?.type === 'code') { + // blockquote continuation cannot be preceded by a code block + break; + } + else if (lastToken?.type === 'blockquote') { + // include continuation in nested blockquote + const oldToken = lastToken; + const newText = oldToken.raw + '\n' + lines.join('\n'); + const newToken = this.blockquote(newText); + tokens[tokens.length - 1] = newToken; + raw = raw.substring(0, raw.length - oldToken.raw.length) + newToken.raw; + text = text.substring(0, text.length - oldToken.text.length) + newToken.text; + break; + } + else if (lastToken?.type === 'list') { + // include continuation in nested list + const oldToken = lastToken; + const newText = oldToken.raw + '\n' + lines.join('\n'); + const newToken = this.list(newText); + tokens[tokens.length - 1] = newToken; + raw = raw.substring(0, raw.length - lastToken.raw.length) + newToken.raw; + text = text.substring(0, text.length - oldToken.raw.length) + newToken.raw; + lines = newText.substring(tokens.at(-1).raw.length).split('\n'); + continue; + } + } + return { + type: 'blockquote', + raw, + tokens, + text, + }; + } + } + list(src) { + let cap = this.rules.block.list.exec(src); + if (cap) { + let bull = cap[1].trim(); + const isordered = bull.length > 1; + const list = { + type: 'list', + raw: '', + ordered: isordered, + start: isordered ? +bull.slice(0, -1) : '', + loose: false, + items: [], + }; + bull = isordered ? `\\d{1,9}\\${bull.slice(-1)}` : `\\${bull}`; + if (this.options.pedantic) { + bull = isordered ? bull : '[*+-]'; + } + // Get next list item + const itemRegex = this.rules.other.listItemRegex(bull); + let endsWithBlankLine = false; + // Check if current bullet point can start a new List Item + while (src) { + let endEarly = false; + let raw = ''; + let itemContents = ''; + if (!(cap = itemRegex.exec(src))) { + break; + } + if (this.rules.block.hr.test(src)) { // End list if bullet was actually HR (possibly move into itemRegex?) + break; + } + raw = cap[0]; + src = src.substring(raw.length); + let line = cap[2].split('\n', 1)[0].replace(this.rules.other.listReplaceTabs, (t) => ' '.repeat(3 * t.length)); + let nextLine = src.split('\n', 1)[0]; + let blankLine = !line.trim(); + let indent = 0; + if (this.options.pedantic) { + indent = 2; + itemContents = line.trimStart(); + } + else if (blankLine) { + indent = cap[1].length + 1; + } + else { + indent = cap[2].search(this.rules.other.nonSpaceChar); // Find first non-space char + indent = indent > 4 ? 1 : indent; // Treat indented code blocks (> 4 spaces) as having only 1 indent + itemContents = line.slice(indent); + indent += cap[1].length; + } + if (blankLine && this.rules.other.blankLine.test(nextLine)) { // Items begin with at most one blank line + raw += nextLine + '\n'; + src = src.substring(nextLine.length + 1); + endEarly = true; + } + if (!endEarly) { + const nextBulletRegex = this.rules.other.nextBulletRegex(indent); + const hrRegex = this.rules.other.hrRegex(indent); + const fencesBeginRegex = this.rules.other.fencesBeginRegex(indent); + const headingBeginRegex = this.rules.other.headingBeginRegex(indent); + const htmlBeginRegex = this.rules.other.htmlBeginRegex(indent); + // Check if following lines should be included in List Item + while (src) { + const rawLine = src.split('\n', 1)[0]; + let nextLineWithoutTabs; + nextLine = rawLine; + // Re-align to follow commonmark nesting rules + if (this.options.pedantic) { + nextLine = nextLine.replace(this.rules.other.listReplaceNesting, ' '); + nextLineWithoutTabs = nextLine; + } + else { + nextLineWithoutTabs = nextLine.replace(this.rules.other.tabCharGlobal, ' '); + } + // End list item if found code fences + if (fencesBeginRegex.test(nextLine)) { + break; + } + // End list item if found start of new heading + if (headingBeginRegex.test(nextLine)) { + break; + } + // End list item if found start of html block + if (htmlBeginRegex.test(nextLine)) { + break; + } + // End list item if found start of new bullet + if (nextBulletRegex.test(nextLine)) { + break; + } + // Horizontal rule found + if (hrRegex.test(nextLine)) { + break; + } + if (nextLineWithoutTabs.search(this.rules.other.nonSpaceChar) >= indent || !nextLine.trim()) { // Dedent if possible + itemContents += '\n' + nextLineWithoutTabs.slice(indent); + } + else { + // not enough indentation + if (blankLine) { + break; + } + // paragraph continuation unless last line was a different block level element + if (line.replace(this.rules.other.tabCharGlobal, ' ').search(this.rules.other.nonSpaceChar) >= 4) { // indented code block + break; + } + if (fencesBeginRegex.test(line)) { + break; + } + if (headingBeginRegex.test(line)) { + break; + } + if (hrRegex.test(line)) { + break; + } + itemContents += '\n' + nextLine; + } + if (!blankLine && !nextLine.trim()) { // Check if current line is blank + blankLine = true; + } + raw += rawLine + '\n'; + src = src.substring(rawLine.length + 1); + line = nextLineWithoutTabs.slice(indent); + } + } + if (!list.loose) { + // If the previous item ended with a blank line, the list is loose + if (endsWithBlankLine) { + list.loose = true; + } + else if (this.rules.other.doubleBlankLine.test(raw)) { + endsWithBlankLine = true; + } + } + let istask = null; + let ischecked; + // Check for task list items + if (this.options.gfm) { + istask = this.rules.other.listIsTask.exec(itemContents); + if (istask) { + ischecked = istask[0] !== '[ ] '; + itemContents = itemContents.replace(this.rules.other.listReplaceTask, ''); + } + } + list.items.push({ + type: 'list_item', + raw, + task: !!istask, + checked: ischecked, + loose: false, + text: itemContents, + tokens: [], + }); + list.raw += raw; + } + // Do not consume newlines at end of final item. Alternatively, make itemRegex *start* with any newlines to simplify/speed up endsWithBlankLine logic + const lastItem = list.items.at(-1); + if (lastItem) { + lastItem.raw = lastItem.raw.trimEnd(); + lastItem.text = lastItem.text.trimEnd(); + } + else { + // not a list since there were no items + return; + } + list.raw = list.raw.trimEnd(); + // Item child tokens handled here at end because we needed to have the final item to trim it first + for (let i = 0; i < list.items.length; i++) { + this.lexer.state.top = false; + list.items[i].tokens = this.lexer.blockTokens(list.items[i].text, []); + if (!list.loose) { + // Check if list should be loose + const spacers = list.items[i].tokens.filter(t => t.type === 'space'); + const hasMultipleLineBreaks = spacers.length > 0 && spacers.some(t => this.rules.other.anyLine.test(t.raw)); + list.loose = hasMultipleLineBreaks; + } + } + // Set all items to loose if list is loose + if (list.loose) { + for (let i = 0; i < list.items.length; i++) { + list.items[i].loose = true; + } + } + return list; + } + } + html(src) { + const cap = this.rules.block.html.exec(src); + if (cap) { + const token = { + type: 'html', + block: true, + raw: cap[0], + pre: cap[1] === 'pre' || cap[1] === 'script' || cap[1] === 'style', + text: cap[0], + }; + return token; + } + } + def(src) { + const cap = this.rules.block.def.exec(src); + if (cap) { + const tag = cap[1].toLowerCase().replace(this.rules.other.multipleSpaceGlobal, ' '); + const href = cap[2] ? cap[2].replace(this.rules.other.hrefBrackets, '$1').replace(this.rules.inline.anyPunctuation, '$1') : ''; + const title = cap[3] ? cap[3].substring(1, cap[3].length - 1).replace(this.rules.inline.anyPunctuation, '$1') : cap[3]; + return { + type: 'def', + tag, + raw: cap[0], + href, + title, + }; + } + } + table(src) { + const cap = this.rules.block.table.exec(src); + if (!cap) { + return; + } + if (!this.rules.other.tableDelimiter.test(cap[2])) { + // delimiter row must have a pipe (|) or colon (:) otherwise it is a setext heading + return; + } + const headers = splitCells(cap[1]); + const aligns = cap[2].replace(this.rules.other.tableAlignChars, '').split('|'); + const rows = cap[3]?.trim() ? cap[3].replace(this.rules.other.tableRowBlankLine, '').split('\n') : []; + const item = { + type: 'table', + raw: cap[0], + header: [], + align: [], + rows: [], + }; + if (headers.length !== aligns.length) { + // header and align columns must be equal, rows can be different. + return; + } + for (const align of aligns) { + if (this.rules.other.tableAlignRight.test(align)) { + item.align.push('right'); + } + else if (this.rules.other.tableAlignCenter.test(align)) { + item.align.push('center'); + } + else if (this.rules.other.tableAlignLeft.test(align)) { + item.align.push('left'); + } + else { + item.align.push(null); + } + } + for (let i = 0; i < headers.length; i++) { + item.header.push({ + text: headers[i], + tokens: this.lexer.inline(headers[i]), + header: true, + align: item.align[i], + }); + } + for (const row of rows) { + item.rows.push(splitCells(row, item.header.length).map((cell, i) => { + return { + text: cell, + tokens: this.lexer.inline(cell), + header: false, + align: item.align[i], + }; + })); + } + return item; + } + lheading(src) { + const cap = this.rules.block.lheading.exec(src); + if (cap) { + return { + type: 'heading', + raw: cap[0], + depth: cap[2].charAt(0) === '=' ? 1 : 2, + text: cap[1], + tokens: this.lexer.inline(cap[1]), + }; + } + } + paragraph(src) { + const cap = this.rules.block.paragraph.exec(src); + if (cap) { + const text = cap[1].charAt(cap[1].length - 1) === '\n' + ? cap[1].slice(0, -1) + : cap[1]; + return { + type: 'paragraph', + raw: cap[0], + text, + tokens: this.lexer.inline(text), + }; + } + } + text(src) { + const cap = this.rules.block.text.exec(src); + if (cap) { + return { + type: 'text', + raw: cap[0], + text: cap[0], + tokens: this.lexer.inline(cap[0]), + }; + } + } + escape(src) { + const cap = this.rules.inline.escape.exec(src); + if (cap) { + return { + type: 'escape', + raw: cap[0], + text: cap[1], + }; + } + } + tag(src) { + const cap = this.rules.inline.tag.exec(src); + if (cap) { + if (!this.lexer.state.inLink && this.rules.other.startATag.test(cap[0])) { + this.lexer.state.inLink = true; + } + else if (this.lexer.state.inLink && this.rules.other.endATag.test(cap[0])) { + this.lexer.state.inLink = false; + } + if (!this.lexer.state.inRawBlock && this.rules.other.startPreScriptTag.test(cap[0])) { + this.lexer.state.inRawBlock = true; + } + else if (this.lexer.state.inRawBlock && this.rules.other.endPreScriptTag.test(cap[0])) { + this.lexer.state.inRawBlock = false; + } + return { + type: 'html', + raw: cap[0], + inLink: this.lexer.state.inLink, + inRawBlock: this.lexer.state.inRawBlock, + block: false, + text: cap[0], + }; + } + } + link(src) { + const cap = this.rules.inline.link.exec(src); + if (cap) { + const trimmedUrl = cap[2].trim(); + if (!this.options.pedantic && this.rules.other.startAngleBracket.test(trimmedUrl)) { + // commonmark requires matching angle brackets + if (!(this.rules.other.endAngleBracket.test(trimmedUrl))) { + return; + } + // ending angle bracket cannot be escaped + const rtrimSlash = rtrim(trimmedUrl.slice(0, -1), '\\'); + if ((trimmedUrl.length - rtrimSlash.length) % 2 === 0) { + return; + } + } + else { + // find closing parenthesis + const lastParenIndex = findClosingBracket(cap[2], '()'); + if (lastParenIndex > -1) { + const start = cap[0].indexOf('!') === 0 ? 5 : 4; + const linkLen = start + cap[1].length + lastParenIndex; + cap[2] = cap[2].substring(0, lastParenIndex); + cap[0] = cap[0].substring(0, linkLen).trim(); + cap[3] = ''; + } + } + let href = cap[2]; + let title = ''; + if (this.options.pedantic) { + // split pedantic href and title + const link = this.rules.other.pedanticHrefTitle.exec(href); + if (link) { + href = link[1]; + title = link[3]; + } + } + else { + title = cap[3] ? cap[3].slice(1, -1) : ''; + } + href = href.trim(); + if (this.rules.other.startAngleBracket.test(href)) { + if (this.options.pedantic && !(this.rules.other.endAngleBracket.test(trimmedUrl))) { + // pedantic allows starting angle bracket without ending angle bracket + href = href.slice(1); + } + else { + href = href.slice(1, -1); + } + } + return outputLink(cap, { + href: href ? href.replace(this.rules.inline.anyPunctuation, '$1') : href, + title: title ? title.replace(this.rules.inline.anyPunctuation, '$1') : title, + }, cap[0], this.lexer, this.rules); + } + } + reflink(src, links) { + let cap; + if ((cap = this.rules.inline.reflink.exec(src)) + || (cap = this.rules.inline.nolink.exec(src))) { + const linkString = (cap[2] || cap[1]).replace(this.rules.other.multipleSpaceGlobal, ' '); + const link = links[linkString.toLowerCase()]; + if (!link) { + const text = cap[0].charAt(0); + return { + type: 'text', + raw: text, + text, + }; + } + return outputLink(cap, link, cap[0], this.lexer, this.rules); + } + } + emStrong(src, maskedSrc, prevChar = '') { + let match = this.rules.inline.emStrongLDelim.exec(src); + if (!match) + return; + // _ can't be between two alphanumerics. \p{L}\p{N} includes non-english alphabet/numbers as well + if (match[3] && prevChar.match(this.rules.other.unicodeAlphaNumeric)) + return; + const nextChar = match[1] || match[2] || ''; + if (!nextChar || !prevChar || this.rules.inline.punctuation.exec(prevChar)) { + // unicode Regex counts emoji as 1 char; spread into array for proper count (used multiple times below) + const lLength = [...match[0]].length - 1; + let rDelim, rLength, delimTotal = lLength, midDelimTotal = 0; + const endReg = match[0][0] === '*' ? this.rules.inline.emStrongRDelimAst : this.rules.inline.emStrongRDelimUnd; + endReg.lastIndex = 0; + // Clip maskedSrc to same section of string as src (move to lexer?) + maskedSrc = maskedSrc.slice(-1 * src.length + lLength); + while ((match = endReg.exec(maskedSrc)) != null) { + rDelim = match[1] || match[2] || match[3] || match[4] || match[5] || match[6]; + if (!rDelim) + continue; // skip single * in __abc*abc__ + rLength = [...rDelim].length; + if (match[3] || match[4]) { // found another Left Delim + delimTotal += rLength; + continue; + } + else if (match[5] || match[6]) { // either Left or Right Delim + if (lLength % 3 && !((lLength + rLength) % 3)) { + midDelimTotal += rLength; + continue; // CommonMark Emphasis Rules 9-10 + } + } + delimTotal -= rLength; + if (delimTotal > 0) + continue; // Haven't found enough closing delimiters + // Remove extra characters. *a*** -> *a* + rLength = Math.min(rLength, rLength + delimTotal + midDelimTotal); + // char length can be >1 for unicode characters; + const lastCharLength = [...match[0]][0].length; + const raw = src.slice(0, lLength + match.index + lastCharLength + rLength); + // Create `em` if smallest delimiter has odd char count. *a*** + if (Math.min(lLength, rLength) % 2) { + const text = raw.slice(1, -1); + return { + type: 'em', + raw, + text, + tokens: this.lexer.inlineTokens(text), + }; + } + // Create 'strong' if smallest delimiter has even char count. **a*** + const text = raw.slice(2, -2); + return { + type: 'strong', + raw, + text, + tokens: this.lexer.inlineTokens(text), + }; + } + } + } + codespan(src) { + const cap = this.rules.inline.code.exec(src); + if (cap) { + let text = cap[2].replace(this.rules.other.newLineCharGlobal, ' '); + const hasNonSpaceChars = this.rules.other.nonSpaceChar.test(text); + const hasSpaceCharsOnBothEnds = this.rules.other.startingSpaceChar.test(text) && this.rules.other.endingSpaceChar.test(text); + if (hasNonSpaceChars && hasSpaceCharsOnBothEnds) { + text = text.substring(1, text.length - 1); + } + return { + type: 'codespan', + raw: cap[0], + text, + }; + } + } + br(src) { + const cap = this.rules.inline.br.exec(src); + if (cap) { + return { + type: 'br', + raw: cap[0], + }; + } + } + del(src) { + const cap = this.rules.inline.del.exec(src); + if (cap) { + return { + type: 'del', + raw: cap[0], + text: cap[2], + tokens: this.lexer.inlineTokens(cap[2]), + }; + } + } + autolink(src) { + const cap = this.rules.inline.autolink.exec(src); + if (cap) { + let text, href; + if (cap[2] === '@') { + text = cap[1]; + href = 'mailto:' + text; + } + else { + text = cap[1]; + href = text; + } + return { + type: 'link', + raw: cap[0], + text, + href, + tokens: [ + { + type: 'text', + raw: text, + text, + }, + ], + }; + } + } + url(src) { + let cap; + if (cap = this.rules.inline.url.exec(src)) { + let text, href; + if (cap[2] === '@') { + text = cap[0]; + href = 'mailto:' + text; + } + else { + // do extended autolink path validation + let prevCapZero; + do { + prevCapZero = cap[0]; + cap[0] = this.rules.inline._backpedal.exec(cap[0])?.[0] ?? ''; + } while (prevCapZero !== cap[0]); + text = cap[0]; + if (cap[1] === 'www.') { + href = 'http://' + cap[0]; + } + else { + href = cap[0]; + } + } + return { + type: 'link', + raw: cap[0], + text, + href, + tokens: [ + { + type: 'text', + raw: text, + text, + }, + ], + }; + } + } + inlineText(src) { + const cap = this.rules.inline.text.exec(src); + if (cap) { + const escaped = this.lexer.state.inRawBlock; + return { + type: 'text', + raw: cap[0], + text: cap[0], + escaped, + }; + } + } +} + +/** + * Block Lexer + */ +class _Lexer { + tokens; + options; + state; + tokenizer; + inlineQueue; + constructor(options) { + // TokenList cannot be created in one go + this.tokens = []; + this.tokens.links = Object.create(null); + this.options = options || _defaults; + this.options.tokenizer = this.options.tokenizer || new _Tokenizer(); + this.tokenizer = this.options.tokenizer; + this.tokenizer.options = this.options; + this.tokenizer.lexer = this; + this.inlineQueue = []; + this.state = { + inLink: false, + inRawBlock: false, + top: true, + }; + const rules = { + other, + block: block.normal, + inline: inline.normal, + }; + if (this.options.pedantic) { + rules.block = block.pedantic; + rules.inline = inline.pedantic; + } + else if (this.options.gfm) { + rules.block = block.gfm; + if (this.options.breaks) { + rules.inline = inline.breaks; + } + else { + rules.inline = inline.gfm; + } + } + this.tokenizer.rules = rules; + } + /** + * Expose Rules + */ + static get rules() { + return { + block, + inline, + }; + } + /** + * Static Lex Method + */ + static lex(src, options) { + const lexer = new _Lexer(options); + return lexer.lex(src); + } + /** + * Static Lex Inline Method + */ + static lexInline(src, options) { + const lexer = new _Lexer(options); + return lexer.inlineTokens(src); + } + /** + * Preprocessing + */ + lex(src) { + src = src.replace(other.carriageReturn, '\n'); + this.blockTokens(src, this.tokens); + for (let i = 0; i < this.inlineQueue.length; i++) { + const next = this.inlineQueue[i]; + this.inlineTokens(next.src, next.tokens); + } + this.inlineQueue = []; + return this.tokens; + } + blockTokens(src, tokens = [], lastParagraphClipped = false) { + if (this.options.pedantic) { + src = src.replace(other.tabCharGlobal, ' ').replace(other.spaceLine, ''); + } + while (src) { + let token; + if (this.options.extensions?.block?.some((extTokenizer) => { + if (token = extTokenizer.call({ lexer: this }, src, tokens)) { + src = src.substring(token.raw.length); + tokens.push(token); + return true; + } + return false; + })) { + continue; + } + // newline + if (token = this.tokenizer.space(src)) { + src = src.substring(token.raw.length); + const lastToken = tokens.at(-1); + if (token.raw.length === 1 && lastToken !== undefined) { + // if there's a single \n as a spacer, it's terminating the last line, + // so move it there so that we don't get unnecessary paragraph tags + lastToken.raw += '\n'; + } + else { + tokens.push(token); + } + continue; + } + // code + if (token = this.tokenizer.code(src)) { + src = src.substring(token.raw.length); + const lastToken = tokens.at(-1); + // An indented code block cannot interrupt a paragraph. + if (lastToken?.type === 'paragraph' || lastToken?.type === 'text') { + lastToken.raw += '\n' + token.raw; + lastToken.text += '\n' + token.text; + this.inlineQueue.at(-1).src = lastToken.text; + } + else { + tokens.push(token); + } + continue; + } + // fences + if (token = this.tokenizer.fences(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // heading + if (token = this.tokenizer.heading(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // hr + if (token = this.tokenizer.hr(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // blockquote + if (token = this.tokenizer.blockquote(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // list + if (token = this.tokenizer.list(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // html + if (token = this.tokenizer.html(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // def + if (token = this.tokenizer.def(src)) { + src = src.substring(token.raw.length); + const lastToken = tokens.at(-1); + if (lastToken?.type === 'paragraph' || lastToken?.type === 'text') { + lastToken.raw += '\n' + token.raw; + lastToken.text += '\n' + token.raw; + this.inlineQueue.at(-1).src = lastToken.text; + } + else if (!this.tokens.links[token.tag]) { + this.tokens.links[token.tag] = { + href: token.href, + title: token.title, + }; + } + continue; + } + // table (gfm) + if (token = this.tokenizer.table(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // lheading + if (token = this.tokenizer.lheading(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // top-level paragraph + // prevent paragraph consuming extensions by clipping 'src' to extension start + let cutSrc = src; + if (this.options.extensions?.startBlock) { + let startIndex = Infinity; + const tempSrc = src.slice(1); + let tempStart; + this.options.extensions.startBlock.forEach((getStartIndex) => { + tempStart = getStartIndex.call({ lexer: this }, tempSrc); + if (typeof tempStart === 'number' && tempStart >= 0) { + startIndex = Math.min(startIndex, tempStart); + } + }); + if (startIndex < Infinity && startIndex >= 0) { + cutSrc = src.substring(0, startIndex + 1); + } + } + if (this.state.top && (token = this.tokenizer.paragraph(cutSrc))) { + const lastToken = tokens.at(-1); + if (lastParagraphClipped && lastToken?.type === 'paragraph') { + lastToken.raw += '\n' + token.raw; + lastToken.text += '\n' + token.text; + this.inlineQueue.pop(); + this.inlineQueue.at(-1).src = lastToken.text; + } + else { + tokens.push(token); + } + lastParagraphClipped = cutSrc.length !== src.length; + src = src.substring(token.raw.length); + continue; + } + // text + if (token = this.tokenizer.text(src)) { + src = src.substring(token.raw.length); + const lastToken = tokens.at(-1); + if (lastToken?.type === 'text') { + lastToken.raw += '\n' + token.raw; + lastToken.text += '\n' + token.text; + this.inlineQueue.pop(); + this.inlineQueue.at(-1).src = lastToken.text; + } + else { + tokens.push(token); + } + continue; + } + if (src) { + const errMsg = 'Infinite loop on byte: ' + src.charCodeAt(0); + if (this.options.silent) { + console.error(errMsg); + break; + } + else { + throw new Error(errMsg); + } + } + } + this.state.top = true; + return tokens; + } + inline(src, tokens = []) { + this.inlineQueue.push({ src, tokens }); + return tokens; + } + /** + * Lexing/Compiling + */ + inlineTokens(src, tokens = []) { + // String with links masked to avoid interference with em and strong + let maskedSrc = src; + let match = null; + // Mask out reflinks + if (this.tokens.links) { + const links = Object.keys(this.tokens.links); + if (links.length > 0) { + while ((match = this.tokenizer.rules.inline.reflinkSearch.exec(maskedSrc)) != null) { + if (links.includes(match[0].slice(match[0].lastIndexOf('[') + 1, -1))) { + maskedSrc = maskedSrc.slice(0, match.index) + + '[' + 'a'.repeat(match[0].length - 2) + ']' + + maskedSrc.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex); + } + } + } + } + // Mask out other blocks + while ((match = this.tokenizer.rules.inline.blockSkip.exec(maskedSrc)) != null) { + maskedSrc = maskedSrc.slice(0, match.index) + '[' + 'a'.repeat(match[0].length - 2) + ']' + maskedSrc.slice(this.tokenizer.rules.inline.blockSkip.lastIndex); + } + // Mask out escaped characters + while ((match = this.tokenizer.rules.inline.anyPunctuation.exec(maskedSrc)) != null) { + maskedSrc = maskedSrc.slice(0, match.index) + '++' + maskedSrc.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex); + } + let keepPrevChar = false; + let prevChar = ''; + while (src) { + if (!keepPrevChar) { + prevChar = ''; + } + keepPrevChar = false; + let token; + // extensions + if (this.options.extensions?.inline?.some((extTokenizer) => { + if (token = extTokenizer.call({ lexer: this }, src, tokens)) { + src = src.substring(token.raw.length); + tokens.push(token); + return true; + } + return false; + })) { + continue; + } + // escape + if (token = this.tokenizer.escape(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // tag + if (token = this.tokenizer.tag(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // link + if (token = this.tokenizer.link(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // reflink, nolink + if (token = this.tokenizer.reflink(src, this.tokens.links)) { + src = src.substring(token.raw.length); + const lastToken = tokens.at(-1); + if (token.type === 'text' && lastToken?.type === 'text') { + lastToken.raw += token.raw; + lastToken.text += token.text; + } + else { + tokens.push(token); + } + continue; + } + // em & strong + if (token = this.tokenizer.emStrong(src, maskedSrc, prevChar)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // code + if (token = this.tokenizer.codespan(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // br + if (token = this.tokenizer.br(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // del (gfm) + if (token = this.tokenizer.del(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // autolink + if (token = this.tokenizer.autolink(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // url (gfm) + if (!this.state.inLink && (token = this.tokenizer.url(src))) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // text + // prevent inlineText consuming extensions by clipping 'src' to extension start + let cutSrc = src; + if (this.options.extensions?.startInline) { + let startIndex = Infinity; + const tempSrc = src.slice(1); + let tempStart; + this.options.extensions.startInline.forEach((getStartIndex) => { + tempStart = getStartIndex.call({ lexer: this }, tempSrc); + if (typeof tempStart === 'number' && tempStart >= 0) { + startIndex = Math.min(startIndex, tempStart); + } + }); + if (startIndex < Infinity && startIndex >= 0) { + cutSrc = src.substring(0, startIndex + 1); + } + } + if (token = this.tokenizer.inlineText(cutSrc)) { + src = src.substring(token.raw.length); + if (token.raw.slice(-1) !== '_') { // Track prevChar before string of ____ started + prevChar = token.raw.slice(-1); + } + keepPrevChar = true; + const lastToken = tokens.at(-1); + if (lastToken?.type === 'text') { + lastToken.raw += token.raw; + lastToken.text += token.text; + } + else { + tokens.push(token); + } + continue; + } + if (src) { + const errMsg = 'Infinite loop on byte: ' + src.charCodeAt(0); + if (this.options.silent) { + console.error(errMsg); + break; + } + else { + throw new Error(errMsg); + } + } + } + return tokens; + } +} + +/** + * Renderer + */ +class _Renderer { + options; + parser; // set by the parser + constructor(options) { + this.options = options || _defaults; + } + space(token) { + return ''; + } + code({ text, lang, escaped }) { + const langString = (lang || '').match(other.notSpaceStart)?.[0]; + const code = text.replace(other.endingNewline, '') + '\n'; + if (!langString) { + return '
    '
    +                + (escaped ? code : escape(code, true))
    +                + '
    \n'; + } + return '
    '
    +            + (escaped ? code : escape(code, true))
    +            + '
    \n'; + } + blockquote({ tokens }) { + const body = this.parser.parse(tokens); + return `
    \n${body}
    \n`; + } + html({ text }) { + return text; + } + heading({ tokens, depth }) { + return `${this.parser.parseInline(tokens)}\n`; + } + hr(token) { + return '
    \n'; + } + list(token) { + const ordered = token.ordered; + const start = token.start; + let body = ''; + for (let j = 0; j < token.items.length; j++) { + const item = token.items[j]; + body += this.listitem(item); + } + const type = ordered ? 'ol' : 'ul'; + const startAttr = (ordered && start !== 1) ? (' start="' + start + '"') : ''; + return '<' + type + startAttr + '>\n' + body + '\n'; + } + listitem(item) { + let itemBody = ''; + if (item.task) { + const checkbox = this.checkbox({ checked: !!item.checked }); + if (item.loose) { + if (item.tokens[0]?.type === 'paragraph') { + item.tokens[0].text = checkbox + ' ' + item.tokens[0].text; + if (item.tokens[0].tokens && item.tokens[0].tokens.length > 0 && item.tokens[0].tokens[0].type === 'text') { + item.tokens[0].tokens[0].text = checkbox + ' ' + escape(item.tokens[0].tokens[0].text); + item.tokens[0].tokens[0].escaped = true; + } + } + else { + item.tokens.unshift({ + type: 'text', + raw: checkbox + ' ', + text: checkbox + ' ', + escaped: true, + }); + } + } + else { + itemBody += checkbox + ' '; + } + } + itemBody += this.parser.parse(item.tokens, !!item.loose); + return `
  • ${itemBody}
  • \n`; + } + checkbox({ checked }) { + return ''; + } + paragraph({ tokens }) { + return `

    ${this.parser.parseInline(tokens)}

    \n`; + } + table(token) { + let header = ''; + // header + let cell = ''; + for (let j = 0; j < token.header.length; j++) { + cell += this.tablecell(token.header[j]); + } + header += this.tablerow({ text: cell }); + let body = ''; + for (let j = 0; j < token.rows.length; j++) { + const row = token.rows[j]; + cell = ''; + for (let k = 0; k < row.length; k++) { + cell += this.tablecell(row[k]); + } + body += this.tablerow({ text: cell }); + } + if (body) + body = `${body}`; + return '\n' + + '\n' + + header + + '\n' + + body + + '
    \n'; + } + tablerow({ text }) { + return `\n${text}\n`; + } + tablecell(token) { + const content = this.parser.parseInline(token.tokens); + const type = token.header ? 'th' : 'td'; + const tag = token.align + ? `<${type} align="${token.align}">` + : `<${type}>`; + return tag + content + `\n`; + } + /** + * span level renderer + */ + strong({ tokens }) { + return `${this.parser.parseInline(tokens)}`; + } + em({ tokens }) { + return `${this.parser.parseInline(tokens)}`; + } + codespan({ text }) { + return `${escape(text, true)}`; + } + br(token) { + return '
    '; + } + del({ tokens }) { + return `${this.parser.parseInline(tokens)}`; + } + link({ href, title, tokens }) { + const text = this.parser.parseInline(tokens); + const cleanHref = cleanUrl(href); + if (cleanHref === null) { + return text; + } + href = cleanHref; + let out = '
    '; + return out; + } + image({ href, title, text }) { + const cleanHref = cleanUrl(href); + if (cleanHref === null) { + return escape(text); + } + href = cleanHref; + let out = `${text} { + const tokens = genericToken[childTokens].flat(Infinity); + values = values.concat(this.walkTokens(tokens, callback)); + }); + } + else if (genericToken.tokens) { + values = values.concat(this.walkTokens(genericToken.tokens, callback)); + } + } + } + } + return values; + } + use(...args) { + const extensions = this.defaults.extensions || { renderers: {}, childTokens: {} }; + args.forEach((pack) => { + // copy options to new object + const opts = { ...pack }; + // set async to true if it was set to true before + opts.async = this.defaults.async || opts.async || false; + // ==-- Parse "addon" extensions --== // + if (pack.extensions) { + pack.extensions.forEach((ext) => { + if (!ext.name) { + throw new Error('extension name required'); + } + if ('renderer' in ext) { // Renderer extensions + const prevRenderer = extensions.renderers[ext.name]; + if (prevRenderer) { + // Replace extension with func to run new extension but fall back if false + extensions.renderers[ext.name] = function (...args) { + let ret = ext.renderer.apply(this, args); + if (ret === false) { + ret = prevRenderer.apply(this, args); + } + return ret; + }; + } + else { + extensions.renderers[ext.name] = ext.renderer; + } + } + if ('tokenizer' in ext) { // Tokenizer Extensions + if (!ext.level || (ext.level !== 'block' && ext.level !== 'inline')) { + throw new Error("extension level must be 'block' or 'inline'"); + } + const extLevel = extensions[ext.level]; + if (extLevel) { + extLevel.unshift(ext.tokenizer); + } + else { + extensions[ext.level] = [ext.tokenizer]; + } + if (ext.start) { // Function to check for start of token + if (ext.level === 'block') { + if (extensions.startBlock) { + extensions.startBlock.push(ext.start); + } + else { + extensions.startBlock = [ext.start]; + } + } + else if (ext.level === 'inline') { + if (extensions.startInline) { + extensions.startInline.push(ext.start); + } + else { + extensions.startInline = [ext.start]; + } + } + } + } + if ('childTokens' in ext && ext.childTokens) { // Child tokens to be visited by walkTokens + extensions.childTokens[ext.name] = ext.childTokens; + } + }); + opts.extensions = extensions; + } + // ==-- Parse "overwrite" extensions --== // + if (pack.renderer) { + const renderer = this.defaults.renderer || new _Renderer(this.defaults); + for (const prop in pack.renderer) { + if (!(prop in renderer)) { + throw new Error(`renderer '${prop}' does not exist`); + } + if (['options', 'parser'].includes(prop)) { + // ignore options property + continue; + } + const rendererProp = prop; + const rendererFunc = pack.renderer[rendererProp]; + const prevRenderer = renderer[rendererProp]; + // Replace renderer with func to run extension, but fall back if false + renderer[rendererProp] = (...args) => { + let ret = rendererFunc.apply(renderer, args); + if (ret === false) { + ret = prevRenderer.apply(renderer, args); + } + return ret || ''; + }; + } + opts.renderer = renderer; + } + if (pack.tokenizer) { + const tokenizer = this.defaults.tokenizer || new _Tokenizer(this.defaults); + for (const prop in pack.tokenizer) { + if (!(prop in tokenizer)) { + throw new Error(`tokenizer '${prop}' does not exist`); + } + if (['options', 'rules', 'lexer'].includes(prop)) { + // ignore options, rules, and lexer properties + continue; + } + const tokenizerProp = prop; + const tokenizerFunc = pack.tokenizer[tokenizerProp]; + const prevTokenizer = tokenizer[tokenizerProp]; + // Replace tokenizer with func to run extension, but fall back if false + // @ts-expect-error cannot type tokenizer function dynamically + tokenizer[tokenizerProp] = (...args) => { + let ret = tokenizerFunc.apply(tokenizer, args); + if (ret === false) { + ret = prevTokenizer.apply(tokenizer, args); + } + return ret; + }; + } + opts.tokenizer = tokenizer; + } + // ==-- Parse Hooks extensions --== // + if (pack.hooks) { + const hooks = this.defaults.hooks || new _Hooks(); + for (const prop in pack.hooks) { + if (!(prop in hooks)) { + throw new Error(`hook '${prop}' does not exist`); + } + if (['options', 'block'].includes(prop)) { + // ignore options and block properties + continue; + } + const hooksProp = prop; + const hooksFunc = pack.hooks[hooksProp]; + const prevHook = hooks[hooksProp]; + if (_Hooks.passThroughHooks.has(prop)) { + // @ts-expect-error cannot type hook function dynamically + hooks[hooksProp] = (arg) => { + if (this.defaults.async) { + return Promise.resolve(hooksFunc.call(hooks, arg)).then(ret => { + return prevHook.call(hooks, ret); + }); + } + const ret = hooksFunc.call(hooks, arg); + return prevHook.call(hooks, ret); + }; + } + else { + // @ts-expect-error cannot type hook function dynamically + hooks[hooksProp] = (...args) => { + let ret = hooksFunc.apply(hooks, args); + if (ret === false) { + ret = prevHook.apply(hooks, args); + } + return ret; + }; + } + } + opts.hooks = hooks; + } + // ==-- Parse WalkTokens extensions --== // + if (pack.walkTokens) { + const walkTokens = this.defaults.walkTokens; + const packWalktokens = pack.walkTokens; + opts.walkTokens = function (token) { + let values = []; + values.push(packWalktokens.call(this, token)); + if (walkTokens) { + values = values.concat(walkTokens.call(this, token)); + } + return values; + }; + } + this.defaults = { ...this.defaults, ...opts }; + }); + return this; + } + setOptions(opt) { + this.defaults = { ...this.defaults, ...opt }; + return this; + } + lexer(src, options) { + return _Lexer.lex(src, options ?? this.defaults); + } + parser(tokens, options) { + return _Parser.parse(tokens, options ?? this.defaults); + } + parseMarkdown(blockType) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const parse = (src, options) => { + const origOpt = { ...options }; + const opt = { ...this.defaults, ...origOpt }; + const throwError = this.onError(!!opt.silent, !!opt.async); + // throw error if an extension set async to true but parse was called with async: false + if (this.defaults.async === true && origOpt.async === false) { + return throwError(new Error('marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise.')); + } + // throw error in case of non string input + if (typeof src === 'undefined' || src === null) { + return throwError(new Error('marked(): input parameter is undefined or null')); + } + if (typeof src !== 'string') { + return throwError(new Error('marked(): input parameter is of type ' + + Object.prototype.toString.call(src) + ', string expected')); + } + if (opt.hooks) { + opt.hooks.options = opt; + opt.hooks.block = blockType; + } + const lexer = opt.hooks ? opt.hooks.provideLexer() : (blockType ? _Lexer.lex : _Lexer.lexInline); + const parser = opt.hooks ? opt.hooks.provideParser() : (blockType ? _Parser.parse : _Parser.parseInline); + if (opt.async) { + return Promise.resolve(opt.hooks ? opt.hooks.preprocess(src) : src) + .then(src => lexer(src, opt)) + .then(tokens => opt.hooks ? opt.hooks.processAllTokens(tokens) : tokens) + .then(tokens => opt.walkTokens ? Promise.all(this.walkTokens(tokens, opt.walkTokens)).then(() => tokens) : tokens) + .then(tokens => parser(tokens, opt)) + .then(html => opt.hooks ? opt.hooks.postprocess(html) : html) + .catch(throwError); + } + try { + if (opt.hooks) { + src = opt.hooks.preprocess(src); + } + let tokens = lexer(src, opt); + if (opt.hooks) { + tokens = opt.hooks.processAllTokens(tokens); + } + if (opt.walkTokens) { + this.walkTokens(tokens, opt.walkTokens); + } + let html = parser(tokens, opt); + if (opt.hooks) { + html = opt.hooks.postprocess(html); + } + return html; + } + catch (e) { + return throwError(e); + } + }; + return parse; + } + onError(silent, async) { + return (e) => { + e.message += '\nPlease report this to https://github.com/markedjs/marked.'; + if (silent) { + const msg = '

    An error occurred:

    '
    +                    + escape(e.message + '', true)
    +                    + '
    '; + if (async) { + return Promise.resolve(msg); + } + return msg; + } + if (async) { + return Promise.reject(e); + } + throw e; + }; + } +} + +const markedInstance = new Marked(); +function marked(src, opt) { + return markedInstance.parse(src, opt); +} +/** + * Sets the default options. + * + * @param options Hash of options + */ +marked.options = + marked.setOptions = function (options) { + markedInstance.setOptions(options); + marked.defaults = markedInstance.defaults; + changeDefaults(marked.defaults); + return marked; + }; +/** + * Gets the original marked default options. + */ +marked.getDefaults = _getDefaults; +marked.defaults = _defaults; +/** + * Use Extension + */ +marked.use = function (...args) { + markedInstance.use(...args); + marked.defaults = markedInstance.defaults; + changeDefaults(marked.defaults); + return marked; +}; +/** + * Run callback for every token + */ +marked.walkTokens = function (tokens, callback) { + return markedInstance.walkTokens(tokens, callback); +}; +/** + * Compiles markdown to HTML without enclosing `p` tag. + * + * @param src String of markdown source to be compiled + * @param options Hash of options + * @return String of compiled HTML + */ +marked.parseInline = markedInstance.parseInline; +/** + * Expose + */ +marked.Parser = _Parser; +marked.parser = _Parser.parse; +marked.Renderer = _Renderer; +marked.TextRenderer = _TextRenderer; +marked.Lexer = _Lexer; +marked.lexer = _Lexer.lex; +marked.Tokenizer = _Tokenizer; +marked.Hooks = _Hooks; +marked.parse = marked; +const options = marked.options; +const setOptions = marked.setOptions; +const use = marked.use; +const walkTokens = marked.walkTokens; +const parseInline = marked.parseInline; +const parse = marked; +const parser = _Parser.parse; +const lexer = _Lexer.lex; + +export { _Hooks as Hooks, _Lexer as Lexer, Marked, _Parser as Parser, _Renderer as Renderer, _TextRenderer as TextRenderer, _Tokenizer as Tokenizer, _defaults as defaults, _getDefaults as getDefaults, lexer, marked, options, parse, parseInline, parser, setOptions, use, walkTokens }; diff --git a/molodetz/static/vendor/purify.es.mjs b/molodetz/static/vendor/purify.es.mjs new file mode 100644 index 0000000..8f895ff --- /dev/null +++ b/molodetz/static/vendor/purify.es.mjs @@ -0,0 +1,1337 @@ +/*! @license DOMPurify 3.2.4 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.2.4/LICENSE */ + +const { + entries, + setPrototypeOf, + isFrozen, + getPrototypeOf, + getOwnPropertyDescriptor +} = Object; +let { + freeze, + seal, + create +} = Object; // eslint-disable-line import/no-mutable-exports +let { + apply, + construct +} = typeof Reflect !== 'undefined' && Reflect; +if (!freeze) { + freeze = function freeze(x) { + return x; + }; +} +if (!seal) { + seal = function seal(x) { + return x; + }; +} +if (!apply) { + apply = function apply(fun, thisValue, args) { + return fun.apply(thisValue, args); + }; +} +if (!construct) { + construct = function construct(Func, args) { + return new Func(...args); + }; +} +const arrayForEach = unapply(Array.prototype.forEach); +const arrayLastIndexOf = unapply(Array.prototype.lastIndexOf); +const arrayPop = unapply(Array.prototype.pop); +const arrayPush = unapply(Array.prototype.push); +const arraySplice = unapply(Array.prototype.splice); +const stringToLowerCase = unapply(String.prototype.toLowerCase); +const stringToString = unapply(String.prototype.toString); +const stringMatch = unapply(String.prototype.match); +const stringReplace = unapply(String.prototype.replace); +const stringIndexOf = unapply(String.prototype.indexOf); +const stringTrim = unapply(String.prototype.trim); +const objectHasOwnProperty = unapply(Object.prototype.hasOwnProperty); +const regExpTest = unapply(RegExp.prototype.test); +const typeErrorCreate = unconstruct(TypeError); +/** + * Creates a new function that calls the given function with a specified thisArg and arguments. + * + * @param func - The function to be wrapped and called. + * @returns A new function that calls the given function with a specified thisArg and arguments. + */ +function unapply(func) { + return function (thisArg) { + for (var _len = arguments.length, args = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) { + args[_key - 1] = arguments[_key]; + } + return apply(func, thisArg, args); + }; +} +/** + * Creates a new function that constructs an instance of the given constructor function with the provided arguments. + * + * @param func - The constructor function to be wrapped and called. + * @returns A new function that constructs an instance of the given constructor function with the provided arguments. + */ +function unconstruct(func) { + return function () { + for (var _len2 = arguments.length, args = new Array(_len2), _key2 = 0; _key2 < _len2; _key2++) { + args[_key2] = arguments[_key2]; + } + return construct(func, args); + }; +} +/** + * Add properties to a lookup table + * + * @param set - The set to which elements will be added. + * @param array - The array containing elements to be added to the set. + * @param transformCaseFunc - An optional function to transform the case of each element before adding to the set. + * @returns The modified set with added elements. + */ +function addToSet(set, array) { + let transformCaseFunc = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : stringToLowerCase; + if (setPrototypeOf) { + // Make 'in' and truthy checks like Boolean(set.constructor) + // independent of any properties defined on Object.prototype. + // Prevent prototype setters from intercepting set as a this value. + setPrototypeOf(set, null); + } + let l = array.length; + while (l--) { + let element = array[l]; + if (typeof element === 'string') { + const lcElement = transformCaseFunc(element); + if (lcElement !== element) { + // Config presets (e.g. tags.js, attrs.js) are immutable. + if (!isFrozen(array)) { + array[l] = lcElement; + } + element = lcElement; + } + } + set[element] = true; + } + return set; +} +/** + * Clean up an array to harden against CSPP + * + * @param array - The array to be cleaned. + * @returns The cleaned version of the array + */ +function cleanArray(array) { + for (let index = 0; index < array.length; index++) { + const isPropertyExist = objectHasOwnProperty(array, index); + if (!isPropertyExist) { + array[index] = null; + } + } + return array; +} +/** + * Shallow clone an object + * + * @param object - The object to be cloned. + * @returns A new object that copies the original. + */ +function clone(object) { + const newObject = create(null); + for (const [property, value] of entries(object)) { + const isPropertyExist = objectHasOwnProperty(object, property); + if (isPropertyExist) { + if (Array.isArray(value)) { + newObject[property] = cleanArray(value); + } else if (value && typeof value === 'object' && value.constructor === Object) { + newObject[property] = clone(value); + } else { + newObject[property] = value; + } + } + } + return newObject; +} +/** + * This method automatically checks if the prop is function or getter and behaves accordingly. + * + * @param object - The object to look up the getter function in its prototype chain. + * @param prop - The property name for which to find the getter function. + * @returns The getter function found in the prototype chain or a fallback function. + */ +function lookupGetter(object, prop) { + while (object !== null) { + const desc = getOwnPropertyDescriptor(object, prop); + if (desc) { + if (desc.get) { + return unapply(desc.get); + } + if (typeof desc.value === 'function') { + return unapply(desc.value); + } + } + object = getPrototypeOf(object); + } + function fallbackValue() { + return null; + } + return fallbackValue; +} + +const html$1 = freeze(['a', 'abbr', 'acronym', 'address', 'area', 'article', 'aside', 'audio', 'b', 'bdi', 'bdo', 'big', 'blink', 'blockquote', 'body', 'br', 'button', 'canvas', 'caption', 'center', 'cite', 'code', 'col', 'colgroup', 'content', 'data', 'datalist', 'dd', 'decorator', 'del', 'details', 'dfn', 'dialog', 'dir', 'div', 'dl', 'dt', 'element', 'em', 'fieldset', 'figcaption', 'figure', 'font', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'hgroup', 'hr', 'html', 'i', 'img', 'input', 'ins', 'kbd', 'label', 'legend', 'li', 'main', 'map', 'mark', 'marquee', 'menu', 'menuitem', 'meter', 'nav', 'nobr', 'ol', 'optgroup', 'option', 'output', 'p', 'picture', 'pre', 'progress', 'q', 'rp', 'rt', 'ruby', 's', 'samp', 'section', 'select', 'shadow', 'small', 'source', 'spacer', 'span', 'strike', 'strong', 'style', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'template', 'textarea', 'tfoot', 'th', 'thead', 'time', 'tr', 'track', 'tt', 'u', 'ul', 'var', 'video', 'wbr']); +const svg$1 = freeze(['svg', 'a', 'altglyph', 'altglyphdef', 'altglyphitem', 'animatecolor', 'animatemotion', 'animatetransform', 'circle', 'clippath', 'defs', 'desc', 'ellipse', 'filter', 'font', 'g', 'glyph', 'glyphref', 'hkern', 'image', 'line', 'lineargradient', 'marker', 'mask', 'metadata', 'mpath', 'path', 'pattern', 'polygon', 'polyline', 'radialgradient', 'rect', 'stop', 'style', 'switch', 'symbol', 'text', 'textpath', 'title', 'tref', 'tspan', 'view', 'vkern']); +const svgFilters = freeze(['feBlend', 'feColorMatrix', 'feComponentTransfer', 'feComposite', 'feConvolveMatrix', 'feDiffuseLighting', 'feDisplacementMap', 'feDistantLight', 'feDropShadow', 'feFlood', 'feFuncA', 'feFuncB', 'feFuncG', 'feFuncR', 'feGaussianBlur', 'feImage', 'feMerge', 'feMergeNode', 'feMorphology', 'feOffset', 'fePointLight', 'feSpecularLighting', 'feSpotLight', 'feTile', 'feTurbulence']); +// List of SVG elements that are disallowed by default. +// We still need to know them so that we can do namespace +// checks properly in case one wants to add them to +// allow-list. +const svgDisallowed = freeze(['animate', 'color-profile', 'cursor', 'discard', 'font-face', 'font-face-format', 'font-face-name', 'font-face-src', 'font-face-uri', 'foreignobject', 'hatch', 'hatchpath', 'mesh', 'meshgradient', 'meshpatch', 'meshrow', 'missing-glyph', 'script', 'set', 'solidcolor', 'unknown', 'use']); +const mathMl$1 = freeze(['math', 'menclose', 'merror', 'mfenced', 'mfrac', 'mglyph', 'mi', 'mlabeledtr', 'mmultiscripts', 'mn', 'mo', 'mover', 'mpadded', 'mphantom', 'mroot', 'mrow', 'ms', 'mspace', 'msqrt', 'mstyle', 'msub', 'msup', 'msubsup', 'mtable', 'mtd', 'mtext', 'mtr', 'munder', 'munderover', 'mprescripts']); +// Similarly to SVG, we want to know all MathML elements, +// even those that we disallow by default. +const mathMlDisallowed = freeze(['maction', 'maligngroup', 'malignmark', 'mlongdiv', 'mscarries', 'mscarry', 'msgroup', 'mstack', 'msline', 'msrow', 'semantics', 'annotation', 'annotation-xml', 'mprescripts', 'none']); +const text = freeze(['#text']); + +const html = freeze(['accept', 'action', 'align', 'alt', 'autocapitalize', 'autocomplete', 'autopictureinpicture', 'autoplay', 'background', 'bgcolor', 'border', 'capture', 'cellpadding', 'cellspacing', 'checked', 'cite', 'class', 'clear', 'color', 'cols', 'colspan', 'controls', 'controlslist', 'coords', 'crossorigin', 'datetime', 'decoding', 'default', 'dir', 'disabled', 'disablepictureinpicture', 'disableremoteplayback', 'download', 'draggable', 'enctype', 'enterkeyhint', 'face', 'for', 'headers', 'height', 'hidden', 'high', 'href', 'hreflang', 'id', 'inputmode', 'integrity', 'ismap', 'kind', 'label', 'lang', 'list', 'loading', 'loop', 'low', 'max', 'maxlength', 'media', 'method', 'min', 'minlength', 'multiple', 'muted', 'name', 'nonce', 'noshade', 'novalidate', 'nowrap', 'open', 'optimum', 'pattern', 'placeholder', 'playsinline', 'popover', 'popovertarget', 'popovertargetaction', 'poster', 'preload', 'pubdate', 'radiogroup', 'readonly', 'rel', 'required', 'rev', 'reversed', 'role', 'rows', 'rowspan', 'spellcheck', 'scope', 'selected', 'shape', 'size', 'sizes', 'span', 'srclang', 'start', 'src', 'srcset', 'step', 'style', 'summary', 'tabindex', 'title', 'translate', 'type', 'usemap', 'valign', 'value', 'width', 'wrap', 'xmlns', 'slot']); +const svg = freeze(['accent-height', 'accumulate', 'additive', 'alignment-baseline', 'amplitude', 'ascent', 'attributename', 'attributetype', 'azimuth', 'basefrequency', 'baseline-shift', 'begin', 'bias', 'by', 'class', 'clip', 'clippathunits', 'clip-path', 'clip-rule', 'color', 'color-interpolation', 'color-interpolation-filters', 'color-profile', 'color-rendering', 'cx', 'cy', 'd', 'dx', 'dy', 'diffuseconstant', 'direction', 'display', 'divisor', 'dur', 'edgemode', 'elevation', 'end', 'exponent', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'filterunits', 'flood-color', 'flood-opacity', 'font-family', 'font-size', 'font-size-adjust', 'font-stretch', 'font-style', 'font-variant', 'font-weight', 'fx', 'fy', 'g1', 'g2', 'glyph-name', 'glyphref', 'gradientunits', 'gradienttransform', 'height', 'href', 'id', 'image-rendering', 'in', 'in2', 'intercept', 'k', 'k1', 'k2', 'k3', 'k4', 'kerning', 'keypoints', 'keysplines', 'keytimes', 'lang', 'lengthadjust', 'letter-spacing', 'kernelmatrix', 'kernelunitlength', 'lighting-color', 'local', 'marker-end', 'marker-mid', 'marker-start', 'markerheight', 'markerunits', 'markerwidth', 'maskcontentunits', 'maskunits', 'max', 'mask', 'media', 'method', 'mode', 'min', 'name', 'numoctaves', 'offset', 'operator', 'opacity', 'order', 'orient', 'orientation', 'origin', 'overflow', 'paint-order', 'path', 'pathlength', 'patterncontentunits', 'patterntransform', 'patternunits', 'points', 'preservealpha', 'preserveaspectratio', 'primitiveunits', 'r', 'rx', 'ry', 'radius', 'refx', 'refy', 'repeatcount', 'repeatdur', 'restart', 'result', 'rotate', 'scale', 'seed', 'shape-rendering', 'slope', 'specularconstant', 'specularexponent', 'spreadmethod', 'startoffset', 'stddeviation', 'stitchtiles', 'stop-color', 'stop-opacity', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke', 'stroke-width', 'style', 'surfacescale', 'systemlanguage', 'tabindex', 'tablevalues', 'targetx', 'targety', 'transform', 'transform-origin', 'text-anchor', 'text-decoration', 'text-rendering', 'textlength', 'type', 'u1', 'u2', 'unicode', 'values', 'viewbox', 'visibility', 'version', 'vert-adv-y', 'vert-origin-x', 'vert-origin-y', 'width', 'word-spacing', 'wrap', 'writing-mode', 'xchannelselector', 'ychannelselector', 'x', 'x1', 'x2', 'xmlns', 'y', 'y1', 'y2', 'z', 'zoomandpan']); +const mathMl = freeze(['accent', 'accentunder', 'align', 'bevelled', 'close', 'columnsalign', 'columnlines', 'columnspan', 'denomalign', 'depth', 'dir', 'display', 'displaystyle', 'encoding', 'fence', 'frame', 'height', 'href', 'id', 'largeop', 'length', 'linethickness', 'lspace', 'lquote', 'mathbackground', 'mathcolor', 'mathsize', 'mathvariant', 'maxsize', 'minsize', 'movablelimits', 'notation', 'numalign', 'open', 'rowalign', 'rowlines', 'rowspacing', 'rowspan', 'rspace', 'rquote', 'scriptlevel', 'scriptminsize', 'scriptsizemultiplier', 'selection', 'separator', 'separators', 'stretchy', 'subscriptshift', 'supscriptshift', 'symmetric', 'voffset', 'width', 'xmlns']); +const xml = freeze(['xlink:href', 'xml:id', 'xlink:title', 'xml:space', 'xmlns:xlink']); + +// eslint-disable-next-line unicorn/better-regex +const MUSTACHE_EXPR = seal(/\{\{[\w\W]*|[\w\W]*\}\}/gm); // Specify template detection regex for SAFE_FOR_TEMPLATES mode +const ERB_EXPR = seal(/<%[\w\W]*|[\w\W]*%>/gm); +const TMPLIT_EXPR = seal(/\$\{[\w\W]*/gm); // eslint-disable-line unicorn/better-regex +const DATA_ATTR = seal(/^data-[\-\w.\u00B7-\uFFFF]+$/); // eslint-disable-line no-useless-escape +const ARIA_ATTR = seal(/^aria-[\-\w]+$/); // eslint-disable-line no-useless-escape +const IS_ALLOWED_URI = seal(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i // eslint-disable-line no-useless-escape +); +const IS_SCRIPT_OR_DATA = seal(/^(?:\w+script|data):/i); +const ATTR_WHITESPACE = seal(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g // eslint-disable-line no-control-regex +); +const DOCTYPE_NAME = seal(/^html$/i); +const CUSTOM_ELEMENT = seal(/^[a-z][.\w]*(-[.\w]+)+$/i); + +var EXPRESSIONS = /*#__PURE__*/Object.freeze({ + __proto__: null, + ARIA_ATTR: ARIA_ATTR, + ATTR_WHITESPACE: ATTR_WHITESPACE, + CUSTOM_ELEMENT: CUSTOM_ELEMENT, + DATA_ATTR: DATA_ATTR, + DOCTYPE_NAME: DOCTYPE_NAME, + ERB_EXPR: ERB_EXPR, + IS_ALLOWED_URI: IS_ALLOWED_URI, + IS_SCRIPT_OR_DATA: IS_SCRIPT_OR_DATA, + MUSTACHE_EXPR: MUSTACHE_EXPR, + TMPLIT_EXPR: TMPLIT_EXPR +}); + +/* eslint-disable @typescript-eslint/indent */ +// https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType +const NODE_TYPE = { + element: 1, + attribute: 2, + text: 3, + cdataSection: 4, + entityReference: 5, + // Deprecated + entityNode: 6, + // Deprecated + progressingInstruction: 7, + comment: 8, + document: 9, + documentType: 10, + documentFragment: 11, + notation: 12 // Deprecated +}; +const getGlobal = function getGlobal() { + return typeof window === 'undefined' ? null : window; +}; +/** + * Creates a no-op policy for internal use only. + * Don't export this function outside this module! + * @param trustedTypes The policy factory. + * @param purifyHostElement The Script element used to load DOMPurify (to determine policy name suffix). + * @return The policy created (or null, if Trusted Types + * are not supported or creating the policy failed). + */ +const _createTrustedTypesPolicy = function _createTrustedTypesPolicy(trustedTypes, purifyHostElement) { + if (typeof trustedTypes !== 'object' || typeof trustedTypes.createPolicy !== 'function') { + return null; + } + // Allow the callers to control the unique policy name + // by adding a data-tt-policy-suffix to the script element with the DOMPurify. + // Policy creation with duplicate names throws in Trusted Types. + let suffix = null; + const ATTR_NAME = 'data-tt-policy-suffix'; + if (purifyHostElement && purifyHostElement.hasAttribute(ATTR_NAME)) { + suffix = purifyHostElement.getAttribute(ATTR_NAME); + } + const policyName = 'dompurify' + (suffix ? '#' + suffix : ''); + try { + return trustedTypes.createPolicy(policyName, { + createHTML(html) { + return html; + }, + createScriptURL(scriptUrl) { + return scriptUrl; + } + }); + } catch (_) { + // Policy creation failed (most likely another DOMPurify script has + // already run). Skip creating the policy, as this will only cause errors + // if TT are enforced. + console.warn('TrustedTypes policy ' + policyName + ' could not be created.'); + return null; + } +}; +const _createHooksMap = function _createHooksMap() { + return { + afterSanitizeAttributes: [], + afterSanitizeElements: [], + afterSanitizeShadowDOM: [], + beforeSanitizeAttributes: [], + beforeSanitizeElements: [], + beforeSanitizeShadowDOM: [], + uponSanitizeAttribute: [], + uponSanitizeElement: [], + uponSanitizeShadowNode: [] + }; +}; +function createDOMPurify() { + let window = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : getGlobal(); + const DOMPurify = root => createDOMPurify(root); + DOMPurify.version = '3.2.4'; + DOMPurify.removed = []; + if (!window || !window.document || window.document.nodeType !== NODE_TYPE.document || !window.Element) { + // Not running in a browser, provide a factory function + // so that you can pass your own Window + DOMPurify.isSupported = false; + return DOMPurify; + } + let { + document + } = window; + const originalDocument = document; + const currentScript = originalDocument.currentScript; + const { + DocumentFragment, + HTMLTemplateElement, + Node, + Element, + NodeFilter, + NamedNodeMap = window.NamedNodeMap || window.MozNamedAttrMap, + HTMLFormElement, + DOMParser, + trustedTypes + } = window; + const ElementPrototype = Element.prototype; + const cloneNode = lookupGetter(ElementPrototype, 'cloneNode'); + const remove = lookupGetter(ElementPrototype, 'remove'); + const getNextSibling = lookupGetter(ElementPrototype, 'nextSibling'); + const getChildNodes = lookupGetter(ElementPrototype, 'childNodes'); + const getParentNode = lookupGetter(ElementPrototype, 'parentNode'); + // As per issue #47, the web-components registry is inherited by a + // new document created via createHTMLDocument. As per the spec + // (http://w3c.github.io/webcomponents/spec/custom/#creating-and-passing-registries) + // a new empty registry is used when creating a template contents owner + // document, so we use that as our parent document to ensure nothing + // is inherited. + if (typeof HTMLTemplateElement === 'function') { + const template = document.createElement('template'); + if (template.content && template.content.ownerDocument) { + document = template.content.ownerDocument; + } + } + let trustedTypesPolicy; + let emptyHTML = ''; + const { + implementation, + createNodeIterator, + createDocumentFragment, + getElementsByTagName + } = document; + const { + importNode + } = originalDocument; + let hooks = _createHooksMap(); + /** + * Expose whether this browser supports running the full DOMPurify. + */ + DOMPurify.isSupported = typeof entries === 'function' && typeof getParentNode === 'function' && implementation && implementation.createHTMLDocument !== undefined; + const { + MUSTACHE_EXPR, + ERB_EXPR, + TMPLIT_EXPR, + DATA_ATTR, + ARIA_ATTR, + IS_SCRIPT_OR_DATA, + ATTR_WHITESPACE, + CUSTOM_ELEMENT + } = EXPRESSIONS; + let { + IS_ALLOWED_URI: IS_ALLOWED_URI$1 + } = EXPRESSIONS; + /** + * We consider the elements and attributes below to be safe. Ideally + * don't add any new ones but feel free to remove unwanted ones. + */ + /* allowed element names */ + let ALLOWED_TAGS = null; + const DEFAULT_ALLOWED_TAGS = addToSet({}, [...html$1, ...svg$1, ...svgFilters, ...mathMl$1, ...text]); + /* Allowed attribute names */ + let ALLOWED_ATTR = null; + const DEFAULT_ALLOWED_ATTR = addToSet({}, [...html, ...svg, ...mathMl, ...xml]); + /* + * Configure how DOMPurify should handle custom elements and their attributes as well as customized built-in elements. + * @property {RegExp|Function|null} tagNameCheck one of [null, regexPattern, predicate]. Default: `null` (disallow any custom elements) + * @property {RegExp|Function|null} attributeNameCheck one of [null, regexPattern, predicate]. Default: `null` (disallow any attributes not on the allow list) + * @property {boolean} allowCustomizedBuiltInElements allow custom elements derived from built-ins if they pass CUSTOM_ELEMENT_HANDLING.tagNameCheck. Default: `false`. + */ + let CUSTOM_ELEMENT_HANDLING = Object.seal(create(null, { + tagNameCheck: { + writable: true, + configurable: false, + enumerable: true, + value: null + }, + attributeNameCheck: { + writable: true, + configurable: false, + enumerable: true, + value: null + }, + allowCustomizedBuiltInElements: { + writable: true, + configurable: false, + enumerable: true, + value: false + } + })); + /* Explicitly forbidden tags (overrides ALLOWED_TAGS/ADD_TAGS) */ + let FORBID_TAGS = null; + /* Explicitly forbidden attributes (overrides ALLOWED_ATTR/ADD_ATTR) */ + let FORBID_ATTR = null; + /* Decide if ARIA attributes are okay */ + let ALLOW_ARIA_ATTR = true; + /* Decide if custom data attributes are okay */ + let ALLOW_DATA_ATTR = true; + /* Decide if unknown protocols are okay */ + let ALLOW_UNKNOWN_PROTOCOLS = false; + /* Decide if self-closing tags in attributes are allowed. + * Usually removed due to a mXSS issue in jQuery 3.0 */ + let ALLOW_SELF_CLOSE_IN_ATTR = true; + /* Output should be safe for common template engines. + * This means, DOMPurify removes data attributes, mustaches and ERB + */ + let SAFE_FOR_TEMPLATES = false; + /* Output should be safe even for XML used within HTML and alike. + * This means, DOMPurify removes comments when containing risky content. + */ + let SAFE_FOR_XML = true; + /* Decide if document with ... should be returned */ + let WHOLE_DOCUMENT = false; + /* Track whether config is already set on this instance of DOMPurify. */ + let SET_CONFIG = false; + /* Decide if all elements (e.g. style, script) must be children of + * document.body. By default, browsers might move them to document.head */ + let FORCE_BODY = false; + /* Decide if a DOM `HTMLBodyElement` should be returned, instead of a html + * string (or a TrustedHTML object if Trusted Types are supported). + * If `WHOLE_DOCUMENT` is enabled a `HTMLHtmlElement` will be returned instead + */ + let RETURN_DOM = false; + /* Decide if a DOM `DocumentFragment` should be returned, instead of a html + * string (or a TrustedHTML object if Trusted Types are supported) */ + let RETURN_DOM_FRAGMENT = false; + /* Try to return a Trusted Type object instead of a string, return a string in + * case Trusted Types are not supported */ + let RETURN_TRUSTED_TYPE = false; + /* Output should be free from DOM clobbering attacks? + * This sanitizes markups named with colliding, clobberable built-in DOM APIs. + */ + let SANITIZE_DOM = true; + /* Achieve full DOM Clobbering protection by isolating the namespace of named + * properties and JS variables, mitigating attacks that abuse the HTML/DOM spec rules. + * + * HTML/DOM spec rules that enable DOM Clobbering: + * - Named Access on Window (§7.3.3) + * - DOM Tree Accessors (§3.1.5) + * - Form Element Parent-Child Relations (§4.10.3) + * - Iframe srcdoc / Nested WindowProxies (§4.8.5) + * - HTMLCollection (§4.2.10.2) + * + * Namespace isolation is implemented by prefixing `id` and `name` attributes + * with a constant string, i.e., `user-content-` + */ + let SANITIZE_NAMED_PROPS = false; + const SANITIZE_NAMED_PROPS_PREFIX = 'user-content-'; + /* Keep element content when removing element? */ + let KEEP_CONTENT = true; + /* If a `Node` is passed to sanitize(), then performs sanitization in-place instead + * of importing it into a new Document and returning a sanitized copy */ + let IN_PLACE = false; + /* Allow usage of profiles like html, svg and mathMl */ + let USE_PROFILES = {}; + /* Tags to ignore content of when KEEP_CONTENT is true */ + let FORBID_CONTENTS = null; + const DEFAULT_FORBID_CONTENTS = addToSet({}, ['annotation-xml', 'audio', 'colgroup', 'desc', 'foreignobject', 'head', 'iframe', 'math', 'mi', 'mn', 'mo', 'ms', 'mtext', 'noembed', 'noframes', 'noscript', 'plaintext', 'script', 'style', 'svg', 'template', 'thead', 'title', 'video', 'xmp']); + /* Tags that are safe for data: URIs */ + let DATA_URI_TAGS = null; + const DEFAULT_DATA_URI_TAGS = addToSet({}, ['audio', 'video', 'img', 'source', 'image', 'track']); + /* Attributes safe for values like "javascript:" */ + let URI_SAFE_ATTRIBUTES = null; + const DEFAULT_URI_SAFE_ATTRIBUTES = addToSet({}, ['alt', 'class', 'for', 'id', 'label', 'name', 'pattern', 'placeholder', 'role', 'summary', 'title', 'value', 'style', 'xmlns']); + const MATHML_NAMESPACE = 'http://www.w3.org/1998/Math/MathML'; + const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'; + const HTML_NAMESPACE = 'http://www.w3.org/1999/xhtml'; + /* Document namespace */ + let NAMESPACE = HTML_NAMESPACE; + let IS_EMPTY_INPUT = false; + /* Allowed XHTML+XML namespaces */ + let ALLOWED_NAMESPACES = null; + const DEFAULT_ALLOWED_NAMESPACES = addToSet({}, [MATHML_NAMESPACE, SVG_NAMESPACE, HTML_NAMESPACE], stringToString); + let MATHML_TEXT_INTEGRATION_POINTS = addToSet({}, ['mi', 'mo', 'mn', 'ms', 'mtext']); + let HTML_INTEGRATION_POINTS = addToSet({}, ['annotation-xml']); + // Certain elements are allowed in both SVG and HTML + // namespace. We need to specify them explicitly + // so that they don't get erroneously deleted from + // HTML namespace. + const COMMON_SVG_AND_HTML_ELEMENTS = addToSet({}, ['title', 'style', 'font', 'a', 'script']); + /* Parsing of strict XHTML documents */ + let PARSER_MEDIA_TYPE = null; + const SUPPORTED_PARSER_MEDIA_TYPES = ['application/xhtml+xml', 'text/html']; + const DEFAULT_PARSER_MEDIA_TYPE = 'text/html'; + let transformCaseFunc = null; + /* Keep a reference to config to pass to hooks */ + let CONFIG = null; + /* Ideally, do not touch anything below this line */ + /* ______________________________________________ */ + const formElement = document.createElement('form'); + const isRegexOrFunction = function isRegexOrFunction(testValue) { + return testValue instanceof RegExp || testValue instanceof Function; + }; + /** + * _parseConfig + * + * @param cfg optional config literal + */ + // eslint-disable-next-line complexity + const _parseConfig = function _parseConfig() { + let cfg = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; + if (CONFIG && CONFIG === cfg) { + return; + } + /* Shield configuration object from tampering */ + if (!cfg || typeof cfg !== 'object') { + cfg = {}; + } + /* Shield configuration object from prototype pollution */ + cfg = clone(cfg); + PARSER_MEDIA_TYPE = + // eslint-disable-next-line unicorn/prefer-includes + SUPPORTED_PARSER_MEDIA_TYPES.indexOf(cfg.PARSER_MEDIA_TYPE) === -1 ? DEFAULT_PARSER_MEDIA_TYPE : cfg.PARSER_MEDIA_TYPE; + // HTML tags and attributes are not case-sensitive, converting to lowercase. Keeping XHTML as is. + transformCaseFunc = PARSER_MEDIA_TYPE === 'application/xhtml+xml' ? stringToString : stringToLowerCase; + /* Set configuration parameters */ + ALLOWED_TAGS = objectHasOwnProperty(cfg, 'ALLOWED_TAGS') ? addToSet({}, cfg.ALLOWED_TAGS, transformCaseFunc) : DEFAULT_ALLOWED_TAGS; + ALLOWED_ATTR = objectHasOwnProperty(cfg, 'ALLOWED_ATTR') ? addToSet({}, cfg.ALLOWED_ATTR, transformCaseFunc) : DEFAULT_ALLOWED_ATTR; + ALLOWED_NAMESPACES = objectHasOwnProperty(cfg, 'ALLOWED_NAMESPACES') ? addToSet({}, cfg.ALLOWED_NAMESPACES, stringToString) : DEFAULT_ALLOWED_NAMESPACES; + URI_SAFE_ATTRIBUTES = objectHasOwnProperty(cfg, 'ADD_URI_SAFE_ATTR') ? addToSet(clone(DEFAULT_URI_SAFE_ATTRIBUTES), cfg.ADD_URI_SAFE_ATTR, transformCaseFunc) : DEFAULT_URI_SAFE_ATTRIBUTES; + DATA_URI_TAGS = objectHasOwnProperty(cfg, 'ADD_DATA_URI_TAGS') ? addToSet(clone(DEFAULT_DATA_URI_TAGS), cfg.ADD_DATA_URI_TAGS, transformCaseFunc) : DEFAULT_DATA_URI_TAGS; + FORBID_CONTENTS = objectHasOwnProperty(cfg, 'FORBID_CONTENTS') ? addToSet({}, cfg.FORBID_CONTENTS, transformCaseFunc) : DEFAULT_FORBID_CONTENTS; + FORBID_TAGS = objectHasOwnProperty(cfg, 'FORBID_TAGS') ? addToSet({}, cfg.FORBID_TAGS, transformCaseFunc) : {}; + FORBID_ATTR = objectHasOwnProperty(cfg, 'FORBID_ATTR') ? addToSet({}, cfg.FORBID_ATTR, transformCaseFunc) : {}; + USE_PROFILES = objectHasOwnProperty(cfg, 'USE_PROFILES') ? cfg.USE_PROFILES : false; + ALLOW_ARIA_ATTR = cfg.ALLOW_ARIA_ATTR !== false; // Default true + ALLOW_DATA_ATTR = cfg.ALLOW_DATA_ATTR !== false; // Default true + ALLOW_UNKNOWN_PROTOCOLS = cfg.ALLOW_UNKNOWN_PROTOCOLS || false; // Default false + ALLOW_SELF_CLOSE_IN_ATTR = cfg.ALLOW_SELF_CLOSE_IN_ATTR !== false; // Default true + SAFE_FOR_TEMPLATES = cfg.SAFE_FOR_TEMPLATES || false; // Default false + SAFE_FOR_XML = cfg.SAFE_FOR_XML !== false; // Default true + WHOLE_DOCUMENT = cfg.WHOLE_DOCUMENT || false; // Default false + RETURN_DOM = cfg.RETURN_DOM || false; // Default false + RETURN_DOM_FRAGMENT = cfg.RETURN_DOM_FRAGMENT || false; // Default false + RETURN_TRUSTED_TYPE = cfg.RETURN_TRUSTED_TYPE || false; // Default false + FORCE_BODY = cfg.FORCE_BODY || false; // Default false + SANITIZE_DOM = cfg.SANITIZE_DOM !== false; // Default true + SANITIZE_NAMED_PROPS = cfg.SANITIZE_NAMED_PROPS || false; // Default false + KEEP_CONTENT = cfg.KEEP_CONTENT !== false; // Default true + IN_PLACE = cfg.IN_PLACE || false; // Default false + IS_ALLOWED_URI$1 = cfg.ALLOWED_URI_REGEXP || IS_ALLOWED_URI; + NAMESPACE = cfg.NAMESPACE || HTML_NAMESPACE; + MATHML_TEXT_INTEGRATION_POINTS = cfg.MATHML_TEXT_INTEGRATION_POINTS || MATHML_TEXT_INTEGRATION_POINTS; + HTML_INTEGRATION_POINTS = cfg.HTML_INTEGRATION_POINTS || HTML_INTEGRATION_POINTS; + CUSTOM_ELEMENT_HANDLING = cfg.CUSTOM_ELEMENT_HANDLING || {}; + if (cfg.CUSTOM_ELEMENT_HANDLING && isRegexOrFunction(cfg.CUSTOM_ELEMENT_HANDLING.tagNameCheck)) { + CUSTOM_ELEMENT_HANDLING.tagNameCheck = cfg.CUSTOM_ELEMENT_HANDLING.tagNameCheck; + } + if (cfg.CUSTOM_ELEMENT_HANDLING && isRegexOrFunction(cfg.CUSTOM_ELEMENT_HANDLING.attributeNameCheck)) { + CUSTOM_ELEMENT_HANDLING.attributeNameCheck = cfg.CUSTOM_ELEMENT_HANDLING.attributeNameCheck; + } + if (cfg.CUSTOM_ELEMENT_HANDLING && typeof cfg.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements === 'boolean') { + CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements = cfg.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements; + } + if (SAFE_FOR_TEMPLATES) { + ALLOW_DATA_ATTR = false; + } + if (RETURN_DOM_FRAGMENT) { + RETURN_DOM = true; + } + /* Parse profile info */ + if (USE_PROFILES) { + ALLOWED_TAGS = addToSet({}, text); + ALLOWED_ATTR = []; + if (USE_PROFILES.html === true) { + addToSet(ALLOWED_TAGS, html$1); + addToSet(ALLOWED_ATTR, html); + } + if (USE_PROFILES.svg === true) { + addToSet(ALLOWED_TAGS, svg$1); + addToSet(ALLOWED_ATTR, svg); + addToSet(ALLOWED_ATTR, xml); + } + if (USE_PROFILES.svgFilters === true) { + addToSet(ALLOWED_TAGS, svgFilters); + addToSet(ALLOWED_ATTR, svg); + addToSet(ALLOWED_ATTR, xml); + } + if (USE_PROFILES.mathMl === true) { + addToSet(ALLOWED_TAGS, mathMl$1); + addToSet(ALLOWED_ATTR, mathMl); + addToSet(ALLOWED_ATTR, xml); + } + } + /* Merge configuration parameters */ + if (cfg.ADD_TAGS) { + if (ALLOWED_TAGS === DEFAULT_ALLOWED_TAGS) { + ALLOWED_TAGS = clone(ALLOWED_TAGS); + } + addToSet(ALLOWED_TAGS, cfg.ADD_TAGS, transformCaseFunc); + } + if (cfg.ADD_ATTR) { + if (ALLOWED_ATTR === DEFAULT_ALLOWED_ATTR) { + ALLOWED_ATTR = clone(ALLOWED_ATTR); + } + addToSet(ALLOWED_ATTR, cfg.ADD_ATTR, transformCaseFunc); + } + if (cfg.ADD_URI_SAFE_ATTR) { + addToSet(URI_SAFE_ATTRIBUTES, cfg.ADD_URI_SAFE_ATTR, transformCaseFunc); + } + if (cfg.FORBID_CONTENTS) { + if (FORBID_CONTENTS === DEFAULT_FORBID_CONTENTS) { + FORBID_CONTENTS = clone(FORBID_CONTENTS); + } + addToSet(FORBID_CONTENTS, cfg.FORBID_CONTENTS, transformCaseFunc); + } + /* Add #text in case KEEP_CONTENT is set to true */ + if (KEEP_CONTENT) { + ALLOWED_TAGS['#text'] = true; + } + /* Add html, head and body to ALLOWED_TAGS in case WHOLE_DOCUMENT is true */ + if (WHOLE_DOCUMENT) { + addToSet(ALLOWED_TAGS, ['html', 'head', 'body']); + } + /* Add tbody to ALLOWED_TAGS in case tables are permitted, see #286, #365 */ + if (ALLOWED_TAGS.table) { + addToSet(ALLOWED_TAGS, ['tbody']); + delete FORBID_TAGS.tbody; + } + if (cfg.TRUSTED_TYPES_POLICY) { + if (typeof cfg.TRUSTED_TYPES_POLICY.createHTML !== 'function') { + throw typeErrorCreate('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.'); + } + if (typeof cfg.TRUSTED_TYPES_POLICY.createScriptURL !== 'function') { + throw typeErrorCreate('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.'); + } + // Overwrite existing TrustedTypes policy. + trustedTypesPolicy = cfg.TRUSTED_TYPES_POLICY; + // Sign local variables required by `sanitize`. + emptyHTML = trustedTypesPolicy.createHTML(''); + } else { + // Uninitialized policy, attempt to initialize the internal dompurify policy. + if (trustedTypesPolicy === undefined) { + trustedTypesPolicy = _createTrustedTypesPolicy(trustedTypes, currentScript); + } + // If creating the internal policy succeeded sign internal variables. + if (trustedTypesPolicy !== null && typeof emptyHTML === 'string') { + emptyHTML = trustedTypesPolicy.createHTML(''); + } + } + // Prevent further manipulation of configuration. + // Not available in IE8, Safari 5, etc. + if (freeze) { + freeze(cfg); + } + CONFIG = cfg; + }; + /* Keep track of all possible SVG and MathML tags + * so that we can perform the namespace checks + * correctly. */ + const ALL_SVG_TAGS = addToSet({}, [...svg$1, ...svgFilters, ...svgDisallowed]); + const ALL_MATHML_TAGS = addToSet({}, [...mathMl$1, ...mathMlDisallowed]); + /** + * @param element a DOM element whose namespace is being checked + * @returns Return false if the element has a + * namespace that a spec-compliant parser would never + * return. Return true otherwise. + */ + const _checkValidNamespace = function _checkValidNamespace(element) { + let parent = getParentNode(element); + // In JSDOM, if we're inside shadow DOM, then parentNode + // can be null. We just simulate parent in this case. + if (!parent || !parent.tagName) { + parent = { + namespaceURI: NAMESPACE, + tagName: 'template' + }; + } + const tagName = stringToLowerCase(element.tagName); + const parentTagName = stringToLowerCase(parent.tagName); + if (!ALLOWED_NAMESPACES[element.namespaceURI]) { + return false; + } + if (element.namespaceURI === SVG_NAMESPACE) { + // The only way to switch from HTML namespace to SVG + // is via . If it happens via any other tag, then + // it should be killed. + if (parent.namespaceURI === HTML_NAMESPACE) { + return tagName === 'svg'; + } + // The only way to switch from MathML to SVG is via` + // svg if parent is either or MathML + // text integration points. + if (parent.namespaceURI === MATHML_NAMESPACE) { + return tagName === 'svg' && (parentTagName === 'annotation-xml' || MATHML_TEXT_INTEGRATION_POINTS[parentTagName]); + } + // We only allow elements that are defined in SVG + // spec. All others are disallowed in SVG namespace. + return Boolean(ALL_SVG_TAGS[tagName]); + } + if (element.namespaceURI === MATHML_NAMESPACE) { + // The only way to switch from HTML namespace to MathML + // is via . If it happens via any other tag, then + // it should be killed. + if (parent.namespaceURI === HTML_NAMESPACE) { + return tagName === 'math'; + } + // The only way to switch from SVG to MathML is via + // and HTML integration points + if (parent.namespaceURI === SVG_NAMESPACE) { + return tagName === 'math' && HTML_INTEGRATION_POINTS[parentTagName]; + } + // We only allow elements that are defined in MathML + // spec. All others are disallowed in MathML namespace. + return Boolean(ALL_MATHML_TAGS[tagName]); + } + if (element.namespaceURI === HTML_NAMESPACE) { + // The only way to switch from SVG to HTML is via + // HTML integration points, and from MathML to HTML + // is via MathML text integration points + if (parent.namespaceURI === SVG_NAMESPACE && !HTML_INTEGRATION_POINTS[parentTagName]) { + return false; + } + if (parent.namespaceURI === MATHML_NAMESPACE && !MATHML_TEXT_INTEGRATION_POINTS[parentTagName]) { + return false; + } + // We disallow tags that are specific for MathML + // or SVG and should never appear in HTML namespace + return !ALL_MATHML_TAGS[tagName] && (COMMON_SVG_AND_HTML_ELEMENTS[tagName] || !ALL_SVG_TAGS[tagName]); + } + // For XHTML and XML documents that support custom namespaces + if (PARSER_MEDIA_TYPE === 'application/xhtml+xml' && ALLOWED_NAMESPACES[element.namespaceURI]) { + return true; + } + // The code should never reach this place (this means + // that the element somehow got namespace that is not + // HTML, SVG, MathML or allowed via ALLOWED_NAMESPACES). + // Return false just in case. + return false; + }; + /** + * _forceRemove + * + * @param node a DOM node + */ + const _forceRemove = function _forceRemove(node) { + arrayPush(DOMPurify.removed, { + element: node + }); + try { + // eslint-disable-next-line unicorn/prefer-dom-node-remove + getParentNode(node).removeChild(node); + } catch (_) { + remove(node); + } + }; + /** + * _removeAttribute + * + * @param name an Attribute name + * @param element a DOM node + */ + const _removeAttribute = function _removeAttribute(name, element) { + try { + arrayPush(DOMPurify.removed, { + attribute: element.getAttributeNode(name), + from: element + }); + } catch (_) { + arrayPush(DOMPurify.removed, { + attribute: null, + from: element + }); + } + element.removeAttribute(name); + // We void attribute values for unremovable "is" attributes + if (name === 'is') { + if (RETURN_DOM || RETURN_DOM_FRAGMENT) { + try { + _forceRemove(element); + } catch (_) {} + } else { + try { + element.setAttribute(name, ''); + } catch (_) {} + } + } + }; + /** + * _initDocument + * + * @param dirty - a string of dirty markup + * @return a DOM, filled with the dirty markup + */ + const _initDocument = function _initDocument(dirty) { + /* Create a HTML document */ + let doc = null; + let leadingWhitespace = null; + if (FORCE_BODY) { + dirty = '' + dirty; + } else { + /* If FORCE_BODY isn't used, leading whitespace needs to be preserved manually */ + const matches = stringMatch(dirty, /^[\r\n\t ]+/); + leadingWhitespace = matches && matches[0]; + } + if (PARSER_MEDIA_TYPE === 'application/xhtml+xml' && NAMESPACE === HTML_NAMESPACE) { + // Root of XHTML doc must contain xmlns declaration (see https://www.w3.org/TR/xhtml1/normative.html#strict) + dirty = '' + dirty + ''; + } + const dirtyPayload = trustedTypesPolicy ? trustedTypesPolicy.createHTML(dirty) : dirty; + /* + * Use the DOMParser API by default, fallback later if needs be + * DOMParser not work for svg when has multiple root element. + */ + if (NAMESPACE === HTML_NAMESPACE) { + try { + doc = new DOMParser().parseFromString(dirtyPayload, PARSER_MEDIA_TYPE); + } catch (_) {} + } + /* Use createHTMLDocument in case DOMParser is not available */ + if (!doc || !doc.documentElement) { + doc = implementation.createDocument(NAMESPACE, 'template', null); + try { + doc.documentElement.innerHTML = IS_EMPTY_INPUT ? emptyHTML : dirtyPayload; + } catch (_) { + // Syntax error if dirtyPayload is invalid xml + } + } + const body = doc.body || doc.documentElement; + if (dirty && leadingWhitespace) { + body.insertBefore(document.createTextNode(leadingWhitespace), body.childNodes[0] || null); + } + /* Work on whole document or just its body */ + if (NAMESPACE === HTML_NAMESPACE) { + return getElementsByTagName.call(doc, WHOLE_DOCUMENT ? 'html' : 'body')[0]; + } + return WHOLE_DOCUMENT ? doc.documentElement : body; + }; + /** + * Creates a NodeIterator object that you can use to traverse filtered lists of nodes or elements in a document. + * + * @param root The root element or node to start traversing on. + * @return The created NodeIterator + */ + const _createNodeIterator = function _createNodeIterator(root) { + return createNodeIterator.call(root.ownerDocument || root, root, + // eslint-disable-next-line no-bitwise + NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT | NodeFilter.SHOW_TEXT | NodeFilter.SHOW_PROCESSING_INSTRUCTION | NodeFilter.SHOW_CDATA_SECTION, null); + }; + /** + * _isClobbered + * + * @param element element to check for clobbering attacks + * @return true if clobbered, false if safe + */ + const _isClobbered = function _isClobbered(element) { + return element instanceof HTMLFormElement && (typeof element.nodeName !== 'string' || typeof element.textContent !== 'string' || typeof element.removeChild !== 'function' || !(element.attributes instanceof NamedNodeMap) || typeof element.removeAttribute !== 'function' || typeof element.setAttribute !== 'function' || typeof element.namespaceURI !== 'string' || typeof element.insertBefore !== 'function' || typeof element.hasChildNodes !== 'function'); + }; + /** + * Checks whether the given object is a DOM node. + * + * @param value object to check whether it's a DOM node + * @return true is object is a DOM node + */ + const _isNode = function _isNode(value) { + return typeof Node === 'function' && value instanceof Node; + }; + function _executeHooks(hooks, currentNode, data) { + arrayForEach(hooks, hook => { + hook.call(DOMPurify, currentNode, data, CONFIG); + }); + } + /** + * _sanitizeElements + * + * @protect nodeName + * @protect textContent + * @protect removeChild + * @param currentNode to check for permission to exist + * @return true if node was killed, false if left alive + */ + const _sanitizeElements = function _sanitizeElements(currentNode) { + let content = null; + /* Execute a hook if present */ + _executeHooks(hooks.beforeSanitizeElements, currentNode, null); + /* Check if element is clobbered or can clobber */ + if (_isClobbered(currentNode)) { + _forceRemove(currentNode); + return true; + } + /* Now let's check the element's type and name */ + const tagName = transformCaseFunc(currentNode.nodeName); + /* Execute a hook if present */ + _executeHooks(hooks.uponSanitizeElement, currentNode, { + tagName, + allowedTags: ALLOWED_TAGS + }); + /* Detect mXSS attempts abusing namespace confusion */ + if (currentNode.hasChildNodes() && !_isNode(currentNode.firstElementChild) && regExpTest(/<[/\w]/g, currentNode.innerHTML) && regExpTest(/<[/\w]/g, currentNode.textContent)) { + _forceRemove(currentNode); + return true; + } + /* Remove any occurrence of processing instructions */ + if (currentNode.nodeType === NODE_TYPE.progressingInstruction) { + _forceRemove(currentNode); + return true; + } + /* Remove any kind of possibly harmful comments */ + if (SAFE_FOR_XML && currentNode.nodeType === NODE_TYPE.comment && regExpTest(/<[/\w]/g, currentNode.data)) { + _forceRemove(currentNode); + return true; + } + /* Remove element if anything forbids its presence */ + if (!ALLOWED_TAGS[tagName] || FORBID_TAGS[tagName]) { + /* Check if we have a custom element to handle */ + if (!FORBID_TAGS[tagName] && _isBasicCustomElement(tagName)) { + if (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof RegExp && regExpTest(CUSTOM_ELEMENT_HANDLING.tagNameCheck, tagName)) { + return false; + } + if (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof Function && CUSTOM_ELEMENT_HANDLING.tagNameCheck(tagName)) { + return false; + } + } + /* Keep content except for bad-listed elements */ + if (KEEP_CONTENT && !FORBID_CONTENTS[tagName]) { + const parentNode = getParentNode(currentNode) || currentNode.parentNode; + const childNodes = getChildNodes(currentNode) || currentNode.childNodes; + if (childNodes && parentNode) { + const childCount = childNodes.length; + for (let i = childCount - 1; i >= 0; --i) { + const childClone = cloneNode(childNodes[i], true); + childClone.__removalCount = (currentNode.__removalCount || 0) + 1; + parentNode.insertBefore(childClone, getNextSibling(currentNode)); + } + } + } + _forceRemove(currentNode); + return true; + } + /* Check whether element has a valid namespace */ + if (currentNode instanceof Element && !_checkValidNamespace(currentNode)) { + _forceRemove(currentNode); + return true; + } + /* Make sure that older browsers don't get fallback-tag mXSS */ + if ((tagName === 'noscript' || tagName === 'noembed' || tagName === 'noframes') && regExpTest(/<\/no(script|embed|frames)/i, currentNode.innerHTML)) { + _forceRemove(currentNode); + return true; + } + /* Sanitize element content to be template-safe */ + if (SAFE_FOR_TEMPLATES && currentNode.nodeType === NODE_TYPE.text) { + /* Get the element's text content */ + content = currentNode.textContent; + arrayForEach([MUSTACHE_EXPR, ERB_EXPR, TMPLIT_EXPR], expr => { + content = stringReplace(content, expr, ' '); + }); + if (currentNode.textContent !== content) { + arrayPush(DOMPurify.removed, { + element: currentNode.cloneNode() + }); + currentNode.textContent = content; + } + } + /* Execute a hook if present */ + _executeHooks(hooks.afterSanitizeElements, currentNode, null); + return false; + }; + /** + * _isValidAttribute + * + * @param lcTag Lowercase tag name of containing element. + * @param lcName Lowercase attribute name. + * @param value Attribute value. + * @return Returns true if `value` is valid, otherwise false. + */ + // eslint-disable-next-line complexity + const _isValidAttribute = function _isValidAttribute(lcTag, lcName, value) { + /* Make sure attribute cannot clobber */ + if (SANITIZE_DOM && (lcName === 'id' || lcName === 'name') && (value in document || value in formElement)) { + return false; + } + /* Allow valid data-* attributes: At least one character after "-" + (https://html.spec.whatwg.org/multipage/dom.html#embedding-custom-non-visible-data-with-the-data-*-attributes) + XML-compatible (https://html.spec.whatwg.org/multipage/infrastructure.html#xml-compatible and http://www.w3.org/TR/xml/#d0e804) + We don't need to check the value; it's always URI safe. */ + if (ALLOW_DATA_ATTR && !FORBID_ATTR[lcName] && regExpTest(DATA_ATTR, lcName)) ; else if (ALLOW_ARIA_ATTR && regExpTest(ARIA_ATTR, lcName)) ; else if (!ALLOWED_ATTR[lcName] || FORBID_ATTR[lcName]) { + if ( + // First condition does a very basic check if a) it's basically a valid custom element tagname AND + // b) if the tagName passes whatever the user has configured for CUSTOM_ELEMENT_HANDLING.tagNameCheck + // and c) if the attribute name passes whatever the user has configured for CUSTOM_ELEMENT_HANDLING.attributeNameCheck + _isBasicCustomElement(lcTag) && (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof RegExp && regExpTest(CUSTOM_ELEMENT_HANDLING.tagNameCheck, lcTag) || CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof Function && CUSTOM_ELEMENT_HANDLING.tagNameCheck(lcTag)) && (CUSTOM_ELEMENT_HANDLING.attributeNameCheck instanceof RegExp && regExpTest(CUSTOM_ELEMENT_HANDLING.attributeNameCheck, lcName) || CUSTOM_ELEMENT_HANDLING.attributeNameCheck instanceof Function && CUSTOM_ELEMENT_HANDLING.attributeNameCheck(lcName)) || + // Alternative, second condition checks if it's an `is`-attribute, AND + // the value passes whatever the user has configured for CUSTOM_ELEMENT_HANDLING.tagNameCheck + lcName === 'is' && CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements && (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof RegExp && regExpTest(CUSTOM_ELEMENT_HANDLING.tagNameCheck, value) || CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof Function && CUSTOM_ELEMENT_HANDLING.tagNameCheck(value))) ; else { + return false; + } + /* Check value is safe. First, is attr inert? If so, is safe */ + } else if (URI_SAFE_ATTRIBUTES[lcName]) ; else if (regExpTest(IS_ALLOWED_URI$1, stringReplace(value, ATTR_WHITESPACE, ''))) ; else if ((lcName === 'src' || lcName === 'xlink:href' || lcName === 'href') && lcTag !== 'script' && stringIndexOf(value, 'data:') === 0 && DATA_URI_TAGS[lcTag]) ; else if (ALLOW_UNKNOWN_PROTOCOLS && !regExpTest(IS_SCRIPT_OR_DATA, stringReplace(value, ATTR_WHITESPACE, ''))) ; else if (value) { + return false; + } else ; + return true; + }; + /** + * _isBasicCustomElement + * checks if at least one dash is included in tagName, and it's not the first char + * for more sophisticated checking see https://github.com/sindresorhus/validate-element-name + * + * @param tagName name of the tag of the node to sanitize + * @returns Returns true if the tag name meets the basic criteria for a custom element, otherwise false. + */ + const _isBasicCustomElement = function _isBasicCustomElement(tagName) { + return tagName !== 'annotation-xml' && stringMatch(tagName, CUSTOM_ELEMENT); + }; + /** + * _sanitizeAttributes + * + * @protect attributes + * @protect nodeName + * @protect removeAttribute + * @protect setAttribute + * + * @param currentNode to sanitize + */ + const _sanitizeAttributes = function _sanitizeAttributes(currentNode) { + /* Execute a hook if present */ + _executeHooks(hooks.beforeSanitizeAttributes, currentNode, null); + const { + attributes + } = currentNode; + /* Check if we have attributes; if not we might have a text node */ + if (!attributes || _isClobbered(currentNode)) { + return; + } + const hookEvent = { + attrName: '', + attrValue: '', + keepAttr: true, + allowedAttributes: ALLOWED_ATTR, + forceKeepAttr: undefined + }; + let l = attributes.length; + /* Go backwards over all attributes; safely remove bad ones */ + while (l--) { + const attr = attributes[l]; + const { + name, + namespaceURI, + value: attrValue + } = attr; + const lcName = transformCaseFunc(name); + let value = name === 'value' ? attrValue : stringTrim(attrValue); + /* Execute a hook if present */ + hookEvent.attrName = lcName; + hookEvent.attrValue = value; + hookEvent.keepAttr = true; + hookEvent.forceKeepAttr = undefined; // Allows developers to see this is a property they can set + _executeHooks(hooks.uponSanitizeAttribute, currentNode, hookEvent); + value = hookEvent.attrValue; + /* Full DOM Clobbering protection via namespace isolation, + * Prefix id and name attributes with `user-content-` + */ + if (SANITIZE_NAMED_PROPS && (lcName === 'id' || lcName === 'name')) { + // Remove the attribute with this value + _removeAttribute(name, currentNode); + // Prefix the value and later re-create the attribute with the sanitized value + value = SANITIZE_NAMED_PROPS_PREFIX + value; + } + /* Work around a security issue with comments inside attributes */ + if (SAFE_FOR_XML && regExpTest(/((--!?|])>)|<\/(style|title)/i, value)) { + _removeAttribute(name, currentNode); + continue; + } + /* Did the hooks approve of the attribute? */ + if (hookEvent.forceKeepAttr) { + continue; + } + /* Remove attribute */ + _removeAttribute(name, currentNode); + /* Did the hooks approve of the attribute? */ + if (!hookEvent.keepAttr) { + continue; + } + /* Work around a security issue in jQuery 3.0 */ + if (!ALLOW_SELF_CLOSE_IN_ATTR && regExpTest(/\/>/i, value)) { + _removeAttribute(name, currentNode); + continue; + } + /* Sanitize attribute content to be template-safe */ + if (SAFE_FOR_TEMPLATES) { + arrayForEach([MUSTACHE_EXPR, ERB_EXPR, TMPLIT_EXPR], expr => { + value = stringReplace(value, expr, ' '); + }); + } + /* Is `value` valid for this attribute? */ + const lcTag = transformCaseFunc(currentNode.nodeName); + if (!_isValidAttribute(lcTag, lcName, value)) { + continue; + } + /* Handle attributes that require Trusted Types */ + if (trustedTypesPolicy && typeof trustedTypes === 'object' && typeof trustedTypes.getAttributeType === 'function') { + if (namespaceURI) ; else { + switch (trustedTypes.getAttributeType(lcTag, lcName)) { + case 'TrustedHTML': + { + value = trustedTypesPolicy.createHTML(value); + break; + } + case 'TrustedScriptURL': + { + value = trustedTypesPolicy.createScriptURL(value); + break; + } + } + } + } + /* Handle invalid data-* attribute set by try-catching it */ + try { + if (namespaceURI) { + currentNode.setAttributeNS(namespaceURI, name, value); + } else { + /* Fallback to setAttribute() for browser-unrecognized namespaces e.g. "x-schema". */ + currentNode.setAttribute(name, value); + } + if (_isClobbered(currentNode)) { + _forceRemove(currentNode); + } else { + arrayPop(DOMPurify.removed); + } + } catch (_) {} + } + /* Execute a hook if present */ + _executeHooks(hooks.afterSanitizeAttributes, currentNode, null); + }; + /** + * _sanitizeShadowDOM + * + * @param fragment to iterate over recursively + */ + const _sanitizeShadowDOM = function _sanitizeShadowDOM(fragment) { + let shadowNode = null; + const shadowIterator = _createNodeIterator(fragment); + /* Execute a hook if present */ + _executeHooks(hooks.beforeSanitizeShadowDOM, fragment, null); + while (shadowNode = shadowIterator.nextNode()) { + /* Execute a hook if present */ + _executeHooks(hooks.uponSanitizeShadowNode, shadowNode, null); + /* Sanitize tags and elements */ + _sanitizeElements(shadowNode); + /* Check attributes next */ + _sanitizeAttributes(shadowNode); + /* Deep shadow DOM detected */ + if (shadowNode.content instanceof DocumentFragment) { + _sanitizeShadowDOM(shadowNode.content); + } + } + /* Execute a hook if present */ + _executeHooks(hooks.afterSanitizeShadowDOM, fragment, null); + }; + // eslint-disable-next-line complexity + DOMPurify.sanitize = function (dirty) { + let cfg = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; + let body = null; + let importedNode = null; + let currentNode = null; + let returnNode = null; + /* Make sure we have a string to sanitize. + DO NOT return early, as this will return the wrong type if + the user has requested a DOM object rather than a string */ + IS_EMPTY_INPUT = !dirty; + if (IS_EMPTY_INPUT) { + dirty = ''; + } + /* Stringify, in case dirty is an object */ + if (typeof dirty !== 'string' && !_isNode(dirty)) { + if (typeof dirty.toString === 'function') { + dirty = dirty.toString(); + if (typeof dirty !== 'string') { + throw typeErrorCreate('dirty is not a string, aborting'); + } + } else { + throw typeErrorCreate('toString is not a function'); + } + } + /* Return dirty HTML if DOMPurify cannot run */ + if (!DOMPurify.isSupported) { + return dirty; + } + /* Assign config vars */ + if (!SET_CONFIG) { + _parseConfig(cfg); + } + /* Clean up removed elements */ + DOMPurify.removed = []; + /* Check if dirty is correctly typed for IN_PLACE */ + if (typeof dirty === 'string') { + IN_PLACE = false; + } + if (IN_PLACE) { + /* Do some early pre-sanitization to avoid unsafe root nodes */ + if (dirty.nodeName) { + const tagName = transformCaseFunc(dirty.nodeName); + if (!ALLOWED_TAGS[tagName] || FORBID_TAGS[tagName]) { + throw typeErrorCreate('root node is forbidden and cannot be sanitized in-place'); + } + } + } else if (dirty instanceof Node) { + /* If dirty is a DOM element, append to an empty document to avoid + elements being stripped by the parser */ + body = _initDocument(''); + importedNode = body.ownerDocument.importNode(dirty, true); + if (importedNode.nodeType === NODE_TYPE.element && importedNode.nodeName === 'BODY') { + /* Node is already a body, use as is */ + body = importedNode; + } else if (importedNode.nodeName === 'HTML') { + body = importedNode; + } else { + // eslint-disable-next-line unicorn/prefer-dom-node-append + body.appendChild(importedNode); + } + } else { + /* Exit directly if we have nothing to do */ + if (!RETURN_DOM && !SAFE_FOR_TEMPLATES && !WHOLE_DOCUMENT && + // eslint-disable-next-line unicorn/prefer-includes + dirty.indexOf('<') === -1) { + return trustedTypesPolicy && RETURN_TRUSTED_TYPE ? trustedTypesPolicy.createHTML(dirty) : dirty; + } + /* Initialize the document to work on */ + body = _initDocument(dirty); + /* Check we have a DOM node from the data */ + if (!body) { + return RETURN_DOM ? null : RETURN_TRUSTED_TYPE ? emptyHTML : ''; + } + } + /* Remove first element node (ours) if FORCE_BODY is set */ + if (body && FORCE_BODY) { + _forceRemove(body.firstChild); + } + /* Get node iterator */ + const nodeIterator = _createNodeIterator(IN_PLACE ? dirty : body); + /* Now start iterating over the created document */ + while (currentNode = nodeIterator.nextNode()) { + /* Sanitize tags and elements */ + _sanitizeElements(currentNode); + /* Check attributes next */ + _sanitizeAttributes(currentNode); + /* Shadow DOM detected, sanitize it */ + if (currentNode.content instanceof DocumentFragment) { + _sanitizeShadowDOM(currentNode.content); + } + } + /* If we sanitized `dirty` in-place, return it. */ + if (IN_PLACE) { + return dirty; + } + /* Return sanitized string or DOM */ + if (RETURN_DOM) { + if (RETURN_DOM_FRAGMENT) { + returnNode = createDocumentFragment.call(body.ownerDocument); + while (body.firstChild) { + // eslint-disable-next-line unicorn/prefer-dom-node-append + returnNode.appendChild(body.firstChild); + } + } else { + returnNode = body; + } + if (ALLOWED_ATTR.shadowroot || ALLOWED_ATTR.shadowrootmode) { + /* + AdoptNode() is not used because internal state is not reset + (e.g. the past names map of a HTMLFormElement), this is safe + in theory but we would rather not risk another attack vector. + The state that is cloned by importNode() is explicitly defined + by the specs. + */ + returnNode = importNode.call(originalDocument, returnNode, true); + } + return returnNode; + } + let serializedHTML = WHOLE_DOCUMENT ? body.outerHTML : body.innerHTML; + /* Serialize doctype if allowed */ + if (WHOLE_DOCUMENT && ALLOWED_TAGS['!doctype'] && body.ownerDocument && body.ownerDocument.doctype && body.ownerDocument.doctype.name && regExpTest(DOCTYPE_NAME, body.ownerDocument.doctype.name)) { + serializedHTML = '\n' + serializedHTML; + } + /* Sanitize final string template-safe */ + if (SAFE_FOR_TEMPLATES) { + arrayForEach([MUSTACHE_EXPR, ERB_EXPR, TMPLIT_EXPR], expr => { + serializedHTML = stringReplace(serializedHTML, expr, ' '); + }); + } + return trustedTypesPolicy && RETURN_TRUSTED_TYPE ? trustedTypesPolicy.createHTML(serializedHTML) : serializedHTML; + }; + DOMPurify.setConfig = function () { + let cfg = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; + _parseConfig(cfg); + SET_CONFIG = true; + }; + DOMPurify.clearConfig = function () { + CONFIG = null; + SET_CONFIG = false; + }; + DOMPurify.isValidAttribute = function (tag, attr, value) { + /* Initialize shared config vars if necessary. */ + if (!CONFIG) { + _parseConfig({}); + } + const lcTag = transformCaseFunc(tag); + const lcName = transformCaseFunc(attr); + return _isValidAttribute(lcTag, lcName, value); + }; + DOMPurify.addHook = function (entryPoint, hookFunction) { + if (typeof hookFunction !== 'function') { + return; + } + arrayPush(hooks[entryPoint], hookFunction); + }; + DOMPurify.removeHook = function (entryPoint, hookFunction) { + if (hookFunction !== undefined) { + const index = arrayLastIndexOf(hooks[entryPoint], hookFunction); + return index === -1 ? undefined : arraySplice(hooks[entryPoint], index, 1)[0]; + } + return arrayPop(hooks[entryPoint]); + }; + DOMPurify.removeHooks = function (entryPoint) { + hooks[entryPoint] = []; + }; + DOMPurify.removeAllHooks = function () { + hooks = _createHooksMap(); + }; + return DOMPurify; +} +var purify = createDOMPurify(); + +export { purify as default }; diff --git a/molodetz/stealth.py b/molodetz/stealth.py new file mode 100644 index 0000000..ba1b659 --- /dev/null +++ b/molodetz/stealth.py @@ -0,0 +1,62 @@ +# retoor +import re +from urllib.parse import unquote + +import httpx + +from molodetz import config +from molodetz.database import get_setting +from molodetz.curl_transport import CURL_AVAILABLE, CurlImpersonationTransport +from molodetz.net_guard import GuardedTransport + +BROWSER_ALIGNED_HEADERS = { + "user-agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "accept-language": "nl-NL,nl;q=0.9,en;q=0.8", +} + +CONSENT_MARKERS = ("consent", "privacy-gate", "cookiewall") +CONSENT_REDIRECT = re.compile(r"""(?:window\.)?location(?:\.href)?\s*=\s*decodeURIComponent\(\s*['"]([^'"]+)['"]""") + + +def configured_proxy(): + try: + value = get_setting("outbound_proxy_url", "") + except Exception: + value = "" + return value or config.OUTBOUND_PROXY_URL or None + + +def _async_transport(proxy, timeout): + if CURL_AVAILABLE: + return CurlImpersonationTransport(proxy=proxy, timeout=timeout) + return httpx.AsyncHTTPTransport(http2=True, proxy=proxy, retries=1) + + +def stealth_async_client(proxy=None, timeout=30.0, **kwargs): + proxy = proxy if proxy is not None else configured_proxy() + transport = _async_transport(proxy, timeout) + headers = None if CURL_AVAILABLE else BROWSER_ALIGNED_HEADERS + return httpx.AsyncClient(transport=transport, timeout=timeout, headers=headers, follow_redirects=True, **kwargs) + + +def stealth_sync_client(proxy=None, timeout=30.0, **kwargs): + proxy = proxy if proxy is not None else configured_proxy() + return httpx.Client(http2=True, proxy=proxy, timeout=timeout, headers=BROWSER_ALIGNED_HEADERS, follow_redirects=True, **kwargs) + + +def guarded_async_client(proxy=None, timeout=20.0, **kwargs): + proxy = proxy if proxy is not None else configured_proxy() + transport = GuardedTransport(_async_transport(proxy, timeout)) + headers = None if CURL_AVAILABLE else BROWSER_ALIGNED_HEADERS + return httpx.AsyncClient(transport=transport, timeout=timeout, headers=headers, follow_redirects=True, max_redirects=5, **kwargs) + + +def detect_consent_gate(html): + lowered = (html or "").lower() + if not any(marker in lowered for marker in CONSENT_MARKERS): + return None + match = CONSENT_REDIRECT.search(html) + if not match: + return None + return unquote(match.group(1)) diff --git a/molodetz/templates/_avatar_link.html b/molodetz/templates/_avatar_link.html new file mode 100644 index 0000000..6b420af --- /dev/null +++ b/molodetz/templates/_avatar_link.html @@ -0,0 +1,5 @@ + +
    + + + diff --git a/molodetz/templates/_breadcrumbs.html b/molodetz/templates/_breadcrumbs.html new file mode 100644 index 0000000..e6f8557 --- /dev/null +++ b/molodetz/templates/_breadcrumbs.html @@ -0,0 +1,6 @@ + + diff --git a/molodetz/templates/_content_header.html b/molodetz/templates/_content_header.html new file mode 100644 index 0000000..bb254eb --- /dev/null +++ b/molodetz/templates/_content_header.html @@ -0,0 +1,7 @@ + + diff --git a/molodetz/templates/_gallery_item.html b/molodetz/templates/_gallery_item.html new file mode 100644 index 0000000..7397764 --- /dev/null +++ b/molodetz/templates/_gallery_item.html @@ -0,0 +1,7 @@ + +
    + + {{ _item.caption }} + +
    {{ _item.caption }}{{ '%02d' % _index }}
    +
    diff --git a/molodetz/templates/_load_more.html b/molodetz/templates/_load_more.html new file mode 100644 index 0000000..7544080 --- /dev/null +++ b/molodetz/templates/_load_more.html @@ -0,0 +1,4 @@ + +{% if _next_url %} + +{% endif %} diff --git a/molodetz/templates/_macros.html b/molodetz/templates/_macros.html new file mode 100644 index 0000000..4e07a41 --- /dev/null +++ b/molodetz/templates/_macros.html @@ -0,0 +1,9 @@ + +{% macro modal(id, title, wide=False) %} + +{% endmacro %} diff --git a/molodetz/templates/_pagination.html b/molodetz/templates/_pagination.html new file mode 100644 index 0000000..cabdb99 --- /dev/null +++ b/molodetz/templates/_pagination.html @@ -0,0 +1,8 @@ + +{% if _pages and _pages.pages > 1 %} + +{% endif %} diff --git a/molodetz/templates/_post_card.html b/molodetz/templates/_post_card.html new file mode 100644 index 0000000..fb824fe --- /dev/null +++ b/molodetz/templates/_post_card.html @@ -0,0 +1,7 @@ + + diff --git a/molodetz/templates/_sidebar_search.html b/molodetz/templates/_sidebar_search.html new file mode 100644 index 0000000..b9e3c6f --- /dev/null +++ b/molodetz/templates/_sidebar_search.html @@ -0,0 +1,5 @@ + + diff --git a/molodetz/templates/_topic_selector.html b/molodetz/templates/_topic_selector.html new file mode 100644 index 0000000..ff04622 --- /dev/null +++ b/molodetz/templates/_topic_selector.html @@ -0,0 +1,9 @@ + +
    + + +
    diff --git a/molodetz/templates/_user_link.html b/molodetz/templates/_user_link.html new file mode 100644 index 0000000..80d46a8 --- /dev/null +++ b/molodetz/templates/_user_link.html @@ -0,0 +1,2 @@ + +{{ _user.username }} diff --git a/molodetz/templates/admin/audit.html b/molodetz/templates/admin/audit.html new file mode 100644 index 0000000..3075374 --- /dev/null +++ b/molodetz/templates/admin/audit.html @@ -0,0 +1,17 @@ + +{% extends "admin/base_admin.html" %} +{% block admin_content %} +

    Auditlog

    +
    + + + + {% for row in rows %} + + {% else %} + + {% endfor %} + +
    TijdGebeurtenisCategorieActorResultaat
    {{ local_dt(row.created_at) }}{{ row.event_key }}{{ row.category }}{{ row.actor_kind }}{{ row.result }}
    Nog geen regels.
    +
    +{% endblock %} diff --git a/molodetz/templates/admin/backups.html b/molodetz/templates/admin/backups.html new file mode 100644 index 0000000..df0a311 --- /dev/null +++ b/molodetz/templates/admin/backups.html @@ -0,0 +1,27 @@ + +{% extends "admin/base_admin.html" %} +{% block admin_content %} +

    Back-ups

    +
    + + +
    +
    + + + + {% for row in backups %} + + + + + + + + {% else %} + + {% endfor %} + +
    DoelStatusGrootteAangemaakt
    {{ row.target }}{{ row.status }}{% if row.error %}
    {{ row.error }}{% endif %}
    {{ row.size_label if row.size_bytes else '' }}{{ local_dt(row.created_at) }}{% if can_download and row.status == 'done' %}Download{% endif %}
    Nog geen back-ups.
    +
    +{% endblock %} diff --git a/molodetz/templates/admin/base_admin.html b/molodetz/templates/admin/base_admin.html new file mode 100644 index 0000000..3e818da --- /dev/null +++ b/molodetz/templates/admin/base_admin.html @@ -0,0 +1,25 @@ + +{% extends "base.html" %} +{% block main_class %}wide{% endblock %} +{% block extra_head %}{% block admin_head %}{% endblock %}{% endblock %} +{% block content %} +
    + +
    + {% block admin_content %}{% endblock %} +
    +
    +{% endblock %} diff --git a/molodetz/templates/admin/index.html b/molodetz/templates/admin/index.html new file mode 100644 index 0000000..a2bc651 --- /dev/null +++ b/molodetz/templates/admin/index.html @@ -0,0 +1,12 @@ + +{% extends "admin/base_admin.html" %} +{% block admin_content %} +

    Beheer

    +
    +
    {{ post_count }}Berichten
    +
    {{ open_joins }}Open aanmeldingen
    +
    {{ user_count }}Accounts
    +
    {{ deleted_count }}In prullenbak
    +
    + +{% endblock %} diff --git a/molodetz/templates/admin/joins.html b/molodetz/templates/admin/joins.html new file mode 100644 index 0000000..9a64104 --- /dev/null +++ b/molodetz/templates/admin/joins.html @@ -0,0 +1,31 @@ + +{% extends "admin/base_admin.html" %} +{% block admin_content %} +

    Aanmeldingen

    +
    + + + + {% for item in requests %} + + + + + + + + + + {% else %} + + {% endfor %} + +
    WieContactWerkBerichtStatusOntvangen
    {{ item.name }}{{ item.contact }}{% if item.repo_url %}{{ item.repo_url }}{% if item.link_status %}
    {{ item.link_status }}{% if item.link_title %}: {{ item.link_title }}{% endif %}{% endif %}{% else %}geen{% endif %}
    {{ item.message }}{{ item.status }}{{ local_dt(item.created_at) }} +
    + + +
    + {% if item.repo_url %}
    {% endif %} +
    Nog geen aanmeldingen.
    +
    +{% endblock %} diff --git a/molodetz/templates/admin/post_form.html b/molodetz/templates/admin/post_form.html new file mode 100644 index 0000000..86f0349 --- /dev/null +++ b/molodetz/templates/admin/post_form.html @@ -0,0 +1,31 @@ + +{% extends "admin/base_admin.html" %} +{% block admin_content %} +

    {{ 'Bericht bewerken' if post else 'Nieuw bericht' }}

    +
    +
    + {% set _selected = post.topic if post else 'rol' %} + {% include "_topic_selector.html" %} +
    +
    + + + Markdown met GFM. Emoji via :shortcode:. Voorbeeld rechts is een live voorbeeld; de gepubliceerde versie wordt op de server gerenderd. +
    +
    + + +
    +
    +
    + + +
    + +
    Annuleren
    +
    +{% endblock %} +{% block extra_js %}{% endblock %} diff --git a/molodetz/templates/admin/posts.html b/molodetz/templates/admin/posts.html new file mode 100644 index 0000000..8221327 --- /dev/null +++ b/molodetz/templates/admin/posts.html @@ -0,0 +1,28 @@ + +{% extends "admin/base_admin.html" %} +{% block admin_content %} +

    Berichten

    + +
    + + + + {% for post in posts %} + + + + + + + + {% else %} + + {% endfor %} + +
    TitelOnderwerpStatusBijgewerkt
    {{ plain_title(post.title) }}{% if post.is_placeholder %} Placeholder{% endif %}{{ TOPICS.get(post.topic, post.topic) }}{{ post.status }}{{ local_dt(post.updated_at) }} + Bewerken +
    +
    +
    Nog geen berichten.
    +
    +{% endblock %} diff --git a/molodetz/templates/admin/service.html b/molodetz/templates/admin/service.html new file mode 100644 index 0000000..4fa3d98 --- /dev/null +++ b/molodetz/templates/admin/service.html @@ -0,0 +1,53 @@ + +{% extends "admin/base_admin.html" %} +{% block admin_content %} +

    {{ service.title }}

    +

    {{ service.description }}

    +
    +
    + + + +
    +
    +

    Status: {{ service.status }}

    +
    + {% for card in service.metrics.cards or [] %}
    {{ card.value }}{{ card.label }}
    {% endfor %} +
    +
    +
    +
    +
    +
    +
    + + +
    +{% endblock %} +{% block extra_js %}{% endblock %} diff --git a/molodetz/templates/admin/services.html b/molodetz/templates/admin/services.html new file mode 100644 index 0000000..7bcec98 --- /dev/null +++ b/molodetz/templates/admin/services.html @@ -0,0 +1,25 @@ + +{% extends "admin/base_admin.html" %} +{% block admin_content %} +

    Services

    +
    + + + + {% for service in services %} + + + + + + + + {% endfor %} + +
    ServiceStatusLaatste runVolgende run
    {{ service.title }}
    {{ service.description }}
    {{ service.status }}{{ local_dt(service.last_run) }}{{ local_dt(service.next_run) }} +
    +
    +
    +
    +{% endblock %} +{% block extra_js %}{% endblock %} diff --git a/molodetz/templates/admin/settings.html b/molodetz/templates/admin/settings.html new file mode 100644 index 0000000..9e2ce4c --- /dev/null +++ b/molodetz/templates/admin/settings.html @@ -0,0 +1,18 @@ + +{% extends "admin/base_admin.html" %} +{% block admin_content %} +

    Instellingen

    +
    + {% for key, value in settings.items() %} +
    + + {% if key in ('maintenance_mode', 'friendly_404') %} + + {% else %} + + {% endif %} +
    + {% endfor %} +
    +
    +{% endblock %} diff --git a/molodetz/templates/admin/stats.html b/molodetz/templates/admin/stats.html new file mode 100644 index 0000000..9093d53 --- /dev/null +++ b/molodetz/templates/admin/stats.html @@ -0,0 +1,19 @@ + +{% extends "admin/base_admin.html" %} +{% block admin_content %} +

    Statistieken

    +
    + + +
    +
    + +{% for row in top_paths %}{% else %}{% endfor %} +
    PadHits
    {{ row.path }}{{ row.hits }}
    Nog geen data.
    +
    + +{% endblock %} diff --git a/molodetz/templates/admin/trash.html b/molodetz/templates/admin/trash.html new file mode 100644 index 0000000..d89580a --- /dev/null +++ b/molodetz/templates/admin/trash.html @@ -0,0 +1,26 @@ + +{% extends "admin/base_admin.html" %} +{% block admin_content %} +

    Prullenbak

    +
    + + + + {% for item in items %} + + + + + + + + {% else %} + + {% endfor %} + +
    SoortItemVerwijderdDoor
    {{ item.table }}{{ item.label }}{{ local_dt(item.deleted_at) }}{{ item.deleted_by }} +
    +
    +
    De prullenbak is leeg.
    +
    +{% endblock %} diff --git a/molodetz/templates/admin/users.html b/molodetz/templates/admin/users.html new file mode 100644 index 0000000..ca30244 --- /dev/null +++ b/molodetz/templates/admin/users.html @@ -0,0 +1,25 @@ + +{% extends "admin/base_admin.html" %} +{% block admin_content %} +

    Accounts

    +
    + + + + {% for user in users %} + + + + + + + {% endfor %} + +
    NaamRolSinds
    {{ user.username }}{% if is_primary_admin(user) %} (primair){% endif %}{{ user.role }}{{ local_dt(user.created_at, 'date') }} +
    + + +
    +
    +
    +{% endblock %} diff --git a/molodetz/templates/auth/login.html b/molodetz/templates/auth/login.html new file mode 100644 index 0000000..e8e88cc --- /dev/null +++ b/molodetz/templates/auth/login.html @@ -0,0 +1,18 @@ + +{% extends "base.html" %} +{% block content %} +
    + Beheer +

    Inloggen

    +
    +{% if errors %} + +{% endif %} +
    + +
    +
    + +
    +
    +{% endblock %} diff --git a/molodetz/templates/base.html b/molodetz/templates/base.html new file mode 100644 index 0000000..543d9c0 --- /dev/null +++ b/molodetz/templates/base.html @@ -0,0 +1,87 @@ + +{% set seo = seo or {} %} +{% set viewer = current_user(request) %} + + + + + +{{ seo.title or app_title }} + + + + + +{% if seo.canonical %}{% endif %} +{% if seo.prev_url %}{% endif %} +{% if seo.next_url %}{% endif %} + + + + +{% if seo.canonical %}{% endif %} +{% if seo.og_image %}{% endif %} + + + + +{% for block_json in seo.json_ld or [] %} + +{% endfor %} + + + + + + + + + +{% block extra_head %}{% endblock %} + + +Naar inhoud + +
    +{% if seo.breadcrumbs and seo.breadcrumbs | length > 1 %} +{% include "_breadcrumbs.html" %} +{% endif %} +{% block content %}{% endblock %} +
    + + + +{{ response_time(request) }} + +{% block extra_js %}{% endblock %} + + diff --git a/molodetz/templates/binnen.html b/molodetz/templates/binnen.html new file mode 100644 index 0000000..705c9d8 --- /dev/null +++ b/molodetz/templates/binnen.html @@ -0,0 +1,37 @@ + +{% extends "base.html" %} +{% block content %} +
    + Binnen +

    Ik schrijf mee

    +

    De rol is open voor mensen, niet voor een publiek. Zeg wie je bent en hoe we je bereiken. Een link naar je werk helpt, maar is niet verplicht.

    +
    +{% if submitted %} +
    Genoteerd. retoor leest elke aanmelding zelf en neemt contact op.
    +{% endif %} +{% if errors %} + +{% endif %} +
    +
    + + +
    +
    + + + Alleen zichtbaar voor beheer. +
    +
    + + +
    +
    + + +
    + +

    Door te versturen ga je akkoord met de privacyverklaring. We bewaren alleen wat je hier invult.

    +
    +
    +{% endblock %} diff --git a/molodetz/templates/docs/api.html b/molodetz/templates/docs/api.html new file mode 100644 index 0000000..de27aab --- /dev/null +++ b/molodetz/templates/docs/api.html @@ -0,0 +1,52 @@ + +{% extends "docs/base_docs.html" %} +{% block docs_content %} +

    API: {{ group.title }}

    +

    {{ group.description }}

    +

    Elke HTML-route geeft JSON terug met Accept: application/json. Authenticatie: sessiecookie, X-API-KEY, Authorization: Bearer of Basic.

    +{% for item in endpoints %} +
    +
    {{ item.method }}{{ item.path }}{{ item.min_role }}
    + {{ item.title }} + {% if item.description %}

    {{ item.description }}

    {% endif %} + {% if item.fields %} + + {% for f in item.fields %}{% endfor %} +
    VeldTypePlekVerplicht
    {{ f.name }}{% if f.enum %}
    Toegestaan: {{ f.enum | join(', ') }}
    {% endif %}
    {{ f.type }}{{ f.location }}{{ 'ja' if f.required else 'nee' }}
    + {% endif %} +
    + + + + + {% if item.interactive %}{% endif %} +
    +
    {{ item.curl }}
    + + + + {% if item.interactive %} + + {% endif %} +
    +{% endfor %} +{% if services %} +

    Live services

    +{% for service in services %} +
    {{ service.title }} {{ service.name }}

    {{ service.description }}

    +

    Velden: {% for f in service.fields %}{{ f.key }} ({{ f.type }}){% if not loop.last %}, {% endif %}{% endfor %}

    +{% endfor %} +{% endif %} +{% endblock %} +{% block extra_js %}{% endblock %} diff --git a/molodetz/templates/docs/base_docs.html b/molodetz/templates/docs/base_docs.html new file mode 100644 index 0000000..74a5cd0 --- /dev/null +++ b/molodetz/templates/docs/base_docs.html @@ -0,0 +1,32 @@ + +{% extends "base.html" %} +{% block main_class %}wide{% endblock %} +{% block extra_head %}{% endblock %} +{% block content %} +
    + +
    + {% block docs_content %}{% endblock %} +
    +
    +{% endblock %} diff --git a/molodetz/templates/docs/index.html b/molodetz/templates/docs/index.html new file mode 100644 index 0000000..49599dc --- /dev/null +++ b/molodetz/templates/docs/index.html @@ -0,0 +1,5 @@ + +{% extends "docs/base_docs.html" %} +{% block docs_content %} +
    {{ body | safe }}
    +{% endblock %} diff --git a/molodetz/templates/docs/page.html b/molodetz/templates/docs/page.html new file mode 100644 index 0000000..49599dc --- /dev/null +++ b/molodetz/templates/docs/page.html @@ -0,0 +1,5 @@ + +{% extends "docs/base_docs.html" %} +{% block docs_content %} +
    {{ body | safe }}
    +{% endblock %} diff --git a/molodetz/templates/docs/prose/api.md b/molodetz/templates/docs/prose/api.md new file mode 100644 index 0000000..e109565 --- /dev/null +++ b/molodetz/templates/docs/prose/api.md @@ -0,0 +1,24 @@ +# API en authenticatie + +Elke route heeft vier gezichten: HTML, JSON, documentatie en (waar geselecteerd) een assistent-tool. Vraag JSON met `Accept: application/json`. + +## Authenticatie + +Volgorde van resolutie: + +1. `session` cookie (64 hex tekens) na inloggen op `/auth/login`. +2. `X-API-KEY` header met je API-sleutel. +3. `Authorization: Bearer `. +4. `Authorization: Basic` met naam of e-mail en wachtwoord. + +Er is geen JWT en geen OAuth. + +## Voorbeeld + +```bash +curl -H 'Accept: application/json' https://molodetz.nl/rol +``` + +## Fouten + +Fouten hebben de vorm `{"error": {"status": 404, "message": "..."}}`. Validatiefouten geven `422` met `{"error": "validation", "fields": [...], "messages": [...]}`. diff --git a/molodetz/templates/docs/prose/architectuur.md b/molodetz/templates/docs/prose/architectuur.md new file mode 100644 index 0000000..a297801 --- /dev/null +++ b/molodetz/templates/docs/prose/architectuur.md @@ -0,0 +1,18 @@ +# Architectuur + +FastAPI met Jinja2, server-side gerenderd. SQLite via `dataset`, synchroon en bewust zonder threadpool. Sessies met PBKDF2-SHA256 wachtwoorden. + +## Lagen + +- `molodetz/database/`: één bestand per onderwerp, importeer altijd vanaf de package-root. +- `molodetz/routers/`: spiegelt het URL-pad. +- `molodetz/services/`: achtergrondservices onder één supervisor met een bestandsslot. +- `molodetz/templates/`: één Jinja-omgeving in `templating.py`. + +## Verwijderen + +Alles wordt zacht verwijderd met `deleted_at` en `deleted_by`. Alleen opruimtaken verwijderen hard. + +## Statische bestanden + +Assets lopen via `static_url()`, wat ze versioneert onder `/static/v/`. Die zijn een jaar onveranderlijk cachebaar. diff --git a/molodetz/templates/docs/prose/backups.md b/molodetz/templates/docs/prose/backups.md new file mode 100644 index 0000000..05f43a3 --- /dev/null +++ b/molodetz/templates/docs/prose/backups.md @@ -0,0 +1,14 @@ +# Back-ups en onderhoud + +Doelen: `database`, `uploads`, `keys`, `full`. Archieven worden gecomprimeerd met zstd of brotli en staan onder `data/backups/`. Alleen de primaire beheerder kan downloaden. + +## CLI + +```bash +molodetz backups list +molodetz backups run --target database +molodetz backups prune --keep 14 --dry-run +molodetz system prune --dry-run +``` + +Het onderhoudsscript `bin/maintenance.sh` draait alle prunes en toont schijfgebruik voor en na. diff --git a/molodetz/templates/docs/prose/beheer.md b/molodetz/templates/docs/prose/beheer.md new file mode 100644 index 0000000..92d86b3 --- /dev/null +++ b/molodetz/templates/docs/prose/beheer.md @@ -0,0 +1,13 @@ +# Beheer en services + +Onder *Beheer, Services* staan alle achtergrondservices. Elke service heeft een overzicht, configuratie en logs. Starten, stoppen, nu draaien en logs wissen zijn databaseschrijfacties waar de service binnen een seconde op reageert. + +| Service | Doel | +|---------|------| +| presence | publiceert de online-set | +| housekeeping | ruimt verlopen sessies op, snoeit het auditlog | +| backup | verwerkt de back-upwachtrij en plant automatische back-ups | + +## Onderhoudsmodus + +Zet `maintenance_mode` op `1` onder *Instellingen*. Beheerders blijven alles zien. diff --git a/molodetz/templates/docs/prose/binnen.md b/molodetz/templates/docs/prose/binnen.md new file mode 100644 index 0000000..edbf017 --- /dev/null +++ b/molodetz/templates/docs/prose/binnen.md @@ -0,0 +1,5 @@ +# Binnenkomen + +Het formulier op [/binnen](/binnen) slaat een aanmelding op: naam, contact, optioneel een link naar je werk en een bericht. Beheerders krijgen een melding en zien de aanmelding onder *Beheer, Aanmeldingen*. + +Er is geen automatische toegang. retoor leest elke aanmelding en neemt zelf contact op. diff --git a/molodetz/templates/docs/prose/component-avatar.md b/molodetz/templates/docs/prose/component-avatar.md new file mode 100644 index 0000000..5fcc6dd --- /dev/null +++ b/molodetz/templates/docs/prose/component-avatar.md @@ -0,0 +1,5 @@ +# dp-avatar + +Toont een deterministische SVG-avatar plus presence-stip. Attribuut `seed`, `size`, `uid`. + +Light DOM, geen shadow DOM. Registreert zichzelf met `customElements.define`. diff --git a/molodetz/templates/docs/prose/component-chat.md b/molodetz/templates/docs/prose/component-chat.md new file mode 100644 index 0000000..c05092c --- /dev/null +++ b/molodetz/templates/docs/prose/component-chat.md @@ -0,0 +1,5 @@ +# dp-chat + +Eenvoudige log met invoerregel. Geeft een `dp-chat-send` event. + +Light DOM, geen shadow DOM. Registreert zichzelf met `customElements.define`. diff --git a/molodetz/templates/docs/prose/component-code.md b/molodetz/templates/docs/prose/component-code.md new file mode 100644 index 0000000..5f4d6e9 --- /dev/null +++ b/molodetz/templates/docs/prose/component-code.md @@ -0,0 +1,5 @@ +# dp-code + +Markeert een codeblok met highlight.js en voegt een Kopieer-knop toe. + +Light DOM, geen shadow DOM. Registreert zichzelf met `customElements.define`. diff --git a/molodetz/templates/docs/prose/component-content.md b/molodetz/templates/docs/prose/component-content.md new file mode 100644 index 0000000..e7865f3 --- /dev/null +++ b/molodetz/templates/docs/prose/component-content.md @@ -0,0 +1,5 @@ +# dp-content + +Rendert live markdown aan de clientkant via marked, daarna DOMPurify. Alleen voor live inhoud, zoals het editorvoorbeeld. + +Light DOM, geen shadow DOM. Registreert zichzelf met `customElements.define`. diff --git a/molodetz/templates/docs/prose/component-context-menu.md b/molodetz/templates/docs/prose/component-context-menu.md new file mode 100644 index 0000000..8082554 --- /dev/null +++ b/molodetz/templates/docs/prose/component-context-menu.md @@ -0,0 +1,5 @@ +# dp-context-menu + +Klein contextmenu met knoppen, gepositioneerd bij de cursor. + +Light DOM, geen shadow DOM. Registreert zichzelf met `customElements.define`. diff --git a/molodetz/templates/docs/prose/component-dialog.md b/molodetz/templates/docs/prose/component-dialog.md new file mode 100644 index 0000000..3053175 --- /dev/null +++ b/molodetz/templates/docs/prose/component-dialog.md @@ -0,0 +1,5 @@ +# dp-dialog + +Bevestigingsdialoog. Formulieren met `data-confirm` lopen hierlangs. + +Light DOM, geen shadow DOM. Registreert zichzelf met `customElements.define`. diff --git a/molodetz/templates/docs/prose/component-lightbox.md b/molodetz/templates/docs/prose/component-lightbox.md new file mode 100644 index 0000000..5604f45 --- /dev/null +++ b/molodetz/templates/docs/prose/component-lightbox.md @@ -0,0 +1,5 @@ +# dp-lightbox + +Volledig scherm voor afbeeldingen met `data-lightbox`. Escape of klik sluit. + +Light DOM, geen shadow DOM. Registreert zichzelf met `customElements.define`. diff --git a/molodetz/templates/docs/prose/component-title.md b/molodetz/templates/docs/prose/component-title.md new file mode 100644 index 0000000..91f814d --- /dev/null +++ b/molodetz/templates/docs/prose/component-title.md @@ -0,0 +1,5 @@ +# dp-title + +Inline-variant van dp-content voor titels. + +Light DOM, geen shadow DOM. Registreert zichzelf met `customElements.define`. diff --git a/molodetz/templates/docs/prose/component-toast.md b/molodetz/templates/docs/prose/component-toast.md new file mode 100644 index 0000000..e2ee6e8 --- /dev/null +++ b/molodetz/templates/docs/prose/component-toast.md @@ -0,0 +1,5 @@ +# dp-toast + +Host voor korte meldingen. Gebruik `Toast.flash(el, bericht)` of `app.toast(bericht)`. + +Light DOM, geen shadow DOM. Registreert zichzelf met `customElements.define`. diff --git a/molodetz/templates/docs/prose/component-upload.md b/molodetz/templates/docs/prose/component-upload.md new file mode 100644 index 0000000..150034e --- /dev/null +++ b/molodetz/templates/docs/prose/component-upload.md @@ -0,0 +1,5 @@ +# dp-upload + +Sleepvlak voor bestanden. Geeft een `dp-files` event met de gekozen bestanden. + +Light DOM, geen shadow DOM. Registreert zichzelf met `customElements.define`. diff --git a/molodetz/templates/docs/prose/frontend.md b/molodetz/templates/docs/prose/frontend.md new file mode 100644 index 0000000..1e2c9df --- /dev/null +++ b/molodetz/templates/docs/prose/frontend.md @@ -0,0 +1,7 @@ +# Frontend en componenten + +ES6-modules, één klasse per bestand, geen framework en geen bundler. `Application.js` maakt alle singletons aan en zet `window.app`. + +Componenten zijn custom elements met het voorvoegsel `dp-`, zonder shadow DOM. Zie de componentpagina's in de zijbalk. + +Clientrendering met marked en DOMPurify is alleen voor live inhoud: het voorbeeld in de editor. Gepubliceerde berichten worden op de server gerenderd. diff --git a/molodetz/templates/docs/prose/juridisch.md b/molodetz/templates/docs/prose/juridisch.md new file mode 100644 index 0000000..463b0fb --- /dev/null +++ b/molodetz/templates/docs/prose/juridisch.md @@ -0,0 +1,6 @@ +# Voorwaarden en privacy + +- [Voorwaarden](/voorwaarden) +- [Privacy](/privacy) + +Molodetz heeft geen advertenties, geen tracking over apps heen, geen betalingen en geen sociale login. diff --git a/molodetz/templates/docs/prose/schrijven.md b/molodetz/templates/docs/prose/schrijven.md new file mode 100644 index 0000000..2511209 --- /dev/null +++ b/molodetz/templates/docs/prose/schrijven.md @@ -0,0 +1,17 @@ +# Schrijven op de rol + +Berichten zijn markdown en staan in SQLite. Alleen beheerders schrijven. Een bericht heeft een titel, een onderwerp (`rol` of `standaard`), een status (`draft` of `published`) en optioneel de markering *placeholder*. + +## Markdown + +De server rendert met GFM: tabellen, doorhalen, harde regeleinden. HTML in de tekst wordt ge-escaped. Links mogen alleen `http`, `https`, `mailto` en `tel` zijn. Emoji schrijf je als `:shortcode:`, bijvoorbeeld `:fire:`. + +Een losse afbeeldings-URL wordt een afbeelding, `@naam` wordt een link naar het profiel. + +## Placeholders + +De eerste notities zijn overgenomen uit de oude Molodetz-pagina en dragen het label *Placeholder*. retoor vervangt ze door eigen verhalen. Haal het vinkje weg bij het bewerken zodra een bericht echt is. + +## Slugs + +Een slug is permanent: titel plus de laatste twaalf tekens van de uid. Bewerken verandert de slug niet. diff --git a/molodetz/templates/docs/prose/welkom.md b/molodetz/templates/docs/prose/welkom.md new file mode 100644 index 0000000..fb7ebf7 --- /dev/null +++ b/molodetz/templates/docs/prose/welkom.md @@ -0,0 +1,15 @@ +# Welkom bij Molodetz + +Molodetz is een rustige blogrol. Notities over het werk, het afmaken en het oordeel. Deze documentatie beschrijft hoe de site werkt, hoe je de API gebruikt en hoe beheer de boel draaiende houdt. + +## Wat er is + +- **Rol**: korte notities, nieuwste eerst. +- **Standaard**: waar Molodetz op staat. +- **Flyers** en **Memes**: twee galerijen. +- **Mensen**: wie er schrijft. +- **Binnen**: het formulier om mee te schrijven. + +## Waar te beginnen + +Lees [Schrijven op de rol](/docs/schrijven) voor de inhoud, [API en authenticatie](/docs/api) voor integraties, en [Architectuur](/docs/architectuur) als je wilt weten hoe het gebouwd is. diff --git a/molodetz/templates/docs/search.html b/molodetz/templates/docs/search.html new file mode 100644 index 0000000..2a13ce1 --- /dev/null +++ b/molodetz/templates/docs/search.html @@ -0,0 +1,14 @@ + +{% extends "docs/base_docs.html" %} +{% block docs_content %} +

    Zoeken

    +{% if query %}

    {{ results | length }} resultaten voor {{ query }}

    {% endif %} +{% for hit in results %} +
    + {{ hit.title }} {{ hit.score }} +

    {{ hit.snippet | e | replace('[[', '') | replace(']]', '') | safe }}

    +
    +{% else %} +{% if query %}

    Niets gevonden.

    {% endif %} +{% endfor %} +{% endblock %} diff --git a/molodetz/templates/error.html b/molodetz/templates/error.html new file mode 100644 index 0000000..543514e --- /dev/null +++ b/molodetz/templates/error.html @@ -0,0 +1,9 @@ + +{% extends "base.html" %} +{% block content %} +
    +
    {{ status_code }}
    +

    {{ message }}

    + +
    +{% endblock %} diff --git a/molodetz/templates/gallery.html b/molodetz/templates/gallery.html new file mode 100644 index 0000000..088ad5d --- /dev/null +++ b/molodetz/templates/gallery.html @@ -0,0 +1,19 @@ + +{% extends "base.html" %} +{% block main_class %}wide{% endblock %} +{% block extra_head %}{% endblock %} +{% block content %} +
    + {{ 'Gezet in serif' if kind == 'flyer' else 'Lichtelijk provocerend' }} +

    {{ title }}

    +
    + + +{% endblock %} diff --git a/molodetz/templates/home.html b/molodetz/templates/home.html new file mode 100644 index 0000000..0f01f84 --- /dev/null +++ b/molodetz/templates/home.html @@ -0,0 +1,38 @@ + +{% extends "base.html" %} +{% block content %} +
    + Een blogrol +

    Molodetz

    +

    Een kleine kring die schrijft over het werk. De standaard ligt boven wat nu gewoon lijkt. Geen cursus. Geen geschreeuw. Wel een mening.

    +
    + +
    + Laatst op de rol + Alles +
    +{% for post in posts %} +{% set _card = post %} +{% include "_post_card.html" %} +{% else %} +

    Nog niets gepubliceerd.

    +{% endfor %} + +{% if featured_flyer %} + +{% endif %} + +
    + Binnen +

    De rol is open voor mensen, niet voor een publiek. Wie mee wil schrijven, zegt het.

    + +
    +{% endblock %} diff --git a/molodetz/templates/listing.html b/molodetz/templates/listing.html new file mode 100644 index 0000000..9a0e39b --- /dev/null +++ b/molodetz/templates/listing.html @@ -0,0 +1,17 @@ + +{% extends "base.html" %} +{% block content %} +
    + {{ 'De rol' if topic == 'rol' else 'De standaard' }} +

    {{ title }}

    +

    {% if topic == 'rol' %}Korte notities. Eén gedachte per keer, scherp genoeg om op terug te komen.{% else %}Waar Molodetz op staat, en waarom de keuzes vastliggen.{% endif %}

    +
    +{% for post in posts %} +{% set _card = post %} +{% include "_post_card.html" %} +{% else %} +

    Hier staat nog niets.

    +{% endfor %} +{% set _next_url = next_url %} +{% include "_load_more.html" %} +{% endblock %} diff --git a/molodetz/templates/mensen.html b/molodetz/templates/mensen.html new file mode 100644 index 0000000..d7cd2f7 --- /dev/null +++ b/molodetz/templates/mensen.html @@ -0,0 +1,23 @@ + +{% extends "base.html" %} +{% block content %} +
    + Mensen +

    Wie schrijft

    +

    Een rol van mensen, geen ledenlijst. Wie binnenkomt, staat hier zodra er geschreven is.

    +
    +{% for person in people %} +
    + {% set _user = person %}{% set _size = 48 %} + {% include "_avatar_link.html" %} +
    {% include "_user_link.html" %}{{ person.bio or 'Schrijft op de rol.' }}
    + {{ person.post_count }} {{ 'bericht' if person.post_count == 1 else 'berichten' }} +
    +{% else %} +

    Nog niemand op de rol.

    +{% endfor %} +
    +

    De volgende schrijft hier zodra die binnen is.

    + +
    +{% endblock %} diff --git a/molodetz/templates/notifications.html b/molodetz/templates/notifications.html new file mode 100644 index 0000000..beddd2c --- /dev/null +++ b/molodetz/templates/notifications.html @@ -0,0 +1,16 @@ + +{% extends "base.html" %} +{% block content %} +
    Account

    Meldingen

    +{% if unread %} +
    +{% endif %} +{% for item in notifications %} +
    + + {{ item.message }} +
    +{% else %} +

    Geen meldingen.

    +{% endfor %} +{% endblock %} diff --git a/molodetz/templates/person.html b/molodetz/templates/person.html new file mode 100644 index 0000000..a2941cb --- /dev/null +++ b/molodetz/templates/person.html @@ -0,0 +1,19 @@ + +{% extends "base.html" %} +{% block content %} +
    + +

    {{ person.username }}

    +

    {{ person.bio or 'Schrijft op de rol.' }}

    +
    +
    Berichten{{ person.post_count }}
    +{% for post in posts %} +{% set _card = post %} +{% include "_post_card.html" %} +{% else %} +

    Nog niets gepubliceerd.

    +{% endfor %} +{% endblock %} diff --git a/molodetz/templates/post.html b/molodetz/templates/post.html new file mode 100644 index 0000000..da52eb6 --- /dev/null +++ b/molodetz/templates/post.html @@ -0,0 +1,29 @@ + +{% extends "base.html" %} +{% block content %} +{% set viewer = current_user(request) %} +
    +
    + {% set _post = post %} + {% include "_content_header.html" %} +

    {{ render_title(post.title) }}

    + {% if author %} + + {% endif %} +
    +
    + {{ render_content(post.body) | safe }} +
    + {% if viewer and is_admin(viewer) %} + + {% endif %} +
    +
    + Verder lezen + +
    +{% endblock %} diff --git a/molodetz/templates/privacy.html b/molodetz/templates/privacy.html new file mode 100644 index 0000000..d2c6554 --- /dev/null +++ b/molodetz/templates/privacy.html @@ -0,0 +1,17 @@ + +{% extends "base.html" %} +{% block content %} +
    Juridisch

    Privacy

    +
    +

    Molodetz verzamelt zo weinig mogelijk.

    +

    Aanmeldingen via Binnen

    +

    Als je het formulier Ik schrijf mee invult, bewaren we je naam, je contactgegeven, optioneel een link naar je werk en je bericht. Daarnaast bewaren we een niet-omkeerbare hash van je IP-adres om misbruik te herkennen. Alleen beheerders zien dit. We delen het met niemand.

    +

    Als beheer je link controleert, haalt de server eenmalig de titel van die pagina op. Er gaat daarbij niets van jou naar die site behalve het verzoek zelf.

    +

    Bezoek

    +

    We tellen per dag hoe vaak een pad wordt opgevraagd, zonder IP-adres of identiteit. Er zijn geen cookies voor lezers. Er zijn geen externe scripts, lettertypen of trackers.

    +

    Accounts

    +

    Wie inlogt, krijgt een httponly sessiecookie. We bewaren je laatste activiteit voor de online-indicator en een auditlog van beheeracties.

    +

    Verwijderen

    +

    Wil je dat je aanmelding verdwijnt? Vraag het aan retoor via retoor@molodetz.nl. Verwijderen gebeurt eerst zacht en daarna definitief.

    +
    +{% endblock %} diff --git a/molodetz/templates/profile/api_key.html b/molodetz/templates/profile/api_key.html new file mode 100644 index 0000000..ea6d28c --- /dev/null +++ b/molodetz/templates/profile/api_key.html @@ -0,0 +1,10 @@ + +{% extends "base.html" %} +{% block content %} +
    Account

    API-sleutel

    +

    Gebruik deze sleutel als X-API-KEY header of als Authorization: Bearer. Vernieuwen maakt de oude direct ongeldig.

    +
    {{ api_key }}
    +
    + +
    +{% endblock %} diff --git a/molodetz/templates/voorwaarden.html b/molodetz/templates/voorwaarden.html new file mode 100644 index 0000000..f9bc9d1 --- /dev/null +++ b/molodetz/templates/voorwaarden.html @@ -0,0 +1,18 @@ + +{% extends "base.html" %} +{% block content %} +{% set viewer = current_user(request) %} +
    Juridisch

    Voorwaarden

    Versie {{ terms_version }}

    +
    +

    Molodetz is een kleine, besloten blogrol. Lezen is vrij. Schrijven gebeurt door mensen die retoor op de rol heeft gezet.

    +

    Wat op de rol staat is van de auteur. Overnemen mag met bronvermelding en een link terug.

    +

    Er is geen advertentie, geen tracking over apps heen, geen betaalmuur en geen sociale login. Accounts loggen in met een sessie op deze site.

    +

    Misbruik van formulieren, automatische aanmeldingen of pogingen om beheer binnen te komen worden gelogd en geweigerd.

    +
    +{% if viewer %} +
    + + +
    +{% endif %} +{% endblock %} diff --git a/molodetz/templating.py b/molodetz/templating.py new file mode 100644 index 0000000..2f1251c --- /dev/null +++ b/molodetz/templating.py @@ -0,0 +1,110 @@ +# retoor +import time +from datetime import datetime, timezone + +from fastapi.templating import Jinja2Templates +from markupsafe import Markup, escape + +from molodetz import config +from molodetz.assets import static_url +from molodetz.avatar import avatar_url as build_avatar_url +from molodetz.constants import MEDIA_KINDS, TOPICS +from molodetz.database import get_setting, parse_iso, unread_count +from molodetz.rendering import content_preview, plain_title, render_content, render_title +from molodetz.utils.auth import avatar_seed, get_current_user, is_admin, is_owner, is_primary_admin +from molodetz.utils.presence import online_uids + +templates = Jinja2Templates(directory=str(config.TEMPLATES_DIR)) +templates.env.trim_blocks = True +templates.env.lstrip_blocks = True +templates.env.auto_reload = config.TEMPLATE_AUTO_RELOAD + + +def nav_active(request, *prefixes, exact=False, css_class="active"): + path = request.url.path + for prefix in prefixes: + if exact and path == prefix: + return css_class + if not exact and (path == prefix or path.startswith(prefix.rstrip("/") + "/")): + return css_class + return "" + + +def format_date(iso): + parsed = parse_iso(iso) + return parsed.strftime("%d/%m/%Y") if parsed else "" + + +def format_datetime(iso): + parsed = parse_iso(iso) + return parsed.strftime("%d/%m/%Y %H:%M") if parsed else "" + + +def local_dt(iso, mode="datetime"): + if not iso: + return "" + fallback = format_date(iso) if mode == "date" else format_datetime(iso) + return Markup(f'') + + +def dt_ago(iso): + return local_dt(iso, "ago") + + +def avatar_for(user, size=64): + return build_avatar_url(avatar_seed(user), size) + + +def response_time(request): + start = getattr(request.state, "request_start", None) + if start is None: + return "" + return f"{(time.perf_counter() - start) * 1000:.1f}ms" + + +def current_user(request): + return get_current_user(request) + + +def unread_for(request): + user = get_current_user(request) + return unread_count(user["uid"]) if user else 0 + + +def is_online(uid): + return uid in online_uids() + + +def setting(key, default=None): + return get_setting(key, default) + + +def current_year(): + return datetime.now(timezone.utc).year + + +templates.env.globals.update( + static_url=static_url, + nav_active=nav_active, + render_content=render_content, + render_title=render_title, + content_preview=content_preview, + plain_title=plain_title, + local_dt=local_dt, + dt_ago=dt_ago, + format_date=format_date, + avatar_url=avatar_for, + is_admin=is_admin, + is_owner=is_owner, + is_primary_admin=is_primary_admin, + current_user=current_user, + response_time=response_time, + unread_count=unread_for, + is_online=is_online, + setting=setting, + current_year=current_year, + static_version=config.STATIC_VERSION, + app_title=config.APP_TITLE, + TOPICS=TOPICS, + MEDIA_KINDS=MEDIA_KINDS, +) diff --git a/molodetz/utils/__init__.py b/molodetz/utils/__init__.py new file mode 100644 index 0000000..95ee7c3 --- /dev/null +++ b/molodetz/utils/__init__.py @@ -0,0 +1 @@ +# retoor diff --git a/molodetz/utils/audit.py b/molodetz/utils/audit.py new file mode 100644 index 0000000..5fd5b64 --- /dev/null +++ b/molodetz/utils/audit.py @@ -0,0 +1,81 @@ +# retoor +import json +import logging + +from molodetz.database import generate_uid, insert_audit, now_iso +from molodetz.services.queue import background +from molodetz.utils.request_meta import client_ip + +logger = logging.getLogger(__name__) + +CATEGORY_BY_PREFIX = { + "auth": "auth", + "admin": "admin", + "post": "content", + "join": "community", + "security": "security", + "service": "service", + "backup": "service", + "system": "system", + "notification": "system", + "profile": "account", +} + + +def category_for(event_key): + return CATEGORY_BY_PREFIX.get(event_key.split(".", 1)[0], "other") + + +def _build(event_key, actor_kind, actor_uid, origin, result, ip, user_agent, message, payload, targets): + uid = generate_uid() + row = { + "uid": uid, + "event_key": event_key, + "category": category_for(event_key), + "actor_kind": actor_kind, + "actor_uid": actor_uid, + "origin": origin, + "result": result, + "ip": ip, + "user_agent": (user_agent or "")[:300] or None, + "message": message, + "payload": json.dumps(payload or {}, default=str), + "created_at": now_iso(), + } + links = [{"audit_uid": uid, "target_type": t, "target_uid": u} for t, u in (targets or [])] + return row, links + + +def record(request, event_key, result="ok", message=None, payload=None, targets=None, user=None): + try: + if user is None: + user = getattr(request.state, "user_cached", None) if request is not None else None + ip = client_ip(request) if request is not None else None + agent = request.headers.get("user-agent") if request is not None else None + row, links = _build( + event_key, + "user" if user else "guest", + user["uid"] if user else None, + "web", + result, + ip, + agent, + message, + payload, + targets, + ) + background.submit(insert_audit, row, links) + return row["uid"] + except Exception: + logger.exception("audit record failed for %s", event_key) + return None + + +def record_system(event_key, actor_kind="system", origin="system", result="ok", message=None, payload=None, targets=None, actor_uid=None): + try: + row, links = _build(event_key, actor_kind, actor_uid, origin, result, None, None, message, payload, targets) + background.submit(insert_audit, row, links) + return row["uid"] + except Exception: + logger.exception("audit record_system failed for %s", event_key) + return None diff --git a/molodetz/utils/auth.py b/molodetz/utils/auth.py new file mode 100644 index 0000000..f8401ea --- /dev/null +++ b/molodetz/utils/auth.py @@ -0,0 +1,222 @@ +# retoor +import asyncio +import base64 +import hashlib +from datetime import datetime, timezone + +from fastapi import HTTPException +from passlib.hash import pbkdf2_sha256 + +from molodetz.cache import TTLCache +from molodetz.constants import ROLE_ADMIN +from molodetz.database import ( + admin_uids_ordered, + bump_cache_version, + get_session, + get_user, + get_user_by_api_key, + get_user_by_email, + get_user_by_username, + hard_delete_session, + parse_iso, + sync_local_cache, +) +from molodetz.responses import wants_json +from molodetz.utils.audit import record + +_UNSET = object() +_user_cache = TTLCache(ttl=300, max_size=2000) + +USERNAME_PATTERN = r"^[A-Za-z0-9_-]{3,32}$" + + +class GuardRedirect(Exception): + def __init__(self, url, status_code=303): + self.url = url + self.status_code = status_code + + +def hash_password(password): + return pbkdf2_sha256.hash(password) + + +def verify_password(password, password_hash): + if not password_hash: + return False + try: + return pbkdf2_sha256.verify(password, password_hash) + except (ValueError, TypeError): + return False + + +async def hash_password_async(password): + return await asyncio.to_thread(hash_password, password) + + +async def verify_password_async(password, password_hash): + return await asyncio.to_thread(verify_password, password, password_hash) + + +def is_account_active(row): + if not row: + return False + value = row.get("is_active") + return value is None or bool(value) + + +def is_admin(user): + return bool(user) and user.get("role") == ROLE_ADMIN + + +def is_primary_admin(user): + if not is_admin(user): + return False + ordered = admin_uids_ordered() + return bool(ordered) and ordered[0] == user["uid"] + + +def is_senior_admin(actor, target): + if not is_admin(target) or not actor: + return False + ordered = admin_uids_ordered() + if target["uid"] not in ordered or actor["uid"] not in ordered: + return False + return ordered.index(target["uid"]) < ordered.index(actor["uid"]) + + +def is_owner(item, user): + return bool(item) and bool(user) and item.get("user_uid") == user.get("uid") + + +def avatar_seed(user): + if not user: + return "guest" + return user.get("avatar_seed") or user.get("username") or "guest" + + +def invalidate_user_cache(propagate=True, key=None): + if propagate: + bump_cache_version("auth", _user_cache) + elif key is not None: + _user_cache.pop(key) + else: + _user_cache.clear() + + +def _resolve_session(token): + row = get_session(token) + if not row: + return None + expires = parse_iso(row.get("expires_at")) + if expires is None or expires < datetime.now(timezone.utc): + hard_delete_session(row["id"]) + return None + user = get_user(row["user_uid"]) + if not is_account_active(user): + hard_delete_session(row["id"]) + return None + return user + + +def _resolve_key(credential): + user = get_user_by_api_key(credential) + if is_account_active(user): + return user, f"k:{credential}" + return None, None + + +def _resolve_basic(header_value): + try: + decoded = base64.b64decode(header_value).decode("utf-8") + except (ValueError, UnicodeDecodeError): + return None + identifier, _, password = decoded.partition(":") + user = get_user_by_username(identifier) or get_user_by_email(identifier.lower()) + if not is_account_active(user) or not verify_password(password, user.get("password_hash")): + return None + return user + + +def _lookup(key, loader): + cached = _user_cache.get(key, _UNSET) + if cached is not _UNSET: + return cached + user = loader() + _user_cache.set(key, user) + return user + + +def get_current_user(request): + memo = getattr(request.state, "user_cached", _UNSET) + if memo is not _UNSET: + return memo + sync_local_cache("auth", _user_cache) + user = None + token = request.cookies.get("session") + if token and len(token) == 64: + user = _lookup(token, lambda: _resolve_session(token)) + if user is None: + api_key = (request.headers.get("x-api-key") or "").strip() + if api_key: + user = _lookup(f"k:{api_key}", lambda: _resolve_key(api_key)[0]) + authorization = request.headers.get("authorization") or "" + if user is None and authorization.lower().startswith("bearer "): + credential = authorization[7:].strip() + user = _lookup(f"k:{credential}", lambda: _resolve_key(credential)[0]) + if user is None and authorization.lower().startswith("basic "): + raw = authorization[6:].strip() + key = "b:" + hashlib.sha256(raw.encode()).hexdigest() + user = _lookup(key, lambda: _resolve_basic(raw)) + request.state.user_cached = user + return user + + +def has_credentials(request): + return bool(request.headers.get("x-api-key") or request.headers.get("authorization")) + + +def refuse_suspended(request, user): + if request.method not in ("POST", "PUT", "PATCH", "DELETE"): + return + path = request.url.path + if path.startswith("/auth"): + return + until = parse_iso(user.get("suspended_until")) + if until and until > datetime.now(timezone.utc): + reason = user.get("suspend_reason") or "" + record(request, "security.suspension.refused", result="denied", user=user) + raise HTTPException(403, f"Account suspended. {reason}".strip()) + + +def require_user(request): + user = get_current_user(request) + if user is None: + record(request, "security.authorization.denied", result="denied", message="guest") + if has_credentials(request) or wants_json(request): + raise HTTPException(401, "Invalid credentials") + raise GuardRedirect(f"/auth/login?next={request.url.path}", 303) + refuse_suspended(request, user) + return user + + +def require_admin(request): + user = require_user(request) + if not is_admin(user): + record(request, "security.authorization.denied", result="denied", message="admin required", user=user) + if wants_json(request) or has_credentials(request): + raise HTTPException(403, "Administrator access required") + raise GuardRedirect("/", 303) + return user + + +def require_user_api(request): + user = get_current_user(request) + if user is None: + record(request, "security.authorization.denied", result="denied", message="api guest") + raise HTTPException(401, "Authentication required") + refuse_suspended(request, user) + return user + + +def not_found(detail="Niet gevonden"): + raise HTTPException(404, detail) diff --git a/molodetz/utils/notifications.py b/molodetz/utils/notifications.py new file mode 100644 index 0000000..bee3ef6 --- /dev/null +++ b/molodetz/utils/notifications.py @@ -0,0 +1,13 @@ +# retoor +from molodetz.database import insert_notification +from molodetz.services.queue import background +from molodetz.utils.audit import record_system + + +def _persist(user_uid, kind, message, url): + insert_notification(user_uid, kind, message, url) + record_system("notification.create", payload={"kind": kind, "user_uid": user_uid}) + + +def create_notification(user_uid, kind, message, url="/"): + background.submit(_persist, user_uid, kind, message, url) diff --git a/molodetz/utils/presence.py b/molodetz/utils/presence.py new file mode 100644 index 0000000..67f27a9 --- /dev/null +++ b/molodetz/utils/presence.py @@ -0,0 +1,53 @@ +# retoor +import json +import threading +import time +from datetime import datetime, timedelta, timezone + +from molodetz import config +from molodetz.database import db, get_service_state, now_iso, update_user + +_lock = threading.Lock() +_last_write = {} + + +def touch(uid): + if not uid: + return + now = time.monotonic() + throttle = max(1, config.PRESENCE_TIMEOUT_SECONDS // 2) + with _lock: + if now - _last_write.get(uid, 0) < throttle: + return + _last_write[uid] = now + update_user(uid, last_seen_at=now_iso()) + + +def compute_roster(): + if "users" not in db.tables: + return [] + window = config.PRESENCE_TIMEOUT_SECONDS + config.PRESENCE_ONLINE_MARGIN_SECONDS + cutoff = (datetime.now(timezone.utc) - timedelta(seconds=window)).isoformat() + rows = db.query( + "SELECT uid FROM users WHERE last_seen_at >= :cutoff AND deleted_at IS NULL " + "ORDER BY last_seen_at DESC LIMIT :limit", + cutoff=cutoff, + limit=config.PRESENCE_TRACK_LIMIT, + ) + return [row["uid"] for row in rows] + + +def published_roster(): + row = get_service_state("presence") + if row and row.get("metrics"): + try: + metrics = json.loads(row["metrics"]) + except ValueError: + metrics = {} + if "roster" in metrics: + return metrics["roster"] + return compute_roster() + + +def online_uids(limit=None): + return published_roster()[: limit or config.PRESENCE_ONLINE_LIMIT] diff --git a/molodetz/utils/request_meta.py b/molodetz/utils/request_meta.py new file mode 100644 index 0000000..420d99c --- /dev/null +++ b/molodetz/utils/request_meta.py @@ -0,0 +1,39 @@ +# retoor +from urllib.parse import urlsplit + + +def client_ip(request): + real = request.headers.get("x-real-ip") + if real: + return real.strip() + forwarded = request.headers.get("x-forwarded-for") + if forwarded: + return forwarded.split(",")[0].strip() + return request.client.host if request.client else "unknown" + + +def safe_next(value, default="/"): + if not value or not isinstance(value, str): + return default + if not value.startswith("/") or value.startswith("//") or value.startswith("/\\"): + return default + if "\\" in value or "\r" in value or "\n" in value: + return default + return value + + +def redirect_back(request, default="/"): + referer = request.headers.get("referer") + if referer: + parts = urlsplit(referer) + if parts.netloc == request.url.netloc: + target = parts.path + (f"?{parts.query}" if parts.query else "") + return safe_next(target, default) + return safe_next(request.query_params.get("next"), default) + + +def is_secure_request(request): + proto = request.headers.get("x-forwarded-proto") + if proto: + return proto.split(",")[0].strip() == "https" + return request.url.scheme == "https" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ba7b29f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,65 @@ +# retoor +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "molodetz" +version = "1.0.0" +description = "Molodetz, a calm community blog roll." +readme = "README.md" +requires-python = ">=3.12" +authors = [{ name = "retoor", email = "retoor@molodetz.nl" }] +dependencies = [ + "fastapi>=0.115", + "jinja2>=3.1", + "uvicorn[standard]>=0.30", + "python-multipart>=0.0.9", + "pydantic>=2.7", + "dataset>=1.6.2", + "sqlalchemy>=1.4,<2.1", + "passlib>=1.7.4", + "httpx[http2]>=0.27", + "curl_cffi>=0.7", + "mistune>=3.0", + "emoji>=2.12", + "Pillow>=10.3", + "imagehash>=4.3", + "pypdf>=4.2", + "sqlglot>=25.0", + "defusedxml>=0.7", + "beautifulsoup4>=4.12", + "lxml>=5.2", + "uuid_utils>=0.9", + "brotli>=1.1", + "zstandard>=0.22", + "aiofiles>=23.2", + "python-dotenv>=1.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.2", + "playwright>=1.44", + "locust>=2.29", + "coverage>=7.5", + "pyflakes>=3.2", +] + +[project.scripts] +molodetz = "molodetz.cli:main" + +[tool.setuptools.packages.find] +include = ["molodetz*"] + +[tool.setuptools.package-data] +molodetz = ["templates/**/*", "static/**/*", "docs_api/*.py"] + +[tool.pytest.ini_options] +testpaths = ["tests/unit", "tests/api", "tests/e2e"] +python_files = ["*.py"] +addopts = "-rf -p no:xdist" +filterwarnings = [ + "ignore::DeprecationWarning", + "ignore::PendingDeprecationWarning", +] diff --git a/sitecustomize.py b/sitecustomize.py new file mode 100644 index 0000000..f9250e7 --- /dev/null +++ b/sitecustomize.py @@ -0,0 +1,10 @@ +# retoor +import os + +if os.environ.get("COVERAGE_PROCESS_START"): + try: + import coverage + + coverage.process_startup() + except ImportError: + pass diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..95ee7c3 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# retoor diff --git a/tests/api/__init__.py b/tests/api/__init__.py new file mode 100644 index 0000000..95ee7c3 --- /dev/null +++ b/tests/api/__init__.py @@ -0,0 +1 @@ +# retoor diff --git a/tests/api/test_admin_posts.py b/tests/api/test_admin_posts.py new file mode 100644 index 0000000..29d1287 --- /dev/null +++ b/tests/api/test_admin_posts.py @@ -0,0 +1,50 @@ +# retoor +JSON = {"Accept": "application/json"} + + +def _create(admin, **overrides): + payload = {"title": "Een echte notitie", "body": "Tekst met **nadruk** en `code`.", "topic": "rol", "status": "draft"} + payload.update(overrides) + response = admin.post("/admin/posts", json=payload, headers=JSON) + assert response.status_code == 200, response.text + return response.json()["data"] + + +def test_create_draft_is_hidden_then_published(admin, anon): + data = _create(admin, title="Concept dat later live gaat") + assert anon.get(f"/posts/{data['slug']}").status_code == 404 + assert admin.post(f"/admin/posts/{data['uid']}/publish", headers=JSON).status_code == 200 + page = anon.get(f"/posts/{data['slug']}") + assert page.status_code == 200 + assert "nadruk" in page.text + + +def test_edit_post(admin, anon): + data = _create(admin, title="Te bewerken notitie", status="published") + response = admin.post(f"/admin/posts/{data['uid']}", json={"title": "Bewerkte notitie", "body": "Nieuwe tekst hier.", "topic": "standaard", "status": "published"}, headers=JSON) + assert response.status_code == 200, response.text + posts = anon.get("/standaard", headers=JSON).json()["posts"] + assert any(post["title"] == "Bewerkte notitie" for post in posts) + + +def test_delete_goes_to_trash(admin, anon): + data = _create(admin, title="Weg ermee notitie", status="published") + assert admin.post(f"/admin/posts/{data['uid']}/delete", headers=JSON).status_code == 200 + assert anon.get(f"/posts/{data['slug']}").status_code == 404 + trash = admin.get("/admin/trash", headers=JSON).json()["items"] + assert any(item["uid"] == data["uid"] for item in trash) + + +def test_validation_error(admin): + response = admin.post("/admin/posts", json={"title": "x"}, headers=JSON) + assert response.status_code == 422 + + +def test_html_form_post(admin): + response = admin.post("/admin/posts", data={"title": "Via formulier", "body": "Formuliertekst hier.", "topic": "rol", "status": "draft"}) + assert response.status_code in (302, 303) + + +def test_member_cannot_write(member): + response = member.post("/admin/posts", json={"title": "Niet toegestaan", "body": "Tekst hier.", "topic": "rol"}, headers=JSON) + assert response.status_code == 403 diff --git a/tests/api/test_auth.py b/tests/api/test_auth.py new file mode 100644 index 0000000..c536f82 --- /dev/null +++ b/tests/api/test_auth.py @@ -0,0 +1,35 @@ +# retoor +from tests.conftest import ADMIN_PASSWORD, ADMIN_USERNAME + + +def test_login_wrong_password(anon): + response = anon.post("/auth/login", data={"username": ADMIN_USERNAME, "password": "verkeerd-wachtwoord"}) + assert response.status_code in (400, 401, 200) + assert "session" not in "".join(response.cookies.keys()).lower() or response.status_code != 302 + + +def test_login_and_logout(anon): + response = anon.post("/auth/login", data={"username": ADMIN_USERNAME, "password": ADMIN_PASSWORD}) + assert response.status_code in (302, 303) + assert anon.get("/admin").status_code == 200 + cookie = response.headers.get("set-cookie", "").lower() + assert "httponly" in cookie and "samesite=lax" in cookie + assert anon.post("/auth/logout").status_code in (302, 303) + assert anon.get("/admin").status_code in (302, 303, 401) + + +def test_admin_redirects_anonymous(anon): + response = anon.get("/admin") + assert response.status_code in (302, 303) + assert "/auth/login" in response.headers["location"] + + +def test_api_key_header(admin, anon): + key = admin.get("/profile/api-key", headers={"Accept": "application/json"}).json()["api_key"] + assert anon.get("/admin", headers={"X-API-KEY": key, "Accept": "application/json"}).status_code == 200 + assert anon.get("/admin", headers={"Authorization": f"Bearer {key}", "Accept": "application/json"}).status_code == 200 + + +def test_no_jwt_anywhere(admin): + response = admin.get("/profile/api-key", headers={"Accept": "application/json"}) + assert response.json()["api_key"].count(".") == 0 diff --git a/tests/api/test_auth_truth.py b/tests/api/test_auth_truth.py new file mode 100644 index 0000000..fc8172b --- /dev/null +++ b/tests/api/test_auth_truth.py @@ -0,0 +1,30 @@ +# retoor +import pytest + +from molodetz.docs_api import all_endpoints + +ENDPOINTS = [item for item in all_endpoints() if "logout" not in item["path"]] +JSON = {"Accept": "application/json"} + + +def _call(client, item): + path = item["probe_path"] + if item["method"] == "GET": + return client.get(path, headers=JSON) + return client.post(path, json=item["probe_body"] or {}, headers=JSON) + + +@pytest.mark.parametrize("item", ENDPOINTS, ids=lambda item: f"{item['method']} {item['path']}") +def test_documented_auth_matches_reality(item, anon, member, admin): + anon_status = _call(anon, item).status_code + member_status = _call(member, item).status_code + admin_status = _call(admin, item).status_code + if item["auth"] == "public": + assert anon_status not in (401, 403) + elif item["auth"] == "member": + assert anon_status in (401, 303, 302) + assert member_status not in (401, 403) + else: + assert anon_status in (401, 303, 302) + assert member_status == 403 + assert admin_status not in (401, 403) diff --git a/tests/api/test_binnen.py b/tests/api/test_binnen.py new file mode 100644 index 0000000..451e2d2 --- /dev/null +++ b/tests/api/test_binnen.py @@ -0,0 +1,35 @@ +# retoor +JSON = {"Accept": "application/json"} + + +def test_join_request_is_stored_and_visible_to_admin(anon, admin): + response = anon.post("/binnen", data={"name": "Schrijver Een", "contact": "schrijver@example.invalid", "repo_url": "https://example.com/repo", "message": "Ik wil meeschrijven.", "website": ""}) + assert response.status_code in (302, 303) + assert "ok=1" in response.headers["location"] + rows = admin.get("/admin/joins", headers=JSON).json()["requests"] + row = next(row for row in rows if row["name"] == "Schrijver Een") + assert row["contact"] == "schrijver@example.invalid" + assert row["repo_url"].startswith("https://example.com/repo") + status = admin.post(f"/admin/joins/{row['uid']}/status", json={"status": "contacted"}, headers=JSON) + assert status.status_code == 200 + + +def test_join_json_api(anon): + response = anon.post("/binnen", json={"name": "Schrijver Twee", "contact": "@twee"}, headers=JSON) + assert response.status_code == 200 + assert response.json()["ok"] is True + + +def test_honeypot_is_not_stored(anon, admin): + anon.post("/binnen", data={"name": "Robot Bot", "contact": "bot@example.invalid", "website": "spam"}) + rows = admin.get("/admin/joins", headers=JSON).json()["requests"] + assert not any(row["name"] == "Robot Bot" for row in rows) + + +def test_join_validation(anon): + response = anon.post("/binnen", json={"name": "x", "contact": ""}, headers=JSON) + assert response.status_code == 422 + + +def test_member_cannot_view_joins(member): + assert member.get("/admin/joins", headers=JSON).status_code == 403 diff --git a/tests/api/test_pages.py b/tests/api/test_pages.py new file mode 100644 index 0000000..64ab02f --- /dev/null +++ b/tests/api/test_pages.py @@ -0,0 +1,93 @@ +# retoor +import re + +import pytest +from defusedxml import ElementTree + +PUBLIC_PAGES = ["/", "/rol", "/standaard", "/flyers", "/memes", "/mensen", "/mensen/retoor", "/binnen", "/docs", "/docs/welkom", "/docs/api/inhoud", "/docs/search?q=markdown", "/voorwaarden", "/privacy", "/auth/login"] +JSON_PAGES = ["/", "/rol", "/standaard", "/flyers", "/memes", "/mensen", "/binnen"] + + +@pytest.mark.parametrize("path", PUBLIC_PAGES) +def test_public_pages_return_200(anon, path): + response = anon.get(path) + assert response.status_code == 200, path + assert "text/html" in response.headers["content-type"] + + +@pytest.mark.parametrize("path", JSON_PAGES) +def test_json_faces(anon, path): + response = anon.get(path, headers={"Accept": "application/json"}) + assert response.status_code == 200 + assert response.headers["content-type"].startswith("application/json") + assert isinstance(response.json(), dict) + + +def test_home_is_molodetz(anon): + html = anon.get("/").text + assert "Molodetz" in html + for label in ("Rol", "Standaard", "Flyers", "Memes", "Mensen", "Binnen"): + assert f">{label}" in html + assert "wa.me" not in html and "whatsapp" not in html.lower() + + +def test_no_bare_static_urls_in_rendered_html(anon): + for path in ("/", "/flyers", "/memes", "/rol"): + html = anon.get(path).text + assert not re.search(r"""(src|href)="/static/(?!v|uploads/)""", html), path + + +def test_versioned_static_asset(anon): + html = anon.get("/").text + match = re.search(r'href="(/static/v[^"]+/css/base\.css)"', html) + assert match + assert anon.get(match.group(1)).status_code == 200 + + +def test_seeded_posts_and_placeholders(anon): + rol = anon.get("/rol", headers={"Accept": "application/json"}).json()["posts"] + seeds = {"Een zes is geen ambitie.", "Af is een eigenschap.", "De assistent is geen senior.", "Geen cursus."} + assert seeds <= {post["title"] for post in rol} + assert all(post["is_placeholder"] for post in rol if post["title"] in seeds) + standaard = anon.get("/standaard", headers={"Accept": "application/json"}).json()["posts"] + keuzes = next(post for post in standaard if post["title"] == "Eenendertig keuzes. Jij hebt er nul.") + assert keuzes["is_placeholder"] is False + + +def test_post_page(anon): + posts = anon.get("/rol", headers={"Accept": "application/json"}).json()["posts"] + post = next(post for post in posts if post["title"] == "Geen cursus.") + response = anon.get(f"/posts/{post['slug']}") + assert response.status_code == 200 + assert "Placeholder" in response.text + + +def test_galleries_have_images(anon): + for path, minimum in (("/flyers", 4), ("/memes", 10)): + html = anon.get(path).text + assert html.count("data-lightbox") >= minimum, path + + +def test_mensen_lists_real_accounts_only(anon): + people = anon.get("/mensen", headers={"Accept": "application/json"}).json() + names = [person["username"] for person in people.get("people", [])] + assert names[0] == "retoor" + assert set(names) <= {"retoor", "lezer"} + + +def test_unknown_page_is_404(anon): + response = anon.get("/bestaat-niet-xyz") + assert response.status_code == 404 + assert anon.get("/bestaat-niet-xyz", headers={"Accept": "application/json"}).json()["error"] + + +def test_robots_and_sitemap(anon): + assert "Sitemap:" in anon.get("/robots.txt").text + sitemap = anon.get("/sitemap.xml") + root = ElementTree.fromstring(sitemap.content) + assert root.tag.endswith("urlset") + assert len(list(root)) >= 5 + + +def test_health(anon): + assert anon.get("/health").json()["status"] == "ok" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..0099417 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,143 @@ +# retoor +import os +import shutil +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent +TEST_DATA_DIR = Path(tempfile.mkdtemp(prefix="molodetz-test-")) +TEST_PORT = int(os.environ.get("MOLODETZ_TEST_PORT", "8099")) +ADMIN_USERNAME = "retoor" +ADMIN_PASSWORD = "test-admin-pass-4f9c2a" +MEMBER_USERNAME = "lezer" +MEMBER_PASSWORD = "test-member-pass-77d1" + +TEST_ENV = { + "MOLODETZ_DATA_DIR": str(TEST_DATA_DIR), + "MOLODETZ_DATABASE_URL": f"sqlite:///{TEST_DATA_DIR / 'molodetz.db'}", + "MOLODETZ_ADMIN_USERNAME": ADMIN_USERNAME, + "MOLODETZ_ADMIN_EMAIL": "retoor@molodetz.nl", + "MOLODETZ_ADMIN_PASSWORD": ADMIN_PASSWORD, + "MOLODETZ_DISABLE_SERVICES": "1", + "MOLODETZ_DISABLE_RATE_LIMIT": "1", + "MOLODETZ_PORT": str(TEST_PORT), + "MOLODETZ_INTERNAL_BASE_URL": f"http://127.0.0.1:{TEST_PORT}", + "MOLODETZ_SITEMAP_TTL": "0", + "MOLODETZ_LANDING_TTL": "0", + "MOLODETZ_UNREAD_TTL": "0", + "MOLODETZ_STATIC_VERSION": "test", + "SECRET_KEY": "test-secret-key-not-for-production", +} +os.environ.update(TEST_ENV) + +BASE_URL = f"http://127.0.0.1:{TEST_PORT}" + + +def _wait_ready(process, timeout=30.0): + import httpx + + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if process.poll() is not None: + raise RuntimeError("test server exited during startup") + try: + if httpx.get(f"{BASE_URL}/health", timeout=1.0).status_code == 200: + return + except httpx.HTTPError: + pass + time.sleep(0.2) + raise RuntimeError("test server did not become ready") + + +@pytest.fixture(scope="session") +def app_server(): + env = {**os.environ, **TEST_ENV} + log = open(TEST_DATA_DIR / "server.log", "w") + process = subprocess.Popen( + [sys.executable, "-m", "uvicorn", "molodetz.main:app", "--host", "127.0.0.1", "--port", str(TEST_PORT)], + cwd=REPO_ROOT, + env=env, + stdout=log, + stderr=subprocess.STDOUT, + ) + try: + _wait_ready(process) + _ensure_member() + yield BASE_URL + finally: + process.terminate() + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + process.kill() + log.close() + + +def _ensure_member(): + from molodetz.database import create_user, get_user_by_username + from molodetz.utils.auth import hash_password + + if get_user_by_username(MEMBER_USERNAME) is None: + create_user(MEMBER_USERNAME, "lezer@example.invalid", hash_password(MEMBER_PASSWORD), role="Member") + + +def _client(base_url): + import httpx + + return httpx.Client(base_url=base_url, timeout=15.0, follow_redirects=False) + + +def _login(client, username, password): + response = client.post("/auth/login", data={"username": username, "password": password}) + assert response.status_code in (302, 303), response.text[:300] + return client + + +@pytest.fixture() +def anon(app_server): + with _client(app_server) as client: + yield client + + +@pytest.fixture() +def member(app_server): + with _client(app_server) as client: + yield _login(client, MEMBER_USERNAME, MEMBER_PASSWORD) + + +@pytest.fixture() +def admin(app_server): + with _client(app_server) as client: + yield _login(client, ADMIN_USERNAME, ADMIN_PASSWORD) + + +@pytest.fixture(scope="session") +def browser(app_server): + from playwright.sync_api import sync_playwright + + headless = os.environ.get("PLAYWRIGHT_HEADLESS", "1") == "1" + slow_mo = int(os.environ.get("PLAYWRIGHT_SLOW_MO", "0") or 0) + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=headless, slow_mo=slow_mo) + yield browser + browser.close() + + +@pytest.fixture() +def page(browser): + context = browser.new_context(viewport={"width": 1280, "height": 900}, locale="nl-NL") + page = context.new_page() + page.console_errors = [] + page.on("pageerror", lambda error: page.console_errors.append(str(error))) + page.on("console", lambda message: message.type == "error" and page.console_errors.append(message.text)) + yield page + context.close() + + +def pytest_sessionfinish(session, exitstatus): + shutil.rmtree(TEST_DATA_DIR, ignore_errors=True) diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 0000000..95ee7c3 --- /dev/null +++ b/tests/e2e/__init__.py @@ -0,0 +1 @@ +# retoor diff --git a/tests/e2e/test_browser.py b/tests/e2e/test_browser.py new file mode 100644 index 0000000..b833c45 --- /dev/null +++ b/tests/e2e/test_browser.py @@ -0,0 +1,102 @@ +# retoor +from tests.conftest import ADMIN_PASSWORD, ADMIN_USERNAME + + +def test_home_renders_without_errors(page, app_server): + page.goto(app_server + "/") + page.wait_for_selector("html[data-app=ready]") + assert page.locator("h1").first.inner_text().strip().lower() == "molodetz" + assert page.locator("nav .nav-links a", has_text="Binnen").count() == 1 + assert page.console_errors == [] + + +def test_lightbox_opens_and_closes(page, app_server): + page.goto(app_server + "/flyers") + page.wait_for_selector("html[data-app=ready]") + page.locator("[data-lightbox]").first.click() + lightbox = page.locator("dp-lightbox") + assert lightbox.evaluate("el => el.classList.contains('visible')") + page.keyboard.press("Escape") + assert not lightbox.evaluate("el => el.classList.contains('visible')") + assert page.console_errors == [] + + +def test_mobile_nav_toggle(browser, app_server): + context = browser.new_context(viewport={"width": 390, "height": 800}) + page = context.new_page() + page.goto(app_server + "/") + page.wait_for_selector("html[data-app=ready]") + assert not page.locator("#nav-links").is_visible() + page.click("[data-nav-toggle]") + assert page.locator("#nav-links").is_visible() + context.close() + + +def test_login_and_editor_preview(page, app_server): + page.goto(app_server + "/auth/login") + page.fill("input[name=username]", ADMIN_USERNAME) + page.fill("input[name=password]", ADMIN_PASSWORD) + page.click("button[type=submit]") + page.wait_for_url("**/admin") + page.goto(app_server + "/admin/posts/new") + page.wait_for_selector("html[data-app=ready]") + page.fill("#body", "Een **vette** zin [link](javascript:alert(1)) b") + page.wait_for_function("document.querySelector('[data-preview-target] strong')") + preview = page.locator("[data-preview-target]").inner_html() + assert "vette" in preview + assert "> nth=0") + assert page.locator("[data-endpoint] [data-panel=expected]").first.is_visible() + assert page.console_errors == [] + + +def test_docs_code_blocks_get_highlight_and_copy(page, app_server): + page.goto(app_server + "/docs/api") + page.wait_for_selector("html[data-app=ready]") + page.wait_for_selector("dp-code .copy-button") + assert page.locator("dp-code pre code.hljs").count() >= 1 + assert page.console_errors == [] diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..95ee7c3 --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1 @@ +# retoor diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py new file mode 100644 index 0000000..2ac10a8 --- /dev/null +++ b/tests/unit/test_cli.py @@ -0,0 +1,33 @@ +# retoor +import io + +from pypdf import PdfWriter + +from molodetz.cli import build_parser +from molodetz.cli.commands import read_source + + +def test_parser_groups(): + parser = build_parser() + args = parser.parse_args(["posts", "import", "notitie.md", "--topic", "standaard"]) + assert args.topic == "standaard" and args.path == "notitie.md" + args = parser.parse_args(["system", "prune", "--dry-run"]) + assert args.dry_run is True + args = parser.parse_args(["role", "set", "retoor", "Admin"]) + assert args.role == "Admin" + + +def test_read_markdown(tmp_path): + path = tmp_path / "x.md" + path.write_text("# Kop\n\nTekst", encoding="utf-8") + assert read_source(path).startswith("# Kop") + + +def test_read_pdf(tmp_path): + writer = PdfWriter() + writer.add_blank_page(width=200, height=200) + buffer = io.BytesIO() + writer.write(buffer) + path = tmp_path / "x.pdf" + path.write_bytes(buffer.getvalue()) + assert read_source(path) == "" diff --git a/tests/unit/test_gallery.py b/tests/unit/test_gallery.py new file mode 100644 index 0000000..00409d5 --- /dev/null +++ b/tests/unit/test_gallery.py @@ -0,0 +1,18 @@ +# retoor +from molodetz import config +from molodetz.gallery import GALLERY + +BANNED = ("selfie", "mirror", "devplace", "nigel", "nldr", "waifu", "zwem") + + +def test_gallery_files_exist_and_are_allowed(): + assert GALLERY + for entry in GALLERY: + name = entry["file"] if isinstance(entry, dict) else entry[0] + assert (config.MEDIA_SOURCE_DIR / name).is_file(), name + assert not any(word in name.lower() for word in BANNED), name + + +def test_both_kinds_present(): + kinds = {entry["kind"] if isinstance(entry, dict) else entry[1] for entry in GALLERY} + assert {"flyer", "meme"} <= kinds diff --git a/tests/unit/test_gates.py b/tests/unit/test_gates.py new file mode 100644 index 0000000..0d026db --- /dev/null +++ b/tests/unit/test_gates.py @@ -0,0 +1,34 @@ +# retoor +from molodetz import gates + + +def test_gate_1_template_environment_only_in_templating(): + assert gates.gate_template_environment() == set() + + +def test_gate_2_breakpoints_closed_set(): + assert gates.gate_breakpoints() == gates.BREAKPOINTS + + +def test_gate_3_http_client_allowlist(): + assert gates.gate_http_clients() == gates.HTTP_CLIENT_ALLOWLIST + + +def test_gate_4_client_render_allowlist(): + assert gates.gate_client_render_templates() == gates.CLIENT_RENDER_ALLOWLIST + + +def test_gate_5_no_bare_static(): + assert gates.gate_bare_static() == [] + + +def test_gate_6_no_manual_loading_toggles(): + assert gates.gate_loading_toggles() == [] + + +def test_no_em_dashes(): + assert gates.gate_em_dashes() == [] + + +def test_json_error_status_first(): + assert gates.gate_json_error_order() == [] diff --git a/tests/unit/test_net_guard.py b/tests/unit/test_net_guard.py new file mode 100644 index 0000000..9878f84 --- /dev/null +++ b/tests/unit/test_net_guard.py @@ -0,0 +1,12 @@ +# retoor +import asyncio + +import pytest + +from molodetz.net_guard import UnsafeURL, guard_public_url + + +@pytest.mark.parametrize("url", ["http://127.0.0.1/", "http://localhost:8088/", "http://10.0.0.1/", "http://169.254.169.254/", "file:///etc/passwd", "ftp://example.com/"]) +def test_private_and_odd_urls_are_refused(url): + with pytest.raises(UnsafeURL): + asyncio.run(guard_public_url(url)) diff --git a/tests/unit/test_registries.py b/tests/unit/test_registries.py new file mode 100644 index 0000000..eb37cd3 --- /dev/null +++ b/tests/unit/test_registries.py @@ -0,0 +1,34 @@ +# retoor +from molodetz.constants import REPORTABLE_TARGETS, SOFT_DELETE_TABLES, UNREPORTABLE_TABLES +from molodetz.docs_api import all_endpoints +from molodetz.docs_api.registry import TARGET_TYPES + + +def test_soft_delete_tables_partition(): + tables = set(SOFT_DELETE_TABLES) + assert tables == set(REPORTABLE_TARGETS) | set(UNREPORTABLE_TABLES) + assert not set(REPORTABLE_TARGETS) & set(UNREPORTABLE_TABLES) + + +def test_documented_endpoints_are_unique(): + keys = [(item["method"], item["path"]) for item in all_endpoints()] + assert len(keys) == len(set(keys)) + + +def test_documented_endpoints_exist_in_app(): + from molodetz.main import app + + routes = {(method.upper(), path) for path, methods in app.openapi()["paths"].items() for method in methods} + missing = [(item["method"], item["path"]) for item in all_endpoints() if (item["method"], item["path"]) not in routes] + assert missing == [] + + +def test_target_types_known(): + assert set(TARGET_TYPES) == {"post", "join_request", "user"} + + +def test_no_oauth_redirect_route(): + from molodetz.main import app + + assert "/docs/oauth2-redirect" not in app.openapi()["paths"] + assert app.swagger_ui_oauth2_redirect_url is None diff --git a/tests/unit/test_rendering.py b/tests/unit/test_rendering.py new file mode 100644 index 0000000..66c2464 --- /dev/null +++ b/tests/unit/test_rendering.py @@ -0,0 +1,34 @@ +# retoor +from molodetz.rendering import content_preview, normalize_dashes, plain_title, render_content, render_title + + +def test_markdown_renders_and_escapes_html(): + html = render_content("**vet** ") + assert "vet" in html + assert "