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:
1 parent
eaeabebe73
commit
eb84104940
37 files changed
+2178
-1510
No files matched your search
+44
-159
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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(' [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()
|
||||
Reference in new issue
Block a user