rapidoc
Version:
RapiDoc - Open API spec viewer with built in console
1,855 lines • 69.5 kB
text/typescript
import { LitElement, noChange } from "lit";
import { customElement, property } from "lit/decorators.js";
import { fixRenderedAnchorLinks } from "~/utils/markdown-utils";
import { marked } from "marked";
import Slugger from "github-slugger";
import { scheduleHighlight } from "~/utils/highlighter";
// Styles
import FontStyles from "~/styles/font-styles";
import InputStyles from "~/styles/input-styles";
import FlexStyles from "~/styles/flex-styles";
import AuthStyles from "~/styles/auth-styles";
import ParamListStyles from "~/styles/param-list-styles";
import EndpointStyles from "~/styles/endpoint-styles";
import MicrolighterStyles from "~/styles/microlighter-styles";
import TabStyles from "~/styles/tab-styles";
import NavStyles from "~/styles/nav-styles";
import InfoStyles from "~/styles/info-styles";
import CustomStyles from "~/styles/custom-styles";
import DialogBoxStyles from "~/styles/dialog-box-styles";
import LayoutStyles from "~/styles/layout-styles";
import ThemeTokens, {
DEFAULT_THEME,
applyCustomTheme,
isValidHexColor,
normalizeTheme,
} from "~/styles/theme-tokens";
import {
advancedSearch,
getMatchedPaths,
getMatchedComponents,
rapidocApiKey,
sleep,
} from "~/utils/common-utils";
import ProcessSpec from "~/utils/spec-parser";
import { enableMockServer, disableMockServer, updateMockConfig } from "~/utils/mock-interceptor";
import mainBodyTemplate from "~/templates/main-body-template";
import { expandParentNavContainers } from "~/templates/navbar-template";
import { applyApiKey, onClearAllApiKeys } from "~/templates/security-scheme-template";
import { setApiServer } from "~/templates/server-template";
import {
generateMcpServer as mcpServerGenerator,
buildMcpManifest,
estimateTokenCost,
parseMcpEnabled,
createZip,
} from "~/utils/mcp-generator";
import { bindNavBar } from "~/utils/bind-navbar";
import { methodPathSlug } from "~/utils/slugger";
import { AutoColorSchemeController } from "~/utils/auto-color-scheme";
const paramsConverter = {
fromAttribute: (attr) => {
if (!attr) {
return null;
}
const trimmed = attr.trim();
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
try {
return JSON.parse(trimmed);
} catch {
return trimmed;
}
}
return trimmed;
},
toAttribute: (prop) => {
if (typeof prop === "object" && prop !== null) {
return JSON.stringify(prop);
}
return prop;
},
};
// Input that marks the reader's first interaction with the page (see getContentScrollBehavior)
const INTERACTION_EVENTS = ["pointerdown", "keydown", "wheel", "touchstart"];
@customElement("rapi-doc")
export default class RapiDoc extends LitElement {
constructor() {
super();
this.showSummaryWhenCollapsed = true;
this._intersectingElements = new Map();
// Will activate intersection observer only after spec load and hash analyze
// to scroll to the proper element without being reverted by observer behavior
this.isIntersectionObserverActive = false;
this._userSetLayout = false;
this._isDefaultingLayout = false;
this._observerLockTimeout = null;
this._observerLockCleanup = null;
// Set on the reader's first pointer, key, wheel or touch input (see getContentScrollBehavior)
this._readerHasInteracted = false;
this._onFirstInteraction = null;
this._onHashChange = null;
// Spec loads carry a token; only the latest load may apply its result (see loadSpec)
this._specLoadSeq = 0;
// The spec-url attribute value whose load has been handled, or that an inlined spec superseded
this._lastSpecUrlRequested = null;
this.initIntersectionObserver();
}
// Heading
@property({ type: String, attribute: "heading-text" }) headingText = "";
@property({ type: String, attribute: "goto-path" }) gotoPath = "";
// Spec
@property({ type: String, attribute: "update-route" }) updateRoute = "true";
@property({ type: String, attribute: "spec-url" }) specUrl = "";
@property({ type: String, attribute: "sort-tags" }) sortTags = "false";
@property({ type: String, attribute: "sort-schemas" }) sortSchemas = "false";
@property({ type: String, attribute: "generate-missing-tags" }) generateMissingTags = "false";
@property({ type: String, attribute: "show-unused-tags" }) showUnusedTags = "false";
@property({ type: String, attribute: "sort-endpoints-by" }) sortEndpointsBy = "path";
@property({ type: String, attribute: false }) specFile: any;
// UI Layouts
@property({ type: String, reflect: true }) layout = "row";
@property({ type: String, attribute: "render-style" }) renderStyle = "read";
@property({ type: String, attribute: "default-schema-tab" }) defaultSchemaTab = "example";
@property({ type: String, attribute: "response-area-height" }) responseAreaHeight = "400px";
@property({ type: String, attribute: "fill-request-fields-with-example" })
fillRequestFieldsWithExample = "true";
@property({ type: String, attribute: "persist-auth" }) persistAuth = "false";
// Schema Styles
@property({ type: String, attribute: "schema-style" }) schemaStyle = "tree";
@property({ type: Number, attribute: "schema-expand-level" }) schemaExpandLevel = 99999;
@property({ type: String, attribute: "schema-description-expanded" }) schemaDescriptionExpanded =
"false";
@property({ type: String, attribute: "schema-hide-read-only" }) schemaHideReadOnly = "true";
@property({ type: String, attribute: "schema-hide-write-only" }) schemaHideWriteOnly = "true";
// API Server
@property({ type: String, attribute: "api-key-name" }) apiKeyName = "";
@property({ type: String, attribute: "api-key-location" }) apiKeyLocation = "header";
@property({ type: String, attribute: "api-key-value" }) apiKeyValue = "-";
@property({ type: String, attribute: "default-api-server" }) defaultApiServerUrl = "";
@property({ type: String, attribute: "server-url" }) serverUrl = "";
@property({ type: String, attribute: "oauth-receiver" }) oauthReceiver = "oauth-receiver.html";
@property({ type: Object, attribute: "additional-authorize-params", converter: paramsConverter })
additionalAuthorizeParams: any;
@property({ type: Object, attribute: "additional-token-params", converter: paramsConverter })
additionalTokenParams: any;
// Mock Server
@property({ type: String, attribute: "mock-server" }) mockServer = "false";
// A string of codes for every operation, or a map by operation (`{ "POST /orders": "503, 200", "*": "200" }`)
@property({ type: String, attribute: "mock-server-status-code" }) mockServerStatusCode:
| string
| Record<string, string> = "200";
@property({ type: String, attribute: "mock-server-status-strategy" }) mockServerStatusStrategy =
"example";
@property({ type: Number, attribute: "mock-server-delay" }) mockServerDelay = 0;
@property({ type: String, attribute: "mock-server-log" }) mockServerLog = "false";
// Hide/Show Sections & Enable Disable actions
@property({ type: String, attribute: "show-header" }) showHeader = "true";
@property({ type: String, attribute: "show-side-nav" }) showSideNav = "true";
@property({ type: String, attribute: "show-info" }) showInfo = "true";
@property({ type: String, attribute: "allow-authentication" }) allowAuthentication = "true";
@property({ type: String, attribute: "allow-try" }) allowTry = "true";
@property({ type: String, attribute: "show-curl-before-try" }) showCurlBeforeTry = "false";
@property({ type: String, attribute: "allow-spec-url-load" }) allowSpecUrlLoad = "true";
@property({ type: String, attribute: "allow-spec-file-load" }) allowSpecFileLoad = "true";
@property({ type: String, attribute: "allow-spec-file-download" }) allowSpecFileDownload =
"false";
@property({ type: String, attribute: "allow-search" }) allowSearch = "true";
@property({ type: String, attribute: "allow-advanced-search" }) allowAdvancedSearch = "true";
@property({ type: String, attribute: "allow-server-selection" }) allowServerSelection = "true";
@property({ type: String, attribute: "allow-schema-description-expand-toggle" })
allowSchemaDescriptionExpandToggle = "true";
// "true" shows the Export MCP Server button; "none" or "false" hides it
@property({ type: String, attribute: "mcp" }) mcp = "false";
@property({ type: String, attribute: "mcp-server-name" }) mcpServerName = "";
@property({ type: String, attribute: "show-components" }) showComponents = "false";
@property({ type: String, attribute: "page-direction" }) pageDirection = "ltr";
@property({ type: String, attribute: "scroll-behavior" }) scrollBehavior = "smooth";
@property({ type: String, attribute: "auto-height" }) autoHeight = "false";
@property({ type: String, attribute: "scroll-container" }) scrollContainer = "";
@property({ type: String, attribute: "scroll-offset" }) scrollOffset = "";
// Main Colors and Font
@property({ type: String, reflect: true }) theme = DEFAULT_THEME;
@property({ type: String, attribute: "primary-color" }) primaryColor = "";
@property({ type: String, attribute: "color-scheme", reflect: true }) colorScheme = "dark";
// color-scheme="auto": follows the page's theme (see utils/auto-color-scheme.js)
_autoColorScheme = new AutoColorSchemeController(this);
@property({ type: String, reflect: true }) radius = "sm";
@property({ type: String, reflect: true }) scale = "default";
@property({ type: String, attribute: "css-file" }) cssFile = "";
@property({ type: String, attribute: "css-classes" }) cssClasses = "";
// Nav Bar
@property({ type: String, attribute: "nav-item-spacing", reflect: true }) navItemSpacing =
"default";
@property({ type: String, attribute: "nav-item-overflow", reflect: true }) navItemOverflow =
"ellipsis";
@property({ type: String, attribute: "show-method-in-nav-bar" }) showMethodInNavBar = "false";
@property({ type: String, attribute: "use-path-in-nav-bar" }) usePathInNavBar = "false";
@property({ type: String, attribute: "info-description-headings-in-navbar" })
infoDescriptionHeadingsInNavBar = "false";
// Fetch Options
@property({ type: String, attribute: "fetch-credentials" }) fetchCredentials = "";
// Filters
@property({ type: String, attribute: "match-paths" }) matchPaths = "";
@property({ type: String, attribute: "match-type" }) matchType = "includes";
@property({ type: String, attribute: "remove-endpoints-with-badge-label-as" })
removeEndpointsWithBadgeLabelAs = "";
// Internal Properties
@property({ type: Boolean }) loading = false;
@property({ type: String }) focusedElementId = "";
@property({ type: Object }) advancedSearchMatches: any;
@property({ type: String }) searchVal = "";
static get styles() {
return [
ThemeTokens,
FontStyles,
InputStyles,
FlexStyles,
AuthStyles,
ParamListStyles,
EndpointStyles,
MicrolighterStyles,
TabStyles,
NavStyles,
InfoStyles,
DialogBoxStyles,
LayoutStyles,
CustomStyles,
];
}
// Startup
connectedCallback() {
super.connectedCallback();
this.initIntersectionObserver();
if (this.shadowRoot && this.shadowRoot.querySelector(".main-content")) {
this._hasDsd = true;
}
const parent = this.parentElement;
if (parent && parent.tagName === "BODY") {
if (parent.offsetWidth === 0 && parent.style.width === "") {
parent.style.width = "100vw";
}
if (parent.offsetHeight === 0 && parent.style.height === "") {
parent.style.height = "100vh";
}
if (!parent.style.marginTop) {
parent.style.marginTop = "0";
}
if (!parent.style.marginRight) {
parent.style.marginRight = "0";
}
if (!parent.style.marginBottom) {
parent.style.marginBottom = "0";
}
if (!parent.style.marginLeft) {
parent.style.marginLeft = "0";
}
}
if (!this.renderStyle || !"read, view, focused,".includes(`${this.renderStyle},`)) {
this.renderStyle = "focused";
}
if (this.hasAttribute("layout")) {
const attrLayout = this.getAttribute("layout");
if ("row, column,".includes(`${attrLayout},`)) {
this.layout = attrLayout;
this._userSetLayout = true;
}
}
if (!this._userSetLayout) {
const defaultLayout =
this.renderStyle === "read" || this.renderStyle === "focused" ? "column" : "row";
this._isDefaultingLayout = true;
this.layout = defaultLayout;
this.setAttribute("layout", defaultLayout);
this._isDefaultingLayout = false;
} else if (!this.layout || !"row, column,".includes(`${this.layout},`)) {
this.layout =
this.renderStyle === "read" || this.renderStyle === "focused" ? "column" : "row";
this.setAttribute("layout", this.layout);
}
if (!this.schemaStyle || !"tree, table,".includes(`${this.schemaStyle},`)) {
this.schemaStyle = "tree";
}
if (this.theme === "light" || this.theme === "dark") {
this.colorScheme = this.theme;
this.theme = DEFAULT_THEME;
}
if (this.primaryColor && isValidHexColor(this.primaryColor)) {
this.theme = this.primaryColor;
}
const normalizedTheme = normalizeTheme(this.theme);
this.theme = normalizedTheme.theme;
this.setAttribute("theme", this.theme);
if (!this.colorScheme || !"dark, light, system,".includes(`${this.colorScheme},`)) {
this.colorScheme = "dark";
}
if (!this.radius || !"none, sm, md, lg, full,".includes(`${this.radius},`)) {
this.radius = "sm";
}
if (!this.scale || !"sm, default, lg,".includes(`${this.scale},`)) {
this.scale = "default";
}
if (!this.navItemSpacing || !"compact, default, relaxed,".includes(`${this.navItemSpacing},`)) {
this.navItemSpacing = "default";
}
if (!this.getAttribute("theme")) {
this.setAttribute("theme", this.theme);
}
if (!this.getAttribute("color-scheme")) {
this.setAttribute("color-scheme", this.colorScheme);
}
if (!this.getAttribute("radius")) {
this.setAttribute("radius", this.radius);
}
if (!this.getAttribute("scale")) {
this.setAttribute("scale", this.scale);
}
applyCustomTheme(this, this.theme);
if (
!this.defaultSchemaTab ||
!"example, schema, model,".includes(`${this.defaultSchemaTab},`)
) {
this.defaultSchemaTab = "example";
} else if (this.defaultSchemaTab === "model") {
this.defaultSchemaTab = "schema";
}
if (!this.schemaExpandLevel || this.schemaExpandLevel < 1) {
this.schemaExpandLevel = 99999;
}
if (
!this.schemaDescriptionExpanded ||
!"true, false,".includes(`${this.schemaDescriptionExpanded},`)
) {
this.schemaDescriptionExpanded = "false";
}
if (!this.schemaHideReadOnly || !"default, never,".includes(`${this.schemaHideReadOnly},`)) {
this.schemaHideReadOnly = "default";
}
if (!this.schemaHideWriteOnly || !"default, never,".includes(`${this.schemaHideWriteOnly},`)) {
this.schemaHideWriteOnly = "default";
}
if (
!this.fillRequestFieldsWithExample ||
!"true, false,".includes(`${this.fillRequestFieldsWithExample},`)
) {
this.fillRequestFieldsWithExample = "true";
}
if (!this.persistAuth || !"true, false,".includes(`${this.persistAuth},`)) {
this.persistAuth = "false";
}
if (!this.responseAreaHeight) {
this.responseAreaHeight = "400px";
}
if (!this.allowSearch || !"true, false,".includes(`${this.allowSearch},`)) {
this.allowSearch = "true";
}
if (!this.allowAdvancedSearch || !"true, false,".includes(`${this.allowAdvancedSearch},`)) {
this.allowAdvancedSearch = "true";
}
if (!this.allowTry || !"true, false,".includes(`${this.allowTry},`)) {
this.allowTry = "true";
}
if (!this.apiKeyValue) {
this.apiKeyValue = "-";
}
if (!this.apiKeyLocation) {
this.apiKeyLocation = "header";
}
if (!this.apiKeyName) {
this.apiKeyName = "";
}
if (!this.oauthReceiver) {
this.oauthReceiver = "oauth-receiver.html";
}
if (!this.updateRoute || !"true, false,".includes(`${this.updateRoute},`)) {
this.updateRoute = "true";
}
if (!this.sortTags || !"true, false,".includes(`${this.sortTags},`)) {
this.sortTags = "false";
}
if (!this.sortSchemas || !"true, false,".includes(`${this.sortSchemas},`)) {
this.sortSchemas = "false";
}
if (!this.generateMissingTags || !"true, false,".includes(`${this.generateMissingTags},`)) {
this.generateMissingTags = "false";
}
if (!this.showUnusedTags || !"true, false,".includes(`${this.showUnusedTags},`)) {
this.showUnusedTags = "false";
}
if (
!this.sortEndpointsBy ||
!"method, path, summary, none,".includes(`${this.sortEndpointsBy},`)
) {
this.sortEndpointsBy = "path";
}
if (!this.navItemSpacing || !"compact, relaxed, default,".includes(`${this.navItemSpacing},`)) {
this.navItemSpacing = "default";
}
if (
!this.showMethodInNavBar ||
!"false, as-plain-text, as-colored-text, as-colored-block,".includes(
`${this.showMethodInNavBar},`,
)
) {
this.showMethodInNavBar = "false";
}
if (!this.usePathInNavBar || !"true, false,".includes(`${this.usePathInNavBar},`)) {
this.usePathInNavBar = "false";
}
if (!this.navItemOverflow || !"ellipsis, wrap,".includes(`${this.navItemOverflow},`)) {
this.navItemOverflow = "ellipsis";
}
if (!this.getAttribute("nav-item-overflow")) {
this.setAttribute("nav-item-overflow", this.navItemOverflow);
}
if (!this.showInfo || !"true, false,".includes(`${this.showInfo},`)) {
this.showInfo = "true";
}
if (!this.allowServerSelection || !"true, false,".includes(`${this.allowServerSelection},`)) {
this.allowServerSelection = "true";
}
if (!this.allowAuthentication || !"true, false,".includes(`${this.allowAuthentication},`)) {
this.allowAuthentication = "true";
}
if (
!this.allowSchemaDescriptionExpandToggle ||
!"true, false,".includes(`${this.allowSchemaDescriptionExpandToggle},`)
) {
this.allowSchemaDescriptionExpandToggle = "true";
}
if (!this.showSideNav || !"true false".includes(this.showSideNav)) {
this.showSideNav = "true";
}
if (!this.showComponents || !"true false".includes(this.showComponents)) {
this.showComponents = "false";
}
if (
!this.infoDescriptionHeadingsInNavBar ||
!"true, false,".includes(`${this.infoDescriptionHeadingsInNavBar},`)
) {
this.infoDescriptionHeadingsInNavBar = "false";
}
if (
!this.fetchCredentials ||
!"omit, same-origin, include,".includes(`${this.fetchCredentials},`)
) {
this.fetchCredentials = "";
}
if (!this.scrollBehavior || !"smooth, auto,".includes(`${this.scrollBehavior},`)) {
this.scrollBehavior = "auto";
}
if (!this.matchType || !"includes regex".includes(this.matchType)) {
this.matchType = "includes";
}
if (!this.matchPaths) {
this.matchPaths = "";
}
if (!this.removeEndpointsWithBadgeLabelAs) {
this.removeEndpointsWithBadgeLabelAs = "";
}
if (!this.cssFile) {
this.cssFile = null;
}
if (!this.cssClasses) {
this.cssClasses = "";
}
if (!this.mcp) {
this.mcp = "none";
}
if (!this._onHashChange) {
this._onHashChange = () => {
this.scrollToPath(this.getElementIDFromURL());
};
window.addEventListener("hashchange", this._onHashChange, true);
}
if (!this._readerHasInteracted && !this._onFirstInteraction) {
this._onFirstInteraction = () => {
this._readerHasInteracted = true;
this.removeFirstInteractionListeners();
};
INTERACTION_EVENTS.forEach((type) =>
window.addEventListener(type, this._onFirstInteraction, { capture: true, passive: true }),
);
}
// Auto-hydrate from inlined spec or spec-url attribute if not already loaded. This block owns
// the initial load; the deferred spec-url path in attributeChangedCallback skips the value
// recorded in _lastSpecUrlRequested so one <rapi-doc spec-url> loads once.
if (!this.resolvedSpec) {
const inlinedSpecEl = this.querySelector(
'script[type="application/json"][slot="rapidoc-spec"]',
);
if (inlinedSpecEl?.textContent) {
try {
const parsed = JSON.parse(inlinedSpecEl.textContent);
// The inlined spec wins over a spec-url attribute present at connect
this._lastSpecUrlRequested = this.getAttribute("spec-url");
this.loadSpec(parsed);
} catch {
// ignore parse error
}
} else if (this.getAttribute("spec-url")) {
this.loadSpec(this.getAttribute("spec-url"));
}
}
}
// Cleanup
disconnectedCallback() {
if (this._intersectionRaf) {
cancelAnimationFrame(this._intersectionRaf);
}
if (typeof this._observerLockCleanup === "function") {
this._observerLockCleanup();
this._observerLockCleanup = null;
}
if (this._observerLockTimeout) {
clearTimeout(this._observerLockTimeout);
this._observerLockTimeout = null;
}
if (this.intersectionObserver) {
this.intersectionObserver.disconnect();
}
if (this._onHashChange) {
window.removeEventListener("hashchange", this._onHashChange, true);
this._onHashChange = null;
}
this.removeFirstInteractionListeners();
if (this.mockServer === "true") {
disableMockServer(this);
}
super.disconnectedCallback();
}
removeFirstInteractionListeners() {
if (!this._onFirstInteraction) return;
INTERACTION_EVENTS.forEach((type) =>
window.removeEventListener(type, this._onFirstInteraction, { capture: true }),
);
this._onFirstInteraction = null;
}
/**
* Behavior of scrolls that move the content to a section. Scrolls before the reader's first input are
* load-time navigation (location hash, goto-path, back/forward) and jump, like the browser's own
* fragment navigation. A smooth scroll advances only while the page renders frames, and a background
* tab renders none: the scroll would not happen, and the active-section tracking would then report the
* section at the top of the page (and rewrite the URL to it).
*/
getContentScrollBehavior() {
return this._readerHasInteracted ? this.scrollBehavior : "instant";
}
infoDescriptionHeadingRenderer() {
const renderer = new marked.Renderer();
const slugger = new Slugger();
renderer.heading = ({ text, depth }) =>
`<h${depth} class="observe-me" id="${slugger.slug(text)}">${text}</h${depth}>`;
fixRenderedAnchorLinks(renderer);
return renderer;
}
willUpdate(changedProperties) {
super.willUpdate?.(changedProperties);
if (this.defaultSchemaTab === "model") {
this.defaultSchemaTab = "schema";
}
if (changedProperties.has("layout") && !this._isDefaultingLayout) {
this._userSetLayout = Boolean(this.layout && "row, column,".includes(`${this.layout},`));
}
if (changedProperties.has("renderStyle") && !this._userSetLayout) {
const targetLayout =
this.renderStyle === "read" || this.renderStyle === "focused" ? "column" : "row";
if (this.layout !== targetLayout) {
this._isDefaultingLayout = true;
this.layout = targetLayout;
this.setAttribute("layout", targetLayout);
this._isDefaultingLayout = false;
}
}
}
_clearDsdIfNeeded() {
if (this._hasDsd && this.shadowRoot) {
this.shadowRoot.replaceChildren();
if (this.renderOptions) {
delete this.renderOptions.renderBefore;
}
this._hasDsd = false;
}
}
render() {
if (this._hasDsd && !this.resolvedSpec) {
return noChange;
}
this._clearDsdIfNeeded();
// adding custom style for RapiDoc: the page's <link> whose href contains the css-file value
if (this.cssFile) {
const cssLinkEl = document.querySelector(`link[href*="${CSS.escape(this.cssFile)}"]`);
if (cssLinkEl) {
this.shadowRoot.appendChild(cssLinkEl.cloneNode());
}
}
return mainBodyTemplate.call(this);
}
get config() {
return {
renderStyle: this.renderStyle,
schemaStyle: this.schemaStyle,
defaultSchemaTab: this.defaultSchemaTab,
schemaExpandLevel: this.schemaExpandLevel,
schemaDescriptionExpanded: this.schemaDescriptionExpanded,
allowSchemaDescriptionExpandToggle: this.allowSchemaDescriptionExpandToggle,
schemaHideReadOnly: this.schemaHideReadOnly,
schemaHideWriteOnly: this.schemaHideWriteOnly,
fillRequestFieldsWithExample: this.fillRequestFieldsWithExample,
allowTry: this.allowTry,
showCurlBeforeTry: this.showCurlBeforeTry,
fetchCredentials: this.fetchCredentials,
};
}
updated(changedProperties) {
super.updated?.(changedProperties);
scheduleHighlight(this.shadowRoot);
if (changedProperties.has("theme")) {
if (this.theme === "light" || this.theme === "dark") {
this.colorScheme = this.theme;
this.setAttribute("color-scheme", this.colorScheme);
this.theme = DEFAULT_THEME;
this.setAttribute("theme", DEFAULT_THEME);
}
const normalizedTheme = normalizeTheme(this.theme);
if (this.theme !== normalizedTheme.theme) {
this.theme = normalizedTheme.theme;
this.setAttribute("theme", this.theme);
}
applyCustomTheme(this, this.theme);
}
if (
changedProperties.has("primaryColor") &&
this.primaryColor &&
isValidHexColor(this.primaryColor)
) {
this.theme = normalizeTheme(this.primaryColor).theme;
this.setAttribute("theme", this.theme);
applyCustomTheme(this, this.theme);
}
if (changedProperties.has("colorScheme")) {
if (!this.colorScheme || !"dark, light, system,".includes(`${this.colorScheme},`)) {
this.colorScheme = "dark";
}
if (this.getAttribute("color-scheme") !== this.colorScheme) {
this.setAttribute("color-scheme", this.colorScheme);
}
}
if (changedProperties.has("mockServer")) {
if (this.mockServer === "true" && this.resolvedSpec) {
enableMockServer(this.resolvedSpec, {
owner: this,
statusCode: this.mockServerStatusCode,
statusStrategy: this.mockServerStatusStrategy,
delay: this.mockServerDelay,
log: this.mockServerLog,
});
} else if (this.mockServer !== "true") {
disableMockServer(this);
}
}
if (
changedProperties.has("mockServerStatusCode") ||
changedProperties.has("mockServerStatusStrategy") ||
changedProperties.has("mockServerDelay") ||
changedProperties.has("mockServerLog")
) {
updateMockConfig({
owner: this,
statusCode: this.mockServerStatusCode,
statusStrategy: this.mockServerStatusStrategy,
delay: this.mockServerDelay,
log: this.mockServerLog,
});
}
if (changedProperties.has("scrollContainer")) {
this.initIntersectionObserver();
}
}
observeExpandedContent() {
// Main Container
const observeOverviewEls = this.shadowRoot.querySelectorAll(".observe-me");
observeOverviewEls.forEach((targetEl) => {
this.intersectionObserver.observe(targetEl);
});
}
attributeChangedCallback(name, oldVal, newVal) {
if (name === "spec-url") {
if (oldVal !== newVal) {
// put it at the end of event-loop to load all the attributes
window.setTimeout(async () => {
// Already loaded by connectedCallback (or superseded by an inlined spec there)
if (newVal === this._lastSpecUrlRequested) {
return;
}
await this.loadSpec(newVal);
// If goto-path is provided and no location-hash is present then try to scroll there
if (this.gotoPath && !window.location.hash) {
this.scrollToPath(this.gotoPath);
}
}, 0);
}
}
if (
name === "match-paths" ||
name === "match-type" ||
name === "remove-endpoints-with-badge-label-as"
) {
if (oldVal !== newVal) {
window.setTimeout(async () => {
await this.loadSpec(this.specUrl);
}, 0);
}
}
if (name === "scroll-offset" && oldVal !== newVal) {
window.setTimeout(() => this.initIntersectionObserver(), 0);
}
if (name === "layout") {
if (!this._isDefaultingLayout) {
if (newVal !== null && "row, column,".includes(`${newVal},`)) {
this._userSetLayout = true;
this.layout = newVal;
} else if (newVal === null) {
this._userSetLayout = false;
const defaultLayout =
this.renderStyle === "read" || this.renderStyle === "focused" ? "column" : "row";
this._isDefaultingLayout = true;
this.layout = defaultLayout;
this.setAttribute("layout", defaultLayout);
this._isDefaultingLayout = false;
}
}
}
if (name === "render-style") {
if (newVal === "read") {
window.setTimeout(() => {
this.observeExpandedContent();
}, 100);
} else {
this.intersectionObserver.disconnect();
}
if (!this._userSetLayout) {
const defaultLayout = newVal === "read" || newVal === "focused" ? "column" : "row";
this._isDefaultingLayout = true;
this.layout = defaultLayout;
this.setAttribute("layout", defaultLayout);
this._isDefaultingLayout = false;
}
}
if (name === "api-key-name" || name === "api-key-location" || name === "api-key-value") {
let updateSelectedApiKey = false;
let apiKeyName = "";
let apiKeyLocation = "";
let apiKeyValue = "";
if (name === "api-key-name") {
if (this.getAttribute("api-key-location") && this.getAttribute("api-key-value")) {
apiKeyName = newVal;
apiKeyLocation = this.getAttribute("api-key-location");
apiKeyValue = this.getAttribute("api-key-value");
updateSelectedApiKey = true;
}
} else if (name === "api-key-location") {
if (this.getAttribute("api-key-name") && this.getAttribute("api-key-value")) {
apiKeyLocation = newVal;
apiKeyName = this.getAttribute("api-key-name");
apiKeyValue = this.getAttribute("api-key-value");
updateSelectedApiKey = true;
}
} else if (name === "api-key-value") {
if (this.getAttribute("api-key-name") && this.getAttribute("api-key-location")) {
apiKeyValue = newVal;
apiKeyLocation = this.getAttribute("api-key-location");
apiKeyName = this.getAttribute("api-key-name");
updateSelectedApiKey = true;
}
}
if (updateSelectedApiKey) {
// While the spec is loading, resolvedSpec is a placeholder without securitySchemes
if (this.resolvedSpec?.securitySchemes) {
const rapiDocApiKey = this.resolvedSpec.securitySchemes.find(
(v) => v.securitySchemeId === rapidocApiKey,
);
if (!rapiDocApiKey) {
this.resolvedSpec.securitySchemes.push({
securitySchemeId: rapidocApiKey,
description: "api-key provided in rapidoc element attributes",
type: "apiKey",
name: apiKeyName,
in: apiKeyLocation,
value: apiKeyValue,
finalKeyValue: apiKeyValue,
});
} else {
rapiDocApiKey.name = apiKeyName;
rapiDocApiKey.in = apiKeyLocation;
rapiDocApiKey.value = apiKeyValue;
rapiDocApiKey.finalKeyValue = apiKeyValue;
}
this.requestUpdate();
}
}
}
super.attributeChangedCallback(name, oldVal, newVal);
}
onSpecUrlChange() {
this.setAttribute("spec-url", this.shadowRoot.getElementById("spec-url").value);
}
onSpecFileChange(e) {
this.setAttribute("spec-file", this.shadowRoot.getElementById("spec-file").value);
const specFile = e.target.files[0];
const reader = new FileReader();
reader.onload = () => {
try {
const specObj = JSON.parse(reader.result);
this.loadSpec(specObj);
this.shadowRoot.getElementById("spec-url").value = "";
} catch {
console.error("RapiDoc: Unable to read or parse json");
}
};
// Read the Text file
reader.readAsText(specFile);
}
onFileLoadClick() {
this.shadowRoot.getElementById("spec-file").click();
}
onSearchChange(e) {
// The event may come from the ↩ icon (a div without `value`): read the nav-bar input instead
const input =
e.target instanceof HTMLInputElement
? e.target
: this.shadowRoot.getElementById("nav-bar-search");
this.searchVal = input?.value ?? "";
this.resolvedSpec.tags.forEach((tag) =>
tag.paths.filter((path) => {
if (this.searchVal) {
if (getMatchedPaths(this.searchVal, path, tag.name)) {
tag.expanded = true;
}
}
}),
);
this.resolvedSpec.components.forEach((component) =>
component.subComponents.filter((v) => {
v.expanded = false;
if (getMatchedComponents(this.searchVal, v)) {
v.expanded = true;
}
}),
);
this.requestUpdate();
}
onClearSearch() {
const searchEl = this.shadowRoot.getElementById("nav-bar-search");
searchEl.value = "";
this.searchVal = "";
this.resolvedSpec.components.forEach((component) =>
component.subComponents.filter((v) => {
v.expanded = true;
}),
);
}
onOpenNavBarToggle() {
const navBarEL = this.shadowRoot.getElementById("nav-bar");
navBarEL.classList.toggle("floating-nav");
}
onShowAdvancedSearchClicked() {
this.shadowRoot.getElementById("advanced-search-dialog").showModal();
}
onAdvancedSearchClose() {
this.shadowRoot.getElementById("advanced-search-dialog").close();
}
// Public Method
async loadSpec(specUrl) {
if (!specUrl) {
return;
}
if (typeof specUrl === "string") {
this._lastSpecUrlRequested = specUrl;
}
// Each load gets a token; a load whose token is no longer current never applies its result
const token = ++this._specLoadSeq;
this.searchVal = "";
try {
if (!this._hasDsd) {
this.resolvedSpec = {
specLoadError: false,
isSpecLoading: true,
tags: [],
};
this.loading = true;
}
this.loadFailed = false;
const spec = await ProcessSpec.call(
this,
specUrl,
this.generateMissingTags === "true",
this.sortTags === "true",
this.sortSchemas === "true",
this.getAttribute("sort-endpoints-by"),
this.getAttribute("api-key-name"),
this.getAttribute("api-key-location"),
this.getAttribute("api-key-value"),
this.getAttribute("server-url"),
this.matchPaths,
this.matchType,
this.removeEndpointsWithBadgeLabelAs,
this.showUnusedTags === "true",
);
if (token !== this._specLoadSeq) {
return; // a newer load started while this one was parsing
}
this.loading = false;
this.afterSpecParsedAndValidated(spec, token);
} catch (err) {
if (token !== this._specLoadSeq) {
return;
}
this.loading = false;
this._clearDsdIfNeeded();
this.loadFailed = true;
this.resolvedSpec = null;
console.error(`RapiDoc: Unable to resolve the API spec.. ${err.message}`);
}
}
async afterSpecParsedAndValidated(spec, token = this._specLoadSeq) {
this._clearDsdIfNeeded();
this.resolvedSpec = spec;
this.selectedServer = undefined;
if (this.defaultApiServerUrl) {
if (this.defaultApiServerUrl === this.serverUrl) {
this.selectedServer = {
url: this.serverUrl,
computedUrl: this.serverUrl,
};
} else if (this.resolvedSpec.servers) {
this.selectedServer = this.resolvedSpec.servers.find(
(v) => v.url === this.defaultApiServerUrl,
);
}
}
if (!this.selectedServer) {
if (this.resolvedSpec.servers) {
this.selectedServer = this.resolvedSpec.servers[0];
}
}
this.requestUpdate();
while (!(await this.updateComplete));
if (token !== this._specLoadSeq) {
return; // a newer load took over while this one was rendering; it fires its own spec-loaded
}
const specLoadedEvent = new CustomEvent("spec-loaded", { detail: spec });
this.dispatchEvent(specLoadedEvent);
if (this.mockServer === "true") {
enableMockServer(this.resolvedSpec, {
owner: this,
statusCode: this.mockServerStatusCode,
statusStrategy: this.mockServerStatusStrategy,
delay: this.mockServerDelay,
log: this.mockServerLog,
});
}
// Initiate IntersectionObserver and put it at the end of event loop, to allow loading all the child elements (must for larger specs)
this.intersectionObserver.disconnect();
if (this._intersectingElements) {
this._intersectingElements.clear();
}
if (this.renderStyle === "read") {
await sleep(100);
if (token !== this._specLoadSeq) {
return;
}
this.observeExpandedContent(); // This will auto-highlight the selected nav-item in read-mode
}
this.isIntersectionObserverActive = true;
// On first time Spec load, try to navigate to location hash if provided
const elementId = this.getElementIDFromURL();
if (elementId) {
if (this.renderStyle === "view") {
this.expandAndGotoOperation(elementId, true, true);
} else {
this.scrollToPath(elementId);
}
} else if (this.gotoPath) {
this.scrollToPath(this.gotoPath);
} else if (this.renderStyle === "focused") {
// If goto-path is provided and no location-hash is present then try to scroll to default element
if (!this.gotoPath) {
const defaultElementId =
this.showInfo === "true"
? "overview"
: this.resolvedSpec.tags.find((t) => t.paths.length > 0)?.paths[0]?.elementId;
if (defaultElementId) {
this.scrollToPath(defaultElementId);
}
}
}
}
/**
* Return the URL from where is served the RapiDoc component, removing any hash
*/
getComponentBaseURL() {
return window.location.href.split("#")[0];
}
/**
* From the URL return the ID of the element from the location hash
*/
getElementIDFromURL() {
return window.location.hash ? window.location.hash.substring(1) : "";
}
replaceHistoryState(hashId) {
const baseURL = this.getComponentBaseURL();
window.history.replaceState(null, null, hashId ? `${baseURL}#${hashId}` : baseURL);
}
expandAndGotoOperation(elementId, scrollToElement = true) {
if (!this.resolvedSpec) {
return;
}
// Expand full operation and tag
let isExpandingNeeded = true;
const tmpElementId = elementId.indexOf("#") === -1 ? elementId : elementId.substring(1);
if (
tmpElementId.startsWith("overview") ||
tmpElementId === "servers" ||
tmpElementId === "auth"
) {
isExpandingNeeded = false;
} else {
for (let i = 0; i < this.resolvedSpec.tags?.length; i++) {
const tag = this.resolvedSpec.tags[i];
const path = tag.paths?.find((p) => p.elementId === elementId);
if (path) {
if (path.expanded && tag.expanded) {
isExpandingNeeded = false;
} else {
path.expanded = true;
tag.expanded = true;
}
}
}
if (this.resolvedSpec.tagGroups) {
for (let g = 0; g < this.resolvedSpec.tagGroups.length; g++) {
const group = this.resolvedSpec.tagGroups[g];
const hasPath = group.tags?.some((t) => t.paths?.some((p) => p.elementId === elementId));
if (hasPath && !group.expanded) {
group.expanded = true;
isExpandingNeeded = true;
}
}
}
}
if (scrollToElement) {
// requestUpdate() and delay required, else we cant find element
if (isExpandingNeeded) {
this.requestUpdate();
}
window.setTimeout(
() => {
const firstNavEl = this.shadowRoot.querySelector(".first-nav-item");
const isFirst = firstNavEl && firstNavEl.dataset.contentId === tmpElementId;
const gotoEl = this.shadowRoot.getElementById(tmpElementId);
if (gotoEl) {
const mainContentEl = this.shadowRoot.querySelector(".main-content");
let targetTop: number;
if (this.autoHeight === "true") {
this.scrollAutoHeightTarget(gotoEl, tmpElementId);
} else if (isFirst) {
targetTop = 0;
this.lockObserverDuringScroll(gotoEl, tmpElementId, targetTop);
if (mainContentEl) {
mainContentEl.scrollTo({ top: 0, behavior: this.getContentScrollBehavior() });
} else {
gotoEl.scrollIntoView({
behavior: this.getContentScrollBehavior(),
block: "start",
});
}
} else if (mainContentEl) {
const containerRect = mainContentEl.getBoundingClientRect();
const targetRect = gotoEl.getBoundingClientRect();
targetTop = targetRect.top - containerRect.top + mainContentEl.scrollTop;
this.lockObserverDuringScroll(gotoEl, tmpElementId, targetTop);
mainContentEl.scrollTo({ top: targetTop, behavior: this.getContentScrollBehavior() });
} else {
this.lockObserverDuringScroll(gotoEl, tmpElementId);
gotoEl.scrollIntoView({ behavior: this.getContentScrollBehavior(), block: "start" });
}
this._activeElementId = tmpElementId;
this.dispatchEvent(
new CustomEvent("nav-active-change", {
detail: {
id: tmpElementId,
elementId: tmpElementId,
},
bubbles: true,
composed: true,
}),
);
if (this.updateRoute === "true") {
this.replaceHistoryState(tmpElementId);
}
}
},
isExpandingNeeded ? 150 : 0,
);
}
}
isValidTopId(id) {
return id.startsWith("overview") || id === "servers" || id === "auth";
}
isValidPathId(id) {
if (id === "overview" && this.showInfo) {
return true;
}
if (id === "servers" && this.allowServerSelection) {
return true;
}
if (id === "auth" && this.allowAuthentication) {
return true;
}
if (id.startsWith("tag--")) {
return this.resolvedSpec?.tags?.find((tag) => tag.elementId === id);
}
return this.resolvedSpec?.tags?.find((tag) => tag.paths.find((path) => path.elementId === id));
}
initIntersectionObserver() {
if (this.intersectionObserver) {
this.intersectionObserver.disconnect();
}
let container = null;
if (this.scrollContainer && typeof document !== "undefined") {
try {
container = document.querySelector(this.scrollContainer);
const isAncestor = Boolean(
container &&
(container.contains(this) ||
(this.assignedSlot && container.contains(this.assignedSlot)) ||
(container.shadowRoot && container.shadowRoot.contains(this.assignedSlot))),
);
if (container && !isAncestor) {
// If the specified element is not an ancestor of <rapi-doc>, IntersectionObserver cannot observe inside it
console.warn(
`RapiDoc: The element specified by scroll-container ("${this.scrollContainer}") is not an ancestor of <rapi-doc>. Falling back to auto-detection.`,
);
container = null;
}
} catch {
// Ignore invalid query selector
}
}
if (!container && typeof window !== "undefined") {
let parent: any = this.assignedSlot ? this.assignedSlot.parentElement : this.parentElement;
while (parent && parent !== document.body && parent !== document.documentElement) {
const overflowY = window.getComputedStyle(parent).overflowY;
if (overflowY === "auto" || overflowY === "scroll") {
container = parent;
break;
}
parent =
parent.parentElement ||
(parent.getRootNode && parent.getRootNode() instanceof ShadowRoot
? (parent.getRootNode() as ShadowRoot).host
: null);
}
}
this._scrollRootContainer = container;
const intersectionObserverOptions = {
root: container,
// Headings that slide under a sticky page header (getScrollOffset) count as scrolled past.
rootMargin: `-${this.getScrollOffset()}px 0px -50% 0px`,
threshold: 0,
};
this.intersectionObserver = new IntersectionObserver((entries) => {
this.onIntersect(entries);
}, intersectionObserverOptions);
if (this.isIntersectionObserverActive) {
this.observeExpandedContent();
}
}
onIntersect(entries) {
if (this.isIntersectionObserverActive === false) {
return;
}
if (!this._intersectingElements) {
this._intersectingElements = new Map();
}
for (const entry of entries) {
if (entry.isIntersecting && entry.intersectionRatio > 0) {
this._intersectingElements.set(entry.target.id, entry.target);
} else {
this._intersectingElements.delete(entry.target.id);
}
}
if (this._intersectingElements.size === 0) {
return;
}
const activeCandidates = Array.from(this._intersectingElements.values()).filter(
(el) => el.isConnected,
);
if (activeCandidates.length === 0) {
return;
}
const containerTop =
(this._scrollRootContainer ? this._scrollRootContainer.getBoundingClientRect().top : 0) +
this.getScrollOffset();
const visibleHeadings = [];
const scrolledPast = [];
for (const el of activeCandidates) {
const top = el.getBoundingClientRect().top - containerTop;
// A section scrolled to (scrollToPath) can end up a fraction of a pixel above the top: the
// browser rounds the scroll position to device pixels. It still counts as visible.
if (top > -1) {
visibleHeadings.push({ el, top });
} else {
scrolledPast.push({ el, top });
}
}
let bestEl;
if (visibleHeadings.length > 0) {
// Preference to the topmost heading visible in the viewport/reading zone
visibleHeadings.sort((a, b) => a.top - b.top);
bestEl = visibleHeadings[0].el;
} else {
// If all visible candidates have their headings scrolled past top (< 0),
// pick the one whose top is closest to 0 (the section currently filling the reading area)
scrolledPast.sort((a, b) => b.top - a.top);
bestEl = scrolledPast[0].el;
}
const targetId = bestEl.id;
if (this._activeElementId === targetId) {
return;
}
this._activeElementId = targetId;
if (this._intersectionRaf) {
cancelAnimationFrame(this._intersectionRaf);
}
this._intersectionRaf = requestAnimationFrame(() => {
// 1. Dispatch custom event so outer portals/host apps can sync active state
let resolvedOperationId = targetId;
if (targetId.startsWith("tag--") && this.resolvedSpec?.tags) {
const matchingTag = this.resolvedSpec.tags.find((t) => t.elementId === targetId);
if (matchingTag?.paths?.[0]?.elementId) {
resolvedOperationId = matchingTag.paths[0].elementId;
}
}
this.dispatchEvent(
new CustomEvent("nav-active-change", {
detail: {
id: targetId,
elementId: targetId,
operationId: resolvedOperationId,
},
bubbles: true,
composed: true,
}),
);
if (this.updateRoute === "true") {
this.replaceHistoryState(targetId);
}
// 2. If internal navbar is active (show-side-nav="true"), update Shadow DOM elements
const newNavEl = this.shadowRoot.getElementById(`link-${targetId}`);
if (newNavEl) {
const oldNavEl = this.shadowRoot.querySelector(
".nav-bar-tag.active, .nav-bar-path.active, .nav-bar-info.active, .nav-bar-h1.active, .nav-bar-h2.active, .operations.active",
);
if (oldNavEl && oldNavEl !== newNavEl) {
oldNavEl.classList.remove("active");
oldNavEl.part.remove("section-navbar-active-item");
}
expandParentNavContainers(newNavEl);
newNavEl.classList.add("active");
newNavEl.part.add("section-navbar-active-item");
// Scroll sidebar only if the active item is out of view
const navScrollEl = newNavEl.closest(".nav-scroll");
if (navScrollEl) {
const headerEl =
navScrollEl.querySelector(".nav-bar-header") ||
navScrollEl.querySelector(".nav-bar-search-container");
const topOffset = headerEl ? headerEl.offsetHeight : 0;
if (newNavEl.offsetTop <= topOffset + 30) {
if (navScrollEl.scrollTop > 0) {
const behavior = this.scrollBehavior === "auto" ? "auto" : "smooth";
navScrollEl.scrollTo({ top: 0, behavior });
}
} else {
const scrollRect = navScrollEl.getBoundingClientRect();
const itemRect = newNavEl.getBoundingClientRect();
const isOutOfView =
itemRect.top < scrollRect.top + topOffset + 15 ||
itemRect.bottom > scrollRect.bottom - 40;
if (isOutOfView) {
const behavior = this.scrollBehavior === "auto" ? "auto" : "smooth";
// Land the item below the sticky search header (which sits inside the scroll area), not under it
const targetTop =
itemRect.top - scrollRect.top + navScrollEl.scrollTop - topOffset - 20;
navScrollEl.scrollTo({ top: targetTop, behavior });
}
}
}
}
});
}
// Called by anchor tags created using markdown
handleHref(e) {
if (e.target.tagName.toLowerCase() === "a") {
if (e.target.getAttribute("href").startsWith("#")) {
const gotoEl = this.shadowRoot.getElementById(
e.target.getAttribute("href").replace("#", ""),
);
if (gotoEl) {
const mainContentEl = this.shadowRoot.querySelector(".main-content");
if (this.autoHeight === "true" || this.hasAttribute("auto-height")) {
this.scrollAutoHeightTarget(gotoEl, gotoEl.id);
} else if (mainContentEl) {
const containerRect = mainContentEl.getBoundingClientRect();
const targetRect = gotoEl.getBoundingClientRect();
const targetTop = targetRect.top - containerRect.top + mainContentEl.scrollTop;
mainContentEl.scrollTo({ top: targetTop, behavior: this.scrollBehavior });
} else {
gotoEl.scrollIntoView({ behavior: this.scrollBehavior, block: "start" });
}
}
}
}
}
/**
* Height of the area at the top of the scroll root that sticky or fixed page headers cover, in px.
* Only applies with auto-height, where the page (or `scroll-container`) scrolls instead of RapiDoc.
* Uses the `scroll-offset` attribute when set, otherwise the scroll root's `scroll-padding-top`,
* so pages that already reserve space for their header need no extra configuration.
*/
getScrollOffset(): number {
if (
!(this.autoHeight === "true" || this.hasAttribute("auto-height")) ||
typeof window === "undefined"
) {
return 0;
}
const explicit = parseFloat(this.scrollOffset);
if (Number.isFinite(explicit)) {
return explicit;
}
const root: HTMLElement =
this._scrollRootContainer ||
(document.scrollingElement as HTMLElement) ||
document.documentElement;
const padding = getComputedStyle(root).scrollPaddingTop;
const value = parseFloat(padding);
if (!Number.isFinite(value)) {
return 0;
}
return padding.endsWith("%") ? (value / 100) * root.clientHeight : value;
}
/**
* auto-height: scrolls the page, or the `scroll-container`, so that `targetEl` starts just below
* the area covered by sticky headers (see getScrollOffset).
*/
scrollAutoHeightTarget(targetEl: HTMLElement, targetId: string) {
const top = targetEl.getBoundingClientRect().top - this.getScrollOffset();
const container = this._scrollRootContainer;
if (container) {
const targetTop = top - container.getBoundingClientRect().top + container.scrollTop;
this.lockObserverDuringScroll(targetEl, targetId, targetTop);
container.scrollTo({ top: targetTop, behavior: this.getContentScrollBehavior() });
} else {
const targetTop = top + window.scrollY;
this.lockObserverDuringScroll(targetEl, targetId, targetTop);
window.scrollTo({ top: targetTop, behavior: this.getContentScrollBehavior() });
}
}
/**
* Temporarily deactivates the IntersectionObserver during navigation/programmatic scrolling,
* preventing intermediate elements from hijacking the active navbar item while smooth scrolling.
* Re-activates the observer once scrolling finishes (via 'scrollend', debounced scroll fallback, or safety timeout).
*
* @param {HTMLElement | null} targetEl - The element being scrolled to
* @param {string} targetId - Element / operation identifier
* @param {number} [targetTop] - Target scroll offset if known
*/
lockObserverDuringScroll(
targetEl: HTMLElement | null,
targetId: string = "",
targetTop?: number,
) {
if (this._intersectionRaf) {
cancelAnimationFrame(this._intersectionRaf);
this._intersectionRaf = null;
}
if (typeof this._observerLockCleanup === "function") {
this._observerLockCleanup();
this._observerLockCleanup = null;
}
if (this._observerLockTimeout) {
clearTimeout(this._observerLockTimeout);
this._observerLockTimeout = null;
}
// onIntersect tracks elements by their DOM id; targetId can be a shorter slug of the same element
// (e.g. "get-route-estimate" for "route-planning--get-route-estimate"). Keying by the slug would leave
// an entry the observer never removes, pinning the active item to this element.
const observedId = targetEl?.id || targetId;
this.isIntersectionObserverActive = false;
if (this._intersectingElements) {
this._intersectingElements.clear();
if (targetEl && observedId) {
this._intersectingElements.set(observedId, targetEl);
}
}
if (observedId) {
this._activeElementId = observedId;
}
const isAutoHeight = this.autoHeight === "true" || this.hasAttribute("auto-height");
const scrollContainer: any = isAutoHeight
? this._scrollRootContainer || (typeof window !== "undefined" ? window : null)
: this.shadowRoot?.querySelector(".main-content") ||
(typeof window !== "undefined" ? window : null);
if (!scrollContainer || typeof window === "undefined") {
this.isIntersectionObserverActive = true;
return;
}
let isUnlocked = false;
const unlock = () => {
if (isUnlocked) return;
isUnlocked = true;
if (cleanup) {
cleanup();
cleanup = null;
this._observerLockCleanup = null;
}
if (this._observerLockTimeout) {
clearTimeout(this._observerLockTimeout);
this._observerLockTimeout = null;
}
if (targetEl && observedId && this._intersectingElements) {
this._intersectingElements.set(observedId, targetEl);
}
this.isIntersectionObserverActive = true;
};
let debounceTimer: any = null;
const onScroll = () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(unlock, 120);
};
const onScrollEnd = () => {
unlock();
};
let cleanup: any = () => {
scrollContainer.removeEventListener?.("scrollend", onScrollEnd);
window.removeEventListener?.("scrollend", onScrollEnd);
scrollContainer.removeEventListener?.("scroll", onScroll);
window.removeEventListener?.("scroll", onScroll);
clearTimeout(debounceTimer);
};
this._observerLockCleanup = cleanup;
if ("onscrollend" in window) {
scrollContainer.addEventListener?.("scrollend", onScrollEnd, { once: true, passive: true });
window.addEventListener?.("scrollend", onScrollEnd, { once: true, passive: true });
}
scrollContainer.addEventListener?.("scroll", onScroll, { passive: true });
window.addEventListener?.("scroll", onScroll, { passive: true });
const currentScrollTop =
scrollContainer === window ? window.scrollY : scrollContainer.scrollTop || 0;
const isZeroDistance =
typeof targetTop === "number" && Math.abs(targetTop - currentScrollTop) < 3;
const maxSafetyDelay = isZeroDistance
? 60
: this.getContentScrollBehavior() === "smooth"
? 1200
: 100;
this._observerLockTimeout = setTimeout(unlock, maxSafetyDelay);
}
/**
* Called by
* - onClick of Navigation Bar
* - onClick of Advanced Search items
*
* Functionality:
* 1. First deactivate IntersectionObserver
* 2. Scroll to the element
* 3. Activate IntersectionObserver (after scroll completes)
*/
async scrollToEventTarget(event, scrollNavItemToView = true) {
if (!(event.type === "click" || (event.type === "keyup" && event.keyCode === 13))) {
return;
}
const navEl = ((event.target as HTMLElement)?.closest?.("[data-content-id]") ||
event.target) as HTMLElement;
if (!navEl?.dataset?.contentId) {
return;
}
if (this.renderStyle === "focused") {
const requestEl = this.shadowRoot.querySelector("api-request");
if (typeof requestEl?.beforeNavigationFocusedMode === "function") {
requestEl.beforeNavigationFocusedMode();
}
}
const isFirstNavItem = navEl.dataset.firstNavItem === "true";
this.scrollToPath(navEl.dataset.contentId, true, scrollNavItemToView, isFirstNavItem);
}
// Public Method - Returns the DOM element in ShadowRoot matching elementId, operationId, or slug
getTargetElement(elementId: string): HTMLElement | null {
if (!elementId || !this.shadowRoot) return null;
return (this.shadowRoot.getElementById(elementId) ||
this.shadowRoot.querySelector(`[data-op-slug="${CSS.escape(elementId)}"]`) ||
this.shadowRoot.querySelector(`[id$="--${CSS.escape(elementId)}"]`) ||
this.shadowRoot.getElementById(
elementId.replace(/^[a-z0-9_-]+--/, ""),
)) as HTMLElement | null;
}
/**
* Every operation can also be reached by its method + path anchor (methodPathSlug, e.g.
* "get-routes-id-estimate"), which stays the same when its operationId or summary changes. The
* comparison ignores case and punctuation, so older RapiDoc anchors ("get-/routes/-id-/estimate") work too.
* Returns the operation's own element id for such an anchor, otherwise elementId unchanged.
*/
resolveOperationAlias(elementId: string): string {
if (!elementId || this.getTargetElement(elementId)) return elementId;
const normalize = (id: string) =>
id
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
const wanted = normalize(elementId);
for (const tag of this.resolvedSpec?.tags || []) {
const op = tag.paths?.find((p) => normalize(methodPathSlug(p.method, p.path)) === wanted);
if (op) return op.elementId;
}
return elementId;
}
// Public Method - Binds an external navbar to this <rapi-doc> element
bindNavBar(sidebar: HTMLElement | string, options?: any) {
return bindNavBar(sidebar, this, options);
}
// Public Method (scrolls to a given path and highlights the left-nav selection)
async scrollTo(elementId) {
return this.scrollToPath(elementId);
}
// Public Method (scrolls to a given path and highlights the left-nav selection)
async scrollToPath(
elementId,
expandPath = true,
scrollNavItemToView = true,
isFirstNavItem = false,
) {
elementId = this.resolveOperationAlias(elementId);
if (this.renderStyle === "focused") {
// for focused mode update this.focusedElementId to update the rendering, else it wont find the needed html elements
// focusedElementId will get validated in the template
this.focusedElementId = elementId;
await sleep(0);
}
if (this.renderStyle === "view") {
this.expandAndGotoOperation(elementId, expandPath, true);
} else {
const contentEl = this.getTargetElement(elementId);
const isValidElementId = Boolean(contentEl);
if (contentEl) {
const firstNavEl = this.shadowRoot.querySelector(".first-nav-item");
const isFirst =
isFirstNavItem || (firstNavEl && firstNavEl.dataset.contentId === elementId);
const mainContentEl = this.shadowRoot.querySelector(".main-content");
let targetTop: number;
if (this.autoHeight === "true" || this.hasAttribute("auto-height")) {
this.scrollAutoHeightTarget(contentEl, elementId);
} else if (isFirst) {
targetTop = 0;
this.lockObserverDuringScroll(contentEl, elementId, targetTop);
if (mainContentEl) {
mainContentEl.scrollTo({ top: 0, behavior: this.getContentScrollBehavior() });
} else {
contentEl.scrollIntoView({ behavior: this.getContentScrollBehavior(), block: "start" });
}
} else if (mainContentEl) {
const containerRect = mainContentEl.getBoundingClientRect();
const targetRect = contentEl.getBoundingClientRect();
targetTop = targetRect.top - containerRect.top + mainContentEl.scrollTop;
this.lockObserverDuringScroll(contentEl, elementId, targetTop);
mainContentEl.scrollTo({ top: targetTop, behavior: this.getContentScrollBehavior() });
} else {
this.lockObserverDuringScroll(contentEl, elementId);
contentEl.scrollIntoView({ behavior: this.getContentScrollBehavior(), block: "start" });
}
}
if (isValidElementId) {
// In focused mode, <api-request> and <api-response> component instances are reused across endpoint switches
// to avoid expensive DOM re-creation. Reset their selection state so selections from the previous operation
// (such as alternative request bodies or non-default status codes) do not persist into the new operation.
if (this.renderStyle === "focused") {
const requestEl = this.shadowRoot.querySelector("api-request");
if (typeof requestEl?.afterNavigationFocusedMode === "function") {
requestEl.afterNavigationFocusedMode();
}
const responseEl = this.shadowRoot.querySelector("api-response");
if (typeof responseEl?.resetSelection === "function") {
responseEl.resetSelection();
}
}
// Update Location Hash
this._activeElementId = contentEl?.id || elementId;
this.dispatchEvent(
new CustomEvent("nav-active-change", {
detail: {
id: elementId,
elementId,
},
bubbles: true,
composed: true,
}),
);
if (this.updateRoute === "true") {
this.replaceHistoryState(elementId);
}
// Update NavBar View and Styles
const newNavEl = this.shadowRoot.getElementById(`link-${elementId}`);
if (newNavEl) {
expandParentNavContainers(newNavEl);
if (scrollNavItemToView) {
const navScrollEl = this.shadowRoot.querySelector(".nav-scroll");
if (navScrollEl) {
const scrollRect = navScrollEl.getBoundingClientRect();
const itemRect = newNavEl.getBoundingClientRect();
const targetTop =
itemRect.top -
scrollRect.top +
navScrollEl.scrollTop -
navScrollEl.clientHeight / 2;
navScrollEl.scrollTo({ top: targetTop, behavior: this.scrollBehavior });
} else {
newNavEl.scrollIntoView({ behavior: this.scrollBehavior, block: "center" });
}
}
await sleep(0);
const oldNavEl = this.shadowRoot.querySelector(
".nav-bar-tag.active, .nav-bar-path.active, .nav-bar-info.active, .nav-bar-h1.active, .nav-bar-h2.active, .operations.active",
);
if (oldNavEl) {
oldNavEl.classList.remove("active");
oldNavEl.part.remove("active");
oldNavEl.part.remove("section-navbar-active-item");
}
newNavEl.classList.add("active"); // must add the class after scrolling
newNavEl.part.add("section-navbar-active-item");
// this.requestUpdate();
}
}
}
}
// Public Method - to update security-scheme of type http
setHttpUserNameAndPassword(securitySchemeId, username, password) {
return applyApiKey.call(this, securitySchemeId, username, password);
}
// Public Method - to update security-scheme of type apiKey or OAuth.
// options.note: where the key came from (e.g. "Using your sign-in"), shown in place of the scheme's form
setApiKey(securitySchemeId, apiKeyValue, options: { note?: string } = {}) {
return applyApiKey.call(this, securitySchemeId, "", "", apiKeyValue, options?.note || "");
}
// Public Method
removeAllSecurityKeys() {
return onClearAllApiKeys.call(this);
}
// Public Method
setApiServer(apiServerUrl) {
// return apiServerUrl;
return setApiServer.call(this, apiServerUrl);
}
// True when the `mcp` attribute asks for the Export MCP Server button
get mcpEnabled() {
return parseMcpEnabled(this.mcp);
}
/**
* The <rapi-flow> on the page whose Arazzo document the export takes workflow tools from: the
* one whose `for` names this element, else one without `for`. Nothing when rapi-flow is not
* loaded.
*/
private findArazzoDocument() {
if (typeof customElements === "undefined" || !customElements.get("rapi-flow")) return undefined;
const flows = [...document.querySelectorAll("rapi-flow")] as any[];
return (
flows.find((f) => this.id && f.getAttribute("for") === this.id && f.arazzoDocument) ||
flows.find((f) => !f.getAttribute("for") && f.arazzoDocument)
);
}
private mcpOptions(options) {
const flow = this.findArazzoDocument();
return {
serverName: this.mcpServerName,
arazzo: flow?.arazzoDocument,
// <rapi-flow> carries the workflow runtime; this bundle does not
workflowRuntime: flow?.workflowRuntimeSource,
...options,
};
}
// Public Method - Builds the MCP manifest and the files of the exported project in memory
generateMcpServer(options = {}) {
const spec = this.resolvedSpec || this.openApiSpec;
if (!spec) return null;
return mcpServerGenerator(spec, this.mcpOptions(options));
}
// Public Method - Downloads the exported MCP project as a zip
downloadMcpServer(options = {}) {
const result = this.generateMcpServer(options);
if (!result) return;
const blob = new Blob([createZip(result.files)], { type: "application/zip" });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = `${result.manifest.server.name.replace(/_/g, "-")}-mcp.zip`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(link.href);
}
// Public Method - Returns token budget diagnostics for MCP tooling
getMcpBudget(options = {}) {
const spec = this.resolvedSpec || this.openApiSpec;
if (!spec) return null;
return estimateTokenCost(buildMcpManifest(spec, this.mcpOptions(options)));
}
// Event handler for Advanced Search text-inputs and checkboxes
onAdvancedSearch(ev, delay) {
const eventTargetEl = ev.target;
clearTimeout(this.timeoutId);
this.timeoutId = setTimeout(() => {
let searchInputEl;
if (eventTargetEl.type === "text") {
searchInputEl = eventTargetEl;
} else {
searchInputEl = eventTargetEl
.closest(".advanced-search-options")
.querySelector("input[type=text]");
}
const searchOptions = [
...eventTargetEl.closest(".advanced-search-options").querySelectorAll("input:checked"),
].map((v) => v.id);
this.advancedSearchMatches = advancedSearch(
searchInputEl.value,
this.resolvedSpec.tags,
searchOptions,
);
}, delay);
}
}