| title | Extending NeoWiki |
|---|---|
| order | 1 |
NeoWiki exposes extension points so other MediaWiki extensions can add custom Property Types, contribute page metadata to the graph, and reuse NeoWiki's UI.
NeoWiki concepts referenced here — Subject, Schema, Property Type, Page Property — are defined in the Glossary.
RedHerb is a minimal, test-backed example extension shipped in the NeoWiki repository. NeoWiki's own tests exercise it, so its examples stay working. Each extension point below links to the RedHerb file that demonstrates it, so the fastest start is to copy the relevant RedHerb file and adapt it.
NeoWiki is pre-1.0. Every extension point on this page is alpha and may change without notice until 1.0.
An extension that builds on NeoWiki declares the dependency in its extension.json:
"requires": {
"extensions": {
"NeoWiki": "*"
}
}Most backend extension points are registered through the NeoWikiRegistration hook, which hands you a
NeoWikiRegistrar:
"Hooks": {
"NeoWikiRegistration": "ProfessionalWiki\\MyExt\\MyExtHooks::onNeoWikiRegistration"
}public static function onNeoWikiRegistration( NeoWikiRegistrar $registrar ): void {
$registrar->addPropertyType( new ColorType() );
$registrar->addPagePropertyProvider( new StaticPagePropertyProvider() );
}Full example: src/RedHerbHooks.php.
Registering a Property Type or View Type under a name already in use — a built-in's or another extension's — replaces the earlier registration; the last registration wins, on both the backend and the frontend.
A Property Type defines a kind of structured value — its Value Type, validation, and Display Attributes. Implement
the PropertyType interface, paired with a class extending PropertyDefinition that holds the type-specific
definition fields, and register it with NeoWikiRegistrar::addPropertyType() (see "Getting started" above). The
linked example shows the methods to implement.
Example: src/ColorType.php
(implements PropertyType) and
src/ColorProperty.php
(extends PropertyDefinition).
To project your Property Type's values into Neo4j, register a builder that converts the Value to Neo4j scalars, keyed by the Property Type name:
$registrar->addNeo4jValueBuilder( ColorType::NAME, static fn ( $value ) => $value->toScalars() );For the RDF export, register a mapper keyed by the Property Type name with
NeoWikiRegistrar::addRdfValueMapper(). It receives the Statement's NeoValue and returns a list of RDF terms —
Literals, or Iris for values that denote a resource, as the built-in url mapper does — one per value part.
Guard the value shape, since the mapper is called for whatever a Statement holds. RedHerb's
RedHerbHooks.php
registers a guarded mapper for its color type.
Without a mapper, a Property Type's Statements are omitted from the RDF export, just as they are from the Neo4j projection.
Page Property Providers contribute key/value metadata to the Page node in the graph (queryable via Cypher;
Neo4j is currently the only graph backend). Providers run for every page that is saved or rebuilt, whether or
not it holds Subjects. Implement PagePropertyProvider:
class StaticPagePropertyProvider implements PagePropertyProvider {
public function getProperties( PagePropertyProviderContext $context ): array {
return [ 'myext_reviewState' => 'approved' ];
}
}Register with NeoWikiRegistrar::addPagePropertyProvider(). Keys are merged across all providers into one
key/value map, with the last-registered provider winning on a key collision, so namespace your keys (e.g. with an
extension prefix). The context exposes the page id, title and namespace, creation and modification times,
categories, and last editor, plus the revision's main slot content, so providers can derive Page Properties from
the 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.
Show a message before a user edits a Subject.
class ApprovalEditNoticeProvider implements SubjectEditNoticeProvider {
public function __construct(
private readonly MessageLocalizer $messageLocalizer
) {
}
public function getNotices( SubjectEditNoticeContext $context ): array {
return [ new SubjectEditNotice(
key: 'myext-approval',
html: $this->messageLocalizer->msg( 'myext-approval-notice' )->parse()
) ];
}
}Register with NeoWikiRegistrar::addSubjectEditNoticeProvider(). Providers run in registration order, after the
notices wiki admins write as interface messages, and only for pages the requesting user may read.
SubjectEditNoticeContext exposes $pageId, $pageDbKey, $namespaceId, and $schemaName when a Schema is
known.
Unlike an admin's wikitext, which MediaWiki's parser sanitizes, provider html is inserted as given: escape it
yourself.
Namespace your keys to your extension. A key reaches the browser as a styling handle, and the first provider to claim one keeps it. See Edit notices for the keys admins use.
A page's graph data is written on edit and on full rebuild. When data your extension contributes through a
PagePropertyProvider changes outside an edit — for example an approval extension marking a revision approved —
the graph keeps the old value until the page is next saved. Trigger a refresh on demand:
$outcome = NeoWikiExtension::getInstance()
->newPageRebuilder()
->rebuild( $title );NeoWiki re-runs every registered PagePropertyProvider for the page (and re-reads its subject slot) and updates
the Page node. No new revision is created. rebuild() returns a PageRefreshOutcome:
Refreshed— the Page node was updated.SkippedMissingRevision— the page has no current revision.SkippedUnreadableSubjects— the page's subject slot holds content NeoWiki cannot read as Subjects.SkippedUnreadablePageProperties— the page's properties could not be built, for instance because a provider or the page's own parse threw.
A graph store that fails is logged and skipped, exactly as on a normal page save; only request timeouts and wiki-database errors throw.
NeoWiki currently supports Neo4j only, but the graph projection is an extension point: implement
GraphDatabasePlugin and NeoWiki keeps your store in sync alongside Neo4j.
class MyGraphDatabasePlugin implements GraphDatabasePlugin {
public function initialize(): void {
// Create any store-level structures your backend needs (e.g. constraints or indexes).
}
public function savePage( Page $page ): void {
// Project the page and its subjects into your store.
}
public function deletePage( PageId $pageId ): void {
// Remove the page from your store.
}
}Register with NeoWikiRegistrar::addGraphDatabasePlugin( $name, $plugin ). Example:
src/RedHerbGraphDatabasePlugin.php.
The name is what --store addresses, and what a rebuild files
its run records under. Pick a stable one and namespace it to your extension. A name is refused with a warning on the
NeoWiki channel when another backend already holds it, when it is neo4j in any casing — reserved for the bundled
Neo4j backend — or when it is longer than 255 bytes, which is all a run record can hold. A refused backend receives
no page changes and cannot be rebuilt.
savePage hands you the page with all of its Subjects and the Page Properties contributed by every
PagePropertyProvider, and runs for every revision, so subject edits, undeletions and page moves all reach you as a
save. deletePage gets only the page id.
initialize runs on update.php, at the start of a RebuildGraphDatabases run of your store before any page is
projected, and once per batch of a rebuild started from the wiki — create the store-level structures a fresh store
needs there. Make it idempotent and cheap, since every path calls it every time; it never runs on an individual edit.
A rebuild also calls it to ask whether your store is still there when a whole batch of pages has failed.
Signal failure by throwing. On an edit, delete or undelete, NeoWiki logs the failure and lets the user's
operation commit, so a backend being down never blocks the wiki or starves the other backends — your projection is
simply out of sync until the store is rebuilt. During a rebuild, failures reach the rebuild instead: a page you refuse
is logged and counted and the rebuild carries on, while an initialize throw ends the run — before a page is read
when it opens the store for the run, and at whichever batch it happens on otherwise. On update.php a failing initialize is reported and the update carries on, though the backends
registered after yours do not initialize on that run.
Make deletePage idempotent: the rebuild re-issues a delete for every page MediaWiki no longer has, so it will ask
you to remove pages that are already gone from your store.
NeoWikiExtension::getInstance() exposes read-side services usable from any MediaWiki extension point
(hooks, special pages):
newSubjectPermissionHints( Authority )— side-effect-free subject permission checks, for showing or hiding affordances. A positive answer is a hint, not authorization to write.newPageSubjectsLookup()— look up the subjects on a page.newSubjectContentRepository()— read Subject data by id.newFrontendModuleLoader()— mount NeoWiki's UI on any page.
Examples: src/RedHerbSidebarHook.php
and src/Specials/SpecialRedHerbSubjectFinder.php.
To run a read-only Cypher query from PHP, use NeoWikiExtension::getInstance()->newCypherQueryService().
It rejects write queries, enforces the timeout against the backend, and truncates results to the row cap;
resolve the limits configured in $wgNeoWikiQueryLimits with
Neo4jQueryLimits::forUser():
$result = NeoWikiExtension::getInstance()->newCypherQueryService()->execute( new Neo4jQueryRequest(
cypher: 'MATCH (s:Subject:Person) WHERE s.`Birth year` > $minYear RETURN s.name AS name',
parameters: [ 'minYear' => 2000 ],
limits: Neo4jQueryLimits::forUser( $this->getUser() ),
) );execute() returns a Neo4jQueryResult (columns, rows, truncation flag) and throws a QueryException
subclass on failure; newCypherQueryService() itself throws a LogicException on a wiki with no Neo4j
backend configured.
The User in forUser() only sizes the limits: how heavy a single query may be, not whether the user may
query at all or how often. When running user-supplied queries, check the neowiki-query right and rate
limit yourself, as the Query API endpoint does.
Two sharp edges: the write check is a keyword check plus EXPLAIN, and the keyword check also rejects CALL and
SHOW, even for read-only procedures (see the parser function notes). And the
row cap truncates the result only after the query has run in full, so bound expensive queries with LIMIT in the
Cypher itself.
Example: src/Specials/SpecialRedHerbContentPageCount.php.
NeoWiki's frontend is built with TypeScript and Vue. Extensions consume it as plain JavaScript and need no build step. You can also author in TypeScript with types; see "Authoring in TypeScript" below.
Getting your JavaScript onto NeoWiki pages takes two steps. First, declare a ResourceLoader module that depends
on ext.neowiki, which makes require( 'ext.neowiki' ) available:
"ResourceModules": {
"ext.myext": {
"class": "MediaWiki\\ResourceLoader\\CodexModule",
"dependencies": [ "vue", "ext.neowiki" ],
"packageFiles": [ "init.js" ]
}
}Then load that module alongside NeoWiki's UI by handling the NeoWikiGetFrontendModules hook:
class MyExtFrontendModulesHook implements NeoWikiGetFrontendModulesHook {
public function onNeoWikiGetFrontendModules( array &$modules, OutputPage $out, Skin $skin ): void {
$modules[] = 'ext.myext';
}
}Example: src/RedHerbFrontendModulesHook.php.
A backend Property Type needs a matching frontend: a display component, an input component, and an
attributes editor. Register them through the neowiki.registration JS hook:
const nw = require( 'ext.neowiki' );
mw.hook( 'neowiki.registration' ).add( ( registrar ) => {
registrar.registerPropertyType( {
typeName: 'color',
valueType: nw.ValueType.String,
displayAttributeNames: [],
createPropertyDefinitionFromJson: function ( base, json ) {
return Object.assign( {}, base, {
allowedColors: Array.isArray( json.allowedColors ) ? json.allowedColors : []
} );
},
getExampleValue: function () {
return nw.newStringValue( '#ff5733' );
},
displayComponent: ColorDisplay,
inputComponent: ColorInput,
attributesEditor: ColorAttributesEditor,
label: 'myext-property-type-color',
icon: icons.cdxIconHighlight
} );
} );The registration object's shape is defined by
PropertyTypeRegistration.ts;
every field is required, including attributesEditor even for a type with no configurable attributes. The
typeName must equal the backend PropertyType::getTypeName(). The display, input, and attributes-editor
components conform to NeoWiki's component prop shapes — see
ValueDisplayContract.ts,
ValueInputContract.ts,
and AttributesEditorContract.ts.
Full example: resources/init.js
with ColorDisplay.vue,
ColorInput.vue,
and ColorAttributesEditor.vue.
A View Type renders a Subject in a particular visual format; infobox is the only built-in one. Register a Vue
component for a new View Type through the same neowiki.registration hook, at parity with Property Types:
const nw = require( 'ext.neowiki' );
const RedHerbCard = require( './RedHerbCard.vue' );
mw.hook( 'neowiki.registration' ).add( ( registrar ) => {
registrar.registerViewType( {
typeName: 'redherb-card',
component: RedHerbCard
} );
} );The registration object's shape is defined by
ViewTypeRegistration.ts:
a typeName and the Vue component that renders it. The component conforms to the ViewProps prop shape
(ViewContract.ts):
the subjectId to render, a canEditSubject flag, and an optional layoutName. Resolve any Layout-specific
configuration (Display Rules and Settings) from the layout store using layoutName. Once registered, the
typeName becomes selectable as a Layout's View Type, and a {{#view}} (or Main Subject) placeholder that
references it renders through your component instead of the built-in infobox.
The redherb-card example reuses NeoWiki's own building blocks rather than rendering values by hand: the subject,
schema, and layout stores for display; nw.resolveDisplayProperties together with the value-display component
registry to render each value through its Property Type's component; and the shared nw.SubjectEditorDialog for
editing when canEditSubject is true. Editing reads go through the repositories your component injects
(nw.NeoWikiServices.getSubjectRepository(), getSchemaRepository()), not the stores, and reach the dialog as
props. Saving updates the stores on its own: a Subject write answers with the Subject as persisted and the Schema
it instantiates, and nw.useSubjectStore() records both.
Full example: resources/init.js
with RedHerbCard.vue.
require( 'ext.neowiki' ) returns NeoWiki's public API barrel; its exports are listed in
public-api.ts.
The value model and factories (newStringValue, newNumberValue) live in
domain/Value.ts;
value shape varies by valueType.
To build a Vue feature wired to NeoWiki's services, obtain NeoWiki's Pinia instance and register its services on your app:
const nw = require( 'ext.neowiki' );
const app = Vue.createMwApp( MyComponent );
app.use( nw.NeoWikiExtension.getInstance().getPinia() );
nw.NeoWikiServices.registerServices( app );
app.mount( '#my-mount-point' );Examples: resources/createChild/,
resources/editMainSubject/,
and resources/subjectFinder/.
nw.SubjectEditor reads back through two calls, not one. unparseableInput() returns the first field showing text
the widget cannot turn into a Value — its property name and the message the field is displaying — or null.
getSubjectData() cannot represent that text, so it returns the statement with no value and the text is lost on save.
Check unparseableInput() before you read, hold the save while it is non-null, and show the message it hands you.
nw.SubjectEditorDialog does this for you.
You can write your extension in TypeScript and get types for NeoWiki's API. This is configuration on your side; NeoWiki ships nothing extra for it. See ADR 24 for the reasoning.
Point your tsconfig.json paths at NeoWiki's barrel source, which sits next to your extension in extensions/:
"paths": {
"ext.neowiki": [ "../NeoWiki/resources/ext.neowiki/src/public-api" ]
}You then get types on the same specifier you load at runtime, for example
import { ValueType, newStringValue } from 'ext.neowiki'; and
import type { PropertyTypeRegistration } from 'ext.neowiki';. Mark the modules NeoWiki already provides as external
in your bundler, so you do not ship a second copy and break the shared store: ext.neowiki, vue, @wikimedia/codex,
@wikimedia/codex-icons and pinia. At runtime your built JavaScript loads the same ext.neowiki module as the rest
of the page.
A Property Type is validated on the backend by PropertyType::validate() (returns Violation[]). The
frontend does not validate; it surfaces the violations the server returns. NeoWiki resolves each violation
code as the message key neowiki-field-<code>, so your extension must define those messages. For example,
a backend validator that returns the code invalid-hex requires a neowiki-field-invalid-hex message (see
RedHerb's i18n/en.json).
The frontend registration's icon is a Codex Icon. RedHerb uses stock Codex icons (browse the
icon gallery) declared via
CodexModule::getIcons in its
extension.json.
Custom SVG icons are also supported — pass an SVG string as the icon.
These extension points are designed or partially present but not yet open to extensions:
- Query surfaces for a graph database backend. The projection side is extensible (see
"Graph Database Backends"), but a backend cannot yet contribute its own parser function, REST route, or
mw.neowikiLua function; Neo4j's are wired in core. - A published TypeScript types package. TypeScript authors get types today by pointing their
tsconfigat NeoWiki's source (see "Authoring in TypeScript" and ADR 24). A published, versioned package is deferred until a consumer needs types without a NeoWiki checkout.
Everything on this page is alpha, but the surfaces below are internal even by that standard: they are implementation details that happen to be reachable, and they can change in any release without notice.
The .ext-neowiki-view placeholder elements and data-mw-neowiki-* attributes are the private contract
between NeoWiki's backend and its frontend for mounting Views. They are not an integration surface: do not
select these elements, read Subject IDs out of them, restyle their internals, or remove and replace them
with your own rendering.
To control where and how a Subject renders:
- To place a Subject rendering in page content, use the
{{#view}}parser function, optionally with a Layout to control which properties are shown. - To render Subjects in your own visual format, register a custom View Type (see Registering a View Type frontend).
- For fully custom UI outside the View system, fetch the data through the REST API or
the public JS API and render your own components, mounted as described in
Mounting standalone Vue features. RedHerb's
editMainSubjectresolves the page's Main Subject through the public JS API.
NeoWikiExtension::getInstance()->getNeo4jClient() and getReadOnlyNeo4jClient() return the Laudis client
NeoWiki itself uses. Neo4j access is treated as an implementation detail of NeoWiki's persistence layer
(ADR 13). Nothing stops an extension from querying through the raw
client, but compare what the documented query interfaces
({{#cypher_raw}}, nw.query, the
Query API, and the PHP query service) provide over it:
- Read-only enforcement. The query interfaces reject write queries (a keyword check plus
EXPLAIN; the keyword check also rejects read-onlyCALLandSHOW); with the raw client, a bug in calling code can corrupt the graph projection, which is what ADR 13 exists to prevent. - Resource limits. The query interfaces enforce a configured timeout against the backend and cap the
rows returned; the raw client has neither. The row cap does not bound the work a query does, so use
LIMITin the Cypher either way. - Result handling. The query interfaces return normalized rows and columns; the raw client returns Laudis driver types that you convert yourself.
Writing to the graph directly deserves particular caution: the graph is a projection of wiki content that
NeoWiki rewrites at will. Saving a page re-projects that page's nodes, and the RebuildGraphDatabases
maintenance script rebuilds the projection from scratch, so anything a third party writes into the graph
can be overwritten, orphaned, or deleted at any time. For page-level key/value metadata there is a durable
path: Page Property Providers, which NeoWiki re-runs whenever a page is saved
or rebuilt. For arbitrary nodes and relationships there is
currently no durable third-party write path into NeoWiki's graph; a
Graph Database Backend projects durably, but into its own store.