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 <title> 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 <head>...</head> region $headStart = strpos($buffer, '<head'); $headEnd = strpos($buffer, '</head>'); if ($headStart === false || $headEnd === false || $headEnd < $headStart) { return $buffer; } $headEnd += 7; // include '</head>' $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 = '<!DOCTYPE html><html>' . $headFragment . '<body></body></html>'; $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 = '<!DOCTYPE html><html>' . $headFragment . '<body></body></html>'; $doc = new DOMDocument('1.0', 'UTF-8'); $doc->loadHTML('<?xml encoding="UTF-8">' . $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 ); } }