├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── composer.json ├── gitiki ├── package.json ├── phpunit.xml.dist ├── src ├── Command │ ├── BootstrapCommand.php │ └── WebpackCommand.php ├── Console.php ├── Controller │ ├── CommonController.php │ ├── ImageController.php │ └── PageController.php ├── Event │ ├── Events.php │ └── Listener │ │ ├── FileLoader.php │ │ ├── Image.php │ │ ├── Markdown.php │ │ ├── Metadata.php │ │ ├── NavigationSource.php │ │ ├── PathFixer.php │ │ ├── RedirectIndexHtml.php │ │ └── WikiLink.php ├── Exception │ ├── InvalidSizeException.php │ ├── PageNotFoundException.php │ └── PageRedirectedException.php ├── Extension │ ├── BootstrapInterface.php │ └── WebpackInterface.php ├── ExtensionInterface.php ├── Git │ ├── Controller │ │ └── DiffController.php │ ├── Event │ │ └── Listener │ │ │ └── NavigationHistory.php │ ├── GitExtension.php │ ├── Gitonomy │ │ ├── Commit.php │ │ └── Repository.php │ └── Resources │ │ ├── assets │ │ ├── bootstrap.json │ │ └── css │ │ │ └── git.css │ │ ├── bin │ │ └── git-diff-highlight │ │ ├── translations │ │ ├── en.yml │ │ └── fr.yml │ │ └── views │ │ ├── diff.html.twig │ │ └── history.html.twig ├── Gitiki.php ├── Image.php ├── Image │ ├── AbstractImage.php │ ├── GdImage.php │ ├── ImageInterface.php │ └── NullImage.php ├── Page.php ├── PageNav.php ├── Parser.php ├── PathResolver.php ├── Resources │ ├── assets │ │ ├── _main.scss │ │ ├── _variables.scss │ │ └── bootstrap.json │ ├── translations │ │ ├── en.yml │ │ └── fr.yml │ └── views │ │ ├── base.html.twig │ │ ├── image.html.twig │ │ ├── menu.html.twig │ │ ├── navigation.html.twig │ │ ├── page.html.twig │ │ └── toc.html.twig ├── Route.php ├── RouteCollection.php ├── TocBuilder.php ├── Twig │ └── CoreExtension.php └── UrlGenerator.php ├── test ├── Event │ └── Listener │ │ ├── FileLoaderTest.php │ │ ├── ImageTest.php │ │ ├── MarkdownTest.php │ │ ├── MetadataTest.php │ │ ├── RedirectTest.php │ │ ├── WikiLinkTest.php │ │ └── fixtures │ │ └── bar.md ├── ParserTest.php ├── RouteCollectionTest.php ├── RouteTest.php └── TocBuilderTest.php ├── webpack.config.js ├── webpack ├── bootstrap-sass.config.js └── font-awesome-sass.config.js └── wiki ├── .gitiki.yml ├── _menu.md ├── extension ├── code-highlight.md ├── git.md ├── index.md └── redirector.md ├── feature ├── image.md └── index.md ├── features.md ├── index.md ├── installation.md └── photos └── cannelle.jpg /.gitignore: -------------------------------------------------------------------------------- 1 | /composer.lock 2 | /node_modules/ 3 | /phpunit.xml 4 | /vendor/ 5 | /webpack/bootstrap.json 6 | /webpack/*.entry.js 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | php: 4 | - 5.4 5 | - 5.5 6 | - 5.6 7 | - 7.0 8 | - hhvm 9 | 10 | matrix: 11 | fast_finish: true 12 | allow_failures: 13 | - php: 7.0 14 | - php: hhvm 15 | 16 | before_script: 17 | - composer self-update 18 | - composer show --platform 19 | - composer install --prefer-dist --no-interaction 20 | 21 | script: 22 | - phpunit --coverage-text 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Francis Besset 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gitiki 2 | 3 | [![Build 4 | Status](https://travis-ci.org/gitiki/Gitiki.svg?branch=master)](https://travis-ci.org/gitiki/Gitiki) 5 | 6 | This project is still in development. 7 | 8 | ## Presentation 9 | 10 | Gitiki is an open source PHP wiki engine from [markdown](http://gitiki.org/#markdown) files and a Git repository (or not). 11 | 12 | ### Why markdown? 13 | 14 | [Markdown](http://daringfireball.net/projects/markdown/syntax) is a simple syntax to structure an information and easy to learn. 15 | It is a natural choice to build a wiki! 16 | 17 | ## Features 18 | 19 | * [Link wiki pages](http://gitiki.org/feature/#link) 20 | * [Specify id attributes on header block](http://gitiki.org/feature/#header-id) 21 | * [Include image](http://gitiki.org/feature/image.html) 22 | * Table of Contents 23 | 24 | ## Extensions 25 | 26 | Gitiki can be extended with [extensions](http://gitiki.org/extension/). 27 | 28 | ## About 29 | 30 | This project is enhanced by [Silex](http://silex.sensiolabs.org), [Symfony2 Yaml](http://symfony.com/doc/current/components/yaml/index.html) and [Parsedown](http://parsedown.org) library. 31 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gitiki/gitiki", 3 | "description": "PHP wiki engine from markdown files and a Git repository (or not)", 4 | "license": "MIT", 5 | "homepage": "http://gitiki.org/", 6 | "keywords": ["gitiki", "wiki", "markdown", "git"], 7 | "authors": [ 8 | { 9 | "name": "Francis Besset", 10 | "email": "francis.besset@gmail.com" 11 | } 12 | ], 13 | "support": { 14 | "issues": "https://github.com/gitiki/Gitiki/issues", 15 | "irc": "irc://irc.freenode.net/gitiki" 16 | }, 17 | "require": { 18 | "php": ">=5.4", 19 | "ext-dom": "*", 20 | "ext-intl": "*", 21 | 22 | "erusev/parsedown": "1.5.*", 23 | "gitonomy/gitlib": "0.1.*", 24 | "silex/silex": "1.3.*", 25 | "symfony/config": "~2.7", 26 | "symfony/console": "~2.7", 27 | "symfony/expression-language": "~2.7", 28 | "symfony/translation": "~2.7", 29 | "symfony/twig-bridge": "~2.7", 30 | "symfony/yaml": "~2.7", 31 | "twig/twig": "~1.18", 32 | "yohang/htmltools": "~0.1" 33 | }, 34 | "suggest": { 35 | "ext-gd": "Resize and crop image", 36 | "gitiki/code-highlight": "Highlight code blocks with highlight.js library", 37 | "gitiki/redirector": "Redirect an old page to new one" 38 | }, 39 | "autoload": { 40 | "psr-4": { "Gitiki\\": "src/" } 41 | }, 42 | "extra": { 43 | "branch-alias": { 44 | "dev-master": "1.0-dev" 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /gitiki: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | run(); 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gitiki", 3 | "repository": "gitiki/Gitiki", 4 | "author": "Francis Besset ", 5 | "license": "SEE LICENSE IN LICENSE", 6 | "dependencies": { 7 | "bootstrap-sass": "^3.3.5", 8 | "bootstrap-sass-loader": "^1.0.9", 9 | "css-loader": "*", 10 | "expose-loader": "^0.7.1", 11 | "extract-text-webpack-plugin": "*", 12 | "file-loader": "*", 13 | "font-awesome": "^4.4.0", 14 | "font-awesome-sass-loader": "^1.0.0", 15 | "jquery": "^2.1", 16 | "jsonfile": "^2.2.3", 17 | "node-sass": "*", 18 | "sass-loader": "*", 19 | "style-loader": "*", 20 | "url-loader": "*", 21 | "webpack": "^1.12.3" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ./test/ 7 | 8 | 9 | 10 | 11 | 12 | ./src/ 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/Command/BootstrapCommand.php: -------------------------------------------------------------------------------- 1 | setName('bootstrap') 16 | ->setDescription('Generate the bootstrap.json') 17 | ->setHelp(<<<'EOF' 18 | The %command.name% command generate the bootstrap.json: 19 | php %command.full_name% 20 | php %command.full_name% --wiki-dir="wiki/dir/" 21 | EOF 22 | ) 23 | ; 24 | } 25 | 26 | protected function execute(InputInterface $input, OutputInterface $output) 27 | { 28 | $output->writeln('Collect required bootstrap components'); 29 | $bootstrap = $this->getJsonContent(__DIR__.'/../Resources/assets/bootstrap.json'); 30 | 31 | foreach ($this->getApplication()->getGitiki()->getExtensions() as $extension) { 32 | if (!$extension instanceof BootstrapInterface) { 33 | continue; 34 | } 35 | 36 | $bootstrap = array_replace_recursive($bootstrap, $this->getJsonContent($extension->getBootstrap())); 37 | } 38 | 39 | $destinationPath = __DIR__.'/../../webpack/bootstrap.json'; 40 | $output->writeln('Write compiled bootstrap components'); 41 | file_put_contents($destinationPath, json_encode($bootstrap, JSON_PRETTY_PRINT)); 42 | 43 | $output->writeln(sprintf( 44 | 'The bootstrap.json has been successfully written: %s', 45 | realpath($destinationPath) 46 | )); 47 | } 48 | 49 | private function getJsonContent($jsonPath) 50 | { 51 | return json_decode(file_get_contents($jsonPath), true); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Command/WebpackCommand.php: -------------------------------------------------------------------------------- 1 | setName('webpack') 16 | ->setDescription('Generate webpack entries') 17 | ->setHelp(<<<'EOF' 18 | The %command.name% command generate the bootstrap.json: 19 | php %command.full_name% 20 | php %command.full_name% --wiki-dir="wiki/dir/" 21 | EOF 22 | ) 23 | ; 24 | } 25 | 26 | protected function execute(InputInterface $input, OutputInterface $output) 27 | { 28 | $output->writeln('Collect webpack entries'); 29 | 30 | $gitiki = $this->getApplication()->getGitiki(); 31 | foreach ($gitiki->getExtensions() as $extension) { 32 | if (!$extension instanceof WebpackInterface) { 33 | continue; 34 | } 35 | 36 | foreach ($extension->getWebpackEntries($gitiki) as $name => $requires) { 37 | $output->writeln(sprintf('Write %s.entry.js file', $name)); 38 | $f = fopen(sprintf('%s/../../webpack/%s.entry.js', __DIR__, $name) , 'w'); 39 | 40 | foreach ((array) $requires as $require) { 41 | fwrite($f, sprintf('require("%s");', $require)."\n"); 42 | } 43 | 44 | fclose($f); 45 | } 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Console.php: -------------------------------------------------------------------------------- 1 | gitiki; 22 | } 23 | 24 | public function doRun(InputInterface $input, OutputInterface $output) 25 | { 26 | $wikiDir = $this->getWikiDir($input) ?: getcwd(); 27 | $this->gitiki = new Gitiki($wikiDir); 28 | 29 | return parent::doRun($input, $output); 30 | } 31 | 32 | protected function getDefaultCommands() 33 | { 34 | $commands = parent::getDefaultCommands(); 35 | 36 | $commands[] = new Command\BootstrapCommand(); 37 | $commands[] = new Command\WebpackCommand(); 38 | 39 | return $commands; 40 | } 41 | 42 | protected function getDefaultInputDefinition() 43 | { 44 | $definition = parent::getDefaultInputDefinition(); 45 | 46 | $definition->addOption(new InputOption('--wiki-dir', '-d', InputOption::VALUE_REQUIRED, 'If specified, use the given directory as wiki directory.')); 47 | 48 | return $definition; 49 | } 50 | 51 | private function getWikiDir(InputInterface $input) 52 | { 53 | $wikiDir = $input->getParameterOption(array('--wiki-dir', '-d')); 54 | 55 | if (false !== $wikiDir && !is_dir($wikiDir)) { 56 | throw new \RuntimeException('Invalid wiki directory specified.'); 57 | } 58 | 59 | return $wikiDir; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Controller/CommonController.php: -------------------------------------------------------------------------------- 1 | getParentRequest()) { 14 | throw $gitiki->abort(404, 'The page "/_menu" cannot be accessed directly.'); 15 | } 16 | 17 | try { 18 | $page = $gitiki->getPage('/_menu.md'); 19 | } catch (PageNotFoundException $e) { 20 | return ''; 21 | } 22 | 23 | return $gitiki['twig']->render('menu.html.twig', [ 24 | 'menu' => $page->getMetas(), 25 | ]); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Controller/ImageController.php: -------------------------------------------------------------------------------- 1 | isFile() || false === $image->isReadable()) { 17 | $gitiki->abort(404, sprintf('The image "%s" was not found.', $image->getRelativePath())); 18 | } 19 | 20 | if ($request->query->has('details')) { 21 | return $gitiki['twig']->render('image.html.twig', [ 22 | 'page' => $image, 23 | ]); 24 | } 25 | 26 | $response = $gitiki->sendFile($image)->setMaxAge(0); 27 | 28 | if (!$response->isNotModified($request) && null !== $size = $request->query->get('size')) { 29 | try { 30 | $response 31 | ->setFile($gitiki['image']->resize($image, $size), null, false, false) 32 | ->deleteFileAfterSend(true) 33 | ; 34 | } catch (InvalidSizeException $e) { 35 | } 36 | } 37 | 38 | return $response; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Controller/PageController.php: -------------------------------------------------------------------------------- 1 | getPage($path); 22 | } catch (PageNotFoundException $e) { 23 | $gitiki->abort(404, sprintf('The page "%s" was not found.', $e->getPage())); 24 | } 25 | 26 | return $gitiki['twig']->render('page.html.twig', [ 27 | 'page' => $page, 28 | ]); 29 | } 30 | 31 | public function sourceAction(Gitiki $gitiki, Request $request, $path) 32 | { 33 | $page = new Page($path); 34 | $gitiki['dispatcher']->dispatch(Events::PAGE_LOAD, new GenericEvent($page)); 35 | 36 | return new Response($page->getContent(), 200, [ 37 | 'content-type' => 'text/plain', 38 | ]); 39 | } 40 | 41 | public function navigationAction(Gitiki $gitiki, $path) 42 | { 43 | $pageNav = new PageNav(new Page($path)); 44 | $gitiki['dispatcher']->dispatch(Events::PAGE_NAVIGATION, new GenericEvent($pageNav)); 45 | 46 | return $gitiki['twig']->render('navigation.html.twig', [ 47 | 'nav' => $pageNav, 48 | ]); 49 | } 50 | 51 | public function pageDirectoryAction(Gitiki $gitiki, Request $request, $path) 52 | { 53 | return $gitiki->handle( 54 | Request::create($request->getBaseUrl().$path.'index.html', 'GET', $request->query->all(), [], [], $request->server->all()), 55 | HttpKernelInterface::SUB_REQUEST, 56 | false 57 | ); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Event/Events.php: -------------------------------------------------------------------------------- 1 | wikiDir = $wikiDir; 18 | } 19 | 20 | public function onLoad(Event $event) 21 | { 22 | $page = $event->getSubject(); 23 | 24 | if (!is_file($pagePath = $this->wikiDir.$page->getName())) { 25 | throw new PageNotFoundException($page->getName()); 26 | } 27 | 28 | $page->setContent(file_get_contents($pagePath)); 29 | } 30 | 31 | public static function getSubscribedEvents() 32 | { 33 | return [ 34 | Events::PAGE_LOAD => ['onLoad', 1024], 35 | ]; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Event/Listener/Image.php: -------------------------------------------------------------------------------- 1 | urlGenerator = $urlGenerator; 18 | } 19 | 20 | public function onContent(Event $event) 21 | { 22 | $page = $event->getSubject(); 23 | 24 | foreach ($page->getDocument()->getElementsByTagName('img') as $image) { 25 | $url = parse_url($image->getAttribute('src')); 26 | if (isset($url['host'])) { 27 | continue; 28 | } 29 | 30 | if (isset($url['query'])) { 31 | parse_str($url['query'], $query); 32 | } 33 | 34 | $src = $this->urlGenerator->generate('image', ['path' => $url['path']]); 35 | 36 | if ('a' !== $image->parentNode->nodeName && (!isset($query['link']) || 'no' !== $query['link'])) { 37 | $a = $image->parentNode->insertBefore($page->getDocument()->createElement('a'), $image); 38 | $a->appendChild($image); 39 | $a->setAttribute('href', $src.(isset($query['link']) && 'direct' === $query['link'] ? '' : '?details')); 40 | } 41 | 42 | if (isset($query['link'])) { 43 | unset($query['link']); 44 | } 45 | 46 | if (!empty($query)) { 47 | $src .= '?'.http_build_query($query); 48 | } 49 | 50 | $image->setAttribute('src', $src); 51 | 52 | unset($query); 53 | } 54 | } 55 | 56 | public static function getSubscribedEvents() 57 | { 58 | return [ 59 | Events::PAGE_CONTENT => ['onContent', 256], 60 | ]; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Event/Listener/Markdown.php: -------------------------------------------------------------------------------- 1 | page($event->getSubject()); 16 | } 17 | 18 | public static function getSubscribedEvents() 19 | { 20 | return [ 21 | Events::PAGE_CONTENT => ['onContent', 1024], 22 | ]; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Event/Listener/Metadata.php: -------------------------------------------------------------------------------- 1 | getSubject(); 16 | if (!preg_match('/^\-{3,}\n(.+)\n\-{3,}(?:\n(.*))?$/sU', $page->getContent(), $matches)) { 17 | return; 18 | } 19 | 20 | $page->setMetas($matches[1]); 21 | $page->setContent( 22 | isset($matches[2]) ? $matches[2] : null 23 | ); 24 | } 25 | 26 | public function onMetaParse(Event $event) 27 | { 28 | $page = $event->getSubject(); 29 | 30 | if (is_string($page->getMetas())) { 31 | $page->setMetas(Yaml::parse($page->getMetas())); 32 | } 33 | } 34 | 35 | public static function getSubscribedEvents() 36 | { 37 | return [ 38 | Events::PAGE_META => [ 39 | ['onMetaLoad', 2048], 40 | ['onMetaParse', 1024], 41 | ], 42 | ]; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Event/Listener/NavigationSource.php: -------------------------------------------------------------------------------- 1 | getSubject(); 15 | 16 | $pageNav->add('file-text', 'page_source', [ 17 | 'path' => $pageNav->getPage()->getName() 18 | ]); 19 | } 20 | 21 | public static function getSubscribedEvents() 22 | { 23 | return [ 24 | Events::PAGE_NAVIGATION => ['onNavigation', 1024], 25 | ]; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Event/Listener/PathFixer.php: -------------------------------------------------------------------------------- 1 | getRequest()->attributes; 15 | if (false === $attributes->has('path')) { 16 | return; 17 | } 18 | 19 | $path = $attributes->get('path'); 20 | if (empty($path)) { 21 | $attributes->set('path', '/'); 22 | 23 | return; 24 | } elseif ('/' !== $path{0}) { 25 | $path = '/'.$path; 26 | } 27 | 28 | if ('/' !== substr($path, -1) && in_array($attributes->get('_format'), ['html', 'md'], true)) { 29 | $path .= '.md'; 30 | } 31 | 32 | $attributes->set('path', $path); 33 | } 34 | 35 | public static function getSubscribedEvents() 36 | { 37 | return [ 38 | KernelEvents::REQUEST => ['onKernelRequest', 8], 39 | ]; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Event/Listener/RedirectIndexHtml.php: -------------------------------------------------------------------------------- 1 | gitiki = $gitiki; 20 | } 21 | 22 | public function onKernelRequest(GetResponseEvent $event) 23 | { 24 | $attributes = $event->getRequest()->attributes; 25 | 26 | if (HttpKernelInterface::MASTER_REQUEST !== $event->getRequestType()) { 27 | return; 28 | } elseif (false === $attributes->has('_format') || 'html' !== $attributes->get('_format')) { 29 | return; 30 | } elseif (false === $attributes->has('path')) { 31 | return; 32 | } elseif (null === $ifIndex = $this->gitiki['routes']->get($attributes->get('_route'))->getOption('_if_index')) { 33 | return; 34 | } elseif (!preg_match('#^(.*/)index\.md$#', $attributes->get('path'), $match)) { 35 | return; 36 | } 37 | 38 | $event->setResponse($this->gitiki->redirect($this->gitiki->path( 39 | $ifIndex[0], $ifIndex[1]($event->getRequest()) 40 | ), 301)); 41 | } 42 | 43 | public static function getSubscribedEvents() 44 | { 45 | return [ 46 | KernelEvents::REQUEST => 'onKernelRequest', 47 | ]; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Event/Listener/WikiLink.php: -------------------------------------------------------------------------------- 1 | wikiDir = $wikiDir; 23 | 24 | $this->pathResolver = $pathResolver; 25 | $this->urlGenerator = $urlGenerator; 26 | } 27 | 28 | public function onContent(Event $event) 29 | { 30 | $page = $event->getSubject(); 31 | 32 | foreach ($page->getDocument()->getElementsByTagName('a') as $link) { 33 | $url = parse_url($link->getAttribute('href')); 34 | if (isset($url['host'])) { 35 | $link->setAttribute('class', 'external'); 36 | 37 | continue; 38 | } elseif (!isset($url['path'])) { // a internal link can be just a fragment 39 | continue; 40 | } 41 | 42 | $href = $this->urlGenerator->generate('page', ['path' => $url['path']]); 43 | $url['path'] = $this->pathResolver->resolve($url['path']); 44 | 45 | if (!is_file($this->wikiDir.'/'.$url['path'])) { 46 | $link->setAttribute('class', 'new'); 47 | } 48 | 49 | if (isset($url['fragment'])) { 50 | $href .= '#'.$url['fragment']; 51 | } 52 | 53 | $link->setAttribute('href', $href); 54 | } 55 | } 56 | 57 | public static function getSubscribedEvents() 58 | { 59 | return [ 60 | Events::PAGE_CONTENT => ['onContent', 512], 61 | ]; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Exception/InvalidSizeException.php: -------------------------------------------------------------------------------- 1 | page = $page; 12 | } 13 | 14 | public function getPage() 15 | { 16 | return $this->page; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Exception/PageRedirectedException.php: -------------------------------------------------------------------------------- 1 | page = $page; 14 | 15 | $this->target = $target; 16 | } 17 | 18 | public function getPage() 19 | { 20 | return $this->page; 21 | } 22 | 23 | public function getTarget() 24 | { 25 | return $this->target; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Extension/BootstrapInterface.php: -------------------------------------------------------------------------------- 1 | render('history.html.twig', [ 18 | 'page' => new Page($path), 19 | 'commits' => $gitiki['git.repository']->getLog('--all', $path)->getCommits(), 20 | ]); 21 | } 22 | 23 | public function diffAction(Gitiki $gitiki, Request $request, $path) 24 | { 25 | $commitNum = $request->query->get('history'); 26 | $commit = $gitiki['git.repository']->getCommit($commitNum); 27 | 28 | try { 29 | $fileDiff = $commit->getDiffFile($path)->getFiles(); 30 | } catch (ProcessException $e) { 31 | $gitiki->abort(404, sprintf('The commit "%s" was not found', $commitNum)); 32 | } 33 | 34 | if (empty($fileDiff)) { 35 | $gitiki->abort(404, sprintf('The commit "%s" does not concern the file "%s"', $commitNum, $path)); 36 | } 37 | 38 | return $gitiki['twig']->render('diff.html.twig', [ 39 | 'page' => new Page($path), 40 | 'commit' => $commit, 41 | 'diff' => $fileDiff[0], 42 | ]); 43 | } 44 | 45 | public function sourceAction(Gitiki $gitiki, Request $request, $path) 46 | { 47 | return new Response($gitiki['git.repository']->getFile($path, $request->query->get('history')), 200, [ 48 | 'content-type' => 'text/plain', 49 | ]); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Git/Event/Listener/NavigationHistory.php: -------------------------------------------------------------------------------- 1 | getSubject(); 15 | 16 | $pageNav->add('history', 'page', [ 17 | 'path' => $pageNav->getPage()->getName(), 18 | 'history' => '', 19 | ]); 20 | } 21 | 22 | public static function getSubscribedEvents() 23 | { 24 | return [ 25 | Events::PAGE_NAVIGATION => ['onNavigation', 2048], 26 | ]; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Git/GitExtension.php: -------------------------------------------------------------------------------- 1 | registerConfiguration($gitiki, $config); 22 | 23 | $gitiki['git.repository'] = $gitiki->share(function($gitiki) { 24 | return new Gitonomy\Repository($gitiki['git']['git_dir'], [ 25 | 'debug' => $gitiki['debug'], 26 | 'wiki_dir' => $gitiki['git']['wiki_dir'], 27 | 'git_command' => $gitiki['git']['git_binary'], 28 | 'perl_command' => $gitiki['git']['perl_binary'], 29 | ]); 30 | }); 31 | 32 | $gitiki['git.controller.diff'] = $gitiki->share(function() use ($gitiki) { 33 | return new Controller\DiffController(); 34 | }); 35 | 36 | $gitiki['git.controller.assets'] = $gitiki->share(function() use ($gitiki) { 37 | return new Controller\AssetsController(); 38 | }); 39 | 40 | $gitiki['translator'] = $gitiki->share($gitiki->extend('translator', function($translator, $gitiki) { 41 | $translator->addResource('yaml', __DIR__.'/Resources/translations/en.yml', 'en'); 42 | $translator->addResource('yaml', __DIR__.'/Resources/translations/fr.yml', 'fr'); 43 | 44 | return $translator; 45 | })); 46 | 47 | $gitiki['twig.path'] = array_merge($gitiki['twig.path'], [ __DIR__.'/Resources/views' ]); 48 | 49 | $gitiki['dispatcher'] = $gitiki->share($gitiki->extend('dispatcher', function ($dispatcher, $app) { 50 | $dispatcher->addSubscriber(new Event\Listener\NavigationHistory()); 51 | 52 | return $dispatcher; 53 | })); 54 | 55 | $this->registerRouting($gitiki); 56 | } 57 | 58 | public function getBootstrap() 59 | { 60 | return __DIR__.'/Resources/assets/bootstrap.json'; 61 | } 62 | 63 | public function getWebpackEntries(Gitiki $gitiki) 64 | { 65 | return [ 66 | 'git' => __DIR__.'/Resources/assets/css/git.css', 67 | ]; 68 | } 69 | 70 | public function boot(Gitiki $gitiki) 71 | { 72 | } 73 | 74 | protected function registerConfiguration(Gitiki $gitiki, array $config) 75 | { 76 | $treeBuilder = new TreeBuilder(); 77 | $rootNode = $treeBuilder->root('git'); 78 | 79 | $rootNode 80 | ->children() 81 | ->scalarNode('git_dir')->defaultValue($gitiki['wiki_path'].'/.git') 82 | ->validate() 83 | ->always() 84 | ->then(function ($v) use ($gitiki) { 85 | if ('/' !== $v{0}) { 86 | $v = $gitiki['wiki_path'].'/'.$v; 87 | } 88 | 89 | if (is_dir($v.'/.git')) { 90 | $v .= '/.git'; 91 | } 92 | 93 | return $v; 94 | }) 95 | ->end() 96 | ->end() 97 | ->scalarNode('wiki_dir')->defaultValue('') 98 | ->validate() 99 | ->always() 100 | ->then(function ($v) { 101 | if ('/' === substr($v, -1)) { 102 | $v = substr($v, 0, -1); 103 | } 104 | 105 | return $v; 106 | }) 107 | ->end() 108 | ->end() 109 | ->scalarNode('git_binary')->defaultValue('git')->end() 110 | ->scalarNode('perl_binary')->defaultValue('perl')->end() 111 | ->end() 112 | ; 113 | 114 | return (new Processor())->process($treeBuilder->buildTree(), [$config]); 115 | } 116 | 117 | protected function registerRouting(Gitiki $gitiki) 118 | { 119 | $routeCollection = $gitiki['routes']; 120 | 121 | $pageHistory = clone $routeCollection->get('page'); 122 | $pageHistory->run('git.controller.diff:historyAction') 123 | ->assertGet('history', '') 124 | ->ifIndex('page', function(Request $request) { 125 | return [ 126 | 'path' => $request->attributes->get('path'), 127 | 'history' => '', 128 | ]; 129 | }); 130 | $routeCollection->addBefore('page', 'git_page_history', $pageHistory); 131 | 132 | $pageDiff = clone $routeCollection->get('page'); 133 | $pageDiff->run('git.controller.diff:diffAction') 134 | ->assertGet('history', '.+') 135 | ->ifIndex('page', function(Request $request) { 136 | return [ 137 | 'path' => $request->attributes->get('path'), 138 | 'history' => $request->query->get('history'), 139 | ]; 140 | }); 141 | $routeCollection->addBefore('page', 'git_page_diff', $pageDiff); 142 | 143 | $pageSource = clone $routeCollection->get('page_source'); 144 | $pageSource->run('git.controller.diff:sourceAction') 145 | ->assertGet('history', '.+'); 146 | $routeCollection->addBefore('page_source', 'git_page_source', $pageSource); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/Git/Gitonomy/Commit.php: -------------------------------------------------------------------------------- 1 | getAuthorEmail()); 16 | } 17 | 18 | /** 19 | * @param string $file Path to file 20 | * 21 | * @return Diff 22 | */ 23 | public function getDiffFile($file) 24 | { 25 | $args = [ 26 | '-r', '-p', '-m', '-M', '--no-commit-id', '--full-index', $this->revision, 27 | '--', $this->repository->getWikiDir().$file, 28 | ]; 29 | 30 | $diff = Diff::parse($this->repository->run('diff-tree', $args)); 31 | $diff->setRepository($this->repository); 32 | 33 | return $diff; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Git/Gitonomy/Repository.php: -------------------------------------------------------------------------------- 1 | perlCommand = $options['perl_command']; 19 | $this->wikiDir = $options['wiki_dir']; 20 | unset($options['git_command'], $options['perl_command'], $options['wiki_dir']); 21 | 22 | parent::__construct($dir, $options); 23 | } 24 | 25 | public function getWikiDir() 26 | { 27 | return $this->wikiDir; 28 | } 29 | 30 | public function getFile($file, $revision) 31 | { 32 | return $this->run('show', [$revision.':'.$this->wikiDir.$file]); 33 | } 34 | 35 | public function getCommit($hash) 36 | { 37 | if (!isset($this->objects[$hash])) { 38 | $this->objects[$hash] = new Commit($this, $hash); 39 | } 40 | 41 | return $this->objects[$hash]; 42 | } 43 | 44 | public function getLog($revisions = null, $paths = null, $offset = null, $limit = null) 45 | { 46 | if (null !== $paths) { 47 | if (is_string($paths)) { 48 | $paths = $this->wikiDir.$paths; 49 | } elseif (is_array($paths)) { 50 | foreach ($paths as $i => $path) { 51 | $paths[$i] = $this->wikiDir.$paths; 52 | } 53 | } 54 | } 55 | 56 | return parent::getLog($revisions, $paths, $offset, $limit); 57 | } 58 | 59 | public function run($command, $args = []) 60 | { 61 | $output = parent::run($command, $args); 62 | 63 | if ('diff-tree' === $command && null !== $this->perlCommand) { 64 | $p = ProcessBuilder::create([$this->perlCommand, __DIR__.'/../Resources/bin/git-diff-highlight']) 65 | ->setInput($output) 66 | ->getProcess(); 67 | 68 | $p->run(); 69 | if ($p->isSuccessful()) { 70 | $output = $p->getOutput(); 71 | } 72 | } 73 | 74 | return $output; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Git/Resources/assets/bootstrap.json: -------------------------------------------------------------------------------- 1 | { 2 | "styles": { 3 | "buttons": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/Git/Resources/assets/css/git.css: -------------------------------------------------------------------------------- 1 | .git-history .message { 2 | font-size: 15px; 3 | font-weight: bold; 4 | } 5 | .git-history .avatar { 6 | width: 41px; 7 | 8 | } 9 | .git-history .avatar img { 10 | border-radius: 3px; 11 | } 12 | 13 | .git-diff { 14 | background-color: transparent; 15 | 16 | padding-left: 0; 17 | padding-right: 0; 18 | } 19 | 20 | .git-diff > .panel-body, .git-history > .panel-body { 21 | padding: 0; 22 | overflow-x: scroll; 23 | } 24 | 25 | .git-diff > .panel-body { 26 | padding-left: 0; 27 | } 28 | 29 | .git-diff hr { 30 | margin: 10px 0; 31 | } 32 | 33 | .git-diff .diff-context, .git-diff .diff-remove, .git-diff .diff-add, .git-diff .diff-separate { 34 | font-family: Menlo,Monaco,Consolas,"Courier New",monospace; 35 | font-size: 13px; 36 | 37 | padding-left: 2px; 38 | 39 | min-width: 100%; 40 | white-space: pre; 41 | display: table-row; 42 | } 43 | 44 | .git-diff h3 { 45 | margin: 0; 46 | } 47 | .git-diff .diff-remove { 48 | background-color: #fcc; 49 | } 50 | .git-diff .diff-removed { 51 | background-color: #f99; 52 | } 53 | .git-diff .diff-add { 54 | background-color: #cfc; 55 | } 56 | .git-diff .diff-added { 57 | background-color: #9f9; 58 | } 59 | 60 | .git-diff .additions { 61 | color: #55a532; 62 | } 63 | .git-diff .deletions { 64 | color: #bd2c00; 65 | } 66 | -------------------------------------------------------------------------------- /src/Git/Resources/bin/git-diff-highlight: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl 2 | 3 | use 5.008; 4 | use warnings FATAL => 'all'; 5 | use strict; 6 | 7 | # Highlight by reversing foreground and background. You could do 8 | # other things like bold or underline if you prefer. 9 | my @OLD_HIGHLIGHT = ( 10 | color_config('color.diff-highlight.oldnormal'), 11 | color_config('color.diff-highlight.oldhighlight', "\x1b[7m"), 12 | color_config('color.diff-highlight.oldreset', "\x1b[27m") 13 | ); 14 | my @NEW_HIGHLIGHT = ( 15 | color_config('color.diff-highlight.newnormal', $OLD_HIGHLIGHT[0]), 16 | color_config('color.diff-highlight.newhighlight', $OLD_HIGHLIGHT[1]), 17 | color_config('color.diff-highlight.newreset', $OLD_HIGHLIGHT[2]) 18 | ); 19 | 20 | my $RESET = "\x1b[m"; 21 | my $COLOR = qr/\x1b\[[0-9;]*m/; 22 | my $BORING = qr/$COLOR|\s/; 23 | 24 | my @removed; 25 | my @added; 26 | my $in_hunk; 27 | 28 | # Some scripts may not realize that SIGPIPE is being ignored when launching the 29 | # pager--for instance scripts written in Python. 30 | $SIG{PIPE} = 'DEFAULT'; 31 | 32 | while (<>) { 33 | if (!$in_hunk) { 34 | print; 35 | $in_hunk = /^$COLOR*\@/; 36 | } 37 | elsif (/^$COLOR*-/) { 38 | push @removed, $_; 39 | } 40 | elsif (/^$COLOR*\+/) { 41 | push @added, $_; 42 | } 43 | else { 44 | show_hunk(\@removed, \@added); 45 | @removed = (); 46 | @added = (); 47 | 48 | print; 49 | $in_hunk = /^$COLOR*[\@ ]/; 50 | } 51 | 52 | # Most of the time there is enough output to keep things streaming, 53 | # but for something like "git log -Sfoo", you can get one early 54 | # commit and then many seconds of nothing. We want to show 55 | # that one commit as soon as possible. 56 | # 57 | # Since we can receive arbitrary input, there's no optimal 58 | # place to flush. Flushing on a blank line is a heuristic that 59 | # happens to match git-log output. 60 | if (!length) { 61 | local $| = 1; 62 | } 63 | } 64 | 65 | # Flush any queued hunk (this can happen when there is no trailing context in 66 | # the final diff of the input). 67 | show_hunk(\@removed, \@added); 68 | 69 | exit 0; 70 | 71 | # Ideally we would feed the default as a human-readable color to 72 | # git-config as the fallback value. But diff-highlight does 73 | # not otherwise depend on git at all, and there are reports 74 | # of it being used in other settings. Let's handle our own 75 | # fallback, which means we will work even if git can't be run. 76 | sub color_config { 77 | my ($key, $default) = @_; 78 | my $s = `git config --get-color $key 2>/dev/null`; 79 | return length($s) ? $s : $default; 80 | } 81 | 82 | sub show_hunk { 83 | my ($a, $b) = @_; 84 | 85 | # If one side is empty, then there is nothing to compare or highlight. 86 | if (!@$a || !@$b) { 87 | print @$a, @$b; 88 | return; 89 | } 90 | 91 | # If we have mismatched numbers of lines on each side, we could try to 92 | # be clever and match up similar lines. But for now we are simple and 93 | # stupid, and only handle multi-line hunks that remove and add the same 94 | # number of lines. 95 | if (@$a != @$b) { 96 | print @$a, @$b; 97 | return; 98 | } 99 | 100 | my @queue; 101 | for (my $i = 0; $i < @$a; $i++) { 102 | my ($rm, $add) = highlight_pair($a->[$i], $b->[$i]); 103 | print $rm; 104 | push @queue, $add; 105 | } 106 | print @queue; 107 | } 108 | 109 | sub highlight_pair { 110 | my @a = split_line(shift); 111 | my @b = split_line(shift); 112 | 113 | # Find common prefix, taking care to skip any ansi 114 | # color codes. 115 | my $seen_plusminus; 116 | my ($pa, $pb) = (0, 0); 117 | while ($pa < @a && $pb < @b) { 118 | if ($a[$pa] =~ /$COLOR/) { 119 | $pa++; 120 | } 121 | elsif ($b[$pb] =~ /$COLOR/) { 122 | $pb++; 123 | } 124 | elsif ($a[$pa] eq $b[$pb]) { 125 | $pa++; 126 | $pb++; 127 | } 128 | elsif (!$seen_plusminus && $a[$pa] eq '-' && $b[$pb] eq '+') { 129 | $seen_plusminus = 1; 130 | $pa++; 131 | $pb++; 132 | } 133 | else { 134 | last; 135 | } 136 | } 137 | 138 | # Find common suffix, ignoring colors. 139 | my ($sa, $sb) = ($#a, $#b); 140 | while ($sa >= $pa && $sb >= $pb) { 141 | if ($a[$sa] =~ /$COLOR/) { 142 | $sa--; 143 | } 144 | elsif ($b[$sb] =~ /$COLOR/) { 145 | $sb--; 146 | } 147 | elsif ($a[$sa] eq $b[$sb]) { 148 | $sa--; 149 | $sb--; 150 | } 151 | else { 152 | last; 153 | } 154 | } 155 | 156 | if (is_pair_interesting(\@a, $pa, $sa, \@b, $pb, $sb)) { 157 | return highlight_line(\@a, $pa, $sa, \@OLD_HIGHLIGHT), 158 | highlight_line(\@b, $pb, $sb, \@NEW_HIGHLIGHT); 159 | } 160 | else { 161 | return join('', @a), 162 | join('', @b); 163 | } 164 | } 165 | 166 | sub split_line { 167 | local $_ = shift; 168 | return utf8::decode($_) ? 169 | map { utf8::encode($_); $_ } 170 | map { /$COLOR/ ? $_ : (split //) } 171 | split /($COLOR+)/ : 172 | map { /$COLOR/ ? $_ : (split //) } 173 | split /($COLOR+)/; 174 | } 175 | 176 | sub highlight_line { 177 | my ($line, $prefix, $suffix, $theme) = @_; 178 | 179 | my $start = join('', @{$line}[0..($prefix-1)]); 180 | my $mid = join('', @{$line}[$prefix..$suffix]); 181 | my $end = join('', @{$line}[($suffix+1)..$#$line]); 182 | 183 | # If we have a "normal" color specified, then take over the whole line. 184 | # Otherwise, we try to just manipulate the highlighted bits. 185 | if (defined $theme->[0]) { 186 | s/$COLOR//g for ($start, $mid, $end); 187 | chomp $end; 188 | return join('', 189 | $theme->[0], $start, $RESET, 190 | $theme->[1], $mid, $RESET, 191 | $theme->[0], $end, $RESET, 192 | "\n" 193 | ); 194 | } else { 195 | return join('', 196 | $start, 197 | $theme->[1], $mid, $theme->[2], 198 | $end 199 | ); 200 | } 201 | } 202 | 203 | # Pairs are interesting to highlight only if we are going to end up 204 | # highlighting a subset (i.e., not the whole line). Otherwise, the highlighting 205 | # is just useless noise. We can detect this by finding either a matching prefix 206 | # or suffix (disregarding boring bits like whitespace and colorization). 207 | sub is_pair_interesting { 208 | my ($a, $pa, $sa, $b, $pb, $sb) = @_; 209 | my $prefix_a = join('', @$a[0..($pa-1)]); 210 | my $prefix_b = join('', @$b[0..($pb-1)]); 211 | my $suffix_a = join('', @$a[($sa+1)..$#$a]); 212 | my $suffix_b = join('', @$b[($sb+1)..$#$b]); 213 | 214 | return $prefix_a !~ /^$COLOR*-$BORING*$/ || 215 | $prefix_b !~ /^$COLOR*\+$BORING*$/ || 216 | $suffix_a !~ /^$BORING*$/ || 217 | $suffix_b !~ /^$BORING*$/; 218 | } 219 | -------------------------------------------------------------------------------- /src/Git/Resources/translations/en.yml: -------------------------------------------------------------------------------- 1 | History of %page%: History of %page% 2 | by %committer%: by %committer% 3 | View file: View file 4 | %number% additions: '{1} one addition|]1,Inf[ %number% additions' 5 | %number% deletions: '{1} one deletion|]1,Inf[ %number% deletions' 6 | 7 | nav: 8 | history: History 9 | -------------------------------------------------------------------------------- /src/Git/Resources/translations/fr.yml: -------------------------------------------------------------------------------- 1 | History of %page%: Historique de %page% 2 | by %committer%: par %committer% 3 | View file: Voir le fichier 4 | %number% additions: '{1} un ajout|]1,Inf[ %number% ajouts' 5 | %number% deletions: '{1} une suppression|]1,Inf[ %number% suppressions' 6 | 7 | nav: 8 | history: Historique 9 | -------------------------------------------------------------------------------- /src/Git/Resources/views/diff.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'page.html.twig' %} 2 | 3 | {% block head %}{% endblock %} 4 | 5 | {% block head_title '%commit_msg% in %page%'|trans({ '%commit_msg%': commit.subjectMessage, '%page%': diff.name|split('/')|last }) ~ ' - ' ~ wiki_name %} 6 | {% block title 'History of %page%'|trans({ '%page%': page.name|split('/')|last }) %} 7 | 8 | {% block page_content %} 9 |
10 |
11 |
12 |
13 |
14 |

