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 ? `${title}${text ? ":" : ""}` : "";
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) ||
/
(?!\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 {
@property({ type: Object }) config: any;
@property({ type: Object }) data: any;
@property({ type: Number, attribute: "schema-expand-level" }) schemaExpandLevel = 99999;
@property({ type: String, attribute: "schema-description-expanded" }) schemaDescriptionExpanded =
"false";
@property({ type: String, attribute: "allow-schema-description-expand-toggle" })
allowSchemaDescriptionExpandToggle = "false";
@property({ type: String, attribute: "schema-hide-read-only" }) schemaHideReadOnly = "true";
@property({ 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` `
: "";
}
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`
${title}${text ? ":" : ""} ${text}
` : text ? html`${text}
` : ""; hasMore = hasChips && !!body; } else { const rendered = renderMarkdownDescription(text, title); body = html`${rendered.summary ? html`${unsafeHTML(rendered.summary)}` : ""}${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`