style: enhance syntax highlighting and theme integration in QueryEditor and GenerateDocs components

This commit is contained in:
Anoop M D
2026-01-02 22:12:56 +05:30
parent a35b455041
commit ea202be476
4 changed files with 261 additions and 191 deletions

View File

@@ -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;

View File

@@ -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};
}
}
}
`;

View File

@@ -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;
}
`;

View File

@@ -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 OpenCollections JavaScript and CSS files from a CDN when viewing docs, which requires an internet connection.
</p>
</div>
)}
</StyledWrapper>
</Modal>
);