Skip to content

Sphinx: per-unit dev builds via UNIT= parameter#1361

Merged
gusthoff merged 17 commits into
AdaCore:mainfrom
gusthoff:topic/infrastructure/sphinx/structure/multi_prj/20260529
May 30, 2026
Merged

Sphinx: per-unit dev builds via UNIT= parameter#1361
gusthoff merged 17 commits into
AdaCore:mainfrom
gusthoff:topic/infrastructure/sphinx/structure/multi_prj/20260529

Conversation

@gusthoff
Copy link
Copy Markdown
Collaborator

Per-unit dev builds: UNIT=courses/advanced-ada pnpm run dev

Adds a UNIT= parameter that scopes both the Sphinx build and the webpack
file-watcher to a single content unit, turning a full-site rebuild into a fast
incremental one. After the initial build, saving an RST file triggers Sphinx on
only the changed file and live-reloads the browser.

# Inside the web VM, in /vagrant/frontend:
UNIT=courses/advanced-ada  pnpm run dev
UNIT=courses/intro-to-ada  pnpm run dev
UNIT=labs/intro-to-ada     pnpm run dev

make local UNIT=courses/intro-to-ada   # without the dev server

A mistyped unit name is caught immediately — at Makefile parse time for
make local, before webpack even starts for pnpm run dev.


What changes

frontend/Makefile — new UNIT ?= variable; passes SPHINX_UNIT to
Sphinx; immediate $(error) on invalid value.

frontend/webpack.dev.cjs — forwards UNIT to make local; skips
make cleanall so the doctree cache survives across recompilations; adds
WatchPlugin for RST files; enables polling (vboxsf has no inotify);
sets liveReload and blocking so the browser reloads only after Sphinx
finishes; validates UNIT before webpack starts.

frontend/sphinx/conf.pySPHINX_UNIT drives exclude_patterns to
scope the source scan to one unit while keeping content/ as the root;
disables nitpicky and suppresses toctree warnings for excluded units;
scopes bibtex discovery to the unit directory; moves redirect rules to
per-unit redirects.json files; uses a cached local objects.inv.learn
instead of fetching from learn.adacore.com on every cold build.

