From cdc7fe05baf3da82034724f87dafbaf9ef8c8587 Mon Sep 17 00:00:00 2001 From: Luca Toniolo <10792599+grandixximo@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:11:05 +0800 Subject: [PATCH 1/3] docs: pin manpage troff filename to the make target asciidoctor names the troff from the page's NAME section, not the make target. po4a translates that NAME, so a translation that altered a command identifier (hm2_pktuart_read rendered as hm2_uart_read) misnames the troff; two pages then race for one output under -j, one going missing. Pass -o "$@" so the build graph sets each filename, produced once at its own name regardless of translation. --- docs/src/Submakefile | 43 +++++++++++++++++++------------------------ 1 file changed, 19 insertions(+), 24 deletions(-) diff --git a/docs/src/Submakefile b/docs/src/Submakefile index 793fd0faeb5..384a9b256fd 100644 --- a/docs/src/Submakefile +++ b/docs/src/Submakefile @@ -100,6 +100,22 @@ MAN_SRCS = $(sort \ $(wildcard $(DOC_MAN)/man9/*.9) \ $(GENERATED_MANPAGES)) +# asciidoctor names the troff from the page's NAME section, not the make +# target, so a translated NAME can misname the file and collide with another +# page under -j. Pin the name with -o. NAME drift itself is reported +# separately by scripts/manpage-name-check.sh (a CI warning), not here, to +# keep the build output quiet. +define MAKE_MANPAGE +@mkdir -p $(dir $@) +$(Q)asciidoctor --doctype=manpage \ + --backend=manpage \ + -o "$@" \ + -a compat-mode \ + -a mansource=LinuxCNC \ + -a manmanual='LinuxCNC Documentation' \ + $< +endef + $(DOC_OUT_ADOC)/en/man/man1/linuxcnc.1.adoc: $(DOC_SRCDIR)/man/man1/linuxcnc.1.adoc.in $(SRCDIR)/config.status @mkdir -p $(@D) $(SRCDIR)/config.status --file=$@:$< @@ -109,14 +125,7 @@ $(DOC_OUT_ADOC)/en/man/man1/linuxcnc.1.adoc: $(DOC_SRCDIR)/man/man1/linuxcnc.1.a # $(DOC_DIR)/src/man/, so override with an explicit rule. $(DOC_MAN)/man1/linuxcnc.1: $(DOC_OUT_ADOC)/en/man/man1/linuxcnc.1.adoc $(ECHO) Making manpage $(notdir $@) - @mkdir -p $(dir $@) - $(Q)asciidoctor --doctype=manpage \ - --backend=manpage \ - --destination-dir="$(dir $@)" \ - -a compat-mode \ - -a mansource=LinuxCNC \ - -a manmanual='LinuxCNC Documentation' \ - $< + $(MAKE_MANPAGE) info:: @echo "I: Expecting the following languages: $(LANGUAGES)" @@ -1365,14 +1374,7 @@ TARGETS += manpages GENERATED_MANPAGES += $(patsubst $(DOC_DIR)/src/man/%.adoc, $(DOC_MAN)/%, $(wildcard $(DOC_DIR)/src/man/man?/*.adoc)) $(DOC_MAN)/%: $(DOC_DIR)/src/man/%.adoc $(ECHO) Making manpage $(notdir $@) - @mkdir -p $(dir $@) - $(Q)asciidoctor --doctype=manpage \ - --backend=manpage \ - --destination-dir="$(dir $@)" \ - -a compat-mode \ - -a mansource=LinuxCNC \ - -a manmanual='LinuxCNC Documentation' \ - $< + $(MAKE_MANPAGE) # Translated manpages. po4a writes per-lang adocs under # $(DOC_OUT_ADOC)//man/manN/X.adoc; generate troff under @@ -1385,14 +1387,7 @@ ifeq ($(BUILD_DOCS_TRANSLATED),yes) define TRANSLATED_TROFF_RULE $$(DOC_MAN)/$1/%: $$(DOC_OUT_ADOC)/$1/man/%.adoc | $$(DOC_DIR)/.translateddocs-stamp $$(ECHO) Making translated manpage $1 $$(notdir $$@) - @mkdir -p $$(dir $$@) - $$(Q)asciidoctor --doctype=manpage \ - --backend=manpage \ - --destination-dir="$$(dir $$@)" \ - -a compat-mode \ - -a mansource=LinuxCNC \ - -a manmanual='LinuxCNC Documentation' \ - $$< + $$(MAKE_MANPAGE) endef $(foreach L,$(LANGUAGES),$(eval $(call TRANSLATED_TROFF_RULE,$(L)))) From 5a59b82d630fbff291bdf82e2568f30071177996 Mon Sep 17 00:00:00 2001 From: Luca Toniolo <10792599+grandixximo@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:11:58 +0800 Subject: [PATCH 2/3] docs: report manpage NAME drift as a CI warning After the doc build, scripts/manpage-name-check.py scans the built man tree for pages whose rendered .TH name disagrees with their filename, the sign of a translated NAME line that drifted from the command identifier. Quiet when nothing drifted. When something has, it prints each page with the string to search in Weblate and its current value side by side, so the output is a ready worklist. Never fails the build: in CI it writes that list to the job summary and emits one warning pointing at it. The NAME lines are managed in Weblate, so fixes go there. --- .github/workflows/ci.yml | 2 + docs/src/Submakefile | 2 +- scripts/manpage-name-check.py | 153 ++++++++++++++++++++++++++++++++++ 3 files changed, 156 insertions(+), 1 deletion(-) create mode 100755 scripts/manpage-name-check.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0545a317a5e..dcd44925255 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -184,6 +184,8 @@ jobs: run: | set -x .github/scripts/build-doc.sh + - name: Check manpage names match filenames + run: scripts/manpage-name-check.py docs/build/man - name: Verify no untracked or modified files after build run: | #*.po and documentation.pot are modifyed by build. Ignore them for now. diff --git a/docs/src/Submakefile b/docs/src/Submakefile index 384a9b256fd..b74593b8896 100644 --- a/docs/src/Submakefile +++ b/docs/src/Submakefile @@ -103,7 +103,7 @@ MAN_SRCS = $(sort \ # asciidoctor names the troff from the page's NAME section, not the make # target, so a translated NAME can misname the file and collide with another # page under -j. Pin the name with -o. NAME drift itself is reported -# separately by scripts/manpage-name-check.sh (a CI warning), not here, to +# separately by scripts/manpage-name-check.py (a CI warning), not here, to # keep the build output quiet. define MAKE_MANPAGE @mkdir -p $(dir $@) diff --git a/scripts/manpage-name-check.py b/scripts/manpage-name-check.py new file mode 100755 index 00000000000..d26dc2e6d2c --- /dev/null +++ b/scripts/manpage-name-check.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +# Report man pages whose rendered NAME drifted from their filename. +# asciidoctor takes the .TH name from the page's NAME section, the build pins the filename with -o, so a translated NAME just leaves the two disagreeing. +# Run after building the docs. Argument: the built man tree (default docs/build/man). +# Quiet when nothing drifted. When something has, it prints each page with the +# string to search in Weblate and its current value, so a translator can fix it. +# Never fails the build: under GitHub Actions it writes the same list to the job +# summary and emits one warning pointing at it. The NAME lines are managed in +# Weblate, so fixes go there. + +import os +import re +import sys +import glob + +# The script lives in scripts/, the docs are one level up at ../docs, so it runs from anywhere. +HERE = os.path.dirname(os.path.realpath(__file__, strict=True)) +DOCS = os.path.normpath(os.path.join(HERE, '..', 'docs')) +MAN = os.path.join(DOCS, 'build/man') +PO = os.path.join(DOCS, 'po') +SRC = os.path.join(DOCS, 'src/man') + +def th_name(troff): + """The .TH page name, lowercased with the roff backslash stripped, or None for an .so alias stub.""" + with open(troff, encoding='utf-8', errors='replace') as f: + if f.readline().startswith('.so '): + return None + f.seek(0) + for line in f: + m = re.match(r'\.TH "([^"]*)"', line) + if m: + return m.group(1).replace('\\', '').lower() + return None + +def name_line(page): + """The first line of the NAME section of a page's AsciiDoc source, or None for a comp page rendered straight to troff.""" + for adoc in sorted(glob.glob(f'{SRC}/man*/{page}.*.adoc')): + lines = open(adoc, encoding='utf-8', errors='replace').read().splitlines() + for i, line in enumerate(lines): + if line.strip() == '== NAME': + for rest in lines[i + 1:]: + if rest.strip(): + return rest.strip() + return None + +_po = {} +def po(lang): + """Parse docs/po/.po into {msgid: msgstr}, joining the continuation lines that wrap one string.""" + if lang not in _po: + d = {} + mid = mstr = mode = None + for line in open(f'{PO}/{lang}.po', encoding='utf-8', errors='replace'): + if line.startswith('msgid '): + if mid is not None: + d[mid] = mstr or '' + mid, mstr, mode = line[6:].strip().strip('"'), None, 'id' + elif line.startswith('msgstr '): + mstr, mode = line[7:].strip().strip('"'), 'str' + elif line.startswith('"'): + s = line.strip().strip('"') + if mode == 'id': + mid += s + elif mode == 'str': + mstr += s + elif not line.strip() and mid is not None: + d[mid] = mstr or '' + mid = mstr = mode = None + if mid is not None: + d[mid] = mstr or '' + _po[lang] = d + return _po[lang] + +def weblate_string(lang, page): + """Return (search, current): the text to search for in Weblate and its current translation.""" + d = po(lang) + th = page.upper() + # A comp page's name is a troff .TH string, searched by the uppercase name. + if d.get(th, '').strip() and d[th].strip().lower() != page.lower(): + return th, d[th] + # An AsciiDoc page's name lives in the NAME line, searched by its text. + nl = name_line(page) + if nl: + cur = d.get(nl) + if cur is None: # the source line and the msgid can differ in trailing text + for mid, mstr in d.items(): + if mid.startswith(page) and ' - ' in mid: + nl, cur = mid, mstr + break + return nl, cur if cur is not None else '(search the text)' + return page, '(unknown)' + +def drifted(man): + """Yield (lang, page) for every built man page whose .TH name does not match its filename.""" + for dirpath, _dirs, files in os.walk(man): + for fn in files: + if not re.search(r'\.\d$', fn): + continue + th = th_name(os.path.join(dirpath, fn)) + page = re.sub(r'\.\d+$', '', fn) + if th is None or th == page.lower(): + continue + lang = os.path.relpath(dirpath, man).split(os.sep)[0] + yield ('en' if re.fullmatch(r'man\d+', lang) else lang), page + +def collect(man): + """All drifted rows as (lang, search, current), sorted, English dropped.""" + rows = [] + for lang, page in drifted(man): + if lang == 'en': # English is the reference, it never drifts + continue + search, current = weblate_string(lang, page) + rows.append((lang, search, current)) + rows.sort() + return rows + +def print_local(rows): + print(f'# {len(rows)} man-page NAME mismatches. Search the "search" text in the Weblate docs component, language in brackets.') + for lang, search, current in rows: + print(f'\n[{lang}]') + print(f' search: {search}') + print(f' current: {current}') + +def write_summary(rows, fh): + fh.write(f'## Manpage NAME mismatches: {len(rows)}\n\n') + fh.write('Translated NAME lines that drifted from the command name.\n') + fh.write('Search the "search" text in the Weblate docs component for the language and fix it there.\n\n') + fh.write('| lang | search | current |\n') + fh.write('| ---- | ------ | ------- |\n') + for lang, search, current in rows: + cells = (c.replace('|', '\\|') for c in (lang, search, current)) + fh.write('| %s | %s | %s |\n' % tuple(cells)) + +def main(argv): + man = argv[1] if len(argv) > 1 else MAN + # A built tree has troff pages; without them the docs are not built and a + # silent "all clean" would read as success when nothing was actually scanned. + if not any(re.search(r'\.\d$', f) for _root, _dirs, files in os.walk(man) for f in files): + print(f'{man}: no built man pages found, build the docs first (make manpages docs).', file=sys.stderr) + return 1 + rows = collect(man) + if not rows: + return 0 # quiet when nothing drifted + print_local(rows) + summary = os.environ.get('GITHUB_STEP_SUMMARY') + if summary: + with open(summary, 'a', encoding='utf-8') as fh: + write_summary(rows, fh) + if os.environ.get('GITHUB_ACTIONS'): + print(f'::warning title=manpage NAME drift::{len(rows)} manpage NAME line(s) disagree with their filename, see the job summary for the list to fix in Weblate') + return 0 + +if __name__ == '__main__': + sys.exit(main(sys.argv)) From 4af4170b8dccc6c4fd5dd6b631cd4d2bb9dca0df Mon Sep 17 00:00:00 2001 From: Luca Toniolo <10792599+grandixximo@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:42:30 +0800 Subject: [PATCH 3/3] docs: fail htmldocs CI on manpage NAME drift Add --enforce (and MANPAGE_NAME_CHECK_ENFORCE) to manpage-name-check.py so the check exits non-zero when a translated NAME line disagrees with its filename, and turn it on for the htmldocs job. The tree is clean as of the #4325 Weblate sync, so the gate starts green. --- .github/workflows/ci.yml | 2 +- scripts/manpage-name-check.py | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dcd44925255..6035eb6289c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -185,7 +185,7 @@ jobs: set -x .github/scripts/build-doc.sh - name: Check manpage names match filenames - run: scripts/manpage-name-check.py docs/build/man + run: scripts/manpage-name-check.py --enforce docs/build/man - name: Verify no untracked or modified files after build run: | #*.po and documentation.pot are modifyed by build. Ignore them for now. diff --git a/scripts/manpage-name-check.py b/scripts/manpage-name-check.py index d26dc2e6d2c..3ac56b82168 100755 --- a/scripts/manpage-name-check.py +++ b/scripts/manpage-name-check.py @@ -4,7 +4,8 @@ # Run after building the docs. Argument: the built man tree (default docs/build/man). # Quiet when nothing drifted. When something has, it prints each page with the # string to search in Weblate and its current value, so a translator can fix it. -# Never fails the build: under GitHub Actions it writes the same list to the job +# Never fails the build unless --enforce or MANPAGE_NAME_CHECK_ENFORCE is set: +# under GitHub Actions it writes the same list to the job # summary and emits one warning pointing at it. The NAME lines are managed in # Weblate, so fixes go there. @@ -131,7 +132,9 @@ def write_summary(rows, fh): fh.write('| %s | %s | %s |\n' % tuple(cells)) def main(argv): - man = argv[1] if len(argv) > 1 else MAN + enforce = '--enforce' in argv or os.environ.get('MANPAGE_NAME_CHECK_ENFORCE') + args = [a for a in argv if a != '--enforce'] + man = args[1] if len(args) > 1 else MAN # A built tree has troff pages; without them the docs are not built and a # silent "all clean" would read as success when nothing was actually scanned. if not any(re.search(r'\.\d$', f) for _root, _dirs, files in os.walk(man) for f in files): @@ -147,7 +150,7 @@ def main(argv): write_summary(rows, fh) if os.environ.get('GITHUB_ACTIONS'): print(f'::warning title=manpage NAME drift::{len(rows)} manpage NAME line(s) disagree with their filename, see the job summary for the list to fix in Weblate') - return 0 + return 1 if enforce else 0 if __name__ == '__main__': sys.exit(main(sys.argv))