nitiwiki: remove completely root_url as everything is now relative
[nit.git] / contrib / nitiwiki / src / wiki_base.nit
1 # This file is part of NIT ( http://www.nitlanguage.org ).
2 #
3 # Licensed under the Apache License, Version 2.0 (the "License");
4 # you may not use this file except in compliance with the License.
5 # You may obtain a copy of the License at
6 #
7 # http://www.apache.org/licenses/LICENSE-2.0
8 #
9 # Unless required by applicable law or agreed to in writing, software
10 # distributed under the License is distributed on an "AS IS" BASIS,
11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 # See the License for the specific language governing permissions and
13 # limitations under the License.
14
15 # Base entities of a nitiwiki.
16 module wiki_base
17
18 import template::macro
19 import opts
20 import ini
21
22 # A Nitiwiki instance.
23 #
24 # Nitiwiki provide all base services used by `WikiSection` and `WikiArticle`.
25 # It manages content and renders pages.
26 #
27 # Each nitiwiki instance is linked to a config file.
28 # This file show to `nitiwiki` that a wiki is present in the current directory.
29 # Without it, nitiwiki will consider the directory as empty.
30 class Nitiwiki
31
32 # Wiki config object.
33 var config: WikiConfig
34
35 # Default config filename.
36 var config_filename = "config.ini"
37
38 # Force render on all file even if the source is unmodified.
39 var force_render = false is writable
40
41 # Verbosity level.
42 var verbose_level = 0 is writable
43
44 # Delete all the output files.
45 fun clean do
46 var out_dir = expand_path(config.root_dir, config.out_dir)
47 if out_dir.file_exists then out_dir.rmdir
48 end
49
50 # Synchronize local output with the distant `WikiConfig::rsync_dir`.
51 fun sync do
52 var root = expand_path(config.root_dir, config.out_dir)
53 sys.system "rsync -vr --delete {root}/ {config.rsync_dir}"
54 end
55
56 # Pull data from git repository.
57 fun fetch do
58 sys.system "git pull {config.git_origin} {config.git_branch}"
59 end
60
61 # Analyze wiki files from `dir` to build wiki entries.
62 #
63 # This method build a hierarchical structure of `WikiSection` and `WikiArticle`
64 # based on the markdown source structure.
65 fun parse do
66 var dir = expand_path(config.root_dir, config.source_dir)
67 root_section = new_section(dir)
68 var files = list_md_files(dir)
69 for file in files do
70 new_article(file)
71 end
72 end
73
74 # Render output.
75 fun render do end
76
77 # Show wiki status.
78 fun status do
79 print "nitiWiki"
80 print "name: {config.wiki_name}"
81 print "config: {config.ini_file}"
82 print ""
83 if root_section.is_dirty then
84 print "There is modified files:"
85 var paths = entries.keys.to_a
86 var s = new DefaultComparator
87 s.sort(paths)
88 for path in paths do
89 var entry = entries[path]
90 if not entry.is_dirty then continue
91 var name = entry.name
92 if entry.has_source then name = entry.src_path.to_s
93 if entry.is_new then
94 print " + {name}"
95 else
96 print " * {name}"
97 end
98 end
99 print ""
100 print "Use nitiwiki --render to render modified files"
101 else
102 print "Wiki is up-to-date"
103 print ""
104 print "Use nitiwiki --fetch to pull modification from origin"
105 print "Use nitiwiki --rsync to synchronize distant output"
106 end
107 end
108
109 # Display msg if `level <= verbose_level`
110 fun message(msg: String, level: Int) do
111 if level <= verbose_level then print msg
112 end
113
114 # List markdown source files from a directory.
115 fun list_md_files(dir: String): Array[String] do
116 var files = new Array[String]
117 var pipe = new ProcessReader("find", dir, "-name", "*.{config.md_ext}")
118 while not pipe.eof do
119 var file = pipe.read_line
120 if file == "" then break # last line
121 var name = file.basename(".{config.md_ext}")
122 if name == "header" or name == "footer" or name == "menu" then continue
123 files.add file
124 end
125 pipe.close
126 pipe.wait
127 if pipe.status != 0 then exit 1
128 var s = new DefaultComparator
129 s.sort(files)
130 return files
131 end
132
133 # Does `src` have been modified since `target` creation?
134 #
135 # Always returns `true` if `--force` is on.
136 fun need_render(src, target: String): Bool do
137 if force_render then return true
138 if not target.file_exists then return true
139 return src.file_stat.mtime >= target.file_stat.mtime
140 end
141
142 # Create a new `WikiSection`.
143 #
144 # `path` is used to determine the place in the wiki hierarchy.
145 protected fun new_section(path: String): WikiSection do
146 path = path.simplify_path
147 if entries.has_key(path) then return entries[path].as(WikiSection)
148 var root = expand_path(config.root_dir, config.source_dir)
149 var name = path.basename("")
150 var section = new WikiSection(self, name)
151 entries[path] = section
152 if path == root then return section
153 var ppath = path.dirname
154 if ppath != path then
155 var parent = new_section(ppath)
156 parent.add_child(section)
157 end
158 section.try_load_config
159 return section
160 end
161
162 # Create a new `WikiArticle`.
163 #
164 # `path` is used to determine the ancestor sections.
165 protected fun new_article(path: String): WikiArticle do
166 if entries.has_key(path) then return entries[path].as(WikiArticle)
167 message("Found article `{path}`", 2)
168 var article = new WikiArticle.from_source(self, path)
169 var section = new_section(path.dirname)
170 section.add_child(article)
171 entries[path] = article
172 return article
173 end
174
175 # Wiki entries found in the last `lookup_hierarchy`.
176 var entries = new HashMap[String, WikiEntry]
177
178 # The root `WikiSection` of the site found in the last `lookup_hierarchy`.
179 var root_section: WikiSection is noinit
180
181 # Does a template named `name` exists for this wiki?
182 fun has_template(name: String): Bool do
183 return expand_path(config.root_dir, config.templates_dir, name).file_exists
184 end
185
186 # Load a template file as a `TemplateString`.
187 #
188 # REQUIRE: `has_template`
189 fun load_template(name: String): TemplateString do
190 if not has_template(name) then
191 message("Error: can't load template `{name}`", 0)
192 exit 1
193 end
194 var file = expand_path(config.root_dir, config.templates_dir, name)
195 var tpl = new TemplateString.from_file(file)
196 if tpl.has_macro("TITLE") then
197 tpl.replace("TITLE", config.wiki_name)
198 end
199 if tpl.has_macro("SUBTITLE") then
200 tpl.replace("SUBTITLE", config.wiki_desc)
201 end
202 if tpl.has_macro("LOGO") then
203 tpl.replace("LOGO", config.wiki_logo)
204 end
205 return tpl
206 end
207
208 # Does a sideblock named `name` exists for this wiki?
209 fun has_sideblock(name: String): Bool do
210 name = "{name}.{config.md_ext}"
211 return expand_path(config.root_dir, config.sidebar_dir, name).file_exists
212 end
213
214 # Load a markdown block with `name` from `WikiConfig::sidebar_dir`.
215 private fun load_sideblock(name: String): nullable String do
216 if not has_sideblock(name) then
217 message("Error: can't load sideblock `{name}`", 0)
218 return null
219 end
220 name = "{name}.{config.md_ext}"
221 var path = expand_path(config.root_dir, config.sidebar_dir, name)
222 var file = new FileReader.open(path)
223 var res = file.read_all
224 file.close
225 return res
226 end
227
228 # Join `parts` as a path and simplify it
229 fun expand_path(parts: String...): String do
230 var path = ""
231 for part in parts do
232 path = path.join_path(part)
233 end
234 return path.simplify_path
235 end
236
237 # Transform an id style name into a pretty printed name.
238 #
239 # Used to translate ids in beautiful page names.
240 fun pretty_name(name: String): String do
241 name = name.replace("_", " ")
242 name = name.capitalized
243 return name
244 end
245 end
246
247 # A wiki is composed of hierarchical entries.
248 abstract class WikiEntry
249
250 # `Nitiwiki` this entry belongs to.
251 var wiki: Nitiwiki
252
253 # Entry data
254
255 # Entry internal name.
256 #
257 # Mainly used in urls.
258 var name: String
259
260 # Displayed title for `self`.
261 #
262 # If `self` is the root entry then display the wiki `WikiConfig::wiki_name` instead.
263 fun title: String do
264 if is_root then return wiki.config.wiki_name
265 return wiki.pretty_name(name)
266 end
267
268 # Is this section rendered from a source document?
269 #
270 # Source is an abstract concept at this level.
271 # It can represent a directory, a source file,
272 # a part of a file, everything needed to
273 # extend this base framework.
274 fun has_source: Bool do return src_path != null
275
276 # Entry creation time.
277 #
278 # Returns `-1` if not `has_source`.
279 fun create_time: Int do
280 if not has_source then return -1
281 return src_full_path.file_stat.ctime
282 end
283
284 # Entry last modification time.
285 #
286 # Returns `-1` if not `has_source`.
287 fun last_edit_time: Int do
288 if not has_source then return -1
289 return src_full_path.file_stat.mtime
290 end
291
292 # Entry list rendering time.
293 #
294 # Returns `-1` if `is_new`.
295 fun last_render_time: Int do
296 if is_new then return -1
297 return out_full_path.file_stat.mtime
298 end
299
300 # Entries hierarchy
301
302 # Type of the parent entry.
303 type PARENT: WikiEntry
304
305 # Parent entry if any.
306 var parent: nullable PARENT = null
307
308 # Does `self` have a parent?
309 fun is_root: Bool do return parent == null
310
311 # Children labelled by `name`.
312 var children = new HashMap[String, WikiEntry]
313
314 # Does `self` have a child nammed `name`?
315 fun has_child(name: String): Bool do return children.keys.has(name)
316
317 # Retrieve the child called `name`.
318 fun child(name: String): WikiEntry do return children[name]
319
320 # Add a sub-entry to `self`.
321 fun add_child(entry: WikiEntry) do
322 entry.parent = self
323 children[entry.name] = entry
324 end
325
326 # Paths and urls
327
328 # Breadcrumbs from the `Nitiwiki::root_section` to `self`.
329 #
330 # Result is returned as an array containg ordered entries:
331 # `breadcrumbs.first` is the root entry and
332 # `breadcrumbs.last == self`
333 var breadcrumbs: Array[WikiEntry] is lazy do
334 var path = new Array[WikiEntry]
335 var entry: nullable WikiEntry = self
336 while entry != null and not entry.is_root do
337 path.add entry
338 entry = entry.parent
339 end
340 return path.reversed
341 end
342
343 # Sidebar relative to this wiki entry.
344 var sidebar = new WikiSidebar(self)
345
346 # Relative path from `wiki.config.root_dir` to source if any.
347 fun src_path: nullable String is abstract
348
349 # Absolute path to the source if any.
350 fun src_full_path: nullable String do
351 var src = src_path
352 if src == null then return null
353 return wiki.config.root_dir.join_path(src)
354 end
355
356 # Relative path from `wiki.config.root_dir` to rendered output.
357 #
358 # Like `src_path`, this method can represent a
359 # directory or a file.
360 fun out_path: String is abstract
361
362 # Absolute path to the output.
363 fun out_full_path: String do return wiki.config.root_dir.join_path(out_path)
364
365 # Rendering
366
367 # Does `self` have already been rendered?
368 fun is_new: Bool do return not out_full_path.file_exists
369
370 # Does `self` rendered output is outdated?
371 #
372 # Returns `true` if `is_new` then check in children.
373 fun is_dirty: Bool do
374 if is_new then return true
375 if has_source then
376 if last_edit_time >= last_render_time then return true
377 end
378 for child in children.values do
379 if child.is_dirty then return true
380 end
381 return false
382 end
383
384 # Render `self` and `children` is needed.
385 fun render do for child in children.values do child.render
386
387 # Templating
388
389 # Template file for `self`.
390 #
391 # Each entity can use a custom template.
392 # By default the template is inherited from the parent.
393 #
394 # If the root does not have a custom template,
395 # then returns the main wiki template file.
396 fun template_file: String do
397 if is_root then return wiki.config.template_file
398 return parent.template_file
399 end
400
401 # Header template file for `self`.
402 #
403 # Behave like `template_file`.
404 fun header_file: String do
405 if is_root then return wiki.config.header_file
406 return parent.header_file
407 end
408
409 # Footer template file for `self`.
410 #
411 # Behave like `template_file`.
412 fun footer_file: String do
413 if is_root then return wiki.config.footer_file
414 return parent.footer_file
415 end
416
417 # Menu template file for `self`.
418 #
419 # Behave like `template_file`.
420 fun menu_file: String do
421 if is_root then return wiki.config.menu_file
422 return parent.menu_file
423 end
424
425 # Display the entry `name`.
426 redef fun to_s do return name
427 end
428
429 # Each WikiSection is related to a source directory.
430 #
431 # A section can contain other sub-sections or pages.
432 class WikiSection
433 super WikiEntry
434
435 # A section can only have another section as parent.
436 redef type PARENT: WikiSection
437
438 redef fun title do
439 if has_config then
440 var title = config.title
441 if title != null then return title
442 end
443 return super
444 end
445
446 # Is this section hidden?
447 #
448 # Hidden section are rendered but not linked in menus.
449 fun is_hidden: Bool do
450 if has_config then return config.is_hidden
451 return false
452 end
453
454 # Source directory.
455 redef fun src_path: String do
456 if parent == null then
457 return wiki.config.source_dir
458 else
459 return wiki.expand_path(parent.src_path, name)
460 end
461 end
462
463 # Config
464
465 # Custom configuration file for this section.
466 var config: nullable SectionConfig = null
467
468 # Does this section have its own config file?
469 fun has_config: Bool do return config != null
470
471 # Try to load the config file for this section.
472 private fun try_load_config do
473 var cfile = wiki.expand_path(wiki.config.root_dir, src_path, wiki.config_filename)
474 if not cfile.file_exists then return
475 wiki.message("Custom config for section {name}", 1)
476 config = new SectionConfig(cfile)
477 end
478
479 # Templating
480
481 # Also check custom config.
482 redef fun template_file do
483 if has_config then
484 var tpl = config.template_file
485 if tpl != null then return tpl
486 end
487 if is_root then return wiki.config.template_file
488 return parent.template_file
489 end
490
491 # Also check custom config.
492 redef fun header_file do
493 if has_config then
494 var tpl = config.header_file
495 if tpl != null then return tpl
496 end
497 if is_root then return wiki.config.header_file
498 return parent.header_file
499 end
500
501 # Also check custom config.
502 redef fun footer_file do
503 if has_config then
504 var tpl = config.footer_file
505 if tpl != null then return tpl
506 end
507 if is_root then return wiki.config.footer_file
508 return parent.footer_file
509 end
510
511 # Also check custom config.
512 redef fun menu_file do
513 if has_config then
514 var tpl = config.menu_file
515 if tpl != null then return tpl
516 end
517 if is_root then return wiki.config.menu_file
518 return parent.menu_file
519 end
520 end
521
522 # Each WikiArticle is related to a HTML file.
523 #
524 # Article can be created from scratch using this API or
525 # automatically from a markdown source file (see: `from_source`).
526 class WikiArticle
527 super WikiEntry
528
529 # Articles can only have `WikiSection` as parents.
530 redef type PARENT: WikiSection
531
532 redef fun title: String do
533 if name == "index" and parent != null then return parent.title
534 return super
535 end
536
537 # Page content.
538 #
539 # What you want to be displayed in the page.
540 var content: nullable Writable = null is writable
541
542 # Create a new article using a markdown source file.
543 init from_source(wiki: Nitiwiki, md_file: String) do
544 src_full_path = md_file
545 init(wiki, md_file.basename(".{wiki.config.md_ext}"))
546 content = md
547 end
548
549 redef var src_full_path: nullable String = null
550
551 redef fun src_path do
552 var src_full_path = self.src_full_path
553 if src_full_path == null then return null
554 var res = wiki.config.root_dir.relpath(src_full_path)
555 return res
556 end
557
558 # The page markdown source content.
559 #
560 # Extract the markdown text from `source_file`.
561 #
562 # REQUIRE: `has_source`.
563 var md: nullable String is lazy do
564 if not has_source then return null
565 var file = new FileReader.open(src_full_path.to_s)
566 var md = file.read_all
567 file.close
568 return md
569 end
570
571 # Returns true if has source and
572 # `last_edit_date` > 'last_render_date'.
573 redef fun is_dirty do
574 if super then return true
575 if has_source then
576 return wiki.need_render(src_full_path.to_s, out_full_path)
577 end
578 return false
579 end
580
581 redef fun to_s do return "{name} ({parent or else "null"})"
582 end
583
584 # The sidebar is displayed in front of the main panel of a `WikiEntry`.
585 class WikiSidebar
586
587 # Wiki used to parse sidebar blocks.
588 var wiki: Nitiwiki is lazy do return entry.wiki
589
590 # WikiEntry this panel is related to.
591 var entry: WikiEntry
592
593 # Blocks are ieces of markdown that will be rendered in the sidebar.
594 var blocks: Array[Text] is lazy do
595 var res = new Array[Text]
596 # TODO get blocks from the entry for more customization
597 for name in entry.wiki.config.sidebar_blocks do
598 var block = wiki.load_sideblock(name)
599 if block == null then continue
600 res.add block
601 end
602 return res
603 end
604 end
605
606 # Wiki configuration class.
607 #
608 # This class provides services that ensure static typing when accessing the `config.ini` file.
609 class WikiConfig
610 super ConfigTree
611
612 # Returns the config value at `key` or return `default` if no key was found.
613 private fun value_or_default(key: String, default: String): String do
614 if not has_key(key) then return default
615 return self[key]
616 end
617
618 # Site name displayed.
619 #
620 # The title is used as home title and in headers.
621 #
622 # * key: `wiki.name`
623 # * default: `MyWiki`
624 var wiki_name: String is lazy do return value_or_default("wiki.name", "MyWiki")
625
626 # Site description.
627 #
628 # Displayed in header.
629 #
630 # * key: `wiki.desc`
631 # * default: ``
632 var wiki_desc: String is lazy do return value_or_default("wiki.desc", "")
633
634 # Site logo url.
635 #
636 # Url of the image to be displayed in header.
637 #
638 # * key: `wiki.logo`
639 # * default: ``
640 var wiki_logo: String is lazy do return value_or_default("wiki.logo", "")
641
642 # Markdown extension recognized by this wiki.
643 #
644 # We allow only one kind of extension per wiki.
645 # Files with other markdown extensions will be treated as resources.
646 #
647 # * key: `wiki.md_ext`
648 # * default: `md`
649 var md_ext: String is lazy do return value_or_default("wiki.md_ext", "md")
650
651 # Root directory of the wiki.
652 #
653 # Directory where the wiki files are stored locally.
654 #
655 # * key: `wiki.root_dir`
656 # * default: `./`
657 var root_dir: String is lazy do return value_or_default("wiki.root_dir", "./").simplify_path
658
659 # Pages directory.
660 #
661 # Directory where markdown source files are stored.
662 #
663 # * key: `wiki.source_dir
664 # * default: `pages/`
665 var source_dir: String is lazy do
666 return value_or_default("wiki.source_dir", "pages/").simplify_path
667 end
668
669 # Output directory.
670 #
671 # Directory where public wiki files are generated.
672 # **This path MUST be relative to `root_dir`.**
673 #
674 # * key: `wiki.out_dir`
675 # * default: `out/`
676 var out_dir: String is lazy do return value_or_default("wiki.out_dir", "out/").simplify_path
677
678 # Asset files directory.
679 #
680 # Directory where public assets like JS scripts or CSS files are stored.
681 # **This path MUST be relative to `root_dir`.**
682 #
683 # * key: `wiki.assets_dir`
684 # * default: `assets/`
685 var assets_dir: String is lazy do
686 return value_or_default("wiki.assets_dir", "assets/").simplify_path
687 end
688
689 # Template files directory.
690 #
691 # Directory where template used in HTML generation are stored.
692 # **This path MUST be relative to `root_dir`.**
693 #
694 # * key: `wiki.templates_dir`
695 # * default: `templates/`
696 var templates_dir: String is lazy do
697 return value_or_default("wiki.templates_dir", "templates/").simplify_path
698 end
699
700 # Main template file.
701 #
702 # The main template is used to specify the overall structure of a page.
703 #
704 # * key: `wiki.template`
705 # * default: `template.html`
706 var template_file: String is lazy do
707 return value_or_default("wiki.template", "template.html")
708 end
709
710 # Main header template file.
711 #
712 # Used to specify the structure of the page header.
713 # This is generally the place where you want to put your logo and wiki title.
714 #
715 # * key: `wiki.header`
716 # * default: `header.html`
717 var header_file: String is lazy do
718 return value_or_default("wiki.header", "header.html")
719 end
720
721 # Main menu template file.
722 #
723 # Used to specify the menu structure.
724 #
725 # * key: `wiki.menu`
726 # * default: `menu.html`
727 var menu_file: String is lazy do
728 return value_or_default("wiki.menu", "menu.html")
729 end
730
731 # Main footer file.
732 #
733 # The main footer is used to specify the structure of the page footer.
734 # This is generally the place where you want to put your copyright.
735 #
736 # * key: `wiki.footer`
737 # * default: `footer.html`
738 var footer_file: String is lazy do
739 return value_or_default("wiki.footer", "footer.html")
740 end
741
742 # Automatically add a summary.
743 #
744 # * key: `wiki.auto_summary`
745 # * default: `true`
746 var auto_summary: Bool is lazy do
747 return value_or_default("wiki.auto_summary", "true") == "true"
748 end
749
750 # Automatically add breadcrumbs.
751 #
752 # * key: `wiki.auto_breadcrumbs`
753 # * default: `true`
754 var auto_breadcrumbs: Bool is lazy do
755 return value_or_default("wiki.auto_breadcrumbs", "true") == "true"
756 end
757
758 # Sidebar position.
759 #
760 # Position of the sidebar between `left`, `right` and `none`. Any other value
761 # will be considered as `none`.
762 #
763 # * key: `wiki.sidebar`
764 # * default: `left`
765 var sidebar: String is lazy do
766 return value_or_default("wiki.sidebar", "left")
767 end
768
769 # Sidebar markdown block to include.
770 #
771 # Blocks are specified by their filename without the extension.
772 #
773 # * key: `wiki.sidebar.blocks`
774 # * default: `[]`
775 var sidebar_blocks: Array[String] is lazy do
776 var res = new Array[String]
777 if not has_key("wiki.sidebar.blocks") then return res
778 for val in at("wiki.sidebar.blocks").values do
779 res.add val
780 end
781 return res
782 end
783
784 # Sidebar files directory.
785 #
786 # Directory where sidebar blocks are stored.
787 # **This path MUST be relative to `root_dir`.**
788 #
789 # * key: `wiki.sidebar_dir`
790 # * default: `sidebar/`
791 var sidebar_dir: String is lazy do
792 return value_or_default("wiki.sidebar_dir", "sidebar/").simplify_path
793 end
794
795 # Directory used by rsync to upload wiki files.
796 #
797 # This information is used to update your distant wiki files (like the webserver).
798 #
799 # * key: `wiki.rsync_dir`
800 # * default: ``
801 var rsync_dir: String is lazy do return value_or_default("wiki.rsync_dir", "")
802
803 # Remote repository used to pull modifications on sources.
804 #
805 # * key: `wiki.git_origin`
806 # * default: `origin`
807 var git_origin: String is lazy do return value_or_default("wiki.git_origin", "origin")
808
809 # Remote branch used to pull modifications on sources.
810 #
811 # * key: `wiki.git_branch`
812 # * default: `master`
813 var git_branch: String is lazy do return value_or_default("wiki.git_branch", "master")
814
815 # URL to source versionning used to display last changes
816 #
817 # * key: `wiki.last_changes`
818 # * default: ``
819 var last_changes: String is lazy do return value_or_default("wiki.last_changes", "")
820
821 # URL to source edition.
822 #
823 # * key: `wiki.edit`
824 # * default: ``
825 var edit: String is lazy do return value_or_default("wiki.edit", "")
826 end
827
828 # WikiSection custom configuration.
829 #
830 # Each section can provide its own config file to customize
831 # appearance or behavior.
832 class SectionConfig
833 super ConfigTree
834
835 # Returns the config value at `key` or `null` if no key was found.
836 private fun value_or_null(key: String): nullable String do
837 if not has_key(key) then return null
838 return self[key]
839 end
840
841 # Is this section hidden in sitemap and trees and menus?
842 fun is_hidden: Bool do return value_or_null("section.hidden") == "true"
843
844 # Custom section title if any.
845 fun title: nullable String do return value_or_null("section.title")
846
847 # Custom template file if any.
848 fun template_file: nullable String do return value_or_null("section.template")
849
850 # Custom header file if any.
851 fun header_file: nullable String do return value_or_null("section.header")
852
853 # Custom menu file if any.
854 fun menu_file: nullable String do return value_or_null("section.menu")
855
856 # Custom footer file if any.
857 fun footer_file: nullable String do return value_or_null("section.footer")
858 end