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