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