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
6 changes: 6 additions & 0 deletions .changelog/graph-assets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
category: fixed
type: patch
---

Preserves shared graph-page assets and initializes zoom controls after loading their dependency. Cancels pending zoom animation before resetting the diagram.
6 changes: 6 additions & 0 deletions .changelog/member-permalinks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
category: fixed
type: patch
---

Scopes member tag and attribute IDs and replaces existing fragments in section permalinks.
6 changes: 6 additions & 0 deletions .changelog/search-reopen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
category: fixed
type: patch
---

Restores search results and active state when reopening an unchanged query.
2 changes: 2 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ jobs:
tools: composer:v2
- run: composer validate --strict
- run: composer install --no-scripts --no-interaction --prefer-dist --no-progress
- name: Verify browser interaction contracts
run: node tests/interactions.test.cjs
- name: Validate rendered checkout
run: php tests/generate-fixture.php
- name: Validate bundled consumer example
Expand Down
4 changes: 2 additions & 2 deletions class.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
<section class="card border-0 shadow-sm">
<div class="card-header bg-body-tertiary border-0 d-flex align-items-center gap-2">
<i class="fas fa-sliders-h text-info"></i>
<span class="fw-semibold">
<span class="fw-semibold" id="properties">
Properties
{{ include('components/headerlink.html.twig', {'on': node, 'at': 'properties'}, with_context = false) }}
</span>
Expand All @@ -52,7 +52,7 @@
<section class="card border-0 shadow-sm">
<div class="card-header bg-body-tertiary border-0 d-flex align-items-center gap-2">
<i class="fas fa-code text-primary"></i>
<span class="fw-semibold">
<span class="fw-semibold" id="methods">
Methods
{{ include('components/headerlink.html.twig', {'on': node, 'at': 'methods'}, with_context = false) }}
</span>
Expand Down
6 changes: 4 additions & 2 deletions components/attributes.html.twig
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
{% set node_link_parts = link(node)|split('#') %}
{% set section_id = (node_link_parts|length > 1 ? node_link_parts|last ~ '-' : '') ~ 'attributes' %}
{% if node is attributed %}
{% set attributes = attributes(node)|specializedAttributes|sortByVisibility %}

{% if attributes is not empty %}
<section class="mt-4">
<div class="d-flex align-items-center gap-2 mb-3">
<i class="fas fa-tags text-info"></i>
<h5 class="h6 text-uppercase text-body-secondary fw-semibold mb-0" id="attributes">
<h5 class="h6 text-uppercase text-body-secondary fw-semibold mb-0" id="{{ section_id }}">
Attributes
{{ include('components/headerlink.html.twig', {'on': node, 'at': 'attributes'}, with_context = false) }}
{{ include('components/headerlink.html.twig', {'on': node, 'at': section_id}, with_context = false) }}
</h5>
</div>

Expand Down
2 changes: 1 addition & 1 deletion components/headerlink.html.twig
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<a
href="{{ link(on) ~ (at ? ('#' ~ at)) }}"
href="{{ at ? (link(on)|split('#')|first ~ '#' ~ at) : link(on) }}"
class="ms-2 text-decoration-none text-body-secondary"
aria-label="Permalink"
title="Permalink"
Expand Down
6 changes: 4 additions & 2 deletions components/tags.html.twig
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
{% set node_link_parts = link(node)|split('#') %}
{% set section_id = (node_link_parts|length > 1 ? node_link_parts|last ~ '-' : '') ~ 'tags' %}
{% set tags = node.tags|filter((v, k) => k not in ['var', 'param', 'property', 'property-read', 'property-write', 'method', 'return', 'package', 'api', 'deprecated']) %}

{% if tags|length > 0 and tags|first|length > 0 %}
<section class="mt-4">
<div class="d-flex align-items-center gap-2 mb-3">
<i class="fas fa-tags text-info"></i>
<h5 class="h6 text-uppercase text-body-secondary fw-semibold mb-0" id="tags">
<h5 class="h6 text-uppercase text-body-secondary fw-semibold mb-0" id="{{ section_id }}">
Tags
{{ include('components/headerlink.html.twig', {'on': node, 'at': 'tags'}, with_context = false) }}
{{ include('components/headerlink.html.twig', {'on': node, 'at': section_id}, with_context = false) }}
</h5>
</div>

Expand Down
79 changes: 42 additions & 37 deletions graphs/class.html.twig
Original file line number Diff line number Diff line change
@@ -1,10 +1,52 @@
{% extends 'base.html.twig' %}

{% block stylesheets %}
{{ parent() }}
{% endblock %}

