[pagename][#filterNS][#!filterNS]...}} * * Original authors: * Mark C. Prins * Michael Klier * https://www.dokuwiki.org/plugin:backlinks * * Section-aware modifications: * Wizardry and Steamworks * * @license GPL 2 (http://www.gnu.org/licenses/gpl.html) */ class syntax_plugin_backlinkswas extends SyntaxPlugin { public function getType(): string { return 'substition'; } public function getPType(): string { return 'block'; } public function getSort(): int { return 304; } public function connectTo($mode): void { $this->Lexer->addSpecialPattern('\{\{backlinkswas>.+?\}\}', $mode, 'plugin_backlinkswas'); } public function handle($match, $state, $pos, Handler $handler): array { Logger::error("backlinkswas[handle]: RAW match=[" . $match . "] len=" . strlen($match)); // strip {{backlinkswas> from start and }} from end (15 chars) $match = substr($match, 15, -2); Logger::error("backlinkswas[handle]: stripped=[" . $match . "]"); $includeNS = []; $excludeNS = []; if (str_contains($match, "#")) { $filters = explode('#', substr(strstr($match, "#"), 1)); $match = strstr($match, "#", true); foreach ($filters as $filter) { if ($filter === '') { continue; } if (str_starts_with($filter, '!')) { $excludeNS[] = substr($filter, 1); } else { $includeNS[] = $filter; } } } Logger::error("backlinkswas[handle]: match=[" . $match . "] includeNS=" . json_encode($includeNS) . " excludeNS=" . json_encode($excludeNS)); return ([$match, $includeNS, $excludeNS]); } public function render($format, Doku_Renderer $renderer, $data): bool { global $lang; global $INFO; global $ID; Logger::error(sprintf( "backlinkswas[render]: ENTER format=[%s] ID=[%s] INFO[id]=[%s] data0=[%s] data1=%s data2=%s", $format, $ID ?? 'null', $INFO['id'] ?? 'null', var_export($data[0] ?? null, true), json_encode($data[1] ?? []), json_encode($data[2] ?? []) )); $id = $ID; // If it's a sidebar, get the original id. if ($INFO != null) { $id = $INFO['id']; } Logger::error("backlinkswas[render]: resolved id=[$id]"); $match = $data[0]; $match = ($match == '.') ? $id : $match; Logger::error("backlinkswas[render]: after dot-resolution match=[$match]"); if (str_contains($match, ".:")) { $resolver = new PageResolver($id); $match = $resolver->resolveId($match); Logger::error("backlinkswas[render]: after PageResolver match=[$match]"); } if ($format != 'xhtml') { Logger::error("backlinkswas[render]: non-xhtml format, bailing"); return false; } $renderer->info['cache'] = false; // 1. Candidate pages from the index. $candidates = (new MetadataSearch())->backlinks($match); Logger::error("backlinkswas[render]: primary lookup match=[$match] candidates=" . json_encode($candidates)); if ($candidates === []) { // Fall back to a wiki:-prefixed form in case the target was // indexed under a URL-style /wiki/... prefix. $alt = 'wiki:' . ltrim(str_replace('/', ':', $match), ':'); if ($alt !== $match) { $candidates = (new MetadataSearch())->backlinks($alt); Logger::error("backlinkswas[render]: fallback lookup alt=[$alt] candidates=" . json_encode($candidates)); } } $includeNS = $data[1]; $excludeNS = $data[2]; // 2. Namespace include filter. if ($candidates !== [] && $includeNS !== []) { Logger::error("backlinkswas[render]: applying includeNS=" . json_encode($includeNS)); $candidates = array_filter( $candidates, static function ($ns) use ($includeNS) { foreach ($includeNS as $filterNS) { if (stripos($ns, (string) $filterNS) === 0) { return true; } } return false; } ); Logger::error("backlinkswas[render]: after includeNS candidates=" . json_encode(array_values($candidates))); } // 3. Namespace exclude filter. if ($candidates !== [] && $excludeNS !== []) { Logger::error("backlinkswas[render]: applying excludeNS=" . json_encode($excludeNS)); $candidates = array_filter( $candidates, static function ($ns) use ($excludeNS) { foreach ($excludeNS as $filterNS) { if (stripos($ns, (string) $filterNS) === 0) { return false; } } return true; } ); Logger::error("backlinkswas[render]: after excludeNS candidates=" . json_encode(array_values($candidates))); } // 4. Resolve each candidate into (page, fragment) pairs. $backlinks = []; foreach ($candidates as $candidate) { Logger::error("backlinkswas[render]: scanning candidate=[$candidate] for target=[$match]"); $fragments = $this->findLinkLocations($candidate, $match, $id); Logger::error("backlinkswas[render]: candidate=[$candidate] fragments=" . json_encode($fragments)); foreach ($fragments as $fragment) { $key = $candidate . '#' . $fragment; $backlinks[$key] = [$candidate, $fragment]; } } $backlinks = array_values($backlinks); Logger::error("backlinkswas[render]: final backlinks=" . json_encode( array_map(static fn($x) => $x[0] . '#' . $x[1], $backlinks) )); $renderer->doc .= '
' . "\n"; if ($backlinks !== []) { $renderer->doc .= '
    '; foreach ($backlinks as [$page, $fragment]) { $name = p_get_metadata($page, 'title'); if (empty($name)) { $name = $page; } $label = $name; if ($fragment !== '') { $sectionTitle = $this->getSectionTitle($page, $fragment); if ($sectionTitle !== '') { $label .= ' » ' . $sectionTitle; } } $targetId = ':' . $page; if ($fragment !== '') { $targetId .= '#' . $fragment; } Logger::error("backlinkswas[render]: emitting link target=[$targetId] label=[$label]"); $renderer->doc .= '
  • '; $renderer->doc .= html_wikilink($targetId, $label); $renderer->doc .= '
  • ' . "\n"; } $renderer->doc .= '
' . "\n"; } else { Logger::error("backlinkswas[render]: nothing found, rendering empty message"); $renderer->doc .= "Plugin Backlinks: " . $lang['nothingfound'] . "" . "\n"; } $renderer->doc .= '
' . "\n"; return true; } /** * Find every location in $sourcePageId where a link to $targetPageId occurs. * * @return string[] List of fragments (may contain '' for top-of-page). */ protected function findLinkLocations(string $sourcePageId, string $targetPageId, string $contextId): array { $file = wikiFN($sourcePageId); $exists = file_exists($file); Logger::error("backlinkswas[findLinkLocations]: source=[$sourcePageId] file=[$file] exists=" . ($exists ? 'yes' : 'no')); if (!$exists) { return []; } $source = io_readFile($file, false); Logger::error("backlinkswas[findLinkLocations]: source length=" . strlen($source)); if ($source === '') { return []; } // Resolve the target the same way DokuWiki would, so relative links match. $resolver = new PageResolver($sourcePageId); $resolvedTarget = $resolver->resolveId($targetPageId); Logger::error("backlinkswas[findLinkLocations]: context=[$sourcePageId] target=[$targetPageId] resolvedTarget=[$resolvedTarget] cleanTarget=[" . cleanID($resolvedTarget) . "]"); $fragments = []; // DokuWiki link syntax: [[target]] or [[target|label]] or [[target#frag|label]] $pattern = '/\[\[([^\]\|]+)(?:\|[^\]]*)?\]\]/u'; $count = preg_match_all($pattern, $source, $matches, PREG_OFFSET_CAPTURE); Logger::error("backlinkswas[findLinkLocations]: regex matched " . (int)$count . " link(s)"); if (!$count) { return []; } $headings = $this->findHeadings($source); Logger::error("backlinkswas[findLinkLocations]: headings found=" . json_encode(array_map( static fn($h) => ['off' => $h['offset'], 'id' => $h['id'], 'title' => $h['title']], $headings ))); foreach ($matches[1] as [$rawTarget, $offset]) { $rawTargetOrig = $rawTarget; $rawTarget = trim($rawTarget); $explicitFragment = ''; if (str_contains($rawTarget, '#')) { [$rawTarget, $explicitFragment] = explode('#', $rawTarget, 2); $explicitFragment = trim($explicitFragment); } Logger::error("backlinkswas[findLinkLocations]: link raw=[$rawTargetOrig] cleaned=[$rawTarget] explicitFrag=[$explicitFragment] offset=[$offset]"); if ($rawTarget === '' || str_contains($rawTarget, '://') || str_contains($rawTarget, '>')) { Logger::error("backlinkswas[findLinkLocations]: -> skipped (external/empty)"); continue; } $resolved = $resolver->resolveId($rawTarget); $resolvedClean = cleanID($resolved); $targetClean = cleanID($resolvedTarget); Logger::error("backlinkswas[findLinkLocations]: -> resolved=[$resolved] clean=[$resolvedClean] want=[$targetClean]"); if ($resolvedClean !== $targetClean) { Logger::error("backlinkswas[findLinkLocations]: -> MISMATCH, skipping"); continue; } if ($explicitFragment !== '') { Logger::error("backlinkswas[findLinkLocations]: -> MATCH, using explicit fragment=[$explicitFragment]"); $fragments[] = cleanID($explicitFragment); continue; } $section = $this->headingBeforeOffset($headings, $offset); Logger::error("backlinkswas[findLinkLocations]: -> MATCH, section=[$section]"); $fragments[] = $section; } $result = array_values(array_unique($fragments)); Logger::error("backlinkswas[findLinkLocations]: result=" . json_encode($result)); return $result; } /** * Extract headings from raw wiki source. * * @return array */ protected function findHeadings(string $source): array { $headings = []; $lines = explode("\n", $source); $offset = 0; foreach ($lines as $line) { if (preg_match('/^(\s*)(={2,6})([^=].*?)\2\s*$/', $line, $m)) { $level = strlen($m[2]); $title = trim($m[3]); $id = $this->sectionId($title); if ($id !== '') { $headings[] = [ 'offset' => $offset, 'id' => $id, 'title' => $title, 'level' => $level, ]; } } $offset += strlen($line) + 1; } return $headings; } /** * Mirror DokuWiki's section-ID generation. */ protected function sectionId(string $title): string { $id = str_replace(' ', '_', strtolower($title)); $id = preg_replace('/[^a-z0-9_\-\.]/u', '', $id); return $id; } /** * Find the ID of the nearest heading at or before $offset. * Returns '' if the link is above the first heading. */ protected function headingBeforeOffset(array $headings, int $offset): string { $current = ''; foreach ($headings as $h) { if ($h['offset'] > $offset) { break; } $current = $h['id']; } return $current; } /** * Look up the human-readable heading title for a fragment on a page. */ protected function getSectionTitle(string $pageId, string $fragment): string { $file = wikiFN($pageId); if (!file_exists($file)) { return ''; } $source = io_readFile($file, false); if ($source === '') { return ''; } foreach ($this->findHeadings($source) as $h) { if ($h['id'] === $fragment) { return $h['title']; } } return ''; } }