Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow page include system to include everything under a header level #3854

Open
1 task done
WoosterInitiative opened this issue Nov 14, 2022 · 4 comments
Open
1 task done

Comments

@WoosterInitiative
Copy link

WoosterInitiative commented Nov 14, 2022

Describe the feature you'd like

It seems that a common use case for an include would be a header and everything underneath it. Say there's a page defined like so (using Markdown for easy text visibility):

# Example Page Title

## Heading 1

Some text

## Heading 2

### Subheading 2.1

Some text

### Subheading 2.2

1. item 1
2. item 2

## Heading 3

Some more **text** with `inline code`

Now lets say I want Heading 2 and everything underneath it on another page; currently, I don't see a "clean" way to do this. The only way I can think of is to grab the bookmark for each element (heading 2, subheadings 2.1 and 2.2, as well as the paragraph in 2.1 and the ol in 2.2).

Describe the benefits this would bring to existing BookStack users

I believe this would be an intuitive and clean way for users to include content from one page on another page. I could be wrong, but it seems that this would be a common need for an include that wasn't a full page include.

Can the goal of this request already be achieved via other means?

Not that I can tell. Perhaps with Page Include Parse, but I don't see how.

I tried using the source code view and adding my own <div> tag around the header and content I wanted with a custom id starting with bkmrk-, but the tag would move every time I saved the code.

Otherwise, it's one block at a time. The other way would be to create a "dummy" page of the content and include it in both places, which I suppose works, but isn't as intuitive to me.

Have you searched for an existing open/closed issue?

  • I have searched for existing issues and none cover my fundemental request

How long have you been using BookStack?

0 to 6 months

Additional context

No response

@gaufde
Copy link

gaufde commented Jan 10, 2023

+1 This seems like a critical feature for creating and maintaining complex documentation across books.

I also tried adding my own div, but just as you found that doesn't work. So far, the only work-arounds I have found are to either make a table with only one cell or to use a collapsible block to group content.

However, neither of these options are ideal since tables and collapsible blocks aren't really the correct elements for a job like this. Also, their UI on the back end makes the process clunky and unintuitive which is a huge problem when working with teams of people.

Seems to me like there are two routes that one could take for this feature. One is to add a new parameter to the embed feature that will automatically include all blocks until the next heading of the same level. This would of course need to be done in such a way that current embeds are not affected for backwards compatibility. The other option would be to add some sort of grouping element to the WSIWYG editor so that people can create custom content groupings for insertion. However, this option might promote bad writing habits and clutter up the UI.

I'm not sure I have the skills to do something like this, but if someone else does, @ssddanbrown are you open to accepting a PR for something like this?

@ssddanbrown
Copy link
Member

@ssddanbrown are you open to accepting a PR for something like this?

@gaufde Not really, at least not without a defined approach that would seem reasonable.

I really wouldn't want to add complexity to the editor/content structure just to support this lesser-used power-user subsystem.

Within v22.09 I added a logical theme even to specifically allow customization/extension of the page include system such as things, since there have been a few similar yet potentially conflicting requests. My intent is that such customization systems can be used to add such functionality as desired without us needing to support it in core. Some of the content parsing required for such a customization might be a little tricky (yet for sure possible) but I'm open to potentially easing that where it helps.

@gaufde
Copy link

gaufde commented Jan 11, 2023

@gaufde Not really, at least not without a defined approach that would seem reasonable.

Fair enough. I still think there could be a good opportunity to add something like this in core, but I went ahead and implemented it myself using the logical theme event you both suggested.

I have made a couple of decisions that I think are quite clever, but they may not be to everyone's preference.

First, is that the heading you reference will not be included. Instead, only the content after the heading and up to the next heading of equal or greater importance will be included. This way, headings become like their own section dividers, and the sections they define are more portable without the heading itself.

Second, when you insert content into a page, you could get a gap in your heading structure where the inserted content uses headings that are all a couple levels smaller than what is on the parent page. This code will automatically adjust the heading tags of the inserted content to avoid this. Though keep in mind that after H5 the headings will be converted into strong tags.

@WoosterInitiative You should be able to copy all of my code into your functions.php file to get this feature working.

@ssddanbrown I'm not really a developer, but I muddle through projects like this when needed. If you have some time, I'd love to get some feedback from you on my implementation! Also, is there a central place for community members to share code snippets like this (maybe an Awesome-Bookstack repo)? Seems like we could all contribute to build a cool library of customizations.

Here is the code that I am using to modify the replacementHTML after Bookstack parses the page include:

use BookStack\Entities\Models\Page;
use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents;
use BookStack\Entities\Tools\PageContent;

