
Pages.pudu
Pudu206 lines14.2 KB
1/** @Docgen.Rest.Pages — HTTP interface reference pages with linked schemas */2module PuduLangDocgen.Rest.Pages34import PuduLangDocgen as Docgen5import PuduLangDocgen.Markdown as Markdown6import PuduLangDocgen.Markdown.Phrase as Phrase7import PuduLangDocgen.Paths as Paths8import PuduLangDocgen.Rest.OpenApi as OpenApi910/** @Docgen.Rest.Body — page HTML with its outline, links, and findings */11type Body = { pieces: Array[Str], headings: Array[Docgen.Heading], links: Array[Docgen.Link], diagnostics: Array[Docgen.Diagnostic] }121314const UNTAGGED: Str = "Operations"15161718export fn page(service: &OpenApi.Service, uid: Str, context: &Phrase.Scope) -> (Docgen.Page, Array[Docgen.Reference], Array[Docgen.Diagnostic]) {19 var body = Body{pieces: [], headings: [], links: [], diagnostics: []}20 let version = if service.version.isEmpty() { "" } else { "<span class=\"rest-version\">" + Paths.escape(service.version) + "</span>" }21 body = emit(body, "<div class=\"api-heading\"><p class=\"api-kind\">HTTP API</p><h1 id=\"" + Paths.escape(Paths.slug(service.title)) + "\">" + Paths.escape(service.title) + version + "</h1></div>\n")22 body = documentation(body, service.description, context)23 if !service.servers.isEmpty() {24 body = emit(section(body, 2, "servers", "Servers"), "<ul class=\"api-list\">\n")25 for server in service.servers { body = emit(body, "<li><code>" + Paths.escape(server) + "</code></li>\n") }26 body = emit(body, "</ul>\n")27 }28 var references: Array[Docgen.Reference] = [Docgen.Reference{uid: uid, name: service.title, fullName: service.title, href: context.page, kind: "rest"}]29 for tag in tags(&service.operations) {30 body = section(body, 2, "tag-" + Paths.slug(tag), tag)31 for operation in service.operations.filter(|held: OpenApi.Operation| held.tags.contains(tag) || (held.tags.isEmpty() && tag == UNTAGGED)) {32 let id = Paths.slug(operation.id)33 if references.filter(|held: Docgen.Reference| held.uid == uid + "." + operation.id).isEmpty() {34 references = references.push(Docgen.Reference{uid: uid + "." + operation.id, name: operation.id, fullName: operation.method + " " + operation.path, href: context.page + "#" + id, kind: "operation"})35 }36 body = operationSection(body, &operation, id, context, "")37 }38 }39 if !service.schemas.isEmpty() {40 body = section(body, 2, "schemas", "Schemas")41 for schema in service.schemas {42 let id = "schema-" + Paths.slug(schema.name)43 references = references.push(Docgen.Reference{uid: uid + ".schemas." + schema.name, name: schema.name, fullName: schema.name, href: context.page + "#" + id, kind: "schema"})44 body = schemaSection(body, &schema, id, context)45 }46 }47 let found = Docgen.Page{path: context.page, source: context.source, kind: "rest", title: service.title, uid: uid, summary: summaryOf(service.description), body: body.pieces.join(""), headings: body.headings, links: body.links, meta: []}48 (found, references, body.diagnostics)49}5051525354export fn split(service: &OpenApi.Service, uid: Str, context: &Phrase.Scope, byTag: Bool, byOperation: Bool) -> (Array[Docgen.Page], Array[Docgen.Reference], Array[Docgen.Diagnostic]) {55 let root = context.page56 let folder = if root.endsWith(".html") { root.take(root.length() - 5) } else { root }57 let schemaHref = |from: Str| Paths.between(from, root)58 var pages: Array[Docgen.Page] = []59 var references: Array[Docgen.Reference] = [Docgen.Reference{uid: uid, name: service.title, fullName: service.title, href: root, kind: "rest"}]60 var problems: Array[Docgen.Diagnostic] = []61 var overview = Body{pieces: [], headings: [], links: [], diagnostics: []}62 overview = emit(overview, "<div class=\"api-heading\"><p class=\"api-kind\">HTTP API</p><h1 id=\"" + Paths.escape(Paths.slug(service.title)) + "\">" + Paths.escape(service.title) + "</h1></div>\n")63 overview = documentation(overview, service.description, context)64 if !service.servers.isEmpty() {65 overview = emit(section(overview, 2, "servers", "Servers"), "<ul class=\"api-list\">\n" + service.servers.map(|server: Str| "<li><code>" + Paths.escape(server) + "</code></li>").join("\n") + "\n</ul>\n")66 }67 for tag in tags(&service.operations) {68 let operations = service.operations.filter(|held: OpenApi.Operation| held.tags.contains(tag) || (held.tags.isEmpty() && tag == UNTAGGED))69 let tagPage = folder + "/" + Paths.slug(tag) + ".html"70 let listed = operations.map(fn(operation: OpenApi.Operation) -> Str {71 let target = if byOperation { folder + "/" + Paths.slug(operation.id) + ".html" } else { tagPage + "#" + Paths.slug(operation.id) }72 "<li><span class=\"method method-" + operation.method.toLower() + "\">" + operation.method + "</span> <a href=\"" + Paths.escape(Paths.between(root, target)) + "\">" + Paths.escape(if operation.summary.isEmpty() { operation.id } else { operation.summary }) + "</a></li>"73 })74 overview = emit(section(overview, 2, "tag-" + Paths.slug(tag), tag), "<ul class=\"api-list rest-index\">\n" + listed.join("\n") + "\n</ul>\n")75 if byTag {76 let located = Phrase.Scope{..*context, page: tagPage}77 var body = emit(Body{pieces: [], headings: [], links: [], diagnostics: []}, "<div class=\"api-heading\"><p class=\"api-kind\">" + Paths.escape(service.title) + "</p><h1 id=\"" + Paths.escape(Paths.slug(tag)) + "\">" + Paths.escape(tag) + "</h1></div>\n")78 for operation in operations {79 if byOperation {80 body = emit(body, "<p><span class=\"method method-" + operation.method.toLower() + "\">" + operation.method + "</span> <a href=\"" + Paths.escape(Paths.between(tagPage, folder + "/" + Paths.slug(operation.id) + ".html")) + "\">" + Paths.escape(operation.path) + "</a></p>\n")81 } else { body = operationSection(body, &operation, Paths.slug(operation.id), &located, schemaHref(tagPage)) }82 }83 pages = pages.push(Docgen.Page{path: tagPage, source: context.source, kind: "rest", title: tag, uid: uid + ".tags." + tag, summary: "", body: body.pieces.join(""), headings: body.headings, links: body.links, meta: []})84 problems = problems.concat(body.diagnostics)85 references = references.push(Docgen.Reference{uid: uid + ".tags." + tag, name: tag, fullName: tag, href: tagPage, kind: "tag"})86 }87 for operation in operations {88 if references.filter(|held: Docgen.Reference| held.uid == uid + "." + operation.id).isEmpty() {89 let destination = if byOperation { folder + "/" + Paths.slug(operation.id) + ".html" } else if byTag { tagPage + "#" + Paths.slug(operation.id) } else { root + "#" + Paths.slug(operation.id) }90 references = references.push(Docgen.Reference{uid: uid + "." + operation.id, name: operation.id, fullName: operation.method + " " + operation.path, href: destination, kind: "operation"})91 if byOperation {92 let path = folder + "/" + Paths.slug(operation.id) + ".html"93 let located = Phrase.Scope{..*context, page: path}94 let body = operationSection(Body{pieces: [], headings: [], links: [], diagnostics: []}, &operation, Paths.slug(operation.id), &located, schemaHref(path))95 pages = pages.push(Docgen.Page{path: path, source: context.source, kind: "rest", title: if operation.summary.isEmpty() { operation.id } else { operation.summary }, uid: "", summary: summaryOf(operation.description), body: body.pieces.join(""), headings: body.headings, links: body.links, meta: []})96 problems = problems.concat(body.diagnostics)97 }98 }99 }100 }101 if !service.schemas.isEmpty() {102 overview = section(overview, 2, "schemas", "Schemas")103 for schema in service.schemas {104 let id = "schema-" + Paths.slug(schema.name)105 references = references.push(Docgen.Reference{uid: uid + ".schemas." + schema.name, name: schema.name, fullName: schema.name, href: root + "#" + id, kind: "schema"})106 overview = schemaSection(overview, &schema, id, context)107 }108 }109 let first = Docgen.Page{path: root, source: context.source, kind: "rest", title: service.title, uid: uid, summary: summaryOf(service.description), body: overview.pieces.join(""), headings: overview.headings, links: overview.links, meta: []}110 ([first].concat(pages), references, problems.concat(overview.diagnostics))111}112113114fn tags(operations: &Array[OpenApi.Operation]) -> Array[Str] {115 var result: Array[Str] = []116 var untagged = false117 for operation in *operations {118 if operation.tags.isEmpty() { untagged = true }119 for tag in operation.tags {120 if !result.contains(tag) { result = result.push(tag) }121 }122 }123 if untagged && !result.contains(UNTAGGED) { result.push(UNTAGGED) } else { result }124}125126127fn summaryOf(text: Str) -> Str { text.trim().split("\n")[0] }128129130fn emit(body: Body, text: Str) -> Body { Body{..body, pieces: body.pieces.push(text)} }131132133fn section(body: Body, level: Int, id: Str, title: Str) -> Body {134 let tag = "h" + show(level)135 let recorded = Body{..body, headings: body.headings.push(Docgen.Heading{id: id, title: title, level: level})}136 emit(recorded, "<" + tag + " id=\"" + Paths.escape(id) + "\">" + Paths.escape(title) + "<a class=\"anchor\" href=\"#" + Paths.escape(id) + "\" aria-label=\"Link to this section\"></a></" + tag + ">\n")137}138139140fn documentation(body: Body, text: Str, context: &Phrase.Scope) -> Body {141 if text.trim().isEmpty() { return body }142 let rendered = Markdown.fragment(text, context, 2)143 let recorded = Body{..body, links: body.links.concat(rendered.links), diagnostics: body.diagnostics.concat(rendered.diagnostics)}144 emit(recorded, "<div class=\"api-doc\">\n" + rendered.html + "</div>\n")145}146147148149fn kind(written: Str, schemas: Str) -> Str {150 var pieces: Array[Str] = []151 for word in written.split(" ") {152 let plain = ["array", "of", "or", "and", "any", "object", "string", "integer", "number", "boolean", "null"].contains(word) || word.startsWith("(")153 pieces = pieces.push(if plain { Paths.escape(word) } else { "<a class=\"xref\" href=\"" + Paths.escape(schemas) + "#schema-" + Paths.escape(Paths.slug(word)) + "\">" + Paths.escape(word) + "</a>" })154 }155 pieces.join(" ")156}157158159fn operationSection(body: Body, operation: &OpenApi.Operation, id: Str, context: &Phrase.Scope, schemas: Str) -> Body {160 let title = if operation.summary.isEmpty() { operation.id } else { operation.summary }161 var held = emit(body, "<section class=\"rest-operation\">\n")162 held = section(held, 3, id, title)163 let deprecated = if operation.deprecated { "<span class=\"badge deprecated\">Deprecated</span>" } else { "" }164 held = emit(held, "<p class=\"rest-endpoint\"><span class=\"method method-" + operation.method.toLower() + "\">" + operation.method + "</span><code>" + Paths.escape(operation.path) + "</code>" + deprecated + "</p>\n")165 held = documentation(held, operation.description, context)166 if !operation.parameters.isEmpty() {167 held = emit(held, "<h4>Parameters</h4>\n<div class=\"table-wrapper\"><table class=\"api-table\"><thead><tr><th>Name</th><th>In</th><th>Type</th><th>Required</th><th>Description</th></tr></thead><tbody>\n")168 for parameter in operation.parameters {169 held = emit(held, "<tr><td><code>" + Paths.escape(parameter.name) + "</code></td><td>" + Paths.escape(parameter.location) + "</td><td>" + kind(parameter.kind, schemas) + "</td><td>" + (if parameter.required { "Yes" } else { "No" }) + "</td><td>" + Paths.escape(parameter.description) + "</td></tr>\n")170 }171 held = emit(held, "</tbody></table></div>\n")172 }173 if !operation.body.isEmpty() {174 held = emit(held, "<h4>Request body" + (if operation.bodyRequired { " <span class=\"badge\">Required</span>" } else { "" }) + "</h4>\n<ul class=\"api-list\">\n")175 for payload in operation.body { held = emit(held, "<li><code>" + Paths.escape(payload.media) + "</code> " + kind(payload.kind, schemas) + "</li>\n") }176 held = emit(held, "</ul>\n")177 }178 if !operation.responses.isEmpty() {179 held = emit(held, "<h4>Responses</h4>\n<div class=\"table-wrapper\"><table class=\"api-table\"><thead><tr><th>Status</th><th>Description</th><th>Content</th></tr></thead><tbody>\n")180 for response in operation.responses {181 let content = response.payloads.map(|payload: OpenApi.Payload| "<code>" + Paths.escape(payload.media) + "</code> " + kind(payload.kind, schemas)).join("<br>")182 held = emit(held, "<tr><td><span class=\"status status-" + Paths.escape(response.status.take(1)) + "xx\">" + Paths.escape(response.status) + "</span></td><td>" + Paths.escape(response.description) + "</td><td>" + content + "</td></tr>\n")183 }184 held = emit(held, "</tbody></table></div>\n")185 }186 emit(held, "</section>\n")187}188189190fn schemaSection(body: Body, schema: &OpenApi.Schema, id: Str, context: &Phrase.Scope) -> Body {191 var held = section(body, 3, id, schema.name)192 held = emit(held, "<p class=\"rest-schema-kind\">" + kind(schema.kind, "") + "</p>\n")193 held = documentation(held, schema.description, context)194 if !schema.properties.isEmpty() {195 held = emit(held, "<div class=\"table-wrapper\"><table class=\"api-table\"><thead><tr><th>Property</th><th>Type</th><th>Required</th><th>Description</th></tr></thead><tbody>\n")196 for property in schema.properties {197 held = emit(held, "<tr><td><code>" + Paths.escape(property.name) + "</code></td><td>" + kind(property.kind, "") + "</td><td>" + (if property.required { "Yes" } else { "No" }) + "</td><td>" + Paths.escape(property.description) + "</td></tr>\n")198 }199 held = emit(held, "</tbody></table></div>\n")200 }201 if !schema.values.isEmpty() {202 held = emit(held, "<p>Allowed values: " + schema.values.map(|value: Str| "<code>" + Paths.escape(value) + "</code>").join(", ") + "</p>\n")203 }204 held205}206