1 /**
2 	Generates offline documentation in the form of HTML files.
3 
4 	Copyright: © 2012 RejectedSoftware e.K.
5 	License: Subject to the terms of the MIT license, as written in the included LICENSE.txt file.
6 	Authors: Sönke Ludwig
7 */
8 module ddox.htmlgenerator;
9 
10 import ddox.api;
11 import ddox.entities;
12 import ddox.settings;
13 
14 import std.algorithm : canFind, countUntil, map;
15 import std.array;
16 import std.digest.md;
17 import std.format : formattedWrite;
18 import std.string : startsWith, toLower;
19 import std.traits : EnumMembers;
20 import std.variant;
21 import vibe.core.log;
22 import vibe.core.file;
23 import vibe.core.stream;
24 import vibe.data.json;
25 import vibe.inet.path;
26 import vibe.http.server;
27 import vibe.stream.wrapper : StreamOutputRange;
28 import diet.html;
29 import diet.traits : dietTraits;
30 
31 
32 /*
33 	structure:
34 	/index.html
35 	/pack1/pack2/module1.html
36 	/pack1/pack2/module1/member.html
37 	/pack1/pack2/module1/member.submember.html
38 */
39 
40 version (Windows) version = CaseInsensitiveFS;
41 else version (OSX) version = CaseInsensitiveFS;
42 
43 void generateHtmlDocs(Path dst_path, Package root, GeneratorSettings settings = null)
44 {
45 	import std.algorithm : splitter;
46 	import vibe.web.common : adjustMethodStyle;
47 
48 	if( !settings ) settings = new GeneratorSettings;
49 
50 	version (CaseInsensitiveFS) {
51 		final switch (settings.fileNameStyle) with (MethodStyle) {
52 			case unaltered, camelCase, pascalCase:
53 				logWarn("On Windows and OS X, file names that differ only in their case "
54 					~ "are treated as equal by default. Use one of the "
55 					~ "lower/upper case styles with the --file-name-style "
56 					~ "option to avoid missing files in the generated output.");
57 				break;
58 			case lowerCase, upperCase, lowerUnderscored, upperUnderscored:
59 				break;
60 		}
61 	}
62 
63 	string[string] file_hashes;
64 	string[string] new_file_hashes;
65 
66 	const hash_file_name = dst_path ~ "file_hashes.json";
67 	if (existsFile(hash_file_name)) {
68 		auto hfi = getFileInfo(hash_file_name);
69 		auto hf = readFileUTF8(hash_file_name);
70 		file_hashes = deserializeJson!(string[string])(hf);
71 	}
72 
73 	string linkTo(in Entity ent_, size_t level)
74 	{
75 		import std.typecons : Rebindable;
76 
77 		auto dst = appender!string();
78 		Rebindable!(const(Entity)) ent = ent_;
79 
80 		if( level ) foreach( i; 0 .. level ) dst.put("../");
81 		else dst.put("./");
82 
83 		if( ent !is null ){
84 			if( !ent.parent ){
85 				dst.put("index.html");
86 				return dst.data();
87 			}
88 
89 			auto dp = cast(const(VariableDeclaration))ent;
90 			auto dfn = ent.parent ? cast(const(FunctionDeclaration))ent.parent : null;
91 			if( dp && dfn ) ent = ent.parent;
92 
93 			const(Entity)[] nodes;
94 			size_t mod_idx = 0;
95 			while( ent ){
96 				if( cast(const(Module))ent ) mod_idx = nodes.length;
97 				nodes ~= ent.get;
98 				ent = ent.parent;
99 			}
100 			foreach_reverse(i, n; nodes[mod_idx .. $-1]){
101 				dst.put(n.name[]);
102 				if( i > 0 ) dst.put('/');
103 			}
104 			if( mod_idx == 0 ) dst.put(".html");
105 			else {
106 				dst.put('/');
107 				foreach_reverse(n; nodes[0 .. mod_idx]){
108 					dst.put(adjustMethodStyle(n.name, settings.fileNameStyle));
109 					dst.put('.');
110 				}
111 				dst.put("html");
112 			}
113 
114 			// FIXME: must also work for multiple function overloads in separate doc groups!
115 			if( dp && dfn ){
116 				dst.put('#');
117 				dst.put(dp.name[]);
118 			}
119 		}
120 
121 		return dst.data();
122 	}
123 
124 	void collectChildren(Entity parent, ref DocGroup[][string] pages)
125 	{
126 		Declaration[] members;
127 		if (!settings.enumMemberPages && cast(EnumDeclaration)parent)
128 			return;
129 		
130 		if (auto mod = cast(Module)parent) members = mod.members;
131 		else if (auto ctd = cast(CompositeTypeDeclaration)parent) members = ctd.members;
132 		else if (auto td = cast(TemplateDeclaration)parent) members = td.members;
133 
134 		foreach (decl; members) {
135 			if (decl.parent !is parent) continue; // exclude inherited members (issue #120)
136 			auto style = settings.fileNameStyle; // workaround for invalid value when directly used inside lamba
137 			auto name = decl.nestedName.splitter(".").map!(n => adjustMethodStyle(n, style)).join(".");
138 			auto pl = name in pages;
139 			if (pl && !canFind(*pl, decl.docGroup)) *pl ~= decl.docGroup;
140 			else if (!pl) pages[name] = [decl.docGroup];
141 
142 			collectChildren(decl, pages);
143 		}
144 	}
145 
146 	void writeHashedFile(Path filename, scope void delegate(OutputStream) del)
147 	{
148 		import vibe.stream.memory;
149 		assert(filename.startsWith(dst_path));
150 
151 		auto str = createMemoryOutputStream();
152 		del(str);
153 		auto h = md5Of(str.data).toHexString.idup;
154 		auto relfilename = filename[dst_path.length .. $].toString();
155 		auto ph = relfilename in file_hashes;
156 		if (!ph || *ph != h) {
157 			//logInfo("do write %s", filename);
158 			writeFile(filename, str.data);
159 		}
160 		new_file_hashes[relfilename] = h;
161 	}
162 
163 	void visitModule(Module mod, Path pack_path)
164 	{
165 		auto modpath = pack_path ~ PathEntry(mod.name);
166 		if (!existsFile(modpath)) createDirectory(modpath);
167 		logInfo("Generating module: %s", mod.qualifiedName);
168 		writeHashedFile(pack_path ~ PathEntry(mod.name~".html"), (stream) {
169 			generateModulePage(stream, root, mod, settings, ent => linkTo(ent, pack_path.length-dst_path.length));
170 		});
171 
172 		DocGroup[][string] pages;
173 		collectChildren(mod, pages);
174 		foreach (name, decls; pages)
175 			writeHashedFile(modpath ~ PathEntry(name~".html"), (stream) {
176 				generateDeclPage(stream, root, mod, name, decls, settings, ent => linkTo(ent, modpath.length-dst_path.length));
177 			});
178 	}
179 
180 	void visitPackage(Package p, Path path)
181 	{
182 		auto packpath = p.parent ? path ~ PathEntry(p.name) : path;
183 		if( !packpath.empty && !existsFile(packpath) ) createDirectory(packpath);
184 		foreach( sp; p.packages ) visitPackage(sp, packpath);
185 		foreach( m; p.modules ) visitModule(m, packpath);
186 	}
187 
188 	dst_path.normalize();
189 
190 	if( !dst_path.empty && !existsFile(dst_path) ) createDirectory(dst_path);
191 
192 	writeHashedFile(dst_path ~ PathEntry("index.html"), (stream) {
193 		generateApiIndex(stream, root, settings, ent => linkTo(ent, 0));
194 	});
195 
196 	writeHashedFile(dst_path ~ "symbols.js", (stream) {
197 		generateSymbolsJS(stream, root, settings, ent => linkTo(ent, 0));
198 	});
199 
200 	writeHashedFile(dst_path ~ PathEntry("sitemap.xml"), (stream) {
201 		generateSitemap(stream, root, settings, ent => linkTo(ent, 0));
202 	});
203 
204 	visitPackage(root, dst_path);
205 
206 	// delete obsolete files
207 	foreach (f; file_hashes.byKey)
208 		if (f !in new_file_hashes) {
209 			try removeFile(dst_path ~ Path(f));
210 			catch (Exception e) logWarn("Failed to remove obsolete file '%s': %s", f, e.msg);
211 		}
212 
213 	// write new file hash list
214 	writeFileUTF8(hash_file_name, new_file_hashes.serializeToJsonString());
215 }
216 
217 class DocPageInfo {
218 	string delegate(in Entity ent) linkTo;
219 	GeneratorSettings settings;
220 	Package rootPackage;
221 	Entity node;
222 	Module mod;
223 	DocGroup[] docGroups; // for multiple doc groups with the same name
224 	string nestedName;
225 	
226 	@property NavigationType navigationType() const { return settings.navigationType; }
227 	string formatType(CachedType tp, bool include_code_tags = true) { return .formatType(tp, linkTo, include_code_tags); }
228 	void renderTemplateArgs(R)(R output, Declaration decl) { .renderTemplateArgs(output, decl, linkTo); }
229 	string formatDoc(DocGroup group, int hlevel, bool delegate(string) display_section)
230 	{
231 		if (!group) return null;
232 		// TODO: memoize the DocGroupContext
233 		return group.comment.renderSections(new DocGroupContext(group, linkTo, settings), display_section, hlevel);
234 	}
235 }
236 
237 @dietTraits
238 struct DdoxDietTraits(HTMLOutputStyle htmlStyle) {
239 	// fields and functions must be static atm., see https://github.com/rejectedsoftware/diet-ng/issues/33
240 	enum HTMLOutputStyle htmlOutputStyle = htmlStyle;
241 }
242 
243 void generateSitemap(OutputStream dst, Package root_package, GeneratorSettings settings, string delegate(in Entity) link_to, HTTPServerRequest req = null)
244 {
245 	dst.write("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
246 	dst.write("<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n");
247 
248 	void writeEntry(string[] parts...){
249 		dst.write("<url><loc>");
250 		foreach( p; parts )
251 			dst.write(p);
252 		dst.write("</loc></url>\n");
253 	}
254 
255 	void writeEntityRec(Entity ent){
256 		import std.string;
257 		if( !cast(Package)ent || ent is root_package ){
258 			auto link = link_to(ent);
259 			if( indexOf(link, '#') < 0 ) // ignore URLs with anchors
260 				writeEntry((settings.siteUrl ~ Path(link)).toString());
261 		}
262 		ent.iterateChildren((ch){ writeEntityRec(ch); return true; });
263 	}
264 
265 	writeEntityRec(root_package);
266 	
267 	dst.write("</urlset>\n");
268 	dst.flush();
269 }
270 
271 void generateSymbolsJS(OutputStream dst, Package root_package, GeneratorSettings settings, string delegate(in Entity) link_to)
272 {
273 	import std.typecons : Tuple, tuple;
274 
275 	bool[Tuple!(Entity, CachedString)] visited;
276 
277 	auto rng = StreamOutputRange(dst);
278 
279 	void writeEntry(Entity ent) {
280 		auto key = tuple(ent.parent, ent.name);
281 		if (cast(Package)ent || cast(TemplateParameterDeclaration)ent) return;
282 		if (key in visited) return;
283 		visited[key] = true;
284 
285 		string kind = ent.classinfo.name.split(".")[$-1].toLower;
286 		const(CachedString)[] cattributes;
287 		if (auto fdecl = cast(FunctionDeclaration)ent) cattributes = fdecl.attributes;
288 		else if (auto adecl = cast(AliasDeclaration)ent) cattributes = adecl.attributes;
289 		else if (auto tdecl = cast(TypedDeclaration)ent) cattributes = tdecl.type.attributes;
290 		auto attributes = cattributes.map!(a => a.str.startsWith("@") ? a[1 .. $] : a);
291 		(&rng).formattedWrite(`{name: '%s', kind: "%s", path: '%s', attributes: %s},`, ent.qualifiedName, kind, link_to(ent), attributes);
292 		rng.put('\n');
293 	}
294 
295 	void writeEntryRec(Entity ent) {
296 		writeEntry(ent);
297 		if (cast(FunctionDeclaration)ent) return;
298 		ent.iterateChildren((ch) { writeEntryRec(ch); return true; });
299 	}
300 
301 	rng.put("// symbol index generated by DDOX - do not edit\n");
302 	rng.put("var symbols = [\n");
303 	writeEntryRec(root_package);
304 	rng.put("];\n");
305 }
306 
307 void generateApiIndex(OutputStream dst, Package root_package, GeneratorSettings settings, string delegate(in Entity) link_to, HTTPServerRequest req = null)
308 {
309 	auto info = new DocPageInfo;
310 	info.linkTo = link_to;
311 	info.settings = settings;
312 	info.rootPackage = root_package;
313 	info.node = root_package;
314 
315 	auto rng = StreamOutputRange(dst);
316 	final switch (settings.htmlOutputStyle)
317 	{
318 		foreach (htmlOutputStyle; EnumMembers!HTMLOutputStyle)
319 		case htmlOutputStyle:
320 		{
321 			rng.compileHTMLDietFile!("ddox.overview.dt", req, info, DdoxDietTraits!(htmlOutputStyle));
322 			return;
323 		}
324 	}
325 }
326 
327 void generateModulePage(OutputStream dst, Package root_package, Module mod, GeneratorSettings settings, string delegate(in Entity) link_to, HTTPServerRequest req = null)
328 {
329 	auto info = new DocPageInfo;
330 	info.linkTo = link_to;
331 	info.settings = settings;
332 	info.rootPackage = root_package;
333 	info.mod = mod;
334 	info.node = mod;
335 	info.docGroups = null;
336 
337 	auto rng = StreamOutputRange(dst);
338 	final switch (settings.htmlOutputStyle)
339 	{
340 		foreach (htmlOutputStyle; EnumMembers!HTMLOutputStyle)
341 		case htmlOutputStyle:
342 		{
343 			rng.compileHTMLDietFile!("ddox.module.dt", req, info, DdoxDietTraits!(htmlOutputStyle));
344 			return;
345 		}
346 	}
347 }
348 
349 void generateDeclPage(OutputStream dst, Package root_package, Module mod, string nested_name, DocGroup[] docgroups, GeneratorSettings settings, string delegate(in Entity) link_to, HTTPServerRequest req = null)
350 {
351 	import std.algorithm : sort;
352 
353 	auto info = new DocPageInfo;
354 	info.linkTo = link_to;
355 	info.settings = settings;
356 	info.rootPackage = root_package;
357 	info.mod = mod;
358 	info.node = mod;
359 	info.docGroups = docgroups;//docGroups(mod.lookupAll!Declaration(nested_name));
360 	sort!((a, b) => cmpKind(a.members[0], b.members[0]))(info.docGroups);
361 	info.nestedName = nested_name;
362 
363 	auto rng = StreamOutputRange(dst);
364 	final switch (settings.htmlOutputStyle)
365 	{
366 		foreach (htmlOutputStyle; EnumMembers!HTMLOutputStyle)
367 		case htmlOutputStyle:
368 		{
369 			rng.compileHTMLDietFile!("ddox.docpage.dt", req, info, DdoxDietTraits!(htmlOutputStyle));
370 			return;
371 		}
372 	}
373 }
374 
375 private bool cmpKind(in Entity a, in Entity b)
376 {
377 	static immutable kinds = [
378 		DeclarationKind.Variable,
379 		DeclarationKind.Function,
380 		DeclarationKind.Struct,
381 		DeclarationKind.Union,
382 		DeclarationKind.Class,
383 		DeclarationKind.Interface,
384 		DeclarationKind.Enum,
385 		DeclarationKind.EnumMember,
386 		DeclarationKind.Template,
387 		DeclarationKind.TemplateParameter,
388 		DeclarationKind.Alias
389 	];
390 
391 	auto ad = cast(const(Declaration))a;
392 	auto bd = cast(const(Declaration))b;
393 
394 	if (!ad && !bd) return false;
395 	if (!ad) return false;
396 	if (!bd) return true;
397 
398 	auto ak = kinds.countUntil(ad.kind);
399 	auto bk = kinds.countUntil(bd.kind);
400 
401 	if (ak < 0 && bk < 0) return false;
402 	if (ak < 0) return false;
403 	if (bk < 0) return true;
404 
405 	return ak < bk;
406 }