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