{% block javascripts %}
{{ parent() }}
<script src="https://unpkg.com/panzoom@8.7.3/dist/panzoom.min.js"></script>
<script>
(function () {
var element = document.getElementById('scene');
var container = document.getElementById('graph-container');

var instance = panzoom(element, {
smoothScroll: false,
maxZoom: 5,
minZoom: 0.2,
bounds: false
});

container.addEventListener('mousedown', function () {
container.style.cursor = 'grabbing';
});

container.addEventListener('mouseup', function () {
container.style.cursor = 'grab';
});

container.addEventListener('mouseleave', function () {
container.style.cursor = 'grab';
});

document.getElementById('zoom-in').addEventListener('click', function () {
instance.smoothZoom(0, 0, 1.2);
});

document.getElementById('zoom-out').addEventListener('click', function () {
instance.smoothZoom(0, 0, 0.8);
});

document.getElementById('reset-panzoom').addEventListener('click', function () {
// zoomTo cancels pending smoothZoom animation in Panzoom 8.x.
instance.zoomTo(0, 0, 1);
instance.moveTo(0, 0);
instance.zoomAbs(0, 0, 1);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
});
})();
</script>
{% endblock %}

{% block content %}
Expand Down Expand Up @@ -49,42 +91,5 @@
</div>
</section>

