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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,20 @@ jobs:
steps:
- uses: actions/checkout@v4

- name: Read Open Question Board source
uses: actions/checkout@v4
with:
repository: GiggleLiu/autoresearch-gadgets
ref: main
path: open-questions-source
token: ${{ secrets.PROJECT_READ_TOKEN }}
persist-credentials: false

- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0

- name: Build Open Question Board
run: uv run --project open-questions-source/website --locked python open-questions-source/website/build.py

- uses: dtolnay/rust-toolchain@stable

- uses: actions/setup-node@v4
Expand Down Expand Up @@ -68,7 +82,7 @@ jobs:
cp -r target/doc/* book/api/

- name: Build research website
run: python3 scripts/build_website.py
run: python3 scripts/build_website.py --open-questions open-questions-source/website/dist

- name: Verify deployment artifact
run: |
Expand All @@ -88,6 +102,7 @@ jobs:
path: './book'

deploy:
if: github.ref == 'refs/heads/main'
permissions:
pages: write
id-token: write
Expand Down
2 changes: 1 addition & 1 deletion book.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,4 @@ copy-js = true
# Pages that moved to the website keep their published URLs.
[output.html.redirect]
"/reduction-graph.html" = "graph.html"
"/open-problems.html" = "index.html#open-questions"
"/open-problems.html" = "open-questions/"
2 changes: 1 addition & 1 deletion docs/src/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -583,4 +583,4 @@ accept full matrices; evaluation and serialization ignore their lower triangle.

## Contributing

See [Call for Contributions](index.html#open-questions) for the recommended issue-based workflow (no coding required).
See the [Open Question Board](open-questions/) to browse problems and register a problem or solution through a GitHub pull request.
25 changes: 24 additions & 1 deletion docs/website/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,30 @@ clipboard actions, the mathematical example, legacy docs, and mobile overflow.

## Documentation

Open questions has its own website navigation tab at `index.html#open-questions`.
The homepage and graph's **Open questions** link opens `open-questions/`.
The board is generated from the registry and shared templates in
[`GiggleLiu/autoresearch-gadgets`](https://github.com/GiggleLiu/autoresearch-gadgets),
then included in the same Pages artifact. Legacy `#open-questions` and
`open-problems.html` links redirect to the board.

The deployment workflow reads that private repository's `main` branch with the
existing `PROJECT_READ_TOKEN` secret. That token needs **Contents: read** access
to `GiggleLiu/autoresearch-gadgets`; the default `GITHUB_TOKEN` cannot read another
private repository. Only the generated `website/dist/` is published. Registry
changes appear on the next documentation deployment; use **Run workflow** on
`main` to refresh the board without changing this repository. Feature-branch
runs build and verify the artifact without deploying it.

To include the board in a local preview, clone its source beside this repository,
build it with its committed lockfile, and run the website overlay after mdBook:

```sh
uv run --project ../autoresearch-gadgets/website --locked python ../autoresearch-gadgets/website/build.py
python3 scripts/build_website.py --open-questions ../autoresearch-gadgets/website/dist
```

Include the board when running the browser suite. Without `--open-questions`,
the builder previews the main website and guide only.

The guide has seven pages listed in `docs/src/SUMMARY.md`: an overview, the CLI
(quick start, command reference), agent skills, the Rust library
Expand Down
4 changes: 2 additions & 2 deletions docs/website/assets/site.js
Original file line number Diff line number Diff line change
Expand Up @@ -491,8 +491,8 @@
hydrateHome();
} else if (parts[0] === "atlas") atlasPage();
else if (parts[0] === "open-questions") {
document.title = `Open questions — ${baseTitle}`;
main.innerHTML = '<div class="wrap page-header"><h1>Open questions</h1><p>To be released.</p></div>';
location.replace("./open-questions/");
return;
}
else if (parts[0] === "problem") problemPage(parts[1], params);
else if (parts[0] === "reduction") rulePage(parts[1], parts[2], params);
Expand Down
2 changes: 1 addition & 1 deletion docs/website/graph.html
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
<a href="./introduction.html" class="docs-nav"
>Docs <span aria-hidden="true">↗</span></a
>
<a href="./#open-questions"
<a href="./open-questions/"
>Open questions <span aria-hidden="true">↗</span></a
>
</nav>
Expand Down
2 changes: 1 addition & 1 deletion docs/website/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
<a href="./introduction.html" class="docs-nav"
>Docs <span aria-hidden="true">↗</span></a
>
<a href="#open-questions" data-nav="open-questions"
<a href="./open-questions/"
>Open questions <span aria-hidden="true">↗</span></a
>
</nav>
Expand Down
20 changes: 18 additions & 2 deletions scripts/build_website.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,16 @@ def relocate_link(match):
summary.removeprefix("# Summary\n").lstrip())


def build(output, graph_path, schemas_path):
def publish_open_questions(source, output):
"""Publish the board with its own assets, preserving repository evidence paths."""
registry = (source / "registry.json").read_bytes()
shutil.copytree(source, output)
finalize(output)
# Registry paths refer to proof repositories, not this site's asset release.
(output / "registry.json").write_bytes(registry)


def build(output, graph_path, schemas_path, open_questions=None):
graph = json.loads(graph_path.read_text())
schemas = json.loads(schemas_path.read_text())
nodes, edges = graph["nodes"], graph["edges"]
Expand All @@ -127,6 +136,9 @@ def build(output, graph_path, schemas_path):
raise ValueError("The atlas requires nodes and valid edge endpoints")
source = ROOT / "docs/website"
output.mkdir(parents=True, exist_ok=True)
# Replace the board on rebuild before finalizing the main site's assets.
if (output / "open-questions").exists():
shutil.rmtree(output / "open-questions")
shutil.copytree(source / "assets", output / "assets", dirs_exist_ok=True)
shutil.copyfile(ROOT / "node_modules/cytoscape/dist/cytoscape.min.js", output / "assets/cytoscape.min.js")
shutil.copyfile(ROOT / "node_modules/cytoscape/LICENSE", output / "assets/cytoscape.LICENSE")
Expand Down Expand Up @@ -234,6 +246,8 @@ def build(output, graph_path, schemas_path):
(output / "assets/graph-data.js").write_text("window.REDUCTIONS = " + json.dumps(
{**summary, "layout": layout}, separators=(",", ":")) + ";\n")
finalize(output)
if open_questions is not None:
publish_open_questions(open_questions, output / "open-questions")
print(f"Built website in {output}: {counts['PROBLEM_COUNT']} families, "
f"{len(nodes)} variants, {len(edges)} directed reductions")

Expand All @@ -245,9 +259,11 @@ def build(output, graph_path, schemas_path):
default=ROOT / "docs/src/reductions/reduction_graph.json")
parser.add_argument("--schemas", type=Path,
default=ROOT / "docs/src/reductions/problem_schemas.json")
parser.add_argument("--open-questions", type=Path,
help="Built Open Question Board directory (website/dist)")
args = parser.parse_args()
try:
build(args.output, args.graph, args.schemas)
build(args.output, args.graph, args.schemas, args.open_questions)
except FileNotFoundError as error:
parser.exit(1, f"{error}\nGenerate atlas data with cargo run --example "
"export_graph and cargo run --example export_schemas first.\n")
57 changes: 34 additions & 23 deletions scripts/test_website.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,25 +59,29 @@ def test_main_navigation_returns_home_from_problem(self):
expect(home).to_have_attribute('aria-current', 'page')
expect(self.page.locator('main .hero')).to_be_visible()

def test_open_questions_has_its_own_navigation_tab(self):
def test_home_navigation_opens_question_board(self):
self.visit()
tab = self.page.get_by_role('navigation', name='Main navigation').get_by_role('link', name='Open questions', exact=True)
tab.click()
expect(tab).to_have_attribute('aria-current', 'page')
self.assertEqual(tab.locator('xpath=following-sibling::a').count(), 0)
expect(tab.locator('span')).to_have_text('↗')
expect(self.page.get_by_role('heading', name='Open questions', exact=True)).to_be_visible()
expect(self.page.locator('main')).to_contain_text('To be released.')
self.page.goto(self.base + 'introduction.html')
expect(self.page.locator('.sidebar')).not_to_contain_text('Open questions')
expect(self.page.locator('.sidebar').get_by_text('Research', exact=True)).to_have_count(0)
expect(self.page).to_have_url(self.base + 'open-questions/')
expect(self.page.get_by_role('heading', name='Open Question Zoo', exact=True)).to_be_visible()
self.page.get_by_role('link', name='All questions', exact=False).click()
self.page.get_by_role('searchbox', name='Search the collection').fill('Q001')
expect(self.page.locator('.question-item:visible')).to_have_count(1)
expect(self.page.locator('.question-item:visible .question-id')).to_have_text('Q001')
self.page.locator('.question-item:visible .question-link').click()
self.page.get_by_role('link', name='Submit a solution', exact=True).click()
expect(self.page.get_by_label('Registry entry', exact=True)).to_have_value('Q001')
registry = self.page.request.get(self.base + 'open-questions/registry.json').json()
question = next(entry for entry in registry['questions'] if entry['id'] == 'Q001')
expect(self.page.get_by_role('link', name='Edit registry entry on GitHub')).to_have_attribute(
'href', f"https://github.com/GiggleLiu/autoresearch-gadgets/edit/main/registry/{question['slug']}.json")

def test_graph_navigation_opens_question_board(self):
self.page.goto(self.base + 'graph.html')
expect(self.page.locator("#cy canvas").first).to_be_visible()
tab = self.page.get_by_role('link', name='Open questions', exact=True)
self.assertEqual(tab.locator('xpath=following-sibling::a').count(), 0)
expect(tab.locator('span')).to_have_text('↗')
tab.click()
expect(self.page.get_by_role('heading', name='Open questions', exact=True)).to_be_visible()
self.page.get_by_role('link', name='Open questions', exact=True).click()
expect(self.page).to_have_url(self.base + 'open-questions/')
expect(self.page.get_by_role('heading', name='Open Question Zoo', exact=True)).to_be_visible()

def test_home_formulas_load_as_typeset_vectors(self):
self.visit()
Expand Down Expand Up @@ -231,13 +235,13 @@ def test_only_turing_rules_have_execution_labels(self):
!link.textContent.includes('Result recovery') && !link.textContent.includes('See contract'))'''))
self.visit('reduction/MaximumIndependentSet/DecisionMaximumIndependentSet')
expect(self.page.locator('.reading-aside .rule-kind')).to_have_text('Turing reduction')
self.visit('reduction/CircuitSAT/SpinGlass')
self.visit('reduction/CircuitSAT/DecisionSpinGlass')
expect(self.page.locator('.reading-aside .rule-kind')).to_have_count(0)
expect(self.page.locator('.reading-aside')).not_to_contain_text('Status')
expect(self.page.locator('.reading-aside')).not_to_contain_text('Capabilities')

def test_rule_parameter_upper_bounds_use_inequalities(self):
self.visit('reduction/CircuitSAT/SpinGlass')
self.visit('reduction/CircuitSAT/DecisionSpinGlass')
expect(self.page.locator('.parameter-operator')).to_have_text(['≤', '≤'])
expect(self.page.locator('.parameter-relation math')).to_have_count(2)
self.page.set_viewport_size({'width': 390, 'height': 900})
Expand Down Expand Up @@ -306,8 +310,13 @@ def test_legacy_documentation_urls_redirect(self):
self.page.wait_for_url(self.base + 'graph.html')
expect(self.page.locator("#cy canvas").first).to_be_visible()
self.page.goto(self.base + 'open-problems.html')
self.page.wait_for_url(self.base + 'index.html#open-questions')
expect(self.page.get_by_role('heading', name='Open questions', exact=True)).to_be_visible()
self.page.wait_for_url(self.base + 'open-questions/')
expect(self.page.get_by_role('heading', name='Open Question Zoo', exact=True)).to_be_visible()

def test_legacy_open_questions_hash_opens_question_board(self):
self.visit('open-questions')
expect(self.page).to_have_url(self.base + 'open-questions/')
expect(self.page.get_by_role('heading', name='Open Question Zoo', exact=True)).to_be_visible()

def test_docs_sidebar_drag_resizes_content(self):
self.page.goto(self.base + 'introduction.html')
Expand Down Expand Up @@ -403,18 +412,20 @@ def test_graph_is_in_primary_navigation_and_opens_details(self):
""")
self.page.locator('#graph-search').fill('ILP')
self.page.locator('#graph-search').press('Enter')
self.assertEqual(self.page.evaluate("""() => {
graph = self.page.request.get(self.base + 'reductions/reduction_graph.json').json()
expected_variants = sum(node['name'] == 'ILP' for node in graph['nodes'])
self.assertEqual(self.page.evaluate("""expectedVariants => {
const cy = document.querySelector('#cy')._cyreg.cy;
const parent = cy.getElementById('ILP');
const box = parent.boundingBox();
const neighbors = parent.children().connectedEdges().connectedNodes()
.difference(parent.children());
if (parent.children().length !== 4 || neighbors.length <= 50) return ['Missing variants or neighbors'];
if (parent.children().length !== expectedVariants || neighbors.length <= 50) return ['Missing variants or neighbors'];
return neighbors.filter(node => {
const {x, y} = node.position();
return x >= box.x1 && x <= box.x2 && y >= box.y1 && y <= box.y2;
}).map(node => node.id());
}"""), [])
}""", expected_variants), [])
displaced = self.page.evaluate("""() =>
document.querySelector('#cy')._cyreg.cy.nodes()
.filter(node => !node.isParent() && !node.data('isVariant'))
Expand Down Expand Up @@ -597,7 +608,7 @@ def test_graph_list_selection_keeps_keyboard_focus(self):
self.assertEqual(self.page.evaluate('document.activeElement.tagName'), 'BODY')

def test_atlas_ilp_prioritizes_documentation_over_metadata(self):
self.visit('problem/ILP')
self.visit('problem/ILP?variant=bounds=general,coefficient=f64,variable=bool')
expect(self.page.locator('article.typst-detail')).to_contain_text('Lenstra')
expect(self.page.locator('.relation-title > span').filter(
has_text='Expected Retrieval Cost')).to_have_text('Expected Retrieval Cost →')
Expand Down
23 changes: 23 additions & 0 deletions scripts/test_website_build.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,41 @@
"""Security and release contracts for the static website build."""

from pathlib import Path
import json
import re
import subprocess
import tempfile
import unittest

from build_graph_details import SafeDetailHTML
import build_website
from finalize_website import finalize

ROOT = Path(__file__).resolve().parents[1]


class WebsiteBuildTests(unittest.TestCase):
def test_question_board_keeps_assets_and_registry_provenance(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
source = root / 'board'
(source / 'assets').mkdir(parents=True)
(source / 'assets/site.js').write_text('document.body.dataset.ready = "true";')
(source / 'assets/site.css').write_text('body { color: green; }')
(source / 'index.html').write_text('<html><head><link rel="stylesheet" href="assets/site.css">'
'<script src="assets/site.js" defer></script></head><body>Questions</body></html>')
registry = json.dumps({'questions': [{'id': 'Q001', 'solutions': [
{'materials': [{'path': 'assets/proof.svg'}]}]}]}) + '\n'
(source / 'registry.json').write_text(registry)
output = root / 'site/open-questions'
build_website.publish_open_questions(source, output)
html = (output / 'index.html').read_text()
self.assertIn("script-src 'self'", html)
for url in re.findall(r'(?:src|href)="([^"]+)"', html):
self.assertTrue((output / url.split('?')[0]).is_file(), url)
self.assertEqual((output / 'registry.json').read_text(), registry)
self.assertEqual((source / 'registry.json').read_text(), registry)

def test_documentation_rejects_active_content(self):
for markup in ['<script>alert(1)</script>', '<img onerror="alert(1)">',
'<a href="java&#10;script:alert(1)">bad</a>',
Expand Down
Loading