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