Merged @tutorial tag in.

This commit is contained in:
Michael Mathews 2011-12-15 22:17:44 +00:00
commit 279554f1a3
22 changed files with 458 additions and 27 deletions

1
.gitignore vendored
View File

@ -1,2 +1,3 @@
build-files/java/build build-files/java/build
jsdoc.jar jsdoc.jar
test/tutorials/out

View File

@ -21,6 +21,10 @@
{ {
"name": "Michael Mathews", "name": "Michael Mathews",
"email": "micmath@gmail.com" "email": "micmath@gmail.com"
},
{
"name": "Rafa\u0105 Wrzeszcz",
"email": "rafal.wrzeszcz@wrzasq.pl"
} }
], ],
"maintainers": [ "maintainers": [

View File

@ -142,7 +142,8 @@ function main() {
opts: { opts: {
parser: require('jsdoc/opts/parser'), parser: require('jsdoc/opts/parser'),
} }
}; },
resolver;
env.opts = jsdoc.opts.parser.parse(env.args); env.opts = jsdoc.opts.parser.parse(env.args);
@ -240,6 +241,15 @@ function main() {
exit(0); exit(0);
} }
// load this module anyway to ensure root instance exists
// it's not a problem since without tutorials root node will have empty children list
resolver = require('jsdoc/tutorial/resolver');
if (env.opts.tutorials) {
resolver.load(env.opts.tutorials);
resolver.resolve();
}
env.opts.template = env.opts.template || 'templates/default'; env.opts.template = env.opts.template || 'templates/default';
// should define a global "publish" function // should define a global "publish" function
@ -248,7 +258,8 @@ function main() {
if (typeof publish === 'function') { if (typeof publish === 'function') {
publish( publish(
new (require('typicaljoe/taffy'))(docs), new (require('typicaljoe/taffy'))(docs),
env.opts env.opts,
resolver.root
); );
} }
else { // TODO throw no publish warning? else { // TODO throw no publish warning?

View File

@ -1,7 +1,7 @@
{ {
"name": "JSDoc", "name": "JSDoc",
"version": "3.0.0alpha", "version": "3.0.0alpha",
"revision": "1323214202201", "revision": "1323947228470",
"description": "An automatic documentation generator for javascript.", "description": "An automatic documentation generator for javascript.",
"keywords": [ "documentation", "javascript" ], "keywords": [ "documentation", "javascript" ],
"licenses": [ "licenses": [
@ -21,6 +21,10 @@
{ {
"name": "Michael Mathews", "name": "Michael Mathews",
"email": "micmath@gmail.com" "email": "micmath@gmail.com"
},
{
"name": "Rafa\u0105 Wrzeszcz",
"email": "rafal.wrzeszcz@wrzasq.pl"
} }
], ],
"maintainers": [ "maintainers": [
@ -29,4 +33,4 @@
"email": "micmath@gmail.com" "email": "micmath@gmail.com"
} }
] ]
} }

View File

@ -133,7 +133,7 @@ exports.copyFile = function(inFile, outDir, fileName) {
bis.close(); bis.close();
}; };
function toFile(path) { var toFile = exports.toFile = function(path) {
var parts = path.split(/[\\\/]/); var parts = path.split(/[\\\/]/);
return parts.pop(); return parts.pop();
} }

View File

