* array( * 'en/someniceurl/' => array( * 'filepath' => '/path/to/docs/en/SomeniceFile.md', * 'title' => 'Some nice URL', * 'summary' => 'Summary Text', * 'basename' => 'SomeniceFile.md', * 'type' => 'DocumentationPage' * ) * ) * * * URL format is in the following structures: * * {lang}/{path} * {lang}/{module}/{path} * {lang}/{module}/{version}/{/path} * * @package framework * @subpackage manifest */ class DocumentationManifest { /** * @config * * @var boolean $automatic_registration */ private static $automatic_registration = true; /** * @config * * @var array $registered_entities */ private static $register_entities = array(); protected $cache; protected $cacheKey; protected $inited; protected $forceRegen; /** * @var array $pages */ protected $pages = array(); /** * @var DocumentationEntity */ private $entity; /** * @var ArrayList */ private $registeredEntities; /** * Constructs a new template manifest. The manifest is not actually built * or loaded from cache until needed. * * @param bool $includeTests Include tests in the manifest. * @param bool $forceRegen Force the manifest to be regenerated. */ public function __construct($forceRegen = false) { $this->cacheKey = 'manifest'; $this->forceRegen = $forceRegen; $this->registeredEntities = new ArrayList(); $this->cache = SS_Cache::factory('DocumentationManifest', 'Core', array( 'automatic_serialization' => true, 'lifetime' => null )); $this->setupEntities(); } /** * Sets up the top level entities. * * Either manually registered through the YAML syntax or automatically * loaded through investigating the file system for `docs` folder. */ public function setupEntities() { if(Config::inst()->get('DocumentationManifest', 'automatic_registration')) { $this->populateEntitiesFromInstall(); } $registered = Config::inst()->get('DocumentationManifest', 'register_entities'); foreach($registered as $details) { // validate the details provided through the YAML configuration $required = array('Path', 'Title'); foreach($required as $require) { if(!isset($details[$require])) { throw new Exception("$require is a required key in DocumentationManifest.register_entities"); } } // if path is not an absolute value then assume it is relative from // the BASE_PATH. $path = $this->getRealPath($details['Path']); $key = (isset($details['Key'])) ? $details['Key'] : $details['Title']; if(!is_dir($path)) { throw new Exception($path . ' is not a valid documentation directory'); } $version = (isset($details['Version'])) ? $details['Version'] : ''; $langs = scandir($path); if($langs) { $possible = i18n::get_common_languages(true); foreach($langs as $k => $lang) { if(isset($possible[$lang])) { $entity = Injector::inst()->create( 'DocumentationEntity', $key ); $entity->setPath(Controller::join_links($path, $lang, '/')); $entity->setTitle($details['Title']); $entity->setLanguage($lang); $entity->setVersion($version); if(isset($details['Stable'])) { $entity->setIsStable($details['Stable']); } if(isset($details['DefaultEntity'])) { $entity->setIsDefaultEntity($details['DefaultEntity']); } $this->registeredEntities->push($entity); } } } } } public function getRealPath($path) { if(substr($path, 0, 1) != '/') { $path = realpath(Controller::join_links(BASE_PATH, $path)); } return $path; } /** * @return ArrayList */ public function getEntities() { return $this->registeredEntities; } /** * Scans the current installation and picks up all the SilverStripe modules * that contain a `docs` folder. * * @return void */ public function populateEntitiesFromInstall() { $entities = array(); foreach(scandir(BASE_PATH) as $key => $entity) { if($key == "themes") { continue; } $dir = is_dir(Controller::join_links(BASE_PATH, $entity)); if($dir) { // check to see if it has docs $docs = Controller::join_links($dir, 'docs'); if(is_dir($docs)) { $entities[] = array( 'BasePath' => $entity, 'Folder' => $key, 'Version' => 'master', 'Stable' => true ); } } } Config::inst()->update( 'DocumentationManifest', 'registered_entities', $entities ); } /** * */ protected function init() { if (!$this->forceRegen && $data = $this->cache->load($this->cacheKey)) { $this->pages = $data; $this->inited = true; } else { $this->regenerate(); } } /** * Returns a map of all documentation pages. * * @return array */ public function getPages() { if (!$this->inited) { $this->init(); } return $this->pages; } /** * Returns a particular page for the requested URL. * * @return DocumentationPage */ public function getPage($url) { $pages = $this->getPages(); $url = $this->normalizeUrl($url); if(!isset($pages[$url])) { return null; } $record = $pages[$url]; foreach($this->getEntities() as $entity) { if(strpos($record['filepath'], $entity->getPath()) !== false) { $page = Injector::inst()->create( $record['type'], $entity, $record['basename'], $record['filepath'] ); return $page; } } } /** * Regenerates the manifest by scanning the base path. * * @param bool $cache */ public function regenerate($cache = true) { $finder = new DocumentationManifestFileFinder(); $finder->setOptions(array( 'dir_callback' => array($this, 'handleFolder'), 'file_callback' => array($this, 'handleFile') )); foreach($this->getEntities() as $entity) { $this->entity = $entity; $this->handleFolder('', $this->entity->getPath(), 0); $finder->find($this->entity->getPath()); } if ($cache) { $this->cache->save($this->pages, $this->cacheKey); } $this->inited = true; } /** * */ public function handleFolder($basename, $path, $depth) { $folder = Injector::inst()->create( 'DocumentationFolder', $this->entity, $basename, $path ); $link = ltrim(str_replace( Config::inst()->get('DocumentationViewer', 'link_base'), '', $folder->Link() ), '/'); $this->pages[$link] = array( 'title' => $folder->getTitle(), 'basename' => $basename, 'filepath' => $path, 'type' => 'DocumentationFolder', 'summary' => '' ); } /** * Individual files can optionally provide a nice title and a better URL * through the use of markdown meta data. This creates a new * {@link DocumentationPage} instance for the file. * * If the markdown does not specify the title in the meta data it falls back * to using the file name. * * @param string $basename * @param string $path * @param int $depth */ public function handleFile($basename, $path, $depth) { $page = Injector::inst()->create( 'DocumentationPage', $this->entity, $basename, $path ); // populate any meta data $page->getMarkdown(); $link = ltrim(str_replace( Config::inst()->get('DocumentationViewer', 'link_base'), '', $page->Link() ), '/'); $this->pages[$link] = array( 'title' => $page->getTitle(), 'filepath' => $path, 'basename' => $basename, 'type' => 'DocumentationPage', 'summary' => $page->getSummary() ); } /** * Generate an {@link ArrayList} of the pages to the given page. * * @param DocumentationPage * @param DocumentationEntityLanguage * * @return ArrayList */ public function generateBreadcrumbs($record, $base) { $output = new ArrayList(); $parts = explode('/', trim($record->getRelativeLink(), '/')); // the first part of the URL should be the language, so shift that off // so we just have the core pages. array_shift($parts); // Add the base link. $output->push(new ArrayData(array( 'Link' => $base->Link(), 'Title' => $base->Title ))); $progress = $base->Link(); foreach($parts as $part) { if($part) { $progress = Controller::join_links($progress, $part, '/'); $output->push(new ArrayData(array( 'Link' => $progress, 'Title' => DocumentationHelper::clean_page_name($part) ))); } } return $output; } /** * Determine the next page from the given page. * * Relies on the fact when the manifest was built, it was generated in * order. * * @param string * * @return ArrayData */ public function getNextPage($filepath) { $grabNext = false; foreach($this->getPages() as $url => $page) { if($grabNext) { return new ArrayData(array( 'Link' => $url, 'Title' => $page['title'] )); } if($filepath == $page['filepath']) { $grabNext = true; } } return null; } /** * Determine the previous page from the given page. * * Relies on the fact when the manifest was built, it was generated in * order. * * @param string * * @return ArrayData */ public function getPreviousPage($filepath) { $previousUrl = $previousPage = null; foreach($this->getPages() as $url => $page) { if($filepath == $page['filepath']) { if($previousUrl) { return new ArrayData(array( 'Link' => $previousUrl, 'Title' => $previousPage['title'] )); } } $previousUrl = $url; $previousPage = $page; } return null; } /** * @param string * * @return string */ public function normalizeUrl($url) { return trim($url, '/') .'/'; } /** * Return the children of the provided record path. * * Looks for any pages in the manifest which have one more slash attached. * * @param string $path * * @return ArrayList */ public function getChildrenFor($path, $recursive = true) { $output = new ArrayList(); $base = Config::inst()->get('DocumentationViewer', 'link_base'); $path = $this->normalizeUrl($path); $depth = substr_count($path, '/'); foreach($this->getPages() as $url => $page) { $pagePath = $this->normalizeUrl($page['filepath']); // check to see if this page is under the given path if(strpos($pagePath, $path) === false) { continue; } // if the page is the index page then hide it from the menu if(strpos(strtolower($pagePath), '/index.md/')) { $pagePath = substr($pagePath, 0, strpos($pagePath, "index.md/")); } // only pull it up if it's one more level depth if(substr_count($pagePath, DIRECTORY_SEPARATOR) == ($depth + 1)) { // found a child $mode = ($pagePath == $path) ? 'current' : 'link'; $children = new ArrayList(); if($mode == 'current' && $recursive) { // $children = $this->getChildrenFor($url, false); } $output->push(new ArrayData(array( 'Link' => Controller::join_links($base, $url, '/'), 'Title' => $page['title'], 'LinkingMode' => $mode, 'Summary' => $page['summary'], 'Children' => $children ))); } } return $output; } /** * @param DocumentationEntity * * @return ArrayList */ public function getAllVersionsOfEntity(DocumentationEntity $entity) { $all = new ArrayList(); foreach($this->getEntities() as $check) { if($check->getKey() == $entity->getKey()) { if($check->getLanguage() == $entity->getLanguage()) { $all->push($check); } } } return $all; } /** * @param DocumentationEntity * * @return DocumentationEntity */ public function getStableVersion(DocumentationEntity $entity) { foreach($this->getEntities() as $check) { if($check->getKey() == $entity->getKey()) { if($check->getLanguage() == $entity->getLanguage()) { if($check->getIsStable()) { return $check; } } } } return $entity; } /** * @param DocumentationEntity * * @return ArrayList */ public function getVersions($entity) { if(!$entity) { return null; } $output = new ArrayList(); foreach($this->getEntities() as $check) { if($check->getKey() == $entity->getKey()) { if($check->getLanguage() == $entity->getLanguage()) { $same = ($check->getVersion() == $entity->getVersion()); $output->push(new ArrayList(array( 'Title' => $entity->getTitle(), 'Link' => $entity->getLink(), 'LinkingMode' => ($same) ? 'current' : 'link' ))); } } } return $output; } /** * Returns a sorted array of all the unique versions registered */ public function getAllVersions() { $versions = array(); foreach($this->getEntities() as $entity) { $versions[$entity->getVersion()] = $entity->getVersion(); } $uniqueVersions = array_unique( ArrayLib::flatten(array_values($versions)) ); asort($uniqueVersions); return array_combine($uniqueVersions, $uniqueVersions); } }