mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-11 09:51:30 +00:00
style: enhance syntax highlighting and theme integration in QueryEditor and GenerateDocs components
This commit is contained in:
@@ -22,46 +22,54 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
}
|
||||
|
||||
.cm-s-monokai span.cm-property,
|
||||
.cm-s-monokai span.cm-attribute {
|
||||
color: #9cdcfe !important;
|
||||
}
|
||||
|
||||
.cm-s-monokai span.cm-property,
|
||||
.cm-s-monokai span.cm-attribute {
|
||||
color: #9cdcfe !important;
|
||||
}
|
||||
|
||||
.cm-s-monokai span.cm-string {
|
||||
color: #ce9178 !important;
|
||||
}
|
||||
|
||||
.cm-s-monokai span.cm-number {
|
||||
color: #b5cea8 !important;
|
||||
}
|
||||
|
||||
.cm-s-monokai span.cm-atom {
|
||||
color: #569cd6 !important;
|
||||
.cm-s-default, .cm-s-monokai {
|
||||
span.cm-def {
|
||||
color: ${(props) => props.theme.codemirror.tokens.definition} !important;
|
||||
}
|
||||
span.cm-property {
|
||||
color: ${(props) => props.theme.codemirror.tokens.property} !important;
|
||||
}
|
||||
span.cm-string {
|
||||
color: ${(props) => props.theme.codemirror.tokens.string} !important;
|
||||
}
|
||||
span.cm-number {
|
||||
color: ${(props) => props.theme.codemirror.tokens.number} !important;
|
||||
}
|
||||
span.cm-atom {
|
||||
color: ${(props) => props.theme.codemirror.tokens.atom} !important;
|
||||
}
|
||||
span.cm-variable, span.cm-variable-2 {
|
||||
color: ${(props) => props.theme.codemirror.tokens.variable} !important;
|
||||
}
|
||||
span.cm-keyword {
|
||||
color: ${(props) => props.theme.codemirror.tokens.keyword} !important;
|
||||
}
|
||||
span.cm-comment {
|
||||
color: ${(props) => props.theme.codemirror.tokens.comment} !important;
|
||||
}
|
||||
span.cm-operator {
|
||||
color: ${(props) => props.theme.codemirror.tokens.operator} !important;
|
||||
}
|
||||
span.cm-tag {
|
||||
color: ${(props) => props.theme.codemirror.tokens.tag} !important;
|
||||
}
|
||||
span.cm-tag.cm-bracket {
|
||||
color: ${(props) => props.theme.codemirror.tokens.tagBracket} !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Variable validation colors */
|
||||
.cm-variable-valid {
|
||||
color: green;
|
||||
color: #5fad89 !important; /* Soft sage */
|
||||
}
|
||||
.cm-variable-invalid {
|
||||
color: red;
|
||||
color: #d17b7b !important; /* Soft coral */
|
||||
}
|
||||
|
||||
|
||||
.CodeMirror-search-hint {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.cm-s-default span.cm-property {
|
||||
color: #1f61a0 !important;
|
||||
}
|
||||
|
||||
.cm-s-default span.cm-variable {
|
||||
color: #397d13 !important;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
|
||||
@@ -96,6 +96,93 @@ const StyledWrapper = styled.div`
|
||||
div.doc-explorer-rhs {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
// GraphQL docs color overrides
|
||||
.doc-explorer-back {
|
||||
color: ${(props) => props.theme.textLink};
|
||||
|
||||
&:before {
|
||||
border-left-color: ${(props) => props.theme.textLink};
|
||||
border-top-color: ${(props) => props.theme.textLink};
|
||||
}
|
||||
}
|
||||
|
||||
.doc-explorer-contents {
|
||||
border-top-color: ${(props) => props.theme.border.border2};
|
||||
}
|
||||
|
||||
.doc-type-description code,
|
||||
.doc-category code {
|
||||
color: ${(props) => props.theme.codemirror.tokens.keyword};
|
||||
background-color: ${(props) => props.theme.background.surface0};
|
||||
border-color: ${(props) => props.theme.border.border1};
|
||||
}
|
||||
|
||||
.doc-category-title {
|
||||
border-bottom-color: ${(props) => props.theme.border.border1};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.doc-category-item {
|
||||
color: ${(props) => props.theme.colors.text.subtext2};
|
||||
}
|
||||
|
||||
.keyword {
|
||||
color: ${(props) => props.theme.codemirror.tokens.property};
|
||||
}
|
||||
|
||||
.type-name {
|
||||
color: ${(props) => props.theme.codemirror.tokens.atom};
|
||||
}
|
||||
|
||||
.field-name {
|
||||
color: ${(props) => props.theme.codemirror.tokens.property};
|
||||
}
|
||||
|
||||
.field-short-description {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.enum-value {
|
||||
color: ${(props) => props.theme.textLink};
|
||||
}
|
||||
|
||||
.arg-name {
|
||||
color: ${(props) => props.theme.colors.text.purple};
|
||||
}
|
||||
|
||||
.arg-default-value {
|
||||
color: ${(props) => props.theme.colors.text.green};
|
||||
}
|
||||
|
||||
.doc-deprecation {
|
||||
background: ${(props) => props.theme.status.warning.background};
|
||||
box-shadow: inset 0 0 1px ${(props) => props.theme.status.warning.border};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
|
||||
&:before {
|
||||
color: ${(props) => props.theme.status.warning.text};
|
||||
}
|
||||
}
|
||||
|
||||
.show-btn {
|
||||
border-color: ${(props) => props.theme.border.border2};
|
||||
background: ${(props) => props.theme.background.surface0};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
.search-box {
|
||||
border-bottom-color: ${(props) => props.theme.border.border1};
|
||||
}
|
||||
|
||||
.search-box-clear {
|
||||
background-color: ${(props) => props.theme.overlay.overlay1};
|
||||
color: ${(props) => props.theme.colors.text.white};
|
||||
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.overlay.overlay2};
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,47 +1,39 @@
|
||||
import styled from 'styled-components';
|
||||
import { rgba } from 'polished';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.info-card {
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
background-color: ${(props) => rgba(props.theme.accents.primary, 0.06)};
|
||||
border: 1px solid ${(props) => rgba(props.theme.accents.primary, 0.2)};
|
||||
|
||||
.info-icon {
|
||||
color: ${(props) => props.theme.accents.primary};
|
||||
}
|
||||
|
||||
.info-title {
|
||||
font-weight: 500;
|
||||
.content {
|
||||
.title {
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
.info-description {
|
||||
.description {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.features {
|
||||
li {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
color: ${(props) => props.theme.colors.text.green};
|
||||
}
|
||||
}
|
||||
|
||||
.note {
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.feature-item {
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
background-color: ${(props) => props.theme.background.base};
|
||||
border: 1px solid ${(props) => props.theme.border.border0};
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
.feature-icon {
|
||||
color: ${(props) => props.theme.colors.text.green};
|
||||
}
|
||||
}
|
||||
|
||||
.note-section {
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
background-color: ${(props) => rgba(props.theme.colors.text.warning, 0.06)};
|
||||
border: 1px solid ${(props) => rgba(props.theme.colors.text.warning, 0.2)};
|
||||
.text-warning {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.colors.text.warning};
|
||||
line-height: 1.5;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,59 +1,92 @@
|
||||
import React from 'react';
|
||||
import Modal from 'components/Modal';
|
||||
import { IconCheck, IconInfoCircle, IconAlertTriangle, IconLoader2 } from '@tabler/icons';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { transformCollectionToSaveToExportAsFile } from 'utils/collections/index';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { findCollectionByUid, areItemsLoading } from 'utils/collections/index';
|
||||
import { brunoToOpenCollection } from '@usebruno/converters';
|
||||
import { sanitizeName } from 'utils/common/regex';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import * as FileSaver from 'file-saver';
|
||||
import jsyaml from 'js-yaml';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IconBook, IconCheck, IconAlertTriangle, IconLoader2 } from '@tabler/icons';
|
||||
|
||||
import Modal from 'components/Modal';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { useApp } from 'providers/App';
|
||||
import { transformCollectionToSaveToExportAsFile, findCollectionByUid, areItemsLoading } from 'utils/collections/index';
|
||||
import { brunoToOpenCollection } from '@usebruno/converters';
|
||||
import { sanitizeName } from 'utils/common/regex';
|
||||
import { escapeHtml } from 'utils/response';
|
||||
|
||||
const CDN_BASE_URL = 'https://cdn.opencollection.com';
|
||||
|
||||
const FEATURES = [
|
||||
'Standalone HTML file - no server required',
|
||||
'Interactive API playground',
|
||||
'Host on any static file server'
|
||||
];
|
||||
|
||||
const escapeForTemplate = (content) =>
|
||||
content.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$/g, '\\$');
|
||||
|
||||
const buildHtmlDocument = (collectionName, yamlContent) => `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${collectionName} - API Documentation</title>
|
||||
<style>
|
||||
body { margin: 0; padding: 0; }
|
||||
#opencollection-container { width: 100vw; height: 100vh; }
|
||||
</style>
|
||||
<link rel="stylesheet" href="${CDN_BASE_URL}/docs.css">
|
||||
<script src="${CDN_BASE_URL}/docs.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="opencollection-container"></div>
|
||||
<script>
|
||||
const collectionData = \`${yamlContent}\`;
|
||||
new window.OpenCollection({
|
||||
target: document.getElementById('opencollection-container'),
|
||||
opencollection: collectionData,
|
||||
theme: 'light'
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const CollectionNotFound = ({ onClose }) => (
|
||||
<Modal size="md" title="Generate Documentation" confirmText="Close" handleConfirm={onClose} hideCancel>
|
||||
<StyledWrapper className="w-[500px]">
|
||||
<div className="flex items-center gap-2 text-warning">
|
||||
<IconAlertTriangle size={16} className="shrink-0" />
|
||||
<span>Collection not found. It may have been deleted or is no longer available.</span>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
const GenerateDocumentation = ({ onClose, collectionUid }) => {
|
||||
const { version } = useApp();
|
||||
const collection = useSelector((state) => findCollectionByUid(state.collections.collections, collectionUid));
|
||||
const isCollectionLoading = collection ? areItemsLoading(collection) : false;
|
||||
const collection = useSelector((state) =>
|
||||
findCollectionByUid(state.collections.collections, collectionUid)
|
||||
);
|
||||
|
||||
if (!collection) {
|
||||
return (
|
||||
<Modal
|
||||
size="md"
|
||||
title="Generate Documentation"
|
||||
confirmText="Close"
|
||||
handleConfirm={onClose}
|
||||
hideCancel={true}
|
||||
>
|
||||
<StyledWrapper className="w-[500px]">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="note-section flex items-start gap-2 p-3">
|
||||
<IconAlertTriangle size={16} className="shrink-0 mt-px" />
|
||||
<span>Collection not found. It may have been deleted or is no longer available.</span>
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
const isLoading = useMemo(
|
||||
() => (collection ? areItemsLoading(collection) : false),
|
||||
[collection]
|
||||
);
|
||||
|
||||
const generateHtmlDocumentation = () => {
|
||||
const handleGenerate = useCallback(() => {
|
||||
try {
|
||||
const collectionCopy = cloneDeep(collection);
|
||||
const transformedCollection = transformCollectionToSaveToExportAsFile(collectionCopy);
|
||||
const openCollection = brunoToOpenCollection(transformedCollection);
|
||||
|
||||
if (!openCollection.extensions) {
|
||||
openCollection.extensions = {};
|
||||
}
|
||||
if (!openCollection.extensions.bruno) {
|
||||
openCollection.extensions.bruno = {};
|
||||
}
|
||||
openCollection.extensions.bruno.exportedAt = new Date().toISOString();
|
||||
openCollection.extensions.bruno.exportedUsing = version ? `Bruno/${version}` : 'Bruno';
|
||||
openCollection.extensions = {
|
||||
...openCollection.extensions,
|
||||
bruno: {
|
||||
...openCollection.extensions?.bruno,
|
||||
exportedAt: new Date().toISOString(),
|
||||
exportedUsing: version ? `Bruno/${version}` : 'Bruno'
|
||||
}
|
||||
};
|
||||
|
||||
const yamlContent = jsyaml.dump(openCollection, {
|
||||
indent: 2,
|
||||
@@ -62,117 +95,67 @@ const GenerateDocumentation = ({ onClose, collectionUid }) => {
|
||||
sortKeys: false
|
||||
});
|
||||
|
||||
const escapedYaml = yamlContent
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/`/g, '\\`')
|
||||
.replace(/\$/g, '\\$');
|
||||
const htmlContent = buildHtmlDocument(
|
||||
escapeHtml(collection.name),
|
||||
escapeForTemplate(yamlContent)
|
||||
);
|
||||
|
||||
const escapedCollectionName = escapeHtml(collection.name);
|
||||
const fileName = `${sanitizeName(collection.name)}-documentation.html`;
|
||||
FileSaver.saveAs(new Blob([htmlContent], { type: 'text/html' }), fileName);
|
||||
|
||||
const htmlContent = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${escapedCollectionName} - API Documentation</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
#opencollection-container {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="https://cdn.opencollection.com/docs.css">
|
||||
<script src="https://cdn.opencollection.com/docs.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="opencollection-container"></div>
|
||||
<script>
|
||||
const collectionData = \`${escapedYaml}\`;
|
||||
const container = document.getElementById('opencollection-container');
|
||||
new window.OpenCollection({
|
||||
target: container,
|
||||
opencollection: collectionData,
|
||||
theme: 'light'
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const sanitizedName = sanitizeName(collection.name);
|
||||
const fileName = `${sanitizedName}-documentation.html`;
|
||||
const fileBlob = new Blob([htmlContent], { type: 'text/html' });
|
||||
|
||||
FileSaver.saveAs(fileBlob, fileName);
|
||||
toast.success('Documentation generated successfully');
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Error generating documentation:', error);
|
||||
toast.error('Failed to generate documentation');
|
||||
}
|
||||
};
|
||||
}, [collection, version, onClose]);
|
||||
|
||||
if (!collection) {
|
||||
return <CollectionNotFound onClose={onClose} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
size="md"
|
||||
title="Generate Documentation"
|
||||
confirmText={isCollectionLoading ? 'Loading...' : 'Generate'}
|
||||
confirmText={isLoading ? 'Loading...' : 'Generate'}
|
||||
cancelText="Cancel"
|
||||
handleConfirm={isCollectionLoading ? undefined : generateHtmlDocumentation}
|
||||
handleConfirm={isLoading ? undefined : handleGenerate}
|
||||
handleCancel={onClose}
|
||||
confirmDisabled={isCollectionLoading}
|
||||
confirmDisabled={isLoading}
|
||||
>
|
||||
<StyledWrapper className="w-[500px]">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="info-card flex items-start p-4 gap-3">
|
||||
<div className="info-icon shrink-0">
|
||||
{isCollectionLoading ? (
|
||||
<IconLoader2 size={20} className="animate-spin" />
|
||||
) : (
|
||||
<IconInfoCircle size={20} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="info-title mb-1">
|
||||
{isCollectionLoading ? 'Loading collection...' : 'Interactive API Documentation'}
|
||||
</div>
|
||||
<div className="info-description">
|
||||
{isCollectionLoading
|
||||
? 'Please wait while the collection is being loaded.'
|
||||
: 'Generate a standalone HTML file containing interactive documentation for your API collection. This file can be hosted anywhere or shared with your team.'}
|
||||
</div>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center gap-3 py-8">
|
||||
<IconLoader2 size={20} className="animate-spin" />
|
||||
<span>Loading collection...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="content">
|
||||
<h3 className="title flex items-center gap-2 mt-2 font-medium">
|
||||
<IconBook size={18} />
|
||||
<span>Interactive API Documentation</span>
|
||||
</h3>
|
||||
<p className="description mb-5">
|
||||
Generate a standalone HTML file containing interactive documentation for your API collection.
|
||||
This file can be hosted anywhere or shared with your team.
|
||||
</p>
|
||||
|
||||
{!isCollectionLoading && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2 mt-2">
|
||||
<div className="feature-item flex items-center gap-2 py-2 px-3">
|
||||
<IconCheck size={16} className="feature-icon shrink-0" />
|
||||
<span>Standalone HTML file - no server required</span>
|
||||
</div>
|
||||
<div className="feature-item flex items-center gap-2 py-2 px-3">
|
||||
<IconCheck size={16} className="feature-icon shrink-0" />
|
||||
<span>Interactive API playground</span>
|
||||
</div>
|
||||
<div className="feature-item flex items-center gap-2 py-2 px-3">
|
||||
<IconCheck size={16} className="feature-icon shrink-0" />
|
||||
<span>Host on any static file server</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="features flex flex-col list-none gap-2.5 p-0 mb-5">
|
||||
{FEATURES.map((feature) => (
|
||||
<li key={feature} className="flex items-center gap-2.5">
|
||||
<IconCheck size={16} className="check-icon flex-shrink-0" />
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="note-section flex items-start gap-2 p-3">
|
||||
<IconAlertTriangle size={16} className="shrink-0 mt-px" />
|
||||
<span>
|
||||
The generated file uses OpenCollection CDN for rendering. An internet connection is required when viewing the documentation.
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="note m-0">
|
||||
The generated file does not embed all assets. It loads OpenCollection’s JavaScript and CSS files from a CDN when viewing docs, which requires an internet connection.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user