Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/docs-site.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ jobs:
- name: F1 coverage
run: npm run check

- name: Script tests
run: npm test

- name: Build
run: npx astro build

Expand All @@ -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
34 changes: 32 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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

Expand Down
38 changes: 38 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<slug>.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:<slug>`, 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
Expand Down
6 changes: 6 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions docs-site/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
51 changes: 50 additions & 1 deletion docs-site/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<slug>.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:<slug>`, 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
Expand Down
19 changes: 19 additions & 0 deletions docs-site/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,38 @@ 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
// (docs/standards/dependencies.md), so the passthrough service is used and
// `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' } },
Expand Down
5 changes: 4 additions & 1 deletion docs-site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
47 changes: 41 additions & 6 deletions docs-site/scripts/adapt.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];

Expand Down Expand Up @@ -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})`);
Expand All @@ -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})`);
}
Expand All @@ -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.
Expand All @@ -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)`,
);
Loading
Loading