├── .docker ├── docs.conf └── php.ini ├── .editorconfig ├── .env-dev ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml └── workflows │ ├── build-assets.yml │ └── validate.yml ├── .gitignore ├── .gitmodules ├── Dockerfile ├── Makefile ├── README.md ├── cache └── .gitkeep ├── composer.json ├── composer.lock ├── db └── .gitignore ├── docker-compose.yml ├── docker-entrypoint.sh ├── docs.php ├── docs └── .gitignore ├── lang.json ├── lang.schema.json ├── phpunit.xml ├── public ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-touch-icon.png ├── browserconfig.xml ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── favicon.svg ├── ht.access ├── images │ ├── logo.svg │ └── menu.svg ├── index.php ├── mstile-144x144.png ├── mstile-150x150.png ├── mstile-310x150.png ├── mstile-310x310.png ├── mstile-70x70.png ├── safari-pinned-tab.svg ├── site.webmanifest ├── template │ ├── README.md │ ├── dist │ │ ├── app.css │ │ ├── app.css.map │ │ ├── main-legacy.js │ │ ├── main-legacy.js.LICENSE.txt │ │ ├── main-legacy.js.map │ │ ├── main-modern.js │ │ ├── main-modern.js.LICENSE.txt │ │ ├── main-modern.js.map │ │ └── sprite.svg │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── js │ │ │ ├── components │ │ │ │ ├── nav.js │ │ │ │ └── search.js │ │ │ ├── main.js │ │ │ ├── polyfills.legacy.js │ │ │ └── polyfills.modern.js │ │ ├── scss │ │ │ ├── _breakpoints.scss │ │ │ ├── _functions.scss │ │ │ ├── _helpers.scss │ │ │ ├── _layout.scss │ │ │ ├── _objects.scss │ │ │ ├── _print.scss │ │ │ ├── _settings.scss │ │ │ ├── _typography.scss │ │ │ ├── app.scss │ │ │ └── components │ │ │ │ ├── _breadcrumb.scss │ │ │ │ ├── _callout.scss │ │ │ │ ├── _code.scss │ │ │ │ ├── _contributors.scss │ │ │ │ ├── _footer.scss │ │ │ │ ├── _heading_links.scss │ │ │ │ ├── _nav.scss │ │ │ │ ├── _opencollective.scss │ │ │ │ ├── _optionswitch.scss │ │ │ │ ├── _search.scss │ │ │ │ ├── _searchform.scss │ │ │ │ ├── _table.scss │ │ │ │ └── _toc.scss │ │ └── svg │ │ │ ├── chevron.svg │ │ │ ├── close.svg │ │ │ ├── externallink.svg │ │ │ ├── logo.svg │ │ │ ├── menu.svg │ │ │ ├── opencollective.svg │ │ │ └── search.svg │ └── webpack.config.js ├── update-cron.php └── update.php ├── sources.dist.json ├── sources.schema.json ├── src ├── CLI │ ├── Application.php │ └── Commands │ │ ├── CacheNavigation.php │ │ ├── CacheRefresh.php │ │ ├── Index │ │ ├── All.php │ │ ├── File.php │ │ ├── Init.php │ │ └── Translations.php │ │ ├── ScrapeImages.php │ │ ├── SourcesInit.php │ │ └── SourcesUpdate.php ├── Containers │ ├── DB.php │ ├── ErrorHandlers.php │ ├── Logger.php │ ├── Services.php │ └── View.php ├── DocsApp.php ├── Exceptions │ ├── NotFoundException.php │ └── RedirectNotFoundException.php ├── Helpers │ ├── LinkRenderer.php │ ├── MarkupFixer.php │ ├── Redirector.php │ ├── RelativeImageRenderer.php │ ├── SettingsParser.php │ └── TocRenderer.php ├── Middlewares │ └── RequestMiddleware.php ├── Model │ ├── Page.php │ ├── PageRequest.php │ ├── SearchQuery.php │ └── SearchResults.php ├── Navigation │ └── Tree.php ├── Services │ ├── CacheService.php │ ├── DocumentService.php │ ├── FilePathService.php │ ├── IndexService.php │ ├── SearchService.php │ ├── TranslationService.php │ └── VersionsService.php ├── Twig │ └── DocExtensions.php └── Views │ ├── Base.php │ ├── Doc.php │ ├── Error.php │ ├── NotFound.php │ ├── Search.php │ └── Stats │ ├── NotFoundRequests.php │ └── Searches.php ├── templates ├── documentation.twig ├── error.twig ├── layout.twig ├── notfound.twig ├── partials │ ├── nav.twig │ └── search_result.twig ├── search.twig ├── search_ajax.twig └── stats │ ├── not-found-requests.twig │ └── searches.twig ├── tests ├── BaseTestCase.php ├── Functional │ ├── DocTest.php │ └── HomepageTest.php └── bootstrap.php └── update-cron.sh /.docker/docs.conf: -------------------------------------------------------------------------------- 1 | 2 | ServerAdmin webmaster@localhost 3 | DocumentRoot /var/www/html/public 4 | 5 | ErrorLog ${APACHE_LOG_DIR}/error.log 6 | CustomLog ${APACHE_LOG_DIR}/access.log combined 7 | -------------------------------------------------------------------------------- /.docker/php.ini: -------------------------------------------------------------------------------- 1 | max_execution_time = 30 2 | max_input_time = 60 3 | memory_limit = 128M 4 | error_reporting = E_ALL 5 | display_errors = On 6 | display_startup_errors = On 7 | log_errors = On 8 | log_errors_max_len = 1024 9 | ignore_repeated_errors = Off 10 | ignore_repeated_source = Off 11 | report_memleaks = On 12 | file_uploads = On 13 | upload_max_filesize = 64M 14 | max_file_uploads = 20 15 | allow_url_fopen = On 16 | date.timezone = Europe/London -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [Makefile] 13 | indent_style = tab 14 | 15 | [*.yml] 16 | indent_size = 2 17 | 18 | [*.scss] 19 | indent_size = 4 20 | 21 | [*.js] 22 | indent_size = 4 23 | 24 | [*.md] 25 | insert_final_newline = false 26 | -------------------------------------------------------------------------------- /.env-dev: -------------------------------------------------------------------------------- 1 | DEV="1" 2 | SSL="0" 3 | 4 | CACHE_ENABLED="0" 5 | CANONICAL_BASE_URL="https://docs.modx.org/" 6 | BASE_DIRECTORY="/var/www/html/" 7 | DOCS_DIRECTORY="/var/www/html/docs/" 8 | TEMPLATE_DIRECTORY="/var/www/html/templates/" 9 | CACHE_DIRECTORY="/var/www/html/cache" 10 | UPDATE_SECRET="Generate secret: openssl rand -hex 38" 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report an issue with the application that serves docs.modx.org. For issues about the content, please visit modxorg/Docs instead. 4 | --- 5 | 6 | ## Summary 7 | 8 | Quick summary what's this issue about. 9 | 10 | ## Step to reproduce 11 | 12 | How to reproduce the issue. If a problem exists on a specific URL, please include that link. Please be specific about the observed and expected behaviour. 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Improvement or Feature Request 3 | about: Suggest an idea for the application running on docs.modx.org, including the design. For content-related suggestions, please visit modxorg/Docs 4 | --- 5 | 6 | ## Summary 7 | 8 | What feature should be added, or what existing feature should be improved? 9 | 10 | ## Why? 11 | 12 | What does the improvement or feature solve, and for who? 13 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### What does it do? 2 | 3 | What has changed for users or contributors of the application? 4 | 5 | ### Why is it needed? 6 | 7 | What problem does this solve? 8 | 9 | ### Related issue(s)/PR(s) 10 | 11 | If there's a related issue, please add `Fixes #`. 12 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "composer" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | day: "tuesday" 13 | versioning-strategy: increase-if-necessary 14 | - package-ecosystem: "npm" 15 | directory: "/public/template/" 16 | schedule: 17 | interval: "weekly" 18 | day: "tuesday" 19 | versioning-strategy: increase-if-necessary 20 | 21 | -------------------------------------------------------------------------------- /.github/workflows/build-assets.yml: -------------------------------------------------------------------------------- 1 | name: Building assets 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build-assets: 7 | 8 | strategy: 9 | matrix: 10 | node-version: [15.x] 11 | 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Setting up node ${{ matrix.node-version }} 16 | uses: actions/setup-node@v1 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | - name: Cache npm modules 20 | uses: actions/cache@v2 21 | with: 22 | # npm cache files are stored in `~/.npm` on Linux/macOS 23 | path: ~/.npm 24 | key: ${{ runner.OS }}-node-${{ hashFiles('**/package-lock.json') }} 25 | restore-keys: | 26 | ${{ runner.OS }}-node- 27 | ${{ runner.OS }}- 28 | - run: npm ci 29 | working-directory: ./public/template 30 | - run: npm run release 31 | working-directory: ./public/template 32 | -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: Validate 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | validate-json: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - name: Validate language file 11 | uses: docker://orrosenblatt/validate-json-action:latest 12 | env: 13 | INPUT_SCHEMA: ./lang.schema.json 14 | INPUT_JSONS: ./lang.json 15 | - name: Validate sources file 16 | uses: docker://orrosenblatt/validate-json-action:latest 17 | env: 18 | INPUT_SCHEMA: ./sources.schema.json 19 | INPUT_JSONS: ./sources.dist.json 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | /logs/* 3 | !/logs/README.md 4 | .idea/ 5 | .DS_Store 6 | cache/* 7 | !cache/.gitkeep 8 | public/.htaccess 9 | node_modules/ 10 | .env 11 | sources.json 12 | markdown/ 13 | public/template/package-lock.json 14 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "doc-sources/current"] 2 | path = doc-sources/current 3 | url = https://github.com/Mark-H/Docs.git 4 | branch = 2.x 5 | [submodule "doc-sources/3.x"] 6 | path = doc-sources/3.x 7 | url = https://github.com/Mark-H/Docs.git 8 | branch = 3.x 9 | [submodule "doc-sources/2.x"] 10 | path = doc-sources/2.x 11 | url = https://github.com/Mark-H/Docs.git 12 | branch = 2.x 13 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16 AS node 2 | FROM php:7.4-rc-apache 3 | 4 | ENV APACHE_DOCUMENT_ROOT=/var/www/html/public 5 | RUN sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/*.conf 6 | RUN sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf 7 | 8 | COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules 9 | COPY --from=node /usr/local/bin/node /usr/local/bin/node 10 | RUN ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm 11 | 12 | # Copy the PHP settings into place 13 | COPY .docker/php.ini /usr/local/etc/php/ 14 | 15 | # Add the Apache config file 16 | COPY .docker/docs.conf /etc/apache2/sites-available/ 17 | 18 | # Install composer 19 | RUN cd /usr/src && curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer 20 | 21 | # Enable Apache rewrite module, create the /var/www/html/public directory, disable the default Apache config file, and 22 | # activate the docs Apache config file. 23 | RUN a2enmod rewrite \ 24 | && mkdir /var/www/html/public \ 25 | && a2dissite 000-default \ 26 | && a2ensite docs 27 | 28 | # Install Git 29 | RUN apt-get update && apt-get install -y --force-yes git zlib1g-dev libicu-dev g++ \ 30 | libzip-dev \ 31 | zip \ 32 | && docker-php-ext-configure intl \ 33 | && docker-php-ext-install intl \ 34 | && docker-php-ext-configure zip --with-libzip \ 35 | && docker-php-ext-install zip 36 | 37 | #Set final permissions 38 | RUN mkdir /var/www/.npm && chown -R www-data:www-data /var/www/.npm 39 | 40 | COPY composer.json /var/www/html/composer.json 41 | RUN composer install 42 | 43 | COPY docker-entrypoint.sh /entrypoint.sh 44 | RUN ["chmod", "+x", "/entrypoint.sh"] 45 | 46 | USER www-data 47 | 48 | ENTRYPOINT ["/entrypoint.sh"] 49 | CMD ["apache2-foreground"] 50 | 51 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all bash build clean down logs restart start status stop tail 2 | 3 | SERVER_SERVICE_NAME = docs 4 | 5 | all: build start 6 | 7 | bash: 8 | @docker-compose run --rm $(SERVER_SERVICE_NAME) bash 9 | 10 | build: 11 | @docker-compose build 12 | 13 | install: 14 | @docker-compose run --rm $(SERVER_SERVICE_NAME) composer install 15 | 16 | clean: 17 | stop 18 | @docker-compose rm --force 19 | 20 | down: 21 | @docker-compose down 22 | 23 | logs: 24 | @docker-compose logs -f 25 | 26 | restart: stop start 27 | 28 | start: 29 | @docker-compose up -d 30 | 31 | status: 32 | @docker-compose ps 33 | 34 | stop: 35 | @docker-compose stop 36 | 37 | tail: 38 | @docker-compose logs $(SERVER_SERVICE_NAME) -------------------------------------------------------------------------------- /cache/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/modxorg/DocsApp/3e019d504b37753440b37ffad55c021eaef4c887/cache/.gitkeep -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mab/modx.org", 3 | "description": "MODX Documentation Website", 4 | "keywords": ["modx", "modx.org"], 5 | "homepage": "https://docs.modx.org", 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Mark Hamstra", 10 | "email": "mark@modmore.com", 11 | "homepage": "https://www.modmore.com/" 12 | } 13 | ], 14 | "require": { 15 | "php": ">=7.4.0", 16 | "ext-json": "*", 17 | "ext-pdo": "*", 18 | "ext-sqlite3": "*", 19 | "ext-fileinfo": "*", 20 | "ext-mbstring": "*", 21 | "slim/slim": "^3.1", 22 | "slim/php-view": "^2.0", 23 | "slim/twig-view": "^2.3", 24 | "spatie/yaml-front-matter": "^2.0", 25 | "league/commonmark": "^1.5", 26 | "caseyamcl/toc": "^3.0", 27 | "vlucas/phpdotenv": "^3.3", 28 | "symfony/console": "^5.2", 29 | "symfony/process": "^5.2", 30 | "voku/stop-words": "^2.0" 31 | }, 32 | "require-dev": { 33 | "ext-intl": "*" 34 | }, 35 | "autoload": { 36 | "psr-4": { 37 | "Tests\\": "tests/", 38 | "MODXDocs\\": "src/" 39 | } 40 | }, 41 | "config": { 42 | "process-timeout" : 0, 43 | "platform": { 44 | "php": "7.4.0" 45 | } 46 | }, 47 | "scripts": { 48 | "start": "php -S localhost:8080 -t public index.php", 49 | "test": "phpunit" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /db/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | docs: 5 | build: 6 | context: . 7 | dockerfile: Dockerfile 8 | ports: 9 | - "8000:80" 10 | environment: 11 | - docker=1 12 | - COMPOSER_ALLOW_SUPERUSER=1 13 | volumes: 14 | - .:/var/www/html 15 | -------------------------------------------------------------------------------- /docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | #cd /var/www/html 5 | #echo "Installing compose dependencies" 6 | #composer install 7 | echo "Init docs and download repos" 8 | php docs.php sources:init 9 | cd /var/www/html/public/template 10 | echo "Install node dependencies and build template" 11 | npm install 12 | npm run build 13 | 14 | exec "$@" 15 | -------------------------------------------------------------------------------- /docs.php: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | getSlimConfig()); 13 | 14 | $cliApp = new Application($docsApp); 15 | $cliApp->run(); 16 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /lang.json: -------------------------------------------------------------------------------- 1 | { 2 | "en": { 3 | "title": "MODX Documentation", 4 | "home": "Home", 5 | "search": "Search documentation", 6 | "last_updated": "Last updated", 7 | "page_history": "Page history", 8 | "improve_page": "Improve this page", 9 | "report_issue": "Report an issue", 10 | "report_issue_long": "Report an issue with this page", 11 | "suggest_delete": "This page is marked as a candidate to be deleted:", 12 | "in_this_document": "In this document:", 13 | "note": "Note:", 14 | "community_forum": "Community Forums", 15 | "slack": "Slack", 16 | "credit": "Designed, built and written with all the love in the world by the MODX Community." 17 | }, 18 | "ru": { 19 | "title": "MODX документация", 20 | "home": "Главная", 21 | "search": "Поиск документации", 22 | "last_updated": "Последнее обновление", 23 | "page_history": "История страницы", 24 | "improve_page": "Улучшить эту страницу", 25 | "report_issue": "Сообщить о проблеме", 26 | "report_issue_long": "Сообщить о проблеме на этой странице", 27 | "suggest_delete": "Эта страница помечена как кандидат на удаление:", 28 | "in_this_document": "Оглавление: ", 29 | "note": "Заметка:", 30 | "community_forum": "Community Forums", 31 | "slack": "Slack", 32 | "credit": "Разработано, построено и написано со всей любовью в мире от сообщества MODX." 33 | }, 34 | "nl": { 35 | "title": "MODX Documentatie", 36 | "home": "Home", 37 | "search": "Zoek in de documentatie", 38 | "last_updated": "Laatst bijgewerkt", 39 | "page_history": "Pagina geschiedenis", 40 | "improve_page": "Verbeter deze pagina", 41 | "report_issue": "Meld een probleem", 42 | "report_issue_long": "Meld een probleem met deze pagina", 43 | "suggest_delete": "Deze pagina is gemarkeerd als een kandidaat voor verwijdering:", 44 | "in_this_document": "In dit document:", 45 | "note": "Opmerking:", 46 | "community_forum": "Community Forums", 47 | "slack": "Slack", 48 | "credit": "Ontworpen, gebouwd en geschreven met alle liefde in de wereld door de MODX Community." 49 | }, 50 | "es": { 51 | "title": "MODX Documentation", 52 | "home": "Home", 53 | "search": "Search documentation", 54 | "last_updated": "Последнее обновление", 55 | "page_history": "Historial de la página", 56 | "improve_page": "Mejorar esta página", 57 | "report_issue": "Reportar un problema", 58 | "report_issue_long": "Informar un problema con esta página", 59 | "suggest_delete": "Esta página está marcada como candidato a ser eliminado:", 60 | "in_this_document": "En este documento:", 61 | "note": "Nota:", 62 | "community_forum": "Community Forums", 63 | "slack": "Slack", 64 | "credit": "Designed, built and written with all the love in the world by the MODX Community." 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /lang.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "$id": "https://github.com/modxorg/DocsApp/blob/master/lang.schema.json", 4 | "title": "Language File", 5 | "description": "Basic language file used to present the site in different languages.", 6 | "type": "object", 7 | "required": ["en"], 8 | "properties": { 9 | }, 10 | "patternProperties": { 11 | "^([a-z]{2})$": { "$ref": "#/definitions/language"} 12 | }, 13 | "additionalProperties": false, 14 | 15 | "definitions": { 16 | "language": { 17 | "type": "object", 18 | "additionalProperties": true 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | tests 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/modxorg/DocsApp/3e019d504b37753440b37ffad55c021eaef4c887/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/modxorg/DocsApp/3e019d504b37753440b37ffad55c021eaef4c887/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/modxorg/DocsApp/3e019d504b37753440b37ffad55c021eaef4c887/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #102c53 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/modxorg/DocsApp/3e019d504b37753440b37ffad55c021eaef4c887/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/modxorg/DocsApp/3e019d504b37753440b37ffad55c021eaef4c887/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/modxorg/DocsApp/3e019d504b37753440b37ffad55c021eaef4c887/public/favicon.ico -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /public/ht.access: -------------------------------------------------------------------------------- 1 | 2 | RewriteEngine On 3 | 4 | # Some hosts may require you to use the `RewriteBase` directive. 5 | # Determine the RewriteBase automatically and set it as environment variable. 6 | # If you are using Apache aliases to do mass virtual hosting or installed the 7 | # project in a subdirectory, the base path will be prepended to allow proper 8 | # resolution of the index.php file and to redirect to the correct URI. It will 9 | # work in environments without path prefix as well, providing a safe, one-size 10 | # fits all solution. But as you do not need it in this case, you can comment 11 | # the following 2 lines to eliminate the overhead. 12 | RewriteCond %{REQUEST_URI}::$1 ^(/.+)/(.*)::\2$ 13 | RewriteRule ^(.*) - [E=BASE:%1] 14 | 15 | # If the above doesn't work you might need to set the `RewriteBase` directive manually, it should be the 16 | # absolute physical path to the directory that contains this htaccess file. 17 | # RewriteBase / 18 | 19 | RewriteCond %{REQUEST_FILENAME} !-f 20 | RewriteRule ^ index.php [QSA,L] 21 | 22 | -------------------------------------------------------------------------------- /public/images/menu.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | getSlimConfig()); 20 | $app->run(); 21 | -------------------------------------------------------------------------------- /public/mstile-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/modxorg/DocsApp/3e019d504b37753440b37ffad55c021eaef4c887/public/mstile-144x144.png -------------------------------------------------------------------------------- /public/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/modxorg/DocsApp/3e019d504b37753440b37ffad55c021eaef4c887/public/mstile-150x150.png -------------------------------------------------------------------------------- /public/mstile-310x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/modxorg/DocsApp/3e019d504b37753440b37ffad55c021eaef4c887/public/mstile-310x150.png -------------------------------------------------------------------------------- /public/mstile-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/modxorg/DocsApp/3e019d504b37753440b37ffad55c021eaef4c887/public/mstile-310x310.png -------------------------------------------------------------------------------- /public/mstile-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/modxorg/DocsApp/3e019d504b37753440b37ffad55c021eaef4c887/public/mstile-70x70.png -------------------------------------------------------------------------------- /public/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 9 | 22 | 24 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "MODX Documentation", 3 | "short_name": "MODX Docs", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#102c53", 17 | "background_color": "#102c53" 18 | } 19 | -------------------------------------------------------------------------------- /public/template/README.md: -------------------------------------------------------------------------------- 1 | # Frontend Tooling 2 | 3 | Our frontend workflow includes NPM Scripts, SASS and Webpack. We use [namespaced BEM](https://csswizardry.com/2015/03/more-transparent-ui-code-with-namespaces/) for classnames in CSS and [ES6 syntax](https://www.taniarascia.com/es6-syntax-and-feature-overview/) for JavaScript. 4 | 5 | ## Prerequisites 6 | 7 | Make sure you got *node* (version 10.12.x or higher) and *npm* isntalled. You can test this with: 8 | ``` 9 | node -v 10 | ``` 11 | (should output something like `v10.15.0`) 12 | ``` 13 | npm -v 14 | ``` 15 | (should output something like `6.9.0`) 16 | 17 | ## Initialization 18 | 19 | To download and install all dependencies you need to `cd` into the `template` folder and run `npm install`. 20 | ``` 21 | cd /public/template/ 22 | npm install 23 | ``` 24 | Whenever new dependencies get added to the project, you need to run this again. 25 | 26 | ## Building assets 27 | 28 | We use [npm scripts](https://css-tricks.com/why-npm-scripts/) for building our frontend assets (instead of Grunt or Gulp). Those scripts are defined in the [package.json](package.json) file in the `scripts` section. 29 | 30 | ### Start development 31 | 32 | Normally when starting your frontend work, you want to start livereload and automatically watch for file changes (and re-build assets automatically). This can be done with the `start` command that triggers a couple of processes. 33 | ``` 34 | npm run start 35 | ``` 36 | (or short `npm start`) 37 | 38 | ### Generating dev builds 39 | 40 | When you just want to create a single dev build of the assets, you can simply run one of the following commands: 41 | ``` 42 | npm run build:js 43 | ``` 44 | ``` 45 | npm run build:css 46 | ``` 47 | ``` 48 | npm run build:svg 49 | ``` 50 | Or to build all 3 types together simply: 51 | ``` 52 | npm run build 53 | ``` 54 | 55 | ### Prepare a new release 56 | To build CSS, JS and SVG for production usage use: 57 | ``` 58 | npm run release 59 | ``` 60 | …this makes sure that webpack runs production ready builds that are fully optimized. 61 | 62 | ## Adding new features / files 63 | 64 | ### SVG Sprite 65 | We automatically generate a SVG sprite from the files located at `public/template/src/svg/`. When adding new files, please make sure they are optimized and as small as possible. One recommended tool to optimize SVGs is [SVGOMG from Jake Archibald](https://jakearchibald.github.io/svgomg/). 66 | 67 | ### CSS 68 | Please make sure to separate CSS into different files based on components/features. This gives other developers much more orientation. When adding new files make sure to import them in the `public/template/src/scss/main.scss`. 69 | 70 | ### JS 71 | Please make sure to separate your JS code into different files based on components/features. This gives other developers much more orientation. When adding new files make sure to use ES6 syntaxt with an `export` and import them in the `public/template/src/scss/main.js`. When you need to add polyfills make sure to them in the `polyfill` files based on browser support. 72 | -------------------------------------------------------------------------------- /public/template/dist/main-legacy.js.LICENSE.txt: -------------------------------------------------------------------------------- 1 | /*! svg4everybody v2.1.9 | github.com/jonathantneal/svg4everybody */ 2 | 3 | /** 4 | * Prism: Lightweight, robust, elegant syntax highlighting 5 | * 6 | * @license MIT 7 | * @author Lea Verou 8 | * @namespace 9 | * @public 10 | */ 11 | -------------------------------------------------------------------------------- /public/template/dist/main-modern.js.LICENSE.txt: -------------------------------------------------------------------------------- 1 | /** 2 | * Prism: Lightweight, robust, elegant syntax highlighting 3 | * 4 | * @license MIT 5 | * @author Lea Verou 6 | * @namespace 7 | * @public 8 | */ 9 | -------------------------------------------------------------------------------- /public/template/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@modxorg/docsapp", 3 | "description": "DocsApp assets for MODX Documentation", 4 | "private": true, 5 | "scripts": { 6 | "start": "run-p livereload watch", 7 | "watch": "run-p watch:*", 8 | "lint": "run-p lint:*", 9 | "build": "run-p build:*", 10 | "release": "run-p release:*", 11 | "analyize": "BROWSERSLIST_ENV=modern webpack --mode production --profile --json > webpackstats.json && webpack-bundle-analyzer webpackstats.json", 12 | "livereload": "livereload 'dist/*' --wait 300", 13 | "sass": "BROWSERSLIST_ENV=modern sass src/scss/:dist --load-path=./ --style=expanded --source-map", 14 | "watch:css": "npm run sass && npm run sass -- --watch", 15 | "build:css": "npm run sass -- --style=compressed --embed-sources", 16 | "postbuild:css": "BROWSERSLIST_ENV=modern postcss dist/*.css --use autoprefixer --replace --map", 17 | "release:css": "npm run build:css", 18 | "watch:svg": "onchange -i 'src/svg/*' -- npm run build:svg", 19 | "build:svg": "svg-sprite --symbol --symbol-dest dist --symbol-sprite sprite.svg src/svg/*.svg", 20 | "release:svg": "npm run build:svg", 21 | "lint:js": "eslint --config package.json --ext '.js' src/js/*", 22 | "watch:js": "onchange 'src/js/**' -- npm run lint:js & BROWSERSLIST_ENV=modern webpack --watch --mode development", 23 | "build:js": "npm run lint:js && BROWSERSLIST_ENV=modern webpack --mode development", 24 | "release:js": "run-p lint:js release:js:*", 25 | "release:js:modern": "BROWSERSLIST_ENV=modern webpack --mode production", 26 | "release:js:legacy": "BROWSERSLIST_ENV=legacy webpack --mode production" 27 | }, 28 | "browserslist": { 29 | "modern": [ 30 | "last 2 versions", 31 | "> 1%", 32 | "not dead" 33 | ], 34 | "legacy": [ 35 | "> 0.5%", 36 | "ie 11" 37 | ] 38 | }, 39 | "eslintConfig": { 40 | "env": { 41 | "browser": true, 42 | "es6": true 43 | }, 44 | "parserOptions": { 45 | "sourceType": "module", 46 | "ecmaVersion": 2017 47 | } 48 | }, 49 | "devDependencies": { 50 | "@babel/core": "^7.13", 51 | "@babel/preset-env": "^7.13", 52 | "@babel/register": "^7.13", 53 | "autoprefixer": "^10.2", 54 | "babel-loader": "^8.2.2", 55 | "babel-plugin-prismjs": "^2.0.1", 56 | "eslint": "^7.20.0", 57 | "livereload": "^0.9.1", 58 | "npm-run-all": "^4.1.5", 59 | "onchange": "^7.1.0", 60 | "postcss-cli": "^8.3.1", 61 | "sass": "^1.32.8", 62 | "script-loader": "^0.7.2", 63 | "webpack": "^5.24", 64 | "webpack-bundle-analyzer": "^4.4.0", 65 | "webpack-cli": "^4.5.0" 66 | }, 67 | "dependencies": { 68 | "@babel/polyfill": "^7.12.1", 69 | "modern-normalize": "^1.0.0", 70 | "prism-github": "^1.1.0", 71 | "prismjs": "^1.23.0", 72 | "svg-sprite": "^2.0.2", 73 | "svg4everybody": "^2.1.9" 74 | }, 75 | "resolutions": { 76 | "braces": "^2.3.1" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /public/template/src/js/components/nav.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Nav Class 3 | */ 4 | 5 | class Nav { 6 | getClassName() {return 'Nav';} 7 | constructor() { 8 | let self = this; 9 | self.collapseNavigation(); 10 | 11 | let menuButton = document.querySelector('a.o-openmenu'); 12 | if (menuButton) { 13 | self.handleToggle(menuButton, 'nav'); 14 | } 15 | 16 | let searchButton = document.querySelector('a.o-search'); 17 | if (searchButton) { 18 | self.handleToggle(searchButton, 'searchform'); 19 | } 20 | 21 | let langSwitch = document.querySelector('a.c-optionswitch__current'); 22 | if (langSwitch) { 23 | self.handleToggle(langSwitch, 'switchsettings'); 24 | } 25 | } 26 | 27 | collapseNavigation() { 28 | let self = this; 29 | 30 | // iterate items with children 31 | let collapseableItems = document.querySelectorAll('.c-nav__item--has-children'); 32 | 33 | collapseableItems.forEach((item) => { 34 | // find link 35 | let link = item.querySelector(':scope > .c-nav__link'); 36 | if (!link) { 37 | return; 38 | } 39 | 40 | // find chevron icon (toggle) 41 | let chevron = link.querySelector('.c-nav__chevron'); 42 | if (!chevron) { 43 | return; 44 | } 45 | 46 | // collapse non-active items 47 | if (item.classList.contains('c-nav__item--active') === false) { 48 | item.classList.add('c-nav__item--collapsed'); 49 | } 50 | 51 | // attach event listener to icon 52 | chevron.addEventListener('click', (e) => { 53 | item.classList.toggle('c-nav__item--collapsed'); 54 | e.stopPropagation() 55 | e.preventDefault(); 56 | return false; 57 | }); 58 | }); 59 | 60 | // scroll active page into view 61 | let activepageItem = document.querySelector('.c-nav__item--activepage'); 62 | if (activepageItem) { 63 | if (activepageItem.classList.contains('c-nav__item--level2')) { 64 | activepageItem = activepageItem.parentNode.parentNode; 65 | } else { 66 | activepageItem = activepageItem.parentNode.parentNode.parentNode; 67 | } 68 | activepageItem.scrollIntoView(); 69 | } 70 | 71 | // expand first item if no active item exists (e.i. root page) 72 | if (document.querySelector('.c-nav__item--active') === null) { 73 | let firstItem = document.querySelector('.c-nav__item:first-of-type'); 74 | if (firstItem) { 75 | firstItem.classList.remove('c-nav__item--collapsed'); 76 | } 77 | } 78 | } 79 | 80 | handleToggle(element, anchor) { 81 | element.addEventListener('click', (e) => { 82 | // toggle state class 83 | element.classList.toggle('is--opened'); 84 | // after delay, toggle '#nav' in href attribute 85 | setTimeout(function(){ 86 | if (window.location.hash === '#'+anchor) { 87 | element.href = element.href.replace('#'+anchor, '#'); 88 | } else { 89 | element.href = element.href.replace('#', '#'+anchor); 90 | } 91 | }, 100); 92 | return true; 93 | }); 94 | } 95 | } 96 | 97 | export default Nav; 98 | -------------------------------------------------------------------------------- /public/template/src/js/main.js: -------------------------------------------------------------------------------- 1 | import Prism from 'prismjs'; 2 | 3 | import 'prismjs/components/prism-php.min.js'; 4 | 5 | // replace no-js class on html tag 6 | document.documentElement.className = document.documentElement.className.replace(/\bno-js\b/, '') + ' js'; 7 | 8 | import Nav from './components/nav.js'; 9 | import Search from './components/search.js'; 10 | let nav = new Nav(), 11 | search = new Search(); 12 | -------------------------------------------------------------------------------- /public/template/src/js/polyfills.legacy.js: -------------------------------------------------------------------------------- 1 | import svg4everybody from 'svg4everybody'; 2 | svg4everybody(); 3 | -------------------------------------------------------------------------------- /public/template/src/js/polyfills.modern.js: -------------------------------------------------------------------------------- 1 | // :scope polyfill for querySelector (via https://stackoverflow.com/a/17989803) 2 | (function(doc, proto) { 3 | try { // check if browser supports :scope natively 4 | doc.querySelector(':scope body'); 5 | } catch (err) { // polyfill native methods if it doesn't 6 | ['querySelector', 'querySelectorAll'].forEach(function(method) { 7 | var nativ = proto[method]; 8 | proto[method] = function(selectors) { 9 | if (/(^|,)\s*:scope/.test(selectors)) { // only if selectors contains :scope 10 | var id = this.id; // remember current element id 11 | this.id = 'ID_' + Date.now(); // assign new unique id 12 | selectors = selectors.replace(/((^|,)\s*):scope/g, '$1#' + this.id); // replace :scope with #ID 13 | var result = doc[method](selectors); 14 | this.id = id; // restore previous id 15 | return result; 16 | } else { 17 | return nativ.call(this, selectors); // use native code for other selectors 18 | } 19 | } 20 | }); 21 | } 22 | })(window.document, Element.prototype); 23 | -------------------------------------------------------------------------------- /public/template/src/scss/_helpers.scss: -------------------------------------------------------------------------------- 1 | .u-show-for-sr, .u-show-on-focus { 2 | position: absolute !important; 3 | width: 1px; 4 | height: 1px; 5 | padding: 0; 6 | overflow: hidden; 7 | clip: rect(0, 0, 0, 0); 8 | white-space: nowrap; 9 | border: 0; 10 | } 11 | 12 | // make anchor tags work with fixed header 13 | :target:before { 14 | content:""; 15 | display:block; 16 | height: ($header-height+$main-margin-top); /* fixed header height*/ 17 | margin: (($header-height+$main-margin-top)*-1) 0 0; /* negative fixed header height */ 18 | } 19 | 20 | .is-brokenlink { 21 | color: map-get($colors, 'link-broken'); 22 | text-decoration: line-through; 23 | 24 | &:after { 25 | content: " (link broken)"; 26 | text-decoration: none; 27 | } 28 | } 29 | 30 | // style external links 31 | //.o-content a[href*="//"]:not([href*="docs.modx.org"]), 32 | .is-externallink { 33 | &:after { 34 | content: ""; 35 | display: inline-block; 36 | width: rem(10); 37 | height: rem(10); 38 | margin: rem(-1 2 0 4); 39 | 40 | background: svg-url(''); 41 | background-size: 100% 100%; 42 | background-repeat: no-repeat; 43 | background-position: center center; 44 | } 45 | } 46 | 47 | .u-no-margin { 48 | margin: 0 !important; 49 | } 50 | .u-no-margin--top { 51 | margin-top: 0 !important; 52 | } 53 | .u-no-margin--bottom { 54 | margin-bottom: 0 !important; 55 | } 56 | 57 | .u-no-padding { 58 | padding: 0 !important; 59 | } 60 | .u-no-padding--top { 61 | padding-top: 0 !important; 62 | } 63 | .u-no-padding--bottom { 64 | padding-bottom: 0 !important; 65 | } 66 | 67 | html.no-js { 68 | .u-show-with-js { 69 | display: none !important; 70 | } 71 | } 72 | html.js { 73 | .u-hide-with-js { 74 | display: none !important; 75 | } 76 | .u-hide-with-js-sr { 77 | @extend .u-show-for-sr; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /public/template/src/scss/_layout.scss: -------------------------------------------------------------------------------- 1 | html, body { 2 | height: 100%; 3 | width: 100%; 4 | background: map-get($colors, 'body-bg'); 5 | scroll-behavior: smooth; 6 | } 7 | 8 | .l-app { 9 | display: flex; 10 | min-height: 100vh; 11 | flex-direction: column; 12 | 13 | padding-top: $header-height; 14 | background: map-get($colors, 'body-bg'); 15 | //filter: invert(100%); 16 | 17 | &--has-sidebar { 18 | @include breakpoint(large) { 19 | padding-left: $sidebar-width; 20 | } 21 | } 22 | } 23 | 24 | .l-header { 25 | z-index: 2; 26 | position: fixed; 27 | top: 0; 28 | right: 0; 29 | left: 0; 30 | display: flex; 31 | align-items: center; 32 | justify-content: space-between; 33 | height: $header-height; 34 | background: map-get($colors, 'header-bg'); 35 | border-bottom: 1px solid lightendarken(map-get($colors, border), 10%); 36 | box-shadow: 0 1px 4px 0 rgba(0,0,0,0.1); 37 | 38 | &__logo { 39 | flex: auto 0 0; 40 | position: relative; 41 | 42 | @include breakpoint(xlarge) { 43 | flex: $sidebar-width 0 0; 44 | flex: calc(#{$sidebar-width} - #{rem(30)}) 0 0; 45 | } 46 | } 47 | 48 | &__search { 49 | flex-basis: 50%; 50 | flex-grow: 0; 51 | flex-shrink: 1; 52 | position: relative; 53 | 54 | @include breakpoint(xlarge) { 55 | margin: rem(0 50 0 75); 56 | } 57 | 58 | @include breakpoint(small only) { 59 | position: fixed; 60 | width: 100%; 61 | margin: 0 !important; 62 | top: 3.75rem; 63 | background: map-get($colors, 'body-bg'); 64 | height: calc(100vh - 2.25rem); 65 | padding: 0 1rem 1rem 1rem; 66 | transform: translateY(100%); 67 | transition: transform .4s ease-in-out, -webkit-transform .4s ease-in-out; 68 | } 69 | } 70 | &__versionswitch{ 71 | margin: rem(0 15); 72 | } 73 | } 74 | @include breakpoint(small only) { 75 | #searchform:target { 76 | transform: translateY(0); 77 | } 78 | } 79 | 80 | .l-sidebar { 81 | outline: none; 82 | display: block; 83 | position: fixed; 84 | top: $header-height; 85 | bottom: 0; 86 | left: 0; 87 | width: 100%; 88 | transform: translateX(-100%); 89 | z-index: 1; 90 | padding: $main-margin-top rem(20) $global-margin; 91 | background: map-get($colors, 'sidebar-bg'); 92 | border-right: 1px solid map-get($colors, border); 93 | overflow-y: scroll; 94 | -webkit-overflow-scrolling: touch; 95 | overflow-x: auto; 96 | transition: transform .4s ease-in-out; 97 | 98 | @include breakpoint($sidebar-breakpoint) { 99 | display: block; 100 | position: fixed; 101 | top: $header-height; 102 | bottom: 0; 103 | left: 0; 104 | width: $sidebar-width; 105 | transform: translateX(0); 106 | transition: none; 107 | } 108 | } 109 | 110 | #nav:target { 111 | transform: translateX(0); 112 | } 113 | 114 | 115 | .l-main { 116 | position: relative; 117 | margin: $main-margin-top rem(20) $global-margin; 118 | 119 | flex: 1; 120 | 121 | @include breakpoint(medium) { 122 | margin: $main-margin-top rem(50) $global-margin rem(75); 123 | } 124 | 125 | &__title { 126 | max-width: rem(1000); 127 | } 128 | 129 | &__contentwrapper { 130 | @include breakpoint(xlarge) { 131 | display: flex; 132 | justify-content: flex-start; 133 | flex-direction: row; 134 | } 135 | } 136 | 137 | &__content { 138 | @include breakpoint(medium) { 139 | padding-right: rem(25); 140 | } 141 | 142 | @include breakpoint(xlarge) { 143 | flex-basis: 80%; 144 | flex-grow: 0; 145 | flex-shrink: 1; 146 | order: 1; 147 | min-width: 0; 148 | } 149 | 150 | > :first-child { 151 | margin-top: 0; 152 | } 153 | } 154 | 155 | &__toc { 156 | @include breakpoint(xlarge) { 157 | flex-basis: 20%; 158 | flex-grow: 1; 159 | flex-shrink: 0; 160 | align-self: flex-start; 161 | order: 2; 162 | padding: rem(0 0 0 25); 163 | top: rem(75); 164 | position: sticky; 165 | } 166 | } 167 | 168 | } 169 | 170 | .l-footer { 171 | flex-basis: 100%; 172 | flex-shrink: 0; 173 | order: 3; 174 | padding: rem(30 15 15); 175 | margin: rem(45 0 0); 176 | border-top: 1px solid map-get($colors, border); 177 | 178 | @include breakpoint(medium) { 179 | padding: rem(30 75 15); 180 | } 181 | } 182 | 183 | @media (max-width: 768px) { 184 | .o-openmenu { 185 | order: 4; 186 | padding: 0 .9375rem 0 .9375rem !important; 187 | } 188 | .o-search, .l-header__search { 189 | order: 3; 190 | margin: 0 0.475rem 0 auto; 191 | } 192 | .l-header__versionswitch { 193 | order: 2; 194 | } 195 | .l-header__logo { 196 | order: 1; 197 | max-width: 25%; 198 | } 199 | } 200 | @media (max-width: 425px) { 201 | .l-footer { 202 | font-size: 14px; 203 | line-height: 1; 204 | padding: .9375rem .9375rem; 205 | .c-footer__nav{ 206 | display: flex; 207 | justify-content: center; 208 | &link { 209 | padding: .425rem; 210 | } 211 | } 212 | .c-footer__copyright{ 213 | font-size: 14px; 214 | line-height: 1; 215 | padding: .3rem .9375rem 0; 216 | text-align: center; 217 | } 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /public/template/src/scss/_objects.scss: -------------------------------------------------------------------------------- 1 | .o-logo { 2 | margin: rem(0 15); 3 | padding: rem(0); 4 | &__image { 5 | display: block; 6 | width: rem(170); 7 | max-width: 100%; 8 | height: $header-height; 9 | fill: map-get($colors, 'header-text'); 10 | 11 | @include breakpoint(medium) { 12 | width: rem(200); 13 | } 14 | } 15 | } 16 | 17 | .o-docmeta { 18 | margin: rem(-15) 0 $global-margin*2; 19 | } 20 | 21 | .o-sidebar-heading { 22 | display: block; 23 | margin: 0 0 $global-margin; 24 | font-size: rem(20); 25 | font-weight: 700; 26 | } 27 | 28 | 29 | .o-openmenu, .o-search { 30 | display: block; 31 | margin: rem(0); 32 | padding: rem(0 15 0 30); 33 | 34 | &__icon { 35 | display: block; 36 | width: rem(29); 37 | height: $header-height; 38 | fill: map-get($colors, 'header-text'); 39 | } 40 | 41 | /* 42 | &--opened { 43 | .o-openmenu__icon { 44 | fill: transparent; 45 | } 46 | } 47 | */ 48 | } 49 | @include breakpoint($sidebar-breakpoint) { 50 | .o-openmenu { 51 | display: none; 52 | } 53 | } 54 | @include breakpoint(medium) { 55 | .o-search { 56 | display: none; 57 | } 58 | } 59 | @include breakpoint(small only) { 60 | .o-logo{ 61 | margin: rem(0 7); 62 | } 63 | .o-search { 64 | padding: rem(0 7); 65 | } 66 | } 67 | .o-closemenu { 68 | z-index: 1; 69 | position: absolute; 70 | top: rem(10); 71 | right: rem(10); 72 | display: block; 73 | padding: rem(15 15); 74 | 75 | @include breakpoint($sidebar-breakpoint) { 76 | display: none; 77 | } 78 | 79 | &__icon { 80 | display: block; 81 | width: rem(30); 82 | height: rem(30); 83 | fill: map-get($colors, 'primary'); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /public/template/src/scss/_print.scss: -------------------------------------------------------------------------------- 1 | @media print { 2 | .l-app > * { 3 | display: none; 4 | } 5 | 6 | .l-app .l-main { 7 | display: block; 8 | } 9 | } -------------------------------------------------------------------------------- /public/template/src/scss/_settings.scss: -------------------------------------------------------------------------------- 1 | $colors: ( 2 | cadetBlue: #a0aec0, 3 | primary: #3b68af, // AA compatible 4 | sapphire: #314a97, 5 | perano: #b2c9f5, 6 | light: #ededed, 7 | gray:#4a5568, // AA compatible 8 | slateGray: #475365, // AAA compatible 9 | mirage: #1a202c, 10 | alert: #d26d69, 11 | success: #2d8b01, 12 | warning: #EEBF41, 13 | dark: #252930, 14 | 'callout-info-border': #5b99ea, 15 | 'callout-alert-border': #d26d69, 16 | 'callout-success-border': #5cb377, 17 | 'callout-warning-border': #EEBF41, 18 | 'callout-info-bg':#f9fbfe, 19 | 'callout-alert-bg': #f5f5f5, 20 | 'callout-success-bg': #e7f4eb, 21 | 'callout-warning-bg': #fdf6e5, 22 | 'header-bg': #fff, 23 | 'header-text': #4a5568, //#4a4a4a, 24 | 'sidebar-bg': #F5F7F9, 25 | 'sidebar-text': #303942, 26 | 'nav-active': #247978, 27 | 'nav-active-bg': #b2f5ea, 28 | 'body-bg': #fff, 29 | 'body-text': #4a5568, 30 | 'border': #E6ECF1, 31 | 'link-broken': #d26d69, 32 | 'link-color': #1c6fdc, 33 | 'inline-code': #ebf1ff, 34 | 'optionswitch-bg': #edf2f7, 35 | ); 36 | 37 | /* 38 | $dark-colors: ( 39 | primary: #68a2ff, // AA compatible 40 | light: #ededed, 41 | gray: #757575, // AA compatible 42 | alert: #c70000, 43 | success: #2d8b01, 44 | warning: #ffae00, 45 | 'header-bg': #052a67, 46 | 'header-text': #fff, //#4a4a4a, 47 | 'sidebar-bg': #141f31, 48 | 'body-bg': #111, 49 | 'body-text': #fff, 50 | border: #E6ECF1, 51 | ); 52 | */ 53 | 54 | $breakpoints: ( 55 | small: 0, 56 | medium: 700px, 57 | large: 1000px, 58 | xlarge: 1220px, 59 | xxlarge: 1300px 60 | ); 61 | 62 | $global-font-size: 100%; 63 | $global-line-height: 1.6; 64 | $global-width: rem(1200); 65 | $global-margin: rem(15); 66 | $global-radius: rem(3); 67 | $paragraph-max-width: 80ch; // approx 60 actual flex-width font characters 68 | 69 | $body-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; 70 | 71 | $header-height: rem(60); 72 | 73 | $main-margin-top: rem(30); 74 | 75 | $sidebar-width: rem(320); 76 | $sidebar-breakpoint: 'large'; 77 | -------------------------------------------------------------------------------- /public/template/src/scss/_typography.scss: -------------------------------------------------------------------------------- 1 | body { 2 | background:map-get($colors, 'body-bg'); 3 | color: map-get($colors, 'gray'); 4 | font-family: $body-font-family; 5 | font-weight: 400; 6 | line-height: $global-line-height; 7 | color: map-get($colors, 'body-text'); 8 | -webkit-font-smoothing: antialiased; 9 | -moz-osx-font-smoothing: grayscale; 10 | } 11 | 12 | h1, h2, h3, h4, h5, h6 { 13 | color: map-get($colors, dark); 14 | line-height: 1.3; 15 | margin-bottom: 2rem; 16 | } 17 | 18 | $min_width: rem(521); 19 | $max_width: rem(1700); 20 | 21 | h1 { 22 | margin-top: rem(30); 23 | //font-size: rem(45); 24 | @include fluid-type($min_width, $max_width, rem(30), rem(50)); 25 | } 26 | 27 | h2 { 28 | //font-size: rem(30); 29 | @include fluid-type($min_width, $max_width, rem(22), rem(35)); 30 | } 31 | 32 | h3 { 33 | //font-size: rem(26); 34 | @include fluid-type($min_width, $max_width, rem(20), rem(31)); 35 | } 36 | 37 | h4 { 38 | //font-size: rem(22); 39 | @include fluid-type($min_width, $max_width, rem(18), rem(27)); 40 | } 41 | 42 | h5 { 43 | //font-size: rem(20); 44 | @include fluid-type($min_width, $max_width, rem(16), rem(25)); 45 | } 46 | 47 | h6 { 48 | //font-size: rem(18); 49 | @include fluid-type($min_width, $max_width, rem(16), rem(23)); 50 | } 51 | 52 | 53 | a { 54 | text-decoration: none; 55 | color: map-get($colors, 'link-color'); 56 | transition: color .2s ease-in-out; 57 | 58 | &:hover { 59 | color: lightendarken(map-get($colors, 'link-color'), 15%); 60 | } 61 | 62 | // Links should look like links 63 | .l-main__content &, .c-callout & { 64 | text-decoration: underline; 65 | } 66 | } 67 | 68 | img { 69 | // Get rid of gap under images by making them display: inline-block; by default 70 | display: inline-block; 71 | vertical-align: middle; 72 | 73 | // Grid defaults to get images and embeds to work properly 74 | max-width: 100%; 75 | height: auto; 76 | -ms-interpolation-mode: bicubic; 77 | } 78 | 79 | p { 80 | margin: 0 0 $global-margin; 81 | line-height: $global-line-height; 82 | max-width: $paragraph-max-width; 83 | } 84 | 85 | li { 86 | max-width: $paragraph-max-width; 87 | } 88 | 89 | code, kbd, samp, pre { 90 | background-color: map-get($colors, 'inline-code'); 91 | padding: 0.1rem 0.25rem; 92 | font-size: .775rem; 93 | border-radius: .125rem; 94 | padding-left: .25rem; 95 | padding-right: .25rem; 96 | color: map-get($colors, sapphire); 97 | font-family: SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace !important; 98 | } 99 | pre > code { 100 | padding: 0; 101 | } 102 | 103 | code[class*="language-"], pre[class*="language-"] { 104 | border-radius: .5rem; 105 | font-size: .775rem !important; 106 | line-height: 1.5 !important; 107 | } 108 | 109 | blockquote { 110 | display: block; 111 | margin: 0 0 $global-margin; 112 | padding: rem(15 20); 113 | background-color: map-get($colors, 'callout-info-bg'); 114 | border-left: rem(3) solid map-get($colors, 'callout-info-border'); 115 | color: map-get($colors, gray); 116 | a{ 117 | color: map-get($colors, 'link-color'); 118 | } 119 | 120 | > :last-child { 121 | margin-bottom: 0; 122 | } 123 | } 124 | 125 | iframe, object, video { 126 | max-width: 100%; 127 | } 128 | -------------------------------------------------------------------------------- /public/template/src/scss/app.scss: -------------------------------------------------------------------------------- 1 | @charset "UTF-8"; 2 | // http://timkadlec.com/2012/10/ie10-snap-mode-and-responsive-design/ 3 | @-ms-viewport{ 4 | width: device-width; 5 | } 6 | 7 | // import modnern-normalize (https://github.com/sindresorhus/modern-normalize) 8 | @import 'node_modules/modern-normalize/modern-normalize'; 9 | 10 | // import basic utils 11 | @import 'functions'; 12 | @import 'breakpoints'; 13 | @import 'settings'; 14 | 15 | // import base styles 16 | @import 'typography'; 17 | @import 'layout'; 18 | @import 'objects'; 19 | 20 | // import components (sorted A -> Z) 21 | @import 'components/breadcrumb'; 22 | @import 'components/callout'; 23 | @import 'components/code'; 24 | @import 'components/contributors'; 25 | @import 'components/footer'; 26 | @import 'components/heading_links'; 27 | @import 'components/nav'; 28 | @import 'components/opencollective'; 29 | @import 'components/optionswitch'; 30 | @import 'components/search'; 31 | @import 'components/searchform'; 32 | @import 'components/table'; 33 | @import 'components/toc'; 34 | 35 | // import helpers and overrides 36 | @import 'helpers'; 37 | @import 'print'; 38 | -------------------------------------------------------------------------------- /public/template/src/scss/components/_breadcrumb.scss: -------------------------------------------------------------------------------- 1 | $breadcrum-item-spacing: rem(5); 2 | 3 | .c-breadcrumb { 4 | margin: 0 0 $global-margin; 5 | 6 | &__list { 7 | display: flex; 8 | flex-direction: row; 9 | flex-wrap: wrap; 10 | margin: 0 $breadcrum-item-spacing*-1; 11 | padding: 0; 12 | list-style: none; 13 | } 14 | 15 | &__item { 16 | display: block; 17 | flex: auto 0 0; 18 | } 19 | 20 | &__link { 21 | display: inline-block; 22 | padding: rem(4) $breadcrum-item-spacing; 23 | line-height: 1.4; 24 | font-size: rem(14); 25 | color: map-get($colors, gray); 26 | } 27 | 28 | &__item + &__item:before { 29 | content: ""; 30 | display: inline-block; 31 | width: rem(10); 32 | height: rem(11); 33 | margin-left: rem(5); 34 | margin-right: rem(-1); 35 | background: svg-url(''); 36 | background-repeat: no-repeat; 37 | background-size: 100% 100%; 38 | background-position: center center; 39 | opacity: 0.25; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /public/template/src/scss/components/_callout.scss: -------------------------------------------------------------------------------- 1 | .c-callout { 2 | color: map-get($colors, gray); 3 | display: block; 4 | margin: 0 0 $global-margin; 5 | padding: rem(15 20); 6 | background-color: map-get($colors, 'callout-info-bg'); 7 | border-left: rem(3) solid map-get($colors, 'callout-info-border'); 8 | a{ 9 | color: map-get($colors, 'link-color'); 10 | } 11 | 12 | &__title { 13 | display: block; 14 | margin: rem(0 0 5); 15 | text-transform: uppercase; 16 | letter-spacing: 1.1; 17 | } 18 | a:hover{ 19 | color: lightendarken(map-get($colors, 'link-color'), 15%); 20 | } 21 | 22 | &--info{ 23 | background-color: map-get($colors, 'callout-info-bg'); 24 | border-color: map-get($colors, 'callout-info-border'); 25 | a{ 26 | color: map-get($colors, 'link-color'); 27 | } 28 | } 29 | 30 | &--warning { 31 | background-color: map-get($colors, 'callout-warning-bg'); 32 | border-color: map-get($colors, 'callout-warning-border'); 33 | a{ 34 | color: #d09c13; 35 | } 36 | } 37 | 38 | &--alert { 39 | background-color: map-get($colors, 'callout-alert-bg'); 40 | border-color: map-get($colors, 'callout-alert-border'); 41 | a{ 42 | color: #d26d69; 43 | } 44 | } 45 | 46 | &--success { 47 | background-color: map-get($colors, 'callout-success-bg'); 48 | border-color: map-get($colors, 'callout-success-border'); 49 | a{ 50 | color:#3e8554; 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /public/template/src/scss/components/_code.scss: -------------------------------------------------------------------------------- 1 | // include prisimjs styles 2 | @import '../../../node_modules/prismjs/themes/prism-tomorrow'; 3 | @import '../../../node_modules/prismjs/plugins/command-line/prism-command-line'; 4 | @import '../../../node_modules/prismjs/plugins/line-highlight/prism-line-highlight'; 5 | @import '../../../node_modules/prismjs/plugins/line-numbers/prism-line-numbers'; 6 | @import '../../../node_modules/prismjs/plugins/toolbar/prism-toolbar'; 7 | 8 | // override background color 9 | // :not(pre)>code[class*="language-"], pre[class*="language-"] { 10 | // background: map-get($colors, 'sidebar-bg'); 11 | // } 12 | 13 | .token.operator { 14 | background: none; 15 | } 16 | -------------------------------------------------------------------------------- /public/template/src/scss/components/_contributors.scss: -------------------------------------------------------------------------------- 1 | .c-history { 2 | display: flex; 3 | align-items: center; 4 | margin: -.9375rem 0 1.875rem; 5 | } 6 | .c-contributors { 7 | display: flex; 8 | } 9 | .c-contributor { 10 | width: 30px; 11 | height: 30px; 12 | margin-left: -10px; 13 | border: 2px solid #fff; 14 | border-radius: 15px; 15 | 16 | &:first-child { 17 | margin-left: 0; 18 | } 19 | } 20 | .c-last-edit { 21 | max-width: 100%; 22 | margin-bottom: 0; 23 | padding-left: 0.5em; 24 | font-size: 0.9em; 25 | font-weight: 500; 26 | color: #555; 27 | flex: 1; // fixes mobile rendering 28 | } 29 | -------------------------------------------------------------------------------- /public/template/src/scss/components/_footer.scss: -------------------------------------------------------------------------------- 1 | .c-footer { 2 | 3 | &__grid { 4 | display: flex; 5 | flex-wrap: wrap; 6 | justify-content: space-between; 7 | align-items: flex-start; 8 | } 9 | 10 | &__cell { 11 | width: 100%; 12 | 13 | @include breakpoint(large) { 14 | width: auto; 15 | } 16 | } 17 | 18 | &__copyright { 19 | color: map-get($colors, gray); 20 | } 21 | 22 | &__nav { 23 | margin: rem(0 -10); 24 | padding: 0; 25 | list-style: none; 26 | } 27 | 28 | &__navitem { 29 | display: inline-block; 30 | } 31 | 32 | &__navlink { 33 | display: block; 34 | font-weight: 700; 35 | padding: rem(10); 36 | color: map-get($colors, gray); 37 | 38 | &:hover { 39 | color: map-get($colors, primary); 40 | } 41 | } 42 | } -------------------------------------------------------------------------------- /public/template/src/scss/components/_heading_links.scss: -------------------------------------------------------------------------------- 1 | 2 | .heading-link { 3 | display: inline-block; 4 | margin-left: 0.67em; 5 | transition: opacity 0.25s ease-in-out; 6 | opacity: 0; 7 | font-size: 0.8em; 8 | bottom: 0.2em; 9 | } 10 | 11 | h1, h2, h3, h4, h5, h6 { 12 | &:hover .heading-link { 13 | opacity: 1; 14 | } 15 | } 16 | 17 | @include breakpoint(medium up) { 18 | h1, h2, h3, h4, h5, h6 { 19 | position: relative; 20 | margin-left: -3rem; 21 | padding-left: 3rem; 22 | 23 | &:target .heading-link{ 24 | opacity: 1; 25 | } 26 | } 27 | .heading-link { 28 | left: 0; 29 | position: absolute; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /public/template/src/scss/components/_nav.scss: -------------------------------------------------------------------------------- 1 | .c-nav { 2 | margin: rem(0 0 0 -20); 3 | padding: 0; 4 | list-style: none; 5 | 6 | &__item { 7 | margin: 0; 8 | padding: 0; 9 | list-style: none; 10 | font-size: rem(15); 11 | line-height: 1.4; 12 | 13 | a { 14 | position: relative; 15 | display: block; 16 | margin-bottom: rem(10); 17 | padding: rem(0 0 0 20); 18 | text-decoration: none; 19 | font-weight: 500; 20 | color: map-get($colors, slateGray); 21 | transition: all .2s ease; 22 | 23 | &:hover { 24 | color: lightendarken(map-get($colors, 'link-color'), 15%); 25 | transform: translateX(2px); 26 | } 27 | } 28 | 29 | &--activepage { 30 | .c-nav__chevron { 31 | top: 0.35rem; 32 | } 33 | > a { 34 | position: relative; 35 | color: map-get($colors, 'nav-active'); 36 | padding: 0.35rem 0 0.35rem 3.5rem; 37 | transition: all .2s ease; 38 | 39 | &:after, &:before { 40 | border: 0 solid #e2e8f0; 41 | } 42 | 43 | &:hover{ 44 | transform: translateX(0); 45 | } 46 | 47 | .inset{ 48 | top: 0; 49 | right: 0; 50 | bottom: 0; 51 | left: 0; 52 | position: absolute; 53 | border-radius: .25rem; 54 | opacity: .25; 55 | background-color:map-get($colors, 'nav-active-bg'); 56 | } 57 | } 58 | 59 | } 60 | 61 | &--level1 > a { 62 | font-size: rem(16); 63 | font-weight: 700; 64 | padding-left: rem(30); 65 | } 66 | 67 | &--level2 > a { 68 | padding-left: rem(50); 69 | } 70 | 71 | &--level3 > a { 72 | padding-left: rem(60); 73 | } 74 | 75 | &--level4 > a { 76 | padding-left: rem(80); 77 | } 78 | 79 | &--level5 > a { 80 | padding-left: rem(100); 81 | } 82 | 83 | &--level6 > a { 84 | padding-left: rem(120); 85 | } 86 | 87 | &--level7> a { 88 | padding-left: rem(140); 89 | } 90 | 91 | /* 92 | &--has-children > a { 93 | position: relative; 94 | &:before { 95 | content: ""; 96 | display: inline-block; 97 | position: absolute; 98 | top: rem(1); 99 | height: rem(20); 100 | width: rem(20); 101 | margin: rem(0 0 0 -23); 102 | background: svg-url(''); 103 | background-repeat: no-repeat; 104 | background-size: rem(7); 105 | background-position: center center; 106 | opacity: 0.6; 107 | transform: rotate(-90deg); 108 | } 109 | }*/ 110 | 111 | &--collapsed { 112 | > ul { 113 | display: none; 114 | } 115 | > a > .c-nav__chevron { 116 | top: rem(1); 117 | transform: rotate(90deg); 118 | } 119 | } 120 | 121 | } 122 | 123 | &__chevron { 124 | display: inline-block; 125 | position: absolute; 126 | top: 0; 127 | height: rem(21); 128 | width: rem(21); 129 | padding: rem(5); 130 | margin: rem(0 0 0 -23); 131 | fill: rgba(map-get($colors, 'body-text'), 0.6); 132 | transform: rotate(-90deg); 133 | vertical-align: middle; 134 | } 135 | 136 | 137 | &__sublist { 138 | margin: 0 0 $global-margin*2; 139 | padding: 0; 140 | } 141 | } 142 | 143 | ::-webkit-scrollbar, ::-webkit-scrollbar-thumb { 144 | width: 1rem; 145 | height: 1rem; 146 | border: .25rem solid transparent; 147 | border-radius: .5rem; 148 | background-color: transparent; 149 | } 150 | 151 | ::-webkit-scrollbar-thumb { 152 | box-shadow: inset 0 0 0 1rem rgba(85,108,136,.1); 153 | } 154 | 155 | ::-webkit-scrollbar-thumb:hover { 156 | box-shadow: inset 0 0 0 1rem rgba(85,108,136,.2); 157 | } 158 | 159 | ::-webkit-resizer, ::-webkit-scrollbar-corner { 160 | background-color: transparent; 161 | } 162 | -------------------------------------------------------------------------------- /public/template/src/scss/components/_opencollective.scss: -------------------------------------------------------------------------------- 1 | .l-main__oc, .c-oc__more { 2 | display: none; 3 | @include breakpoint(xlarge) { 4 | display: block; 5 | } 6 | } 7 | .l-main__oc--below, .c-oc__more--below { 8 | display: block; 9 | @include breakpoint(xlarge) { 10 | display: none; 11 | } 12 | } 13 | 14 | .l-main__oc { 15 | margin-top: 5em; 16 | 17 | background: map-get($colors, 'sidebar-bg'); 18 | color: map-get($colors, 'sidebar-text'); 19 | padding: 1em; 20 | border-radius: 1em; 21 | border: 1px solid transparent; 22 | transition: background-color .5s ease-in-out; 23 | text-decoration: none !important; 24 | max-width: rem(400); 25 | 26 | @include breakpoint(xlarge) { 27 | margin-left: rem(-25); 28 | } 29 | 30 | .c-oc__member_image { 31 | transition: border .5s ease-in-out; 32 | } 33 | &:hover { 34 | color: map-get($colors, 'sidebar-text'); 35 | background: #e3efff; 36 | 37 | .c-oc__member_image { 38 | border-color: #e3efff; 39 | } 40 | 41 | } 42 | } 43 | 44 | .c-oc__logo { 45 | max-width: 100%; 46 | max-height: 60px; 47 | } 48 | 49 | .c-oc__text { 50 | font-size: rem(13); 51 | font-weight: 500; 52 | } 53 | 54 | .c-oc__subtitle { 55 | font-size: rem(13); 56 | font-weight: 700; 57 | text-transform: uppercase; 58 | } 59 | 60 | .c-oc__members { 61 | padding-left: 0; 62 | list-style: none; 63 | margin-bottom: 25px; 64 | padding-right: 25px; 65 | margin-left: -28px; 66 | margin-right: -35px; 67 | text-align: center; 68 | } 69 | .c-oc__member { 70 | display: inline-block; 71 | margin-right: -20px; 72 | margin-bottom: -15px; 73 | } 74 | 75 | .c-oc__member_image { 76 | width: 60px; 77 | height: 60px; 78 | border-radius: 50%; 79 | border: 5px solid map-get($colors, 'sidebar-bg'); 80 | background: #00B5DE; 81 | } 82 | 83 | .c-oc__total { 84 | 85 | span { 86 | height: 60px; 87 | background: #00B5DE; 88 | border-radius: 30px; 89 | border: 5px solid map-get($colors, 'sidebar-bg'); 90 | color: #fff; 91 | padding: 0.7em 1em; 92 | font-weight: 600; 93 | display: block; 94 | } 95 | } 96 | 97 | .c-oc__more { 98 | text-align: center; 99 | font-size: rem(13); 100 | font-weight: 500; 101 | text-decoration: underline; 102 | margin-top: 1em; 103 | margin-left: -1em; 104 | max-width: rem(400); 105 | } 106 | -------------------------------------------------------------------------------- /public/template/src/scss/components/_optionswitch.scss: -------------------------------------------------------------------------------- 1 | .c-optionswitch { 2 | 3 | &__current { 4 | display: block; 5 | padding: rem(7 10); 6 | color: map-get($colors, 'header-text'); 7 | white-space: nowrap; 8 | background: map-get($colors, 'header-bg'); 9 | transition: background-color .2s ease-in-out; 10 | border-radius: $global-radius; 11 | 12 | &:hover { 13 | color: map-get($colors, 'header-text'); 14 | background: lighten(map-get($colors, 'header-bg'), 5%); 15 | } 16 | } 17 | 18 | &__currentchevron { 19 | display: inline-block; 20 | width: rem(10); 21 | height: rem(10); 22 | fill: map-get($colors, 'header-text'); 23 | vertical-align: middle; 24 | margin: rem(-1 0 0 3); 25 | transform: rotate(90deg); 26 | } 27 | 28 | &__listwrapper { 29 | position: absolute; 30 | top: $header-height - rem(1); 31 | display: none; 32 | background: map-get($colors, 'optionswitch-bg'); 33 | box-shadow: rem(0 3 8 0) rgba(116, 129, 141, 0.1); 34 | 35 | @include breakpoint(xxlarge) { 36 | right: 0; 37 | } 38 | @include breakpoint(1440 down) { 39 | right: 0; 40 | } 41 | @include breakpoint(768 down) { 42 | right: 0; 43 | } 44 | 45 | &:target { 46 | display: block; 47 | 48 | @include breakpoint(medium up) { 49 | display: flex; 50 | } 51 | 52 | &:before { 53 | content: none; 54 | } 55 | } 56 | } 57 | 58 | &__list { 59 | margin: 0; 60 | padding: 0; 61 | list-style: none; 62 | } 63 | 64 | &__item { 65 | position: relative; 66 | display: block; 67 | } 68 | 69 | &__link { 70 | display: block; 71 | padding: rem(4 10); 72 | color: map-get($colors, 'header-text'); 73 | transition-duration: .2s; 74 | transition-timing-function: cubic-bezier(.4,0,.2,1); 75 | transition-property: background-color,border-color,color; 76 | 77 | &:hover { 78 | color: map-get($colors, 'header-text'); 79 | background: lighten(map-get($colors, 'header-bg'), 5%); 80 | } 81 | } 82 | 83 | &__item--active &__link { 84 | background: lighten(map-get($colors, 'header-bg'), 7%); 85 | } 86 | 87 | &__translation { 88 | color: darken(map-get($colors, 'header-text'), 15%); 89 | 90 | &:before { 91 | content: "("; 92 | } 93 | &:after { 94 | content: ")"; 95 | } 96 | } 97 | 98 | &__note { 99 | padding: rem(0 10 10); 100 | color: darken(map-get($colors, 'header-text'), 15%); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /public/template/src/scss/components/_search.scss: -------------------------------------------------------------------------------- 1 | .l-main-search__container { 2 | list-style: none; 3 | padding: 0; 4 | margin-bottom: 2rem; 5 | } 6 | 7 | .l-main-search__result, .l-live-search__result { 8 | border-top: 1px solid map_get($colors, 'header-bg'); 9 | padding-top: 1rem; 10 | margin-top: 1rem; 11 | 12 | .c-breadcrumb { 13 | margin-bottom: 0; 14 | } 15 | 16 | h4 { 17 | margin-top: 0; 18 | margin-bottom: 0; 19 | } 20 | 21 | @include breakpoint(small only) { 22 | &:first-child { 23 | border-top: none; 24 | } 25 | } 26 | } 27 | 28 | .l-main-search__match, .l-live-search__match { 29 | summary { 30 | float: right; 31 | color: #777; 32 | } 33 | } 34 | 35 | .l-search__pagination-holder { 36 | list-style: none; 37 | padding: 0; 38 | text-align: center; 39 | } 40 | .l-search-page { 41 | display: inline-block; 42 | } 43 | .l-search-page__link { 44 | display: block; 45 | cursor: pointer; 46 | padding: .5rem 1rem; 47 | line-height: initial; 48 | background: #e2e8f0; 49 | color: map-get($colors, mirage); 50 | border-radius: 0.25rem; 51 | border: 1px solid #e2e8f0; 52 | 53 | &:hover, &:focus{ 54 | background: map_get($colors, slateGray); 55 | border: 1px solid map_get($colors, #708090); 56 | color: map_get($colors, light); 57 | } 58 | 59 | &--active { 60 | background: map_get($colors, sapphire); 61 | border: 1px solid map_get($colors, sapphire); 62 | color: map_get($colors, light); 63 | } 64 | } 65 | 66 | .c-searchform--resultspage { 67 | margin-bottom: 1rem; 68 | .c-searchform__input { 69 | border: 1px solid map_get($colors, 'header-bg'); 70 | } 71 | } 72 | .l-search__meta { 73 | color: map_get($colors, gray); 74 | } 75 | 76 | 77 | $ignoredColor: map_get($colors, warning); 78 | .l-search__ignored { 79 | border-left: rem(8) solid $ignoredColor; 80 | padding: rem(15 20); 81 | background-color: rgba($ignoredColor, 0.3); 82 | color: darken($ignoredColor, 50%); 83 | } 84 | $tipColor: map_get($colors, primary); 85 | .l-search__tip { 86 | border-left: rem(8) solid $tipColor; 87 | padding: rem(15 20); 88 | background-color: rgba($tipColor, 0.3); 89 | color: darken($tipColor, 50%); 90 | } 91 | 92 | $errorColor: map_get($colors, alert); 93 | .l-search__no_results { 94 | border-left: rem(4) solid $errorColor; 95 | padding: rem(15 20); 96 | background-color: rgba($errorColor, 0.3); 97 | color: darken($errorColor, 50%); 98 | max-width: 100%; 99 | 100 | &--live { 101 | margin: 0; 102 | } 103 | } 104 | 105 | .c-searchform--header { 106 | z-index: 5; 107 | } 108 | .l-live-search__container { 109 | position: absolute; 110 | background: #fff; 111 | width: 100%; 112 | display: none; 113 | border-radius: 0 0 5px 5px; 114 | margin-top: -3px; 115 | z-index: 4; 116 | box-shadow: 0 2px 7px 2px rgba(0,0,0,0.5); 117 | 118 | &--visible { 119 | display: block; 120 | } 121 | 122 | &--loading { 123 | &::before { 124 | content: "Loading..."; 125 | width: 100%; 126 | display: block; 127 | padding: 0.5em 0.5em 0.5em 1em; 128 | border-radius: 5px 5px 0 0; 129 | color: map_get($colors, 'header-text'); 130 | background: map_get($colors, 'header-bg'); 131 | 132 | } 133 | &:empty::before { 134 | border-radius: 5px; 135 | } 136 | } 137 | 138 | @include breakpoint(small only) { 139 | position: initial; 140 | margin-top: 1em; 141 | border-radius: 7px; 142 | box-shadow: none; 143 | border: 1px solid #3b68af; 144 | } 145 | } 146 | .l-live-search__results { 147 | list-style: none; 148 | padding: 0; 149 | margin: 0; 150 | max-height: 70vh; 151 | overflow-y: scroll; 152 | 153 | @include breakpoint(small only) { 154 | max-height: calc(100vh - 15rem); 155 | } 156 | } 157 | .l-live-search__result, .l-main-search__result { 158 | padding: 0; 159 | margin: 0; 160 | position: relative; 161 | } 162 | .c-live-search__result-crumbs, .l-main-search__crumbs { 163 | position: absolute; 164 | top: 0.7rem; 165 | white-space: nowrap; 166 | text-overflow: ellipsis; 167 | padding: 0 1rem; 168 | height: 1.6em; // = line-height 169 | overflow: hidden; 170 | font-weight: 500; 171 | } 172 | .c-live-search__result-link, .l-main-search__link { 173 | color: map-get($colors, sapphire); 174 | display: block; 175 | padding: 2.2rem 1rem 0.7rem; 176 | font-weight: 500; 177 | &:hover, &:focus, .l-live-search__result--selected & { 178 | background: #f0f6ff; 179 | } 180 | } 181 | .l-live-search__search { 182 | margin: 0; 183 | a { 184 | font-weight: 500; 185 | display: block; 186 | padding: 0.7rem 1rem; 187 | width: 100%; 188 | color: map_get($colors, 'header-text'); 189 | background: map_get($colors, 'header-bg'); 190 | border-radius: 0 0 5px 5px; 191 | } 192 | } 193 | .l-main-search__result { 194 | max-width: $paragraph-max-width; 195 | } 196 | .l-main-search__title { 197 | color: map-get($colors, sapphire); 198 | } 199 | .l-main-search__link { 200 | text-decoration: none !important; 201 | &:hover, &:focus { 202 | .l-main-search__title { 203 | color: lightendarken(map-get($colors, 'link-color'), 15%); 204 | } 205 | } 206 | } 207 | .l-main-search__snippet { 208 | color: map-get($colors, 'body-text'); 209 | } 210 | .size_code{ 211 | font-size: 1em; 212 | } 213 | -------------------------------------------------------------------------------- /public/template/src/scss/components/_searchform.scss: -------------------------------------------------------------------------------- 1 | .c-searchform { 2 | position: relative; 3 | width: 100%; 4 | 5 | @include breakpoint(small only) { 6 | margin-top: 1rem; 7 | } 8 | 9 | &__input { 10 | display: block; 11 | width: 100%; 12 | border: none; 13 | border-radius: .5rem; 14 | padding: rem(10 35 10 20); 15 | line-height: 1.5!important; 16 | font-family: inherit; 17 | font-size: 100%; 18 | transition: all .1s ease-in; 19 | background-color: #edf2f7; 20 | 21 | &:focus{ 22 | border-color: #e2e8f0; 23 | background-color: #fff; 24 | } 25 | 26 | @include breakpoint(small only) { 27 | border: 1px solid map_get($colors, 'header-bg'); 28 | } 29 | } 30 | 31 | &__button { 32 | position: absolute; 33 | top: rem(0); 34 | right: rem(10); 35 | bottom: rem(0); 36 | margin: 0; 37 | padding: 0; 38 | background: transparent; 39 | border: none; 40 | } 41 | 42 | &__icon { 43 | width: rem(24); 44 | height: rem(20); 45 | fill: #717171; 46 | vertical-align: middle; 47 | } 48 | } 49 | 50 | // Undo helpers.scss:14 for the searchform target; without this there's an unclickable area before the search. 51 | #searchform:target:before { 52 | content: ""; 53 | height: 0; 54 | margin: 0; 55 | } 56 | -------------------------------------------------------------------------------- /public/template/src/scss/components/_table.scss: -------------------------------------------------------------------------------- 1 | table { 2 | display: block; 3 | width: fit-content; 4 | //min-width: 100%; 5 | max-width: 100%; 6 | max-width: calc(100% + #{rem(20)}); 7 | margin: $global-margin*2 0; 8 | border-collapse: collapse; 9 | overflow-x: auto; 10 | 11 | @include breakpoint(medium) { 12 | display: table; 13 | table-layout:fixed; 14 | width: 100%; 15 | max-width: 100%; 16 | 17 | td { 18 | word-wrap: break-word; 19 | } 20 | } 21 | 22 | tr:nth-child(2n) { 23 | background: map-get($colors, 'sidebar-bg'); 24 | } 25 | 26 | td, th { 27 | border: 1px solid lightendarken(map-get($colors, 'border'), 10%); 28 | padding: rem(9 16); 29 | } 30 | 31 | th { 32 | font-weight: 700; 33 | text-align: center; 34 | } 35 | 36 | td { 37 | min-width: rem(170); 38 | 39 | @include breakpoint(medium) { 40 | min-width: 0; 41 | } 42 | } 43 | 44 | &::-webkit-scrollbar, &::-webkit-scrollbar-thumb { 45 | width: 1rem; 46 | height: 1rem; 47 | border: .25rem solid transparent; 48 | border-radius: .5rem; 49 | background-color: transparent; 50 | } 51 | 52 | &::-webkit-scrollbar-thumb { 53 | box-shadow: inset 0 0 0 1rem rgba(85,108,136,.1); 54 | } 55 | 56 | &::-webkit-scrollbar-thumb:hover { 57 | box-shadow: inset 0 0 0 1rem rgba(85,108,136,.2); 58 | } 59 | 60 | &::-webkit-resizer, &::-webkit-scrollbar-corner { 61 | background-color: transparent; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /public/template/src/scss/components/_toc.scss: -------------------------------------------------------------------------------- 1 | .c-toc { 2 | position: relative; 3 | margin: 0 0 $global-margin*2; 4 | /* 5 | &__wrapper { 6 | @include breakpoint(xlarge) { 7 | position: absolute; 8 | top: rem(15); 9 | left: 100%; 10 | left: calc(100% + #{rem(50)}); 11 | width: rem(250); 12 | } 13 | } 14 | */ 15 | &__title { 16 | display: block; 17 | margin: 0 0 rem(5); 18 | font-size: rem(12); 19 | font-weight: 700; 20 | color: map-get($colors, cadetBlue); 21 | text-transform: uppercase; 22 | } 23 | 24 | ul { 25 | margin: 0; 26 | padding: 0; 27 | list-style: none; 28 | } 29 | 30 | &__wrapper > ul { 31 | //padding-left: rem(15); 32 | } 33 | 34 | &__wrapper > ul > li { 35 | margin: 0 0 rem(7); 36 | 37 | &:last-child { 38 | margin-bottom: 0; 39 | } 40 | 41 | > a { 42 | font-weight: 700; 43 | } 44 | } 45 | 46 | a { 47 | display: block; 48 | margin: rem(0 0 3); 49 | font-size: rem(13); 50 | font-weight: 500; 51 | color: map-get($colors, slateGray); 52 | line-height: 1.4; 53 | transition: all .2s ease; 54 | 55 | &:hover { 56 | color: map-get($colors, mirage); 57 | transform: translateX(2px); 58 | } 59 | } 60 | 61 | li li { 62 | padding-left: rem(15); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /public/template/src/svg/chevron.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/template/src/svg/close.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /public/template/src/svg/externallink.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /public/template/src/svg/menu.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/template/src/svg/search.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /public/template/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const isModern = process.env.BROWSERSLIST_ENV === 'modern' 4 | 5 | let mainEntry = [ 6 | isModern ? './src/js/polyfills.modern.js' : './src/js/polyfills.legacy.js', 7 | './src/js/main.js', 8 | ]; 9 | // add modern polyfills also to legacy main.js 10 | if (!isModern) mainEntry.push('./src/js/polyfills.modern.js'); 11 | 12 | module.exports = { 13 | entry: { 14 | main: mainEntry, 15 | //head: './src/js/head.js' 16 | }, 17 | 18 | output: { 19 | path: path.join(__dirname, 'dist'), 20 | filename: '[name]-' + (isModern ? 'modern' : 'legacy') + '.js' 21 | }, 22 | 23 | module: { 24 | rules: [ 25 | { // configure babel 26 | test: /\.js$/, 27 | include: /\/(src)\//, 28 | use: { 29 | loader: 'babel-loader', 30 | options: { 31 | presets: [ 32 | ['@babel/preset-env', { 33 | debug: false, 34 | corejs: '2', 35 | useBuiltIns: 'usage', 36 | }], 37 | ], 38 | plugins: [ 39 | ['prismjs', { 40 | languages: ['markup', 'css', 'clike', 'javascript', 'bash', 'css-extras', 'markup-templating', 'json', 'markdown', 'php', 'php-extras', 'sql', 'smarty'], 41 | plugins: ['line-highlight', 'line-numbers', 'toolbar', 'command-line', 'normalize-whitespace', 'copy-to-clipboard'], 42 | css: false 43 | }], 44 | ] 45 | } 46 | } 47 | } 48 | ] 49 | }, 50 | 51 | plugins: [ 52 | new webpack.SourceMapDevToolPlugin({ 53 | test: [/\.js$/], 54 | filename: '[name]-' + (isModern ? 'modern' : 'legacy') + '.js.map', 55 | append: '//# sourceMappingURL=[url]', 56 | }) 57 | ] 58 | }; 59 | -------------------------------------------------------------------------------- /public/update-cron.php: -------------------------------------------------------------------------------- 1 | $body, 'signature' => $signature], true)); 17 | echo "Invalid signature. Are you sure you're GitHub?\n"; 18 | @session_write_close(); 19 | exit(); 20 | } 21 | 22 | 23 | if (!file_exists(dirname(__DIR__) . '/.update-sources')) { 24 | file_put_contents(dirname(__DIR__) . '/.update-sources', '1'); 25 | } 26 | 27 | http_response_code(200); 28 | echo 'Update scheduled.'; 29 | -------------------------------------------------------------------------------- /public/update.php: -------------------------------------------------------------------------------- 1 | $body, 'signature' => $signature], true)); 21 | echo "Invalid signature. Are you sure you're GitHub?\n"; 22 | @session_write_close(); 23 | exit(); 24 | } 25 | 26 | $docsApp = new DocsApp($settingsParser->getSlimConfig()); 27 | $cliApp = new Application($docsApp); 28 | $cliApp->setAutoExit(false); 29 | 30 | $input = new ArrayInput([ 31 | 'command' => 'sources:update', 32 | ]); 33 | 34 | // You can use NullOutput() if you don't need the output 35 | $output = new BufferedOutput(); 36 | $cliApp->run($input, $output); 37 | $content = $output->fetch(); 38 | 39 | // Write output to a log file. 40 | file_put_contents(dirname(__DIR__) . '/logs/' . date('Ymd-his') . '_pull.log', $content); 41 | 42 | //echo $content; 43 | 44 | http_response_code(200); 45 | echo 'Done.'; 46 | -------------------------------------------------------------------------------- /sources.dist.json: -------------------------------------------------------------------------------- 1 | { 2 | "2.x": { 3 | "type": "git", 4 | "url": "https://github.com/modxorg/Docs.git", 5 | "branch": "2.x" 6 | }, 7 | "3.x": { 8 | "type": "git", 9 | "url": "https://github.com/modxorg/Docs.git", 10 | "branch": "3.x" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /sources.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "$id": "https://github.com/modxorg/DocsApp/blob/master/sources.schema.json", 4 | "title": "Sources File", 5 | "description": "Description of version branches.", 6 | "type": "object", 7 | "properties": { 8 | }, 9 | "patternProperties": { 10 | "^(\\d.x)$": { "$ref": "#/definitions/source"} 11 | }, 12 | "additionalProperties": false, 13 | 14 | "definitions": { 15 | "source": { 16 | "type": "object", 17 | "additionalProperties": false, 18 | "required": ["type"], 19 | "properties": { 20 | "type": { 21 | "enum": ["git", "local"] 22 | }, 23 | "url": { 24 | "type": "string" 25 | }, 26 | "branch": { 27 | "type": "string" 28 | } 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/CLI/Application.php: -------------------------------------------------------------------------------- 1 | app = $docsApp; 26 | $this->container = $docsApp->getContainer(); 27 | } 28 | 29 | /** 30 | * @return DocsApp 31 | */ 32 | public function getDocsApp(): DocsApp 33 | { 34 | return $this->app; 35 | } 36 | 37 | /** 38 | * @return \Psr\Container\ContainerInterface 39 | */ 40 | public function getContainer(): \Psr\Container\ContainerInterface 41 | { 42 | return $this->container; 43 | } 44 | 45 | protected function getDefaultCommands() 46 | { 47 | $cmds = parent::getDefaultCommands(); 48 | $cmds[] = new SourcesInit(); 49 | $cmds[] = new SourcesUpdate(); 50 | $cmds[] = new CacheRefresh(); 51 | $cmds[] = new CacheNavigation(); 52 | $cmds[] = new Init(); 53 | $cmds[] = new File(); 54 | $cmds[] = new Translations(); 55 | $cmds[] = new All(); 56 | $cmds[] = new ScrapeImages(); 57 | return $cmds; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/CLI/Commands/CacheNavigation.php: -------------------------------------------------------------------------------- 1 | writeln('Regenerating navigation cache...'); 18 | 19 | $versions = array_keys(VersionsService::getAvailableVersions()); 20 | $languages = ['en', 'ru', 'nl', 'es']; 21 | foreach ($versions as $version) { 22 | foreach ($languages as $language) { 23 | $output->writeln('- ' . $version. '/' . $language . ''); 24 | Tree::get($version, $language, true); 25 | } 26 | } 27 | 28 | return 0; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/CLI/Commands/CacheRefresh.php: -------------------------------------------------------------------------------- 1 | writeln('Emptying caches...'); 19 | 20 | $directories = [ 21 | $root . 'rendered/', 22 | $root . 'twig/', 23 | $root . 'stats/', 24 | ]; 25 | foreach ($directories as $directory) { 26 | if (file_exists($directory) && is_dir($directory)) { 27 | $output->writeln('- Removing: ' . $directory); 28 | $rm = new Process(['rm', '-r', $directory]); 29 | $rm->setWorkingDirectory($root); 30 | if ($output->isVerbose()) { 31 | $output->writeln('' . $rm->getCommandLine() . ''); 32 | } 33 | 34 | $rm->run(function ($type, $buffer) use ($output) { 35 | $output->writeln('' . $buffer); 36 | }); 37 | } 38 | else { 39 | $output->writeln('- Already empty: ' . $directory); 40 | } 41 | } 42 | 43 | $command = $this->getApplication()->find('cache:navigation'); 44 | $command->run(new ArrayInput([ 45 | 'command' => 'cache:navigation', 46 | ]), $output); 47 | 48 | return 0; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/CLI/Commands/Index/All.php: -------------------------------------------------------------------------------- 1 | getApplication(); 35 | if (!$app instanceof Application) { 36 | $output->writeln('Command not loaded on right Application'); 37 | return 1; 38 | } 39 | $docsApp = $app->getDocsApp(); 40 | if (!$docsApp) { 41 | $output->writeln('DocsApp not available'); 42 | return 1; 43 | } 44 | $container = $docsApp->getContainer(); 45 | 46 | $time = microtime(true); 47 | 48 | /** @var \PDO $db */ 49 | $db = $container->get('db'); 50 | 51 | $this->docService = $container->get(DocumentService::class); 52 | $this->searchService = $container->get(SearchService::class); 53 | $this->indexService = $container->get(IndexService::class); 54 | 55 | $search = $input->getOption('skip-search') ? false : true; 56 | $history = $input->getOption('skip-history') ? false : true; 57 | $this->indexService->setIndexOptions($search, $history); 58 | if (!$search) { 59 | $output->writeln('- Will not index search terms.'); 60 | } 61 | else { 62 | $db->exec('DELETE FROM Search_Terms'); 63 | $db->exec('DELETE FROM Search_Pages'); 64 | $db->exec('DELETE FROM Search_Terms_Occurrences'); 65 | } 66 | if (!$history) { 67 | $output->writeln('- Will not index history/contributors.'); 68 | } 69 | 70 | // Wipe the current index 71 | 72 | $versions = array_keys(VersionsService::getAvailableVersions(false)); 73 | $languages = ['en', 'ru', 'nl', 'es']; 74 | 75 | $count = 0; 76 | foreach ($versions as $version) { 77 | foreach ($languages as $language) { 78 | $nav = Tree::get($version, $language); 79 | foreach ($nav->getAllItems() as $item) { 80 | $count++; 81 | $output->writeln('Indexing ' . $item['file'] . '...'); 82 | 83 | $result = $this->indexService->indexFile($language, $version, $item['uri']); 84 | if ($result !== true) { 85 | $output->writeln('- Error: ' . $result . ''); 86 | } 87 | } 88 | } 89 | } 90 | 91 | $took = microtime(true) - $time; 92 | $output->writeln('Done! Indexed ' . $count . ' files across ' . count($versions) . ' versions and ' . count($languages) . ' in ' . $took . 'ms.'); 93 | return 0; 94 | } 95 | 96 | protected function configure() 97 | { 98 | $this 99 | ->addOption('skip-search', null, InputOption::VALUE_NONE, 'Specify this option to skip indexing this page for the search functionality.') 100 | ->addOption('skip-history', null, InputOption::VALUE_NONE, 'Specify this option to skip indexing the history of this page to render contributors.') 101 | ; 102 | 103 | $this->setAliases(['index:search']); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/CLI/Commands/Index/File.php: -------------------------------------------------------------------------------- 1 | getApplication(); 32 | if (!$app instanceof Application) { 33 | $output->writeln('Command not loaded on right Application'); 34 | return 1; 35 | } 36 | $docsApp = $app->getDocsApp(); 37 | if (!$docsApp) { 38 | $output->writeln('DocsApp not available'); 39 | return 1; 40 | } 41 | $container = $docsApp->getContainer(); 42 | 43 | $this->docService = $container->get(DocumentService::class); 44 | $this->searchService = $container->get(SearchService::class); 45 | $this->indexService = $container->get(IndexService::class); 46 | 47 | $files = $input->getArgument('files'); 48 | if (!is_array($files) || count($files) === 0) { 49 | throw new \InvalidArgumentException('You must provide at least one filename to index.'); 50 | } 51 | 52 | foreach ($files as $file) { 53 | [$version, $language] = explode('/', trim($file, '/')); 54 | $output->writeln('- Updating index for ' . $file); 55 | $result = $this->indexService->indexFile($language, $version, $file); 56 | if ($result !== true) { 57 | $output->writeln('- Error indexing ' . $file . ': ' . $result . ''); 58 | } 59 | } 60 | 61 | return 0; 62 | } 63 | 64 | protected function configure() 65 | { 66 | $this 67 | ->addArgument('files', InputArgument::IS_ARRAY, 'File names, relative to the documentation root, that need to be indexed.') 68 | ; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/CLI/Commands/Index/Translations.php: -------------------------------------------------------------------------------- 1 | getApplication(); 18 | if (!$app instanceof Application) { 19 | $output->writeln('Command not loaded on right Application'); 20 | return 1; 21 | } 22 | $docsApp = $app->getDocsApp(); 23 | if (!$docsApp) { 24 | $output->writeln('DocsApp not available'); 25 | return 1; 26 | } 27 | $container = $docsApp->getContainer(); 28 | 29 | /** @var \PDO $db */ 30 | $db = $container->get('db'); 31 | 32 | // Wipe the current index 33 | $db->exec('DELETE FROM Translations'); 34 | 35 | foreach (array_keys(VersionsService::getAvailableVersions(false)) as $version) { 36 | $output->writeln('Indexing translations for ' . $version . '..'); 37 | $this->indexVersion($output, $db, $version); 38 | } 39 | 40 | return 0; 41 | } 42 | 43 | /** 44 | * @param OutputInterface $output 45 | * @param $db 46 | */ 47 | protected function indexVersion(OutputInterface $output, \PDO $db, $version): void 48 | { 49 | $navEn = Tree::get($version, 'en'); 50 | $mapEn = []; 51 | $items = $navEn->getAllItems(); 52 | foreach ($items as $item) { 53 | $mapEn[$item['uri']] = []; 54 | } 55 | 56 | $languages = ['ru', 'nl', 'es']; 57 | 58 | foreach ($languages as $language) { 59 | $output->writeln('- Processing ' . $version . '/' . $language . '...'); 60 | $languageNav = Tree::get($version, $language); 61 | $languageItems = $languageNav->getAllItems(); 62 | 63 | foreach ($languageItems as $item) { 64 | $translationOf = array_key_exists('translation', 65 | $item) ? $item['translation'] : str_replace('/' . $language . '/', '/en/', $item['uri']); 66 | if (strpos($translationOf, '/' . $version . '/en/') !== 0) { 67 | $translationOf = '/' . $version . '/en/' . trim($translationOf, '/'); 68 | } 69 | if (array_key_exists($translationOf, $mapEn)) { 70 | $mapEn[$translationOf][$language] = $item['uri']; 71 | } else { 72 | $output->writeln('Original path "' . $translationOf . '" not found for "' . $item['file'] . '"'); 73 | } 74 | } 75 | } 76 | 77 | $mapEn = array_filter($mapEn, function ($item) { 78 | return count($item) > 0; 79 | }); 80 | 81 | $fetch = 'SELECT * FROM Translations WHERE en = :source'; 82 | $fetchStmt = $db->prepare($fetch); 83 | $fetchStmt->bindParam(':source', $source); 84 | 85 | $insert = 'INSERT INTO Translations (en, ru, nl, es) VALUES (:en, :ru, :nl, :es)'; 86 | $insertStmt = $db->prepare($insert); 87 | foreach ($mapEn as $source => $translations) { 88 | $insertStmt->bindValue(':en', $source); 89 | $insertStmt->bindValue(':ru', $translations['ru'] ?? ''); 90 | $insertStmt->bindValue(':nl', $translations['nl'] ?? ''); 91 | $insertStmt->bindValue(':es', $translations['es'] ?? ''); 92 | 93 | $insertStmt->execute(); 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/CLI/Commands/ScrapeImages.php: -------------------------------------------------------------------------------- 1 | getApplication(); 24 | if (!$app instanceof Application) { 25 | $output->writeln('Command not loaded on right Application'); 26 | return 1; 27 | } 28 | $docsApp = $app->getDocsApp(); 29 | if (!$docsApp) { 30 | $output->writeln('DocsApp not available'); 31 | return 1; 32 | } 33 | $container = $docsApp->getContainer(); 34 | /** @var DocumentService $docs */ 35 | $docs = $container->get(DocumentService::class); 36 | 37 | $root = VersionsService::getDocsRoot(); 38 | $downloadedRoot = '/Users/mhamstra/Sites/docs.modx.local/download/'; 39 | $targetRoot = $downloadedRoot . '/c/'; 40 | 41 | $tree = Tree::get('2.x', 'en'); 42 | $images = []; 43 | foreach ($tree->getAllItems() as $item) { 44 | 45 | $itemFile = $root . $item['file']; 46 | 47 | $contents = file_get_contents($itemFile); 48 | $parsedContents = YamlFrontMatter::parseFile($itemFile); 49 | $body = $parsedContents->body(); 50 | if (empty($body)) { 51 | $output->writeln($item['file'] . ' is empty'); 52 | } 53 | 54 | $pattern = "/\!\[([^]]+)?\]\((?P.+?)\)/"; 55 | preg_match_all($pattern, $body, $matches); 56 | 57 | if (count($matches[0]) === 0) { 58 | continue; 59 | } 60 | 61 | $files = []; 62 | foreach ($matches[0] as $idx => $match) { 63 | $url = $rawUrl = $matches['url'][$idx]; 64 | if (strpos($url, '?') !== false) { 65 | $url = substr($url, 0, strpos($url, '?')); 66 | } 67 | $files[] = [ 68 | 'match' => $match, 69 | 'url' => $url, 70 | 'raw_url' => $rawUrl, 71 | ]; 72 | } 73 | 74 | $output->writeln('Parsing files in ' . $itemFile . '...'); 75 | 76 | $changed = false; 77 | foreach ($files as &$file) { 78 | $oldUrl = $file['url']; 79 | if (strpos(ltrim($oldUrl, '/'), 'download/') === 0) { 80 | // Make sure we use full-size images 81 | $oldUrl = '/' . ltrim($oldUrl, '/'); 82 | $oldUrl = str_replace('/thumbnails/', '/attachments/', $oldUrl); 83 | $oldPath = $downloadedRoot . substr($oldUrl, strlen('/download/')); 84 | 85 | if (!file_exists($oldPath)) { 86 | $output->writeln('- $oldPath ' . $oldPath . ' does not exist'); 87 | } 88 | 89 | 90 | $targetUrl = strtolower(ltrim(basename($file['url']), '/')); 91 | $targetUrl = str_replace([' ', '%20'], '-', $targetUrl); 92 | if (strpos($itemFile, 'index.md') !== false) { 93 | $targetPath = $root . dirname($item['file']) . '/' . $targetUrl; 94 | } 95 | else { 96 | $targetPath = $root . dirname($item['file']) . '/' . $targetUrl; 97 | } 98 | 99 | $output->writeln('- ' . $file['url'] . ' => ' . $targetPath . ' [' . $targetUrl . ']'); 100 | 101 | if (!mkdir($concurrentDirectory = dirname($targetPath), 0777, 102 | true) && !is_dir($concurrentDirectory)) { 103 | throw new \RuntimeException(sprintf('Directory "%s" was not created', 104 | $concurrentDirectory)); 105 | } 106 | if (!copy($oldPath, $targetPath)) { 107 | $output->writeln('- ERROR copying ' . $oldPath . ' => ' . $targetPath . ''); 108 | } 109 | else { 110 | $contents = str_replace($file['raw_url'], $targetUrl, $contents); 111 | $changed = true; 112 | } 113 | } 114 | else { 115 | $output->writeln('- $oldUrl ' . $oldUrl . ' not in /download/'); 116 | } 117 | } 118 | unset($file); 119 | if ($changed) { 120 | file_put_contents($itemFile, $contents); 121 | } 122 | 123 | 124 | 125 | 126 | } 127 | 128 | return 0; 129 | } 130 | 131 | 132 | private function getCommitHash($path) { 133 | $process = new Process(['git', 'rev-parse', 'HEAD']); 134 | $process->setWorkingDirectory($path); 135 | $process->run(); 136 | $out = $process->getOutput(); 137 | return trim($out); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/CLI/Commands/SourcesInit.php: -------------------------------------------------------------------------------- 1 | getApplication(); 19 | if (!$app instanceof Application) { 20 | $output->writeln('Command not loaded on right Application'); 21 | return 1; 22 | } 23 | $docsApp = $app->getDocsApp(); 24 | if (!$docsApp) { 25 | $output->writeln('DocsApp not available'); 26 | return 1; 27 | } 28 | $container = $docsApp->getContainer(); 29 | 30 | /** @var VersionsService $versionService */ 31 | $versionService = $container->get(VersionsService::class); 32 | $definition = $versionService::getAvailableVersions(false); 33 | 34 | if (empty($definition)) { 35 | $output->writeln('Doc sources definition is missing or invalid JSON'); 36 | return 1; 37 | } 38 | 39 | $output->write('Found ' . count($definition) . ' documentation sources: ' . implode(', ', array_keys($definition)) . ''); 40 | 41 | foreach ($definition as $key => $info) { 42 | $output->writeln("\nInitialising \"{$key}\" ({$info['type']})"); 43 | switch ($info['type']) { 44 | case 'git': 45 | $this->initRepository($output, $key, $info['url'], $info['branch']); 46 | break; 47 | 48 | case 'local': 49 | $root = VersionsService::getDocsRoot(); 50 | if (file_exists($root . $key) && is_dir($root . $key)) { 51 | $output->writeln('Source ' . $key . ' is of type local, and the directory exists.'); 52 | } 53 | else { 54 | $output->writeln('Source ' . $key . ' is of type local, so you have to initialise it manually.'); 55 | } 56 | break; 57 | 58 | default: 59 | $output->writeln('Unsupported type "' . $info['type'] . '"'); 60 | break; 61 | } 62 | } 63 | 64 | $command = $this->getApplication()->find('cache:navigation'); 65 | $command->run(new ArrayInput([ 66 | 'command' => 'cache:navigation', 67 | ]), $output); 68 | 69 | // Index translations 70 | $command = $this->getApplication()->find('index:init'); 71 | $command->run(new ArrayInput([ 72 | 'command' => 'index:init', 73 | ]), $output); 74 | return 0; 75 | } 76 | 77 | public function initRepository(OutputInterface $output, $version, $url, $branch) 78 | { 79 | $root = VersionsService::getDocsRoot(); 80 | $fullPath = $root . $version . '/'; 81 | 82 | if (file_exists($fullPath) && is_dir($fullPath) && is_dir($fullPath . '.git/')) { 83 | $output->writeln('Already a git repository: ' . $fullPath . ''); 84 | } 85 | else { 86 | $output->writeln('Cloning ' . $url . ' on branch ' . $branch . ' into docs directory ' . $version . '...'); 87 | $clone = new Process(['git', 'clone', '-b', $branch, '--single-branch', $url, $version]); 88 | $clone->setWorkingDirectory($root); 89 | if ($output->isVerbose()) { 90 | $output->writeln('' . $clone->getCommandLine() . ''); 91 | } 92 | 93 | $clone->run(function ($type, $buffer) use ($output) { 94 | $output->writeln('' . $buffer); 95 | }); 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/Containers/DB.php: -------------------------------------------------------------------------------- 1 | setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); 15 | $db->setAttribute(\PDO::ATTR_TIMEOUT, 10000); 16 | $db->exec('PRAGMA busy_timeout = 15000'); 17 | return $db; 18 | }; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Containers/ErrorHandlers.php: -------------------------------------------------------------------------------- 1 | get($request, $response); 19 | }; 20 | }; 21 | $container['errorHandler'] = $container['phpErrorHandler'] = function ($container) { 22 | return function ($request, $response, $exception) use ($container) { 23 | $pageNotFound = new Error($container, $exception); 24 | 25 | return $pageNotFound->get($request, $response); 26 | }; 27 | }; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Containers/Logger.php: -------------------------------------------------------------------------------- 1 | pushProcessor(new UidProcessor()); 19 | $logger->pushHandler(new StreamHandler( 20 | isset($_ENV['docker']) ? 'php://stdout' : getenv('BASE_DIRECTORY') . '/logs/app.log', 21 | MonologLogger::DEBUG 22 | )); 23 | 24 | return $logger; 25 | }; 26 | } 27 | } -------------------------------------------------------------------------------- /src/Containers/Services.php: -------------------------------------------------------------------------------- 1 | get(FilePathService::class), 25 | $container->get('db') 26 | ); 27 | }; 28 | 29 | $container[VersionsService::class] = function (Container $container) { 30 | return new VersionsService( 31 | $container->get('router') 32 | ); 33 | }; 34 | 35 | $container[TranslationService::class] = function (Container $container) { 36 | return new TranslationService( 37 | $container->get('db'), 38 | $container->get('router') 39 | ); 40 | }; 41 | 42 | $container[SearchService::class] = function (Container $container) { 43 | return new SearchService( 44 | $container->get('db'), 45 | $container->get(DocumentService::class) 46 | ); 47 | }; 48 | $container[IndexService::class] = function (Container $container) { 49 | return new IndexService( 50 | $container->get('db'), 51 | $container->get(DocumentService::class) 52 | ); 53 | }; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Containers/View.php: -------------------------------------------------------------------------------- 1 | get('request'); 20 | $router = $container->get('router'); 21 | 22 | $view = new Twig(getenv('TEMPLATE_DIRECTORY'), [ 23 | 'cache' => getenv('DEV') === '1' ? false : getenv('CACHE_DIRECTORY') . '/twig', 24 | 'debug' => true, 25 | ]); 26 | $view->addExtension(new DebugExtension()); 27 | 28 | 29 | // Instantiate and add Slim specific extension 30 | $basePath = rtrim(str_ireplace(static::BASE_REQUEST_HANDLER, '', $request->getUri()->getBasePath()), '/'); 31 | $view->addExtension(new TwigExtension($router, $basePath)); 32 | $view->addExtension(new DocExtensions($router, $request)); 33 | 34 | return $view; 35 | }; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/DocsApp.php: -------------------------------------------------------------------------------- 1 | app = new App($settings); 28 | 29 | $this->routes(); 30 | $this->dependencies(); 31 | $this->middlewares(); 32 | } 33 | 34 | private function routes() 35 | { 36 | $this->app->get('/', Doc::class . ':get')->setName('home'); 37 | $this->app->get('/stats/searches', Searches::class . ':get')->setName('stats/searches'); 38 | $this->app->get('/stats/page-not-found', NotFoundRequests::class . ':get')->setName('stats/page-not-found'); 39 | $this->app->get('/{version}/{language}/search', Search::class . ':get')->setName('search'); 40 | $this->app->get('/{version}/{language}/{path:.*}', Doc::class . ':get')->setName('documentation'); 41 | } 42 | 43 | private function middlewares() 44 | { 45 | $this->app->add(new RequestMiddleware()); 46 | } 47 | 48 | private function dependencies() 49 | { 50 | $containers = [ 51 | DB::class, 52 | View::class, 53 | ErrorHandlers::class, 54 | Logger::class, 55 | Services::class 56 | ]; 57 | 58 | foreach ($containers as $container) { 59 | call_user_func([$container, 'load'], $this->app->getContainer()); 60 | } 61 | } 62 | 63 | public function getContainer() 64 | { 65 | return $this->app->getContainer(); 66 | } 67 | 68 | public function run() 69 | { 70 | $this->app->run(); 71 | } 72 | 73 | public function process(Request $request, Response $response) 74 | { 75 | return $this->app->process($request, $response); 76 | } 77 | 78 | } 79 | -------------------------------------------------------------------------------- /src/Exceptions/NotFoundException.php: -------------------------------------------------------------------------------- 1 | baseUri = $baseUri; 22 | $this->currentDoc = $currentDoc; 23 | } 24 | 25 | public function render(AbstractInline $inline, ElementRendererInterface $htmlRenderer) 26 | { 27 | if (!($inline instanceof Link)) { 28 | throw new \InvalidArgumentException('Incompatible inline type: ' . \get_class($inline)); 29 | } 30 | 31 | $href = $this->getHref($inline->getUrl()); 32 | $attributes = [ 33 | 'href' => $href, 34 | ]; 35 | 36 | // Handle hashes in links 37 | $hash = ''; 38 | $hashPosition = strpos($href, '#'); 39 | if ($hashPosition !== false) { 40 | $hash = substr($href, $hashPosition); 41 | $href = substr($href, 0, $hashPosition); 42 | } 43 | 44 | 45 | if (isset($inline->attributes['title']) && $inline->attributes['title'] !== '') { 46 | $attributes['title'] = $htmlRenderer->escape($inline->data['title'], true); 47 | } 48 | 49 | if (static::isExternalUrl($inline->getUrl())) { 50 | $attributes['class'] = 'is-externallink'; 51 | $attributes['target'] = '_blank'; 52 | $attributes['rel'] = 'noreferrer noopener'; 53 | } else { 54 | // Check if the link points to somewhere valid 55 | $docs = getenv('DOCS_DIRECTORY'); 56 | $href = static::replaceCurrentUrl($href); 57 | if (!file_exists($docs . $href . '.md') && !file_exists($docs . $href . '/index.md')) { 58 | try { 59 | $newUri = Redirector::findNewURI($href); 60 | $attributes['href'] = $newUri . $hash; 61 | } catch (RedirectNotFoundException $e) { 62 | $attributes['class'] = 'is-brokenlink'; 63 | } 64 | } 65 | } 66 | 67 | return new HtmlElement('a', $attributes, $htmlRenderer->renderInlines($inline->children())); 68 | } 69 | 70 | private function getHref($url) 71 | { 72 | if (static::isExternalUrl($url)) { 73 | return $url; 74 | } 75 | 76 | if (substr($url, -3) === '.md') { 77 | $url = substr($url,0,-3); 78 | } 79 | 80 | if (strpos($url, '#') === 0) { 81 | return $this->baseUri . $this->currentDoc . $url; 82 | } 83 | 84 | $versions = array_keys(VersionsService::getAvailableVersions()); 85 | $temp = ltrim($url, '/'); 86 | foreach ($versions as $key) { 87 | if (strpos($temp, $key) === 0) { 88 | return '/' . $temp; 89 | } 90 | } 91 | 92 | return $this->baseUri . ltrim($url, '/'); 93 | } 94 | 95 | private static function replaceCurrentUrl($href) 96 | { 97 | $href = ltrim($href, '/'); 98 | // If the URL starts with `current/`, then replace it with the actual branch name 99 | if (strpos($href, VersionsService::getCurrentVersion()) !== 0) { 100 | return $href; 101 | } 102 | 103 | return VersionsService::getCurrentVersionBranch() . substr($href, strlen(VersionsService::getCurrentVersion())); 104 | } 105 | 106 | private static function isExternalUrl($url) 107 | { 108 | return preg_match('#^(?:[a-z]+:)?//|^mailto:#', $url); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/Helpers/MarkupFixer.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | class MarkupFixer extends \TOC\MarkupFixer 18 | { 19 | /** 20 | * @var HTML5 21 | */ 22 | private $htmlParser; 23 | 24 | /** 25 | * @var SlugifyInterface 26 | */ 27 | private $sluggifier; 28 | 29 | /** 30 | * Constructor 31 | * 32 | * @param HTML5|null $htmlParser 33 | * @param SlugifyInterface|null $slugify 34 | */ 35 | public function __construct(?HTML5 $htmlParser = null, ?SlugifyInterface $slugify = null) 36 | { 37 | $this->htmlParser = $htmlParser ?? new HTML5(); 38 | $this->sluggifier = $slugify ?? new UniqueSlugify(); 39 | parent::__construct($this->htmlParser, $this->sluggifier); 40 | } 41 | 42 | /** 43 | * Fix markup 44 | * 45 | * @param string $markup 46 | * @param int $topLevel 47 | * @param int $depth 48 | * @return string Markup with added IDs 49 | * @throws RuntimeException 50 | */ 51 | public function fix(string $markup, int $topLevel = 1, int $depth = 6): string 52 | { 53 | if (! $this->isFullHtmlDocument($markup)) { 54 | $partialID = uniqid('toc_generator_'); 55 | $markup = sprintf("%s", $partialID, $markup); 56 | } 57 | 58 | $domDocument = $this->htmlParser->loadHTML($markup); 59 | $domDocument->preserveWhiteSpace = true; // do not clobber whitespace 60 | 61 | // If using the default slugifier, ensure that a unique instance of the class 62 | $slugger = $this->sluggifier instanceof UniqueSlugify ? new UniqueSlugify() : $this->sluggifier; 63 | 64 | /** @var DOMElement $node */ 65 | foreach ($this->traverseHeaderTags($domDocument, $topLevel, $depth) as $node) { 66 | if ($node->getAttribute('id')) { 67 | continue; 68 | } 69 | 70 | $node->setAttribute('id', $slugger->slugify($node->getAttribute('title') ?: $node->textContent)); 71 | $node->setAttribute('title', $node->textContent); 72 | 73 | // Add the heading link 74 | $link = $domDocument->createElement('a'); 75 | $link->setAttribute('href', '#' . $node->getAttribute('id')); 76 | $link->setAttribute('class', 'heading-link'); 77 | $link->setAttribute('data-nosnippet', 'true'); 78 | $link->textContent = '¶'; 79 | $node->appendChild($link); 80 | } 81 | 82 | return $this->htmlParser->saveHTML( 83 | (isset($partialID)) ? $domDocument->getElementById($partialID)->childNodes : $domDocument 84 | ); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Helpers/Redirector.php: -------------------------------------------------------------------------------- 1 | isDir() || $fileinfo->isDot()) { 22 | continue; 23 | } 24 | 25 | // Filename for a directory is actually the directory name without path or quotes 26 | $key = $fileinfo->getFilename(); 27 | 28 | // Check if the URI starts with the key; if it does, treat it as a relative redirect, setting the preferred 29 | // version accordingly, and removing the version from the uri we're looking for 30 | if (substr($uri, 1, \strlen($key)) === $key) { 31 | $preferredVersion = $key; 32 | $preferredVersionUrl = $preferredVersion === VersionsService::getCurrentVersionBranch() 33 | ? VersionsService::getCurrentVersion() 34 | : $key; 35 | $uri = substr($uri, 1 + \strlen($preferredVersion)); 36 | } 37 | 38 | // Get the redirects for this version, and store it in an array 39 | $file = $fileinfo->getPathname() . '/redirects.json'; 40 | if (file_exists($file) && is_readable($file)) { 41 | $versionRedirects = json_decode(file_get_contents($file), true); 42 | if (\is_array($versionRedirects)) { 43 | $redirects[$key] = $versionRedirects; 44 | } 45 | } 46 | } 47 | 48 | $baseDir = '/'; 49 | 50 | // The uri may be stored in different formats in the redirects file, so we account for some different options 51 | $decodedUri = urldecode($uri); 52 | $possibilities = [ 53 | $uri, 54 | rtrim($uri, '/') . '/', 55 | rtrim($uri, '/'), 56 | $decodedUri, 57 | rtrim($decodedUri, '/') . '/', 58 | rtrim($decodedUri, '/'), 59 | ]; 60 | $possibilities = array_unique($possibilities); 61 | 62 | // First, check if the requested URI exists in the preferred version 63 | if (array_key_exists($preferredVersion, $redirects)) { 64 | foreach ($possibilities as $possibility) { 65 | if (array_key_exists($possibility, $redirects[$preferredVersion])) { 66 | return $baseDir . $preferredVersionUrl . '/' . $redirects[$preferredVersion][$uri]; 67 | } 68 | } 69 | unset($redirects[$preferredVersion]); 70 | } 71 | 72 | // If not in the preferred version, check the others 73 | foreach ($redirects as $version => $options) { 74 | foreach ($possibilities as $possibility) { 75 | if (array_key_exists($possibility, $options)) { 76 | return $baseDir . $version . '/' . $options[$possibility]; 77 | } 78 | } 79 | } 80 | 81 | // No clue what you're looking for! 82 | throw new RedirectNotFoundException(); 83 | } 84 | 85 | private static function cleanRequestUri($uri): string 86 | { 87 | $uri = strtolower($uri); 88 | $uri = '/' . ltrim($uri, '/'); 89 | $currentBranchString = '/' . VersionsService::getCurrentVersion() . '/'; 90 | 91 | if (strpos($uri, $currentBranchString) !== 0) { 92 | return $uri; 93 | } 94 | 95 | return '/' 96 | . VersionsService::getCurrentVersion() 97 | . '/' 98 | . substr($uri, strlen($currentBranchString)); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/Helpers/RelativeImageRenderer.php: -------------------------------------------------------------------------------- 1 | relativeFilePath = $relativeFilePath; 20 | } 21 | 22 | /** 23 | * @param Image $inline 24 | * @param ElementRendererInterface $htmlRenderer 25 | * 26 | * @return HtmlElement 27 | */ 28 | public function render(AbstractInline $inline, ElementRendererInterface $htmlRenderer) 29 | { 30 | if (!($inline instanceof Image)) { 31 | throw new \InvalidArgumentException('Incompatible inline type: ' . get_class($inline)); 32 | } 33 | 34 | $attrs = []; 35 | foreach ($inline->getData('attributes', []) as $key => $value) { 36 | $attrs[$key] = Xml::escape($value, true); 37 | } 38 | 39 | $url = $inline->getUrl(); 40 | 41 | $path = '/' . dirname($this->relativeFilePath) . '/'; 42 | $imageIsRelative = strpos($url, '/') !== 0 && strpos($url, 'http') !== 0; 43 | if ($imageIsRelative) { 44 | $url = $path . $url; 45 | } 46 | 47 | if (RegexHelper::isLinkPotentiallyUnsafe($url)) { 48 | $url = ''; 49 | } 50 | $attrs['src'] = Xml::escape($url, true); 51 | 52 | $alt = $htmlRenderer->renderInlines($inline->children()); 53 | $alt = preg_replace('/\<[^>]*alt="([^"]*)"[^>]*\>/', '$1', $alt); 54 | $attrs['alt'] = preg_replace('/\<[^>]*\>/', '', $alt); 55 | 56 | if (isset($inline->data['title'])) { 57 | $attrs['title'] = Xml::escape($inline->data['title'], true); 58 | } 59 | 60 | return new HtmlElement('img', $attrs, '', true); 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /src/Helpers/SettingsParser.php: -------------------------------------------------------------------------------- 1 | load(); 18 | } 19 | 20 | public function getSlimConfig() 21 | { 22 | return [ 23 | 'settings' => [ 24 | 'displayErrorDetails' => getenv('DEV') === '1', 25 | 'addContentLengthHeader' => false, 26 | ] 27 | ]; 28 | } 29 | 30 | private static function getDotFile($baseDir) 31 | { 32 | if (file_exists($baseDir . SettingsParser::DEFAULT_FILE)) { 33 | return SettingsParser::DEFAULT_FILE; 34 | } 35 | 36 | return SettingsParser::DEV_FILE; 37 | } 38 | } -------------------------------------------------------------------------------- /src/Helpers/TocRenderer.php: -------------------------------------------------------------------------------- 1 | prefix = $prefix; 17 | 18 | parent::__construct($matcher, $defaultOptions, null); 19 | } 20 | 21 | protected function renderLink(ItemInterface $item, array $options = []): string 22 | { 23 | $newUri = $this->prefix . $item->getUri(); 24 | $item->setUri($newUri); 25 | 26 | return parent::renderLink($item, $options); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Middlewares/RequestMiddleware.php: -------------------------------------------------------------------------------- 1 | getUri(); 13 | $path = $uri->getPath(); 14 | 15 | if ($path !== '/' && substr($path, -1) === '/') { 16 | // permanently redirect paths with a trailing slash 17 | // to their non-trailing counterpart 18 | $uri = $uri->withPath(substr($path, 0, -1)); 19 | 20 | if ($request->getMethod() === 'GET') { 21 | return $response->withRedirect((string)$uri, 301); 22 | } 23 | 24 | return $next($request->withUri($uri), $response); 25 | } 26 | 27 | return $next($request, $response); 28 | } 29 | } -------------------------------------------------------------------------------- /src/Model/PageRequest.php: -------------------------------------------------------------------------------- 1 | version = $version; 18 | $this->language = $language; 19 | $this->path = $path; 20 | $this->versionBranch = $this->version === VersionsService::getCurrentVersion() ? VersionsService::getCurrentVersionBranch() : $this->version; 21 | } 22 | 23 | public static function fromRequest(Request $request): self 24 | { 25 | return new static( 26 | $request->getAttribute('version', VersionsService::getCurrentVersion()), 27 | $request->getAttribute('language', VersionsService::getDefaultLanguage()), 28 | $request->getAttribute('path', VersionsService::getDefaultPath()) 29 | ); 30 | } 31 | 32 | /** 33 | * @return string 34 | */ 35 | public function getVersion(): string 36 | { 37 | return $this->version; 38 | } 39 | 40 | /** 41 | * @return string 42 | */ 43 | public function getLanguage(): string 44 | { 45 | return $this->language; 46 | } 47 | 48 | public function getContextUrl(): string 49 | { 50 | return '/' . $this->version . '/' . $this->language . '/'; 51 | } 52 | 53 | public function getActualContextUrl(): string 54 | { 55 | return '/' . $this->versionBranch . '/' . $this->language . '/'; 56 | } 57 | 58 | /** 59 | * @return string 60 | */ 61 | public function getPath(): string 62 | { 63 | return $this->path; 64 | } 65 | 66 | /** 67 | * @return string 68 | */ 69 | public function getVersionBranch(): string 70 | { 71 | return $this->versionBranch; 72 | } 73 | 74 | public function getLocale(): string 75 | { 76 | switch ($this->language) { 77 | case 'ru': 78 | return 'ru_RU'; 79 | case 'nl': 80 | return 'nl_NL'; 81 | } 82 | return 'en_US'; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Model/SearchQuery.php: -------------------------------------------------------------------------------- 1 | searchService = $searchService; 40 | $this->pageRequest = $pageRequest; 41 | $this->stopwords = (new StopWords())->getStopWordsFromLanguage($pageRequest->getLanguage()); 42 | $this->isLive = (bool)$isLive; 43 | $this->parseQueryString($queryString); 44 | } 45 | 46 | private function parseQueryString($queryString) 47 | { 48 | $this->queryString = $queryString; 49 | 50 | $q = strtolower(trim($queryString)); 51 | $q = preg_split('/[\s\-\\\:]+/', $q, -1, PREG_SPLIT_NO_EMPTY); 52 | foreach ($q as $term) { 53 | $this->addTerm($term); 54 | } 55 | } 56 | 57 | private function addTerm(string $term): void 58 | { 59 | // Force exact match with "quotes" 60 | if (strpos($term, '"') === 0 && strrpos($term, '"') === strlen($term) - 1) { 61 | $term = trim($term, '"'); 62 | $references = $this->searchService->getExactTermReferences( 63 | $this->pageRequest->getVersionBranch(), 64 | $this->pageRequest->getLanguage(), 65 | $term 66 | ); 67 | 68 | foreach ($references as $ref => $t) { 69 | $this->exactTerms[$ref] = $t; 70 | } 71 | return; 72 | } 73 | 74 | // Cleanup the string 75 | $term = trim($term, '"\'$,.-():;&#_?/\\'); 76 | 77 | // Shorter than 2 characters? Ignore 78 | if (mb_strlen($term) < SearchService::MIN_TERM_LENGTH) { 79 | $this->ignoredTerms[] = $term; 80 | return; 81 | } 82 | 83 | // In the stopwords list? Ignore. 84 | if (in_array($term, $this->stopwords, true)) { 85 | $this->ignoredTerms[] = $term; 86 | return; 87 | } 88 | 89 | // Get fuzzy matches 90 | $references = array_replace( 91 | $this->searchService->getStartsWithReferences( 92 | $this->pageRequest->getVersionBranch(), 93 | $this->pageRequest->getLanguage(), 94 | $term 95 | ), 96 | $this->searchService->getFuzzyTermReferences( 97 | $this->pageRequest->getVersionBranch(), 98 | $this->pageRequest->getLanguage(), 99 | $term 100 | ) 101 | ); 102 | 103 | foreach ($references as $ref => $t) { 104 | if ($t === $term) { 105 | $this->exactTerms[$ref] = $t; 106 | } 107 | else { 108 | $this->fuzzyTerms[$ref] = $t; 109 | } 110 | } 111 | } 112 | 113 | 114 | /** 115 | * @return string 116 | */ 117 | public function getQueryString(): string 118 | { 119 | return $this->queryString; 120 | } 121 | 122 | public function getAllTerms() 123 | { 124 | return array_unique($this->exactTerms + $this->fuzzyTerms); 125 | } 126 | 127 | public function getSearchTermReferences() 128 | { 129 | $all = array_merge(array_keys($this->exactTerms), array_keys($this->fuzzyTerms)); 130 | $all = array_filter(array_unique($all)); 131 | return $all; 132 | } 133 | 134 | public function getExactTerms() 135 | { 136 | return $this->exactTerms; 137 | } 138 | 139 | public function getFuzzyTerms() 140 | { 141 | return $this->fuzzyTerms; 142 | } 143 | 144 | public function getIgnoredTerms() 145 | { 146 | return $this->ignoredTerms; 147 | } 148 | 149 | } 150 | -------------------------------------------------------------------------------- /src/Model/SearchResults.php: -------------------------------------------------------------------------------- 1 | documentService = $documentService; 26 | $this->query = $query; 27 | $this->exactTerms = $query->getExactTerms(); 28 | $this->fuzzyTerms = $query->getFuzzyTerms(); 29 | } 30 | 31 | public function addMatch($termId, $page, $weight): void 32 | { 33 | if (!array_key_exists($page, $this->results)) { 34 | $this->results[$page] = 0.00; 35 | } 36 | if (!array_key_exists($page, $this->resultDetails)) { 37 | $this->resultDetails[$page] = []; 38 | } 39 | 40 | // Reduce weight for fuzzy matches 41 | $isExact = array_key_exists($termId, $this->exactTerms); 42 | if (!$isExact) { 43 | $weight *= 0.75; 44 | } 45 | 46 | $this->results[$page] += $weight; 47 | 48 | // if (!array_key_exists($page, $this->matches)) { 49 | // $this->matches[$page] = []; 50 | // } 51 | // $this->matches[$page][] = [ 52 | // 'term' => $termId, 'weight' => $weight 53 | // ]; 54 | 55 | $this->resultDetails[$page][] = [$isExact, $termId, $weight]; 56 | } 57 | 58 | public function getDetailedMatches($page) 59 | { 60 | if (array_key_exists($page, $this->resultDetails)) { 61 | return $this->resultDetails[$page]; 62 | } 63 | return []; 64 | } 65 | 66 | public function process(): void 67 | { 68 | // Sort best match first 69 | arsort($this->results, SORT_NUMERIC); 70 | } 71 | 72 | public function getCount(): int 73 | { 74 | return count($this->results); 75 | } 76 | 77 | public function getResults($offset, $limit = 10) 78 | { 79 | return array_slice($this->results, $offset, $limit, true); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Services/CacheService.php: -------------------------------------------------------------------------------- 1 | cacheRoot = rtrim(getenv('CACHE_DIRECTORY'), '/') . '/'; 13 | $this->enabled = (bool)getenv('CACHE_ENABLED'); 14 | } 15 | 16 | public static function getInstance(): CacheService 17 | { 18 | return new self(); 19 | } 20 | 21 | public function get($key, $hash = null) 22 | { 23 | if (!$this->enabled) { 24 | return false; 25 | } 26 | 27 | $file = $this->keyToFile($key); 28 | if (!file_exists($file)) { 29 | return false; 30 | } 31 | 32 | $data = file_get_contents($file); 33 | $data = json_decode($data, true); 34 | if (is_array($data)) { 35 | if ($hash !== null && $data['hash'] !== $hash) { 36 | return false; 37 | } 38 | 39 | if (is_numeric($data['expiration']) && time() > $data['expiration']) { 40 | return false; 41 | } 42 | 43 | return $data['contents']; 44 | } 45 | 46 | return false; 47 | } 48 | 49 | public function set($key, $value, $expiration = null, $hash = null) 50 | { 51 | if (!$this->enabled) { 52 | return false; 53 | } 54 | 55 | $file = $this->keyToFile($key); 56 | $data = [ 57 | 'generated' => date('Y-m-d H:i:s'), 58 | 'contents' => $value, 59 | 'hash' => $hash, 60 | 'expiration' => $expiration, 61 | ]; 62 | 63 | $this->ensurePathsExist(dirname($file)); 64 | 65 | file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); 66 | return true; 67 | } 68 | 69 | private function keyToFile($key) 70 | { 71 | return $this->cacheRoot . strtolower($key) . '.json'; 72 | } 73 | 74 | private function ensurePathsExist(string $path): bool 75 | { 76 | if (file_exists($path) && is_dir($path)) { 77 | return true; 78 | } 79 | 80 | if (!mkdir($path, 0777, true) && !is_dir($path)) { 81 | return false; 82 | } 83 | 84 | return true; 85 | } 86 | 87 | public function isEnabled(): bool 88 | { 89 | return $this->enabled; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Services/DocumentService.php: -------------------------------------------------------------------------------- 1 | filePathService = $filePathService; 22 | $this->db = $db; 23 | } 24 | 25 | /** 26 | * @param PageRequest $request 27 | * @return Page 28 | * @throws NotFoundException 29 | */ 30 | public function load(PageRequest $request) : Page 31 | { 32 | $path = $this->filePathService->getFilePath($request); 33 | 34 | if ($path === null || !file_exists($path)) { 35 | throw new NotFoundException(); 36 | } 37 | 38 | $fileContents = file_get_contents($path); 39 | $parsed = YamlFrontMatter::parse($fileContents); 40 | 41 | return new Page( 42 | $this, 43 | $this->db, 44 | $request->getVersion(), 45 | $request->getLanguage(), 46 | $request->getPath(), 47 | $path, 48 | $parsed->matter(), 49 | $parsed->body() 50 | ); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Services/FilePathService.php: -------------------------------------------------------------------------------- 1 | getFilePath($request) !== null; 13 | } 14 | 15 | public function getFilePath(PageRequest $request) : ?string 16 | { 17 | $basePath = rtrim($this->getAbsoluteContextPath($request), '/'); 18 | 19 | $fullRequestPath = $basePath . '/' . trim($request->getPath(), '/'); 20 | 21 | $file = $fullRequestPath . '.md'; 22 | if (file_exists($file)) { 23 | return $file; 24 | } 25 | 26 | // See if we have an index file instead 27 | $file = $fullRequestPath . '/index.md'; 28 | if (file_exists($file)) { 29 | return $file; 30 | } 31 | 32 | return null; 33 | } 34 | 35 | public function getAbsoluteRootPath() : string 36 | { 37 | return getenv('DOCS_DIRECTORY'); 38 | } 39 | 40 | public function getAbsoluteContextPath(PageRequest $request) : string 41 | { 42 | return $this->getAbsoluteRootPath() . $request->getActualContextUrl(); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Services/TranslationService.php: -------------------------------------------------------------------------------- 1 | db = $db; 18 | } 19 | 20 | public function getAvailableTranslations(PageRequest $request): array 21 | { 22 | /** 23 | * Index pages don't show up in the translation index - so return them manually. 24 | */ 25 | if ($request->getPath() === 'index') { 26 | return [ 27 | 'en' => '/' . $request->getVersionBranch() . '/en/index', 28 | 'ru' => '/' . $request->getVersionBranch() . '/ru/index', 29 | 'nl' => '/' . $request->getVersionBranch() . '/nl/index', 30 | 'es' => '/' . $request->getVersionBranch() . '/es/index', 31 | ]; 32 | } 33 | 34 | $language = $request->getLanguage(); 35 | if (!in_array($language, ['nl', 'ru', 'en', 'es'])) { 36 | $language = 'en'; 37 | } 38 | try { 39 | $q = 'SELECT * FROM Translations WHERE ' . $language . ' = :uri'; 40 | $stmt = $this->db->prepare($q); 41 | $stmt->bindValue(':uri', $request->getActualContextUrl() . $request->getPath()); 42 | 43 | if ($stmt->execute() && $row = $stmt->fetch(\PDO::FETCH_ASSOC)) { 44 | $stmt->closeCursor(); 45 | foreach ($row as $lang => $uri) { 46 | if (empty($uri)) { 47 | unset($row[$lang]); 48 | } 49 | } 50 | 51 | return $row; 52 | } 53 | 54 | $stmt->closeCursor(); 55 | } catch (\Exception $e) { 56 | return []; 57 | } 58 | return []; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Services/VersionsService.php: -------------------------------------------------------------------------------- 1 | router = $router; 20 | } 21 | 22 | public static function getAvailableVersions($includeCurrent = true): array 23 | { 24 | $versions = []; 25 | 26 | $base = getenv('BASE_DIRECTORY'); 27 | $config = null; 28 | $files = ['sources.dist.json', 'sources.json']; 29 | foreach ($files as $file) { 30 | $path = $base . $file; 31 | if (file_exists($path)) { 32 | $config = json_decode(file_get_contents($path), true); 33 | } 34 | } 35 | 36 | if ($config === null) { 37 | return []; 38 | } 39 | 40 | foreach ($config as $versionKey => $details) { 41 | $versions[$versionKey] = $details; 42 | } 43 | 44 | if ($includeCurrent 45 | && !array_key_exists(self::getCurrentVersion(), $versions) 46 | && array_key_exists(self::getCurrentVersionBranch(), $versions)) { 47 | $versions[self::getCurrentVersion()] = $versions[self::getCurrentVersionBranch()]; 48 | } 49 | 50 | return $versions; 51 | } 52 | 53 | public function getVersions(PageRequest $request) 54 | { 55 | $dir = new \DirectoryIterator(getenv('DOCS_DIRECTORY')); 56 | 57 | $versions = []; 58 | 59 | foreach ($dir as $fileInfo) { 60 | if (!$fileInfo->isDir() || $fileInfo->isDot()) { 61 | continue; 62 | } 63 | 64 | $file = $fileInfo->getPathname() 65 | . '/' 66 | . $request->getLanguage() 67 | . '/' 68 | . $request->getPath(); 69 | 70 | if (file_exists($file . '.md') || file_exists($file . '/index.md')) { 71 | $versions[] = $this->createVersion($request, $fileInfo); 72 | } 73 | } 74 | 75 | return $versions; 76 | } 77 | 78 | private function createVersion(PageRequest $request, \DirectoryIterator $fileInfo) 79 | { 80 | $versionKey = static::getVersionUrl($fileInfo->getFilename()); 81 | return [ 82 | 'title' => static::getVersionTitle($fileInfo->getFilename()), 83 | 'active' => $versionKey === $request->getVersion(), 84 | 'key' => $versionKey, 85 | 'uri' => $this->router->pathFor('documentation', [ 86 | 'version' => $versionKey, 87 | 'language' => $request->getLanguage(), 88 | 'path' => $request->getPath(), 89 | ]) 90 | ]; 91 | } 92 | 93 | private static function getVersionUrl($version) 94 | { 95 | // If we found another version e.g. 2.x, and 2.x is the `current` branch, use `current` 96 | // instead of 2.x in the URL 97 | if (static::getCurrentVersionBranch() === $version) { 98 | return static::getCurrentVersion(); 99 | } 100 | 101 | return $version; 102 | } 103 | 104 | private static function getVersionTitle($fileVersion) 105 | { 106 | if (static::getCurrentVersionBranch() === $fileVersion) { 107 | return $fileVersion . ' (current)'; 108 | } 109 | 110 | return $fileVersion; 111 | } 112 | 113 | public static function getCurrentVersion(): string 114 | { 115 | return static::CURRENT_VERSION; 116 | } 117 | 118 | public static function getCurrentVersionBranch(): string 119 | { 120 | return static::CURRENT_VERSION_BRANCH; 121 | } 122 | 123 | public static function getDefaultLanguage(): string 124 | { 125 | return static::DEFAULT_LANGUAGE; 126 | } 127 | 128 | public static function getDefaultPath(): string 129 | { 130 | return static::DEFAULT_PATH; 131 | } 132 | 133 | public static function getDocsRoot(): string 134 | { 135 | return getenv('DOCS_DIRECTORY'); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/Twig/DocExtensions.php: -------------------------------------------------------------------------------- 1 | router = $router; 19 | $this->request = $request; 20 | } 21 | 22 | public function getFunctions() 23 | { 24 | return [ 25 | new TwigFunction('base_href', [$this, 'getBaseHref']), 26 | new TwigFunction('icon', [$this, 'getInlineSvg'], ['is_safe' => ['html']]), 27 | ]; 28 | } 29 | 30 | public function getBaseHref() 31 | { 32 | $scheme = getenv('SSL') === '1' ? 'https' : 'http'; 33 | $uri = $this->request->getUri(); 34 | $port = \in_array($uri->getPort(), [80, 443, null], true) ? '' : (':' . $uri->getPort()); 35 | 36 | return $scheme . '://' . $uri->getHost() . $port . '/'; 37 | } 38 | 39 | public static function getInlineSvg($name, $title = '', $classes = '', $role = 'presentation', $attributes = '') { 40 | $rev = Base::getRevision(); 41 | $url = '/template/dist/sprite.svg?v=' . $rev . '#' . $name; 42 | 43 | return ''.$title.''; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Views/Base.php: -------------------------------------------------------------------------------- 1 | container = $container; 27 | $this->view = $this->container->get('view'); 28 | $this->versionsService = $this->container->get(VersionsService::class); 29 | } 30 | 31 | protected function render(Request $request, Response $response, $template, array $data = []): \Psr\Http\Message\ResponseInterface 32 | { 33 | $pageRequest = PageRequest::fromRequest($request); 34 | 35 | $initialData = [ 36 | 'revision' => static::getRevision(), 37 | 'canonical_base' => getenv('CANONICAL_BASE_URL'), 38 | 'current_uri' => $request->getUri()->getPath(), 39 | 'version' => $pageRequest->getVersion(), 40 | 'version_branch' => $pageRequest->getVersionBranch(), 41 | 'versions' => $this->versionsService->getVersions($pageRequest), 42 | 'language' => $pageRequest->getLanguage(), 43 | 'locale' => $pageRequest->getLocale(), 44 | 'path' => $pageRequest->getPath(), 45 | 'logo_link' => $pageRequest->getContextUrl() . VersionsService::getDefaultPath(), 46 | 'is_dev' => (bool) getenv('DEV'), 47 | 'analytics_id' => (string) getenv('ANALYTICS_ID'), 48 | 'lang' => $this->getLang($pageRequest->getLanguage()), 49 | 'opencollective' => $this->getOpenCollectiveInfo(), 50 | 'opencollective_members' => $this->getOpenCollectiveMembers(), 51 | ]; 52 | 53 | $data = \array_merge( 54 | $initialData, 55 | $data 56 | ); 57 | 58 | if (!array_key_exists('canonical_url', $data) || empty($data['canonical_url'])) { 59 | $data['canonical_url'] = $data['canonical_base'] . ltrim($data['current_uri'], '/'); 60 | } 61 | 62 | return $this->view->render( 63 | $response, 64 | $template, 65 | $data 66 | ); 67 | } 68 | 69 | protected function render404(Request $request, Response $response, array $data = []): \Psr\Http\Message\ResponseInterface 70 | { 71 | return $this->render( 72 | $request, 73 | $response->withStatus(404), 74 | 'notfound.twig', 75 | \array_merge( 76 | $data 77 | ) 78 | ); 79 | } 80 | 81 | public static function getRevision() : string 82 | { 83 | if (!empty(self::$rev)) { 84 | return self::$rev; 85 | } 86 | $revision = 'dev'; 87 | 88 | $projectDir = getenv('BASE_DIRECTORY'); 89 | if (file_exists($projectDir . '.revision')) { 90 | $revision = trim((string)file_get_contents($projectDir . '.revision')); 91 | } 92 | 93 | self::$rev = $revision; 94 | 95 | return $revision; 96 | } 97 | 98 | protected function getLang(string $language): array 99 | { 100 | $lang = json_decode(file_get_contents($_ENV['BASE_DIRECTORY'] . 'lang.json'), true); 101 | if (!is_array($lang)) { 102 | return []; 103 | } 104 | if (array_key_exists($language, $lang)) { 105 | $lang = array_merge($lang['en'], $lang[$language]); 106 | } 107 | else { 108 | $lang = $lang['en']; 109 | } 110 | return $lang; 111 | } 112 | 113 | private function getOpenCollectiveInfo(): array 114 | { 115 | $cache = CacheService::getInstance(); 116 | $cacheKey = 'opencollective_fc'; 117 | $info = $cache->get($cacheKey); 118 | if (!is_array($info)) { 119 | $data = @file_get_contents('https://opencollective.com/modx.json'); 120 | $data = json_decode($data, true); 121 | if (!empty($data['slug']) && $data['slug'] === 'modx') { 122 | $data['fetched'] = time(); 123 | $cache->set($cacheKey, $data, strtotime('+2 hours')); 124 | $info = $data; 125 | } 126 | } 127 | 128 | return $info ?: []; 129 | } 130 | 131 | private function getOpenCollectiveMembers(): array 132 | { 133 | $cache = CacheService::getInstance(); 134 | $cacheKey = 'opencollective_members_fc'; 135 | $info = $cache->get($cacheKey); 136 | if (!is_array($info)) { 137 | $data = @file_get_contents('https://opencollective.com/modx/members.json?limit=50&isActive=1'); 138 | $data = json_decode($data, true); 139 | if (is_array($data) && count($data) > 0) { 140 | 141 | $merged = []; 142 | foreach ($data as $i => $member) { 143 | // filter out non-backers (OC itself, admin) 144 | if ($member['role'] !== 'BACKER') { 145 | continue; 146 | } 147 | 148 | // Sometimes, users appear multiple times because of having a subscription but also 149 | // standalone donations. Merging those profiles here makes sure they appear just once. 150 | if (!isset($merged[$member['profile']])) { 151 | $merged[$member['profile']] = $member; 152 | } 153 | } 154 | 155 | // Sort by total amount donated 156 | uasort($merged, static function ($a, $b) { 157 | return $a['totalAmountDonated'] < $b['totalAmountDonated'] ? 1 : -1; 158 | }); 159 | 160 | $cache->set($cacheKey, $merged, strtotime('+2 hours')); 161 | $info = $merged; 162 | } 163 | } 164 | 165 | return $info ?: []; 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/Views/Error.php: -------------------------------------------------------------------------------- 1 | throwable = $e; 20 | parent::__construct($container); 21 | } 22 | 23 | public function get(Request $request, Response $response) 24 | { 25 | 26 | $pageRequest = PageRequest::fromRequest($request); 27 | 28 | $data = [ 29 | 'revision' => static::getRevision(), 30 | 'is_dev' => (bool) getenv('DEV'), 31 | 'exception_type' => get_class($this->throwable), 32 | 'exception' => $this->throwable, 33 | 'current_uri' => $request->getUri()->getPath(), 34 | 35 | 'page_title' => 'Oops, an error occurred.', 36 | 37 | 'version' => $pageRequest->getVersion(), 38 | 'version_branch' => $pageRequest->getVersionBranch(), 39 | 'language' => $pageRequest->getLanguage(), 40 | 41 | // We always disregard the path here, because we know the request is always invalid 42 | 'path' => null, 43 | ]; 44 | 45 | return $this->view->render( 46 | $response->withStatus(503), 47 | 'error.twig', 48 | $data 49 | ); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Views/NotFound.php: -------------------------------------------------------------------------------- 1 | db = $container->get('db'); 32 | $this->searchService = $this->container->get(SearchService::class); 33 | } 34 | 35 | public function get(Request $request, Response $response) 36 | { 37 | $currentUri = $request->getUri()->getPath(); 38 | 39 | // Make sure links ending in .md get redirected 40 | if (substr($currentUri, -strlen(static::MARKDOWN_SUFFIX)) === static::MARKDOWN_SUFFIX) { 41 | $uri = substr($currentUri, 0, -strlen(static::MARKDOWN_SUFFIX)); 42 | return $response->withRedirect($uri, 301); 43 | } 44 | 45 | try { 46 | $redirectUri = Redirector::findNewURI($currentUri); 47 | 48 | return $response->withRedirect($redirectUri, 301); 49 | } catch (RedirectNotFoundException $e) { 50 | 51 | $this->logNotFoundRequest($currentUri); 52 | 53 | // Render the default tree on the 404 page 54 | // @todo See if it's possible to use version/language specific trees without breaking when invalid 55 | $tree = Tree::get(VersionsService::getCurrentVersion(), VersionsService::getDefaultLanguage()); 56 | 57 | // Prepare a somewhat normalised search query 58 | $query = str_replace(['-', '_', '+', '/'], ' ', strtolower(urldecode($currentUri))); 59 | $query = explode(' ', $query); 60 | // Filter out some common old url structures 61 | $query = array_diff($query, ['display', 'revolution20', 'revo', '_legacy', '1.x', '2.x']); 62 | $query = trim(implode(' ', $query)); 63 | 64 | // Run the search 65 | $pageRequest = new PageRequest(VersionsService::getCurrentVersion(), VersionsService::getDefaultLanguage(), ''); 66 | $sq = new SearchQuery($this->searchService, $query, $pageRequest, false); 67 | $result = $this->searchService->execute($sq); 68 | 69 | // Maximum 5 results, with a score of at least 30 (75% confidence) 70 | $pageIDs = $result->getResults(0, 5); 71 | $pageIDs = array_filter($pageIDs, static function($value) { 72 | return $value >= 30; 73 | }); 74 | 75 | $searchResults = $this->searchService->populateResults($pageRequest, $result, $pageIDs); 76 | 77 | return $this->render404($request, $response, [ 78 | 'req_url' => urlencode($currentUri), 79 | 'page_title' => 'Oops, page not found.', 80 | 'nav' => $tree->renderTree($this->view), 81 | 82 | 'version' => VersionsService::getCurrentVersion(), 83 | 'version_branch' => VersionsService::getCurrentVersionBranch(), 84 | 'language' => VersionsService::getDefaultLanguage(), 85 | 86 | 'search_results' => $searchResults, 87 | 'search_query' => $query, 88 | 'terms' => $sq->getAllTerms(), 89 | 90 | // We always disregard the path here, because we know the request is always invalid 91 | 'path' => null, 92 | ]); 93 | } 94 | } 95 | 96 | private function logNotFoundRequest(string $requestUri): void 97 | { 98 | try { 99 | $fetch = $this->db->prepare('SELECT rowid, url, hit_count FROM PageNotFound WHERE url = :url'); 100 | $fetch->bindValue(':url', $requestUri); 101 | if ($fetch->execute() && $log = $fetch->fetch(\PDO::FETCH_ASSOC)) { 102 | $update = $this->db->prepare('UPDATE PageNotFound SET hit_count = :hit_count, last_seen = :last_seen WHERE ROWID = :rowid'); 103 | $update->bindValue('hit_count', (int)$log['hit_count'] + 1); 104 | $update->bindValue('last_seen', time()); 105 | $update->bindValue('rowid', $log['rowid']); 106 | $update->execute(); 107 | } 108 | else { 109 | $insert = $this->db->prepare('INSERT INTO PageNotFound (url, hit_count, last_seen) VALUES (:url, 1, :last_seen)'); 110 | $insert->bindValue('url', $requestUri); 111 | $insert->bindValue('last_seen', time()); 112 | $insert->execute(); 113 | } 114 | } 115 | catch (\PDOException $e) { 116 | // Silence logging errors.. not interesting 117 | } 118 | } 119 | 120 | private function searchForPage(string $currentUri) 121 | { 122 | 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/Views/Search.php: -------------------------------------------------------------------------------- 1 | router = $this->container->get('router'); 28 | $this->searchService = $this->container->get(SearchService::class); 29 | } 30 | 31 | /** 32 | * @param Request $request 33 | * @param Response $response 34 | * @return \Psr\Http\Message\ResponseInterface 35 | * @throws \Exception 36 | */ 37 | public function get(Request $request, Response $response) 38 | { 39 | // The PageRequest gives us the version/language/etc. 40 | $pageRequest = PageRequest::fromRequest($request); 41 | 42 | $query = trim((string)$request->getParam('q', '')); 43 | 44 | $title = 'Search the documentation'; 45 | $live = (bool)$request->getParam('live'); 46 | 47 | $crumbs = []; 48 | $crumbs[] = [ 49 | 'title' => 'Search ' . $pageRequest->getVersion(), // @todo i18n 50 | 'href' => $this->router->pathFor('search', ['version' => $pageRequest->getVersion(), 'language' => $pageRequest->getLanguage()]) 51 | ]; 52 | 53 | $searchValues = []; 54 | if (!empty($query)) { 55 | $crumbs[] = [ 56 | 'title' => '"' . $query . '"', 57 | 'href' => $this->router->pathFor('search', ['version' => $pageRequest->getVersion(), 'language' => $pageRequest->getLanguage()], ['q' => $query]) 58 | ]; 59 | 60 | $startTime = microtime(true); 61 | $sq = new SearchQuery($this->searchService, $query, $pageRequest, $live); 62 | 63 | $result = $this->searchService->execute($sq); 64 | $resultCount = $result->getCount(); 65 | 66 | $limit = 10; 67 | $page = abs((int)$request->getParam('page', 1)); 68 | $totalPages = ceil($resultCount / $limit); 69 | $start = 0 + ($page - 1) * $limit; 70 | 71 | $pageIDs = $result->getResults($start, $limit); 72 | $results = $this->searchService->populateResults($pageRequest, $result, $pageIDs); 73 | 74 | $pagination = []; 75 | if (!empty($query)) { 76 | if ($resultCount > 0) { 77 | $title = $resultCount . ' results for "' . $query . '"'; 78 | $pagination = $this->getPagination($page, $pageRequest, $query, $totalPages); 79 | } 80 | else { 81 | $title = 'No results for "' . $query . '"'; 82 | } 83 | } 84 | 85 | $searchValues = [ 86 | 'timing' => number_format((microtime(true) - $startTime) * 1000), 87 | 'terms' => $sq->getAllTerms(), 88 | 'exact_terms' => $sq->getExactTerms(), 89 | 'fuzzy_terms' => $sq->getFuzzyTerms(), 90 | 'ignored_terms' => $sq->getIgnoredTerms(), 91 | 'pagination' => $pagination, 92 | ]; 93 | } 94 | else { 95 | $resultCount = 0; 96 | $results = []; 97 | } 98 | 99 | $tree = Tree::get($pageRequest->getVersion(), $pageRequest->getLanguage()); 100 | $tree->setActivePath($pageRequest->getContextUrl() . $pageRequest->getPath()); 101 | 102 | $template = $live ? 'search_ajax.twig' : 'search.twig'; 103 | 104 | $values = array_merge([ 105 | 'page_title' => $title, 106 | 'search_query' => $query, 107 | 'result_count' => $resultCount, 108 | 'results' => $results, 109 | 'crumbs' => $crumbs, 110 | 'canonical_url' => '', 111 | 'versions' => $this->versionsService->getVersions($pageRequest), 112 | 'nav' => $tree->renderTree($this->view), 113 | ], $searchValues); 114 | 115 | return $this->render($request, $response, $template, $values); 116 | } 117 | 118 | protected function getPagination(int $page, PageRequest $pageRequest, string $query, int $totalPages): array 119 | { 120 | $pagination = []; 121 | $max = 3; 122 | $looped = 0; 123 | $prev = $page - 1; 124 | while ($prev > 1 && $looped < $max) { 125 | $looped++; 126 | $pagination[] = [ 127 | 'page' => $prev, 128 | 'href' => $this->router->pathFor('search', 129 | ['version' => $pageRequest->getVersion(), 'language' => $pageRequest->getLanguage()], 130 | ['q' => $query, 'page' => $prev]) 131 | ]; 132 | $prev--; 133 | } 134 | 135 | if ($page > 1) { 136 | $pagination[] = [ 137 | 'page' => 'First', 138 | 'href' => $this->router->pathFor('search', 139 | ['version' => $pageRequest->getVersion(), 'language' => $pageRequest->getLanguage()], 140 | ['q' => $query]) 141 | ]; 142 | } 143 | 144 | $pagination = array_reverse($pagination); 145 | 146 | 147 | $pagination[] = [ 148 | 'current' => true, 149 | 'page' => $page, 150 | 'href' => $this->router->pathFor('search', 151 | ['version' => $pageRequest->getVersion(), 'language' => $pageRequest->getLanguage()], 152 | ['q' => $query, 'page' => $page]) 153 | ]; 154 | 155 | $looped = 0; 156 | $next = $page + 1; 157 | while ($next > 1 && $looped < $max && $next < $totalPages) { 158 | $looped++; 159 | $pagination[] = [ 160 | 'page' => $next, 161 | 'href' => $this->router->pathFor('search', 162 | ['version' => $pageRequest->getVersion(), 'language' => $pageRequest->getLanguage()], 163 | ['q' => $query, 'page' => $next]) 164 | ]; 165 | $next++; 166 | } 167 | 168 | if ($next < $totalPages) { 169 | 170 | $pagination[] = [ 171 | 'page' => 'Last', 172 | 'href' => $this->router->pathFor('search', 173 | ['version' => $pageRequest->getVersion(), 'language' => $pageRequest->getLanguage()], 174 | ['q' => $query, 'page' => $totalPages]) 175 | ]; 176 | } 177 | 178 | return $pagination; 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/Views/Stats/NotFoundRequests.php: -------------------------------------------------------------------------------- 1 | router = $this->container->get('router'); 28 | $this->db = $this->container->get('db'); 29 | $this->cache = CacheService::getInstance(); 30 | } 31 | 32 | /** 33 | * @param Request $request 34 | * @param Response $response 35 | * @return \Psr\Http\Message\ResponseInterface 36 | * @throws \Exception 37 | */ 38 | public function get(Request $request, Response $response) 39 | { 40 | $crumbs = []; 41 | $crumbs[] = [ 42 | 'title' => 'Page Not Found Errors', // @todo i18n 43 | 'href' => $this->router->pathFor('stats/page-not-found') 44 | ]; 45 | 46 | $phs = [ 47 | 'page_title' => 'Page Not Found Statistics', 48 | 'crumbs' => $crumbs, 49 | 'canonical_url' => '', 50 | 51 | 'top_requests' => $this->getTopRequests(), 52 | 'recent_requests' => $this->getRecentRequests(), 53 | // 'versions' => $this->versionsService->getVersions($pageRequest), 54 | // 'nav' => $tree->renderTree($this->view), 55 | 56 | // 'timing' => number_format((microtime(true) - $startTime) * 1000), 57 | // 'terms' => $sq->getAllTerms(), 58 | // 'exact_terms' => $sq->getExactTerms(), 59 | // 'fuzzy_terms' => $sq->getFuzzyTerms(), 60 | // 'ignored_terms' => $sq->getIgnoredTerms(), 61 | // 'pagination' => $pagination, 62 | ]; 63 | 64 | return $this->render($request, $response, 'stats/not-found-requests.twig', $phs); 65 | } 66 | 67 | private function getTopRequests() 68 | { 69 | $results = $this->cache->get('stats/notfoundrequests/top'); 70 | if (is_array($results)) { 71 | return $results; 72 | } 73 | $statement = $this->db->prepare('SELECT url, hit_count, last_seen FROM PageNotFound ORDER BY hit_count DESC LIMIT 50'); 74 | 75 | $results = []; 76 | if ($statement->execute() && $requests = $statement->fetchAll(\PDO::FETCH_ASSOC)) { 77 | foreach ($requests as $req) { 78 | $results[] = $req; 79 | } 80 | } 81 | 82 | $this->cache->set('stats/notfoundrequests/top', $results, strtotime('+12 hours')); 83 | return $results; 84 | } 85 | 86 | private function getRecentRequests() 87 | { 88 | $results = $this->cache->get('stats/notfoundrequests/recent'); 89 | if (is_array($results)) { 90 | return $results; 91 | } 92 | $statement = $this->db->prepare('SELECT url, hit_count, last_seen FROM PageNotFound ORDER BY last_seen DESC LIMIT 50'); 93 | 94 | $results = []; 95 | if ($statement->execute() && $requests = $statement->fetchAll(\PDO::FETCH_ASSOC)) { 96 | foreach ($requests as $req) { 97 | $results[] = $req; 98 | } 99 | } 100 | 101 | $this->cache->set('stats/notfoundrequests/recent', $results, strtotime('+3 hours')); 102 | return $results; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/Views/Stats/Searches.php: -------------------------------------------------------------------------------- 1 | router = $this->container->get('router'); 29 | $this->db = $this->container->get('db'); 30 | $this->cache = CacheService::getInstance(); 31 | } 32 | 33 | /** 34 | * @param Request $request 35 | * @param Response $response 36 | * @return \Psr\Http\Message\ResponseInterface 37 | * @throws \Exception 38 | */ 39 | public function get(Request $request, Response $response) 40 | { 41 | $crumbs = []; 42 | $crumbs[] = [ 43 | 'title' => 'Search Statistics', // @todo i18n 44 | 'href' => $this->router->pathFor('stats/searches') 45 | ]; 46 | 47 | $startTime = microtime(true); 48 | 49 | $phs = [ 50 | 'page_title' => 'Search Statistics', 51 | 'crumbs' => $crumbs, 52 | 'canonical_url' => '', 53 | 54 | 'top_searches' => $this->getTopSearches(), 55 | 'searches_without_results' => $this->getSearchesWithoutResults(), 56 | 'recent_searches' => $this->getRecentSearches(), 57 | // 'versions' => $this->versionsService->getVersions($pageRequest), 58 | // 'nav' => $tree->renderTree($this->view), 59 | 60 | // 'timing' => number_format((microtime(true) - $startTime) * 1000), 61 | // 'terms' => $sq->getAllTerms(), 62 | // 'exact_terms' => $sq->getExactTerms(), 63 | // 'fuzzy_terms' => $sq->getFuzzyTerms(), 64 | // 'ignored_terms' => $sq->getIgnoredTerms(), 65 | // 'pagination' => $pagination, 66 | ]; 67 | 68 | return $this->render($request, $response, 'stats/searches.twig', $phs); 69 | } 70 | 71 | private function getTopSearches() 72 | { 73 | $results = $this->cache->get('stats/searches/top'); 74 | if (is_array($results)) { 75 | return $results; 76 | } 77 | $statement = $this->db->prepare('SELECT search_query, result_count, search_count, first_seen, last_seen FROM Searches ORDER BY search_count DESC LIMIT 50'); 78 | 79 | $results = []; 80 | if ($statement->execute() && $terms = $statement->fetchAll(\PDO::FETCH_ASSOC)) { 81 | foreach ($terms as $term) { 82 | $results[] = $term; 83 | } 84 | } 85 | 86 | $this->cache->set('stats/searches/top', $results, strtotime('+48 hours')); 87 | return $results; 88 | } 89 | 90 | private function getSearchesWithoutResults() 91 | { 92 | $results = $this->cache->get('stats/searches/without_results'); 93 | if (is_array($results)) { 94 | return $results; 95 | } 96 | $statement = $this->db->prepare('SELECT search_query, result_count, search_count, first_seen, last_seen FROM Searches ORDER BY result_count ASC, search_count DESC LIMIT 50'); 97 | 98 | $results = []; 99 | if ($statement->execute() && $terms = $statement->fetchAll(\PDO::FETCH_ASSOC)) { 100 | foreach ($terms as $term) { 101 | $results[] = $term; 102 | } 103 | } 104 | 105 | $this->cache->set('stats/searches/without_results', $results, strtotime('+48 hours')); 106 | return $results; 107 | } 108 | 109 | private function getRecentSearches() 110 | { 111 | $results = $this->cache->get('stats/searches/recent'); 112 | if (is_array($results)) { 113 | return $results; 114 | } 115 | $statement = $this->db->prepare('SELECT search_query, result_count, search_count, first_seen, last_seen FROM Searches ORDER BY last_seen DESC LIMIT 50'); 116 | 117 | $results = []; 118 | if ($statement->execute() && $terms = $statement->fetchAll(\PDO::FETCH_ASSOC)) { 119 | foreach ($terms as $term) { 120 | $results[] = $term; 121 | } 122 | } 123 | 124 | $this->cache->set('stats/searches/recent', $results, strtotime('+3 hours')); 125 | return $results; 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /templates/error.twig: -------------------------------------------------------------------------------- 1 | {% extends "layout.twig" %} 2 | 3 | {% block title %}{{ page_title }}{% endblock %} 4 | 5 | {% block content %} 6 |
7 |

