From df0362eb3ec9e0e20b4b69b31ab5324faff74f98 Mon Sep 17 00:00:00 2001 From: Jeroen De Dauw Date: Fri, 3 Jul 2026 18:29:39 +0200 Subject: [PATCH 1/2] Provide revision content to PagePropertyProviderContext Fixes https://github.com/ProfessionalWiki/NeoWiki/issues/979 PagePropertiesBuilder already loaded the revision's main slot content and parsed it to extract categories, but threw everything else away. Providers wanting to derive Page Properties from the content had to re-fetch and re-parse it themselves - and since the context carries no revision ID, they could only look up the latest revision of the page, which is redundant work and subtly racy. The context now additionally exposes: * `content` - the serialized main slot content, e.g. the wikitext * `contentModel` - e.g. "wikitext" * `parserProperties` - the MediaWiki page properties recorded while parsing the content (e.g. "defaultsort", or values set by parser hooks via ParserOutput::setPageProperty) Design notes: * The context is a Domain value object holding only scalars and arrays. Exposing RevisionRecord, Content, or ParserOutput directly would put MediaWiki core classes into the Domain layer and make provider tests heavyweight. The relevant data is flattened instead, following the approach already used for `categories`. * `parserProperties` covers the template-expansion-safe path: extensions whose parser hooks already record values during the parse can read them back in their provider instead of re-analyzing raw markup. The parse NeoWiki already performs for categories is reused, so no additional parse happens. * When the content is unavailable (e.g. suppressed), `content` and `contentModel` are empty strings and `parserProperties` is empty, matching the existing behavior of `categories`. Note: content-derived properties refresh only when a new revision is stored, so values coming from transcluded templates can go stale - the same existing limitation as categories. Co-Authored-By: Claude Fable 5 --- docs/reference/extending.md | 5 +- .../Page/PagePropertyProviderContext.php | 11 ++++ src/PagePropertiesBuilder.php | 29 ++++---- tests/phpunit/PagePropertiesBuilderTest.php | 66 +++++++++++++++++++ .../CorePagePropertyProviderTest.php | 3 + .../TestDoubles/SpyPagePropertyProvider.php | 27 ++++++++ 6 files changed, 124 insertions(+), 17 deletions(-) create mode 100644 tests/phpunit/PagePropertiesBuilderTest.php create mode 100644 tests/phpunit/TestDoubles/SpyPagePropertyProvider.php diff --git a/docs/reference/extending.md b/docs/reference/extending.md index 218e38a74..c7075cc21 100644 --- a/docs/reference/extending.md +++ b/docs/reference/extending.md @@ -87,7 +87,10 @@ class StaticPagePropertyProvider implements PagePropertyProvider { ``` Register with `NeoWikiRegistrar::addPagePropertyProvider()`. The context exposes the page id, title, -creation and modification times, categories, and last editor. Example: +creation and modification times, categories, and last editor. It also exposes the revision's main slot +content (plus its content model) and the properties recorded during parsing of that content (MediaWiki +page properties, e.g. those set by parser hooks via `ParserOutput::setPageProperty`), so providers can +derive Page Properties from the page content without re-fetching or re-parsing it. Example: [`src/StaticPagePropertyProvider.php`](https://github.com/ProfessionalWiki/NeoWiki/blob/master/tests/RedHerb/src/StaticPagePropertyProvider.php). ### Reading NeoWiki data and authorization diff --git a/src/Domain/Page/PagePropertyProviderContext.php b/src/Domain/Page/PagePropertyProviderContext.php index bb14488d7..a2877b8bc 100644 --- a/src/Domain/Page/PagePropertyProviderContext.php +++ b/src/Domain/Page/PagePropertyProviderContext.php @@ -13,6 +13,14 @@ * @param string $modificationTime In the standard MediaWiki format, ie 20230726163439 * @param string[] $categories * @param string $lastEditor Plain username of the last editor, e.g. "JohnDoe". Empty string if unknown. + * @param string $content Serialized main slot content of the revision, e.g. the wikitext. + * Empty string if the content is unavailable. + * @param string $contentModel Content model of the main slot, e.g. "wikitext". + * Empty string if the content is unavailable. + * @param array $parserProperties Properties recorded during parsing of the + * content (MediaWiki page properties, e.g. "defaultsort" or values set by parser hooks via + * ParserOutput::setPageProperty). These are inputs from the parse, not to be confused with the NeoWiki + * Page Properties that providers return. */ public function __construct( public PageId $pageId, @@ -22,6 +30,9 @@ public function __construct( public string $modificationTime, public array $categories, public string $lastEditor, + public string $content, + public string $contentModel, + public array $parserProperties, ) { } diff --git a/src/PagePropertiesBuilder.php b/src/PagePropertiesBuilder.php index f0c2a27c1..b24f4dd26 100644 --- a/src/PagePropertiesBuilder.php +++ b/src/PagePropertiesBuilder.php @@ -4,8 +4,10 @@ namespace ProfessionalWiki\NeoWiki; +use MediaWiki\Content\Content; use MediaWiki\Content\IContentHandlerFactory; use MediaWiki\Content\Renderer\ContentParseParams; +use MediaWiki\Parser\ParserOutput; use MediaWiki\Revision\RevisionRecord; use MediaWiki\Revision\RevisionStore; use MediaWiki\Revision\SlotRecord; @@ -40,6 +42,8 @@ public function getPagePropertiesFor( RevisionRecord $revision, ?UserIdentity $u private function buildContext( RevisionRecord $revision, ?UserIdentity $user ): PagePropertyProviderContext { $linkTarget = $revision->getPageAsLinkTarget(); + $content = $revision->getContent( SlotRecord::MAIN ); + $parserOutput = $content === null ? null : $this->parse( $content, $revision ); return new PagePropertyProviderContext( pageId: new PageId( $revision->getPageId() ), @@ -47,11 +51,19 @@ private function buildContext( RevisionRecord $revision, ?UserIdentity $user ): namespaceId: $linkTarget->getNamespace(), creationTime: $this->getCreationTime( $revision ), modificationTime: $this->getModificationTime( $revision ), - categories: $this->getCategories( $revision ), + categories: $parserOutput === null ? [] : $parserOutput->getCategoryNames(), lastEditor: $user?->getName() ?? '', + content: $content === null ? '' : $content->serialize(), + contentModel: $content === null ? '' : $content->getModel(), + parserProperties: $parserOutput === null ? [] : $parserOutput->getPageProperties(), ); } + private function parse( Content $content, RevisionRecord $revision ): ParserOutput { + return $this->contentHandlerFactory->getContentHandler( $content->getModel() ) + ->getParserOutput( $content, new ContentParseParams( $revision->getPage() ) ); + } + private function getCreationTime( RevisionRecord $revision ): string { $time = $this->revisionStore->getFirstRevision( $revision->getPage() )?->getTimestamp(); @@ -72,19 +84,4 @@ private function getModificationTime( RevisionRecord $revision ): string { return $time; } - /** - * @return string[] - */ - private function getCategories( RevisionRecord $revision ): array { - $content = $revision->getContent( SlotRecord::MAIN ); - - if ( $content === null ) { - return []; - } - - return $this->contentHandlerFactory->getContentHandler( $content->getModel() ) - ->getParserOutput( $content, new ContentParseParams( $revision->getPage() ) ) - ->getCategoryNames(); - } - } diff --git a/tests/phpunit/PagePropertiesBuilderTest.php b/tests/phpunit/PagePropertiesBuilderTest.php new file mode 100644 index 000000000..f1e18e332 --- /dev/null +++ b/tests/phpunit/PagePropertiesBuilderTest.php @@ -0,0 +1,66 @@ +getContextForNewPageWithContent( 'Some text [[Category:Cats]]' ); + + $this->assertSame( 'Some text [[Category:Cats]]', $context->content ); + } + + public function testProviderReceivesContentModel(): void { + $context = $this->getContextForNewPageWithContent( 'Whatever wikitext' ); + + $this->assertSame( CONTENT_MODEL_WIKITEXT, $context->contentModel ); + } + + public function testProviderReceivesParserRecordedProperties(): void { + $context = $this->getContextForNewPageWithContent( 'Sorted {{DEFAULTSORT:Zebra}}' ); + + $this->assertSame( 'Zebra', $context->parserProperties['defaultsort'] ); + } + + public function testProviderReceivesCategoriesFromParsedContent(): void { + $context = $this->getContextForNewPageWithContent( 'Some text [[Category:Cats]]' ); + + $this->assertSame( [ 'Cats' ], $context->categories ); + } + + private function getContextForNewPageWithContent( string $wikitext ): PagePropertyProviderContext { + $revision = $this->editPage( 'PagePropertiesBuilderTestPage', $wikitext )->getNewRevision(); + + $spy = new SpyPagePropertyProvider(); + + $this->newPagePropertiesBuilder( $spy )->getPagePropertiesFor( $revision, null ); + + return $spy->getReceivedContext(); + } + + private function newPagePropertiesBuilder( SpyPagePropertyProvider $provider ): PagePropertiesBuilder { + $registry = new PagePropertyProviderRegistry(); + $registry->addProvider( $provider ); + + $services = $this->getServiceContainer(); + + return new PagePropertiesBuilder( + revisionStore: $services->getRevisionStore(), + contentHandlerFactory: $services->getContentHandlerFactory(), + titleFormatter: $services->getTitleFormatter(), + providerRegistry: $registry, + ); + } + +} diff --git a/tests/phpunit/Persistence/CorePagePropertyProviderTest.php b/tests/phpunit/Persistence/CorePagePropertyProviderTest.php index 9b26a2116..4573ca393 100644 --- a/tests/phpunit/Persistence/CorePagePropertyProviderTest.php +++ b/tests/phpunit/Persistence/CorePagePropertyProviderTest.php @@ -82,6 +82,9 @@ private function getPropertiesForContext( modificationTime: $modificationTime, categories: $categories, lastEditor: $lastEditor, + content: 'Whatever content', + contentModel: 'wikitext', + parserProperties: [], ) ); } diff --git a/tests/phpunit/TestDoubles/SpyPagePropertyProvider.php b/tests/phpunit/TestDoubles/SpyPagePropertyProvider.php new file mode 100644 index 000000000..63951b573 --- /dev/null +++ b/tests/phpunit/TestDoubles/SpyPagePropertyProvider.php @@ -0,0 +1,27 @@ +receivedContext = $context; + return []; + } + + public function getReceivedContext(): PagePropertyProviderContext { + if ( $this->receivedContext === null ) { + throw new \LogicException( 'getProperties was not called' ); + } + + return $this->receivedContext; + } + +} From ec599b95b4a23f86978d73a438b50c807975d404 Mon Sep 17 00:00:00 2001 From: Jeroen De Dauw Date: Fri, 3 Jul 2026 19:20:42 +0200 Subject: [PATCH 2/2] Steer providers to parse products over raw content Follow-up polish on the extending.md guidance for the new context fields: recommend deriving Page Properties from the robust parse products (categories, parserProperties) and frame raw content/contentModel as the escape hatch for custom, non-wikitext content models rather than the default. Also carries the "parserProperties is a parse input, not the NeoWiki Page Properties you return" disambiguation into the docs, where provider authors actually read it. Co-Authored-By: Claude Fable 5 --- docs/reference/extending.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/reference/extending.md b/docs/reference/extending.md index c7075cc21..32f691071 100644 --- a/docs/reference/extending.md +++ b/docs/reference/extending.md @@ -87,10 +87,16 @@ class StaticPagePropertyProvider implements PagePropertyProvider { ``` Register with `NeoWikiRegistrar::addPagePropertyProvider()`. The context exposes the page id, title, -creation and modification times, categories, and last editor. It also exposes the revision's main slot -content (plus its content model) and the properties recorded during parsing of that content (MediaWiki -page properties, e.g. those set by parser hooks via `ParserOutput::setPageProperty`), so providers can -derive Page Properties from the page content without re-fetching or re-parsing it. Example: +creation and modification times, categories, and last editor, so providers can derive Page Properties +from the page content without re-fetching or re-parsing it. + +To derive Page Properties from the content, prefer the parse products: `categories`, and +`parserProperties` — the MediaWiki page properties recorded during parsing (e.g. those a parser hook +sets via `ParserOutput::setPageProperty`). These are template-expansion-safe and robust. (Note that +`parserProperties` are an input from MediaWiki's parse; they are not the NeoWiki Page Properties this +provider returns.) The raw main slot `content` and its `contentModel` are also exposed, but scraping +raw wikitext is fragile — reach for them mainly when handling a custom, non-wikitext content model that +the parse products do not cover. Example: [`src/StaticPagePropertyProvider.php`](https://github.com/ProfessionalWiki/NeoWiki/blob/master/tests/RedHerb/src/StaticPagePropertyProvider.php). ### Reading NeoWiki data and authorization