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
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,12 +133,14 @@ The recommended way to extend sitemaps uses the unified `Sitemap` extender with

```php
use FoF\Sitemap\Extend;
use Illuminate\Database\Eloquent\Builder;

return [
(new Extend\Sitemap())
->addResource(YourCustomResource::class)
->removeResource(\FoF\Sitemap\Resources\Tag::class)
->replaceResource(\FoF\Sitemap\Resources\User::class, YourCustomUserResource::class)
->modifyResourceQuery(\FoF\Sitemap\Resources\Discussion::class, fn (Builder $query) => $query->where('discussions.is_sticky', false))
->addStaticUrl('reviews.index')
->forceCached(),
];
Expand All @@ -148,9 +150,39 @@ return [
- **`addResource(string $resourceClass)`**: Add a custom resource to the sitemap
- **`removeResource(string $resourceClass)`**: Remove an existing resource from the sitemap
- **`replaceResource(string $oldResourceClass, string $newResourceClass)`**: Replace an existing resource
- **`modifyResourceQuery(string $resourceClass, callable|string $modifier)`**: Filter or adjust the query of an existing resource (see below)
- **`addStaticUrl(string $routeName)`**: Add a static URL by route name
- **`forceCached()`**: Force cached mode for managed hosting environments

#### Filtering Existing Resources

To leave items out of a built-in resource, use `modifyResourceQuery()` rather than `replaceResource()`. Modifiers from all extensions stack: every registered modifier runs, in registration order. `replaceResource()` swaps the class by exact name, so when two extensions replace or remove the same resource, only one of them takes effect.

```php
use FoF\Sitemap\Extend;
use FoF\Sitemap\Resources;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;

