diff --git a/.github/workflows/docs-site.yml b/.github/workflows/docs-site.yml index 38262ee..7024aed 100644 --- a/.github/workflows/docs-site.yml +++ b/.github/workflows/docs-site.yml @@ -59,6 +59,9 @@ jobs: - name: F1 coverage run: npm run check + - name: Script tests + run: npm test + - name: Build run: npx astro build @@ -67,3 +70,35 @@ jobs: name: docs-site path: docs-site/dist retention-days: 7 + + # The offline reader that ships inside a release (ADR-0009 / docs-s2). Built + # separately from the web site because the two differ in output format and in + # whether search exists, so one build cannot prove the other works. + local: + name: build local reader + runs-on: ubuntu-latest + defaults: + run: + working-directory: docs-site + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: docs-site/.nvmrc + cache: npm + cache-dependency-path: docs-site/package-lock.json + + - name: Install + run: npm ci + + # build:local ends in check-local-build.mjs, which fails on any surviving + # root-absolute href/src — the one thing that makes a build openable from + # a disc rather than only from a server. + - name: Build the local reader + run: npm run build:local + + - uses: actions/upload-artifact@v4 + with: + name: docs-manual-local + path: docs-site/dist + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b9c1eb8..e711a51 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,8 +63,26 @@ jobs: path: build/*/_deps/*-subbuild/*-populate-prefix/src/*.tar.gz key: deps-${{ matrix.os }}-${{ hashFiles('cmake/deps.cmake') }} + # The offline manual (ADR-0009 / docs-s2). Built with Node HERE and handed + # to CMake as a finished directory below — CMake never invokes npm, so a + # developer build stays Node-free. + - uses: actions/setup-node@v4 + with: + node-version-file: docs-site/.nvmrc + cache: npm + cache-dependency-path: docs-site/package-lock.json + + - name: Build the offline manual + working-directory: docs-site + run: | + npm ci + npm run build:local + - name: Configure - run: cmake --preset ${{ matrix.preset }} + run: > + cmake --preset ${{ matrix.preset }} + -DROADMAKER_BUNDLE_MANUAL=ON + -DROADMAKER_MANUAL_DIR=${{ github.workspace }}/docs-site/dist - name: Build run: cmake --build --preset ${{ matrix.preset }} @@ -128,7 +146,7 @@ jobs: # above must match cmake/QtVersion.cmake's pin. NO_STRIP: "1" - - name: Smoke test packaged binary (--version + bundled help) + - name: Smoke test packaged binary (--version + bundled help + manual) shell: bash env: QT_QPA_PLATFORM: offscreen @@ -138,6 +156,12 @@ jobs: # its qsqlite driver (QHelpEngine is SQLite-backed). macdeployqt / # windeployqt / linuxdeploy pull QtSql in via Qt6::Help; assert every # piece actually shipped in the packaged artifact. + # + # The manual is asserted alongside it, at the layout manual_locator.cpp + # resolves for this platform — the two must agree or Help ▸ Open Manual + # silently falls back to the online pointer in a build that HAS one. + # A page under it is checked too: an empty manual/ directory would pass + # an index-only check while opening to nothing. case "${{ runner.os }}" in macOS) hdiutil attach dist/roadmaker-*-Darwin-*.dmg -mountpoint /tmp/rmdmg -nobrowse @@ -147,6 +171,8 @@ jobs: test -f "$app/Contents/Resources/help/roadmaker.qhc" test -e "$app/Contents/Frameworks/QtSql.framework" ls "$app/Contents/PlugIns/sqldrivers/"libqsqlite*.dylib + test -f "$app/Contents/Resources/manual/index.html" + test -f "$app/Contents/Resources/manual/tutorials/getting-around.html" hdiutil detach /tmp/rmdmg ;; Linux) @@ -157,6 +183,8 @@ jobs: test -f "$root/usr/bin/help/roadmaker.qhc" ls "$root"/usr/lib/libQt6Sql.so* ls "$root"/usr/plugins/sqldrivers/libqsqlite.so + test -f "$root/usr/share/roadmaker/manual/index.html" + test -f "$root/usr/share/roadmaker/manual/tutorials/getting-around.html" ;; Windows) 7z x dist/roadmaker-*-Windows-*.zip -osmoke -y > /dev/null @@ -166,6 +194,8 @@ jobs: test -f "$root/help/roadmaker.qhc" ls "$root"/Qt6Sql.dll ls "$root"/sqldrivers/qsqlite.dll + test -f "$root/manual/index.html" + test -f "$root/manual/tutorials/getting-around.html" ;; esac diff --git a/CHANGELOG.md b/CHANGELOG.md index 74de273..c466548 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Current version on `main`: **0.0.1**. ### Added +- **The manual ships with the app, and reference pages bridge into it** + ([#346](https://github.com/Robomous/RoadMaker/issues/346), docs-s2 — + [ADR-0009](docs/decisions/0009-documentation-site-tiered-docs.md)). Every + release now carries a browser-openable copy of the full illustrated manual, + reachable from **Help ▸ Open Manual in Browser**, alongside the in-app `F1` + book it already had. `F1` and the `.qch` pipeline are behaviourally unchanged. + + `npm run build:local` produces the offline reader. Opening from `file://` + forces three things, each enforced rather than assumed: pages are emitted as + `.html` (a browser will not serve `index.html` for a bare directory over + `file://`), every reference is rewritten relative (a root-absolute `/…` + resolves against the filesystem root), and search is **off** — Pagefind fetches + its index over XHR, which `file://` blocks, so the search UI is removed with it + and the landing page says where search lives. `check-local-build.mjs` verifies + the built output rather than the transform, so the gate still fails if the + rewriting step were dropped from the build. + + Packaging is opt-in (`ROADMAKER_BUNDLE_MANUAL`, default `OFF`) and **CMake + never invokes npm**: the release job builds the manual with Node and passes the + finished directory in as `ROADMAKER_MANUAL_DIR`, so a developer build still + needs no Node. The release smoke test asserts the manual on all three + platforms, at the same layout the app's own resolver computes. + + A reference page may end with a `## Full guide` section linking its tutorial. + The heading is the marker, so the authored link stays ordinary Markdown that + renders on GitHub; the site emits a normal link and the help compiler emits + `rmmanual:`, which the viewer resolves against the packaged manual at + runtime and opens externally (ADR-0009 rejects embedding a web view). Applied + to the 13 reference pages that have a matching tutorial, and gated from both + sides — renaming a tutorial fails the C++ bridge gate and the site adapter. + + Two defects surfaced on the way and are fixed here. The adapter treated any + `../`-prefixed link as leaving the guide, so since the tier split every + tutorial's link to a reference page left the site for GitHub; it now resolves + the target first. And the help build's dependency glob still watched + `tutorials/` while missing `reference/`, so editing a reference page left the + shipped collection stale. ([#297](https://github.com/Robomous/RoadMaker/issues/297) + is the help compiler's own `../` rewriting and is untouched.) - **The user guide is split into tiers, and a documentation site is scaffolded** ([#345](https://github.com/Robomous/RoadMaker/issues/345), docs-s1 — [ADR-0009](docs/decisions/0009-documentation-site-tiered-docs.md)). The diff --git a/CMakeLists.txt b/CMakeLists.txt index e27c583..b6c5a7a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -18,6 +18,12 @@ option(RM_BUILD_SHARED "Build roadmaker_core as a shared library" OFF) # Stub until M2 phase 5 — the exporter lands per docs/design/m2/04_usd_export.md. option(RM_BUILD_USD "Build the OpenUSD exporter" OFF) option(RM_INSTALL "Generate install rules for the kernel" ${PROJECT_IS_TOP_LEVEL}) +# The offline HTML manual (ADR-0009). OFF so a developer build never needs Node; +# the release packaging job builds it and passes ROADMAKER_MANUAL_DIR. No CMake +# target ever invokes npm. +option(ROADMAKER_BUNDLE_MANUAL "Install the prebuilt offline manual with the editor" OFF) +set(ROADMAKER_MANUAL_DIR "" CACHE PATH + "Prebuilt manual directory (docs-site/dist) — required when ROADMAKER_BUNDLE_MANUAL is ON") if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) set(CMAKE_BUILD_TYPE RelWithDebInfo CACHE STRING "Build type" FORCE) diff --git a/docs-site/.gitignore b/docs-site/.gitignore index e73c1a8..d071d0e 100644 --- a/docs-site/.gitignore +++ b/docs-site/.gitignore @@ -2,6 +2,7 @@ # hand-edited. Same for the theme CSS, which is derived from theme.cpp. src/content/docs/ src/styles/theme.css +public/ node_modules/ dist/ .astro/ diff --git a/docs-site/README.md b/docs-site/README.md index 330d29a..c23c108 100644 --- a/docs-site/README.md +++ b/docs-site/README.md @@ -34,13 +34,62 @@ Tiers ([ADR-0009](../docs/decisions/0009-documentation-site-tiered-docs.md)): ```sh npm ci npm run build # theme -> adapt -> F1 coverage -> astro build -npm run dev # same, then a dev server +npm run build:local # the offline reader that ships in a release +npm run dev # same as build, then a dev server npm run licenses # licence gate over the installed tree +npm test # script tests (node:test) ``` The adapter **fails the build** on a broken link, naming the source page and the target. +## Two builds, one source + +| Build | Output | Search | Links | +|---|---|---|---| +| `build` (web) | directory URLs | Pagefind | root-absolute | +| `build:local` | `format: 'file'` | **off** | fully relative | + +`build:local` produces the copy bundled in every release, which a reader opens +straight from disk. Three things follow from `file://`, and each is enforced +rather than assumed: + +- **`format: 'file'`** — a browser will not serve `index.html` for a bare + directory over `file://`, so pages are `.html`. +- **Relative references** — a root-absolute `/…` resolves against the filesystem + root and 404s. `scripts/relativize.mjs` rewrites them, and + `scripts/check-local-build.mjs` then verifies the OUTPUT, so the gate still + fails if the transform were removed or skipped. +- **No search** — Pagefind fetches its index over XHR, which `file://` blocks. + Switching it off removes the UI too: never ship a search box that does nothing. + The landing page says where search lives instead. + +`relativize.mjs` treats a reference matching no file in the build as an **error**, +not something to rewrite quietly — that is what catches a link to a page that was +renamed. It is idempotent, and `test/relativize.test.mjs` proves that by running +it twice and comparing bytes rather than by asserting it in a comment. + +A maintained relative-links integration was considered and rejected: every npm +package here is a permanent obligation under the licence gate, and this transform +is string work over a directory of HTML. + +## The reference → guide bridge + +A reference page may end with a section under the exact heading `## Full guide` +whose first link points at its tutorial. The **heading** is the marker, so the +authored link stays an ordinary relative Markdown link that renders correctly on +GitHub. Each pipeline then retargets it: + +- **this site** — an ordinary site link, via the adapter; +- **the `.qch`** — `rmmanual:`, which the in-app viewer resolves against the + packaged manual at runtime and opens in the system browser (ADR-0009 rejects + embedding a web view). The path is only knowable at runtime, which is why the + compiler emits a scheme rather than a URL. + +Two independent gates keep it honest: `HelpBridge.EveryBridgeTargetIsAPageThatExists` +(C++, over `docs/user-guide`) and the adapter's own broken-link failure. Renaming +a tutorial fails both. + ## Licences Every npm dependency must be MIT/BSD/Apache-2.0-compatible under diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index ec2e595..782e48c 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -4,6 +4,17 @@ import starlight from '@astrojs/starlight'; // The site is built from adapted content (see scripts/adapt.mjs); nothing under // src/content/docs/ is hand-written. +// +// TWO BUILDS, ONE SOURCE (ADR-0009): +// web — the published site. Directory URLs, Pagefind search on. +// local — the offline reader bundled in every release. It opens from file://, +// which changes two things. `format: 'file'` because a browser will +// not serve index.html for a bare directory over file://, and search +// OFF because Pagefind fetches its index over XHR, which file:// +// blocks in every mainstream browser. scripts/relativize.mjs then +// turns the root-absolute refs Astro emits into relative ones. +const local = process.env.RM_DOCS_TARGET === 'local'; + export default defineConfig({ // Astro's default image service is `sharp`, whose prebuilt libvips binaries // are LGPL-3.0-or-later. Qt is this project's ONLY sanctioned LGPL dependency @@ -11,12 +22,20 @@ export default defineConfig({ // `npm ci --omit=optional` keeps sharp out of the tree entirely. Guide images // are editor screenshots that need no build-time processing. image: { service: passthroughImageService() }, + ...(local ? { build: { format: 'file' } } : {}), integrations: [ starlight({ title: 'RoadMaker', description: 'Open-source ASAM OpenDRIVE road authoring — user guide, tutorials and tool reference.', customCss: ['./src/styles/theme.css'], + // Copied from editor/resources/branding by scripts/adapt.mjs. Starlight + // links a favicon whether or not one exists, so naming a real file is what + // stops the reference dangling. + favicon: '/favicon.png', + // Never ship a search box that does nothing: switching Pagefind off also + // removes the header UI that would query it. + pagefind: !local, sidebar: [ { label: 'Guide', link: '/' }, { label: 'Reference', autogenerate: { directory: 'reference' } }, diff --git a/docs-site/package.json b/docs-site/package.json index 24a6266..8271947 100644 --- a/docs-site/package.json +++ b/docs-site/package.json @@ -12,9 +12,12 @@ "adapt": "node scripts/adapt.mjs", "theme": "node scripts/theme-css.mjs", "build": "npm run theme && npm run adapt && node scripts/check-f1-coverage.mjs && astro build", + "build:local": "node scripts/build-local.mjs", "dev": "npm run theme && npm run adapt && astro dev", "licenses": "node scripts/licenses.mjs", - "check": "node scripts/check-f1-coverage.mjs" + "check": "node scripts/check-f1-coverage.mjs", + "check:local": "node scripts/check-local-build.mjs", + "test": "node --test \"test/**/*.test.mjs\"" }, "dependencies": { "@astrojs/starlight": "0.36.0", diff --git a/docs-site/scripts/adapt.mjs b/docs-site/scripts/adapt.mjs index 3d4bf75..1351690 100644 --- a/docs-site/scripts/adapt.mjs +++ b/docs-site/scripts/adapt.mjs @@ -35,6 +35,24 @@ const guide = join(repo, 'docs', 'user-guide'); const outDir = join(here, '..', 'src', 'content', 'docs'); const REPO_BLOB = 'https://github.com/Robomous/RoadMaker/blob/main'; +const WEB_DOCS = 'https://github.com/Robomous/RoadMaker/tree/main/docs/user-guide'; + +/// `local` builds the offline reader that ships in a release (ADR-0009): it opens +/// from file://, so Pagefind cannot index it and the search UI is switched off. +/// Everything else about the two builds is identical. +const target = process.env.RM_DOCS_TARGET === 'local' ? 'local' : 'web'; + +/// Said once, on the landing page, so a reader who reaches for search learns +/// where it lives instead of finding a box that does nothing. +const LOCAL_SEARCH_NOTE = [ + ':::note[Offline copy]', + 'This is the manual bundled with your copy of RoadMaker, opened straight from', + `disk. Full-text search needs a web server, so it is available on the [online`, + `documentation](${WEB_DOCS}) instead. Every page is here; only the search box`, + 'is missing.', + ':::', + '', +].join('\n'); const errors = []; @@ -92,9 +110,14 @@ for (const rel of pages) { const [path, anchor = ''] = target.split(/(#.*)/); if (!path) return whole; - // Leaves the guide -> the repo on GitHub, as the Qt Help renderer does. - if (path.startsWith('../')) { - const absolute = resolve(dirname(srcPath), path); + // `../` means "up one directory" — NOT "out of the guide". Since docs-s1 the + // guide has subdirectories, so `reference/x.md` -> `../tutorials/y.md` lands + // back INSIDE it and is an ordinary in-guide link. Resolve first, then decide. + const absolute = resolve(dirname(srcPath), path); + const insideGuide = !relative(guide, absolute).startsWith('..'); + + // Genuinely leaves the guide -> the repo on GitHub, as the Qt renderer does. + if (path.startsWith('../') && !insideGuide) { const resolved = relative(repo, absolute).split('\\').join('/'); if (!existsSync(absolute)) { errors.push(`${rel}: broken link to '${target}' (resolved to ${resolved})`); @@ -114,7 +137,7 @@ for (const rel of pages) { } if (path.endsWith('.md')) { - const target_rel = relative(guide, resolve(dirname(srcPath), path)).split('\\').join('/'); + const target_rel = relative(guide, absolute).split('\\').join('/'); if (!pageSet.has(target_rel)) { errors.push(`${rel}: broken link to '${target}' (no page ${target_rel})`); } @@ -141,9 +164,11 @@ for (const rel of pages) { '', ].join('\n'); + const note = target === 'local' && rel === 'index.md' ? LOCAL_SEARCH_NOTE : ''; + const dest = join(outDir, rel); mkdirSync(dirname(dest), { recursive: true }); - writeFileSync(dest, frontmatter + body.trimStart()); + writeFileSync(dest, frontmatter + note + body.trimStart()); } // Image folders ride along so the pages' relative srcs resolve. @@ -152,9 +177,19 @@ for (const rel of ['reference/img', 'tutorials/img']) { if (existsSync(src)) cpSync(src, join(outDir, rel), { recursive: true }); } +// The tab icon, taken from the app's own icon set rather than drawn again, so +// the site and the editor cannot show different marks. Starlight links a favicon +// unconditionally; without the file the reference dangles, which is invisible on +// a server (a 404 in the console) and a real broken reference under file://. +const publicDir = join(here, '..', 'public'); +mkdirSync(publicDir, { recursive: true }); +cpSync(join(repo, 'editor', 'resources', 'branding', 'icon_64.png'), join(publicDir, 'favicon.png')); + if (errors.length > 0) { console.error(`adapt: ${errors.length} problem(s)`); for (const e of errors) console.error(` ${e}`); process.exit(1); } -console.log(`adapt: ${pages.length} pages, ${order.length} ordered from index.md`); +console.log( + `adapt: ${pages.length} pages, ${order.length} ordered from index.md (${target} build)`, +); diff --git a/docs-site/scripts/build-local.mjs b/docs-site/scripts/build-local.mjs new file mode 100644 index 0000000..179fa18 --- /dev/null +++ b/docs-site/scripts/build-local.mjs @@ -0,0 +1,56 @@ +// Copyright 2026 Robomous +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// `npm run build:local` — the offline reader that ships inside a release +// (ADR-0009 / docs-s2). One script rather than a chain of npm scripts for two +// reasons: setting an environment variable inside an npm script is not portable +// without a dependency, and the post-processing step MUST NOT be skippable — +// a build whose links were never relativized looks perfectly fine until someone +// opens it from a disc. +// +// The release packaging job runs this and hands the resulting dist/ to CMake as +// a path; CMake never invokes npm. +import { spawnSync } from 'node:child_process'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = join(here, '..'); +const env = { ...process.env, RM_DOCS_TARGET: 'local' }; + +/** Run a step; a non-zero exit stops the build with that step's status. */ +function step(label, command, args) { + console.log(`\nbuild:local — ${label}`); + const result = spawnSync(command, args, { cwd: root, env, stdio: 'inherit', shell: false }); + if (result.error) { + console.error(`build:local: ${label} could not start: ${result.error.message}`); + process.exit(1); + } + if (result.status !== 0) { + console.error(`build:local: ${label} failed (exit ${result.status})`); + process.exit(result.status ?? 1); + } +} + +const node = process.execPath; +const astro = join(root, 'node_modules', 'astro', 'astro.js'); + +step('theme tokens', node, [join(here, 'theme-css.mjs')]); +step('adapt docs/user-guide', node, [join(here, 'adapt.mjs')]); +step('F1 coverage', node, [join(here, 'check-f1-coverage.mjs')]); +step('astro build (file format, search off)', node, [astro, 'build']); +step('relativize references', node, [join(here, 'relativize.mjs')]); +step('verify the local build', node, [join(here, 'check-local-build.mjs')]); + +console.log('\nbuild:local: dist/ is ready to open from file://'); diff --git a/docs-site/scripts/check-local-build.mjs b/docs-site/scripts/check-local-build.mjs new file mode 100644 index 0000000..e837c6d --- /dev/null +++ b/docs-site/scripts/check-local-build.mjs @@ -0,0 +1,88 @@ +// Copyright 2026 Robomous +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// The gate on the local reader build (ADR-0009 / docs-s2): a root-absolute +// reference resolves against the filesystem root under file://, so a single +// surviving `/…` is a dead link in the shipped manual. +// +// This checks the OUTPUT, deliberately not the transform — it would still fail +// if relativize.mjs were removed, reordered out of the build, or silently +// skipped a page. It also asserts the two other things that make the build +// "local": that the entry point exists, and that no search UI shipped without an +// index behind it. +import { readFileSync, existsSync, statSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { htmlFiles } from './relativize.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); +const distDir = process.argv[2] ?? join(here, '..', 'dist'); + +if (!existsSync(distDir) || !statSync(distDir).isDirectory()) { + console.error(`check-local-build: no build at ${distDir}`); + process.exit(1); +} + +const failures = []; + +// 1. The reader's entry point. +if (!existsSync(join(distDir, 'index.html'))) { + failures.push('no index.html at the root of the build — nothing to open'); +} + +// 2. No root-absolute href/src/srcset anywhere. +const pages = htmlFiles(distDir); +if (pages.length === 0) { + failures.push('the build contains no HTML pages at all'); +} +let absolute = 0; +for (const pageRel of pages) { + const html = readFileSync(join(distDir, pageRel), 'utf8'); + for (const match of html.matchAll(/\b(href|src|srcset)="([^"]*)"/g)) { + const [, attr, value] = match; + const urls = attr === 'srcset' ? value.split(',').map((p) => p.trim().split(/\s+/)[0]) : [value]; + for (const url of urls) { + // `//host/…` is protocol-relative and genuinely absolute — not our concern. + if (url.startsWith('/') && !url.startsWith('//')) { + absolute += 1; + if (absolute <= 20) failures.push(`${pageRel}: root-absolute ${attr}="${url}"`); + } + } + } +} +if (absolute > 20) { + failures.push(`…and ${absolute - 20} more root-absolute reference(s)`); +} + +// 3. Pagefind is off, so nothing may advertise a search that cannot answer. +if (existsSync(join(distDir, 'pagefind'))) { + failures.push('a pagefind/ index shipped in the local build — search must be off (file:// cannot fetch it)'); +} +for (const pageRel of pages) { + const html = readFileSync(join(distDir, pageRel), 'utf8'); + if (html.includes('/pagefind/') || html.includes('data-open-modal')) { + failures.push(`${pageRel}: still carries the Pagefind search UI`); + break; + } +} + +if (failures.length > 0) { + console.error(`check-local-build: ${failures.length} problem(s) in ${distDir}`); + for (const failure of failures) console.error(` ${failure}`); + process.exit(1); +} +console.log( + `check-local-build: ${pages.length} pages, no root-absolute references, search correctly absent`, +); diff --git a/docs-site/scripts/relativize.mjs b/docs-site/scripts/relativize.mjs new file mode 100644 index 0000000..e7103b2 --- /dev/null +++ b/docs-site/scripts/relativize.mjs @@ -0,0 +1,150 @@ +// Copyright 2026 Robomous +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Turns the root-absolute references Astro emits into page-relative ones, so the +// local reader opens straight off disk (ADR-0009). `file:///…/manual/index.html` +// has no site root, so `/reference/junction.html` resolves against the +// filesystem root and 404s; `../reference/junction.html` just works. +// +// Deliberately NOT a dependency: the transform is string work over a directory of +// HTML, and every npm package added here has to pass the licence gate forever +// after. See docs-site/README.md. +// +// IDEMPOTENT BY CONSTRUCTION: it only rewrites values that begin with `/`, and +// produces none, so a second run is a no-op. test/relativize.test.mjs proves it +// on the real build output rather than taking the argument's word for it. +// +// A reference that resolves to no file in the build is an ERROR, not a silent +// rewrite — that is what catches a link to a page that was renamed or never +// existed, which is otherwise invisible until a human clicks it. +import { readFileSync, writeFileSync, readdirSync, statSync, existsSync, realpathSync } from 'node:fs'; +import { join, relative, dirname, posix } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** Every .html file under `dir`, as paths relative to it. */ +export function htmlFiles(dir, base = dir) { + const out = []; + for (const name of readdirSync(dir)) { + const path = join(dir, name); + if (statSync(path).isDirectory()) { + out.push(...htmlFiles(path, base)); + } else if (name.endsWith('.html')) { + out.push(relative(base, path).split('\\').join('/')); + } + } + return out; +} + +/** + * The file in `distDir` a root-absolute reference points at, as a dist-relative + * path — or '' when nothing matches. + * + * Two shapes have to resolve. Starlight's own navigation already emits the built + * filename (`/reference/junction.html`), but links written in the guide's + * Markdown arrive extensionless (`/reference/junction`), because the adapter + * emits Starlight slugs and Astro passes content links through untouched. + */ +export function resolveTarget(distDir, pathname) { + const clean = pathname.replace(/\/+$/, ''); + const candidates = clean === '' ? ['index.html'] : [clean, `${clean}.html`, `${clean}/index.html`]; + for (const candidate of candidates) { + const rel = candidate.replace(/^\//, ''); + const full = join(distDir, rel); + if (existsSync(full) && statSync(full).isFile()) return rel; + } + return ''; +} + +/** + * Rewrite one page's root-absolute href/src/srcset values to paths relative to + * `pageRel`. Returns the new text plus any references that resolved to nothing. + */ +export function relativizePage(distDir, pageRel, html) { + const fromDir = posix.dirname(pageRel); + const unresolved = []; + + const rewrite = (value) => { + const hash = value.indexOf('#'); + const pathname = hash >= 0 ? value.slice(0, hash) : value; + const suffix = hash >= 0 ? value.slice(hash) : ''; + let decoded; + try { + decoded = decodeURI(pathname); + } catch { + decoded = pathname; + } + const targetRel = resolveTarget(distDir, decoded); + if (!targetRel) { + unresolved.push(value); + return value; + } + const rel = fromDir === '.' ? targetRel : posix.relative(fromDir, targetRel); + return `${rel.startsWith('.') ? rel : `./${rel}`}${suffix}`; + }; + + const text = html.replace( + /\b(href|src|srcset)="([^"]*)"/g, + (whole, attr, value) => { + if (attr === 'srcset') { + // "a.webp 1x, b.webp 2x" — rewrite each URL, keep each descriptor. + if (!/(^|,)\s*\//.test(value)) return whole; + const parts = value.split(',').map((part) => { + const [url, ...rest] = part.trim().split(/\s+/); + if (!url.startsWith('/')) return part.trim(); + return [rewrite(url), ...rest].join(' '); + }); + return `${attr}="${parts.join(', ')}"`; + } + if (!value.startsWith('/')) return whole; + return `${attr}="${rewrite(value)}"`; + }, + ); + + return { text, unresolved }; +} + +/** Rewrite every page under `distDir` in place. Returns the pages touched. */ +export function relativize(distDir) { + const problems = []; + let touched = 0; + for (const pageRel of htmlFiles(distDir)) { + const full = join(distDir, pageRel); + const before = readFileSync(full, 'utf8'); + const { text, unresolved } = relativizePage(distDir, pageRel, before); + for (const value of unresolved) problems.push(`${pageRel}: '${value}' matches no file in the build`); + if (text !== before) { + writeFileSync(full, text); + touched += 1; + } + } + return { touched, problems }; +} + +// Run as a script; importable for the tests. +const here = fileURLToPath(import.meta.url); +const invoked = process.argv[1] ? realpathSync(process.argv[1]) : ''; +if (invoked === realpathSync(here)) { + const distDir = process.argv[2] ?? join(dirname(here), '..', 'dist'); + if (!existsSync(distDir)) { + console.error(`relativize: no build at ${distDir} — run the local build first`); + process.exit(1); + } + const { touched, problems } = relativize(distDir); + if (problems.length > 0) { + console.error(`relativize: ${problems.length} unresolved reference(s)`); + for (const problem of problems) console.error(` ${problem}`); + process.exit(1); + } + console.log(`relativize: rewrote ${touched} page(s) in ${distDir}`); +} diff --git a/docs-site/test/relativize.test.mjs b/docs-site/test/relativize.test.mjs new file mode 100644 index 0000000..bc6d6b4 --- /dev/null +++ b/docs-site/test/relativize.test.mjs @@ -0,0 +1,115 @@ +// Copyright 2026 Robomous +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// node:test — the runner Node ships. The site's licence gate makes every added +// package a permanent obligation, so a test runner is not worth one. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { relativize, relativizePage, resolveTarget, htmlFiles } from '../scripts/relativize.mjs'; + +/** A throwaway dist/ with the shapes the real build produces. */ +function fixture() { + const dir = mkdtempSync(join(tmpdir(), 'rm-relativize-')); + mkdirSync(join(dir, 'reference'), { recursive: true }); + mkdirSync(join(dir, '_astro'), { recursive: true }); + writeFileSync(join(dir, '_astro', 'index.css'), 'body{}'); + writeFileSync(join(dir, '_astro', 'shot.webp'), ''); + writeFileSync(join(dir, 'favicon.svg'), ''); + writeFileSync(join(dir, 'index.html'), 'j'); + writeFileSync( + join(dir, 'reference', 'junction.html'), + [ + '', + 'home', + 'sibling', + // The adapter emits Starlight slugs, so content links arrive extensionless. + 'slug form', + 'with an anchor', + '', + 'external', + 'in-page', + ].join('\n'), + ); + writeFileSync(join(dir, 'reference', 'create-road.html'), '

