diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index 5ba828f..aac3081 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -26,7 +26,7 @@ jobs: python-version: '3.9' - name: Install dependencies run: | - python -m pip install --upgrade meson + python -m pip install --upgrade meson PyYAML sudo apt update sudo apt install -y \ doxygen libxcb-xkb-dev valgrind ninja-build \ diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index c57a0d7..cde0989 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -23,7 +23,7 @@ jobs: python-version: '3.9' - name: Install dependencies run: | - python -m pip install --upgrade meson + python -m pip install --upgrade meson PyYAML brew install libxml2 doxygen bison ninja brew link bison --force env: diff --git a/doc/cool-uris.yaml b/doc/cool-uris.yaml new file mode 100644 index 0000000..b739ee5 --- /dev/null +++ b/doc/cool-uris.yaml @@ -0,0 +1,63 @@ +# WARNING: This file is autogenerated by: scripts/ensure-stable-doc-urls.py +# Do not edit manually. +annotated.html: [] +classes.html: [] +deprecated.html: [] +dir_63ce773eee1f9b680e6e312b48cc99ca.html: [] +dir_891596f32582d3133e8915e72908625f.html: [] +dir_d44c64559bbebec7f509842c48db8b23.html: [] +dir_e68e8157741866f444e17edd764ebbae.html: [] +files.html: [] +functions.html: [] +functions_func.html: [] +functions_type.html: [] +functions_vars.html: [] +globals.html: [] +globals_defs.html: [] +globals_enum.html: [] +globals_eval.html: [] +globals_func.html: [] +globals_type.html: [] +graph_legend.html: [] +group__components.html: [] +group__compose.html: [] +group__context.html: [] +group__include-path.html: [] +group__keymap.html: [] +group__keysyms.html: [] +group__logging.html: [] +group__registry.html: [] +group__state.html: [] +group__x11.html: [] +index.html: [] +keymap-text-format-v1.html: +- md_doc_keymap_format_text_v1.html +md_doc_quick_guide.html: [] +md_doc_user_configuration.html: [] +modules.html: [] +pages.html: [] +rule-file-format.html: +- md_doc_rules_format.html +structrxkb__context.html: [] +structrxkb__iso3166__code.html: [] +structrxkb__iso639__code.html: [] +structrxkb__layout.html: [] +structrxkb__model.html: [] +structrxkb__option.html: [] +structrxkb__option__group.html: [] +structxkb__compose__state.html: [] +structxkb__compose__table.html: [] +structxkb__context.html: [] +structxkb__keymap.html: [] +structxkb__rule__names.html: [] +structxkb__state.html: [] +todo.html: [] +xkb-intro.html: [] +xkbcommon-compatibility.html: +- md_doc_compat.html +xkbcommon-compose_8h.html: [] +xkbcommon-keysyms_8h.html: [] +xkbcommon-names_8h.html: [] +xkbcommon-x11_8h.html: [] +xkbcommon_8h.html: [] +xkbregistry_8h.html: [] diff --git a/meson.build b/meson.build index b64427d..2cd1ee7 100644 --- a/meson.build +++ b/meson.build @@ -819,15 +819,34 @@ You can disable the documentation with -Denable-docs=false.''') ) # TODO: Meson should provide this. docdir = get_option('datadir')/'doc'/meson.project_name() - custom_target( + doc_gen = custom_target( 'doc', input: [doxyfile] + doxygen_input, output: 'html', - command: [doxygen_wrapper, doxygen, meson.current_build_dir()/'Doxyfile', meson.current_source_dir()], + command: [ + doxygen_wrapper, + doxygen, + meson.current_build_dir()/'Doxyfile', + meson.current_source_dir(), + ], install: true, install_dir: docdir, build_by_default: true, ) + ensure_stable_urls = find_program('scripts'/'ensure-stable-doc-urls.py') + custom_target( + 'doc-cool-uris', + input: [doc_gen, 'doc'/'cool-uris.yaml'], + output: 'html-xtra', + command: [ + ensure_stable_urls, + 'generate-redirections', + meson.current_source_dir()/'doc'/'cool-uris.yaml', + meson.current_build_dir()/'html' + ], + install: false, + build_by_default: true, + ) endif configure_file(output: 'config.h', configuration: configh_data) diff --git a/scripts/ensure-stable-doc-urls.py b/scripts/ensure-stable-doc-urls.py new file mode 100755 index 0000000..27f8232 --- /dev/null +++ b/scripts/ensure-stable-doc-urls.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python3 + +# Doc URLs may change with time because they depend on Doxygen machinery. +# This is unfortunate because it is good practice to keep valid URLs. +# See: “Cool URIs don’t change” at https://www.w3.org/Provider/Style/URI.html. +# +# There is no built-in solution in Doxygen that we are aware of. +# The solution proposed here is to maintain a registry of all URLs and manage +# legacy URLs as redirections to their canonical page. + +import argparse +from enum import IntFlag +import glob +from itertools import chain +from pathlib import Path +from string import Template +from typing import NamedTuple, Sequence + +import yaml + + +class Update(NamedTuple): + new: str + old: str + + +class ExitCode(IntFlag): + NORMAL = 0 + INVALID_UPDATES = 1 << 4 + MISSING_UPDATES = 1 << 5 + + +THIS_SCRIPT_PATH = Path(__file__) +RELATIVE_SCRIPT_PATH = THIS_SCRIPT_PATH.relative_to(THIS_SCRIPT_PATH.parent.parent) + +REDIRECTION_DELAY = 6 # in seconds. Note: at least 6s for accessibility + +# NOTE: The redirection works with the HTML tag: . +# See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta#http-equiv +# +# NOTE: This page is a simplified version of the Doxygen-generated ones. +# It does use the current stylesheets, but it may break if the theme is updated. +# Ideally, we would just let Doxygen generate them, but I (Wismill) could not +# find a way to do this with the redirection feature. +REDIRECTION_PAGE_TEMPLATE = Template( + """ + + + + + + + xkbcommon: Page Redirection + + +
+
+
+ libxkbcommon +
+
+
+
+
+
+
🔀 Redirection
+
+
+
+