{{ commit.subjectMessage }}

15 |
16 | 17 |
18 | {% set additions, deletions = diff.additions, diff.deletions %} 19 | {% if 0 != additions %} 20 | 21 | {{ '%number% additions'|transchoice(additions, { '%number%': additions }) }} 22 | 23 | {% endif %} 24 | 25 | {% if 0 != deletions %} 26 | 27 | {{ '%number% deletions'|transchoice(deletions, { '%number%': deletions }) }} 28 | 29 | {% endif %} 30 |
31 |
32 | 33 | 36 |
37 |
38 |
39 | {% for change in diff.changes %} 40 | {% if not loop.first %} 41 |

42 | {% endif %} 43 | 44 | {% for line in change.lines %} 45 | {% if constant('LINE_CONTEXT', change) == line.0 %} 46 | {% set class, symbol, content = 'context', ' ', line.1|escape %} 47 | {% else %} 48 | {% if constant('LINE_REMOVE', change) == line.0 %} 49 | {% set class, highlight_class, symbol = 'remove', 'removed', '-' %} 50 | {% else %} 51 | {% set class, highlight_class, symbol = 'add', 'added', '+' %} 52 | {% endif %} 53 | 54 | {% set content = line.1|escape|replace({ '': '', '': '' }) %} 55 | {% endif %} 56 | 57 |
{{ symbol }}{{ content|raw }}
58 | {% endfor %} 59 | {% endfor %} 60 |
61 |
62 | {% endblock %} 63 | -------------------------------------------------------------------------------- /src/Git/Resources/views/history.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'page.html.twig' %} 2 | 3 | {% block head %}{% endblock %} 4 | {% block title 'History of %page%'|trans({ '%page%': page.name|split('/')|last }) %} 5 | 6 | {% block page_content %} 7 | 8 | {% set current_date = null %} 9 | {% for commit in commits %} 10 | {% set date = commit.committerDate|date_day %} 11 | {% if current_date != date %} 12 | {% set current_date = date %} 13 | 14 | 15 | 16 | {% endif %} 17 | 18 | 19 | 22 | 28 | 29 | {% endfor %} 30 |
{{ current_date }}
20 | 21 | 23 | 24 | {{- commit.subjectMessage -}} 25 |
26 | {{ 'by %committer%'|trans({ '%committer%': commit.authorName }) }} 27 |
31 | {% endblock %} 32 | -------------------------------------------------------------------------------- /src/Gitiki.php: -------------------------------------------------------------------------------- 1 | registerConfiguration($wikiPath); 35 | 36 | $extensions = $config['extensions']; 37 | unset($config['extensions']); 38 | 39 | parent::__construct($config); 40 | 41 | $this['route_class'] = 'Gitiki\\Route'; 42 | $this['routes'] = $this->share(function () { 43 | return new RouteCollection(); 44 | }); 45 | 46 | $this->register(new Provider\UrlGeneratorServiceProvider()); 47 | $this['url_generator'] = $this->share($this->extend('url_generator', function ($urlGenerator, $app) { 48 | return new UrlGenerator($app['path_resolver'], $urlGenerator); 49 | })); 50 | 51 | $this->register(new Provider\TranslationServiceProvider(), array( 52 | 'locale_fallbacks' => array('en'), 53 | )); 54 | $this['translator'] = $this->share($this->extend('translator', function($translator, $app) { 55 | $translator->addLoader('yaml', new YamlFileLoader()); 56 | 57 | $translator->addResource('yaml', __DIR__.'/Resources/translations/en.yml', 'en'); 58 | $translator->addResource('yaml', __DIR__.'/Resources/translations/fr.yml', 'fr'); 59 | 60 | return $translator; 61 | })); 62 | 63 | $this->register(new Provider\HttpFragmentServiceProvider()); 64 | $this->register(new Provider\TwigServiceProvider(), [ 65 | 'twig.path' => [ __DIR__.'/Resources/views' ], 66 | ]); 67 | 68 | $this['twig'] = $this->share($this->extend('twig', function ($twig, $app) { 69 | $twig->addExtension(new Twig\CoreExtension($app['translator'])); 70 | $twig->addGlobal('wiki_name', $app['name']); 71 | 72 | return $twig; 73 | })); 74 | 75 | $this['dispatcher'] = $this->share($this->extend('dispatcher', function ($dispatcher, $app) { 76 | foreach ($dispatcher->getListeners(KernelEvents::REQUEST) as $listener) { 77 | if (!$listener[0] instanceof RouterListener) { 78 | continue; 79 | } 80 | 81 | $dispatcher->removeSubscriber($listener[0]); 82 | 83 | if (Kernel::VERSION_ID >= 20800) { 84 | $dispatcher->addSubscriber(new RouterListener($app['url_matcher'], $app['request_stack'], $app['request_context'], $app['logger'])); 85 | } else { 86 | $dispatcher->addSubscriber(new RouterListener($app['url_matcher'], $app['request_context'], $app['logger'], $app['request_stack'])); 87 | } 88 | 89 | break; 90 | } 91 | 92 | $dispatcher->addSubscriber(new Event\Listener\FileLoader($this['wiki_path'])); 93 | $dispatcher->addSubscriber(new Event\Listener\Metadata()); 94 | $dispatcher->addSubscriber(new Event\Listener\Markdown()); 95 | $dispatcher->addSubscriber(new Event\Listener\WikiLink($this['wiki_path'], $this['path_resolver'], $this['url_generator'])); 96 | $dispatcher->addSubscriber(new Event\Listener\Image($this['url_generator'])); 97 | 98 | $dispatcher->addSubscriber(new Event\Listener\NavigationSource()); 99 | 100 | $dispatcher->addSubscriber(new Event\Listener\RedirectIndexHtml($this)); 101 | $dispatcher->addSubscriber(new Event\Listener\PathFixer()); 102 | 103 | return $dispatcher; 104 | })); 105 | 106 | $this['image'] = $this->share(function ($app) { 107 | if (extension_loaded('gd')) { 108 | return new Image\GdImage(); 109 | } 110 | 111 | return new Image\NullImage(); 112 | }); 113 | 114 | $this['path_resolver'] = $this->share(function ($app) { 115 | return new PathResolver($app['request_context']); 116 | }); 117 | 118 | $this->register(new Provider\ServiceControllerServiceProvider()); 119 | 120 | $this['controller.common'] = $this->share(function() { 121 | return new Controller\CommonController(); 122 | }); 123 | $this['controller.page'] = $this->share(function() { 124 | return new Controller\PageController(); 125 | }); 126 | $this['controller.image'] = $this->share(function() { 127 | return new Controller\ImageController(); 128 | }); 129 | 130 | $this->registerRouting(); 131 | 132 | $this->extensions = []; 133 | $this->registerExtensions($extensions); 134 | } 135 | 136 | public function getExtensions() 137 | { 138 | return $this->extensions; 139 | } 140 | 141 | public function getPage($name) 142 | { 143 | $page = new Page($name); 144 | 145 | $this['dispatcher']->dispatch(Event\Events::PAGE_LOAD, new GenericEvent($page)); 146 | $this['dispatcher']->dispatch(Event\Events::PAGE_META, new GenericEvent($page)); 147 | $this['dispatcher']->dispatch(Event\Events::PAGE_CONTENT, new GenericEvent($page)); 148 | $this['dispatcher']->dispatch(Event\Events::PAGE_TERMINATE, new GenericEvent($page)); 149 | 150 | return $page; 151 | } 152 | 153 | protected function registerConfiguration($wikiPath) 154 | { 155 | $config = [ 156 | 'debug' => false, 157 | 'locale' => 'en', 158 | 159 | 'name' => 'Wiki', 160 | 'extensions' => [], 161 | ]; 162 | 163 | if (is_file($wikiPath.'/.gitiki.yml')) { 164 | $wikiConfig = Yaml::parse(file_get_contents($wikiPath.'/.gitiki.yml')); 165 | 166 | if ($wikiConfig) { 167 | foreach ($config as $key => $value) { 168 | if (isset($wikiConfig[$key])) { 169 | $config[$key] = $wikiConfig[$key]; 170 | } 171 | } 172 | } 173 | } 174 | 175 | $config['wiki_path'] = $wikiPath; 176 | 177 | return $config; 178 | } 179 | 180 | protected function registerRouting() 181 | { 182 | // common 183 | $this->get('/_menu', 'controller.common:menuAction') 184 | ->bind('_common_menu'); 185 | $this->flush('_common'); 186 | 187 | // page & image 188 | $this->get('/{path}', 'controller.page:pageDirectoryAction') 189 | ->assert('path', '([\w\d-\./]+/|)$') 190 | ->bind('page_dir'); 191 | 192 | $this->get('/{path}.{_format}', 'controller.page:navigationAction') 193 | ->assert('path', '[\w\d-\./]+') 194 | ->assert('_format', 'html') 195 | ->assertGet('navigation', ''); 196 | 197 | $this->get('/{path}.{_format}', 'controller.page:pageAction') 198 | ->assert('path', '[\w\d-\./]+') 199 | ->assert('_format', 'html') 200 | ->ifIndex('page', function(Request $request) { 201 | return ['path' => $request->attributes->get('path')]; 202 | }) 203 | ->bind('page'); 204 | 205 | $this->get('/{path}.{_format}', 'controller.page:sourceAction') 206 | ->assert('path', '[\w\d-\./]+') 207 | ->assert('_format', 'md') 208 | ->bind('page_source'); 209 | 210 | $this->get('/{path}.{_format}', 'controller.image:imageAction') 211 | ->assert('path', '[\w\d/]+') 212 | ->assert('_format', '(jpe?g|png|gif)') 213 | ->bind('image'); 214 | 215 | $this->flush(); 216 | } 217 | 218 | protected function registerExtensions(array $extensions) 219 | { 220 | foreach ($extensions as $class => $config) { 221 | $this->registerExtension(new $class(), $config); 222 | } 223 | } 224 | 225 | protected function registerExtension(ExtensionInterface $extension, array $config = null) 226 | { 227 | $this->extensions[] = $this->providers[] = $extension; 228 | 229 | $extension->register($this, $config ?: []); 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /src/Image.php: -------------------------------------------------------------------------------- 1 | relativePath = $relativePath; 16 | } 17 | 18 | public function getTitle() 19 | { 20 | return basename($this->relativePath); 21 | } 22 | 23 | public function getRelativePath() 24 | { 25 | return $this->relativePath; 26 | } 27 | 28 | public function getImageSize() 29 | { 30 | if (!$this->imageSize) { 31 | list($this->imageSize[0], $this->imageSize[1]) = getimagesize($this->getPathname()); 32 | } 33 | 34 | return $this->imageSize; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Image/AbstractImage.php: -------------------------------------------------------------------------------- 1 | parseSize($size); 15 | if (INF === $sizeParsed['width'] && INF === $sizeParsed['height']) { 16 | throw new InvalidSizeException(sprintf('The size "%s" has been invalid.', $size)); 17 | } 18 | 19 | return $this->doResize($image, $sizeParsed); 20 | } 21 | 22 | protected function parseSize($size) 23 | { 24 | if (!preg_match('/^(?\d+)?(?:x(?\d+))?$/', $size, $match)) { 25 | throw new InvalidSizeException(sprintf('The size "%s" cannot be parsed!', $size)); 26 | } 27 | 28 | if (empty($match['width'])) { 29 | $match['width'] = INF; 30 | } else { 31 | $match['width'] = (int) $match['width']; 32 | } 33 | 34 | if (empty($match['height'])) { 35 | $match['height'] = INF; 36 | } else { 37 | $match['height'] = (int) $match['height']; 38 | } 39 | 40 | return [ 41 | 'width' => $match['width'], 42 | 'height' => $match['height'], 43 | ]; 44 | } 45 | 46 | protected function getSizeRatio($originalSize, $destinationSize, $sizeToCompute) 47 | { 48 | return (int) round($sizeToCompute / ($originalSize / $destinationSize)); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Image/GdImage.php: -------------------------------------------------------------------------------- 1 | getImageSize(); 12 | $srcX = $srcY = 0; 13 | 14 | $crop = true; 15 | if (INF === $size['width'] && INF !== $size['height']) { // compute width size 16 | $size['width'] = $this->getSizeRatio($srcH, $size['height'], $srcW); 17 | $crop = false; 18 | } elseif (INF === $size['height'] && INF !== $size['width']) { // compute height size 19 | $size['height'] = $this->getSizeRatio($srcW, $size['width'], $srcH); 20 | $crop = false; 21 | } elseif (INF !== $size['width'] && INF !== $size['height']) { 22 | $srcRatio = $srcW / $srcH; 23 | $destRation = $size['width'] / $size['height']; 24 | 25 | if ($srcRatio === $destRation) { 26 | $crop = false; 27 | } 28 | } 29 | 30 | if ($crop) { // crop and resize 31 | $ratioW = ($size['width'] * 100) / $srcW; 32 | $ratioH = ($size['height'] * 100) / $srcH; 33 | 34 | if ($ratioW > $ratioH) { 35 | $oldSrcH = $srcH; 36 | $srcH = $this->getSizeRatio($size['width'], $srcW, $size['height']); 37 | $srcY = ($oldSrcH - $srcH) / 2; 38 | } else { 39 | $oldSrcW = $srcW; 40 | $srcW = $this->getSizeRatio($size['height'], $srcH, $size['width']); 41 | $srcX = ($oldSrcW - $srcW) / 2; 42 | } 43 | } 44 | 45 | $resized = imagecreatetruecolor($size['width'], $size['height']); 46 | 47 | imagecopyresampled( 48 | $resized, $this->loadImage($image), 49 | 0, 0, $srcX, $srcY, 50 | isset($destW) ? $destW : $size['width'], isset($destH) ? $destH : $size['height'], 51 | $srcW, $srcH 52 | ); 53 | 54 | $temp = new \SplFileInfo(tempnam(sys_get_temp_dir(), 'gitiki')); 55 | $this->saveImage($image, $temp, $resized); 56 | 57 | return $temp; 58 | } 59 | 60 | private function loadImage(\SplFileInfo $image) 61 | { 62 | switch ($image->getExtension()) { 63 | case 'jpg': 64 | case 'jpeg': 65 | return imagecreatefromjpeg($image->getPathname()); 66 | 67 | case 'png': 68 | return imagecreatefrompng($image->getPathname()); 69 | 70 | case 'gif': 71 | return imagecreatefromgif($image->getPathname()); 72 | } 73 | 74 | throw new \InvalidArgumentException; 75 | } 76 | 77 | private function saveImage(\SplFileInfo $original, \SplFileInfo $destination, $image) 78 | { 79 | switch ($original->getExtension()) { 80 | case 'jpg': 81 | case 'jpeg': 82 | return imagejpeg($image, $destination->getPathname()); 83 | 84 | case 'png': 85 | return imagepng($image, $destination->getPathname()); 86 | 87 | case 'gif': 88 | return imagegif($image, $destination->getPathname()); 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Image/ImageInterface.php: -------------------------------------------------------------------------------- 1 | name = $name; 20 | } 21 | 22 | public function getName() 23 | { 24 | return $this->name; 25 | } 26 | 27 | public function getTitle() 28 | { 29 | return $this->getMeta('title'); 30 | } 31 | 32 | public function getMeta($meta) 33 | { 34 | return isset($this->metas[$meta]) ? $this->metas[$meta] : null; 35 | } 36 | 37 | public function getMetas() 38 | { 39 | return $this->metas; 40 | } 41 | 42 | public function setMetas($metas) 43 | { 44 | $this->metas = $metas; 45 | } 46 | 47 | public function getToc() 48 | { 49 | return $this->toc; 50 | } 51 | 52 | public function setToc(array $toc) 53 | { 54 | $this->toc = $toc; 55 | } 56 | 57 | public function getDocument() 58 | { 59 | if (!$this->document) { 60 | $this->document = new \DOMDocument(); 61 | $this->document->loadHTML(''.$this->content); 62 | } 63 | 64 | return $this->document; 65 | } 66 | 67 | public function getContent() 68 | { 69 | if ($this->document) { 70 | $this->content = null; 71 | 72 | $html = $this->document->childNodes->item(2); 73 | if ($html) { 74 | $nodes = $html 75 | ->firstChild // body 76 | ->childNodes // body child 77 | ; 78 | 79 | foreach ($nodes as $node) { 80 | $this->content .= $this->document->saveHTML($node); 81 | } 82 | } 83 | } 84 | 85 | return $this->content ?: ''; 86 | } 87 | 88 | public function setContent($content) 89 | { 90 | $this->content = empty($content) ? null : $content; 91 | $this->document = null; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/PageNav.php: -------------------------------------------------------------------------------- 1 | page = $page; 14 | } 15 | 16 | public function getIterator() 17 | { 18 | return new \ArrayIterator($this->nav); 19 | } 20 | 21 | public function getPage() 22 | { 23 | return $this->page; 24 | } 25 | 26 | public function add($icon, $route, array $routeParams = null) 27 | { 28 | unset($this->nav[$icon]); 29 | 30 | $this->nav[$icon] = ['name' => $route, 'params' => $routeParams]; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Parser.php: -------------------------------------------------------------------------------- 1 | tocBuilder = new TocBuilder(); 17 | 18 | $page->setContent( 19 | $this->fixIssue358(parent::text($page->getContent())) 20 | ); 21 | $page->setToc($this->tocBuilder->getToc()); 22 | 23 | $this->tocBuilder = null; 24 | } 25 | 26 | protected function blockHeader($line) 27 | { 28 | return $this->addHeaderInToc(parent::blockHeader($line)); 29 | } 30 | 31 | protected function blockSetextHeader($line, array $block = null) 32 | { 33 | $header = parent::blockSetextHeader($line, $block); 34 | if (null !== $header) { 35 | $header = $this->addHeaderInToc(); 36 | } 37 | 38 | return $header; 39 | } 40 | 41 | protected function blockTable($line, array $block = null) 42 | { 43 | $table = parent::blockTable($line, $block); 44 | 45 | if (null !== $table) { 46 | $table['element']['attributes']['class'] = 'table table-striped'; 47 | } 48 | 49 | return $table; 50 | } 51 | 52 | protected function addHeaderInToc(array $header) 53 | { 54 | if (preg_match('/^(.+) \{#([\w-]+)\}$/', $header['element']['text'], $matches)) { 55 | $text = $matches[1]; 56 | $id = $matches[2]; 57 | } else { 58 | $text = $header['element']['text']; 59 | $id = null; 60 | } 61 | 62 | $header['element']['text'] = $text; 63 | $header['element']['attributes']['id'] = $this->tocBuilder->add( 64 | (int) $header['element']['name']{1}, $text, $id 65 | ); 66 | 67 | return $header; 68 | } 69 | 70 | /** 71 | * @see https://github.com/erusev/parsedown/issues/358 72 | */ 73 | private function fixIssue358($content) 74 | { 75 | if (empty($content)) { 76 | return $content; 77 | } 78 | 79 | $document = new \DOMDocument(); 80 | $document->loadXML(''.$content.''); 81 | 82 | $xpath = new \DOMXPath($document); 83 | $badLinks = $xpath->query('//a[a]'); 84 | if (0 === $badLinks->length) { 85 | return $content; 86 | } 87 | 88 | // iterate on links containing link 89 | foreach ($badLinks as $link) { 90 | foreach ($link->childNodes as $node) { 91 | // test if is a link node 92 | if ($node instanceof \DOMElement && 'a' === $node->tagName) { 93 | $link->insertBefore($node->childNodes->item(0), $node); 94 | $link->removeChild($node); 95 | } 96 | } 97 | } 98 | 99 | $content = ''; 100 | foreach ($xpath->query('//xml/*') as $node) { 101 | $content .= $document->saveXML($node); 102 | } 103 | 104 | return $content; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/PathResolver.php: -------------------------------------------------------------------------------- 1 | context = $context; 17 | } 18 | 19 | public function getBaseUrl() 20 | { 21 | return $this->context->getBaseUrl(); 22 | } 23 | 24 | public function resolve($path) 25 | { 26 | if ($path === '/index.md') { 27 | return ''; 28 | } 29 | 30 | $pathParts = explode('/', dirname($path)); 31 | 32 | if ('.' === $pathParts[0] || '..' === $pathParts[0] || '' !== $pathParts[0]) { 33 | $newPathParts = $this->getBaseDirnameParts(); 34 | } else { 35 | $newPathParts = []; 36 | } 37 | 38 | foreach ($pathParts as $part) { 39 | if ('..' === $part) { 40 | array_pop($newPathParts); 41 | } 42 | 43 | if ('.' === $part || '..' === $part || '' === $part) { 44 | continue; 45 | } 46 | 47 | $newPathParts[] = $part; 48 | } 49 | 50 | $pathResolved = empty($newPathParts) ? '' : implode('/', $newPathParts).'/'; 51 | 52 | $filename = basename($path); 53 | if ('index.md' !== $filename) { 54 | $pathResolved .= $filename; 55 | } 56 | 57 | return $pathResolved; 58 | } 59 | 60 | private function getBaseDirnameParts() 61 | { 62 | if ($this->pathInfo !== $this->context->getPathInfo()) { 63 | $this->pathInfo = $this->context->getPathInfo(); 64 | 65 | $dirname = dirname($this->pathInfo); 66 | if ('/' === $dirname) { 67 | // create empty array because explode create an array with two entries empty... 68 | $this->baseDirnameParts = []; 69 | } else { 70 | $this->baseDirnameParts = explode('/', $dirname); 71 | 72 | // remove the first entry because it is an empty entry 73 | array_shift($this->baseDirnameParts); 74 | } 75 | } 76 | 77 | return $this->baseDirnameParts; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Resources/assets/_main.scss: -------------------------------------------------------------------------------- 1 | body { 2 | border-top: 2px solid $gitiki-title-bg; 3 | border-bottom: 12px solid $gitiki-title-bg; 4 | } 5 | 6 | .navbar { 7 | margin-bottom: 0; 8 | } 9 | 10 | #title { 11 | background-color: $gitiki-title-bg; 12 | padding-top: 40px; 13 | padding-bottom: 40px; 14 | margin-bottom: $line-height-computed; 15 | color: $body-bg; 16 | } 17 | 18 | #toc { 19 | float: right; 20 | margin-left: 10px; 21 | 22 | ul { 23 | padding-left: 15px; 24 | } 25 | } 26 | 27 | #content { 28 | .table { 29 | width: auto; 30 | } 31 | 32 | code { 33 | white-space: pre; 34 | background: inherit; 35 | padding: 0; 36 | } 37 | 38 | img { 39 | max-width: 100%; 40 | height: auto; 41 | } 42 | 43 | a.external { 44 | color: $brand-info; 45 | } 46 | } 47 | 48 | #page-nav { 49 | background-image: linear-gradient(to right, #f6f6f6 0%, #fff 8px); 50 | 51 | ul { 52 | padding-left: 0; 53 | margin: 5px 0; 54 | list-style: none; 55 | font-size: 20px; 56 | } 57 | 58 | a { 59 | color: $gray-base; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Resources/assets/_variables.scss: -------------------------------------------------------------------------------- 1 | $grid-columns: 15; 2 | 3 | $gitiki-title-bg: #3ea9f5; 4 | -------------------------------------------------------------------------------- /src/Resources/assets/bootstrap.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "transition": false, 4 | "alert": false, 5 | "button": false, 6 | "carousel": false, 7 | "collapse": false, 8 | "dropdown": false, 9 | "modal": false, 10 | "tooltip": false, 11 | "popover": false, 12 | "scrollspy": false, 13 | "tab": false, 14 | "affix": false 15 | }, 16 | "styles": { 17 | "mixins": true, 18 | 19 | "normalize": true, 20 | "print": false, 21 | "glyphicons": false, 22 | 23 | "scaffolding": true, 24 | "type": true, 25 | "code": true, 26 | "grid": true, 27 | "tables": true, 28 | "forms": true, 29 | "buttons": false, 30 | 31 | "component-animations-animations": false, 32 | "dropdowns": false, 33 | "button-groups-groups": false, 34 | "input-groups-groups": false, 35 | "navs": true, 36 | "navbar": true, 37 | "breadcrumbs": false, 38 | "pagination": false, 39 | "pager": false, 40 | "labels": false, 41 | "badges": false, 42 | "jumbotron": false, 43 | "thumbnails": false, 44 | "alerts": false, 45 | "progress-bars-bars": false, 46 | "media": false, 47 | "list-group-group": false, 48 | "panels": true, 49 | "wells": false, 50 | "responsive-embed-embed": false, 51 | "close": false, 52 | 53 | "modals": false, 54 | "tooltip": false, 55 | "popovers": false, 56 | "carousel": false, 57 | 58 | "utilities": false, 59 | "responsive-utilities-utilities": false 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Resources/translations/en.yml: -------------------------------------------------------------------------------- 1 | Table of Contents: Table of Contents 2 | 3 | Image detail %image%: Image detail %image% 4 | Filename: Filename 5 | File size: File size 6 | 7 | Width: Width 8 | Height: Height 9 | 10 | bytes: bytes 11 | kB: kB 12 | MB: MB 13 | GB: GB 14 | TB: TB 15 | 16 | nav: 17 | file-text: Source 18 | -------------------------------------------------------------------------------- /src/Resources/translations/fr.yml: -------------------------------------------------------------------------------- 1 | Table of Contents: Table des matières 2 | 3 | Image detail %image%: "Détail de l'image %image%" 4 | Filename: Nom du fichier 5 | File size: Taille du fichier 6 | 7 | Width: Largeur 8 | Height: Hauteur 9 | 10 | bytes: octets 11 | kB: ko 12 | MB: Mo 13 | GB: Go 14 | TB: To 15 | 16 | nav: 17 | file-text: Source 18 | -------------------------------------------------------------------------------- /src/Resources/views/base.html.twig: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {% block head_title (block('title') ~ ' - ' ~ wiki_name)|raw %} 8 | 9 | 10 | 11 | 12 | {% block head '' %} 13 | 14 | 15 | 26 | 27 |
28 |
29 |

{% block title '' %}

30 |
31 |
32 | 33 | {% block content '' %} 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/Resources/views/image.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block title 'Image detail %image%'|trans({ '%image%': page.title }) %} 4 | 5 | {% block content %} 6 |
7 |
8 |

