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
50 changes: 0 additions & 50 deletions AGENTS.md

This file was deleted.

21 changes: 21 additions & 0 deletions README.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion composer.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

42 changes: 42 additions & 0 deletions src/Rules/AbstractA11yRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

namespace TwigA11y\Rules;

use TwigA11y\Template\TemplateClassifier;
use TwigA11y\Template\TemplateKind;
use TwigCsFixer\Rules\AbstractRule;
use TwigCsFixer\Token\Token;
use TwigCsFixer\Token\Tokens;
Expand All @@ -12,8 +14,48 @@ abstract class AbstractA11yRule extends AbstractRule implements EvaluatableRuleI
{
use TokenCollectorTrait;

/** Cached decision for the currently-processed file when rules are reused */
private ?bool $skipThisFile = null;

// By default rules apply to all template kinds. Rules that should be
// limited to specific kinds can override supportedKinds().
/**
* @return TemplateKind[]
*/
protected function supportedKinds(): array
{
return TemplateKind::cases();
}

// Rules can opt to run only once per file (for page-level scans).
protected function evaluateOncePerFile(): bool
{
return false;
}

final protected function process(int $tokenIndex, Tokens $tokens): void
{
// On the first token, determine the template kind and record whether
// this rule applies to the file. This supports rule instances being
// reused across multiple files.
if (0 === $tokenIndex) {
$kind = TemplateClassifier::classify(
$this->getFullContent($tokens)
);

$this->skipThisFile = !in_array($kind, $this->supportedKinds(), true);
}

// If earlier we decided this rule doesn't apply to this file, skip.
if (true === $this->skipThisFile) {
return;
}

// If the rule only runs once per file, only evaluate at tokenIndex 0.
if ($this->evaluateOncePerFile() && 0 !== $tokenIndex) {
return;
}

$this->evaluate($tokens, $tokenIndex, $this->createEmitter());
}

Expand Down
25 changes: 18 additions & 7 deletions src/Rules/Structure/LandmarkRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace TwigA11y\Rules\Structure;

use TwigA11y\Rules\AbstractA11yRule;
use TwigA11y\Template\TemplateKind;
use TwigCsFixer\Token\Token;
use TwigCsFixer\Token\Tokens;

Expand All @@ -25,13 +26,10 @@ public function evaluate(Tokens $tokens, int $tokenIndex, callable $emit): void
return;
}

// This rule is page-level. Only evaluate once per file (at tokenIndex 0)
// and only if the content looks like a full HTML page (contains
// a <body> or a <!DOCTYPE). This avoids flagging fragments/partials.
if (0 !== $tokenIndex) {
return;
}

// This is a page-level rule: only run once per file and only on full
// pages. The AbstractA11yRule helper provides the once-per-file
// behaviour; here we just need to check the full-page heuristics and
// emit if missing.
$full = $this->getFullContent($tokens);

// If this looks like a fragment (no body/doctype), skip evaluation
Expand All @@ -47,4 +45,17 @@ public function evaluate(Tokens $tokens, int $tokenIndex, callable $emit): void
$first = $tokens->get(0);
$emit('Page should include a main landmark', $first, 'Landmark.MissingMain');
}

/**
* @return TemplateKind[]
*/
protected function supportedKinds(): array
{
return [TemplateKind::FullPage];
}

protected function evaluateOncePerFile(): bool
{
return true;
}
}
9 changes: 9 additions & 0 deletions src/Rules/Structure/LangAttributeRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace TwigA11y\Rules\Structure;

use TwigA11y\Rules\AbstractA11yRule;
use TwigA11y\Template\TemplateKind;
use TwigCsFixer\Token\Token;
use TwigCsFixer\Token\Tokens;

Expand All @@ -29,4 +30,12 @@ public function evaluate(Tokens $tokens, int $tokenIndex, callable $emit): void
$emit('The <html> element should have a lang attribute.', $token, 'LangAttribute.MissingLang');
}
}

/**
* @return TemplateKind[]
*/
protected function supportedKinds(): array
{
return [TemplateKind::FullPage];
}
}
14 changes: 14 additions & 0 deletions src/Rules/Structure/MetaViewportRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace TwigA11y\Rules\Structure;

use TwigA11y\Rules\AbstractA11yRule;
use TwigA11y\Template\TemplateKind;
use TwigCsFixer\Token\Token;
use TwigCsFixer\Token\Tokens;

Expand Down Expand Up @@ -36,4 +37,17 @@ public function evaluate(Tokens $tokens, int $tokenIndex, callable $emit): void
$emit('Avoid using user-scalable=no in the viewport meta.', $token, 'MetaViewport.UserScalable');
}
}

