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