element in the rendered HTML output.
* - Does NOT touch og:title / twitter:title. Those belong to whichever
* plugin owns social meta and reads from p_get_metadata().
* - Skips media detail pages (image viewer, media manager) — those use
* the media filename, not a wiki slug, and would produce ugly titles.
*
* HTML handling note:
* DokuWiki templates emit a concatenation of HTML fragments (multiple
* /
/ sequences). Parsing the whole buffer as a single
* document corrupts it. Instead, only the first ... region
* is isolated, parsed with DOM, rewritten, and spliced back. Everything
* before and after is preserved byte-for-byte.
*
* The HTML elements produced by Dom\HTMLDocument live in the XHTML
* namespace (http://www.w3.org/1999/xhtml), so XPath queries must
* register a prefix for it. Also, in the new Dom\ API, nodeValue is
* read-only on elements — use textContent to set text.
*
* @license MIT License (https://opensource.org/licenses/MIT)
* @author Wizardry and Steamworks
* @date 2026-09-21
* @link https://grimore.org
*/
if (!defined('DOKU_INC')) die();
$autoload = __DIR__ . '/vendor/autoload.php';
if (file_exists($autoload)) {
require_once $autoload;
}
require_once __DIR__ . '/acronyms.php';
use dokuwiki\Extension\ActionPlugin;
use dokuwiki\Extension\EventHandler;
use dokuwiki\Extension\Event;
class action_plugin_naturaltitle extends ActionPlugin {
/** @var bool Toggle diagnostic logging to /tmp/naturaltitle-debug.log */
const DEBUG_TO_FILE = false;
public function register(EventHandler $controller) {
// 1. Metadata hook: authoritative title for all metadata consumers
$controller->register_hook(
'PARSER_METADATA_RENDER',
'AFTER',
$this,
'handleMetadataRender'
);
// 2. Output buffer: rewrite in the rendered HTML
if (!defined('DOKU_API') && (!defined('DOKU_CLI') || !DOKU_CLI)) {
ob_start([$this, 'filterServerHtmlStream']);
}
}
/**
* Override the metadata 'title' field with the slug-derived title.
*/
public function handleMetadataRender(Event $event, $param) {
$id = $event->data['page'] ?? null;
if (empty($id) || !page_exists($id)) return;
// Skip media items — their metadata title should come from
// DokuWiki's own media handling, not from a slug rewrite.
if (preg_match('/\.[a-z0-9]{2,6}$/i', noNS($id))) {
return;
}
$title = $this->titleForPage($id);
if (isset($event->result['current'])) {
$event->result['current']['title'] = $title;
}
}
/**
* Rewrite in the HTML output stream.
*/
public function filterServerHtmlStream($buffer) {
global $ID, $ACT;
if (empty($ID) || !page_exists($ID)) {
return $buffer;
}
// Only handle normal page views, not edit/history/admin/etc.
if (isset($_REQUEST['do']) && $_REQUEST['do'] !== 'show') {
return $buffer;
}
// Skip media detail pages (lib/exe/detail.php) and media manager.
if ($ACT === 'detail' || $ACT === 'media') {
return $buffer;
}
// Skip any ID that looks like a media filename rather than a slug.
if (preg_match('/\.[a-z0-9]{2,6}$/i', noNS($ID))) {
return $buffer;
}
$title = $this->titleForPage($ID);
// Isolate the first ... region
$headStart = strpos($buffer, '');
if ($headStart === false || $headEnd === false || $headEnd < $headStart) {
return $buffer;
}
$headEnd += 7; // include ''
$headFragment = substr($buffer, $headStart, $headEnd - $headStart);
$before = substr($buffer, 0, $headStart);
$after = substr($buffer, $headEnd);
// Preferred path: PHP 8.4+ DOM (XHTML namespace aware)
if (class_exists('Dom\\HTMLDocument')) {
return $this->rewriteHeadDom84(
$before, $headFragment, $after, $title, $buffer
);
}
// Fallback path: legacy DOMDocument (PHP 8.1–8.3, no namespace)
return $this->rewriteHeadLegacy(
$before, $headFragment, $after, $title, $buffer
);
}
/**
* PHP 8.4+ rewrite path using Dom\HTMLDocument + Dom\XPath.
*/
private function rewriteHeadDom84(
string $before,
string $headFragment,
string $after,
string $title,
string $original
): string {
try {
$wrapped = '' . $headFragment . '';
$doc = \Dom\HTMLDocument::createFromString($wrapped, LIBXML_NOERROR);
$xpath = new \Dom\XPath($doc);
$xpath->registerNamespace('x', 'http://www.w3.org/1999/xhtml');
$titles = $xpath->query('//x:title');
if ($titles->length > 0) {
// In the Dom\ API, nodeValue is read-only on elements.
// textContent is the writable equivalent.
$titles->item(0)->textContent = $title;
} else {
$this->debug("no title node matched — returning original");
return $original;
}
$heads = $xpath->query('//x:head');
if ($heads->length === 0) {
$this->debug("no head node matched — returning original");
return $original;
}
$newHead = $doc->saveHtml($heads->item(0));
return $before . $newHead . $after;
} catch (\Throwable $e) {
$this->debug("EXCEPTION: " . $e->getMessage()
. "\n" . $e->getTraceAsString());
return $original;
}
}
/**
* PHP 8.1–8.3 fallback using legacy DOMDocument + DOMXPath.
*/
private function rewriteHeadLegacy(
string $before,
string $headFragment,
string $after,
string $title,
string $original
): string {
try {
$wrapped = '' . $headFragment . '';
$doc = new DOMDocument('1.0', 'UTF-8');
$doc->loadHTML('' . $wrapped);
$xpath = new DOMXPath($doc);
$titles = $xpath->query('//title');
if ($titles->length > 0) {
$titles->item(0)->nodeValue = $title;
}
$heads = $xpath->query('//head');
if ($heads->length === 0) {
return $original;
}
$newHead = $doc->saveHTML($heads->item(0));
$newHead = preg_replace('/<\?xml encoding="UTF-8"\?>/', '', $newHead);
if (preg_match('/[^\x00-\x7F]/', $newHead)) {
$newHead = $doc->saveXML($heads->item(0));
$newHead = preg_replace('/^<\?xml[^>]+\?>\s*/', '', $newHead);
}
return $before . $newHead . $after;
} catch (\Throwable $e) {
$this->debug("legacy EXCEPTION: " . $e->getMessage());
return $original;
}
}
/**
* Compute the slug-derived title for a page ID.
*/
private function titleForPage(string $id): string {
$pageName = noNS($id);
$cleanString = str_replace(['_', '-'], ' ', $pageName);
return $this->buildTitle($cleanString);
}
/**
* Produce the final title string using the title-case library
* and Wikidata-derived acronym overrides.
*/
private function buildTitle(string $cleanString): string {
if (!function_exists('\\RJT\\TitleCase\\titleCase')) {
return ucwords($cleanString);
}
try {
$normalized = \RJT\TitleCase\titleCase($cleanString);
$resolver = new NaturalTitleAcronyms();
$overrides = $resolver->buildOverrides($normalized);
return \RJT\TitleCase\titleCase($cleanString, false, 'UTF-8', $overrides);
} catch (\Throwable $e) {
$this->debug("buildTitle EXCEPTION: " . $e->getMessage());
return ucwords($cleanString);
}
}
/**
* Diagnostic logger. Writes to /tmp so we can read it directly.
*/
private function debug(string $message): void {
if (!self::DEBUG_TO_FILE) return;
@file_put_contents(
'/tmp/naturaltitle-debug.log',
date('c') . ' ' . $message . "\n",
FILE_APPEND
);
}
}