An error occurred

8 |

We're sorry, but it appears something critical went wrong and we can't currently show you this page.

9 | 10 |

11 | Report issue on GitHub 12 |

13 | 14 |

{{ exception_type }}

15 |
{{ exception.message }}
16 | {% if is_dev %} 17 |

Triggered by {{ exception.file }} at line {{ exception.line }}

18 |

19 |                     {{ exception.traceAsString }}
20 |             
21 | {% endif %} 22 |
23 | {% endblock %} 24 | -------------------------------------------------------------------------------- /templates/notfound.twig: -------------------------------------------------------------------------------- 1 | {% extends "layout.twig" %} 2 | 3 | {% block title %}{{ page_title }}{% endblock %} 4 | 5 | {% block content %} 6 |
7 | 8 |
9 | 10 |
11 |

Page not found

12 |

Uh oh, you've reached a page that does not exist.

13 |

It may have gotten lost on the new documentation platform or it was removed because it was no longer relevant. It could also be a really old link from two (or more) documentation systems ago, which is not part of the 2000+ automatic redirects currently in place.

14 | 15 | {% if search_results|length > 0 %} 16 |

Based on your request, this may be what you're looking for:

17 | 18 |
    19 | {% for result in search_results %} 20 | {% include "partials/search_result.twig" with {result: result} %} 21 | {% endfor %} 22 |
