-
diff --git a/packages/bruno-api-docs/src/entries/docs.ts b/packages/bruno-api-docs/src/entries/docs.ts
new file mode 100644
index 00000000..63c3dd85
--- /dev/null
+++ b/packages/bruno-api-docs/src/entries/docs.ts
@@ -0,0 +1,18 @@
+import '../styles/index.css';
+import Prism from '../utils/prism';
+import OpenCollectionDocs from '../components/OpenCollectionDocs/OpenCollectionDocs';
+import { createRendererClass, type OpenCollectionOptions } from '../renderer';
+
+if (typeof window !== 'undefined') {
+ (window as any).Prism = Prism;
+}
+
+export type { OpenCollectionOptions };
+
+export const OpenCollectionDocsRenderer = createRendererClass(OpenCollectionDocs);
+
+export default OpenCollectionDocsRenderer;
+
+if (typeof window !== 'undefined') {
+ (window as any).OpenCollectionDocs = OpenCollectionDocsRenderer;
+}
diff --git a/packages/bruno-api-docs/src/entries/playground.ts b/packages/bruno-api-docs/src/entries/playground.ts
new file mode 100644
index 00000000..c02c512a
--- /dev/null
+++ b/packages/bruno-api-docs/src/entries/playground.ts
@@ -0,0 +1,18 @@
+import '../styles/index.css';
+import Prism from '../utils/prism';
+import OpenCollectionPlayground from '../components/OpenCollectionPlayground/OpenCollectionPlayground';
+import { createRendererClass, type OpenCollectionOptions } from '../renderer';
+
+if (typeof window !== 'undefined') {
+ (window as any).Prism = Prism;
+}
+
+export type { OpenCollectionOptions };
+
+export const OpenCollectionPlaygroundRenderer = createRendererClass(OpenCollectionPlayground);
+
+export default OpenCollectionPlaygroundRenderer;
+
+if (typeof window !== 'undefined') {
+ (window as any).OpenCollectionPlayground = OpenCollectionPlaygroundRenderer;
+}
diff --git a/packages/bruno-api-docs/src/hooks/index.ts b/packages/bruno-api-docs/src/hooks/index.ts
index 43a69202..56eaa372 100644
--- a/packages/bruno-api-docs/src/hooks/index.ts
+++ b/packages/bruno-api-docs/src/hooks/index.ts
@@ -26,7 +26,8 @@ export {
ItemVariableResolverProvider,
ShowVarsOverrideProvider,
type VariableResolver,
- type VariableLookup
+ type VariableLookup,
+ type VariableChange
} from './useVariableResolver';
export { usePlaygroundUrlState, type PlaygroundUrlApi } from './usePlaygroundUrlState';
export { useDocsNavigate } from './useDocsNavigate';
diff --git a/packages/bruno-api-docs/src/hooks/useVariableResolver.spec.tsx b/packages/bruno-api-docs/src/hooks/useVariableResolver.spec.tsx
index 3f9af104..d47e3edd 100644
--- a/packages/bruno-api-docs/src/hooks/useVariableResolver.spec.tsx
+++ b/packages/bruno-api-docs/src/hooks/useVariableResolver.spec.tsx
@@ -3,7 +3,7 @@ import { renderToStaticMarkup } from 'react-dom/server';
import { Provider } from 'react-redux';
import { describe, it, expect } from 'vitest';
import { createOpenCollectionStore } from '@/store/store';
-import { setDocsCollection } from '@/store/slices/docs';
+import { collectionLoaded } from '@/store/slices/collection';
import { setActiveEnv, setShowVars } from '@/store/slices/env';
import { useVariableResolver, useResolvedVariables, ItemVariableResolverProvider } from './useVariableResolver';
@@ -37,7 +37,7 @@ const Probe: React.FC = () => {
const render = (configure: (store: ReturnType
) => void): string => {
const store = createOpenCollectionStore();
- store.dispatch(setDocsCollection(collection));
+ store.dispatch(collectionLoaded(collection));
configure(store);
return renderToStaticMarkup(
@@ -98,7 +98,7 @@ describe('lookup (variable hover card)', () => {
const renderLookup = (name: string, showVars = false): string => {
const store = createOpenCollectionStore();
- store.dispatch(setDocsCollection(collection));
+ store.dispatch(collectionLoaded(collection));
store.dispatch(setActiveEnv('Dev'));
if (showVars) store.dispatch(setShowVars(true));
return renderToStaticMarkup(
@@ -165,13 +165,13 @@ describe('nested variable resolution', () => {
it('follows a variable that points at another variable, for display and for interpolation', () => {
const store = createOpenCollectionStore();
- store.dispatch(setDocsCollection(nested));
+ store.dispatch(collectionLoaded(nested));
store.dispatch(setActiveEnv('Dev'));
store.dispatch(setShowVars(true));
const html = renderToStaticMarkup(
-
+ {}}>
@@ -183,13 +183,13 @@ describe('nested variable resolution', () => {
it('gates resolve on showVars but never interpolate', () => {
const store = createOpenCollectionStore();
- store.dispatch(setDocsCollection(nested));
+ store.dispatch(collectionLoaded(nested));
store.dispatch(setActiveEnv('Dev'));
store.dispatch(setShowVars(false));
const html = renderToStaticMarkup(
-
+ {}}>
@@ -198,4 +198,44 @@ describe('nested variable resolution', () => {
expect(html).toContain('{{endpoint}}');
expect(html).toContain('https://api.test/v1');
});
+
+ const WriteProbe: React.FC = () => {
+ const r = useResolvedVariables();
+ r.updateVariable('host', 'https://edited.test');
+ return {String(r.canWrite)};
+ };
+
+ it('hands the change to the injected writer instead of writing the store itself', () => {
+ const store = createOpenCollectionStore();
+ store.dispatch(collectionLoaded(nested));
+ store.dispatch(setActiveEnv('Dev'));
+ const changes: unknown[] = [];
+
+ const html = renderToStaticMarkup(
+
+ changes.push(c)}>
+
+
+
+ );
+
+ expect(html).toContain('true');
+ expect(changes).toEqual([{ scope: 'collection', name: 'host', value: 'https://edited.test' }]);
+ });
+
+ it('is read-only with no writer, as the docs pages mount it', () => {
+ const store = createOpenCollectionStore();
+ store.dispatch(collectionLoaded(nested));
+ store.dispatch(setActiveEnv('Dev'));
+
+ const html = renderToStaticMarkup(
+
+
+
+
+
+ );
+
+ expect(html).toContain('false');
+ });
});
diff --git a/packages/bruno-api-docs/src/hooks/useVariableResolver.tsx b/packages/bruno-api-docs/src/hooks/useVariableResolver.tsx
index f07b9d64..dc32adb6 100644
--- a/packages/bruno-api-docs/src/hooks/useVariableResolver.tsx
+++ b/packages/bruno-api-docs/src/hooks/useVariableResolver.tsx
@@ -3,13 +3,12 @@ import type { OpenCollection } from '@opencollection/types';
import type { Environment } from '@opencollection/types/config/environments';
import type { Item } from '@opencollection/types/collection/item';
import type { Variable, SecretVariable } from '@opencollection/types/common/variables';
-import { useAppDispatch, useAppSelector } from '@/store/hooks';
-import { selectDocsCollection } from '@/store/slices/docs';
-import { setPlaygroundVariable } from '@/store/slices/playground';
+import { useAppSelector } from '@/store/hooks';
+import { selectCollection } from '@/store/slices/collection';
import { selectActiveEnvName, selectShowVars } from '@/store/slices/env';
import { getRequestVariables, isFolder } from '@/utils/schemaHelpers';
import { getItemUuid } from '@/utils/itemUtils';
-import { mockDataFunctions, timeBasedDynamicVars } from '@/runner/utils/faker-functions';
+import { mockDataFunctions, timeBasedDynamicVars } from '@/utils/faker-functions';
import {
buildScopedVariableModel,
resolveValueDeep,
@@ -56,6 +55,16 @@ const classifyDynamic = (name: string): DynamicVariableKind => {
* exactly a secret reference is reported by `secretRefName()` so the caller
* can mask the whole cell.
*/
+// The one write this hook can request. Named here, at the boundary, so the
+// surface that owns the state supplies the writer instead of the hook importing it.
+export interface VariableChange {
+ scope: 'environment' | 'collection' | 'folder' | 'request' | '$secrets';
+ name: string;
+ value: string;
+ envName?: string;
+ itemUuid?: string;
+}
+
export interface VariableResolver {
showVars: boolean;
activeEnvName: string | null;
@@ -145,7 +154,7 @@ const itemSource = (item: Item): VariableSource =>
: { scope: 'request', variables: getRequestVariables(item as never) as (Variable | SecretVariable)[] };
export const useVariableResolver = (): VariableResolver => {
- const collection = useAppSelector(selectDocsCollection) as OpenCollection | null;
+ const collection = useAppSelector(selectCollection) as OpenCollection | null;
const activeEnvName = useAppSelector(selectActiveEnvName);
const showVars = useAppSelector(selectShowVars);
@@ -221,15 +230,15 @@ export const ItemVariableResolverProvider: React.FC<{
collection: OpenCollection | null;
ancestry: Item[];
item: Item | null;
- writable?: boolean;
+ onUpdateVariable?: (change: VariableChange) => void;
children: React.ReactNode;
-}> = ({ collection, ancestry, item, writable = false, children }) => {
- const dispatch = useAppDispatch();
+}> = ({ collection, ancestry, item, onUpdateVariable, children }) => {
+ const writable = onUpdateVariable !== undefined;
const activeEnvName = useAppSelector(selectActiveEnvName);
const showVars = useAppSelector(selectShowVars);
// Both the docs pages and the playground mount this provider; only the
- // playground passes `writable`, and only it can supply an external secret.
+ // playground supplies a writer, and only it can supply an external secret.
const model = useMemo(() => {
const sources: VariableSource[] = collectionAndEnvSources(collection, activeEnvName, writable);
for (const folder of ancestry) {
@@ -245,21 +254,21 @@ export const ItemVariableResolverProvider: React.FC<{
(name: string, value: string) => {
const { name: varName, scope } = resolver.lookup(name);
if (scope === 'environment' || scope === '$secrets') {
- if (activeEnvName) dispatch(setPlaygroundVariable({ scope, name: varName, value, envName: activeEnvName }));
+ if (activeEnvName) onUpdateVariable?.({ scope, name: varName, value, envName: activeEnvName });
} else if (scope === 'collection') {
- dispatch(setPlaygroundVariable({ scope, name: varName, value }));
+ onUpdateVariable?.({ scope, name: varName, value });
} else if (scope === 'request') {
const itemUuid = getItemUuid(item);
- if (itemUuid) dispatch(setPlaygroundVariable({ scope, name: varName, value, itemUuid }));
+ if (itemUuid) onUpdateVariable?.({ scope, name: varName, value, itemUuid });
} else if (scope === 'folder') {
const owner = [...ancestry].reverse().find((folder) =>
folderVariables(folder).some((v) => v.name === varName && !v.disabled)
);
const itemUuid = getItemUuid(owner);
- if (itemUuid) dispatch(setPlaygroundVariable({ scope, name: varName, value, itemUuid }));
+ if (itemUuid) onUpdateVariable?.({ scope, name: varName, value, itemUuid });
}
},
- [resolver, dispatch, activeEnvName, item, ancestry]
+ [resolver, onUpdateVariable, activeEnvName, item, ancestry]
);
const interpolateWithSecrets = useCallback(
diff --git a/packages/bruno-api-docs/src/renderer.ts b/packages/bruno-api-docs/src/renderer.ts
new file mode 100644
index 00000000..423c818e
--- /dev/null
+++ b/packages/bruno-api-docs/src/renderer.ts
@@ -0,0 +1,119 @@
+import React from 'react';
+import type { Root } from 'react-dom/client';
+import { createRoot } from 'react-dom/client';
+import type { OpenCollection as IOpenCollection } from '@opencollection/types';
+import { parseCollectionContent } from './utils/yamlUtils';
+
+export interface OpenCollectionOptions {
+ target: HTMLElement;
+ opencollection: any;
+ logo?: string;
+ gitCollectionUrl?: string;
+}
+
+type SurfaceComponent = React.ComponentType<{
+ collection: IOpenCollection;
+ logo?: React.ReactNode;
+ gitCollectionUrl?: string;
+}>;
+
+export interface OpenCollectionRenderer {
+ updateCollection(opencollection: any): void;
+ destroy(): void;
+}
+
+export type OpenCollectionRendererClass = new (options: OpenCollectionOptions) => OpenCollectionRenderer;
+
+/**
+ * The mount/update/destroy shell every standalone bundle needs, over whichever
+ * surface that bundle ships. The surface is the only difference between them.
+ */
+export const createRendererClass = (Surface: SurfaceComponent): OpenCollectionRendererClass =>
+ class {
+ private root: Root | null = null;
+ private options: OpenCollectionOptions;
+
+ constructor(options: OpenCollectionOptions) {
+ this.options = options;
+ this.init();
+ }
+
+ private injectInterFont() {
+ // Only inject if not already present
+ if (!document.querySelector('link[href*="fonts.googleapis.com/css2?family=Inter"]')) {
+ const links = [
+ { rel: 'preconnect', href: 'https://fonts.googleapis.com' },
+ { rel: 'preconnect', href: 'https://fonts.gstatic.com', crossOrigin: 'anonymous' },
+ {
+ rel: 'stylesheet',
+ href: 'https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=JetBrains+Mono:wght@400;500;600;700&display=swap'
+ }
+ ];
+
+ links.forEach((linkProps) => {
+ const link = document.createElement('link');
+ Object.entries(linkProps).forEach(([key, value]) => {
+ link.setAttribute(key, value);
+ });
+ document.head.appendChild(link);
+ });
+ }
+ }
+
+ private init() {
+ if (!this.options.target) {
+ throw new Error('Target element is required');
+ }
+
+ this.injectInterFont();
+ this.root = createRoot(this.options.target);
+ this.render();
+ }
+
+ private convertCollection(opencollection: any): IOpenCollection {
+ if (typeof opencollection === 'string') {
+ try {
+ return parseCollectionContent(opencollection) as IOpenCollection;
+ } catch (error) {
+ console.error('Failed to parse collection:', error);
+ throw new Error(`Invalid collection format: ${error instanceof Error ? error.message : 'Unknown error'}`);
+ }
+ }
+
+ return opencollection as IOpenCollection;
+ }
+
+ private createLogoElement(): React.ReactNode {
+ if (!this.options.logo) return undefined;
+
+ return React.createElement('img', {
+ src: this.options.logo,
+ alt: 'Logo',
+ style: { height: '32px', width: 'auto' }
+ });
+ }
+
+ private render() {
+ if (!this.root) return;
+
+ const collection = this.convertCollection(this.options.opencollection);
+
+ this.root.render(React.createElement(Surface, {
+ collection,
+ logo: this.createLogoElement(),
+ gitCollectionUrl: this.options.gitCollectionUrl
+ }));
+ }
+
+ public updateCollection(opencollection: any) {
+ this.options.opencollection = opencollection;
+ this.render();
+ }
+
+ public destroy() {
+ if (this.root) {
+ this.root.unmount();
+ this.root = null;
+ }
+ }
+ };
diff --git a/packages/bruno-api-docs/src/routing/hooks.ts b/packages/bruno-api-docs/src/routing/hooks.ts
index b4525218..61ca7232 100644
--- a/packages/bruno-api-docs/src/routing/hooks.ts
+++ b/packages/bruno-api-docs/src/routing/hooks.ts
@@ -1,14 +1,14 @@
import { useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { useAppSelector } from '@/store/hooks';
-import { selectDocsCollection } from '@/store/slices/docs';
+import { selectCollection } from '@/store/slices/collection';
import { buildNavModel } from './navModel';
import { resolveSlug, type Resolution } from './resolve';
import type { NavModel } from './types';
/** Memoised nav model for the currently loaded collection. */
export const useNavModel = (): NavModel => {
- const collection = useAppSelector(selectDocsCollection);
+ const collection = useAppSelector(selectCollection);
return useMemo(() => buildNavModel(collection), [collection]);
};
diff --git a/packages/bruno-api-docs/src/runner/utils/variable-interpolator.ts b/packages/bruno-api-docs/src/runner/utils/variable-interpolator.ts
index 7b9caf58..438af49f 100644
--- a/packages/bruno-api-docs/src/runner/utils/variable-interpolator.ts
+++ b/packages/bruno-api-docs/src/runner/utils/variable-interpolator.ts
@@ -2,7 +2,7 @@ import type { HttpRequest, HttpRequestHeader, HttpRequestParam } from '@opencoll
import { isPlainObject } from 'lodash-es';
import { getRequestUrl, getHttpMethod, getHttpHeaders, getHttpBody, getHttpParams, getRequestAuth } from '@/utils/schemaHelpers';
import { templateVariableGlobalRegex } from '@/utils/common';
-import { mockDataFunctions } from './faker-functions';
+import { mockDataFunctions } from '@/utils/faker-functions';
export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
export type Variables = Record;
diff --git a/packages/bruno-api-docs/src/standalone.ts b/packages/bruno-api-docs/src/standalone.ts
index 254da928..27b3e441 100644
--- a/packages/bruno-api-docs/src/standalone.ts
+++ b/packages/bruno-api-docs/src/standalone.ts
@@ -1,113 +1,17 @@
-import React from 'react';
-import type { Root } from 'react-dom/client';
-import { createRoot } from 'react-dom/client';
import './styles/index.css';
// Import Prism (with our token customizations) to ensure it's bundled
import Prism from './utils/prism';
import OpenCollection from './components/OpenCollection/OpenCollection';
-import type { OpenCollection as IOpenCollection } from '@opencollection/types';
-import { parseCollectionContent } from './utils/yamlUtils';
+import { createRendererClass, type OpenCollectionOptions } from './renderer';
// Ensure Prism is available globally for any code that might access it
if (typeof window !== 'undefined') {
(window as any).Prism = Prism;
}
-export interface OpenCollectionOptions {
- target: HTMLElement;
- opencollection: any;
- logo?: string;
- gitCollectionUrl?: string;
-}
-
-export class OpenCollectionRenderer {
- private root: Root | null = null;
- private options: OpenCollectionOptions;
-
- constructor(options: OpenCollectionOptions) {
- this.options = options;
- this.init();
- }
-
- private injectInterFont() {
- // Only inject if not already present
- if (!document.querySelector('link[href*="fonts.googleapis.com/css2?family=Inter"]')) {
- const links = [
- { rel: 'preconnect', href: 'https://fonts.googleapis.com' },
- { rel: 'preconnect', href: 'https://fonts.gstatic.com', crossOrigin: 'anonymous' },
- {
- rel: 'stylesheet',
- href: 'https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=JetBrains+Mono:wght@400;500;600;700&display=swap'
- }
- ];
-
- links.forEach((linkProps) => {
- const link = document.createElement('link');
- Object.entries(linkProps).forEach(([key, value]) => {
- link.setAttribute(key, value);
- });
- document.head.appendChild(link);
- });
- }
- }
-
- private init() {
- if (!this.options.target) {
- throw new Error('Target element is required');
- }
-
- this.injectInterFont();
- this.root = createRoot(this.options.target);
- this.render();
- }
-
- private convertCollection(opencollection: any): IOpenCollection {
- if (typeof opencollection === 'string') {
- try {
- return parseCollectionContent(opencollection) as IOpenCollection;
- } catch (error) {
- console.error('Failed to parse collection:', error);
- throw new Error(`Invalid collection format: ${error instanceof Error ? error.message : 'Unknown error'}`);
- }
- }
+export type { OpenCollectionOptions };
- return opencollection as IOpenCollection;
- }
-
- private createLogoElement(): React.ReactNode {
- if (!this.options.logo) return undefined;
-
- return React.createElement('img', {
- src: this.options.logo,
- alt: 'Logo',
- style: { height: '32px', width: 'auto' }
- });
- }
-
- private render() {
- if (!this.root) return;
-
- const collection = this.convertCollection(this.options.opencollection);
-
- this.root.render(React.createElement(OpenCollection, {
- collection,
- logo: this.createLogoElement(),
- gitCollectionUrl: this.options.gitCollectionUrl
- }));
- }
-
- public updateCollection(opencollection: any) {
- this.options.opencollection = opencollection;
- this.render();
- }
-
- public destroy() {
- if (this.root) {
- this.root.unmount();
- this.root = null;
- }
- }
-}
+export const OpenCollectionRenderer = createRendererClass(OpenCollection);
export default OpenCollectionRenderer;
diff --git a/packages/bruno-api-docs/src/store/slices/app.ts b/packages/bruno-api-docs/src/store/slices/app.ts
deleted file mode 100644
index d09b6908..00000000
--- a/packages/bruno-api-docs/src/store/slices/app.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
-import type { RootState } from '@/store/store';
-
-export type CollectionStatus = 'idle' | 'loading' | 'succeeded' | 'failed';
-
-export interface AppState {
- collectionStatus: CollectionStatus;
- collectionError: string | null;
- gitCollectionUrl: string | null;
-}
-
-const initialState: AppState = {
- collectionStatus: 'idle',
- collectionError: null,
- gitCollectionUrl: null
-};
-
-const appSlice = createSlice({
- name: 'app',
- initialState,
- reducers: {
- setCollectionLoading: (state: AppState) => {
- state.collectionStatus = 'loading';
- state.collectionError = null;
- },
- setCollectionSucceeded: (state: AppState) => {
- state.collectionStatus = 'succeeded';
- state.collectionError = null;
- },
- setCollectionFailed: (state: AppState, action: PayloadAction) => {
- state.collectionStatus = 'failed';
- state.collectionError = action.payload;
- },
- resetCollectionState: (state: AppState) => {
- state.collectionStatus = 'idle';
- state.collectionError = null;
- },
- setGitCollectionUrl: (state: AppState, action: PayloadAction) => {
- state.gitCollectionUrl = action.payload;
- }
- }
-});
-
-export const {
- setCollectionLoading,
- setCollectionSucceeded,
- setCollectionFailed,
- resetCollectionState,
- setGitCollectionUrl
-} = appSlice.actions;
-export default appSlice.reducer;
-
-export const selectCollectionStatus = (state: RootState) => state.app.collectionStatus;
-export const selectCollectionError = (state: RootState) => state.app.collectionError;
-export const selectGitCollectionUrl = (state: RootState) => state.app.gitCollectionUrl;
diff --git a/packages/bruno-api-docs/src/store/slices/collection.ts b/packages/bruno-api-docs/src/store/slices/collection.ts
new file mode 100644
index 00000000..c49557c3
--- /dev/null
+++ b/packages/bruno-api-docs/src/store/slices/collection.ts
@@ -0,0 +1,103 @@
+import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
+import type { OpenCollection } from '@opencollection/types';
+import type { Item, Folder } from '@opencollection/types/collection/item';
+import { hydrateWithUUIDs, findAndUpdateItem } from '@/utils/fileUtils';
+import { isFolder } from '@/utils/schemaHelpers';
+
+export type CollectionStatus = 'idle' | 'loading' | 'succeeded' | 'failed';
+
+// The document every surface reads. Loaded once by CollectionRoot; the playground
+// forks its own working copy from it and never writes back.
+export interface CollectionState {
+ document: OpenCollection | null;
+ status: CollectionStatus;
+ error: string | null;
+ gitCollectionUrl: string | null;
+}
+
+const initialState: CollectionState = {
+ document: null,
+ status: 'idle',
+ error: null,
+ gitCollectionUrl: null
+};
+
+const initializeCollapsedState = (items: Item[] | undefined): void => {
+ if (!items) return;
+ for (const item of items) {
+ if (isFolder(item)) {
+ if ((item as any).isCollapsed === undefined) {
+ (item as any).isCollapsed = true;
+ }
+ const folder = item as Folder;
+ if (folder.items) initializeCollapsedState(folder.items);
+ }
+ }
+};
+
+const collectionSlice = createSlice({
+ name: 'collection',
+ initialState,
+ reducers: {
+ collectionLoading: (state: CollectionState) => {
+ state.status = 'loading';
+ state.error = null;
+ },
+ collectionLoaded: (state: CollectionState, action: PayloadAction) => {
+ const document = hydrateWithUUIDs(action.payload);
+ initializeCollapsedState(document.items);
+ state.document = document;
+ state.status = 'succeeded';
+ state.error = null;
+ },
+ collectionFailed: (state: CollectionState, action: PayloadAction) => {
+ state.document = null;
+ state.status = 'failed';
+ state.error = action.payload;
+ },
+ collectionCleared: (state: CollectionState) => {
+ state.document = null;
+ state.status = 'idle';
+ state.error = null;
+ },
+ setGitCollectionUrl: (state: CollectionState, action: PayloadAction) => {
+ state.gitCollectionUrl = action.payload;
+ },
+ // Sidebar expansion still rides on the tree nodes. Moving it to a side table
+ // keyed by uuid is the remaining step that makes the document read-only.
+ toggleItem: (state: CollectionState, action: PayloadAction) => {
+ if (!state.document?.items) return;
+ findAndUpdateItem(state.document.items, action.payload, (item) => {
+ const currentCollapsed = (item as any).isCollapsed ?? true;
+ (item as any).isCollapsed = !currentCollapsed;
+ });
+ },
+ // Expand-only: reveal the active item's ancestors without fighting a folder
+ // the user closed by hand.
+ expandFolders: (state: CollectionState, action: PayloadAction) => {
+ if (!state.document?.items || action.payload.length === 0) return;
+ for (const uuid of new Set(action.payload)) {
+ findAndUpdateItem(state.document.items, uuid, (item) => {
+ (item as { isCollapsed?: boolean }).isCollapsed = false;
+ });
+ }
+ }
+ }
+});
+
+export const {
+ collectionLoading,
+ collectionLoaded,
+ collectionFailed,
+ collectionCleared,
+ setGitCollectionUrl,
+ toggleItem,
+ expandFolders
+} = collectionSlice.actions;
+export default collectionSlice.reducer;
+
+type WithCollection = { collection: CollectionState };
+export const selectCollection = (state: WithCollection) => state.collection.document;
+export const selectCollectionStatus = (state: WithCollection) => state.collection.status;
+export const selectCollectionError = (state: WithCollection) => state.collection.error;
+export const selectGitCollectionUrl = (state: WithCollection) => state.collection.gitCollectionUrl;
diff --git a/packages/bruno-api-docs/src/store/slices/docs.ts b/packages/bruno-api-docs/src/store/slices/docs.ts
deleted file mode 100644
index 5c0c2d55..00000000
--- a/packages/bruno-api-docs/src/store/slices/docs.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
-import type { OpenCollection as OpenCollectionCollection } from '@opencollection/types';
-import type { Item as OpenCollectionItem, Folder } from '@opencollection/types/collection/item';
-import type { RootState } from '@/store/store';
-import { hydrateWithUUIDs, findAndUpdateItem } from '@/utils/fileUtils';
-import { isFolder } from '@/utils/schemaHelpers';
-
-export interface DocsState {
- collection: OpenCollectionCollection | null;
- selectedItemId: string | null;
-}
-
-const initialState: DocsState = {
- collection: null,
- selectedItemId: null
-};
-
-// Helper function to initialize isCollapsed for folders
-const initializeCollapsedState = (items: OpenCollectionItem[] | undefined): void => {
- if (!items) return;
-
- for (const item of items) {
- if (isFolder(item)) {
- // Initialize isCollapsed to true (collapsed) if not already set
- if ((item as any).isCollapsed === undefined) {
- (item as any).isCollapsed = true;
- }
- const folder = item as Folder;
- if (folder.items) {
- initializeCollapsedState(folder.items);
- }
- }
- }
-};
-
-const docsSlice = createSlice({
- name: 'docs',
- initialState,
- reducers: {
- setDocsCollection: (state: DocsState, action: PayloadAction) => {
- // Hydrate collection with UUIDs if collection is provided
- const collection = action.payload ? hydrateWithUUIDs(action.payload) : null;
- state.collection = collection;
- // Initialize isCollapsed for all folders
- if (state.collection && state.collection.items) {
- initializeCollapsedState(state.collection.items);
- }
- // Reset selected item when collection changes
- state.selectedItemId = null;
- },
- clearDocsCollection: (state: DocsState) => {
- state.collection = null;
- state.selectedItemId = null;
- },
- toggleItem: (state: DocsState, action: PayloadAction) => {
- const uuid = action.payload;
- if (state.collection && state.collection.items) {
- findAndUpdateItem(state.collection.items, uuid, (item) => {
- // Treat undefined as true (collapsed), then toggle
- const currentCollapsed = (item as any).isCollapsed ?? true;
- (item as any).isCollapsed = !currentCollapsed;
- });
- }
- },
- selectItem: (state: DocsState, action: PayloadAction) => {
- state.selectedItemId = action.payload;
- },
- // Expand-only: force the given folders open (used to reveal the active
- // item's ancestors on navigation/deep-link). Never collapses, so it does
- // not fight a folder the user manually closed.
- expandFolders: (state: DocsState, action: PayloadAction) => {
- if (!state.collection?.items || action.payload.length === 0) return;
- const targets = new Set(action.payload);
- for (const uuid of targets) {
- findAndUpdateItem(state.collection.items, uuid, (item) => {
- (item as { isCollapsed?: boolean }).isCollapsed = false;
- });
- }
- }
- }
-});
-
-export const { setDocsCollection, clearDocsCollection, toggleItem, selectItem, expandFolders } = docsSlice.actions;
-export default docsSlice.reducer;
-
-export const selectDocsCollection = (state: RootState) => state.docs.collection;
-export const selectSelectedItemId = (state: RootState) => state.docs.selectedItemId;
diff --git a/packages/bruno-api-docs/src/store/slices/playground.spec.ts b/packages/bruno-api-docs/src/store/slices/playground.spec.ts
index 3ce2833b..255b7aad 100644
--- a/packages/bruno-api-docs/src/store/slices/playground.spec.ts
+++ b/packages/bruno-api-docs/src/store/slices/playground.spec.ts
@@ -5,7 +5,6 @@ import reducer, {
updatePlaygroundItem,
resetPlaygroundEnvironments,
selectHydratedCollection,
- selectPlaygroundCollection,
setViewMode,
setSelectedExampleIndex,
clearPlaygroundCollection,
@@ -38,7 +37,7 @@ const envExternalSecrets = (store: ReturnType)
describe('resetPlaygroundEnvironments', () => {
it('restores the original environments after an edit', () => {
- const store = createOpenCollectionStore();
+ const store = createOpenCollectionStore({ playground: reducer });
store.dispatch(setPlaygroundCollection(makeCollection()));
const edited = makeCollection();
@@ -51,7 +50,7 @@ describe('resetPlaygroundEnvironments', () => {
});
it('keeps the restore independent of later edits (cloned, not shared)', () => {
- const store = createOpenCollectionStore();
+ const store = createOpenCollectionStore({ playground: reducer });
store.dispatch(setPlaygroundCollection(makeCollection()));
store.dispatch(resetPlaygroundEnvironments());
@@ -89,7 +88,7 @@ describe('updatePlaygroundItem', () => {
select(store.getState())!.items![0] as unknown as { uuid: string; http: { url: string } };
it('updates the item in the hydrated collection (what the UI renders) as well as the base collection', () => {
- const store = createOpenCollectionStore();
+ const store = createOpenCollectionStore({ playground: reducer });
store.dispatch(setPlaygroundCollection(withRequest()));
const updated = { type: 'http', uuid: 'r1', name: 'Req', http: { url: 'new', method: 'GET' } };
@@ -98,20 +97,17 @@ describe('updatePlaygroundItem', () => {
// The tree the UI reads must reflect the edit, with the uuid preserved so findItemByUuid resolves.
expect(firstItem(store, selectHydratedCollection).http.url).toBe('new');
expect(firstItem(store, selectHydratedCollection).uuid).toBe('r1');
- expect(firstItem(store, selectPlaygroundCollection).http.url).toBe('new');
});
});
describe('setPlaygroundVariable', () => {
- it('edits an environment variable in both the hydrated and base collections', () => {
- const store = createOpenCollectionStore();
+ it('edits an environment variable in the collection the UI reads', () => {
+ const store = createOpenCollectionStore({ playground: reducer });
store.dispatch(setPlaygroundCollection(makeCollection()));
store.dispatch(setPlaygroundVariable({ scope: 'environment', name: 'a', value: '99', envName: 'Dev' }));
expect((envVariables(store).find((v) => v.name === 'a') as unknown as { value: string }).value).toBe('99');
- const base = selectPlaygroundCollection(store.getState())!.config!.environments![0].variables!;
- expect((base.find((v) => v.name === 'a') as unknown as { value: string }).value).toBe('99');
});
it('edits the last enabled duplicate, matching the resolver', () => {
@@ -121,7 +117,7 @@ describe('setPlaygroundVariable', () => {
{ name: 'dup', value: 'shadowed', disabled: true },
{ name: 'dup', value: 'winner' }
];
- const store = createOpenCollectionStore();
+ const store = createOpenCollectionStore({ playground: reducer });
store.dispatch(setPlaygroundCollection(collection));
store.dispatch(setPlaygroundVariable({ scope: 'environment', name: 'dup', value: 'edited', envName: 'Dev' }));
@@ -135,7 +131,7 @@ describe('setPlaygroundVariable', () => {
it('edits a collection variable', () => {
const collection = makeCollection();
collection.request = { variables: [{ name: 'cv', value: 'x' }] };
- const store = createOpenCollectionStore();
+ const store = createOpenCollectionStore({ playground: reducer });
store.dispatch(setPlaygroundCollection(collection));
store.dispatch(setPlaygroundVariable({ scope: 'collection', name: 'cv', value: 'y' }));
@@ -151,7 +147,7 @@ describe('setPlaygroundVariable', () => {
{ type: 'http', uuid: 'r1', name: 'Req', http: { url: 'u', method: 'GET' }, variables: [{ name: 'rv', value: '1' }] }
]
} as unknown as OpenCollectionCollection;
- const store = createOpenCollectionStore();
+ const store = createOpenCollectionStore({ playground: reducer });
store.dispatch(setPlaygroundCollection(collection));
store.dispatch(setPlaygroundVariable({ scope: 'request', name: 'rv', value: '2', itemUuid: 'r1' }));
@@ -163,7 +159,7 @@ describe('setPlaygroundVariable', () => {
it('writes a session value to a secret variable, keeping it marked secret', () => {
const collection = makeCollection();
collection.config.environments[0].variables.push({ name: 'sec', secret: true });
- const store = createOpenCollectionStore();
+ const store = createOpenCollectionStore({ playground: reducer });
store.dispatch(setPlaygroundCollection(collection));
store.dispatch(setPlaygroundVariable({ scope: 'environment', name: 'sec', value: 'typed', envName: 'Dev' }));
@@ -179,7 +175,7 @@ describe('setPlaygroundVariable', () => {
type: 'aws-secrets-manager',
variables: [{ name: 'vaultKey', secretName: 'prod/api-key' }]
};
- const store = createOpenCollectionStore();
+ const store = createOpenCollectionStore({ playground: reducer });
store.dispatch(setPlaygroundCollection(collection));
store.dispatch(setPlaygroundVariable({ scope: '$secrets', name: 'vaultKey', value: 'typed', envName: 'Dev' }));
@@ -200,7 +196,7 @@ describe('playground folder collapse', () => {
selectHydratedCollection(store.getState())!.items![0] as { isCollapsed?: boolean };
it('expandFolders reveals a collapsed folder', () => {
- const store = createOpenCollectionStore();
+ const store = createOpenCollectionStore({ playground: reducer });
store.dispatch(setPlaygroundCollection(withFolder()));
store.dispatch(toggleFolderCollapse('f1'));
expect(folder(store).isCollapsed).toBe(true);
@@ -210,7 +206,7 @@ describe('playground folder collapse', () => {
});
it('expandFolders keeps an already-open folder open (never collapses)', () => {
- const store = createOpenCollectionStore();
+ const store = createOpenCollectionStore({ playground: reducer });
store.dispatch(setPlaygroundCollection(withFolder()));
store.dispatch(expandFolders(['f1']));
expect(folder(store).isCollapsed).toBe(false);
@@ -243,7 +239,7 @@ describe('applyScriptVariableChanges', () => {
view(store).config.environments[0].variables;
it('reconciles environment variables onto the current collection', () => {
- const store = createOpenCollectionStore();
+ const store = createOpenCollectionStore({ playground: reducer });
store.dispatch(setPlaygroundCollection(withRequestAndEnv()));
store.dispatch(applyScriptVariableChanges({
@@ -254,7 +250,7 @@ describe('applyScriptVariableChanges', () => {
});
it('reconciles collection variables onto the current collection', () => {
- const store = createOpenCollectionStore();
+ const store = createOpenCollectionStore({ playground: reducer });
store.dispatch(setPlaygroundCollection(withRequestAndEnv()));
store.dispatch(applyScriptVariableChanges({ collectionVariables: { variables: { c: 'changed', d: '2' }, deleted: [] } }));
@@ -263,7 +259,7 @@ describe('applyScriptVariableChanges', () => {
});
it('deletes only the variables named in deleted and leaves the rest', () => {
- const store = createOpenCollectionStore();
+ const store = createOpenCollectionStore({ playground: reducer });
store.dispatch(setPlaygroundCollection(withRequestAndEnv()));
store.dispatch(applyScriptVariableChanges({
@@ -276,7 +272,7 @@ describe('applyScriptVariableChanges', () => {
});
it('leaves store variables the delta never mentions untouched (upsert-only, not a full replace)', () => {
- const store = createOpenCollectionStore();
+ const store = createOpenCollectionStore({ playground: reducer });
store.dispatch(setPlaygroundCollection(withRequestAndEnv()));
store.dispatch(applyScriptVariableChanges({
@@ -289,7 +285,7 @@ describe('applyScriptVariableChanges', () => {
});
it('keeps a request edit made while the request was in flight', () => {
- const store = createOpenCollectionStore();
+ const store = createOpenCollectionStore({ playground: reducer });
store.dispatch(setPlaygroundCollection(withRequestAndEnv()));
const inFlightItem = view(store).items[0];
diff --git a/packages/bruno-api-docs/src/store/slices/playground.ts b/packages/bruno-api-docs/src/store/slices/playground.ts
index 8313c294..0a3d76a3 100644
--- a/packages/bruno-api-docs/src/store/slices/playground.ts
+++ b/packages/bruno-api-docs/src/store/slices/playground.ts
@@ -5,6 +5,7 @@ import type { Environment } from '@opencollection/types/config/environments';
import type { Item as OpenCollectionItem, Folder } from '@opencollection/types/collection/item';
import type { HttpRequest } from '@opencollection/types/requests/http';
import type { Variable, SecretVariable } from '@opencollection/types/common/variables';
+import { useSelector, type TypedUseSelectorHook } from 'react-redux';
import type { RootState } from '@/store/store';
import { hydrateWithUUIDs, findAndUpdateItem } from '@/utils/fileUtils';
import { isFolder, getRequestVariables } from '@/utils/schemaHelpers';
@@ -12,11 +13,12 @@ import { applyScriptEnvVars } from '@/utils/environments';
import { reconcileScriptVariables } from '@/utils/scriptVariables';
import type { Variables } from '@/runner/utils/variable-interpolator';
import type { ResponseBodyFormat } from '@/constants';
+import type { VariableChange } from '@/hooks/useVariableResolver';
+import { collectionLoaded, collectionCleared, collectionFailed } from '@/store/slices/collection';
export type ViewMode = 'playground' | 'environments' | 'folder-settings' | 'collection-settings' | 'example';
export interface PlaygroundState {
- collection: OpenCollectionCollection | null;
hydratedCollection: OpenCollectionCollection | null;
pristineEnvironments: Environment[] | null;
responses: Record; // Store responses by item UUID
@@ -30,7 +32,6 @@ export interface PlaygroundState {
}
const initialState: PlaygroundState = {
- collection: null,
hydratedCollection: null,
pristineEnvironments: null,
responses: {},
@@ -124,44 +125,47 @@ const preserveCollapsedState = (
}
};
+// The working copy: forked from the document on load, edited here, never
+// written back. Re-hydrating makes fresh item objects, which matters because the
+// document reducer has already stored (and Immer has frozen) its own copy.
+const seedWorkingCopy = (state: PlaygroundState, document: OpenCollectionCollection) => {
+ const envs = readEnvironments(document);
+ state.pristineEnvironments = envs ? cloneDeep(envs) : null;
+
+ const hydrated = hydrateWithUUIDs(document);
+
+ if (state.hydratedCollection?.items && hydrated.items) {
+ preserveCollapsedState(hydrated.items, state.hydratedCollection.items);
+ } else if (hydrated.items) {
+ initializeCollapsedState(hydrated.items);
+ }
+
+ state.hydratedCollection = hydrated;
+};
+
+const clearWorkingCopy = (state: PlaygroundState) => {
+ state.hydratedCollection = null;
+ state.pristineEnvironments = null;
+ state.responses = {};
+ state.selectedItemId = null;
+ state.selectedExampleIndex = null;
+};
+
const playgroundSlice = createSlice({
name: 'playground',
initialState,
reducers: {
setPlaygroundCollection: (state: PlaygroundState, action: PayloadAction) => {
- state.collection = action.payload;
-
if (!action.payload) {
state.hydratedCollection = null;
state.pristineEnvironments = null;
return;
}
-
- const envs = readEnvironments(action.payload);
- state.pristineEnvironments = envs ? cloneDeep(envs) : null;
-
- const hydrated = hydrateWithUUIDs(action.payload);
-
- // Preserve existing collapsed states from previous hydrated collection
- if (state.hydratedCollection?.items && hydrated.items) {
- preserveCollapsedState(hydrated.items, state.hydratedCollection.items);
- } else if (hydrated.items) {
- initializeCollapsedState(hydrated.items);
- }
-
- state.hydratedCollection = hydrated;
- },
- clearPlaygroundCollection: (state: PlaygroundState) => {
- state.collection = null;
- state.hydratedCollection = null;
- state.pristineEnvironments = null;
- state.responses = {};
- state.selectedItemId = null;
- state.selectedExampleIndex = null;
+ seedWorkingCopy(state, action.payload);
},
+ clearPlaygroundCollection: clearWorkingCopy,
updatePlaygroundItem: (state: PlaygroundState, action: PayloadAction<{ uuid: string; item: HttpRequest }>) => {
const { uuid, item } = action.payload;
- if (state.collection?.items) findAndUpdateItemInCollection(state.collection.items, uuid, item);
if (state.hydratedCollection?.items) findAndUpdateItemInCollection(state.hydratedCollection.items, uuid, item);
},
setPlaygroundResponse: (state: PlaygroundState, action: PayloadAction<{ uuid: string; response: any }>) => {
@@ -208,11 +212,9 @@ const playgroundSlice = createSlice({
}
},
updateCollectionSettings: (state: PlaygroundState, action: PayloadAction) => {
- state.collection = action.payload;
state.hydratedCollection = action.payload;
},
updateCollectionEnvironments: (state: PlaygroundState, action: PayloadAction) => {
- state.collection = action.payload;
state.hydratedCollection = action.payload;
},
applyScriptVariableChanges: (
@@ -243,7 +245,6 @@ const playgroundSlice = createSlice({
}
};
- applyTo(state.collection);
applyTo(state.hydratedCollection);
},
updateFolderInCollection: (state: PlaygroundState, action: PayloadAction<{ uuid: string; folder: Folder }>) => {
@@ -253,28 +254,14 @@ const playgroundSlice = createSlice({
findAndUpdateItem(state.hydratedCollection.items, uuid, (item) => {
Object.assign(item, folder);
});
-
- // Also update the base collection
- if (state.collection?.items) {
- findAndUpdateItem(state.collection.items, uuid, (item) => {
- Object.assign(item, folder);
- });
- }
},
resetPlaygroundEnvironments: (state: PlaygroundState) => {
const environments = state.pristineEnvironments ? cloneDeep(state.pristineEnvironments) : null;
if (state.hydratedCollection) writeEnvironments(state.hydratedCollection, environments);
- if (state.collection) writeEnvironments(state.collection, environments);
},
setPlaygroundVariable: (
state: PlaygroundState,
- action: PayloadAction<{
- scope: 'environment' | 'collection' | 'folder' | 'request' | '$secrets';
- name: string;
- value: string;
- envName?: string;
- itemUuid?: string;
- }>
+ action: PayloadAction
) => {
const { scope, name, value, envName, itemUuid } = action.payload;
// Secret variables are writable. Their values only ever live on this
@@ -304,7 +291,6 @@ const playgroundSlice = createSlice({
}
};
apply(state.hydratedCollection);
- apply(state.collection);
},
setResponseFormat: (state: PlaygroundState, action: PayloadAction<{
uuid: PlaygroundState['selectedItemId'];
@@ -324,6 +310,13 @@ const playgroundSlice = createSlice({
if (uuid != null)
state.showResponsePreview[uuid] = showResponsePreview;
}
+ },
+ // The playground follows the document's lifecycle; nothing has to tell it.
+ extraReducers: (builder) => {
+ builder
+ .addCase(collectionLoaded, (state, action) => seedWorkingCopy(state, action.payload))
+ .addCase(collectionCleared, clearWorkingCopy)
+ .addCase(collectionFailed, clearWorkingCopy);
}
});
@@ -350,19 +343,21 @@ export const {
} = playgroundSlice.actions;
// Selectors
-export const selectPlaygroundCollection = (state: RootState) => state.playground.collection;
-export const selectHydratedCollection = (state: RootState) => state.playground.hydratedCollection;
-export const selectPlaygroundResponses = (state: RootState) => state.playground.responses;
-export const selectPlaygroundResponse = (state: RootState, uuid: string) => state.playground.responses[uuid];
-export const selectViewMode = (state: RootState) => state.playground.viewMode;
-export const selectSelectedItemId = (state: RootState) => state.playground.selectedItemId;
-export const selectSelectedExampleIndex = (state: RootState) => state.playground.selectedExampleIndex;
-export const selectResponsePaneOrientation = (state: RootState) => state.playground.responsePaneOrientation;
+type WithPlayground = { playground: PlaygroundState };
+export const usePlaygroundSelector: TypedUseSelectorHook = useSelector;
+
+export const selectHydratedCollection = (state: WithPlayground) => state.playground.hydratedCollection;
+export const selectPlaygroundResponses = (state: WithPlayground) => state.playground.responses;
+export const selectPlaygroundResponse = (state: WithPlayground, uuid: string) => state.playground.responses[uuid];
+export const selectViewMode = (state: WithPlayground) => state.playground.viewMode;
+export const selectSelectedItemId = (state: WithPlayground) => state.playground.selectedItemId;
+export const selectSelectedExampleIndex = (state: WithPlayground) => state.playground.selectedExampleIndex;
+export const selectResponsePaneOrientation = (state: WithPlayground) => state.playground.responsePaneOrientation;
export const selectResponseFormat
= (uuid: PlaygroundState['selectedItemId']) =>
- (state: RootState) => uuid ? state.playground.selectedResponseFormat[uuid] : null;
+ (state: WithPlayground) => uuid ? state.playground.selectedResponseFormat[uuid] : null;
export const selectShowResponsePreview
= (uuid: PlaygroundState['selectedItemId']) =>
- (state: RootState) => uuid ? state.playground.showResponsePreview[uuid] : null;
+ (state: WithPlayground) => uuid ? state.playground.showResponsePreview[uuid] : null;
export default playgroundSlice.reducer;
diff --git a/packages/bruno-api-docs/src/store/store.ts b/packages/bruno-api-docs/src/store/store.ts
index 8061c8cc..f3c8c9da 100644
--- a/packages/bruno-api-docs/src/store/store.ts
+++ b/packages/bruno-api-docs/src/store/store.ts
@@ -1,19 +1,21 @@
-import { configureStore } from '@reduxjs/toolkit';
-import appReducer from '@/store/slices/app';
-import docsReducer from '@/store/slices/docs';
+import { configureStore, type ReducersMapObject, type StateFromReducersMapObject } from '@reduxjs/toolkit';
+import collectionReducer from '@/store/slices/collection';
import envReducer, { persistEnv } from '@/store/slices/env';
-import playgroundReducer from '@/store/slices/playground';
import themeReducer, { persistThemeMode } from '@/store/slices/theme';
-export const createOpenCollectionStore = () => {
+// What every surface may read. A surface's own slice is registered by the root
+// that mounts it and is typed by that surface, never here.
+const coreReducers = {
+ collection: collectionReducer,
+ env: envReducer,
+ theme: themeReducer
+};
+
+export type RootState = StateFromReducersMapObject;
+
+export const createOpenCollectionStore = >(surfaces?: S) => {
const store = configureStore({
- reducer: {
- app: appReducer,
- docs: docsReducer,
- env: envReducer,
- playground: playgroundReducer,
- theme: themeReducer
- }
+ reducer: { ...coreReducers, ...surfaces }
});
// Persist theme changes (localStorage + root data-theme) outside the reducer.
@@ -38,4 +40,3 @@ export const createOpenCollectionStore = () => {
export type AppStore = ReturnType;
export type AppDispatch = AppStore['dispatch'];
-export type RootState = ReturnType;
diff --git a/packages/bruno-api-docs/src/runner/utils/faker-functions.ts b/packages/bruno-api-docs/src/utils/faker-functions.ts
similarity index 100%
rename from packages/bruno-api-docs/src/runner/utils/faker-functions.ts
rename to packages/bruno-api-docs/src/utils/faker-functions.ts
diff --git a/packages/bruno-api-docs/src/utils/variableAutocomplete.ts b/packages/bruno-api-docs/src/utils/variableAutocomplete.ts
index 90e6499a..0c27f936 100644
--- a/packages/bruno-api-docs/src/utils/variableAutocomplete.ts
+++ b/packages/bruno-api-docs/src/utils/variableAutocomplete.ts
@@ -1,4 +1,4 @@
-import { mockDataFunctions } from '@/runner/utils/faker-functions';
+import { mockDataFunctions } from '@/utils/faker-functions';
/** The `$`-prefixed mock/dynamic function hints, e.g. `$randomUUID` (Bruno's MOCK_DATA_HINTS). */
const MOCK_HINTS = Object.keys(mockDataFunctions).map((key) => `$${key}`);
diff --git a/packages/bruno-api-docs/src/utils/variableHighlight.ts b/packages/bruno-api-docs/src/utils/variableHighlight.ts
index f137b7da..9fdc3b5d 100644
--- a/packages/bruno-api-docs/src/utils/variableHighlight.ts
+++ b/packages/bruno-api-docs/src/utils/variableHighlight.ts
@@ -1,4 +1,4 @@
-import { mockDataFunctions } from '@/runner/utils/faker-functions';
+import { mockDataFunctions } from '@/utils/faker-functions';
export type VariableTokenClass = 'variable-valid' | 'variable-invalid' | 'variable-prompt';
diff --git a/packages/bruno-api-docs/vite.config.docs.ts b/packages/bruno-api-docs/vite.config.docs.ts
new file mode 100644
index 00000000..008b3fd3
--- /dev/null
+++ b/packages/bruno-api-docs/vite.config.docs.ts
@@ -0,0 +1,55 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+import { resolve } from 'path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = fileURLToPath(new URL('.', import.meta.url));
+
+// One surface per bundle. Same shape as vite.config.standalone.ts, which builds
+// both surfaces together; only the entry differs.
+export default defineConfig({
+ resolve: {
+ alias: {
+ '@slices': resolve(__dirname, 'src/store/slices'),
+ '@': resolve(__dirname, 'src')
+ }
+ },
+ plugins: [react()],
+ define: {
+ 'process.env.NODE_ENV': '"production"'
+ },
+ build: {
+ lib: {
+ entry: resolve(__dirname, 'src/entries/docs.ts'),
+ name: 'OpenCollectionDocs',
+ fileName: (format) => format === 'umd' ? 'docs.js' : 'docs.esm.js',
+ formats: ['umd', 'es']
+ },
+ cssCodeSplit: false,
+ rollupOptions: {
+ output: {
+ inlineDynamicImports: true,
+ manualChunks: undefined,
+ globals: {},
+ exports: 'named',
+ assetFileNames: (assetInfo) => {
+ if (assetInfo.name && assetInfo.name.endsWith('.css')) {
+ return 'docs.css';
+ }
+ return assetInfo.name || 'asset';
+ }
+ }
+ },
+ outDir: 'dist-docs',
+ minify: 'terser',
+ terserOptions: {
+ compress: {
+ drop_console: true,
+ drop_debugger: true
+ }
+ }
+ },
+ css: {
+ postcss: './postcss.config.cjs'
+ }
+});
diff --git a/packages/bruno-api-docs/vite.config.playground.ts b/packages/bruno-api-docs/vite.config.playground.ts
new file mode 100644
index 00000000..73d2a1c7
--- /dev/null
+++ b/packages/bruno-api-docs/vite.config.playground.ts
@@ -0,0 +1,55 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+import { resolve } from 'path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = fileURLToPath(new URL('.', import.meta.url));
+
+// One surface per bundle. Same shape as vite.config.standalone.ts, which builds
+// both surfaces together; only the entry differs.
+export default defineConfig({
+ resolve: {
+ alias: {
+ '@slices': resolve(__dirname, 'src/store/slices'),
+ '@': resolve(__dirname, 'src')
+ }
+ },
+ plugins: [react()],
+ define: {
+ 'process.env.NODE_ENV': '"production"'
+ },
+ build: {
+ lib: {
+ entry: resolve(__dirname, 'src/entries/playground.ts'),
+ name: 'OpenCollectionPlayground',
+ fileName: (format) => format === 'umd' ? 'playground.js' : 'playground.esm.js',
+ formats: ['umd', 'es']
+ },
+ cssCodeSplit: false,
+ rollupOptions: {
+ output: {
+ inlineDynamicImports: true,
+ manualChunks: undefined,
+ globals: {},
+ exports: 'named',
+ assetFileNames: (assetInfo) => {
+ if (assetInfo.name && assetInfo.name.endsWith('.css')) {
+ return 'playground.css';
+ }
+ return assetInfo.name || 'asset';
+ }
+ }
+ },
+ outDir: 'dist-playground',
+ minify: 'terser',
+ terserOptions: {
+ compress: {
+ drop_console: true,
+ drop_debugger: true
+ }
+ }
+ },
+ css: {
+ postcss: './postcss.config.cjs'
+ }
+});