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.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/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/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 5f15e3b..c3a2e4e 100644
--- a/lib/utopia/project/guide.rb
+++ b/lib/utopia/project/guide.rb
@@ -44,32 +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
- 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, self.name] <=> [other.order || 0, other.name]
end
README = "readme.md"
@@ -93,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/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..e9cc8a4 100644
--- a/pages/index.xnode
+++ b/pages/index.xnode
@@ -3,12 +3,12 @@
if document = self[:document]
child = document.first_child
- if child.type == :header
+ if child&.type == :header
header = child
child.delete
title = header.first_child
- case title.type
+ case title&.type
when :text
?>#{title.string_content}
-
\ 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..f0cad3e 100644
--- a/releases.md
+++ b/releases.md
@@ -2,6 +2,11 @@
## Unreleased
+ - 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.
+ - Treat empty Markdown headings as missing titles and preserve the following content when rendering pages or updating documentation.
+ - 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..faa411c
--- /dev/null
+++ b/test/browser/readme.md
@@ -0,0 +1,23 @@
+# 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.
+
+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
+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..991d144
--- /dev/null
+++ b/test/browser/server.cjs
@@ -0,0 +1,22 @@
+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 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'});
+ 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..85341ce
--- /dev/null
+++ b/test/browser/site.spec.cjs
@@ -0,0 +1,130 @@
+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 => new URL(href).origin === location.origin));
+ const targetsByPage = new Map();
+ for (const href of new Set(links)) {
+ 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([]);
+});
+
+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());
+ 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();
+ 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..07fb335
--- /dev/null
+++ b/test/utopia/project/.fixtures/site/guides/getting-started/readme.md
@@ -0,0 +1,39 @@
+# 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.
+
+Return to the [overview](#overview) or the [top of the page](#).
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..50715d8 100644
--- a/test/utopia/project/document.rb
+++ b/test/utopia/project/document.rb
@@ -134,3 +134,53 @@
end
end
end
+
+describe Utopia::Project::Document do
+ it "returns no title for empty headings without losing subsequent content" do
+ ["#", "##"].each do |heading|
+ expect(subject.new("#{heading}\n").title).to be_nil
+ document = subject.new("#{heading}\n\nIntroduction.\n")
+ expect(document.title).to be_nil
+ expect(document.to_html).to be(:include?, "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
+ 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..f38a6fe
--- /dev/null
+++ b/test/utopia/project/guides.rb
@@ -0,0 +1,54 @@
+# 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 by order and then name, treating unspecified orders as zero" do
+ guides = [
+ ["alpha", {}], ["zebra", {order: 1}], ["beta", {order: 1}],
+ ["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 == ["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
+ 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..7d45330
--- /dev/null
+++ b/test/utopia/project/rendering.rb
@@ -0,0 +1,143 @@
+# 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
+
+ ["#", "##"].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
+ 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..4f9e2b6
--- /dev/null
+++ b/test/utopia/project/tasks.rb
@@ -0,0 +1,150 @@
+# 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
+ previous = $stdout
+ output = StringIO.new
+ begin
+ $stdout = output
+ ["# 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" * 3
+ 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