Skip to content
Open
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
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,17 @@ Follow clean code principles and SOLID design patterns when working with this co
- Write code that is easy to test and extend

### Testing Requirements
New functionality must be **thoroughly tested with both unit tests and functional tests where applicable**, and any feature with an admin UI or end-to-end flow must additionally be **verified using the Playwright MCP** (drive the running test application in a browser and observe the real behavior — do not rely on automated tests alone for UI flows).

- Write unit tests for all new functionality (if it makes sense)
- Write functional tests where applicable (PHPUnit `WebTestCase` against `tests/Application`, using `symfony/browser-kit`) for commands, controllers, and request/response behavior
- Follow the BDD-style naming convention for test methods (e.g., `it_should_do_something_when_condition_is_met`)
- **MUST use Prophecy for mocking** - Use the `ProphecyTrait` and `$this->prophesize()` for all mocks, NOT PHPUnit's `$this->createMock()`
- **Form testing** - Use Symfony's best practices for form testing as documented at https://symfony.com/doc/current/form/unit_testing.html
- Extend `Symfony\Component\Form\Test\TypeTestCase` for form type tests
- Use `$this->factory->create()` to create form instances
- Test form submission, validation, and data transformation
- **Browser verification (Playwright MCP)** - For admin UI / end-to-end features, run the test app (`symfony serve`) and use the Playwright MCP to exercise the real flow before considering the work done
- Ensure tests are isolated and don't depend on external state
- Test both happy path and edge cases

