UNPKG

22.6 kBJavaScriptView Raw
1"use strict";
2
3/**
4 * @module template/publish
5 * @type {*}
6 */
7/*global env: true */
8
9var template = require('jsdoc/template'),
10 doop = require('jsdoc/util/doop'),
11 fs = require('jsdoc/fs'),
12 _ = require('underscore'),
13 path = require('jsdoc/path'),
14
15 taffy = require('taffydb').taffy,
16 handle = require('jsdoc/util/error').handle,
17 helper = require('jsdoc/util/templateHelper'),
18 // jsdoc node support is still a bit odd
19 moment = require("moment"),
20 htmlsafe = helper.htmlsafe,
21 linkto = helper.linkto,
22 resolveAuthorLinks = helper.resolveAuthorLinks,
23 scopeToPunc = helper.scopeToPunc,
24 hasOwnProp = Object.prototype.hasOwnProperty,
25 conf = env.conf.templates || {},
26 data,
27 view,
28 outdir = env.opts.destination;
29
30var globalUrl = helper.getUniqueFilename('global');
31var indexUrl = helper.getUniqueFilename('index');
32
33var navOptions = {
34 logoFile: conf.logoFile,
35 systemName: conf.systemName || "Documentation",
36 navType: conf.navType || "vertical",
37 footer: conf.footer || "",
38 copyright: conf.copyright || "",
39 theme: conf.theme || "simplex",
40 syntaxTheme: conf.syntaxTheme || "default",
41 linenums: conf.linenums,
42 collapseSymbols: conf.collapseSymbols || false,
43 inverseNav: conf.inverseNav,
44 outputSourceFiles: conf.outputSourceFiles === true,
45 sourceRootPath: conf.sourceRootPath,
46 outputSourcePath: conf.outputSourcePath,
47 dateFormat: conf.dateFormat,
48 analytics: conf.analytics || null,
49 highlightTutorialCode: conf.highlightTutorialCode,
50 methodHeadingReturns: conf.methodHeadingReturns === true
51};
52
53var navigationMaster = {
54 index: {
55 title: navOptions.systemName,
56 link: indexUrl,
57 members: []
58 },
59 namespace: {
60 title: "Namespaces",
61 link: helper.getUniqueFilename("namespaces.list"),
62 members: []
63 },
64 module: {
65 title: "Modules",
66 link: helper.getUniqueFilename("modules.list"),
67 members: []
68 },
69 class: {
70 title: "Classes",
71 link: helper.getUniqueFilename('classes.list'),
72 members: []
73 },
74
75 mixin: {
76 title: "Mixins",
77 link: helper.getUniqueFilename("mixins.list"),
78 members: []
79 },
80 event: {
81 title: "Events",
82 link: helper.getUniqueFilename("events.list"),
83 members: []
84 },
85 interface: {
86 title: "Interfaces",
87 link: helper.getUniqueFilename("interfaces.list"),
88 members: []
89 },
90 tutorial: {
91 title: "Tutorials",
92 link: helper.getUniqueFilename("tutorials.list"),
93 members: []
94 },
95 global: {
96 title: "Global",
97 link: globalUrl,
98 members: []
99
100 },
101 external: {
102 title: "Externals",
103 link: helper.getUniqueFilename("externals.list"),
104 members: []
105 }
106};
107
108var prefixSymbols = {
109 "static": ".",
110 "instance": "#",
111 "global": ""
112};
113
114function find(spec) {
115 return helper.find(data, spec);
116}
117
118function tutoriallink(tutorial) {
119 return helper.toTutorial(tutorial, null, {
120 tag: 'em',
121 classname: 'disabled',
122 prefix: 'Tutorial: '
123 });
124}
125
126function getAncestorLinks(doclet) {
127 return helper.getAncestorLinks(data, doclet);
128}
129
130function hashToLink(doclet, hash) {
131 if (!/^(#.+)/.test(hash)) {
132 return hash;
133 }
134
135 var url = helper.createLink(doclet);
136
137 url = url.replace(/(#.+|$)/, hash);
138 return '<a href="' + url + '">' + hash + '</a>';
139}
140
141function needsSignature(doclet) {
142 var needsSig = false;
143
144 // function and class definitions always get a signature
145 if (doclet.kind === 'function' || doclet.kind === 'class') {
146 needsSig = true;
147 }
148 // typedefs that contain functions get a signature, too
149 else if (doclet.kind === 'typedef' && doclet.type && doclet.type.names &&
150 doclet.type.names.length) {
151 for (var i = 0, l = doclet.type.names.length; i < l; i++) {
152 if (doclet.type.names[i].toLowerCase() === 'function') {
153 needsSig = true;
154 break;
155 }
156 }
157 }
158
159 return needsSig;
160}
161
162function addPrefixSymbol(f) {
163 f.prefixSymbol = prefixSymbols[f.scope] || "";
164}
165
166function addSignatureParams(f) {
167 var params = helper.getSignatureParams(f, 'optional');
168
169 f.signature = (f.signature || '') + '(' + params.join(', ') + ')';
170}
171
172function addSignatureReturns(f) {
173 if (navOptions.methodHeadingReturns) {
174 var returnTypes = helper.getSignatureReturns(f);
175
176 f.signature = '<span class="signature">' + (f.signature || '') + '</span>' + '<span class="type-signature">' + (returnTypes.length ? ' &rarr; {' + returnTypes.join('|') + '}' : '') + '</span>';
177 }
178 else {
179 f.signature = f.signature || '';
180 }
181}
182
183function addSignatureTypes(f) {
184 var types = helper.getSignatureTypes(f);
185
186 f.signature = (f.signature || '') + '<span class="type-signature">' + (types.length ? ' :' + types.join('|') : '') + '</span>';
187}
188
189function addAttribs(f) {
190 var attribs = helper.getAttribs(f);
191
192 f.attribs = '<span class="type-signature">' + htmlsafe(attribs.length ? '<' + attribs.join(', ') + '> ' : '') + '</span>';
193}
194
195function shortenPaths(files, commonPrefix) {
196 // // always use forward slashes
197 // var regexp = new RegExp( '\\\\', 'g' );
198 //
199 // var prefix = commonPrefix.toLowerCase().replace( regexp, "/" );
200 //
201 // Object.keys( files ).forEach( function ( file ) {
202 // files[file].shortened = files[file]
203 // .resolved
204 // .toLowerCase()
205 // .replace( regexp, '/' )
206 // .replace( prefix, '' );
207 // } );
208
209 Object.keys(files).forEach(function(file) {
210 files[file].shortened = files[file].resolved.replace(commonPrefix, '')
211 // always use forward slashes
212 .replace(/\\/g, '/');
213 });
214
215
216 return files;
217}
218
219function getPathFromDoclet(doclet) {
220 if (!doclet.meta) {
221 return;
222 }
223
224 return path.normalize(doclet.meta.path && doclet.meta.path !== 'null' ?
225 doclet.meta.path + '/' + doclet.meta.filename :
226 doclet.meta.filename);
227}
228
229function generate(docType, title, docs, filename, resolveLinks) {
230 resolveLinks = resolveLinks === false ? false : true;
231
232 var docData = {
233 title: title,
234 docs: docs,
235 docType: docType
236 };
237
238 var outpath = path.join(outdir, filename),
239 html = view.render('container.tmpl', docData);
240
241 if (resolveLinks) {
242 html = helper.resolveLinks(html); // turn {@link foo} into <a href="foodoc.html">foo</a>
243 }
244
245 fs.writeFileSync(outpath, html, 'utf8');
246}
247
248function generateSourceFiles(sourceFiles) {
249 Object.keys(sourceFiles).forEach(function(file) {
250 var source;
251 // links are keyed to the shortened path in each doclet's `meta.shortpath` property
252 var sourceOutfile = helper.getUniqueFilename(sourceFiles[file].shortened);
253 helper.registerLink(sourceFiles[file].shortened, sourceOutfile);
254
255 try {
256 source = {
257 kind: 'source',
258 code: helper.htmlsafe(fs.readFileSync(sourceFiles[file].resolved, 'utf8'))
259 };
260 } catch (e) {
261 handle(e);
262 }
263
264 generate('source', 'Source: ' + sourceFiles[file].shortened, [source], sourceOutfile,
265 false);
266 });
267}
268
269/**
270 * Look for classes or functions with the same name as modules (which indicates that the module
271 * exports only that class or function), then attach the classes or functions to the `module`
272 * property of the appropriate module doclets. The name of each class or function is also updated
273 * for display purposes. This function mutates the original arrays.
274 *
275 * @private
276 * @param {Array.<module:jsdoc/doclet.Doclet>} doclets - The array of classes and functions to
277 * check.
278 * @param {Array.<module:jsdoc/doclet.Doclet>} modules - The array of module doclets to search.
279 */
280function attachModuleSymbols(doclets, modules) {
281 var symbols = {};
282
283 // build a lookup table
284 doclets.forEach(function(symbol) {
285 symbols[symbol.longname] = symbols[symbol.longname] || [];
286 symbols[symbol.longname].push(symbol);
287 });
288
289 return modules.map(function(module) {
290 if (symbols[module.longname]) {
291 module.modules = symbols[module.longname]
292 // Only show symbols that have a description. Make an exception for classes, because
293 // we want to show the constructor-signature heading no matter what.
294 .filter(function(symbol) {
295 return symbol.description || symbol.kind === 'class';
296 })
297 .map(function(symbol) {
298 symbol = doop(symbol);
299
300 if (symbol.kind === 'class' || symbol.kind === 'function') {
301 symbol.name = symbol.name.replace('module:', '(require("') + '"))';
302 }
303
304 return symbol;
305 });
306 }
307 });
308}
309
310/**
311 * Create the navigation sidebar.
312 * @param {object} members The members that will be used to create the sidebar.
313 * @param {array<object>} members.classes
314 * @param {array<object>} members.externals
315 * @param {array<object>} members.globals
316 * @param {array<object>} members.mixins
317 * @param {array<object>} members.interfaces
318 * @param {array<object>} members.modules
319 * @param {array<object>} members.namespaces
320 * @param {array<object>} members.tutorials
321 * @param {array<object>} members.events
322 * @return {string} The HTML for the navigation sidebar.
323 */
324function buildNav(members) {
325
326 var seen = {};
327 var nav = navigationMaster;
328 if (members.modules.length) {
329
330 members.modules.forEach(function(m) {
331 if (!hasOwnProp.call(seen, m.longname)) {
332
333 nav.module.members.push(linkto(m.longname, m.longname.replace("module:", "")));
334 }
335 seen[m.longname] = true;
336 });
337 }
338
339 if (members.externals.length) {
340
341 members.externals.forEach(function(e) {
342 if (!hasOwnProp.call(seen, e.longname)) {
343
344 nav.external.members.push(linkto(e.longname, e.name.replace(/(^"|"$)/g, '')));
345 }
346 seen[e.longname] = true;
347 });
348 }
349
350 if (members.classes.length) {
351
352 members.classes.forEach(function(c) {
353 if (!hasOwnProp.call(seen, c.longname)) {
354
355 nav.class.members.push(linkto(c.longname, c.longname.replace("module:", "")));
356 }
357 seen[c.longname] = true;
358 });
359
360 }
361
362 if (members.events.length) {
363
364 members.events.forEach(function(e) {
365 if (!hasOwnProp.call(seen, e.longname)) {
366
367 nav.event.members.push(linkto(e.longname, e.longname.replace("module:", "")));
368 }
369 seen[e.longname] = true;
370 });
371
372 }
373
374 if (members.namespaces.length) {
375
376 members.namespaces.forEach(function(n) {
377 if (!hasOwnProp.call(seen, n.longname)) {
378
379 nav.namespace.members.push(linkto(n.longname, n.longname.replace("module:", "")));
380 }
381 seen[n.longname] = true;
382 });
383
384 }
385
386 if (members.mixins.length) {
387
388 members.mixins.forEach(function(m) {
389 if (!hasOwnProp.call(seen, m.longname)) {
390
391 nav.mixin.members.push(linkto(m.longname, m.longname.replace("module:", "")));
392 }
393 seen[m.longname] = true;
394 });
395
396 }
397
398 if (members.interfaces.length) {
399
400 members.interfaces.forEach(function(m) {
401 if (!hasOwnProp.call(seen, m.longname)) {
402
403 nav.interface.members.push(linkto(m.longname, m.longname.replace("module:", "")));
404 }
405 seen[m.longname] = true;
406 });
407
408 }
409
410 if (members.tutorials.length) {
411
412 members.tutorials.forEach(function(t) {
413
414 nav.tutorial.members.push(tutoriallink(t.name));
415 });
416
417 }
418
419 if (members.globals.length) {
420 members.globals.forEach(function(g) {
421 if (g.kind !== 'typedef' && !hasOwnProp.call(seen, g.longname)) {
422
423 nav.global.members.push(linkto(g.longname, g.longname.replace("module:", "")));
424 }
425 seen[g.longname] = true;
426 });
427
428 // even if there are no links, provide a link to the global page.
429 if (nav.global.members.length === 0) {
430 nav.global.members.push(linkto("global", "Global"));
431 }
432 }
433
434 var topLevelNav = [];
435 _.each(nav, function(entry, name) {
436 if (entry.members.length > 0 && name !== "index") {
437 topLevelNav.push({
438 title: entry.title,
439 link: entry.link,
440 members: entry.members
441 });
442 }
443 });
444 nav.topLevelNav = topLevelNav;
445}
446
447/**
448 @param {TAFFY} taffyData See <http://taffydb.com/>.
449 @param {object} opts
450 @param {Tutorial} tutorials
451 */
452exports.publish = function(taffyData, opts, tutorials) {
453 data = taffyData;
454
455 conf['default'] = conf['default'] || {};
456
457 var templatePath = opts.template;
458 view = new template.Template(templatePath + '/tmpl');
459
460 // claim some special filenames in advance, so the All-Powerful Overseer of Filename Uniqueness
461 // doesn't try to hand them out later
462 // var indexUrl = helper.getUniqueFilename( 'index' );
463 // don't call registerLink() on this one! 'index' is also a valid longname
464
465 // var globalUrl = helper.getUniqueFilename( 'global' );
466 helper.registerLink('global', globalUrl);
467
468 // set up templating
469 view.layout = 'layout.tmpl';
470
471 // set up tutorials for helper
472 helper.setTutorials(tutorials);
473
474 data = helper.prune(data);
475 data.sort('longname, version, since');
476 helper.addEventListeners(data);
477
478 var sourceFiles = {};
479 var sourceFilePaths = [];
480 data().each(function(doclet) {
481 doclet.attribs = '';
482
483 if (doclet.examples) {
484 doclet.examples = doclet.examples.map(function(example) {
485 var caption, code, lang;
486
487 if (example.match(/^\s*<caption>([\s\S]+?)<\/caption>(\s*[\n\r])([\s\S]+)$/i)) {
488 caption = RegExp.$1;
489 code = RegExp.$3;
490 }
491
492 var lang = /{@lang (.*?)}/.exec(example);
493
494 if (lang && lang[1]) {
495 example = example.replace(lang[0], "");
496 lang = lang[1];
497
498 } else {
499 lang = null;
500 }
501
502 return {
503 caption: caption || '',
504 code: code || example,
505 lang: (lang && navOptions.highlightTutorialCode) ? lang : "javascript"
506 };
507 });
508 }
509 if (doclet.see) {
510 doclet.see.forEach(function(seeItem, i) {
511 doclet.see[i] = hashToLink(doclet, seeItem);
512 });
513 }
514
515 // build a list of source files
516 var sourcePath;
517 if (doclet.meta) {
518 sourcePath = getPathFromDoclet(doclet);
519 sourceFiles[sourcePath] = {
520 resolved: sourcePath,
521 shortened: null
522 };
523
524 sourceFilePaths.push(sourcePath);
525
526 }
527 });
528
529 // update outdir if necessary, then create outdir
530 var packageInfo = (find({
531 kind: 'package'
532 }) || [])[0];
533 if (packageInfo && packageInfo.name) {
534 outdir = path.join(outdir, packageInfo.name, packageInfo.version);
535 }
536 fs.mkPath(outdir);
537
538 // copy the template's static files to outdir
539 var fromDir = path.join( templatePath, 'static' );
540 var staticFiles = fs.ls( fromDir, 3 );
541
542 staticFiles.forEach( function ( fileName ) {
543 var toDir = fs.toDir( fileName.replace( fromDir, outdir ) );
544 fs.mkPath( toDir );
545 fs.copyFileSync( fileName, toDir );
546 } );
547
548 // copy user-specified static files to outdir
549 var staticFilePaths;
550 var staticFileFilter;
551 var staticFileScanner;
552 if (conf.default.staticFiles) {
553 // The canonical property name is `include`. We accept `paths` for backwards compatibility
554 // with a bug in JSDoc 3.2.x.
555 staticFilePaths = conf.default.staticFiles.include ||
556 conf.default.staticFiles.paths ||
557 [];
558 staticFileFilter = new (require('jsdoc/src/filter')).Filter(conf.default.staticFiles);
559 staticFileScanner = new (require('jsdoc/src/scanner')).Scanner();
560
561 staticFilePaths.forEach(function(filePath) {
562 var extraStaticFiles = staticFileScanner.scan([filePath], 10, staticFileFilter);
563
564 extraStaticFiles.forEach(function(fileName) {
565 var sourcePath = fs.toDir(filePath);
566 var toDir = fs.toDir( fileName.replace(sourcePath, outdir) );
567 fs.mkPath(toDir);
568 fs.copyFileSync(fileName, toDir);
569 });
570 });
571 }
572
573 if (sourceFilePaths.length) {
574 var payload = navOptions.sourceRootPath;
575 if (!payload) {
576 payload = path.commonPrefix(sourceFilePaths);
577 }
578 sourceFiles = shortenPaths(sourceFiles, payload);
579 }
580 data().each(function(doclet) {
581 var url = helper.createLink(doclet);
582 helper.registerLink(doclet.longname, url);
583
584 // add a shortened version of the full path
585 var docletPath;
586 if (doclet.meta) {
587 docletPath = getPathFromDoclet(doclet);
588 if (!_.isEmpty(sourceFiles[docletPath])) {
589 docletPath = sourceFiles[docletPath].shortened;
590 if (docletPath) {
591 doclet.meta.shortpath = docletPath;
592 }
593 }
594 }
595 });
596
597 data().each(function(doclet) {
598 var url = helper.longnameToUrl[doclet.longname];
599
600 if (url.indexOf('#') > -1) {
601 doclet.id = helper.longnameToUrl[doclet.longname].split(/#/).pop();
602 } else {
603 doclet.id = doclet.name;
604 }
605
606 if (needsSignature(doclet)) {
607 addSignatureParams(doclet);
608 addSignatureReturns(doclet);
609 addAttribs(doclet);
610 addPrefixSymbol(doclet);
611 }
612 });
613
614 // do this after the urls have all been generated
615 data().each(function(doclet) {
616 doclet.ancestors = getAncestorLinks(doclet);
617 addPrefixSymbol(doclet);
618
619 if (doclet.kind === 'member') {
620 addSignatureTypes(doclet);
621 addAttribs(doclet);
622 }
623
624 if (doclet.kind === 'constant') {
625 addSignatureTypes(doclet);
626 addAttribs(doclet);
627 doclet.kind = 'member';
628 }
629 });
630
631 var members = helper.getMembers(data);
632 members.tutorials = tutorials.children;
633
634 // add template helpers
635 view.find = find;
636 view.linkto = linkto;
637 view.resolveAuthorLinks = resolveAuthorLinks;
638 view.tutoriallink = tutoriallink;
639 view.htmlsafe = htmlsafe;
640 view.moment = moment;
641
642 // once for all
643 buildNav(members);
644 view.nav = navigationMaster;
645 view.navOptions = navOptions;
646 attachModuleSymbols(find({
647 kind: ['class', 'function'],
648 longname: {
649 left: 'module:'
650 }
651 }),
652 members.modules);
653
654 // only output pretty-printed source files if requested; do this before generating any other
655 // pages, so the other pages can link to the source files
656 if (navOptions.outputSourceFiles) {
657 generateSourceFiles(sourceFiles);
658 }
659
660 if (members.globals.length) {
661 generate('global', 'Global', [{
662 kind: 'globalobj'
663 }], globalUrl);
664 }
665
666 // some browsers can't make the dropdown work
667 if (view.nav.module && view.nav.module.members.length) {
668 generate('module', view.nav.module.title, [{
669 kind: 'sectionIndex',
670 contents: view.nav.module
671 }], navigationMaster.module.link);
672 }
673
674 if (view.nav.class && view.nav.class.members.length) {
675 generate('class', view.nav.class.title, [{
676 kind: 'sectionIndex',
677 contents: view.nav.class
678 }], navigationMaster.class.link);
679 }
680
681 if (view.nav.namespace && view.nav.namespace.members.length) {
682 generate('namespace', view.nav.namespace.title, [{
683 kind: 'sectionIndex',
684 contents: view.nav.namespace
685 }], navigationMaster.namespace.link);
686 }
687
688 if (view.nav.mixin && view.nav.mixin.members.length) {
689 generate('mixin', view.nav.mixin.title, [{
690 kind: 'sectionIndex',
691 contents: view.nav.mixin
692 }], navigationMaster.mixin.link);
693 }
694
695 if (view.nav.interface && view.nav.interface.members.length) {
696 generate('interface', view.nav.interface.title, [{
697 kind: 'sectionIndex',
698 contents: view.nav.interface
699 }], navigationMaster.interface.link);
700 }
701
702 if (view.nav.external && view.nav.external.members.length) {
703 generate('external', view.nav.external.title, [{
704 kind: 'sectionIndex',
705 contents: view.nav.external
706 }], navigationMaster.external.link);
707 }
708
709 if (view.nav.tutorial && view.nav.tutorial.members.length) {
710 generate('tutorial', view.nav.tutorial.title, [{
711 kind: 'sectionIndex',
712 contents: view.nav.tutorial
713 }], navigationMaster.tutorial.link);
714 }
715
716 // index page displays information from package.json and lists files
717 var files = find({
718 kind: 'file'
719 }),
720 packages = find({
721 kind: 'package'
722 });
723
724 generate('index', 'Index',
725 packages.concat(
726 [{
727 kind: 'mainpage',
728 readme: opts.readme,
729 longname: (opts.mainpagetitle) ? opts.mainpagetitle : 'Main Page'
730 }]
731 ).concat(files),
732 indexUrl);
733
734 // set up the lists that we'll use to generate pages
735 var classes = taffy(members.classes);
736 var modules = taffy(members.modules);
737 var namespaces = taffy(members.namespaces);
738 var mixins = taffy(members.mixins);
739 var interfaces = taffy(members.interfaces);
740 var externals = taffy(members.externals);
741
742 for (var longname in helper.longnameToUrl) {
743 if (hasOwnProp.call(helper.longnameToUrl, longname)) {
744 var myClasses = helper.find(classes, {
745 longname: longname
746 });
747 if (myClasses.length) {
748 generate('class', 'Class: ' + myClasses[0].name, myClasses, helper.longnameToUrl[longname]);
749 }
750
751 var myModules = helper.find(modules, {
752 longname: longname
753 });
754 if (myModules.length) {
755 generate('module', 'Module: ' + myModules[0].name, myModules, helper.longnameToUrl[longname]);
756 }
757
758 var myNamespaces = helper.find(namespaces, {
759 longname: longname
760 });
761 if (myNamespaces.length) {
762 generate('namespace', 'Namespace: ' + myNamespaces[0].name, myNamespaces, helper.longnameToUrl[longname]);
763 }
764
765 var myMixins = helper.find(mixins, {
766 longname: longname
767 });
768 if (myMixins.length) {
769 generate('mixin', 'Mixin: ' + myMixins[0].name, myMixins, helper.longnameToUrl[longname]);
770 }
771
772 var myInterfaces = helper.find(interfaces, {
773 longname: longname
774 });
775 if (myInterfaces.length) {
776 generate('interface', 'Interface: ' + myInterfaces[0].name, myInterfaces, helper.longnameToUrl[longname]);
777 }
778
779 var myExternals = helper.find(externals, {
780 longname: longname
781 });
782 if (myExternals.length) {
783 generate('external', 'External: ' + myExternals[0].name, myExternals, helper.longnameToUrl[longname]);
784 }
785 }
786 }
787
788 // TODO: move the tutorial functions to templateHelper.js
789 function generateTutorial(title, tutorial, filename) {
790 var tutorialData = {
791 title: title,
792 header: tutorial.title,
793 content: tutorial.parse(),
794 children: tutorial.children,
795 docs: null
796 };
797
798 var tutorialPath = path.join(outdir, filename),
799 html = view.render('tutorial.tmpl', tutorialData);
800
801 // yes, you can use {@link} in tutorials too!
802 html = helper.resolveLinks(html); // turn {@link foo} into <a href="foodoc.html">foo</a>
803
804 fs.writeFileSync(tutorialPath, html, 'utf8');
805 }
806
807 // tutorials can have only one parent so there is no risk for loops
808 function saveChildren(node) {
809 node.children.forEach(function(child) {
810 generateTutorial('tutorial' + child.title, child, helper.tutorialToUrl(child.name));
811 saveChildren(child);
812 });
813 }
814
815 saveChildren(tutorials);
816};