9 | 10 | {{ page.title }} 11 | 12 |

13 | 14 |

15 |

16 |
{{ 'Filename'|trans }}
17 |
{{ page.filename }}
18 | 19 |
{{ 'File size'|trans }}
20 |
{{ page.size|bytes_to_human }}
21 | 22 |
{{ 'Width'|trans }}
23 |
{{ page.imageSize.0 }}
24 | 25 |
{{ 'Height'|trans }}
26 |
{{ page.imageSize.1 }}
27 |
28 |

29 |
30 |
31 | {% endblock %} 32 | -------------------------------------------------------------------------------- /src/Resources/views/menu.html.twig: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /src/Resources/views/navigation.html.twig: -------------------------------------------------------------------------------- 1 |
    2 | {% for icon, route in nav %} 3 |
  • 4 | 5 | 6 | 7 |
  • 8 | {% endfor %} 9 |
10 | -------------------------------------------------------------------------------- /src/Resources/views/page.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block title page.title %} 4 | 5 | {% block content %} 6 |
7 |
8 |
9 | {% block page_content %} 10 | {% if page.toc %} 11 | {{ include('toc.html.twig', { toc: page.toc }, with_context = false) }} 12 | {% endif %} 13 | 14 |
15 | {{ page.content|raw }} 16 |
17 | {% endblock %} 18 |
19 | 22 |
23 |
24 | {% endblock %} 25 | -------------------------------------------------------------------------------- /src/Resources/views/toc.html.twig: -------------------------------------------------------------------------------- 1 |
2 |
{{ 'Table of Contents'|trans }}
3 |
4 | {{ _self.toc(toc) }} 5 |
6 |
7 | 8 | {% macro toc(toc) %} 9 |
    10 | {% for child in toc %} 11 |
  • 12 | {{ child.text }} 13 |
  • 14 | 15 | {% if child.children is defined %} 16 | {{ _self.toc(child.children) }} 17 | {% endif %} 18 | {% endfor %} 19 |