This page has been moved.

+

+ If you are not redirected automatically, + follow the link to the current page. +

+
+
+ + +""" +) + + +def parse_page_update(update: str) -> Update: + updateʹ = Update(*update.split("=")) + if updateʹ.new == updateʹ.old: + raise ValueError(f"Invalid update: {updateʹ}") + return updateʹ + + +def update_registry(registry_path: Path, doc_dir: Path, updates: Sequence[str]): + """ + Update the URL registry by: + • Adding new pages + • Updating page aliases + """ + # Parse updates + updates_ = dict(map(parse_page_update, updates)) + # Load previous registry + with registry_path.open("rt", encoding="utf-8") as fd: + registry = yaml.safe_load(fd) or {} + # Expected updates + missing_updates = set(file for file in registry if not (doc_dir / file).is_file()) + # Update + invalid_updates = set(updates_) + redirections = frozenset(chain(*registry.values())) + for file in glob.iglob("**/*.html", root_dir=doc_dir, recursive=True): + # Skip redirection pages + if file in redirections: + continue + # Get previous entry and potential update + old = updates_.get(file) + if old: + # Update old entry + invalid_updates.remove(file) + entry = registry.get(old) + if entry is None: + raise ValueError(f"Invalid update: {file}<-{old}") + else: + del registry[old] + missing_updates.remove(old) + registry[file] = [e for e in [old] + entry if e != file] + print(f"[INFO] Updated: “{old}” to “{file}”") + else: + entry = registry.get(file) + if entry is None: + # New entry + registry[file] = [] + print(f"[INFO] Added: {file}") + else: + # Keep previous entry + pass + exit_code = ExitCode.NORMAL + # Check + if invalid_updates: + for update in invalid_updates: + print(f"[ERROR] Update not processed: {update}") + exit_code |= ExitCode.INVALID_UPDATES + if missing_updates: + for old in missing_updates: + print(f"[ERROR] “{old}” not found and has no update.") + exit_code |= ExitCode.MISSING_UPDATES + if exit_code: + print(f"[ERROR] Processing interrupted: please fix the errors above.") + exit(exit_code.value) + # Write changes + with registry_path.open("wt", encoding="utf-8") as fd: + fd.write(f"# WARNING: This file is autogenerated by: {RELATIVE_SCRIPT_PATH}\n") + fd.write(f"# Do not edit manually.\n") + yaml.dump( + registry, + fd, + ) + + +def generate_redirections(registry_path: Path, doc_dir: Path): + """ + Create redirection pages using the aliases in the given URL registry. + """ + cool = True + # Load registry + with registry_path.open("rt", encoding="utf-8") as fd: + registry = yaml.safe_load(fd) or {} + for canonical, aliases in registry.items(): + # Check canonical path is up-to-date + if not (doc_dir / canonical).is_file(): + cool = False + print( + f"ERROR: missing canonical documentation page “{canonical}”. " + f"Please update “{registry_path}” using b{RELATIVE_SCRIPT_PATH}”." + ) + # Add a redirection page + for alias in aliases: + path = doc_dir / alias + with path.open("wt", encoding="utf-8") as fd: + fd.write( + REDIRECTION_PAGE_TEMPLATE.substitute( + canonical=canonical, delay=REDIRECTION_DELAY + ) + ) + if not cool: + exit(1) + + +def add_registry_argument(parser): + parser.add_argument( + "registry", + type=Path, + help="Path to the doc URI registry.", + ) + + +def add_docdir_argument(parser): + parser.add_argument( + "docdir", + type=Path, + metavar="DOC_DIR", + help="Path to the generated HTML documentation directory.", + ) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Tool to ensure HTML documentation has stable URLs" + ) + subparsers = parser.add_subparsers() + + parser_registry = subparsers.add_parser( + "update-registry", help="Update the registry of URIs" + ) + add_registry_argument(parser_registry) + add_docdir_argument(parser_registry) + parser_registry.add_argument( + "updates", + nargs="*", + type=str, + help="Update: new=previous entries", + ) + parser_registry.set_defaults( + run=lambda args: update_registry(args.registry, args.docdir, args.updates) + ) + + parser_redirections = subparsers.add_parser( + "generate-redirections", help="Generate URIs redirections" + ) + add_registry_argument(parser_redirections) + add_docdir_argument(parser_redirections) + parser_redirections.set_defaults( + run=lambda args: generate_redirections(args.registry, args.docdir) + ) + + args = parser.parse_args() + args.run(args)