From 415d5def3d48511a86c5e1a85bf61487794ca180 Mon Sep 17 00:00:00 2001
From: Samuel Williams
Date: Mon, 28 Sep 2026 10:46:36 +1300
Subject: [PATCH 1/5] Complete Ruby coverage and exercise generated
documentation in browsers
Signed-off-by: Samuel Williams
---
.github/workflows/documentation.yaml | 26 ++-
.gitignore | 4 +
bake/utopia/project/agent/context.rb | 2 +-
config/covered.rb | 9 ++
fixtures/utopia/project/build_site.rb | 14 ++
fixtures/utopia/project/site.rb | 50 ++++++
lib/utopia/project/base.rb | 4 +-
lib/utopia/project/guide.rb | 24 +--
package-lock.json | 48 ++++++
package.json | 6 +
pages/guides/controller.rb | 2 +
pages/index.xnode | 4 +-
pages/reference/controller.rb | 2 +-
playwright.config.cjs | 24 +++
releases.md | 3 +
test/browser/readme.md | 21 +++
test/browser/server.cjs | 21 +++
test/browser/site.spec.cjs | 110 +++++++++++++
.../project/.fixtures/site/example.gemspec | 11 ++
.../.fixtures/site/guides/empty/readme.md | 1 +
.../site/guides/getting-started/readme.md | 35 +++++
.../project/.fixtures/site/guides/links.yaml | 6 +
.../site/guides/source-example/example.rb | 6 +
.../project/.fixtures/site/lib/example.rb | 36 +++++
.../.fixtures/site/lib/example/client.md | 7 +
test/utopia/project/.fixtures/site/readme.md | 11 ++
.../utopia/project/.fixtures/site/releases.md | 17 ++
test/utopia/project/base.rb | 45 ++++++
test/utopia/project/changes_document.rb | 20 +++
test/utopia/project/document.rb | 33 ++++
test/utopia/project/guides.rb | 53 +++++++
test/utopia/project/rendering.rb | 122 +++++++++++++++
test/utopia/project/tasks.rb | 148 ++++++++++++++++++
33 files changed, 895 insertions(+), 30 deletions(-)
create mode 100644 config/covered.rb
create mode 100644 fixtures/utopia/project/build_site.rb
create mode 100644 fixtures/utopia/project/site.rb
create mode 100644 playwright.config.cjs
create mode 100644 test/browser/readme.md
create mode 100644 test/browser/server.cjs
create mode 100644 test/browser/site.spec.cjs
create mode 100644 test/utopia/project/.fixtures/site/example.gemspec
create mode 100644 test/utopia/project/.fixtures/site/guides/empty/readme.md
create mode 100644 test/utopia/project/.fixtures/site/guides/getting-started/readme.md
create mode 100644 test/utopia/project/.fixtures/site/guides/links.yaml
create mode 100644 test/utopia/project/.fixtures/site/guides/source-example/example.rb
create mode 100644 test/utopia/project/.fixtures/site/lib/example.rb
create mode 100644 test/utopia/project/.fixtures/site/lib/example/client.md
create mode 100644 test/utopia/project/.fixtures/site/readme.md
create mode 100644 test/utopia/project/.fixtures/site/releases.md
create mode 100644 test/utopia/project/base.rb
create mode 100644 test/utopia/project/guides.rb
create mode 100644 test/utopia/project/rendering.rb
create mode 100644 test/utopia/project/tasks.rb
diff --git a/.github/workflows/documentation.yaml b/.github/workflows/documentation.yaml
index 0a76c8d..c3d68b9 100644
--- a/.github/workflows/documentation.yaml
+++ b/.github/workflows/documentation.yaml
@@ -41,9 +41,9 @@ jobs:
key: pagefind-binary-v1-${{ runner.os }}-${{ runner.arch }}-${{ env.PAGEFIND_REVISION }}
- uses: actions/setup-node@v7
- if: steps.pagefind-cache.outputs.cache-hit != 'true'
with:
node-version: 24
+ cache: npm
- name: Install Pagefind Rust toolchain
if: steps.pagefind-cache.outputs.cache-hit != 'true'
@@ -63,7 +63,6 @@ jobs:
bundler-cache: true
- name: Installing packages
- if: steps.pagefind-cache.outputs.cache-hit != 'true'
run: sudo apt-get install wget
- name: Build Pagefind fork
@@ -76,6 +75,29 @@ jobs:
env:
PAGEFIND_BINARY_PATH: ${{ github.workspace }}/.pagefind/target/release/pagefind
run: bundle exec bake utopia:project:static --force no
+
+ - name: Install browser test dependencies
+ run: |
+ npm ci
+ npx playwright install --with-deps chromium
+
+ - name: Build browser fixture
+ timeout-minutes: 5
+ env:
+ PAGEFIND_BINARY_PATH: ${{ github.workspace }}/.pagefind/target/release/pagefind
+ run: bundle exec ruby fixtures/utopia/project/build_site.rb
+
+ - name: Test generated documentation in Chromium
+ timeout-minutes: 5
+ run: npm run test:browser
+
+ - name: Upload browser failure diagnostics
+ if: failure()
+ uses: actions/upload-artifact@v7
+ with:
+ name: browser-test-results
+ path: test-results
+ if-no-files-found: ignore
- name: Upload documentation artifact
uses: actions/upload-pages-artifact@v5
diff --git a/.gitignore b/.gitignore
index 8410daf..382f9a1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,7 @@
/node_modules
/.github/workflows/test-external.yaml
+
+/test/browser/.site
+/test-results
+/playwright-report
diff --git a/bake/utopia/project/agent/context.rb b/bake/utopia/project/agent/context.rb
index 38df190..ed820b6 100644
--- a/bake/utopia/project/agent/context.rb
+++ b/bake/utopia/project/agent/context.rb
@@ -24,7 +24,7 @@ def update
files << {
"path" => guide.name + ".md",
"title" => guide.title,
- "description" => guide.description.to_markdown.chomp,
+ "description" => guide.description&.to_markdown&.chomp || "",
}
end
diff --git a/config/covered.rb b/config/covered.rb
new file mode 100644
index 0000000..80b7161
--- /dev/null
+++ b/config/covered.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2026, by Samuel Williams.
+
+# Include exported tasks and controllers even when no test loads them.
+def include_patterns
+ super + ["bake/**/*.rb", "pages/**/*.rb"]
+end
diff --git a/fixtures/utopia/project/build_site.rb b/fixtures/utopia/project/build_site.rb
new file mode 100644
index 0000000..3a17344
--- /dev/null
+++ b/fixtures/utopia/project/build_site.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2026, by Samuel Williams.
+
+require "bake/context"
+require "fileutils"
+
+root = File.expand_path("../../../test/utopia/project/.fixtures/site", __dir__)
+output = File.expand_path("../../../test/browser/.site", __dir__)
+
+Dir.chdir(root) do
+ Bake::Context.load(root)["utopia:project:static"].call(output_path: output)
+end
diff --git a/fixtures/utopia/project/site.rb b/fixtures/utopia/project/site.rb
new file mode 100644
index 0000000..cae501b
--- /dev/null
+++ b/fixtures/utopia/project/site.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2026, by Samuel Williams.
+
+require "tmpdir"
+require "fileutils"
+require "utopia/project"
+require_relative "../../../template/config/environment"
+require "sus/fixtures/protocol/http/middleware_context"
+
+module Utopia
+ module Project
+ module SiteContext
+ include Sus::Fixtures::Protocol::HTTP::MiddlewareContext
+
+ SITE = File.expand_path("../../../test/utopia/project/.fixtures/site", __dir__)
+
+ def around(&block)
+ previous = Thread.current.thread_variable_get(Base.name)
+ Dir.mktmpdir("utopia-project-test") do |root|
+ @root = root
+ FileUtils.cp_r("#{SITE}/.", root)
+ Base.instance = base
+ super(&block)
+ end
+ ensure
+ Base.instance = previous
+ end
+
+ def base
+ @base ||= Base.new(@root).tap do |base|
+ base.update(Dir.glob("#{@root}/lib/**/*.rb"))
+ end
+ end
+
+ def middleware
+ @middleware ||= Utopia::Application.build do |builder|
+ Project.call(builder, @root)
+ end
+ end
+
+ def write(path, content)
+ path = File.join(@root, path)
+ FileUtils.mkdir_p(File.dirname(path))
+ File.write(path, content)
+ end
+ end
+ end
+end
diff --git a/lib/utopia/project/base.rb b/lib/utopia/project/base.rb
index 25de00f..6507d5e 100644
--- a/lib/utopia/project/base.rb
+++ b/lib/utopia/project/base.rb
@@ -107,12 +107,12 @@ def lookup(path)
# @parameter definition [Decode::Definition] The definition to load documentation for.
# @returns [Document | Nil] The supplemental document, if it exists.
def document_for(definition)
- document_path = File.join("lib", definition.lexical_path.map{|_| _.to_s.downcase}) + ".md"
+ document_path = File.join(@root, "lib", definition.lexical_path.map{|_| _.to_s.downcase}) + ".md"
if File.exist?(document_path)
document = self.document(File.read(document_path), definition)
- if document.first_child.type == :header
+ if document.first_child&.type == :header
document.first_child.delete
end
diff --git a/lib/utopia/project/guide.rb b/lib/utopia/project/guide.rb
index 5f15e3b..c32221e 100644
--- a/lib/utopia/project/guide.rb
+++ b/lib/utopia/project/guide.rb
@@ -48,28 +48,8 @@ def order
# @parameter other [Guide] The other guide to compare.
# @returns [Integer] The comparison result.
def <=> other
- if order = self.order
- if other_order = other.order
- if order < other_order
- return -1
- elsif order > other_order
- return 1
- end
- else
- # If we have order, but the other doesn't, we come first:
- return -1
- end
- end
-
- if name = self.name
- if other_name = other.name
- return name <=> other_name
- else
- return -1
- end
- end
-
- return 0
+ [self.order ? 0 : 1, self.order || 0, self.name] <=>
+ [other.order ? 0 : 1, other.order || 0, other.name]
end
README = "readme.md"
diff --git a/package-lock.json b/package-lock.json
index 998f42a..829b6a6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7,6 +7,9 @@
"dependencies": {
"@socketry/syntax": "^0.6.2",
"mermaid": "^11.16.1"
+ },
+ "devDependencies": {
+ "@playwright/test": "1.63.0"
}
},
"node_modules/@antfu/install-pkg": {
@@ -74,6 +77,22 @@
"@chevrotain/types": "~11.1.2"
}
},
+ "node_modules/@playwright/test": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
+ "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.63.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/@socketry/syntax": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/@socketry/syntax/-/syntax-0.6.2.tgz",
@@ -1158,6 +1177,35 @@
"pathe": "^2.0.3"
}
},
+ "node_modules/playwright": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
+ "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.63.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
+ "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/points-on-curve": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz",
diff --git a/package.json b/package.json
index e5ba456..07566d8 100644
--- a/package.json
+++ b/package.json
@@ -20,5 +20,11 @@
}
}
}
+ },
+ "devDependencies": {
+ "@playwright/test": "1.63.0"
+ },
+ "scripts": {
+ "test:browser": "playwright test"
}
}
diff --git a/pages/guides/controller.rb b/pages/guides/controller.rb
index fe12ffa..759b9f9 100644
--- a/pages/guides/controller.rb
+++ b/pages/guides/controller.rb
@@ -12,5 +12,7 @@
guide.name == name
end
+ respond! Utopia::Response[404] unless @guide
+
path.components = ["show"]
end
diff --git a/pages/index.xnode b/pages/index.xnode
index 77c1e71..5bf1264 100644
--- a/pages/index.xnode
+++ b/pages/index.xnode
@@ -3,7 +3,7 @@
if document = self[:document]
child = document.first_child
- if child.type == :header
+ if child&.type == :header
header = child
child.delete
title = header.first_child
@@ -35,4 +35,4 @@
-
\ No newline at end of file
+
diff --git a/pages/reference/controller.rb b/pages/reference/controller.rb
index 2cf0012..e4df623 100644
--- a/pages/reference/controller.rb
+++ b/pages/reference/controller.rb
@@ -13,7 +13,7 @@
@node, @symbol = @base.lookup(@lexical_path)
unless @symbol
- fail! :not_found
+ respond! Utopia::Response[404]
end
path.components = ["show"]
diff --git a/playwright.config.cjs b/playwright.config.cjs
new file mode 100644
index 0000000..6cf8de5
--- /dev/null
+++ b/playwright.config.cjs
@@ -0,0 +1,24 @@
+const {defineConfig} = require('@playwright/test');
+
+module.exports = defineConfig({
+ testDir: './test/browser',
+ fullyParallel: true,
+ forbidOnly: !!process.env.CI,
+ retries: 0,
+ workers: 2,
+ use: {
+ baseURL: 'http://127.0.0.1:9294/project/',
+ trace: 'retain-on-failure',
+ screenshot: 'only-on-failure',
+ launchOptions: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH ? {executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH} : {},
+ },
+ projects: ['light', 'dark'].flatMap(colorScheme => [390, 1440].map(width => ({
+ name: `${colorScheme}-${width}`,
+ use: {browserName: 'chromium', colorScheme, viewport: {width, height: 900}},
+ }))),
+ webServer: {
+ command: 'node test/browser/server.cjs',
+ url: 'http://127.0.0.1:9294/project/index.html',
+ reuseExistingServer: false,
+ },
+});
diff --git a/releases.md b/releases.md
index efda0b7..074b480 100644
--- a/releases.md
+++ b/releases.md
@@ -2,6 +2,9 @@
## Unreleased
+ - Fix guide ordering, supplemental documentation paths, and missing guide/reference responses.
+ - Handle empty READMEs and guides without descriptions when rendering pages and generating agent context.
+ - Cover all Ruby, task, and rendered template lines, and exercise the generated site in Chromium at mobile and desktop widths in light and dark mode.
- Add padding to documentation table cells and allow tables to scroll whenever they exceed the available width.
- Scale table, inline code, badge, navigation link, and disclosure spacing with the local font size.
diff --git a/test/browser/readme.md b/test/browser/readme.md
new file mode 100644
index 0000000..a321733
--- /dev/null
+++ b/test/browser/readme.md
@@ -0,0 +1,21 @@
+# Browser Tests
+
+These tests use the exported fixture project in `test/utopia/project/.fixtures/site`. They exercise navigation, search, diagrams, syntax highlighting, keyboard disclosures, and table layout at mobile and desktop widths in light and dark mode. The static server mounts the site at `/project/` to check GitHub Pages subpath handling.
+
+Install the bundle with the maintenance group enabled, then install the browser dependencies:
+
+``` sh
+npm ci
+npx playwright install chromium
+```
+
+Build the Pagefind fork using the revision and setup steps in `.github/workflows/documentation.yaml`. Set `PAGEFIND_BINARY_PATH` to its executable, then build the fixture and run the tests:
+
+``` sh
+bundle exec ruby fixtures/utopia/project/build_site.rb
+npm run test:browser
+```
+
+Alternatively, set `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH` to an existing Chromium executable. Failures save screenshots and Playwright traces in `test-results/`.
+
+CI runs these checks in the documentation workflow, using the same Pagefind build as the published documentation. Browser checks do not contribute to the Ruby line coverage percentage.
diff --git a/test/browser/server.cjs b/test/browser/server.cjs
new file mode 100644
index 0000000..d1446f3
--- /dev/null
+++ b/test/browser/server.cjs
@@ -0,0 +1,21 @@
+const http = require('node:http');
+const fs = require('node:fs/promises');
+const path = require('node:path');
+
+const root = path.resolve(__dirname, '.site');
+const types = {'.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.wasm': 'application/wasm', '.svg': 'image/svg+xml'};
+
+http.createServer(async (request, response) => {
+ try {
+ const pathname = decodeURIComponent(new URL(request.url, 'http://localhost').pathname);
+ if (!pathname.startsWith('/project/')) throw new Error('Outside project');
+ const filename = path.resolve(root, pathname.slice('/project/'.length) || 'index.html');
+ if (!filename.startsWith(root + path.sep)) throw new Error('Outside fixture');
+ const content = await fs.readFile(filename);
+ response.writeHead(200, {'content-type': types[path.extname(filename)] || 'application/octet-stream'});
+ response.end(content);
+ } catch {
+ response.writeHead(404);
+ response.end('Not found');
+ }
+}).listen(9294, '127.0.0.1');
diff --git a/test/browser/site.spec.cjs b/test/browser/site.spec.cjs
new file mode 100644
index 0000000..4db1592
--- /dev/null
+++ b/test/browser/site.spec.cjs
@@ -0,0 +1,110 @@
+const {test, expect} = require('@playwright/test');
+const guide = 'guides/getting-started/index.html';
+
+test('renders diagrams, highlighted code and local links under a project subpath', async ({page, request}) => {
+ const errors = [];
+ page.on('pageerror', error => errors.push(error.message));
+ await page.goto(guide);
+ await expect(page.locator('.mermaid svg')).toBeVisible();
+ await expect(page.locator('syntax-code')).not.toHaveCount(0);
+ await expect(page.locator('#configuration-2')).toHaveCount(1);
+ const links = await page.locator('a[href]').evaluateAll(links => links.map(link => link.href).filter(href => href.startsWith(location.origin)));
+ for (const href of new Set(links)) {
+ expect(new URL(href).pathname).toMatch(/^\/project\//);
+ expect((await request.get(href)).ok(), href).toBeTruthy();
+ }
+ expect(errors).toEqual([]);
+});
+
+test('preserves deep links and tracks sidebar navigation', async ({page}, testInfo) => {
+ await page.goto(guide + '#configuration-2');
+ await expect(page.locator('a.self')).not.toHaveCount(0);
+ await expect(page).toHaveURL(/#configuration-2$/);
+ if (testInfo.project.use.viewport.width < 1024) {
+ await expect(page.locator('.sidebar')).toBeHidden();
+ return;
+ }
+ const link = page.locator('.sidebar a[href$="#configuration"]');
+ await link.click();
+ await expect(link).toBeFocused();
+ await expect(link).toHaveClass(/active/);
+ await expect(page).toHaveURL(/#configuration$/);
+ await page.locator('#deployment').evaluate(element => element.scrollIntoView());
+ await expect(page.locator('.sidebar a[href$="#deployment"]')).toHaveClass(/active/);
+ await expect(page).toHaveURL(/#deployment$/);
+});
+
+test('contains wide tables and scales spacing with table text', async ({page}) => {
+ await page.goto(guide);
+ const table = page.locator('table');
+ const padding = [];
+ for (const size of ['80%', '125%']) {
+ padding.push(await table.evaluate((table, size) => {
+ table.style.fontSize = size;
+ const style = getComputedStyle(table.querySelector('td'));
+ return {font: parseFloat(style.fontSize), top: parseFloat(style.paddingTop), left: parseFloat(style.paddingLeft)};
+ }, size));
+ }
+ expect(padding[1].top / padding[0].top).toBeCloseTo(padding[1].font / padding[0].font);
+ expect(padding[1].left / padding[0].left).toBeCloseTo(padding[1].font / padding[0].font);
+ const backgrounds = await table.locator('tbody tr').first().locator('td').evaluateAll(cells => cells.map(cell => getComputedStyle(cell).backgroundColor));
+ expect(backgrounds[1]).not.toEqual(backgrounds[0]);
+ expect(backgrounds[2]).toEqual(backgrounds[0]);
+ await table.locator('td').first().evaluate(cell => cell.textContent = 'LONG_CONFIGURATION_NAME_'.repeat(30));
+ const scrolling = await table.evaluate(table => {
+ table.scrollLeft = 100;
+ return {offset: table.scrollLeft, pageWidth: document.documentElement.scrollWidth, viewport: innerWidth};
+ });
+ expect(scrolling.offset).toBe(100);
+ expect(scrolling.pageWidth).toBe(scrolling.viewport);
+});
+
+test('opens example disclosures with the keyboard without shifting their summaries', async ({page}) => {
+ await page.goto('reference/Example/Client/index.html');
+ const details = page.locator('details').first();
+ await details.evaluate(element => element.style.fontSize = '125%');
+ const summary = details.locator('summary');
+ await summary.focus();
+ const before = await summary.boundingBox();
+ await summary.press('Enter');
+ await expect(details).toHaveAttribute('open', '');
+ await expect(details.locator('pre')).toBeVisible();
+ const after = await summary.boundingBox();
+ expect(after.x).toBeCloseTo(before.x);
+ expect(after.width).toBeCloseTo(before.width);
+ await summary.press('Space');
+ await expect(details).not.toHaveAttribute('open');
+});
+
+test('hides unavailable search without breaking navigation', async ({page}) => {
+ await page.route('**/pagefind-component-ui.js', route => route.abort());
+ await page.goto(guide);
+ await expect(page.locator('a.self')).not.toHaveCount(0);
+ await expect(page.locator('pagefind-modal-trigger')).toBeHidden();
+ await page.locator('.section-links a').filter({hasText: 'Reference'}).click();
+ await expect(page).toHaveURL(/reference\/index.html$/);
+});
+
+test('searches the generated index and follows results under the project subpath', async ({page}) => {
+ const errors = [];
+ page.on('pageerror', error => errors.push(error.message));
+ await page.goto('index.html');
+ const trigger = page.locator('pagefind-modal-trigger button');
+ await trigger.click();
+ const dialog = page.getByRole('dialog');
+ await expect(dialog).toBeVisible();
+ const input = dialog.locator('input');
+ await expect(input).toBeFocused();
+ await input.fill('preview');
+ const result = dialog.locator('pagefind-results a[href*="/guides/getting-started/"]').first();
+ await expect(result).toBeVisible();
+ await expect(result).toHaveAttribute('href', /^\/project\/guides\/getting-started\//);
+ await result.click();
+ await expect(page).toHaveURL(/\/project\/guides\/getting-started\/index.html/);
+ await expect(page.locator('h1')).toHaveText('Getting Started');
+ await page.locator('pagefind-modal-trigger button').click();
+ await page.keyboard.press('Escape');
+ await expect(page.getByRole('dialog')).toBeHidden();
+ await expect(page.locator('pagefind-modal-trigger button')).toBeFocused();
+ expect(errors).toEqual([]);
+});
diff --git a/test/utopia/project/.fixtures/site/example.gemspec b/test/utopia/project/.fixtures/site/example.gemspec
new file mode 100644
index 0000000..f5255ac
--- /dev/null
+++ b/test/utopia/project/.fixtures/site/example.gemspec
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+Gem::Specification.new do |spec|
+ spec.name = "example"
+ spec.version = "1.0.0"
+ spec.summary = "Example documentation."
+ spec.authors = ["Example"]
+ spec.homepage = "https://example.com/project/"
+ spec.metadata["documentation_uri"] = "https://example.com/project/"
+ spec.metadata["source_code_uri"] = "https://github.com/example/project"
+end
diff --git a/test/utopia/project/.fixtures/site/guides/empty/readme.md b/test/utopia/project/.fixtures/site/guides/empty/readme.md
new file mode 100644
index 0000000..847c3af
--- /dev/null
+++ b/test/utopia/project/.fixtures/site/guides/empty/readme.md
@@ -0,0 +1 @@
+# Empty Guide
diff --git a/test/utopia/project/.fixtures/site/guides/getting-started/readme.md b/test/utopia/project/.fixtures/site/guides/getting-started/readme.md
new file mode 100644
index 0000000..68532c6
--- /dev/null
+++ b/test/utopia/project/.fixtures/site/guides/getting-started/readme.md
@@ -0,0 +1,35 @@
+# Getting Started
+
+This guide explains how to preview the example project.
+
+## Installation
+
+Install the project before running the examples.
+
+~~~ ruby
+Example::Client.new
+~~~
+
+## Usage
+
+| Task | Command | Result |
+| --- | --- | --- |
+| Preview | `bake utopia:project:serve` | Serve documentation while editing guides. |
+| Build | `bake utopia:project:static` | Generate documentation for static hosting. |
+
+~~~ mermaid
+flowchart LR
+ Source --> Documentation
+~~~
+
+### Configuration
+
+See ruby:`Example::Client#call`.
+
+## Deployment
+
+Publish the generated documentation.
+
+### Configuration
+
+Check links after deployment.
diff --git a/test/utopia/project/.fixtures/site/guides/links.yaml b/test/utopia/project/.fixtures/site/guides/links.yaml
new file mode 100644
index 0000000..0af65c7
--- /dev/null
+++ b/test/utopia/project/.fixtures/site/guides/links.yaml
@@ -0,0 +1,6 @@
+getting-started:
+ order: 1
+source-example:
+ order: 2
+empty:
+ order: 3
diff --git a/test/utopia/project/.fixtures/site/guides/source-example/example.rb b/test/utopia/project/.fixtures/site/guides/source-example/example.rb
new file mode 100644
index 0000000..41aba5d
--- /dev/null
+++ b/test/utopia/project/.fixtures/site/guides/source-example/example.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+# This example explains how to call the client.
+Example::Client.new.call("hello")
+
+puts "done"
diff --git a/test/utopia/project/.fixtures/site/lib/example.rb b/test/utopia/project/.fixtures/site/lib/example.rb
new file mode 100644
index 0000000..f392d4b
--- /dev/null
+++ b/test/utopia/project/.fixtures/site/lib/example.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+# Example project namespace.
+module Example
+ # Adds instrumentation.
+ module Logging
+ # Record an event.
+ def log
+ end
+ end
+
+ # Adds metrics.
+ module Metrics
+ # Count requests.
+ def count
+ end
+ end
+
+ # Sends requests.
+ class Client
+ include Logging
+ include Metrics
+
+ # Send a request.
+ # @parameter message [String] The request text.
+ # @returns [String] The response.
+ # @asynchronous
+ # @example Send a message
+ # Client.new.call("hello")
+ # @example
+ # Client.new.call("goodbye")
+ def call(message)
+ message
+ end
+ end
+end
diff --git a/test/utopia/project/.fixtures/site/lib/example/client.md b/test/utopia/project/.fixtures/site/lib/example/client.md
new file mode 100644
index 0000000..a5c47ed
--- /dev/null
+++ b/test/utopia/project/.fixtures/site/lib/example/client.md
@@ -0,0 +1,7 @@
+# Client Details
+
+Supplemental documentation for the client.
+
+## Requests
+
+Call ruby:`Example::Client#call` to send a request.
diff --git a/test/utopia/project/.fixtures/site/readme.md b/test/utopia/project/.fixtures/site/readme.md
new file mode 100644
index 0000000..abfb1e5
--- /dev/null
+++ b/test/utopia/project/.fixtures/site/readme.md
@@ -0,0 +1,11 @@
+# Example Project
+
+Documentation for a small project.
+
+## Usage
+
+This section is generated.
+
+## Releases
+
+This section is generated.
diff --git a/test/utopia/project/.fixtures/site/releases.md b/test/utopia/project/.fixtures/site/releases.md
new file mode 100644
index 0000000..c54a91b
--- /dev/null
+++ b/test/utopia/project/.fixtures/site/releases.md
@@ -0,0 +1,17 @@
+# Changes
+
+## v1.1.0
+
+Improved documentation.
+
+### Code Examples
+
+Examples now have titles.
+
+#### Details
+
+Nested release sections stay with their parent.
+
+## v1.0.0
+
+First release.
diff --git a/test/utopia/project/base.rb b/test/utopia/project/base.rb
new file mode 100644
index 0000000..4577fae
--- /dev/null
+++ b/test/utopia/project/base.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2026, by Samuel Williams.
+
+require "utopia/project/site"
+
+describe Utopia::Project::Base do
+ include Utopia::Project::SiteContext
+
+ it "loads supplemental documentation from the project root" do
+ _, definition = base.lookup(%w[Example Client])
+ document = base.document_for(definition)
+
+ expect(document).not.to be_nil
+ expect(document.to_html).to be(:include?, "Supplemental documentation")
+ expect(document.to_html).not.to be(:include?, "Client Details")
+ end
+
+ it "handles empty supplemental documents" do
+ write("lib/example/client.md", "")
+ _, definition = base.lookup(%w[Example Client])
+
+ expect(base.document_for(definition).to_html.to_s).to be == ""
+ end
+
+ it "accepts absent and enumerable documentation" do
+ expect(base.document(nil)).to be_nil
+ expect(base.format(nil)).to be_nil
+ expect(base.document(["First paragraph.", "", "Second paragraph."]).to_html).to be(:include?, "Second paragraph.
")
+ end
+
+ it "gives alternative definitions distinct identifiers" do
+ _, definition = base.lookup(%w[Example Client])
+ expect(base.id_for(definition, "alternate")).to be == "Example::Client-alternate"
+ end
+
+ it "uses source metadata and falls back to the homepage or no source link" do
+ expect(base.source_code_uri).to be == "https://github.com/example/project"
+ base.gemspec.metadata.delete("source_code_uri")
+ expect(base.source_code_uri).to be == "https://example.com/project/"
+ base.gemspec.homepage = nil
+ expect(base.source_code_uri).to be_nil
+ end
+end
diff --git a/test/utopia/project/changes_document.rb b/test/utopia/project/changes_document.rb
index b964537..491c8f5 100644
--- a/test/utopia/project/changes_document.rb
+++ b/test/utopia/project/changes_document.rb
@@ -21,3 +21,23 @@
expect(names).to be(:include?, "v0.28.0")
end
end
+
+describe Utopia::Project::ReleasesDocument do
+ it "finds releases and keeps nested change sections within their release" do
+ document = subject.new("# Changes\n\n## v2.0\n\nNew release.\n\n### New Feature\n\n#### Details\n\nText.\n\n## v1.0\n\nOld release.\n")
+ release = document.latest_release
+
+ expect(release.name).to be == "v2.0"
+ expect(release.notes.to_markdown).to be == "New release.\n"
+ expect(release.changes.map(&:to_markdown)).to be == ["New Feature"]
+ expect(release.changes.map(&:id)).to be == ["new-feature"]
+ expect(document.release("missing")).to be_nil
+ expect(document.releases.map(&:name)).to be == ["v2.0", "v1.0"]
+ end
+
+ it "handles empty release notes" do
+ document = subject.new("# Changes")
+ expect(document.latest_release).to be_nil
+ expect(document.navigation.to_html.to_s).to be == ""
+ end
+end
diff --git a/test/utopia/project/document.rb b/test/utopia/project/document.rb
index c7ca758..2890f73 100644
--- a/test/utopia/project/document.rb
+++ b/test/utopia/project/document.rb
@@ -134,3 +134,36 @@
end
end
end
+
+describe Utopia::Project::Document do
+ it "renders Mermaid source safely and keeps ordinary fenced code" do
+ document = subject.new("~~~ mermaid\nflowchart LR\n A[\"\"] --> B\n~~~\n\n~~~ ruby\nputs 42\n~~~\n")
+ html = document.to_html.to_s
+ expect(html).to be(:include?, 'class="mermaid"')
+ expect(html).to be(:include?, "<text>")
+ expect(html).to be(:include?, 'class="language-ruby"')
+ end
+
+ it "builds escaped linked code inside a paragraph" do
+ document = subject.new("")
+ code = document.code_node("foo < bar", "ruby")
+ link = document.link_node("Example", "/example", code)
+ document.root.append_child(document.paragraph_node(link))
+
+ expect(document.to_html.to_s).to be == 'foo < bar
' + "\n"
+ end
+
+ it "resolves a reference that consumes the entire text node" do
+ base = Utopia::Project::Base.new
+ document = subject.new("{ruby Missing}", base)
+ expect(document.to_html.to_s).to be == 'Missing
' + "\n"
+ end
+
+ it "replaces nested sections without removing the following peer section" do
+ document = subject.new("## Usage\n\nOld content.\n\n### Example\n\nNested content.\n\n## License\n\nKeep this.\n")
+ document.replace_section("Usage", children: true) do |header|
+ header.insert_after(document.paragraph_node(document.text_node("New content.")))
+ end
+ expect(document.to_markdown).to be == "## Usage\n\nNew content.\n\n## License\n\nKeep this.\n"
+ end
+end
diff --git a/test/utopia/project/guides.rb b/test/utopia/project/guides.rb
new file mode 100644
index 0000000..64d8bae
--- /dev/null
+++ b/test/utopia/project/guides.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2026, by Samuel Williams.
+
+require "utopia/project/site"
+
+describe Utopia::Project::Guides do
+ include Utopia::Project::SiteContext
+
+ it "sorts explicit priorities before names, including ties and unspecified priorities" do
+ guides = [
+ ["alpha", {}], ["zebra", {order: 1}], ["beta", {order: 1}],
+ ["gamma", {order: 2}], ["delta", {}]
+ ].map do |name, metadata|
+ Utopia::Project::Guide.new(base, File.join(@root, "guides", name), metadata)
+ end
+
+ expect(guides.sort.map(&:name)).to be == ["beta", "zebra", "gamma", "alpha", "delta"]
+ expect(guides.reverse.sort.map(&:name)).to be == ["beta", "zebra", "gamma", "alpha", "delta"]
+ end
+
+ it "finds guides and handles navigation boundaries" do
+ guides = base.guides
+ first, middle, last = guides.to_a
+
+ expect(guides["getting-started"]).to be_equal(first)
+ expect(guides["missing"]).to be_nil
+ expect(guides.related(first)).to be == [nil, middle]
+ expect(guides.related(middle)).to be == [first, last]
+ expect(guides.related(last)).to be == [middle, nil]
+ unknown = Utopia::Project::Guide.new(base, File.join(@root, "missing"), {})
+ expect(guides.related(unknown)).to be == [nil, nil]
+ end
+
+ it "extracts source documentation for a guide without a README" do
+ guide = base.guides["source-example"]
+
+ expect(guide.readme?).to be == false
+ expect(guide.title).to be == "Source Example"
+ expect(guide.documentation.text.join).to be(:include?, "This example explains")
+ expect(guide.sources.map{|source| File.basename(source.path)}).to be == ["example.rb"]
+ expect(guide.navigation.any?).to be == false
+ end
+
+ it "allows guides without an introductory paragraph or source documentation" do
+ guide = base.guides["empty"]
+
+ expect(guide.title).to be == "Empty Guide"
+ expect(guide.description).to be_nil
+ expect(guide.documentation).to be_nil
+ end
+end
diff --git a/test/utopia/project/rendering.rb b/test/utopia/project/rendering.rb
new file mode 100644
index 0000000..95318fa
--- /dev/null
+++ b/test/utopia/project/rendering.rb
@@ -0,0 +1,122 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2026, by Samuel Williams.
+
+require "utopia/project/site"
+
+describe "Project pages" do
+ include Utopia::Project::SiteContext
+
+ it "renders source examples and guides without descriptions" do
+ response = client.get("/guides/source-example/index")
+ expect(response.status).to be == 200
+ expect(response.read).to be(:include?, "This example explains how to call the client.")
+ body = client.get("/index").read
+ expect(body).to be(:include?, "No description.")
+ expect(body).to be(:include?, "This example explains how to call the client.")
+ end
+
+ it "renders examples, pragmas, multiple relationships and supplemental documentation" do
+ response = client.get("/reference/Example/Client/index")
+ body = response.read
+
+ expect(response.status).to be == 200
+ expect(body).to be(:include?, "Supplemental documentation for the client.")
+ expect(body).to be(:include?, "Example: Send a message")
+ expect(body).to be(:include?, "Example.
")
+ expect(body).to be(:include?, 'class="pragma asynchronous"')
+ expect(body).to be(:include?, "; ")
+ expect(body).to be(:include?, "/reference/Example/Logging/index")
+ expect(body).to be(:include?, "/reference/Example/Metrics/index")
+ end
+
+ it "returns 404 for an unknown reference or guide" do
+ ["/reference/Missing/index", "/guides/missing/index"].each do |path|
+ response = client.get(path)
+ expect(response.status).to be == 404
+ expect(response.read).to be(:include?, "File Not Found")
+ end
+ end
+
+ it "renders release navigation and the missing releases fallback" do
+ body = client.get("/releases/index").read
+ expect(body).to be(:include?, 'href="#v1.1.0"')
+ expect(body).to be(:include?, "Improved documentation.")
+ File.unlink(File.join(@root, "releases.md"))
+ expect(client.get("/releases/index").read).to be(:include?, "This project does not have a")
+ end
+
+ it "renders fallback content without a README" do
+ File.unlink(File.join(@root, "readme.md"))
+ body = client.get("/index").read
+ expect(body).to be(:include?, "This project does not have a")
+ end
+
+ ["Introductory paragraph.", "# *Formatted title*", ""].each do |markdown|
+ with "README #{markdown.inspect}" do
+ it "renders a fallback heading" do
+ write("readme.md", markdown)
+ response = client.get("/index")
+ expect(response.status).to be == 200
+ expect(response.read).to be(:include?, "Project
")
+ end
+ end
+ end
+
+ ["svg", "png"].each do |extension|
+ with "#{extension} title image" do
+ it "renders a logo and page title" do
+ write("readme.md", "# \n\nIntroduction.")
+ body = client.get("/index").read
+ expect(body).to be(:include?, "Project Logo")
+ expect(body).to be(:include?, "logo.#{extension}")
+ end
+ end
+ end
+
+ it "renders the exception document" do
+ expect(client.get("/errors/exception").read).to be(:include?, "something didn't quite work out")
+ end
+
+ it "renders discussion settings when configured" do
+ key = "UTOPIA_PROJECT_GISCUS_REPO"
+ previous = ENV[key]
+ begin
+ ENV[key] = "example/project"
+ expect(client.get("/reference/Example/Client/index").read).to be(:include?, 'data-repo="example/project"')
+ ensure
+ previous ? ENV[key] = previous : ENV.delete(key)
+ end
+ end
+end
+
+describe "Application configuration" do
+ include Utopia::Project::SiteContext
+
+ it "serves healthy requests with production exception middleware" do
+ mock(UTOPIA) do |wrapper|
+ wrapper.replace(:production?){true}
+ end
+
+ response = client.get("/index")
+ expect(response.status).to be == 200
+ expect(response.read).to be(:include?, "Example Project")
+ end
+
+ it "serves documentation with localization enabled" do
+ @middleware = Utopia::Application.build do |builder|
+ Utopia::Project.call(builder, @root, locales: ["en", "ja"])
+ end
+
+ response = client.get("/index", {"accept-language" => "ja"})
+ expect(response.status).to be == 200
+ expect(response.read).to be(:include?, "Example Project")
+ end
+
+ it "lists guides with and without descriptions" do
+ response = client.get("/guides/index")
+ expect(response.status).to be == 200
+ expect(response.read).to be(:include?, "Source Example")
+ end
+end
diff --git a/test/utopia/project/tasks.rb b/test/utopia/project/tasks.rb
new file mode 100644
index 0000000..9f4109f
--- /dev/null
+++ b/test/utopia/project/tasks.rb
@@ -0,0 +1,148 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2026, by Samuel Williams.
+
+require "utopia/project/site"
+require "bake/context"
+require "stringio"
+require "yaml"
+
+describe "Documentation tasks" do
+ include Utopia::Project::SiteContext
+
+ let(:context) {Bake::Context.load(@root)}
+
+ it "updates README and agent context deterministically" do
+ Dir.chdir(@root) do
+ context["utopia:project:update"].call
+ first = File.read("readme.md")
+ index = YAML.load_file("context/index.yaml")
+
+ expect(first).to be(:include?, "https://example.com/project/guides/getting-started/index")
+ expect(first).to be(:include?, "This example explains how to call the client.")
+ expect(first).to be(:include?, "v1.1.0")
+ expect(first).to be(:include?, "#code-examples")
+ expect(index["files"].map{|entry| entry["path"]}).to be == ["getting-started.md", "empty.md"]
+ expect(File.read("context/getting-started.md")).to be == File.read("guides/getting-started/readme.md")
+ context["utopia:project:update"].call
+ expect(File.read("readme.md")).to be == first
+ expect(YAML.load_file("context/index.yaml")).to be == index
+ end
+ end
+
+ it "generates no context index when there are no guides" do
+ FileUtils.remove_entry(File.join(@root, "guides"))
+ expect(context["utopia:project:agent:context:update"].call).to be_nil
+ expect(File).not.to be(:exist?, File.join(@root, "context/index.yaml"))
+ end
+
+ it "uses the homepage when no documentation URL is configured" do
+ write("example.gemspec", 'Gem::Specification.new {|s| s.name = "example"; s.version = "1.0"; s.homepage = "https://example.com/fallback/"}')
+ Dir.chdir(@root) do
+ context["utopia:project:readme:update"].call
+ expect(File.read("readme.md")).to be(:include?, "https://example.com/fallback/guides/getting-started/index")
+ end
+ end
+
+ it "creates a project template in an empty directory" do
+ mock(FileUtils::Verbose) do |wrapper|
+ wrapper.replace(:cp_r){|source, destination| FileUtils.cp_r(source, destination)}
+ end
+ Dir.mktmpdir do |root|
+ Dir.chdir(root) do
+ context["utopia:project:create"].call
+ expect(File).to be(:exist?, "config/serve.rb")
+ expect(File.read("config/application.rb")).to be(:include?, "Utopia::Project.call")
+ end
+ end
+ end
+
+ it "extracts the first sentence as the project description" do
+ write("readme.md", "# Example\n\nFirst sentence. Second sentence.\n")
+ previous = $stdout
+ output = StringIO.new
+ begin
+ $stdout = output
+ context["utopia:project:description"].call
+ ensure
+ $stdout = previous
+ end
+ expect(output.string).to be == "First sentence.\n"
+ end
+
+ it "passes custom binding options to Falcon" do
+ recipe = context["utopia:project:serve"]
+ commands = []
+ mock(recipe.instance) do |wrapper|
+ wrapper.replace(:system){|*command| commands << command; true}
+ end
+
+ recipe.call(port: 9293, bind: "http://127.0.0.1")
+ expect(commands.first.first(2)).to be == ["falcon", "serve"]
+ expect(commands.first.last(4)).to be == ["--bind", "http://127.0.0.1", "--port", "9293"]
+ end
+
+ it "marks static output for GitHub Pages and builds its search index" do
+ output = File.join(@root, "export")
+ generate = context["utopia:static:generate"]
+ mock(generate.instance) do |wrapper|
+ wrapper.replace(:generate) do |output_path:, application_path:, public_path:, force:|
+ expect(output_path).to be == output
+ expect(force).to be == false
+ expect(File).to be(:exist?, application_path)
+ expect(File).to be(:directory?, public_path)
+ FileUtils.mkdir_p(output_path)
+ end
+ end
+ indexed = nil
+ mock(context["utopia:project:search:build"].instance) do |wrapper|
+ wrapper.replace(:build){|output_path:| indexed = output_path}
+ end
+
+ context["utopia:project:static"].call(output_path: output, force: false)
+ expect(File).to be(:exist?, File.join(output, ".nojekyll"))
+ expect(indexed).to be == output
+ end
+
+ it "builds search for the selected directory and propagates failures" do
+ recipe = context["utopia:project:search:build"]
+ commands = []
+ mock(recipe.instance) do |wrapper|
+ wrapper.replace(:system){|*command| commands << command; false}
+ end
+
+ expect{recipe.call(output_path: "a path with spaces")}.to raise_exception(RuntimeError, message: be =~ /Pagefind index build failed/)
+ expect(commands.first.last(3)).to be == ["pagefind@1.5.2", "--site", "a path with spaces"]
+ end
+
+ it "builds Pagefind in order and returns its executable" do
+ recipe = context["utopia:project:pagefind"]
+ commands = []
+ write("pagefind/target/release/pagefind", "#!/bin/sh\n")
+ binary = File.join(@root, "pagefind/target/release/pagefind")
+ File.chmod(0o755, binary)
+ mock(recipe.instance) do |wrapper|
+ wrapper.replace(:system) do |*command, chdir:|
+ commands << [command, chdir]
+ true
+ end
+ end
+
+ expect(recipe.call(source_path: File.join(@root, "pagefind"))).to be == binary
+ expect(commands.first).to be == [["npm", "ci"], File.join(@root, "pagefind/pagefind_web_js")]
+ expect(commands.last).to be == [["cargo", "build", "--release", "--features", "extended"], File.join(@root, "pagefind/pagefind")]
+ end
+
+ it "reports failed Pagefind commands and missing executables" do
+ recipe = context["utopia:project:pagefind"]
+ mock(recipe.instance) do |wrapper|
+ wrapper.replace(:system){false}
+ end
+ expect{recipe.call(source_path: @root)}.to raise_exception(RuntimeError, message: be =~ /Pagefind build failed/)
+ mock(recipe.instance) do |wrapper|
+ wrapper.replace(:system){true}
+ end
+ expect{recipe.call(source_path: @root)}.to raise_exception(RuntimeError, message: be =~ /did not produce an executable/)
+ end
+end
From 8342cd220069123b669bb45d459ef0dc6925a811 Mon Sep 17 00:00:00 2001
From: Samuel Williams
Date: Mon, 28 Sep 2026 10:48:45 +1300
Subject: [PATCH 2/5] Serve directory indexes for search results in the browser
fixture
Signed-off-by: Samuel Williams
---
test/browser/server.cjs | 3 ++-
test/browser/site.spec.cjs | 4 +++-
2 files changed, 5 insertions(+), 2 deletions(-)
diff --git a/test/browser/server.cjs b/test/browser/server.cjs
index d1446f3..991d144 100644
--- a/test/browser/server.cjs
+++ b/test/browser/server.cjs
@@ -9,7 +9,8 @@ http.createServer(async (request, response) => {
try {
const pathname = decodeURIComponent(new URL(request.url, 'http://localhost').pathname);
if (!pathname.startsWith('/project/')) throw new Error('Outside project');
- const filename = path.resolve(root, pathname.slice('/project/'.length) || 'index.html');
+ const relative = pathname.slice('/project/'.length);
+ const filename = path.resolve(root, pathname.endsWith('/') ? relative + 'index.html' : relative);
if (!filename.startsWith(root + path.sep)) throw new Error('Outside fixture');
const content = await fs.readFile(filename);
response.writeHead(200, {'content-type': types[path.extname(filename)] || 'application/octet-stream'});
diff --git a/test/browser/site.spec.cjs b/test/browser/site.spec.cjs
index 4db1592..7ccb988 100644
--- a/test/browser/site.spec.cjs
+++ b/test/browser/site.spec.cjs
@@ -78,7 +78,9 @@ test('opens example disclosures with the keyboard without shifting their summari
test('hides unavailable search without breaking navigation', async ({page}) => {
await page.route('**/pagefind-component-ui.js', route => route.abort());
+ const unavailable = page.waitForEvent('console', message => message.text().includes('Documentation search is unavailable.'));
await page.goto(guide);
+ await unavailable;
await expect(page.locator('a.self')).not.toHaveCount(0);
await expect(page.locator('pagefind-modal-trigger')).toBeHidden();
await page.locator('.section-links a').filter({hasText: 'Reference'}).click();
@@ -100,7 +102,7 @@ test('searches the generated index and follows results under the project subpath
await expect(result).toBeVisible();
await expect(result).toHaveAttribute('href', /^\/project\/guides\/getting-started\//);
await result.click();
- await expect(page).toHaveURL(/\/project\/guides\/getting-started\/index.html/);
+ await expect(page).toHaveURL(/\/project\/guides\/getting-started\/(?:index\.html)?(?:#.*)?$/);
await expect(page.locator('h1')).toHaveText('Getting Started');
await page.locator('pagefind-modal-trigger button').click();
await page.keyboard.press('Escape');
From f69b976ad270ce17c509456a62725305dbab7238 Mon Sep 17 00:00:00 2001
From: Samuel Williams
Date: Mon, 28 Sep 2026 10:51:05 +1300
Subject: [PATCH 3/5] Default unspecified guide ordering to zero
Signed-off-by: Samuel Williams
---
lib/utopia/project/guide.rb | 5 ++---
releases.md | 3 ++-
test/utopia/project/guides.rb | 9 +++++----
3 files changed, 9 insertions(+), 8 deletions(-)
diff --git a/lib/utopia/project/guide.rb b/lib/utopia/project/guide.rb
index c32221e..b1e53a7 100644
--- a/lib/utopia/project/guide.rb
+++ b/lib/utopia/project/guide.rb
@@ -44,12 +44,11 @@ def order
metadata[:order]
end
- # Compare guides by explicit order and then by name.
+ # Compare guides by order (defaulting to zero) and then by name.
# @parameter other [Guide] The other guide to compare.
# @returns [Integer] The comparison result.
def <=> other
- [self.order ? 0 : 1, self.order || 0, self.name] <=>
- [other.order ? 0 : 1, other.order || 0, other.name]
+ [self.order || 0, self.name] <=> [other.order || 0, other.name]
end
README = "readme.md"
diff --git a/releases.md b/releases.md
index 074b480..dc70a6a 100644
--- a/releases.md
+++ b/releases.md
@@ -2,7 +2,8 @@
## Unreleased
- - Fix guide ordering, supplemental documentation paths, and missing guide/reference responses.
+ - Sort guides by order (defaulting to zero), then name.
+ - Fix supplemental documentation paths and missing guide/reference responses.
- Handle empty READMEs and guides without descriptions when rendering pages and generating agent context.
- Cover all Ruby, task, and rendered template lines, and exercise the generated site in Chromium at mobile and desktop widths in light and dark mode.
- Add padding to documentation table cells and allow tables to scroll whenever they exceed the available width.
diff --git a/test/utopia/project/guides.rb b/test/utopia/project/guides.rb
index 64d8bae..f38a6fe 100644
--- a/test/utopia/project/guides.rb
+++ b/test/utopia/project/guides.rb
@@ -8,16 +8,17 @@
describe Utopia::Project::Guides do
include Utopia::Project::SiteContext
- it "sorts explicit priorities before names, including ties and unspecified priorities" do
+ it "sorts by order and then name, treating unspecified orders as zero" do
guides = [
["alpha", {}], ["zebra", {order: 1}], ["beta", {order: 1}],
- ["gamma", {order: 2}], ["delta", {}]
+ ["gamma", {order: 2}], ["delta", {}],
+ ["omega", {order: -1}], ["charlie", {order: 0}]
].map do |name, metadata|
Utopia::Project::Guide.new(base, File.join(@root, "guides", name), metadata)
end
- expect(guides.sort.map(&:name)).to be == ["beta", "zebra", "gamma", "alpha", "delta"]
- expect(guides.reverse.sort.map(&:name)).to be == ["beta", "zebra", "gamma", "alpha", "delta"]
+ expect(guides.sort.map(&:name)).to be == ["omega", "alpha", "charlie", "delta", "beta", "zebra", "gamma"]
+ expect(guides.reverse.sort.map(&:name)).to be == ["omega", "alpha", "charlie", "delta", "beta", "zebra", "gamma"]
end
it "finds guides and handles navigation boundaries" do
From 32c3b2a1bef3ab2d068327ef59051aa26da1620e Mon Sep 17 00:00:00 2001
From: Samuel Williams
Date: Mon, 28 Sep 2026 10:58:39 +1300
Subject: [PATCH 4/5] Handle empty Markdown headings in documentation
Use existing fallback titles, skip empty headings during section lookup, and preserve introductory content. Cover document titles, rendered guides and homepages, section replacement, and description extraction.
Signed-off-by: Samuel Williams
---
bake/utopia/project.rb | 2 --
lib/utopia/project/document.rb | 6 +++---
lib/utopia/project/guide.rb | 2 +-
pages/index.xnode | 2 +-
releases.md | 1 +
test/utopia/project/document.rb | 17 +++++++++++++++++
test/utopia/project/rendering.rb | 23 ++++++++++++++++++++++-
test/utopia/project/tasks.rb | 8 +++++---
8 files changed, 50 insertions(+), 11 deletions(-)
diff --git a/bake/utopia/project.rb b/bake/utopia/project.rb
index 3d47b4a..2f99df8 100644
--- a/bake/utopia/project.rb
+++ b/bake/utopia/project.rb
@@ -121,8 +121,6 @@ def description(root: context.root)
child = document.first_child
if child&.type == :header
- title = child.first_child.string_content
-
# First sentence
if introduction = child.next
$stdout.puts introduction.to_plaintext[/.*?\./]
diff --git a/lib/utopia/project/document.rb b/lib/utopia/project/document.rb
index 0ad4efe..6827695 100644
--- a/lib/utopia/project/document.rb
+++ b/lib/utopia/project/document.rb
@@ -33,12 +33,12 @@ def root
end
# Extract the leading heading as the document title.
- # @returns [String | Nil] The title, if the document starts with a heading.
+ # @returns [String | Nil] The title, if the document starts with a non-empty heading.
def title
child = self.root.first_child
if child && child.type == :header
- return child.first_child.to_plaintext
+ return child.first_child&.to_plaintext
end
end
@@ -61,7 +61,7 @@ def replace_section(name, children: false)
header = child
# We found the matched header:
- if header.first_child.to_plaintext.include?(name)
+ if header.first_child&.to_plaintext&.include?(name)
# Now subsequent children:
current = header.next
diff --git a/lib/utopia/project/guide.rb b/lib/utopia/project/guide.rb
index b1e53a7..c3a2e4e 100644
--- a/lib/utopia/project/guide.rb
+++ b/lib/utopia/project/guide.rb
@@ -72,7 +72,7 @@ def document
child = document.first_child
if child&.type == :header
- @title = child.first_child.string_content
+ @title = child.first_child&.string_content
@description = child.next
child.delete
diff --git a/pages/index.xnode b/pages/index.xnode
index 5bf1264..e9cc8a4 100644
--- a/pages/index.xnode
+++ b/pages/index.xnode
@@ -8,7 +8,7 @@
child.delete
title = header.first_child
- case title.type
+ case title&.type
when :text
?>#{title.string_content}Introduction.
")
+ end
+ end
+
+ it "skips empty headings when finding a section to replace" do
+ document = subject.new("#\n\nIntroduction.\n\n##\n\nKeep this.\n\n## Usage\n\nOld content.\n")
+ document.replace_section("Usage") do |header|
+ header.insert_after(document.paragraph_node(document.text_node("New content.")))
+ end
+ expect(document.to_markdown).to be == "# \n\nIntroduction.\n\n## \n\nKeep this.\n\n## Usage\n\nNew content.\n"
+ end
+
it "renders Mermaid source safely and keeps ordinary fenced code" do
document = subject.new("~~~ mermaid\nflowchart LR\n A[\"\"] --> B\n~~~\n\n~~~ ruby\nputs 42\n~~~\n")
html = document.to_html.to_s
diff --git a/test/utopia/project/rendering.rb b/test/utopia/project/rendering.rb
index 95318fa..7d45330 100644
--- a/test/utopia/project/rendering.rb
+++ b/test/utopia/project/rendering.rb
@@ -53,7 +53,7 @@
expect(body).to be(:include?, "This project does not have a")
end
- ["Introductory paragraph.", "# *Formatted title*", ""].each do |markdown|
+ ["Introductory paragraph.", "# *Formatted title*", "", "#", "##"].each do |markdown|
with "README #{markdown.inspect}" do
it "renders a fallback heading" do
write("readme.md", markdown)
@@ -64,6 +64,27 @@
end
end
+ ["#", "##"].each do |heading|
+ with "empty #{heading.inspect} heading followed by an introduction" do
+ it "renders fallback titles and preserves the introduction" do
+ markdown = "#{heading}\n\nIntroduction.\n"
+ write("readme.md", markdown)
+ write("guides/empty/readme.md", markdown)
+ guide = base.guides["empty"]
+ expect(guide.title).to be == "Empty"
+ expect(guide.description.to_plaintext).to be == "Introduction.\n"
+
+ {"/index" => "Project", "/guides/empty/index" => "Empty"}.each do |path, title|
+ response = client.get(path)
+ body = response.read
+ expect(response.status).to be == 200
+ expect(body).to be(:include?, "#{title}
")
+ expect(body).to be(:include?, "Introduction.
")
+ end
+ end
+ end
+ end
+
["svg", "png"].each do |extension|
with "#{extension} title image" do
it "renders a logo and page title" do
diff --git a/test/utopia/project/tasks.rb b/test/utopia/project/tasks.rb
index 9f4109f..4f9e2b6 100644
--- a/test/utopia/project/tasks.rb
+++ b/test/utopia/project/tasks.rb
@@ -59,16 +59,18 @@
end
it "extracts the first sentence as the project description" do
- write("readme.md", "# Example\n\nFirst sentence. Second sentence.\n")
previous = $stdout
output = StringIO.new
begin
$stdout = output
- context["utopia:project:description"].call
+ ["# Example", "#", "##"].each do |heading|
+ write("readme.md", "#{heading}\n\nFirst sentence. Second sentence.\n")
+ context["utopia:project:description"].call
+ end
ensure
$stdout = previous
end
- expect(output.string).to be == "First sentence.\n"
+ expect(output.string).to be == "First sentence.\n" * 3
end
it "passes custom binding options to Falcon" do
From 8bd42d95c02bd836c3f88dc71413c484ac7c1724 Mon Sep 17 00:00:00 2001
From: Samuel Williams
Date: Mon, 28 Sep 2026 10:59:56 +1300
Subject: [PATCH 5/5] Validate fragment targets in browser link checks
Fetch each destination once and match decoded URL fragments against IDs and named anchors. Exercise empty fragments, duplicate headings, named anchors, and encoded cross-page Ruby method references.
Signed-off-by: Samuel Williams
---
test/browser/readme.md | 2 ++
test/browser/site.spec.cjs | 24 ++++++++++++++++---
.../site/guides/getting-started/readme.md | 4 ++++
3 files changed, 27 insertions(+), 3 deletions(-)
diff --git a/test/browser/readme.md b/test/browser/readme.md
index a321733..faa411c 100644
--- a/test/browser/readme.md
+++ b/test/browser/readme.md
@@ -2,6 +2,8 @@
These tests use the exported fixture project in `test/utopia/project/.fixtures/site`. They exercise navigation, search, diagrams, syntax highlighting, keyboard disclosures, and table layout at mobile and desktop widths in light and dark mode. The static server mounts the site at `/project/` to check GitHub Pages subpath handling.
+The link check fetches each destination page once and verifies decoded fragments against element IDs and named anchors in the exported HTML. This includes duplicate headings and cross-page Ruby method references.
+
Install the bundle with the maintenance group enabled, then install the browser dependencies:
``` sh
diff --git a/test/browser/site.spec.cjs b/test/browser/site.spec.cjs
index 7ccb988..85341ce 100644
--- a/test/browser/site.spec.cjs
+++ b/test/browser/site.spec.cjs
@@ -8,10 +8,28 @@ test('renders diagrams, highlighted code and local links under a project subpath
await expect(page.locator('.mermaid svg')).toBeVisible();
await expect(page.locator('syntax-code')).not.toHaveCount(0);
await expect(page.locator('#configuration-2')).toHaveCount(1);
- const links = await page.locator('a[href]').evaluateAll(links => links.map(link => link.href).filter(href => href.startsWith(location.origin)));
+ const links = await page.locator('a[href]').evaluateAll(links => links.map(link => link.href).filter(href => new URL(href).origin === location.origin));
+ const targetsByPage = new Map();
for (const href of new Set(links)) {
- expect(new URL(href).pathname).toMatch(/^\/project\//);
- expect((await request.get(href)).ok(), href).toBeTruthy();
+ const url = new URL(href);
+ expect(url.pathname).toMatch(/^\/project\//);
+ const fragment = decodeURIComponent(url.hash.slice(1));
+ url.hash = '';
+ if (!targetsByPage.has(url.href)) {
+ const response = await request.get(url.href);
+ expect(response.ok(), url.href).toBeTruthy();
+ const targets = await page.evaluate(html => {
+ const document = new DOMParser().parseFromString(html, 'text/html');
+ return [
+ ...Array.from(document.querySelectorAll('[id]'), element => element.id),
+ ...Array.from(document.querySelectorAll('a[name]'), element => element.name),
+ ];
+ }, await response.text());
+ targetsByPage.set(url.href, new Set(targets));
+ }
+ if (fragment) {
+ expect(targetsByPage.get(url.href).has(fragment), `Missing fragment target: ${href}`).toBeTruthy();
+ }
}
expect(errors).toEqual([]);
});
diff --git a/test/utopia/project/.fixtures/site/guides/getting-started/readme.md b/test/utopia/project/.fixtures/site/guides/getting-started/readme.md
index 68532c6..07fb335 100644
--- a/test/utopia/project/.fixtures/site/guides/getting-started/readme.md
+++ b/test/utopia/project/.fixtures/site/guides/getting-started/readme.md
@@ -2,6 +2,8 @@
This guide explains how to preview the example project.
+
+
## Installation
Install the project before running the examples.
@@ -33,3 +35,5 @@ Publish the generated documentation.
### Configuration
Check links after deployment.
+
+Return to the [overview](#overview) or the [top of the page](#).