]> git.bts.cx Git - garden.git/blob - garden.php
Added template fallback support
[garden.git] / garden.php
1 <?php
2
3 /*
4    _____               _               _____ _ _          _____                           _             
5   / ____|             | |             / ____(_) |        / ____|                         | |            
6  | |  __  __ _ _ __ __| | ___ _ __   | (___  _| |_ ___  | |  __  ___ _ __   ___ _ __ __ _| |_ ___  _ __ 
7  | | |_ |/ _` | '__/ _` |/ _ \ '_ \   \___ \| | __/ _ \ | | |_ |/ _ \ '_ \ / _ \ '__/ _` | __/ _ \| '__|
8  | |__| | (_| | | | (_| |  __/ | | |  ____) | | ||  __/ | |__| |  __/ | | |  __/ | | (_| | || (_) | |   
9   \_____|\__,_|_|  \__,_|\___|_| |_| |_____/|_|\__\___|  \_____|\___|_| |_|\___|_|  \__,_|\__\___/|_|
10
11  */
12
13 ///////////////////////////////////////////////////////////////////////////////
14 // Logging
15 ///////////////////////////////////////////////////////////////////////////////
16
17 function output_debug(...$str_segments) {
18         $str = join('', $str_segments);
19         echo($str . PHP_EOL);
20 }
21
22 function output_error(...$str_segments) {
23         $str = join('', $str_segments);
24         die($str);
25 }
26
27 ///////////////////////////////////////////////////////////////////////////////
28 // Paths
29 ///////////////////////////////////////////////////////////////////////////////
30
31 function garden_path(...$path_segments) {
32         $segments = array();
33         foreach ($path_segments as $path_segment) {
34                 $inner_segments = explode(DIRECTORY_SEPARATOR, $path_segment);
35                 foreach ($inner_segments as $inner_segment) {
36                         if ($inner_segment != '') {
37                                 $segments[] = $inner_segment;
38                         }
39                 }
40         }
41         array_unshift($segments, '');
42         return join(DIRECTORY_SEPARATOR, $segments);
43 }
44
45 ///////////////////////////////////////////////////////////////////////////////
46 // String
47 ///////////////////////////////////////////////////////////////////////////////
48
49 function garden_slug($str) {
50         $str = preg_replace('/\s+/', '-', $str);
51         $str = preg_replace('/\-+/', '-', $str);
52         $str = preg_replace('/[^\w\s\-\.]+/', '', $str);
53         $str = strtolower($str);
54         return $str;
55 }
56
57 function garden_url(...$url_segments) {
58         $segments = array();
59         foreach ($url_segments as $url_segment) {
60                 $inner_segments = explode(DIRECTORY_SEPARATOR, $url_segment);
61                 foreach ($inner_segments as $inner_segment) {
62                         if ($inner_segment != '') {
63                                 $segments[] = garden_slug($inner_segment);
64                         }
65                 }
66         }
67         array_unshift($segments, '');
68         return join(DIRECTORY_SEPARATOR, $segments);
69 }
70
71 ///////////////////////////////////////////////////////////////////////////////
72 // I/O
73 ///////////////////////////////////////////////////////////////////////////////
74
75 function garden_read_file($filename) {
76         return file_get_contents($filename);
77 }
78
79 function garden_write_file($filename, $content) {
80         $utf8_bom = "\xEF\xBB\xBF";
81         file_put_contents($filename, $utf8_bom . $content);
82 }
83
84 ///////////////////////////////////////////////////////////////////////////////
85 // Items to process
86 ///////////////////////////////////////////////////////////////////////////////
87
88 enum GardenItemType {
89         case Article;
90         case Image;
91         case Raw;
92 }
93
94 class GardenItem {
95         public $path;
96         public $type;
97         public $id;
98         public $title;
99         public $category;
100         public $url;
101         public $date;
102         public $source_filename;
103         public $target_filename;
104
105         public function __construct($path, $type, $id, $title, $category, $url, $date, $source_filename, $target_filename) {
106                 $this->path = $path;
107                 $this->type = $type;
108                 $this->id = $id;
109                 $this->title = $title;
110                 $this->category = $category;
111                 $this->url = $url;
112                 $this->date = $date;
113                 $this->source_filename = $source_filename;
114                 $this->target_filename = $target_filename;
115         }
116 }
117
118 function garden_make_process_items($output_directory, $content_paths) {
119         $output_items = [];
120
121         foreach ($content_paths as $item) {
122                 $id = garden_slug($item->filename);
123
124                 $target_extension = $item->extension;
125
126                 $ignore_file = false;
127                 $type = GardenItemType::Raw;
128                 switch ($item->extension) {
129                         case 'php':
130                                 $ignore_file = true;
131                                 break;
132
133                         case 'md':
134                                 $type = GardenItemType::Article;
135                                 $target_extension = 'html';
136                                 break;
137
138                         case 'png':
139                         case 'jpg':
140                         case 'jpeg':
141                         case 'gif':
142                                 $type = GardenItemType::Image;
143                                 break;
144                 }
145
146                 if ($ignore_file == true) {
147                         continue;
148                 }
149
150                 $target_basename = $item->filename;
151                 if ($target_extension != '') {
152                         $target_basename .= '.' . $target_extension;
153                 }
154
155                 $category_components = explode(DIRECTORY_SEPARATOR, $item->path);
156                 $category = count($category_components) >= 2 ? $category_components[1] : "";
157
158                 $url = GARDEN_SITE_BASE_URL . garden_url($item->path, $target_basename);
159                 $target_path = garden_path($output_directory, garden_url($item->path, $target_basename));
160
161                 $date = filemtime($item->full_path);
162
163                 $output_items[] = new GardenItem($item, $type, $id, $item->filename, $category, $url, $date, $item->full_path, $target_path);
164         }
165
166         return $output_items;
167 }
168
169 ///////////////////////////////////////////////////////////////////////////////
170 // Paths
171 ///////////////////////////////////////////////////////////////////////////////
172
173 class GardenContentPath {
174         public $full_path;
175         public $root;
176         public $path;
177         public $basename;
178         public $filename;
179         public $extension;
180
181         public function __construct($full_path, $root, $path, $path_parts) {
182                 $this->full_path = $full_path;
183                 $this->root = $root;
184                 $this->path = $path;
185                 $this->basename = $path_parts['basename'];
186                 $this->filename = $path_parts['filename'];
187                 $this->extension = $path_parts['extension'];
188         }
189 }
190
191 function garden_find_content_files($content_dir) {
192         $content_dir = realpath($content_dir);
193
194         $scan_paths = [''];
195         $output_paths = [];
196
197         while (count($scan_paths) > 0) {
198                 $scan_path = array_shift($scan_paths);
199                 $path_contents = scandir(garden_path($content_dir, $scan_path));
200                 foreach ($path_contents as $item) {
201                         if (str_starts_with($item, '.')) {
202                                 continue;
203                         }
204
205                         $full_path = garden_path($content_dir, $scan_path, $item);
206                         if (is_dir($full_path)) {
207                                 $scan_paths[] = garden_path($scan_path, $item);
208                                 continue;
209                         }
210
211                         $path_parts = pathinfo($full_path);
212                         $output_paths[] = new GardenContentPath($full_path, $content_dir, $scan_path, $path_parts);
213                 }
214         }
215
216         return $output_paths;
217 }
218
219 ///////////////////////////////////////////////////////////////////////////////
220 // Directories
221 ///////////////////////////////////////////////////////////////////////////////
222
223 function garden_make_directories($content_items) {
224         foreach ($content_items as $item) {
225                 $path_parts = pathinfo($item->target_filename);
226
227                 $directory = $path_parts['dirname'];
228                 if (is_dir($directory ) == false) {
229                         mkdir($directory , 0777, true); // FIXME, permissions...
230                 }
231         }
232
233         return $content_items;
234 }
235
236 function garden_move_raw($content_items) {
237         foreach ($content_items as $item) {
238                 if ($item->type != GardenItemType::Raw && $item->type != GardenItemType::Image) { // FIXME, do we need to copy images?
239                         continue;
240                 }
241
242                 $success = copy($item->source_filename, $item->target_filename);
243                 if ($success != true) {
244                         error('Failed to copy file: filename="', $item->source_filename, '"');
245                 }
246         }
247
248         return $content_items;
249 }
250
251 ///////////////////////////////////////////////////////////////////////////////
252 // Template
253 ///////////////////////////////////////////////////////////////////////////////
254
255 function garden_template_render($name, $variables = null) {
256         global $garden_template_base, $garden_template_content;
257         
258         $base_template = null;
259
260         $output = '';
261         while ($name != null) {
262                 $path = garden_path(GARDEN_TEMPLATE_DIR, $name . '.php');
263
264                 if (file_exists($path) == false) {
265                         $path = garden_path(GARDEN_FALLBACK_TEMPLATE_DIR, $name . '.php');
266                 }
267
268                 $base_template = null;
269                 $base_template_variables = null;
270
271                 $garden_template_base_previous = $garden_template_base;
272                 $garden_template_base = function($name, $base_variables) use (&$base_template, &$base_template_variables) {
273                         $base_template = $name;
274                         $base_template_variables = $base_variables;
275                 };
276
277                 $garden_template_content_previous = $garden_template_content;
278                 $garden_template_content = function() use ($output) {
279                         return $output;
280                 };
281
282                 if ($variables != null) {
283                         extract($variables);
284                 }
285
286                 ob_start();
287                 include($path);
288                 $output = ob_get_contents();
289                 ob_end_clean();
290
291                 $garden_template_base = $garden_template_base_previous;
292                 $garden_template_content = $garden_template_content_previous;
293
294                 $name = $base_template;
295                 $variables = $base_template_variables;
296         }
297
298         return $output;
299 }
300
301 ///////////////////////////////////////////////////////////////////////////////
302 // Template helpers
303 ///////////////////////////////////////////////////////////////////////////////
304
305 function garden_template_base($name, $variables = null) {
306         global $garden_template_base;
307         $garden_template_base($name, $variables);
308 }
309
310 function garden_template_content() {
311         global $garden_template_content;
312         return $garden_template_content();
313 }
314
315 function garden_site_url(...$url_segments) {
316         return GARDEN_SITE_BASE_URL . garden_url(...$url_segments);
317 }
318
319 ///////////////////////////////////////////////////////////////////////////////
320 // Images
321 ///////////////////////////////////////////////////////////////////////////////
322
323 class GardenImage {
324         public $content_item;
325         public $width;
326         public $height;
327         public $url;
328         public $target_filename;
329         
330         public function __construct($content_item, $width, $height, $url, $target_filename) {
331                 $this->content_item = $content_item;
332                 $this->width = $width;
333                 $this->height = $height;
334                 $this->url = $url;
335                 $this->target_filename = $target_filename;
336         }
337 }
338
339 function garden_make_images($post_process_items) {
340         foreach ($post_process_items as $item) {
341                 $image = new Imagick($item->content_item->source_filename);
342                 $image->thumbnailImage($item->width, $item->height);
343                 $image->writeImage($item->target_filename);
344         }
345 }
346
347 ///////////////////////////////////////////////////////////////////////////////
348 // HTML
349 ///////////////////////////////////////////////////////////////////////////////
350
351 require_once(garden_path(__DIR__, 'third_party', 'parsedown', 'Parsedown.php'));
352
353 class GardenExtendedParsedown extends Parsedown {
354         public $post_process_items;
355         private $output_directory;
356         private $content_items;
357
358         public function __construct($output_directory, $content_items) {
359                 $this->post_process_items = [];
360                 $this->output_directory = $output_directory;
361                 $this->content_items = $content_items;
362                 $this->InlineTypes['!'][] = 'Youtube';
363                 $this->InlineTypes['!'][] = 'Image';
364                 $this->InlineTypes['['][] = 'WikiLinks';
365                 $this->inlineMarkerList .= '!';
366                 $this->inlineMarkerList .= '[';
367         }
368
369         protected function inlineImage($excerpt) {
370                 if (preg_match('/^!\[\[([^\|\]]+)(\|([0-9]+)x([0-9]+))?\]\]/', $excerpt['text'], $matches)) {
371                         $image_name = $matches[1];
372                         $image_width = count($matches) == 5 ? $matches[3] : null;
373                         $image_height = count($matches) == 5 ? $matches[4] : null;
374
375                         if ($image_name == null) {
376                                 return;
377                         }
378
379                         $image_content_item = null;
380                         foreach ($this->content_items as $content_item) {
381                                 if ($content_item->path->basename == $image_name) {
382                                         $image_content_item = $content_item;
383                                         break;
384                                 }
385                         }
386
387                         if ($image_content_item == null) {
388                                 return;
389                         }
390
391                         $target_url = $image_content_item->url;
392                         if ($image_width != null && $image_height != null) {
393                                 $original_path = $image_content_item->path;
394
395                                 $target_basename = $original_path->filename . '_' . $image_width . 'x' . $image_height . '.png';
396
397                                 $target_url = GARDEN_SITE_BASE_URL . garden_url($original_path->path, $target_basename);
398                                 $target_path = garden_path($this->output_directory, garden_url($original_path->path, $target_basename));
399
400                                 $post_process_item = new GardenImage($image_content_item, $image_width, $image_height, $target_url, $target_path);
401                                 $this->post_process_items[] = $post_process_item;
402                         }
403
404                         return array(
405                                 'extent' => strlen($matches[0]), 
406                                 'element' => array(
407                                         'name' => 'img',
408                                         'attributes' => array(
409                                                 'src' => $target_url,
410                                         ),
411                                 ),
412                         );
413                 }
414         }
415
416         protected function inlineYoutube($excerpt) {
417                 if (preg_match('/^!\[\[yt:([^\]]+)\]\]/', $excerpt['text'], $matches)) {
418                         $video_id = $matches[1];
419
420                         if ($video_id == null) {
421                                 return;
422                         }
423
424                         return array(
425                                 'extent' => strlen($matches[0]), 
426                                 'element' => array(
427                                         'name' => 'iframe',
428                                         'text' => '',
429                                         'attributes' => array(
430                                                 'class' => "video",
431                                                 'type' => "text/html",
432                                                 //'width' => "640",
433                                                 //'height' => "360",
434                                                 'src' => "https://www.youtube.com/embed/" . $video_id,
435                                                 'frameborder' => "0",
436                                                 'loading' => "lazy",
437                                                 'referrerpolicy' => "no-referrer",
438                                                 'sandbox' => "allow-same-origin allow-scripts",
439                                         ),
440                                 ),
441                         );
442                 }
443         }
444
445         protected function inlineWikiLinks($excerpt) {
446                 if (preg_match('/^\[\[(.+)\]\]/', $excerpt['text'], $matches)) {
447                         $target_title = $matches[1];
448
449                         if ($target_title == null) {
450                                 return;
451                         }
452
453                         $target_url = null;
454                         foreach ($this->content_items as $content_item) {
455                                 if ($content_item->title == $target_title) {
456                                         $target_url = $content_item->url;
457                                         break;
458                                 }
459                         }
460
461                         if ($target_url == null) {
462                                 return;
463                         }
464
465                         return array(
466                                 'extent' => strlen($matches[0]), 
467                                 'element' => array(
468                                         'name' => 'a',
469                                         'text' => $target_title,
470                                         'attributes' => array(
471                                                 'href' => $target_url,
472                                         ),
473                                 ),
474                         );
475                 }
476         }
477 }
478
479 function garden_generate_html($output_directory, $content_items) {
480         $output_items = [];
481
482         $categorised_items = [];
483         foreach ($content_items as $item) {
484                 if ($item->type != GardenItemType::Article) {
485                         continue;
486                 }
487
488                 if (isset($categorised_items[$item->category]) == false) {
489                         $categorised_items[$item->category] = [];
490                 }
491                 $categorised_items[$item->category][] = $item;
492         }
493
494         foreach ($content_items as $item) {
495                 if ($item->type != GardenItemType::Article) {
496                         continue;
497                 }
498
499                 $markdown_data = garden_read_file($item->source_filename);
500
501                 $parsedown = new GardenExtendedParsedown($output_directory, $content_items);
502                 $markdown_html = $parsedown->text($markdown_data);
503
504                 if (count($parsedown->post_process_items) > 0) {
505                         array_push($output_items, ...$parsedown->post_process_items);
506                 }
507
508                 $variables = GARDEN_TEMPLATE_CONSTANTS; // PHP will copy by default!
509                 $variables['article'] =  $item;
510                 $variables['article_content'] =  $markdown_html;
511                 $variables['categories'] =  $categorised_items;
512
513                 $html_data = garden_template_render('article', $variables);
514
515                 garden_write_file($item->target_filename, $html_data);
516         }
517
518         return $output_items;
519 }
520
521 ///////////////////////////////////////////////////////////////////////////////
522 // Feed
523 ///////////////////////////////////////////////////////////////////////////////
524
525 function garden_generate_atom($output_directory, $output_filename, $content_items) {
526         $recent_items = [];
527         foreach ($content_items as $item) {
528                 if ($item->type != GardenItemType::Article) {
529                         continue;
530                 }
531
532                 $recent_items[$item->date] = $item;
533         }
534
535         // Sort by date...
536         ksort($recent_items);
537         $recent_items = array_reverse($recent_items);
538
539         $variables = GARDEN_TEMPLATE_CONSTANTS; // PHP will copy by default!
540         $variables['recent_items'] =  $recent_items;
541         $html_data = garden_template_render('atom', $variables);
542
543         garden_write_file(garden_path($output_directory, $output_filename), $html_data);
544 }
545
546 function garden_generate_json($output_directory, $output_filename, $content_items) {
547         $recent_items = [];
548         foreach ($content_items as $item) {
549                 if ($item->type != GardenItemType::Article) {
550                         continue;
551                 }
552
553                 $recent_items[$item->date] = $item;
554         }
555
556         // Sort by date...
557         ksort($recent_items);
558         $recent_items = array_reverse($recent_items);
559
560         $feed = [];
561         $feed['version'] = 'https://jsonfeed.org/version/1.1';
562         $feed['title'] = GARDEN_TEMPLATE_CONSTANTS['site_name'];
563         $feed['home_page_url'] = garden_site_url('/');
564         $feed['feed_url'] = garden_site_url($output_filename);
565         $feed['items'] = [];
566
567         foreach ($recent_items as $item) {
568                 $feed_item = [];
569                 $feed_item['id'] = $item->date + hexdec(hash('crc32', $item->title));
570                 $feed_item['date_published'] = date(DATE_RFC3339, $item->date);
571                 $feed_item['title'] = $item->title;
572                 $feed_item['content_text'] = 'An update was published.';
573                 $feed_item['url'] = $item->url;
574                 $feed['items'][] = $feed_item;
575         }
576
577         $json_data = json_encode($feed);
578         garden_write_file(garden_path($output_directory, $output_filename), $json_data);
579 }
580
581 ///////////////////////////////////////////////////////////////////////////////
582 // Main
583 ///////////////////////////////////////////////////////////////////////////////
584
585 function garden() {
586         $content_files = garden_find_content_files(GARDEN_CONTENT_DIR);
587         array_push($content_files, ...garden_find_content_files(GARDEN_TEMPLATE_DIR));
588         $process_items = garden_make_process_items(GARDEN_OUTPUT_DIR, $content_files);
589         garden_make_directories($process_items);
590         garden_move_raw($process_items);
591         $post_process_items = garden_generate_html(GARDEN_OUTPUT_DIR, $process_items);
592         garden_make_images($post_process_items);
593
594         if (defined('GARDEN_ATOM_FEED_FILENAME')) {
595                 garden_generate_atom(GARDEN_OUTPUT_DIR, GARDEN_ATOM_FEED_FILENAME, $process_items);
596         }
597
598         if (defined('GARDEN_JSON_FEED_FILENAME')) {
599                 garden_generate_json(GARDEN_OUTPUT_DIR, GARDEN_JSON_FEED_FILENAME, $process_items);
600         }
601 }
602
603 ///////////////////////////////////////////////////////////////////////////////
604 // Main code!
605 ///////////////////////////////////////////////////////////////////////////////
606
607 assert(extension_loaded('imagick'), 'Needs Imagick');
608 assert($argc >= 2, 'Please provide configuration file');
609
610 define('GARDEN_FALLBACK_TEMPLATE_DIR', garden_path(__DIR__, 'templates'));
611
612 // First parameter needs to be the configuration php
613 $config_file = $argv[1];
614
615 output_debug("Will use configuration: file='", $config_file, "'");
616 require_once($config_file);
617
618 garden();