hi

'); + return dir; +} + +test('resolveTarget accepts the built filename, the slug, and a directory', () => { + const dir = fixture(); + try { + assert.equal(resolveTarget(dir, '/reference/junction.html'), 'reference/junction.html'); + assert.equal(resolveTarget(dir, '/reference/junction'), 'reference/junction.html'); + assert.equal(resolveTarget(dir, '/'), 'index.html'); + assert.equal(resolveTarget(dir, '/reference/nope'), ''); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('a nested page gets ../-relative references and keeps its anchors', () => { + const dir = fixture(); + try { + const page = 'reference/junction.html'; + const { text, unresolved } = relativizePage(dir, page, readFileSync(join(dir, page), 'utf8')); + assert.deepEqual(unresolved, []); + assert.match(text, /href="\.\.\/_astro\/index\.css"/); + assert.match(text, /href="\.\.\/index\.html"/); + assert.match(text, /href="\.\/create-road\.html"/); + assert.match(text, /href="\.\/create-road\.html#lanes"/); + assert.match(text, /srcset="\.\.\/_astro\/shot\.webp 1x, \.\.\/_astro\/shot\.webp 2x"/); + // Untouched: neither is root-absolute. + assert.match(text, /href="https:\/\/example\.invalid\/x"/); + assert.match(text, /href="#top"/); + assert.equal(text.includes('="/'), false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('running it twice equals running it once', () => { + const dir = fixture(); + try { + const first = relativize(dir); + assert.deepEqual(first.problems, []); + assert.ok(first.touched > 0, 'the first pass must actually change something'); + const after = htmlFiles(dir).map((rel) => readFileSync(join(dir, rel), 'utf8')); + + const second = relativize(dir); + assert.deepEqual(second.problems, []); + assert.equal(second.touched, 0, 'the second pass must rewrite nothing'); + assert.deepEqual( + htmlFiles(dir).map((rel) => readFileSync(join(dir, rel), 'utf8')), + after, + 'byte-identical after a second pass', + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('a reference matching no file is reported, not silently rewritten', () => { + const dir = fixture(); + try { + writeFileSync(join(dir, 'reference', 'junction.html'), 'x'); + const { problems } = relativize(dir); + assert.equal(problems.length, 1); + assert.match(problems[0], /renamed-away/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/docs/user-guide/reference/camera-navigation.md b/docs/user-guide/reference/camera-navigation.md index 836d1ea..0de9676 100644 --- a/docs/user-guide/reference/camera-navigation.md +++ b/docs/user-guide/reference/camera-navigation.md @@ -154,3 +154,7 @@ click. - [Right-click menus](context-menus.md) — the menu the short right-click opens - [Moving and transforming](moving-and-transforming.md) — moving *content*, as opposed to moving the camera + +## Full guide + +[Getting around](../tutorials/getting-around.md) — orbit, frame and switch views on a real scene. diff --git a/docs/user-guide/reference/create-road.md b/docs/user-guide/reference/create-road.md index 435b939..04cf972 100644 --- a/docs/user-guide/reference/create-road.md +++ b/docs/user-guide/reference/create-road.md @@ -37,3 +37,7 @@ reshape it later without re-deriving them. Precise interaction, preview, and undo semantics: [M2 editing tools §2 (Create Road)](../../design/m2/02_editing_tools.md). + +## Full guide + +[Your first road network](../tutorials/first-road-network.md) — draw this road, tee in a second, and export OpenDRIVE. diff --git a/docs/user-guide/reference/edit-nodes.md b/docs/user-guide/reference/edit-nodes.md index fcd9b43..bbaf600 100644 --- a/docs/user-guide/reference/edit-nodes.md +++ b/docs/user-guide/reference/edit-nodes.md @@ -30,3 +30,7 @@ session that commits one command on release. [M2 editing tools §3 (Edit Nodes)](../../design/m2/02_editing_tools.md) — insert / delete / drag semantics and the `edit::insert_waypoint` / `edit::delete_waypoint` kernel API. + +## Full guide + +[Your first road network](../tutorials/first-road-network.md) — reshape the road you just drew, in context. diff --git a/docs/user-guide/reference/junction.md b/docs/user-guide/reference/junction.md index 8503f96..71b2e89 100644 --- a/docs/user-guide/reference/junction.md +++ b/docs/user-guide/reference/junction.md @@ -229,3 +229,7 @@ the `junction_signals()` query, the four templates and the two signalization commands. The `rm:maneuver`, `rm:signal` and `rm:signalmount` payloads are registered in [ADR-0008](../../decisions/0008-persistence-layers-asam-first.md). + +## Full guide + +[Your first road network](../tutorials/first-road-network.md) — build a junction between two roads you drew yourself. diff --git a/docs/user-guide/reference/lane-add.md b/docs/user-guide/reference/lane-add.md index 261fbba..cdbeedb 100644 --- a/docs/user-guide/reference/lane-add.md +++ b/docs/user-guide/reference/lane-add.md @@ -33,3 +33,7 @@ is one undoable command. [M2 editing tools §4](../../design/m2/02_editing_tools.md) and the [P2 discovery report](../../roadmap/pillars/p2_discovery.md). + +## Full guide + +[Shaping lanes](../tutorials/shaping-lanes.md) — drop a pocket lane into a road you have been editing. diff --git a/docs/user-guide/reference/lane-carve.md b/docs/user-guide/reference/lane-carve.md index b5e2f8e..fc136ad 100644 --- a/docs/user-guide/reference/lane-carve.md +++ b/docs/user-guide/reference/lane-carve.md @@ -34,3 +34,7 @@ held-full tail into a connecting lane. [M2 editing tools §4](../../design/m2/02_editing_tools.md) and the [P2 discovery report](../../roadmap/pillars/p2_discovery.md). + +## Full guide + +[Shaping lanes](../tutorials/shaping-lanes.md) — carve a turn lane on a junction approach. diff --git a/docs/user-guide/reference/lane-form.md b/docs/user-guide/reference/lane-form.md index 2b29834..d8aa00a 100644 --- a/docs/user-guide/reference/lane-form.md +++ b/docs/user-guide/reference/lane-form.md @@ -37,3 +37,7 @@ whole operation is a single undoable command. [M2 editing tools §4](../../design/m2/02_editing_tools.md) and the [P2 discovery report](../../roadmap/pillars/p2_discovery.md). Lane linking and sections: [OpenDRIVE conventions](../../domain/opendrive.md). + +## Full guide + +[Shaping lanes](../tutorials/shaping-lanes.md) — grow a lane to the road end, linked across seams. diff --git a/docs/user-guide/reference/lane-profile.md b/docs/user-guide/reference/lane-profile.md index 2a10747..71ac414 100644 --- a/docs/user-guide/reference/lane-profile.md +++ b/docs/user-guide/reference/lane-profile.md @@ -56,3 +56,7 @@ junctions are updated. Removing a lane and undoing restores the very same lane [M2 editing tools §4 (Lane Profile)](../../design/m2/02_editing_tools.md) and the [P2 discovery report](../../roadmap/pillars/p2_discovery.md) for the lane-editing tools added in the P2 pillar. + +## Full guide + +[Shaping lanes](../tutorials/shaping-lanes.md) — add, retype and widen lanes across a whole road. diff --git a/docs/user-guide/reference/lane-width.md b/docs/user-guide/reference/lane-width.md index e7f4aec..7cba581 100644 --- a/docs/user-guide/reference/lane-width.md +++ b/docs/user-guide/reference/lane-width.md @@ -38,3 +38,7 @@ in or out of existence (the basis for [Lane Add](lane-add.md), [M2 editing tools §4](../../design/m2/02_editing_tools.md) and the [P2 discovery report](../../roadmap/pillars/p2_discovery.md). Width semantics and the zero-width rule: [OpenDRIVE conventions](../../domain/opendrive.md). + +## Full guide + +[Shaping lanes](../tutorials/shaping-lanes.md) — shape one lane's width as part of a full cross-section pass. diff --git a/docs/user-guide/reference/road-styles.md b/docs/user-guide/reference/road-styles.md index 791e6d2..72d252a 100644 --- a/docs/user-guide/reference/road-styles.md +++ b/docs/user-guide/reference/road-styles.md @@ -52,3 +52,7 @@ bridge and the trees stay put — only the lanes and their markings change. [Library](library.md) for the drag-and-drop model and the [P2 discovery report](../../roadmap/pillars/p2_discovery.md) for the road-style preservation contract. + +## Full guide + +[Working with road styles](../tutorials/working-with-road-styles.md) — restyle a whole road from the Library, end to end. diff --git a/docs/user-guide/reference/save-export.md b/docs/user-guide/reference/save-export.md index c610fb1..bc4b611 100644 --- a/docs/user-guide/reference/save-export.md +++ b/docs/user-guide/reference/save-export.md @@ -48,3 +48,7 @@ network model itself stays right-handed and Z-up. load / author / export from code. - [OpenDRIVE conventions](../../domain/opendrive.md) — what the writer emits and the coordinate frame it uses. + +## Full guide + +[Your first road network](../tutorials/first-road-network.md) — take a scene from an empty window to a written .xodr. diff --git a/docs/user-guide/reference/scene-tree.md b/docs/user-guide/reference/scene-tree.md index 1619c8d..1ccbbd4 100644 --- a/docs/user-guide/reference/scene-tree.md +++ b/docs/user-guide/reference/scene-tree.md @@ -31,3 +31,7 @@ you add, split, merge, or delete objects. The tree is backed by a `QAbstractItemModel` over the road network; the model and its update rules are covered in the editor architecture notes (`docs/architecture/editor.md`). + +## Full guide + +[Getting around](../tutorials/getting-around.md) — find and frame things in a scene you did not build. diff --git a/docs/user-guide/reference/t-junction.md b/docs/user-guide/reference/t-junction.md index 8ac43a4..93a0ec2 100644 --- a/docs/user-guide/reference/t-junction.md +++ b/docs/user-guide/reference/t-junction.md @@ -56,3 +56,7 @@ If the side road is too short to reach, the attach reports an error instead. [T-junction design](../../design/hardening/t_junction.md) — the split/attach composition, the gap formula, and the connecting-road conventions with their OpenDRIVE rule citations. + +## Full guide + +[Your first road network](../tutorials/first-road-network.md) — tee a second road into the first, step by step. diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 881144a..21454e0 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -97,6 +97,7 @@ add_library(roadmaker_editor_lib STATIC src/help/help_locator.cpp src/help/help_registry.cpp src/help/help_viewer.cpp + src/help/manual_locator.cpp src/panels/diagnostics_panel.cpp src/panels/export_preview_window.cpp src/panels/world_georeference_window.cpp @@ -253,11 +254,15 @@ set(_rm_help_out ${CMAKE_BINARY_DIR}/help) set(_rm_guide_dir ${CMAKE_SOURCE_DIR}/docs/user-guide) set(_rm_help_css ${CMAKE_CURRENT_SOURCE_DIR}/resources/help/help.css) +# The .qhp pipeline reads index.md plus the reference/ tier; docs-s1 took +# tutorials/ out of it (helpc::build_toc skips them — Starlight serves that tier). +# Watch exactly what rm_helpc reads, or editing a reference page leaves the +# shipped collection stale. file(GLOB _rm_help_inputs CONFIGURE_DEPENDS ${_rm_guide_dir}/*.md - ${_rm_guide_dir}/tutorials/*.md + ${_rm_guide_dir}/reference/*.md ${_rm_guide_dir}/img/* - ${_rm_guide_dir}/tutorials/img/*) + ${_rm_guide_dir}/reference/img/*) set(_rm_qhp ${_rm_help_out}/html/roadmaker.qhp) set(_rm_qhcp ${_rm_help_out}/roadmaker.qhcp) @@ -336,6 +341,34 @@ if(RM_INSTALL) install(FILES ${_rm_qch} ${_rm_qhc} DESTINATION bin/help) endif() + # The offline HTML manual (ADR-0009 / docs-s2), opt-in so a developer build + # never needs Node. CMAKE NEVER INVOKES NPM: the packaging job runs + # `npm run build:local` first and passes the finished directory in as + # ROADMAKER_MANUAL_DIR. Layout per ADR-0009 — inside the bundle on macOS, + # share/ on Linux, beside the exe on Windows. + if(ROADMAKER_BUNDLE_MANUAL) + if(NOT ROADMAKER_MANUAL_DIR) + message(FATAL_ERROR + "ROADMAKER_BUNDLE_MANUAL is ON but ROADMAKER_MANUAL_DIR is unset. Build the " + "manual first (cd docs-site && npm ci && npm run build:local) and pass " + "-DROADMAKER_MANUAL_DIR=.") + endif() + if(NOT EXISTS ${ROADMAKER_MANUAL_DIR}/index.html) + message(FATAL_ERROR + "ROADMAKER_MANUAL_DIR=${ROADMAKER_MANUAL_DIR} has no index.html — that is not a " + "built manual.") + endif() + if(APPLE) + set(_rm_manual_dest roadmaker-editor.app/Contents/Resources) + elseif(WIN32) + set(_rm_manual_dest ${CMAKE_INSTALL_BINDIR}) + else() + set(_rm_manual_dest ${CMAKE_INSTALL_DATADIR}/roadmaker) + endif() + install(DIRECTORY ${ROADMAKER_MANUAL_DIR}/ DESTINATION ${_rm_manual_dest}/manual) + message(STATUS "Bundling the offline manual from ${ROADMAKER_MANUAL_DIR}") + endif() + # Linux desktop integration: the .desktop entry (app menu / launcher) plus # the hicolor icon theme the entry's `Icon=roadmaker` resolves against. if(UNIX AND NOT APPLE) diff --git a/editor/help_compiler/helpc/render.cpp b/editor/help_compiler/helpc/render.cpp index 62a6646..db10cc6 100644 --- a/editor/help_compiler/helpc/render.cpp +++ b/editor/help_compiler/helpc/render.cpp @@ -54,8 +54,76 @@ std::pair split_anchor(const std::string& target) { return {target.substr(0, hash), target.substr(hash)}; } +/// The span of the `## Full guide` section, or npos when the page has none. +/// The heading must be alone on its line; the section runs to the next H2. +std::pair bridge_span(const std::string& markdown) { + const std::string heading = kBridgeHeading; + std::size_t at = 0; + while ((at = markdown.find(heading, at)) != std::string::npos) { + const bool line_start = at == 0 || markdown[at - 1] == '\n'; + const std::size_t after = at + heading.size(); + const bool line_end = + after >= markdown.size() || markdown[after] == '\n' || markdown[after] == '\r'; + if (line_start && line_end) { + const std::size_t next = markdown.find("\n## ", after); + return {after, next == std::string::npos ? markdown.size() : next}; + } + at = after; + } + return {std::string::npos, std::string::npos}; +} + } // namespace +std::optional bridge_link(const std::string& markdown, const std::string& page_rel) { + const auto [begin, end] = bridge_span(markdown); + if (begin == std::string::npos) { + return std::nullopt; + } + + // The FIRST markdown link in the section is the bridge; anything after it is + // ordinary prose. + const std::size_t open = markdown.find('[', begin); + if (open == std::string::npos || open >= end) { + return std::nullopt; + } + const std::size_t close = markdown.find("](", open); + if (close == std::string::npos || close >= end) { + return std::nullopt; + } + const std::size_t paren = markdown.find(')', close + 2); + if (paren == std::string::npos || paren >= end) { + return std::nullopt; + } + + BridgeLink link; + link.text = markdown.substr(open + 1, close - (open + 1)); + link.target_begin = close + 2; + link.target_end = paren; + link.target = markdown.substr(link.target_begin, link.target_end - link.target_begin); + + std::string path = link.target; + if (const auto hash = path.find('#'); hash != std::string::npos) { + link.anchor = path.substr(hash); + path = path.substr(0, hash); + } + if (path.empty() || is_absolute_link(path)) { + return std::nullopt; + } + + // Resolve against the PAGE's directory, which is what turns a reference page's + // `../tutorials/x.md` into the guide-relative `tutorials/x`. + std::filesystem::path resolved = + std::filesystem::path(page_rel).parent_path() / std::filesystem::path(path); + resolved = resolved.lexically_normal(); + std::string slug = resolved.generic_string(); + if (slug.size() >= 3 && slug.substr(slug.size() - 3) == ".md") { + slug = slug.substr(0, slug.size() - 3); + } + link.slug = slug; + return link; +} + std::string rewrite_target(const std::string& target, const RenderOptions& opts, bool is_image, @@ -89,7 +157,17 @@ std::string rewrite_target(const std::string& target, } std::string render_page(const std::string& markdown, const RenderOptions& opts) { - std::string body = md_to_html(markdown); + std::string source = markdown; + + // Retarget the bridge link BEFORE the Markdown is rendered, so the generic + // href rewriting below never sees it. `rmmanual:` reaches the viewer intact. + if (const std::optional bridge = bridge_link(source, opts.page_rel)) { + source.replace(bridge->target_begin, + bridge->target_end - bridge->target_begin, + std::string(kManualScheme) + bridge->slug + bridge->anchor); + } + + std::string body = md_to_html(source); const auto rewrite_attr = [&](const std::string& attr, bool is_image) { const std::regex pattern(attr + R"rx(="([^"]*)")rx"); diff --git a/editor/help_compiler/helpc/render.hpp b/editor/help_compiler/helpc/render.hpp index 233b94d..c538f4b 100644 --- a/editor/help_compiler/helpc/render.hpp +++ b/editor/help_compiler/helpc/render.hpp @@ -20,14 +20,49 @@ // Qt Help collection needs. Qt-free (md4c-html only). #include +#include #include namespace roadmaker::helpc { +/// The reference→guide bridge (ADR-0009 / docs-s2). A reference page ends with a +/// section under this exact heading whose first link points at the full guide. +/// +/// The heading is the marker, so the AUTHORED link stays an ordinary relative +/// Markdown link — it renders correctly on GitHub and the site adapter turns it +/// into a normal site link. Only this pipeline rewrites it, because only this +/// pipeline needs a URL the in-app viewer can recognise. +inline constexpr const char* kBridgeHeading = "## Full guide"; + +/// Scheme the rewritten bridge link carries. `HelpBrowser` resolves it against +/// the packaged manual at runtime and opens it in the external browser; nothing +/// else in the collection uses it. It survives `rewrite_target` untouched, being +/// neither `../`-prefixed nor `.md`-suffixed. +inline constexpr const char* kManualScheme = "rmmanual:"; + +/// One page's bridge link, as authored and as resolved. +struct BridgeLink { + std::string text; ///< the link's label + std::string target; ///< exactly as authored, e.g. `../tutorials/getting-around.md` + std::string slug; ///< guide-relative, extensionless, e.g. `tutorials/getting-around` + std::string anchor; ///< `#fragment`, or empty + + /// Byte range of `target` in the source. The renderer rewrites AT this offset + /// rather than searching for the text: a page may well link the same guide + /// earlier in its prose, and only the one in the bridge section may change. + std::size_t target_begin = 0; + std::size_t target_end = 0; +}; + struct RenderOptions { std::string title; ///< page H1, used for std::string css_href = "help.css"; ///< stylesheet the page links + /// This page's path relative to the guide dir (`reference/junction.md`). Used + /// ONLY to resolve the bridge link's slug; the general link rewriting below is + /// deliberately left as it was (see #297). + std::string page_rel; + /// `../foo.md` links leave the guide, so they cannot be served from the /// collection; they are rewritten to the page on GitHub. `guide_rel` is the /// guide directory relative to the repo root, so `../` normalises correctly. @@ -44,6 +79,16 @@ struct RenderOptions { /// Render `markdown` to a full standalone HTML document. [[nodiscard]] std::string render_page(const std::string& markdown, const RenderOptions& opts); +/// The bridge link in `markdown`'s `## Full guide` section, or nullopt when the +/// page has no such section. `page_rel` is the page's path relative to the guide +/// directory, which is what makes `../tutorials/x.md` resolve to `tutorials/x`. +/// +/// Shared by the renderer and by the gate that proves every bridge target is a +/// page that exists — one parser, so the gate cannot check a different thing +/// from the one that ships. +[[nodiscard]] std::optional<BridgeLink> bridge_link(const std::string& markdown, + const std::string& page_rel); + /// Rewrite a single href/src target per the collection rules. Exposed for /// tests; `copied_image` (out) receives the source path of any external image /// that should be copied, or stays empty. diff --git a/editor/help_compiler/main.cpp b/editor/help_compiler/main.cpp index d6d87cd..274c069 100644 --- a/editor/help_compiler/main.cpp +++ b/editor/help_compiler/main.cpp @@ -119,6 +119,7 @@ int main(int argc, char** argv) { for (const roadmaker::helpc::TocEntry& page : roadmaker::helpc::all_pages(toc)) { roadmaker::helpc::RenderOptions opts; opts.title = page.title; + opts.page_rel = page.rel_path; opts.guide_dir = guide; opts.img_out_dir = img_dir; const std::string html = roadmaker::helpc::render_page(read_file(guide / page.rel_path), opts); diff --git a/editor/src/app/actions.cpp b/editor/src/app/actions.cpp index f88591e..eab8c3c 100644 --- a/editor/src/app/actions.cpp +++ b/editor/src/app/actions.cpp @@ -532,6 +532,10 @@ Actions::Actions(QUndoStack& undo_stack, QObject* parent) : QObject(parent) { help_contents->setShortcuts(shortcuts::sequences(shortcuts::Id::Help)); help_contents->setToolTip(tr("Open the RoadMaker user guide (F1)")); + open_manual = new QAction(tr("Open &Manual in Browser"), this); + open_manual->setShortcuts(shortcuts::sequences(shortcuts::Id::OpenManual)); + open_manual->setToolTip(tr("Open the full illustrated manual in your web browser")); + about = new QAction(tr("&About RoadMaker"), this); about->setMenuRole(QAction::AboutRole); @@ -665,6 +669,8 @@ QAction* Actions::action(shortcuts::Id id) const { return viewport_hints; case Id::Help: return help_contents; + case Id::OpenManual: + return open_manual; case Id::kIdCount: break; // the sentinel names no action } diff --git a/editor/src/app/actions.hpp b/editor/src/app/actions.hpp index 8d1847b..c99ca7d 100644 --- a/editor/src/app/actions.hpp +++ b/editor/src/app/actions.hpp @@ -222,6 +222,10 @@ class Actions : public QObject { /// Opens the in-app user guide (Help menu, F1). QAction* help_contents = nullptr; + + /// Opens the packaged HTML manual in the system browser (Help menu). Unbound + /// — F1 belongs to the in-app viewer. + QAction* open_manual = nullptr; QAction* about = nullptr; }; diff --git a/editor/src/app/main_window.cpp b/editor/src/app/main_window.cpp index 410702d..527d0dd 100644 --- a/editor/src/app/main_window.cpp +++ b/editor/src/app/main_window.cpp @@ -79,8 +79,10 @@ #include "document/signal_phase_overlay.hpp" #include "document/signal_placement.hpp" #include "document/units.hpp" +#include "help/help_locator.hpp" #include "help/help_registry.hpp" #include "help/help_viewer.hpp" +#include "help/manual_locator.hpp" #include "panels/asset_import_dialog.hpp" #include "panels/diagnostics_panel.hpp" #include "panels/editor2d_host.hpp" @@ -1389,6 +1391,8 @@ void MainWindow::build_menus() { connect(actions_->help_contents, &QAction::triggered, this, [this] { show_help(help::context_page(tool_manager_.active_id(), help_context_dock())); }); + help_menu->addAction(actions_->open_manual); + connect(actions_->open_manual, &QAction::triggered, this, &MainWindow::open_manual); QAction* tour_action = help_menu->addAction(tr("&Guided Tour")); tour_action->setToolTip(tr("Replay the 5-step first-run tour")); connect(tour_action, &QAction::triggered, this, &MainWindow::start_tour); @@ -2854,6 +2858,37 @@ void MainWindow::show_help(const QString& slug) { help_viewer_->activateWindow(); } +void MainWindow::open_manual() { + // The whole decision is "did a manual ship with this build?" — help::manual_index() + // answers it and is unit-tested; everything here is the two consequences. + if (const std::optional<std::filesystem::path> index = help::manual_index()) { + if (QDesktopServices::openUrl(QUrl::fromLocalFile(QString::fromStdString(index->string())))) { + return; + } + QMessageBox::warning(this, + tr("Open Manual"), + tr("The manual is installed at %1, but no browser could be " + "launched to open it.") + .arg(QString::fromStdString(index->string()))); + return; + } + + // Not an error: bundling the manual is opt-in (ROADMAKER_BUNDLE_MANUAL) so a + // developer build never has one. Say where it is instead of failing. + auto* box = new QMessageBox(QMessageBox::Information, + tr("Manual Not Bundled"), + tr("This build does not include the offline manual — release " + "builds do.<br><br>The full guide is online at:" + "<br><a href=\"%1\">%1</a><br><br>" + "The in-app user guide is always available with F1.") + .arg(QLatin1String(help::kGithubUserGuideUrl)), + QMessageBox::Ok, + this); + box->setTextFormat(Qt::RichText); + box->setAttribute(Qt::WA_DeleteOnClose); + box->open(); // non-blocking: never stalls a headless run +} + void MainWindow::show_world_georeference() { if (world_georeference_.isNull()) { world_georeference_ = new WorldGeoreferenceWindow(document_, selection_, this); diff --git a/editor/src/app/main_window.hpp b/editor/src/app/main_window.hpp index e4567ec..e612549 100644 --- a/editor/src/app/main_window.hpp +++ b/editor/src/app/main_window.hpp @@ -264,6 +264,12 @@ class MainWindow : public QMainWindow { void show_world_georeference(); void show_help(const QString& slug = QStringLiteral("index")); + + /// Help ▸ Open Manual in Browser. Opens the packaged HTML manual in the system + /// browser (ADR-0009 rejects embedding a web view), or points at the online + /// docs when this build did not bundle one — the normal developer case, since + /// bundling is opt-in and needs Node. + void open_manual(); /// The objectName of the QDockWidget that owns the keyboard focus, or an /// empty string when focus is not inside a dock. F1 feeds this to /// help::context_page so the focused panel's page wins over the active tool. diff --git a/editor/src/app/shortcut_registry.cpp b/editor/src/app/shortcut_registry.cpp index ba71b66..3de6c0d 100644 --- a/editor/src/app/shortcut_registry.cpp +++ b/editor/src/app/shortcut_registry.cpp @@ -432,6 +432,13 @@ constexpr std::array kTable{ .description = "Open the user guide", .standard = QKeySequence::HelpContents, .documented = "F1"}, + // Menu-only and unbound (docs-s2, #346): no .primary and no .documented, so + // it renders nothing on the shortcuts page. F1 keeps the in-app viewer; this + // opens the packaged HTML manual in the system browser, and taking a second + // binding for it would spend a key on something the menu already offers. + Entry{.id = Id::OpenManual, + .category = "Help", + .description = "Open the full manual in a browser"}, }; /// PortableText so the page is platform-stable; the StandardKey rows still diff --git a/editor/src/app/shortcut_registry.hpp b/editor/src/app/shortcut_registry.hpp index c68b14f..2ade7bb 100644 --- a/editor/src/app/shortcut_registry.hpp +++ b/editor/src/app/shortcut_registry.hpp @@ -118,6 +118,7 @@ enum class Id { ViewportHints, // Help Help, + OpenManual, /// Count sentinel — always last. Iterating `[0, kIdCount)` is what the tests /// use to prove the table and the Id→QAction map cover every value. diff --git a/editor/src/help/help_browser.cpp b/editor/src/help/help_browser.cpp index 0fbd79a..e720f2d 100644 --- a/editor/src/help/help_browser.cpp +++ b/editor/src/help/help_browser.cpp @@ -18,6 +18,13 @@ #include <QDesktopServices> #include <QHelpEngineCore> +#include <QMessageBox> +#include <filesystem> +#include <optional> +#include <system_error> + +#include "help/help_locator.hpp" +#include "help/manual_locator.hpp" namespace roadmaker::editor::help { @@ -40,11 +47,52 @@ QVariant HelpBrowser::resource(int type, const QUrl& name) { return loadResource(type, name); } +bool HelpBrowser::open_manual_page(const QString& slug) { + const std::optional<std::filesystem::path> index = manual_index(); + if (index) { + const std::optional<std::filesystem::path> page = + manual_page_for(index->parent_path(), slug.toStdString()); + std::error_code ec; + if (page && std::filesystem::exists(*page, ec) && !ec) { + return QDesktopServices::openUrl(QUrl::fromLocalFile(QString::fromStdString(page->string()))); + } + // The manual is here but this page is not: still better to land the reader on + // its front page than to do nothing. + return QDesktopServices::openUrl(QUrl::fromLocalFile(QString::fromStdString(index->string()))); + } + return false; +} + void HelpBrowser::on_anchor_clicked(const QUrl& url) { if (url.scheme() == QLatin1String("http") || url.scheme() == QLatin1String("https")) { QDesktopServices::openUrl(url); return; } + + // A reference page's bridge into the full guide (helpc::kManualScheme). The + // manual is a sibling of this collection in the install tree, so the target is + // only knowable at runtime — which is why the compiler emits a scheme rather + // than a path. + if (url.scheme() == QLatin1String("rmmanual")) { + // QUrl parses `rmmanual:tutorials/x` as an opaque path, not a host. + const QString slug = url.path(); + if (!open_manual_page(slug)) { + // No bundled manual — the normal state of a developer build. Say where the + // guide is rather than failing silently or showing a raw error. + auto* box = new QMessageBox(QMessageBox::Information, + tr("Full Guide Not Bundled"), + tr("This build does not include the full HTML manual. " + "It is available online at:<br><a href=\"%1\">%1</a>") + .arg(QLatin1String(kGithubUserGuideUrl)), + QMessageBox::Ok, + this); + box->setTextFormat(Qt::RichText); + box->setAttribute(Qt::WA_DeleteOnClose); + box->open(); // non-blocking: never stalls a headless run + } + return; + } + setSource(url); } diff --git a/editor/src/help/help_browser.hpp b/editor/src/help/help_browser.hpp index 7861b3b..4a127f6 100644 --- a/editor/src/help/help_browser.hpp +++ b/editor/src/help/help_browser.hpp @@ -17,9 +17,12 @@ #pragma once // The rich-text pane of the help window. Resolves qthelp:// resources (pages, -// stylesheet, images) out of the QHelpEngine, and hands http(s) links to the -// system browser instead of trying to render them. +// stylesheet, images) out of the QHelpEngine, hands http(s) links to the system +// browser instead of trying to render them, and resolves the `rmmanual:` bridge +// links the help compiler emits (helpc::kManualScheme) against the packaged HTML +// manual — also in the system browser, per ADR-0009. +#include <QString> #include <QTextBrowser> #include <QUrl> #include <QVariant> @@ -37,6 +40,11 @@ class HelpBrowser : public QTextBrowser { /// Public seam over the protected loadResource override (tested directly). [[nodiscard]] QVariant resource(int type, const QUrl& name); + /// Open one page of the packaged manual in the system browser. False when no + /// manual shipped with this build, which is what selects the pointer at the + /// online docs. Public so the decision is testable without a click. + [[nodiscard]] bool open_manual_page(const QString& slug); + protected: QVariant loadResource(int type, const QUrl& name) override; diff --git a/editor/src/help/manual_locator.cpp b/editor/src/help/manual_locator.cpp new file mode 100644 index 0000000..b4651f2 --- /dev/null +++ b/editor/src/help/manual_locator.cpp @@ -0,0 +1,92 @@ +/* + * Copyright 2026 Robomous + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "help/manual_locator.hpp" + +#include <QCoreApplication> +#include <system_error> + +namespace roadmaker::editor::help { + +namespace { + +constexpr const char* kIndexName = "index.html"; + +} // namespace + +ManualPlatform this_platform() { +#if defined(Q_OS_MACOS) + return ManualPlatform::kMacOS; +#elif defined(Q_OS_WIN) + return ManualPlatform::kWindows; +#else + return ManualPlatform::kLinux; +#endif +} + +std::filesystem::path manual_dir_for(const std::filesystem::path& exe_dir, + ManualPlatform platform) { + switch (platform) { + case ManualPlatform::kMacOS: + // .../RoadMaker.app/Contents/MacOS -> .../Contents/Resources/manual + return (exe_dir / ".." / "Resources" / "manual").lexically_normal(); + case ManualPlatform::kLinux: + // The archive puts the executable in bin/ and shared data under share/. + return (exe_dir / ".." / "share" / "roadmaker" / "manual").lexically_normal(); + case ManualPlatform::kWindows: + break; + } + return (exe_dir / "manual").lexically_normal(); +} + +std::filesystem::path manual_dir() { + const std::filesystem::path exe_dir(QCoreApplication::applicationDirPath().toStdString()); + return manual_dir_for(exe_dir, this_platform()); +} + +std::optional<std::filesystem::path> manual_index() { + const std::filesystem::path index = manual_dir() / kIndexName; + std::error_code ec; + if (!std::filesystem::exists(index, ec) || ec) { + return std::nullopt; + } + return index; +} + +std::optional<std::filesystem::path> manual_page_for(const std::filesystem::path& manual_root, + const std::string& slug) { + if (slug.empty()) { + return std::nullopt; + } + // A backslash would be a directory separator on Windows only, so the same slug + // would mean two different things; reject rather than normalise. + if (slug.find('\\') != std::string::npos || slug.front() == '/') { + return std::nullopt; + } + + const std::filesystem::path relative = std::filesystem::path(slug).lexically_normal(); + for (const std::filesystem::path& part : relative) { + if (part == "..") { + return std::nullopt; + } + } + + std::filesystem::path page = manual_root / relative; + page += ".html"; + return page.lexically_normal(); +} + +} // namespace roadmaker::editor::help diff --git a/editor/src/help/manual_locator.hpp b/editor/src/help/manual_locator.hpp new file mode 100644 index 0000000..01bb7c3 --- /dev/null +++ b/editor/src/help/manual_locator.hpp @@ -0,0 +1,67 @@ +/* + * Copyright 2026 Robomous + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +// Where the packaged HTML manual lives (ADR-0009 / docs-s2), and what to do when +// it does not — which is the normal case for a developer build, since bundling +// it is opt-in (`ROADMAKER_BUNDLE_MANUAL`, default OFF) and needs Node. +// +// The layout question is deliberately a PURE function of an executable directory +// and a platform, so all three platforms' answers are checked by one headless +// test on whichever platform happens to be running it. `help_locator.hpp` is the +// same shape for the Qt Help collection. + +#include <cstdint> +#include <filesystem> +#include <optional> +#include <string> + +namespace roadmaker::editor::help { + +/// Install layouts the manual ships in. Named rather than `#ifdef`-ed so a test +/// can ask about a platform it is not running on. +enum class ManualPlatform : std::uint8_t { + kMacOS, ///< RoadMaker.app/Contents/Resources/manual + kLinux, ///< share/roadmaker/manual, with the executable in bin/ + kWindows, ///< manual/ beside the executable +}; + +/// The platform this build targets. +[[nodiscard]] ManualPlatform this_platform(); + +/// Directory the manual is installed in, for an executable in `exe_dir`. +/// Pure: no filesystem access, no Qt, no globals. +[[nodiscard]] std::filesystem::path manual_dir_for(const std::filesystem::path& exe_dir, + ManualPlatform platform); + +/// `manual_dir_for` against the running executable's directory. +[[nodiscard]] std::filesystem::path manual_dir(); + +/// The manual's entry page if the manual actually shipped, else nullopt. This is +/// the whole dev-build fallback decision: nullopt means "point at the web docs". +[[nodiscard]] std::optional<std::filesystem::path> manual_index(); + +/// The file a `rmmanual:<slug>` link resolves to inside `manual_root`. +/// +/// The local build uses Astro's `file` format, so `tutorials/getting-around` +/// is `tutorials/getting-around.html` — not a directory with an index. Pure, and +/// it REFUSES a slug that climbs out of the manual (`..`), because the slug +/// arrives from a generated document rather than from this code. +[[nodiscard]] std::optional<std::filesystem::path> +manual_page_for(const std::filesystem::path& manual_root, const std::string& slug); + +} // namespace roadmaker::editor::help diff --git a/editor/tests/CMakeLists.txt b/editor/tests/CMakeLists.txt index 026cf52..ae63808 100644 --- a/editor/tests/CMakeLists.txt +++ b/editor/tests/CMakeLists.txt @@ -98,6 +98,7 @@ add_executable(roadmaker_editor_tests test_help_registry.cpp test_help_style.cpp test_help_viewer.cpp + test_manual_bridge.cpp test_picking.cpp test_preview_session.cpp test_phase_panel.cpp diff --git a/editor/tests/test_manual_bridge.cpp b/editor/tests/test_manual_bridge.cpp new file mode 100644 index 0000000..cf4fe7d --- /dev/null +++ b/editor/tests/test_manual_bridge.cpp @@ -0,0 +1,252 @@ +/* + * Copyright 2026 Robomous + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// The packaged manual (ADR-0009 / docs-s2): where it installs on each platform, +// how a `rmmanual:` bridge link resolves inside it, what the help compiler emits +// for the bridge section, and the gate that every bridge target is a page that +// exists. + +#include <gtest/gtest.h> + +#include <QFile> +#include <QHelpEngineCore> +#include <QTemporaryDir> +#include <filesystem> +#include <fstream> +#include <sstream> +#include <string> +#include <vector> + +#include "help/help_browser.hpp" +#include "help/manual_locator.hpp" +#include "helpc/render.hpp" + +namespace roadmaker::editor { +namespace { + +namespace fs = std::filesystem; + +std::string read_file(const fs::path& path) { + std::ifstream file(path, std::ios::binary); + std::ostringstream buffer; + buffer << file.rdbuf(); + return buffer.str(); +} + +// ---------------------------------------------------------------- install layout + +// One test covers all three platforms because the resolver is a pure function of +// (exe dir, platform) — otherwise two thirds of this would only ever be checked +// by the release job, on a machine nobody is watching. +TEST(ManualLocator, EachPlatformResolvesItsInstallLayout) { + using help::ManualPlatform; + + EXPECT_EQ(help::manual_dir_for("/Apps/RoadMaker.app/Contents/MacOS", ManualPlatform::kMacOS), + fs::path("/Apps/RoadMaker.app/Contents/Resources/manual")); + EXPECT_EQ(help::manual_dir_for("/opt/roadmaker/bin", ManualPlatform::kLinux), + fs::path("/opt/roadmaker/share/roadmaker/manual")); + EXPECT_EQ(help::manual_dir_for("C:/Program Files/RoadMaker", ManualPlatform::kWindows), + fs::path("C:/Program Files/RoadMaker/manual")); +} + +TEST(ManualLocator, TheLayoutsAreDistinct) { + using help::ManualPlatform; + const fs::path exe = "/somewhere/bin"; + EXPECT_NE(help::manual_dir_for(exe, ManualPlatform::kMacOS), + help::manual_dir_for(exe, ManualPlatform::kLinux)); + EXPECT_NE(help::manual_dir_for(exe, ManualPlatform::kLinux), + help::manual_dir_for(exe, ManualPlatform::kWindows)); +} + +// --------------------------------------------------------------- slug resolution + +TEST(ManualLocator, ASlugResolvesToTheFileFormatPage) { + // The local build uses Astro's `file` format, so a page is `<slug>.html` and + // NOT `<slug>/index.html` — a directory URL does not open over file://. + const auto page = help::manual_page_for("/m/manual", "tutorials/getting-around"); + ASSERT_TRUE(page.has_value()); + EXPECT_EQ(*page, fs::path("/m/manual/tutorials/getting-around.html")); +} + +TEST(ManualLocator, ASlugThatClimbsOutOfTheManualIsRefused) { + // The slug arrives from a generated document, so it is input, not a constant. + EXPECT_FALSE(help::manual_page_for("/m/manual", "../../etc/passwd").has_value()); + EXPECT_FALSE(help::manual_page_for("/m/manual", "tutorials/../../secrets").has_value()); + EXPECT_FALSE(help::manual_page_for("/m/manual", "/etc/passwd").has_value()); + EXPECT_FALSE(help::manual_page_for("/m/manual", "").has_value()); + // A backslash means a separator on exactly one platform, so the same slug + // would resolve to two different files. Refuse rather than normalise. + EXPECT_FALSE(help::manual_page_for("/m/manual", "tutorials\\..\\..\\x").has_value()); +} + +TEST(ManualLocator, AnInnocentSlugWithADotIsStillAccepted) { + const auto page = help::manual_page_for("/m/manual", "reference/v1.2-notes"); + ASSERT_TRUE(page.has_value()); + EXPECT_EQ(*page, fs::path("/m/manual/reference/v1.2-notes.html")); +} + +// -------------------------------------------------------- the dev-build fallback + +TEST(ManualLocator, NoManualShippedWithThisTestBuild) { + // The premise the fallback test below depends on: bundling is opt-in and the + // test build never turns it on. If this ever fails, the next test is vacuous. + EXPECT_FALSE(help::manual_index().has_value()) + << "a manual appeared at " << help::manual_dir().string() + << ", so the fallback test no longer exercises the fallback"; +} + +TEST(ManualLocator, TheBridgeReportsFailureWhenNoManualIsBundled) { + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const fs::path stage = RM_HELP_STAGE_DIR; + const QString collection = dir.filePath(QStringLiteral("roadmaker.qhc")); + ASSERT_TRUE(QFile::copy(QString::fromStdString((stage / "roadmaker.qhc").string()), collection)); + ASSERT_TRUE(QFile::copy(QString::fromStdString((stage / "roadmaker.qch").string()), + dir.filePath(QStringLiteral("roadmaker.qch")))); + + QHelpEngineCore engine(collection); + ASSERT_TRUE(engine.setupData()) << engine.error().toStdString(); + help::HelpBrowser browser(engine); + + // False is what selects the "read it online" pointer instead of a dead click. + // Asserted rather than the message box, because the decision is the behaviour; + // launching a browser is not something a headless test may do. + EXPECT_FALSE(browser.open_manual_page(QStringLiteral("tutorials/getting-around"))); +} + +// ------------------------------------------------------------ the bridge section + +TEST(HelpBridge, TheRecognizedSectionYieldsAGuideRelativeSlug) { + const std::string page = R"(# Lane + +Body text with an ordinary link to [Lane Width](lane-width.md). + +## Full guide + +[Shaping lanes](../tutorials/shaping-lanes.md) — the whole cross-section pass. +)"; + const auto link = helpc::bridge_link(page, "reference/lane-profile.md"); + ASSERT_TRUE(link.has_value()); + EXPECT_EQ(link->text, "Shaping lanes"); + EXPECT_EQ(link->target, "../tutorials/shaping-lanes.md"); + EXPECT_EQ(link->slug, "tutorials/shaping-lanes"); + EXPECT_TRUE(link->anchor.empty()); +} + +TEST(HelpBridge, APageWithoutTheSectionHasNoBridge) { + const std::string page = "# Lane\n\n## See also\n\n[Lane Width](lane-width.md)\n"; + EXPECT_FALSE(helpc::bridge_link(page, "reference/lane-profile.md").has_value()); +} + +TEST(HelpBridge, TheHeadingMustBeAHeadingNotProse) { + const std::string page = "# Lane\n\nSee the ## Full guide [x](../tutorials/a.md) below\n"; + EXPECT_FALSE(helpc::bridge_link(page, "reference/lane-profile.md").has_value()); +} + +TEST(HelpBridge, TheSectionEndsAtTheNextHeading) { + const std::string page = R"(# Lane + +## Full guide + +Nothing links out of here. + +## See also + +[Lane Width](lane-width.md) +)"; + EXPECT_FALSE(helpc::bridge_link(page, "reference/lane-profile.md").has_value()); +} + +TEST(HelpBridge, RenderRewritesOnlyTheBridgeLink) { + // The same guide is linked twice: once in prose, once as the bridge. Only the + // second may become a manual link, which is why the renderer rewrites at the + // parser's offset rather than searching for the text. + const std::string page = R"(# Lane + +As covered in [Shaping lanes](../tutorials/shaping-lanes.md), lanes have widths. + +## Full guide + +[Shaping lanes](../tutorials/shaping-lanes.md) — the whole cross-section pass. +)"; + helpc::RenderOptions opts; + opts.title = "Lane"; + opts.page_rel = "reference/lane-profile.md"; + const std::string html = helpc::render_page(page, opts); + + EXPECT_NE(html.find("href=\"rmmanual:tutorials/shaping-lanes\""), std::string::npos) << html; + // The prose one keeps the pipeline's ordinary treatment (a repo URL). + EXPECT_NE(html.find("href=\"https://github.com/Robomous/RoadMaker/blob/main/"), std::string::npos) + << html; + EXPECT_EQ(html.find("href=\"rmmanual:tutorials/shaping-lanes\"", 0), + html.rfind("href=\"rmmanual:tutorials/shaping-lanes\"")) + << "exactly one link may be retargeted"; +} + +TEST(HelpBridge, APageWithNoBridgeRendersExactlyAsBefore) { + const std::string page = "# Lane\n\n[Lane Width](lane-width.md)\n"; + helpc::RenderOptions opts; + opts.title = "Lane"; + opts.page_rel = "reference/lane-profile.md"; + const std::string html = helpc::render_page(page, opts); + EXPECT_EQ(html.find("rmmanual:"), std::string::npos); + EXPECT_NE(html.find("href=\"lane-width.html\""), std::string::npos) << html; +} + +// -------------------------------------------------------------------- the gate + +/// Every reference page carrying a bridge section, paired with its target. +std::vector<std::pair<std::string, helpc::BridgeLink>> committed_bridges() { + std::vector<std::pair<std::string, helpc::BridgeLink>> found; + const fs::path reference = fs::path(RM_DOCS_DIR) / "user-guide" / "reference"; + for (const auto& entry : fs::directory_iterator(reference)) { + if (!entry.is_regular_file() || entry.path().extension() != ".md") { + continue; + } + const std::string rel = "reference/" + entry.path().filename().generic_string(); + if (auto link = helpc::bridge_link(read_file(entry.path()), rel)) { + found.emplace_back(rel, *link); + } + } + return found; +} + +TEST(HelpBridge, EveryBridgeTargetIsAPageThatExists) { + const auto bridges = committed_bridges(); + ASSERT_FALSE(bridges.empty()) << "no reference page carries a '" << helpc::kBridgeHeading + << "' section — the convention would be unenforced"; + + const fs::path guide = fs::path(RM_DOCS_DIR) / "user-guide"; + for (const auto& [page, link] : bridges) { + const fs::path target = guide / (link.slug + ".md"); + EXPECT_TRUE(fs::exists(target)) + << page << " bridges to '" << link.target << "' (slug '" << link.slug + << "'), which is not a committed guide page. Renaming a guide must update " + "every reference page that bridges to it."; + } +} + +TEST(HelpBridge, EveryBridgeTargetIsInTheGuidesTier) { + // A bridge points at the site-only tier. One aimed at a reference page would + // send the reader to the browser for something F1 already had. + for (const auto& [page, link] : committed_bridges()) { + EXPECT_TRUE(link.slug.starts_with("tutorials/") || link.slug.starts_with("guides/")) + << page << " bridges to '" << link.slug << "', which is not in the guides tier"; + } +} + +} // namespace +} // namespace roadmaker::editor