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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- A `<meta name="generator" content="NimbusCMS">` on every public page — a versionless CMS-identification tag (real, widely-consumed convention). Deliberately carries no version string and no PageContext value, so it is neither a fingerprinting nor an escaping/injection surface. (Advertising the MCP/agent surface is a core `/llms.txt` concern, not per-page head markup — no agent convention consumes head-level MCP hints today.)


### Added

- Initial release: a head contributor emitting schema.org JSON-LD for public
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,20 @@ block, built entirely from the page data Nimbus hands the plugin:
| the home page | `WebSite` |
| a collection index | `CollectionPage` |

Every public page also gains a `<meta name="generator" content="NimbusCMS">` —
the standard CMS-identification tag. It is deliberately **version-less** (a version
would be fingerprinting surface for no benefit) and carries no page data, so it is
neither an escaping nor a fingerprinting concern.

No configuration. No database access — the plugin only ever sees the prepared
view-model of the page being rendered. Output is encoded so a value containing
`</script>` can never break out of the script element.

> Advertising the CMS's agent/MCP control surface is **not** done in the page
> `<head>`: no agent convention consumes head-level MCP hints today, and the
> endpoint is a core fact. That belongs in a core-served `/llms.txt`, alongside
> `robots.txt`.

## Install

```bash
Expand Down
30 changes: 30 additions & 0 deletions src/GeneratorContributor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace NimbusCMS\Seo;

use Nimbus\Site\HeadContributor;
use Nimbus\Site\PageContext;

/**
* Emits a `<meta name="generator">` identifying the CMS on every public page — a
* real, widely-consumed convention (CMS detectors, ecosystem stats).
*
* **Constant, and deliberately version-less.** The tag carries the fixed string
* "NimbusCMS" and nothing else: no version (which would be fingerprinting surface
* for no benefit) and no value from the PageContext (which would be an escaping
* and, for agent-facing metadata, a prompt-injection sink). There is nothing to
* escape because there is nothing dynamic to emit.
*
* Advertising the CMS's agent/MCP control surface is deliberately NOT done here:
* per-page head markup has no consuming agent convention today, and the endpoint
* is a core fact. That belongs in a core-served `/llms.txt`, beside robots.txt.
*/
final class GeneratorContributor implements HeadContributor
{
public function head(PageContext $page): string
{
return '<meta name="generator" content="NimbusCMS">';
}
}
10 changes: 6 additions & 4 deletions src/SeoPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@
* The official SEO plugin — and the reference implementation of the head
* contribution capability (Nimbus ADR 0004).
*
* Deliberately small: it registers one head contributor that emits schema.org
* JSON-LD for public pages. No routes, no admin UI, no migrations. It exists to
* prove that a plugin can enrich the rendered <head> using only the data-only
* PageContext core hands it — never a repository or the database.
* Deliberately small: it registers head contributors that emit schema.org
* JSON-LD and a `<meta name="generator">` for public pages. No routes, no admin
* UI, no migrations. It exists to prove that a plugin can enrich the rendered
* <head> using only the data-only PageContext core hands it — never a repository
* or the database.
*/
final class SeoPlugin implements Plugin
{
Expand All @@ -24,5 +25,6 @@ final class SeoPlugin implements Plugin
public function register(PluginContext $context): void
{
$context->head()->register(new JsonLdContributor());
$context->head()->register(new GeneratorContributor());
}
}
49 changes: 49 additions & 0 deletions tests/GeneratorContributorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

declare(strict_types=1);

namespace NimbusCMS\Seo\Tests;

use Nimbus\Site\PageContext;
use NimbusCMS\Seo\GeneratorContributor;
use PHPUnit\Framework\TestCase;

final class GeneratorContributorTest extends TestCase
{
private GeneratorContributor $contributor;

protected function setUp(): void
{
$this->contributor = new GeneratorContributor();
}

public function test_it_emits_a_versionless_generator_meta_on_every_page_kind(): void
{
foreach (['home', 'entry', 'collection'] as $kind) {
$html = $this->contributor->head(new PageContext($kind, 'https://example.test/', 'Title', 'My Site', 'AAAAAAAAAAAAAAAAAAAAAA=='));
self::assertSame('<meta name="generator" content="NimbusCMS">', $html);
}
}

public function test_the_tag_is_constant_and_carries_no_version(): void
{
$html = $this->contributor->head(new PageContext('home', 'https://example.test/', 'Home', 'My Site', 'AAAAAAAAAAAAAAAAAAAAAA=='));
// No version string, and no digit that could be one, ever leaks.
self::assertDoesNotMatchRegularExpression('/\d/', $html);
}

public function test_a_hostile_title_or_site_name_cannot_affect_the_static_tag(): void
{
// The tag is constants-only, so no PageContext value reaches it — an
// injection payload in the title or site name is simply never rendered.
$html = $this->contributor->head(new PageContext(
'entry',
'https://example.test/x',
'"><script>alert(1)</script>',
'</head><script>evil()</script>',
'AAAAAAAAAAAAAAAAAAAAAA==',
));
self::assertSame('<meta name="generator" content="NimbusCMS">', $html);
self::assertStringNotContainsString('<script>', $html);
}
}
Loading