diff --git a/examples/static-html/9ci/api.yml b/examples/static-html/9ci/api.yml
new file mode 100644
index 000000000..418520512
--- /dev/null
+++ b/examples/static-html/9ci/api.yml
@@ -0,0 +1,65 @@
+openapi: 3.0.3
+info:
+ title: Sample API
+ version: 1.0.0
+ description: API documentation with Stoplight-compatible x-codeSamples
+
+paths:
+ /users:
+ get:
+ summary: Get all usersww
+ x-codeSamples:
+ - lang: shell
+ label: JavaScript (fetch22)
+ lib: curl
+ source: >
+ fetch("https://api.example.com/users")
+ .then(res => res.json())
+ .then(data => console.log(data));
+ tags: [User]
+ responses:
+ '200':
+ description: List of userslll
+
+ /payments:
+ post:
+ summary: Make a payment
+ tags: [Payment]
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ amount:
+ type: number
+ responses:
+ '201':
+ description: Payment created
+ x-code-samples:
+ - lang: curl
+ label: Curl11
+ source: |
+ curl -X POST "https://api.example.com/payments" \
+ -H "Content-Type: application/json" \
+ -d '{"amount": 100}'
+ - lang: python
+ label: Python (requests)
+ source: |
+ import requests
+ response = requests.post(
+ "https://api.example.com/payments",
+ json={"amount": 100}
+ )
+ print(response.json())
+tags:
+ - name: User
+ description: Operations about users
+ - name: Payment
+ description: Payment processing
+x-taggroups:
+ - name: Users
+ tags: [User]
+ - name: Payments
+ tags: [Payment]
\ No newline at end of file
diff --git a/examples/static-html/9ci/index.html b/examples/static-html/9ci/index.html
new file mode 100644
index 000000000..701b0a6e7
--- /dev/null
+++ b/examples/static-html/9ci/index.html
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+ Stoplight Elements example
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/elements-core/src/components/RequestSamples/requestSampleConfigs.ts b/packages/elements-core/src/components/RequestSamples/requestSampleConfigs.ts
index 5b36c64a0..679195eb5 100644
--- a/packages/elements-core/src/components/RequestSamples/requestSampleConfigs.ts
+++ b/packages/elements-core/src/components/RequestSamples/requestSampleConfigs.ts
@@ -13,6 +13,7 @@ export interface LanguageConfig {
}
export type RequestSampleConfigs = Dictionary;
+//XXX: List of supported languages
export const requestSampleConfigs: RequestSampleConfigs = {
Shell: {
mosaicCodeViewerLanguage: 'bash',
diff --git a/packages/elements-core/src/components/TableOfContents/TableOfContents.tsx b/packages/elements-core/src/components/TableOfContents/TableOfContents.tsx
index e0cfd7485..8f19ecd98 100644
--- a/packages/elements-core/src/components/TableOfContents/TableOfContents.tsx
+++ b/packages/elements-core/src/components/TableOfContents/TableOfContents.tsx
@@ -1,5 +1,6 @@
import { Box, Flex, Icon, ITextColorProps } from '@stoplight/mosaic';
import { HttpMethod, NodeType } from '@stoplight/types';
+import { capitalize } from 'lodash';
import * as React from 'react';
import { useFirstRender } from '../../hooks/useFirstRender';
@@ -258,7 +259,7 @@ const Group = React.memo<{
elem = (
- {
if (item.type === NodeType.HttpOperation) {
return hash.substr(1) === `${item.data.path}-${item.data.method}`;
- } else {
- return hash.substr(1) === `${item.data.name}-${item.data.method}`;
}
+
+ return hash.substr(1) === `${item.data.name}-${item.data.method}`;
};
const TryItContext = React.createContext<{
@@ -91,9 +91,17 @@ export const APIWithStackedLayout: React.FC = ({
showPoweredByLink = true,
location,
}) => {
- const { groups: operationGroups } = computeTagGroups(serviceNode, NodeType.HttpOperation);
- const { groups: webhookGroups } = computeTagGroups(serviceNode, NodeType.HttpWebhook);
+ const rootVendorExtensions = serviceNode.data.extensions ?? ({} as Extensions);
+ const rootVendorExtensionNames = Object.keys(rootVendorExtensions).map(item => item.toLowerCase());
+ const isHavingTagGroupsExtension =
+ typeof rootVendorExtensions['x-tagGroups'] !== 'undefined' && rootVendorExtensionNames.length > 0;
+ const { groups: operationGroups } = computeTagGroups(serviceNode, NodeType.HttpOperation, {
+ useTagGroups: isHavingTagGroupsExtension,
+ });
+ const { groups: webhookGroups } = computeTagGroups(serviceNode, NodeType.HttpWebhook, {
+ useTagGroups: isHavingTagGroupsExtension,
+ });
return (
= ({
/>
{operationGroups.length > 0 && webhookGroups.length > 0 ? Endpoints : null}
- {operationGroups.map(group => (
-
- ))}
+ {operationGroups.map(group =>
+ group.isDivider ? (
+
+ {group.title}
+
+ ) : (
+
+ ),
+ )}
{webhookGroups.length > 0 ? Webhooks : null}
- {webhookGroups.map(group => (
-
- ))}
+ {webhookGroups.map(group =>
+ group.isDivider ? (
+
+ {group.title}
+
+ ) : (
+
+ ),
+ )}
@@ -139,7 +159,10 @@ const Group = React.memo<{ group: TagGroup }>(({ gr
const onClick = React.useCallback(() => setIsExpanded(!isExpanded), [isExpanded]);
const shouldExpand = React.useMemo(() => {
- return urlHashMatches || group.items.some(item => itemMatchesHash(hash, item));
+ const groupMatches = (group.items ?? []).some(item => {
+ return itemMatchesHash(hash, item);
+ });
+ return urlHashMatches || groupMatches;
}, [group, hash, urlHashMatches]);
React.useEffect(() => {
@@ -150,7 +173,7 @@ const Group = React.memo<{ group: TagGroup }>(({ gr
window.scrollTo(0, scrollRef.current.offsetTop);
}
}
- }, [shouldExpand, urlHashMatches, group, hash]);
+ }, [shouldExpand, urlHashMatches]);
return (
@@ -173,7 +196,7 @@ const Group = React.memo<{ group: TagGroup }>(({ gr
- {group.items.map(item => {
+ {(group.items ?? []).map(item => {
return
;
})}
@@ -221,7 +244,7 @@ const Item = React.memo<{ item: OperationNode | WebhookNode }>(({ item }) => {
rounded
px={2}
bg="canvas"
- className={cn(`sl-mr-5 sl-text-base`, `sl-text-${color}`, `sl-border-${color}`)}
+ className={cn('sl-mr-5 sl-text-base', `sl-text-${color}`, `sl-border-${color}`)}
>
{item.data.method || 'UNKNOWN'}
diff --git a/packages/elements/src/components/API/computeAPITree.ts b/packages/elements/src/components/API/computeAPITree.ts
new file mode 100644
index 000000000..add2aceaf
--- /dev/null
+++ b/packages/elements/src/components/API/computeAPITree.ts
@@ -0,0 +1,105 @@
+import type { TableOfContentsItem } from '@stoplight/elements-core';
+import type { Extensions } from '@stoplight/types';
+import { NodeType } from '@stoplight/types';
+import { defaults } from 'lodash';
+
+import type { OperationNode, SchemaNode, ServiceNode, WebhookNode } from '../../utils/oas/types';
+import { addTagGroupsToTree, computeTagGroups, isInternal } from './utils';
+
+export interface ComputeAPITreeConfig {
+ hideSchemas?: boolean;
+ hideInternal?: boolean;
+}
+
+export const defaultComputerAPITreeConfig = {
+ hideSchemas: false,
+ hideInternal: false,
+};
+
+export const computeAPITree = (serviceNode: ServiceNode, config: ComputeAPITreeConfig = {}) => {
+ const mergedConfig = defaults(config, defaultComputerAPITreeConfig);
+ const tree: TableOfContentsItem[] = [];
+
+ // check if spec has x-tagGroups extension
+ const rootVendorExtensions = serviceNode.data.extensions ?? ({} as Extensions);
+ const rootVendorExtensionNames = Object.keys(rootVendorExtensions).map(item => item.toLowerCase());
+ const isHavingTagGroupsExtension =
+ typeof rootVendorExtensions['x-taggroups'] !== 'undefined' && rootVendorExtensionNames.length > 0;
+
+ tree.push({
+ id: '/',
+ slug: '/',
+ title: 'Overview',
+ type: 'overview',
+ meta: '',
+ });
+
+ const hasOperationNodes = serviceNode.children.some(node => node.type === NodeType.HttpOperation);
+ if (hasOperationNodes) {
+ tree.push({
+ title: 'Endpoints',
+ });
+
+ const { groups, ungrouped } = computeTagGroups(serviceNode, NodeType.HttpOperation, {
+ useTagGroups: isHavingTagGroupsExtension,
+ });
+ addTagGroupsToTree(groups, ungrouped, tree, NodeType.HttpOperation, {
+ hideInternal: mergedConfig.hideInternal,
+ useTagGroups: isHavingTagGroupsExtension,
+ });
+ }
+
+ const hasWebhookNodes = serviceNode.children.some(node => node.type === NodeType.HttpWebhook, {
+ useTagGroups: isHavingTagGroupsExtension,
+ });
+ if (hasWebhookNodes) {
+ tree.push({
+ title: 'Webhooks',
+ });
+
+ const { groups, ungrouped } = computeTagGroups(serviceNode, NodeType.HttpWebhook, {
+ useTagGroups: isHavingTagGroupsExtension,
+ });
+ addTagGroupsToTree(groups, ungrouped, tree, NodeType.HttpWebhook, {
+ hideInternal: mergedConfig.hideInternal,
+ useTagGroups: isHavingTagGroupsExtension,
+ });
+ }
+
+ let schemaNodes = serviceNode.children.filter(node => node.type === NodeType.Model);
+ if (mergedConfig.hideInternal) {
+ schemaNodes = schemaNodes.filter(n => !isInternal(n));
+ }
+
+ if (!mergedConfig.hideSchemas && schemaNodes.length) {
+ tree.push({
+ title: 'Schemas',
+ });
+
+ const { groups, ungrouped } = computeTagGroups(serviceNode, NodeType.Model, {
+ useTagGroups: isHavingTagGroupsExtension,
+ });
+ addTagGroupsToTree(groups, ungrouped, tree, NodeType.Model, {
+ hideInternal: mergedConfig.hideInternal,
+ useTagGroups: isHavingTagGroupsExtension,
+ });
+ }
+ return tree;
+};
+
+export const findFirstNodeSlug = (tree: TableOfContentsItem[]): string | void => {
+ for (const item of tree) {
+ if ('slug' in item) {
+ return item.slug;
+ }
+
+ if ('items' in item) {
+ const slug = findFirstNodeSlug(item.items);
+ if (slug) {
+ return slug;
+ }
+ }
+ }
+
+ return;
+};
\ No newline at end of file
diff --git a/packages/elements/src/components/API/utils.ts b/packages/elements/src/components/API/utils.ts
index 66d8d9e0b..ccfd2f590 100644
--- a/packages/elements/src/components/API/utils.ts
+++ b/packages/elements/src/components/API/utils.ts
@@ -6,44 +6,138 @@ import {
TableOfContentsGroup,
TableOfContentsItem,
} from '@stoplight/elements-core';
-import { NodeType } from '@stoplight/types';
+import { INodeTag } from '@stoplight/types';
import { JSONSchema7 } from 'json-schema';
-import { defaults } from 'lodash';
import { OperationNode, SchemaNode, ServiceChildNode, ServiceNode, WebhookNode } from '../../utils/oas/types';
type GroupableNode = OperationNode | WebhookNode | SchemaNode;
-export type TagGroup = { title: string; items: T[] };
+type OpenApiTagGroup = { name: string; tags: string[] };
-export function computeTagGroups(serviceNode: ServiceNode, nodeType: T['type']) {
+export type TagGroup = { title: string; isDivider: boolean; items?: T[] };
+
+export function computeTagGroups(
+ serviceNode: ServiceNode,
+ nodeType: T['type'],
+ config: { useTagGroups: boolean },
+): { groups: TagGroup[]; ungrouped: T[] } {
const groupsByTagId: { [tagId: string]: TagGroup } = {};
+ const nodesByTagId: { [tagId: string]: TagGroup } = {};
const ungrouped: T[] = [];
+ const { useTagGroups = false } = config ?? {};
+
const lowerCaseServiceTags = serviceNode.tags.map(tn => tn.toLowerCase());
const groupableNodes = serviceNode.children.filter(n => n.type === nodeType) as T[];
+ const rawServiceTags = serviceNode.data.tags ?? [];
+
+ const serviceExtensions = serviceNode.data.extensions ?? {};
+ const tagGroupExtensionName = Object.keys(serviceExtensions).find(item => item.toLowerCase() === 'x-taggroups');
+ const tagGroups: OpenApiTagGroup[] = tagGroupExtensionName
+ ? (serviceExtensions[tagGroupExtensionName] as OpenApiTagGroup[])
+ : [];
+
for (const node of groupableNodes) {
for (const tagName of node.tags) {
const tagId = tagName.toLowerCase();
if (groupsByTagId[tagId]) {
- groupsByTagId[tagId].items.push(node);
+ groupsByTagId[tagId].items?.push(node);
} else {
const serviceTagIndex = lowerCaseServiceTags.findIndex(tn => tn === tagId);
- const serviceTagName = serviceNode.tags[serviceTagIndex];
+ const rawServiceTag: INodeTag & { 'x-displayName': string | undefined } = rawServiceTags[
+ serviceTagIndex
+ ] as INodeTag & { 'x-displayName': string | undefined };
+ let serviceTagName = serviceNode.tags[serviceTagIndex];
+ if (rawServiceTag && typeof rawServiceTag['x-displayName'] !== 'undefined') {
+ serviceTagName = rawServiceTag['x-displayName'];
+ }
groupsByTagId[tagId] = {
title: serviceTagName || tagName,
+ isDivider: false,
items: [node],
};
}
+
+ // Only bother collecting node-groups mapping data when tag groups are used
+ if (useTagGroups) {
+ for (const nodeTag of node.tags) {
+ const nodeTagId = nodeTag.toLowerCase();
+ const serviceTag = rawServiceTags.find(t => t.name.toLowerCase() === nodeTagId) as
+ | (INodeTag & {
+ 'x-displayName': string | undefined;
+ })
+ | undefined;
+
+ let nodeTagName = nodeTag;
+ if (serviceTag && typeof serviceTag['x-displayName'] !== 'undefined') {
+ nodeTagName = serviceTag['x-displayName'];
+ }
+
+ if (nodesByTagId[nodeTagId]) {
+ nodesByTagId[nodeTagId].items?.push(node);
+ } else {
+ nodesByTagId[nodeTagId] = {
+ title: nodeTagName,
+ isDivider: false,
+ items: [node],
+ };
+ }
+ }
+ }
}
if (node.tags.length === 0) {
ungrouped.push(node);
}
}
- const orderedTagGroups = Object.entries(groupsByTagId)
+ let orderedTagGroups: TagGroup[] = [];
+ if (useTagGroups) {
+ let grouped: TagGroup[] = [];
+ for (const tagGroup of tagGroups) {
+ if (!tagGroup.tags.length) {
+ continue;
+ }
+
+ const tagGroups = [];
+ for (const tag of tagGroup.tags) {
+ const tagGroupTagId = tag.toLowerCase();
+ const entries = nodesByTagId[tagGroupTagId];
+ if (entries) {
+ tagGroups.push(entries);
+ }
+ }
+
+ //
+ if (tagGroups.length > 0) {
+ let groupTitle = tagGroup.name;
+
+ const groupTag = rawServiceTags.find(t => t.name.toLowerCase() === tagGroup.name.toLowerCase()) as
+ | (INodeTag & {
+ 'x-displayName': string | undefined;
+ })
+ | undefined;
+ if (groupTag && typeof groupTag['x-displayName'] !== 'undefined') {
+ groupTitle = groupTag['x-displayName'];
+ }
+
+ grouped.push({
+ title: groupTitle,
+ isDivider: true,
+ });
+
+ for (const entries of tagGroups) {
+ grouped.push(entries);
+ }
+ }
+ }
+
+ return { groups: grouped, ungrouped };
+ }
+
+ orderedTagGroups = Object.entries(groupsByTagId)
.sort(([g1], [g2]) => {
const g1LC = g1.toLowerCase();
const g2LC = g2.toLowerCase();
@@ -63,80 +157,80 @@ export function computeTagGroups(serviceNode: ServiceNo
return { groups: orderedTagGroups, ungrouped };
}
-interface ComputeAPITreeConfig {
- hideSchemas?: boolean;
- hideInternal?: boolean;
-}
-
-const defaultComputerAPITreeConfig = {
- hideSchemas: false,
- hideInternal: false,
-};
-
-export const computeAPITree = (serviceNode: ServiceNode, config: ComputeAPITreeConfig = {}) => {
- const mergedConfig = defaults(config, defaultComputerAPITreeConfig);
- const tree: TableOfContentsItem[] = [];
-
- tree.push({
- id: '/',
- slug: '/',
- title: 'Overview',
- type: 'overview',
- meta: '',
- });
-
- const hasOperationNodes = serviceNode.children.some(node => node.type === NodeType.HttpOperation);
- if (hasOperationNodes) {
- tree.push({
- title: 'Endpoints',
- });
-
- const { groups, ungrouped } = computeTagGroups(serviceNode, NodeType.HttpOperation);
- addTagGroupsToTree(groups, ungrouped, tree, NodeType.HttpOperation, mergedConfig.hideInternal);
- }
-
- const hasWebhookNodes = serviceNode.children.some(node => node.type === NodeType.HttpWebhook);
- if (hasWebhookNodes) {
- tree.push({
- title: 'Webhooks',
- });
-
- const { groups, ungrouped } = computeTagGroups(serviceNode, NodeType.HttpWebhook);
- addTagGroupsToTree(groups, ungrouped, tree, NodeType.HttpWebhook, mergedConfig.hideInternal);
- }
-
- let schemaNodes = serviceNode.children.filter(node => node.type === NodeType.Model);
- if (mergedConfig.hideInternal) {
- schemaNodes = schemaNodes.filter(n => !isInternal(n));
- }
-
- if (!mergedConfig.hideSchemas && schemaNodes.length) {
- tree.push({
- title: 'Schemas',
- });
-
- const { groups, ungrouped } = computeTagGroups(serviceNode, NodeType.Model);
- addTagGroupsToTree(groups, ungrouped, tree, NodeType.Model, mergedConfig.hideInternal);
- }
- return tree;
-};
-
-export const findFirstNodeSlug = (tree: TableOfContentsItem[]): string | void => {
- for (const item of tree) {
- if ('slug' in item) {
- return item.slug;
- }
-
- if ('items' in item) {
- const slug = findFirstNodeSlug(item.items);
- if (slug) {
- return slug;
- }
- }
- }
-
- return;
-};
+// interface ComputeAPITreeConfig {
+// hideSchemas?: boolean;
+// hideInternal?: boolean;
+// }
+
+// const defaultComputerAPITreeConfig = {
+// hideSchemas: false,
+// hideInternal: false,
+// };
+
+// export const computeAPITree = (serviceNode: ServiceNode, config: ComputeAPITreeConfig = {}) => {
+// const mergedConfig = defaults(config, defaultComputerAPITreeConfig);
+// const tree: TableOfContentsItem[] = [];
+
+// tree.push({
+// id: '/',
+// slug: '/',
+// title: 'Overview',
+// type: 'overview',
+// meta: '',
+// });
+
+// const hasOperationNodes = serviceNode.children.some(node => node.type === NodeType.HttpOperation);
+// if (hasOperationNodes) {
+// tree.push({
+// title: 'Endpoints',
+// });
+
+// const { groups, ungrouped } = computeTagGroups(serviceNode, NodeType.HttpOperation);
+// addTagGroupsToTree(groups, ungrouped, tree, NodeType.HttpOperation, mergedConfig.hideInternal);
+// }
+
+// const hasWebhookNodes = serviceNode.children.some(node => node.type === NodeType.HttpWebhook);
+// if (hasWebhookNodes) {
+// tree.push({
+// title: 'Webhooks',
+// });
+
+// const { groups, ungrouped } = computeTagGroups(serviceNode, NodeType.HttpWebhook);
+// addTagGroupsToTree(groups, ungrouped, tree, NodeType.HttpWebhook, mergedConfig.hideInternal);
+// }
+
+// let schemaNodes = serviceNode.children.filter(node => node.type === NodeType.Model);
+// if (mergedConfig.hideInternal) {
+// schemaNodes = schemaNodes.filter(n => !isInternal(n));
+// }
+
+// if (!mergedConfig.hideSchemas && schemaNodes.length) {
+// tree.push({
+// title: 'Schemas',
+// });
+
+// const { groups, ungrouped } = computeTagGroups(serviceNode, NodeType.Model);
+// addTagGroupsToTree(groups, ungrouped, tree, NodeType.Model, mergedConfig.hideInternal);
+// }
+// return tree;
+// };
+
+// export const findFirstNodeSlug = (tree: TableOfContentsItem[]): string | void => {
+// for (const item of tree) {
+// if ('slug' in item) {
+// return item.slug;
+// }
+
+// if ('items' in item) {
+// const slug = findFirstNodeSlug(item.items);
+// if (slug) {
+// return slug;
+// }
+// }
+// }
+
+// return;
+// };
export const isInternal = (node: ServiceChildNode | ServiceNode): boolean => {
const data = node.data;
@@ -152,29 +246,32 @@ export const isInternal = (node: ServiceChildNode | ServiceNode): boolean => {
return !!data['x-internal' as keyof JSONSchema7];
};
-const addTagGroupsToTree = (
+export const addTagGroupsToTree = (
groups: TagGroup[],
ungrouped: T[],
tree: TableOfContentsItem[],
itemsType: TableOfContentsGroup['itemsType'],
- hideInternal: boolean,
+ config: { hideInternal: boolean; useTagGroups: boolean },
) => {
+ const { hideInternal = false } = config ?? {};
// Show ungrouped nodes above tag groups
- ungrouped.forEach(node => {
- if (hideInternal && isInternal(node)) {
- return;
- }
- tree.push({
- id: node.uri,
- slug: node.uri,
- title: node.name,
- type: node.type,
- meta: isHttpOperation(node.data) || isHttpWebhookOperation(node.data) ? node.data.method : '',
+ ungrouped
+ .sort((a, b) => a.name.localeCompare(b.name))
+ .forEach(node => {
+ if (hideInternal && isInternal(node)) {
+ return;
+ }
+ tree.push({
+ id: node.uri,
+ slug: node.uri,
+ title: node.name,
+ type: node.type,
+ meta: isHttpOperation(node.data) || isHttpWebhookOperation(node.data) ? node.data.method : '',
+ });
});
- });
groups.forEach(group => {
- const items = group.items.flatMap(node => {
+ const items = group.items?.flatMap(node => {
if (hideInternal && isInternal(node)) {
return [];
}
@@ -186,12 +283,16 @@ const addTagGroupsToTree = (
meta: isHttpOperation(node.data) || isHttpWebhookOperation(node.data) ? node.data.method : '',
};
});
- if (items.length > 0) {
+ if (items && items.length > 0) {
tree.push({
title: group.title,
items,
itemsType,
});
+ } else if (group.isDivider) {
+ tree.push({
+ title: group.title,
+ });
}
});
};