Commit 69d21fd3 authored by Sean Madsen's avatar Sean Madsen

Merge branch '50-book-definition' into 'master'

Allow book versions to be defined in a more flexible way

Closes #50

See merge request documentation/docs-publisher!79
parents 44221d46 78b9cafb
......@@ -2,7 +2,6 @@
# http://symfony.com/doc/current/book/service_container.html
parameters:
books_dir: %kernel.root_dir%/../books
publish_path_root: %kernel.root_dir%/../web
services:
......@@ -48,6 +47,12 @@ services:
- 'publish'
- ['@streamhandler']
redirecter:
class: AppBundle\Utils\Redirecter
arguments:
- '@paths'
- '@library'
streamhandler:
class: Monolog\Handler\StreamHandler
arguments:
......@@ -56,6 +61,6 @@ services:
app.exception_listener:
class: AppBundle\EventListener\ExceptionListener
arguments:
- %publish_path_root%
- '@redirecter'
tags:
- { name: kernel.event_listener, event: kernel.exception, method: onKernelException }
......@@ -2,7 +2,5 @@ name: Activity iCal
description: Provides an iCalendar feed of assigned activities per contact
type: extension
langs:
en:
repo: 'https://github.com/twomice/com.joineryhq.activityical'
latest: master
stable: master
en:
repo: 'https://github.com/twomice/com.joineryhq.activityical'
......@@ -3,7 +3,5 @@ weight: 10
description: For computer programmers who create and improve functionality within CiviCRM
category: Core
langs:
en:
repo: 'https://github.com/civicrm/civicrm-dev-docs'
latest: master
stable: master
en:
repo: 'https://github.com/civicrm/civicrm-dev-docs'
......@@ -2,7 +2,7 @@ name: SMS conversation
description: Automate SMS conversations with your contacts
type: extension
langs:
en:
repo: https://github.com/3sd/civicrm-sms-conversation
latest: master
stable: master
en:
repo: https://github.com/3sd/civicrm-sms-conversation
latest: master
stable: master
name: SparkPost
description: Allows CiviCRM to send emails and process bounces through the SparkPost service.
langs:
en:
repo: 'https://github.com/cividesk/com.cividesk.email.sparkpost'
latest: master
stable: master
en:
repo: 'https://github.com/cividesk/com.cividesk.email.sparkpost'
latest: master
stable: master
......@@ -3,7 +3,7 @@ weight: 0
description: For tech savvy people who install, upgrade, and maintain CiviCRM for an organization
category: Core
langs:
en:
repo: 'https://github.com/civicrm/civicrm-sysadmin-guide'
latest: master
stable: master
en:
repo: 'https://github.com/civicrm/civicrm-sysadmin-guide'
latest: master
stable: master
......@@ -3,20 +3,24 @@ weight: -100
description: For staff members who use CiviCRM's web-based interface as part of their job at an organization
category: Core
langs:
en:
repo: 'https://github.com/civicrm/civicrm-user-guide'
latest: master
stable: master
history:
- 4.6
ca:
repo: 'https://github.com/babu-cat/civicrm-user-guide-ca'
latest: master
fr:
repo: 'https://github.com/civicrm-french/civicrm-user-guide'
latest: master
stable: master
es:
repo: 'https://github.com/ixiam/civicrm-user-guide-spanish'
latest: master
stable: master
en:
repo: 'https://github.com/civicrm/civicrm-user-guide'
versions:
4.7: # This is stored as $slug
name: 4.7 / Latest # If omitted, use $slug
path: latest # If omitted, use a URL-safe version of $slug
branch: master # If omitted, use $slug
redirects: # These are not displayed anywhere
- current
- stable
4.6:
branch: 4.6
name: 4.6 / Long Term Support
redirects:
- lts
ca:
repo: 'https://github.com/babu-cat/civicrm-user-guide-ca'
fr:
repo: 'https://github.com/civicrm-french/civicrm-user-guide'
es:
repo: 'https://github.com/ixiam/civicrm-user-guide-spanish'
......@@ -2,27 +2,22 @@
namespace AppBundle\EventListener;
use AppBundle\Utils\StringTools;
use Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use \AppBundle\Model\Library;
class ExceptionListener {
/**
* @var string Filesystem path to the directory where all published books go
*/
public $publishPathRoot;
public $redirecter;
/**
* ExceptionListener constructor.
*
* @param string $publishPathRoot
* Full filesystem path to the directory where books are to be published
* @param \AppBundle\Utils\Redirecter $redirecter
*/
public function __construct($publishPathRoot) {
$this->publishPathRoot = $publishPathRoot;
public function __construct($redirecter) {
$this->redirecter = $redirecter;
}
/**
......@@ -34,7 +29,7 @@ class ExceptionListener {
$exception = $event->getException();
if ($exception instanceof NotFoundHttpException) {
$requestUri = $event->getRequest()->getRequestUri();
$redirect = $this->lookupRedirect($requestUri);
$redirect = $this->redirecter->lookupRedirect($requestUri);
if ($redirect) {
$response = new RedirectResponse($redirect);
$event->setResponse($response);
......@@ -42,53 +37,4 @@ class ExceptionListener {
}
}
/**
* See if we have a redirect stored for the given URI. If so, return the full
* path to it as a string (which begins with a slash). If not, return NULL
*
* @param string $requestUri
* e.g. "/dev/en/latest/my-category/my-page"
*
* @return null|string
* e.g. "/dev/en/latest/foo/bar"
*/
private function lookupRedirect($requestUri) {
// Give up right away if the request contains two dots (for security)
if (strstr($requestUri, '..')) {
return NULL;
}
$requestParts = Library::parseIdentifier($requestUri);
$edition = $requestParts['editionIdentifier'];
$path = $requestParts['path'];
$fragment = $requestParts['fragment'] ? "#${requestParts['fragment']}" : '';
$redirectsFile = $this->publishPathRoot . '/' . $edition . '/redirects.txt';
// If we don't have all the info we need, then give up
if ($edition === NULL || $path === NULL || !file_exists($redirectsFile)) {
return NULL;
}
// Look for a redirect
$redirects = file($redirectsFile);
foreach ($redirects as $redirect) {
$rule = StringTools::parseRedirectRule($redirect);
if (empty($rule)) {
// Skip any rules that are invalid
break;
}
$ruleMatchesRequest = ($rule['from'] == $path);
if ($ruleMatchesRequest) {
if ($rule['type'] == 'internal') {
return "/$edition/${rule['to']}$fragment";
}
else {
return $rule['to'];
}
}
}
return NULL;
}
}
......@@ -15,27 +15,32 @@ class Book {
public $slug;
/**
* @var string The title of the book, taken from the "name"
* @var string
* The title of the book, taken from the "name"
*/
public $name;
/**
* @var string Short phrase describing the book, taken from the
* @var string
* Short phrase describing the book, taken from the
*/
public $description;
/**
* @var array An array (without keys of Language objects to
* @var Language[]
* An array (without keys of Language objects to
*/
public $languages;
/**
* @var int Used to sort books
* @var int
* Used to sort books
*/
public $weight;
/**
* @var string (e.g. "Core", "Extensions") Should be in sentence case
* @var string
* (e.g. "Core", "Extensions") Should be in sentence case
*/
public $category;
......
......@@ -7,12 +7,14 @@ use AppBundle\Utils\LocaleTools;
class Language {
/**
* @var string The URL to the git repository for this language
* @var string
* The URL to the git repository for this language
*/
public $repo;
/**
* @var array An array (without keys) of Version objects
* @var Version[]
* An array (without keys) of Version objects
*/
public $versions;
......@@ -25,7 +27,7 @@ class Language {
/**
*
* @var array
* @var string[]
* Email addresses for people who would like to receive a notification any
* time a version within this language is published
*/
......@@ -58,30 +60,19 @@ class Language {
* Data passed into the language constructor.
*/
private function setupVersions($yaml) {
$latestBranch = isset($yaml['latest']) ? $yaml['latest'] : NULL;
$stableBranch = isset($yaml['stable']) ? $yaml['stable'] : NULL;
$history = isset($yaml['history']) ? $yaml['history'] : array();
if ($latestBranch && $stableBranch) {
if ($latestBranch == $stableBranch) {
$this->addVersion('latest', $latestBranch,
array('stable'));
}
else {
$this->addVersion('latest', $latestBranch);
$this->addVersion('stable', $stableBranch);
}
}
elseif ($latestBranch) {
$this->addVersion('latest', $latestBranch);
}
elseif ($stableBranch) {
$this->addVersion('stable', $latestBranch);
}
foreach ($history as $item) {
$this->addVersion($item);
// Add versions defined in yaml
$versions = $yaml['versions'] ?? [];
foreach ($versions as $slug => $version) {
$name = $version['name'] ?? 'Latest';
$path = $version['path'] ?? $slug;
$branch = $version['branch'] ?? 'master';
$redirects = $version['redirects'] ?? [];
$this->versions[] = new Version($slug, $name, $path, $branch, $redirects);
}
// If no versions were defined, then add one version (with default values)
if (count($this->versions) == 0) {
$this->addVersion('latest', 'master');
$this->versions[] = new Version();
}
}
......@@ -113,21 +104,6 @@ class Language {
}
}
/**
* Adds a new version to this branch
*
* @param string $name
* (e.g. "latest", "master", "4.7", etc.)
* @param string $branch
* (e.g "master", "4.7", etc)
* @param array $aliases
* Array of strings containing names which can also be used to reference
* the version.
*/
public function addVersion($name, $branch = NULL, $aliases = array()) {
$this->versions[] = new Version($name, $branch, $aliases);
}
/**
* Check all versions within this language to make sure there are no
* collisions between name/branch/aliases across different versions.
......
......@@ -7,7 +7,7 @@ use Symfony\Component\Finder\Finder;
class Library {
/**
* @var array
* @var Book[]
* An array (without keys) of Book objects to represent all the books in
* the system.
*/
......@@ -72,7 +72,7 @@ class Library {
$key = "$book->slug/$language->code/$version->branch";
$row = array(
'book' => $book->name,
'language' => $language->englishName(),
'language' => $language->getEnglishName(),
'repo' => $language->repo,
'branch' => $version->branch,
);
......@@ -152,6 +152,8 @@ class Library {
/**
* Parses an identifier into components we can use to identify a book
*
* This is the reverse of assembleIdentifier.
*
* See LibraryTest::identifierProvider() for examples
*
* @param string $identifier
......@@ -167,7 +169,6 @@ class Library {
* 'path' => 'category/foo/my-page',
* 'fragment' => 'some-section',
* ]
*
* The array will aways have the keys shown above, but values will be NULL
* if those components do not exist in the given identifier
*/
......@@ -204,4 +205,80 @@ class Library {
return $result;
}
/**
* Take several pieces of a docs URL, and combined them into a full identifier
*
* This is the reverse of parseIdentifier.
*
* If 'editionIdentifier' is present, it will trump the values in 'bookSlug',
* 'languageCode', and 'versionDescriptor'.
*
* @param array $pieces
* e.g.
* [
* 'bookSlug' => 'dev',
* 'languageCode' => 'en',
* 'versionDescriptor' => 'latest',
* 'editionIdentifier' => 'dev/en/latest',
* 'path' => 'category/foo/my-page',
* 'fragment' => 'some-section',
* ]
*
* @return string
*/
public static function assembleIdentifier($pieces) {
$result = '';
if ($pieces['editionIdentifier']) {
$result .= $pieces['editionIdentifier'];
}
else {
if ($pieces['bookSlug']) {
$result .= $pieces['bookSlug'];
}
else {
return $result;
}
if ($pieces['languageCode']) {
$result .= '/' . $pieces['languageCode'];
}
else {
return $result;
}
if ($pieces['versionDescriptor']) {
$result .= '/' . $pieces['versionDescriptor'];
}
else {
return $result;
}
}
if ($pieces['path']) {
$result .= '/' . $pieces['path'];
}
else {
return $result;
}
if ($pieces['fragment']) {
$result .= '/#' . $pieces['fragment'];
}
return $result;
}
/**
* @param string $identifier
* examples: "user/en/master", "user/en", "user", ""
*
* @return array
*/
public function getObjectsByIdentifier($identifier) {
$parts = self::parseIdentifier($identifier);
$book = $this->getBookBySlug($parts['bookSlug']);
$language = $book->getLanguageByCode($parts['languageCode']);
$version = $language->getVersionByDescriptor($parts['versionDescriptor']);
return [
'book' => $book,
'language' => $language,
'version' => $version,
];
}
}
......@@ -8,7 +8,24 @@ class Version {
/**
* @var string
* Version name (e.g. "4.6" or "latest"). This is what readers see.
* The machine readable name of this version. For example, in a book with
* multiple versions, the slug should be defined to correspond to the
* version numbers of the product (i.e. "4.7", or "4.6"). The slug can be
* "master" if a book is only using one version.
*/
public $slug;
/**
* @var string
* This is the URL component for the published book. If it's not defined
* in the book's yaml file, then we use $slug as the path component.
*/
public $path;
/**
* @var string
* A human-readable name of this version (e.g. "4.7 / Current" or "latest").
* This is what readers see.
* Sometimes it's the same as name of the branch, but not always. It can
* contain pretty much whatever characters you want.
*/
......@@ -24,17 +41,17 @@ class Version {
public $branch;
/**
* @var array
* An array (without keys) of strings which represent aliases to this
* version of the book. For each alias, we will create symbolic links so
* that a reader can also access this version of the book at a URL with that
* alias.
* @var string[]
* An array (without keys) of strings which represent redirects to this
* version of the book.
* (e.g. ["latest", "current"])
* If a reader requests a page with one of these redirects in place of the
* $path, then the app will redirect them to the proper page.
*/
public $aliases;
public $redirects;
/**
* Defines a new "version" of a book, with aliases.
*
* A version has one and only
* one "branch", meaning the git branch used for the version. A version also
* can have many "aliases", which are other descriptors like "stable" that we
......@@ -43,63 +60,66 @@ class Version {
* links to this directory for each of the aliases. So if the branch is
* "master" and we have an alias called "stable", then the book will be
* accessible at "master" via the directory and at "stable" via the symlink.
*
* If the constructor receives different $name and $branch values, it will
* automatically add an alias for $name.
*
* @param string $slug
* @see Version::slug
* @param string $name
* e.g. "latest", "master", "4.7"
*
* @see Version::name
* @param null $path
* @see Version::path
* @param string $branch
* e.g "master", "4.7"
*
* @param array $aliases
* Array of strings containing names which can also be used to reference
* this version.
* @see Version::branch
* @param array $redirects
* @see Version::redirects
*/
public function __construct($name, $branch = NULL, $aliases = array()) {
public function __construct($slug = 'latest', $name = 'Latest', $path = NULL, $branch = 'master', $redirects = []) {
$this->slug = $slug;
$this->name = $name;
$this->branch = $branch ?: $name;
$this->setupAliases($aliases);
$this->path = $path ?? $slug;
$this->branch = $branch;
$this->setupRedirects($redirects);
}
/**
* @param $aliases array|string
* @param $redirects array|string
* e.g. "latest"
*/
private function setupAliases($aliases) {
private function setupRedirects($redirects) {
// wrap $aliases in array, if necessary
if (!is_array($aliases)) {
$aliases = array($aliases);
if (!is_array($redirects)) {
$redirects = array($redirects);
}
// Add an alias for $name if necessary
if ($this->name != $this->branch && !isset($aliases[$this->name])) {
$aliases[] = $this->name;
}
// Remove alias for $path if it exists
unset($redirects[$this->path]);
// Add alias for $branch (e.g. so urls with "master" will work correctly)
$redirects[] = $this->branch;
// Remove alias for $branch if it exists
unset($aliases[$this->branch]);
// Add alias for $slug (e.g. so urls with "4.7" will work correctly)
$redirects[] = $this->slug;
// Make sure each alias is URL-safe
foreach ($aliases as &$alias) {
$alias = StringTools::urlSafe($alias);
foreach ($redirects as &$redirect) {
$redirect = StringTools::urlSafe($redirect);
}
$this->aliases = array_unique($aliases);
// Make sure we don't have any duplicate branches
$this->redirects = array_unique($redirects);
}
/**
* Gives an array of all unique strings that can be used to describe this
* version, including branch, name, and any aliases.
* version, including the path plus any aliases.
*
* @return array
* Array of strings (without keys)
*/
public function allDescriptors() {
$result = $this->aliases;
$result[] = $this->name;
$result[] = $this->branch;
$result = $this->redirects;
$result[] = $this->path;
return array_unique($result);
}
......@@ -114,7 +134,7 @@ class Version {
*/
public function validate() {
if (preg_match("#/#", $this->branch)) {
throw new Exception("Branch name can not contain a forward slash.");
throw new \Exception("Branch name can not contain a forward slash.");
}
}
......
......@@ -12,7 +12,7 @@
{% set default_language = book.getDefaultLanguage() %}
{% set default_version = default_language.getDefaultVersion() %}
{% set default_url = "/" ~ book.slug ~ "/" ~ default_language.code ~ "/"
~ default_version.name %}
~ default_version.path %}
{% set default_title = default_language.nativeName() ~ " / " ~ default_version.name %}
<li>Default edition: <a href="{{ default_url }}">{{ default_title }}</a></li>
<li>Category: {{ book.category }}</li>
......@@ -32,7 +32,7 @@
</tr>
{% for language in book.languages %}
{% for version in language.versions %}
{% set url = "/" ~ book.slug ~ "/" ~ language.code ~ "/" ~ version.name %}
{% set url = "/" ~ book.slug ~ "/" ~ language.code ~ "/" ~ version.path %}
<tr>
<td class="language">
<span class="native-name">{{ language.nativeName }}</span>
......
......@@ -5,13 +5,13 @@
<div>
{% set language = book.getDefaultLanguage %}
{% set version = language.getDefaultVersion %}
{% set url = "/" ~ book.slug ~ "/" ~ language.code ~ "/" ~ version.name %}
{% set url = "/" ~ book.slug ~ "/" ~ language.code ~ "/" ~ version.path %}
<a href="{{ url }}">
<span class="name">
{{ book.name }}
</span>
<span class="edition">
({{ language.nativeName() }} / {{ version.name }})
({{ language.nativeName() }} &ndash; {{ version.name }})
</span>
</a>
{% set countOtherEditions = book.countEditions - 1 %}
......
......@@ -48,7 +48,7 @@ class Publisher {
private $publishingMessages = [];
/**
* @param LoggerInterface $logger
* @param Logger $logger
* @param Filesystem $fs
* @param Library $library
* @param MkDocs $mkDocs
......@@ -168,7 +168,7 @@ class Publisher {
$language = $this->getLanguage($book, $languageCode);
if ($language) {
foreach ($language->versions as $version) {
$this->publishVersion($book, $language, $version->branch);
$this->publishVersion($book, $language, $version->slug);
}
}
}
......@@ -180,7 +180,9 @@ class Publisher {
* @param Language $language
* @param string $versionDescriptor
* Can be the name of the version, the name of the git branch, or a name
* of an alias defined for the version
* of a redirect defined for the version
*
* @throws \Exception
*/
private function publishVersion($book, $language, $versionDescriptor) {
$version = $this->getVersion($book, $language, $versionDescriptor);
......@@ -192,7 +194,7 @@ class Publisher {
$tmpPublishDir = $this->makeTmpDir('publish_' . $tmpPrefix);
$repoRoot = $this->paths->getRepoPathRoot();
$masterRepoDir = sprintf('%s/%s/%s', $repoRoot, $slug, $langCode);
$webPath = sprintf('%s/%s/%s', $slug, $langCode, $branch);
$webPath = sprintf('%s/%s/%s', $slug, $langCode, $version->path);
$webRoot = $this->paths->getPublishPathRoot() . "/" . $webPath;
try {
......@@ -210,7 +212,6 @@ class Publisher {
// remove existing and copy new
$this->fs->removeDir($webRoot);
$this->fs->copyDir($tmpPublishDir, $webRoot);
$this->setupSymlinks($book, $language, $version, $webRoot);
$this->setupRedirects($tmpRepoDir, $webRoot);
$msg = sprintf("<a href='/%s'>Book published!</a>.", $webPath);
......@@ -230,6 +231,8 @@ class Publisher {
* @param string $bookSlug
*
* @return Book
*
* @throws \Exception
*/
private function getBook($bookSlug) {
$book = $this->library->getBookBySlug($bookSlug);
......@@ -249,6 +252,8 @@ class Publisher {
* @param string $languageCode
*
* @return Language
*
* @throws \Exception
*/
private function getLanguage($book, $languageCode) {
$language = $book->getLanguageByCode($languageCode);
......@@ -272,6 +277,8 @@ class Publisher {
* @param string $versionDescriptor
*
* @return Version
*
* @throws \Exception
*/
private function getVersion($book, $language, $versionDescriptor) {
$version = $language->getVersionByDescriptor($versionDescriptor);
......@@ -298,6 +305,8 @@ class Publisher {
* @param Version $version
* @param string $repoPath
* @param string $publishPath
*
* @throws \Exception
*/
private function build(
Book $book,
......@@ -319,37 +328,6 @@ class Publisher {
}
}
/**
* Check and update symlinks so that latest and stable point to the right
* places
*
* @param Book $book
* @param Language $language