Browse Source

Merge pull request 'Rewrite in seleclolax, FastAPI and aiohttp' (#4) from thirtysix/rural-dict:feat/rewrite-in-seleclolax-fastapi-aiohttp into main

Reviewed-on: https://codeberg.org/cobra/rural-dict/pulls/4
Skylar "The Cobra" Widulski 6 months ago
parent
commit
ef57a7dbc3
19 changed files with 656 additions and 196 deletions
  1. 6 0
      .dockerignore
  2. 13 0
      .editorconfig
  3. 25 2
      .gitignore
  4. 1 0
      .python-version
  5. 6 9
      Dockerfile
  6. 111 0
      README.md
  7. 0 43
      README.org
  8. 2 4
      docker-compose.yml
  9. 20 12
      instances.json
  10. 0 59
      main.py
  11. 84 0
      pyproject.toml
  12. 74 0
      requirements-dev.lock
  13. 71 0
      requirements.lock
  14. 0 4
      requirements.txt
  15. 125 0
      src/main.py
  16. 37 22
      static/css/main.css
  17. 12 0
      templates/404.html
  18. 46 0
      templates/base.html
  19. 23 41
      templates/index.html

+ 6 - 0
.dockerignore

@@ -0,0 +1,6 @@
+*
+!requirements.lock
+!src/
+!templates/
+!static/
+static/**/*.xcf

+ 13 - 0
.editorconfig

@@ -0,0 +1,13 @@
+root = true
+
+[**]
+charset = utf-8
+end_of_line = lf
+indent_size = 4
+indent_style = space
+insert_final_newline = true
+trim_trailing_whitespace = true
+max_line_length = 99
+
+[{**.yml,**.yaml,**.html}]
+indent_size = 2

+ 25 - 2
.gitignore

@@ -1,2 +1,25 @@
-__pycache__
-*.pyc
+# Project
+.idea/
+.vscode/
+.venv/
+.tests/
+.env
+venv/
+
+# Cache
+__pycache__/
+*.py[cod]
+.cache/
+.ruff_cache/
+.mypy_cache/
+.pytest_cache/
+.coverage/
+
+# Build
+env/
+build/
+_build/
+dist/
+site/
+*.egg-info/
+*.egg

+ 1 - 0
.python-version

@@ -0,0 +1 @@
+3.12.4

+ 6 - 9
Dockerfile

@@ -1,10 +1,7 @@
-FROM python:3-alpine
-
-WORKDIR /usr/src/rural-dict
-
-COPY requirements.txt ./
-RUN pip install --no-cache-dir -r requirements.txt
-
+FROM python:3.12.4-alpine
+WORKDIR /app
+COPY requirements.lock ./
+RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -r requirements.lock
 COPY . .
-
-CMD [ "python", "./main.py" ]
+CMD [ "uvicorn", "src.main:app", "--no-access-log", "--proxy-headers", \
+    "--forwarded-allow-ips", "*", "--host", "0.0.0.0", "--port", "5758" ]

+ 111 - 0
README.md

@@ -0,0 +1,111 @@
+# 📖 Rural Dictionary
+
+> We're rural, not urban.
+
+Privacy-respecting, NoJS-supporting Urban Dictionary frontend.
+
+## 🌐 Instances
+
+| URL                                                                                                                                                                                 | Country | Owner name | Owner Website          |
+|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------|------------|------------------------|
+| <https://rd.vern.cc> + [Tor](http://rd.vernccvbvyi5qhfzyqengccj7lkove6bjot2xhh5kajhwvidqafczrad.onion) + [I2P](http://vern5cxiaufqvhv4hu5ypkvw3tiwvuinae4evdbqzrioql6s2sha.b32.i2p) | US      | ~vern      | <https://vern.cc>      |
+| <https://rd.bloat.cat>                                                                                                                                                              | RO      | bloatcat   | <https://bloat.cat>    |
+| <https://rd.thirtysix.pw>                                                                                                                                                           | NL      | thirtysix  | <https://thirtysix.pw> |
+
+## ✨ Features
+
+Frontend supports all Urban Dictionary features and has endpoint-parity with it.
+Available features include:
+
+- Word definitions
+- Author pages
+- Homepage with words of the day
+- Random word definitions
+- 404 page with words similar to search
+- Pagination
+
+## 🚀 Deployment
+
+Clone repository:
+
+```sh
+git clone https://git.vern.cc/cobra/rural-dict.git
+cd rural-dict
+```
+
+### 🐳 With Docker
+
+```sh
+docker build . -t rural-dict
+docker compose up -d
+```
+
+### 💻 Without containerization
+
+```sh
+python3 -m venv .venv
+. .venv/bin/activate
+pip install -r requirements.lock
+uvicorn src.main:app --no-access-log --proxy-headers --forwarded-allow-ips '*' --host 0.0.0.0 --port 5758
+```
+
+### 🛡️ Running behind a reverse proxy
+
+To run the app behind a reverse proxy, ensure that the appropriate proxy headers are added.
+Below is a sample configuration for NGINX:
+
+```text
+location / {
+    proxy_pass http://127.0.0.1:5758;
+    proxy_set_header Host $host;
+    proxy_set_header X-Real-IP $remote_addr;
+    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+    proxy_set_header X-Forwarded-Proto $scheme;
+}
+```
+
+## 🔧 Development
+
+Install Rye by following
+the [installation guide](https://rye.astral.sh/guide/installation/).
+
+Use `rye sync` to install dependencies and required Python version.
+
+Use `rye run dev` to start development server which will reload on every change to source code.
+
+Use `rye check --fix` and `rye fmt` to lint and format code. Assumed to be run before each commit
+to guarantee code quality.
+
+Use `rye run basedpyright` to ensure typing is correct.
+
+## 🤝 Support
+
+Join our [Matrix room](https://mto.vern.cc/#/#cobra-frontends:vern.cc) for support and other
+things related to Rural Dictionary.
+
+## 🔗 Redirection
+
+To use Rural Dictionary, simply replace an Urban Dictionary URL with a Rural Dictionary URL from
+the instance list above. Auto-redirect browser extension
+like [Redirector](https://github.com/einaregilsson/Redirector) can be used to achieve this.
+
+For example, change:
+
+`https://urbandictionary.com/define.php?term=kin`
+
+to:
+
+`https://rd.vern.cc/define.php?term=kin`
+
+**Note:** More endpoints are supported.
+
+## 👥 Contributors
+
+- [thirtysix](https://thirtysix.pw), rewrote project in a more modern libraries stack and
+  implemented missing Urban Dictionary features
+- [zortazert](https://codeberg.org/zortazert), created the initial Urban Dictionary frontend using
+  JavaScript and helped develop Rural Dictionary
+
+## 📜 License
+
+This project is licensed under the AGPLv3+ license - see the [license file](LICENSE) for details.

+ 0 - 43
README.org

@@ -1,43 +0,0 @@
-* Rural Dictionary
-
-We're rural, not urban. A privacy respecting urban dictionary client, powered by Flask.
-
-* Instances
-
-| URL                | Country | Ownername | Owner Website   |
-|--------------------+---------+-----------+-----------------|
-| https://rd.vern.cc + [[http://rd.vernccvbvyi5qhfzyqengccj7lkove6bjot2xhh5kajhwvidqafczrad.onion][Tor]] + [[http://vern5cxiaufqvhv4hu5ypkvw3tiwvuinae4evdbqzrioql6s2sha.b32.i2p][I2P]] | US      | ~vern     | https://vern.cc |
-| https://rd.bloat.cat | RO      | bloatcat | https://bloat.cat |
-
-* About
-Rural Dictionary scrapes urban dictionary for data and then displays it in html.
-
-* Support
-Join our [[https://mto.vern.cc/#/#cobra-frontends:vern.cc][Matrix room]] for support and other things related to Rural Dictionary
-
-
-* Supports
-- Define a word with multiple entries
-- Pagination
-- Random list of words
-- User pages
-- Urban Dictionary home with words of the day
-- Matches urban dictionary's endpoints for features listed above
-
-* Dependencies
-- bs4
-- requests
-- waitress
-- Relatively new version of python
-
-* Redirection
-Simply replace a urban dictionary url with a Rural Dictionary url from the instance list above.
-#+BEGIN_SRC
-https://urbandictionary.com/define.php?term=eevee
-
-https://rd.vern.cc/define.php?term=eevee
-#+END_SRC
-NOTE: More endpoints are supported
-
-* Contributors
-- https://codeberg.org/zortazert, created the initial Urban Dictionary frontend using JavaScript and helped develop Rural Dictionary

+ 2 - 4
docker-compose.yml

@@ -1,9 +1,7 @@
 version: "3"
 services:
   rural-dict:
-    build:
-      context: .
-      dockerfile: Dockerfile
+    build: .
     restart: unless-stopped
     ports:
-      - "127.0.0.1:8080:8080"
+      - "127.0.0.1:5758:5758"

+ 20 - 12
instances.json

@@ -1,18 +1,26 @@
 [
     {
-	"clearnet": "https://rd.vern.cc",
-	"tor": "http://rd.vernccvbvyi5qhfzyqengccj7lkove6bjot2xhh5kajhwvidqafczrad.onion",
-	"i2p": "http://vern5cxiaufqvhv4hu5ypkvw3tiwvuinae4evdbqzrioql6s2sha.b32.i2p",
-	"country": "US",
-	"owner_name": "~vern",
-	"owner_website": "https://vern.cc"
+        "clearnet": "https://rd.vern.cc",
+        "tor": "http://rd.vernccvbvyi5qhfzyqengccj7lkove6bjot2xhh5kajhwvidqafczrad.onion",
+        "i2p": "http://vern5cxiaufqvhv4hu5ypkvw3tiwvuinae4evdbqzrioql6s2sha.b32.i2p",
+        "country": "US",
+        "owner_name": "~vern",
+        "owner_website": "https://vern.cc"
     },
     {
-	"clearnet": "https://rd.bloat.cat",
-	"tor": null,
-	"i2p": null,
-	"country": "RO",
-	"owner_name": "bloatcat",
-	"owner_website": "https://bloat.cat"
+        "clearnet": "https://rd.bloat.cat",
+        "tor": null,
+        "i2p": null,
+        "country": "RO",
+        "owner_name": "bloatcat",
+        "owner_website": "https://bloat.cat"
+    },
+    {
+        "clearnet": "https://rd.thirtysix.pw",
+        "tor": null,
+        "i2p": null,
+        "country": "NL",
+        "owner_name": "thirtysix",
+        "owner_website": "https://thirtysix.pw"
     }
 ]

+ 0 - 59
main.py

@@ -1,59 +0,0 @@
-#!/usr/bin/env python
-
-from flask import Flask, render_template, request, redirect
-import requests
-import html
-import re
-from bs4 import BeautifulSoup
-from urllib.parse import quote, unquote
-
-def scrape(url):
-    data = requests.get(url)
-    
-    our_path = re.sub(r".*://.*/", "/", request.url)
-    path = re.sub(r".*://.*/", "/", data.url)
-    if our_path != path and \
-            quote(unquote(re.sub("[?&=]", "", our_path))) != re.sub("[?&=]", "", path):
-                # this is bad ^
-                return f"REDIRECT {path}"
-    ret = []
-    soup = BeautifulSoup(data.text, "html.parser")
-
-    defs = [(div, div.get('data-defid')) for div in soup.find_all("div") if div.get('data-defid')]
-    try:
-        thumbs_data = {
-            str(entry['defid']): entry
-            for entry
-            in requests.get(
-                'https://api.urbandictionary.com/v0/uncacheable?ids=' + ','.join(defid for (_, defid) in defs)
-            ).json()['thumbs']
-        }
-    except:
-        thumbs_data = {}
-
-    for (definition, defid) in defs:
-        word = definition.select("div div h1 a, div div h2 a")[0].text
-        meaning = definition.find(attrs={"class" : ["break-words meaning mb-4"]}).decode_contents()
-        example = definition.find(attrs={"class" : ["break-words example italic mb-4"]}).decode_contents()
-        contributor = definition.find(attrs={"class" : ["contributor font-bold"]})
-        thumbs_up = thumbs_data.get(defid, {}).get('up')
-        thumbs_down = thumbs_data.get(defid, {}).get('down')
-        ret.append([defid, word, meaning, example, contributor, thumbs_up, thumbs_down])
-    pages = soup.find(attrs={"class" : ["pagination text-xl text-center"]})
-    if pages == None:
-        pages = ""
-    return (ret, pages)
-
-app = Flask(__name__, template_folder="templates", static_folder="static")
-
-@app.route('/', defaults={'path': ''})
-@app.route('/<path:path>')
-def catch_all(path):
-    scraped = scrape(f"https://urbandictionary.com/{re.sub(r'.*://.*/', '/', request.url)}")
-    if type(scraped) == str and scraped.startswith("REDIRECT"):
-        return redirect(scraped.replace("REDIRECT ", ""), 302)
-    return render_template('index.html', data=scraped, term=request.args.get("term"))
-
-if __name__ == '__main__':
-    from waitress import serve
-    serve(app, host="0.0.0.0", port=8080)

+ 84 - 0
pyproject.toml

@@ -0,0 +1,84 @@
+[project]
+name = "rural-dict"
+version = "1.0.0"
+description = "Privacy-respecting, NoJS-supporting Urban Dictionary frontend."
+license = "AGPL-3.0-or-later"
+readme = "README.md"
+requires-python = ">=3.12"
+authors = [
+    { name = "Zubarev Grigoriy", email = "thirtysix@thirtysix.pw" },
+    { name = "vlnst", email = "vlnst@bloat.cat" },
+    { name = "Skylar Widulski", email = "cobra@vern.cc" },
+    { name = "zortazert", email = "zortazert@matthewevan.xyz" },
+]
+dependencies = [
+    "aiohttp~=3.10.3",
+    "selectolax~=0.3.21",
+    "fastapi~=0.112.1",
+    "uvicorn[standard]~=0.30.6",
+    "jinja2~=3.1.4",
+]
+
+[tool.rye]
+virtual = true
+managed = true
+universal = true
+dev-dependencies = [
+    "basedpyright>=1.16.0",
+]
+
+[tool.rye.scripts]
+dev = """uvicorn src.main:app --reload --reload-include 'src/**/*.py'
+--reload-include 'templates/**/*.html' --reload-include 'static/**/*.css' --port 5758"""
+start = """uvicorn src.main:app --no-access-log --proxy-headers
+--forwarded-allow-ips '*' --host 0.0.0.0 --port 5758"""
+
+[tool.ruff]
+target-version = "py312"
+line-length = 99
+exclude = [
+    ".git",
+    ".venv",
+    ".idea",
+    ".tests",
+    "build",
+    "dist",
+]
+
+[tool.ruff.lint]
+select = [
+    "E", # pycodestyle errors
+    "W", # pycodestyle warnings
+    "F", # pyflakes
+    "I", # isort
+    "N", # pep8-naming
+    "S", # flake8-bandit
+    "B", # flake8-bugbear
+    "G", # flake8-logging-format
+    "C4", # flake8-comprehensions
+    "UP", # pyupgrade
+    "PLC", # pylint conventions
+    "PLE", # pylint errors
+    "SIM", # flake8-simplify
+    "RET", # flake8-return
+    "YTT", # flake8-2020
+    "RUF", # ruff-specific rules
+    "TCH", # flake8-type-checking
+    "PTH", # flake8-use-pathlib
+    "ASYNC", # flake8-async
+]
+
+[tool.basedpyright]
+exclude = [
+    ".git",
+    ".venv",
+    ".idea",
+    ".tests",
+    "build",
+    "dist",
+]
+typeCheckingMode = "standard"
+pythonPlatform = "All"
+pythonVersion = "3.12"
+reportMissingImports = true
+reportMissingTypeStubs = false

+ 74 - 0
requirements-dev.lock

@@ -0,0 +1,74 @@
+# generated by rye
+# use `rye lock` or `rye sync` to update this lockfile
+#
+# last locked with the following flags:
+#   pre: false
+#   features: []
+#   all-features: false
+#   with-sources: false
+#   generate-hashes: false
+#   universal: true
+
+aiohappyeyeballs==2.3.6
+    # via aiohttp
+aiohttp==3.10.3
+aiosignal==1.3.1
+    # via aiohttp
+annotated-types==0.7.0
+    # via pydantic
+anyio==4.4.0
+    # via starlette
+    # via watchfiles
+attrs==24.2.0
+    # via aiohttp
+basedpyright==1.16.0
+click==8.1.7
+    # via uvicorn
+colorama==0.4.6 ; platform_system == 'Windows' or sys_platform == 'win32'
+    # via click
+    # via uvicorn
+fastapi==0.112.1
+frozenlist==1.4.1
+    # via aiohttp
+    # via aiosignal
+h11==0.14.0
+    # via uvicorn
+httptools==0.6.1
+    # via uvicorn
+idna==3.7
+    # via anyio
+    # via yarl
+jinja2==3.1.4
+markupsafe==2.1.5
+    # via jinja2
+multidict==6.0.5
+    # via aiohttp
+    # via yarl
+nodejs-wheel-binaries==20.16.0
+    # via basedpyright
+pydantic==2.8.2
+    # via fastapi
+pydantic-core==2.20.1
+    # via pydantic
+python-dotenv==1.0.1
+    # via uvicorn
+pyyaml==6.0.2
+    # via uvicorn
+selectolax==0.3.21
+sniffio==1.3.1
+    # via anyio
+starlette==0.38.2
+    # via fastapi
+typing-extensions==4.12.2
+    # via fastapi
+    # via pydantic
+    # via pydantic-core
+uvicorn==0.30.6
+uvloop==0.20.0 ; platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'
+    # via uvicorn
+watchfiles==0.23.0
+    # via uvicorn
+websockets==12.0
+    # via uvicorn
+yarl==1.9.4
+    # via aiohttp

+ 71 - 0
requirements.lock

@@ -0,0 +1,71 @@
+# generated by rye
+# use `rye lock` or `rye sync` to update this lockfile
+#
+# last locked with the following flags:
+#   pre: false
+#   features: []
+#   all-features: false
+#   with-sources: false
+#   generate-hashes: false
+#   universal: true
+
+aiohappyeyeballs==2.3.6
+    # via aiohttp
+aiohttp==3.10.3
+aiosignal==1.3.1
+    # via aiohttp
+annotated-types==0.7.0
+    # via pydantic
+anyio==4.4.0
+    # via starlette
+    # via watchfiles
+attrs==24.2.0
+    # via aiohttp
+click==8.1.7
+    # via uvicorn
+colorama==0.4.6 ; platform_system == 'Windows' or sys_platform == 'win32'
+    # via click
+    # via uvicorn
+fastapi==0.112.1
+frozenlist==1.4.1
+    # via aiohttp
+    # via aiosignal
+h11==0.14.0
+    # via uvicorn
+httptools==0.6.1
+    # via uvicorn
+idna==3.7
+    # via anyio
+    # via yarl
+jinja2==3.1.4
+markupsafe==2.1.5
+    # via jinja2
+multidict==6.0.5
+    # via aiohttp
+    # via yarl
+pydantic==2.8.2
+    # via fastapi
+pydantic-core==2.20.1
+    # via pydantic
+python-dotenv==1.0.1
+    # via uvicorn
+pyyaml==6.0.2
+    # via uvicorn
+selectolax==0.3.21
+sniffio==1.3.1
+    # via anyio
+starlette==0.38.2
+    # via fastapi
+typing-extensions==4.12.2
+    # via fastapi
+    # via pydantic
+    # via pydantic-core
+uvicorn==0.30.6
+uvloop==0.20.0 ; platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'
+    # via uvicorn
+watchfiles==0.23.0
+    # via uvicorn
+websockets==12.0
+    # via uvicorn
+yarl==1.9.4
+    # via aiohttp

+ 0 - 4
requirements.txt

@@ -1,4 +0,0 @@
-beautifulsoup4
-requests
-flask
-waitress

+ 125 - 0
src/main.py

@@ -0,0 +1,125 @@
+import re
+from contextlib import asynccontextmanager
+from datetime import datetime
+from json import JSONDecodeError
+
+import aiohttp
+from fastapi import FastAPI, Request
+from fastapi.responses import HTMLResponse, RedirectResponse
+from fastapi.staticfiles import StaticFiles
+from fastapi.templating import Jinja2Templates
+from selectolax.parser import HTMLParser, Node
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+    """Establishing an aiohttp ClientSession for the duration of the app's lifecycle."""
+    global session
+    session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(10))
+    yield
+    await session.close()
+
+
+app = FastAPI(lifespan=lifespan, docs_url=None, redoc_url=None)
+app.mount("/static", StaticFiles(directory="static"), name="static")
+templates = Jinja2Templates(directory="templates")
+session: aiohttp.ClientSession = None  # pyright: ignore[reportAssignmentType]
+
+
+def remove_classes(node: Node) -> Node:
+    """Recursively remove all classes from all nodes."""
+    if "class" in node.attributes:
+        del node.attrs["class"]  # pyright: ignore [reportIndexIssue]
+    for child in node.iter():
+        remove_classes(child)
+    return node
+
+
+@app.get("/{path:path}", response_class=HTMLResponse)
+async def catch_all(response: Request):
+    """Handle all routes on Urban Dictionary and perform redirection if necessary."""
+    path_without_host = (
+        f"{response.url.path}{f'?{response.url.query}' if response.url.query else ''}"
+    )
+    url = f"https://www.urbandictionary.com{path_without_host}"
+    term = response.query_params.get("term")
+
+    async with session.get(url) as dict_response:
+        if dict_response.history:
+            return RedirectResponse(str(dict_response.url.relative()), status_code=301)
+        html = await dict_response.text()
+        parser = HTMLParser(html)
+        if dict_response.status != 200:
+            similar_words = None
+            if (try_this := parser.css_first("div.try-these")) is not None:
+                similar_words = [remove_classes(word).html for word in try_this.css("li a")]
+            return templates.TemplateResponse(
+                "404.html",
+                {
+                    "request": response,
+                    "similar_words": similar_words,
+                    "term": term,
+                    "site_title": f"Rural Dictionary: {term}",
+                    "site_description": (
+                        "View on Rural Dictionary, an alternative private "
+                        "frontend to Urban Dictionary."
+                    ),
+                },
+                status_code=404,
+            )
+
+    results = []
+    definitions = parser.css("div[data-defid]")
+    try:
+        thumbs_api_url = (
+            f'https://api.urbandictionary.com/v0/uncacheable?ids='
+            f'{",".join(d.attributes["data-defid"] or "-1" for d in definitions)}'
+        )
+        async with session.get(thumbs_api_url) as thumbs_response:
+            thumbs_json = await thumbs_response.json()
+            thumbs_data = {el["defid"]: el for el in thumbs_json["thumbs"]}
+    except (KeyError, JSONDecodeError, TimeoutError):
+        thumbs_data = {}
+
+    site_description = None
+    for definition in definitions:
+        word = definition.css_first("a.word").text()
+        meaning_node = remove_classes(definition.css_first("div.meaning"))
+        if site_description is None:
+            site_description = re.sub(r"\s+", " ", meaning_node.text(strip=True, separator=" "))
+        meaning = meaning_node.html
+        example = remove_classes(definition.css_first("div.example")).html
+        contributor = remove_classes(definition.css_first("div.contributor")).html
+        definition_id = int(definition.attributes["data-defid"] or "-1")
+        definition_thumbs = thumbs_data.get(definition_id, {})
+        thumbs_up = definition_thumbs.get("up")
+        thumbs_down = definition_thumbs.get("down")
+        results.append(
+            [definition_id, word, meaning, example, contributor, thumbs_up, thumbs_down]
+        )
+    if (pagination := parser.css_first("div.pagination")) is not None:
+        pagination = remove_classes(pagination)
+        pagination.attrs["class"] = "pagination"  # pyright: ignore [reportIndexIssue]
+        pagination = pagination.html
+
+    term = term or results[0][1]
+    site_title = "Rural Dictionary"
+    match response.url.path:
+        case "/":
+            # add current date for page with words of the day
+            site_title += f', {datetime.now().strftime("%d %B")}'
+        case "/random.php":
+            term = "Random words"
+    site_title += f": {term}"
+
+    return templates.TemplateResponse(
+        "index.html",
+        {
+            "request": response,
+            "results": results,
+            "pagination": pagination,
+            "term": term,
+            "site_title": site_title,
+            "site_description": site_description,
+        },
+    )

+ 37 - 22
static/css/main.css

@@ -1,34 +1,49 @@
 body {
-	font-family: DejaVu Sans Mono, monospace;
-	margin:20px auto;
-	max-width:800px;
-	line-height:1.5em;
-	font-size:1.1em;
-	background-color:#282c34;
-	color:#bbc2cf;
-	padding:0 10px;
-	hyphens:auto;
+    font-family: DejaVu Sans Mono, monospace;
+    margin: 20px auto;
+    max-width: 800px;
+    line-height: 1.5em;
+    font-size: 1.1em;
+    background-color: #282c34;
+    color: #bbc2cf;
+    padding: 0 10px;
+    hyphens: auto;
 }
 
 img {
-	max-width:80vw;
+    max-width: 80vw;
+}
+
+a {
+    color: #ff6c6b;
+    text-decoration: none;
+}
+a:hover {
+    color: #ff6c6b;
+    text-decoration: underline;
+}
+.underline-links a {
+    text-decoration: underline;
 }
 
-a { color:#ff6c6b; text-decoration:none; }
-a:hover { color:#ff6c6b; text-decoration:underline; }
 h2 {
-	display:inline;
-	line-height:1.2;
-	color:#51afef;
-	font-size:1.2em;
+    display: inline;
+    line-height: 1.2;
+    color: #51afef;
+    font-size: 1.2em;
 }
-input { background-color: #282c34; color: #bbc2cf; }
+
+input {
+    background-color: #282c34;
+    color: #bbc2cf;
+}
+
 .pagination {
-	margin-right: 1ch;
-	text-align: center;
+    margin-right: 1ch;
+    text-align: center;
 }
 .pagination ul > li {
-	list-style: none;
-	display: inline-block;
-	padding-left: 1ch;
+    list-style: none;
+    display: inline-block;
+    padding-left: 1ch;
 }

+ 12 - 0
templates/404.html

@@ -0,0 +1,12 @@
+{% extends "base.html" %} {% block content %}
+<div style="text-align: center">
+  <h2>Definition not found: {{ term }}</h2>
+  {% if similar_words %}
+  {% for word in similar_words %}
+  <h3 class="underline-links">{{ word | safe }}</h3>
+  {% endfor %}
+  {% else %}
+  <p>There are no similar words. Try correcting your search.</p>
+  {% endif %}
+</div>
+{% endblock %}

+ 46 - 0
templates/base.html

@@ -0,0 +1,46 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <link rel="stylesheet" href="{{ url_for('static', path='css/main.css') }}" />
+    <link rel="icon" type="image/png" href="{{ url_for('static', path='img/favicon.png') }}" />
+    <title>{{ site_title }}</title>
+    <meta name="description" content="{{ site_description }}" />
+    <!-- The Open Graph Protocol meta tags -->
+    <meta property="og:url" content="{{ request.url }}" />
+    <meta property="og:type" content="website" />
+    <meta property="og:title" content="{{ site_title }}" />
+    <meta property="og:description" content="{{ site_description }}" />
+    <meta property="twitter:domain" content="{{ request.url.hostname }}" />
+    <meta property="twitter:url" content="{{ request.url }}" />
+    <meta name="twitter:title" content="{{ site_title }}" />
+    <meta name="twitter:description" content="{{ site_description }}" />
+  </head>
+  <body>
+    <div style="text-align: center">
+      <a href="/">
+        <img src="{{ url_for('static', path='img/logo.png') }}" alt="logo" />
+      </a>
+      <form id="search" role="search" method="get" action="/define.php">
+        <input
+          autocomplete="off"
+          type="search"
+          id="term"
+          name="term"
+          placeholder="Search"
+          aria-label="Search"
+          value="{{ term if request.url.path == '/define.php' else '' }}"
+          autofocus
+        />
+        <button>Go</button>
+      </form>
+      <a href="/random.php">Random</a>
+      <br />
+      <a href="https://git.vern.cc/cobra/rural-dict">Source Code</a>
+    </div>
+    <br />
+    {% block content %}{% endblock %}
+  </body>
+</html>

+ 23 - 41
templates/index.html

@@ -1,41 +1,23 @@
-<!DOCTYPE html>
-<html>
-  <head>
-    <title>Rural Dictionary{% if term %}: {{ term }}{% endif %}</title>
-    <meta charset="UTF-8">
-    <meta name="viewport" content="width=device-width">
-    <link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
-    <link rel="icon" type="image/png" href="{{ url_for('static', filename='img/favicon.png') }}">
-  </head>
-  <body>
-    <center>
-      <a href="/">
-        <img src="{{ url_for('static', filename='img/logo.png')  }}">
-      </a>
-      <form id="search" role="search" method="get" action="/define.php">
-        <input type="search" id="term" name="term" placeholder="Search" autofocus>
-        <button>Go</button>
-      </form>
-      <a href=/random.php>Random</a>
-      <br>
-      <a href="https://git.vern.cc/cobra/rural-dict">Source Code</a>
-    </center>
-    <br>
-    {% for defid, word, definition, example, author, thumbs_up, thumbs_down in data[0] %}
-    
-    <div class="{{ defid }}">
-      <a href="/define.php?term={{ word }}">
-        <h2>{{ word }}</h2>
-      </a>
-      <p>{{ definition|safe }}</p>
-      <p><i>{{ example|safe }}</i></p>
-      <p>{{ author|safe }}</p>
-      {% if thumbs_up and thumbs_down %}
-      <p>{{ thumbs_up|safe }}<span title="thumbs up">👍</span> {{ thumbs_down|safe }}<span title="thumbs down">👎</span></p>
-      {% endif %}
-    </div>
-    <br>
-    {% endfor %}
-    {{ data[1]|safe }}
-  </body>
-</html>
+{% extends "base.html" %}
+{% block content %}
+{% for definition_id, word, meaning, example, contributor, thumbs_up, thumbs_down in results %}
+<div data-id="{{ definition_id }}">
+  <a href="/define.php?term={{ word }}">
+    <h2>{{ word }}</h2>
+  </a>
+  <div class="underline-links">
+    <p>{{ meaning | safe }}</p>
+    <p><i>{{ example | safe }}</i></p>
+  </div>
+  <div>{{ contributor | safe }}</div>
+  {% if thumbs_up is not none and thumbs_down is not none %}
+  <p>
+    <span title="thumbs up">👍{{ thumbs_up }}</span>
+    <span title="thumbs down">👎{{ thumbs_down }}</span>
+  </p>
+  {% endif %}
+</div>
+<br />
+{% endfor %}
+{% if pagination %}{{ pagination | safe }}{% endif %}
+{% endblock %}