@ -48,6 +48,8 @@
members = getMembers(parents[j], docs); members = getMembers(parents[j], docs);
for (var k=0, kk=members.length; k<kk; ++k) { for (var k=0, kk=members.length; k<kk; ++k) {
member = doop(members[k]); member = doop(members[k]);
member.inherits = member.longname;
member.inherited = true;
member.memberof = doc.longname; member.memberof = doc.longname;
parts = member.longname.split("#"); parts = member.longname.split("#");
parts[0] = doc.longname; parts[0] = doc.longname;

View File

@ -12,7 +12,6 @@ var common = {
var argParser = new common.args.ArgParser(), var argParser = new common.args.ArgParser(),
ourOptions, ourOptions,
defaults = { defaults = {
template: 'templates/default',
destination: './out/' destination: './out/'
}; };
@ -26,6 +25,7 @@ argParser.addOption('r', 'recurse', false, 'Recurse into subdirectories when
argParser.addOption('h', 'help', false, 'Print this message and quit.'); argParser.addOption('h', 'help', false, 'Print this message and quit.');
argParser.addOption('X', 'explain', false, 'Dump all found doclet internals to console and quit.'); argParser.addOption('X', 'explain', false, 'Dump all found doclet internals to console and quit.');
argParser.addOption('q', 'query', true, 'Provide a querystring to define custom variable names/values to add to the options hash.'); argParser.addOption('q', 'query', true, 'Provide a querystring to define custom variable names/values to add to the options hash.');
argParser.addOption('u', 'tutorials', true, 'Directory in which JSDoc should search for tutorials.');
// TODO [-R, recurseonly] = a number representing the depth to recurse // TODO [-R, recurseonly] = a number representing the depth to recurse

View File

@ -54,6 +54,13 @@ exports.jsdocSchema = {
"items": { "items": {
"type": "string" "type": "string"
} }
},
"tutorials": { // extended tutorials
"type": ["string", "array"],
"optional": true,
"items": {
"type": "string"
}
}, },
"deprecated": { // is usage of this symbol deprecated? "deprecated": { // is usage of this symbol deprecated?
"type": ["string", "boolean"], "type": ["string", "boolean"],

View File

@ -486,6 +486,14 @@ exports.defineTags = function(dictionary) {
} }
}); });
dictionary.defineTag('tutorial', {
mustHaveValue: true,
onTagged: function(doclet, tag) {
if (!doclet.tutorials) { doclet.tutorials = []; }
doclet.tutorials.push(tag.value);
}
});
dictionary.defineTag('type', { dictionary.defineTag('type', {
mustHaveValue: true, mustHaveValue: true,
canHaveType: true, canHaveType: true,

View File

@ -0,0 +1,89 @@
/**
@overview
@author Rafał Wrzeszcz <rafal.wrzeszcz@wrzasq.pl>
@license Apache License 2.0 - See file 'LICENSE.md' in this project.
*/
var mdParser = require('evilstreak/markdown');
/**
@module jsdoc/tutorial
*/
/**
@class
@classdesc Represents a single JSDoc tutorial.
@param {string} name - Tutorial name.
@param {string} content - Text content.
@param {number} type - Source formating.
*/
exports.Tutorial = function(name, content, type) {
this.title = this.name = name;
this.content = content;
this.type = type;
// default values
this.parent = null;
this.children = [];
};
/** Moves children from current parent to different one.
@param {Tutorial} parent - New parent.
*/
exports.Tutorial.prototype.setParent = function(parent) {
// removes node from old parent
if (this.parent) {
this.parent.removeChild(this);
}
this.parent = parent;
this.parent.addChild(this);
};
/** Removes children from current node.
@param {Tutorial} child - Old child.
*/
exports.Tutorial.prototype.removeChild = function(child) {
var index = this.children.indexOf(child);
if (index != -1) {
this.children.splice(index, 1);
}
};
/** Adds new children to current node.
@param {Tutorial} child - New child.
*/
exports.Tutorial.prototype.addChild = function(child) {
this.children.push(child);
};
/** Prepares source.
@return {string} HTML source.
*/
exports.Tutorial.prototype.parse = function() {
switch (this.type) {
// nothing to do
case exports.TYPES.HTML:
return this.content;
// markdown
case exports.TYPES.MARKDOWN:
return mdParser.toHTML(this.content)
.replace(/&amp;/g, '&') // because markdown escapes these
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>');
// uhm... should we react somehow?
// if not then this case can be merged with TYPES.HTML
default:
return this.content;
}
};
/** Tutorial source types.
@enum {number}
*/
exports.TYPES = {
HTML: 1,
MARKDOWN: 2
};

View File

@ -0,0 +1,126 @@
/**
@overview
@author Rafał Wrzeszcz <rafal.wrzeszcz@wrzasq.pl>
@license Apache License 2.0 - See file 'LICENSE.md' in this project.
*/
/**
@module jsdoc/tutorial/resolver
*/
var tutorial = require('jsdoc/tutorial'),
fs = require('fs'),
conf = {},
tutorials = {},
finder = /^(.*)\.(x(?:ht)?ml|html?|md|markdown|js(?:on)?)$/i;
/** Adds new tutorial.
@param {tutorial.Tutorial} current - New tutorial.
*/
exports.addTutorial = function(current) {
tutorials[current.name] = current;
// default temporary parent
current.setParent(exports.root);
};
/** Root tutorial.
@type tutorial.Tutorial
*/
exports.root = new tutorial.Tutorial('', '');
/** Additional instance method for root node.
@param {string} name - Tutorial name.
@reutrn {tutorial.Tutorial} Tutorial instance.
*/
exports.root.getByName = function(name) {
return tutorials[name];
};
/** Load tutorials from given path.
@param {string} path - Tutorials directory.
*/
exports.load = function(path) {
var match,
type,
name,
current,
files = fs.ls(path);
// tutorials handling
files.forEach(function(file) {
match = file.match(finder);
// any filetype that can apply to tutorials
if (match) {
name = fs.toFile(match[1]);
content = fs.readFileSync(file);
switch (match[2].toLowerCase()) {
// HTML type
case 'xml':
case 'xhtml':
case 'html':
case 'htm':
type = tutorial.TYPES.HTML;
break;
// Markdown typs
case 'md':
case 'markdown':
type = tutorial.TYPES.MARKDOWN;
break;
// configuration file
case 'js':
case 'json':
conf[name] = JSON.parse(content);
// how can it be? check `finder' regexp
default:
// not a file we want to work with
return;
}
current = new tutorial.Tutorial(name, content, type);
exports.addTutorial(current);
}
});
};
/** Resolves hierarchical structure.
@param {object} map - Contents map.
*/
exports.resolve = function() {
var item,
current;
for (var name in conf) {
// should we be restrictive here?
// what is someone just wants to keep sample sources in same directory with tutorials?
// I've decided to leave such cases alone
if (!(name in tutorials)) {
continue;
}
item = conf[name];
current = tutorials[name]
// set title
if (item.title) {
current.title = item.title;
}
// add children
if (item.children) {
item.children.forEach(function(child) {
// I really didn't want to throw you an exception in most cases
// but now, user, you pissed me off ;)
if (!(child in tutorials)) {
throw new Error("Missing child tutorial: " + child);
}
tutorials[child].setParent(current);
});
}
}
};

View File

@ -8,7 +8,7 @@ var dictionary = require('jsdoc/tag/dictionary');
exports.globalName = 'global'; exports.globalName = 'global';
exports.fileExtension = '.html'; exports.fileExtension = '.html';
/** Find symbol {@link ...} strings in text and turn into html links */ /** Find symbol {@link ...} and {@tutorial ...} strings in text and turn into html links */
exports.resolveLinks = function(str) { exports.resolveLinks = function(str) {
str = str.replace(/(?:\[(.+?)\])?\{@link +(.+?)\}/gi, str = str.replace(/(?:\[(.+?)\])?\{@link +(.+?)\}/gi,
function(match, content, longname) { function(match, content, longname) {
@ -16,6 +16,12 @@ exports.resolveLinks = function(str) {
} }
); );
str = str.replace(/(?:\[(.+?)\])?\{@tutorial +(.+?)\}/gi,
function(match, content, tutorial) {
return toTutorial(tutorial, content);
}
);
return str; return str;
} }
@ -102,4 +108,40 @@ function toLink(longname, content) {
} }
} }
exports.longnameToUrl = linkMap.longnameToUrl; /** @external {jsdoc.tutorial.Tutorial} */
var tutorials;
/** Sets tutorials map.
@param {jsdoc.tutorial.Tutorial} root - Root tutorial node.
*/
exports.setTutorials = function(root) {
tutorials = root;
};
exports.toTutorial = toTutorial = function(tutorial, content) {
if (!tutorial) {
throw new Error('Missing required parameter: tutorial');
}
var node = tutorials.getByName(tutorial);
// no such tutorial
if (!node) {
return '<em class="disabled">Tutorial: '+tutorial+'</em>';
}
content = content || node.title;
return '<a href="'+exports.tutorialToUrl(tutorial)+'">'+content+'</a>';
}
exports.longnameToUrl = linkMap.longnameToUrl;
exports.tutorialToUrl = function(tutorial) {
var node = tutorials.getByName(tutorial);
// no such tutorial
if (!node) {
throw new Error('No such tutorial: '+tutorial);
}
return 'tutorial-'+strToFilename(node.name)+exports.fileExtension;
};

View File

@ -13,11 +13,16 @@
@global @global
@param {TAFFY} data See <http://taffydb.com/>. @param {TAFFY} data See <http://taffydb.com/>.
@param {object} opts @param {object} opts
@param {Tutorial} tutorials
*/ */
publish = function(data, opts) { publish = function(data, opts, tutorials) {
var out = '', var out = '',
containerTemplate = template.render(fs.readFileSync(__dirname + '/templates/default/tmpl/container.tmpl')); containerTemplate = template.render(fs.readFileSync(__dirname + '/templates/default/tmpl/container.tmpl')),
tutorialTemplate = template.render(fs.readFileSync(__dirname + '/templates/default/tmpl/tutorial.tmpl'));
// set up tutorials for helper
helper.setTutorials(tutorials);
function render(tmpl, partialData) { function render(tmpl, partialData) {
var renderFunction = arguments.callee.cache[tmpl]; var renderFunction = arguments.callee.cache[tmpl];
if (!renderFunction) { if (!renderFunction) {
@ -26,6 +31,7 @@
partialData.render = arguments.callee; partialData.render = arguments.callee;
partialData.find = find; partialData.find = find;
partialData.linkto = linkto; partialData.linkto = linkto;
partialData.tutoriallink = tutoriallink;
partialData.htmlsafe = htmlsafe; partialData.htmlsafe = htmlsafe;
return renderFunction.call(partialData, partialData); return renderFunction.call(partialData, partialData);
@ -162,7 +168,7 @@
}; };
}); });
} }
else if (doclet.see) { if (doclet.see) {
doclet.see.forEach(function(seeItem, i) { doclet.see.forEach(function(seeItem, i) {
doclet.see[i] = hashToLink(doclet, seeItem); doclet.see[i] = hashToLink(doclet, seeItem);
}); });
@ -199,6 +205,10 @@
return url? '<a href="'+url+'">'+(linktext || longname)+'</a>' : (linktext || longname); return url? '<a href="'+url+'">'+(linktext || longname)+'</a>' : (linktext || longname);
} }
function tutoriallink(tutorial) {
return helper.toTutorial(tutorial);
}
var containers = ['class', 'module', 'external', 'namespace', 'mixin']; var containers = ['class', 'module', 'external', 'namespace', 'mixin'];
data.forEach(function(doclet) { data.forEach(function(doclet) {
@ -245,29 +255,29 @@
var moduleNames = find({kind: 'module'}); var moduleNames = find({kind: 'module'});
if (moduleNames.length) { if (moduleNames.length) {
nav = nav + '<h3>Modules</h3><ul>'; nav += '<h3>Modules</h3><ul>';
moduleNames.forEach(function(m) { moduleNames.forEach(function(m) {
if ( !seen.hasOwnProperty(m.longname) ) nav += '<li>'+linkto(m.longname, m.name)+'</li>'; if ( !seen.hasOwnProperty(m.longname) ) nav += '<li>'+linkto(m.longname, m.name)+'</li>';
seen[m.longname] = true; seen[m.longname] = true;
}); });
nav = nav + '</ul>'; nav += '</ul>';
} }
var externalNames = find({kind: 'external'}); var externalNames = find({kind: 'external'});
if (externalNames.length) { if (externalNames.length) {
nav = nav + '<h3>Externals</h3><ul>'; nav += '<h3>Externals</h3><ul>';
externalNames.forEach(function(e) { externalNames.forEach(function(e) {
if ( !seen.hasOwnProperty(e.longname) ) nav += '<li>'+linkto( e.longname, e.name.replace(/(^"|"$)/g, '') )+'</li>'; if ( !seen.hasOwnProperty(e.longname) ) nav += '<li>'+linkto( e.longname, e.name.replace(/(^"|"$)/g, '') )+'</li>';
seen[e.longname] = true; seen[e.longname] = true;
}); });
nav = nav + '</ul>'; nav += '</ul>';
} }
var classNames = find({kind: 'class'}); var classNames = find({kind: 'class'});
if (classNames.length) { if (classNames.length) {
nav = nav + '<h3>Classes</h3><ul>'; nav += '<h3>Classes</h3><ul>';
classNames.forEach(function(c) { classNames.forEach(function(c) {
var moduleSameName = find({kind: 'module', longname: c.longname}); var moduleSameName = find({kind: 'module', longname: c.longname});
if (moduleSameName.length) { if (moduleSameName.length) {
@ -279,52 +289,61 @@
seen[c.longname] = true; seen[c.longname] = true;
}); });
nav = nav + '</ul>'; nav += '</ul>';
} }
var namespaceNames = find({kind: 'namespace'}); var namespaceNames = find({kind: 'namespace'});
if (namespaceNames.length) { if (namespaceNames.length) {
nav = nav + '<h3>Namespaces</h3><ul>'; nav += '<h3>Namespaces</h3><ul>';
namespaceNames.forEach(function(n) { namespaceNames.forEach(function(n) {
if ( !seen.hasOwnProperty(n.longname) ) nav += '<li>'+linkto(n.longname, n.name)+'</li>'; if ( !seen.hasOwnProperty(n.longname) ) nav += '<li>'+linkto(n.longname, n.name)+'</li>';
seen[n.longname] = true; seen[n.longname] = true;
}); });
nav = nav + '</ul>'; nav += '</ul>';
} }
// var constantNames = find({kind: 'constants'}); // var constantNames = find({kind: 'constants'});
// if (constantNames.length) { // if (constantNames.length) {
// nav = nav + '<h3>Constants</h3><ul>'; // nav += '<h3>Constants</h3><ul>';
// constantNames.forEach(function(c) { // constantNames.forEach(function(c) {
// if ( !seen.hasOwnProperty(c.longname) ) nav += '<li>'+linkto(c.longname, c.name)+'</li>'; // if ( !seen.hasOwnProperty(c.longname) ) nav += '<li>'+linkto(c.longname, c.name)+'</li>';
// seen[c.longname] = true; // seen[c.longname] = true;
// }); // });
// //
// nav = nav + '</ul>'; // nav += '</ul>';
// } // }
var mixinNames = find({kind: 'mixin'}); var mixinNames = find({kind: 'mixin'});
if (mixinNames.length) { if (mixinNames.length) {
nav = nav + '<h3>Mixins</h3><ul>'; nav += '<h3>Mixins</h3><ul>';
mixinNames.forEach(function(m) { mixinNames.forEach(function(m) {
if ( !seen.hasOwnProperty(m.longname) ) nav += '<li>'+linkto(m.longname, m.name)+'</li>'; if ( !seen.hasOwnProperty(m.longname) ) nav += '<li>'+linkto(m.longname, m.name)+'</li>';
seen[m.longname] = true; seen[m.longname] = true;
}); });
nav = nav + '</ul>'; nav += '</ul>';
} }
if (tutorials.children.length) {
nav += '<h3>Tutorials</h3><ul>';
tutorials.children.forEach(function(t) {
nav += '<li>'+tutoriallink(t.name)+'</li>';
});
nav += '</ul>';
}
var globalNames = find({kind: ['member', 'function', 'constant', 'typedef'], 'memberof': {'isUndefined': true}}); var globalNames = find({kind: ['member', 'function', 'constant', 'typedef'], 'memberof': {'isUndefined': true}});
if (globalNames.length) { if (globalNames.length) {
nav = nav + '<h3>Global</h3><ul>'; nav += '<h3>Global</h3><ul>';
globalNames.forEach(function(g) { globalNames.forEach(function(g) {
if ( g.kind !== 'typedef' && !seen.hasOwnProperty(g.longname) ) nav += '<li>'+linkto(g.longname, g.name)+'</li>'; if ( g.kind !== 'typedef' && !seen.hasOwnProperty(g.longname) ) nav += '<li>'+linkto(g.longname, g.name)+'</li>';
seen[g.longname] = true; seen[g.longname] = true;
}); });
nav = nav + '</ul>'; nav += '</ul>';
} }
for (var longname in helper.longnameToUrl) { for (var longname in helper.longnameToUrl) {
@ -348,8 +367,8 @@
} }
if (globals.length) generate('Global', [{kind: 'globalobj'}], 'global.html'); if (globals.length) generate('Global', [{kind: 'globalobj'}], 'global.html');
function generate(title, docs, filename) { function generate(title, docs, filename) {
var data = { var data = {
title: title, title: title,
@ -360,6 +379,7 @@
render: render, render: render,
find: find, find: find,
linkto: linkto, linkto: linkto,
tutoriallink: tutoriallink,
htmlsafe: htmlsafe htmlsafe: htmlsafe
}; };
@ -370,6 +390,39 @@
fs.writeFileSync(path, html) fs.writeFileSync(path, html)
} }
function generateTutorial(title, tutorial, filename) {
var data = {
title: title,
header: tutorial.title,
content: tutorial.parse(),
children: tutorial.children,
nav: nav,
// helpers
render: render,
find: find,
linkto: linkto,
tutoriallink: tutoriallink,
htmlsafe: htmlsafe
};
var path = outdir + '/' + filename,
html = tutorialTemplate.call(data, data);
// yes, you can use {@link} in tutorials too!
html = helper.resolveLinks(html); // turn {@link foo} into <a href="foodoc.html">foo</a>
fs.writeFileSync(path, html)
}
// tutorials can have only one parent so there is no risk for loops
function saveChildren(node) {
node.children.forEach(function(child) {
generateTutorial('Tutorial: '+child.title, child, helper.tutorialToUrl(child.name));
});
}
saveChildren(tutorials);
} }
function hashToLink(doclet, hash) { function hashToLink(doclet, hash) {

View File

@ -254,3 +254,7 @@ h6
.params th, .props th { border-right: 1px solid #aaa; } .params th, .props th { border-right: 1px solid #aaa; }
.params thead .last, .props thead .last { border-right: 1px solid #ddd; } .params thead .last, .props thead .last { border-right: 1px solid #ddd; }
.disabled {
color: #454545;
}

View File

@ -60,6 +60,17 @@
<dt class="tag-source">Source:</dt> <dt class="tag-source">Source:</dt>
<dd class="tag-source"><ul class="dummy"><li><?js= meta.filename ?>, line <?js= meta.lineno ?></li></ul></dd> <dd class="tag-source"><ul class="dummy"><li><?js= meta.filename ?>, line <?js= meta.lineno ?></li></ul></dd>
<?js } ?> <?js } ?>
<?js if (this.tutorials && tutorials.length) {?>
<dt class="tag-tutorial">Tutorials:</dt>
<dd class="tag-tutorial">
<ul><?js
tutorials.forEach(function(t) {
print('<li>'+tutoriallink(t)+'</li>');
});
?></ul>
</dd>
<?js } ?>
<?js if (this.see && see.length) {?> <?js if (this.see && see.length) {?>
<dt class="tag-see">See:</dt> <dt class="tag-see">See:</dt>

View File

@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>JSDoc: <?js= title ?></title>
<script src="http://shjs.sourceforge.net/sh_main.min.js"> </script>
<script src="http://shjs.sourceforge.net/lang/sh_javascript.min.js"> </script>
<link type="text/css" rel="stylesheet" href="styles/node-dark.css">
<link type="text/css" rel="stylesheet" href="styles/jsdoc-default.css">
</head>
<body>
<div id="main">
<h1 class="page-title"><?js= title ?></h1>
<section>
<header>
<?js if (children.length > 0) { ?>
<ul><?js
children.forEach(function(t) {
print('<li>'+tutoriallink(t.name)+'</li>');
});
?></ul>
<?js } ?>
<h2><?js= header ?></h2>
</header>
<article>
<?js= content ?>
</article>
</section>
</div>
<nav>
<?js= nav ?>
</nav>
<br clear="both">
<footer>
Documentation generated by <a href="https://github.com/micmath/jsdoc">JSDoc 3</a> on <?js= (new Date()) ?>
</footer>
<script> sh_highlightDocument(); </script>
</body>
</html>

2
test/tutorials/build.sh Executable file
View File

@ -0,0 +1,2 @@
rm -rf out
../../jsdoc -u tutorials src -d out

8
test/tutorials/src/x.js Normal file
View File

@ -0,0 +1,8 @@
/**
* Test {@tutorial test2} {@tutorial dupa}
*
* @class
* @tutorial test
* @tutorial jasia
*/
function Test() {}

View File

@ -0,0 +1,3 @@
<h1>Test.html</h1>
<p>{@link Test}</p>

View File

@ -0,0 +1 @@
{"title": "Test tutorial", "children": ["test2"]}

View File

@ -0,0 +1 @@
{"title": "Test 2"}

View File

@ -0,0 +1 @@
# test2.markdown