For the start of a managed cross-linking system.
--- /dev/null
+<?php
+
+namespace BookStack\Util\CrossLinking;
+
+use BookStack\Model;
+use BookStack\Util\CrossLinking\ModelResolvers\BookLinkModelResolver;
+use BookStack\Util\CrossLinking\ModelResolvers\BookshelfLinkModelResolver;
+use BookStack\Util\CrossLinking\ModelResolvers\ChapterLinkModelResolver;
+use BookStack\Util\CrossLinking\ModelResolvers\CrossLinkModelResolver;
+use BookStack\Util\CrossLinking\ModelResolvers\PageLinkModelResolver;
+use BookStack\Util\CrossLinking\ModelResolvers\PagePermalinkModelResolver;
+use DOMDocument;
+use DOMXPath;
+
+class CrossLinkParser
+{
+ /**
+ * @var CrossLinkModelResolver[]
+ */
+ protected array $modelResolvers;
+
+ public function __construct(array $modelResolvers)
+ {
+ $this->modelResolvers = $modelResolvers;
+ }
+
+ /**
+ * Extract any found models within the given HTML content.
+ *
+ * @returns Model[]
+ */
+ public function extractLinkedModels(string $html): array
+ {
+ $models = [];
+
+ $links = $this->getLinksFromContent($html);
+
+ foreach ($links as $link) {
+ $model = $this->linkToModel($link);
+ if (!is_null($model)) {
+ $models[get_class($model) . ':' . $model->id] = $model;
+ }
+ }
+
+ return array_values($models);
+ }
+
+ /**
+ * Get a list of href values from the given document.
+ *
+ * @returns string[]
+ */
+ protected function getLinksFromContent(string $html): array
+ {
+ $links = [];
+
+ $html = '<body>' . $html . '</body>';
+ libxml_use_internal_errors(true);
+ $doc = new DOMDocument();
+ $doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
+
+ $xPath = new DOMXPath($doc);
+ $anchors = $xPath->query('//a[@href]');
+
+ /** @var \DOMElement $anchor */
+ foreach ($anchors as $anchor) {
+ $links[] = $anchor->getAttribute('href');
+ }
+
+ return $links;
+ }
+
+ /**
+ * Attempt to resolve the given link to a model using the instance model resolvers.
+ */
+ protected function linkToModel(string $link): ?Model
+ {
+ foreach ($this->modelResolvers as $resolver) {
+ $model = $resolver->resolve($link);
+ if (!is_null($model)) {
+ return $model;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Create a new instance with a pre-defined set of model resolvers, specifically for the
+ * default set of entities within BookStack.
+ */
+ public static function createWithEntityResolvers(): self
+ {
+ return new static([
+ new PagePermalinkModelResolver(),
+ new PageLinkModelResolver(),
+ new ChapterLinkModelResolver(),
+ new BookLinkModelResolver(),
+ new BookshelfLinkModelResolver(),
+ ]);
+ }
+
+}
\ No newline at end of file
--- /dev/null
+<?php
+
+namespace BookStack\Util\CrossLinking\ModelResolvers;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Model;
+
+class BookLinkModelResolver implements CrossLinkModelResolver
+{
+ public function resolve(string $link): ?Model
+ {
+ $pattern = '/^' . preg_quote(url('/books'), '/') . '\/([\w-]+)' . '[#?\/$]/';
+ $matches = [];
+ $match = preg_match($pattern, $link, $matches);
+ if (!$match) {
+ return null;
+ }
+
+ $bookSlug = $matches[1];
+
+ /** @var ?Book $model */
+ $model = Book::query()->where('slug', '=', $bookSlug)->first();
+
+ return $model;
+ }
+}
\ No newline at end of file
--- /dev/null
+<?php
+
+namespace BookStack\Util\CrossLinking\ModelResolvers;
+
+use BookStack\Entities\Models\Bookshelf;
+use BookStack\Model;
+
+class BookshelfLinkModelResolver implements CrossLinkModelResolver
+{
+ public function resolve(string $link): ?Model
+ {
+ $pattern = '/^' . preg_quote(url('/shelves'), '/') . '\/([\w-]+)' . '[#?\/$]/';
+ $matches = [];
+ $match = preg_match($pattern, $link, $matches);
+ if (!$match) {
+ return null;
+ }
+
+ $shelfSlug = $matches[1];
+
+ /** @var ?Bookshelf $model */
+ $model = Bookshelf::query()->where('slug', '=', $shelfSlug)->first();
+
+ return $model;
+ }
+}
\ No newline at end of file
--- /dev/null
+<?php
+
+namespace BookStack\Util\CrossLinking\ModelResolvers;
+
+use BookStack\Entities\Models\Chapter;
+use BookStack\Model;
+
+class ChapterLinkModelResolver implements CrossLinkModelResolver
+{
+ public function resolve(string $link): ?Model
+ {
+ $pattern = '/^' . preg_quote(url('/books'), '/') . '\/([\w-]+)' . '\/chapter\/' . '([\w-]+)' . '[#?\/$]/';
+ $matches = [];
+ $match = preg_match($pattern, $link, $matches);
+ if (!$match) {
+ return null;
+ }
+
+ $bookSlug = $matches[1];
+ $chapterSlug = $matches[2];
+
+ /** @var ?Chapter $model */
+ $model = Chapter::query()->whereSlugs($bookSlug, $chapterSlug)->first();
+
+ return $model;
+ }
+}
\ No newline at end of file
--- /dev/null
+<?php
+
+namespace BookStack\Util\CrossLinking\ModelResolvers;
+
+use BookStack\Model;
+
+interface CrossLinkModelResolver
+{
+ /**
+ * Resolve the given href link value to a model.
+ */
+ public function resolve(string $link): ?Model;
+}
\ No newline at end of file
--- /dev/null
+<?php
+
+namespace BookStack\Util\CrossLinking\ModelResolvers;
+
+use BookStack\Entities\Models\Page;
+use BookStack\Model;
+
+class PageLinkModelResolver implements CrossLinkModelResolver
+{
+ public function resolve(string $link): ?Model
+ {
+ $pattern = '/^' . preg_quote(url('/books'), '/') . '\/([\w-]+)' . '\/page\/' . '([\w-]+)' . '[#?\/$]/';
+ $matches = [];
+ $match = preg_match($pattern, $link, $matches);
+ if (!$match) {
+ return null;
+ }
+
+ $bookSlug = $matches[1];
+ $pageSlug = $matches[2];
+
+ /** @var ?Page $model */
+ $model = Page::query()->whereSlugs($bookSlug, $pageSlug)->first();
+
+ return $model;
+ }
+}
\ No newline at end of file
--- /dev/null
+<?php
+
+namespace BookStack\Util\CrossLinking\ModelResolvers;
+
+use BookStack\Entities\Models\Page;
+use BookStack\Model;
+
+class PagePermalinkModelResolver implements CrossLinkModelResolver
+{
+ public function resolve(string $link): ?Model
+ {
+ $pattern = '/^' . preg_quote(url('/link'), '/') . '\/(\d+)/';
+ $matches = [];
+ $match = preg_match($pattern, $link, $matches);
+ if (!$match) {
+ return null;
+ }
+
+ $id = intval($matches[1]);
+ /** @var ?Page $model */
+ $model = Page::query()->find($id);
+
+ return $model;
+ }
+}
\ No newline at end of file
--- /dev/null
+<?php
+
+namespace Tests\Util;
+
+use BookStack\Entities\Models\Page;
+use BookStack\Util\CrossLinking\CrossLinkParser;
+use Tests\TestCase;
+
+class CrossLinkParserTest extends TestCase
+{
+
+ public function test_instance_with_entity_resolvers_matches_entity_links()
+ {
+ $entities = $this->getEachEntityType();
+ $otherPage = Page::query()->where('id', '!=', $entities['page']->id)->first();
+
+ $html = '
+<a href="' . url('/link/' . $otherPage->id) . '#cat">Page Permalink</a>
+<a href="' . $entities['page'] ->getUrl(). '?a=b">Page Link</a>
+<a href="' . $entities['chapter']->getUrl() . '?cat=mouse#donkey">Chapter Link</a>
+<a href="' . $entities['book']->getUrl() . '/edit">Book Link</a>
+<a href="' . $entities['bookshelf']->getUrl() . '/edit?cat=happy#hello">Shelf Link</a>
+<a href="' . url('/settings') . '">Settings Link</a>
+ ';
+
+ $parser = CrossLinkParser::createWithEntityResolvers();
+ $results = $parser->extractLinkedModels($html);
+
+ $this->assertCount(5, $results);
+ $this->assertEquals(get_class($otherPage), get_class($results[0]));
+ $this->assertEquals($otherPage->id, $results[0]->id);
+ $this->assertEquals(get_class($entities['page']), get_class($results[1]));
+ $this->assertEquals($entities['page']->id, $results[1]->id);
+ $this->assertEquals(get_class($entities['chapter']), get_class($results[2]));
+ $this->assertEquals($entities['chapter']->id, $results[2]->id);
+ $this->assertEquals(get_class($entities['book']), get_class($results[3]));
+ $this->assertEquals($entities['book']->id, $results[3]->id);
+ $this->assertEquals(get_class($entities['bookshelf']), get_class($results[4]));
+ $this->assertEquals($entities['bookshelf']->id, $results[4]->id);
+ }
+}