return [
(new Extend\Sitemap())
// Modify the Builder in place...
->modifyResourceQuery(Resources\Discussion::class, function (Builder $query) {
$query->whereNull('discussions.redirect_url');
})
// ...or pass the ::class of an invokable class, resolved from the container
->modifyResourceQuery(Resources\Tag::class, ExcludeInternalTags::class)
// Collection resources such as StaticUrls are filtered by returning a new Collection
->modifyResourceQuery(Resources\StaticUrls::class, fn (Collection $routes) => $routes->reject(fn ($route) => $route === 'tags')),
];
```

The modifier receives the resource's query (an Eloquent `Builder`, or a `Collection` for `StaticUrls`) and the resource instance. If it returns `null`, the query it was given is used. Any other return value replaces the query and must be a `Builder` or a `Collection`.

Things to keep in mind:
- A modifier applies to the resource class you name **and to its subclasses**, including a `replaceResource()` replacement that extends it. It does not apply to a replacement that extends `Resource` directly.
- Qualify column names (`discussions.id`, not `id`). When **Risky Performance Improvements** is enabled, the Discussion query selects only `id`, `slug`, `created_at` and `last_posted_at`. Adding `where` clauses is fine, but don't rely on other columns being loaded, and avoid joins, which make the unqualified `id` in that select ambiguous.

#### Creating Custom Resources

Create a class that extends `FoF\Sitemap\Resources\Resource`:
Expand Down
34 changes: 34 additions & 0 deletions src/Extend/Sitemap.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

use Flarum\Extend\ExtenderInterface;
use Flarum\Extension\Extension;
use Flarum\Foundation\ContainerUtil;
use FoF\Sitemap\Resources\Resource;
use FoF\Sitemap\Resources\StaticUrls;
use Illuminate\Contracts\Container\Container;
Expand All @@ -24,6 +25,7 @@ class Sitemap implements ExtenderInterface
private array $resourcesToAdd = [];
private array $resourcesToRemove = [];
private array $resourcesToReplace = [];
private array $queryModifiers = [];
private array $staticUrls = [];
private bool $forceCached = false;

Expand Down Expand Up @@ -74,6 +76,28 @@ public function replaceResource(string $oldResource, string $newResource): self
return $this;
}

/**
* Modify the query of a resource before the sitemap is built.
* Applies to the given resource class and all of its subclasses, including replacements registered
* via replaceResource() that extend it. Modifiers from all extensions stack and run in registration order.
*
* The modifier receives the result of Resource::query() (a Builder, or a Collection for StaticUrls) and the
* resource instance. A Builder can be modified in place; any non-null return value replaces the query.
* The modifier can be a callable or the ::class of an invokable class, which is resolved from the container.
*
* @param class-string<resource> $resourceClass
* @param callable|class-string $modifier
*
* @return self
*/
public function modifyResourceQuery(string $resourceClass, callable|string $modifier): self
{
$this->validateResource($resourceClass);
$this->queryModifiers[] = [$resourceClass, $modifier];

return $this;
}

/**
* Add a static URL to the sitemap. Specify the route name.
*
Expand Down Expand Up @@ -131,6 +155,16 @@ public function extend(Container $container, ?Extension $extension = null): void
});
}

if (!empty($this->queryModifiers)) {
$container->extend('fof-sitemaps.queryModifiers', function (array $modifiers) use ($container) {
foreach ($this->queryModifiers as [$resourceClass, $modifier]) {
$modifiers[] = [$resourceClass, ContainerUtil::wrapCallback($modifier, $container)];
}

return $modifiers;
});
}

// Register static URLs
foreach ($this->staticUrls as $routeName) {
StaticUrls::addRoute($routeName);
Expand Down
29 changes: 27 additions & 2 deletions src/Generate/Generator.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,15 @@
use Illuminate\Support\Collection;
use Symfony\Component\Console\Output\NullOutput;
use Symfony\Component\Console\Output\OutputInterface;
use UnexpectedValueException;

class Generator
{
public function __construct(
protected DeployInterface $deploy,
protected array $resources,
protected SettingsRepositoryInterface $settings
protected SettingsRepositoryInterface $settings,
protected array $queryModifiers = []
) {
}

Expand Down Expand Up @@ -88,7 +90,7 @@ public function loop(?OutputInterface $output = null): array
}

// Get query once and reuse
$query = $resource->query();
$query = $this->modifyQuery($resource, $resource->query());

// For Collections, check if empty immediately to avoid unnecessary processing
if ($query instanceof Collection && $query->isEmpty()) {
Expand Down Expand Up @@ -163,4 +165,27 @@ public function loop(?OutputInterface $output = null): array

return $remotes;
}

protected function modifyQuery(AbstractResource $resource, Builder|Collection $query): Builder|Collection
{
foreach ($this->queryModifiers as [$resourceClass, $modifier]) {
if (!$resource instanceof $resourceClass) {
continue;
}

$result = $modifier($query, $resource);

if ($result === null) {
continue;
}

if (!$result instanceof Builder && !$result instanceof Collection) {
throw new UnexpectedValueException('Sitemap query modifier for '.$resourceClass.' must return null, '.Builder::class.' or '.Collection::class.', got '.get_debug_type($result));
}

$query = $result;
}

return $query;
}
}
7 changes: 6 additions & 1 deletion src/Providers/Provider.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,16 @@ public function register()
];
});

$this->container->singleton('fof-sitemaps.queryModifiers', function () {
return [];
});

$this->container->singleton(Generator::class, function (Container $container) {
return new Generator(
$container->make(DeployInterface::class),
$container->make('fof-sitemaps.resources'),
$container->make(SettingsRepositoryInterface::class)
$container->make(SettingsRepositoryInterface::class),
$container->make('fof-sitemaps.queryModifiers')
);
});
}
Expand Down
29 changes: 29 additions & 0 deletions tests/integration/ExcludeDiscussionQueryModifier.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

/*
* This file is part of fof/sitemap.
*
* Copyright (c) FriendsOfFlarum.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
*/

namespace FoF\Sitemap\Tests\integration;

use Flarum\Settings\SettingsRepositoryInterface;
use Illuminate\Database\Eloquent\Builder;

class ExcludeDiscussionQueryModifier
{
public function __construct(
protected SettingsRepositoryInterface $settings
) {
}

public function __invoke(Builder $query): void
{
$query->where('discussions.id', '!=', (int) $this->settings->get('fof-sitemap-test.excluded_discussion_id'));
}
}
23 changes: 23 additions & 0 deletions tests/integration/XmlSitemapTestTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,29 @@ private function getUrlsFromSitemap(string $sitemapXml): array
return $urls;
}

/**
* Fetch the sitemap index and every set it lists, and return all URLs found in the sets.
*/
private function getAllSitemapUrls(): array
{
$indexResponse = $this->send($this->request('GET', '/sitemap.xml'));
$this->assertEquals(200, $indexResponse->getStatusCode());

$urls = [];

foreach ($this->getSitemapUrls($indexResponse->getBody()->getContents()) as $sitemapUrl) {
$sitemapResponse = $this->send($this->request('GET', parse_url($sitemapUrl, PHP_URL_PATH)));
$this->assertEquals(200, $sitemapResponse->getStatusCode());

$sitemapBody = $sitemapResponse->getBody()->getContents();
$this->assertValidSitemapXml($sitemapBody);

$urls = array_merge($urls, $this->getUrlsFromSitemap($sitemapBody));
}

return $urls;
}

private function assertValidSitemapIndexXml(string $xml): void
{
// Check if XML is well-formed
Expand Down
Loading
Loading