feat: 14kB articles site

This commit is contained in:
Krzysztof Rudnicki 2025-09-07 21:26:55 +02:00
parent 175a2b256c
commit 8f24526058
9 changed files with 543 additions and 37 deletions

15
articles/README.md Normal file
View File

@ -0,0 +1,15 @@
Mini Articles (<=14KB)
- Single-file site: `index.html` with inline CSS & JS
- Features:
- List of articles with thumbnails (cards)
- Read view: thumbnail, title, body (supports inline images/videos)
- Create view: title, thumbnail picker/drag-drop, rich body via contenteditable
- Drag/drop or choose images/videos anywhere in the body
- Local persistence via localStorage (no server required)
How to open
- Open `site/index.html` in a browser.
Tests
- `pytest` includes a test to enforce the 14KB budget for `index.html`.

File diff suppressed because one or more lines are too long

183
articles/index.html Normal file
View File

@ -0,0 +1,183 @@
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Mini Articles</title>
<style>
/* Tiny, readable defaults */
*{box-sizing:border-box}html,body{height:100%}body{margin:0;font:16px/1.4 system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,'Helvetica Neue',Arial,sans-serif;color:#222;background:#fff}
header{display:flex;gap:.5rem;align-items:center;justify-content:space-between;padding:.5rem .75rem;border-bottom:1px solid #ddd;position:sticky;top:0;background:#fff}
header h1{margin:0;font-size:1.1rem}
header nav{display:flex;gap:.5rem}
button, input[type=file]{font:inherit}
main{max-width:960px;margin:0 auto;padding:1rem}
.hidden{display:none!important}
section.hidden{display:none!important}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:.75rem}
.card{border:1px solid #ddd;border-radius:.5rem;overflow:hidden;background:#fff;cursor:pointer;display:flex;flex-direction:column}
.card img{width:100%;height:120px;object-fit:cover;display:block}
.card h3{margin:.5rem;font-size:1rem}
#editor label{display:block;margin:.5rem 0 .25rem}
input[type=text]{width:100%;padding:.5rem;border:1px solid #ccc;border-radius:.4rem}
.drop{border:1.5px dashed #bbb;border-radius:.5rem;padding:.5rem;text-align:center;color:#666}
.drop img{max-width:100%;max-height:200px;display:block;margin:.25rem auto}
#content{min-height:200px;padding:.5rem;border:1px solid #ccc;border-radius:.5rem}
#content:empty:before{content:attr(data-ph);color:#999}
article img, article video{max-width:100%;height:auto;display:block;margin:.5rem 0}
article h1{margin:.25rem 0 .5rem;font-size:1.6rem}
.controls{display:flex;gap:.5rem;flex-wrap:wrap;margin:.5rem 0}
.small{font-size:.85rem;color:#666}
/* Focused reading mode: keep header headline, hide nav and extras */
.reading header nav{display:none}
.reading #delBtn,.reading #dateInfo{display:none}
</style>
<header>
<h1>Mini Articles</h1>
<nav>
<button id="toList" title="All articles">List</button>
<button id="toNew" title="Create article">New</button>
</nav>
</header>
<main>
<!-- List View -->
<section id="listV" class="grid"></section>
<!-- Read View -->
<section id="readV" class="hidden">
<div class="controls">
<button id="backBtn">← Back</button>
<button id="delBtn" title="Delete">Delete</button>
<span class="small" id="dateInfo"></span>
</div>
<article id="readA">
<img id="readThumb" alt="thumbnail"/>
<h1 id="readTitle"></h1>
<div id="readBody"></div>
</article>
</section>
<!-- Editor View -->
<section id="editor" class="hidden">
<label>Title</label>
<input id="title" type="text" maxlength="200" placeholder="Article title"/>
<label>Thumbnail</label>
<div id="thumbDrop" class="drop" tabindex="0">Drop image here or choose…<br><img id="thumbPrev" alt="thumbnail preview"/></div>
<input id="thumbFile" type="file" accept="image/*"/>
<label>Body</label>
<div class="controls">
<button id="insImg">Insert image</button>
<button id="insVid">Insert video</button>
</div>
<div id="content" contenteditable="true" data-ph="Write here. Drag & drop images/videos where you want them."></div>
<div class="controls">
<button id="saveBtn">Save</button>
<button id="cancelBtn">Cancel</button>
<span class="small">Stored locally or on server if available.</span>
</div>
<input id="imgFile" type="file" accept="image/*" hidden>
<input id="vidFile" type="file" accept="video/*" hidden>
</section>
</main>
<script>
// Tiny SPA with optional backend. Falls back to localStorage.
(function(){
const $=s=>document.querySelector(s);
const listV=$('#listV'), readV=$('#readV'), editor=$('#editor');
const rT=$('#readTitle'), rB=$('#readBody'), rTh=$('#readThumb'), dt=$('#dateInfo');
const toList=$('#toList'), toNew=$('#toNew'), back=$('#backBtn'), del=$('#delBtn');
const title=$('#title'), content=$('#content');
const tDrop=$('#thumbDrop'), tPrev=$('#thumbPrev'), tFile=$('#thumbFile');
const insImg=$('#insImg'), insVid=$('#insVid'), imgFile=$('#imgFile'), vidFile=$('#vidFile');
const saveBtn=$('#saveBtn'), cancelBtn=$('#cancelBtn');
const KEY='articles14k';
let selId=null; let API=null;
// API client with detection
async function detectAPI(){
try{const r=await fetch('/api/articles'); if(r.ok){return {
async list(){return (await fetch('/api/articles')).json()},
async get(id){const r=await fetch('/api/articles/'+id); if(!r.ok) return null; return r.json()},
async create(a){const r=await fetch('/api/articles',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(a)}); return r.json()},
async update(id,patch){const r=await fetch('/api/articles/'+id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(patch)}); return r.ok? r.json():null},
async remove(id){await fetch('/api/articles/'+id,{method:'DELETE'})}
}} }catch(e){}
// Fallback to localStorage
const S={get(){try{return JSON.parse(localStorage.getItem(KEY)||'[]')}catch(e){return []}},set(a){localStorage.setItem(KEY,JSON.stringify(a))}};
const uid=()=>Date.now().toString(36)+Math.random().toString(36).slice(2,6);
return {
async list(){return S.get()},
async get(id){return S.get().find(x=>x.id===id)||null},
async create(a){const arr=S.get(); const obj=Object.assign({id:uid(),createdAt:Date.now()},a); arr.unshift(obj); S.set(arr); return obj},
async update(id,patch){const arr=S.get(); const i=arr.findIndex(x=>x.id===id); if(i<0) return null; arr[i]=Object.assign(arr[i],patch,{updatedAt:Date.now()}); S.set(arr); return arr[i]},
async remove(id){const arr=S.get().filter(x=>x.id!==id); S.set(arr)}
};
}
function show(v){[listV,readV,editor].forEach(x=>x.classList.add('hidden')); v.classList.remove('hidden'); document.documentElement.classList.toggle('reading', v===readV)}
async function renderList(){
const a=await API.list();
if(!a.length){listV.innerHTML='<div class="small">No articles yet. Click New to create one.</div>';return}
listV.innerHTML=a.map(x=>`<div class="card" data-id="${x.id}"><img src="${x.thumb||''}" alt="thumb"><h3>${esc(x.title)}</h3></div>`).join('');
}
function esc(s){return (s||'').replace(/[&<>]/g,m=>({"&":"&amp;","<":"&lt;",">":"&gt;"}[m]))}
function pick(el,cb){el.onchange=e=>{const f=e.target.files&&e.target.files[0]; if(f) fileToDataURL(f).then(cb)}; el.click()}
function fileToDataURL(f){return new Promise((res,rej)=>{const r=new FileReader(); r.onload=()=>res(r.result); r.onerror=rej; r.readAsDataURL(f)})}
function insertAtSel(node){const s=window.getSelection(); if(!s||!s.rangeCount){content.appendChild(node); return} const r=s.getRangeAt(0); r.deleteContents(); r.insertNode(node); r.setStartAfter(node); r.setEndAfter(node); s.removeAllRanges(); s.addRange(r)}
function dropHandler(zone, cb){zone.addEventListener('dragover',e=>{e.preventDefault(); zone.style.background='#f7f7f7'}); zone.addEventListener('dragleave',()=>zone.style.background=''); zone.addEventListener('drop',e=>{e.preventDefault(); zone.style.background=''; const fs=[...(e.dataTransfer.files||[])]; fs.forEach(cb)});}
// Thumb drag/drop or file select
dropHandler(tDrop, f=>{ if(!f.type.startsWith('image/')) return; fileToDataURL(f).then(u=>{tPrev.src=u})});
tFile.addEventListener('change',e=>{const f=e.target.files[0]; if(f) fileToDataURL(f).then(u=>tPrev.src=u)});
tDrop.addEventListener('click',()=>tFile.click());
// Content drag/drop insert and paste
content.addEventListener('paste',e=>{const it=(e.clipboardData||{}).items||[]; for(const x of it){const f=x.getAsFile&&x.getAsFile(); if(!f) continue; e.preventDefault(); if(f.type.startsWith('image/')) fileToDataURL(f).then(u=>{const img=new Image(); img.src=u; insertAtSel(img)})
else if(f.type.startsWith('video/')) fileToDataURL(f).then(u=>{const v=document.createElement('video'); v.controls=true; v.src=u; v.style.maxWidth='100%'; insertAtSel(v)}) } });
dropHandler(content, f=>{ if(f.type.startsWith('image/')) { fileToDataURL(f).then(u=>{const img=new Image(); img.src=u; insertAtSel(img)}) } else if(f.type.startsWith('video/')) { fileToDataURL(f).then(u=>{const v=document.createElement('video'); v.controls=true; v.src=u; v.style.maxWidth='100%'; insertAtSel(v)}) } });
// Insert buttons
insImg.onclick=()=>pick(imgFile, u=>{const img=new Image(); img.src=u; insertAtSel(img)});
insVid.onclick=()=>pick(vidFile, u=>{const v=document.createElement('video'); v.controls=true; v.src=u; v.style.maxWidth='100%'; insertAtSel(v)});
// Save article
saveBtn.onclick=async()=>{
let th=tPrev.src||''; if(!th){const fi=content.querySelector('img'); if(fi) th=fi.src}
const obj={title:title.value.trim()||'Untitled', body:content.innerHTML, thumb:th||''};
await API.create(obj); selId=null; clearEditor(); await renderList(); show(listV);
};
function clearEditor(){ title.value=''; content.innerHTML=''; tPrev.removeAttribute('src') }
cancelBtn.onclick=()=>{selId=null; clearEditor(); show(listV)};
// List click -> read (ensure full focus by switching view)
listV.addEventListener('click',async e=>{const card=e.target.closest('.card'); if(!card) return; const id=card.getAttribute('data-id'); const a=await API.get(id); if(!a) return; openRead(a)});
function openRead(a){ selId=a.id; rTh.src=a.thumb||''; rT.textContent=a.title||''; rB.innerHTML=a.body||''; dt.textContent=new Date(a.createdAt||Date.now()).toLocaleString(); show(readV) }
// Delete
del.onclick=async()=>{ if(!selId) return; await API.remove(selId); selId=null; await renderList(); show(listV) };
// Nav
toList.onclick=async()=>{await renderList(); show(listV)};
toNew.onclick=()=>{selId=null; clearEditor(); show(editor)};
back.onclick=()=>{show(listV)};
// No deep linking; keep UI simple and focused
(async function init(){
API=await detectAPI();
const items=await API.list();
if(!items.length){await API.create({title:'Welcome to Mini Articles', body:'<p>Edit or create your first article. Drop images or videos right into the text.</p>', thumb:''});}
await renderList();
show(listV);
})();
})();
</script>
</html>

46
articles/run.sh Executable file
View File

@ -0,0 +1,46 @@
#!/usr/bin/env bash
set -euo pipefail
# Run Mini Articles (backend + frontend)
# Options (env): HOST (default 127.0.0.1), PORT (default 8000), ARTICLES_DATA_DIR
DIR=$(cd -- "$(dirname -- "$0")" && pwd)
SITE_DIR="$DIR"
HOST="${HOST:-127.0.0.1}"
PORT="${PORT:-8000}"
PYTHON_BIN="${PYTHON:-}"
if [[ -z "${PYTHON_BIN}" ]]; then
if command -v python >/dev/null 2>&1; then PYTHON_BIN=python
elif command -v python3 >/dev/null 2>&1; then PYTHON_BIN=python3
else
echo "Python is required but not found in PATH." >&2
exit 1
fi
fi
if [[ ! -f "$SITE_DIR/server.py" ]]; then
echo "server.py not found in $SITE_DIR" >&2
exit 1
fi
# Start server in background
export HOST PORT ARTICLES_DATA_DIR
"$PYTHON_BIN" "$SITE_DIR/server.py" &
SRV_PID=$!
trap 'kill $SRV_PID 2>/dev/null || true' EXIT INT TERM
# Give it a moment to start
sleep 0.5
URL="http://$HOST:$PORT/"
# Try to open browser on Linux
if command -v xdg-open >/dev/null 2>&1; then
xdg-open "$URL" >/dev/null 2>&1 || true
fi
echo "Mini Articles running at $URL"
echo "Press Ctrl+C to stop."
# Wait on server
wait "$SRV_PID"

19
articles/run_tests.sh Executable file
View File

@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
# Run only the website tests from this directory
DIR=$(cd -- "$(dirname -- "$0")" && pwd)
cd "$DIR"
PYTHON_BIN="${PYTHON:-}"
if [[ -z "${PYTHON_BIN}" ]]; then
if command -v python >/dev/null 2>&1; then PYTHON_BIN=python
elif command -v python3 >/dev/null 2>&1; then PYTHON_BIN=python3
else
echo "Python is required but not found in PATH." >&2
exit 1
fi
fi
# Be explicit to avoid collecting tests from other repo paths
"$PYTHON_BIN" -m pytest -q test_site_size.py test_server_api.py

191
articles/server.py Normal file
View File

@ -0,0 +1,191 @@
#!/usr/bin/env python3
import json
import os
import random
import threading
from http import HTTPStatus
from http.server import SimpleHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse
# Storage helpers
def _data_dir():
base = os.environ.get("ARTICLES_DATA_DIR") or os.path.join(os.path.dirname(__file__), "data")
os.makedirs(base, exist_ok=True)
return base
def _data_file():
return os.path.join(_data_dir(), "articles.json")
def _load():
fp = _data_file()
if not os.path.exists(fp):
return []
try:
with open(fp, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return []
def _save(articles):
with open(_data_file(), "w", encoding="utf-8") as f:
json.dump(articles, f, ensure_ascii=False)
def _uid():
return f"{int(__import__('time').time()*1000):x}{random.randrange(1<<20):x}"[:16]
class App(SimpleHTTPRequestHandler):
# Serve static from the site directory
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=os.path.dirname(__file__), **kwargs)
# CORS / common headers
def _headers(self, code=200, ctype="application/json"):
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
def do_OPTIONS(self):
if self.path.startswith("/api/"):
self._headers(HTTPStatus.NO_CONTENT)
self.end_headers()
else:
super().do_OPTIONS()
def do_GET(self):
if self.path.startswith("/api/articles"):
self._api_get()
else:
# Serve static (index.html for root)
if self.path == "/":
self.path = "/index.html"
return super().do_GET()
def do_POST(self):
if self.path == "/api/articles":
self._api_post()
else:
self.send_error(HTTPStatus.NOT_FOUND, "Unknown endpoint")
def do_PUT(self):
if self.path.startswith("/api/articles/"):
self._api_put()
else:
self.send_error(HTTPStatus.NOT_FOUND, "Unknown endpoint")
def do_DELETE(self):
if self.path.startswith("/api/articles/"):
self._api_delete()
else:
self.send_error(HTTPStatus.NOT_FOUND, "Unknown endpoint")
# --- API methods ---
def _read_json(self):
try:
ln = int(self.headers.get('Content-Length', 0))
except Exception:
ln = 0
raw = self.rfile.read(ln) if ln > 0 else b""
if not raw:
return {}
try:
return json.loads(raw.decode("utf-8"))
except Exception:
return {}
def _api_get(self):
parts = urlparse(self.path).path.strip("/").split("/")
arts = sorted(_load(), key=lambda x: x.get("createdAt", 0), reverse=True)
if len(parts) == 1: # /api
self.send_error(HTTPStatus.NOT_FOUND)
return
if len(parts) == 2 and parts[1] == "articles":
self._headers(HTTPStatus.OK)
self.end_headers()
self.wfile.write(json.dumps(arts).encode("utf-8"))
return
# /api/articles/<id>
if len(parts) == 3 and parts[1] == "articles":
art = next((a for a in arts if a.get("id") == parts[2]), None)
if not art:
self._headers(HTTPStatus.NOT_FOUND)
self.end_headers()
return
self._headers(HTTPStatus.OK)
self.end_headers()
self.wfile.write(json.dumps(art).encode("utf-8"))
return
self.send_error(HTTPStatus.NOT_FOUND)
def _api_post(self):
data = self._read_json()
title = (data.get("title") or "Untitled").strip()
body = data.get("body") or ""
thumb = data.get("thumb") or ""
art = {"id": _uid(), "title": title, "body": body, "thumb": thumb, "createdAt": int(__import__('time').time()*1000)}
arts = _load()
arts.insert(0, art)
_save(arts)
self._headers(HTTPStatus.CREATED)
self.end_headers()
self.wfile.write(json.dumps(art).encode("utf-8"))
def _api_put(self):
art_id = urlparse(self.path).path.rsplit("/", 1)[-1]
data = self._read_json()
arts = _load()
for i, a in enumerate(arts):
if a.get("id") == art_id:
a.update({k: v for k, v in data.items() if k in ("title", "body", "thumb")})
a["updatedAt"] = int(__import__('time').time()*1000)
arts[i] = a
_save(arts)
self._headers(HTTPStatus.OK)
self.end_headers()
self.wfile.write(json.dumps(a).encode("utf-8"))
return
self._headers(HTTPStatus.NOT_FOUND)
self.end_headers()
def _api_delete(self):
art_id = urlparse(self.path).path.rsplit("/", 1)[-1]
arts = _load()
new_arts = [a for a in arts if a.get("id") != art_id]
if len(new_arts) == len(arts):
self._headers(HTTPStatus.NOT_FOUND)
self.end_headers()
return
_save(new_arts)
self._headers(HTTPStatus.NO_CONTENT)
self.end_headers()
def serve(host="127.0.0.1", port=8000):
httpd = HTTPServer((host, port), App)
print(f"Serving Mini Articles on http://{host}:{port}")
try:
httpd.serve_forever()
except KeyboardInterrupt:
pass
finally:
httpd.server_close()
def make_server(host="127.0.0.1", port=0):
httpd = HTTPServer((host, port), App)
th = threading.Thread(target=httpd.serve_forever, daemon=True)
th.start()
return httpd, th
if __name__ == "__main__":
host = os.environ.get("HOST", "127.0.0.1")
port = int(os.environ.get("PORT", "8000"))
serve(host, port)

View File

@ -0,0 +1,72 @@
import json
import os
import time
import urllib.request
import urllib.error
SITE_DIR = os.path.join(os.path.dirname(__file__), '..', 'site')
import sys
sys.path.insert(0, SITE_DIR)
from server import make_server # type: ignore
def _req(url, method="GET", data=None):
if data is not None and not isinstance(data, (bytes, bytearray)):
data = json.dumps(data).encode("utf-8")
req = urllib.request.Request(url, data=data, method=method)
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req, timeout=5) as resp:
body = resp.read()
return resp.getcode(), body
def test_crud_roundtrip(tmp_path):
# Isolate storage
os.environ["ARTICLES_DATA_DIR"] = str(tmp_path)
httpd, th = make_server(port=0)
host, port = httpd.server_address
base = f"http://{host}:{port}"
# Create
code, body = _req(base+"/api/articles", method="POST", data={
"title": "T1",
"body": "<p>Hello</p>",
"thumb": "data:image/png;base64,xyz"
})
assert code == 201
created = json.loads(body)
art_id = created["id"]
# List
code, body = _req(base+"/api/articles")
assert code == 200
items = json.loads(body)
assert any(a["id"] == art_id for a in items)
# Get one
code, body = _req(base+f"/api/articles/{art_id}")
assert code == 200
got = json.loads(body)
assert got["title"] == "T1"
# Update
code, body = _req(base+f"/api/articles/{art_id}", method="PUT", data={"title": "T2"})
assert code == 200
updated = json.loads(body)
assert updated["title"] == "T2"
# Delete
code, _ = _req(base+f"/api/articles/{art_id}", method="DELETE")
assert code == 204
# Ensure gone
try:
_req(base+f"/api/articles/{art_id}")
assert False, "Expected 404"
except urllib.error.HTTPError as e:
assert e.code == 404
httpd.shutdown()
th.join(timeout=2)

View File

@ -0,0 +1,16 @@
import os
# Budget for the entire website (single file) in bytes
BUDGET = 14 * 1024 # 14 KiB
HERE = os.path.dirname(__file__)
SITE_FILE = os.path.join(HERE, 'index.html')
def test_site_file_exists():
assert os.path.exists(SITE_FILE), f"Missing site file: {SITE_FILE}"
def test_site_size_under_budget():
size = os.path.getsize(SITE_FILE)
assert size <= BUDGET, f"Site size {size} bytes exceeds budget {BUDGET}"

View File

@ -1,37 +0,0 @@
import os
import sys
import chess
# Ensure repo root in path for 'PYTHON' package imports when running locally
REPO_ROOT = os.path.dirname(os.path.abspath(__file__ + "/.."))
PARENT = os.path.dirname(REPO_ROOT)
if PARENT not in sys.path:
sys.path.insert(0, PARENT)
from PYTHON.lichess_bot.engine import RandomEngine # noqa: E402
def position_after_italian_bg5():
# 1.e4 e5 2.Nf3 Nc6 3.Bc4 Nf6 4.d3 Bc5 5.O-O d6 6.Bg5
moves = [
"e4", "e5", "Nf3", "Nc6", "Bc4", "Nf6", "d3", "Bc5", "O-O", "d6", "Bg5"
]
board = chess.Board()
for san in moves:
board.push_san(san)
return board
def test_engine_avoids_unsound_bxf2_in_italian_bg5():
board = position_after_italian_bg5()
eng = RandomEngine(depth=4, max_time_sec=1.5)
move, expl = eng.choose_move_with_explanation(board, time_budget_sec=1.5)
# The engine should avoid Bxf2+ here (known blunder); assert chosen move isn't that
bxf2 = chess.Move.from_uci("c5f2")
assert move != bxf2, f"Engine picked unsound Bxf2+: {expl}"
# Also ensure Bxf2+ is not the top candidate in analyze list for small depth
# We can do a weaker invariant: if the engine considered only a couple moves earlier,
# ensure current top move either equals move or is not Bxf2.
assert move is not None
assert move is None or move != bxf2