23 | 24 | 25 | 34 | {% else %} 35 |

Please use the search at the top to find what you're looking for.

36 | {% endif %} 37 |
38 |
39 |
40 | {% endblock %} 41 | -------------------------------------------------------------------------------- /templates/partials/nav.twig: -------------------------------------------------------------------------------- 1 | 17 | -------------------------------------------------------------------------------- /templates/partials/search_result.twig: -------------------------------------------------------------------------------- 1 | 2 |
  • 3 | {#
    #} 4 | {# Score: {{ result.score }}/100#} 5 | {#
      #} 6 | {# {% for match in result.details %}#} 7 | {#
    • #} 8 | {# {% if match[0] %} #}{# = exact #} 9 | {# Exact match on#} 10 | {# {% else %}#} 11 | {# Fuzzy match#} 12 | {# {% endif %}#} 13 | {# {{ terms[match[1]] }} with weight {{ match[2] }}#} 14 | {#
    • #} 15 | {# {% endfor %}#} 16 | {#
    #} 17 | {#

    Total weight {{ result.weight }} (= a score of {{ result.weight }} / 40 * 100 = {{ result.score }})

    #} 18 | {#
    #} 19 | 20 | 32 | 33 |

    {{ result.title }}

    34 |

    {{ result.snippet|raw }}

    35 |
    36 |
  • 37 | -------------------------------------------------------------------------------- /templates/search.twig: -------------------------------------------------------------------------------- 1 | {% extends "layout.twig" %} 2 | 3 | {% block title %}{{ page_title }}{% endblock %} 4 | 5 | {% block content %} 6 |
    7 |
    8 | 20 | 21 |

    22 | {% if result_count > 0 %} 23 | {{ result_count }} results for {{ search_query }} 24 | {% elseif search_query %} 25 | No results for {{ search_query }} 26 | {% else %} 27 | Search the documentation 28 | {% endif %} 29 |

    30 | 31 |

    32 | 33 | 34 | {% if versions %} 35 | Search in other versions: 36 | {% for version in versions %} 37 | {{ version.title }} 38 | {% endfor %} 39 | — 40 | {% endif %} 41 | Didn't find what you were looking for? Please 42 | report an issue. 44 | 45 | 46 |

    47 |
    48 | 49 |
    50 | 51 |
    52 | 53 |
    54 | 55 | 56 | 59 |
    60 | 61 | {% if search_query %} 62 |
    63 |

    64 | Search took {{ timing }}ms 65 |

    66 | 67 | {% if ignored_terms %} 68 |

    69 | {% if ignored_terms|length > 1 %} 70 | The words "{{ ignored_terms|join('", "') }}" are ignored because they are too short or common stopwords. 71 | {% else %} 72 | "{{ ignored_terms|join('') }}" is ignored because it is too short or a common stopword. 73 | {% endif %} 74 |

    75 | {% endif %} 76 | 77 | {% if fuzzy_terms %} 78 |

    79 | Using fuzzy matching. Wrap words in "double" "quotes" to find exact matches. 80 |

    81 | {% endif %} 82 | 83 |
    84 | 85 | {% if result_count > 0 %} 86 |
      87 | {% for result in results %} 88 | {% include "partials/search_result.twig" with {result: result} %} 89 | {% endfor %} 90 |
    91 | 92 | {% if pagination|length > 1 %} 93 |
    94 | 101 |
    102 | {% endif %} 103 | {% elseif search_query %} 104 | 105 |

    106 | No results found for your search. :( 107 |

    108 | {% endif %} 109 | 110 |
    111 |
    112 | See terms used for this search 113 | {% if exact_terms %} 114 |

    Exact terms (100% weight): {{ exact_terms|join(' || ') }}

    115 | {% endif %} 116 | {% if fuzzy_terms %} 117 |

    Fuzzy terms (75% weight): {{ fuzzy_terms|join(' || ') }}

    118 | {% endif %} 119 |
    120 |
    121 | {% else %} 122 |

    Please enter a search term. Wrap terms in "double" "quotes" to only include exact matches.

    123 | {% endif %} 124 |
    125 |
    126 | 127 |
    128 | {% endblock %} 129 | -------------------------------------------------------------------------------- /templates/search_ajax.twig: -------------------------------------------------------------------------------- 1 |
    2 | {% if result_count > 0 %} 3 | 24 | 25 | 30 | {% elseif search_query %} 31 |

    32 | No results found for your search. :( 33 | 34 | 35 | View search details » 36 | 37 |

    38 | {% endif %} 39 |
    40 | -------------------------------------------------------------------------------- /templates/stats/not-found-requests.twig: -------------------------------------------------------------------------------- 1 | {% extends "layout.twig" %} 2 | 3 | {% block title %}{{ page_title }}{% endblock %} 4 | 5 | {% block content %} 6 |
    7 |
    8 | 20 | 21 |

    22 | {{ page_title }} 23 |

    24 |
    25 | 26 |
    27 |
    28 | 29 |

    30 | This page lists requests made on the documentation site that ended up serving a 404 page not found. 31 |

    32 | 33 |
    34 | The requests shown are unfiltered. It's easy to manipulate these listings, or for inappropriate results to be shown. They're meant to be informational only. 35 |
    36 | 37 |

    Top 50 most common requests

    38 |

    Commonly occurring 404 requests. Cached for 12 hours.

    39 |
      40 | {% for request in top_requests %} 41 |
    • 42 | {{ request.url }} 43 | 44 | {{ request.hit_count|number_format }} request{% if request.hit_count > 1 %}s{% endif %} 45 | - 46 | Last seen {{ request.last_seen|date('Y-m-d H:i T') }} 47 | 48 |
    • 49 | {% endfor %} 50 |
    51 | 52 |

    Last 50 requests

    53 |

    50 most recent requests that resulted in a page not found error. Cached for 3 hours.

    54 |
      55 | {% for request in recent_requests %} 56 |
    • 57 | {{ request.url }} 58 | 59 | {{ request.hit_count|number_format }} request{% if request.hit_count > 1 %}s{% endif %} 60 | - 61 | Last seen {{ request.last_seen|date('Y-m-d H:i T') }} 62 | 63 |
    • 64 | {% endfor %} 65 |
    66 |
    67 |
    68 | 69 |
    70 | {% endblock %} 71 | -------------------------------------------------------------------------------- /templates/stats/searches.twig: -------------------------------------------------------------------------------- 1 | {% extends "layout.twig" %} 2 | 3 | {% block title %}{{ page_title }}{% endblock %} 4 | 5 | {% block content %} 6 |
    7 |
    8 | 20 | 21 |

    22 | {{ page_title }} 23 |

    24 |
    25 | 26 |
    27 |
    28 | 29 |

    30 | This page lists searches made on the documentation site. External searches are not shown. 31 |

    32 | 33 |
    34 | The searches shown are unfiltered. It's easy to manipulate these listings, or for inappropriate results to be shown. They're meant to be informational only. 35 |
    36 | 37 |

    Top 50 most searched

    38 |

    Search queries that were seen most often. Cached for 48 hours.

    39 |
      40 | {% for term in top_searches %} 41 |
    • 42 | {{ term.search_query }} 43 | 44 | {{ term.search_count|number_format }} search{% if term.search_count > 1 %}es{% endif %} - 45 | {{ term.result_count|number_format }} result{% if term.result_count > 1 %}s{% endif %} 46 | 47 |
    • 48 | {% endfor %} 49 |
    50 |

    Top 50 without results

    51 |

    Search queries with no, or limited results. Ordered by number of results, and then number of searches. Cached for 48 hours.

    52 |
      53 | {% for term in searches_without_results %} 54 |
    • 55 | {{ term.search_query }} 56 | 57 | {{ term.search_count|number_format }} search{% if term.search_count > 1 %}es{% endif %} - 58 | {{ term.result_count|number_format }} result{% if term.result_count > 1 %}s{% endif %} 59 | 60 |
    • 61 | {% endfor %} 62 |
    63 | 64 |

    Last 50 searches

    65 |

    50 most recent searches. Cached for 3 hours.

    66 |
      67 | {% for term in recent_searches %} 68 |
    • 69 | [{{ term.last_seen|date('H:i T') }}] 70 | {{ term.search_query }} 71 | 72 | {{ term.search_count|number_format }} search{% if term.search_count > 1 %}es{% endif %} - 73 | {{ term.result_count|number_format }} result{% if term.result_count > 1 %}s{% endif %} 74 | 75 |
    • 76 | {% endfor %} 77 |
    78 |
    79 |
    80 | 81 |
    82 | {% endblock %} 83 | -------------------------------------------------------------------------------- /tests/BaseTestCase.php: -------------------------------------------------------------------------------- 1 | $requestMethod, 42 | 'REQUEST_URI' => $requestUri 43 | ] 44 | ); 45 | 46 | // Set up a request object based on the environment 47 | $request = Request::createFromEnvironment($environment); 48 | 49 | // Add request data, if it exists 50 | if ($requestData !== null) { 51 | $request = $request->withParsedBody($requestData); 52 | } 53 | 54 | // Set up a response object 55 | $response = new Response(); 56 | // Process the application 57 | $response = $app->process($request, $response); 58 | 59 | // Return the response 60 | return $response; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /tests/Functional/DocTest.php: -------------------------------------------------------------------------------- 1 | runApp('GET', '/2.x/en/getting-started'); 13 | 14 | $this->assertEquals(200, $response->getStatusCode()); 15 | $this->assertContains('Welcome to MODX Revolution', (string)$response->getBody()); 16 | $this->assertNotContains('WordPress', (string)$response->getBody()); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tests/Functional/HomepageTest.php: -------------------------------------------------------------------------------- 1 | runApp('GET', '/'); 16 | 17 | $this->assertEquals(200, $response->getStatusCode()); 18 | $this->assertContains('Creative Freedom', (string)$response->getBody()); 19 | $this->assertNotContains('Hello', (string)$response->getBody()); 20 | } 21 | 22 | /** 23 | * Test that the index route won't accept a post request 24 | */ 25 | public function testPostHomepageNotAllowed() : void 26 | { 27 | $response = $this->runApp('POST', '/', ['test']); 28 | 29 | $this->assertEquals(405, $response->getStatusCode()); 30 | $this->assertContains('Method not allowed', (string)$response->getBody()); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | getSlimConfig()); 10 | -------------------------------------------------------------------------------- /update-cron.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | if [ -f /www/.update-sources ]; then 4 | rm /www/.update-sources 5 | echo "Updating sources" 6 | cd /www/ 7 | php docs.php sources:update 8 | fi 9 | --------------------------------------------------------------------------------