/**
* @return TemplateKind[]
*/
protected function supportedKinds(): array
{
return [TemplateKind::FullPage];
}

protected function evaluateOncePerFile(): bool
{
return true;
}
}
27 changes: 19 additions & 8 deletions src/Rules/Structure/SkipLinkRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace TwigA11y\Rules\Structure;

use TwigA11y\Rules\AbstractA11yRule;
use TwigA11y\Template\TemplateKind;
use TwigCsFixer\Token\Token;
use TwigCsFixer\Token\Tokens;

Expand All @@ -18,13 +19,9 @@ public function evaluate(Tokens $tokens, int $tokenIndex, callable $emit): void
return;
}

// Page-level rule: only run once (tokenIndex 0) and only for full
// pages (containing <body> or <!DOCTYPE). This avoids reporting on
// partials/components.
if (0 !== $tokenIndex) {
return;
}

// Only perform the page-level checks once per file; AbstractA11yRule
// will enforce the evaluateOncePerFile behaviour if needed. Here we
// simply run the detection logic and emit on the current token.
$content = $this->getFullContent($tokens);

if (!str_contains($content, '<body') && !str_contains(strtoupper($content), '<!DOCTYPE')) {
Expand All @@ -39,6 +36,20 @@ public function evaluate(Tokens $tokens, int $tokenIndex, callable $emit): void
return;
}

$emit('Page should include a skip link to bypass navigation', $token, 'SkipLink.Missing');
$first = $tokens->get(0);
$emit('Page should include a skip link to bypass navigation', $first, 'SkipLink.Missing');
}

/**
* @return TemplateKind[]
*/
protected function supportedKinds(): array
{
return [TemplateKind::FullPage];
}

protected function evaluateOncePerFile(): bool
{
return true;
}
}
39 changes: 39 additions & 0 deletions src/Template/TemplateClassifier.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

namespace TwigA11y\Template;

final class TemplateClassifier
{
public static function classify(string $content): TemplateKind
{
$hasExtends = str_contains($content, '{% extends');
$hasBlock = str_contains($content, '{% block');
$hasHtml = false !== stripos($content, '<html');
$hasBody = false !== stripos($content, '<body');
$hasProps = str_contains($content, '{% props');

if ($hasProps) {
return TemplateKind::TwigUxComponent;
}

if ($hasExtends && $hasBlock) {
return TemplateKind::MixedTemplate;
}

if ($hasExtends) {
return TemplateKind::ChildTemplate;
}

if ($hasBlock && !$hasHtml) {
return TemplateKind::ParentTemplate;
}

if ($hasHtml && $hasBody) {
return TemplateKind::FullPage;
}

return TemplateKind::Partial;
}
}
15 changes: 15 additions & 0 deletions src/Template/TemplateKind.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace TwigA11y\Template;

enum TemplateKind
{
case FullPage;
case ChildTemplate;
case Partial;
case ParentTemplate;
case MixedTemplate;
case TwigUxComponent;
}
7 changes: 5 additions & 2 deletions tests/Rules/Structure/MetaViewportRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ public function testRule(string $fixture, array $expectedErrors): void
/** @return iterable<string, array{0:string,1:array<null|string>}> */
public static function provideFixtures(): iterable
{
yield 'bad viewport' => [__DIR__.'/Fixtures/invalid/meta_viewport_bad.html.twig', ['MetaViewport.MetaViewport.UserScalable:1:1' => 'Avoid using user-scalable=no in the viewport meta.']];
// This fixture is a fragment (no <html>/<body>) — rule is page-level
// and should not emit on partials.
yield 'bad viewport' => [__DIR__.'/Fixtures/invalid/meta_viewport_bad.html.twig', []];

yield 'ok' => [__DIR__.'/Fixtures/valid/no_banned.html.twig', []];
}
Expand All @@ -33,6 +35,7 @@ public function testRuleWorksWhenTheSameInstanceIsReusedAcrossFiles(): void
$rule = new MetaViewportRule();

$this->checkRule($rule, [], __DIR__.'/Fixtures/valid/no_banned.html.twig');
$this->checkRule($rule, ['MetaViewport.MetaViewport.UserScalable:1:1' => 'Avoid using user-scalable=no in the viewport meta.'], __DIR__.'/Fixtures/invalid/meta_viewport_bad.html.twig');
// fragment should still not trigger when same instance is reused
$this->checkRule($rule, [], __DIR__.'/Fixtures/invalid/meta_viewport_bad.html.twig');
}
}
Loading
Loading