Add dark website and documentation with GitHub Pages deployment

Include the updated desktop screenshot, active-monitor screenshot capture and editor locking, and Settings window drag areas.
This commit is contained in:
Samuel Onoja committed 2026-09-27 10:34:33 +01:00
1 parent e5e2b74a25
commit 0cd48bcc81
15 files changed
+1794 -12

No files matched your search

+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""Build Ferese's static website and handbook from the project Markdown docs."""
from pathlib import Path
from html import escape
import json
import re
import shutil
from urllib.parse import urlsplit
from markdown_it import MarkdownIt
ROOT = Path(__file__).resolve().parent.parent
OUTPUT = ROOT / 'build' / 'site'
REPO = 'https://github.com/borngraced/ferese'
PAGES = [
('index', 'Introduction', ROOT / 'site/content/index.md'),
('installation', 'Installation', ROOT / 'docs/installation.md'),
('configuration', 'Configuration', ROOT / 'docs/configuration.md'),
('desktop-widgets', 'Desktop widgets', ROOT / 'docs/desktop-widgets.md'),
('notifications', 'Notifications', ROOT / 'docs/notifications.md'),
]
md = MarkdownIt('commonmark', {'html': False}).enable('table')
OUTPUT.mkdir(parents=True, exist_ok=True)
for name in ('index.html', 'styles.css', 'app.js'):
shutil.copy2(ROOT / 'site' / name, OUTPUT / name)
(OUTPUT / 'assets').mkdir(exist_ok=True)
for name in ('ferese-desktop.png', 'ferese-monochrome.png'):
shutil.copy2(ROOT / 'docs/images' / name, OUTPUT / 'assets' / name)
shutil.copy2(ROOT / 'packaging/icons/ferese.svg', OUTPUT / 'assets/ferese.svg')
(OUTPUT / 'docs').mkdir(exist_ok=True)
(OUTPUT / '.nojekyll').touch()
search_index = []
def rewrite_link(href, source):
url = urlsplit(href)
if url.scheme or url.netloc or not url.path:
return href
basename = Path(url.path).stem
if url.path.endswith('.md') and basename in {page[0] for page in PAGES}:
return f'{basename}.html' + (f'#{url.fragment}' if url.fragment else '')
destination = (source.parent / url.path).resolve().relative_to(ROOT)
return f'{REPO}/blob/main/{destination}' + (f'#{url.fragment}' if url.fragment else '')
for page_number, (slug, title, source) in enumerate(PAGES):
tokens = md.parse(source.read_text())
toc, ids, sections = [], set(), []
current = None
for i, token in enumerate(tokens):
if token.type == 'heading_open':
heading = tokens[i + 1].content
base = re.sub(r'[^\w\s-]', '', heading.lower()).strip()
base = re.sub(r'[\s_]+', '-', base)
anchor = base
suffix = 1
while anchor in ids:
anchor = f'{base}-{suffix}'
suffix += 1
ids.add(anchor)
token.attrSet('id', anchor)
if token.tag != 'h1':
toc.append((anchor, heading))
current = {'page': title, 'heading': heading, 'url': f'{slug}.html#{anchor}', 'text': ''}
sections.append(current)
elif current and token.type in ('inline', 'fence', 'code_block'):
current['text'] += token.content + ' '
for child in token.children or []:
if child.type == 'link_open':
child.attrSet('href', rewrite_link(child.attrGet('href'), source))
search_index.extend(sections)
body = md.renderer.render(tokens, md.options, {})
body = body.replace('<table>', '<div class="table-scroll" tabindex="0" role="region" aria-label="Reference table"><table>').replace('</table>', '</table></div>')
navigation = ''.join(f'<a href="{s}.html"' + (' aria-current="page"' if s == slug else '') + f'>{escape(t)}</a>' for s, t, _ in PAGES)
contents = ''.join(f'<a href="#{anchor}">{escape(heading)}</a>' for anchor, heading in toc)
pager = ''
if page_number:
prev = PAGES[page_number - 1]
pager += f'<a href="{prev[0]}.html"><small>PREVIOUS</small>← {escape(prev[1])}</a>'
if page_number + 1 < len(PAGES):
nxt = PAGES[page_number + 1]
pager += f'<a href="{nxt[0]}.html"><small>NEXT</small>{escape(nxt[1])} →</a>'
doc = f'''<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#10151c"><title>{escape(title)} — Ferese handbook</title><meta name="description" content="{escape(title)} in the Ferese handbook: setup, configuration, and guides for your Wayland desktop.">
<link rel="icon" href="../assets/ferese.svg" type="image/svg+xml"><link rel="stylesheet" href="../styles.css"><script src="../app.js" defer></script></head>
<body class="docs-page"><a class="skip" href="#main">Skip to content</a>
<header class="site-header"><div class="header-inner"><a class="wordmark" href="../" aria-label="Ferese home"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 3h6v18H3zM11 3h10v6H11zM11 11h7v6h-7z"/></svg>ferese</a><nav aria-label="Main navigation"><a href="../">Overview</a><a href="./" aria-current="page">Documentation</a><a href="{REPO}">GitHub ↗</a></nav></div></header>
<div class="docs-layout"><aside class="docs-sidebar"><div class="docs-search" hidden><label class="sr-only" for="docs-search">Search the handbook</label><input id="docs-search" type="search" placeholder="Search the handbook…" autocomplete="off" aria-controls="search-results"><div class="search-results" id="search-results" hidden></div><p class="sr-only" id="search-status" role="status"></p></div><details open><summary>FERESE HANDBOOK</summary><nav aria-label="Documentation">{navigation}</nav></details><div class="sidebar-links"><a href="{REPO}/issues">Report an issue ↗</a><a href="../">Back to Ferese ←</a></div></aside>
<main class="docs-article" id="main"><div class="breadcrumb"><a href="./">Handbook</a><span aria-hidden="true">/</span><span>{escape(title)}</span></div><article class="prose">{body}</article><a class="edit-link" href="{REPO}/blob/main/{source.relative_to(ROOT)}">View this guide on GitHub ↗</a><nav class="docs-pager" aria-label="Previous and next guide">{pager}</nav></main>
<aside class="docs-toc" aria-label="On this page">{'<p>ON THIS PAGE</p><nav>' + contents + '</nav>' if contents else ''}</aside></div>
<footer class="site-footer page-width"><a class="wordmark" href="../">ferese</a><p>A beautiful, fluid Wayland desktop.</p><a href="{REPO}">Built in the open ↗</a></footer></body></html>'''
(OUTPUT / 'docs' / f'{slug}.html').write_text(doc)
(OUTPUT / 'docs/search-index.json').write_text(json.dumps(search_index, ensure_ascii=False))
print(f'Built website and {len(PAGES)} handbook pages at {OUTPUT}')
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""Exercise screenshot selection and locking without a Wayland desktop."""
import json
import os
from pathlib import Path
import subprocess
import tempfile
import time
import unittest
SCRIPT = Path(__file__).resolve().parents[2] / 'packaging/ferese-screenshot'
class ScreenshotTest(unittest.TestCase):
def test_active_output_all_outputs_and_editor_lock(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
bins = root / 'bin'
bins.mkdir()
env = dict(os.environ, XDG_RUNTIME_DIR=directory, TEST_ROOT=directory,
PATH=f'{bins}:{os.environ["PATH"]}')
(root / 'outputs.json').write_text(json.dumps([
{'name': 'eDP-1', 'enabled': True, 'focused': False},
{'name': 'HDMI-A-1', 'enabled': True, 'focused': True},
]))
scripts = {
'grim': '''#!/bin/bash
printf '%s\\n' "$@" >> "$TEST_ROOT/captures"
printf image > "${@: -1}"
''',
'feresectl': '#!/bin/bash\ncat "$TEST_ROOT/outputs.json"\n',
'notify-send': '#!/bin/bash\nexit 0\n',
'wl-copy': '#!/bin/bash\nexit 0\n',
'xdg-user-dir': '#!/bin/bash\nprintf "%s" "$TEST_ROOT/Pictures"\n',
'satty': '''#!/bin/bash
if [[ -e /proc/$$/fd/9 ]]; then touch "$TEST_ROOT/inherited-lock"; fi
printf 'editor\\n' >> "$TEST_ROOT/editors"
touch "$TEST_ROOT/ready"
while [[ ! -e "$TEST_ROOT/release" ]]; do sleep 0.02; done
''',
}
for name, contents in scripts.items():
path = bins / name
path.write_text(contents)
path.chmod(0o755)
def run(mode):
return subprocess.run(['bash', str(SCRIPT), mode], env=env,
capture_output=True, timeout=3)
first = subprocess.Popen(['bash', str(SCRIPT), '--full'], env=env,
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
try:
deadline = time.monotonic() + 5
while not (root / 'ready').exists():
if time.monotonic() > deadline or first.poll() is not None:
self.fail('First screenshot did not open its editor')
time.sleep(0.02)
second = run('--full')
self.assertEqual(second.returncode, 0, second.stderr)
self.assertEqual((root / 'editors').read_text().splitlines(), ['editor'])
self.assertFalse((root / 'inherited-lock').exists())
(root / 'release').touch()
_, stderr = first.communicate(timeout=3)
self.assertEqual(first.returncode, 0, stderr)
third = run('--full')
self.assertEqual(third.returncode, 0, third.stderr)
self.assertEqual((root / 'editors').read_text().splitlines(), ['editor', 'editor'])
self.assertFalse(list((root / 'Pictures/Screenshots').glob('*.png')))
self.assertFalse(list(root.glob('ferese-screenshot-*.png')))
captures = (root / 'captures').read_text().splitlines()
self.assertEqual(captures[0:2], ['-o', 'HDMI-A-1'])
self.assertEqual(captures[3:5], ['-o', 'HDMI-A-1'])
all_monitors = run('--all')
self.assertEqual(all_monitors.returncode, 0, all_monitors.stderr)
captures = (root / 'captures').read_text().splitlines()
self.assertEqual(len(captures), 7) # --all passes only the filename
for outputs in [[], [{'enabled': True, 'name': 'A'}, {'enabled': True, 'name': 'B'}]]:
(root / 'outputs.json').write_text(json.dumps(outputs))
unavailable = run('--full')
self.assertNotEqual(unavailable.returncode, 0)
self.assertIn(b'Could not determine the active monitor', unavailable.stderr)
self.assertEqual((root / 'captures').read_text().splitlines(), captures)
(root / 'outputs.json').write_text(json.dumps([
{'name': 'eDP-1', 'enabled': False, 'focused': True},
{'name': 'HDMI-A-1', 'enabled': True, 'focused': False},
]))
single = run('--full')
self.assertEqual(single.returncode, 0, single.stderr)
self.assertEqual((root / 'captures').read_text().splitlines()[-3:-1], ['-o', 'HDMI-A-1'])
finally:
(root / 'release').touch()
if first.poll() is None:
first.terminate()
first.communicate(timeout=3)
if __name__ == '__main__':
unittest.main()