nitc: split nitcatalog into a lib and a program
[nit.git] / src / nitcatalog.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 # Basic catalog generator for Nit packages
16 #
17 # See: <http://nitlanguage.org/catalog/>
18 #
19 # The tool scans packages and generates the HTML files of a catalog.
20 #
21 # See `catalog` for details
22 module nitcatalog
23
24 import loader # Scan&load packages, groups and modules
25 import doc::doc_down # Display mdoc
26 import catalog
27
28 # A HTML page in a catalog
29 #
30 # This is just a template with the header pre-filled and the footer injected at rendering.
31 # Therefore, once instantiated, the content can just be added to it.
32 class CatalogPage
33 super Template
34
35 # The associated catalog, used to groups options and other global data
36 var catalog: Catalog
37
38 # Placeholder to include additional things before the `</head>`.
39 var more_head = new Template
40
41 # Relative path to the root directory (with the index file).
42 #
43 # Use "" for pages in the root directory
44 # Use ".." for pages in a subdirectory
45 var rootpath: String
46
47 redef init
48 do
49 add """
50 <!DOCTYPE html>
51 <html>
52 <head>
53 <meta charset="utf-8">
54 <link rel="stylesheet" media="all" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.1/css/bootstrap.min.css">
55 <link rel="stylesheet" media="all" href="{{{rootpath / "style.css"}}}">
56 """
57 add more_head
58
59 add """
60 </head>
61 <body>
62 <div class='container-fluid'>
63 <div class='row'>
64 <nav id='topmenu' class='navbar navbar-default navbar-fixed-top' role='navigation'>
65 <div class='container-fluid'>
66 <div class='navbar-header'>
67 <button type='button' class='navbar-toggle' data-toggle='collapse' data-target='#topmenu-collapse'>
68 <span class='sr-only'>Toggle menu</span>
69 <span class='icon-bar'></span>
70 <span class='icon-bar'></span>
71 <span class='icon-bar'></span>
72 </button>
73 <span class='navbar-brand'><a href="http://nitlanguage.org/">Nitlanguage.org</a></span>
74 </div>
75 <div class='collapse navbar-collapse' id='topmenu-collapse'>
76 <ul class='nav navbar-nav'>
77 <li><a href="{{{rootpath / "index.html"}}}">Catalog</a></li>
78 </ul>
79 </div>
80 </div>
81 </nav>
82 </div>
83 """
84 end
85
86 # Inject piwik HTML code if required
87 private fun add_piwik
88 do
89 var tracker_url = catalog.piwik_tracker
90 if tracker_url == null then return
91
92 var site_id = catalog.piwik_site_id
93
94 tracker_url = tracker_url.trim
95 if tracker_url.chars.last != '/' then tracker_url += "/"
96 add """
97 <!-- Piwik -->
98 <script type="text/javascript">
99 var _paq = _paq || [];
100 _paq.push(['trackPageView']);
101 _paq.push(['enableLinkTracking']);
102 (function() {
103 var u=(("https:" == document.location.protocol) ? "https" : "http") + "://{{{tracker_url.escape_to_c}}}";
104 _paq.push(['setTrackerUrl', u+'piwik.php']);
105 _paq.push(['setSiteId', {{{site_id}}}]);
106 var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0]; g.type='text/javascript';
107 g.defer=true; g.async=true; g.src=u+'piwik.js'; s.parentNode.insertBefore(g,s);
108 })();
109
110 </script>
111 <noscript><p><img src="http://{{{tracker_url.html_escape}}}piwik.php?idsite={{{site_id}}}" style="border:0;" alt="" /></p></noscript>
112 <!-- End Piwik Code -->
113 """
114
115 end
116
117 redef fun rendering
118 do
119 add """
120 </div> <!-- container-fluid -->
121 <script src='https://code.jquery.com/jquery-latest.min.js'></script>
122 <script src='https://maxcdn.bootstrapcdn.com/bootstrap/3.3.1/js/bootstrap.min.js'></script>
123 <script src='https://cdnjs.cloudflare.com/ajax/libs/bootstrap-table/1.8.1/bootstrap-table-all.min.js'></script>
124 """
125 add_piwik
126 add """
127
128 </body>
129 </html>
130 """
131 end
132 end
133
134 redef class Catalog
135 # Return a empty `CatalogPage`.
136 fun new_page(rootpath: String): CatalogPage
137 do
138 return new CatalogPage(self, rootpath)
139 end
140
141 # Add a contributor to a package
142 fun add_contrib(person: String, mpackage: MPackage, res: Template)
143 do
144 var name = person
145 var email = null
146 var page = null
147
148 # Regular expressions are broken, need to investigate.
149 # So split manually.
150 #
151 #var re = "([^<(]*?)(<([^>]*?)>)?(\\((.*)\\))?".to_re
152 #var m = (person+" ").search(re)
153 #print "{person}: `{m or else "?"}` `{m[1] or else "?"}` `{m[3] or else "?"}` `{m[5] or else "?"}`"
154 do
155 var sp1 = person.split_once_on("<")
156 if sp1.length < 2 then
157 break
158 end
159 var sp2 = sp1.last.split_once_on(">")
160 if sp2.length < 2 then
161 break
162 end
163 name = sp1.first.trim
164 email = sp2.first.trim
165 var sp3 = sp2.last.split_once_on("(")
166 if sp3.length < 2 then
167 break
168 end
169 var sp4 = sp3.last.split_once_on(")")
170 if sp4.length < 2 then
171 break
172 end
173 page = sp4.first.trim
174 end
175
176 var e = name.html_escape
177 res.add "<li>"
178 if page != null then
179 res.add "<a href=\"{page.html_escape}\">"
180 end
181 if email != null then
182 # TODO get more things from github by using the email as a key
183 # "https://api.github.com/search/users?q={email}+in:email"
184 var md5 = email.md5.to_lower
185 res.add "<img src=\"https://secure.gravatar.com/avatar/{md5}?size=20&amp;default=retro\">&nbsp;"
186 end
187 res.add "{e}"
188 if page != null then res.add "</a>"
189 res.add "</li>"
190 end
191
192 # Recursively generate a level in the file tree of the *content* section
193 private fun gen_content_level(ot: OrderedTree[MConcern], os: Array[Object], res: Template)
194 do
195 res.add "<ul>\n"
196 for o in os do
197 res.add "<li>"
198 if o isa MGroup then
199 var d = ""
200 var mdoc = o.mdoc
201 if mdoc != null then d = ": {mdoc.html_synopsis.write_to_string}"
202 res.add "<strong>{o.name}</strong>{d} ({o.filepath.to_s})"
203 else if o isa MModule then
204 var d = ""
205 var mdoc = o.mdoc
206 if mdoc != null then d = ": {mdoc.html_synopsis.write_to_string}"
207 res.add "<strong>{o.name}</strong>{d} ({o.filepath.to_s})"
208 else
209 abort
210 end
211 var subs = ot.sub.get_or_null(o)
212 if subs != null then gen_content_level(ot, subs, res)
213 res.add "</li>\n"
214 end
215 res.add "</ul>\n"
216 end
217
218 # Generate a full HTML page for a package
219 fun generate_page(mpackage: MPackage): Writable
220 do
221 var res = new_page("..")
222 var name = mpackage.name.html_escape
223 res.more_head.add """<title>{{{name}}}</title>"""
224
225 res.add """
226 <div class="content">
227 <h1 class="package-name">{{{name}}}</h1>
228 """
229 var mdoc = mpackage.mdoc_or_fallback
230 if mdoc != null then res.add mdoc.html_documentation
231
232 res.add "<h2>Content</h2>"
233 var ot = new OrderedTree[MConcern]
234 for g in mpackage.mgroups do
235 var pa = g.parent
236 if g.is_interesting then
237 ot.add(pa, g)
238 pa = g
239 end
240 for mp in g.mmodules do
241 ot.add(pa, mp)
242 end
243 end
244 ot.sort_with(alpha_comparator)
245 gen_content_level(ot, ot.roots, res)
246
247
248 res.add """
249 </div>
250 <div class="sidebar">
251 <ul class="box">
252 """
253 var tryit = mpackage.metadata("upstream.tryit")
254 if tryit != null then
255 var e = tryit.html_escape
256 res.add "<li><a href=\"{e}\">Try<span style=\"color:white\">n</span>it!</a></li>\n"
257 end
258 var apk = mpackage.metadata("upstream.apk")
259 if apk != null then
260 var e = apk.html_escape
261 res.add "<li><a href=\"{e}\">Android apk</a></li>\n"
262 end
263
264 res.add """</ul>\n<ul class="box">\n"""
265
266 var homepage = mpackage.metadata("upstream.homepage")
267 if homepage != null then
268 var e = homepage.html_escape
269 res.add "<li><a href=\"{e}\">{e}</a></li>\n"
270 end
271 var maintainer = mpackage.metadata("package.maintainer")
272 if maintainer != null then
273 add_contrib(maintainer, mpackage, res)
274 end
275 var license = mpackage.metadata("package.license")
276 if license != null then
277 var e = license.html_escape
278 res.add "<li><a href=\"http://opensource.org/licenses/{e}\">{e}</a> license</li>\n"
279 end
280 res.add "</ul>\n"
281
282 res.add "<h3>Source Code</h3>\n<ul class=\"box\">\n"
283 var browse = mpackage.metadata("upstream.browse")
284 if browse != null then
285 var e = browse.html_escape
286 res.add "<li><a href=\"{e}\">{e}</a></li>\n"
287 end
288 var git = mpackage.metadata("upstream.git")
289 if git != null then
290 var e = git.html_escape
291 res.add "<li><tt>{e}</tt></li>\n"
292 end
293 var last_date = mpackage.last_date
294 if last_date != null then
295 var e = last_date.html_escape
296 res.add "<li>most recent commit: {e}</li>\n"
297 end
298 var first_date = mpackage.first_date
299 if first_date != null then
300 var e = first_date.html_escape
301 res.add "<li>oldest commit: {e}</li>\n"
302 end
303 var commits = commits[mpackage]
304 if commits != 0 then
305 res.add "<li>{commits} commits</li>\n"
306 end
307 res.add "</ul>\n"
308
309 res.add "<h3>Tags</h3>\n"
310 var ts2 = new Array[String]
311 for t in mpackage.tags do
312 t = t.html_escape
313 ts2.add "<a href=\"../index.html#tag_{t}\">{t}</a>"
314 end
315 res.add_list(ts2, ", ", ", ")
316
317 if deps.has(mpackage) then
318 var reqs = deps[mpackage].greaters.to_a
319 reqs.remove(mpackage)
320 alpha_comparator.sort(reqs)
321 res.add "<h3>Requirements</h3>\n"
322 if reqs.is_empty then
323 res.add "none"
324 else
325 var list = new Array[String]
326 for r in reqs do
327 var direct = deps.has_direct_edge(mpackage, r)
328 var s = "<a href=\"{r}.html\">"
329 if direct then s += "<strong>"
330 s += r.to_s
331 if direct then s += "</strong>"
332 s += "</a>"
333 list.add s
334 end
335 res.add_list(list, ", ", " and ")
336 end
337
338 reqs = deps[mpackage].smallers.to_a
339 reqs.remove(mpackage)
340 alpha_comparator.sort(reqs)
341 res.add "<h3>Clients</h3>\n"
342 if reqs.is_empty then
343 res.add "none"
344 else
345 var list = new Array[String]
346 for r in reqs do
347 var direct = deps.has_direct_edge(r, mpackage)
348 var s = "<a href=\"{r}.html\">"
349 if direct then s += "<strong>"
350 s += r.to_s
351 if direct then s += "</strong>"
352 s += "</a>"
353 list.add s
354 end
355 res.add_list(list, ", ", " and ")
356 end
357 end
358
359 var contributors = mpackage.contributors
360 if not contributors.is_empty then
361 res.add "<h3>Contributors</h3>\n<ul class=\"box\">"
362 for c in contributors do
363 add_contrib(c, mpackage, res)
364 end
365 res.add "</ul>"
366 end
367
368 res.add """
369 <h3>Stats</h3>
370 <ul class="box">
371 <li>{{{mmodules[mpackage]}}} modules</li>
372 <li>{{{mclasses[mpackage]}}} classes</li>
373 <li>{{{mmethods[mpackage]}}} methods</li>
374 <li>{{{loc[mpackage]}}} lines of code</li>
375 </ul>
376 """
377
378 res.add """
379 </div>
380 """
381 return res
382 end
383
384 # Return a short HTML sequence for a package
385 #
386 # Intended to use in lists.
387 fun li_package(p: MPackage): String
388 do
389 var res = ""
390 var f = "p/{p.name}.html"
391 res += "<a href=\"{f}\">{p}</a>"
392 var d = p.mdoc_or_fallback
393 if d != null then res += " - {d.html_synopsis.write_to_string}"
394 return res
395 end
396
397 # List packages by group.
398 #
399 # For each key of the `map` a `<h3>` is generated.
400 # Each package is then listed.
401 #
402 # The list of keys is generated first to allow fast access to the correct `<h3>`.
403 # `id_prefix` is used to give an id to the `<h3>` element.
404 fun list_by(map: MultiHashMap[String, MPackage], id_prefix: String): Template
405 do
406 var res = new Template
407 var keys = map.keys.to_a
408 alpha_comparator.sort(keys)
409 var list = [for x in keys do "<a href=\"#{id_prefix}{x.html_escape}\">{x.html_escape}</a>"]
410 res.add_list(list, ", ", " and ")
411
412 for k in keys do
413 var projs = map[k].to_a
414 alpha_comparator.sort(projs)
415 var e = k.html_escape
416 res.add "<h3 id=\"{id_prefix}{e}\">{e} ({projs.length})</h3>\n<ul>\n"
417 for p in projs do
418 res.add "<li>"
419 res.add li_package(p)
420 res.add "</li>"
421 end
422 res.add "</ul>"
423 end
424 return res
425 end
426
427 # List the 10 best packages from `cpt`
428 fun list_best(cpt: Counter[MPackage]): Template
429 do
430 var res = new Template
431 res.add "<ul>"
432 var best = cpt.sort
433 for i in [1..10] do
434 if i > best.length then break
435 var p = best[best.length-i]
436 res.add "<li>"
437 res.add li_package(p)
438 # res.add " ({cpt[p]})"
439 res.add "</li>"
440 end
441 res.add "</ul>"
442 return res
443 end
444
445 # Produce a HTML table containig information on the packages
446 #
447 # `package_page` must have been called before so that information is computed.
448 fun table_packages(mpackages: Array[MPackage]): Template
449 do
450 alpha_comparator.sort(mpackages)
451 var res = new Template
452 res.add "<table data-toggle=\"table\" data-sort-name=\"name\" data-sort-order=\"desc\" width=\"100%\">\n"
453 res.add "<thead><tr>\n"
454 res.add "<th data-field=\"name\" data-sortable=\"true\">name</th>\n"
455 res.add "<th data-field=\"maint\" data-sortable=\"true\">maint</th>\n"
456 res.add "<th data-field=\"contrib\" data-sortable=\"true\">contrib</th>\n"
457 if deps.not_empty then
458 res.add "<th data-field=\"reqs\" data-sortable=\"true\">reqs</th>\n"
459 res.add "<th data-field=\"dreqs\" data-sortable=\"true\">direct<br>reqs</th>\n"
460 res.add "<th data-field=\"cli\" data-sortable=\"true\">clients</th>\n"
461 res.add "<th data-field=\"dcli\" data-sortable=\"true\">direct<br>clients</th>\n"
462 end
463 res.add "<th data-field=\"mod\" data-sortable=\"true\">modules</th>\n"
464 res.add "<th data-field=\"cla\" data-sortable=\"true\">classes</th>\n"
465 res.add "<th data-field=\"met\" data-sortable=\"true\">methods</th>\n"
466 res.add "<th data-field=\"loc\" data-sortable=\"true\">lines</th>\n"
467 res.add "<th data-field=\"score\" data-sortable=\"true\">score</th>\n"
468 res.add "</tr></thead>"
469 for p in mpackages do
470 res.add "<tr>"
471 res.add "<td><a href=\"p/{p.name}.html\">{p.name}</a></td>"
472 var maint = "?"
473 if p.maintainers.not_empty then maint = p.maintainers.first
474 res.add "<td>{maint}</td>"
475 res.add "<td>{p.contributors.length}</td>"
476 if deps.not_empty then
477 res.add "<td>{deps[p].greaters.length-1}</td>"
478 res.add "<td>{deps[p].direct_greaters.length}</td>"
479 res.add "<td>{deps[p].smallers.length-1}</td>"
480 res.add "<td>{deps[p].direct_smallers.length}</td>"
481 end
482 res.add "<td>{mmodules[p]}</td>"
483 res.add "<td>{mclasses[p]}</td>"
484 res.add "<td>{mmethods[p]}</td>"
485 res.add "<td>{loc[p]}</td>"
486 res.add "<td>{score[p]}</td>"
487 res.add "</tr>\n"
488 end
489 res.add "</table>\n"
490 return res
491 end
492
493 # Piwik tracker URL, if any
494 var piwik_tracker: nullable String = null
495
496 # Piwik site ID
497 # Used when `piwik_tracker` is set
498 var piwik_site_id: Int = 1
499 end
500
501 var model = new Model
502 var tc = new ToolContext
503
504 var opt_dir = new OptionString("Directory where the HTML files are generated", "-d", "--dir")
505 var opt_no_git = new OptionBool("Do not gather git information from the working directory", "--no-git")
506 var opt_no_parse = new OptionBool("Do not parse nit files (no importation information)", "--no-parse")
507 var opt_no_model = new OptionBool("Do not analyse nit files (no class/method information)", "--no-model")
508
509 # Piwik tracker URL.
510 # If you want to monitor your visitors.
511 var opt_piwik_tracker = new OptionString("Piwik tracker URL (ex: `nitlanguage.org/piwik/`)", "--piwik-tracker")
512 # Piwik tracker site id.
513 var opt_piwik_site_id = new OptionString("Piwik site ID", "--piwik-site-id")
514
515 tc.option_context.add_option(opt_dir, opt_no_git, opt_no_parse, opt_no_model, opt_piwik_tracker, opt_piwik_site_id)
516
517 tc.process_options(sys.args)
518 tc.keep_going = true
519
520 var modelbuilder = new ModelBuilder(model, tc)
521 var catalog = new Catalog(modelbuilder)
522
523 catalog.piwik_tracker = opt_piwik_tracker.value
524 var piwik_site_id = opt_piwik_site_id.value
525 if piwik_site_id != null then
526 if catalog.piwik_tracker == null then
527 print_error "Warning: ignored `{opt_piwik_site_id}` because `{opt_piwik_tracker}` is not set."
528 else if piwik_site_id.is_int then
529 print_error "Warning: ignored `{opt_piwik_site_id}`, an integer is required."
530 else
531 catalog.piwik_site_id = piwik_site_id.to_i
532 end
533 end
534
535
536 # Get files or groups
537 var args = tc.option_context.rest
538 if opt_no_parse.value then
539 modelbuilder.scan_full(args)
540 else
541 modelbuilder.parse_full(args)
542 end
543
544 # Scan packages and compute information
545 for p in model.mpackages do
546 var g = p.root
547 assert g != null
548 modelbuilder.scan_group(g)
549
550 # Load the module to process importation information
551 if opt_no_parse.value then continue
552
553 catalog.deps.add_node(p)
554 for gg in p.mgroups do for m in gg.mmodules do
555 for im in m.in_importation.direct_greaters do
556 var ip = im.mpackage
557 if ip == null or ip == p then continue
558 catalog.deps.add_edge(p, ip)
559 end
560 end
561 end
562
563 if not opt_no_git.value then for p in model.mpackages do
564 catalog.git_info(p)
565 end
566
567 # Run phases to modelize classes and properties (so we can count them)
568 if not opt_no_model.value then
569 modelbuilder.run_phases
570 end
571
572 var out = opt_dir.value or else "catalog.out"
573 (out/"p").mkdir
574
575 # Generate the css (hard coded)
576 var css = """
577 body {
578 margin-top: 15px;
579 background-color: #f8f8f8;
580 }
581
582 a {
583 color: #0D8921;
584 text-decoration: none;
585 }
586
587 a:hover {
588 color: #333;
589 text-decoration: none;
590 }
591
592 h1 {
593 font-weight: bold;
594 color: #0D8921;
595 font-size: 22px;
596 }
597
598 h2 {
599 color: #6C6C6C;
600 font-size: 18px;
601 border-bottom: solid 3px #CCC;
602 }
603
604 h3 {
605 color: #6C6C6C;
606 font-size: 15px;
607 border-bottom: solid 1px #CCC;
608 }
609
610 ul {
611 list-style-type: square;
612 }
613
614 dd {
615 color: #6C6C6C;
616 margin-top: 1em;
617 margin-bottom: 1em;
618 }
619
620 pre {
621 border: 1px solid #CCC;
622 font-family: Monospace;
623 color: #2d5003;
624 background-color: rgb(250, 250, 250);
625 }
626
627 code {
628 font-family: Monospace;
629 color: #2d5003;
630 }
631
632 footer {
633 margin-top: 20px;
634 }
635
636 .container {
637 margin: 0 auto;
638 padding: 0 20px;
639 }
640
641 .content {
642 float: left;
643 margin-top: 40px;
644 width: 65%;
645 }
646
647 .sidebar {
648 float: right;
649 margin-top: 40px;
650 width: 30%
651 }
652
653 .sidebar h3 {
654 color: #0D8921;
655 font-size: 18px;
656 border-bottom: 0px;
657 }
658
659 .box {
660 margin: 0;
661 padding: 0;
662 }
663
664 .box li {
665 line-height: 2.5;
666 white-space: nowrap;
667 overflow: hidden;
668 text-overflow: ellipsis;
669 padding-right: 10px;
670 border-bottom: 1px solid rgba(0,0,0,0.2);
671 }
672 """
673 css.write_to_file(out/"style.css")
674
675 # PAGES
676
677 for p in model.mpackages do
678 # print p
679 var f = "p/{p.name}.html"
680 catalog.package_page(p)
681 catalog.generate_page(p).write_to_file(out/f)
682 end
683
684 # INDEX
685
686 var index = catalog.new_page("")
687 index.more_head.add "<title>Packages in Nit</title>"
688
689 index.add """
690 <div class="content">
691 <h1>Packages in Nit</h1>
692 """
693
694 index.add "<h2>Highlighted Packages</h2>\n"
695 index.add catalog.list_best(catalog.score)
696
697 if catalog.deps.not_empty then
698 index.add "<h2>Most Required</h2>\n"
699 var reqs = new Counter[MPackage]
700 for p in model.mpackages do
701 reqs[p] = catalog.deps[p].smallers.length - 1
702 end
703 index.add catalog.list_best(reqs)
704 end
705
706 index.add "<h2>By First Tag</h2>\n"
707 index.add catalog.list_by(catalog.cat2proj, "cat_")
708
709 index.add "<h2>By Any Tag</h2>\n"
710 index.add catalog.list_by(catalog.tag2proj, "tag_")
711
712 index.add """
713 </div>
714 <div class="sidebar">
715 <h3>Stats</h3>
716 <ul class="box">
717 <li>{{{model.mpackages.length}}} packages</li>
718 <li>{{{catalog.maint2proj.length}}} maintainers</li>
719 <li>{{{catalog.contrib2proj.length}}} contributors</li>
720 <li>{{{catalog.tag2proj.length}}} tags</li>
721 <li>{{{catalog.mmodules.sum}}} modules</li>
722 <li>{{{catalog.mclasses.sum}}} classes</li>
723 <li>{{{catalog.mmethods.sum}}} methods</li>
724 <li>{{{catalog.loc.sum}}} lines of code</li>
725 </ul>
726 </div>
727 """
728
729 index.write_to_file(out/"index.html")
730
731 # PEOPLE
732
733 var page = catalog.new_page("")
734 page.more_head.add "<title>People of Nit</title>"
735 page.add """<div class="content">\n<h1>People of Nit</h1>\n"""
736 page.add "<h2>By Maintainer</h2>\n"
737 page.add catalog.list_by(catalog.maint2proj, "maint_")
738 page.add "<h2>By Contributor</h2>\n"
739 page.add catalog.list_by(catalog.contrib2proj, "contrib_")
740 page.add "</div>\n"
741 page.write_to_file(out/"people.html")
742
743 # TABLE
744
745 page = catalog.new_page("")
746 page.more_head.add "<title>Projets of Nit</title>"
747 page.add """<div class="content">\n<h1>People of Nit</h1>\n"""
748 page.add "<h2>Table of Projets</h2>\n"
749 page.add catalog.table_packages(model.mpackages)
750 page.add "</div>\n"
751 page.write_to_file(out/"table.html")