diff --git a/README.md b/README.md index bbefde8fa..2b2a3c122 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,28 @@ using `make local` which will point the widgets at 127.0.0.1:8000 You can then point your browser on your host to 127.0.0.1:8080 to see the learn website being served from vagrant. +### Building a single course or lab (faster dev builds) + +By default `pnpm run dev` rebuilds the entire content tree. +If you are working on a single unit you can limit both the Sphinx build and the +watched file set to that unit by setting the `UNIT` environment variable: + +```bash +# Inside the web VM, in /vagrant/frontend: +UNIT=courses/intro-to-ada pnpm run dev +UNIT=labs/intro-to-ada pnpm run dev +``` + +You can also invoke the Sphinx step directly without the webpack server: + +```bash +make local UNIT=courses/intro-to-ada +``` + +`UNIT` must be a path relative to the `content/` directory. Every unit with a +`conf.ini` file at its root is supported (all courses, labs, and booklets). +When `UNIT` is unset the behaviour is unchanged: the full site is built. + ## Generate content for publishing To build and start the publishing server, run: diff --git a/content/courses/Ada_For_The_Embedded_C_Developer/redirects.json b/content/courses/Ada_For_The_Embedded_C_Developer/redirects.json new file mode 100644 index 000000000..4ab83501c --- /dev/null +++ b/content/courses/Ada_For_The_Embedded_C_Developer/redirects.json @@ -0,0 +1,3 @@ +{ + "courses/Ada_For_The_C_Embedded_Developer/index": "../Ada_For_The_Embedded_C_Developer/" +} diff --git a/content/courses/GNAT_Toolchain_Intro/redirects.json b/content/courses/GNAT_Toolchain_Intro/redirects.json new file mode 100644 index 000000000..6443f98d9 --- /dev/null +++ b/content/courses/GNAT_Toolchain_Intro/redirects.json @@ -0,0 +1,3 @@ +{ + "courses/GNAT_Toolchain_Getting_Started/index": "../GNAT_Toolchain_Intro/" +} diff --git a/frontend/.gitignore b/frontend/.gitignore index 37aff653a..7f554c0be 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -3,7 +3,9 @@ npm-debug.log *.map build-manifest.json dist +dist-poc .nyc_output/ coverage/ pnpm-debug.log -.coverage \ No newline at end of file +.coverage +sphinx/objects.inv.learn \ No newline at end of file diff --git a/frontend/Makefile b/frontend/Makefile index db0eaa9d4..af422ebc3 100644 --- a/frontend/Makefile +++ b/frontend/Makefile @@ -22,6 +22,22 @@ MKFILE_DIR := $(dir $(MKFILE_PATH)) BUILD_MANIFEST := $(MKFILE_DIR)/build-manifest.json CONTENT_DIR = $(MKFILE_DIR)/../content + +# Optional: limit the local/dev Sphinx build to a single content unit. +# conf.py reads SPHINX_UNIT and adds all other unit directories to +# exclude_patterns, so only the target unit's RST files are processed. +# The source root stays content/ and the output paths are unchanged. +# Usage: make local UNIT=courses/intro-to-ada +UNIT ?= + +# Validate UNIT immediately at parse time so a typo fails loudly before +# Sphinx is invoked. Every valid unit has a conf.ini at its root. +ifneq ($(UNIT),) +ifeq ($(wildcard $(CONTENT_DIR)/$(UNIT)/conf.ini),) +$(error UNIT="$(UNIT)": not found or missing conf.ini under $(CONTENT_DIR). Check for typos.) +endif +endif + TEST_CONTENT = tests/rst TEST_BUILDDIR = tests/html @@ -221,6 +237,7 @@ publish: # # This should only be called from `pnpm run dev` local: @SPHINX_CONF_INI="$(SPHINX_CONF_INI)" \ + SPHINX_UNIT="$(UNIT)" \ SRC_TEST_DIR="$(SRC_TEST_DIR)" \ $(SPHINXBUILD) -M html $(CONTENT_DIR) \ "$(BUILDDIR)" $(SPHINXOPTS) $(O) -v -c "$(SPHINXCONF)" diff --git a/frontend/sphinx/conf.py b/frontend/sphinx/conf.py index 6e4bcbdf1..84693db65 100644 --- a/frontend/sphinx/conf.py +++ b/frontend/sphinx/conf.py @@ -178,6 +178,31 @@ def get_file_from_conf_ini(path_to_file): else: tags.add('no_hidden_contents') +# Per-unit build: exclude every content unit except the target so that +# Sphinx only reads and writes the target unit's RST files. +# The source root stays content/ and output paths are unchanged +# (e.g. dist/html/courses/advanced-ada/). +_sphinx_unit = os.environ.get('SPHINX_UNIT', '') +if _sphinx_unit: + _content_root = Path(os.path.dirname(__file__)) / '../../content' + if not (_content_root / _sphinx_unit / 'conf.ini').is_file(): + print( + f'\nERROR: SPHINX_UNIT={_sphinx_unit!r} is not a valid content unit' + f' — no conf.ini found at {_content_root / _sphinx_unit / "conf.ini"}.' + '\nCheck for typos in the UNIT variable.\n', + file=sys.stderr, + ) + sys.exit(1) + for _top in ('courses', 'labs', 'booklets'): + _top_path = _content_root / _top + if _top_path.is_dir(): + for _sub in sorted(_top_path.iterdir()): + if _sub.is_dir() and f'{_top}/{_sub.name}' != _sphinx_unit: + exclude_patterns.append(f'{_top}/{_sub.name}') + # The root index.rst still references excluded units via toctree — + # suppress those warnings rather than failing or spamming the output. + suppress_warnings = ['toc.not_readable'] + show_authors = True # The name of the Pygments (syntax highlighting) style to use. @@ -191,7 +216,7 @@ def get_file_from_conf_ini(path_to_file): # numbered starting at 1. numfig_secnum_depth = 0 -nitpicky = True +nitpicky = not bool(_sphinx_unit) # sphinx.ext.extlinks: markup to shorten external links extlinks = { @@ -511,7 +536,11 @@ def get_file_from_conf_ini(path_to_file): # -- Options for intersphinx extension --------------------------------------- -intersphinx_mapping = {'learn': ('https://learn.adacore.com/', None)} +_inv_cache = os.path.join(os.path.dirname(__file__), 'objects.inv.learn') +intersphinx_mapping = { + 'learn': ('https://learn.adacore.com/', + _inv_cache if os.path.exists(_inv_cache) else None) +} # -- Options for redirects extension ----------------------------------------- @@ -527,8 +556,12 @@ def get_file_from_conf_ini(path_to_file): bibtex_file_full_path = get_file_from_conf_ini(bibtex_file) bibtex_bibfiles.append(bibtex_file_full_path) else: - ## PATCH: get content directory - content_dir = os.path.abspath(os.path.dirname(__file__ ) + '/../../content') + if 'SPHINX_CONF_INI' in os.environ and os.environ['SPHINX_CONF_INI']: + # Per-unit build: only search within the unit's own directory + content_dir = os.path.dirname(os.path.abspath(os.environ['SPHINX_CONF_INI'])) + else: + # Full-site build: search entire content directory + content_dir = os.path.abspath(os.path.dirname(__file__) + '/../../content') for bib_file in Path(content_dir).rglob('*.bib'): bibtex_bibfiles.append(str(bib_file)) @@ -542,10 +575,27 @@ def setup(app): if 'html' in outdir: templates_path.append('_templates') - redirects.update({ - "courses/Ada_For_The_C_Embedded_Developer/index": "../Ada_For_The_Embedded_C_Developer/", - "courses/GNAT_Toolchain_Getting_Started/index": "../GNAT_Toolchain_Intro/" - }) + if 'SPHINX_CONF_INI' in os.environ and os.environ['SPHINX_CONF_INI']: + # Per-unit book build (source-root = unit dir): load this unit's redirects + unit_dir = Path(os.environ['SPHINX_CONF_INI']).parent + redirects_file = unit_dir / 'redirects.json' + if redirects_file.is_file(): + with open(redirects_file) as _f: + redirects.update(json.load(_f)) + elif _sphinx_unit: + # Per-unit dev build (source-root = content/, SPHINX_UNIT set): + # load only the target unit's redirects + _unit_redirects = (Path(os.path.dirname(__file__)) + / '../../content' / _sphinx_unit / 'redirects.json') + if _unit_redirects.is_file(): + with open(_unit_redirects) as _f: + redirects.update(json.load(_f)) + else: + # Full-site build: merge redirects from all units + _content_dir = Path(os.path.dirname(__file__)) / '../../content' + for _redirects_file in sorted(_content_dir.rglob('redirects.json')): + with open(_redirects_file) as _f: + redirects.update(json.load(_f)) if not os.getenv('FRONTEND_TESTING'): diff --git a/frontend/webpack.dev.cjs b/frontend/webpack.dev.cjs index bd8b9727e..7a1b74f96 100644 --- a/frontend/webpack.dev.cjs +++ b/frontend/webpack.dev.cjs @@ -1,3 +1,4 @@ +const fs = require('fs'); const path = require('path'); const { merge } = require('webpack-merge'); const exec = require('child_process').exec; @@ -7,6 +8,19 @@ const common = require('./webpack.common.cjs'); module.exports = function(env) { const common_config = common(env); + const unit = process.env.UNIT || ''; + + // Validate UNIT before webpack starts so a typo produces a clean error. + // The Makefile $(error) catches the same mistake for direct `make` CLI use. + if (unit) { + const confIni = path.join(__dirname, '..', 'content', unit, 'conf.ini'); + if (!fs.existsSync(confIni)) { + console.error(`\nERROR: UNIT="${unit}" is not a valid content unit.`); + console.error(` No conf.ini found at: ${confIni}`); + console.error(` Check for typos.\n`); + process.exit(1); + } + } const dev_config = { mode: 'development', @@ -16,31 +30,53 @@ module.exports = function(env) { host: '0.0.0.0', port: 8080, hot: true, - liveReload: false, - // devMiddleware: { - // index: 'index.html', - // }, + // liveReload: for UNIT builds the browser must reload after sphinx + // rewrites the HTML. For full-site builds keep it off (HMR only). + liveReload: !!unit, }, output: { publicPath: '/_static/' }, devtool: 'source-map', + // /vagrant/{content,frontend} are VirtualBox shared folders (vboxsf). + // vboxsf does not generate inotify events, so webpack's default + // inotify-based watcher never fires for RST file changes. Force polling + // when UNIT is set so WatchPlugin can detect edits made on the host. + ...(unit ? { watchOptions: { poll: 1000, ignored: /node_modules/ } } : {}), plugins: [ new ShellPlugin({ onBuildStart:{ - scripts: ['make cleanall'], + // Skip cleanall for per-unit builds: the doctree cache must survive + // across webpack recompilations so that subsequent sphinx invocations + // are incremental (near-instant) rather than full rebuilds. + scripts: [unit ? 'echo "UNIT build: skipping cleanall"' : 'make cleanall'], blocking: true, parallel: false }, onBuildExit:{ - scripts: ['make local'], - blocking: false, + scripts: [`make local${unit ? ' UNIT=' + unit : ''}`], + // blocking for UNIT builds: webpack must not signal "done" until + // sphinx has finished writing HTML, otherwise the live-reload fires + // before the new page content is on disk. + blocking: !!unit, parallel: false }, // dev=false here to force every build to trigger make, the default is // first build only. dev: false, }), + // Watch RST files so that editing content triggers a sphinx rebuild via + // the ShellPlugin's onBuildExit. Scoped to the target unit when UNIT is + // set, otherwise watches all content. + // Polling is used because vboxsf does not generate inotify events. + new WatchPlugin({ + files: [ + path.join( + __dirname, '..', 'content', + unit ? `${unit}/**/*.rst` : '**/*.rst' + ) + ] + }), ] };