Theme::listen(ThemeEvents::PAGE_INCLUDE_PARSE, function(string $tagReference, string $replacementHTML, Page $currentPage, ?Page $referencedPage) {
  $splitInclude = explode('#', $tagReference, 2);
  //don't do anything if we only have the page ID.
  if (count($splitInclude) >= 2) {

    $sectionID = $splitInclude[1];
    $sectionsBelowHeaders = (new ExtendedPageContent($currentPage))->fetchSectionsBelowHeader($referencedPage, $sectionID);

    if ( $sectionsBelowHeaders != '' ){
      $replacementHTML = $sectionsBelowHeaders;
    }
  }

  return $replacementHTML;
});

And, here is the extension I made to the PageContent class:

class ExtendedPageContent extends PageContent {
  /**
   * Fetch the sub-contents from below a header
   */
   public function fetchSectionsBelowHeader(Page $page, string $sectionId): string
  {
      $doc = parent::loadDocumentFromHtml($page->html);

      // Search included content for the id given and blank out if not exists.
      $matchingElem = $doc->getElementById($sectionId);
      if ($matchingElem === null) {
          return '';
      }

      //Return empty if the element is not a heading
      if ( !preg_match ("/^h\\d$/i", $matchingElem->nodeName) ) {
        return '';
      }


      //Include all content between this heading and the next heading of greater or equal importance.
      $innerContent = '';
      $originalHeadingLevel = (int) filter_var($matchingElem->nodeName, FILTER_SANITIZE_NUMBER_INT);
      //Don't include the original heading itself.
      $nextSib = $matchingElem->nextSibling;

      while ( $nextSib ) {
        //Break if the next sibling is a heading with equal or greater importance to the original heading.
        //Adjust heading levels so that no heading level is skipped.
        if ( preg_match ("/^h\\d$/i", $nextSib->nodeName) ) {
          $nextSibHeadingLevel = (int) filter_var($nextSib->nodeName, FILTER_SANITIZE_NUMBER_INT);
          $preHeadingLevel = $this->getPreHeadingLevel( $sectionId );
          //Use the difference between the original section heading level and the heading level immediatly before the content insertion to adjust all headings in the inserted content.
          $newlevel = $nextSibHeadingLevel-($originalHeadingLevel-$preHeadingLevel);

          //Don't go lower than H5
          if ($newlevel <= 5) {
            $name  = "h" . $newlevel;
          } else {
            $name = "strong";
          }

          //Remove old heading and create new one with the correct level
          $nextSib = $this->changeTagName($nextSib, $name);

          //Break at the next heading of equal or greater importance within the referenced page.
          if ($nextSibHeadingLevel <= $originalHeadingLevel) {
            break;
          }
        }

        //Output the HTML for this sibling
        $innerContent .= $doc->saveHTML($nextSib);

        //iterate to next sibling
        $nextSib = $nextSib->nextSibling;
      }


      libxml_clear_errors();

      return $innerContent;
  }

  /**
  * Gets the level of the heading on the currentPage immediately before the insertion point.
  * Fallback value is 1 if no headings are found.
  *
  * @param string  $sectionId
  *
  * @return int
  */
  protected function getPreHeadingLevel ( string $sectionId ) {
    $doc = parent::loadDocumentFromHtml($this->page->html);

    $xpath = new DOMXPath ($doc);
    $query = "//*[contains(text(),'$sectionId')]/preceding::*[self::h1 or self::h2 or self::h3 or self::h4 or self::h5 or self::h6][1]";
    $nodeList = $xpath->query($query);

    if ( $nodeList[0] ) {
      $preHeadingLevel = (int) filter_var($nodeList[0]->nodeName, FILTER_SANITIZE_NUMBER_INT);
    } else {
      $preHeadingLevel = (int) 1;
    }

    return $preHeadingLevel;
  }

  /**
   * Renames a node in a DOM Document.
   *
   * @param DOMElement $node
   * @param string     $name
   *
   * @return DOMNode
   */
   protected function changeTagName(DOMElement $node, string $name)
   {
     $childnodes = array();
     foreach ($node->childNodes as $child){
         $childnodes[] = $child;
     }
     $newnode = $node->ownerDocument->createElement($name);
     foreach ($childnodes as $child){
         $child2 = $node->ownerDocument->importNode($child, true);
         $newnode->appendChild($child2);
     }
     foreach ($node->attributes as $attrName => $attrNode) {
         $attrName = $attrNode->nodeName;
         $attrValue = $attrNode->nodeValue;
         $newnode->setAttribute($attrName, $attrValue);
     }
     $node->parentNode->replaceChild($newnode, $node);
     return $newnode;
   }

}

@abr1x
Copy link

abr1x commented Jan 30, 2024

@ssddanbrown this feature would be much appreciated if it were added to BookStack as a core feature. Because as @gaufde already said, this is quite an important feature if you maintain a more complex kind of documentation in BookStack without copying and pasting or micro segmenting to include whole pages

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Development

No branches or pull requests

4 participants