From 979219ae4ad8cef6ac396aa668359cda61c7e399 Mon Sep 17 00:00:00 2001 From: DanMat Date: Tue, 25 Aug 2026 07:40:34 -0400 Subject: [PATCH] Add a versionless contributor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every public page now advertises the CMS with a standard ``. Constant and deliberately version-less: it carries no version string (fingerprinting surface for no benefit) and no PageContext value (which would be an escaping and, for agent-facing metadata, a prompt-injection sink) — there is nothing dynamic to escape. Reviewed by both Fable skills before build. The review split the original "agent discoverability" idea: this real, shippable sliver ships here; advertising the MCP/agent surface moves to a core-served /llms.txt (a plugin can't serve a root route, and the endpoint is a core fact) — and all three speculative head-advertisement forms (custom link rels, a JSON discovery block, a schema.org potentialAction) were rejected because no agent consumes them today. Tests: emitted on every page kind; constant + no digit (version) ever; a hostile title/site name can't affect the static tag. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 5 +++ README.md | 10 ++++++ src/GeneratorContributor.php | 30 ++++++++++++++++++ src/SeoPlugin.php | 10 +++--- tests/GeneratorContributorTest.php | 49 ++++++++++++++++++++++++++++++ 5 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 src/GeneratorContributor.php create mode 100644 tests/GeneratorContributorTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 97c27fb..189eabb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- A `` 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 diff --git a/README.md b/README.md index f7a2373..d6f2567 100644 --- a/README.md +++ b/README.md @@ -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 `` — +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 `` can never break out of the script element. +> Advertising the CMS's agent/MCP control surface is **not** done in the page +> ``: 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 diff --git a/src/GeneratorContributor.php b/src/GeneratorContributor.php new file mode 100644 index 0000000..7c370b8 --- /dev/null +++ b/src/GeneratorContributor.php @@ -0,0 +1,30 @@ +` 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 ''; + } +} diff --git a/src/SeoPlugin.php b/src/SeoPlugin.php index d363c5f..a1987bf 100644 --- a/src/SeoPlugin.php +++ b/src/SeoPlugin.php @@ -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 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 `` for public pages. No routes, no admin + * UI, no migrations. It exists to prove that a plugin can enrich the rendered + * using only the data-only PageContext core hands it — never a repository + * or the database. */ final class SeoPlugin implements Plugin { @@ -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()); } } diff --git a/tests/GeneratorContributorTest.php b/tests/GeneratorContributorTest.php new file mode 100644 index 0000000..9a048ff --- /dev/null +++ b/tests/GeneratorContributorTest.php @@ -0,0 +1,49 @@ +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('', $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', + '">', + '', + 'AAAAAAAAAAAAAAAAAAAAAA==', + )); + self::assertSame('', $html); + self::assertStringNotContainsString('