Publish topic guides to the wiki and document tabbed windows

Publish the guides from this repository to the GitHub Wiki. Organize them into Usage, Configuration and Development, with 15 separate configuration topics and redirects for the former website guide URLs.

Add a tabbed-windows guide covering grouping, switching tabs and taking windows out. Document the scrolling indicator's defaults and limits, and link the guide from the README, shortcuts and IPC pages. Update from main, including the latest tabbed-layout changes.

Separate Activities setup, configuration and publishing documentation. Review the guide prose with the unslop skill, keeping existing examples and heading links. The wiki workflow uses GitHub's built-in token to publish documentation changes on main.

Validation:

- 20 documentation tests pass, including README links and Activities/tab-indicator field coverage.
- The publisher builds 46 wiki pages; the site build and Python syntax checks pass.
- All 115 code examples from main are preserved.
- The Python suite passes: 90 tests passed and 31 opt-in integration tests skipped, with the system GI module available.
This commit is contained in:
Samuel Onoja authored and GitHub committed 2026-10-13 00:32:23 +01:00
1 parent eaeabebe73
commit eb84104940
37 files changed
+2178 -1510

No files matched your search

+44 -159
View File
@@ -1,172 +1,57 @@
#!/usr/bin/env python3
"""Build Ferese's static website and handbook from the project Markdown docs."""
from pathlib import Path
"""Build the Ferese homepage and redirect former handbook URLs to the wiki."""
from html import escape
import importlib.util
import json
import re
from pathlib import Path
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/ferese-wm/ferese'
PAGES = [
('index', 'Introduction', ROOT / 'site/content/index.md'),
('screenshots', 'Screenshots', ROOT / 'docs/screenshots.md'),
('why-ferese', 'Why Ferese', ROOT / 'docs/why-ferese.md'),
('installation', 'Installation', ROOT / 'docs/installation.md'),
('packaging', 'Packages and releases', ROOT / 'docs/packaging.md'),
('configuration', 'Configuration', ROOT / 'docs/configuration.md'),
('panel-configuration', 'Panel configuration', ROOT / 'docs/panel-configuration.md'),
('animation-model', 'Animation model', ROOT / 'docs/animation-model.md'),
('shortcuts', 'Shortcuts and gestures', ROOT / 'docs/shortcuts.md'),
('locking', 'Lock screen', ROOT / 'docs/locking.md'),
('screen-sharing', 'Screen sharing', ROOT / 'docs/screen-sharing.md'),
('portals', 'Desktop portals', ROOT / 'docs/portals.md'),
('development', 'Development', ROOT / 'docs/development.md'),
]
md = MarkdownIt('commonmark', {'html': False}).enable('table')
if OUTPUT.exists():
shutil.rmtree(OUTPUT)
OUTPUT.mkdir(parents=True)
for name in ('index.html', 'home.css', 'handbook.css', 'styles.css', 'app.js'):
shutil.copy2(ROOT / 'site' / name, OUTPUT / name)
(OUTPUT / 'assets').mkdir(exist_ok=True)
shutil.copytree(ROOT / 'site/assets', OUTPUT / 'assets', dirs_exist_ok=True)
shutil.copytree(ROOT / 'docs/images', OUTPUT / 'assets', dirs_exist_ok=True)
shutil.copytree(ROOT / 'assets/branding', OUTPUT / 'assets/branding')
(OUTPUT / 'docs').mkdir(exist_ok=True)
(OUTPUT / '.nojekyll').touch()
search_index = []
OUTPUT = ROOT / 'build/site'
spec = importlib.util.spec_from_file_location('wiki', ROOT / 'scripts/build-wiki.py')
wiki = importlib.util.module_from_spec(spec)
spec.loader.exec_module(wiki)
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 '')
path = (source.parent / url.path).resolve()
if path.is_relative_to(ROOT / 'docs/images'):
return '../assets/' + path.relative_to(ROOT / 'docs/images').as_posix()
destination = path.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))
elif child.type == 'image':
image = urlsplit(child.attrGet('src'))
if not image.scheme and not image.netloc:
path = (source.parent / image.path).resolve()
relative = path.relative_to(ROOT / 'docs/images')
if not path.is_file():
raise FileNotFoundError(path)
child.attrSet('src', f'../assets/{relative.as_posix()}')
child.attrSet('loading', 'lazy')
child.attrSet('decoding', 'async')
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 class="pager-previous" 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 class="pager-next" href="{nxt[0]}.html"><small>Next</small>{escape(nxt[1])} →</a>'
mobile_contents = (
f'<details class="mobile-contents"><summary>On this page</summary><nav aria-label="Page sections">{contents}</nav></details>'
if contents else ''
)
doc = f'''<!doctype html>
def redirect_page(destination):
return 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="#111821">
<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/branding/ferese.svg" type="image/svg+xml">
<link rel="icon" href="../assets/favicon.ico" sizes="16x16 32x32 48x48">
<link rel="icon" href="../assets/favicon-32.png" type="image/png" sizes="32x32">
<link rel="stylesheet" href="../styles.css">
<link rel="stylesheet" href="../handbook.css">
<script src="../app.js" defer></script>
<title>Ferese documentation</title>
<link rel="canonical" href="{escape(destination, quote=True)}">
<meta http-equiv="refresh" content="0;url={escape(destination, quote=True)}">
<script>location.replace({json.dumps(destination)} + location.hash);</script>
</head>
<body class="docs-page">
<svg class="icon-definitions" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><defs>
<symbol id="i-arrow" viewBox="0 0 24 24"><path d="M5 12h14M13 6l6 6-6 6"/></symbol>
<symbol id="i-external" viewBox="0 0 24 24"><path d="M7 17 17 7M7 7h10v10"/></symbol>
</defs></svg>
<a class="skip" href="#main">Skip to content</a>
<header class="site-header">
<div class="header-inner page-width">
<a class="brand" href="../" aria-label="Ferese home"><img src="../assets/branding/ferese.svg" width="34" height="34" alt=""><span>Ferese</span></a>
<nav aria-label="Main navigation"><a href="../#make-room">The desktop</a><a href="../#appearance">Appearance</a><a href="./" aria-current="page">Handbook</a></nav>
<div class="header-actions"><a class="small-button" href="installation.html">Get Ferese</a></div>
</div>
</header>
<div class="docs-layout page-width">
<aside class="docs-sidebar" aria-label="Handbook navigation">
<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 class="handbook-navigation" open>
<summary>Browse the handbook <svg class="icon" aria-hidden="true"><use href="#i-arrow"/></svg></summary>
<nav aria-label="Documentation">{navigation}</nav>
</details>
<div class="sidebar-links">
<a href="{REPO}/issues">Report an issue <svg class="icon" aria-hidden="true"><use href="#i-external"/></svg></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>
{mobile_contents}
<article class="prose">{body}</article>
<a class="edit-link" href="{REPO}/blob/main/{source.relative_to(ROOT)}">View this guide on GitHub <svg class="icon" aria-hidden="true"><use href="#i-external"/></svg></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="brand" href="../" aria-label="Ferese home"><img src="../assets/branding/ferese.svg" width="28" height="28" alt=""><span>Ferese</span></a>
<span>Built in the open for Linux.</span>
<nav aria-label="Footer navigation"><a href="./">Handbook</a><a href="{REPO}">GitHub <svg class="icon" aria-hidden="true"><use href="#i-external"/></svg></a></nav>
</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}')
<body><p>This guide is on the <a href="{escape(destination, quote=True)}">Ferese wiki</a>.</p></body>
</html>
'''
def main():
if OUTPUT.exists():
shutil.rmtree(OUTPUT)
OUTPUT.mkdir(parents=True)
for name in ('index.html', 'home.css', 'styles.css'):
shutil.copy2(ROOT / 'site' / name, OUTPUT / name)
(OUTPUT / 'assets').mkdir(exist_ok=True)
shutil.copytree(ROOT / 'site/assets', OUTPUT / 'assets', dirs_exist_ok=True)
shutil.copytree(ROOT / 'docs/images', OUTPUT / 'assets', dirs_exist_ok=True)
shutil.copytree(ROOT / 'assets/branding', OUTPUT / 'assets/branding')
(OUTPUT / '.nojekyll').touch()
handbook = OUTPUT / 'docs'
handbook.mkdir()
(handbook / 'index.html').write_text(redirect_page(wiki.WIKI))
for source in wiki.guide_sources():
if not source.is_relative_to(ROOT / 'docs') or source.stem == 'README':
continue
destination = wiki.WIKI + '/' + wiki.page_name(source)
output = handbook / source.relative_to(ROOT / 'docs').with_suffix('.html')
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(redirect_page(destination))
print(f'Built homepage and documentation redirects at {OUTPUT}')
if __name__ == '__main__':
main()
+207
View File
@@ -0,0 +1,207 @@
#!/usr/bin/env python3
"""Publish repository guides as GitHub Wiki Markdown, preserving their prose."""
import argparse
import json
from pathlib import Path
import re
import subprocess
from urllib.parse import unquote, urlsplit
from markdown_it import MarkdownIt
ROOT = Path(__file__).resolve().parent.parent
REPO = "https://github.com/ferese-wm/ferese"
WIKI = REPO + "/wiki"
MANIFEST = ".ferese-pages.json"
MD = MarkdownIt("commonmark").enable("table")
NAMES = {
"README": "Home",
"activities": "Live-Activities",
"ipc": "IPC",
"locking": "Lock-Screen",
"packaging": "Packages-and-Releases",
"portals": "Desktop-Portals",
"screen-sharing": "Screen-Sharing-and-Recording",
"shortcuts": "Shortcuts-and-Gestures",
}
ALIASES = {
"Appearance": [("Configuration-Appearance", "", "Appearance")],
"Desktop-Widgets": [("Configuration", "", "Configuration")],
"Displays": [("Configuration-Outputs", "", "Displays")],
"Input-and-Bindings": [
("Configuration-Input", "", "Input"),
("Configuration-Bindings", "", "Commands and bindings"),
],
"Layout-and-Motion": [
("Configuration-Layout", "", "Layout"),
("Configuration-Animations", "", "Animation configuration"),
("Animation-Model", "", "Animation model"),
],
"Services-and-Locking": [
("Configuration-Startup-And-Locking", "", "Login items"),
("Lock-Screen", "", "Lock screen"),
("Desktop-Portals", "", "Desktop portals"),
],
"Window-Rules": [("Configuration-Window-Rules", "", "Window rules")],
}
# Inline links/images and reference definitions. Only destinations recognized by
# the Markdown parser are rewritten; examples in code blocks remain untouched.
LINK = re.compile(r"(?P<code>(?P<ticks>`+).*?(?P=ticks))|(?P<prefix>\]\(|^ {0,3}\[[^]\n]+\]:\s*)(?P<url><[^>\n]+>|[^\s)]+)", re.MULTILINE)
def page_name(source):
if source == ROOT / "site/README.md":
return "Website-Development"
if source == ROOT / "crates/ferese-theme/README.md":
return "Theme-Components"
if source.parent == ROOT / "docs/development":
return "Development-" + "-".join(word.capitalize() for word in source.stem.split("-"))
if source.parent == ROOT / "docs/configuration":
return "Configuration-" + "-".join(word.capitalize() for word in source.stem.split("-"))
return NAMES.get(source.stem, "-".join(word.capitalize() for word in source.stem.split("-")))
def heading_ids(text):
counts, result = {}, set()
tokens = MD.parse(text)
for index, token in enumerate(tokens):
if token.type != "heading_open":
continue
children = tokens[index + 1].children or []
title = "".join(child.content for child in children if child.type in ("text", "code_inline", "image"))
base = re.sub(r"[^\w\s-]", "", title.lower()).replace(" ", "-")
count = counts.get(base, 0)
anchor = base if count == 0 else f"{base}-{count}"
while anchor in result:
count += 1
anchor = f"{base}-{count}"
counts[base] = count + 1
result.add(anchor)
return result
def destinations(text):
return {
child.attrGet("href" if child.type == "link_open" else "src")
for token in MD.parse(text)
for child in token.children or []
if child.type in ("link_open", "image")
}
def rewrite(text, source, pages, revision):
replacements = {}
for href in destinations(text):
url = urlsplit(href)
if url.scheme or url.netloc:
continue
target = (source.parent / unquote(url.path)).resolve() if url.path else source
if url.path and (not target.is_relative_to(ROOT) or not target.is_file()):
raise ValueError(f"{source.relative_to(ROOT)}: missing link target {href}")
if target in pages:
if url.fragment and unquote(url.fragment) not in heading_ids(target.read_text()):
raise ValueError(f"{source.relative_to(ROOT)}: missing heading {href}")
destination = WIKI + "/" + pages[target]
elif target.suffix == ".md":
raise ValueError(f"{source.relative_to(ROOT)}: unpublished guide {href}")
else:
relative = target.relative_to(ROOT).as_posix()
destination = f"{REPO}/raw/{revision}/{relative}" if target.suffix in (".png", ".jpg", ".webp", ".svg") else f"{REPO}/blob/{revision}/{relative}"
if not url.path:
destination = ""
if url.query:
destination += "?" + url.query
if url.fragment:
destination += "#" + url.fragment
replacements[href] = destination
def replace(match):
if match["code"] is not None:
return match[0]
raw = match["url"]
angled = raw.startswith("<")
href = raw[1:-1] if angled else raw
if href not in replacements:
return match[0]
replacement = replacements[href]
return match["prefix"] + ("<" + replacement + ">" if angled else replacement)
# Use parser line ranges to protect both fenced and indented code blocks.
lines = text.splitlines(keepends=True)
protected = set()
for token in MD.parse(text):
if token.type in ("fence", "code_block"):
protected.update(range(*token.map))
return "".join(line if number in protected else LINK.sub(replace, line) for number, line in enumerate(lines))
def guide_sources():
return (sorted((ROOT / "docs").glob("*.md"))
+ sorted((ROOT / "docs/configuration").glob("*.md"))
+ sorted((ROOT / "docs/development").glob("*.md"))
+ [ROOT / "site/README.md", ROOT / "crates/ferese-theme/README.md"])
def render(revision):
sources = guide_sources()
pages = {source.resolve(): page_name(source) for source in sources}
if len(set(pages.values())) != len(pages):
raise ValueError("Wiki page names must be unique")
output = {}
for source, name in pages.items():
text = rewrite(source.read_text(), source, pages, revision)
output[name + ".md"] = text
for alias, links in ALIASES.items():
targets = []
for name, anchor, title in links:
if name + ".md" not in output:
raise ValueError(f"Alias points to absent page {name}")
if anchor and anchor not in heading_ids(output[name + ".md"]):
raise ValueError(f"Alias points to absent heading {name}#{anchor}")
targets.append(f"- [{title}]({WIKI}/{name}" + ("#" + anchor if anchor else "") + ")")
output[alias + ".md"] = "# " + alias.replace("-", " ") + "\n\nSee the current guides:\n\n" + "\n".join(targets) + "\n"
home = output["Home.md"]
# The documentation index is also the navigation source. Keep descriptions
# on Home, and use its grouped link list for the Wiki sidebar.
sidebar = [f"[Home]({WIKI})"]
for line in home.splitlines():
if line.startswith("## "):
sidebar.extend(["", line, ""])
elif line.startswith("- "):
link = re.match(r"- \[[^]]+\]\([^)]+\)", line)
if link:
sidebar.append(link[0])
output["_Sidebar.md"] = "\n".join(sidebar) + "\n"
output["Home.md"] += f"\nEdit the guide sources in the [main repository]({REPO}/tree/main/docs).\n"
return output
def write_pages(output, destination):
destination.mkdir(parents=True, exist_ok=True)
manifest = destination / MANIFEST
previous = json.loads(manifest.read_text()) if manifest.exists() else []
if not isinstance(previous, list) or any(not isinstance(name, str) or not re.fullmatch(r"[A-Za-z0-9_-]+\.md", name) for name in previous):
raise ValueError("Invalid wiki page manifest")
for name in previous:
if name not in output:
(destination / name).unlink(missing_ok=True)
for name, text in output.items():
(destination / name).write_text(text)
manifest.write_text(json.dumps(sorted(output), indent=2) + "\n")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, default=ROOT / "build/wiki")
args = parser.parse_args()
destination = args.output.resolve()
if destination == ROOT or destination.is_relative_to(ROOT / "docs"):
parser.error("output must not overwrite documentation sources")
revision = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True).strip()
output = render(revision) # Validate every guide before touching destination.
write_pages(output, destination)
print(f"Built {len(output)} wiki pages at {destination}")
if __name__ == "__main__":
main()
+271
View File
@@ -0,0 +1,271 @@
"""Wiki publication must preserve examples and resolve links before writing."""
import importlib.util
import json
import re
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from unittest import mock
ROOT = Path(__file__).resolve().parents[2]
PARSER_AVAILABLE = importlib.util.find_spec('markdown_it') is not None
if PARSER_AVAILABLE:
spec = importlib.util.spec_from_file_location('wiki', ROOT / 'scripts/build-wiki.py')
wiki = importlib.util.module_from_spec(spec)
spec.loader.exec_module(wiki)
@unittest.skipUnless(PARSER_AVAILABLE, 'Install site/requirements.txt for wiki checks')
class WikiTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
(self.root / 'docs').mkdir()
self.source = self.root / 'docs/intro.md'
self.source.write_text('# Intro\n\n## Local heading\n')
self.config = self.root / 'docs/configuration.md'
self.config.write_text('# Configuration\n\n## With `code` & punctuation!\n\n## Again\n\n## Again\n')
self.pages = {self.source: 'Intro', self.config: 'Configuration'}
self.patch = mock.patch.object(wiki, 'ROOT', self.root)
self.patch.start()
self.addCleanup(self.patch.stop)
def rewrite(self, text):
return wiki.rewrite(text, self.source, self.pages, 'deadbeef')
def test_guide_links_and_duplicate_heading_anchors(self):
text = '[Reference](configuration.md#with-code--punctuation) and [again](configuration.md#again-1).\n'
result = self.rewrite(text)
self.assertIn(wiki.WIKI + '/Configuration#with-code--punctuation', result)
self.assertIn(wiki.WIKI + '/Configuration#again-1', result)
def test_local_anchor_and_external_link_unchanged(self):
text = '[Here](#local-heading) and [Elsewhere](https://example.org/a?q=1#fragment).\n'
self.assertEqual(self.rewrite(text), text)
def test_source_files_and_images_are_pinned_to_source_commit(self):
(self.root / 'docs/image.webp').write_bytes(b'image')
(self.root / 'example.kdl').write_text('layout {}')
result = self.rewrite('![Desktop](image.webp) [Example](../example.kdl)\n')
self.assertIn(wiki.REPO + '/raw/deadbeef/docs/image.webp', result)
self.assertIn(wiki.REPO + '/blob/deadbeef/example.kdl', result)
def test_fenced_indented_and_inline_code_are_preserved(self):
example = '[Config](configuration.md)'
text = f'{example}\n\n```md\n{example}\n```\n\n {example}\n\nUse `{example}` as an example.\n'
result = self.rewrite(text)
self.assertIn(wiki.WIKI + '/Configuration', result)
self.assertIn(f'```md\n{example}\n```', result)
self.assertIn(f' {example}', result)
self.assertIn(f'`{example}`', result)
def test_reference_links_and_angle_destinations(self):
result = self.rewrite('[Config][ref]\n\n[ref]: <configuration.md> "Title"\n')
self.assertIn('[ref]: <' + wiki.WIKI + '/Configuration> "Title"', result)
def test_missing_file_missing_anchor_and_unpublished_markdown_fail(self):
for target in ('missing.md', 'configuration.md#missing'):
with self.subTest(target=target), self.assertRaises(ValueError):
self.rewrite('[Missing](' + target + ')')
(self.root / 'unpublished.md').write_text('# Unpublished')
with self.assertRaises(ValueError):
self.rewrite('[Missing](../unpublished.md)')
def test_failed_validation_does_not_replace_any_destination_page(self):
destination = self.root / 'published'
destination.mkdir()
(destination / 'Home.md').write_text('Previously published')
with mock.patch.object(sys, 'argv', ['build-wiki.py', '--output', str(destination)]), mock.patch.object(wiki, 'render', side_effect=ValueError('bad link')), mock.patch.object(wiki.subprocess, 'check_output', return_value='deadbeef'):
with self.assertRaises(ValueError):
wiki.main()
self.assertEqual((destination / 'Home.md').read_text(), 'Previously published')
self.assertEqual(list(destination.iterdir()), [destination / 'Home.md'])
def test_stale_generated_pages_removed_and_manual_pages_preserved(self):
destination = self.root / 'published'
wiki.write_pages({'Old.md': '# Old', 'Home.md': '# Home'}, destination)
(destination / 'Manual.md').write_text('Manual page')
wiki.write_pages({'Home.md': '# New home'}, destination)
self.assertFalse((destination / 'Old.md').exists())
self.assertEqual((destination / 'Manual.md').read_text(), 'Manual page')
self.assertEqual(json.loads((destination / wiki.MANIFEST).read_text()), ['Home.md'])
def test_manifest_cannot_delete_files_outside_destination(self):
destination = self.root / 'published'
destination.mkdir()
(destination / wiki.MANIFEST).write_text('["../keep.md"]')
(self.root / 'keep.md').write_text('Keep')
with self.assertRaises(ValueError):
wiki.write_pages({'Home.md': '# New'}, destination)
self.assertEqual((self.root / 'keep.md').read_text(), 'Keep')
self.assertFalse((destination / 'Home.md').exists())
def test_repeated_generation_has_identical_content(self):
destination = self.root / 'published'
pages = {'Home.md': '# Home', '_Sidebar.md': '[Home](Home)'}
wiki.write_pages(pages, destination)
before = {p.name: p.read_bytes() for p in destination.iterdir()}
wiki.write_pages(pages, destination)
self.assertEqual(before, {p.name: p.read_bytes() for p in destination.iterdir()})
def test_every_repository_guide_is_published_and_navigable(self):
with mock.patch.object(wiki, 'ROOT', ROOT):
output = wiki.render('deadbeef')
sources = wiki.guide_sources()
sidebar = output['_Sidebar.md']
self.assertEqual([line for line in sidebar.splitlines() if line.startswith('## ')], ['## Usage', '## Configuration', '## Development'])
self.assertNotIn('Wiki-Publishing.md', output)
self.assertNotIn('Getting-Started.md', output)
for source in sources:
name = wiki.page_name(source)
with self.subTest(source=source):
self.assertIn(name + '.md', output)
if name != 'Home':
self.assertIn(wiki.WIKI + '/' + name, sidebar)
for name, content in output.items():
for href in wiki.destinations(content):
self.assertFalse(href.endswith('.md') and not href.startswith('https://'), (name, href))
def test_configuration_topics_have_distinct_pages_and_direct_navigation(self):
with mock.patch.object(wiki, 'ROOT', ROOT):
output = wiki.render('deadbeef')
for path in (ROOT / 'docs/configuration').glob('*.md'):
with self.subTest(topic=path.name):
page = wiki.page_name(path)
self.assertTrue(page.startswith('Configuration-'))
self.assertIn(page + '.md', output)
self.assertIn(wiki.WIKI + '/' + page + ')', output['_Sidebar.md'])
self.assertIn('## Layout', output['Configuration.md'])
self.assertNotIn('default-column-width', output['Configuration.md'])
self.assertIn('default-column-width', output['Configuration-Layout.md'])
self.assertIn('### Binding modes', output['Configuration.md'])
self.assertIn('## Binding modes', output['Configuration-Bindings.md'])
self.assertIn(wiki.WIKI + '/Configuration-Bindings', output['Input-and-Bindings.md'])
def test_activity_settings_have_separate_configuration_page(self):
with mock.patch.object(wiki, 'ROOT', ROOT):
output = wiki.render('deadbeef')
settings = output['Configuration-Activities.md']
usage = output['Live-Activities.md']
sidebar_configuration = output['_Sidebar.md'].split('## Configuration', 1)[1].split('## Development', 1)[0]
self.assertIn(wiki.WIKI + '/Configuration-Activities)', sidebar_configuration)
self.assertIn('shell-hook #true', settings)
self.assertIn('rule "cargo"', settings)
self.assertIn('## Command rules', settings)
self.assertNotIn('rule "cargo"', usage)
self.assertIn('## Configure Activities', usage)
self.assertIn(wiki.WIKI + '/Configuration-Activities#command-rules', usage)
self.assertIn('## Start using it', usage)
self.assertIn('## Publisher ownership and lifecycle', usage)
def test_tabbed_windows_usage_and_settings_are_published_separately(self):
with mock.patch.object(wiki, 'ROOT', ROOT):
output = wiki.render('deadbeef')
usage = output['Tabbed-Windows.md']
layout = output['Configuration-Layout.md']
bindings = output['Configuration-Bindings.md']
navigation = output['_Sidebar.md'].split('## Usage', 1)[1].split('## Configuration', 1)[0]
self.assertIn(wiki.WIKI + '/Tabbed-Windows)', navigation)
self.assertIn('segmented rail', usage)
self.assertIn('feresectl join-tab left', usage)
self.assertIn(wiki.WIKI + '/Configuration-Layout#tabbed-columns', usage)
self.assertIn('default-column-display "tabbed"', layout)
self.assertNotIn('default-column-display', output['Configuration.md'])
for action in ('toggle-column-tabbed', 'join-tab', 'leave-tab', 'next-tab', 'previous-tab'):
with self.subTest(action=action):
self.assertIn('`' + action + '`', bindings)
for page in ('Shortcuts-and-Gestures.md', 'IPC.md', 'Configuration-Bindings.md'):
self.assertIn(wiki.WIKI + '/Tabbed-Windows', output[page])
def test_tab_indicator_reference_documents_every_configuration_field(self):
source = (ROOT / 'compositor/src/config/tab_indicator.rs').read_text()
reference = (ROOT / 'docs/configuration/layout.md').read_text()
body = re.search(r'struct TabIndicatorConfig\s*\{([^}]+)\}', source).group(1)
for field in re.findall(r'^\s+(\w+):', body, re.MULTILINE):
with self.subTest(field=field):
self.assertIn('| `' + field.replace('_', '-') + '` |', reference)
def test_activity_publishing_has_its_own_development_page(self):
with mock.patch.object(wiki, 'ROOT', ROOT):
output = wiki.render('deadbeef')
developer = output['Development-Activities.md']
usage = output['Live-Activities.md']
navigation = output['_Sidebar.md'].split('## Development', 1)[1]
self.assertIn(wiki.WIKI + '/Development-Activities)', navigation)
self.assertIn('Linux 6.5', developer)
self.assertIn('socket-bound pidfd', ' '.join(developer.split()))
self.assertNotIn('socket-bound pidfd', usage)
for anchor in ('publish-your-own-status', 'publisher-ownership-and-lifecycle', 'extensions-and-templates'):
with self.subTest(anchor=anchor):
self.assertIn(anchor, wiki.heading_ids(usage))
self.assertIn(wiki.WIKI + '/Development-Activities#' + anchor, usage)
self.assertIn(anchor, wiki.heading_ids(developer))
def test_activity_reference_documents_every_configuration_field(self):
source = (ROOT / 'crates/ferese-config/src/activities.rs').read_text()
reference = (ROOT / 'docs/configuration/activities.md').read_text()
special = {'name': 'Rule name', 'pattern': '`match`', 'sources': '## Sources', 'rules': '## Command rules', 'result': '`result.ok`'}
for name in ('Settings', 'Sources', 'Rule', 'ResultText'):
body = re.search(r'pub struct ' + name + r'\s*\{([^}]+)\}', source).group(1)
for field in re.findall(r'pub (\w+):', body):
label = special.get(field, '`' + ('result.' if name == 'ResultText' else '') + field.replace('_', '-') + '`')
with self.subTest(struct=name, field=field):
self.assertIn(label, reference)
def test_readme_wiki_links_resolve_to_published_pages(self):
from html.parser import HTMLParser
from urllib.parse import urlsplit
class Links(HTMLParser):
def __init__(self):
super().__init__()
self.hrefs = set()
def handle_starttag(self, tag, attrs):
if tag == 'a':
self.hrefs.update(value for key, value in attrs if key == 'href')
with mock.patch.object(wiki, 'ROOT', ROOT):
output = wiki.render('deadbeef')
readme = (ROOT / 'README.md').read_text()
links = Links()
links.feed(readme)
for href in wiki.destinations(readme) | links.hrefs:
if not href.startswith(wiki.WIKI):
continue
relative = href[len(wiki.WIKI):].lstrip('/')
url = urlsplit(relative)
page = (url.path or 'Home') + '.md'
with self.subTest(href=href):
self.assertIn(page, output)
if url.fragment:
self.assertIn(url.fragment, wiki.heading_ids(output[page]))
def test_legacy_configuration_anchors_point_to_published_topics(self):
with mock.patch.object(wiki, 'ROOT', ROOT):
output = wiki.render('deadbeef')
introduction = output['Configuration.md']
for href in wiki.destinations(introduction):
prefix = wiki.WIKI + '/Configuration-'
if not href.startswith(prefix):
continue
page, _, anchor = href[len(wiki.WIKI) + 1:].partition('#')
self.assertIn(page + '.md', output)
if anchor:
self.assertIn(anchor, wiki.heading_ids(output[page + '.md']))
self.assertIn(wiki.WIKI + '/Configuration-Bindings#binding-modes', introduction)
def test_homepage_redirects_keep_old_paths_and_fragments(self):
subprocess.run([sys.executable, str(ROOT / 'scripts/build-site.py')], check=True, capture_output=True)
destination = ROOT / 'build/site/docs/configuration.html'
content = destination.read_text()
self.assertIn(wiki.WIKI + '/Configuration', content)
self.assertIn('location.hash', content)
self.assertIn('http-equiv="refresh"', content)
self.assertIn('<a href="' + wiki.WIKI + '/Configuration">', content)
self.assertTrue((ROOT / 'build/site/assets/screenshots/ferese-media.webp').is_file())
self.assertIn(wiki.WIKI + '/Configuration-Appearance', (ROOT / 'build/site/docs/configuration/appearance.html').read_text())
if __name__ == '__main__':
unittest.main()