<script>
(function () {
var element = document.getElementById('scene');
var container = document.getElementById('graph-container');

var instance = panzoom(element, {
smoothScroll: false,
maxZoom: 5,
minZoom: 0.2,
bounds: false
});

container.addEventListener('mousedown', function () {
container.style.cursor = 'grabbing';
});

container.addEventListener('mouseup', function () {
container.style.cursor = 'grab';
});

container.addEventListener('mouseleave', function () {
container.style.cursor = 'grab';
});

document.getElementById('zoom-in').addEventListener('click', function () {
instance.smoothZoom(0, 0, 1.2);
});

document.getElementById('zoom-out').addEventListener('click', function () {
instance.smoothZoom(0, 0, 0.8);
});

document.getElementById('reset-panzoom').addEventListener('click', function () {
instance.moveTo(0, 0);
instance.zoomAbs(0, 0, 1);
});
})();
</script>
{% endblock %}
5 changes: 2 additions & 3 deletions js/search.js.twig
Original file line number Diff line number Diff line change
Expand Up @@ -170,10 +170,9 @@ var Search = (function () {
searchField.setAttribute('placeholder', 'Search (Press "/" to focus)');
searchField.removeAttribute('disabled');
searchField.addEventListener('keyup', debounce(search, 250));
searchField.addEventListener('focus', function () {
searchField.addEventListener('focus', function (event) {
if (searchField.value.trim()) {
searchResults.classList.remove('phpdocumentor-search-results--hidden');
searchResults.classList.add('show');
search(event);
}
});

Expand Down
8 changes: 8 additions & 0 deletions tests/fixture/src/Example.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,19 @@ final class Example
* echo $example->greet('Dash');
* ```
*
* @since 1.0.0
* @param string $name Reader name.
* @return string A personalized greeting.
*/
#[ExampleAttribute]
public function greet(string $name): string
{
return 'Hello, ' . $name;
}
/** @since 1.1.0 */
#[ExampleAttribute]
public function farewell(): string
{
return 'Goodbye';
}
}
10 changes: 10 additions & 0 deletions tests/fixture/src/ExampleAttribute.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

declare(strict_types=1);

namespace FastForward\Documentation;

#[\Attribute(\Attribute::TARGET_METHOD)]
final class ExampleAttribute
{
}
54 changes: 54 additions & 0 deletions tests/interactions.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const root = process.env.TEMPLATE_ROOT || path.join(__dirname, '..');
function element() {
const classes = new Set();
return {listeners: {}, classList: {add(...xs) {xs.forEach(x => classes.add(x));}, remove(...xs) {xs.forEach(x => classes.delete(x));}, contains(x) {return classes.has(x);}}, addEventListener(n, f) {this.listeners[n] = f;}, setAttribute() {}, removeAttribute() {}, contains() {return false;}, value: '', innerHTML: '', blur() {}};
}
const form = element(), field = element(), results = element(), entries = element(), close = element(), clear = element(), body = element();
const nodes = {'[data-search-form]': form, '[data-search-form] input[type="search"]': field, '[data-search-results]': results, '[data-search-results] .phpdocumentor-search-results__entries': entries, '.phpdocumentor-search-results__close': close, '[data-search-clear]': clear};
const documentListeners = {}, windowListeners = {};
const context = {document: {querySelector: s => nodes[s], body, addEventListener(name, callback) {documentListeners[name] = callback;}, createElement() {return {set textContent(v) {this.innerHTML = v;}};}}, window: {addEventListener(name, callback) {windowListeners[name] = callback;}}, Fuse: function () {this.search = value => value === 'missing' ? [] : [{name: 'Example', url: 'classes/Example.html'}];}, setTimeout(f) {f(); return 1;}, clearTimeout() {}};
vm.createContext(context);
vm.runInContext(fs.readFileSync(path.join(root, 'js/search.js.twig'), 'utf8'), context);
context.Search.init();
const event = {target: field, stopPropagation() {}, preventDefault() {}};
for (const query of ['Example', 'missing']) {
field.value = query;
field.listeners.keyup(event);
const original = entries.innerHTML;
assert.ok(original);
for (const dismiss of [() => close.listeners.click(event), () => documentListeners.click(event), () => windowListeners.keyup({...event, key: 'Escape'})]) {
dismiss();
assert.equal(entries.innerHTML, '');
field.listeners.focus(event);
assert.equal(entries.innerHTML, original, 'Reopening must regenerate unchanged-query results');
assert.ok(body.classList.contains('phpdocumentor-search--active-body'));
assert.ok(form.classList.contains('phpdocumentor-search--has-results'));
assert.ok(results.classList.contains('show'));
}
}
clear.listeners.click(event);
assert.equal(field.value, '');
assert.equal(entries.innerHTML, '');
assert.ok(!results.classList.contains('show'));
console.log('PASS search reopen, empty results, state and clear');
const graph = fs.readFileSync(path.join(root, 'graphs/class.html.twig'), 'utf8');
const scripts = graph.match(/{% block javascripts %}([\s\S]*?){% endblock %}/)[1];
assert.match(graph, /{% block stylesheets %}\s*{{ parent\(\) }}/);
assert.match(scripts, /{{ parent\(\) }}/);
assert.ok(scripts.indexOf('panzoom.min.js') < scripts.indexOf('var instance = panzoom'));
assert.ok(scripts.includes('var instance = panzoom'), 'Graph initialization must live after its footer dependency');
const graphNodes = {};
const calls = [];
let pendingZoom = null, scale = 1;
vm.runInNewContext(scripts.match(/<script>([\s\S]*?)<\/script>/)[1], {document: {getElementById(id) {return graphNodes[id] ||= {...element(), style: {}};}}, panzoom() {return {smoothZoom(...args) {calls.push(args); pendingZoom = () => {scale = args[2];};}, zoomTo(...args) {calls.push(args); pendingZoom = null;}, moveTo(...args) {calls.push(args);}, zoomAbs(...args) {calls.push(args); scale = args[2];}};}});
graphNodes['zoom-in'].listeners.click();
graphNodes['zoom-out'].listeners.click();
graphNodes['reset-panzoom'].listeners.click();
if (pendingZoom) pendingZoom();
assert.equal(scale, 1, 'Reset must cancel in-flight zoom animation');
assert.deepEqual(calls, [[0, 0, 1.2], [0, 0, 0.8], [0, 0, 1], [0, 0], [0, 0, 1]]);
console.log('PASS graph inherited assets, dependency ordering and controls');
20 changes: 19 additions & 1 deletion tests/verify-output.php
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,9 @@ function verify(string $output): void
}
$ids = [];
foreach ($xpath->query('//*[@id]') as $element) {
$ids[$element->getAttribute('id')] = true;
$id = $element->getAttribute('id');
requireValid(!isset($ids[$id]), "Duplicate ID: {$id} in {$source}");
$ids[$id] = true;
}
requireValid($xpath->query('//input[@type="search" and @aria-label="Search documentation"]')->length > 0, "Search label missing: {$source}");
requireValid(isset($ids['content']), "Reading target missing: {$source}");
Expand All @@ -117,6 +119,22 @@ function verify(string $output): void
requireValid(isset($ids[$target['fragment']]), "Reading anchor is missing: {$href} in {$source}");
}

foreach ($xpath->query('//a[@aria-label="Permalink"]') as $anchor) {
$href = $anchor->getAttribute('href');
requireValid(substr_count($href, '#') === 1, "Invalid permalink fragment: {$href}");
$target = localTarget($base['path'], $href);
requireValid($target !== null && $target['path'] === $source, "Permalink leaves page: {$href}");
requireValid(isset($ids[$target['fragment']]), "Permalink target missing: {$href}");
}

if (basename($source) === 'FastForward-Documentation-Example.html') {
foreach (['greet', 'farewell'] as $method) {
foreach (['tags', 'attributes'] as $section) {
requireValid(isset($ids["method_{$method}-{$section}"]), "Member section missing: {$method} {$section}");
}
}
}

$toggles = $xpath->query('//button[@data-reading-theme-toggle]');
requireValid($toggles->length === 1, "Theme toggle missing: {$source}");
$toggle = $toggles->item(0);
Expand Down
Loading