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