content/courses/*/redirects.json — two new files extracted from the
hardcoded redirect dict in conf.py.

frontend/.gitignore, README.md — housekeeping and docs.


Test plan

  • UNIT=courses/GNAT_Toolchain_Intro pnpm run dev — page loads at http://127.0.0.1:8080/courses/GNAT_Toolchain_Intro/index.html
  • Edit an RST file — browser reloads with the change
  • UNIT=courses/intro-to--ada pnpm run dev (typo) — clean error, no webpack compile
  • make local UNIT=courses/intro-to--ada (typo) — fails at parse time with Stop.

gusthoff and others added 17 commits May 29, 2026 17:23
The rglob for .bib files always searched from frontend/sphinx/../../content,
finding all .bib files across the entire content tree. In a per-unit build
(SPHINX_CONF_INI set) this is unnecessary: the unit's conf.ini directory is
the correct root to search from, so only that unit's .bib files are loaded.

The full-site build path (SPHINX_CONF_INI unset) is unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The two redirect rules were hardcoded in conf.py's setup() function and
applied to every Sphinx build, including per-unit builds where they don't
belong (GNAT_Toolchain_Getting_Started belongs to GNAT_Toolchain_Intro;
Ada_For_The_C_Embedded_Developer belongs to Ada_For_The_Embedded_C_Developer).

Each unit that needs redirects now carries its own redirects.json alongside
conf.ini. conf.py's setup() loads:
- Per-unit build (SPHINX_CONF_INI set): only the unit's redirects.json
- Full-site build (SPHINX_CONF_INI unset): all redirects.json files merged

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
intersphinx_mapping pointed to https://learn.adacore.com/ with no local
inventory, causing Sphinx to fetch objects.inv from the production server on
every cold build. With 17 parallel per-unit builds this would be 17 concurrent
requests to the live site.

conf.py now checks for frontend/sphinx/objects.inv.learn; if it exists it is
used as the local inventory path and no network request is made. If absent,
behaviour falls back to fetching from the URL (first-run or CI without cache).

objects.inv.learn is generated, not committed: add it to .gitignore.
Also exclude dist-poc/ (the PoC build output directory) from git tracking.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds an optional UNIT variable to the Makefile and wires it through to
pnpm run dev, so a single content unit can be built instead of the full
284-file site.

Makefile:
- UNIT ?= (empty = full-site build, unchanged)
- When set, _SPHINX_SRC points to content/<UNIT>/ and _SPHINX_INI is
  derived automatically from that unit's conf.ini
- The local target now uses _SPHINX_SRC / _SPHINX_INI instead of the
  hardcoded CONTENT_DIR / SPHINX_CONF_INI

webpack.dev.cjs:
- The ShellPlugin's onBuildExit script passes UNIT to make local when
  the UNIT env var is set in the calling shell

Usage:
  make local UNIT=courses/intro-to-ada
  UNIT=courses/advanced-ada pnpm run dev

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a "Building a single course or lab" subsection under the dev server
section in README.md, explaining the UNIT environment variable, showing
example invocations for pnpm run dev and make local, and noting that all
units with a conf.ini are supported.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…t change

The previous approach set the Sphinx source root to content/<UNIT>/,
which caused two problems:
1. RST files were processed more times than expected because changing the
   source root invalidated all doctrees cached from the full-site build
   (file paths are relative to the source root, so they all changed).
2. The landing page was overwritten: with the unit as source root,
   dist/html/index.html became the unit's own index rather than the site
   landing page.

Fix: keep content/ as the source root always. Pass UNIT to conf.py as
SPHINX_UNIT; conf.py adds all other unit directories to exclude_patterns
so Sphinx only reads and writes the target unit's RST files. The output
paths remain dist/html/courses/<unit>/, dist/html/labs/<unit>/, etc. —
the landing page at dist/html/index.html is never overwritten.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When SPHINX_UNIT is set (e.g. SPHINX_UNIT=courses/advanced-ada), conf.py
adds every other unit directory to exclude_patterns so Sphinx only reads
and writes the target unit's RST files. The root index.rst and about.rst
are still processed (they are not in any unit subdirectory), so the
landing page is rebuilt with the full navigation intact.

Three related changes in conf.py:

- exclude_patterns: glob courses/, labs/, and booklets/ and exclude every
  subdirectory that is not the target unit.

- nitpicky: disabled when SPHINX_UNIT is set. The root index.rst has
  toctree entries for all units; with most units excluded those entries
  cannot be resolved. nitpicky=True would turn those into errors.

- suppress_warnings = ['toc.not_readable']: suppresses the toctree
  reference warnings that would otherwise flood the output.

- Redirect loading in setup(): adds a third branch for SPHINX_UNIT builds
  (source-root = content/) alongside the existing SPHINX_CONF_INI branch
  (source-root = unit dir) and the full-site else branch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
webpack-dev-server triggers multiple compilation cycles on startup (due
to HMR setup and other internal passes). With dev=false in ShellPlugin,
every compilation runs make cleanall (onBuildStart) followed by make local
(onBuildExit). make cleanall deletes the entire dist/ including sphinx's
doctree cache, forcing a full RST rebuild on every webpack compilation.

For the full-site build this was invisible (7-minute sphinx runs meant the
user never noticed the extra passes). For per-unit UNIT= builds the sphinx
run takes ~10 seconds, so all four passes complete quickly enough to be
clearly visible.

Fix: when UNIT is set, skip make cleanall. The doctree cache survives
across webpack recompilations, so the second and subsequent sphinx
invocations triggered by webpack find 0 changed files and return
immediately. Only the first invocation does the full unit rebuild.

The full-site build path (UNIT unset) is unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… on edit

WatchPlugin was imported in webpack.dev.cjs but never added to the plugins
array. Without it, webpack only watches TypeScript and SCSS source files.
Editing an RST file never triggered a webpack recompilation, so the
ShellPlugin's onBuildExit never fired and sphinx never re-ran.

Add WatchPlugin to the dev config:
- UNIT set: watches content/<UNIT>/**/*.rst (target unit only)
- UNIT unset: watches content/**/*.rst (all content)

