rapidoc
Version:
RapiDoc - Open API spec viewer with built in console
330 lines (308 loc) • 13.6 kB
text/typescript
import { LitElement, html } from "lit";
import { property } from "lit/decorators.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { marked } from "marked";
import { sanitizeHTML } from "../utils/sanitize.js";
import { blockSummary, firstLineHtml } from "../utils/markdown-utils.js";
// Markdown or HTML syntax, a bare URL or e-mail (GFM autolinks), or a list, quote, heading or rule marker at the start
const MARKDOWN_SYNTAX = /[*_`~[\]<>#|\\&\n]|https?:|ftp:|www\.|@|^([-+]|\d+[.)])\s|^-{3,}$/;
function isPlainText(text) {
return !MARKDOWN_SYNTAX.test(text);
}
// Rendered markdown descriptions by source text: large specs repeat the same description thousands of times
const markdownCache = new Map();
const MARKDOWN_CACHE_SIZE = 5000;
/**
* Markdown description as sanitized HTML, with the one-line summary and whether the row hides content (see renderDescrCell).
* @param {string} text - Markdown description, trimmed
* @param {string} title - Schema title, shown in bold before the description
* @returns {{ summary: string, html: string, hasMore: boolean }}
*/
function renderMarkdownDescription(text, title) {
const key = `${title}\n\n${text}`;
let rendered = markdownCache.get(key);
if (rendered) {
return rendered;
}
const firstBlock: any = text ? marked.lexer(text).find((t: any) => t.type !== "space") : null;
const blockLine = blockSummary(firstBlock);
// A title can only be prefixed inline to a paragraph; before a list, heading, table, ... it gets its own paragraph
const titleHtml = title ? `<b>${title}${text ? ":" : ""}</b>` : "";
const titleOwnBlock = !!titleHtml && !!firstBlock && firstBlock.type !== "paragraph";
const summary = titleOwnBlock
? `${titleHtml} ${blockLine || firstLineHtml(firstBlock)}`
: blockLine;
const markdown =
titleHtml && text ? `${titleHtml}${titleOwnBlock ? "\n\n" : " "}${text}` : titleHtml || text;
const tokens = markdown ? marked.lexer(markdown) : [];
const blocks = tokens.filter((t: any) => t.type !== "space");
rendered = {
summary: summary ? sanitizeHTML(summary) : "",
html: blocks.length ? sanitizeHTML(marked.parser(tokens)).trim() : "",
hasMore:
!!summary ||
blocks.length > 1 ||
(blocks[0]?.type === "list" && blocks[0].items.length > 1) ||
/<br\s*\/?>(?!\s*$)/i.test(markdown),
};
if (markdownCache.size >= MARKDOWN_CACHE_SIZE) {
markdownCache.clear();
}
markdownCache.set(key, rendered);
return rendered;
}
/**
* The host's `schema-hide-*` flags are `"default"` | `"never"`: `"never"` shows the fields, `"default"` keeps
* the `"true"` | `"false"` the template chose for this element (requests hide read-only, responses write-only).
*/
function hideFlagFromConfig(configValue, current) {
if (configValue === "never") return "false";
if (configValue === "true" || configValue === "false") return configValue;
return current;
}
export default class SchemaBaseElement extends LitElement {
({ type: Object }) config: any;
({ type: Object }) data: any;
({ type: Number, attribute: "schema-expand-level" }) schemaExpandLevel = 99999;
({ type: String, attribute: "schema-description-expanded" }) schemaDescriptionExpanded =
"false";
({ type: String, attribute: "allow-schema-description-expand-toggle" })
allowSchemaDescriptionExpandToggle = "false";
({ type: String, attribute: "schema-hide-read-only" }) schemaHideReadOnly = "true";
({ type: String, attribute: "schema-hide-write-only" }) schemaHideWriteOnly = "true";
willUpdate(changedProperties) {
super.willUpdate?.(changedProperties);
if (this.config && changedProperties.has("config")) {
if (this.config.schemaExpandLevel !== undefined)
this.schemaExpandLevel = this.config.schemaExpandLevel;
if (this.config.schemaDescriptionExpanded !== undefined)
this.schemaDescriptionExpanded = this.config.schemaDescriptionExpanded;
if (this.config.allowSchemaDescriptionExpandToggle !== undefined)
this.allowSchemaDescriptionExpandToggle = this.config.allowSchemaDescriptionExpandToggle;
this.schemaHideReadOnly = hideFlagFromConfig(
this.config.schemaHideReadOnly,
this.schemaHideReadOnly,
);
this.schemaHideWriteOnly = hideFlagFromConfig(
this.config.schemaHideWriteOnly,
this.schemaHideWriteOnly,
);
}
if (!this.schemaExpandLevel || this.schemaExpandLevel < 1) {
this.schemaExpandLevel = 99999;
}
if (!this.schemaDescriptionExpanded || !"true false".includes(this.schemaDescriptionExpanded)) {
this.schemaDescriptionExpanded = "false";
}
if (!this.schemaHideReadOnly || !"true false".includes(this.schemaHideReadOnly)) {
this.schemaHideReadOnly = "true";
}
if (!this.schemaHideWriteOnly || !"true false".includes(this.schemaHideWriteOnly)) {
this.schemaHideWriteOnly = "true";
}
}
handleAllEvents(e) {
const toggleTrigger = e.target.closest(".open-bracket, .obj-toggle");
if (toggleTrigger) {
this.toggleObjectExpand(toggleTrigger);
} else if (e.target.classList.contains("schema-multiline-toggle")) {
this.schemaDescriptionExpanded = this.schemaDescriptionExpanded === "true" ? "false" : "true";
} else if (e.target.closest(".descr-expand-toggle, .descr-body")) {
// Links inside a description still navigate, and selecting text to copy it doesn't toggle the row
if (
this.schemaDescriptionExpanded === "true" ||
e.target.closest("a") ||
window.getSelection()?.isCollapsed === false
) {
return;
}
const trEl = e.target.closest(".tr");
if (
trEl &&
(trEl.classList.contains("expanded-descr") ||
this.descrHasMore(e.target.closest(".descr-body")))
) {
const isExpanded = trEl.classList.toggle("expanded-descr");
trEl
.querySelector(".descr-expand-toggle")
?.setAttribute("aria-expanded", isExpanded ? "true" : "false");
}
}
}
/**
* Whether a collapsed description has more to show, so a click expands it: CSS shows the arrow for those rows,
* and a line cut off for length counts too (browsers without scroll-state queries can't show the arrow for it).
* Read at click time only; nothing measures rows as they render or resize.
*/
descrHasMore(bodyEl) {
if (!bodyEl) {
return false;
}
const toggleEl = bodyEl.querySelector(":scope > .descr-expand-toggle");
return (
(toggleEl && getComputedStyle(toggleEl).visibility === "visible") ||
bodyEl.scrollWidth > bodyEl.clientWidth
);
}
handleKeyDown(e) {
if (e.key === "Enter" || e.key === " ") {
const toggleTrigger = e.target.closest(
".open-bracket, .obj-toggle, .descr-expand-toggle, .schema-multiline-toggle",
);
if (toggleTrigger) {
e.preventDefault();
toggleTrigger.click();
}
}
}
toggleObjectExpand(targetEl) {
const rowEl = targetEl.closest(".tr");
if (!rowEl) {
return;
}
const isExpanding = rowEl.classList.contains("collapsed");
if (isExpanding) {
rowEl.classList.replace("collapsed", "expanded");
} else {
rowEl.classList.replace("expanded", "collapsed");
}
const toggleEl = rowEl.querySelector(".open-bracket, .obj-toggle");
if (toggleEl) {
toggleEl.setAttribute("aria-expanded", isExpanding ? "true" : "false");
}
}
isFieldHidden(node) {
if (!node) {
return true;
}
if (this.schemaHideReadOnly === "true" && node.readOnly) {
return true;
}
if (this.schemaHideWriteOnly === "true" && node.writeOnly) {
return true;
}
return false;
}
// Ends with the space before the name, so rows without the icon have no whitespace node there
renderDeprecatedIcon(node) {
return node?.deprecated
? html`<svg viewBox="0 0 10 10" width="10" height="10" style="stroke:var(--destructive); margin-right:-6px">
<path d="M2 2L8 8M2 8L8 2" />
</svg> `
: "";
}
getReadWriteBadge(node) {
return node?.readOnly ? html` 🆁` : node?.writeOnly ? html` 🆆` : "";
}
getReadWriteTip(node) {
return node?.readOnly ? "Read-Only" : node?.writeOnly ? "Write-Only" : "";
}
getDataTypeCss(type) {
return (type || "")
.replace(/┃.*/g, "")
.replace(/[^a-zA-Z0-9+]/g, "")
.substring(0, 4)
.toLowerCase();
}
hasDetailChips(node) {
return Boolean(
node &&
(node.constraints ||
node.defaultValue ||
node.allowedValues ||
node.pattern ||
node.contentMediaType ||
node.contentEncoding),
);
}
renderDetailChips(node) {
if (!node) {
return "";
}
return html`
${node.constraints ? html`<div class="descr-chip"><span class="descr-label">Constraints:</span> <span class="descr-value">${node.constraints}</span></div>` : ""}
${node.defaultValue ? html`<div class="descr-chip"><span class="descr-label">Default:</span> <span class="descr-value">${node.defaultValue}</span></div>` : ""}
${node.allowedValues ? html`<div class="descr-chip"><span class="descr-label">${node.type === "const" ? "Value" : "Allowed"}:</span> <span class="descr-value">${node.allowedValues}</span></div>` : ""}
${node.pattern ? html`<div class="descr-chip"><span class="descr-label">Pattern:</span> <span class="descr-value">${node.pattern}</span></div>` : ""}
${node.contentMediaType ? html`<div class="descr-chip"><span class="descr-label">Media-Type:</span> <span class="descr-value">${node.contentMediaType}</span></div>` : ""}
${node.contentEncoding ? html`<div class="descr-chip"><span class="descr-label">Encoding:</span> <span class="descr-value">${node.contentEncoding}</span></div>` : ""}
`;
}
renderDescriptionHtml(desc, title = "") {
if (!desc && !title) {
return "";
}
const text = title ? (desc ? `<b>${title}:</b> ${desc}` : `<b>${title}</b>`) : desc;
return html`${unsafeHTML(sanitizeHTML(marked(text)))}`;
}
/**
* Description cell shared by every schema row.
* Collapsed rows show only the first markdown block (or the constraint chips, when there is no description) on one line,
* and CSS marks a cut-off line with an ellipsis.
* A first block that cannot fit on one line (table, code block, HTML block, blockquote) is replaced there by a summary
* line (`.descr-summary`), which CSS shows only in collapsed rows. A title before any block other than a paragraph
* (list, heading, table, ...) gets a paragraph of its own, and the summary line is the title plus the block's first line.
* CSS shows the arrow when the row has hidden content (the `has-more` class: more markdown blocks, a line break,
* a summarized block, or chips under the text) or, where scroll-state container queries work, when the line is cut off.
* The arrow sits inside the description box so those queries can reach it.
*
* @param {string} desc - Markdown description
* @param {object} [chipsNode] - Node whose constraints, default, allowed values, etc. are shown as chips
* @param {string} [title] - Schema title, shown in bold before the description
*/
renderDescrCell(desc, chipsNode = null, title = "") {
const text = typeof desc === "string" ? desc.trim() : "";
const hasChips = this.hasDetailChips(chipsNode);
if (!text && !title && !hasChips) {
return html`<div class="td key-descr"></div>`;
}
let body;
let hasMore;
if (isPlainText(text) && isPlainText(title)) {
// Most descriptions are plain sentences: shown as they are, without the markdown parser, the sanitizer and the HTML parse
body = title
? html`<p><b>${title}${text ? ":" : ""}</b> ${text}</p>`
: text
? html`<p>${text}</p>`
: "";
hasMore = hasChips && !!body;
} else {
const rendered = renderMarkdownDescription(text, title);
body = html`${rendered.summary ? html`<span class="descr-summary">${unsafeHTML(rendered.summary)}</span>` : ""}${rendered.html ? unsafeHTML(rendered.html) : ""}`;
hasMore = rendered.hasMore || (!!rendered.html && hasChips);
}
const isExpanded = this.schemaDescriptionExpanded === "true";
// The arrow, the text and the chips sit next to each other: whitespace between them would be a text node per row
return html`
<div class="td key-descr">
<div class="descr-body m-markdown-small ${hasMore ? "has-more" : ""}">
<span
class="descr-expand-toggle"
role="button"
tabindex="0"
aria-label="Toggle description"
aria-expanded="${isExpanded ? "true" : "false"}"
>➔</span
>${body}${hasChips ? html`<div class="item-details">${this.renderDetailChips(chipsNode)}</div>` : ""}
</div>
</div>
`;
}
renderToolbar(rootType) {
return html`
<div class="toolbar">
<div class="toolbar-item schema-root-type ${rootType} ">${rootType}</div>
${
this.allowSchemaDescriptionExpandToggle === "true"
? html`
<div style="flex:1"></div>
<div part="schema-toolbar-item schema-multiline-toggle" class="toolbar-item schema-multiline-toggle">
${this.schemaDescriptionExpanded === "true" ? "Single line description" : "Multiline description"}
</div>
`
: ""
}
</div>
`;
}
}