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", "# ![Project Logo](logo.#{extension})\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](#).