When an RST file changes, webpack detects it, recompiles, and onBuildExit
runs make local (with or without UNIT=), triggering an incremental sphinx
rebuild that picks up only the changed file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
/vagrant/content and /vagrant/frontend are mounted as vboxsf (VirtualBox
shared folders). vboxsf does not generate inotify events, so webpack's
default inotify-based watcher never fires when RST files are edited on
the host — WatchPlugin added the files to fileDependencies but changes
were silently ignored.

Three changes when UNIT is set:

watchOptions.poll = 1000: force webpack to poll watched files every
second instead of relying on inotify. This makes WatchPlugin's RST file
watching functional on vboxsf.

liveReload = true: after each webpack build cycle the browser reloads the
page. Without this, even if sphinx rebuilt the HTML the browser would
continue showing the stale version.

onBuildExit.blocking = true: webpack must not emit its "build done"
signal (which triggers the live-reload) until sphinx has finished writing
the new HTML to disk. With blocking: false the reload races the sphinx
write and the browser may load the old page.

All three changes are conditional on UNIT being set; the full-site build
path is unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…split)

content/index.rst and content/about.rst are the site landing page — they
belong to the top-level aggregator project, not to any content unit. Before
this change they were rebuilt on every make local UNIT=... invocation (adding
~1-2 s and producing a spurious gnatchop ERROR from the "Try Ada Now" code
block in index.rst).

Two changes in the SPHINX_UNIT block in conf.py:

1. exclude_patterns += ['index.rst', 'about.rst']
   Removes the landing page files from Sphinx's source discovery.
   Note: Sphinx 9.1's get_matching_files requires the full filename
   ('index.rst'), not just the docname ('index'), to match .rst files.

2. master_doc = f'{_sphinx_unit}/index'
   With the site root excluded, the unit's own index.rst becomes the
   Sphinx entry point. The sidebar shows only the unit's chapter
   structure, and Sphinx has a valid root document to start from.

Result: per-unit builds now process exactly the unit's own RST files
(5 for GNAT_Toolchain_Intro), produce 0 warnings, and leave
dist/html/index.html untouched from the previous full build.

suppress_warnings is no longer needed (no toctree references to
excluded units from within the unit's own toctree).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
A typo in UNIT= (e.g. UNIT=courses/intro-to--ada) previously produced no
error: conf.py silently excluded every unit directory (none matched the
bad name), so Sphinx built only the landing page and exited cleanly. The
user had no indication that their intended unit was never built.

Two-layer validation, both using the presence of conf.ini as the sentinel
(every valid content unit has one):

Makefile: $(wildcard) check in an ifneq block evaluated at parse time.
Make exits immediately with a clear message before any target runs, so the
error appears in the webpack dev-server output before Sphinx is even
invoked.

conf.py: Path.is_file() check at config-load time. Covers the case where
sphinx-build is called directly (e.g. in CI or from the epub VM) without
going through the Makefile.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The Makefile $(error) validation is correct for direct `make local UNIT=…`
CLI use but causes a disruptive ShellPlugin stack trace when pnpm run dev
is used with a typo: Make exits with code 2 after 30 s of webpack
compilation, ShellPlugin throws, and the dev server crashes.

Add an fs.existsSync check at the top of webpack.dev.cjs (before any
webpack config is constructed) that validates UNIT against
content/<UNIT>/conf.ini. On failure it prints a clean 3-line error and
calls process.exit(1) immediately — before webpack even starts.

The Makefile $(error) is retained for the CLI path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@gusthoff gusthoff merged commit 83dd443 into AdaCore:main May 30, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant