diff --git a/.gitattributes b/.gitattributes index 7cc55a72..7baa5b29 100644 --- a/.gitattributes +++ b/.gitattributes @@ -16,3 +16,10 @@ tests/esmini/*.xosc text eol=lf # reads in binary, so without this the Windows runner's autocrlf checkout would # fail the gate on line endings alone. core/tests/data/junction/straight_floor_golden.txt text eol=lf + +# Fourth instance (#563): the site's Starlight palette is what +# help_style::starlight_css() emits, compared byte for byte by +# StarlightCssMatchesCommittedThemeCss. The generator writes '\n' and the test +# reads in binary, so without this the Windows runner's autocrlf checkout fails +# the gate on line endings alone. +docs-site/src/styles/theme.css text eol=lf diff --git a/.github/workflows/docs-site.yml b/.github/workflows/docs-site.yml index 878db713..0ceedcae 100644 --- a/.github/workflows/docs-site.yml +++ b/.github/workflows/docs-site.yml @@ -66,13 +66,6 @@ jobs: - name: Internal links run: npm run check:links - # Report only, on purpose: link rot in a third party's URL is not a reason - # a contributor's merge cannot happen. `continue-on-error` as well as the - # script's own exit 0, so a crash in the reporter cannot block either. - - name: Outbound link report (never blocks) - continue-on-error: true - run: npm run report:external - - uses: actions/upload-artifact@v4 with: name: docs-site diff --git a/.github/workflows/external-links.yml b/.github/workflows/external-links.yml new file mode 100644 index 00000000..0d498434 --- /dev/null +++ b/.github/workflows/external-links.yml @@ -0,0 +1,52 @@ +name: external links + +# Reports outbound links that have rotted. Replaces docs-site's hand-rolled +# report-external-links.mjs (#563), which was 125 lines of fetch, concurrency +# and timeout handling to produce a report that always exited 0 — all of it +# already in the lychee action ci.yml runs `--offline` on the PR path. +# +# WHY A SEPARATE WORKFLOW AND NOT A JOB IN ci.yml: adding `schedule` there would +# arm all fourteen of its jobs, and gating each one is a worse diff than this +# file. It also keeps the network-touching check off the PR path entirely. +# +# DELIBERATELY NON-BLOCKING (`fail: false`). A third party rearranging their +# site, or rate-limiting a runner, is not a reason a contributor's merge cannot +# happen — and a gate that fails for reasons nobody in this repository can fix +# is a gate people learn to ignore, which costs more than the link rot it was +# meant to catch. Read the summary; it is a report, not a status to satisfy. + +on: + schedule: + - cron: '17 6 * * 1' # Mondays, 06:17 UTC + workflow_dispatch: + +permissions: + contents: read + +jobs: + external-links: + name: outbound link report (never blocks) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + # No --offline, so external URLs ARE fetched here — the one place they + # are. The PR-path `docs` job in ci.yml keeps --offline and stays + # deterministic. + - name: Check outbound links + uses: lycheeverse/lychee-action@v2 + with: + args: >- + --no-progress + --max-concurrency 8 + --timeout 15 + --accept 200,206,429 + "docs/**/*.md" + "*.md" + fail: false + output: lychee-report.md + + - name: Publish the report to the run summary + if: always() + run: cat lychee-report.md >> "$GITHUB_STEP_SUMMARY" || true diff --git a/CHANGELOG.md b/CHANGELOG.md index a7fa4b06..e1898589 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1483,6 +1483,22 @@ Current version on `main`: **0.0.1**. `append_fragment` was NOT shared despite the identical name and shape — the OpenSCENARIO writer passes `pugi::parse_fragment` and the OpenDRIVE one does not, so that pair is a real divergence and stays per-format. +- **The documentation site stops parsing C++ source, and outbound links move to + lychee** ([#563](https://github.com/Robomous/RoadMaker/issues/563)). + `docs-site/scripts/theme-css.mjs` located `graphite_amber()` inside + `theme.cpp` with `indexOf` and regexed the `QColor(0x..)` literals back out — + a JavaScript parser of C++ source that any reformat of `theme.cpp` breaks. The + palette is now emitted by `help_style::starlight_css()`, beside the in-app + `help.css` generator it copies, and `docs-site/src/styles/theme.css` is + committed and byte-gated by a gtest. Every colour is unchanged. The site build + still needs nothing but Node (ADR-0009): the gate runs in the C++ matrix and + the site just reads a committed file. + + `docs-site/scripts/report-external-links.mjs` is gone too — 125 lines of + hand-rolled `fetch`, concurrency and timeout handling for a report that always + exited 0, when lychee was already a CI dependency. A weekly non-blocking + `external-links` workflow replaces it; it stays off the PR path, so a third + party's outage still cannot block a merge. - **New roads default to the urban-with-sidewalks template** ([#355](https://github.com/Robomous/RoadMaker/issues/355)): the Create Road tool, its toolbar dropdown, and the Library fallback now start from diff --git a/docs-site/.gitignore b/docs-site/.gitignore index d071d0e4..0a702da8 100644 --- a/docs-site/.gitignore +++ b/docs-site/.gitignore @@ -1,7 +1,11 @@ # Adapted content is generated by scripts/adapt.mjs — never committed, never -# hand-edited. Same for the theme CSS, which is derived from theme.cpp. +# hand-edited. +# +# src/styles/theme.css is NOT here: it is generated by +# help_style::starlight_css() and COMMITTED, byte-gated by the +# StarlightCssMatchesCommittedThemeCss gtest (#563), so the Node-only site build +# never needs a C++ toolchain to produce it. src/content/docs/ -src/styles/theme.css public/ node_modules/ dist/ diff --git a/docs-site/README.md b/docs-site/README.md index e435a9e5..96d8ac91 100644 --- a/docs-site/README.md +++ b/docs-site/README.md @@ -5,17 +5,30 @@ Astro Starlight project that publishes `docs/user-guide/` as a static site ## The content here is GENERATED — never hand-edit it -`src/content/docs/` and `src/styles/theme.css` are build outputs and are -gitignored. The sources are: +`src/content/docs/` is a build output and is gitignored. `src/styles/theme.css` +is generated too, but it is **committed** — see below. The sources are: -| Output | Source | Generator | -|---|---|---| -| `src/content/docs/**` | `docs/user-guide/**` | `scripts/adapt.mjs` | -| `src/styles/theme.css` | `editor/src/theme/theme.cpp` | `scripts/theme-css.mjs` | +| Output | Source | Generator | Committed? | +|---|---|---|---| +| `src/content/docs/**` | `docs/user-guide/**` | `scripts/adapt.mjs` | no, gitignored | +| `src/styles/theme.css` | `editor/src/theme/theme.cpp` | `help_style::starlight_css()` (C++) | **yes**, byte-gated | Editing a generated file silently loses the edit on the next build. Change the source instead. +**Why the palette is generated by C++ and committed** (#563). It used to be +produced by `scripts/theme-css.mjs`, which located `graphite_amber()` inside +`theme.cpp` with `indexOf` and regexed the `QColor(0x..)` literals back out — a +JavaScript parser of C++ source, which any reformat of `theme.cpp` breaks. It is +now emitted by `help_style::starlight_css()` beside the in-app `help.css` +generator, and the committed file is compared byte-for-byte against it by the +`HelpStyle.StarlightCssMatchesCommittedThemeCss` gtest. + +That gate runs in the C++ CI matrix, so **the site build still needs nothing but +Node** (ADR-0009) — it just reads a file that is already in the tree. To change +the palette: edit `theme.cpp`, run the editor tests, and paste the new file the +failing gate prints. + ## The two pipelines share one manifest `docs/user-guide/index.md` is the ordering manifest for **both** the in-app Qt diff --git a/docs-site/package.json b/docs-site/package.json index c0a9a308..800052c2 100644 --- a/docs-site/package.json +++ b/docs-site/package.json @@ -10,18 +10,16 @@ }, "scripts": { "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": "npm run adapt && node scripts/check-f1-coverage.mjs && astro build", "build:local": "node scripts/build-local.mjs", "build:web": "node scripts/build-web.mjs", "assemble": "node scripts/assemble.mjs", - "dev": "npm run theme && npm run adapt && astro dev", + "dev": "npm run adapt && astro dev", "licenses": "node scripts/licenses.mjs", "check": "node scripts/check-f1-coverage.mjs", "check:local": "node scripts/check-local-build.mjs", "test": "node --test \"test/**/*.test.mjs\"", - "check:links": "node scripts/check-links.mjs", - "report:external": "node scripts/report-external-links.mjs" + "check:links": "node scripts/check-links.mjs" }, "dependencies": { "@astrojs/starlight": "0.36.0", diff --git a/docs-site/scripts/build-local.mjs b/docs-site/scripts/build-local.mjs index f8c2e4f6..dbd65d2c 100644 --- a/docs-site/scripts/build-local.mjs +++ b/docs-site/scripts/build-local.mjs @@ -47,7 +47,6 @@ function step(label, command, args) { 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']); diff --git a/docs-site/scripts/build-web.mjs b/docs-site/scripts/build-web.mjs index dfaf2d7d..eae2d9ed 100644 --- a/docs-site/scripts/build-web.mjs +++ b/docs-site/scripts/build-web.mjs @@ -56,7 +56,6 @@ function step(label, args) { } } -step('theme tokens', [join(here, 'theme-css.mjs')]); step('adapt docs/user-guide', [join(here, 'adapt.mjs')]); step('F1 coverage', [join(here, 'check-f1-coverage.mjs')]); step('astro build', [join(root, 'node_modules', 'astro', 'astro.js'), 'build']); diff --git a/docs-site/scripts/check-links.mjs b/docs-site/scripts/check-links.mjs index bc27e9e4..31428e22 100644 --- a/docs-site/scripts/check-links.mjs +++ b/docs-site/scripts/check-links.mjs @@ -21,8 +21,8 @@ // version directory that was published months ago and is still being served. // // EXTERNAL LINKS ARE NOT FETCHED HERE. Link rot in somebody else's URL is not a -// reason a merge cannot happen; report-external-links.mjs reports on those -// without failing anything. +// reason a merge cannot happen; the weekly `external-links` job in ci.yml +// reports on those without failing anything. import { readFileSync, existsSync, statSync } from 'node:fs'; import { join, posix, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; diff --git a/docs-site/scripts/report-external-links.mjs b/docs-site/scripts/report-external-links.mjs deleted file mode 100644 index 00080a99..00000000 --- a/docs-site/scripts/report-external-links.mjs +++ /dev/null @@ -1,125 +0,0 @@ -// 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. - -// Reports outbound links that look dead (ADR-0009 / docs-s4). -// -// DELIBERATELY NON-BLOCKING. It always exits 0, even when every request fails. -// A third party rearranging their site, or rate-limiting a CI runner, is not a -// reason a contributor's merge cannot happen — and a gate that fails for -// reasons nobody in this repository can fix is a gate people learn to ignore, -// which costs more than the link rot it was meant to catch. -// -// The output is a report to read, not a status to satisfy. -import { readFileSync, existsSync, statSync } from 'node:fs'; -import { join, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { htmlFiles } from './relativize.mjs'; - -const TIMEOUT_MS = 15_000; -const CONCURRENCY = 8; - -/** Every distinct external URL in a built tree, with the pages that use it. */ -export function collectExternalLinks(dir) { - const found = new Map(); - for (const pageRel of htmlFiles(dir)) { - const html = readFileSync(join(dir, pageRel), 'utf8'); - for (const match of html.matchAll(/\bhref="(https?:\/\/[^"]+)"/g)) { - const url = match[1].split('#')[0]; - if (!found.has(url)) found.set(url, new Set()); - found.get(url).add(pageRel); - } - } - return found; -} - -/** HEAD, falling back to GET — a fair number of hosts refuse HEAD outright. */ -async function probe(url) { - for (const method of ['HEAD', 'GET']) { - try { - const response = await fetch(url, { - method, - redirect: 'follow', - signal: AbortSignal.timeout(TIMEOUT_MS), - headers: { 'user-agent': 'RoadMaker-docs-link-report' }, - }); - if (response.ok) return { ok: true, status: response.status }; - if (method === 'GET') return { ok: false, status: String(response.status) }; - } catch (error) { - if (method === 'GET') return { ok: false, status: error.name ?? 'error' }; - } - } - return { ok: false, status: 'unknown' }; -} - -async function main() { - const here = dirname(fileURLToPath(import.meta.url)); - const dir = process.argv[2] ?? join(here, '..', 'dist'); - if (!existsSync(dir) || !statSync(dir).isDirectory()) { - console.log(`external-links: nothing at ${dir} — skipping the report`); - return; - } - - const links = [...collectExternalLinks(dir).entries()]; - console.log(`external-links: probing ${links.length} distinct outbound URL(s)`); - - const suspect = []; - let index = 0; - const workers = Array.from({ length: Math.min(CONCURRENCY, links.length) }, async () => { - while (index < links.length) { - const [url, pages] = links[index++]; - const result = await probe(url); - if (!result.ok) suspect.push({ url, status: result.status, pages: [...pages] }); - } - }); - await Promise.all(workers); - - if (suspect.length === 0) { - console.log('external-links: every outbound link answered'); - return; - } - - suspect.sort((a, b) => a.url.localeCompare(b.url)); - console.log(`\nexternal-links: ${suspect.length} did not answer (REPORT ONLY — nothing fails):`); - for (const entry of suspect) { - console.log(` [${entry.status}] ${entry.url}`); - for (const page of entry.pages.slice(0, 3)) console.log(` on ${page}`); - if (entry.pages.length > 3) console.log(` …and ${entry.pages.length - 3} more pages`); - } - - const summary = process.env.GITHUB_STEP_SUMMARY; - if (summary) { - const lines = [ - '### Outbound links that did not answer', - '', - 'Report only — this does not fail the build. A third party rearranging their', - 'site is not something a merge should wait on.', - '', - '| Status | URL | Pages |', - '|---|---|---|', - ...suspect.map((e) => `| \`${e.status}\` | ${e.url} | ${e.pages.length} |`), - '', - ]; - const { appendFileSync } = await import('node:fs'); - appendFileSync(summary, lines.join('\n')); - } -} - -// Only when run as a script — importing this module (the tests do, for -// collectExternalLinks) must not fire a few dozen network requests. -if (process.argv[1] && join(process.argv[1]) === fileURLToPath(import.meta.url)) { - await main(); - // Explicit: this script never fails a build. - process.exitCode = 0; -} diff --git a/docs-site/scripts/theme-css.mjs b/docs-site/scripts/theme-css.mjs deleted file mode 100644 index ef97e1c8..00000000 --- a/docs-site/scripts/theme-css.mjs +++ /dev/null @@ -1,86 +0,0 @@ -// 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. - -// Single export path: theme::graphite_amber() -> Starlight CSS custom properties. -// -// WHY A PARSER AND NOT A C++ GENERATOR. The obvious alternative is a generator -// alongside help_style::css(). It was rejected because the site build must not -// require a C++ toolchain: ADR-0009 puts site CI on Linux-only Node runners, -// independent of the C++/CMake matrix, and a generated-artifact dependency -// would couple them. Parsing the initializer keeps theme.cpp the single source -// of truth while leaving the two builds unrelated. -// -// The in-app sheet (editor/resources/help/help.css) is NOT touched: QTextBrowser -// does not support CSS custom properties, so it stays committed pre-substituted -// with flattened hex and byte-gated by test_help_style.cpp. -import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const here = dirname(fileURLToPath(import.meta.url)); -const repo = join(here, '..', '..'); -const source = join(repo, 'editor', 'src', 'theme', 'theme.cpp'); -const out = join(here, '..', 'src', 'styles', 'theme.css'); - -const text = readFileSync(source, 'utf8'); - -// The initializer block of graphite_amber(), up to its closing `};`. -const start = text.indexOf('const Theme& graphite_amber()'); -if (start < 0) throw new Error(`graphite_amber() not found in ${source}`); -const block = text.slice(start, text.indexOf('};', start)); - -/** `.field = QColor(0xAA, 0xBB, 0xCC),` -> { field: '#aabbcc' } */ -const colors = new Map(); -const pattern = /\.(\w+)\s*=\s*QColor\(\s*0x([0-9a-fA-F]{2})\s*,\s*0x([0-9a-fA-F]{2})\s*,\s*0x([0-9a-fA-F]{2})\s*\)/g; -for (const m of block.matchAll(pattern)) { - colors.set(m[1], `#${m[2]}${m[3]}${m[4]}`.toLowerCase()); -} - -// The tokens the site actually consumes. Named explicitly rather than dumping -// every field, so a rename in theme.cpp fails loudly here instead of silently -// dropping a colour from the site. -const REQUIRED = [ - 'bg0', 'bg1', 'bg2', 'border', 'border_strong', - 'text_primary', 'text_secondary', 'accent', -]; -const missing = REQUIRED.filter((name) => !colors.has(name)); -if (missing.length > 0) { - throw new Error( - `theme.cpp no longer defines: ${missing.join(', ')}. ` + - 'Update REQUIRED in scripts/theme-css.mjs to match the renamed fields.', - ); -} - -const get = (name) => colors.get(name); -const css = `/* GENERATED by docs-site/scripts/theme-css.mjs from - editor/src/theme/theme.cpp — theme::graphite_amber(). - Do not edit: run \`npm run theme\`. */ -:root[data-theme='dark'] { - --sl-color-accent-low: ${get('bg2')}; - --sl-color-accent: ${get('accent')}; - --sl-color-accent-high: ${get('text_primary')}; - --sl-color-white: ${get('text_primary')}; - --sl-color-gray-1: ${get('text_primary')}; - --sl-color-gray-2: ${get('text_secondary')}; - --sl-color-gray-3: ${get('border_strong')}; - --sl-color-gray-4: ${get('border')}; - --sl-color-gray-5: ${get('bg2')}; - --sl-color-gray-6: ${get('bg1')}; - --sl-color-black: ${get('bg0')}; -} -`; - -mkdirSync(dirname(out), { recursive: true }); -writeFileSync(out, css); -console.log(`theme.css written from ${REQUIRED.length} tokens in theme.cpp`); diff --git a/docs-site/src/styles/theme.css b/docs-site/src/styles/theme.css new file mode 100644 index 00000000..4f31c265 --- /dev/null +++ b/docs-site/src/styles/theme.css @@ -0,0 +1,16 @@ +/* GENERATED from theme::graphite_amber() by help_style::starlight_css() + (editor/src/theme/help_style.cpp). Do not edit: the + StarlightCssMatchesCommittedThemeCss gate regenerates it. */ +:root[data-theme='dark'] { + --sl-color-accent-low: #22252a; + --sl-color-accent: #f5a623; + --sl-color-accent-high: #e8eaed; + --sl-color-white: #e8eaed; + --sl-color-gray-1: #e8eaed; + --sl-color-gray-2: #a7adb5; + --sl-color-gray-3: #434951; + --sl-color-gray-4: #2e3238; + --sl-color-gray-5: #22252a; + --sl-color-gray-6: #1b1d21; + --sl-color-black: #131417; +} diff --git a/docs-site/test/check-links.test.mjs b/docs-site/test/check-links.test.mjs index 7a0d92ba..cfe3a160 100644 --- a/docs-site/test/check-links.test.mjs +++ b/docs-site/test/check-links.test.mjs @@ -20,7 +20,6 @@ import { join } from 'node:path'; import { checkRoot, checkPublishRoot, findRoots, resolveRef } from '../scripts/check-links.mjs'; import { assemble } from '../scripts/assemble.mjs'; -import { collectExternalLinks } from '../scripts/report-external-links.mjs'; /** A built version directory whose links are all sound. */ function makeVersion(label, extraBody = '') { @@ -161,14 +160,3 @@ test('a publish root whose redirect disagrees with the manifest is reported', () rmSync(dev, { recursive: true, force: true }); } }); - -test('external links are collected with the pages that use them', () => { - const dir = makeVersion('dev'); - try { - const found = collectExternalLinks(dir); - assert.ok(found.has('https://example.invalid/x')); - assert.deepEqual([...found.get('https://example.invalid/x')], ['reference/junction.html']); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); diff --git a/editor/src/theme/help_style.cpp b/editor/src/theme/help_style.cpp index dae7eae1..318660d6 100644 --- a/editor/src/theme/help_style.cpp +++ b/editor/src/theme/help_style.cpp @@ -81,4 +81,36 @@ td { color: @text; border: 1px solid @border; padding: 4px 8px; } return sheet; } +QString starlight_css(const Theme& t) { + // Starlight reads its palette from these ten custom properties; the mapping + // from RoadMaker's tokens to them is the whole file. `QColor::name()` gives + // lower-case #rrggbb, which is what the previous generator emitted too. + return QStringLiteral( + R"css(/* GENERATED from theme::graphite_amber() by help_style::starlight_css() + (editor/src/theme/help_style.cpp). Do not edit: the + StarlightCssMatchesCommittedThemeCss gate regenerates it. */ +:root[data-theme='dark'] { + --sl-color-accent-low: %1; + --sl-color-accent: %2; + --sl-color-accent-high: %3; + --sl-color-white: %3; + --sl-color-gray-1: %3; + --sl-color-gray-2: %4; + --sl-color-gray-3: %5; + --sl-color-gray-4: %6; + --sl-color-gray-5: %1; + --sl-color-gray-6: %7; + --sl-color-black: %8; +} +)css") + .arg(t.bg2.name(), + t.accent.name(), + t.text_primary.name(), + t.text_secondary.name(), + t.border_strong.name(), + t.border.name(), + t.bg1.name(), + t.bg0.name()); +} + } // namespace roadmaker::editor::help_style diff --git a/editor/src/theme/help_style.hpp b/editor/src/theme/help_style.hpp index a68eb841..3772b692 100644 --- a/editor/src/theme/help_style.hpp +++ b/editor/src/theme/help_style.hpp @@ -33,4 +33,17 @@ namespace roadmaker::editor::help_style { /// header comment (so the committed file is byte-for-byte this string). [[nodiscard]] QString css(const Theme& theme); +/// The documentation site's Starlight custom-property block for `theme`, same +/// contract as `css()` above: the committed docs-site/src/styles/theme.css is +/// byte-for-byte this string, and a gtest keeps them in step (#563). +/// +/// It lives here rather than in the site build because the site must not need a +/// C++ toolchain (ADR-0009 puts site CI on Linux-only Node runners). The +/// previous arrangement had docs-site/scripts/theme-css.mjs LOCATE +/// `graphite_amber()` in theme.cpp with `indexOf` and regex the `QColor(0x..)` +/// literals back out — a JavaScript parser of C++ source that any reformat +/// breaks. Generating here and byte-gating the committed file is the same shape +/// help.css has always used, and it keeps the two builds unrelated. +[[nodiscard]] QString starlight_css(const Theme& theme); + } // namespace roadmaker::editor::help_style diff --git a/editor/tests/CMakeLists.txt b/editor/tests/CMakeLists.txt index ae63808d..c655aec1 100644 --- a/editor/tests/CMakeLists.txt +++ b/editor/tests/CMakeLists.txt @@ -152,6 +152,10 @@ target_compile_definitions(roadmaker_editor_tests # The help-style gate compares the committed help.css to the # generator (test_help_style.cpp). RM_EDITOR_RESOURCES_DIR="${CMAKE_SOURCE_DIR}/editor/resources" + # Same gate, second file: docs-site/src/styles/theme.css is generated + # by help_style::starlight_css() and committed (#563), and it lives + # outside editor/ — so the gate needs the repo root. + RM_REPO_ROOT_DIR="${CMAKE_SOURCE_DIR}" # Where the build stages the compiled .qch/.qhc collection # (test_help_collection.cpp / test_help_viewer.cpp). RM_HELP_STAGE_DIR="${CMAKE_BINARY_DIR}/help") diff --git a/editor/tests/test_help_style.cpp b/editor/tests/test_help_style.cpp index c1f7f878..e2b8641d 100644 --- a/editor/tests/test_help_style.cpp +++ b/editor/tests/test_help_style.cpp @@ -59,5 +59,42 @@ TEST(HelpStyle, CssUsesDefaultThemeAccent) { << "the default accent " << t.accent.name().toStdString() << " must drive link colour"; } +// The same tie for the documentation site's palette (#563). It replaced +// docs-site/scripts/theme-css.mjs, which parsed theme.cpp with `indexOf` and a +// `QColor(0x..)` regex — this gate is what lets the site read a committed file +// instead, without putting a C++ toolchain on the Node-only site build. +// +// docs-site/src/styles/theme.css is pinned `text eol=lf` in .gitattributes: the +// generator emits '\n' and this reads in binary, so without the pin the Windows +// runner's autocrlf checkout fails on line endings alone — invisible on macOS +// and Linux, and this repository has been bitten by it three times already. +TEST(HelpStyle, StarlightCssMatchesCommittedThemeCss) { + const std::filesystem::path path = + std::filesystem::path(RM_REPO_ROOT_DIR) / "docs-site" / "src" / "styles" / "theme.css"; + std::ifstream file(path, std::ios::binary); + ASSERT_TRUE(file.is_open()) << "missing " << path.string(); + std::stringstream buffer; + buffer << file.rdbuf(); + const QString committed = QString::fromStdString(buffer.str()); + + // graphite_amber, not default_theme(): the site ships one palette, and it is + // named explicitly so renaming the default cannot silently repaint the site. + const QString generated = help_style::starlight_css(theme::graphite_amber()); + EXPECT_EQ(committed, generated) + << "docs-site/src/styles/theme.css is out of date with " + "help_style::starlight_css().\nReplace the committed file with:\n\n" + << generated.toStdString(); +} + +TEST(HelpStyle, StarlightCssSubstitutesEveryPlaceholder) { + // A leftover %1..%9 means an .arg() was dropped and the site would ship the + // placeholder as a colour value. + const QString css = help_style::starlight_css(theme::graphite_amber()); + for (int i = 1; i <= 9; ++i) { + EXPECT_FALSE(css.contains(QStringLiteral("%%%1").arg(i))) + << "placeholder %" << i << " survived substitution"; + } +} + } // namespace } // namespace roadmaker::editor