├── ISSUE_TEMPLATE.md ├── LICENSE.txt ├── PULL_REQUEST_TEMPLATE.md ├── README.md ├── class.php ├── composer.json ├── example.gif ├── index.css ├── index.js ├── index.php └── media.class.php /ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ## Expected Behavior 5 | 6 | 7 | 8 | ## Current Behavior 9 | 10 | 11 | 12 | ## Possible Solution 13 | 14 | 15 | 16 | ## Steps to Reproduce 17 | 18 | 19 | 20 | - first ... 21 | - then ... 22 | - then ... 23 | 24 | ## Context 25 | 26 | 27 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 d4l data4life gGmbH 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ## Description 5 | 6 | 7 | 8 | ## Motivation 9 | 10 | 11 | 12 | ## Testing 13 | 14 | 15 | 16 | 17 | ## Related issue 18 | 19 | 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Kirby 3+ - Static Site Generator 2 | 3 | ![License](https://img.shields.io/github/license/mashape/apistatus.svg) ![Kirby Version](https://img.shields.io/badge/Kirby-3%2B-black.svg) 4 | 5 | With this plugin you can create a directory with assets, media and static html files generated from your pages. You can simply upload the generated files to any CDN and everything (with small exceptions, see below) will still work. The result is an even faster site with less potential vulnerabilities. 6 | 7 | ## Example 8 | 9 | ![static site generator field example](example.gif) 10 | 11 | ## What is Kirby? 12 | 13 | [Kirby](https://getkirby.com) is a highly [customizable](https://getkirby.com/docs/guide/blueprints/introduction) and [file-based](https://getkirby.com/docs/guide/database) CMS (content management system). Before using this plugin make sure you have [installed](https://getkirby.com/docs/guide/installation) the latest version of Kirby CMS and are familiar with the [plugin basics](https://getkirby.com/docs/guide/plugins/plugin-basics). 14 | 15 | ## How to install the plugin 16 | 17 | If you use composer, you can install the plugin with: `composer require d4l/kirby-static-site-generator` 18 | 19 | Alternatively, create a `static-site-generator` folder in `site/plugins`, download this repository and extract its contents into the new folder. 20 | 21 | ## What works 22 | 23 | - Compatibility with multilanguage sites 24 | - Translated URLs 25 | - Assets 26 | - Media (also when resized; files are automatically generated and copied when used) 27 | - Customizable base URL 28 | - Customizable paths to copy 29 | - Customizable output folder 30 | - Preserve individual files / folders in the output folder 31 | - Custom routes (click [here](#custom-routes) for more information) 32 | - Custom pages filtering (click [here](#custom-filters) for more information) 33 | 34 | ## What doesn't work 35 | 36 | - Dynamic routes (unless when called by custom route - click [here](#custom-routes) for more information) 37 | - Query parameters (unless processed by javascript) 38 | - Redirections / `die` or `exit` in the code (this also affects the compatibility with some other plugins) 39 | - Kirby paginations (only manual paginations via custom routes) 40 | - Directly opening the html files in the browser with the file protocol (absolute base url `/`) 41 | - Compatibility with other plugins that work with the `file::version` and `file::url` components 42 | 43 | ## How to use it 44 | 45 | ### 1) Directly (e.g. from a kirby hook) 46 | 47 | ```php 48 | $staticSiteGenerator = new D4L\StaticSiteGenerator($kirby, $pathsToCopy = null, $pages = null); 49 | $fileList = $staticSiteGenerator->generate($outputFolder = './static', $baseUrl = '/', $preserve = []); 50 | ``` 51 | 52 | - `$pathsToCopy`: if not given, `$kirby->roots()->assets()` is used; set to `[]` to skip copying other files than media 53 | - `$pages`: if not given, all pages are rendered 54 | - use `$preserve` to preserve individual files or folders in your output folder, e.g. if you want to preserve a `README.md` in your output folder, set `$preserve`to `['README.md']`; any files or folders directly in the root level and starting with `.` are always preserved (e.g. `.git`) 55 | - The `D4L\StaticSiteGenerator` class offers a couple of public methods that allow to make further configuration changes. 56 | 57 | ### 2) By triggering an endpoint 58 | 59 | To use this, adapt config option `d4l.static_site_generator.endpoint` to your needs (should be a string) 60 | 61 | ### 3) By using a `static-site-generator` field 62 | 63 | Do the same as for option 2) and then add a `staticSiteGenerator` type field to one of your blueprints: 64 | 65 | ```yaml 66 | fields: 67 | staticSiteGenerator: 68 | label: Generate a static version of the site 69 | # ... (see "Field options") 70 | ``` 71 | 72 | ## Available configuration options 73 | 74 | ```php 75 | return [ 76 | 'd4l' => [ 77 | 'static_site_generator' => [ 78 | 'endpoint' => null, # set to any string like 'generate-static-site' to use the built-in endpoint (necessary when using the blueprint field) 79 | 'output_folder' => './static', # you can specify an absolute or relative path 80 | 'preserve' => [], # preserve individual files / folders in the root level of the output folder (anything starting with "." is always preserved) 81 | 'base_url' => '/', # if the static site is not mounted to the root folder of your domain, change accordingly here 82 | 'skip_media' => false, # set to true to skip copying media files, e.g. when they are already on a CDN; combinable with 'preserve' => ['media'] 83 | 'skip_templates' => [], # ignore pages with given templates (home is always rendered) 84 | 'custom_routes' => [], # see below for more information on custom routes 85 | 'custom_filters' => [], # see below for more information on custom filters 86 | 'ignore_untranslated_pages' => false, # set to true to ignore pages without an own language 87 | 'index_file_name' => 'index.html' # you can change the directory index file name, e.g. to 'index.json' when generating an API 88 | ] 89 | ] 90 | ]; 91 | ``` 92 | 93 | All of these options are only relevant if you use implementation options 2) or 3). 94 | When directly using the `D4L\StaticSiteGenerator` class, no config options are required. 95 | In that case, options like `skip_media` can be achieved by calling `$staticSiteGenerator->skipMedia(true)`. 96 | 97 | ## Field options 98 | 99 | ```yaml 100 | label: Generate static site 101 | help: Custom help text 102 | progress: Custom please-wait message 103 | success: Custom success message 104 | error: Custom error message 105 | ``` 106 | 107 | ## Custom routes 108 | 109 | You can also use this plugin to render custom routes. This way, e.g. paginations can be created programmatically. 110 | 111 | Custom routes are passed as an array. Each item must contain at least a `path` property and if the path does not match a route, either the `page` or `route` property must be set. 112 | 113 | Here is an example array, showing the different configuration options: 114 | 115 | ```php 116 | $customRoutes = [ 117 | [ // minimal configuration to render a route (must match, else skipped) 118 | 'path' => 'my/route', 119 | ], 120 | [ // minimal configuration to render a page 121 | 'path' => 'foo/bar', 122 | 'page' => 'some-page-id' 123 | ], 124 | [ // advanced configuration to render a route (write to different path) 125 | 'path' => 'sitemap.xml', 126 | 'route' => 'my/sitemap/route' 127 | ], 128 | [ // advanced configuration to render a page 129 | 'path' => 'foo/baz', 130 | 'page' => page('some-page-id'), 131 | 'languageCode' => 'en', 132 | 'baseUrl' => '/custom-base-url/', 133 | 'data' => [ 134 | 'foo' => 'bar' 135 | ] 136 | ] 137 | ]; 138 | ``` 139 | 140 | Only `GET` routes without `language` scope are supported (you can of course add multiple custom routes for multiple languages). Patterns and action arguments are supported. 141 | 142 | `page` is provided as a string containing the page ID, or as a page object. 143 | 144 | If `languageCode` is not provided, the given page is rendered in the default language. 145 | 146 | If `baseUrl` is not provided, the default base url is taken. 147 | 148 | `path` may also end with a file name, in which case the given file is created instead of using the `/index.html` schema. 149 | 150 | To pass custom data to the controller or template, use `data`. [Click here](https://getkirby.com/docs/guide/templates/controllers#arguments-from-page-render-in-route) for more information how to use it. 151 | 152 | ⚠️ Have a look [here](https://getkirby.com/docs/reference/system/options/ready) in case you want to dynamically generate the custom routes based on a specific page or point to pages in the config. Kirby comes with a `ready` option for this purpose. 153 | 154 | ### There are two ways to define custom routes: 155 | 156 | #### 1) Directly, when using this plugin directly 157 | 158 | ```php 159 | $staticSiteGenerator->setCustomRoutes($customRoutes); 160 | ``` 161 | 162 | #### 2) Via configuration, when using the endpoint or `static-site-generator` field 163 | 164 | ```php 165 | 'd4l.static_site_generator.custom_routes' => $customRoutes 166 | ``` 167 | 168 | ## Custom filters 169 | 170 | When using the endpoint or `static-site-generator` field, this plugin will by default render all pages and subpages (using `pages()->index()`). 171 | You can filter the pages to be rendered by providing an array of custom filters in config option `custom_filters`. 172 | 173 | ```php 174 | 'd4l.static_site_generator.custom_filters' => $customFilters 175 | ``` 176 | 177 | Each element of this array must be an array of arguments accepted by [`$pages->filterBy()` method](https://getkirby.com/docs/cookbook/content/filtering). 178 | Here is an example array, showing some filters you could use (not exhaustive): 179 | 180 | ```php 181 | $customFilters = [ 182 | ['slug', '==', 'foo'], // will render page if its slug is exactly 'foo' 183 | ['url', '!*=', 'bar'], // will render page if its url doesn't contain 'bar' 184 | ['uri', '*', '/[1-9]/'], // will render page if its uri match regex '/[1-9]/' 185 | ['depth', '>', '2'], // will render page if its depth is greater than 2 186 | ['category', 'bar'], // will render page if its value in 'category' field is 'bar' ('category' being a single value field) 187 | ['tags', 'bar', ','], // will render page if its value in 'tags' field includes 'bar' ('tags' being a field accepting a comma-separated list of values) 188 | ['date', 'date >', '2018-01-01'], // will render page if its date is after '2018-01-01' 189 | ]; 190 | ``` 191 | 192 | ⚠️ Here again, you can use [Kirby's `ready` option](https://getkirby.com/docs/reference/system/options/ready) to dynamically generate the custom filters. 193 | 194 | ## Warnings 195 | 196 | Be careful when specifying the output folder, as the given path (except files starting with `.`) will be erased before the generation! There is a safety check in place to avoid accidental erasure when specifying existing, non-empty folders. 197 | 198 | ## Contribute 199 | 200 | Feedback and contributions are welcome! 201 | 202 | For commit messages we're following the [gitmoji](https://gitmoji.dev/) guide :smiley: 203 | Below you can find an example commit message for fixing a bug: 204 | :bug: fix copying of individual files 205 | 206 | Please post all bug reports in our issue tracker. 207 | We have prepared a template which will make it easier to describe the bug. 208 | -------------------------------------------------------------------------------- /class.php: -------------------------------------------------------------------------------- 1 | _kirby = $kirby; 38 | 39 | $this->_pathsToCopy = $pathsToCopy ?: [$kirby->roots()->assets()]; 40 | $this->_pathsToCopy = $this->_resolveRelativePaths($this->_pathsToCopy); 41 | $this->_outputFolder = $this->_resolveRelativePath('./static'); 42 | 43 | $this->_pages = $pages ?: $kirby->site()->index(); 44 | 45 | $this->_defaultLanguage = $kirby->languages()->default(); 46 | $this->_languages = $this->_defaultLanguage ? $kirby->languages()->keys() : [$this->_defaultLanguage]; 47 | } 48 | 49 | public function generate(string $outputFolder = './static', string $baseUrl = '/', array $preserve = []) 50 | { 51 | $this->_outputFolder = $this->_resolveRelativePath($outputFolder ?: $this->_outputFolder); 52 | $this->_checkOutputFolder(); 53 | F::write($this->_outputFolder . '/.kirbystatic', ''); 54 | 55 | $this->clearFolder($this->_outputFolder, $preserve); 56 | $this->generatePages($baseUrl); 57 | foreach ($this->_pathsToCopy as $pathToCopy) { 58 | $this->copyFiles($pathToCopy); 59 | } 60 | 61 | return $this->_fileList; 62 | } 63 | 64 | public function generatePages(string $baseUrl = '/') 65 | { 66 | $this->_setOriginalBaseUrl(); 67 | 68 | $baseUrl = rtrim($baseUrl, '/') . '/'; 69 | 70 | $copyMedia = !$this->_skipCopyingMedia; 71 | $copyMedia && StaticSiteGeneratorMedia::setActive(true); 72 | 73 | $homePage = $this->_pages->findBy('isHomePage', 'true'); 74 | if ($homePage) { 75 | $this->_setPageLanguage($homePage, $this->_defaultLanguage ? $this->_defaultLanguage->code() : null); 76 | $this->_generatePage($homePage, $this->_outputFolder . '/' . $this->_indexFileName, $baseUrl); 77 | } 78 | 79 | foreach ($this->_languages as $languageCode) { 80 | $this->_generatePagesByLanguage($baseUrl, $languageCode); 81 | } 82 | 83 | foreach ($this->_customRoutes as $route) { 84 | $this->_generateCustomRoute($baseUrl, $route); 85 | } 86 | 87 | if ($copyMedia) { 88 | $this->_copyMediaFiles(); 89 | 90 | StaticSiteGeneratorMedia::setActive(false); 91 | StaticSiteGeneratorMedia::clearList(); 92 | } 93 | 94 | $this->_restoreOriginalBaseUrl(); 95 | return $this->_fileList; 96 | } 97 | 98 | public function skipMedia($skipCopyingMedia = true) 99 | { 100 | $this->_skipCopyingMedia = $skipCopyingMedia; 101 | } 102 | 103 | public function setCustomRoutes(array $customRoutes) 104 | { 105 | $this->_customRoutes = $customRoutes; 106 | } 107 | 108 | public function setIgnoreUntranslatedPages(bool $ignoreUntranslatedPages) 109 | { 110 | $this->_ignoreUntranslatedPages = $ignoreUntranslatedPages; 111 | } 112 | 113 | public function setIndexFileName(string $indexFileName) 114 | { 115 | $indexFileName = preg_replace('/[^a-z0-9.]/i', '', $indexFileName); 116 | if (!preg_replace('/[.]/', '', $indexFileName)) { 117 | return; 118 | } 119 | 120 | $this->_indexFileName = $indexFileName; 121 | } 122 | 123 | protected function _setOriginalBaseUrl() 124 | { 125 | if (!$this->_kirby->urls()->base()) { 126 | $this->_modifyBaseUrl('https://d4l-ssg-base-url'); 127 | } 128 | 129 | $this->_originalBaseUrl = $this->_kirby->urls()->base(); 130 | } 131 | 132 | protected function _restoreOriginalBaseUrl() 133 | { 134 | if ($this->_originalBaseUrl === 'https://d4l-ssg-base-url') { 135 | $this->_modifyBaseUrl(''); 136 | } 137 | } 138 | 139 | protected function _modifyBaseUrl(string $baseUrl) 140 | { 141 | $urls = array_map(function ($url) use ($baseUrl) { 142 | $newUrl = $url === '/' ? $baseUrl : $baseUrl . $url; 143 | return strpos($url, 'http') === 0 ? $url : $newUrl; 144 | }, $this->_kirby->urls()->toArray()); 145 | $this->_kirby = $this->_kirby->clone(['urls' => $urls]); 146 | } 147 | 148 | protected function _generatePagesByLanguage(string $baseUrl, string $languageCode = null) 149 | { 150 | foreach ($this->_pages->keys() as $key) { 151 | $page = $this->_pages->$key; 152 | if ($this->_ignoreUntranslatedPages && !$page->translation($languageCode)->exists()) { 153 | continue; 154 | } 155 | 156 | $this->_setPageLanguage($page, $languageCode); 157 | $path = str_replace($this->_originalBaseUrl, '/', $page->url()); 158 | $path = $this->_cleanPath($this->_outputFolder . $path . '/' . $this->_indexFileName); 159 | try { 160 | $this->_generatePage($page, $path, $baseUrl); 161 | } catch (ErrorException $error) { 162 | $this->_handleRenderError($error, $key, $languageCode); 163 | } 164 | } 165 | } 166 | 167 | protected function _getRouteContent(string $routePath) 168 | { 169 | if (!$routePath) { 170 | return null; 171 | } 172 | 173 | $routeResult = kirby() 174 | ->router() 175 | ->call($routePath, 'GET'); 176 | 177 | if ($routeResult instanceof Page) { 178 | return $routeResult; 179 | } 180 | 181 | if ($routeResult instanceof \Kirby\Http\Response) { 182 | $routeResult = $routeResult->body(); 183 | } 184 | 185 | return is_string($routeResult) ? $routeResult : null; 186 | } 187 | 188 | protected function _generateCustomRoute(string $baseUrl, array $route) 189 | { 190 | $path = A::get($route, 'path'); 191 | $page = A::get($route, 'page'); 192 | $routePath = A::get($route, 'route'); 193 | $baseUrl = A::get($route, 'baseUrl', $baseUrl); 194 | $data = A::get($route, 'data', []); 195 | $languageCode = A::get($route, 'languageCode'); 196 | 197 | if (is_string($page)) { 198 | $page = page($page); 199 | } 200 | 201 | $routeContent = $page ? null : $this->_getRouteContent($routePath ?: $path); 202 | if ($routeContent instanceof Page) { 203 | $page = $routeContent; 204 | $routeContent = null; 205 | } 206 | 207 | if (!$path || (!$page && !$routeContent)) { 208 | return; 209 | } 210 | 211 | if (!$page) { 212 | $page = new Page(['slug' => 'static-site-generator/' . uniqid()]); 213 | } 214 | 215 | $path = $this->_cleanPath($this->_outputFolder . '/' . $path . '/' . $this->_indexFileName); 216 | $this->_setPageLanguage($page, $languageCode, false); 217 | $this->_generatePage($page, $path, $baseUrl, $data, $routeContent); 218 | } 219 | 220 | protected function _resetPage(Page|Site $page) { 221 | $page->content = null; 222 | 223 | foreach ($page->children() as $child) { 224 | $this->_resetPage($child); 225 | } 226 | 227 | foreach ($page->files() as $file) { 228 | $file->content = null; 229 | } 230 | } 231 | 232 | protected function _setPageLanguage(Page $page, string $languageCode = null, $forceReset = true) 233 | { 234 | $this->_resetCollections(); 235 | 236 | $kirby = $this->_kirby; 237 | $kirby->setCurrentTranslation($languageCode); 238 | $kirby->setCurrentLanguage($languageCode); 239 | 240 | $site = $kirby->site(); 241 | $this->_resetPage($site); 242 | 243 | if ($page->exists() || $forceReset) { 244 | $this->_resetPage($page); 245 | } 246 | 247 | $kirby->cache('pages')->flush(); 248 | $site->visit($page, $languageCode); 249 | } 250 | 251 | protected function _resetCollections() 252 | { 253 | (function () { 254 | $this->collections = null; 255 | })->bindTo($this->_kirby, 'Kirby\\Cms\\App')($this->_kirby); 256 | } 257 | 258 | protected function _generatePage(Page $page, string $path, string $baseUrl, array $data = [], string $content = null) 259 | { 260 | $page->setSite(null); 261 | $content = $content ?: $page->render($data); 262 | 263 | $jsonOriginalBaseUrl = trim(json_encode($this->_originalBaseUrl), '"'); 264 | $jsonBaseUrl = trim(json_encode($baseUrl), '"'); 265 | $content = str_replace($this->_originalBaseUrl . '/', $baseUrl, $content); 266 | $content = str_replace($this->_originalBaseUrl, $baseUrl, $content); 267 | $content = str_replace($jsonOriginalBaseUrl . '\\/', $jsonBaseUrl, $content); 268 | $content = str_replace($jsonOriginalBaseUrl, $jsonBaseUrl, $content); 269 | 270 | F::write($path, $content); 271 | 272 | $this->_fileList = array_unique(array_merge($this->_fileList, [$path])); 273 | } 274 | 275 | public function copyFiles(string $folder = null) 276 | { 277 | $outputFolder = $this->_outputFolder; 278 | 279 | if (!$folder || !file_exists($folder)) { 280 | return $this->_fileList; 281 | } 282 | 283 | $folderName = $this->_getFolderName($folder); 284 | $targetPath = $outputFolder . '/' . $folderName; 285 | 286 | if (is_file($folder)) { 287 | return $this->_copyFile($folder, $targetPath); 288 | } 289 | 290 | $this->clearFolder($targetPath); 291 | if (!Dir::copy($folder, $targetPath)) { 292 | return $this->_fileList; 293 | } 294 | 295 | $list = $this->_getFileList($targetPath, true); 296 | $this->_fileList = array_unique(array_merge($this->_fileList, $list)); 297 | return $this->_fileList; 298 | } 299 | 300 | protected function _copyMediaFiles() 301 | { 302 | $outputFolder = $this->_outputFolder; 303 | $mediaList = StaticSiteGeneratorMedia::getList(); 304 | 305 | foreach ($mediaList as $item) { 306 | $file = $item['root']; 307 | $path = str_replace($this->_originalBaseUrl, '/', $item['url']); 308 | $path = $this->_cleanPath($path); 309 | $path = $outputFolder . $path; 310 | $this->_copyFile($file, $path); 311 | } 312 | 313 | $this->_fileList = array_unique($this->_fileList); 314 | return $this->_fileList; 315 | } 316 | 317 | protected function _copyFile($file, $targetPath) 318 | { 319 | if (F::copy($file, $targetPath)) { 320 | $this->_fileList[] = $targetPath; 321 | } 322 | 323 | return $this->_fileList; 324 | } 325 | 326 | public function clearFolder(string $folder, array $preserve = []) 327 | { 328 | $folder = $this->_resolveRelativePath($folder); 329 | $items = $this->_getFileList($folder); 330 | return array_reduce( 331 | $items, 332 | function ($totalResult, $item) use ($preserve) { 333 | $folderName = $this->_getFolderName($item); 334 | if (in_array($folderName, $preserve)) { 335 | return $totalResult; 336 | } 337 | 338 | if (strpos($folderName, '.') === 0) { 339 | return $totalResult; 340 | } 341 | 342 | $result = is_dir($item) === false ? F::remove($item) : Dir::remove($item); 343 | return $totalResult && $result; 344 | }, 345 | true 346 | ); 347 | } 348 | 349 | protected function _getFolderName(string $folder) 350 | { 351 | $segments = explode(DIRECTORY_SEPARATOR, $folder); 352 | return array_pop($segments); 353 | } 354 | 355 | protected function _getFileList(string $path, bool $recursively = false) 356 | { 357 | $items = array_map(function ($item) { 358 | return str_replace('/', DIRECTORY_SEPARATOR, $item); 359 | }, Dir::read($path, [], true)); 360 | if (!$recursively) { 361 | return $items; 362 | } 363 | 364 | return array_reduce( 365 | $items, 366 | function ($list, $item) { 367 | if (is_dir($item)) { 368 | return array_merge($list, $this->_getFileList($item, true)); 369 | } 370 | 371 | return array_merge($list, [$item]); 372 | }, 373 | [] 374 | ) ?: 375 | []; 376 | } 377 | 378 | protected function _resolveRelativePaths(array $paths) 379 | { 380 | return array_values( 381 | array_filter( 382 | array_map(function ($path) { 383 | return $this->_resolveRelativePath($path); 384 | }, $paths) 385 | ) 386 | ); 387 | } 388 | 389 | protected function _resolveRelativePath(string $path = null) 390 | { 391 | if (!$path || strpos($path, '.') !== 0) { 392 | return realpath($path) ?: $path; 393 | } 394 | 395 | $path = $this->_kirby->roots()->index() . '/' . $path; 396 | return realpath($path) ?: $path; 397 | } 398 | 399 | protected function _cleanPath(string $path): string 400 | { 401 | $path = str_replace('//', '/', $path); 402 | $path = preg_replace('/([^\/]+\.[a-z]{2,5})\/' . $this->_indexFileName . '$/i', '$1', $path); 403 | $path = preg_replace('/(\.[^\/.]+)\/' . $this->_indexFileName . '$/i', '$1', $path); 404 | 405 | if (strpos($path, '//') !== false) { 406 | return $this->_cleanPath($path); 407 | } 408 | 409 | return $path; 410 | } 411 | 412 | protected function _checkOutputFolder() 413 | { 414 | $folder = $this->_outputFolder; 415 | if (!$folder) { 416 | throw new Error('Error: Please specify a valid output folder!'); 417 | } 418 | 419 | if (Dir::isEmpty($folder)) { 420 | return; 421 | } 422 | 423 | if (!Dir::isWritable($folder)) { 424 | throw new Error('Error: The output folder is not writable'); 425 | } 426 | 427 | $fileList = array_map(function ($path) use ($folder) { 428 | return str_replace($folder . DIRECTORY_SEPARATOR, '', $path); 429 | }, $this->_getFileList($folder)); 430 | 431 | if (in_array($this->_indexFileName, $fileList) || in_array('.kirbystatic', $fileList)) { 432 | return; 433 | } 434 | 435 | throw new Error( 436 | 'Hello! It seems the given output folder "' . 437 | $folder . 438 | '" already contains other files or folders. ' . 439 | 'Please specify a path that does not exist yet, or is empty. If it absolutely has to be this path, create ' . 440 | 'an empty .kirbystatic file and retry. WARNING: Any contents of the output folder not starting with "." ' . 441 | 'are erased before generation! Information on preserving individual files and folders can be found in the Readme.' 442 | ); 443 | } 444 | 445 | protected function _handleRenderError(ErrorException $error, string $key, string $languageCode = null) 446 | { 447 | $message = $error->getMessage(); 448 | $file = str_replace($this->_kirby->roots()->index(), '', $error->getFile()); 449 | $line = $error->getLine(); 450 | throw new Error( 451 | "Error in $file line $line while rendering page \"$key\"" . 452 | ($languageCode ? " ($languageCode)" : '') . 453 | ": $message" 454 | ); 455 | } 456 | } 457 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "d4l/kirby-static-site-generator", 3 | "version": "1.13.0", 4 | "type": "kirby-plugin", 5 | "description": "Static site generator plugin for Kirby 3+", 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "D4L data4life gGmbH", 10 | "email": "contact@data4life.care", 11 | "homepage": "https://www.data4life.care" 12 | } 13 | ], 14 | "config": { 15 | "optimize-autoloader": true 16 | }, 17 | "require": { 18 | "getkirby/composer-installer": "^1.1" 19 | }, 20 | "extra": { 21 | "installer-name": "static-site-generator" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d4l-data4life/kirby3-static-site-generator/60d39c0ceff7687165085c32fd87bfd5e43bb9df/example.gif -------------------------------------------------------------------------------- /index.css: -------------------------------------------------------------------------------- 1 | .d4l-static-site-generator__container { 2 | background: #fff; 3 | padding: 1.5rem; 4 | } 5 | 6 | .d4l-static-site-generator__help { 7 | margin-bottom: 1rem; 8 | } 9 | 10 | .d4l-static-site-generator .d4l-static-site-generator__status { 11 | padding: 1.5rem; 12 | } 13 | 14 | .d4l-static-site-generator__message { 15 | white-space: pre; 16 | overflow-x: auto; 17 | } 18 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | panel.plugin('d4l/static-site-generator', { 2 | fields: { 3 | staticSiteGenerator: { 4 | props: { 5 | label: String, 6 | endpoint: String, 7 | help: { 8 | type: String, 9 | default: 'Click the button to generate a static version of the website.', 10 | }, 11 | progress: { 12 | type: String, 13 | default: 'Please wait...', 14 | }, 15 | success: { 16 | type: String, 17 | default: 'Static site successfully generated', 18 | }, 19 | error: { 20 | type: String, 21 | default: 'An error occurred', 22 | }, 23 | }, 24 | data() { 25 | return { 26 | isBusy: false, 27 | response: null, 28 | }; 29 | }, 30 | template: ` 31 |
32 | 33 | 34 | 35 | {{ help.replace(/<\\/?p>/g, '') }} 36 | 37 | 38 | {{ label }} 39 | 40 | 41 | 42 | 43 | 44 | {{ progress }} 45 | 46 | 47 | {{ success }} 48 | {{ response.message }} 49 | 50 | 51 | {{ error }} 52 | {{ response.message }} 53 | 54 |
55 | `, 56 | methods: { 57 | async execute() { 58 | const { endpoint } = this.$props; 59 | if (!endpoint) { 60 | throw new Error('Error: Config option "d4l.static_site_generator.endpoint" is missing or null. Please set this to any string, e.g. "generate-static-site".'); 61 | } 62 | 63 | this.isBusy = true; 64 | const response = await this.$api.post(`${endpoint}`); 65 | this.isBusy = false; 66 | this.response = response; 67 | }, 68 | }, 69 | }, 70 | }, 71 | }); 72 | -------------------------------------------------------------------------------- /index.php: -------------------------------------------------------------------------------- 1 | [ 13 | 'routes' => function ($kirby) { 14 | $endpoint = $kirby->option('d4l.static_site_generator.endpoint'); 15 | if (!$endpoint) { 16 | return []; 17 | } 18 | 19 | return [ 20 | [ 21 | 'pattern' => $endpoint, 22 | 'action' => function () use ($kirby) { 23 | $outputFolder = $kirby->option('d4l.static_site_generator.output_folder', './static'); 24 | $baseUrl = $kirby->option('d4l.static_site_generator.base_url', '/'); 25 | $preserve = $kirby->option('d4l.static_site_generator.preserve', []); 26 | $skipMedia = $kirby->option('d4l.static_site_generator.skip_media', false); 27 | $skipTemplates = array_diff($kirby->option('d4l.static_site_generator.skip_templates', []), ['home']); 28 | $customRoutes = $kirby->option('d4l.static_site_generator.custom_routes', []); 29 | $customFilters = $kirby->option('d4l.static_site_generator.custom_filters', []); 30 | $ignoreUntranslatedPages = $kirby->option('d4l.static_site_generator.ignore_untranslated_pages', false); 31 | $indexFileName = $kirby->option('d4l.static_site_generator.index_file_name', 'index.html'); 32 | if (!empty($skipTemplates)) { 33 | array_push($customFilters, ['intendedTemplate', 'not in', $skipTemplates]); 34 | } 35 | 36 | $pages = $kirby->site()->index(); 37 | foreach ($customFilters as $filter) { 38 | $pages = $pages->filterBy(...$filter); 39 | } 40 | 41 | $staticSiteGenerator = new StaticSiteGenerator($kirby, null, $pages); 42 | $staticSiteGenerator->skipMedia($skipMedia); 43 | $staticSiteGenerator->setCustomRoutes($customRoutes); 44 | $staticSiteGenerator->setIgnoreUntranslatedPages($ignoreUntranslatedPages); 45 | $staticSiteGenerator->setIndexFileName($indexFileName); 46 | $list = $staticSiteGenerator->generate($outputFolder, $baseUrl, $preserve); 47 | $count = count($list); 48 | return ['success' => true, 'files' => $list, 'message' => "$count files generated / copied"]; 49 | }, 50 | 'method' => 'POST' 51 | ] 52 | ]; 53 | } 54 | ], 55 | 'fields' => [ 56 | 'staticSiteGenerator' => [ 57 | 'props' => [ 58 | 'endpoint' => function () { 59 | return $this->kirby()->option('d4l.static_site_generator.endpoint'); 60 | } 61 | ] 62 | ] 63 | ] 64 | ]); 65 | -------------------------------------------------------------------------------- /media.class.php: -------------------------------------------------------------------------------- 1 | component('file::version'); 8 | $urlFn = kirby()->component('file::url'); 9 | 10 | 11 | Kirby::plugin('d4l/static-site-generator-media', [ 12 | 'components' => [ 13 | 'file::version' => function (Kirby $kirby, $file, array $options = []) use ($versionFn) { 14 | $version = $versionFn($kirby, $file, $options); 15 | if ($mediaPlugin = $kirby->option('d4l.static_site_generator.media_plugin', null)) { 16 | $version = Kirby::plugin($mediaPlugin)->extends()['components']['file::version']($kirby, $file, $options); 17 | } 18 | 19 | if (!StaticSiteGeneratorMedia::isActive()) { 20 | return $version; 21 | } 22 | 23 | if (!$version->exists()) { 24 | $version->save(); 25 | } 26 | 27 | $url = $version->url(); 28 | if ($urlTransform = $kirby->option('d4l.static_site_generator.media_url_transform', null)) { 29 | $url = $urlTransform($url, $kirby); 30 | } 31 | 32 | StaticSiteGeneratorMedia::register($version->root(), $url); 33 | return $version; 34 | }, 35 | 'file::url' => function (Kirby $kirby, $file, array $options = []) use ($urlFn) { 36 | $url = $urlFn($kirby, $file, $options); 37 | $mediaPlugin = $kirby->option('d4l.static_site_generator.media_plugin', null); 38 | if ($mediaPlugin) { 39 | $url = Kirby::plugin($mediaPlugin)->extends()['components']['file::url']($kirby, $file, $options); 40 | } 41 | 42 | if (!StaticSiteGeneratorMedia::isActive()) { 43 | return $url; 44 | } 45 | 46 | if ($urlTransform = $kirby->option('d4l.static_site_generator.media_url_transform', null)) { 47 | $url = $urlTransform($url, $kirby); 48 | } 49 | 50 | StaticSiteGeneratorMedia::register($file->root(), $url); 51 | return $url; 52 | } 53 | ] 54 | ]); 55 | 56 | 57 | class StaticSiteGeneratorMedia 58 | { 59 | protected static $_instance; 60 | protected $_active = false; 61 | protected $_list = []; 62 | 63 | public static function getInstance() 64 | { 65 | if (!static::$_instance) { 66 | static::$_instance = new static(); 67 | } 68 | 69 | return static::$_instance; 70 | } 71 | 72 | public static function register($root, $url) 73 | { 74 | $instance = static::getInstance(); 75 | $item = [ 76 | 'root' => $root, 77 | 'url' => $url 78 | ]; 79 | 80 | if (in_array($item, $instance->_list)) { 81 | return; 82 | } 83 | 84 | $instance->_list[] = $item; 85 | } 86 | 87 | public static function getList() 88 | { 89 | $instance = static::getInstance(); 90 | return $instance->_list; 91 | } 92 | 93 | public static function clearList() 94 | { 95 | $instance = static::getInstance(); 96 | $instance->_list = []; 97 | } 98 | 99 | public static function isActive() 100 | { 101 | $instance = static::getInstance(); 102 | return $instance->_active; 103 | } 104 | 105 | public static function setActive(bool $active) 106 | { 107 | $instance = static::getInstance(); 108 | $instance->_active = $active; 109 | } 110 | } 111 | --------------------------------------------------------------------------------