20 | {% endmacro %} 21 | -------------------------------------------------------------------------------- /src/Route.php: -------------------------------------------------------------------------------- 1 | queryRequirements[$variable] = 'request.query.has("'.$variable.'") and request.query.get("'.$variable.'") matches ("#^'.$this->sanitizeRegex($regexp).'$#")'; 16 | $this->conditionComputed = false; 17 | 18 | return $this; 19 | } 20 | 21 | public function ifIndex($page, \Closure $callable) 22 | { 23 | $this->setOption('_if_index', [$page, $callable]); 24 | } 25 | 26 | public function getCondition() 27 | { 28 | if (!empty($this->queryRequirements) && false === $this->conditionComputed) { 29 | $this->setCondition(implode(' and ', $this->queryRequirements)); 30 | $this->conditionComputed = true; 31 | } 32 | 33 | return parent::getCondition(); 34 | } 35 | 36 | private function sanitizeRegex($regexp) 37 | { 38 | return str_replace( 39 | [ 40 | '\\', // avoid escaping next char 41 | '#', '^', '$', // regex 42 | '"', '(', ')', // expression language 43 | ], 44 | [ 45 | '\\\\', 46 | '\\\\#', '\\\\^', '\\\\$', 47 | '\\"', '\\(', '\\)', 48 | ], 49 | $regexp 50 | ); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/RouteCollection.php: -------------------------------------------------------------------------------- 1 | all() as $routeName => $route) { 24 | if (null !== $newRoute && $before === $routeName) { 25 | $this->add($name, $newRoute); 26 | $newRoute = null; 27 | } 28 | 29 | if (null === $newRoute) { 30 | // move the existing route onto the end of collection 31 | $this->add($routeName, $route); 32 | } 33 | } 34 | 35 | if (null !== $newRoute) { 36 | throw new \InvalidArgumentException(sprintf('The route "%s" cannot be added before "%s", because the route "%2$s" was not found.', $name, $before)); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/TocBuilder.php: -------------------------------------------------------------------------------- 1 | ids = 20 | $this->toc = []; 21 | 22 | $this->levels = array_fill(1, 6, 0); 23 | $this->previousLevel = 0; 24 | } 25 | 26 | public function getToc() 27 | { 28 | if (1 === count($this->toc) && !isset($this->toc[0]['id'])) { 29 | return $this->toc[0]['children']; 30 | } 31 | 32 | return $this->toc; 33 | } 34 | 35 | public function add($level, $text, $id = null) 36 | { 37 | $this->fixLevel($level); 38 | 39 | $id = null === $id ? $this->getId($text) : $this->fixId($id); 40 | 41 | $toc = &$this->getTocLevel($level); 42 | $toc[] = [ 43 | 'id' => $id, 44 | 'text' => $text, 45 | ]; 46 | 47 | return $id; 48 | } 49 | 50 | protected function getId($text) 51 | { 52 | return $this->fixId(Inflector::urlize($text)); 53 | } 54 | 55 | protected function fixId($id) 56 | { 57 | if (isset($this->ids[$id])) { 58 | $this->ids[$id]++; 59 | $id .= '-'.$this->ids[$id]; 60 | } else { 61 | $this->ids[$id] = 1; 62 | } 63 | 64 | return $id; 65 | } 66 | 67 | protected function fixLevel($currentLevel) 68 | { 69 | if ($this->previousLevel >= $currentLevel) { 70 | // increment current level 71 | $this->levels[$currentLevel]++; 72 | 73 | // reset sublevels 74 | for ($i = $currentLevel + 1; $i <= 6; $i++) { 75 | $this->levels[$i] = 0; 76 | } 77 | } 78 | 79 | $this->previousLevel = $currentLevel; 80 | } 81 | 82 | protected function &getTocLevel($level) 83 | { 84 | $toc = &$this->toc; 85 | for ($i = 1; $i < $level; $i++) { 86 | $toc = &$toc[$this->levels[$i]]['children']; 87 | } 88 | 89 | return $toc; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Twig/CoreExtension.php: -------------------------------------------------------------------------------- 1 | translator = $translator; 14 | } 15 | 16 | public function getFilters() 17 | { 18 | return [ 19 | new \Twig_SimpleFilter('bytes_to_human', [$this, 'bytesToHuman']), 20 | new \Twig_SimpleFilter('date_day', [$this, 'dateDay']), 21 | ]; 22 | } 23 | 24 | public function bytesToHuman($bytes, $precision = 2) 25 | { 26 | $suffixes = ['bytes', 'kB', 'MB', 'GB', 'TB']; 27 | 28 | $formatter = new \NumberFormatter( 29 | $this->translator->getLocale(), 30 | \NumberFormatter::PATTERN_DECIMAL, 31 | 0 === $precision ? '#' : '.'.str_repeat('#', $precision) 32 | ); 33 | 34 | $exp = floor(log($bytes, 1024)); 35 | 36 | return $formatter->format($bytes / pow(1024, floor($exp))).' '.$this->translator->trans($suffixes[$exp]); 37 | } 38 | 39 | public function dateDay(\DateTime $date) 40 | { 41 | $formatter = new \IntlDateFormatter($this->translator->getLocale(), \IntlDateFormatter::MEDIUM, \IntlDateFormatter::NONE); 42 | 43 | return $formatter->format($date); 44 | } 45 | 46 | public function getName() 47 | { 48 | return 'gitiki_core'; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/UrlGenerator.php: -------------------------------------------------------------------------------- 1 | pathResolver = $pathResolver; 22 | $this->urlGenerator = $urlGenerator; 23 | } 24 | 25 | /** 26 | * {@inheritdoc} 27 | */ 28 | public function setContext(RequestContext $context) 29 | { 30 | $this->urlGenerator->setContext($context); 31 | } 32 | 33 | /** 34 | * {@inheritdoc} 35 | */ 36 | public function getContext() 37 | { 38 | return $this->urlGenerator->getContext(); 39 | } 40 | 41 | /** 42 | * {@inheritdoc} 43 | */ 44 | public function setStrictRequirements($enabled) 45 | { 46 | if ($this->urlGenerator instanceof ConfigurableRequirementsInterface) { 47 | $this->urlGenerator->setStrictRequirements($enabled); 48 | } 49 | } 50 | 51 | /** 52 | * {@inheritdoc} 53 | */ 54 | public function isStrictRequirements() 55 | { 56 | return $this->urlGenerator instanceof ConfigurableRequirementsInterface ? $this->urlGenerator->isStrictRequirements() : true; 57 | } 58 | 59 | /** 60 | * {@inheritdoc} 61 | */ 62 | public function generate($name, $parameters = [], $referenceType = self::ABSOLUTE_PATH) 63 | { 64 | if (isset($parameters['path'])) { 65 | $parameters['path'] = $this->pathResolver->resolve($parameters['path']); 66 | 67 | if ('page' === $name) { 68 | if ('' === $parameters['path'] || '/' === substr($parameters['path'], -1)) { 69 | $name = 'page_dir'; 70 | } elseif (!isset($parameters['_format'])) { 71 | $parameters['path'] = preg_replace('#\.md$#', '', $parameters['path'], 1); 72 | $parameters['_format'] = 'html'; 73 | } 74 | } elseif ('page_source' === $name) { 75 | $parameters['_format'] = 'md'; 76 | 77 | if ('' === $parameters['path'] || '/' === substr($parameters['path'], -1)) { 78 | $parameters['path'] .= 'index'; 79 | } else { 80 | $parameters['path'] = preg_replace('#\.md$#', '', $parameters['path'], 1); 81 | } 82 | } elseif ('image' === $name) { 83 | if (!isset($parameters['_format']) && preg_match('#(.*)\.(jpe?g|png|gif)$#', $parameters['path'], $match)) { 84 | $parameters['path'] = $match[1]; 85 | $parameters['_format'] = $match[2]; 86 | } 87 | } 88 | } 89 | 90 | return $this->urlGenerator->generate($name, $parameters, $referenceType); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /test/Event/Listener/FileLoaderTest.php: -------------------------------------------------------------------------------- 1 | onLoad(new GenericEvent($page)); 17 | 18 | $this->assertSame(file_get_contents(__DIR__.'/fixtures/bar.md'), $page->getContent()); 19 | } 20 | 21 | public function testOnLoadWithNonexistentPage() 22 | { 23 | $page = new Page('nonexistent'); 24 | 25 | $this->setExpectedException('Gitiki\\Exception\\PageNotFoundException'); 26 | (new FileLoader(__DIR__.'/fixtures'))->onLoad(new GenericEvent($page)); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /test/Event/Listener/ImageTest.php: -------------------------------------------------------------------------------- 1 | setContent($content); 25 | 26 | 27 | $routes = new RouteCollection(); 28 | $routes->add('image', new Route('/{path}.{_format}', [], [ 29 | 'path' => '[\w\d/]+', 30 | '_format' => '(jpe?g|png|gif)', 31 | ])); 32 | $requestContext = new RequestContext('/foo.php'); 33 | 34 | (new Image( 35 | new UrlGenerator(new PathResolver($requestContext), new RealUrlGenerator($routes, $requestContext)) 36 | ))->onContent(new GenericEvent($page)); 37 | 38 | $this->assertSame($expected, $page->getContent(), $comment); 39 | } 40 | 41 | public function provideContent() 42 | { 43 | return [ 44 | ['', '', 'Test with empty content'], 45 | 46 | ['

bar image

', '

bar image

', 'Test image without link'], 47 | ['

bar image

', '

bar image

', 'Test image with link'], 48 | 49 | ['

bar remote image

', '

bar remote image

', 'Test image with remote image'], 50 | 51 | ['

bar resized image

', '

bar resized image

', 'Test image with size parameter (width)'], 52 | ['

bar resized image

', '

bar resized image

', 'Test image with size parameter (height)'], 53 | ['

bar cropped image

', '

bar cropped image

', 'Test image with size parameter (crop)'], 54 | 55 | ['

bar no link image

', '

bar no link image

', 'Test image without link'], 56 | ['

bar custom link image

', '

bar custom link image

', 'Test image without link to image and specific link'], 57 | ['

bar no link with resize image

', '

bar no link with resize image

', 'Test image resize without link'], 58 | 59 | ['

bar direct link image

', '

bar direct link image

', 'Test image direct link'], 60 | ['

bar custom link image

', '

bar custom link image

', 'Test image with direct link to image and specific link'], 61 | ['

bar direct link with resize image

', '

bar direct link with resize image

', 'Test image resize with direct link'], 62 | ]; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /test/Event/Listener/MarkdownTest.php: -------------------------------------------------------------------------------- 1 | setContent('# Hello World!'); 16 | 17 | (new Markdown())->onContent(new GenericEvent($page)); 18 | 19 | $this->assertSame('

Hello World!

', $page->getContent()); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /test/Event/Listener/MetadataTest.php: -------------------------------------------------------------------------------- 1 | setContent($content); 19 | 20 | (new Metadata())->onMetaLoad(new GenericEvent($page)); 21 | 22 | $this->assertSame($expectedMetas, $page->getMetas(), $comment); 23 | $this->assertSame($expectedContent, $page->getContent(), $comment); 24 | } 25 | 26 | /** 27 | * @dataProvider provideMetas 28 | */ 29 | public function testOnMetaParse($metas, $comment) 30 | { 31 | $page = new Page('test'); 32 | $page->setMetas($metas); 33 | 34 | (new Metadata())->onMetaParse(new GenericEvent($page)); 35 | 36 | $this->assertInternalType('array', $page->getMetas(), $comment); 37 | $this->assertEquals(['foo' => 'bar'], $page->getMetas(), $comment); 38 | } 39 | 40 | public function provideContent() 41 | { 42 | return [ 43 | [<< 'bar'], 'Test array meta'], 76 | ]; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /test/Event/Listener/RedirectTest.php: -------------------------------------------------------------------------------- 1 | markTestSkipped(); 17 | 18 | $page = new Page('test'); 19 | $page->setMetas(['title' => 'Hello World!']); 20 | 21 | $event = new GenericEvent($page); 22 | $redirect = new Redirect(new PathResolver(new RequestContext('/foo.php'))); 23 | 24 | // no redirect 25 | $redirect->onMeta(new GenericEvent($page)); 26 | 27 | // set meta redirect 28 | $page->setMetas(['redirect' => 'foobar']); 29 | 30 | $this->setExpectedException('Gitiki\\Exception\\PageRedirectedException'); 31 | $redirect->onMeta($event); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /test/Event/Listener/WikiLinkTest.php: -------------------------------------------------------------------------------- 1 | setContent($content); 25 | 26 | $routes = new RouteCollection(); 27 | $routes->add('page', new Route('/{path}.{_format}', [], [ 28 | 'path' => '[\w\d-/]+', 29 | '_format' => 'html', 30 | ])); 31 | $routes->add('page_dir', new Route('/{path}', [], [ 32 | 'path' => '([\w\d-/]+/|)$', 33 | ])); 34 | 35 | $requestContext = new RequestContext('/foo.php'); 36 | $pathResolver = new PathResolver($requestContext); 37 | 38 | (new WikiLink( 39 | __DIR__.'/fixtures', 40 | $pathResolver, 41 | new UrlGenerator($pathResolver, new RealUrlGenerator($routes, $requestContext)) 42 | ))->onContent(new GenericEvent($page)); 43 | 44 | $this->assertSame($expected, $page->getContent(), $comment); 45 | } 46 | 47 | public function provideContent() 48 | { 49 | return [ 50 | ['', '', 'Test with empty content'], 51 | ['

Bar page

', '

Bar page

', 'Test link to another wiki page'], 52 | ['

hello

', '

hello

', 'Test link to nonexistent wiki page'], 53 | ['

foo

', '

foo

', 'Test link with a fragment'], 54 | ['

hello

', '

hello

', 'Test link to other website'], 55 | ['

Où est Brian?

', '

Où est Brian?

', 'Test with utf-8 content'], 56 | ['

index

', '

index

', 'Test with index page'], 57 | ['

dir index

', '

dir index

', 'Test with index page in directory'], 58 | ['

foo part

', '

foo part

', 'Test with only fragment part'], 59 | ]; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /test/Event/Listener/fixtures/bar.md: -------------------------------------------------------------------------------- 1 | # Bar page 2 | 3 | ## Hello World! 4 | -------------------------------------------------------------------------------- /test/ParserTest.php: -------------------------------------------------------------------------------- 1 | setExpectedException('BadMethodCallException'); 15 | $parser->text('foo'); 16 | } 17 | 18 | public function testBlockHeader() 19 | { 20 | $parser = new Parser(); 21 | $page = new Page('test'); 22 | 23 | $page->setContent('# Hello World!'); 24 | $parser->page($page); 25 | $this->assertSame('

Hello World!

', $page->getContent(), 'Test without specific id'); 26 | 27 | $page->setContent('# Hello World! {#hello}'); 28 | $parser->page($page); 29 | $this->assertSame('

Hello World!

', $page->getContent(), 'Test with specific id'); 30 | } 31 | 32 | public function testBlockSetextHeaderWithoutHeader() 33 | { 34 | $parser = new Parser(); 35 | $page = new Page('test'); 36 | 37 | $page->setContent(<<page($page); 44 | $this->assertSame(array(), $page->getToc()); 45 | } 46 | 47 | public function testPage() 48 | { 49 | $parser = new Parser(); 50 | $page = new Page('test'); 51 | 52 | $page->setContent(<<page($page); 59 | $this->assertSame([[ 60 | 'id' => 'hello-world', 61 | 'text' => 'Hello World!', 62 | 'children' => [[ 63 | 'id' => 'foo-bar', 64 | 'text' => 'foo bar', 65 | ]] 66 | ]] , $page->getToc()); 67 | } 68 | 69 | /** 70 | * @see https://github.com/erusev/parsedown/issues/358 71 | * @see https://github.com/gitiki/Gitiki/issues/7 72 | */ 73 | public function testPageWithoutDuplicatedLink() 74 | { 75 | $page = new Page('test'); 76 | $page->setContent('[http://gitiki.org](http://gitiki.org/)'); 77 | 78 | (new Parser())->page($page); 79 | 80 | $this->assertSame('

http://gitiki.org

', $page->getContent()); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /test/RouteCollectionTest.php: -------------------------------------------------------------------------------- 1 | add('foo', $foo); 18 | $collection->add('bar', $bar); 19 | $this->assertSame(['foo' => $foo, 'bar' => $bar], $collection->all()); 20 | 21 | $collection->addBefore('bar', 'hello_world', $helloWorld); 22 | $this->assertSame(['foo' => $foo, 'hello_world' => $helloWorld, 'bar' => $bar], $collection->all()); 23 | } 24 | 25 | public function testAddBeforeWithNonexistentBeforeRouteName() 26 | { 27 | $collection = new RouteCollection(); 28 | 29 | $this->setExpectedException('InvalidArgumentException'); 30 | $collection->addBefore('nonexistent', 'foo', new Route('/foo')); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /test/RouteTest.php: -------------------------------------------------------------------------------- 1 | evaluate( 27 | (new Route())->assertGet($key, $regexp)->getCondition(), 28 | ['request' => new Request($query)] 29 | ); 30 | 31 | call_user_func([$this, $resultTest ? 'assertTrue' : 'assertFalse'], $result, $name); 32 | } 33 | } 34 | 35 | public function provideTrueConditions() 36 | { 37 | return [ 38 | ['foo', 'bar', ['foo' => 'bar'], 'Test with simple text'], 39 | ['foo', '', ['foo' => ''], 'Test with empty value'], 40 | ['foo', '/bar/', ['foo' => '/bar/'], 'Test with slash chars'], 41 | ['foo', 'ba?r', [ 42 | ['foo' => 'bar'], 43 | ['foo' => 'br'], 44 | ], 'Test with optional char'], 45 | ['foo', 'b(a|i|o)r', [ 46 | ['foo' => 'bar'], 47 | ['foo' => 'bir'], 48 | ['foo' => 'bor'], 49 | ], 'Test with alternative char'], 50 | ['foo', 'b\(ar', ['foo' => 'b(ar'], 'Test with real bracket'], 51 | ['foo', 'b\(?ar', [ 52 | ['foo' => 'b(ar'], 53 | ['foo' => 'bar'], 54 | ], 'Test with real bracket optional'], 55 | ['foo', 'ba{2,5}r', [ 56 | ['foo' => 'baar'], 57 | ['foo' => 'baaar'], 58 | ['foo' => 'baaaar'], 59 | ['foo' => 'baaaaar'], 60 | [['foo' => 'bar'], false], 61 | [['foo' => 'baaaaaar'], false], 62 | ], 'Test with quantifier'], 63 | ['foo', 't^es$t', ['foo' => 't^es$t'], 'Test with start and end regexp char'] 64 | ]; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /test/TocBuilderTest.php: -------------------------------------------------------------------------------- 1 | assertSame($toc, $builder->getToc(), $message); 21 | } 22 | 23 | public function headerDataProvider() 24 | { 25 | return [ 26 | [ 27 | [ 28 | [1, 'Hello World!'], 29 | [2, 'Foo bar', 'foo'], 30 | [3, 'Test'], 31 | [2, 'Bar foo', 'bar'], 32 | [3, 'Test'], 33 | ], [[ 34 | 'id' => 'hello-world', 35 | 'text' => 'Hello World!', 36 | 'children' => [[ 37 | 'id' => 'foo', 38 | 'text' => 'Foo bar', 39 | 'children' => [[ 40 | 'id' => 'test', 41 | 'text' => 'Test', 42 | ]] 43 | ], [ 44 | 'id' => 'bar', 45 | 'text' => 'Bar foo', 46 | 'children' => [[ 47 | 'id' => 'test-2', 48 | 'text' => 'Test', 49 | ]] 50 | ]] 51 | ]], 'Test with first level' 52 | ], 53 | [ 54 | [ 55 | [2, 'Foo bar', 'foo'], 56 | [3, 'Test'], 57 | [2, 'Bar foo', 'bar'], 58 | [3, 'Test'], 59 | ], [[ 60 | 'id' => 'foo', 61 | 'text' => 'Foo bar', 62 | 'children' => [[ 63 | 'id' => 'test', 64 | 'text' => 'Test', 65 | ]] 66 | ], [ 67 | 'id' => 'bar', 68 | 'text' => 'Bar foo', 69 | 'children' => [[ 70 | 'id' => 'test-2', 71 | 'text' => 'Test', 72 | ]] 73 | ]], 'Test without first level' 74 | ], 75 | [ 76 | [ 77 | [2, 'First title'], 78 | [2, 'Second title'], 79 | [3, 'First sub second title'], 80 | ], [[ 81 | 'id' => 'first-title', 82 | 'text' => 'First title', 83 | ], [ 84 | 'id' => 'second-title', 85 | 'text' => 'Second title', 86 | 'children' => [[ 87 | 'id' => 'first-sub-second-title', 88 | 'text' => 'First sub second title', 89 | ]] 90 | ]], 'Test with first element of first level without children' 91 | ], 92 | ]; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var ExtractTextPlugin = require("extract-text-webpack-plugin"); 4 | var webpack = require("webpack"); 5 | var glob = require("glob"); 6 | 7 | var entries = { 8 | "font-awesome": "font-awesome-sass!./webpack/font-awesome-sass.config.js", 9 | "bootstrap": "bootstrap-sass!./webpack/bootstrap-sass.config.js" 10 | }; 11 | for (let entry of glob.sync("./webpack/*.entry.js")) { 12 | entries[entry.match(/webpack\/(.+).entry.js/)[1]] = entry; 13 | } 14 | 15 | module.exports = { 16 | entry: entries, 17 | output: { 18 | filename: "[name].js" 19 | }, 20 | module: { 21 | loaders: [ 22 | // the url-loader uses DataUrls. 23 | // the file-loader emits files. 24 | { test: /\.woff(2)?(\?v=[0-9]\.[0-9]\.[0-9])?$/, loader: "url-loader?limit=10000&mimetype=application/font-woff" }, 25 | { test: /\.(ttf|eot|svg)(\?v=[0-9]\.[0-9]\.[0-9])?$/, loader: "file-loader" }, 26 | { test: /\.css$/, loader: ExtractTextPlugin.extract("style-loader", "css-loader") } 27 | ] 28 | }, 29 | plugins: [ 30 | new ExtractTextPlugin("[name].css"), 31 | new webpack.ProvidePlugin({ 32 | $: "jquery", 33 | jQuery: "jquery" 34 | }) 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /webpack/bootstrap-sass.config.js: -------------------------------------------------------------------------------- 1 | var bootstrap = require('jsonfile').readFileSync( 2 | __dirname + "/bootstrap.json" 3 | ); 4 | 5 | module.exports = { 6 | verbose: true, 7 | 8 | bootstrapCustomizations: "./src/Resources/assets/variables", 9 | mainSass: "./src/Resources/assets/main", 10 | 11 | styleLoader: require('extract-text-webpack-plugin').extract('style-loader', 'css-loader!sass-loader'), 12 | 13 | scripts: bootstrap.scripts, 14 | styles: bootstrap.styles 15 | }; 16 | -------------------------------------------------------------------------------- /webpack/font-awesome-sass.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | styleLoader: require('extract-text-webpack-plugin').extract('style-loader', 'css-loader!sass-loader'), 3 | 4 | styles: { 5 | "mixins": true, 6 | 7 | "bordered-pulled": true, 8 | "core": true, 9 | "fixed-width": true, 10 | "icons": true, 11 | "larger": true, 12 | "list": true, 13 | "path": true, 14 | "rotated-flipped": true, 15 | "animated": true, 16 | "stacked": true 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /wiki/.gitiki.yml: -------------------------------------------------------------------------------- 1 | name: Gitiki wiki 2 | 3 | debug: false 4 | locale: en 5 | 6 | extensions: 7 | Gitiki\CodeHighlight\CodeHighlightExtension: 8 | style: tomorrow 9 | Gitiki\Redirector\RedirectorExtension: ~ 10 | Gitiki\Git\GitExtension: 11 | git_dir: ../ 12 | wiki_dir: wiki/ 13 | -------------------------------------------------------------------------------- /wiki/_menu.md: -------------------------------------------------------------------------------- 1 | --- 2 | /index.md: Homepage 3 | /feature/index.md: Features 4 | /extension/index.md: Extensions 5 | /installation.md: Installation 6 | --- 7 | -------------------------------------------------------------------------------- /wiki/extension/code-highlight.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Code Highlight 3 | --- 4 | 5 | This extension add syntax highlighting on code blocks. 6 | 7 | ## How to install? 8 | 9 | With composer you must run this command `composer require gitiki/code-highlight`. 10 | 11 | After, register the extension to Gitiki: 12 | 13 | ``` 14 | # .gitiki.yml 15 | extensions: 16 | Gitiki\CodeHighlight\CodeHighlightExtension: ~ 17 | ``` 18 | 19 | ## How to use? 20 | 21 | In your markdown file you start a code block with the language name: 22 | 23 | ```php 24 | class HelloWorld 25 | { 26 | } 27 | ``` 28 | 29 | To see the list of languages supported, you must refer to git repository: https://github.com/gitiki/code-highlight/tree/master/src/Resources/highlightjs/languages 30 | 31 | ## How to change style? 32 | 33 | The extension have `style` option to set the style name: 34 | 35 | ``` 36 | // .gitiki.yml 37 | extensions: 38 | Gitiki\CodeHighlight\CodeHighlightExtension: 39 | style: tomorrow # default style 40 | ]; 41 | ``` 42 | 43 | To see the list of styles, you must refer to git repository: https://github.com/gitiki/code-highlight/tree/master/src/Resources/highlightjs/styles 44 | -------------------------------------------------------------------------------- /wiki/extension/git.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Git 3 | --- 4 | 5 | Git extension add history on pages. 6 | 7 | ## How to install? 8 | 9 | The Git extension is in Gitiki library. You should register the extension: 10 | 11 | ``` 12 | # .gitiki.yml 13 | extensions: 14 | Gitiki\Git\GitExtension: 15 | git_dir: path/to/.git # default the path to current wiki dir 16 | wiki_dir: path/to/wiki/in/git # default empty value 17 | git_binary: /path/to/git # default git 18 | perl_binary: /path/to/perl # default perl 19 | ``` 20 | 21 | ## Highlight diff 22 | 23 | If the path to Perl binary is nonnull, the diffs are highlighted. 24 | 25 | You can disable this feature to set the path to perl binary with a null value: 26 | 27 | ``` 28 | Gitiki\Git\GitExtension: 29 | perl_binary: ~ 30 | ``` 31 | -------------------------------------------------------------------------------- /wiki/extension/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Extensions 3 | --- 4 | 5 | You must create or use extensions for Gitiki! 6 | 7 | ## Official extensions 8 | 9 | * [Code Highlight](code-highlight.md): Syntax highlighting code blocks with [highlight.js][] library. 10 | * [Git](git.md): Show history of pages. 11 | * [Redirector](redirector.md): Redirect an old page to new one. 12 | 13 | [highlight.js]: https://highlightjs.org 14 | -------------------------------------------------------------------------------- /wiki/extension/redirector.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Redirector 3 | --- 4 | 5 | If you rename a page, its URI change. It can be interesting to redirect your users to new page. 6 | 7 | Also, you can keep links to an old page which redirect to an other. 8 | 9 | ## How to install? 10 | 11 | With composer you must run this command `composer require gitiki/redirector`. 12 | 13 | After, register the extension to Gitiki: 14 | 15 | ``` 16 | # .gitiki.yml 17 | extensions: 18 | Gitiki\Redirector\RedirectorExtension: ~ 19 | ``` 20 | 21 | If you move a page, its URI change. Hum… How I can redirect users on my new page? 22 | 23 | ## How to use? 24 | 25 | You must use the meta data to specify the target page with `redirect` attribute: 26 | 27 | --- 28 | redirect: /features/index.md 29 | --- 30 | -------------------------------------------------------------------------------- /wiki/feature/image.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Image 3 | --- 4 | 5 | You can use image media in your pages with the markdown syntax: `![Alt text](path/to/image.jpg)` 6 | 7 | For many reasons you would want to resize image, or add a direct link to the original image or not and this page explain the syntax. 8 | 9 | **The path to an image can be relative (from the current page path: `../photos/cannelle.jpg`) or absolute (from the base path of wiki: `/photos/cannelle.jpg`).** 10 | 11 | Original image 12 | -------------- 13 | 14 | To include the original image, you must use the markdown syntax: 15 | 16 | Absolute style: `![Cannelle](photos/cannelle.jpg)` or relative: `![Cannelle](../photos/cannelle.jpg)` 17 | 18 | ![Cannelle](../photos/cannelle.jpg) 19 | 20 | Resize image {#resize} 21 | ------------ 22 | 23 | To resize an image, the [GD extension](http://php.net/manual/en/book.image.php) is required. If GD is not available, the original image will be returned. 24 | 25 | To resize an image, you must add the `size` GET HTTP parameter. 26 | 27 | ### Resize by width 28 | 29 | Absolute style: `![Cannelle resized by width](/photos/cannelle.jpg?size=200)` or relative: `![Cannelle resized by width](../photos/cannelle.jpg?size=200)` 30 | 31 | ![Cannelle resized by width](../photos/cannelle.jpg?size=200) 32 | 33 | ### Resize by height 34 | 35 | Absolute style: `![Cannelle resized by height](/photos/cannelle.jpg?size=x100)` or relative: `![Cannelle resized by height](../photos/cannelle.jpg?size=x100)` 36 | 37 | ![Cannelle resized by height](../photos/cannelle.jpg?size=x100) 38 | 39 | ### Crop 40 | 41 | Absolute style: `![Cannelle cropped](/photos/cannelle.jpg?size=200x100)` or relative: `![Cannelle cropped](../photos/cannelle.jpg?size=200x100)` 42 | 43 | ![Cannelle cropped](../photos/cannelle.jpg?size=200x100) 44 | 45 | ## Link image 46 | 47 | By default, when you add an image, a link is added to go on specific image page (display image informations). 48 | 49 | You can modify this behavior to remove link or add a direct link to image with `link` GET HTTP parameter. This parameter can be used with `size` parameter. 50 | 51 | ### Direct link 52 | 53 | Absolute style: `![Cannelle without link](/photos/cannelle.jpg?link=direct&size=200)` or relative: `![Cannelle without link](../photos/cannelle.jpg?link=direct&size=200)` 54 | 55 | ![Cannelle without link](../photos/cannelle.jpg?link=direct&size=200) 56 | 57 | ### No link 58 | 59 | Absolute style: `![Cannelle without link](/photos/cannelle.jpg?link=no&size=200)` or relative: `![Cannelle without link](../photos/cannelle.jpg?link=no&size=200)` 60 | 61 | ![Cannelle without link](../photos/cannelle.jpg?link=no&size=200) 62 | -------------------------------------------------------------------------------- /wiki/feature/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Features 3 | --- 4 | 5 | ## Link to another wiki page {#link} 6 | 7 | | Syntax | Output | 8 | |------------------------------------|----------------------------------| 9 | | `[Image](image.md)` | [Image](image.md) | 10 | | `[Anchor link](image.md#resize)` | [Anchor link](image.md#resize) | 11 | 12 | ## Specify id attribute on header {#header-id} 13 | 14 | If you need to link your pages with anchor, you must use ID attribute `## Section title {#section-anchor}`. 15 | 16 | ## Include image {#image} 17 | 18 | A specific [image page](image.md) has been created for this part. 19 | 20 | -------------------------------------------------------------------------------- /wiki/features.md: -------------------------------------------------------------------------------- 1 | --- 2 | redirect: /feature/index.md 3 | --- 4 | -------------------------------------------------------------------------------- /wiki/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Gitiki 3 | --- 4 | 5 | This project is still in development. 6 | 7 | ## Presentation 8 | 9 | Gitiki is an [Open Source PHP wiki engine][github] from [markdown](#markdown) files and a Git repository (or not). 10 | 11 | ### Why markdown? {#markdown} 12 | 13 | [Markdown][] is a simple syntax to structure an information and easy to learn. 14 | It is a natural choice to build a wiki! 15 | 16 | ## Features 17 | 18 | * [History with Git](/extension/git.md) 19 | * [Link wiki pages](/feature/index.md#link) 20 | * [Specify id attributes on header block](/feature/index.md#header-id) 21 | * [Include image](/feature/image.md) 22 | * Table of Contents 23 | 24 | ## Extensions 25 | 26 | Gitiki can be extended with [extensions](/extension/index.md). 27 | 28 | ## TODO 29 | 30 | * Use HTTP Cache (expiration / validation) 31 | * (Optional) Search with Elasticsearch 32 | * Responsive interface 33 | 34 | ## About 35 | 36 | This project is enhanced by [Silex][], [Symfony2 Yaml][yaml] and [Parsedown][]. 37 | 38 | [github]: https://github.com/gitiki/Gitiki/ 39 | [markdown]: http://daringfireball.net/projects/markdown/syntax 40 | [silex]: http://silex.sensiolabs.org 41 | [yaml]: http://symfony.com/doc/current/components/yaml/index.html 42 | [parsedown]: http://parsedown.org 43 | -------------------------------------------------------------------------------- /wiki/installation.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Installation 3 | --- 4 | 5 | ## Docker 6 | 7 | To use easaly Gitiki, you can use our official Docker container: https://hub.docker.com/r/gitiki/gitiki/ 8 | 9 | You must share your wiki directory in container: 10 | 11 | ```bash 12 | $ docker run --detach --name "some-gitiki" --volume "/your/wiki/path:/srv/wiki" --publish "1234:80" gitiki/gitiki 13 | ``` 14 | 15 | And go to http://localhost:1234! 16 | 17 | ## Composer 18 | 19 | With composer, you must download the [Gitiki library from packagist][packagist]: 20 | 21 | ```bash 22 | $ composer create-project --prefer-dist "gitiki/gitiki" "gitiki" "1.0.x-dev" 23 | ``` 24 | 25 | After, it is necessary to create your frontend controller: 26 | 27 | ```php 28 | run(); 34 | ``` 35 | 36 | **Do not forget to install [Gitiki extensions][extensions].** 37 | 38 | 39 | [packagist]: https://packagist.org/packages/gitiki/gitiki 40 | [extensions]: /extension/index.md 41 | -------------------------------------------------------------------------------- /wiki/photos/cannelle.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitiki/Gitiki/f017672d4f5d0ef6015fad44724f05e3b1f08463/wiki/photos/cannelle.jpg --------------------------------------------------------------------------------