Expand Down
80 changes: 80 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ engines and social networks understand your catalog out of the box.
products.
- **🤖 robots.txt management** — edit a `robots.txt` per channel straight from the admin panel and
serve it at `/robots.txt`. The content is rendered as a Twig template, so it can be dynamic.
- **🩺 SEO checks** — define the pages you want to monitor in the admin, fetch them over
HTTP, and run them through a battery of generic checks (HTTP status, title, meta description, H1,
canonical, robots/noindex, Open Graph, JSON-LD, mixed content, …). Detected problems are listed in
an admin grid with severities and can be ignored. You can even define ad-hoc checks from the admin
(assert any CSS/XPath selector's content, including a JSON path into a JSON-LD block) — no code.
- **🧩 Extensible by design** — add or override any fact in the structured data with a tagged data
mapper, or hook into the dedicated events. Nothing is hardcoded.
- **🌍 Translations** — the admin UI ships with translations for 16 locales.
Expand Down Expand Up @@ -119,6 +124,47 @@ served at `/robots.txt` for that channel's domain.
> If a physical `public/robots.txt` file exists it is served by your web server and shadows this
> route. The admin form warns you when that is the case.

### SEO checks

This feature adds two database tables (`setono_sylius_seo__page` and `setono_sylius_seo__issue`).
The admin routes are registered automatically under `/admin` when you import the plugin routing
(installation step 3), and the tables are created by the migration you generate in installation
step 5 (`doctrine:migrations:diff` + `migrate`).

Then, in the admin:

- Go to **Admin → SEO → Pages to test** and add the pages you care about. A page points at a Sylius
route via a *page type* (`Homepage`, `Product`, `Taxon` or a custom route); for the dynamic types
you can pick a representative product/taxon code, or leave it empty to auto-pick one. Tick the
checks that should run on each page.
- Run the checks — per page with the **Run checks** row action, or all at once on the command line:

```shell
bin/console setono:sylius-seo:detect-issues # all enabled pages
bin/console setono:sylius-seo:detect-issues --channel=FASHION_WEB
bin/console setono:sylius-seo:detect-issues --page=42
```

- Review the results under **Admin → SEO → Issues**, filter by severity/status/check, and **ignore**
the ones you don't care about. Ignored issues stay ignored across re-runs, and issues that are no
longer detected are automatically marked as *resolved*.

Pages are fetched over real HTTP using each channel's hostname. On the command line there is no
incoming request, so the scheme/host/port used to build the URLs come from the framework's
[`framework.router.default_uri`](https://symfony.com/doc/current/routing.html#generating-urls-in-commands).
When testing against a local server, set it to the URL your app is served on (for example the one
`symfony serve` exposes via the `SYMFONY_DEFAULT_ROUTE_URL` environment variable).

#### Ad-hoc checks (no code)

Two built-in checks let you assert almost anything from the admin, without writing PHP:

- **Element content** — select an element with a CSS or XPath selector, optionally read an attribute
or extract a value with a JSON path, then assert it `contains` / `equals` / `matches` (regex) /
`exists` / is `absent`. For example, to assert the price inside a JSON-LD block:
selector `script#seo-ggg`, JSON path `$.offers[0].price`, assertion `equals`, value `19.99`.
- **Element exists** — assert that the number of elements matching a selector is within a range.

## Configuration

All structured data features are enabled by default. The full configuration with its defaults:
Expand Down Expand Up @@ -190,6 +236,40 @@ to extend or replace the generated data:
- `Setono\SyliusSEOPlugin\Event\ProductGroupAddedToGraph`
- `Setono\SyliusSEOPlugin\Event\WebsiteAddedToGraph`

### Add a custom SEO check

Implement `IssueDetectorInterface` — it is autoconfigured, so tagging is optional. The detector
receives a neutral `Inspection` (URL, HTTP status, all response headers, raw body and the lazily
parsed DOM) and yields `DetectedIssue` value objects. Once registered, your check becomes selectable
on every page in the admin (its label/description come from the
`setono_sylius_seo.check.<code>.{label,description}` translation keys).

```php
use Setono\SyliusSEOPlugin\Checker\DetectedIssue;
use Setono\SyliusSEOPlugin\Checker\Detector\IssueDetectorInterface;
use Setono\SyliusSEOPlugin\Checker\Inspection;
use Setono\SyliusSEOPlugin\Model\Severity;

final class FaviconDetector implements IssueDetectorInterface
{
public function getCode(): string
{
return 'favicon';
}

public function detect(Inspection $inspection, array $config = []): iterable
{
$crawler = $inspection->getCrawler();
if (null !== $crawler && 0 === $crawler->filter('link[rel~="icon"]')->count()) {
yield new DetectedIssue($this->getCode(), Severity::Notice, 'app.issue.favicon');
}
}
}
```

To make the check configurable from the admin, implement `ConfigurableIssueDetectorInterface` instead
and return the Symfony form type used to edit its configuration from `getConfigFormType()`.

## Contributing

```shell
Expand Down
7 changes: 5 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,24 @@
"psr/event-dispatcher": "^1.0",
"psr/log": "^1.1 || ^2.0 || ^3.0",
"setono/composite-compiler-pass": "^1.2",
"softcreatr/jsonpath": "^0.10.0",
"spatie/schema-org": "^3.23.1",
"sylius/channel": "^2.0",
"sylius/channel-bundle": "^2.0",
"sylius/core": "^2.0",
"sylius/core-bundle": "^2.0",
"sylius/grid-bundle": "^1.15",
"sylius/inventory": "^2.0",
"sylius/locale": "^2.0",
"sylius/product": "^2.0",
"sylius/resource-bundle": "^1.12",
"symfony/config": "^6.4 || ^7.4",
"symfony/css-selector": "^6.4 || ^7.4",
"symfony/dependency-injection": "^6.4 || ^7.4",
"symfony/dom-crawler": "^6.4 || ^7.4",
"symfony/event-dispatcher": "^6.4 || ^7.4",
"symfony/form": "^6.4 || ^7.4",
"symfony/http-client": "^6.4 || ^7.4",
"symfony/http-foundation": "^6.4 || ^7.4",
"symfony/http-kernel": "^6.4 || ^7.4",
"symfony/routing": "^6.4 || ^7.4",
Expand All @@ -39,11 +44,9 @@
"payum/core": "^1.7.5",
"phpunit/phpunit": "^10.5",
"setono/sylius-plugin": "^2.0",
"sylius/grid-bundle": "^1.15",
"sylius/sylius": "~2.2.5",
"symfony/browser-kit": "^6.4 || ^7.4",
"symfony/debug-bundle": "^6.4 || ^7.4",
"symfony/dom-crawler": "^6.4 || ^7.4",
"symfony/dotenv": "^6.4 || ^7.4",
"symfony/intl": "^6.4 || ^7.4",
"symfony/property-info": "^6.4 || ^7.4",
Expand Down
4 changes: 4 additions & 0 deletions config/routes.yaml
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
setono_sylius_seo_global:
resource: "@SetonoSyliusSEOPlugin/config/routes/global.yaml"

setono_sylius_seo_admin:
resource: "@SetonoSyliusSEOPlugin/config/routes/admin.yaml"
prefix: /admin
46 changes: 46 additions & 0 deletions config/routes/admin.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
setono_sylius_seo_admin_page:
resource: |
alias: setono_sylius_seo.page
section: admin
templates: "@SyliusAdmin\\shared\\crud"
except: ['show']
redirect: update
grid: setono_sylius_seo_admin_page
form:
type: Setono\SyliusSEOPlugin\Form\Type\PageType
permission: true
type: sylius.resource

setono_sylius_seo_admin_issue:
resource: |
alias: setono_sylius_seo.issue
section: admin
templates: "@SyliusAdmin\\shared\\crud"
except: ['create', 'show', 'update']
grid: setono_sylius_seo_admin_issue
permission: true
type: sylius.resource

setono_sylius_seo_admin_page_run:
path: /seo-pages/{id}/run
methods: [GET]
defaults:
_controller: Setono\SyliusSEOPlugin\Controller\Admin\RunPageChecksAction

setono_sylius_seo_admin_run_all:
path: /seo-pages/run-all
methods: [GET]
defaults:
_controller: Setono\SyliusSEOPlugin\Controller\Admin\RunAllChecksAction

setono_sylius_seo_admin_issue_ignore:
path: /seo-issues/{id}/ignore
methods: [GET]
defaults:
_controller: Setono\SyliusSEOPlugin\Controller\Admin\IgnoreIssueAction

setono_sylius_seo_admin_issue_unignore:
path: /seo-issues/{id}/unignore
methods: [GET]
defaults:
_controller: Setono\SyliusSEOPlugin\Controller\Admin\UnignoreIssueAction
Loading
Loading