Pudu programming language
Menu
Package

@chrismichaelps / pudu-lang-docgen

Documentation publishing for Pudu: articles, API references, navigation, search, and static websites

0.1.0Apache-2.01

InstallClose

Pages.pudu

Pudu206 lines14.2 KB

GitHub ↗
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] }1213/// Tag grouping operations that declare none.14const UNTAGGED: Str = "Operations"1516/// The page of a service and the identities of its operations and schemas. The service's own17/// identity is `uid`; operations are `uid.operationId` and schemas `uid.schemas.Name`.18export 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}5051/// A service spread over several pages: an overview holding servers and schemas, a page per52/// tag when `byTag`, and a page per operation when `byOperation`. Sub-pages live in a folder53/// named after the overview page.54export 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}112113/// Tags in first-use order, with untagged operations last.114fn 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}125126/// The first line of a description.127fn summaryOf(text: Str) -> Str { text.trim().split("\n")[0] }128129/// A body with HTML appended.130fn emit(body: Body, text: Str) -> Body { Body{..body, pieces: body.pieces.push(text)} }131132/// A body with a section heading appended and recorded.133fn 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}138139/// Description text rendered as HTML below the page sections.140fn 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}146147/// A type description with schema names linked to their sections on the page `schemas`148/// names; the empty text means the current page.149fn 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}157158/// One operation with its parameters, body, and responses.159fn 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}188189/// One schema with its properties or enumerated values.190fn 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