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